diff --git a/config/contracts/registry-governance.json b/config/contracts/registry-governance.json index 9659ea0..34c3c3c 100644 --- a/config/contracts/registry-governance.json +++ b/config/contracts/registry-governance.json @@ -379,6 +379,66 @@ ], "breakingFields": ["kind", "userMessageKey", "action", "telemetryEvent"] }, + { + "registryId": "FE-REG-QUERY-INVALIDATION", + "path": "src/features/installed-feature-contracts.ts", + "exportName": "INVALIDATION_REGISTRY", + "rowsPath": "edges", + "rowKeyFields": [ + "topicId", + "namespace.namespaceId", + "namespace.namespaceVersion" + ], + "owner": "feature-frontend-server-state-caching-contract", + "requiredFields": [ + "topicId", + "namespace.namespaceId", + "namespace.namespaceVersion" + ], + "fieldTypes": { + "topicId": "string", + "namespace.namespaceId": "string", + "namespace.namespaceVersion": "integer" + }, + "uniqueFieldSets": [ + [ + "topicId", + "namespace.namespaceId", + "namespace.namespaceVersion" + ] + ], + "consumers": [ + { + "path": "src/bootstrap/runtime-adapters.ts", + "token": "indexInvalidationRegistry(INVALIDATION_REGISTRY)" + } + ], + "breakingFields": [ + "topicId", + "namespace.namespaceId", + "namespace.namespaceVersion" + ] + }, + { + "registryId": "FE-REG-QUERY-INVALIDATION-TOPIC-VERSION", + "path": "src/features/installed-feature-contracts.ts", + "exportName": "INVALIDATION_TOPIC_VERSIONS", + "rowKeyFields": ["topicId"], + "owner": "feature-frontend-server-state-caching-contract", + "requiredFields": ["topicId", "topicVersion"], + "fieldTypes": { + "topicId": "string", + "topicVersion": "integer" + }, + "uniqueFields": ["topicId"], + "consumers": [ + { + "path": "src/bootstrap/runtime-adapters.ts", + "token": "indexInvalidationTopicVersions(" + } + ], + "breakingFields": ["topicId", "topicVersion"] + }, { "registryId": "FE-REG-TELEMETRY", "path": "src/contracts/telemetry.ts", diff --git a/schemas/artifacts/registry-snapshot.schema.json b/schemas/artifacts/registry-snapshot.schema.json index 30c2a93..2343765 100644 --- a/schemas/artifacts/registry-snapshot.schema.json +++ b/schemas/artifacts/registry-snapshot.schema.json @@ -35,8 +35,8 @@ "failures": { "type": "array", "maxItems": 0 }, "registries": { "type": "array", - "minItems": 9, - "maxItems": 9, + "minItems": 11, + "maxItems": 11, "items": { "type": "object", "required": [ diff --git a/scripts/check-registries.ts b/scripts/check-registries.ts index fbbc32f..f303063 100644 --- a/scripts/check-registries.ts +++ b/scripts/check-registries.ts @@ -31,10 +31,13 @@ type RegistrySpecification = Readonly<{ path: string; exportName: string; declaredRows?: unknown; + rowsPath?: string; + rowKeyFields?: readonly string[]; requiredFields: readonly string[]; fieldTypes?: Readonly>; keyField?: string; uniqueFields?: readonly string[]; + uniqueFieldSets?: readonly (readonly string[])[]; allowedValues?: Readonly>; references?: readonly RegistryReference[]; breakingFields?: readonly string[]; @@ -145,6 +148,73 @@ function matchesDeclaredType(value: unknown, declaration: string): boolean { ); } +type FieldLookup = Readonly<{ + found: boolean; + value: unknown; +}>; + +function lookupField(value: unknown, fieldPath: string): FieldLookup { + let current = value; + for (const segment of fieldPath.split(".")) { + if ( + !current || + typeof current !== "object" || + Array.isArray(current) || + !Object.hasOwn(current, segment) + ) { + return Object.freeze({ found: false, value: undefined }); + } + current = (current as Record)[segment]; + } + return Object.freeze({ found: true, value: current }); +} + +function projectRegistryRows( + specification: RegistrySpecification, + exportedValue: unknown, +): RegistryRows | null { + const selected = specification.rowsPath + ? lookupField(exportedValue, specification.rowsPath) + : Object.freeze({ found: true, value: exportedValue }); + if (!selected.found) { + failures.push( + `${specification.registryId} is missing rows path ${specification.rowsPath}`, + ); + return null; + } + if (!Array.isArray(selected.value)) { + if ( + !selected.value || + typeof selected.value !== "object" + ) { + failures.push(`${specification.registryId} is not an object registry`); + return null; + } + return selected.value as RegistryRows; + } + + const projected: RegistryRows = {}; + const keyOccurrences = new Map(); + for (const [index, row] of selected.value.entries()) { + if (!row || typeof row !== "object" || Array.isArray(row)) { + projected[String(index)] = row as RegistryRow; + continue; + } + const keyValues = (specification.rowKeyFields ?? []).map( + (field) => lookupField(row, field).value, + ); + const baseKey = + keyValues.length > 0 + ? JSON.stringify(keyValues.map(canonicalizeRegistryValue)) + : String(index); + const occurrence = keyOccurrences.get(baseKey) ?? 0; + keyOccurrences.set(baseKey, occurrence + 1); + projected[occurrence === 0 ? baseKey : `${baseKey}#${occurrence + 1}`] = + row as RegistryRow; + } + return projected; +} + async function filesBelow(directory: string): Promise { try { const entries = await readdir(directory, { withFileTypes: true }); @@ -180,12 +250,10 @@ for (const specification of governance.registries) { if (!rows) failures.push(`missing registry source ${specification.path}`); } - if (!rows || typeof rows !== "object" || Array.isArray(rows)) { - failures.push(`${specification.registryId} is not an object registry`); + const registryRows = projectRegistryRows(specification, rows); + if (!registryRows) { continue; } - - const registryRows = rows as RegistryRows; rowsByRegistry.set(specification.registryId, registryRows); sourcesByRegistry.set( specification.registryId, @@ -198,7 +266,7 @@ for (const specification of governance.registries) { continue; } for (const field of specification.requiredFields) { - if (!(field in row)) { + if (!lookupField(row, field).found) { failures.push(`${specification.registryId}.${rowName} missing ${field}`); } } @@ -206,17 +274,20 @@ for (const specification of governance.registries) { specification.fieldTypes ?? {}, )) { if ( - field in row && - !matchesDeclaredType(row[field], String(declaredType)) + lookupField(row, field).found && + !matchesDeclaredType( + lookupField(row, field).value, + String(declaredType), + ) ) { failures.push( - `${specification.registryId}.${rowName}.${field} expected ${declaredType}, received ${runtimeType(row[field])}`, + `${specification.registryId}.${rowName}.${field} expected ${declaredType}, received ${runtimeType(lookupField(row, field).value)}`, ); } } if ( specification.keyField && - row[specification.keyField] !== rowName + lookupField(row, specification.keyField).value !== rowName ) { failures.push( `${specification.registryId}.${rowName}.${specification.keyField} must match its registry key`, @@ -228,8 +299,9 @@ for (const specification of governance.registries) { const values = new Map(); for (const [rowName, row] of Object.entries(registryRows)) { if (!row || typeof row !== "object" || Array.isArray(row)) continue; - const value = row[field]; - if (value === undefined) continue; + const selected = lookupField(row, field); + if (!selected.found) continue; + const value = selected.value; const identity = JSON.stringify(canonicalizeRegistryValue(value)); if (values.has(identity)) { failures.push( @@ -241,16 +313,38 @@ for (const specification of governance.registries) { } } + for (const fields of specification.uniqueFieldSets ?? []) { + const values = new Map(); + for (const [rowName, row] of Object.entries(registryRows)) { + if (!row || typeof row !== "object" || Array.isArray(row)) continue; + const selected = fields.map((field) => lookupField(row, field)); + if (selected.some((field) => !field.found)) continue; + const identity = JSON.stringify( + selected.map((field) => canonicalizeRegistryValue(field.value)), + ); + const previous = values.get(identity); + if (previous) { + failures.push( + `${specification.registryId}.${rowName} duplicates ${fields.join("+")}=${identity} from ${previous}`, + ); + } else { + values.set(identity, rowName); + } + } + } + for (const [field, allowed] of Object.entries( specification.allowedValues ?? {}, )) { for (const [rowName, row] of Object.entries(registryRows)) { if (!row || typeof row !== "object" || Array.isArray(row)) continue; if ( - !allowed.some((value) => Object.is(value, row[field])) + !allowed.some((value) => + Object.is(value, lookupField(row, field).value), + ) ) { failures.push( - `${specification.registryId}.${rowName}.${field} has unknown value ${String(row[field])}`, + `${specification.registryId}.${rowName}.${field} has unknown value ${String(lookupField(row, field).value)}`, ); } } @@ -264,6 +358,15 @@ for (const specification of governance.registries) { references: specification.references ?? [], keyField: specification.keyField ?? null, breakingFields: specification.breakingFields ?? [], + ...(specification.rowsPath + ? { rowsPath: specification.rowsPath } + : {}), + ...(specification.rowKeyFields + ? { rowKeyFields: specification.rowKeyFields } + : {}), + ...(specification.uniqueFieldSets + ? { uniqueFieldSets: specification.uniqueFieldSets } + : {}), }); snapshots.push({ registryId: specification.registryId, @@ -289,12 +392,12 @@ for (const specification of governance.registries) { const targetValues = new Set( Object.values(targetRows) .filter((row) => row && typeof row === "object" && !Array.isArray(row)) - .map((row) => row[reference.targetField]) + .map((row) => lookupField(row, reference.targetField).value) .filter((value) => value !== undefined && value !== null), ); for (const [rowName, row] of Object.entries(rows)) { if (!row || typeof row !== "object" || Array.isArray(row)) continue; - const value = row[reference.field]; + const value = lookupField(row, reference.field).value; if ( value !== undefined && value !== null && @@ -346,7 +449,10 @@ for (const specification of governance.registries) { ) { continue; } - const identity = row[specification.consumerIdentityField]; + const identity = lookupField( + row, + specification.consumerIdentityField, + ).value; if ( (typeof identity !== "string" && typeof identity !== "number") || diff --git a/scripts/test-sample-removal.ts b/scripts/test-sample-removal.ts index 3b50a2b..717925c 100644 --- a/scripts/test-sample-removal.ts +++ b/scripts/test-sample-removal.ts @@ -1,5 +1,6 @@ import { spawnSync } from "node:child_process"; import { + access, cp, mkdir, readFile, @@ -14,6 +15,11 @@ const fixtureRoot = path.resolve(".tmp/reference-feature-removal"); const pnpmCli = requireEnvironment("npm_execpath"); const featureSource = "src/features/reference-feature"; const featureTests = "tests/features/reference-feature"; +const commonTestPaths = [ + "tests/unit/external-contract-runtime.test.ts", + "tests/unit/http-execution-v3.test.ts", + "tests/unit/runtime-adapters.test.ts", +]; const featureOwnedPaths = [ featureSource, featureTests, @@ -22,9 +28,6 @@ const featureOwnedPaths = [ "tests/mocks", "tests/fixtures/typecheck/invalid-feature-input.ts", "tests/fixtures/typecheck/invalid-reference-operation.ts", - "tests/unit/external-contract-runtime.test.ts", - "tests/unit/http-execution-v3.test.ts", - "tests/unit/runtime-adapters.test.ts", ]; const copyTargets = [ "src", @@ -314,6 +317,21 @@ for (const root of ["src", "tests"]) { } const checks: Array<[string, boolean]> = [ + [ + "common-test-evidence", + ( + await Promise.all( + commonTestPaths.map(async (testPath) => { + try { + await access(path.join(fixtureRoot, testPath)); + return true; + } catch { + return false; + } + }), + ) + ).every(Boolean), + ], ["typecheck", runPnpm("check:types")], ["architecture", runPnpm("check:architecture")], ["registry-structure", runPnpm("check:registries:structure")], diff --git a/src/contracts/query-invalidation.ts b/src/contracts/query-invalidation.ts index 0b00a1c..aa58c33 100644 --- a/src/contracts/query-invalidation.ts +++ b/src/contracts/query-invalidation.ts @@ -1,5 +1,6 @@ import { isCacheInvalidationTopic } from "./cache-invalidation.ts"; import { + defineQueryNamespaceIdentity, queryNamespaceIdentityKey, type QueryNamespaceIdentity, } from "./query-keys.ts"; @@ -111,8 +112,13 @@ export function indexInvalidationRegistry( const namespaces = new Map(); for (const namespace of registry.namespaces) { let namespaceKey: string; + let namespaceSnapshot: QueryNamespaceIdentity; try { - namespaceKey = queryNamespaceIdentityKey(namespace); + namespaceSnapshot = defineQueryNamespaceIdentity( + namespace.namespaceId, + namespace.namespaceVersion, + ); + namespaceKey = queryNamespaceIdentityKey(namespaceSnapshot); } catch (error) { throw new TypeError("Invalidation registry namespace is invalid.", { cause: error, @@ -121,7 +127,7 @@ export function indexInvalidationRegistry( if (namespaces.has(namespaceKey)) { throw new TypeError(`Duplicate invalidation namespace: ${namespaceKey}`); } - namespaces.set(namespaceKey, namespace); + namespaces.set(namespaceKey, namespaceSnapshot); } const namespacesForTopic = new Map(); diff --git a/tests/features/reference-feature/reference-runtime-composition.test.ts b/tests/features/reference-feature/reference-runtime-composition.test.ts new file mode 100644 index 0000000..e069ed1 --- /dev/null +++ b/tests/features/reference-feature/reference-runtime-composition.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it, vi } from "vitest"; + +import { createRuntimeAdapters } from "../../../src/bootstrap/runtime-adapters.ts"; +import { createRuntimeIdentityRegistry } from "../../../src/contracts/query-keys.ts"; +import { bindQuery } from "../../../src/contracts/server-state.ts"; +import type { CacheScopeSnapshot } from "../../../src/contracts/server-state-scope.ts"; +import { + REFERENCE_FEATURE_ID, + REFERENCE_RESOURCE_INVALIDATION_TOPIC, +} from "../../../src/features/reference-feature/contracts/reference-feature-contract.ts"; + +type Runtime = Parameters[0]["runtime"]; +type Release = Parameters[0]["release"]; + +const runtime: Runtime = { + config: { + APP_ENV: "local", + API_BASE_URL: "http://localhost:8080", + TELEMETRY_ENABLED: false, + AUTH_MODE: "demo", + REQUEST_TIMEOUT_MS: 4321, + MAX_RETRY_ATTEMPTS: 0, + RELEASE_MANIFEST_URL: "/release-manifest.json", + CONFIG_SCHEMA_VERSION: "2.0", + CAPABILITY_OVERRIDES: { + REALTIME: "DEFAULT", + WEB_WORKER: "DEFAULT", + SERVICE_WORKER: "DEFAULT", + OFFLINE_COMMANDS: "DEFAULT", + }, + }, + configSchema: "V2", + build: { + buildId: "build-a", + commitSha: "abc123", + routerBasePath: "/", + runtimeConfigUrl: "/config.json", + }, + validationDurationMs: 0, +}; + +const release: Release = { + schemaVersion: 2, + appVersion: "0.1.0", + buildId: "build-a", + commitSha: "abc123", + configSchemaVersion: "2.0", + contractSet: { + setAlgorithm: "CA_CONTRACT_SET_V1", + setDigest: `sha256:${"0".repeat(64)}`, + packages: [], + }, + assetManifestHash: "hash-a", + releaseId: "release-a", + builtAt: "2026-07-25T00:00:00Z", + routeChunks: { "route-home": "assets/home.js" }, +}; + +function referenceBoundQueryKey() { + const scope: CacheScopeSnapshot = { + generation: 1, + fingerprint: "runtime-scope-fingerprint-0001", + identities: createRuntimeIdentityRegistry({ + tokenFactory: () => "runtime-identity-token-0001", + }), + signal: new AbortController().signal, + isCurrent: () => true, + }; + return bindQuery( + { + definitionId: "reference-resource-runtime-test-v1", + definitionVersion: 1, + owner: REFERENCE_FEATURE_ID, + namespace: "reference-resource", + namespaceVersion: 1, + operationId: "GET_REFERENCE_RESOURCE", + profileId: "DETAIL_STANDARD", + measureResult: () => ({ itemCount: 1, estimatedBytes: 8 }), + execute: async () => ({ ok: true as const, value: "reference-1" }), + }, + "reference-1", + scope, + ).queryKey; +} + +describe("reference feature runtime composition", () => { + it("invalidates a real bound query through the installed production graph", async () => { + const adapters = await createRuntimeAdapters({ runtime, release, host: {} }); + const queryKey = referenceBoundQueryKey(); + adapters.infrastructure.queryClient.setQueryData(queryKey, { + resourceId: "reference-1", + }); + + await adapters.infrastructure.queryInvalidation.invalidate([ + REFERENCE_RESOURCE_INVALIDATION_TOPIC, + ]); + + expect( + adapters.infrastructure.queryClient.getQueryState(queryKey)?.isInvalidated, + ).toBe(true); + adapters.infrastructure.dispose(); + }); + + it("executes installed feature HTTP through the composed contract registry", async () => { + const fetcher = vi.fn(async () => + Response.json([{ id: "reference-1", name: "Direct contract payload" }]), + ); + const adapters = await createRuntimeAdapters({ + runtime, + release, + host: {}, + fetcher, + }); + await adapters.outputPorts.session.beginSignIn(); + await vi.waitFor(() => + expect(adapters.infrastructure.serverStateScope.getPhase()).toBe("READY"), + ); + + await expect( + adapters.featureInputs[REFERENCE_FEATURE_ID].listResources({ limit: 20 }), + ).resolves.toEqual({ + ok: true, + value: [ + { + resourceId: "reference-1", + title: "Direct contract payload", + createdAt: null, + }, + ], + }); + expect(fetcher).toHaveBeenCalledWith( + "http://localhost:8080/api/reference-resources?limit=20", + expect.objectContaining({ + method: "GET", + redirect: "error", + cache: "no-store", + }), + ); + adapters.infrastructure.dispose(); + }); +}); diff --git a/tests/fixtures/registry/invalidation/duplicate-governance.json b/tests/fixtures/registry/invalidation/duplicate-governance.json new file mode 100644 index 0000000..fb8180b --- /dev/null +++ b/tests/fixtures/registry/invalidation/duplicate-governance.json @@ -0,0 +1,40 @@ +{ + "schemaVersion": 2, + "sourceDirectories": [], + "registries": [ + { + "registryId": "FIXTURE-DUPLICATE-INVALIDATION-EDGES", + "path": "tests/fixtures/registry/invalidation/registries.ts", + "exportName": "DUPLICATE_INVALIDATION_REGISTRY", + "rowsPath": "edges", + "rowKeyFields": [ + "topicId", + "namespace.namespaceId", + "namespace.namespaceVersion" + ], + "owner": "fixture", + "requiredFields": [ + "topicId", + "namespace.namespaceId", + "namespace.namespaceVersion" + ], + "fieldTypes": { + "topicId": "string", + "namespace.namespaceId": "string", + "namespace.namespaceVersion": "integer" + }, + "uniqueFieldSets": [ + [ + "topicId", + "namespace.namespaceId", + "namespace.namespaceVersion" + ] + ], + "breakingFields": [ + "topicId", + "namespace.namespaceId", + "namespace.namespaceVersion" + ] + } + ] +} diff --git a/tests/fixtures/registry/invalidation/governance.json b/tests/fixtures/registry/invalidation/governance.json new file mode 100644 index 0000000..ed02939 --- /dev/null +++ b/tests/fixtures/registry/invalidation/governance.json @@ -0,0 +1,54 @@ +{ + "schemaVersion": 2, + "sourceDirectories": [], + "registries": [ + { + "registryId": "FIXTURE-INVALIDATION-EDGES", + "path": "tests/fixtures/registry/invalidation/registries.ts", + "exportName": "INVALIDATION_REGISTRY", + "rowsPath": "edges", + "rowKeyFields": [ + "topicId", + "namespace.namespaceId", + "namespace.namespaceVersion" + ], + "owner": "fixture", + "requiredFields": [ + "topicId", + "namespace.namespaceId", + "namespace.namespaceVersion" + ], + "fieldTypes": { + "topicId": "string", + "namespace.namespaceId": "string", + "namespace.namespaceVersion": "integer" + }, + "uniqueFieldSets": [ + [ + "topicId", + "namespace.namespaceId", + "namespace.namespaceVersion" + ] + ], + "breakingFields": [ + "topicId", + "namespace.namespaceId", + "namespace.namespaceVersion" + ] + }, + { + "registryId": "FIXTURE-INVALIDATION-VERSIONS", + "path": "tests/fixtures/registry/invalidation/registries.ts", + "exportName": "INVALIDATION_TOPIC_VERSIONS", + "rowKeyFields": ["topicId"], + "owner": "fixture", + "requiredFields": ["topicId", "topicVersion"], + "fieldTypes": { + "topicId": "string", + "topicVersion": "integer" + }, + "uniqueFields": ["topicId"], + "breakingFields": ["topicId", "topicVersion"] + } + ] +} diff --git a/tests/fixtures/registry/invalidation/registries.ts b/tests/fixtures/registry/invalidation/registries.ts new file mode 100644 index 0000000..4f7a8a6 --- /dev/null +++ b/tests/fixtures/registry/invalidation/registries.ts @@ -0,0 +1,49 @@ +const namespace = Object.freeze({ + namespaceId: "orders", + namespaceVersion: 1, +}); +const reverseNamespace = Object.freeze({ + namespaceId: "qinv.fixture.changed", + namespaceVersion: 1, +}); + +export const INVALIDATION_REGISTRY = Object.freeze({ + topics: Object.freeze(["qinv.fixture.changed", "orders"]), + namespaces: Object.freeze([namespace, reverseNamespace]), + edges: Object.freeze([ + Object.freeze({ + topicId: "qinv.fixture.changed", + namespace, + }), + Object.freeze({ + topicId: "orders", + namespace: reverseNamespace, + }), + ]), +}); + +export const INVALIDATION_TOPIC_VERSIONS = Object.freeze([ + Object.freeze({ + topicId: "qinv.fixture.changed", + topicVersion: 1, + }), + Object.freeze({ + topicId: "orders", + topicVersion: 1, + }), +]); + +export const DUPLICATE_INVALIDATION_REGISTRY = Object.freeze({ + topics: INVALIDATION_REGISTRY.topics, + namespaces: INVALIDATION_REGISTRY.namespaces, + edges: Object.freeze([ + INVALIDATION_REGISTRY.edges[0], + Object.freeze({ + topicId: "qinv.fixture.changed", + namespace: Object.freeze({ + namespaceId: "orders", + namespaceVersion: 1, + }), + }), + ]), +}); diff --git a/tests/helpers/external-contract-fixture.ts b/tests/helpers/external-contract-fixture.ts new file mode 100644 index 0000000..d4458f6 --- /dev/null +++ b/tests/helpers/external-contract-fixture.ts @@ -0,0 +1,220 @@ +import { z } from "zod"; + +import type { + CommandEffectDescriptor, + InstalledContractContribution, + InstalledHttpContract, + RuntimeValidator, +} from "../../src/contracts/external-contract-runtime.ts"; + +function zodValidator( + schemaId: string, + schema: z.ZodType, +): RuntimeValidator { + return Object.freeze({ + schemaId, + safeParse(value: unknown) { + const result = schema.safeParse(value); + if (result.success) { + return Object.freeze({ success: true as const, data: result.data }); + } + return Object.freeze({ + success: false as const, + issues: Object.freeze( + result.error.issues.map((issue) => + Object.freeze({ + path: Object.freeze( + issue.path.map((segment) => + typeof segment === "number" ? segment : String(segment), + ), + ), + code: String(issue.code), + }), + ), + ), + }); + }, + }); +} + +const entitySchema = z + .object({ + id: z.string().min(1), + name: z.string().min(1), + }) + .strip(); + +const problemSchema = z + .object({ + type: z.string().min(1), + title: z.string().min(1), + status: z.int().min(100).max(599), + }) + .strip(); + +type TestProblem = z.output; + +const problemValidator = zodValidator("TestProblem", problemSchema); +const readPolicy = Object.freeze({ + policyId: "TEST_READ_V1", + requestByteLimit: 0, + responseByteLimit: 32_768, + totalDeadlineMs: 10_000, + retryBudget: 2 as const, + authProfileId: "TEST_AUTH", + diagnosticsOperation: "test.read", +}); + +export const TEST_LIST_HTTP_CONTRACT: InstalledHttpContract< + Readonly<{ limit: number }>, + readonly z.output[], + TestProblem +> = Object.freeze({ + contract: Object.freeze({ + operationId: "TEST_LIST_ENTITIES", + method: "GET" as const, + pathTemplate: "/api/test-entities", + inputValidator: zodValidator( + "TestEntityListQuery", + z + .object({ + limit: z.coerce.number().int().min(1).max(100).default(20), + }) + .strict(), + ), + outputValidator: zodValidator( + "TestEntityListPayload", + z.array(entitySchema).max(100), + ), + problemValidator, + acceptedStatuses: Object.freeze([200]), + emptyBodyStatuses: Object.freeze([]), + retrySemantics: "SAFE" as const, + requestBody: "NONE" as const, + responseBody: "REQUIRED_JSON" as const, + commandRecovery: null, + commandEffect: null, + projectRequest(input: Readonly<{ limit: number }>) { + return Object.freeze({ + pathValues: Object.freeze({}), + queryEntries: Object.freeze([ + Object.freeze(["limit", String(input.limit)] as const), + ]), + body: null, + }); + }, + }), + frontend: readPolicy, +}); + +export const TEST_DETAIL_HTTP_CONTRACT: InstalledHttpContract< + Readonly<{ entityId: string }>, + z.output, + TestProblem +> = Object.freeze({ + contract: Object.freeze({ + operationId: "TEST_GET_ENTITY", + method: "GET" as const, + pathTemplate: "/api/test-entities/{entityId}", + inputValidator: zodValidator( + "TestEntityParams", + z.object({ entityId: z.string().min(1) }).strict(), + ), + outputValidator: zodValidator("TestEntityPayload", entitySchema), + problemValidator, + acceptedStatuses: Object.freeze([200]), + emptyBodyStatuses: Object.freeze([]), + retrySemantics: "SAFE" as const, + requestBody: "NONE" as const, + responseBody: "REQUIRED_JSON" as const, + commandRecovery: null, + commandEffect: null, + projectRequest(input: Readonly<{ entityId: string }>) { + return Object.freeze({ + pathValues: Object.freeze({ entityId: input.entityId }), + queryEntries: Object.freeze([]), + body: null, + }); + }, + }), + frontend: Object.freeze({ + ...readPolicy, + policyId: "TEST_DETAIL_V1", + diagnosticsOperation: "test.detail", + }), +}); + +const commandEffect: CommandEffectDescriptor = Object.freeze({ + successEffect: "APPLIED_CONFIRMED" as const, + classifyProblem({ + status, + }: Readonly<{ status: number; problem: TestProblem }>) { + return status === 400 ? "NOT_APPLIED" : "MAYBE_APPLIED"; + }, +}); + +export const TEST_CREATE_HTTP_CONTRACT: InstalledHttpContract< + Readonly<{ name: string }>, + z.output, + TestProblem +> = Object.freeze({ + contract: Object.freeze({ + operationId: "TEST_CREATE_ENTITY", + method: "POST" as const, + pathTemplate: "/api/test-entities", + inputValidator: zodValidator( + "TestCreateEntityCommand", + z.object({ name: z.string().trim().min(1) }).strict(), + ), + outputValidator: zodValidator("TestEntityPayload", entitySchema), + problemValidator, + acceptedStatuses: Object.freeze([201]), + emptyBodyStatuses: Object.freeze([]), + retrySemantics: "KEYED" as const, + requestBody: "JSON" as const, + responseBody: "REQUIRED_JSON" as const, + commandRecovery: Object.freeze({ + mode: "IDEMPOTENCY_REPLAY" as const, + operationIdentityField: "idempotencyKey", + }), + commandEffect, + projectRequest(input: Readonly<{ name: string }>) { + return Object.freeze({ + pathValues: Object.freeze({}), + queryEntries: Object.freeze([]), + body: Object.freeze({ ...input }), + }); + }, + }), + frontend: Object.freeze({ + policyId: "TEST_CREATE_V1", + requestByteLimit: 32_768, + responseByteLimit: 32_768, + totalDeadlineMs: 10_000, + retryBudget: 0 as const, + authProfileId: "TEST_AUTH", + diagnosticsOperation: "test.create", + }), +}); + +export const TEST_CONTRACT_CONTRIBUTION: InstalledContractContribution = + Object.freeze({ + contributionId: "test-http-v1", + featureId: "test-feature", + source: Object.freeze({ + kind: "EXTERNAL_PACKAGE" as const, + package: Object.freeze({ + packageId: "@test/contracts", + version: "1.0.0", + digest: `sha256:${"a".repeat(64)}`, + runtimeProtocolVersion: 1 as const, + sourceRevision: "abcdef1", + }), + }), + http: Object.freeze([ + TEST_LIST_HTTP_CONTRACT, + TEST_DETAIL_HTTP_CONTRACT, + TEST_CREATE_HTTP_CONTRACT, + ]) as readonly InstalledHttpContract[], + events: Object.freeze([]), + }); diff --git a/tests/unit/external-contract-runtime.test.ts b/tests/unit/external-contract-runtime.test.ts index 615da2f..24a2611 100644 --- a/tests/unit/external-contract-runtime.test.ts +++ b/tests/unit/external-contract-runtime.test.ts @@ -5,14 +5,14 @@ import { composeContractContributions, type InstalledContractContribution, } from "../../src/contracts/external-contract-runtime.ts"; -import { REFERENCE_FEATURE_TEMPLATE_CONTRIBUTION } from "../../src/features/reference-feature/contracts/reference-feature-contract-contribution.ts"; +import { TEST_CONTRACT_CONTRIBUTION } from "../helpers/external-contract-fixture.ts"; function contribution( contributionId: string, http: InstalledContractContribution["http"], ): InstalledContractContribution & Readonly<{ contributionId: string }> { return Object.freeze({ - ...REFERENCE_FEATURE_TEMPLATE_CONTRIBUTION, + ...TEST_CONTRACT_CONTRIBUTION, contributionId, http: Object.freeze([...http]), }); @@ -30,10 +30,10 @@ function captureContributionError(operation: () => unknown): ContractContributio describe("external contract contribution composition", () => { it("allows one feature to install multiple uniquely identified contributions", () => { - const operations = REFERENCE_FEATURE_TEMPLATE_CONTRIBUTION.http; + const operations = TEST_CONTRACT_CONTRIBUTION.http; const composed = composeContractContributions([ - contribution("reference-read-contracts", operations.slice(0, 2)), - contribution("reference-command-contracts", operations.slice(2)), + contribution("test-read-contracts", operations.slice(0, 2)), + contribution("test-command-contracts", operations.slice(2)), ]); expect(composed.contributions).toHaveLength(2); @@ -42,12 +42,12 @@ describe("external contract contribution composition", () => { it("rejects duplicate contribution identities even across different feature entries", () => { const first = contribution( - "reference-contracts", - REFERENCE_FEATURE_TEMPLATE_CONTRIBUTION.http.slice(0, 1), + "test-contracts", + TEST_CONTRACT_CONTRIBUTION.http.slice(0, 1), ); const second = contribution( - "reference-contracts", - REFERENCE_FEATURE_TEMPLATE_CONTRIBUTION.http.slice(1), + "test-contracts", + TEST_CONTRACT_CONTRIBUTION.http.slice(1), ); const error = captureContributionError(() => @@ -69,7 +69,7 @@ describe("external contract contribution composition", () => { }); it("rejects method and body vocabulary outside the runtime protocol", () => { - const installed = REFERENCE_FEATURE_TEMPLATE_CONTRIBUTION.http[0]!; + const installed = TEST_CONTRACT_CONTRIBUTION.http[0]!; const malformedOperation = { ...installed, contract: { ...installed.contract, method: "TRACE" }, diff --git a/tests/unit/http-execution-v3.test.ts b/tests/unit/http-execution-v3.test.ts index 98bfb7d..2b71acc 100644 --- a/tests/unit/http-execution-v3.test.ts +++ b/tests/unit/http-execution-v3.test.ts @@ -2,15 +2,13 @@ import { describe, expect, it, vi } from "vitest"; import { createContractHttpExecutor } from "../../src/adapters/http/http-execution-v3.ts"; import type { InstalledHttpContract } from "../../src/contracts/external-contract-runtime.ts"; -import { COMPOSED_CONTRACT_CONTRIBUTIONS } from "../../src/features/installed-contract-contributions.ts"; +import { + TEST_CREATE_HTTP_CONTRACT, + TEST_LIST_HTTP_CONTRACT, +} from "../helpers/external-contract-fixture.ts"; -const installed = (() => { - const candidate = COMPOSED_CONTRACT_CONTRIBUTIONS.httpByOperationId.get( - "LIST_REFERENCE_RESOURCES", - ); - if (!candidate) throw new Error("reference list contract is not installed"); - return candidate; -})(); +const installed: InstalledHttpContract = + TEST_LIST_HTTP_CONTRACT; const scope = Object.freeze({ generation: 1, @@ -20,13 +18,8 @@ const scope = Object.freeze({ isCurrent: () => true, }); -const createInstalled = (() => { - const candidate = COMPOSED_CONTRACT_CONTRIBUTIONS.httpByOperationId.get( - "CREATE_REFERENCE_RESOURCE", - ); - if (!candidate) throw new Error("reference create contract is not installed"); - return candidate; -})(); +const createInstalled: InstalledHttpContract = + TEST_CREATE_HTTP_CONTRACT; function operation( overrides: Readonly<{ diff --git a/tests/unit/query-invalidation-registry.test.ts b/tests/unit/query-invalidation-registry.test.ts index 11c265e..9493aa4 100644 --- a/tests/unit/query-invalidation-registry.test.ts +++ b/tests/unit/query-invalidation-registry.test.ts @@ -106,6 +106,31 @@ describe("query invalidation registry", () => { ]); }); + it("snapshots a caller-owned namespace identity before indexing it", () => { + const mutableNamespace = { + namespaceId: "orders", + namespaceVersion: 1, + }; + const index = indexInvalidationRegistry({ + topics: [topic], + namespaces: [mutableNamespace], + edges: [{ topicId: topic, namespace: mutableNamespace }], + }); + + mutableNamespace.namespaceId = "mutated-orders"; + mutableNamespace.namespaceVersion = 2; + + const indexedNamespace = index.namespacesForTopic.get(topic)?.[0]; + expect(indexedNamespace).toEqual({ + namespaceId: "orders", + namespaceVersion: 1, + }); + expect(Object.isFrozen(indexedNamespace)).toBe(true); + expect( + indexedNamespace && createQueryInvalidationPrefix(indexedNamespace), + ).toEqual(["query", 2, "orders", 1]); + }); + it("rejects duplicate namespace identities even when they are separate objects", () => { const duplicate = defineQueryNamespaceIdentity("orders", 1); diff --git a/tests/unit/registry-governance.test.ts b/tests/unit/registry-governance.test.ts index 5f2d1e0..0f4974d 100644 --- a/tests/unit/registry-governance.test.ts +++ b/tests/unit/registry-governance.test.ts @@ -1,4 +1,7 @@ -import { readFile } from "node:fs/promises"; +import { spawnSync } from "node:child_process"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; import { describe, expect, it } from "vitest"; type RegistryDefinition = Readonly<{ @@ -6,17 +9,25 @@ type RegistryDefinition = Readonly<{ owner: string; requiredFields: string[]; fieldTypes: Record; + path: string; + exportName: string; + rowsPath?: string; + rowKeyFields?: string[]; + uniqueFields?: string[]; + uniqueFieldSets?: string[][]; + consumers?: Array<{ path: string; token: string }>; + breakingFields?: string[]; }>; describe("registry governance manifest", () => { - it("declares nine typed, single-owner executable registries", async () => { + it("declares eleven typed, single-owner executable registries", async () => { const governance = JSON.parse( await readFile("config/contracts/registry-governance.json", "utf8"), ); const registries = governance.registries as RegistryDefinition[]; - expect(governance.registries).toHaveLength(9); + expect(governance.registries).toHaveLength(11); expect(new Set(registries.map((entry) => entry.registryId)).size).toBe( - 9, + 11, ); expect(registries.every((entry) => entry.owner)).toBe(true); expect( @@ -29,4 +40,131 @@ describe("registry governance manifest", () => { ), ).toBe(true); }); + + it("governs the installed invalidation graph and wire versions at their real exports", async () => { + const governance = JSON.parse( + await readFile("config/contracts/registry-governance.json", "utf8"), + ) as { registries: RegistryDefinition[] }; + const byId = new Map( + governance.registries.map((registry) => [registry.registryId, registry]), + ); + + expect(byId.get("FE-REG-QUERY-INVALIDATION")).toMatchObject({ + path: "src/features/installed-feature-contracts.ts", + exportName: "INVALIDATION_REGISTRY", + rowsPath: "edges", + rowKeyFields: [ + "topicId", + "namespace.namespaceId", + "namespace.namespaceVersion", + ], + uniqueFieldSets: [ + [ + "topicId", + "namespace.namespaceId", + "namespace.namespaceVersion", + ], + ], + consumers: [ + { + path: "src/bootstrap/runtime-adapters.ts", + token: "indexInvalidationRegistry(INVALIDATION_REGISTRY)", + }, + ], + breakingFields: [ + "topicId", + "namespace.namespaceId", + "namespace.namespaceVersion", + ], + }); + expect(byId.get("FE-REG-QUERY-INVALIDATION-TOPIC-VERSION")).toMatchObject({ + path: "src/features/installed-feature-contracts.ts", + exportName: "INVALIDATION_TOPIC_VERSIONS", + rowKeyFields: ["topicId"], + uniqueFields: ["topicId"], + consumers: [ + { + path: "src/bootstrap/runtime-adapters.ts", + token: "indexInvalidationTopicVersions(", + }, + ], + breakingFields: ["topicId", "topicVersion"], + }); + }); + + it("projects graph and array exports into deterministic governed rows", async () => { + const directory = await mkdtemp( + path.join(tmpdir(), "registry-governance-projection-"), + ); + const artifact = path.join(directory, "registries.json"); + try { + const result = spawnSync( + process.execPath, + [ + "scripts/check-registries.ts", + "--governance", + "tests/fixtures/registry/invalidation/governance.json", + "--artifact", + artifact, + "--no-baseline", + ], + { encoding: "utf8" }, + ); + + expect(result.status, result.stderr).toBe(0); + const report = JSON.parse(await readFile(artifact, "utf8")) as { + registries: Array<{ registryId: string; rows: Record }>; + }; + expect( + report.registries.find( + (registry) => registry.registryId === "FIXTURE-INVALIDATION-EDGES", + )?.rows, + ).toHaveProperty('["qinv.fixture.changed","orders",1]'); + expect( + report.registries.find( + (registry) => registry.registryId === "FIXTURE-INVALIDATION-EDGES", + )?.rows, + ).toHaveProperty('["orders","qinv.fixture.changed",1]'); + expect( + report.registries.find( + (registry) => registry.registryId === "FIXTURE-INVALIDATION-VERSIONS", + )?.rows, + ).toHaveProperty('["qinv.fixture.changed"]'); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + + it("rejects duplicate composite invalidation edges after projection", async () => { + const directory = await mkdtemp( + path.join(tmpdir(), "registry-governance-duplicate-"), + ); + const artifact = path.join(directory, "registries.json"); + try { + const result = spawnSync( + process.execPath, + [ + "scripts/check-registries.ts", + "--governance", + "tests/fixtures/registry/invalidation/duplicate-governance.json", + "--artifact", + artifact, + "--no-baseline", + ], + { encoding: "utf8" }, + ); + + expect(result.status).toBe(1); + const report = JSON.parse(await readFile(artifact, "utf8")) as { + failures: string[]; + }; + expect(report.failures).toEqual([ + expect.stringContaining( + "duplicates topicId+namespace.namespaceId+namespace.namespaceVersion", + ), + ]); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); }); diff --git a/tests/unit/runtime-adapters.test.ts b/tests/unit/runtime-adapters.test.ts index 1979b71..fefd89c 100644 --- a/tests/unit/runtime-adapters.test.ts +++ b/tests/unit/runtime-adapters.test.ts @@ -4,14 +4,6 @@ import { createRuntimeAdapters, createRuntimeHttpClient, } from "../../src/bootstrap/runtime-adapters.ts"; -import { INVALIDATION_REGISTRY } from "../../src/features/installed-feature-contracts.ts"; -import { - REFERENCE_FEATURE_ID, - REFERENCE_RESOURCE_INVALIDATION_TOPIC, -} from "../../src/features/reference-feature/contracts/reference-feature-contract.ts"; -import { createRuntimeIdentityRegistry } from "../../src/contracts/query-keys.ts"; -import { bindQuery } from "../../src/contracts/server-state.ts"; -import type { CacheScopeSnapshot } from "../../src/contracts/server-state-scope.ts"; import { TEST_HTTP_CONTRACT } from "../helpers/http-contract-fixture.ts"; type Runtime = Parameters[0]["runtime"]; @@ -60,33 +52,6 @@ const release: Release = { routeChunks: { "route-home": "assets/home.js" }, }; -function referenceBoundQueryKey() { - const scope: CacheScopeSnapshot = { - generation: 1, - fingerprint: "runtime-scope-fingerprint-0001", - identities: createRuntimeIdentityRegistry({ - tokenFactory: () => "runtime-identity-token-0001", - }), - signal: new AbortController().signal, - isCurrent: () => true, - }; - return bindQuery( - { - definitionId: "reference-resource-runtime-test-v1", - definitionVersion: 1, - owner: REFERENCE_FEATURE_ID, - namespace: "reference-resource", - namespaceVersion: 1, - operationId: "GET_REFERENCE_RESOURCE", - profileId: "DETAIL_STANDARD", - measureResult: () => ({ itemCount: 1, estimatedBytes: 8 }), - execute: async () => ({ ok: true as const, value: "reference-1" }), - }, - "reference-1", - scope, - ).queryKey; -} - describe("runtime adapter composition", () => { it("constructs the local demo seam and infrastructure adapters", async () => { const adapters = await createRuntimeAdapters({ @@ -112,66 +77,11 @@ describe("runtime adapter composition", () => { adapters.infrastructure.dispose(); }); - it("invalidates a real bound query through the installed production graph", async () => { - const adapters = await createRuntimeAdapters({ runtime, release, host: {} }); - const queryKey = referenceBoundQueryKey(); - adapters.infrastructure.queryClient.setQueryData(queryKey, { - resourceId: "reference-1", - }); - - await adapters.infrastructure.queryInvalidation.invalidate([ - REFERENCE_RESOURCE_INVALIDATION_TOPIC, - ]); - - expect( - adapters.infrastructure.queryClient.getQueryState(queryKey)?.isInvalidated, - ).toBe(true); - adapters.infrastructure.dispose(); - }); - - it("executes installed feature HTTP through the composed contract registry", async () => { - const fetcher = vi.fn(async () => - Response.json([{ id: "reference-1", name: "Direct contract payload" }]), - ); - const adapters = await createRuntimeAdapters({ - runtime, - release, - host: {}, - fetcher, - }); - await adapters.outputPorts.session.beginSignIn(); - await vi.waitFor(() => - expect(adapters.infrastructure.serverStateScope.getPhase()).toBe("READY"), - ); - - await expect( - adapters.featureInputs[REFERENCE_FEATURE_ID].listResources({ limit: 20 }), - ).resolves.toEqual({ - ok: true, - value: [ - { - resourceId: "reference-1", - title: "Direct contract payload", - createdAt: null, - }, - ], - }); - expect(fetcher).toHaveBeenCalledWith( - "http://localhost:8080/api/reference-resources?limit=20", - expect.objectContaining({ - method: "GET", - redirect: "error", - cache: "no-store", - }), - ); - adapters.infrastructure.dispose(); - }); - it("replaces the QueryClient and coordinator for each session generation", async () => { const adapters = await createRuntimeAdapters({ runtime, release, host: {} }); const previousClient = adapters.infrastructure.queryClient; const previousCoordinator = adapters.infrastructure.queryInvalidation; - const invalidatePrevious = vi.spyOn(previousClient, "invalidateQueries"); + const clearPrevious = vi.spyOn(previousClient, "clear"); await adapters.outputPorts.session.beginSignIn(); @@ -182,10 +92,9 @@ describe("runtime adapter composition", () => { previousCoordinator, ); - const topic = INVALIDATION_REGISTRY.topics[0]; - if (!topic) throw new Error("expected an installed invalidation topic"); - await previousCoordinator.invalidate([topic]); - expect(invalidatePrevious).not.toHaveBeenCalled(); + const clearCallsAfterReplacement = clearPrevious.mock.calls.length; + await previousCoordinator.resetLocal(); + expect(clearPrevious).toHaveBeenCalledTimes(clearCallsAfterReplacement); adapters.infrastructure.dispose(); });