@classytic/repo-core 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +243 -0
- package/dist/adapter/index.d.mts +3 -0
- package/dist/adapter/index.mjs +2 -0
- package/dist/adapter/types.d.mts +222 -0
- package/dist/adapter/widen.d.mts +22 -0
- package/dist/adapter/widen.mjs +26 -0
- package/dist/aggregate/index.d.mts +3 -0
- package/dist/aggregate/index.mjs +3 -0
- package/dist/aggregate/keyset.d.mts +57 -0
- package/dist/aggregate/keyset.mjs +45 -0
- package/dist/aggregate/normalize.d.mts +24 -0
- package/dist/aggregate/normalize.mjs +28 -0
- package/dist/better-auth/index.d.mts +110 -0
- package/dist/better-auth/index.mjs +71 -0
- package/dist/cache/engine.d.mts +127 -0
- package/dist/cache/engine.mjs +235 -0
- package/dist/cache/envelope.mjs +32 -0
- package/dist/cache/index.d.mts +7 -2
- package/dist/cache/index.mjs +6 -2
- package/dist/cache/keys.mjs +131 -0
- package/dist/cache/memory-adapter.mjs +41 -7
- package/dist/cache/options.d.mts +112 -0
- package/dist/cache/options.mjs +25 -0
- package/dist/cache/plugin/context.d.mts +18 -0
- package/dist/cache/plugin/context.mjs +121 -0
- package/dist/cache/plugin/index.d.mts +86 -0
- package/dist/cache/plugin/index.mjs +78 -0
- package/dist/cache/plugin/invalidation-hooks.mjs +35 -0
- package/dist/cache/plugin/read-hooks.mjs +96 -0
- package/dist/cache/plugin/swr.mjs +20 -0
- package/dist/cache/runtime.d.mts +43 -0
- package/dist/cache/runtime.mjs +14 -0
- package/dist/cache/tag-index.mjs +84 -0
- package/dist/cache/timeout-adapter.d.mts +30 -0
- package/dist/cache/timeout-adapter.mjs +58 -0
- package/dist/cache/types.d.mts +45 -0
- package/dist/cache/version-store.mjs +57 -0
- package/dist/errors/index.d.mts +2 -1
- package/dist/errors/index.mjs +2 -1
- package/dist/errors/schema.d.mts +101 -0
- package/dist/errors/schema.mjs +78 -0
- package/dist/filter/match.mjs +38 -2
- package/dist/pagination/canonical.d.mts +8 -8
- package/dist/pagination/canonical.mjs +3 -9
- package/dist/pagination/cursor.mjs +4 -1
- package/dist/pagination/index.d.mts +2 -2
- package/dist/pagination/types.d.mts +17 -27
- package/dist/plugins/index.d.mts +2 -0
- package/dist/plugins/index.mjs +2 -0
- package/dist/plugins/tenant-helpers.d.mts +63 -0
- package/dist/plugins/tenant-helpers.mjs +84 -0
- package/dist/query-parser/index.d.mts +2 -1
- package/dist/query-parser/index.mjs +2 -1
- package/dist/query-parser/parse-url.mjs +13 -11
- package/dist/query-parser/reserved.d.mts +43 -0
- package/dist/query-parser/reserved.mjs +56 -0
- package/dist/repository/agg-output.d.mts +63 -0
- package/dist/repository/agg-output.mjs +89 -0
- package/dist/repository/index.d.mts +4 -2
- package/dist/repository/index.mjs +3 -1
- package/dist/repository/options.d.mts +62 -0
- package/dist/repository/options.mjs +57 -0
- package/dist/repository/types.d.mts +935 -48
- package/dist/schema/field-rules.d.mts +41 -1
- package/dist/schema/field-rules.mjs +92 -1
- package/dist/schema/index.d.mts +2 -2
- package/dist/schema/index.mjs +2 -2
- package/dist/schema/types.d.mts +21 -0
- package/dist/testing/conformance.mjs +666 -17
- package/dist/testing/index.d.mts +2 -2
- package/dist/testing/types.d.mts +99 -2
- package/package.json +19 -1
- package/dist/cache/stable-stringify.d.mts +0 -15
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { HOOK_PRIORITY } from "../../hooks/priority.mjs";
|
|
2
|
+
import { buildCacheKey, extractScopeTags, mergeTags, scopeKeyFromTags } from "../keys.mjs";
|
|
3
|
+
import { ctx, extractCallCacheOptions, extractShapeFields } from "./context.mjs";
|
|
4
|
+
import { resolveCacheOptions } from "../options.mjs";
|
|
5
|
+
import { scheduleSwrRefresh } from "./swr.mjs";
|
|
6
|
+
//#region src/cache/plugin/read-hooks.ts
|
|
7
|
+
/**
|
|
8
|
+
* Read-side hook registration: `before:<op>` (cache check + single-
|
|
9
|
+
* flight claim), `after:<op>` (cache write + resolve waiters), and
|
|
10
|
+
* `error:<op>` (reject waiters fail-fast).
|
|
11
|
+
*
|
|
12
|
+
* All three hooks coordinate via typed slots on the shared context bag
|
|
13
|
+
* (see `./context.ts`). The kit's read method short-circuits when
|
|
14
|
+
* `_cacheHit === true` via `RepositoryBase._cachedValue<T>(context)`.
|
|
15
|
+
*/
|
|
16
|
+
function registerReadHooks(repo, op, engine, hookCtx) {
|
|
17
|
+
repo.on(`before:${op}`, registerBefore(op, engine, hookCtx), { priority: HOOK_PRIORITY.CACHE });
|
|
18
|
+
repo.on(`after:${op}`, registerAfter(op, engine, hookCtx), { priority: HOOK_PRIORITY.CACHE });
|
|
19
|
+
repo.on(`error:${op}`, registerError(engine), { priority: HOOK_PRIORITY.CACHE });
|
|
20
|
+
}
|
|
21
|
+
function registerBefore(op, engine, hookCtx) {
|
|
22
|
+
return async (rawContext) => {
|
|
23
|
+
const context = ctx(rawContext);
|
|
24
|
+
const resolved = resolveCacheOptions(extractCallCacheOptions(context, op), hookCtx.perOpDefaults, hookCtx.defaults);
|
|
25
|
+
if (!resolved.enabled) return;
|
|
26
|
+
const scopeTags = hookCtx.autoTagsFromScope ? extractScopeTags(context) : [];
|
|
27
|
+
const allTags = mergeTags(resolved.tags, scopeTags);
|
|
28
|
+
const key = resolved.key ?? await deriveKey(engine, op, context, scopeTags, hookCtx);
|
|
29
|
+
context._cacheKey = key;
|
|
30
|
+
context._cacheResolved = {
|
|
31
|
+
...resolved,
|
|
32
|
+
tags: allTags
|
|
33
|
+
};
|
|
34
|
+
const result = await engine.get(key, resolved);
|
|
35
|
+
if (result.status === "fresh" || result.status === "stale") {
|
|
36
|
+
context._cacheHit = true;
|
|
37
|
+
context._cachedResult = result.data;
|
|
38
|
+
context._cacheStatus = result.status;
|
|
39
|
+
if (result.status === "fresh") hookCtx.log.onHit?.(key, op, result.age ?? 0);
|
|
40
|
+
else hookCtx.log.onStale?.(key, op, result.age ?? 0);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
hookCtx.log.onMiss?.(key, op);
|
|
44
|
+
if (resolved.bypass) return;
|
|
45
|
+
const claim = engine.claimPending(key);
|
|
46
|
+
if (claim.status === "wait") try {
|
|
47
|
+
const data = await claim.promise;
|
|
48
|
+
context._cacheHit = true;
|
|
49
|
+
context._cachedResult = data;
|
|
50
|
+
context._cacheStatus = "fresh";
|
|
51
|
+
context._cacheCoalesced = true;
|
|
52
|
+
hookCtx.log.onCoalesce?.(key, op);
|
|
53
|
+
} catch {}
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
function registerAfter(op, engine, hookCtx) {
|
|
57
|
+
return async (rawPayload) => {
|
|
58
|
+
const payload = rawPayload;
|
|
59
|
+
const context = ctx(payload.context);
|
|
60
|
+
if (context._cacheHit === true && context._cacheStatus === "stale") {
|
|
61
|
+
scheduleSwrRefresh(op, hookCtx.repo, context);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (context._cacheHit === true && context._cacheStatus === "fresh") return;
|
|
65
|
+
const key = context._cacheKey;
|
|
66
|
+
const resolved = context._cacheResolved;
|
|
67
|
+
if (!key || !resolved) return;
|
|
68
|
+
await engine.set(key, payload.result, resolved);
|
|
69
|
+
engine.resolvePending(key, payload.result);
|
|
70
|
+
hookCtx.log.onWrite?.(key, op, resolved.tags);
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
function registerError(engine) {
|
|
74
|
+
return async (rawPayload) => {
|
|
75
|
+
const payload = rawPayload;
|
|
76
|
+
const key = ctx(payload.context)._cacheKey;
|
|
77
|
+
if (!key) return;
|
|
78
|
+
engine.rejectPending(key, payload.error);
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
async function deriveKey(engine, op, context, scopeTags, hookCtx) {
|
|
82
|
+
const model = context["model"] ?? "unknown";
|
|
83
|
+
const scopeKey = scopeKeyFromTags(scopeTags);
|
|
84
|
+
const version = await engine.getVersion(model, scopeKey);
|
|
85
|
+
const params = extractShapeFields(context, op, hookCtx.shapeKeysByOp);
|
|
86
|
+
return buildCacheKey({
|
|
87
|
+
prefix: engine.keyPrefix,
|
|
88
|
+
operation: op,
|
|
89
|
+
model,
|
|
90
|
+
version,
|
|
91
|
+
params: params ?? {},
|
|
92
|
+
scopeTags
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
//#endregion
|
|
96
|
+
export { registerReadHooks };
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { AGGREGATE_OPS } from "./context.mjs";
|
|
2
|
+
import { scheduleBackground } from "../runtime.mjs";
|
|
3
|
+
//#region src/cache/plugin/swr.ts
|
|
4
|
+
function scheduleSwrRefresh(op, repo, context) {
|
|
5
|
+
if (!AGGREGATE_OPS.has(op)) return;
|
|
6
|
+
const aggReq = context["aggRequest"];
|
|
7
|
+
if (!aggReq) return;
|
|
8
|
+
const refreshReq = {
|
|
9
|
+
...aggReq,
|
|
10
|
+
cache: {
|
|
11
|
+
...aggReq.cache ?? {},
|
|
12
|
+
bypass: true
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
scheduleBackground(() => {
|
|
16
|
+
repo[op]?.(refreshReq).catch(() => {});
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
//#endregion
|
|
20
|
+
export { scheduleSwrRefresh };
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
//#region src/cache/runtime.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Cross-runtime scheduling primitives.
|
|
4
|
+
*
|
|
5
|
+
* The cache layer targets every JS runtime kits + arc + Express/Nest
|
|
6
|
+
* hosts run on — Node, Bun, Deno Deploy, Cloudflare Workers, edge
|
|
7
|
+
* functions. Most Web-style APIs (`Map`, `Promise`, `setTimeout`,
|
|
8
|
+
* `BigInt`, `queueMicrotask`) are available across all of them, but
|
|
9
|
+
* `setImmediate` is Node-specific — Workers / Deno / browsers throw a
|
|
10
|
+
* `ReferenceError` if you call it.
|
|
11
|
+
*
|
|
12
|
+
* `scheduleBackground` resolves the right primitive at module load and
|
|
13
|
+
* exposes a single API the rest of the cache layer uses.
|
|
14
|
+
*
|
|
15
|
+
* **Semantics:**
|
|
16
|
+
*
|
|
17
|
+
* | Runtime | Mechanism | When the callback fires |
|
|
18
|
+
* | ----------- | -------------------- | ----------------------------------- |
|
|
19
|
+
* | Node / Bun | `setImmediate(fn)` | After current I/O phase |
|
|
20
|
+
* | Workers | `setTimeout(fn, 0)` | After current task (min 0ms) |
|
|
21
|
+
* | Deno Deploy | `setTimeout(fn, 0)` | After current task |
|
|
22
|
+
* | Browser | `setTimeout(fn, 0)` | After current task (clamped 4ms) |
|
|
23
|
+
*
|
|
24
|
+
* Every runtime guarantees the callback runs AFTER the current sync
|
|
25
|
+
* block + any pending microtasks of the current task — which is the
|
|
26
|
+
* actual contract callers rely on (don't run the bg work synchronously
|
|
27
|
+
* in the response path).
|
|
28
|
+
*
|
|
29
|
+
* **Why not `queueMicrotask`?** Microtasks flush BEFORE the current
|
|
30
|
+
* I/O phase completes. For SWR, that means the bg refresh's first
|
|
31
|
+
* `await` could delay the user's HTTP response write. `setImmediate`
|
|
32
|
+
* (or `setTimeout(0)` on edge runtimes) defers past the I/O phase.
|
|
33
|
+
*/
|
|
34
|
+
/**
|
|
35
|
+
* Schedule a fire-and-forget callback to run after the current task.
|
|
36
|
+
*
|
|
37
|
+
* Used by SWR background refresh + (potentially) any future hook that
|
|
38
|
+
* wants "run after response" semantics. Caller is responsible for
|
|
39
|
+
* error handling — this helper does NOT add a default `.catch`.
|
|
40
|
+
*/
|
|
41
|
+
declare const scheduleBackground: (fn: () => void) => void;
|
|
42
|
+
//#endregion
|
|
43
|
+
export { scheduleBackground };
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schedule a fire-and-forget callback to run after the current task.
|
|
3
|
+
*
|
|
4
|
+
* Used by SWR background refresh + (potentially) any future hook that
|
|
5
|
+
* wants "run after response" semantics. Caller is responsible for
|
|
6
|
+
* error handling — this helper does NOT add a default `.catch`.
|
|
7
|
+
*/
|
|
8
|
+
const scheduleBackground = typeof globalThis.setImmediate === "function" ? (fn) => {
|
|
9
|
+
globalThis.setImmediate(fn);
|
|
10
|
+
} : (fn) => {
|
|
11
|
+
setTimeout(fn, 0);
|
|
12
|
+
};
|
|
13
|
+
//#endregion
|
|
14
|
+
export { scheduleBackground };
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { tagIndexKey } from "./keys.mjs";
|
|
2
|
+
//#region src/cache/tag-index.ts
|
|
3
|
+
/**
|
|
4
|
+
* Tag side-index — non-pattern-dependent tag invalidation.
|
|
5
|
+
*
|
|
6
|
+
* Adapters that don't support `clear(pattern)` (most plain KV stores,
|
|
7
|
+
* Upstash REST, in-memory `Map` adapters) can still implement
|
|
8
|
+
* tag-based group invalidation via this side-index: every `set` adds
|
|
9
|
+
* the entry's key to each tag's index list; `invalidateByTags` reads
|
|
10
|
+
* the index, deletes the listed keys, and clears the index.
|
|
11
|
+
*
|
|
12
|
+
* **TTL hygiene.** The index entry's TTL tracks the longest-lived
|
|
13
|
+
* cached entry under that tag. When the data behind every key under
|
|
14
|
+
* a tag has expired, the index naturally evicts too — preventing
|
|
15
|
+
* unbounded growth on hot tags.
|
|
16
|
+
*
|
|
17
|
+
* **Parallelization.** Both `appendKeyToTags` and `invalidateByTags`
|
|
18
|
+
* fan out adapter operations via `Promise.all` — for Redis-backed
|
|
19
|
+
* adapters this means N tags / M keys complete in 1 RTT (pipelined)
|
|
20
|
+
* instead of N+M sequential RTTs. Single-key paths bypass the
|
|
21
|
+
* scheduling overhead.
|
|
22
|
+
*
|
|
23
|
+
* **Non-transactional.** A crash between writing the entry and
|
|
24
|
+
* appending to the index — or between deleting entries and clearing
|
|
25
|
+
* the index — leaves stale references. Stale references resolve to
|
|
26
|
+
* `undefined` on next read (treated as miss); the TTL is the safety
|
|
27
|
+
* net for orphaned index entries.
|
|
28
|
+
*/
|
|
29
|
+
/**
|
|
30
|
+
* Append `cacheKey` to each tag's index in parallel. Uses
|
|
31
|
+
* `adapter.addToSet` when available (Redis SADD, in-memory mutation)
|
|
32
|
+
* for `O(M)` appends; falls back to GET+SET-array otherwise.
|
|
33
|
+
*
|
|
34
|
+
* **Performance note.** The benchmark suite measured `O(N²)` overhead
|
|
35
|
+
* on the GET+SET fallback (178× slower than no-tags writes at hot-tag
|
|
36
|
+
* sizes). The fast path eliminates the per-write array copy entirely.
|
|
37
|
+
*
|
|
38
|
+
* The index is set with a TTL matching the entry's own TTL — when the
|
|
39
|
+
* entry naturally expires, its index reference does too, bounding
|
|
40
|
+
* index growth. Dedups same-key writes (SWR refresh re-writes the
|
|
41
|
+
* same key) so the index doesn't grow unboundedly under SWR.
|
|
42
|
+
*/
|
|
43
|
+
async function appendKeyToTags(adapter, prefix, cacheKey, tags, ttlSeconds) {
|
|
44
|
+
if (tags.length === 0) return;
|
|
45
|
+
const indexTtl = Math.min(Math.max(ttlSeconds, 60), 1440 * 60);
|
|
46
|
+
if (adapter.addToSet) {
|
|
47
|
+
const fn = adapter.addToSet.bind(adapter);
|
|
48
|
+
await Promise.all(tags.map((tag) => fn(tagIndexKey(prefix, tag), [cacheKey], indexTtl)));
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
await Promise.all(tags.map(async (tag) => {
|
|
52
|
+
const idxKey = tagIndexKey(prefix, tag);
|
|
53
|
+
const existing = await adapter.get(idxKey);
|
|
54
|
+
const current = Array.isArray(existing) ? existing : [];
|
|
55
|
+
if (current.includes(cacheKey)) return;
|
|
56
|
+
const next = [...current, cacheKey];
|
|
57
|
+
await adapter.set(idxKey, next, indexTtl);
|
|
58
|
+
}));
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Read each tag's index in parallel, delete every listed cache entry
|
|
62
|
+
* + each index in parallel, and return the count of distinct cache
|
|
63
|
+
* entries removed.
|
|
64
|
+
*
|
|
65
|
+
* For a Redis-backed adapter pipelining these operations, this is
|
|
66
|
+
* effectively 2 RTTs (read-fan-out + delete-fan-out) regardless of
|
|
67
|
+
* tag/entry count — vs N+M sequential RTTs in the prior impl.
|
|
68
|
+
*
|
|
69
|
+
* Returns `0` when no tags were provided or no entries matched.
|
|
70
|
+
*/
|
|
71
|
+
async function invalidateByTags(adapter, prefix, tags) {
|
|
72
|
+
if (tags.length === 0) return 0;
|
|
73
|
+
const indexKeys = tags.map((t) => tagIndexKey(prefix, t));
|
|
74
|
+
const indices = await Promise.all(indexKeys.map((k) => adapter.get(k)));
|
|
75
|
+
const cacheKeys = /* @__PURE__ */ new Set();
|
|
76
|
+
for (const idx of indices) if (Array.isArray(idx)) for (const k of idx) cacheKeys.add(k);
|
|
77
|
+
const deletions = [];
|
|
78
|
+
for (const k of cacheKeys) deletions.push(adapter.delete(k));
|
|
79
|
+
for (const k of indexKeys) deletions.push(adapter.delete(k));
|
|
80
|
+
await Promise.all(deletions);
|
|
81
|
+
return cacheKeys.size;
|
|
82
|
+
}
|
|
83
|
+
//#endregion
|
|
84
|
+
export { appendKeyToTags, invalidateByTags };
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { CacheAdapter } from "./types.mjs";
|
|
2
|
+
|
|
3
|
+
//#region src/cache/timeout-adapter.d.ts
|
|
4
|
+
declare class CacheTimeoutError extends Error {
|
|
5
|
+
readonly op: string;
|
|
6
|
+
readonly key?: string;
|
|
7
|
+
readonly ms: number;
|
|
8
|
+
constructor(op: string, ms: number, key?: string);
|
|
9
|
+
}
|
|
10
|
+
interface TimeoutAdapterOptions {
|
|
11
|
+
/** Per-operation deadline in milliseconds. Default: `250`. */
|
|
12
|
+
ms?: number;
|
|
13
|
+
/**
|
|
14
|
+
* What to do when an operation exceeds `ms`:
|
|
15
|
+
* - `'miss'` — `get` returns `undefined` (cache miss); writes
|
|
16
|
+
* swallow the timeout. Default.
|
|
17
|
+
* - `'throw'` — throw `CacheTimeoutError` on every timeout.
|
|
18
|
+
*/
|
|
19
|
+
onTimeout?: 'miss' | 'throw';
|
|
20
|
+
/** Optional callback fired on every timeout (observability). */
|
|
21
|
+
onSlow?: (op: string, ms: number, key?: string) => void;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Wrap a `CacheAdapter` with per-op timeouts. The returned adapter
|
|
25
|
+
* matches the original's contract (sync-or-async returns); ops that
|
|
26
|
+
* complete before the deadline pass through unchanged.
|
|
27
|
+
*/
|
|
28
|
+
declare function withTimeout(adapter: CacheAdapter, options?: TimeoutAdapterOptions): CacheAdapter;
|
|
29
|
+
//#endregion
|
|
30
|
+
export { CacheTimeoutError, TimeoutAdapterOptions, withTimeout };
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
//#region src/cache/timeout-adapter.ts
|
|
2
|
+
var CacheTimeoutError = class extends Error {
|
|
3
|
+
op;
|
|
4
|
+
key;
|
|
5
|
+
ms;
|
|
6
|
+
constructor(op, ms, key) {
|
|
7
|
+
super(`Cache adapter timed out after ${ms}ms during ${op}${key ? ` for key "${key}"` : ""}`);
|
|
8
|
+
this.name = "CacheTimeoutError";
|
|
9
|
+
this.op = op;
|
|
10
|
+
this.ms = ms;
|
|
11
|
+
if (key !== void 0) this.key = key;
|
|
12
|
+
}
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* Wrap a `CacheAdapter` with per-op timeouts. The returned adapter
|
|
16
|
+
* matches the original's contract (sync-or-async returns); ops that
|
|
17
|
+
* complete before the deadline pass through unchanged.
|
|
18
|
+
*/
|
|
19
|
+
function withTimeout(adapter, options = {}) {
|
|
20
|
+
const ms = options.ms ?? 250;
|
|
21
|
+
const onTimeout = options.onTimeout ?? "miss";
|
|
22
|
+
const onSlow = options.onSlow;
|
|
23
|
+
/** Race a possibly-async value against a timeout. */
|
|
24
|
+
function withDeadline(op, fallback, fn, key) {
|
|
25
|
+
const result = fn();
|
|
26
|
+
if (!(result instanceof Promise)) return result;
|
|
27
|
+
return Promise.race([result, new Promise((resolve, reject) => {
|
|
28
|
+
const timer = setTimeout(() => {
|
|
29
|
+
onSlow?.(op, ms, key);
|
|
30
|
+
if (onTimeout === "throw") reject(new CacheTimeoutError(op, ms, key));
|
|
31
|
+
else resolve(fallback());
|
|
32
|
+
}, ms);
|
|
33
|
+
result.then(() => clearTimeout(timer), () => clearTimeout(timer));
|
|
34
|
+
if (typeof timer === "object" && timer !== null && "unref" in timer) timer.unref?.();
|
|
35
|
+
})]);
|
|
36
|
+
}
|
|
37
|
+
return {
|
|
38
|
+
get(key) {
|
|
39
|
+
return withDeadline("get", () => void 0, () => adapter.get(key), key);
|
|
40
|
+
},
|
|
41
|
+
set(key, value, ttlSeconds) {
|
|
42
|
+
return withDeadline("set", () => void 0, () => adapter.set(key, value, ttlSeconds), key);
|
|
43
|
+
},
|
|
44
|
+
delete(key) {
|
|
45
|
+
return withDeadline("delete", () => void 0, () => adapter.delete(key), key);
|
|
46
|
+
},
|
|
47
|
+
...adapter.clear ? { clear(pattern) {
|
|
48
|
+
const fn = adapter.clear;
|
|
49
|
+
return withDeadline("clear", () => void 0, () => fn.call(adapter, pattern));
|
|
50
|
+
} } : {},
|
|
51
|
+
...adapter.increment ? { increment(key, by, ttlSeconds) {
|
|
52
|
+
const fn = adapter.increment;
|
|
53
|
+
return withDeadline("increment", () => 0, () => fn.call(adapter, key, by, ttlSeconds), key);
|
|
54
|
+
} } : {}
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
//#endregion
|
|
58
|
+
export { CacheTimeoutError, withTimeout };
|
package/dist/cache/types.d.mts
CHANGED
|
@@ -54,6 +54,51 @@ interface CacheAdapter {
|
|
|
54
54
|
* one interface flows across every layer.
|
|
55
55
|
*/
|
|
56
56
|
clear?(pattern?: string): Promise<void> | void;
|
|
57
|
+
/**
|
|
58
|
+
* Atomic add-to-set — append `members` to the set at `key`,
|
|
59
|
+
* creating it when absent. Existing members are no-ops (idempotent).
|
|
60
|
+
* Returns the count of newly-added members.
|
|
61
|
+
*
|
|
62
|
+
* **Why optional.** The cache engine's tag side-index uses this
|
|
63
|
+
* for `O(M)` appends instead of the GET+SET-array fallback (which
|
|
64
|
+
* is `O(N²)` per write — read the whole list, copy, append, write
|
|
65
|
+
* back). At scale (hot tags with thousands of entries) the
|
|
66
|
+
* difference is the 178× slowdown the in-memory benchmark surfaced.
|
|
67
|
+
*
|
|
68
|
+
* **Implementation guidance:**
|
|
69
|
+
* - Redis: `SADD key m1 m2 ...` + `EXPIRE key ttlSeconds NX`
|
|
70
|
+
* - Memory: in-place push on the underlying array (no copy)
|
|
71
|
+
* - DynamoDB: `UpdateItem` with `ADD` action on a String Set
|
|
72
|
+
* - Cloudflare KV / pure GET-SET stores: omit; engine falls back.
|
|
73
|
+
*
|
|
74
|
+
* `ttlSeconds` is applied only when the key is created — existing
|
|
75
|
+
* sets keep their original expiry (Redis NX semantics).
|
|
76
|
+
*/
|
|
77
|
+
addToSet?(key: string, members: readonly string[], ttlSeconds?: number): Promise<number> | number;
|
|
78
|
+
/**
|
|
79
|
+
* Atomic increment — adds `by` (default 1) to the integer at `key`,
|
|
80
|
+
* creating the key with value `by` when absent. Returns the NEW
|
|
81
|
+
* value. `ttlSeconds` is applied only when the key is created (most
|
|
82
|
+
* implementations match Redis SETEX-on-INCR semantics).
|
|
83
|
+
*
|
|
84
|
+
* **Why optional.** Pure KV stores without atomic counters
|
|
85
|
+
* (Cloudflare Workers KV, file-backed stores) can't implement this
|
|
86
|
+
* without external coordination. The cache engine falls back to a
|
|
87
|
+
* `get → max → set` pattern when `increment` is absent, accepting
|
|
88
|
+
* the (rare, multi-pod) race condition where two simultaneous bumps
|
|
89
|
+
* collide and only one increment records.
|
|
90
|
+
*
|
|
91
|
+
* **Implementation guidance:**
|
|
92
|
+
* - Redis: `INCRBY key by` + `EXPIRE key ttlSeconds NX`
|
|
93
|
+
* (NX so existing TTLs aren't reset on every increment)
|
|
94
|
+
* - Memory: synchronous via JS single-thread guarantee
|
|
95
|
+
* - DynamoDB / Mongo `$inc`: native atomic update
|
|
96
|
+
*
|
|
97
|
+
* Used by the cache engine's per-scope `bumpModelVersion` to ensure
|
|
98
|
+
* concurrent writes to the same model never lose bumps — every
|
|
99
|
+
* write is reflected in the next read's cache key.
|
|
100
|
+
*/
|
|
101
|
+
increment?(key: string, by?: number, ttlSeconds?: number): Promise<number> | number;
|
|
57
102
|
}
|
|
58
103
|
//#endregion
|
|
59
104
|
export { CacheAdapter };
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { versionKey } from "./keys.mjs";
|
|
2
|
+
//#region src/cache/version-store.ts
|
|
3
|
+
/**
|
|
4
|
+
* Collection-version store — O(1) bulk invalidation by bumping a
|
|
5
|
+
* counter that's embedded in every cache key.
|
|
6
|
+
*
|
|
7
|
+
* **Per-scope sharding** (TanStack-style targeted invalidation).
|
|
8
|
+
* Without scope, a write to the model bumps ONE counter and orphans
|
|
9
|
+
* every cached read for that model — including OTHER tenants' caches.
|
|
10
|
+
* With a `scopeKey` (e.g. `'org:abc'`), the version key becomes
|
|
11
|
+
* `<prefix>:ver:<model>:<scopeKey>` so the write only invalidates the
|
|
12
|
+
* writing tenant's cache.
|
|
13
|
+
*
|
|
14
|
+
* **Atomic-when-supported.** The adapter MAY ship `increment(key, by,
|
|
15
|
+
* ttl)` for atomic counter bumps. When present, concurrent writes
|
|
16
|
+
* from multiple pods produce strictly-monotonic versions (no lost
|
|
17
|
+
* bumps). When absent, falls back to `get → max → set` — correct in
|
|
18
|
+
* single-pod, racy in multi-pod (rare bump-loss; mitigated by the
|
|
19
|
+
* `Date.now()` floor below).
|
|
20
|
+
*
|
|
21
|
+
* **Strict monotonicity** — fallback path uses
|
|
22
|
+
* `max(Date.now(), previous + 1)` so same-millisecond writes still
|
|
23
|
+
* advance the counter (atomic path is naturally monotonic via
|
|
24
|
+
* adapter.increment).
|
|
25
|
+
*/
|
|
26
|
+
const VERSION_TTL_SECONDS = 1440 * 60;
|
|
27
|
+
/**
|
|
28
|
+
* Read the current version for a `model` (optionally per-scope). Returns
|
|
29
|
+
* `0` when no version has been set yet — the initial value before any
|
|
30
|
+
* writes have hit.
|
|
31
|
+
*/
|
|
32
|
+
async function getModelVersion(adapter, prefix, model, scopeKey) {
|
|
33
|
+
const value = await adapter.get(versionKey(prefix, model, scopeKey));
|
|
34
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
35
|
+
return 0;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Bump the model's version (per-scope when `scopeKey` is supplied) to
|
|
39
|
+
* a strictly-monotonic value. Returns the new value.
|
|
40
|
+
*
|
|
41
|
+
* Atomic when the adapter supports `increment` — concurrent multi-pod
|
|
42
|
+
* writes produce distinct versions. Falls back to `get → max → set`
|
|
43
|
+
* otherwise; under concurrent multi-pod load, two writes may collide
|
|
44
|
+
* on the same `previous` and one bump is lost — accepted trade-off
|
|
45
|
+
* for adapters without atomic counters (Cloudflare KV, etc.).
|
|
46
|
+
*/
|
|
47
|
+
async function bumpModelVersion(adapter, prefix, model, scopeKey) {
|
|
48
|
+
const key = versionKey(prefix, model, scopeKey);
|
|
49
|
+
if (adapter.increment) return await adapter.increment(key, 1, VERSION_TTL_SECONDS);
|
|
50
|
+
const previous = await adapter.get(key);
|
|
51
|
+
const previousNum = typeof previous === "number" && Number.isFinite(previous) ? previous : 0;
|
|
52
|
+
const next = Math.max(Date.now(), previousNum + 1);
|
|
53
|
+
await adapter.set(key, next, VERSION_TTL_SECONDS);
|
|
54
|
+
return next;
|
|
55
|
+
}
|
|
56
|
+
//#endregion
|
|
57
|
+
export { bumpModelVersion, getModelVersion };
|
package/dist/errors/index.d.mts
CHANGED
|
@@ -2,4 +2,5 @@ import { DuplicateKeyMeta, ERROR_CODES, ErrorCode, ErrorContract, ErrorDetail, H
|
|
|
2
2
|
import { statusToErrorCode, toErrorContract } from "./contract.mjs";
|
|
3
3
|
import { createError, isHttpError } from "./create-error.mjs";
|
|
4
4
|
import { IsDuplicateKeyErrorFn, ToDuplicateKeyHttpErrorOptions, conservativeMongoIsDuplicateKey, toDuplicateKeyHttpError } from "./duplicate-key.mjs";
|
|
5
|
-
|
|
5
|
+
import { errorContractSchema, errorDetailSchema } from "./schema.mjs";
|
|
6
|
+
export { type DuplicateKeyMeta, ERROR_CODES, type ErrorCode, type ErrorContract, type ErrorDetail, type HttpError, type IsDuplicateKeyErrorFn, type ToDuplicateKeyHttpErrorOptions, type ValidationErrorMeta, conservativeMongoIsDuplicateKey, createError, errorContractSchema, errorDetailSchema, isHttpError, statusToErrorCode, toDuplicateKeyHttpError, toErrorContract };
|
package/dist/errors/index.mjs
CHANGED
|
@@ -2,4 +2,5 @@ import { ERROR_CODES } from "./types.mjs";
|
|
|
2
2
|
import { statusToErrorCode, toErrorContract } from "./contract.mjs";
|
|
3
3
|
import { createError, isHttpError } from "./create-error.mjs";
|
|
4
4
|
import { conservativeMongoIsDuplicateKey, toDuplicateKeyHttpError } from "./duplicate-key.mjs";
|
|
5
|
-
|
|
5
|
+
import { errorContractSchema, errorDetailSchema } from "./schema.mjs";
|
|
6
|
+
export { ERROR_CODES, conservativeMongoIsDuplicateKey, createError, errorContractSchema, errorDetailSchema, isHttpError, statusToErrorCode, toDuplicateKeyHttpError, toErrorContract };
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
//#region src/errors/schema.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Canonical JSON Schema constants for `ErrorContract` + `ErrorDetail`.
|
|
4
|
+
*
|
|
5
|
+
* The runtime wire spec for the `ErrorContract` and `ErrorDetail`
|
|
6
|
+
* TypeScript interfaces in `./types.ts`. Lives next to the interface so
|
|
7
|
+
* downstream packages (arc, every kit, host apps) consume from one place
|
|
8
|
+
* and the JSON Schema cannot drift away from the TS shape.
|
|
9
|
+
*
|
|
10
|
+
* Plain JSON-Schema objects — no runtime dependency on AJV, TypeBox,
|
|
11
|
+
* Zod, or any validator. Anything that consumes JSON Schema can use
|
|
12
|
+
* these (Fastify schema validator, OpenAPI generators, AJV directly,
|
|
13
|
+
* `Type.Unsafe<ErrorContract>(errorContractSchema)` for TypeBox).
|
|
14
|
+
*/
|
|
15
|
+
/**
|
|
16
|
+
* Single field-scoped error detail. Mirrors `ErrorDetail` in
|
|
17
|
+
* {@link ./types.ts}.
|
|
18
|
+
*/
|
|
19
|
+
declare const errorDetailSchema: {
|
|
20
|
+
readonly type: "object";
|
|
21
|
+
readonly properties: {
|
|
22
|
+
readonly path: {
|
|
23
|
+
readonly type: "string";
|
|
24
|
+
readonly description: "Dot-path to the offending field, e.g. 'lines.0.quantity'.";
|
|
25
|
+
};
|
|
26
|
+
readonly code: {
|
|
27
|
+
readonly type: "string";
|
|
28
|
+
};
|
|
29
|
+
readonly message: {
|
|
30
|
+
readonly type: "string";
|
|
31
|
+
};
|
|
32
|
+
readonly meta: {
|
|
33
|
+
readonly type: "object";
|
|
34
|
+
readonly description: "Non-PII per-detail diagnostics (safe to log + return).";
|
|
35
|
+
};
|
|
36
|
+
};
|
|
37
|
+
readonly required: readonly ["code", "message"];
|
|
38
|
+
};
|
|
39
|
+
/**
|
|
40
|
+
* Canonical error response. Mirrors `ErrorContract` in {@link ./types.ts}.
|
|
41
|
+
*
|
|
42
|
+
* `code` and `message` are the only required fields. `status` is a
|
|
43
|
+
* *suggested* HTTP status hosts may override at the edge. `details` is
|
|
44
|
+
* an array of structured `ErrorDetail` objects (validation failures,
|
|
45
|
+
* duplicate-key surfaces, etc.). `meta` is a non-PII object.
|
|
46
|
+
*
|
|
47
|
+
* Wire shape every 4xx/5xx response in the org follows. Errors live on
|
|
48
|
+
* a separate path from success — HTTP status discriminates.
|
|
49
|
+
*/
|
|
50
|
+
declare const errorContractSchema: {
|
|
51
|
+
readonly type: "object";
|
|
52
|
+
readonly properties: {
|
|
53
|
+
readonly code: {
|
|
54
|
+
readonly type: "string";
|
|
55
|
+
readonly description: "Hierarchical machine-readable code (e.g. 'arc.not_found').";
|
|
56
|
+
};
|
|
57
|
+
readonly message: {
|
|
58
|
+
readonly type: "string";
|
|
59
|
+
readonly description: "Human-readable, safe-for-client message.";
|
|
60
|
+
};
|
|
61
|
+
readonly status: {
|
|
62
|
+
readonly type: "integer";
|
|
63
|
+
readonly description: "Suggested HTTP status code (hosts may override).";
|
|
64
|
+
};
|
|
65
|
+
readonly details: {
|
|
66
|
+
readonly type: "array";
|
|
67
|
+
readonly description: "Field-scoped structured details (validation failures, duplicate keys, multi-code domain errors).";
|
|
68
|
+
readonly items: {
|
|
69
|
+
readonly type: "object";
|
|
70
|
+
readonly properties: {
|
|
71
|
+
readonly path: {
|
|
72
|
+
readonly type: "string";
|
|
73
|
+
readonly description: "Dot-path to the offending field, e.g. 'lines.0.quantity'.";
|
|
74
|
+
};
|
|
75
|
+
readonly code: {
|
|
76
|
+
readonly type: "string";
|
|
77
|
+
};
|
|
78
|
+
readonly message: {
|
|
79
|
+
readonly type: "string";
|
|
80
|
+
};
|
|
81
|
+
readonly meta: {
|
|
82
|
+
readonly type: "object";
|
|
83
|
+
readonly description: "Non-PII per-detail diagnostics (safe to log + return).";
|
|
84
|
+
};
|
|
85
|
+
};
|
|
86
|
+
readonly required: readonly ["code", "message"];
|
|
87
|
+
};
|
|
88
|
+
};
|
|
89
|
+
readonly correlationId: {
|
|
90
|
+
readonly type: "string";
|
|
91
|
+
readonly description: "Request id for support lookups.";
|
|
92
|
+
};
|
|
93
|
+
readonly meta: {
|
|
94
|
+
readonly type: "object";
|
|
95
|
+
readonly description: "Non-PII diagnostics (safe to log + return).";
|
|
96
|
+
};
|
|
97
|
+
};
|
|
98
|
+
readonly required: readonly ["code", "message"];
|
|
99
|
+
};
|
|
100
|
+
//#endregion
|
|
101
|
+
export { errorContractSchema, errorDetailSchema };
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
//#region src/errors/schema.ts
|
|
2
|
+
/**
|
|
3
|
+
* Canonical JSON Schema constants for `ErrorContract` + `ErrorDetail`.
|
|
4
|
+
*
|
|
5
|
+
* The runtime wire spec for the `ErrorContract` and `ErrorDetail`
|
|
6
|
+
* TypeScript interfaces in `./types.ts`. Lives next to the interface so
|
|
7
|
+
* downstream packages (arc, every kit, host apps) consume from one place
|
|
8
|
+
* and the JSON Schema cannot drift away from the TS shape.
|
|
9
|
+
*
|
|
10
|
+
* Plain JSON-Schema objects — no runtime dependency on AJV, TypeBox,
|
|
11
|
+
* Zod, or any validator. Anything that consumes JSON Schema can use
|
|
12
|
+
* these (Fastify schema validator, OpenAPI generators, AJV directly,
|
|
13
|
+
* `Type.Unsafe<ErrorContract>(errorContractSchema)` for TypeBox).
|
|
14
|
+
*/
|
|
15
|
+
/**
|
|
16
|
+
* Single field-scoped error detail. Mirrors `ErrorDetail` in
|
|
17
|
+
* {@link ./types.ts}.
|
|
18
|
+
*/
|
|
19
|
+
const errorDetailSchema = {
|
|
20
|
+
type: "object",
|
|
21
|
+
properties: {
|
|
22
|
+
path: {
|
|
23
|
+
type: "string",
|
|
24
|
+
description: "Dot-path to the offending field, e.g. 'lines.0.quantity'."
|
|
25
|
+
},
|
|
26
|
+
code: { type: "string" },
|
|
27
|
+
message: { type: "string" },
|
|
28
|
+
meta: {
|
|
29
|
+
type: "object",
|
|
30
|
+
description: "Non-PII per-detail diagnostics (safe to log + return)."
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
required: ["code", "message"]
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* Canonical error response. Mirrors `ErrorContract` in {@link ./types.ts}.
|
|
37
|
+
*
|
|
38
|
+
* `code` and `message` are the only required fields. `status` is a
|
|
39
|
+
* *suggested* HTTP status hosts may override at the edge. `details` is
|
|
40
|
+
* an array of structured `ErrorDetail` objects (validation failures,
|
|
41
|
+
* duplicate-key surfaces, etc.). `meta` is a non-PII object.
|
|
42
|
+
*
|
|
43
|
+
* Wire shape every 4xx/5xx response in the org follows. Errors live on
|
|
44
|
+
* a separate path from success — HTTP status discriminates.
|
|
45
|
+
*/
|
|
46
|
+
const errorContractSchema = {
|
|
47
|
+
type: "object",
|
|
48
|
+
properties: {
|
|
49
|
+
code: {
|
|
50
|
+
type: "string",
|
|
51
|
+
description: "Hierarchical machine-readable code (e.g. 'arc.not_found')."
|
|
52
|
+
},
|
|
53
|
+
message: {
|
|
54
|
+
type: "string",
|
|
55
|
+
description: "Human-readable, safe-for-client message."
|
|
56
|
+
},
|
|
57
|
+
status: {
|
|
58
|
+
type: "integer",
|
|
59
|
+
description: "Suggested HTTP status code (hosts may override)."
|
|
60
|
+
},
|
|
61
|
+
details: {
|
|
62
|
+
type: "array",
|
|
63
|
+
description: "Field-scoped structured details (validation failures, duplicate keys, multi-code domain errors).",
|
|
64
|
+
items: errorDetailSchema
|
|
65
|
+
},
|
|
66
|
+
correlationId: {
|
|
67
|
+
type: "string",
|
|
68
|
+
description: "Request id for support lookups."
|
|
69
|
+
},
|
|
70
|
+
meta: {
|
|
71
|
+
type: "object",
|
|
72
|
+
description: "Non-PII diagnostics (safe to log + return)."
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
required: ["code", "message"]
|
|
76
|
+
};
|
|
77
|
+
//#endregion
|
|
78
|
+
export { errorContractSchema, errorDetailSchema };
|