@cosmicdrift/kumiko-framework 0.157.2 → 0.159.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 (110) hide show
  1. package/package.json +7 -2
  2. package/src/__tests__/consumer-cli.integration.test.ts +110 -0
  3. package/src/api/__tests__/auth-routes-cookie.test.ts +16 -1
  4. package/src/api/__tests__/csrf-constants-sync.test.ts +20 -0
  5. package/src/api/__tests__/jwt.test.ts +150 -1
  6. package/src/api/__tests__/redis-login-rate-limiter.integration.test.ts +66 -0
  7. package/src/api/__tests__/server-jwt-ttl.test.ts +58 -0
  8. package/src/api/api-constants.ts +4 -0
  9. package/src/api/auth-middleware.ts +48 -59
  10. package/src/api/auth-routes.ts +83 -17
  11. package/src/api/index.ts +8 -4
  12. package/src/api/jwt.ts +148 -7
  13. package/src/api/pii-leak-guard.ts +5 -2
  14. package/src/api/server.ts +19 -5
  15. package/src/bun-db/__tests__/select-many-retry.test.ts +79 -0
  16. package/src/bun-db/query.ts +34 -2
  17. package/src/consumer-cli.ts +87 -0
  18. package/src/crypto/__tests__/pii-field-encryption.test.ts +69 -13
  19. package/src/crypto/blind-index.ts +8 -4
  20. package/src/crypto/event-pii.ts +1 -0
  21. package/src/crypto/pii-field-encryption.ts +49 -15
  22. package/src/db/__tests__/event-store-executor-context.pii-roundtrip.test.ts +67 -0
  23. package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +305 -0
  24. package/src/db/__tests__/event-store-executor.integration.test.ts +5 -5
  25. package/src/db/blind-index-cleanup.ts +3 -1
  26. package/src/db/connection.ts +3 -11
  27. package/src/db/encryption.ts +2 -3
  28. package/src/db/entity-table-meta-types.ts +92 -0
  29. package/src/db/entity-table-meta.ts +16 -90
  30. package/src/db/queries/backfill-pii.ts +1 -0
  31. package/src/db/queries/event-consumer.ts +35 -2
  32. package/src/engine/__tests__/boot-validator-boot-check.test.ts +99 -0
  33. package/src/engine/__tests__/boot-validator-gdpr-storage.test.ts +7 -233
  34. package/src/engine/__tests__/define-roles.test.ts +21 -0
  35. package/src/engine/__tests__/event-type-map-augmentation.test.ts +24 -0
  36. package/src/engine/__tests__/store-table.test.ts +12 -0
  37. package/src/engine/boot-validator/action-wiring.ts +1 -1
  38. package/src/engine/boot-validator/boot-check.ts +21 -0
  39. package/src/engine/boot-validator/entity-list-screens.ts +1 -1
  40. package/src/engine/boot-validator/gdpr-storage.ts +0 -112
  41. package/src/engine/boot-validator/index.ts +3 -9
  42. package/src/engine/boot-validator/screens.ts +1 -1
  43. package/src/engine/define-feature.ts +1 -0
  44. package/src/engine/define-handler.ts +10 -91
  45. package/src/engine/entity-handlers.ts +15 -27
  46. package/src/engine/feature-builder-state.ts +3 -0
  47. package/src/engine/feature-config-events-jobs.ts +1 -1
  48. package/src/engine/feature-entity-handlers.ts +1 -1
  49. package/src/engine/feature-ui-extensions.ts +5 -1
  50. package/src/engine/field-helpers.ts +31 -0
  51. package/src/engine/handler-helpers.ts +26 -0
  52. package/src/engine/hook-helpers.ts +14 -0
  53. package/src/engine/index.ts +2 -2
  54. package/src/engine/ownership.ts +22 -76
  55. package/src/engine/registry-validate.ts +1 -1
  56. package/src/engine/screen-helpers.ts +54 -0
  57. package/src/engine/tier-resolver-extension.ts +3 -2
  58. package/src/engine/types/define-handler.ts +94 -0
  59. package/src/engine/types/entity-handlers.ts +30 -0
  60. package/src/engine/types/event-type-map.ts +1 -37
  61. package/src/engine/types/feature.ts +45 -0
  62. package/src/engine/types/fields.ts +19 -31
  63. package/src/engine/types/handlers.ts +7 -26
  64. package/src/engine/types/hooks.ts +1 -15
  65. package/src/engine/types/http-route.ts +1 -72
  66. package/src/engine/types/identifiers.ts +1 -47
  67. package/src/engine/types/index.ts +34 -9
  68. package/src/engine/types/ownership.ts +83 -0
  69. package/src/engine/types/relations.ts +1 -51
  70. package/src/engine/types/screen.ts +0 -46
  71. package/src/engine/types/target-ref.ts +1 -21
  72. package/src/engine/types/tree-node.ts +1 -129
  73. package/src/entrypoint/index.ts +2 -2
  74. package/src/event-store/__tests__/event-store.integration.test.ts +31 -0
  75. package/src/event-store/__tests__/unscoped-stream-primitives.guard.test.ts +43 -0
  76. package/src/event-store/event-store.ts +28 -32
  77. package/src/event-store/events-schema.ts +1 -10
  78. package/src/event-store/index.ts +3 -2
  79. package/src/event-store/types.ts +22 -0
  80. package/src/files/__tests__/in-memory-provider.contract.test.ts +4 -0
  81. package/src/files/file-handle.ts +2 -19
  82. package/src/i18n/required-surface-keys.ts +1 -1
  83. package/src/logging/types.ts +1 -7
  84. package/src/observability/types/index.ts +1 -29
  85. package/src/observability/types/metric.ts +1 -56
  86. package/src/observability/types/provider.ts +1 -32
  87. package/src/observability/types/span.ts +1 -58
  88. package/src/pipeline/__tests__/dispatcher.test.ts +38 -1
  89. package/src/pipeline/__tests__/event-dispatcher-delivery-max-attempts.test.ts +126 -0
  90. package/src/pipeline/__tests__/event-dispatcher-rearm.integration.test.ts +180 -0
  91. package/src/pipeline/dispatch-shared.ts +12 -2
  92. package/src/pipeline/entity-cache.ts +2 -33
  93. package/src/pipeline/event-consumer-state.ts +28 -3
  94. package/src/pipeline/event-dispatcher-admin.ts +4 -0
  95. package/src/pipeline/event-dispatcher-delivery.ts +29 -3
  96. package/src/pipeline/event-dispatcher.ts +27 -1
  97. package/src/pipeline/system-hooks.ts +7 -0
  98. package/src/search/types.ts +1 -39
  99. package/src/secrets/__tests__/envelope-cipher.test.ts +2 -30
  100. package/src/secrets/__tests__/envelope.test.ts +1 -1
  101. package/src/secrets/envelope-cipher.ts +13 -39
  102. package/src/stack/__tests__/event-collector.test.ts +42 -0
  103. package/src/testing/__tests__/late-bound.test.ts +25 -0
  104. package/src/testing/__tests__/wait-for.test.ts +53 -0
  105. package/src/testing/boot-validator-fixture.ts +1 -1
  106. package/src/testing/file-provider-contract.ts +84 -0
  107. package/src/testing/handler-context.ts +1 -1
  108. package/src/testing/index.ts +1 -0
  109. package/src/time/geo-tz.ts +1 -32
  110. package/src/ui-types/index.ts +7 -7
