@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.
@@ -2,7 +2,9 @@ import { Filter } from "../filter/types.mjs";
2
2
  import { OffsetPaginationResult } from "../pagination/types.mjs";
3
3
  import { LookupPopulateOptions, LookupPopulateResult, LookupSpec } from "../lookup/types.mjs";
4
4
  import { UpdateInput } from "../update/types.mjs";
5
+ import { RepoCapabilities } from "./capabilities.mjs";
5
6
  import { CacheOptions } from "../cache/options.mjs";
7
+ import { RetryPolicy } from "./resilience.mjs";
6
8
 
7
9
  //#region src/repository/types.d.ts
8
10
  /**
@@ -51,6 +53,18 @@ interface QueryOptions {
51
53
  user?: Record<string, unknown>;
52
54
  /** Arc request context (orgId, roles, requestId, ...). */
53
55
  context?: Record<string, unknown>;
56
+ /**
57
+ * Abort signal. Kits check it at the op boundary (and between chunks of
58
+ * chunked work) — cancelled requests stop before the next driver
59
+ * round-trip. Aborting never rolls back a write that already committed.
60
+ */
61
+ signal?: AbortSignal;
62
+ /**
63
+ * Retry transient driver failures with exponential backoff. Same shape
64
+ * everywhere (`TenantPurgeOptions.retry`, kit-internal loops) — see
65
+ * {@link RetryPolicy}. Default: no retry.
66
+ */
67
+ retryPolicy?: RetryPolicy;
54
68
  /** Driver-specific escape hatch — see JSDoc. */
55
69
  [key: string]: unknown;
56
70
  }
@@ -103,6 +117,127 @@ interface FindOneAndUpdateOptions extends QueryOptions {
103
117
  /** Insert when no doc matches. Default: false. */
104
118
  upsert?: boolean;
105
119
  }
