@cosmicdrift/kumiko-framework 0.387.0 → 0.388.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 (240) hide show
  1. package/AGENTS.md +6 -1
  2. package/agents/examples/anonymous-access.md +1 -1
  3. package/agents/examples/apex-landing.md +204 -0
  4. package/agents/examples/api-worker-split.md +64 -0
  5. package/agents/examples/app-config.md +14 -0
  6. package/agents/examples/auth-mfa.md +115 -0
  7. package/agents/examples/env-composition.md +28 -1
  8. package/agents/examples/legal-pages.md +1 -1
  9. package/agents/examples/prod-boot-options.md +16 -0
  10. package/agents/examples/rate-limiting.md +48 -0
  11. package/agents/examples/record-detail-layout.md +692 -0
  12. package/agents/examples/seed-migration.md +212 -0
  13. package/agents/examples/wizard-form.md +941 -0
  14. package/agents/examples/writeform-section.md +548 -0
  15. package/agents/features.md +19 -2
  16. package/agents/widgets.md +1 -0
  17. package/dist/api/client-error-route.js +8 -2
  18. package/dist/api/index.d.ts +3 -1
  19. package/dist/api/index.js +3 -1
  20. package/dist/api/request-context.d.ts +1 -1
  21. package/dist/api/request-context.js +1 -1
  22. package/dist/api/server-boot-checks.d.ts +10 -0
  23. package/dist/api/server-boot-checks.js +157 -0
  24. package/dist/api/server-event-dispatcher.d.ts +26 -0
  25. package/dist/api/server-event-dispatcher.js +208 -0
  26. package/dist/api/server-extra-routes.d.ts +11 -0
  27. package/dist/api/server-extra-routes.js +203 -0
  28. package/dist/api/server-middleware.d.ts +20 -0
  29. package/dist/api/server-middleware.js +211 -0
  30. package/dist/api/server-routes.d.ts +19 -0
  31. package/dist/api/server-routes.js +293 -0
  32. package/dist/api/server-websocket-routes.d.ts +4 -0
  33. package/dist/api/server-websocket-routes.js +108 -0
  34. package/dist/api/server-wiring.d.ts +32 -0
  35. package/dist/api/server-wiring.js +236 -0
  36. package/dist/api/server.d.ts +10 -15
  37. package/dist/api/server.js +48 -1251
  38. package/dist/api/session-cookies.js +4 -4
  39. package/dist/api/sse-route.js +16 -12
  40. package/dist/crypto/owner-transition-rekey.d.ts +5 -1
  41. package/dist/crypto/owner-transition-rekey.js +1 -1
  42. package/dist/crypto/pii-field-encryption.d.ts +5 -1
  43. package/dist/crypto/pii-field-encryption.js +3 -1
  44. package/dist/db/aggregate-sql.d.ts +21 -0
  45. package/dist/db/aggregate-sql.js +155 -0
  46. package/dist/db/closed-connection.d.ts +9 -0
  47. package/dist/db/closed-connection.js +95 -0
  48. package/dist/db/dialect.js +1 -1
  49. package/dist/db/distinct-on.d.ts +1 -1
  50. package/dist/db/driver-row-coercion.d.ts +2 -0
  51. package/dist/db/driver-row-coercion.js +186 -0
  52. package/dist/db/driver-value-preparation.d.ts +16 -0
  53. package/dist/db/driver-value-preparation.js +99 -0
  54. package/dist/db/entity-field-encryption.d.ts +1 -0
  55. package/dist/db/entity-field-encryption.js +1 -1
  56. package/dist/db/entity-table-meta.js +103 -110
  57. package/dist/db/event-store-executor-context.d.ts +2 -1
  58. package/dist/db/event-store-executor-read-aggregate.d.ts +8 -0
  59. package/dist/db/event-store-executor-read-aggregate.js +112 -0
  60. package/dist/db/event-store-executor-read-filter.d.ts +31 -0
  61. package/dist/db/event-store-executor-read-filter.js +203 -0
  62. package/dist/db/event-store-executor-read-paging.d.ts +14 -0
  63. package/dist/db/event-store-executor-read-paging.js +99 -0
  64. package/dist/db/event-store-executor-read-rows.d.ts +6 -0
  65. package/dist/db/event-store-executor-read-rows.js +32 -0
  66. package/dist/db/event-store-executor-read-search.d.ts +28 -0
  67. package/dist/db/event-store-executor-read-search.js +221 -0
  68. package/dist/db/event-store-executor-read-sort.d.ts +41 -0
  69. package/dist/db/event-store-executor-read-sort.js +130 -0
  70. package/dist/db/event-store-executor-read-where.d.ts +24 -0
  71. package/dist/db/event-store-executor-read-where.js +81 -0
  72. package/dist/db/event-store-executor-read.d.ts +2 -9
  73. package/dist/db/event-store-executor-read.js +48 -701
  74. package/dist/db/event-store-executor-write-steps.d.ts +20 -0
  75. package/dist/db/event-store-executor-write-steps.js +100 -0
  76. package/dist/db/event-store-executor-write.js +395 -499
  77. package/dist/db/index.d.ts +6 -2
  78. package/dist/db/index.js +5 -1
  79. package/dist/db/list-aggregate-labels.d.ts +14 -0
  80. package/dist/db/list-aggregate-labels.js +26 -0
  81. package/dist/db/migrate-generator.js +71 -86
  82. package/dist/db/migrate-runner.js +4 -1
  83. package/dist/db/query.d.ts +2 -15
  84. package/dist/db/query.js +8 -761
  85. package/dist/db/reference-data.js +6 -1
  86. package/dist/db/table-builder.js +113 -166
  87. package/dist/db/table-info.d.ts +10 -0
  88. package/dist/db/table-info.js +45 -0
  89. package/dist/db/temporal-value-guards.d.ts +6 -0
  90. package/dist/db/temporal-value-guards.js +15 -0
  91. package/dist/db/tenant-db-runner.d.ts +1 -0
  92. package/dist/db/tenant-db-runner.js +3 -0
  93. package/dist/db/tenant-db.js +39 -9
  94. package/dist/db/where-clause.d.ts +6 -0
  95. package/dist/db/where-clause.js +236 -0
  96. package/dist/derivatives/delete-stored-file.js +2 -1
  97. package/dist/engine/boot-validator/boot-check.js +1 -1
  98. package/dist/engine/boot-validator/detail-screens.js +2 -2
  99. package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
  100. package/dist/engine/boot-validator/entity-handler.js +17 -0
  101. package/dist/engine/boot-validator/form-derivations.js +35 -1
  102. package/dist/engine/boot-validator/i18n-keys.js +5 -3
  103. package/dist/engine/boot-validator/index.js +2 -0
  104. package/dist/engine/boot-validator/pii-retention.js +233 -155
  105. package/dist/engine/boot-validator/screens-actions.d.ts +1 -0
  106. package/dist/engine/boot-validator/screens-actions.js +8 -1
  107. package/dist/engine/boot-validator/screens-dashboard.js +2 -5
  108. package/dist/engine/boot-validator/screens-forms.js +2 -6
  109. package/dist/engine/boot-validator/screens-kind-config-edit.d.ts +3 -0
  110. package/dist/engine/boot-validator/screens-kind-config-edit.js +88 -0
  111. package/dist/engine/boot-validator/screens-kind-entity-edit.d.ts +3 -0
  112. package/dist/engine/boot-validator/screens-kind-entity-edit.js +55 -0
  113. package/dist/engine/boot-validator/screens-kind-entity-list.d.ts +3 -0
  114. package/dist/engine/boot-validator/screens-kind-entity-list.js +228 -0
  115. package/dist/engine/boot-validator/screens-kind-projection-detail.d.ts +3 -0
  116. package/dist/engine/boot-validator/screens-kind-projection-detail.js +235 -0
  117. package/dist/engine/boot-validator/screens-kind-projection-list.d.ts +3 -0
  118. package/dist/engine/boot-validator/screens-kind-projection-list.js +52 -0
  119. package/dist/engine/boot-validator/screens-kind-secret-mint.d.ts +3 -0
  120. package/dist/engine/boot-validator/screens-kind-secret-mint.js +28 -0
  121. package/dist/engine/boot-validator/screens-kind-shared.d.ts +37 -0
  122. package/dist/engine/boot-validator/screens-kind-shared.js +142 -0
  123. package/dist/engine/boot-validator/screens-lists.d.ts +2 -1
  124. package/dist/engine/boot-validator/screens-lists.js +12 -9
  125. package/dist/engine/boot-validator/screens.d.ts +0 -2
  126. package/dist/engine/boot-validator/screens.js +99 -860
  127. package/dist/engine/build-app-schema.js +56 -96
  128. package/dist/engine/define-feature.js +9 -2
  129. package/dist/engine/define-handler.js +1 -0
  130. package/dist/engine/entity-handlers.d.ts +13 -0
  131. package/dist/engine/entity-handlers.js +10 -21
  132. package/dist/engine/entity-list-runtime-options.d.ts +8 -0
  133. package/dist/engine/entity-list-runtime-options.js +18 -0
  134. package/dist/engine/feature-ast/extractors/handlers.js +1 -0
  135. package/dist/engine/feature-ast/extractors/round2.js +25 -6
  136. package/dist/engine/feature-ast/patch.js +4 -182
  137. package/dist/engine/feature-ast/pattern-change-schema.d.ts +3 -0
  138. package/dist/engine/feature-ast/pattern-change-schema.js +1 -0
  139. package/dist/engine/feature-ast/pattern-id-matchers.d.ts +7 -0
  140. package/dist/engine/feature-ast/pattern-id-matchers.js +210 -0
  141. package/dist/engine/feature-ast/patterns.d.ts +9 -0
  142. package/dist/engine/feature-ast/patterns.js +43 -51
  143. package/dist/engine/feature-ast/render.js +48 -87
  144. package/dist/engine/feature-entity-handlers.js +3 -0
  145. package/dist/engine/form-derivations.js +18 -7
  146. package/dist/engine/handler-field-references.js +1 -1
  147. package/dist/engine/project-app-schema-for-roles.d.ts +2 -0
  148. package/dist/engine/project-app-schema-for-roles.js +6 -4
  149. package/dist/engine/registry-ingest.js +1 -1
  150. package/dist/engine/schema-builder.js +228 -212
  151. package/dist/engine/screen-helpers.js +1 -1
  152. package/dist/engine/screen-kind.d.ts +7 -0
  153. package/dist/engine/screen-kind.js +1 -0
  154. package/dist/engine/strip-disabled-features-from-app-schema.js +51 -14
  155. package/dist/engine/validate-projection-allowlist.js +2 -1
  156. package/dist/entrypoint/index.d.ts +4 -1
  157. package/dist/entrypoint/index.js +7 -1
  158. package/dist/errors/docs-url.d.ts +1 -1
  159. package/dist/errors/docs-url.js +22 -2
  160. package/dist/es-ops/context.js +1 -1
  161. package/dist/event-store/admin-api.js +1 -1
  162. package/dist/event-store/event-store.js +10 -4
  163. package/dist/files/bind-pending-file-refs.js +3 -3
  164. package/dist/files/create-file-ref-from-bytes.js +4 -1
  165. package/dist/files/file-handle.js +2 -3
  166. package/dist/files/file-routes.js +11 -8
  167. package/dist/files/resolve-content-type.js +43 -0
  168. package/dist/files/upload-ledger.js +7 -0
  169. package/dist/i18n/required-surface-keys.js +182 -206
  170. package/dist/internal/stack-support.d.ts +2 -1
  171. package/dist/internal/stack-support.js +1 -1
  172. package/dist/jobs/job-runner-context.d.ts +32 -0
  173. package/dist/jobs/job-runner-context.js +305 -0
  174. package/dist/jobs/job-runner-execution.d.ts +7 -0
  175. package/dist/jobs/job-runner-execution.js +160 -0
  176. package/dist/jobs/job-runner-gates.d.ts +19 -0
  177. package/dist/jobs/job-runner-gates.js +198 -0
  178. package/dist/jobs/job-runner-helpers.d.ts +17 -3
  179. package/dist/jobs/job-runner-helpers.js +61 -2
  180. package/dist/jobs/job-runner-scope.d.ts +44 -0
  181. package/dist/jobs/job-runner-scope.js +102 -0
  182. package/dist/jobs/job-runner-types.d.ts +12 -0
  183. package/dist/jobs/job-runner.d.ts +0 -1
  184. package/dist/jobs/job-runner.js +47 -657
  185. package/dist/observability/escape-hatch-report.d.ts +3 -0
  186. package/dist/observability/escape-hatch-report.js +12 -1
  187. package/dist/pipeline/cascade-handler.js +165 -138
  188. package/dist/pipeline/dispatch-query.js +10 -3
  189. package/dist/pipeline/dispatch-shared.d.ts +13 -3
  190. package/dist/pipeline/dispatch-shared.js +58 -710
  191. package/dist/pipeline/dispatch-write.js +296 -189
  192. package/dist/pipeline/event-dispatcher-admin.js +2 -1
  193. package/dist/pipeline/event-dispatcher-delivery.js +3 -5
  194. package/dist/pipeline/handler-context-auth.d.ts +9 -0
  195. package/dist/pipeline/handler-context-auth.js +54 -0
  196. package/dist/pipeline/handler-context-config.d.ts +14 -0
  197. package/dist/pipeline/handler-context-config.js +113 -0
  198. package/dist/pipeline/handler-context-db.d.ts +24 -0
  199. package/dist/pipeline/handler-context-db.js +149 -0
  200. package/dist/pipeline/handler-context-dispatch.d.ts +21 -0
  201. package/dist/pipeline/handler-context-dispatch.js +89 -0
  202. package/dist/pipeline/handler-context-events.d.ts +43 -0
  203. package/dist/pipeline/handler-context-events.js +237 -0
  204. package/dist/pipeline/handler-context-notify.d.ts +12 -0
  205. package/dist/pipeline/handler-context-notify.js +55 -0
  206. package/dist/pipeline/handler-context-observability.d.ts +7 -0
  207. package/dist/pipeline/handler-context-observability.js +21 -0
  208. package/dist/pipeline/handler-context-scope.d.ts +48 -0
  209. package/dist/pipeline/handler-context-scope.js +88 -0
  210. package/dist/pipeline/notify-scope.js +12 -1
  211. package/dist/pipeline/pre-handler-gates.js +20 -4
  212. package/dist/pipeline/pre-transaction-reservations.d.ts +1 -0
  213. package/dist/pipeline/pre-transaction-reservations.js +9 -3
  214. package/dist/pipeline/projection-rebuild.js +2 -1
  215. package/dist/schema-cli/apply-command.d.ts +2 -0
  216. package/dist/schema-cli/apply-command.js +97 -0
  217. package/dist/schema-cli/baseline-command.d.ts +2 -0
  218. package/dist/schema-cli/baseline-command.js +33 -0
  219. package/dist/schema-cli/generate-command.d.ts +2 -0
  220. package/dist/schema-cli/generate-command.js +90 -0
  221. package/dist/schema-cli/shared.d.ts +39 -0
  222. package/dist/schema-cli/shared.js +95 -0
  223. package/dist/schema-cli/status-command.d.ts +2 -0
  224. package/dist/schema-cli/status-command.js +73 -0
  225. package/dist/schema-cli/validate-boot-input.d.ts +5 -0
  226. package/dist/schema-cli/validate-boot-input.js +130 -0
  227. package/dist/schema-cli/validate-command.d.ts +2 -0
  228. package/dist/schema-cli/validate-command.js +130 -0
  229. package/dist/schema-cli.d.ts +2 -18
  230. package/dist/schema-cli.js +47 -604
  231. package/dist/search/meilisearch-adapter.js +2 -1
  232. package/dist/upgrade-cli.js +149 -121
  233. package/package.json +4 -4
  234. package/src/changes.json +310 -0
  235. package/src/scripts/codemod/feature-layout.ts +10 -2
  236. package/src/scripts/codemod/object-form.ts +41 -4
  237. package/src/scripts/codemod/package-merge.ts +58 -8
  238. package/src/scripts/codemod/pii-personal-migration.ts +175 -145
  239. package/src/scripts/codemod/testing-injection.ts +122 -42
  240. package/src/scripts/codemod/testing-move.ts +29 -7
