@cosmicdrift/kumiko-framework 0.65.0 → 0.67.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.65.0",
3
+ "version": "0.67.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>",
@@ -181,7 +181,7 @@
181
181
  "zod": "^4.4.3"
182
182
  },
183
183
  "devDependencies": {
184
- "@cosmicdrift/kumiko-dispatcher-live": "0.65.0",
184
+ "@cosmicdrift/kumiko-dispatcher-live": "0.66.0",
185
185
  "bun-types": "^1.3.13",
186
186
  "pino-pretty": "^13.1.3"
187
187
  },
@@ -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
- if (payload.filter !== undefined) {
879
- const col = table[payload.filter.field];
880
- if (col !== undefined) {
881
- const screen = buildFilterWhere(
882
- payload.filter.field,
883
- payload.filter.op,
884
- payload.filter.value,
885
- );
886
- if (screen === null) {
887
- whereSql.push("FALSE");
888
- } else {
889
- for (const [field, value] of Object.entries(screen)) {
890
- if (Array.isArray(value)) {
891
- const placeholders = value.map((v) => {
892
- params.push(v);
893
- return `$${params.length}`;
894
- });
895
- whereSql.push(`${colSql(field)} IN (${placeholders.join(", ")})`);
896
- } else if (typeof value === "object" && value !== null) {
897
- const opMap: Record<string, string> = {
898
- gt: ">",
899
- gte: ">=",
900
- lt: "<",
901
- lte: "<=",
902
- ne: "<>",
903
- };
904
- for (const [opKey, opSym] of Object.entries(opMap)) {
905
- if (!(opKey in value)) continue;
906
- params.push((value as Record<string, unknown>)[opKey]);
907
- whereSql.push(`${colSql(field)} ${opSym} $${params.length}`);
908
- }
909
- } else {
910
- params.push(value);
911
- whereSql.push(`${colSql(field)} = $${params.length}`);
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>(
@@ -204,6 +204,7 @@ export {
204
204
  findTierResolverUsage,
205
205
  TENANT_TIER_RESOLVER_EXT,
206
206
  type TierResolverPlugin,
207
+ type TrialGate,
207
208
  } from "./tier-resolver-extension";
208
209
  // Types
209
210
  export type {
@@ -41,12 +41,29 @@ export const TENANT_TIER_RESOLVER_EXT = "tenantTierResolver";
41
41
  * **System-context convention:** call mit SYSTEM_TENANT_ID erwartet die union
42
42
  * aller tier-features (siehe DispatcherOptions.effectiveFeatures doc-block).
43
43
  */
44
- export type EffectiveFeaturesResolver = (tenantId: TenantId) => ReadonlySet<string>;
44
+ export type EffectiveFeaturesResolver = ((tenantId: TenantId) => ReadonlySet<string>) & {
45
+ /**
46
+ * Optionaler Live-Trial-Gate, vom dispatcher-feature-gate NUR konsultiert
47
+ * wenn der synchrone Resolver ein Feature als disabled meldet. Liest das
48
+ * Signup-Datum des Tenants live (tenant.inserted_at) und returnt true wenn
49
+ * der Tenant im Trial-Fenster ist UND das Feature zum Trial-Tier gehört.
50
+ * Async + nicht im Boot-Cache, weil der Trial zeit-abgeleitet ist (ändert
51
+ * sich zwischen Requests). Nur die 2 Gate-Aufrufstellen awaiten ihn; der
52
+ * synchrone Hot-Path (ctx.hasFeature, Feature-Set) bleibt unberührt.
53
+ */
54
+ readonly trialGate?: TrialGate;
55
+ };
56
+
57
+ /**
58
+ * Live-Trial-Gate-Shape. Siehe EffectiveFeaturesResolver.trialGate.
59
+ */
60
+ export type TrialGate = (tenantId: TenantId, featureName: string) => Promise<boolean>;
45
61
 
46
62
  /**
47
63
  * Plugin-shape für tier-resolver-extension. Plugins implementieren `build`
48
64
  * als boot-time factory: kriegen `db` + `registry` (post-stack-setup),
49
- * laden initial cache aus DB, returnen den synchronen resolver-callback.
65
+ * laden initial cache aus DB, returnen den synchronen resolver-callback
66
+ * (optional mit angehängtem `trialGate`).
50
67
  */
51
68
  export type TierResolverPlugin = {
52
69
  readonly build: (deps: {
@@ -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
- readonly title: string;
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
@@ -376,6 +376,37 @@ describe("dispatcher feature-gate", () => {
376
376
  });
377
377
  });
378
378
 
379
+ test("trialGate: disabled feature is allowed on the gate's cold path when trialGate returns true", async () => {
380
+ // Beweist die Live-Trial-Mechanik: der sync resolver hat das Feature NICHT
381
+ // im Set (alles disabled), aber checkFeatureEnabled konsultiert den async
382
+ // trialGate auf dem disabled-Pfad. Deckt beide Aufrufstellen ab (query =
383
+ // ensureFeatureEnabled, write = checkFeatureEnabled).
384
+ const registry = createRegistry([toggled()]);
385
+ let trialOpen = false;
386
+ const effectiveFeatures = Object.assign(() => new Set<string>(), {
387
+ trialGate: async (_tenantId: TenantId, feature: string) => trialOpen && feature === "toggled",
388
+ });
389
+ const dispatcher = createDispatcher(registry, {}, { effectiveFeatures });
390
+
391
+ // Trial zu → 403 (resolver-Set leer, Gate verweigert).
392
+ await expect(dispatcher.query("toggled:query:widget:list", {}, user)).rejects.toThrow(
393
+ /feature toggled is disabled/,
394
+ );
395
+ const writeClosed = await dispatcher.write("toggled:write:widget:create", { name: "x" }, user);
396
+ expect(writeClosed.isSuccess).toBe(false);
397
+ if (!writeClosed.isSuccess) expect(writeClosed.error.code).toBe("feature_disabled");
398
+
399
+ // Trial offen → Gate lässt durch, obwohl der Resolver das Feature nicht
400
+ // führt. Query passiert; Write passiert das Gate (scheitert ggf. später an
401
+ // fehlender DB, aber NICHT mehr an feature_disabled).
402
+ trialOpen = true;
403
+ await expect(dispatcher.query("toggled:query:widget:list", {}, user)).resolves.toEqual({
404
+ items: [],
405
+ });
406
+ const writeOpen = await dispatcher.write("toggled:write:widget:create", { name: "x" }, user);
407
+ if (!writeOpen.isSuccess) expect(writeOpen.error.code).not.toBe("feature_disabled");
408
+ });
409
+
379
410
  test("Sprint 8a: per-tenant gating — Tenant A passes, Tenant B gets feature_disabled", async () => {
380
411
  // Beweist die Phase-1-Architektur: dispatcher ruft effectiveFeatures
381
412
  // mit user.tenantId, resolver kann pro Tenant unterschiedliche Sets
@@ -659,10 +659,10 @@ export function createDispatcher(
659
659
  // When `effectiveFeatures` is not wired (tests, apps without feature-toggles
660
660
  // loaded), every handler is treated as enabled — the gate is a pure
661
661
  // pass-through in that common case.
662
- function checkFeatureEnabled(
662
+ async function checkFeatureEnabled(
663
663
  qualifiedHandler: string,
664
664
  tenantId: TenantId,
665
- ): import("../errors").FeatureDisabledError | undefined {
665
+ ): Promise<import("../errors").FeatureDisabledError | undefined> {
666
666
  if (!effectiveFeatures) return undefined;
667
667
  const owner = registry.getHandlerFeature(qualifiedHandler);
668
668
  // skip: handler without an owning feature cannot be toggled — shouldn't
@@ -671,11 +671,18 @@ export function createDispatcher(
671
671
  if (!owner) return undefined;
672
672
  const set = effectiveFeatures(tenantId);
673
673
  if (set.has(owner)) return undefined;
674
+ // Feature is off for the stored tier — give the live trial-gate a last
675
+ // chance. Time-derived (tenant.inserted_at + window), so it can't live in
676
+ // the boot-cached sync resolver; consulted only on this already-disabled
677
+ // cold path, never on the hot enabled path.
678
+ if (effectiveFeatures.trialGate && (await effectiveFeatures.trialGate(tenantId, owner))) {
679
+ return undefined;
680
+ }
674
681
  return new FeatureDisabledError(owner, qualifiedHandler);
675
682
  }
676
683
 
677
- function ensureFeatureEnabled(qualifiedHandler: string, tenantId: TenantId): void {
678
- const err = checkFeatureEnabled(qualifiedHandler, tenantId);
684
+ async function ensureFeatureEnabled(qualifiedHandler: string, tenantId: TenantId): Promise<void> {
685
+ const err = await checkFeatureEnabled(qualifiedHandler, tenantId);
679
686
  if (err) throw err;
680
687
  }
681
688
 
@@ -737,7 +744,7 @@ export function createDispatcher(
737
744
  // disabled feature must not consume the rate-limit quota — the call
738
745
  // never happened from the feature's perspective. Order is: lookup →
739
746
  // feature-gate → rate-limit → access → validation → handler.
740
- ensureFeatureEnabled(type, user.tenantId);
747
+ await ensureFeatureEnabled(type, user.tenantId);
741
748
 
742
749
  // Rate-limit gate runs BEFORE access-check on purpose: anonymous /
743
750
  // unauthorized callers must hit the cap too (otherwise the limit
@@ -1028,7 +1035,7 @@ export function createDispatcher(
1028
1035
 
1029
1036
  // Feature-toggle gate: disabled handlers must short-circuit before any
1030
1037
  // rate-limit/access/validation work — see executeQueryInner comment.
1031
- const disabledErr = checkFeatureEnabled(type, user.tenantId);
1038
+ const disabledErr = await checkFeatureEnabled(type, user.tenantId);
1032
1039
  if (disabledErr) return writeFailure(disabledErr);
1033
1040
 
1034
1041
  // Rate-limit gate before access (same reasoning as in executeQueryInner).
@@ -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";