@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
@@ -1,14 +1,14 @@
1
- import { n as collectIds, r as idChunks, t as DEFAULT_ID_CHUNK } from "./id-chunks-BmAwsqNk.mjs";
2
- import { a as ALL_OPERATIONS, s as OP_REGISTRY } from "./Repository-Dj6xqPxJ.mjs";
3
- import { w as upsert } from "./update-WgeAUJ_I.mjs";
4
- import { n as warn } from "./logger-K7dPmu2U.mjs";
5
- import { t as createError } from "./error-BuByZwKU.mjs";
6
- import { c as tenantContextKeysOf, o as forwardScope } from "./timestamp.plugin-DpOWavVI.mjs";
7
- import { r as getFieldsForUser } from "./field-selection-FSQFLpsU.mjs";
1
+ import { t as createError } from "./error-DjdmPLWO.mjs";
2
+ import { a as ALL_OPERATIONS, f as readRetentionPurge, p as retentionPurgeEvent, s as OP_REGISTRY } from "./Repository-eMRYZ_2Y.mjs";
3
+ import { n as collectIds, r as idChunks, t as DEFAULT_ID_CHUNK } from "./id-chunks-DH-RAZWG.mjs";
4
+ import { w as upsert } from "./update-DcfmNbJe.mjs";
5
+ import { n as warn } from "./logger-hbWOlj_X.mjs";
6
+ import { c as tenantContextKeysOf, o as forwardScope } from "./timestamp.plugin-Bz_zL9PX.mjs";
7
+ import { r as getFieldsForUser } from "./field-selection-DPFaFw9Y.mjs";
8
8
  import mongoose from "mongoose";
9
9
  import { HOOK_PRIORITY } from "@classytic/repo-core/hooks";
10
10
  import { AsyncLocalStorage } from "node:async_hooks";
11
- //#region src/plugins/aggregate-helpers.plugin.ts
11
+ // src/plugins/aggregate-helpers.plugin.ts
12
12
  /**
13
13
  * Aggregate helpers plugin
14
14
  *
@@ -71,8 +71,7 @@ function aggregateHelpersPlugin() {
71
71
  }
72
72
  };
73
73
  }
74
- //#endregion
75
- //#region src/plugins/append-only.plugin.ts
74
+ // src/plugins/append-only.plugin.ts
76
75
  /**
77
76
  * Append-Only Plugin
78
77
  *
@@ -114,6 +113,19 @@ function aggregateHelpersPlugin() {
114
113
  * maintenance (backfill repair, migration), grep-auditable at the
115
114
  * call site.
116
115
  *
116
+ * **Retention purge** (`allowPurge: true`): the one public path for a
117
+ * retention job to physically remove rows. Pass
118
+ * `retentionPurge: { reason, actor? }` with `mode: 'hard'` on `delete` /
119
+ * `deleteMany`, or run `purgeByFilter(filter, { type: 'hard' },
120
+ * { retentionPurge })` / `purgeByField(...)`, which forward it to every
121
+ * chunk. Each successful call emits `after:append-only-purge`
122
+ * ({@link AppendOnlyPurgeEvent}) with the reason, actor, target and
123
+ * `deletedCount`. Without `allowPurge`, a missing reason or `mode: 'hard'`
124
+ * the call is refused; on any other op the option is ignored (the fence
125
+ * applies). The same option is honoured by `immutableStatesPlugin`, so a
126
+ * repository with both plugins purges through one call. Prefer it over
127
+ * `bypassAppendOnly` for retention: it is delete-only and carries a reason.
128
+ *
117
129
  * `skipPlugins` is deliberately IGNORED — same policy as tenant scope
118
130
  * and soft delete: integrity fences don't have a convenience bypass.
119
131
  *
@@ -125,7 +137,14 @@ function aggregateHelpersPlugin() {
125
137
  * await events.create({ ... }); // OK
126
138
  * await events.update(id, { ... }); // throws 405 APPEND_ONLY_VIOLATION
127
139
  * await events.deleteMany({ ... }, { bypassAppendOnly: true }); // audited bypass
140
+ *
141
+ * @example Retention purge (construct with `appendOnlyPlugin({ allowPurge: true })`)
142
+ * events.on('after:append-only-purge', (e) => audit('retention.purge', e));
143
+ * await events.purgeByFilter({ at: { $lt: cutoff } }, { type: 'hard' }, {
144
+ * retentionPurge: { reason: 'retention: 1095 days', actor: 'job:retention' },
145
+ * });
128
146
  */
