@classytic/repo-core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/LICENSE +21 -0
  3. package/README.md +154 -0
  4. package/dist/cache/index.d.mts +4 -0
  5. package/dist/cache/index.mjs +3 -0
  6. package/dist/cache/memory-adapter.d.mts +7 -0
  7. package/dist/cache/memory-adapter.mjs +37 -0
  8. package/dist/cache/stable-stringify.d.mts +15 -0
  9. package/dist/cache/stable-stringify.mjs +19 -0
  10. package/dist/cache/types.d.mts +59 -0
  11. package/dist/context/index.d.mts +2 -0
  12. package/dist/context/index.mjs +0 -0
  13. package/dist/context/types.d.mts +24 -0
  14. package/dist/errors/create-error.d.mts +19 -0
  15. package/dist/errors/create-error.mjs +23 -0
  16. package/dist/errors/duplicate-key.d.mts +38 -0
  17. package/dist/errors/duplicate-key.mjs +57 -0
  18. package/dist/errors/index.d.mts +4 -0
  19. package/dist/errors/index.mjs +3 -0
  20. package/dist/errors/types.d.mts +37 -0
  21. package/dist/filter/builders.d.mts +60 -0
  22. package/dist/filter/builders.mjs +172 -0
  23. package/dist/filter/guard.d.mts +13 -0
  24. package/dist/filter/guard.mjs +34 -0
  25. package/dist/filter/index.d.mts +7 -0
  26. package/dist/filter/index.mjs +6 -0
  27. package/dist/filter/match.d.mts +12 -0
  28. package/dist/filter/match.mjs +91 -0
  29. package/dist/filter/scope.d.mts +31 -0
  30. package/dist/filter/scope.mjs +54 -0
  31. package/dist/filter/types.d.mts +143 -0
  32. package/dist/filter/walk.d.mts +24 -0
  33. package/dist/filter/walk.mjs +77 -0
  34. package/dist/hooks/engine.d.mts +48 -0
  35. package/dist/hooks/engine.mjs +101 -0
  36. package/dist/hooks/events.d.mts +95 -0
  37. package/dist/hooks/events.mjs +93 -0
  38. package/dist/hooks/index.d.mts +5 -0
  39. package/dist/hooks/index.mjs +4 -0
  40. package/dist/hooks/priority.d.mts +23 -0
  41. package/dist/hooks/priority.mjs +21 -0
  42. package/dist/hooks/types.d.mts +37 -0
  43. package/dist/lookup/index.d.mts +2 -0
  44. package/dist/lookup/index.mjs +0 -0
  45. package/dist/lookup/types.d.mts +170 -0
  46. package/dist/operations/index.d.mts +3 -0
  47. package/dist/operations/index.mjs +2 -0
  48. package/dist/operations/registry.d.mts +41 -0
  49. package/dist/operations/registry.mjs +140 -0
  50. package/dist/operations/types.d.mts +49 -0
  51. package/dist/pagination/cursor.d.mts +44 -0
  52. package/dist/pagination/cursor.mjs +150 -0
  53. package/dist/pagination/index.d.mts +5 -0
  54. package/dist/pagination/index.mjs +4 -0
  55. package/dist/pagination/keyset.d.mts +25 -0
  56. package/dist/pagination/keyset.mjs +61 -0
  57. package/dist/pagination/offset.d.mts +26 -0
  58. package/dist/pagination/offset.mjs +47 -0
  59. package/dist/pagination/types.d.mts +136 -0
  60. package/dist/query-parser/coerce.d.mts +16 -0
  61. package/dist/query-parser/coerce.mjs +73 -0
  62. package/dist/query-parser/index.d.mts +4 -0
  63. package/dist/query-parser/index.mjs +3 -0
  64. package/dist/query-parser/parse-url.d.mts +7 -0
  65. package/dist/query-parser/parse-url.mjs +224 -0
  66. package/dist/query-parser/types.d.mts +104 -0
  67. package/dist/repository/base.d.mts +90 -0
  68. package/dist/repository/base.mjs +111 -0
  69. package/dist/repository/index.d.mts +5 -0
  70. package/dist/repository/index.mjs +3 -0
  71. package/dist/repository/plugin-types.d.mts +27 -0
  72. package/dist/repository/plugin-types.mjs +45 -0
  73. package/dist/repository/types.d.mts +470 -0
  74. package/dist/schema/field-rules.d.mts +62 -0
  75. package/dist/schema/field-rules.mjs +110 -0
  76. package/dist/schema/index.d.mts +3 -0
  77. package/dist/schema/index.mjs +2 -0
  78. package/dist/schema/types.d.mts +138 -0
  79. package/dist/testing/conformance.d.mts +6 -0
  80. package/dist/testing/conformance.mjs +481 -0
  81. package/dist/testing/index.d.mts +3 -0
  82. package/dist/testing/index.mjs +2 -0
  83. package/dist/testing/types.d.mts +113 -0
  84. package/package.json +130 -0
