@classytic/repo-core 0.1.0 → 0.3.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 +153 -1
- package/README.md +28 -7
- package/dist/errors/contract.d.mts +37 -0
- package/dist/errors/contract.mjs +75 -0
- package/dist/errors/index.d.mts +3 -2
- package/dist/errors/index.mjs +3 -1
- package/dist/errors/types.d.mts +113 -8
- package/dist/errors/types.mjs +29 -0
- package/dist/pagination/canonical.d.mts +35 -0
- package/dist/pagination/canonical.mjs +32 -0
- package/dist/pagination/index.d.mts +3 -2
- package/dist/pagination/index.mjs +2 -1
- package/dist/pagination/types.d.mts +65 -1
- package/dist/repository/base.mjs +21 -0
- package/dist/repository/index.d.mts +3 -2
- package/dist/repository/types.d.mts +63 -10
- package/dist/schema/field-rules.d.mts +19 -8
- package/dist/schema/field-rules.mjs +29 -9
- package/dist/schema/generator.d.mts +72 -0
- package/dist/schema/generator.mjs +16 -0
- package/dist/schema/index.d.mts +2 -1
- package/dist/schema/index.mjs +2 -1
- package/dist/schema/types.d.mts +56 -3
- package/dist/tenant/index.d.mts +3 -0
- package/dist/tenant/index.mjs +2 -0
- package/dist/tenant/resolve.d.mts +27 -0
- package/dist/tenant/resolve.mjs +69 -0
- package/dist/tenant/types.d.mts +142 -0
- package/dist/update/builders.d.mts +52 -0
- package/dist/update/builders.mjs +92 -0
- package/dist/update/compile.d.mts +46 -0
- package/dist/update/compile.mjs +33 -0
- package/dist/update/guard.d.mts +20 -0
- package/dist/update/guard.mjs +24 -0
- package/dist/update/index.d.mts +5 -0
- package/dist/update/index.mjs +4 -0
- package/dist/update/types.d.mts +62 -0
- package/package.json +13 -1
|
@@ -132,5 +132,69 @@ interface KeysetPaginationResultCore<TDoc> {
|
|
|
132
132
|
* for the rationale. Defaults to `{}`.
|
|
133
133
|
*/
|
|
134
134
|
type KeysetPaginationResult<TDoc, TExtra extends Record<string, unknown> = {}> = KeysetPaginationResultCore<TDoc> & TExtra;
|
|
135
|
+
/**
|
|
136
|
+
* Core fields of an aggregate-paginated result. Don't consume this directly —
|
|
137
|
+
* use `AggregatePaginationResult<TDoc>` or `AggregatePaginationResult<TDoc, TExtra>`.
|
|
138
|
+
*
|
|
139
|
+
* Aggregate pagination produces page-shaped envelopes from arbitrary aggregate
|
|
140
|
+
* pipelines (mongokit's `aggregatePaginate` / `aggregatePipelinePaginate`,
|
|
141
|
+
* pgkit's CTE-based windowed counts, etc). The shape mirrors offset because
|
|
142
|
+
* the math is the same — the discriminant exists so consumers can route
|
|
143
|
+
* "this came from an aggregate, not a plain find" without inspecting the
|
|
144
|
+
* pipeline.
|
|
145
|
+
*/
|
|
146
|
+
interface AggregatePaginationResultCore<TDoc> {
|
|
147
|
+
method: 'aggregate';
|
|
148
|
+
docs: TDoc[];
|
|
149
|
+
page: number;
|
|
150
|
+
limit: number;
|
|
151
|
+
total: number;
|
|
152
|
+
pages: number;
|
|
153
|
+
hasNext: boolean;
|
|
154
|
+
hasPrev: boolean;
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Aggregate-paginated result envelope.
|
|
158
|
+
*
|
|
159
|
+
* `TExtra` parallels `OffsetPaginationResult` — kits surface deep-pagination
|
|
160
|
+
* warnings (`warning?: string`), aggregate-specific stats, etc.
|
|
161
|
+
*/
|
|
162
|
+
type AggregatePaginationResult<TDoc, TExtra extends Record<string, unknown> = {}> = AggregatePaginationResultCore<TDoc> & TExtra;
|
|
163
|
+
/**
|
|
164
|
+
* Union of every pagination *result* shape (server-side, pre-wire).
|
|
165
|
+
*
|
|
166
|
+
* What kits return from `getAll` / `aggregatePaginate`. Use this as the
|
|
167
|
+
* input type to anything that converts repo results into HTTP envelopes —
|
|
168
|
+
* see {@link toCanonicalList}.
|
|
169
|
+
*/
|
|
170
|
+
type AnyPaginationResult<TDoc, TExtra extends Record<string, unknown> = {}> = OffsetPaginationResult<TDoc, TExtra> | KeysetPaginationResult<TDoc, TExtra> | AggregatePaginationResult<TDoc, TExtra>;
|
|
171
|
+
/** HTTP success envelope wrapping {@link OffsetPaginationResult}. */
|
|
172
|
+
type OffsetPaginationResponse<TDoc, TExtra extends Record<string, unknown> = {}> = {
|
|
173
|
+
success: true;
|
|
174
|
+
} & OffsetPaginationResult<TDoc, TExtra>;
|
|
175
|
+
/** HTTP success envelope wrapping {@link KeysetPaginationResult}. */
|
|
176
|
+
type KeysetPaginationResponse<TDoc, TExtra extends Record<string, unknown> = {}> = {
|
|
177
|
+
success: true;
|
|
178
|
+
} & KeysetPaginationResult<TDoc, TExtra>;
|
|
179
|
+
/** HTTP success envelope wrapping {@link AggregatePaginationResult}. */
|
|
180
|
+
type AggregatePaginationResponse<TDoc, TExtra extends Record<string, unknown> = {}> = {
|
|
181
|
+
success: true;
|
|
182
|
+
} & AggregatePaginationResult<TDoc, TExtra>;
|
|
183
|
+
/**
|
|
184
|
+
* Bare list envelope — a successful response that wasn't paginated (raw
|
|
185
|
+
* array result). No `method` discriminant; consumers branch on the absence
|
|
186
|
+
* of pagination fields. Most useful when an endpoint sometimes paginates
|
|
187
|
+
* and sometimes returns a fixed-size list.
|
|
188
|
+
*/
|
|
189
|
+
interface BareListResponse<TDoc> {
|
|
190
|
+
success: true;
|
|
191
|
+
docs: TDoc[];
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Union of every wire envelope a paginated/list endpoint can emit. Locked
|
|
195
|
+
* to `success: true` because errors take a separate envelope shape — a
|
|
196
|
+
* client-side type guard checks `success` first, then `method`.
|
|
197
|
+
*/
|
|
198
|
+
type PaginatedResponse<TDoc, TExtra extends Record<string, unknown> = {}> = OffsetPaginationResponse<TDoc, TExtra> | KeysetPaginationResponse<TDoc, TExtra> | AggregatePaginationResponse<TDoc, TExtra> | BareListResponse<TDoc>;
|
|
135
199
|
//#endregion
|
|
136
|
-
export { CursorPayload, DecodedCursor, KeysetPaginationResult, KeysetPaginationResultCore, OffsetPaginationResult, OffsetPaginationResultCore, PaginationConfig, SortDirection, SortSpec, ValueType };
|
|
200
|
+
export { AggregatePaginationResponse, AggregatePaginationResult, AggregatePaginationResultCore, AnyPaginationResult, BareListResponse, CursorPayload, DecodedCursor, KeysetPaginationResponse, KeysetPaginationResult, KeysetPaginationResultCore, OffsetPaginationResponse, OffsetPaginationResult, OffsetPaginationResultCore, PaginatedResponse, PaginationConfig, SortDirection, SortSpec, ValueType };
|
package/dist/repository/base.mjs
CHANGED
|
@@ -15,11 +15,13 @@ var RepositoryBase = class {
|
|
|
15
15
|
this.modelName = options.name;
|
|
16
16
|
this.hooks = new HookEngine(options.hooks ?? "async");
|
|
17
17
|
const plugins = options.plugins ?? [];
|
|
18
|
+
for (let i = 0; i < plugins.length; i++) assertValidPlugin(plugins[i], this.modelName, i);
|
|
18
19
|
validatePluginOrder(plugins, this.modelName, options.pluginOrderChecks ?? "warn", options.onPluginOrderWarning);
|
|
19
20
|
for (const plugin of plugins) this.use(plugin);
|
|
20
21
|
}
|
|
21
22
|
/** Install a plugin (object with `apply(repo)` or a plain function). */
|
|
22
23
|
use(plugin) {
|
|
24
|
+
assertValidPlugin(plugin, this.modelName);
|
|
23
25
|
if (typeof plugin === "function") plugin(this);
|
|
24
26
|
else plugin.apply(this);
|
|
25
27
|
return this;
|
|
@@ -107,5 +109,24 @@ var RepositoryBase = class {
|
|
|
107
109
|
return context["_cachedResult"];
|
|
108
110
|
}
|
|
109
111
|
};
|
|
112
|
+
/**
|
|
113
|
+
* Reject malformed plugin entries before they reach `use()`.
|
|
114
|
+
*
|
|
115
|
+
* Caught the field-reported `new Repository(Model, ['organizationId'], ...)`
|
|
116
|
+
* crash where a tenant-field string array landed in the plugins slot and
|
|
117
|
+
* blew up with `TypeError: plugin.apply is not a function` deep inside the
|
|
118
|
+
* constructor. Validating shape up front turns that into a single, action-
|
|
119
|
+
* able error pointing at the offending index.
|
|
120
|
+
*/
|
|
121
|
+
function assertValidPlugin(plugin, repoName, index) {
|
|
122
|
+
const where = typeof index === "number" ? `plugin at index ${index}` : "plugin";
|
|
123
|
+
if (plugin === null || plugin === void 0) throw new TypeError(`[repo-core] Repository "${repoName}": ${where} is ${plugin === null ? "null" : "undefined"}. Expected a function \`(repo) => void\` or an object \`{ name, apply(repo) }\`.`);
|
|
124
|
+
if (typeof plugin === "function") return;
|
|
125
|
+
if (typeof plugin !== "object") {
|
|
126
|
+
const detail = typeof plugin === "string" ? `'${plugin}'` : "";
|
|
127
|
+
throw new TypeError(`[repo-core] Repository "${repoName}": ${where} has wrong type. Expected a function or { name, apply(repo) } object — got ${typeof plugin} ${detail}. Common cause: \`new Repository(Model, [tenantField], opts)\` — second argument must be a plugins array.`);
|
|
128
|
+
}
|
|
129
|
+
if (typeof plugin.apply !== "function") throw new TypeError(`[repo-core] Repository "${repoName}": ${where} is an object but missing \`apply(repo)\`. Expected \`{ name: string, apply: (repo) => void }\`.`);
|
|
130
|
+
}
|
|
110
131
|
//#endregion
|
|
111
132
|
export { RepositoryBase };
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { LookupPopulateOptions, LookupPopulateResult, LookupRow, LookupSpec } from "../lookup/types.mjs";
|
|
2
|
+
import { UpdateInput } from "../update/types.mjs";
|
|
2
3
|
import { PLUGIN_ORDER_CONSTRAINTS, Plugin, PluginFunction, PluginType, validatePluginOrder } from "./plugin-types.mjs";
|
|
3
4
|
import { RepositoryBase, RepositoryBaseOptions } from "./base.mjs";
|
|
4
|
-
import { AggMeasure, AggPaginationRequest, AggRequest, AggResult, AggRow, BulkWriteOperation, BulkWriteResult, DeleteManyResult, DeleteOptions, DeleteResult, FindOneAndUpdateOptions, InferDoc, MinimalRepo, PaginationParams, QueryOptions, RepositorySession, StandardRepo, UpdateManyResult, WriteOptions } from "./types.mjs";
|
|
5
|
-
export { type AggMeasure, type AggPaginationRequest, type AggRequest, type AggResult, type AggRow, type BulkWriteOperation, type BulkWriteResult, type DeleteManyResult, type DeleteOptions, type DeleteResult, type FindOneAndUpdateOptions, type InferDoc, type LookupPopulateOptions, type LookupPopulateResult, type LookupRow, type LookupSpec, type MinimalRepo, PLUGIN_ORDER_CONSTRAINTS, type PaginationParams, type Plugin, type PluginFunction, type PluginType, type QueryOptions, RepositoryBase, type RepositoryBaseOptions, type RepositorySession, type StandardRepo, type UpdateManyResult, type WriteOptions, validatePluginOrder };
|
|
5
|
+
import { AggMeasure, AggPaginationRequest, AggRequest, AggResult, AggRow, BulkWriteOperation, BulkWriteResult, DeleteManyResult, DeleteOptions, DeleteResult, FilterInput, FindOneAndUpdateOptions, InferDoc, MinimalRepo, PaginationParams, QueryOptions, RepositorySession, StandardRepo, UpdateManyResult, WriteOptions } from "./types.mjs";
|
|
6
|
+
export { type AggMeasure, type AggPaginationRequest, type AggRequest, type AggResult, type AggRow, type BulkWriteOperation, type BulkWriteResult, type DeleteManyResult, type DeleteOptions, type DeleteResult, type FilterInput, type FindOneAndUpdateOptions, type InferDoc, type LookupPopulateOptions, type LookupPopulateResult, type LookupRow, type LookupSpec, type MinimalRepo, PLUGIN_ORDER_CONSTRAINTS, type PaginationParams, type Plugin, type PluginFunction, type PluginType, type QueryOptions, RepositoryBase, type RepositoryBaseOptions, type RepositorySession, type StandardRepo, type UpdateInput, type UpdateManyResult, type WriteOptions, validatePluginOrder };
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Filter } from "../filter/types.mjs";
|
|
2
2
|
import { OffsetPaginationResult } from "../pagination/types.mjs";
|
|
3
3
|
import { LookupPopulateOptions, LookupPopulateResult } from "../lookup/types.mjs";
|
|
4
|
+
import { UpdateInput } from "../update/types.mjs";
|
|
4
5
|
|
|
5
6
|
//#region src/repository/types.d.ts
|
|
6
7
|
/**
|
|
@@ -286,7 +287,17 @@ interface AggResult<TRow extends AggRow = AggRow> {
|
|
|
286
287
|
* - **Raw** — neither; kit returns all matching docs (may be large).
|
|
287
288
|
*/
|
|
288
289
|
interface PaginationParams<TDoc = unknown> {
|
|
289
|
-
|
|
290
|
+
/**
|
|
291
|
+
* Predicate narrowing the rows that feed into the list query. Accepts
|
|
292
|
+
* the portable Filter IR (`and(eq(...), gt(...))`) OR a flat kit-native
|
|
293
|
+
* record (`{ status: 'active', age: { $gt: 18 } }`). Every kit's
|
|
294
|
+
* `getAll` compiler handles both forms.
|
|
295
|
+
*
|
|
296
|
+
* The `Partial<TDoc>` intersection preserves the old "typed flat record"
|
|
297
|
+
* DX for callers that pass a POJO — they still get autocomplete on
|
|
298
|
+
* known document fields while the union branch allows the Filter IR.
|
|
299
|
+
*/
|
|
300
|
+
filters?: (Partial<TDoc> & Record<string, unknown>) | Filter;
|
|
290
301
|
sort?: string | Record<string, 1 | -1>;
|
|
291
302
|
page?: number;
|
|
292
303
|
limit?: number;
|
|
@@ -373,8 +384,26 @@ interface StandardRepo<TDoc> extends MinimalRepo<TDoc> {
|
|
|
373
384
|
* Required for arc's outbox, distributed-lock, and workflow-semaphore
|
|
374
385
|
* patterns. Kits without atomic CAS should simulate it inside a
|
|
375
386
|
* transaction — arc's stores assume single-round-trip semantics.
|
|
387
|
+
*
|
|
388
|
+
* **Update argument forms** (see {@link UpdateInput}):
|
|
389
|
+
*
|
|
390
|
+
* 1. `UpdateSpec` — portable IR built via `update({ set, unset, inc,
|
|
391
|
+
* setOnInsert })`. Every kit compiles this to its native shape.
|
|
392
|
+
* **Prefer this for portable code** (arc's infrastructure stores,
|
|
393
|
+
* plugins targeting multiple backends).
|
|
394
|
+
* 2. `Record<string, unknown>` — kit-native raw record. mongokit
|
|
395
|
+
* treats this as a Mongo operator document (`$set`, `$inc`,
|
|
396
|
+
* `$unset`, ...). SQL kits treat it as flat column overwrites. Use
|
|
397
|
+
* for kit-specific fast paths.
|
|
398
|
+
* 3. `Record<string, unknown>[]` — Mongo aggregation pipeline. Only
|
|
399
|
+
* mongokit executes this; SQL kits throw `UnsupportedOperationError`.
|
|
400
|
+
* Use for the rare cases where you need `$ifNull` / `$cond` /
|
|
401
|
+
* `$toLower` to preserve invariants atomically (e.g. outbox's
|
|
402
|
+
* `firstFailedAt`).
|
|
403
|
+
*
|
|
404
|
+
* Kits dispatch via `isUpdateSpec(update)` from `@classytic/repo-core/update`.
|
|
376
405
|
*/
|
|
377
|
-
findOneAndUpdate?(filter: FilterInput, update:
|
|
406
|
+
findOneAndUpdate?(filter: FilterInput, update: UpdateInput, options?: FindOneAndUpdateOptions): Promise<TDoc | null>;
|
|
378
407
|
/**
|
|
379
408
|
* Classify an error from a write as a unique-constraint violation.
|
|
380
409
|
* Arc's idempotency + outbox adapters need this to distinguish
|
|
@@ -397,14 +426,38 @@ interface StandardRepo<TDoc> extends MinimalRepo<TDoc> {
|
|
|
397
426
|
findAll?(filter?: FilterInput, options?: QueryOptions): Promise<TDoc[]>;
|
|
398
427
|
getOrCreate?(filter: FilterInput, data: Partial<TDoc>, options?: WriteOptions): Promise<TDoc | null>;
|
|
399
428
|
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
429
|
/**
|
|
403
|
-
*
|
|
404
|
-
*
|
|
405
|
-
*
|
|
406
|
-
*
|
|
407
|
-
*
|
|
430
|
+
* Apply the same update to every matching document. Required — every
|
|
431
|
+
* `StandardRepo` kit must implement bulk update; arc's outbox,
|
|
432
|
+
* idempotency, and cleanup stores depend on it. `data` accepts the
|
|
433
|
+
* same three forms as {@link findOneAndUpdate}: portable `UpdateSpec`,
|
|
434
|
+
* kit-native raw record, or Mongo aggregation pipeline (mongokit-only).
|
|
435
|
+
*
|
|
436
|
+
* **Promoted from optional to required in repo-core 0.2.0** — sqlitekit
|
|
437
|
+
* and mongokit both ship this as a class primitive. Third-party kits
|
|
438
|
+
* that previously omitted it now need to implement. Kits that lack a
|
|
439
|
+
* native bulk-update primitive should fan out in a transaction.
|
|
440
|
+
*/
|
|
441
|
+
updateMany(filter: FilterInput, data: UpdateInput, options?: WriteOptions): Promise<UpdateManyResult>;
|
|
442
|
+
/**
|
|
443
|
+
* Delete every document matching the filter. Required — symmetrically
|
|
444
|
+
* with `updateMany`. Pass `{ mode: 'hard' }` to bypass soft-delete
|
|
445
|
+
* interception; kits without soft-delete accept and ignore the flag.
|
|
446
|
+
*
|
|
447
|
+
* **Promoted from optional to required in repo-core 0.2.0.**
|
|
448
|
+
*/
|
|
449
|
+
deleteMany(filter: FilterInput, options?: DeleteOptions): Promise<DeleteManyResult>;
|
|
450
|
+
/**
|
|
451
|
+
* Heterogeneous bulk write. Stays optional — kits dispatch each op
|
|
452
|
+
* against the appropriate driver primitive inside a single transaction;
|
|
453
|
+
* see each kit's docs for the exact semantics of `upsert` and
|
|
454
|
+
* operator-shaped update values (mongokit honors `$set` etc., SQL kits
|
|
455
|
+
* treat `update` as a flat column overwrite).
|
|
456
|
+
*
|
|
457
|
+
* Kept optional because the mongoose-shaped `BulkWriteOperation` has no
|
|
458
|
+
* clean SQL analogue beyond "loop and dispatch" — forcing every kit to
|
|
459
|
+
* implement it would push kits to ship a thin wrapper around updateMany
|
|
460
|
+
* / deleteMany that offers nothing over calling them directly.
|
|
408
461
|
*/
|
|
409
462
|
bulkWrite?(operations: readonly BulkWriteOperation<TDoc>[]): Promise<BulkWriteResult>;
|
|
410
463
|
/**
|
|
@@ -467,4 +520,4 @@ interface StandardRepo<TDoc> extends MinimalRepo<TDoc> {
|
|
|
467
520
|
withTransaction?<T>(fn: (txRepo: StandardRepo<TDoc>) => Promise<T>, options?: Record<string, unknown>): Promise<T>;
|
|
468
521
|
}
|
|
469
522
|
//#endregion
|
|
470
|
-
export { AggMeasure, AggPaginationRequest, AggRequest, AggResult, AggRow, BulkWriteOperation, BulkWriteResult, DeleteManyResult, DeleteOptions, DeleteResult, FindOneAndUpdateOptions, InferDoc, MinimalRepo, PaginationParams, QueryOptions, RepositorySession, StandardRepo, UpdateManyResult, WriteOptions };
|
|
523
|
+
export { AggMeasure, AggPaginationRequest, AggRequest, AggResult, AggRow, BulkWriteOperation, BulkWriteResult, DeleteManyResult, DeleteOptions, DeleteResult, FilterInput, FindOneAndUpdateOptions, InferDoc, MinimalRepo, PaginationParams, QueryOptions, RepositorySession, StandardRepo, UpdateManyResult, WriteOptions };
|
|
@@ -4,17 +4,28 @@ import { JsonSchema, SchemaBuilderOptions, ValidationResult } from "./types.mjs"
|
|
|
4
4
|
/**
|
|
5
5
|
* Collect the set of fields that must NOT appear in a generated schema.
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
7
|
+
* Three purposes have three different policies:
|
|
8
|
+
*
|
|
9
|
+
* - `'create'` / `'update'` (request-body schemas):
|
|
10
|
+
* 1. Always-hidden system fields (`createdAt`, `updatedAt`, `__v`).
|
|
11
|
+
* 2. `fieldRules[field].systemManaged` → hidden from both.
|
|
12
|
+
* 3. `'update'` only: `fieldRules[field].immutable` /
|
|
13
|
+
* `immutableAfterCreate` → hidden from update.
|
|
14
|
+
* 4. `options.create.omitFields` / `options.update.omitFields` —
|
|
15
|
+
* explicit caller-provided omit list for the matching purpose.
|
|
16
|
+
*
|
|
17
|
+
* - `'response'` (response-shape schema):
|
|
18
|
+
* 1. `fieldRules[field].hidden: true` ONLY — passwords, secrets,
|
|
19
|
+
* internal scoring. Server-set fields (`createdAt`, `updatedAt`,
|
|
20
|
+
* `_id`, systemManaged, immutable / readonly) ARE returned to
|
|
21
|
+
* clients and so ARE included in the response shape.
|
|
22
|
+
* 2. `options.response?.omitFields` — explicit caller-provided omit
|
|
23
|
+
* list when the host wants to strip extra fields from responses
|
|
24
|
+
* without marking them `hidden` globally.
|
|
14
25
|
*
|
|
15
26
|
* Returns a fresh `Set<string>` so callers can freely mutate.
|
|
16
27
|
*/
|
|
17
|
-
declare function collectFieldsToOmit(options: SchemaBuilderOptions, purpose: 'create' | 'update'): Set<string>;
|
|
28
|
+
declare function collectFieldsToOmit(options: SchemaBuilderOptions, purpose: 'create' | 'update' | 'response'): Set<string>;
|
|
18
29
|
/**
|
|
19
30
|
* Apply omissions + `optional` overrides to a built JSON Schema in place.
|
|
20
31
|
*
|
|
@@ -2,23 +2,43 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* Collect the set of fields that must NOT appear in a generated schema.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
5
|
+
* Three purposes have three different policies:
|
|
6
|
+
*
|
|
7
|
+
* - `'create'` / `'update'` (request-body schemas):
|
|
8
|
+
* 1. Always-hidden system fields (`createdAt`, `updatedAt`, `__v`).
|
|
9
|
+
* 2. `fieldRules[field].systemManaged` → hidden from both.
|
|
10
|
+
* 3. `'update'` only: `fieldRules[field].immutable` /
|
|
11
|
+
* `immutableAfterCreate` → hidden from update.
|
|
12
|
+
* 4. `options.create.omitFields` / `options.update.omitFields` —
|
|
13
|
+
* explicit caller-provided omit list for the matching purpose.
|
|
14
|
+
*
|
|
15
|
+
* - `'response'` (response-shape schema):
|
|
16
|
+
* 1. `fieldRules[field].hidden: true` ONLY — passwords, secrets,
|
|
17
|
+
* internal scoring. Server-set fields (`createdAt`, `updatedAt`,
|
|
18
|
+
* `_id`, systemManaged, immutable / readonly) ARE returned to
|
|
19
|
+
* clients and so ARE included in the response shape.
|
|
20
|
+
* 2. `options.response?.omitFields` — explicit caller-provided omit
|
|
21
|
+
* list when the host wants to strip extra fields from responses
|
|
22
|
+
* without marking them `hidden` globally.
|
|
12
23
|
*
|
|
13
24
|
* Returns a fresh `Set<string>` so callers can freely mutate.
|
|
14
25
|
*/
|
|
15
26
|
function collectFieldsToOmit(options, purpose) {
|
|
27
|
+
const rules = options?.fieldRules ?? {};
|
|
28
|
+
const globalExcludes = options?.excludeFields ?? [];
|
|
29
|
+
if (purpose === "response") {
|
|
30
|
+
const result = new Set(globalExcludes);
|
|
31
|
+
for (const [field, rule] of Object.entries(rules)) if (rule.hidden) result.add(field);
|
|
32
|
+
const explicit = options?.response?.omitFields;
|
|
33
|
+
if (explicit) for (const f of explicit) result.add(f);
|
|
34
|
+
return result;
|
|
35
|
+
}
|
|
16
36
|
const result = new Set([
|
|
17
37
|
"createdAt",
|
|
18
38
|
"updatedAt",
|
|
19
|
-
"__v"
|
|
39
|
+
"__v",
|
|
40
|
+
...globalExcludes
|
|
20
41
|
]);
|
|
21
|
-
const rules = options?.fieldRules ?? {};
|
|
22
42
|
for (const [field, rule] of Object.entries(rules)) {
|
|
23
43
|
if (rule.systemManaged) result.add(field);
|
|
24
44
|
if (purpose === "update" && (rule.immutable || rule.immutableAfterCreate)) result.add(field);
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { CrudSchemas, SchemaBuilderOptions } from "./types.mjs";
|
|
2
|
+
|
|
3
|
+
//#region src/schema/generator.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* Resource-level context threaded into the generator at boot. Lets the
|
|
6
|
+
* generator shape output to per-resource config (idField pattern,
|
|
7
|
+
* resource name for OpenAPI titles).
|
|
8
|
+
*
|
|
9
|
+
* All fields optional — generators that ignore the context still produce
|
|
10
|
+
* valid schemas; arc applies safety-net normalization downstream.
|
|
11
|
+
*/
|
|
12
|
+
interface SchemaGeneratorContext {
|
|
13
|
+
/**
|
|
14
|
+
* The `idField` configured on the resource. Defaults to `'_id'` for
|
|
15
|
+
* Mongoose-shaped kits, `'id'` for SQL kits. Generators emit the
|
|
16
|
+
* matching `params.properties[idField]` so route-param validation
|
|
17
|
+
* matches the actual lookup field.
|
|
18
|
+
*/
|
|
19
|
+
idField?: string;
|
|
20
|
+
/** Resource name (for OpenAPI titles, generator log messages). */
|
|
21
|
+
resourceName?: string;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Canonical generator contract. Functions that produce CRUD JSON schemas
|
|
25
|
+
* for a kit satisfy this shape — `mongokit/buildCrudSchemasFromModel`,
|
|
26
|
+
* `sqlitekit/buildCrudSchemasFromTable`, etc.
|
|
27
|
+
*
|
|
28
|
+
* The return type is intentionally widened to `CrudSchemas | Record<string,
|
|
29
|
+
* unknown>` so kits that emit additional vendor-specific schema fields
|
|
30
|
+
* (`x-ref`, `x-foreign-key`, OpenAPI extensions) flow through without
|
|
31
|
+
* type erosion. Arc's adapter post-processes via `mergeFieldRuleConstraints`
|
|
32
|
+
* so portable `fieldRules` constraints (`minLength`/`maxLength`/`min`/
|
|
33
|
+
* `max`/`pattern`/`enum`/`description`/`nullable`) apply uniformly across
|
|
34
|
+
* kit outputs.
|
|
35
|
+
*
|
|
36
|
+
* @typeParam TModel - The kit's native model / table type. `Model<unknown>`
|
|
37
|
+
* for Mongoose kits, a Drizzle `Table` for SQL kits, etc. Widened to
|
|
38
|
+
* `unknown` by default so adapters that don't care about model typing
|
|
39
|
+
* (or cross-kit utilities) pass any model through.
|
|
40
|
+
*
|
|
41
|
+
* @example mongokit conformance (one-line `satisfies`)
|
|
42
|
+
* ```ts
|
|
43
|
+
* import type { SchemaGenerator } from '@classytic/repo-core/schema';
|
|
44
|
+
*
|
|
45
|
+
* export const buildCrudSchemasFromModel = ((model, options, ctx) => {
|
|
46
|
+
* // ... existing impl
|
|
47
|
+
* }) satisfies SchemaGenerator<Model<unknown>>;
|
|
48
|
+
* ```
|
|
49
|
+
*
|
|
50
|
+
* @example arc adapter typing
|
|
51
|
+
* ```ts
|
|
52
|
+
* import type { SchemaGenerator } from '@classytic/repo-core/schema';
|
|
53
|
+
*
|
|
54
|
+
* interface MongooseAdapterOptions<TDoc> {
|
|
55
|
+
* schemaGenerator?: SchemaGenerator<Model<unknown>>;
|
|
56
|
+
* }
|
|
57
|
+
* ```
|
|
58
|
+
*/
|
|
59
|
+
type SchemaGenerator<TModel = unknown> = (model: TModel, options?: SchemaBuilderOptions, context?: SchemaGeneratorContext) => CrudSchemas | Record<string, unknown>;
|
|
60
|
+
/**
|
|
61
|
+
* Runtime predicate — true when `value` matches the generator shape.
|
|
62
|
+
*
|
|
63
|
+
* Conservative: only checks `typeof value === 'function'` and arity.
|
|
64
|
+
* Doesn't invoke the function with a sentinel argument because doing so
|
|
65
|
+
* could trigger expensive schema introspection on a single test call.
|
|
66
|
+
* The structural-typing alignment (`satisfies SchemaGenerator<...>`) is
|
|
67
|
+
* the primary contract enforcement; this guard is for runtime hosts that
|
|
68
|
+
* accept either a generator or a config-bag.
|
|
69
|
+
*/
|
|
70
|
+
declare function isSchemaGenerator(value: unknown): value is SchemaGenerator;
|
|
71
|
+
//#endregion
|
|
72
|
+
export { SchemaGenerator, SchemaGeneratorContext, isSchemaGenerator };
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
//#region src/schema/generator.ts
|
|
2
|
+
/**
|
|
3
|
+
* Runtime predicate — true when `value` matches the generator shape.
|
|
4
|
+
*
|
|
5
|
+
* Conservative: only checks `typeof value === 'function'` and arity.
|
|
6
|
+
* Doesn't invoke the function with a sentinel argument because doing so
|
|
7
|
+
* could trigger expensive schema introspection on a single test call.
|
|
8
|
+
* The structural-typing alignment (`satisfies SchemaGenerator<...>`) is
|
|
9
|
+
* the primary contract enforcement; this guard is for runtime hosts that
|
|
10
|
+
* accept either a generator or a config-bag.
|
|
11
|
+
*/
|
|
12
|
+
function isSchemaGenerator(value) {
|
|
13
|
+
return typeof value === "function" && value.length >= 1 && value.length <= 3;
|
|
14
|
+
}
|
|
15
|
+
//#endregion
|
|
16
|
+
export { isSchemaGenerator };
|
package/dist/schema/index.d.mts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
import { CrudSchemas, FieldRule, FieldRules, JsonSchema, SchemaBuilderOptions, ValidationResult } from "./types.mjs";
|
|
2
2
|
import { applyFieldRules, collectFieldsToOmit, getImmutableFields, getSystemManagedFields, isFieldUpdateAllowed, validateUpdateBody } from "./field-rules.mjs";
|
|
3
|
-
|
|
3
|
+
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, collectFieldsToOmit, getImmutableFields, getSystemManagedFields, isFieldUpdateAllowed, isSchemaGenerator, validateUpdateBody };
|
package/dist/schema/index.mjs
CHANGED
|
@@ -1,2 +1,3 @@
|
|
|
1
1
|
import { applyFieldRules, collectFieldsToOmit, getImmutableFields, getSystemManagedFields, isFieldUpdateAllowed, validateUpdateBody } from "./field-rules.mjs";
|
|
2
|
-
|
|
2
|
+
import { isSchemaGenerator } from "./generator.mjs";
|
|
3
|
+
export { applyFieldRules, collectFieldsToOmit, getImmutableFields, getSystemManagedFields, isFieldUpdateAllowed, isSchemaGenerator, validateUpdateBody };
|
package/dist/schema/types.d.mts
CHANGED
|
@@ -26,6 +26,16 @@ interface FieldRule {
|
|
|
26
26
|
systemManaged?: boolean;
|
|
27
27
|
/** Remove from `required[]` in the generated schema. DB-level constraints unaffected. */
|
|
28
28
|
optional?: boolean;
|
|
29
|
+
/**
|
|
30
|
+
* Strip the field from the response shape. Use for passwords, secrets,
|
|
31
|
+
* internal scoring — anything the server stores but should never echo.
|
|
32
|
+
*
|
|
33
|
+
* Distinct from `systemManaged` (which only affects request bodies):
|
|
34
|
+
* `hidden` is a *response* concern and lives at the schema-builder
|
|
35
|
+
* boundary so kits, OpenAPI tooling, and arc's response serializer
|
|
36
|
+
* narrow on the same flag.
|
|
37
|
+
*/
|
|
38
|
+
hidden?: boolean;
|
|
29
39
|
}
|
|
30
40
|
/** Map of field name → FieldRule. */
|
|
31
41
|
interface FieldRules {
|
|
@@ -56,9 +66,10 @@ interface JsonSchema {
|
|
|
56
66
|
[key: `x-${string}`]: unknown;
|
|
57
67
|
}
|
|
58
68
|
/**
|
|
59
|
-
* CRUD schema bundle — the
|
|
60
|
-
* body validation on POST / PATCH, route-param validation on id routes,
|
|
61
|
-
* query-string validation on list endpoints
|
|
69
|
+
* CRUD schema bundle — the JSON Schemas every HTTP endpoint needs:
|
|
70
|
+
* body validation on POST / PATCH, route-param validation on id routes,
|
|
71
|
+
* query-string validation on list endpoints, and (optionally) response-shape
|
|
72
|
+
* documentation for OpenAPI / strict reply serialization.
|
|
62
73
|
*/
|
|
63
74
|
interface CrudSchemas {
|
|
64
75
|
/** JSON Schema for create request body (POST). */
|
|
@@ -69,6 +80,23 @@ interface CrudSchemas {
|
|
|
69
80
|
params: JsonSchema;
|
|
70
81
|
/** JSON Schema for list/query parameters. */
|
|
71
82
|
listQuery: JsonSchema;
|
|
83
|
+
/**
|
|
84
|
+
* JSON Schema for response shape (optional).
|
|
85
|
+
*
|
|
86
|
+
* Includes every field a client receives — server-set fields
|
|
87
|
+
* (`createdAt`, `updatedAt`, `_id`, immutable / readonly fields) ARE
|
|
88
|
+
* returned to clients and so ARE included in the response shape, in
|
|
89
|
+
* contrast to `createBody` / `updateBody` which exclude them. Only
|
|
90
|
+
* `fieldRules[field].hidden: true` excludes a field from responses
|
|
91
|
+
* (passwords, secrets, internal scoring).
|
|
92
|
+
*
|
|
93
|
+
* Set `additionalProperties: true` so virtuals and computed fields
|
|
94
|
+
* pass through without being stripped by AJV's strict serialization.
|
|
95
|
+
*
|
|
96
|
+
* Optional — kits that don't ship a response builder leave it unset
|
|
97
|
+
* and arc treats response validation as opt-out for that resource.
|
|
98
|
+
*/
|
|
99
|
+
response?: JsonSchema;
|
|
72
100
|
}
|
|
73
101
|
/**
|
|
74
102
|
* Options consumed by every kit's schema builder. Fields are additive:
|
|
@@ -78,6 +106,18 @@ interface CrudSchemas {
|
|
|
78
106
|
interface SchemaBuilderOptions {
|
|
79
107
|
/** Field rules for create/update schemas. */
|
|
80
108
|
fieldRules?: FieldRules;
|
|
109
|
+
/**
|
|
110
|
+
* Global field exclusion — fields listed here are dropped from EVERY
|
|
111
|
+
* generated schema (create / update / response). Shortcut for setting
|
|
112
|
+
* `create.omitFields`, `update.omitFields`, AND `response.omitFields`
|
|
113
|
+
* to the same list. Use for fields that should never appear in any
|
|
114
|
+
* HTTP-facing schema (e.g. internal-only columns, framework-private
|
|
115
|
+
* fields).
|
|
116
|
+
*
|
|
117
|
+
* Per-purpose overrides still apply on top — a field listed here AND
|
|
118
|
+
* in `create.omitFields` is dropped once.
|
|
119
|
+
*/
|
|
120
|
+
excludeFields?: string[];
|
|
81
121
|
/**
|
|
82
122
|
* When `true`, emit `"additionalProperties": false` on create/update/query
|
|
83
123
|
* schemas. Default `false` so generators stay permissive by default;
|
|
@@ -113,6 +153,19 @@ interface SchemaBuilderOptions {
|
|
|
113
153
|
type: string;
|
|
114
154
|
} | unknown>;
|
|
115
155
|
};
|
|
156
|
+
/**
|
|
157
|
+
* Response-schema overrides.
|
|
158
|
+
*
|
|
159
|
+
* Response shape includes server-set fields (`createdAt`, `updatedAt`,
|
|
160
|
+
* `_id`, immutable / readonly / systemManaged fields) since those ARE
|
|
161
|
+
* returned to clients. Only `fieldRules[field].hidden: true` fields are
|
|
162
|
+
* stripped automatically. Use `omitFields` to drop additional fields
|
|
163
|
+
* from responses without marking them globally hidden (e.g. internal
|
|
164
|
+
* scoring you want kept in update bodies but stripped from list reads).
|
|
165
|
+
*/
|
|
166
|
+
response?: {
|
|
167
|
+
/** Extra fields to omit from the response shape. */omitFields?: string[];
|
|
168
|
+
};
|
|
116
169
|
/**
|
|
117
170
|
* Emit OpenAPI vendor extensions (`x-*` keywords like `x-ref` for populated
|
|
118
171
|
* foreign-key fields).
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import { ResolvedTenantConfig, TenantConfig, TenantFieldType, TenantStrategy } from "./types.mjs";
|
|
2
|
+
import { DEFAULT_TENANT_CONFIG, resolveTenantConfig } from "./resolve.mjs";
|
|
3
|
+
export { DEFAULT_TENANT_CONFIG, type ResolvedTenantConfig, type TenantConfig, type TenantFieldType, type TenantStrategy, resolveTenantConfig };
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { ResolvedTenantConfig, TenantConfig } from "./types.mjs";
|
|
2
|
+
|
|
3
|
+
//#region src/tenant/resolve.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* Sensible defaults for a freshly-built package (field strategy).
|
|
6
|
+
*
|
|
7
|
+
* `fieldType: 'objectId'` is the recommended default for new Mongo-shaped
|
|
8
|
+
* kits because it enables `$lookup` / `.populate()`. Existing kits that
|
|
9
|
+
* historically defaulted to `'string'` (mongokit pre-3.x) keep their own
|
|
10
|
+
* runtime default — `Pick<TenantConfig, 'fieldType'>` extension preserves
|
|
11
|
+
* type-level alignment without forcing a runtime default change.
|
|
12
|
+
*/
|
|
13
|
+
declare const DEFAULT_TENANT_CONFIG: Required<Pick<TenantConfig, 'strategy' | 'enabled' | 'tenantField' | 'fieldType' | 'ref' | 'contextKey' | 'required'>>;
|
|
14
|
+
/**
|
|
15
|
+
* Resolve a possibly-partial {@link TenantConfig} against the defaults.
|
|
16
|
+
*
|
|
17
|
+
* - `false` → `enabled: false`, `strategy: 'none'`, `required: false`.
|
|
18
|
+
* - `true` / `undefined` → default field strategy.
|
|
19
|
+
* - Object with `strategy: 'custom'` → `resolve` is required; throws
|
|
20
|
+
* otherwise so the misconfiguration surfaces at boot, not runtime.
|
|
21
|
+
* - Object with `strategy: 'none'` → `enabled: false` (preserves
|
|
22
|
+
* user-supplied `tenantField` / `fieldType` / `ref` so the doc field
|
|
23
|
+
* stays correctly typed even with scoping off).
|
|
24
|
+
*/
|
|
25
|
+
declare function resolveTenantConfig(config?: TenantConfig | boolean): ResolvedTenantConfig;
|
|
26
|
+
//#endregion
|
|
27
|
+
export { DEFAULT_TENANT_CONFIG, resolveTenantConfig };
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
//#region src/tenant/resolve.ts
|
|
2
|
+
/**
|
|
3
|
+
* Sensible defaults for a freshly-built package (field strategy).
|
|
4
|
+
*
|
|
5
|
+
* `fieldType: 'objectId'` is the recommended default for new Mongo-shaped
|
|
6
|
+
* kits because it enables `$lookup` / `.populate()`. Existing kits that
|
|
7
|
+
* historically defaulted to `'string'` (mongokit pre-3.x) keep their own
|
|
8
|
+
* runtime default — `Pick<TenantConfig, 'fieldType'>` extension preserves
|
|
9
|
+
* type-level alignment without forcing a runtime default change.
|
|
10
|
+
*/
|
|
11
|
+
const DEFAULT_TENANT_CONFIG = {
|
|
12
|
+
strategy: "field",
|
|
13
|
+
enabled: true,
|
|
14
|
+
tenantField: "organizationId",
|
|
15
|
+
fieldType: "objectId",
|
|
16
|
+
ref: "organization",
|
|
17
|
+
contextKey: "organizationId",
|
|
18
|
+
required: true
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* Resolve a possibly-partial {@link TenantConfig} against the defaults.
|
|
22
|
+
*
|
|
23
|
+
* - `false` → `enabled: false`, `strategy: 'none'`, `required: false`.
|
|
24
|
+
* - `true` / `undefined` → default field strategy.
|
|
25
|
+
* - Object with `strategy: 'custom'` → `resolve` is required; throws
|
|
26
|
+
* otherwise so the misconfiguration surfaces at boot, not runtime.
|
|
27
|
+
* - Object with `strategy: 'none'` → `enabled: false` (preserves
|
|
28
|
+
* user-supplied `tenantField` / `fieldType` / `ref` so the doc field
|
|
29
|
+
* stays correctly typed even with scoping off).
|
|
30
|
+
*/
|
|
31
|
+
function resolveTenantConfig(config) {
|
|
32
|
+
if (config === false) return {
|
|
33
|
+
...DEFAULT_TENANT_CONFIG,
|
|
34
|
+
strategy: "none",
|
|
35
|
+
enabled: false,
|
|
36
|
+
required: false
|
|
37
|
+
};
|
|
38
|
+
if (config === true || config === void 0) return { ...DEFAULT_TENANT_CONFIG };
|
|
39
|
+
const strategy = config.strategy ?? (config.enabled === false ? "none" : "field");
|
|
40
|
+
const contextKey = config.contextKey ?? config.tenantField ?? DEFAULT_TENANT_CONFIG.contextKey;
|
|
41
|
+
if (strategy === "none") return {
|
|
42
|
+
...DEFAULT_TENANT_CONFIG,
|
|
43
|
+
...config,
|
|
44
|
+
contextKey,
|
|
45
|
+
strategy: "none",
|
|
46
|
+
enabled: false,
|
|
47
|
+
required: false
|
|
48
|
+
};
|
|
49
|
+
if (strategy === "custom") {
|
|
50
|
+
if (typeof config.resolve !== "function") throw new Error("[repo-core] TenantConfig.strategy 'custom' requires a 'resolve' function");
|
|
51
|
+
return {
|
|
52
|
+
...DEFAULT_TENANT_CONFIG,
|
|
53
|
+
...config,
|
|
54
|
+
contextKey,
|
|
55
|
+
strategy: "custom",
|
|
56
|
+
enabled: config.enabled ?? true,
|
|
57
|
+
resolve: config.resolve
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
...DEFAULT_TENANT_CONFIG,
|
|
62
|
+
...config,
|
|
63
|
+
contextKey,
|
|
64
|
+
strategy: "field",
|
|
65
|
+
enabled: config.enabled ?? true
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
//#endregion
|
|
69
|
+
export { DEFAULT_TENANT_CONFIG, resolveTenantConfig };
|