diff --git a/src/contracts/query-keys.ts b/src/contracts/query-keys.ts index 6a15257..0d67187 100644 --- a/src/contracts/query-keys.ts +++ b/src/contracts/query-keys.ts @@ -1,6 +1,90 @@ export const QUERY_REGISTRY: Readonly> = Object.freeze({}); +export const QUERY_KEY_SCHEMA_VERSION = 2 as const; +export const QUERY_NAMESPACE_ID_MAX_BYTES = 80 as const; + +export type QueryNamespaceIdentity = Readonly<{ + namespaceId: string; + namespaceVersion: number; +}>; + +function hasControlCharacter(value: string): boolean { + for (const character of value) { + const codePoint = character.codePointAt(0) ?? 0; + if ( + codePoint <= 0x1f || + (codePoint >= 0x7f && codePoint <= 0x9f) + ) { + return true; + } + } + return false; +} + +function assertQueryNamespaceIdentity( + namespace: QueryNamespaceIdentity, +): void { + if ( + !namespace || + typeof namespace !== "object" || + typeof namespace.namespaceId !== "string" || + namespace.namespaceId.length === 0 || + hasControlCharacter(namespace.namespaceId) || + new TextEncoder().encode(namespace.namespaceId).byteLength > + QUERY_NAMESPACE_ID_MAX_BYTES || + !Number.isSafeInteger(namespace.namespaceVersion) || + namespace.namespaceVersion < 1 + ) { + throw new TypeError("Query namespace identity is invalid."); + } +} + +export function defineQueryNamespaceIdentity( + namespaceId: string, + namespaceVersion: number, +): QueryNamespaceIdentity { + const namespace = { namespaceId, namespaceVersion }; + assertQueryNamespaceIdentity(namespace); + return Object.freeze(namespace); +} + +export function queryNamespaceIdentityKey( + namespace: QueryNamespaceIdentity, +): string { + assertQueryNamespaceIdentity(namespace); + return JSON.stringify([namespace.namespaceId, namespace.namespaceVersion]); +} + +export function createQueryInvalidationPrefix( + namespace: QueryNamespaceIdentity, +) { + assertQueryNamespaceIdentity(namespace); + return Object.freeze([ + "query", + QUERY_KEY_SCHEMA_VERSION, + namespace.namespaceId, + namespace.namespaceVersion, + ] as const); +} + +export function createBoundQueryKey( + namespace: QueryNamespaceIdentity, + scopeFingerprint: string, + definitionVersion: number, + identityToken: string, +) { + if (!Number.isSafeInteger(definitionVersion) || definitionVersion < 1) { + throw new TypeError("Query definition version is invalid."); + } + return Object.freeze([ + ...createQueryInvalidationPrefix(namespace), + scopeFingerprint, + definitionVersion, + identityToken, + ] as const); +} + export type CanonicalValue = | null | boolean diff --git a/src/contracts/server-state.ts b/src/contracts/server-state.ts index 7dbaf32..7d08682 100644 --- a/src/contracts/server-state.ts +++ b/src/contracts/server-state.ts @@ -1,6 +1,10 @@ import type { Result } from "../application/result.ts"; import type { QueryInvalidationTopic } from "./query-invalidation.ts"; -import type { RuntimeIdentityBinding } from "./query-keys.ts"; +import { + createBoundQueryKey, + defineQueryNamespaceIdentity, + type RuntimeIdentityBinding, +} from "./query-keys.ts"; import type { CacheScopeSnapshot } from "./server-state-scope.ts"; /** @@ -176,20 +180,21 @@ export function bindQuery( `Query definition requires measureResult: ${definition.definitionId}`, ); } + const namespace = defineQueryNamespaceIdentity( + definition.namespace, + definition.namespaceVersion, + ); const identity = scope.identities.intern(input); return Object.freeze({ definitionId: definition.definitionId, // §10.7. Opaque runtime identity only. No raw account/resource ID, URL, // filter object, document or cursor ever enters a query key. - queryKey: Object.freeze([ - "query", - 1, + queryKey: createBoundQueryKey( + namespace, scope.fingerprint, - definition.namespace, - definition.namespaceVersion, definition.definitionVersion, identity.token, - ]), + ), profile: getServerStateProfile(definition.profileId), identity, scope, diff --git a/tests/component/application-query.test.tsx b/tests/component/application-query.test.tsx index 3d9a082..4ea9e3d 100644 --- a/tests/component/application-query.test.tsx +++ b/tests/component/application-query.test.tsx @@ -20,7 +20,11 @@ import { type QueryInvalidationCoordinator, } from "../../src/contracts/query-invalidation.ts"; import { createFailure } from "../../src/contracts/errors.ts"; -import { createRuntimeIdentityRegistry } from "../../src/contracts/query-keys.ts"; +import { + createQueryInvalidationPrefix, + createRuntimeIdentityRegistry, + defineQueryNamespaceIdentity, +} from "../../src/contracts/query-keys.ts"; import { bindQuery, type QueryResultMeasure, @@ -216,6 +220,38 @@ describe("application query inbound bridge", () => { }); describe("scope-bound query commit fence", () => { + it("binds the namespace-first V2 query key", () => { + const scope = scopeSnapshot(); + const definition = { + definitionId: "reference-detail-v1", + definitionVersion: 1, + owner: "platform-test", + namespace: "reference-resource", + namespaceVersion: 1, + operationId: "GET_REFERENCE_RESOURCE", + profileId: "DETAIL_STANDARD" as const, + measureResult: measureOne, + execute: async () => ({ ok: true as const, value: "value" }), + }; + + const bound = bindQuery(definition, "resource-1", scope); + + expect(bound.queryKey).toEqual([ + "query", + 2, + "reference-resource", + 1, + "scope-fingerprint-0001", + 1, + "scope-identity-token-0001", + ]); + expect(bound.queryKey.slice(0, 4)).toEqual( + createQueryInvalidationPrefix( + defineQueryNamespaceIdentity("reference-resource", 1), + ), + ); + }); + it("discards a successful result whose scope was fenced during execution", async () => { const client = queryClient(); const scope = scopeSnapshot(); diff --git a/tests/unit/query-invalidation-registry.test.ts b/tests/unit/query-invalidation-registry.test.ts index f5d4253..48a4600 100644 --- a/tests/unit/query-invalidation-registry.test.ts +++ b/tests/unit/query-invalidation-registry.test.ts @@ -1,6 +1,80 @@ import { describe, expect, it } from "vitest"; import { indexInvalidationRegistry } from "../../src/contracts/query-invalidation.ts"; +import { + createBoundQueryKey, + createQueryInvalidationPrefix, + defineQueryNamespaceIdentity, + queryNamespaceIdentityKey, +} from "../../src/contracts/query-keys.ts"; + +describe("query namespace identity", () => { + it("uses a canonical JSON tuple as the namespace identity key", () => { + const namespace = defineQueryNamespaceIdentity("reference-resource", 1); + + expect(queryNamespaceIdentityKey(namespace)).toBe( + '["reference-resource",1]', + ); + }); + + it("creates one namespace-first prefix for invalidation and bound keys", () => { + const namespace = defineQueryNamespaceIdentity("reference-resource", 1); + const prefix = createQueryInvalidationPrefix(namespace); + const key = createBoundQueryKey( + namespace, + "scope-fingerprint-0001", + 3, + "identity-token-0001", + ); + + expect(prefix).toEqual(["query", 2, "reference-resource", 1]); + expect(key).toEqual([ + "query", + 2, + "reference-resource", + 1, + "scope-fingerprint-0001", + 3, + "identity-token-0001", + ]); + expect(key.slice(0, 4)).toEqual(prefix); + expect(Object.isFrozen(prefix)).toBe(true); + expect(Object.isFrozen(key)).toBe(true); + }); + + it.each([ + ["empty", ""], + ["C0 control", "orders\u0000private"], + ["DEL control", "orders\u007fprivate"], + ["81 UTF-8 bytes", "가".repeat(27)], + ])("rejects a $0 namespace ID", (_label, namespaceId) => { + expect(() => defineQueryNamespaceIdentity(namespaceId, 1)).toThrow( + /namespace identity is invalid/u, + ); + }); + + it.each([0, -1, 1.5, Number.MAX_SAFE_INTEGER + 1])( + "rejects the non-positive-safe namespace version %s", + (namespaceVersion) => { + expect(() => + defineQueryNamespaceIdentity("reference-resource", namespaceVersion), + ).toThrow(/namespace identity is invalid/u); + }, + ); + + it("rejects a non-positive definition version before creating a bound key", () => { + const namespace = defineQueryNamespaceIdentity("reference-resource", 1); + + expect(() => + createBoundQueryKey( + namespace, + "scope-fingerprint-0001", + 0, + "identity-token-0001", + ), + ).toThrow(/definition version is invalid/u); + }); +}); describe("query invalidation registry", () => { it.each([