@classytic/mongokit 3.43.0 → 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.
Files changed (92) hide show
  1. package/README.md +14 -0
  2. package/dist/{PaginationEngine-DZnkbSrr.mjs → PaginationEngine-C9iS0U5l.mjs} +13 -27
  3. package/dist/{PaginationEngine-CRHyuUtw.d.mts → PaginationEngine-D17AthKw.d.mts} +1 -3
  4. package/dist/{Repository-DnL4xk5w.d.mts → Repository-B6oL6OMv.d.mts} +33 -11
  5. package/dist/{Repository-Dj6xqPxJ.mjs → Repository-eMRYZ_2Y.mjs} +204 -92
  6. package/dist/actions/index.d.mts +1 -1
  7. package/dist/actions/index.mjs +2 -12
  8. package/dist/actions-DExyXMTy.mjs +11 -0
  9. package/dist/adapter/index.d.mts +6 -10
  10. package/dist/adapter/index.mjs +1 -2
  11. package/dist/{adapter-CkMoMF83.mjs → adapter-Qzgc3ZD_.mjs} +4 -6
  12. package/dist/ai/index.d.mts +21 -8
  13. package/dist/ai/index.mjs +15 -9
  14. package/dist/better-auth/index.d.mts +18 -32
  15. package/dist/better-auth/index.mjs +3 -4
  16. package/dist/{coercion-BjdPX3YF.d.mts → coercion-COp9DRNw.d.mts} +0 -2
  17. package/dist/{create-repository-CJtVdwig.d.mts → create-repository-BOFUYj1l.d.mts} +5 -7
  18. package/dist/{create-repository-UjwPWR1z.mjs → create-repository-D7MQpIZo.mjs} +4 -5
  19. package/dist/{derive-DRBiegRH.mjs → derive-Dxsc31fV.mjs} +3 -6
  20. package/dist/{error-BuByZwKU.mjs → error-DjdmPLWO.mjs} +1 -2
  21. package/dist/{field-selection-FSQFLpsU.mjs → field-selection-DPFaFw9Y.mjs} +1 -2
  22. package/dist/{id-chunks-BmAwsqNk.mjs → id-chunks-DH-RAZWG.mjs} +13 -8
  23. package/dist/{id-resolution-BqDrk0S2.mjs → id-resolution-Cqh49d9J.mjs} +1 -2
  24. package/dist/idempotency/index.d.mts +4 -7
  25. package/dist/idempotency/index.mjs +1 -2
  26. package/dist/{index-SYF8N28D.d.mts → index-8QUEsOFI.d.mts} +3 -4
  27. package/dist/{index-C0AjVqX3.d.mts → index-ehGZLbrd.d.mts} +2 -8
  28. package/dist/index.d.mts +63 -96
  29. package/dist/index.mjs +36 -52
  30. package/dist/indexes/verify-indexes.d.mts +8 -11
  31. package/dist/indexes/verify-indexes.mjs +1 -2
  32. package/dist/{indexes-DIjMpYES.d.mts → indexes-FfyOuhDK.d.mts} +0 -2
  33. package/dist/{join-scope-DSAznE8E.mjs → join-scope-C1gOtZ59.mjs} +2 -3
  34. package/dist/kernel-conformance/index.d.mts +39 -13
  35. package/dist/kernel-conformance/index.mjs +4 -7
  36. package/dist/lock/index.d.mts +134 -7
  37. package/dist/lock/index.mjs +349 -14
  38. package/dist/{logger-K7dPmu2U.mjs → logger-hbWOlj_X.mjs} +1 -2
  39. package/dist/{method-registry.plugin-C63pGn1d.mjs → method-registry.plugin-CGAepsBU.mjs} +3 -5
  40. package/dist/{model-registry-ClKtWiHa.mjs → model-registry-DrVfJ3Nt.mjs} +1 -2
  41. package/dist/{mongooseToJsonSchema-C3k4ide0.mjs → mongooseToJsonSchema-DMDotz4_.mjs} +2 -3
  42. package/dist/{multi-tenant.plugin-B_q9NUHc.d.mts → multi-tenant.plugin-DfeDMiI8.d.mts} +143 -11
  43. package/dist/{operations-XiFgG5US.d.mts → operations-Dw9OFV0V.d.mts} +14 -5
  44. package/dist/outbox/index.d.mts +24 -9
  45. package/dist/outbox/index.mjs +17 -6
  46. package/dist/pagination/PaginationEngine.d.mts +1 -1
  47. package/dist/pagination/PaginationEngine.mjs +1 -2
  48. package/dist/pagination/utils/limits.d.mts +6 -9
  49. package/dist/pagination/utils/limits.mjs +2 -3
  50. package/dist/{pagination-D6psses0.d.mts → pagination-DAUg5zOY.d.mts} +2 -10
  51. package/dist/{plugin-options-Cirmb4Kp.d.mts → plugin-options-Bpcuz9kS.d.mts} +2 -4
  52. package/dist/plugins/index.d.mts +6 -7
  53. package/dist/plugins/index.mjs +6 -7
  54. package/dist/query/primitives/coercion.d.mts +1 -1
  55. package/dist/query/primitives/coercion.mjs +2 -3
  56. package/dist/query/primitives/geo.d.mts +11 -14
  57. package/dist/query/primitives/geo.mjs +1 -2
  58. package/dist/query/primitives/indexes.d.mts +1 -1
  59. package/dist/query/primitives/indexes.mjs +1 -2
  60. package/dist/{query-defaults-BxUcSOoF.mjs → query-defaults-aUoKlEbh.mjs} +2 -3
  61. package/dist/read-model/index.d.mts +18 -21
  62. package/dist/read-model/index.mjs +3 -4
  63. package/dist/{repository-MqAP409e.d.mts → repository-DzVIwhU2.d.mts} +25 -29
  64. package/dist/retention-purge-DqcYvRnG.d.mts +35 -0
  65. package/dist/{rolldown-runtime-D7D4PA-g.mjs → rolldown-runtime-D8jQpDG7.mjs} +1 -2
  66. package/dist/search/index.d.mts +1 -1
  67. package/dist/search/index.mjs +1 -2
  68. package/dist/sync/index.d.mts +95 -15
  69. package/dist/sync/index.mjs +308 -62
  70. package/dist/tenant/index.d.mts +8 -11
  71. package/dist/tenant/index.mjs +1 -2
  72. package/dist/testkit/index.d.mts +179 -23
  73. package/dist/testkit/index.mjs +421 -23
  74. package/dist/{timestamp.plugin-DpOWavVI.mjs → timestamp.plugin-Bz_zL9PX.mjs} +192 -49
  75. package/dist/{to-plain-vksRrLtc.mjs → to-plain-CEKv_ylg.mjs} +2 -4
  76. package/dist/{to-plain-o5tUY2nV.d.mts → to-plain-PO5r1Qoy.d.mts} +2 -16
  77. package/dist/{transaction-DmiB4k2P.mjs → transaction-C2PFxfIv.mjs} +5 -8
  78. package/dist/{type-utils-CLzRFtIA.d.mts → type-utils-CKvuewAL.d.mts} +0 -2
  79. package/dist/{update-WgeAUJ_I.mjs → update-DcfmNbJe.mjs} +12 -20
  80. package/dist/usage/index.d.mts +2 -5
  81. package/dist/usage/index.mjs +8 -6
  82. package/dist/utils/index.d.mts +8 -10
  83. package/dist/utils/index.mjs +7 -7
  84. package/dist/{validation-chain.plugin-BAeC5eVT.d.mts → validation-chain.plugin-B3gZCZj4.d.mts} +135 -48
  85. package/dist/{validation-chain.plugin-DRApTmkN.mjs → validation-chain.plugin-DNY6MIF6.mjs} +207 -55
  86. package/llms.txt +33 -0
  87. package/package.json +29 -25
  88. package/skills/mongokit/SKILL.md +112 -0
  89. package/skills/mongokit/references/pagination-search.md +87 -0
  90. package/skills/mongokit/references/plugins-tenancy.md +102 -0
  91. package/skills/mongokit/references/repository-api.md +83 -0
  92. 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).