120
+ /**
121
+ * Strategy for processing rows matched by a tenant/scope field — the
122
+ * decision every multi-tenant host makes when an organization (or any
123
+ * tenant) is deleted. Each variant maps to a kit-native primitive;
124
+ * arc's `createOrgDeleteCascade` orchestrates the per-resource declarations.
125
+ *
126
+ * **Why a discriminated union (not an enum)**: each strategy carries
127
+ * its own arguments — `fields` for anonymize, `reason` for skip. The
128
+ * union forces callers to supply them at the type level rather than
129
+ * fail at runtime.
130
+ *
131
+ * **Compliance shapes covered**:
132
+ * - `hard` — GDPR right-to-be-forgotten, SOC 2 deletion timelines.
133
+ * - `soft` — recoverable deletes within audit retention windows; pairs
134
+ * with TTL indexes (MongoDB `expireAfterSeconds` on `deletedAt`) for
135
+ * eventual hard-purge.
136
+ * - `anonymize` — records that legally must outlive the tenant (audit
137
+ * ledgers, financial records, medical history) but must lose PII
138
+ * linkage. HIPAA / PCI / SOX-compatible.
139
+ * - `skip` — explicitly opt out, with a mandatory `reason` that surfaces
140
+ * in audit reports.
141
+ */
142
+ type TenantPurgeStrategy =
143
+ /**
144
+ * Permanently remove every matching row.
145
+ */
146
+ {
147
+ type: 'hard';
148
+ }
149
+ /**
150
+ * Mark every matching row as deleted via the soft-delete convention.
151
+ * Pair with `softDeletePlugin` or a TTL index for eventual cleanup.
152
+ */
153
+ | {
154
+ type: 'soft'; /** Boolean flag field set to `true`. Default `'deleted'`. */
155
+ deletedField?: string; /** Timestamp field set to purge-time. Default `'deletedAt'`. */
156
+ deletedAtField?: string;
157
+ }
158
+ /**
159
+ * Retain every matching row but overwrite the declared fields. Field
160
+ * values can be static (preferred — statically inspectable for audit)
161
+ * or per-row functions when deterministic transforms are needed
162
+ * (hashing, derived identifiers).
163
+ */
164
+ | {
165
+ type: 'anonymize';
166
+ fields: Record<string, unknown | ((doc: Record<string, unknown>) => unknown)>;
167
+ }
168
+ /**
169
+ * Take no action. `reason` is required — an undocumented skip is a
170
+ * silent compliance leak. Surfaces in audit reports + introspection.
171
+ */
172
+ | {
173
+ type: 'skip';
174
+ reason: string;
175
+ };
176
+ /**
177
+ * Per-call options for `purgeByField`. Chunking is required for
178
+ * correctness on large tenant datasets; the kit implementation MUST
179
+ * honor `batchSize` to avoid OOM / lock contention.
180
+ */
181
+ interface TenantPurgeOptions {
182
+ /** Rows per batch. Default kit-specific (typically 1000). */
183
+ batchSize?: number;
184
+ /** Driver session for transactional callers. */
185
+ session?: RepositorySession;
186
+ /** Per-chunk progress callback. `processed` is cumulative. */
187
+ onProgress?: (event: TenantPurgeProgress) => void | Promise<void>;
188
+ /**
189
+ * Abort signal. Kits MUST check between chunks and finalize with the
190
+ * cumulative `processed` count when aborted (no rollback — chunks
191
+ * already committed remain committed; this is at-least-once cleanup).
192
+ */
193
+ signal?: AbortSignal;
194
+ /**
195
+ * Retry transient chunk-level failures (network blips, write
196
+ * conflicts, busy-locks). Default `undefined` → no retry: first
197
+ * chunk error aborts the run. Hosts opt in for robustness:
198
+ *
199
+ * ```ts
200
+ * retry: {
201
+ * maxAttempts: 3, // default 3 when block present
202
+ * baseDelayMs: 100, // exponential: 100ms, 200ms, 400ms
203
+ * shouldRetry: (err) => // optional: narrow retry to transient
204
+ * /WriteConflict|SQLITE_BUSY|ECONNRESET/i.test(String(err)),
205
+ * }
206
+ * ```
207
+ *
208
+ * The retry happens at the CHUNK level — already-committed chunks
209
+ * stay committed. A retry that eventually succeeds reports `ok: true`;
210
+ * one that exhausts `maxAttempts` aborts with the underlying error.
211
+ */
212
+ retry?: RetryPolicy;
213
+ }
214
+ /** Chunk-level progress event for `purgeByField`. */
215
+ interface TenantPurgeProgress {
216
+ /** Rows processed so far (cumulative across chunks). */
217
+ processed: number;
218
+ /** Rows in the chunk that just completed. */
219
+ chunkSize: number;
220
+ /** Wall-clock ms elapsed since the call started. */
221
+ elapsedMs: number;
222
+ }
223
+ /** Final result of a `purgeByField` invocation. */
224
+ interface TenantPurgeResult {
225
+ /** Strategy that actually executed (echoes input.type). */
226
+ strategy: TenantPurgeStrategy['type'];
227
+ /** Total rows processed (0 for `skip`). */
228
+ processed: number;
229
+ /** True iff the call completed without abort / error. */
230
+ ok: boolean;
231
+ /** Wall-clock ms. */
232
+ durationMs: number;
233
+ /** First error if `ok: false`. Kits abort the run on a chunk failure. */
234
+ error?: {
235
+ message: string;
236
+ chunkOffset: number;
237
+ };
238
+ /** Echoed for `skip` strategy. Undefined for other strategies. */
239
+ skipReason?: string;
240
+ }
106
241
  /**
107
242
  * Transition spec for `StandardRepo.claim()` — a CAS state change.
108
243
  *
@@ -1142,6 +1277,18 @@ interface MinimalRepo<TDoc> {
1142
1277
  * kit-native.
1143
1278
  */
