@classytic/repo-core 0.2.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 (87) hide show
  1. package/CHANGELOG.md +363 -0
  2. package/README.md +28 -7
  3. package/dist/adapter/index.d.mts +3 -0
  4. package/dist/adapter/index.mjs +2 -0
  5. package/dist/adapter/types.d.mts +222 -0
  6. package/dist/adapter/widen.d.mts +22 -0
  7. package/dist/adapter/widen.mjs +26 -0
  8. package/dist/aggregate/index.d.mts +3 -0
  9. package/dist/aggregate/index.mjs +3 -0
  10. package/dist/aggregate/keyset.d.mts +57 -0
  11. package/dist/aggregate/keyset.mjs +45 -0
  12. package/dist/aggregate/normalize.d.mts +24 -0
  13. package/dist/aggregate/normalize.mjs +28 -0
  14. package/dist/better-auth/index.d.mts +110 -0
  15. package/dist/better-auth/index.mjs +71 -0
  16. package/dist/cache/engine.d.mts +127 -0
  17. package/dist/cache/engine.mjs +235 -0
  18. package/dist/cache/envelope.mjs +32 -0
  19. package/dist/cache/index.d.mts +7 -2
  20. package/dist/cache/index.mjs +6 -2
  21. package/dist/cache/keys.mjs +131 -0
  22. package/dist/cache/memory-adapter.mjs +41 -7
  23. package/dist/cache/options.d.mts +112 -0
  24. package/dist/cache/options.mjs +25 -0
  25. package/dist/cache/plugin/context.d.mts +18 -0
  26. package/dist/cache/plugin/context.mjs +121 -0
  27. package/dist/cache/plugin/index.d.mts +86 -0
  28. package/dist/cache/plugin/index.mjs +78 -0
  29. package/dist/cache/plugin/invalidation-hooks.mjs +35 -0
  30. package/dist/cache/plugin/read-hooks.mjs +96 -0
  31. package/dist/cache/plugin/swr.mjs +20 -0
  32. package/dist/cache/runtime.d.mts +43 -0
  33. package/dist/cache/runtime.mjs +14 -0
  34. package/dist/cache/tag-index.mjs +84 -0
  35. package/dist/cache/timeout-adapter.d.mts +30 -0
  36. package/dist/cache/timeout-adapter.mjs +58 -0
  37. package/dist/cache/types.d.mts +45 -0
  38. package/dist/cache/version-store.mjs +57 -0
  39. package/dist/errors/contract.d.mts +37 -0
  40. package/dist/errors/contract.mjs +75 -0
  41. package/dist/errors/index.d.mts +4 -2
  42. package/dist/errors/index.mjs +4 -1
  43. package/dist/errors/schema.d.mts +101 -0
  44. package/dist/errors/schema.mjs +78 -0
  45. package/dist/errors/types.d.mts +113 -8
  46. package/dist/errors/types.mjs +29 -0
  47. package/dist/filter/match.mjs +38 -2
  48. package/dist/pagination/canonical.d.mts +35 -0
  49. package/dist/pagination/canonical.mjs +26 -0
  50. package/dist/pagination/cursor.mjs +4 -1
  51. package/dist/pagination/index.d.mts +3 -2
  52. package/dist/pagination/index.mjs +2 -1
  53. package/dist/pagination/types.d.mts +57 -3
  54. package/dist/plugins/index.d.mts +2 -0
  55. package/dist/plugins/index.mjs +2 -0
  56. package/dist/plugins/tenant-helpers.d.mts +63 -0
  57. package/dist/plugins/tenant-helpers.mjs +84 -0
  58. package/dist/query-parser/index.d.mts +2 -1
  59. package/dist/query-parser/index.mjs +2 -1
  60. package/dist/query-parser/parse-url.mjs +13 -11
  61. package/dist/query-parser/reserved.d.mts +43 -0
  62. package/dist/query-parser/reserved.mjs +56 -0
  63. package/dist/repository/agg-output.d.mts +63 -0
  64. package/dist/repository/agg-output.mjs +89 -0
  65. package/dist/repository/base.mjs +21 -0
  66. package/dist/repository/index.d.mts +4 -2
  67. package/dist/repository/index.mjs +3 -1
  68. package/dist/repository/options.d.mts +62 -0
  69. package/dist/repository/options.mjs +57 -0
  70. package/dist/repository/types.d.mts +935 -48
  71. package/dist/schema/field-rules.d.mts +60 -9
  72. package/dist/schema/field-rules.mjs +121 -10
  73. package/dist/schema/generator.d.mts +72 -0
  74. package/dist/schema/generator.mjs +16 -0
  75. package/dist/schema/index.d.mts +3 -2
  76. package/dist/schema/index.mjs +3 -2
  77. package/dist/schema/types.d.mts +77 -3
  78. package/dist/tenant/index.d.mts +3 -0
  79. package/dist/tenant/index.mjs +2 -0
  80. package/dist/tenant/resolve.d.mts +27 -0
  81. package/dist/tenant/resolve.mjs +69 -0
  82. package/dist/tenant/types.d.mts +142 -0
  83. package/dist/testing/conformance.mjs +666 -17
  84. package/dist/testing/index.d.mts +2 -2
  85. package/dist/testing/types.d.mts +99 -2
  86. package/package.json +27 -1
  87. package/dist/cache/stable-stringify.d.mts +0 -15
