@cosmicdrift/kumiko-framework 0.1.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.
Files changed (74) hide show
  1. package/LICENSE +57 -0
  2. package/package.json +3 -3
  3. package/src/__tests__/anonymous-access.integration.ts +7 -7
  4. package/src/__tests__/error-contract.integration.ts +2 -2
  5. package/src/__tests__/field-access.integration.ts +2 -2
  6. package/src/__tests__/full-stack.integration.ts +2 -2
  7. package/src/__tests__/ownership.integration.ts +2 -2
  8. package/src/__tests__/raw-table.integration.ts +128 -0
  9. package/src/__tests__/reference-data.integration.ts +2 -2
  10. package/src/__tests__/transition-guard.integration.ts +4 -4
  11. package/src/api/__tests__/batch.integration.ts +3 -3
  12. package/src/api/__tests__/dispatcher-live.integration.ts +2 -2
  13. package/src/api/__tests__/nested-write.integration.ts +3 -3
  14. package/src/db/__tests__/drizzle-helpers.integration.ts +2 -2
  15. package/src/db/__tests__/event-store-executor-list.integration.ts +2 -2
  16. package/src/db/__tests__/event-store-executor.integration.ts +9 -3
  17. package/src/db/__tests__/implicit-projection-equivalence.integration.ts +3 -3
  18. package/src/db/__tests__/multi-row-insert.integration.ts +3 -3
  19. package/src/db/__tests__/schema-migration.integration.ts +9 -9
  20. package/src/db/__tests__/tenant-db.integration.ts +4 -4
  21. package/src/db/__tests__/unique-violation-mapping.integration.ts +2 -2
  22. package/src/db/schema-inspection.ts +1 -1
  23. package/src/engine/__tests__/raw-table.test.ts +149 -0
  24. package/src/engine/define-feature.ts +38 -0
  25. package/src/engine/index.ts +6 -0
  26. package/src/engine/registry.ts +46 -0
  27. package/src/engine/tier-resolver-extension.ts +78 -0
  28. package/src/engine/types/feature.ts +55 -0
  29. package/src/engine/types/handlers.ts +13 -5
  30. package/src/engine/types/index.ts +3 -0
  31. package/src/event-store/__tests__/upcaster.integration.ts +11 -5
  32. package/src/event-store/archive.ts +2 -2
  33. package/src/event-store/events-schema.ts +2 -2
  34. package/src/event-store/snapshot.ts +2 -2
  35. package/src/event-store/upcaster-dead-letter.ts +2 -2
  36. package/src/files/__tests__/file-field-column.integration.ts +4 -4
  37. package/src/files/__tests__/file-field-pipeline.integration.ts +2 -2
  38. package/src/files/__tests__/files.integration.ts +8 -8
  39. package/src/observability/__tests__/observability.integration.ts +2 -2
  40. package/src/pipeline/__tests__/archive-stream.integration.ts +2 -2
  41. package/src/pipeline/__tests__/cascade-handler.integration.ts +9 -9
  42. package/src/pipeline/__tests__/causation-chain.integration.ts +2 -2
  43. package/src/pipeline/__tests__/ctx-bridge.integration.ts +3 -3
  44. package/src/pipeline/__tests__/dispatcher.test.ts +73 -0
  45. package/src/pipeline/__tests__/domain-events-projections.integration.ts +2 -2
  46. package/src/pipeline/__tests__/event-dedup.integration.ts +2 -2
  47. package/src/pipeline/__tests__/event-define-event-strict.integration.ts +2 -2
  48. package/src/pipeline/__tests__/event-dispatcher-lifecycle.integration.ts +3 -3
  49. package/src/pipeline/__tests__/event-dispatcher-multi-instance.integration.ts +2 -2
  50. package/src/pipeline/__tests__/event-dispatcher-pg-listen.integration.ts +2 -2
  51. package/src/pipeline/__tests__/event-dispatcher-recovery.integration.ts +2 -2
  52. package/src/pipeline/__tests__/event-dispatcher-second-audit.integration.ts +4 -4
  53. package/src/pipeline/__tests__/event-dispatcher.integration.ts +4 -4
  54. package/src/pipeline/__tests__/event-retention.integration.ts +2 -2
  55. package/src/pipeline/__tests__/fetch-for-writing.integration.ts +2 -2
  56. package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +100 -0
  57. package/src/pipeline/__tests__/load-aggregate-query.integration.ts +2 -2
  58. package/src/pipeline/__tests__/msp-error-mode.integration.ts +2 -2
  59. package/src/pipeline/__tests__/msp-multi-hop.integration.ts +2 -2
  60. package/src/pipeline/__tests__/msp-rebuild.integration.ts +3 -3
  61. package/src/pipeline/__tests__/multi-stream-projection.integration.ts +3 -3
  62. package/src/pipeline/__tests__/perf-rebuild.integration.ts +2 -2
  63. package/src/pipeline/__tests__/projection-rebuild.integration.ts +9 -3
  64. package/src/pipeline/__tests__/query-projection.integration.ts +2 -2
  65. package/src/pipeline/dispatcher.ts +35 -15
  66. package/src/pipeline/event-consumer-state.ts +2 -2
  67. package/src/pipeline/event-dispatcher.ts +10 -1
  68. package/src/pipeline/lifecycle-pipeline.ts +22 -4
  69. package/src/pipeline/projection-state.ts +3 -3
  70. package/src/stack/index.ts +4 -3
  71. package/src/stack/push-entity-projection-tables.ts +51 -0
  72. package/src/stack/table-helpers.ts +20 -13
  73. package/src/stack/test-stack.ts +14 -5
  74. package/src/testing/__tests__/ensure-entity-table.integration.ts +16 -11
