@classytic/mongokit 3.39.0 → 3.40.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/dist/{PaginationEngine-DQ7beTCR.mjs → PaginationEngine-Dvl8jcRY.mjs} +33 -17
  2. package/dist/{Repository-D7fDpyFx.mjs → Repository-BMgPSkqZ.mjs} +279 -625
  3. package/dist/{Repository-IxI983_m.d.mts → Repository-CR8Y9g2E.d.mts} +57 -32
  4. package/dist/actions/index.mjs +1 -1
  5. package/dist/ai/index.d.mts +1 -1
  6. package/dist/better-auth/index.mjs +1 -1
  7. package/dist/{create-repository-DOe9E_5F.mjs → create-repository-BzBUMvd1.mjs} +2 -2
  8. package/dist/{create-repository-CRXjDa3N.d.mts → create-repository-CAle1MSU.d.mts} +4 -4
  9. package/dist/idempotency/index.d.mts +23 -0
  10. package/dist/idempotency/index.mjs +134 -0
  11. package/dist/index.d.mts +58 -32
  12. package/dist/index.mjs +285 -675
  13. package/dist/indexes/verify-indexes.d.mts +14 -1
  14. package/dist/indexes/verify-indexes.mjs +54 -7
  15. package/dist/{multi-tenant.plugin-B-tzNeYL.d.mts → multi-tenant.plugin-CBgCo9im.d.mts} +18 -2
  16. package/dist/outbox/index.d.mts +1 -1
  17. package/dist/outbox/index.mjs +6 -4
  18. package/dist/pagination/PaginationEngine.mjs +1 -1
  19. package/dist/{plugin-options-BuvtOSZf.d.mts → plugin-options-Zk46lD6F.d.mts} +15 -1
  20. package/dist/plugins/index.d.mts +8 -3
  21. package/dist/plugins/index.mjs +98 -25
  22. package/dist/{repository-ZbLtL7Nk.d.mts → repository-odl03582.d.mts} +20 -2
  23. package/dist/sync/index.d.mts +40 -0
  24. package/dist/sync/index.mjs +334 -0
  25. package/dist/tenant/index.mjs +2 -0
  26. package/dist/testkit/index.d.mts +2 -2
  27. package/dist/testkit/index.mjs +1 -1
  28. package/dist/{timestamp.plugin-B5eiaItQ.mjs → timestamp.plugin-DF0gJB6p.mjs} +86 -2
  29. package/dist/{to-plain-B8ufk3Qz.d.mts → to-plain-k7SjNlSS.d.mts} +2 -2
  30. package/dist/transaction-B5L9glO0.mjs +533 -0
  31. package/dist/{update-c_01A0if.mjs → update-DG0fCwRJ.mjs} +168 -11
  32. package/dist/utils/index.d.mts +1 -1
  33. package/dist/{validation-chain.plugin-Bju1D1Sn.mjs → validation-chain.plugin-BLY2gLEk.mjs} +192 -279
  34. package/dist/{validation-chain.plugin-DvrLJIbJ.d.mts → validation-chain.plugin-DYp9E7K2.d.mts} +25 -3
  35. package/package.json +13 -4
