@cosmicdrift/kumiko-framework 0.364.0 → 0.365.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 (163) hide show
  1. package/AGENTS.md +36 -5
  2. package/agents/examples/anonymous-access-multitenant.md +9 -6
  3. package/agents/examples/anonymous-access.md +14 -26
  4. package/agents/examples/auth-mfa.md +5 -9
  5. package/agents/examples/custom-fields-basic.md +7 -6
  6. package/agents/examples/custom-handlers.md +72 -25
  7. package/agents/examples/dashboard-stats.md +229 -0
  8. package/agents/examples/encrypted-tenant-config.md +17 -8
  9. package/agents/examples/error-contract.md +1 -5
  10. package/agents/examples/feature-toggles.md +28 -17
  11. package/agents/examples/ledger-banking.md +7 -6
  12. package/agents/examples/ledger-invoicing.md +7 -6
  13. package/agents/examples/legal-pages.md +2 -2
  14. package/agents/examples/managed-pages.md +2 -2
  15. package/agents/examples/notes-history-basic.md +7 -6
  16. package/agents/examples/ownership.md +30 -22
  17. package/agents/examples/pdf-audit-export.md +87 -51
  18. package/agents/examples/session-revocation.md +1 -10
  19. package/agents/examples/tags-basic.md +7 -7
  20. package/agents/examples/user-data-rights.md +25 -17
  21. package/agents/examples/webhook-step.md +11 -1
  22. package/agents/examples/workflow-engine.md +2 -2
  23. package/agents/examples.md +2 -1
  24. package/agents/features.md +4 -4
  25. package/dist/api/auth-routes.d.ts +0 -1
  26. package/dist/api/auth-routes.js +44 -43
  27. package/dist/api/index.d.ts +1 -1
  28. package/dist/api/index.js +1 -1
  29. package/dist/api/server.d.ts +2 -2
  30. package/dist/api/server.js +29 -4
  31. package/dist/bun-db/connection.js +3 -16
  32. package/dist/bun-db/index.d.ts +1 -1
  33. package/dist/bun-db/index.js +1 -1
  34. package/dist/bun-db/query.d.ts +7 -0
  35. package/dist/bun-db/query.js +50 -18
  36. package/dist/crypto/in-memory-kms-adapter.js +4 -7
  37. package/dist/crypto/owner-transition-rekey.d.ts +1 -0
  38. package/dist/crypto/owner-transition-rekey.js +7 -3
  39. package/dist/crypto/pg-kms-adapter.js +10 -6
  40. package/dist/crypto/pii-field-encryption.d.ts +1 -0
  41. package/dist/crypto/pii-field-encryption.js +9 -2
  42. package/dist/crypto/subject-resolver.d.ts +1 -0
  43. package/dist/crypto/subject-resolver.js +7 -0
  44. package/dist/db/api.d.ts +4 -3
  45. package/dist/db/apply-entity-event.js +26 -2
  46. package/dist/db/blind-index-cleanup.js +2 -1
  47. package/dist/db/bun-provider.js +9 -18
  48. package/dist/db/dialect.d.ts +1 -1
  49. package/dist/db/dialect.js +2 -0
  50. package/dist/db/eagerload.js +2 -1
  51. package/dist/db/event-store-executor-context.d.ts +1 -0
  52. package/dist/db/event-store-executor-context.js +1 -0
  53. package/dist/db/event-store-executor-write.d.ts +1 -1
  54. package/dist/db/event-store-executor-write.js +85 -58
  55. package/dist/db/index.d.ts +1 -0
  56. package/dist/db/index.js +1 -0
  57. package/dist/db/migrate-runner.d.ts +1 -0
  58. package/dist/db/migrate-runner.js +56 -31
  59. package/dist/db/queries/backfill-pii.js +63 -25
  60. package/dist/db/queries/event-consumer.js +2 -1
  61. package/dist/db/queries/event-store-transfer.js +15 -4
  62. package/dist/db/queries/event-store.js +11 -10
  63. package/dist/db/queries/pii-backfill-state.js +3 -2
  64. package/dist/db/queries/postgres-array-literal.d.ts +2 -0
  65. package/dist/db/queries/postgres-array-literal.js +6 -0
  66. package/dist/db/queries/projection-rebuild.js +3 -2
  67. package/dist/db/queries/shadow-swap.d.ts +1 -1
  68. package/dist/db/queries/shadow-swap.js +12 -8
  69. package/dist/db/query.d.ts +1 -1
  70. package/dist/db/query.js +1 -1
  71. package/dist/db/retired-framework-tables.js +22 -4
  72. package/dist/db/table-builder.js +6 -2
  73. package/dist/db/tenant-db.d.ts +2 -0
  74. package/dist/db/tenant-db.js +20 -1
  75. package/dist/derivatives/delete-stored-file.d.ts +4 -0
  76. package/dist/derivatives/delete-stored-file.js +9 -0
  77. package/dist/derivatives/index.d.ts +1 -1
  78. package/dist/derivatives/index.js +1 -1
  79. package/dist/engine/boot-validator/entity-handler.js +3 -0
  80. package/dist/engine/boot-validator/query-refs.js +14 -2
  81. package/dist/engine/dedupe-features.js +31 -4
  82. package/dist/engine/entity-handlers.js +14 -7
  83. package/dist/engine/index.d.ts +2 -2
  84. package/dist/engine/index.js +1 -1
  85. package/dist/engine/registry-validate.d.ts +1 -0
  86. package/dist/engine/registry-validate.js +21 -0
  87. package/dist/engine/registry.js +2 -1
  88. package/dist/engine/screen-helpers.d.ts +1 -0
  89. package/dist/engine/screen-helpers.js +2 -2
  90. package/dist/engine/steps/_read-source.js +5 -2
  91. package/dist/engine/tier-resolver-extension.d.ts +1 -0
  92. package/dist/engine/tier-resolver-extension.js +6 -0
  93. package/dist/engine/types/index.d.ts +1 -1
  94. package/dist/entrypoint/index.d.ts +2 -2
  95. package/dist/errors/docs-url.js +6 -6
  96. package/dist/errors/index.d.ts +1 -1
  97. package/dist/errors/index.js +1 -1
  98. package/dist/errors/subject-erased.d.ts +1 -0
  99. package/dist/errors/subject-erased.js +6 -0
  100. package/dist/event-store/admin-api.js +3 -2
  101. package/dist/event-store/errors.d.ts +9 -0
  102. package/dist/event-store/errors.js +20 -0
  103. package/dist/event-store/event-store.d.ts +6 -0
  104. package/dist/event-store/event-store.js +28 -6
  105. package/dist/event-store/index.d.ts +1 -1
  106. package/dist/event-store/index.js +1 -1
  107. package/dist/files/create-file-ref.d.ts +2 -1
  108. package/dist/files/create-file-ref.js +16 -2
  109. package/dist/files/dispose-removed-file-refs.js +21 -6
  110. package/dist/files/feature.d.ts +1 -0
  111. package/dist/files/feature.js +2 -1
  112. package/dist/files/file-handle.js +4 -0
  113. package/dist/files/file-routes.js +9 -5
  114. package/dist/files/index.d.ts +1 -1
  115. package/dist/files/index.js +1 -1
  116. package/dist/files/purge-expired-file-ref-bytes.js +2 -3
  117. package/dist/files/resolve-content-type.js +40 -4
  118. package/dist/files/storage-tracking.js +5 -4
  119. package/dist/files/types.js +5 -8
  120. package/dist/files/zip-entry-names.d.ts +1 -0
  121. package/dist/files/zip-entry-names.js +46 -0
  122. package/dist/jobs/job-runner.d.ts +2 -0
  123. package/dist/jobs/job-runner.js +183 -48
  124. package/dist/jobs/max-per-tenant.d.ts +8 -0
  125. package/dist/jobs/max-per-tenant.js +14 -0
  126. package/dist/observability/escape-hatch-report.d.ts +1 -0
  127. package/dist/observability/escape-hatch-report.js +31 -2
  128. package/dist/observability/index.d.ts +1 -1
  129. package/dist/observability/index.js +1 -1
  130. package/dist/observability/metrics-handle.d.ts +1 -0
  131. package/dist/observability/metrics-handle.js +7 -0
  132. package/dist/observability/standard-metrics.js +4 -4
  133. package/dist/pipeline/dispatch-query.d.ts +2 -1
  134. package/dist/pipeline/dispatch-query.js +6 -6
  135. package/dist/pipeline/dispatch-shared.d.ts +1 -1
  136. package/dist/pipeline/dispatch-shared.js +41 -30
  137. package/dist/pipeline/dispatch-stream.js +10 -4
  138. package/dist/pipeline/dispatcher.js +3 -1
  139. package/dist/pipeline/event-dispatcher.d.ts +2 -2
  140. package/dist/pipeline/event-dispatcher.js +11 -9
  141. package/dist/pipeline/event-retention.d.ts +1 -0
  142. package/dist/pipeline/event-retention.js +7 -1
  143. package/dist/pipeline/index.d.ts +2 -0
  144. package/dist/pipeline/index.js +1 -0
  145. package/dist/pipeline/multi-stream-apply-context.d.ts +1 -0
  146. package/dist/pipeline/multi-stream-apply-context.js +1 -0
  147. package/dist/pipeline/notify-scope.js +3 -1
  148. package/dist/pipeline/pre-transaction-reservations.js +2 -1
  149. package/dist/schema-cli.js +12 -1
  150. package/dist/stack/db.d.ts +2 -2
  151. package/dist/stack/test-stack.js +2 -1
  152. package/dist/testing/index.d.ts +1 -0
  153. package/dist/testing/index.js +1 -0
  154. package/dist/testing/multipart-helper.d.ts +6 -0
  155. package/dist/testing/multipart-helper.js +1 -1
  156. package/dist/testing/zip-fixture.d.ts +1 -0
  157. package/dist/testing/zip-fixture.js +33 -0
  158. package/dist/ui-types/card-column-roles.js +4 -2
  159. package/dist/ui-types/index.d.ts +1 -1
  160. package/dist/ui-types/index.js +1 -1
  161. package/dist/upgrade-cli.js +46 -13
  162. package/package.json +4 -4
  163. package/src/changes.json +323 -0
