@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.
Files changed (73) hide show
  1. package/CHANGELOG.md +243 -0
  2. package/dist/adapter/index.d.mts +3 -0
  3. package/dist/adapter/index.mjs +2 -0
  4. package/dist/adapter/types.d.mts +222 -0
  5. package/dist/adapter/widen.d.mts +22 -0
  6. package/dist/adapter/widen.mjs +26 -0
  7. package/dist/aggregate/index.d.mts +3 -0
  8. package/dist/aggregate/index.mjs +3 -0
  9. package/dist/aggregate/keyset.d.mts +57 -0
  10. package/dist/aggregate/keyset.mjs +45 -0
  11. package/dist/aggregate/normalize.d.mts +24 -0
  12. package/dist/aggregate/normalize.mjs +28 -0
  13. package/dist/better-auth/index.d.mts +110 -0
  14. package/dist/better-auth/index.mjs +71 -0
  15. package/dist/cache/engine.d.mts +127 -0
  16. package/dist/cache/engine.mjs +235 -0
  17. package/dist/cache/envelope.mjs +32 -0
  18. package/dist/cache/index.d.mts +7 -2
  19. package/dist/cache/index.mjs +6 -2
  20. package/dist/cache/keys.mjs +131 -0
  21. package/dist/cache/memory-adapter.mjs +41 -7
  22. package/dist/cache/options.d.mts +112 -0
  23. package/dist/cache/options.mjs +25 -0
  24. package/dist/cache/plugin/context.d.mts +18 -0
  25. package/dist/cache/plugin/context.mjs +121 -0
  26. package/dist/cache/plugin/index.d.mts +86 -0
  27. package/dist/cache/plugin/index.mjs +78 -0
  28. package/dist/cache/plugin/invalidation-hooks.mjs +35 -0
  29. package/dist/cache/plugin/read-hooks.mjs +96 -0
  30. package/dist/cache/plugin/swr.mjs +20 -0
  31. package/dist/cache/runtime.d.mts +43 -0
  32. package/dist/cache/runtime.mjs +14 -0
  33. package/dist/cache/tag-index.mjs +84 -0
  34. package/dist/cache/timeout-adapter.d.mts +30 -0
  35. package/dist/cache/timeout-adapter.mjs +58 -0
  36. package/dist/cache/types.d.mts +45 -0
  37. package/dist/cache/version-store.mjs +57 -0
  38. package/dist/errors/index.d.mts +2 -1
  39. package/dist/errors/index.mjs +2 -1
  40. package/dist/errors/schema.d.mts +101 -0
  41. package/dist/errors/schema.mjs +78 -0
  42. package/dist/filter/match.mjs +38 -2
  43. package/dist/pagination/canonical.d.mts +8 -8
  44. package/dist/pagination/canonical.mjs +3 -9
  45. package/dist/pagination/cursor.mjs +4 -1
  46. package/dist/pagination/index.d.mts +2 -2
  47. package/dist/pagination/types.d.mts +17 -27
  48. package/dist/plugins/index.d.mts +2 -0
  49. package/dist/plugins/index.mjs +2 -0
  50. package/dist/plugins/tenant-helpers.d.mts +63 -0
  51. package/dist/plugins/tenant-helpers.mjs +84 -0
  52. package/dist/query-parser/index.d.mts +2 -1
  53. package/dist/query-parser/index.mjs +2 -1
  54. package/dist/query-parser/parse-url.mjs +13 -11
  55. package/dist/query-parser/reserved.d.mts +43 -0
  56. package/dist/query-parser/reserved.mjs +56 -0
  57. package/dist/repository/agg-output.d.mts +63 -0
  58. package/dist/repository/agg-output.mjs +89 -0
  59. package/dist/repository/index.d.mts +4 -2
  60. package/dist/repository/index.mjs +3 -1
  61. package/dist/repository/options.d.mts +62 -0
  62. package/dist/repository/options.mjs +57 -0
  63. package/dist/repository/types.d.mts +935 -48
  64. package/dist/schema/field-rules.d.mts +41 -1
  65. package/dist/schema/field-rules.mjs +92 -1
  66. package/dist/schema/index.d.mts +2 -2
  67. package/dist/schema/index.mjs +2 -2
  68. package/dist/schema/types.d.mts +21 -0
  69. package/dist/testing/conformance.mjs +666 -17
  70. package/dist/testing/index.d.mts +2 -2
  71. package/dist/testing/types.d.mts +99 -2
  72. package/package.json +19 -1
  73. 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 };
@@ -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 };
@@ -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
- export { type DuplicateKeyMeta, ERROR_CODES, type ErrorCode, type ErrorContract, type ErrorDetail, type HttpError, type IsDuplicateKeyErrorFn, type ToDuplicateKeyHttpErrorOptions, type ValidationErrorMeta, conservativeMongoIsDuplicateKey, createError, isHttpError, statusToErrorCode, toDuplicateKeyHttpError, toErrorContract };
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 };
@@ -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
- export { ERROR_CODES, conservativeMongoIsDuplicateKey, createError, isHttpError, statusToErrorCode, toDuplicateKeyHttpError, toErrorContract };
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 };