@cosmicdrift/kumiko-framework 0.235.0 → 0.235.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.235.0",
3
+ "version": "0.235.1",
4
4
  "description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -198,7 +198,7 @@
198
198
  "./package.json": "./package.json"
199
199
  },
200
200
  "dependencies": {
201
- "@cosmicdrift/kumiko-types": "0.235.0",
201
+ "@cosmicdrift/kumiko-types": "0.235.1",
202
202
  "bullmq": "^5.76.7",
203
203
  "bun-types": "^1.3.13",
204
204
  "hono": "^4.13.1",
@@ -214,7 +214,7 @@
214
214
  "zod": "^4.4.3"
215
215
  },
216
216
  "devDependencies": {
217
- "@cosmicdrift/kumiko-dispatcher-live": "0.235.0",
217
+ "@cosmicdrift/kumiko-dispatcher-live": "0.235.1",
218
218
  "bun-types": "^1.3.13",
219
219
  "pino-pretty": "^13.1.3"
220
220
  },
@@ -0,0 +1,15 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { LIST_ROW_META_COLUMNS } from "../../ui-types/list-row-meta";
3
+ import { rowMetaFieldNames } from "../table-builder";
4
+
5
+ // Drift-Guard: LIST_ROW_META_COLUMNS (client-safe subpath, headless's
6
+ // computeListViewModel) duplicates the base row-meta column names instead
7
+ // of importing db/table-builder directly (that would pull drizzle into the
8
+ // client bundle — see ui-types/list-row-meta.ts). This test is the only
9
+ // place both sides meet, so a base-columns change here fails loud instead
10
+ // of silently drifting the two representations apart.
11
+ describe("LIST_ROW_META_COLUMNS ↔ rowMetaFieldNames(false)", () => {
12
+ test("key set matches the non-softDelete base row-meta columns exactly", () => {
13
+ expect(new Set(Object.keys(LIST_ROW_META_COLUMNS))).toEqual(new Set(rowMetaFieldNames(false)));
14
+ });
15
+ });
@@ -2,8 +2,11 @@ import { describe, expect, spyOn, test } from "bun:test";
2
2
  import { z } from "zod";
3
3
  import type { SchemaTable } from "../../db/dialect";
4
4
  import { table, text } from "../../db/dialect";
5
+ import { rowMetaFieldNames } from "../../db/table-builder";
5
6
  import { withBootValidatorFixture } from "../../testing/boot-validator-fixture";
7
+ import { LIST_ROW_META_COLUMNS } from "../../ui-types/list-row-meta";
6
8
  import { validateBoot as validateBootRaw } from "../boot-validator";
9
+ import { validateEntityListScreens } from "../boot-validator/entity-list-screens";
7
10
  import { createSystemConfig, createTenantConfig } from "../config-helpers";
8
11
  import {
9
12
  createDerivedField,
@@ -1953,6 +1956,117 @@ describe("boot-validator", () => {
1953
1956
  });
1954
1957
  });
1955
1958
 
