@cosmicdrift/kumiko-framework 0.158.2 → 0.160.0

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 (168) hide show
  1. package/package.json +7 -2
  2. package/src/__tests__/consumer-cli.integration.test.ts +110 -0
  3. package/src/api/__tests__/api.test.ts +65 -0
  4. package/src/api/__tests__/auth-routes-cookie.test.ts +17 -1
  5. package/src/api/__tests__/auth-routes-invalid-body-invite.test.ts +237 -0
  6. package/src/api/__tests__/auth-routes-mfa-verify.test.ts +1 -0
  7. package/src/api/__tests__/csrf-constants-sync.test.ts +20 -0
  8. package/src/api/__tests__/dispatcher-live.integration.test.ts +74 -0
  9. package/src/api/__tests__/jwt.test.ts +150 -1
  10. package/src/api/__tests__/login-rate-limiter-sweep.test.ts +41 -0
  11. package/src/api/__tests__/server-boot-guards.test.ts +71 -0
  12. package/src/api/__tests__/server-jwt-ttl.test.ts +58 -0
  13. package/src/api/api-constants.ts +5 -0
  14. package/src/api/auth-middleware.ts +48 -59
  15. package/src/api/auth-routes.ts +51 -17
  16. package/src/api/index.ts +3 -3
  17. package/src/api/jwt.ts +148 -7
  18. package/src/api/pii-leak-guard.ts +5 -2
  19. package/src/api/routes.ts +57 -0
  20. package/src/api/server.ts +19 -5
  21. package/src/bun-db/__tests__/select-many-retry.test.ts +79 -0
  22. package/src/bun-db/query.ts +46 -27
  23. package/src/consumer-cli.ts +87 -0
  24. package/src/crypto/__tests__/pii-field-encryption.test.ts +69 -13
  25. package/src/crypto/blind-index.ts +8 -4
  26. package/src/crypto/event-pii.ts +1 -0
  27. package/src/crypto/kms-adapter.ts +2 -118
  28. package/src/crypto/pii-field-encryption.ts +49 -15
  29. package/src/db/__tests__/build-filter-where.test.ts +34 -0
  30. package/src/db/__tests__/event-store-executor-context.pii-roundtrip.test.ts +67 -0
  31. package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +396 -0
  32. package/src/db/__tests__/event-store-executor.integration.test.ts +5 -5
  33. package/src/db/blind-index-cleanup.ts +3 -1
  34. package/src/db/connection.ts +3 -11
  35. package/src/db/cursor.ts +1 -18
  36. package/src/db/dialect.ts +8 -19
  37. package/src/db/encryption.ts +2 -3
  38. package/src/db/entity-table-meta-types.ts +2 -0
  39. package/src/db/entity-table-meta.ts +16 -90
  40. package/src/db/event-store-executor.ts +4 -96
  41. package/src/db/queries/backfill-pii.ts +1 -0
  42. package/src/db/queries/event-consumer.ts +35 -2
  43. package/src/db/table-builder.ts +2 -19
  44. package/src/db/tenant-db.ts +6 -55
  45. package/src/engine/__tests__/boot-validator-boot-check.test.ts +99 -0
  46. package/src/engine/__tests__/boot-validator-gdpr-storage.test.ts +7 -233
  47. package/src/engine/__tests__/boot-validator.test.ts +46 -0
  48. package/src/engine/__tests__/codemod-pipeline.test.ts +139 -10
  49. package/src/engine/__tests__/define-roles.test.ts +21 -0
  50. package/src/engine/__tests__/engine.test.ts +28 -0
  51. package/src/engine/__tests__/event-type-map-augmentation.test.ts +24 -0
  52. package/src/engine/__tests__/registry-facade-sweep.test.ts +80 -0
  53. package/src/engine/__tests__/registry.test.ts +40 -0
  54. package/src/engine/__tests__/store-table.test.ts +12 -0
  55. package/src/engine/__tests__/tier-resolver-extension.test.ts +19 -1
  56. package/src/engine/boot-validator/action-wiring.ts +1 -1
  57. package/src/engine/boot-validator/boot-check.ts +21 -0
  58. package/src/engine/boot-validator/entity-handler.ts +10 -1
  59. package/src/engine/boot-validator/entity-list-screens.ts +1 -1
  60. package/src/engine/boot-validator/gdpr-storage.ts +0 -112
  61. package/src/engine/boot-validator/index.ts +3 -9
  62. package/src/engine/boot-validator/screens.ts +1 -1
  63. package/src/engine/define-feature.ts +2 -0
  64. package/src/engine/define-handler.ts +11 -91
  65. package/src/engine/entity-handlers.ts +15 -27
  66. package/src/engine/feature-ast/__tests__/canonical-form.test.ts +11 -1
  67. package/src/engine/feature-ast/__tests__/parse.test.ts +983 -3
  68. package/src/engine/feature-ast/__tests__/patch.test.ts +168 -0
  69. package/src/engine/feature-ast/__tests__/patcher.test.ts +7 -0
  70. package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +9 -0
  71. package/src/engine/feature-ast/extractors/handlers.ts +19 -2
  72. package/src/engine/feature-ast/extractors/index.ts +1 -0
  73. package/src/engine/feature-ast/index.ts +2 -0
  74. package/src/engine/feature-ast/parse.ts +3 -0
  75. package/src/engine/feature-ast/patch.ts +2 -0
  76. package/src/engine/feature-ast/patcher.ts +21 -0
  77. package/src/engine/feature-ast/patterns.ts +16 -0
  78. package/src/engine/feature-ast/render.ts +15 -0
  79. package/src/engine/feature-builder-state.ts +6 -0
  80. package/src/engine/feature-config-events-jobs.ts +1 -1
  81. package/src/engine/feature-entity-handlers.ts +36 -2
  82. package/src/engine/feature-ui-extensions.ts +5 -1
  83. package/src/engine/field-helpers.ts +31 -0
  84. package/src/engine/handler-helpers.ts +26 -0
  85. package/src/engine/hook-helpers.ts +14 -0
  86. package/src/engine/index.ts +5 -2
  87. package/src/engine/ownership.ts +22 -76
  88. package/src/engine/pattern-library/__tests__/library.test.ts +9 -0
  89. package/src/engine/pattern-library/library.ts +2 -0
  90. package/src/engine/pattern-library/mixed-schemas.ts +37 -0
  91. package/src/engine/registry-facade.ts +9 -0
  92. package/src/engine/registry-ingest.ts +10 -0
  93. package/src/engine/registry-state.ts +3 -0
  94. package/src/engine/registry-validate.ts +1 -1
  95. package/src/engine/screen-helpers.ts +54 -0
  96. package/src/engine/tier-resolver-extension.ts +3 -2
  97. package/src/engine/types/config.ts +2 -497
  98. package/src/engine/types/define-handler.ts +2 -0
  99. package/src/engine/types/entity-handlers.ts +2 -0
  100. package/src/engine/types/event-type-map.ts +1 -37
  101. package/src/engine/types/feature.ts +2 -976
  102. package/src/engine/types/fields.ts +2 -697
  103. package/src/engine/types/handlers.ts +2 -839
  104. package/src/engine/types/hooks.ts +2 -184
  105. package/src/engine/types/http-route.ts +1 -72
  106. package/src/engine/types/identifiers.ts +1 -47
  107. package/src/engine/types/index.ts +66 -33
  108. package/src/engine/types/nav.ts +2 -67
  109. package/src/engine/types/ownership.ts +2 -0
  110. package/src/engine/types/projection.ts +2 -165
  111. package/src/engine/types/relations.ts +1 -51
  112. package/src/engine/types/screen.ts +2 -793
  113. package/src/engine/types/step.ts +2 -334
  114. package/src/engine/types/target-ref.ts +1 -21
  115. package/src/engine/types/tree-node.ts +1 -129
  116. package/src/engine/types/workspace.ts +2 -42
  117. package/src/entrypoint/index.ts +2 -2
  118. package/src/errors/write-error-info.ts +6 -22
  119. package/src/event-store/__tests__/event-store.integration.test.ts +31 -0
  120. package/src/event-store/__tests__/unscoped-stream-primitives.guard.test.ts +43 -0
  121. package/src/event-store/errors.ts +2 -35
  122. package/src/event-store/event-store.ts +28 -51
  123. package/src/event-store/events-schema.ts +1 -10
  124. package/src/event-store/index.ts +3 -2
  125. package/src/event-store/snapshot.ts +11 -35
  126. package/src/event-store/types.ts +2 -0
  127. package/src/files/__tests__/in-memory-provider.contract.test.ts +4 -0
  128. package/src/files/file-handle.ts +2 -19
  129. package/src/files/provider-resolver.ts +3 -5
  130. package/src/files/types.ts +5 -54
  131. package/src/i18n/required-surface-keys.ts +1 -1
  132. package/src/jobs/__tests__/jobs.integration.test.ts +102 -1
  133. package/src/logging/types.ts +1 -7
  134. package/src/observability/types/index.ts +1 -29
  135. package/src/observability/types/metric.ts +1 -56
  136. package/src/observability/types/provider.ts +1 -32
  137. package/src/observability/types/span.ts +1 -58
  138. package/src/pipeline/__tests__/dispatcher.test.ts +134 -1
  139. package/src/pipeline/__tests__/event-dispatcher-delivery-max-attempts.test.ts +126 -0
  140. package/src/pipeline/__tests__/event-dispatcher-rearm.integration.test.ts +180 -0
  141. package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +208 -0
  142. package/src/pipeline/dispatch-shared.ts +51 -3
  143. package/src/pipeline/dispatch-stream.ts +74 -0
  144. package/src/pipeline/dispatcher-utils.ts +1 -1
  145. package/src/pipeline/dispatcher.ts +7 -0
  146. package/src/pipeline/entity-cache.ts +2 -33
  147. package/src/pipeline/event-consumer-state.ts +28 -3
  148. package/src/pipeline/event-dispatcher-admin.ts +4 -0
  149. package/src/pipeline/event-dispatcher-delivery.ts +29 -3
  150. package/src/pipeline/event-dispatcher.ts +27 -1
  151. package/src/pipeline/multi-stream-apply-context.ts +4 -42
  152. package/src/pipeline/system-hooks.ts +7 -0
  153. package/src/rate-limit/resolver.ts +10 -30
  154. package/src/search/types.ts +1 -39
  155. package/src/secrets/__tests__/envelope-cipher.test.ts +2 -30
  156. package/src/secrets/__tests__/envelope.test.ts +1 -1
  157. package/src/secrets/envelope-cipher.ts +17 -45
  158. package/src/secrets/types.ts +2 -177
  159. package/src/stack/__tests__/event-collector.test.ts +42 -0
  160. package/src/testing/__tests__/late-bound.test.ts +25 -0
  161. package/src/testing/__tests__/wait-for.test.ts +53 -0
  162. package/src/testing/boot-validator-fixture.ts +1 -1
  163. package/src/testing/file-provider-contract.ts +84 -0
  164. package/src/testing/handler-context.ts +1 -1
  165. package/src/testing/index.ts +1 -0
  166. package/src/time/geo-tz.ts +1 -32
  167. package/src/time/tz-context.ts +9 -56
  168. package/src/ui-types/index.ts +7 -7
