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) +``` 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/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. 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..1bd12f8 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", @@ -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/src/__tests__/e2e/helpers.ts b/src/__tests__/e2e/helpers.ts new file mode 100644 index 0000000..87c68fd --- /dev/null +++ b/src/__tests__/e2e/helpers.ts @@ -0,0 +1,91 @@ +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[] = []; + // 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: deleteHeaders, + }); + 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; +} diff --git a/src/__tests__/e2e/junction-e2e.test.ts b/src/__tests__/e2e/junction-e2e.test.ts new file mode 100644 index 0000000..66541a1 --- /dev/null +++ b/src/__tests__/e2e/junction-e2e.test.ts @@ -0,0 +1,217 @@ +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]); + }); + + 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); + }); + + 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); + }); + + 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([]); + }); + + 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); + }); + + 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); + } + }); +}); 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..1f28c1c --- /dev/null +++ b/src/__tests__/e2e/nested-write-e2e.test.ts @@ -0,0 +1,308 @@ +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']); + }); + + 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); + }); + + 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']); + }); + + 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. + }); + + 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); + }); +}); + +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(); + }); +}); 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}`, + ); + } +} 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'); + }); +}); diff --git a/src/__tests__/hasura-client.test.ts b/src/__tests__/hasura-client.test.ts new file mode 100644 index 0000000..80d9418 --- /dev/null +++ b/src/__tests__/hasura-client.test.ts @@ -0,0 +1,70 @@ +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; + 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> { + 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 + } + + 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/__tests__/integration.test.ts b/src/__tests__/integration.test.ts index e7ad243..720269e 100644 --- a/src/__tests__/integration.test.ts +++ b/src/__tests__/integration.test.ts @@ -479,17 +479,11 @@ describe('Custom handler URI strict mode', () => { 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 () => { + // 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: {} }) - 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: encodeURIComponent('https://w3id.org/okn/i/mint/MODEL-1') }, @@ -505,23 +499,23 @@ describe('PUT model with hasVersion sets software_id on child rows', () => { await (CatalogService as any).models_id_put(req, reply) expect(mockMutate).toHaveBeenCalledOnce() + 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).not.toContain('upsert_software_versions:') + expect(m).toContain('link_0: update_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.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') + 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 AND link branches when hasVersion is empty array (clear-only)', 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: encodeURIComponent('https://w3id.org/okn/i/mint/MODEL-2') }, @@ -538,22 +532,14 @@ 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([]) + expect(m).toContain('clear_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: {} }) - 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: encodeURIComponent('https://w3id.org/okn/i/mint/V-1') }, @@ -570,16 +556,21 @@ 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_configurations: update_modelcatalog_configuration') - expect(m).toContain('link_configurations: update_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(m).toContain('software_version_id: $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 insert + link_versions update with software_id = parentId', 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' }, @@ -602,9 +593,12 @@ describe('POST software with hasVersion links existing version rows', () => { const args = mockMutate.mock.calls[0][0] const m = typeof args.mutation === 'string' ? args.mutation : args.mutation?.loc?.source?.body ?? '' 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).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') + // 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/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(), diff --git a/src/mappers/__tests__/mutation-compiler.test.ts b/src/mappers/__tests__/mutation-compiler.test.ts new file mode 100644 index 0000000..a2a9c34 --- /dev/null +++ b/src/mappers/__tests__/mutation-compiler.test.ts @@ -0,0 +1,432 @@ +import { describe, it, expect } from 'vitest'; +import { compilePost, compilePut } 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', + hasuraRelName: 'inputs', + 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.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[]; constraint: 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('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', + 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-existing', + columns: {}, + junctions: [], + childFks: [], + }, + ], + }, + ], + childFks: [], + }; + const { variables } = compilePost(tree); + const obj = variables.object as Record; + 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', () => { + const tree: WriteNode = { + table: 'modelcatalog_configuration', + id: 'cfg-3', + columns: {}, + junctions: [ + { + apiFieldName: 'hasInput', + junctionTable: 'modelcatalog_configuration_input', + hasuraRelName: 'inputs', + 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).inputs.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', + hasuraRelName: 'configurations', + childTable: 'modelcatalog_configuration', + childFkColumn: 'software_version_id', + children: [ + { table: 'modelcatalog_configuration', id: 'cfg-a', columns: { label: 'A' }, junctions: [], childFks: [] }, + { table: 'modelcatalog_configuration', id: 'cfg-b', columns: { label: 'B' }, junctions: [], childFks: [] }, + ], + }, + ], + }; + const { variables } = compilePost(tree); + const obj = variables.object as any; + expect(obj.configurations).toBeDefined(); + const arr = obj.configurations.data as any[]; + expect(arr.length).toBe(2); + // 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).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', () => { + 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', + hasuraRelName: 'inputs', + 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', + hasuraRelName: 'inputs', + 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', + hasuraRelName: 'configurations', + 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(/_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[]; + expect(upsertObjs[0].id).toBe('cfg-a'); + expect(upsertObjs[0].software_version_id).toBe('sv-1'); + 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', + id: 'cfg-3', + 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', 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/__tests__/nested-tree.test.ts b/src/mappers/__tests__/nested-tree.test.ts new file mode 100644 index 0000000..034a1f6 --- /dev/null +++ b/src/mappers/__tests__/nested-tree.test.ts @@ -0,0 +1,254 @@ +import { describe, it, expect } from 'vitest'; +import { + MAX_DEPTH, + MAX_NODES, + MAX_ARRAY_LENGTH, + ValidationError, + type WriteNode, + type JunctionEdge, + type ChildFkEdge, + type BuildTreeOptions, +} 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', () => { + 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', + hasuraRelName: 'inputs', + junctionRelName: 'input', + parentFkColumn: 'configuration_id', + targetFkColumn: 'input_id', + junctionColumns: [], + children: [], + }; + const child: ChildFkEdge = { + apiFieldName: 'hasConfiguration', + hasuraRelName: 'configurations', + 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'); + expect(child.hasuraRelName).toBe('configurations'); + }); +}); + +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}$/); + }); +}); + +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' }); + }); +}); + +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.hasuraRelName).toBe('configurations'); + 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'); + }); +}); + +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/__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'); + }); +}); diff --git a/src/mappers/__tests__/request.test.ts b/src/mappers/__tests__/request.test.ts index 881c537..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,163 +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'); - expect(targetConflict['update_columns']).toEqual(['label']); - }); - - 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: passes caller-supplied IDs through unchanged (validation at service boundary)', () => { - // Bare shortnames are rejected upstream in service.ts; buildJunctionInserts - // trusts the input and does not reconstruct. - const body = { - hasModelCategory: ['https://w3id.org/okn/i/mint/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('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/mutation-compiler.ts b/src/mappers/mutation-compiler.ts new file mode 100644 index 0000000..78b849b --- /dev/null +++ b/src/mappers/mutation-compiler.ts @@ -0,0 +1,245 @@ +/** + * 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.hasuraRelName] = buildJunctionInsert(j); + } + for (const c of node.childFks) { + const insert = buildChildFkInsert(c); + if (insert !== null) obj[c.hasuraRelName] = insert; + } + return obj; +} + +function isLinkOnly(child: WriteNode): boolean { + return ( + Object.keys(child.columns).length === 0 && + child.junctions.length === 0 && + child.childFks.length === 0 + ); +} + +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 { + data, + on_conflict: { + constraint: `${j.junctionTable}_pkey`, + update_columns: [], + }, + }; +} + +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 | 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(inline.flatMap((ch) => Object.keys(ch.columns)))], + }, + }; +} + +function buildPutJunctionRow(j: JunctionEdge, idx: number): Record { + return buildJunctionRow(j, j.children[idx], idx); +} + +function appendLinkOps( + ops: LinkOp[], + parts: string[], + varDecls: string[], + variables: 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 }; + 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), + [j.parentFkColumn]: tree.id, + })); + 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}`; + // 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); + variables[idsVar] = ids; + varDecls.push(`$${idsVar}: [String!]!`); + parts.push( + `clear_${childSuffixPlural}: update_modelcatalog_${childSuffix}(where: { ${c.childFkColumn}: { _eq: $id }, id: { _nin: $${idsVar} } }, _set: { ${c.childFkColumn}: null }) { 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 ')} + } + `; + return { mutation, variables }; +} + +export function compilePost(tree: WriteNode): CompiledMutation { + const object = buildInsertObject(tree); + + // 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 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(${varDecls.join(', ')}) { + ${parts.join('\n ')} + } + `; + return { mutation, variables }; +} diff --git a/src/mappers/nested-tree.ts b/src/mappers/nested-tree.ts new file mode 100644 index 0000000..6daad17 --- /dev/null +++ b/src/mappers/nested-tree.ts @@ -0,0 +1,321 @@ +/** + * 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; + hasuraRelName: string; + junctionRelName: string; + parentFkColumn: string; + targetFkColumn: string; + junctionColumns: Record[]; + children: WriteNode[]; +} + +export interface ChildFkEdge { + apiFieldName: string; + hasuraRelName: string; + childTable: string; + 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; + maxDepth: number; +} + +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, + maxDepth: ctx.maxDepth, + }; + 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!, + hasuraRelName: rel.hasuraRelName, + junctionRelName: rel.junctionRelName!, + parentFkColumn: rel.parentFkColumn!, + targetFkColumn: resolveTargetFkColumn(rel), + junctionColumns, + children, + }; +} + +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, + maxDepth: ctx.maxDepth, + }; + children.push(buildNode(item as Record, targetCfg, childCtx)); + }); + return { + apiFieldName, + hasuraRelName: rel.hasuraRelName, + childTable: targetCfg.hasuraTable, + childFkColumn: rel.childFkColumn!, + children, + }; +} + +function buildNode( + body: Record, + cfg: ResourceConfig, + ctx: BuildContext, + excludeKeys: Set = new Set(), +): WriteNode { + 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) { + 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); + } else if (rel.childFkColumn) { + const edge = buildChildFkEdge(key, rel, value, ctx); + if (edge) childFks.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 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); +} diff --git a/src/mappers/request.ts b/src/mappers/request.ts index ead793f..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,100 +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 = {}; - - // Caller-supplied IDs must be full URIs (validated upstream at the - // service boundary). Missing IDs auto-generate (D-02). - const rawId = item['id'] as string | undefined; - nestedData['id'] = rawId ? rawId : `${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: ['label'], - }, - }, - }; - 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; -} 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, diff --git a/src/service.ts b/src/service.ts index 163a6f6..2eca21f 100644 --- a/src/service.ts +++ b/src/service.ts @@ -8,10 +8,10 @@ * 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, buildJunctionInserts } 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' @@ -224,124 +224,57 @@ class CatalogServiceImpl { return } - const body = req.body || {} - if (typeof body.id === 'string' && body.id !== '' && !isFullUri(body.id)) { - reply.code(400).send({ - error: 'Resource ID must be a full URI', - hint: `Got "${body.id}". Pass full URI in body.id (https:// or http://).`, - }) - return - } - const badRel = findBadBodyRelationshipId(body as Record, resourceConfig as any) - if (badRel) { - reply.code(400).send({ - error: `Bad body-supplied ID in relationship "${badRel.field}"`, - hint: `Got "${badRel.got}". Nested IDs must be full URIs (https:// or http://).`, - }) + const authHeader = req.headers?.authorization + if (!authHeader) { + reply.code(401).send({ error: 'Authorization header required' }) return } - 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 - } + const body = req.body || {} - // Generate a URI-based ID if not provided - if (!input['id']) { - input['id'] = `${ID_PREFIX}${randomUUID()}` + 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 } - // 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 - }) - .filter((x): x is string => !!x) - - if (newIds.length === 0) continue - - 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 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 @@ -364,193 +297,50 @@ class CatalogServiceImpl { return } - const id = decodeURIComponent(req.params.id) - if (!id.startsWith('https://') && !id.startsWith('http://')) { - reply.code(400).send({ - error: 'Resource ID must be a full URL-encoded URI', - hint: `Got "${id}". Pass URL-encoded full URI, e.g. /${resource}/${encodeURIComponent(resourceConfig.idPrefix + id)}`, - }) - return - } - const fullId = id - const body = req.body || {} - const badRel = findBadBodyRelationshipId(body as Record, resourceConfig as any) - if (badRel) { - reply.code(400).send({ - error: `Bad body-supplied ID in relationship "${badRel.field}"`, - hint: `Got "${badRel.got}". Nested IDs must be full URIs (https:// or http://).`, - }) - return - } - 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 : `${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 - }) - .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 diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..c341e4c --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts'], + exclude: ['src/__tests__/e2e/**', 'node_modules/**', 'dist/**'], + }, +}); 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 }, + }, +});