@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.
- package/agents/examples/apex-landing.md +1 -1
- package/agents/examples/apex-surface-auth.md +35 -35
- package/agents/examples/compliance-profiles-demo.md +4 -4
- package/agents/examples/crypto-shredding-hr.md +1 -1
- package/agents/examples/designer-demo.md +6 -6
- package/agents/examples/e2e-generator.md +18 -18
- package/agents/examples/encrypted-tenant-config.md +31 -31
- package/agents/examples/files-post-processing.md +1 -1
- package/agents/examples/inbound-mail-triage.md +1 -1
- package/agents/examples/lane-routing.md +5 -5
- package/agents/examples/legal-pages.md +36 -36
- package/agents/examples/record-detail-layout.md +41 -8
- package/agents/examples/seed-migration.md +44 -44
- package/agents/examples/user-data-rights.md +21 -21
- package/agents/examples/user-profile.md +5 -5
- package/agents/examples/wizard-form.md +1 -1
- package/agents/features.md +3 -3
- package/dist/db/entity-column-meta.d.ts +3 -0
- package/dist/db/entity-column-meta.js +113 -0
- package/dist/db/entity-table-indexes.d.ts +7 -0
- package/dist/db/entity-table-indexes.js +124 -0
- package/dist/db/entity-table-meta.js +1 -153
- package/dist/db/migrate-change-render.d.ts +40 -0
- package/dist/db/migrate-change-render.js +89 -0
- package/dist/db/migrate-generator.d.ts +5 -37
- package/dist/db/migrate-generator.js +99 -171
- package/dist/db/row-level-security-boot-check.js +4 -4
- package/dist/db/table-builder.d.ts +1 -1
- package/dist/db/table-builder.js +42 -133
- package/dist/engine/boot-validator/query-refs.js +22 -9
- package/dist/engine/boot-validator/screens-actions.js +11 -8
- package/dist/engine/boot-validator/screens-kind-projection-detail.js +8 -0
- package/dist/engine/build-app-schema.js +5 -0
- package/dist/engine/extensions/user-data.js +0 -2
- package/dist/engine/project-query-live-entities.d.ts +2 -0
- package/dist/engine/project-query-live-entities.js +11 -0
- package/dist/engine/strip-disabled-features-from-app-schema.js +1 -1
- package/dist/env/index.d.ts +1 -0
- package/dist/env/index.js +1 -0
- package/dist/env/required-env.d.ts +2 -0
- package/dist/env/required-env.js +9 -0
- package/dist/i18n/required-surface-keys.js +4 -0
- package/dist/migrations/kumiko-drift.js +4 -4
- package/dist/ui-types/app-schema.d.ts +1 -0
- package/dist/ui-types/index.d.ts +1 -1
- package/package.json +3 -3
- 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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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_";
|
package/dist/db/table-builder.js
CHANGED
|
@@ -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,
|
|
7
|
-
|
|
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
|
-
|
|
241
|
-
Object.assign(fieldColumns, cols);
|
|
271
|
+
Object.assign(fieldColumns, fieldToColumns(name, field, entity));
|
|
242
272
|
}
|
|
243
|
-
|
|
244
|
-
const
|
|
245
|
-
//
|
|
246
|
-
//
|
|
247
|
-
//
|
|
248
|
-
|
|
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
|
-
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
|
|
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:
|
|
22
|
-
if (handler === undefined
|
|
22
|
+
// skip: unknown handler, checkQueryRef already threw
|
|
23
|
+
if (handler === undefined)
|
|
23
24
|
return;
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
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
|
|
437
|
+
// the entity; a typo would otherwise silently fall back to the default header layout.
|
|
438
438
|
export function validatePrimaryActionTarget(featureName, screenId, screen) {
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
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
|
|
445
|
-
(editIsAvailable &&
|
|
446
|
-
|
|
447
|
-
throw new Error(`[Feature ${featureName}] Screen "${screenId}" (projectionDetail)
|
|
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
|
}
|
|
@@ -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)
|
package/dist/env/index.d.ts
CHANGED
|
@@ -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")
|
|
@@ -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
|
|
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
|
|
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
|
|
142
|
-
lines.push("produce an ALTER-TABLE migration for the new column(s), then 'kumiko
|
|
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;
|
package/dist/ui-types/index.d.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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",
|