fix: align bound query keys with invalidation prefixes

This commit is contained in:
DongHyeonka
2026-08-01 22:11:04 +09:00
parent 92c3d438ab
commit 853c2e3f30
4 changed files with 207 additions and 8 deletions
+84
View File
@@ -1,6 +1,90 @@
export const QUERY_REGISTRY: Readonly<Record<string, readonly unknown[]>> = export const QUERY_REGISTRY: Readonly<Record<string, readonly unknown[]>> =
Object.freeze({}); 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 = export type CanonicalValue =
| null | null
| boolean | boolean
+12 -7
View File
@@ -1,6 +1,10 @@
import type { Result } from "../application/result.ts"; import type { Result } from "../application/result.ts";
import type { QueryInvalidationTopic } from "./query-invalidation.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"; import type { CacheScopeSnapshot } from "./server-state-scope.ts";
/** /**
@@ -176,20 +180,21 @@ export function bindQuery<Input, Value>(
`Query definition requires measureResult: ${definition.definitionId}`, `Query definition requires measureResult: ${definition.definitionId}`,
); );
} }
const namespace = defineQueryNamespaceIdentity(
definition.namespace,
definition.namespaceVersion,
);
const identity = scope.identities.intern(input); const identity = scope.identities.intern(input);
return Object.freeze({ return Object.freeze({
definitionId: definition.definitionId, definitionId: definition.definitionId,
// §10.7. Opaque runtime identity only. No raw account/resource ID, URL, // §10.7. Opaque runtime identity only. No raw account/resource ID, URL,
// filter object, document or cursor ever enters a query key. // filter object, document or cursor ever enters a query key.
queryKey: Object.freeze([ queryKey: createBoundQueryKey(
"query", namespace,
1,
scope.fingerprint, scope.fingerprint,
definition.namespace,
definition.namespaceVersion,
definition.definitionVersion, definition.definitionVersion,
identity.token, identity.token,
]), ),
profile: getServerStateProfile(definition.profileId), profile: getServerStateProfile(definition.profileId),
identity, identity,
scope, scope,
+37 -1
View File
@@ -20,7 +20,11 @@ import {
type QueryInvalidationCoordinator, type QueryInvalidationCoordinator,
} from "../../src/contracts/query-invalidation.ts"; } from "../../src/contracts/query-invalidation.ts";
import { createFailure } from "../../src/contracts/errors.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 { import {
bindQuery, bindQuery,
type QueryResultMeasure, type QueryResultMeasure,
@@ -216,6 +220,38 @@ describe("application query inbound bridge", () => {
}); });
describe("scope-bound query commit fence", () => { 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 () => { it("discards a successful result whose scope was fenced during execution", async () => {
const client = queryClient(); const client = queryClient();
const scope = scopeSnapshot(); const scope = scopeSnapshot();
@@ -1,6 +1,80 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { indexInvalidationRegistry } from "../../src/contracts/query-invalidation.ts"; 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", () => { describe("query invalidation registry", () => {
it.each([ it.each([