@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