@classytic/mongokit 3.39.0 → 3.40.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 (35) hide show
  1. package/dist/{PaginationEngine-DQ7beTCR.mjs → PaginationEngine-Dvl8jcRY.mjs} +33 -17
  2. package/dist/{Repository-D7fDpyFx.mjs → Repository-BMgPSkqZ.mjs} +279 -625
  3. package/dist/{Repository-IxI983_m.d.mts → Repository-CR8Y9g2E.d.mts} +57 -32
  4. package/dist/actions/index.mjs +1 -1
  5. package/dist/ai/index.d.mts +1 -1
  6. package/dist/better-auth/index.mjs +1 -1
  7. package/dist/{create-repository-DOe9E_5F.mjs → create-repository-BzBUMvd1.mjs} +2 -2
  8. package/dist/{create-repository-CRXjDa3N.d.mts → create-repository-CAle1MSU.d.mts} +4 -4
  9. package/dist/idempotency/index.d.mts +23 -0
  10. package/dist/idempotency/index.mjs +134 -0
  11. package/dist/index.d.mts +58 -32
  12. package/dist/index.mjs +285 -675
  13. package/dist/indexes/verify-indexes.d.mts +14 -1
  14. package/dist/indexes/verify-indexes.mjs +54 -7
  15. package/dist/{multi-tenant.plugin-B-tzNeYL.d.mts → multi-tenant.plugin-CBgCo9im.d.mts} +18 -2
  16. package/dist/outbox/index.d.mts +1 -1
  17. package/dist/outbox/index.mjs +6 -4
  18. package/dist/pagination/PaginationEngine.mjs +1 -1
  19. package/dist/{plugin-options-BuvtOSZf.d.mts → plugin-options-Zk46lD6F.d.mts} +15 -1
  20. package/dist/plugins/index.d.mts +8 -3
  21. package/dist/plugins/index.mjs +98 -25
  22. package/dist/{repository-ZbLtL7Nk.d.mts → repository-odl03582.d.mts} +20 -2
  23. package/dist/sync/index.d.mts +40 -0
  24. package/dist/sync/index.mjs +334 -0
  25. package/dist/tenant/index.mjs +2 -0
  26. package/dist/testkit/index.d.mts +2 -2
  27. package/dist/testkit/index.mjs +1 -1
  28. package/dist/{timestamp.plugin-B5eiaItQ.mjs → timestamp.plugin-DF0gJB6p.mjs} +86 -2
  29. package/dist/{to-plain-B8ufk3Qz.d.mts → to-plain-k7SjNlSS.d.mts} +2 -2
  30. package/dist/transaction-B5L9glO0.mjs +533 -0
  31. package/dist/{update-c_01A0if.mjs → update-DG0fCwRJ.mjs} +168 -11
  32. package/dist/utils/index.d.mts +1 -1
  33. package/dist/{validation-chain.plugin-Bju1D1Sn.mjs → validation-chain.plugin-BLY2gLEk.mjs} +192 -279
  34. package/dist/{validation-chain.plugin-DvrLJIbJ.d.mts → validation-chain.plugin-DYp9E7K2.d.mts} +25 -3
  35. package/package.json +13 -4
