fix: align bound query keys with invalidation prefixes
This commit is contained in:
@@ -1,6 +1,90 @@
|
||||
export const QUERY_REGISTRY: Readonly<Record<string, readonly unknown[]>> =
|
||||
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
|
||||
|
||||
@@ -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<Input, Value>(
|
||||
`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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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([
|
||||
|
||||
Reference in New Issue
Block a user