1959
+ // --- entityList: row-meta columns (id/tenantId/version/...) ---
1960
+ // Row-meta columns are real base-table columns, never declared entity
1961
+ // fields — a SystemAdmin cross-tenant list exposes tenantId this way.
1962
+ //
1963
+ // Two separate boot-validators check entityList columns (validateScreens
1964
+ // here, validateEntityListScreens in entity-list-screens.ts) — a #2601
1965
+ // review found they'd drifted to accept different column sets, which
1966
+ // would have let a softDelete column pass one gate and throw only in
1967
+ // computeListViewModel at render time. Table-driven over
1968
+ // LIST_ROW_META_COLUMNS (not one-off cases) so a future column added
1969
+ // there is proven against BOTH validators automatically. Companion
1970
+ // assertions: list.test.ts (headless renderer draws the same set) and
1971
+ // list-row-meta-drift.test.ts (LIST_ROW_META_COLUMNS key-set drift guard).
1972
+ describe("entityList row-meta column — accepted by both boot validators", () => {
1973
+ function noteFeatureWithColumn(
1974
+ columnName: string,
1975
+ options?: { readonly softDelete?: boolean },
1976
+ ) {
1977
+ return defineFeature("notes", (r) => {
1978
+ r.entity(
1979
+ "note",
1980
+ createEntity({ fields: { title: createTextField() }, softDelete: options?.softDelete }),
1981
+ );
1982
+ r.screen({
1983
+ id: "note-list",
1984
+ type: "entityList",
1985
+ entity: "note",
1986
+ columns: ["title", columnName],
1987
+ });
1988
+ });
1989
+ }
1990
+
1991
+ for (const [columnName, columnType] of Object.entries(LIST_ROW_META_COLUMNS)) {
1992
+ test(`"${columnName}" (${columnType}) on a non-softDelete entity → kein Throw`, () => {
1993
+ const feature = noteFeatureWithColumn(columnName);
1994
+ expect(() => validateBoot([feature])).not.toThrow();
1995
+ const fixed = withBootValidatorFixture([feature]);
1996
+ expect(() => validateEntityListScreens(fixed)).not.toThrow();
1997
+ });
1998
+ }
1999
+
2000
+ // rowMetaFieldNames(true) also carries the softDelete-only columns
2001
+ // (isDeleted/deletedAt/deletedById) — real base-table columns, but
2002
+ // deliberately absent from LIST_ROW_META_COLUMNS because
2003
+ // computeListViewModel can't render them. Both validators must reject
2004
+ // them, or a softDelete column would pass boot and only throw at
2005
+ // render-time.
2006
+ const softDeleteOnlyColumns = [...rowMetaFieldNames(true)].filter(
2007
+ (name) => LIST_ROW_META_COLUMNS[name] === undefined,
2008
+ );
2009
+ expect(softDeleteOnlyColumns.sort()).toEqual(["deletedAt", "deletedById", "isDeleted"]);
2010
+
2011
+ for (const columnName of softDeleteOnlyColumns) {
2012
+ test(`"${columnName}" (softDelete-only, not renderable) → Throw on both validators`, () => {
2013
+ const feature = noteFeatureWithColumn(columnName, { softDelete: true });
2014
+ expect(() => validateBoot([feature])).toThrow(
2015
+ new RegExp(`references field "${columnName}" which does not exist`),
2016
+ );
2017
+ const fixed = withBootValidatorFixture([feature]);
2018
+ expect(() => validateEntityListScreens(fixed)).toThrow(
2019
+ new RegExp(`unknown column field "${columnName}"`),
2020
+ );
2021
+ });
2022
+ }
2023
+ });
2024
+
2025
+ // --- entityList: typo columns must still fail (row-meta acceptance is
2026
+ // NOT a general gate-loosening) ---
2027
+ //
2028
+ // validateBoot only ever surfaces validateScreens' message here — it runs
2029
+ // before validateEntityListScreens in the per-feature loop (see
2030
+ // boot-validator/index.ts) and throws first. validateEntityListScreens'
2031
+ // own "unknown column field" message is only reachable by calling it
2032
+ // directly, which these tests also do.
2033
+ describe("entityList typo column → Throw on both validators (not silently accepted)", () => {
2034
+ const typoColumns = ["tenantID", "tenatId", "totallyMadeUp"];
2035
+
2036
+ for (const typo of typoColumns) {
2037
+ test(`"${typo}" fails validateScreens (via validateBoot)`, () => {
2038
+ const feature = defineFeature("notes", (r) => {
2039
+ r.entity("note", createEntity({ fields: { title: createTextField() } }));
2040
+ r.screen({
2041
+ id: "note-list",
2042
+ type: "entityList",
2043
+ entity: "note",
2044
+ columns: ["title", typo],
2045
+ });
2046
+ });
2047
+ expect(() => validateBoot([feature])).toThrow(
2048
+ new RegExp(`references field "${typo}" which does not exist`),
2049
+ );
2050
+ });
2051
+
2052
+ test(`"${typo}" fails validateEntityListScreens directly ("unknown column field")`, () => {
2053
+ const feature = defineFeature("notes", (r) => {
2054
+ r.entity("note", createEntity({ fields: { title: createTextField() } }));
2055
+ r.screen({
2056
+ id: "note-list",
2057
+ type: "entityList",
2058
+ entity: "note",
2059
+ columns: ["title", typo],
2060
+ });
2061
+ });
2062
+ const fixed = withBootValidatorFixture([feature]);
2063
+ expect(() => validateEntityListScreens(fixed)).toThrow(
2064
+ new RegExp(`unknown column field "${typo}"`),
2065
+ );
2066
+ });
2067
+ }
2068
+ });
2069
+
1956
2070
  // --- entityList: pagination + sort validation ---
