@cosmicdrift/kumiko-framework 0.2.0 → 0.2.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.
- package/package.json +3 -3
- package/src/engine/index.ts +6 -0
- package/src/engine/tier-resolver-extension.ts +78 -0
- package/src/engine/types/handlers.ts +13 -5
- package/src/pipeline/__tests__/dispatcher.test.ts +73 -0
- package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +100 -0
- package/src/pipeline/dispatcher.ts +35 -15
- package/src/pipeline/event-dispatcher.ts +10 -1
- package/src/pipeline/lifecycle-pipeline.ts +22 -4
- package/src/stack/test-stack.ts +1 -1
package/package.json
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
7
7
|
"repository": {
|
|
8
8
|
"type": "git",
|
|
9
|
-
"url": "git+https://github.com/
|
|
9
|
+
"url": "git+https://github.com/CosmicDriftGameStudio/kumiko-framework.git",
|
|
10
10
|
"directory": "packages/framework"
|
|
11
11
|
},
|
|
12
12
|
"bugs": {
|
|
13
|
-
"url": "https://github.com/
|
|
13
|
+
"url": "https://github.com/CosmicDriftGameStudio/kumiko-framework/issues"
|
|
14
14
|
},
|
|
15
15
|
"homepage": "https://kumiko.so",
|
|
16
16
|
"keywords": [
|
package/src/engine/index.ts
CHANGED
|
@@ -126,6 +126,12 @@ export {
|
|
|
126
126
|
SYSTEM_ROLE,
|
|
127
127
|
SYSTEM_USER_ID,
|
|
128
128
|
} from "./system-user";
|
|
129
|
+
export {
|
|
130
|
+
type EffectiveFeaturesResolver,
|
|
131
|
+
findTierResolverUsage,
|
|
132
|
+
TENANT_TIER_RESOLVER_EXT,
|
|
133
|
+
type TierResolverPlugin,
|
|
134
|
+
} from "./tier-resolver-extension";
|
|
129
135
|
// Types
|
|
130
136
|
export type {
|
|
131
137
|
AccessRule,
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// Sprint-8a Tier-Composition: framework-extension-point für per-tenant
|
|
2
|
+
// effective-feature resolution.
|
|
3
|
+
//
|
|
4
|
+
// **Pattern (analog mail-foundation / file-foundation):** ein Feature
|
|
5
|
+
// declares `r.extendsRegistrar("tenantTierResolver")` und plugins
|
|
6
|
+
// implementieren via `r.useExtension("tenantTierResolver", "id", { build })`.
|
|
7
|
+
//
|
|
8
|
+
// **Auto-wiring im Framework:** runDevApp + runProdApp scannen das registry
|
|
9
|
+
// nach genau diesem extension-name. Wenn ein plugin gefunden ist UND der
|
|
10
|
+
// App-Author nicht selbst eine `effectiveFeatures`-callback gesetzt hat,
|
|
11
|
+
// wird `plugin.build({ db, registry })` einmalig in onAfterSetup aufgerufen.
|
|
12
|
+
// Der returned callback landet als `effectiveFeatures` im dispatcher.
|
|
13
|
+
//
|
|
14
|
+
// **Warum dieser Pattern:** App-Author mountet `createTierEngineFeature(opts)`
|
|
15
|
+
// und das war's — kein Late-Bound-Holder im run-config, kein
|
|
16
|
+
// effectiveFeatures-callback wiren. Das Framework macht den Late-Bound-Trick
|
|
17
|
+
// intern. Memory `feedback_alles_ist_ein_feature`: Tier-Composition ist
|
|
18
|
+
// ein Feature, nicht ein Subsystem mit App-spezifischem Wiring.
|
|
19
|
+
//
|
|
20
|
+
// **Apps ohne tier-engine:** wenn keine plugin registriert ist, framework
|
|
21
|
+
// macht nichts — `effectiveFeatures` bleibt undefined, alle features sind on.
|
|
22
|
+
|
|
23
|
+
import type { DbConnection } from "../db/connection";
|
|
24
|
+
import type { RegistrarExtensionRegistration } from "./types/config";
|
|
25
|
+
import type { FeatureDefinition, Registry } from "./types/feature";
|
|
26
|
+
import type { TenantId } from "./types/identifiers";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Extension-name unter dem ein tier-resolver-plugin im registry registriert
|
|
30
|
+
* werden muss. Konstante damit `r.useExtension(TENANT_TIER_RESOLVER_EXT, ...)`
|
|
31
|
+
* + framework's `getExtensionUsages(TENANT_TIER_RESOLVER_EXT)` typo-resistent
|
|
32
|
+
* gegen den selben Wert prüfen.
|
|
33
|
+
*/
|
|
34
|
+
export const TENANT_TIER_RESOLVER_EXT = "tenantTierResolver";
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Resolver-callback shape: synchron (dispatcher hot-path), per-tenant.
|
|
38
|
+
* Returnt das effective feature-Set für den tenant. Implementations sollten
|
|
39
|
+
* einen in-memory cache pflegen + per `r.entityHook` invalidieren.
|
|
40
|
+
*
|
|
41
|
+
* **System-context convention:** call mit SYSTEM_TENANT_ID erwartet die union
|
|
42
|
+
* aller tier-features (siehe DispatcherOptions.effectiveFeatures doc-block).
|
|
43
|
+
*/
|
|
44
|
+
export type EffectiveFeaturesResolver = (tenantId: TenantId) => ReadonlySet<string>;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Plugin-shape für tier-resolver-extension. Plugins implementieren `build`
|
|
48
|
+
* als boot-time factory: kriegen `db` + `registry` (post-stack-setup),
|
|
49
|
+
* laden initial cache aus DB, returnen den synchronen resolver-callback.
|
|
50
|
+
*/
|
|
51
|
+
export type TierResolverPlugin = {
|
|
52
|
+
readonly build: (deps: {
|
|
53
|
+
readonly db: DbConnection;
|
|
54
|
+
readonly registry: Registry;
|
|
55
|
+
}) => Promise<EffectiveFeaturesResolver>;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Scan a composed feature-list for a `tenantTierResolver`-extension usage.
|
|
60
|
+
* Single plugin assumption — multiple wären ambiguous (welcher resolver
|
|
61
|
+
* gewinnt). Memory `feedback_no_options_without_need`: kein multi-merge-
|
|
62
|
+
* pattern bis es echten Use-Case gibt.
|
|
63
|
+
*
|
|
64
|
+
* Geteilter helper für runDevApp + runProdApp damit der Pickup-Pfad
|
|
65
|
+
* bit-identisch ist (drift-resistent).
|
|
66
|
+
*/
|
|
67
|
+
export function findTierResolverUsage(
|
|
68
|
+
features: readonly FeatureDefinition[],
|
|
69
|
+
): RegistrarExtensionRegistration | undefined {
|
|
70
|
+
for (const feature of features) {
|
|
71
|
+
for (const usage of feature.extensionUsages) {
|
|
72
|
+
if (usage.extensionName === TENANT_TIER_RESOLVER_EXT) {
|
|
73
|
+
return usage;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
@@ -261,11 +261,13 @@ type SharedContextFields = {
|
|
|
261
261
|
// rebuildProjection) honour it automatically.
|
|
262
262
|
readonly signal?: AbortSignal;
|
|
263
263
|
// Effective feature-toggle resolver. Wired by the dispatcher when the
|
|
264
|
-
// feature-toggles feature is loaded — the lifecycle
|
|
265
|
-
// and ctx.hasFeature all read from this single
|
|
266
|
-
//
|
|
267
|
-
//
|
|
268
|
-
|
|
264
|
+
// feature-toggles or tier-engine feature is loaded — the lifecycle
|
|
265
|
+
// pipeline, MSP runner, and ctx.hasFeature all read from this single
|
|
266
|
+
// source. Per-tenant: tenantId argument enables tier-cuts (Sprint 8a)
|
|
267
|
+
// where Tenant-A sees Pro features and Tenant-B sees Free features in
|
|
268
|
+
// the same process. Returns the Set of feature names effectively
|
|
269
|
+
// enabled for that tenant. Absent = all features on (back-compat).
|
|
270
|
+
readonly effectiveFeatures?: (tenantId: TenantId) => ReadonlySet<string>;
|
|
269
271
|
};
|
|
270
272
|
|
|
271
273
|
// All optional — used at pipeline/system boundaries.
|
|
@@ -286,6 +288,12 @@ export type AppContext = SharedContextFields & {
|
|
|
286
288
|
readonly triggerName?: string;
|
|
287
289
|
readonly _userId?: string | undefined;
|
|
288
290
|
readonly _handlerType?: string | undefined;
|
|
291
|
+
/** Tenant des aktuellen Pipeline-Calls. Wird vom Dispatcher beim Bauen
|
|
292
|
+
* des HandlerContext aus `user.tenantId` gespiegelt, damit lifecycle-
|
|
293
|
+
* pipeline + system-hooks den Wert ohne Zugriff auf user-Object haben.
|
|
294
|
+
* Sprint-8a Tier-Composition: `effectiveFeatures(ctx._tenantId)`-call
|
|
295
|
+
* in den hook-filter-Stellen. */
|
|
296
|
+
readonly _tenantId?: TenantId;
|
|
289
297
|
};
|
|
290
298
|
|
|
291
299
|
// Handler execution: db (tenant-scoped) + registry guaranteed.
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { describe, expect, test } from "vitest";
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { createEntity, createRegistry, createTextField, defineFeature } from "../../engine";
|
|
4
|
+
import type { TenantId } from "../../engine/types/identifiers";
|
|
4
5
|
import { createTestUser } from "../../stack";
|
|
5
6
|
import { createDispatcher } from "../dispatcher";
|
|
6
7
|
|
|
@@ -321,6 +322,78 @@ describe("dispatcher feature-gate", () => {
|
|
|
321
322
|
items: [],
|
|
322
323
|
});
|
|
323
324
|
});
|
|
325
|
+
|
|
326
|
+
test("Sprint 8a: per-tenant gating — Tenant A passes, Tenant B gets feature_disabled", async () => {
|
|
327
|
+
// Beweist die Phase-1-Architektur: dispatcher ruft effectiveFeatures
|
|
328
|
+
// mit user.tenantId, resolver kann pro Tenant unterschiedliche Sets
|
|
329
|
+
// returnen → Tier-A sieht feature, Tier-B nicht.
|
|
330
|
+
const registry = createRegistry([toggled()]);
|
|
331
|
+
const tenantA = "00000000-0000-4000-8000-0000000000a1" as TenantId;
|
|
332
|
+
const tenantB = "00000000-0000-4000-8000-0000000000b2" as TenantId;
|
|
333
|
+
|
|
334
|
+
const dispatcher = createDispatcher(
|
|
335
|
+
registry,
|
|
336
|
+
{},
|
|
337
|
+
{
|
|
338
|
+
effectiveFeatures: (tenantId) => (tenantId === tenantA ? new Set(["toggled"]) : new Set()),
|
|
339
|
+
},
|
|
340
|
+
);
|
|
341
|
+
|
|
342
|
+
const userA = createTestUser({ id: "u-a", tenantId: tenantA, roles: ["Admin"] });
|
|
343
|
+
const userB = createTestUser({ id: "u-b", tenantId: tenantB, roles: ["Admin"] });
|
|
344
|
+
|
|
345
|
+
await expect(dispatcher.query("toggled:query:widget:list", {}, userA)).resolves.toEqual({
|
|
346
|
+
items: [],
|
|
347
|
+
});
|
|
348
|
+
await expect(dispatcher.query("toggled:query:widget:list", {}, userB)).rejects.toThrow(
|
|
349
|
+
/feature toggled is disabled/,
|
|
350
|
+
);
|
|
351
|
+
|
|
352
|
+
const writeA = await dispatcher.write("toggled:write:widget:create", { name: "from-a" }, userA);
|
|
353
|
+
expect(writeA.isSuccess).toBe(true);
|
|
354
|
+
|
|
355
|
+
const writeB = await dispatcher.write("toggled:write:widget:create", { name: "from-b" }, userB);
|
|
356
|
+
expect(writeB.isSuccess).toBe(false);
|
|
357
|
+
if (!writeB.isSuccess) {
|
|
358
|
+
expect(writeB.error.code).toBe("feature_disabled");
|
|
359
|
+
}
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
test("Sprint 8a: ctx.hasFeature is current-user-scoped", async () => {
|
|
363
|
+
// Pin: hasFeature() in handler-bodies resolves against ctx.user.tenantId,
|
|
364
|
+
// NICHT gegen einen globalen Set. Two tenants call same handler,
|
|
365
|
+
// beide rufen hasFeature("toggled") — A bekommt true, B false.
|
|
366
|
+
const tenantA = "00000000-0000-4000-8000-0000000000a3" as TenantId;
|
|
367
|
+
const tenantB = "00000000-0000-4000-8000-0000000000b4" as TenantId;
|
|
368
|
+
|
|
369
|
+
const probe = defineFeature("probe", (r) => {
|
|
370
|
+
r.queryHandler(
|
|
371
|
+
"check",
|
|
372
|
+
z.object({}).passthrough(),
|
|
373
|
+
async (_event, ctx) => ({ enabled: ctx.hasFeature("toggled") }),
|
|
374
|
+
{ access: { openToAll: true } },
|
|
375
|
+
);
|
|
376
|
+
});
|
|
377
|
+
const registry = createRegistry([toggled(), probe]);
|
|
378
|
+
const dispatcher = createDispatcher(
|
|
379
|
+
registry,
|
|
380
|
+
{},
|
|
381
|
+
{
|
|
382
|
+
effectiveFeatures: (tenantId) =>
|
|
383
|
+
tenantId === tenantA ? new Set(["toggled", "probe"]) : new Set(["probe"]),
|
|
384
|
+
},
|
|
385
|
+
);
|
|
386
|
+
|
|
387
|
+
const userA = createTestUser({ id: "u-a2", tenantId: tenantA, roles: ["Admin"] });
|
|
388
|
+
const userB = createTestUser({ id: "u-b2", tenantId: tenantB, roles: ["Admin"] });
|
|
389
|
+
|
|
390
|
+
await expect(dispatcher.query("probe:query:check", {}, userA)).resolves.toEqual({
|
|
391
|
+
enabled: true,
|
|
392
|
+
});
|
|
393
|
+
await expect(dispatcher.query("probe:query:check", {}, userB)).resolves.toEqual({
|
|
394
|
+
enabled: false,
|
|
395
|
+
});
|
|
396
|
+
});
|
|
324
397
|
});
|
|
325
398
|
|
|
326
399
|
describe("write-handler shape guard", () => {
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
type PreSaveHookFn,
|
|
10
10
|
type SaveContext,
|
|
11
11
|
} from "../../engine";
|
|
12
|
+
import type { TenantId } from "../../engine/types/identifiers";
|
|
12
13
|
import { buildEventId, createLifecycleHooks, type SystemHooks } from "../lifecycle-pipeline";
|
|
13
14
|
|
|
14
15
|
function makeRegistry(hooks?: { preSave?: PreSaveHookFn[]; postSave?: PostSaveHookFn[] }) {
|
|
@@ -400,6 +401,105 @@ describe("runPostSave phase routing", () => {
|
|
|
400
401
|
});
|
|
401
402
|
});
|
|
402
403
|
|
|
404
|
+
// =============================================================================
|
|
405
|
+
// Sprint 8a: per-tenant entity-hook filter
|
|
406
|
+
// =============================================================================
|
|
407
|
+
//
|
|
408
|
+
// Setup: Feature A owns the entity. Feature B registers an entity-hook
|
|
409
|
+
// on A's entity (cross-feature pattern). lifecycle-pipeline must filter
|
|
410
|
+
// B's hook based on the active tenant's effectiveFeatures-set.
|
|
411
|
+
|
|
412
|
+
describe("Sprint 8a: per-tenant entity-hook filter", () => {
|
|
413
|
+
function setupTwoFeatures() {
|
|
414
|
+
const calls: Array<{ tenant: string }> = [];
|
|
415
|
+
|
|
416
|
+
const featureA = defineFeature("feat-a", (r) => {
|
|
417
|
+
r.entity("widget", createEntity({ table: "Widgets", fields: { name: createTextField() } }));
|
|
418
|
+
r.writeHandler(
|
|
419
|
+
"widget:create",
|
|
420
|
+
z.object({ name: z.string() }),
|
|
421
|
+
async () => ({ isSuccess: true as const, data: null }),
|
|
422
|
+
{ access: { openToAll: true } },
|
|
423
|
+
);
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
const featureB = defineFeature("feat-b", (r) => {
|
|
427
|
+
r.entityHook("postSave", "widget", async (_result, ctx) => {
|
|
428
|
+
calls.push({ tenant: ctx._tenantId ?? "no-tenant" });
|
|
429
|
+
});
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
return { registry: createRegistry([featureA, featureB]), calls };
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const tenantA = "00000000-0000-4000-8000-0000000000a1" as TenantId;
|
|
436
|
+
const tenantB = "00000000-0000-4000-8000-0000000000b2" as TenantId;
|
|
437
|
+
|
|
438
|
+
const baseSaveCtx: SaveContext = {
|
|
439
|
+
kind: "save",
|
|
440
|
+
id: 1,
|
|
441
|
+
data: { name: "x", tenantId: tenantA },
|
|
442
|
+
changes: { name: "x" },
|
|
443
|
+
previous: {},
|
|
444
|
+
isNew: true,
|
|
445
|
+
entityName: "widget",
|
|
446
|
+
};
|
|
447
|
+
|
|
448
|
+
test("Tenant A (feat-b enabled) → hook fires; Tenant B (feat-b disabled) → hook skipped", async () => {
|
|
449
|
+
const { registry, calls } = setupTwoFeatures();
|
|
450
|
+
const pipeline = createLifecycleHooks(registry);
|
|
451
|
+
const effectiveFeatures = (tenantId: TenantId) =>
|
|
452
|
+
tenantId === tenantA ? new Set(["feat-a", "feat-b"]) : new Set(["feat-a"]);
|
|
453
|
+
|
|
454
|
+
await pipeline.runPostSave("feat-a:write:widget:create", baseSaveCtx, {
|
|
455
|
+
_tenantId: tenantA,
|
|
456
|
+
effectiveFeatures,
|
|
457
|
+
});
|
|
458
|
+
await pipeline.runPostSave("feat-a:write:widget:create", baseSaveCtx, {
|
|
459
|
+
_tenantId: tenantB,
|
|
460
|
+
effectiveFeatures,
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
expect(calls).toEqual([{ tenant: tenantA }]);
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
test("ctx without _tenantId → hook fires (legacy back-compat: undefined = skip filter)", async () => {
|
|
467
|
+
// System-jobs / boot-time pipeline-calls have no user → no _tenantId.
|
|
468
|
+
// currentEffectiveFeatures returns undefined; registry filterByPhase
|
|
469
|
+
// treats undefined as "skip filter" → all hooks fire (back-compat).
|
|
470
|
+
const { registry, calls } = setupTwoFeatures();
|
|
471
|
+
const pipeline = createLifecycleHooks(registry);
|
|
472
|
+
|
|
473
|
+
await pipeline.runPostSave("feat-a:write:widget:create", baseSaveCtx, {});
|
|
474
|
+
|
|
475
|
+
expect(calls).toHaveLength(1);
|
|
476
|
+
expect(calls[0]?.tenant).toBe("no-tenant");
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
test("effectiveFeatures wird PRO call konsultiert (kein staler Cache zwischen runPostSave-aufrufen)", async () => {
|
|
480
|
+
// Pin: currentEffectiveFeatures-helper ruft effectiveFeatures jedes
|
|
481
|
+
// mal neu — keine pipeline-internal Memoization. Toggle-flips müssen
|
|
482
|
+
// sofort greifen, nicht erst nach pipeline-restart.
|
|
483
|
+
const { registry, calls } = setupTwoFeatures();
|
|
484
|
+
const pipeline = createLifecycleHooks(registry);
|
|
485
|
+
const enabled = new Set<string>(["feat-a", "feat-b"]);
|
|
486
|
+
const effectiveFeatures = (_tenantId: TenantId) => enabled;
|
|
487
|
+
|
|
488
|
+
await pipeline.runPostSave("feat-a:write:widget:create", baseSaveCtx, {
|
|
489
|
+
_tenantId: tenantA,
|
|
490
|
+
effectiveFeatures,
|
|
491
|
+
});
|
|
492
|
+
enabled.delete("feat-b");
|
|
493
|
+
await pipeline.runPostSave("feat-a:write:widget:create", baseSaveCtx, {
|
|
494
|
+
_tenantId: tenantA,
|
|
495
|
+
effectiveFeatures,
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
expect(calls).toHaveLength(1);
|
|
499
|
+
expect(calls[0]?.tenant).toBe(tenantA);
|
|
500
|
+
});
|
|
501
|
+
});
|
|
502
|
+
|
|
403
503
|
describe("buildEventId — dedup key construction", () => {
|
|
404
504
|
test("includes handler, id, version and phase when payload is complete", () => {
|
|
405
505
|
const payload = { id: 42, data: { version: 3 } };
|
|
@@ -7,6 +7,7 @@ import { hasAccess } from "../engine/access";
|
|
|
7
7
|
import { checkWriteFieldRoles, filterReadFields } from "../engine/field-access";
|
|
8
8
|
import { parseQn, qn } from "../engine/qualified-name";
|
|
9
9
|
import { defineTransitions, guardTransition } from "../engine/state-machine";
|
|
10
|
+
import type { EffectiveFeaturesResolver } from "../engine/tier-resolver-extension";
|
|
10
11
|
import type {
|
|
11
12
|
AggregateStreamHandle,
|
|
12
13
|
AppContext,
|
|
@@ -25,6 +26,7 @@ import type {
|
|
|
25
26
|
WriteResult,
|
|
26
27
|
} from "../engine/types";
|
|
27
28
|
import { HookPhases } from "../engine/types";
|
|
29
|
+
import type { TenantId } from "../engine/types/identifiers";
|
|
28
30
|
|
|
29
31
|
// Re-export for callers that reach for dispatcher-adjacent types (tests,
|
|
30
32
|
// HTTP-layer stubs) — dispatch consumes these, grouping the type-surface
|
|
@@ -318,13 +320,26 @@ export type DispatcherOptions = {
|
|
|
318
320
|
idempotency?: IdempotencyGuard;
|
|
319
321
|
lifecycle?: LifecycleHooks;
|
|
320
322
|
jobRunner?: JobRunnerRef;
|
|
321
|
-
// Resolves the
|
|
322
|
-
// to gate calls to handlers of disabled features (403 feature_disabled)
|
|
323
|
+
// Resolves the effective-feature set per tenant — the dispatcher uses
|
|
324
|
+
// it to gate calls to handlers of disabled features (403 feature_disabled)
|
|
323
325
|
// and to populate ctx.hasFeature. Absent = all features treated as
|
|
324
|
-
// always-on (no feature-toggles feature loaded). The
|
|
325
|
-
// fast and synchronous per call; implementations cache
|
|
326
|
-
//
|
|
327
|
-
|
|
326
|
+
// always-on (no feature-toggles or tier-engine feature loaded). The
|
|
327
|
+
// resolver must be fast and synchronous per call; implementations cache
|
|
328
|
+
// tenant-keyed sets and refresh on tier-assignment / toggle events.
|
|
329
|
+
//
|
|
330
|
+
// **System-context convention:** when called with SYSTEM_TENANT_ID, the
|
|
331
|
+
// resolver should return the union/superset of all tier-features. Two
|
|
332
|
+
// contexts call with this sentinel:
|
|
333
|
+
// 1. event-dispatcher async-pass (consumers tagged with feature X
|
|
334
|
+
// should not silently skip events from a tenant where X is off —
|
|
335
|
+
// events are immutable, async work runs through).
|
|
336
|
+
// 2. operator-tooling queries (e.g. feature-toggles:registered) where
|
|
337
|
+
// a SystemAdmin needs to see platform-truth, not their own
|
|
338
|
+
// tier-cut.
|
|
339
|
+
// Returning a non-superset for SYSTEM_TENANT_ID will cause silent
|
|
340
|
+
// event-skips and a confusing operator-UI — the framework cannot
|
|
341
|
+
// enforce this contract, but the recipe-test pins the convention.
|
|
342
|
+
effectiveFeatures?: EffectiveFeaturesResolver;
|
|
328
343
|
};
|
|
329
344
|
|
|
330
345
|
type HandlerType = string | HandlerRef;
|
|
@@ -729,11 +744,14 @@ export function createDispatcher(
|
|
|
729
744
|
// dispatcher.resolveAuthClaims) cannot drift.
|
|
730
745
|
resolveAuthClaims: (claimsUser: SessionUser) => resolveAuthClaimsFn(claimsUser),
|
|
731
746
|
|
|
732
|
-
// Feature-effective check for in-handler opt-in logic.
|
|
733
|
-
//
|
|
734
|
-
//
|
|
747
|
+
// Feature-effective check for in-handler opt-in logic. Scope:
|
|
748
|
+
// **current user's tenant** — for cross-tenant lookups (rare,
|
|
749
|
+
// SysAdmin operations) read effectiveFeatures(otherTenantId) directly.
|
|
750
|
+
// When the feature-toggles or tier-engine feature isn't wired (no
|
|
751
|
+
// effectiveFeatures callback), always returns true — apps without
|
|
752
|
+
// tier-cuts treat all features on.
|
|
735
753
|
hasFeature: (featureName: string): boolean =>
|
|
736
|
-
effectiveFeatures ? effectiveFeatures().has(featureName) : true,
|
|
754
|
+
effectiveFeatures ? effectiveFeatures(user.tenantId).has(featureName) : true,
|
|
737
755
|
};
|
|
738
756
|
|
|
739
757
|
// Registry is always the dispatcher's registry — injecting it here lets
|
|
@@ -771,6 +789,7 @@ export function createDispatcher(
|
|
|
771
789
|
// event.user-Wert; Identity-Switches nutzen weiterhin queryAs/writeAs.
|
|
772
790
|
user,
|
|
773
791
|
_userId: user.id,
|
|
792
|
+
_tenantId: user.tenantId,
|
|
774
793
|
_handlerType: type,
|
|
775
794
|
...bridge,
|
|
776
795
|
} as HandlerContext;
|
|
@@ -860,6 +879,7 @@ export function createDispatcher(
|
|
|
860
879
|
// pass-through in that common case.
|
|
861
880
|
function checkFeatureEnabled(
|
|
862
881
|
qualifiedHandler: string,
|
|
882
|
+
tenantId: TenantId,
|
|
863
883
|
): import("../errors").FeatureDisabledError | undefined {
|
|
864
884
|
if (!effectiveFeatures) return undefined;
|
|
865
885
|
const owner = registry.getHandlerFeature(qualifiedHandler);
|
|
@@ -867,13 +887,13 @@ export function createDispatcher(
|
|
|
867
887
|
// happen for registry-built handlers, but guards against edge-case
|
|
868
888
|
// runtime injections.
|
|
869
889
|
if (!owner) return undefined;
|
|
870
|
-
const set = effectiveFeatures();
|
|
890
|
+
const set = effectiveFeatures(tenantId);
|
|
871
891
|
if (set.has(owner)) return undefined;
|
|
872
892
|
return new FeatureDisabledError(owner, qualifiedHandler);
|
|
873
893
|
}
|
|
874
894
|
|
|
875
|
-
function ensureFeatureEnabled(qualifiedHandler: string): void {
|
|
876
|
-
const err = checkFeatureEnabled(qualifiedHandler);
|
|
895
|
+
function ensureFeatureEnabled(qualifiedHandler: string, tenantId: TenantId): void {
|
|
896
|
+
const err = checkFeatureEnabled(qualifiedHandler, tenantId);
|
|
877
897
|
if (err) throw err;
|
|
878
898
|
}
|
|
879
899
|
|
|
@@ -935,7 +955,7 @@ export function createDispatcher(
|
|
|
935
955
|
// disabled feature must not consume the rate-limit quota — the call
|
|
936
956
|
// never happened from the feature's perspective. Order is: lookup →
|
|
937
957
|
// feature-gate → rate-limit → access → validation → handler.
|
|
938
|
-
ensureFeatureEnabled(type);
|
|
958
|
+
ensureFeatureEnabled(type, user.tenantId);
|
|
939
959
|
|
|
940
960
|
// Rate-limit gate runs BEFORE access-check on purpose: anonymous /
|
|
941
961
|
// unauthorized callers must hit the cap too (otherwise the limit
|
|
@@ -1173,7 +1193,7 @@ export function createDispatcher(
|
|
|
1173
1193
|
|
|
1174
1194
|
// Feature-toggle gate: disabled handlers must short-circuit before any
|
|
1175
1195
|
// rate-limit/access/validation work — see executeQueryInner comment.
|
|
1176
|
-
const disabledErr = checkFeatureEnabled(type);
|
|
1196
|
+
const disabledErr = checkFeatureEnabled(type, user.tenantId);
|
|
1177
1197
|
if (disabledErr) return writeFailure(disabledErr);
|
|
1178
1198
|
|
|
1179
1199
|
// Rate-limit gate before access (same reasoning as in executeQueryInner).
|
|
@@ -2,6 +2,7 @@ import { and, asc, eq, gt, sql } from "drizzle-orm";
|
|
|
2
2
|
import { requestContext } from "../api/request-context";
|
|
3
3
|
import type { DbConnection, DbTx, PgClient } from "../db/connection";
|
|
4
4
|
import type { AppContext } from "../engine/types";
|
|
5
|
+
import { SYSTEM_TENANT_ID } from "../engine/types/identifiers";
|
|
5
6
|
import {
|
|
6
7
|
EVENTS_PUBSUB_CHANNEL,
|
|
7
8
|
eventsTable,
|
|
@@ -487,7 +488,15 @@ export function createEventDispatcher(options: EventDispatcherOptions): EventDis
|
|
|
487
488
|
// Feature-toggle snapshot taken once per pass (not per consumer): all
|
|
488
489
|
// consumers see the same disabled-set even if an operator flips a
|
|
489
490
|
// toggle mid-pass, so "this event batch" decisions stay consistent.
|
|
490
|
-
|
|
491
|
+
//
|
|
492
|
+
// Sprint-8a tier-composition: per-tenant resolver per-pass-konsultiert
|
|
493
|
+
// mit SYSTEM_TENANT_ID. Async-events sind tier-agnostic — wenn ein
|
|
494
|
+
// Tenant downgrade'd, sollen seine queued events trotzdem verarbeitet
|
|
495
|
+
// werden (events sind immutable, projection ist eventually-consistent).
|
|
496
|
+
// Tier-cuts wirken request-time im sync-dispatcher + lifecycle-pipeline,
|
|
497
|
+
// nicht im async-replay. App-level resolver entscheidet was er bei
|
|
498
|
+
// SYSTEM_TENANT_ID returnt (typisch: union-of-all-tier-features).
|
|
499
|
+
const effective = context.effectiveFeatures?.(SYSTEM_TENANT_ID);
|
|
491
500
|
|
|
492
501
|
// Seriell pro consumer. Parallelisierung wäre möglich (je eigene TX), aber
|
|
493
502
|
// das einfache Modell reicht für v1 — jeder consumer hat geringe
|
|
@@ -20,6 +20,24 @@ function resolveTracer(context: AppContext): Tracer {
|
|
|
20
20
|
return context.tracer ?? getFallbackTracer();
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
/**
|
|
24
|
+
* Resolve effective-feature set for the active context's tenant.
|
|
25
|
+
*
|
|
26
|
+
* `_tenantId` wird vom dispatcher beim HandlerContext-Bau aus `user.tenantId`
|
|
27
|
+
* gespiegelt. Bei legacy-callsites ohne user (system-jobs, boot-time
|
|
28
|
+
* pipeline) ist es undefined — `effectiveFeatures(undefined)` wäre type-
|
|
29
|
+
* unsafe; wir return undefined und behandeln das als "all features on"
|
|
30
|
+
* (registry filterhooks akzeptieren undefined als "skip filter").
|
|
31
|
+
*
|
|
32
|
+
* Single source — die 4 lifecycle-hook-callsites (preSave, postSave,
|
|
33
|
+
* preDelete, postDelete) rufen alle hier durch, damit der tenant-
|
|
34
|
+
* scoping-Pfad nie inkonsistent ist.
|
|
35
|
+
*/
|
|
36
|
+
function currentEffectiveFeatures(context: AppContext): ReadonlySet<string> | undefined {
|
|
37
|
+
if (context._tenantId === undefined) return undefined;
|
|
38
|
+
return context.effectiveFeatures?.(context._tenantId);
|
|
39
|
+
}
|
|
40
|
+
|
|
23
41
|
export type SystemHookDef<TFn> = {
|
|
24
42
|
readonly name: string;
|
|
25
43
|
readonly priority: number;
|
|
@@ -250,7 +268,7 @@ export function createLifecycleHooks(
|
|
|
250
268
|
async runPreSave(handlerName, changes, previous, isNew, context) {
|
|
251
269
|
let currentChanges = changes;
|
|
252
270
|
const hookContext = { ...context, previous, isNew };
|
|
253
|
-
const eff = context
|
|
271
|
+
const eff = currentEffectiveFeatures(context);
|
|
254
272
|
|
|
255
273
|
for (const hook of registry.getPreSaveHooks(handlerName, eff)) {
|
|
256
274
|
currentChanges = await hook(currentChanges, hookContext);
|
|
@@ -266,7 +284,7 @@ export function createLifecycleHooks(
|
|
|
266
284
|
},
|
|
267
285
|
|
|
268
286
|
async runPostSave(handlerName, result, context, phase = HookPhases.afterCommit) {
|
|
269
|
-
const eff = context
|
|
287
|
+
const eff = currentEffectiveFeatures(context);
|
|
270
288
|
await runHookSet({
|
|
271
289
|
handlerName,
|
|
272
290
|
payload: result,
|
|
@@ -283,7 +301,7 @@ export function createLifecycleHooks(
|
|
|
283
301
|
async runPreDelete(handlerName, payload, context) {
|
|
284
302
|
// preDelete hooks run in-transaction and throw on failure (not best-effort).
|
|
285
303
|
// They're used to check invariants before delete, so phase filter is "inTransaction".
|
|
286
|
-
const eff = context
|
|
304
|
+
const eff = currentEffectiveFeatures(context);
|
|
287
305
|
for (const hook of registry.getPreDeleteHooks(handlerName, HookPhases.inTransaction, eff)) {
|
|
288
306
|
await hook(payload, context);
|
|
289
307
|
}
|
|
@@ -308,7 +326,7 @@ export function createLifecycleHooks(
|
|
|
308
326
|
},
|
|
309
327
|
|
|
310
328
|
async runPostDelete(handlerName, payload, context, phase = HookPhases.afterCommit) {
|
|
311
|
-
const eff = context
|
|
329
|
+
const eff = currentEffectiveFeatures(context);
|
|
312
330
|
await runHookSet({
|
|
313
331
|
handlerName,
|
|
314
332
|
payload,
|
package/src/stack/test-stack.ts
CHANGED
|
@@ -89,7 +89,7 @@ export type TestStackOptions = {
|
|
|
89
89
|
* GlobalFeatureToggleRuntime.effectiveFeatures for real DB-backed
|
|
90
90
|
* toggles, or a plain `() => new Set<string>(registry.features.keys())`
|
|
91
91
|
* to force a specific snapshot in a unit-style setup. */
|
|
92
|
-
effectiveFeatures?: () => ReadonlySet<string>;
|
|
92
|
+
effectiveFeatures?: (tenantId: TenantId) => ReadonlySet<string>;
|
|
93
93
|
/** Pin the underlying Postgres DB name instead of the default
|
|
94
94
|
* `kumiko_test_<8chars>`. Forwarded to createTestDb. Primary use
|
|
95
95
|
* case: dev servers that want persistent storage across restarts —
|