From f242d4a0c7856eb6f141066bfd0861374ed2dffc Mon Sep 17 00:00:00 2001 From: Mathew Goldsborough <1759329+mgoldsborough@users.noreply.github.com> Date: Wed, 8 Apr 2026 14:39:38 -1000 Subject: [PATCH 1/3] TypeScript feature parity: relations, activities, graph traversal, utility tools Bring the TypeScript library to full feature parity with Python: - Add reverse relationship index (relations.ts): load, save, update, remove, rebuild, queryReverse with atomic writes - Add activity tracking (activity.ts): ACTIVITY_ENTITY_DEF, getActivitySchema, bundled activity.schema.json - Add schema functions: hydrateDefaults, validateSchemaChange, buildEntityOutputSchema, buildListOutputSchema - Add entity enhancements: schema hydration on get/list/update, onRelationshipsChanged callbacks on create/update/delete - Add UpjackApp methods: queryByRelationship, getRelated, getComposite, logActivity, getActivities, reloadSchema, prefix resolution - Add MCP server tools: 3 relationship tools per entity, log_activity, get_activities, seed_data, add_field, rebuild_index - Add output schemas to all MCP tools - Add response envelopes ({entities, count}) for list/search tools - Add utility_tools manifest filter support - Add indexDir/indexPath to paths module 272 tests passing (was 233), make check clean. --- lib/typescript/src/activity.ts | 28 + lib/typescript/src/app.ts | 298 ++++++++- lib/typescript/src/entity.ts | 58 +- lib/typescript/src/index.ts | 24 +- lib/typescript/src/paths.ts | 26 + lib/typescript/src/relations.ts | 220 +++++++ lib/typescript/src/schema.ts | 170 ++++++ .../src/schemas/activity.schema.json | 18 + lib/typescript/src/server.ts | 565 ++++++++++++++++-- lib/typescript/tests/activity.test.ts | 237 ++++++++ lib/typescript/tests/e2e.test.ts | 32 +- lib/typescript/tests/entity.test.ts | 161 +++++ lib/typescript/tests/graph.test.ts | 257 ++++++++ lib/typescript/tests/paths.test.ts | 45 +- lib/typescript/tests/relations.test.ts | 239 ++++++++ lib/typescript/tests/schema.test.ts | 243 +++++++- lib/typescript/tests/server.test.ts | 76 +-- 17 files changed, 2589 insertions(+), 108 deletions(-) create mode 100644 lib/typescript/src/activity.ts create mode 100644 lib/typescript/src/relations.ts create mode 100644 lib/typescript/src/schemas/activity.schema.json create mode 100644 lib/typescript/tests/activity.test.ts create mode 100644 lib/typescript/tests/graph.test.ts create mode 100644 lib/typescript/tests/relations.test.ts diff --git a/lib/typescript/src/activity.ts b/lib/typescript/src/activity.ts new file mode 100644 index 0000000..ec31606 --- /dev/null +++ b/lib/typescript/src/activity.ts @@ -0,0 +1,28 @@ +/** + * Activity entity definition and helpers for upjack apps. + * + * Activities are entities that record events against other entities. They + * use the existing CRUD, search, and relationship infrastructure so they + * get indexing, querying, and MCP tools for free. + * + * Opt-in via "activities": true in the manifest's upjack extension. + */ + +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import type { EntityDefinition } from "./entity.js"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const SCHEMA_PATH = join(__dirname, "schemas", "activity.schema.json"); + +export const ACTIVITY_ENTITY_DEF: EntityDefinition = { + name: "activity", + plural: "activities", + prefix: "act", + schema: SCHEMA_PATH, +}; + +export function getActivitySchema(): Record { + return JSON.parse(readFileSync(SCHEMA_PATH, "utf-8")); +} diff --git a/lib/typescript/src/app.ts b/lib/typescript/src/app.ts index 4598c23..39e63d2 100644 --- a/lib/typescript/src/app.ts +++ b/lib/typescript/src/app.ts @@ -1,5 +1,6 @@ import { readFileSync } from "node:fs"; import { dirname, join } from "node:path"; +import { ACTIVITY_ENTITY_DEF, getActivitySchema } from "./activity.js"; import { type EntityDefinition, type EntityRecord, @@ -10,6 +11,7 @@ import { updateEntity, } from "./entity.js"; import { resolveRoot } from "./paths.js"; +import { queryReverse, removeFromIndex, updateIndex } from "./relations.js"; import { loadSchema } from "./schema.js"; import { searchEntities as _searchEntities } from "./search.js"; @@ -22,6 +24,8 @@ export interface UpjackManifestExtension { skills?: Array<{ source: string; path: string; name?: string; version?: string }>; context?: string; seed?: { data?: string; run_on_install?: boolean }; + activities?: boolean; + utility_tools?: string[]; [key: string]: unknown; } @@ -31,17 +35,22 @@ export class UpjackApp { /** @internal */ readonly _schemas: Record>; private readonly _entities: Record; + private readonly _prefixMap: Record; + private readonly _manifestDir?: string; constructor( namespace: string, entities: EntityDefinition[], root?: string, schemas?: Record>, + manifestDir?: string, ) { this.namespace = namespace; this.root = resolveRoot(root); this._entities = Object.fromEntries(entities.map((e) => [e.name, e])); this._schemas = schemas ?? {}; + this._prefixMap = Object.fromEntries(entities.map((e) => [e.prefix, e.name])); + this._manifestDir = manifestDir; } /** @@ -71,9 +80,24 @@ export class UpjackApp { ); } + let entities = upjack.entities; const manifestDir = dirname(manifestPath); + + // Opt-in activity tracking + const activitiesEnabled = Boolean(upjack.activities); + if (activitiesEnabled) { + const userNames = new Set(entities.map((e) => e.name)); + if (userNames.has("activity")) { + throw new Error( + "Cannot enable built-in activities: an entity named 'activity' " + + "is already defined in the manifest", + ); + } + entities = [...entities, ACTIVITY_ENTITY_DEF]; + } + const schemas: Record> = {}; - for (const entityDef of upjack.entities) { + for (const entityDef of entities) { const schemaPath = join(manifestDir, entityDef.schema); try { schemas[entityDef.name] = loadSchema(schemaPath); @@ -82,7 +106,12 @@ export class UpjackApp { } } - return new UpjackApp(upjack.namespace, upjack.entities, root, schemas); + // Load the built-in activity schema from the package + if (activitiesEnabled) { + schemas.activity = getActivitySchema(); + } + + return new UpjackApp(upjack.namespace, entities, root, schemas, manifestDir); } private _getEntityDef(entityType: string): EntityDefinition { @@ -99,6 +128,65 @@ export class UpjackApp { return entityDef.plural ?? `${entityDef.name}s`; } + // ------------------------------------------------------------------ + // Relationship index callbacks + // ------------------------------------------------------------------ + + /** @internal */ + _onRelationshipsChanged( + entityId: string, + oldRels: Array>, + newRels: Array>, + ): void { + updateIndex(this.root, this.namespace, entityId, oldRels, newRels); + } + + /** @internal */ + _onRelationshipsRemoved( + entityId: string, + oldRels: Array>, + _newRels: Array>, + ): void { + removeFromIndex(this.root, this.namespace, entityId, oldRels); + } + + // ------------------------------------------------------------------ + // Prefix resolution + // ------------------------------------------------------------------ + + /** @internal */ + _resolveType(entityId: string): string { + const prefix = entityId.split("_", 1)[0]; + if (!(prefix in this._prefixMap)) { + throw new Error( + `Unknown prefix '${prefix}' in entity ID '${entityId}'. Known: ${Object.keys(this._prefixMap).join(", ")}`, + ); + } + return this._prefixMap[prefix]; + } + + /** @internal */ + _entityDefsList(): EntityDefinition[] { + return Object.values(this._entities); + } + + // ------------------------------------------------------------------ + // Schema reloading + // ------------------------------------------------------------------ + + reloadSchema(entityType: string): void { + if (!this._manifestDir) { + throw new Error("Cannot reload schema: manifestDir is not set"); + } + const entityDef = this._getEntityDef(entityType); + const schemaPath = join(this._manifestDir, entityDef.schema); + this._schemas[entityType] = loadSchema(schemaPath); + } + + // ------------------------------------------------------------------ + // CRUD operations + // ------------------------------------------------------------------ + createEntity( entityType: string, data: Record, @@ -115,6 +203,7 @@ export class UpjackApp { this._schemas[entityType], 1, createdBy, + this._onRelationshipsChanged.bind(this), ); } @@ -133,22 +222,43 @@ export class UpjackApp { data, this._schemas[entityType], merge, + this._onRelationshipsChanged.bind(this), ); } getEntity(entityType: string, entityId: string): EntityRecord { const entityDef = this._getEntityDef(entityType); - return getEntity(this.root, this.namespace, this._getPlural(entityDef), entityId); + return getEntity( + this.root, + this.namespace, + this._getPlural(entityDef), + entityId, + this._schemas[entityType], + ); } listEntities(entityType: string, status = "active", limit = 50): EntityRecord[] { const entityDef = this._getEntityDef(entityType); - return listEntities(this.root, this.namespace, this._getPlural(entityDef), status, limit); + return listEntities( + this.root, + this.namespace, + this._getPlural(entityDef), + status, + limit, + this._schemas[entityType], + ); } deleteEntity(entityType: string, entityId: string, hard = false): EntityRecord { const entityDef = this._getEntityDef(entityType); - return deleteEntity(this.root, this.namespace, this._getPlural(entityDef), entityId, hard); + return deleteEntity( + this.root, + this.namespace, + this._getPlural(entityDef), + entityId, + hard, + this._onRelationshipsRemoved.bind(this), + ); } searchEntities( @@ -171,4 +281,182 @@ export class UpjackApp { options.limit ?? 20, ); } + + // ------------------------------------------------------------------ + // Graph traversal + // ------------------------------------------------------------------ + + queryByRelationship( + entityType: string, + rel: string, + targetId: string, + filter?: Record, + limit = 50, + ): EntityRecord[] { + const entityDef = this._getEntityDef(entityType); + const prefix = entityDef.prefix; + + const entries = queryReverse(this.root, this.namespace, targetId, rel, this._entityDefsList()); + + const matchingIds = entries + .filter((e) => e.source.startsWith(`${prefix}_`)) + .map((e) => e.source); + + const results: EntityRecord[] = []; + for (const eid of matchingIds) { + let entity: EntityRecord; + try { + entity = this.getEntity(entityType, eid); + } catch { + continue; + } + if ((entity.status ?? "active") !== "active") continue; + if (filter && !UpjackApp._matchesFilter(entity, filter)) continue; + results.push(entity); + if (results.length >= limit) break; + } + + return results; + } + + getRelated( + entityId: string, + rel?: string, + direction: "forward" | "reverse" = "forward", + ): EntityRecord[] { + if (direction === "forward") { + return this._getRelatedForward(entityId, rel); + } + if (direction === "reverse") { + return this._getRelatedReverse(entityId, rel); + } + throw new Error(`direction must be 'forward' or 'reverse', got '${direction}'`); + } + + private _getRelatedForward(entityId: string, rel?: string): EntityRecord[] { + const sourceType = this._resolveType(entityId); + const entity = this.getEntity(sourceType, entityId); + let relationships = entity.relationships ?? []; + if (rel !== undefined) { + relationships = relationships.filter((r) => r.rel === rel); + } + + const results: EntityRecord[] = []; + for (const r of relationships) { + if (!r.target) continue; + try { + const targetType = this._resolveType(r.target); + results.push(this.getEntity(targetType, r.target)); + } catch {} + } + return results; + } + + private _getRelatedReverse(entityId: string, rel?: string): EntityRecord[] { + const entries = queryReverse(this.root, this.namespace, entityId, rel, this._entityDefsList()); + + const results: EntityRecord[] = []; + for (const entry of entries) { + try { + const sourceType = this._resolveType(entry.source); + results.push(this.getEntity(sourceType, entry.source)); + } catch {} + } + return results; + } + + getComposite(entityType: string, entityId: string, depth = 1): Record { + const entity = this.getEntity(entityType, entityId); + const related: Record = {}; + + if (depth >= 1) { + // Forward relationships + for (const r of entity.relationships ?? []) { + if (!r.target || !r.rel) continue; + try { + const targetType = this._resolveType(r.target); + const target = this.getEntity(targetType, r.target); + if (!related[r.rel]) related[r.rel] = []; + related[r.rel].push(target); + } catch {} + } + + // Reverse relationships + const entries = queryReverse( + this.root, + this.namespace, + entityId, + undefined, + this._entityDefsList(), + ); + for (const entry of entries) { + const relName = `~${entry.rel}`; + try { + const sourceType = this._resolveType(entry.source); + const source = this.getEntity(sourceType, entry.source); + if (!related[relName]) related[relName] = []; + related[relName].push(source); + } catch {} + } + } + + return { ...entity, _related: related }; + } + + private static _matchesFilter(entity: EntityRecord, filter: Record): boolean { + for (const [key, value] of Object.entries(filter)) { + if (typeof value === "object" && value !== null && !Array.isArray(value)) { + throw new Error( + "Operator filters are not supported in queryByRelationship. Use simple equality filters.", + ); + } + if (entity[key] !== value) return false; + } + return true; + } + + // ------------------------------------------------------------------ + // Activity tracking + // ------------------------------------------------------------------ + + logActivity(subjectId: string, action: string, detail?: Record): EntityRecord { + this._getEntityDef("activity"); + const data: Record = { + action, + detail: detail ?? {}, + relationships: [{ rel: "subject", target: subjectId }], + }; + return this.createEntity("activity", data, "system"); + } + + getActivities(subjectId: string, action?: string, limit = 50): EntityRecord[] { + this._getEntityDef("activity"); + const entityDef = this._entities.activity; + const prefix = entityDef.prefix; + + const entries = queryReverse( + this.root, + this.namespace, + subjectId, + "subject", + this._entityDefsList(), + ); + + const activityIds = entries + .filter((e) => e.source.startsWith(`${prefix}_`)) + .map((e) => e.source); + + const results: EntityRecord[] = []; + for (const eid of activityIds) { + try { + const entity = this.getEntity("activity", eid); + if ((entity.status ?? "active") !== "active") continue; + if (action !== undefined && entity.action !== action) continue; + results.push(entity); + } catch {} + } + + results.sort((a, b) => (b.updated_at ?? "").localeCompare(a.updated_at ?? "")); + return results.slice(0, limit); + } } diff --git a/lib/typescript/src/entity.ts b/lib/typescript/src/entity.ts index 4dfb3ef..3ef5b12 100644 --- a/lib/typescript/src/entity.ts +++ b/lib/typescript/src/entity.ts @@ -9,7 +9,13 @@ import { import { dirname, join } from "node:path"; import { generateId } from "./ids.js"; import { entityDir, entityPath } from "./paths.js"; -import { validateEntity } from "./schema.js"; +import { hydrateDefaults, validateEntity } from "./schema.js"; + +export type RelationshipsChangedCallback = ( + entityId: string, + oldRels: Array>, + newRels: Array>, +) => void; /** Base fields present on every entity record. */ export interface EntityRecord { @@ -52,6 +58,7 @@ export function createEntity( schema?: Record, schemaVersion = 1, createdBy = "agent", + onRelationshipsChanged?: RelationshipsChangedCallback, ): EntityRecord { const now = nowIso(); const entityId = generateId(prefix); @@ -85,6 +92,14 @@ export function createEntity( mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, `${JSON.stringify(record, null, 2)}\n`); + if (onRelationshipsChanged && record.relationships.length > 0) { + onRelationshipsChanged( + entityId, + [], + record.relationships as unknown as Array>, + ); + } + return record; } @@ -99,6 +114,7 @@ export function updateEntity( data: Record, schema?: Record, merge = true, + onRelationshipsChanged?: RelationshipsChangedCallback, ): EntityRecord { const path = entityPath(root, namespace, plural, entityId); if (!existsSync(path)) { @@ -106,6 +122,13 @@ export function updateEntity( } let existing = JSON.parse(readFileSync(path, "utf-8")) as EntityRecord; + const oldRelationships = JSON.stringify(existing.relationships ?? []); + + // Hydrate defaults before merge so old entities missing new fields + // get filled in — prevents validation failures on schema evolution. + if (schema) { + existing = hydrateDefaults(existing, schema) as EntityRecord; + } // Strip immutable fields const immutable = new Set(["id", "type", "version", "created_at", "created_by"]); @@ -135,6 +158,18 @@ export function updateEntity( } writeFileSync(path, `${JSON.stringify(existing, null, 2)}\n`); + + if (onRelationshipsChanged) { + const newRelationships = JSON.stringify(existing.relationships ?? []); + if (oldRelationships !== newRelationships) { + onRelationshipsChanged( + entityId, + JSON.parse(oldRelationships), + (existing.relationships ?? []) as unknown as Array>, + ); + } + } + return existing; } @@ -146,12 +181,17 @@ export function getEntity( namespace: string, plural: string, entityId: string, + schema?: Record, ): EntityRecord { const path = entityPath(root, namespace, plural, entityId); if (!existsSync(path)) { throw new Error(`Entity not found: ${entityId}`); } - return JSON.parse(readFileSync(path, "utf-8")); + const entity = JSON.parse(readFileSync(path, "utf-8")) as EntityRecord; + if (schema) { + return hydrateDefaults(entity, schema) as EntityRecord; + } + return entity; } /** @@ -163,6 +203,7 @@ export function listEntities( plural: string, status = "active", limit = 50, + schema?: Record, ): EntityRecord[] { const directory = entityDir(root, namespace, plural); if (!existsSync(directory)) { @@ -173,8 +214,11 @@ export function listEntities( for (const file of readdirSync(directory)) { if (!file.endsWith(".json")) continue; try { - const entity = JSON.parse(readFileSync(join(directory, file), "utf-8")) as EntityRecord; + let entity = JSON.parse(readFileSync(join(directory, file), "utf-8")) as EntityRecord; if ((entity.status ?? "active") === status) { + if (schema) { + entity = hydrateDefaults(entity, schema) as EntityRecord; + } results.push(entity); } } catch { @@ -195,6 +239,7 @@ export function deleteEntity( plural: string, entityId: string, hard = false, + onRelationshipsChanged?: RelationshipsChangedCallback, ): EntityRecord { const path = entityPath(root, namespace, plural, entityId); if (!existsSync(path)) { @@ -205,6 +250,13 @@ export function deleteEntity( if (hard) { unlinkSync(path); + if (onRelationshipsChanged && entity.relationships?.length > 0) { + onRelationshipsChanged( + entityId, + entity.relationships as unknown as Array>, + [], + ); + } } else { entity.status = "deleted"; entity.updated_at = nowIso(); diff --git a/lib/typescript/src/index.ts b/lib/typescript/src/index.ts index 9e6cefd..6bad5c2 100644 --- a/lib/typescript/src/index.ts +++ b/lib/typescript/src/index.ts @@ -1,5 +1,6 @@ export { UpjackApp } from "./app.js"; export type { UpjackManifestExtension } from "./app.js"; +export { ACTIVITY_ENTITY_DEF, getActivitySchema } from "./activity.js"; export { createEntity, updateEntity, @@ -7,8 +8,25 @@ export { listEntities, deleteEntity, } from "./entity.js"; -export type { EntityRecord, EntityDefinition } from "./entity.js"; +export type { EntityRecord, EntityDefinition, RelationshipsChangedCallback } from "./entity.js"; export { generateId, parseId, validateId } from "./ids.js"; -export { entityDir, entityPath, resolveRoot, schemaDir } from "./paths.js"; -export { loadSchema, validateEntity, resolveEntitySchema } from "./schema.js"; +export { entityDir, entityPath, indexDir, indexPath, resolveRoot, schemaDir } from "./paths.js"; +export { + loadSchema, + validateEntity, + resolveEntitySchema, + hydrateDefaults, + validateSchemaChange, + buildEntityOutputSchema, + buildListOutputSchema, +} from "./schema.js"; +export { + loadIndex, + saveIndex, + updateIndex, + removeFromIndex, + rebuildIndex, + queryReverse, +} from "./relations.js"; +export type { RelationEntry, RelationIndex } from "./relations.js"; export { searchEntities } from "./search.js"; diff --git a/lib/typescript/src/paths.ts b/lib/typescript/src/paths.ts index c2b6083..2733106 100644 --- a/lib/typescript/src/paths.ts +++ b/lib/typescript/src/paths.ts @@ -63,3 +63,29 @@ export function schemaDir(root: string, namespace: string): string { const target = `${root}/${namespace}/schemas`; return checkWithinRoot(root, target); } + +/** + * Get the directory path for the relationship index. + * + * @param root - Workspace root directory. + * @param namespace - App namespace (e.g., 'apps/crm'). + * @returns Path to the index directory. + * @throws Error if the resolved path escapes the workspace root. + */ +export function indexDir(root: string, namespace: string): string { + const target = `${root}/${namespace}/data/_index`; + return checkWithinRoot(root, target); +} + +/** + * Get the file path for the relationship index. + * + * @param root - Workspace root directory. + * @param namespace - App namespace (e.g., 'apps/crm'). + * @returns Path to the relations.json index file. + * @throws Error if the resolved path escapes the workspace root. + */ +export function indexPath(root: string, namespace: string): string { + const target = `${root}/${namespace}/data/_index/relations.json`; + return checkWithinRoot(root, target); +} diff --git a/lib/typescript/src/relations.ts b/lib/typescript/src/relations.ts new file mode 100644 index 0000000..65a1169 --- /dev/null +++ b/lib/typescript/src/relations.ts @@ -0,0 +1,220 @@ +/** + * Reverse relationship index for upjack entities. + * + * Maintains a write-time index that maps target entity IDs to the source + * entities that reference them. The index file lives at + * {root}/{namespace}/data/_index/relations.json and is updated + * atomically (temp file + rename) on every CRUD operation that + * touches relationships. + */ + +import { + existsSync, + mkdirSync, + readFileSync, + readdirSync, + renameSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import { join } from "node:path"; +import { indexDir, indexPath } from "./paths.js"; + +export interface RelationEntry { + source: string; + rel: string; +} + +export interface RelationIndex { + reverse: Record; +} + +export function loadIndex(root: string, namespace: string): RelationIndex { + const path = indexPath(root, namespace); + if (!existsSync(path)) { + return { reverse: {} }; + } + try { + return JSON.parse(readFileSync(path, "utf-8")); + } catch { + return { reverse: {} }; + } +} + +export function saveIndex(root: string, namespace: string, index: RelationIndex): void { + const path = indexPath(root, namespace); + const dir = indexDir(root, namespace); + mkdirSync(dir, { recursive: true }); + + const tmpPath = `${path}.tmp`; + try { + writeFileSync(tmpPath, `${JSON.stringify(index, null, 2)}\n`); + renameSync(tmpPath, path); + } catch (err) { + try { + if (existsSync(tmpPath)) unlinkSync(tmpPath); + } catch { + // ignore cleanup errors + } + throw err; + } +} + +export function updateIndex( + root: string, + namespace: string, + entityId: string, + oldRels: Array<{ rel?: string; target?: string }>, + newRels: Array<{ rel?: string; target?: string }>, +): void { + const index = loadIndex(root, namespace); + const reverse = index.reverse; + + // Build sets of "target|rel" strings for diffing + const toKey = (r: { target?: string; rel?: string }) => + r.target && r.rel ? `${r.target}|${r.rel}` : null; + + const oldSet = new Set(); + const oldMap = new Map(); + for (const r of oldRels) { + const key = toKey(r); + if (key && r.target && r.rel) { + oldSet.add(key); + oldMap.set(key, { target: r.target, rel: r.rel }); + } + } + + const newSet = new Set(); + const newMap = new Map(); + for (const r of newRels) { + const key = toKey(r); + if (key && r.target && r.rel) { + newSet.add(key); + newMap.set(key, { target: r.target, rel: r.rel }); + } + } + + // Remove stale entries + for (const key of oldSet) { + if (newSet.has(key)) continue; + const pair = oldMap.get(key); + if (!pair) continue; + const { target, rel } = pair; + const entries = reverse[target]; + if (entries) { + reverse[target] = entries.filter((e) => !(e.source === entityId && e.rel === rel)); + if (reverse[target].length === 0) { + delete reverse[target]; + } + } + } + + // Add new entries + for (const key of newSet) { + if (oldSet.has(key)) continue; + const pair = newMap.get(key); + if (!pair) continue; + const { target, rel } = pair; + if (!reverse[target]) { + reverse[target] = []; + } + const entry: RelationEntry = { source: entityId, rel }; + const exists = reverse[target].some((e) => e.source === entry.source && e.rel === entry.rel); + if (!exists) { + reverse[target].push(entry); + } + } + + saveIndex(root, namespace, index); +} + +export function removeFromIndex( + root: string, + namespace: string, + entityId: string, + rels: Array<{ rel?: string; target?: string }>, +): void { + if (!rels || rels.length === 0) return; + + const index = loadIndex(root, namespace); + const reverse = index.reverse; + + for (const r of rels) { + const target = r.target; + if (!target) continue; + const entries = reverse[target]; + if (entries) { + reverse[target] = entries.filter((e) => e.source !== entityId); + if (reverse[target].length === 0) { + delete reverse[target]; + } + } + } + + saveIndex(root, namespace, index); +} + +export function rebuildIndex( + root: string, + namespace: string, + entityDefs: Array<{ plural?: string; name?: string }>, +): RelationIndex { + const reverse: Record = {}; + + for (const edef of entityDefs) { + const plural = edef.plural ?? `${edef.name ?? ""}s`; + const edir = join(root, namespace, "data", plural); + if (!existsSync(edir)) continue; + + for (const file of readdirSync(edir)) { + if (!file.endsWith(".json")) continue; + try { + const entity = JSON.parse(readFileSync(join(edir, file), "utf-8")); + const entityId = entity.id ?? ""; + for (const rel of entity.relationships ?? []) { + const target = rel.target; + const relName = rel.rel; + if (!target || !relName) continue; + if (!reverse[target]) { + reverse[target] = []; + } + const entry: RelationEntry = { source: entityId, rel: relName }; + const exists = reverse[target].some( + (e) => e.source === entry.source && e.rel === entry.rel, + ); + if (!exists) { + reverse[target].push(entry); + } + } + } catch { + // Skip corrupt files + } + } + } + + const index: RelationIndex = { reverse }; + saveIndex(root, namespace, index); + return index; +} + +export function queryReverse( + root: string, + namespace: string, + targetId: string, + rel?: string, + entityDefs?: Array<{ plural?: string; name?: string }>, +): RelationEntry[] { + const path = indexPath(root, namespace); + if (!existsSync(path) && entityDefs) { + rebuildIndex(root, namespace, entityDefs); + } + + const index = loadIndex(root, namespace); + let entries = index.reverse[targetId] ?? []; + + if (rel !== undefined) { + entries = entries.filter((e) => e.rel === rel); + } + + return entries; +} diff --git a/lib/typescript/src/schema.ts b/lib/typescript/src/schema.ts index a336730..a228dda 100644 --- a/lib/typescript/src/schema.ts +++ b/lib/typescript/src/schema.ts @@ -25,6 +25,11 @@ addFormats(ajv); // Register the base schema under its remote URI so $ref resolution works offline ajv.addSchema(BASE_SCHEMA, "https://upjack.dev/schemas/v1/upjack-entity.schema.json"); +// Map $ref URIs to resolved schemas for hydration +const REF_MAP: Record> = { + "https://upjack.dev/schemas/v1/upjack-entity.schema.json": BASE_SCHEMA as Record, +}; + /** * Load a JSON Schema from a file path. * @@ -77,3 +82,168 @@ export function resolveEntitySchema( allOf: [baseSchema, appSchema], }; } + +/** + * Fill missing fields from schema defaults. + * + * Walks `properties` and `allOf` sub-schemas (resolving `$ref` to the + * bundled base entity schema). Does NOT mutate the input data. + */ +export function hydrateDefaults( + data: Record, + schema: Record, +): Record { + const result = { ...data }; + applyPropertyDefaults(result, schema); + return result; +} + +function applyPropertyDefaults( + data: Record, + schema: Record, +): void { + // Handle allOf — walk each sub-schema + const allOf = schema.allOf as Array> | undefined; + if (allOf) { + for (const sub of allOf) { + const ref = sub.$ref as string | undefined; + if (ref && ref in REF_MAP) { + applyPropertyDefaults(data, REF_MAP[ref]); + } else { + applyPropertyDefaults(data, sub); + } + } + } + + const props = schema.properties as Record> | undefined; + if (!props) return; + + for (const [fieldName, fieldSchema] of Object.entries(props)) { + if (!(fieldName in data) && "default" in fieldSchema) { + data[fieldName] = structuredClone(fieldSchema.default); + } + } +} + +/** + * Compare two app-level schema dicts and return diagnostics. + * + * Compares top-level `properties` and `required` only. + */ +export function validateSchemaChange( + oldSchema: Record, + newSchema: Record, +): Array<{ severity: string; field: string; message: string }> { + const diagnostics: Array<{ severity: string; field: string; message: string }> = []; + + const oldProps = (oldSchema.properties ?? {}) as Record>; + const newProps = (newSchema.properties ?? {}) as Record>; + const oldRequired = new Set((oldSchema.required ?? []) as string[]); + const newRequired = new Set((newSchema.required ?? []) as string[]); + + // Newly required without default + for (const field of [...newRequired].sort()) { + if (oldRequired.has(field)) continue; + const prop = newProps[field]; + if (prop && !("default" in prop)) { + diagnostics.push({ + severity: "error", + field, + message: `Field '${field}' is newly required but has no default`, + }); + } + } + + // Type change and enum narrowing on shared fields + const sharedFields = Object.keys(oldProps) + .filter((k) => k in newProps) + .sort(); + for (const field of sharedFields) { + const oldType = oldProps[field].type as string | undefined; + const newType = newProps[field].type as string | undefined; + if (oldType && newType && oldType !== newType) { + diagnostics.push({ + severity: "error", + field, + message: `Type changed from '${oldType}' to '${newType}'`, + }); + } + + const oldEnum = oldProps[field].enum as unknown[] | undefined; + const newEnum = newProps[field].enum as unknown[] | undefined; + if (oldEnum !== undefined && newEnum !== undefined) { + const oldSet = new Set(oldEnum.map(String)); + const newSet = new Set(newEnum.map(String)); + const isSubset = [...newSet].every((v) => oldSet.has(v)); + if (isSubset && newSet.size < oldSet.size) { + diagnostics.push({ + severity: "error", + field, + message: `Enum narrowed from ${JSON.stringify(oldEnum)} to ${JSON.stringify(newEnum)}`, + }); + } + } + } + + // Field removed + for (const field of Object.keys(oldProps).sort()) { + if (!(field in newProps)) { + diagnostics.push({ + severity: "warning", + field, + message: `Field '${field}' was removed`, + }); + } + } + + return diagnostics; +} + +/** + * Build an output schema for a single-entity tool response. + * + * Strips JSON Schema meta keywords and ensures `type: "object"`. + */ +export function buildEntityOutputSchema(schema: Record): Record { + const result = structuredClone(schema); + result.$schema = undefined; + result.$id = undefined; + if (!("type" in result)) { + result.type = "object"; + } + // Resolve $ref in allOf to prevent downstream AJV resolution failures + if (Array.isArray(result.allOf)) { + result.allOf = (result.allOf as Array>).map((sub) => { + const ref = sub.$ref as string | undefined; + if (ref && ref in REF_MAP) { + return structuredClone(REF_MAP[ref]); + } + return sub; + }); + } + return result; +} + +/** + * Build an output schema for a list/search tool response. + * + * Returns an envelope schema with `entities` array and `count`. + */ +export function buildListOutputSchema( + entitySchema: Record, +): Record { + return { + type: "object", + properties: { + entities: { + type: "array", + items: buildEntityOutputSchema(entitySchema), + }, + count: { + type: "integer", + description: "Number of entities returned", + }, + }, + required: ["entities", "count"], + }; +} diff --git a/lib/typescript/src/schemas/activity.schema.json b/lib/typescript/src/schemas/activity.schema.json new file mode 100644 index 0000000..d3ff5d9 --- /dev/null +++ b/lib/typescript/src/schemas/activity.schema.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Upjack Activity", + "description": "A logged event or action recorded against another entity. Built-in activity type for Upjack apps with activities enabled.", + "allOf": [{ "$ref": "https://upjack.dev/schemas/v1/upjack-entity.schema.json" }], + "properties": { + "action": { + "type": "string", + "description": "The action that occurred (e.g., 'email_sent', 'status_changed', 'note_added')" + }, + "detail": { + "type": "object", + "description": "Additional structured data about the activity", + "default": {} + } + }, + "required": ["action"] +} diff --git a/lib/typescript/src/server.ts b/lib/typescript/src/server.ts index 260592b..8fef92c 100644 --- a/lib/typescript/src/server.ts +++ b/lib/typescript/src/server.ts @@ -1,5 +1,5 @@ -import { readFileSync } from "node:fs"; -import { dirname, join } from "node:path"; +import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { @@ -10,6 +10,13 @@ import { } from "@modelcontextprotocol/sdk/types.js"; import { UpjackApp } from "./app.js"; import type { UpjackManifestExtension } from "./app.js"; +import { rebuildIndex } from "./relations.js"; +import { + buildEntityOutputSchema, + buildListOutputSchema, + loadSchema, + validateSchemaChange, +} from "./schema.js"; // Base entity fields auto-managed by the framework — stripped from tool input schemas const BASE_ENTITY_KEYS = new Set([ @@ -39,7 +46,6 @@ interface JsonSchema { } function prepareEntitySchema(schema: JsonSchema, opts?: { forUpdate?: boolean }): JsonSchema { - // Strip JSON Schema meta keywords not applicable inside tool input const { $schema: _, $id: __, ...result } = structuredClone(schema); if (result.properties) { @@ -49,7 +55,6 @@ function prepareEntitySchema(schema: JsonSchema, opts?: { forUpdate?: boolean }) } if (opts?.forUpdate) { - // Updates are partial merges — all fields optional const { required: _req, ...rest } = result; return rest; } @@ -77,8 +82,13 @@ const CATEGORY_TO_TOOL: Record = { list: "list_{plural}", search: "search_{plural}", delete: "delete_{name}", + query_by_relationship: "query_{plural}_by_relationship", + get_related: "get_related_{name}", + get_composite: "get_{name}_composite", }; +const ALL_UTILITY_TOOLS = new Set(["seed_data", "add_field", "rebuild_index"]); + function resolveListedTools( name: string, plural: string | undefined, @@ -95,6 +105,11 @@ function resolveListedTools( return result; } +function resolveUtilityTools(utilityTools: string[] | undefined): Set { + if (utilityTools === undefined) return new Set(ALL_UTILITY_TOOLS); + return new Set([...utilityTools].filter((t) => ALL_UTILITY_TOOLS.has(t))); +} + // --------------------------------------------------------------------------- // Tool definition builders // --------------------------------------------------------------------------- @@ -103,10 +118,15 @@ interface ToolDefinition { name: string; description: string; inputSchema: Record; + outputSchema?: Record; } type ToolHandler = (args: Record) => unknown; +function wrapList(entities: unknown[]): { entities: unknown[]; count: number } { + return { entities, count: entities.length }; +} + function buildEntityTools( app: UpjackApp, entityDef: { name: string; plural?: string; prefix: string; schema: string }, @@ -122,6 +142,9 @@ function buildEntityTools( ? prepareEntitySchema(schema as JsonSchema, { forUpdate: true }) : { type: "object" }; + const entityOut = schema ? buildEntityOutputSchema(schema) : undefined; + const listOut = schema ? buildListOutputSchema(schema) : undefined; + const definitions: ToolDefinition[] = [ { name: `create_${name}`, @@ -131,6 +154,7 @@ function buildEntityTools( properties: { data: dataSchema }, required: ["data"], }, + ...(entityOut ? { outputSchema: entityOut } : {}), }, { name: `get_${name}`, @@ -142,6 +166,7 @@ function buildEntityTools( }, required: ["entity_id"], }, + ...(entityOut ? { outputSchema: entityOut } : {}), }, { name: `update_${name}`, @@ -154,6 +179,7 @@ function buildEntityTools( }, required: ["entity_id", "data"], }, + ...(entityOut ? { outputSchema: entityOut } : {}), }, { name: `list_${plural}`, @@ -165,6 +191,7 @@ function buildEntityTools( limit: { type: "number", default: 50, description: "Max results" }, }, }, + ...(listOut ? { outputSchema: listOut } : {}), }, { name: `search_${plural}`, @@ -178,6 +205,7 @@ function buildEntityTools( limit: { type: "number", default: 20, description: "Max results" }, }, }, + ...(listOut ? { outputSchema: listOut } : {}), }, { name: `delete_${name}`, @@ -192,6 +220,7 @@ function buildEntityTools( }, required: ["entity_id"], }, + ...(entityOut ? { outputSchema: entityOut } : {}), }, ]; @@ -206,14 +235,18 @@ function buildEntityTools( (args.data ?? {}) as Record, ), [`list_${plural}`]: (args) => - app.listEntities(name, (args.status as string) ?? "active", (args.limit as number) ?? 50), + wrapList( + app.listEntities(name, (args.status as string) ?? "active", (args.limit as number) ?? 50), + ), [`search_${plural}`]: (args) => - app.searchEntities(name, { - query: args.query as string | undefined, - filter: args.filter as Record | undefined, - sort: (args.sort as string) ?? "-updated_at", - limit: (args.limit as number) ?? 20, - }), + wrapList( + app.searchEntities(name, { + query: args.query as string | undefined, + filter: args.filter as Record | undefined, + sort: (args.sort as string) ?? "-updated_at", + limit: (args.limit as number) ?? 20, + }), + ), [`delete_${name}`]: (args) => app.deleteEntity(name, args.entity_id as string, (args.hard as boolean) ?? false), }; @@ -221,6 +254,375 @@ function buildEntityTools( return { definitions, handlers }; } +// --------------------------------------------------------------------------- +// Relationship tools +// --------------------------------------------------------------------------- + +function buildRelationshipTools( + app: UpjackApp, + entityDef: { name: string; plural?: string; prefix: string; schema: string }, + schema: Record | undefined, +): { definitions: ToolDefinition[]; handlers: Record } { + const name = entityDef.name; + const plural = entityDef.plural ?? `${name}s`; + const idParam = `${name}_id`; + + const listOut = schema ? buildListOutputSchema(schema) : undefined; + const entityOut = schema ? buildEntityOutputSchema(schema) : undefined; + + const definitions: ToolDefinition[] = [ + { + name: `query_${plural}_by_relationship`, + description: + `Find ${plural} that have a specific relationship pointing to a target entity. ` + + `For example, find all ${plural} that 'belongs_to' a given entity.`, + inputSchema: { + type: "object", + properties: { + rel: { type: "string", description: "Relationship type to query" }, + target_id: { type: "string", description: "Target entity ID" }, + filter: { type: "object", description: "Optional equality filters" }, + limit: { type: "number", default: 50, description: "Max results" }, + }, + required: ["rel", "target_id"], + }, + ...(listOut ? { outputSchema: listOut } : {}), + }, + { + name: `get_related_${name}`, + description: + `Follow relationship edges from a ${name}. ` + + `'forward' returns entities this ${name} points to. ` + + `'reverse' returns entities that point to this ${name}.`, + inputSchema: { + type: "object", + properties: { + [idParam]: { + type: "string", + description: `${name} ID (${entityDef.prefix}_...)`, + }, + rel: { type: "string", description: "Relationship type to follow. Omit to follow all." }, + direction: { + type: "string", + description: "'forward' or 'reverse'.", + default: "forward", + }, + }, + required: [idParam], + }, + ...(listOut ? { outputSchema: listOut } : {}), + }, + { + name: `get_${name}_composite`, + description: `Load a ${name} with all related entities in one call. Returns the entity with a '_related' key containing forward relationships (keyed by rel name) and reverse relationships (keyed by ~rel name).`, + inputSchema: { + type: "object", + properties: { + [idParam]: { + type: "string", + description: `${name} ID (${entityDef.prefix}_...)`, + }, + depth: { type: "integer", description: "Traversal depth (default 1).", default: 1 }, + }, + required: [idParam], + }, + ...(entityOut ? { outputSchema: entityOut } : {}), + }, + ]; + + const handlers: Record = { + [`query_${plural}_by_relationship`]: (args) => + wrapList( + app.queryByRelationship( + name, + args.rel as string, + args.target_id as string, + args.filter as Record | undefined, + (args.limit as number) ?? 50, + ), + ), + [`get_related_${name}`]: (args) => + wrapList( + app.getRelated( + args[idParam] as string, + args.rel as string | undefined, + (args.direction as "forward" | "reverse") ?? "forward", + ), + ), + [`get_${name}_composite`]: (args) => + app.getComposite(name, args[idParam] as string, (args.depth as number) ?? 1), + }; + + return { definitions, handlers }; +} + +// --------------------------------------------------------------------------- +// Activity tools +// --------------------------------------------------------------------------- + +function buildActivityTools(app: UpjackApp): { + definitions: ToolDefinition[]; + handlers: Record; +} { + const definitions: ToolDefinition[] = [ + { + name: "log_activity", + description: + "Log an activity against an entity. Auto-wires a 'subject' relationship " + + "to the given entity. Use this instead of create_activity when you want " + + "the relationship set up automatically.", + inputSchema: { + type: "object", + properties: { + subject_id: { type: "string", description: "The entity ID this activity is about." }, + action: { + type: "string", + description: "What happened (e.g., 'email_sent', 'meeting_held').", + }, + detail: { type: "object", description: "Optional structured data about the activity." }, + }, + required: ["subject_id", "action"], + }, + }, + { + name: "get_activities", + description: + "Get activities recorded against an entity. Returns activities sorted " + + "most-recent first. Optionally filter by action type.", + inputSchema: { + type: "object", + properties: { + subject_id: { type: "string", description: "The entity ID to get activities for." }, + action: { + type: "string", + description: "Optional filter — only return activities with this action.", + }, + limit: { + type: "integer", + description: "Maximum number of results (default 50).", + default: 50, + }, + }, + required: ["subject_id"], + }, + }, + ]; + + const handlers: Record = { + log_activity: (args) => + app.logActivity( + args.subject_id as string, + args.action as string, + args.detail as Record | undefined, + ), + get_activities: (args) => + wrapList( + app.getActivities( + args.subject_id as string, + args.action as string | undefined, + (args.limit as number) ?? 50, + ), + ), + }; + + return { definitions, handlers }; +} + +// --------------------------------------------------------------------------- +// Utility tools +// --------------------------------------------------------------------------- + +const FIELD_NAME_RE = /^[a-z][a-z0-9_]*$/; +const BASE_ENTITY_FIELD_NAMES = new Set([ + "id", + "type", + "version", + "created_at", + "updated_at", + "created_by", + "status", + "tags", + "source", + "relationships", +]); +const ALLOWED_FIELD_TYPES = new Set(["string", "number", "integer", "boolean", "array", "object"]); +const TYPE_VALIDATORS: Record boolean> = { + string: (v) => typeof v === "string", + number: (v) => typeof v === "number", + integer: (v) => typeof v === "number" && Number.isInteger(v), + boolean: (v) => typeof v === "boolean", + array: (v) => Array.isArray(v), + object: (v) => typeof v === "object" && v !== null && !Array.isArray(v), +}; + +function buildUtilityTools( + app: UpjackApp, + manifestDir: string, + upjack: UpjackManifestExtension, +): { definitions: ToolDefinition[]; handlers: Record } { + const definitions: ToolDefinition[] = []; + const handlers: Record = {}; + + // seed_data + if (upjack.seed?.data) { + const seedDir = join(manifestDir, upjack.seed.data); + definitions.push({ + name: "seed_data", + description: "Load sample data from the seed directory.", + inputSchema: { type: "object", properties: {} }, + }); + handlers.seed_data = () => { + const loaded: string[] = []; + const errors: string[] = []; + if (!existsSync(seedDir)) { + return { loaded, errors: ["Seed directory not found"] }; + } + // Build plural→name map + const pluralToName: Record = {}; + for (const eDef of app._entityDefsList()) { + const p = eDef.plural ?? `${eDef.name}s`; + pluralToName[p] = eDef.name; + } + for (const file of readdirSync(seedDir)) { + if (!file.endsWith(".json")) continue; + try { + const raw = JSON.parse(readFileSync(join(seedDir, file), "utf-8")); + const entities = Array.isArray(raw) ? raw : [raw]; + const baseName = file.replace(".json", ""); + const entityName = pluralToName[baseName] ?? baseName; + for (const entity of entities) { + // Strip system fields + for (const k of BASE_ENTITY_FIELD_NAMES) { + delete entity[k]; + } + app.createEntity(entityName, entity); + } + loaded.push(file); + } catch (err) { + errors.push(`${file}: ${err instanceof Error ? err.message : String(err)}`); + } + } + return { loaded, errors }; + }; + } + + // add_field + definitions.push({ + name: "add_field", + description: + "Add a new field to an entity schema. Validates the change is safe, " + + "writes the updated schema to disk, and reloads it.", + inputSchema: { + type: "object", + properties: { + entity_type: { type: "string" }, + field_name: { type: "string" }, + field_type: { type: "string" }, + default: {}, + description: { type: "string" }, + required: { type: "boolean", default: true }, + }, + required: ["entity_type", "field_name", "field_type", "default"], + }, + }); + handlers.add_field = (args) => { + const entityType = args.entity_type as string; + const fieldName = args.field_name as string; + const fieldType = args.field_type as string; + const defaultValue = args.default; + const description = args.description as string | undefined; + const isRequired = (args.required as boolean) ?? true; + + if (!FIELD_NAME_RE.test(fieldName)) { + return { error: `Invalid field_name '${fieldName}'. Must match [a-z][a-z0-9_]*` }; + } + if (BASE_ENTITY_FIELD_NAMES.has(fieldName)) { + return { error: `Field '${fieldName}' is a reserved base entity field` }; + } + if (!ALLOWED_FIELD_TYPES.has(fieldType)) { + return { + error: `Invalid field_type '${fieldType}'. Allowed: ${[...ALLOWED_FIELD_TYPES].sort().join(", ")}`, + }; + } + const validator = TYPE_VALIDATORS[fieldType]; + if (validator && !validator(defaultValue)) { + return { + error: `Default value ${JSON.stringify(defaultValue)} is not compatible with type '${fieldType}'`, + }; + } + + const entityDefs = app._entityDefsList(); + const entityDef = entityDefs.find((e) => e.name === entityType); + if (!entityDef) { + return { error: `Unknown entity type '${entityType}'` }; + } + + const schemaPath = resolve(join(manifestDir, entityDef.schema)); + if (!schemaPath.startsWith(resolve(manifestDir))) { + return { error: "Schema path escapes the manifest directory" }; + } + + const oldSchema = loadSchema(schemaPath) as Record; + const oldProps = (oldSchema.properties ?? {}) as Record>; + if (fieldName in oldProps) { + const existingType = oldProps[fieldName].type; + if (existingType && existingType !== fieldType) { + return { error: `Field '${fieldName}' already exists with type '${existingType}'` }; + } + return { error: `Field '${fieldName}' already exists` }; + } + + const newSchema = structuredClone(oldSchema); + if (!newSchema.properties) newSchema.properties = {}; + const props = newSchema.properties as Record>; + const propDef: Record = { type: fieldType, default: defaultValue }; + if (description) propDef.description = description; + props[fieldName] = propDef; + + if (isRequired) { + const req = (newSchema.required ?? []) as string[]; + if (!req.includes(fieldName)) { + req.push(fieldName); + newSchema.required = req; + } + } + + const diagnostics = validateSchemaChange(oldSchema, newSchema); + const errs = diagnostics.filter((d) => d.severity === "error"); + if (errs.length > 0) { + return { error: "Schema change validation failed", diagnostics: errs }; + } + + const warnings = diagnostics.filter((d) => d.severity === "warning"); + writeFileSync(schemaPath, `${JSON.stringify(newSchema, null, 2)}\n`); + app.reloadSchema(entityType); + + const result: Record = { + success: true, + entity_type: entityType, + field: { name: fieldName, type: fieldType, default: defaultValue, required: isRequired }, + }; + if (warnings.length > 0) result.warnings = warnings; + return result; + }; + + // rebuild_index + definitions.push({ + name: "rebuild_index", + description: + "Force a full rebuild of the relationship index from entity files. " + + "Use this if the index seems stale or after manual file edits.", + inputSchema: { type: "object", properties: {} }, + }); + handlers.rebuild_index = () => { + const index = rebuildIndex(app.root, app.namespace, app._entityDefsList()); + const total = Object.values(index.reverse).reduce((sum, entries) => sum + entries.length, 0); + return { success: true, entries: total }; + }; + + return { definitions, handlers }; +} + // --------------------------------------------------------------------------- // Resource builders // --------------------------------------------------------------------------- @@ -240,12 +642,11 @@ function buildResources( const definitions: ResourceDefinition[] = []; const readers: Record = {}; - // Context resource const contextFile = upjack.context; if (contextFile) { const contextPath = join(manifestDir, contextFile); try { - readFileSync(contextPath, "utf-8"); // Check it exists + readFileSync(contextPath, "utf-8"); definitions.push({ uri: "upjack://context", name: "Context", @@ -257,12 +658,11 @@ function buildResources( } } - // Skill resources for (const skill of upjack.skills ?? []) { if (skill.source !== "bundled") continue; const skillPath = join(manifestDir, skill.path); try { - readFileSync(skillPath, "utf-8"); // Check it exists + readFileSync(skillPath, "utf-8"); const skillName = skillPath.split("/").slice(-2, -1)[0]; const uri = `upjack://skills/${skillName}`; definitions.push({ @@ -301,16 +701,6 @@ function buildInstructions(upjack: UpjackManifestExtension): string { // Server factory // --------------------------------------------------------------------------- -/** - * Create an MCP server from an Upjack manifest. - * - * Uses the low-level Server class directly so entity JSON Schemas can be - * passed as raw tool inputSchema — no Zod conversion, no translation layer. - * - * @param manifestPath - Path to manifest.json. - * @param root - Workspace root directory. - * @returns Configured Server instance. - */ export function createServer(manifestPath: string, root?: string): Server { const manifest = JSON.parse(readFileSync(manifestPath, "utf-8")); const manifestDir = dirname(manifestPath); @@ -320,24 +710,108 @@ export function createServer(manifestPath: string, root?: string): Server { const app = UpjackApp.fromManifest(manifestPath, root); - // Collect tool definitions (listed vs all) and handlers - const listedDefinitions: ToolDefinition[] = []; + // Collect all tool definitions and handlers + const allDefinitions: ToolDefinition[] = []; const allHandlers: Record = {}; + // Per-entity: CRUD + relationship tools for (const entityDef of upjack.entities ?? []) { const schema = app._schemas[entityDef.name]; const { definitions, handlers } = buildEntityTools(app, entityDef, schema); - Object.assign(allHandlers, handlers); - - const toolsFilter = (entityDef as unknown as Record).tools as - | string[] - | undefined; - if (toolsFilter) { - const listed = resolveListedTools(entityDef.name, entityDef.plural, toolsFilter); - listedDefinitions.push(...definitions.filter((d) => listed.has(d.name))); - } else { - listedDefinitions.push(...definitions); + const { definitions: relDefs, handlers: relHandlers } = buildRelationshipTools( + app, + entityDef, + schema, + ); + + allDefinitions.push(...definitions, ...relDefs); + Object.assign(allHandlers, handlers, relHandlers); + } + + // Activity entity: CRUD + relationship + convenience tools + const activitiesEnabled = Boolean(upjack.activities); + if (activitiesEnabled) { + const activityDef = { name: "activity", plural: "activities", prefix: "act", schema: "" }; + const activitySchema = app._schemas.activity; + const { definitions: actDefs, handlers: actHandlers } = buildEntityTools( + app, + activityDef, + activitySchema, + ); + const { definitions: actRelDefs, handlers: actRelHandlers } = buildRelationshipTools( + app, + activityDef, + activitySchema, + ); + const { definitions: actConvDefs, handlers: actConvHandlers } = buildActivityTools(app); + + allDefinitions.push(...actDefs, ...actRelDefs, ...actConvDefs); + Object.assign(allHandlers, actHandlers, actRelHandlers, actConvHandlers); + } + + // Utility tools + const { definitions: utilDefs, handlers: utilHandlers } = buildUtilityTools( + app, + manifestDir, + upjack, + ); + allDefinitions.push(...utilDefs); + Object.assign(allHandlers, utilHandlers); + + // Apply tool listing filter + let listedDefinitions = allDefinitions; + + const hasFilter = + (upjack.entities ?? []).some( + (e) => (e as unknown as Record).tools !== undefined, + ) || upjack.utility_tools !== undefined; + + if (hasFilter) { + // Build allowed tool set + const listedTools = new Set(); + for (const entityDef of upjack.entities ?? []) { + const toolsFilter = (entityDef as unknown as Record).tools as + | string[] + | undefined; + const name = entityDef.name; + const plural = entityDef.plural ?? `${name}s`; + if (toolsFilter) { + for (const t of resolveListedTools(name, plural, toolsFilter)) listedTools.add(t); + } else { + // No filter for this entity: list all categories + for (const t of resolveListedTools(name, plural, Object.keys(CATEGORY_TO_TOOL))) + listedTools.add(t); + } } + if (activitiesEnabled) { + for (const t of resolveListedTools("activity", "activities", Object.keys(CATEGORY_TO_TOOL))) + listedTools.add(t); + listedTools.add("log_activity"); + listedTools.add("get_activities"); + } + for (const t of resolveUtilityTools(upjack.utility_tools)) listedTools.add(t); + + // Build full set of auto-generated names (for custom tool detection) + const allAuto = new Set(); + for (const entityDef of upjack.entities ?? []) { + for (const t of resolveListedTools( + entityDef.name, + entityDef.plural, + Object.keys(CATEGORY_TO_TOOL), + )) + allAuto.add(t); + } + if (activitiesEnabled) { + for (const t of resolveListedTools("activity", "activities", Object.keys(CATEGORY_TO_TOOL))) + allAuto.add(t); + allAuto.add("log_activity"); + allAuto.add("get_activities"); + } + for (const t of ALL_UTILITY_TOOLS) allAuto.add(t); + + listedDefinitions = allDefinitions.filter( + (d) => listedTools.has(d.name) || !allAuto.has(d.name), + ); } // Collect resources @@ -351,19 +825,23 @@ export function createServer(manifestPath: string, root?: string): Server { if (Object.keys(allHandlers).length > 0) capabilities.tools = {}; if (resourceDefs.length > 0) capabilities.resources = {}; - // Create the low-level Server — raw JSON Schema, no Zod const server = new Server( { name: appName, version: manifest.version ?? "0.1.0" }, { capabilities, instructions: buildInstructions(upjack) }, ); - // tools/list — return listed tool definitions only (filtered by entity tools array) + // tools/list if (Object.keys(allHandlers).length > 0) { server.setRequestHandler(ListToolsRequestSchema, () => ({ - tools: listedDefinitions, + tools: listedDefinitions.map((d) => ({ + name: d.name, + description: d.description, + inputSchema: d.inputSchema, + ...(d.outputSchema ? { outputSchema: d.outputSchema } : {}), + })), })); - // tools/call — dispatch to the right entity operation + // tools/call server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; const handler = allHandlers[name]; @@ -373,8 +851,6 @@ export function createServer(manifestPath: string, root?: string): Server { isError: true, }; } - // Raw Server bypasses SDK's Zod deserialization — - // object arguments may arrive as JSON strings over stdio transport const parsed: Record = {}; for (const [k, v] of Object.entries(args ?? {})) { if (typeof v === "string" && (v.startsWith("{") || v.startsWith("["))) { @@ -424,9 +900,6 @@ export function createServer(manifestPath: string, root?: string): Server { return server; } -/** - * Start the MCP server with stdio transport. - */ export async function startServer(manifestPath: string, root?: string): Promise { const server = createServer(manifestPath, root); const transport = new StdioServerTransport(); diff --git a/lib/typescript/tests/activity.test.ts b/lib/typescript/tests/activity.test.ts new file mode 100644 index 0000000..777f6dd --- /dev/null +++ b/lib/typescript/tests/activity.test.ts @@ -0,0 +1,237 @@ +import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { beforeEach, describe, expect, it } from "vitest"; +import { ACTIVITY_ENTITY_DEF, getActivitySchema } from "../src/activity.js"; +import { UpjackApp } from "../src/app.js"; +import type { EntityDefinition } from "../src/entity.js"; + +describe("ACTIVITY_ENTITY_DEF", () => { + it("has correct fields", () => { + expect(ACTIVITY_ENTITY_DEF.name).toBe("activity"); + expect(ACTIVITY_ENTITY_DEF.plural).toBe("activities"); + expect(ACTIVITY_ENTITY_DEF.prefix).toBe("act"); + expect(ACTIVITY_ENTITY_DEF.schema).toBeDefined(); + }); +}); + +describe("getActivitySchema", () => { + it("returns schema with allOf", () => { + const schema = getActivitySchema(); + expect(schema.allOf).toBeDefined(); + }); + + it("schema has action property", () => { + const schema = getActivitySchema(); + const props = schema.properties as Record>; + expect(props.action).toBeDefined(); + expect(props.action.type).toBe("string"); + }); +}); + +describe("manifest activities", () => { + let workspace: string; + let manifestDir: string; + + beforeEach(() => { + workspace = mkdtempSync(join(tmpdir(), "upjack-activity-")); + manifestDir = join(workspace, "app"); + mkdirSync(join(manifestDir, "schemas"), { recursive: true }); + writeFileSync( + join(manifestDir, "schemas", "contact.schema.json"), + JSON.stringify({ + allOf: [ + { $ref: "https://upjack.dev/schemas/v1/upjack-entity.schema.json" }, + { type: "object", properties: { first_name: { type: "string" } } }, + ], + }), + ); + }); + + function writeManifest(activities: boolean | undefined) { + const manifest: Record = { + _meta: { + "ai.nimblebrain/upjack": { + upjack_version: "0.1", + namespace: "apps/crm", + entities: [ + { + name: "contact", + plural: "contacts", + prefix: "ct", + schema: "schemas/contact.schema.json", + }, + ], + ...(activities !== undefined ? { activities } : {}), + }, + }, + }; + const path = join(manifestDir, "manifest.json"); + writeFileSync(path, JSON.stringify(manifest)); + return path; + } + + it("activities: true registers activity entity type", () => { + const app = UpjackApp.fromManifest(writeManifest(true), workspace); + expect(() => app.logActivity("ct_01FAKE00000000000000000000", "test")).not.toThrow(); + }); + + it("activities: false does not register", () => { + const app = UpjackApp.fromManifest(writeManifest(false), workspace); + expect(() => app.logActivity("ct_01FAKE00000000000000000000", "test")).toThrow( + "Unknown entity type", + ); + }); + + it("activities absent does not register", () => { + const app = UpjackApp.fromManifest(writeManifest(undefined), workspace); + expect(() => app.logActivity("ct_01FAKE00000000000000000000", "test")).toThrow( + "Unknown entity type", + ); + }); + + it("activities: true with user activity entity throws", () => { + const manifest = { + _meta: { + "ai.nimblebrain/upjack": { + upjack_version: "0.1", + namespace: "apps/crm", + entities: [ + { + name: "activity", + plural: "activities", + prefix: "act", + schema: "schemas/contact.schema.json", + }, + ], + activities: true, + }, + }, + }; + const path = join(manifestDir, "manifest.json"); + writeFileSync(path, JSON.stringify(manifest)); + expect(() => UpjackApp.fromManifest(path, workspace)).toThrow( + "Cannot enable built-in activities", + ); + }); +}); + +describe("logActivity", () => { + let workspace: string; + let app: UpjackApp; + + const ENTITIES: EntityDefinition[] = [ + { name: "contact", plural: "contacts", prefix: "ct", schema: "schemas/contact.schema.json" }, + ACTIVITY_ENTITY_DEF, + ]; + + beforeEach(() => { + workspace = mkdtempSync(join(tmpdir(), "upjack-activity-")); + app = new UpjackApp("apps/crm", ENTITIES, workspace); + }); + + it("creates activity entity with action", () => { + const contact = app.createEntity("contact", { first_name: "Sarah" }); + const activity = app.logActivity(contact.id, "email_sent"); + expect(activity.type).toBe("activity"); + expect(activity.action).toBe("email_sent"); + expect(activity.id.startsWith("act_")).toBe(true); + }); + + it("auto-wires subject relationship", () => { + const contact = app.createEntity("contact", { first_name: "Sarah" }); + const activity = app.logActivity(contact.id, "called"); + expect(activity.relationships).toHaveLength(1); + expect(activity.relationships[0].rel).toBe("subject"); + expect(activity.relationships[0].target).toBe(contact.id); + }); + + it("uses created_by system", () => { + const contact = app.createEntity("contact", { first_name: "Sarah" }); + const activity = app.logActivity(contact.id, "called"); + expect(activity.created_by).toBe("system"); + }); + + it("works with detail dict", () => { + const contact = app.createEntity("contact", { first_name: "Sarah" }); + const activity = app.logActivity(contact.id, "email_sent", { subject: "Hello" }); + expect(activity.detail).toEqual({ subject: "Hello" }); + }); + + it("detail defaults to empty object", () => { + const contact = app.createEntity("contact", { first_name: "Sarah" }); + const activity = app.logActivity(contact.id, "called"); + expect(activity.detail).toEqual({}); + }); + + it("throws when activity type not registered", () => { + const noActivityApp = new UpjackApp("apps/crm", [ENTITIES[0]], workspace); + expect(() => noActivityApp.logActivity("ct_01FAKE", "test")).toThrow("Unknown entity type"); + }); +}); + +describe("getActivities", () => { + let workspace: string; + let app: UpjackApp; + + const ENTITIES: EntityDefinition[] = [ + { name: "contact", plural: "contacts", prefix: "ct", schema: "schemas/contact.schema.json" }, + ACTIVITY_ENTITY_DEF, + ]; + + beforeEach(() => { + workspace = mkdtempSync(join(tmpdir(), "upjack-activity-")); + app = new UpjackApp("apps/crm", ENTITIES, workspace); + }); + + it("returns activities for subject", () => { + const contact = app.createEntity("contact", { first_name: "Sarah" }); + app.logActivity(contact.id, "called"); + app.logActivity(contact.id, "email_sent"); + + const activities = app.getActivities(contact.id); + expect(activities).toHaveLength(2); + }); + + it("filters by action", () => { + const contact = app.createEntity("contact", { first_name: "Sarah" }); + app.logActivity(contact.id, "called"); + app.logActivity(contact.id, "email_sent"); + + const activities = app.getActivities(contact.id, "called"); + expect(activities).toHaveLength(1); + expect(activities[0].action).toBe("called"); + }); + + it("returns empty when no activities", () => { + const contact = app.createEntity("contact", { first_name: "Sarah" }); + const activities = app.getActivities(contact.id); + expect(activities).toHaveLength(0); + }); + + it("multiple subjects are isolated", () => { + const sarah = app.createEntity("contact", { first_name: "Sarah" }); + const bob = app.createEntity("contact", { first_name: "Bob" }); + app.logActivity(sarah.id, "called"); + app.logActivity(bob.id, "email_sent"); + + const sarahActivities = app.getActivities(sarah.id); + expect(sarahActivities).toHaveLength(1); + expect(sarahActivities[0].action).toBe("called"); + }); + + it("respects limit", () => { + const contact = app.createEntity("contact", { first_name: "Sarah" }); + for (let i = 0; i < 5; i++) { + app.logActivity(contact.id, `action_${i}`); + } + + const activities = app.getActivities(contact.id, undefined, 2); + expect(activities).toHaveLength(2); + }); + + it("throws when activity type not registered", () => { + const noActivityApp = new UpjackApp("apps/crm", [ENTITIES[0]], workspace); + expect(() => noActivityApp.getActivities("ct_01FAKE")).toThrow("Unknown entity type"); + }); +}); diff --git a/lib/typescript/tests/e2e.test.ts b/lib/typescript/tests/e2e.test.ts index 6487b72..e3a0c67 100644 --- a/lib/typescript/tests/e2e.test.ts +++ b/lib/typescript/tests/e2e.test.ts @@ -506,7 +506,8 @@ describe("CRM Server E2E", () => { expect(names.has(`search_${plural}`)).toBe(true); expect(names.has(`delete_${name}`)).toBe(true); } - expect(tools.tools).toHaveLength(30); + // 5 entities × 9 tools + 3 utility = 48 + expect(tools.tools).toHaveLength(48); }); it("registers context and skill resources", async () => { @@ -563,8 +564,8 @@ describe("Research Server E2E", () => { expect(names.has(`create_${name}`)).toBe(true); expect(names.has(`list_${plural}`)).toBe(true); } - // 4 entities × 6 tools = 24 - expect(tools.tools).toHaveLength(24); + // 4 entities × 9 tools + 3 utility = 39 + expect(tools.tools).toHaveLength(39); }); }); @@ -599,7 +600,8 @@ describe("Todo Server E2E", () => { expect(names.has(`search_${plural}`)).toBe(true); expect(names.has(`delete_${name}`)).toBe(true); } - expect(tools.tools).toHaveLength(18); + // 3 entities × 9 tools + 3 utility = 30 + expect(tools.tools).toHaveLength(30); }); it("registers context and skill resources", async () => { @@ -648,18 +650,21 @@ describe("Todo Server E2E", () => { // List const listResult = await client.callTool({ name: "list_tasks", arguments: {} }); - const items = JSON.parse((listResult.content as Array<{ text: string }>)[0].text) as unknown[]; - expect(items).toHaveLength(1); + const listData = JSON.parse((listResult.content as Array<{ text: string }>)[0].text) as Record< + string, + unknown + >; + expect((listData.entities as unknown[]).length).toBe(1); // Search const searchResult = await client.callTool({ name: "search_tasks", arguments: { query: "Test" }, }); - const results = JSON.parse( + const searchData = JSON.parse( (searchResult.content as Array<{ text: string }>)[0].text, - ) as unknown[]; - expect(results).toHaveLength(1); + ) as Record; + expect((searchData.entities as unknown[]).length).toBe(1); // Delete const deleteResult = await client.callTool({ @@ -671,10 +676,11 @@ describe("Todo Server E2E", () => { // Verify excluded from list const finalList = await client.callTool({ name: "list_tasks", arguments: {} }); - const finalItems = JSON.parse( - (finalList.content as Array<{ text: string }>)[0].text, - ) as unknown[]; - expect(finalItems).toHaveLength(0); + const finalData = JSON.parse((finalList.content as Array<{ text: string }>)[0].text) as Record< + string, + unknown + >; + expect((finalData.entities as unknown[]).length).toBe(0); }); }); diff --git a/lib/typescript/tests/entity.test.ts b/lib/typescript/tests/entity.test.ts index b369148..e9dd36b 100644 --- a/lib/typescript/tests/entity.test.ts +++ b/lib/typescript/tests/entity.test.ts @@ -234,3 +234,164 @@ describe("deleteEntity", () => { ).toThrow("Entity not found"); }); }); + +const HYDRATION_SCHEMA: Record = { + allOf: [ + { $ref: "https://upjack.dev/schemas/v1/upjack-entity.schema.json" }, + { + type: "object", + properties: { + first_name: { type: "string" }, + priority: { type: "string", default: "medium" }, + }, + }, + ], +}; + +describe("getEntity with schema hydration", () => { + it("hydrates missing field from schema default", () => { + const created = createEntity(workspace, NAMESPACE, "contact", "contacts", "ct", { + first_name: "Sarah", + }); + const entity = getEntity(workspace, NAMESPACE, "contacts", created.id, HYDRATION_SCHEMA); + expect(entity.priority).toBe("medium"); + expect(entity.first_name).toBe("Sarah"); + }); + + it("works without schema (returns raw entity)", () => { + const created = createEntity(workspace, NAMESPACE, "contact", "contacts", "ct", { + first_name: "Sarah", + }); + const entity = getEntity(workspace, NAMESPACE, "contacts", created.id); + expect(entity.priority).toBeUndefined(); + }); +}); + +describe("listEntities with schema hydration", () => { + it("hydrates all entities in list", () => { + createEntity(workspace, NAMESPACE, "contact", "contacts", "ct", { first_name: "A" }); + createEntity(workspace, NAMESPACE, "contact", "contacts", "ct", { first_name: "B" }); + const entities = listEntities(workspace, NAMESPACE, "contacts", "active", 50, HYDRATION_SCHEMA); + expect(entities).toHaveLength(2); + for (const e of entities) { + expect(e.priority).toBe("medium"); + } + }); +}); + +describe("createEntity with callback", () => { + it("fires callback when entity has relationships", () => { + const calls: Array<{ id: string; oldRels: unknown[]; newRels: unknown[] }> = []; + const cb = (id: string, oldRels: unknown[], newRels: unknown[]) => { + calls.push({ id, oldRels, newRels }); + }; + const created = createEntity( + workspace, + NAMESPACE, + "contact", + "contacts", + "ct", + { first_name: "Sarah", relationships: [{ rel: "works_at", target: "co_01FAKE" }] }, + undefined, + 1, + "agent", + cb, + ); + expect(calls).toHaveLength(1); + expect(calls[0].id).toBe(created.id); + expect(calls[0].oldRels).toEqual([]); + expect(calls[0].newRels).toHaveLength(1); + }); + + it("does not fire callback when no relationships", () => { + const calls: unknown[] = []; + createEntity( + workspace, + NAMESPACE, + "contact", + "contacts", + "ct", + { first_name: "Sarah" }, + undefined, + 1, + "agent", + () => { + calls.push(1); + }, + ); + expect(calls).toHaveLength(0); + }); +}); + +describe("updateEntity with callback", () => { + it("fires callback when relationships change", () => { + const created = createEntity(workspace, NAMESPACE, "contact", "contacts", "ct", { + first_name: "Sarah", + }); + const calls: Array<{ oldRels: unknown[]; newRels: unknown[] }> = []; + updateEntity( + workspace, + NAMESPACE, + "contacts", + created.id, + { relationships: [{ rel: "works_at", target: "co_01FAKE" }] }, + undefined, + true, + (_id, oldRels, newRels) => { + calls.push({ oldRels, newRels }); + }, + ); + expect(calls).toHaveLength(1); + expect(calls[0].oldRels).toEqual([]); + expect(calls[0].newRels).toHaveLength(1); + }); + + it("does not fire callback when relationships unchanged", () => { + const created = createEntity(workspace, NAMESPACE, "contact", "contacts", "ct", { + first_name: "Sarah", + relationships: [{ rel: "works_at", target: "co_01FAKE" }], + }); + const calls: unknown[] = []; + updateEntity( + workspace, + NAMESPACE, + "contacts", + created.id, + { first_name: "Sarah Updated" }, + undefined, + true, + () => { + calls.push(1); + }, + ); + expect(calls).toHaveLength(0); + }); +}); + +describe("deleteEntity with callback", () => { + it("fires callback on hard delete with relationships", () => { + const created = createEntity(workspace, NAMESPACE, "contact", "contacts", "ct", { + first_name: "Sarah", + relationships: [{ rel: "works_at", target: "co_01FAKE" }], + }); + const calls: Array<{ oldRels: unknown[]; newRels: unknown[] }> = []; + deleteEntity(workspace, NAMESPACE, "contacts", created.id, true, (_id, oldRels, newRels) => { + calls.push({ oldRels, newRels }); + }); + expect(calls).toHaveLength(1); + expect(calls[0].oldRels).toHaveLength(1); + expect(calls[0].newRels).toEqual([]); + }); + + it("does not fire callback on soft delete", () => { + const created = createEntity(workspace, NAMESPACE, "contact", "contacts", "ct", { + first_name: "Sarah", + relationships: [{ rel: "works_at", target: "co_01FAKE" }], + }); + const calls: unknown[] = []; + deleteEntity(workspace, NAMESPACE, "contacts", created.id, false, () => { + calls.push(1); + }); + expect(calls).toHaveLength(0); + }); +}); diff --git a/lib/typescript/tests/graph.test.ts b/lib/typescript/tests/graph.test.ts new file mode 100644 index 0000000..6d5a166 --- /dev/null +++ b/lib/typescript/tests/graph.test.ts @@ -0,0 +1,257 @@ +import { existsSync } from "node:fs"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { beforeEach, describe, expect, it } from "vitest"; +import { UpjackApp } from "../src/app.js"; +import type { EntityDefinition } from "../src/entity.js"; +import { indexPath } from "../src/paths.js"; + +let workspace: string; +let app: UpjackApp; + +const ENTITIES: EntityDefinition[] = [ + { name: "contact", plural: "contacts", prefix: "ct", schema: "schemas/contact.schema.json" }, + { name: "company", plural: "companies", prefix: "co", schema: "schemas/company.schema.json" }, + { name: "deal", plural: "deals", prefix: "dl", schema: "schemas/deal.schema.json" }, +]; + +beforeEach(() => { + workspace = mkdtempSync(join(tmpdir(), "upjack-graph-")); + app = new UpjackApp("apps/crm", ENTITIES, workspace); +}); + +describe("prefix resolution", () => { + it("resolves known prefix to entity name", () => { + expect(app._resolveType("ct_01FAKE00000000000000000000")).toBe("contact"); + expect(app._resolveType("co_01FAKE00000000000000000000")).toBe("company"); + expect(app._resolveType("dl_01FAKE00000000000000000000")).toBe("deal"); + }); + + it("throws for unknown prefix", () => { + expect(() => app._resolveType("xx_01FAKE00000000000000000000")).toThrow("Unknown prefix"); + }); +}); + +describe("queryByRelationship", () => { + it("returns entities with matching relationship", () => { + const company = app.createEntity("company", { name: "Acme" }); + app.createEntity("contact", { + first_name: "Sarah", + relationships: [{ rel: "works_at", target: company.id }], + }); + app.createEntity("contact", { + first_name: "Bob", + relationships: [{ rel: "works_at", target: company.id }], + }); + + const results = app.queryByRelationship("contact", "works_at", company.id); + expect(results).toHaveLength(2); + }); + + it("filters to correct entity type", () => { + const company = app.createEntity("company", { name: "Acme" }); + app.createEntity("contact", { + first_name: "Sarah", + relationships: [{ rel: "belongs_to", target: company.id }], + }); + app.createEntity("deal", { + title: "Big Deal", + relationships: [{ rel: "belongs_to", target: company.id }], + }); + + const contacts = app.queryByRelationship("contact", "belongs_to", company.id); + expect(contacts).toHaveLength(1); + expect(contacts[0].type).toBe("contact"); + }); + + it("applies equality filter", () => { + const company = app.createEntity("company", { name: "Acme" }); + app.createEntity("contact", { + first_name: "Sarah", + role: "engineer", + relationships: [{ rel: "works_at", target: company.id }], + }); + app.createEntity("contact", { + first_name: "Bob", + role: "manager", + relationships: [{ rel: "works_at", target: company.id }], + }); + + const results = app.queryByRelationship("contact", "works_at", company.id, { + role: "engineer", + }); + expect(results).toHaveLength(1); + expect(results[0].first_name).toBe("Sarah"); + }); + + it("throws on operator filter when there are matching entities", () => { + const company = app.createEntity("company", { name: "Acme" }); + app.createEntity("contact", { + first_name: "Sarah", + age: 35, + relationships: [{ rel: "works_at", target: company.id }], + }); + + expect(() => + app.queryByRelationship("contact", "works_at", company.id, { age: { $gt: 30 } }), + ).toThrow("Operator filters"); + }); + + it("respects limit", () => { + const company = app.createEntity("company", { name: "Acme" }); + for (let i = 0; i < 5; i++) { + app.createEntity("contact", { + first_name: `Person${i}`, + relationships: [{ rel: "works_at", target: company.id }], + }); + } + + const results = app.queryByRelationship("contact", "works_at", company.id, undefined, 2); + expect(results).toHaveLength(2); + }); + + it("excludes deleted entities", () => { + const company = app.createEntity("company", { name: "Acme" }); + const contact = app.createEntity("contact", { + first_name: "Sarah", + relationships: [{ rel: "works_at", target: company.id }], + }); + app.deleteEntity("contact", contact.id); + + const results = app.queryByRelationship("contact", "works_at", company.id); + expect(results).toHaveLength(0); + }); + + it("returns empty for no matches", () => { + const company = app.createEntity("company", { name: "Acme" }); + const results = app.queryByRelationship("contact", "works_at", company.id); + expect(results).toHaveLength(0); + }); +}); + +describe("getRelated", () => { + it("forward returns resolved target entities", () => { + const company = app.createEntity("company", { name: "Acme" }); + const contact = app.createEntity("contact", { + first_name: "Sarah", + relationships: [{ rel: "works_at", target: company.id }], + }); + + const related = app.getRelated(contact.id, undefined, "forward"); + expect(related).toHaveLength(1); + expect(related[0].id).toBe(company.id); + }); + + it("forward with rel filter", () => { + const company = app.createEntity("company", { name: "Acme" }); + const other = app.createEntity("company", { name: "Other" }); + const contact = app.createEntity("contact", { + first_name: "Sarah", + relationships: [ + { rel: "works_at", target: company.id }, + { rel: "founded", target: other.id }, + ], + }); + + const related = app.getRelated(contact.id, "works_at", "forward"); + expect(related).toHaveLength(1); + expect(related[0].id).toBe(company.id); + }); + + it("reverse returns entities pointing at this one", () => { + const company = app.createEntity("company", { name: "Acme" }); + app.createEntity("contact", { + first_name: "Sarah", + relationships: [{ rel: "works_at", target: company.id }], + }); + + const related = app.getRelated(company.id, undefined, "reverse"); + expect(related).toHaveLength(1); + expect(related[0].first_name).toBe("Sarah"); + }); + + it("skips missing target entities", () => { + const contact = app.createEntity("contact", { + first_name: "Sarah", + relationships: [{ rel: "works_at", target: "co_01NONEXISTENT0000000000000" }], + }); + + const related = app.getRelated(contact.id, undefined, "forward"); + expect(related).toHaveLength(0); + }); + + it("throws for invalid direction", () => { + const contact = app.createEntity("contact", { first_name: "Sarah" }); + expect(() => app.getRelated(contact.id, undefined, "sideways" as "forward")).toThrow( + "direction must be", + ); + }); + + it("empty relationships returns empty array", () => { + const contact = app.createEntity("contact", { first_name: "Sarah" }); + const related = app.getRelated(contact.id, undefined, "forward"); + expect(related).toEqual([]); + }); +}); + +describe("getComposite", () => { + it("includes forward and reverse relationships", () => { + const company = app.createEntity("company", { name: "Acme" }); + const contact = app.createEntity("contact", { + first_name: "Sarah", + relationships: [{ rel: "works_at", target: company.id }], + }); + + const composite = app.getComposite("company", company.id); + const related = composite._related as Record; + + // Reverse: contact works_at company + expect(related["~works_at"]).toHaveLength(1); + expect((related["~works_at"][0] as { id: string }).id).toBe(contact.id); + }); + + it("forward rels keyed by rel name", () => { + const company = app.createEntity("company", { name: "Acme" }); + const contact = app.createEntity("contact", { + first_name: "Sarah", + relationships: [{ rel: "works_at", target: company.id }], + }); + + const composite = app.getComposite("contact", contact.id); + const related = composite._related as Record; + expect(related.works_at).toHaveLength(1); + expect((related.works_at[0] as { id: string }).id).toBe(company.id); + }); + + it("depth 0 returns entity with empty _related", () => { + const contact = app.createEntity("contact", { first_name: "Sarah" }); + const composite = app.getComposite("contact", contact.id, 0); + expect(composite._related).toEqual({}); + }); +}); + +describe("relationship index wiring", () => { + it("creating entity with relationships updates reverse index", () => { + const company = app.createEntity("company", { name: "Acme" }); + app.createEntity("contact", { + first_name: "Sarah", + relationships: [{ rel: "works_at", target: company.id }], + }); + + const path = indexPath(workspace, "apps/crm"); + expect(existsSync(path)).toBe(true); + }); + + it("hard deleting entity removes from reverse index", () => { + const company = app.createEntity("company", { name: "Acme" }); + const contact = app.createEntity("contact", { + first_name: "Sarah", + relationships: [{ rel: "works_at", target: company.id }], + }); + app.deleteEntity("contact", contact.id, true); + + const results = app.queryByRelationship("contact", "works_at", company.id); + expect(results).toHaveLength(0); + }); +}); diff --git a/lib/typescript/tests/paths.test.ts b/lib/typescript/tests/paths.test.ts index 7be53ee..e1a9c20 100644 --- a/lib/typescript/tests/paths.test.ts +++ b/lib/typescript/tests/paths.test.ts @@ -4,7 +4,14 @@ import { tmpdir } from "node:os"; import { resolve } from "node:path"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import { entityDir, entityPath, resolveRoot, schemaDir } from "../src/paths.js"; +import { + entityDir, + entityPath, + indexDir, + indexPath, + resolveRoot, + schemaDir, +} from "../src/paths.js"; describe("schemaDir", () => { it("returns correct path", () => { @@ -68,6 +75,42 @@ describe("path traversal", () => { }); }); +describe("indexDir", () => { + it("returns correct path", () => { + const result = indexDir("/workspace", "apps/crm"); + expect(result).toBe("/workspace/apps/crm/data/_index"); + }); + + it("rejects namespace with traversal", () => { + const root = mkdtempSync(join(tmpdir(), "upjack-")); + mkdirSync(join(root, "workspace"), { recursive: true }); + const workspace = join(root, "workspace"); + + expect(() => indexDir(workspace, "../../etc")).toThrow("Path escapes workspace root"); + }); +}); + +describe("indexPath", () => { + it("returns correct path", () => { + const result = indexPath("/workspace", "apps/crm"); + expect(result).toBe("/workspace/apps/crm/data/_index/relations.json"); + }); + + it("rejects namespace with traversal", () => { + const root = mkdtempSync(join(tmpdir(), "upjack-")); + mkdirSync(join(root, "workspace"), { recursive: true }); + const workspace = join(root, "workspace"); + + expect(() => indexPath(workspace, "../../etc")).toThrow("Path escapes workspace root"); + }); + + it("indexPath is child of indexDir", () => { + const dir = indexDir("/ws", "apps/crm"); + const path = indexPath("/ws", "apps/crm"); + expect(path).toBe(`${dir}/relations.json`); + }); +}); + describe("resolveRoot", () => { const originalEnv = process.env.UPJACK_ROOT; diff --git a/lib/typescript/tests/relations.test.ts b/lib/typescript/tests/relations.test.ts new file mode 100644 index 0000000..3f8f265 --- /dev/null +++ b/lib/typescript/tests/relations.test.ts @@ -0,0 +1,239 @@ +import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { beforeEach, describe, expect, it } from "vitest"; +import { indexPath } from "../src/paths.js"; +import { + loadIndex, + queryReverse, + rebuildIndex, + removeFromIndex, + saveIndex, + updateIndex, +} from "../src/relations.js"; + +const NAMESPACE = "apps/crm"; +let workspace: string; + +beforeEach(() => { + workspace = mkdtempSync(join(tmpdir(), "upjack-relations-")); +}); + +describe("loadIndex / saveIndex", () => { + it("returns empty index for nonexistent file", () => { + const index = loadIndex(workspace, NAMESPACE); + expect(index).toEqual({ reverse: {} }); + }); + + it("round-trips save then load", () => { + const index = { + reverse: { + co_01FAKE: [{ source: "ct_01FAKE", rel: "works_at" }], + }, + }; + saveIndex(workspace, NAMESPACE, index); + const loaded = loadIndex(workspace, NAMESPACE); + expect(loaded).toEqual(index); + }); + + it("creates directory if missing", () => { + const path = indexPath(workspace, NAMESPACE); + expect(existsSync(path)).toBe(false); + saveIndex(workspace, NAMESPACE, { reverse: {} }); + expect(existsSync(path)).toBe(true); + }); + + it("returns empty index for corrupt JSON", () => { + const dir = join(workspace, NAMESPACE, "data", "_index"); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "relations.json"), "NOT JSON{{{"); + const index = loadIndex(workspace, NAMESPACE); + expect(index).toEqual({ reverse: {} }); + }); +}); + +describe("updateIndex", () => { + it("adds new relationships to index", () => { + updateIndex(workspace, NAMESPACE, "ct_01A", [], [{ rel: "works_at", target: "co_01B" }]); + const index = loadIndex(workspace, NAMESPACE); + expect(index.reverse.co_01B).toEqual([{ source: "ct_01A", rel: "works_at" }]); + }); + + it("changes target: removes old, adds new", () => { + updateIndex(workspace, NAMESPACE, "ct_01A", [], [{ rel: "works_at", target: "co_01B" }]); + updateIndex( + workspace, + NAMESPACE, + "ct_01A", + [{ rel: "works_at", target: "co_01B" }], + [{ rel: "works_at", target: "co_01C" }], + ); + const index = loadIndex(workspace, NAMESPACE); + expect(index.reverse.co_01B).toBeUndefined(); + expect(index.reverse.co_01C).toEqual([{ source: "ct_01A", rel: "works_at" }]); + }); + + it("removes all relationships: cleans up index", () => { + updateIndex(workspace, NAMESPACE, "ct_01A", [], [{ rel: "works_at", target: "co_01B" }]); + updateIndex(workspace, NAMESPACE, "ct_01A", [{ rel: "works_at", target: "co_01B" }], []); + const index = loadIndex(workspace, NAMESPACE); + expect(index.reverse.co_01B).toBeUndefined(); + }); + + it("no duplicates on repeated update", () => { + updateIndex(workspace, NAMESPACE, "ct_01A", [], [{ rel: "works_at", target: "co_01B" }]); + updateIndex(workspace, NAMESPACE, "ct_01A", [], [{ rel: "works_at", target: "co_01B" }]); + const index = loadIndex(workspace, NAMESPACE); + expect(index.reverse.co_01B).toHaveLength(1); + }); + + it("multiple sources can point to same target", () => { + updateIndex(workspace, NAMESPACE, "ct_01A", [], [{ rel: "works_at", target: "co_01X" }]); + updateIndex(workspace, NAMESPACE, "ct_01B", [], [{ rel: "works_at", target: "co_01X" }]); + const index = loadIndex(workspace, NAMESPACE); + expect(index.reverse.co_01X).toHaveLength(2); + }); +}); + +describe("removeFromIndex", () => { + it("removes all entries for a source entity", () => { + updateIndex( + workspace, + NAMESPACE, + "ct_01A", + [], + [ + { rel: "works_at", target: "co_01B" }, + { rel: "knows", target: "ct_01C" }, + ], + ); + removeFromIndex(workspace, NAMESPACE, "ct_01A", [ + { rel: "works_at", target: "co_01B" }, + { rel: "knows", target: "ct_01C" }, + ]); + const index = loadIndex(workspace, NAMESPACE); + expect(Object.keys(index.reverse)).toHaveLength(0); + }); + + it("preserves entries from other entities", () => { + updateIndex(workspace, NAMESPACE, "ct_01A", [], [{ rel: "works_at", target: "co_01X" }]); + updateIndex(workspace, NAMESPACE, "ct_01B", [], [{ rel: "works_at", target: "co_01X" }]); + removeFromIndex(workspace, NAMESPACE, "ct_01A", [{ rel: "works_at", target: "co_01X" }]); + const index = loadIndex(workspace, NAMESPACE); + expect(index.reverse.co_01X).toHaveLength(1); + expect(index.reverse.co_01X[0].source).toBe("ct_01B"); + }); + + it("no-op for empty rels (does not create index file)", () => { + removeFromIndex(workspace, NAMESPACE, "ct_01A", []); + const path = indexPath(workspace, NAMESPACE); + expect(existsSync(path)).toBe(false); + }); +}); + +describe("rebuildIndex", () => { + it("rebuilds from entity files on disk", () => { + const contactsDir = join(workspace, NAMESPACE, "data", "contacts"); + mkdirSync(contactsDir, { recursive: true }); + writeFileSync( + join(contactsDir, "ct_01A.json"), + JSON.stringify({ + id: "ct_01A", + relationships: [{ rel: "works_at", target: "co_01B" }], + }), + ); + writeFileSync( + join(contactsDir, "ct_01C.json"), + JSON.stringify({ + id: "ct_01C", + relationships: [{ rel: "works_at", target: "co_01B" }], + }), + ); + + const index = rebuildIndex(workspace, NAMESPACE, [{ name: "contact", plural: "contacts" }]); + expect(index.reverse.co_01B).toHaveLength(2); + }); + + it("skips corrupt JSON files", () => { + const contactsDir = join(workspace, NAMESPACE, "data", "contacts"); + mkdirSync(contactsDir, { recursive: true }); + writeFileSync(join(contactsDir, "ct_01A.json"), "NOT JSON"); + writeFileSync( + join(contactsDir, "ct_01B.json"), + JSON.stringify({ + id: "ct_01B", + relationships: [{ rel: "works_at", target: "co_01X" }], + }), + ); + + const index = rebuildIndex(workspace, NAMESPACE, [{ name: "contact", plural: "contacts" }]); + expect(index.reverse.co_01X).toHaveLength(1); + }); + + it("skips missing entity directories", () => { + const index = rebuildIndex(workspace, NAMESPACE, [{ name: "contact", plural: "contacts" }]); + expect(index).toEqual({ reverse: {} }); + }); +}); + +describe("queryReverse", () => { + it("returns entries for target ID", () => { + updateIndex(workspace, NAMESPACE, "ct_01A", [], [{ rel: "works_at", target: "co_01B" }]); + const entries = queryReverse(workspace, NAMESPACE, "co_01B"); + expect(entries).toEqual([{ source: "ct_01A", rel: "works_at" }]); + }); + + it("filters by rel type", () => { + updateIndex( + workspace, + NAMESPACE, + "ct_01A", + [], + [ + { rel: "works_at", target: "co_01B" }, + { rel: "founded", target: "co_01B" }, + ], + ); + const entries = queryReverse(workspace, NAMESPACE, "co_01B", "works_at"); + expect(entries).toHaveLength(1); + expect(entries[0].rel).toBe("works_at"); + }); + + it("returns empty for unknown target", () => { + const entries = queryReverse(workspace, NAMESPACE, "unknown_01Z"); + expect(entries).toEqual([]); + }); + + it("auto-rebuilds when index missing and entityDefs provided", () => { + const contactsDir = join(workspace, NAMESPACE, "data", "contacts"); + mkdirSync(contactsDir, { recursive: true }); + writeFileSync( + join(contactsDir, "ct_01A.json"), + JSON.stringify({ + id: "ct_01A", + relationships: [{ rel: "works_at", target: "co_01B" }], + }), + ); + + const entries = queryReverse(workspace, NAMESPACE, "co_01B", undefined, [ + { name: "contact", plural: "contacts" }, + ]); + expect(entries).toHaveLength(1); + }); + + it("does NOT rebuild when entityDefs not provided", () => { + const contactsDir = join(workspace, NAMESPACE, "data", "contacts"); + mkdirSync(contactsDir, { recursive: true }); + writeFileSync( + join(contactsDir, "ct_01A.json"), + JSON.stringify({ + id: "ct_01A", + relationships: [{ rel: "works_at", target: "co_01B" }], + }), + ); + + const entries = queryReverse(workspace, NAMESPACE, "co_01B"); + expect(entries).toEqual([]); + }); +}); diff --git a/lib/typescript/tests/schema.test.ts b/lib/typescript/tests/schema.test.ts index 10913b0..3e4f22c 100644 --- a/lib/typescript/tests/schema.test.ts +++ b/lib/typescript/tests/schema.test.ts @@ -3,7 +3,15 @@ import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { loadSchema, resolveEntitySchema, validateEntity } from "../src/schema.js"; +import { + buildEntityOutputSchema, + buildListOutputSchema, + hydrateDefaults, + loadSchema, + resolveEntitySchema, + validateEntity, + validateSchemaChange, +} from "../src/schema.js"; describe("loadSchema", () => { it("loads a valid schema file", () => { @@ -88,3 +96,236 @@ describe("resolveEntitySchema", () => { expect((result.allOf as unknown[])[1]).toEqual(app); }); }); + +describe("hydrateDefaults", () => { + it("fills missing field with schema default", () => { + const schema = { + type: "object", + properties: { + name: { type: "string" }, + status: { type: "string", default: "active" }, + }, + }; + const data = { name: "test" }; + const result = hydrateDefaults(data, schema); + expect(result.status).toBe("active"); + expect(result.name).toBe("test"); + }); + + it("preserves existing field value", () => { + const schema = { + type: "object", + properties: { + status: { type: "string", default: "active" }, + }, + }; + const data = { status: "archived" }; + const result = hydrateDefaults(data, schema); + expect(result.status).toBe("archived"); + }); + + it("does not mutate input object", () => { + const schema = { + type: "object", + properties: { + status: { type: "string", default: "active" }, + }, + }; + const data = { name: "test" }; + hydrateDefaults(data, schema); + expect(data).toEqual({ name: "test" }); + }); + + it("handles allOf with $ref to base entity schema", () => { + const schema = { + allOf: [ + { $ref: "https://upjack.dev/schemas/v1/upjack-entity.schema.json" }, + { + type: "object", + properties: { + priority: { type: "string", default: "medium" }, + }, + }, + ], + }; + const data = { name: "test" }; + const result = hydrateDefaults(data, schema); + // Base schema has defaults for created_by, status, tags, relationships + expect(result.status).toBe("active"); + expect(result.tags).toEqual([]); + expect(result.relationships).toEqual([]); + expect(result.created_by).toBe("agent"); + // App schema default + expect(result.priority).toBe("medium"); + }); + + it("returns shallow copy when no defaults", () => { + const schema = { + type: "object", + properties: { name: { type: "string" } }, + }; + const data = { name: "test" }; + const result = hydrateDefaults(data, schema); + expect(result).toEqual(data); + expect(result).not.toBe(data); + }); + + it("deep-clones mutable defaults so they are isolated", () => { + const schema = { + type: "object", + properties: { + items: { type: "array", default: ["a", "b"] }, + }, + }; + const result1 = hydrateDefaults({}, schema); + const result2 = hydrateDefaults({}, schema); + (result1.items as string[]).push("c"); + expect(result2.items).toEqual(["a", "b"]); + }); +}); + +describe("validateSchemaChange", () => { + it("returns empty for identical schemas", () => { + const schema = { + properties: { name: { type: "string" } }, + required: ["name"], + }; + expect(validateSchemaChange(schema, schema)).toEqual([]); + }); + + it("detects newly required field without default as error", () => { + const old = { properties: { name: { type: "string" } } }; + const new_ = { + properties: { name: { type: "string" }, age: { type: "number" } }, + required: ["age"], + }; + const diags = validateSchemaChange(old, new_); + expect(diags).toHaveLength(1); + expect(diags[0].severity).toBe("error"); + expect(diags[0].field).toBe("age"); + }); + + it("accepts newly required field with default", () => { + const old = { properties: { name: { type: "string" } } }; + const new_ = { + properties: { name: { type: "string" }, age: { type: "number", default: 0 } }, + required: ["age"], + }; + expect(validateSchemaChange(old, new_)).toEqual([]); + }); + + it("detects type change as error", () => { + const old = { properties: { count: { type: "string" } } }; + const new_ = { properties: { count: { type: "number" } } }; + const diags = validateSchemaChange(old, new_); + expect(diags).toHaveLength(1); + expect(diags[0].severity).toBe("error"); + expect(diags[0].message).toContain("Type changed"); + }); + + it("detects enum narrowing as error", () => { + const old = { properties: { status: { type: "string", enum: ["a", "b", "c"] } } }; + const new_ = { properties: { status: { type: "string", enum: ["a", "b"] } } }; + const diags = validateSchemaChange(old, new_); + expect(diags).toHaveLength(1); + expect(diags[0].severity).toBe("error"); + expect(diags[0].message).toContain("Enum narrowed"); + }); + + it("accepts enum widening", () => { + const old = { properties: { status: { type: "string", enum: ["a", "b"] } } }; + const new_ = { properties: { status: { type: "string", enum: ["a", "b", "c"] } } }; + expect(validateSchemaChange(old, new_)).toEqual([]); + }); + + it("detects field removal as warning", () => { + const old = { properties: { name: { type: "string" }, age: { type: "number" } } }; + const new_ = { properties: { name: { type: "string" } } }; + const diags = validateSchemaChange(old, new_); + expect(diags).toHaveLength(1); + expect(diags[0].severity).toBe("warning"); + expect(diags[0].field).toBe("age"); + }); + + it("returns multiple issues together", () => { + const old = { + properties: { + name: { type: "string" }, + count: { type: "string" }, + extra: { type: "boolean" }, + }, + }; + const new_ = { + properties: { + name: { type: "string" }, + count: { type: "number" }, + priority: { type: "string" }, + }, + required: ["priority"], + }; + const diags = validateSchemaChange(old, new_); + // type change on count, field removed (extra), newly required without default (priority) + expect(diags.length).toBeGreaterThanOrEqual(3); + }); +}); + +describe("buildEntityOutputSchema", () => { + it("adds type:object to allOf schema without type", () => { + const base = { type: "object", properties: { id: { type: "string" } } }; + const app = { type: "object", properties: { name: { type: "string" } } }; + const composed = resolveEntitySchema(base, app); + expect(composed.type).toBeUndefined(); + + const result = buildEntityOutputSchema(composed); + expect(result.type).toBe("object"); + expect(result.allOf).toBeDefined(); + }); + + it("preserves existing type:object", () => { + const schema = { type: "object", properties: { name: { type: "string" } } }; + const result = buildEntityOutputSchema(schema); + expect(result.type).toBe("object"); + }); + + it("strips $schema and $id", () => { + const schema = { + $schema: "https://json-schema.org/draft/2020-12/schema", + $id: "https://example.com/widget", + type: "object", + properties: { name: { type: "string" } }, + }; + const result = buildEntityOutputSchema(schema); + expect(result.$schema).toBeUndefined(); + expect(result.$id).toBeUndefined(); + }); + + it("deep copies input (mutations isolated)", () => { + const schema = { allOf: [{ type: "object" }] }; + const result = buildEntityOutputSchema(schema); + (result as Record).extra = true; + expect(schema).not.toHaveProperty("extra"); + }); +}); + +describe("buildListOutputSchema", () => { + it("returns object with entities array and count", () => { + const schema = { type: "object", properties: { name: { type: "string" } } }; + const result = buildListOutputSchema(schema); + expect(result.type).toBe("object"); + const props = result.properties as Record>; + expect(props.entities.type).toBe("array"); + expect(props.count.type).toBe("integer"); + expect(result.required).toEqual(["entities", "count"]); + }); + + it("items schema has type:object via buildEntityOutputSchema", () => { + const composed = resolveEntitySchema( + { type: "object", properties: { id: { type: "string" } } }, + { type: "object", properties: { name: { type: "string" } } }, + ); + const result = buildListOutputSchema(composed); + const props = result.properties as Record>; + const items = props.entities.items as Record; + expect(items.type).toBe("object"); + }); +}); diff --git a/lib/typescript/tests/server.test.ts b/lib/typescript/tests/server.test.ts index 6c9f1f6..5193c0c 100644 --- a/lib/typescript/tests/server.test.ts +++ b/lib/typescript/tests/server.test.ts @@ -256,7 +256,7 @@ beforeEach(() => { }); describe("createServer", () => { - it("registers 6 tools per entity", async () => { + it("registers 9 entity tools + utility tools per entity", async () => { const manifestPath = makeManifest(tmpDir, [ { name: "widget", plural: "widgets", prefix: "wg" }, ]); @@ -264,16 +264,18 @@ describe("createServer", () => { const tools = await client.listTools(); const names = new Set(tools.tools.map((t) => t.name)); - expect(names).toEqual( - new Set([ - "create_widget", - "get_widget", - "update_widget", - "list_widgets", - "search_widgets", - "delete_widget", - ]), - ); + // 6 CRUD + 3 relationship + 2 utility (add_field, rebuild_index) + expect(names).toContain("create_widget"); + expect(names).toContain("get_widget"); + expect(names).toContain("update_widget"); + expect(names).toContain("list_widgets"); + expect(names).toContain("search_widgets"); + expect(names).toContain("delete_widget"); + expect(names).toContain("query_widgets_by_relationship"); + expect(names).toContain("get_related_widget"); + expect(names).toContain("get_widget_composite"); + expect(names).toContain("add_field"); + expect(names).toContain("rebuild_index"); await client.close(); }); @@ -285,10 +287,12 @@ describe("createServer", () => { const client = await connectClient(manifestPath, workspace); const tools = await client.listTools(); - expect(tools.tools).toHaveLength(12); + // 9 per entity * 2 + 2 utility = 20 const names = new Set(tools.tools.map((t) => t.name)); expect(names.has("create_contact")).toBe(true); expect(names.has("search_deals")).toBe(true); + expect(names.has("query_contacts_by_relationship")).toBe(true); + expect(names.has("get_related_deal")).toBe(true); await client.close(); }); }); @@ -426,16 +430,17 @@ describe("tool CRUD", () => { expect(updated.extra).toBe("field"); }); - it("list returns created entities", async () => { + it("list returns created entities in envelope", async () => { await client.callTool({ name: "create_item", arguments: { data: { name: "A" } } }); await client.callTool({ name: "create_item", arguments: { data: { name: "B" } } }); const listResult = await client.callTool({ name: "list_items", arguments: {} }); - const items = JSON.parse((listResult.content as Array<{ text: string }>)[0].text); - expect(items).toHaveLength(2); + const result = JSON.parse((listResult.content as Array<{ text: string }>)[0].text); + expect(result.entities).toHaveLength(2); + expect(result.count).toBe(2); }); - it("search finds by text", async () => { + it("search finds by text in envelope", async () => { await client.callTool({ name: "create_item", arguments: { data: { name: "Alpha" } } }); await client.callTool({ name: "create_item", arguments: { data: { name: "Beta" } } }); @@ -443,9 +448,9 @@ describe("tool CRUD", () => { name: "search_items", arguments: { query: "Alpha" }, }); - const results = JSON.parse((searchResult.content as Array<{ text: string }>)[0].text); - expect(results).toHaveLength(1); - expect(results[0].name).toBe("Alpha"); + const result = JSON.parse((searchResult.content as Array<{ text: string }>)[0].text); + expect(result.entities).toHaveLength(1); + expect(result.entities[0].name).toBe("Alpha"); }); it("soft delete", async () => { @@ -463,8 +468,8 @@ describe("tool CRUD", () => { expect(deleted.status).toBe("deleted"); const listResult = await client.callTool({ name: "list_items", arguments: {} }); - const items = JSON.parse((listResult.content as Array<{ text: string }>)[0].text); - expect(items).toHaveLength(0); + const result = JSON.parse((listResult.content as Array<{ text: string }>)[0].text); + expect(result.entities).toHaveLength(0); }); }); @@ -518,9 +523,9 @@ describe("JSON string deserialization", () => { name: "search_items", arguments: { filter: JSON.stringify({ name: "Findme" }) }, }); - const results = JSON.parse((searchResult.content as Array<{ text: string }>)[0].text); - expect(results).toHaveLength(1); - expect(results[0].name).toBe("Findme"); + const result = JSON.parse((searchResult.content as Array<{ text: string }>)[0].text); + expect(result.entities).toHaveLength(1); + expect(result.entities[0].name).toBe("Findme"); }); it("plain string args are not mangled", async () => { @@ -623,7 +628,11 @@ describe("tool listing filter", () => { const tools = await client.listTools(); const names = new Set(tools.tools.map((t) => t.name)); - expect(names).toEqual(new Set(["get_session", "search_sessions"])); + // Only get + search for session, plus utility tools + expect(names.has("get_session")).toBe(true); + expect(names.has("search_sessions")).toBe(true); + expect(names.has("create_session")).toBe(false); + expect(names.has("query_sessions_by_relationship")).toBe(false); // Hidden tools are still callable const result = await client.callTool({ @@ -635,7 +644,7 @@ describe("tool listing filter", () => { await client.close(); }); - it("no tools array lists all tools", async () => { + it("no tools array lists all entity + utility tools", async () => { const manifestPath = makeManifest(tmpDir, [ { name: "widget", plural: "widgets", prefix: "wg" }, ]); @@ -643,16 +652,11 @@ describe("tool listing filter", () => { const tools = await client.listTools(); const names = new Set(tools.tools.map((t) => t.name)); - expect(names).toEqual( - new Set([ - "create_widget", - "get_widget", - "update_widget", - "list_widgets", - "search_widgets", - "delete_widget", - ]), - ); + // All 9 entity tools + utility tools + expect(names.has("create_widget")).toBe(true); + expect(names.has("query_widgets_by_relationship")).toBe(true); + expect(names.has("add_field")).toBe(true); + expect(names.has("rebuild_index")).toBe(true); await client.close(); }); From 963b5406f70bcf0dc959a8a4c6eea816801f4e19 Mon Sep 17 00:00:00 2001 From: Mathew Goldsborough <1759329+mgoldsborough@users.noreply.github.com> Date: Wed, 8 Apr 2026 14:56:42 -1000 Subject: [PATCH 2/3] Fix QA review issues: error handling, type safety, atomic writes, seed data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace empty catch {} blocks in graph traversal with targeted error handling — only catch "Entity not found" and "Unknown prefix", re-throw unexpected errors - Add Relationship interface to replace unsafe `as unknown as` type casts on entity relationships throughout entity.ts and app.ts - Use destructuring to strip $schema/$id in buildEntityOutputSchema so keys are truly absent (not set to undefined) - Atomic write for add_field tool (temp file + rename) to prevent corrupt schema files from concurrent calls - Seed data now preserves relationships and tags (only strips metadata fields), matching Python lib behavior - Add 8 server-level tests for add_field tool covering success path, field usability, and validation error cases - Tighten buildEntityOutputSchema test to assert key absence --- lib/typescript/src/app.ts | 42 ++++++--- lib/typescript/src/entity.ts | 31 +++---- lib/typescript/src/index.ts | 7 +- lib/typescript/src/schema.ts | 4 +- lib/typescript/src/server.ts | 30 +++++- lib/typescript/tests/schema.test.ts | 6 +- lib/typescript/tests/server.test.ts | 137 ++++++++++++++++++++++++++++ 7 files changed, 217 insertions(+), 40 deletions(-) diff --git a/lib/typescript/src/app.ts b/lib/typescript/src/app.ts index 39e63d2..744b120 100644 --- a/lib/typescript/src/app.ts +++ b/lib/typescript/src/app.ts @@ -4,6 +4,7 @@ import { ACTIVITY_ENTITY_DEF, getActivitySchema } from "./activity.js"; import { type EntityDefinition, type EntityRecord, + type Relationship, createEntity, deleteEntity, getEntity, @@ -135,8 +136,8 @@ export class UpjackApp { /** @internal */ _onRelationshipsChanged( entityId: string, - oldRels: Array>, - newRels: Array>, + oldRels: Relationship[], + newRels: Relationship[], ): void { updateIndex(this.root, this.namespace, entityId, oldRels, newRels); } @@ -144,8 +145,8 @@ export class UpjackApp { /** @internal */ _onRelationshipsRemoved( entityId: string, - oldRels: Array>, - _newRels: Array>, + oldRels: Relationship[], + _newRels: Relationship[], ): void { removeFromIndex(this.root, this.namespace, entityId, oldRels); } @@ -307,8 +308,9 @@ export class UpjackApp { let entity: EntityRecord; try { entity = this.getEntity(entityType, eid); - } catch { - continue; + } catch (err) { + if (err instanceof Error && err.message.startsWith("Entity not found")) continue; + throw err; } if ((entity.status ?? "active") !== "active") continue; if (filter && !UpjackApp._matchesFilter(entity, filter)) continue; @@ -347,7 +349,10 @@ export class UpjackApp { try { const targetType = this._resolveType(r.target); results.push(this.getEntity(targetType, r.target)); - } catch {} + } catch (err) { + if (err instanceof Error && err.message.startsWith("Entity not found")) continue; + throw err; + } } return results; } @@ -360,7 +365,11 @@ export class UpjackApp { try { const sourceType = this._resolveType(entry.source); results.push(this.getEntity(sourceType, entry.source)); - } catch {} + } catch (err) { + if (err instanceof Error && err.message.startsWith("Entity not found")) continue; + if (err instanceof Error && err.message.startsWith("Unknown prefix")) continue; + throw err; + } } return results; } @@ -378,7 +387,11 @@ export class UpjackApp { const target = this.getEntity(targetType, r.target); if (!related[r.rel]) related[r.rel] = []; related[r.rel].push(target); - } catch {} + } catch (err) { + if (err instanceof Error && err.message.startsWith("Entity not found")) continue; + if (err instanceof Error && err.message.startsWith("Unknown prefix")) continue; + throw err; + } } // Reverse relationships @@ -396,7 +409,11 @@ export class UpjackApp { const source = this.getEntity(sourceType, entry.source); if (!related[relName]) related[relName] = []; related[relName].push(source); - } catch {} + } catch (err) { + if (err instanceof Error && err.message.startsWith("Entity not found")) continue; + if (err instanceof Error && err.message.startsWith("Unknown prefix")) continue; + throw err; + } } } @@ -453,7 +470,10 @@ export class UpjackApp { if ((entity.status ?? "active") !== "active") continue; if (action !== undefined && entity.action !== action) continue; results.push(entity); - } catch {} + } catch (err) { + if (err instanceof Error && err.message.startsWith("Entity not found")) continue; + throw err; + } } results.sort((a, b) => (b.updated_at ?? "").localeCompare(a.updated_at ?? "")); diff --git a/lib/typescript/src/entity.ts b/lib/typescript/src/entity.ts index 3ef5b12..a250cf8 100644 --- a/lib/typescript/src/entity.ts +++ b/lib/typescript/src/entity.ts @@ -11,10 +11,17 @@ import { generateId } from "./ids.js"; import { entityDir, entityPath } from "./paths.js"; import { hydrateDefaults, validateEntity } from "./schema.js"; +export interface Relationship { + rel: string; + target: string; + label?: string; + [key: string]: string | undefined; +} + export type RelationshipsChangedCallback = ( entityId: string, - oldRels: Array>, - newRels: Array>, + oldRels: Relationship[], + newRels: Relationship[], ) => void; /** Base fields present on every entity record. */ @@ -28,7 +35,7 @@ export interface EntityRecord { status: string; tags: string[]; source?: Record; - relationships: Array<{ rel: string; target: string; label?: string }>; + relationships: Relationship[]; [key: string]: unknown; } @@ -93,11 +100,7 @@ export function createEntity( writeFileSync(path, `${JSON.stringify(record, null, 2)}\n`); if (onRelationshipsChanged && record.relationships.length > 0) { - onRelationshipsChanged( - entityId, - [], - record.relationships as unknown as Array>, - ); + onRelationshipsChanged(entityId, [], record.relationships); } return record; @@ -162,11 +165,7 @@ export function updateEntity( if (onRelationshipsChanged) { const newRelationships = JSON.stringify(existing.relationships ?? []); if (oldRelationships !== newRelationships) { - onRelationshipsChanged( - entityId, - JSON.parse(oldRelationships), - (existing.relationships ?? []) as unknown as Array>, - ); + onRelationshipsChanged(entityId, JSON.parse(oldRelationships), existing.relationships ?? []); } } @@ -251,11 +250,7 @@ export function deleteEntity( if (hard) { unlinkSync(path); if (onRelationshipsChanged && entity.relationships?.length > 0) { - onRelationshipsChanged( - entityId, - entity.relationships as unknown as Array>, - [], - ); + onRelationshipsChanged(entityId, entity.relationships, []); } } else { entity.status = "deleted"; diff --git a/lib/typescript/src/index.ts b/lib/typescript/src/index.ts index 6bad5c2..d698b18 100644 --- a/lib/typescript/src/index.ts +++ b/lib/typescript/src/index.ts @@ -8,7 +8,12 @@ export { listEntities, deleteEntity, } from "./entity.js"; -export type { EntityRecord, EntityDefinition, RelationshipsChangedCallback } from "./entity.js"; +export type { + EntityRecord, + EntityDefinition, + Relationship, + RelationshipsChangedCallback, +} from "./entity.js"; export { generateId, parseId, validateId } from "./ids.js"; export { entityDir, entityPath, indexDir, indexPath, resolveRoot, schemaDir } from "./paths.js"; export { diff --git a/lib/typescript/src/schema.ts b/lib/typescript/src/schema.ts index a228dda..fddf07a 100644 --- a/lib/typescript/src/schema.ts +++ b/lib/typescript/src/schema.ts @@ -205,9 +205,7 @@ export function validateSchemaChange( * Strips JSON Schema meta keywords and ensures `type: "object"`. */ export function buildEntityOutputSchema(schema: Record): Record { - const result = structuredClone(schema); - result.$schema = undefined; - result.$id = undefined; + const { $schema: _, $id: __, ...result } = structuredClone(schema); if (!("type" in result)) { result.type = "object"; } diff --git a/lib/typescript/src/server.ts b/lib/typescript/src/server.ts index 8fef92c..fc55264 100644 --- a/lib/typescript/src/server.ts +++ b/lib/typescript/src/server.ts @@ -1,4 +1,11 @@ -import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; +import { + existsSync, + readFileSync, + readdirSync, + renameSync, + unlinkSync, + writeFileSync, +} from "node:fs"; import { dirname, join, resolve } from "node:path"; import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; @@ -445,6 +452,10 @@ const BASE_ENTITY_FIELD_NAMES = new Set([ "source", "relationships", ]); + +// Fields stripped from seed data before create — matches Python behavior. +// Preserves relationships and tags so seed data can set up a connected graph. +const SEED_STRIP_FIELDS = new Set(["type", "created_at", "updated_at", "created_by"]); const ALLOWED_FIELD_TYPES = new Set(["string", "number", "integer", "boolean", "array", "object"]); const TYPE_VALIDATORS: Record boolean> = { string: (v) => typeof v === "string", @@ -491,8 +502,8 @@ function buildUtilityTools( const baseName = file.replace(".json", ""); const entityName = pluralToName[baseName] ?? baseName; for (const entity of entities) { - // Strip system fields - for (const k of BASE_ENTITY_FIELD_NAMES) { + // Strip system metadata but preserve relationships and tags + for (const k of SEED_STRIP_FIELDS) { delete entity[k]; } app.createEntity(entityName, entity); @@ -594,7 +605,18 @@ function buildUtilityTools( } const warnings = diagnostics.filter((d) => d.severity === "warning"); - writeFileSync(schemaPath, `${JSON.stringify(newSchema, null, 2)}\n`); + const tmpPath = `${schemaPath}.tmp`; + try { + writeFileSync(tmpPath, `${JSON.stringify(newSchema, null, 2)}\n`); + renameSync(tmpPath, schemaPath); + } catch (err) { + try { + if (existsSync(tmpPath)) unlinkSync(tmpPath); + } catch { + // ignore cleanup errors + } + throw err; + } app.reloadSchema(entityType); const result: Record = { diff --git a/lib/typescript/tests/schema.test.ts b/lib/typescript/tests/schema.test.ts index 3e4f22c..25329e0 100644 --- a/lib/typescript/tests/schema.test.ts +++ b/lib/typescript/tests/schema.test.ts @@ -287,7 +287,7 @@ describe("buildEntityOutputSchema", () => { expect(result.type).toBe("object"); }); - it("strips $schema and $id", () => { + it("strips $schema and $id (keys absent, not undefined)", () => { const schema = { $schema: "https://json-schema.org/draft/2020-12/schema", $id: "https://example.com/widget", @@ -295,8 +295,8 @@ describe("buildEntityOutputSchema", () => { properties: { name: { type: "string" } }, }; const result = buildEntityOutputSchema(schema); - expect(result.$schema).toBeUndefined(); - expect(result.$id).toBeUndefined(); + expect("$schema" in result).toBe(false); + expect("$id" in result).toBe(false); }); it("deep copies input (mutations isolated)", () => { diff --git a/lib/typescript/tests/server.test.ts b/lib/typescript/tests/server.test.ts index 5193c0c..cee442e 100644 --- a/lib/typescript/tests/server.test.ts +++ b/lib/typescript/tests/server.test.ts @@ -702,3 +702,140 @@ describe("tool listing filter", () => { await client.close(); }); }); + +describe("add_field tool", () => { + let client: Awaited>; + + beforeEach(async () => { + const manifestPath = makeManifest(tmpDir, [ + { name: "widget", plural: "widgets", prefix: "wg" }, + ]); + client = await connectClient(manifestPath, workspace); + }); + + afterEach(async () => { + await client.close(); + }); + + it("adds a new field to entity schema", async () => { + const result = await client.callTool({ + name: "add_field", + arguments: { + entity_type: "widget", + field_name: "priority", + field_type: "string", + default: "medium", + description: "Priority level", + }, + }); + const parsed = JSON.parse((result.content as Array<{ text: string }>)[0].text); + expect(parsed.success).toBe(true); + expect(parsed.field.name).toBe("priority"); + expect(parsed.field.type).toBe("string"); + expect(parsed.field.default).toBe("medium"); + }); + + it("new field is usable after add", async () => { + await client.callTool({ + name: "add_field", + arguments: { + entity_type: "widget", + field_name: "color", + field_type: "string", + default: "blue", + }, + }); + + // Create an entity — the new field should be accepted + const createResult = await client.callTool({ + name: "create_widget", + arguments: { data: { name: "Test", color: "red" } }, + }); + const created = JSON.parse((createResult.content as Array<{ text: string }>)[0].text); + expect(created.color).toBe("red"); + }); + + it("rejects invalid field name", async () => { + const result = await client.callTool({ + name: "add_field", + arguments: { + entity_type: "widget", + field_name: "BadName", + field_type: "string", + default: "", + }, + }); + const parsed = JSON.parse((result.content as Array<{ text: string }>)[0].text); + expect(parsed.error).toContain("Invalid field_name"); + }); + + it("rejects reserved field name", async () => { + const result = await client.callTool({ + name: "add_field", + arguments: { + entity_type: "widget", + field_name: "status", + field_type: "string", + default: "active", + }, + }); + const parsed = JSON.parse((result.content as Array<{ text: string }>)[0].text); + expect(parsed.error).toContain("reserved base entity field"); + }); + + it("rejects invalid field type", async () => { + const result = await client.callTool({ + name: "add_field", + arguments: { + entity_type: "widget", + field_name: "score", + field_type: "float", + default: 0, + }, + }); + const parsed = JSON.parse((result.content as Array<{ text: string }>)[0].text); + expect(parsed.error).toContain("Invalid field_type"); + }); + + it("rejects type mismatch between default and field_type", async () => { + const result = await client.callTool({ + name: "add_field", + arguments: { + entity_type: "widget", + field_name: "count", + field_type: "integer", + default: "not a number", + }, + }); + const parsed = JSON.parse((result.content as Array<{ text: string }>)[0].text); + expect(parsed.error).toContain("not compatible with type"); + }); + + it("rejects duplicate field", async () => { + const result = await client.callTool({ + name: "add_field", + arguments: { + entity_type: "widget", + field_name: "name", + field_type: "string", + default: "", + }, + }); + const parsed = JSON.parse((result.content as Array<{ text: string }>)[0].text); + expect(parsed.error).toContain("already exists"); + }); + + it("rejects unknown entity type", async () => { + const result = await client.callTool({ + name: "add_field", + arguments: { + entity_type: "nonexistent", + field_name: "foo", + field_type: "string", + default: "", + }, + }); + const parsed = JSON.parse((result.content as Array<{ text: string }>)[0].text); + expect(parsed.error).toContain("Unknown entity type"); + }); +}); From 3851f6276cb6df933f99286d3299622303c42e4f Mon Sep 17 00:00:00 2001 From: Mathew Goldsborough <1759329+mgoldsborough@users.noreply.github.com> Date: Wed, 8 Apr 2026 15:01:10 -1000 Subject: [PATCH 3/3] Fix createEntity to respect provided IDs for seed data parity Match Python create_entity behavior: if data contains a valid prefixed ID matching the entity prefix, use it instead of generating a new one. This allows seed data with stable IDs and cross-references to work correctly. --- lib/typescript/src/entity.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/lib/typescript/src/entity.ts b/lib/typescript/src/entity.ts index a250cf8..ebf095c 100644 --- a/lib/typescript/src/entity.ts +++ b/lib/typescript/src/entity.ts @@ -7,7 +7,7 @@ import { writeFileSync, } from "node:fs"; import { dirname, join } from "node:path"; -import { generateId } from "./ids.js"; +import { generateId, validateId } from "./ids.js"; import { entityDir, entityPath } from "./paths.js"; import { hydrateDefaults, validateEntity } from "./schema.js"; @@ -68,8 +68,17 @@ export function createEntity( onRelationshipsChanged?: RelationshipsChangedCallback, ): EntityRecord { const now = nowIso(); - const entityId = generateId(prefix); - const { tags: rawTags, relationships: rawRelationships, source: rawSource, ...appData } = data; + const { + id: providedId, + tags: rawTags, + relationships: rawRelationships, + source: rawSource, + ...appData + } = data; + const entityId = + typeof providedId === "string" && validateId(providedId) && providedId.startsWith(`${prefix}_`) + ? providedId + : generateId(prefix); const tags = (rawTags as string[]) ?? []; const relationships = (rawRelationships as EntityRecord["relationships"]) ?? []; const source = rawSource as Record | undefined;