@classytic/mongokit 3.36.0 → 3.39.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 (47) hide show
  1. package/dist/{PaginationEngine-CBS1XGzp.d.mts → PaginationEngine-D-P19Y9m.d.mts} +34 -9
  2. package/dist/PaginationEngine-DQ7beTCR.mjs +1182 -0
  3. package/dist/{Repository-BArvU32x.mjs → Repository-D7fDpyFx.mjs} +405 -37
  4. package/dist/{Repository-BdSkQ8vc.d.mts → Repository-IxI983_m.d.mts} +55 -8
  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.d.mts +78 -1
  12. package/dist/better-auth/index.mjs +233 -3
  13. package/dist/{create-repository-BU4x9Kb0.d.mts → create-repository-CRXjDa3N.d.mts} +5 -5
  14. package/dist/{create-repository-CPNbktFG.mjs → create-repository-DOe9E_5F.mjs} +3 -3
  15. package/dist/{index-DKEql4cL.d.mts → index-CmjNyulu.d.mts} +67 -8
  16. package/dist/index.d.mts +77 -13
  17. package/dist/index.mjs +31 -8
  18. package/dist/indexes/verify-indexes.d.mts +69 -0
  19. package/dist/indexes/verify-indexes.mjs +76 -0
  20. package/dist/{method-registry.plugin-DblM7V6j.mjs → method-registry.plugin-C63pGn1d.mjs} +54 -4
  21. package/dist/{multi-tenant.plugin-BwSvTirB.d.mts → multi-tenant.plugin-B-tzNeYL.d.mts} +60 -4
  22. package/dist/{operations-DZodvjC-.d.mts → operations-CrRIoc8Z.d.mts} +22 -7
  23. package/dist/outbox/index.d.mts +1 -1
  24. package/dist/outbox/index.mjs +2 -2
  25. package/dist/pagination/PaginationEngine.d.mts +1 -1
  26. package/dist/pagination/PaginationEngine.mjs +1 -1
  27. package/dist/pagination/utils/limits.d.mts +47 -0
  28. package/dist/pagination/utils/limits.mjs +85 -0
  29. package/dist/pagination-BpIv2bdS.d.mts +364 -0
  30. package/dist/{plugin-options-DXnlKM3B.d.mts → plugin-options-BuvtOSZf.d.mts} +2 -2
  31. package/dist/plugins/index.d.mts +3 -3
  32. package/dist/plugins/index.mjs +3 -3
  33. package/dist/{repository-H_ffGgpE.d.mts → repository-ZbLtL7Nk.d.mts} +30 -4
  34. package/dist/tenant/index.d.mts +89 -0
  35. package/dist/tenant/index.mjs +94 -0
  36. package/dist/testkit/index.d.mts +2 -2
  37. package/dist/testkit/index.mjs +1 -1
  38. package/dist/{timestamp.plugin-BEc6TANm.mjs → timestamp.plugin-B5eiaItQ.mjs} +75 -8
  39. package/dist/{to-plain-Dyz_qvuo.d.mts → to-plain-B8ufk3Qz.d.mts} +2 -2
  40. package/dist/{update-jSXp3nu3.mjs → update-c_01A0if.mjs} +126 -73
  41. package/dist/utils/index.d.mts +1 -1
  42. package/dist/{validation-chain.plugin-CyK1s9Ze.mjs → validation-chain.plugin-Bju1D1Sn.mjs} +2 -2
  43. package/dist/{validation-chain.plugin-yQRob3Av.d.mts → validation-chain.plugin-DvrLJIbJ.d.mts} +3 -19
  44. package/package.json +15 -3
  45. package/dist/PaginationEngine-GdyX29tE.mjs +0 -797
  46. package/dist/core-62oo3N1C.d.mts +0 -50
  47. 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 */
