@classytic/mongokit 3.38.1 → 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 (51) hide show
  1. package/dist/{PaginationEngine-CBS1XGzp.d.mts → PaginationEngine-D-P19Y9m.d.mts} +34 -9
  2. package/dist/PaginationEngine-Dvl8jcRY.mjs +1198 -0
  3. package/dist/{Repository-Ck5x43wF.mjs → Repository-BMgPSkqZ.mjs} +672 -655
  4. package/dist/{Repository-BdSkQ8vc.d.mts → Repository-CR8Y9g2E.d.mts} +106 -34
  5. package/dist/actions/index.d.mts +1 -1
  6. package/dist/actions/index.mjs +1 -1
  7. package/dist/adapter/index.d.mts +21 -0
  8. package/dist/adapter/index.mjs +1 -1
  9. package/dist/{adapter-Bfqz8yHE.mjs → adapter-BrVfPFSW.mjs} +45 -1
  10. package/dist/ai/index.d.mts +1 -1
  11. package/dist/better-auth/index.mjs +2 -2
  12. package/dist/{create-repository-B5EulQ31.mjs → create-repository-BzBUMvd1.mjs} +3 -3
  13. package/dist/{create-repository-BU4x9Kb0.d.mts → create-repository-CAle1MSU.d.mts} +5 -5
  14. package/dist/idempotency/index.d.mts +23 -0
  15. package/dist/idempotency/index.mjs +134 -0
  16. package/dist/{index-DKEql4cL.d.mts → index-CmjNyulu.d.mts} +67 -8
  17. package/dist/index.d.mts +125 -35
  18. package/dist/index.mjs +286 -676
  19. package/dist/indexes/verify-indexes.d.mts +14 -1
  20. package/dist/indexes/verify-indexes.mjs +54 -7
  21. package/dist/{method-registry.plugin-DblM7V6j.mjs → method-registry.plugin-C63pGn1d.mjs} +54 -4
  22. package/dist/{multi-tenant.plugin-BwSvTirB.d.mts → multi-tenant.plugin-CBgCo9im.d.mts} +77 -5
  23. package/dist/{operations-DZodvjC-.d.mts → operations-CrRIoc8Z.d.mts} +22 -7
  24. package/dist/outbox/index.d.mts +1 -1
  25. package/dist/outbox/index.mjs +7 -5
  26. package/dist/pagination/PaginationEngine.d.mts +1 -1
  27. package/dist/pagination/PaginationEngine.mjs +1 -1
  28. package/dist/pagination/utils/limits.d.mts +1 -1
  29. package/dist/pagination/utils/limits.mjs +22 -1
  30. package/dist/pagination-BpIv2bdS.d.mts +364 -0
  31. package/dist/{plugin-options-DXnlKM3B.d.mts → plugin-options-Zk46lD6F.d.mts} +16 -2
  32. package/dist/plugins/index.d.mts +8 -3
  33. package/dist/plugins/index.mjs +99 -26
  34. package/dist/{repository-H_ffGgpE.d.mts → repository-odl03582.d.mts} +50 -6
  35. package/dist/sync/index.d.mts +40 -0
  36. package/dist/sync/index.mjs +334 -0
  37. package/dist/tenant/index.d.mts +89 -0
  38. package/dist/tenant/index.mjs +96 -0
  39. package/dist/testkit/index.d.mts +2 -2
  40. package/dist/testkit/index.mjs +1 -1
  41. package/dist/{timestamp.plugin-CBQukQzV.mjs → timestamp.plugin-DF0gJB6p.mjs} +116 -9
  42. package/dist/{to-plain-Dyz_qvuo.d.mts → to-plain-k7SjNlSS.d.mts} +2 -2
  43. package/dist/transaction-B5L9glO0.mjs +533 -0
  44. package/dist/{update-jSXp3nu3.mjs → update-DG0fCwRJ.mjs} +293 -83
  45. package/dist/utils/index.d.mts +1 -1
  46. package/dist/{validation-chain.plugin-DhNyxNCw.mjs → validation-chain.plugin-BLY2gLEk.mjs} +192 -279
  47. package/dist/{validation-chain.plugin-yQRob3Av.d.mts → validation-chain.plugin-DYp9E7K2.d.mts} +26 -20
  48. package/package.json +17 -4
  49. package/dist/PaginationEngine-BX9o_-4R.mjs +0 -735
  50. package/dist/core-62oo3N1C.d.mts +0 -50
  51. package/dist/pagination-jSUdrACK.d.mts +0 -158
