@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.
Files changed (68) hide show
  1. package/package.json +6 -2
  2. package/src/api/auth-routes.ts +32 -67
  3. package/src/api/routes.ts +1 -3
  4. package/src/bun-db/__tests__/PATTERN.md +0 -1
  5. package/src/db/__tests__/assert-no-unreachable-live-rows.integration.test.ts +29 -3
  6. package/src/db/__tests__/schema-inspection.test.ts +7 -0
  7. package/src/db/event-store-executor-context.ts +398 -0
  8. package/src/db/event-store-executor-read.ts +276 -0
  9. package/src/db/event-store-executor-write.ts +615 -0
  10. package/src/db/event-store-executor.ts +29 -1166
  11. package/src/db/queries/shadow-swap.ts +3 -1
  12. package/src/db/schema-inspection.ts +1 -1
  13. package/src/engine/__tests__/boot-validator-dashboard.test.ts +10 -0
  14. package/src/engine/__tests__/engine.test.ts +45 -1
  15. package/src/engine/__tests__/event-migration-declarative.test.ts +6 -0
  16. package/src/engine/__tests__/membership-roles.test.ts +4 -10
  17. package/src/engine/__tests__/screen.test.ts +26 -0
  18. package/src/engine/boot-validator/entity-list-screens.ts +1 -1
  19. package/src/engine/boot-validator/gdpr-storage.ts +1 -1
  20. package/src/engine/boot-validator/index.ts +12 -8
  21. package/src/engine/boot-validator/nav.ts +120 -0
  22. package/src/engine/boot-validator/{screens-nav.ts → screens.ts} +12 -190
  23. package/src/engine/boot-validator/workspaces.ts +68 -0
  24. package/src/engine/define-feature.ts +77 -1011
  25. package/src/engine/feature-ast/__tests__/fixtures/cross-file-registrar/feature.ts +9 -0
  26. package/src/engine/feature-ast/__tests__/fixtures/cross-file-registrar/screens.ts +4 -0
  27. package/src/engine/feature-ast/__tests__/parse-real-features.test.ts +18 -8
  28. package/src/engine/feature-ast/__tests__/parse.test.ts +291 -2
  29. package/src/engine/feature-ast/__tests__/patcher.test.ts +1 -1
  30. package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +73 -0
  31. package/src/engine/feature-ast/extractors/round1.ts +3 -3
  32. package/src/engine/feature-ast/extractors/round4.ts +45 -10
  33. package/src/engine/feature-ast/extractors/shared.ts +64 -6
  34. package/src/engine/feature-ast/parse.ts +123 -10
  35. package/src/engine/feature-ast/patterns.ts +14 -7
  36. package/src/engine/feature-ast/render.ts +28 -7
  37. package/src/engine/feature-builder-state.ts +165 -0
  38. package/src/engine/feature-config-events-jobs.ts +303 -0
  39. package/src/engine/feature-entity-handlers.ts +161 -0
  40. package/src/engine/feature-ui-extensions.ts +413 -0
  41. package/src/engine/index.ts +0 -2
  42. package/src/engine/pattern-library/library.ts +44 -1131
  43. package/src/engine/pattern-library/mixed-schemas.ts +484 -0
  44. package/src/engine/pattern-library/opaque-schemas.ts +124 -0
  45. package/src/engine/pattern-library/shared-fields.ts +80 -0
  46. package/src/engine/pattern-library/static-schemas.ts +456 -0
  47. package/src/engine/registry-facade.ts +349 -0
  48. package/src/engine/registry-ingest.ts +473 -0
  49. package/src/engine/registry-state.ts +388 -0
  50. package/src/engine/registry-validate.ts +660 -0
  51. package/src/engine/registry.ts +79 -1695
  52. package/src/engine/types/screen.ts +4 -2
  53. package/src/engine/types/tree-node.ts +2 -5
  54. package/src/event-store/snapshot.ts +7 -7
  55. package/src/i18n/required-surface-keys.ts +2 -0
  56. package/src/jobs/job-runner.ts +1 -1
  57. package/src/observability/standard-metrics.ts +4 -3
  58. package/src/pipeline/dispatch-batch.ts +3 -3
  59. package/src/pipeline/dispatch-shared.ts +17 -10
  60. package/src/pipeline/event-dispatcher-admin.ts +289 -0
  61. package/src/pipeline/event-dispatcher-delivery.ts +264 -0
  62. package/src/pipeline/event-dispatcher.ts +28 -540
  63. package/src/pipeline/projection-rebuild.ts +1 -1
  64. package/src/stack/__tests__/setup-test-stack-jobs.integration.test.ts +95 -0
  65. package/src/stack/test-stack.ts +46 -1
  66. package/src/testing/boot-validator-fixture.ts +1 -2
  67. package/src/engine/__tests__/deep-link.test.ts +0 -35
  68. 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.146.4",
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.146.4",
196
+ "@cosmicdrift/kumiko-dispatcher-live": "0.147.1",
193
197
  "bun-types": "^1.3.13",
194
198
  "pino-pretty": "^13.1.3"
195
199
  },
@@ -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). Creating the session BEFORE signing the
501
- // JWT is load-bearing: the sid must exist on the server before the
502
- // token that references it can be handed out, otherwise a fast client
503
- // could arrive at an auth-middleware check before the insert commits.
504
- let sessionForJwt: SessionUser = data.session;
505
- if (config.sessionCreator) {
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-Creator analog login wenn wired, sid wird im JWT
607
- // platziert und der Server kann später den Session revoken
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
- let sessionForJwt: SessionUser = data.session;
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
- let sessionForJwt: SessionUser = data.session;
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
- let sessionForJwt: SessionUser =
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
- if (config.sessionCreator) {
898
- const sid = await config.sessionCreator(sessionForJwt, requestMeta(c));
899
- sessionForJwt = { ...sessionForJwt, sid };
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
- // 615/1: pass the batch's command types, not undefined a single
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
- /have no\s+event in the projection's source streams/,
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
+ }