@@ -0,0 +1,396 @@
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 type { EntityCache } from "../../pipeline/entity-cache";
13
+ import { createTestDb, type TestDb, TestUsers, unsafeCreateEntityTable } from "../../stack";
14
+ import { createEventStoreExecutor } from "../event-store-executor";
15
+ import { buildEntityTable } from "../table-builder";
16
+ import { createTenantDb, type TenantDb } from "../tenant-db";
17
+
18
+ let testDb: TestDb;
19
+ let tdb: TenantDb;
20
+ const admin = TestUsers.admin;
21
+ const nonAdmin = TestUsers.user;
22
+
23
+ beforeAll(async () => {
24
+ testDb = await createTestDb();
25
+ await createEventsTable(testDb.db);
26
+ tdb = createTenantDb(testDb.db, admin.tenantId);
27
+ });
28
+
29
+ afterAll(async () => {
30
+ await testDb.cleanup();
31
+ });
32
+
33
+ // =============================================================================
34
+ // entity-level ownership_denied — create/update/delete/forget/restore
35
+ // =============================================================================
36
+
37
+ const restrictedEntity = createEntity({
38
+ table: "read_es_write_restricted",
39
+ fields: {
40
+ email: createTextField({ required: true }),
41
+ },
42
+ softDelete: true,
43
+ access: { write: { Admin: "all" } },
44
+ });
45
+ const restrictedTable = buildEntityTable("esWriteRestricted", restrictedEntity);
46
+
47
+ describe("event-store-executor write-verbs — entity-level ownership_denied", () => {
48
+ const crud = createEventStoreExecutor(restrictedTable, restrictedEntity, {
49
+ entityName: "esWriteRestricted",
50
+ });
51
+
52
+ beforeAll(async () => {
53
+ await unsafeCreateEntityTableFor(restrictedEntity, "esWriteRestricted");
54
+ });
55
+
56
+ beforeEach(async () => {
57
+ await asRawClient(testDb.db).unsafe(
58
+ `TRUNCATE kumiko_events, read_es_write_restricted RESTART IDENTITY CASCADE`,
59
+ );
60
+ });
61
+
62
+ test("create: role without a write-rule → ownership_denied", async () => {
63
+ const result = await crud.create({ email: "denied@test.de" }, nonAdmin, tdb);
64
+ expect(result.isSuccess).toBe(false);
65
+ if (result.isSuccess) return;
66
+ expect((result.error.details as { reason?: string }).reason).toBe("ownership_denied");
67
+ });
68
+
69
+ test("update: role without a write-rule → ownership_denied", async () => {
70
+ const created = await crud.create({ email: "owner@test.de" }, admin, tdb);
71
+ if (!created.isSuccess) throw new Error("setup failed");
72
+
73
+ const result = await crud.update(
74
+ { id: created.data.id, version: 1, changes: { email: "changed@test.de" } },
75
+ nonAdmin,
76
+ tdb,
77
+ );
78
+ expect(result.isSuccess).toBe(false);
79
+ if (result.isSuccess) return;
80
+ expect((result.error.details as { reason?: string }).reason).toBe("ownership_denied");
81
+ });
82
+
83
+ test("delete: role without a write-rule → ownership_denied", async () => {
84
+ const created = await crud.create({ email: "todelete@test.de" }, admin, tdb);
85
+ if (!created.isSuccess) throw new Error("setup failed");
86
+
87
+ const result = await crud.delete({ id: created.data.id }, nonAdmin, tdb);
88
+ expect(result.isSuccess).toBe(false);
89
+ if (result.isSuccess) return;
90
+ expect((result.error.details as { reason?: string }).reason).toBe("ownership_denied");
91
+ });
92
+
93
+ test("forget: role without a write-rule → ownership_denied", async () => {
94
+ const created = await crud.create({ email: "toforget@test.de" }, admin, tdb);
95
+ if (!created.isSuccess) throw new Error("setup failed");
96
+
97
+ const result = await crud.forget({ id: created.data.id }, nonAdmin, tdb);
98
+ expect(result.isSuccess).toBe(false);
99
+ if (result.isSuccess) return;
100
+ expect((result.error.details as { reason?: string }).reason).toBe("ownership_denied");
101
+ });
102
+
103
+ test("forget: happy path hard-deletes the row and appends a forgotten event", async () => {
104
+ const created = await crud.create({ email: "gone@test.de" }, admin, tdb);
105
+ if (!created.isSuccess) throw new Error("setup failed");
106
+
107
+ const result = await crud.forget({ id: created.data.id }, admin, tdb);
108
+ expect(result.isSuccess).toBe(true);
109
+
110
+ const rows = (await asRawClient(testDb.db).unsafe(
111
+ `SELECT id FROM read_es_write_restricted WHERE email = 'gone@test.de'`,
112
+ )) as unknown[];
113
+ expect(rows).toHaveLength(0);
114
+
115
+ const events = (await asRawClient(testDb.db).unsafe(
116
+ `SELECT type FROM kumiko_events WHERE type = 'esWriteRestricted.forgotten'`,
117
+ )) as unknown[];
118
+ expect(events).toHaveLength(1);
119
+ });
120
+
121
+ test("restore: role without a write-rule → ownership_denied", async () => {
122
+ const created = await crud.create({ email: "torestore@test.de" }, admin, tdb);
123
+ if (!created.isSuccess) throw new Error("setup failed");
124
+ await crud.delete({ id: created.data.id }, admin, tdb);
125
+
126
+ const result = await crud.restore({ id: created.data.id }, nonAdmin, tdb);
127
+ expect(result.isSuccess).toBe(false);
128
+ if (result.isSuccess) return;
129
+ expect((result.error.details as { reason?: string }).reason).toBe("ownership_denied");
130
+ });
131
+
132
+ test("restore: not yet deleted row → not_deleted", async () => {
133
+ const created = await crud.create({ email: "notdeleted@test.de" }, admin, tdb);
134
+ if (!created.isSuccess) throw new Error("setup failed");
135
+
136
+ const result = await crud.restore({ id: created.data.id }, admin, tdb);
137
+ expect(result.isSuccess).toBe(false);
138
+ if (result.isSuccess) return;
139
+ expect((result.error.details as { reason?: string }).reason).toBe("not_deleted");
140
+ });
141
+ });
142
+
143
+ // =============================================================================
144
+ // restore() on a non-soft-delete entity → soft_delete_not_enabled
145
+ // =============================================================================
146
+
147
+ const hardDeleteEntity = createEntity({
148
+ table: "read_es_write_hard",
149
+ fields: {
150
+ email: createTextField({ required: true }),
151
+ },
152
+ });
153
+ const hardDeleteTable = buildEntityTable("esWriteHard", hardDeleteEntity);
154
+
155
+ describe("event-store-executor write-verbs — restore without softDelete", () => {
156
+ const crud = createEventStoreExecutor(hardDeleteTable, hardDeleteEntity, {
157
+ entityName: "esWriteHard",
158
+ });
159
+
160
+ beforeAll(async () => {
161
+ await unsafeCreateEntityTableFor(hardDeleteEntity, "esWriteHard");
162
+ });
163
+
164
+ beforeEach(async () => {
165
+ await asRawClient(testDb.db).unsafe(
166
+ `TRUNCATE kumiko_events, read_es_write_hard RESTART IDENTITY CASCADE`,
167
+ );
168
+ });
169
+
170
+ test("restore on an entity without softDelete → soft_delete_not_enabled", async () => {
171
+ const created = await crud.create({ email: "hard@test.de" }, admin, tdb);
172
+ if (!created.isSuccess) throw new Error("setup failed");
173
+
174
+ const result = await crud.restore({ id: created.data.id }, admin, tdb);
175
+ expect(result.isSuccess).toBe(false);
176
+ if (result.isSuccess) return;
177
+ expect((result.error.details as { reason?: string }).reason).toBe("soft_delete_not_enabled");
178
+ });
179
+ });
180
+
181
+ // =============================================================================
182
+ // field-level ownership_denied — checkWriteFieldOwnership via a row-scoped rule
183
+ // =============================================================================
184
+
185
+ const ownedFieldEntity = createEntity({
186
+ table: "read_es_write_owned_field",
187
+ fields: {
188
+ authorId: createTextField({ required: true }),
189
+ note: createTextField({
190
+ access: { write: { Admin: "all", User: from("user:id", "authorId") } },
191
+ }),
192
+ },
193
+ });
194
+ const ownedFieldTable = buildEntityTable("esWriteOwnedField", ownedFieldEntity);
195
+
196
+ describe("event-store-executor write-verbs — field-level ownership_denied", () => {
197
+ const crud = createEventStoreExecutor(ownedFieldTable, ownedFieldEntity, {
198
+ entityName: "esWriteOwnedField",
199
+ });
200
+
201
+ beforeAll(async () => {
202
+ await unsafeCreateEntityTableFor(ownedFieldEntity, "esWriteOwnedField");
203
+ });
204
+
205
+ beforeEach(async () => {
206
+ await asRawClient(testDb.db).unsafe(
207
+ `TRUNCATE kumiko_events, read_es_write_owned_field RESTART IDENTITY CASCADE`,
208
+ );
209
+ });
210
+
211
+ test("create: writing `note` while authorId names someone else → ownership_denied (field scope)", async () => {
212
+ const result = await crud.create(
213
+ { authorId: TestUsers.driver.id, note: "not mine" },
214
+ nonAdmin,
215
+ tdb,
216
+ );
217
+ expect(result.isSuccess).toBe(false);
218
+ if (result.isSuccess) return;
219
+ const details = result.error.details as { reason?: string; scope?: string; field?: string };
220
+ expect(details.reason).toBe("ownership_denied");
221
+ expect(details.scope).toBe("field");
222
+ expect(details.field).toBe("note");
223
+ });
224
+
225
+ test("create: writing `note` as the named author succeeds", async () => {
226
+ const result = await crud.create({ authorId: nonAdmin.id, note: "mine" }, nonAdmin, tdb);
227
+ expect(result.isSuccess).toBe(true);
228
+ });
229
+
230
+ test("update: changing `note` on someone else's row → ownership_denied (field scope)", async () => {
231
+ const created = await crud.create(
232
+ { authorId: TestUsers.driver.id, note: "original" },
233
+ admin,
234
+ tdb,
235
+ );
236
+ if (!created.isSuccess) throw new Error("setup failed");
237
+
238
+ const result = await crud.update(
239
+ { id: created.data.id, version: 1, changes: { note: "hijacked" } },
240
+ nonAdmin,
241
+ tdb,
242
+ );
243
+ expect(result.isSuccess).toBe(false);
244
+ if (result.isSuccess) return;
245
+ expect((result.error.details as { reason?: string }).reason).toBe("ownership_denied");
246
+ });
247
+ });
248
+
249
+ // =============================================================================
250
+ // version_conflict — explicit-id create collision, missing version on update
251
+ // =============================================================================
252
+
253
+ const versionEntity = createEntity({
254
+ table: "read_es_write_version",
255
+ fields: {
256
+ email: createTextField({ required: true }),
257
+ },
258
+ });
259
+ const versionTable = buildEntityTable("esWriteVersion", versionEntity);
260
+
261
+ describe("event-store-executor write-verbs — version_conflict edge cases", () => {
262
+ const crud = createEventStoreExecutor(versionTable, versionEntity, {
263
+ entityName: "esWriteVersion",
264
+ });
265
+
266
+ beforeAll(async () => {
267
+ await unsafeCreateEntityTableFor(versionEntity, "esWriteVersion");
268
+ });
269
+
270
+ beforeEach(async () => {
271
+ await asRawClient(testDb.db).unsafe(
272
+ `TRUNCATE kumiko_events, read_es_write_version RESTART IDENTITY CASCADE`,
273
+ );
274
+ });
275
+
276
+ test("create with an explicit id that already has a stream → version_conflict", async () => {
277
+ const first = await crud.create({ email: "first@test.de" }, admin, tdb);
278
+ if (!first.isSuccess) throw new Error("setup failed");
279
+
280
+ const collision = await crud.create({ id: first.data.id, email: "second@test.de" }, admin, tdb);
281
+ expect(collision.isSuccess).toBe(false);
282
+ if (collision.isSuccess) return;
283
+ expect(collision.error.code).toBe("version_conflict");
284
+ });
285
+
286
+ test("update without a version field (and no skipOptimisticLock) → version_conflict", async () => {
287
+ const created = await crud.create({ email: "noversion@test.de" }, admin, tdb);
288
+ if (!created.isSuccess) throw new Error("setup failed");
289
+
290
+ const result = await crud.update(
291
+ { id: created.data.id, changes: { email: "changed@test.de" } },
292
+ admin,
293
+ tdb,
294
+ );
295
+ expect(result.isSuccess).toBe(false);
296
+ if (result.isSuccess) return;
297
+ expect(result.error.code).toBe("version_conflict");
298
+ });
299
+ });
300
+
301
+ async function unsafeCreateEntityTableFor(
302
+ entity: Parameters<typeof buildEntityTable>[1],
303
+ name: string,
304
+ ): Promise<void> {
305
+ await unsafeCreateEntityTable(testDb.db, entity, name);
306
+ }
307
+
308
+ // =============================================================================
309
+ // Concurrent update race → EventStoreVersionConflict catch + entityCache.del
310
+ // on forget/restore (create/update/delete already exercise cache in the
311
+ // main suite; forget/restore del() stayed uncovered).
312
+ // =============================================================================
313
+
314
+ const raceEntity = createEntity({
315
+ table: "read_es_write_race",
316
+ fields: {
317
+ email: createTextField({ required: true }),
318
+ },
319
+ softDelete: true,
320
+ });
321
+ const raceTable = buildEntityTable("esWriteRace", raceEntity);
322
+
323
+ describe("event-store-executor write-verbs — concurrent version race + cache", () => {
324
+ const store = new Map<string, Record<string, unknown>>();
325
+ const entityCache: EntityCache = {
326
+ get: async (tenantId, name, id) => store.get(`${tenantId}:${name}:${id}`) ?? null,
327
+ mget: async () => new Map(),
328
+ set: async (tenantId, name, id, data) => {
329
+ store.set(`${tenantId}:${name}:${id}`, data);
330
+ },
331
+ mset: async (tenantId, name, entries) => {
332
+ for (const { id, data } of entries) store.set(`${tenantId}:${name}:${id}`, data);
333
+ },
334
+ del: async (tenantId, name, id) => {
335
+ store.delete(`${tenantId}:${name}:${id}`);
336
+ },
337
+ };
338
+ const crud = createEventStoreExecutor(raceTable, raceEntity, {
339
+ entityName: "esWriteRace",
340
+ entityCache,
341
+ });
342
+
343
+ beforeAll(async () => {
344
+ await unsafeCreateEntityTableFor(raceEntity, "esWriteRace");
345
+ });
346
+
347
+ beforeEach(async () => {
348
+ store.clear();
349
+ await asRawClient(testDb.db).unsafe(
350
+ `TRUNCATE kumiko_events, read_es_write_race RESTART IDENTITY CASCADE`,
351
+ );
352
+ });
353
+
354
+ test("two concurrent updates with the same version → one wins, one version_conflict", async () => {
355
+ const created = await crud.create({ email: "race@test.de" }, admin, tdb);
356
+ if (!created.isSuccess) throw new Error("setup failed");
357
+ const id = created.data.id;
358
+
359
+ const [a, b] = await Promise.all([
360
+ crud.update({ id, version: 1, changes: { email: "a@test.de" } }, admin, tdb),
361
+ crud.update({ id, version: 1, changes: { email: "b@test.de" } }, admin, tdb),
362
+ ]);
363
+
364
+ const results = [a, b];
365
+ expect(results.filter((r) => r.isSuccess)).toHaveLength(1);
366
+ expect(results.filter((r) => !r.isSuccess && r.error.code === "version_conflict")).toHaveLength(
367
+ 1,
368
+ );
369
+ });
370
+
371
+ test("forget with entityCache clears the cache entry", async () => {
372
+ const created = await crud.create({ email: "cache-forget@test.de" }, admin, tdb);
373
+ if (!created.isSuccess) throw new Error("setup failed");
374
+ const id = created.data.id;
375
+ const key = `${admin.tenantId}:esWriteRace:${id}`;
376
+ store.set(key, { email: "poison" });
377
+
378
+ const result = await crud.forget({ id }, admin, tdb);
379
+ expect(result.isSuccess).toBe(true);
380
+ expect(store.has(key)).toBe(false);
381
+ });
382
+
383
+ test("restore with entityCache clears the cache entry", async () => {
384
+ const created = await crud.create({ email: "cache-restore@test.de" }, admin, tdb);
385
+ if (!created.isSuccess) throw new Error("setup failed");
386
+ const id = created.data.id;
387
+ await crud.delete({ id }, admin, tdb);
388
+
389
+ const key = `${admin.tenantId}:esWriteRace:${id}`;
390
+ store.set(key, { email: "poison" });
391
+
392
+ const result = await crud.restore({ id }, admin, tdb);
393
+ expect(result.isSuccess).toBe(true);
394
+ expect(store.has(key)).toBe(false);
395
+ });
396
+ });
@@ -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)
package/src/db/cursor.ts CHANGED
@@ -1,21 +1,4 @@
1
- import type { EntityId, TenantId } from "../engine/types/identifiers";
2
-
3
- export type CursorQueryOptions = {
4
- tenantId: TenantId;
5
- cursor?: string;
6
- limit?: number;
7
- filterIds?: readonly EntityId[];
8
- sort?: string;
9
- sortDirection?: "asc" | "desc";
10
- };
11
-
12
- export type CursorResult<T> = {
13
- rows: T[];
14
- nextCursor: string | null;
15
- /** Optional total row count — nur present wenn der Caller `totalCount: true`
16
- * in der Query setzt. */
17
- total?: number;
18
- };
1
+ export type { CursorQueryOptions, CursorResult } from "@cosmicdrift/kumiko-types/cursor-types";
19
2
 
