@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
package/AGENTS.md CHANGED
@@ -49,7 +49,10 @@ write the table yourself.
49
49
  **Handler.** The only way in. A write handler changes state, a query handler
50
50
  reads it. Both have a qualified name (QN), a Zod payload schema and an access
51
51
  rule. `r.crud(name, entity, options)` registers the entity plus create,
52
- update, delete, restore, list and detail handlers in one call. Turn single
52
+ update, delete, restore, list and detail handlers in one call. Its options
53
+ need an access rule for both sides, `write: { access }` and
54
+ `read: { access }`; `verbAccess: { delete: { roles: ["Admin"] } }`
55
+ overrides a single verb. Without them boot stops. Turn single
53
56
  verbs off with `verbs: { delete: false }` and register them yourself when
54
57
  they need different access or logic. Custom logic goes into
55
58
  `r.writeHandler(qn, schema, fn, { access })` and
@@ -60,7 +63,10 @@ executor from `createEntityExecutor(name, entity)`.
60
63
  `entityList`, `entityEdit`, `projectionList`, `projectionDetail`,
61
64
  `actionForm`, `dashboard` and `custom`. The renderer builds the UI from the
62
65
  declaration. Columns, sections, row actions, tabs, wizard steps and
63
- conditional fields are data, not JSX.
66
+ conditional fields are data, not JSX. A `dashboard` lists `panels`; a
67
+ `stat` panel names a query handler and the `valueField` to show from its
68
+ result, and a `stat-group` panel holds several stats. The query returns one
69
+ flat object with those fields.
64
70
 
65
71
  **Nav.** A sidebar entry: `r.nav({ id, label, screen, parent, order })`.
66
72
  Parents may belong to another feature; refer to them by QN.
@@ -83,6 +89,12 @@ that holds global data calls `r.systemScope()`.
83
89
  Screens and nav entries can narrow visibility further with their own
84
90
  `access`. Anonymous endpoints use the `anonymous` role. Field level and row
85
91
  level rules exist as well (see the table below).
92
+ An `openToAll` write handler whose payload contains personal fields must
93
+ say that every tenant member may write them:
94
+ `{ openToAll: { reason: "...", personalData: "tenant-members" } }`. The
95
+ alternatives are roles, or binding each row to its caller. Boot names all
96
+ three. On query handlers `personalData` is only valid as
97
+ `access: { roles: ["anonymous"], personalData: "public-intake" }`.
86
98
 
87
99
  **Personal data.** Every text field says whether it holds personal data;
88
100
  the field types reject a text field without that stance.
@@ -90,7 +102,10 @@ the field types reject a text field without that stance.
90
102
  business data. For personal data name the subject, `personal: "self"`,
91
103
  `personal: { of: "<ownerField>" }` or `personal: "tenant"`, plus
92
104
  `find: "exact" | "fuzzy" | "none" | "secret"`. A plain foreign key to a user
93
- is `personal: "ref"`. The framework encrypts personal fields per subject and
105
+ is `personal: "ref"`. `personal: "self"` is for the row that is the user.
106
+ When the record itself is the person, such as a contact or a customer that
107
+ is not a user, use `personal: { of: "id" }`; the entity then needs a uuid
108
+ `id`. The framework encrypts personal fields per subject and
94
109
  can erase them on request.
95
110
 
96
111
  **Client.** The web side of a feature is a `ClientFeatureDefinition` in the
@@ -145,9 +160,15 @@ feature and test. Start from them.
145
160
  | Unit tests | `bun run test` |
146
161
  | Integration tests (needs `docker compose up -d`) | `bun run test:integration` |
147
162
  | Browser tests | `bun run e2e` |
148
- | New migration after an entity change | `bun run schema:generate` |
163
+ | New migration after an entity change or a newly mounted bundled feature | `bun run schema:generate <name>` |
164
+ | Check migrations against the features, no database needed | `bun kumiko-schema validate` |
149
165
  | Apply migrations | `bun run schema:apply` |
150
166
 
167
+ `<name>` is required and becomes part of the file name
168
+ (`schema:generate add-invoice`). Mounting a bundled feature in
169
+ `APP_FEATURES` adds its tables, so it needs a migration like your own
170
+ entities do.
171
+
151
172
  ## Rules: never this, do that
