@classytic/mongokit 3.41.0 → 3.42.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.
@@ -7,6 +7,8 @@ const PLUGIN_NAME = "changeLog";
7
7
  const CAPTURED = Symbol.for("mongokit.changeLog.capture");
8
8
  /** Where `before:updateMany` / `before:deleteMany` leave the ids they matched for the after hook. */
9
9
  const MATCHED_IDS = Symbol("changeLog.matchedIds");
10
+ /** Ids per `$in` when a many-document write is read back. */
11
+ const READ_BACK_CHUNK = 5e3;
10
12
  function isSkipped(context) {
11
13
  const list = context.skipPlugins;
12
14
  return Array.isArray(list) && list.includes(PLUGIN_NAME);
@@ -29,29 +31,34 @@ function changeLogPlugin(options) {
29
31
  return raw === void 0 || raw === null ? void 0 : String(raw);
30
32
  };
31
33
  const appendOptions = (context) => context.session !== void 0 ? { session: context.session } : void 0;
34
+ /** `stage` when the store offers it (no cursor wait under deferred sequencing); the contract's `append` otherwise. */
35
+ const recordChange = (entry, context) => {
36
+ const staging = store.stage;
37
+ return staging ? staging.call(store, entry, appendOptions(context)) : store.append(entry, appendOptions(context));
38
+ };
32
39
  const upsert = async (doc, context) => {
33
40
  const record = toRecord(doc);
34
41
  if (!record || record._id === void 0) return;
35
42
  const tenantId = tenantOf(record, context);
36
- await store.append({
43
+ await recordChange({
37
44
  scope,
38
45
  docId: String(record._id),
39
46
  op: "upsert",
40
47
  version: versionOf(record),
41
48
  doc: project ? project(record) : record,
42
49
  ...tenantId !== void 0 ? { tenantId } : {}
43
- }, appendOptions(context));
50
+ }, context);
44
51
  };
45
52
  const tombstone = async (docId, context) => {
46
53
  if (docId === void 0 || docId === null) return;
47
54
  const tenantId = tenantOf(null, context);
48
- await store.append({
55
+ await recordChange({
49
56
  scope,
50
57
  docId: String(docId),
51
58
  op: "delete",
52
59
  version: 0,
53
60
  ...tenantId !== void 0 ? { tenantId } : {}
54
- }, appendOptions(context));
61
+ }, context);
55
62
  };
56
63
  return {
57
64
  name: PLUGIN_NAME,
@@ -72,6 +79,22 @@ function changeLogPlugin(options) {
72
79
  if (context.session !== void 0) query.session(context.session);
73
80
  return query.exec();
74
81
  };
82
+ /** A write's result is the feed row only when it is the whole post-image, as a plain object. */
83
+ const isWholePostImage = (result, context) => {
84
+ if (!result || typeof result !== "object") return false;
85
+ if (typeof result.toObject === "function") return false;
86
+ const c = context;
87
+ return !c.select && !c.projection && !c.populate && c.returnDocument !== "before";
88
+ };
89
+ /** `upsert` from the result the write returned; a narrowed result is re-read by id (one read, that verb only). */
90
+ const captureResult = async (result, context) => {
91
+ const id = toRecord(result)?._id;
92
+ if (id === void 0) return;
93
+ if (isWholePostImage(result, context)) return upsert(result, context);
94
+ const doc = await reread(id, context);
95
+ if (doc) await upsert(doc, context);
96
+ else await tombstone(id, context);
97
+ };
75
98
  const matchingIds = async (context, filter) => {
76
99
  const query = repo.Model.find(filter ?? {}).select({ _id: 1 }).lean();
77
100
  if (context.session !== void 0) query.session(context.session);
@@ -85,29 +108,37 @@ function changeLogPlugin(options) {
85
108
  for (const doc of result) await upsert(doc, context);
86
109
  });
87
110
  repo.on("after:update", async ({ context, result }) => {
88
- if (!isSkipped(context) && result) await upsert(result, context);
111
+ if (isSkipped(context) || !result) return;
112
+ const c = context;
113
+ if (c.select || c.projection || c.populate) await captureResult(result, context);
114
+ else await upsert(result, context);
115
+ });
116
+ repo.on("after:getOrCreate", async ({ context, result }) => {
117
+ const doc = result?.doc;
118
+ if (!isSkipped(context) && doc) await upsert(doc, context);
89
119
  });
90
120
  for (const op of [
91
121
  "claim",
92
122
  "claimVersion",
93
123
  "findOneAndUpdate",
94
- "getOrCreate",
95
124
  "restore"
96
125
  ]) repo.on(`after:${op}`, async ({ context, result }) => {
97
- if (isSkipped(context)) return;
98
- const id = toRecord(result)?._id;
99
- if (id === void 0) return;
100
- const doc = await reread(id, context);
101
- if (doc) await upsert(doc, context);
102
- else await tombstone(id, context);
126
+ if (!isSkipped(context)) await captureResult(result, context);
103
127
  });
104
128
  for (const op of ["updateMany", "deleteMany"]) repo.on(`before:${op}`, async (context) => {
105
129
  if (!isSkipped(context)) context[MATCHED_IDS] = await matchingIds(context, context.query);
106
130
  });
107
- /** Each re-read document as an upsert, or a tombstone where it is gone. */
131
+ /** Each target as an upsert, or a tombstone where it is gone — read back in batches, not one query per id. */
108
132
  const capture = async (ids, context) => {
109
- for (const id of ids) {
110
- const doc = await reread(id, context);
133
+ const wanted = ids.filter((id) => id !== void 0 && id !== null);
134
+ const found = /* @__PURE__ */ new Map();
135
+ for (let i = 0; i < wanted.length; i += READ_BACK_CHUNK) {
136
+ const query = repo.Model.find({ _id: { $in: wanted.slice(i, i + READ_BACK_CHUNK) } }).lean();
137
+ if (context.session !== void 0) query.session(context.session);
138
+ for (const doc of await query.exec()) found.set(String(doc._id), doc);
139
+ }
140
+ for (const id of wanted) {
141
+ const doc = found.get(String(id));
111
142
  if (doc) await upsert(doc, context);
112
143
  else await tombstone(id, context);
113
144
  }
@@ -1,15 +1,26 @@
1
1
  import { ClientSession, Connection, Model } from "mongoose";
2
- import { ChangeLogStore, CommandStreamStore, CommandStreamTx } from "@classytic/repo-core/sync";
2
+ import { ChangeLogAppendOptions, ChangeLogStore, CommandStreamStore, CommandStreamTx } from "@classytic/repo-core/sync";
3
3
  //#region src/sync/index.d.ts
4
4
  interface ChangeLogModelOptions {
5
5
  /** Entries collection. Default `sync_changes`; its counter lives in `<collection>_counter`. */
6
6
  collection?: string;
7
7
  /** Mongoose model name prefix. Default `SyncChange`. Must be unique per connection. */
8
8
  modelName?: string;
9
+ /**
10
+ * When an entry gets its cursor. `'in-transaction'` (default): from the counter, inside the writer's transaction —
11
+ * every writer serialises on that one document and overlapping transactions abort and retry. `'deferred'`: the
12
+ * writer stages the entry (`<collection>_pending`, no counter); a single leased sequencer numbers COMMITTED
13
+ * entries before every read. A cursor is only ever given to a committed entry and is above every cursor given
14
+ * before, so no reader can checkpoint past an entry that appears later.
15
+ */
16
+ sequencing?: 'in-transaction' | 'deferred';
9
17
  }
10
18
  interface ChangeLogModels {
11
19
  entries: Model<Record<string, unknown>>;
12
20
  counter: Model<Record<string, unknown>>;
21
+ /** Staged, not-yet-numbered entries — present only under `sequencing: 'deferred'`. */
22
+ pending?: Model<Record<string, unknown>>;
23
+ sequencing: 'in-transaction' | 'deferred';
13
24
  /** The counter document's id (one feed per entries collection). */
14
25
  feed: string;
15
26
  }
@@ -26,6 +37,16 @@ interface MongoChangeLogStore extends ChangeLogStore {
26
37
  }): Promise<{
27
38
  removed: number;
28
39
  }>;
40
+ /**
41
+ * Number every staged entry (deferred sequencing; a no-op otherwise). Reads call it first; a scheduler may too.
42
+ * Returns how many it numbered, or `null` when another sequencer holds the lease.
43
+ */
44
+ sequencePending(): Promise<number | null>;
45
+ /**
46
+ * Record a change WITHOUT waiting for its cursor (deferred sequencing): the next read numbers it. For writers that
47
+ * never use the cursor (`changeLogPlugin`). Under in-transaction sequencing it is `append`.
48
+ */
49
+ stage(entry: Parameters<ChangeLogStore['append']>[0], options?: ChangeLogAppendOptions): Promise<void>;
29
50
  }
30
51
  declare function createChangeLogStore(models: ChangeLogModels): MongoChangeLogStore;
31
52
  interface CommandStreamModelOptions {
@@ -1,77 +1,133 @@
1
1
  import { o as withTransaction } from "../transaction-DmiB4k2P.mjs";
2
- import { Schema } from "mongoose";
2
+ import { Schema, Types } from "mongoose";
3
3
  //#region src/sync/index.ts
4
4
  function createChangeLogModels(connection, options = {}) {
5
- const { collection = "sync_changes", modelName = "SyncChange" } = options;
5
+ const { collection = "sync_changes", modelName = "SyncChange", sequencing = "in-transaction" } = options;
6
6
  const entriesName = modelName;
7
7
  const counterName = `${modelName}Counter`;
8
- return {
9
- entries: connection.models[entriesName] ?? connection.model(entriesName, (() => {
10
- const schema = new Schema({
11
- seq: {
12
- type: Number,
13
- required: true
14
- },
15
- scope: {
16
- type: String,
17
- required: true
18
- },
19
- docId: {
20
- type: String,
21
- required: true
22
- },
23
- op: {
24
- type: String,
25
- enum: ["upsert", "delete"],
26
- required: true
27
- },
28
- version: {
29
- type: Number,
30
- required: true
31
- },
32
- doc: { type: Schema.Types.Mixed },
33
- tenantId: { type: String },
34
- at: {
35
- type: Date,
36
- required: true
37
- }
38
- }, {
39
- collection,
40
- versionKey: false,
41
- minimize: false
42
- });
43
- schema.index({ seq: 1 }, {
44
- unique: true,
45
- name: "feed_order"
46
- });
47
- schema.index({
48
- tenantId: 1,
49
- scope: 1,
50
- seq: 1
51
- }, { name: "tenant_scope_feed" });
52
- schema.index({
53
- scope: 1,
54
- docId: 1,
55
- seq: 1
56
- }, { name: "doc_versions" });
57
- return schema;
58
- })()),
59
- counter: connection.models[counterName] ?? connection.model(counterName, new Schema({
60
- _id: {
8
+ const entries = connection.models[entriesName] ?? connection.model(entriesName, (() => {
9
+ const schema = new Schema({
10
+ seq: {
11
+ type: Number,
12
+ required: true
13
+ },
14
+ scope: {
61
15
  type: String,
62
16
  required: true
63
17
  },
64
- seq: {
18
+ docId: {
19
+ type: String,
20
+ required: true
21
+ },
22
+ op: {
23
+ type: String,
24
+ enum: ["upsert", "delete"],
25
+ required: true
26
+ },
27
+ version: {
65
28
  type: Number,
66
29
  required: true
67
- }
30
+ },
31
+ doc: { type: Schema.Types.Mixed },
32
+ tenantId: { type: String },
33
+ at: {
34
+ type: Date,
35
+ required: true
36
+ },
37
+ /** The staged row this entry was numbered from (deferred sequencing) — the sequencer's idempotency key. */
38
+ pendingId: { type: String }
68
39
  }, {
69
- collection: `${collection}_counter`,
70
- versionKey: false
71
- })),
40
+ collection,
41
+ versionKey: false,
42
+ minimize: false
43
+ });
44
+ schema.index({ seq: 1 }, {
45
+ unique: true,
46
+ name: "feed_order"
47
+ });
48
+ schema.index({ pendingId: 1 }, {
49
+ unique: true,
50
+ name: "sequenced_from",
51
+ partialFilterExpression: { pendingId: { $gte: "" } }
52
+ });
53
+ schema.index({
54
+ tenantId: 1,
55
+ scope: 1,
56
+ seq: 1
57
+ }, { name: "tenant_scope_feed" });
58
+ schema.index({
59
+ scope: 1,
60
+ docId: 1,
61
+ seq: 1
62
+ }, { name: "doc_versions" });
63
+ return schema;
64
+ })());
65
+ const counter = connection.models[counterName] ?? connection.model(counterName, new Schema({
66
+ _id: {
67
+ type: String,
68
+ required: true
69
+ },
70
+ seq: {
71
+ type: Number,
72
+ required: true
73
+ },
74
+ /** The deferred sequencer's lease. */
75
+ lockUntil: { type: Date },
76
+ lockBy: { type: String }
77
+ }, {
78
+ collection: `${collection}_counter`,
79
+ versionKey: false
80
+ }));
81
+ const pendingName = `${modelName}Pending`;
82
+ const pending = sequencing === "deferred" ? connection.models[pendingName] ?? connection.model(pendingName, new Schema({
83
+ scope: {
84
+ type: String,
85
+ required: true
86
+ },
87
+ docId: {
88
+ type: String,
89
+ required: true
90
+ },
91
+ op: {
92
+ type: String,
93
+ enum: ["upsert", "delete"],
94
+ required: true
95
+ },
96
+ version: {
97
+ type: Number,
98
+ required: true
99
+ },
100
+ doc: { type: Schema.Types.Mixed },
101
+ tenantId: { type: String },
102
+ at: {
103
+ type: Date,
104
+ required: true
105
+ }
106
+ }, {
107
+ collection: `${collection}_pending`,
108
+ versionKey: false,
109
+ minimize: false
110
+ })) : void 0;
111
+ return {
112
+ entries,
113
+ counter,
114
+ ...pending ? { pending } : {},
115
+ sequencing,
72
116
  feed: collection
73
117
  };
74
118
  }
119
+ /** A change as staged under deferred sequencing — the feed row without its `seq`. */
120
+ function stagedRow(entry, at) {
121
+ return {
122
+ scope: entry.scope,
123
+ docId: entry.docId,
124
+ op: entry.op,
125
+ version: entry.version,
126
+ ...entry.op === "upsert" && entry.doc !== void 0 ? { doc: entry.doc } : {},
127
+ ...entry.tenantId !== void 0 ? { tenantId: entry.tenantId } : {},
128
+ at
129
+ };
130
+ }
75
131
  /** Fixed-width, so the opaque cursor also sorts as text. */
76
132
  const toCursor = (seq) => String(seq).padStart(16, "0");
77
133
  const fromCursor = (cursor) => {
@@ -81,8 +137,96 @@ const fromCursor = (cursor) => {
81
137
  return seq;
82
138
  };
83
139
  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
+ const SEQUENCER_BATCH = 500;
84
143
  function createChangeLogStore(models) {
85
- const { entries, counter, feed } = models;
144
+ const { entries, counter, feed, pending } = models;
145
+ const deferred = models.sequencing === "deferred" && pending !== void 0;
146
+ /**
147
+ * 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.
150
+ */
151
+ const sequencePending = async () => {
152
+ if (!deferred || !pending) return 0;
153
+ if (!await pending.exists({}).exec()) return 0;
154
+ 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();
170
+ let lease = await takeLease();
171
+ if (!lease) {
172
+ if (await counter.exists({ _id: feed }).exec()) return null;
173
+ await counter.create([{
174
+ _id: feed,
175
+ seq: 0
176
+ }]).catch((err) => {
177
+ if (err.code !== 11e3) throw err;
178
+ });
179
+ lease = await takeLease();
180
+ if (!lease) return null;
181
+ }
182
+ let seq = lease.seq ?? 0;
183
+ let numbered = 0;
184
+ try {
185
+ for (;;) {
186
+ const rows = await pending.find({}).sort({ _id: 1 }).limit(SEQUENCER_BATCH).lean().exec();
187
+ 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
+ try {
197
+ await entries.insertMany(build(seq, rows), { ordered: true });
198
+ seq += rows.length;
199
+ numbered += rows.length;
200
+ } catch (err) {
201
+ if (err.code !== 11e3) throw err;
202
+ 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
+ const fresh = rows.filter((r) => !placed.has(String(r._id)));
204
+ const top = await entries.findOne({}).sort({ seq: -1 }).select({ seq: 1 }).lean().exec();
205
+ 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;
210
+ }
211
+ }
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
+ if (rows.length < SEQUENCER_BATCH) break;
218
+ }
219
+ } finally {
220
+ await counter.updateOne({
221
+ _id: feed,
222
+ lockBy: token
223
+ }, { $set: {
224
+ lockUntil: null,
225
+ lockBy: null
226
+ } }).exec();
227
+ }
228
+ return numbered;
229
+ };
86
230
  const sessionOf = (options) => options?.session ? { session: options.session } : {};
87
231
  const filterOf = (after, options = {}) => {
88
232
  const { tenantId, scopes, sharedScopes } = options;
@@ -104,8 +248,37 @@ function createChangeLogStore(models) {
104
248
  cursor: toCursor(row.seq)
105
249
  });
106
250
  return {
251
+ sequencePending,
252
+ async stage(entry, options) {
253
+ if (!deferred || !pending) {
254
+ await this.append(entry, options);
255
+ return;
256
+ }
257
+ await pending.create([stagedRow(entry, /* @__PURE__ */ new Date())], sessionOf(options));
258
+ },
107
259
  async append(entry, options) {
108
260
  const session = sessionOf(options);
261
+ if (deferred && pending) {
262
+ const at = /* @__PURE__ */ new Date();
263
+ const [staged] = await pending.create([stagedRow(entry, at)], session);
264
+ if (options?.session) return {
265
+ ...entry,
266
+ at,
267
+ cursor: ""
268
+ };
269
+ const stagedId = String(staged?._id);
270
+ for (let attempt = 0; attempt < 50; attempt++) {
271
+ await sequencePending();
272
+ const at2 = await entries.findOne({ pendingId: stagedId }).select({ seq: 1 }).lean().exec();
273
+ if (typeof at2?.seq === "number") return {
274
+ ...entry,
275
+ at,
276
+ cursor: toCursor(at2.seq)
277
+ };
278
+ await new Promise((r) => setTimeout(r, 20));
279
+ }
280
+ throw new Error(`[mongokit:sync] staged entry ${stagedId} was not sequenced — a sequencer lease is stuck`);
281
+ }
109
282
  const seq = (await counter.findOneAndUpdate({ _id: feed }, { $inc: { seq: 1 } }, {
110
283
  upsert: true,
111
284
  returnDocument: "after",
@@ -130,6 +303,7 @@ function createChangeLogStore(models) {
130
303
  };
131
304
  },
132
305
  async since(cursor, options = {}) {
306
+ if (deferred) await sequencePending();
133
307
  const limit = options.limit ?? DEFAULT_LIMIT;
134
308
  const rows = await entries.find(filterOf(fromCursor(cursor), options)).sort({ seq: 1 }).limit(limit + 1).lean().exec();
135
309
  const page = rows.slice(0, limit).map(toEntry);
@@ -141,6 +315,7 @@ function createChangeLogStore(models) {
141
315
  };
142
316
  },
143
317
  async latestCursor(options = {}) {
318
+ if (deferred) await sequencePending();
144
319
  const row = await entries.findOne(filterOf(0, options)).sort({ seq: -1 }).select({ seq: 1 }).lean().exec();
145
320
  return row?.seq ? toCursor(row.seq) : "";
146
321
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@classytic/mongokit",
3
- "version": "3.41.0",
3
+ "version": "3.42.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,
@@ -199,7 +199,7 @@
199
199
  "@better-auth/mongo-adapter": "^1.6.29",
200
200
  "@biomejs/biome": "^2.5.8",
201
201
  "@classytic/dev-tools": "^0.2.0",
202
- "@classytic/repo-core": "^0.31.0",
202
+ "@classytic/repo-core": "^0.31.1",
203
203
  "@types/node": "^26.2.0",
204
204
  "@vitest/coverage-v8": "^4.1.10",
205
205
  "ajv": "^8.20.0",