@@ -2,7 +2,7 @@
2
2
 
3
3
  # pdf-audit-export
4
4
 
5
- Exports an audit table as a PDF: a write handler renders rows with renderTablePdf and stores the bytes as a FileRef via createFileRefFromBytes.
5
+ Exports an audit table as a PDF: a write handler renders rows with renderTablePdf and uploads the bytes before the transaction (uploadFileBytesBeforeTransaction) and stores them as a FileRef (createFileRefFromUploadedBytes).
6
6
 
7
7
  ## README
8
8
 
@@ -13,7 +13,8 @@ A write handler exports tenant data as a PDF and keeps it as a FileRef.
13
13
  ## What it shows
14
14
 
15
15
  - `renderTablePdf` (`@cosmicdrift/kumiko-bundled-features/renderer-pdf`) draws a table document in pure JavaScript: repeating header, weighted columns, page footer. The handler passes ready-formatted strings.
16
- - `createFileRefFromBytes` (`@cosmicdrift/kumiko-framework/files`) stores the bytes like an upload (validation, tenant-prefixed storage key, `fileRef.created` event); the tenant comes from the caller.
16
+ - The export is a `beforeTransaction` handler: it reads the rows (reads work in that phase), renders the PDF and calls `uploadFileBytesBeforeTransaction` (`@cosmicdrift/kumiko-framework/files`), which validates the bytes and writes them to the tenant-prefixed storage key. The upload runs before the transaction because storage I/O inside it would hold a pool connection for as long as the upload takes.
17
+ - The handler itself calls `createFileRefFromUploadedBytes(ctx, event.prepared)` and creates the `file_refs` row and the `fileRef.created` event in the transaction. If the transaction does not commit, the phase's `release` deletes the uploaded bytes again (only when no row exists for the storage key). A crash between upload and commit leaves the bytes in storage; nothing sweeps them.
17
18
  - The result is a normal FileRef: `GET /api/files/:id` serves it with `Content-Type: application/pdf`, other tenants get 404.