1144
1279
  interface StandardRepo<TDoc> extends MinimalRepo<TDoc> {
1280
+ /**
1281
+ * Runtime capability descriptor — feature-detection at boot instead of
1282
+ * `UnsupportedOperationError` at runtime. Required: every kit declares
1283
+ * what its backend supports (`arrayOperators`, `changeStreams`,
1284
+ * `aggregateOps.percentile`, ...) so kit-portable hosts and arc can
1285
+ * branch once instead of try/catching per call.
1286
+ *
1287
+ * The same shape gates the cross-kit conformance suite
1288
+ * (`ConformanceFeatures` is an alias) — runtime declaration and test
1289
+ * coverage cannot drift.
1290
+ */
1291
+ readonly capabilities: RepoCapabilities;
1145
1292
  /**
1146
1293
  * Atomic compare-and-set. Match one document, mutate it, return the
1147
1294
  * post-update doc (or pre-update when `returnDocument: 'before'`).
@@ -1328,6 +1475,56 @@ interface StandardRepo<TDoc> extends MinimalRepo<TDoc> {
1328
1475
  * **Promoted from optional to required in repo-core 0.2.0.**
1329
1476
  */
1330
1477
  deleteMany(filter: FilterInput, options?: DeleteOptions): Promise<DeleteManyResult>;
1478
+ /**
1479
+ * Compliance-grade cleanup primitive — processes every row matching
1480
+ * `field = value` under the given strategy. Powers tenant-scoped
1481
+ * data cleanup (org delete → cascade across every multi-tenant
1482
+ * resource) without forcing each consumer to hand-roll `deleteMany`
1483
+ * + chunking + audit + idempotency.
1484
+ *
1485
+ * Strategy → kit-native primitive:
1486
+ * - `hard` → chunked `deleteMany({ [field]: value })`
1487
+ * - `soft` → chunked `updateMany` setting deleted/deletedAt
1488
+ * - `anonymize` → chunked `updateMany` applying the field map per-row
1489
+ * - `skip` → no-op; `result.skipReason` echoes the declared reason
1490
+ *
1491
+ * **Chunking is mandatory.** Implementations MUST honor `batchSize`
1492
+ * — a 10M-row tenant cleanup can't run as a single `deleteMany`
1493
+ * (lock contention, oplog blowup, replication lag). Kits process
1494
+ * rows in chunks of `batchSize` (default ~1000) and emit per-chunk
1495
+ * `onProgress` events.
1496
+ *
1497
+ * **Index requirement — load-bearing for tractability.** The store
1498
+ * MUST have an index leading with `field` (single-field
1499
+ * `{ [field]: 1 }`, or a compound index whose first column is
1500
+ * `field`). Without it, every chunk's selection runs a full
1501
+ * collection / table scan — purge becomes O(n²) on large tenants
1502
+ * and can lock the table for minutes. Verify before shipping:
1503
+ * - mongo: `db.coll.getIndexes()` shows an index keyed on `field`.
1504
+ * - sqlite: `EXPLAIN QUERY PLAN SELECT … WHERE field = ?` shows
1505
+ * `SEARCH … USING INDEX`, never `SCAN`.
1506
+ *
1507
+ * **Idempotent.** Re-running with the same arguments is safe — rows
1508
+ * already deleted/anonymized simply don't match the next pass.
1509
+ * Crucial for at-least-once cascade workers that may retry after
1510
+ * partial failure.
1511
+ *
1512
+ * **Plugin composition.** Kits route the underlying chunked ops
1513
+ * through their standard `before:deleteMany` / `before:updateMany`
1514
+ * hooks so audit / cache-invalidation / observability plugins fire
1515
+ * naturally — no separate `before:purgeByField` hook is required.
1516
+ *
1517
+ * **Optional method.** Kits without bulk-cleanup needs leave this
1518
+ * undefined. Arc's `createOrgDeleteCascade` checks for the method
1519
+ * at boot and emits a clear error naming the offending resource if
1520
+ * a tenant-flagged resource's repo lacks it.
1521
+ *
1522
+ * @param field Document field to match against (e.g. `'organizationId'`).
1523
+ * @param value Value the field must equal (e.g. the deleted org id).
1524
+ * @param strategy Strategy declaration — see {@link TenantPurgeStrategy}.
1525
+ * @param options Chunking, session, progress, abort signal.
1526
+ */
1527
+ purgeByField?(field: string, value: unknown, strategy: TenantPurgeStrategy, options?: TenantPurgeOptions): Promise<TenantPurgeResult>;
1331
1528
  /**
1332
1529
  * Heterogeneous bulk write. Stays optional — kits dispatch each op
1333
1530
  * against the appropriate driver primitive inside a single transaction;
@@ -1425,6 +1622,51 @@ interface StandardRepo<TDoc> extends MinimalRepo<TDoc> {
1425
1622
  * ```
1426
1623
  */
1427
1624
  withTransaction?<T>(fn: (txRepo: StandardRepo<TDoc>) => Promise<T>, options?: Record<string, unknown>): Promise<T>;
1625
+ /**
1626
+ * Portable change feed — `for await` over committed mutations:
1627
+ *
1628
+ * ```ts
1629
+ * for await (const change of repo.watch!({ status: 'pending' })) {
1630
+ * if (change.operation === 'create') enqueue(change.doc!);
1631
+ * }
1632
+ * ```
1633
+ *
1634
+ * Backends differ wildly here, so the method is optional and gated by
1635
+ * `capabilities.changeStreams`:
1636
+ * - mongokit — Mongo change streams (`Model.watch`); requires a
1637
+ * replica set. `fullDocument: 'updateLookup'` semantics for updates.
1638
+ * - SQL kits — typically absent (no native feed). Hosts that need
1639
+ * a feed on SQL pair the repo with `events` emission instead.
1640
+ *
1641
+ * The iterator ends when `options.signal` aborts. Errors from the
1642
+ * underlying stream propagate to the consumer.
1643
+ */
1644
+ watch?(filter?: FilterInput, options?: WatchOptions): AsyncIterable<ChangeEvent<TDoc>>;
1645
+ }
1646
+ /** A single committed mutation observed by `watch()`. */
1647
+ interface ChangeEvent<TDoc = unknown> {
1648
+ /** What happened. `replace` = full-document overwrite (Mongo `replaceOne`). */
1649
+ operation: 'create' | 'update' | 'delete' | 'replace';
1650
+ /** Primary key of the affected document. */
1651
+ id?: unknown;
1652
+ /**
1653
+ * The post-image document — present on create/replace always, on update
1654
+ * when the backend supports post-image lookup, absent on delete.
1655
+ */
1656
+ doc?: TDoc;
1657
+ /** Commit timestamp as reported by the backend. */
1658
+ timestamp: Date;
1659
+ }
1660
+ /** Options for `StandardRepo.watch()`. */
1661
+ interface WatchOptions {
1662
+ /** End the iterator. The only portable way to stop a change feed. */
1663
+ signal?: AbortSignal;
1664
+ /**
1665
+ * Resume token / cursor from a previous stream (kit-specific shape —
1666
+ * Mongo resume tokens are opaque BSON). Hosts persist and replay it
1667
+ * for at-least-once consumption across restarts.
1668
+ */
1669
+ resumeAfter?: unknown;
1428
1670
  }
