@classytic/mongokit 3.39.0 → 3.40.1

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/README.md +71 -0
  2. package/dist/{PaginationEngine-D-P19Y9m.d.mts → PaginationEngine-CRHyuUtw.d.mts} +17 -3
  3. package/dist/{PaginationEngine-DQ7beTCR.mjs → PaginationEngine-CfT2Cxkj.mjs} +320 -145
  4. package/dist/{Repository-D7fDpyFx.mjs → Repository-CYhSC9ut.mjs} +844 -794
  5. package/dist/{Repository-IxI983_m.d.mts → Repository-pnLw1rG5.d.mts} +113 -47
  6. package/dist/actions/index.d.mts +1 -1
  7. package/dist/actions/index.mjs +1 -1
  8. package/dist/adapter/index.mjs +1 -1
  9. package/dist/{adapter-BrVfPFSW.mjs → adapter-CkMoMF83.mjs} +1 -1
  10. package/dist/ai/index.d.mts +1 -1
  11. package/dist/better-auth/index.mjs +2 -2
  12. package/dist/{create-repository-CRXjDa3N.d.mts → create-repository-BUx9vfG9.d.mts} +5 -5
  13. package/dist/{create-repository-DOe9E_5F.mjs → create-repository-BYyWzLbt.mjs} +2 -2
  14. package/dist/idempotency/index.d.mts +23 -0
  15. package/dist/idempotency/index.mjs +134 -0
  16. package/dist/{index-CmjNyulu.d.mts → index-SYF8N28D.d.mts} +37 -19
  17. package/dist/index.d.mts +105 -39
  18. package/dist/index.mjs +332 -676
  19. package/dist/indexes/verify-indexes.d.mts +14 -1
  20. package/dist/indexes/verify-indexes.mjs +54 -7
  21. package/dist/join-scope-DSAznE8E.mjs +168 -0
  22. package/dist/{mongooseToJsonSchema-waUVQwK4.mjs → mongooseToJsonSchema-C3k4ide0.mjs} +5 -1
  23. package/dist/{multi-tenant.plugin-B-tzNeYL.d.mts → multi-tenant.plugin-a3UhQARj.d.mts} +19 -3
  24. package/dist/{operations-CrRIoc8Z.d.mts → operations-XiFgG5US.d.mts} +36 -13
  25. package/dist/outbox/index.d.mts +1 -1
  26. package/dist/outbox/index.mjs +6 -4
  27. package/dist/pagination/PaginationEngine.d.mts +2 -2
  28. package/dist/pagination/PaginationEngine.mjs +1 -1
  29. package/dist/pagination/utils/limits.d.mts +1 -1
  30. package/dist/{pagination-BpIv2bdS.d.mts → pagination-D6psses0.d.mts} +116 -13
  31. package/dist/{plugin-options-BuvtOSZf.d.mts → plugin-options-CRhIvSzx.d.mts} +16 -2
  32. package/dist/plugins/index.d.mts +8 -3
  33. package/dist/plugins/index.mjs +98 -25
  34. package/dist/query-defaults-BxUcSOoF.mjs +208 -0
  35. package/dist/read-model/index.d.mts +66 -0
  36. package/dist/read-model/index.mjs +193 -0
  37. package/dist/{repository-ZbLtL7Nk.d.mts → repository-DNHfjAZX.d.mts} +36 -4
  38. package/dist/sync/index.d.mts +53 -0
  39. package/dist/sync/index.mjs +371 -0
  40. package/dist/tenant/index.mjs +2 -0
  41. package/dist/testkit/index.d.mts +40 -3
  42. package/dist/testkit/index.mjs +113 -2
  43. package/dist/{timestamp.plugin-B5eiaItQ.mjs → timestamp.plugin-zfdLYZiO.mjs} +135 -32
  44. package/dist/{to-plain-B8ufk3Qz.d.mts → to-plain-CjgEInWV.d.mts} +2 -2
  45. package/dist/transaction-BrRYtH_b.mjs +655 -0
  46. package/dist/{update-c_01A0if.mjs → update-WgeAUJ_I.mjs} +212 -33
  47. package/dist/utils/index.d.mts +1 -1
  48. package/dist/utils/index.mjs +1 -1
  49. package/dist/{validation-chain.plugin-Bju1D1Sn.mjs → validation-chain.plugin-CHUHWMtk.mjs} +204 -279
  50. package/dist/{validation-chain.plugin-DvrLJIbJ.d.mts → validation-chain.plugin-CTcNJCmV.d.mts} +27 -5
  51. package/package.json +18 -4
