@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.
- package/CHANGELOG.md +67 -0
- package/LICENSE +21 -0
- package/README.md +154 -0
- package/dist/cache/index.d.mts +4 -0
- package/dist/cache/index.mjs +3 -0
- package/dist/cache/memory-adapter.d.mts +7 -0
- package/dist/cache/memory-adapter.mjs +37 -0
- package/dist/cache/stable-stringify.d.mts +15 -0
- package/dist/cache/stable-stringify.mjs +19 -0
- package/dist/cache/types.d.mts +59 -0
- package/dist/context/index.d.mts +2 -0
- package/dist/context/index.mjs +0 -0
- package/dist/context/types.d.mts +24 -0
- package/dist/errors/create-error.d.mts +19 -0
- package/dist/errors/create-error.mjs +23 -0
- package/dist/errors/duplicate-key.d.mts +38 -0
- package/dist/errors/duplicate-key.mjs +57 -0
- package/dist/errors/index.d.mts +4 -0
- package/dist/errors/index.mjs +3 -0
- package/dist/errors/types.d.mts +37 -0
- package/dist/filter/builders.d.mts +60 -0
- package/dist/filter/builders.mjs +172 -0
- package/dist/filter/guard.d.mts +13 -0
- package/dist/filter/guard.mjs +34 -0
- package/dist/filter/index.d.mts +7 -0
- package/dist/filter/index.mjs +6 -0
- package/dist/filter/match.d.mts +12 -0
- package/dist/filter/match.mjs +91 -0
- package/dist/filter/scope.d.mts +31 -0
- package/dist/filter/scope.mjs +54 -0
- package/dist/filter/types.d.mts +143 -0
- package/dist/filter/walk.d.mts +24 -0
- package/dist/filter/walk.mjs +77 -0
- package/dist/hooks/engine.d.mts +48 -0
- package/dist/hooks/engine.mjs +101 -0
- package/dist/hooks/events.d.mts +95 -0
- package/dist/hooks/events.mjs +93 -0
- package/dist/hooks/index.d.mts +5 -0
- package/dist/hooks/index.mjs +4 -0
- package/dist/hooks/priority.d.mts +23 -0
- package/dist/hooks/priority.mjs +21 -0
- package/dist/hooks/types.d.mts +37 -0
- package/dist/lookup/index.d.mts +2 -0
- package/dist/lookup/index.mjs +0 -0
- package/dist/lookup/types.d.mts +170 -0
- package/dist/operations/index.d.mts +3 -0
- package/dist/operations/index.mjs +2 -0
- package/dist/operations/registry.d.mts +41 -0
- package/dist/operations/registry.mjs +140 -0
- package/dist/operations/types.d.mts +49 -0
- package/dist/pagination/cursor.d.mts +44 -0
- package/dist/pagination/cursor.mjs +150 -0
- package/dist/pagination/index.d.mts +5 -0
- package/dist/pagination/index.mjs +4 -0
- package/dist/pagination/keyset.d.mts +25 -0
- package/dist/pagination/keyset.mjs +61 -0
- package/dist/pagination/offset.d.mts +26 -0
- package/dist/pagination/offset.mjs +47 -0
- package/dist/pagination/types.d.mts +136 -0
- package/dist/query-parser/coerce.d.mts +16 -0
- package/dist/query-parser/coerce.mjs +73 -0
- package/dist/query-parser/index.d.mts +4 -0
- package/dist/query-parser/index.mjs +3 -0
- package/dist/query-parser/parse-url.d.mts +7 -0
- package/dist/query-parser/parse-url.mjs +224 -0
- package/dist/query-parser/types.d.mts +104 -0
- package/dist/repository/base.d.mts +90 -0
- package/dist/repository/base.mjs +111 -0
- package/dist/repository/index.d.mts +5 -0
- package/dist/repository/index.mjs +3 -0
- package/dist/repository/plugin-types.d.mts +27 -0
- package/dist/repository/plugin-types.mjs +45 -0
- package/dist/repository/types.d.mts +470 -0
- package/dist/schema/field-rules.d.mts +62 -0
- package/dist/schema/field-rules.mjs +110 -0
- package/dist/schema/index.d.mts +3 -0
- package/dist/schema/index.mjs +2 -0
- package/dist/schema/types.d.mts +138 -0
- package/dist/testing/conformance.d.mts +6 -0
- package/dist/testing/conformance.mjs +481 -0
- package/dist/testing/index.d.mts +3 -0
- package/dist/testing/index.mjs +2 -0
- package/dist/testing/types.d.mts +113 -0
- 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 };
|