@@ -0,0 +1,305 @@
1
+ // The five write-verbs (create/update/delete/forget/restore) have several
2
+ // error paths the existing suites (event-store-executor.integration.test.ts,
3
+ // unique-violation-mapping.integration.test.ts) don't touch: entity- and
4
+ // field-level ownership denials, explicit version conflicts, the forget()
5
+ // verb entirely, and restore()'s two precondition failures.
6
+
7
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
8
+ import { asRawClient } from "../../db/query";
9
+ import { createEntity, createTextField } from "../../engine";
10
+ import { from } from "../../engine/ownership";
11
+ import { createEventsTable } from "../../event-store";
12
+ import { createTestDb, type TestDb, TestUsers, unsafeCreateEntityTable } from "../../stack";
13
+ import { createEventStoreExecutor } from "../event-store-executor";
14
+ import { buildEntityTable } from "../table-builder";
15
+ import { createTenantDb, type TenantDb } from "../tenant-db";
16
+
17
+ let testDb: TestDb;
18
+ let tdb: TenantDb;
19
+ const admin = TestUsers.admin;
20
+ const nonAdmin = TestUsers.user;
21
+
22
+ beforeAll(async () => {
23
+ testDb = await createTestDb();
24
+ await createEventsTable(testDb.db);
25
+ tdb = createTenantDb(testDb.db, admin.tenantId);
26
+ });
27
+
28
+ afterAll(async () => {
29
+ await testDb.cleanup();
30
+ });
31
+
32
+ // =============================================================================
33
+ // entity-level ownership_denied — create/update/delete/forget/restore
34
+ // =============================================================================
35
+
36
+ const restrictedEntity = createEntity({
37
+ table: "read_es_write_restricted",
38
+ fields: {
39
+ email: createTextField({ required: true }),
40
+ },
41
+ softDelete: true,
42
+ access: { write: { Admin: "all" } },
43
+ });
44
+ const restrictedTable = buildEntityTable("esWriteRestricted", restrictedEntity);
45
+
46
+ describe("event-store-executor write-verbs — entity-level ownership_denied", () => {
47
+ const crud = createEventStoreExecutor(restrictedTable, restrictedEntity, {
48
+ entityName: "esWriteRestricted",
49
+ });
50
+
51
+ beforeAll(async () => {
52
+ await unsafeCreateEntityTableFor(restrictedEntity, "esWriteRestricted");
53
+ });
54
+
55
+ beforeEach(async () => {
56
+ await asRawClient(testDb.db).unsafe(
57
+ `TRUNCATE kumiko_events, read_es_write_restricted RESTART IDENTITY CASCADE`,
58
+ );
59
+ });
60
+
61
+ test("create: role without a write-rule → ownership_denied", async () => {
62
+ const result = await crud.create({ email: "denied@test.de" }, nonAdmin, tdb);
63
+ expect(result.isSuccess).toBe(false);
64
+ if (result.isSuccess) return;
65
+ expect((result.error.details as { reason?: string }).reason).toBe("ownership_denied");
66
+ });
67
+
68
+ test("update: role without a write-rule → ownership_denied", async () => {
69
+ const created = await crud.create({ email: "owner@test.de" }, admin, tdb);
70
+ if (!created.isSuccess) throw new Error("setup failed");
71
+
72
+ const result = await crud.update(
73
+ { id: created.data.id, version: 1, changes: { email: "changed@test.de" } },
74
+ nonAdmin,
75
+ tdb,
76
+ );
77
+ expect(result.isSuccess).toBe(false);
78
+ if (result.isSuccess) return;
79
+ expect((result.error.details as { reason?: string }).reason).toBe("ownership_denied");
80
+ });
81
+
82
+ test("delete: role without a write-rule → ownership_denied", async () => {
83
+ const created = await crud.create({ email: "todelete@test.de" }, admin, tdb);
84
+ if (!created.isSuccess) throw new Error("setup failed");
85
+
86
+ const result = await crud.delete({ id: created.data.id }, nonAdmin, tdb);
87
+ expect(result.isSuccess).toBe(false);
88
+ if (result.isSuccess) return;
89
+ expect((result.error.details as { reason?: string }).reason).toBe("ownership_denied");
90
+ });
91
+
92
+ test("forget: role without a write-rule → ownership_denied", async () => {
93
+ const created = await crud.create({ email: "toforget@test.de" }, admin, tdb);
94
+ if (!created.isSuccess) throw new Error("setup failed");
95
+
96
+ const result = await crud.forget({ id: created.data.id }, nonAdmin, tdb);
97
+ expect(result.isSuccess).toBe(false);
98
+ if (result.isSuccess) return;
99
+ expect((result.error.details as { reason?: string }).reason).toBe("ownership_denied");
100
+ });
101
+
102
+ test("forget: happy path hard-deletes the row and appends a forgotten event", async () => {
103
+ const created = await crud.create({ email: "gone@test.de" }, admin, tdb);
104
+ if (!created.isSuccess) throw new Error("setup failed");
105
+
106
+ const result = await crud.forget({ id: created.data.id }, admin, tdb);
107
+ expect(result.isSuccess).toBe(true);
108
+
109
+ const rows = (await asRawClient(testDb.db).unsafe(
110
+ `SELECT id FROM read_es_write_restricted WHERE email = 'gone@test.de'`,
111
+ )) as unknown[];
112
+ expect(rows).toHaveLength(0);
113
+
114
+ const events = (await asRawClient(testDb.db).unsafe(
115
+ `SELECT type FROM kumiko_events WHERE type = 'esWriteRestricted.forgotten'`,
116
+ )) as unknown[];
117
+ expect(events).toHaveLength(1);
118
+ });
119
+
120
+ test("restore: role without a write-rule → ownership_denied", async () => {
121
+ const created = await crud.create({ email: "torestore@test.de" }, admin, tdb);
122
+ if (!created.isSuccess) throw new Error("setup failed");
123
+ await crud.delete({ id: created.data.id }, admin, tdb);
124
+
125
+ const result = await crud.restore({ id: created.data.id }, nonAdmin, tdb);
126
+ expect(result.isSuccess).toBe(false);
127
+ if (result.isSuccess) return;
128
+ expect((result.error.details as { reason?: string }).reason).toBe("ownership_denied");
129
+ });
130
+
131
+ test("restore: not yet deleted row → not_deleted", async () => {
132
+ const created = await crud.create({ email: "notdeleted@test.de" }, admin, tdb);
133
+ if (!created.isSuccess) throw new Error("setup failed");
134
+
135
+ const result = await crud.restore({ id: created.data.id }, admin, tdb);
136
+ expect(result.isSuccess).toBe(false);
137
+ if (result.isSuccess) return;
138
+ expect((result.error.details as { reason?: string }).reason).toBe("not_deleted");
139
+ });
140
+ });
141
+
142
+ // =============================================================================
143
+ // restore() on a non-soft-delete entity → soft_delete_not_enabled
144
+ // =============================================================================
145
+
146
+ const hardDeleteEntity = createEntity({
147
+ table: "read_es_write_hard",
148
+ fields: {
149
+ email: createTextField({ required: true }),
150
+ },
151
+ });
152
+ const hardDeleteTable = buildEntityTable("esWriteHard", hardDeleteEntity);
153
+
154
+ describe("event-store-executor write-verbs — restore without softDelete", () => {
155
+ const crud = createEventStoreExecutor(hardDeleteTable, hardDeleteEntity, {
156
+ entityName: "esWriteHard",
157
+ });
158
+
159
+ beforeAll(async () => {
160
+ await unsafeCreateEntityTableFor(hardDeleteEntity, "esWriteHard");
161
+ });
162
+
163
+ beforeEach(async () => {
164
+ await asRawClient(testDb.db).unsafe(
165
+ `TRUNCATE kumiko_events, read_es_write_hard RESTART IDENTITY CASCADE`,
166
+ );
167
+ });
168
+
169
+ test("restore on an entity without softDelete → soft_delete_not_enabled", async () => {
170
+ const created = await crud.create({ email: "hard@test.de" }, admin, tdb);
171
+ if (!created.isSuccess) throw new Error("setup failed");
172
+
173
+ const result = await crud.restore({ id: created.data.id }, admin, tdb);
174
+ expect(result.isSuccess).toBe(false);
175
+ if (result.isSuccess) return;
176
+ expect((result.error.details as { reason?: string }).reason).toBe("soft_delete_not_enabled");
177
+ });
178
+ });
179
+
180
+ // =============================================================================
181
+ // field-level ownership_denied — checkWriteFieldOwnership via a row-scoped rule
182
+ // =============================================================================
183
+
184
+ const ownedFieldEntity = createEntity({
185
+ table: "read_es_write_owned_field",
186
+ fields: {
187
+ authorId: createTextField({ required: true }),
188
+ note: createTextField({
189
+ access: { write: { Admin: "all", User: from("user:id", "authorId") } },
190
+ }),
191
+ },
192
+ });
193
+ const ownedFieldTable = buildEntityTable("esWriteOwnedField", ownedFieldEntity);
194
+
195
+ describe("event-store-executor write-verbs — field-level ownership_denied", () => {
196
+ const crud = createEventStoreExecutor(ownedFieldTable, ownedFieldEntity, {
197
+ entityName: "esWriteOwnedField",
198
+ });
199
+
200
+ beforeAll(async () => {
201
+ await unsafeCreateEntityTableFor(ownedFieldEntity, "esWriteOwnedField");
202
+ });
203
+
204
+ beforeEach(async () => {
205
+ await asRawClient(testDb.db).unsafe(
206
+ `TRUNCATE kumiko_events, read_es_write_owned_field RESTART IDENTITY CASCADE`,
207
+ );
208
+ });
209
+
210
+ test("create: writing `note` while authorId names someone else → ownership_denied (field scope)", async () => {
211
+ const result = await crud.create(
212
+ { authorId: TestUsers.driver.id, note: "not mine" },
213
+ nonAdmin,
214
+ tdb,
215
+ );
216
+ expect(result.isSuccess).toBe(false);
217
+ if (result.isSuccess) return;
218
+ const details = result.error.details as { reason?: string; scope?: string; field?: string };
219
+ expect(details.reason).toBe("ownership_denied");
220
+ expect(details.scope).toBe("field");
221
+ expect(details.field).toBe("note");
222
+ });
223
+
224
+ test("create: writing `note` as the named author succeeds", async () => {
225
+ const result = await crud.create({ authorId: nonAdmin.id, note: "mine" }, nonAdmin, tdb);
226
+ expect(result.isSuccess).toBe(true);
227
+ });
228
+
229
+ test("update: changing `note` on someone else's row → ownership_denied (field scope)", async () => {
230
+ const created = await crud.create(
231
+ { authorId: TestUsers.driver.id, note: "original" },
232
+ admin,
233
+ tdb,
234
+ );
235
+ if (!created.isSuccess) throw new Error("setup failed");
236
+
237
+ const result = await crud.update(
238
+ { id: created.data.id, version: 1, changes: { note: "hijacked" } },
239
+ nonAdmin,
240
+ tdb,
241
+ );
242
+ expect(result.isSuccess).toBe(false);
243
+ if (result.isSuccess) return;
244
+ expect((result.error.details as { reason?: string }).reason).toBe("ownership_denied");
245
+ });
246
+ });
247
+
248
+ // =============================================================================
249
+ // version_conflict — explicit-id create collision, missing version on update
250
+ // =============================================================================
251
+
252
+ const versionEntity = createEntity({
253
+ table: "read_es_write_version",
254
+ fields: {
255
+ email: createTextField({ required: true }),
256
+ },
257
+ });
258
+ const versionTable = buildEntityTable("esWriteVersion", versionEntity);
259
+
260
+ describe("event-store-executor write-verbs — version_conflict edge cases", () => {
261
+ const crud = createEventStoreExecutor(versionTable, versionEntity, {
262
+ entityName: "esWriteVersion",
263
+ });
264
+
265
+ beforeAll(async () => {
266
+ await unsafeCreateEntityTableFor(versionEntity, "esWriteVersion");
267
+ });
268
+
269
+ beforeEach(async () => {
270
+ await asRawClient(testDb.db).unsafe(
271
+ `TRUNCATE kumiko_events, read_es_write_version RESTART IDENTITY CASCADE`,
272
+ );
273
+ });
274
+
275
+ test("create with an explicit id that already has a stream → version_conflict", async () => {
276
+ const first = await crud.create({ email: "first@test.de" }, admin, tdb);
277
+ if (!first.isSuccess) throw new Error("setup failed");
278
+
279
+ const collision = await crud.create({ id: first.data.id, email: "second@test.de" }, admin, tdb);
280
+ expect(collision.isSuccess).toBe(false);
281
+ if (collision.isSuccess) return;
282
+ expect(collision.error.code).toBe("version_conflict");
283
+ });
284
+
285
+ test("update without a version field (and no skipOptimisticLock) → version_conflict", async () => {
286
+ const created = await crud.create({ email: "noversion@test.de" }, admin, tdb);
287
+ if (!created.isSuccess) throw new Error("setup failed");
288
+
289
+ const result = await crud.update(
290
+ { id: created.data.id, changes: { email: "changed@test.de" } },
291
+ admin,
292
+ tdb,
293
+ );
294
+ expect(result.isSuccess).toBe(false);
295
+ if (result.isSuccess) return;
296
+ expect(result.error.code).toBe("version_conflict");
297
+ });
298
+ });
299
+
300
+ async function unsafeCreateEntityTableFor(
301
+ entity: Parameters<typeof buildEntityTable>[1],
302
+ name: string,
303
+ ): Promise<void> {
304
+ await unsafeCreateEntityTable(testDb.db, entity, name);
305
+ }
@@ -1,5 +1,5 @@
1
1
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
2
- import { InMemoryKmsAdapter, PII_ERASED_SENTINEL } from "../../crypto";
2
+ import { InMemoryKmsAdapter, PII_CIPHERTEXT_PREFIX, PII_ERASED_SENTINEL } from "../../crypto";
3
3
  import { asRawClient } from "../../db/query";