@@ -1,5 +1,6 @@
1
- import { c as ALL_OPERATIONS, t as HOOK_PRIORITY, u as OP_REGISTRY } from "./Repository-D7fDpyFx.mjs";
1
+ import { i as ALL_OPERATIONS, o as OP_REGISTRY, t as HOOK_PRIORITY } from "./Repository-CYhSC9ut.mjs";
2
2
  import { n as warn } from "./logger-K7dPmu2U.mjs";
3
+ import { n as declareCollectionScope } from "./join-scope-DSAznE8E.mjs";
3
4
  import { throwIfAborted, withRetry } from "@classytic/repo-core/repository";
4
5
  import mongoose from "mongoose";
5
6
  import { cachePlugin } from "@classytic/repo-core/cache";
@@ -754,6 +755,33 @@ async function getNextSequence(counterKey, increment = 1, connection, session) {
754
755
  return result.seq;
755
756
  }
756
757
  /**
758
+ * Current value of a counter, or `null` when it has never been created.
759
+ * `null` and `0` differ: only a missing counter needs seeding.
760
+ */
761
+ async function readSequence(counterKey, connection) {
762
+ const doc = await getCounterModel(connection).findById(counterKey).lean();
763
+ return doc ? doc.seq : null;
764
+ }
765
+ /**
766
+ * Raise a counter to at least `floor` — never lowers it. Atomic (`$max` +
767
+ * upsert), so concurrent seeders converge on the highest floor.
768
+ *
769
+ * Use before a counter's first increment when ids were already issued under
770
+ * another key (e.g. moving from a global to a per-tenant sequence); otherwise
771
+ * the new counter restarts at 1 and re-issues taken ids.
772
+ *
773
+ * @returns The counter's value after the raise.
774
+ */
775
+ async function ensureSequenceAtLeast(counterKey, floor, connection) {
776
+ if (!Number.isSafeInteger(floor) || floor < 0) throw new Error(`ensureSequenceAtLeast: floor must be a non-negative integer, got ${floor}`);
777
+ const result = await getCounterModel(connection).findOneAndUpdate({ _id: counterKey }, { $max: { seq: floor } }, {
778
+ upsert: true,
779
+ returnDocument: "after"
780
+ });
781
+ if (!result) throw new Error(`Failed to seed counter '${counterKey}'`);
782
+ return result.seq;
783
+ }
784
+ /**
757
785
  * Generator: Simple sequential counter.
758
786
  * Produces IDs like `INV-0001`, `INV-0002`, etc.
759
787
  *
@@ -930,6 +958,62 @@ function customIdPlugin(options) {
930
958
  };
931
959
  }
932
960
  //#endregion
961
+ //#region src/utils/scope.ts
962
+ /** Tenant context keys forwarded even when no tenant plugin declared one. */
963
+ const CONVENTIONAL_TENANT_KEYS = ["organizationId", "tenantId"];
964
+ const ATTRIBUTION_KEYS = [
965
+ "session",
966
+ "user",
967
+ "userId",
968
+ "requestId"
969
+ ];
970
+ /** Where a repository records the tenant context keys its plugins read. */
971
+ const TENANT_KEYS = Symbol.for("@classytic/mongokit/tenant-context-keys");
972
+ /**
973
+ * Record that `repo` resolves its tenant from `contextKey`. Called by
974
+ * `multiTenantPlugin` at bind, so forwarding knows a custom key (`branchId`)
975
+ * without the host restating it.
976
+ */
977
+ function declareTenantContextKey(repo, contextKey) {
978
+ const holder = repo;
979
+ if (!holder[TENANT_KEYS]) Object.defineProperty(holder, TENANT_KEYS, {
980
+ value: /* @__PURE__ */ new Set(),
981
+ enumerable: false
982
+ });
983
+ holder[TENANT_KEYS]?.add(contextKey);
984
+ }
985
+ /** Every tenant context key relevant to a call spanning these repositories. */
986
+ function tenantContextKeysOf(...repos) {
987
+ const keys = new Set(CONVENTIONAL_TENANT_KEYS);
988
+ for (const repo of repos) for (const key of repo?.[TENANT_KEYS] ?? []) keys.add(key);
989
+ return [...keys];
990
+ }
991
+ /**
992
+ * The scope to hand a second repository call made on behalf of `source`
993
+ * (a hook's `RepositoryContext`, or a host's request context).
994
+ *
995
+ * Absent values are omitted, never written as `undefined`, so spreading the
996
+ * result into an options bag cannot erase a value set there.
997
+ */
998
+ function forwardScope(source, tenantKeys = CONVENTIONAL_TENANT_KEYS) {
999
+ if (!source) return {};
1000
+ const out = {};
1001
+ let hasTenant = false;
1002
+ for (const key of tenantKeys) {
1003
+ const value = source[key];
1004
+ if (value === void 0) continue;
1005
+ out[key] = value;
1006
+ if (value !== null && value !== "") hasTenant = true;
1007
+ }
1008
+ if (!hasTenant && source.bypassTenant === true) out.bypassTenant = true;
1009
+ for (const key of ATTRIBUTION_KEYS) if (source[key] !== void 0) out[key] = source[key];
1010
+ return out;
1011
+ }
1012
+ /** Every key `forwardScope` governs. Other forwarding helpers must not copy these themselves. */
1013
+ function isScopeKey(key, tenantKeys = CONVENTIONAL_TENANT_KEYS) {
1014
+ return key === "bypassTenant" || tenantKeys.includes(key) || ATTRIBUTION_KEYS.includes(key);
1015
+ }
1016
+ //#endregion
933
1017
  //#region src/plugins/multi-tenant.plugin.ts