@@ -77,139 +77,230 @@ function embeddedSubFieldToZod(subField) {
77
77
  assertUnreachable(subField, "embedded sub-field type");
78
78
  }
79
79
  }
80
- // kumiko-lint-ignore complexity-budget field→zod switch is the single schema source of truth
80
+ function textFieldToZod(field, { applyDefaults, isUpdate }) {
81
+ let schema = z.string();
82
+ if (field.maxLength)
83
+ schema = schema.max(field.maxLength);
84
+ if (field.minLength) {
85
+ // "" must stay valid for optional fields and writeOnly updates (""
86
+ // = unchanged); `required` rejects it separately via min(1) below.
87
+ const minLength = field.minLength;
88
+ schema = schema.refine((value) => value.length === 0 || value.length >= minLength, {
89
+ message: `at least ${minLength} characters`,
90
+ });
91
+ }
92
+ if (field.writeOnly === true) {
93
+ // Update: "" = unchanged (dropped before the executor), null = clear,
94
+ // unless required. Create: "" would leave a required field unset.
95
+ if (!isUpdate)
96
+ return field.required ? schema.min(1) : schema;
97
+ return field.required ? schema : schema.nullable();
98
+ }
99
+ if (field.format === "email")
100
+ schema = schema.email();
101
+ if (field.format === "url")
102
+ schema = schema.url();
103
+ if (field.format === "iban") {
104
+ // "" stays valid for an optional field; `required` rejects it via min(1) below.
105
+ schema = schema.superRefine((value, ctx) => {
106
+ if (value !== "" && !isValidIban(value)) {
107
+ ctx.addIssue({ code: "invalid_format", format: "iban", input: value });
108
+ }
109
+ });
110
+ }
111
+ if (field.format === "month") {
112
+ // "" stays valid for an optional field; `required` rejects it via min(1) below.
113
+ schema = schema.refine((value) => value === "" || MONTH_PATTERN.test(value), {
114
+ message: "expected a month as YYYY-MM",
115
+ });
116
+ }
117
+ if (field.required)
118
+ schema = schema.min(1);
119
+ return field.default !== undefined && applyDefaults ? schema.default(field.default) : schema;
120
+ }
121
+ function longTextFieldToZod(field, applyDefaults) {
122
+ // longText has no `format` variant (by type design). Only optional
123
+ // maxLength and required are supported; otherwise it is an unrestricted
124
+ // z.string().
125
+ let schema = z.string();
126
+ if (field.maxLength)
127
+ schema = schema.max(field.maxLength);
128
+ if (field.required)
129
+ schema = schema.min(1);
130
+ return field.default !== undefined && applyDefaults ? schema.default(field.default) : schema;
131
+ }
132
+ function selectFieldToZod(field, applyDefaults) {
133
+ const [first, ...rest] = field.options;
134
+ if (!first)
135
+ return z.string();
136
+ const enumSchema = z.enum([first, ...rest]);
137
+ if (field.default !== undefined) {
138
+ // Untouched <select> sends "" too; with a default that maps to the
139
+ // default (same semantics as undefined) instead of the invalid-value
140
+ // rejection. A field with a default is never "unset" —
141
+ // true on both insert AND update, so this branch (and its "" → default
142
+ // mapping) fires regardless of applyDefaults; only the `.default(...)`
143
+ // schema-level fallback for OMITTED input is update-gated below.
144
+ // `null` maps the same way: the no-default branch below normalizes
145
+ // an untouched select to null, and a client that reuses that value
146
+ // against a defaulted field must not get rejected either.
147
+ const mapped = z.preprocess((value) => (value === "" || value === null ? field.default : value), enumSchema);
148
+ return applyDefaults ? mapped.default(field.default) : mapped;
149
+ }
150
+ if (field.required)
151
+ return enumSchema;
152
+ // Optional select without a default: an untouched HTML <select> submits
153
+ // "" for its placeholder option. Treat that as "unset" (null) instead of
154
+ // an invalid enum value — null (not undefined) so the value survives the
155
+ // JSON-serialized event payload and an update can actually clear a
156
+ // previously-set select back to unset, not just skip validation.
157
+ return z.preprocess((value) => (value === "" ? null : value), enumSchema.nullable());
158
+ }
159
+ function multiSelectFieldToZod(field, applyDefaults) {
160
+ const [first, ...rest] = field.options;
161
+ if (!first)
162
+ return z.array(z.string());
163
+ // `required: true` means non-empty, as with a `text` field.
164
+ // An empty array is rejected; the global `.optional()` wrapping
165
+ // in buildInsertSchema determines whether the field may be omitted.
166
+ let schema = z.array(z.enum([first, ...rest]));
167
+ if (field.required)
168
+ schema = schema.min(1);
169
+ return field.default !== undefined && applyDefaults ? schema.default([...field.default]) : schema;
170
+ }
171
+ function numberFieldToZod(field, applyDefaults) {
172
+ let schema = z.number();
173
+ // `integer: true` maps to a Postgres int4 column (entity-table-meta.ts)
174
+ // — bound it here so an out-of-range write fails loud (400) at the
175
+ // schema boundary instead of dying in Postgres (22003 → 500).
176
+ if (field.integer)
177
+ schema = schema.int().min(-2147483648).max(2147483647);
178
+ if (field.min !== undefined)
179
+ schema = schema.min(field.min);
180
+ if (field.max !== undefined)
181
+ schema = schema.max(field.max);
182
+ return field.default !== undefined && applyDefaults ? schema.default(field.default) : schema;
183
+ }
184
+ function decimalFieldToZod(field, applyDefaults) {
185
+ // Stored as numeric(precision, scale), surfaced as JS number. Bound the
186
+ // value at the write boundary so an over-range or over-scale input fails
187
+ // loud here instead of being silently rounded/rejected by Postgres.
188
+ const limit = 10 ** (field.precision - field.scale);
189
+ const schema = z
190
+ .number()
191
+ .gt(-limit)
192
+ .lt(limit)
193
+ .refine((n) => isRepresentableAtScale(n, field.scale), {
194
+ message: `at most ${field.scale} decimal places`,
195
+ });
196
+ return field.default !== undefined && applyDefaults ? schema.default(field.default) : schema;
197
+ }
198
+ function moneyFieldToZod(currencies) {
199
+ const [first, ...rest] = currencies;
200
+ if (!first)
201
+ throw new Error("No currencies configured");
202
+ return z.object({
203
+ amount: z.number(),
204
+ currency: z.enum([first, ...rest]),
205
+ });
206
+ }
207
+ function embeddedFieldToZod(field) {
208
+ const shape = {};
209
+ for (const [subName, subField] of Object.entries(field.schema)) {
210
+ const zodSub = embeddedSubFieldToZod(subField);
211
+ shape[subName] = subField.required ? zodSub : zodSub.optional();
212
+ }
213
+ const baseRow = z.object(shape);
214
+ const derived = field.derived;
215
+ // The server is the authority for derived cells: a row is recomputed
216
+ // here, overwriting whatever the client sent, instead of merely
217
+ // being checked against it.
218
+ const row = derived === undefined
219
+ ? baseRow
220
+ : z.preprocess((value) => withDerivedCells(value, derived, field.schema), baseRow);
221
+ if (field.multiple !== true)
222
+ return row;
223
+ // `required: true` means non-empty, same reading as multiSelect —
224
+ // whether the key may be omitted at all is decided by buildInsertSchema
225
+ // off the same flag. `minItems` overrides that default when set.
226
+ let list = z.array(row);
227
+ if (field.maxItems !== undefined)
228
+ list = list.max(field.maxItems);
229
+ const min = field.minItems ?? (field.required === true ? 1 : undefined);
230
+ if (min === undefined)
231
+ return list;
232
+ // A custom issue rather than min(): other arrays (multiSelect) share zod's
233
+ // too_small and must not read "rows"; the bridge honours params.i18nKey.
234
+ // The refinement is invisible to z.toJSONSchema; the meta keeps minItems for agent manifests.
235
+ return list.meta({ minItems: min }).superRefine((rows, ctx) => {
236
+ // skip: the minimum is met, nothing to report
237
+ if (rows.length >= min)
238
+ return;
239
+ ctx.addIssue({
240
+ code: "custom",
241
+ input: rows,
242
+ params: {
243
+ i18nKey: `errors.validation.list_min_rows.${min === 1 ? "one" : "other"}`,
244
+ minimum: min,
245
+ },
246
+ });
247
+ });
248
+ }
249
+ function timestampFieldToZod(field) {
250
+ // When locatedBy is set: wall-clock time without an offset (ISO datetime
251
+ // without `Z`). Otherwise: ISO UTC datetime (with `Z`). Both are validated
252
+ // against the ISO-8601 schema through z.iso.datetime; precision (with or
253
+ // without an offset) depends on locatedBy.
254
+ const schema = field.locatedBy !== undefined ? z.iso.datetime({ local: true }) : z.iso.datetime();
255
+ return withDateBounds(schema, field.min, field.max);
256
+ }
257
+ function locatedTimestampToZod() {
258
+ // Combined wall-clock and time-zone object. On write, either
259
+ // { at, tz } (typical for a UI form, with utc calculated) or { utc, tz }
260
+ // (typical for server-to-server communication, with at calculated) is
261
+ // accepted. On read, the read wrapper provides all three fields.
262
+ //
263
+ // The schema guarantees only tz plus at least one of at or utc.
264
+ const at = z.iso.datetime({ local: true });
265
+ const tz = z.string().refine(isValidIanaTimeZone, { message: "invalid IANA time zone" });
266
+ const utc = z.iso.datetime();
267
+ return z.union([
268
+ z.object({ at, tz, utc: utc.optional() }),
269
+ z.object({ utc, tz, at: at.optional() }),
270
+ ]);
271
+ }
272
+ function fileListFieldToZod(field) {
273
+ // Multi file: array of fileRef UUIDs. Same story as the singular
274
+ // variant — the element type has to match the UUID column on
275
+ // fileRefsTable.id.
276
+ const ids = z.array(z.uuid());
277
+ return field.maxCount === undefined ? ids : ids.max(field.maxCount);
278
+ }
81
279
  export function fieldToZod(field, currencies, opts = {}) {
82
280
  // Insert callers want `.default(...)` applied so an omitted field falls
83
281
  // back to it; buildUpdateSchema passes applyDefaults: false so an omitted
84
282
  // field on update stays omitted (a `{ title }` patch must not clobber
85
283
  // other columns with their defaults) while a field's own default value is
86
- // still known here for "" → default mapping (select case below).
284
+ // still known here for "" → default mapping (selectFieldToZod).
87
285
  const applyDefaults = opts.applyDefaults ?? true;
286
+ const ctx = { applyDefaults, isUpdate: opts.isUpdate === true };
88
287
  switch (field.type) {
89
- case "text": {
90
- let schema = z.string();
91
- if (field.maxLength)
92
- schema = schema.max(field.maxLength);
93
- if (field.minLength) {
94
- // "" must stay valid for optional fields and writeOnly updates (""
95
- // = unchanged); `required` rejects it separately via min(1) below.
96
- const minLength = field.minLength;
97
- schema = schema.refine((value) => value.length === 0 || value.length >= minLength, {
98
- message: `at least ${minLength} characters`,
99
- });
100
- }
101
- if (field.writeOnly === true) {
102
- // Update: "" = unchanged (dropped before the executor), null = clear,
103
- // unless required. Create: "" would leave a required field unset.
104
- if (opts.isUpdate !== true)
105
- return field.required ? schema.min(1) : schema;
106
- return field.required ? schema : schema.nullable();
107
- }
108
- if (field.format === "email")
109
- schema = schema.email();
110
- if (field.format === "url")
111
- schema = schema.url();
112
- if (field.format === "iban") {
113
- // "" stays valid for an optional field; `required` rejects it via min(1) below.
114
- schema = schema.superRefine((value, ctx) => {
115
- if (value !== "" && !isValidIban(value)) {
116
- ctx.addIssue({ code: "invalid_format", format: "iban", input: value });
117
- }
118
- });
119
- }
120
- if (field.format === "month") {
121
- // "" stays valid for an optional field; `required` rejects it via min(1) below.
122
- schema = schema.refine((value) => value === "" || MONTH_PATTERN.test(value), {
123
- message: "expected a month as YYYY-MM",
124
- });
125
- }
126
- if (field.required)
127
- schema = schema.min(1);
128
- return field.default !== undefined && applyDefaults ? schema.default(field.default) : schema;
129
- }
130
- case "longText": {
131
- // longText has no `format` variant (by type design). Only optional
132
- // maxLength and required are supported; otherwise it is an unrestricted
133
- // z.string().
134
- let schema = z.string();
135
- if (field.maxLength)
136
- schema = schema.max(field.maxLength);
137
- if (field.required)
138
- schema = schema.min(1);
139
- return field.default !== undefined && applyDefaults ? schema.default(field.default) : schema;
140
- }
288
+ case "text":
289
+ return textFieldToZod(field, ctx);
290
+ case "longText":
291
+ return longTextFieldToZod(field, applyDefaults);
141
292
  case "boolean": {
142
293
  const schema = z.boolean();
143
294
  return field.default !== undefined && applyDefaults ? schema.default(field.default) : schema;
144
295
  }
145
- case "select": {
146
- const [first, ...rest] = field.options;
147
- if (!first)
148
- return z.string();
149
- const enumSchema = z.enum([first, ...rest]);
150
- if (field.default !== undefined) {
151
- // Untouched <select> sends "" too; with a default that maps to the
152
- // default (same semantics as undefined) instead of the invalid-value
153
- // rejection. A field with a default is never "unset" —
154
- // true on both insert AND update, so this branch (and its "" → default
155
- // mapping) fires regardless of applyDefaults; only the `.default(...)`
156
- // schema-level fallback for OMITTED input is update-gated below.
157
- // `null` maps the same way: the no-default branch below normalizes
158
- // an untouched select to null, and a client that reuses that value
159
- // against a defaulted field must not get rejected either.
160
- const mapped = z.preprocess((value) => (value === "" || value === null ? field.default : value), enumSchema);
161
- return applyDefaults ? mapped.default(field.default) : mapped;
162
- }
163
- if (field.required)
164
- return enumSchema;
165
- // Optional select without a default: an untouched HTML <select> submits
166
- // "" for its placeholder option. Treat that as "unset" (null) instead of
167
- // an invalid enum value — null (not undefined) so the value survives the
168
- // JSON-serialized event payload and an update can actually clear a
169
- // previously-set select back to unset, not just skip validation.
170
- return z.preprocess((value) => (value === "" ? null : value), enumSchema.nullable());
171
- }
172
- case "multiSelect": {
173
- const [first, ...rest] = field.options;
174
- if (!first)
175
- return z.array(z.string());
176
- // `required: true` means non-empty, as with a `text` field.
177
- // An empty array is rejected; the global `.optional()` wrapping
178
- // in buildInsertSchema determines whether the field may be omitted.
179
- let schema = z.array(z.enum([first, ...rest]));
180
- if (field.required)
181
- schema = schema.min(1);
182
- return field.default !== undefined && applyDefaults
183
- ? schema.default([...field.default])
184
- : schema;
185
- }
186
- case "number": {
187
- let schema = z.number();
188
- // `integer: true` maps to a Postgres int4 column (entity-table-meta.ts)
189
- // — bound it here so an out-of-range write fails loud (400) at the
190
- // schema boundary instead of dying in Postgres (22003 → 500).
191
- if (field.integer)
192
- schema = schema.int().min(-2147483648).max(2147483647);
193
- if (field.min !== undefined)
194
- schema = schema.min(field.min);
195
- if (field.max !== undefined)
196
- schema = schema.max(field.max);
197
- return field.default !== undefined && applyDefaults ? schema.default(field.default) : schema;
198
- }
199
- case "decimal": {
200
- // Stored as numeric(precision, scale), surfaced as JS number. Bound the
201
- // value at the write boundary so an over-range or over-scale input fails
202
- // loud here instead of being silently rounded/rejected by Postgres.
203
- const limit = 10 ** (field.precision - field.scale);
204
- const schema = z
205
- .number()
206
- .gt(-limit)
207
- .lt(limit)
208
- .refine((n) => isRepresentableAtScale(n, field.scale), {
209
- message: `at most ${field.scale} decimal places`,
210
- });
211
- return field.default !== undefined && applyDefaults ? schema.default(field.default) : schema;
212
- }
296
+ case "select":
297
+ return selectFieldToZod(field, applyDefaults);
298
+ case "multiSelect":
299
+ return multiSelectFieldToZod(field, applyDefaults);
300
+ case "number":
301
+ return numberFieldToZod(field, applyDefaults);
302
+ case "decimal":
303
+ return decimalFieldToZod(field, applyDefaults);
213
304
  case "bigInt": {
214
305
  // JS-`number` round-trip via mode:"number"; exact up to 2^53.
215
306
  // The safe-integer cap ensures that a caller passing a float (for example,
@@ -218,111 +309,36 @@ export function fieldToZod(field, currencies, opts = {}) {
218
309
  const schema = z.number().int().safe();
219
310
  return field.default !== undefined && applyDefaults ? schema.default(field.default) : schema;
220
311
  }
221
- case "money": {
222
- const [first, ...rest] = currencies;
223
- if (!first)
224
- throw new Error("No currencies configured");
225
- return z.object({
226
- amount: z.number(),
227
- currency: z.enum([first, ...rest]),
228
- });
229
- }
230
- case "embedded": {
231
- const shape = {};
232
- for (const [subName, subField] of Object.entries(field.schema)) {
233
- const zodSub = embeddedSubFieldToZod(subField);
234
- shape[subName] = subField.required ? zodSub : zodSub.optional();
235
- }
236
- const baseRow = z.object(shape);
237
- const derived = field.derived;
238
- // The server is the authority for derived cells: a row is recomputed
239
- // here, overwriting whatever the client sent, instead of merely
240
- // being checked against it.
241
- const row = derived === undefined
242
- ? baseRow
243
- : z.preprocess((value) => withDerivedCells(value, derived, field.schema), baseRow);
244
- if (field.multiple !== true)
245
- return row;
246
- // `required: true` means non-empty, same reading as multiSelect —
247
- // whether the key may be omitted at all is decided by buildInsertSchema
248
- // off the same flag. `minItems` overrides that default when set.
249
- let list = z.array(row);
250
- if (field.maxItems !== undefined)
251
- list = list.max(field.maxItems);
252
- const min = field.minItems ?? (field.required === true ? 1 : undefined);
253
- if (min === undefined)
254
- return list;
255
- // A custom issue rather than min(): other arrays (multiSelect) share zod's
256
- // too_small and must not read "rows"; the bridge honours params.i18nKey.
257
- return list.superRefine((rows, ctx) => {
258
- // skip: the minimum is met, nothing to report
259
- if (rows.length >= min)
260
- return;
261
- ctx.addIssue({
262
- code: "custom",
263
- input: rows,
264
- params: {
265
- i18nKey: `errors.validation.list_min_rows.${min === 1 ? "one" : "other"}`,
266
- minimum: min,
267
- },
268
- });
269
- });
270
- }
271
- case "jsonb": {
312
+ case "money":
313
+ return moneyFieldToZod(currencies);
314
+ case "embedded":
315
+ return embeddedFieldToZod(field);
316
+ case "jsonb":
272
317
  // Free-form jsonb — keys are tenant-/runtime-defined. Validation
273
318
  // passes through any plain object.
274
319
  return z.record(z.string(), z.unknown());
275
- }
276
- case "date": {
277
- const schema = z.string().date();
278
- return withDateBounds(schema, field.min, field.max);
279
- }
280
- case "timestamp": {
281
- // When locatedBy is set: wall-clock time without an offset (ISO datetime
282
- // without `Z`). Otherwise: ISO UTC datetime (with `Z`). Both are validated
283
- // against the ISO-8601 schema through z.iso.datetime; precision (with or
284
- // without an offset) depends on locatedBy.
285
- const schema = field.locatedBy !== undefined ? z.iso.datetime({ local: true }) : z.iso.datetime();
286
- return withDateBounds(schema, field.min, field.max);
287
- }
288
- case "tz": {
320
+ case "date":
321
+ return withDateBounds(z.string().date(), field.min, field.max);
322
+ case "timestamp":
323
+ return timestampFieldToZod(field);
324
+ case "tz":
289
325
  // IANA time-zone name, validated against the runtime's zone list
290
326
  // (isValidIanaTimeZone). An invalid name fails at the write boundary
291
327
  // instead of later in ctx.tz.parse / Temporal.
292
328
  return z.string().refine(isValidIanaTimeZone, { message: "invalid IANA time zone" });
293
- }
294
- case "locatedTimestamp": {
295
- // Combined wall-clock and time-zone object. On write, either
296
- // { at, tz } (typical for a UI form, with utc calculated) or { utc, tz }
297
- // (typical for server-to-server communication, with at calculated) is
298
- // accepted. On read, the read wrapper provides all three fields.
299
- //
300
- // The schema guarantees only tz plus at least one of at or utc.
301
- const at = z.iso.datetime({ local: true });
302
- const tz = z.string().refine(isValidIanaTimeZone, { message: "invalid IANA time zone" });
303
- const utc = z.iso.datetime();
304
- return z.union([
305
- z.object({ at, tz, utc: utc.optional() }),
306
- z.object({ utc, tz, at: at.optional() }),
307
- ]);
308
- }
329
+ case "locatedTimestamp":
330
+ return locatedTimestampToZod();
309
331
  case "file":
310
- case "image": {
332
+ case "image":
311
333
  // Single file: stores a fileRef UUID — must match fileRefsTable.id
312
334
  // (uuid column). Pre-fix this was z.number() from an era when the
313
335
  // column was (wrongly) integer; the table-builder fix to uuid needs
314
336
  // a matching validation-layer fix here or the CRUD pipeline rejects
315
337
  // every valid UUID with "expected number".
316
338
  return z.uuid();
317
- }
318
339
  case "files":
319
- case "images": {
320
- // Multi file: array of fileRef UUIDs. Same story as the singular
321
- // variant — the element type has to match the UUID column on
322
- // fileRefsTable.id.
323
- const ids = z.array(z.uuid());
324
- return field.maxCount === undefined ? ids : ids.max(field.maxCount);
325
- }
340
+ case "images":
341
+ return fileListFieldToZod(field);
326
342
  case "reference":
327
343
  // Validates the UUID shape. Checking that the reference exists
328
344
  // (the row exists in the referenced table and belongs to the tenant) is
@@ -92,7 +92,7 @@ export function normalizeListColumn(c) {
92
92
  process.env.NODE_ENV !== "production" &&
93
93
  col.renderer !== undefined &&
94
94
  typeof col.renderer === "function") {
95
- log.warn(`normalizeListColumn: Feld "${col.field}" hat einen Funktions-Renderer — dieser wird von JSON.stringify verworfen. Bitte auf FormatSpec ({ format: "..." }) migrieren.`);
95
+ log.warn(`normalizeListColumn: field "${col.field}" has a function renderer, which JSON.stringify drops. Migrate it to a FormatSpec ({ format: "..." }).`);
96
96
  }
97
97
  return col;
98
98
  }
@@ -0,0 +1,7 @@
1
+ import type { ScreenDefinition } from "./types/screen.ts";
2
+ export type ScreenKind = ScreenDefinition["type"];
3
+ export type ScreenOfKind = {
4
+ readonly [K in ScreenKind]: Extract<ScreenDefinition, {
5
+ readonly type: K;
6
+ }>;
7
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -11,42 +11,79 @@
11
11
  // gone it also drops every action, redirect, nav and workspace member that
12
12
  // pointed at them. The one reference it does not resolve is a nav `parent`
13
13
  // pointing at a missing nav; that is pruned here.
14
+ import { createFallbackLogger, scrubLogText } from "../logging/index.js";
14
15
  import { resolveScreenTargetQn } from "./boot-validator/screens.js";
15
16
  import { isToggleableFeature } from "./effective-features.js";
17
+ import { isPureGroupingNav } from "./project-app-schema-for-roles.js";
16
18
  import { isValidQn, qualifyEntityName } from "./qualified-name.js";
19
+ const schemaLogger = createFallbackLogger("app-schema");
17
20
  // Sorted, so the result doubles as a stable cache key. The trial gate is
18
21
  // consulted like the dispatcher's feature gate does, but only for toggleable
19
22
  // features: a trial can only unlock tier (toggleable) features, so a
20
- // cascade-disabled always-on feature needs no gate call.
23
+ // cascade-disabled always-on feature needs no gate call. A failing gate counts
24
+ // as "not unlocked" (the handlers answer 403 then anyway), so a gate outage
25
+ // hides the add-on instead of taking the whole schema endpoint down.
21
26
  export async function resolveDisabledFeatureNames(registry, effectiveFeatures, tenantId) {
22
27
  const enabled = effectiveFeatures(tenantId);
23
- const disabled = [];
24
- for (const [featureName, feature] of registry.features) {
25
- if (enabled.has(featureName))
26
- continue;
27
- if (isToggleableFeature(feature) &&
28
- (await effectiveFeatures.trialGate?.(tenantId, featureName))) {
29
- continue;
28
+ const trialGate = effectiveFeatures.trialGate;
29
+ const disabled = [...registry.features].filter(([featureName]) => !enabled.has(featureName));
30
+ const unlockedByTrial = await Promise.all(disabled.map(async ([featureName, feature]) => {
31
+ if (trialGate === undefined || !isToggleableFeature(feature))
32
+ return false;
33
+ try {
34
+ return await trialGate(tenantId, featureName);
30
35
  }
31
- disabled.push(featureName);
32
- }
33
- return disabled.sort();
36
+ catch (error) {
37
+ schemaLogger.warn("trial gate failed, treating feature as not unlocked", {
38
+ featureName,
39
+ error: scrubLogText(error instanceof Error ? error.message : String(error)),
40
+ });
41
+ return false;
42
+ }
43
+ }));
44
+ return disabled
45
+ .filter((_, index) => !unlockedByTrial[index])
46
+ .map(([featureName]) => featureName)
47
+ .sort();
34
48
  }
35
49
  function navParentQn(featureName, parent) {
36
50
  return isValidQn(parent) ? parent : qualifyEntityName(featureName, "nav", parent);
37
51
  }
38
- // Navs of enabled features whose parent chain ends in a removed nav.
52
+ function navQnsByParent(features) {
53
+ const childrenByParent = new Map();
54
+ for (const feature of features) {
55
+ for (const nav of feature.navs ?? []) {
56
+ if (nav.parent === undefined)
57
+ continue;
58
+ const parentQn = navParentQn(feature.featureName, nav.parent);
59
+ const children = childrenByParent.get(parentQn) ?? [];
60
+ children.push(qualifyEntityName(feature.featureName, "nav", nav.id));
61
+ childrenByParent.set(parentQn, children);
62
+ }
63
+ }
64
+ return childrenByParent;
65
+ }
66
+ // Navs of enabled features whose parent chain ends in a removed nav, plus pure grouping
67
+ // navs whose children are all removed (an empty section the role projection cannot drop,
68
+ // because it only drops groups that still have children).
39
69
  function collectOrphanedNavQns(features, removedNavQns) {
40
70
  const orphaned = new Set();
71
+ const childrenByParent = navQnsByParent(features);
41
72
  let changed = true;
42
73
  while (changed) {
43
74
  changed = false;
44
75
  for (const feature of features) {
45
76
  for (const nav of feature.navs ?? []) {
46
77
  const qn = qualifyEntityName(feature.featureName, "nav", nav.id);
47
- if (removedNavQns.has(qn) || nav.parent === undefined)
78
+ if (removedNavQns.has(qn))
48
79
  continue;
49
- if (removedNavQns.has(navParentQn(feature.featureName, nav.parent))) {
80
+ const parentRemoved = nav.parent !== undefined &&
81
+ removedNavQns.has(navParentQn(feature.featureName, nav.parent));
82
+ const children = childrenByParent.get(qn) ?? [];
83
+ const emptiedGroup = isPureGroupingNav(nav) &&
84
+ children.length > 0 &&
85
+ children.every((childQn) => removedNavQns.has(childQn));
86
+ if (parentRemoved || emptiedGroup) {
50
87
  removedNavQns.add(qn);
51
88
  orphaned.add(qn);
52
89
  changed = true;
@@ -24,6 +24,7 @@
24
24
  // statically; today it lives in this comment + the StepBuilder doc.
25
25
  import { KUMIKO_META_SYMBOL } from "@cosmicdrift/kumiko-types/schema-table-types";
26
26
  import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
27
+ import { resolveTableName } from "../db/entity-table-meta.js";
27
28
  import { schemaTableName } from "../db/table-introspection.js";
28
29
  import { getStep } from "./define-step.js";
29
30
  import { buildPipelineSteps } from "./pipeline.js";
@@ -79,7 +80,7 @@ export function validateProjectionAllowlist(features) {
79
80
  const aggregateTables = new Map();
80
81
  for (const f of features) {
81
82
  for (const [entityName, entity] of Object.entries(f.entities ?? {})) {
82
- const tableName = entity.table ?? entityName;
83
+ const tableName = resolveTableName(entityName, entity, undefined);
83
84
  const existing = aggregateTables.get(tableName);
84
85
  if (existing && existing !== f.name) {
85
86
  throw new Error(`Aggregate-table "${tableName}" is registered by both feature "${existing}" and feature "${f.name}" via r.entity. ` +
@@ -10,7 +10,7 @@ import type { Lifecycle } from "../lifecycle/index.ts";
10
10
  import type { ObservabilityProvider } from "../observability/index.ts";
11
11
  import type { Dispatcher, DispatcherOptions } from "../pipeline/dispatcher.ts";
12
12
  import type { EventDedup, EventDispatcher } from "../pipeline/index.ts";
13
- import type { SystemHooks } from "../pipeline/lifecycle-pipeline.ts";
13
+ import type { LifecycleHooks, SystemHooks } from "../pipeline/lifecycle-pipeline.ts";
14
14
  export type BaseEntrypointOptions = {
15
15
  readonly registry: Registry;
16
16
  readonly context: AppContext;
@@ -21,6 +21,7 @@ export type BaseEntrypointOptions = {
21
21
  readonly systemHooks?: SystemHooks;
22
22
  readonly eventDedup?: EventDedup;
23
23
  readonly lifecycle?: Lifecycle;
24
+ readonly instanceId?: ServerOptions["instanceId"];
24
25
  };
25
26
  type JobsBlock = {
26
27
  readonly redisUrl: string;
@@ -49,6 +50,7 @@ export type ApiEntrypointOptions = BaseEntrypointOptions & {
49
50
  readonly runLocal?: boolean;
50
51
  };
51
52
  readonly trustedProxyHops?: ServerOptions["trustedProxyHops"];
53
+ readonly errorDocs?: ServerOptions["errorDocs"];
52
54
  };
53
55
  export type WorkerEntrypointOptions = BaseEntrypointOptions & JobsBlock & {
54
56
  readonly eventDispatcher?: ServerOptions["eventDispatcher"];
@@ -61,6 +63,7 @@ export type ApiEntrypoint = {
61
63
  readonly lifecycle: Lifecycle;
62
64
  readonly observability: ObservabilityProvider;
63
65
  readonly dispatcher: Dispatcher;
66
+ readonly lifecycleHooks: LifecycleHooks;
64
67
  readonly eventDispatcher?: EventDispatcher;
65
68
  readonly mode: "api";
66
69
  start(): Promise<void>;