@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.
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 -2
  8. package/dist/{actions-ChYfkPYP.mjs → actions-DExyXMTy.mjs} +3 -4
  9. package/dist/adapter/index.d.mts +6 -10
  10. package/dist/adapter/index.mjs +1 -1
  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 +17 -19
  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 -1
  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 -1
  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,8 +1,27 @@
1
- import { o as withTransaction } from "../transaction-DmiB4k2P.mjs";
1
+ import { o as withTransaction } from "../transaction-C2PFxfIv.mjs";
2
2
  import { Schema, Types } from "mongoose";
3
- //#region src/sync/index.ts
3
+ import { CursorExpiredError } from "@classytic/repo-core/sync";
4
+ // src/sync/index.ts
5
+ /**
6
+ * The mongo stores behind `@classytic/repo-core/sync` (replica set): the change feed
7
+ * (`createChangeLogStore`, filled by `changeLogPlugin`) and the command streams
8
+ * (`createCommandStreamStore`) a sync engine such as `@classytic/arc-sync` records verdicts in.
9
+ * Proven by `runChangeLogStoreConformance` and `runCommandStreamStoreConformance`.
10
+ *
11
+ * ```ts
12
+ * const changes = createChangeLogStore(createChangeLogModels(connection));
13
+ * const streams = createCommandStreamStore(connection, createCommandStreamModels(connection), changes);
14
+ * ```
15
+ *
16
+ * Feed cursors are a gap-free sequence taken from ONE counter document inside the caller's
17
+ * transaction, so sequence order IS commit order: no reader can checkpoint past an entry that has
18
+ * not committed yet. The single counter caps append throughput — the price of never losing an entry.
19
+ */
20
+ /** Default deferred-sequencer lease: a crashed holder delays numbering at most this long. */
21
+ const DEFAULT_SEQUENCER_LEASE_MS = 5e3;
4
22
  function createChangeLogModels(connection, options = {}) {
5
- const { collection = "sync_changes", modelName = "SyncChange", sequencing = "in-transaction" } = options;
23
+ const { collection = "sync_changes", modelName = "SyncChange", sequencing = "in-transaction", sequencerLeaseMs = DEFAULT_SEQUENCER_LEASE_MS } = options;
24
+ assertLeaseMs(sequencerLeaseMs);
6
25
  const entriesName = modelName;
7
26
  const counterName = `${modelName}Counter`;
8
27
  const entries = connection.models[entriesName] ?? connection.model(entriesName, (() => {
@@ -60,6 +79,7 @@ function createChangeLogModels(connection, options = {}) {
60
79
  docId: 1,
61
80
  seq: 1
62
81
  }, { name: "doc_versions" });
82
+ schema.index({ at: 1 }, { name: "feed_age" });
63
83
  return schema;
64
84
  })());
65
85
  const counter = connection.models[counterName] ?? connection.model(counterName, new Schema({
@@ -73,11 +93,42 @@ function createChangeLogModels(connection, options = {}) {
73
93
  },
74
94
  /** The deferred sequencer's lease. */
75
95
  lockUntil: { type: Date },
76
- lockBy: { type: String }
96
+ lockBy: { type: String },
97
+ /** `prune`'s feed-wide horizon: the highest seq it deleted. An unfiltered cursor below it is expired. */
98
+ horizon: { type: Number }
77
99
  }, {
78
100
  collection: `${collection}_counter`,
79
101
  versionKey: false
80
102
  }));
103
+ const horizonName = `${modelName}Horizon`;
104
+ const horizons = connection.models[horizonName] ?? connection.model(horizonName, (() => {
105
+ const schema = new Schema({
106
+ tenantId: {
107
+ type: String,
108
+ default: null
109
+ },
110
+ scope: {
111
+ type: String,
112
+ required: true
113
+ },
114
+ horizon: {
115
+ type: Number,
116
+ required: true
117
+ }
118
+ }, {
119
+ collection: `${collection}_horizon`,
120
+ versionKey: false
121
+ });
122
+ schema.index({
123
+ tenantId: 1,
124
+ scope: 1
125
+ }, {
126
+ unique: true,
127
+ name: "tenant_scope_horizon"
128
+ });
129
+ schema.index({ scope: 1 }, { name: "scope_horizon" });
130
+ return schema;
131
+ })());
81
132
  const pendingName = `${modelName}Pending`;
