@classytic/repo-core 0.28.0 → 0.30.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 (55) hide show
  1. package/CHANGELOG.md +1290 -1098
  2. package/dist/cache/engine.d.mts +7 -5
  3. package/dist/cache/engine.mjs +34 -16
  4. package/dist/cache/memo.d.mts +4 -0
  5. package/dist/cache/memo.mjs +17 -4
  6. package/dist/cache/memory-adapter.mjs +6 -4
  7. package/dist/cache/plugin/read-hooks.mjs +1 -1
  8. package/dist/cache/stable-stringify.d.mts +7 -8
  9. package/dist/cache/stable-stringify.mjs +14 -11
  10. package/dist/cache/tag-index.mjs +1 -2
  11. package/dist/cache/types.d.mts +4 -3
  12. package/dist/errors/codes.d.mts +117 -0
  13. package/dist/errors/codes.mjs +114 -0
  14. package/dist/errors/contract.d.mts +2 -1
  15. package/dist/errors/contract.mjs +9 -3
  16. package/dist/errors/index.d.mts +3 -2
  17. package/dist/errors/index.mjs +2 -2
  18. package/dist/errors/types.d.mts +3 -30
  19. package/dist/filter/builders.mjs +1 -1
  20. package/dist/filter/match-record.mjs +9 -3
  21. package/dist/filter/match.mjs +25 -9
  22. package/dist/idempotency/index.d.mts +462 -0
  23. package/dist/idempotency/index.mjs +419 -0
  24. package/dist/pagination/types.d.mts +6 -0
  25. package/dist/query-parser/coerce.d.mts +18 -11
  26. package/dist/query-parser/coerce.mjs +37 -69
  27. package/dist/query-parser/errors.d.mts +30 -0
  28. package/dist/query-parser/errors.mjs +32 -0
  29. package/dist/query-parser/grammar.d.mts +107 -0
  30. package/dist/query-parser/grammar.mjs +462 -0
  31. package/dist/query-parser/index.d.mts +5 -2
  32. package/dist/query-parser/index.mjs +5 -2
  33. package/dist/query-parser/parse-url.d.mts +1 -1
  34. package/dist/query-parser/parse-url.mjs +72 -205
  35. package/dist/query-parser/regex-safety.d.mts +31 -0
  36. package/dist/query-parser/regex-safety.mjs +151 -0
  37. package/dist/query-parser/reserved.mjs +1 -0
  38. package/dist/query-parser/types.d.mts +15 -25
  39. package/dist/repository/types.d.mts +6 -5
  40. package/dist/schema/field-rules.mjs +49 -6
  41. package/dist/schema/standard-schema.mjs +1 -1
  42. package/dist/sync/index.d.mts +90 -55
  43. package/dist/sync/index.mjs +0 -0
  44. package/dist/testing/change-log-conformance.d.mts +21 -0
  45. package/dist/testing/change-log-conformance.mjs +204 -0
  46. package/dist/testing/command-stream-conformance.d.mts +15 -0
  47. package/dist/testing/command-stream-conformance.mjs +163 -0
  48. package/dist/testing/idempotency-conformance.d.mts +15 -0
  49. package/dist/testing/idempotency-conformance.mjs +315 -0
  50. package/dist/testing/index.d.mts +5 -1
  51. package/dist/testing/index.mjs +5 -1
  52. package/dist/testing/query-grammar-conformance.d.mts +60 -0
  53. package/dist/testing/query-grammar-conformance.mjs +380 -0
  54. package/package.json +6 -2
  55. package/dist/errors/types.mjs +0 -29
