316 lines
9.1 KiB
TypeScript
316 lines
9.1 KiB
TypeScript
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
|
|
| number
|
|
| string
|
|
| readonly CanonicalValue[]
|
|
| Readonly<{ [key: string]: CanonicalValue }>;
|
|
|
|
const DEFAULT_LIMITS = Object.freeze({
|
|
maxDepth: 12,
|
|
maxNodes: 512,
|
|
maxStringBytes: 2_048,
|
|
maxEncodedBytes: 16_384,
|
|
});
|
|
|
|
export function canonicalize(value: unknown): CanonicalValue {
|
|
const seen = new WeakSet<object>();
|
|
let nodes = 0;
|
|
const encoder = new TextEncoder();
|
|
|
|
function visit(candidate: unknown, depth: number): CanonicalValue {
|
|
nodes += 1;
|
|
if (nodes > DEFAULT_LIMITS.maxNodes || depth > DEFAULT_LIMITS.maxDepth) {
|
|
throw new TypeError("Query identity exceeds its structural budget.");
|
|
}
|
|
if (
|
|
candidate === null ||
|
|
typeof candidate === "boolean" ||
|
|
(typeof candidate === "number" &&
|
|
Number.isFinite(candidate) &&
|
|
!Object.is(candidate, -0))
|
|
) {
|
|
return candidate;
|
|
}
|
|
if (typeof candidate === "string") {
|
|
if (encoder.encode(candidate).byteLength > DEFAULT_LIMITS.maxStringBytes) {
|
|
throw new TypeError("Query identity string exceeds its byte budget.");
|
|
}
|
|
return candidate;
|
|
}
|
|
if (!candidate || typeof candidate !== "object") {
|
|
throw new TypeError("Query identity contains a non-canonical value.");
|
|
}
|
|
if (seen.has(candidate)) {
|
|
throw new TypeError("Query identity contains a cycle or shared reference.");
|
|
}
|
|
seen.add(candidate);
|
|
if (Array.isArray(candidate)) {
|
|
for (let index = 0; index < candidate.length; index += 1) {
|
|
if (!Object.hasOwn(candidate, index)) {
|
|
throw new TypeError("Query identity contains a sparse array.");
|
|
}
|
|
}
|
|
return Object.freeze(candidate.map((item) => visit(item, depth + 1)));
|
|
}
|
|
const prototype = Object.getPrototypeOf(candidate);
|
|
if (prototype !== Object.prototype && prototype !== null) {
|
|
throw new TypeError("Query identity requires plain objects.");
|
|
}
|
|
const descriptors = Object.getOwnPropertyDescriptors(candidate);
|
|
const output: Record<string, CanonicalValue> = Object.create(null);
|
|
for (const key of Object.keys(descriptors).sort()) {
|
|
if (key === "__proto__" || key === "prototype" || key === "constructor") {
|
|
throw new TypeError("Query identity contains a forbidden key.");
|
|
}
|
|
const descriptor = descriptors[key];
|
|
if (!descriptor || !("value" in descriptor)) {
|
|
throw new TypeError("Query identity contains an accessor.");
|
|
}
|
|
output[key] = visit(descriptor.value, depth + 1);
|
|
}
|
|
return Object.freeze(output);
|
|
}
|
|
|
|
const result = visit(value, 0);
|
|
if (encoder.encode(JSON.stringify(result)).byteLength > DEFAULT_LIMITS.maxEncodedBytes) {
|
|
throw new TypeError("Query identity exceeds its encoded byte budget.");
|
|
}
|
|
return result;
|
|
}
|
|
|
|
export type RuntimeIdentityBinding = Readonly<{
|
|
token: string;
|
|
acquire(): void;
|
|
release(): void;
|
|
}>;
|
|
|
|
export type RuntimeIdentityRegistry = Readonly<{
|
|
intern(value: unknown): RuntimeIdentityBinding;
|
|
close(): void;
|
|
inspect(): Readonly<{
|
|
entries: number;
|
|
canonicalBytes: number;
|
|
activeLeases: number;
|
|
closed: boolean;
|
|
}>;
|
|
}>;
|
|
|
|
type IdentityRow = {
|
|
canonical: string;
|
|
canonicalBytes: number;
|
|
token: string;
|
|
refCount: number;
|
|
touched: number;
|
|
};
|
|
|
|
export function createRuntimeIdentityRegistry(
|
|
options: Readonly<{
|
|
maxEntries?: number;
|
|
maxCanonicalBytes?: number;
|
|
tokenFactory?: () => string;
|
|
}> = {},
|
|
): RuntimeIdentityRegistry {
|
|
const maxEntries = options.maxEntries ?? 4_096;
|
|
const maxCanonicalBytes = options.maxCanonicalBytes ?? 4 * 1024 * 1024;
|
|
const tokenFactory =
|
|
options.tokenFactory ??
|
|
(() => {
|
|
if (
|
|
typeof crypto === "undefined" ||
|
|
typeof crypto.randomUUID !== "function"
|
|
) {
|
|
throw new TypeError("Secure runtime identity generation is unavailable.");
|
|
}
|
|
return crypto.randomUUID();
|
|
});
|
|
const byCanonical = new Map<string, IdentityRow>();
|
|
const byToken = new Map<string, IdentityRow>();
|
|
let totalCanonicalBytes = 0;
|
|
let sequence = 0;
|
|
let closed = false;
|
|
|
|
function evictAvailable(requiredBytes: number): void {
|
|
const candidates = [...byCanonical.values()]
|
|
.filter((row) => row.refCount === 0)
|
|
.sort((left, right) => left.touched - right.touched);
|
|
for (const row of candidates) {
|
|
if (
|
|
byCanonical.size < maxEntries &&
|
|
totalCanonicalBytes + requiredBytes <= maxCanonicalBytes
|
|
) {
|
|
return;
|
|
}
|
|
byCanonical.delete(row.canonical);
|
|
byToken.delete(row.token);
|
|
totalCanonicalBytes -= row.canonicalBytes;
|
|
}
|
|
}
|
|
|
|
return Object.freeze({
|
|
intern(value): RuntimeIdentityBinding {
|
|
if (closed) throw new TypeError("Runtime identity registry is closed.");
|
|
const canonical = JSON.stringify(canonicalize(value));
|
|
const canonicalBytes = new TextEncoder().encode(canonical).byteLength;
|
|
let row = byCanonical.get(canonical);
|
|
if (!row) {
|
|
evictAvailable(canonicalBytes);
|
|
if (
|
|
byCanonical.size >= maxEntries ||
|
|
totalCanonicalBytes + canonicalBytes > maxCanonicalBytes
|
|
) {
|
|
throw new TypeError("Runtime identity capacity exceeded.");
|
|
}
|
|
let token = "";
|
|
for (let attempt = 0; attempt < 8; attempt += 1) {
|
|
const candidate = tokenFactory();
|
|
if (
|
|
/^[A-Za-z0-9._:-]{16,128}$/.test(candidate) &&
|
|
!byToken.has(candidate)
|
|
) {
|
|
token = candidate;
|
|
break;
|
|
}
|
|
}
|
|
if (!token) {
|
|
throw new TypeError("Runtime identity token collision.");
|
|
}
|
|
row = {
|
|
canonical,
|
|
canonicalBytes,
|
|
token,
|
|
refCount: 0,
|
|
touched: sequence++,
|
|
};
|
|
byCanonical.set(canonical, row);
|
|
byToken.set(token, row);
|
|
totalCanonicalBytes += canonicalBytes;
|
|
}
|
|
row.touched = sequence++;
|
|
let leaseCount = 0;
|
|
return Object.freeze({
|
|
token: row.token,
|
|
acquire() {
|
|
if (closed) return;
|
|
leaseCount += 1;
|
|
row.refCount += 1;
|
|
row.touched = sequence++;
|
|
},
|
|
release() {
|
|
if (leaseCount === 0) return;
|
|
leaseCount -= 1;
|
|
row.refCount = Math.max(0, row.refCount - 1);
|
|
row.touched = sequence++;
|
|
},
|
|
});
|
|
},
|
|
close() {
|
|
closed = true;
|
|
byCanonical.clear();
|
|
byToken.clear();
|
|
totalCanonicalBytes = 0;
|
|
},
|
|
inspect() {
|
|
return Object.freeze({
|
|
entries: byCanonical.size,
|
|
canonicalBytes: totalCanonicalBytes,
|
|
activeLeases: [...byCanonical.values()].reduce(
|
|
(total, row) => total + row.refCount,
|
|
0,
|
|
),
|
|
closed,
|
|
});
|
|
},
|
|
});
|
|
}
|
|
|
|
const defaultIdentityRegistry = createRuntimeIdentityRegistry();
|
|
|
|
export function runtimeIdentityToken(value: unknown): string {
|
|
return defaultIdentityRegistry.intern(value).token;
|
|
}
|