@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
@@ -14,6 +14,10 @@
14
14
  * Missing UNIQUE indexes are reported separately from missing regular ones —
15
15
  * the first silently admits duplicate data, the second only costs a scan.
16
16
  *
17
+ * Same keys are not the same index: a live index is compared on the options that decide
18
+ * correctness (`unique`, `partialFilterExpression`, `sparse`, `collation`, `expireAfterSeconds`).
19
+ * A same-key index that differs is reported as `incompatible` — its fix is a rebuild, not a create.
20
+ *
17
21
  * DELIBERATELY ONE-DIRECTIONAL: an index present in the database but absent
18
22
  * from a schema is NOT drift. Auth libraries and migrations manage indexes
19
23
  * outside the ODM, and treating those as removable is exactly what makes a
@@ -44,11 +48,20 @@ interface MissingIndex {
44
48
  /** The declared key, JSON-stringified — stable to log and to diff. */
45
49
  key: string;
46
50
  }
51
+ /** A live index on the declared keys whose correctness options differ from the declaration. */
52
+ interface IncompatibleIndex extends MissingIndex {
53
+ /** Declared `unique` — a mismatch here can already have admitted duplicates. */
54
+ unique: boolean;
55
+ /** One line per differing option: `unique: declared true, live false`. */
56
+ differences: string[];
57
+ }
47
58
  interface IndexVerifyReport {
48
59
  modelsChecked: number;
49
60
  /** Declared `unique` indexes with nothing behind them. Duplicates can already exist. */
50
61
  missingUnique: MissingIndex[];
51
62
  missingRegular: MissingIndex[];
63
+ /** Same keys, different semantics — e.g. declared unique, built non-unique. */
64
+ incompatible: IncompatibleIndex[];
52
65
  /** Models whose indexes could not be read — collection absent, or no permission. */
53
66
  unreadable: string[];
54
67
  }
