@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.
- package/CHANGELOG.md +69 -0
- package/README.md +65 -3
- package/dist/events/emit.d.mts +22 -0
- package/dist/events/emit.mjs +105 -0
- package/dist/events/index.d.mts +3 -0
- package/dist/events/index.mjs +2 -0
- package/dist/events/types.d.mts +73 -0
- package/dist/filter/from-record.d.mts +13 -0
- package/dist/filter/from-record.mjs +135 -0
- package/dist/filter/index.d.mts +2 -1
- package/dist/filter/index.mjs +2 -1
- package/dist/hooks/priority.d.mts +7 -1
- package/dist/hooks/priority.mjs +1 -0
- package/dist/repository/base.d.mts +32 -0
- package/dist/repository/base.mjs +25 -0
- package/dist/repository/capabilities.d.mts +159 -0
- package/dist/repository/index.d.mts +5 -2
- package/dist/repository/index.mjs +3 -1
- package/dist/repository/options.d.mts +5 -1
- package/dist/repository/options.mjs +6 -1
- package/dist/repository/purge.d.mts +57 -0
- package/dist/repository/purge.mjs +83 -0
- package/dist/repository/resilience.d.mts +55 -0
- package/dist/repository/resilience.mjs +39 -0
- package/dist/repository/types.d.mts +243 -1
- package/dist/schema/index.d.mts +2 -1
- package/dist/schema/index.mjs +2 -1
- package/dist/schema/standard-schema.d.mts +89 -0
- package/dist/schema/standard-schema.mjs +59 -0
- package/dist/testing/conformance.mjs +144 -0
- package/dist/testing/index.d.mts +2 -1
- package/dist/testing/types.d.mts +12 -105
- package/package.json +5 -1
package/dist/repository/base.mjs
CHANGED
|
@@ -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 {
|
|
8
|
-
|
|
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
|
-
|
|
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 };
|