147
+ const PURGE_OPS = /* @__PURE__ */ new Set(["delete", "deleteMany"]);
129
148
  const ALWAYS_ALLOWED = /* @__PURE__ */ new Set(["create", "createMany"]);
130
149
  function appendOnlyPlugin(options = {}) {
131
150
  const allowed = /* @__PURE__ */ new Set([...ALWAYS_ALLOWED, ...options.allow ?? []]);
@@ -133,9 +152,21 @@ function appendOnlyPlugin(options = {}) {
133
152
  name: "appendOnly",
134
153
  apply(repo) {
135
154
  const modelName = repo.Model?.modelName ?? "model";
155
+ const purgeGuard = {
156
+ plugin: "appendOnlyPlugin",
157
+ allowPurge: options.allowPurge === true
158
+ };
159
+ const priority = options.priority ?? HOOK_PRIORITY.POLICY;
136
160
  for (const [op, meta] of Object.entries(OP_REGISTRY)) {
137
- if (!meta.mutates || allowed.has(op)) continue;
161
+ if (!meta.mutates) continue;
162
+ if (allowed.has(op)) {
163
+ if (PURGE_OPS.has(op)) repo.on(`before:${op}`, (context) => {
164
+ readRetentionPurge(context, op, purgeGuard);
165
+ }, { priority });
166
+ continue;
167
+ }
138
168
  repo.on(`before:${op}`, (context) => {
169
+ if (PURGE_OPS.has(op) && readRetentionPurge(context, op, purgeGuard)) return;
139
170
  if (context.bypassAppendOnly === true) {
140
171
  repo.emit("after:append-only-bypass", {
141
172
  operation: op,
@@ -150,13 +181,16 @@ function appendOnlyPlugin(options = {}) {
150
181
  model: modelName
151
182
  }
152
183
  });
153
- }, { priority: options.priority ?? HOOK_PRIORITY.POLICY });
184
+ }, { priority });
154
185
  }
186
+ if (options.allowPurge) for (const op of ["delete", "deleteMany"]) repo.on(`after:${op}`, async (payload) => {
187
+ const event = retentionPurgeEvent(op, payload.context, payload.result);
188
+ if (event) await repo.emitAsync("after:append-only-purge", event);
189
+ });
155
190
  }
156
191
  };
157
192
  }
158
- //#endregion
159
- //#region src/plugins/cascade.plugin.ts
193
+ // src/plugins/cascade.plugin.ts
160
194
  /**
161
195
  * Cascade Delete Plugin
162
196
  *
@@ -477,8 +511,7 @@ async function runCascades(relations, fn, parallel, session) {
477
511
  }
478
512
  } else for (const relation of relations) await fn(relation);
479
513
  }
480
- //#endregion
481
- //#region src/plugins/elastic.plugin.ts
514
+ // src/plugins/elastic.plugin.ts
482
515
  function elasticSearchPlugin(options) {
483
516
  return {
484
517
  name: "elastic-search",
@@ -549,8 +582,7 @@ function elasticSearchPlugin(options) {
549
582
  }
550
583
  };
551
584
  }
552
- //#endregion
553
- //#region src/plugins/field-filter.plugin.ts
585
+ // src/plugins/field-filter.plugin.ts
554
586
  /**
555
587
  * Field filter plugin that restricts fields based on user context
556
588
  *
@@ -582,8 +614,7 @@ function fieldFilterPlugin(fieldPreset) {
582
614
  }
583
615
  };
584
616
  }
585
- //#endregion
586
- //#region src/plugins/idempotency-store.plugin.ts
617
+ // src/plugins/idempotency-store.plugin.ts
587
618
  function randomToken() {
588
619
  const bytes = /* @__PURE__ */ new Uint8Array(16);
589
620
  globalThis.crypto.getRandomValues(bytes);
@@ -735,8 +766,7 @@ function idempotencyStorePlugin(options = {}) {
735
766
  }
736
767
  };
737
768
  }
738
- //#endregion
739
- //#region src/plugins/immutable-states.plugin.ts
769
+ // src/plugins/immutable-states.plugin.ts
740
770
  /** Ops this plugin has an explicit strategy for (see module doc). */