@@ -66,4 +79,4 @@ declare function verifyIndexes(connection: IndexVerifiableConnection, options?:
66
79
  /** One-line summary for a boot log. */
67
80
  declare function formatIndexReport(report: IndexVerifyReport): string;
68
81
  //#endregion
69
- export { IndexVerifiableConnection, IndexVerifiableModel, IndexVerifyReport, MissingIndex, VerifyIndexesOptions, formatIndexReport, verifyIndexes };
82
+ export { IncompatibleIndex, IndexVerifiableConnection, IndexVerifiableModel, IndexVerifyReport, MissingIndex, VerifyIndexesOptions, formatIndexReport, verifyIndexes };
@@ -22,12 +22,45 @@ function textFields(key) {
22
22
  const fields = Object.entries(key).filter(([, dir]) => dir === "text").map(([field]) => field);
23
23
  return fields.length > 0 ? fields.sort() : null;
24
24
  }
25
+ /** Order-free JSON, so `{a:1,b:2}` equals `{b:2,a:1}` — option objects are not order-sensitive. */
26
+ function canonical(value) {
27
+ if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "undefined";
28
+ if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
29
+ return `{${Object.keys(value).sort().map((k) => `${JSON.stringify(k)}:${canonical(value[k])}`).join(",")}}`;
30
+ }
31
+ /**
32
+ * The options that change what an index GUARANTEES, compared declared → live. Absent means
33
+ * off (`unique`, `sparse`) or none (`partialFilterExpression`, `expireAfterSeconds`). A collation
34
+ * is compared on the DECLARED fields only: the server fills in every default it does not store.
35
+ */
36
+ function optionDifferences(declared, live) {
37
+ const diffs = [];
38
+ const flag = (name) => {
39
+ const d = declared[name] === true;
40
+ const l = live[name] === true;
41
+ if (d !== l) diffs.push(`${name}: declared ${d}, live ${l}`);
42
+ };
43
+ flag("unique");
44
+ flag("sparse");
45
+ for (const name of ["partialFilterExpression", "expireAfterSeconds"]) {
46
+ const d = declared[name] === void 0 ? "none" : canonical(declared[name]);
47
+ const l = live[name] === void 0 ? "none" : canonical(live[name]);
48
+ if (d !== l) diffs.push(`${name}: declared ${d}, live ${l}`);
49
+ }
50
+ const dColl = declared.collation;
51
+ const lColl = live.collation;
52
+ if (dColl) {
53
+ if (Object.keys(dColl).filter((k) => canonical(dColl[k]) !== canonical(lColl?.[k])).length > 0) diffs.push(`collation: declared ${canonical(dColl)}, live ${lColl ? canonical(lColl) : "none"}`);
54
+ } else if (lColl && lColl.locale !== "simple") diffs.push(`collation: declared none, live ${canonical(lColl)}`);
55
+ return diffs;
56
+ }
25
57
  async function verifyIndexes(connection, options = {}) {
26
58
  const include = options.includeModel ?? (() => true);
27
59
  const report = {
28
60
  modelsChecked: 0,
29
61
  missingUnique: [],
30
62
  missingRegular: [],
63
+ incompatible: [],
31
64
  unreadable: []
32
65
  };
33
66
  for (const name of connection.modelNames().sort()) {
@@ -39,8 +72,12 @@ async function verifyIndexes(connection, options = {}) {
39
72
  let existingText;
40
73
  try {
41
74
  const live = await model.collection.listIndexes().toArray();
42
- existing = new Set(live.map((i) => normalizeKey(i.key)));
43
- existingText = new Set(live.filter((i) => i.weights).map((i) => JSON.stringify(Object.keys(i.weights).sort())));
75
+ existing = /* @__PURE__ */ new Map();
76
+ existingText = /* @__PURE__ */ new Map();
77
+ for (const index of live) {
78
+ const [map, k] = index.weights ? [existingText, JSON.stringify(Object.keys(index.weights).sort())] : [existing, normalizeKey(index.key)];
79
+ map.set(k, [...map.get(k) ?? [], index]);
80
+ }
44
81
  } catch {
45
82
  report.unreadable.push(name);
46
83
  continue;
@@ -48,15 +85,24 @@ async function verifyIndexes(connection, options = {}) {
48
85
  report.modelsChecked += 1;
49
86
  for (const [key, indexOptions] of declared) {
50
87
  const asText = textFields(key);
51
- if (asText) {
52
- if (existingText.has(JSON.stringify(asText))) continue;
53
- } else if (existing.has(normalizeKey(key))) continue;
88
+ const sameKey = asText ? existingText.get(JSON.stringify(asText)) : existing.get(normalizeKey(key));
54
89
  const entry = {
55
90
  model: name,
56
91
  collection: model.collection.name,
57
92
  key: JSON.stringify(key)
58
93
  };
59
- if (indexOptions?.unique) report.missingUnique.push(entry);
94
+ const opts = indexOptions ?? {};
95
+ if (sameKey && sameKey.length > 0) {
96
+ const diffsPerCandidate = sameKey.map((live) => optionDifferences(opts, live));
97
+ if (diffsPerCandidate.some((d) => d.length === 0)) continue;
98
+ report.incompatible.push({
99
+ ...entry,
100
+ unique: opts.unique === true,
101
+ differences: diffsPerCandidate[0] ?? []
102
+ });
103
+ continue;
104
+ }
105
+ if (opts.unique === true) report.missingUnique.push(entry);
60
106
  else report.missingRegular.push(entry);
61
107
  }
62
108
  }
@@ -67,7 +113,8 @@ function formatIndexReport(report) {
67
113
  const parts = [
68
114
  `${report.modelsChecked} models checked`,
69
115
  `missing unique: ${report.missingUnique.length}`,
70
- `missing regular: ${report.missingRegular.length}`
116
+ `missing regular: ${report.missingRegular.length}`,
117
+ `incompatible: ${report.incompatible.length}`
71
118
  ];
72
119
  if (report.unreadable.length > 0) parts.push(`unreadable: ${report.unreadable.length}`);
73
120
  return `[index-check] ${parts.join(" — ")}`;
@@ -16,6 +16,58 @@ import { t as createError } from "./error-BuByZwKU.mjs";
16
16
  * // bulkWrite is what this plugin contributes.
17
17
  * await repo.bulkWrite([...ops]);
18
18
  */
19
+ /**
20
+ * Driver options `bulkWrite` forwards, and the only ones it forwards.
21
+ *
22
+ * The signature used to be `{ session?, ordered?, [key: string]: unknown }` and
23
+ * the call forwarded exactly two of them. So `writeConcern: { w: 'majority' }`
24
+ * on a money import was accepted by the type, dropped on the floor, and the
25
+ * write went out under the default concern with nothing raised.
26
+ */
27
+ const BULK_WRITE_OPTIONS = [
28
+ "ordered",
29
+ "session",
30
+ "writeConcern",
31
+ "bypassDocumentValidation",
32
+ "comment",
33
+ "let",
34
+ "ignoreUndefined"
35
+ ];
36
+ /**
37
+ * Driver options that EXIST but this method does not forward.
38
+ *
39
+ * The options bag is dual-purpose by design: it is spread into
40
+ * `_buildContext`, so it also carries plugin context — `organizationId` for
41
+ * multi-tenant scoping, and whatever else a host's plugins read off it.
42
+ * Refusing every unrecognised key therefore breaks legitimate callers, which
43
+ * is exactly how the first version of this fence failed: it rejected the
44
+ * tenant id and took two passing tests with it.
45
+ *
46
+ * So the line sits where the caller's expectation is. If the DRIVER would
47
+ * understand the option and we would silently not pass it on, refuse — that is
48
+ * the case where someone believes they configured the write. A key the driver
49
+ * does not know either is plugin context, and passes through untouched.
50
+ */
51
+ const UNSUPPORTED_DRIVER_OPTIONS = /* @__PURE__ */ new Set([
52
+ "forceServerObjectId",
53
+ "timeoutMS",
54
+ "maxTimeMS",
55
+ "retryWrites",
56
+ "checkKeys",
57
+ "serializeFunctions",
58
+ "enableUtf8Validation",
59
+ "raw",
60
+ "explain"
61
+ ]);
62
+ /** Narrow the bag to driver options, refusing the ones we would silently drop. */
63
+ function pickBulkWriteOptions(options) {
64
+ const dropped = Object.keys(options).filter((k) => UNSUPPORTED_DRIVER_OPTIONS.has(k));
65
+ if (dropped.length > 0) throw createError(400, `bulkWrite: option(s) ${dropped.map((k) => `'${k}'`).join(", ")} are MongoDB driver options this method does not forward. Refused rather than ignored, because silently dropping one changes what the write does. Supported: ${BULK_WRITE_OPTIONS.join(", ")}.`);
66
+ const out = {};
67
+ for (const key of BULK_WRITE_OPTIONS) if (options[key] !== void 0) out[key] = options[key];
68
+ out.ordered = options.ordered ?? true;
69
+ return out;
70
+ }
19
71
  function batchOperationsPlugin() {
20
72
  return {
21
73
  name: "batch-operations",
@@ -36,6 +88,7 @@ function batchOperationsPlugin() {
36
88
  * ]);
37
89
  */
38
90
  repo.registerMethod("bulkWrite", async function(operations, options = {}) {
91
+ const driverOptions = pickBulkWriteOptions(options);
39
92
  const context = await this._buildContext("bulkWrite", {
40
93
  operations,
41
94
  ...options
@@ -43,10 +96,7 @@ function batchOperationsPlugin() {
43
96
  try {
44
97
  const finalOps = context.operations || operations;
45
98
  if (!finalOps || finalOps.length === 0) throw createError(400, "bulkWrite requires at least one operation");
46
- const result = await this.Model.bulkWrite(finalOps, {
47
- ordered: options.ordered ?? true,
48
- session: options.session
49
- });
99
+ const result = await this.Model.bulkWrite(finalOps, driverOptions);
50
100
  const bulkResult = {
51
101
  ok: result.ok,
52
102
  insertedCount: result.insertedCount,
@@ -1,5 +1,5 @@
1
- import { i as ObjectId } from "./core-62oo3N1C.mjs";
2
- import { l as Plugin, p as RepositoryContext } from "./repository-H_ffGgpE.mjs";
1
+ import { g as ObjectId } from "./pagination-BpIv2bdS.mjs";
2
+ import { l as Plugin, p as RepositoryContext } from "./repository-odl03582.mjs";
3
3
  import mongoose, { Connection } from "mongoose";
4
4
  import { CacheAdapter as CacheAdapter$1, CacheOptions, RepositoryCacheHandle as RepositoryCacheHandle$1, RepositoryCachePluginOptions, cachePlugin } from "@classytic/repo-core/cache";
5
5
  import { TenantConfig } from "@classytic/repo-core/tenant";
@@ -307,12 +307,31 @@ interface CustomIdOptions {
307
307
  * });
308
308
  */
309
309
  declare function getNextSequence(counterKey: string, increment?: number, connection?: mongoose.Connection, session?: mongoose.ClientSession): Promise<number>;
310
+ /**
311
+ * Current value of a counter, or `null` when it has never been created.
312
+ * `null` and `0` differ: only a missing counter needs seeding.
313
+ */
314
+ declare function readSequence(counterKey: string, connection?: mongoose.Connection): Promise<number | null>;
315
+ /**
316
+ * Raise a counter to at least `floor` — never lowers it. Atomic (`$max` +
317
+ * upsert), so concurrent seeders converge on the highest floor.
318
+ *
319
+ * Use before a counter's first increment when ids were already issued under
320
+ * another key (e.g. moving from a global to a per-tenant sequence); otherwise
321
+ * the new counter restarts at 1 and re-issues taken ids.
322
+ *
323
+ * @returns The counter's value after the raise.
324
+ */
325
+ declare function ensureSequenceAtLeast(counterKey: string, floor: number, connection?: mongoose.Connection): Promise<number>;
310
326
  interface SequentialIdOptions {
311
327
  /** Prefix string (e.g., 'INV', 'ORD') */
312
328
  prefix: string;
313
329
  /** Mongoose model — used to derive the counter key from model name */
314
330
  model: mongoose.Model<any>;
315
- /** Number of digits to pad to (default: 4 → "0001") */
331
+ /**
332
+ * MINIMUM number of digits (default: 4 → "0001"). Never truncates —
333
+ * sequence 10000 under `padding: 4` renders as `INV-10000`.
334
+ */
316
335
  padding?: number;
317
336
  /** Separator between prefix and number (default: '-') */
318
337
  separator?: string;
@@ -346,10 +365,54 @@ interface DateSequentialIdOptions {
346
365
  * - 'daily' → BILL-2026-02-20-0001, resets every day
347
366
  */
348
367
  partition?: 'yearly' | 'monthly' | 'daily';
349
- /** Number of digits to pad to (default: 4) */
368
+ /**
369
+ * MINIMUM number of digits for the sequence (default: 4). `padStart` never
370
+ * truncates, so the 10,000th id of a period under `padding: 4` is
371
+ * `BILL-2026-10000` — it grows a digit rather than wrapping to `0000`.
372
+ * Lexical sort order therefore only holds while a period stays below
373
+ * 10^padding; size the padding for your peak period.
374
+ */
350
375
  padding?: number;
351
376
  /** Separator (default: '-') */
352
377
  separator?: string;
378
+ /**
379
+ * Counter scope.
380
+ * - `'global'` (default) — one counter per model + period, shared by
381
+ * every tenant in the database. Ids are unique across the deployment.
382
+ * - `'tenant'` — one counter per tenant + model + period, keyed by
383
+ * `context[tenantKey]`. Every tenant's numbering starts at 1 (Shopify-
384
+ * style per-shop order numbers) and the write hot spot on the single
385
+ * shared counter document splits per tenant. Ids are unique WITHIN a
386
+ * tenant only — index the field compound with the tenant field.
387
+ * Fails closed: a create with no tenant in context throws rather than
388
+ * falling back to the global key, which would silently merge two
389
+ * tenants' sequences.
390
+ */
391
+ scope?: 'global' | 'tenant';
392
+ /**
393
+ * Context key carrying the tenant id under `scope: 'tenant'` (default:
394
+ * `'organizationId'` — the same default as `multiTenantPlugin`'s
395
+ * `contextKey`, so `repo.create(data, { organizationId })` feeds both).
396
+ */
397
+ tenantKey?: string;
398
+ /**
399
+ * Which clock decides the period boundary.
400
+ *
401
+ * - `'local'` (default) — the server's `TZ`. Kept as the default because it
402
+ * is what every existing deployment already generated: flipping it would
403
+ * retroactively change which period an id near a month boundary belongs
404
+ * to, and those ids are invoice and order numbers.
405
+ * - `'utc'` — **the correct choice for more than one replica.** Two replicas
406
+ * with different `TZ` settings derive different counter keys for the same
407
+ * logical month and each increments its own, so the same number is issued
408
+ * twice inside one tenant-period. UTC is the one boundary every replica
409
+ * agrees on. It also matches the ecosystem's other period key,
410
+ * `usagePeriod()` in `@classytic/repo-core/usage`.
411
+ *
412
+ * Containers usually run UTC anyway, which is exactly why the difference
413
+ * stays invisible until one host is configured otherwise.
414
+ */
415
+ timezone?: 'local' | 'utc';
353
416
  }
354
417
  /**
355
418
  * Generator: Date-partitioned sequential counter.
@@ -371,6 +434,15 @@ interface DateSequentialIdOptions {
371
434
  * }),
372
435
  * })
373
436
  * ```
437
+ *
438
+ * @example Per-tenant numbering in a shared database
439
+ * ```typescript
440
+ * customIdPlugin({
441
+ * field: 'orderNumber',
442
+ * generator: dateSequentialId({ prefix: 'ORD', model: OrderModel, scope: 'tenant' }),
443
+ * })
444
+ * // org-a: ORD-2026-02-0001, ORD-2026-02-0002 … org-b: ORD-2026-02-0001 …
445
+ * ```
374
446
  */
375
447
  declare function dateSequentialId(options: DateSequentialIdOptions): IdGenerator;
376
448
  interface PrefixedIdOptions {
@@ -582,4 +654,4 @@ declare function adminBypass(options: {
582
654
  adminRoles: readonly string[];
583
655
  }): (context: RepositoryContext, operation: string) => boolean;
584
656
  //#endregion
585
- export { ensureAuditTrailReady as A, AuditTrailMethods as C, AuditTrailQueryOptions as D, AuditTrailQuery as E, EnsureAuditTrailReadyOptions as O, AuditQueryResult as S, AuditTrailOptions as T, RepositoryCachePluginOptions as _, DateSequentialIdOptions as a, AuditOperation as b, SequentialIdOptions as c, getNextSequence as d, prefixedId as f, RepositoryCacheHandle$1 as g, CacheOptions as h, CustomIdOptions as i, auditTrailPlugin as k, customIdPlugin as l, CacheAdapter$1 as m, adminBypass as n, IdGenerator as o, sequentialId as p, multiTenantPlugin as r, PrefixedIdOptions as s, MultiTenantOptions as t, dateSequentialId as u, cachePlugin as v, AuditTrailMode as w, AuditQueryOptions as x, AuditEntry as y };
657
+ export { EnsureAuditTrailReadyOptions as A, AuditQueryOptions as C, AuditTrailOptions as D, AuditTrailMode as E, ensureAuditTrailReady as M, AuditTrailQuery as O, AuditOperation as S, AuditTrailMethods as T, CacheOptions as _, DateSequentialIdOptions as a, cachePlugin as b, SequentialIdOptions as c, ensureSequenceAtLeast as d, getNextSequence as f, CacheAdapter$1 as g, sequentialId as h, CustomIdOptions as i, auditTrailPlugin as j, AuditTrailQueryOptions as k, customIdPlugin as l, readSequence as m, adminBypass as n, IdGenerator as o, prefixedId as p, multiTenantPlugin as r, PrefixedIdOptions as s, MultiTenantOptions as t, dateSequentialId as u, RepositoryCacheHandle$1 as v, AuditQueryResult as w, AuditEntry as x, RepositoryCachePluginOptions as y };
@@ -1,5 +1,4 @@
1
- import { a as PopulateSpec, i as ObjectId, l as SortSpec, o as ReadPreferenceType, s as SelectSpec } from "./core-62oo3N1C.mjs";
2
- import { r as CollationOptions } from "./pagination-jSUdrACK.mjs";
1
+ import { _ as PopulateSpec, g as ObjectId, i as CountStrategy, r as CollationOptions, v as ReadPreferenceType, x as SortSpec, y as SelectSpec } from "./pagination-BpIv2bdS.mjs";
3
2
  import { DeleteManyResult, DeleteResult, LookupPopulateResult, UpdateManyResult } from "@classytic/repo-core/repository";
4
3
  import { PipelineStage, PopulateOptions } from "mongoose";
5
4
  import { Filter } from "@classytic/repo-core/filter";
@@ -383,9 +382,21 @@ interface WithTransactionOptions {
383
382
  }
384
383
  /** Create operation options */
385
384
  interface CreateOptions extends SessionOptions {
386
- /** Keep insertion order on error (default: false).
387
- * When false, all valid documents insert even if some fail (e.g. duplicates).
388
- * Set to true to abort remaining inserts on first error. */
385
+ /**
386
+ * Keep insertion order on error. **Default `false`, which is the OPPOSITE of
387
+ * the MongoDB driver's own default** — a deliberate choice for import
388
+ * throughput, pinned by `tests/integration/createMany-partial-failure.test.ts`.
389
+ *
390
+ * The consequence is worth stating plainly, because it is the part that
391
+ * surprises: with `false`, a REJECTED `createMany` can still have written
392
+ * documents. Every valid document inserts and only the failures are reported,
393
+ * so a caller that treats the rejection as "nothing happened" and retries
394
+ * will double-insert everything that succeeded the first time.
395
+ *
396
+ * Retryable imports therefore need a unique index or an idempotency key —
397
+ * not a try/catch. Pass `ordered: true` when the batch is an all-or-nothing
398
+ * business operation, and a transaction when it must also be atomic.
399
+ */
389
400
  ordered?: boolean;
390
401
  }
391
402
  /** Update operation options */
@@ -485,14 +496,18 @@ interface LookupPopulateOptions<TBase = unknown> {
485
496
  page?: number;
486
497
  /** Cursor token for next page (keyset mode) */
487
498
  after?: string;
499
+ /** Cursor token for the PREVIOUS page (keyset mode). Mutually exclusive with `after`. */
500
+ before?: string;
488
501
  /** Number of documents per page */
489
502
  limit?: number;
490
503
  /** Fields to select */
491
504
  select?: SelectSpec;
492
505
  /** Collation for locale-aware string comparison */
493
506
  collation?: CollationOptions;
494
- /** Count strategy for offset pagination */
495
- countStrategy?: 'exact' | 'estimated' | 'none';
507
+ /** Count strategy for offset pagination. See {@link CountStrategy}. */
508
+ countStrategy?: CountStrategy;
509
+ /** Ceiling for `countStrategy: 'capped'` (default 10000). */
510
+ countLimit?: number;
496
511
  /**
497
512
  * Transaction session. Typed `unknown` to match repo-core's
498
513
  * `RepositorySession` contract — mongokit narrows to `ClientSession`
@@ -1,4 +1,4 @@
1
- import { n as Repository } from "../Repository-BdSkQ8vc.mjs";
1
+ import { r as Repository } from "../Repository-CR8Y9g2E.mjs";
2
2
  import { Connection, Model } from "mongoose";
3
3
  //#region src/outbox/index.d.ts
4
4
  /** Delivery states. `dead_letter` is terminal and operator-visible. */
@@ -1,5 +1,5 @@
1
- import { n as Repository } from "../Repository-Ck5x43wF.mjs";
2
- import { n as batchOperationsPlugin, t as methodRegistryPlugin } from "../method-registry.plugin-DblM7V6j.mjs";
1
+ import { n as Repository } from "../Repository-BMgPSkqZ.mjs";
2
+ import { n as batchOperationsPlugin, t as methodRegistryPlugin } from "../method-registry.plugin-C63pGn1d.mjs";
3
3
  import { Schema } from "mongoose";
4
4
  //#region src/outbox/index.ts
5
5
  /**
@@ -172,12 +172,14 @@ function createOutboxModel(connection, options = {}) {
172
172
  *
173
173
  * Mongo rejects `unique` combined with a `sparse: true` + partial filter ("cannot mix"), and
174
174
  * the failure is that the index silently never builds — so duplicate events insert happily
175
- * and the outbox's idempotency guarantee is gone with nothing in the logs. Filtering on
176
- * `$type: 'string'` indexes only rows that actually carry a key.
175
+ * and the outbox's idempotency guarantee is gone with nothing in the logs. `$gte: ''` indexes
176
+ * only rows that carry a string key — the same rows as `$type: 'string'`, but the dedupe
177
+ * lookup by key can use it (a `$type` filter is never implied by an equality query).
177
178
  */
178
179
  schema.index({ dedupeKey: 1 }, {
180
+ name: "dedupeKey_str",
179
181
  unique: true,
180
- partialFilterExpression: { dedupeKey: { $type: "string" } }
182
+ partialFilterExpression: { dedupeKey: { $gte: "" } }
181
183
  });
182
184
  /**
183
185
  * The DEAD-LETTER read. `getDeadLettered` is part of the `OutboxStore` contract and is what
@@ -1,2 +1,2 @@
1
- import { t as PaginationEngine } from "../PaginationEngine-CBS1XGzp.mjs";
1
+ import { t as PaginationEngine } from "../PaginationEngine-D-P19Y9m.mjs";
2
2
  export { PaginationEngine };
@@ -1,2 +1,2 @@
1
- import { t as PaginationEngine } from "../PaginationEngine-BX9o_-4R.mjs";
1
+ import { t as PaginationEngine } from "../PaginationEngine-Dvl8jcRY.mjs";
2
2
  export { PaginationEngine };
@@ -1,4 +1,4 @@
1
- import { c as PaginationConfig } from "../../pagination-jSUdrACK.mjs";
1
+ import { u as PaginationConfig } from "../../pagination-BpIv2bdS.mjs";
2
2
  //#region src/pagination/utils/limits.d.ts
3
3
  /**
4
4
  * Validates and sanitizes limit value
@@ -1,3 +1,4 @@
1
+ import { t as createError } from "../../error-BuByZwKU.mjs";
1
2
  //#region src/pagination/utils/limits.ts
2
3
  /**
3
4
  * Validates and sanitizes limit value
@@ -27,7 +28,27 @@ function validatePage(page, config) {
27
28
  const parsed = Number(page);
28
29
  if (!Number.isFinite(parsed) || parsed < 1) return 1;
29
30
  const sanitized = Math.floor(parsed);
30
- if (sanitized > (config.maxPage || 1e4)) throw new Error(`Page ${sanitized} exceeds maximum ${config.maxPage || 1e4}`);
31
+ const max = config.maxPage || 1e4;
32
+ if (sanitized > max)
33
+ /**
34
+ * 400, not a bare `Error`.
35
+ *
36
+ * The page number comes from the querystring, so this is the caller's
37
+ * mistake — a crawler walking past the end, a fuzzer, a stale deep link.
38
+ * A bare `Error` carries no status, so every framework above maps it to
39
+ * `internal_error` 500: the request is refused either way, but one of them
40
+ * pages an on-call engineer and burns an error budget over `?page=999999`.
41
+ *
42
+ * It matters more the lower `maxPage` is set, and a deployment that has
43
+ * tuned it down for the skip cost is exactly the one getting this traffic.
44
+ */
45
+ throw createError(400, `Page ${sanitized} exceeds maximum ${max}`, {
46
+ code: "PAGE_OUT_OF_RANGE",
47
+ meta: {
48
+ page: sanitized,
49
+ maxPage: max
50
+ }
51
+ });
31
52
  return sanitized;
32
53
  }
33
54
  /**