1429
1671
  //#endregion
1430
- export { 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 };
1672
+ export { 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 };
@@ -1,4 +1,5 @@
1
+ import { StandardSchemaV1, validateStandardSchema } from "./standard-schema.mjs";
1
2
  import { CrudSchemas, FieldRule, FieldRules, JsonSchema, SchemaBuilderOptions, ValidationResult } from "./types.mjs";
2
3
  import { applyFieldRules, applyNullable, collectFieldsToOmit, getImmutableFields, getSystemManagedFields, isFieldUpdateAllowed, mergeFieldRuleConstraints, validateUpdateBody } from "./field-rules.mjs";
3
4
  import { SchemaGenerator, SchemaGeneratorContext, isSchemaGenerator } from "./generator.mjs";
4
- export { type CrudSchemas, type FieldRule, type FieldRules, type JsonSchema, type SchemaBuilderOptions, type SchemaGenerator, type SchemaGeneratorContext, type ValidationResult, applyFieldRules, applyNullable, collectFieldsToOmit, getImmutableFields, getSystemManagedFields, isFieldUpdateAllowed, isSchemaGenerator, mergeFieldRuleConstraints, validateUpdateBody };
5
+ export { type CrudSchemas, type FieldRule, type FieldRules, type JsonSchema, type SchemaBuilderOptions, type SchemaGenerator, type SchemaGeneratorContext, type StandardSchemaV1, type ValidationResult, applyFieldRules, applyNullable, collectFieldsToOmit, getImmutableFields, getSystemManagedFields, isFieldUpdateAllowed, isSchemaGenerator, mergeFieldRuleConstraints, validateStandardSchema, validateUpdateBody };
@@ -1,3 +1,4 @@
1
+ import { validateStandardSchema } from "./standard-schema.mjs";
1
2
  import { applyFieldRules, applyNullable, collectFieldsToOmit, getImmutableFields, getSystemManagedFields, isFieldUpdateAllowed, mergeFieldRuleConstraints, validateUpdateBody } from "./field-rules.mjs";