741
771
  const STRATEGY_OPS = /* @__PURE__ */ new Set([
742
772
  "create",
@@ -778,10 +808,12 @@ function immutableStatesPlugin(options) {
778
808
  if (options.tenantField && ctx.query && options.tenantField in ctx.query) q[options.tenantField] = ctx.query[options.tenantField];
779
809
  return q;
780
810
  };
811
+ /** The call's session: every guard read runs in it, so a row frozen earlier in the same transaction counts. */
812
+ const sessionOf = (ctx) => ctx.session ?? null;
781
813
  /** Raw-Model read — never re-enters the repo hook pipeline. */
782
814
  const readStateById = async (ctx) => {
783
815
  if (ctx.id === void 0 || ctx.id === null) return void 0;
784
- const doc = await Model.findOne(scoped({ _id: ctx.id }, ctx)).select({ [field]: 1 }).lean();
816
+ const doc = await Model.findOne(scoped({ _id: ctx.id }, ctx)).select({ [field]: 1 }).session(sessionOf(ctx)).lean();
785
817
  return doc ? getState(doc) : void 0;
786
818
  };
787
819
  const guardById = (op) => async (ctx) => {
@@ -793,19 +825,41 @@ function immutableStatesPlugin(options) {
793
825
  state
794
826
  });
795
827
  };
828
+ const purgeGuard = {
829
+ plugin: "immutableStatesPlugin",
830
+ allowPurge: options.allowPurge === true
831
+ };
832
+ const purgeRequest = (op, ctx) => readRetentionPurge(ctx, op, purgeGuard);
796
833
  for (const op of [
797
834
  "update",
798
- "delete",
799
835
  "claimVersion",
800
836
  "restore"
801
837
  ]) repo.on(`before:${op}`, guardById(op));
838
+ const guardDelete = guardById("delete");
839
+ repo.on("before:delete", async (ctx) => {
840
+ if (ctx[internalFlag]) return;
841
+ if (purgeRequest("delete", ctx)) return;
842
+ await guardDelete(ctx);
843
+ });
844
+ const emitPurge = async (operation, context, result) => {
845
+ if (!options.allowPurge || context[internalFlag]) return;
846
+ const base = retentionPurgeEvent(operation, context, result);
847
+ if (!base) return;
848
+ const event = {
849
+ ...base,
850
+ states: [...frozen]
851
+ };
852
+ await repo.emitAsync("after:immutable-states-purge", event);
853
+ };
854
+ repo.on("after:delete", (payload) => emitPurge("delete", payload.context, payload.result));
855
+ repo.on("after:deleteMany", (payload) => emitPurge("deleteMany", payload.context, payload.result));
802
856
  repo.on("before:findOneAndUpdate", async (ctx) => {
803
857
  if (ctx[internalFlag]) return;
804
858
  const filter = ctx.query ?? {};
805
859
  const doc = await Model.findOne(filter).select({
806
860
  [field]: 1,
807
861
  _id: 1
808
- }).lean();
862
+ }).session(sessionOf(ctx)).lean();
809
863
  if (!doc) return;
810
864
  const state = getState(doc);
811
865
  if (state !== void 0 && frozen.has(state)) throwViolation({
@@ -817,7 +871,7 @@ function immutableStatesPlugin(options) {
817
871
  const guardMany = (op) => async (ctx) => {
818
872
  if (ctx[internalFlag]) return;
819
873
  const filter = ctx.query ?? {};
820
- const hit = await Model.exists({ $and: [filter, { [field]: { $in: [...frozen] } }] });
874
+ const hit = await Model.exists({ $and: [filter, { [field]: { $in: [...frozen] } }] }).session(sessionOf(ctx));
821
875
  if (hit) throwViolation({
822
876
  id: hit._id,
823
877
  operation: op,
@@ -825,7 +879,12 @@ function immutableStatesPlugin(options) {
825
879
  });
826
880
  };
827
881
  repo.on("before:updateMany", guardMany("updateMany"));
828
- repo.on("before:deleteMany", guardMany("deleteMany"));
882
+ const guardDeleteMany = guardMany("deleteMany");
883
+ repo.on("before:deleteMany", async (ctx) => {
884
+ if (ctx[internalFlag]) return;
885
+ if (purgeRequest("deleteMany", ctx)) return;
886
+ await guardDeleteMany(ctx);
887
+ });
829
888
  repo.on("before:bulkUpsert", async (ctx) => {
830
889
  if (ctx[internalFlag]) return;
831
890
  const filters = (ctx.operations ?? []).flatMap((op) => op.updateOne?.filter ? [op.updateOne.filter] : []);
@@ -881,8 +940,10 @@ function immutableStatesPlugin(options) {
881
940
  }
882
941
  };
883
942
  }
884
- //#endregion
885
- //#region src/plugins/lease.plugin.ts
943
+ // src/plugins/lease.plugin.ts
944
+ function isNonEmpty(obj) {
945
+ return obj !== void 0 && obj !== null && Object.keys(obj).length > 0;
946
+ }
886
947
  function leasePlugin(options = {}) {
887
948
  const statusField = options.statusField ?? "status";
888
949
  const leasedByField = options.leasedByField ?? "leasedBy";
@@ -891,6 +952,57 @@ function leasePlugin(options = {}) {
891
952
  const processingStatus = options.processingStatus ?? "processing";
892
953
  const doneStatus = options.doneStatus ?? "done";
893
954
  const sort = options.sort ?? { createdAt: 1 };
955
+ const nowFn = options.nowFn ?? (() => /* @__PURE__ */ new Date());
956
+ const deadLeaseStatuses = options.deadLeaseStatuses ?? [processingStatus];
957
+ const attemptsField = options.attemptsField;
958
+ const maxAttempts = options.maxAttempts;
959
+ if (deadLeaseStatuses.length === 0) throw new Error("leasePlugin: deadLeaseStatuses must name at least one status");
960
+ if (maxAttempts !== void 0) {
961
+ if (attemptsField === void 0) throw new Error("leasePlugin: maxAttempts requires attemptsField");
962
+ if (!Number.isInteger(maxAttempts) || maxAttempts < 1) throw new Error("leasePlugin: maxAttempts must be a positive integer");
963
+ }
964
+ const exhaustedStatus = options.exhaustedStatus;
965
+ const exhaustedAtField = options.exhaustedAtField;
966
+ const assertTerminal = (where, status) => {
967
+ if (status === pendingStatus || status === processingStatus || deadLeaseStatuses.includes(status)) throw new Error(`leasePlugin: ${where} "${status}" must differ from the pending, processing and dead-lease statuses`);
968
+ };
969
+ if (exhaustedStatus !== void 0) {
970
+ if (maxAttempts === void 0) throw new Error("leasePlugin: exhaustedStatus requires maxAttempts");
971
+ assertTerminal("exhaustedStatus", exhaustedStatus);
972
+ }
973
+ if (exhaustedAtField !== void 0 && maxAttempts === void 0) throw new Error("leasePlugin: exhaustedAtField requires maxAttempts");
974
+ /**
975
+ * The claim condition: pending (narrowed by `pendingFilter`) OR a dead
976
+ * lease, a row still in a dead-lease status whose `leaseExpiresAt <
977
+ * now` (worker crashed mid-lease). The status guard on the dead arm
978
+ * keeps a cancelled / terminal row with a stale expiry from being
979
+ * revived. `lease()` adds "under the attempts cap"; `reapExhausted()`
980
+ * adds "at or over it".
981
+ */
982
+ const claimable = (now, pendingSource) => {
983
+ const pendingFilter = typeof pendingSource === "function" ? pendingSource(now) : pendingSource;
984
+ return { $or: [isNonEmpty(pendingFilter) ? { $and: [{ [statusField]: pendingStatus }, pendingFilter] } : { [statusField]: pendingStatus }, {
985
+ [statusField]: deadLeaseStatuses.length === 1 ? deadLeaseStatuses[0] : { $in: deadLeaseStatuses },
986
+ [leaseExpiresAtField]: { $lt: now }
987
+ }] };
988
+ };
989
+ /** The caller's filter as given, with `conditions` ANDed beside it. */
990
+ const withConditions = (method, baseFilter, conditions) => {
991
+ if (baseFilter.$and !== void 0 && !Array.isArray(baseFilter.$and)) throw createError(400, `leasePlugin.${method}: filter.$and must be an array`);
992
+ const callerAnd = baseFilter.$and ?? [];
993
+ return {
994
+ ...baseFilter,
995
+ $and: [...callerAnd, ...conditions]
996
+ };
997
+ };
998
+ const resolveNow = (method, perCall) => {
999
+ const now = perCall ?? nowFn();
1000
+ if (!(now instanceof Date) || Number.isNaN(now.getTime())) throw createError(400, `leasePlugin.${method}: now must be a valid Date`);
1001
+ return now;
1002
+ };
1003
+ const assertLeasedBy = (method, leasedBy) => {
1004
+ if (typeof leasedBy !== "string" || leasedBy.length === 0) throw createError(400, `leasePlugin.${method}: leasedBy must be a non-empty string`);
1005
+ };
894
1006
  return {
895
1007
  name: "lease",
896
1008
  apply(repo) {
@@ -904,18 +1016,20 @@ function leasePlugin(options = {}) {
904
1016
  */
905
1017
  repo.registerMethod("lease", async function(opts) {
906
1018
  if (typeof opts?.leaseFor !== "number" || opts.leaseFor <= 0) throw createError(400, "leasePlugin.lease: leaseFor must be a positive number (ms)");
907
- if (typeof opts.leasedBy !== "string" || opts.leasedBy.length === 0) throw createError(400, "leasePlugin.lease: leasedBy must be a non-empty string");
908
- const now = /* @__PURE__ */ new Date();
1019
+ assertLeasedBy("lease", opts.leasedBy);
1020
+ const now = resolveNow("lease", opts.now);
909
1021
  const leaseUntil = new Date(now.getTime() + opts.leaseFor);
910
- const claimFilter = {
911
- ...opts.filter ?? {},
912
- $or: [{ [statusField]: pendingStatus }, { [leaseExpiresAtField]: { $lt: now } }]
913
- };
914
- return this.findOneAndUpdate(claimFilter, { $set: {
915
- [statusField]: processingStatus,
916
- [leasedByField]: opts.leasedBy,
917
- [leaseExpiresAtField]: leaseUntil
918
- } }, {
1022
+ const conditions = [claimable(now, opts.pendingFilter ?? options.pendingFilter)];
1023
+ if (attemptsField !== void 0 && maxAttempts !== void 0) conditions.push({ [attemptsField]: { $not: { $gte: maxAttempts } } });
1024
+ const claimFilter = withConditions("lease", opts.filter ?? {}, conditions);
1025
+ return this.findOneAndUpdate(claimFilter, {
1026
+ $set: {
1027
+ [statusField]: processingStatus,
1028
+ [leasedByField]: opts.leasedBy,
1029
+ [leaseExpiresAtField]: leaseUntil
1030
+ },
1031
+ ...attemptsField !== void 0 ? { $inc: { [attemptsField]: 1 } } : {}
1032
+ }, {
919
1033
  ...opts.options ?? {},
920
1034
  sort,
921
1035
  returnDocument: "after"
@@ -923,11 +1037,13 @@ function leasePlugin(options = {}) {
923
1037
  });
924
1038
  repo.registerMethod("extend", async function(id, opts) {
925
1039
  if (typeof opts?.leaseFor !== "number" || opts.leaseFor <= 0) throw createError(400, "leasePlugin.extend: leaseFor must be a positive number (ms)");
926
- const now = /* @__PURE__ */ new Date();
1040
+ assertLeasedBy("extend", opts.leasedBy);
1041
+ const now = resolveNow("extend", opts.now);
927
1042
  const leaseUntil = new Date(now.getTime() + opts.leaseFor);
928
1043
  const idField = this.idField || "_id";
929
1044
  return this.findOneAndUpdate({
930
1045
  [idField]: id,
1046
+ [statusField]: processingStatus,
931
1047
  [leasedByField]: opts.leasedBy,
932
1048
  [leaseExpiresAtField]: { $gt: now }
933
1049
  }, { $set: { [leaseExpiresAtField]: leaseUntil } }, {
@@ -938,10 +1054,11 @@ function leasePlugin(options = {}) {
938
1054
  repo.registerMethod("release", async function(id, opts) {
939
1055
  if (typeof opts?.leasedBy !== "string" || opts.leasedBy.length === 0) throw createError(400, "leasePlugin.release: leasedBy must be a non-empty string (the holder finalising the lease)");
940
1056
  const finalStatus = opts.finalStatus ?? doneStatus;
941
- const now = /* @__PURE__ */ new Date();
1057
+ const now = resolveNow("release", opts.now);
942
1058
  const idField = this.idField || "_id";
943
1059
  return this.findOneAndUpdate({
944
1060
  [idField]: id,
1061
+ [statusField]: processingStatus,
945
1062
  [leasedByField]: opts.leasedBy,
946
1063
  [leaseExpiresAtField]: { $gt: now }
947
1064
  }, {
@@ -955,11 +1072,52 @@ function leasePlugin(options = {}) {
955
1072
  returnDocument: "after"
956
1073
  });
957
1074
  });
1075
+ repo.registerMethod("reapExhausted", async function(opts = {}) {
1076
+ if (attemptsField === void 0 || maxAttempts === void 0) throw createError(400, "leasePlugin.reapExhausted: requires maxAttempts");
1077
+ const status = opts.status ?? exhaustedStatus;
1078
+ if (typeof status !== "string" || status.length === 0) throw createError(400, "leasePlugin.reapExhausted: pass a status, or configure exhaustedStatus");
1079
+ if (opts.status !== void 0) try {
1080
+ assertTerminal("reapExhausted status", status);
1081
+ } catch (err) {
1082
+ throw createError(400, err.message);
1083
+ }
1084
+ const limit = opts.limit ?? 100;
1085
+ if (!Number.isInteger(limit) || limit < 1) throw createError(400, "leasePlugin.reapExhausted: limit must be a positive integer");
1086
+ const now = resolveNow("reapExhausted", opts.now);
1087
+ const exhausted = withConditions("reapExhausted", opts.filter ?? {}, [claimable(now, opts.pendingFilter ?? options.pendingFilter), { [attemptsField]: { $gte: maxAttempts } }]);
1088
+ const idField = this.idField || "_id";
1089
+ const candidates = await this.findAll.call(this, exhausted, {
1090
+ ...opts.options ?? {},
1091
+ sort,
1092
+ limit,
1093
+ lean: true
1094
+ });
1095
+ const reaped = [];
1096
+ for (const row of candidates) {
1097
+ const moved = await this.findOneAndUpdate({
1098
+ ...exhausted,
1099
+ [idField]: row[idField]
1100
+ }, {
1101
+ $set: {
1102
+ [statusField]: status,
1103
+ ...exhaustedAtField !== void 0 ? { [exhaustedAtField]: now } : {}
1104
+ },
1105
+ $unset: {
1106
+ [leasedByField]: "",
1107
+ [leaseExpiresAtField]: ""
1108
+ }
1109
+ }, {
1110
+ ...opts.options ?? {},
1111
+ returnDocument: "after"
1112
+ });
1113
+ if (moved) reaped.push(moved);
1114
+ }
1115
+ return reaped;
1116
+ });
958
1117
  }
959
1118
  };
960
1119
  }
961
- //#endregion
962
- //#region src/plugins/mongo-operations.plugin.ts
1120
+ // src/plugins/mongo-operations.plugin.ts
963
1121
  /**
964
1122
  * MongoDB Operations Plugin
965
1123
  *
@@ -1196,8 +1354,7 @@ function mongoOperationsPlugin() {
1196
1354
  }
1197
1355
  };
1198
1356
  }
1199
- //#endregion
1200
- //#region src/plugins/names.ts
1357
+ // src/plugins/names.ts
1201
1358
  /**
1202
1359
  * Canonical identifiers exposed by mongokit's bundled plugins.
1203
1360
  *
@@ -1229,8 +1386,7 @@ const MONGOKIT_PLUGIN_NAMES = {
1229
1386
  timestamp: "timestamp",
1230
1387
  validationChain: "validation-chain"
1231
1388
  };
1232
- //#endregion
1233
- //#region src/plugins/observability.plugin.ts
1389
+ // src/plugins/observability.plugin.ts
1234
1390
  /**
1235
1391
  * Observability Plugin
1236
1392
  *
@@ -1305,8 +1461,7 @@ function observabilityPlugin(options) {
1305
1461
  }
1306
1462
  };
1307
1463
  }
1308
- //#endregion
1309
- //#region src/plugins/subdocument.plugin.ts
1464
+ // src/plugins/subdocument.plugin.ts
1310
1465
  /**
1311
1466
  * Subdocument plugin for managing nested arrays
1312
1467
  *
@@ -1375,8 +1530,7 @@ function subdocumentPlugin() {
1375
1530
  }
1376
1531
  };
1377
1532
  }
1378
- //#endregion
1379
- //#region src/plugins/tenant-context.ts
1533
+ // src/plugins/tenant-context.ts
1380
1534
  /**
1381
1535
  * Request-scoped tenant context via AsyncLocalStorage.
1382
1536
  *
@@ -1426,8 +1580,7 @@ function createTenantContext() {
1426
1580
  }
1427
1581
  };
1428
1582
  }
1429
- //#endregion
1430
- //#region src/plugins/validation-chain.plugin.ts
1583
+ // src/plugins/validation-chain.plugin.ts
1431
1584
  /**
1432
1585
  * Validation chain plugin
1433
1586
  *
@@ -1576,5 +1729,4 @@ function uniqueField(field, errorMessage) {
1576
1729
  }
1577
1730
  };
1578
1731
  }
1579
- //#endregion
1580
1732
  export { cascadePlugin as _, uniqueField as a, subdocumentPlugin as c, mongoOperationsPlugin as d, leasePlugin as f, elasticSearchPlugin as g, fieldFilterPlugin as h, requireField as i, observabilityPlugin as l, idempotencyStorePlugin as m, blockIf as n, validationChainPlugin as o, immutableStatesPlugin as p, immutableField as r, createTenantContext as s, autoInject as t, MONGOKIT_PLUGIN_NAMES as u, appendOnlyPlugin as v, aggregateHelpersPlugin as y };
package/llms.txt ADDED
@@ -0,0 +1,33 @@
1
+ # @classytic/mongokit
2
+
3
+ Mongoose 9 repositories implementing repo-core StandardRepo. Skill: skills/mongokit/SKILL.md. Peers: @classytic/repo-core, mongoose.
4
+
5
+ | Need | Use |
6
+ |---|---|
7
+ | Register a model once, safely | `defineModel`, `defineModels` |
8
+ | Repository with tenant, soft delete, timestamps, cache, audit | `createRepository` |
9
+ | List page (offset or keyset) | `repo.getAll({ mode, page, after, sort })`, rows in `data` |
10
+ | HTTP query string to repo params | `QueryParser` |
11
+ | Status change with history and race diagnosis | `repo.applyTransition` with a `TransitionMachine` |
12
+ | Optimistic lock | `repo.claimVersion` |
13
+ | Atomic multi-write | `withTransaction`, `batchTransaction` |
14
+ | Duplicate key error | `isDuplicateKeyError` |
15
+ | Search as you type | `searchMode: 'prefix'`, `searchKeysPath`, `rebuildSearchKeys` |
16
+ | Outbox table | `createOutboxRepository` from `@classytic/mongokit/outbox` |
17
+ | Idempotency claims | `createIdempotencyStore` from `@classytic/mongokit/idempotency` |
18
+ | Cross-process lock | `createMongoLockAdapter` from `@classytic/mongokit/lock` |
19
+ | Once-per-day job ledger for the arc daily gate | `createMongoDailyLedger` from `@classytic/mongokit/lock` |
20
+ | Atomic counter in your own collection | `createSequenceCounter` |
21
+ | Counter that resets per civil year in a time zone | `getNextPeriodSequence` |
22
+ | Worker lease with clock and attempts cap | `leasePlugin` |
23
+ | Check declared vs built indexes | `verifyIndexes` from `@classytic/mongokit/indexes` |
24
+ | Tenant-led indexes on a schema | `injectTenantField` from `@classytic/mongokit/tenant` |
25
+ | Query timeout, read concern, profiler comment | `configureQueryDefaults` |
26
+ | Drop the per-page count | `configurePaginationDefaults` |
27
+ | Tenant bypass for jobs | `systemContext` |
28
+ | Forward request scope | `repoOptionsFromCtx` |
29
+ | arc adapter | `createMongooseAdapter` from `@classytic/mongokit/adapter` |
30
+ | Test database | `createTestRepository` from `@classytic/mongokit/testkit` |
31
+
32
+ Read first: SKILL.md. Details: references/repository-api.md, pagination-search.md, plugins-tenancy.md, transactions-outbox.md.
33
+ Envelope and error types: skill repo-core.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@classytic/mongokit",
3
- "version": "3.43.0",
3
+ "version": "3.44.0",
4
4
  "description": "Production-grade MongoDB repositories - smart pagination, events, and plugins. Built on @classytic/repo-core.",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -105,7 +105,9 @@
105
105
  "dist",
106
106
  "README.md",
107
107
  "LICENSE",
108
- "TRADEMARK.md"
108
+ "TRADEMARK.md",
109
+ "llms.txt",
110
+ "skills/**/*.md"
109
111
  ],
110
112
  "keywords": [
111
113
  "mongodb",
@@ -150,9 +152,9 @@
150
152
  },
151
153
  "homepage": "https://github.com/classytic/mongokit#readme",
152
154
  "peerDependencies": {
153
- "@classytic/repo-core": ">=0.31.0",
154
- "mongodb-memory-server": ">=10.4.3",
155
- "mongoose": ">=9.11.0"
155
+ "@classytic/repo-core": ">=0.37.0",
156
+ "mongodb-memory-server": ">=11.3.0",
157
+ "mongoose": ">=9.11.4"
156
158
  },
157
159
  "peerDependenciesMeta": {
158
160
  "mongodb-memory-server": {
@@ -185,7 +187,7 @@
185
187
  "check": "biome ci src/ --diagnostic-level=error",
186
188
  "check:dead-code": "knip",
187
189
  "check:peer-skew": "node scripts/check-peer-skew.mjs",
188
- "ci": "npm run check:peer-skew && npm run check && npm run typecheck && npm run typecheck:tests && npm run build && npm run lint:package && npm run check:dead-code && npm run check:forgotten-exports && npm test",
190
+ "ci": "npm run check:peer-skew && npm run check && npm run typecheck && npm run typecheck:tests && npm run build && npm run lint:package && npm run check:dead-code && npm run check:forgotten-exports && npm run check:skills && npm test",
189
191
  "push": "classytic-push",
190
192
  "prepublishOnly": "npm run ci",
191
193
  "publish:dry": "npm publish --dry-run --access public",
@@ -195,29 +197,31 @@
195
197
  "release:patch": "npm version patch && npm run release",
196
198
  "release:minor": "npm version minor && npm run release",
197
199
  "release:major": "npm version major && npm run release",
198
- "check:forgotten-exports": "node scripts/check-forgotten-exports.mjs"
200
+ "check:forgotten-exports": "node scripts/check-forgotten-exports.mjs",
201
+ "check:skills": "node scripts/check-skills.mjs"
199
202
  },
200
203
  "devDependencies": {
201
204
  "@arethetypeswrong/cli": "^0.18.5",
202
- "@better-auth/api-key": "^1.6.29",
203
- "@better-auth/mongo-adapter": "^1.6.29",
204
- "@biomejs/biome": "^2.5.8",
205
- "@classytic/dev-tools": "^0.2.0",
206
- "@classytic/repo-core": "^0.31.1",
207
- "@types/node": "^26.2.0",
208
- "@vitest/coverage-v8": "^4.1.10",
205
+ "@better-auth/api-key": "^1.7.15",
206
+ "@better-auth/mongo-adapter": "^1.7.15",
207
+ "@biomejs/biome": "^2.6.4",
208
+ "@classytic/dev-tools": "^0.2.1",
209
+ "@classytic/repo-core": "^0.37.0",
210
+ "@types/node": "^26.6.6",
211
+ "@vitest/coverage-v8": "^5.1.0",
209
212
  "ajv": "^8.20.0",
210
213
  "ajv-formats": "^3.0.1",
211
- "better-auth": "^1.6.29",
212
- "dotenv": "^17.4.2",
213
- "fast-check": "^4.7.0",
214
- "knip": "^6.32.2",
215
- "mongodb": "^7.5.0",
216
- "mongodb-memory-server": "^11.2.0",
217
- "mongoose": "^9.11.1",
218
- "publint": "^0.3.23",
219
- "tsdown": "^0.22.14",
220
- "typescript": "~5.9.3",
221
- "vitest": "^4.1.10"
214
+ "better-auth": "^1.7.15",
215
+ "dotenv": "^18.0.9",
216
+ "fast-check": "^4.10.2",
217
+ "knip": "^6.44.1",
218
+ "mongodb": "~7.7.0",
219
+ "mongodb-memory-server": "^11.3.0",
220
+ "mongoose": "^9.11.4",
221
+ "publint": "^0.3.25",
222
+ "ts-api": "npm:typescript@~5.9.3",
223
+ "tsdown": "^0.23.4",
224
+ "typescript": "~7.0.2",
225
+ "vitest": "^5.1.0"
222
226
  }
223
227
  }