@@ -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(" — ")}`;
@@ -1,5 +1,5 @@
1
1
  import { g as ObjectId } from "./pagination-BpIv2bdS.mjs";
2
- import { l as Plugin, p as RepositoryContext } from "./repository-ZbLtL7Nk.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,6 +307,22 @@ 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;
@@ -638,4 +654,4 @@ declare function adminBypass(options: {
638
654
  adminRoles: readonly string[];
639
655
  }): (context: RepositoryContext, operation: string) => boolean;
640
656
  //#endregion
641
- 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,4 +1,4 @@
1
- import { n as Repository } from "../Repository-IxI983_m.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,4 +1,4 @@
1
- import { n as Repository } from "../Repository-D7fDpyFx.mjs";
1
+ import { n as Repository } from "../Repository-BMgPSkqZ.mjs";
2
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
@@ -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-DQ7beTCR.mjs";
1
+ import { t as PaginationEngine } from "../PaginationEngine-Dvl8jcRY.mjs";
2
2
  export { PaginationEngine };
@@ -1,5 +1,5 @@
1
1
  import { _ as PopulateSpec, g as ObjectId, x as SortSpec, y as SelectSpec } from "./pagination-BpIv2bdS.mjs";
2
- import { h as RepositoryInstance, p as RepositoryContext } from "./repository-ZbLtL7Nk.mjs";
2
+ import { h as RepositoryInstance, p as RepositoryContext } from "./repository-odl03582.mjs";
3
3
  import { OffsetPaginationResult } from "@classytic/repo-core/pagination";
4
4
  //#region src/types/plugin-options.d.ts
5
5
  /** Field preset configuration (field-filter plugin) */
@@ -69,6 +69,9 @@ interface SoftDeleteRepository<TDoc = unknown> {
69
69
  */
70
70
  restore(id: string | ObjectId, options?: {
71
71
  session?: unknown;
72
+ organizationId?: unknown;
73
+ bypassTenant?: boolean;
74
+ [key: string]: unknown;
72
75
  }): Promise<TDoc>;
73
76
  /**
74
77
  * Get all soft-deleted documents
@@ -109,6 +112,17 @@ interface CascadeRelation {
109
112
  model?: string;
110
113
  /** Foreign key field in the related model that references the deleted document */
111
114
  foreignKey: string;
115
+ /**
116
+ * WHICH parent field `foreignKey` stores. Default: the parent repository's
117
+ * `idField` (`_id` unless the repository declares another).
118
+ *
119
+ * Set it when the two differ. An order repository addressed by `orderNumber`
120
+ * whose children store the order's `_id` declares `parentKey: '_id'`;
121
+ * without it, `delete('ORD-2026-0001')` cascaded with the order NUMBER and
122
+ * every child delete failed on the ObjectId cast. The cascade reads the
123
+ * parent's value for this field before the delete, under the same session.
124
+ */
125
+ parentKey?: string;
112
126
  /** Whether to use soft delete if available (default: follows parent behavior) */
113
127
  softDelete?: boolean;
114
128
  /**
@@ -1,6 +1,6 @@
1
- import { S as RepositoryPluginName, b as MONGOKIT_PLUGIN_NAMES, l as Plugin, x as MongoKitPluginName } from "../repository-ZbLtL7Nk.mjs";
2
- import { A as ensureAuditTrailReady, C as AuditTrailMethods, D as AuditTrailQueryOptions, E as AuditTrailQuery, O as EnsureAuditTrailReadyOptions, S as AuditQueryResult, T as AuditTrailOptions, _ as RepositoryCachePluginOptions, a as DateSequentialIdOptions, b as AuditOperation, c as SequentialIdOptions, d as getNextSequence, f as prefixedId, g as RepositoryCacheHandle, h as CacheOptions, i as CustomIdOptions, k as auditTrailPlugin, l as customIdPlugin, m as CacheAdapter, o as IdGenerator, p as sequentialId, r as multiTenantPlugin, s as PrefixedIdOptions, t as MultiTenantOptions, u as dateSequentialId, v as cachePlugin, w as AuditTrailMode, x as AuditQueryOptions, y as AuditEntry } from "../multi-tenant.plugin-B-tzNeYL.mjs";
3
- import { A as IdempotencyStoreMethods, B as auditLogPlugin, C as LeaseMethods, D as ImmutableStatesPluginOptions, E as ImmutableClaimView, F as elasticSearchPlugin, H as appendOnlyPlugin, I as cascadePlugin, L as BatchOperationsMethods, M as idempotencyStorePlugin, N as fieldFilterPlugin, O as immutableStatesPlugin, P as ElasticSearchOptions, S as methodRegistryPlugin, T as leasePlugin, U as AggregateHelpersMethods, V as AppendOnlyPluginOptions, W as aggregateHelpersPlugin, _ as OperationMetric, a as requireField, b as mongoOperationsPlugin, c as timestampPlugin, d as createTenantContext, f as SubdocumentMethods, g as ObservabilityOptions, h as softDeletePlugin, i as immutableField, j as IdempotencyStorePluginOptions, k as IdempotencyClaim, l as TenantContext, m as SoftDeleteMethods, n as autoInject, o as uniqueField, p as subdocumentPlugin, r as blockIf, s as validationChainPlugin, t as OperationType, u as TenantStore, v as observabilityPlugin, w as LeasePluginOptions, x as MethodRegistryRepository, y as MongoOperationsMethods, z as batchOperationsPlugin } from "../validation-chain.plugin-DvrLJIbJ.mjs";
1
+ import { S as RepositoryPluginName, b as MONGOKIT_PLUGIN_NAMES, l as Plugin, x as MongoKitPluginName } from "../repository-odl03582.mjs";
2
+ import { A as EnsureAuditTrailReadyOptions, C as AuditQueryOptions, D as AuditTrailOptions, E as AuditTrailMode, M as ensureAuditTrailReady, O as AuditTrailQuery, S as AuditOperation, T as AuditTrailMethods, _ as CacheOptions, a as DateSequentialIdOptions, b as cachePlugin, c as SequentialIdOptions, f as getNextSequence, g as CacheAdapter, h as sequentialId, i as CustomIdOptions, j as auditTrailPlugin, k as AuditTrailQueryOptions, l as customIdPlugin, o as IdGenerator, p as prefixedId, r as multiTenantPlugin, s as PrefixedIdOptions, t as MultiTenantOptions, u as dateSequentialId, v as RepositoryCacheHandle, w as AuditQueryResult, x as AuditEntry, y as RepositoryCachePluginOptions } from "../multi-tenant.plugin-CBgCo9im.mjs";
3
+ import { A as IdempotencyStoreMethods, B as batchOperationsPlugin, C as LeaseMethods, D as ImmutableStatesPluginOptions, E as ImmutableClaimView, F as elasticSearchPlugin, G as aggregateHelpersPlugin, H as AppendOnlyPluginOptions, I as cascadePlugin, L as BatchOperationsMethods, M as idempotencyStorePlugin, N as fieldFilterPlugin, O as immutableStatesPlugin, P as ElasticSearchOptions, S as methodRegistryPlugin, T as leasePlugin, U as appendOnlyPlugin, V as auditLogPlugin, W as AggregateHelpersMethods, _ as OperationMetric, a as requireField, b as mongoOperationsPlugin, c as timestampPlugin, d as createTenantContext, f as SubdocumentMethods, g as ObservabilityOptions, h as softDeletePlugin, i as immutableField, j as IdempotencyStorePluginOptions, k as IdempotencyClaim, l as TenantContext, m as SoftDeleteMethods, n as autoInject, o as uniqueField, p as subdocumentPlugin, r as blockIf, s as validationChainPlugin, t as OperationType, u as TenantStore, v as observabilityPlugin, w as LeasePluginOptions, x as MethodRegistryRepository, y as MongoOperationsMethods } from "../validation-chain.plugin-DYp9E7K2.mjs";
4
4
  import { ChangeLogStore } from "@classytic/repo-core/sync";
5
5
  //#region src/plugins/change-log.plugin.d.ts
6
6
  interface ChangeLogPluginOptions {
@@ -12,6 +12,11 @@ interface ChangeLogPluginOptions {
12
12
  tenantField?: string;
13
13
  /** Monotonic per-doc version field. Default `version` (kernel convention). */
14
14
  versionField?: string;
15
+ /**
16
+ * The document as a subscriber may see it — fields picked, secrets (a cost price) dropped. Tenant
17
+ * and version are still read from the whole document. Default: the whole document.
18
+ */
19
+ project?: (doc: Record<string, unknown>) => Record<string, unknown>;
15
20
  }
16
21
  declare function changeLogPlugin(options: ChangeLogPluginOptions): Plugin;
17
22
  //#endregion
@@ -1,14 +1,18 @@
1
- import { a as customIdPlugin, c as prefixedId, d as AuditTrailQuery, f as auditTrailPlugin, i as multiTenantPlugin, l as sequentialId, m as auditLogPlugin, n as softDeletePlugin, o as dateSequentialId, p as ensureAuditTrailReady, s as getNextSequence, t as timestampPlugin, u as cachePlugin } from "../timestamp.plugin-B5eiaItQ.mjs";
1
+ import { _ as AuditTrailQuery, b as auditLogPlugin, f as getNextSequence, g as cachePlugin, h as sequentialId, i as multiTenantPlugin, l as customIdPlugin, n as softDeletePlugin, p as prefixedId, t as timestampPlugin, u as dateSequentialId, v as auditTrailPlugin, y as ensureAuditTrailReady } from "../timestamp.plugin-DF0gJB6p.mjs";
2
2
  import { n as batchOperationsPlugin, t as methodRegistryPlugin } from "../method-registry.plugin-C63pGn1d.mjs";
3
- import { _ as cascadePlugin, a as uniqueField, c as subdocumentPlugin, d as mongoOperationsPlugin, f as leasePlugin, g as elasticSearchPlugin, h as fieldFilterPlugin, i as requireField, l as observabilityPlugin, m as idempotencyStorePlugin, n as blockIf, o as validationChainPlugin, p as immutableStatesPlugin, r as immutableField, s as createTenantContext, t as autoInject, u as MONGOKIT_PLUGIN_NAMES, v as appendOnlyPlugin, y as aggregateHelpersPlugin } from "../validation-chain.plugin-Bju1D1Sn.mjs";
3
+ import { _ as cascadePlugin, a as uniqueField, c as subdocumentPlugin, d as mongoOperationsPlugin, f as leasePlugin, g as elasticSearchPlugin, h as fieldFilterPlugin, i as requireField, l as observabilityPlugin, m as idempotencyStorePlugin, n as blockIf, o as validationChainPlugin, p as immutableStatesPlugin, r as immutableField, s as createTenantContext, t as autoInject, u as MONGOKIT_PLUGIN_NAMES, v as appendOnlyPlugin, y as aggregateHelpersPlugin } from "../validation-chain.plugin-BLY2gLEk.mjs";
4
4
  //#region src/plugins/change-log.plugin.ts
5
5
  const PLUGIN_NAME = "changeLog";
6
+ /** Marks a repository already captured: the store and scope it writes to. */
7
+ const CAPTURED = Symbol.for("mongokit.changeLog.capture");
8
+ /** Where `before:updateMany` / `before:deleteMany` leave the ids they matched for the after hook. */
9
+ const MATCHED_IDS = Symbol("changeLog.matchedIds");
6
10
  function isSkipped(context) {
7
11
  const list = context.skipPlugins;
8
12
  return Array.isArray(list) && list.includes(PLUGIN_NAME);
9
13
  }
10
14
  function changeLogPlugin(options) {
11
- const { store, scope, tenantField = "organizationId", versionField = "version" } = options;
15
+ const { store, scope, tenantField = "organizationId", versionField = "version", project } = options;
12
16
  const toRecord = (doc) => {
13
17
  if (!doc || typeof doc !== "object") return null;
14
18
  const d = doc;
@@ -21,51 +25,120 @@ function changeLogPlugin(options) {
21
25
  return updatedAt instanceof Date ? updatedAt.getTime() : 0;
22
26
  };
23
27
  const tenantOf = (doc, context) => {
24
- const raw = doc[tenantField] ?? context.organizationId;
28
+ const raw = doc?.[tenantField] ?? context.organizationId;
25
29
  return raw === void 0 || raw === null ? void 0 : String(raw);
26
30
  };
27
- const capture = async (op, doc, context) => {
31
+ const appendOptions = (context) => context.session !== void 0 ? { session: context.session } : void 0;
32
+ const upsert = async (doc, context) => {
28
33
  const record = toRecord(doc);
29
34
  if (!record || record._id === void 0) return;
30
35
  const tenantId = tenantOf(record, context);
31
36
  await store.append({
32
37
  scope,
33
38
  docId: String(record._id),
34
- op,
39
+ op: "upsert",
35
40
  version: versionOf(record),
36
- ...op === "upsert" ? { doc: record } : {},
41
+ doc: project ? project(record) : record,
37
42
  ...tenantId !== void 0 ? { tenantId } : {}
38
- }, context.session !== void 0 ? { session: context.session } : void 0);
43
+ }, appendOptions(context));
44
+ };
45
+ const tombstone = async (docId, context) => {
46
+ if (docId === void 0 || docId === null) return;
47
+ const tenantId = tenantOf(null, context);
48
+ await store.append({
49
+ scope,
50
+ docId: String(docId),
51
+ op: "delete",
52
+ version: 0,
53
+ ...tenantId !== void 0 ? { tenantId } : {}
54
+ }, appendOptions(context));
39
55
  };
40
56
  return {
41
57
  name: PLUGIN_NAME,
42
58
  apply(repo) {
59
+ const marked = repo;
60
+ const existing = marked[CAPTURED];
61
+ if (existing) {
62
+ if (existing.store === store && existing.scope === scope) return;
63
+ throw new Error(`[mongokit] changeLogPlugin is already capturing this repository as "${existing.scope}"`);
64
+ }
65
+ marked[CAPTURED] = {
66
+ store,
67
+ scope
68
+ };
69
+ /** The whole document as committed in this session — never a projection. */
70
+ const reread = async (id, context) => {
71
+ const query = repo.Model.findById(id).lean();
72
+ if (context.session !== void 0) query.session(context.session);
73
+ return query.exec();
74
+ };
75
+ const matchingIds = async (context, filter) => {
76
+ const query = repo.Model.find(filter ?? {}).select({ _id: 1 }).lean();
77
+ if (context.session !== void 0) query.session(context.session);
78
+ return (await query.exec()).map((d) => d._id);
79
+ };
43
80
  repo.on("after:create", async ({ context, result }) => {
44
- if (isSkipped(context) || !result) return;
45
- await capture("upsert", result, context);
81
+ if (!isSkipped(context) && result) await upsert(result, context);
46
82
  });
47
83
  repo.on("after:createMany", async ({ context, result }) => {
48
84
  if (isSkipped(context) || !Array.isArray(result)) return;
49
- for (const doc of result) await capture("upsert", doc, context);
85
+ for (const doc of result) await upsert(doc, context);
50
86
  });
51
87
  repo.on("after:update", async ({ context, result }) => {
52
- if (isSkipped(context) || !result) return;
53
- await capture("upsert", result, context);
88
+ if (!isSkipped(context) && result) await upsert(result, context);
89
+ });
90
+ for (const op of [
91
+ "claim",
92
+ "claimVersion",
93
+ "findOneAndUpdate",
94
+ "getOrCreate",
95
+ "restore"
96
+ ]) repo.on(`after:${op}`, async ({ context, result }) => {
97
+ if (isSkipped(context)) return;
98
+ const id = toRecord(result)?._id;
99
+ if (id === void 0) return;
100
+ const doc = await reread(id, context);
101
+ if (doc) await upsert(doc, context);
102
+ else await tombstone(id, context);
103
+ });
104
+ for (const op of ["updateMany", "deleteMany"]) repo.on(`before:${op}`, async (context) => {
105
+ if (!isSkipped(context)) context[MATCHED_IDS] = await matchingIds(context, context.query);
106
+ });
107
+ /** Each re-read document as an upsert, or a tombstone where it is gone. */
108
+ const capture = async (ids, context) => {
109
+ for (const id of ids) {
110
+ const doc = await reread(id, context);
111
+ if (doc) await upsert(doc, context);
112
+ else await tombstone(id, context);
113
+ }
114
+ };
115
+ repo.on("after:updateMany", async ({ context }) => {
116
+ if (!isSkipped(context)) await capture(context[MATCHED_IDS] ?? [], context);
117
+ });
118
+ repo.on("after:anonymize", async ({ context, result }) => {
119
+ if (!isSkipped(context)) await capture(result.ids, context);
120
+ });
121
+ repo.on("after:deleteMany", async ({ context }) => {
122
+ if (!isSkipped(context)) await capture(context[MATCHED_IDS] ?? [], context);
54
123
  });
55
124
  repo.on("after:delete", async ({ context, result }) => {
56
125
  if (isSkipped(context) || !result) return;
57
- const summary = result;
58
- const ctxId = context.id;
59
- const docId = summary.id ?? ctxId;
60
- if (docId === void 0 || docId === null) return;
61
- const tenantRaw = context.organizationId;
62
- await store.append({
63
- scope,
64
- docId: String(docId),
65
- op: "delete",
66
- version: 0,
67
- ...tenantRaw !== void 0 && tenantRaw !== null ? { tenantId: String(tenantRaw) } : {}
68
- }, context.session !== void 0 ? { session: context.session } : void 0);
126
+ await tombstone(result.id ?? context.id, context);
127
+ });
128
+ repo.on("before:bulkWrite", async (context) => {
129
+ if (isSkipped(context)) return;
130
+ const ids = [];
131
+ for (const op of context.operations ?? []) {
132
+ const [kind, spec] = Object.entries(op)[0] ?? [];
133
+ if (!kind || !spec) continue;
134
+ if (kind === "insertOne") ids.push(spec.document?._id);
135
+ else ids.push(...await matchingIds(context, spec.filter));
136
+ }
137
+ context[MATCHED_IDS] = ids.filter((id) => id !== void 0);
138
+ });
139
+ repo.on("after:bulkWrite", async ({ context, result }) => {
140
+ if (isSkipped(context)) return;
141
+ await capture([...context[MATCHED_IDS] ?? [], ...Object.values(result?.upsertedIds ?? {})], context);
69
142
  });
70
143
  }
71
144
  };
@@ -363,8 +363,23 @@ interface RepositoryContext {
363
363
  _cacheResolved?: unknown;
364
364
  /** Cache hit status (internal — `'fresh'` or `'stale'`) */
365
365
  _cacheStatus?: 'fresh' | 'stale' | 'miss' | 'disabled' | 'bypass';
366
- /** IDs to cascade delete (internal) */
367
- _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>;
368
383
  /** Custom context data from plugins */
369
384
  [key: string]: unknown;
370
385
  }
@@ -528,6 +543,9 @@ type AllPluginMethods<TDoc> = {
528
543
  deleteSubdocument(parentId: string | ObjectId, arrayPath: DocField<TDoc>, subId: string | ObjectId, options?: Record<string, unknown>): Promise<TDoc>;
529
544
  restore(id: string | ObjectId, options?: {
530
545
  session?: unknown;
546
+ organizationId?: unknown;
547
+ bypassTenant?: boolean;
548
+ [key: string]: unknown;
531
549
  }): Promise<TDoc>;
532
550
  getDeleted(params?: {
533
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 };