18
19
 
19
20
  ## Source
@@ -147,6 +148,21 @@ describe("export-audit", () => {
147
148
  const { text } = await extractText(proxy, { mergePages: true });
148
149
  expect(text).toContain("Keine Prüfungen im Zeitraum");
149
150
  });
151
+
152
+ test("a period with more inspections than the export holds is rejected instead of cut off", async () => {
153
+ const assetName = "Großanlage";
154
+ for (let day = 0; day < 101; day += 1) {
155
+ const inspectedOn = new Date(Date.UTC(2025, 0, 1 + day)).toISOString().slice(0, 10);
156
+ await addInspection(inspectedOn, "ok", `Prüfer-${day}`, assetName);
157
+ }
158
+
159
+ const err = await stack.http.writeErr(
160
+ "pdf-audit-export:write:export-audit",
161
+ { assetName, from: "2025-01-01", to: "2025-12-31" },
162
+ userA,
163
+ );
164
+ expect(err.httpStatus).toBe(422);
165
+ });
150
166
  });
151
167
  ```
152
168
 
@@ -156,9 +172,10 @@ describe("export-audit", () => {
156
172
  // PDF Audit Export Sample
157
173
  //
158
174
  // A write handler turns tenant data into a PDF and keeps it as a regular
159
- // FileRef: renderTablePdf draws the table (pure JS, no browser), then
160
- // createFileRefFromBytes stores the bytes through the same validation,
161
- // storage layout and fileRef.created event as an upload.
175
+ // FileRef: renderTablePdf draws the table (pure JS, no browser). The storage
176
+ // write runs in the beforeTransaction phase, so the transaction holds no pool
177
+ // connection while the bytes upload; the handler then only creates the
178
+ // fileRef row from event.prepared.
162
179
 
163
180
  import { renderTablePdf } from "@cosmicdrift/kumiko-bundled-features/renderer-pdf";
164
181
  import {
@@ -168,8 +185,13 @@ import {
168
185
  createSelectField,
169
186
  createTextField,
170
187
  defineFeature,
188
+ defineWriteHandler,
171
189
  } from "@cosmicdrift/kumiko-framework/engine";
172
- import { createFileRefFromBytes } from "@cosmicdrift/kumiko-framework/files";
190
+ import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
191
+ import {
192
+ createFileRefFromUploadedBytes,
193
+ uploadFileBytesBeforeTransaction,
194
+ } from "@cosmicdrift/kumiko-framework/files";
173
195
  import * as z from "zod";
174
196
 
175
197
  export const INSPECTION_RESULTS = ["ok", "defect"] as const;
@@ -199,7 +221,8 @@ function toFileNameSegment(text: string): string {
199
221
  return text.replaceAll(/[^A-Za-z0-9_-]+/g, "-");
200
222
  }
201
223
 
202
- const AUDIT_ROW_LIMIT = 200;
224
+ // One row above the limit tells "exactly full" from "cut off"; entity lists cap at 200 rows.
225
+ const AUDIT_ROW_LIMIT = 100;
203
226
 
204
227
  const access = { access: { roles: ["Admin", "User"] } } as const;
205
228
 
@@ -220,51 +243,64 @@ export const pdfAuditExportFeature = defineFeature("pdf-audit-export", (r) => {
220
243
  );
221
244
 
222
245
  r.writeHandler(
223
- "export-audit",
224
- z.object({ assetName: z.string().min(1), from: z.iso.date(), to: z.iso.date() }),
225
- async (event, ctx) => {
226
- const { assetName, from, to } = event.payload;
227
- const inspections = await inspectionExecutor.list(
228
- {
229
- filters: [
230
- { field: "assetName", op: "eq", value: assetName },
231
- { field: "inspectedOn", op: "gte", value: from },
232
- { field: "inspectedOn", op: "lte", value: to },
246
+ defineWriteHandler({
247
+ name: "export-audit",
248
+ schema: z.object({ assetName: z.string().min(1), from: z.iso.date(), to: z.iso.date() }),
249
+ access: access.access,
250
+ beforeTransaction: async (event, ctx) => {
251
+ const { assetName, from, to } = event.payload;
252
+ const inspections = await inspectionExecutor.list(
253
+ {
254
+ filters: [
255
+ { field: "assetName", op: "eq", value: assetName },
256
+ { field: "inspectedOn", op: "gte", value: from },
257
+ { field: "inspectedOn", op: "lte", value: to },
258
+ ],
259
+ sort: "inspectedOn",
260
+ sortDirection: "asc",
261
+ limit: AUDIT_ROW_LIMIT + 1,
262
+ },
263
+ event.user,
264
+ ctx.db,
265
+ );
266
+ // A protocol that silently drops inspections would look complete.
267
+ if (inspections.rows.length > AUDIT_ROW_LIMIT) {
268
+ return writeFailure(
269
+ new UnprocessableError("audit_period_too_large", {
270
+ details: { limit: AUDIT_ROW_LIMIT },
271
+ }),
272
+ );
273
+ }
274
+
275
+ const { pdfBytes } = await renderTablePdf({
276
+ title: `Prüfprotokoll ${assetName}`,
277
+ meta: [
278
+ { label: "Zeitraum", value: `${formatGermanDate(from)} bis ${formatGermanDate(to)}` },
279
+ { label: "Prüfungen", value: String(inspections.rows.length) },
233
280
  ],
234
- sort: "inspectedOn",
235
- sortDirection: "asc",
236
- limit: AUDIT_ROW_LIMIT,
237
- },
238
- event.user,
239
- ctx.db,
240
- );
241
-
242
- const { pdfBytes } = await renderTablePdf({
243
- title: `Prüfprotokoll ${assetName}`,
244
- meta: [
245
- { label: "Zeitraum", value: `${formatGermanDate(from)} bis ${formatGermanDate(to)}` },
246
- { label: "Prüfungen", value: String(inspections.rows.length) },
247
- ],
248
- columns: [{ header: "Datum" }, { header: "Ergebnis" }, { header: "Prüfer", weight: 2 }],
249
- rows: inspections.rows.map((row) => [
250
- formatGermanDate(String(row["inspectedOn"])),
251
- RESULT_LABELS[z.enum(INSPECTION_RESULTS).parse(row["result"])],
252
- String(row["inspector"] ?? ""),
253
- ]),
254
- emptyText: "Keine Prüfungen im Zeitraum",
255
- footerText: `Prüfbericht ${assetName}`,
256
- pageLabel: "Seite {page} von {pages}",
257
- });
258
-
259
- const created = await createFileRefFromBytes(ctx, {
260
- fileName: `audit-${toFileNameSegment(assetName)}.pdf`,
261
- mimeType: "application/pdf",
262
- data: pdfBytes,
263
- });
264
- if (!created.isSuccess) return created;
265
- return { isSuccess: true as const, data: { fileRefId: created.data.id } };
266
- },
267
- access,
281
+ columns: [{ header: "Datum" }, { header: "Ergebnis" }, { header: "Prüfer", weight: 2 }],
282
+ rows: inspections.rows.map((row) => [
283
+ formatGermanDate(String(row["inspectedOn"])),
284
+ RESULT_LABELS[z.enum(INSPECTION_RESULTS).parse(row["result"])],
285
+ String(row["inspector"] ?? ""),
286
+ ]),
287
+ emptyText: "Keine Prüfungen im Zeitraum",
288
+ footerText: `Prüfbericht ${assetName}`,
289
+ pageLabel: "Seite {page} von {pages}",
290
+ });
291
+
292
+ return uploadFileBytesBeforeTransaction(ctx, {
293
+ fileName: `audit-${toFileNameSegment(assetName)}.pdf`,
294
+ mimeType: "application/pdf",
295
+ data: pdfBytes,
296
+ });
297
+ },
298
+ handler: async (event, ctx) => {
299
+ const created = await createFileRefFromUploadedBytes(ctx, event.prepared);
300
+ if (!created.isSuccess) return created;
301
+ return { isSuccess: true as const, data: { fileRefId: created.data.id } };
302
+ },
303
+ }),
268
304
  );
269
305
  });
270
306
  ```