2
3
  import { isSchemaGenerator } from "./generator.mjs";
3
- export { applyFieldRules, applyNullable, collectFieldsToOmit, getImmutableFields, getSystemManagedFields, isFieldUpdateAllowed, isSchemaGenerator, mergeFieldRuleConstraints, validateUpdateBody };
4
+ export { applyFieldRules, applyNullable, collectFieldsToOmit, getImmutableFields, getSystemManagedFields, isFieldUpdateAllowed, isSchemaGenerator, mergeFieldRuleConstraints, validateStandardSchema, validateUpdateBody };
@@ -0,0 +1,89 @@
1
+ //#region src/schema/standard-schema.d.ts
2
+ /**
3
+ * Standard Schema integration — the validator-agnostic validation slot.
4
+ *
5
+ * [Standard Schema](https://standardschema.dev) is the shared interface
6
+ * implemented by Zod 3.24+, Valibot 1.0+, ArkType 2.0+, Effect Schema and
7
+ * others. Vendoring the interface (officially encouraged — it's a
8
+ * types-only spec designed to be copied) keeps repo-core's zero-dependency
9
+ * guarantee while letting hosts plug ANY conforming validator into a
10
+ * repository:
11
+ *
12
+ * ```ts
13
+ * import { z } from 'zod';
14
+ *
15
+ * const repo = createRepository(UserModel, {
16
+ * schema: z.object({ name: z.string(), email: z.string().email() }),
17
+ * });
18
+ * await repo.create({ name: 1 }); // throws HttpError 400 with validationErrors
19
+ * ```
20
+ *
21
+ * `RepositoryBase` wires `schema` / `updateSchema` into `before:create` /
22
+ * `before:createMany` / `before:update` hooks at `HOOK_PRIORITY.VALIDATION`
23
+ * — after policy plugins (so tenant-stamped fields are present) and before
24
+ * cache/observability.
25
+ */
26
+ /** The Standard Schema interface. Any conforming validator satisfies it. */
27
+ interface StandardSchemaV1<Input = unknown, Output = Input> {
28
+ /** The Standard Schema properties. */
29
+ readonly '~standard': StandardSchemaV1.Props<Input, Output>;
30
+ }
31
+ declare namespace StandardSchemaV1 {
32
+ /** The Standard Schema properties interface. */
33
+ interface Props<Input = unknown, Output = Input> {
34
+ /** The version number of the standard. */
35
+ readonly version: 1;
36
+ /** The vendor name of the schema library. */
37
+ readonly vendor: string;
38
+ /** Validates unknown input values. */
39
+ readonly validate: (value: unknown) => Result<Output> | Promise<Result<Output>>;
40
+ /** Inferred types associated with the schema. */
41
+ readonly types?: Types<Input, Output> | undefined;
42
+ }
43
+ /** The result interface of the validate function. */
44
+ type Result<Output> = SuccessResult<Output> | FailureResult;
45
+ /** The result interface if validation succeeds. */
46
+ interface SuccessResult<Output> {
47
+ /** The typed output value. */
48
+ readonly value: Output;
49
+ /** The non-existent issues. */
50
+ readonly issues?: undefined;
51
+ }
52
+ /** The result interface if validation fails. */
53
+ interface FailureResult {
54
+ /** The issues of failed validation. */
55
+ readonly issues: readonly Issue[];
56
+ }
57
+ /** The issue interface of the failure output. */
58
+ interface Issue {
59
+ /** The error message of the issue. */
60
+ readonly message: string;
61
+ /** The path of the issue, if any. */
62
+ readonly path?: ReadonlyArray<PropertyKey | PathSegment> | undefined;
63
+ }
64
+ /** The path segment interface of the issue. */
65
+ interface PathSegment {
66
+ /** The key representing a path segment. */
67
+ readonly key: PropertyKey;
68
+ }
69
+ /** The Standard Schema types interface. */
70
+ interface Types<Input = unknown, Output = Input> {
71
+ /** The input type of the schema. */
72
+ readonly input: Input;
73
+ /** The output type of the schema. */
74
+ readonly output: Output;
75
+ }
76
+ /** Infers the input type of a Standard Schema. */
77
+ type InferInput<Schema extends StandardSchemaV1> = NonNullable<Schema['~standard']['types']>['input'];
78
+ /** Infers the output type of a Standard Schema. */
79
+ type InferOutput<Schema extends StandardSchemaV1> = NonNullable<Schema['~standard']['types']>['output'];
80
+ }
81
+ /**
82
+ * Validate `data` against a Standard Schema. Returns the schema's typed
83
+ * output (validators may coerce/transform) or throws an `HttpError` 400
84
+ * carrying `validationErrors` + structured `meta.issues` — the same wire
85
+ * shape every kit's own validation errors serialize to.
86
+ */
87
+ declare function validateStandardSchema<TSchema extends StandardSchemaV1>(schema: TSchema, data: unknown): Promise<StandardSchemaV1.InferOutput<TSchema>>;
88
+ //#endregion
89
+ export { StandardSchemaV1, validateStandardSchema };
@@ -0,0 +1,59 @@
1
+ import { ERROR_CODES } from "../errors/types.mjs";
2
+ //#region src/schema/standard-schema.ts
3
+ /**
4
+ * Standard Schema integration — the validator-agnostic validation slot.
5
+ *
6
+ * [Standard Schema](https://standardschema.dev) is the shared interface
7
+ * implemented by Zod 3.24+, Valibot 1.0+, ArkType 2.0+, Effect Schema and
8
+ * others. Vendoring the interface (officially encouraged — it's a
9
+ * types-only spec designed to be copied) keeps repo-core's zero-dependency
10
+ * guarantee while letting hosts plug ANY conforming validator into a
11
+ * repository:
12
+ *
13
+ * ```ts
14
+ * import { z } from 'zod';
15
+ *
16
+ * const repo = createRepository(UserModel, {
17
+ * schema: z.object({ name: z.string(), email: z.string().email() }),
18
+ * });
19
+ * await repo.create({ name: 1 }); // throws HttpError 400 with validationErrors
20
+ * ```
21
+ *
22
+ * `RepositoryBase` wires `schema` / `updateSchema` into `before:create` /
23
+ * `before:createMany` / `before:update` hooks at `HOOK_PRIORITY.VALIDATION`
24
+ * — after policy plugins (so tenant-stamped fields are present) and before
25
+ * cache/observability.
26
+ */
27
+ /** Dot-path string from a Standard Schema issue path. */
28
+ function issuePath(issue) {
29
+ if (!issue.path || issue.path.length === 0) return "";
30
+ return issue.path.map((seg) => String(typeof seg === "object" && seg !== null && "key" in seg ? seg.key : seg)).join(".");
31
+ }
32
+ /**
33
+ * Validate `data` against a Standard Schema. Returns the schema's typed
34
+ * output (validators may coerce/transform) or throws an `HttpError` 400
35
+ * carrying `validationErrors` + structured `meta.issues` — the same wire
36
+ * shape every kit's own validation errors serialize to.
37
+ */
38
+ async function validateStandardSchema(schema, data) {
39
+ let result = schema["~standard"].validate(data);
40
+ if (result instanceof Promise) result = await result;
41
+ if (result.issues) {
42
+ const validationErrors = result.issues.map((issue) => ({
43
+ validator: schema["~standard"].vendor,
44
+ error: issuePath(issue) ? `${issuePath(issue)}: ${issue.message}` : issue.message
45
+ }));
46
+ throw Object.assign(/* @__PURE__ */ new Error("Validation failed"), {
47
+ status: 400,
48
+ code: ERROR_CODES.VALIDATION,
49
+ validationErrors,
50
+ meta: { issues: result.issues.map((issue) => ({
51
+ path: issuePath(issue) || void 0,
52
+ message: issue.message
53
+ })) }
54
+ });
55
+ }
56
+ return result.value;
57
+ }
58
+ //#endregion
59
+ export { validateStandardSchema };
@@ -34,6 +34,7 @@ function runStandardRepoConformance(harness) {
34
34
  const skipNoSubMinuteBuckets = !aggGate || !ops?.dateBucketSubMinute;
35
35
  const skipNoStddev = !aggGate || !ops?.stddev;
36
36
  const skipNoCache = !aggGate || !ops?.cache;
37
+ const skipNoPurge = !harness.features.purgeByField;
37
38
  describe(`[conformance] ${harness.name}`, () => {
38
39
  let ctx;
39
40
  beforeEach(async () => {
@@ -1124,6 +1125,149 @@ function runStandardRepoConformance(harness) {
1124
1125
  });
1125
1126
  });
1126
1127
  });
