@cosmicdrift/kumiko-framework 0.365.0 → 0.366.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/AGENTS.md +2 -2
  2. package/agents/examples/access-control.md +32 -30
  3. package/agents/examples/anonymous-access-multitenant.md +14 -16
  4. package/agents/examples/anonymous-access.md +36 -43
  5. package/agents/examples/auth-foundation-providers.md +1 -1
  6. package/agents/examples/basic-entity.md +1 -1
  7. package/agents/examples/boot-check.md +3 -3
  8. package/agents/examples/chat-channels.md +1 -1
  9. package/agents/examples/cross-feature-events.md +7 -7
  10. package/agents/examples/currencies-global.md +3 -3
  11. package/agents/examples/currencies-per-tenant.md +2 -2
  12. package/agents/examples/custom-fields-basic.md +4 -3
  13. package/agents/examples/custom-handlers.md +40 -44
  14. package/agents/examples/dashboard-stats.md +6 -6
  15. package/agents/examples/delivery-notifications.md +9 -9
  16. package/agents/examples/designer-demo.md +2 -1
  17. package/agents/examples/embedded-entity-form.md +13 -13
  18. package/agents/examples/embedded.md +1 -1
  19. package/agents/examples/encrypted-tenant-config.md +1 -1
  20. package/agents/examples/error-contract.md +25 -25
  21. package/agents/examples/event-sourcing.md +96 -76
  22. package/agents/examples/feature-toggles.md +29 -21
  23. package/agents/examples/files-post-processing.md +22 -26
  24. package/agents/examples/idempotency.md +17 -19
  25. package/agents/examples/inbound-mail-triage.md +1 -1
  26. package/agents/examples/lane-routing.md +10 -12
  27. package/agents/examples/legal-pages.md +1 -1
  28. package/agents/examples/lifecycle-hooks.md +59 -39
  29. package/agents/examples/managed-config.md +9 -9
  30. package/agents/examples/managed-pages.md +1 -1
  31. package/agents/examples/notes-history-basic.md +3 -3
  32. package/agents/examples/pdf-audit-export.md +7 -7
  33. package/agents/examples/pipeline-basics.md +8 -8
  34. package/agents/examples/public-share-token.md +1 -1
  35. package/agents/examples/rate-limiting.md +7 -9
  36. package/agents/examples/record-detail-layout.md +26 -20
  37. package/agents/examples/reference-data.md +1 -1
  38. package/agents/examples/relations.md +2 -2
  39. package/agents/examples/screens-nav.md +2 -2
  40. package/agents/examples/search.md +17 -19
  41. package/agents/examples/security-baseline.md +1 -1
  42. package/agents/examples/state-machine.md +1 -1
  43. package/agents/examples/tags-basic.md +3 -3
  44. package/agents/examples/tenant-settings.md +2 -2
  45. package/agents/examples/timezones.md +12 -12
  46. package/agents/examples/user-data-rights.md +3 -3
  47. package/agents/examples/user-profile.md +1 -1
  48. package/agents/examples/webhook-step.md +12 -2
  49. package/agents/examples/writeform-section.md +21 -15
  50. package/agents/features.md +1 -1
  51. package/agents/widgets.md +5 -0
  52. package/dist/api/server.js +20 -4
  53. package/dist/bun-db/query.js +16 -1
  54. package/dist/crypto/in-memory-kms-adapter.js +4 -7
  55. package/dist/crypto/pg-kms-adapter.js +10 -6
  56. package/dist/db/entity-table-meta.js +4 -4
  57. package/dist/db/event-store-executor-write.js +16 -12
  58. package/dist/engine/boot-validator/api-ext.js +2 -2
  59. package/dist/engine/boot-validator/config-deps.js +1 -1
  60. package/dist/engine/boot-validator/handler-field-references.js +3 -10
  61. package/dist/engine/boot-validator/screens.js +1 -1
  62. package/dist/engine/build-app-schema.js +3 -6
  63. package/dist/engine/define-feature.js +5 -5
  64. package/dist/engine/entity-handlers.d.ts +2 -2
  65. package/dist/engine/entity-handlers.js +5 -5
  66. package/dist/engine/factories.d.ts +11 -11
  67. package/dist/engine/factories.js +11 -11
  68. package/dist/engine/feature-ast/extractors/round2.js +16 -2
  69. package/dist/engine/feature-ast/render.js +4 -7
  70. package/dist/engine/feature-config-events-jobs.d.ts +2 -0
  71. package/dist/engine/feature-config-events-jobs.js +3 -0
  72. package/dist/engine/feature-entity-handlers.d.ts +4 -23
  73. package/dist/engine/feature-entity-handlers.js +77 -100
  74. package/dist/engine/feature-ui-extensions.d.ts +9 -14
  75. package/dist/engine/feature-ui-extensions.js +22 -14
  76. package/dist/engine/handler-field-references.d.ts +25 -0
  77. package/dist/engine/handler-field-references.js +80 -6
  78. package/dist/engine/index.d.ts +3 -3
  79. package/dist/engine/index.js +2 -2
  80. package/dist/engine/object-form.d.ts +2 -0
  81. package/dist/engine/object-form.js +13 -10
  82. package/dist/engine/registry-state.js +2 -2
  83. package/dist/engine/registry-validate.d.ts +2 -0
  84. package/dist/engine/registry-validate.js +42 -2
  85. package/dist/engine/registry.js +3 -1
  86. package/dist/engine/strip-disabled-features-from-app-schema.d.ts +6 -0
  87. package/dist/engine/strip-disabled-features-from-app-schema.js +89 -0
  88. package/dist/engine/types/index.d.ts +2 -2
  89. package/dist/files/feature.js +1 -1
  90. package/dist/jobs/job-runner.d.ts +1 -0
  91. package/dist/jobs/job-runner.js +83 -17
  92. package/dist/jobs/max-per-tenant.d.ts +8 -0
  93. package/dist/jobs/max-per-tenant.js +14 -0
  94. package/dist/pipeline/dispatch-query.js +1 -1
  95. package/dist/pipeline/dispatch-shared.d.ts +2 -1
  96. package/dist/pipeline/dispatch-shared.js +3 -6
  97. package/dist/pipeline/index.d.ts +2 -0
  98. package/dist/pipeline/index.js +1 -0
  99. package/dist/pipeline/multi-stream-apply-context.d.ts +1 -0
  100. package/dist/pipeline/multi-stream-apply-context.js +1 -0
  101. package/dist/pipeline/tenant-timezone-config-key.d.ts +1 -0
  102. package/dist/pipeline/tenant-timezone-config-key.js +6 -0
  103. package/dist/testing/index.d.ts +1 -0
  104. package/dist/testing/index.js +1 -0
  105. package/dist/testing/manual-clock.d.ts +14 -0
  106. package/dist/testing/manual-clock.js +12 -0
  107. package/dist/time/index.d.ts +1 -1
  108. package/dist/time/tz-context.d.ts +1 -1
  109. package/dist/time/tz-context.js +9 -4
  110. package/dist/upgrade-cli.js +13 -2
  111. package/package.json +4 -4
  112. package/src/changes.json +58 -0
  113. package/src/scripts/codemod/README.md +23 -0
  114. package/src/scripts/codemod/object-form.ts +709 -0
