fix: harden invalidation registry governance
This commit is contained in:
@@ -379,6 +379,66 @@
|
|||||||
],
|
],
|
||||||
"breakingFields": ["kind", "userMessageKey", "action", "telemetryEvent"]
|
"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",
|
"registryId": "FE-REG-TELEMETRY",
|
||||||
"path": "src/contracts/telemetry.ts",
|
"path": "src/contracts/telemetry.ts",
|
||||||
|
|||||||
@@ -35,8 +35,8 @@
|
|||||||
"failures": { "type": "array", "maxItems": 0 },
|
"failures": { "type": "array", "maxItems": 0 },
|
||||||
"registries": {
|
"registries": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"minItems": 9,
|
"minItems": 11,
|
||||||
"maxItems": 9,
|
"maxItems": 11,
|
||||||
"items": {
|
"items": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": [
|
"required": [
|
||||||
|
|||||||
+122
-16
@@ -31,10 +31,13 @@ type RegistrySpecification = Readonly<{
|
|||||||
path: string;
|
path: string;
|
||||||
exportName: string;
|
exportName: string;
|
||||||
declaredRows?: unknown;
|
declaredRows?: unknown;
|
||||||
|
rowsPath?: string;
|
||||||
|
rowKeyFields?: readonly string[];
|
||||||
requiredFields: readonly string[];
|
requiredFields: readonly string[];
|
||||||
fieldTypes?: Readonly<Record<string, string>>;
|
fieldTypes?: Readonly<Record<string, string>>;
|
||||||
keyField?: string;
|
keyField?: string;
|
||||||
uniqueFields?: readonly string[];
|
uniqueFields?: readonly string[];
|
||||||
|
uniqueFieldSets?: readonly (readonly string[])[];
|
||||||
allowedValues?: Readonly<Record<string, readonly unknown[]>>;
|
allowedValues?: Readonly<Record<string, readonly unknown[]>>;
|
||||||
references?: readonly RegistryReference[];
|
references?: readonly RegistryReference[];
|
||||||
breakingFields?: readonly string[];
|
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<string, unknown>)[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<string, number>();
|
||||||
|
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<string[]> {
|
async function filesBelow(directory: string): Promise<string[]> {
|
||||||
try {
|
try {
|
||||||
const entries = await readdir(directory, { withFileTypes: true });
|
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) failures.push(`missing registry source ${specification.path}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!rows || typeof rows !== "object" || Array.isArray(rows)) {
|
const registryRows = projectRegistryRows(specification, rows);
|
||||||
failures.push(`${specification.registryId} is not an object registry`);
|
if (!registryRows) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const registryRows = rows as RegistryRows;
|
|
||||||
rowsByRegistry.set(specification.registryId, registryRows);
|
rowsByRegistry.set(specification.registryId, registryRows);
|
||||||
sourcesByRegistry.set(
|
sourcesByRegistry.set(
|
||||||
specification.registryId,
|
specification.registryId,
|
||||||
@@ -198,7 +266,7 @@ for (const specification of governance.registries) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
for (const field of specification.requiredFields) {
|
for (const field of specification.requiredFields) {
|
||||||
if (!(field in row)) {
|
if (!lookupField(row, field).found) {
|
||||||
failures.push(`${specification.registryId}.${rowName} missing ${field}`);
|
failures.push(`${specification.registryId}.${rowName} missing ${field}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -206,17 +274,20 @@ for (const specification of governance.registries) {
|
|||||||
specification.fieldTypes ?? {},
|
specification.fieldTypes ?? {},
|
||||||
)) {
|
)) {
|
||||||
if (
|
if (
|
||||||
field in row &&
|
lookupField(row, field).found &&
|
||||||
!matchesDeclaredType(row[field], String(declaredType))
|
!matchesDeclaredType(
|
||||||
|
lookupField(row, field).value,
|
||||||
|
String(declaredType),
|
||||||
|
)
|
||||||
) {
|
) {
|
||||||
failures.push(
|
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 (
|
if (
|
||||||
specification.keyField &&
|
specification.keyField &&
|
||||||
row[specification.keyField] !== rowName
|
lookupField(row, specification.keyField).value !== rowName
|
||||||
) {
|
) {
|
||||||
failures.push(
|
failures.push(
|
||||||
`${specification.registryId}.${rowName}.${specification.keyField} must match its registry key`,
|
`${specification.registryId}.${rowName}.${specification.keyField} must match its registry key`,
|
||||||
@@ -228,8 +299,9 @@ for (const specification of governance.registries) {
|
|||||||
const values = new Map<string, string>();
|
const values = new Map<string, string>();
|
||||||
for (const [rowName, row] of Object.entries(registryRows)) {
|
for (const [rowName, row] of Object.entries(registryRows)) {
|
||||||
if (!row || typeof row !== "object" || Array.isArray(row)) continue;
|
if (!row || typeof row !== "object" || Array.isArray(row)) continue;
|
||||||
const value = row[field];
|
const selected = lookupField(row, field);
|
||||||
if (value === undefined) continue;
|
if (!selected.found) continue;
|
||||||
|
const value = selected.value;
|
||||||
const identity = JSON.stringify(canonicalizeRegistryValue(value));
|
const identity = JSON.stringify(canonicalizeRegistryValue(value));
|
||||||
if (values.has(identity)) {
|
if (values.has(identity)) {
|
||||||
failures.push(
|
failures.push(
|
||||||
@@ -241,16 +313,38 @@ for (const specification of governance.registries) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const fields of specification.uniqueFieldSets ?? []) {
|
||||||
|
const values = new Map<string, string>();
|
||||||
|
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(
|
for (const [field, allowed] of Object.entries(
|
||||||
specification.allowedValues ?? {},
|
specification.allowedValues ?? {},
|
||||||
)) {
|
)) {
|
||||||
for (const [rowName, row] of Object.entries(registryRows)) {
|
for (const [rowName, row] of Object.entries(registryRows)) {
|
||||||
if (!row || typeof row !== "object" || Array.isArray(row)) continue;
|
if (!row || typeof row !== "object" || Array.isArray(row)) continue;
|
||||||
if (
|
if (
|
||||||
!allowed.some((value) => Object.is(value, row[field]))
|
!allowed.some((value) =>
|
||||||
|
Object.is(value, lookupField(row, field).value),
|
||||||
|
)
|
||||||
) {
|
) {
|
||||||
failures.push(
|
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 ?? [],
|
references: specification.references ?? [],
|
||||||
keyField: specification.keyField ?? null,
|
keyField: specification.keyField ?? null,
|
||||||
breakingFields: specification.breakingFields ?? [],
|
breakingFields: specification.breakingFields ?? [],
|
||||||
|
...(specification.rowsPath
|
||||||
|
? { rowsPath: specification.rowsPath }
|
||||||
|
: {}),
|
||||||
|
...(specification.rowKeyFields
|
||||||
|
? { rowKeyFields: specification.rowKeyFields }
|
||||||
|
: {}),
|
||||||
|
...(specification.uniqueFieldSets
|
||||||
|
? { uniqueFieldSets: specification.uniqueFieldSets }
|
||||||
|
: {}),
|
||||||
});
|
});
|
||||||
snapshots.push({
|
snapshots.push({
|
||||||
registryId: specification.registryId,
|
registryId: specification.registryId,
|
||||||
@@ -289,12 +392,12 @@ for (const specification of governance.registries) {
|
|||||||
const targetValues = new Set(
|
const targetValues = new Set(
|
||||||
Object.values(targetRows)
|
Object.values(targetRows)
|
||||||
.filter((row) => row && typeof row === "object" && !Array.isArray(row))
|
.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),
|
.filter((value) => value !== undefined && value !== null),
|
||||||
);
|
);
|
||||||
for (const [rowName, row] of Object.entries(rows)) {
|
for (const [rowName, row] of Object.entries(rows)) {
|
||||||
if (!row || typeof row !== "object" || Array.isArray(row)) continue;
|
if (!row || typeof row !== "object" || Array.isArray(row)) continue;
|
||||||
const value = row[reference.field];
|
const value = lookupField(row, reference.field).value;
|
||||||
if (
|
if (
|
||||||
value !== undefined &&
|
value !== undefined &&
|
||||||
value !== null &&
|
value !== null &&
|
||||||
@@ -346,7 +449,10 @@ for (const specification of governance.registries) {
|
|||||||
) {
|
) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const identity = row[specification.consumerIdentityField];
|
const identity = lookupField(
|
||||||
|
row,
|
||||||
|
specification.consumerIdentityField,
|
||||||
|
).value;
|
||||||
if (
|
if (
|
||||||
(typeof identity !== "string" &&
|
(typeof identity !== "string" &&
|
||||||
typeof identity !== "number") ||
|
typeof identity !== "number") ||
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { spawnSync } from "node:child_process";
|
import { spawnSync } from "node:child_process";
|
||||||
import {
|
import {
|
||||||
|
access,
|
||||||
cp,
|
cp,
|
||||||
mkdir,
|
mkdir,
|
||||||
readFile,
|
readFile,
|
||||||
@@ -14,6 +15,11 @@ const fixtureRoot = path.resolve(".tmp/reference-feature-removal");
|
|||||||
const pnpmCli = requireEnvironment("npm_execpath");
|
const pnpmCli = requireEnvironment("npm_execpath");
|
||||||
const featureSource = "src/features/reference-feature";
|
const featureSource = "src/features/reference-feature";
|
||||||
const featureTests = "tests/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 = [
|
const featureOwnedPaths = [
|
||||||
featureSource,
|
featureSource,
|
||||||
featureTests,
|
featureTests,
|
||||||
@@ -22,9 +28,6 @@ const featureOwnedPaths = [
|
|||||||
"tests/mocks",
|
"tests/mocks",
|
||||||
"tests/fixtures/typecheck/invalid-feature-input.ts",
|
"tests/fixtures/typecheck/invalid-feature-input.ts",
|
||||||
"tests/fixtures/typecheck/invalid-reference-operation.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 = [
|
const copyTargets = [
|
||||||
"src",
|
"src",
|
||||||
@@ -314,6 +317,21 @@ for (const root of ["src", "tests"]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const checks: Array<[string, boolean]> = [
|
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")],
|
["typecheck", runPnpm("check:types")],
|
||||||
["architecture", runPnpm("check:architecture")],
|
["architecture", runPnpm("check:architecture")],
|
||||||
["registry-structure", runPnpm("check:registries:structure")],
|
["registry-structure", runPnpm("check:registries:structure")],
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { isCacheInvalidationTopic } from "./cache-invalidation.ts";
|
import { isCacheInvalidationTopic } from "./cache-invalidation.ts";
|
||||||
import {
|
import {
|
||||||
|
defineQueryNamespaceIdentity,
|
||||||
queryNamespaceIdentityKey,
|
queryNamespaceIdentityKey,
|
||||||
type QueryNamespaceIdentity,
|
type QueryNamespaceIdentity,
|
||||||
} from "./query-keys.ts";
|
} from "./query-keys.ts";
|
||||||
@@ -111,8 +112,13 @@ export function indexInvalidationRegistry(
|
|||||||
const namespaces = new Map<string, QueryNamespaceIdentity>();
|
const namespaces = new Map<string, QueryNamespaceIdentity>();
|
||||||
for (const namespace of registry.namespaces) {
|
for (const namespace of registry.namespaces) {
|
||||||
let namespaceKey: string;
|
let namespaceKey: string;
|
||||||
|
let namespaceSnapshot: QueryNamespaceIdentity;
|
||||||
try {
|
try {
|
||||||
namespaceKey = queryNamespaceIdentityKey(namespace);
|
namespaceSnapshot = defineQueryNamespaceIdentity(
|
||||||
|
namespace.namespaceId,
|
||||||
|
namespace.namespaceVersion,
|
||||||
|
);
|
||||||
|
namespaceKey = queryNamespaceIdentityKey(namespaceSnapshot);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new TypeError("Invalidation registry namespace is invalid.", {
|
throw new TypeError("Invalidation registry namespace is invalid.", {
|
||||||
cause: error,
|
cause: error,
|
||||||
@@ -121,7 +127,7 @@ export function indexInvalidationRegistry(
|
|||||||
if (namespaces.has(namespaceKey)) {
|
if (namespaces.has(namespaceKey)) {
|
||||||
throw new TypeError(`Duplicate invalidation namespace: ${namespaceKey}`);
|
throw new TypeError(`Duplicate invalidation namespace: ${namespaceKey}`);
|
||||||
}
|
}
|
||||||
namespaces.set(namespaceKey, namespace);
|
namespaces.set(namespaceKey, namespaceSnapshot);
|
||||||
}
|
}
|
||||||
|
|
||||||
const namespacesForTopic = new Map<string, QueryNamespaceIdentity[]>();
|
const namespacesForTopic = new Map<string, QueryNamespaceIdentity[]>();
|
||||||
|
|||||||
@@ -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<typeof createRuntimeAdapters>[0]["runtime"];
|
||||||
|
type Release = Parameters<typeof createRuntimeAdapters>[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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
});
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
CommandEffectDescriptor,
|
||||||
|
InstalledContractContribution,
|
||||||
|
InstalledHttpContract,
|
||||||
|
RuntimeValidator,
|
||||||
|
} from "../../src/contracts/external-contract-runtime.ts";
|
||||||
|
|
||||||
|
function zodValidator<T>(
|
||||||
|
schemaId: string,
|
||||||
|
schema: z.ZodType<T>,
|
||||||
|
): RuntimeValidator<T> {
|
||||||
|
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<typeof problemSchema>;
|
||||||
|
|
||||||
|
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<typeof entitySchema>[],
|
||||||
|
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<typeof entitySchema>,
|
||||||
|
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<TestProblem> = 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<typeof entitySchema>,
|
||||||
|
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<unknown, unknown, unknown>[],
|
||||||
|
events: Object.freeze([]),
|
||||||
|
});
|
||||||
@@ -5,14 +5,14 @@ import {
|
|||||||
composeContractContributions,
|
composeContractContributions,
|
||||||
type InstalledContractContribution,
|
type InstalledContractContribution,
|
||||||
} from "../../src/contracts/external-contract-runtime.ts";
|
} 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(
|
function contribution(
|
||||||
contributionId: string,
|
contributionId: string,
|
||||||
http: InstalledContractContribution["http"],
|
http: InstalledContractContribution["http"],
|
||||||
): InstalledContractContribution & Readonly<{ contributionId: string }> {
|
): InstalledContractContribution & Readonly<{ contributionId: string }> {
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
...REFERENCE_FEATURE_TEMPLATE_CONTRIBUTION,
|
...TEST_CONTRACT_CONTRIBUTION,
|
||||||
contributionId,
|
contributionId,
|
||||||
http: Object.freeze([...http]),
|
http: Object.freeze([...http]),
|
||||||
});
|
});
|
||||||
@@ -30,10 +30,10 @@ function captureContributionError(operation: () => unknown): ContractContributio
|
|||||||
|
|
||||||
describe("external contract contribution composition", () => {
|
describe("external contract contribution composition", () => {
|
||||||
it("allows one feature to install multiple uniquely identified contributions", () => {
|
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([
|
const composed = composeContractContributions([
|
||||||
contribution("reference-read-contracts", operations.slice(0, 2)),
|
contribution("test-read-contracts", operations.slice(0, 2)),
|
||||||
contribution("reference-command-contracts", operations.slice(2)),
|
contribution("test-command-contracts", operations.slice(2)),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(composed.contributions).toHaveLength(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", () => {
|
it("rejects duplicate contribution identities even across different feature entries", () => {
|
||||||
const first = contribution(
|
const first = contribution(
|
||||||
"reference-contracts",
|
"test-contracts",
|
||||||
REFERENCE_FEATURE_TEMPLATE_CONTRIBUTION.http.slice(0, 1),
|
TEST_CONTRACT_CONTRIBUTION.http.slice(0, 1),
|
||||||
);
|
);
|
||||||
const second = contribution(
|
const second = contribution(
|
||||||
"reference-contracts",
|
"test-contracts",
|
||||||
REFERENCE_FEATURE_TEMPLATE_CONTRIBUTION.http.slice(1),
|
TEST_CONTRACT_CONTRIBUTION.http.slice(1),
|
||||||
);
|
);
|
||||||
|
|
||||||
const error = captureContributionError(() =>
|
const error = captureContributionError(() =>
|
||||||
@@ -69,7 +69,7 @@ describe("external contract contribution composition", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("rejects method and body vocabulary outside the runtime protocol", () => {
|
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 = {
|
const malformedOperation = {
|
||||||
...installed,
|
...installed,
|
||||||
contract: { ...installed.contract, method: "TRACE" },
|
contract: { ...installed.contract, method: "TRACE" },
|
||||||
|
|||||||
@@ -2,15 +2,13 @@ import { describe, expect, it, vi } from "vitest";
|
|||||||
|
|
||||||
import { createContractHttpExecutor } from "../../src/adapters/http/http-execution-v3.ts";
|
import { createContractHttpExecutor } from "../../src/adapters/http/http-execution-v3.ts";
|
||||||
import type { InstalledHttpContract } from "../../src/contracts/external-contract-runtime.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 installed: InstalledHttpContract<unknown, unknown, unknown> =
|
||||||
const candidate = COMPOSED_CONTRACT_CONTRIBUTIONS.httpByOperationId.get(
|
TEST_LIST_HTTP_CONTRACT;
|
||||||
"LIST_REFERENCE_RESOURCES",
|
|
||||||
);
|
|
||||||
if (!candidate) throw new Error("reference list contract is not installed");
|
|
||||||
return candidate;
|
|
||||||
})();
|
|
||||||
|
|
||||||
const scope = Object.freeze({
|
const scope = Object.freeze({
|
||||||
generation: 1,
|
generation: 1,
|
||||||
@@ -20,13 +18,8 @@ const scope = Object.freeze({
|
|||||||
isCurrent: () => true,
|
isCurrent: () => true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const createInstalled = (() => {
|
const createInstalled: InstalledHttpContract<unknown, unknown, unknown> =
|
||||||
const candidate = COMPOSED_CONTRACT_CONTRIBUTIONS.httpByOperationId.get(
|
TEST_CREATE_HTTP_CONTRACT;
|
||||||
"CREATE_REFERENCE_RESOURCE",
|
|
||||||
);
|
|
||||||
if (!candidate) throw new Error("reference create contract is not installed");
|
|
||||||
return candidate;
|
|
||||||
})();
|
|
||||||
|
|
||||||
function operation(
|
function operation(
|
||||||
overrides: Readonly<{
|
overrides: Readonly<{
|
||||||
|
|||||||
@@ -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", () => {
|
it("rejects duplicate namespace identities even when they are separate objects", () => {
|
||||||
const duplicate = defineQueryNamespaceIdentity("orders", 1);
|
const duplicate = defineQueryNamespaceIdentity("orders", 1);
|
||||||
|
|
||||||
|
|||||||
@@ -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";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
type RegistryDefinition = Readonly<{
|
type RegistryDefinition = Readonly<{
|
||||||
@@ -6,17 +9,25 @@ type RegistryDefinition = Readonly<{
|
|||||||
owner: string;
|
owner: string;
|
||||||
requiredFields: string[];
|
requiredFields: string[];
|
||||||
fieldTypes: Record<string, string>;
|
fieldTypes: Record<string, string>;
|
||||||
|
path: string;
|
||||||
|
exportName: string;
|
||||||
|
rowsPath?: string;
|
||||||
|
rowKeyFields?: string[];
|
||||||
|
uniqueFields?: string[];
|
||||||
|
uniqueFieldSets?: string[][];
|
||||||
|
consumers?: Array<{ path: string; token: string }>;
|
||||||
|
breakingFields?: string[];
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
describe("registry governance manifest", () => {
|
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(
|
const governance = JSON.parse(
|
||||||
await readFile("config/contracts/registry-governance.json", "utf8"),
|
await readFile("config/contracts/registry-governance.json", "utf8"),
|
||||||
);
|
);
|
||||||
const registries = governance.registries as RegistryDefinition[];
|
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(
|
expect(new Set(registries.map((entry) => entry.registryId)).size).toBe(
|
||||||
9,
|
11,
|
||||||
);
|
);
|
||||||
expect(registries.every((entry) => entry.owner)).toBe(true);
|
expect(registries.every((entry) => entry.owner)).toBe(true);
|
||||||
expect(
|
expect(
|
||||||
@@ -29,4 +40,131 @@ describe("registry governance manifest", () => {
|
|||||||
),
|
),
|
||||||
).toBe(true);
|
).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<string, unknown> }>;
|
||||||
|
};
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,14 +4,6 @@ import {
|
|||||||
createRuntimeAdapters,
|
createRuntimeAdapters,
|
||||||
createRuntimeHttpClient,
|
createRuntimeHttpClient,
|
||||||
} from "../../src/bootstrap/runtime-adapters.ts";
|
} 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";
|
import { TEST_HTTP_CONTRACT } from "../helpers/http-contract-fixture.ts";
|
||||||
|
|
||||||
type Runtime = Parameters<typeof createRuntimeAdapters>[0]["runtime"];
|
type Runtime = Parameters<typeof createRuntimeAdapters>[0]["runtime"];
|
||||||
@@ -60,33 +52,6 @@ const release: Release = {
|
|||||||
routeChunks: { "route-home": "assets/home.js" },
|
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", () => {
|
describe("runtime adapter composition", () => {
|
||||||
it("constructs the local demo seam and infrastructure adapters", async () => {
|
it("constructs the local demo seam and infrastructure adapters", async () => {
|
||||||
const adapters = await createRuntimeAdapters({
|
const adapters = await createRuntimeAdapters({
|
||||||
@@ -112,66 +77,11 @@ describe("runtime adapter composition", () => {
|
|||||||
adapters.infrastructure.dispose();
|
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 () => {
|
it("replaces the QueryClient and coordinator for each session generation", async () => {
|
||||||
const adapters = await createRuntimeAdapters({ runtime, release, host: {} });
|
const adapters = await createRuntimeAdapters({ runtime, release, host: {} });
|
||||||
const previousClient = adapters.infrastructure.queryClient;
|
const previousClient = adapters.infrastructure.queryClient;
|
||||||
const previousCoordinator = adapters.infrastructure.queryInvalidation;
|
const previousCoordinator = adapters.infrastructure.queryInvalidation;
|
||||||
const invalidatePrevious = vi.spyOn(previousClient, "invalidateQueries");
|
const clearPrevious = vi.spyOn(previousClient, "clear");
|
||||||
|
|
||||||
await adapters.outputPorts.session.beginSignIn();
|
await adapters.outputPorts.session.beginSignIn();
|
||||||
|
|
||||||
@@ -182,10 +92,9 @@ describe("runtime adapter composition", () => {
|
|||||||
previousCoordinator,
|
previousCoordinator,
|
||||||
);
|
);
|
||||||
|
|
||||||
const topic = INVALIDATION_REGISTRY.topics[0];
|
const clearCallsAfterReplacement = clearPrevious.mock.calls.length;
|
||||||
if (!topic) throw new Error("expected an installed invalidation topic");
|
await previousCoordinator.resetLocal();
|
||||||
await previousCoordinator.invalidate([topic]);
|
expect(clearPrevious).toHaveBeenCalledTimes(clearCallsAfterReplacement);
|
||||||
expect(invalidatePrevious).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
adapters.infrastructure.dispose();
|
adapters.infrastructure.dispose();
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user