@cosmicdrift/kumiko-framework 0.146.4 → 0.147.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 +6 -2
- package/src/api/auth-routes.ts +32 -67
- package/src/api/routes.ts +1 -3
- package/src/bun-db/__tests__/PATTERN.md +0 -1
- package/src/db/__tests__/assert-no-unreachable-live-rows.integration.test.ts +29 -3
- package/src/db/__tests__/schema-inspection.test.ts +7 -0
- package/src/db/event-store-executor-context.ts +398 -0
- package/src/db/event-store-executor-read.ts +276 -0
- package/src/db/event-store-executor-write.ts +615 -0
- package/src/db/event-store-executor.ts +29 -1166
- package/src/db/queries/shadow-swap.ts +3 -1
- package/src/db/schema-inspection.ts +1 -1
- package/src/engine/__tests__/boot-validator-dashboard.test.ts +10 -0
- package/src/engine/__tests__/engine.test.ts +45 -1
- package/src/engine/__tests__/event-migration-declarative.test.ts +6 -0
- package/src/engine/__tests__/membership-roles.test.ts +4 -10
- package/src/engine/__tests__/screen.test.ts +26 -0
- package/src/engine/boot-validator/entity-list-screens.ts +1 -1
- package/src/engine/boot-validator/gdpr-storage.ts +1 -1
- package/src/engine/boot-validator/index.ts +12 -8
- package/src/engine/boot-validator/nav.ts +120 -0
- package/src/engine/boot-validator/{screens-nav.ts → screens.ts} +12 -190
- package/src/engine/boot-validator/workspaces.ts +68 -0
- package/src/engine/define-feature.ts +77 -1011
- package/src/engine/feature-ast/__tests__/fixtures/cross-file-registrar/feature.ts +9 -0
- package/src/engine/feature-ast/__tests__/fixtures/cross-file-registrar/screens.ts +4 -0
- package/src/engine/feature-ast/__tests__/parse-real-features.test.ts +18 -8
- package/src/engine/feature-ast/__tests__/parse.test.ts +291 -2
- package/src/engine/feature-ast/__tests__/patcher.test.ts +1 -1
- package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +73 -0
- package/src/engine/feature-ast/extractors/round1.ts +3 -3
- package/src/engine/feature-ast/extractors/round4.ts +45 -10
- package/src/engine/feature-ast/extractors/shared.ts +64 -6
- package/src/engine/feature-ast/parse.ts +123 -10
- package/src/engine/feature-ast/patterns.ts +14 -7
- package/src/engine/feature-ast/render.ts +28 -7
- package/src/engine/feature-builder-state.ts +165 -0
- package/src/engine/feature-config-events-jobs.ts +303 -0
- package/src/engine/feature-entity-handlers.ts +161 -0
- package/src/engine/feature-ui-extensions.ts +413 -0
- package/src/engine/index.ts +0 -2
- package/src/engine/pattern-library/library.ts +44 -1131
- package/src/engine/pattern-library/mixed-schemas.ts +484 -0
- package/src/engine/pattern-library/opaque-schemas.ts +124 -0
- package/src/engine/pattern-library/shared-fields.ts +80 -0
- package/src/engine/pattern-library/static-schemas.ts +456 -0
- package/src/engine/registry-facade.ts +349 -0
- package/src/engine/registry-ingest.ts +473 -0
- package/src/engine/registry-state.ts +388 -0
- package/src/engine/registry-validate.ts +660 -0
- package/src/engine/registry.ts +79 -1695
- package/src/engine/types/screen.ts +4 -2
- package/src/engine/types/tree-node.ts +2 -5
- package/src/event-store/snapshot.ts +7 -7
- package/src/i18n/required-surface-keys.ts +2 -0
- package/src/jobs/job-runner.ts +1 -1
- package/src/observability/standard-metrics.ts +4 -3
- package/src/pipeline/dispatch-batch.ts +3 -3
- package/src/pipeline/dispatch-shared.ts +17 -10
- package/src/pipeline/event-dispatcher-admin.ts +289 -0
- package/src/pipeline/event-dispatcher-delivery.ts +264 -0
- package/src/pipeline/event-dispatcher.ts +28 -540
- package/src/pipeline/projection-rebuild.ts +1 -1
- package/src/stack/__tests__/setup-test-stack-jobs.integration.test.ts +95 -0
- package/src/stack/test-stack.ts +46 -1
- package/src/testing/boot-validator-fixture.ts +1 -2
- package/src/engine/__tests__/deep-link.test.ts +0 -35
- package/src/engine/deep-link.ts +0 -23
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.147.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>",
|
|
@@ -99,6 +99,10 @@
|
|
|
99
99
|
"types": "./src/jobs/index.ts",
|
|
100
100
|
"default": "./src/jobs/index.ts"
|
|
101
101
|
},
|
|
102
|
+
"./observability": {
|
|
103
|
+
"types": "./src/observability/index.ts",
|
|
104
|
+
"default": "./src/observability/index.ts"
|
|
105
|
+
},
|
|
102
106
|
"./migrations": {
|
|
103
107
|
"types": "./src/migrations/index.ts",
|
|
104
108
|
"default": "./src/migrations/index.ts"
|
|
@@ -189,7 +193,7 @@
|
|
|
189
193
|
"zod": "^4.4.3"
|
|
190
194
|
},
|
|
191
195
|
"devDependencies": {
|
|
192
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
196
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.147.1",
|
|
193
197
|
"bun-types": "^1.3.13",
|
|
194
198
|
"pino-pretty": "^13.1.3"
|
|
195
199
|
},
|
package/src/api/auth-routes.ts
CHANGED
|
@@ -441,6 +441,23 @@ export function createAuthRoutes(
|
|
|
441
441
|
const cookieSameSite = config.cookieSameSite ?? "lax";
|
|
442
442
|
const cookieDomain = config.cookieDomain;
|
|
443
443
|
|
|
444
|
+
// Shared tail of every route that ends a request logged-in: create the
|
|
445
|
+
// session record (if wired), sign the JWT, set the auth+csrf cookies. Was
|
|
446
|
+
// duplicated 5x (login/signup-confirm/invite-accept-with-login/invite-
|
|
447
|
+
// signup-complete/switch-tenant) — extracted so a 6th caller (mfa-verify)
|
|
448
|
+
// doesn't grow it to 6.
|
|
449
|
+
async function mintSessionAndRespond(c: Context, session: SessionUser): Promise<string> {
|
|
450
|
+
let sessionForJwt = session;
|
|
451
|
+
if (config.sessionCreator) {
|
|
452
|
+
const sid = await config.sessionCreator(session, requestMeta(c));
|
|
453
|
+
sessionForJwt = { ...session, sid };
|
|
454
|
+
}
|
|
455
|
+
const token = await jwt.sign(sessionForJwt);
|
|
456
|
+
const csrfToken = generateToken();
|
|
457
|
+
setAuthCookies(c, { token, csrfToken, sameSite: cookieSameSite, domain: cookieDomain });
|
|
458
|
+
return token;
|
|
459
|
+
}
|
|
460
|
+
|
|
444
461
|
// POST /auth/login — public endpoint (bypasses auth middleware via PUBLIC_API_PATHS).
|
|
445
462
|
// The configured login handler authenticates and returns a SessionUser;
|
|
446
463
|
// the route signs the JWT and hands it back to the client.
|
|
@@ -497,30 +514,17 @@ export function createAuthRoutes(
|
|
|
497
514
|
// @cast-boundary engine-payload — generic dispatcher.write result for auth-session handler
|
|
498
515
|
const data = result.data as { kind: "auth-session"; session: SessionUser };
|
|
499
516
|
|
|
500
|
-
// Session creation (optional)
|
|
501
|
-
//
|
|
502
|
-
//
|
|
503
|
-
//
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
const sid = await config.sessionCreator(data.session, requestMeta(c));
|
|
507
|
-
sessionForJwt = { ...data.session, sid };
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
const token = await jwt.sign(sessionForJwt);
|
|
517
|
+
// Session creation (optional) + JWT sign + cookies — see
|
|
518
|
+
// mintSessionAndRespond. Creating the session BEFORE signing the JWT
|
|
519
|
+
// is load-bearing: the sid must exist on the server before the token
|
|
520
|
+
// that references it can be handed out, otherwise a fast client could
|
|
521
|
+
// arrive at an auth-middleware check before the insert commits.
|
|
522
|
+
const token = await mintSessionAndRespond(c, data.session);
|
|
511
523
|
|
|
512
524
|
if (rateLimiter) {
|
|
513
525
|
await rateLimiter.reset(rateLimitKey);
|
|
514
526
|
}
|
|
515
527
|
|
|
516
|
-
// Cookie transport (web): set HttpOnly auth cookie + JS-readable csrf
|
|
517
|
-
// cookie. Bearer transport (native) reads the token from the body
|
|
518
|
-
// below — the token is returned for both, so a Bearer client that
|
|
519
|
-
// ignores Set-Cookie keeps working without any server-side knowledge
|
|
520
|
-
// of which transport this client will use next.
|
|
521
|
-
const csrfToken = generateToken();
|
|
522
|
-
setAuthCookies(c, { token, csrfToken, sameSite: cookieSameSite, domain: cookieDomain });
|
|
523
|
-
|
|
524
528
|
return c.json({
|
|
525
529
|
isSuccess: true,
|
|
526
530
|
token,
|
|
@@ -603,18 +607,8 @@ export function createAuthRoutes(
|
|
|
603
607
|
tenantKey: string;
|
|
604
608
|
};
|
|
605
609
|
|
|
606
|
-
// Session
|
|
607
|
-
|
|
608
|
-
// (Logout, Compromise).
|
|
609
|
-
let sessionForJwt: SessionUser = data.session;
|
|
610
|
-
if (config.sessionCreator) {
|
|
611
|
-
const sid = await config.sessionCreator(data.session, requestMeta(c));
|
|
612
|
-
sessionForJwt = { ...data.session, sid };
|
|
613
|
-
}
|
|
614
|
-
|
|
615
|
-
const token = await jwt.sign(sessionForJwt);
|
|
616
|
-
const csrfToken = generateToken();
|
|
617
|
-
setAuthCookies(c, { token, csrfToken, sameSite: cookieSameSite, domain: cookieDomain });
|
|
610
|
+
// Session creation + JWT sign + cookies — see mintSessionAndRespond.
|
|
611
|
+
const token = await mintSessionAndRespond(c, data.session);
|
|
618
612
|
|
|
619
613
|
return c.json({
|
|
620
614
|
isSuccess: true,
|
|
@@ -690,14 +684,7 @@ export function createAuthRoutes(
|
|
|
690
684
|
tenantId: TenantId;
|
|
691
685
|
role: string;
|
|
692
686
|
}; // @cast-boundary engine-payload
|
|
693
|
-
|
|
694
|
-
if (config.sessionCreator) {
|
|
695
|
-
const sid = await config.sessionCreator(data.session, requestMeta(c));
|
|
696
|
-
sessionForJwt = { ...data.session, sid };
|
|
697
|
-
}
|
|
698
|
-
const token = await jwt.sign(sessionForJwt);
|
|
699
|
-
const csrfToken = generateToken();
|
|
700
|
-
setAuthCookies(c, { token, csrfToken, sameSite: cookieSameSite, domain: cookieDomain });
|
|
687
|
+
const token = await mintSessionAndRespond(c, data.session);
|
|
701
688
|
return c.json({
|
|
702
689
|
isSuccess: true,
|
|
703
690
|
token,
|
|
@@ -730,14 +717,7 @@ export function createAuthRoutes(
|
|
|
730
717
|
tenantId: TenantId;
|
|
731
718
|
role: string;
|
|
732
719
|
}; // @cast-boundary engine-payload
|
|
733
|
-
|
|
734
|
-
if (config.sessionCreator) {
|
|
735
|
-
const sid = await config.sessionCreator(data.session, requestMeta(c));
|
|
736
|
-
sessionForJwt = { ...data.session, sid };
|
|
737
|
-
}
|
|
738
|
-
const token = await jwt.sign(sessionForJwt);
|
|
739
|
-
const csrfToken = generateToken();
|
|
740
|
-
setAuthCookies(c, { token, csrfToken, sameSite: cookieSameSite, domain: cookieDomain });
|
|
720
|
+
const token = await mintSessionAndRespond(c, data.session);
|
|
741
721
|
return c.json({
|
|
742
722
|
isSuccess: true,
|
|
743
723
|
token,
|
|
@@ -882,7 +862,7 @@ export function createAuthRoutes(
|
|
|
882
862
|
roles: mergedRoles,
|
|
883
863
|
};
|
|
884
864
|
const claims = await dispatcher.resolveAuthClaims(targetSession);
|
|
885
|
-
|
|
865
|
+
const sessionForJwt: SessionUser =
|
|
886
866
|
Object.keys(claims).length > 0 ? { ...targetSession, claims } : targetSession;
|
|
887
867
|
|
|
888
868
|
// Session rotation: revoke old sid BEFORE creating the new one so a
|
|
@@ -894,25 +874,10 @@ export function createAuthRoutes(
|
|
|
894
874
|
if (config.sessionRevoker && user.sid) {
|
|
895
875
|
await config.sessionRevoker(user.sid);
|
|
896
876
|
}
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
const newToken = await jwt.sign(sessionForJwt);
|
|
903
|
-
|
|
904
|
-
// Rotate both cookies in lock-step with the new JWT. A fresh csrfToken
|
|
905
|
-
// is minted so a compromised csrf-value (e.g. leaked via a bug in the
|
|
906
|
-
// app's JS) can't cross a tenant boundary. Bearer-only clients get
|
|
907
|
-
// the new token in the body below — their Set-Cookie is a no-op
|
|
908
|
-
// because the browser never sent cookies.
|
|
909
|
-
const csrfToken = generateToken();
|
|
910
|
-
setAuthCookies(c, {
|
|
911
|
-
token: newToken,
|
|
912
|
-
csrfToken,
|
|
913
|
-
sameSite: cookieSameSite,
|
|
914
|
-
domain: cookieDomain,
|
|
915
|
-
});
|
|
877
|
+
// Session creation + JWT sign + cookies — see mintSessionAndRespond. A
|
|
878
|
+
// fresh csrfToken is minted (inside the helper) so a compromised csrf-
|
|
879
|
+
// value can't cross a tenant boundary.
|
|
880
|
+
const newToken = await mintSessionAndRespond(c, sessionForJwt);
|
|
916
881
|
|
|
917
882
|
return c.json({ token: newToken, tenantId: targetTenantId, roles: mergedRoles });
|
|
918
883
|
});
|
package/src/api/routes.ts
CHANGED
|
@@ -90,9 +90,7 @@ export function createApiRoutes(dispatcher: Dispatcher) {
|
|
|
90
90
|
}
|
|
91
91
|
return c.json(result);
|
|
92
92
|
} catch (e) {
|
|
93
|
-
//
|
|
94
|
-
// "type" doesn't apply to a batch, but logging none loses the fault
|
|
95
|
-
// context entirely.
|
|
93
|
+
// single "type" doesn't apply to a batch; command types keep the fault context.
|
|
96
94
|
return writeErrorResponse(c, toKumiko(e), body.commands?.map((cmd) => cmd.type).join(","));
|
|
97
95
|
}
|
|
98
96
|
});
|
|
@@ -8,7 +8,6 @@ db/postgres-provider.ts postgres-js Factory (default, stabil)
|
|
|
8
8
|
db/bun-provider.ts Bun.SQL Factory (DB_PROVIDER=bun, experimentell)
|
|
9
9
|
stack/db.ts createTestDb() — provider-agnostisch via createConnection
|
|
10
10
|
stack/test-stack.ts setupTestStack() — provider-agnostisch via createTestDb
|
|
11
|
-
bun-db/__tests__/bun-test-stack.ts Alias: setupBunTestStack → setupTestStack
|
|
12
11
|
bun-db/__tests__/bun-test-db.ts Alias: createBunTestDb → createTestDb
|
|
13
12
|
```
|
|
14
13
|
|
|
@@ -14,7 +14,7 @@ import { type BunTestDb, createTestDb } from "../../bun-db/__tests__/bun-test-db
|
|
|
14
14
|
import { asRawClient, insertOne, selectMany } from "../../db/query";
|
|
15
15
|
import { createBooleanField, createEntity, createTextField, defineFeature } from "../../engine";
|
|
16
16
|
import { createRegistry } from "../../engine/registry";
|
|
17
|
-
import { createEventsTable } from "../../event-store";
|
|
17
|
+
import { archiveStream, createArchivedStreamsTable, createEventsTable } from "../../event-store";
|
|
18
18
|
import { rebuildProjection } from "../../pipeline";
|
|
19
19
|
import { createProjectionStateTable } from "../../pipeline/projection-state";
|
|
20
20
|
import { TestUsers, unsafeCreateEntityTable } from "../../stack";
|
|
@@ -50,6 +50,7 @@ beforeAll(async () => {
|
|
|
50
50
|
testDb = await createTestDb();
|
|
51
51
|
await unsafeCreateEntityTable(testDb.db, userEntity, "user");
|
|
52
52
|
await createEventsTable(testDb.db);
|
|
53
|
+
await createArchivedStreamsTable(testDb.db);
|
|
53
54
|
await createProjectionStateTable(testDb.db);
|
|
54
55
|
tdb = createTenantDb(testDb.db, adminUser.tenantId);
|
|
55
56
|
});
|
|
@@ -60,7 +61,7 @@ afterAll(async () => {
|
|
|
60
61
|
|
|
61
62
|
beforeEach(async () => {
|
|
62
63
|
await asRawClient(testDb.db).unsafe(
|
|
63
|
-
`TRUNCATE kumiko_events, read_unreachable_users, kumiko_projections RESTART IDENTITY CASCADE`,
|
|
64
|
+
`TRUNCATE kumiko_events, kumiko_archived_streams, read_unreachable_users, kumiko_projections RESTART IDENTITY CASCADE`,
|
|
64
65
|
);
|
|
65
66
|
});
|
|
66
67
|
|
|
@@ -105,7 +106,8 @@ describe("assert-no-unreachable-live-rows / #722 ghost-row guard", () => {
|
|
|
105
106
|
expect(await snapshotTable()).toHaveLength(2);
|
|
106
107
|
|
|
107
108
|
await expect(rebuildProjection(projectionName, { db: testDb.db, registry })).rejects.toThrow(
|
|
108
|
-
|
|
109
|
+
// #915: exact count below the truncation limit, no misleading "+" suffix.
|
|
110
|
+
/^projection-rebuild ".*": 1 live rows in ".*" have no\s+event in the projection's source streams/,
|
|
109
111
|
);
|
|
110
112
|
|
|
111
113
|
// Swap never ran: both rows — including the ghost — survive.
|
|
@@ -136,4 +138,28 @@ describe("assert-no-unreachable-live-rows / #722 ghost-row guard", () => {
|
|
|
136
138
|
// The direct write is overwritten by the replay — deliberately allowed.
|
|
137
139
|
expect(rebuilt?.["firstName"]).toBe("Alice");
|
|
138
140
|
});
|
|
141
|
+
|
|
142
|
+
test("archived stream (fw#832 tombstone) does NOT trip the guard", async () => {
|
|
143
|
+
// archiveStream never deletes the .created event — projection rebuild
|
|
144
|
+
// deliberately excludes archived streams from replay (fw#832, see
|
|
145
|
+
// projection-rebuild.ts), so the row is wiped from live on swap. The
|
|
146
|
+
// guard only checks event EXISTENCE, not replay-inclusion, so it must
|
|
147
|
+
// not fire — this is the shipped tombstone behavior, not drift.
|
|
148
|
+
const crud = createEventStoreExecutor(userTable, userEntity, { entityName: "user" });
|
|
149
|
+
const a = await crud.create({ email: "a@test.de", firstName: "Alice" }, adminUser, tdb);
|
|
150
|
+
if (!a.isSuccess) throw new Error("setup failed");
|
|
151
|
+
|
|
152
|
+
await archiveStream(testDb.db, {
|
|
153
|
+
tenantId: adminUser.tenantId,
|
|
154
|
+
aggregateId: a.data.id as string,
|
|
155
|
+
aggregateType: "user",
|
|
156
|
+
archivedBy: adminUser.id,
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// No throw — the row is event-backed despite being archived.
|
|
160
|
+
const result = await rebuildProjection(projectionName, { db: testDb.db, registry });
|
|
161
|
+
|
|
162
|
+
expect(result.eventsProcessed).toBe(0);
|
|
163
|
+
expect(await snapshotTable()).toHaveLength(0);
|
|
164
|
+
});
|
|
139
165
|
});
|
|
@@ -17,4 +17,11 @@ describe("schema-inspection — resolveUnsafeClient guard", () => {
|
|
|
17
17
|
test("columnNamesOf throws the same named error", async () => {
|
|
18
18
|
await expect(columnNamesOf(noUnsafe, "x")).rejects.toThrow(/resolveUnsafeClient: no `\.unsafe/);
|
|
19
19
|
});
|
|
20
|
+
|
|
21
|
+
test("tableExists throws the same named error when .unsafe is a non-function value", async () => {
|
|
22
|
+
const notAFunction = { unsafe: "notafunction" } as unknown as DbConnection;
|
|
23
|
+
await expect(tableExists(notAFunction, "public.x")).rejects.toThrow(
|
|
24
|
+
/resolveUnsafeClient: no `\.unsafe/,
|
|
25
|
+
);
|
|
26
|
+
});
|
|
20
27
|
});
|
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
import { requestContext } from "../api/request-context";
|
|
2
|
+
import {
|
|
3
|
+
collectPiiSubjectFields,
|
|
4
|
+
configuredPiiSubjectKms,
|
|
5
|
+
decryptPiiFieldValues,
|
|
6
|
+
encryptPiiFieldValues,
|
|
7
|
+
type KmsContext,
|
|
8
|
+
type LocalKeyKmsAdapter,
|
|
9
|
+
} from "../crypto";
|
|
10
|
+
import { executeRawQuery } from "../db/queries/raw-sql";
|
|
11
|
+
import type { WhereObject } from "../db/query";
|
|
12
|
+
import { shiftParams } from "../engine/ownership";
|
|
13
|
+
import type {
|
|
14
|
+
EntityDefinition,
|
|
15
|
+
EntityId,
|
|
16
|
+
FieldDefinition,
|
|
17
|
+
SessionUser,
|
|
18
|
+
TenantId,
|
|
19
|
+
} from "../engine/types";
|
|
20
|
+
import { SYSTEM_TENANT_ID } from "../engine/types/identifiers";
|
|
21
|
+
import { UniqueViolationError, type WriteFailure, writeFailure } from "../errors";
|
|
22
|
+
import { ArchivedStreamError, type EventMetadata, isStreamArchived } from "../event-store";
|
|
23
|
+
import type { EntityCache } from "../pipeline/entity-cache";
|
|
24
|
+
import type { SearchAdapter } from "../search/types";
|
|
25
|
+
import type { EnvelopeCipher } from "../secrets/envelope-cipher";
|
|
26
|
+
import { assertUnreachable } from "../utils";
|
|
27
|
+
import { rehydrateCompoundTypes } from "./compound-types";
|
|
28
|
+
import type { DbRow } from "./connection";
|
|
29
|
+
import type { TableColumns } from "./dialect";
|
|
30
|
+
import {
|
|
31
|
+
collectEncryptedFieldNames,
|
|
32
|
+
decryptEntityFieldValues,
|
|
33
|
+
encryptEntityFieldValues,
|
|
34
|
+
resolveEntityFieldEncryption,
|
|
35
|
+
} from "./entity-field-encryption";
|
|
36
|
+
import type { EventStoreExecutorOptions } from "./event-store-executor";
|
|
37
|
+
import { constraintOf, isUniqueViolation } from "./pg-error";
|
|
38
|
+
import { toSnakeCase } from "./table-builder";
|
|
39
|
+
import type { TenantDb } from "./tenant-db";
|
|
40
|
+
|
|
41
|
+
// Shared context-building for the event-store-executor CRUD verbs (create/
|
|
42
|
+
// update/delete/forget/restore/list/detail — see event-store-executor-write.ts
|
|
43
|
+
// and event-store-executor-read.ts). Precomputes entity-derived state once
|
|
44
|
+
// per createEventStoreExecutor() call and bundles the crypto/ownership
|
|
45
|
+
// helpers the verbs need, so the verb modules don't each re-derive them.
|
|
46
|
+
|
|
47
|
+
// biome-ignore lint/suspicious/noExplicitAny: Drizzle dynamic tables
|
|
48
|
+
export type Table = TableColumns<any>;
|
|
49
|
+
|
|
50
|
+
// Screen-Filter (Tier 2.7c) — Op-Mapping als Where-Operator. Boot-
|
|
51
|
+
// Validator pinst field-Existenz + filterable + op-vs-Type-Compat.
|
|
52
|
+
// `op` ist auf {eq,ne,lt,gt,in} normalisiert; "in" mit empty-array ist
|
|
53
|
+
// explizit no-match.
|
|
54
|
+
export function buildFilterWhere(
|
|
55
|
+
field: string,
|
|
56
|
+
op: "eq" | "ne" | "lt" | "gt" | "in",
|
|
57
|
+
value: unknown,
|
|
58
|
+
): WhereObject | null {
|
|
59
|
+
switch (op) {
|
|
60
|
+
case "eq":
|
|
61
|
+
return { [field]: value };
|
|
62
|
+
case "ne":
|
|
63
|
+
return { [field]: { ne: value } };
|
|
64
|
+
case "lt":
|
|
65
|
+
return { [field]: { lt: value } };
|
|
66
|
+
case "gt":
|
|
67
|
+
return { [field]: { gt: value } };
|
|
68
|
+
case "in":
|
|
69
|
+
if (Array.isArray(value) && value.length > 0) {
|
|
70
|
+
return { [field]: value };
|
|
71
|
+
}
|
|
72
|
+
return null; // no-match short-circuit
|
|
73
|
+
default:
|
|
74
|
+
return assertUnreachable(op, "filter op");
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Returns the scalar default of a field, or undefined if the field's type
|
|
79
|
+
// doesn't carry a default or no default was declared. Only scalar types
|
|
80
|
+
// (text/number/boolean/select) support creation-time defaults — money/date/
|
|
81
|
+
// file/embedded fields don't.
|
|
82
|
+
function scalarDefault(field: FieldDefinition): unknown {
|
|
83
|
+
switch (field.type) {
|
|
84
|
+
case "text":
|
|
85
|
+
case "longText":
|
|
86
|
+
case "number":
|
|
87
|
+
case "boolean":
|
|
88
|
+
case "select":
|
|
89
|
+
return field.default;
|
|
90
|
+
default:
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// F8 helper: PG-23505 (unique-violation) catched aus applyEntityEvent
|
|
96
|
+
// (create + update Pfade) → WriteFailure(UniqueViolationError 409).
|
|
97
|
+
// Andere Errors propagieren via re-throw. Lokal extrahiert weil das
|
|
98
|
+
// Pattern an zwei Stellen im executor lebt — der Caller wrap't den
|
|
99
|
+
// applyEntityEvent-call in try-catch und delegiert das Mapping hierher.
|
|
100
|
+
//
|
|
101
|
+
// Returns WriteFailure on match, null otherwise (caller re-throws).
|
|
102
|
+
export function tryMapUniqueViolation(e: unknown, entityName: string): WriteFailure | null {
|
|
103
|
+
if (!isUniqueViolation(e)) return null;
|
|
104
|
+
const constraintName = constraintOf(e);
|
|
105
|
+
return writeFailure(
|
|
106
|
+
new UniqueViolationError(
|
|
107
|
+
{
|
|
108
|
+
entityName,
|
|
109
|
+
...(constraintName !== undefined && { constraintName }),
|
|
110
|
+
},
|
|
111
|
+
{ cause: e instanceof Error ? e : undefined },
|
|
112
|
+
),
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Build the metadata envelope for an append. userId always set; requestId +
|
|
117
|
+
// correlation + causation come from the AsyncLocalStorage request-context
|
|
118
|
+
// when present (e.g. HTTP request, MSP-apply, job run). requestId is a pure
|
|
119
|
+
// trace marker — HTTP-level retry idempotency runs separately via
|
|
120
|
+
// pipeline/idempotency.ts (Redis-cached response replay), so a single
|
|
121
|
+
// request can write N events freely without the events-table needing a
|
|
122
|
+
// uniqueness constraint.
|
|
123
|
+
export function buildEventMetadata(user: SessionUser): EventMetadata {
|
|
124
|
+
const reqCtx = requestContext.get();
|
|
125
|
+
return {
|
|
126
|
+
userId: String(user.id),
|
|
127
|
+
...(reqCtx?.requestId ? { requestId: reqCtx.requestId } : {}),
|
|
128
|
+
...(reqCtx?.correlationId ? { correlationId: reqCtx.correlationId } : {}),
|
|
129
|
+
...(reqCtx?.causationId ? { causationId: reqCtx.causationId } : {}),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Lifecycle verbs the event-store-executor auto-emits. MSPs that react
|
|
134
|
+
// to entity creates/updates/etc should reference this helper instead of
|
|
135
|
+
// hardcoding the string — a future rename in the executor then surfaces
|
|
136
|
+
// as a type error at every call site rather than a silent miss.
|
|
137
|
+
export type EntityLifecycleVerb = "created" | "updated" | "deleted" | "restored" | "forgotten";
|
|
138
|
+
|
|
139
|
+
export function entityEventName(entityName: string, verb: EntityLifecycleVerb): string {
|
|
140
|
+
return `${entityName}.${verb}`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export type ExecutorContext = {
|
|
144
|
+
readonly table: Table;
|
|
145
|
+
readonly entity: EntityDefinition;
|
|
146
|
+
readonly entityName: string;
|
|
147
|
+
readonly entityCache?: EntityCache;
|
|
148
|
+
readonly searchAdapter?: SearchAdapter;
|
|
149
|
+
readonly softDelete: boolean;
|
|
150
|
+
readonly streamTenantFor: (user: SessionUser) => TenantId;
|
|
151
|
+
readonly idFilter: (id: EntityId) => WhereObject;
|
|
152
|
+
readonly loadById: (id: EntityId, db: TenantDb) => Promise<Record<string, unknown> | null>;
|
|
153
|
+
readonly assertStreamWritable: (db: TenantDb, id: EntityId, tenantId: TenantId) => Promise<void>;
|
|
154
|
+
readonly loadWithOwnership: (
|
|
155
|
+
db: TenantDb,
|
|
156
|
+
idWhere: WhereObject,
|
|
157
|
+
ownership:
|
|
158
|
+
| { kind: "pass" }
|
|
159
|
+
| { kind: "empty" }
|
|
160
|
+
| { kind: "sql"; sqlText: string; params: readonly unknown[] },
|
|
161
|
+
) => Promise<Record<string, unknown>[]>;
|
|
162
|
+
readonly encryptForStorage: (
|
|
163
|
+
row: Record<string, unknown>,
|
|
164
|
+
user: SessionUser,
|
|
165
|
+
opts?: { onlyKeys?: Iterable<string>; subjectSource?: Record<string, unknown> },
|
|
166
|
+
) => Promise<Record<string, unknown>>;
|
|
167
|
+
readonly decryptForRead: (row: Record<string, unknown>) => Promise<Record<string, unknown>>;
|
|
168
|
+
readonly applyDefaults: (payload: Record<string, unknown>) => Record<string, unknown>;
|
|
169
|
+
readonly stripSensitive: (
|
|
170
|
+
payload: Record<string, unknown> | undefined,
|
|
171
|
+
) => Record<string, unknown>;
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
export function buildExecutorContext(
|
|
175
|
+
table: Table,
|
|
176
|
+
entity: EntityDefinition,
|
|
177
|
+
options: EventStoreExecutorOptions,
|
|
178
|
+
): ExecutorContext {
|
|
179
|
+
const { searchAdapter, entityName, entityCache } = options;
|
|
180
|
+
const softDelete = entity.softDelete ?? false;
|
|
181
|
+
|
|
182
|
+
// Stream-tenant choke-point. A systemStream entity (tenant-independent, e.g.
|
|
183
|
+
// user) lives on SYSTEM_TENANT_ID deterministically — every op addresses it
|
|
184
|
+
// there. Everything else stays on the caller's tenant (byte-identical to the
|
|
185
|
+
// old hardcoded user.tenantId). Single source of truth for the stream key.
|
|
186
|
+
const streamTenantFor = (user: SessionUser): TenantId =>
|
|
187
|
+
entity.systemStream ? SYSTEM_TENANT_ID : user.tenantId;
|
|
188
|
+
|
|
189
|
+
// idType default (undefined) is now "uuid" — the ES-pivot made UUID the
|
|
190
|
+
// only valid aggregate-id type. Explicit `idType: "serial"` is the only
|
|
191
|
+
// shape that's incompatible with the event-store and still rejected.
|
|
192
|
+
if (entity.idType !== undefined && entity.idType !== "uuid") {
|
|
193
|
+
throw new Error(
|
|
194
|
+
`event-store-executor requires entity "${entityName}" to declare idType: "uuid" — ` +
|
|
195
|
+
`got idType: "${entity.idType}". ` +
|
|
196
|
+
`The events-table keys aggregates by uuid(aggregate_id); non-UUID PKs would ` +
|
|
197
|
+
`require a schema split the framework does not currently support. ` +
|
|
198
|
+
`Fix: remove the \`idType\`-override from createEntity({...}) for "${entityName}" ` +
|
|
199
|
+
`(the default is "uuid"). The framework auto-assigns UUIDs on create — ` +
|
|
200
|
+
`you do not need to generate them yourself. ` +
|
|
201
|
+
`See docs/plans/architecture/event-sourcing-pivot.md (section "UUID-only aggregate IDs") for the full rationale.`,
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Pre-compute defaults once so create() doesn't loop the entity every call.
|
|
206
|
+
const fieldDefaults: Record<string, unknown> = {};
|
|
207
|
+
for (const [name, field] of Object.entries(entity.fields)) {
|
|
208
|
+
const def = scalarDefault(field);
|
|
209
|
+
if (def !== undefined) fieldDefaults[name] = def;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Pre-compute the set of sensitive field names once. The event log stores
|
|
213
|
+
// these fields as table ciphertext (boot validates sensitive ⇒ pii |
|
|
214
|
+
// encrypted, #967) — the set only strips the caller-facing event echo so
|
|
215
|
+
// responses never carry the value (#820).
|
|
216
|
+
const sensitiveFields = new Set<string>();
|
|
217
|
+
for (const [name, field] of Object.entries(entity.fields)) {
|
|
218
|
+
if ("sensitive" in field && field.sensitive === true) {
|
|
219
|
+
sensitiveFields.add(name);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const encryptedFields = collectEncryptedFieldNames(entity);
|
|
224
|
+
const hasEncryptedFields = encryptedFields.size > 0;
|
|
225
|
+
|
|
226
|
+
const piiSubjectFields = collectPiiSubjectFields(entity);
|
|
227
|
+
const hasPiiFields = piiSubjectFields.length > 0;
|
|
228
|
+
|
|
229
|
+
function fieldCipher(): EnvelopeCipher {
|
|
230
|
+
if (options.encryption) return options.encryption;
|
|
231
|
+
return resolveEntityFieldEncryption();
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// No adapter configured = crypto-shredding off; pii fields stay plaintext
|
|
235
|
+
// (pre-#724 behavior). The hard boot gate ships with the prod-grade
|
|
236
|
+
// PgKmsAdapter (phase E).
|
|
237
|
+
function piiKms(): LocalKeyKmsAdapter | undefined {
|
|
238
|
+
return options.kms ?? configuredPiiSubjectKms();
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function kmsContextFor(user?: SessionUser): KmsContext {
|
|
242
|
+
return {
|
|
243
|
+
requestId: requestContext.get()?.requestId ?? "event-store-executor",
|
|
244
|
+
...(user && { tenantId: user.tenantId, userId: String(user.id) }),
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Async on purpose: the envelope cipher wraps/unwraps DEKs via the
|
|
249
|
+
// MasterKeyProvider. Callers MUST await — a missed await here writes
|
|
250
|
+
// "[object Promise]" into the projection, which the Promise return
|
|
251
|
+
// types turn into a compile error at every call site.
|
|
252
|
+
async function encryptForStorage(
|
|
253
|
+
row: Record<string, unknown>,
|
|
254
|
+
user: SessionUser,
|
|
255
|
+
opts?: { onlyKeys?: Iterable<string>; subjectSource?: Record<string, unknown> },
|
|
256
|
+
): Promise<Record<string, unknown>> {
|
|
257
|
+
let out = row;
|
|
258
|
+
if (hasEncryptedFields) {
|
|
259
|
+
out = await encryptEntityFieldValues(out, encryptedFields, fieldCipher(), {
|
|
260
|
+
...(opts?.onlyKeys !== undefined && { onlyKeys: opts.onlyKeys }),
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
const kms = piiKms();
|
|
264
|
+
if (hasPiiFields && kms) {
|
|
265
|
+
out = await encryptPiiFieldValues(out, entity, piiSubjectFields, kms, kmsContextFor(user), {
|
|
266
|
+
tenantId: user.tenantId,
|
|
267
|
+
...(opts?.onlyKeys !== undefined && { onlyKeys: opts.onlyKeys }),
|
|
268
|
+
...(opts?.subjectSource !== undefined && { subjectSource: opts.subjectSource }),
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
return out;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async function decryptForRead(row: Record<string, unknown>): Promise<Record<string, unknown>> {
|
|
275
|
+
let out = row;
|
|
276
|
+
if (hasEncryptedFields) {
|
|
277
|
+
out = await decryptEntityFieldValues(out, encryptedFields, fieldCipher());
|
|
278
|
+
}
|
|
279
|
+
const kms = piiKms();
|
|
280
|
+
if (hasPiiFields && kms) {
|
|
281
|
+
out = await decryptPiiFieldValues(out, piiSubjectFields, kms, kmsContextFor());
|
|
282
|
+
}
|
|
283
|
+
return out;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function applyDefaults(payload: Record<string, unknown>): Record<string, unknown> {
|
|
287
|
+
if (Object.keys(fieldDefaults).length === 0) return payload;
|
|
288
|
+
const result: Record<string, unknown> = { ...payload };
|
|
289
|
+
for (const [name, def] of Object.entries(fieldDefaults)) {
|
|
290
|
+
if (result[name] === undefined) result[name] = def;
|
|
291
|
+
}
|
|
292
|
+
return result;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function stripSensitive(payload: Record<string, unknown> | undefined): Record<string, unknown> {
|
|
296
|
+
if (!payload) return {};
|
|
297
|
+
if (sensitiveFields.size === 0) return payload;
|
|
298
|
+
const result: Record<string, unknown> = {};
|
|
299
|
+
for (const [key, value] of Object.entries(payload)) {
|
|
300
|
+
if (sensitiveFields.has(key)) continue;
|
|
301
|
+
result[key] = value;
|
|
302
|
+
}
|
|
303
|
+
return result;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function idFilter(id: EntityId): WhereObject {
|
|
307
|
+
const filter: WhereObject = { id };
|
|
308
|
+
if (softDelete && table["isDeleted"]) filter["isDeleted"] = false;
|
|
309
|
+
return filter;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
async function loadById(id: EntityId, db: TenantDb): Promise<Record<string, unknown> | null> {
|
|
313
|
+
const row = await db.fetchOne(table, idFilter(id));
|
|
314
|
+
if (!row) return null;
|
|
315
|
+
return await decryptForRead(rehydrateCompoundTypes(row as DbRow, entity));
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Archive guard for the CRUD write paths. Archived streams are read-only —
|
|
319
|
+
// ctx.appendEvent (append-event-core) already enforces this, but the
|
|
320
|
+
// executor appends directly via append() and getStreamVersion() ignores
|
|
321
|
+
// the archive flag, so without this check a PATCH/DELETE on an archived
|
|
322
|
+
// entity would silently land an event and break the read-only contract
|
|
323
|
+
// (loadAggregate returns [] for the same stream). Throws ArchivedStreamError
|
|
324
|
+
// to mirror the appendEvent path exactly — same 500 + rolled-back tx.
|
|
325
|
+
// Creates skip this: a fresh UUID can't be archived, and a deterministic-id
|
|
326
|
+
// re-create onto an archived stream collides on the unique index →
|
|
327
|
+
// version_conflict, which already blocks the write.
|
|
328
|
+
async function assertStreamWritable(
|
|
329
|
+
db: TenantDb,
|
|
330
|
+
id: EntityId,
|
|
331
|
+
tenantId: TenantId,
|
|
332
|
+
): Promise<void> {
|
|
333
|
+
if (await isStreamArchived(db.raw, tenantId, String(id))) {
|
|
334
|
+
throw new ArchivedStreamError(tenantId, String(id));
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// SELECT a row by id with the ownership clause applied at the DB layer.
|
|
339
|
+
// Detail() uses this both on cold path and as a cache-revalidation probe.
|
|
340
|
+
async function loadWithOwnership(
|
|
341
|
+
db: TenantDb,
|
|
342
|
+
idWhere: WhereObject,
|
|
343
|
+
ownership:
|
|
344
|
+
| { kind: "pass" }
|
|
345
|
+
| { kind: "empty" }
|
|
346
|
+
| { kind: "sql"; sqlText: string; params: readonly unknown[] },
|
|
347
|
+
): Promise<Record<string, unknown>[]> {
|
|
348
|
+
if (ownership.kind === "empty") return [];
|
|
349
|
+
if (ownership.kind === "pass") {
|
|
350
|
+
const row = await db.fetchOne(table, idWhere);
|
|
351
|
+
return row ? [row as Record<string, unknown>] : [];
|
|
352
|
+
}
|
|
353
|
+
// ownership has raw SQL — splice it into a raw query alongside the
|
|
354
|
+
// idFilter + tenant-filter that TenantDb would have added.
|
|
355
|
+
const tableName = String(
|
|
356
|
+
(table as unknown as Record<symbol, unknown>)[Symbol.for("kumiko:schema:Name")],
|
|
357
|
+
);
|
|
358
|
+
const colSql = (field: string): string =>
|
|
359
|
+
`"${(table[field] as { name?: string } | undefined)?.name ?? toSnakeCase(field)}"`;
|
|
360
|
+
const whereParts: string[] = [];
|
|
361
|
+
const params: unknown[] = [];
|
|
362
|
+
if (table["tenantId"] !== undefined && db.mode === "tenant") {
|
|
363
|
+
params.push(db.tenantId, SYSTEM_TENANT_ID);
|
|
364
|
+
whereParts.push(`${colSql("tenantId")} IN ($${params.length - 1}, $${params.length})`);
|
|
365
|
+
}
|
|
366
|
+
for (const [field, value] of Object.entries(idWhere)) {
|
|
367
|
+
if (typeof value === "boolean") {
|
|
368
|
+
whereParts.push(`${colSql(field)} = ${value ? "TRUE" : "FALSE"}`);
|
|
369
|
+
} else {
|
|
370
|
+
params.push(value);
|
|
371
|
+
whereParts.push(`${colSql(field)} = $${params.length}`);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
const shifted = shiftParams(ownership, params.length);
|
|
375
|
+
whereParts.push(shifted.sqlText);
|
|
376
|
+
for (const p of shifted.params) params.push(p);
|
|
377
|
+
const sqlText = `SELECT * FROM "${tableName}" WHERE ${whereParts.join(" AND ")} LIMIT 1`;
|
|
378
|
+
return [...(await executeRawQuery<Record<string, unknown>>(db.raw, sqlText, params))];
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
return {
|
|
382
|
+
table,
|
|
383
|
+
entity,
|
|
384
|
+
entityName,
|
|
385
|
+
entityCache,
|
|
386
|
+
searchAdapter,
|
|
387
|
+
softDelete,
|
|
388
|
+
streamTenantFor,
|
|
389
|
+
idFilter,
|
|
390
|
+
loadById,
|
|
391
|
+
assertStreamWritable,
|
|
392
|
+
loadWithOwnership,
|
|
393
|
+
encryptForStorage,
|
|
394
|
+
decryptForRead,
|
|
395
|
+
applyDefaults,
|
|
396
|
+
stripSensitive,
|
|
397
|
+
};
|
|
398
|
+
}
|