@classytic/repo-core 0.4.2 → 0.6.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.
@@ -1,4 +1,7 @@
1
1
  import { HookEngine } from "../hooks/engine.mjs";
2
+ import { HOOK_PRIORITY } from "../hooks/priority.mjs";
3
+ import { registerRepositoryEvents } from "../events/emit.mjs";
4
+ import { validateStandardSchema } from "../schema/standard-schema.mjs";
2
5
  import { validatePluginOrder } from "./plugin-types.mjs";
3
6
  //#region src/repository/base.ts
4
7
  /**
@@ -18,6 +21,28 @@ var RepositoryBase = class {
18
21
  for (let i = 0; i < plugins.length; i++) assertValidPlugin(plugins[i], this.modelName, i);
19
22
  validatePluginOrder(plugins, this.modelName, options.pluginOrderChecks ?? "warn", options.onPluginOrderWarning);
20
23
  for (const plugin of plugins) this.use(plugin);
24
+ if (options.schema) this._registerSchemaValidation(options.schema, options.updateSchema);
25
+ if (options.events) registerRepositoryEvents(this, options.events);
26
+ }
27
+ /**
28
+ * Wire Standard Schema validation into the write lifecycle. Validator
29
+ * output replaces the payload so schema-declared coercions and defaults
30
+ * flow into the write.
31
+ */
32
+ _registerSchemaValidation(schema, updateSchema) {
33
+ const validation = { priority: HOOK_PRIORITY.VALIDATION };
34
+ this.on("before:create", async (context) => {
35
+ if (context.data === void 0) return;
36
+ context.data = await validateStandardSchema(schema, context.data);
37
+ }, validation);
38
+ this.on("before:createMany", async (context) => {
39
+ if (!Array.isArray(context.dataArray)) return;
40
+ context.dataArray = await Promise.all(context.dataArray.map((doc) => validateStandardSchema(schema, doc)));
41
+ }, validation);
42
+ if (updateSchema) this.on("before:update", async (context) => {
43
+ if (context.data === void 0) return;
44
+ context.data = await validateStandardSchema(updateSchema, context.data);
45
+ }, validation);
21
46
  }
22
47
  /** Install a plugin (object with `apply(repo)` or a plain function). */