@@ -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,94 @@
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 isObjectId = tenant.fieldType === "objectId";
68
+ const scoped = isScoped(tenant);
69
+ schema.add({ [tenant.tenantField]: {
70
+ type: isObjectId ? mongoose.Schema.Types.ObjectId : String,
71
+ ...scoped && tenant.required ? { required: true } : {},
72
+ ...isObjectId && tenant.ref ? { ref: tenant.ref } : {}
73
+ } });
74
+ if (!scoped) {
75
+ declareIndexes(schema, tenant, options.indexes);
76
+ return;
77
+ }
78
+ const skip = new Set(options.skipIndexes ?? []);
79
+ for (const entry of indexEntries(schema)) {
80
+ const [fields, indexOptions] = entry;
81
+ if (fields[tenant.tenantField] !== void 0) continue;
82
+ if (indexOptions?.expireAfterSeconds !== void 0) continue;
83
+ const name = indexOptions?.name;
84
+ if (typeof name === "string" && skip.has(name)) continue;
85
+ entry[0] = {
86
+ [tenant.tenantField]: 1,
87
+ ...fields
88
+ };
89
+ }
90
+ declareIndexes(schema, tenant, options.indexes);
91
+ if (!indexEntries(schema).some(([fields]) => Object.keys(fields)[0] === tenant.tenantField)) schema.index({ [tenant.tenantField]: 1 });
92
+ }
93
+ //#endregion
94
+ 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 { n as Repository } from "../Repository-IxI983_m.mjs";
2
+ import { n as CreateRepositoryConfig } from "../create-repository-CRXjDa3N.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-CPNbktFG.mjs";
1
+ import { t as createRepository } from "../create-repository-DOe9E_5F.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-BArvU32x.mjs";
1
+ import { c as ALL_OPERATIONS, t as HOOK_PRIORITY, u as OP_REGISTRY } from "./Repository-D7fDpyFx.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";
@@ -795,30 +795,53 @@ function sequentialId(options) {
795
795
  * }),
796
796
  * })
797
797
  * ```
798
+ *
799
+ * @example Per-tenant numbering in a shared database
800
+ * ```typescript
801
+ * customIdPlugin({
802
+ * field: 'orderNumber',
803
+ * generator: dateSequentialId({ prefix: 'ORD', model: OrderModel, scope: 'tenant' }),
804
+ * })
805
+ * // org-a: ORD-2026-02-0001, ORD-2026-02-0002 … org-b: ORD-2026-02-0001 …
806
+ * ```
798
807
  */
799
808
  function dateSequentialId(options) {
800
- const { prefix, model, partition = "monthly", padding = 4, separator = "-" } = options;
809
+ const { prefix, model, partition = "monthly", padding = 4, separator = "-", scope = "global", tenantKey = "organizationId", timezone = "local" } = options;
810
+ /** Per-generator, not module-level — two generators warn independently. */
811
+ const overflowWarned = /* @__PURE__ */ new Set();
812
+ const paddingCeiling = 10 ** padding;
801
813
  return async (context) => {
802
814
  const now = /* @__PURE__ */ new Date();
803
- const year = String(now.getFullYear());
804
- const month = String(now.getMonth() + 1).padStart(2, "0");
805
- const day = String(now.getDate()).padStart(2, "0");
815
+ const utc = timezone === "utc";
816
+ const year = String(utc ? now.getUTCFullYear() : now.getFullYear());
817
+ const month = String((utc ? now.getUTCMonth() : now.getMonth()) + 1).padStart(2, "0");
818
+ const day = String(utc ? now.getUTCDate() : now.getDate()).padStart(2, "0");
819
+ let keyBase = model.modelName;
820
+ if (scope === "tenant") {
821
+ const tenantId = context[tenantKey];
822
+ if (tenantId === void 0 || tenantId === null || tenantId === "") throw new Error(`[mongokit] dateSequentialId: scope 'tenant' requires '${tenantKey}' in context for '${model.modelName}' create, but none was provided. Pass it per call (repo.create(data, { ${tenantKey}: '<id>' })) or resolve it ambiently; the generator never falls back to the global counter.`);
823
+ keyBase = `${model.modelName}:${String(tenantId)}`;
824
+ }
806
825
  let datePart;
807
826
  let counterKey;
808
827
  switch (partition) {
809
828
  case "yearly":
810
829
  datePart = year;
811
- counterKey = `${model.modelName}:${year}`;
830
+ counterKey = `${keyBase}:${year}`;
812
831
  break;
813
832
  case "daily":
814
833
  datePart = `${year}${separator}${month}${separator}${day}`;
815
- counterKey = `${model.modelName}:${year}-${month}-${day}`;
834
+ counterKey = `${keyBase}:${year}-${month}-${day}`;
816
835
  break;
817
836
  default:
818
837
  datePart = `${year}${separator}${month}`;
819
- counterKey = `${model.modelName}:${year}-${month}`;
838
+ counterKey = `${keyBase}:${year}-${month}`;
820
839
  }
821
840
  const seq = await getNextSequence(counterKey, 1, context._counterConnection, context.session);
841
+ if (seq >= paddingCeiling && !overflowWarned.has(counterKey)) {
842
+ overflowWarned.add(counterKey);
843
+ warn(`[mongokit] dateSequentialId: '${model.modelName}' sequence ${seq} exceeds padding ${padding} (max ${paddingCeiling - 1}) for period '${counterKey}'. Ids remain unique but no longer sort lexically — raise \`padding\` for new periods, or use a finer \`partition\`.`);
844
+ }
822
845
  return `${prefix}${separator}${datePart}${separator}${String(seq).padStart(padding, "0")}`;