@@ -567,7 +567,7 @@ import type { InvoiceStatus } from "./reducer";
567
567
  export { invoiceEntity } from "./entities/invoice";
568
568
 
569
569
  export const stateMachineFeature = defineFeature("billing", (r) => {
570
- r.entity("invoice", invoiceEntity);
570
+ r.entity({ name: "invoice", ...invoiceEntity });
571
571
 
572
572
  // Domain events — one per intent. Names live in events.ts so the reducer
573
573
  // and the projection stay in sync. The auto "invoice.created" event from
@@ -85,7 +85,7 @@ See the [tags feature reference](/en/bundled-features/tags/) for the full set.
85
85
  tags → core bundle: tag + tag-assignment entities,
86
86
  create/assign/remove handlers, list queries
87
87
  note-management → our feature: a plain `note` entity. Declares
88
- r.requires("tags") only so the bundle is mounted —
88
+ r.requires({ features: ["tags"] }) only so the bundle is mounted —
89
89
  the note itself is completely tag-agnostic.
90
90
  ```
91
91
 
@@ -321,9 +321,9 @@ export const noteFeature = defineFeature("note-management", (r) => {
321
321
  // tags is non-optional for this recipe: the demo tags notes. The note
322
322
  // feature itself stays completely tag-agnostic — it only declares the
323
323
  // dependency so the bundle is mounted.
324
- r.requires("tags");
324
+ r.requires({ features: ["tags"] });
325
325
 
326
- r.entity("note", noteEntity);
326
+ r.entity({ name: "note", ...noteEntity });
327
327
 
328
328
  r.writeHandler({
329
329
  name: "note:create",
@@ -35,7 +35,7 @@ export const invoiceEntity = createEntity({
35
35
  });
36
36
 
37
37
  export const invoiceFeature = defineFeature("invoice", (r) => {
38
- r.entity("invoice", invoiceEntity);
38
+ r.entity({ name: "invoice", ...invoiceEntity });
39
39
  r.writeHandler(
40
40
  defineCreateWithTenantDefaults("invoice", invoiceEntity, {
41
41
  access: { roles: ["Admin"] },
@@ -263,7 +263,7 @@ export const invoiceEntity = createEntity({
263
263
  const ACCESS = { roles: ["Admin"] } as const;
264
264
 
265
265
  export const invoiceFeature = defineFeature("invoice", (r) => {
266
- r.entity("invoice", invoiceEntity);
266
+ r.entity({ name: "invoice", ...invoiceEntity });
267
267
  r.writeHandler(
268
268
  defineCreateWithTenantDefaults("invoice", invoiceEntity, {
269
269
  access: ACCESS,
@@ -336,12 +336,12 @@ export const timezonesFeature = defineFeature("timezones", (r) => {
336
336
  });
337
337
  r.writeHandler(defineEntityDeleteHandler("delivery", deliveryEntity, adminWrite));
338
338
 
339
- r.queryHandler(
340
- "day-window",
341
- z.object({
339
+ r.queryHandler({
340
+ name: "day-window",
341
+ schema: z.object({
342
342
  zone: z.string().refine(isValidIanaTimeZone, { message: "Invalid IANA timezone" }),
343
343
  }),
344
- async (query, ctx) => {
344
+ handler: async (query, ctx) => {
345
345
  const { start, end } = ctx.tz.todayRange(query.payload.zone);
346
346
  return {
347
347
  zone: query.payload.zone,
@@ -350,17 +350,17 @@ export const timezonesFeature = defineFeature("timezones", (r) => {
350
350
  end: end.toString(),
351
351
  };
352
352
  },
353
- openRead,
354
- );
353
+ ...openRead,
354
+ });
355
355
 
356
- r.queryHandler(
357
- "zone-at",
358
- z.object({ latitude: z.number(), longitude: z.number() }),
359
- async (query, ctx) => {
356
+ r.queryHandler({
357
+ name: "zone-at",
358
+ schema: z.object({ latitude: z.number(), longitude: z.number() }),
359
+ handler: async (query, ctx) => {
360
360
  const zone = await ctx.tz.fromCoordinates(query.payload);
361
361
  return { zone };
362
362
  },
363
- openRead,
364
- );
363
+ ...openRead,
364
+ });
365
365
  });
366
366
  ```
@@ -19,8 +19,8 @@ forget logic:
19
19
 
20
20
  ```ts illustration
21
21
  defineFeature("notes", (r) => {
22
- r.requires("user-data-rights");
23
- r.entity("note", noteEntity);
22
+ r.requires({ features: ["user-data-rights"] });
23
+ r.entity({ name: "note", ...noteEntity });
24
24
 
25
25
  r.useExtension(EXT_USER_DATA, "note", {
26
26
  export: async (ctx) => ({
@@ -375,7 +375,7 @@ export const noteEntity = createEntity({
375
375
  export const notesTable = deriveEntityTableMeta("note", noteEntity, { source: "unmanaged" });
376
376
 
377
377
  export const notesFeature = defineFeature("notes", (r) => {
378
- r.requires("user-data-rights");
378
+ r.requires({ features: ["user-data-rights"] });
379
379
  // store_notes is a direct-write store: the forget hook below `updateMany`/
380
380
  // `deleteMany`s rows WITHOUT emitting lifecycle events. r.entity would make
381
381
  // it a rebuildable implicit projection whose replay finds zero note events
@@ -187,7 +187,7 @@ export function createAccountFeature(): FeatureDefinition {
187
187
  "entry — user-profile registers the `profile` screen itself " +
188
188
  "(fw#2312 declarative projectionDetail).",
189
189
  );
190
- r.requires("user-profile");
190
+ r.requires({ features: ["user-profile"] });
191
191
 
192
192
  r.nav({
193
193
  id: "profile",
@@ -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, {
@@ -426,7 +436,7 @@ const incidentExecutor = createEventStoreExecutor(incidentTable, incidentEntity,
426
436
  const INCIDENT_HOOK_URL = "https://hooks.example/incident";
427
437
 
428
438
  export const webhookDemoFeature = defineFeature("webhook-demo", (r) => {
429
- r.entity("incident", incidentEntity);
439
+ r.entity({ name: "incident", ...incidentEntity });
430
440
  r.requires.step("webhook.send");
431
441
  r.requires.step("mail.send");
432
442
  r.requires.step("callFeature");
@@ -127,10 +127,16 @@ describe("writeform-section sample — boot-validator catches author mistakes",
127
127
  test("a writeForm field without its pseudo-entity translation key fails boot", () => {
128
128
  const broken = defineFeature("broken-note-desk", (r) => {
129
129
  r.translations({ keys: { "screen:x.title": { en: "X" } } });
130
- r.queryHandler("foo:detail", z.object({ id: z.string() }), async () => ({}), {
130
+ r.queryHandler({
131
+ name: "foo:detail",
132
+ schema: z.object({ id: z.string() }),
133
+ handler: async () => ({}),
131
134
  access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
132
135
  });
133
- r.writeHandler("save", z.object({}), async () => ({ isSuccess: true as const, data: null }), {
136
+ r.writeHandler({
137
+ name: "save",
138
+ schema: z.object({}),
139
+ handler: async () => ({ isSuccess: true as const, data: null }),
134
140
  access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
135
141
  });
136
142
  r.screen({
@@ -249,19 +255,19 @@ export const noteDeskFeature = defineFeature("note-desk", (r) => {
249
255
  access: open.access,
250
256
  });
251
257
 
252
- r.queryHandler(
253
- "note:summary",
254
- z.object({ id: z.string() }),
255
- async (query) => ({ id: query.payload.id, ...NOTE_DETAIL_FIELDS }),
256
- open,
257
- );
258
-
259
- r.writeHandler(
260
- "note:comment",
261
- commentPayloadSchema,
262
- async () => ({ isSuccess: true as const, data: null }),
263
- open,
264
- );
258
+ r.queryHandler({
259
+ name: "note:summary",
260
+ schema: z.object({ id: z.string() }),
261
+ handler: async (query) => ({ id: query.payload.id, ...NOTE_DETAIL_FIELDS }),
262
+ ...open,
263
+ });
264
+
265
+ r.writeHandler({
266
+ name: "note:comment",
267
+ schema: commentPayloadSchema,
268
+ handler: async () => ({ isSuccess: true as const, data: null }),
269
+ ...open,
270
+ });
265
271
 
266
272
  r.screen({
267
273
  id: "note-detail",
@@ -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
 
package/agents/widgets.md CHANGED
@@ -147,6 +147,9 @@ Value exports of `@cosmicdrift/kumiko-renderer-web`.
147
147
  | `createKumikoApp` | |
148
148
  | `createPublicSurface` | |
149
149
  | `createStaticLocaleResolver` | Default-Resolver für Apps ohne eigene i18n-Schicht. |
150
+ | `downloadFileRef` | |
151
+ | `fetchFileRefBlob` | |
152
+ | `fileNameFromContentDisposition` | |
150
153
  | `filterAppSchemaNavsByAllowlist` | |
151
154
  | `filterByAccess` | |
152
155
  | `formatMoney` | |
@@ -158,6 +161,7 @@ Value exports of `@cosmicdrift/kumiko-renderer-web`.
158
161
  | `readLoadedBuild` | |
159
162
  | `reparentAppSchemaNavs` | |
160
163
  | `resolveDefaultId` | |
164
+ | `saveBlobAsFile` | |
161
165
  | `smoothPath` | Quadratic-durch-Mittelpunkte-Trick: glättet die Zick-Zack-Linie ohne Overshoot (~5 Zeilen statt Catmull-Rom/Bezier-Fit). |
162
166
  | `synthesizeActionFormEntity` | Baut eine minimale EntityDefinition aus den Inline-Fields eines ActionForm- oder SecretMint-Screens. |
163
167
  | `synthesizeActionFormScreen` | Wandelt ein ActionFormScreenDefinition oder SecretMintScreenDefinition in die EntityEditScreen-Shape die RenderEdit erwartet. |
@@ -178,4 +182,5 @@ Value exports of `@cosmicdrift/kumiko-renderer-web`.
178
182
 
179
183
  | Name | Description |
180
184
  | --- | --- |
185
+ | `FileDownloadFailedError` | |
181
186
  | `FileUploadFailedError` | Carries the server's own `error` message when the upload endpoint sent one. |
@@ -6,6 +6,7 @@ import { buildAppSchema } from "../engine/build-app-schema.js";
6
6
  import { EXT_FILE_PROVIDER, EXT_PRINCIPAL_STATUS } from "../engine/extension-names.js";
7
7
  import { projectAppSchemaForRoles } from "../engine/project-app-schema-for-roles.js";
8
8
  import { runsInLane } from "../engine/run-in.js";
9
+ import { resolveDisabledFeatureNames, stripDisabledFeaturesFromAppSchema, } from "../engine/strip-disabled-features-from-app-schema.js";
9
10
  import { createAnonymousUser, createSystemUser, isAnonymousSessionUser, } from "../engine/system-user.js";
10
11
  import { isFileField, } from "../engine/types/index.js";
11
12
  import { assertSeedModeNotInProduction } from "../event-store/seed-clock.js";
@@ -310,6 +311,13 @@ export function buildServer(options) {
310
311
  const searchConsumerEnabled = options.eventDispatcher?.systemConsumers?.search ?? true;
311
312
  const jobTriggerConsumerEnabled = options.eventDispatcher?.systemConsumers?.jobTrigger ?? true;
312
313
  const jobRunnerForTriggers = options.dispatcherOptions?.jobRunner;
314
+ const mspJobs = jobRunnerForTriggers?.dispatch
315
+ ? {
316
+ dispatch: async (jobName, payload, dispatchOptions) => {
317
+ await jobRunnerForTriggers.dispatch?.(jobName, payload, dispatchOptions);
318
+ },
319
+ }
320
+ : undefined;
313
321
  const systemConsumers = [];
314
322
  if (sseConsumerEnabled) {
315
323
  systemConsumers.push(createSseBroadcastEventConsumer(sseBroker));
@@ -391,6 +399,7 @@ export function buildServer(options) {
391
399
  ...(mspOwner && { callerFeature: mspOwner }),
392
400
  ...(mspFiles && { files: mspFiles }),
393
401
  ...(contextWithObservability.secrets && { secrets: contextWithObservability.secrets }),
402
+ ...(mspJobs && { jobs: mspJobs }),
394
403
  ...(mspFiles && {
395
404
  derivatives: createDerivativesContext({
396
405
  files: mspFiles,
@@ -665,15 +674,22 @@ export function buildServer(options) {
665
674
  // not the whole app's schema) and cached per role key. Tenant is
666
675
  // deliberately NOT part of the key: the projection depends only on
667
676
  // roles, and per-tenant caching would grow unboundedly across tenants
668
- // that share the same role set.
677
+ // that share the same role set. What IS part of the key: the sorted set of
678
+ // features the caller's tenant has switched off (r.toggleable) — few
679
+ // distinct sets, so the cache stays small. The resolver is read per request
680
+ // because run{Dev,Prod}App fill it in after the server is built.
669
681
  let fullSchema;
670
682
  const projectedSchemaCache = new Map();
671
- app.get(`/api${Routes.schema}`, (c) => {
683
+ app.get(`/api${Routes.schema}`, async (c) => {
672
684
  const user = getUser(c);
673
685
  if (isMissingOrAnonymousUser(user)) {
674
686
  return unauthenticatedResponse(c);
675
687
  }
676
- const roleKey = JSON.stringify([...new Set(user?.roles ?? [])].sort());
688
+ const effectiveFeatures = options.dispatcherOptions?.effectiveFeatures;
689
+ const disabledFeatureNames = effectiveFeatures === undefined
690
+ ? []
691
+ : await resolveDisabledFeatureNames(options.registry, effectiveFeatures, user.tenantId);
692
+ const roleKey = JSON.stringify([[...new Set(user?.roles ?? [])].sort(), disabledFeatureNames]);
677
693
  let cached = projectedSchemaCache.get(roleKey);
678
694
  if (cached === undefined) {
679
695
  if (fullSchema === undefined) {
@@ -681,7 +697,7 @@ export function buildServer(options) {
681
697
  searchAdapterMissing: !options.context.searchAdapter,
682
698
  });
683
699
  }
684
- const projected = projectAppSchemaForRoles(fullSchema, user?.roles ?? []);
700
+ const projected = projectAppSchemaForRoles(stripDisabledFeaturesFromAppSchema(fullSchema, options.registry, disabledFeatureNames), user?.roles ?? []);
685
701
  const json = JSON.stringify(projected);
686
702
  cached = { json, etag: computeStrongEtag(json) };
687
703
  projectedSchemaCache.set(roleKey, cached);
@@ -861,6 +861,21 @@ export async function unsafeReadRetrying(db, sqlText, params) {
861
861
  throw lastErr;
862
862
  }
863
863
  }
864
+ function selectColumnList(info, fields) {
865
+ if (fields === undefined)
866
+ return "*";
867
+ if (fields.length === 0) {
868
+ throw new Error(`selectMany: fields must not be empty for "${info.name}"`);
869
+ }
870
+ return fields
871
+ .map((field) => {
872
+ if (!info.hasColumn(field)) {
873
+ throw new Error(`selectMany: fields entry "${field}" is not a column of "${info.name}"`);
874
+ }
875
+ return quoteIdent(info.columnOf(field));
876
+ })
877
+ .join(", ");
878
+ }
864
879
  // biome-ignore lint/suspicious/noExplicitAny: opt-in default loosens row type for unannotated test fixtures
865
880
  export async function selectMany(db, table, where, options) {
866
881
  const scoped = tenantDbDelegate(db);
@@ -868,7 +883,7 @@ export async function selectMany(db, table, where, options) {
868
883
  return scoped.selectMany(table, where, options);
869
884
  }
870
885
  const info = extractTableInfo(table);
871
- let sqlText = `SELECT * FROM ${quoteIdent(info.name)}`;
886
+ let sqlText = `SELECT ${selectColumnList(info, options?.fields)} FROM ${quoteIdent(info.name)}`;
872
887
  let values = [];
873
888
  if (where && Object.keys(where).length > 0) {
874
889
  const w = buildWhereClause(info, where, 1);
@@ -1,7 +1,8 @@
1
1
  import { randomBytes } from "node:crypto";
2
2
  import { KeyAlreadyExistsError, KeyErasedError, KeyNotFoundError, subjectIdToKey, } from "./kms-adapter.js";
3
3
  // Non-persistent adapter for tests and dev mode. Erased entries stay as
4
- // tombstones so the create-after-erase contract holds within a process.
4
+ // tombstones (also for never-keyed subjects) so the create-after-erase
5
+ // contract holds within a process.
5
6
  export class InMemoryKmsAdapter {
6
7
  capabilities = { mode: "local-key" };
7
8
  keys = new Map();
@@ -20,12 +21,8 @@ export class InMemoryKmsAdapter {
20
21
  return entry.key;
21
22
  }
22
23
  async eraseKey(subject) {
23
- const entry = this.keys.get(subjectIdToKey(subject));
24
- // skip: eraseKey is contractually idempotent — unknown subject is a no-op
25
- if (!entry)
26
- return;
27
- entry.key = null;
28
- entry.erased = true;
24
+ // A never-keyed subject gets a tombstone too, so createKey cannot mint one later.
25
+ this.keys.set(subjectIdToKey(subject), { key: null, erased: true });
29
26
  }
30
27
  async health() {
31
28
  return { ok: true, latencyMs: 0 };
@@ -107,15 +107,19 @@ export class PgKmsAdapter {
107
107
  }
108
108
  async eraseKey(subject, ctx) {
109
109
  await this.ensureSchema();
110
- // Guard on erased_at IS NULL keeps repeat erases from overwriting the
111
- // original tombstone audit fields; unknown subjects update zero rows.
110
+ // Upsert: a never-keyed subject (pre-KMS plaintext) still gets a tombstone,
111
+ // so a later createKey cannot mint a fresh key and re-encrypt the plaintext.
112
+ // The erased_at IS NULL guard keeps repeat erases from overwriting the
113
+ // original tombstone audit fields.
112
114
  await this.sql `
113
- UPDATE kumiko_subject_keys
115
+ INSERT INTO kumiko_subject_keys (subject_id, cipher_key, kek_version, erased_at, erased_by, erase_reason)
116
+ VALUES (${subjectIdToKey(subject)}, NULL, ${this.kekVersion}, now(), ${ctx.userId ?? null}, ${ctx.eraseReason ?? null})
117
+ ON CONFLICT (subject_id) DO UPDATE
114
118
  SET cipher_key = NULL,
115
119
  erased_at = now(),
116
- erased_by = ${ctx.userId ?? null},
117
- erase_reason = ${ctx.eraseReason ?? null}
118
- WHERE subject_id = ${subjectIdToKey(subject)} AND erased_at IS NULL`;
120
+ erased_by = EXCLUDED.erased_by,
121
+ erase_reason = EXCLUDED.erase_reason
122
+ WHERE kumiko_subject_keys.erased_at IS NULL`;
119
123
  }
120
124
  async health() {
121
125
  const start = Date.now();
@@ -358,8 +358,8 @@ export const buildEntityTableMeta = deriveEntityTableMeta;
358
358
  function sqlExpressionText(where) {
359
359
  return isSqlExpression(where) ? where.text : undefined;
360
360
  }
361
- // Validates that a backing Drizzle table (declared via `r.entity(name, def,
362
- // { table })`) is a SUPERSET of the field-derived meta: every column the
361
+ // Validates that a backing Drizzle table (declared via `r.entity({ name, ...def,
362
+ // backingTable })`) is a SUPERSET of the field-derived meta: every column the
363
363
  // entity fields produce must exist on the table with the same pgType +
364
364
  // notNull. Ride-along columns/indexes the table adds on top (envelope,
365
365
  // uniqueIndex, …) are exactly the point — they pass. A field with no matching
@@ -371,13 +371,13 @@ export function assertBackingTableSuperset(entityName, fieldMeta, tableMeta) {
371
371
  for (const fieldCol of fieldMeta.columns) {
372
372
  const tableCol = tableCols.get(fieldCol.name);
373
373
  if (!tableCol) {
374
- throw new Error(`r.entity("${entityName}", …, { table }): the backing table ` +
374
+ throw new Error(`r.entity({ name: "${entityName}", …, backingTable }): the backing table ` +
375
375
  `"${tableMeta.tableName}" is missing column "${fieldCol.name}" that the ` +
376
376
  "entity field declares. The table must be a superset of the entity's " +
377
377
  "fields — add the column to the table or remove the field.");
378
378
  }
379
379
  if (tableCol.pgType !== fieldCol.pgType || tableCol.notNull !== fieldCol.notNull) {
380
- throw new Error(`r.entity("${entityName}", …, { table }): column "${fieldCol.name}" ` +
380
+ throw new Error(`r.entity({ name: "${entityName}", …, backingTable }): column "${fieldCol.name}" ` +
381
381
  `disagrees between entity field (${fieldCol.pgType}, ` +
382
382
  `notNull=${fieldCol.notNull}) and backing table "${tableMeta.tableName}" ` +
383
383
  `(${tableCol.pgType}, notNull=${tableCol.notNull}). Align them.`);
@@ -169,7 +169,7 @@ export function createWriteVerbs(ctx) {
169
169
  failure: writeFailure(new FrameworkVersionConflict({
170
170
  entityId: id,
171
171
  expectedVersion: currentVersion,
172
- currentVersion: -1,
172
+ currentVersion: await versionAfterConflict(runner, String(id), streamTenantFor(user, streamTenantId)),
173
173
  })),
174
174
  };
175
175
  }
@@ -181,6 +181,16 @@ export function createWriteVerbs(ctx) {
181
181
  throw e;
182
182
  }
183
183
  }
184
+ // After a lost append race the stream version read before the append is stale;
185
+ // report the actual one. -1 when the lookup itself fails.
186
+ async function versionAfterConflict(runner, aggregateId, tenantId) {
187
+ try {
188
+ return await getStreamVersion(runner, aggregateId, tenantId);
189
+ }
190
+ catch {
191
+ return -1;
192
+ }
193
+ }
184
194
  async function expectedStreamVersion(db, id, streamTenant, expect) {
185
195
  const snapshot = await loadExpectSnapshot(db, id, streamTenant, Object.keys(expect));
186
196
  const row = snapshot.row;
@@ -298,13 +308,7 @@ export function createWriteVerbs(ctx) {
298
308
  }
299
309
  catch (e) {
300
310
  if (e instanceof EventStoreVersionConflict) {
301
- let currentVersion = -1;
302
- try {
303
- currentVersion = await getStreamVersion(runner, aggregateId, streamTenantFor(user, streamTenantId));
304
- }
305
- catch {
306
- // Lookup failure — keep the sentinel.
307
- }
311
+ const currentVersion = await versionAfterConflict(runner, aggregateId, streamTenantFor(user, streamTenantId));
308
312
  return writeFailure(new FrameworkVersionConflict({
309
313
  entityId: aggregateId,
310
314
  expectedVersion: 0,
@@ -603,7 +607,7 @@ export function createWriteVerbs(ctx) {
603
607
  return writeFailure(new FrameworkVersionConflict({
604
608
  entityId: payload.id,
605
609
  expectedVersion: payload.version ?? 0,
606
- currentVersion,
610
+ currentVersion: await versionAfterConflict(runner, String(payload.id), streamTenant),
607
611
  }));
608
612
  }
609
613
  if (e instanceof EventStoreIdempotentAppendConflict) {
@@ -675,7 +679,7 @@ export function createWriteVerbs(ctx) {
675
679
  return writeFailure(new FrameworkVersionConflict({
676
680
  entityId: payload.id,
677
681
  expectedVersion: currentVersion,
678
- currentVersion: -1,
682
+ currentVersion: await versionAfterConflict(runner, String(payload.id), streamTenantFor(user, streamTenantId)),
679
683
  }));
680
684
  }
681
685
  if (e instanceof EventStoreIdempotentAppendConflict) {
@@ -760,7 +764,7 @@ export function createWriteVerbs(ctx) {
760
764
  return writeFailure(new FrameworkVersionConflict({
761
765
  entityId: payload.id,
762
766
  expectedVersion: currentVersion,
763
- currentVersion: -1,
767
+ currentVersion: await versionAfterConflict(runner, String(payload.id), streamTenantFor(user, streamTenantId)),
764
768
  }));
765
769
  }
766
770
  if (e instanceof EventStoreIdempotentAppendConflict) {
@@ -825,7 +829,7 @@ export function createWriteVerbs(ctx) {
825
829
  // table. Rows predating the subject-KMS rollout may still hold plaintext
826
830
  // pii, and envelope encryption is not idempotent: decrypt, then
827
831
  // re-encrypt as delete does.
828
- const previousPlain = await decryptForRead(data);
832
+ const previousPlain = await decryptForRead(rehydrateCompoundTypes(data, entity));
829
833
  const appended = await appendRestoredEvent(runner, user, payload.id, previousPlain, streamTenantId, currentVersion);
830
834
  if ("failure" in appended)
831
835
  return appended.failure;
@@ -25,7 +25,7 @@ export function validateApiExposureMatching(feature, allExposedApis, featureMap)
25
25
  }
26
26
  const allDeps = [...feature.requires, ...feature.optionalRequires];
27
27
  if (!allDeps.includes(providerFeature)) {
28
- throw new Error(`[Feature ${feature.name}] r.usesApi("${apiName}") is exposed by "${providerFeature}" but feature is not in requires/optionalRequires. Add r.requires("${providerFeature}").`);
28
+ throw new Error(`[Feature ${feature.name}] r.usesApi("${apiName}") is exposed by "${providerFeature}" but feature is not in requires/optionalRequires. Add r.requires({ features: ["${providerFeature}"] }).`);
29
29
  }
30
30
  // Sanity: provider feature actually exists in this app's feature set.
31
31
  // Should always be true if allExposedApis was built from `features`,
@@ -48,7 +48,7 @@ export function validateExtensionUsages(feature, extensionProviders) {
48
48
  }
49
49
  const allDeps = [...feature.requires, ...feature.optionalRequires];
50
50
  if (!allDeps.includes(providerFeature)) {
51
- throw new Error(`Feature "${feature.name}" uses extension "${usage.extensionName}" but missing requires("${providerFeature}"). Add r.requires("${providerFeature}").`);
51
+ throw new Error(`Feature "${feature.name}" uses extension "${usage.extensionName}" but missing requires("${providerFeature}"). Add r.requires({ features: ["${providerFeature}"] }).`);
52
52
  }
53
53
  }
54
54
  }
@@ -2,7 +2,7 @@ import { optionsQueryFieldRefs } from "@cosmicdrift/kumiko-types/fields";
2
2
  import { isEncryptedAtRest } from "../config-helpers.js";
3
3
  // --- Toggleable-dependency warnings ---
4
4
  //
5
- // When feature A declares r.requires("B") and B is toggleable with
5
+ // When feature A declares r.requires({ features: ["B"] }) and B is toggleable with
6
6
  // default=false, A is effectively disabled out-of-the-box until someone
7
7
  // flips B on globally. That's usually an oversight — the dev either meant
8
8
  // optionalRequires, or forgot to ship B with default=true. We warn (not
@@ -1,16 +1,9 @@
1
- import { readHandlerFieldReference } from "../handler-field-references.js";
1
+ import { readHandlerFieldReferences } from "../handler-field-references.js";
2
2
  import { parseRefTarget } from "../parse-ref-target.js";
3
- import { getZodObjectShape } from "./zod-shape.js";
4
3
  function validateHandlerGroup(feature, featureMap, handlers, handlerNoun) {
5
4
  for (const [handlerName, def] of Object.entries(handlers)) {
6
- const shape = getZodObjectShape(def.schema);
7
- if (shape === undefined)
8
- continue;
9
- for (const fieldName of Object.keys(shape)) {
10
- const where = `Input field "${fieldName}" of ${handlerNoun} "${handlerName}"`;
11
- const reading = readHandlerFieldReference(def.schema, fieldName);
12
- if (reading.kind === "none")
13
- continue;
5
+ for (const { path, reading } of readHandlerFieldReferences(def.schema)) {
6
+ const where = `Input field "${path}" of ${handlerNoun} "${handlerName}"`;
14
7
  if (reading.kind === "invalid") {
15
8
  throw new Error(`[Feature ${feature.name}] ${where} has an invalid meta references value ${JSON.stringify(reading.raw)}. ` +
16
9
  `Use "<entity>", "<feature>:<entity>" or { entity, by? }.`);
@@ -2216,7 +2216,7 @@ function assertRefTargetRegistered(prefix, subject, refTarget, currentFeatureNam
2216
2216
  const targetFeature = featureMap.get(target.featureName);
2217
2217
  if (targetFeature === undefined) {
2218
2218
  throw new Error(`${prefix} ${subject} targets entity "${refTarget}", but feature "${target.featureName}" is not ` +
2219
- `mounted — mount it and declare r.requires("${target.featureName}").`);
2219
+ `mounted — mount it and declare r.requires({ features: ["${target.featureName}"] }).`);
2220
2220
  }
2221
2221
  if (targetFeature.entities?.[target.entityName] === undefined) {
2222
2222
  throw new Error(`${prefix} ${subject} targets entity "${refTarget}", which does not resolve to a ` +
@@ -20,12 +20,9 @@
20
20
  // Translations: verbatim feature.translations (raw r.translations keys,
21
21
  // unfiltered — see FeatureSchema.translations doc)
22
22
  //
23
- // Feature-Toggles: BISHER NICHT GEFILTERT. Wenn ein Feature über die
24
- // feature-toggles-bundled-feature global deaktiviert ist, erscheint es
25
- // trotzdem im AppSchema. Reason: die Toggle-Auflösung lebt im pipeline-
26
- // dispatcher, nicht in der Registry, und wir haben hier keinen TenantDb-
27
- // Kontext um sie zu lesen. TODO wenn das ein realer Use-Case wird:
28
- // `effectiveFeatures` Argument annehmen und über alle iterations filtern.
23
+ // Feature toggles: this schema is tenant-independent and lists every feature.
24
+ // The schema route strips features the caller's tenant has switched off
25
+ // (r.toggleable) via stripDisabledFeaturesFromAppSchema.
29
26
  import { ZodObject } from "zod";
30
27
  import { collectUrlPrefillFieldsByScreenQn } from "./boot-validator/url-prefill-fields.js";
31
28
  import { buildConfigFeatureSchema, SETTINGS_HUB_FEATURE, } from "./build-config-feature-schema.js";