@@ -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 } };
@@ -13,11 +13,11 @@ import { buildDrizzleTable } from "../../db/table-builder";
13
13
  import { createEntity, createTextField, defineFeature } from "../../engine";
14
14
  import { append, loadAggregate as loadAggregateRaw } from "../../event-store";
15
15
  import {
16
- createEntityTable,
17
16
  resetEventStore,
18
17
  setupTestStack,
19
18
  type TestStack,
20
19
  TestUsers,
20
+ unsafeCreateEntityTable,
21
21
  } from "../../stack";
22
22
 
23
23
  // --- Fixture entity ---
@@ -131,7 +131,7 @@ const admin = TestUsers.admin;
131
131
 
132
132
  beforeAll(async () => {
133
133
  stack = await setupTestStack({ features: [asOfFeature], systemHooks: [] });
134
- await createEntityTable(stack.db, invoiceEntity, "asof-invoice");
134
+ await unsafeCreateEntityTable(stack.db, invoiceEntity, "asof-invoice");
135
135
  });
136
136
 
137
137
  afterAll(async () => {
@@ -15,11 +15,11 @@ import { createTenantDb, type TenantDb } from "../../db/tenant-db";
15
15
  import { defineFeature } from "../../engine";
16
16
  import { getConsumerState } from "../../pipeline";
17
17
  import {
18
- createEntityTable,
19
18
  resetEventStore,
20
19
  setupTestStack,
21
20
  type TestStack,
22
21
  TestUsers,
22
+ unsafeCreateEntityTable,
23
23
  } from "../../stack";
24
24
  import { sharedWidgetEntity, sharedWidgetTable } from "../../testing";
25
25
 
@@ -80,7 +80,7 @@ beforeAll(async () => {
80
80
  features: [z2Feature],
81
81
  systemHooks: [],
82
82
  });
83
- await createEntityTable(stack.db, sharedWidgetEntity, "widget");
83
+ await unsafeCreateEntityTable(stack.db, sharedWidgetEntity, "widget");
84
84
  tdb = createTenantDb(stack.db, admin.tenantId);
85
85
  });
86
86
 
@@ -17,11 +17,11 @@ import { buildDrizzleTable } from "../../db/table-builder";
17
17
  import { createEntity, createTextField, defineFeature } from "../../engine";
18
18
  import { eventsTable } from "../../event-store";
19
19
  import {
20
- createEntityTable,
21
20
  resetEventStore,
22
21
  setupTestStack,
23
22
  type TestStack,
24
23
  TestUsers,
24
+ unsafeCreateEntityTable,
25
25
  } from "../../stack";
26
26
 
27
27
  // --- Feature ---
@@ -109,7 +109,7 @@ const admin = TestUsers.admin;
109
109
 
110
110
  beforeAll(async () => {
111
111
  stack = await setupTestStack({ features: [mmhFeature], systemHooks: [] });
112
- await createEntityTable(stack.db, orderEntity, "mmh-order");
112
+ await unsafeCreateEntityTable(stack.db, orderEntity, "mmh-order");
113
113
  });
