@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
@@ -0,0 +1,229 @@
1
+ <!-- Generated by `bun run gen:agent-docs` from samples/recipes/dashboard-stats. Do not edit. -->
2
+
3
+ # dashboard-stats
4
+
5
+ Key figures on a dashboard: stat panels backed by one counting query.
6
+
7
+ ## README
8
+
9
+ # Dashboard stats
10
+
11
+ Key figures on a dashboard. This recipe ships a `ticket` entity, one query
12
+ handler that returns the figures as a flat object, and a `dashboard` screen
13
+ with a stat group (open, closed) and a single stat (total).
14
+
15
+ The point is to show how the three pieces connect: the screen only names a
16
+ query and the fields to read, the query does the counting.
17
+
18
+ ## What it shows
19
+
20
+ - **Panels reference a query by qualified name**: each `stat` carries
21
+ `query: "tickets:query:ticket:stats"`. The dashboard calls that handler and
22
+ renders the result.
23
+ - **`valueField` picks a field from a flat result**: the handler returns
24
+ `{ open, closed, total }`, and each stat names one of those keys.
25
+ - **`stat-group` and `stat`**: a group shows several stats side by side under
26
+ one title, a top-level `stat` stands alone.
27
+ - **Counting happens in the database**: the handler calls the executor's
28
+ `list` with `filter`, `limit: 1` and `totalCount: true`. The total is counted
29
+ over the whole WHERE clause, so no rows are loaded just to be counted.
30
+ - **Labels are translation keys**: declared with `r.translations`.
31
+
32
+ ## When to reach for it
33
+
34
+ You want a landing screen with a few numbers: open items, overdue invoices,
35
+ this month's revenue. Write one query per screen that returns every figure,
36
+ and let the stats pick their field.
37
+
38
+ ## Source
39
+
40
+ The whole feature lives in `src/feature.ts` (~100 lines). The integration test
41
+ checks that every stat reads a field the query returns, and that the query
42
+ counts open and closed tickets correctly.
43
+
44
+ ```bash
45
+ bun kumiko test integration samples/dashboard-stats
46
+ ```
47
+
48
+ ## Source files
49
+
50
+ ### src/__tests__/feature.integration.test.ts
51
+
52
+ ```ts
53
+ // Dashboard Stats Sample, integration test.
54
+ // Proves: the dashboard screen boots, its panels point at a registered query,
55
+ // and that query counts per status in the database.
56
+
57
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
58
+ import type { SaveContext } from "@cosmicdrift/kumiko-framework/engine";
59
+ import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
60
+ import {
61
+ createTestUser,
62
+ setupTestStack,
63
+ type TestStack,
64
+ testTenantId,
65
+ unsafeCreateEntityTable,
66
+ } from "@cosmicdrift/kumiko-framework/stack";
67
+ import { TICKET_STATS_QUERY, ticketEntity, ticketFeature } from "../feature";
68
+
69
+ let stack: TestStack;
70
+
71
+ const admin = createTestUser({ roles: ["Admin"], tenantId: testTenantId(31) });
72
+
73
+ beforeAll(async () => {
74
+ stack = await setupTestStack({ features: [ticketFeature] });
75
+ await unsafeCreateEntityTable(stack.db, ticketEntity);
76
+ await createEventsTable(stack.db);
77
+ });
78
+
79
+ afterAll(async () => {
80
+ await stack.cleanup();
81
+ });
82
+
83
+ describe("dashboard screen", () => {
84
+ test("boots, and every stat reads a field the stats query returns", async () => {
85
+ const screen = ticketFeature.screens["ticket-dashboard"];
86
+ if (screen?.type !== "dashboard") throw new Error("ticket-dashboard is not a dashboard screen");
87
+
88
+ const stats = screen.panels.flatMap((panel) =>
89
+ panel.kind === "stat-group" ? panel.stats : panel.kind === "stat" ? [panel] : [],
90
+ );
91
+ expect(stats.map((s) => s.valueField)).toEqual(["open", "closed", "total"]);
92
+ for (const stat of stats) expect(stat.query).toBe(TICKET_STATS_QUERY);
93
+
94
+ const result = await stack.http.queryOk<Record<string, unknown>>(TICKET_STATS_QUERY, {}, admin);
95
+ for (const stat of stats) expect(result).toHaveProperty(stat.valueField);
96
+ });
97
+ });
98
+
99
+ describe("ticket:stats", () => {
100
+ test("counts open and closed tickets in the database", async () => {
101
+ for (const [title, status] of [
102
+ ["a", "open"],
103
+ ["b", "open"],
104
+ ["c", "open"],
105
+ ["d", "closed"],
106
+ ["e", "closed"],
107
+ ] as const) {
108
+ await stack.http.writeOk<SaveContext>(
109
+ "tickets:write:ticket:create",
110
+ { title, status },
111
+ admin,
112
+ );
113
+ }
114
+
115
+ const result = await stack.http.queryOk<Record<string, unknown>>(TICKET_STATS_QUERY, {}, admin);
116
+
117
+ expect(result).toEqual({ open: 3, closed: 2, total: 5 });
118
+ });
119
+ });
120
+ ```
121
+
122
+ ### src/feature.ts
123
+
124
+ ```ts
125
+ // Dashboard Stats Sample
126
+ // Shows: a dashboard screen with a stat-group and a single stat, backed by one
127
+ // query handler that counts in the database.
128
+
129
+ import {
130
+ createEntity,
131
+ createEntityExecutor,
132
+ createSelectField,
133
+ createTextField,
134
+ defineFeature,
135
+ } from "@cosmicdrift/kumiko-framework/engine";
136
+ import { z } from "zod";
137
+
138
+ export const TICKET_STATUSES = ["open", "closed"] as const;
139
+
140
+ export const ticketEntity = createEntity({
141
+ table: "read_sample_tickets",
142
+ fields: {
143
+ title: createTextField({ personal: false, reason: "is_business_data", required: true }),
144
+ status: createSelectField({ options: TICKET_STATUSES, default: "open", required: true }),
145
+ },
146
+ });
147
+
148
+ export const TICKET_STATS_QUERY = "tickets:query:ticket:stats";
149
+
150
+ const readerRoles = { roles: ["Admin", "User"] } as const;
151
+
152
+ export const ticketFeature = defineFeature("tickets", (r) => {
153
+ const { executor: ticketExecutor } = createEntityExecutor("ticket", ticketEntity);
154
+
155
+ r.crud("ticket", ticketEntity, {
156
+ write: { access: { roles: ["Admin"] } },
157
+ read: { access: readerRoles },
158
+ });
159
+
160
+ // Flat result: each dashboard stat reads one field via its valueField.
161
+ // limit: 1 keeps the page tiny; totalCount counts over the whole WHERE in
162
+ // the database, so no rows are loaded just to be counted.
163
+ r.queryHandler(
164
+ "ticket:stats",
165
+ z.object({}),
166
+ async (query, ctx) => {
167
+ const countWithStatus = async (status: (typeof TICKET_STATUSES)[number]): Promise<number> => {
168
+ const page = await ticketExecutor.list(
169
+ { filter: { field: "status", op: "eq", value: status }, limit: 1, totalCount: true },
170
+ query.user,
171
+ ctx.db,
172
+ );
173
+ return page.total ?? 0;
174
+ };
175
+ const [open, closed] = await Promise.all([
176
+ countWithStatus("open"),
177
+ countWithStatus("closed"),
178
+ ]);
179
+ return { open, closed, total: open + closed };
180
+ },
181
+ { access: readerRoles },
182
+ );
183
+
184
+ r.translations({
185
+ keys: {
186
+ "tickets.dashboard.group": { en: "Tickets by status" },
187
+ "tickets.dashboard.open": { en: "Open" },
188
+ "tickets.dashboard.closed": { en: "Closed" },
189
+ "tickets.dashboard.total": { en: "All tickets" },
190
+ },
191
+ });
192
+
193
+ r.screen({
194
+ id: "ticket-dashboard",
195
+ type: "dashboard",
196
+ panels: [
197
+ {
198
+ kind: "stat-group",
199
+ id: "by-status",
200
+ label: "tickets.dashboard.group",
201
+ stats: [
202
+ {
203
+ kind: "stat",
204
+ id: "open",
205
+ label: "tickets.dashboard.open",
206
+ query: TICKET_STATS_QUERY,
207
+ valueField: "open",
208
+ },
209
+ {
210
+ kind: "stat",
211
+ id: "closed",
212
+ label: "tickets.dashboard.closed",
213
+ query: TICKET_STATS_QUERY,
214
+ valueField: "closed",
215
+ },
216
+ ],
217
+ },
218
+ {
219
+ kind: "stat",
220
+ id: "total",
221
+ label: "tickets.dashboard.total",
222
+ query: TICKET_STATS_QUERY,
223
+ valueField: "total",
224
+ },
225
+ ],
226
+ access: readerRoles,
227
+ });
228
+ });
229
+ ```
@@ -155,7 +155,7 @@ import {
155
155
  createConfigFeature,
156
156
  createConfigResolver,
157
157
  } from "@cosmicdrift/kumiko-bundled-features/config";