@@ -0,0 +1,470 @@
1
+ import { Filter } from "../filter/types.mjs";
2
+ import { OffsetPaginationResult } from "../pagination/types.mjs";
3
+ import { LookupPopulateOptions, LookupPopulateResult } from "../lookup/types.mjs";
4
+
5
+ //#region src/repository/types.d.ts
6
+ /**
7
+ * Accepted filter input across every repository method. A repository
8
+ * call can pass either a plain record (`{ status: 'active' }`) or a
9
+ * Filter IR node (`and(eq('status', 'active'), gt('age', 18))`) — every
10
+ * kit's compiler handles both forms. Kit-native power filters (raw SQL
11
+ * fragments, Mongo `$expr`) still live on the kit's own types; this
12
+ * union covers the portable path.
13
+ */
14
+ type FilterInput = Filter | Record<string, unknown>;
15
+ /**
16
+ * Opaque transaction session handle. Each driver binds this to its
17
+ * concrete type (`mongoose.ClientSession`, `pg.PoolClient`, `better-sqlite3`
18
+ * transaction function, Prisma transaction client, ...). Code that passes
19
+ * the session through uses `unknown`; kits narrow at the boundary.
20
+ */
21
+ type RepositorySession = unknown;
22
+ /**
23
+ * Read-operation options. The index signature is the escape hatch kits use
24
+ * for driver-specific flags (`populate`, `select`, `readPreference`,
25
+ * `__pgHint`, ...). Namespace custom flags to avoid collisions with future
26
+ * arc-reserved keys.
27
+ */
28
+ interface QueryOptions {
29
+ /**
30
+ * Mongoose-style session handle. Threaded through every call so mongoose
31
+ * knows the op is inside a transaction — the driver has no other way to
32
+ * discover that.
33
+ *
34
+ * **SQL / Prisma kits don't use this field.** Their transactions are
35
+ * connection-scoped (SQL) or client-scoped (Prisma): `withTransaction(fn)`
36
+ * hands the callback a `txRepo` whose internal driver is already bound to
37
+ * the transaction, so op methods pick up the tx automatically. Callers who
38
+ * stay on the bound-`txRepo` pattern never touch `session`.
39
+ *
40
+ * Kept on the common `QueryOptions` so mongokit can read it on every op
41
+ * and so arc stores (outbox, idempotency) can forward it where necessary.
42
+ */
43
+ session?: RepositorySession;
44
+ /** Return plain objects rather than driver documents. */
45
+ lean?: boolean;
46
+ /** Include soft-deleted docs in reads (honored by soft-delete plugin). */
47
+ includeDeleted?: boolean;
48
+ /** Request-scoped user metadata forwarded to policy/tenant hooks. */
49
+ user?: Record<string, unknown>;
50
+ /** Arc request context (orgId, roles, requestId, ...). */
51
+ context?: Record<string, unknown>;
52
+ /** Driver-specific escape hatch — see JSDoc. */
53
+ [key: string]: unknown;
54
+ }
55
+ /** Write-operation options. Superset of `QueryOptions`. */
56
+ interface WriteOptions extends QueryOptions {
57
+ /** Upsert on update/replace. */
58
+ upsert?: boolean;
59
+ }
60
+ /**
61
+ * Delete-operation options.
62
+ *
63
+ * `mode: 'hard'` opts out of soft-delete interception when the kit has a
64
+ * soft-delete plugin wired. Policy, cascade, audit, and cache hooks still
65
+ * fire — only the soft-delete rewrite is bypassed. Use for GDPR erasure /
66
+ * admin purge. Kits without soft-delete MUST accept and ignore the flag.
67
+ */
68
+ interface DeleteOptions extends QueryOptions {
69
+ mode?: 'hard' | 'soft';
70
+ }
71
+ /**
72
+ * Compare-and-set options for `findOneAndUpdate`. The four core knobs
73
+ * (`sort`, `returnDocument`, `upsert`, `session`) are cross-driver; the
74
+ * index signature lets kits thread through their own additions.
75
+ */
76
+ interface FindOneAndUpdateOptions extends QueryOptions {
77
+ /** Sort disambiguating when the filter matches multiple docs (FIFO claim). */
78
+ sort?: Record<string, unknown>;
79
+ /** Return doc state before or after the update. Default: 'after'. */
80
+ returnDocument?: 'before' | 'after';
81
+ /** Insert when no doc matches. Default: false. */
82
+ upsert?: boolean;
83
+ }
84
+ /** Result of a single delete — matches mongokit's shape. */
85
+ interface DeleteResult {
86
+ success: boolean;
87
+ message: string;
88
+ /** Primary key of the removed doc (string form). */
89
+ id?: string;
90
+ /** True when a soft-delete plugin intercepted the operation. */
91
+ soft?: boolean;
92
+ /** For batch-variant implementations that surface the count inline. */
93
+ count?: number;
94
+ }
95
+ /** Result of a batch delete. */
96
+ interface DeleteManyResult {
97
+ acknowledged?: boolean;
98
+ deletedCount: number;
99
+ /** True when a soft-delete plugin rewrote the op to an updateMany. */
100
+ soft?: boolean;
101
+ }
102
+ /** Result of a bulk update. */
103
+ interface UpdateManyResult {
104
+ acknowledged?: boolean;
105
+ matchedCount: number;
106
+ modifiedCount: number;
107
+ upsertedCount?: number;
108
+ upsertedId?: unknown;
109
+ }
110
+ /**
111
+ * Heterogeneous bulk-write operation. Mongo-shaped so arc code written
112
+ * against mongokit's `bulkWrite` drops into any kit that implements the
113
+ * StandardRepo `bulkWrite?` method.
114
+ *
115
+ * Kit-specific constraints apply:
116
+ *
117
+ * - SQL/Prisma kits evaluate `updateOne.update` as a flat column
118
+ * overwrite, not a MongoDB operator expression (no `$set`, `$inc`).
119
+ * Pass raw column values.
120
+ * - `updateOne` / `replaceOne` on SQL kits typically route through a
121
+ * SELECT-then-UPDATE because `UPDATE ... LIMIT 1` isn't portable.
122
+ * - `upsert: true` on kits without a native compound unique key may
123
+ * require the filter to be a flat-literal record (so the kit can
124
+ * merge filter + update into an INSERT).
125
+ */
126
+ type BulkWriteOperation<TDoc = unknown> = {
127
+ insertOne: {
128
+ document: Partial<TDoc>;
129
+ };
130
+ } | {
131
+ updateOne: {
132
+ filter: Record<string, unknown>;
133
+ update: Record<string, unknown>;
134
+ upsert?: boolean;
135
+ };
136
+ } | {
137
+ updateMany: {
138
+ filter: Record<string, unknown>;
139
+ update: Record<string, unknown>;
140
+ upsert?: boolean;
141
+ };
142
+ } | {
143
+ deleteOne: {
144
+ filter: Record<string, unknown>;
145
+ };
146
+ } | {
147
+ deleteMany: {
148
+ filter: Record<string, unknown>;
149
+ };
150
+ } | {
151
+ replaceOne: {
152
+ filter: Record<string, unknown>;
153
+ replacement: Partial<TDoc>;
154
+ upsert?: boolean;
155
+ };
156
+ };
157
+ /**
158
+ * Result envelope for `bulkWrite`. Mongo-shaped — arc's idempotency /
159
+ * outbox adapters read the same fields regardless of backend.
160
+ *
161
+ * `insertedIds` / `upsertedIds` are keyed by the operation's index in
162
+ * the input array, matching mongoose's convention.
163
+ */
164
+ interface BulkWriteResult {
165
+ ok?: number;
166
+ insertedCount?: number;
167
+ matchedCount?: number;
168
+ modifiedCount?: number;
169
+ deletedCount?: number;
170
+ upsertedCount?: number;
171
+ insertedIds?: Record<number, unknown>;
172
+ upsertedIds?: Record<number, unknown>;
173
+ }
174
+ /**
175
+ * A single named aggregation. Mongo-style operator names because they're
176
+ * the lowest common denominator across SQL and MongoDB — `count` / `sum`
177
+ * / `avg` / `min` / `max` / `countDistinct` map cleanly to both
178
+ * `COUNT(*)` + `GROUP BY` in SQL and `{ $group: { _id, x: { $sum: ... } } }`
179
+ * in Mongo.
180
+ *
181
+ * `count` is the only measure whose `field` is optional: `{ op: 'count' }`
182
+ * counts rows in the group (`COUNT(*)` / `$sum: 1`). With a field name
183
+ * it counts non-null values.
184
+ *
185
+ * Kit compilers normalize unknown ops to a runtime error — keep the set
186
+ * tight so aggregations compile identically everywhere.
187
+ */
188
+ type AggMeasure = {
189
+ op: 'count';
190
+ field?: string;
191
+ } | {
192
+ op: 'countDistinct';
193
+ field: string;
194
+ } | {
195
+ op: 'sum';
196
+ field: string;
197
+ } | {
198
+ op: 'avg';
199
+ field: string;
200
+ } | {
201
+ op: 'min';
202
+ field: string;
203
+ } | {
204
+ op: 'max';
205
+ field: string;
206
+ };
207
+ /**
208
+ * Portable aggregation request. Compiles to SQL (`SELECT ... WHERE ...
209
+ * GROUP BY ... HAVING ... ORDER BY ... LIMIT ... OFFSET`) on sqlitekit /
210
+ * pgkit and to a `[$match, $group, $match, $sort, $limit, $skip]`
211
+ * pipeline on mongokit. Output shape is identical either way: one row
212
+ * per group, keyed by `groupBy` fields + measure aliases.
213
+ *
214
+ * Without `groupBy`: returns a single row of scalar aggregates over the
215
+ * full filtered set. With `groupBy`: one row per distinct group.
216
+ *
217
+ * `filter` and `having` both reuse the Filter IR — `filter` narrows the
218
+ * rows that feed into the aggregate (WHERE), `having` narrows the
219
+ * aggregated result (HAVING). Use `having` to reference measure aliases
220
+ * (`{ field: 'revenue', op: 'gt', value: 1000 }`); kit compilers
221
+ * substitute the aggregate expression when the field matches a measure.
222
+ *
223
+ * Power features that don't translate across backends — `$lookup`,
224
+ * `$unwind`, window functions, CTEs — stay kit-native. Reach for
225
+ * mongokit's `aggregatePipeline` or sqlitekit's raw `repo.db` when you
226
+ * need them.
227
+ */
228
+ interface AggRequest {
229
+ /** Pre-aggregate predicate. Reuses Filter IR; compiles to WHERE / `$match`. */
230
+ filter?: unknown;
231
+ /** Grouping columns. Single string, array of strings, or omitted for scalar aggregation. */
232
+ groupBy?: string | readonly string[];
233
+ /**
234
+ * Named aggregations. At least one key required — an empty `measures`
235
+ * bag is a wiring bug (nothing to compute).
236
+ */
237
+ measures: Record<string, AggMeasure>;
238
+ /** Post-aggregate predicate. Reuses Filter IR; references measure aliases. */
239
+ having?: unknown;
240
+ /** Order the grouped rows. Keys may be `groupBy` fields or measure aliases. */
241
+ sort?: Record<string, 1 | -1>;
242
+ /** Row cap; applied after `having` + `sort`. */
243
+ limit?: number;
244
+ /** Skip N grouped rows. Paginated callers use `aggregatePaginate` instead. */
245
+ offset?: number;
246
+ }
247
+ /**
248
+ * Paginated variant of `AggRequest`. Returns the standard offset
249
+ * pagination envelope — same shape as `getAll({ page, limit })` so UI
250
+ * code renders aggregates and raw document lists with the same
251
+ * pagination primitives.
252
+ */
253
+ interface AggPaginationRequest extends Omit<AggRequest, 'limit' | 'offset'> {
254
+ /** 1-indexed page number. Defaults to 1. */
255
+ page?: number;
256
+ /** Rows per page. Defaults to the kit's standard limit. */
257
+ limit?: number;
258
+ /**
259
+ * `exact` runs `COUNT(DISTINCT groupBy)` (or `COUNT(*)` for scalar
260
+ * aggregates) alongside the data query. `none` skips the count
261
+ * entirely — the envelope's `total` / `pages` are 0 and `hasNext` is
262
+ * derived from a `LIMIT N+1` peek. Defaults to `exact`.
263
+ */
264
+ countStrategy?: 'exact' | 'none';
265
+ }
266
+ /**
267
+ * Shape of each row returned by `aggregate` / `aggregatePaginate`.
268
+ * Keys are the `groupBy` fields (when present) plus the measure
269
+ * aliases. Values are SQL-native scalars — numbers for count / sum /
270
+ * avg, the group-by column's native type for group keys.
271
+ *
272
+ * Generic defaults to `Record<string, unknown>` because cross-kit
273
+ * callers usually don't need the narrower type — cast at the call
274
+ * site with your own `interface RevenueByCategory { ... }` if you do.
275
+ */
276
+ type AggRow = Record<string, unknown>;
277
+ /** Unpaginated aggregation result. Just an array — no envelope. */
278
+ interface AggResult<TRow extends AggRow = AggRow> {
279
+ rows: TRow[];
280
+ }
281
+ /**
282
+ * Pagination parameters. Auto-detects three modes:
283
+ *
284
+ * - **Offset** — `page` + `limit` given.
285
+ * - **Keyset** — `sort` + `limit` (+ optional `after` cursor) given.
286
+ * - **Raw** — neither; kit returns all matching docs (may be large).
287
+ */
288
+ interface PaginationParams<TDoc = unknown> {
289
+ filters?: Partial<TDoc> & Record<string, unknown>;
290
+ sort?: string | Record<string, 1 | -1>;
291
+ page?: number;
292
+ limit?: number;
293
+ /** Opaque cursor token from a prior `next` field. */
294
+ after?: string;
295
+ /** Escape hatch for kit-specific options (select, search, populate, ...). */
296
+ [key: string]: unknown;
297
+ }
298
+ /**
299
+ * Extract document type from any repository. Useful downstream for
300
+ * generic helpers:
301
+ *
302
+ * ```ts
303
+ * type UserDoc = InferDoc<typeof userRepo>;
304
+ * ```
305
+ */
306
+ type InferDoc<R> = R extends MinimalRepo<infer T> ? T : never;
307
+ /**
308
+ * Absolute minimum repository contract. Arc's `BaseController` makes no
309
+ * assumption beyond these methods — if a repo satisfies `MinimalRepo`,
310
+ * arc's auto-generated CRUD routes will work against it.
311
+ *
312
+ * Target audience:
313
+ * - Kit authors (mongokit, sqlitekit, pgkit): implement this first, then
314
+ * layer `StandardRepo` optional capabilities on top.
315
+ * - App authors: stub repositories in unit tests without a DB. A `Map`-backed
316
+ * mock implementing `MinimalRepo` passes all of arc's type checks.
317
+ * - Gateway/proxy authors: wrap a remote service as a local repository by
318
+ * implementing these five methods around HTTP calls.
319
+ *
320
+ * @typeParam TDoc Document / entity type this repository produces.
321
+ */
322
+ interface MinimalRepo<TDoc> {
323
+ /**
324
+ * Primary key field. Defaults to `'_id'` (Mongo convention) when omitted.
325
+ * Arc reads this to decide whether route params pass straight through to
326
+ * `update`/`delete` or translate via a fetched doc's `_id` first.
327
+ */
328
+ readonly idField?: string;
329
+ /**
330
+ * List with pagination. Kit auto-selects offset vs keyset based on the
331
+ * presence of `page` vs `sort`/`after`. Return shapes all valid:
332
+ *
333
+ * - offset envelope when `page` is given
334
+ * - keyset envelope when `sort` (+ optional `after`) is given
335
+ * - raw array when neither drives pagination
336
+ *
337
+ * Arc's `BaseController` narrows the union before responding.
338
+ */
339
+ getAll(params?: PaginationParams<TDoc>, options?: QueryOptions): Promise<unknown>;
340
+ /**
341
+ * Fetch a single document by its primary key.
342
+ *
343
+ * **Miss semantics:** MAY return `null` or throw a 404-style error whose
344
+ * message contains `"not found"`. Arc handles both. Pick one convention
345
+ * and document it.
346
+ */
347
+ getById(id: string, options?: QueryOptions): Promise<TDoc | null>;
348
+ /** Insert a single document. */
349
+ create(data: Partial<TDoc>, options?: WriteOptions): Promise<TDoc>;
350
+ /** Update by primary key. Returns the updated doc or null. */
351
+ update(id: string, data: Partial<TDoc>, options?: WriteOptions): Promise<TDoc | null>;
352
+ /**
353
+ * Delete by primary key. Pass `{ mode: 'hard' }` to bypass soft-delete
354
+ * interception (kits without soft-delete accept and ignore the flag).
355
+ */
356
+ delete(id: string, options?: DeleteOptions): Promise<DeleteResult>;
357
+ }
358
+ /**
359
+ * Recommended repository contract. Every method beyond `MinimalRepo` is
360
+ * optional — kits implement what their backend can express. Arc
361
+ * feature-detects at runtime.
362
+ *
363
+ * Kits targeting arc 2.10+ should aim for this shape. Everything beyond
364
+ * (aggregate, bulkWrite, kit-specific builders, vector search) stays
365
+ * kit-native.
366
+ */
367
+ interface StandardRepo<TDoc> extends MinimalRepo<TDoc> {
368
+ /**
369
+ * Atomic compare-and-set. Match one document, mutate it, return the
370
+ * post-update doc (or pre-update when `returnDocument: 'before'`).
371
+ * Returns `null` when no match and `upsert` is false.
372
+ *
373
+ * Required for arc's outbox, distributed-lock, and workflow-semaphore
374
+ * patterns. Kits without atomic CAS should simulate it inside a
375
+ * transaction — arc's stores assume single-round-trip semantics.
376
+ */
377
+ findOneAndUpdate?(filter: FilterInput, update: Record<string, unknown> | Record<string, unknown>[], options?: FindOneAndUpdateOptions): Promise<TDoc | null>;
378
+ /**
379
+ * Classify an error from a write as a unique-constraint violation.
380
+ * Arc's idempotency + outbox adapters need this to distinguish
381
+ * "already landed (idempotent no-op)" from "retry the write".
382
+ *
383
+ * Every backend signals duplicates differently (Mongo 11000, Prisma
384
+ * P2002, Postgres 23505, SQLite UNIQUE constraint) — classification
385
+ * lives in the kit that knows its driver.
386
+ */
387
+ isDuplicateKeyError?(err: unknown): boolean;
388
+ /** Find a single doc by compound filter (used by arc's AccessControl). */
389
+ getOne?(filter: FilterInput, options?: QueryOptions): Promise<TDoc | null>;
390
+ /** Alias many kits expose alongside `getOne`. Arc checks both names. */
391
+ getByQuery?(filter: FilterInput, options?: QueryOptions): Promise<TDoc | null>;
392
+ count?(filter?: FilterInput, options?: QueryOptions): Promise<number>;
393
+ exists?(filter: FilterInput, options?: QueryOptions): Promise<boolean | {
394
+ _id: unknown;
395
+ } | null>;
396
+ distinct?<T = unknown>(field: string, filter?: FilterInput, options?: QueryOptions): Promise<T[]>;
397
+ findAll?(filter?: FilterInput, options?: QueryOptions): Promise<TDoc[]>;
398
+ getOrCreate?(filter: FilterInput, data: Partial<TDoc>, options?: WriteOptions): Promise<TDoc | null>;
399
+ createMany?(items: Partial<TDoc>[], options?: WriteOptions): Promise<TDoc[]>;
400
+ updateMany?(filter: FilterInput, data: Record<string, unknown>, options?: WriteOptions): Promise<UpdateManyResult>;
401
+ deleteMany?(filter: FilterInput, options?: DeleteOptions): Promise<DeleteManyResult>;
402
+ /**
403
+ * Heterogeneous bulk write. Kits dispatch each op against the
404
+ * appropriate driver primitive inside a single transaction; see each
405
+ * kit's docs for the exact semantics of `upsert` and operator-shaped
406
+ * update values (mongokit honors `$set` etc., SQL kits treat `update`
407
+ * as a flat column overwrite).
408
+ */
409
+ bulkWrite?(operations: readonly BulkWriteOperation<TDoc>[]): Promise<BulkWriteResult>;
410
+ /**
411
+ * Portable aggregation. Compiles to `SELECT ... GROUP BY ...` on SQL
412
+ * kits and to a `[$match, $group, $sort, $limit]` pipeline on mongokit.
413
+ * Output shape (`{ rows }`) is identical across backends — dashboards
414
+ * and admin tooling read the same result regardless of the driver.
415
+ *
416
+ * Distinct from kit-native aggregation APIs (mongokit's
417
+ * `aggregatePipeline(stages)`, sqlitekit's raw `repo.db`) by design:
418
+ * those take backend-specific inputs and return backend-specific
419
+ * shapes, suited for joins / unwinds / window functions / CTEs. The
420
+ * portable `aggregate` covers the filter + group + measures + sort +
421
+ * limit subset that every backend supports — and nothing else, so
422
+ * the behavior stays identical across drivers.
423
+ */
424
+ aggregate?<TRow extends AggRow = AggRow>(req: AggRequest): Promise<AggResult<TRow>>;
425
+ /**
426
+ * Paginated aggregation. Returns the standard offset envelope so UI
427
+ * code paginates aggregated dashboards with the same primitives as
428
+ * raw document lists. `countStrategy: 'none'` skips the distinct-
429
+ * group count for infinite-scroll use.
430
+ */
431
+ aggregatePaginate?<TRow extends AggRow = AggRow>(req: AggPaginationRequest): Promise<OffsetPaginationResult<TRow>>;
432
+ /**
433
+ * Paginated join. Compiles the portable `LookupSpec[]` to `$lookup`
434
+ * stages on mongokit or `LEFT JOIN` + `json_object()` / `json_group_array()`
435
+ * on sqlitekit. Each returned row carries the base doc plus one key
436
+ * per lookup's `as` (or `from` default). Output shape is identical
437
+ * across backends — dashboards and detail views stop being kit-specific.
438
+ *
439
+ * Scope is deliberate: single-level joins keyed on `localField` /
440
+ * `foreignField`. Pipeline-form `$lookup`, nested lookups, and
441
+ * backend-specific join kinds stay on the kit-native path
442
+ * (mongokit's `aggregatePipeline`, sqlitekit's raw Drizzle).
443
+ */
444
+ lookupPopulate?<TExtra extends Record<string, unknown> = Record<string, unknown>>(options: LookupPopulateOptions<TDoc>): Promise<LookupPopulateResult<TDoc, TExtra>>;
445
+ restore?(id: string, options?: QueryOptions): Promise<TDoc | null>;
446
+ getDeleted?(params?: PaginationParams<TDoc>, options?: QueryOptions): Promise<unknown>;
447
+ /**
448
+ * Run `fn` inside a transaction. The callback receives a transaction-
449
+ * bound repository (`txRepo`) — **call methods on `txRepo`, not on the
450
+ * outer repo**, so SQL connection-scoped transactions and Prisma
451
+ * client-scoped transactions actually contain the operations.
452
+ *
453
+ * For cross-kit consistency, SQL/Prisma kits return a rebound repo whose
454
+ * internal driver points at the transaction. Mongokit's implementation
455
+ * returns a proxy that threads `session` automatically — callers never
456
+ * see the mongoose session directly.
457
+ *
458
+ * @example
459
+ * ```ts
460
+ * await repo.withTransaction?.(async (txRepo) => {
461
+ * const user = await txRepo.create({ name: 'Alice' });
462
+ * await txRepo.update(user.id, { role: 'admin' });
463
+ * });
464
+ * // Either both writes commit or neither does.
465
+ * ```
466
+ */
467
+ withTransaction?<T>(fn: (txRepo: StandardRepo<TDoc>) => Promise<T>, options?: Record<string, unknown>): Promise<T>;
468
+ }
469
+ //#endregion
470
+ export { AggMeasure, AggPaginationRequest, AggRequest, AggResult, AggRow, BulkWriteOperation, BulkWriteResult, DeleteManyResult, DeleteOptions, DeleteResult, FindOneAndUpdateOptions, InferDoc, MinimalRepo, PaginationParams, QueryOptions, RepositorySession, StandardRepo, UpdateManyResult, WriteOptions };
@@ -0,0 +1,62 @@
1
+ import { JsonSchema, SchemaBuilderOptions, ValidationResult } from "./types.mjs";
2
+
3
+ //#region src/schema/field-rules.d.ts
4
+ /**
5
+ * Collect the set of fields that must NOT appear in a generated schema.
6
+ *
7
+ * Combines four sources in priority order:
8
+ * 1. Always-hidden system fields (`createdAt`, `updatedAt`, `__v`).
9
+ * 2. `fieldRules[field].systemManaged` → hidden from both create & update.
10
+ * 3. For update schemas: `fieldRules[field].immutable` /
11
+ * `immutableAfterCreate` → hidden from update only.
12
+ * 4. `options.create.omitFields` / `options.update.omitFields` — explicit
13
+ * caller-provided omit list for the matching purpose.
14
+ *
15
+ * Returns a fresh `Set<string>` so callers can freely mutate.
16
+ */
17
+ declare function collectFieldsToOmit(options: SchemaBuilderOptions, purpose: 'create' | 'update'): Set<string>;
18
+ /**
19
+ * Apply omissions + `optional` overrides to a built JSON Schema in place.
20
+ *
21
+ * Deletes each omitted field from `schema.properties` AND removes it from
22
+ * `schema.required`. Also honors `fieldRules[field].optional` by stripping
23
+ * matching names from `required`.
24
+ *
25
+ * In-place mutation is deliberate: every kit's builder constructs a fresh
26
+ * schema immediately before calling this helper, so there is no risk of
27
+ * aliasing a schema the caller still holds.
28
+ */
29
+ declare function applyFieldRules(schema: JsonSchema, fieldsToOmit: Set<string>, options: SchemaBuilderOptions): void;
30
+ /**
31
+ * List of fields that cannot be mutated through an update body.
32
+ *
33
+ * Union of:
34
+ * - Every `fieldRules[field].immutable` / `immutableAfterCreate` entry.
35
+ * - Every `options.update.omitFields` entry (explicit exclusion still
36
+ * counts as immutable from the caller's perspective).
37
+ *
38
+ * Returns a deduplicated array; insertion order follows rules-then-omitFields.
39
+ */
40
+ declare function getImmutableFields(options?: SchemaBuilderOptions): string[];
41
+ /**
42
+ * List of fields that cannot be set by clients on either create or update.
43
+ * These are typically stamps written by the server (audit trail, computed
44
+ * state) regardless of method.
45
+ */
46
+ declare function getSystemManagedFields(options?: SchemaBuilderOptions): string[];
47
+ /**
48
+ * Convenience: is `fieldName` allowed in an update body?
49
+ *
50
+ * Equivalent to `!getImmutableFields(...).includes(fieldName) &&
51
+ * !getSystemManagedFields(...).includes(fieldName)` — the exact semantics
52
+ * enforced by `validateUpdateBody`.
53
+ */
54
+ declare function isFieldUpdateAllowed(fieldName: string, options?: SchemaBuilderOptions): boolean;
55
+ /**
56
+ * Validate an update body against `fieldRules`. Returns every violation so
57
+ * callers can surface a structured error (per-field message) without
58
+ * walking the rules themselves.
59
+ */
60
+ declare function validateUpdateBody(body?: Record<string, unknown>, options?: SchemaBuilderOptions): ValidationResult;
61
+ //#endregion
62
+ export { applyFieldRules, collectFieldsToOmit, getImmutableFields, getSystemManagedFields, isFieldUpdateAllowed, validateUpdateBody };
@@ -0,0 +1,110 @@
1
+ //#region src/schema/field-rules.ts
2
+ /**
3
+ * Collect the set of fields that must NOT appear in a generated schema.
4
+ *
5
+ * Combines four sources in priority order:
6
+ * 1. Always-hidden system fields (`createdAt`, `updatedAt`, `__v`).
7
+ * 2. `fieldRules[field].systemManaged` → hidden from both create & update.
8
+ * 3. For update schemas: `fieldRules[field].immutable` /
9
+ * `immutableAfterCreate` → hidden from update only.
10
+ * 4. `options.create.omitFields` / `options.update.omitFields` — explicit
11
+ * caller-provided omit list for the matching purpose.
12
+ *
13
+ * Returns a fresh `Set<string>` so callers can freely mutate.
14
+ */
15
+ function collectFieldsToOmit(options, purpose) {
16
+ const result = new Set([
17
+ "createdAt",
18
+ "updatedAt",
19
+ "__v"
20
+ ]);
21
+ const rules = options?.fieldRules ?? {};
22
+ for (const [field, rule] of Object.entries(rules)) {
23
+ if (rule.systemManaged) result.add(field);
24
+ if (purpose === "update" && (rule.immutable || rule.immutableAfterCreate)) result.add(field);
25
+ }
26
+ const explicit = purpose === "create" ? options?.create?.omitFields : options?.update?.omitFields;
27
+ if (explicit) for (const f of explicit) result.add(f);
28
+ return result;
29
+ }
30
+ /**
31
+ * Apply omissions + `optional` overrides to a built JSON Schema in place.
32
+ *
33
+ * Deletes each omitted field from `schema.properties` AND removes it from
34
+ * `schema.required`. Also honors `fieldRules[field].optional` by stripping
35
+ * matching names from `required`.
36
+ *
37
+ * In-place mutation is deliberate: every kit's builder constructs a fresh
38
+ * schema immediately before calling this helper, so there is no risk of
39
+ * aliasing a schema the caller still holds.
40
+ */
41
+ function applyFieldRules(schema, fieldsToOmit, options) {
42
+ for (const field of fieldsToOmit) {
43
+ if (schema.properties?.[field]) delete schema.properties[field];
44
+ if (schema.required) schema.required = schema.required.filter((k) => k !== field);
45
+ }
46
+ const rules = options?.fieldRules ?? {};
47
+ for (const [field, rule] of Object.entries(rules)) if (rule.optional && schema.required) schema.required = schema.required.filter((k) => k !== field);
48
+ }
49
+ /**
50
+ * List of fields that cannot be mutated through an update body.
51
+ *
52
+ * Union of:
53
+ * - Every `fieldRules[field].immutable` / `immutableAfterCreate` entry.
54
+ * - Every `options.update.omitFields` entry (explicit exclusion still
55
+ * counts as immutable from the caller's perspective).
56
+ *
57
+ * Returns a deduplicated array; insertion order follows rules-then-omitFields.
58
+ */
59
+ function getImmutableFields(options = {}) {
60
+ const immutable = [];
61
+ const rules = options?.fieldRules ?? {};
62
+ for (const [field, rule] of Object.entries(rules)) if (rule.immutable || rule.immutableAfterCreate) immutable.push(field);
63
+ for (const f of options?.update?.omitFields ?? []) if (!immutable.includes(f)) immutable.push(f);
64
+ return immutable;
65
+ }
66
+ /**
67
+ * List of fields that cannot be set by clients on either create or update.
68
+ * These are typically stamps written by the server (audit trail, computed
69
+ * state) regardless of method.
70
+ */
71
+ function getSystemManagedFields(options = {}) {
72
+ const systemManaged = [];
73
+ const rules = options?.fieldRules ?? {};
74
+ for (const [field, rule] of Object.entries(rules)) if (rule.systemManaged) systemManaged.push(field);
75
+ return systemManaged;
76
+ }
77
+ /**
78
+ * Convenience: is `fieldName` allowed in an update body?
79
+ *
80
+ * Equivalent to `!getImmutableFields(...).includes(fieldName) &&
81
+ * !getSystemManagedFields(...).includes(fieldName)` — the exact semantics
82
+ * enforced by `validateUpdateBody`.
83
+ */
84
+ function isFieldUpdateAllowed(fieldName, options = {}) {
85
+ return !getImmutableFields(options).includes(fieldName) && !getSystemManagedFields(options).includes(fieldName);
86
+ }
87
+ /**
88
+ * Validate an update body against `fieldRules`. Returns every violation so
89
+ * callers can surface a structured error (per-field message) without
90
+ * walking the rules themselves.
91
+ */
92
+ function validateUpdateBody(body = {}, options = {}) {
93
+ const violations = [];
94
+ const immutableFields = getImmutableFields(options);
95
+ const systemManagedFields = getSystemManagedFields(options);
96
+ for (const field of Object.keys(body)) if (immutableFields.includes(field)) violations.push({
97
+ field,
98
+ reason: "Field is immutable"
99
+ });
100
+ else if (systemManagedFields.includes(field)) violations.push({
101
+ field,
102
+ reason: "Field is system-managed"
103
+ });
104
+ return {
105
+ valid: violations.length === 0,
106
+ violations
107
+ };
108
+ }
109
+ //#endregion
110
+ export { applyFieldRules, collectFieldsToOmit, getImmutableFields, getSystemManagedFields, isFieldUpdateAllowed, validateUpdateBody };
@@ -0,0 +1,3 @@
1
+ import { CrudSchemas, FieldRule, FieldRules, JsonSchema, SchemaBuilderOptions, ValidationResult } from "./types.mjs";
2
+ import { applyFieldRules, collectFieldsToOmit, getImmutableFields, getSystemManagedFields, isFieldUpdateAllowed, validateUpdateBody } from "./field-rules.mjs";
3
+ export { type CrudSchemas, type FieldRule, type FieldRules, type JsonSchema, type SchemaBuilderOptions, type ValidationResult, applyFieldRules, collectFieldsToOmit, getImmutableFields, getSystemManagedFields, isFieldUpdateAllowed, validateUpdateBody };
@@ -0,0 +1,2 @@
1
+ import { applyFieldRules, collectFieldsToOmit, getImmutableFields, getSystemManagedFields, isFieldUpdateAllowed, validateUpdateBody } from "./field-rules.mjs";
2
+ export { applyFieldRules, collectFieldsToOmit, getImmutableFields, getSystemManagedFields, isFieldUpdateAllowed, validateUpdateBody };