1128
+ describe("purgeByField (tenant cleanup)", () => {
1129
+ const seedTwoTenants = async () => {
1130
+ await ctx.repo.create(harness.makeDoc({
1131
+ name: "a-1",
1132
+ category: "org-a"
1133
+ }));
1134
+ await ctx.repo.create(harness.makeDoc({
1135
+ name: "a-2",
1136
+ category: "org-a"
1137
+ }));
1138
+ await ctx.repo.create(harness.makeDoc({
1139
+ name: "a-3",
1140
+ category: "org-a"
1141
+ }));
1142
+ await ctx.repo.create(harness.makeDoc({
1143
+ name: "b-1",
1144
+ category: "org-b"
1145
+ }));
1146
+ await ctx.repo.create(harness.makeDoc({
1147
+ name: "b-2",
1148
+ category: "org-b"
1149
+ }));
1150
+ };
1151
+ it.skipIf(skipNoPurge)("hard: removes every matching row, leaves others intact", async () => {
1152
+ await seedTwoTenants();
1153
+ const result = await ctx.repo.purgeByField("category", "org-a", { type: "hard" });
1154
+ expect(result.ok).toBe(true);
1155
+ expect(result.strategy).toBe("hard");
1156
+ expect(result.processed).toBe(3);
1157
+ expect(typeof result.durationMs).toBe("number");
1158
+ expect(await ctx.repo.count({ category: "org-a" })).toBe(0);
1159
+ expect(await ctx.repo.count({ category: "org-b" })).toBe(2);
1160
+ });
1161
+ it.skipIf(skipNoPurge)("hard: empty match completes ok with processed: 0", async () => {
1162
+ const result = await ctx.repo.purgeByField("category", "nonexistent", { type: "hard" });
1163
+ expect(result.ok).toBe(true);
1164
+ expect(result.processed).toBe(0);
1165
+ });
1166
+ it.skipIf(skipNoPurge)("anonymize: overwrites declared fields, keeps the row", async () => {
1167
+ await seedTwoTenants();
1168
+ const result = await ctx.repo.purgeByField("category", "org-a", {
1169
+ type: "anonymize",
1170
+ fields: {
1171
+ name: "[REDACTED]",
1172
+ notes: null
1173
+ }
1174
+ });
1175
+ expect(result.ok).toBe(true);
1176
+ expect(result.strategy).toBe("anonymize");
1177
+ expect(result.processed).toBe(3);
1178
+ expect(await ctx.repo.count({ category: "org-a" })).toBe(3);
1179
+ const redacted = await ctx.repo.findAll({ category: "org-a" });
1180
+ for (const row of redacted) {
1181
+ expect(row.name).toBe("[REDACTED]");
1182
+ expect(row.notes).toBeNull();
1183
+ }
1184
+ const others = await ctx.repo.findAll({ category: "org-b" });
1185
+ for (const row of others) expect(row.name).not.toBe("[REDACTED]");
1186
+ });
1187
+ it.skipIf(skipNoPurge)("skip: no-op, returns reason and processed: 0", async () => {
1188
+ await seedTwoTenants();
1189
+ const result = await ctx.repo.purgeByField("category", "org-a", {
1190
+ type: "skip",
1191
+ reason: "audit-retained-per-SOX"
1192
+ });
1193
+ expect(result.ok).toBe(true);
1194
+ expect(result.strategy).toBe("skip");
1195
+ expect(result.processed).toBe(0);
1196
+ expect(result.skipReason).toBe("audit-retained-per-SOX");
1197
+ expect(await ctx.repo.count({ category: "org-a" })).toBe(3);
1198
+ expect(await ctx.repo.count({ category: "org-b" })).toBe(2);
1199
+ });
1200
+ it.skipIf(skipNoPurge)("chunking: batchSize honored, onProgress fires per chunk", async () => {
1201
+ for (let i = 0; i < 25; i++) await ctx.repo.create(harness.makeDoc({
1202
+ name: `chunk-${i}`,
1203
+ category: "org-chunk"
1204
+ }));
1205
+ const progressEvents = [];
1206
+ expect((await ctx.repo.purgeByField("category", "org-chunk", { type: "hard" }, {
1207
+ batchSize: 10,
1208
+ onProgress: (event) => {
1209
+ progressEvents.push({
1210
+ processed: event.processed,
1211
+ chunkSize: event.chunkSize
1212
+ });
1213
+ }
1214
+ })).processed).toBe(25);
1215
+ expect(progressEvents.length).toBe(3);
1216
+ expect(progressEvents[0]).toEqual({
1217
+ processed: 10,
1218
+ chunkSize: 10
1219
+ });
1220
+ expect(progressEvents[1]).toEqual({
1221
+ processed: 20,
1222
+ chunkSize: 10
1223
+ });
1224
+ expect(progressEvents[2]).toEqual({
1225
+ processed: 25,
1226
+ chunkSize: 5
1227
+ });
1228
+ expect(await ctx.repo.count({ category: "org-chunk" })).toBe(0);
1229
+ });
1230
+ it.skipIf(skipNoPurge)("idempotent: re-running on the same tenant is a no-op", async () => {
1231
+ await seedTwoTenants();
1232
+ expect((await ctx.repo.purgeByField("category", "org-a", { type: "hard" })).processed).toBe(3);
1233
+ const second = await ctx.repo.purgeByField("category", "org-a", { type: "hard" });
1234
+ expect(second.ok).toBe(true);
1235
+ expect(second.processed).toBe(0);
1236
+ });
1237
+ it.skipIf(skipNoPurge)("scoping: only rows matching field=value are affected", async () => {
1238
+ await seedTwoTenants();
1239
+ const totalBefore = await ctx.repo.count({});
1240
+ await ctx.repo.purgeByField("category", "org-a", { type: "hard" });
1241
+ expect(await ctx.repo.count({})).toBe(totalBefore - 3);
1242
+ expect(await ctx.repo.count({ category: "org-b" })).toBe(2);
1243
+ });
1244
+ it.skipIf(skipNoPurge)("abort signal: stops between chunks, returns partial count", async () => {
1245
+ for (let i = 0; i < 25; i++) await ctx.repo.create(harness.makeDoc({
1246
+ name: `abort-${i}`,
1247
+ category: "org-abort"
1248
+ }));
1249
+ const controller = new AbortController();
1250
+ const result = await ctx.repo.purgeByField("category", "org-abort", { type: "hard" }, {
1251
+ batchSize: 10,
1252
+ signal: controller.signal,
1253
+ onProgress: (event) => {
1254
+ if (event.processed === 10) controller.abort();
1255
+ }
1256
+ });
1257
+ expect(result.ok).toBe(false);
1258
+ expect(result.processed).toBe(10);
1259
+ expect(await ctx.repo.count({ category: "org-abort" })).toBe(15);
1260
+ });
1261
+ it.skipIf(skipNoPurge)("retry policy is plumbed through (default no retry, opt-in works)", async () => {
1262
+ await seedTwoTenants();
1263
+ const result = await ctx.repo.purgeByField("category", "org-a", { type: "hard" }, { retry: {
1264
+ maxAttempts: 1,
1265
+ baseDelayMs: 10
1266
+ } });
1267
+ expect(result.ok).toBe(true);
1268
+ expect(result.processed).toBe(3);
1269
+ });
1270
+ });
1127
1271
  });
1128
1272
  }
1129
1273
  //#endregion
@@ -1,4 +1,5 @@
1
- import { AggregateOpsSupport, ConformanceContext, ConformanceDoc, ConformanceFeatures, ConformanceHarness } from "./types.mjs";
1
+ import { AggregateOpsSupport } from "../repository/capabilities.mjs";
2
+ import { ConformanceContext, ConformanceDoc, ConformanceFeatures, ConformanceHarness } from "./types.mjs";
2
3
  import { runStandardRepoConformance } from "./conformance.mjs";
3
4
  import { LockConformanceHarness, runLockAdapterConformance } from "./lock-conformance.mjs";
4
5
  export { type AggregateOpsSupport, type ConformanceContext, type ConformanceDoc, type ConformanceFeatures, type ConformanceHarness, type LockConformanceHarness, runLockAdapterConformance, runStandardRepoConformance };