@cosmicdrift/kumiko-framework 0.365.1 → 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 (103) 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 +1 -1
  49. package/agents/examples/writeform-section.md +21 -15
  50. package/agents/widgets.md +5 -0
  51. package/dist/api/server.js +12 -4
  52. package/dist/bun-db/query.js +16 -1
  53. package/dist/db/entity-table-meta.js +4 -4
  54. package/dist/engine/boot-validator/api-ext.js +2 -2
  55. package/dist/engine/boot-validator/config-deps.js +1 -1
  56. package/dist/engine/boot-validator/handler-field-references.js +3 -10
  57. package/dist/engine/boot-validator/screens.js +1 -1
  58. package/dist/engine/build-app-schema.js +3 -6
  59. package/dist/engine/define-feature.js +5 -5
  60. package/dist/engine/entity-handlers.d.ts +2 -2
  61. package/dist/engine/entity-handlers.js +5 -5
  62. package/dist/engine/factories.d.ts +11 -11
  63. package/dist/engine/factories.js +11 -11
  64. package/dist/engine/feature-ast/extractors/round2.js +16 -2
  65. package/dist/engine/feature-ast/render.js +4 -7
  66. package/dist/engine/feature-config-events-jobs.d.ts +2 -0
  67. package/dist/engine/feature-config-events-jobs.js +3 -0
  68. package/dist/engine/feature-entity-handlers.d.ts +4 -23
  69. package/dist/engine/feature-entity-handlers.js +77 -100
  70. package/dist/engine/feature-ui-extensions.d.ts +9 -14
  71. package/dist/engine/feature-ui-extensions.js +22 -14
  72. package/dist/engine/handler-field-references.d.ts +25 -0
  73. package/dist/engine/handler-field-references.js +80 -6
  74. package/dist/engine/index.d.ts +3 -3
  75. package/dist/engine/index.js +2 -2
  76. package/dist/engine/object-form.d.ts +2 -0
  77. package/dist/engine/object-form.js +13 -10
  78. package/dist/engine/registry-state.js +2 -2
  79. package/dist/engine/registry-validate.d.ts +1 -0
  80. package/dist/engine/registry-validate.js +21 -2
  81. package/dist/engine/registry.js +2 -1
  82. package/dist/engine/strip-disabled-features-from-app-schema.d.ts +6 -0
  83. package/dist/engine/strip-disabled-features-from-app-schema.js +89 -0
  84. package/dist/engine/types/index.d.ts +1 -1
  85. package/dist/files/feature.js +1 -1
  86. package/dist/jobs/job-runner.js +16 -2
  87. package/dist/pipeline/dispatch-query.js +1 -1
  88. package/dist/pipeline/dispatch-shared.d.ts +2 -1
  89. package/dist/pipeline/dispatch-shared.js +3 -6
  90. package/dist/pipeline/tenant-timezone-config-key.d.ts +1 -0
  91. package/dist/pipeline/tenant-timezone-config-key.js +6 -0
  92. package/dist/testing/index.d.ts +1 -0
  93. package/dist/testing/index.js +1 -0
  94. package/dist/testing/manual-clock.d.ts +14 -0
  95. package/dist/testing/manual-clock.js +12 -0
  96. package/dist/time/index.d.ts +1 -1
  97. package/dist/time/tz-context.d.ts +1 -1
  98. package/dist/time/tz-context.js +9 -4
  99. package/dist/upgrade-cli.js +13 -2
  100. package/package.json +4 -4
  101. package/src/changes.json +40 -0
  102. package/src/scripts/codemod/README.md +23 -0
  103. 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",