823
846
  };
824
847
  }
@@ -1290,6 +1313,50 @@ function softDeletePlugin(options = {}) {
1290
1313
  };
1291
1314
  }
1292
1315
  }, { priority: HOOK_PRIORITY.POLICY });
1316
+ /**
1317
+ * Hook: before:bulkWrite — every sub-op gets what its standalone form gets.
1318
+ *
1319
+ * This was skipped as "not a single filter we can scope", but a sub-op DOES carry
1320
+ * its own filter — the tenant plugin walks exactly these keys. Skipping it meant a
1321
+ * soft-delete repository's `bulkWrite` updated rows it had already deleted, and
1322
+ * HARD-deleted through `deleteOne`/`deleteMany`, destroying data the model says is
1323
+ * recoverable. The suite's own header claimed this was covered.
1324
+ *
1325
+ * `deleteMode: 'hard'` still bypasses, exactly as the single-doc path does — a purge
1326
+ * is a deliberate physical delete, and it reaches the driver directly anyway.
1327
+ */
1328
+ repo.on("before:bulkWrite", (context) => {
1329
+ if (options.soft === false || context.deleteMode === "hard") return;
1330
+ const ops = context.operations;
1331
+ if (!Array.isArray(ops)) return;
1332
+ const notDeleted = buildDeletedFilter(deletedField, filterMode, false);
1333
+ for (const subOp of ops) {
1334
+ if (!subOp || typeof subOp !== "object") continue;
1335
+ for (const key of [
1336
+ "updateOne",
1337
+ "updateMany",
1338
+ "replaceOne"
1339
+ ]) {
1340
+ const body = subOp[key];
1341
+ if (body?.filter) body.filter = {
1342
+ ...body.filter,
1343
+ ...notDeleted
1344
+ };
1345
+ }
1346
+ for (const [key, target] of [["deleteOne", "updateOne"], ["deleteMany", "updateMany"]]) {
1347
+ const body = subOp[key];
1348
+ if (!body?.filter) continue;
1349
+ subOp[target] = {
1350
+ filter: {
1351
+ ...body.filter,
1352
+ ...notDeleted
1353
+ },
1354
+ update: { $set: { [deletedField]: /* @__PURE__ */ new Date() } }
1355
+ };
1356
+ delete subOp[key];
1357
+ }
1358
+ }
1359
+ }, { priority: HOOK_PRIORITY.POLICY });
1293
1360
  repo.on("before:deleteMany", async (context) => {
1294
1361
  if (context.deleteMode === "hard") return;
1295
1362
  if (options.soft !== false) {
@@ -1,5 +1,5 @@
1
- import { v as UserContext } from "./repository-H_ffGgpE.mjs";
2
- import { r as FieldPreset } from "./plugin-options-DXnlKM3B.mjs";
1
+ import { v as UserContext } from "./repository-ZbLtL7Nk.mjs";
2
+ import { r as FieldPreset } from "./plugin-options-BuvtOSZf.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";
@@ -2,6 +2,99 @@ import { t as __exportAll } from "./rolldown-runtime-D7D4PA-g.mjs";
2
2
  import { n as warn } from "./logger-K7dPmu2U.mjs";
3
3
  import { t as createError } from "./error-BuByZwKU.mjs";
4
4
  import { coerceFilterDates, isFilter } from "@classytic/repo-core/filter";
5
+ //#region src/actions/create.ts
6
+ var create_exports = /* @__PURE__ */ __exportAll({
7
+ create: () => create,
8
+ createDefault: () => createDefault,
9
+ createMany: () => createMany,
10
+ isCreateManyPartialError: () => isCreateManyPartialError,
11
+ upsert: () => upsert
12
+ });
13
+ /**
14
+ * Create single document
15
+ */
16
+ async function create(Model, data, options = {}) {
17
+ const document = new Model(data);
18
+ await document.save({ session: options.session });
19
+ return document;
20
+ }
21
+ /** Narrow a caught error to one carrying a partial-write report. */
22
+ function isCreateManyPartialError(err) {
23
+ return err instanceof Error && typeof err.partial === "object" && err.partial !== null;
24
+ }
25
+ /** Pull the positional failures out of a driver bulk-write error. */
26
+ function readFailures(err, dataArray) {
27
+ const raw = err.writeErrors;
28
+ return (Array.isArray(raw) ? raw : raw ? [raw] : []).map((entry) => {
29
+ const e = entry;
30
+ const inner = e.err ?? e;
31
+ const index = Number(inner.index ?? e.index ?? -1);
32
+ return {
33
+ index,
34
+ doc: dataArray[index] ?? {},
35
+ ...typeof inner.code === "number" ? { code: inner.code } : {},
36
+ message: String(inner.errmsg ?? inner.message ?? e.message ?? "write failed")
37
+ };
38
+ });
39
+ }
40
+ /**
41
+ * Create multiple documents.
42
+ *
43
+ * **A rejection does NOT mean nothing was written.** Neither ordering does:
44
+ * `ordered: true` stops at the first failure and keeps everything before it,
45
+ * `ordered: false` keeps everything valid. Measured on a six-document batch
46
+ * with a conflict in the middle — ordered wrote 3, unordered wrote 5. Only a
47
+ * transaction gives all-or-nothing.
48
+ *
49
+ * So the rejection carries a `partial` report naming what landed and what did
50
+ * not, because the alternative is a caller retrying the whole batch and
51
+ * double-writing everything that had already succeeded. Use it to retry only
52
+ * `failed`, or to reconcile — and prefer a unique index or an idempotency key
53
+ * for anything that will be retried automatically.
54
+ */
55
+ async function createMany(Model, dataArray, options = {}) {
56
+ try {
57
+ return await Model.insertMany(dataArray, {
58
+ session: options.session,
59
+ ordered: options.ordered === true
60
+ });
61
+ } catch (err) {
62
+ const inserted = err.insertedDocs;
63
+ const failed = readFailures(err, dataArray);
64
+ if (Array.isArray(inserted) || failed.length > 0) err.partial = {
65
+ inserted: Array.isArray(inserted) ? inserted : [],
66
+ failed
67
+ };
68
+ throw err;
69
+ }
70
+ }
71
+ /**
72
+ * Create with defaults (useful for initialization)
73
+ */
74
+ async function createDefault(Model, overrides = {}, options = {}) {
75
+ const defaults = {};
76
+ Model.schema.eachPath((path, schemaType) => {
77
+ const schemaOptions = schemaType.options;
78
+ if (schemaOptions.default !== void 0 && path !== "_id") defaults[path] = typeof schemaOptions.default === "function" ? schemaOptions.default() : schemaOptions.default;
79
+ });
80
+ return create(Model, {
81
+ ...defaults,
82
+ ...overrides
83
+ }, options);
84
+ }
85
+ /**
86
+ * Upsert (create or update)
87
+ */
88
+ async function upsert(Model, query, data, options = {}) {
89
+ return Model.findOneAndUpdate(query, { $setOnInsert: data }, {
90
+ upsert: true,
91
+ returnDocument: "after",
92
+ runValidators: true,
93
+ session: options.session,
94
+ ...options.updatePipeline !== void 0 ? { updatePipeline: options.updatePipeline } : {}
95
+ });
96
+ }
97
+ //#endregion
5
98
  //#region src/filter/compile.ts
6
99
  /**
7
100
  * Compile a Filter IR node to a MongoDB query object. Returns `{}`
@@ -611,24 +704,35 @@ async function aggregate(Model, pipeline, options = {}) {
611
704
  return aggregation.exec();
612
705
  }
613
706
  /**
614
- * Aggregate with pagination using native MongoDB $facet
615
- * WARNING: $facet results must be <16MB. For larger results (limit >1000),
616
- * consider using Repository.aggregatePaginate() or splitting into separate queries.
707
+ * Aggregate with pagination.
708
+ *
709
+ * Page and count run as TWO concurrent pipelines rather than one `$facet`.
710
+ * `$facet` bundles every returned document plus the count into a SINGLE output
711
+ * document, and no stage may exceed 16MB — so a page of large documents failed
712
+ * with `BSONObjectTooLarge` instead of paginating, and the only defence was a
713
+ * warning above an arbitrary limit of 1000 that told the caller to go
714
+ * elsewhere. `$facet` also cannot use an index for the stages inside it.
715
+ *
716
+ * The trade: two reads, so a concurrent write between them can leave `total`
717
+ * a document out of step with `data` — the same trade `PaginationEngine`
718
+ * already makes on both its offset and aggregate paths.
617
719
  */
618
720
  async function aggregatePaginate(Model, pipeline, options = {}) {
619
721
  const page = parseInt(String(options.page || 1), 10);
620
722
  const limit = parseInt(String(options.limit || 10), 10);
621
723
  const skip = (page - 1) * limit;
622
- if (limit > 1e3) warn(`[mongokit] Large aggregation limit (${limit}). $facet results must be <16MB. Consider using Repository.aggregatePaginate() for safer handling of large datasets.`);
623
- const facetPipeline = [...pipeline, { $facet: {
624
- data: [{ $skip: skip }, { $limit: limit }],
625
- total: [{ $count: "count" }]
626
- } }];
627
- const aggregation = Model.aggregate(facetPipeline);
628
- if (options.session) aggregation.session(options.session);
629
- const [result] = await aggregation.exec();
630
- const data = result.data || [];
631
- const total = result.total[0]?.count || 0;
724
+ const run = (stages) => {
725
+ const aggregation = Model.aggregate(stages);
726
+ if (options.session) aggregation.session(options.session);
727
+ return aggregation.exec();
728
+ };
729
+ const [dataRows, countRows] = await Promise.all([run([
730
+ ...pipeline,
731
+ { $skip: skip },
732
+ { $limit: limit }
733
+ ]), run([...pipeline, { $count: "count" }])]);
734
+ const data = dataRows || [];
735
+ const total = countRows[0]?.count || 0;
632
736
  const pages = Math.ceil(total / limit);
633
737
  return {
634
738
  data,
@@ -748,57 +852,6 @@ async function minMax(Model, field, query = {}, options = {}) {
748
852
  };
749
853
  }
750
854
  //#endregion
751
- //#region src/actions/create.ts
752
- var create_exports = /* @__PURE__ */ __exportAll({
753
- create: () => create,
754
- createDefault: () => createDefault,
755
- createMany: () => createMany,
756
- upsert: () => upsert
757
- });
758
- /**
759
- * Create single document
760
- */
761
- async function create(Model, data, options = {}) {
762
- const document = new Model(data);
763
- await document.save({ session: options.session });
764
- return document;
765
- }
766
- /**
767
- * Create multiple documents
768
- */
769
- async function createMany(Model, dataArray, options = {}) {
770
- return Model.insertMany(dataArray, {
771
- session: options.session,
772
- ordered: options.ordered === true
773
- });
774
- }
775
- /**
776
- * Create with defaults (useful for initialization)
777
- */
778
- async function createDefault(Model, overrides = {}, options = {}) {
779
- const defaults = {};
780
- Model.schema.eachPath((path, schemaType) => {
781
- const schemaOptions = schemaType.options;
782
- if (schemaOptions.default !== void 0 && path !== "_id") defaults[path] = typeof schemaOptions.default === "function" ? schemaOptions.default() : schemaOptions.default;
783
- });
784
- return create(Model, {
785
- ...defaults,
786
- ...overrides
787
- }, options);
788
- }
789
- /**
790
- * Upsert (create or update)
791
- */
792
- async function upsert(Model, query, data, options = {}) {
793
- return Model.findOneAndUpdate(query, { $setOnInsert: data }, {
794
- upsert: true,
795
- returnDocument: "after",
796
- runValidators: true,
797
- session: options.session,
798
- ...options.updatePipeline !== void 0 ? { updatePipeline: options.updatePipeline } : {}
799
- });
800
- }
801
- //#endregion
802
855
  //#region src/actions/delete.ts
803
856
  var delete_exports = /* @__PURE__ */ __exportAll({
804
857
  deleteById: () => deleteById,
@@ -813,8 +866,8 @@ var delete_exports = /* @__PURE__ */ __exportAll({
813
866
  */
814
867
  async function deleteById(Model, id, options = {}) {
815
868
  const query = {
816
- _id: id,
817
- ...options.query
869
+ ...options.query,
870
+ _id: id
818
871
  };
819
872
  if (!await Model.findOneAndDelete(query).session(options.session ?? null)) return null;
820
873
  return {
@@ -1053,8 +1106,8 @@ function parsePopulate(populate) {
1053
1106
  async function update(Model, id, data, options = {}) {
1054
1107
  assertUpdatePipelineAllowed(data, options.updatePipeline);
1055
1108
  const query = {
1056
- _id: id,
1057
- ...options.query
1109
+ ...options.query,
1110
+ _id: id
1058
1111
  };
1059
1112
  return await Model.findOneAndUpdate(query, data, {
1060
1113
  returnDocument: "after",
@@ -1071,8 +1124,8 @@ async function update(Model, id, data, options = {}) {
1071
1124
  async function updateWithConstraints(Model, id, data, constraints = {}, options = {}) {
1072
1125
  assertUpdatePipelineAllowed(data, options.updatePipeline);
1073
1126
  const query = {
1074
- _id: id,
1075
- ...constraints
1127
+ ...constraints,
1128
+ _id: id
1076
1129
  };
1077
1130
  return await Model.findOneAndUpdate(query, data, {
1078
1131
  returnDocument: "after",
@@ -1097,8 +1150,8 @@ async function updateWithValidation(Model, id, data, validationOptions = {}, opt
1097
1150
  };
1098
1151
  }
1099
1152
  const findQuery = {
1100
- _id: id,
1101
- ...options.query
1153
+ ...options.query,
1154
+ _id: id
1102
1155
  };
1103
1156
  const existing = await Model.findOne(findQuery).select(options.select || "").session(options.session ?? null).lean();
1104
1157
  if (!existing) return {
@@ -1204,4 +1257,4 @@ async function pullFromArray(Model, id, field, value, options = {}) {
1204
1257
  return update(Model, id, { $pull: { [field]: value } }, options);
1205
1258
  }
1206
1259
  //#endregion
1207
- export { upsert as _, count as a, LookupBuilder as b, getByQuery as c, deleteById as d, deleteByQuery as f, create_exports as g, createMany as h, update_exports as i, getOrCreate as l, create as m, update as n, exists as o, delete_exports as p, updateByQuery as r, getById as s, findOneAndUpdate as t, read_exports as u, aggregate_exports as v, compileFilterToMongo as x, distinct as y };
1260
+ export { upsert as S, compileFilterToMongo as _, count as a, create_exports as b, getByQuery as c, deleteById as d, deleteByQuery as f, LookupBuilder as g, distinct as h, update_exports as i, getOrCreate as l, aggregate_exports as m, update as n, exists as o, delete_exports as p, updateByQuery as r, getById as s, findOneAndUpdate as t, read_exports as u, create as v, isCreateManyPartialError as x, createMany as y };
@@ -1,4 +1,4 @@
1
- import { _ as getMongooseProjection, a as createMemoryCache, b as createError, c as IdType, d as isObjectIdInstance, f as isValidIdForType, g as getFieldsForUser, h as filterResponseData, i as buildCrudSchemasFromMongooseSchema, l as getSchemaIdType, m as createFieldPreset, n as CrudSchemasFramework, o as LoggerConfig, p as toObjectId, r as buildCrudSchemasFromModel, s as configureLogger, t as toPlain, v as CreateErrorOptions, y as ParseDuplicateKeyOptions } from "../to-plain-Dyz_qvuo.mjs";
1
+ import { _ as getMongooseProjection, a as createMemoryCache, b as createError, c as IdType, d as isObjectIdInstance, f as isValidIdForType, g as getFieldsForUser, h as filterResponseData, i as buildCrudSchemasFromMongooseSchema, l as getSchemaIdType, m as createFieldPreset, n as CrudSchemasFramework, o as LoggerConfig, p as toObjectId, r as buildCrudSchemasFromModel, s as configureLogger, t as toPlain, v as CreateErrorOptions, y as ParseDuplicateKeyOptions } from "../to-plain-B8ufk3Qz.mjs";
2
2
  import { ClientSession, Model } from "mongoose";
3
3
  //#region src/utils/id-chunks.d.ts
4
4
  /**
@@ -1,6 +1,6 @@
1
1
  import { n as collectIds, r as idChunks, t as DEFAULT_ID_CHUNK } from "./id-chunks-BmAwsqNk.mjs";
2
- import { c as ALL_OPERATIONS, u as OP_REGISTRY } from "./Repository-BArvU32x.mjs";
3
- import { _ as upsert } from "./update-jSXp3nu3.mjs";
2
+ import { c as ALL_OPERATIONS, u as OP_REGISTRY } from "./Repository-D7fDpyFx.mjs";
3
+ import { S as upsert } from "./update-c_01A0if.mjs";
4
4
  import { n as warn } from "./logger-K7dPmu2U.mjs";
5
5
  import { t as createError } from "./error-BuByZwKU.mjs";
6
6
  import { r as getFieldsForUser } from "./field-selection-FSQFLpsU.mjs";