23
48
  use(plugin) {
@@ -0,0 +1,159 @@
1
+ //#region src/repository/capabilities.d.ts
2
+ /**
3
+ * Runtime capability descriptor — the feature-detection contract every
4
+ * kit declares so hosts (and arc) can branch on backend support at boot
5
+ * instead of discovering an `UnsupportedOperationError` at runtime.
6
+ *
7
+ * One shape, two consumers:
8
+ *
9
+ * - **Runtime**: `repo.capabilities.arrayOperators` tells a kit-portable
10
+ * host whether `$push` / `$pull` updates will work before it ships a
11
+ * write that throws on SQLite.
12
+ * - **Conformance**: the cross-kit test harness declares the same shape
13
+ * (`ConformanceFeatures` in `@classytic/repo-core/testing` is an alias
14
+ * of this type) — the flags a kit declares at runtime are exactly the
15
+ * scenarios the conformance suite exercises. One source of truth; the
16
+ * two can't drift.
17
+ *
18
+ * **Stability contract.** Adding a flag is additive — kits that don't
19
+ * declare a new optional key default to "not supported", the conservative
20
+ * read. Renaming or removing a flag is a breaking change.
21
+ *
22
+ * **Naming convention.** Flag names match the surface they gate
23
+ * (`percentile` → `AggMeasure.op === 'percentile'`, `changeStreams` →
24
+ * `StandardRepo.watch`). When in doubt, grep the contract types and use
25
+ * the same identifier.
26
+ */
27
+ /**
28
+ * Per-aggregate-op support matrix. Some aggregate ops aren't portable
29
+ * across every backend — `percentile` requires Mongo 7+'s `$percentile`
30
+ * accumulator or SQL's `PERCENTILE_CONT`, neither of which sqlitekit
31
+ * ships. Kits opt INTO support; absent keys mean "not supported".
32
+ */
33
+ interface AggregateOpsSupport {
34
+ /**
35
+ * `{ op: 'percentile', field, p }` measure. Mongokit (Mongo 7+)
36
+ * supports it; sqlitekit throws by design (no native function).
37
+ * Hosts targeting percentile dashboards pin to a kit that supports it.
38
+ */
39
+ percentile?: boolean;
40
+ /**
41
+ * `{ op: 'stddev', field }` / `{ op: 'stddevPop', field }` measures.
42
+ * Mongokit supports both via native `$stdDevSamp` / `$stdDevPop`
43
+ * (Welford). Sqlitekit throws — SQLite has no native STDDEV and
44
+ * the computational formula is numerically unstable. Hosts pin
45
+ * to mongokit / future pgkit when stddev is load-bearing.
46
+ */
47
+ stddev?: boolean;
48
+ /**
49
+ * `topN: { partitionBy, sortBy, limit, ties }` filter. Both
50
+ * mongokit and sqlitekit support it as of repo-core 0.4.x; the
51
+ * flag exists for future kits that may not ship window-function
52
+ * equivalents.
53
+ */
54
+ topN?: boolean;
55
+ /**
56
+ * `dateBuckets: { ..., interval: { every, unit } }` custom-bin
57
+ * form. Kits that only support named-bucket form can leave this
58
+ * `false`; tests for `'minute'` / `'hour'` named intervals are
59
+ * gated separately via `dateBucketSubMinute`.
60
+ */
61
+ customDateBuckets?: boolean;
62
+ /**
63
+ * Sub-day-granularity named buckets (`'minute'` / `'hour'`).
64
+ * Older kits may only support day+ named intervals; flag exists
65
+ * to gate those scenarios cleanly.
66
+ */
67
+ dateBucketSubMinute?: boolean;
68
+ /**
69
+ * Per-request `cache?: AggCacheOptions` slot — TTL / tags / SWR /
70
+ * bypass / `repo.invalidateAggregateCache(tags)`. Both mongokit
71
+ * and sqlitekit support it as of repo-core 0.4.x. Future kits
72
+ * without the wiring can leave this false to skip cache scenarios.
73
+ *
74
+ * Independent of which CACHE BACKEND the harness wires — test
75
+ * scenarios construct their own `createMemoryCacheAdapter()` so
76
+ * this flag is purely "does the kit honour the request slot".
77
+ */
78
+ cache?: boolean;
79
+ }
80
+ /**
81
+ * Per-kit capability flags. Every `StandardRepo` implementation declares
82
+ * one of these as `readonly capabilities` — the runtime twin of the
83
+ * conformance harness's feature declaration.
84
+ *
85
+ * Hosts that target multiple kits feature-detect once at boot:
86
+ *
87
+ * ```ts
88
+ * if (!repo.capabilities.arrayOperators) {
89
+ * // SQL kit — model tags as a join table instead of $push on a JSON column
90
+ * }
91
+ * ```
92
+ */
93
+ interface RepoCapabilities {
94
+ /** `withTransaction(fn)` — D1 throws, standalone Mongo throws 263. */
95
+ transactions: boolean;
96
+ /**
97
+ * True if calling `withTransaction` inside another `withTransaction`
98
+ * callback is expected to work. Mongo's driver supports it via the
99
+ * same session; SQL drivers typically reject it.
100
+ */
101
+ nestedTransactions: boolean;
102
+ /** `findOneAndUpdate` with upsert: true. */
103
+ upsert: boolean;
104
+ /** `isDuplicateKeyError(err)` classifier. */
105
+ duplicateKeyError: boolean;
106
+ /** `distinct(field)`. */
107
+ distinct: boolean;
108
+ /**
109
+ * Portable `aggregate({ measures, groupBy, having })`. Coarse
110
+ * top-level flag. Per-op flags live on `aggregateOps` for asymmetric
111
+ * capabilities (percentile, custom date bins, etc.).
112
+ */
113
+ aggregate: boolean;
114
+ /**
115
+ * Per-op feature matrix for the aggregate surface. Optional —
116
+ * absent matrix or absent key both mean "not supported", so kits
117
+ * opt INTO ops they implement.
118
+ */
119
+ aggregateOps?: AggregateOpsSupport;
120
+ /** `getOrCreate(filter, data)`. */
121
+ getOrCreate: boolean;
122
+ /** `count(filter)` and `exists(filter)`. */
123
+ countAndExists: boolean;
124
+ /**
125
+ * `purgeByField(field, value, strategy, options)` — compliance-grade
126
+ * tenant cleanup primitive.
127
+ */
128
+ purgeByField?: boolean;
129
+ /**
130
+ * Mongo-style array update operators (`$push`, `$pull`, `$addToSet`,
131
+ * `$pop`, `$pullAll`). Mongokit: native. Sqlitekit: implemented over
132
+ * JSON TEXT columns via `json_insert` / `json_each` rewrites — see
133
+ * the sqlitekit docs for the supported subset.
134
+ */
135
+ arrayOperators?: boolean;
136
+ /**
137
+ * Filter IR `regex` op. Mongokit: native `$regex`. Sqlitekit throws
138
+ * unless the host registers a `REGEXP` SQL function on the connection.
139
+ */
140
+ regexFilter?: boolean;
141
+ /**
142
+ * `watch(filter?)` change feed — `AsyncIterable<ChangeEvent<TDoc>>`.
143
+ * Mongokit: Mongo change streams (replica set required). Kits without
144
+ * a native feed leave this false and omit the method.
145
+ */
146
+ changeStreams?: boolean;
147
+ /**
148
+ * `lean: true` read option — return plain objects instead of driver
149
+ * documents. SQL kits return plain rows always (trivially true);
150
+ * mongokit opts in once reads honor the flag.
151
+ */
152
+ lean?: boolean;
153
+ /** Portable `lookupPopulate(options)` join IR. */
154
+ lookupPopulate?: boolean;
155
+ /** `cursor(filter, options)` streaming reads (AsyncIterable batches). */
156
+ streaming?: boolean;
157
+ }
158
+ //#endregion
159
+ export { AggregateOpsSupport, RepoCapabilities };
@@ -3,6 +3,9 @@ import { UpdateInput } from "../update/types.mjs";
3
3
  import { nestDottedKeys, nestDottedKeysAll } from "./agg-output.mjs";
4
4
  import { PLUGIN_ORDER_CONSTRAINTS, Plugin, PluginFunction, PluginType, validatePluginOrder } from "./plugin-types.mjs";
5
5
  import { RepositoryBase, RepositoryBaseOptions } from "./base.mjs";
6
+ import { AggregateOpsSupport, RepoCapabilities } from "./capabilities.mjs";
6
7
  import { STANDARD_REPO_OPTION_KEYS, StandardRepoOptionKey } from "./options.mjs";
7
- import { AggCacheOptions, AggDateBucket, AggDateBucketInterval, AggDateBucketUnit, AggExecutionHints, AggMeasure, AggPaginationRequest, AggRequest, AggResult, AggRow, AggTopN, AggTopNTies, BulkCreateResult, BulkWriteOperation, BulkWriteResult, ClaimTransition, ClaimVersionTransition, DeleteManyResult, DeleteOptions, DeleteResult, FilterInput, FindAllOptions, FindOneAndUpdateOptions, InferDoc, KeysetAggPaginationResult, MinimalRepo, PaginationParams, QueryOptions, RepositorySession, StandardRepo, UpdateManyResult, WriteOptions } from "./types.mjs";
8
- export { type AggCacheOptions, type AggDateBucket, type AggDateBucketInterval, type AggDateBucketUnit, type AggExecutionHints, type AggMeasure, type AggPaginationRequest, type AggRequest, type AggResult, type AggRow, type AggTopN, type AggTopNTies, type BulkCreateResult, type BulkWriteOperation, type BulkWriteResult, type ClaimTransition, type ClaimVersionTransition, type DeleteManyResult, type DeleteOptions, type DeleteResult, type FilterInput, type FindAllOptions, type FindOneAndUpdateOptions, type InferDoc, type KeysetAggPaginationResult, type LookupPopulateOptions, type LookupPopulateResult, type LookupRow, type LookupSpec, type MinimalRepo, PLUGIN_ORDER_CONSTRAINTS, type PaginationParams, type Plugin, type PluginFunction, type PluginType, type QueryOptions, RepositoryBase, type RepositoryBaseOptions, type RepositorySession, STANDARD_REPO_OPTION_KEYS, type StandardRepo, type StandardRepoOptionKey, type UpdateInput, type UpdateManyResult, type WriteOptions, nestDottedKeys, nestDottedKeysAll, validatePluginOrder };
8
+ import { RetryPolicy, throwIfAborted, withRetry } from "./resilience.mjs";
9
+ import { AggCacheOptions, AggDateBucket, AggDateBucketInterval, AggDateBucketUnit, AggExecutionHints, AggMeasure, AggPaginationRequest, AggRequest, AggResult, AggRow, AggTopN, AggTopNTies, BulkCreateResult, BulkWriteOperation, BulkWriteResult, ChangeEvent, ClaimTransition, ClaimVersionTransition, DeleteManyResult, DeleteOptions, DeleteResult, FilterInput, FindAllOptions, FindOneAndUpdateOptions, InferDoc, KeysetAggPaginationResult, MinimalRepo, PaginationParams, QueryOptions, RepositorySession, StandardRepo, TenantPurgeOptions, TenantPurgeProgress, TenantPurgeResult, TenantPurgeStrategy, UpdateManyResult, WatchOptions, WriteOptions } from "./types.mjs";
10
+ import { PurgePort, WritingPurgeStrategy, runChunkedPurge } from "./purge.mjs";
11
+ export { type AggCacheOptions, type AggDateBucket, type AggDateBucketInterval, type AggDateBucketUnit, type AggExecutionHints, type AggMeasure, type AggPaginationRequest, type AggRequest, type AggResult, type AggRow, type AggTopN, type AggTopNTies, type AggregateOpsSupport, type BulkCreateResult, type BulkWriteOperation, type BulkWriteResult, type ChangeEvent, type ClaimTransition, type ClaimVersionTransition, type DeleteManyResult, type DeleteOptions, type DeleteResult, type FilterInput, type FindAllOptions, type FindOneAndUpdateOptions, type InferDoc, type KeysetAggPaginationResult, type LookupPopulateOptions, type LookupPopulateResult, type LookupRow, type LookupSpec, type MinimalRepo, PLUGIN_ORDER_CONSTRAINTS, type PaginationParams, type Plugin, type PluginFunction, type PluginType, type PurgePort, type QueryOptions, type RepoCapabilities, RepositoryBase, type RepositoryBaseOptions, type RepositorySession, type RetryPolicy, STANDARD_REPO_OPTION_KEYS, type StandardRepo, type StandardRepoOptionKey, type TenantPurgeOptions, type TenantPurgeProgress, type TenantPurgeResult, type TenantPurgeStrategy, type UpdateInput, type UpdateManyResult, type WatchOptions, type WriteOptions, type WritingPurgeStrategy, nestDottedKeys, nestDottedKeysAll, runChunkedPurge, throwIfAborted, validatePluginOrder, withRetry };
@@ -2,4 +2,6 @@ import { nestDottedKeys, nestDottedKeysAll } from "./agg-output.mjs";
2
2
  import { PLUGIN_ORDER_CONSTRAINTS, validatePluginOrder } from "./plugin-types.mjs";
3
3
  import { RepositoryBase } from "./base.mjs";
4
4
  import { STANDARD_REPO_OPTION_KEYS } from "./options.mjs";
5
- export { PLUGIN_ORDER_CONSTRAINTS, RepositoryBase, STANDARD_REPO_OPTION_KEYS, nestDottedKeys, nestDottedKeysAll, validatePluginOrder };
5
+ import { throwIfAborted, withRetry } from "./resilience.mjs";
6
+ import { runChunkedPurge } from "./purge.mjs";
7
+ export { PLUGIN_ORDER_CONSTRAINTS, RepositoryBase, STANDARD_REPO_OPTION_KEYS, nestDottedKeys, nestDottedKeysAll, runChunkedPurge, throwIfAborted, validatePluginOrder, withRetry };
@@ -40,13 +40,17 @@
40
40
  * boundary.
41
41
  * - `requestId` — request correlation id for trace stitching across
42
42
  * logs, events, and downstream service calls.
43
+ * - `traceId` — distributed-tracing trace id (W3C traceparent /
44
+ * OpenTelemetry). Observability plugins read it to join repo spans
45
+ * onto the host's trace; distinct from `requestId`, which is the
46
+ * host's own correlation id and may outlive a single trace.
43
47
  *
44
48
  * Frameworks should treat this set as the canonical forward list:
45
49
  * peel matching keys off the request context, drop them into the
46
50
  * options bag, and let kit plugins read what they implement. Unknown
47
51
  * ctx keys do NOT forward — the bag stays narrow.
48
52
  */
49
- declare const STANDARD_REPO_OPTION_KEYS: readonly ["organizationId", "userId", "user", "session", "requestId"];
53
+ declare const STANDARD_REPO_OPTION_KEYS: readonly ["organizationId", "userId", "user", "session", "requestId", "traceId"];
50
54
  /**
51
55
  * Type-level union of canonical option keys. Use to constrain
52
56
  * framework helpers that thread request context into repo options:
@@ -40,6 +40,10 @@
40
40
  * boundary.
41
41
  * - `requestId` — request correlation id for trace stitching across
42
42
  * logs, events, and downstream service calls.
43
+ * - `traceId` — distributed-tracing trace id (W3C traceparent /
44
+ * OpenTelemetry). Observability plugins read it to join repo spans
45
+ * onto the host's trace; distinct from `requestId`, which is the
46
+ * host's own correlation id and may outlive a single trace.
43
47
  *
44
48
  * Frameworks should treat this set as the canonical forward list:
45
49
  * peel matching keys off the request context, drop them into the
@@ -51,7 +55,8 @@ const STANDARD_REPO_OPTION_KEYS = [
51
55
  "userId",
52
56
  "user",
53
57
  "session",
54
- "requestId"
58
+ "requestId",
59
+ "traceId"
55
60
  ];
56
61
  //#endregion
57
62
  export { STANDARD_REPO_OPTION_KEYS };
@@ -0,0 +1,57 @@
1
+ import { TenantPurgeOptions, TenantPurgeResult, TenantPurgeStrategy } from "./types.mjs";
2
+
3
+ //#region src/repository/purge.d.ts
4
+ /**
5
+ * Strategies that perform a write. `skip` is handled by the orchestrator
6
+ * before the port is ever consulted, so ports only deal with the three
7
+ * writing variants.
8
+ */
9
+ type WritingPurgeStrategy = Exclude<TenantPurgeStrategy, {
10
+ type: 'skip';
11
+ }>;
12
+ /**
13
+ * Driver-facing port the orchestrator drives. Each kit implements one
14
+ * closure over its driver primitives + the purge predicate.
15
+ *
16
+ * **Plugin-bypass invariant.** Implementations MUST bypass tenant
17
+ * scoping in plugin hooks — the caller's `field = value` predicate IS
18
+ * the authoritative scope; a tenant-injecting hook would narrow to the
19
+ * wrong tenant. Pass `bypassTenant: true` on inner Repository calls
20
+ * (which keeps audit / cache hooks active but disables tenant injection).
21
+ *
22
+ * **Throughput contract.** Implementations should issue the minimum
23
+ * number of round-trips a chunk requires:
24
+ *
25
+ * - `hard` on SQLite: `DELETE FROM t WHERE field = ? LIMIT n` — 1 RT
26
+ * - `hard` on Mongo: `find(filter, {_id:1}).limit(n)` + `deleteMany`
27
+ * — 2 RTs (Mongo has no DELETE LIMIT)
28
+ * - `soft`: read ids + updateMany with `$set: {deleted, deletedAt}` — 2 RTs
29
+ * - `anonymize` static fields: read ids + updateMany — 2 RTs
30
+ * - `anonymize` with function-form replacers: read docs +
31
+ * `bulkWrite([updateOne, …])` — 2 RTs (vs N+1 with per-doc fan-out)
32
+ */
33
+ interface PurgePort {
34
+ /**
35
+ * Process one chunk under the given strategy. Returns the row count
36
+ * actually touched (≤ `limit`). The orchestrator loops until this
37
+ * returns less than `limit` (natural exit) or the abort signal fires.
38
+ *
39
+ * Returning `0` signals "no more matching rows"; the orchestrator
40
+ * exits. Returning a partial batch (`< limit`) is also a terminal
41
+ * signal — saves one round-trip on the last chunk.
42
+ */
43
+ purgeChunk(strategy: WritingPurgeStrategy, limit: number): Promise<number>;
44
+ }
45
+ /**
46
+ * Drive a chunked purge to completion. Returns a `TenantPurgeResult`
47
+ * envelope describing what happened — never throws for in-strategy
48
+ * errors (those wrap into `result.error`); only throws for invalid
49
+ * input (`batchSize < 1`).
50
+ *
51
+ * @param strategy Strategy declaration — `skip` short-circuits.
52
+ * @param options Chunking + signal + progress + optional retry.
53
+ * @param port Kit-specific driver glue (one `purgeChunk` method).
54
+ */
55
+ declare function runChunkedPurge(strategy: TenantPurgeStrategy, options: TenantPurgeOptions, port: PurgePort): Promise<TenantPurgeResult>;
56
+ //#endregion
57
+ export { PurgePort, WritingPurgeStrategy, runChunkedPurge };
@@ -0,0 +1,83 @@
1
+ import { withRetry } from "./resilience.mjs";
2
+ //#region src/repository/purge.ts
3
+ /**
4
+ * Chunked tenant-purge orchestrator — kit-agnostic.
5
+ *
6
+ * Owns the loop / signal / progress / retry / error envelope for
7
+ * `StandardRepo.purgeByField`. Each kit (mongokit, sqlitekit, future
8
+ * pgkit) plugs in a `PurgePort` that knows how to talk to its driver;
9
+ * the orchestrator drives the chunked work.
10
+ *
11
+ * **Why a single-method port** (`purgeChunk(strategy, limit)`): each
12
+ * driver has different round-trip optima — sqlite hard-strategy compiles
13
+ * to one `DELETE … LIMIT` (no SELECT), mongo hard-strategy needs SELECT
14
+ * + deleteMany, anonymize-with-function-form needs SELECT + bulkWrite
15
+ * to batch heterogeneous patches in one round-trip. A two-method port
16
+ * (`selectChunkIds` + `applyStrategy`) forces 2 round-trips for every
17
+ * kit; the single method lets each port pick its own access shape.
18
+ *
19
+ * Hexagonal pattern: the orchestrator is the use-case; `PurgePort` is
20
+ * the driving port; each kit's port factory is the adapter.
21
+ */
22
+ /**
23
+ * Drive a chunked purge to completion. Returns a `TenantPurgeResult`
24
+ * envelope describing what happened — never throws for in-strategy
25
+ * errors (those wrap into `result.error`); only throws for invalid
26
+ * input (`batchSize < 1`).
27
+ *
28
+ * @param strategy Strategy declaration — `skip` short-circuits.
29
+ * @param options Chunking + signal + progress + optional retry.
30
+ * @param port Kit-specific driver glue (one `purgeChunk` method).
31
+ */
32
+ async function runChunkedPurge(strategy, options, port) {
33
+ const start = Date.now();
34
+ if (strategy.type === "skip") return {
35
+ strategy: "skip",
36
+ processed: 0,
37
+ ok: true,
38
+ durationMs: Date.now() - start,
39
+ skipReason: strategy.reason
40
+ };
41
+ const batchSize = options.batchSize ?? 1e3;
42
+ if (!Number.isInteger(batchSize) || batchSize < 1) throw new Error("purgeByField: batchSize must be a positive integer");
43
+ const retry = options.retry;
44
+ let processed = 0;
45
+ try {
46
+ while (true) {
47
+ if (options.signal?.aborted) return {
48
+ strategy: strategy.type,
49
+ processed,
50
+ ok: false,
51
+ durationMs: Date.now() - start
52
+ };
53
+ const chunkSize = await withRetry(() => port.purgeChunk(strategy, batchSize), retry, options.signal);
54
+ if (chunkSize === 0) break;
55
+ processed += chunkSize;
56
+ if (options.onProgress) await options.onProgress({
57
+ processed,
58
+ chunkSize,
59
+ elapsedMs: Date.now() - start
60
+ });
61
+ if (chunkSize < batchSize) break;
62
+ }
63
+ } catch (err) {
64
+ return {
65
+ strategy: strategy.type,
66
+ processed,
67
+ ok: false,
68
+ durationMs: Date.now() - start,
69
+ error: {
70
+ message: err instanceof Error ? err.message : String(err),
71
+ chunkOffset: processed
72
+ }
73
+ };
74
+ }
75
+ return {
76
+ strategy: strategy.type,
77
+ processed,
78
+ ok: true,
79
+ durationMs: Date.now() - start
80
+ };
81
+ }
82
+ //#endregion
83
+ export { runChunkedPurge };
@@ -0,0 +1,55 @@
1
+ //#region src/repository/resilience.d.ts
2
+ /**
3
+ * Resilience primitives — the single retry/abort contract every kit and
4
+ * every chunked orchestrator (purge, batch imports, outbox relays) uses.
5
+ *
6
+ * One `RetryPolicy` shape across the contract: `QueryOptions.retryPolicy`,
7
+ * `TenantPurgeOptions.retry`, and any kit-internal retry loop all accept
8
+ * the same three knobs. One `withRetry` implementation so backoff math
9
+ * never drifts between call sites.
10
+ */
11
+ /**
12
+ * Retry policy for transient failures (network blips, write conflicts,
13
+ * busy-locks, connection resets).
14
+ *
15
+ * **Don't retry blindly.** Validation errors, schema errors, permission
16
+ * errors are NOT transient — retrying just delays the same failure.
17
+ * Mongo `WriteConflict`, SQLite `SQLITE_BUSY`, `ECONNRESET` ARE transient
18
+ * — backoff + retry recovers. Pass `shouldRetry` to narrow when you know
19
+ * your driver's error taxonomy:
20
+ *
21
+ * ```ts
22
+ * retryPolicy: {
23
+ * maxAttempts: 3, // default 3 when block present
24
+ * baseDelayMs: 100, // exponential: 100ms, 200ms, 400ms
25
+ * shouldRetry: (err) =>
26
+ * /WriteConflict|SQLITE_BUSY|ECONNRESET/i.test(String(err)),
27
+ * }
28
+ * ```
29
+ */
30
+ interface RetryPolicy {
31
+ /** Max attempts (including the first try). Default 3 when a policy is present. */
32
+ maxAttempts?: number;
33
+ /** Base delay (ms) for exponential backoff. Default 100ms; doubles each attempt. */
34
+ baseDelayMs?: number;
35
+ /** Decide whether a given error is transient. Default: retry every error. */
36
+ shouldRetry?: (err: unknown, attempt: number) => boolean;
37
+ }
38
+ /**
39
+ * Run `fn` with exponential backoff when a policy is provided. Falls
40
+ * through to a single attempt when `policy` is undefined — callers wrap
41
+ * unconditionally and the no-policy path costs nothing.
42
+ *
43
+ * Honors `signal`: aborts between attempts (never mid-attempt) by
44
+ * rethrowing the signal's abort reason.
45
+ */
46
+ declare function withRetry<T>(fn: () => Promise<T>, policy: RetryPolicy | undefined, signal?: AbortSignal): Promise<T>;
47
+ /**
48
+ * Abort guard for op boundaries. Kits call this at the top of every
49
+ * operation (and between chunks of chunked work) when the caller passed
50
+ * `options.signal` — cancelled requests stop before the next driver
51
+ * round-trip instead of running to completion.
52
+ */
53
+ declare function throwIfAborted(signal: AbortSignal | undefined): void;
54
+ //#endregion
55
+ export { RetryPolicy, throwIfAborted, withRetry };
@@ -0,0 +1,39 @@
1
+ //#region src/repository/resilience.ts
2
+ /**
3
+ * Run `fn` with exponential backoff when a policy is provided. Falls
4
+ * through to a single attempt when `policy` is undefined — callers wrap
5
+ * unconditionally and the no-policy path costs nothing.
6
+ *
7
+ * Honors `signal`: aborts between attempts (never mid-attempt) by
8
+ * rethrowing the signal's abort reason.
9
+ */
10
+ async function withRetry(fn, policy, signal) {
11
+ if (!policy) return fn();
12
+ const maxAttempts = policy.maxAttempts ?? 3;
13
+ const baseDelayMs = policy.baseDelayMs ?? 100;
14
+ const shouldRetry = policy.shouldRetry ?? (() => true);
15
+ let lastErr;
16
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
17
+ signal?.throwIfAborted();
18
+ try {
19
+ return await fn();
20
+ } catch (err) {
21
+ lastErr = err;
22
+ if (attempt === maxAttempts - 1) break;
23
+ if (!shouldRetry(err, attempt + 1)) break;
24
+ await new Promise((r) => setTimeout(r, baseDelayMs * 2 ** attempt));
25
+ }
26
+ }
27
+ throw lastErr instanceof Error ? lastErr : new Error(String(lastErr));
28
+ }
29
+ /**
30
+ * Abort guard for op boundaries. Kits call this at the top of every
31
+ * operation (and between chunks of chunked work) when the caller passed
32
+ * `options.signal` — cancelled requests stop before the next driver
33
+ * round-trip instead of running to completion.
34
+ */
35
+ function throwIfAborted(signal) {
36
+ signal?.throwIfAborted();
37
+ }
38
+ //#endregion
39
+ export { throwIfAborted, withRetry };