@classytic/mongokit 3.45.0 → 3.46.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.
package/README.md CHANGED
@@ -584,6 +584,25 @@ describeKernelConformance({
584
584
  });
585
585
  ```
586
586
 
587
+ ### Sharding readiness: `tenantUniques`
588
+
589
+ MongoDB refuses `shardCollection` on a tenant key while ANY unique index lacks that key as its
590
+ prefix, so one global unique index makes a collection unshardable by tenant. Supply
591
+ `tenantUniques` to enable `unique-indexes-tenant-prefixed`: the suite binds the kernel with
592
+ tenancy ON, reads every registered schema's indexes, and requires each unique one to lead with the
593
+ tenant field, or to appear in `allowGlobal` with a reason and a disposition for when the
594
+ deployment is sharded or split into pods (`shard-key-prefix`, `identity-registry`, `pod-local` or
595
+ `drop`). It also fails when no unique index was inspected, and on a stale or reasonless
596
+ `allowGlobal` entry.
597
+
598
+ ```ts
599
+ tenantUniques: {
600
+ tenantField: 'organizationId',
601
+ bindScoped: (conn) => kernel.defineParty({ tenant: { enabled: true }, autoIndex: false }).bind(conn, runtime),
602
+ allowGlobal: [{ index: 'party_site_slug', reason: 'public slug, no tenant', t2: 'identity-registry' }],
603
+ },
604
+ ```
605
+
587
606
  Check ids are exported as `KERNEL_CONFORMANCE_CHECKS` (a `const` tuple, so `skip` is a typed
588
607
  union — a mistyped id is a compile error, not an ineffective skip).
589
608
 
@@ -37,14 +37,16 @@ export declare const KERNEL_CONFORMANCE_CHECKS: readonly [
37
37
  /** §11.5 — an injected outbox that does not enlist `ctx.session` is REFUSED. (conditional) */
38
38
  'outbox-requires-transactional-save',
39
39
  /** §11.10 — a blueprint with optional modules off registers no extra models. (conditional) */
40
- 'optional-modules-register-no-models'];
40
+ 'optional-modules-register-no-models',
41
+ /** scale D7 — every unique index leads with the tenant field, or is a registered global. (conditional) */
42
+ 'unique-indexes-tenant-prefixed'];
41
43
  type ConformanceCheck = (typeof KERNEL_CONFORMANCE_CHECKS)[number];
42
44
  /**
43
45
  * Checks that only run when the kernel supplies the input they need. When the input is
44
46
  * absent they are reported as NOT EXERCISED — a visible marker test plus a report entry,
45
47
  * never a silent pass.
46
48
  */
47
- export declare const CONDITIONAL_CHECKS: readonly ["bind-verifies-requirements", "outbox-requires-transactional-save", "optional-modules-register-no-models"];
49
+ export declare const CONDITIONAL_CHECKS: readonly ["bind-verifies-requirements", "outbox-requires-transactional-save", "optional-modules-register-no-models", "unique-indexes-tenant-prefixed"];
48
50
  /**
49
51
  * The two runner functions the suite needs, INJECTED by the consumer.
50
52
  *
@@ -118,6 +120,23 @@ interface KernelConformanceReport {
118
120
  readonly skipped: readonly ConformanceCheckStatus[];
119
121
  readonly notExercised: readonly ConformanceCheckStatus[];
120
122
  }
123
+ /** What becomes of a deliberately global unique index once a deployment is sharded or podded (D7). */
124
+ type GlobalUniqueDisposition = 'shard-key-prefix' | 'identity-registry' | 'pod-local' | 'drop';
125
+ /** A unique index that is global ON PURPOSE. A bare name is not enough: the reason is the contract. */
126
+ interface AllowedGlobalUnique {
127
+ /** The index NAME as declared (`options.name`), or its keys joined by `,` when unnamed. */
128
+ readonly index: string;
129
+ readonly reason: string;
130
+ readonly t2: GlobalUniqueDisposition;
131
+ }
132
+ interface TenantUniquesOptions {
133
+ /** The tenant field the kernel prefixes with, e.g. `'organizationId'`. */
134
+ readonly tenantField: string;
135
+ /** Bind the kernel with tenancy ENABLED (field strategy) on the connection the suite supplies. */
136
+ readonly bindScoped: (connection: Connection) => unknown | Promise<unknown>;
137
+ /** Deliberate globals. Empty (the default) means every unique index must be tenant-led. */
138
+ readonly allowGlobal?: readonly AllowedGlobalUnique[];
139
+ }
121
140
  interface KernelConformanceOptions<TBlueprint extends KernelBlueprintLike, TEngine> {
122
141
  /** Kernel name, e.g. `'party'`. Used in test titles and to derive legacy export names. */
123
142
  readonly name: string;
@@ -215,6 +234,28 @@ interface KernelConformanceOptions<TBlueprint extends KernelBlueprintLike, TEngi
215
234
  * `optional-modules-register-no-models` (§11.10).
216
235
  */
217
236
  readonly minimalBlueprint?: () => TBlueprint;
237
+ /**
238
+ * Enables `unique-indexes-tenant-prefixed` (scale decision D7).
239
+ *
240
+ * MongoDB refuses `shardCollection` on a tenant key when ANY unique index does not start with
241
+ * that key, so one global unique makes the whole collection unshardable by tenant and turns a
242
+ * later move to pods or shards into a data migration. The check binds the kernel with tenancy
243
+ * ON, reads every registered schema's indexes, and requires each unique one to lead with the
244
+ * tenant field, or to be registered in `allowGlobal` with a reason and a T2 disposition.
245
+ *
246
+ * It also fails when it inspected no unique index at all (a scan that finds nothing proves
247
+ * nothing), and when an `allowGlobal` entry names an index that no longer exists or is now
248
+ * tenant-led (a stale entry would hide a real one).
249
+ *
250
+ * ```ts
251
+ * tenantUniques: {
252
+ * tenantField: 'organizationId',
253
+ * bindScoped: (connection) => defineX({ tenant: { enabled: true } }).bind(connection, runtime),
254
+ * allowGlobal: [{ index: 'directory_site_slug', reason: 'public slug resolved without a tenant', t2: 'identity-registry' }],
255
+ * }
256
+ * ```
257
+ */
258
+ readonly tenantUniques?: TenantUniquesOptions;
218
259
  /**
219
260
  * Explicit, reasoned opt-outs. Unknown or duplicate ids throw at call time. Every skip is
220
261
  * printed and registered as a visible marker test.
@@ -240,4 +281,4 @@ interface InstrumentedTransport {
240
281
  * "did I create this?", never by inspecting the object.
241
282
  */
242
283
  export declare function instrumentTransport(inner: EventTransportLike): InstrumentedTransport;
243
- export type { ConformanceCheck, ConformanceCheckOutcome, ConformanceCheckStatus, ConformanceConnect, ConformanceConnectionHandle, ConformanceEvent, ConformanceRunner, ConformanceSkip, EventTransportLike, InstrumentedTransport, KernelBindContext, KernelBlueprintLike, KernelConformanceOptions, KernelConformanceReport };
284
+ export type { AllowedGlobalUnique, ConformanceCheck, ConformanceCheckOutcome, ConformanceCheckStatus, ConformanceConnect, ConformanceConnectionHandle, ConformanceEvent, ConformanceRunner, ConformanceSkip, EventTransportLike, GlobalUniqueDisposition, InstrumentedTransport, KernelBindContext, KernelBlueprintLike, KernelConformanceOptions, KernelConformanceReport, TenantUniquesOptions };
@@ -104,7 +104,8 @@ const KERNEL_CONFORMANCE_CHECKS = [
104
104
  "maintenance-fails-loud",
105
105
  "bind-verifies-requirements",
106
106
  "outbox-requires-transactional-save",
107
- "optional-modules-register-no-models"
107
+ "optional-modules-register-no-models",
108
+ "unique-indexes-tenant-prefixed"
108
109
  ];
109
110
  /**
110
111
  * Checks that only run when the kernel supplies the input they need. When the input is
@@ -114,7 +115,8 @@ const KERNEL_CONFORMANCE_CHECKS = [
114
115
  const CONDITIONAL_CHECKS = [
115
116
  "bind-verifies-requirements",
116
117
  "outbox-requires-transactional-save",
117
- "optional-modules-register-no-models"
118
+ "optional-modules-register-no-models",
119
+ "unique-indexes-tenant-prefixed"
118
120
  ];
119
121
  // src/kernel-conformance/suite.ts
120
122
  /**
@@ -217,12 +219,14 @@ function describeKernelConformance(opts) {
217
219
  const conditionalInputs = {
218
220
  "bind-verifies-requirements": "bindWithUnmetRequirement",
219
221
  "outbox-requires-transactional-save": "bindWithNonTransactionalOutbox",
220
- "optional-modules-register-no-models": "minimalBlueprint"
222
+ "optional-modules-register-no-models": "minimalBlueprint",
223
+ "unique-indexes-tenant-prefixed": "tenantUniques"
221
224
  };
222
225
  const conditionalSupplied = {
223
226
  "bind-verifies-requirements": opts.bindWithUnmetRequirement !== void 0,
224
227
  "outbox-requires-transactional-save": opts.bindWithNonTransactionalOutbox !== void 0,
225
- "optional-modules-register-no-models": opts.minimalBlueprint !== void 0
228
+ "optional-modules-register-no-models": opts.minimalBlueprint !== void 0,
229
+ "unique-indexes-tenant-prefixed": opts.tenantUniques !== void 0
226
230
  };
227
231
  const statuses = KERNEL_CONFORMANCE_CHECKS.map((check) => {
228
232
  const reason = skipReasons.get(check);
@@ -589,6 +593,41 @@ function describeKernelConformance(opts) {
589
593
  assert(check, drift.extra.length === 0 && drift.missing.length === 0, `the minimal bind registered [${registered.join(", ")}] but declared [${minimal.modelNames.join(", ")}] — a disabled module still compiled a model`);
590
594
  });
591
595
  });
596
+ register("unique-indexes-tenant-prefixed", "every unique index leads with the tenant field, or is a registered global", async () => {
597
+ const check = "unique-indexes-tenant-prefixed";
598
+ const cfg = opts.tenantUniques;
599
+ const allowed = new Map((cfg.allowGlobal ?? []).map((g) => [g.index, g]));
600
+ for (const g of allowed.values()) assert(check, g.reason.trim().length > 0, `allowGlobal entry '${g.index}' has no reason — a global unique index without one is the defect this check exists to catch`);
601
+ await withConnection(async (connection) => {
602
+ const before = new Set(Object.keys(connection.models));
603
+ await opts.tenantUniques?.bindScoped(connection);
604
+ const offenders = [];
605
+ const seenGlobals = /* @__PURE__ */ new Set();
606
+ let inspected = 0;
607
+ for (const modelName of Object.keys(connection.models).filter((n) => !before.has(n))) {
608
+ const schema = connection.models[modelName]?.schema;
609
+ for (const [fields, options] of schema?.indexes() ?? []) {
610
+ if (!options?.unique) continue;
611
+ inspected++;
612
+ const keys = Object.keys(fields);
613
+ const name = String(options.name ?? keys.join(","));
614
+ if (keys[0] === cfg.tenantField) {
615
+ assert(check, !allowed.has(name), `allowGlobal lists '${name}' but it already leads with '${cfg.tenantField}' — remove the stale entry (it would hide a future global)`);
616
+ continue;
617
+ }
618
+ if (allowed.has(name)) {
619
+ seenGlobals.add(name);
620
+ continue;
621
+ }
622
+ offenders.push(`${modelName}.${name} {${keys.join(", ")}}`);
623
+ }
624
+ }
625
+ assert(check, offenders.length === 0, `unique index(es) that do not lead with '${cfg.tenantField}': [${offenders.join("; ")}]. MongoDB refuses shardCollection on a tenant key while any unique index lacks it. Prefix the index with the tenant field, or register it in tenantUniques.allowGlobal with a reason and a T2 disposition.`);
626
+ assert(check, inspected > 0, "no unique index was inspected — the scoped bind registered none, so this check proved nothing");
627
+ const stale = [...allowed.keys()].filter((n) => !seenGlobals.has(n));
628
+ assert(check, stale.length === 0, `allowGlobal names index(es) that no scoped model declares: [${stale.join(", ")}] — remove them`);
629
+ });
630
+ });
592
631
  });
593
632
  }
594
633
  function defaultReport(report) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@classytic/mongokit",
3
- "version": "3.45.0",
3
+ "version": "3.46.0",
4
4
  "description": "Production-grade MongoDB repositories - smart pagination, events, and plugins. Built on @classytic/repo-core.",
5
5
  "type": "module",
6
6
  "sideEffects": false,