@cosmicdrift/kumiko-framework 0.391.0 → 0.392.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 (47) hide show
  1. package/agents/examples/apex-landing.md +1 -1
  2. package/agents/examples/apex-surface-auth.md +35 -35
  3. package/agents/examples/compliance-profiles-demo.md +4 -4
  4. package/agents/examples/crypto-shredding-hr.md +1 -1
  5. package/agents/examples/designer-demo.md +6 -6
  6. package/agents/examples/e2e-generator.md +18 -18
  7. package/agents/examples/encrypted-tenant-config.md +31 -31
  8. package/agents/examples/files-post-processing.md +1 -1
  9. package/agents/examples/inbound-mail-triage.md +1 -1
  10. package/agents/examples/lane-routing.md +5 -5
  11. package/agents/examples/legal-pages.md +36 -36
  12. package/agents/examples/record-detail-layout.md +41 -8
  13. package/agents/examples/seed-migration.md +44 -44
  14. package/agents/examples/user-data-rights.md +21 -21
  15. package/agents/examples/user-profile.md +5 -5
  16. package/agents/examples/wizard-form.md +1 -1
  17. package/agents/features.md +3 -3
  18. package/dist/db/entity-column-meta.d.ts +3 -0
  19. package/dist/db/entity-column-meta.js +113 -0
  20. package/dist/db/entity-table-indexes.d.ts +7 -0
  21. package/dist/db/entity-table-indexes.js +124 -0
  22. package/dist/db/entity-table-meta.js +1 -153
  23. package/dist/db/migrate-change-render.d.ts +40 -0
  24. package/dist/db/migrate-change-render.js +89 -0
  25. package/dist/db/migrate-generator.d.ts +5 -37
  26. package/dist/db/migrate-generator.js +99 -171
  27. package/dist/db/row-level-security-boot-check.js +4 -4
  28. package/dist/db/table-builder.d.ts +1 -1
  29. package/dist/db/table-builder.js +42 -133
  30. package/dist/engine/boot-validator/query-refs.js +22 -9
  31. package/dist/engine/boot-validator/screens-actions.js +11 -8
  32. package/dist/engine/boot-validator/screens-kind-projection-detail.js +8 -0
  33. package/dist/engine/build-app-schema.js +5 -0
  34. package/dist/engine/extensions/user-data.js +0 -2
  35. package/dist/engine/project-query-live-entities.d.ts +2 -0
  36. package/dist/engine/project-query-live-entities.js +11 -0
  37. package/dist/engine/strip-disabled-features-from-app-schema.js +1 -1
  38. package/dist/env/index.d.ts +1 -0
  39. package/dist/env/index.js +1 -0
  40. package/dist/env/required-env.d.ts +2 -0
  41. package/dist/env/required-env.js +9 -0
  42. package/dist/i18n/required-surface-keys.js +4 -0
  43. package/dist/migrations/kumiko-drift.js +4 -4
  44. package/dist/ui-types/app-schema.d.ts +1 -0
  45. package/dist/ui-types/index.d.ts +1 -1
  46. package/package.json +3 -3
  47. package/src/changes.json +42 -0