152
173
 
153
174
  These rules are enforced by boot validation, guards or review. Breaking them
@@ -164,6 +185,11 @@ produces code that compiles and then fails in production.
164
185
  - Never hand-write SQL for reads that an executor `list` or `detail` covers.
165
186
  Use `r.queryHandler` with the executor, or a projection with
166
187
  `ctx.queryProjection`.
188
+ - Never filter or count the rows of one `list` page in code. `list` returns
189
+ one page (default 50, `limit` at most 200). Pass
190
+ `filter: { field, op, value }` (`eq`, `ne`, `lt`, `gt`, `lte`, `gte`, `in`)
191
+ so the database filters, and `totalCount: true` to get `total` over all
192
+ matching rows; `limit: 1, totalCount: true` is a count query.
167
193
  - Never change an event schema in place once it has been emitted. Version it
168
194
  (see `agents/examples/event-sourcing.md`).
169
195
  - Never read or write another tenant's data. `ctx.db` is tenant scoped on
@@ -237,6 +263,8 @@ as bundled features.
237
263
  | --- | --- | --- |
238
264
  | A CRUD entity with list and edit | `createEntity` + `r.crud` + `entityList` / `entityEdit` screens | `agents/examples/basic-entity.md` |
239
265
  | Business logic beyond CRUD | `r.writeHandler` / `r.queryHandler` with `createEntityExecutor` | `agents/examples/custom-handlers.md` |
266
+ | Filtered lists and counts | Executor `list` with `filter` and `totalCount` | `agents/examples/custom-handlers.md` |
267
+ | Key figures on a dashboard | `dashboard` screen with `stat` / `stat-group` panels and a query handler | `agents/examples/dashboard-stats.md` |
240
268
  | Relations between entities | `r.relation(entity, name, { type, target, foreignKey, onDelete })` | `agents/examples/relations.md` |
241
269
  | Structured sub-objects in a field | Embedded fields | `agents/examples/embedded.md` |
242
270
  | List, edit, custom screens and navs | `r.screen`, `r.nav` | `agents/examples/screens-nav.md` |
@@ -283,7 +311,10 @@ source file with its path. `src/feature.ts` holds the feature;
283
311
  the framework repo set up their stack with `setupTestStack` from
284
312
  `@cosmicdrift/kumiko-framework/stack` and create tables in the test. In an
285
313
  app, `setupAppTestStack` and `seedTenant` do that for you; keep the
286
- assertions, take the setup from your scaffolded integration test.
314
+ assertions, take the setup from your scaffolded integration test. Table
315
+ setup in sample tests (`unsafeCreateEntityTable`, `createEventsTable`) and
316
+ any raw `DELETE FROM` cleanup are framework internals. Never copy them;
317
+ isolate tests with one tenant each.
287
318
 
288
319
  Some samples import from `@app/define`. That alias points at the generated
289
320
  `.kumiko/define.ts`, which re-exports the engine with event types narrowed to
@@ -39,7 +39,6 @@ bun test
39
39
 
40
40
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
41
41
  import { setTenantCookie } from "@cosmicdrift/kumiko-framework/api";
42
- import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
43
42
  import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
44
43
  import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
