202 lines
6.0 KiB
TypeScript
202 lines
6.0 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
|
|
import {
|
|
defineQueryInvalidationTopic,
|
|
indexInvalidationRegistry,
|
|
indexInvalidationTopicVersions,
|
|
type InvalidationRegistry,
|
|
} from "../../src/contracts/query-invalidation.ts";
|
|
import {
|
|
createBoundQueryKey,
|
|
createQueryInvalidationPrefix,
|
|
defineQueryNamespaceIdentity,
|
|
queryNamespaceIdentityKey,
|
|
type QueryNamespaceIdentity,
|
|
} from "../../src/contracts/query-keys.ts";
|
|
|
|
describe("query namespace identity", () => {
|
|
it("uses a canonical JSON tuple as the namespace identity key", () => {
|
|
const namespace = defineQueryNamespaceIdentity("orders", 1);
|
|
|
|
expect(queryNamespaceIdentityKey(namespace)).toBe(
|
|
'["orders",1]',
|
|
);
|
|
});
|
|
|
|
it("creates one namespace-first prefix for invalidation and bound keys", () => {
|
|
const namespace = defineQueryNamespaceIdentity("orders", 1);
|
|
const prefix = createQueryInvalidationPrefix(namespace);
|
|
const key = createBoundQueryKey(
|
|
namespace,
|
|
"scope-fingerprint-0001",
|
|
3,
|
|
"identity-token-0001",
|
|
);
|
|
|
|
expect(prefix).toEqual(["query", 2, "orders", 1]);
|
|
expect(key).toEqual([
|
|
"query",
|
|
2,
|
|
"orders",
|
|
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("orders", namespaceVersion),
|
|
).toThrow(/namespace identity is invalid/u);
|
|
},
|
|
);
|
|
|
|
it("rejects a non-positive definition version before creating a bound key", () => {
|
|
const namespace = defineQueryNamespaceIdentity("orders", 1);
|
|
|
|
expect(() =>
|
|
createBoundQueryKey(
|
|
namespace,
|
|
"scope-fingerprint-0001",
|
|
0,
|
|
"identity-token-0001",
|
|
),
|
|
).toThrow(/definition version is invalid/u);
|
|
});
|
|
});
|
|
|
|
describe("query invalidation registry", () => {
|
|
const topic = defineQueryInvalidationTopic("qinv.orders.changed");
|
|
const orders = defineQueryNamespaceIdentity("orders", 1);
|
|
const summaries = defineQueryNamespaceIdentity("order-summaries", 2);
|
|
|
|
it("indexes every namespace identity connected to one topic", () => {
|
|
const registry: InvalidationRegistry = {
|
|
topics: [topic],
|
|
namespaces: [orders, summaries],
|
|
edges: [
|
|
{ topicId: topic, namespace: orders },
|
|
{ topicId: topic, namespace: summaries },
|
|
],
|
|
};
|
|
|
|
const index = indexInvalidationRegistry(registry);
|
|
|
|
expect(index.namespacesForTopic.get(topic)).toEqual([orders, summaries]);
|
|
expect(index.topicsForNamespace.get('["orders",1]')).toEqual([topic]);
|
|
expect(index.topicsForNamespace.get('["order-summaries",2]')).toEqual([
|
|
topic,
|
|
]);
|
|
});
|
|
|
|
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);
|
|
|
|
expect(() =>
|
|
indexInvalidationRegistry({
|
|
topics: [topic],
|
|
namespaces: [orders, duplicate],
|
|
edges: [{ topicId: topic, namespace: orders }],
|
|
}),
|
|
).toThrow(/Duplicate invalidation namespace/u);
|
|
});
|
|
|
|
it("projects one bounded transport version for every registered topic", () => {
|
|
const registry: InvalidationRegistry = {
|
|
topics: [topic],
|
|
namespaces: [orders],
|
|
edges: [{ topicId: topic, namespace: orders }],
|
|
};
|
|
const versions = indexInvalidationTopicVersions(registry, [
|
|
{ topicId: topic, topicVersion: 1 },
|
|
]);
|
|
|
|
expect([...versions]).toEqual([[topic, 1]]);
|
|
expect(() =>
|
|
indexInvalidationTopicVersions(registry, [
|
|
{ topicId: topic, topicVersion: 1 },
|
|
{ topicId: topic, topicVersion: 2 },
|
|
]),
|
|
).toThrow(/topic version registry/u);
|
|
expect(() => indexInvalidationTopicVersions(registry, [])).toThrow(
|
|
/topic version registry/u,
|
|
);
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
label: "topic",
|
|
registry: {
|
|
topics: ["orders\u0000private"],
|
|
namespaces: [orders],
|
|
edges: [{ topicId: "orders\u0000private", namespace: orders }],
|
|
},
|
|
},
|
|
{
|
|
label: "namespace",
|
|
registry: {
|
|
topics: ["orders"],
|
|
namespaces: [
|
|
{
|
|
namespaceId: "orders\u001fprivate",
|
|
namespaceVersion: 1,
|
|
} as QueryNamespaceIdentity,
|
|
],
|
|
edges: [
|
|
{
|
|
topicId: "orders",
|
|
namespace: {
|
|
namespaceId: "orders\u001fprivate",
|
|
namespaceVersion: 1,
|
|
} as QueryNamespaceIdentity,
|
|
},
|
|
],
|
|
},
|
|
},
|
|
])("rejects control characters in a registry $label", ({ registry }) => {
|
|
expect(() => indexInvalidationRegistry(registry)).toThrow(/is invalid/u);
|
|
});
|
|
});
|