@@ -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 };
@@ -0,0 +1,37 @@
1
+ import { ErrorCode, ErrorContract } from "./types.mjs";
2
+
3
+ //#region src/errors/contract.d.ts
4
+ /**
5
+ * Map an HTTP status code to the canonical {@link ErrorCode}. Used as a
6
+ * fallback when a thrown `HttpError` lacks an explicit `code` field.
7
+ *
8
+ * The mapping is conservative: only well-known status codes get a
9
+ * canonical code; unmapped statuses (406, 422, 410, ...) flow through as
10
+ * `'internal_error'` because there's no portable HTTP-Spec mapping
11
+ * everyone agrees on. Domain handlers should set `code` explicitly for
12
+ * any non-default status.
13
+ */
14
+ declare function statusToErrorCode(status: number): ErrorCode;
15
+ /**
16
+ * Convert a throwable {@link HttpError} (or any `Error` with a `status`
17
+ * field) into the canonical {@link ErrorContract} wire shape.
18
+ *
19
+ * `code` cascade:
20
+ * 1. `error.code` (explicit machine code on the throwable) — preferred.
21
+ * 2. {@link statusToErrorCode}(`error.status`) — derived from status.
22
+ * 3. `'internal_error'` — fallback for plain `Error` without status.
23
+ *
24
+ * `validationErrors` (mongokit-shaped throwable field) is mapped into the
25
+ * canonical `details` array so wire consumers see one shape regardless
26
+ * of which kit threw the error. Each `validationErrors[i]` becomes an
27
+ * `ErrorDetail` with `code: validator`, `message: error`, `path` left
28
+ * unset (kits that have field paths set them in their own ErrorDetail
29
+ * mapping).
30
+ *
31
+ * `duplicate.fields` is similarly flattened into `details` with the
32
+ * duplicate-key code so unique-constraint failures look uniform on the
33
+ * wire.
34
+ */
35
+ declare function toErrorContract(error: unknown): ErrorContract;
36
+ //#endregion
37
+ export { statusToErrorCode, toErrorContract };
@@ -0,0 +1,75 @@
1
+ import { ERROR_CODES } from "./types.mjs";
2
+ //#region src/errors/contract.ts
3
+ /**
4
+ * Map an HTTP status code to the canonical {@link ErrorCode}. Used as a
5
+ * fallback when a thrown `HttpError` lacks an explicit `code` field.
6
+ *
7
+ * The mapping is conservative: only well-known status codes get a
8
+ * canonical code; unmapped statuses (406, 422, 410, ...) flow through as
9
+ * `'internal_error'` because there's no portable HTTP-Spec mapping
10
+ * everyone agrees on. Domain handlers should set `code` explicitly for
11
+ * any non-default status.
12
+ */
13
+ function statusToErrorCode(status) {
14
+ switch (status) {
15
+ case 400: return ERROR_CODES.VALIDATION;
16
+ case 401: return ERROR_CODES.UNAUTHORIZED;
17
+ case 403: return ERROR_CODES.FORBIDDEN;
18
+ case 404: return ERROR_CODES.NOT_FOUND;
19
+ case 409: return ERROR_CODES.CONFLICT;
20
+ case 412: return ERROR_CODES.PRECONDITION_FAILED;
21
+ case 429: return ERROR_CODES.RATE_LIMITED;
22
+ case 503: return ERROR_CODES.UNAVAILABLE;
23
+ case 504: return ERROR_CODES.TIMEOUT;
24
+ default: return ERROR_CODES.INTERNAL;
25
+ }
26
+ }
27
+ /**
28
+ * Convert a throwable {@link HttpError} (or any `Error` with a `status`
29
+ * field) into the canonical {@link ErrorContract} wire shape.
30
+ *
31
+ * `code` cascade:
32
+ * 1. `error.code` (explicit machine code on the throwable) — preferred.
33
+ * 2. {@link statusToErrorCode}(`error.status`) — derived from status.
34
+ * 3. `'internal_error'` — fallback for plain `Error` without status.
35
+ *
36
+ * `validationErrors` (mongokit-shaped throwable field) is mapped into the
37
+ * canonical `details` array so wire consumers see one shape regardless
38
+ * of which kit threw the error. Each `validationErrors[i]` becomes an
39
+ * `ErrorDetail` with `code: validator`, `message: error`, `path` left
40
+ * unset (kits that have field paths set them in their own ErrorDetail
41
+ * mapping).
42
+ *
43
+ * `duplicate.fields` is similarly flattened into `details` with the
44
+ * duplicate-key code so unique-constraint failures look uniform on the
45
+ * wire.
46
+ */
47
+ function toErrorContract(error) {
48
+ if (!(error instanceof Error)) return {
49
+ code: "internal_error",
50
+ message: typeof error === "string" ? error : "Internal error",
51
+ status: 500
52
+ };
53
+ const e = error;
54
+ const status = typeof e.status === "number" ? e.status : 500;
55
+ const contract = {
56
+ code: e.code ?? statusToErrorCode(status),
57
+ message: e.message || "Internal error",
58
+ status
59
+ };
60
+ const details = [];
61
+ if (Array.isArray(e.validationErrors)) for (const v of e.validationErrors) details.push({
62
+ code: v.validator,
63
+ message: v.error
64
+ });
65
+ if (e.duplicate?.fields?.length) for (const field of e.duplicate.fields) details.push({
66
+ path: field,
67
+ code: "duplicate_key",
68
+ message: `Duplicate value for "${field}"`
69
+ });
70
+ if (details.length > 0) contract.details = details;
71
+ if (e.meta) contract.meta = e.meta;
72
+ return contract;
73
+ }
74
+ //#endregion
75
+ export { statusToErrorCode, toErrorContract };
@@ -1,4 +1,6 @@
1
- import { DuplicateKeyMeta, HttpError, ValidationErrorMeta } from "./types.mjs";
1
+ import { DuplicateKeyMeta, ERROR_CODES, ErrorCode, ErrorContract, ErrorDetail, HttpError, ValidationErrorMeta } from "./types.mjs";
2
+ import { statusToErrorCode, toErrorContract } from "./contract.mjs";
2
3
  import { createError, isHttpError } from "./create-error.mjs";
3
4
  import { IsDuplicateKeyErrorFn, ToDuplicateKeyHttpErrorOptions, conservativeMongoIsDuplicateKey, toDuplicateKeyHttpError } from "./duplicate-key.mjs";
4
- export { type DuplicateKeyMeta, type HttpError, type IsDuplicateKeyErrorFn, type ToDuplicateKeyHttpErrorOptions, type ValidationErrorMeta, conservativeMongoIsDuplicateKey, createError, isHttpError, toDuplicateKeyHttpError };
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 };
@@ -1,3 +1,6 @@
1
+ import { ERROR_CODES } from "./types.mjs";
2
+ import { statusToErrorCode, toErrorContract } from "./contract.mjs";
1
3
  import { createError, isHttpError } from "./create-error.mjs";
2
4
  import { conservativeMongoIsDuplicateKey, toDuplicateKeyHttpError } from "./duplicate-key.mjs";
3
- export { conservativeMongoIsDuplicateKey, createError, isHttpError, toDuplicateKeyHttpError };
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 };