@@ -42,17 +42,17 @@ async function assertAllTenantTablesProtected(db, knownTables, scope) {
42
42
  const sections = [
43
43
  ...(frameworkTables.length > 0
44
44
  ? [
45
- `framework tables without it: ${listingOf(frameworkTables)}. Run \`kumiko schema apply\`: it adds the missing policies to the framework tables (\`kumiko schema generate\` reports no changes for them).`,
45
+ `framework tables without it: ${listingOf(frameworkTables)}. Run \`bun kumiko-schema apply\`: it adds the missing policies to the framework tables (\`bun kumiko-schema generate\` reports no changes for them).`,
46
46
  ]
47
47
  : []),
48
48
  ...(known.length > 0
49
49
  ? [
50
- `tables of this app without it: ${listingOf(known)}. Run \`kumiko schema generate\` and apply the migration.`,
50
+ `tables of this app without it: ${listingOf(known)}. Run \`bun kumiko-schema generate\` and apply the migration.`,
51
51
  ]
52
52
  : []),
53
53
  ...(orphaned.length > 0
54
54
  ? [
55
- `tables no feature declares anymore: ${listingOf(orphaned)}. No feature declares them: add a migration with DROP TABLE (enable the commented \`-- DESTRUCTIVE\` DROP that \`kumiko schema generate\` emitted; see \`kumiko schema status\`).`,
55
+ `tables no feature declares anymore: ${listingOf(orphaned)}. No feature declares them: add a migration with DROP TABLE (enable the commented \`-- DESTRUCTIVE\` DROP that \`bun kumiko-schema generate\` emitted; see \`bun kumiko-schema status\`).`,
56
56
  ]
57
57
  : []),
58
58
  ];
@@ -66,7 +66,7 @@ async function assertNoLiveRowLevelSecurity(db, knownTables, scope) {
66
66
  if (tables.length === 0)
67
67
  return;
68
68
  throw new InternalError({
69
- message: `rowLevelSecurity is "off" but row-level security is still enabled on: ${tables.join(", ")}. Without a tenant scope every statement would see zero rows. Run \`kumiko schema generate\` with rowLevelSecurity off to produce the DISABLE ROW LEVEL SECURITY migration and apply it.`,
69
+ message: `rowLevelSecurity is "off" but row-level security is still enabled on: ${tables.join(", ")}. Without a tenant scope every statement would see zero rows. Run \`bun kumiko-schema generate\` with rowLevelSecurity off to produce the DISABLE ROW LEVEL SECURITY migration and apply it.`,
70
70
  });
71
71
  }
72
72
  // Table state only, no role check: the role is a runtime property of the boot connection, so tools
@@ -7,7 +7,7 @@ import type { LocatedTimestampRead } from "./located-timestamp.ts";
7
7
  import type { MoneyRead } from "./money.ts";
8
8
  export { toSnakeCase } from "../utils/case.ts";
9
9
  import { type ColumnBuilder, type ColumnHandle, type SchemaTable, type TableColumns } from "./dialect.ts";
10
- export declare const SOFT_DELETE_LIVE_ROW_PREDICATE = "\"is_deleted\" = false";
10
+ export { SOFT_DELETE_LIVE_ROW_PREDICATE } from "./entity-table-indexes.ts";
11
11
  export declare function physicalColumnName(table: Table, field: string): string;
12
12
  export type AnyPgColumn = ColumnHandle;
13
13
  export declare const READ_MODEL_PREFIX = "read_";
@@ -1,12 +1,11 @@
1
1
  import { KUMIKO_META_SYMBOL } from "@cosmicdrift/kumiko-types/schema-table-types";
2
2
  import { toSnakeCase } from "../utils/case.js";
3
+ import { buildEntityIndexes } from "./entity-table-indexes.js";
3
4
  import { markExecutorManagedTable } from "./executor-managed-tables.js";
4
5
  import { columnHandleName } from "./table-introspection.js";
5
6
  export { toSnakeCase } from "../utils/case.js";
6
- import { bigint, boolean, decimalColumn, doublePrecision, index, instant, integer, isSqlExpression, jsonb, moneyAmount, table as pgTable, plainDate, serial, sql, text, uniqueIndex, uuid, } from "./dialect.js";
7
- // Partial-index predicate for soft-delete entities; table-builder and
8
- // entity-table-meta must render identical index definitions.
9
- export const SOFT_DELETE_LIVE_ROW_PREDICATE = `"is_deleted" = false`;
7
+ import { bigint, boolean, decimalColumn, doublePrecision, instant, integer, jsonb, moneyAmount, table as pgTable, plainDate, serial, sql, text, uuid, } from "./dialect.js";
8
+ export { SOFT_DELETE_LIVE_ROW_PREDICATE } from "./entity-table-indexes.js";
10
9
  // Shared by the CRUD executor and the parent-ref read-gate — a field's
11
10
  // declared column name if it has one, else its snake_case default.
12
11
  export function physicalColumnName(table, field) {
@@ -233,140 +232,50 @@ export function declareGlobalTenancy(table) {
233
232
  // @cast-boundary phantom-brand — TenancyBrand<"global"> is a phantom marker with no runtime representation.
234
233
  return table;
235
234
  }
235
+ // The featureName prefix is inserted between read_ and the base name so all
236
+ // read models consistently start with `read_`, whether or not featureName is set:
237
+ // featureName="shop", base="read_orders" → "read_shop_orders"
238
+ // featureName=undef, base="read_orders" → "read_orders"
239
+ // featureName="shop", base="orders" (no read_) → "shop_orders"
240
+ function entityTableName(entityName, entity, featureName) {
241
+ const baseTableName = entity.table ?? toTableName(entityName);
242
+ if (!featureName)
243
+ return baseTableName;
244
+ return baseTableName.startsWith(READ_MODEL_PREFIX)
245
+ ? `${READ_MODEL_PREFIX}${featureName}_${baseTableName.slice(READ_MODEL_PREFIX.length)}`
246
+ : `${featureName}_${baseTableName}`;
247
+ }
248
+ // Fields to index as foreign keys. Sources:
249
+ // (a) single-file / single-image fields store a fileRef id and are queried
250
+ // by that id whenever a detail view resolves attachments.
251
+ // (b) belongsTo relations declared via r.relation() — the FK column is the
252
+ // parent-side lookup key; without an index every child join scans the
253
+ // full table.
254
+ // `Set` keeps the list deduplicated when (a) and (b) name the same column.
255
+ function foreignKeyFieldNames(entity, relations) {
256
+ const names = new Set();
257
+ for (const [name, field] of Object.entries(entity.fields)) {
258
+ if (field.type === "file" || field.type === "image")
259
+ names.add(name);
260
+ }
261
+ for (const rel of Object.values(relations ?? {})) {
262
+ if (rel.type === "belongsTo")
263
+ names.add(rel.foreignKey);
264
+ }
265
+ return names;
266
+ }
236
267
  export function buildEntityTable(entityName, entity, options) {
237
268
  const baseColumns = buildBaseColumns(entity.softDelete ?? false, entity.idType ?? "uuid");
238
269
  const fieldColumns = {};
239
270
  for (const [name, field] of Object.entries(entity.fields)) {
240
- const cols = fieldToColumns(name, field, entity);
241
- Object.assign(fieldColumns, cols);
271
+ Object.assign(fieldColumns, fieldToColumns(name, field, entity));
242
272
  }
243
- // Default table name derived from entityName (e.g. "memberTask" → "read_member_tasks")
244
- const baseTableName = entity.table ?? toTableName(entityName);
245
- // The featureName prefix is inserted between read_ and the base name so
246
- // all read models consistently start with `read_`, whether or not
247
- // featureName is set. Examples:
248
- // featureName="shop", base="read_orders" → "read_shop_orders"
249
- // featureName=undef, base="read_orders" → "read_orders"
250
- // featureName="shop", base="orders" (no read_) → "shop_orders"
251
- const tableName = options?.featureName
252
- ? baseTableName.startsWith(READ_MODEL_PREFIX)
253
- ? `${READ_MODEL_PREFIX}${options.featureName}_${baseTableName.slice(READ_MODEL_PREFIX.length)}`
254
- : `${options.featureName}_${baseTableName}`
255
- : baseTableName;
256
- // Build the list of foreign-key columns to index. Sources:
257
- // (a) single-file / single-image fields store a fileRef id and are queried
258
- // by that id whenever a detail view resolves attachments.
259
- // (b) belongsTo relations declared via r.relation() — the FK column is the
260
- // parent-side lookup key; without an index every child join scans the
261
- // full table.
262
- // `Set` keeps the list deduplicated when (a) and (b) name the same column.
263
- const foreignKeyFields = new Set();
264
- for (const [name, field] of Object.entries(entity.fields)) {
265
- if (field.type === "file" || field.type === "image") {
266
- foreignKeyFields.add(name);
267
- }
268
- }
269
- if (options?.relations) {
270
- for (const rel of Object.values(options.relations)) {
271
- if (rel.type === "belongsTo")
272
- foreignKeyFields.add(rel.foreignKey);
273
- }
274
- }
275
- // Cast back to EntityTable<E>: drizzle-kit's pgTable returns a fully
276
- // inferred PgTableWithColumns over the *exact* column-builder map we
277
- // hand in. Our typed signature narrows that to the static names from
278
- // EntityDefinition (kept in sync with fieldToColumns + buildBaseColumns).
279
- // Drizzle's runtime instance carries every needed method on top.
280
- const built = pgTable(tableName, {
281
- ...baseColumns,
282
- ...fieldColumns,
283
- },
284
- // Every multi-tenant query filters by tenant_id. Without this index, list
285
- // queries scan the whole table across all tenants. Applies to every table
286
- // built via buildEntityTable since every entity inherits tenantId.
287
- (table) => {
288
- const indexes = {};
289
- const tHandle = table;
290
- indexes[`${tableName}_tenant_id_idx`] = index(`${tableName}_tenant_id_idx`).on(
291
- // biome-ignore lint/style/noNonNullAssertion: tenantId column always exists on entity tables
292
- tHandle["tenantId"]);
293
- for (const fieldName of foreignKeyFields) {
294
- const column = tHandle[fieldName];
295
- if (column) {
296
- indexes[`${tableName}_${toSnakeCase(fieldName)}_idx`] = index(`${tableName}_${toSnakeCase(fieldName)}_idx`).on(column);
297
- }
298
- }
299
- // lookupable fields: index the bidx column (kept in lockstep with
300
- // deriveEntityTableMeta).
301
- const bidxFieldByField = new Map();
302
- for (const [name, field] of Object.entries(entity.fields)) {
303
- if (field.type !== "text" || field.lookupable !== true)
304
- continue;
305
- bidxFieldByField.set(name, `${name}Bidx`);
306
- const column = tHandle[`${name}Bidx`];
307
- if (column) {
308
- const idxName = `${tableName}_${toSnakeCase(name)}_bidx_idx`;
309
- indexes[idxName] = index(idxName).on(column);
310
- }
311
- }
312
- // entity.indexes contains composite/unique indexes explicitly declared by
313
- // the author. Columns are addressed by field name (camelCase), and the
314
- // index name follows <table>_<col1>_<col2>_<unique|idx>; index.name can
315
- // override the convention.
316
- for (const def of entity.indexes ?? []) {
317
- const cols = def.columns
318
- .map((fieldName) => tHandle[fieldName])
319
- .filter((col) => col !== undefined);
320
- if (cols.length !== def.columns.length)
321
- continue;
322
- const suffix = def.unique === true ? "unique" : "idx";
323
- const indexName = def.name ?? `${tableName}_${def.columns.map((c) => toSnakeCase(c)).join("_")}_${suffix}`;
324
- const builder = def.unique === true ? uniqueIndex(indexName) : index(indexName);
325
- let chain = builder.on(...cols);
326
- // entity.indexes[].where is now a SqlExpression (was drizzle SQL).
327
- // Pass through to the IndexBuilderWithCols.where()-API.
328
- if (def.where !== undefined) {
329
- // A silently dropped where would build a full (unique) index that
330
- // entity-table-meta flags as needsManualWhere.
331
- if (!isSqlExpression(def.where)) {
332
- throw new Error(`[${tableName}] index "${indexName}": where must be a sql\`...\` expression`);
333
- }
334
- chain = chain.where(def.where);
335
- }
336
- else if (def.unique === true && entity.softDelete === true) {
337
- // Soft-deleted rows keep their value, so a full unique index would
338
- // permanently block reuse; kept in lock-step with entity-table-meta.ts.
339
- chain = chain.where(sql.raw(SOFT_DELETE_LIVE_ROW_PREDICATE));
340
- }
341
- indexes[indexName] = chain;
342
- // Partial bidx counterpart for unique indexes over lookupable fields
343
- // (kept in lockstep with deriveEntityTableMeta).
344
- if (def.unique === true && def.where === undefined) {
345
- const bidxFieldNames = def.columns.map((c) => bidxFieldByField.get(c) ?? c);
346
- if (bidxFieldNames.some((c, i) => c !== def.columns[i])) {
347
- const bidxCols = bidxFieldNames
348
- .map((c) => tHandle[c])
349
- .filter((col) => col !== undefined);
350
- if (bidxCols.length === bidxFieldNames.length) {
351
- const notNullParts = bidxFieldNames
352
- .filter((c, i) => c !== def.columns[i])
353
- .map((c) => `"${toSnakeCase(c)}" IS NOT NULL`);
354
- // Soft-deleted rows keep their bidx hash — without this, a
355
- // restored row can't reuse a value a soft-deleted sibling still
356
- // holds.
357
- const whereText = (entity.softDelete === true
358
- ? [...notNullParts, SOFT_DELETE_LIVE_ROW_PREDICATE]
359
- : notNullParts).join(" AND ");
360
- const partialWhere = sql.raw(whereText);
361
- indexes[`${indexName}_bidx`] = uniqueIndex(`${indexName}_bidx`)
362
- .on(...bidxCols)
363
- .where(partialWhere);
364
- }
365
- }
366
- }
367
- }
368
- return indexes;
369
- });
273
+ const tableName = entityTableName(entityName, entity, options?.featureName);
274
+ const foreignKeyFields = foreignKeyFieldNames(entity, options?.relations);
275
+ // Cast back to EntityTable<E>: the typed signature narrows the dynamic column
276
+ // map to the static names from EntityDefinition (kept in sync with
277
+ // fieldToColumns + buildBaseColumns).
278
+ const built = pgTable(tableName, { ...baseColumns, ...fieldColumns }, (table) => buildEntityIndexes(tableName, entity, table, foreignKeyFields));
370
279
  stampEntityTableMeta(built, entity);
371
280
  markExecutorManagedTable(built, { entityName, entity });
372
281
  return built;
@@ -12,17 +12,25 @@ export function checkQueryRef(queryHandlers, query, buildPrefix) {
12
12
  return;
13
13
  throw new Error(`${buildPrefix()} query "${query}" ${NOT_REGISTERED_SUFFIX}`);
14
14
  }
15
- // visibleWhen queries are run with an empty payload by the client gate, so a query
16
- // that requires input would fail on every call and the screen/panel would stay
17
- // unavailable with no hint.
18
- function checkVisibleWhenQueryRef(queryHandlers, query, buildPrefix) {
15
+ const PROBE_ROUTE_ID = "00000000-0000-4000-8000-000000000000";
16
+ // Panel gates run with an empty payload; a screen gate with `idParam` runs with the route id
17
+ // under that key. A query needing anything more would fail on every call and the
18
+ // screen/panel would stay unavailable with no hint.
19
+ function checkVisibleWhenQueryRef(queryHandlers, query, buildPrefix, idParam) {
19
20
  checkQueryRef(queryHandlers, query, buildPrefix);
20
21
  const handler = queryHandlers.get(query);
21
- // skip: nothing to check for an unknown handler (checkQueryRef already threw) or an input-free query
22
- if (handler === undefined || handler.schema.safeParse({}).success)
22
+ // skip: unknown handler, checkQueryRef already threw
23
+ if (handler === undefined)
23
24
  return;
24
- throw new Error(`${buildPrefix()} query "${query}" requires input; visibleWhen queries run with an empty payload. ` +
25
- "Make the query's input fields optional or use a query without required input.");
25
+ const probe = idParam === undefined ? {} : { [idParam]: PROBE_ROUTE_ID };
26
+ // skip: the query parses what the gate sends
27
+ if (handler.schema.safeParse(probe).success)
28
+ return;
29
+ throw new Error(idParam === undefined
30
+ ? `${buildPrefix()} query "${query}" requires input; visibleWhen queries run with an empty payload. ` +
31
+ "Make the query's input fields optional, use a query without required input, or (projectionDetail only) set visibleWhen.idParam to receive the route id."
32
+ : `${buildPrefix()} query "${query}" requires input beyond idParam "${idParam}"; visibleWhen queries run with only the route id under that key. ` +
33
+ `Make the other input fields optional or fix idParam "${idParam}".`);
26
34
  }
27
35
  // relatedList is currently only reachable on projectionDetail — screens.ts
28
36
  // rejects it outright on entityEdit/actionForm/configEdit. Walking
@@ -104,7 +112,12 @@ function checkEditFormScreenQueryRefs(queryHandlers, featureName, screenId, scre
104
112
  }
105
113
  function checkScreenQueryRefs(queryHandlers, featureName, screenId, screen) {
106
114
  if (screen.visibleWhen !== undefined) {
107
- checkVisibleWhenQueryRef(queryHandlers, screen.visibleWhen.query, () => `[Feature ${featureName}] Screen "${screenId}" (${screen.type}) visibleWhen`);
115
+ const { idParam } = screen.visibleWhen;
116
+ if (idParam !== undefined && screen.type !== "projectionDetail") {
117
+ throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screen.type}) visibleWhen.idParam is only supported on projectionDetail, ` +
118
+ "where the route always carries a record id. Remove idParam or use a projectionDetail screen.");
119
+ }
120
+ checkVisibleWhenQueryRef(queryHandlers, screen.visibleWhen.query, () => `[Feature ${featureName}] Screen "${screenId}" (${screen.type}) visibleWhen`, idParam);
108
121
  }
109
122
  if (screen.type === "projectionList") {
110
123
  checkQueryRef(queryHandlers, screen.query, () => `[Feature ${featureName}] Screen "${screenId}" (projectionList)`);
@@ -434,17 +434,20 @@ export function validateRecordActionRedirect(feature, screenId, screenKind, acti
434
434
  }
435
435
  }
436
436
  // "edit" is the default edit action, which only exists when detailFor names
437
- // the entity; a typo would otherwise silently fall back to the default precedence.
437
+ // the entity; a typo would otherwise silently fall back to the default header layout.
438
438
  export function validatePrimaryActionTarget(featureName, screenId, screen) {
439
- // skip: no declared primaryAction, the default header precedence applies
440
- if (screen.primaryAction === undefined)
441
- return;
439
+ const targets = [
440
+ ...(screen.primaryAction !== undefined
441
+ ? [["primaryAction", screen.primaryAction]]
442
+ : []),
443
+ ...(screen.inlineActions ?? []).map((id) => ["inlineActions", id]),
444
+ ];
442
445
  const declaredIds = (screen.actions ?? []).map((action) => action.id);
443
446
  const editIsAvailable = screen.detailFor !== undefined;
444
- const targetExists = declaredIds.includes(screen.primaryAction) ||
445
- (editIsAvailable && screen.primaryAction === "edit");
446
- if (!targetExists) {
447
- throw new Error(`[Feature ${featureName}] Screen "${screenId}" (projectionDetail) primaryAction "${screen.primaryAction}" ` +
447
+ for (const [option, id] of targets) {
448
+ if (declaredIds.includes(id) || (editIsAvailable && id === "edit"))
449
+ continue;
450
+ throw new Error(`[Feature ${featureName}] Screen "${screenId}" (projectionDetail) ${option} "${id}" ` +
448
451
  `matches no action — use one of the declared action ids (${declaredIds.join(", ") || "none declared"})` +
449
452
  `${editIsAvailable ? ' or "edit"' : ' ("edit" needs detailFor on this screen)'}.`);
450
453
  }
@@ -74,6 +74,13 @@ function validateProjectionDetailStatusTones(featureName, screenId, screen) {
74
74
  }
75
75
  }
76
76
  }
77
+ function validateProjectionDetailStatusFormat(featureName, screenId, screen) {
78
+ const header = screen.header;
79
+ if (header?.statusFormat !== undefined && header.status === undefined) {
80
+ throw new Error(`[Feature ${featureName}] Screen "${screenId}" (projectionDetail) sets header.statusFormat ` +
81
+ `without header.status — there is no status badge to format. Set header.status or remove statusFormat.`);
82
+ }
83
+ }
77
84
  function validateProjectionDetailSubtitleParts(ctx, screenId, subtitleHref, parts) {
78
85
  const featureName = ctx.feature.name;
79
86
  if (subtitleHref !== undefined) {
@@ -223,6 +230,7 @@ export function validateProjectionDetailScreen(ctx, screenId, screen) {
223
230
  validateProjectionDetailMetric(ctx, screenId, screen, metric);
224
231
  }
225
232
  validateProjectionDetailStatusTones(featureName, screenId, screen);
233
+ validateProjectionDetailStatusFormat(featureName, screenId, screen);
226
234
  validateProjectionDetailSubtitle(ctx, screenId, screen);
227
235
  for (const section of screen.layout.sections) {
228
236
  validateProjectionDetailSection(ctx, screenId, screen, section);
@@ -19,6 +19,8 @@
19
19
  // Workspace: verbatim definition + getWorkspaceNavs() from the registry
20
20
  // Translations: verbatim feature.translations (raw r.translations keys,
21
21
  // unfiltered — see FeatureSchema.translations doc)
22
+ // queryLiveEntities: { <query QN>: liveEntities } of the feature's own
23
+ // query handlers with a non-empty liveEntities list
22
24
  //
23
25
  // Feature toggles: this schema is tenant-independent and lists every feature.
24
26
  // The schema route strips features the caller's tenant has switched off
@@ -29,6 +31,7 @@ import { collectUrlPrefillFieldsByScreenQn } from "./boot-validator/url-prefill-
29
31
  import { buildConfigFeatureSchema, SETTINGS_HUB_FEATURE, } from "./build-config-feature-schema.js";
30
32
  import { isEncryptedAtRest } from "./config-helpers.js";
31
33
  import { isListQueryWithoutSearchableFields } from "./entity-list-searchability.js";
34
+ import { projectQueryLiveEntities } from "./project-query-live-entities.js";
32
35
  import { QnTypes, qualifyEntityName } from "./qualified-name.js";
33
36
  const log = createFallbackLogger("kumiko");
34
37
  // Workspace ids are only unique per feature, and the schema carries the short id. The owner stays
@@ -54,6 +57,7 @@ export function buildAppSchema(registry, options = {}) {
54
57
  ...collection,
55
58
  navQn: `${featureName}:nav:${collection.id}`,
56
59
  }));
60
+ const queryLiveEntities = projectQueryLiveEntities(registry, featureName);
57
61
  const featureSchema = {
58
62
  featureName,
59
63
  entities: projectEntities(feature.entities ?? {}),
@@ -67,6 +71,7 @@ export function buildAppSchema(registry, options = {}) {
67
71
  translations: feature.translations,
68
72
  }),
69
73
  ...(options.searchAdapterMissing === true && { searchAdapterMissing: true }),
74
+ ...(Object.keys(queryLiveEntities).length > 0 && { queryLiveEntities }),
70
75
  };
71
76
  features.push(featureSchema);
72
77
  }
@@ -7,6 +7,4 @@
7
7
  // The runner pipelines that consume these hooks are built and live: see
8
8
  // packages/bundled-features/src/user-data-rights/run-user-export.ts,
9
9
  // run-forget-cleanup.ts, run-export-jobs.ts.
10
- //
11
- // See docs/plans/datenschutz/user-data-rights.md.
12
10
  export {};
@@ -0,0 +1,2 @@
1
+ import type { Registry } from "./types/feature.ts";
2
+ export declare function projectQueryLiveEntities(registry: Registry, featureName: string): Readonly<Record<string, readonly string[]>>;
@@ -0,0 +1,11 @@
1
+ export function projectQueryLiveEntities(registry, featureName) {
2
+ const projected = {};
3
+ for (const [queryQn, handler] of registry.getAllQueryHandlers()) {
4
+ if (handler.liveEntities === undefined || handler.liveEntities.length === 0)
5
+ continue;
6
+ if (registry.getHandlerFeature(queryQn) !== featureName)
7
+ continue;
8
+ projected[queryQn] = [...handler.liveEntities];
9
+ }
10
+ return projected;
11
+ }
@@ -131,7 +131,7 @@ export function stripDisabledFeaturesFromAppSchema(schema, registry, disabledFea
131
131
  const orphanedNavQns = collectOrphanedNavQns(schema.features, removedNavQns);
132
132
  const features = schema.features.map((feature) => {
133
133
  if (disabled.has(feature.featureName)) {
134
- const { navs: _navs, contentCollections: _collections, workspaces: _ws, ...rest } = feature;
134
+ const { navs: _navs, contentCollections: _collections, workspaces: _ws, queryLiveEntities: _live, ...rest } = feature;
135
135
  return { ...rest, screens: screensKeptForFallback(feature) };
136
136
  }
137
137
  if (orphanedNavQns.size === 0 || feature.navs === undefined)
@@ -1,6 +1,7 @@
1
1
  import * as z from "zod";
2
2
  import type { FeatureDefinition } from "../engine/types/feature.ts";
3
3
  import { type ComposedEnvShape, type ComposeEnvSchemaOptions, type EmptyShape, type EnvError, type EnvFieldClass, type EnvFieldStatus, type KumikoEnvMeta, type ParseEnvOptions } from "./internals.ts";
4
+ export { type EnvSource, requiredEnvFor } from "./required-env.ts";
4
5
  export declare function readKumikoMeta(field: z.ZodType): KumikoEnvMeta;
5
6
  /** Env names whose schema field declares `kms: true` — the slots
6
7
  * `resolvePlatformKeks` resolves from a ciphertext. */
package/dist/env/index.js CHANGED
@@ -16,6 +16,7 @@
16
16
  import * as z from "zod";
17
17
  import { zodDef, zodMeta, zodShape, zodShapeField } from "./_zod-introspect.js";
18
18
  import { buildPulumiSuggestion, getFieldDescription, } from "./internals.js";
19
+ export { requiredEnvFor } from "./required-env.js";
19
20
  // --- Per-env-var metadata (attach via Zod's `.meta()`) ---
20
21
  function isKumikoMeta(value) {
21
22
  if (value === null || typeof value !== "object")
@@ -0,0 +1,2 @@
1
+ export type EnvSource = Readonly<Record<string, string | undefined>>;
2
+ export declare function requiredEnvFor(label: string, env: EnvSource): (name: string) => string;
@@ -0,0 +1,9 @@
1
+ export function requiredEnvFor(label, env) {
2
+ return (name) => {
3
+ const value = env[name];
4
+ if (value === undefined || value === "") {
5
+ throw new Error(`[${label}] required env var ${name} is missing or empty`);
6
+ }
7
+ return value;
8
+ };
9
+ }
@@ -203,6 +203,10 @@ function pushRelatedListSectionKeys(out, section) {
203
203
  pushKey(out, grouping?.label);
204
204
  for (const label of Object.values(grouping?.labels ?? {}))
205
205
  pushKey(out, label);
206
+ for (const variant of section.emptyState?.variants ?? []) {
207
+ pushKey(out, variant.title);
208
+ pushKey(out, variant.description);
209
+ }
206
210
  }
207
211
  function pushListEmptyStateKeys(out, emptyState) {
208
212
  pushKey(out, emptyState?.title);
@@ -125,11 +125,11 @@ export function formatKumikoDriftReport(report) {
125
125
  // restored by apply.
126
126
  lines.push("");
127
127
  if (report.pending.length > 0) {
128
- lines.push("Run 'kumiko schema apply' to apply the pending migration(s).");
128
+ lines.push("Run 'bun kumiko-schema apply' to apply the pending migration(s).");
129
129
  }
130
130
  if (report.checksumMismatches.length > 0) {
131
131
  lines.push("Revert the edited migration file(s) to their applied content, or hand-correct");
132
- lines.push("the checksum in _kumiko_migrations — 'kumiko schema apply' cannot resolve a");
132
+ lines.push("the checksum in _kumiko_migrations — 'bun kumiko-schema apply' cannot resolve a");
133
133
  lines.push("checksum mismatch.");
134
134
  }
135
135
  if (report.missingTables.length > 0 && report.pending.length === 0) {
@@ -138,8 +138,8 @@ export function formatKumikoDriftReport(report) {
138
138
  }
139
139
  if (report.missingColumns.length > 0) {
140
140
  lines.push("Missing column(s) — the live table predates a richer snapshot (e.g. a");
141
- lines.push("ride-along column the generator now emits). Run 'kumiko schema generate' to");
142
- lines.push("produce an ALTER-TABLE migration for the new column(s), then 'kumiko schema apply'.");
141
+ lines.push("ride-along column the generator now emits). Run 'bun kumiko-schema generate' to");
142
+ lines.push("produce an ALTER-TABLE migration for the new column(s), then 'bun kumiko-schema apply'.");
143
143
  }
144
144
  return lines.join("\n");
145
145
  }
@@ -12,6 +12,7 @@ export type FeatureSchema = {
12
12
  readonly translations?: TranslationKeys;
13
13
  readonly workspaces?: readonly WorkspaceSchema[];
14
14
  readonly searchAdapterMissing?: boolean;
15
+ readonly queryLiveEntities?: Readonly<Record<string, readonly string[]>>;
15
16
  };
16
17
  export type QualifiedContentCollection = ContentCollectionDefinition & {
17
18
  readonly navQn: string;
@@ -10,7 +10,7 @@ export type { EmbeddedDerivedCellDef, EntityDefinition, FieldDefinition, Options
10
10
  export type { AccessRule } from "../engine/types/handlers.ts";
11
11
  export { isOpenToAllGranted, isUiAccessGranted } from "../engine/types/handlers.ts";
12
12
  export type { IconKey, NavDefinition, NavIconKey } from "../engine/types/nav.ts";
13
- export type { ActionFormFooterAction, ActionFormLoad, ActionFormRedirect, ActionFormScreenDefinition, ActionFormSuccessFact, ActionFormSuccessLink, ConfigEditScreenDefinition, CustomScreenDefinition, DashboardChartMarkerKind, DashboardChartPanel, DashboardCurrencyFormat, DashboardCustomPanel, DashboardDateParam, DashboardFeedPanel, DashboardFilterDefinition, DashboardI18nText, DashboardListPanel, DashboardMoneyParam, DashboardPanelDefinition, DashboardPanelEmptyState, DashboardPanelQueryOptions, DashboardPanelSpan, DashboardPanelVisibility, DashboardProgressListPanel, DashboardProgressTone, DashboardRefresh, DashboardRelativeTimeParam, DashboardScopeDefinition, DashboardScreenDefinition, DashboardScreenPanel, DashboardStatGroupPanel, DashboardStatPanel, DashboardText, DashboardTextBlock, DashboardTextPanel, DashboardTextParam, DashboardTimeRangeDefinition, EditExtensionSection, EditFieldSpec, EditFieldsSection, EditLayout, EditRelatedListSection, EditSectionSpec, EditWriteFormSection, EmbeddedListEditSpec, EntityEditAction, EntityEditScreenDefinition, EntityListFacetConfig, EntityListScreenDefinition, FieldAffix, FieldCondition, FieldFillAction, FieldIconKey, FieldRenderer, FormWidth, ListBulkAction, ListColumnSpec, ListEmptyStateSpec, ListFacetSpec, ListSortSpec, ListTableOptions, MetricNavigate, PlatformComponent, ProjectionDetailScreenDefinition, ProjectionListScreenDefinition, RecordHeaderSubtitlePart, RelatedListRowTone, RelatedListToolbarAction, RowAction, RowActionClientAction, RowActionDisplay, RowActionDrawer, RowActionMode, RowActionNavigate, RowActionWriteHandler, RowFieldExtractor, ScreenDefinition, ScreenSlots, SecretMintConfirmStep, SecretMintScreenDefinition, SecretsEditScreenDefinition, ToolbarAction, } from "../engine/types/screen.ts";
13
+ export type { ActionFormFooterAction, ActionFormLoad, ActionFormRedirect, ActionFormScreenDefinition, ActionFormSuccessFact, ActionFormSuccessLink, ConfigEditScreenDefinition, CustomScreenDefinition, DashboardChartMarkerKind, DashboardChartPanel, DashboardCurrencyFormat, DashboardCustomPanel, DashboardDateParam, DashboardFeedPanel, DashboardFilterDefinition, DashboardI18nText, DashboardListPanel, DashboardMoneyParam, DashboardPanelDefinition, DashboardPanelEmptyState, DashboardPanelQueryOptions, DashboardPanelSpan, DashboardPanelVisibility, DashboardProgressListPanel, DashboardProgressTone, DashboardRefresh, DashboardRelativeTimeParam, DashboardScopeDefinition, DashboardScreenDefinition, DashboardScreenPanel, DashboardStatGroupPanel, DashboardStatPanel, DashboardText, DashboardTextBlock, DashboardTextPanel, DashboardTextParam, DashboardTimeRangeDefinition, EditExtensionSection, EditFieldSpec, EditFieldsSection, EditLayout, EditRelatedListSection, EditSectionSpec, EditWriteFormSection, EmbeddedListEditSpec, EntityEditAction, EntityEditScreenDefinition, EntityListFacetConfig, EntityListScreenDefinition, FieldAffix, FieldCondition, FieldFillAction, FieldIconKey, FieldRenderer, FormWidth, ListBulkAction, ListColumnSpec, ListEmptyStateSpec, ListFacetSpec, ListSortSpec, ListTableOptions, MetricNavigate, PlatformComponent, ProjectionDetailScreenDefinition, ProjectionListScreenDefinition, RecordHeaderSubtitlePart, RelatedListEmptyStateSpec, RelatedListEmptyStateVariant, RelatedListRowTone, RelatedListToolbarAction, RowAction, RowActionClientAction, RowActionDisplay, RowActionDrawer, RowActionMode, RowActionNavigate, RowActionWriteHandler, RowFieldExtractor, ScreenDefinition, ScreenSlots, ScreenVisibility, SecretMintConfirmStep, SecretMintScreenDefinition, SecretsEditScreenDefinition, ToolbarAction, } from "../engine/types/screen.ts";
14
14
  export { MAX_BULK_SELECTION } from "../engine/types/screen.ts";
15
15
  export type { TargetRef } from "../engine/types/target-ref.ts";
16
16
  export type { TreeAction } from "../engine/types/tree-node.ts";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.391.0",
3
+ "version": "0.392.0",
4
4
  "description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
5
5
  "license": "MIT",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -199,7 +199,7 @@
199
199
  "./package.json": "./package.json"
200
200
  },
201
201
  "dependencies": {
202
- "@cosmicdrift/kumiko-types": "0.391.0",
202
+ "@cosmicdrift/kumiko-types": "0.392.0",
203
203
  "bullmq": "^5.76.7",
204
204
  "hono": "^4.13.1",
205
205
  "i18next": "^26.1.0",
@@ -221,7 +221,7 @@
221
221
  }
222
222
  },
223
223
  "devDependencies": {
224
- "@cosmicdrift/kumiko-testing": "0.391.0",
224
+ "@cosmicdrift/kumiko-testing": "0.392.0",
225
225
  "bun-types": "^1.4.4",
226
226
  "fflate": "^0.8.2",
227
227
  "meilisearch": "^0.58.0",
package/src/changes.json CHANGED
@@ -1,4 +1,46 @@
1
1
  [
2
+ {
3
+ "version": "0.392.0",
4
+ "type": "improvement",
5
+ "title": "Table and migration generation split into smaller steps",
6
+ "detail": "fieldToColumnMeta, buildEntityTable and renderMigrationSql are now tables and named steps instead of long functions. Columns, indexes and migration SQL are byte-identical; nothing changes for apps."
7
+ },
8
+ {
9
+ "version": "0.392.0",
10
+ "type": "improvement",
11
+ "title": "requiredEnvFor reads a required env var in ops scripts",
12
+ "detail": "@cosmicdrift/kumiko-framework/env exports requiredEnvFor(label, env). It returns a reader that hands back the value of a set variable and throws \"[label] required env var NAME is missing or empty\" otherwise; an empty string counts as missing and the error never contains a value. The env source is passed in, so a script can read from the KEK-resolved env of resolvePlatformKeks as well as from process.env. It replaces the requiredEnv copies in app bin/ops scripts."
13
+ },
14
+ {
15
+ "version": "0.392.0",
16
+ "type": "improvement",
17
+ "title": "The boot validator checks header.statusFormat",
18
+ "detail": "A projectionDetail screen with header.statusFormat but without header.status fails boot, because there is no badge to format."
19
+ },
20
+ {
21
+ "version": "0.392.0",
22
+ "type": "improvement",
23
+ "title": "The boot check validates inlineActions",
24
+ "detail": "Every id in a projectionDetail inlineActions list must name a declared action (or edit when the screen has an edit target), the same rule as primaryAction; an unknown id fails the boot."
25
+ },
26
+ {
27
+ "version": "0.392.0",
28
+ "type": "improvement",
29
+ "title": "The i18n boot check covers emptyState variants",
30
+ "detail": "Title and description keys of relatedList emptyState variants count as required surface keys, so a missing translation fails the boot check."
31
+ },
32
+ {
33
+ "version": "0.392.0",
34
+ "type": "improvement",
35
+ "title": "projectionDetail visibleWhen can pass the route id",
36
+ "detail": "A screen-level visibleWhen takes idParam on projectionDetail. The gate then queries with the route id under that key, and boot accepts a visibleWhen query that requires exactly that input. Without idParam the empty-payload rule stays, and idParam on another screen type fails boot. A failing gate query, such as a 403 for a downgraded tenant, shows the fallback or the standard notice instead of the screen."
37
+ },
38
+ {
39
+ "version": "0.391.1",
40
+ "type": "fix",
41
+ "title": "Boot errors name the real schema command",
42
+ "detail": "The drift report and the row-level-security boot check recommended `kumiko schema apply` and `kumiko schema generate`. Apps run the CLI as `bun kumiko-schema <subcommand>`, so the hints now say that."
43
+ },
2
44
  {
3
45
  "version": "0.391.0",
4
46
  "type": "fix",