fix: harden invalidation registry governance
This commit is contained in:
@@ -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" },
|
||||
|
||||
@@ -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<unknown, unknown, unknown> =
|
||||
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<unknown, unknown, unknown> =
|
||||
TEST_CREATE_HTTP_CONTRACT;
|
||||
|
||||
function operation(
|
||||
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", () => {
|
||||
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";
|
||||
|
||||
type RegistryDefinition = Readonly<{
|
||||
@@ -6,17 +9,25 @@ type RegistryDefinition = Readonly<{
|
||||
owner: string;
|
||||
requiredFields: 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", () => {
|
||||
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<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,
|
||||
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<typeof createRuntimeAdapters>[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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user