934
1018
  /**
935
1019
  * Multi-Tenant Plugin
@@ -983,6 +1067,42 @@ function multiTenantPlugin(options = {}) {
983
1067
  return {
984
1068
  name: "multi-tenant",
985
1069
  apply(repo) {
1070
+ declareTenantContextKey(repo, contextKey);
1071
+ /** The one tenant decision, shared by the hook and the join rule below. */
1072
+ const decide = (context, op) => {
1073
+ if (context.bypassTenant === true) return { bypass: "option" };
1074
+ if (skipWhen?.(context, op) === true) return { bypass: "callback" };
1075
+ let tenantId = context[contextKey];
1076
+ if (!tenantId && resolveContext) tenantId = resolveContext();
1077
+ return { tenantId };
1078
+ };
1079
+ const castTenant = (tenantId) => fieldType === "objectId" ? new mongoose.Types.ObjectId(tenantId) : tenantId;
1080
+ const missingTenant = (op) => new Error([
1081
+ `[mongokit] Multi-tenant: Missing '${contextKey}' in context for '${op}'.`,
1082
+ "",
1083
+ "Three ways to provide it — pick the one that fits the call site:",
1084
+ "",
1085
+ ` 1. Per-call: repo.${op}(..., { ${contextKey}: '<id>' })`,
1086
+ " 2. Ambient (ALS): `createTenantContext()` + `resolveContext` —",
1087
+ " wrap the request in `tenantContext.run({ tenantId }, fn)`,",
1088
+ " then every repo call inside the closure inherits the",
1089
+ " tenant with no options-bag plumbing. See",
1090
+ " `createTenantContext` in @classytic/mongokit.",
1091
+ " 3. Cross-tenant: `bypassTenant: true` per-call (admin / migration /",
1092
+ " support), or `skipWhen: adminBypass({...})` at plugin",
1093
+ " construction for an always-on role-based bypass.",
1094
+ "",
1095
+ "Or set `required: false` if this collection is genuinely not tenant-scoped."
1096
+ ].join("\n"));
1097
+ if (repo.Model) declareCollectionScope(repo.Model, (context, op) => {
1098
+ const d = decide(context, op);
1099
+ if ("bypass" in d) return void 0;
1100
+ if (!d.tenantId) {
1101
+ if (required) throw missingTenant(op);
1102
+ return;
1103
+ }
1104
+ return { [tenantField]: castTenant(d.tenantId) };
1105
+ }, { tenantField });
986
1106
  const allOps = [...ALL_OPERATIONS.map((op) => ({
987
1107
  op,
988
1108
  policyKey: OP_REGISTRY[op].policyKey
@@ -991,41 +1111,21 @@ function multiTenantPlugin(options = {}) {
991
1111
  if (skipOperations.includes(op)) continue;
992
1112
  if (policyKey === "none") continue;
993
1113
  repo.on(`before:${op}`, (context) => {
994
- const perCallBypass = context.bypassTenant === true;
995
- const callbackBypass = !perCallBypass && skipWhen?.(context, op) === true;
996
- if (perCallBypass || callbackBypass) {
1114
+ const decision = decide(context, op);
1115
+ if ("bypass" in decision) {
997
1116
  repo.emit("after:tenant-bypass", {
998
1117
  context,
999
1118
  operation: op,
1000
- reason: perCallBypass ? "option" : "callback"
1119
+ reason: decision.bypass
1001
1120
  });
1002
1121
  return;
1003
1122
  }
1004
- let tenantId = context[contextKey];
1005
- if (!tenantId && resolveContext) {
1006
- tenantId = resolveContext();
1007
- if (tenantId) context[contextKey] = tenantId;
1008
- }
1123
+ const tenantId = decision.tenantId;
1124
+ if (tenantId && !context[contextKey]) context[contextKey] = tenantId;
1009
1125
  if (!tenantId && allowDataInjection && payloadHasTenantField(context, policyKey, tenantField)) return;
1010
- if (!tenantId && required) throw new Error([
1011
- `[mongokit] Multi-tenant: Missing '${contextKey}' in context for '${op}'.`,
1012
- "",
1013
- "Three ways to provide it — pick the one that fits the call site:",
1014
- "",
1015
- ` 1. Per-call: repo.${op}(..., { ${contextKey}: '<id>' })`,
1016
- " 2. Ambient (ALS): `createTenantContext()` + `resolveContext` —",
1017
- " wrap the request in `tenantContext.run({ tenantId }, fn)`,",
1018
- " then every repo call inside the closure inherits the",
1019
- " tenant with no options-bag plumbing. See",
1020
- " `createTenantContext` in @classytic/mongokit.",
1021
- " 3. Cross-tenant: `bypassTenant: true` per-call (admin / migration /",
1022
- " support), or `skipWhen: adminBypass({...})` at plugin",
1023
- " construction for an always-on role-based bypass.",
1024
- "",
1025
- "Or set `required: false` if this collection is genuinely not tenant-scoped."
1026
- ].join("\n"));
1126
+ if (!tenantId && required) throw missingTenant(op);
1027
1127
  if (!tenantId) return;
1028
- const castId = fieldType === "objectId" ? new mongoose.Types.ObjectId(tenantId) : tenantId;
1128
+ const castId = castTenant(tenantId);
1029
1129
  const assertTenantValue = (supplied, where) => {
1030
1130
  if (onMismatch === "overwrite") return;
1031
1131
  if (supplied === void 0 || supplied === null) return;
@@ -1234,6 +1334,7 @@ function softDeletePlugin(options = {}) {
1234
1334
  return {
1235
1335
  name: "softDelete",
1236
1336
  apply(repo) {
1337
+ if (repo.Model) declareCollectionScope(repo.Model, () => buildDeletedFilter(deletedField, filterMode, false));
1237
1338
  try {
1238
1339
  const schemaPaths = repo.Model.schema.paths;
1239
1340
  if (!schemaPaths[deletedField]) warn(`[softDeletePlugin] Model '${repo.Model.modelName}' has no '${deletedField}' schema path. Soft-delete marks documents by \`$set\`-ing '${deletedField}', but Mongoose strict mode drops updates to undeclared paths — so delete() will SILENTLY no-op (documents are never soft-deleted). Declare it on the schema: { ${deletedField}: { type: Date, default: ${filterMode === "null" ? "null" : "undefined"} } }`);
@@ -1294,7 +1395,8 @@ function softDeletePlugin(options = {}) {
1294
1395
  const SOFT_DELETE_SPECIAL = /* @__PURE__ */ new Set([
1295
1396
  "updateMany",
1296
1397
  "deleteMany",
1297
- "bulkWrite"
1398
+ "bulkWrite",
1399
+ "bulkUpsert"
1298
1400
  ]);
1299
1401
  for (const op of ALL_OPERATIONS) {
1300
1402
  if (SOFT_DELETE_SPECIAL.has(op)) continue;
@@ -1325,7 +1427,7 @@ function softDeletePlugin(options = {}) {
1325
1427
  * `deleteMode: 'hard'` still bypasses, exactly as the single-doc path does — a purge
1326
1428
  * is a deliberate physical delete, and it reaches the driver directly anyway.
1327
1429
  */
1328
- repo.on("before:bulkWrite", (context) => {
1430
+ const scopeSubOps = (context) => {
1329
1431
  if (options.soft === false || context.deleteMode === "hard") return;
1330
1432
  const ops = context.operations;
1331
1433
  if (!Array.isArray(ops)) return;
@@ -1356,7 +1458,8 @@ function softDeletePlugin(options = {}) {
1356
1458
  delete subOp[key];
1357
1459
  }
1358
1460
  }
1359
- }, { priority: HOOK_PRIORITY.POLICY });
1461
+ };
1462
+ for (const op of ["bulkWrite", "bulkUpsert"]) repo.on(`before:${op}`, scopeSubOps, { priority: HOOK_PRIORITY.POLICY });
1360
1463
  repo.on("before:deleteMany", async (context) => {
1361
1464
  if (context.deleteMode === "hard") return;
1362
1465
  if (options.soft !== false) {
@@ -1500,4 +1603,4 @@ function timestampPlugin() {
1500
1603
  };
1501
1604
  }
1502
1605
  //#endregion
1503
- export { customIdPlugin as a, prefixedId as c, AuditTrailQuery as d, auditTrailPlugin as f, multiTenantPlugin as i, sequentialId as l, auditLogPlugin as m, softDeletePlugin as n, dateSequentialId as o, ensureAuditTrailReady as p, adminBypass$1 as r, getNextSequence as s, timestampPlugin as t, cachePlugin as u };
1606
+ export { AuditTrailQuery as _, declareTenantContextKey as a, auditLogPlugin as b, tenantContextKeysOf as c, ensureSequenceAtLeast as d, getNextSequence as f, cachePlugin as g, sequentialId as h, multiTenantPlugin as i, customIdPlugin as l, readSequence as m, softDeletePlugin as n, forwardScope as o, prefixedId as p, adminBypass$1 as r, isScopeKey as s, timestampPlugin as t, dateSequentialId as u, auditTrailPlugin as v, ensureAuditTrailReady as y };
@@ -1,5 +1,5 @@
1
- import { v as UserContext } from "./repository-ZbLtL7Nk.mjs";
2
- import { r as FieldPreset } from "./plugin-options-BuvtOSZf.mjs";
1
+ import { v as UserContext } from "./repository-DNHfjAZX.mjs";
2
+ import { r as FieldPreset } from "./plugin-options-CRhIvSzx.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";