@cosmicdrift/kumiko-framework 0.65.0 → 0.66.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 +1 -1
- package/src/db/__tests__/event-store-executor-list.integration.test.ts +65 -0
- package/src/db/event-store-executor.ts +49 -36
- package/src/engine/__tests__/build-app-schema.test.ts +21 -0
- package/src/engine/build-app-schema.ts +3 -0
- package/src/engine/entity-handlers.ts +11 -0
- package/src/engine/types/nav.ts +17 -0
- package/src/engine/types/screen.ts +8 -1
- 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.66.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>",
|
|
@@ -256,6 +256,71 @@ describe("event-store-executor.list — filter (Tier 2.7c)", () => {
|
|
|
256
256
|
expect(res.rows).toHaveLength(4);
|
|
257
257
|
expect(res.total).toBe(4);
|
|
258
258
|
});
|
|
259
|
+
|
|
260
|
+
test("filters[] AND: zwei dynamische Filter werden mit AND verknüpft", async () => {
|
|
261
|
+
await seed(10);
|
|
262
|
+
const res = await exec.list(
|
|
263
|
+
{
|
|
264
|
+
limit: 50,
|
|
265
|
+
sort: "rank",
|
|
266
|
+
sortDirection: "asc",
|
|
267
|
+
filters: [
|
|
268
|
+
{ field: "rank", op: "gt", value: 2 },
|
|
269
|
+
{ field: "rank", op: "lt", value: 6 },
|
|
270
|
+
],
|
|
271
|
+
},
|
|
272
|
+
admin,
|
|
273
|
+
tdb,
|
|
274
|
+
);
|
|
275
|
+
expect(res.rows.map((r) => r["rank"])).toEqual([3, 4, 5]);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test("filters[] in: Faceted-Multi-Select rank in [2,4,8]", async () => {
|
|
279
|
+
await seed(10);
|
|
280
|
+
const res = await exec.list(
|
|
281
|
+
{
|
|
282
|
+
limit: 50,
|
|
283
|
+
sort: "rank",
|
|
284
|
+
sortDirection: "asc",
|
|
285
|
+
filters: [{ field: "rank", op: "in", value: [2, 4, 8] }],
|
|
286
|
+
},
|
|
287
|
+
admin,
|
|
288
|
+
tdb,
|
|
289
|
+
);
|
|
290
|
+
expect(res.rows.map((r) => r["rank"])).toEqual([2, 4, 8]);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test("statischer filter + dynamische filters[] kombinieren mit AND", async () => {
|
|
294
|
+
await seed(10);
|
|
295
|
+
const res = await exec.list(
|
|
296
|
+
{
|
|
297
|
+
limit: 50,
|
|
298
|
+
sort: "rank",
|
|
299
|
+
sortDirection: "asc",
|
|
300
|
+
filter: { field: "rank", op: "gt", value: 2 },
|
|
301
|
+
filters: [{ field: "rank", op: "in", value: [1, 3, 5, 9] }],
|
|
302
|
+
},
|
|
303
|
+
admin,
|
|
304
|
+
tdb,
|
|
305
|
+
);
|
|
306
|
+
// gt:2 AND in[1,3,5,9] → 3,5,9 (1 fällt durch gt:2 raus)
|
|
307
|
+
expect(res.rows.map((r) => r["rank"])).toEqual([3, 5, 9]);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
test("filters[] mit leerem in-Array: leeres Resultat (kein Match-All)", async () => {
|
|
311
|
+
await seed(5);
|
|
312
|
+
const res = await exec.list(
|
|
313
|
+
{
|
|
314
|
+
limit: 50,
|
|
315
|
+
sort: "rank",
|
|
316
|
+
sortDirection: "asc",
|
|
317
|
+
filters: [{ field: "rank", op: "in", value: [] }],
|
|
318
|
+
},
|
|
319
|
+
admin,
|
|
320
|
+
tdb,
|
|
321
|
+
);
|
|
322
|
+
expect(res.rows).toHaveLength(0);
|
|
323
|
+
});
|
|
259
324
|
});
|
|
260
325
|
|
|
261
326
|
describe("event-store-executor.list — runtime SearchAdapter (Tier 2.7e Audit-Fix #1)", () => {
|
|
@@ -200,6 +200,15 @@ export type EventStoreExecutor = {
|
|
|
200
200
|
readonly value: unknown;
|
|
201
201
|
}
|
|
202
202
|
| undefined;
|
|
203
|
+
// User-gewählte Faceted-Filter (dynamisch, additiv zum statischen
|
|
204
|
+
// `filter`). Alle werden mit AND verknüpft.
|
|
205
|
+
filters?:
|
|
206
|
+
| ReadonlyArray<{
|
|
207
|
+
readonly field: string;
|
|
208
|
+
readonly op: "eq" | "ne" | "lt" | "gt" | "in";
|
|
209
|
+
readonly value: unknown;
|
|
210
|
+
}>
|
|
211
|
+
| undefined;
|
|
203
212
|
},
|
|
204
213
|
user: SessionUser,
|
|
205
214
|
db: TenantDb,
|
|
@@ -875,45 +884,49 @@ export function createEventStoreExecutor(
|
|
|
875
884
|
whereSql.push(shifted.sqlText);
|
|
876
885
|
for (const p of shifted.params) params.push(p);
|
|
877
886
|
}
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
}
|
|
887
|
+
const applyFilter = (f: {
|
|
888
|
+
readonly field: string;
|
|
889
|
+
readonly op: "eq" | "ne" | "lt" | "gt" | "in";
|
|
890
|
+
readonly value: unknown;
|
|
891
|
+
}): void => {
|
|
892
|
+
if (table[f.field] === undefined) {
|
|
893
|
+
// skip: unknown field — not a real column, drop the filter (injection guard)
|
|
894
|
+
return;
|
|
895
|
+
}
|
|
896
|
+
const screen = buildFilterWhere(f.field, f.op, f.value);
|
|
897
|
+
if (screen === null) {
|
|
898
|
+
whereSql.push("FALSE");
|
|
899
|
+
// skip: filter is unsatisfiable → emit FALSE, no params to bind
|
|
900
|
+
return;
|
|
901
|
+
}
|
|
902
|
+
for (const [field, value] of Object.entries(screen)) {
|
|
903
|
+
if (Array.isArray(value)) {
|
|
904
|
+
const placeholders = value.map((v) => {
|
|
905
|
+
params.push(v);
|
|
906
|
+
return `$${params.length}`;
|
|
907
|
+
});
|
|
908
|
+
whereSql.push(`${colSql(field)} IN (${placeholders.join(", ")})`);
|
|
909
|
+
} else if (typeof value === "object" && value !== null) {
|
|
910
|
+
const opMap: Record<string, string> = {
|
|
911
|
+
gt: ">",
|
|
912
|
+
gte: ">=",
|
|
913
|
+
lt: "<",
|
|
914
|
+
lte: "<=",
|
|
915
|
+
ne: "<>",
|
|
916
|
+
};
|
|
917
|
+
for (const [opKey, opSym] of Object.entries(opMap)) {
|
|
918
|
+
if (!(opKey in value)) continue;
|
|
919
|
+
params.push((value as Record<string, unknown>)[opKey]);
|
|
920
|
+
whereSql.push(`${colSql(field)} ${opSym} $${params.length}`);
|
|
913
921
|
}
|
|
922
|
+
} else {
|
|
923
|
+
params.push(value);
|
|
924
|
+
whereSql.push(`${colSql(field)} = $${params.length}`);
|
|
914
925
|
}
|
|
915
926
|
}
|
|
916
|
-
}
|
|
927
|
+
};
|
|
928
|
+
if (payload.filter !== undefined) applyFilter(payload.filter);
|
|
929
|
+
if (payload.filters !== undefined) for (const f of payload.filters) applyFilter(f);
|
|
917
930
|
|
|
918
931
|
const orderByClause =
|
|
919
932
|
payload.sort && table[payload.sort]
|
|
@@ -134,6 +134,27 @@ describe("buildAppSchema", () => {
|
|
|
134
134
|
expect(fields["label"]?.["default"]).toBe("");
|
|
135
135
|
});
|
|
136
136
|
|
|
137
|
+
test("filterable kommt ins Client-Schema (steuert die Faceted-Filter)", () => {
|
|
138
|
+
const entity = {
|
|
139
|
+
fields: {
|
|
140
|
+
status: { type: "select", options: ["draft", "published"], filterable: true },
|
|
141
|
+
name: { type: "text" },
|
|
142
|
+
},
|
|
143
|
+
} as unknown as EntityDefinition;
|
|
144
|
+
const f = defineFeature("ent", (r) => {
|
|
145
|
+
r.entity("thing", entity);
|
|
146
|
+
});
|
|
147
|
+
const app = buildAppSchema(createRegistry([f]));
|
|
148
|
+
const fields = (
|
|
149
|
+
app.features[0]?.entities["thing"] as unknown as {
|
|
150
|
+
fields: Record<string, Record<string, unknown>>;
|
|
151
|
+
}
|
|
152
|
+
).fields;
|
|
153
|
+
expect(fields["status"]?.["filterable"]).toBe(true);
|
|
154
|
+
// Felder ohne filterable tragen den Key nicht (kein false-Müll).
|
|
155
|
+
expect(fields["name"]?.["filterable"]).toBeUndefined();
|
|
156
|
+
});
|
|
157
|
+
|
|
137
158
|
test("AppSchema ist via JSON.stringify roundtrip-sicher", () => {
|
|
138
159
|
// Echter Smoke-Test des Vertrags — wenn jemand in den project-
|
|
139
160
|
// Helper eine Function reinschmuggelt, würde das hier brennen.
|
|
@@ -311,6 +311,9 @@ function projectField(fieldDef: FieldDefinition): FieldDefinition {
|
|
|
311
311
|
if (typeof def["type"] === "string") out["type"] = def["type"];
|
|
312
312
|
if (typeof def["required"] === "boolean") out["required"] = def["required"];
|
|
313
313
|
if (typeof def["sortable"] === "boolean") out["sortable"] = def["sortable"];
|
|
314
|
+
// filterable steuert die Faceted-Filter-Dropdowns im Renderer (select/
|
|
315
|
+
// boolean) — muss daher ins Client-Schema.
|
|
316
|
+
if (typeof def["filterable"] === "boolean") out["filterable"] = def["filterable"];
|
|
314
317
|
if (isLiteral(def["default"])) out["default"] = def["default"];
|
|
315
318
|
// Select: options-Liste ist plain JSON, durchschicken.
|
|
316
319
|
if (Array.isArray(def["options"])) out["options"] = def["options"];
|
|
@@ -95,6 +95,17 @@ const listSchema = z.object({
|
|
|
95
95
|
value: z.unknown(),
|
|
96
96
|
})
|
|
97
97
|
.optional(),
|
|
98
|
+
// User-gewählte Faceted-Filter (dynamisch). Additiv zum statischen
|
|
99
|
+
// `filter` — executor.list verknüpft alle mit AND.
|
|
100
|
+
filters: z
|
|
101
|
+
.array(
|
|
102
|
+
z.object({
|
|
103
|
+
field: z.string(),
|
|
104
|
+
op: z.enum(["eq", "ne", "lt", "gt", "in"]),
|
|
105
|
+
value: z.unknown(),
|
|
106
|
+
}),
|
|
107
|
+
)
|
|
108
|
+
.optional(),
|
|
98
109
|
});
|
|
99
110
|
|
|
100
111
|
function parseHandlerName<TVerb extends string>(
|
package/src/engine/types/nav.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { AccessRule } from "./handlers";
|
|
2
|
+
import type { TargetRef } from "./target-ref";
|
|
3
|
+
import type { TreeAction } from "./tree-node";
|
|
2
4
|
|
|
3
5
|
// Nav entry declaration. Every feature that wants to appear in the app's
|
|
4
6
|
// navigation tree registers one or more entries via r.nav(). The engine
|
|
@@ -34,6 +36,21 @@ export type NavDefinition = {
|
|
|
34
36
|
// ("<feature>:screen:<id>"). Omit for pure grouping entries (a parent-only
|
|
35
37
|
// nav node that renders a sub-tree but has no target screen itself).
|
|
36
38
|
readonly screen?: string;
|
|
39
|
+
// Polymorphes Klick-Ziel (öffnet die EditorPanel-Maske via Target-
|
|
40
|
+
// Resolver) — Alternative zu `screen`. Ein Knoten trägt screen XOR
|
|
41
|
+
// target; der Renderer dispatcht das target statt einen Route-Link zu
|
|
42
|
+
// rendern. Gespiegelt aus dem alten Visual-Tree (TreeNode.target).
|
|
43
|
+
readonly target?: TargetRef;
|
|
44
|
+
// Hover-Actions rechts in der Zeile (VS-Code-Pattern) — erst bei Hover
|
|
45
|
+
// sichtbar. Reihenfolge wie deklariert.
|
|
46
|
+
readonly actions?: readonly TreeAction[];
|
|
47
|
+
// „+"-Affordance am Knoten. Klick dispatcht createAction.target; der
|
|
48
|
+
// Provider weiß was „leer befüllen" für ihn heißt (neuer Page-Slug etc.).
|
|
49
|
+
readonly createAction?: TreeAction;
|
|
50
|
+
// Children kommen zur Laufzeit aus einem registrierten nav-provider
|
|
51
|
+
// (lazy beim Ausklappen, SSE-live via treeEntities), keyed auf diese
|
|
52
|
+
// Nav-QN. Macht den Knoten expandable auch ohne statische children.
|
|
53
|
+
readonly provider?: boolean;
|
|
37
54
|
// Role / openToAll gate. The nav resolver hides entries the user can't
|
|
38
55
|
// reach; leave unset to always show (engine stays un-opinionated about
|
|
39
56
|
// who sees what — apps that need default-deny can set { roles: [] }).
|
|
@@ -309,7 +309,10 @@ export type EditSectionSpec = EditFieldsSection | EditExtensionSection;
|
|
|
309
309
|
|
|
310
310
|
export type EditFieldsSection = {
|
|
311
311
|
readonly kind?: "fields";
|
|
312
|
-
|
|
312
|
+
/** Optional. Ohne Titel rendert die Section nur ihre Felder (keine h3-
|
|
313
|
+
* Überschrift) — für flache Forms (Card-Titel + Felder direkt, ein
|
|
314
|
+
* einzelner Abschnitt) wie bei den meisten shadcn-Form-Mustern. */
|
|
315
|
+
readonly title?: string;
|
|
313
316
|
readonly columns?: number;
|
|
314
317
|
readonly fields: readonly EditFieldSpec[];
|
|
315
318
|
};
|
|
@@ -333,6 +336,10 @@ export type EntityEditScreenDefinition = {
|
|
|
333
336
|
readonly type: "entityEdit";
|
|
334
337
|
readonly entity: string;
|
|
335
338
|
readonly layout: EditLayout;
|
|
339
|
+
/** Optionaler i18n-Key (oder Roh-String) für den Submit-Button. Default
|
|
340
|
+
* `kumiko.actions.save`. Lässt den Auto-Edit-Screen domain-spezifische
|
|
341
|
+
* CTAs zeigen ("Save Address", "Create item") statt generisch "Speichern". */
|
|
342
|
+
readonly submitLabel?: string;
|
|
336
343
|
/** Default true. `false` für Entities deren Create über einen eigenen
|
|
337
344
|
* Lifecycle-Write läuft (z.B. incident:open mit Event-Stream + Joins)
|
|
338
345
|
* statt über `<entity>:create`: unterdrückt den automatischen
|
package/src/ui-types/index.ts
CHANGED
|
@@ -74,5 +74,7 @@ export {
|
|
|
74
74
|
normalizeEditField,
|
|
75
75
|
normalizeListColumn,
|
|
76
76
|
} from "../engine/types/screen";
|
|
77
|
+
export type { TargetRef } from "../engine/types/target-ref";
|
|
78
|
+
export type { TreeAction, TreeNode, TreeNodeState } from "../engine/types/tree-node";
|
|
77
79
|
export type { WorkspaceDefinition } from "../engine/types/workspace";
|
|
78
80
|
export type { AppSchema, FeatureSchema, WorkspaceSchema } from "./app-schema";
|