114
114
 
115
115
  afterAll(async () => {
@@ -28,11 +28,11 @@ import {
28
28
  rebuildMultiStreamProjection,
29
29
  } from "../../pipeline";
30
30
  import {
31
- createEntityTable,
32
31
  resetEventStore,
33
32
  setupTestStack,
34
33
  type TestStack,
35
34
  TestUsers,
35
+ unsafeCreateEntityTable,
36
36
  } from "../../stack";
37
37
 
38
38
  // --- Fixtures: two aggregates feeding one MSP + two cornered MSPs ---
@@ -211,8 +211,8 @@ beforeAll(async () => {
211
211
  features: [feature],
212
212
  systemHooks: [],
213
213
  });
214
- await createEntityTable(stack.db, invoiceEntity, "msp-reb-invoice");
215
- await createEntityTable(stack.db, paymentEntity, "msp-reb-payment");
214
+ await unsafeCreateEntityTable(stack.db, invoiceEntity, "msp-reb-invoice");
215
+ await unsafeCreateEntityTable(stack.db, paymentEntity, "msp-reb-payment");
216
216
  });
217
217
 
218
218
  afterAll(async () => {
@@ -14,12 +14,12 @@ import { createEventStoreExecutor } from "../../db/event-store-executor";
14
14
  import { buildDrizzleTable } from "../../db/table-builder";
15
15
  import { createEntity, createTextField, defineFeature } from "../../engine";
16
16
  import {
17
- createEntityTable,
18
17
  createTestUser,
19
18
  resetEventStore,
20
19
  setupTestStack,
21
20
  type TestStack,
22
21
  TestUsers,
22
+ unsafeCreateEntityTable,
23
23
  } from "../../stack";
24
24
 
25
25
  // --- Two aggregate types that feed one MSP ---
@@ -162,8 +162,8 @@ const admin = TestUsers.admin;
162
162
 
163
163
  beforeAll(async () => {
164
164
  stack = await setupTestStack({ features: [mspFeature], systemHooks: [] });
165
- await createEntityTable(stack.db, shipmentEntity, "msp-shipment");
166
- await createEntityTable(stack.db, refundEntity, "msp-refund");
165
+ await unsafeCreateEntityTable(stack.db, shipmentEntity, "msp-shipment");
166
+ await unsafeCreateEntityTable(stack.db, refundEntity, "msp-refund");
167
167
  });
168
168
 
169
169
  afterAll(async () => {
@@ -24,7 +24,7 @@ import { createEntity, createRegistry, createTextField, defineFeature } from "..
24
24
  import type { ProjectionDefinition } from "../../engine/types";
25
25
  import { createEventsTable } from "../../event-store";
26
26
  import { createProjectionStateTable, rebuildProjection } from "../../pipeline";
27
- import { createTestDb, pushTables, type TestDb, TestUsers } from "../../stack";
27
+ import { createTestDb, type TestDb, TestUsers, unsafePushTables } from "../../stack";
28
28
  import { generateId as uuid } from "../../utils";
29
29
 
30
30
  // Counter projection: every task.created bumps a counter, every
@@ -77,7 +77,7 @@ beforeAll(async () => {
77
77
  testDb = await createTestDb();
78
78
  await createEventsTable(testDb.db);
79
79
  await createProjectionStateTable(testDb.db);
80
- await pushTables(testDb.db, { perf_rebuild_task_count: taskCountTable });
80
+ await unsafePushTables(testDb.db, { perf_rebuild_task_count: taskCountTable });
81
81
  });
82
82
 
83
83
  afterAll(async () => {
@@ -36,7 +36,13 @@ import {
36
36
  listProjectionsWithState,
37
37
  rebuildProjection,
38
38
  } from "../../pipeline";
39
- import { createEntityTable, createTestDb, pushTables, type TestDb, TestUsers } from "../../stack";
39
+ import {
40
+ createTestDb,
41
+ type TestDb,
42
+ TestUsers,
43
+ unsafeCreateEntityTable,
44
+ unsafePushTables,
45
+ } from "../../stack";
40
46
 
41
47
  // --- Test fixtures ---
42
48
 
@@ -103,10 +109,10 @@ const executor = createEventStoreExecutor(itemTable, itemEntity, { entityName: "
103
109
 
104
110
  beforeAll(async () => {
105
111
  testDb = await createTestDb();
106
- await createEntityTable(testDb.db, itemEntity, "rebuild-item");
112
+ await unsafeCreateEntityTable(testDb.db, itemEntity, "rebuild-item");
107
113
  await createEventsTable(testDb.db);
108
114
  await createProjectionStateTable(testDb.db);
109
- await pushTables(testDb.db, { rebuildItemsPerGroup: itemsPerGroupTable });
115
+ await unsafePushTables(testDb.db, { rebuildItemsPerGroup: itemsPerGroupTable });
110
116
  tdb = createTenantDb(testDb.db, admin.tenantId);
111
117
  });
112
118
 
@@ -15,11 +15,11 @@ import { createEventStoreExecutor } from "../../db/event-store-executor";
15
15
  import { buildDrizzleTable } from "../../db/table-builder";
16
16
  import { createEntity, createTextField, defineFeature } from "../../engine";
17
17
  import {
18
- createEntityTable,
19
18
  resetEventStore,
20
19
  setupTestStack,
21
20
  type TestStack,
22
21
  TestUsers,
22
+ unsafeCreateEntityTable,
23
23
  } from "../../stack";
24
24
 
25
25
  const widgetEntity = createEntity({
@@ -121,7 +121,7 @@ const otherTenantAdmin = {
121
121
 
122
122
  beforeAll(async () => {
123
123
  stack = await setupTestStack({ features: [qpFeature], systemHooks: [] });
124
- await createEntityTable(stack.db, widgetEntity, "qp-widget");
124
+ await unsafeCreateEntityTable(stack.db, widgetEntity, "qp-widget");
125
125
  });
126
126
 
127
127
  afterAll(async () => {
@@ -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 current effective-feature set — the dispatcher uses it
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 resolver must be
325
- // fast and synchronous per call; implementations cache a DB snapshot
326
- // under the hood and refresh on toggle events.
327
- effectiveFeatures?: () => ReadonlySet<string>;
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. When the
733
- // feature-toggles feature isn't wired (no effectiveFeatures callback),
734
- // always returns true apps without toggles treat all features on.
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,7 +2,7 @@ import { sql } from "drizzle-orm";
2
2
  import type { DbConnection } from "../db/connection";
3
3
  import { bigint, index, instant, integer, table as pgTable, primaryKey, text } from "../db/dialect";
4
4
  import { tableExists } from "../db/schema-inspection";
5
- import { pushTables } from "../stack";
5
+ import { unsafePushTables } from "../stack";
6
6
 
7
7
  // Reserved sentinel used in the instance_id column for consumers whose
8
8
  // delivery is "shared" — i.e. one cursor across all dispatcher instances
@@ -104,5 +104,5 @@ export const CONSUMER_STATUSES = [
104
104
  export async function createEventConsumerStateTable(db: DbConnection): Promise<void> {
105
105
  // skip: table already exists — bootstrap is called from multiple paths
106
106
  if (await tableExists(db, "public.kumiko_event_consumers")) return;
107
- await pushTables(db, { kumikoEventConsumers: eventConsumerStateTable });
107
+ await unsafePushTables(db, { kumikoEventConsumers: eventConsumerStateTable });
108
108
  }
@@ -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
- const effective = context.effectiveFeatures?.();
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.effectiveFeatures?.();
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.effectiveFeatures?.();
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.effectiveFeatures?.();
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.effectiveFeatures?.();
329
+ const eff = currentEffectiveFeatures(context);
312
330
  await runHookSet({
313
331
  handlerName,
314
332
  payload,
@@ -2,7 +2,7 @@ import { sql } from "drizzle-orm";
2
2
  import type { DbConnection } from "../db/connection";
3
3
  import { bigint, index, instant, table as pgTable, text } from "../db/dialect";
4
4
  import { tableExists } from "../db/schema-inspection";
5
- import { pushTables } from "../stack";
5
+ import { unsafePushTables } from "../stack";
6
6
 
7
7
  // Framework-level state for every registered projection. One row per qualified
8
8
  // projection name. Written by the rebuild machinery; read by the CLI + any
@@ -23,7 +23,7 @@ import { pushTables } from "../stack";
23
23
  // last_processed_event_id uses a raw DEFAULT 0 instead of .default(0n) because
24
24
  // drizzle-kit's JSON snapshot generator cannot serialise bigint literals —
25
25
  // `TypeError: Do not know how to serialize a BigInt` bubbles through
26
- // pushTables → generateMigration. `sql\`0\`` yields the same server-side
26
+ // unsafePushTables → generateMigration. `sql\`0\`` yields the same server-side
27
27
  // default without ever putting a bigint in a generated-JSON path.
28
28
  export const projectionStateTable = pgTable(
29
29
  "kumiko_projections",
@@ -68,5 +68,5 @@ export const PROJECTION_STATUSES = [
68
68
  export async function createProjectionStateTable(db: DbConnection): Promise<void> {
69
69
  // skip: table already exists — bootstrap is called from multiple paths
70
70
  if (await tableExists(db, "public.kumiko_projections")) return;
71
- await pushTables(db, { kumikoProjections: projectionStateTable });
71
+ await unsafePushTables(db, { kumikoProjections: projectionStateTable });
72
72
  }
@@ -15,13 +15,14 @@ export {
15
15
  type TestDb,
16
16
  } from "./db";
17
17
  export { createEventCollector, type EventCollector } from "./event-collector";
18
+ export { pushEntityProjectionTables } from "./push-entity-projection-tables";
18
19
  export { createTestRedis, type TestRedis } from "./redis";
19
20
  export { createRequestHelper, type RequestHelper } from "./request-helper";
20
21
  export {
21
- createEntityTable,
22
- ensureEntityTable,
23
- pushTables,
24
22
  resetEventStore,
23
+ unsafeCreateEntityTable,
24
+ unsafeEnsureEntityTable,
25
+ unsafePushTables,
25
26
  } from "./table-helpers";
26
27
  export { setupTestStack, type TestStack, type TestStackOptions } from "./test-stack";
27
28
  export {
@@ -0,0 +1,51 @@
1
+ import { getTableName } from "drizzle-orm";
2
+ import { tableExists } from "../db/schema-inspection";
3
+ import type { Registry } from "../engine/types";
4
+ import { unsafePushTables } from "./table-helpers";
5
+ import type { TestStack } from "./test-stack";
6
+
7
+ // biome-ignore lint/suspicious/noConsole: stack-internal status logging
8
+ const logInfo = (msg: string): void => console.log(msg);
9
+
10
+ /**
11
+ * Push all implicit-projection tables — one per `r.entity()` — that the
12
+ * registry knows about. setupTestStack already handles explicit
13
+ * projections, MSPs, and `r.rawTable()` declarations in its own loop;
14
+ * implicit projections are the missing piece for a fresh boot.
15
+ *
16
+ * Idempotent via `tableExists` so a persistent dev DB
17
+ * (`KUMIKO_DEV_DB_NAME`) reuses existing tables on reboot. One batched
18
+ * push at the end so drizzle-kit's `generateMigration` runs once over
19
+ * the whole missing set.
20
+ *
21
+ * Lives next to `setupTestStack` because both are stack-bootstrap
22
+ * helpers that legitimately speak the `unsafe*`-DDL layer; the
23
+ * Table-DDL Guard's stack/** allowlist is the single shared exemption
24
+ * site. Apps still declare data via `r.entity()` / `r.rawTable()` and
25
+ * never call this directly.
26
+ */
27
+ export async function pushEntityProjectionTables(
28
+ stack: TestStack,
29
+ registry: Registry,
30
+ ): Promise<void> {
31
+ const seen = new Set<unknown>();
32
+ const missing: Record<string, unknown> = {};
33
+
34
+ for (const [projName, proj] of registry.getAllProjections()) {
35
+ if (!proj.isImplicit) continue;
36
+ if (seen.has(proj.table)) continue;
37
+ seen.add(proj.table);
38
+ // @cast-boundary drizzle-bridge — ProjectionTable + PgTable both round-trip
39
+ // through getTableName at runtime; the type system can't unify them.
40
+ const physical = getTableName(proj.table as Parameters<typeof getTableName>[0]);
41
+ if (await tableExists(stack.db, `public.${physical}`)) {
42
+ logInfo(`[kumiko-stack] table ${physical} already exists — skipping create`);
43
+ continue;
44
+ }
45
+ missing[projName] = proj.table;
46
+ }
47
+
48
+ if (Object.keys(missing).length > 0) {
49
+ await unsafePushTables(stack.db, missing);
50
+ }
51
+ }