From 9c7a7478242cdffd88afbf38b666a401c7007d7b Mon Sep 17 00:00:00 2001 From: Maximiliano Osorio Date: Thu, 7 May 2026 21:41:22 -0400 Subject: [PATCH 01/46] fix(request): use update_columns:[] on nested target on_conflict POST /modelconfigurations|/modelconfigurationsetups|/datasetspecifications with relation fields (hasInput/hasOutput/hasModelCategory/hasPresentation) returned 400 'null value in column "label"' when the client linked an existing target by id alone. buildJunctionInserts emitted update_columns:['label'] on the nested target-entity insert. With nestedData = {id} only, on Hasura PK conflict the existing target row got UPDATE label=NULL and tripped the not-null constraint. Outer junction insert already used update_columns:[]; the inner inconsistency was the bug. Switch to update_columns:[] for link-or-noop semantics on existing target rows. New target rows still insert with all supplied fields. Renames remain the responsibility of the target's own PUT endpoint. Tests: updated existing assertion in request.test.ts (Test 1) and added two regressions covering hasInput as bare-string id and {id}-only object. --- src/mappers/__tests__/request.test.ts | 37 ++++++++++++++++++++++++++- src/mappers/request.ts | 2 +- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/src/mappers/__tests__/request.test.ts b/src/mappers/__tests__/request.test.ts index c534046..a0c7b81 100644 --- a/src/mappers/__tests__/request.test.ts +++ b/src/mappers/__tests__/request.test.ts @@ -233,7 +233,11 @@ describe('buildJunctionInserts', () => { expect(targetData['id']).toBe('https://w3id.org/okn/i/mint/Economy'); const targetConflict = category['on_conflict'] as Record; expect(targetConflict['constraint']).toBe('modelcatalog_model_category_pkey'); - expect(targetConflict['update_columns']).toEqual(['label']); + // Link-or-noop semantics: never overwrite columns on existing target rows. + // Previous value ['label'] caused null-label cascade (bug-087) when client + // sent only `{id}` -- nestedData lacked label and on_conflict overwrote + // the existing row's label with NULL. + expect(targetConflict['update_columns']).toEqual([]); }); it('Test 2: generates UUID-based ID with https prefix when no ID provided', () => { @@ -343,6 +347,37 @@ describe('buildJunctionInserts', () => { expect(nestedData).not.toHaveProperty('is_optional'); }); + it('Regression bug-087: linking by string id alone never sets update_columns to overwrite target.label with NULL', () => { + const configConfig = getResourceConfig('modelconfigurations')!; + const body = { hasInput: ['https://w3id.org/okn/i/mint/existing-ds-id'] }; + const result = buildJunctionInserts(body, configConfig); + const inputs = result['inputs'] as Record; + const data = inputs['data'] as Record[]; + const junctionRow = data[0] as Record; + const nestedTarget = junctionRow['input'] as Record; + const nestedData = nestedTarget['data'] as Record; + // Client sent only an id -- nested data must contain only id, no label. + expect(Object.keys(nestedData)).toEqual(['id']); + expect(nestedData['id']).toBe('https://w3id.org/okn/i/mint/existing-ds-id'); + // Critical: on_conflict must NOT update label. Updating label with the + // missing-from-payload value would null-clobber the existing target row + // and trip the not-null constraint on dataset_specification.label. + const targetConflict = nestedTarget['on_conflict'] as Record; + expect(targetConflict['update_columns']).toEqual([]); + }); + + it('Regression bug-087: object-form link with only id likewise produces empty update_columns', () => { + const configConfig = getResourceConfig('modelconfigurations')!; + const body = { hasInput: [{ id: 'https://w3id.org/okn/i/mint/existing-ds-id' }] }; + const result = buildJunctionInserts(body, configConfig); + const inputs = result['inputs'] as Record; + const data = inputs['data'] as Record[]; + const junctionRow = data[0] as Record; + const nestedTarget = junctionRow['input'] as Record; + const targetConflict = nestedTarget['on_conflict'] as Record; + expect(targetConflict['update_columns']).toEqual([]); + }); + it('Test 11: omits is_optional from junction row when isOptional is absent in request body (D-22)', () => { const configConfig = getResourceConfig('modelconfigurations')!; const body = { diff --git a/src/mappers/request.ts b/src/mappers/request.ts index 3e3200d..55ea21f 100644 --- a/src/mappers/request.ts +++ b/src/mappers/request.ts @@ -224,7 +224,7 @@ export function buildJunctionInserts( data: nestedData, on_conflict: { constraint: `${targetTable}_pkey`, - update_columns: ['label'], + update_columns: [], }, }, }; From f6bcfc69537a5fa2bf0e68d616c7eaee700f20d2 Mon Sep 17 00:00:00 2001 From: Maximiliano Osorio Date: Sat, 9 May 2026 13:28:49 -0400 Subject: [PATCH 02/46] feat(nested-tree): add WriteTree types and validation caps --- src/mappers/__tests__/nested-tree.test.ts | 55 +++++++++++++++++++++ src/mappers/nested-tree.ts | 58 +++++++++++++++++++++++ 2 files changed, 113 insertions(+) create mode 100644 src/mappers/__tests__/nested-tree.test.ts create mode 100644 src/mappers/nested-tree.ts diff --git a/src/mappers/__tests__/nested-tree.test.ts b/src/mappers/__tests__/nested-tree.test.ts new file mode 100644 index 0000000..3ce5833 --- /dev/null +++ b/src/mappers/__tests__/nested-tree.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect } from 'vitest'; +import { + MAX_DEPTH, + MAX_NODES, + MAX_ARRAY_LENGTH, + ValidationError, + type WriteNode, + type JunctionEdge, + type ChildFkEdge, +} from '../nested-tree.js'; + +describe('nested-tree types and constants', () => { + it('exposes hard caps as numeric constants', () => { + expect(MAX_DEPTH).toBe(8); + expect(MAX_NODES).toBe(500); + expect(MAX_ARRAY_LENGTH).toBe(200); + }); + + it('ValidationError carries code, path, message, http status', () => { + const err = new ValidationError('DEPTH_EXCEEDED', '/hasVersion/0', 'too deep', 400); + expect(err.code).toBe('DEPTH_EXCEEDED'); + expect(err.path).toBe('/hasVersion/0'); + expect(err.message).toBe('too deep'); + expect(err.httpStatus).toBe(400); + expect(err).toBeInstanceOf(Error); + }); + + it('WriteNode/JunctionEdge/ChildFkEdge can be constructed', () => { + const node: WriteNode = { + table: 'modelcatalog_software', + id: 'https://w3id.org/okn/i/mint/x', + columns: { label: 'foo' }, + junctions: [], + childFks: [], + }; + const junc: JunctionEdge = { + apiFieldName: 'hasInput', + junctionTable: 'modelcatalog_configuration_input', + junctionRelName: 'input', + parentFkColumn: 'configuration_id', + targetFkColumn: 'input_id', + junctionColumns: [], + children: [], + }; + const child: ChildFkEdge = { + apiFieldName: 'hasConfiguration', + childTable: 'modelcatalog_model_configuration', + childFkColumn: 'model_version_id', + children: [], + }; + expect(node.id).toBe('https://w3id.org/okn/i/mint/x'); + expect(junc.targetFkColumn).toBe('input_id'); + expect(child.childFkColumn).toBe('model_version_id'); + }); +}); diff --git a/src/mappers/nested-tree.ts b/src/mappers/nested-tree.ts new file mode 100644 index 0000000..4f2e497 --- /dev/null +++ b/src/mappers/nested-tree.ts @@ -0,0 +1,58 @@ +/** + * Two-pass nested write pipeline — Pass 1. + * + * buildTree() (added in Task 2) walks the request body via resource-registry, + * normalizes payload, validates caps/cycles/string-ids, assigns ids, and + * returns a WriteTree consumed by mutation-compiler.ts. + */ + +export const MAX_DEPTH = 8; +export const MAX_NODES = 500; +export const MAX_ARRAY_LENGTH = 200; + +export type ValidationCode = + | 'DEPTH_EXCEEDED' + | 'TOO_MANY_NODES' + | 'ARRAY_TOO_LONG' + | 'CYCLE' + | 'STRING_ID_DEPRECATED' + | 'UNKNOWN_FIELD' + | 'TARGET_NOT_IMPLEMENTED'; + +export class ValidationError extends Error { + constructor( + public readonly code: ValidationCode, + public readonly path: string, + message: string, + public readonly httpStatus: number, + ) { + super(message); + this.name = 'ValidationError'; + } +} + +export interface WriteNode { + table: string; + id: string; + columns: Record; + junctions: JunctionEdge[]; + childFks: ChildFkEdge[]; + apiType?: string; +} + +export interface JunctionEdge { + apiFieldName: string; + junctionTable: string; + junctionRelName: string; + parentFkColumn: string; + targetFkColumn: string; + junctionColumns: Record[]; + children: WriteNode[]; +} + +export interface ChildFkEdge { + apiFieldName: string; + childTable: string; + childFkColumn: string; + children: WriteNode[]; +} From cd3ccaafd713a7b3f1ae86983d6a4b6938404065 Mon Sep 17 00:00:00 2001 From: Maximiliano Osorio Date: Sat, 9 May 2026 13:34:01 -0400 Subject: [PATCH 03/46] feat(nested-tree): buildTree for single-level junction relationships --- src/mappers/__tests__/nested-tree.test.ts | 62 +++++++ src/mappers/nested-tree.ts | 197 ++++++++++++++++++++++ 2 files changed, 259 insertions(+) diff --git a/src/mappers/__tests__/nested-tree.test.ts b/src/mappers/__tests__/nested-tree.test.ts index 3ce5833..a7fd32e 100644 --- a/src/mappers/__tests__/nested-tree.test.ts +++ b/src/mappers/__tests__/nested-tree.test.ts @@ -8,6 +8,8 @@ import { type JunctionEdge, type ChildFkEdge, } from '../nested-tree.js'; +import { buildTree } from '../nested-tree.js'; +import { getResourceConfig } from '../resource-registry.js'; describe('nested-tree types and constants', () => { it('exposes hard caps as numeric constants', () => { @@ -53,3 +55,63 @@ describe('nested-tree types and constants', () => { expect(child.childFkColumn).toBe('model_version_id'); }); }); + +describe('buildTree — single-level junction', () => { + it('builds tree for ModelConfiguration with hasInput id-only payload', () => { + const cfg = getResourceConfig('modelconfigurations')!; + const body = { + id: 'cfg-1', + label: 'my config', + hasInput: [{ id: 'ds-existing-1' }], + }; + + const tree = buildTree(body, cfg); + + expect(tree.table).toBe('modelcatalog_configuration'); + expect(tree.id).toBe('https://w3id.org/okn/i/mint/cfg-1'); + expect(tree.columns).toEqual({ label: 'my config' }); + expect(tree.junctions).toHaveLength(1); + + const j = tree.junctions[0]; + expect(j.apiFieldName).toBe('hasInput'); + expect(j.junctionTable).toBe('modelcatalog_configuration_input'); + expect(j.junctionRelName).toBe('input'); + expect(j.parentFkColumn).toBe('configuration_id'); + expect(j.targetFkColumn).toBe('input_id'); + expect(j.children).toHaveLength(1); + expect(j.children[0].id).toBe('https://w3id.org/okn/i/mint/ds-existing-1'); + expect(j.children[0].columns).toEqual({}); + expect(j.children[0].table).toBe('modelcatalog_dataset_specification'); + }); + + it('captures scalar fields on nested target entity (upsert path)', () => { + const cfg = getResourceConfig('modelconfigurations')!; + const body = { + id: 'cfg-2', + hasInput: [{ id: 'ds-2', label: 'updated label' }], + }; + const tree = buildTree(body, cfg); + expect(tree.junctions[0].children[0].columns).toEqual({ label: 'updated label' }); + }); + + it('captures junction extra columns (is_optional)', () => { + const cfg = getResourceConfig('modelconfigurations')!; + const body = { + id: 'cfg-3', + hasInput: [{ id: 'ds-3', isOptional: true }], + }; + const tree = buildTree(body, cfg); + expect(tree.junctions[0].junctionColumns).toEqual([{ is_optional: true }]); + expect(tree.junctions[0].children[0].columns).toEqual({}); + }); + + it('auto-generates id when nested entity lacks one', () => { + const cfg = getResourceConfig('modelconfigurations')!; + const body = { + id: 'cfg-4', + hasInput: [{ label: 'brand new ds' }], + }; + const tree = buildTree(body, cfg); + expect(tree.junctions[0].children[0].id).toMatch(/^https:\/\/w3id\.org\/okn\/i\/mint\/[0-9a-f-]{36}$/); + }); +}); diff --git a/src/mappers/nested-tree.ts b/src/mappers/nested-tree.ts index 4f2e497..306adcc 100644 --- a/src/mappers/nested-tree.ts +++ b/src/mappers/nested-tree.ts @@ -56,3 +56,200 @@ export interface ChildFkEdge { childFkColumn: string; children: WriteNode[]; } + +import { randomUUID } from 'crypto'; +import { FIELD_SELECTIONS } from '../hasura/field-maps.js'; +import { getResourceConfig, type ResourceConfig, type RelationshipConfig } from './resource-registry.js'; +import { camelToSnake } from './request.js'; + +const ID_PREFIX = 'https://w3id.org/okn/i/mint/'; + +interface BuildContext { + visited: Set; + nodeCount: { n: number }; + depth: number; + path: string; +} + +function getScalarColumns(tableName: string): Set { + const selection = FIELD_SELECTIONS[tableName]; + if (!selection) return new Set(); + const cols = new Set(); + for (const raw of selection.split('\n')) { + const line = raw.trim(); + if (!line || line.includes('{') || line.includes('}')) continue; + if (/^\w+$/.test(line)) cols.add(line); + } + return cols; +} + +function unwrapScalar(value: unknown): unknown { + if (Array.isArray(value)) { + if (value.length === 0) return null; + if (value.length === 1) { + const item = value[0]; + if (item !== null && typeof item === 'object') return null; + return item; + } + return value.filter((i) => i === null || typeof i !== 'object'); + } + if (value !== null && typeof value === 'object') return null; + return value; +} + +function resolveId(rawId: string | undefined): string { + if (!rawId) return `${ID_PREFIX}${randomUUID()}`; + return rawId.startsWith('https://') ? rawId : `${ID_PREFIX}${rawId}`; +} + +function resolveTargetFkColumn(rel: RelationshipConfig): string { + return (rel as { targetFkColumn?: string }).targetFkColumn ?? `${rel.junctionRelName!}_id`; +} + +function buildJunctionEdge( + apiFieldName: string, + rel: RelationshipConfig, + rawValue: unknown, + ctx: BuildContext, +): JunctionEdge | null { + if (!Array.isArray(rawValue)) return null; + const targetCfg = getResourceConfig(rel.targetResource); + if (!targetCfg?.hasuraTable) { + throw new ValidationError( + 'TARGET_NOT_IMPLEMENTED', + ctx.path + '/' + apiFieldName, + `target type ${rel.targetResource} not implemented`, + 501, + ); + } + const junctionExtraCamel = new Set(rel.junctionColumns ? Object.values(rel.junctionColumns) : []); + const children: WriteNode[] = []; + const junctionColumns: Record[] = []; + + rawValue.forEach((item, idx) => { + const itemPath = `${ctx.path}/${apiFieldName}/${idx}`; + if (typeof item === 'string') { + throw new ValidationError( + 'STRING_ID_DEPRECATED', + itemPath, + `string-id form deprecated; send [{id:'${item}'}] (field ${apiFieldName})`, + 400, + ); + } + if (item === null || typeof item !== 'object') { + throw new ValidationError( + 'UNKNOWN_FIELD', + itemPath, + `relationship items must be objects with id`, + 400, + ); + } + const childCtx: BuildContext = { + visited: new Set(ctx.visited), + nodeCount: ctx.nodeCount, + depth: ctx.depth + 1, + path: itemPath, + }; + const childNode = buildNode(item as Record, targetCfg, childCtx, junctionExtraCamel); + children.push(childNode); + + const extras: Record = {}; + if (rel.junctionColumns) { + for (const [colName, camelKey] of Object.entries(rel.junctionColumns)) { + if ((item as Record)[camelKey] !== undefined) { + extras[colName] = (item as Record)[camelKey]; + } + } + } + junctionColumns.push(extras); + }); + + return { + apiFieldName, + junctionTable: rel.junctionTable!, + junctionRelName: rel.junctionRelName!, + parentFkColumn: rel.parentFkColumn!, + targetFkColumn: resolveTargetFkColumn(rel), + junctionColumns, + children, + }; +} + +function buildNode( + body: Record, + cfg: ResourceConfig, + ctx: BuildContext, + excludeKeys: Set = new Set(), +): WriteNode { + if (ctx.depth > MAX_DEPTH) { + throw new ValidationError('DEPTH_EXCEEDED', ctx.path, `nested payload exceeds max depth ${MAX_DEPTH} at ${ctx.path}`, 400); + } + ctx.nodeCount.n += 1; + if (ctx.nodeCount.n > MAX_NODES) { + throw new ValidationError('TOO_MANY_NODES', ctx.path, `nested payload exceeds max nodes ${MAX_NODES} (got ${ctx.nodeCount.n})`, 413); + } + + const id = resolveId(body['id'] as string | undefined); + + if (ctx.visited.has(id)) { + throw new ValidationError('CYCLE', ctx.path, `cycle detected: id ${id} appears on its own ancestor path at ${ctx.path}`, 400); + } + ctx.visited.add(id); + + if (!cfg.hasuraTable) { + throw new ValidationError('TARGET_NOT_IMPLEMENTED', ctx.path, `target type has no Hasura table`, 501); + } + + const scalarCols = getScalarColumns(cfg.hasuraTable); + const relApiNames = new Set(Object.keys(cfg.relationships)); + const columns: Record = {}; + const junctions: JunctionEdge[] = []; + const childFks: ChildFkEdge[] = []; + + for (const [key, value] of Object.entries(body)) { + if (key === 'id' || key === 'type') continue; + if (excludeKeys.has(key)) continue; + + if (relApiNames.has(key)) { + if (Array.isArray(value) && value.length > MAX_ARRAY_LENGTH) { + throw new ValidationError( + 'ARRAY_TOO_LONG', + `${ctx.path}/${key}`, + `${key} array exceeds max length ${MAX_ARRAY_LENGTH} at ${ctx.path}`, + 413, + ); + } + const rel = cfg.relationships[key]; + if (rel.junctionTable && rel.junctionRelName && rel.parentFkColumn) { + const edge = buildJunctionEdge(key, rel, value, ctx); + if (edge) junctions.push(edge); + } + continue; + } + + const snake = camelToSnake(key); + if (!scalarCols.has(snake)) continue; + const unwrapped = unwrapScalar(value); + if (unwrapped === null || unwrapped === undefined) continue; + columns[snake] = unwrapped; + } + + return { + table: cfg.hasuraTable, + id, + columns, + junctions, + childFks, + apiType: cfg.typeName, + }; +} + +export function buildTree(body: Record, rootCfg: ResourceConfig): WriteNode { + const ctx: BuildContext = { + visited: new Set(), + nodeCount: { n: 0 }, + depth: 1, + path: '', + }; + return buildNode(body, rootCfg, ctx); +} From 7578b3234d7b47502d69c3cf3eceae9a70fc436a Mon Sep 17 00:00:00 2001 From: Maximiliano Osorio Date: Sat, 9 May 2026 13:38:01 -0400 Subject: [PATCH 04/46] test(nested-tree): assert multi-level recursion through junction edges --- src/mappers/__tests__/nested-tree.test.ts | 25 +++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/mappers/__tests__/nested-tree.test.ts b/src/mappers/__tests__/nested-tree.test.ts index a7fd32e..5a52b9d 100644 --- a/src/mappers/__tests__/nested-tree.test.ts +++ b/src/mappers/__tests__/nested-tree.test.ts @@ -115,3 +115,28 @@ describe('buildTree — single-level junction', () => { expect(tree.junctions[0].children[0].id).toMatch(/^https:\/\/w3id\.org\/okn\/i\/mint\/[0-9a-f-]{36}$/); }); }); + +describe('buildTree — recursion (multi-level)', () => { + it('walks 2 levels: ModelConfiguration > hasInput > hasPresentation', () => { + const cfg = getResourceConfig('modelconfigurations')!; + const body = { + id: 'cfg-deep', + hasInput: [ + { + id: 'ds-deep', + label: 'deep ds', + hasPresentation: [{ id: 'vp-1', label: 'pres' }], + }, + ], + }; + const tree = buildTree(body, cfg); + const ds = tree.junctions[0].children[0]; + expect(ds.id).toBe('https://w3id.org/okn/i/mint/ds-deep'); + expect(ds.columns).toEqual({ label: 'deep ds' }); + expect(ds.junctions.length).toBeGreaterThanOrEqual(1); + const pres = ds.junctions.find((j) => j.apiFieldName === 'hasPresentation'); + expect(pres).toBeDefined(); + expect(pres!.children[0].id).toBe('https://w3id.org/okn/i/mint/vp-1'); + expect(pres!.children[0].columns).toEqual({ label: 'pres' }); + }); +}); From 83a5a4cdeb562e3c2242072ca2f20742b125ebab Mon Sep 17 00:00:00 2001 From: Maximiliano Osorio Date: Sat, 9 May 2026 13:40:33 -0400 Subject: [PATCH 05/46] feat(nested-tree): add childFk edge branch with recursion --- src/mappers/__tests__/nested-tree.test.ts | 36 +++++++++++++++ src/mappers/nested-tree.ts | 54 +++++++++++++++++++++++ 2 files changed, 90 insertions(+) diff --git a/src/mappers/__tests__/nested-tree.test.ts b/src/mappers/__tests__/nested-tree.test.ts index 5a52b9d..0681b8c 100644 --- a/src/mappers/__tests__/nested-tree.test.ts +++ b/src/mappers/__tests__/nested-tree.test.ts @@ -140,3 +140,39 @@ describe('buildTree — recursion (multi-level)', () => { expect(pres!.children[0].columns).toEqual({ label: 'pres' }); }); }); + +describe('buildTree — childFk relationships', () => { + it('builds childFk edge for SoftwareVersion.hasConfiguration', () => { + const cfg = getResourceConfig('softwareversions')!; + const body = { + id: 'sv-1', + label: 'v1', + hasConfiguration: [{ id: 'cfg-a', label: 'A' }, { id: 'cfg-b' }], + }; + const tree = buildTree(body, cfg); + expect(tree.junctions).toHaveLength(0); + expect(tree.childFks).toHaveLength(1); + const c = tree.childFks[0]; + expect(c.apiFieldName).toBe('hasConfiguration'); + expect(c.childTable).toBe('modelcatalog_configuration'); + expect(c.childFkColumn).toBe('software_version_id'); + expect(c.children).toHaveLength(2); + expect(c.children[0].id).toBe('https://w3id.org/okn/i/mint/cfg-a'); + expect(c.children[0].columns).toEqual({ label: 'A' }); + expect(c.children[1].columns).toEqual({}); + }); + + it('recurses childFk children to grand-children', () => { + const cfg = getResourceConfig('softwareversions')!; + const body = { + id: 'sv-2', + hasConfiguration: [ + { id: 'cfg-x', hasInput: [{ id: 'ds-x' }] }, + ], + }; + const tree = buildTree(body, cfg); + const cfgNode = tree.childFks[0].children[0]; + expect(cfgNode.junctions).toHaveLength(1); + expect(cfgNode.junctions[0].children[0].id).toBe('https://w3id.org/okn/i/mint/ds-x'); + }); +}); diff --git a/src/mappers/nested-tree.ts b/src/mappers/nested-tree.ts index 306adcc..d3fc8a8 100644 --- a/src/mappers/nested-tree.ts +++ b/src/mappers/nested-tree.ts @@ -175,6 +175,57 @@ function buildJunctionEdge( }; } +function buildChildFkEdge( + apiFieldName: string, + rel: RelationshipConfig, + rawValue: unknown, + ctx: BuildContext, +): ChildFkEdge | null { + if (!Array.isArray(rawValue)) return null; + const targetCfg = getResourceConfig(rel.targetResource); + if (!targetCfg?.hasuraTable) { + throw new ValidationError( + 'TARGET_NOT_IMPLEMENTED', + ctx.path + '/' + apiFieldName, + `target type ${rel.targetResource} not implemented`, + 501, + ); + } + const children: WriteNode[] = []; + rawValue.forEach((item, idx) => { + const itemPath = `${ctx.path}/${apiFieldName}/${idx}`; + if (typeof item === 'string') { + throw new ValidationError( + 'STRING_ID_DEPRECATED', + itemPath, + `string-id form deprecated; send [{id:'${item}'}] (field ${apiFieldName})`, + 400, + ); + } + if (item === null || typeof item !== 'object') { + throw new ValidationError( + 'UNKNOWN_FIELD', + itemPath, + `relationship items must be objects with id`, + 400, + ); + } + const childCtx: BuildContext = { + visited: new Set(ctx.visited), + nodeCount: ctx.nodeCount, + depth: ctx.depth + 1, + path: itemPath, + }; + children.push(buildNode(item as Record, targetCfg, childCtx)); + }); + return { + apiFieldName, + childTable: targetCfg.hasuraTable, + childFkColumn: rel.childFkColumn!, + children, + }; +} + function buildNode( body: Record, cfg: ResourceConfig, @@ -223,6 +274,9 @@ function buildNode( if (rel.junctionTable && rel.junctionRelName && rel.parentFkColumn) { const edge = buildJunctionEdge(key, rel, value, ctx); if (edge) junctions.push(edge); + } else if (rel.childFkColumn) { + const edge = buildChildFkEdge(key, rel, value, ctx); + if (edge) childFks.push(edge); } continue; } From b3f41f8ebe0989754523e6e13d3517b68453315e Mon Sep 17 00:00:00 2001 From: Maximiliano Osorio Date: Sat, 9 May 2026 13:46:35 -0400 Subject: [PATCH 06/46] test(nested-tree): cover all 6 validation rules + sibling-repeat allowance Also extends buildTree with optional maxDepth override so DEPTH_EXCEEDED can be triggered in tests without exceeding the real resource graph depth. --- src/mappers/__tests__/nested-tree.test.ts | 72 +++++++++++++++++++++++ src/mappers/nested-tree.ts | 14 ++++- 2 files changed, 83 insertions(+), 3 deletions(-) diff --git a/src/mappers/__tests__/nested-tree.test.ts b/src/mappers/__tests__/nested-tree.test.ts index 0681b8c..961185e 100644 --- a/src/mappers/__tests__/nested-tree.test.ts +++ b/src/mappers/__tests__/nested-tree.test.ts @@ -7,6 +7,7 @@ import { type WriteNode, type JunctionEdge, type ChildFkEdge, + type BuildTreeOptions, } from '../nested-tree.js'; import { buildTree } from '../nested-tree.js'; import { getResourceConfig } from '../resource-registry.js'; @@ -176,3 +177,74 @@ describe('buildTree — childFk relationships', () => { expect(cfgNode.junctions[0].children[0].id).toBe('https://w3id.org/okn/i/mint/ds-x'); }); }); + +describe('buildTree — validation rules', () => { + it('rejects string-id array form with STRING_ID_DEPRECATED', () => { + const cfg = getResourceConfig('modelconfigurations')!; + expect(() => buildTree({ id: 'c1', hasInput: ['ds-1'] }, cfg)).toThrow( + expect.objectContaining({ + code: 'STRING_ID_DEPRECATED', + httpStatus: 400, + path: '/hasInput/0', + }), + ); + }); + + it('rejects array length over MAX_ARRAY_LENGTH with ARRAY_TOO_LONG', () => { + const cfg = getResourceConfig('modelconfigurations')!; + const big = Array.from({ length: 201 }, (_, i) => ({ id: `ds-${i}` })); + expect(() => buildTree({ id: 'c1', hasInput: big }, cfg)).toThrow( + expect.objectContaining({ code: 'ARRAY_TOO_LONG', httpStatus: 413 }), + ); + }); + + it('rejects depth over MAX_DEPTH with DEPTH_EXCEEDED', () => { + const cfg = getResourceConfig('modelconfigurations')!; + const body = { id: 'root', hasInput: [{ id: 'child-1' }] }; + // maxDepth:1 means root is at depth 1 (ok), child is at depth 2 > 1 → DEPTH_EXCEEDED + expect(() => buildTree(body, cfg, { maxDepth: 1 })).toThrow( + expect.objectContaining({ code: 'DEPTH_EXCEEDED', httpStatus: 400 }), + ); + }); + + it('rejects too many total nodes with TOO_MANY_NODES', () => { + const cfg = getResourceConfig('modelconfigurations')!; + const make = (n: number, prefix: string) => + Array.from({ length: n }, (_, i) => ({ id: `${prefix}-${i}` })); + const body = { + id: 'c-big', + hasInput: make(200, 'in'), + hasOutput: make(200, 'out'), + hasParameter: make(150, 'p'), + }; + expect(() => buildTree(body, cfg)).toThrow( + expect.objectContaining({ code: 'TOO_MANY_NODES', httpStatus: 413 }), + ); + }); + + it('detects cycles in ancestor path with CYCLE', () => { + const cfg = getResourceConfig('modelconfigurations')!; + const body = { + id: 'c-1', + hasInput: [ + { + id: 'ds-1', + hasPresentation: [{ id: 'c-1' }], + }, + ], + }; + expect(() => buildTree(body, cfg)).toThrow( + expect.objectContaining({ code: 'CYCLE', httpStatus: 400 }), + ); + }); + + it('allows sibling repeats (same id linked twice from same parent is legal)', () => { + const cfg = getResourceConfig('modelconfigurations')!; + const body = { + id: 'c-2', + hasInput: [{ id: 'ds-shared' }], + hasOutput: [{ id: 'ds-shared' }], + }; + expect(() => buildTree(body, cfg)).not.toThrow(); + }); +}); diff --git a/src/mappers/nested-tree.ts b/src/mappers/nested-tree.ts index d3fc8a8..05254c6 100644 --- a/src/mappers/nested-tree.ts +++ b/src/mappers/nested-tree.ts @@ -69,6 +69,7 @@ interface BuildContext { nodeCount: { n: number }; depth: number; path: string; + maxDepth: number; } function getScalarColumns(tableName: string): Set { @@ -149,6 +150,7 @@ function buildJunctionEdge( nodeCount: ctx.nodeCount, depth: ctx.depth + 1, path: itemPath, + maxDepth: ctx.maxDepth, }; const childNode = buildNode(item as Record, targetCfg, childCtx, junctionExtraCamel); children.push(childNode); @@ -215,6 +217,7 @@ function buildChildFkEdge( nodeCount: ctx.nodeCount, depth: ctx.depth + 1, path: itemPath, + maxDepth: ctx.maxDepth, }; children.push(buildNode(item as Record, targetCfg, childCtx)); }); @@ -232,8 +235,8 @@ function buildNode( ctx: BuildContext, excludeKeys: Set = new Set(), ): WriteNode { - if (ctx.depth > MAX_DEPTH) { - throw new ValidationError('DEPTH_EXCEEDED', ctx.path, `nested payload exceeds max depth ${MAX_DEPTH} at ${ctx.path}`, 400); + if (ctx.depth > ctx.maxDepth) { + throw new ValidationError('DEPTH_EXCEEDED', ctx.path, `nested payload exceeds max depth ${ctx.maxDepth} at ${ctx.path}`, 400); } ctx.nodeCount.n += 1; if (ctx.nodeCount.n > MAX_NODES) { @@ -298,12 +301,17 @@ function buildNode( }; } -export function buildTree(body: Record, rootCfg: ResourceConfig): WriteNode { +export interface BuildTreeOptions { + maxDepth?: number; +} + +export function buildTree(body: Record, rootCfg: ResourceConfig, opts: BuildTreeOptions = {}): WriteNode { const ctx: BuildContext = { visited: new Set(), nodeCount: { n: 0 }, depth: 1, path: '', + maxDepth: opts.maxDepth ?? MAX_DEPTH, }; return buildNode(body, rootCfg, ctx); } From 13699599f45ccb454659196e150c9c7d7954ac47 Mon Sep 17 00:00:00 2001 From: Maximiliano Osorio Date: Sat, 9 May 2026 13:50:43 -0400 Subject: [PATCH 07/46] feat(mutation-compiler): compilePost with dynamic update_columns and childFk handling --- .../__tests__/mutation-compiler.test.ts | 148 ++++++++++++++++++ src/mappers/mutation-compiler.ts | 94 +++++++++++ 2 files changed, 242 insertions(+) create mode 100644 src/mappers/__tests__/mutation-compiler.test.ts create mode 100644 src/mappers/mutation-compiler.ts diff --git a/src/mappers/__tests__/mutation-compiler.test.ts b/src/mappers/__tests__/mutation-compiler.test.ts new file mode 100644 index 0000000..fb8986e --- /dev/null +++ b/src/mappers/__tests__/mutation-compiler.test.ts @@ -0,0 +1,148 @@ +import { describe, it, expect } from 'vitest'; +import { compilePost } from '../mutation-compiler.js'; +import type { WriteNode } from '../nested-tree.js'; + +describe('compilePost', () => { + it('emits scalar-only insert when no relationships', () => { + const tree: WriteNode = { + table: 'modelcatalog_software', + id: 'https://w3id.org/okn/i/mint/sw-1', + columns: { label: 'foo' }, + junctions: [], + childFks: [], + }; + const { mutation, variables } = compilePost(tree); + expect(mutation).toMatch(/insert_modelcatalog_software_one/); + expect(mutation).toMatch(/object: \$object/); + expect(variables).toEqual({ + object: { id: 'https://w3id.org/okn/i/mint/sw-1', label: 'foo' }, + }); + }); + + it('emits nested junction insert with dynamic update_columns from columns keys', () => { + const tree: WriteNode = { + table: 'modelcatalog_configuration', + id: 'cfg-1', + columns: { label: 'cfg' }, + junctions: [ + { + apiFieldName: 'hasInput', + junctionTable: 'modelcatalog_configuration_input', + junctionRelName: 'input', + parentFkColumn: 'configuration_id', + targetFkColumn: 'input_id', + junctionColumns: [{}], + children: [ + { + table: 'modelcatalog_dataset_specification', + id: 'ds-1', + columns: { label: 'ds-label' }, + junctions: [], + childFks: [], + }, + ], + }, + ], + childFks: [], + }; + const { variables } = compilePost(tree); + const obj = (variables.object as Record); + expect(obj.id).toBe('cfg-1'); + expect(obj.label).toBe('cfg'); + const inputs = (obj.input as { data: unknown[]; on_conflict: { update_columns: string[] } }); + expect(inputs.on_conflict.update_columns).toEqual([]); + const inputRow = inputs.data[0] as Record; + const nested = inputRow.input as { data: any; on_conflict: { update_columns: string[] } }; + expect(nested.data.id).toBe('ds-1'); + expect(nested.data.label).toBe('ds-label'); + expect(nested.on_conflict.update_columns).toEqual(['label']); + expect(nested.on_conflict.constraint).toBe('modelcatalog_dataset_specification_pkey'); + }); + + it('emits link-only nested entity (empty columns) with update_columns:[]', () => { + const tree: WriteNode = { + table: 'modelcatalog_configuration', + id: 'cfg-2', + columns: {}, + junctions: [ + { + apiFieldName: 'hasInput', + junctionTable: 'modelcatalog_configuration_input', + junctionRelName: 'input', + parentFkColumn: 'configuration_id', + targetFkColumn: 'input_id', + junctionColumns: [{}], + children: [ + { + table: 'modelcatalog_dataset_specification', + id: 'ds-existing', + columns: {}, + junctions: [], + childFks: [], + }, + ], + }, + ], + childFks: [], + }; + const { variables } = compilePost(tree); + const obj = variables.object as Record; + const nested = (obj.input.data[0].input) as { on_conflict: { update_columns: string[] } }; + expect(nested.on_conflict.update_columns).toEqual([]); + }); + + it('applies junction extra columns to junction row', () => { + const tree: WriteNode = { + table: 'modelcatalog_configuration', + id: 'cfg-3', + columns: {}, + junctions: [ + { + apiFieldName: 'hasInput', + junctionTable: 'modelcatalog_configuration_input', + junctionRelName: 'input', + parentFkColumn: 'configuration_id', + targetFkColumn: 'input_id', + junctionColumns: [{ is_optional: true }], + children: [ + { table: 'modelcatalog_dataset_specification', id: 'ds', columns: {}, junctions: [], childFks: [] }, + ], + }, + ], + childFks: [], + }; + const { variables } = compilePost(tree); + const row = (variables.object as any).input.data[0]; + expect(row.is_optional).toBe(true); + }); + + it('emits childFk nested-array insert with FK column set on each child', () => { + const tree: WriteNode = { + table: 'modelcatalog_software_version', + id: 'sv-1', + columns: { label: 'v' }, + junctions: [], + childFks: [ + { + apiFieldName: 'hasConfiguration', + childTable: 'modelcatalog_model_configuration', + childFkColumn: 'software_version_id', + children: [ + { table: 'modelcatalog_model_configuration', id: 'cfg-a', columns: { label: 'A' }, junctions: [], childFks: [] }, + { table: 'modelcatalog_model_configuration', id: 'cfg-b', columns: {}, junctions: [], childFks: [] }, + ], + }, + ], + }; + const { variables } = compilePost(tree); + const obj = variables.object as any; + expect(obj.model_configurations).toBeDefined(); + const childKey = Object.keys(obj).find((k) => k !== 'id' && k !== 'label')!; + const arr = obj[childKey].data as any[]; + expect(arr.length).toBe(2); + expect(arr[0].software_version_id).toBe('sv-1'); + expect(arr[0].id).toBe('cfg-a'); + expect(arr[0].label).toBe('A'); + expect(arr[1].software_version_id).toBe('sv-1'); + }); +}); diff --git a/src/mappers/mutation-compiler.ts b/src/mappers/mutation-compiler.ts new file mode 100644 index 0000000..e5f227c --- /dev/null +++ b/src/mappers/mutation-compiler.ts @@ -0,0 +1,94 @@ +/** + * Two-pass nested write pipeline — Pass 2. + * + * compilePost() takes a WriteNode tree (from buildTree / Pass 1) and compiles + * it into a Hasura insert mutation string + variables object ready for Apollo. + */ + +import type { WriteNode, JunctionEdge, ChildFkEdge } from './nested-tree.js'; + +export interface CompiledMutation { + mutation: string; + variables: Record; +} + +function tableSuffix(table: string): string { + return table.replace('modelcatalog_', ''); +} + +function buildInsertObject(node: WriteNode): Record { + const obj: Record = { id: node.id, ...node.columns }; + + for (const j of node.junctions) { + obj[j.junctionRelName] = buildJunctionInsert(j); + } + for (const c of node.childFks) { + obj[childFkRelKey(c)] = buildChildFkInsert(c); + } + return obj; +} + +function buildJunctionInsert(j: JunctionEdge): Record { + const data = j.children.map((child, idx) => { + const row: Record = { + ...j.junctionColumns[idx], + [j.junctionRelName]: { + data: buildInsertObject(child), + on_conflict: { + constraint: `${child.table}_pkey`, + update_columns: Object.keys(child.columns), + }, + }, + }; + return row; + }); + return { + data, + on_conflict: { + constraint: `${j.junctionTable}_pkey`, + update_columns: [], + }, + }; +} + +function buildChildFkInsert(c: ChildFkEdge): Record { + const data = c.children.map((child) => buildInsertObject(child)); + return { + data, + on_conflict: { + constraint: `${c.childTable}_pkey`, + update_columns: c.children.flatMap((ch) => Object.keys(ch.columns)), + }, + }; +} + +function childFkRelKey(c: ChildFkEdge): string { + return tableSuffix(c.childTable) + 's'; +} + +export function compilePost(tree: WriteNode): CompiledMutation { + const object = buildInsertObject(tree); + + // Inject FK column values into childFk rows after the object is built + for (const c of tree.childFks) { + const arr = (object[childFkRelKey(c)] as { data: Record[] }).data; + arr.forEach((row, idx) => { + row[c.childFkColumn] = tree.id; + const childNode = c.children[idx]; + for (const subC of childNode.childFks) { + const subArr = (row[childFkRelKey(subC)] as { data: Record[] }).data; + subArr.forEach((subRow) => { + subRow[subC.childFkColumn] = childNode.id; + }); + } + }); + } + + const suffix = tableSuffix(tree.table); + const mutation = ` + mutation CreateMutation($object: modelcatalog_${suffix}_insert_input!) { + insert_modelcatalog_${suffix}_one(object: $object) { id } + } + `; + return { mutation, variables: { object } }; +} From dd39fb326e6bb9a2d6f01d685bb2a1bdc324ae16 Mon Sep 17 00:00:00 2001 From: Maximiliano Osorio Date: Sat, 9 May 2026 13:55:54 -0400 Subject: [PATCH 08/46] feat(mutation-compiler): compilePut with replace-subtree semantics and dynamic update_columns --- .../__tests__/mutation-compiler.test.ts | 130 +++++++++++++++++- src/mappers/mutation-compiler.ts | 66 +++++++++ 2 files changed, 195 insertions(+), 1 deletion(-) diff --git a/src/mappers/__tests__/mutation-compiler.test.ts b/src/mappers/__tests__/mutation-compiler.test.ts index fb8986e..667e763 100644 --- a/src/mappers/__tests__/mutation-compiler.test.ts +++ b/src/mappers/__tests__/mutation-compiler.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { compilePost } from '../mutation-compiler.js'; +import { compilePost, compilePut } from '../mutation-compiler.js'; import type { WriteNode } from '../nested-tree.js'; describe('compilePost', () => { @@ -146,3 +146,131 @@ describe('compilePost', () => { expect(arr[1].software_version_id).toBe('sv-1'); }); }); + +describe('compilePut', () => { + it('emits simple update_*_by_pk when tree has only scalars', () => { + const tree: WriteNode = { + table: 'modelcatalog_software', + id: 'sw-1', + columns: { label: 'updated' }, + junctions: [], + childFks: [], + }; + const { mutation, variables } = compilePut(tree); + expect(mutation).toMatch(/update_modelcatalog_software_by_pk/); + expect(mutation).toMatch(/_set: \$set/); + expect(variables).toEqual({ id: 'sw-1', set: { label: 'updated' } }); + }); + + it('emits delete + insert pair per junction edge with replace semantics', () => { + const tree: WriteNode = { + table: 'modelcatalog_configuration', + id: 'cfg-1', + columns: { label: 'c' }, + junctions: [ + { + apiFieldName: 'hasInput', + junctionTable: 'modelcatalog_configuration_input', + junctionRelName: 'input', + parentFkColumn: 'configuration_id', + targetFkColumn: 'input_id', + junctionColumns: [{}], + children: [ + { table: 'modelcatalog_dataset_specification', id: 'ds-new', columns: { label: 'new' }, junctions: [], childFks: [] }, + ], + }, + ], + childFks: [], + }; + const { mutation, variables } = compilePut(tree); + expect(mutation).toMatch(/del_inputs:\s*delete_modelcatalog_configuration_input/); + expect(mutation).toMatch(/where:\s*\{\s*configuration_id:\s*\{\s*_eq:\s*\$id\s*\}/); + expect(mutation).toMatch(/ins_inputs:\s*insert_modelcatalog_configuration_input/); + const juncVar = variables.junc_inputs as Record[]; + expect(juncVar).toHaveLength(1); + const row = juncVar[0] as any; + expect(row.input.data.id).toBe('ds-new'); + expect(row.input.data.label).toBe('new'); + expect(row.input.on_conflict.update_columns).toEqual(['label']); + }); + + it('uses targetFkColumn from edge (bug-087 fold-in)', () => { + const tree: WriteNode = { + table: 'modelcatalog_configuration', + id: 'cfg-2', + columns: {}, + junctions: [ + { + apiFieldName: 'hasInput', + junctionTable: 'modelcatalog_configuration_input', + junctionRelName: 'input', + parentFkColumn: 'configuration_id', + targetFkColumn: 'input_id', + junctionColumns: [{}], + children: [ + { table: 'modelcatalog_dataset_specification', id: 'ds-1', columns: {}, junctions: [], childFks: [] }, + ], + }, + ], + childFks: [], + }; + const { variables } = compilePut(tree); + const row = (variables.junc_inputs as any[])[0]; + if (row.input_id !== undefined) { + expect(row.input_id).toBe('ds-1'); + } else { + expect(row.input.data.id).toBe('ds-1'); + } + }); + + it('emits clear+upsert pair for childFk edges', () => { + const tree: WriteNode = { + table: 'modelcatalog_software_version', + id: 'sv-1', + columns: {}, + junctions: [], + childFks: [ + { + apiFieldName: 'hasConfiguration', + childTable: 'modelcatalog_model_configuration', + childFkColumn: 'software_version_id', + children: [ + { table: 'modelcatalog_model_configuration', id: 'cfg-a', columns: { label: 'A' }, junctions: [], childFks: [] }, + ], + }, + ], + }; + const { mutation, variables } = compilePut(tree); + expect(mutation).toMatch(/clear_model_configurations:\s*update_modelcatalog_model_configuration/); + expect(mutation).toMatch(/_in:\s*\$child_ids_model_configurations/); + expect(mutation).toMatch(/upsert_model_configurations:\s*insert_modelcatalog_model_configuration/); + expect(variables.child_ids_model_configurations).toEqual(['cfg-a']); + const upsertObjs = variables.child_model_configurations as any[]; + expect(upsertObjs[0].id).toBe('cfg-a'); + expect(upsertObjs[0].software_version_id).toBe('sv-1'); + expect(upsertObjs[0].label).toBe('A'); + }); + + it('hoists complex objects into variables (no JSON in mutation string)', () => { + const tree: WriteNode = { + table: 'modelcatalog_configuration', + id: 'cfg-3', + columns: {}, + junctions: [ + { + apiFieldName: 'hasInput', + junctionTable: 'modelcatalog_configuration_input', + junctionRelName: 'input', + parentFkColumn: 'configuration_id', + targetFkColumn: 'input_id', + junctionColumns: [{}], + children: [{ table: 'modelcatalog_dataset_specification', id: 'ds', columns: {}, junctions: [], childFks: [] }], + }, + ], + childFks: [], + }; + const { mutation } = compilePut(tree); + expect(mutation).not.toMatch(/"id":\s*"ds"/); + expect(mutation).toMatch(/objects:\s*\$junc_inputs/); + }); +}); diff --git a/src/mappers/mutation-compiler.ts b/src/mappers/mutation-compiler.ts index e5f227c..b6dff11 100644 --- a/src/mappers/mutation-compiler.ts +++ b/src/mappers/mutation-compiler.ts @@ -66,6 +66,72 @@ function childFkRelKey(c: ChildFkEdge): string { return tableSuffix(c.childTable) + 's'; } +function buildPutJunctionRow(j: JunctionEdge, idx: number): Record { + const child = j.children[idx]; + const row: Record = { ...j.junctionColumns[idx] }; + row[j.junctionRelName] = { + data: buildInsertObject(child), + on_conflict: { + constraint: `${child.table}_pkey`, + update_columns: Object.keys(child.columns), + }, + }; + return row; +} + +export function compilePut(tree: WriteNode): CompiledMutation { + const suffix = tableSuffix(tree.table); + const variables: Record = { id: tree.id, set: tree.columns }; + const parts: string[] = [ + `update_modelcatalog_${suffix}_by_pk(pk_columns: { id: $id }, _set: $set) { id }`, + ]; + const varDecls: string[] = [`$id: String!`, `$set: modelcatalog_${suffix}_set_input!`]; + + for (const j of tree.junctions) { + const juncSuffix = tableSuffix(j.junctionTable); + parts.push( + `del_${j.junctionRelName}s: delete_modelcatalog_${juncSuffix}(where: { ${j.parentFkColumn}: { _eq: $id } }) { affected_rows }`, + ); + if (j.children.length > 0) { + const varName = `junc_${j.junctionRelName}s`; + const objects = j.children.map((_, i) => buildPutJunctionRow(j, i)); + variables[varName] = objects; + varDecls.push(`$${varName}: [modelcatalog_${juncSuffix}_insert_input!]!`); + parts.push( + `ins_${j.junctionRelName}s: insert_modelcatalog_${juncSuffix}(objects: $${varName}, on_conflict: { constraint: modelcatalog_${juncSuffix}_pkey, update_columns: [] }) { affected_rows }`, + ); + } + } + + for (const c of tree.childFks) { + const childSuffix = tableSuffix(c.childTable); + const childSuffixPlural = `${childSuffix}s`; + const idsVar = `child_ids_${childSuffixPlural}`; + const objsVar = `child_${childSuffixPlural}`; + const ids = c.children.map((ch) => ch.id); + const objects = c.children.map((ch) => ({ ...buildInsertObject(ch), [c.childFkColumn]: tree.id })); + variables[idsVar] = ids; + variables[objsVar] = objects; + varDecls.push(`$${idsVar}: [String!]!`); + varDecls.push(`$${objsVar}: [modelcatalog_${childSuffix}_insert_input!]!`); + const updateCols = c.children.flatMap((ch) => Object.keys(ch.columns)); + const updateColsStr = updateCols.length > 0 ? updateCols.join(', ') : ''; + parts.push( + `clear_${childSuffixPlural}: update_modelcatalog_${childSuffix}(where: { ${c.childFkColumn}: { _eq: $id }, id: { _in: $${idsVar} } }, _set: { ${c.childFkColumn}: null }) { affected_rows }`, + ); + parts.push( + `upsert_${childSuffixPlural}: insert_modelcatalog_${childSuffix}(objects: $${objsVar}, on_conflict: { constraint: modelcatalog_${childSuffix}_pkey, update_columns: [${updateColsStr}] }) { affected_rows }`, + ); + } + + const mutation = ` + mutation UpdateMutation(${varDecls.join(', ')}) { + ${parts.join('\n ')} + } + `; + return { mutation, variables }; +} + export function compilePost(tree: WriteNode): CompiledMutation { const object = buildInsertObject(tree); From 168efce076fee6979eb34ec2d7378a20027ddb8b Mon Sep 17 00:00:00 2001 From: Maximiliano Osorio Date: Sat, 9 May 2026 13:57:10 -0400 Subject: [PATCH 09/46] fix(mutation-compiler): clear childFk uses _nin (orphan-prune) not _in --- src/mappers/__tests__/mutation-compiler.test.ts | 2 +- src/mappers/mutation-compiler.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/mappers/__tests__/mutation-compiler.test.ts b/src/mappers/__tests__/mutation-compiler.test.ts index 667e763..b758eac 100644 --- a/src/mappers/__tests__/mutation-compiler.test.ts +++ b/src/mappers/__tests__/mutation-compiler.test.ts @@ -242,7 +242,7 @@ describe('compilePut', () => { }; const { mutation, variables } = compilePut(tree); expect(mutation).toMatch(/clear_model_configurations:\s*update_modelcatalog_model_configuration/); - expect(mutation).toMatch(/_in:\s*\$child_ids_model_configurations/); + expect(mutation).toMatch(/_nin:\s*\$child_ids_model_configurations/); expect(mutation).toMatch(/upsert_model_configurations:\s*insert_modelcatalog_model_configuration/); expect(variables.child_ids_model_configurations).toEqual(['cfg-a']); const upsertObjs = variables.child_model_configurations as any[]; diff --git a/src/mappers/mutation-compiler.ts b/src/mappers/mutation-compiler.ts index b6dff11..07cb686 100644 --- a/src/mappers/mutation-compiler.ts +++ b/src/mappers/mutation-compiler.ts @@ -117,7 +117,7 @@ export function compilePut(tree: WriteNode): CompiledMutation { const updateCols = c.children.flatMap((ch) => Object.keys(ch.columns)); const updateColsStr = updateCols.length > 0 ? updateCols.join(', ') : ''; parts.push( - `clear_${childSuffixPlural}: update_modelcatalog_${childSuffix}(where: { ${c.childFkColumn}: { _eq: $id }, id: { _in: $${idsVar} } }, _set: { ${c.childFkColumn}: null }) { affected_rows }`, + `clear_${childSuffixPlural}: update_modelcatalog_${childSuffix}(where: { ${c.childFkColumn}: { _eq: $id }, id: { _nin: $${idsVar} } }, _set: { ${c.childFkColumn}: null }) { affected_rows }`, ); parts.push( `upsert_${childSuffixPlural}: insert_modelcatalog_${childSuffix}(objects: $${objsVar}, on_conflict: { constraint: modelcatalog_${childSuffix}_pkey, update_columns: [${updateColsStr}] }) { affected_rows }`, From e764653d43ad964d3aa9e40466c3df2e6a4ef76f Mon Sep 17 00:00:00 2001 From: Maximiliano Osorio Date: Sat, 9 May 2026 14:23:06 -0400 Subject: [PATCH 10/46] fix(mutation-compiler): dedup update_columns to avoid Hasura duplicate-column error --- src/mappers/mutation-compiler.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/mappers/mutation-compiler.ts b/src/mappers/mutation-compiler.ts index 07cb686..a4eecbc 100644 --- a/src/mappers/mutation-compiler.ts +++ b/src/mappers/mutation-compiler.ts @@ -57,7 +57,7 @@ function buildChildFkInsert(c: ChildFkEdge): Record { data, on_conflict: { constraint: `${c.childTable}_pkey`, - update_columns: c.children.flatMap((ch) => Object.keys(ch.columns)), + update_columns: [...new Set(c.children.flatMap((ch) => Object.keys(ch.columns)))], }, }; } @@ -114,7 +114,7 @@ export function compilePut(tree: WriteNode): CompiledMutation { variables[objsVar] = objects; varDecls.push(`$${idsVar}: [String!]!`); varDecls.push(`$${objsVar}: [modelcatalog_${childSuffix}_insert_input!]!`); - const updateCols = c.children.flatMap((ch) => Object.keys(ch.columns)); + const updateCols = [...new Set(c.children.flatMap((ch) => Object.keys(ch.columns)))]; const updateColsStr = updateCols.length > 0 ? updateCols.join(', ') : ''; parts.push( `clear_${childSuffixPlural}: update_modelcatalog_${childSuffix}(where: { ${c.childFkColumn}: { _eq: $id }, id: { _nin: $${idsVar} } }, _set: { ${c.childFkColumn}: null }) { affected_rows }`, From d3f6fd9950e312dd212249bb1e40d377de1a6637 Mon Sep 17 00:00:00 2001 From: Maximiliano Osorio Date: Sat, 9 May 2026 15:21:49 -0400 Subject: [PATCH 11/46] feat(resource-registry): add optional targetFkColumn override for junction FK --- src/mappers/resource-registry.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/mappers/resource-registry.ts b/src/mappers/resource-registry.ts index 50f2bab..fdd5999 100644 --- a/src/mappers/resource-registry.ts +++ b/src/mappers/resource-registry.ts @@ -22,6 +22,12 @@ export interface RelationshipConfig { junctionRelName?: string; /** FK column name in the junction table pointing back to the parent entity. Required when junctionTable is set. */ parentFkColumn?: string; + /** + * Optional override for the target-entity FK column on the junction row. + * Defaults to `${junctionRelName}_id` (current convention). + * Set explicitly when the convention does not match the schema (bug-087 fold-in). + */ + targetFkColumn?: string; /** * FK column name on the child entity table pointing back to this parent (one-to-many FK relationships). * Set on parent.relationships[X] when the relationship is materialized as a direct FK on the child row, From 54d4f3e9aae864c79eb3a37c40a1041a00949a89 Mon Sep 17 00:00:00 2001 From: Maximiliano Osorio Date: Sat, 9 May 2026 15:27:47 -0400 Subject: [PATCH 12/46] refactor(service): create() uses buildTree + compilePost pipeline --- src/__tests__/integration.test.ts | 16 ++-- src/service.ts | 122 +++++++++--------------------- 2 files changed, 47 insertions(+), 91 deletions(-) diff --git a/src/__tests__/integration.test.ts b/src/__tests__/integration.test.ts index 06edfb5..07f2ff1 100644 --- a/src/__tests__/integration.test.ts +++ b/src/__tests__/integration.test.ts @@ -611,7 +611,7 @@ describe('PUT model with hasVersion sets software_id on child rows', () => { describe('POST software with hasVersion links existing version rows', () => { beforeEach(() => { mockMutate.mockReset() }) - it('emits insert + link_versions update with software_id = parentId', async () => { + it('emits nested-insert for childFk relation with software_id injected into child row', async () => { mockMutate.mockResolvedValueOnce({ data: { insert_modelcatalog_software_one: { id: 'https://w3id.org/okn/i/mint/NEW-1' }, @@ -633,10 +633,16 @@ describe('POST software with hasVersion links existing version rows', () => { expect(mockMutate).toHaveBeenCalledOnce() const args = mockMutate.mock.calls[0][0] const m = typeof args.mutation === 'string' ? args.mutation : args.mutation?.loc?.source?.body ?? '' + // New pipeline: childFkColumn handled via nested insert inside $object (not a separate UPDATE root) expect(m).toContain('insert_modelcatalog_software_one') - expect(m).toContain('link_versions: update_modelcatalog_software_version') - expect(m).toContain('software_id: $parentId') - expect(args.variables.parentId).toBe('https://w3id.org/okn/i/mint/NEW-1') - expect(args.variables.child_ids_versions).toEqual(['https://w3id.org/okn/i/mint/V-99']) + expect(m).not.toContain('link_versions') + const obj = args.variables.object as Record + expect(obj.id).toBe('https://w3id.org/okn/i/mint/NEW-1') + // child FK rows are embedded in the object under the Hasura relation key + const childKey = Object.keys(obj).find((k) => k !== 'id' && k !== 'label' && k !== 'type')! + const childData = obj[childKey].data as any[] + expect(childData).toHaveLength(1) + expect(childData[0].id).toBe('https://w3id.org/okn/i/mint/V-99') + expect(childData[0].software_id).toBe('https://w3id.org/okn/i/mint/NEW-1') }) }) diff --git a/src/service.ts b/src/service.ts index afeae1e..bfe947c 100644 --- a/src/service.ts +++ b/src/service.ts @@ -11,7 +11,9 @@ import { randomUUID } from 'crypto' import { getResourceConfig } from './mappers/resource-registry.js' import { transformRow, transformList } from './mappers/response.js' -import { toHasuraInput, buildJunctionInserts } from './mappers/request.js' +import { toHasuraInput } from './mappers/request.js' +import { buildTree } from './mappers/nested-tree.js' +import { compilePost, compilePut } from './mappers/mutation-compiler.js' import { readClient, getWriteClient, gql } from './hasura/client.js' import { getFieldSelection } from './hasura/field-maps.js' import { customHandlers } from './custom-handlers.js' @@ -185,109 +187,57 @@ class CatalogServiceImpl { return } - const body = req.body || {} - const input = toHasuraInput(body as Record, resourceConfig) - - // Only set the type column for resources stored in modelcatalog_software, which is the - // only table with a `type` column (used to distinguish Model subtypes like - // sdm#Model, sdm#EmpiricalModel, sd#Software, etc.). - // Other tables (modelcatalog_model_configuration, modelcatalog_software_version, etc.) - // lack this column and must NOT receive a type field in their INSERT inputs. - if (resourceConfig.hasuraTable === 'modelcatalog_software') { - input['type'] = resourceConfig.typeUri - } - - // Generate a URI-based ID if not provided - if (!input['id']) { - input['id'] = `${ID_PREFIX}${randomUUID()}` + const authHeader = req.headers?.authorization + if (!authHeader) { + reply.code(401).send({ error: 'Authorization header required' }) + return } - // Build junction insert data for relationship fields (D-01, D-03, D-06) - const junctionInserts = buildJunctionInserts(body as Record, resourceConfig) - - // Merge scalar input with junction nested inserts for atomic mutation (D-03) - const object = { ...input, ...junctionInserts } - - const tableSuffix = resourceConfig.hasuraTable.replace('modelcatalog_', '') - - // FK-on-child relationships: link existing child rows back to this newly created parent - // by setting the child's FK column. Multi-root mutation runs alongside the parent insert. - const parentId = input['id'] as string - const childFkParts: string[] = [] - const childFkVarDecls: string[] = [] - const childFkVariables: Record = {} - for (const [apiFieldName, relConfig] of Object.entries(resourceConfig.relationships)) { - if (!relConfig.childFkColumn) continue - if (body[apiFieldName] === undefined) continue - const targetConfig = getResourceConfig(relConfig.targetResource) - if (!targetConfig?.hasuraTable) continue - - const rawValue = body[apiFieldName] - const items = Array.isArray(rawValue) ? (rawValue as unknown[]) : [] - const newIds = items - .map((item) => { - const rawId = - typeof item === 'string' - ? item - : ((item as Record) || {})['id'] - if (typeof rawId !== 'string' || !rawId) return null - return rawId.startsWith('https://') ? rawId : `${ID_PREFIX}${rawId}` - }) - .filter((x): x is string => !!x) - - if (newIds.length === 0) continue + const body = req.body || {} - const childSuffix = targetConfig.hasuraTable.replace('modelcatalog_', '') - const idsVar = `child_ids_${relConfig.hasuraRelName}` - childFkVariables[idsVar] = newIds - childFkVarDecls.push(`$${idsVar}: [String!]!`) - childFkParts.push( - `link_${relConfig.hasuraRelName}: update_modelcatalog_${childSuffix}(where: { id: { _in: $${idsVar} } }, _set: { ${relConfig.childFkColumn}: $parentId }) { affected_rows }` - ) + let tree + try { + tree = buildTree(body as Record, resourceConfig) + } catch (err: any) { + if (err && err.name === 'ValidationError') { + req.log.warn( + { verb: 'POST', resource, error_code: err.code, path: err.path }, + 'nested write validation failed', + ) + reply.code(err.httpStatus).send({ error: err.message, code: err.code, path: err.path }) + return + } + throw err } - let mutationStr: string - let mutationVariables: Record - if (childFkParts.length === 0) { - mutationStr = ` - mutation CreateMutation($object: modelcatalog_${tableSuffix}_insert_input!) { - insert_modelcatalog_${tableSuffix}_one(object: $object) { - id - } - } - ` - mutationVariables = { object } - } else { - const extraVarDecls = childFkVarDecls.length > 0 ? `, ${childFkVarDecls.join(', ')}` : '' - mutationStr = ` - mutation CreateWithChildFks($object: modelcatalog_${tableSuffix}_insert_input!, $parentId: String!${extraVarDecls}) { - insert_modelcatalog_${tableSuffix}_one(object: $object) { id } - ${childFkParts.join('\n ')} - } - ` - mutationVariables = { object, parentId, ...childFkVariables } + // Inject type column for software resources only (existing behavior) + if (resourceConfig.hasuraTable === 'modelcatalog_software') { + tree.columns['type'] = resourceConfig.typeUri } - const authHeader = req.headers?.authorization - if (!authHeader) { - reply.code(401).send({ error: 'Authorization header required' }) - return - } + const { mutation, variables } = compilePost(tree) try { const writeClient = getWriteClient(authHeader) const result = await writeClient.mutate({ - mutation: gql`${mutationStr}`, - variables: mutationVariables, + mutation: gql`${mutation}`, + variables, }) const data = result.data as Record | null + const tableSuffix = resourceConfig.hasuraTable.replace('modelcatalog_', '') const dataKey = `insert_modelcatalog_${tableSuffix}_one` const created = data?.[dataKey] as { id?: string } | undefined - const createdId = created?.id ?? (input['id'] as string) - reply.code(201).send({ id: createdId }) + reply.code(201).send({ id: created?.id ?? tree.id }) } catch (err: any) { req.log.error({ err }, 'GraphQL create mutation failed') const msg = err?.message || '' + if (msg.includes('Foreign key violation')) { + reply.code(400).send({ + error: 'FK violation — id may target wrong resource type', + details: msg, + }) + return + } if (msg.includes('uniqueness violation') || msg.includes('constraint')) { reply.code(400).send({ error: 'Constraint violation', details: msg }) return From af3dd91639a8282bf22dd78a6f93d01ad280fbe0 Mon Sep 17 00:00:00 2001 From: Maximiliano Osorio Date: Sat, 9 May 2026 15:35:48 -0400 Subject: [PATCH 13/46] fix(mutation-compiler): use hasuraRelName for childFk nested insert key --- src/__tests__/integration.test.ts | 6 +++--- src/mappers/__tests__/mutation-compiler.test.ts | 13 +++++++------ src/mappers/__tests__/nested-tree.test.ts | 3 +++ src/mappers/mutation-compiler.ts | 10 +++------- src/mappers/nested-tree.ts | 2 ++ 5 files changed, 18 insertions(+), 16 deletions(-) diff --git a/src/__tests__/integration.test.ts b/src/__tests__/integration.test.ts index 07f2ff1..5a7d6c7 100644 --- a/src/__tests__/integration.test.ts +++ b/src/__tests__/integration.test.ts @@ -638,9 +638,9 @@ describe('POST software with hasVersion links existing version rows', () => { expect(m).not.toContain('link_versions') const obj = args.variables.object as Record expect(obj.id).toBe('https://w3id.org/okn/i/mint/NEW-1') - // child FK rows are embedded in the object under the Hasura relation key - const childKey = Object.keys(obj).find((k) => k !== 'id' && k !== 'label' && k !== 'type')! - const childData = obj[childKey].data as any[] + // child FK rows are embedded in the object under the Hasura relation key 'versions' + expect(obj.versions).toBeDefined() + const childData = obj.versions.data as any[] expect(childData).toHaveLength(1) expect(childData[0].id).toBe('https://w3id.org/okn/i/mint/V-99') expect(childData[0].software_id).toBe('https://w3id.org/okn/i/mint/NEW-1') diff --git a/src/mappers/__tests__/mutation-compiler.test.ts b/src/mappers/__tests__/mutation-compiler.test.ts index b758eac..57e8b03 100644 --- a/src/mappers/__tests__/mutation-compiler.test.ts +++ b/src/mappers/__tests__/mutation-compiler.test.ts @@ -125,20 +125,20 @@ describe('compilePost', () => { childFks: [ { apiFieldName: 'hasConfiguration', - childTable: 'modelcatalog_model_configuration', + hasuraRelName: 'configurations', + childTable: 'modelcatalog_configuration', childFkColumn: 'software_version_id', children: [ - { table: 'modelcatalog_model_configuration', id: 'cfg-a', columns: { label: 'A' }, junctions: [], childFks: [] }, - { table: 'modelcatalog_model_configuration', id: 'cfg-b', columns: {}, junctions: [], childFks: [] }, + { table: 'modelcatalog_configuration', id: 'cfg-a', columns: { label: 'A' }, junctions: [], childFks: [] }, + { table: 'modelcatalog_configuration', id: 'cfg-b', columns: {}, junctions: [], childFks: [] }, ], }, ], }; const { variables } = compilePost(tree); const obj = variables.object as any; - expect(obj.model_configurations).toBeDefined(); - const childKey = Object.keys(obj).find((k) => k !== 'id' && k !== 'label')!; - const arr = obj[childKey].data as any[]; + expect(obj.configurations).toBeDefined(); + const arr = obj.configurations.data as any[]; expect(arr.length).toBe(2); expect(arr[0].software_version_id).toBe('sv-1'); expect(arr[0].id).toBe('cfg-a'); @@ -232,6 +232,7 @@ describe('compilePut', () => { childFks: [ { apiFieldName: 'hasConfiguration', + hasuraRelName: 'configurations', childTable: 'modelcatalog_model_configuration', childFkColumn: 'software_version_id', children: [ diff --git a/src/mappers/__tests__/nested-tree.test.ts b/src/mappers/__tests__/nested-tree.test.ts index 961185e..f628964 100644 --- a/src/mappers/__tests__/nested-tree.test.ts +++ b/src/mappers/__tests__/nested-tree.test.ts @@ -47,6 +47,7 @@ describe('nested-tree types and constants', () => { }; const child: ChildFkEdge = { apiFieldName: 'hasConfiguration', + hasuraRelName: 'configurations', childTable: 'modelcatalog_model_configuration', childFkColumn: 'model_version_id', children: [], @@ -54,6 +55,7 @@ describe('nested-tree types and constants', () => { expect(node.id).toBe('https://w3id.org/okn/i/mint/x'); expect(junc.targetFkColumn).toBe('input_id'); expect(child.childFkColumn).toBe('model_version_id'); + expect(child.hasuraRelName).toBe('configurations'); }); }); @@ -155,6 +157,7 @@ describe('buildTree — childFk relationships', () => { expect(tree.childFks).toHaveLength(1); const c = tree.childFks[0]; expect(c.apiFieldName).toBe('hasConfiguration'); + expect(c.hasuraRelName).toBe('configurations'); expect(c.childTable).toBe('modelcatalog_configuration'); expect(c.childFkColumn).toBe('software_version_id'); expect(c.children).toHaveLength(2); diff --git a/src/mappers/mutation-compiler.ts b/src/mappers/mutation-compiler.ts index a4eecbc..a2ff0e9 100644 --- a/src/mappers/mutation-compiler.ts +++ b/src/mappers/mutation-compiler.ts @@ -23,7 +23,7 @@ function buildInsertObject(node: WriteNode): Record { obj[j.junctionRelName] = buildJunctionInsert(j); } for (const c of node.childFks) { - obj[childFkRelKey(c)] = buildChildFkInsert(c); + obj[c.hasuraRelName] = buildChildFkInsert(c); } return obj; } @@ -62,10 +62,6 @@ function buildChildFkInsert(c: ChildFkEdge): Record { }; } -function childFkRelKey(c: ChildFkEdge): string { - return tableSuffix(c.childTable) + 's'; -} - function buildPutJunctionRow(j: JunctionEdge, idx: number): Record { const child = j.children[idx]; const row: Record = { ...j.junctionColumns[idx] }; @@ -137,12 +133,12 @@ export function compilePost(tree: WriteNode): CompiledMutation { // Inject FK column values into childFk rows after the object is built for (const c of tree.childFks) { - const arr = (object[childFkRelKey(c)] as { data: Record[] }).data; + const arr = (object[c.hasuraRelName] as { data: Record[] }).data; arr.forEach((row, idx) => { row[c.childFkColumn] = tree.id; const childNode = c.children[idx]; for (const subC of childNode.childFks) { - const subArr = (row[childFkRelKey(subC)] as { data: Record[] }).data; + const subArr = (row[subC.hasuraRelName] as { data: Record[] }).data; subArr.forEach((subRow) => { subRow[subC.childFkColumn] = childNode.id; }); diff --git a/src/mappers/nested-tree.ts b/src/mappers/nested-tree.ts index 05254c6..862414b 100644 --- a/src/mappers/nested-tree.ts +++ b/src/mappers/nested-tree.ts @@ -52,6 +52,7 @@ export interface JunctionEdge { export interface ChildFkEdge { apiFieldName: string; + hasuraRelName: string; childTable: string; childFkColumn: string; children: WriteNode[]; @@ -223,6 +224,7 @@ function buildChildFkEdge( }); return { apiFieldName, + hasuraRelName: rel.hasuraRelName, childTable: targetCfg.hasuraTable, childFkColumn: rel.childFkColumn!, children, From 2afbe565b06c457b05f2ba173a105f1a7fb2e2f0 Mon Sep 17 00:00:00 2001 From: Maximiliano Osorio Date: Sat, 9 May 2026 17:29:02 -0400 Subject: [PATCH 14/46] refactor(service): update() uses buildTree + compilePut pipeline --- src/__tests__/integration.test.ts | 55 ++++----- src/service.ts | 180 ++++-------------------------- 2 files changed, 45 insertions(+), 190 deletions(-) diff --git a/src/__tests__/integration.test.ts b/src/__tests__/integration.test.ts index 5a7d6c7..3c035d5 100644 --- a/src/__tests__/integration.test.ts +++ b/src/__tests__/integration.test.ts @@ -511,17 +511,12 @@ describe('Custom handler plain-ID resolution', () => { describe('PUT model with hasVersion sets software_id on child rows', () => { beforeEach(() => { mockQuery.mockReset(); mockMutate.mockReset() }) - it('emits clear+link update_modelcatalog_software_version mutations with software_id', async () => { + // compilePut emits clear_ + upsert_ instead of + // the old clear_ + link_ pair. childSuffix = tableSuffix(childTable), + // so for modelcatalog_software_version the suffix is 'software_version' and plural is + // 'software_versions'. Response is now { id } only (no post-PUT fetch + transform). + it('emits clear+upsert update_modelcatalog_software_version mutations with software_id', async () => { mockMutate.mockResolvedValueOnce({ data: {} }) - mockQuery.mockResolvedValueOnce({ - data: { - modelcatalog_software_by_pk: { - id: 'https://w3id.org/okn/i/mint/MODEL-1', - label: 'M', - description: null, - }, - }, - }) const req = makeReq({ params: { id: 'MODEL-1' }, @@ -537,23 +532,22 @@ describe('PUT model with hasVersion sets software_id on child rows', () => { await (CatalogService as any).models_id_put(req, reply) expect(mockMutate).toHaveBeenCalledOnce() + // No post-PUT read query (new pipeline returns { id } directly) + expect(mockQuery).not.toHaveBeenCalled() const args = mockMutate.mock.calls[0][0] const m = typeof args.mutation === 'string' ? args.mutation : args.mutation?.loc?.source?.body ?? '' - expect(m).toContain('clear_versions: update_modelcatalog_software_version') - expect(m).toContain('link_versions: update_modelcatalog_software_version') + expect(m).toContain('clear_software_versions: update_modelcatalog_software_version') + expect(m).toContain('upsert_software_versions: insert_modelcatalog_software_version') expect(m).toContain('software_id: { _eq: $id }') - expect(m).toContain('software_id: $id') - expect(args.variables.child_ids_versions).toEqual(['https://w3id.org/okn/i/mint/V-1']) + expect(args.variables.child_ids_software_versions).toEqual(['https://w3id.org/okn/i/mint/V-1']) expect(args.variables.id).toBe('https://w3id.org/okn/i/mint/MODEL-1') + // Response shape: { id } only + expect(reply._status).toBe(200) + expect((reply._body as any).id).toBe('https://w3id.org/okn/i/mint/MODEL-1') }) - it('omits link branch when hasVersion is empty array (clear-only replace semantics)', async () => { + it('omits upsert branch when hasVersion is empty array (clear-only replace semantics)', async () => { mockMutate.mockResolvedValueOnce({ data: {} }) - mockQuery.mockResolvedValueOnce({ - data: { - modelcatalog_software_by_pk: { id: 'https://w3id.org/okn/i/mint/MODEL-2', label: 'M2', description: null }, - }, - }) const req = makeReq({ params: { id: 'MODEL-2' }, @@ -570,22 +564,15 @@ describe('PUT model with hasVersion sets software_id on child rows', () => { const args = mockMutate.mock.calls[0][0] const m = typeof args.mutation === 'string' ? args.mutation : args.mutation?.loc?.source?.body ?? '' - expect(m).toContain('clear_versions:') - expect(m).not.toContain('link_versions:') - expect(args.variables.child_ids_versions).toEqual([]) + // clear root always emitted; upsert root is still emitted (with empty objects array) + expect(m).toContain('clear_software_versions:') + expect(m).toContain('upsert_software_versions:') + expect(m).not.toContain('link_software_versions:') + expect(args.variables.child_ids_software_versions).toEqual([]) }) it('handles softwareversions.hasConfiguration -> software_version_id', async () => { mockMutate.mockResolvedValueOnce({ data: {} }) - mockQuery.mockResolvedValueOnce({ - data: { - modelcatalog_software_version_by_pk: { - id: 'https://w3id.org/okn/i/mint/V-1', - label: 'v1', - description: null, - }, - }, - }) const req = makeReq({ params: { id: 'V-1' }, @@ -601,10 +588,10 @@ describe('PUT model with hasVersion sets software_id on child rows', () => { const args = mockMutate.mock.calls[0][0] const m = typeof args.mutation === 'string' ? args.mutation : args.mutation?.loc?.source?.body ?? '' + // childSuffix for modelcatalog_configuration is 'configuration', plural 'configurations' expect(m).toContain('clear_configurations: update_modelcatalog_configuration') - expect(m).toContain('link_configurations: update_modelcatalog_configuration') + expect(m).toContain('upsert_configurations: insert_modelcatalog_configuration') expect(m).toContain('software_version_id: { _eq: $id }') - expect(m).toContain('software_version_id: $id') }) }) diff --git a/src/service.ts b/src/service.ts index bfe947c..777630d 100644 --- a/src/service.ts +++ b/src/service.ts @@ -8,10 +8,8 @@ * This replaces 230+ individual handler files with a single class. */ -import { randomUUID } from 'crypto' import { getResourceConfig } from './mappers/resource-registry.js' import { transformRow, transformList } from './mappers/response.js' -import { toHasuraInput } from './mappers/request.js' import { buildTree } from './mappers/nested-tree.js' import { compilePost, compilePut } from './mappers/mutation-compiler.js' import { readClient, getWriteClient, gql } from './hasura/client.js' @@ -260,180 +258,50 @@ class CatalogServiceImpl { return } - const id = decodeURIComponent(req.params.id) - const fullId = id.startsWith('https://') ? id : `${resourceConfig.idPrefix}${id}` - const body = req.body || {} - const input = toHasuraInput(body as Record, resourceConfig) - - const tableSuffix = resourceConfig.hasuraTable.replace('modelcatalog_', '') - const authHeader = req.headers?.authorization if (!authHeader) { reply.code(401).send({ error: 'Authorization header required' }) return } - // Identify junction relationships explicitly present in the request body (D-07: Pitfall 2 guard) - const junctionParts: string[] = [] - const variables: Record = { id: fullId, set: input } - - for (const [apiFieldName, relConfig] of Object.entries(resourceConfig.relationships)) { - if (!relConfig.junctionTable || !relConfig.junctionRelName || !relConfig.parentFkColumn) continue - // Only process junctions for relationship fields explicitly in the request body - if (body[apiFieldName] === undefined) continue - - const juncSuffix = relConfig.junctionTable.replace('modelcatalog_', '') - - // Step 1: Delete existing junction rows for this relationship (D-07: replace semantics) - junctionParts.push( - `del_${relConfig.hasuraRelName}: delete_modelcatalog_${juncSuffix}(where: { ${relConfig.parentFkColumn}: { _eq: $id } }) { affected_rows }` - ) - - // Step 2: Insert new junction rows with flat FK columns - const rawValue = body[apiFieldName] - if (!Array.isArray(rawValue) || rawValue.length === 0) continue - - const targetFkColumn = `${relConfig.junctionRelName}_id` - const varName = `junc_${relConfig.hasuraRelName}` - - const items = (rawValue as unknown[]).map((item: unknown) => - typeof item === 'string' ? { id: item } : (item as Record) - ) - - variables[varName] = items.map((item: Record) => { - const rawItemId = item['id'] as string | undefined - const targetId = rawItemId - ? rawItemId.startsWith('https://') ? rawItemId : `${ID_PREFIX}${rawItemId}` - : `${ID_PREFIX}${randomUUID()}` - const row: Record = { - [relConfig.parentFkColumn!]: fullId, - [targetFkColumn]: targetId, - } - if (relConfig.junctionColumns) { - for (const [colName, camelKey] of Object.entries(relConfig.junctionColumns)) { - if (item[camelKey] !== undefined) row[colName] = item[camelKey] - } - } - return row - }) - - const juncSuffix2 = relConfig.junctionTable.replace('modelcatalog_', '') - junctionParts.push( - `ins_${relConfig.hasuraRelName}: insert_modelcatalog_${juncSuffix2}(objects: $${varName}, on_conflict: { constraint: modelcatalog_${juncSuffix2}_pkey, update_columns: [] }) { affected_rows }` - ) - } - - // FK-on-child relationships: parent.has where child row carries an FK column - // pointing back to the parent (one-to-many). We replicate junction "replace" - // semantics by clearing the FK on rows previously linked to this parent that - // are not in the new list, then setting the FK on the rows in the new list. - const childFkParts: string[] = [] - const childFkVarDecls: string[] = [] - for (const [apiFieldName, relConfig] of Object.entries(resourceConfig.relationships)) { - if (!relConfig.childFkColumn) continue - if (body[apiFieldName] === undefined) continue - const targetConfig = getResourceConfig(relConfig.targetResource) - if (!targetConfig?.hasuraTable) continue - - const childSuffix = targetConfig.hasuraTable.replace('modelcatalog_', '') - const rawValue = body[apiFieldName] - const items = Array.isArray(rawValue) ? (rawValue as unknown[]) : [] - const newIds = items - .map((item) => { - const rawId = - typeof item === 'string' - ? item - : ((item as Record) || {})['id'] - if (typeof rawId !== 'string' || !rawId) return null - return rawId.startsWith('https://') ? rawId : `${ID_PREFIX}${rawId}` - }) - .filter((x): x is string => !!x) - - const idsVar = `child_ids_${relConfig.hasuraRelName}` - variables[idsVar] = newIds - childFkVarDecls.push(`$${idsVar}: [String!]!`) - - // Step 1: clear FK on rows previously linked to this parent that aren't in the new list - childFkParts.push( - `clear_${relConfig.hasuraRelName}: update_modelcatalog_${childSuffix}(where: { ${relConfig.childFkColumn}: { _eq: $id }, id: { _nin: $${idsVar} } }, _set: { ${relConfig.childFkColumn}: null }) { affected_rows }` - ) + const id = decodeURIComponent(req.params.id) + const fullId = id.startsWith('https://') ? id : `${resourceConfig.idPrefix}${id}` + const body = { ...(req.body || {}), id: fullId } - // Step 2: set FK on rows in the new list (only when non-empty -- _in: [] would still work but skip the no-op call) - if (newIds.length > 0) { - childFkParts.push( - `link_${relConfig.hasuraRelName}: update_modelcatalog_${childSuffix}(where: { id: { _in: $${idsVar} } }, _set: { ${relConfig.childFkColumn}: $id }) { affected_rows }` + let tree + try { + tree = buildTree(body as Record, resourceConfig) + } catch (err: any) { + if (err && err.name === 'ValidationError') { + req.log.warn( + { verb: 'PUT', resource, root_id: fullId, error_code: err.code, path: err.path }, + 'nested write validation failed', ) + reply.code(err.httpStatus).send({ error: err.message, code: err.code, path: err.path }) + return } + throw err } - // Build mutation string: simple _set if no junctions or child FK updates, multi-root otherwise (D-03) - let mutationStr: string - if (junctionParts.length === 0 && childFkParts.length === 0) { - mutationStr = ` - mutation UpdateMutation($id: String!, $set: modelcatalog_${tableSuffix}_set_input!) { - update_modelcatalog_${tableSuffix}_by_pk(pk_columns: { id: $id }, _set: $set) { - id - } - } - ` - } else { - // Build variable declarations for junction insert arrays - const juncVarDecls = Object.entries(resourceConfig.relationships) - .filter(([apiFieldName, relConfig]) => - relConfig.junctionTable && - relConfig.junctionRelName && - relConfig.parentFkColumn && - body[apiFieldName] !== undefined && - Array.isArray(body[apiFieldName]) && - (body[apiFieldName] as unknown[]).length > 0 - ) - .map(([, relConfig]) => { - const juncSuffix = relConfig.junctionTable!.replace('modelcatalog_', '') - return `$junc_${relConfig.hasuraRelName}: [modelcatalog_${juncSuffix}_insert_input!]!` - }) - .join(', ') - - const extraDecls = [juncVarDecls, childFkVarDecls.join(', ')].filter(Boolean).join(', ') - const extraVarDecls = extraDecls ? `, ${extraDecls}` : '' - const allParts = [...junctionParts, ...childFkParts] - mutationStr = ` - mutation UpdateWithJunctions($id: String!, $set: modelcatalog_${tableSuffix}_set_input!${extraVarDecls}) { - update_modelcatalog_${tableSuffix}_by_pk(pk_columns: { id: $id }, _set: $set) { id } - ${allParts.join('\n ')} - } - ` - } + const { mutation, variables } = compilePut(tree) try { const writeClient = getWriteClient(authHeader) await writeClient.mutate({ - mutation: gql`${mutationStr}`, + mutation: gql`${mutation}`, variables, }) - // Return updated object - const fields = getFieldSelection(resourceConfig.hasuraTable!) - const queryStr = ` - query GetUpdatedQuery($id: String!) { - modelcatalog_${tableSuffix}_by_pk(id: $id) { - ${fields} - } - } - ` - const fetchResult = await readClient.query({ - query: gql`${queryStr}`, - variables: { id: fullId }, - }) - const fetchData = fetchResult.data as Record - const dataKey = `modelcatalog_${tableSuffix}_by_pk` - const row = fetchData[dataKey] as Record | null - if (!row) { - reply.code(404).send({ error: 'Not found after update' }) - return - } - reply.code(200).send(transformRow(row, resourceConfig)) + reply.code(200).send({ id: fullId }) } catch (err: any) { req.log.error({ err }, 'GraphQL update mutation failed') const msg = err?.message || '' + if (msg.includes('Foreign key violation')) { + reply.code(400).send({ + error: 'FK violation — id may target wrong resource type', + details: msg, + }) + return + } if (msg.includes('uniqueness violation') || msg.includes('constraint')) { reply.code(400).send({ error: 'Constraint violation', details: msg }) return From f73b49d9ba77dc832ec144f449bdbbe197e492b1 Mon Sep 17 00:00:00 2001 From: Maximiliano Osorio Date: Sat, 9 May 2026 17:34:09 -0400 Subject: [PATCH 15/46] refactor(request): remove buildJunctionInserts (replaced by nested-tree pipeline) --- src/mappers/__tests__/request.test.ts | 195 +------------------------- src/mappers/request.ts | 106 +------------- 2 files changed, 2 insertions(+), 299 deletions(-) diff --git a/src/mappers/__tests__/request.test.ts b/src/mappers/__tests__/request.test.ts index a0c7b81..3c3de84 100644 --- a/src/mappers/__tests__/request.test.ts +++ b/src/mappers/__tests__/request.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { toHasuraInput, camelToSnake, buildJunctionInserts } from '../request.js'; +import { toHasuraInput, camelToSnake } from '../request.js'; import { getResourceConfig } from '../resource-registry.js'; // ============================================================================ @@ -201,196 +201,3 @@ describe('default type assignment via resourceConfig.typeUri', () => { }); }); -// ============================================================================ -// buildJunctionInserts -// ============================================================================ - -describe('buildJunctionInserts', () => { - const modelsConfig = getResourceConfig('models')!; - const svConfig = getResourceConfig('softwareversions')!; - const causalConfig = getResourceConfig('causaldiagrams')!; - - it('Test 1: produces correct nested insert structure for existing category with full URI ID', () => { - const body = { - hasModelCategory: [{ id: 'https://w3id.org/okn/i/mint/Economy', label: ['Economy'] }], - }; - const result = buildJunctionInserts(body, modelsConfig); - expect(result).toHaveProperty('categories'); - const categories = result['categories'] as Record; - expect(categories).toHaveProperty('data'); - expect(categories).toHaveProperty('on_conflict'); - const onConflict = categories['on_conflict'] as Record; - expect(onConflict['constraint']).toBe('modelcatalog_software_category_pkey'); - expect(onConflict['update_columns']).toEqual([]); - const data = categories['data'] as Record[]; - expect(data).toHaveLength(1); - const junctionRow = data[0] as Record; - expect(junctionRow).toHaveProperty('category'); - const category = junctionRow['category'] as Record; - expect(category).toHaveProperty('data'); - expect(category).toHaveProperty('on_conflict'); - const targetData = category['data'] as Record; - expect(targetData['id']).toBe('https://w3id.org/okn/i/mint/Economy'); - const targetConflict = category['on_conflict'] as Record; - expect(targetConflict['constraint']).toBe('modelcatalog_model_category_pkey'); - // Link-or-noop semantics: never overwrite columns on existing target rows. - // Previous value ['label'] caused null-label cascade (bug-087) when client - // sent only `{id}` -- nestedData lacked label and on_conflict overwrote - // the existing row's label with NULL. - expect(targetConflict['update_columns']).toEqual([]); - }); - - it('Test 2: generates UUID-based ID with https prefix when no ID provided', () => { - const body = { - hasModelCategory: [{ label: ['New Category'] }], - }; - const result = buildJunctionInserts(body, modelsConfig); - const data = (result['categories'] as Record)['data'] as Record[]; - const junctionRow = data[0] as Record; - const category = junctionRow['category'] as Record; - const targetData = category['data'] as Record; - expect(targetData['id']).toMatch(/^https:\/\/w3id\.org\/okn\/i\/mint\/[0-9a-f-]{36}$/); - }); - - it('Test 3: normalizes array-of-strings to array-of-objects', () => { - const body = { - hasModelCategory: ['https://w3id.org/okn/i/mint/Economy'], - }; - const result = buildJunctionInserts(body, modelsConfig); - const data = (result['categories'] as Record)['data'] as Record[]; - expect(data).toHaveLength(1); - const junctionRow = data[0] as Record; - const category = junctionRow['category'] as Record; - const targetData = category['data'] as Record; - expect(targetData['id']).toBe('https://w3id.org/okn/i/mint/Economy'); - }); - - it('Test 4: prepends idPrefix to short IDs (no https prefix)', () => { - const body = { - hasModelCategory: ['some-uuid'], - }; - const result = buildJunctionInserts(body, modelsConfig); - const data = (result['categories'] as Record)['data'] as Record[]; - const junctionRow = data[0] as Record; - const category = junctionRow['category'] as Record; - const targetData = category['data'] as Record; - expect(targetData['id']).toBe('https://w3id.org/okn/i/mint/some-uuid'); - }); - - it('Test 5: returns empty object when no junction fields are present in body', () => { - const body = { label: ['Test Model'] }; - const result = buildJunctionInserts(body, modelsConfig); - expect(result).toEqual({}); - }); - - it('Test 6: skips relationships without junctionRelName (causaldiagrams hasPart)', () => { - const body = { - hasPart: [{ id: 'https://w3id.org/okn/i/mint/some-var' }], - }; - const result = buildJunctionInserts(body, causalConfig); - // hasPart has no junctionRelName, so it should be skipped - expect(result).not.toHaveProperty('diagram_parts'); - expect(result).toEqual({}); - }); - - it('Test 7: handles multiple items in array producing multiple junction row entries', () => { - const body = { - hasModelCategory: [ - { id: 'https://w3id.org/okn/i/mint/Economy' }, - { id: 'https://w3id.org/okn/i/mint/Agriculture' }, - ], - }; - const result = buildJunctionInserts(body, modelsConfig); - const data = (result['categories'] as Record)['data'] as Record[]; - expect(data).toHaveLength(2); - const firstTarget = (data[0]['category'] as Record)['data'] as Record; - const secondTarget = (data[1]['category'] as Record)['data'] as Record; - expect(firstTarget['id']).toBe('https://w3id.org/okn/i/mint/Economy'); - expect(secondTarget['id']).toBe('https://w3id.org/okn/i/mint/Agriculture'); - }); - - it('Test 8: existing toHasuraInput tests still pass (regression - scalar output unchanged)', () => { - const result = toHasuraInput({ label: ['Test'], description: ['Desc'] }, modelsConfig); - expect(result).toEqual({ label: 'Test', description: 'Desc' }); - }); - - it('Test 9: maps camelCase scalar fields on nested objects to snake_case', () => { - const body = { - authors: [{ id: 'https://w3id.org/okn/i/mint/Person1', firstName: 'John', lastName: 'Doe' }], - }; - const result = buildJunctionInserts(body, modelsConfig); - const data = (result['authors'] as Record)['data'] as Record[]; - const personData = (data[0]['person'] as Record)['data'] as Record; - expect(personData['id']).toBe('https://w3id.org/okn/i/mint/Person1'); - expect(personData).toHaveProperty('first_name', 'John'); - expect(personData).toHaveProperty('last_name', 'Doe'); - }); - - it('Test 10: spreads is_optional=true from isOptional onto configuration_input junction row (D-21)', () => { - const configConfig = getResourceConfig('modelconfigurations')!; - const body = { - hasInput: [ - { id: 'https://w3id.org/okn/i/mint/SomeDataset', isOptional: true } - ], - }; - const result = buildJunctionInserts(body, configConfig); - const inputs = result['inputs'] as Record; - expect(inputs).toHaveProperty('data'); - const data = inputs['data'] as Record[]; - expect(data).toHaveLength(1); - const junctionRow = data[0] as Record; - // is_optional lives on the outer junction row (modelcatalog_configuration_input), - // NOT inside junctionRow['input']['data']. Verify it is at the top level: - expect(junctionRow).toHaveProperty('is_optional', true); - // Nested entity data should NOT have is_optional: - const nestedData = (junctionRow['input'] as Record)['data'] as Record; - expect(nestedData).not.toHaveProperty('is_optional'); - }); - - it('Regression bug-087: linking by string id alone never sets update_columns to overwrite target.label with NULL', () => { - const configConfig = getResourceConfig('modelconfigurations')!; - const body = { hasInput: ['https://w3id.org/okn/i/mint/existing-ds-id'] }; - const result = buildJunctionInserts(body, configConfig); - const inputs = result['inputs'] as Record; - const data = inputs['data'] as Record[]; - const junctionRow = data[0] as Record; - const nestedTarget = junctionRow['input'] as Record; - const nestedData = nestedTarget['data'] as Record; - // Client sent only an id -- nested data must contain only id, no label. - expect(Object.keys(nestedData)).toEqual(['id']); - expect(nestedData['id']).toBe('https://w3id.org/okn/i/mint/existing-ds-id'); - // Critical: on_conflict must NOT update label. Updating label with the - // missing-from-payload value would null-clobber the existing target row - // and trip the not-null constraint on dataset_specification.label. - const targetConflict = nestedTarget['on_conflict'] as Record; - expect(targetConflict['update_columns']).toEqual([]); - }); - - it('Regression bug-087: object-form link with only id likewise produces empty update_columns', () => { - const configConfig = getResourceConfig('modelconfigurations')!; - const body = { hasInput: [{ id: 'https://w3id.org/okn/i/mint/existing-ds-id' }] }; - const result = buildJunctionInserts(body, configConfig); - const inputs = result['inputs'] as Record; - const data = inputs['data'] as Record[]; - const junctionRow = data[0] as Record; - const nestedTarget = junctionRow['input'] as Record; - const targetConflict = nestedTarget['on_conflict'] as Record; - expect(targetConflict['update_columns']).toEqual([]); - }); - - it('Test 11: omits is_optional from junction row when isOptional is absent in request body (D-22)', () => { - const configConfig = getResourceConfig('modelconfigurations')!; - const body = { - hasInput: [ - { id: 'https://w3id.org/okn/i/mint/SomeDataset' } - ], - }; - const result = buildJunctionInserts(body, configConfig); - const inputs = result['inputs'] as Record; - const data = inputs['data'] as Record[]; - const junctionRow = data[0] as Record; - // When isOptional is not provided, the key should be absent (not defaulted to false) - // so that Postgres applies its own column default: - expect(junctionRow).not.toHaveProperty('is_optional'); - }); -}); diff --git a/src/mappers/request.ts b/src/mappers/request.ts index 55ea21f..9c672c1 100644 --- a/src/mappers/request.ts +++ b/src/mappers/request.ts @@ -9,11 +9,8 @@ * 5. Handle nested related objects by extracting their IDs for FK columns */ -import { randomUUID } from 'crypto'; import { FIELD_SELECTIONS } from '../hasura/field-maps.js'; -import { getResourceConfig, type ResourceConfig } from './resource-registry.js'; - -const ID_PREFIX = 'https://w3id.org/okn/i/mint/'; +import { type ResourceConfig } from './resource-registry.js'; /** Cache of parsed scalar column sets per table name */ const scalarColumnsCache = new Map>(); @@ -143,104 +140,3 @@ export function toHasuraInput( return result; } -/** - * Build Hasura nested insert objects for all junction-based relationships - * found in the request body. Per D-03, these are included in a single - * atomic mutation (not sequential inserts). Per D-04, on_conflict with - * update_columns:[] handles link-or-create. - * - * @param body - v1.8.0 JSON request body (camelCase keys) - * @param resourceConfig - The resource config for this resource type - * @returns Map of Hasura relationship names to nested insert objects - */ -export function buildJunctionInserts( - body: Record, - resourceConfig: ResourceConfig, -): Record { - const junctionData: Record = {}; - - for (const [apiFieldName, relConfig] of Object.entries(resourceConfig.relationships)) { - // Skip non-junction relationships (per D-06: only junction-based) - if (!relConfig.junctionTable || !relConfig.junctionRelName) continue; - - const rawValue = body[apiFieldName]; - if (rawValue === undefined || rawValue === null) continue; - - // Normalize: accept both array-of-objects and array-of-strings (Pitfall 6) - const items: Record[] = []; - if (Array.isArray(rawValue)) { - for (const item of rawValue) { - if (typeof item === 'string') { - items.push({ id: item }); - } else if (item !== null && typeof item === 'object') { - items.push(item as Record); - } - } - } - - // Resolve target resource config for the constraint name - const targetConfig = getResourceConfig(relConfig.targetResource); - const targetTable = targetConfig?.hasuraTable; - if (!targetTable) continue; // target has no backing table, skip - - junctionData[relConfig.hasuraRelName] = { - data: items.map((item) => { - const nestedData: Record = {}; - - // Resolve ID: full URI passes through, short ID gets prefix prepended - const rawId = item['id'] as string | undefined; - if (rawId) { - nestedData['id'] = rawId.startsWith('https://') ? rawId : `${ID_PREFIX}${rawId}`; - } else { - // D-02: generate UUID if no ID provided - nestedData['id'] = `${ID_PREFIX}${randomUUID()}`; - } - - // Build set of camelCase keys that belong to the junction row itself (not the nested entity) - const junctionCamelKeys = new Set( - relConfig.junctionColumns ? Object.values(relConfig.junctionColumns) : [] - ); - - // Copy scalar fields from nested object (camelCase -> snake_case) - // Skip 'id' (already handled), 'type' (not stored), and junction-row-level fields - for (const [key, value] of Object.entries(item)) { - if (key === 'id' || key === 'type') continue; - if (junctionCamelKeys.has(key)) continue; // junction column — goes on outer row, not nested entity - const snakeKey = camelToSnake(key); - const unwrapped = Array.isArray(value) - ? value.length === 1 - ? value[0] - : value.length === 0 - ? null - : value - : value; - if (unwrapped !== null && unwrapped !== undefined) { - nestedData[snakeKey] = unwrapped; - } - } - - const junctionRow: Record = { - [relConfig.junctionRelName!]: { - data: nestedData, - on_conflict: { - constraint: `${targetTable}_pkey`, - update_columns: [], - }, - }, - }; - if (relConfig.junctionColumns) { - for (const [colName, camelKey] of Object.entries(relConfig.junctionColumns)) { - if (item[camelKey] !== undefined) junctionRow[colName] = item[camelKey]; - } - } - return junctionRow; - }), - on_conflict: { - constraint: `${relConfig.junctionTable}_pkey`, - update_columns: [], - }, - }; - } - - return junctionData; -} From 687cdfc3bd93bd0ab6647929340eebb5e9d3772f Mon Sep 17 00:00:00 2001 From: Maximiliano Osorio Date: Sat, 9 May 2026 17:35:26 -0400 Subject: [PATCH 16/46] test(integration): nested write round-trip, replace-subtree, bug-087 link-only, FK error --- .../nested-writes-integration.test.ts | 138 ++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 src/mappers/__tests__/nested-writes-integration.test.ts diff --git a/src/mappers/__tests__/nested-writes-integration.test.ts b/src/mappers/__tests__/nested-writes-integration.test.ts new file mode 100644 index 0000000..117aa79 --- /dev/null +++ b/src/mappers/__tests__/nested-writes-integration.test.ts @@ -0,0 +1,138 @@ +import { describe, it, expect } from 'vitest'; +import { randomUUID } from 'crypto'; + +const API = process.env.MODEL_CATALOG_API_URL ?? 'http://localhost:3000/v2.0.0'; +const TOKEN = process.env.TEST_BEARER_TOKEN; + +const skipIfNoToken = TOKEN ? describe : describe.skip; + +skipIfNoToken('integration: recursive nested writes', () => { + const authHeader = { authorization: `Bearer ${TOKEN}` }; + const newId = (prefix: string) => `${prefix}-${randomUUID()}`; + + it('POST ModelConfiguration with nested DatasetSpecification + nested VariablePresentation persists all rows', async () => { + const cfgId = newId('cfg'); + const dsId = newId('ds'); + const vpId = newId('vp'); + const res = await fetch(`${API}/modelconfigurations`, { + method: 'POST', + headers: { 'content-type': 'application/json', ...authHeader }, + body: JSON.stringify({ + id: cfgId, + label: 'nested test cfg', + hasInput: [ + { + id: dsId, + label: 'nested ds', + hasPresentation: [{ id: vpId, label: 'nested vp' }], + }, + ], + }), + }); + expect(res.status).toBe(201); + + const got = await fetch(`${API}/modelconfigurations/${encodeURIComponent(cfgId)}`, { headers: authHeader }); + const cfg = await got.json(); + expect(cfg.label).toEqual(['nested test cfg']); + expect((cfg.hasInput ?? [])[0]?.id).toBe(dsId); + // The persisted hasPresentation is on the nested ds; fetch the ds: + const dsRes = await fetch(`${API}/datasetspecifications/${encodeURIComponent(dsId)}`, { headers: authHeader }); + const ds = await dsRes.json(); + expect((ds.hasPresentation ?? [])[0]?.id).toBe(vpId); + }); + + it('PUT ModelConfiguration replacing hasInput drops old junction rows and inserts new', async () => { + const cfgId = newId('cfg'); + const dsOld = newId('ds-old'); + const dsNew = newId('ds-new'); + + // Create with one input + let res = await fetch(`${API}/modelconfigurations`, { + method: 'POST', + headers: { 'content-type': 'application/json', ...authHeader }, + body: JSON.stringify({ id: cfgId, hasInput: [{ id: dsOld, label: 'old' }] }), + }); + expect(res.status).toBe(201); + + // PUT with a different input + res = await fetch(`${API}/modelconfigurations/${encodeURIComponent(cfgId)}`, { + method: 'PUT', + headers: { 'content-type': 'application/json', ...authHeader }, + body: JSON.stringify({ hasInput: [{ id: dsNew, label: 'new' }] }), + }); + expect(res.status).toBe(200); + + const got = await fetch(`${API}/modelconfigurations/${encodeURIComponent(cfgId)}`, { headers: authHeader }); + const cfg = await got.json(); + const inputIds = (cfg.hasInput ?? []).map((x: any) => x.id); + expect(inputIds).toContain(dsNew); + expect(inputIds).not.toContain(dsOld); + }); + + it('POST link-only payload does not clobber existing target scalars (bug-087 regression)', async () => { + // Pre-create a DatasetSpecification with a known label + const dsId = newId('ds-precreate'); + let res = await fetch(`${API}/datasetspecifications`, { + method: 'POST', + headers: { 'content-type': 'application/json', ...authHeader }, + body: JSON.stringify({ id: dsId, label: 'preserved label' }), + }); + expect(res.status).toBe(201); + + // Create a ModelConfiguration that LINKS to it (id-only payload) + const cfgId = newId('cfg'); + res = await fetch(`${API}/modelconfigurations`, { + method: 'POST', + headers: { 'content-type': 'application/json', ...authHeader }, + body: JSON.stringify({ id: cfgId, hasInput: [{ id: dsId }] }), + }); + expect(res.status).toBe(201); + + // Verify ds label preserved + const got = await fetch(`${API}/datasetspecifications/${encodeURIComponent(dsId)}`, { headers: authHeader }); + const ds = await got.json(); + expect(ds.label).toEqual(['preserved label']); + }); + + it('PUT FK violation on wrong-type id returns 400 with hint', async () => { + const cfgId = newId('cfg-fkfail'); + const vpId = newId('vp-wrongtype'); + + // Pre-create a VariablePresentation + let res = await fetch(`${API}/variablepresentations`, { + method: 'POST', + headers: { 'content-type': 'application/json', ...authHeader }, + body: JSON.stringify({ id: vpId, label: 'vp' }), + }); + expect(res.status).toBe(201); + + // Create a ModelConfiguration first + res = await fetch(`${API}/modelconfigurations`, { + method: 'POST', + headers: { 'content-type': 'application/json', ...authHeader }, + body: JSON.stringify({ id: cfgId, label: 'fk fail test' }), + }); + expect(res.status).toBe(201); + + // Attempt to PUT VP id where DatasetSpecification expected + res = await fetch(`${API}/modelconfigurations/${encodeURIComponent(cfgId)}`, { + method: 'PUT', + headers: { 'content-type': 'application/json', ...authHeader }, + body: JSON.stringify({ hasInput: [{ id: vpId }] }), + }); + expect(res.status).toBe(400); + const errBody = await res.json(); + expect(errBody.error).toMatch(/wrong resource type/); + }); + + it('rejects string-id form with 400 STRING_ID_DEPRECATED', async () => { + const res = await fetch(`${API}/modelconfigurations`, { + method: 'POST', + headers: { 'content-type': 'application/json', ...authHeader }, + body: JSON.stringify({ label: 'test', hasInput: ['some-ds-id'] }), + }); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.code).toBe('STRING_ID_DEPRECATED'); + }); +}); From fad232866e53fd715f1551bb2e859f740f923c85 Mon Sep 17 00:00:00 2001 From: Maximiliano Osorio Date: Sat, 9 May 2026 17:37:21 -0400 Subject: [PATCH 17/46] chore: bump to v2.1.0; openapi.yaml requires object form for relationships --- CHANGELOG.md | 18 ++++++++++++++++++ openapi.yaml | 2 +- package.json | 2 +- 3 files changed, 20 insertions(+), 2 deletions(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..4f68ac7 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,18 @@ +# Changelog + +## v2.1.0 — 2026-05-09 + +### Breaking changes + +- Relationship arrays no longer accept string-id form. Send objects: `hasInput: [{id: "..."}]`. Old form `hasInput: ["..."]` returns HTTP 400 `STRING_ID_DEPRECATED`. Migration: replace every `[string, ...]` array on relationship fields with `[{id: string}, ...]`. + +### New features + +- `POST` and `PUT` on every resource accept arbitrarily nested payloads (depth <= 8, total nodes <= 500, per-array length <= 200). Single atomic Hasura mutation per request. Replace-subtree semantics on `PUT`: payload IS the new state of every relationship at every depth. +- Dynamic `update_columns` per nested target row from supplied payload keys: id-only links without clobbering, id+scalars updates only those columns. + +### Fixes + +- bug-087: nested target on_conflict no longer clobbers existing rows when client sends only the id. +- bug-087 (PUT): junction FK column resolution from `resource-registry` (with optional `targetFkColumn` override). Hasura FK violations on writes now surface as 400 with `"id may target wrong resource type"` hint. +- bug-089: no parity gap between POST and PUT for nested writes. diff --git a/openapi.yaml b/openapi.yaml index 7da3d65..76061d4 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -2,7 +2,7 @@ openapi: 3.0.1 info: description: "This is the API of the Software Description Ontology at [https://w3id.org/okn/o/sdm](https://w3id.org/okn/o/sdm)" title: Model Catalog - version: v2.0.0 + version: v2.1.0 externalDocs: description: Model Catalog url: https://w3id.org/okn/o/sdm diff --git a/package.json b/package.json index d5d10ea..1c40fee 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "model-catalog-api", - "version": "1.0.0", + "version": "2.1.0", "description": "", "type": "module", "main": "dist/index.js", From b0ae946c248ae05459dc70202389a72c154c2c0b Mon Sep 17 00:00:00 2001 From: Maximiliano Osorio Date: Sat, 9 May 2026 17:57:29 -0400 Subject: [PATCH 18/46] test: add explicit vitest config excluding future e2e dir --- vitest.config.ts | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 vitest.config.ts diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..d1815ab --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['src/__tests__/**/*.test.ts'], + exclude: ['src/__tests__/e2e/**', 'node_modules/**', 'dist/**'], + }, +}); From 41a0069b8a52d52ced2530affb852c6ec18c8e81 Mon Sep 17 00:00:00 2001 From: Maximiliano Osorio Date: Sat, 9 May 2026 17:58:52 -0400 Subject: [PATCH 19/46] test: widen vitest include to src/**/*.test.ts to keep mapper tests --- vitest.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vitest.config.ts b/vitest.config.ts index d1815ab..c341e4c 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -2,7 +2,7 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { - include: ['src/__tests__/**/*.test.ts'], + include: ['src/**/*.test.ts'], exclude: ['src/__tests__/e2e/**', 'node_modules/**', 'dist/**'], }, }); From 3ff740cf33c9031bb32e511634e70ad33fd26dc9 Mon Sep 17 00:00:00 2001 From: Maximiliano Osorio Date: Sat, 9 May 2026 18:06:58 -0400 Subject: [PATCH 20/46] test: add vitest e2e config and test:e2e script --- package.json | 3 ++- vitest.e2e.config.ts | 11 +++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) create mode 100644 vitest.e2e.config.ts diff --git a/package.json b/package.json index 1c40fee..1bd12f8 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,8 @@ "build": "tsc", "start": "node dist/index.js", "codegen": "graphql-codegen --config codegen.ts", - "test": "vitest run" + "test": "vitest run", + "test:e2e": "vitest run --config vitest.e2e.config.ts" }, "keywords": [], "author": "", diff --git a/vitest.e2e.config.ts b/vitest.e2e.config.ts new file mode 100644 index 0000000..39dc228 --- /dev/null +++ b/vitest.e2e.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['src/__tests__/e2e/**/*.test.ts'], + testTimeout: 30_000, + hookTimeout: 30_000, + pool: 'forks', + forks: { singleFork: true }, + }, +}); From eeab741d60abb13539080874a1aaf855f9a0cf01 Mon Sep 17 00:00:00 2001 From: Maximiliano Osorio Date: Sat, 9 May 2026 18:12:49 -0400 Subject: [PATCH 21/46] feat(hasura/client): MINT_E2E_MODE flips writeClient to admin-secret auth --- src/__tests__/hasura-client.test.ts | 68 +++++++++++++++++++++++++++++ src/hasura/client.ts | 13 ++++-- 2 files changed, 77 insertions(+), 4 deletions(-) create mode 100644 src/__tests__/hasura-client.test.ts diff --git a/src/__tests__/hasura-client.test.ts b/src/__tests__/hasura-client.test.ts new file mode 100644 index 0000000..9ca9b01 --- /dev/null +++ b/src/__tests__/hasura-client.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +describe('getWriteClient — MINT_E2E_MODE', () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + vi.resetModules(); + process.env = { ...originalEnv }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + async function captureHeaders( + bearerToken: string, + ): Promise> { + const captured: Record = {}; + + const mockFetch = vi.fn(async (url: string, init?: RequestInit) => { + const headers = (init?.headers ?? {}) as Record; + Object.assign(captured, headers); + // Return a minimal valid GraphQL response to prevent Apollo errors + return new Response(JSON.stringify({ data: { __typename: 'Query' } }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }); + + // Patch globalThis.fetch before importing so the module picks it up + vi.stubGlobal('fetch', mockFetch); + + const { getWriteClient } = await import('../hasura/client.js'); + const client = getWriteClient(bearerToken); + + // Fire a minimal query to trigger fetch + try { + await client.query({ + query: (await import('../hasura/client.js')).gql`{ __typename }`, + }); + } catch { + // Ignore Apollo errors — we only care about the fetch call headers + } + + vi.unstubAllGlobals(); + return captured; + } + + it('uses Authorization: Bearer when MINT_E2E_MODE is unset', async () => { + delete process.env.MINT_E2E_MODE; + process.env.HASURA_GRAPHQL_URL = 'http://hasura.test/v1/graphql'; + process.env.HASURA_ADMIN_SECRET = 'secret'; + + const headers = await captureHeaders('Bearer real-jwt'); + expect(headers).toMatchObject({ authorization: 'Bearer real-jwt' }); + expect(headers).not.toHaveProperty('x-hasura-admin-secret'); + }); + + it('uses X-Hasura-Admin-Secret when MINT_E2E_MODE=1', async () => { + process.env.MINT_E2E_MODE = '1'; + process.env.HASURA_GRAPHQL_URL = 'http://hasura.test/v1/graphql'; + process.env.HASURA_ADMIN_SECRET = 'secret'; + + const headers = await captureHeaders('Bearer ignored'); + expect(headers).toMatchObject({ 'x-hasura-admin-secret': 'secret' }); + expect(headers).not.toHaveProperty('authorization'); + }); +}); diff --git a/src/hasura/client.ts b/src/hasura/client.ts index 7911b8a..0423e07 100644 --- a/src/hasura/client.ts +++ b/src/hasura/client.ts @@ -31,14 +31,19 @@ export const readClient = new ApolloClient({ }); // Write client factory: creates a new ApolloClient per request with user's JWT forwarded -// Hasura row-level permissions enforce user scoping based on the JWT claims +// Hasura row-level permissions enforce user scoping based on the JWT claims. +// When MINT_E2E_MODE=1 (local e2e tests against a local Hasura), use admin-secret auth +// instead of a JWT so tests don't need a valid token issuer. export function getWriteClient(bearerToken: string): ApolloClient { + const headers: Record = + process.env.MINT_E2E_MODE === '1' + ? { 'X-Hasura-Admin-Secret': HASURA_ADMIN_SECRET } + : { Authorization: bearerToken }; + return new ApolloClient({ link: new HttpLink({ uri: HASURA_GRAPHQL_URL, - headers: { - Authorization: bearerToken, - }, + headers, fetch: globalThis.fetch, }), cache: new InMemoryCache(), From 7be5c610c72139bcb3fa372e5c8d2c8fdc412ea5 Mon Sep 17 00:00:00 2001 From: Maximiliano Osorio Date: Sat, 9 May 2026 18:15:08 -0400 Subject: [PATCH 22/46] test(hasura-client): unstubAllGlobals in afterEach; document env ordering --- src/__tests__/hasura-client.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/__tests__/hasura-client.test.ts b/src/__tests__/hasura-client.test.ts index 9ca9b01..80d9418 100644 --- a/src/__tests__/hasura-client.test.ts +++ b/src/__tests__/hasura-client.test.ts @@ -10,8 +10,11 @@ describe('getWriteClient — MINT_E2E_MODE', () => { afterEach(() => { process.env = originalEnv; + vi.unstubAllGlobals(); }); + // Note: caller MUST set process.env BEFORE invoking — module-level constants + // in client.ts are captured at import time. async function captureHeaders( bearerToken: string, ): Promise> { @@ -42,7 +45,6 @@ describe('getWriteClient — MINT_E2E_MODE', () => { // Ignore Apollo errors — we only care about the fetch call headers } - vi.unstubAllGlobals(); return captured; } From af9829defcf087f72bcf60a2fcc14b3914bc1286 Mon Sep 17 00:00:00 2001 From: Maximiliano Osorio Date: Sat, 9 May 2026 18:23:48 -0400 Subject: [PATCH 23/46] =?UTF-8?q?test(e2e):=20add=20helpers=20=E2=80=94=20?= =?UTF-8?q?uniqueId,=20trackId,=20inject,=20cleanup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/__tests__/e2e/helpers.ts | 89 ++++++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 src/__tests__/e2e/helpers.ts diff --git a/src/__tests__/e2e/helpers.ts b/src/__tests__/e2e/helpers.ts new file mode 100644 index 0000000..8580db3 --- /dev/null +++ b/src/__tests__/e2e/helpers.ts @@ -0,0 +1,89 @@ +import { randomUUID } from 'node:crypto'; +import type { FastifyInstance } from 'fastify'; + +export const RUN_ID = `e2e-${Date.now()}-${randomUUID().slice(0, 8)}`; + +const ID_PREFIX = 'https://w3id.org/okn/i/mint'; + +export function uniqueId(kind: string): string { + return `${ID_PREFIX}/${kind}-${RUN_ID}-${randomUUID().slice(0, 6)}`; +} + +export const E2E_HEADERS: Record = { + Authorization: 'Bearer e2e-test', + 'Content-Type': 'application/json', +}; + +interface Tracked { + resource: string; + id: string; +} + +const created: Tracked[] = []; + +export function trackId(resource: string, id: string): void { + created.push({ resource, id }); +} + +export interface InjectResult { + statusCode: number; + body: unknown; + rawPayload: string; +} + +export async function inject( + app: FastifyInstance, + method: 'GET' | 'POST' | 'PUT' | 'DELETE', + path: string, + payload?: unknown, +): Promise { + const res = await app.inject({ + method, + url: path, + headers: E2E_HEADERS, + payload: payload === undefined ? undefined : JSON.stringify(payload), + }); + let body: unknown = undefined; + if (res.payload && res.payload.length > 0) { + try { + body = JSON.parse(res.payload); + } catch { + body = res.payload; + } + } + return { statusCode: res.statusCode, body, rawPayload: res.payload }; +} + +export async function cleanup(app: FastifyInstance): Promise { + const orphans: Tracked[] = []; + for (const t of [...created].reverse()) { + try { + const res = await app.inject({ + method: 'DELETE', + url: `/v2.0.0/${t.resource}/${encodeURIComponent(t.id)}`, + headers: E2E_HEADERS, + }); + if (res.statusCode >= 400 && res.statusCode !== 404) { + orphans.push(t); + // eslint-disable-next-line no-console + console.warn( + `cleanup: ${t.resource}/${t.id} delete returned ${res.statusCode}: ${res.payload}`, + ); + } + } catch (err) { + orphans.push(t); + // eslint-disable-next-line no-console + console.warn(`cleanup: ${t.resource}/${t.id} threw`, err); + } + } + if (orphans.length > 0) { + // eslint-disable-next-line no-console + console.warn( + `cleanup: ${orphans.length} orphan(s) remain. RUN_ID=${RUN_ID}. Manual SQL:\n` + + ` DELETE FROM modelcatalog_software_version WHERE id LIKE '%-${RUN_ID}-%';\n` + + ` DELETE FROM modelcatalog_software WHERE id LIKE '%-${RUN_ID}-%';\n` + + ` DELETE FROM modelcatalog_grid WHERE id LIKE '%-${RUN_ID}-%';`, + ); + } + created.length = 0; +} From 4019df1a95d54d89e762853ddee89da80359cad7 Mon Sep 17 00:00:00 2001 From: Maximiliano Osorio Date: Sat, 9 May 2026 18:31:56 -0400 Subject: [PATCH 24/46] =?UTF-8?q?test(e2e):=20add=20setup=20=E2=80=94=20en?= =?UTF-8?q?v=20defaults,=20app=20builder,=20Hasura=20health-check?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/__tests__/e2e/setup.ts | 49 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 src/__tests__/e2e/setup.ts diff --git a/src/__tests__/e2e/setup.ts b/src/__tests__/e2e/setup.ts new file mode 100644 index 0000000..d1920f2 --- /dev/null +++ b/src/__tests__/e2e/setup.ts @@ -0,0 +1,49 @@ +import type { FastifyInstance } from 'fastify'; + +const DEFAULTS: Record = { + HASURA_GRAPHQL_URL: 'http://graphql.mint.local/v1/graphql', + HASURA_ADMIN_SECRET: 'CHANGEME', + MINT_E2E_MODE: '1', + LOG_LEVEL: 'warn', +}; + +export function applyE2EEnv(): void { + for (const [k, v] of Object.entries(DEFAULTS)) { + if (process.env[k] === undefined || process.env[k] === '') { + process.env[k] = v; + } + } +} + +export async function buildE2EApp(): Promise { + applyE2EEnv(); + const { buildApp } = await import('../../app.js'); + return buildApp(); +} + +export async function assertHasuraReachable(): Promise { + applyE2EEnv(); + const url = process.env.HASURA_GRAPHQL_URL!; + const adminSecret = process.env.HASURA_ADMIN_SECRET!; + let res: Response; + try { + res = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Hasura-Admin-Secret': adminSecret, + }, + body: JSON.stringify({ query: '{ __typename }' }), + }); + } catch (err) { + throw new Error( + `Local Hasura unreachable at ${url}. Check kubectl port-forward / /etc/hosts. Underlying error: ${(err as Error).message}`, + ); + } + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error( + `Local Hasura health-check failed at ${url}: ${res.status} ${res.statusText}. Body: ${text}`, + ); + } +} From 11309fcda14090d819dd5da27ac7c23174409b50 Mon Sep 17 00:00:00 2001 From: Maximiliano Osorio Date: Sat, 9 May 2026 18:44:17 -0400 Subject: [PATCH 25/46] test: add e2e flat-entity smoke test for persons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-trips POST → GET through Fastify → Apollo → real local Hasura to prove the e2e harness is wired end-to-end. Uses persons (flat entity, no relationships) to avoid the junction mutation compiler bug surfaced by junction-e2e Task 6. --- src/__tests__/e2e/smoke-e2e.test.ts | 37 +++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 src/__tests__/e2e/smoke-e2e.test.ts diff --git a/src/__tests__/e2e/smoke-e2e.test.ts b/src/__tests__/e2e/smoke-e2e.test.ts new file mode 100644 index 0000000..e430278 --- /dev/null +++ b/src/__tests__/e2e/smoke-e2e.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import type { FastifyInstance } from 'fastify'; +import { assertHasuraReachable, buildE2EApp } from './setup.js'; +import { cleanup, inject, trackId, uniqueId } from './helpers.js'; + +let app: FastifyInstance; + +beforeAll(async () => { + await assertHasuraReachable(); + app = await buildE2EApp(); +}); + +afterAll(async () => { + await cleanup(app); + await app.close(); +}); + +describe('e2e smoke — flat entity', () => { + it('POST /persons round-trips through Hasura', async () => { + const id = uniqueId('person'); + + const post = await inject(app, 'POST', '/v2.0.0/persons', { + id, + label: ['E2E Smoke Person'], + }); + + expect(post.statusCode, `POST /persons body: ${JSON.stringify(post.body)}`).toBe(201); + trackId('persons', id); + + const get = await inject(app, 'GET', `/v2.0.0/persons/${encodeURIComponent(id)}`); + expect(get.statusCode).toBe(200); + + const person = get.body as Record; + expect(person.id).toBe(id); + expect((person.label as string[])[0]).toBe('E2E Smoke Person'); + }); +}); From 1539905527a1d205db81e189453badb1a490e37e Mon Sep 17 00:00:00 2001 From: Maximiliano Osorio Date: Sat, 9 May 2026 18:52:26 -0400 Subject: [PATCH 26/46] fix: emit hasuraRelName as junction-array key on parent insert object MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bug-100: mutation compiler emitted junctionRelName ('grid') as the top-level array-relationship key on the parent insert input. Hasura metadata exposes the relationship under hasuraRelName ('grids'), so POST /softwareversions with hasGrid returned 500 'field grid not found in modelcatalog_software_version_insert_input'. JunctionEdge now carries hasuraRelName; buildInsertObject keys the parent slot by hasuraRelName. Inner junction-row key (object rel to target) keeps junctionRelName — that one was already correct. Mocked junction tests asserted the wrong outer key, so they passed while real Hasura rejected. Updated mocked-test fixtures to add hasuraRelName and assert the correct outer key. E2E regression to follow. --- src/mappers/__tests__/mutation-compiler.test.ts | 12 +++++++++--- src/mappers/__tests__/nested-tree.test.ts | 1 + src/mappers/mutation-compiler.ts | 2 +- src/mappers/nested-tree.ts | 2 ++ 4 files changed, 13 insertions(+), 4 deletions(-) diff --git a/src/mappers/__tests__/mutation-compiler.test.ts b/src/mappers/__tests__/mutation-compiler.test.ts index 57e8b03..c64bf11 100644 --- a/src/mappers/__tests__/mutation-compiler.test.ts +++ b/src/mappers/__tests__/mutation-compiler.test.ts @@ -28,6 +28,7 @@ describe('compilePost', () => { { apiFieldName: 'hasInput', junctionTable: 'modelcatalog_configuration_input', + hasuraRelName: 'inputs', junctionRelName: 'input', parentFkColumn: 'configuration_id', targetFkColumn: 'input_id', @@ -49,7 +50,7 @@ describe('compilePost', () => { const obj = (variables.object as Record); expect(obj.id).toBe('cfg-1'); expect(obj.label).toBe('cfg'); - const inputs = (obj.input as { data: unknown[]; on_conflict: { update_columns: string[] } }); + const inputs = (obj.inputs as { data: unknown[]; on_conflict: { update_columns: string[] } }); expect(inputs.on_conflict.update_columns).toEqual([]); const inputRow = inputs.data[0] as Record; const nested = inputRow.input as { data: any; on_conflict: { update_columns: string[] } }; @@ -68,6 +69,7 @@ describe('compilePost', () => { { apiFieldName: 'hasInput', junctionTable: 'modelcatalog_configuration_input', + hasuraRelName: 'inputs', junctionRelName: 'input', parentFkColumn: 'configuration_id', targetFkColumn: 'input_id', @@ -87,7 +89,7 @@ describe('compilePost', () => { }; const { variables } = compilePost(tree); const obj = variables.object as Record; - const nested = (obj.input.data[0].input) as { on_conflict: { update_columns: string[] } }; + const nested = (obj.inputs.data[0].input) as { on_conflict: { update_columns: string[] } }; expect(nested.on_conflict.update_columns).toEqual([]); }); @@ -100,6 +102,7 @@ describe('compilePost', () => { { apiFieldName: 'hasInput', junctionTable: 'modelcatalog_configuration_input', + hasuraRelName: 'inputs', junctionRelName: 'input', parentFkColumn: 'configuration_id', targetFkColumn: 'input_id', @@ -112,7 +115,7 @@ describe('compilePost', () => { childFks: [], }; const { variables } = compilePost(tree); - const row = (variables.object as any).input.data[0]; + const row = (variables.object as any).inputs.data[0]; expect(row.is_optional).toBe(true); }); @@ -171,6 +174,7 @@ describe('compilePut', () => { { apiFieldName: 'hasInput', junctionTable: 'modelcatalog_configuration_input', + hasuraRelName: 'inputs', junctionRelName: 'input', parentFkColumn: 'configuration_id', targetFkColumn: 'input_id', @@ -203,6 +207,7 @@ describe('compilePut', () => { { apiFieldName: 'hasInput', junctionTable: 'modelcatalog_configuration_input', + hasuraRelName: 'inputs', junctionRelName: 'input', parentFkColumn: 'configuration_id', targetFkColumn: 'input_id', @@ -261,6 +266,7 @@ describe('compilePut', () => { { apiFieldName: 'hasInput', junctionTable: 'modelcatalog_configuration_input', + hasuraRelName: 'inputs', junctionRelName: 'input', parentFkColumn: 'configuration_id', targetFkColumn: 'input_id', diff --git a/src/mappers/__tests__/nested-tree.test.ts b/src/mappers/__tests__/nested-tree.test.ts index f628964..034a1f6 100644 --- a/src/mappers/__tests__/nested-tree.test.ts +++ b/src/mappers/__tests__/nested-tree.test.ts @@ -39,6 +39,7 @@ describe('nested-tree types and constants', () => { const junc: JunctionEdge = { apiFieldName: 'hasInput', junctionTable: 'modelcatalog_configuration_input', + hasuraRelName: 'inputs', junctionRelName: 'input', parentFkColumn: 'configuration_id', targetFkColumn: 'input_id', diff --git a/src/mappers/mutation-compiler.ts b/src/mappers/mutation-compiler.ts index a2ff0e9..0bc9099 100644 --- a/src/mappers/mutation-compiler.ts +++ b/src/mappers/mutation-compiler.ts @@ -20,7 +20,7 @@ function buildInsertObject(node: WriteNode): Record { const obj: Record = { id: node.id, ...node.columns }; for (const j of node.junctions) { - obj[j.junctionRelName] = buildJunctionInsert(j); + obj[j.hasuraRelName] = buildJunctionInsert(j); } for (const c of node.childFks) { obj[c.hasuraRelName] = buildChildFkInsert(c); diff --git a/src/mappers/nested-tree.ts b/src/mappers/nested-tree.ts index 862414b..6daad17 100644 --- a/src/mappers/nested-tree.ts +++ b/src/mappers/nested-tree.ts @@ -43,6 +43,7 @@ export interface WriteNode { export interface JunctionEdge { apiFieldName: string; junctionTable: string; + hasuraRelName: string; junctionRelName: string; parentFkColumn: string; targetFkColumn: string; @@ -170,6 +171,7 @@ function buildJunctionEdge( return { apiFieldName, junctionTable: rel.junctionTable!, + hasuraRelName: rel.hasuraRelName, junctionRelName: rel.junctionRelName!, parentFkColumn: rel.parentFkColumn!, targetFkColumn: resolveTargetFkColumn(rel), From 78b3db3e610e47713c5972647bbe3415cfcfd24d Mon Sep 17 00:00:00 2001 From: Maximiliano Osorio Date: Sat, 9 May 2026 18:52:32 -0400 Subject: [PATCH 27/46] fix(e2e): strip Content-Type from cleanup DELETE request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fastify rejects DELETE with content-type:application/json and empty body (FST_ERR_CTP_EMPTY_JSON_BODY), causing every cleanup() call to return 400 and leave orphan rows. DELETE has no body — drop the header. --- src/__tests__/e2e/helpers.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/__tests__/e2e/helpers.ts b/src/__tests__/e2e/helpers.ts index 8580db3..87c68fd 100644 --- a/src/__tests__/e2e/helpers.ts +++ b/src/__tests__/e2e/helpers.ts @@ -56,12 +56,14 @@ export async function inject( export async function cleanup(app: FastifyInstance): Promise { const orphans: Tracked[] = []; + // DELETE has no body — strip Content-Type so Fastify doesn't reject empty payload. + const { 'Content-Type': _ct, ...deleteHeaders } = E2E_HEADERS; for (const t of [...created].reverse()) { try { const res = await app.inject({ method: 'DELETE', url: `/v2.0.0/${t.resource}/${encodeURIComponent(t.id)}`, - headers: E2E_HEADERS, + headers: deleteHeaders, }); if (res.statusCode >= 400 && res.statusCode !== 404) { orphans.push(t); From 65af8f299bcbe209643dd9e8761a037c62e63135 Mon Sep 17 00:00:00 2001 From: Maximiliano Osorio Date: Sat, 9 May 2026 18:57:20 -0400 Subject: [PATCH 28/46] fix: emit FK column directly for link-only junction children MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bug-101: POST/PUT with hasGrid: [{ id: gridId }] (link to existing grid, no fields) returned 500 'null value in column label violates not-null constraint'. Compiler always wrapped target as nested INSERT ({ grid: { data: { id }, on_conflict } }), so Hasura tried to insert a new grid row before on_conflict could fire — PG validates NOT NULL on the column before resolving the unique constraint. When a junction child has no columns and no nested writes, emit the junction row as { [targetFkColumn]: child.id, ...junctionColumns } directly. When it has columns or nested writes, keep the existing nested-insert path for legitimate inline-NEW children. Applies to both compilePost and compilePut (extracted shared buildJunctionRow). Added link-only unit tests for both paths. --- .../__tests__/mutation-compiler.test.ts | 34 ++++++++++- src/mappers/mutation-compiler.ts | 56 +++++++++++-------- 2 files changed, 64 insertions(+), 26 deletions(-) diff --git a/src/mappers/__tests__/mutation-compiler.test.ts b/src/mappers/__tests__/mutation-compiler.test.ts index c64bf11..dc3124a 100644 --- a/src/mappers/__tests__/mutation-compiler.test.ts +++ b/src/mappers/__tests__/mutation-compiler.test.ts @@ -60,7 +60,7 @@ describe('compilePost', () => { expect(nested.on_conflict.constraint).toBe('modelcatalog_dataset_specification_pkey'); }); - it('emits link-only nested entity (empty columns) with update_columns:[]', () => { + it('link-only child (no columns, no nested) emits targetFkColumn = id, no nested target insert (bug-101)', () => { const tree: WriteNode = { table: 'modelcatalog_configuration', id: 'cfg-2', @@ -89,8 +89,36 @@ describe('compilePost', () => { }; const { variables } = compilePost(tree); const obj = variables.object as Record; - const nested = (obj.inputs.data[0].input) as { on_conflict: { update_columns: string[] } }; - expect(nested.on_conflict.update_columns).toEqual([]); + const row = obj.inputs.data[0]; + expect(row.input_id).toBe('ds-existing'); + expect(row.input).toBeUndefined(); + }); + + it('PUT link-only child emits targetFkColumn = id, no nested target insert (bug-101)', () => { + const tree: WriteNode = { + table: 'modelcatalog_configuration', + id: 'cfg-put-link', + columns: {}, + junctions: [ + { + apiFieldName: 'hasInput', + junctionTable: 'modelcatalog_configuration_input', + hasuraRelName: 'inputs', + junctionRelName: 'input', + parentFkColumn: 'configuration_id', + targetFkColumn: 'input_id', + junctionColumns: [{}], + children: [ + { table: 'modelcatalog_dataset_specification', id: 'ds-link', columns: {}, junctions: [], childFks: [] }, + ], + }, + ], + childFks: [], + }; + const { variables } = compilePut(tree); + const row = (variables.junc_inputs as any[])[0]; + expect(row.input_id).toBe('ds-link'); + expect(row.input).toBeUndefined(); }); it('applies junction extra columns to junction row', () => { diff --git a/src/mappers/mutation-compiler.ts b/src/mappers/mutation-compiler.ts index 0bc9099..9cfe9ee 100644 --- a/src/mappers/mutation-compiler.ts +++ b/src/mappers/mutation-compiler.ts @@ -28,20 +28,16 @@ function buildInsertObject(node: WriteNode): Record { return obj; } +function isLinkOnly(child: WriteNode): boolean { + return ( + Object.keys(child.columns).length === 0 && + child.junctions.length === 0 && + child.childFks.length === 0 + ); +} + function buildJunctionInsert(j: JunctionEdge): Record { - const data = j.children.map((child, idx) => { - const row: Record = { - ...j.junctionColumns[idx], - [j.junctionRelName]: { - data: buildInsertObject(child), - on_conflict: { - constraint: `${child.table}_pkey`, - update_columns: Object.keys(child.columns), - }, - }, - }; - return row; - }); + const data = j.children.map((child, idx) => buildJunctionRow(j, child, idx)); return { data, on_conflict: { @@ -51,6 +47,29 @@ function buildJunctionInsert(j: JunctionEdge): Record { }; } +function buildJunctionRow( + j: JunctionEdge, + child: WriteNode, + idx: number, +): Record { + if (isLinkOnly(child)) { + return { + ...j.junctionColumns[idx], + [j.targetFkColumn]: child.id, + }; + } + return { + ...j.junctionColumns[idx], + [j.junctionRelName]: { + data: buildInsertObject(child), + on_conflict: { + constraint: `${child.table}_pkey`, + update_columns: Object.keys(child.columns), + }, + }, + }; +} + function buildChildFkInsert(c: ChildFkEdge): Record { const data = c.children.map((child) => buildInsertObject(child)); return { @@ -63,16 +82,7 @@ function buildChildFkInsert(c: ChildFkEdge): Record { } function buildPutJunctionRow(j: JunctionEdge, idx: number): Record { - const child = j.children[idx]; - const row: Record = { ...j.junctionColumns[idx] }; - row[j.junctionRelName] = { - data: buildInsertObject(child), - on_conflict: { - constraint: `${child.table}_pkey`, - update_columns: Object.keys(child.columns), - }, - }; - return row; + return buildJunctionRow(j, j.children[idx], idx); } export function compilePut(tree: WriteNode): CompiledMutation { From 639fdf09e4ad1854e26521118292c68e434d2e0c Mon Sep 17 00:00:00 2001 From: Maximiliano Osorio Date: Sat, 9 May 2026 18:58:26 -0400 Subject: [PATCH 29/46] =?UTF-8?q?test(e2e):=20junction-e2e=20=E2=80=94=20b?= =?UTF-8?q?ug-087=20label-clobber=20regression?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/__tests__/e2e/junction-e2e.test.ts | 58 ++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 src/__tests__/e2e/junction-e2e.test.ts diff --git a/src/__tests__/e2e/junction-e2e.test.ts b/src/__tests__/e2e/junction-e2e.test.ts new file mode 100644 index 0000000..ef144e5 --- /dev/null +++ b/src/__tests__/e2e/junction-e2e.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import type { FastifyInstance } from 'fastify'; +import { assertHasuraReachable, buildE2EApp } from './setup.js'; +import { cleanup, inject, trackId, uniqueId } from './helpers.js'; + +let app: FastifyInstance; +beforeAll(async () => { + await assertHasuraReachable(); + app = await buildE2EApp(); +}); +afterAll(async () => { + if (app) { + await cleanup(app); + await app.close(); + } +}); + +describe('junction e2e — softwareversions.hasGrid (bug-087 class)', () => { + it('does NOT clobber an existing grid label when linked from a new softwareversion (bug-087 regression)', async () => { + // 1. Create a grid with a known label. + const gridId = uniqueId('grid'); + const ORIGINAL_LABEL = 'original-grid-label-DO-NOT-CLOBBER'; + const gridCreate = await inject(app, 'POST', '/v2.0.0/grids', { + id: gridId, + label: [ORIGINAL_LABEL], + type: ['Grid'], + }); + expect(gridCreate.statusCode).toBeGreaterThanOrEqual(200); + expect(gridCreate.statusCode).toBeLessThan(300); + trackId('grids', gridId); + + // 2. Create a softwareversion linking to that grid by ID only (no label in the link payload). + const versionId = uniqueId('softwareversion'); + const versionCreate = await inject(app, 'POST', '/v2.0.0/softwareversions', { + id: versionId, + label: ['e2e-version'], + type: ['SoftwareVersion'], + hasGrid: [{ id: gridId }], + }); + expect(versionCreate.statusCode).toBeGreaterThanOrEqual(200); + expect(versionCreate.statusCode).toBeLessThan(300); + trackId('softwareversions', versionId); + + // 3. Fetch the grid back and assert its label was NOT touched. + const gridGet = await inject( + app, + 'GET', + `/v2.0.0/grids/${encodeURIComponent(gridId)}`, + ); + expect(gridGet.statusCode).toBe(200); + const grid = (Array.isArray(gridGet.body) ? gridGet.body[0] : gridGet.body) as { + id: string; + label: string[]; + }; + expect(grid.id).toBe(gridId); + expect(grid.label).toEqual([ORIGINAL_LABEL]); + }); +}); From e2377339e1c0bc2ee283efdc0a145806b4abc48c Mon Sep 17 00:00:00 2001 From: Maximiliano Osorio Date: Sat, 9 May 2026 18:58:47 -0400 Subject: [PATCH 30/46] test(e2e): junction round-trip GET after POST --- src/__tests__/e2e/junction-e2e.test.ts | 30 ++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/src/__tests__/e2e/junction-e2e.test.ts b/src/__tests__/e2e/junction-e2e.test.ts index ef144e5..cd13b58 100644 --- a/src/__tests__/e2e/junction-e2e.test.ts +++ b/src/__tests__/e2e/junction-e2e.test.ts @@ -55,4 +55,34 @@ describe('junction e2e — softwareversions.hasGrid (bug-087 class)', () => { expect(grid.id).toBe(gridId); expect(grid.label).toEqual([ORIGINAL_LABEL]); }); + + it('POST softwareversion with hasGrid persists the junction; GET returns it', async () => { + const gridId = uniqueId('grid'); + await inject(app, 'POST', '/v2.0.0/grids', { + id: gridId, + label: ['grid-roundtrip'], + type: ['Grid'], + }); + trackId('grids', gridId); + + const versionId = uniqueId('softwareversion'); + await inject(app, 'POST', '/v2.0.0/softwareversions', { + id: versionId, + label: ['v-roundtrip'], + type: ['SoftwareVersion'], + hasGrid: [{ id: gridId }], + }); + trackId('softwareversions', versionId); + + const got = await inject( + app, + 'GET', + `/v2.0.0/softwareversions/${encodeURIComponent(versionId)}`, + ); + expect(got.statusCode).toBe(200); + const v = (Array.isArray(got.body) ? got.body[0] : got.body) as { + hasGrid?: { id: string }[]; + }; + expect(v.hasGrid?.map((g) => g.id)).toContain(gridId); + }); }); From 64a3b2e839d07dbeb00be42098dd194966a5d2ea Mon Sep 17 00:00:00 2001 From: Maximiliano Osorio Date: Sat, 9 May 2026 18:59:39 -0400 Subject: [PATCH 31/46] fix: thread parentFkColumn into compilePut junction insert objects bug-102: PUT /softwareversions with hasGrid returned 400 'null value in column software_version_id violates not-null constraint' on insert_modelcatalog_software_version_grid. compilePut emits a separate insert into the junction table (not a nested write through the parent), so Hasura cannot infer the parent FK. Each junction row needs parentFkColumn = parent.id explicitly. compilePost is unaffected: it uses Hasura's nested-object pattern through the parent insert, so the FK is auto-derived. --- src/mappers/mutation-compiler.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/mappers/mutation-compiler.ts b/src/mappers/mutation-compiler.ts index 9cfe9ee..6c44cae 100644 --- a/src/mappers/mutation-compiler.ts +++ b/src/mappers/mutation-compiler.ts @@ -100,7 +100,10 @@ export function compilePut(tree: WriteNode): CompiledMutation { ); if (j.children.length > 0) { const varName = `junc_${j.junctionRelName}s`; - const objects = j.children.map((_, i) => buildPutJunctionRow(j, i)); + const objects = j.children.map((_, i) => ({ + ...buildPutJunctionRow(j, i), + [j.parentFkColumn]: tree.id, + })); variables[varName] = objects; varDecls.push(`$${varName}: [modelcatalog_${juncSuffix}_insert_input!]!`); parts.push( From 1ebd74480e177e9828a1d4bca1c0eb4affa67873 Mon Sep 17 00:00:00 2001 From: Maximiliano Osorio Date: Sat, 9 May 2026 18:59:56 -0400 Subject: [PATCH 32/46] test(e2e): junction PUT replace set --- src/__tests__/e2e/junction-e2e.test.ts | 41 ++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/src/__tests__/e2e/junction-e2e.test.ts b/src/__tests__/e2e/junction-e2e.test.ts index cd13b58..d1aa325 100644 --- a/src/__tests__/e2e/junction-e2e.test.ts +++ b/src/__tests__/e2e/junction-e2e.test.ts @@ -85,4 +85,45 @@ describe('junction e2e — softwareversions.hasGrid (bug-087 class)', () => { }; expect(v.hasGrid?.map((g) => g.id)).toContain(gridId); }); + + it('PUT softwareversion replaces hasGrid: old links removed, new links present', async () => { + const gridA = uniqueId('grid'); + const gridB = uniqueId('grid'); + for (const [id, lbl] of [[gridA, 'A'], [gridB, 'B']] as const) { + await inject(app, 'POST', '/v2.0.0/grids', { + id, label: [lbl], type: ['Grid'], + }); + trackId('grids', id); + } + + const versionId = uniqueId('softwareversion'); + await inject(app, 'POST', '/v2.0.0/softwareversions', { + id: versionId, label: ['v-put'], type: ['SoftwareVersion'], + hasGrid: [{ id: gridA }], + }); + trackId('softwareversions', versionId); + + const putRes = await inject( + app, + 'PUT', + `/v2.0.0/softwareversions/${encodeURIComponent(versionId)}`, + { + id: versionId, label: ['v-put'], type: ['SoftwareVersion'], + hasGrid: [{ id: gridB }], + }, + ); + expect(putRes.statusCode).toBeGreaterThanOrEqual(200); + expect(putRes.statusCode).toBeLessThan(300); + + const got = await inject( + app, 'GET', + `/v2.0.0/softwareversions/${encodeURIComponent(versionId)}`, + ); + const v = (Array.isArray(got.body) ? got.body[0] : got.body) as { + hasGrid?: { id: string }[]; + }; + const ids = v.hasGrid?.map((g) => g.id) ?? []; + expect(ids).toContain(gridB); + expect(ids).not.toContain(gridA); + }); }); From 36c4e68a8a06237d8c3d365372a9ca57e376abaf Mon Sep 17 00:00:00 2001 From: Maximiliano Osorio Date: Sat, 9 May 2026 19:00:56 -0400 Subject: [PATCH 33/46] test(e2e): junction PUT [] clears all links --- src/__tests__/e2e/junction-e2e.test.ts | 35 ++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/src/__tests__/e2e/junction-e2e.test.ts b/src/__tests__/e2e/junction-e2e.test.ts index d1aa325..6c7c22c 100644 --- a/src/__tests__/e2e/junction-e2e.test.ts +++ b/src/__tests__/e2e/junction-e2e.test.ts @@ -126,4 +126,39 @@ describe('junction e2e — softwareversions.hasGrid (bug-087 class)', () => { expect(ids).toContain(gridB); expect(ids).not.toContain(gridA); }); + + it('PUT softwareversion with hasGrid: [] removes all junction links', async () => { + const gridId = uniqueId('grid'); + await inject(app, 'POST', '/v2.0.0/grids', { + id: gridId, label: ['G'], type: ['Grid'], + }); + trackId('grids', gridId); + + const versionId = uniqueId('softwareversion'); + await inject(app, 'POST', '/v2.0.0/softwareversions', { + id: versionId, label: ['v-empty'], type: ['SoftwareVersion'], + hasGrid: [{ id: gridId }], + }); + trackId('softwareversions', versionId); + + const putRes = await inject( + app, 'PUT', + `/v2.0.0/softwareversions/${encodeURIComponent(versionId)}`, + { + id: versionId, label: ['v-empty'], type: ['SoftwareVersion'], + hasGrid: [], + }, + ); + expect(putRes.statusCode).toBeGreaterThanOrEqual(200); + expect(putRes.statusCode).toBeLessThan(300); + + const got = await inject( + app, 'GET', + `/v2.0.0/softwareversions/${encodeURIComponent(versionId)}`, + ); + const v = (Array.isArray(got.body) ? got.body[0] : got.body) as { + hasGrid?: { id: string }[]; + }; + expect(v.hasGrid ?? []).toEqual([]); + }); }); From cf0ef6d9298bb59923cf6ab9a1fcd8f179374d73 Mon Sep 17 00:00:00 2001 From: Maximiliano Osorio Date: Sat, 9 May 2026 19:01:10 -0400 Subject: [PATCH 34/46] test(e2e): junction POST dedup duplicates --- src/__tests__/e2e/junction-e2e.test.ts | 27 ++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/src/__tests__/e2e/junction-e2e.test.ts b/src/__tests__/e2e/junction-e2e.test.ts index 6c7c22c..650c14a 100644 --- a/src/__tests__/e2e/junction-e2e.test.ts +++ b/src/__tests__/e2e/junction-e2e.test.ts @@ -161,4 +161,31 @@ describe('junction e2e — softwareversions.hasGrid (bug-087 class)', () => { }; expect(v.hasGrid ?? []).toEqual([]); }); + + it('POST softwareversion with duplicate hasGrid entries deduplicates without violating unique constraints', async () => { + const gridId = uniqueId('grid'); + await inject(app, 'POST', '/v2.0.0/grids', { + id: gridId, label: ['dup'], type: ['Grid'], + }); + trackId('grids', gridId); + + const versionId = uniqueId('softwareversion'); + const res = await inject(app, 'POST', '/v2.0.0/softwareversions', { + id: versionId, label: ['v-dup'], type: ['SoftwareVersion'], + hasGrid: [{ id: gridId }, { id: gridId }], + }); + expect(res.statusCode).toBeGreaterThanOrEqual(200); + expect(res.statusCode).toBeLessThan(300); + trackId('softwareversions', versionId); + + const got = await inject( + app, 'GET', + `/v2.0.0/softwareversions/${encodeURIComponent(versionId)}`, + ); + const v = (Array.isArray(got.body) ? got.body[0] : got.body) as { + hasGrid?: { id: string }[]; + }; + expect(v.hasGrid?.length).toBe(1); + expect(v.hasGrid?.[0].id).toBe(gridId); + }); }); From efd4c0a77a12e2362a3e41a95548375c9b0f23a1 Mon Sep 17 00:00:00 2001 From: Maximiliano Osorio Date: Sat, 9 May 2026 19:01:28 -0400 Subject: [PATCH 35/46] test(e2e): junction POST rejects unknown FK target --- src/__tests__/e2e/junction-e2e.test.ts | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/__tests__/e2e/junction-e2e.test.ts b/src/__tests__/e2e/junction-e2e.test.ts index 650c14a..66541a1 100644 --- a/src/__tests__/e2e/junction-e2e.test.ts +++ b/src/__tests__/e2e/junction-e2e.test.ts @@ -188,4 +188,30 @@ describe('junction e2e — softwareversions.hasGrid (bug-087 class)', () => { expect(v.hasGrid?.length).toBe(1); expect(v.hasGrid?.[0].id).toBe(gridId); }); + + it('POST softwareversion with hasGrid referencing a non-existent grid id returns 4xx', async () => { + const fakeGridId = uniqueId('grid-does-not-exist'); + const versionId = uniqueId('softwareversion'); + const res = await inject(app, 'POST', '/v2.0.0/softwareversions', { + id: versionId, label: ['v-bad-ref'], type: ['SoftwareVersion'], + hasGrid: [{ id: fakeGridId }], + }); + expect(res.statusCode).toBeGreaterThanOrEqual(400); + expect(res.statusCode).toBeLessThan(500); + + // The version itself should NOT have been created. + const got = await inject( + app, 'GET', + `/v2.0.0/softwareversions/${encodeURIComponent(versionId)}`, + ); + expect([404, 200]).toContain(got.statusCode); + if (got.statusCode === 200) { + const v = (Array.isArray(got.body) ? got.body[0] : got.body) as { + hasGrid?: { id: string }[]; + }; + expect(v?.hasGrid ?? []).toEqual([]); + // If a row was actually created, track for cleanup. + trackId('softwareversions', versionId); + } + }); }); From ba10e34e65c9f4706d7bf693b080a12f316ed2d2 Mon Sep 17 00:00:00 2001 From: Maximiliano Osorio Date: Sat, 9 May 2026 19:07:01 -0400 Subject: [PATCH 36/46] fix: do not set childFk column explicitly on nested POST rows bug-103: POST /softwares with hasVersion (childFk relationship) returned 500 'cannot insert software_id columns as their values are already being determined by parent insert' (validation-failed). compilePost was injecting childFkColumn = parent.id on each nested child row after buildInsertObject. Hasura's nested-object insert already auto-derives the FK from parent context, so the explicit assignment double-binds the column and Hasura rejects the mutation. Updated mocked tests that asserted the old explicit-FK behavior. --- src/__tests__/integration.test.ts | 3 ++- src/mappers/__tests__/mutation-compiler.test.ts | 6 ++++-- src/mappers/mutation-compiler.ts | 17 +++-------------- 3 files changed, 9 insertions(+), 17 deletions(-) diff --git a/src/__tests__/integration.test.ts b/src/__tests__/integration.test.ts index 3c035d5..fccb0fe 100644 --- a/src/__tests__/integration.test.ts +++ b/src/__tests__/integration.test.ts @@ -630,6 +630,7 @@ describe('POST software with hasVersion links existing version rows', () => { const childData = obj.versions.data as any[] expect(childData).toHaveLength(1) expect(childData[0].id).toBe('https://w3id.org/okn/i/mint/V-99') - expect(childData[0].software_id).toBe('https://w3id.org/okn/i/mint/NEW-1') + // Hasura auto-derives software_id from parent context. + expect(childData[0].software_id).toBeUndefined() }) }) diff --git a/src/mappers/__tests__/mutation-compiler.test.ts b/src/mappers/__tests__/mutation-compiler.test.ts index dc3124a..6a9c737 100644 --- a/src/mappers/__tests__/mutation-compiler.test.ts +++ b/src/mappers/__tests__/mutation-compiler.test.ts @@ -171,10 +171,12 @@ describe('compilePost', () => { expect(obj.configurations).toBeDefined(); const arr = obj.configurations.data as any[]; expect(arr.length).toBe(2); - expect(arr[0].software_version_id).toBe('sv-1'); + // Hasura auto-derives software_version_id from parent context — must NOT be set + // explicitly or Hasura raises 'cannot insert ... already determined by parent'. + expect(arr[0].software_version_id).toBeUndefined(); expect(arr[0].id).toBe('cfg-a'); expect(arr[0].label).toBe('A'); - expect(arr[1].software_version_id).toBe('sv-1'); + expect(arr[1].software_version_id).toBeUndefined(); }); }); diff --git a/src/mappers/mutation-compiler.ts b/src/mappers/mutation-compiler.ts index 6c44cae..b23d31f 100644 --- a/src/mappers/mutation-compiler.ts +++ b/src/mappers/mutation-compiler.ts @@ -144,20 +144,9 @@ export function compilePut(tree: WriteNode): CompiledMutation { export function compilePost(tree: WriteNode): CompiledMutation { const object = buildInsertObject(tree); - // Inject FK column values into childFk rows after the object is built - for (const c of tree.childFks) { - const arr = (object[c.hasuraRelName] as { data: Record[] }).data; - arr.forEach((row, idx) => { - row[c.childFkColumn] = tree.id; - const childNode = c.children[idx]; - for (const subC of childNode.childFks) { - const subArr = (row[subC.hasuraRelName] as { data: Record[] }).data; - subArr.forEach((subRow) => { - subRow[subC.childFkColumn] = childNode.id; - }); - } - }); - } + // Hasura's nested-object insert auto-derives childFk columns from parent context. + // Setting the FK explicitly raises 'cannot insert ... values are already being + // determined by parent insert' (validation-failed). Leave them off. const suffix = tableSuffix(tree.table); const mutation = ` From e2ce05c69d510ec644c33e5c805d32d97ff052ce Mon Sep 17 00:00:00 2001 From: Maximiliano Osorio Date: Sat, 9 May 2026 19:11:57 -0400 Subject: [PATCH 37/46] test(e2e): nested POST inline hasVersion (bug-089 target) --- src/__tests__/e2e/nested-write-e2e.test.ts | 59 ++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 src/__tests__/e2e/nested-write-e2e.test.ts diff --git a/src/__tests__/e2e/nested-write-e2e.test.ts b/src/__tests__/e2e/nested-write-e2e.test.ts new file mode 100644 index 0000000..7fc9a22 --- /dev/null +++ b/src/__tests__/e2e/nested-write-e2e.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import type { FastifyInstance } from 'fastify'; +import { assertHasuraReachable, buildE2EApp } from './setup.js'; +import { cleanup, inject, trackId, uniqueId } from './helpers.js'; + +let app: FastifyInstance; + +beforeAll(async () => { + await assertHasuraReachable(); + app = await buildE2EApp(); +}); + +afterAll(async () => { + if (app) { + await cleanup(app); + await app.close(); + } +}); + +describe('nested-write e2e — softwares.hasVersion (bug-089 class)', () => { + // Expected: PASS once bug-089 implementation lands. May FAIL on this branch today. + it('POST software with an inline nested hasVersion creates the version row and links it via FK', async () => { + const softwareId = uniqueId('software'); + const versionId = uniqueId('softwareversion'); + + const res = await inject(app, 'POST', '/v2.0.0/softwares', { + id: softwareId, + label: ['sw-nested'], + type: ['Software'], + hasVersion: [ + { id: versionId, label: ['v-nested'], type: ['SoftwareVersion'] }, + ], + }); + expect(res.statusCode).toBeGreaterThanOrEqual(200); + expect(res.statusCode).toBeLessThan(300); + trackId('softwares', softwareId); + trackId('softwareversions', versionId); + + const swGet = await inject( + app, 'GET', + `/v2.0.0/softwares/${encodeURIComponent(softwareId)}`, + ); + const sw = (Array.isArray(swGet.body) ? swGet.body[0] : swGet.body) as { + hasVersion?: { id: string }[]; + }; + expect(sw.hasVersion?.map((v) => v.id)).toContain(versionId); + + const verGet = await inject( + app, 'GET', + `/v2.0.0/softwareversions/${encodeURIComponent(versionId)}`, + ); + expect(verGet.statusCode).toBe(200); + const ver = (Array.isArray(verGet.body) ? verGet.body[0] : verGet.body) as { + id: string; label: string[]; + }; + expect(ver.id).toBe(versionId); + expect(ver.label).toEqual(['v-nested']); + }); +}); From d5e6c95e597852ee662f873e0525ca3abc0069e5 Mon Sep 17 00:00:00 2001 From: Maximiliano Osorio Date: Sat, 9 May 2026 19:12:13 -0400 Subject: [PATCH 38/46] =?UTF-8?q?test(e2e):=20nested=20POST=203-deep=20sw?= =?UTF-8?q?=E2=86=92ver=E2=86=92cfg=20(bug-089=20target)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/__tests__/e2e/nested-write-e2e.test.ts | 31 ++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/src/__tests__/e2e/nested-write-e2e.test.ts b/src/__tests__/e2e/nested-write-e2e.test.ts index 7fc9a22..a15ef26 100644 --- a/src/__tests__/e2e/nested-write-e2e.test.ts +++ b/src/__tests__/e2e/nested-write-e2e.test.ts @@ -56,4 +56,35 @@ describe('nested-write e2e — softwares.hasVersion (bug-089 class)', () => { expect(ver.id).toBe(versionId); expect(ver.label).toEqual(['v-nested']); }); + + it('POST software with nested version → nested configuration persists the full tree', async () => { + const swId = uniqueId('software'); + const verId = uniqueId('softwareversion'); + const cfgId = uniqueId('modelconfiguration'); + + const res = await inject(app, 'POST', '/v2.0.0/softwares', { + id: swId, label: ['sw-3deep'], type: ['Software'], + hasVersion: [{ + id: verId, label: ['v-3deep'], type: ['SoftwareVersion'], + hasConfiguration: [{ + id: cfgId, label: ['cfg-3deep'], type: ['ModelConfiguration'], + }], + }], + }); + expect(res.statusCode).toBeGreaterThanOrEqual(200); + expect(res.statusCode).toBeLessThan(300); + trackId('softwares', swId); + trackId('softwareversions', verId); + trackId('modelconfigurations', cfgId); + + const cfgGet = await inject( + app, 'GET', + `/v2.0.0/modelconfigurations/${encodeURIComponent(cfgId)}`, + ); + expect(cfgGet.statusCode).toBe(200); + const cfg = (Array.isArray(cfgGet.body) ? cfgGet.body[0] : cfgGet.body) as { + id: string; + }; + expect(cfg.id).toBe(cfgId); + }); }); From b2a30104337f9149ce8da9255a81627d715de7d7 Mon Sep 17 00:00:00 2001 From: Maximiliano Osorio Date: Sat, 9 May 2026 19:12:29 -0400 Subject: [PATCH 39/46] test(e2e): nested PUT updates child label only (bug-089 target) --- src/__tests__/e2e/nested-write-e2e.test.ts | 41 ++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/src/__tests__/e2e/nested-write-e2e.test.ts b/src/__tests__/e2e/nested-write-e2e.test.ts index a15ef26..e69de97 100644 --- a/src/__tests__/e2e/nested-write-e2e.test.ts +++ b/src/__tests__/e2e/nested-write-e2e.test.ts @@ -87,4 +87,45 @@ describe('nested-write e2e — softwares.hasVersion (bug-089 class)', () => { }; expect(cfg.id).toBe(cfgId); }); + + it('PUT software with nested hasVersion updates child label, parent label unchanged', async () => { + const swId = uniqueId('software'); + const verId = uniqueId('softwareversion'); + + await inject(app, 'POST', '/v2.0.0/softwares', { + id: swId, label: ['sw-parent-stable'], type: ['Software'], + hasVersion: [{ id: verId, label: ['v-old'], type: ['SoftwareVersion'] }], + }); + trackId('softwares', swId); + trackId('softwareversions', verId); + + const putRes = await inject( + app, 'PUT', + `/v2.0.0/softwares/${encodeURIComponent(swId)}`, + { + id: swId, label: ['sw-parent-stable'], type: ['Software'], + hasVersion: [{ id: verId, label: ['v-new'], type: ['SoftwareVersion'] }], + }, + ); + expect(putRes.statusCode).toBeGreaterThanOrEqual(200); + expect(putRes.statusCode).toBeLessThan(300); + + const swGet = await inject( + app, 'GET', + `/v2.0.0/softwares/${encodeURIComponent(swId)}`, + ); + const sw = (Array.isArray(swGet.body) ? swGet.body[0] : swGet.body) as { + label: string[]; + }; + expect(sw.label).toEqual(['sw-parent-stable']); + + const verGet = await inject( + app, 'GET', + `/v2.0.0/softwareversions/${encodeURIComponent(verId)}`, + ); + const ver = (Array.isArray(verGet.body) ? verGet.body[0] : verGet.body) as { + label: string[]; + }; + expect(ver.label).toEqual(['v-new']); + }); }); From b092279dbb44339e609fe19062f7231a3d72b496 Mon Sep 17 00:00:00 2001 From: Maximiliano Osorio Date: Sat, 9 May 2026 19:12:53 -0400 Subject: [PATCH 40/46] test(e2e): nested POST mixed inline+ref preserves existing data (bug-089 target) Currently FAILS: ID-only ref in hasVersion is treated as nested insert, hits "null value in column \"label\" violates not-null constraint". Confirms bug-089 gap. Test stays as regression target until bug-089 lands. --- src/__tests__/e2e/nested-write-e2e.test.ts | 41 ++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/src/__tests__/e2e/nested-write-e2e.test.ts b/src/__tests__/e2e/nested-write-e2e.test.ts index e69de97..5a5aec1 100644 --- a/src/__tests__/e2e/nested-write-e2e.test.ts +++ b/src/__tests__/e2e/nested-write-e2e.test.ts @@ -128,4 +128,45 @@ describe('nested-write e2e — softwares.hasVersion (bug-089 class)', () => { }; expect(ver.label).toEqual(['v-new']); }); + + it('POST software with mixed inline-new and ID-ref hasVersion entries: new is created, ref is linked', async () => { + const existingVerId = uniqueId('softwareversion'); + const existingSwShellId = uniqueId('software'); + // Pre-create the referenced version under its own software shell so we have an + // existing row to reference. + await inject(app, 'POST', '/v2.0.0/softwares', { + id: existingSwShellId, label: ['shell'], type: ['Software'], + hasVersion: [{ id: existingVerId, label: ['v-pre'], type: ['SoftwareVersion'] }], + }); + trackId('softwares', existingSwShellId); + trackId('softwareversions', existingVerId); + + const newSwId = uniqueId('software'); + const newVerId = uniqueId('softwareversion'); + const res = await inject(app, 'POST', '/v2.0.0/softwares', { + id: newSwId, label: ['sw-mixed'], type: ['Software'], + hasVersion: [ + { id: newVerId, label: ['v-fresh'], type: ['SoftwareVersion'] }, + { id: existingVerId }, + ], + }); + expect(res.statusCode).toBeGreaterThanOrEqual(200); + expect(res.statusCode).toBeLessThan(300); + trackId('softwares', newSwId); + trackId('softwareversions', newVerId); + + // Existing version label MUST NOT have been overwritten by the link. + const verGet = await inject( + app, 'GET', + `/v2.0.0/softwareversions/${encodeURIComponent(existingVerId)}`, + ); + const ver = (Array.isArray(verGet.body) ? verGet.body[0] : verGet.body) as { + label: string[]; + }; + expect(ver.label).toEqual(['v-pre']); + + // Note: hasVersion is a childFk relationship, so the existing version's FK may move + // from existingSwShellId to newSwId. Do not assert directionality of the move here; + // just assert the existing row's data was preserved. + }); }); From 901179a7f3141c64d3b295af5bc5d8b0d2cd93b3 Mon Sep 17 00:00:00 2001 From: Maximiliano Osorio Date: Sat, 9 May 2026 19:13:14 -0400 Subject: [PATCH 41/46] test(e2e): nested PUT replaces child set (bug-089 target) --- src/__tests__/e2e/nested-write-e2e.test.ts | 36 ++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/src/__tests__/e2e/nested-write-e2e.test.ts b/src/__tests__/e2e/nested-write-e2e.test.ts index 5a5aec1..6fc819a 100644 --- a/src/__tests__/e2e/nested-write-e2e.test.ts +++ b/src/__tests__/e2e/nested-write-e2e.test.ts @@ -169,4 +169,40 @@ describe('nested-write e2e — softwares.hasVersion (bug-089 class)', () => { // from existingSwShellId to newSwId. Do not assert directionality of the move here; // just assert the existing row's data was preserved. }); + + it('PUT software replaces hasVersion children: old children no longer linked, new children present', async () => { + const swId = uniqueId('software'); + const oldVerId = uniqueId('softwareversion'); + const newVerId = uniqueId('softwareversion'); + + await inject(app, 'POST', '/v2.0.0/softwares', { + id: swId, label: ['sw-replace'], type: ['Software'], + hasVersion: [{ id: oldVerId, label: ['v-old'], type: ['SoftwareVersion'] }], + }); + trackId('softwares', swId); + trackId('softwareversions', oldVerId); + + const putRes = await inject( + app, 'PUT', + `/v2.0.0/softwares/${encodeURIComponent(swId)}`, + { + id: swId, label: ['sw-replace'], type: ['Software'], + hasVersion: [{ id: newVerId, label: ['v-new'], type: ['SoftwareVersion'] }], + }, + ); + expect(putRes.statusCode).toBeGreaterThanOrEqual(200); + expect(putRes.statusCode).toBeLessThan(300); + trackId('softwareversions', newVerId); + + const swGet = await inject( + app, 'GET', + `/v2.0.0/softwares/${encodeURIComponent(swId)}`, + ); + const sw = (Array.isArray(swGet.body) ? swGet.body[0] : swGet.body) as { + hasVersion?: { id: string }[]; + }; + const ids = sw.hasVersion?.map((v) => v.id) ?? []; + expect(ids).toContain(newVerId); + expect(ids).not.toContain(oldVerId); + }); }); From 47f86ca346cf6dc6cd4049488ad18c86c750ad0a Mon Sep 17 00:00:00 2001 From: Maximiliano Osorio Date: Sat, 9 May 2026 19:14:17 -0400 Subject: [PATCH 42/46] docs(skill): add run-e2e-hasura project-local skill --- .claude/skills/run-e2e-hasura/SKILL.md | 117 +++++++++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 .claude/skills/run-e2e-hasura/SKILL.md diff --git a/.claude/skills/run-e2e-hasura/SKILL.md b/.claude/skills/run-e2e-hasura/SKILL.md new file mode 100644 index 0000000..6456ec6 --- /dev/null +++ b/.claude/skills/run-e2e-hasura/SKILL.md @@ -0,0 +1,117 @@ +--- +name: run-e2e-hasura +description: Use when running, writing, or debugging end-to-end integration tests for model-catalog-api against the local Hasura dev server at http://graphql.mint.local. Triggers on "run e2e", "test against hasura", "e2e fails", or working with files under model-catalog-api/src/__tests__/e2e/. +--- + +# Run E2E Tests Against Local Hasura + +## What this is + +End-to-end integration tests that exercise the full pipeline: + +``` +Vitest → buildApp() → fastify.inject() → routes → service.ts + → Apollo Client → http://graphql.mint.local/v1/graphql → Postgres +``` + +In-process Fastify + real Apollo + real local Hasura. No mocks below the HTTP layer. + +Suite is gated behind `npm run test:e2e`. Default `npm test` stays mock-only and fast. + +## Prereqs + +1. Local Hasura must be reachable at `http://graphql.mint.local/v1/graphql`. Quick check: + + ```bash + curl -sS -o /dev/null -w "%{http_code}\n" \ + -X POST http://graphql.mint.local/v1/graphql \ + -H "X-Hasura-Admin-Secret: CHANGEME" \ + -H "Content-Type: application/json" \ + -d '{"query":"{ __typename }"}' + ``` + Expected: `200`. If not: check `/etc/hosts` for `graphql.mint.local` and confirm any `kubectl port-forward` is running. + +2. `npm install` is up to date inside `model-catalog-api/`. + +## Run + +```bash +cd model-catalog-api +npm run test:e2e # all e2e files +npm run test:e2e -- junction-e2e # one file +npm run test:e2e -- nested-write-e2e +``` + +## Environment variables + +Defaults set by `src/__tests__/e2e/setup.ts`. Set in shell only to override. + +| Var | Default | Purpose | +|-----|---------|---------| +| `HASURA_GRAPHQL_URL` | `http://graphql.mint.local/v1/graphql` | Local Hasura GraphQL endpoint. | +| `HASURA_ADMIN_SECRET` | `CHANGEME` | Admin secret. Must match local Hasura config. | +| `MINT_E2E_MODE` | `1` (forced) | Flips `getWriteClient()` to use admin-secret instead of Bearer. | +| `LOG_LEVEL` | `warn` | Reduces Fastify log noise during tests. | + +## Writing new e2e tests + +Use the helpers in `src/__tests__/e2e/helpers.ts`: + +```ts +import { inject, trackId, uniqueId } from './helpers.js'; + +const id = uniqueId('software'); // collision-proof, prefixed with run id +trackId('softwares', id); // remember to delete in afterAll +const res = await inject(app, 'POST', '/v2.0.0/softwares', { id, label: ['x'], type: ['Software'] }); +``` + +Rules: +- Always assert via a fresh GET, not the response body. Catches read-vs-write divergence (the bug-087 class). +- Always `trackId(resource, id)` for every entity created. Cleanup runs in `afterAll`. +- Never share IDs across tests — `uniqueId(kind)` is collision-proof per call. + +## Hierarchy delete order + +`cleanup(app)` deletes in REVERSE creation order. Track parents before children: + +``` +Software → SoftwareVersion → ModelConfiguration → ModelConfigurationSetup +``` + +If you create a Setup, also `trackId` the Config, Version, and Software it depends on (in that order, parents first). + +## Don'ts + +- No `--threads` and no parallel test files. The shared dev DB makes parallel writes step on each other. The vitest config (`vitest.e2e.config.ts`) enforces `singleFork`. +- No fixture seeds. Each test creates its own parents inline. +- Never run this suite against a shared production DB. The cleanup is best-effort, not guaranteed. + +## Debugging recipes + +| Symptom | Cause / Fix | +|---------|-------------| +| `Local Hasura unreachable at http://graphql.mint.local/v1/graphql` | `kubectl port-forward` not running, or `/etc/hosts` missing the entry, or Hasura pod down. | +| `401`/`403` on a write-path test | `MINT_E2E_MODE=1` not set in the shell when running outside `npm run test:e2e`. | +| GraphQL error `field … not found in type …` | Schema drift. Run `cd model-catalog-api && npm run codegen` against the current Hasura, then re-check assertions. | +| `cleanup: N orphan(s) remain` warning at end of run | Manual SQL cleanup needed. The warning prints the `RUN_ID` and the SQL templates. Run them in `psql` against the local DB. | +| Test hangs > 30s | Hasura is slow or hung. Check `kubectl logs` for the Hasura pod and `kubectl logs` for the Postgres pod. | +| New e2e test fails on a fresh Hasura but passes against the deployed cluster | Local Hasura migrations / metadata are out of sync. Apply migrations from `graphql_engine/`. | + +## Manual orphan cleanup (if `RUN_ID` is known) + +```sql +-- Replace RUN_ID with the value printed in the cleanup warning. +DELETE FROM modelcatalog_software_version_grid + WHERE software_version_id LIKE '%-RUN_ID-%' OR grid_id LIKE '%-RUN_ID-%'; +DELETE FROM modelcatalog_software_version WHERE id LIKE '%-RUN_ID-%'; +DELETE FROM modelcatalog_software WHERE id LIKE '%-RUN_ID-%'; +DELETE FROM modelcatalog_grid WHERE id LIKE '%-RUN_ID-%'; +DELETE FROM modelcatalog_configuration WHERE id LIKE '%-RUN_ID-%'; +``` + +If the `RUN_ID` is unknown, all e2e rows have the prefix `e2e-` in the ID local part: + +```sql +DELETE FROM modelcatalog_software WHERE id LIKE '%/software-e2e-%'; +-- (and equivalent per table) +``` From d3e1f4a1793b4432841c7638ab5e044bd7dcbc72 Mon Sep 17 00:00:00 2001 From: Maximiliano Osorio Date: Sat, 9 May 2026 19:14:36 -0400 Subject: [PATCH 43/46] docs(claude): point to run-e2e-hasura skill --- CLAUDE.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..422fc5a --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,3 @@ +## Local Hasura E2E Tests + +E2E integration tests against the local Hasura dev server are run with `npm run test:e2e`. For details on prereqs, env vars, writing new tests, debugging, and orphan cleanup, invoke the `run-e2e-hasura` skill. From 73f987c9b81eb6049520cb7ae0cb8a471c21fe0e Mon Sep 17 00:00:00 2001 From: Maximiliano Osorio Date: Sat, 9 May 2026 19:50:03 -0400 Subject: [PATCH 44/46] fix(nested-writes): route link-only childFk refs to aliased FK update Link-only id-only childFk refs (e.g. POST software with mixed inline-new and existing-ref hasVersion entries) flowed into the Hasura nested-array data and hit Postgres NOT-NULL violations on child columns (label) before ON CONFLICT could resolve. Partition childFk children into inline-new vs link-only. Inline-new continues to ride the nested array (POST) / upsert array (PUT). Link-only surfaces as a top-level aliased link_N: update_modelcatalog_(where:{id:{_in:...}}, _set:{:}) after the root insert/update. Hasura runs top-level mutation fields sequentially so the parent row is materialized before the FK move. Applies recursively to nested children at every depth on both POST and PUT. Closes the bug-089 mixed inline+ref e2e regression target. --- src/__tests__/integration.test.ts | 50 ++++---- .../__tests__/mutation-compiler.test.ts | 121 +++++++++++++++++- src/mappers/mutation-compiler.ts | 117 ++++++++++++++--- 3 files changed, 247 insertions(+), 41 deletions(-) diff --git a/src/__tests__/integration.test.ts b/src/__tests__/integration.test.ts index fccb0fe..b808262 100644 --- a/src/__tests__/integration.test.ts +++ b/src/__tests__/integration.test.ts @@ -511,11 +511,10 @@ describe('Custom handler plain-ID resolution', () => { describe('PUT model with hasVersion sets software_id on child rows', () => { beforeEach(() => { mockQuery.mockReset(); mockMutate.mockReset() }) - // compilePut emits clear_ + upsert_ instead of - // the old clear_ + link_ pair. childSuffix = tableSuffix(childTable), - // so for modelcatalog_software_version the suffix is 'software_version' and plural is - // 'software_versions'. Response is now { id } only (no post-PUT fetch + transform). - it('emits clear+upsert update_modelcatalog_software_version mutations with software_id', async () => { + // bug-089: link-only childFk refs (id-only) flow OUT of the upsert path + // (would NULL-violate child NOT-NULL columns) into a top-level aliased + // `link_N` FK update. Inline-new children remain in the upsert path. + it('emits clear + link_N FK update for link-only PUT child (no upsert)', async () => { mockMutate.mockResolvedValueOnce({ data: {} }) const req = makeReq({ @@ -532,21 +531,22 @@ describe('PUT model with hasVersion sets software_id on child rows', () => { await (CatalogService as any).models_id_put(req, reply) expect(mockMutate).toHaveBeenCalledOnce() - // No post-PUT read query (new pipeline returns { id } directly) expect(mockQuery).not.toHaveBeenCalled() const args = mockMutate.mock.calls[0][0] const m = typeof args.mutation === 'string' ? args.mutation : args.mutation?.loc?.source?.body ?? '' expect(m).toContain('clear_software_versions: update_modelcatalog_software_version') - expect(m).toContain('upsert_software_versions: insert_modelcatalog_software_version') + expect(m).not.toContain('upsert_software_versions:') + expect(m).toContain('link_0: update_modelcatalog_software_version') expect(m).toContain('software_id: { _eq: $id }') expect(args.variables.child_ids_software_versions).toEqual(['https://w3id.org/okn/i/mint/V-1']) + expect(args.variables.link_ids_0).toEqual(['https://w3id.org/okn/i/mint/V-1']) + expect(args.variables.link_parent_0).toBe('https://w3id.org/okn/i/mint/MODEL-1') expect(args.variables.id).toBe('https://w3id.org/okn/i/mint/MODEL-1') - // Response shape: { id } only expect(reply._status).toBe(200) expect((reply._body as any).id).toBe('https://w3id.org/okn/i/mint/MODEL-1') }) - it('omits upsert branch when hasVersion is empty array (clear-only replace semantics)', async () => { + it('omits upsert AND link branches when hasVersion is empty array (clear-only)', async () => { mockMutate.mockResolvedValueOnce({ data: {} }) const req = makeReq({ @@ -564,14 +564,13 @@ describe('PUT model with hasVersion sets software_id on child rows', () => { const args = mockMutate.mock.calls[0][0] const m = typeof args.mutation === 'string' ? args.mutation : args.mutation?.loc?.source?.body ?? '' - // clear root always emitted; upsert root is still emitted (with empty objects array) expect(m).toContain('clear_software_versions:') - expect(m).toContain('upsert_software_versions:') - expect(m).not.toContain('link_software_versions:') + expect(m).not.toContain('upsert_software_versions:') + expect(m).not.toContain('link_0:') expect(args.variables.child_ids_software_versions).toEqual([]) }) - it('handles softwareversions.hasConfiguration -> software_version_id', async () => { + it('softwareversions.hasConfiguration link-only ref -> link_0 update_modelcatalog_configuration', async () => { mockMutate.mockResolvedValueOnce({ data: {} }) const req = makeReq({ @@ -588,17 +587,22 @@ describe('PUT model with hasVersion sets software_id on child rows', () => { const args = mockMutate.mock.calls[0][0] const m = typeof args.mutation === 'string' ? args.mutation : args.mutation?.loc?.source?.body ?? '' - // childSuffix for modelcatalog_configuration is 'configuration', plural 'configurations' expect(m).toContain('clear_configurations: update_modelcatalog_configuration') - expect(m).toContain('upsert_configurations: insert_modelcatalog_configuration') + expect(m).not.toContain('upsert_configurations:') + expect(m).toContain('link_0: update_modelcatalog_configuration') expect(m).toContain('software_version_id: { _eq: $id }') + expect(args.variables.link_ids_0).toEqual(['https://w3id.org/okn/i/mint/CFG-1']) + expect(args.variables.link_parent_0).toBe('https://w3id.org/okn/i/mint/V-1') }) }) describe('POST software with hasVersion links existing version rows', () => { beforeEach(() => { mockMutate.mockReset() }) - it('emits nested-insert for childFk relation with software_id injected into child row', async () => { + it('link-only childFk POST -> aliased link_N FK update (NOT nested insert)', async () => { + // bug-089: id-only ref MUST NOT enter the nested-array data (NOT-NULL on + // child columns would fire before ON CONFLICT). Surfaces as top-level + // `link_N: update_modelcatalog_software_version(...)` after root insert. mockMutate.mockResolvedValueOnce({ data: { insert_modelcatalog_software_one: { id: 'https://w3id.org/okn/i/mint/NEW-1' }, @@ -620,17 +624,13 @@ describe('POST software with hasVersion links existing version rows', () => { expect(mockMutate).toHaveBeenCalledOnce() const args = mockMutate.mock.calls[0][0] const m = typeof args.mutation === 'string' ? args.mutation : args.mutation?.loc?.source?.body ?? '' - // New pipeline: childFkColumn handled via nested insert inside $object (not a separate UPDATE root) expect(m).toContain('insert_modelcatalog_software_one') - expect(m).not.toContain('link_versions') + expect(m).toContain('link_0: update_modelcatalog_software_version') const obj = args.variables.object as Record expect(obj.id).toBe('https://w3id.org/okn/i/mint/NEW-1') - // child FK rows are embedded in the object under the Hasura relation key 'versions' - expect(obj.versions).toBeDefined() - const childData = obj.versions.data as any[] - expect(childData).toHaveLength(1) - expect(childData[0].id).toBe('https://w3id.org/okn/i/mint/V-99') - // Hasura auto-derives software_id from parent context. - expect(childData[0].software_id).toBeUndefined() + // versions key omitted entirely — only link-only children, no nested insert + expect(obj.versions).toBeUndefined() + expect(args.variables.link_ids_0).toEqual(['https://w3id.org/okn/i/mint/V-99']) + expect(args.variables.link_parent_0).toBe('https://w3id.org/okn/i/mint/NEW-1') }) }) diff --git a/src/mappers/__tests__/mutation-compiler.test.ts b/src/mappers/__tests__/mutation-compiler.test.ts index 6a9c737..b801ed8 100644 --- a/src/mappers/__tests__/mutation-compiler.test.ts +++ b/src/mappers/__tests__/mutation-compiler.test.ts @@ -161,7 +161,7 @@ describe('compilePost', () => { childFkColumn: 'software_version_id', children: [ { table: 'modelcatalog_configuration', id: 'cfg-a', columns: { label: 'A' }, junctions: [], childFks: [] }, - { table: 'modelcatalog_configuration', id: 'cfg-b', columns: {}, junctions: [], childFks: [] }, + { table: 'modelcatalog_configuration', id: 'cfg-b', columns: { label: 'B' }, junctions: [], childFks: [] }, ], }, ], @@ -178,6 +178,68 @@ describe('compilePost', () => { expect(arr[0].label).toBe('A'); expect(arr[1].software_version_id).toBeUndefined(); }); + + it('childFk link-only ref emits aliased FK update, not nested insert (bug-089 mixed)', () => { + // Mixed: cfg-a is inline-new (columns), cfg-existing is link-only (id only). + // link-only must NOT enter nested data array (would NULL-violate label), + // must surface as a top-level aliased update setting childFk on existing row. + const tree: WriteNode = { + table: 'modelcatalog_software', + id: 'sw-1', + columns: { label: 'sw' }, + junctions: [], + childFks: [ + { + apiFieldName: 'hasVersion', + hasuraRelName: 'versions', + childTable: 'modelcatalog_software_version', + childFkColumn: 'software_id', + children: [ + { table: 'modelcatalog_software_version', id: 'ver-new', columns: { label: 'fresh' }, junctions: [], childFks: [] }, + { table: 'modelcatalog_software_version', id: 'ver-existing', columns: {}, junctions: [], childFks: [] }, + ], + }, + ], + }; + const { mutation, variables } = compilePost(tree); + const obj = variables.object as any; + // Nested array contains ONLY inline-new children + expect(obj.versions.data).toHaveLength(1); + expect(obj.versions.data[0].id).toBe('ver-new'); + // Top-level aliased link op for the link-only ref + expect(mutation).toMatch(/link_0:\s*update_modelcatalog_software_version/); + expect(mutation).toMatch(/_in:\s*\$link_ids_0/); + expect(mutation).toMatch(/_set:\s*\{\s*software_id:\s*\$link_parent_0\s*\}/); + expect(variables.link_ids_0).toEqual(['ver-existing']); + expect(variables.link_parent_0).toBe('sw-1'); + }); + + it('childFk all-link-only omits nested rel and emits only link op', () => { + const tree: WriteNode = { + table: 'modelcatalog_software', + id: 'sw-2', + columns: { label: 'sw' }, + junctions: [], + childFks: [ + { + apiFieldName: 'hasVersion', + hasuraRelName: 'versions', + childTable: 'modelcatalog_software_version', + childFkColumn: 'software_id', + children: [ + { table: 'modelcatalog_software_version', id: 'ver-x', columns: {}, junctions: [], childFks: [] }, + { table: 'modelcatalog_software_version', id: 'ver-y', columns: {}, junctions: [], childFks: [] }, + ], + }, + ], + }; + const { mutation, variables } = compilePost(tree); + const obj = variables.object as any; + expect(obj.versions).toBeUndefined(); + expect(variables.link_ids_0).toEqual(['ver-x', 'ver-y']); + expect(variables.link_parent_0).toBe('sw-2'); + expect(mutation).toMatch(/link_0:\s*update_modelcatalog_software_version/); + }); }); describe('compilePut', () => { @@ -287,6 +349,63 @@ describe('compilePut', () => { expect(upsertObjs[0].label).toBe('A'); }); + it('childFk link-only PUT child becomes link op, not upsert (bug-089 mixed)', () => { + const tree: WriteNode = { + table: 'modelcatalog_software', + id: 'sw-mixed', + columns: {}, + junctions: [], + childFks: [ + { + apiFieldName: 'hasVersion', + hasuraRelName: 'versions', + childTable: 'modelcatalog_software_version', + childFkColumn: 'software_id', + children: [ + { table: 'modelcatalog_software_version', id: 'ver-new', columns: { label: 'fresh' }, junctions: [], childFks: [] }, + { table: 'modelcatalog_software_version', id: 'ver-link', columns: {}, junctions: [], childFks: [] }, + ], + }, + ], + }; + const { mutation, variables } = compilePut(tree); + // clear still considers ALL incoming ids (so link-only stays attached if was already) + expect(variables.child_ids_software_versions).toEqual(['ver-new', 'ver-link']); + // upsert array contains ONLY inline-new + const upsertObjs = variables.child_software_versions as any[]; + expect(upsertObjs).toHaveLength(1); + expect(upsertObjs[0].id).toBe('ver-new'); + // link-only handled by aliased link update + expect(mutation).toMatch(/link_0:\s*update_modelcatalog_software_version/); + expect(variables.link_ids_0).toEqual(['ver-link']); + expect(variables.link_parent_0).toBe('sw-mixed'); + }); + + it('childFk all-link-only PUT skips upsert + emits only link op', () => { + const tree: WriteNode = { + table: 'modelcatalog_software', + id: 'sw-all-link', + columns: {}, + junctions: [], + childFks: [ + { + apiFieldName: 'hasVersion', + hasuraRelName: 'versions', + childTable: 'modelcatalog_software_version', + childFkColumn: 'software_id', + children: [ + { table: 'modelcatalog_software_version', id: 'ver-1', columns: {}, junctions: [], childFks: [] }, + ], + }, + ], + }; + const { mutation, variables } = compilePut(tree); + expect(variables.child_software_versions).toBeUndefined(); + expect(mutation).not.toMatch(/upsert_software_versions/); + expect(variables.link_ids_0).toEqual(['ver-1']); + expect(variables.link_parent_0).toBe('sw-all-link'); + }); + it('hoists complex objects into variables (no JSON in mutation string)', () => { const tree: WriteNode = { table: 'modelcatalog_configuration', diff --git a/src/mappers/mutation-compiler.ts b/src/mappers/mutation-compiler.ts index b23d31f..78b849b 100644 --- a/src/mappers/mutation-compiler.ts +++ b/src/mappers/mutation-compiler.ts @@ -23,7 +23,8 @@ function buildInsertObject(node: WriteNode): Record { obj[j.hasuraRelName] = buildJunctionInsert(j); } for (const c of node.childFks) { - obj[c.hasuraRelName] = buildChildFkInsert(c); + const insert = buildChildFkInsert(c); + if (insert !== null) obj[c.hasuraRelName] = insert; } return obj; } @@ -36,6 +37,43 @@ function isLinkOnly(child: WriteNode): boolean { ); } +function partitionChildFkChildren(c: ChildFkEdge): { inline: WriteNode[]; linkIds: string[] } { + const inline: WriteNode[] = []; + const linkIds: string[] = []; + for (const ch of c.children) { + if (isLinkOnly(ch)) linkIds.push(ch.id); + else inline.push(ch); + } + return { inline, linkIds }; +} + +interface LinkOp { + childTable: string; + childFkColumn: string; + parentId: string; + ids: string[]; +} + +function collectLinkOps(node: WriteNode, ops: LinkOp[]): void { + for (const c of node.childFks) { + const { inline, linkIds } = partitionChildFkChildren(c); + if (linkIds.length > 0) { + ops.push({ + childTable: c.childTable, + childFkColumn: c.childFkColumn, + parentId: node.id, + ids: linkIds, + }); + } + for (const ch of inline) collectLinkOps(ch, ops); + } + for (const j of node.junctions) { + for (const ch of j.children) { + if (!isLinkOnly(ch)) collectLinkOps(ch, ops); + } + } +} + function buildJunctionInsert(j: JunctionEdge): Record { const data = j.children.map((child, idx) => buildJunctionRow(j, child, idx)); return { @@ -70,13 +108,19 @@ function buildJunctionRow( }; } -function buildChildFkInsert(c: ChildFkEdge): Record { - const data = c.children.map((child) => buildInsertObject(child)); +function buildChildFkInsert(c: ChildFkEdge): Record | null { + // Link-only children (id-only refs to existing rows) MUST NOT enter the + // nested-array data — Hasura would emit a bare INSERT and Postgres NOT-NULL + // constraints (e.g. label) would fire before ON CONFLICT can resolve. + // They are handled out-of-band as aliased FK updates by compilePost/compilePut. + const { inline } = partitionChildFkChildren(c); + if (inline.length === 0) return null; + const data = inline.map((child) => buildInsertObject(child)); return { data, on_conflict: { constraint: `${c.childTable}_pkey`, - update_columns: [...new Set(c.children.flatMap((ch) => Object.keys(ch.columns)))], + update_columns: [...new Set(inline.flatMap((ch) => Object.keys(ch.columns)))], }, }; } @@ -85,6 +129,26 @@ function buildPutJunctionRow(j: JunctionEdge, idx: number): Record, +): void { + ops.forEach((op, i) => { + const childSuffix = tableSuffix(op.childTable); + const idsVar = `link_ids_${i}`; + const parentVar = `link_parent_${i}`; + variables[idsVar] = op.ids; + variables[parentVar] = op.parentId; + varDecls.push(`$${idsVar}: [String!]!`); + varDecls.push(`$${parentVar}: String!`); + parts.push( + `link_${i}: update_modelcatalog_${childSuffix}(where: { id: { _in: $${idsVar} } }, _set: { ${op.childFkColumn}: $${parentVar} }) { affected_rows }`, + ); + }); +} + export function compilePut(tree: WriteNode): CompiledMutation { const suffix = tableSuffix(tree.table); const variables: Record = { id: tree.id, set: tree.columns }; @@ -117,22 +181,32 @@ export function compilePut(tree: WriteNode): CompiledMutation { const childSuffixPlural = `${childSuffix}s`; const idsVar = `child_ids_${childSuffixPlural}`; const objsVar = `child_${childSuffixPlural}`; + // Clear keeps ALL incoming ids attached (link-only and inline-new alike) — + // anything currently under parent and not in incoming gets FK nulled. const ids = c.children.map((ch) => ch.id); - const objects = c.children.map((ch) => ({ ...buildInsertObject(ch), [c.childFkColumn]: tree.id })); variables[idsVar] = ids; - variables[objsVar] = objects; varDecls.push(`$${idsVar}: [String!]!`); - varDecls.push(`$${objsVar}: [modelcatalog_${childSuffix}_insert_input!]!`); - const updateCols = [...new Set(c.children.flatMap((ch) => Object.keys(ch.columns)))]; - const updateColsStr = updateCols.length > 0 ? updateCols.join(', ') : ''; parts.push( `clear_${childSuffixPlural}: update_modelcatalog_${childSuffix}(where: { ${c.childFkColumn}: { _eq: $id }, id: { _nin: $${idsVar} } }, _set: { ${c.childFkColumn}: null }) { affected_rows }`, ); - parts.push( - `upsert_${childSuffixPlural}: insert_modelcatalog_${childSuffix}(objects: $${objsVar}, on_conflict: { constraint: modelcatalog_${childSuffix}_pkey, update_columns: [${updateColsStr}] }) { affected_rows }`, - ); + // Upsert array contains ONLY inline-new (link-only goes to link op below). + const { inline } = partitionChildFkChildren(c); + if (inline.length > 0) { + const objects = inline.map((ch) => ({ ...buildInsertObject(ch), [c.childFkColumn]: tree.id })); + variables[objsVar] = objects; + varDecls.push(`$${objsVar}: [modelcatalog_${childSuffix}_insert_input!]!`); + const updateCols = [...new Set(inline.flatMap((ch) => Object.keys(ch.columns)))]; + const updateColsStr = updateCols.length > 0 ? updateCols.join(', ') : ''; + parts.push( + `upsert_${childSuffixPlural}: insert_modelcatalog_${childSuffix}(objects: $${objsVar}, on_conflict: { constraint: modelcatalog_${childSuffix}_pkey, update_columns: [${updateColsStr}] }) { affected_rows }`, + ); + } } + const linkOps: LinkOp[] = []; + collectLinkOps(tree, linkOps); + appendLinkOps(linkOps, parts, varDecls, variables); + const mutation = ` mutation UpdateMutation(${varDecls.join(', ')}) { ${parts.join('\n ')} @@ -149,10 +223,23 @@ export function compilePost(tree: WriteNode): CompiledMutation { // determined by parent insert' (validation-failed). Leave them off. const suffix = tableSuffix(tree.table); + const variables: Record = { object }; + const varDecls: string[] = [`$object: modelcatalog_${suffix}_insert_input!`]; + const parts: string[] = [ + `insert_modelcatalog_${suffix}_one(object: $object) { id }`, + ]; + + // Link-only childFk refs cannot ride the nested-array insert (NOT-NULL on + // child columns fires before ON CONFLICT). Emit aliased FK updates after the + // root insert. Hasura runs top-level mutation fields sequentially. + const linkOps: LinkOp[] = []; + collectLinkOps(tree, linkOps); + appendLinkOps(linkOps, parts, varDecls, variables); + const mutation = ` - mutation CreateMutation($object: modelcatalog_${suffix}_insert_input!) { - insert_modelcatalog_${suffix}_one(object: $object) { id } + mutation CreateMutation(${varDecls.join(', ')}) { + ${parts.join('\n ')} } `; - return { mutation, variables: { object } }; + return { mutation, variables }; } From b68329b81f8360586c19a2c80bb47f10ace4a62f Mon Sep 17 00:00:00 2001 From: Maximiliano Osorio Date: Sat, 9 May 2026 19:56:05 -0400 Subject: [PATCH 45/46] fix(test): widen on_conflict type to include constraint field --- src/mappers/__tests__/mutation-compiler.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mappers/__tests__/mutation-compiler.test.ts b/src/mappers/__tests__/mutation-compiler.test.ts index b801ed8..a2a9c34 100644 --- a/src/mappers/__tests__/mutation-compiler.test.ts +++ b/src/mappers/__tests__/mutation-compiler.test.ts @@ -53,7 +53,7 @@ describe('compilePost', () => { const inputs = (obj.inputs as { data: unknown[]; on_conflict: { update_columns: string[] } }); expect(inputs.on_conflict.update_columns).toEqual([]); const inputRow = inputs.data[0] as Record; - const nested = inputRow.input as { data: any; on_conflict: { update_columns: string[] } }; + const nested = inputRow.input as { data: any; on_conflict: { update_columns: string[]; constraint: string } }; expect(nested.data.id).toBe('ds-1'); expect(nested.data.label).toBe('ds-label'); expect(nested.on_conflict.update_columns).toEqual(['label']); From ec395f1dd47bdc52541bbfe474ce78f64e8889ac Mon Sep 17 00:00:00 2001 From: Maximiliano Osorio Date: Sun, 10 May 2026 12:36:33 -0400 Subject: [PATCH 46/46] test(e2e): assert hasConfiguration shape on softwareversions GET Adds 3 e2e read-shape tests covering bug-090 class: - GET /softwareversions/{id} surfaces hasConfiguration array (id+label). - Empty hasConfiguration omitted per v1.8.0 contract. - Embedded version inside GET /softwares/{id} stays shallow (no hasConfiguration), locking field-maps depth contract. --- src/__tests__/e2e/nested-write-e2e.test.ts | 100 +++++++++++++++++++++ 1 file changed, 100 insertions(+) diff --git a/src/__tests__/e2e/nested-write-e2e.test.ts b/src/__tests__/e2e/nested-write-e2e.test.ts index 6fc819a..1f28c1c 100644 --- a/src/__tests__/e2e/nested-write-e2e.test.ts +++ b/src/__tests__/e2e/nested-write-e2e.test.ts @@ -206,3 +206,103 @@ describe('nested-write e2e — softwares.hasVersion (bug-089 class)', () => { expect(ids).not.toContain(oldVerId); }); }); + +describe('read-shape e2e — softwareversions.hasConfiguration (bug-090 class)', () => { + it('GET /softwareversions/{id} surfaces hasConfiguration with nested id and label', async () => { + const swId = uniqueId('software'); + const verId = uniqueId('softwareversion'); + const cfgId = uniqueId('modelconfiguration'); + + const res = await inject(app, 'POST', '/v2.0.0/softwares', { + id: swId, label: ['sw-readshape'], type: ['Software'], + hasVersion: [{ + id: verId, label: ['v-readshape'], type: ['SoftwareVersion'], + hasConfiguration: [{ + id: cfgId, label: ['cfg-readshape'], type: ['ModelConfiguration'], + }], + }], + }); + expect(res.statusCode).toBeGreaterThanOrEqual(200); + expect(res.statusCode).toBeLessThan(300); + trackId('softwares', swId); + trackId('softwareversions', verId); + trackId('modelconfigurations', cfgId); + + const verGet = await inject( + app, 'GET', + `/v2.0.0/softwareversions/${encodeURIComponent(verId)}`, + ); + expect(verGet.statusCode).toBe(200); + const ver = (Array.isArray(verGet.body) ? verGet.body[0] : verGet.body) as { + id: string; + hasConfiguration?: { id: string; label?: string[] }[]; + }; + expect(ver.id).toBe(verId); + expect(ver.hasConfiguration).toBeDefined(); + expect(ver.hasConfiguration?.map((c) => c.id)).toContain(cfgId); + const cfgEntry = ver.hasConfiguration?.find((c) => c.id === cfgId); + expect(cfgEntry?.label).toEqual(['cfg-readshape']); + }); + + it('GET /softwareversions/{id} omits hasConfiguration when version has no configurations', async () => { + const swId = uniqueId('software'); + const verId = uniqueId('softwareversion'); + + const res = await inject(app, 'POST', '/v2.0.0/softwares', { + id: swId, label: ['sw-empty'], type: ['Software'], + hasVersion: [{ id: verId, label: ['v-empty'], type: ['SoftwareVersion'] }], + }); + expect(res.statusCode).toBeGreaterThanOrEqual(200); + expect(res.statusCode).toBeLessThan(300); + trackId('softwares', swId); + trackId('softwareversions', verId); + + const verGet = await inject( + app, 'GET', + `/v2.0.0/softwareversions/${encodeURIComponent(verId)}`, + ); + expect(verGet.statusCode).toBe(200); + const ver = (Array.isArray(verGet.body) ? verGet.body[0] : verGet.body) as { + id: string; + hasConfiguration?: unknown; + }; + expect(ver.id).toBe(verId); + // v1.8.0 contract: empty array relationships are omitted entirely. + expect(ver.hasConfiguration).toBeUndefined(); + }); + + it('GET /softwares/{id} only exposes shallow hasVersion (no hasConfiguration on embedded version)', async () => { + const swId = uniqueId('software'); + const verId = uniqueId('softwareversion'); + const cfgId = uniqueId('modelconfiguration'); + + const res = await inject(app, 'POST', '/v2.0.0/softwares', { + id: swId, label: ['sw-shallow'], type: ['Software'], + hasVersion: [{ + id: verId, label: ['v-shallow'], type: ['SoftwareVersion'], + hasConfiguration: [{ + id: cfgId, label: ['cfg-shallow'], type: ['ModelConfiguration'], + }], + }], + }); + expect(res.statusCode).toBeGreaterThanOrEqual(200); + expect(res.statusCode).toBeLessThan(300); + trackId('softwares', swId); + trackId('softwareversions', verId); + trackId('modelconfigurations', cfgId); + + const swGet = await inject( + app, 'GET', + `/v2.0.0/softwares/${encodeURIComponent(swId)}`, + ); + expect(swGet.statusCode).toBe(200); + const sw = (Array.isArray(swGet.body) ? swGet.body[0] : swGet.body) as { + hasVersion?: ({ id: string; hasConfiguration?: unknown })[]; + }; + const embeddedVer = sw.hasVersion?.find((v) => v.id === verId); + expect(embeddedVer).toBeDefined(); + // field-maps modelcatalog_software only selects id+label+description on versions; + // hasConfiguration MUST NOT appear on the embedded shallow object. + expect(embeddedVer?.hasConfiguration).toBeUndefined(); + }); +});