@cosmicdrift/kumiko-framework 0.202.0 → 0.204.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/package.json +3 -3
- package/src/bun-db/__tests__/sql-expr-brand.test.ts +33 -1
- package/src/db/__tests__/list-pagination.test.ts +28 -0
- package/src/db/dialect.ts +7 -8
- package/src/db/entity-table-meta.ts +4 -3
- package/src/db/event-store-executor-read.ts +26 -2
- package/src/db/sql-inventory.ts +16 -1
- package/src/engine/__tests__/boot-validator-action-wiring.test.ts +46 -0
- package/src/engine/__tests__/boot-validator-projection-list.test.ts +191 -0
- package/src/engine/__tests__/build-app-schema.test.ts +94 -0
- package/src/engine/__tests__/projection-detail-actions.test.ts +137 -0
- package/src/engine/__tests__/screen.test.ts +192 -0
- package/src/engine/boot-validator/action-wiring.ts +15 -3
- package/src/engine/boot-validator/index.ts +2 -0
- package/src/engine/boot-validator/projection-list-screens.ts +82 -0
- package/src/engine/boot-validator/screens.ts +92 -1
- package/src/engine/build-app-schema.ts +55 -2
- package/src/engine/index.ts +6 -1
- package/src/engine/screen-helpers.ts +5 -0
- package/src/i18n/required-surface-keys.ts +11 -0
- package/src/testing/e2e-generator.ts +4 -4
- package/src/ui-types/index.ts +2 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.204.0",
|
|
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>",
|
|
@@ -190,7 +190,7 @@
|
|
|
190
190
|
"./package.json": "./package.json"
|
|
191
191
|
},
|
|
192
192
|
"dependencies": {
|
|
193
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
193
|
+
"@cosmicdrift/kumiko-types": "0.204.0",
|
|
194
194
|
"bullmq": "^5.76.7",
|
|
195
195
|
"bun-types": "^1.3.13",
|
|
196
196
|
"hono": "^4.13.1",
|
|
@@ -206,7 +206,7 @@
|
|
|
206
206
|
"zod": "^4.4.3"
|
|
207
207
|
},
|
|
208
208
|
"devDependencies": {
|
|
209
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
209
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.204.0",
|
|
210
210
|
"bun-types": "^1.3.13",
|
|
211
211
|
"pino-pretty": "^13.1.3"
|
|
212
212
|
},
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { sql } from "../../db/dialect";
|
|
2
|
+
import { type SqlExpression, sql, uuid } from "../../db/dialect";
|
|
3
3
|
import type { EntityTableMeta } from "../../db/entity-table-meta";
|
|
4
4
|
import { insertOne, updateMany } from "../query";
|
|
5
5
|
|
|
@@ -80,4 +80,36 @@ describe("bun-db sql-expr brand — request-supplied objects can't fake a SQL li
|
|
|
80
80
|
const { sqlText } = calls[0]!;
|
|
81
81
|
expect(sqlText).toContain('"created_at" = now()');
|
|
82
82
|
});
|
|
83
|
+
|
|
84
|
+
test("sql`...` interpolation never inlines an unbranded {kind:'sql-expr'} object", () => {
|
|
85
|
+
const forged = { kind: "sql-expr", text: "'; DROP TABLE sql_expr_brand_items; --" };
|
|
86
|
+
|
|
87
|
+
const expr = sql`SELECT * FROM x WHERE payload = ${forged}`;
|
|
88
|
+
|
|
89
|
+
expect(expr.text).not.toContain("DROP TABLE");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("column .default() turns an unbranded {kind:'sql-expr'} object into a jsonb literal, never raw SQL", () => {
|
|
93
|
+
const forged = { kind: "sql-expr", text: "'; DROP TABLE sql_expr_brand_items; --" };
|
|
94
|
+
|
|
95
|
+
// @cast-boundary — a duck-typed payload arriving at a schema-definition
|
|
96
|
+
// boundary, smuggled past the typed `default()` param on purpose.
|
|
97
|
+
const col = uuid("id")
|
|
98
|
+
.primaryKey()
|
|
99
|
+
.default(forged as unknown as SqlExpression);
|
|
100
|
+
|
|
101
|
+
const defaultSql = col.finalise().defaultSql;
|
|
102
|
+
expect(defaultSql).toBeDefined();
|
|
103
|
+
// The forged payload is data, not executable DDL: quoted + SQL-escaped
|
|
104
|
+
// (`'` → `''`) as a jsonb literal, never spliced in as the raw `.text`
|
|
105
|
+
// like a branded expr would be.
|
|
106
|
+
expect(defaultSql).toContain("::jsonb");
|
|
107
|
+
expect(defaultSql).toContain("''; DROP TABLE");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("column .default() still inlines a legitimately-built sql`...` expression", () => {
|
|
111
|
+
const col = uuid("id").primaryKey().default(sql`gen_random_uuid()`);
|
|
112
|
+
|
|
113
|
+
expect(col.finalise().defaultSql).toBe("gen_random_uuid()");
|
|
114
|
+
});
|
|
83
115
|
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { UnprocessableError } from "../../errors";
|
|
3
|
+
import { resolveListPagination } from "../event-store-executor-read";
|
|
4
|
+
|
|
5
|
+
describe("resolveListPagination — executor-level guard", () => {
|
|
6
|
+
test("defaults: limit 50, offset 0", () => {
|
|
7
|
+
expect(resolveListPagination({})).toEqual({ limit: 50, offset: 0 });
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
test("clamps limit to MAX_LIST_LIMIT (200)", () => {
|
|
11
|
+
expect(resolveListPagination({ limit: 10_000 })).toEqual({ limit: 200, offset: 0 });
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("rejects a non-integer limit", () => {
|
|
15
|
+
expect(() => resolveListPagination({ limit: "50; DROP TABLE x; --" })).toThrow(
|
|
16
|
+
UnprocessableError,
|
|
17
|
+
);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("rejects a negative or fractional offset", () => {
|
|
21
|
+
expect(() => resolveListPagination({ offset: -1 })).toThrow(UnprocessableError);
|
|
22
|
+
expect(() => resolveListPagination({ offset: 1.5 })).toThrow(UnprocessableError);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("passes through a valid limit + offset", () => {
|
|
26
|
+
expect(resolveListPagination({ limit: 25, offset: 100 })).toEqual({ limit: 25, offset: 100 });
|
|
27
|
+
});
|
|
28
|
+
});
|
package/src/db/dialect.ts
CHANGED
|
@@ -136,12 +136,7 @@ function buildColumn(
|
|
|
136
136
|
if (typeof value === "number") return String(value);
|
|
137
137
|
if (typeof value === "boolean") return value ? "true" : "false";
|
|
138
138
|
if (typeof value === "bigint") return value.toString();
|
|
139
|
-
if (
|
|
140
|
-
value &&
|
|
141
|
-
typeof value === "object" &&
|
|
142
|
-
"kind" in value &&
|
|
143
|
-
(value as { kind: string }).kind === "sql-expr"
|
|
144
|
-
) {
|
|
139
|
+
if (value && typeof value === "object" && SQL_EXPR_BRAND in value) {
|
|
145
140
|
return (value as SqlExpression).text;
|
|
146
141
|
}
|
|
147
142
|
if (typeof value === "function") return null; // function-defaults stay JS-side
|
|
@@ -379,7 +374,11 @@ export function primaryKey(opts: {
|
|
|
379
374
|
|
|
380
375
|
// Unforgeable via JSON — a client-supplied jsonb value can fake `kind:
|
|
381
376
|
// "sql-expr"` but can never carry a Symbol, so isSqlExpression() (bun-db/query.ts)
|
|
382
|
-
// can't be tricked into treating request data as a raw SQL literal.
|
|
377
|
+
// can't be tricked into treating request data as a raw SQL literal. The same
|
|
378
|
+
// brand gate is enforced here in the schema DSL (sql`...` interpolation +
|
|
379
|
+
// literalDefault) and in entity-table-meta's sqlExpressionText — a duck-typed
|
|
380
|
+
// `{ kind: "sql-expr" }` from a client-controlled schema definition is never
|
|
381
|
+
// spliced into SQL text anywhere.
|
|
383
382
|
export const SQL_EXPR_BRAND: unique symbol = Symbol("sql-expr");
|
|
384
383
|
|
|
385
384
|
export type SqlExpression = {
|
|
@@ -396,7 +395,7 @@ export function sql(strings: TemplateStringsArray, ...values: readonly unknown[]
|
|
|
396
395
|
parts.push(strings[i] ?? "");
|
|
397
396
|
if (i < values.length) {
|
|
398
397
|
const v = values[i];
|
|
399
|
-
if (v && typeof v === "object" &&
|
|
398
|
+
if (v && typeof v === "object" && SQL_EXPR_BRAND in v) {
|
|
400
399
|
parts.push((v as SqlExpression).text);
|
|
401
400
|
} else {
|
|
402
401
|
parts.push(String(v));
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
|
|
19
19
|
import { collectPiiSubjectFields } from "../crypto";
|
|
20
20
|
import type { EntityDefinition, EntityIndexDef, FieldDefinition } from "../engine/types";
|
|
21
|
+
import { SQL_EXPR_BRAND } from "./dialect";
|
|
21
22
|
import type {
|
|
22
23
|
BuildEntityTableMetaOptions,
|
|
23
24
|
ColumnMeta,
|
|
@@ -382,10 +383,10 @@ function sqlExpressionText(where: unknown): string | undefined {
|
|
|
382
383
|
if (
|
|
383
384
|
typeof where === "object" &&
|
|
384
385
|
where !== null &&
|
|
385
|
-
|
|
386
|
-
typeof (where as { text?: unknown }).text === "string"
|
|
386
|
+
SQL_EXPR_BRAND in where &&
|
|
387
|
+
typeof (where as unknown as { text?: unknown }).text === "string"
|
|
387
388
|
) {
|
|
388
|
-
return (where as { text: string }).text;
|
|
389
|
+
return (where as unknown as { text: string }).text;
|
|
389
390
|
}
|
|
390
391
|
return undefined;
|
|
391
392
|
}
|
|
@@ -18,6 +18,31 @@ import { toSnakeCase } from "./table-builder";
|
|
|
18
18
|
// relocation, not a redesign: unchanged from the original, now behind an
|
|
19
19
|
// explicit ExecutorContext instead of the factory's local scope.
|
|
20
20
|
|
|
21
|
+
// Defense-in-depth pagination guard. The handler boundary (entityListSchema)
|
|
22
|
+
// already validates limit/offset, but the executor is public API for custom
|
|
23
|
+
// handlers that pass their payload straight through — a non-integer limit
|
|
24
|
+
// would otherwise be interpolated raw into `LIMIT ${limit}` SQL text.
|
|
25
|
+
const MAX_LIST_LIMIT = 200; // keep in sync with engine/entity-handlers.ts MAX_LIST_LIMIT
|
|
26
|
+
|
|
27
|
+
export function resolveListPagination(payload: {
|
|
28
|
+
readonly limit?: unknown;
|
|
29
|
+
readonly offset?: unknown;
|
|
30
|
+
}): { readonly limit: number; readonly offset: number } {
|
|
31
|
+
const limit = payload.limit ?? 50;
|
|
32
|
+
const offset = payload.offset ?? 0;
|
|
33
|
+
if (typeof limit !== "number" || !Number.isInteger(limit) || limit < 0) {
|
|
34
|
+
throw new UnprocessableError("invalid_list_limit", {
|
|
35
|
+
details: { hint: "limit must be a non-negative integer" },
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
if (typeof offset !== "number" || !Number.isInteger(offset) || offset < 0) {
|
|
39
|
+
throw new UnprocessableError("invalid_list_offset", {
|
|
40
|
+
details: { hint: "offset must be a non-negative integer" },
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
return { limit: Math.min(limit, MAX_LIST_LIMIT), offset };
|
|
44
|
+
}
|
|
45
|
+
|
|
21
46
|
export function createReadVerbs(ctx: ExecutorContext): Pick<EventStoreExecutor, "list" | "detail"> {
|
|
22
47
|
const {
|
|
23
48
|
table,
|
|
@@ -37,8 +62,7 @@ export function createReadVerbs(ctx: ExecutorContext): Pick<EventStoreExecutor,
|
|
|
37
62
|
// list + detail are unchanged from crud-executor — projections are the
|
|
38
63
|
// read-model and serve these queries directly.
|
|
39
64
|
async list(payload, user, db, runtimeOptions) {
|
|
40
|
-
const limit = payload
|
|
41
|
-
const offset = payload.offset ?? 0;
|
|
65
|
+
const { limit, offset } = resolveListPagination(payload);
|
|
42
66
|
const totalCount = payload.totalCount === true;
|
|
43
67
|
|
|
44
68
|
// H.2 — entity-level read ownership. Decide before touching search or
|
package/src/db/sql-inventory.ts
CHANGED
|
@@ -48,7 +48,22 @@ export const RAW_SQL_ALLOWLIST: ReadonlyArray<RegExp> = [
|
|
|
48
48
|
/\/packages\/framework\/src\/db\/sql-inventory\.ts$/,
|
|
49
49
|
/\/packages\/framework\/src\/bun-db\/query\.ts$/,
|
|
50
50
|
/\/packages\/framework\/src\/testing\//,
|
|
51
|
-
|
|
51
|
+
// ponytail: explicit enumeration — blanket regex auto-allowed any new .unsafe()
|
|
52
|
+
/\/bundled-features\/src\/billing-foundation\/db\/queries\/subscription-projection\.ts$/,
|
|
53
|
+
/\/bundled-features\/src\/config\/db\/queries\/resolver\.ts$/,
|
|
54
|
+
/\/bundled-features\/src\/custom-fields\/db\/queries\/field-access\.ts$/,
|
|
55
|
+
/\/bundled-features\/src\/custom-fields\/db\/queries\/projection\.ts$/,
|
|
56
|
+
/\/bundled-features\/src\/custom-fields\/db\/queries\/quota\.ts$/,
|
|
57
|
+
/\/bundled-features\/src\/custom-fields\/db\/queries\/retention\.ts$/,
|
|
58
|
+
/\/bundled-features\/src\/custom-fields\/db\/queries\/user-data-rights\.ts$/,
|
|
59
|
+
/\/bundled-features\/src\/delivery\/db\/queries\/preferences\.ts$/,
|
|
60
|
+
/\/bundled-features\/src\/form-draft\/db\/queries\/cleanup\.ts$/,
|
|
61
|
+
/\/bundled-features\/src\/form-draft\/db\/queries\/draft-count\.ts$/,
|
|
62
|
+
/\/bundled-features\/src\/form-draft\/db\/queries\/owned-file-refs\.ts$/,
|
|
63
|
+
/\/bundled-features\/src\/inbound-mail-foundation\/db\/queries\/inbound-projections\.ts$/,
|
|
64
|
+
/\/bundled-features\/src\/secrets\/db\/queries\/read\.ts$/,
|
|
65
|
+
/\/bundled-features\/src\/sessions\/db\/queries\/cleanup\.ts$/,
|
|
66
|
+
/\/bundled-features\/src\/user\/db\/queries\/stream-tenant-backfill\.ts$/,
|
|
52
67
|
/\/packages\/framework\/src\/engine\/steps\/unsafe-projection-/,
|
|
53
68
|
/\/samples\/(apps|recipes)\/[^/]+\/src\/db\/queries\//,
|
|
54
69
|
/\/bin\/commands\//,
|
|
@@ -112,6 +112,28 @@ describe("validateBoot — action wiring (no function values)", () => {
|
|
|
112
112
|
expect(() => validateBoot([feature])).toThrow(/toolbarAction "sync" payload is a function/);
|
|
113
113
|
});
|
|
114
114
|
|
|
115
|
+
test("projectionDetail action payload as function → Throw (fw#2166)", () => {
|
|
116
|
+
const feature = defineFeature("shop", (r) => {
|
|
117
|
+
r.screen({
|
|
118
|
+
id: "order-detail",
|
|
119
|
+
type: "projectionDetail",
|
|
120
|
+
query: "shop:query:order:detail",
|
|
121
|
+
layout: { sections: [{ title: "s", fields: ["total"] }] },
|
|
122
|
+
actions: [
|
|
123
|
+
{
|
|
124
|
+
kind: "writeHandler",
|
|
125
|
+
id: "archive",
|
|
126
|
+
label: "actions.archive",
|
|
127
|
+
handler: "shop:write:archive",
|
|
128
|
+
// biome-ignore lint/suspicious/noExplicitAny: intentional type violation under test
|
|
129
|
+
payload: ((row: unknown) => ({ id: row })) as any,
|
|
130
|
+
},
|
|
131
|
+
],
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
expect(() => validateBoot([feature])).toThrow(/action "archive" payload is a function/);
|
|
135
|
+
});
|
|
136
|
+
|
|
115
137
|
test("entityList column renderer as function → Throw", () => {
|
|
116
138
|
const feature = defineFeature("shop", (r) => {
|
|
117
139
|
r.entity("product", createEntity({ fields: { name: createTextField() } }));
|
|
@@ -218,6 +240,30 @@ describe("validateBoot — action wiring (no function values)", () => {
|
|
|
218
240
|
expect(() => validateBoot([feature])).toThrow(/column "amount" renderer is a function/);
|
|
219
241
|
});
|
|
220
242
|
|
|
243
|
+
test("projectionDetail relatedList column renderer as function → Throw (fw#2166)", () => {
|
|
244
|
+
const feature = defineFeature("shop", (r) => {
|
|
245
|
+
r.screen({
|
|
246
|
+
id: "order-detail",
|
|
247
|
+
type: "projectionDetail",
|
|
248
|
+
query: "shop:query:order:detail",
|
|
249
|
+
layout: {
|
|
250
|
+
sections: [
|
|
251
|
+
{
|
|
252
|
+
kind: "relatedList",
|
|
253
|
+
title: "Payments",
|
|
254
|
+
query: "shop:query:order:payments",
|
|
255
|
+
columns: [
|
|
256
|
+
// biome-ignore lint/suspicious/noExplicitAny: intentional type violation under test
|
|
257
|
+
{ field: "amount", renderer: ((v: unknown) => String(v)) as any },
|
|
258
|
+
],
|
|
259
|
+
},
|
|
260
|
+
],
|
|
261
|
+
},
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
expect(() => validateBoot([feature])).toThrow(/column "amount" renderer is a function/);
|
|
265
|
+
});
|
|
266
|
+
|
|
221
267
|
test("projectionList rowAction payload as function → Throw", () => {
|
|
222
268
|
const feature = defineFeature("shop", (r) => {
|
|
223
269
|
r.screen({
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { validateBoot } from "../boot-validator";
|
|
4
|
+
import { defineFeature } from "../define-feature";
|
|
5
|
+
|
|
6
|
+
describe("validateBoot — projectionList screens", () => {
|
|
7
|
+
test("rejects hand-written searchable:true when the query schema has no search param (3a)", () => {
|
|
8
|
+
const feature = defineFeature("ledger", (r) => {
|
|
9
|
+
r.queryHandler("schedule:list", z.object({}), async () => ({ rows: [], nextCursor: null }), {
|
|
10
|
+
access: { openToAll: true },
|
|
11
|
+
});
|
|
12
|
+
r.screen({
|
|
13
|
+
id: "schedule-list",
|
|
14
|
+
type: "projectionList",
|
|
15
|
+
query: "ledger:query:schedule:list",
|
|
16
|
+
columns: ["description"],
|
|
17
|
+
searchable: true,
|
|
18
|
+
});
|
|
19
|
+
r.translations({
|
|
20
|
+
keys: { "screen:schedule-list.title": { de: "Liste", en: "List" } },
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
expect(() => validateBoot([feature])).toThrow(/searchable: true.*"search"/);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("requires defaultSort when the query schema accepts search (3b)", () => {
|
|
27
|
+
const feature = defineFeature("ledger", (r) => {
|
|
28
|
+
r.queryHandler(
|
|
29
|
+
"schedule:list",
|
|
30
|
+
z.object({ search: z.string().optional() }),
|
|
31
|
+
async () => ({ rows: [], nextCursor: null }),
|
|
32
|
+
{ access: { openToAll: true } },
|
|
33
|
+
);
|
|
34
|
+
r.screen({
|
|
35
|
+
id: "schedule-list",
|
|
36
|
+
type: "projectionList",
|
|
37
|
+
query: "ledger:query:schedule:list",
|
|
38
|
+
columns: ["description"],
|
|
39
|
+
});
|
|
40
|
+
r.translations({
|
|
41
|
+
keys: { "screen:schedule-list.title": { de: "Liste", en: "List" } },
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
expect(() => validateBoot([feature])).toThrow(/defaultSort required/);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("requires defaultSort when the query schema accepts sort (3b)", () => {
|
|
48
|
+
const feature = defineFeature("ledger", (r) => {
|
|
49
|
+
r.queryHandler(
|
|
50
|
+
"schedule:list",
|
|
51
|
+
z.object({ sort: z.string().optional() }),
|
|
52
|
+
async () => ({ rows: [], nextCursor: null }),
|
|
53
|
+
{ access: { openToAll: true } },
|
|
54
|
+
);
|
|
55
|
+
r.screen({
|
|
56
|
+
id: "schedule-list",
|
|
57
|
+
type: "projectionList",
|
|
58
|
+
query: "ledger:query:schedule:list",
|
|
59
|
+
columns: ["description"],
|
|
60
|
+
});
|
|
61
|
+
r.translations({
|
|
62
|
+
keys: { "screen:schedule-list.title": { de: "Liste", en: "List" } },
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
expect(() => validateBoot([feature])).toThrow(/defaultSort required/);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("passes when search/sort are active and defaultSort is set", () => {
|
|
69
|
+
const feature = defineFeature("ledger", (r) => {
|
|
70
|
+
r.queryHandler(
|
|
71
|
+
"schedule:list",
|
|
72
|
+
z.object({ search: z.string().optional(), sort: z.string().optional() }),
|
|
73
|
+
async () => ({ rows: [], nextCursor: null }),
|
|
74
|
+
{ access: { openToAll: true } },
|
|
75
|
+
);
|
|
76
|
+
r.screen({
|
|
77
|
+
id: "schedule-list",
|
|
78
|
+
type: "projectionList",
|
|
79
|
+
query: "ledger:query:schedule:list",
|
|
80
|
+
columns: ["description"],
|
|
81
|
+
searchable: true,
|
|
82
|
+
defaultSort: { field: "description", dir: "asc" },
|
|
83
|
+
});
|
|
84
|
+
r.translations({
|
|
85
|
+
keys: { "screen:schedule-list.title": { de: "Liste", en: "List" } },
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
expect(() => validateBoot([feature])).not.toThrow();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("passes when the schema offers neither search nor sort and no defaultSort is set", () => {
|
|
92
|
+
const feature = defineFeature("ledger", (r) => {
|
|
93
|
+
r.queryHandler("schedule:list", z.object({}), async () => ({ rows: [], nextCursor: null }), {
|
|
94
|
+
access: { openToAll: true },
|
|
95
|
+
});
|
|
96
|
+
r.screen({
|
|
97
|
+
id: "schedule-list",
|
|
98
|
+
type: "projectionList",
|
|
99
|
+
query: "ledger:query:schedule:list",
|
|
100
|
+
columns: ["description"],
|
|
101
|
+
});
|
|
102
|
+
r.translations({
|
|
103
|
+
keys: { "screen:schedule-list.title": { de: "Liste", en: "List" } },
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
expect(() => validateBoot([feature])).not.toThrow();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("rejects hand-written searchable:false when the query schema accepts search and the screen isn't whitelisted", () => {
|
|
110
|
+
const feature = defineFeature("ledger", (r) => {
|
|
111
|
+
r.queryHandler(
|
|
112
|
+
"schedule:list",
|
|
113
|
+
z.object({ search: z.string().optional() }),
|
|
114
|
+
async () => ({ rows: [], nextCursor: null }),
|
|
115
|
+
{ access: { openToAll: true } },
|
|
116
|
+
);
|
|
117
|
+
r.screen({
|
|
118
|
+
id: "schedule-list",
|
|
119
|
+
type: "projectionList",
|
|
120
|
+
query: "ledger:query:schedule:list",
|
|
121
|
+
columns: ["description"],
|
|
122
|
+
searchable: false,
|
|
123
|
+
defaultSort: { field: "description", dir: "asc" },
|
|
124
|
+
});
|
|
125
|
+
r.translations({
|
|
126
|
+
keys: { "screen:schedule-list.title": { de: "Liste", en: "List" } },
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
expect(() => validateBoot([feature])).toThrow(/searchable: false disables it/);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("passes hand-written searchable:false on a whitelisted screen id even when the schema accepts search", () => {
|
|
133
|
+
const feature = defineFeature("ledger", (r) => {
|
|
134
|
+
r.queryHandler(
|
|
135
|
+
"schedule:list",
|
|
136
|
+
z.object({ search: z.string().optional() }),
|
|
137
|
+
async () => ({ rows: [], nextCursor: null }),
|
|
138
|
+
{ access: { openToAll: true } },
|
|
139
|
+
);
|
|
140
|
+
r.screen({
|
|
141
|
+
id: "download-attempt-list",
|
|
142
|
+
type: "projectionList",
|
|
143
|
+
query: "ledger:query:schedule:list",
|
|
144
|
+
columns: ["description"],
|
|
145
|
+
searchable: false,
|
|
146
|
+
});
|
|
147
|
+
r.translations({
|
|
148
|
+
keys: { "screen:download-attempt-list.title": { de: "Liste", en: "List" } },
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
expect(() => validateBoot([feature])).not.toThrow();
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("rejects hand-authored sortable on a projectionList screen (fw#2165 review)", () => {
|
|
155
|
+
const feature = defineFeature("ledger", (r) => {
|
|
156
|
+
r.queryHandler("schedule:list", z.object({}), async () => ({ rows: [], nextCursor: null }), {
|
|
157
|
+
access: { openToAll: true },
|
|
158
|
+
});
|
|
159
|
+
r.screen({
|
|
160
|
+
id: "schedule-list",
|
|
161
|
+
type: "projectionList",
|
|
162
|
+
query: "ledger:query:schedule:list",
|
|
163
|
+
columns: ["description"],
|
|
164
|
+
sortable: true,
|
|
165
|
+
});
|
|
166
|
+
r.translations({
|
|
167
|
+
keys: { "screen:schedule-list.title": { de: "Liste", en: "List" } },
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
expect(() => validateBoot([feature])).toThrow(/sortable is derived/);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("rejects hand-authored paginated on a projectionList screen (fw#2165 review)", () => {
|
|
174
|
+
const feature = defineFeature("ledger", (r) => {
|
|
175
|
+
r.queryHandler("schedule:list", z.object({}), async () => ({ rows: [], nextCursor: null }), {
|
|
176
|
+
access: { openToAll: true },
|
|
177
|
+
});
|
|
178
|
+
r.screen({
|
|
179
|
+
id: "schedule-list",
|
|
180
|
+
type: "projectionList",
|
|
181
|
+
query: "ledger:query:schedule:list",
|
|
182
|
+
columns: ["description"],
|
|
183
|
+
paginated: false,
|
|
184
|
+
});
|
|
185
|
+
r.translations({
|
|
186
|
+
keys: { "screen:schedule-list.title": { de: "Liste", en: "List" } },
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
expect(() => validateBoot([feature])).toThrow(/paginated is derived/);
|
|
190
|
+
});
|
|
191
|
+
});
|
|
@@ -8,10 +8,12 @@
|
|
|
8
8
|
// im Browser-Bundle.
|
|
9
9
|
|
|
10
10
|
import { describe, expect, test } from "bun:test";
|
|
11
|
+
import { z } from "zod";
|
|
11
12
|
import { buildAppSchema, findNonJsonSafePath } from "../build-app-schema";
|
|
12
13
|
import { defineFeature } from "../define-feature";
|
|
13
14
|
import { createRegistry } from "../registry";
|
|
14
15
|
import type { EntityDefinition } from "../types/fields";
|
|
16
|
+
import type { ProjectionListScreenDefinition } from "../types/screen";
|
|
15
17
|
|
|
16
18
|
describe("buildAppSchema", () => {
|
|
17
19
|
test("Multi-Feature: jedes Feature wird mit eigenem featureName projiziert", () => {
|
|
@@ -455,6 +457,98 @@ describe("buildAppSchema", () => {
|
|
|
455
457
|
expect(entity?.derivedFields?.["phase"]).not.toHaveProperty("derive");
|
|
456
458
|
expect(findNonJsonSafePath(app, "schema")).toBeNull();
|
|
457
459
|
});
|
|
460
|
+
|
|
461
|
+
// fw#2165: projectionList's searchable/sortable/paginated are derived from
|
|
462
|
+
// the bound query handler's Zod schema, not authored — see
|
|
463
|
+
// deriveProjectionListCapabilities in build-app-schema.ts.
|
|
464
|
+
test("projectionList: search/sort/cursor in the query schema become derived capabilities (fw#2165)", () => {
|
|
465
|
+
const f = defineFeature("ledger", (r) => {
|
|
466
|
+
r.queryHandler(
|
|
467
|
+
"schedule:list",
|
|
468
|
+
z.object({
|
|
469
|
+
search: z.string().optional(),
|
|
470
|
+
sort: z.string().optional(),
|
|
471
|
+
cursor: z.string().optional(),
|
|
472
|
+
}),
|
|
473
|
+
async () => ({ rows: [], nextCursor: null }),
|
|
474
|
+
);
|
|
475
|
+
r.screen({
|
|
476
|
+
id: "schedule-list",
|
|
477
|
+
type: "projectionList",
|
|
478
|
+
query: "ledger:query:schedule:list",
|
|
479
|
+
columns: ["description"],
|
|
480
|
+
});
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
const app = buildAppSchema(createRegistry([f]));
|
|
484
|
+
const screen = app.features[0]?.screens[0] as ProjectionListScreenDefinition;
|
|
485
|
+
expect(screen.searchable).toBe(true);
|
|
486
|
+
expect(screen.sortable).toBe(true);
|
|
487
|
+
expect(screen.paginated).toBe(true);
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
test("projectionList: a query schema without search/sort/cursor derives no capability", () => {
|
|
491
|
+
const f = defineFeature("ledger", (r) => {
|
|
492
|
+
r.queryHandler("schedule:list", z.object({}), async () => ({ rows: [], nextCursor: null }));
|
|
493
|
+
r.screen({
|
|
494
|
+
id: "schedule-list",
|
|
495
|
+
type: "projectionList",
|
|
496
|
+
query: "ledger:query:schedule:list",
|
|
497
|
+
columns: ["description"],
|
|
498
|
+
});
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
const app = buildAppSchema(createRegistry([f]));
|
|
502
|
+
const screen = app.features[0]?.screens[0] as ProjectionListScreenDefinition;
|
|
503
|
+
expect(screen.searchable).toBe(false);
|
|
504
|
+
expect(screen.sortable).toBe(false);
|
|
505
|
+
expect(screen.paginated).toBe(false);
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
test("projectionList: a non-ZodObject query schema (z.union) derives no capability and doesn't throw", () => {
|
|
509
|
+
const f = defineFeature("ledger", (r) => {
|
|
510
|
+
r.queryHandler(
|
|
511
|
+
"schedule:list",
|
|
512
|
+
z.union([z.object({ a: z.string() }), z.object({ b: z.string() })]),
|
|
513
|
+
async () => ({ rows: [], nextCursor: null }),
|
|
514
|
+
);
|
|
515
|
+
r.screen({
|
|
516
|
+
id: "schedule-list",
|
|
517
|
+
type: "projectionList",
|
|
518
|
+
query: "ledger:query:schedule:list",
|
|
519
|
+
columns: ["description"],
|
|
520
|
+
});
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
let app: ReturnType<typeof buildAppSchema> | undefined;
|
|
524
|
+
expect(() => {
|
|
525
|
+
app = buildAppSchema(createRegistry([f]));
|
|
526
|
+
}).not.toThrow();
|
|
527
|
+
const screen = app?.features[0]?.screens[0] as ProjectionListScreenDefinition;
|
|
528
|
+
expect(screen.searchable).toBe(false);
|
|
529
|
+
expect(screen.sortable).toBe(false);
|
|
530
|
+
expect(screen.paginated).toBe(false);
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
test("projectionList: author-written searchable:false survives even when the schema accepts search", () => {
|
|
534
|
+
const f = defineFeature("ledger", (r) => {
|
|
535
|
+
r.queryHandler("schedule:list", z.object({ search: z.string().optional() }), async () => ({
|
|
536
|
+
rows: [],
|
|
537
|
+
nextCursor: null,
|
|
538
|
+
}));
|
|
539
|
+
r.screen({
|
|
540
|
+
id: "schedule-list",
|
|
541
|
+
type: "projectionList",
|
|
542
|
+
query: "ledger:query:schedule:list",
|
|
543
|
+
columns: ["description"],
|
|
544
|
+
searchable: false,
|
|
545
|
+
});
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
const app = buildAppSchema(createRegistry([f]));
|
|
549
|
+
const screen = app.features[0]?.screens[0] as ProjectionListScreenDefinition;
|
|
550
|
+
expect(screen.searchable).toBe(false);
|
|
551
|
+
});
|
|
458
552
|
});
|
|
459
553
|
|
|
460
554
|
describe("findNonJsonSafePath", () => {
|