20
3
  // String-basiert damit UUIDs (Default seit Sprint F) + Integer-Auto-Increment
21
4
  // durch denselben Cursor-Pfad laufen. UUIDv7 erfüllt die lex-Monotonie
package/src/db/dialect.ts CHANGED
@@ -13,6 +13,12 @@
13
13
  // The framework no longer imports drizzle-orm at runtime — schema-files
14
14
  // use only this module.
15
15
 
16
+ import {
17
+ type ColumnHandle,
18
+ KUMIKO_COLUMNS_SYMBOL,
19
+ KUMIKO_NAME_SYMBOL,
20
+ type SchemaTable,
21
+ } from "@cosmicdrift/kumiko-types/schema-table-types";
16
22
  import type {
17
23
  ColumnMeta,
18
24
  CompositePrimaryKeyMeta,
@@ -21,6 +27,8 @@ import type {
21
27
  PgType,
22
28
  } from "./entity-table-meta";
23
29
 
30
+ export type { ColumnHandle, SchemaTable } from "@cosmicdrift/kumiko-types/schema-table-types";
31
+
24
32
  // Public type aliases — historical compat for callers that used to import
25
33
  // these from drizzle-orm/pg-core. SelectQuery is no longer a meaningful
26
34
  // shape (no chain builder); TableColumns is the new SchemaTable union.
@@ -29,31 +37,12 @@ export type TableColumns<_T = any> = SchemaTable;
29
37
  // biome-ignore lint/suspicious/noExplicitAny: legacy type — chain API is gone
30
38
  export type SelectQuery = any;
31
39
 
32
- // Column handle exposed on the SchemaTable. The `name` is the SQL column
33
- // name (snake_case); legacy code accesses `table.fieldName.name` to
34
- // produce raw SQL.
35
- export type ColumnHandle = {
36
- readonly name: string;
37
- readonly pgType: PgType;
38
- readonly getSQLType: () => string;
39
- };
40
-
41
- const KUMIKO_NAME_SYMBOL = Symbol.for("kumiko:schema:Name");
42
- const KUMIKO_COLUMNS_SYMBOL = Symbol.for("kumiko:schema:Columns");
43
40
  // Shadow-proof handle on the EntityTableMeta. The column handles below are
44
41
  // spread as enumerable props, so an entity field named `source`/`columns`/
45
42
  // `tableName`/… would overwrite the matching meta key. extractTableInfo reads
46
43
  // the canonical meta from this symbol instead of the (shadowable) props.
47
44
  const KUMIKO_META_SYMBOL = Symbol.for("kumiko:schema:Meta");
48
45
 
49
- // SchemaTable — opaque shape with both EntityTableMeta + Symbol-based
50
- // introspection. Returned by `table(...)`.
51
- export type SchemaTable = EntityTableMeta & {
52
- readonly [KUMIKO_NAME_SYMBOL]: string;
53
- readonly [KUMIKO_COLUMNS_SYMBOL]: Record<string, ColumnHandle>;
54
- readonly [field: string]: unknown;
55
- };
56
-
57
46
  function isNumericPgType(t: PgType): t is `numeric(${number},${number})` {
58
47
  return t.startsWith("numeric(");
59
48
  }
@@ -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,2 @@
1
+ // Legacy path — re-exported for callers still importing this module directly.
2
+ export * from "@cosmicdrift/kumiko-types/entity-table-meta-types";