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[]>> =
|
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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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([
|
||||||
|
|||||||
Reference in New Issue
Block a user