@cosmicdrift/kumiko-framework 0.225.0 → 0.227.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/query.ts +37 -1
- package/src/db/__tests__/migrate-generator.test.ts +173 -1
- package/src/db/event-store-executor-read.ts +115 -54
- package/src/db/migrate-generator.ts +111 -8
- package/src/db/render-ddl.ts +1 -1
- package/src/engine/__tests__/boot-validator-query-output-schema.test.ts +435 -0
- package/src/engine/__tests__/build-app-schema.test.ts +215 -22
- package/src/engine/__tests__/multiselect-filter.integration.test.ts +141 -0
- package/src/engine/__tests__/screen.test.ts +68 -2
- package/src/engine/boot-validator/index.ts +4 -0
- package/src/engine/boot-validator/projection-list-screens.ts +2 -2
- package/src/engine/boot-validator/query-output-columns.ts +236 -0
- package/src/engine/boot-validator/screens.ts +31 -5
- package/src/engine/boot-validator/zod-shape.ts +51 -0
- package/src/engine/build-app-schema.ts +50 -13
- package/src/engine/feature-entity-handlers.ts +3 -1
- package/src/i18n/required-surface-keys.ts +4 -1
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { withBootValidatorFixture } from "../../testing/boot-validator-fixture";
|
|
4
|
+
import { validateBoot as validateBootRaw } from "../boot-validator";
|
|
5
|
+
import { defineFeature } from "../define-feature";
|
|
6
|
+
import { definePagedQueryHandler, defineQueryHandler } from "../define-handler";
|
|
7
|
+
|
|
8
|
+
function validateBoot(features: Parameters<typeof validateBootRaw>[0]): void {
|
|
9
|
+
validateBootRaw(withBootValidatorFixture(features));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const rowSchema = z.object({ id: z.string(), name: z.string() });
|
|
13
|
+
const pagedSchema = z.object({ rows: z.array(rowSchema), nextCursor: z.string().nullable() });
|
|
14
|
+
|
|
15
|
+
// fw#2493: query handlers can declare `outputSchema` — the Zod shape of
|
|
16
|
+
// their return value — so the boot-validator can catch a typo in a screen's
|
|
17
|
+
// column/field references against a query's result instead of that typo
|
|
18
|
+
// surfacing as a silently-empty field at runtime.
|
|
19
|
+
describe("validateBoot — query output schema column refs (fw#2493)", () => {
|
|
20
|
+
test("projectionList column not in the paged query's row shape throws", () => {
|
|
21
|
+
const feature = defineFeature("catalog", (r) => {
|
|
22
|
+
r.queryHandler("items:list", z.object({}), async () => ({ rows: [], nextCursor: null }), {
|
|
23
|
+
access: { openToAll: true },
|
|
24
|
+
outputSchema: pagedSchema,
|
|
25
|
+
});
|
|
26
|
+
r.screen({
|
|
27
|
+
id: "items",
|
|
28
|
+
type: "projectionList",
|
|
29
|
+
query: "catalog:query:items:list",
|
|
30
|
+
columns: ["ghost-field"],
|
|
31
|
+
});
|
|
32
|
+
r.translations({ keys: { "screen:items.title": { de: "Artikel", en: "Items" } } });
|
|
33
|
+
});
|
|
34
|
+
expect(() => validateBoot([feature])).toThrow(
|
|
35
|
+
/column "ghost-field" is not present in query "catalog:query:items:list"'s outputSchema/,
|
|
36
|
+
);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("projectionList column present in the row shape does not throw", () => {
|
|
40
|
+
const feature = defineFeature("catalog", (r) => {
|
|
41
|
+
r.queryHandler("items:list", z.object({}), async () => ({ rows: [], nextCursor: null }), {
|
|
42
|
+
access: { openToAll: true },
|
|
43
|
+
outputSchema: pagedSchema,
|
|
44
|
+
});
|
|
45
|
+
r.screen({
|
|
46
|
+
id: "items",
|
|
47
|
+
type: "projectionList",
|
|
48
|
+
query: "catalog:query:items:list",
|
|
49
|
+
columns: ["name"],
|
|
50
|
+
});
|
|
51
|
+
r.translations({ keys: { "screen:items.title": { de: "Artikel", en: "Items" } } });
|
|
52
|
+
});
|
|
53
|
+
expect(() => validateBoot([feature])).not.toThrow();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("projectionList column with a label is exempt as a virtual/computed column", () => {
|
|
57
|
+
const feature = defineFeature("catalog", (r) => {
|
|
58
|
+
r.queryHandler("items:list", z.object({}), async () => ({ rows: [], nextCursor: null }), {
|
|
59
|
+
access: { openToAll: true },
|
|
60
|
+
outputSchema: pagedSchema,
|
|
61
|
+
});
|
|
62
|
+
r.screen({
|
|
63
|
+
id: "items",
|
|
64
|
+
type: "projectionList",
|
|
65
|
+
query: "catalog:query:items:list",
|
|
66
|
+
columns: [{ field: "tags-chip", label: "catalog:screen:items.col.tags" }],
|
|
67
|
+
});
|
|
68
|
+
r.translations({
|
|
69
|
+
keys: {
|
|
70
|
+
"screen:items.title": { de: "Artikel", en: "Items" },
|
|
71
|
+
"catalog:screen:items.col.tags": { de: "Tags", en: "Tags" },
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
expect(() => validateBoot([feature])).not.toThrow();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("projectionList without a declared outputSchema skips the column check entirely", () => {
|
|
79
|
+
const feature = defineFeature("catalog", (r) => {
|
|
80
|
+
r.queryHandler("items:list", z.object({}), async () => ({ rows: [], nextCursor: null }), {
|
|
81
|
+
access: { openToAll: true },
|
|
82
|
+
});
|
|
83
|
+
r.screen({
|
|
84
|
+
id: "items",
|
|
85
|
+
type: "projectionList",
|
|
86
|
+
query: "catalog:query:items:list",
|
|
87
|
+
columns: ["anything-goes"],
|
|
88
|
+
});
|
|
89
|
+
r.translations({ keys: { "screen:items.title": { de: "Artikel", en: "Items" } } });
|
|
90
|
+
});
|
|
91
|
+
expect(() => validateBoot([feature])).not.toThrow();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("projectionList outputSchema that isn't a ZodObject (e.g. a union) skips the column check", () => {
|
|
95
|
+
const feature = defineFeature("catalog", (r) => {
|
|
96
|
+
r.queryHandler("items:list", z.object({}), async () => ({ rows: [], nextCursor: null }), {
|
|
97
|
+
access: { openToAll: true },
|
|
98
|
+
outputSchema: z.union([pagedSchema, z.null()]),
|
|
99
|
+
});
|
|
100
|
+
r.screen({
|
|
101
|
+
id: "items",
|
|
102
|
+
type: "projectionList",
|
|
103
|
+
query: "catalog:query:items:list",
|
|
104
|
+
columns: ["anything-goes"],
|
|
105
|
+
});
|
|
106
|
+
r.translations({ keys: { "screen:items.title": { de: "Artikel", en: "Items" } } });
|
|
107
|
+
});
|
|
108
|
+
expect(() => validateBoot([feature])).not.toThrow();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("relatedList column not in the query's row shape throws", () => {
|
|
112
|
+
const feature = defineFeature("app", (r) => {
|
|
113
|
+
r.queryHandler("rent:detail", z.object({}), async () => ({ description: "x" }), {
|
|
114
|
+
access: { openToAll: true },
|
|
115
|
+
outputSchema: z.object({ description: z.string() }),
|
|
116
|
+
});
|
|
117
|
+
r.queryHandler("rent:payments", z.object({}), async () => ({ rows: [], nextCursor: null }), {
|
|
118
|
+
access: { openToAll: true },
|
|
119
|
+
outputSchema: z.object({
|
|
120
|
+
rows: z.array(z.object({ amount: z.number() })),
|
|
121
|
+
nextCursor: z.string().nullable(),
|
|
122
|
+
}),
|
|
123
|
+
});
|
|
124
|
+
r.screen({
|
|
125
|
+
id: "rent-detail",
|
|
126
|
+
type: "projectionDetail",
|
|
127
|
+
query: "app:query:rent:detail",
|
|
128
|
+
layout: {
|
|
129
|
+
sections: [
|
|
130
|
+
{
|
|
131
|
+
kind: "relatedList",
|
|
132
|
+
title: "Payments",
|
|
133
|
+
query: "app:query:rent:payments",
|
|
134
|
+
columns: ["ghost-amount"],
|
|
135
|
+
},
|
|
136
|
+
],
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
expect(() => validateBoot([feature])).toThrow(
|
|
141
|
+
/relatedList section "Payments" column "ghost-amount" is not present in query "app:query:rent:payments"'s outputSchema/,
|
|
142
|
+
);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("projectionDetail header.title referencing an unknown field throws", () => {
|
|
146
|
+
const feature = defineFeature("app", (r) => {
|
|
147
|
+
r.queryHandler("tenant:detail", z.object({}), async () => ({ id: "1", name: "x" }), {
|
|
148
|
+
access: { openToAll: true },
|
|
149
|
+
outputSchema: z.object({ id: z.string(), name: z.string() }),
|
|
150
|
+
});
|
|
151
|
+
r.screen({
|
|
152
|
+
id: "tenant-detail",
|
|
153
|
+
type: "projectionDetail",
|
|
154
|
+
query: "app:query:tenant:detail",
|
|
155
|
+
layout: { sections: [{ fields: ["id"] }] },
|
|
156
|
+
header: { title: "ghost-title" },
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
expect(() => validateBoot([feature])).toThrow(
|
|
160
|
+
/header\.title references field "ghost-title" which is not present in query "app:query:tenant:detail"'s outputSchema/,
|
|
161
|
+
);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("projectionDetail header.status referencing an unknown field throws", () => {
|
|
165
|
+
const feature = defineFeature("app", (r) => {
|
|
166
|
+
r.queryHandler("tenant:detail", z.object({}), async () => ({ id: "1", name: "x" }), {
|
|
167
|
+
access: { openToAll: true },
|
|
168
|
+
outputSchema: z.object({ id: z.string(), name: z.string() }),
|
|
169
|
+
});
|
|
170
|
+
r.screen({
|
|
171
|
+
id: "tenant-detail",
|
|
172
|
+
type: "projectionDetail",
|
|
173
|
+
query: "app:query:tenant:detail",
|
|
174
|
+
layout: { sections: [{ fields: ["id"] }] },
|
|
175
|
+
header: { title: "name", status: "ghost-status" },
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
expect(() => validateBoot([feature])).toThrow(/header\.status references field "ghost-status"/);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("projectionDetail metrics referencing an unknown field throws", () => {
|
|
182
|
+
const feature = defineFeature("app", (r) => {
|
|
183
|
+
r.queryHandler("tenant:detail", z.object({}), async () => ({ id: "1", balance: 0 }), {
|
|
184
|
+
access: { openToAll: true },
|
|
185
|
+
outputSchema: z.object({ id: z.string(), balance: z.number() }),
|
|
186
|
+
});
|
|
187
|
+
r.screen({
|
|
188
|
+
id: "tenant-detail",
|
|
189
|
+
type: "projectionDetail",
|
|
190
|
+
query: "app:query:tenant:detail",
|
|
191
|
+
layout: { sections: [{ fields: ["id"] }] },
|
|
192
|
+
metrics: ["balance", "ghost-metric"],
|
|
193
|
+
fieldLabels: {
|
|
194
|
+
balance: "app:screen:tenant-detail.metric.balance",
|
|
195
|
+
"ghost-metric": "app:screen:tenant-detail.metric.ghost-metric",
|
|
196
|
+
},
|
|
197
|
+
});
|
|
198
|
+
r.translations({
|
|
199
|
+
keys: {
|
|
200
|
+
"app:screen:tenant-detail.metric.balance": { de: "Saldo", en: "Balance" },
|
|
201
|
+
"app:screen:tenant-detail.metric.ghost-metric": { de: "Geist", en: "Ghost" },
|
|
202
|
+
},
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
expect(() => validateBoot([feature])).toThrow(
|
|
206
|
+
/metrics references field "ghost-metric" which is not present in query "app:query:tenant:detail"'s outputSchema/,
|
|
207
|
+
);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("projectionDetail header + metrics all present in the outputSchema does not throw", () => {
|
|
211
|
+
const feature = defineFeature("app", (r) => {
|
|
212
|
+
r.queryHandler(
|
|
213
|
+
"tenant:detail",
|
|
214
|
+
z.object({}),
|
|
215
|
+
async () => ({ id: "1", name: "x", balance: 0 }),
|
|
216
|
+
{
|
|
217
|
+
access: { openToAll: true },
|
|
218
|
+
outputSchema: z.object({
|
|
219
|
+
id: z.string(),
|
|
220
|
+
name: z.string(),
|
|
221
|
+
balance: z.number(),
|
|
222
|
+
}),
|
|
223
|
+
},
|
|
224
|
+
);
|
|
225
|
+
r.screen({
|
|
226
|
+
id: "tenant-detail",
|
|
227
|
+
type: "projectionDetail",
|
|
228
|
+
query: "app:query:tenant:detail",
|
|
229
|
+
layout: { sections: [{ fields: ["id"] }] },
|
|
230
|
+
header: { title: "name" },
|
|
231
|
+
metrics: ["balance"],
|
|
232
|
+
fieldLabels: { balance: "app:screen:tenant-detail.metric.balance" },
|
|
233
|
+
});
|
|
234
|
+
r.translations({
|
|
235
|
+
keys: { "app:screen:tenant-detail.metric.balance": { de: "Saldo", en: "Balance" } },
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
expect(() => validateBoot([feature])).not.toThrow();
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test("dashboard stat panel valueField referencing an unknown field throws", () => {
|
|
242
|
+
const feature = defineFeature("demo", (r) => {
|
|
243
|
+
r.queryHandler("open-count", z.object({}), async () => ({ value: "0" }), {
|
|
244
|
+
access: { openToAll: true },
|
|
245
|
+
outputSchema: z.object({ value: z.string() }),
|
|
246
|
+
});
|
|
247
|
+
r.screen({
|
|
248
|
+
id: "overview",
|
|
249
|
+
type: "dashboard",
|
|
250
|
+
panels: [
|
|
251
|
+
{
|
|
252
|
+
kind: "stat",
|
|
253
|
+
id: "open",
|
|
254
|
+
label: "demo:dashboard:panel:open",
|
|
255
|
+
query: "demo:query:open-count",
|
|
256
|
+
valueField: "ghost-value",
|
|
257
|
+
},
|
|
258
|
+
],
|
|
259
|
+
});
|
|
260
|
+
r.translations({
|
|
261
|
+
keys: { "demo:dashboard:panel:open": { de: "Offen", en: "Open" } },
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
expect(() => validateBoot([feature])).toThrow(
|
|
265
|
+
/panel "open" valueField references field "ghost-value" which is not present in query "demo:query:open-count"'s outputSchema/,
|
|
266
|
+
);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test("dashboard stat-group child deltaField referencing an unknown field throws", () => {
|
|
270
|
+
const feature = defineFeature("demo", (r) => {
|
|
271
|
+
r.queryHandler("net-worth", z.object({}), async () => ({ value: "0" }), {
|
|
272
|
+
access: { openToAll: true },
|
|
273
|
+
outputSchema: z.object({ value: z.string() }),
|
|
274
|
+
});
|
|
275
|
+
r.screen({
|
|
276
|
+
id: "overview",
|
|
277
|
+
type: "dashboard",
|
|
278
|
+
panels: [
|
|
279
|
+
{
|
|
280
|
+
kind: "stat-group",
|
|
281
|
+
id: "net-worth",
|
|
282
|
+
label: "demo:dashboard:group:net-worth",
|
|
283
|
+
stats: [
|
|
284
|
+
{
|
|
285
|
+
kind: "stat",
|
|
286
|
+
id: "assets",
|
|
287
|
+
label: "demo:dashboard:panel:assets",
|
|
288
|
+
query: "demo:query:net-worth",
|
|
289
|
+
valueField: "value",
|
|
290
|
+
deltaField: "ghost-delta",
|
|
291
|
+
},
|
|
292
|
+
],
|
|
293
|
+
},
|
|
294
|
+
],
|
|
295
|
+
});
|
|
296
|
+
r.translations({
|
|
297
|
+
keys: {
|
|
298
|
+
"demo:dashboard:group:net-worth": { de: "Net Worth", en: "Net Worth" },
|
|
299
|
+
"demo:dashboard:panel:assets": { de: "Assets", en: "Assets" },
|
|
300
|
+
},
|
|
301
|
+
});
|
|
302
|
+
});
|
|
303
|
+
expect(() => validateBoot([feature])).toThrow(
|
|
304
|
+
/stat-group.*deltaField references field "ghost-delta"|panel "assets" deltaField references field "ghost-delta"/,
|
|
305
|
+
);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
test("dashboard list panel column not in the query's row shape throws", () => {
|
|
309
|
+
const feature = defineFeature("demo", (r) => {
|
|
310
|
+
r.queryHandler("next-events", z.object({}), async () => ({ rows: [], nextCursor: null }), {
|
|
311
|
+
access: { openToAll: true },
|
|
312
|
+
outputSchema: z.object({
|
|
313
|
+
rows: z.array(z.object({ title: z.string() })),
|
|
314
|
+
nextCursor: z.string().nullable(),
|
|
315
|
+
}),
|
|
316
|
+
});
|
|
317
|
+
r.screen({
|
|
318
|
+
id: "overview",
|
|
319
|
+
type: "dashboard",
|
|
320
|
+
panels: [
|
|
321
|
+
{
|
|
322
|
+
kind: "list",
|
|
323
|
+
id: "events",
|
|
324
|
+
label: "demo:dashboard:panel:events",
|
|
325
|
+
query: "demo:query:next-events",
|
|
326
|
+
columns: ["ghost-title"],
|
|
327
|
+
},
|
|
328
|
+
],
|
|
329
|
+
});
|
|
330
|
+
r.translations({
|
|
331
|
+
keys: { "demo:dashboard:panel:events": { de: "Termine", en: "Events" } },
|
|
332
|
+
});
|
|
333
|
+
});
|
|
334
|
+
expect(() => validateBoot([feature])).toThrow(
|
|
335
|
+
/panel "events" column "ghost-title" is not present in query "demo:query:next-events"'s outputSchema/,
|
|
336
|
+
);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
test("object-form r.queryHandler(defineQueryHandler(...)) threads outputSchema through", () => {
|
|
340
|
+
const feature = defineFeature("catalog", (r) => {
|
|
341
|
+
r.queryHandler(
|
|
342
|
+
defineQueryHandler({
|
|
343
|
+
name: "items:detail",
|
|
344
|
+
schema: z.object({ id: z.string() }),
|
|
345
|
+
access: { openToAll: true },
|
|
346
|
+
outputSchema: z.object({ id: z.string(), name: z.string() }),
|
|
347
|
+
handler: async () => ({ id: "1", name: "x" }),
|
|
348
|
+
}),
|
|
349
|
+
);
|
|
350
|
+
r.screen({
|
|
351
|
+
id: "item-detail",
|
|
352
|
+
type: "projectionDetail",
|
|
353
|
+
query: "catalog:query:items:detail",
|
|
354
|
+
layout: { sections: [{ fields: ["id"] }] },
|
|
355
|
+
header: { title: "ghost-name" },
|
|
356
|
+
});
|
|
357
|
+
});
|
|
358
|
+
expect(() => validateBoot([feature])).toThrow(
|
|
359
|
+
/header\.title references field "ghost-name" which is not present in query "catalog:query:items:detail"'s outputSchema/,
|
|
360
|
+
);
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
test("object-form r.queryHandler(definePagedQueryHandler(...)) threads outputSchema through", () => {
|
|
364
|
+
const feature = defineFeature("catalog", (r) => {
|
|
365
|
+
r.queryHandler(
|
|
366
|
+
definePagedQueryHandler({
|
|
367
|
+
name: "items:list",
|
|
368
|
+
schema: z.object({}),
|
|
369
|
+
access: { openToAll: true },
|
|
370
|
+
outputSchema: pagedSchema,
|
|
371
|
+
handler: async () => ({ rows: [], nextCursor: null }),
|
|
372
|
+
}),
|
|
373
|
+
);
|
|
374
|
+
r.screen({
|
|
375
|
+
id: "items",
|
|
376
|
+
type: "projectionList",
|
|
377
|
+
query: "catalog:query:items:list",
|
|
378
|
+
columns: ["ghost-field"],
|
|
379
|
+
});
|
|
380
|
+
r.translations({ keys: { "screen:items.title": { de: "Artikel", en: "Items" } } });
|
|
381
|
+
});
|
|
382
|
+
expect(() => validateBoot([feature])).toThrow(
|
|
383
|
+
/column "ghost-field" is not present in query "catalog:query:items:list"'s outputSchema/,
|
|
384
|
+
);
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
test("paged handler's outputSchema missing the rows envelope throws", () => {
|
|
388
|
+
const feature = defineFeature("catalog", (r) => {
|
|
389
|
+
r.queryHandler(
|
|
390
|
+
definePagedQueryHandler({
|
|
391
|
+
name: "items:list",
|
|
392
|
+
schema: z.object({}),
|
|
393
|
+
access: { openToAll: true },
|
|
394
|
+
// Row schema passed directly instead of wrapping it in
|
|
395
|
+
// { rows: [...], nextCursor } — the mistake fw#2493's follow-up
|
|
396
|
+
// check catches instead of silently skipping every column check.
|
|
397
|
+
outputSchema: rowSchema,
|
|
398
|
+
handler: async () => ({ rows: [], nextCursor: null }),
|
|
399
|
+
}),
|
|
400
|
+
);
|
|
401
|
+
r.screen({
|
|
402
|
+
id: "items",
|
|
403
|
+
type: "projectionList",
|
|
404
|
+
query: "catalog:query:items:list",
|
|
405
|
+
columns: ["name"],
|
|
406
|
+
});
|
|
407
|
+
r.translations({ keys: { "screen:items.title": { de: "Artikel", en: "Items" } } });
|
|
408
|
+
});
|
|
409
|
+
expect(() => validateBoot([feature])).toThrow(
|
|
410
|
+
/paged handler.*outputSchema does not describe the paged envelope.*no "rows" field/,
|
|
411
|
+
);
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
test("paged handler's outputSchema correctly wrapped in the rows envelope does not throw", () => {
|
|
415
|
+
const feature = defineFeature("catalog", (r) => {
|
|
416
|
+
r.queryHandler(
|
|
417
|
+
definePagedQueryHandler({
|
|
418
|
+
name: "items:list",
|
|
419
|
+
schema: z.object({}),
|
|
420
|
+
access: { openToAll: true },
|
|
421
|
+
outputSchema: pagedSchema,
|
|
422
|
+
handler: async () => ({ rows: [], nextCursor: null }),
|
|
423
|
+
}),
|
|
424
|
+
);
|
|
425
|
+
r.screen({
|
|
426
|
+
id: "items",
|
|
427
|
+
type: "projectionList",
|
|
428
|
+
query: "catalog:query:items:list",
|
|
429
|
+
columns: ["name"],
|
|
430
|
+
});
|
|
431
|
+
r.translations({ keys: { "screen:items.title": { de: "Artikel", en: "Items" } } });
|
|
432
|
+
});
|
|
433
|
+
expect(() => validateBoot([feature])).not.toThrow();
|
|
434
|
+
});
|
|
435
|
+
});
|