4
4
  import { createBooleanField, createEntity, createTextField } from "../../engine";
5
5
  import { append, createEventsTable, loadEventsAfterVersion } from "../../event-store";
@@ -726,13 +726,13 @@ describe("event-store-executor — pii subject encryption", () => {
726
726
  const rawRows = (await asRawClient(testDb.db).unsafe(
727
727
  `SELECT email, plain FROM read_es_exec_pii LIMIT 1`,
728
728
  )) as Array<{ email: string; plain: string }>;
729
- expect(rawRows[0]!.email).toStartWith(`kumiko-pii:v1:user:${result.data.id}:`);
729
+ expect(rawRows[0]!.email).toStartWith(`${PII_CIPHERTEXT_PREFIX}user:${result.data.id}:`);
730
730
  expect(rawRows[0]!.plain).toBe("visible");
731
731
 
732
732
  const events = (await asRawClient(testDb.db).unsafe(
733
733
  `SELECT payload FROM kumiko_events WHERE type = 'esExecPii.created' LIMIT 1`,
734
734
  )) as Array<{ payload: { email?: string } }>;
735
- expect(events[0]?.payload.email).toStartWith("kumiko-pii:v1:");
735
+ expect(events[0]?.payload.email).toStartWith(PII_CIPHERTEXT_PREFIX);
736
736
 
737
737
  const detail = await crud.detail({ id: result.data.id }, adminUser, tdb);
738
738
  expect(detail?.["email"]).toBe("pii@test.de");
@@ -759,7 +759,7 @@ describe("event-store-executor — pii subject encryption", () => {
759
759
  const rawRows = (await asRawClient(testDb.db).unsafe(
760
760
  `SELECT note FROM read_es_exec_pii LIMIT 1`,
761
761
  )) as Array<{ note: string }>;
762
- expect(rawRows[0]!.note).toStartWith(`kumiko-pii:v1:user:${TestUsers.admin.id}:`);
762
+ expect(rawRows[0]!.note).toStartWith(`${PII_CIPHERTEXT_PREFIX}user:${TestUsers.admin.id}:`);
763
763
  });
764
764
 
765
765
  test("eraseKey: detail renders the sentinel, stored events stay byte-identical", async () => {
@@ -805,7 +805,7 @@ describe("event-store-executor — pii subject encryption", () => {
805
805
  const rawRows = (await asRawClient(testDb.db).unsafe(
806
806
  `SELECT email FROM read_es_exec_pii LIMIT 1`,
807
807
  )) as Array<{ email: string }>;
808
- expect(rawRows[0]!.email).toStartWith("kumiko-pii:v1:");
808
+ expect(rawRows[0]!.email).toStartWith(PII_CIPHERTEXT_PREFIX);
809
809
  expect(rawRows[0]!.email).not.toContain("rebuild@test.de");
810
810
 
811
811
  const detail = await crud.detail({ id: created.data.id }, adminUser, tdb);
@@ -31,7 +31,9 @@ export async function nullBlindIndexesForSubject(
31
31
  features: ReadonlyMap<string, FeatureDefinition>,
32
32
  subjectKey: string,
33
33
  ): Promise<void> {
34
- const likePattern = `kumiko-pii:v1:${escapeLikePattern(subjectKey)}:%`;
34
+ // "v%" matches any format version (v1 no-AAD, v2 AAD-bound, #1263) — the
35
+ // subject key placement is stable across versions.
36
+ const likePattern = `kumiko-pii:v%:${escapeLikePattern(subjectKey)}:%`;
35
37
  for (const feature of features.values()) {
36
38
  for (const [entityName, entity] of Object.entries(feature.entities ?? {})) {
37
39
  const lookupable = collectLookupableFields(entity);
@@ -1,20 +1,12 @@
1
1
  // DB-Connection-Types: provider-agnostic via db/api.ts.
2
2
  // createConnection delegiert an postgres-provider (default) oder bun-provider (DB_PROVIDER=bun).
3
+ import type { DbConnection, PgClient } from "@cosmicdrift/kumiko-types/db-connection";
3
4
  import postgres from "postgres";
4
5
  import { readPositiveIntEnv } from "../utils/env-parse";
5
6
 
6
- export { createConnection, type DbConnectionOptions } from "./api";
7
-
8
7
  // Legacy Types — für Aufrufer die direkt diese Module importieren
9
- // biome-ignore lint/suspicious/noExplicitAny: Bun.SQL global type
10
- export type DbConnection = ReturnType<typeof postgres> | any;
11
- // biome-ignore lint/suspicious/noExplicitAny: postgres-js namespace lookup
12
- export type DbTx = postgres.TransactionSql<any> | any;
13
- export type DbRunner = DbConnection | DbTx;
14
- export type DbRow = Record<string, unknown>;
15
- export type PgClient = ReturnType<typeof postgres>;
16
-
17
- export type PgListenClient = ReturnType<typeof postgres>;
8
+ export * from "@cosmicdrift/kumiko-types/db-connection";
9
+ export { createConnection, type DbConnectionOptions } from "./api";
18
10
 
19
11
  // Legacy: postgres-js only. Neue Aufrufer: createConnection() aus api.ts.
20
12
  // guard:dup-ok — andere Layer als createPgConnection (gibt DbConnection zurück, nicht postgres-Instanz)
@@ -12,9 +12,8 @@ export type EncryptionProvider = {
12
12
  /**
13
13
  * @deprecated Legacy single-key format: base64(iv+tag+ct) with NO key id —
14
14
  * a key change makes existing ciphertexts permanently undecryptable. Use
15
- * `createEnvelopeCipher` (@cosmicdrift/kumiko-framework/secrets) for new code; this
16
- * provider remains only as the decrypt-fallback for pre-envelope values
17
- * (EnvelopeCipherOptions.legacy) until a re-encrypt job migrated them.
15
+ * `createEnvelopeCipher` (@cosmicdrift/kumiko-framework/secrets) for new code;
16
+ * this provider has no consumers left in the framework.
18
17
  */
19
18
  export function createEncryptionProvider(key: string): EncryptionProvider {
20
19
  // Key must be 32 bytes for AES-256
@@ -0,0 +1,92 @@
1
+ // Plain-data types for EntityTableMeta — split from the runtime
2
+ // (buildEntityTableMeta, resolveTableName, defineUnmanagedTable) in
3
+ // entity-table-meta.ts. Prep step for the types-only package extraction
4
+ // (#1283) — this file must have ONLY `import type`, no value imports
5
+ // (crypto/DB deps).
6
+
7
+ import type { EntityRelations } from "../engine/types";
8
+
9
+ // PG type repertoire the read-model tables need. Deliberately narrow — no
10
+ // vendor-specific types (TSVECTOR, HSTORE, etc.). An app-author who needs
11
+ // those reaches into the reviewed SQL migration by hand, not the generator.
12
+ export type PgType =
13
+ | "uuid"
14
+ | "text"
15
+ | "boolean"
16
+ | "integer"
17
+ | "double precision"
18
+ | "bigint"
19
+ | "serial"
20
+ | "bigserial"
21
+ | "jsonb"
22
+ | "timestamptz"
23
+ | "timestamptz(3)"
24
+ // Exact decimal — precision/scale are encoded in the type string so the
25
+ // DDL renderer and read-coercion need no side-channel metadata.
26
+ | `numeric(${number},${number})`;
27
+
28
+ export type ColumnMeta = {
29
+ readonly name: string; // snake_case PG column name
30
+ readonly pgType: PgType;
31
+ readonly notNull: boolean;
32
+ // Raw SQL-default-expression (e.g. `now()`, `gen_random_uuid()`,
33
+ // `'[]'::jsonb`). undefined = no DEFAULT clause.
34
+ readonly defaultSql?: string;
35
+ readonly primaryKey?: boolean;
36
+ readonly identity?: boolean;
37
+ // bigint/bigserial only: JS round-trip mode. `number` = createBigIntField /
38
+ // drizzle mode:"number" (safe ≤2^53). `bigint` = money cents, raw unmanaged.
39
+ readonly bigintJsMode?: "number" | "bigint";
40
+ };
41
+
42
+ export type IndexMeta = {
43
+ readonly name: string;
44
+ readonly columns: readonly string[]; // snake_case PG column names
45
+ readonly unique?: boolean;
46
+ // Raw SQL-where-expression for partial indexes. Caller is responsible
47
+ // for safety — emitted verbatim.
48
+ readonly whereSql?: string;
49
+ // Set when the EntityDefinition has a partial index (def.where as a
50
+ // drizzle SQL AST) the generator can't reliably render. The renderer
51
+ // emits the statement COMMENTED OUT with a warning hint — the app-author
52
+ // has to add the WHERE manually in the generated SQL.
53
+ readonly needsManualWhere?: boolean;
54
+ };
55
+
56
+ export type CompositePrimaryKeyMeta = {
57
+ readonly name: string;
58
+ readonly columns: readonly string[];
59
+ };
60
+
61
+ export type EntityTableMeta = {
62
+ readonly tableName: string;
63
+ readonly columns: readonly ColumnMeta[];
64
+ readonly indexes: readonly IndexMeta[];
65
+ // For tables with composite PK (no single id column, e.g. snapshots
66
+ // keyed by aggregate_id+version). When set, no column should have
67
+ // primaryKey:true; the constraint is emitted at table-level.
68
+ readonly compositePrimaryKey?: CompositePrimaryKeyMeta;
69
+ // Source hint for diagnostics/tests — not used functionally.
70
+ // "managed" = from EntityDefinition (with base-columns + audit trail).
71
+ // "unmanaged" = via defineUnmanagedTable — no standard audit, the app
72
+ // carries the responsibility. Migration-generator + tooling can use the
73
+ // discriminator to render warnings ("X tables are unmanaged").
74
+ readonly source: "managed" | "unmanaged";
75
+ // PII-subject-annotated field names (pii/userOwned/tenantOwned). Set by
76
+ // buildEntityTableMeta so the registry can reject r.storeTable stores
77
+ // whose direct writes would skip the executor's encryption (#820).
78
+ readonly piiSubjectFields?: readonly string[];
79
+ };
80
+
81
+ export type BuildEntityTableMetaOptions = {
82
+ readonly featureName?: string;
83
+ readonly relations?: EntityRelations;
84
+ readonly source?: "managed" | "unmanaged";
85
+ };
86
+
87
+ export type UnmanagedTableInput = {
88
+ readonly tableName: string;
89
+ readonly columns: readonly ColumnMeta[];
90
+ readonly indexes?: readonly IndexMeta[];
91
+ readonly compositePrimaryKey?: CompositePrimaryKeyMeta;
92
+ };
@@ -19,86 +19,25 @@
19
19
  // append-only-logs mit serial PK, aggregate-ID ohne DEFAULT, …).
20
20
 
21
21
  import { collectPiiSubjectFields } from "../crypto";
22
+ import type { EntityDefinition, EntityIndexDef, FieldDefinition } from "../engine/types";
22
23
  import type {
23
- EntityDefinition,
24
- EntityIndexDef,
25
- EntityRelations,
26
- FieldDefinition,
27
- } from "../engine/types";
24
+ BuildEntityTableMetaOptions,
25
+ ColumnMeta,
26
+ EntityTableMeta,
27
+ IndexMeta,
28
+ UnmanagedTableInput,
29
+ } from "./entity-table-meta-types";
28
30
  import { READ_MODEL_PREFIX, toSnakeCase, toTableName } from "./table-builder";
29
31
 
30
- // PG-Type-Repertoire das die Read-Model-Tabellen brauchen. Bewusst
31
- // schmal — keine Vendor-spezifischen Typen (TSVECTOR, HSTORE, etc.).
32
- // App-Author die solche braucht greift in der reviewten SQL-Migration
33
- // hand-edit ein, nicht im Generator.
34
- export type PgType =
35
- | "uuid"
36
- | "text"
37
- | "boolean"
38
- | "integer"
39
- | "double precision"
40
- | "bigint"
41
- | "serial"
42
- | "bigserial"
43
- | "jsonb"
44
- | "timestamptz"
45
- | "timestamptz(3)"
46
- // Exact decimal — precision/scale are encoded in the type string so the
47
- // DDL renderer and read-coercion need no side-channel metadata.
48
- | `numeric(${number},${number})`;
49
-
50
- export type ColumnMeta = {
51
- readonly name: string; // snake_case PG column name
52
- readonly pgType: PgType;
53
- readonly notNull: boolean;
54
- // Raw SQL-default-expression (e.g. `now()`, `gen_random_uuid()`,
55
- // `'[]'::jsonb`). undefined = no DEFAULT clause.
56
- readonly defaultSql?: string;
57
- readonly primaryKey?: boolean;
58
- readonly identity?: boolean;
59
- // bigint/bigserial only: JS round-trip mode. `number` = createBigIntField /
60
- // drizzle mode:"number" (safe ≤2^53). `bigint` = money cents, raw unmanaged.
61
- readonly bigintJsMode?: "number" | "bigint";
62
- };
63
-
64
- export type IndexMeta = {
65
- readonly name: string;
66
- readonly columns: readonly string[]; // snake_case PG column names
67
- readonly unique?: boolean;
68
- // Raw SQL-where-expression for partial indexes. Caller is responsible
69
- // for safety — emitted verbatim.
70
- readonly whereSql?: string;
71
- // Set wenn die EntityDefinition ein partial-Index hat (def.where als
72
- // drizzle SQL-AST), der vom Generator nicht zuverlässig renderbar ist.
73
- // Renderer emittiert das Statement dann AUSKOMMENTIERT mit Warn-Hint —
74
- // App-Author muss das WHERE manuell im generierten SQL hinzufügen.
75
- readonly needsManualWhere?: boolean;
76
- };
77
-
78
- export type CompositePrimaryKeyMeta = {
79
- readonly name: string;
80
- readonly columns: readonly string[];
81
- };
82
-
83
- export type EntityTableMeta = {
84
- readonly tableName: string;
85
- readonly columns: readonly ColumnMeta[];
86
- readonly indexes: readonly IndexMeta[];
87
- // For tables with composite PK (no single id column, e.g. snapshots
88
- // keyed by aggregate_id+version). When set, no column should have
89
- // primaryKey:true; the constraint is emitted at table-level.
90
- readonly compositePrimaryKey?: CompositePrimaryKeyMeta;
91
- // Source-hint für Diagnose/Tests — nicht funktional verwendet.
92
- // "managed" = aus EntityDefinition (mit base-columns + audit-trail).
93
- // "unmanaged" = via defineUnmanagedTable — kein Standard-Audit, App
94
- // trägt Verantwortung. Migration-Generator + Tooling können den
95
- // discriminator nutzen um Warnungen zu rendern ("X tables are unmanaged").
96
- readonly source: "managed" | "unmanaged";
97
- // PII-Subject-annotated field names (pii/userOwned/tenantOwned). Set by
98
- // buildEntityTableMeta so the registry can reject r.storeTable stores
99
- // whose direct writes would skip the executor's encryption (#820).
100
- readonly piiSubjectFields?: readonly string[];
101
- };
32
+ export type {
33
+ BuildEntityTableMetaOptions,
34
+ ColumnMeta,
35
+ CompositePrimaryKeyMeta,
36
+ EntityTableMeta,
37
+ IndexMeta,
38
+ PgType,
39
+ UnmanagedTableInput,
40
+ } from "./entity-table-meta-types";
102
41
 
103
42
  // Standard base-columns für event-sourced Read-Model-Tabellen. Spiegelt
104
43
  // `buildBaseColumns()` aus table-builder.ts (drizzle-Variante).
@@ -293,12 +232,6 @@ export function resolveTableName(
293
232
  return `${featureName}_${baseName}`;
294
233
  }
295
234
 
296
- export type BuildEntityTableMetaOptions = {
297
- readonly featureName?: string;
298
- readonly relations?: EntityRelations;
299
- readonly source?: "managed" | "unmanaged";
300
- };
301
-
302
235
  export function buildEntityTableMeta(
303
236
  entityName: string,
304
237
  entity: EntityDefinition,
@@ -434,13 +367,6 @@ export function buildEntityTableMeta(
434
367
  // Heutige use-cases im framework:
435
368
  // - `store_delivery_attempts` — id kommt aus dem Aggregate-Stream
436
369
  // - `store_job_run_logs` — child-table, serial PK, kein tenant-scope
437
- export type UnmanagedTableInput = {
438
- readonly tableName: string;
439
- readonly columns: readonly ColumnMeta[];
440
- readonly indexes?: readonly IndexMeta[];
441
- readonly compositePrimaryKey?: CompositePrimaryKeyMeta;
442
- };
443
-
444
370
  function sqlExpressionText(where: unknown): string | undefined {
445
371
  if (
446
372
  typeof where === "object" &&
@@ -236,6 +236,7 @@ export async function backfillEventPiiEncryption(
236
236
  subject,
237
237
  value,
238
238
  kmsCtx,
239
+ field,
239
240
  );
240
241
  return "encrypted";
241
242
  } catch (e) {