158
- import { asRawClient, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
158
+ import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
159
159
  import {
160
160
  setupTestStack,
161
161
  type TestStack,
@@ -166,10 +166,10 @@ import {
166
166
  import { createTestEnvelopeCipher } from "@cosmicdrift/kumiko-framework/testing";
167
167
  import { billingFeature, stripeApiKeyHandle } from "../feature";
168
168
 
169
- const TENANT_A = testTenantId(1);
170
- const TENANT_B = testTenantId(2);
171
- const acmeAdmin = { ...TestUsers.admin, tenantId: TENANT_A };
172
- const globexAdmin = { ...TestUsers.admin, tenantId: TENANT_B, id: "globex-admin" };
169
+ // Each test gets its own pair of tenants, so config rows from other tests never show up.
170
+ let acmeAdmin = { ...TestUsers.admin, tenantId: testTenantId(1) };
171
+ let globexAdmin = { ...TestUsers.admin, tenantId: testTenantId(2), id: "globex-admin" };
172
+ let nextTenantPair = 0;
173
173
 
174
174
  let stack: TestStack;
175
175
 
@@ -190,8 +190,14 @@ beforeAll(async () => {
190
190
 
191
191
  afterAll(async () => stack?.cleanup());
192
192
 
193
- beforeEach(async () => {
194
- await asRawClient(stack.db).unsafe(`DELETE FROM "${configValuesTable.tableName}"`);
193
+ beforeEach(() => {
194
+ nextTenantPair += 1;
195
+ acmeAdmin = { ...TestUsers.admin, tenantId: testTenantId(nextTenantPair * 2 + 1) };
196
+ globexAdmin = {
197
+ ...TestUsers.admin,
198
+ tenantId: testTenantId(nextTenantPair * 2 + 2),
199
+ id: "globex-admin",
200
+ };
195
201
  });
196
202
 
197
203
  describe("encrypted tenant-config: per-tenant Stripe-API-key", () => {
@@ -203,7 +209,10 @@ describe("encrypted tenant-config: per-tenant Stripe-API-key", () => {
203
209
  acmeAdmin,
204
210
  );
205
211
 
206
- const rows = await selectMany(stack.db, configValuesTable, { key: stripeApiKeyHandle.name });
212
+ const rows = await selectMany(stack.db, configValuesTable, {
213
+ key: stripeApiKeyHandle.name,
214
+ tenantId: acmeAdmin.tenantId,
215
+ });
207
216
  expect(rows.length).toBe(1);
208
217
  const stored = rows[0]?.["value"];
209
218
  expect(typeof stored).toBe("string");
@@ -115,8 +115,7 @@ The chain lands in the log (for forensics), but **not** in the response to the c
115
115
  // documented wire shape. If you extend the sample, add a matching test here
116
116
  // so the "copy this" guarantee stays valid.
117
117
 
118
- import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
119
- import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
118
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
120
119
  import { buildEntityTable } from "@cosmicdrift/kumiko-framework/db";
121
120
  import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
122
121
  import {
@@ -142,9 +141,6 @@ beforeAll(async () => {
142
141
  await createEventsTable(stack.db);
143
142
  });
144
143
  afterAll(async () => stack.cleanup());
145
- beforeEach(async () => {
146
- await asRawClient(stack.db).unsafe(`DELETE FROM "${orderTable.tableName}"`);
147
- });
148
144
 
149
145
  // --- 1) Zod validation error via schema: empty cart rejected ---
150
146
 
@@ -39,7 +39,7 @@ import {
39
39
  GlobalFeatureToggleRuntime,
40
40
  globalFeatureStateTable,
41
41
  } from "@cosmicdrift/kumiko-bundled-features/feature-toggles";
42
- import { asRawClient, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
42
+ import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
43
43
  import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
44
44
  import {
45
45
  createTestUser,
@@ -89,21 +89,36 @@ afterAll(async () => {
89
89
  await stack?.cleanup();
90
90
  });
91
91
 
92
- beforeEach(async () => {
93
- await asRawClient(stack.db).unsafe(`DELETE FROM "${productAuditTable.tableName}"`);
94
- await asRawClient(stack.db).unsafe(`DELETE FROM "${productTable.tableName}"`);
95
- await asRawClient(stack.db).unsafe(`DELETE FROM "${globalFeatureStateTable.tableName}"`);
96
- await runtime.refresh();
97
- });
98
-
99
92
  const admin = createTestUser({
100
93
  id: "11111111-1111-1111-1111-111111111111",
101
94
  tenantId: SYSTEM_TENANT_ID,
102
95
  roles: ["SystemAdmin"],
103
96
  });
104
97
 
98
+ // Products and audit rows are system-wide by design, so tests isolate through
99
+ // product names that are unique per test instead of per tenant.
100
+ let testNumber = 0;
101
+ let suffix = "";
102
+
103
+ beforeEach(() => {
104
+ testNumber += 1;
105
+ suffix = `-t${testNumber}`;
106
+ runtime.apply("product", true);
107
+ runtime.apply("product-audit", true);
108
+ });
109
+
110
+ async function auditNames(): Promise<string[]> {
111
+ const rows = await selectMany(stack.db, productAuditTable);
112
+ return rows.map((row) => String(row["productName"])).filter((name) => name.endsWith(suffix));
113
+ }
114
+
115
+ async function productNames(): Promise<string[]> {
116
+ const rows = await selectMany(stack.db, productTable);
117
+ return rows.map((row) => String(row["name"])).filter((name) => name.endsWith(suffix));
118
+ }
119
+
105
120
  async function createProduct(name: string) {
106
- return stack.http.write("product:write:product:create", { name }, admin);
121
+ return stack.http.write("product:write:product:create", { name: `${name}${suffix}` }, admin);
107
122
  }
108
123
 
109
124
  describe("feature-toggles showcase", () => {
@@ -111,8 +126,7 @@ describe("feature-toggles showcase", () => {
111
126
  // ON
112
127
  const ok = await createProduct("alpha");
113
128
  expect((await ok.json()).isSuccess).toBe(true);
114
- const auditRowsAfterOn = await selectMany(stack.db, productAuditTable);
115
- expect(auditRowsAfterOn.length).toBe(1);
129
+ expect(await auditNames()).toHaveLength(1);
116
130
 
117
131
  // OFF
118
132
  runtime.apply("product", false);
@@ -127,8 +141,7 @@ describe("feature-toggles showcase", () => {
127
141
  runtime.apply("product", true);
128
142
  const again = await createProduct("gamma");
129
143
  expect((await again.json()).isSuccess).toBe(true);
130
- const auditRowsAfterReEnable = await selectMany(stack.db, productAuditTable);
131
- expect(auditRowsAfterReEnable.length).toBe(2);
144
+ expect(await auditNames()).toHaveLength(2);
132
145
  });
133
146
 
134
147
  test("cross-feature hook: disabling product-audit skips its hook but product keeps working", async () => {
@@ -137,11 +150,9 @@ describe("feature-toggles showcase", () => {
137
150
  const ok = await createProduct("delta");
138
151
  expect((await ok.json()).isSuccess).toBe(true);
139
152
  // Product row written — handler unaffected.
140
- const productRows = await selectMany(stack.db, productTable);
141
- expect(productRows.length).toBe(1);
153
+ expect(await productNames()).toHaveLength(1);
142
154
  // Audit hook owned by product-audit — silently skipped.
143
- const auditRows = await selectMany(stack.db, productAuditTable);
144
- expect(auditRows.length).toBe(0);
155
+ expect(await auditNames()).toHaveLength(0);
145
156
  });
146
157
  });
147
158
  ```
@@ -72,17 +72,19 @@ import {
72
72
  createLedgerFeature,
73
73
  transactionEntity,
74
74
  } from "@cosmicdrift/kumiko-bundled-features/ledger";
75
- import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
76
75
  import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
77
76
  import {
78
77
  createTestUser,
79
78
  setupTestStack,
80
79
  type TestStack,
80
+ testTenantId,
81
81
  unsafeCreateEntityTable,
82
82
  } from "@cosmicdrift/kumiko-framework/stack";
83
83
  import { bankingFlow, type LedgerClient } from "../usage";
84
84
 
85
- const admin = createTestUser({ roles: ["TenantAdmin"] });
85
+ // Each test runs in its own tenant, so rows from other tests never show up.
86
+ let admin = createTestUser({ roles: ["TenantAdmin"], tenantId: testTenantId(1) });
87
+ let nextTenant = 1;
86
88
  const ledger = createLedgerFeature();
87
89
 
88
90
  let stack: TestStack;
@@ -104,10 +106,9 @@ afterAll(async () => {
104
106
  await stack.cleanup();
105
107
  });
106
108
 
107
- beforeEach(async () => {
108
- await asRawClient(stack.db).unsafe("DELETE FROM kumiko_events");
109
- await asRawClient(stack.db).unsafe("DELETE FROM read_ledger_accounts");
110
- await asRawClient(stack.db).unsafe("DELETE FROM read_ledger_transactions");
109
+ beforeEach(() => {
110
+ nextTenant += 1;
111
+ admin = createTestUser({ roles: ["TenantAdmin"], tenantId: testTenantId(nextTenant) });
111
112
  });
112
113
 
113
114
  describe("ledger-banking recipe — transfer + derived balance", () => {
@@ -81,17 +81,19 @@ import {
81
81
  createLedgerFeature,
82
82
  transactionEntity,
83
83
  } from "@cosmicdrift/kumiko-bundled-features/ledger";
84
- import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
85
84
  import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
86
85
  import {
87
86
  createTestUser,
88
87
  setupTestStack,
89
88
  type TestStack,
89
+ testTenantId,
90
90
  unsafeCreateEntityTable,
91
91
  } from "@cosmicdrift/kumiko-framework/stack";
92
92
  import { invoicingFlow, type LedgerClient } from "../usage";
93
93
 
94
- const admin = createTestUser({ roles: ["TenantAdmin"] });
94
+ // Each test runs in its own tenant, so rows from other tests never show up.
95
+ let admin = createTestUser({ roles: ["TenantAdmin"], tenantId: testTenantId(1) });
96
+ let nextTenant = 1;
95
97
  const ledger = createLedgerFeature();
96
98
 
97
99
  let stack: TestStack;
@@ -112,10 +114,9 @@ afterAll(async () => {
112
114
  await stack.cleanup();
113
115
  });
114
116
 
115
- beforeEach(async () => {
116
- await asRawClient(stack.db).unsafe("DELETE FROM kumiko_events");
117
- await asRawClient(stack.db).unsafe("DELETE FROM read_ledger_accounts");
118
- await asRawClient(stack.db).unsafe("DELETE FROM read_ledger_transactions");
117
+ beforeEach(() => {
118
+ nextTenant += 1;
119
+ admin = createTestUser({ roles: ["TenantAdmin"], tenantId: testTenantId(nextTenant) });
119
120
  });
120
121
 
121
122
  describe("ledger-invoicing recipe — accrual + receivable lifecycle", () => {
@@ -95,8 +95,8 @@ imprint" decision.
95
95
 
96
96
  ```bash
97
97
  # In the app workspace:
98
- bun kumiko-schema generate <name> # detects text-block entity → SQL migration
99
- bun kumiko-schema apply # one-time (pre-deploy step in prod)
98
+ bunx kumiko-schema generate <name> # detects text-block entity → SQL migration
99
+ bunx kumiko-schema apply # one-time (pre-deploy step in prod)
100
100
  ```
101
101
 
102
102
  ### 3. Initial seed of the required blocks
@@ -89,8 +89,8 @@ the recipe lists `createConfigFeature()` explicitly (see `src/feature.ts`).
89
89
  ### 2. Create the table
90
90
 
91
91
  ```bash
92
- bun kumiko-schema generate <name> # detects the `page` entity → SQL migration
93
- bun kumiko-schema apply
92
+ bunx kumiko-schema generate <name> # detects the `page` entity → SQL migration
93
+ bunx kumiko-schema apply
94
94
  ```
95
95
 
96
96
  ### 3. Authoring + branding
@@ -121,18 +121,20 @@ import {
121
121
  noteMentionEntity,
122
122
  notesHistoryFeature,
123
123
  } from "@cosmicdrift/kumiko-bundled-features/notes-history";
124
- import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
125
124
  import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
126
125
  import {
127
126
  createTestUser,
128
127
  setupTestStack,
129
128
  type TestStack,
129
+ testTenantId,
130
130
  unsafeCreateEntityTable,
131
131
  } from "@cosmicdrift/kumiko-framework/stack";
132
132
  import { taskEntity, taskFeature } from "../feature";
133
133
  import { type NotesClient, notesFlow } from "../usage";
134
134
 
135
- const admin = createTestUser({ roles: ["TenantAdmin"] });
135
+ // Each test runs in its own tenant, so rows from other tests never show up.
136
+ let admin = createTestUser({ roles: ["TenantAdmin"], tenantId: testTenantId(1) });
137
+ let nextTenant = 1;
136
138
 
137
139
  // Adapt the test stack to the host-facing NotesClient the docs embed uses.
138
140
  const notesClient: NotesClient = {
@@ -154,10 +156,9 @@ afterAll(async () => {
154
156
  await stack.cleanup();
155
157
  });
156
158
 
157
- beforeEach(async () => {
158
- await asRawClient(stack.db).unsafe("DELETE FROM kumiko_events");
159
- await asRawClient(stack.db).unsafe("DELETE FROM read_sample_notes_history_tasks");
160
- await asRawClient(stack.db).unsafe("DELETE FROM read_note_entries");
159
+ beforeEach(() => {
160
+ nextTenant += 1;
161
+ admin = createTestUser({ roles: ["TenantAdmin"], tenantId: testTenantId(nextTenant) });
161
162
  });
162
163
 
163
164
  async function createTask(id: string, title: string) {
@@ -39,7 +39,7 @@ bun test
39
39
  // feature. Each test maps directly to a cell in the design-doc matrix.
40
40
 
41
41
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
42
- import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
42
+ import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
43
43
  import {
44
44
  createTestUser,
45
45
  setupTestStack,
@@ -52,26 +52,33 @@ import { expectErrorIncludes } from "@cosmicdrift/kumiko-framework/testing";
52
52
  import { contractEntity, contractsFeature, teamsFeature } from "../feature";
53
53
 
54
54
  let stack: TestStack;
55
- const tenant = testTenantId(1);
56
-
57
- const admin = { ...TestUsers.admin, tenantId: tenant };
58
- const teamMemberEng = createTestUser({
59
- id: "11111111-0000-4000-8000-000000000001",
60
- tenantId: tenant,
61
- roles: ["TeamMember"],
62
- claims: { "teams:teamId": "eng" },
63
- });
64
- const teamMemberOps = createTestUser({
65
- id: "11111111-0000-4000-8000-000000000002",
66
- tenantId: tenant,
67
- roles: ["TeamMember"],
68
- claims: { "teams:teamId": "ops" },
69
- });
70
- const driverAlice = createTestUser({
71
- id: "22222222-0000-4000-8000-000000000001",
72
- tenantId: tenant,
73
- roles: ["Driver"],
74
- });
55
+
56
+ function usersForTenant(tenant: TenantId) {
57
+ return {
58
+ admin: { ...TestUsers.admin, tenantId: tenant },
59
+ teamMemberEng: createTestUser({
60
+ id: "11111111-0000-4000-8000-000000000001",
61
+ tenantId: tenant,
62
+ roles: ["TeamMember"],
63
+ claims: { "teams:teamId": "eng" },
64
+ }),
65
+ teamMemberOps: createTestUser({
66
+ id: "11111111-0000-4000-8000-000000000002",
67
+ tenantId: tenant,
68
+ roles: ["TeamMember"],
69
+ claims: { "teams:teamId": "ops" },
70
+ }),
71
+ driverAlice: createTestUser({
72
+ id: "22222222-0000-4000-8000-000000000001",
73
+ tenantId: tenant,
74
+ roles: ["Driver"],
75
+ }),
76
+ };
77
+ }
78
+
79
+ // Each test runs in its own tenant, so contract rows from other tests never show up.
80
+ let nextTenant = 1;
81
+ let { admin, teamMemberEng, teamMemberOps, driverAlice } = usersForTenant(testTenantId(nextTenant));
75
82
 
76
83
  beforeAll(async () => {
77
84
  stack = await setupTestStack({ features: [teamsFeature, contractsFeature] });
@@ -87,7 +94,8 @@ let engRowVersion: number;
87
94
  let opsRowId: string;
88
95
 
89
96
  beforeEach(async () => {
90
- await asRawClient(stack.db).unsafe("DELETE FROM read_ownership_contracts");
97
+ nextTenant += 1;
98
+ ({ admin, teamMemberEng, teamMemberOps, driverAlice } = usersForTenant(testTenantId(nextTenant)));
91
99
  const eng = await stack.http.writeOk<{ id: string; data: { version: number } }>(
92
100
  "contracts:write:contract:create",
93
101
  {