82
133
  const pending = sequencing === "deferred" ? connection.models[pendingName] ?? connection.model(pendingName, new Schema({
83
134
  scope: {
@@ -111,11 +162,16 @@ function createChangeLogModels(connection, options = {}) {
111
162
  return {
112
163
  entries,
113
164
  counter,
165
+ horizons,
114
166
  ...pending ? { pending } : {},
115
167
  sequencing,
168
+ sequencerLeaseMs,
116
169
  feed: collection
117
170
  };
118
171
  }
172
+ function assertLeaseMs(ms) {
173
+ if (typeof ms !== "number" || !Number.isInteger(ms) || ms < 100) throw new Error("[mongokit:sync] sequencerLeaseMs must be an integer of at least 100");
174
+ }
119
175
  /** A change as staged under deferred sequencing — the feed row without its `seq`. */
120
176
  function stagedRow(entry, at) {
121
177
  return {
@@ -137,36 +193,81 @@ const fromCursor = (cursor) => {
137
193
  return seq;
138
194
  };
139
195
  const DEFAULT_LIMIT = 500;
140
- /** How long one sequencer holds the lease — far above one batch, so a live holder never loses it mid-batch. */
141
- const SEQUENCER_LEASE_MS = 3e4;
142
196
  const SEQUENCER_BATCH = 500;
143
- function createChangeLogStore(models) {
144
- const { entries, counter, feed, pending } = models;
197
+ /** Thrown inside a batch transaction whose lease was taken over: the transaction aborts, nothing it wrote lands. */
198
+ var SequencerLeaseLost = class extends Error {};
199
+ function createChangeLogStore(models, storeOptions = {}) {
200
+ const { entries, counter, feed, pending, horizons } = models;
145
201
  const deferred = models.sequencing === "deferred" && pending !== void 0;
202
+ const leaseMs = models.sequencerLeaseMs ?? 5e3;
203
+ assertLeaseMs(leaseMs);
204
+ const now = storeOptions.now ?? (() => /* @__PURE__ */ new Date());
205
+ const onSequenced = storeOptions.onSequenced;
206
+ const counterId = feed;
207
+ const report = (event) => {
208
+ try {
209
+ onSequenced?.(event);
210
+ } catch {}
211
+ };
212
+ /** The feed's numbered top (the counter row's `seq`; 0 before the first entry). */
213
+ const counterTop = async () => {
214
+ const row = await counter.collection.findOne({ _id: counterId }, { projection: { seq: 1 } });
215
+ return typeof row?.seq === "number" ? row.seq : 0;
216
+ };
217
+ /**
218
+ * After a pass found the lease busy (and a hook is set): retry in the background until the holder is done, then
219
+ * report what got numbered meanwhile. One follow-up per store at a time; bounded by three leases.
220
+ */
221
+ let followUp = false;
222
+ const followBusyLease = async () => {
223
+ if (!onSequenced || followUp) return;
224
+ followUp = true;
225
+ try {
226
+ const seen = await counterTop();
227
+ const deadline = Date.now() + leaseMs * 3;
228
+ let delay = Math.max(10, Math.min(50, Math.floor(leaseMs / 10)));
229
+ for (;;) {
230
+ await new Promise((r) => {
231
+ setTimeout(r, delay).unref?.();
232
+ });
233
+ const result = await runPass();
234
+ if (result !== null) {
235
+ if (result === 0) {
236
+ const top = await counterTop();
237
+ if (top > seen) report({
238
+ cursor: toCursor(top),
239
+ numbered: top - seen,
240
+ by: "other"
241
+ });
242
+ }
243
+ return;
244
+ }
245
+ if (Date.now() > deadline) return;
246
+ delay = Math.min(delay * 2, Math.max(50, Math.floor(leaseMs / 2)));
247
+ }
248
+ } catch {} finally {
249
+ followUp = false;
250
+ }
251
+ };
146
252
  /**
147
253
  * One pass under the lease: committed staged rows → the feed, in staging order, numbered above everything.
148
- * Nothing staged costs ONE read. A crash mid-pass is converged by the next one: an entry already in the feed is
149
- * recognised by its unique `pendingId`, and a stale counter is caught by the unique `seq` index.
254
+ * Nothing staged costs ONE read. The lease lives on the counter row and is judged on the database clock; each
255
+ * batch commits in ONE transaction (insert, counter + lease renewal gated on this pass's token, staged-row delete),
256
+ * so a holder that lost the lease to a takeover writes nothing, and a crash leaves no half batch. Without
257
+ * transactions (standalone) the batch runs unfenced, as before. A pre-existing half batch is still converged:
258
+ * an entry already in the feed is recognised by its unique `pendingId`, a stale counter by the unique `seq` index.
150
259
  */
151
- const sequencePending = async () => {
260
+ const runPass = async () => {
152
261
  if (!deferred || !pending) return 0;
153
262
  if (!await pending.exists({}).exec()) return 0;
154
263
  const token = new Types.ObjectId().toString();
155
- const unlocked = {
156
- _id: feed,
157
- $or: [
158
- { lockUntil: { $exists: false } },
159
- { lockUntil: null },
160
- { lockUntil: { $lt: /* @__PURE__ */ new Date() } }
161
- ]
162
- };
163
- const takeLease = () => counter.findOneAndUpdate(unlocked, { $set: {
164
- lockUntil: new Date(Date.now() + SEQUENCER_LEASE_MS),
165
- lockBy: token
166
- } }, {
167
- returnDocument: "after",
168
- lean: true
169
- }).exec();
264
+ const takeLease = async () => await counter.collection.findOneAndUpdate({
265
+ _id: counterId,
266
+ $expr: { $lt: ["$lockUntil", "$$NOW"] }
267
+ }, [{ $set: {
268
+ lockUntil: { $add: ["$$NOW", leaseMs] },
269
+ lockBy: { $literal: token }
270
+ } }], { returnDocument: "after" });
170
271
  let lease = await takeLease();
171
272
  if (!lease) {
172
273
  if (await counter.exists({ _id: feed }).exec()) return null;
@@ -181,39 +282,53 @@ function createChangeLogStore(models) {
181
282
  }
182
283
  let seq = lease.seq ?? 0;
183
284
  let numbered = 0;
285
+ const build = (from, list) => list.map((r, i) => {
286
+ const { _id, ...rest } = r;
287
+ return {
288
+ ...rest,
289
+ seq: from + i + 1,
290
+ pendingId: String(_id)
291
+ };
292
+ });
293
+ /** Commit one batch atomically under the lease; `fresh` are the rows not yet in the feed. */
294
+ const commit = (rows, fresh) => withTransaction(entries.db, async (session) => {
295
+ if (fresh.length > 0) await entries.insertMany(build(seq, fresh), {
296
+ ordered: true,
297
+ session
298
+ });
299
+ if ((await counter.collection.updateOne({
300
+ _id: counterId,
301
+ lockBy: token
302
+ }, [{ $set: {
303
+ seq: { $max: ["$seq", seq + fresh.length] },
304
+ lockUntil: { $add: ["$$NOW", leaseMs] }
305
+ } }], { session })).matchedCount === 0) throw new SequencerLeaseLost();
306
+ await pending.deleteMany({ _id: { $in: rows.map((r) => r._id) } }, { session }).exec();
307
+ }, { allowFallback: true });
184
308
  try {
185
309
  for (;;) {
186
310
  const rows = await pending.find({}).sort({ _id: 1 }).limit(SEQUENCER_BATCH).lean().exec();
187
311
  if (rows.length === 0) break;
188
- const build = (from, list) => list.map((r, i) => {
189
- const { _id, ...rest } = r;
190
- return {
191
- ...rest,
192
- seq: from + i + 1,
193
- pendingId: String(_id)
194
- };
195
- });
196
312
  try {
197
- await entries.insertMany(build(seq, rows), { ordered: true });
313
+ await commit(rows, rows);
198
314
  seq += rows.length;
199
315
  numbered += rows.length;
200
316
  } catch (err) {
317
+ if (err instanceof SequencerLeaseLost) break;
201
318
  if (err.code !== 11e3) throw err;
202
319
  const placed = new Set((await entries.find({ pendingId: { $in: rows.map((r) => String(r._id)) } }).select({ pendingId: 1 }).lean().exec()).map((e) => e.pendingId));
203
320
  const fresh = rows.filter((r) => !placed.has(String(r._id)));
204
321
  const top = await entries.findOne({}).sort({ seq: -1 }).select({ seq: 1 }).lean().exec();
205
322
  seq = Math.max(seq, top?.seq ?? 0);
206
- if (fresh.length > 0) {
207
- await entries.insertMany(build(seq, fresh), { ordered: true });
208
- seq += fresh.length;
209
- numbered += fresh.length;
323
+ try {
324
+ await commit(rows, fresh);
325
+ } catch (again) {
326
+ if (again instanceof SequencerLeaseLost) break;
327
+ throw again;
210
328
  }
329
+ seq += fresh.length;
330
+ numbered += fresh.length;
211
331
  }
212
- await counter.updateOne({
213
- _id: feed,
214
- lockBy: token
215
- }, { $max: { seq } }).exec();
216
- await pending.deleteMany({ _id: { $in: rows.map((r) => r._id) } }).exec();
217
332
  if (rows.length < SEQUENCER_BATCH) break;
218
333
  }
219
334
  } finally {
@@ -225,18 +340,60 @@ function createChangeLogStore(models) {
225
340
  lockBy: null
226
341
  } }).exec();
227
342
  }
343
+ if (numbered > 0) report({
344
+ cursor: toCursor(seq),
345
+ numbered,
346
+ by: "self"
347
+ });
228
348
  return numbered;
229
349
  };
350
+ /** One pass; a busy lease starts the background follow-up (only with `onSequenced`). */
351
+ const sequencePending = async () => {
352
+ const result = await runPass();
353
+ if (result === null) followBusyLease();
354
+ return result;
355
+ };
230
356
  const sessionOf = (options) => options?.session ? { session: options.session } : {};
231
- const filterOf = (after, options = {}) => {
357
+ /** What a reader sees: its tenant (plus shared scopes), narrowed by `scopes`. Matches entries and horizon rows. */
358
+ const viewOf = (options = {}) => {
232
359
  const { tenantId, scopes, sharedScopes } = options;
233
- const tenant = tenantId === void 0 ? {} : sharedScopes?.length ? { $or: [{ tenantId }, { scope: { $in: [...sharedScopes] } }] } : { tenantId };
234
360
  return {
235
- seq: { $gt: after },
236
- ...tenant,
361
+ ...tenantId === void 0 ? {} : sharedScopes?.length ? { $or: [{ tenantId }, { scope: { $in: [...sharedScopes] } }] } : { tenantId },
237
362
  ...scopes ? { scope: { $in: [...scopes] } } : {}
238
363
  };
239
364
  };
365
+ const filterOf = (after, options = {}) => ({
366
+ seq: { $gt: after },
367
+ ...viewOf(options)
368
+ });
369
+ /** The recorded feed-wide compaction horizon (0: never pruned). Raw driver: the counter row is schemaless here. */
370
+ const horizonSeq = async () => {
371
+ const row = await counter.collection.findOne({ _id: feed }, { projection: { horizon: 1 } });
372
+ return typeof row?.horizon === "number" ? row.horizon : 0;
373
+ };
374
+ /** The horizon a reader is held to: the highest one among the `(tenantId, scope)` rows it can see. */
375
+ const horizonFor = async (options = {}) => {
376
+ if (!horizons || options.tenantId === void 0 && !options.scopes) return horizonSeq();
377
+ const row = await horizons.collection.findOne(viewOf(options), {
378
+ sort: { horizon: -1 },
379
+ projection: { horizon: 1 }
380
+ });
381
+ return typeof row?.horizon === "number" ? row.horizon : 0;
382
+ };
383
+ /**
384
+ * One document's identity: tenants sharing a docId in one scope are different documents. A missing
385
+ * tenantId groups (and, as `null`, matches) as untenanted.
386
+ */
387
+ const docKey = {
388
+ tenantId: { $ifNull: ["$tenantId", null] },
389
+ scope: "$scope",
390
+ docId: "$docId"
391
+ };
392
+ const docOf = (id) => ({
393
+ tenantId: id.tenantId ?? null,
394
+ scope: id.scope,
395
+ docId: id.docId
396
+ });
240
397
  const toEntry = (row) => ({
241
398
  scope: row.scope,
242
399
  docId: row.docId,
@@ -254,12 +411,12 @@ function createChangeLogStore(models) {
254
411
  await this.append(entry, options);
255
412
  return;
256
413
  }
257
- await pending.create([stagedRow(entry, /* @__PURE__ */ new Date())], sessionOf(options));
414
+ await pending.create([stagedRow(entry, now())], sessionOf(options));
258
415
  },
259
416
  async append(entry, options) {
260
417
  const session = sessionOf(options);
261
418
  if (deferred && pending) {
262
- const at = /* @__PURE__ */ new Date();
419
+ const at = now();
263
420
  const [staged] = await pending.create([stagedRow(entry, at)], session);
264
421
  if (options?.session) return {
265
422
  ...entry,
@@ -267,7 +424,8 @@ function createChangeLogStore(models) {
267
424
  cursor: ""
268
425
  };
269
426
  const stagedId = String(staged?._id);
270
- for (let attempt = 0; attempt < 50; attempt++) {
427
+ const deadline = Date.now() + Math.max(1e3, leaseMs + 1e3);
428
+ for (let attempt = 0; attempt < 50 || Date.now() < deadline; attempt++) {
271
429
  await sequencePending();
272
430
  const at2 = await entries.findOne({ pendingId: stagedId }).select({ seq: 1 }).lean().exec();
273
431
  if (typeof at2?.seq === "number") return {
@@ -285,7 +443,7 @@ function createChangeLogStore(models) {
285
443
  lean: true,
286
444
  ...session
287
445
  }).exec()).seq;
288
- const at = /* @__PURE__ */ new Date();
446
+ const at = now();
289
447
  await entries.create([{
290
448
  seq,
291
449
  scope: entry.scope,
@@ -305,7 +463,10 @@ function createChangeLogStore(models) {
305
463
  async since(cursor, options = {}) {
306
464
  if (deferred) await sequencePending();
307
465
  const limit = options.limit ?? DEFAULT_LIMIT;
308
- const rows = await entries.find(filterOf(fromCursor(cursor), options)).sort({ seq: 1 }).limit(limit + 1).lean().exec();
466
+ const after = fromCursor(cursor);
467
+ const rows = await entries.find(filterOf(after, options)).sort({ seq: 1 }).limit(limit + 1).lean().exec();
468
+ const horizon = await horizonFor(options);
469
+ if (horizon > 0 && after < horizon) throw new CursorExpiredError(cursor, toCursor(horizon));
309
470
  const page = rows.slice(0, limit).map(toEntry);
310
471
  const last = page[page.length - 1];
311
472
  return {
@@ -314,19 +475,37 @@ function createChangeLogStore(models) {
314
475
  hasMore: rows.length > limit
315
476
  };
316
477
  },
478
+ async latestOf(scope, docId, options = {}) {
479
+ const filter = {
480
+ scope,
481
+ docId,
482
+ tenantId: options.tenantId ?? null
483
+ };
484
+ const session = options.session ? { session: options.session } : {};
485
+ if (deferred && pending) {
486
+ const staged = await pending.findOne(filter, null, session).sort({ _id: -1 }).lean().exec();
487
+ if (staged) return {
488
+ ...toEntry({
489
+ ...staged,
490
+ seq: 0
491
+ }),
492
+ cursor: ""
493
+ };
494
+ }
495
+ const row = await entries.findOne(filter, null, session).sort({ seq: -1 }).lean().exec();
496
+ return row ? toEntry(row) : null;
497
+ },
317
498
  async latestCursor(options = {}) {
318
499
  if (deferred) await sequencePending();
319
500
  const row = await entries.findOne(filterOf(0, options)).sort({ seq: -1 }).select({ seq: 1 }).lean().exec();
320
- return row?.seq ? toCursor(row.seq) : "";
501
+ const seq = Math.max(row?.seq ?? 0, await horizonFor(options));
502
+ return seq > 0 ? toCursor(seq) : "";
321
503
  },
322
504
  async compactSuperseded(options = {}) {
323
505
  const batchSize = options.batchSize ?? 1e3;
324
506
  const groups = entries.aggregate([
325
507
  { $group: {
326
- _id: {
327
- scope: "$scope",
328
- docId: "$docId"
329
- },
508
+ _id: docKey,
330
509
  latest: { $max: "$seq" },
331
510
  n: { $sum: 1 }
332
511
  } },
@@ -342,14 +521,82 @@ function createChangeLogStore(models) {
342
521
  };
343
522
  for await (const g of groups) {
344
523
  ops.push({ deleteMany: { filter: {
345
- scope: g._id.scope,
346
- docId: g._id.docId,
524
+ ...docOf(g._id),
347
525
  seq: { $lt: g.latest }
348
526
  } } });
349
527
  if (ops.length >= batchSize) await flush();
350
528
  }
351
529
  await flush();
352
530
  return { removed };
531
+ },
532
+ async horizon(options) {
533
+ const seq = await horizonFor(options);
534
+ return seq > 0 ? toCursor(seq) : "";
535
+ },
536
+ async prune(before, options = {}) {
537
+ if (!(before instanceof Date) || Number.isNaN(before.getTime())) throw new Error("[mongokit:sync] prune(before) needs a valid Date");
538
+ const batchSize = options.batchSize ?? 1e3;
539
+ if (!Number.isInteger(batchSize) || batchSize < 1) throw new Error("[mongokit:sync] prune batchSize must be a positive integer");
540
+ if (deferred) await sequencePending();
541
+ const old = { $lt: before };
542
+ const groups = entries.aggregate([{ $match: { at: old } }, { $group: {
543
+ _id: docKey,
544
+ oldMax: { $max: "$seq" },
545
+ n: { $sum: 1 }
546
+ } }]).allowDiskUse(true).cursor({ batchSize });
547
+ const topSeq = async (filter) => {
548
+ return (await entries.findOne(filter).sort({ seq: -1 }).select({ seq: 1 }).lean().exec())?.seq ?? 0;
549
+ };
550
+ let ops = [];
551
+ let batchMax = 0;
552
+ /** Highest seq this batch deletes per `(tenantId, scope)` — the rows a filtered reader is held to. */
553
+ let viewMax = /* @__PURE__ */ new Map();
554
+ const flush = async () => {
555
+ if (ops.length === 0) return;
556
+ if (horizons) await horizons.collection.bulkWrite([...viewMax.values()].map(({ tenantId, scope, seq }) => ({ updateOne: {
557
+ filter: {
558
+ tenantId,
559
+ scope
560
+ },
561
+ update: { $max: { horizon: seq } },
562
+ upsert: true
563
+ } })), { ordered: false });
564
+ await counter.collection.updateOne({ _id: feed }, { $max: { horizon: batchMax } }, { upsert: true });
565
+ await entries.bulkWrite(ops, { ordered: false });
566
+ ops = [];
567
+ batchMax = 0;
568
+ viewMax = /* @__PURE__ */ new Map();
569
+ };
570
+ for await (const g of groups) {
571
+ const doc = docOf(g._id);
572
+ const latest = await topSeq(doc);
573
+ let deletedMax;
574
+ if (latest > g.oldMax) deletedMax = g.oldMax;
575
+ else if (g.n > 1) deletedMax = await topSeq({
576
+ ...doc,
577
+ at: old,
578
+ seq: { $lt: latest }
579
+ });
580
+ else continue;
581
+ if (deletedMax <= 0) continue;
582
+ ops.push({ deleteMany: { filter: {
583
+ ...doc,
584
+ at: old,
585
+ seq: { $lte: deletedMax }
586
+ } } });
587
+ batchMax = Math.max(batchMax, deletedMax);
588
+ const key = JSON.stringify([doc.tenantId, doc.scope]);
589
+ const seen = viewMax.get(key);
590
+ if (!seen || seen.seq < deletedMax) viewMax.set(key, {
591
+ tenantId: doc.tenantId,
592
+ scope: doc.scope,
593
+ seq: deletedMax
594
+ });
595
+ if (ops.length >= batchSize) await flush();
596
+ }
597
+ await flush();
598
+ const horizon = await horizonSeq();
599
+ return horizon > 0 ? toCursor(horizon) : "";
353
600
  }
354
601
  };
355
602
  }
@@ -542,5 +789,4 @@ function createCommandStreamStore(connection, models, changes) {
542
789
  }
543
790
  };
544
791
  }
545
- //#endregion
546
- export { createChangeLogModels, createChangeLogStore, createCommandStreamModels, createCommandStreamStore };
792
+ export { DEFAULT_SEQUENCER_LEASE_MS, createChangeLogModels, createChangeLogStore, createCommandStreamModels, createCommandStreamStore };
@@ -1,15 +1,14 @@
1
1
  import { Schema } from "mongoose";
2
2
  import { ResolvedTenantConfig } from "@classytic/repo-core/tenant";
3
- //#region src/tenant/index.d.ts
4
3
  /** Which read an index serves. Default `'tenant'`. See the module docblock. */
5
- type IndexScope = 'tenant' | 'global';
4
+ export type IndexScope = 'tenant' | 'global';
6
5
  /** Key directions and types a declaration may use. Mirrors what `schema.index()` accepts. */
7
- type IndexKeyValue = 1 | -1 | 'text' | '2dsphere' | '2d' | 'hashed';
6
+ export type IndexKeyValue = 1 | -1 | 'text' | '2dsphere' | '2d' | 'hashed';
8
7
  /**
9
8
  * Index options passed through to `schema.index()`. `name` is the only one this module
10
9
  * reads (to apply and to explain exemptions); everything else is Mongoose's.
11
10
  */
12
- interface IndexDeclarationOptions {
11
+ export interface IndexDeclarationOptions {
13
12
  name?: string | undefined;
14
13
  unique?: boolean | undefined;
15
14
  sparse?: boolean | undefined;
@@ -25,13 +24,13 @@ interface IndexDeclarationOptions {
25
24
  * makes `scope` mean something: a `'tenant'` declaration is prefixed exactly like the
26
25
  * kernel's own indexes, a `'global'` one is kept as written.
27
26
  */
28
- interface IndexDeclaration {
27
+ export interface IndexDeclaration {
29
28
  readonly fields: Readonly<Record<string, IndexKeyValue>>;
30
29
  readonly options?: IndexDeclarationOptions | undefined;
31
30
  /** @default 'tenant' */
32
31
  readonly scope?: IndexScope | undefined;
33
32
  }
34
- interface InjectTenantFieldOptions {
33
+ export interface InjectTenantFieldOptions {
35
34
  /**
36
35
  * Index NAMES (the `name` option of `schema.index()`) already on the schema whose key
37
36
  * spec must NOT get the tenant prefix. The escape hatch for a kernel's OWN global
@@ -53,7 +52,7 @@ interface InjectTenantFieldOptions {
53
52
  * so a bad host config fails at DESCRIBE time, with the fix in the message, rather than
54
53
  * building a wrong index on the first bind.
55
54
  */
56
- declare function assertIndexDeclaration(declaration: IndexDeclaration, tenant: Pick<ResolvedTenantConfig, 'tenantField'>): void;
55
+ export declare function assertIndexDeclaration(declaration: IndexDeclaration, tenant: Pick<ResolvedTenantConfig, 'tenantField'>): void;
57
56
  /**
58
57
  * Add `declarations` to `schema` honouring each one's scope.
59
58
  *
@@ -65,7 +64,7 @@ declare function assertIndexDeclaration(declaration: IndexDeclaration, tenant: P
65
64
  * Exported for kernels whose optional models inject the tenant field on their own path and
66
65
  * only need the host's extra declarations applied afterwards.
67
66
  */
68
- declare function declareIndexes(schema: Schema, tenant: ResolvedTenantConfig, declarations: readonly IndexDeclaration[] | undefined): void;
67
+ export declare function declareIndexes(schema: Schema, tenant: ResolvedTenantConfig, declarations: readonly IndexDeclaration[] | undefined): void;
69
68
  /**
70
69
  * Inject the tenant field into a Mongoose schema and make its indexes tenant-correct.
71
70
  *
@@ -84,6 +83,4 @@ declare function declareIndexes(schema: Schema, tenant: ResolvedTenantConfig, de
84
83
  * insert (PACKAGE_RULES P11.1; the fleet index audit found this pattern behind most of
85
84
  * 86 redundant indexes across 615).
86
85
  */
87
- declare function injectTenantField(schema: Schema, tenant: ResolvedTenantConfig, options?: InjectTenantFieldOptions): void;
88
- //#endregion
89
- export { IndexDeclaration, IndexDeclarationOptions, IndexKeyValue, IndexScope, InjectTenantFieldOptions, assertIndexDeclaration, declareIndexes, injectTenantField };
86
+ export declare function injectTenantField(schema: Schema, tenant: ResolvedTenantConfig, options?: InjectTenantFieldOptions): void;
@@ -1,5 +1,5 @@
1
1
  import mongoose from "mongoose";
2
- //#region src/tenant/index.ts
2
+ // src/tenant/index.ts
3
3
  /** Mongoose keeps declared indexes on `schema._indexes`; `schema.indexes()` is a copy. */
4
4
  function indexEntries(schema) {
5
5
  return schema._indexes ?? [];
@@ -92,5 +92,4 @@ function injectTenantField(schema, tenant, options = {}) {
92
92
  declareIndexes(schema, tenant, options.indexes);
93
93
  if (!indexEntries(schema).some(([fields]) => Object.keys(fields)[0] === tenant.tenantField)) schema.index({ [tenant.tenantField]: 1 });
94
94
  }
95
- //#endregion
96
95
  export { assertIndexDeclaration, declareIndexes, injectTenantField };