@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
|
@@ -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 };
|
package/dist/schema/index.d.mts
CHANGED
|
@@ -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 };
|
package/dist/schema/index.mjs
CHANGED
|
@@ -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
|
package/dist/testing/index.d.mts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { AggregateOpsSupport
|
|
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 };
|