1957
2071
  // Author-Fehler vor Production fangen, damit "Screen lädt nichts /
1958
2072
  // sortiert falsch / crasht beim Pager-Klick" nicht erst zur Laufzeit
@@ -1,9 +1,17 @@
1
+ import { LIST_ROW_META_COLUMNS } from "../../ui-types/list-row-meta";
1
2
  import { normalizeListColumn } from "../screen-helpers";
2
3
  import type { EntityListScreenDefinition, FeatureDefinition } from "../types";
3
4
 
4
5
  /** Operator lists default searchable; low-cardinality audit trails stay opt-out. */
5
6
  export const SEARCHABLE_FALSE_WHITELIST = new Set(["download-attempt-list"]);
6
7
 
8
+ // Same set computeListViewModel (headless) can actually render — NOT
9
+ // db/table-builder's rowMetaFieldNames(softDelete), which also carries
10
+ // isDeleted/deletedAt/deletedById. Accepting those here would let a
11
+ // softDelete column pass the boot gate and then throw at render-time
12
+ // because the renderer's LIST_ROW_META_COLUMNS doesn't know them.
13
+ const LIST_ROW_META_COLUMN_NAMES = new Set(Object.keys(LIST_ROW_META_COLUMNS));
14
+
7
15
  function hasFilterableFields(feature: FeatureDefinition, entityName: string): boolean {
8
16
  const entities = feature.entities;
9
17
  if (!entities) return false;
@@ -71,7 +79,11 @@ function validateOneEntityListScreen(
71
79
  if (normalized.label === undefined) {
72
80
  const entity = feature.entities?.[screen.entity];
73
81
  const isDerived = entity?.derivedFields?.[normalized.field] !== undefined;
74
- if (!entity?.fields[normalized.field] && !isDerived) {
82
+ // Row-meta column (id/tenantId/version/insertedAt/modifiedAt/...) is a
83
+ // real base-table column without being a declared entity field — e.g.
84
+ // a SystemAdmin cross-tenant list exposing tenantId.
85
+ const isRowMeta = LIST_ROW_META_COLUMN_NAMES.has(normalized.field);
86
+ if (!entity?.fields[normalized.field] && !isDerived && !isRowMeta) {
75
87
  throw new Error(`${prefix}: unknown column field "${normalized.field}"`);
76
88
  }
77
89
  }
@@ -6,6 +6,7 @@
6
6
 
7
7
  import { NO_WIDGET_FIELD_TYPES } from "@cosmicdrift/kumiko-types/fields";
8
8
  import { rowMetaFieldNames } from "../../db/table-builder";
9
+ import { LIST_ROW_META_COLUMNS } from "../../ui-types/list-row-meta";
9
10
  import { isKebabSegment, isValidQn, qualifyEntityName } from "../qualified-name";
10
11
  import { getAllowedFilterOps, isFieldFilterable } from "../screen-filter-ops";
11
12
  import { isExtensionEditSection, normalizeEditField, normalizeListColumn } from "../screen-helpers";
@@ -26,6 +27,15 @@ import type {
26
27
  ToolbarAction,
27
28
  } from "../types/screen";
28
29
 
30
+ // entityList columns accept exactly the row-meta columns the renderer
31
+ // (computeListViewModel) knows how to type — LIST_ROW_META_COLUMNS, NOT the
32
+ // wider rowMetaFieldNames(softDelete) used below for rowAction payload/visible
33
+ // field refs. Those two checks guard different things: a screen column must
34
+ // be renderable, a payload/visible field reference only needs to exist on the
35
+ // row. Using the wider set here would let a softDelete column like
36
+ // "deletedAt" pass the boot gate and then throw at render-time instead.
37
+ const LIST_ROW_META_COLUMN_NAMES = new Set(Object.keys(LIST_ROW_META_COLUMNS));
38
+
29
39
  // entityList and projectionList both allow a rowAction to double as the
30
40
  // row-body click target (rowClick: true, fw#1708/#2164) — at most one per
31
41
  // screen, or the renderer can't tell which one should fire.
@@ -1036,14 +1046,23 @@ export function validateScreens(
1036
1046
  }
1037
1047
  for (const col of screen.columns) {
1038
1048
  const normalized = normalizeListColumn(col);
1049
+ // A row-meta column (id/tenantId/version/insertedAt/modifiedAt/...) is a
1050
+ // real DB column without being a declared entity field — e.g. a
1051
+ // SystemAdmin cross-tenant list exposing tenantId. Accept it like a
1052
+ // regular field name — but only the columns computeListViewModel can
1053
+ // actually render (LIST_ROW_META_COLUMN_NAMES), not the wider
1054
+ // softDelete-aware rowMeta set used below for payload/visible refs.
1055
+ //
1039
1056
  // A virtual presentational column (drawn by a columnRenderer component
1040
1057
  // from the row, e.g. tag chips) needs BOTH a label AND a renderer —
1041
1058
  // renderer is what actually makes it "virtual" (label alone still
1042
1059
  // needs list.ts's virtual-branch to have something to draw; without
1043
1060
  // a renderer the column would render nothing). A column with neither
1044
- // matching a real field NOR a renderer is a typo worth failing the boot.
1061
+ // matching a real field/row-meta column NOR a renderer is a typo worth
1062
+ // failing the boot.
1045
1063
  if (
1046
1064
  !columnFieldNames.has(normalized.field) &&
1065
+ !LIST_ROW_META_COLUMN_NAMES.has(normalized.field) &&
1047
1066
  !(normalized.label !== undefined && normalized.renderer !== undefined)
1048
1067
  ) {
1049
1068
  throw new Error(
@@ -11,10 +11,10 @@ export type { MultiStreamApplyContext } from "@cosmicdrift/kumiko-types/multi-st
11
11
 
12
12
  export type MultiStreamApplyContextDeps = {
13
13
  readonly registry: Registry;
14
- // TX-scoped DbRunner — the same `tx` the applyFn receives as the 2nd
15
- // arg. ctx.appendEvent + inline-projections run inside this tx so a
16
- // throw rolls the whole hop back (consumer retries the triggering
17
- // event on the next pass).
14
+ // Same DbRunner the applyFn receives as its 2nd arg. In the server's MSP
15
+ // consumer wiring (api/server.ts) that's the unbound pool, not the
16
+ // dispatcher's cursor tx — a throw does NOT roll back appends/inline
17
+ // projections already made; apply must be idempotent for the retry.
18
18
  readonly db: DbRunner;
19
19
  // tenantId + userId of the TRIGGERING event. appendEvent stamps these
20
20
  // onto the new event so the causal chain stays tenant-consistent and
@@ -107,3 +107,5 @@ export type {
107
107
  QualifiedContentCollection,
108
108
  WorkspaceSchema,
109
109
  } from "./app-schema";
110
+ export type { ListRowMetaColumnType } from "./list-row-meta";
111
+ export { LIST_ROW_META_COLUMNS } from "./list-row-meta";
@@ -0,0 +1,25 @@
1
+ // Base row-meta columns (id/tenantId/version/insertedAt/modifiedAt/
2
+ // insertedById/modifiedById) exist on every entity table without being
3
+ // declared entity fields (see rowMetaFieldNames in db/table-builder.ts).
4
+ // computeListViewModel needs a type + sortable hint per column when a screen
5
+ // picks one of these as a list column (e.g. a SystemAdmin cross-tenant view
6
+ // exposing tenantId). This stays a plain string map instead of importing
7
+ // db/table-builder directly, so headless (client bundle) doesn't pull
8
+ // drizzle in through this type-only subpath. list-row-meta-drift.test.ts
9
+ // guards the key set against drift from rowMetaFieldNames(false).
10
+ //
11
+ // Deliberately excludes the softDelete-only columns (isDeleted/deletedAt/
12
+ // deletedById) — this is also the SAME set the boot-validator's entityList
13
+ // column checks accept, so a softDelete column stays a boot-time error
14
+ // instead of a renderer-side throw (see screens.ts / entity-list-screens.ts).
15
+ export type ListRowMetaColumnType = "text" | "number" | "timestamp";
16
+
17
+ export const LIST_ROW_META_COLUMNS: Readonly<Record<string, ListRowMetaColumnType>> = {
18
+ id: "text",
19
+ tenantId: "text",
20
+ version: "number",
21
+ insertedAt: "timestamp",
22
+ modifiedAt: "timestamp",
23
+ insertedById: "text",
24
+ modifiedById: "text",
25
+ };