@@ -436,7 +436,7 @@ const incidentExecutor = createEventStoreExecutor(incidentTable, incidentEntity,
436
436
  const INCIDENT_HOOK_URL = "https://hooks.example/incident";
437
437
 
438
438
  export const webhookDemoFeature = defineFeature("webhook-demo", (r) => {
439
- r.entity("incident", incidentEntity);
439
+ r.entity({ name: "incident", ...incidentEntity });
440
440
  r.requires.step("webhook.send");
441
441
  r.requires.step("mail.send");
442
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",
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";
@@ -673,15 +674,22 @@ export function buildServer(options) {
673
674
  // not the whole app's schema) and cached per role key. Tenant is
674
675
  // deliberately NOT part of the key: the projection depends only on
675
676
  // roles, and per-tenant caching would grow unboundedly across tenants
676
- // 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.
677
681
  let fullSchema;
678
682
  const projectedSchemaCache = new Map();
679
- app.get(`/api${Routes.schema}`, (c) => {
683
+ app.get(`/api${Routes.schema}`, async (c) => {
680
684
  const user = getUser(c);
681
685
  if (isMissingOrAnonymousUser(user)) {
682
686
  return unauthenticatedResponse(c);
683
687
  }
684
- 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]);
685
693
  let cached = projectedSchemaCache.get(roleKey);
686
694
  if (cached === undefined) {
687
695
  if (fullSchema === undefined) {
@@ -689,7 +697,7 @@ export function buildServer(options) {
689
697
  searchAdapterMissing: !options.context.searchAdapter,
690
698
  });
691
699
  }
692
- const projected = projectAppSchemaForRoles(fullSchema, user?.roles ?? []);
700
+ const projected = projectAppSchemaForRoles(stripDisabledFeaturesFromAppSchema(fullSchema, options.registry, disabledFeatureNames), user?.roles ?? []);
693
701
  const json = JSON.stringify(projected);
694
702
  cached = { json, etag: computeStrongEtag(json) };
695
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);
@@ -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.`);
@@ -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";
@@ -2,7 +2,7 @@ import { createInitialFeatureBuilderState } from "./feature-builder-state.js";
2
2
  import { buildConfigEventsJobsMethods } from "./feature-config-events-jobs.js";
3
3
  import { buildEntityHandlerMethods } from "./feature-entity-handlers.js";
4
4
  import { buildUiExtensionsMethods } from "./feature-ui-extensions.js";
5
- import { unwrapArrayForm } from "./object-form.js";
5
+ import { isObjectArgument, throwPositionalFormRemoved, unwrapArrayForm } from "./object-form.js";
6
6
  // `TExports` lets the setup callback hand back a typed object that
7
7
  // downstream features can import (e.g. `tenantFeature.exports.config`). The
8
8
  // runtime always packs whatever setup returns into `featureDef.exports` —
@@ -15,10 +15,8 @@ import { unwrapArrayForm } from "./object-form.js";
15
15
  // for `ctx.appendEvent({ type: eventDef.name, ... })` lights up. Apps
16
16
  // that don't care can keep the default-string and use the wrapper-based
17
17
  // strict-mode (string-literal types per call-site) like before.
18
- // requires/optionalRequires accept either variadic strings (hand-written
19
- // call sites) or a single `{ features }` object (the feature-ast renderer's
20
- // canonical Object-Form for Designer/AI-generated code) — see the matching
21
- // overloads on RequiresApi / FeatureRegistrar['optionalRequires'].
18
+ // optionalRequires accepts either variadic strings or a single `{ features }` object; requires
19
+ // takes only the object (see RequiresApi).
22
20
  function resolveFeatureNamesArgs(args) {
23
21
  return unwrapArrayForm(args, "features");
24
22
  }
@@ -50,6 +48,8 @@ export function defineFeature(name, setup, settings) {
50
48
  },
51
49
  requires: (() => {
52
50
  const fn = (...args) => {
51
+ if (!args.every(isObjectArgument))
52
+ throwPositionalFormRemoved(name, "requires");
53
53
  state.requires.push(...resolveFeatureNamesArgs(args));
54
54
  };
55
55
  fn.projection = (tableName) => {
@@ -63,5 +63,5 @@ export declare function defineProjectionQueryHandler(name: string, projectionQua
63
63
  };
64
64
  escapeHatch?: EscapeHatchDeclaration;
65
65
  }): QueryHandlerDef;
66
- /** Register standard entity CRUD handlers in one call. Access stays explicit — no openToAll default. */
67
- export declare function registerEntityCrud(r: EntityCrudRegistrar, entityName: string, entity: EntityDefinition, options?: RegisterEntityCrudOptions): void;
66
+ /** Backs `r.crud`: registers the standard entity CRUD handlers. Access stays explicit — no openToAll default. */
67
+ export declare function registerCrudHandlers(r: EntityCrudRegistrar, entityName: string, entity: EntityDefinition, options?: RegisterEntityCrudOptions): void;
@@ -551,21 +551,21 @@ function defaultCrudVerbs(entity) {
551
551
  detail: true,
552
552
  };
553
553
  }
554
- /** Register standard entity CRUD handlers in one call. Access stays explicit — no openToAll default. */
555
- export function registerEntityCrud(r, entityName, entity, options) {
554
+ /** Backs `r.crud`: registers the standard entity CRUD handlers. Access stays explicit — no openToAll default. */
555
+ export function registerCrudHandlers(r, entityName, entity, options) {
556
556
  const verbs = { ...defaultCrudVerbs(entity), ...options?.verbs };
557
557
  if (verbs.restore && entity.softDelete !== true) {
558
558
  throw new Error(`restore requested but entity "${entityName}" has no softDelete: true`);
559
559
  }
560
560
  if (options?.registerEntity !== false) {
561
- r.entity(entityName, entity);
561
+ r.entity({ name: entityName, ...entity });
562
562
  }
563
563
  const writeOpts = options?.write;
564
564
  const readOpts = options?.read;
565
565
  const requireAccess = (verb, access) => {
566
566
  if (!access) {
567
- throw new Error(`registerEntityCrud("${entityName}", verb: "${verb}"): no access rule resolved. ` +
568
- `Set write.access/read.access on registerEntityCrud's options, or verbAccess.${verb}.`);
567
+ throw new Error(`r.crud("${entityName}", …): no access rule resolved for verb "${verb}". ` +
568
+ `Set write.access/read.access in r.crud's options, or verbAccess.${verb}.`);
569
569
  }
570
570
  return access;
571
571
  };
@@ -133,34 +133,34 @@ export declare function createTzField<R extends true | false = false>(overrides?
133
133
  required: R;
134
134
  };
135
135
  /**
136
- * Wall-Clock-Termin an einem Ort als ATOMARES Field. Empfohlene Form für
137
- * jeden Date-Wert mit Location-Bezug (Pickup, Delivery, Meeting, Schedule).
136
+ * Wall-clock time at a place as one atomic field. Use it for every date
137
+ * value tied to a location (pickup, delivery, meeting, schedule).
138
138
  *
139
- * EIN Schema-Feld → ZWEI DB-Spalten (`<name>_utc TIMESTAMPTZ` + `<name>_tz TEXT`)
140
- * → DREI API-Felder beim Read (`{ at, tz, utc }`).
139
+ * One schema field maps to two DB columns (`<name>_utc TIMESTAMPTZ` +
140
+ * `<name>_tz TEXT`) and to three API fields on read (`{ at, tz, utc }`).
141
141
  *
142
142
  * ```ts
143
- * r.entity("order", {
143
+ * r.entity({
144
+ * name: "order",
144
145
  * fields: {
145
146
  * pickup: createLocatedTimestampField(),
146
147
  * delivery: createLocatedTimestampField(),
147
148
  * },
148
149
  * });
149
150
  *
150
- * // Write — Caller schickt at+tz, Framework rechnet utc aus
151
+ * // Write: the caller sends at+tz, the framework computes utc
151
152
  * await create({ pickup: { at: "2026-04-15T10:00:00", tz: "Europe/Lisbon" } });
152
153
  *
153
- * // Read — Framework liefert alle drei Felder zurück
154
+ * // Read: the framework returns all three fields
154
155
  * // → { pickup: { at: "10:00", tz: "Europe/Lisbon", utc: "09:00Z" } }
155
156
  *
156
- * // Sort/Filter über die UTC-Spalte
157
+ * // Sort/filter on the UTC column
157
158
  * orderBy(asc(orderTable.pickupUtc))
158
159
  * where(between(orderTable.pickupUtc, start, end))
159
160
  * ```
160
161
  *
161
- * Default-Sicht für `at` ist Pickup-Ort-lokal (was eingegeben wurde, kommt
162
- * mit demselben Tag zurück). User-lokale Sicht kommt aus `utc` per
163
- * separater Berechnung.
162
+ * `at` is local to the place (what was entered comes back on the same day).
163
+ * A user-local view is computed separately from `utc`.
164
164
  */
165
165
  export declare function createLocatedTimestampField<R extends true | false = false>(overrides?: Partial<Omit<LocatedTimestampFieldDef, "type" | "required" | keyof ResolvedPiiFlags>> & PersonalAnnotationsNoFind & {
166
166
  required?: R;
@@ -324,34 +324,34 @@ export function createTzField(overrides) {
324
324
  }; // @cast-boundary engine-payload
325
325
  }
326
326
  /**
327
- * Wall-Clock-Termin an einem Ort als ATOMARES Field. Empfohlene Form für
328
- * jeden Date-Wert mit Location-Bezug (Pickup, Delivery, Meeting, Schedule).
327
+ * Wall-clock time at a place as one atomic field. Use it for every date
328
+ * value tied to a location (pickup, delivery, meeting, schedule).
329
329
  *
330
- * EIN Schema-Feld → ZWEI DB-Spalten (`<name>_utc TIMESTAMPTZ` + `<name>_tz TEXT`)
331
- * → DREI API-Felder beim Read (`{ at, tz, utc }`).
330
+ * One schema field maps to two DB columns (`<name>_utc TIMESTAMPTZ` +
331
+ * `<name>_tz TEXT`) and to three API fields on read (`{ at, tz, utc }`).
332
332
  *
333
333
  * ```ts
334
- * r.entity("order", {
334
+ * r.entity({
335
+ * name: "order",
335
336
  * fields: {
336
337
  * pickup: createLocatedTimestampField(),
337
338
  * delivery: createLocatedTimestampField(),
338
339
  * },
339
340
  * });
340
341
  *
341
- * // Write — Caller schickt at+tz, Framework rechnet utc aus
342
+ * // Write: the caller sends at+tz, the framework computes utc
342
343
  * await create({ pickup: { at: "2026-04-15T10:00:00", tz: "Europe/Lisbon" } });
343
344
  *
344
- * // Read — Framework liefert alle drei Felder zurück
345
+ * // Read: the framework returns all three fields
345
346
  * // → { pickup: { at: "10:00", tz: "Europe/Lisbon", utc: "09:00Z" } }
346
347
  *
347
- * // Sort/Filter über die UTC-Spalte
348
+ * // Sort/filter on the UTC column
348
349
  * orderBy(asc(orderTable.pickupUtc))
349
350
  * where(between(orderTable.pickupUtc, start, end))
350
351
  * ```
351
352
  *
352
- * Default-Sicht für `at` ist Pickup-Ort-lokal (was eingegeben wurde, kommt
353
- * mit demselben Tag zurück). User-lokale Sicht kommt aus `utc` per
354
- * separater Berechnung.
353
+ * `at` is local to the place (what was entered comes back on the same day).
354
+ * A user-local view is computed separately from `utc`.
355
355
  */
356
356
  export function createLocatedTimestampField(overrides) {
357
357
  return {
@@ -1,7 +1,21 @@
1
1
  import { SyntaxKind } from "ts-morph";
2
2
  import { describeUnknownFieldType, findUnknownEntityFieldTypes } from "../entity-field-types.js";
3
3
  import { sourceLocationFromNode } from "../source-location.js";
4
- import { fail, isPlainObject, ok, readDataLiteralNode, readNameLiteral, readNameOrRef, } from "./shared.js";
4
+ import { fail, isPlainObject, isRawRefSentinel, ok, readDataLiteralNode, readNameLiteral, readNameOrRef, } from "./shared.js";
5
+ // `{ name: "x", ...xEntity }` — the definition is a reference the parser cannot
6
+ // inline, so it is kept as a raw-ref sentinel and re-rendered as the same spread.
7
+ // `backingTable` is runtime wiring (a table identifier), not part of the
8
+ // definition data, so it does not take part in the shape check.
9
+ function readSpreadOnlyDefinition(obj) {
10
+ const properties = obj
11
+ .getProperties()
12
+ .filter((property) => property.asKind(SyntaxKind.PropertyAssignment)?.getName() !== "backingTable");
13
+ const spread = properties.length === 2 ? properties[1]?.asKind(SyntaxKind.SpreadAssignment) : undefined;
14
+ if (!spread)
15
+ return undefined;
16
+ const reference = readDataLiteralNode(spread.getExpression());
17
+ return isRawRefSentinel(reference) ? reference : undefined;
18
+ }
5
19
  export function extractEntity(call, sourceFile) {
6
20
  const args = call.getArguments();
7
21
  const first = args[0];
@@ -18,7 +32,7 @@ export function extractEntity(call, sourceFile) {
18
32
  if (!nameInit) {
19
33
  return fail("entity", sourceLocationFromNode(call, sourceFile), "object form requires a string-literal `name` property");
20
34
  }
21
- const definition = readDataLiteralNode(obj);
35
+ const definition = readSpreadOnlyDefinition(obj) ?? readDataLiteralNode(obj);
22
36
  if (!isPlainObject(definition)) {
23
37
  return fail("entity", sourceLocationFromNode(call, sourceFile), "definition could not be read as a plain object (contains functions or identifiers)");
24
38
  }
@@ -202,15 +202,12 @@ function renderDescribe(p) {
202
202
  return `r.describe(${JSON.stringify(p.text)});`;
203
203
  }
204
204
  function renderEntity(p) {
205
- // A whole-value raw-ref (`r.entity("x", eventEntity)`) can't be spread
206
- // into the merged Object-Form without losing `name` to the sentinel
207
- // shortcut in renderValue — fall back to the classic positional form,
208
- // which keeps the reference verbatim alongside the name.
205
+ // A whole-value raw-ref (`eventEntity`) can't go through renderValue without
206
+ // losing `name` to the sentinel shortcut — spread the reference verbatim
207
+ // next to the name instead.
209
208
  if (isRawRefSentinel(p.definition)) {
210
- return `r.entity(${JSON.stringify(p.entityName)}, ${p.definition.__raw});`;
209
+ return `r.entity({ name: ${JSON.stringify(p.entityName)}, ...${p.definition.__raw} });`;
211
210
  }
212
- // Inline `name` into the definition object — canonical Object-Form
213
- // is a single arg with name-as-property.
214
211
  const merged = { name: p.entityName, ...p.definition };
215
212
  return `r.entity(${renderValue(merged)});`;
216
213
  }
@@ -25,6 +25,7 @@ export declare function buildConfigEventsJobsMethods<TName extends string>(state
25
25
  readonly template?: Readonly<Record<string, NotificationTemplateBody>>;
26
26
  readonly meta?: Readonly<Record<string, NotificationTemplateMeta>>;
27
27
  readonly localizedTemplate?: () => NotificationLocalizedTemplate;
28
+ readonly renamedFrom?: readonly string[];
28
29
  }), definition?: {
29
30
  readonly trigger?: {
30
31
  readonly on: NameOrRef;
@@ -37,6 +38,7 @@ export declare function buildConfigEventsJobsMethods<TName extends string>(state
37
38
  readonly template?: Readonly<Record<string, NotificationTemplateBody>>;
38
39
  readonly meta?: Readonly<Record<string, NotificationTemplateMeta>>;
39
40
  readonly localizedTemplate?: () => NotificationLocalizedTemplate;
41
+ readonly renamedFrom?: readonly string[];
40
42
  }): void;
41
43
  translations(def: TranslationsDef): void;
42
44
  defineEvent: <const TInner extends string, TPayload>(eventName: TInner, schema: ZodType<TPayload>, options: {