45
44
  import {
@@ -48,7 +47,7 @@ import {
48
47
  TestUsers,
49
48
  unsafeCreateEntityTable,
50
49
  } from "@cosmicdrift/kumiko-framework/stack";
51
- import { multiTenantShopFeature, productEntity, productTable } from "../feature";
50
+ import { multiTenantShopFeature, productEntity } from "../feature";
52
51
  import { createSubdomainResolver, extractSubdomain } from "../subdomain-resolver";
53
52
 
54
53
  const ACME_TENANT_ID = "00000000-0000-4000-8000-000000000001" as TenantId;
@@ -61,6 +60,9 @@ const KNOWN_TENANTS: Record<string, TenantId> = {
61
60
  globex: GLOBEX_TENANT_ID,
62
61
  };
63
62
 
63
+ // Unique value: the acme tenant is fixed, so the row is told apart by name, not by reset.
64
+ const ACME_PRODUCT_NAME = "Acme Anvil (subdomain read)";
65
+
64
66
  let stack: TestStack;
65
67
  let resolver: ReturnType<typeof createSubdomainResolver>;
66
68
  // Counters prove that the cache actually hits — one DB lookup per
@@ -110,8 +112,7 @@ afterAll(async () => {
110
112
  await stack?.cleanup();
111
113
  });
112
114
 
113
- beforeEach(async () => {
114
- await asRawClient(stack.db).unsafe(`DELETE FROM "${productTable.tableName}"`);
115
+ beforeEach(() => {
115
116
  resolver.invalidateAll();
116
117
  lookupCount = 0;
117
118
  existsCount = 0;
@@ -123,7 +124,7 @@ describe("subdomain resolution", () => {
123
124
  // visitors who land on acme.shop.com.
124
125
  await stack.http.writeOk(
125
126
  "mtshop:write:product:create",
126
- { name: "Acme Anvil" },
127
+ { name: ACME_PRODUCT_NAME },
127
128
  { ...TestUsers.admin, tenantId: ACME_TENANT_ID },
128
129
  );
129
130
 
@@ -136,7 +137,9 @@ describe("subdomain resolution", () => {
136
137
 
137
138
  expect(res.status).toBe(200);
138
139
  const body = (await res.json()) as { data: Array<{ name: string }> };
139
- expect(body.data.map((p) => p.name)).toEqual(["Acme Anvil"]);
140
+ expect(body.data.map((p) => p.name).filter((name) => name === ACME_PRODUCT_NAME)).toEqual([
141
+ ACME_PRODUCT_NAME,
142
+ ]);
140
143
  });
141
144
 
142
145
  test("unknown subdomain → 400 tenant_required (resolver returned null)", async () => {
@@ -41,8 +41,8 @@ bun test
41
41
  // that parses the host (acme.shop.com → tenantId) plus a tenantExists
42
42
  // callback that confirms the tenant is real (against DB or cache).
43
43
 
44
- import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
45
- import { asRawClient, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
44
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
45
+ import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
46
46
  import { ANONYMOUS_USER_ID, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
47
47
  import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
48
48
  import {
@@ -56,11 +56,14 @@ import {
56
56
  guestOrderEntity,
57
57
  guestOrderTable,
58
58
  productEntity,
59
- productTable,
60
59
  } from "../feature";
61
60
 
62
61
  const TENANT_ID = "00000000-0000-4000-8000-000000000001" as TenantId;
63
62
 
63
+ // Unique per test: the tenant is fixed, so rows are told apart by value, not by reset.
64
+ const PRODUCT_NAME = "Espresso Beans (anonymous read)";
65
+ const GUEST_EMAIL = "guest-checkout@example.com";
66
+
64
67
  let stack: TestStack;
65
68
 
66
69
  beforeAll(async () => {
@@ -79,16 +82,11 @@ afterAll(async () => {
79
82
  await stack?.cleanup();
80
83
  });
81
84
 
82
- beforeEach(async () => {
83
- await asRawClient(stack.db).unsafe(`DELETE FROM "${productTable.tableName}"`);
84
- await asRawClient(stack.db).unsafe(`DELETE FROM "${guestOrderTable.tableName}"`);
85
- });
86
-
87
85
  describe("public read: anonymous + authenticated share the listing", () => {
88
86
  test("anonymous caller sees products without any auth headers", async () => {
89
87
  await stack.http.writeOk(
90
88
  "shop:write:product:create",
91
- { name: "Espresso Beans", priceCents: "1500" },
89
+ { name: PRODUCT_NAME, priceCents: "1500" },
92
90
  TestUsers.admin,
93
91
  );
94
92
 
@@ -99,17 +97,11 @@ describe("public read: anonymous + authenticated share the listing", () => {
99
97
 
100
98
  expect(res.status).toBe(200);
101
99
  const body = (await res.json()) as { data: Array<{ name: string }> };
102
- expect(body.data).toHaveLength(1);
103
- expect(body.data[0]?.name).toBe("Espresso Beans");
100
+ const mine = body.data.filter((product) => product.name === PRODUCT_NAME);
101
+ expect(mine).toHaveLength(1);
104
102
  });
105
103
 
106
104
  test("authenticated user reaches the same handler via JWT", async () => {
107
- await stack.http.writeOk(
108
- "shop:write:product:create",
109
- { name: "Espresso Beans", priceCents: "1500" },
110
- TestUsers.admin,
111
- );
112
-
113
105
  const res = await stack.http.query("shop:query:product:list", {}, TestUsers.user);
114
106
  expect(res.status).toBe(200);
115
107
  });
@@ -117,26 +109,22 @@ describe("public read: anonymous + authenticated share the listing", () => {
117
109
 
118
110
  describe("guest checkout: anonymous write lands with placedBy=anonymous", () => {
119
111
  test("anonymous order is stored and tagged with the anonymous user-id", async () => {
120
- await stack.http.writeOk(
121
- "shop:write:product:create",
122
- { name: "Espresso Beans", priceCents: "1500" },
123
- TestUsers.admin,
124
- );
125
-
126
112
  const res = await stack.http.raw("POST", "/api/write", {
127
113
  type: "shop:write:guest-order:place",
128
114
  payload: {
129
115
  productId: "00000000-0000-4000-8000-000000000099",
130
- email: "guest@example.com",
116
+ email: GUEST_EMAIL,
131
117
  },
132
118
  });
133
119
 
134
120
  expect(res.status).toBe(200);
135
121
 
136
- const orders = await selectMany(stack.db, guestOrderTable);
122
+ const orders = (await selectMany(stack.db, guestOrderTable)).filter(
123
+ (order) => order.email === GUEST_EMAIL,
124
+ );
137
125
  expect(orders).toHaveLength(1);
138
126
  expect(orders[0]?.placedBy).toBe(ANONYMOUS_USER_ID);
139
- expect(orders[0]?.email).toBe("guest@example.com");
127
+ expect(orders[0]?.email).toBe(GUEST_EMAIL);
140
128
  });
141
129
  });
142
130
 
@@ -18,7 +18,7 @@ The production wiring shape: mount `createAuthMfaFeature()` next to `createAuthE
18
18
 
19
19
  1. `createAuthMfaFeature({ setupTokenSecret, challengeTokenSecret, issuer })` builds the feature; `mfaStatusCheckerFromFeature(mfaFeature)` reads its login-status callback back off.
20
20
  2. That callback goes into `createAuthEmailPasswordFeature({ mfaStatusChecker: ... })` — `login.write.ts` calls it after the password check to decide whether to mint a JWT or a challenge.
21
- 3. `buildServer({ auth: { mfaVerifyHandler: AuthMfaHandlers.verify } })` mounts `POST /auth/mfa/verify`, a framework route (not a dispatcher write-handler) with its own rate limiter, since it runs before a JWT exists.
21
+ 3. `buildServer({ auth: { mfaVerifyHandler: AuthMfaHandlers.verify, switchTenantMfaGateHandler: AuthHandlers.switchTenantMfaGate } })` (the gate is required with `mfaVerifyHandler`, so a tenant switch asks for the second factor too) mounts `POST /auth/mfa/verify`, a framework route (not a dispatcher write-handler) with its own rate limiter, since it runs before a JWT exists.
22
22
  4. If `sessions` is mounted too, `bindMfaRevokeAllOtherSessionsFromFeature(mfaFeature)?.(sessionCallbacks.sessionRevokeAllOthers)` wires "enable/disable/regenerate signs out every other session."
23
23
 
24
24
  ## What to copy into your app
@@ -51,6 +51,7 @@ const server = buildServer({
51
51
  membershipQuery: "tenant:query:memberships",
52
52
  loginHandler: "auth-email-password:[REDACTED:API key param]",
53
53
  mfaVerifyHandler: AuthMfaHandlers.verify,
54
+ switchTenantMfaGateHandler: AuthHandlers.switchTenantMfaGate,
54
55
  sessionCreator: sessionCallbacks.sessionCreator,
55
56
  sessionRevoker: sessionCallbacks.sessionRevoker,
56
57
  sessionChecker: sessionCallbacks.sessionChecker,
@@ -86,7 +87,7 @@ Apps built via `runDevApp`/`runProdApp` (the dev-server bootstrap used by `creat
86
87
  // the resulting app behaves like this test asserts: login is two-step once
87
88
  // MFA is enabled, and a valid TOTP code completes it.
88
89
 
89
- import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
90
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
90
91
  import {
91
92
  AuthHandlers,
92
93
  createAuthEmailPasswordFeature,
@@ -106,9 +107,7 @@ import {
106
107
  createUserFeature,
107
108
  UserHandlers,
108
109
  userEntity,
109
- userTable,
110
110
  } from "@cosmicdrift/kumiko-bundled-features/user";
111
- import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
112
111
  import { configureEntityFieldEncryption } from "@cosmicdrift/kumiko-framework/db";
113
112
  import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
114
113
  import {
@@ -158,6 +157,7 @@ beforeAll(async () => {
158
157
  membershipQuery: "tenant:query:memberships",
159
158
  loginHandler: AuthHandlers.login,
160
159
  mfaVerifyHandler: AuthMfaHandlers.verify,
160
+ switchTenantMfaGateHandler: AuthHandlers.switchTenantMfaGate,
161
161
  },
162
162
  });
163
163
 
@@ -172,11 +172,6 @@ afterAll(async () => {
172
172
  configureEntityFieldEncryption(undefined);
173
173
  });
174
174
 
175
- beforeEach(async () => {
176
- await asRawClient(stack.db).unsafe(`DELETE FROM "${userTable.tableName}"`);
177
- await asRawClient(stack.db).unsafe(`DELETE FROM "${tenantMembershipsTable.tableName}"`);
178
- });
179
-
180
175
  describe("auth-mfa sample wiring", () => {
181
176
  test("login without MFA enrolled returns a JWT directly", async () => {
182
177
  const hash = await hashPassword("super-long-password");
@@ -287,6 +282,7 @@ describe("auth-mfa sample wiring", () => {
287
282
  // membershipQuery: "tenant:query:memberships",
288
283
  // loginHandler: "auth-email-password:[REDACTED:API key param]",
289
284
  // mfaVerifyHandler: AuthMfaHandlers.verify,
285
+ // switchTenantMfaGateHandler: AuthHandlers.switchTenantMfaGate,
290
286
  // sessionCreator: sessionCallbacks.sessionCreator,
291
287
  // sessionRevoker: sessionCallbacks.sessionRevoker,
292
288
  // sessionChecker: sessionCallbacks.sessionChecker,
@@ -113,17 +113,19 @@ import {
113
113
  createCustomFieldsFeature,
114
114
  fieldDefinitionEntity,
115
115
  } from "@cosmicdrift/kumiko-bundled-features/custom-fields";
116
- import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
117
116
  import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
118
117
  import {
119
118
  createTestUser,
120
119
  setupTestStack,
121
120
  type TestStack,
121
+ testTenantId,
122
122
  unsafeCreateEntityTable,
123
123
  } from "@cosmicdrift/kumiko-framework/stack";
124
124
  import { propertyEntity, propertyFeature } from "../feature";
125
125
 
126
- const admin = createTestUser({ roles: ["TenantAdmin"] });
126
+ // Each test runs in its own tenant, so rows from other tests never show up.
127
+ let admin = createTestUser({ roles: ["TenantAdmin"], tenantId: testTenantId(1) });
128
+ let nextTenant = 1;
127
129
  const customFields = createCustomFieldsFeature();
128
130
 
129
131
  let stack: TestStack;
@@ -139,10 +141,9 @@ afterAll(async () => {
139
141
  await stack.cleanup();
140
142
  });
141
143
 
142
- beforeEach(async () => {
143
- await asRawClient(stack.db).unsafe(`DELETE FROM kumiko_events`);
144
- await asRawClient(stack.db).unsafe(`DELETE FROM read_sample_cf_properties`);
145
- await asRawClient(stack.db).unsafe(`DELETE FROM read_custom_field_definitions`);
144
+ beforeEach(() => {
145
+ nextTenant += 1;
146
+ admin = createTestUser({ roles: ["TenantAdmin"], tenantId: testTenantId(nextTenant) });
146
147
  });
147
148
 
148
149
  async function defineField(fieldKey: string, type = "text") {
@@ -30,9 +30,10 @@ logic is yours.
30
30
  - **`skipOptimisticLock` for last-writer-wins** — `reset` deliberately
31
31
  bypasses the version check because the operation is admin-driven and
32
32
  resetting twice is semantically the same as resetting once.
33
- - **Custom queries** — `counter:active` filters the projection in
34
- TypeScript after the executor's list returns. Useful when the filter is
35
- computed (above-threshold) rather than indexed.
33
+ - **Custom queries** — `counter:active` passes a server-side `filter`
34
+ (`eq`/`ne`/`lt`/`gt`/`lte`/`gte`/`in`) and `totalCount` to the executor's
35
+ list. Never filter a page in TypeScript: list is paginated, so rows after
36
+ the first page would be missed.
36
37
  - **`failNotFound` helper** — typed error response for the 404 case
37
38
  without a hand-built `KumikoError` subclass.
38
39
 
@@ -69,6 +70,7 @@ import {
69
70
  setupTestStack,
70
71
  type TestStack,
71
72
  TestUsers,
73
+ testTenantId,
72
74
  unsafeCreateEntityTable,
73
75
  } from "@cosmicdrift/kumiko-framework/stack";
74
76
  import { expectErrorIncludes } from "@cosmicdrift/kumiko-framework/testing";
@@ -231,44 +233,80 @@ describe("custom write handler: reset", () => {
231
233
  });
232
234
 
233
235
  describe("custom query handler: active counters", () => {
236
+ // Own tenants: the total assertions would otherwise count counters created by other tests.
237
+ const activeAdmin = createTestUser({ roles: ["Admin"], tenantId: testTenantId(11) });
238
+ const activeUser = createTestUser({ id: 2, roles: ["User"], tenantId: testTenantId(11) });
239
+ const otherAdmin = createTestUser({ roles: ["Admin"], tenantId: testTenantId(12) });
240
+
234
241
  test("filters counters by minimum count", async () => {
235
242
  const c1 = await stack.http.writeOk<SaveContext>(
236
243
  "counters:write:counter:create",
237
244
  { name: "active-a" },
238
- admin,
245
+ activeAdmin,
239
246
  );
240
247
  const c2 = await stack.http.writeOk<SaveContext>(
241
248
  "counters:write:counter:create",
242
249
  { name: "active-b" },
243
- admin,
250
+ activeAdmin,
244
251
  );
245
252
  await stack.http.writeOk<SaveContext>(
246
253
  "counters:write:counter:create",
247
254
  { name: "inactive" },
248
- admin,
255
+ activeAdmin,
249
256
  );
250
257
 
251
258
  await stack.http.writeOk<SaveContext>(
252
259
  "counters:write:counter:increment",
253
260
  { id: c1.id, amount: 10 },
254
- user,
261
+ activeUser,
255
262
  );
256
263
  await stack.http.writeOk<SaveContext>(
257
264
  "counters:write:counter:increment",
258
265
  { id: c2.id, amount: 5 },
259
- user,
266
+ activeUser,
260
267
  );
261
268
 
262
- const result = await stack.http.queryOk<{ rows: Record<string, unknown>[] }>(
263
- "counters:query:counter:active",
264
- { minCount: 5 },
265
- admin,
266
- );
269
+ const result = await stack.http.queryOk<{
270
+ rows: Record<string, unknown>[];
271
+ nextCursor: string | null;
272
+ total?: number;
273
+ }>("counters:query:counter:active", { minCount: 5 }, activeAdmin);
267
274
 
268
275
  const names = result.rows.map((r) => r["name"]);
269
276
  expect(names).toContain("active-a");
270
277
  expect(names).toContain("active-b");
271
278
  expect(names).not.toContain("inactive");
279
+ expect(result.total).toBe(2);
280
+ });
281
+
282
+ test("total and nextCursor describe the full filtered set", async () => {
283
+ for (const name of ["total-a", "total-b", "total-c"]) {
284
+ const created = await stack.http.writeOk<SaveContext>(
285
+ "counters:write:counter:create",
286
+ { name },
287
+ otherAdmin,
288
+ );
289
+ await stack.http.writeOk<SaveContext>(
290
+ "counters:write:counter:increment",
291
+ { id: created.id, amount: 1 },
292
+ otherAdmin,
293
+ );
294
+ }
295
+ await stack.http.writeOk<SaveContext>(
296
+ "counters:write:counter:create",
297
+ { name: "total-untouched" },
298
+ otherAdmin,
299
+ );
300
+
301
+ const result = await stack.http.queryOk<{
302
+ rows: Record<string, unknown>[];
303
+ nextCursor: string | null;
304
+ total?: number;
305
+ }>("counters:query:counter:active", { minCount: 1 }, otherAdmin);
306
+
307
+ expect(result.rows).toHaveLength(3);
308
+ expect(result.total).toBe(3);
309
+ expect(result.nextCursor).toBeNull();
272
310
  });
273
311
  });
274
312
  ```
@@ -277,7 +315,7 @@ describe("custom query handler: active counters", () => {
277
315
 
278
316
  ```ts
279
317
  // Custom Handlers Sample
280
- // Shows: writeHandler with business logic, queryHandler with custom filtering,
318
+ // Shows: writeHandler with business logic, queryHandler with a server-side filter,
281
319
  // handler that modifies payload before DB write.
282
320
 
283
321
  import {
@@ -290,6 +328,9 @@ import {
290
328
  import { failNotFound } from "@cosmicdrift/kumiko-framework/errors";
291
329
  import { z } from "zod";
292
330
 
331
+ // detail() returns an untyped row; parse the two fields the increment needs once.
332
+ const counterVersionRowSchema = z.object({ count: z.number(), version: z.number() });
333
+
293
334
  export const counterEntity = createEntity({
294
335
  table: "read_sample_counters",
295
336
  fields: {
@@ -326,13 +367,14 @@ export const counterFeature = defineFeature("counters", (r) => {
326
367
  return failNotFound("counter", event.payload.id);
327
368
  }
328
369
 
329
- const newCount = (current["count"] as number) + event.payload.amount;
370
+ const { count, version } = counterVersionRowSchema.parse(current);
371
+ const newCount = count + event.payload.amount;
330
372
  // Read-modify-write on counter: use the version we just read so two
331
373
  // concurrent increments don't clobber each other's changes.
332
374
  return counterExecutor.update(
333
375
  {
334
376
  id: event.payload.id,
335
- version: current["version"] as number,
377
+ version,
336
378
  changes: { count: newCount, lastIncrementedBy: `user:${event.user.id}` },
337
379
  },
338
380
  event.user,
@@ -358,17 +400,22 @@ export const counterFeature = defineFeature("counters", (r) => {
358
400
  { access: { roles: ["Admin"] } },
359
401
  );
360
402
 
361
- // Custom query: only counters above a threshold
403
+ // Custom query: only counters above a threshold. list() returns one page
404
+ // (default 50, max 200, MAX_LIST_LIMIT), so a JS filter would miss rows after
405
+ // page 1; filter in the query instead. totalCount counts over the whole WHERE.
362
406
  r.queryHandler(
363
407
  "counter:active",
364
- z.object({ minCount: z.number().default(1) }),
365
- async (query, ctx) => {
366
- const all = await counterExecutor.list({}, query.user, ctx.db);
367
- return {
368
- ...all,
369
- rows: all.rows.filter((r) => (r["count"] as number) >= query.payload.minCount),
370
- };
371
- },
408
+ z.object({ minCount: z.number().int().default(1), cursor: z.string().optional() }),
409
+ async (query, ctx) =>
410
+ counterExecutor.list(
411
+ {
412
+ filter: { field: "count", op: "gte", value: query.payload.minCount },
413
+ totalCount: true,
414
+ cursor: query.payload.cursor,
415
+ },
416
+ query.user,
417
+ ctx.db,
418
+ ),
372
419
  {
373
420
  access: {
374
421
  openToAll: {