@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.
- package/LICENSE +57 -0
- package/package.json +3 -3
- package/src/__tests__/anonymous-access.integration.ts +7 -7
- package/src/__tests__/error-contract.integration.ts +2 -2
- package/src/__tests__/field-access.integration.ts +2 -2
- package/src/__tests__/full-stack.integration.ts +2 -2
- package/src/__tests__/ownership.integration.ts +2 -2
- package/src/__tests__/raw-table.integration.ts +128 -0
- package/src/__tests__/reference-data.integration.ts +2 -2
- package/src/__tests__/transition-guard.integration.ts +4 -4
- package/src/api/__tests__/batch.integration.ts +3 -3
- package/src/api/__tests__/dispatcher-live.integration.ts +2 -2
- package/src/api/__tests__/nested-write.integration.ts +3 -3
- package/src/db/__tests__/drizzle-helpers.integration.ts +2 -2
- package/src/db/__tests__/event-store-executor-list.integration.ts +2 -2
- package/src/db/__tests__/event-store-executor.integration.ts +9 -3
- package/src/db/__tests__/implicit-projection-equivalence.integration.ts +3 -3
- package/src/db/__tests__/multi-row-insert.integration.ts +3 -3
- package/src/db/__tests__/schema-migration.integration.ts +9 -9
- package/src/db/__tests__/tenant-db.integration.ts +4 -4
- package/src/db/__tests__/unique-violation-mapping.integration.ts +2 -2
- package/src/db/schema-inspection.ts +1 -1
- package/src/engine/__tests__/raw-table.test.ts +149 -0
- package/src/engine/define-feature.ts +38 -0
- package/src/engine/index.ts +6 -0
- package/src/engine/registry.ts +46 -0
- package/src/engine/tier-resolver-extension.ts +78 -0
- package/src/engine/types/feature.ts +55 -0
- package/src/engine/types/handlers.ts +13 -5
- package/src/engine/types/index.ts +3 -0
- package/src/event-store/__tests__/upcaster.integration.ts +11 -5
- package/src/event-store/archive.ts +2 -2
- package/src/event-store/events-schema.ts +2 -2
- package/src/event-store/snapshot.ts +2 -2
- package/src/event-store/upcaster-dead-letter.ts +2 -2
- package/src/files/__tests__/file-field-column.integration.ts +4 -4
- package/src/files/__tests__/file-field-pipeline.integration.ts +2 -2
- package/src/files/__tests__/files.integration.ts +8 -8
- package/src/observability/__tests__/observability.integration.ts +2 -2
- package/src/pipeline/__tests__/archive-stream.integration.ts +2 -2
- package/src/pipeline/__tests__/cascade-handler.integration.ts +9 -9
- package/src/pipeline/__tests__/causation-chain.integration.ts +2 -2
- package/src/pipeline/__tests__/ctx-bridge.integration.ts +3 -3
- package/src/pipeline/__tests__/dispatcher.test.ts +73 -0
- package/src/pipeline/__tests__/domain-events-projections.integration.ts +2 -2
- package/src/pipeline/__tests__/event-dedup.integration.ts +2 -2
- package/src/pipeline/__tests__/event-define-event-strict.integration.ts +2 -2
- package/src/pipeline/__tests__/event-dispatcher-lifecycle.integration.ts +3 -3
- package/src/pipeline/__tests__/event-dispatcher-multi-instance.integration.ts +2 -2
- package/src/pipeline/__tests__/event-dispatcher-pg-listen.integration.ts +2 -2
- package/src/pipeline/__tests__/event-dispatcher-recovery.integration.ts +2 -2
- package/src/pipeline/__tests__/event-dispatcher-second-audit.integration.ts +4 -4
- package/src/pipeline/__tests__/event-dispatcher.integration.ts +4 -4
- package/src/pipeline/__tests__/event-retention.integration.ts +2 -2
- package/src/pipeline/__tests__/fetch-for-writing.integration.ts +2 -2
- package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +100 -0
- package/src/pipeline/__tests__/load-aggregate-query.integration.ts +2 -2
- package/src/pipeline/__tests__/msp-error-mode.integration.ts +2 -2
- package/src/pipeline/__tests__/msp-multi-hop.integration.ts +2 -2
- package/src/pipeline/__tests__/msp-rebuild.integration.ts +3 -3
- package/src/pipeline/__tests__/multi-stream-projection.integration.ts +3 -3
- package/src/pipeline/__tests__/perf-rebuild.integration.ts +2 -2
- package/src/pipeline/__tests__/projection-rebuild.integration.ts +9 -3
- package/src/pipeline/__tests__/query-projection.integration.ts +2 -2
- package/src/pipeline/dispatcher.ts +35 -15
- package/src/pipeline/event-consumer-state.ts +2 -2
- package/src/pipeline/event-dispatcher.ts +10 -1
- package/src/pipeline/lifecycle-pipeline.ts +22 -4
- package/src/pipeline/projection-state.ts +3 -3
- package/src/stack/index.ts +4 -3
- package/src/stack/push-entity-projection-tables.ts +51 -0
- package/src/stack/table-helpers.ts +20 -13
- package/src/stack/test-stack.ts +14 -5
- 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
|
|
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
|
|
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
|
|
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
|
|
215
|
-
await
|
|
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
|
|
166
|
-
await
|
|
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,
|
|
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
|
|
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 {
|
|
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
|
|
112
|
+
await unsafeCreateEntityTable(testDb.db, itemEntity, "rebuild-item");
|
|
107
113
|
await createEventsTable(testDb.db);
|
|
108
114
|
await createProjectionStateTable(testDb.db);
|
|
109
|
-
await
|
|
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
|
|
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
|
|
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,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 {
|
|
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
|
|
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
|
-
|
|
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,
|
|
@@ -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 {
|
|
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
|
-
//
|
|
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
|
|
71
|
+
await unsafePushTables(db, { kumikoProjections: projectionStateTable });
|
|
72
72
|
}
|
package/src/stack/index.ts
CHANGED
|
@@ -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
|
+
}
|