@@ -67,7 +67,7 @@ const server = buildServer({
67
67
  // real HTTP + DB + Redis. If a reader copies the buildServer({...}) block
68
68
  // verbatim, the resulting app behaves like this test asserts.
69
69
 
70
- import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
70
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
71
71
  import {
72
72
  AuthHandlers,
73
73
  createAuthEmailPasswordFeature,
@@ -81,7 +81,6 @@ import {
81
81
  import {
82
82
  type SessionCallbacks,
83
83
  userSessionEntity,
84
- userSessionTable,
85
84
  } from "@cosmicdrift/kumiko-bundled-features/sessions";
86
85
  import {
87
86
  sessionCallbacksFromLateBound,
@@ -97,9 +96,7 @@ import {
97
96
  createUserFeature,
98
97
  UserHandlers,
99
98
  userEntity,
100
- userTable,
101
99
  } from "@cosmicdrift/kumiko-bundled-features/user";
102
- import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
103
100
  import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
104
101
  import {
105
102
  setupTestStack,
@@ -147,12 +144,6 @@ afterAll(async () => {
147
144
  await stack.cleanup();
148
145
  });
149
146
 
150
- beforeEach(async () => {
151
- await asRawClient(stack.db).unsafe(`DELETE FROM "${userSessionTable.tableName}"`);
152
- await asRawClient(stack.db).unsafe(`DELETE FROM "${userTable.tableName}"`);
153
- await asRawClient(stack.db).unsafe(`DELETE FROM "${tenantMembershipsTable.tableName}"`);
154
- });
155
-
156
147
  describe("session-revocation sample wiring", () => {
157
148
  test("end-to-end: login, use, logout, reject", async () => {
158
149
  const hash = await hashPassword("super-long-password");
@@ -130,18 +130,20 @@ import {
130
130
  tagAssignmentEntity,
131
131
  tagEntity,
132
132
  } from "@cosmicdrift/kumiko-bundled-features/tags";
133
- import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
134
133
  import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
135
134
  import {
136
135
  createTestUser,
137
136
  setupTestStack,
138
137
  type TestStack,
138
+ testTenantId,
139
139
  unsafeCreateEntityTable,
140
140
  } from "@cosmicdrift/kumiko-framework/stack";
141
141
  import { noteEntity, noteFeature } from "../feature";
142
142
  import { type TagClient, tagFlow } from "../usage";
143
143
 
144
- const admin = createTestUser({ roles: ["TenantAdmin"] });
144
+ // Each test runs in its own tenant, so rows from other tests never show up.
145
+ let admin = createTestUser({ roles: ["TenantAdmin"], tenantId: testTenantId(1) });
146
+ let nextTenant = 1;
145
147
 
146
148
  // Adapt the test stack to the host-facing TagClient the docs embed uses.
147
149
  const tagClient: TagClient = {
@@ -164,11 +166,9 @@ afterAll(async () => {
164
166
  await stack.cleanup();
165
167
  });
166
168
 
167
- beforeEach(async () => {
168
- await asRawClient(stack.db).unsafe("DELETE FROM kumiko_events");
169
- await asRawClient(stack.db).unsafe("DELETE FROM read_sample_tags_notes");
170
- await asRawClient(stack.db).unsafe("DELETE FROM read_tags");
171
- await asRawClient(stack.db).unsafe("DELETE FROM read_tag_assignments");
169
+ beforeEach(() => {
170
+ nextTenant += 1;
171
+ admin = createTestUser({ roles: ["TenantAdmin"], tenantId: testTenantId(nextTenant) });
172
172
  });
173
173
 
174
174
  async function createNote(id: string, title: string) {
@@ -144,7 +144,7 @@ import {
144
144
  runForgetCleanup,
145
145
  runUserExport,
146
146
  } from "@cosmicdrift/kumiko-bundled-features/user-data-rights";
147
- import { asRawClient, insertOne } from "@cosmicdrift/kumiko-framework/bun-db";
147
+ import { asRawClient, insertOne, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
148
148
  import { EXT_USER_DATA } from "@cosmicdrift/kumiko-framework/engine";
149
149
  import {
150
150
  setupTestStack,
@@ -157,9 +157,13 @@ import { noteEntity, notesFeature, notesTable } from "../feature";
157
157
 
158
158
  let stack: TestStack;
159
159
 
160
- const TENANT_A = "00000000-0000-4000-8000-00000000000a";
161
160
  const TENANT_SYSTEM = "00000000-0000-4000-8000-000000000001";
162
- const ALICE_ID = "11111111-1111-4111-8111-000000000001";
161
+
162
+ // Each test gets its own tenant and user, so rows from other tests never show up.
163
+ let testNumber = 0;
164
+ let TENANT_A = "";
165
+ let ALICE_ID = "";
166
+ let ALICE_EMAIL = "";
163
167
 
164
168
  type Instant = InstanceType<ReturnType<typeof getTemporal>["Instant"]>;
165
169
  const NOW = (): Instant => getTemporal().Now.instant();
@@ -204,17 +208,23 @@ afterAll(async () => {
204
208
  await stack.cleanup();
205
209
  });
206
210
 
207
- beforeEach(async () => {
208
- await asRawClient(stack.db).unsafe(`DELETE FROM store_notes`);
209
- await asRawClient(stack.db).unsafe(`DELETE FROM "${userTable.tableName}"`);
210
- await asRawClient(stack.db).unsafe(`DELETE FROM read_tenant_memberships`);
211
+ beforeEach(() => {
212
+ testNumber += 1;
213
+ const suffix = String(testNumber).padStart(12, "0");
214
+ TENANT_A = `00000000-0000-4000-8000-${suffix}`;
215
+ ALICE_ID = `11111111-1111-4111-8111-${suffix}`;
216
+ ALICE_EMAIL = `alice-${testNumber}@recipe.test`;
211
217
  });
212
218
 
219
+ function noteId(index: number): string {
220
+ return `aaaaaaaa-aaaa-4aaa-8aaa-${String(testNumber * 10 + index).padStart(12, "0")}`;
221
+ }
222
+
213
223
  async function seedAlice(opts: { gracePeriodEnd?: Instant; status?: string } = {}): Promise<void> {
214
224
  await seedRow(stack.db, userTable, {
215
225
  id: ALICE_ID,
216
226
  tenantId: TENANT_SYSTEM,
217
- email: "alice@recipe.test",
227
+ email: ALICE_EMAIL,
218
228
  passwordHash: "h",
219
229
  displayName: "Alice",
220
230
  locale: "de",
@@ -246,8 +256,8 @@ async function seedNote(id: string, title: string): Promise<void> {
246
256
  describe("user-data-rights recipe :: EXT_USER_DATA-Hooks integrieren Notes-Domain", () => {
247
257
  test("Export-Bundle enthaelt Note-Snippet via Hook", async () => {
248
258
  await seedAlice();
249
- await seedNote("aaaaaaaa-aaaa-4aaa-8aaa-000000000001", "first");
250
- await seedNote("aaaaaaaa-aaaa-4aaa-8aaa-000000000002", "second");
259
+ await seedNote(noteId(1), "first");
260
+ await seedNote(noteId(2), "second");
251
261
 
252
262
  const bundle = await runUserExport({
253
263
  db: stack.db,
@@ -267,7 +277,7 @@ describe("user-data-rights recipe :: EXT_USER_DATA-Hooks integrieren Notes-Domai
267
277
 
268
278
  test("Forget-Cron strategy=delete entfernt Notes (Default-Pfad)", async () => {
269
279
  await seedAlice({ status: USER_STATUS.DeletionRequested, gracePeriodEnd: PAST() });
270
- await seedNote("aaaaaaaa-aaaa-4aaa-8aaa-000000000003", "to-delete");
280
+ await seedNote(noteId(1), "to-delete");
271
281
 
272
282
  const result = await runForgetCleanup({
273
283
  db: stack.db,
@@ -276,13 +286,13 @@ describe("user-data-rights recipe :: EXT_USER_DATA-Hooks integrieren Notes-Domai
276
286
  });
277
287
  expect(result.processedUserIds).toContain(ALICE_ID);
278
288
 
279
- const remaining = await asRawClient(stack.db).unsafe(`SELECT id FROM store_notes`);
289
+ const remaining = await selectMany(stack.db, notesTable, { tenantId: TENANT_A });
280
290
  expect(remaining.length).toBe(0);
281
291
  });
282
292
 
283
293
  test("Strategy=anonymize: authorId=null, Row bleibt (HR-Compliance-Pfad)", async () => {
284
294
  await seedAlice();
285
- await seedNote("aaaaaaaa-aaaa-4aaa-8aaa-000000000004", "stays-anonymous");
295
+ await seedNote(noteId(1), "stays-anonymous");
286
296
 
287
297
  const usage = stack.registry
288
298
  .getExtensionUsages(EXT_USER_DATA)
@@ -297,11 +307,9 @@ describe("user-data-rights recipe :: EXT_USER_DATA-Hooks integrieren Notes-Domai
297
307
  | undefined;
298
308
  await hooks?.delete?.({ db: stack.db, tenantId: TENANT_A, userId: ALICE_ID }, "anonymize");
299
309
 
300
- const after = (await asRawClient(stack.db).unsafe(
301
- `SELECT title, author_id FROM store_notes`,
302
- )) as Array<{ title: string; author_id: string | null }>;
310
+ const after = await selectMany(stack.db, notesTable, { tenantId: TENANT_A });
303
311
  expect(after).toHaveLength(1);
304
- expect(after[0]?.author_id).toBeNull();
312
+ expect(after[0]?.authorId).toBeNull();
305
313
  expect(after[0]?.title).toBe("stays-anonymous");
306
314
  });
307
315
  });
@@ -68,7 +68,7 @@ bun test
68
68
  ```ts
69
69
  // webhook-step integration test — drives r.step.webhook.send end-to-end:
70
70
  // handler appends step.dispatch-requested in TX, dispatcher subscription
71
- // drains after COMMIT and calls the (stubbed) fetch, follow-up
71
+ // enqueues a delivery job after COMMIT, the job calls the (stubbed) fetch, follow-up
72
72
  // step.dispatched / step.dispatch-failed events land on the same stream.
73
73
 
74
74
  import { afterAll, beforeAll, beforeEach, describe, expect, mock, test } from "bun:test";
@@ -140,6 +140,7 @@ beforeAll(async () => {
140
140
  stack = await setupTestStack({
141
141
  features: [createStepDispatcherFeature(), createSecretsFeature(), webhookDemoFeature],
142
142
  systemHooks: [],
143
+ jobs: { consumerLane: "worker" },
143
144
  extraContext: ({ db, registry }) => ({
144
145
  secrets: createSecretsContext({ db, masterKeyProvider, registry }),
145
146
  }),
@@ -184,6 +185,7 @@ describe("webhook-step Sample", () => {
184
185
 
185
186
  // Drain dispatcher (MSP runs async via runOnce in test mode)
186
187
  await stack.eventDispatcher?.runOnce();
188
+ await stack.drainJobs();
187
189
 
188
190
  expect(fetchMock).toHaveBeenCalledTimes(1);
189
191
  const [calledUrl, init] = fetchMock.mock.calls[0]!;
@@ -202,6 +204,7 @@ describe("webhook-step Sample", () => {
202
204
  expect(id).toMatch(/^[0-9a-f-]{36}$/);
203
205
 
204
206
  await stack.eventDispatcher?.runOnce();
207
+ await stack.drainJobs();
205
208
 
206
209
  expect(mailMock).toHaveBeenCalledTimes(1);
207
210
  expect(mailMock).toHaveBeenCalledWith(
@@ -231,6 +234,7 @@ describe("webhook-step Sample", () => {
231
234
 
232
235
  // And the inner handler's webhook fired
233
236
  await stack.eventDispatcher?.runOnce();
237
+ await stack.drainJobs();
234
238
  expect(fetchMock).toHaveBeenCalledTimes(1);
235
239
  });
236
240
 
@@ -243,6 +247,7 @@ describe("webhook-step Sample", () => {
243
247
  expect(res.status).toBe(500);
244
248
 
245
249
  await stack.eventDispatcher?.runOnce();
250
+ await stack.drainJobs();
246
251
 
247
252
  expect(fetchMock).not.toHaveBeenCalled();
248
253
  const rows = await selectMany(stack.db, incidentTable);
@@ -261,6 +266,7 @@ describe("access-denied — role-restricted handlers", () => {
261
266
  );
262
267
  expect(error.code).toBe("access_denied");
263
268
  await stack.eventDispatcher?.runOnce();
269
+ await stack.drainJobs();
264
270
  expect(mailMock).not.toHaveBeenCalled();
265
271
  });
266
272
 
@@ -287,6 +293,7 @@ describe("access-denied — role-restricted handlers", () => {
287
293
  );
288
294
  expect(error.code).toBe("access_denied");
289
295
  await stack.eventDispatcher?.runOnce();
296
+ await stack.drainJobs();
290
297
  expect(fetchMock).not.toHaveBeenCalled();
291
298
  });
292
299
  });
@@ -317,6 +324,7 @@ describe("incident:open-authenticated — tenant-owned webhook auth secret", ()
317
324
  adminB,
318
325
  );
319
326
  await stack.eventDispatcher?.runOnce();
327
+ await stack.drainJobs();
320
328
 
321
329
  expect(fetchMock).toHaveBeenCalledTimes(1);
322
330
  const [, init] = fetchMock.mock.calls[0]!;
@@ -348,6 +356,7 @@ describe("incident:open-authenticated — tenant-owned webhook auth secret", ()
348
356
  adminA,
349
357
  );
350
358
  await stack.eventDispatcher?.runOnce();
359
+ await stack.drainJobs();
351
360
 
352
361
  expect(fetchMock).not.toHaveBeenCalled();
353
362
 
@@ -378,6 +387,7 @@ describe("incident:open-authenticated — tenant-owned webhook auth secret", ()
378
387
  adminC,
379
388
  );
380
389
  await stack.eventDispatcher?.runOnce();
390
+ await stack.drainJobs();
381
391
 
382
392
  expect(fetchMock).not.toHaveBeenCalled();
383
393
  const failedEvents = await selectMany(stack.db, eventsTable, {
@@ -983,7 +983,7 @@ export function startCronScheduler(
983
983
 
984
984
  ```ts
985
985
  import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
986
- import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
986
+ import { type DbRunner, toPostgresArrayLiteral } from "@cosmicdrift/kumiko-framework/db";
987
987
 
988
988
  export async function selectExpiredSuspensionEvents(
989
989
  db: DbRunner,
@@ -993,7 +993,7 @@ export async function selectExpiredSuspensionEvents(
993
993
  `SELECT * FROM "kumiko_events"
994
994
  WHERE "type" = ANY($1::text[])
995
995
  AND (("payload"->>'wakeAt')::timestamptz < now() OR ("payload"->>'timeoutAt')::timestamptz < now())`,
996
- [eventTypes as unknown as string[]],
996
+ [toPostgresArrayLiteral(eventTypes)],
997
997
  )) as Record<string, unknown>[];
998
998
  }
999
999
  ```
@@ -23,6 +23,7 @@
23
23
  | `currencies-per-tenant` | Tenant-scoped money fields. Commas matter. | `examples/currencies-per-tenant.md` |
24
24
  | `custom-fields-basic` | Tenant-defined extra fields on an entity without writing migrations. | `examples/custom-fields-basic.md` |
25
25
  | `custom-handlers` | Beyond CRUD — when your domain refuses to fit a table. | `examples/custom-handlers.md` |
26
+ | `dashboard-stats` | Key figures on a dashboard: stat panels backed by one counting query. | `examples/dashboard-stats.md` |
26
27
  | `delivery-notifications` | Delivery flows plus notifications. Package tracking, minus the tracking. | `examples/delivery-notifications.md` |
27
28
  | `designer-demo` | Kumiko sample feature — scaffolded by `bun kumiko create` | `examples/designer-demo.md` |
28
29
  | `e2e-generator` | generateE2ESpec + withBootValidatorFixture — Registry → JSON-Specs für externen Playwright-Worker. Zeigt die 4 Test-Kinds, Feldtyp→Interaktion-Mapping und welche Feldtypen heute noch nicht generisch unterstützt sind. | `examples/e2e-generator.md` |
@@ -46,7 +47,7 @@
46
47
  | `managed-pages` | Tenant-editable, server-rendered public pages with per-tenant branding + tier-gated sanitized custom CSS — managed-pages wired up with config + anonymousAccess. | `examples/managed-pages.md` |
47
48
  | `notes-history-basic` | Append-only note history — who wrote what, when — attached to any entity without changing it. | `examples/notes-history-basic.md` |
48
49
  | `ownership` | Entity- and Field-level Row-Ownership: from() helper, Straddle-safe multi-role checks, Field-level Read/Write filtering. | `examples/ownership.md` |
49
- | `pdf-audit-export` | Exports an audit table as a PDF: a write handler renders rows with renderTablePdf and stores the bytes as a FileRef via createFileRefFromBytes. | `examples/pdf-audit-export.md` |
50
+ | `pdf-audit-export` | Exports an audit table as a PDF: a write handler renders rows with renderTablePdf and uploads the bytes before the transaction (uploadFileBytesBeforeTransaction) and stores them as a FileRef (createFileRefFromUploadedBytes). | `examples/pdf-audit-export.md` |
50
51
  | `pipeline-basics` | M.1 Tier-1 step-vocabulary showcase: every step in one inventory-management feature (aggregate.{create,update,appendEvent}, read.{findOne,findMany}, branch, forEach, compute, return, unsafeProjection{Upsert,Delete}). | `examples/pipeline-basics.md` |
51
52
  | `public-share-token` | Generic share-token entity: mint hash-stored token, anonymous read-by-token with IP rate-limit. | `examples/public-share-token.md` |
52
53
  | `rate-limiting` | End-to-end rate limiting: L1 global-IP + L2 auth-endpoints + L3 handler opt-in. | `examples/rate-limiting.md` |
@@ -538,7 +538,7 @@ Requires: `template-resolver`
538
538
 
539
539
  ## renderer-mail-html
540
540
 
541
- A `renderer-foundation` plugin that handles `RenderRequest { kind: "mail" }` and returns `{ subject, html, text, previewText? }` suitable for sending via `channel-email`. The mail template (`kind: "mail"`, document HTML, subject in `meta`) resolves through `template-resolver`'s DB fallback, its body is compiled to Handlebars by template-resolver and rendered with escaping, then placed into an MJML layout (`kind: "mail-layout"`, slug `default` unless `meta.layoutSlug` says otherwise, in-code `DEFAULT_MAIL_LAYOUT_MJML` as last resort) that `mjml()` compiles to email-safe table HTML, with `html-to-text` producing the plain-text alternative. Seed an editable system layout with `seedSystemMailLayout(db)`. Requires `renderer-foundation` + `template-resolver`.
541
+ A `renderer-foundation` plugin that handles `RenderRequest { kind: "mail" }` and returns `{ subject, html, text, previewText? }` suitable for sending via `channel-email`. The mail template (`kind: "mail"`, document HTML, subject in `meta`) resolves through `template-resolver`'s DB fallback, its body is compiled to Handlebars by template-resolver and rendered with escaping, then placed into an MJML layout (`kind: "mail-layout"`, slug `default` unless `meta.layoutSlug` says otherwise, an in-code structured layout with the tenant branding logo and accent color as last resort) that `mjml()` compiles to email-safe table HTML, with `html-to-text` producing the plain-text alternative. An optional `footer` resolver adds the platform imprint, privacy link and footer text below every structured layout, and an optional `brandSuffix` resolver adds a word after the tenant name in the brand row of the in-code layout. Mail meta `headerChip`, `tone` and `footerNote` add a chip beside the brand, the tone of the top line and chip, and a note above the site and unsubscribe links of the card. Seed an editable system layout with `seedSystemMailLayout(db)`. Requires `renderer-foundation` + `template-resolver`.
542
542
 
543
543
  Requires: `renderer-foundation`, `template-resolver`
544
544
 
@@ -552,7 +552,7 @@ Requires: `renderer-foundation`
552
552
 
553
553
  ## renderer-puppeteer-client
554
554
 
555
- A `renderer-foundation` plugin that prints documents to PDF through a separately deployed `puppeteer-service`. A `document-pdf` request names a stored template (`{ slug, locale, variables }`): the plugin resolves the template and its `pdf-layout` (header, signature and footer regions), embeds every image as a data URI, turns repeating table header rows into a real table head and sends the finished HTML to the service, which performs no network fetches itself. The page format, margins, file name pattern and title come from the template meta. A write handler renders with `renderPdfBeforeTransaction(ctx, { slug, locale, variables })` as its `beforeTransaction` phase, so the print service call holds no database connection, and stores the result with `storeRenderedPdf(ctx, event.prepared)` in the handler: a file plus a `rendered-pdf` record that keeps the template and layout version, so a stored document can always be traced to the text it was created from. The `puppeteer-status` query tells a TenantAdmin whether the service answers; while it does not, rendering fails with a `puppeteer_unavailable` error (HTTP 503) and no PDF is created. `image-snapshot` requests still take ready HTML in `payload.content`. Configure the endpoint and token with `PUPPETEER_SERVICE_URL` and `PUPPETEER_AUTH_TOKEN`; the client retries 5xx and network errors with exponential backoff (default 3 retries), 4xx fails at once. The feature adds the `rendered_pdfs` table, so apps that mount it need a schema migration.
555
+ A `renderer-foundation` plugin that prints documents to PDF through a separately deployed `puppeteer-service`. A `document-pdf` request names a stored template (`{ slug, locale, variables }`): the plugin resolves the template and its `pdf-layout` (header, signature and footer regions), embeds every image as a data URI, turns repeating table header rows into a real table head and sends the finished HTML to the service, which performs no network fetches itself. The page format, margins, file name pattern and title come from the template meta. A write handler renders with `renderPdfBeforeTransaction(ctx, { slug, locale, variables })` as its `beforeTransaction` phase, so the print service call holds no database connection, and stores the result with `storeRenderedPdf(ctx, event.prepared)` in the handler: a file plus a `rendered-pdf` record that keeps the template and layout version, so a stored document can always be traced to the text it was created from. The `puppeteer-status` query tells a TenantAdmin whether the service answers; while it does not, rendering fails with a `puppeteer_unavailable` error (HTTP 503) and no PDF is created. `image-snapshot` requests still take ready HTML in `payload.content`, which must embed all resources as data: URIs because the service fetches no http(s) resources. Configure the endpoint and token with `PUPPETEER_SERVICE_URL` and `PUPPETEER_AUTH_TOKEN`; the client retries 5xx and network errors with exponential backoff (default 3 retries), 4xx fails at once. The feature adds the `rendered_pdfs` table, so apps that mount it need a schema migration.
556
556
 
557
557
  Requires: `renderer-foundation`
558
558
 
@@ -594,7 +594,7 @@ Exposes APIs: `sessions.revokeAllForUser`
594
594
 
595
595
  ## step-dispatcher
596
596
 
597
- Internal system feature that drains deferred Tier-2 side-effects (currently `webhook.send` and `mail.send`) after their originating transaction commits. Listens via `r.multiStreamProjection` on the `kumiko:system:step.dispatch-requested` system event, performs the actual HTTP or mail delivery, then appends `kumiko:system:step.dispatched` or `kumiko:system:step.dispatch-failed` back onto the same stream so the outcome is recorded in the event log without a separate status table. Mount this feature explicitly via `createStepDispatcherFeature()` in your app's feature list alongside any features that use `r.step.webhook.send` or `r.step.mail.send`. Requires the `secrets` feature (`createSecretsFeature()`) to be mounted — `webhook.send` auth resolves per-tenant through it, under `step-dispatcher:webhook-auth.<name>`. Every `webhook.send` request carries `Idempotency-Key: <dispatch stream id>`, stable across redeliveries of the same dispatch request; an explicit `Idempotency-Key` in `headers` takes precedence.
597
+ Internal system feature that drains deferred Tier-2 side-effects (currently `webhook.send` and `mail.send`) after their originating transaction commits. An `r.multiStreamProjection` on the `kumiko:system:step.dispatch-requested` system event enqueues one `step-dispatcher:job:deliver` BullMQ job per dispatch (Redis carries only the stream id and tenant, never the request payload). The job performs the actual HTTP or mail delivery and appends `kumiko:system:step.dispatched` or `kumiko:system:step.dispatch-failed` back onto the same stream, so the outcome is recorded in the event log without a separate status table. Transient failures (network error, timeout, 5xx, 408, 429, any mail adapter failure) are retried 3 times with exponential backoff; the outcome is recorded after the final attempt and the payload key is erased only then. Permanent failures (invalid url, blocked host, other 4xx, unavailable auth secret) are recorded immediately. At most 2 deliveries per tenant run at once (`maxPerTenant` with `onLimit: "defer"`), so a slow receiver of one tenant does not delay other tenants. Requires a job runner (Redis) and a worker consuming the job's default `worker` lane; without a job runner the projection fails loudly. Mount this feature explicitly via `createStepDispatcherFeature()` in your app's feature list alongside any features that use `r.step.webhook.send` or `r.step.mail.send`. Requires the `secrets` feature (`createSecretsFeature()`) to be mounted — `webhook.send` auth resolves per-tenant through it, under `step-dispatcher:webhook-auth.<name>`. Every `webhook.send` request carries `Idempotency-Key: <dispatch stream id>`, stable across retries and redeliveries of the same dispatch request; an explicit `Idempotency-Key` in `headers` takes precedence.
598
598
 
599
599
  Category: infrastructure
600
600
 
@@ -755,7 +755,7 @@ Requires: `user`, `auth-email-password`, `user-data-rights`, `user-data-rights-d
755
755
 
756
756
  ## waitlist
757
757
 
758
- Public waitlist: an anonymous, rate-limited `submit` handler collects name, email and optional company, portfolio and message (a honeypot field and per-email dedupe keep bots and repeat submits out, and the response never reveals whether an address was already listed), and mails the submitter a confirmation. SystemAdmins review entries in a list screen and invite or reject them; an invite creates the entrant's own tenant (or joins one shared tenant) and mails an accept link with the configured membership role, never a global role. Entries are platform-wide, their personal data is crypto-shredding-ready, and the EXT_USER_DATA hooks export and erase entries matching a user's email.
758
+ Public waitlist: an anonymous, rate-limited `submit` handler collects name, email and optional company, portfolio and message (a honeypot field and per-email dedupe keep bots and repeat submits out, and the response body is identical whether or not an address was already listed), and mails the submitter a confirmation. SystemAdmins review entries in a list screen and invite or reject them; an invite creates the entrant's own tenant (or joins one shared tenant) and mails an accept link with the configured membership role, never a global role. Entries are platform-wide, their personal data is crypto-shredding-ready, and the EXT_USER_DATA hooks export and erase entries matching a user's email.
759
759
 
760
760
  Category: data
761
761
 
@@ -6,7 +6,6 @@ import { type AuthSessionChecker, type AuthSessionStatus, type TokenVerifier } f
6
6
  import { type ClientIpResolver } from "./client-ip.js";
7
7
  import type { JwtHelper } from "./jwt.js";
8
8
  import type { PostAuthLandingResolver } from "./post-auth-landing.js";
9
- export declare function assertRetiredCookieDomains(retiredCookieDomains: readonly string[] | undefined, cookieDomain: string | undefined): void;
10
9
  export type LoginRateLimiter = {
11
10
  check(key: string): Promise<boolean>;
12
11
  reset(key: string): Promise<void>;