@@ -0,0 +1,334 @@
1
+ import { i as withTransaction } from "../transaction-B5L9glO0.mjs";
2
+ import { Schema } from "mongoose";
3
+ //#region src/sync/index.ts
4
+ function createChangeLogModels(connection, options = {}) {
5
+ const { collection = "sync_changes", modelName = "SyncChange" } = options;
6
+ const entriesName = modelName;
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
+ return schema;
53
+ })()),
54
+ counter: connection.models[counterName] ?? connection.model(counterName, new Schema({
55
+ _id: {
56
+ type: String,
57
+ required: true
58
+ },
59
+ seq: {
60
+ type: Number,
61
+ required: true
62
+ }
63
+ }, {
64
+ collection: `${collection}_counter`,
65
+ versionKey: false
66
+ })),
67
+ feed: collection
68
+ };
69
+ }
70
+ /** Fixed-width, so the opaque cursor also sorts as text. */
71
+ const toCursor = (seq) => String(seq).padStart(16, "0");
72
+ const fromCursor = (cursor) => {
73
+ if (cursor === "") return 0;
74
+ const seq = Number(cursor);
75
+ if (!Number.isSafeInteger(seq) || seq < 0) throw new Error(`[mongokit:sync] "${cursor}" is not a cursor from this feed`);
76
+ return seq;
77
+ };
78
+ const DEFAULT_LIMIT = 500;
79
+ function createChangeLogStore(models) {
80
+ const { entries, counter, feed } = models;
81
+ const sessionOf = (options) => options?.session ? { session: options.session } : {};
82
+ const filterOf = (after, options = {}) => {
83
+ const { tenantId, scopes, sharedScopes } = options;
84
+ const tenant = tenantId === void 0 ? {} : sharedScopes?.length ? { $or: [{ tenantId }, { scope: { $in: [...sharedScopes] } }] } : { tenantId };
85
+ return {
86
+ seq: { $gt: after },
87
+ ...tenant,
88
+ ...scopes ? { scope: { $in: [...scopes] } } : {}
89
+ };
90
+ };
91
+ const toEntry = (row) => ({
92
+ scope: row.scope,
93
+ docId: row.docId,
94
+ op: row.op,
95
+ version: row.version,
96
+ ...row.doc !== void 0 && row.doc !== null ? { doc: row.doc } : {},
97
+ ...typeof row.tenantId === "string" ? { tenantId: row.tenantId } : {},
98
+ at: row.at,
99
+ cursor: toCursor(row.seq)
100
+ });
101
+ return {
102
+ async append(entry, options) {
103
+ const session = sessionOf(options);
104
+ const seq = (await counter.findOneAndUpdate({ _id: feed }, { $inc: { seq: 1 } }, {
105
+ upsert: true,
106
+ returnDocument: "after",
107
+ lean: true,
108
+ ...session
109
+ }).exec()).seq;
110
+ const at = /* @__PURE__ */ new Date();
111
+ await entries.create([{
112
+ seq,
113
+ scope: entry.scope,
114
+ docId: entry.docId,
115
+ op: entry.op,
116
+ version: entry.version,
117
+ ...entry.op === "upsert" && entry.doc !== void 0 ? { doc: entry.doc } : {},
118
+ ...entry.tenantId !== void 0 ? { tenantId: entry.tenantId } : {},
119
+ at
120
+ }], session);
121
+ return {
122
+ ...entry,
123
+ at,
124
+ cursor: toCursor(seq)
125
+ };
126
+ },
127
+ async since(cursor, options = {}) {
128
+ const limit = options.limit ?? DEFAULT_LIMIT;
129
+ const rows = await entries.find(filterOf(fromCursor(cursor), options)).sort({ seq: 1 }).limit(limit + 1).lean().exec();
130
+ const page = rows.slice(0, limit).map(toEntry);
131
+ const last = page[page.length - 1];
132
+ return {
133
+ changes: page,
134
+ cursor: last ? last.cursor : cursor,
135
+ hasMore: rows.length > limit
136
+ };
137
+ },
138
+ async latestCursor(options = {}) {
139
+ const row = await entries.findOne(filterOf(0, options)).sort({ seq: -1 }).select({ seq: 1 }).lean().exec();
140
+ return row?.seq ? toCursor(row.seq) : "";
141
+ }
142
+ };
143
+ }
144
+ function createCommandStreamModels(connection, options = {}) {
145
+ const { prefix = "sync", modelName = "SyncStream" } = options;
146
+ const model = (name, schema) => connection.models[name] ?? connection.model(name, schema());
147
+ return {
148
+ streams: model(modelName, () => new Schema({
149
+ _id: {
150
+ type: String,
151
+ required: true
152
+ },
153
+ tenantId: {
154
+ type: String,
155
+ required: true
156
+ },
157
+ processed: {
158
+ type: Number,
159
+ required: true
160
+ }
161
+ }, {
162
+ collection: `${prefix}_streams`,
163
+ versionKey: false
164
+ })),
165
+ verdicts: model(`${modelName}Verdict`, () => {
166
+ const schema = new Schema({
167
+ _id: {
168
+ type: String,
169
+ required: true
170
+ },
171
+ stream: {
172
+ type: String,
173
+ required: true
174
+ },
175
+ seq: {
176
+ type: Number,
177
+ required: true
178
+ },
179
+ outcome: {
180
+ type: String,
181
+ enum: [
182
+ "applied",
183
+ "rejected",
184
+ "blocked",
185
+ "resolved"
186
+ ],
187
+ required: true
188
+ },
189
+ code: { type: String },
190
+ fingerprint: {
191
+ type: String,
192
+ required: true
193
+ },
194
+ body: { type: String },
195
+ dependsOn: {
196
+ type: [String],
197
+ default: void 0
198
+ },
199
+ resolution: {
200
+ type: {
201
+ action: String,
202
+ by: String,
203
+ at: String,
204
+ _id: false
205
+ },
206
+ default: void 0
207
+ }
208
+ }, {
209
+ collection: `${prefix}_verdicts`,
210
+ versionKey: false
211
+ });
212
+ schema.index({
213
+ stream: 1,
214
+ seq: 1
215
+ }, {
216
+ unique: true,
217
+ name: "stream_seq"
218
+ });
219
+ schema.index({
220
+ dependsOn: 1,
221
+ outcome: 1
222
+ }, {
223
+ name: "blocked_on",
224
+ partialFilterExpression: { outcome: "blocked" }
225
+ });
226
+ return schema;
227
+ }),
228
+ aliases: model(`${modelName}Alias`, () => {
229
+ const schema = new Schema({
230
+ tenantId: {
231
+ type: String,
232
+ required: true
233
+ },
234
+ localId: {
235
+ type: String,
236
+ required: true
237
+ },
238
+ serverId: {
239
+ type: String,
240
+ required: true
241
+ }
242
+ }, {
243
+ collection: `${prefix}_aliases`,
244
+ versionKey: false
245
+ });
246
+ schema.index({
247
+ tenantId: 1,
248
+ localId: 1
249
+ }, {
250
+ unique: true,
251
+ name: "tenant_local"
252
+ });
253
+ return schema;
254
+ })
255
+ };
256
+ }
257
+ /**
258
+ * The mongo {@link CommandStreamStore} (replica set). Proven by `runCommandStreamStoreConformance`.
259
+ * Concurrent transactions on one stream write-conflict on its document and `withTransaction`
260
+ * retries the loser, which then reads the winner's watermark.
261
+ */
262
+ function createCommandStreamStore(connection, models, changes) {
263
+ const { streams, verdicts, aliases } = models;
264
+ const toVerdict = ({ _id, code, ...rest }) => ({
265
+ commandId: _id,
266
+ ...rest,
267
+ ...code == null ? {} : { code }
268
+ });
269
+ return {
270
+ changes,
271
+ transaction: (work) => withTransaction(connection, (session) => work({ session })),
272
+ async stream({ session }, id) {
273
+ const row = await streams.findById(id).session(session).lean().exec();
274
+ return row ? {
275
+ tenantId: row.tenantId,
276
+ processed: row.processed
277
+ } : null;
278
+ },
279
+ async advance({ session }, id, tenantId, from, to) {
280
+ if (from === 0) {
281
+ if (await streams.exists({ _id: id }).session(session)) return false;
282
+ await streams.create([{
283
+ _id: id,
284
+ tenantId,
285
+ processed: to
286
+ }], { session });
287
+ return true;
288
+ }
289
+ return (await streams.updateOne({
290
+ _id: id,
291
+ processed: from
292
+ }, { $set: { processed: to } }, { session }).exec()).modifiedCount === 1;
293
+ },
294
+ async verdict({ session }, commandId) {
295
+ const row = await verdicts.findById(commandId).session(session).lean().exec();
296
+ return row ? toVerdict(row) : null;
297
+ },
298
+ async record({ session }, { commandId, ...rest }) {
299
+ await verdicts.replaceOne({ _id: commandId }, rest, {
300
+ upsert: true,
301
+ session
302
+ }).exec();
303
+ },
304
+ async blockedOn({ session }, commandId) {
305
+ return (await verdicts.find({
306
+ outcome: "blocked",
307
+ dependsOn: commandId
308
+ }).sort({ seq: 1 }).session(session).lean().exec()).map(toVerdict);
309
+ },
310
+ async alias({ session }, { tenantId, localId, serverId }) {
311
+ const existing = await aliases.findOne({
312
+ tenantId,
313
+ localId
314
+ }).session(session).lean().exec();
315
+ if (existing) {
316
+ if (existing.serverId !== serverId) throw new Error(`[mongokit:sync] "${localId}" already names "${existing.serverId}"`);
317
+ return;
318
+ }
319
+ await aliases.create([{
320
+ tenantId,
321
+ localId,
322
+ serverId
323
+ }], { session });
324
+ },
325
+ async aliasOf({ session }, tenantId, localId) {
326
+ return (await aliases.findOne({
327
+ tenantId,
328
+ localId
329
+ }).session(session).lean().exec())?.serverId ?? null;
330
+ }
331
+ };
332
+ }
333
+ //#endregion
334
+ export { createChangeLogModels, createChangeLogStore, createCommandStreamModels, createCommandStreamStore };
@@ -64,6 +64,8 @@ function declareIndexes(schema, tenant, declarations) {
64
64
  */
65
65
  function injectTenantField(schema, tenant, options = {}) {
66
66
  for (const declaration of options.indexes ?? []) assertIndexDeclaration(declaration, tenant);
67
+ const declaredNames = new Set(indexEntries(schema).map(([, o]) => o?.name).filter((n) => typeof n === "string"));
68
+ for (const name of options.skipIndexes ?? []) if (!declaredNames.has(name)) throw new Error(`[mongokit/tenant] skipIndexes names '${name}', but no index on this schema has that name — rename the exemption with the index, or the index silently gets the tenant prefix`);
67
69
  const isObjectId = tenant.fieldType === "objectId";
68
70
  const scoped = isScoped(tenant);
69
71
  schema.add({ [tenant.tenantField]: {
@@ -1,5 +1,5 @@
1
- import { n as Repository } from "../Repository-IxI983_m.mjs";
2
- import { n as CreateRepositoryConfig } from "../create-repository-CRXjDa3N.mjs";
1
+ import { r as Repository } from "../Repository-CR8Y9g2E.mjs";
2
+ import { n as CreateRepositoryConfig } from "../create-repository-CAle1MSU.mjs";
3
3
  import { Connection, Model, Schema } from "mongoose";
4
4
  import { MongoMemoryReplSet, MongoMemoryServer } from "mongodb-memory-server";
5
5
  //#region src/testkit/index.d.ts
@@ -1,4 +1,4 @@
1
- import { t as createRepository } from "../create-repository-DOe9E_5F.mjs";
1
+ import { t as createRepository } from "../create-repository-BzBUMvd1.mjs";
2
2
  import { createConnection } from "mongoose";
3
3
  //#region src/testkit/index.ts
4
4
  function idempotentStop(server) {
@@ -1,4 +1,4 @@
1
- import { c as ALL_OPERATIONS, t as HOOK_PRIORITY, u as OP_REGISTRY } from "./Repository-D7fDpyFx.mjs";
1
+ import { i as ALL_OPERATIONS, o as OP_REGISTRY, t as HOOK_PRIORITY } from "./Repository-BMgPSkqZ.mjs";
2
2
  import { n as warn } from "./logger-K7dPmu2U.mjs";
3
3
  import { throwIfAborted, withRetry } from "@classytic/repo-core/repository";
4
4
  import mongoose from "mongoose";
@@ -754,6 +754,33 @@ async function getNextSequence(counterKey, increment = 1, connection, session) {
754
754
  return result.seq;
755
755
  }
756
756
  /**
757
+ * Current value of a counter, or `null` when it has never been created.
758
+ * `null` and `0` differ: only a missing counter needs seeding.
759
+ */
760
+ async function readSequence(counterKey, connection) {
761
+ const doc = await getCounterModel(connection).findById(counterKey).lean();
762
+ return doc ? doc.seq : null;
763
+ }
764
+ /**
765
+ * Raise a counter to at least `floor` — never lowers it. Atomic (`$max` +
766
+ * upsert), so concurrent seeders converge on the highest floor.
767
+ *
768
+ * Use before a counter's first increment when ids were already issued under
769
+ * another key (e.g. moving from a global to a per-tenant sequence); otherwise
770
+ * the new counter restarts at 1 and re-issues taken ids.
771
+ *
772
+ * @returns The counter's value after the raise.
773
+ */
774
+ async function ensureSequenceAtLeast(counterKey, floor, connection) {
775
+ if (!Number.isSafeInteger(floor) || floor < 0) throw new Error(`ensureSequenceAtLeast: floor must be a non-negative integer, got ${floor}`);
776
+ const result = await getCounterModel(connection).findOneAndUpdate({ _id: counterKey }, { $max: { seq: floor } }, {
777
+ upsert: true,
778
+ returnDocument: "after"
779
+ });
780
+ if (!result) throw new Error(`Failed to seed counter '${counterKey}'`);
781
+ return result.seq;
782
+ }
783
+ /**
757
784
  * Generator: Simple sequential counter.
758
785
  * Produces IDs like `INV-0001`, `INV-0002`, etc.
759
786
  *
@@ -930,6 +957,62 @@ function customIdPlugin(options) {
930
957
  };
931
958
  }
932
959
  //#endregion
960
+ //#region src/utils/scope.ts
961
+ /** Tenant context keys forwarded even when no tenant plugin declared one. */
962
+ const CONVENTIONAL_TENANT_KEYS = ["organizationId", "tenantId"];
963
+ const ATTRIBUTION_KEYS = [
964
+ "session",
965
+ "user",
966
+ "userId",
967
+ "requestId"
968
+ ];
969
+ /** Where a repository records the tenant context keys its plugins read. */
970
+ const TENANT_KEYS = Symbol.for("@classytic/mongokit/tenant-context-keys");
971
+ /**
972
+ * Record that `repo` resolves its tenant from `contextKey`. Called by
973
+ * `multiTenantPlugin` at bind, so forwarding knows a custom key (`branchId`)
974
+ * without the host restating it.
975
+ */
976
+ function declareTenantContextKey(repo, contextKey) {
977
+ const holder = repo;
978
+ if (!holder[TENANT_KEYS]) Object.defineProperty(holder, TENANT_KEYS, {
979
+ value: /* @__PURE__ */ new Set(),
980
+ enumerable: false
981
+ });
982
+ holder[TENANT_KEYS]?.add(contextKey);
983
+ }
984
+ /** Every tenant context key relevant to a call spanning these repositories. */
985
+ function tenantContextKeysOf(...repos) {
986
+ const keys = new Set(CONVENTIONAL_TENANT_KEYS);
987
+ for (const repo of repos) for (const key of repo?.[TENANT_KEYS] ?? []) keys.add(key);
988
+ return [...keys];
989
+ }
990
+ /**
991
+ * The scope to hand a second repository call made on behalf of `source`
992
+ * (a hook's `RepositoryContext`, or a host's request context).
993
+ *
994
+ * Absent values are omitted, never written as `undefined`, so spreading the
995
+ * result into an options bag cannot erase a value set there.
996
+ */
997
+ function forwardScope(source, tenantKeys = CONVENTIONAL_TENANT_KEYS) {
998
+ if (!source) return {};
999
+ const out = {};
1000
+ let hasTenant = false;
1001
+ for (const key of tenantKeys) {
1002
+ const value = source[key];
1003
+ if (value === void 0) continue;
1004
+ out[key] = value;
1005
+ if (value !== null && value !== "") hasTenant = true;
1006
+ }
1007
+ if (!hasTenant && source.bypassTenant === true) out.bypassTenant = true;
1008
+ for (const key of ATTRIBUTION_KEYS) if (source[key] !== void 0) out[key] = source[key];
1009
+ return out;
1010
+ }
1011
+ /** Every key `forwardScope` governs. Other forwarding helpers must not copy these themselves. */
1012
+ function isScopeKey(key, tenantKeys = CONVENTIONAL_TENANT_KEYS) {
1013
+ return key === "bypassTenant" || tenantKeys.includes(key) || ATTRIBUTION_KEYS.includes(key);
1014
+ }
1015
+ //#endregion
933
1016
  //#region src/plugins/multi-tenant.plugin.ts
934
1017
  /**
935
1018
  * Multi-Tenant Plugin
@@ -983,6 +1066,7 @@ function multiTenantPlugin(options = {}) {
983
1066
  return {
984
1067
  name: "multi-tenant",
985
1068
  apply(repo) {
1069
+ declareTenantContextKey(repo, contextKey);
986
1070
  const allOps = [...ALL_OPERATIONS.map((op) => ({
987
1071
  op,
988
1072
  policyKey: OP_REGISTRY[op].policyKey
@@ -1500,4 +1584,4 @@ function timestampPlugin() {
1500
1584
  };
1501
1585
  }
1502
1586
  //#endregion
1503
- export { customIdPlugin as a, prefixedId as c, AuditTrailQuery as d, auditTrailPlugin as f, multiTenantPlugin as i, sequentialId as l, auditLogPlugin as m, softDeletePlugin as n, dateSequentialId as o, ensureAuditTrailReady as p, adminBypass$1 as r, getNextSequence as s, timestampPlugin as t, cachePlugin as u };
1587
+ export { AuditTrailQuery as _, declareTenantContextKey as a, auditLogPlugin as b, tenantContextKeysOf as c, ensureSequenceAtLeast as d, getNextSequence as f, cachePlugin as g, sequentialId as h, multiTenantPlugin as i, customIdPlugin as l, readSequence as m, softDeletePlugin as n, forwardScope as o, prefixedId as p, adminBypass$1 as r, isScopeKey as s, timestampPlugin as t, dateSequentialId as u, auditTrailPlugin as v, ensureAuditTrailReady as y };
@@ -1,5 +1,5 @@
1
- import { v as UserContext } from "./repository-ZbLtL7Nk.mjs";
2
- import { r as FieldPreset } from "./plugin-options-BuvtOSZf.mjs";
1
+ import { v as UserContext } from "./repository-odl03582.mjs";
2
+ import { r as FieldPreset } from "./plugin-options-Zk46lD6F.mjs";
3
3
  import mongoose, { Schema } from "mongoose";
4
4
  import { CacheAdapter } from "@classytic/repo-core/cache";
5
5
  import { HttpError } from "@classytic/repo-core/errors";