chore: initialize from frontend template 4dc033c
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
import type { CacheScopeSnapshot } from "../../contracts/server-state-scope.ts";
|
||||
|
||||
export type ConditionalValidatorBinding = Readonly<{
|
||||
definitionId: string;
|
||||
identityToken: string;
|
||||
representationVersion: number;
|
||||
scope: CacheScopeSnapshot;
|
||||
}>;
|
||||
|
||||
export type ConditionalValidatorStore = Readonly<{
|
||||
install(
|
||||
binding: ConditionalValidatorBinding,
|
||||
validator: string,
|
||||
cacheRevision: number,
|
||||
): boolean;
|
||||
prepare(
|
||||
binding: ConditionalValidatorBinding,
|
||||
cacheRevision: number,
|
||||
): string | null;
|
||||
acceptNotModified(
|
||||
binding: ConditionalValidatorBinding,
|
||||
cacheRevision: number,
|
||||
hasMappedValue: boolean,
|
||||
): boolean;
|
||||
remove(binding: ConditionalValidatorBinding): void;
|
||||
clear(): void;
|
||||
}>;
|
||||
|
||||
type ValidatorRow = {
|
||||
validator: string;
|
||||
cacheRevision: number;
|
||||
generation: number;
|
||||
};
|
||||
|
||||
export function createConditionalValidatorStore(
|
||||
maxEntries = 1_024,
|
||||
): ConditionalValidatorStore {
|
||||
if (!Number.isSafeInteger(maxEntries) || maxEntries < 1) {
|
||||
throw new TypeError("Invalid conditional validator capacity.");
|
||||
}
|
||||
const rows = new Map<string, ValidatorRow>();
|
||||
|
||||
function key(binding: ConditionalValidatorBinding): string | null {
|
||||
if (
|
||||
!binding.scope.isCurrent() ||
|
||||
!binding.definitionId ||
|
||||
!/^[A-Za-z0-9._:-]{16,128}$/.test(binding.identityToken) ||
|
||||
!Number.isSafeInteger(binding.representationVersion) ||
|
||||
binding.representationVersion < 1
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return [
|
||||
binding.scope.fingerprint,
|
||||
binding.definitionId,
|
||||
binding.identityToken,
|
||||
binding.representationVersion,
|
||||
].join(":");
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
install(binding, validator, cacheRevision) {
|
||||
const selectedKey = key(binding);
|
||||
if (
|
||||
!selectedKey ||
|
||||
!isSafeEntityTag(validator) ||
|
||||
!Number.isSafeInteger(cacheRevision) ||
|
||||
cacheRevision < 0
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (!rows.has(selectedKey) && rows.size >= maxEntries) return false;
|
||||
rows.set(selectedKey, {
|
||||
validator,
|
||||
cacheRevision,
|
||||
generation: binding.scope.generation,
|
||||
});
|
||||
return true;
|
||||
},
|
||||
prepare(binding, cacheRevision) {
|
||||
const selectedKey = key(binding);
|
||||
if (!selectedKey) return null;
|
||||
const row = rows.get(selectedKey);
|
||||
return row &&
|
||||
row.generation === binding.scope.generation &&
|
||||
row.cacheRevision === cacheRevision
|
||||
? row.validator
|
||||
: null;
|
||||
},
|
||||
acceptNotModified(binding, cacheRevision, hasMappedValue) {
|
||||
const selectedKey = key(binding);
|
||||
if (!selectedKey || !hasMappedValue) return false;
|
||||
const row = rows.get(selectedKey);
|
||||
return Boolean(
|
||||
row &&
|
||||
row.generation === binding.scope.generation &&
|
||||
row.cacheRevision === cacheRevision,
|
||||
);
|
||||
},
|
||||
remove(binding) {
|
||||
const selectedKey = key(binding);
|
||||
if (selectedKey) rows.delete(selectedKey);
|
||||
},
|
||||
clear() {
|
||||
rows.clear();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function isSafeEntityTag(value: string): boolean {
|
||||
if (value.length < 3 || value.length > 256) return false;
|
||||
const opaque = value.startsWith('W/"')
|
||||
? value.slice(3, -1)
|
||||
: value.startsWith('"')
|
||||
? value.slice(1, -1)
|
||||
: null;
|
||||
if (opaque === null || !value.endsWith('"')) return false;
|
||||
return [...opaque].every((character) => {
|
||||
const code = character.codePointAt(0) ?? 0;
|
||||
return code === 0x21 || (code >= 0x23 && code <= 0x7e) ||
|
||||
(code >= 0x80 && code <= 0xff);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import type { Result } from "../../application/result.ts";
|
||||
import type {
|
||||
CursorPage,
|
||||
CursorPaginationProfile,
|
||||
CursorPaginationRuntime,
|
||||
} from "../../contracts/cursor-pagination.ts";
|
||||
import { createFailure } from "../../contracts/errors.ts";
|
||||
|
||||
export function createCursorPaginationRuntime<Value>(dependencies: Readonly<{
|
||||
definitionId: string;
|
||||
profile: CursorPaginationProfile;
|
||||
loadPage(
|
||||
cursor: string | null,
|
||||
context: Readonly<{ signal?: AbortSignal }>,
|
||||
): Promise<Result<CursorPage<Value>>>;
|
||||
}>): CursorPaginationRuntime<Value> {
|
||||
validateProfile(dependencies.profile);
|
||||
return Object.freeze({
|
||||
async loadAll(context) {
|
||||
const items: Value[] = [];
|
||||
const cursors = new Set<string>();
|
||||
let cursor: string | null = null;
|
||||
let snapshot: string | null | undefined;
|
||||
for (
|
||||
let pageIndex = 0;
|
||||
pageIndex < dependencies.profile.maxPages;
|
||||
pageIndex += 1
|
||||
) {
|
||||
if (context.signal?.aborted) {
|
||||
return failure("REQUEST_ABORTED", "PAGINATION_ABORTED");
|
||||
}
|
||||
const result = await dependencies.loadPage(cursor, context);
|
||||
if (!result.ok) return result;
|
||||
const page = result.value;
|
||||
if (!isValidPage(page, dependencies.profile)) {
|
||||
return failure(
|
||||
"PAGINATION_CONTRACT_VIOLATION",
|
||||
"PAGINATION_PAGE_INVALID",
|
||||
);
|
||||
}
|
||||
if (snapshot === undefined) {
|
||||
snapshot = page.snapshotToken;
|
||||
} else if (snapshot !== page.snapshotToken) {
|
||||
return failure(
|
||||
"PAGINATION_CONTRACT_VIOLATION",
|
||||
"PAGINATION_SNAPSHOT_CHANGED",
|
||||
);
|
||||
}
|
||||
items.push(...page.items);
|
||||
if (
|
||||
items.length > dependencies.profile.maxTotalItems ||
|
||||
estimatedBytes(items) > dependencies.profile.maxEstimatedBytes
|
||||
) {
|
||||
return failure(
|
||||
"RESULT_LIMIT_EXCEEDED",
|
||||
"PAGINATION_RESULT_LIMIT",
|
||||
);
|
||||
}
|
||||
if (!page.hasMore) return { ok: true, value: Object.freeze(items) };
|
||||
const nextCursor = page.nextCursor;
|
||||
if (!nextCursor || cursors.has(nextCursor)) {
|
||||
return failure(
|
||||
"PAGINATION_CONTRACT_VIOLATION",
|
||||
"PAGINATION_CURSOR_LOOP",
|
||||
);
|
||||
}
|
||||
cursors.add(nextCursor);
|
||||
cursor = nextCursor;
|
||||
}
|
||||
return failure(
|
||||
"RESULT_LIMIT_EXCEEDED",
|
||||
"PAGINATION_PAGE_LIMIT",
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
function failure(
|
||||
kind:
|
||||
| "PAGINATION_CONTRACT_VIOLATION"
|
||||
| "RESULT_LIMIT_EXCEEDED"
|
||||
| "REQUEST_ABORTED",
|
||||
code: string,
|
||||
) {
|
||||
return {
|
||||
ok: false as const,
|
||||
error: createFailure(kind, dependencies.definitionId, 0, { code }),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function validateProfile(profile: CursorPaginationProfile): void {
|
||||
if (
|
||||
!profile.profileId ||
|
||||
!Number.isSafeInteger(profile.maxPages) ||
|
||||
profile.maxPages < 1 ||
|
||||
profile.maxPages > 100 ||
|
||||
!Number.isSafeInteger(profile.maxTotalItems) ||
|
||||
profile.maxTotalItems < 1 ||
|
||||
!Number.isSafeInteger(profile.maxEstimatedBytes) ||
|
||||
profile.maxEstimatedBytes < 1 ||
|
||||
!Number.isSafeInteger(profile.maxCursorBytes) ||
|
||||
profile.maxCursorBytes < 1 ||
|
||||
profile.maxCursorBytes > 4_096
|
||||
) {
|
||||
throw new TypeError("Invalid cursor pagination profile.");
|
||||
}
|
||||
}
|
||||
|
||||
function isValidPage<Value>(
|
||||
page: CursorPage<Value>,
|
||||
profile: CursorPaginationProfile,
|
||||
): boolean {
|
||||
const encoder = new TextEncoder();
|
||||
return (
|
||||
Boolean(page) &&
|
||||
Array.isArray(page.items) &&
|
||||
typeof page.hasMore === "boolean" &&
|
||||
page.hasMore === (page.nextCursor !== null) &&
|
||||
(page.nextCursor === null ||
|
||||
(typeof page.nextCursor === "string" &&
|
||||
page.nextCursor.length > 0 &&
|
||||
encoder.encode(page.nextCursor).byteLength <=
|
||||
profile.maxCursorBytes)) &&
|
||||
(page.snapshotToken === null ||
|
||||
(typeof page.snapshotToken === "string" &&
|
||||
page.snapshotToken.length > 0 &&
|
||||
encoder.encode(page.snapshotToken).byteLength <=
|
||||
profile.maxCursorBytes)) &&
|
||||
(profile.allowSparsePage || !page.hasMore || page.items.length > 0)
|
||||
);
|
||||
}
|
||||
|
||||
function estimatedBytes(value: unknown): number {
|
||||
try {
|
||||
return new TextEncoder().encode(JSON.stringify(value)).byteLength;
|
||||
} catch {
|
||||
return Number.POSITIVE_INFINITY;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import type { AuthSessionPort } from "../../application/ports/auth-session-port.ts";
|
||||
import type { QueryInvalidationCoordinator } from "../../contracts/query-invalidation.ts";
|
||||
import {
|
||||
createRuntimeIdentityRegistry,
|
||||
type RuntimeIdentityRegistry,
|
||||
} from "../../contracts/query-keys.ts";
|
||||
import type {
|
||||
CacheScopeSnapshot,
|
||||
ClientScopeLifecycleEvent,
|
||||
ClientScopePhase,
|
||||
ServerStateScopeRuntime,
|
||||
} from "../../contracts/server-state-scope.ts";
|
||||
|
||||
/**
|
||||
* Steps 4-11 of §10.6 that this runtime does not own directly. Each optional
|
||||
* capability registers its own closer so the ordering lives in one place rather
|
||||
* than being re-derived by every subsystem.
|
||||
*/
|
||||
export type ScopeResetParticipant = Readonly<{
|
||||
/** Lower runs earlier; the §10.6 step number is used as the rank. */
|
||||
order: number;
|
||||
label: string;
|
||||
close(): void | Promise<void>;
|
||||
}>;
|
||||
|
||||
export type ServerStateScopeDependencies = Readonly<{
|
||||
session: Pick<AuthSessionPort, "subscribe">;
|
||||
queryInvalidation: Pick<QueryInvalidationCoordinator, "resetLocal">;
|
||||
tokenFactory?: () => string;
|
||||
participants?: readonly ScopeResetParticipant[];
|
||||
activateNextGeneration?: () => void | Promise<void>;
|
||||
}>;
|
||||
|
||||
export function createServerStateScopeRuntime(
|
||||
dependencies: ServerStateScopeDependencies,
|
||||
): ServerStateScopeRuntime {
|
||||
const listeners = new Set<() => void>();
|
||||
const lifecycleListeners = new Set<
|
||||
(event: ClientScopeLifecycleEvent) => void
|
||||
>();
|
||||
const participants = [...(dependencies.participants ?? [])].sort(
|
||||
(left, right) => left.order - right.order,
|
||||
);
|
||||
|
||||
let generation = 1;
|
||||
let identities = newIdentityRegistry(dependencies.tokenFactory);
|
||||
let fingerprint = scopeFingerprint(dependencies.tokenFactory);
|
||||
let generationLifetime = new AbortController();
|
||||
let phase: ClientScopePhase = "READY";
|
||||
let disposed = false;
|
||||
let resetChain = Promise.resolve();
|
||||
|
||||
function createSnapshot(): CacheScopeSnapshot {
|
||||
const capturedGeneration = generation;
|
||||
const capturedIdentities = identities;
|
||||
return Object.freeze({
|
||||
generation: capturedGeneration,
|
||||
fingerprint,
|
||||
identities: capturedIdentities,
|
||||
signal: generationLifetime.signal,
|
||||
isCurrent: () =>
|
||||
!disposed &&
|
||||
phase === "READY" &&
|
||||
generation === capturedGeneration &&
|
||||
identities === capturedIdentities,
|
||||
});
|
||||
}
|
||||
let currentSnapshot = createSnapshot();
|
||||
|
||||
function publishLifecycle(event: ClientScopeLifecycleEvent): void {
|
||||
for (const listener of [...lifecycleListeners]) {
|
||||
try {
|
||||
listener(event);
|
||||
} catch {
|
||||
// One subscriber defect cannot stop the fence from propagating.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function publishSnapshot(): void {
|
||||
for (const listener of [...listeners]) {
|
||||
try {
|
||||
listener();
|
||||
} catch {
|
||||
// Subscriber defects are isolated from the mandatory reset sequence.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const unsubscribe = dependencies.session.subscribe(() => {
|
||||
if (disposed) return;
|
||||
const previousIdentities = identities;
|
||||
const previousGeneration = generation;
|
||||
|
||||
// §10.6 steps 1-3 are synchronous: increment the generation, invalidate the
|
||||
// old snapshot, publish FENCED. Nothing between here and READY may render a
|
||||
// value that belonged to the previous identity.
|
||||
generationLifetime.abort();
|
||||
const targetGeneration = ++generation;
|
||||
phase = "FENCED";
|
||||
currentSnapshot = createSnapshot();
|
||||
publishLifecycle(
|
||||
Object.freeze({ kind: "FENCED" as const, previousGeneration }),
|
||||
);
|
||||
publishSnapshot();
|
||||
|
||||
resetChain = resetChain
|
||||
.catch(() => {})
|
||||
.then(async () => {
|
||||
let failed = false;
|
||||
// Steps 4-11: close admission, cancel and clear, release leases.
|
||||
for (const participant of participants) {
|
||||
try {
|
||||
await participant.close();
|
||||
} catch {
|
||||
failed = true;
|
||||
}
|
||||
}
|
||||
try {
|
||||
await dependencies.queryInvalidation.resetLocal();
|
||||
} catch {
|
||||
failed = true;
|
||||
}
|
||||
|
||||
previousIdentities.close();
|
||||
if (disposed || generation !== targetGeneration) return;
|
||||
|
||||
if (!failed) {
|
||||
try {
|
||||
await dependencies.activateNextGeneration?.();
|
||||
} catch {
|
||||
failed = true;
|
||||
}
|
||||
}
|
||||
if (disposed || generation !== targetGeneration) return;
|
||||
|
||||
if (failed) {
|
||||
phase = "FAILED";
|
||||
currentSnapshot = createSnapshot();
|
||||
publishLifecycle(
|
||||
Object.freeze({
|
||||
kind: "FAILED" as const,
|
||||
generation: targetGeneration,
|
||||
}),
|
||||
);
|
||||
publishSnapshot();
|
||||
return;
|
||||
}
|
||||
|
||||
// Steps 12-15: new identity registry, READY, notify, reopen admission.
|
||||
identities = newIdentityRegistry(dependencies.tokenFactory);
|
||||
fingerprint = scopeFingerprint(dependencies.tokenFactory);
|
||||
generationLifetime = new AbortController();
|
||||
phase = "READY";
|
||||
currentSnapshot = createSnapshot();
|
||||
publishLifecycle(
|
||||
Object.freeze({ kind: "READY" as const, snapshot: currentSnapshot }),
|
||||
);
|
||||
publishSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
return Object.freeze({
|
||||
getSnapshot: () => currentSnapshot,
|
||||
getPhase: () => phase,
|
||||
subscribe(listener: () => void) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
subscribeLifecycle(listener: (event: ClientScopeLifecycleEvent) => void) {
|
||||
lifecycleListeners.add(listener);
|
||||
return () => lifecycleListeners.delete(listener);
|
||||
},
|
||||
dispose() {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
phase = "DISPOSED";
|
||||
generationLifetime.abort();
|
||||
unsubscribe();
|
||||
publishLifecycle(Object.freeze({ kind: "DISPOSED" as const }));
|
||||
listeners.clear();
|
||||
lifecycleListeners.clear();
|
||||
identities.close();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function newIdentityRegistry(
|
||||
tokenFactory: (() => string) | undefined,
|
||||
): RuntimeIdentityRegistry {
|
||||
return createRuntimeIdentityRegistry({
|
||||
...(tokenFactory ? { tokenFactory } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
function scopeFingerprint(tokenFactory: (() => string) | undefined): string {
|
||||
const candidate = tokenFactory?.() ?? crypto.randomUUID();
|
||||
if (!/^[A-Za-z0-9._:-]{16,128}$/.test(candidate)) {
|
||||
throw new TypeError("Invalid cache scope fingerprint.");
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
|
||||
import type { DiagnosticsPort } from "../../application/ports/diagnostics-port.ts";
|
||||
import type {
|
||||
InvalidationRegistryIndex,
|
||||
QueryInvalidationCoordinator,
|
||||
QueryInvalidationTopic,
|
||||
QueryMutationLease,
|
||||
} from "../../contracts/query-invalidation.ts";
|
||||
import { INVALIDATION_REGISTRY_BOUNDS } from "../../contracts/query-invalidation.ts";
|
||||
import {
|
||||
createQueryInvalidationPrefix,
|
||||
queryNamespaceIdentityKey,
|
||||
type QueryNamespaceIdentity,
|
||||
} from "../../contracts/query-keys.ts";
|
||||
import type {
|
||||
BrowserCrossContextInvalidation,
|
||||
CrossContextInvalidationDelivery,
|
||||
} from "../cross-context-invalidation/index.ts";
|
||||
|
||||
export type TanStackCacheCoordinatorDependencies = Readonly<{
|
||||
queryClient: QueryClient;
|
||||
invalidationIndex: InvalidationRegistryIndex;
|
||||
topicVersions: ReadonlyMap<string, number>;
|
||||
crossContext?: BrowserCrossContextInvalidation;
|
||||
diagnostics?: DiagnosticsPort;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Joins registry-owned invalidation topics to TanStack Query without putting a
|
||||
* query key or cached value on the cross-context wire.
|
||||
*/
|
||||
export function createTanStackCacheCoordinator(
|
||||
dependencies: TanStackCacheCoordinatorDependencies,
|
||||
): QueryInvalidationCoordinator {
|
||||
validateConfiguration(
|
||||
dependencies.invalidationIndex,
|
||||
dependencies.topicVersions,
|
||||
);
|
||||
const mutationLeases = new Map<QueryInvalidationTopic, number>();
|
||||
const pendingRemote = new Set<QueryInvalidationTopic>();
|
||||
let disposed = false;
|
||||
let resetting = false;
|
||||
let lifecycleGeneration = 0;
|
||||
let flushPromise: Promise<void> | null = null;
|
||||
let resetPromise: Promise<void> | null = null;
|
||||
|
||||
const unsubscribe = dependencies.crossContext?.subscribe((delivery) => {
|
||||
receiveRemote(delivery);
|
||||
});
|
||||
|
||||
function namespacesFor(
|
||||
topic: QueryInvalidationTopic,
|
||||
): readonly QueryNamespaceIdentity[] {
|
||||
const selected = dependencies.invalidationIndex.namespacesForTopic.get(topic);
|
||||
if (!selected) {
|
||||
throw new TypeError("Unregistered query invalidation topic.");
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
async function invalidateLocal(
|
||||
topics: readonly QueryInvalidationTopic[],
|
||||
expectedGeneration = lifecycleGeneration,
|
||||
): Promise<void> {
|
||||
if (
|
||||
disposed ||
|
||||
resetting ||
|
||||
expectedGeneration !== lifecycleGeneration
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const namespaces = new Map<string, QueryNamespaceIdentity>();
|
||||
for (const topic of topics) {
|
||||
for (const namespace of namespacesFor(topic)) {
|
||||
namespaces.set(queryNamespaceIdentityKey(namespace), namespace);
|
||||
}
|
||||
}
|
||||
for (const namespace of namespaces.values()) {
|
||||
try {
|
||||
await dependencies.queryClient.invalidateQueries({
|
||||
queryKey: createQueryInvalidationPrefix(namespace),
|
||||
exact: false,
|
||||
refetchType: "active",
|
||||
});
|
||||
} catch {
|
||||
report("invalidate");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function receiveRemote(
|
||||
delivery: CrossContextInvalidationDelivery,
|
||||
): void {
|
||||
if (disposed) return;
|
||||
if (
|
||||
!dependencies.invalidationIndex.namespacesForTopic.has(
|
||||
delivery.event.topic,
|
||||
)
|
||||
) {
|
||||
report("unknown-topic");
|
||||
return;
|
||||
}
|
||||
const selectedTopic = delivery.event.topic as QueryInvalidationTopic;
|
||||
|
||||
if (delivery.ordering === "GAP") {
|
||||
for (const topic of dependencies.invalidationIndex.namespacesForTopic.keys()) {
|
||||
pendingRemote.add(topic as QueryInvalidationTopic);
|
||||
}
|
||||
report("sequence-gap");
|
||||
} else {
|
||||
pendingRemote.add(selectedTopic);
|
||||
}
|
||||
if (!resetting) void flushRemote();
|
||||
}
|
||||
|
||||
function flushRemote(): Promise<void> {
|
||||
if (disposed || resetting) return Promise.resolve();
|
||||
if (flushPromise) return flushPromise;
|
||||
const expectedGeneration = lifecycleGeneration;
|
||||
|
||||
flushPromise = Promise.resolve()
|
||||
.then(async () => {
|
||||
while (
|
||||
!disposed &&
|
||||
!resetting &&
|
||||
expectedGeneration === lifecycleGeneration
|
||||
) {
|
||||
const ready = [...pendingRemote].filter(
|
||||
(topic) => (mutationLeases.get(topic) ?? 0) === 0,
|
||||
);
|
||||
if (ready.length === 0) return;
|
||||
for (const topic of ready) {
|
||||
pendingRemote.delete(topic);
|
||||
}
|
||||
await invalidateLocal(ready, expectedGeneration);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
report("remote-flush");
|
||||
})
|
||||
.finally(() => {
|
||||
flushPromise = null;
|
||||
if (
|
||||
!disposed &&
|
||||
!resetting &&
|
||||
[...pendingRemote].some(
|
||||
(topic) => (mutationLeases.get(topic) ?? 0) === 0,
|
||||
)
|
||||
) {
|
||||
void flushRemote();
|
||||
}
|
||||
});
|
||||
return flushPromise;
|
||||
}
|
||||
|
||||
function uniqueTopics(
|
||||
topics: readonly QueryInvalidationTopic[],
|
||||
): readonly QueryInvalidationTopic[] {
|
||||
const unique = [...new Set(topics)];
|
||||
for (const topic of unique) namespacesFor(topic);
|
||||
return unique;
|
||||
}
|
||||
|
||||
function report(operation: string): void {
|
||||
try {
|
||||
dependencies.diagnostics?.record({
|
||||
level: "warn",
|
||||
eventId: "cache.operation.failed",
|
||||
context: {
|
||||
operation,
|
||||
error_kind: "QUERY_CACHE_FAILURE",
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// Cache correctness and cleanup do not depend on diagnostics.
|
||||
}
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
async invalidate(
|
||||
topics: readonly QueryInvalidationTopic[],
|
||||
): Promise<void> {
|
||||
if (disposed) return;
|
||||
const selectedTopics = uniqueTopics(topics);
|
||||
await invalidateLocal(selectedTopics);
|
||||
for (const topic of selectedTopics) {
|
||||
const topicVersion = dependencies.topicVersions.get(topic);
|
||||
if (topicVersion === undefined) {
|
||||
throw new TypeError("Unregistered query invalidation topic.");
|
||||
}
|
||||
const published = dependencies.crossContext?.publish({
|
||||
topic,
|
||||
topicVersion,
|
||||
});
|
||||
if (published && !published.ok) {
|
||||
report("cross-context-publish");
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
beginMutation(
|
||||
topics: readonly QueryInvalidationTopic[],
|
||||
): QueryMutationLease {
|
||||
if (disposed) {
|
||||
throw new TypeError("Query invalidation coordinator is disposed.");
|
||||
}
|
||||
const selectedTopics = uniqueTopics(topics);
|
||||
for (const topic of selectedTopics) {
|
||||
mutationLeases.set(
|
||||
topic,
|
||||
(mutationLeases.get(topic) ?? 0) + 1,
|
||||
);
|
||||
}
|
||||
let released = false;
|
||||
return Object.freeze({
|
||||
async release() {
|
||||
if (released) return;
|
||||
released = true;
|
||||
for (const topic of selectedTopics) {
|
||||
const remaining = (mutationLeases.get(topic) ?? 1) - 1;
|
||||
if (remaining <= 0) {
|
||||
mutationLeases.delete(topic);
|
||||
} else {
|
||||
mutationLeases.set(topic, remaining);
|
||||
}
|
||||
}
|
||||
await flushRemote();
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
async resetLocal() {
|
||||
if (disposed) return;
|
||||
if (resetPromise) return resetPromise;
|
||||
resetting = true;
|
||||
lifecycleGeneration += 1;
|
||||
pendingRemote.clear();
|
||||
mutationLeases.clear();
|
||||
const activeFlush = flushPromise;
|
||||
resetPromise = (async () => {
|
||||
try {
|
||||
await activeFlush;
|
||||
} catch {
|
||||
report("reset-flush");
|
||||
}
|
||||
let cancellationFailed = false;
|
||||
try {
|
||||
await dependencies.queryClient.cancelQueries();
|
||||
} catch {
|
||||
report("reset-cancel");
|
||||
cancellationFailed = true;
|
||||
}
|
||||
dependencies.queryClient.clear();
|
||||
if (cancellationFailed) {
|
||||
throw new TypeError("mandatory query cancellation failed");
|
||||
}
|
||||
})().finally(() => {
|
||||
resetting = false;
|
||||
resetPromise = null;
|
||||
if (
|
||||
!disposed &&
|
||||
[...pendingRemote].some(
|
||||
(topic) => (mutationLeases.get(topic) ?? 0) === 0,
|
||||
)
|
||||
) {
|
||||
void flushRemote();
|
||||
}
|
||||
});
|
||||
return resetPromise;
|
||||
},
|
||||
|
||||
dispose() {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
unsubscribe?.();
|
||||
dependencies.crossContext?.close();
|
||||
pendingRemote.clear();
|
||||
mutationLeases.clear();
|
||||
flushPromise = null;
|
||||
resetPromise = null;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function validateConfiguration(
|
||||
index: InvalidationRegistryIndex,
|
||||
topicVersions: ReadonlyMap<string, number>,
|
||||
): void {
|
||||
if (
|
||||
index.namespacesForTopic.size > INVALIDATION_REGISTRY_BOUNDS.maxTopics ||
|
||||
topicVersions.size !== index.namespacesForTopic.size
|
||||
) {
|
||||
throw new TypeError("Query invalidation registry is invalid.");
|
||||
}
|
||||
for (const [topic, namespaces] of index.namespacesForTopic) {
|
||||
const version = topicVersions.get(topic);
|
||||
if (
|
||||
namespaces.length < 1 ||
|
||||
!Number.isSafeInteger(version) ||
|
||||
(version ?? 0) < 1
|
||||
) {
|
||||
throw new TypeError("Query invalidation registry is invalid.");
|
||||
}
|
||||
const namespaceKeys = new Set<string>();
|
||||
for (const namespace of namespaces) {
|
||||
const key = queryNamespaceIdentityKey(namespace);
|
||||
if (namespaceKeys.has(key)) {
|
||||
throw new TypeError("Query invalidation registry is invalid.");
|
||||
}
|
||||
namespaceKeys.add(key);
|
||||
}
|
||||
}
|
||||
for (const topic of topicVersions.keys()) {
|
||||
if (!index.namespacesForTopic.has(topic)) {
|
||||
throw new TypeError("Query invalidation registry is invalid.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { MutationCache, QueryCache, QueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { createFailure } from "../../contracts/errors.ts";
|
||||
import { safeErrorKind } from "../../contracts/diagnostics.ts";
|
||||
import type { DiagnosticsPort } from "../../application/ports/diagnostics-port.ts";
|
||||
import type { QueryCachePort } from "../../application/ports/query-cache-port.ts";
|
||||
|
||||
export type QueryCacheDependencies = Readonly<{
|
||||
diagnostics?: DiagnosticsPort;
|
||||
}>;
|
||||
|
||||
export const QUERY_CACHE_DEFAULTS = Object.freeze({
|
||||
staleTime: 30_000,
|
||||
gcTime: 300_000,
|
||||
refetchOnWindowFocus: true,
|
||||
retry: false,
|
||||
mutationRetry: false,
|
||||
persistence: false,
|
||||
});
|
||||
|
||||
export function createQueryClient(
|
||||
dependencies: QueryCacheDependencies = {},
|
||||
): QueryClient {
|
||||
function report(operation: string, error: unknown): void {
|
||||
try {
|
||||
dependencies.diagnostics?.record({
|
||||
level: "warn",
|
||||
eventId: "cache.operation.failed",
|
||||
context: {
|
||||
operation,
|
||||
error_kind: safeErrorKind(error),
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// Query behavior remains independent from diagnostics.
|
||||
}
|
||||
}
|
||||
return new QueryClient({
|
||||
queryCache: new QueryCache({
|
||||
onError: (error) => report("query", error),
|
||||
}),
|
||||
mutationCache: new MutationCache({
|
||||
onError: (error) => report("mutation", error),
|
||||
}),
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: QUERY_CACHE_DEFAULTS.staleTime,
|
||||
gcTime: QUERY_CACHE_DEFAULTS.gcTime,
|
||||
refetchOnWindowFocus: QUERY_CACHE_DEFAULTS.refetchOnWindowFocus,
|
||||
retry: QUERY_CACHE_DEFAULTS.retry,
|
||||
},
|
||||
mutations: {
|
||||
retry: QUERY_CACHE_DEFAULTS.mutationRetry,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function createQueryCacheAdapter(
|
||||
queryClient: QueryClient,
|
||||
dependencies: QueryCacheDependencies = {},
|
||||
): QueryCachePort {
|
||||
return Object.freeze({
|
||||
read(key) {
|
||||
try {
|
||||
return { ok: true, value: queryClient.getQueryData(key) };
|
||||
} catch {
|
||||
return cacheFailure("read", key, dependencies.diagnostics);
|
||||
}
|
||||
},
|
||||
write(key, value) {
|
||||
try {
|
||||
queryClient.setQueryData(key, structuredClone(value));
|
||||
return { ok: true };
|
||||
} catch {
|
||||
return cacheFailure("write", key, dependencies.diagnostics);
|
||||
}
|
||||
},
|
||||
async invalidate(namespace) {
|
||||
try {
|
||||
await queryClient.invalidateQueries({ queryKey: namespace, exact: false });
|
||||
return { ok: true };
|
||||
} catch {
|
||||
return cacheFailure("invalidate", namespace, dependencies.diagnostics);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function cacheFailure(
|
||||
phase: string,
|
||||
key: readonly unknown[],
|
||||
diagnostics: DiagnosticsPort | undefined,
|
||||
): Readonly<{ ok: false; error: ReturnType<typeof createFailure> }> {
|
||||
const namespace = typeof key[0] === "string" ? key[0] : "unknown";
|
||||
try {
|
||||
diagnostics?.record({
|
||||
level: "warn",
|
||||
eventId: "cache.operation.failed",
|
||||
context: {
|
||||
operation: phase,
|
||||
error_kind: "QUERY_CACHE_FAILURE",
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// Cache behavior remains independent from diagnostics.
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
error: createFailure("QUERY_CACHE_FAILURE", "QUERY_CACHE", 0, {
|
||||
code: `QUERY_CACHE_${phase.toUpperCase()}_FAILED`,
|
||||
causeClass: `namespace:${namespace}`,
|
||||
}),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user