@@ -55,13 +55,13 @@ declare class CacheEngine {
55
55
  private readonly prefix;
56
56
  private readonly jitter;
57
57
  /**
58
- * In-flight fetches keyed by cache-key. Process-local (lives in this
59
- * engine instance) — server restart clears it; cross-pod fanout is
60
- * fine because each pod runs its own single-flight, and downstream
61
- * load is bounded to N-pods worst case (a huge improvement over
62
- * unbounded burst).
58
+ * In-flight fetches, partitioned by the RESOLVED adapter, then keyed by cache-key. A scoped store
59
+ * (per request, per job) gets its own partition, so two scopes reading the same key never share a
60
+ * result — and a finished scope's partition is collected with its store. Process-local; cross-pod
61
+ * fanout is bounded to N pods.
63
62
  */
64
63
  private readonly pending;
64
+ private pendingTotal;
65
65
  constructor(adapter: CacheAdapterSource, options?: CacheEngineOptions);
66
66
  /**
67
67
  * Read a cache entry under SWR + TTL semantics. Returns a
@@ -91,6 +91,8 @@ declare class CacheEngine {
91
91
  * pending.
92
92
  */
93
93
  getPending<T = unknown>(key: string): Promise<T> | undefined;
94
+ /** This scope's in-flight partition; `undefined` when no store is in scope (never coalesce there). */
95
+ private pendingIn;
94
96
  /**
95
97
  * Atomically claim `key` for a fetch. Returns `'claimed'` when this
96
98
  * caller owns the fetch (it must call `resolvePending` or
@@ -35,13 +35,13 @@ var CacheEngine = class {
35
35
  prefix;
36
36
  jitter;
37
37
  /**
38
- * In-flight fetches keyed by cache-key. Process-local (lives in this
39
- * engine instance) — server restart clears it; cross-pod fanout is
40
- * fine because each pod runs its own single-flight, and downstream
41
- * load is bounded to N-pods worst case (a huge improvement over
42
- * unbounded burst).
38
+ * In-flight fetches, partitioned by the RESOLVED adapter, then keyed by cache-key. A scoped store
39
+ * (per request, per job) gets its own partition, so two scopes reading the same key never share a
40
+ * result — and a finished scope's partition is collected with its store. Process-local; cross-pod
41
+ * fanout is bounded to N pods.
43
42
  */
44
- pending = /* @__PURE__ */ new Map();
43
+ pending = /* @__PURE__ */ new WeakMap();
44
+ pendingTotal = 0;
45
45
  constructor(adapter, options = {}) {
46
46
  this.resolveAdapter = typeof adapter === "function" ? adapter : () => adapter;
47
47
  this.prefix = options.prefix ?? "rc";
@@ -125,7 +125,18 @@ var CacheEngine = class {
125
125
  * pending.
126
126
  */
127
127
  getPending(key) {
128
- return this.pending.get(key)?.promise;
128
+ return this.pendingIn(false)?.get(key)?.promise;
129
+ }
130
+ /** This scope's in-flight partition; `undefined` when no store is in scope (never coalesce there). */
131
+ pendingIn(create) {
132
+ const adapter = this.resolveAdapter();
133
+ if (!adapter) return void 0;
134
+ let partition = this.pending.get(adapter);
135
+ if (!partition && create) {
136
+ partition = /* @__PURE__ */ new Map();
137
+ this.pending.set(adapter, partition);
138
+ }
139
+ return partition;
129
140
  }
130
141
  /**
131
142
  * Atomically claim `key` for a fetch. Returns `'claimed'` when this
@@ -135,21 +146,26 @@ var CacheEngine = class {
135
146
  * with the first claimer's result.
136
147
  */
137
148
  claimPending(key) {
138
- const existing = this.pending.get(key);
149
+ const partition = this.pendingIn(true);
150
+ if (!partition) return { status: "claimed" };
151
+ const existing = partition.get(key);
139
152
  if (existing) return {
140
153
  status: "wait",
141
154
  promise: existing.promise
142
155
  };
143
156
  const deferred = Promise.withResolvers();
144
157
  deferred.promise.catch(() => {});
145
- this.pending.set(key, deferred);
158
+ partition.set(key, deferred);
159
+ this.pendingTotal += 1;
146
160
  return { status: "claimed" };
147
161
  }
148
162
  /** Resolve an in-flight claim with the fresh result + clear it. */
149
163
  resolvePending(key, value) {
150
- const deferred = this.pending.get(key);
151
- if (!deferred) return;
152
- this.pending.delete(key);
164
+ const partition = this.pendingIn(false);
165
+ const deferred = partition?.get(key);
166
+ if (!partition || !deferred) return;
167
+ partition.delete(key);
168
+ this.pendingTotal -= 1;
153
169
  deferred.resolve(value);
154
170
  }
155
171
  /**
@@ -158,14 +174,16 @@ var CacheEngine = class {
158
174
  * whether to retry on a higher level.
159
175
  */
160
176
  rejectPending(key, error) {
161
- const deferred = this.pending.get(key);
162
- if (!deferred) return;
163
- this.pending.delete(key);
177
+ const partition = this.pendingIn(false);
178
+ const deferred = partition?.get(key);
179
+ if (!partition || !deferred) return;
180
+ partition.delete(key);
181
+ this.pendingTotal -= 1;
164
182
  deferred.reject(error);
165
183
  }
166
184
  /** Internal — number of in-flight fetches; observability hook. */
167
185
  get pendingCount() {
168
- return this.pending.size;
186
+ return this.pendingTotal;
169
187
  }
170
188
  /**
171
189
  * Invalidate every entry tagged with ANY of the provided tags. Reads
@@ -7,6 +7,10 @@
7
7
  * only ever helped the caller after them. This is `CacheEngine.prefetch` over
8
8
  * the memory adapter: concurrent misses share one load, a thrown load caches
9
9
  * nothing, and a `null` result is an answer.
10
+ *
11
+ * `invalidate` is FENCED by a generation in the slot key: a load already in flight writes to the
12
+ * superseded slot, which nothing reads, so it can never repopulate the memo after an invalidation
13
+ * — and a `get` after the invalidation never joins that load either.
10
14
  */
11
15
  interface TtlMemoOptions<K> {
12
16
  /** Freshness window. `0` disables memoisation — every call loads. */
@@ -9,6 +9,10 @@ import { createMemoryCacheAdapter } from "./memory-adapter.mjs";
9
9
  * only ever helped the caller after them. This is `CacheEngine.prefetch` over
10
10
  * the memory adapter: concurrent misses share one load, a thrown load caches
11
11
  * nothing, and a `null` result is an answer.
12
+ *
13
+ * `invalidate` is FENCED by a generation in the slot key: a load already in flight writes to the
14
+ * superseded slot, which nothing reads, so it can never repopulate the memo after an invalidation
15
+ * — and a `get` after the invalidation never joins that load either.
12
16
  */
13
17
  const PREFIX = "memo";
14
18
  function createTtlMemo(load, options) {
@@ -23,6 +27,11 @@ function createTtlMemo(load, options) {
23
27
  swr: false,
24
28
  enabled: ttlMs > 0
25
29
  };
30
+ const limit = maxEntries ?? 1e4;
31
+ /** Bumped by a full `invalidate()`; per-key generations exist only for keys invalidated since. */
32
+ let epoch = 0;
33
+ const generations = /* @__PURE__ */ new Map();
34
+ const slot = (base) => `${base}#${epoch}.${generations.get(base) ?? 0}`;
26
35
  const toKey = (key) => {
27
36
  if (keyOf) return `${PREFIX}:${keyOf(key)}`;
28
37
  const t = typeof key;
@@ -31,15 +40,19 @@ function createTtlMemo(load, options) {
31
40
  };
32
41
  return {
33
42
  get(key) {
34
- const k = toKey(key);
35
- return engine.prefetch(k, resolved, () => load(key));
43
+ return engine.prefetch(slot(toKey(key)), resolved, () => load(key));
36
44
  },
37
45
  async invalidate(key) {
38
- if (key === void 0) {
46
+ if (key === void 0 || generations.size >= limit) {
47
+ epoch += 1;
48
+ generations.clear();
39
49
  await engine.clear();
40
50
  return;
41
51
  }
42
- await adapter.delete(toKey(key));
52
+ const base = toKey(key);
53
+ const superseded = slot(base);
54
+ generations.set(base, (generations.get(base) ?? 0) + 1);
55
+ await adapter.delete(superseded);
43
56
  }
44
57
  };
45
58
  }
@@ -62,13 +62,15 @@ function createMemoryCacheAdapter(options = {}) {
62
62
  addToSet(key, members, ttlSeconds = 60) {
63
63
  const existing = readUnexpired(key);
64
64
  let set;
65
- if (existing && existing.value instanceof Set) set = existing.value;
66
- else {
65
+ const expiry = ttlSeconds === 0 ? 0 : now() + ttlSeconds * 1e3;
66
+ if (existing && existing.value instanceof Set) {
67
+ set = existing.value;
68
+ if (existing.expiresAt !== 0 && (expiry === 0 || expiry > existing.expiresAt)) existing.expiresAt = expiry;
69
+ } else {
67
70
  set = /* @__PURE__ */ new Set();
68
- const expiresAt = existing?.expiresAt ?? (ttlSeconds === 0 ? 0 : now() + ttlSeconds * 1e3);
69
71
  store.set(key, {
70
72
  value: set,
71
- expiresAt
73
+ expiresAt: expiry
72
74
  });
73
75
  evictIfNeeded();
74
76
  }
@@ -42,7 +42,7 @@ function registerBefore(op, engine, hookCtx) {
42
42
  return;
43
43
  }
44
44
  hookCtx.log.onMiss?.(key, op);
45
- if (resolved.bypass) return;
45
+ if (resolved.bypass || result.status === "disabled") return;
46
46
  const claim = engine.claimPending(key);
47
47
  if (claim.status === "wait") try {
48
48
  const data = await claim.promise;
@@ -1,14 +1,13 @@
1
1
  //#region src/cache/stable-stringify.d.ts
2
2
  /**
3
- * Deterministic JSON stringify — equivalent values produce identical output.
3
+ * Deterministic JSON stringify — equivalent values produce identical output, different values
4
+ * never do. Every cache key and `contentHash` is built on it.
4
5
  *
5
- * Used by kits that build cache keys from hook contexts so
6
- * `{ b: 1, a: 2 }` and `{ a: 2, b: 1 }` hash to the same bucket. Arrays
7
- * preserve order (order is part of array identity).
8
- *
9
- * Extracted from the mongokit / sqlitekit cache plugins into repo-core so
10
- * every kit's cachePlugin uses the same keying rule — cross-kit caches
11
- * remain bucket-compatible.
6
+ * WIRE semantics, so a value built in memory serialises like the same value after a JSON round
7
+ * trip: `toJSON` is applied (a `Date` is its ISO string, an ObjectId its hex), an `undefined`
8
+ * property is absent and an `undefined` array slot is `null`. A `RegExp` — which JSON would render
9
+ * `{}`, colliding every pattern — is `{"$regex","$options"}`. Keys sort by code unit, never by
10
+ * locale: a key that moves with the server's ICU data changes every digest. Arrays keep order.
12
11
  */
13
12
  declare function stableStringify(value: unknown): string;
14
13
  //#endregion
@@ -1,19 +1,22 @@
1
1
  //#region src/cache/stable-stringify.ts
2
2
  /**
3
- * Deterministic JSON stringify — equivalent values produce identical output.
3
+ * Deterministic JSON stringify — equivalent values produce identical output, different values
4
+ * never do. Every cache key and `contentHash` is built on it.
4
5
  *
5
- * Used by kits that build cache keys from hook contexts so
6
- * `{ b: 1, a: 2 }` and `{ a: 2, b: 1 }` hash to the same bucket. Arrays
7
- * preserve order (order is part of array identity).
8
- *
9
- * Extracted from the mongokit / sqlitekit cache plugins into repo-core so
10
- * every kit's cachePlugin uses the same keying rule — cross-kit caches
11
- * remain bucket-compatible.
6
+ * WIRE semantics, so a value built in memory serialises like the same value after a JSON round
7
+ * trip: `toJSON` is applied (a `Date` is its ISO string, an ObjectId its hex), an `undefined`
8
+ * property is absent and an `undefined` array slot is `null`. A `RegExp` — which JSON would render
9
+ * `{}`, colliding every pattern — is `{"$regex","$options"}`. Keys sort by code unit, never by
10
+ * locale: a key that moves with the server's ICU data changes every digest. Arrays keep order.
12
11
  */
13
12
  function stableStringify(value) {
14
- if (value === null || typeof value !== "object") return JSON.stringify(value);
15
- if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
16
- return `{${Object.entries(value).sort(([a], [b]) => a.localeCompare(b)).map(([k, v]) => `${JSON.stringify(k)}:${stableStringify(v)}`).join(",")}}`;
13
+ if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "null";
14
+ if (value instanceof RegExp) return `{"$options":${JSON.stringify(value.flags)},"$regex":${JSON.stringify(value.source)}}`;
15
+ const toJSON = value.toJSON;
16
+ if (typeof toJSON === "function") return stableStringify(toJSON.call(value));
17
+ if (Array.isArray(value)) return `[${value.map((v) => v === void 0 ? "null" : stableStringify(v)).join(",")}]`;
18
+ const record = value;
19
+ return `{${Object.keys(record).filter((k) => record[k] !== void 0).sort((a, b) => a < b ? -1 : a > b ? 1 : 0).map((k) => `${JSON.stringify(k)}:${stableStringify(record[k])}`).join(",")}}`;
17
20
  }
18
21
  //#endregion
19
22
  export { stableStringify };
@@ -52,8 +52,7 @@ async function appendKeyToTags(adapter, prefix, cacheKey, tags, ttlSeconds) {
52
52
  const idxKey = tagIndexKey(prefix, tag);
53
53
  const existing = await adapter.get(idxKey);
54
54
  const current = Array.isArray(existing) ? existing : [];
55
- if (current.includes(cacheKey)) return;
56
- const next = [...current, cacheKey];
55
+ const next = current.includes(cacheKey) ? current : [...current, cacheKey];
57
56
  await adapter.set(idxKey, next, indexTtl);
58
57
  }));
59
58
  }
@@ -66,13 +66,14 @@ interface CacheAdapter {
66
66
  * difference is the 178× slowdown the in-memory benchmark surfaced.
67
67
  *
68
68
  * **Implementation guidance:**
69
- * - Redis: `SADD key m1 m2 ...` + `EXPIRE key ttlSeconds NX`
69
+ * - Redis: `SADD key m1 m2 ...` + `EXPIRE key ttlSeconds GT` (Redis ≥ 7; `NX` is wrong, see below)
70
70
  * - Memory: in-place push on the underlying array (no copy)
71
71
  * - DynamoDB: `UpdateItem` with `ADD` action on a String Set
72
72
  * - Cloudflare KV / pure GET-SET stores: omit; engine falls back.
73
73
  *
74
- * `ttlSeconds` is applied only when the key is created — existing
75
- * sets keep their original expiry (Redis NX semantics).
74
+ * `ttlSeconds` EXTENDS the set's expiry and never shortens it (Redis `GT` semantics). A set used
75
+ * as a tag index must outlive its newest member; keeping the creation-time expiry lets the index
76
+ * vanish while members live on, and invalidating the tag then misses them.
76
77
  */
77
78
  addToSet?(key: string, members: readonly string[], ttlSeconds?: number): Promise<number> | number;
78
79
  /**
@@ -0,0 +1,117 @@
1
+ //#region src/errors/codes.d.ts
2
+ /**
3
+ * The error-code vocabulary of the wire — every value a client may see in
4
+ * `ErrorContract.code`, in one place.
5
+ *
6
+ * Codes live here, in the contract package, rather than in the servers that
7
+ * emit them: a client SDK consumes the wire vocabulary by depending on
8
+ * repo-core, never on the server framework. The emitting side proves it only
9
+ * uses catalogued codes (arc's test suite fails on an uncatalogued `arc.*`
10
+ * literal); the consuming side spreads these constants instead of copying
11
+ * strings, so an addition reaches every client with a repo-core bump.
12
+ *
13
+ * Three families:
14
+ *
15
+ * - {@link ERROR_CODES} — cross-cutting, lowercase snake_case (RFC 7807 /
16
+ * Stripe style). What {@link statusToErrorCode} derives and any
17
+ * HTTP-emitting package may use.
18
+ * - {@link ARC_ERROR_CODES} — arc's `arc.`-namespaced codes: the
19
+ * status-derived family, request validation, auth, tenancy, idempotency,
20
+ * capacity, and per-feature codes. Values are wire-stable — never renamed.
21
+ * - {@link ARC_REASON_CODES} — UPPER_SNAKE business reasons arc's mixins put
22
+ * on `code` alongside the HTTP status (why a bulk write was refused, …).
23
+ *
24
+ * `ErrorContract.code` stays `string`: domain packages extend hierarchically
25
+ * (`'order.validation.missing_line'`) without registering here.
26
+ */
27
+ declare const ERROR_CODES: {
28
+ readonly VALIDATION: 'validation_error';
29
+ readonly NOT_FOUND: 'not_found';
30
+ readonly CONFLICT: 'conflict';
31
+ readonly UNAUTHORIZED: 'unauthorized';
32
+ readonly FORBIDDEN: 'forbidden';
33
+ readonly RATE_LIMITED: 'rate_limited';
34
+ readonly IDEMPOTENCY_CONFLICT: 'idempotency_conflict';
35
+ readonly PRECONDITION_FAILED: 'precondition_failed';
36
+ readonly INTERNAL: 'internal_error';
37
+ readonly UNAVAILABLE: 'service_unavailable';
38
+ readonly TIMEOUT: 'timeout';
39
+ };
40
+ type ErrorCode = (typeof ERROR_CODES)[keyof typeof ERROR_CODES];
41
+ declare const ARC_ERROR_CODES: {
42
+ readonly ERROR: 'arc.error';
43
+ readonly BAD_REQUEST: 'arc.bad_request';
44
+ readonly UNAUTHORIZED: 'arc.unauthorized';
45
+ readonly FORBIDDEN: 'arc.forbidden';
46
+ readonly NOT_FOUND: 'arc.not_found';
47
+ readonly CONFLICT: 'arc.conflict';
48
+ readonly PAYLOAD_TOO_LARGE: 'arc.payload_too_large';
49
+ readonly UNSUPPORTED_MEDIA_TYPE: 'arc.unsupported_media_type';
50
+ readonly UNPROCESSABLE_ENTITY: 'arc.unprocessable_entity';
51
+ readonly RATE_LIMITED: 'arc.rate_limited';
52
+ readonly INTERNAL_ERROR: 'arc.internal_error';
53
+ readonly NOT_IMPLEMENTED: 'arc.not_implemented';
54
+ readonly BAD_GATEWAY: 'arc.bad_gateway';
55
+ readonly SERVICE_UNAVAILABLE: 'arc.service_unavailable';
56
+ readonly GATEWAY_TIMEOUT: 'arc.gateway_timeout';
57
+ readonly VALIDATION_ERROR: 'arc.validation_error';
58
+ readonly INVALID_ID: 'arc.invalid_id';
59
+ readonly INVALID_ACTION: 'arc.invalid_action';
60
+ readonly UNSUPPORTED_API_VERSION: 'arc.unsupported_api_version';
61
+ /** A stale `If-Match` — re-read the document (its `ETag`) and retry. HTTP 412. */
62
+ readonly PRECONDITION_FAILED: 'arc.precondition_failed';
63
+ readonly AUTH_INVALID_TOKEN_TYPE: 'arc.auth.invalid_token_type';
64
+ readonly AUTH_TOKEN_REVOKED: 'arc.auth.token_revoked';
65
+ readonly AUTH_REVOCATION_CHECK_FAILED: 'arc.auth.revocation_check_failed';
66
+ readonly AUTH_MISCONFIGURED: 'arc.auth.misconfigured';
67
+ /** HTTP 500 — the auth provider itself failed (not a bad credential). */
68
+ readonly AUTH_SERVICE_ERROR: 'arc.auth.service_error';
69
+ /** HTTP 401 — `x-arc-scope: platform` without an authenticated user. */
70
+ readonly ELEVATION_AUTH_REQUIRED: 'arc.elevation.auth_required';
71
+ /** HTTP 403 — `x-arc-scope: platform` from a user without a platform role. */
72
+ readonly ELEVATION_FORBIDDEN: 'arc.elevation.forbidden';
73
+ readonly ORG_SELECTION_REQUIRED: 'arc.org.selection_required';
74
+ readonly ORG_ACCESS_DENIED: 'arc.org.access_denied';
75
+ /** Convention for host tier gates; arc surfaces it verbatim with `{ requiredMode, currentMode }`. */
76
+ readonly TIER_REQUIRED: 'arc.tier_required';
77
+ /** HTTP 400 — the route requires an `Idempotency-Key`. */
78
+ readonly IDEMPOTENCY_KEY_REQUIRED: 'arc.idempotency_key_required';
79
+ /** HTTP 422 — the key already named an operation with a different body. */
80
+ readonly IDEMPOTENCY_KEY_REUSED: 'arc.idempotency_key_reused';
81
+ /** HTTP 400 — the body could not be fingerprinted (too deep). */
82
+ readonly IDEMPOTENCY_BODY_UNFINGERPRINTABLE: 'arc.idempotency_body_unfingerprintable';
83
+ /** HTTP 409 — the same key is still executing; retry after `Retry-After`. */
84
+ readonly IDEMPOTENCY_CONFLICT: 'arc.idempotency_conflict';
85
+ readonly CIRCUIT_OPEN: 'arc.circuit_open';
86
+ readonly UNAVAILABLE: 'arc.unavailable';
87
+ /** HTTP 500 — a host write verb broke its contract (e.g. `create` returned nothing). */
88
+ readonly WRITE_VERB_CONTRACT_VIOLATION: 'arc.write_verb.contract_violation';
89
+ readonly ADAPTER_CAPABILITY_REQUIRED: 'arc.adapter.capability_required';
90
+ readonly NO_ADAPTER: 'arc.no_adapter';
91
+ readonly AGGREGATION_MAX_GROUPS_EXCEEDED: 'arc.aggregation.max_groups_exceeded';
92
+ readonly AGGREGATION_REQUIRED_FILTER_MISSING: 'arc.aggregation.required_filter_missing';
93
+ readonly AGGREGATION_REQUIRED_DATE_RANGE_MISSING: 'arc.aggregation.required_date_range_missing';
94
+ readonly AGGREGATION_DATE_RANGE_EXCEEDED: 'arc.aggregation.date_range_exceeded';
95
+ readonly EVENT_VALIDATION_ERROR: 'arc.event.validation_error';
96
+ readonly REALTIME_UNFILTERABLE: 'arc.realtime.unfilterable';
97
+ readonly JOBS_POLICY_UNSUPPORTED: 'arc.jobs.policy_unsupported';
98
+ readonly STREAMLINE_INVALID_BODY: 'arc.streamline.invalid_body';
99
+ readonly STREAMLINE_MISSING_INPUT_ENVELOPE: 'arc.streamline.missing_input_envelope';
100
+ readonly STREAMLINE_UNKNOWN_ENVELOPE_KEYS: 'arc.streamline.unknown_envelope_keys';
101
+ readonly PURGE_CHUNKED_REQUIRED: 'arc.purge.chunked_required';
102
+ readonly PURGE_UNSUPPORTED_STRATEGY: 'arc.purge.unsupported_strategy';
103
+ readonly PURGE_NO_BULK_OP: 'arc.purge.no_bulk_op';
104
+ readonly MCP_TOOL_NAME_COLLISION: 'arc.mcp.tool_name_collision';
105
+ };
106
+ type ArcErrorCode = (typeof ARC_ERROR_CODES)[keyof typeof ARC_ERROR_CODES];
107
+ declare const ARC_REASON_CODES: {
108
+ readonly ORG_CONTEXT_REQUIRED: 'ORG_CONTEXT_REQUIRED';
109
+ readonly MIXED_UPDATE_SHAPE: 'MIXED_UPDATE_SHAPE';
110
+ readonly ALL_FIELDS_STRIPPED: 'ALL_FIELDS_STRIPPED';
111
+ readonly BEFORE_RESTORE_HOOK_ERROR: 'BEFORE_RESTORE_HOOK_ERROR';
112
+ };
113
+ type ArcReasonCode = (typeof ARC_REASON_CODES)[keyof typeof ARC_REASON_CODES];
114
+ /** `details[].code` for a unique-constraint violation (see `toErrorContract`). */
115
+ declare const DUPLICATE_KEY_DETAIL_CODE = "duplicate_key";
116
+ //#endregion
117
+ export { ARC_ERROR_CODES, ARC_REASON_CODES, ArcErrorCode, ArcReasonCode, DUPLICATE_KEY_DETAIL_CODE, ERROR_CODES, ErrorCode };
@@ -0,0 +1,114 @@
1
+ //#region src/errors/codes.ts
2
+ /**
3
+ * The error-code vocabulary of the wire — every value a client may see in
4
+ * `ErrorContract.code`, in one place.
5
+ *
6
+ * Codes live here, in the contract package, rather than in the servers that
7
+ * emit them: a client SDK consumes the wire vocabulary by depending on
8
+ * repo-core, never on the server framework. The emitting side proves it only
9
+ * uses catalogued codes (arc's test suite fails on an uncatalogued `arc.*`
10
+ * literal); the consuming side spreads these constants instead of copying
11
+ * strings, so an addition reaches every client with a repo-core bump.
12
+ *
13
+ * Three families:
14
+ *
15
+ * - {@link ERROR_CODES} — cross-cutting, lowercase snake_case (RFC 7807 /
16
+ * Stripe style). What {@link statusToErrorCode} derives and any
17
+ * HTTP-emitting package may use.
18
+ * - {@link ARC_ERROR_CODES} — arc's `arc.`-namespaced codes: the
19
+ * status-derived family, request validation, auth, tenancy, idempotency,
20
+ * capacity, and per-feature codes. Values are wire-stable — never renamed.
21
+ * - {@link ARC_REASON_CODES} — UPPER_SNAKE business reasons arc's mixins put
22
+ * on `code` alongside the HTTP status (why a bulk write was refused, …).
23
+ *
24
+ * `ErrorContract.code` stays `string`: domain packages extend hierarchically
25
+ * (`'order.validation.missing_line'`) without registering here.
26
+ */
27
+ const ERROR_CODES = {
28
+ VALIDATION: "validation_error",
29
+ NOT_FOUND: "not_found",
30
+ CONFLICT: "conflict",
31
+ UNAUTHORIZED: "unauthorized",
32
+ FORBIDDEN: "forbidden",
33
+ RATE_LIMITED: "rate_limited",
34
+ IDEMPOTENCY_CONFLICT: "idempotency_conflict",
35
+ PRECONDITION_FAILED: "precondition_failed",
36
+ INTERNAL: "internal_error",
37
+ UNAVAILABLE: "service_unavailable",
38
+ TIMEOUT: "timeout"
39
+ };
40
+ const ARC_ERROR_CODES = {
41
+ ERROR: "arc.error",
42
+ BAD_REQUEST: "arc.bad_request",
43
+ UNAUTHORIZED: "arc.unauthorized",
44
+ FORBIDDEN: "arc.forbidden",
45
+ NOT_FOUND: "arc.not_found",
46
+ CONFLICT: "arc.conflict",
47
+ PAYLOAD_TOO_LARGE: "arc.payload_too_large",
48
+ UNSUPPORTED_MEDIA_TYPE: "arc.unsupported_media_type",
49
+ UNPROCESSABLE_ENTITY: "arc.unprocessable_entity",
50
+ RATE_LIMITED: "arc.rate_limited",
51
+ INTERNAL_ERROR: "arc.internal_error",
52
+ NOT_IMPLEMENTED: "arc.not_implemented",
53
+ BAD_GATEWAY: "arc.bad_gateway",
54
+ SERVICE_UNAVAILABLE: "arc.service_unavailable",
55
+ GATEWAY_TIMEOUT: "arc.gateway_timeout",
56
+ VALIDATION_ERROR: "arc.validation_error",
57
+ INVALID_ID: "arc.invalid_id",
58
+ INVALID_ACTION: "arc.invalid_action",
59
+ UNSUPPORTED_API_VERSION: "arc.unsupported_api_version",
60
+ /** A stale `If-Match` — re-read the document (its `ETag`) and retry. HTTP 412. */
61
+ PRECONDITION_FAILED: "arc.precondition_failed",
62
+ AUTH_INVALID_TOKEN_TYPE: "arc.auth.invalid_token_type",
63
+ AUTH_TOKEN_REVOKED: "arc.auth.token_revoked",
64
+ AUTH_REVOCATION_CHECK_FAILED: "arc.auth.revocation_check_failed",
65
+ AUTH_MISCONFIGURED: "arc.auth.misconfigured",
66
+ /** HTTP 500 — the auth provider itself failed (not a bad credential). */
67
+ AUTH_SERVICE_ERROR: "arc.auth.service_error",
68
+ /** HTTP 401 — `x-arc-scope: platform` without an authenticated user. */
69
+ ELEVATION_AUTH_REQUIRED: "arc.elevation.auth_required",
70
+ /** HTTP 403 — `x-arc-scope: platform` from a user without a platform role. */
71
+ ELEVATION_FORBIDDEN: "arc.elevation.forbidden",
72
+ ORG_SELECTION_REQUIRED: "arc.org.selection_required",
73
+ ORG_ACCESS_DENIED: "arc.org.access_denied",
74
+ /** Convention for host tier gates; arc surfaces it verbatim with `{ requiredMode, currentMode }`. */
75
+ TIER_REQUIRED: "arc.tier_required",
76
+ /** HTTP 400 — the route requires an `Idempotency-Key`. */
77
+ IDEMPOTENCY_KEY_REQUIRED: "arc.idempotency_key_required",
78
+ /** HTTP 422 — the key already named an operation with a different body. */
79
+ IDEMPOTENCY_KEY_REUSED: "arc.idempotency_key_reused",
80
+ /** HTTP 400 — the body could not be fingerprinted (too deep). */
81
+ IDEMPOTENCY_BODY_UNFINGERPRINTABLE: "arc.idempotency_body_unfingerprintable",
82
+ /** HTTP 409 — the same key is still executing; retry after `Retry-After`. */
83
+ IDEMPOTENCY_CONFLICT: "arc.idempotency_conflict",
84
+ CIRCUIT_OPEN: "arc.circuit_open",
85
+ UNAVAILABLE: "arc.unavailable",
86
+ /** HTTP 500 — a host write verb broke its contract (e.g. `create` returned nothing). */
87
+ WRITE_VERB_CONTRACT_VIOLATION: "arc.write_verb.contract_violation",
88
+ ADAPTER_CAPABILITY_REQUIRED: "arc.adapter.capability_required",
89
+ NO_ADAPTER: "arc.no_adapter",
90
+ AGGREGATION_MAX_GROUPS_EXCEEDED: "arc.aggregation.max_groups_exceeded",
91
+ AGGREGATION_REQUIRED_FILTER_MISSING: "arc.aggregation.required_filter_missing",
92
+ AGGREGATION_REQUIRED_DATE_RANGE_MISSING: "arc.aggregation.required_date_range_missing",
93
+ AGGREGATION_DATE_RANGE_EXCEEDED: "arc.aggregation.date_range_exceeded",
94
+ EVENT_VALIDATION_ERROR: "arc.event.validation_error",
95
+ REALTIME_UNFILTERABLE: "arc.realtime.unfilterable",
96
+ JOBS_POLICY_UNSUPPORTED: "arc.jobs.policy_unsupported",
97
+ STREAMLINE_INVALID_BODY: "arc.streamline.invalid_body",
98
+ STREAMLINE_MISSING_INPUT_ENVELOPE: "arc.streamline.missing_input_envelope",
99
+ STREAMLINE_UNKNOWN_ENVELOPE_KEYS: "arc.streamline.unknown_envelope_keys",
100
+ PURGE_CHUNKED_REQUIRED: "arc.purge.chunked_required",
101
+ PURGE_UNSUPPORTED_STRATEGY: "arc.purge.unsupported_strategy",
102
+ PURGE_NO_BULK_OP: "arc.purge.no_bulk_op",
103
+ MCP_TOOL_NAME_COLLISION: "arc.mcp.tool_name_collision"
104
+ };
105
+ const ARC_REASON_CODES = {
106
+ ORG_CONTEXT_REQUIRED: "ORG_CONTEXT_REQUIRED",
107
+ MIXED_UPDATE_SHAPE: "MIXED_UPDATE_SHAPE",
108
+ ALL_FIELDS_STRIPPED: "ALL_FIELDS_STRIPPED",
109
+ BEFORE_RESTORE_HOOK_ERROR: "BEFORE_RESTORE_HOOK_ERROR"
110
+ };
111
+ /** `details[].code` for a unique-constraint violation (see `toErrorContract`). */
112
+ const DUPLICATE_KEY_DETAIL_CODE = "duplicate_key";
113
+ //#endregion
114
+ export { ARC_ERROR_CODES, ARC_REASON_CODES, DUPLICATE_KEY_DETAIL_CODE, ERROR_CODES };
@@ -1,4 +1,5 @@
1
- import { ErrorCode, ErrorContract } from "./types.mjs";
1
+ import { ErrorCode } from "./codes.mjs";
2
+ import { ErrorContract } from "./types.mjs";
2
3
  //#region src/errors/contract.d.ts
3
4
  /**
4
5
  * Map an HTTP status code to the canonical {@link ErrorCode}. Used as a
@@ -1,6 +1,12 @@
1
- import { ERROR_CODES } from "./types.mjs";
1
+ import { DUPLICATE_KEY_DETAIL_CODE, ERROR_CODES } from "./codes.mjs";
2
2
  //#region src/errors/contract.ts
3
3
  /**
4
+ * Wire-contract helpers — convert throwable {@link HttpError} into the
5
+ * serializable {@link ErrorContract} shape, with a `code` cascade so any
6
+ * thrown error produces a sensible wire envelope even when it's a plain
7
+ * `Error` from third-party code.
8
+ */
9
+ /**
4
10
  * Map an HTTP status code to the canonical {@link ErrorCode}. Used as a
5
11
  * fallback when a thrown `HttpError` lacks an explicit `code` field.
6
12
  *
@@ -47,7 +53,7 @@ function statusToErrorCode(status) {
47
53
  */
48
54
  function toErrorContract(error) {
49
55
  if (!(error instanceof Error)) return {
50
- code: "internal_error",
56
+ code: ERROR_CODES.INTERNAL,
51
57
  message: typeof error === "string" ? error : "Internal error",
52
58
  status: 500
53
59
  };
@@ -67,7 +73,7 @@ function toErrorContract(error) {
67
73
  });
68
74
  if (e.duplicate?.fields?.length) for (const field of e.duplicate.fields) details.push({
69
75
  path: field,
70
- code: "duplicate_key",
76
+ code: DUPLICATE_KEY_DETAIL_CODE,
71
77
  message: `Duplicate value for "${field}"`
72
78
  });
73
79
  if (details.length > 0) contract.details = details;
@@ -1,7 +1,8 @@
1
1
  import { IsTransientConflictFn, VersionConflictError, conservativeMongoIsTransientConflict, isVersionConflictError, neverTransient } from "./conflict.mjs";
2
- import { DuplicateKeyMeta, ERROR_CODES, ErrorCode, ErrorContract, ErrorDetail, HttpError, ValidationErrorMeta } from "./types.mjs";
2
+ import { ARC_ERROR_CODES, ARC_REASON_CODES, ArcErrorCode, ArcReasonCode, DUPLICATE_KEY_DETAIL_CODE, ERROR_CODES, ErrorCode } from "./codes.mjs";
3
+ import { DuplicateKeyMeta, ErrorContract, ErrorDetail, HttpError, ValidationErrorMeta } from "./types.mjs";
3
4
  import { statusToErrorCode, toErrorContract } from "./contract.mjs";
4
5
  import { createError, isHttpError } from "./create-error.mjs";
5
6
  import { IsDuplicateKeyErrorFn, ToDuplicateKeyHttpErrorOptions, conservativeMongoIsDuplicateKey, toDuplicateKeyHttpError } from "./duplicate-key.mjs";
6
7
  import { errorContractSchema, errorDetailSchema } from "./schema.mjs";
7
- export { type DuplicateKeyMeta, ERROR_CODES, type ErrorCode, type ErrorContract, type ErrorDetail, type HttpError, type IsDuplicateKeyErrorFn, type IsTransientConflictFn, type ToDuplicateKeyHttpErrorOptions, type ValidationErrorMeta, VersionConflictError, conservativeMongoIsDuplicateKey, conservativeMongoIsTransientConflict, createError, errorContractSchema, errorDetailSchema, isHttpError, isVersionConflictError, neverTransient, statusToErrorCode, toDuplicateKeyHttpError, toErrorContract };
8
+ export { ARC_ERROR_CODES, ARC_REASON_CODES, type ArcErrorCode, type ArcReasonCode, DUPLICATE_KEY_DETAIL_CODE, type DuplicateKeyMeta, ERROR_CODES, type ErrorCode, type ErrorContract, type ErrorDetail, type HttpError, type IsDuplicateKeyErrorFn, type IsTransientConflictFn, type ToDuplicateKeyHttpErrorOptions, type ValidationErrorMeta, VersionConflictError, conservativeMongoIsDuplicateKey, conservativeMongoIsTransientConflict, createError, errorContractSchema, errorDetailSchema, isHttpError, isVersionConflictError, neverTransient, statusToErrorCode, toDuplicateKeyHttpError, toErrorContract };
@@ -1,7 +1,7 @@
1
+ import { ARC_ERROR_CODES, ARC_REASON_CODES, DUPLICATE_KEY_DETAIL_CODE, ERROR_CODES } from "./codes.mjs";
1
2
  import { VersionConflictError, conservativeMongoIsTransientConflict, isVersionConflictError, neverTransient } from "./conflict.mjs";
2
- import { ERROR_CODES } from "./types.mjs";
3
3
  import { statusToErrorCode, toErrorContract } from "./contract.mjs";
4
4
  import { createError, isHttpError } from "./create-error.mjs";
5
5
  import { conservativeMongoIsDuplicateKey, toDuplicateKeyHttpError } from "./duplicate-key.mjs";
6
6
  import { errorContractSchema, errorDetailSchema } from "./schema.mjs";
7
- export { ERROR_CODES, VersionConflictError, conservativeMongoIsDuplicateKey, conservativeMongoIsTransientConflict, createError, errorContractSchema, errorDetailSchema, isHttpError, isVersionConflictError, neverTransient, statusToErrorCode, toDuplicateKeyHttpError, toErrorContract };
7
+ export { ARC_ERROR_CODES, ARC_REASON_CODES, DUPLICATE_KEY_DETAIL_CODE, ERROR_CODES, VersionConflictError, conservativeMongoIsDuplicateKey, conservativeMongoIsTransientConflict, createError, errorContractSchema, errorDetailSchema, isHttpError, isVersionConflictError, neverTransient, statusToErrorCode, toDuplicateKeyHttpError, toErrorContract };
@@ -18,8 +18,8 @@
18
18
  * `implements HttpError` and serialize to `ErrorContract` for the wire.
19
19
  * One contract, one canonical home, every package follows the same shape.
20
20
  *
21
- * **Custom-domain escape hatch.** {@link ErrorCode} is a documented union
22
- * of canonical codes; `code: string` accepts ANY string so domain packages
21
+ * **Custom-domain escape hatch.** The catalogued codes live in `codes.ts`;
22
+ * `code: string` accepts ANY string so domain packages
23
23
  * can extend (`'order.validation.missing_line'`, `'payment.gateway.timeout'`).
24
24
  * The canonical codes cover cross-cutting concerns; domain extensions
25
25
  * hierarchically narrow.
@@ -124,32 +124,5 @@ interface ErrorDetail {
124
124
  message: string;
125
125
  meta?: Readonly<Record<string, unknown>>;
126
126
  }
127
- /**
128
- * Cross-cutting error codes used across the org. Every canonical code is
129
- * lowercase + snake_case to match RFC 7807, Stripe, and Shopify
130
- * conventions. Domain packages add their own hierarchical codes
131
- * (`'order.validation.*'`, `'payment.gateway.*'`); these cover the
132
- * universal cases every HTTP-emitting layer needs.
133
- *
134
- * **Arc compatibility note.** Arc's `ArcError` hierarchy historically
135
- * uses UPPER_SNAKE codes (`'NOT_FOUND'`, `'VALIDATION_ERROR'`) for
136
- * back-compat with hosts that switch on those values. New code should
137
- * prefer the canonical lowercase codes; arc keeps emitting its UPPER_SNAKE
138
- * codes on the wire so existing client switches keep working.
139
- */
140
- declare const ERROR_CODES: {
141
- readonly VALIDATION: 'validation_error';
142
- readonly NOT_FOUND: 'not_found';
143
- readonly CONFLICT: 'conflict';
144
- readonly UNAUTHORIZED: 'unauthorized';
145
- readonly FORBIDDEN: 'forbidden';
146
- readonly RATE_LIMITED: 'rate_limited';
147
- readonly IDEMPOTENCY_CONFLICT: 'idempotency_conflict';
148
- readonly PRECONDITION_FAILED: 'precondition_failed';
149
- readonly INTERNAL: 'internal_error';
150
- readonly UNAVAILABLE: 'service_unavailable';
151
- readonly TIMEOUT: 'timeout';
152
- };
153
- type ErrorCode = (typeof ERROR_CODES)[keyof typeof ERROR_CODES];
154
127
  //#endregion
155
- export { DuplicateKeyMeta, ERROR_CODES, ErrorCode, ErrorContract, ErrorDetail, HttpError, ValidationErrorMeta };
128
+ export { DuplicateKeyMeta, ErrorContract, ErrorDetail, HttpError, ValidationErrorMeta };