@classytic/mongokit 3.43.1 → 3.44.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/README.md +14 -0
- package/dist/{PaginationEngine-DZnkbSrr.mjs → PaginationEngine-C9iS0U5l.mjs} +13 -27
- package/dist/{PaginationEngine-CRHyuUtw.d.mts → PaginationEngine-D17AthKw.d.mts} +1 -3
- package/dist/{Repository-DnL4xk5w.d.mts → Repository-B6oL6OMv.d.mts} +33 -11
- package/dist/{Repository-Dj6xqPxJ.mjs → Repository-eMRYZ_2Y.mjs} +204 -92
- package/dist/actions/index.d.mts +1 -1
- package/dist/actions/index.mjs +2 -2
- package/dist/{actions-ChYfkPYP.mjs → actions-DExyXMTy.mjs} +3 -4
- package/dist/adapter/index.d.mts +6 -10
- package/dist/adapter/index.mjs +1 -1
- package/dist/{adapter-CkMoMF83.mjs → adapter-Qzgc3ZD_.mjs} +4 -6
- package/dist/ai/index.d.mts +21 -8
- package/dist/ai/index.mjs +15 -9
- package/dist/better-auth/index.d.mts +17 -19
- package/dist/better-auth/index.mjs +3 -4
- package/dist/{coercion-BjdPX3YF.d.mts → coercion-COp9DRNw.d.mts} +0 -2
- package/dist/{create-repository-CJtVdwig.d.mts → create-repository-BOFUYj1l.d.mts} +5 -7
- package/dist/{create-repository-UjwPWR1z.mjs → create-repository-D7MQpIZo.mjs} +4 -5
- package/dist/{derive-DRBiegRH.mjs → derive-Dxsc31fV.mjs} +3 -6
- package/dist/{error-BuByZwKU.mjs → error-DjdmPLWO.mjs} +1 -2
- package/dist/{field-selection-FSQFLpsU.mjs → field-selection-DPFaFw9Y.mjs} +1 -2
- package/dist/{id-chunks-BmAwsqNk.mjs → id-chunks-DH-RAZWG.mjs} +13 -8
- package/dist/{id-resolution-BqDrk0S2.mjs → id-resolution-Cqh49d9J.mjs} +1 -2
- package/dist/idempotency/index.d.mts +4 -7
- package/dist/idempotency/index.mjs +1 -2
- package/dist/{index-SYF8N28D.d.mts → index-8QUEsOFI.d.mts} +3 -4
- package/dist/{index-C0AjVqX3.d.mts → index-ehGZLbrd.d.mts} +2 -8
- package/dist/index.d.mts +63 -96
- package/dist/index.mjs +36 -52
- package/dist/indexes/verify-indexes.d.mts +8 -11
- package/dist/indexes/verify-indexes.mjs +1 -2
- package/dist/{indexes-DIjMpYES.d.mts → indexes-FfyOuhDK.d.mts} +0 -2
- package/dist/{join-scope-DSAznE8E.mjs → join-scope-C1gOtZ59.mjs} +2 -3
- package/dist/kernel-conformance/index.d.mts +39 -13
- package/dist/kernel-conformance/index.mjs +4 -7
- package/dist/lock/index.d.mts +134 -7
- package/dist/lock/index.mjs +349 -14
- package/dist/{logger-K7dPmu2U.mjs → logger-hbWOlj_X.mjs} +1 -2
- package/dist/{method-registry.plugin-C63pGn1d.mjs → method-registry.plugin-CGAepsBU.mjs} +3 -5
- package/dist/{model-registry-ClKtWiHa.mjs → model-registry-DrVfJ3Nt.mjs} +1 -2
- package/dist/{mongooseToJsonSchema-C3k4ide0.mjs → mongooseToJsonSchema-DMDotz4_.mjs} +2 -3
- package/dist/{multi-tenant.plugin-B_q9NUHc.d.mts → multi-tenant.plugin-DfeDMiI8.d.mts} +143 -11
- package/dist/{operations-XiFgG5US.d.mts → operations-Dw9OFV0V.d.mts} +14 -5
- package/dist/outbox/index.d.mts +24 -9
- package/dist/outbox/index.mjs +17 -6
- package/dist/pagination/PaginationEngine.d.mts +1 -1
- package/dist/pagination/PaginationEngine.mjs +1 -1
- package/dist/pagination/utils/limits.d.mts +6 -9
- package/dist/pagination/utils/limits.mjs +2 -3
- package/dist/{pagination-D6psses0.d.mts → pagination-DAUg5zOY.d.mts} +2 -10
- package/dist/{plugin-options-Cirmb4Kp.d.mts → plugin-options-Bpcuz9kS.d.mts} +2 -4
- package/dist/plugins/index.d.mts +6 -7
- package/dist/plugins/index.mjs +6 -7
- package/dist/query/primitives/coercion.d.mts +1 -1
- package/dist/query/primitives/coercion.mjs +2 -3
- package/dist/query/primitives/geo.d.mts +11 -14
- package/dist/query/primitives/geo.mjs +1 -2
- package/dist/query/primitives/indexes.d.mts +1 -1
- package/dist/query/primitives/indexes.mjs +1 -2
- package/dist/{query-defaults-BxUcSOoF.mjs → query-defaults-aUoKlEbh.mjs} +2 -3
- package/dist/read-model/index.d.mts +18 -21
- package/dist/read-model/index.mjs +3 -4
- package/dist/{repository-MqAP409e.d.mts → repository-DzVIwhU2.d.mts} +25 -29
- package/dist/retention-purge-DqcYvRnG.d.mts +35 -0
- package/dist/{rolldown-runtime-D7D4PA-g.mjs → rolldown-runtime-D8jQpDG7.mjs} +1 -2
- package/dist/search/index.d.mts +1 -1
- package/dist/search/index.mjs +1 -1
- package/dist/sync/index.d.mts +95 -15
- package/dist/sync/index.mjs +308 -62
- package/dist/tenant/index.d.mts +8 -11
- package/dist/tenant/index.mjs +1 -2
- package/dist/testkit/index.d.mts +179 -23
- package/dist/testkit/index.mjs +421 -23
- package/dist/{timestamp.plugin-DpOWavVI.mjs → timestamp.plugin-Bz_zL9PX.mjs} +192 -49
- package/dist/{to-plain-vksRrLtc.mjs → to-plain-CEKv_ylg.mjs} +2 -4
- package/dist/{to-plain-o5tUY2nV.d.mts → to-plain-PO5r1Qoy.d.mts} +2 -16
- package/dist/{transaction-DmiB4k2P.mjs → transaction-C2PFxfIv.mjs} +5 -8
- package/dist/{type-utils-CLzRFtIA.d.mts → type-utils-CKvuewAL.d.mts} +0 -2
- package/dist/{update-WgeAUJ_I.mjs → update-DcfmNbJe.mjs} +12 -20
- package/dist/usage/index.d.mts +2 -5
- package/dist/usage/index.mjs +8 -6
- package/dist/utils/index.d.mts +8 -10
- package/dist/utils/index.mjs +7 -7
- package/dist/{validation-chain.plugin-BAeC5eVT.d.mts → validation-chain.plugin-B3gZCZj4.d.mts} +135 -48
- package/dist/{validation-chain.plugin-DRApTmkN.mjs → validation-chain.plugin-DNY6MIF6.mjs} +207 -55
- package/llms.txt +33 -0
- package/package.json +29 -25
- package/skills/mongokit/SKILL.md +112 -0
- package/skills/mongokit/references/pagination-search.md +87 -0
- package/skills/mongokit/references/plugins-tenancy.md +102 -0
- package/skills/mongokit/references/repository-api.md +83 -0
- package/skills/mongokit/references/transactions-outbox.md +101 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: mongokit
|
|
3
|
+
description: Mongoose 9 repositories for MongoDB, implementing repo-core StandardRepo. Use for models, repositories, plugins (tenant, soft delete, cache, audit), offset and keyset pagination, status transitions with history, transactions, outbox, search, geo, index checks. Triggers - mongokit, Repository, applyTransition, getAll, withTransaction, QueryParser.
|
|
4
|
+
metadata:
|
|
5
|
+
minVersion: "3.43.1"
|
|
6
|
+
package: "@classytic/mongokit"
|
|
7
|
+
peers: "@classytic/repo-core >=0.31.1, mongoose >=9.11.2, node >=22"
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
# @classytic/mongokit
|
|
11
|
+
|
|
12
|
+
Repository layer over Mongoose with the shared `StandardRepo<TDoc>` contract. Misses return `null` (or `{ success: false }`), not a thrown 404, unless `throwOnNotFound: true`.
|
|
13
|
+
|
|
14
|
+
## Do not hand-roll
|
|
15
|
+
|
|
16
|
+
| Need | Use |
|
|
17
|
+
|---|---|
|
|
18
|
+
| Register a model twice safely | `defineModel`, `defineModels` (explicit `onExisting`) |
|
|
19
|
+
| Repo with tenant, soft delete, timestamps, cache, audit in the safe order | `createRepository` |
|
|
20
|
+
| Status change with legality check, history row, race diagnosis | `repo.applyTransition` + `TransitionMachine` |
|
|
21
|
+
| Compare-and-set on a version stamp | `repo.claimVersion` |
|
|
22
|
+
| Multi-document atomic write | `withTransaction`, `batchTransaction`, `repo.withTransaction` |
|
|
23
|
+
| Duplicate key (E11000) detection | `isDuplicateKeyError` |
|
|
24
|
+
| URL query string to filter, sort, page, populate | `QueryParser` |
|
|
25
|
+
| Indexed search-as-you-type | `searchMode: 'prefix'`, `searchKeysPath`, `searchKeysIndex`, `rebuildSearchKeys` |
|
|
26
|
+
| Transactional outbox table and repo | `createOutboxRepository` (`/outbox`) |
|
|
27
|
+
| HTTP idempotency replay store | `createIdempotencyStore` (`/idempotency`) |
|
|
28
|
+
| Cross-process lock | `createMongoLockAdapter` (`/lock`) |
|
|
29
|
+
| "Are my declared indexes built?" | `verifyIndexes`, `formatIndexReport` (`/indexes`) |
|
|
30
|
+
| Tenant field plus tenant-led indexes on a schema | `injectTenantField`, `declareIndexes` (`/tenant`) |
|
|
31
|
+
| Query timeout, read concern, profiler comment | `configureQueryDefaults`, `assertQueryDefaultsConfigured` |
|
|
32
|
+
| Skip the per-page count at scale | `configurePaginationDefaults`, `countStrategy` |
|
|
33
|
+
| Soft delete, append-only, immutable states | `softDeletePlugin`, `appendOnlyPlugin`, `immutableStatesPlugin` |
|
|
34
|
+
| Sequential or prefixed ids | `customIdPlugin`, `sequentialId`, `prefixedId` |
|
|
35
|
+
| Worker lease on pending rows | `leasePlugin` |
|
|
36
|
+
| Bypass tenant scope on purpose | `systemContext`, `adminBypass` |
|
|
37
|
+
| Forward request scope to a repo call | `repoOptionsFromCtx` |
|
|
38
|
+
| arc DataAdapter for a repo | `createMongooseAdapter` (`/adapter`) |
|
|
39
|
+
| Throwaway Mongo in tests | `createTestRepository`, `withMongoMemory` (`/testkit`) |
|
|
40
|
+
|
|
41
|
+
## Golden path
|
|
42
|
+
|
|
43
|
+
Model and repo (one registration, tenant and soft delete in the canonical order):
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
import { createRepository, defineModel } from '@classytic/mongokit';
|
|
47
|
+
import { Schema } from 'mongoose';
|
|
48
|
+
|
|
49
|
+
const Order = defineModel('Order', () => {
|
|
50
|
+
const schema = new Schema({ organizationId: String, status: String, total: Number,
|
|
51
|
+
statusHistory: { type: [new Schema({ status: String, occurredAt: Date, by: String, note: String }, { _id: false })], default: [] } },
|
|
52
|
+
{ timestamps: true });
|
|
53
|
+
schema.index({ organizationId: 1, createdAt: -1, _id: -1 }); // keyset order + _id tiebreak
|
|
54
|
+
return schema;
|
|
55
|
+
}, { onExisting: 'reuse' });
|
|
56
|
+
|
|
57
|
+
export const orderRepo = createRepository(Order, {
|
|
58
|
+
tenant: { tenantField: 'organizationId', required: true }, softDelete: true, batch: true,
|
|
59
|
+
});
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
List (envelope is `{ method, data, ... }`; pass `mode` explicitly):
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
const page = await orderRepo.getAll({ mode: 'offset', page: 1, limit: 20, filters: { status: 'open' }, sort: { createdAt: -1 } }, { organizationId });
|
|
66
|
+
const first = await orderRepo.getAll({ mode: 'keyset', limit: 20, sort: { createdAt: -1 } }, { organizationId });
|
|
67
|
+
const next = await orderRepo.getAll({ mode: 'keyset', after: first.next, limit: 20, sort: { createdAt: -1 } }, { organizationId });
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Status transition (machine is `{ name, assertTransition }`; `defineStateMachine` satisfies it):
|
|
71
|
+
|
|
72
|
+
```ts
|
|
73
|
+
const moved = await orderRepo.applyTransition(id, MACHINE, { from: 'open', to: 'paid', by: actorId, note }, { organizationId });
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Atomic write plus outbox row in one transaction (needs a replica set):
|
|
77
|
+
|
|
78
|
+
```ts
|
|
79
|
+
import { withTransaction } from '@classytic/mongokit';
|
|
80
|
+
await withTransaction(mongoose.connection, async (session) => {
|
|
81
|
+
await orderRepo.create(data, { session, organizationId });
|
|
82
|
+
await outboxRepo.create(row, { session });
|
|
83
|
+
});
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
HTTP query: `const parsed = new QueryParser({ schema: OrderSchema }).parse(req.query); await repo.getAll(parsed, scope);`
|
|
87
|
+
|
|
88
|
+
## Gotchas
|
|
89
|
+
|
|
90
|
+
- Tenant scope rides in the options bag (`{ organizationId }`), not in `filters`. Missing it throws; cross-tenant ids return `null`.
|
|
91
|
+
- Offset and keyset are chosen by `mode`, else `page`, else `after`, else a non-default `sort` means keyset. Always pass `mode`.
|
|
92
|
+
- Keyset needs a compound index on the sort keys plus `_id` (tenant field first when scoped).
|
|
93
|
+
- `applyTransition` pushes `{ status, occurredAt, by, note }` to `statusHistory` in the same atomic write; `history: false` skips it. Loss throws `TRANSITION_RACE_LOST` (409) or `TRANSITION_TARGET_MISSING` (404), codes are upper-case.
|
|
94
|
+
- `repo.withTransaction` hands you a tx-bound repo; the standalone `withTransaction` hands you a raw session. Standalone mongod cannot transact.
|
|
95
|
+
- Filter keys missing from the schema are stripped by `strictQuery`; develop with `warnOnStrictQueryStrip: true`.
|
|
96
|
+
- `QueryParser` throws 400 `INVALID_QUERY_INPUT` on blocked input by default; `search` text is escaped, never rejected.
|
|
97
|
+
- Reads through the raw model skip tenant, soft delete and cache.
|
|
98
|
+
- Prefix search keys are derived; writes outside the repo need `rebuildSearchKeys`.
|
|
99
|
+
|
|
100
|
+
## Never
|
|
101
|
+
|
|
102
|
+
- Hand-roll `findOneAndUpdate({ status })` CAS, E11000 string matching, or `mongoose.models.X ?? model()`.
|
|
103
|
+
- Deep-import; use the subpaths in `llms.txt`.
|
|
104
|
+
- Look for pagination result or error types here: they live in `@classytic/repo-core`.
|
|
105
|
+
|
|
106
|
+
## References
|
|
107
|
+
|
|
108
|
+
- `references/repository-api.md`: read for the method list, options bag, bulk, createMany, claim, aggregate, soft delete.
|
|
109
|
+
- `references/pagination-search.md`: read for keyset indexes, count strategy, prefix search, QueryParser grammar, geo, lookups.
|
|
110
|
+
- `references/plugins-tenancy.md`: read for plugin order, tenant indexes, cache, audit, cascade, field filter, hooks.
|
|
111
|
+
- `references/transactions-outbox.md`: read for transactions, outbox, idempotency, lock, query defaults and comments, index verification.
|
|
112
|
+
- Cross-package: contract types, errors, envelopes in skill `repo-core`; arc wiring in skill `arc`.
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# Pagination, search, QueryParser
|
|
2
|
+
|
|
3
|
+
## Envelopes (types from `@classytic/repo-core/pagination`)
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
offset: { method: 'offset', data, page, limit, total, pages, hasNext, hasPrev, warning? }
|
|
7
|
+
keyset: { method: 'keyset', data, limit, hasMore, next, prev?, hasPrev?, end? }
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
Narrow on `result.method`. Rows are in `data`.
|
|
11
|
+
|
|
12
|
+
## Choosing a mode
|
|
13
|
+
|
|
14
|
+
`mode` wins; else `page` means offset; else `after` means keyset; else `PaginationConfig.defaultMode`; else any non-default `sort` means keyset.
|
|
15
|
+
Pass `mode` so a changed sort does not flip the response shape. `before` walks backward from a `prev` cursor.
|
|
16
|
+
|
|
17
|
+
Keyset sort gets an `_id` tiebreak automatically; the index must match: `{ organizationId: 1, createdAt: -1, _id: -1 }`.
|
|
18
|
+
`strictKeysetSortFields` limits which fields may be sorted on. Cursors are scoped to collection, tenant, filter and collation; a wrong one
|
|
19
|
+
is 400 `repo.cursor.invalid` or `repo.cursor.scope_mismatch` (`PAGINATION_ERROR_CODES`). Sign cursors with `cursorSecret`.
|
|
20
|
+
|
|
21
|
+
## Counting
|
|
22
|
+
|
|
23
|
+
Offset pages count by default. `countStrategy`: `exact`, `estimated`, `capped` (with `countLimit`), `cached` (memoised per filter, never inside a
|
|
24
|
+
transaction), `none` (no total). Set a deployment default once at boot:
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
import { configurePaginationDefaults } from '@classytic/mongokit';
|
|
28
|
+
configurePaginationDefaults({ defaultCountStrategy: 'capped', defaultCountLimit: 10000 });
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Offsets past `maxOffset` are refused with `mongokit.pagination.offset_too_deep`; page with keyset instead.
|
|
32
|
+
|
|
33
|
+
## Search modes (`RepositoryOptions.searchMode`)
|
|
34
|
+
|
|
35
|
+
| Mode | Behavior | Needs |
|
|
36
|
+
|---|---|---|
|
|
37
|
+
| `text` (default) | `$text` | text index |
|
|
38
|
+
| `regex` | case-insensitive `$or` over `searchFields` | `searchFields`, scans |
|
|
39
|
+
| `auto` | text if a text index exists, else regex | |
|
|
40
|
+
| `prefix` | every query word starts some stored token (`moh ali` finds "Mohammed Al-Ali") | `searchFields`, keys path, multikey index |
|
|
41
|
+
|
|
42
|
+
Prefix setup:
|
|
43
|
+
|
|
44
|
+
```ts
|
|
45
|
+
import { Repository, searchKeysPath } from '@classytic/mongokit';
|
|
46
|
+
schema.add({ _searchKeys: searchKeysPath() });
|
|
47
|
+
schema.index({ organizationId: 1, _searchKeys: 1 }); // tenant first when scoped
|
|
48
|
+
const repo = new Repository(Model, [], {}, { searchMode: 'prefix', searchFields: ['name', 'code'] });
|
|
49
|
+
await repo.getAll({ search: 'moh ali', mode: 'offset', page: 1, limit: 10 }, { organizationId });
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Unscoped models can use `searchKeysIndex()`, which returns `{ fields, options }` for `schema.index`. Keys are rewritten on every repo write path.
|
|
53
|
+
Writes that skip the repo (raw model, an auth adapter) must call `buildSearchKeys` or be repaired with `rebuildSearchKeys`. Client-side highlight with
|
|
54
|
+
`findSearchMatches` from `@classytic/mongokit/search` (mongoose-free).
|
|
55
|
+
Folding: case, accents, tashkeel, `أ إ آ ٱ` to `ا`, `ة` to `ه`, `ى` to `ي`, Arabic-Indic digits. To OR the keys with a plain prefix on a code field
|
|
56
|
+
(not tokenised), build the filter yourself: `{ $or: [prefixSearchFilter(q, { keysField: repo.searchKeysField }), { code: { $regex: '^' + escaped } }] }`
|
|
57
|
+
(drop the first arm when it is `null`) and pass it as `filters`; `search` itself ANDs with `filters`.
|
|
58
|
+
|
|
59
|
+
## QueryParser
|
|
60
|
+
|
|
61
|
+
`new QueryParser({ schema, allowedFilterFields, allowedSortFields, maxLimit, searchMode, searchFields, invalidInput, allowedLookupCollections })`.
|
|
62
|
+
`parse(req.query)` returns `{ filters, sort, page, limit, after, select, populate, lookups, search }`, ready for `repo.getAll`.
|
|
63
|
+
|
|
64
|
+
```
|
|
65
|
+
?status=active&age[gte]=18&sort=-createdAt&page=1&limit=20
|
|
66
|
+
?populate[orders][select]=id,total&populate[orders][match][status]=paid
|
|
67
|
+
?lookup[category][from]=categories&lookup[category][localField]=slug&lookup[category][foreignField]=slug&lookup[category][single]=true
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Invalid or blocked input throws 400 `INVALID_QUERY_INPUT`; `invalidInput: 'drop'` widens results and is for trusted tooling only.
|
|
71
|
+
Dangerous operators and ReDoS regexes are blocked; `$lookup` targets must be listed in `allowedLookupCollections`. The same grammar as other kits is
|
|
72
|
+
exposed by `parseUrl` in `@classytic/repo-core/query-parser`.
|
|
73
|
+
|
|
74
|
+
## Geo
|
|
75
|
+
|
|
76
|
+
Coordinates are always `lng,lat`. Operators on a 2dsphere field:
|
|
77
|
+
|
|
78
|
+
```
|
|
79
|
+
?location[near]=-73.97,40.78,5000 // sorted by distance, meters optional
|
|
80
|
+
?location[nearSphere]=-73.97,40.78,5000
|
|
81
|
+
?location[withinRadius]=-73.97,40.78,5000 // unsorted, safe for counts, pagination and joins
|
|
82
|
+
?location[geoWithin]=minLng,minLat,maxLng,maxLat
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Out-of-range coordinates drop the clause. Declare the `2dsphere` index in the schema. `near` without a max distance cannot be counted (use
|
|
86
|
+
`countStrategy: 'none'`); do not combine `near` with keyset mode. Prefer `withinRadius` for paged lists. Pure builders are at
|
|
87
|
+
`@classytic/mongokit/query/primitives/geo`.
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# Plugins, tenancy, indexes
|
|
2
|
+
|
|
3
|
+
## Order
|
|
4
|
+
|
|
5
|
+
Prefer `createRepository`: it installs the canonical order and sets `pluginOrderChecks: 'throw'`. Hand-built chains
|
|
6
|
+
(`new Repository(Model, [...])`) must keep: `methodRegistryPlugin` first; `multiTenantPlugin` before soft delete and cache (cache keys need
|
|
7
|
+
the tenant); `softDeletePlugin` before `batchOperationsPlugin` (bulk ops must see the deletion filter). `requirePlugins: [MONGOKIT_PLUGIN_NAMES.multiTenant]`
|
|
8
|
+
makes a missing plugin a boot error instead of a tenant leak.
|
|
9
|
+
|
|
10
|
+
## Bundled plugins
|
|
11
|
+
|
|
12
|
+
| Plugin | Adds |
|
|
13
|
+
|---|---|
|
|
14
|
+
| `timestampPlugin` | `createdAt`, `updatedAt` |
|
|
15
|
+
| `softDeletePlugin` | `deletedAt` mark, read filter, `restore`, `getDeleted` |
|
|
16
|
+
| `multiTenantPlugin` | tenant filter and payload injection (`tenantField`, `contextKey`, `required`, `fieldType`, `skipWhen`, `resolveContext`) |
|
|
17
|
+
| `cachePlugin` | read-through cache with write invalidation |
|
|
18
|
+
| `auditTrailPlugin` | persisted audit ledger with field diffs (`mode: 'transactional'` is atomic only inside a transaction) |
|
|
19
|
+
| `auditLogPlugin` | structured log lines through your logger |
|
|
20
|
+
| `changeLogPlugin` | change feed for sync (`@classytic/mongokit/plugins`) |
|
|
21
|
+
| `cascadePlugin` | delete related rows (`relations`, `parallel`, `batchSize`) |
|
|
22
|
+
| `fieldFilterPlugin` | role-based field visibility (`createFieldPreset`) |
|
|
23
|
+
| `validationChainPlugin` | `requireField`, `immutableField`, `uniqueField`, `blockIf`, `autoInject` |
|
|
24
|
+
| `customIdPlugin` | `sequentialId`, `prefixedId`, `dateSequentialId` |
|
|
25
|
+
| `appendOnlyPlugin`, `immutableStatesPlugin` | refuse updates and deletes, or freeze rows in terminal states |
|
|
26
|
+
| `leasePlugin` | time-bounded work claims (`nowFn` or per-call `now`, `attemptsField`, `maxAttempts`, `pendingFilter` object or `(now) => filter`, `deadLeaseStatuses`; `extend`/`release` need the row still in `processingStatus`) |
|
|
27
|
+
| `batchOperationsPlugin`, `mongoOperationsPlugin`, `aggregateHelpersPlugin`, `subdocumentPlugin` | extra methods through the registry |
|
|
28
|
+
| `observabilityPlugin` | timing, metrics, slow-op callback |
|
|
29
|
+
| `elasticSearchPlugin` | delegate `search` to Elasticsearch |
|
|
30
|
+
| `searchKeysPlugin` | prefix keys (installed by `searchMode: 'prefix'`) |
|
|
31
|
+
|
|
32
|
+
Counters: `getNextSequence(key, inc, connection, session)` uses `_mongokit_counters`; `createSequenceCounter({ connection, collection })`
|
|
33
|
+
binds `next`, `read`, `ensureAtLeast` (each with an optional session) to your own collection name; `sequentialId` / `dateSequentialId` take `counterCollection` for the same collection.
|
|
34
|
+
Period-scoped: `getNextPeriodSequence(base, { period: 'year' | 'month' | 'day', timeZone, at?, session? })` (or `nextInPeriod`) derives the
|
|
35
|
+
key from the civil date of `at` in an IANA zone and returns `{ seq, period, counterKey }`; `dateSequentialId` takes `timeZone`, `at(ctx)`, `counterKey`.
|
|
36
|
+
Retention purge (append-only or frozen rows): build `appendOnlyPlugin` and/or `immutableStatesPlugin` with `allowPurge: true`, then
|
|
37
|
+
`purgeByFilter(filter, { type: 'hard' }, { retentionPurge: { reason, actor } })` (or `deleteMany(filter, { mode: 'hard', retentionPurge })`).
|
|
38
|
+
Each delete call emits `after:append-only-purge` / `after:immutable-states-purge` with `deletedCount` and `session`. Listeners are awaited and a
|
|
39
|
+
throwing one fails the purge; write the audit row with `{ session: event.session }` so it commits or aborts with the delete. Ordinary deletes stay refused.
|
|
40
|
+
Function-form anonymize writes through the guarded `updateMany` (frozen / append-only rows refused); `immutableStatesPlugin` reads state in the call's session.
|
|
41
|
+
|
|
42
|
+
`leasePlugin` (after `methodRegistryPlugin`): `lease({ filter, leaseFor, leasedBy, now })` claims a pending row or a dead lease (a row
|
|
43
|
+
still in `processingStatus` whose expiry passed); the caller filter, its `$or` included, is ANDed in. `extend` and `release` CAS on
|
|
44
|
+
`leasedBy` plus a live expiry, so make `leasedBy` unique per claim. Cancelling a leased row from outside should also `$unset` the lease fields.
|
|
45
|
+
With `maxAttempts`, set `exhaustedStatus: 'failed'` (and `exhaustedAtField`) and schedule `reapExhausted({ now, limit })`: it moves rows at the cap
|
|
46
|
+
whose lease lapsed (or that sit pending) to that status by CAS and returns them once, for the host to audit and alert.
|
|
47
|
+
|
|
48
|
+
## Tenancy
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
multiTenantPlugin({ tenantField: 'organizationId', contextKey: 'organizationId', required: true, fieldType: 'objectId' });
|
|
52
|
+
await repo.getAll({ mode: 'offset', page: 1 }, { organizationId }); // scoped
|
|
53
|
+
await repo.update(id, data, { organizationId: 'other' }); // null: cross-tenant miss
|
|
54
|
+
await repo.getAll({ mode: 'offset', page: 1 }, systemContext()); // bypassTenant for jobs
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
`createTenantContext()` (AsyncLocalStorage) removes the need to pass the scope on each call. `repoOptionsFromCtx(ctx)` forwards a request scope.
|
|
58
|
+
Richer rules (branches, owners, roles) compose on top with `skipWhen` and `adminBypass({ adminRoles })`; do not widen the plugin.
|
|
59
|
+
|
|
60
|
+
## Tenant-led indexes
|
|
61
|
+
|
|
62
|
+
`@classytic/mongokit/tenant` mutates the schema so every declared compound index leads with the tenant field:
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
import { injectTenantField } from '@classytic/mongokit/tenant';
|
|
66
|
+
import { resolveTenantConfig } from '@classytic/repo-core/tenant';
|
|
67
|
+
|
|
68
|
+
injectTenantField(schema, resolveTenantConfig({ tenantField: 'organizationId' }), {
|
|
69
|
+
skipIndexes: ['event_id_unique'],
|
|
70
|
+
indexes: [{ fields: { buyerId: 1, createdAt: -1 }, scope: 'global', options: { name: 'buyer_history_global' } }],
|
|
71
|
+
});
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
`scope: 'global'` keeps the index as written for an identity read across tenants; it must be named, must not name the tenant field, and must be
|
|
75
|
+
paired with a `bypassTenant` read. TTL indexes are never prefixed.
|
|
76
|
+
|
|
77
|
+
## Cache
|
|
78
|
+
|
|
79
|
+
```ts
|
|
80
|
+
import { cachePlugin, createMemoryCache } from '@classytic/mongokit';
|
|
81
|
+
cachePlugin({ adapter: createMemoryCache(), defaults: { staleTime: 30, gcTime: 300, swr: true }, prefix: 'orders' });
|
|
82
|
+
await repo.getById(id, { cache: { bypass: true } }); // per call
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
An adapter is `{ get, set(key, value, ttlSeconds), delete, clear? }`. Writes invalidate by tag; `autoTagsFromScope` tags with the tenant.
|
|
86
|
+
|
|
87
|
+
## Audit trail
|
|
88
|
+
|
|
89
|
+
```ts
|
|
90
|
+
auditTrailPlugin({ mode: 'transactional', collectionName: 'audit_trail', ttlDays: 365, excludeFields: ['password'], metadata: (ctx) => ({ actor: ctx.actorId }) });
|
|
91
|
+
await ensureAuditTrailReady(); // at boot, so the first transaction does not create the collection
|
|
92
|
+
const query = new AuditTrailQuery(Model);
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
`best-effort` (default) is fire and forget and is observability only.
|
|
96
|
+
|
|
97
|
+
## Index discipline
|
|
98
|
+
|
|
99
|
+
- Declare every index in the schema (`schema.index(fields, { name })`); name each one so drift reports are readable.
|
|
100
|
+
- Sort, cursor, purge and prefix-search fields all need a supporting index; unindexed purge is O(n squared).
|
|
101
|
+
- Run `verifyIndexes(connection)` in CI or a deploy check (see `transactions-outbox.md`); it is read-only and never builds indexes.
|
|
102
|
+
- Tag slow queries with `comment` (per call or `configureQueryDefaults({ comment })`) so they are findable in the profiler.
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# Repository API
|
|
2
|
+
|
|
3
|
+
`new Repository(Model, plugins?, paginationConfig?, options?)` or `createRepository(Model, config)`. `config` keys:
|
|
4
|
+
`tenant`, `softDelete`, `timestamps`, `batch`, `cache`, `audit`, `customId`, `plugins` (appended last), `pagination`, plus
|
|
5
|
+
`RepositoryOptions` (`idField`, `searchMode`, `searchFields`, `pluginOrderChecks`, `requirePlugins`, `queryDefaults`, `hooks`).
|
|
6
|
+
The factory installs plugins in the safe order (registry, tenant, soft delete, timestamps, custom id, cache, audit, batch) and
|
|
7
|
+
fails fast on a bad order.
|
|
8
|
+
|
|
9
|
+
Every method takes an options bag last: `{ session, organizationId, select, populate, lean, throwOnNotFound, bypassTenant, includeDeleted, comment, maxTimeMS }`.
|
|
10
|
+
|
|
11
|
+
## Reads
|
|
12
|
+
|
|
13
|
+
| Method | Miss result |
|
|
14
|
+
|---|---|
|
|
15
|
+
| `getById(id, opts)` | `null` (a malformed id is a miss, not a CastError) |
|
|
16
|
+
| `getOne(filter, { sort })`, `getByQuery` | `null` |
|
|
17
|
+
| `getByIds(ids)` | `Map` of found rows |
|
|
18
|
+
| `getAll(params, opts)` | envelope with empty `data` |
|
|
19
|
+
| `findAll(filter, opts)` | `[]` |
|
|
20
|
+
| `count`, `exists`, `distinct` | `0`, `null`, `[]` |
|
|
21
|
+
| `iterate(filter, { batchSize, after })` | async batches `{ docs, checkpoint }`, resumable |
|
|
22
|
+
| `cursor`, `watch` | streams (watch needs a replica set) |
|
|
23
|
+
|
|
24
|
+
Filters accept Mongo objects or repo-core Filter IR (`eq`, `and`, `in_` from `@classytic/repo-core/filter`).
|
|
25
|
+
|
|
26
|
+
## Writes
|
|
27
|
+
|
|
28
|
+
| Method | Notes |
|
|
29
|
+
|---|---|
|
|
30
|
+
| `create`, `createMany` | `createMany` rejects on a partial write; `isCreateManyPartialError(err)` then read the report |
|
|
31
|
+
| `update(id, data, opts)` | `null` on miss; `ifVersion` adds a stamp check |
|
|
32
|
+
| `findOneAndUpdate(filter, update, { sort })` | atomic claim of the oldest row |
|
|
33
|
+
| `getOrCreate(filter, data)` | `{ doc, created }`, no race |
|
|
34
|
+
| `delete(id, { mode })` | soft when `softDeletePlugin` is on; `mode: 'hard'` removes |
|
|
35
|
+
| `updateMany`, `deleteMany`, `bulkWrite` | need `batchOperationsPlugin` (or `batch: true`) after soft delete |
|
|
36
|
+
| `bulkUpsert(rows, { key, set, inc, setOnInsert })` | one command, per-row outcome; MongoDB 8; refused without a unique index on key plus scope |
|
|
37
|
+
| `purgeByField`, `purgeByFilter`, `archiveByFilter` | chunked, abortable, plugin-composed cleanup; index the field first |
|
|
38
|
+
|
|
39
|
+
## Compare-and-set family
|
|
40
|
+
|
|
41
|
+
| Primitive | Use when | Loss signal |
|
|
42
|
+
|---|---|---|
|
|
43
|
+
| `claim(id, { from, to, where }, patch)` | state move, `from` may be an array | `null` |
|
|
44
|
+
| `applyTransition(id, machine, args)` | state move with legality, history, diagnosis | throws 409, 404 or the machine's error |
|
|
45
|
+
| `claimVersion(id, { from, where }, update)` | optimistic lock, `$inc` on `version` | `null` |
|
|
46
|
+
| `claim` with `upsert` and `from === to` | first-writer-wins insert | existing doc |
|
|
47
|
+
| `leasePlugin().lease()` | worker takes the next pending row with a TTL | `null` |
|
|
48
|
+
|
|
49
|
+
`applyTransition` args: `from`, `to`, `set`, `push`, `inc`, `unset`, `where`, `by`, `note`, `history` (field name or `false`), `field`, `at`,
|
|
50
|
+
`priorAt`, `historyExtra`. Options also take `target` (forwarded to `claim`).
|
|
51
|
+
A `TransitionMachine` is `{ name, assertTransition(entityId, from, to) }`; throw your domain error from it. Legality is checked before the
|
|
52
|
+
write; on a lost race the row is re-read and the error reflects its CURRENT state. It does no read of its own, so pass `priorAt` (when the
|
|
53
|
+
row entered its current state, from your read) to record `durationInPriorMs`, and guard that read with `where: { updatedAt }` or `target`.
|
|
54
|
+
`historyExtra` merges extra fields into the entry; reserved keys and either arg with `history: false` are a 400.
|
|
55
|
+
|
|
56
|
+
Codes: `TRANSITION_RACE_LOST` (409, still legal, re-read and retry), `TRANSITION_TARGET_MISSING` (404). An idempotent event handler
|
|
57
|
+
treats both as "already moved". Tenant scope must be passed in the options bag.
|
|
58
|
+
|
|
59
|
+
## Aggregation
|
|
60
|
+
|
|
61
|
+
`aggregate(req)` is the portable IR (`groupBy`, `measures`, `having`, `sort`, date buckets), identical on other kits. `aggregatePipeline(stages)`
|
|
62
|
+
is native Mongo (`$lookup`, `$facet`). `aggregatePaginate` and `aggregatePipelinePaginate` return envelopes. `lookupPopulate(params)` joins by
|
|
63
|
+
any field without a `ref`. `compileFilterToMongo(filterIR)` reuses the filter compiler inside a custom pipeline.
|
|
64
|
+
|
|
65
|
+
## Soft delete
|
|
66
|
+
|
|
67
|
+
`softDeletePlugin({ deletedField: 'deletedAt', filterMode })` adds `restore(id)` and `getDeleted(params)`; reads hide deleted rows unless
|
|
68
|
+
`includeDeleted: true`. Make unique indexes partial so deleted rows do not block inserts:
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
schema.index({ email: 1 }, { unique: true, partialFilterExpression: { deletedAt: null } });
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Events and hooks
|
|
75
|
+
|
|
76
|
+
`repo.on('before:update' | 'after:create' | 'error:delete', handler, { priority })` with `HOOK_PRIORITY` (POLICY 100, VALIDATION 150, CACHE 200,
|
|
77
|
+
OBSERVABILITY 300, DEFAULT 500). `before:*` handlers may mutate `context.data` and `context.query`. `registerRepositoryEvents` (from
|
|
78
|
+
`@classytic/repo-core/events`) publishes domain events from repo operations.
|
|
79
|
+
|
|
80
|
+
## Errors
|
|
81
|
+
|
|
82
|
+
Repo errors are `HttpError` shaped (`status`, `code`, `meta`, `duplicate`, `validationErrors`); see skill `repo-core`.
|
|
83
|
+
`isDuplicateKeyError(err)` and `parseDuplicateKeyError(err)` (field names only unless `exposeValues`) classify E11000.
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# Transactions, outbox, idempotency, locks, defaults
|
|
2
|
+
|
|
3
|
+
Transactions need a replica set (a single-node replica set is fine). `supportsTransactions`, `isTransactionUnsupported` and
|
|
4
|
+
`repo.capabilities.transactions` report what the deployment can do; the descriptor is observed from the live topology.
|
|
5
|
+
|
|
6
|
+
## Three transaction forms
|
|
7
|
+
|
|
8
|
+
```ts
|
|
9
|
+
import { batchTransaction, withTransaction } from '@classytic/mongokit';
|
|
10
|
+
|
|
11
|
+
// 1. one repo: callback gets a tx-bound repo and a handle with the raw session
|
|
12
|
+
await orderRepo.withTransaction(async (tx, uow) => {
|
|
13
|
+
const order = await tx.create({ total: 100 });
|
|
14
|
+
await tx.update(order._id, { confirmed: true });
|
|
15
|
+
await outboxRepo.create(row, { session: uow?.session }); // work outside the repo joins the session
|
|
16
|
+
return order;
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
// 2. several repos, each auto-bound to one session
|
|
20
|
+
await batchTransaction(mongoose.connection, { orders: orderRepo, events: eventRepo }, async ({ orders, events }) => {
|
|
21
|
+
const created = await orders.create(data);
|
|
22
|
+
await events.create({ type: 'order.placed', orderId: created._id });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
// 3. raw session, for code that is not a repo
|
|
26
|
+
await withTransaction(mongoose.connection, async (session) => { await orderRepo.create(data, { session }); });
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Options: `transactionOptions`, `allowFallback` (dev on standalone: runs once without a transaction), `onFallback(err)`. Transient errors retry
|
|
30
|
+
per the driver rules. Nested `tx.withTransaction` throws; reuse the outer bound repo. `isTransientConflictError(err)` marks retryable conflicts.
|
|
31
|
+
|
|
32
|
+
To serialise writers on a row, the write must change it. An update that sets a field to the value it already has (`$set: { at: now }` from two
|
|
33
|
+
writers at the same instant) is a no-op: it takes no write lock, so two transactions doing it never conflict. Make the write real, for example
|
|
34
|
+
`$inc: { writes: 1 }` on the row (the same holds for a lease or gate row you rely on to order concurrent work).
|
|
35
|
+
|
|
36
|
+
## Outbox (`@classytic/mongokit/outbox`)
|
|
37
|
+
|
|
38
|
+
```ts
|
|
39
|
+
import { createOutboxRepository } from '@classytic/mongokit/outbox';
|
|
40
|
+
const outboxRepo = createOutboxRepository(mongoose.connection, { visibleAtField: 'visibleAt' });
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
It is the Mongo half only: the model (`_id` is the event id, so replays are duplicate-key no-ops), four indexes for claim, dedupe and retention, and
|
|
44
|
+
a repository with the required registry and batch plugins. Wrap it with arc's `repositoryAsOutboxStore(repo, { visibleAtField })` (use the SAME
|
|
45
|
+
`visibleAtField`) and relay with arc's outbox module. Write the row in the same session as the business write. Delivered rows expire by TTL
|
|
46
|
+
(`ttlSeconds`, default 7 days); pending and dead-lettered rows never do. Statuses: `OUTBOX_STATUSES`. The repository has no `maxLimit` cap, so
|
|
47
|
+
the relay's `batchSize` is the claim window; pass `pagination: { maxLimit }` to set a ceiling (keep `batchSize` at or below it).
|
|
48
|
+
|
|
49
|
+
## Idempotency claims (`/idempotency`)
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
import { createIdempotencyModel, createIdempotencyStore } from '@classytic/mongokit/idempotency';
|
|
53
|
+
import { runIdempotent } from '@classytic/repo-core/idempotency';
|
|
54
|
+
const store = createIdempotencyStore(createIdempotencyModel(mongoose.connection), { retentionSeconds: 7 * 86400 });
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Size `retentionSeconds` to the longest a caller may retry. Contract details are in skill `repo-core`.
|
|
58
|
+
|
|
59
|
+
## Locks, usage, sync, read models
|
|
60
|
+
|
|
61
|
+
| Subpath | Export | Use |
|
|
62
|
+
|---|---|---|
|
|
63
|
+
| `/lock` | `createMongoLockAdapter({ connection, nowFn })` | cross-process mutex with expiry (`nowFn` must track wall time: TTL index). `acquire(name, holder, leaseMs)` answers `{ acquired, holder, previousHolder?, acquiredAt, expiresAt }` from the one atomic write (no `inspect` first to learn who held it); `tryAcquire` stays a boolean |
|
|
64
|
+
| `/lock` | `createMongoDailyLedger({ connection, collectionName })` | arc `DailyLedger` for `createDailyGate`: one claim per key and civil day; every claim gets a unique token, and complete/release act only on the claim the calling invocation won (an older run never completes or releases a newer stale re-grant, a retry stays fenced). Host code: `claim(key, day, info)` returns a handle with fenced `complete()` / `release()`. Manual completion is the explicit `adminCompleteDay`, never a fallback |
|
|
65
|
+
| `/usage` | `createMongoUsageStore` | metered counters per period |
|
|
66
|
+
| `/sync` | `createChangeLogStore(models, { now, onSequenced })`, `createCommandStreamStore` | offline sync stores (models from `createChangeLogModels`, `sequencing: 'deferred'` with `sequencerLeaseMs` to keep writers off the counter row); `now` stamps `at`; `onSequenced({ cursor, numbered, by })` wakes a replay hub when entries got cursors, also after a read lost the sequencer lease to another holder; retention: schedule `prune(before)`; horizons are kept per `(tenantId, scope)`, so `since(cursor, { tenantId, scopes })` throws `CursorExpiredError` only when something that reader can see was pruned past its cursor, and it resyncs from `latestCursor(sameOptions)` (never below its horizon) |
|
|
67
|
+
| `/read-model` | `applyIncrements`, `rebuildInto`, `reconcile` | counter read models with drift reports |
|
|
68
|
+
| `/testkit` | `createTestRepository`, `withMongoMemory`, `createMongoMemory`, `clearCollections` | in-memory Mongo (replica set) for tests |
|
|
69
|
+
| `/testkit` | `assertQueryPlan`, `explainCommands` | gate or explain the commands a call really sent (`commands: ['findAndModify']` for a lease claim) |
|
|
70
|
+
| `/kernel-conformance` | conformance suite | prove a kernel behaves on this kit |
|
|
71
|
+
|
|
72
|
+
## Query defaults and comments
|
|
73
|
+
|
|
74
|
+
Resolution order: per call, then repository (`queryDefaults` option), then deployment.
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
import { assertQueryDefaultsConfigured, configureAggregateDefaults, configureQueryDefaults } from '@classytic/mongokit';
|
|
78
|
+
configureQueryDefaults({ maxTimeMS: 15000, readConcern: 'majority', comment: 'api' });
|
|
79
|
+
configureAggregateDefaults({ maxTimeMs: 30000, allowDiskUse: true });
|
|
80
|
+
assertQueryDefaultsConfigured(mongoose.connection); // boot gate: throws without maxTimeMS or a client timeoutMS
|
|
81
|
+
await repo.getAll({ mode: 'offset', page: 1 }, { comment: 'orders.list', maxTimeMS: 5000, hint: { organizationId: 1, createdAt: -1 } });
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Inside a transaction a default read or write concern is dropped (the transaction owns it); a per-call one is passed through.
|
|
85
|
+
|
|
86
|
+
## Verify indexes
|
|
87
|
+
|
|
88
|
+
```ts
|
|
89
|
+
import { formatIndexReport, verifyIndexes } from '@classytic/mongokit/indexes';
|
|
90
|
+
const report = await verifyIndexes(mongoose.connection, { includeModel: (name) => name !== 'AuthSession' });
|
|
91
|
+
if (report.missingUnique.length || report.incompatible.length) throw new Error(formatIndexReport(report));
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
`missingUnique` means duplicates may already exist; `incompatible` means the same keys with different `unique`, `partialFilterExpression`,
|
|
95
|
+
`sparse`, `collation` or TTL (fix by rebuild). Extra indexes in the database are not drift. Build indexes in a deploy step, never at serve time.
|
|
96
|
+
|
|
97
|
+
## Model registry
|
|
98
|
+
|
|
99
|
+
`defineModel(name, () => schema, { onExisting: 'reuse' | 'throw', connection })`: `reuse` for stubs and counters, `throw` (`ModelCollisionError`)
|
|
100
|
+
when you contribute fields or indexes a locked schema would silently drop. `defineModels({ models, assemble })` describes a model set without a
|
|
101
|
+
connection, then `blueprint.bind(connection)` registers it (policies `throw`, `reuse`, `replace` for tests only).
|