@@ -1,6 +1,6 @@
1
- import { a as PopulateSpec, i as ObjectId, l as SortSpec, o as ReadPreferenceType, r as FilterQuery, s as SelectSpec, t as AnyDocument } from "./core-62oo3N1C.mjs";
1
+ import { _ as PopulateSpec, g as ObjectId, h as FilterQuery, i as CountStrategy, p as AnyDocument, v as ReadPreferenceType, x as SortSpec, y as SelectSpec } from "./pagination-BpIv2bdS.mjs";
2
2
  import { r as DocField } from "./type-utils-CLzRFtIA.mjs";
3
- import { RepoCapabilities, RepositoryBase } from "@classytic/repo-core/repository";
3
+ import { DistributionConfig, DistributionMissHandler, RepoCapabilities, RepositoryBase } from "@classytic/repo-core/repository";
4
4
  import { Model, PipelineStage } from "mongoose";
5
5
  import { HttpError } from "@classytic/repo-core/errors";
6
6
  import { OffsetPaginationResult } from "@classytic/repo-core/pagination";
@@ -199,6 +199,28 @@ interface RepositoryOptions {
199
199
  * ```
200
200
  */
201
201
  requirePlugins?: readonly RepositoryPluginName[];
202
+ /**
203
+ * Declare the collection's shard key so every filter-taking verb
204
+ * (`getAll`, `findAll`, `getOne`, `count`, `updateMany`, `aggregate`, …)
205
+ * catches a filter that omits it — the query mongos has to fan out to
206
+ * every shard. Id-addressed verbs (`getById`, `update`, `delete`,
207
+ * `claim`) are not checked; their `_id` match is routed, not scattered.
208
+ *
209
+ * The check runs AFTER `before:*` hooks, so a tenant field injected by
210
+ * `multiTenantPlugin` satisfies a `key: 'organizationId'` guard and the
211
+ * guard only ever fires on the calls that dropped the scope —
212
+ * `bypassTenant: true`, an unscoped repo, a hand-built cross-tenant
213
+ * `$or`. List verbs a host legitimately runs cross-shard (a global
214
+ * dashboard's `aggregate`) in `exemptOperations`.
215
+ *
216
+ * `onMissingKey` defaults to `'warn'` — once per operation name through
217
+ * mongokit's logger (`configureLogger` routes it); pass `onMiss` to
218
+ * observe every miss yourself, or `'throw'` for hosts where a scatter-
219
+ * gather is never acceptable. Omit the option entirely and nothing runs.
220
+ */
221
+ distribution?: DistributionConfig & {
222
+ onMiss?: DistributionMissHandler;
223
+ };
202
224
  /**
203
225
  * Override the whole runtime capability descriptor.
204
226
  *
@@ -295,6 +317,8 @@ interface RepositoryContext {
295
317
  limit?: number;
296
318
  /** Cursor for next page (keyset pagination) */
297
319
  after?: string;
320
+ /** Cursor for the PREVIOUS page (keyset pagination). Mutually exclusive with `after`. */
321
+ before?: string;
298
322
  /** Search query string */
299
323
  search?: string;
300
324
  /** Pagination mode */
@@ -303,8 +327,10 @@ interface RepositoryContext {
303
327
  hint?: string | Record<string, 1 | -1>;
304
328
  /** Maximum execution time in milliseconds */
305
329
  maxTimeMS?: number;
306
- /** Count strategy for offset pagination */
307
- countStrategy?: 'exact' | 'estimated' | 'none';
330
+ /** Count strategy for offset pagination. See {@link CountStrategy}. */
331
+ countStrategy?: CountStrategy;
332
+ /** Ceiling for `countStrategy: 'capped'` (default 10000). */
333
+ countLimit?: number;
308
334
  /** Whether this is a soft delete operation (set by softDeletePlugin) */
309
335
  softDeleted?: boolean;
310
336
  /** Include soft-deleted documents in queries */
@@ -337,8 +363,23 @@ interface RepositoryContext {
337
363
  _cacheResolved?: unknown;
338
364
  /** Cache hit status (internal — `'fresh'` or `'stale'`) */
339
365
  _cacheStatus?: 'fresh' | 'stale' | 'miss' | 'disabled' | 'bypass';
340
- /** IDs to cascade delete (internal) */
341
- _cascadeIds?: unknown[];
366
+ /**
367
+ * Parent key values captured before a delete, by `parentKey` (internal —
368
+ * cascade plugin). Restrict, detach and cascade all read these.
369
+ */
370
+ _cascadeParentKeys?: Map<string, unknown[]>;
371
+ /**
372
+ * Memo shared by every hook of THIS operation: N plugins that need the same read pay for
373
+ * one. Scoped to the operation, so it can never serve a value across writes. A rejected
374
+ * load is forgotten, so the next caller retries.
375
+ */
376
+ memo?: <T>(key: string, load: () => Promise<T>) => Promise<T>;
377
+ /**
378
+ * The persisted document an id-addressed operation targets, as it is BEFORE the write,
379
+ * read once under the operation's session (not tenant-filtered — the write's own filter
380
+ * decides whether it applies). Use this instead of `Model.findById(context.id)` in a hook.
381
+ */
382
+ loadTarget?: () => Promise<Record<string, unknown> | null>;
342
383
  /** Custom context data from plugins */
343
384
  [key: string]: unknown;
344
385
  }
@@ -502,6 +543,9 @@ type AllPluginMethods<TDoc> = {
502
543
  deleteSubdocument(parentId: string | ObjectId, arrayPath: DocField<TDoc>, subId: string | ObjectId, options?: Record<string, unknown>): Promise<TDoc>;
503
544
  restore(id: string | ObjectId, options?: {
504
545
  session?: unknown;
546
+ organizationId?: unknown;
547
+ bypassTenant?: boolean;
548
+ [key: string]: unknown;
505
549
  }): Promise<TDoc>;
506
550
  getDeleted(params?: {
507
551
  filters?: Record<string, unknown>;
@@ -0,0 +1,40 @@
1
+ import { ClientSession, Connection, Model } from "mongoose";
2
+ import { ChangeLogStore, CommandStreamStore, CommandStreamTx } from "@classytic/repo-core/sync";
3
+ //#region src/sync/index.d.ts
4
+ interface ChangeLogModelOptions {
5
+ /** Entries collection. Default `sync_changes`; its counter lives in `<collection>_counter`. */
6
+ collection?: string;
7
+ /** Mongoose model name prefix. Default `SyncChange`. Must be unique per connection. */
8
+ modelName?: string;
9
+ }
10
+ interface ChangeLogModels {
11
+ entries: Model<Record<string, unknown>>;
12
+ counter: Model<Record<string, unknown>>;
13
+ /** The counter document's id (one feed per entries collection). */
14
+ feed: string;
15
+ }
16
+ declare function createChangeLogModels(connection: Connection, options?: ChangeLogModelOptions): ChangeLogModels;
17
+ declare function createChangeLogStore(models: ChangeLogModels): ChangeLogStore;
18
+ interface CommandStreamModelOptions {
19
+ /** Collection prefix: `<prefix>_streams`, `<prefix>_verdicts`, `<prefix>_aliases`. Default `sync`. */
20
+ prefix?: string;
21
+ /** Mongoose model name prefix. Default `SyncStream`. Must be unique per connection. */
22
+ modelName?: string;
23
+ }
24
+ interface CommandStreamModels {
25
+ streams: Model<Record<string, unknown>>;
26
+ verdicts: Model<Record<string, unknown>>;
27
+ aliases: Model<Record<string, unknown>>;
28
+ }
29
+ declare function createCommandStreamModels(connection: Connection, options?: CommandStreamModelOptions): CommandStreamModels;
30
+ interface MongoCommandStreamTx extends CommandStreamTx {
31
+ readonly session: ClientSession;
32
+ }
33
+ /**
34
+ * The mongo {@link CommandStreamStore} (replica set). Proven by `runCommandStreamStoreConformance`.
35
+ * Concurrent transactions on one stream write-conflict on its document and `withTransaction`
36
+ * retries the loser, which then reads the winner's watermark.
37
+ */
38
+ declare function createCommandStreamStore(connection: Pick<Connection, 'startSession'>, models: CommandStreamModels, changes: ChangeLogStore): CommandStreamStore<MongoCommandStreamTx>;
39
+ //#endregion
40
+ export { ChangeLogModelOptions, ChangeLogModels, CommandStreamModelOptions, CommandStreamModels, MongoCommandStreamTx, createChangeLogModels, createChangeLogStore, createCommandStreamModels, createCommandStreamStore };
@@ -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 };
@@ -0,0 +1,89 @@
1
+ import { Schema } from "mongoose";
2
+ import { ResolvedTenantConfig } from "@classytic/repo-core/tenant";
3
+ //#region src/tenant/index.d.ts
4
+ /** Which read an index serves. Default `'tenant'`. See the module docblock. */
5
+ type IndexScope = 'tenant' | 'global';
6
+ /** Key directions and types a declaration may use. Mirrors what `schema.index()` accepts. */
7
+ type IndexKeyValue = 1 | -1 | 'text' | '2dsphere' | '2d' | 'hashed';
8
+ /**
9
+ * Index options passed through to `schema.index()`. `name` is the only one this module
10
+ * reads (to apply and to explain exemptions); everything else is Mongoose's.
11
+ */
12
+ interface IndexDeclarationOptions {
13
+ name?: string | undefined;
14
+ unique?: boolean | undefined;
15
+ sparse?: boolean | undefined;
16
+ partialFilterExpression?: Record<string, unknown> | undefined;
17
+ expireAfterSeconds?: number | undefined;
18
+ collation?: Record<string, unknown> | undefined;
19
+ background?: boolean | undefined;
20
+ [option: string]: unknown;
21
+ }
22
+ /**
23
+ * One index, declared by a kernel for its own model or by a host through a kernel's
24
+ * `indexes` seam. The kernel applies it through {@link injectTenantField}, which is what
25
+ * makes `scope` mean something: a `'tenant'` declaration is prefixed exactly like the
26
+ * kernel's own indexes, a `'global'` one is kept as written.
27
+ */
28
+ interface IndexDeclaration {
29
+ readonly fields: Readonly<Record<string, IndexKeyValue>>;
30
+ readonly options?: IndexDeclarationOptions | undefined;
31
+ /** @default 'tenant' */
32
+ readonly scope?: IndexScope | undefined;
33
+ }
34
+ interface InjectTenantFieldOptions {
35
+ /**
36
+ * Index NAMES (the `name` option of `schema.index()`) already on the schema whose key
37
+ * spec must NOT get the tenant prefix. The escape hatch for a kernel's OWN global
38
+ * identity index declared inline in its schema builder (`order_event_id_unique`: the
39
+ * event id is a kernel-generated UUID and the drain's idempotency contract is exactly
40
+ * one row per event regardless of tenant). Prefer declaring such an index through
41
+ * `indexes` with `scope: 'global'`; this exists so a schema builder need not know the
42
+ * tenant config. Unnamed indexes cannot be exempted: give the index a name first.
43
+ */
44
+ readonly skipIndexes?: readonly string[] | undefined;
45
+ /**
46
+ * Additional declarations, applied AFTER the prepend pass so their `scope` is honoured.
47
+ * This is where a kernel threads its host's `indexes` seam.
48
+ */
49
+ readonly indexes?: readonly IndexDeclaration[] | undefined;
50
+ }
51
+ /**
52
+ * Refuse a declaration that cannot mean what it says. Called before any schema mutation
53
+ * so a bad host config fails at DESCRIBE time, with the fix in the message, rather than
54
+ * building a wrong index on the first bind.
55
+ */
56
+ declare function assertIndexDeclaration(declaration: IndexDeclaration, tenant: Pick<ResolvedTenantConfig, 'tenantField'>): void;
57
+ /**
58
+ * Add `declarations` to `schema` honouring each one's scope.
59
+ *
60
+ * `'tenant'` declarations are prefixed with the tenant field when scoping is enabled
61
+ * (unless they already lead with it, so a host that wrote it out is not double-prefixed);
62
+ * `'global'` ones are added exactly as written. When scoping is disabled every declaration
63
+ * is added as written, because there is no partition to lead with.
64
+ *
65
+ * Exported for kernels whose optional models inject the tenant field on their own path and
66
+ * only need the host's extra declarations applied afterwards.
67
+ */
68
+ declare function declareIndexes(schema: Schema, tenant: ResolvedTenantConfig, declarations: readonly IndexDeclaration[] | undefined): void;
69
+ /**
70
+ * Inject the tenant field into a Mongoose schema and make its indexes tenant-correct.
71
+ *
72
+ * 1. Adds the field, typed by `tenant.fieldType` (`ObjectId` with `ref`, or `String`).
73
+ * `required` only when scoping is enabled and the config says so: a single-tenant
74
+ * deployment still gets the field, because domain verbs reference it in raw queries.
75
+ * 2. When scoping is enabled, prepends the tenant field to every index already declared
76
+ * on the schema, EXCEPT: one that already names the field; a TTL index (MongoDB rejects
77
+ * a compound TTL spec outright, and a time-driven sweep has no tenant to give); and any
78
+ * index named in `skipIndexes`.
79
+ * 3. Applies `indexes` through {@link declareIndexes}, so a `'global'` declaration is never
80
+ * touched by step 2 whatever order the caller thought about it in.
81
+ * 4. Guarantees one tenant-leading index. After the prepend any compound serves a
82
+ * tenant-only query via the prefix rule, so a bare `{ tenant: 1 }` is added ONLY when
83
+ * nothing was prepended: next to a compound it is a redundant prefix paid on every
84
+ * insert (PACKAGE_RULES P11.1; the fleet index audit found this pattern behind most of
85
+ * 86 redundant indexes across 615).
86
+ */
87
+ declare function injectTenantField(schema: Schema, tenant: ResolvedTenantConfig, options?: InjectTenantFieldOptions): void;
88
+ //#endregion
89
+ export { IndexDeclaration, IndexDeclarationOptions, IndexKeyValue, IndexScope, InjectTenantFieldOptions, assertIndexDeclaration, declareIndexes, injectTenantField };
@@ -0,0 +1,96 @@
1
+ import mongoose from "mongoose";
2
+ //#region src/tenant/index.ts
3
+ /** Mongoose keeps declared indexes on `schema._indexes`; `schema.indexes()` is a copy. */
4
+ function indexEntries(schema) {
5
+ return schema._indexes ?? [];
6
+ }
7
+ function isScoped(tenant) {
8
+ return tenant.enabled && tenant.strategy === "field";
9
+ }
10
+ /**
11
+ * Refuse a declaration that cannot mean what it says. Called before any schema mutation
12
+ * so a bad host config fails at DESCRIBE time, with the fix in the message, rather than
13
+ * building a wrong index on the first bind.
14
+ */
15
+ function assertIndexDeclaration(declaration, tenant) {
16
+ if (Object.keys(declaration.fields).length === 0) throw new Error("[mongokit/tenant] an index declaration needs at least one field");
17
+ if (declaration.scope === "global") {
18
+ if (typeof declaration.options?.name !== "string" || declaration.options.name.length === 0) throw new Error(`[mongokit/tenant] a global index must be named: { fields: ${JSON.stringify(declaration.fields)}, scope: 'global' } has no \`options.name\`. A tenant exemption is deliberate, and an unnamed one cannot be told from an oversight.`);
19
+ if (declaration.fields[tenant.tenantField] !== void 0) throw new Error(`[mongokit/tenant] index "${declaration.options.name}" is scope 'global' but names the tenant field "${tenant.tenantField}". A global index serves reads that have no tenant to give; drop the field, or declare the index with scope 'tenant'.`);
20
+ }
21
+ }
22
+ /**
23
+ * Add `declarations` to `schema` honouring each one's scope.
24
+ *
25
+ * `'tenant'` declarations are prefixed with the tenant field when scoping is enabled
26
+ * (unless they already lead with it, so a host that wrote it out is not double-prefixed);
27
+ * `'global'` ones are added exactly as written. When scoping is disabled every declaration
28
+ * is added as written, because there is no partition to lead with.
29
+ *
30
+ * Exported for kernels whose optional models inject the tenant field on their own path and
31
+ * only need the host's extra declarations applied afterwards.
32
+ */
33
+ function declareIndexes(schema, tenant, declarations) {
34
+ if (!declarations || declarations.length === 0) return;
35
+ for (const declaration of declarations) {
36
+ assertIndexDeclaration(declaration, tenant);
37
+ const options = declaration.options;
38
+ const scoped = isScoped(tenant) && declaration.scope !== "global";
39
+ const leadsWithTenant = Object.keys(declaration.fields)[0] === tenant.tenantField;
40
+ const fields = scoped && !leadsWithTenant ? {
41
+ [tenant.tenantField]: 1,
42
+ ...declaration.fields
43
+ } : { ...declaration.fields };
44
+ schema.index(fields, options);
45
+ }
46
+ }
47
+ /**
48
+ * Inject the tenant field into a Mongoose schema and make its indexes tenant-correct.
49
+ *
50
+ * 1. Adds the field, typed by `tenant.fieldType` (`ObjectId` with `ref`, or `String`).
51
+ * `required` only when scoping is enabled and the config says so: a single-tenant
52
+ * deployment still gets the field, because domain verbs reference it in raw queries.
53
+ * 2. When scoping is enabled, prepends the tenant field to every index already declared
54
+ * on the schema, EXCEPT: one that already names the field; a TTL index (MongoDB rejects
55
+ * a compound TTL spec outright, and a time-driven sweep has no tenant to give); and any
56
+ * index named in `skipIndexes`.
57
+ * 3. Applies `indexes` through {@link declareIndexes}, so a `'global'` declaration is never
58
+ * touched by step 2 whatever order the caller thought about it in.
59
+ * 4. Guarantees one tenant-leading index. After the prepend any compound serves a
60
+ * tenant-only query via the prefix rule, so a bare `{ tenant: 1 }` is added ONLY when
61
+ * nothing was prepended: next to a compound it is a redundant prefix paid on every
62
+ * insert (PACKAGE_RULES P11.1; the fleet index audit found this pattern behind most of
63
+ * 86 redundant indexes across 615).
64
+ */
65
+ function injectTenantField(schema, tenant, options = {}) {
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`);
69
+ const isObjectId = tenant.fieldType === "objectId";
70
+ const scoped = isScoped(tenant);
71
+ schema.add({ [tenant.tenantField]: {
72
+ type: isObjectId ? mongoose.Schema.Types.ObjectId : String,
73
+ ...scoped && tenant.required ? { required: true } : {},
74
+ ...isObjectId && tenant.ref ? { ref: tenant.ref } : {}
75
+ } });
76
+ if (!scoped) {
77
+ declareIndexes(schema, tenant, options.indexes);
78
+ return;
79
+ }
80
+ const skip = new Set(options.skipIndexes ?? []);
81
+ for (const entry of indexEntries(schema)) {
82
+ const [fields, indexOptions] = entry;
83
+ if (fields[tenant.tenantField] !== void 0) continue;
84
+ if (indexOptions?.expireAfterSeconds !== void 0) continue;
85
+ const name = indexOptions?.name;
86
+ if (typeof name === "string" && skip.has(name)) continue;
87
+ entry[0] = {
88
+ [tenant.tenantField]: 1,
89
+ ...fields
90
+ };
91
+ }
92
+ declareIndexes(schema, tenant, options.indexes);
93
+ if (!indexEntries(schema).some(([fields]) => Object.keys(fields)[0] === tenant.tenantField)) schema.index({ [tenant.tenantField]: 1 });
94
+ }
95
+ //#endregion
96
+ export { assertIndexDeclaration, declareIndexes, injectTenantField };
@@ -1,5 +1,5 @@
1
- import { n as Repository } from "../Repository-BdSkQ8vc.mjs";
2
- import { n as CreateRepositoryConfig } from "../create-repository-BU4x9Kb0.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-B5EulQ31.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) {