@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 +3 -3
- package/src/db/__tests__/list-row-meta-drift.test.ts +15 -0
- package/src/engine/__tests__/boot-validator.test.ts +114 -0
- package/src/engine/boot-validator/entity-list-screens.ts +13 -1
- package/src/engine/boot-validator/screens.ts +20 -1
- package/src/pipeline/multi-stream-apply-context.ts +4 -4
- package/src/ui-types/index.ts +2 -0
- package/src/ui-types/list-row-meta.ts +25 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.235.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
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
|
package/src/ui-types/index.ts
CHANGED
|
@@ -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
|
+
};
|