@cosmicdrift/kumiko-framework 0.402.0 → 0.403.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.
@@ -35,7 +35,7 @@ function createDecimalField<R extends true | false = false>(config: { precision:
35
35
  function createDerivedField(spec: DerivedFieldDef): DerivedFieldDef; // Read-time computed field for `EntityDefinition.derivedFields` (NOT `fields`), never persisted.
36
36
  function createEmbeddedField(schema: EmbeddedFieldDef["schema"], overrides?: Partial<Omit<EmbeddedFieldDef, "type" | "schema" | keyof ResolvedPiiFlags>> & PersonalAnnotationsNoFind): EmbeddedFieldDef;
37
37
  function createEmbeddedListField(schema: EmbeddedFieldDef["schema"], overrides?: Partial<Omit<EmbeddedFieldDef, "type" | "schema" | "multiple" | keyof ResolvedPiiFlags>> & PersonalAnnotationsNoFind): EmbeddedFieldDef & { multiple: true; };
38
- function createEntity<const F, const T extends EntityTenancy = "tenant">(def: { table?: string; fields: F; description?: string; softDelete?: boolean; systemStream?: boolean; eventVersion?: number; eventMigrations?: EntityDefinition["eventMigrations"]; searchWeight?: number; defaultCurrency?: string; transitions?: Readonly<Record<string, Readonly<Record<string, string[]>>>>; lockedWhen?: EntityDefinition["lockedWhen"]; indexes?: EntityIndexDef[]; idType?: "serial" | "uuid"; access?: EntityDefinition["access"]; parentRef?: EntityDefinition["parentRef"]; readGate?: EntityDefinition["readGate"]; retention?: RetentionDef; derivedFields?: EntityDefinition["derivedFields"]; tenancy?: T; transferable?: boolean; attachedFileAccess?: EntityDefinition["attachedFileAccess"]; }): F extends FieldsMap ? EntityDefinition<F, T> : never;
38
+ function createEntity<const F, const T extends EntityTenancy = "tenant">(def: { table?: string; fields: F; description?: string; softDelete?: boolean; systemStream?: boolean; eventVersion?: number; eventMigrations?: EntityDefinition["eventMigrations"]; searchWeight?: number; defaultCurrency?: string; transitions?: Readonly<Record<string, Readonly<Record<string, string[]>>>>; lockedWhen?: EntityDefinition["lockedWhen"]; indexes?: EntityIndexDef[]; idType?: "serial" | "uuid"; access?: EntityDefinition["access"]; parentRef?: EntityDefinition["parentRef"]; readGate?: EntityDefinition["readGate"]; retention?: RetentionDef; derivedFields?: EntityDefinition["derivedFields"]; namedFilters?: EntityDefinition["namedFilters"]; tenancy?: T; transferable?: boolean; attachedFileAccess?: EntityDefinition["attachedFileAccess"]; }): F extends FieldsMap ? EntityDefinition<F, T> : never;
39
39
  function createEntityExecutor<E extends EntityDefinition>(entityName: string, entity: E): { table: EntityTable; executor: EventStoreExecutor<EntityRow<E>>; };
40
40
  function createFileField(overrides?: Partial<Omit<FileFieldDef, "type" | keyof ResolvedPiiFlags>> & PersonalAnnotationsNoFind): FileFieldDef;
41
41
  function createFilesField(overrides?: Partial<Omit<FilesFieldDef, "type" | keyof ResolvedPiiFlags>> & PersonalAnnotationsNoFind): FilesFieldDef;
@@ -137,6 +137,7 @@ function referenceGroupLabels(ctx: HandlerContext, user: SessionUser, sourceTabl
137
137
  function requireTierResolverPlugin(usage: RegistrarExtensionRegistration): TierResolverPlugin;
138
138
  function resolveAgentExposure(def: { description?: string; agent?: AgentHandlerHints; }, kind: "query" | "write"): AgentExposure; // Fail-closed: a handler without a `description` stays invisible to the agent unless it opts in explicitly.
139
139
  function resolveEntityTransferAdjacency(entities: ReadonlyMap<string, EntityDefinition>, rootEntityType?: string): TransferAdjacency;
140
+ function resolveNamedListFilters(ctx: HandlerContext, user: SessionUser, entityName: string, name: string): Promise<NamedListFilterCondition[]>;
140
141
  function runPipeline<TPayload, TData, TMap extends object = KumikoEventTypeMap>(pipelineDef: PipelineDef<TPayload, TData>, event: WriteEvent<TPayload>, handlerCtx: HandlerContext<TMap>, workflow?: PipelineCtx["workflow"], resumeFrom?: number): Promise<WriteResult<TData>>;
141
142
  function runStepList<TPayload, TMap extends object = KumikoEventTypeMap>(steps: StepInstance[], event: WriteEvent<TPayload>, handlerCtx: HandlerContext<TMap>, stepsAcc: Record<string, unknown>, scopeAcc: Record<string, unknown>, workflow?: PipelineCtx["workflow"], resumeFrom?: number): Promise<StepListOutcome>; // Walk a step-list.
142
143
  function sectionFieldSpecs(section: FieldsOrGroupsSection): EditFieldSpec[];
@@ -564,6 +565,7 @@ type EntityDefinition<F extends FieldsMap = FieldsMap, T extends EntityTenancy =
564
565
  readGate?: ReadGateDef; // Declarative read gate for rows readable only through other rows (a link to a visible host, a file whose owner is readable).
565
566
  retention?: RetentionDef; // Default retention policy for this entity.
566
567
  derivedFields?: DerivedFieldsMap; // Read-time computed fields, keyed by name.
568
+ namedFilters?: Readonly<Record<string, NamedListFilterDef>>; // Named work-queue rules keyed by id, selected via `namedFilter`; function-valued, never in the client schema.
567
569
  description?: string; // Optional free-text semantics for the AI agent manifest — see TextFieldDef.description.
568
570
  transferable?: boolean; // Opts the rows into `tenant-handover`, a grant-legitimated move from an anonymous tenant to the signed-up account's tenant.
569
571
  attachedFileAccess?: "record-only"; // Attached files are readable only through the record's detail query: the files feature's privilegedRoles do not bypass it, the uploader check stays.
@@ -628,6 +630,7 @@ type EntityListScreenDefinition = {
628
630
  defaultSort?: ListSortSpec;
629
631
  defaultFilters?: Readonly<Record<string, string[] | boolean>>; // Initial facet selection, per filterable field: option values (string facets) or a boolean.
630
632
  facets?: Readonly<Record<string, EntityListFacetConfig | false>>; // Per-facet display options for the entity's `filterable` fields, keyed by field name.
633
+ namedFilters?: NamedListFilterRef[]; // Named work-queue rules offered on this list: ids declared in `EntityDefinition.namedFilters`, `label` an i18n key.
631
634
  emptyState?: ListEmptyStateSpec; // Replaces the default empty-list title/hint; the create button stays when the list can create.
632
635
  createLabel?: string; // i18n key for the primary create button label (default `kumiko.actions.create`).
633
636
  searchPlaceholder?: string; // i18n key for the search field placeholder (default `kumiko.list.search-placeholder`).
@@ -1351,6 +1354,7 @@ type ProjectionListScreenDefinition = {
1351
1354
  paginated?: boolean; // Derived by buildAppSchema from the query handler's Zod schema (`cursor` or `offset` param present).
1352
1355
  filter?: ScreenFilter; // Server-side filter, fixed on the screen — see `ScreenFilter` doc above (entityList).
1353
1356
  facets?: ListFacetSpec[]; // User-toggleable facet dropdowns — see `ListFacetSpec` doc.
1357
+ namedFilters?: NamedListFilterRef[]; // Named work-queue rules the bound query understands: its Zod schema must have a `namedFilter` key (boot-checked) and is sent the selected id in every list and count query.
1354
1358
  slots?: ScreenSlots;
1355
1359
  access?: AccessRule;
1356
1360
  };
@@ -252,6 +252,10 @@ type DashboardStatPanel = DashboardPanelQueryOptions & DashboardPanelGate & {
252
252
  deltaToneField?: string;
253
253
  icon?: PlatformComponent; // Static icon next to the label.
254
254
  accentColor?: string; // Static CSS color value (e.g. "var(--color-debt)") for the icon chip, passed straight to the tile without a registry lookup.
255
+ navigate?: {
256
+ screen: string;
257
+ namedFilter?: string;
258
+ }; // Makes the tile a link to a list screen (qualified screen id).
255
259
  };
256
260
  type DashboardText = string | DashboardI18nText; // Plain string is displayed as-is; DashboardI18nText is translated by the renderer.
257
261
  type DashboardTextBlock =
@@ -85,7 +85,7 @@ function SidebarProvider(props: React.ComponentProps<"div"> & { defaultOpen?: bo
85
85
  function SidebarUser(props: SidebarUserProps): ReactNode;
86
86
  function Sparkline(props: { points: number[]; className?: string; }): ReactNode;
87
87
  function Stack(props: StackProps): ReactNode;
88
- function StatCard(props: { icon?: ReactNode; label: string; value: string; sub?: string; tone?: StatTone; accentColor?: string; delta?: StatDelta; trend?: string; spark?: number[]; testId?: string; children?: ReactNode; }): ReactNode; // Icon-bearing metric tile with an optional delta chip, trend line, and sparkline.
88
+ function StatCard(props: { icon?: ReactNode; label: string; value: string; sub?: string; tone?: StatTone; accentColor?: string; delta?: StatDelta; trend?: string; spark?: number[]; testId?: string; link?: StatLink; children?: ReactNode; }): ReactNode; // Icon-bearing metric tile with an optional delta chip, trend line, and sparkline.
89
89
  function StatusBadge(props: { tone: StatusBadgeTone; children: ReactNode; className?: string; testId?: string; }): ReactNode; // Pill badge with a status dot.
90
90
  function StatusBarChart(props: { entries: StatusBarEntry[]; ariaLabel: string; startLabel?: string; endLabel?: string; highlightLast?: boolean; dense?: boolean; testId?: string; }): ReactNode; // Status bar strip (e.g. 90-day uptime): variable-height bars with gradient fade + tick line on top; the last entry gets a "now" accent stripe.
91
91
  function StepBar(props: { steps: string[]; currentIndex: number; compactLabel: string; onStepSelect?: (index: number) => void; doneSteps?: boolean[]; selectableSteps?: "done" | "all"; narrowLayout?: "label" | "steps"; orientation?: "horizontal" | "vertical"; heading?: string; description?: string; subtitles?: (string | undefined)[]; upNext?: { heading: string; title: string; subtitle?: string; }; testId?: string; compactTestId?: string; }): ReactNode; // Wizard step overview — numbered chips with a connector line between them.
@@ -2676,6 +2676,15 @@ interface ResultTableFooterRow {
2676
2676
  }
2677
2677
  ```
2678
2678
 
2679
+ ## packages/renderer/src/web/widgets/stat.tsx
2680
+
2681
+ ```ts
2682
+ type StatLink = { // Makes a tile a link; `onPress` is the SPA navigation, `href` serves modified clicks and a11y.
2683
+ href: string;
2684
+ onPress: () => void;
2685
+ };
2686
+ ```
2687
+
2679
2688
  ## packages/testing/src/bundled-injection.ts
2680
2689
 
2681
2690
  ```ts
@@ -4377,6 +4386,30 @@ type MultiStreamApplyContext<TMap extends object = KumikoEventTypeMap> = {
4377
4386
  };
4378
4387
  ```
4379
4388
 
4389
+ ## packages/types/src/named-list-filters.ts
4390
+
4391
+ ```ts
4392
+ type NamedListFilterCondition = {
4393
+ field: string;
4394
+ op: NamedListFilterOp;
4395
+ value: unknown;
4396
+ };
4397
+ type NamedListFilterDef = { // A work-queue rule defined once on the entity: the list and every count query resolve the same conditions.
4398
+ access?: AccessRule;
4399
+ filters: (scope: NamedListFilterScope) => NamedListFilterCondition[] | Promise<NamedListFilterCondition[]>;
4400
+ };
4401
+ type NamedListFilterOp = "eq" | "ne" | "lt" | "gt" | "lte" | "gte" | "in";
4402
+ type NamedListFilterRef = { // Screen-side reference to an entity-defined named filter; `label` is an i18n key.
4403
+ id: string;
4404
+ label: string;
4405
+ };
4406
+ type NamedListFilterScope = {
4407
+ ctx: HandlerContext;
4408
+ user: SessionUser;
4409
+ now: Temporal.Instant;
4410
+ };
4411
+ ```
4412
+
4380
4413
  ## packages/types/src/nav.ts
4381
4414
 
4382
4415
  ```ts
package/agents/api.md CHANGED
@@ -8,7 +8,7 @@ Format: one line per function or constant, object and options types as property
8
8
 
9
9
  | Import path | File | Exports |
10
10
  | --- | --- | --- |
11
- | `@cosmicdrift/kumiko-framework/engine` | `api/framework-engine.md` | 419 |
11
+ | `@cosmicdrift/kumiko-framework/engine` | `api/framework-engine.md` | 420 |
12
12
  | `@cosmicdrift/kumiko-framework/errors` | `api/framework-errors.md` | 25 |
13
13
  | `@cosmicdrift/kumiko-framework/ui-types` | `api/framework-ui-types.md` | 154 |
14
14
  | `@cosmicdrift/kumiko-framework/db` | `api/framework-db.md` | 150 |
@@ -28,6 +28,7 @@
28
28
  | <a id="entity-list-detail-without-navigate"></a>`entity-list/detail-without-navigate` | A list whose entity has a detail screen offers a navigate row action. | Add a navigate rowAction with id "view" or "edit". | `packages/framework/src/engine/boot-validator/entity-list-screens-rules.ts` |
29
29
  | <a id="entity-list-searchable-false"></a>`entity-list/searchable-false` | Operator lists are searchable unless they are on the searchable-false whitelist. | Set searchable: true or add the screen id to the whitelist. | `packages/framework/src/engine/boot-validator/entity-list-screens-rules.ts` |
30
30
  | <a id="entity-list-unknown-column"></a>`entity-list/unknown-column` | Every column without a label names an entity field, derived field or row-meta column. | Fix the column field name or give the column a label. | `packages/framework/src/engine/boot-validator/entity-list-screens-rules.ts` |
31
+ | <a id="entity-list-unknown-named-filter"></a>`entity-list/unknown-named-filter` | Every screen namedFilters id is declared in the entity's namedFilters. | Declare the rule in EntityDefinition.namedFilters or remove the id from the screen. | `packages/framework/src/engine/boot-validator/entity-list-screens-rules.ts` |
31
32
 
32
33
  ## extension
33
34
 
@@ -52,6 +53,17 @@
52
53
  | --- | --- | --- | --- |
53
54
  | <a id="list-card-meta-overflow"></a>`list/card-meta-overflow` | A card list shows at most the maximum number of meta columns. | Reduce each list to the maximum number of meta columns or mark the others hideOnNarrow: true. | `packages/framework/src/engine/boot-validator/card-meta-overflow-rules.ts` |
54
55
 
56
+ ## named-filter
57
+
58
+ | Rule | Summary | Fix | Source |
59
+ | --- | --- | --- | --- |
60
+ | <a id="named-filter-projection-query-accepts-named-filter"></a>`named-filter/projection-query-accepts-named-filter` | A projectionList with namedFilters binds a query whose schema has a namedFilter key. | Add namedFilter: z.string().optional() to the query schema, resolve it with resolveNamedListFilters and pass the conditions into executor.list filters, the only path that applies field-read and PII guards. | `packages/framework/src/engine/boot-validator/named-filter-rules.ts` |
61
+ | <a id="named-filter-stat-navigate-filter-declared"></a>`named-filter/stat-navigate-filter-declared` | A stat navigate.namedFilter is declared in the target screen's namedFilters. | Add { id, label } to namedFilters of the target screen. | `packages/framework/src/engine/boot-validator/named-filter-rules.ts` |
62
+ | <a id="named-filter-stat-navigate-same-named-filter"></a>`named-filter/stat-navigate-same-named-filter` | A stat's params.namedFilter equals navigate.namedFilter. | Set params.namedFilter to the same id as navigate.namedFilter. | `packages/framework/src/engine/boot-validator/named-filter-rules.ts` |
63
+ | <a id="named-filter-stat-navigate-same-query"></a>`named-filter/stat-navigate-same-query` | A stat with navigate counts with the target list's own query. | Set the stat query to the target list's query. | `packages/framework/src/engine/boot-validator/named-filter-rules.ts` |
64
+ | <a id="named-filter-stat-navigate-static-filter"></a>`named-filter/stat-navigate-static-filter` | A stat repeats the target screen's static filter in params.filter. | Set params.filter to the target screen's filter. | `packages/framework/src/engine/boot-validator/named-filter-rules.ts` |
65
+ | <a id="named-filter-stat-navigate-target"></a>`named-filter/stat-navigate-target` | A stat navigate.screen resolves to an entityList or projectionList screen. | Point navigate.screen at a registered list screen. | `packages/framework/src/engine/boot-validator/named-filter-rules.ts` |
66
+
55
67
  ## pii
56
68
 
57
69
  | Rule | Summary | Fix | Source |
@@ -36,6 +36,11 @@ query and the fields to read, the query does the counting.
36
36
  - **Fallback by reason**: `fallback: { forbidden, notFound }` renders a screen on
37
37
  403/404 of the gate query; other reasons get the default notice or an error
38
38
  banner with retry.
39
+ - **Named filter shared by tile and list**: the entity declares `namedFilters`,
40
+ the `ticket-queue` entityList lists the id in `namedFilters`, and the
41
+ `open-work` stat counts through the list's own query with `params.namedFilter`
42
+ and `navigate`. A click opens the queue filtered by the same rule, so tile and
43
+ list cannot drift.
39
44
 
40
45
  ## When to reach for it
41
46
 
@@ -73,6 +78,7 @@ import {
73
78
  } from "@cosmicdrift/kumiko-testing/stack";
74
79
  import {
75
80
  TICKET_CAPABILITIES_QUERY,
81
+ TICKET_LIST_QUERY,
76
82
  TICKET_STATS_QUERY,
77
83
  ticketEntity,
78
84
  ticketFeature,
@@ -100,11 +106,11 @@ describe("dashboard screen", () => {
100
106
  const stats = screen.panels.flatMap((panel) =>
101
107
  panel.kind === "stat-group" ? panel.stats : panel.kind === "stat" ? [panel] : [],
102
108
  );
103
- expect(stats.map((s) => s.valueField)).toEqual(["open", "closed", "total"]);
104
- for (const stat of stats) expect(stat.query).toBe(TICKET_STATS_QUERY);
109
+ expect(stats.map((s) => s.valueField)).toEqual(["open", "closed", "total", "total"]);
110
+ const statsQueryStats = stats.filter((s) => s.query === TICKET_STATS_QUERY);
105
111
 
106
112
  const result = await stack.http.queryOk<Record<string, unknown>>(TICKET_STATS_QUERY, {}, admin);
107
- for (const stat of stats) expect(result).toHaveProperty(stat.valueField);
113
+ for (const stat of statsQueryStats) expect(result).toHaveProperty(stat.valueField);
108
114
  });
109
115
  });
110
116
 
@@ -165,6 +171,28 @@ describe("record-bound dashboard with a gated detail panel", () => {
165
171
  });
166
172
  });
167
173
  });
174
+
175
+ describe("named filter shared by the dashboard tile and the list", () => {
176
+ test("the tile total equals the filtered list total over HTTP", async () => {
177
+ const dashboard = ticketFeature.screens["ticket-dashboard"];
178
+ if (dashboard?.type !== "dashboard") throw new Error("ticket-dashboard missing");
179
+ const tile = dashboard.panels.find(
180
+ (panel) => panel.kind === "stat" && panel.id === "open-work",
181
+ );
182
+ if (tile?.kind !== "stat") throw new Error("open-work tile missing");
183
+ expect(tile.navigate).toEqual({ screen: "ticket-queue", namedFilter: "open-work" });
184
+
185
+ const counted = await stack.http.queryOk<{ total: number }>(tile.query, tile.params, admin);
186
+ const listed = await stack.http.queryOk<{ rows: unknown[]; total: number }>(
187
+ TICKET_LIST_QUERY,
188
+ { namedFilter: "open-work", totalCount: true, limit: 50 },
189
+ admin,
190
+ );
191
+ expect(counted.total).toBe(listed.total);
192
+ expect(listed.rows).toHaveLength(listed.total);
193
+ expect(listed.total).toBeGreaterThan(0);
194
+ });
195
+ });
168
196
  ```
169
197
 
170
198
  ### src/feature.ts
@@ -193,10 +221,15 @@ export const ticketEntity = createEntity({
193
221
  title: createTextField({ personal: false, reason: "is_business_data", required: true }),
194
222
  status: createSelectField({ options: TICKET_STATUSES, default: "open", required: true }),
195
223
  },
224
+ // Defined once: the queue list and the dashboard tile both read it.
225
+ namedFilters: {
226
+ "open-work": { filters: () => [{ field: "status", op: "eq", value: "open" }] },
227
+ },
196
228
  });
197
229
 
198
230
  export const TICKET_STATS_QUERY = "tickets:query:ticket:stats";
199
231
  export const TICKET_CAPABILITIES_QUERY = "tickets:query:ticket:capabilities";
232
+ export const TICKET_LIST_QUERY = "tickets:query:ticket:list";
200
233
 
201
234
  const readerRoles = { roles: ["Admin", "User"] } as const;
202
235
 
@@ -281,6 +314,9 @@ export const ticketFeature = defineFeature("tickets", (r) => {
281
314
  "tickets.dashboard.open": { en: "Open" },
282
315
  "tickets.dashboard.closed": { en: "Closed" },
283
316
  "tickets.dashboard.total": { en: "All tickets" },
317
+ "tickets.dashboard.open-work": { en: "Open work" },
318
+ "tickets.filter.open-work": { en: "Open work" },
319
+ "screen:ticket-queue.title": { en: "Ticket queue" },
284
320
  },
285
321
  });
286
322
 
@@ -316,6 +352,16 @@ export const ticketFeature = defineFeature("tickets", (r) => {
316
352
  query: TICKET_STATS_QUERY,
317
353
  valueField: "total",
318
354
  },
355
+ // Counts through the list's own query with the same named filter, then links to that list.
356
+ {
357
+ kind: "stat",
358
+ id: "open-work",
359
+ label: "tickets.dashboard.open-work",
360
+ query: TICKET_LIST_QUERY,
361
+ valueField: "total",
362
+ params: { namedFilter: "open-work", limit: 1, totalCount: true },
363
+ navigate: { screen: "ticket-queue", namedFilter: "open-work" },
364
+ },
319
365
  ],
320
366
  access: readerRoles,
321
367
  });
@@ -324,6 +370,15 @@ export const ticketFeature = defineFeature("tickets", (r) => {
324
370
  // button, and a fallback chosen by reason.
325
371
  const gate = { query: TICKET_CAPABILITIES_QUERY, field: "canClose", eq: true } as const;
326
372
 
373
+ r.screen({
374
+ id: "ticket-queue",
375
+ type: "entityList",
376
+ entity: "ticket",
377
+ columns: ["title", "status"],
378
+ namedFilters: [{ id: "open-work", label: "tickets.filter.open-work" }],
379
+ access: readerRoles,
380
+ });
381
+
327
382
  // The list doubles as the fallback target: it has no visibleWhen of its own.
328
383
  r.screen({
329
384
  id: "ticket-list",
@@ -5,6 +5,7 @@ import { validateProjectionAllowlist } from "../validate-projection-allowlist.js
5
5
  import { runFeatureBootCheck } from "./boot-check.js";
6
6
  import { validateCardMetaOverflow } from "./card-meta-overflow.js";
7
7
  import { validateConfigReads } from "./config-deps.js";
8
+ import { validateDashboardStatNavigate } from "./dashboard-stat-navigate.js";
8
9
  import { validateExtensionPreSaveWiring } from "./entity-handler.js";
9
10
  import { validateEntityListScreens } from "./entity-list-screens.js";
10
11
  import { validateGdprStoragePersistence } from "./gdpr-storage.js";
@@ -45,6 +46,10 @@ const APP_CHECKS_AFTER_I18N = [
45
46
  check: "validateProjectionListScreens",
46
47
  run: (c) => validateProjectionListScreens(c.features),
47
48
  },
49
+ {
50
+ check: "validateDashboardStatNavigate",
51
+ run: (c) => validateDashboardStatNavigate(c.features),
52
+ },
48
53
  {
49
54
  check: "validateRelatedListSectionQueries",
50
55
  run: (c) => validateRelatedListSectionQueries(c.features),
@@ -131,11 +131,47 @@ export declare const FRAMEWORK_BOOT_RULE_TABLES: readonly [{
131
131
  readonly summary: "A list whose entity has a detail screen offers a navigate row action.";
132
132
  readonly fix: 'Add a navigate rowAction with id "view" or "edit".';
133
133
  };
134
+ readonly unknownNamedFilter: {
135
+ readonly id: "entity-list/unknown-named-filter";
136
+ readonly summary: "Every screen namedFilters id is declared in the entity's namedFilters.";
137
+ readonly fix: "Declare the rule in EntityDefinition.namedFilters or remove the id from the screen.";
138
+ };
134
139
  readonly unknownColumn: {
135
140
  readonly id: "entity-list/unknown-column";
136
141
  readonly summary: "Every column without a label names an entity field, derived field or row-meta column.";
137
142
  readonly fix: "Fix the column field name or give the column a label.";
138
143
  };
144
+ }, {
145
+ readonly projectionQueryAcceptsNamedFilter: {
146
+ readonly id: "named-filter/projection-query-accepts-named-filter";
147
+ readonly summary: "A projectionList with namedFilters binds a query whose schema has a namedFilter key.";
148
+ readonly fix: "Add namedFilter: z.string().optional() to the query schema, resolve it with resolveNamedListFilters and pass the conditions into executor.list filters, the only path that applies field-read and PII guards.";
149
+ };
150
+ readonly statNavigateTarget: {
151
+ readonly id: "named-filter/stat-navigate-target";
152
+ readonly summary: "A stat navigate.screen resolves to an entityList or projectionList screen.";
153
+ readonly fix: "Point navigate.screen at a registered list screen.";
154
+ };
155
+ readonly statNavigateFilterDeclared: {
156
+ readonly id: "named-filter/stat-navigate-filter-declared";
157
+ readonly summary: "A stat navigate.namedFilter is declared in the target screen's namedFilters.";
158
+ readonly fix: "Add { id, label } to namedFilters of the target screen.";
159
+ };
160
+ readonly statNavigateSameQuery: {
161
+ readonly id: "named-filter/stat-navigate-same-query";
162
+ readonly summary: "A stat with navigate counts with the target list's own query.";
163
+ readonly fix: "Set the stat query to the target list's query.";
164
+ };
165
+ readonly statNavigateSameNamedFilter: {
166
+ readonly id: "named-filter/stat-navigate-same-named-filter";
167
+ readonly summary: "A stat's params.namedFilter equals navigate.namedFilter.";
168
+ readonly fix: "Set params.namedFilter to the same id as navigate.namedFilter.";
169
+ };
170
+ readonly statNavigateStaticFilter: {
171
+ readonly id: "named-filter/stat-navigate-static-filter";
172
+ readonly summary: "A stat repeats the target screen's static filter in params.filter.";
173
+ readonly fix: "Set params.filter to the target screen's filter.";
174
+ };
139
175
  }, {
140
176
  readonly missingFeatureKey: {
141
177
  readonly id: "i18n/missing-feature-key";
@@ -3,11 +3,13 @@ import { BOOT_CHECK_RULES } from "./boot-check-rules.js";
3
3
  import { CARD_META_OVERFLOW_RULES } from "./card-meta-overflow-rules.js";
4
4
  import { ENTITY_LIST_RULES } from "./entity-list-screens-rules.js";
5
5
  import { I18N_RULES } from "./i18n-keys-rules.js";
6
+ import { NAMED_FILTER_RULES } from "./named-filter-rules.js";
6
7
  import { PII_RULES } from "./pii-retention-rules.js";
7
8
  import { SCREEN_ACTION_RULES } from "./screens-actions-rules.js";
8
9
  export const FRAMEWORK_BOOT_RULE_TABLES = [
9
10
  PII_RULES,
10
11
  ENTITY_LIST_RULES,
12
+ NAMED_FILTER_RULES,
11
13
  I18N_RULES,
12
14
  CARD_META_OVERFLOW_RULES,
13
15
  SCREEN_ACTION_RULES,
@@ -0,0 +1,2 @@
1
+ import type { FeatureDefinition } from "../types/index.ts";
2
+ export declare function validateDashboardStatNavigate(features: readonly FeatureDefinition[]): void;
@@ -0,0 +1,77 @@
1
+ import { isDeepStrictEqual } from "node:util";
2
+ import { toKebab } from "../qualified-name.js";
3
+ import { bootRuleError } from "./boot-rule.js";
4
+ import { NAMED_FILTER_RULES } from "./named-filter-rules.js";
5
+ import { resolveScreenTargetQn } from "./screens-actions.js";
6
+ function entityListQueryName(feature, screen) {
7
+ return `${toKebab(feature.name)}:query:${toKebab(screen.entity)}:list`;
8
+ }
9
+ function listQueryOf(target) {
10
+ return target.screen.type === "entityList"
11
+ ? entityListQueryName(target.feature, target.screen)
12
+ : target.screen.query;
13
+ }
14
+ function declaredNamedFilterIds(target) {
15
+ return new Set((target.screen.namedFilters ?? []).map((ref) => ref.id));
16
+ }
17
+ function resolveListTarget(featureName, screenRef, featureMap) {
18
+ const qn = resolveScreenTargetQn(featureName, screenRef);
19
+ const [targetFeatureName = "", , targetShortId = ""] = qn.split(":");
20
+ const feature = featureMap.get(targetFeatureName);
21
+ const screen = feature?.screens[targetShortId];
22
+ if (feature === undefined || screen === undefined)
23
+ return { qn };
24
+ if (screen.type !== "entityList" && screen.type !== "projectionList")
25
+ return { qn };
26
+ return { feature, screen };
27
+ }
28
+ function validateStatNavigate(context, featureName, stat, featureMap) {
29
+ const navigate = stat.navigate;
30
+ // skip: stat has no navigate target to validate
31
+ if (navigate === undefined)
32
+ return;
33
+ const target = resolveListTarget(featureName, navigate.screen, featureMap);
34
+ if ("qn" in target) {
35
+ throw bootRuleError(NAMED_FILTER_RULES.statNavigateTarget, {
36
+ message: `${context} navigate.screen "${navigate.screen}" does not resolve to a registered entityList or projectionList screen (checked "${target.qn}").`,
37
+ });
38
+ }
39
+ if (navigate.namedFilter !== undefined &&
40
+ !declaredNamedFilterIds(target).has(navigate.namedFilter)) {
41
+ throw bootRuleError(NAMED_FILTER_RULES.statNavigateFilterDeclared, {
42
+ message: `${context} navigate.namedFilter "${navigate.namedFilter}" is not declared in namedFilters of screen "${target.screen.id}". Add { id, label } to that screen.`,
43
+ });
44
+ }
45
+ const listQuery = listQueryOf(target);
46
+ if (stat.query !== listQuery) {
47
+ throw bootRuleError(NAMED_FILTER_RULES.statNavigateSameQuery, {
48
+ message: `${context} navigate targets screen "${target.screen.id}" whose list query is "${listQuery}", but the stat counts with query "${stat.query}". Count with the list's own query so tile and list cannot drift.`,
49
+ });
50
+ }
51
+ if (stat.params?.["namedFilter"] !== navigate.namedFilter) {
52
+ throw bootRuleError(NAMED_FILTER_RULES.statNavigateSameNamedFilter, {
53
+ message: `${context} params.namedFilter (${JSON.stringify(stat.params?.["namedFilter"])}) must equal navigate.namedFilter (${JSON.stringify(navigate.namedFilter)}), otherwise the tile counts other rows than the list shows.`,
54
+ });
55
+ }
56
+ const staticFilter = target.screen.filter;
57
+ if (staticFilter !== undefined && !isDeepStrictEqual(stat.params?.["filter"], staticFilter)) {
58
+ throw bootRuleError(NAMED_FILTER_RULES.statNavigateStaticFilter, {
59
+ message: `${context} target screen "${target.screen.id}" has a static filter the tile does not apply. Set params.filter to the same filter on the stat.`,
60
+ });
61
+ }
62
+ }
63
+ export function validateDashboardStatNavigate(features) {
64
+ const featureMap = new Map(features.map((f) => [f.name, f]));
65
+ for (const feature of features) {
66
+ for (const screen of Object.values(feature.screens)) {
67
+ if (screen.type !== "dashboard")
68
+ continue;
69
+ for (const panel of screen.panels) {
70
+ const stats = panel.kind === "stat" ? [panel] : panel.kind === "stat-group" ? panel.stats : [];
71
+ for (const stat of stats) {
72
+ validateStatNavigate(`[Feature ${feature.name}] Screen "${screen.id}" (dashboard) stat "${stat.id}"`, feature.name, stat, featureMap);
73
+ }
74
+ }
75
+ }
76
+ }
77
+ }
@@ -24,6 +24,11 @@ export declare const ENTITY_LIST_RULES: {
24
24
  readonly summary: "A list whose entity has a detail screen offers a navigate row action.";
25
25
  readonly fix: 'Add a navigate rowAction with id "view" or "edit".';
26
26
  };
27
+ readonly unknownNamedFilter: {
28
+ readonly id: "entity-list/unknown-named-filter";
29
+ readonly summary: "Every screen namedFilters id is declared in the entity's namedFilters.";
30
+ readonly fix: "Declare the rule in EntityDefinition.namedFilters or remove the id from the screen.";
31
+ };
27
32
  readonly unknownColumn: {
28
33
  readonly id: "entity-list/unknown-column";
29
34
  readonly summary: "Every column without a label names an entity field, derived field or row-meta column.";
@@ -24,6 +24,11 @@ export const ENTITY_LIST_RULES = {
24
24
  summary: "A list whose entity has a detail screen offers a navigate row action.",
25
25
  fix: 'Add a navigate rowAction with id "view" or "edit".',
26
26
  },
27
+ unknownNamedFilter: {
28
+ id: "entity-list/unknown-named-filter",
29
+ summary: "Every screen namedFilters id is declared in the entity's namedFilters.",
30
+ fix: "Declare the rule in EntityDefinition.namedFilters or remove the id from the screen.",
31
+ },
27
32
  unknownColumn: {
28
33
  id: "entity-list/unknown-column",
29
34
  summary: "Every column without a label names an entity field, derived field or row-meta column.",
@@ -95,12 +95,24 @@ function checkColumns(feature, screen) {
95
95
  }
96
96
  });
97
97
  }
98
+ function checkNamedFilters(feature, screen) {
99
+ const declared = feature.entities?.[screen.entity]?.namedFilters ?? {};
100
+ collectBootRuleViolations(screen.namedFilters ?? [], (ref) => {
101
+ // skip: id is declared on the entity
102
+ if (Object.hasOwn(declared, ref.id))
103
+ return;
104
+ throw bootRuleError(ENTITY_LIST_RULES.unknownNamedFilter, {
105
+ message: `${screenPrefix(feature, screen)}: namedFilters id "${ref.id}" is not declared in entity "${screen.entity}" namedFilters. Known: ${Object.keys(declared).sort().join(", ") || "(none)"}.`,
106
+ });
107
+ });
108
+ }
98
109
  const ENTITY_LIST_CHECKS = [
99
110
  checkSearchable,
100
111
  checkDefaultSortRequired,
101
112
  checkDefaultSortColumn,
102
113
  checkDetailNavigate,
103
114
  checkColumns,
115
+ checkNamedFilters,
104
116
  ];
105
117
  export function validateEntityListScreens(features) {
106
118
  const screens = features.flatMap((feature) => Object.values(feature.screens).flatMap((screen) => screen.type === "entityList" ? [{ feature, screen }] : []));
@@ -0,0 +1,32 @@
1
+ export declare const NAMED_FILTER_RULES: {
2
+ readonly projectionQueryAcceptsNamedFilter: {
3
+ readonly id: "named-filter/projection-query-accepts-named-filter";
4
+ readonly summary: "A projectionList with namedFilters binds a query whose schema has a namedFilter key.";
5
+ readonly fix: "Add namedFilter: z.string().optional() to the query schema, resolve it with resolveNamedListFilters and pass the conditions into executor.list filters, the only path that applies field-read and PII guards.";
6
+ };
7
+ readonly statNavigateTarget: {
8
+ readonly id: "named-filter/stat-navigate-target";
9
+ readonly summary: "A stat navigate.screen resolves to an entityList or projectionList screen.";
10
+ readonly fix: "Point navigate.screen at a registered list screen.";
11
+ };
12
+ readonly statNavigateFilterDeclared: {
13
+ readonly id: "named-filter/stat-navigate-filter-declared";
14
+ readonly summary: "A stat navigate.namedFilter is declared in the target screen's namedFilters.";
15
+ readonly fix: "Add { id, label } to namedFilters of the target screen.";
16
+ };
17
+ readonly statNavigateSameQuery: {
18
+ readonly id: "named-filter/stat-navigate-same-query";
19
+ readonly summary: "A stat with navigate counts with the target list's own query.";
20
+ readonly fix: "Set the stat query to the target list's query.";
21
+ };
22
+ readonly statNavigateSameNamedFilter: {
23
+ readonly id: "named-filter/stat-navigate-same-named-filter";
24
+ readonly summary: "A stat's params.namedFilter equals navigate.namedFilter.";
25
+ readonly fix: "Set params.namedFilter to the same id as navigate.namedFilter.";
26
+ };
27
+ readonly statNavigateStaticFilter: {
28
+ readonly id: "named-filter/stat-navigate-static-filter";
29
+ readonly summary: "A stat repeats the target screen's static filter in params.filter.";
30
+ readonly fix: "Set params.filter to the target screen's filter.";
31
+ };
32
+ };
@@ -0,0 +1,32 @@
1
+ export const NAMED_FILTER_RULES = {
2
+ projectionQueryAcceptsNamedFilter: {
3
+ id: "named-filter/projection-query-accepts-named-filter",
4
+ summary: "A projectionList with namedFilters binds a query whose schema has a namedFilter key.",
5
+ fix: "Add namedFilter: z.string().optional() to the query schema, resolve it with resolveNamedListFilters and pass the conditions into executor.list filters, the only path that applies field-read and PII guards.",
6
+ },
7
+ statNavigateTarget: {
8
+ id: "named-filter/stat-navigate-target",
9
+ summary: "A stat navigate.screen resolves to an entityList or projectionList screen.",
10
+ fix: "Point navigate.screen at a registered list screen.",
11
+ },
12
+ statNavigateFilterDeclared: {
13
+ id: "named-filter/stat-navigate-filter-declared",
14
+ summary: "A stat navigate.namedFilter is declared in the target screen's namedFilters.",
15
+ fix: "Add { id, label } to namedFilters of the target screen.",
16
+ },
17
+ statNavigateSameQuery: {
18
+ id: "named-filter/stat-navigate-same-query",
19
+ summary: "A stat with navigate counts with the target list's own query.",
20
+ fix: "Set the stat query to the target list's query.",
21
+ },
22
+ statNavigateSameNamedFilter: {
23
+ id: "named-filter/stat-navigate-same-named-filter",
24
+ summary: "A stat's params.namedFilter equals navigate.namedFilter.",
25
+ fix: "Set params.namedFilter to the same id as navigate.namedFilter.",
26
+ },
27
+ statNavigateStaticFilter: {
28
+ id: "named-filter/stat-navigate-static-filter",
29
+ summary: "A stat repeats the target screen's static filter in params.filter.",
30
+ fix: "Set params.filter to the target screen's filter.",
31
+ },
32
+ };
@@ -1,8 +1,10 @@
1
1
  import { isListQueryWithoutSearchableFields, resolveEntityBehindListQuery, } from "../entity-list-searchability.js";
2
2
  import { QnTypes, qualifyEntityName } from "../qualified-name.js";
3
3
  import { isEditLayoutScreen } from "../screen-helpers.js";
4
+ import { bootRuleError } from "./boot-rule.js";
4
5
  import { SEARCHABLE_FALSE_WHITELIST } from "./entity-list-screens.js";
5
6
  import { validateListTableAggregateQuery } from "./list-table-aggregate-query.js";
7
+ import { NAMED_FILTER_RULES } from "./named-filter-rules.js";
6
8
  import { findEntity } from "./parent-ref.js";
7
9
  import { getZodObjectShape } from "./zod-shape.js";
8
10
  // Sibling to entity-list-screens.ts rather than an extension of it:
@@ -66,6 +68,7 @@ function validateOneProjectionListScreen(feature, screen, queryHandlers, feature
66
68
  }
67
69
  validateProjectionListFilterSchemaAcceptance(prefix, screen, schema);
68
70
  validateProjectionListFacetsSchemaAcceptance(prefix, screen, schema);
71
+ validateProjectionListNamedFilterSchemaAcceptance(prefix, screen, schema);
69
72
  validateListTableAggregateQuery({ prefix, ...screen, handler, featureMap });
70
73
  }
71
74
  // filter sends `payload.filter` — same 422 footgun as facets below
@@ -78,6 +81,16 @@ function validateProjectionListFilterSchemaAcceptance(prefix, screen, schema) {
78
81
  `schema — add filter: z.object({ field: z.string(), op: z.enum(["eq","ne","lt","gt","lte","gte","in"]), ` +
79
82
  `value: z.unknown() }).optional() (or reuse entityListSchema's shape) to the handler's schema.`);
80
83
  }
84
+ // namedFilters send `payload.namedFilter` — same 422 footgun as filter/facets.
85
+ function validateProjectionListNamedFilterSchemaAcceptance(prefix, screen, schema) {
86
+ // skip: no named filters declared, or the schema already accepts the key — nothing to reject.
87
+ if (screen.namedFilters === undefined || schemaAccepts(schema, "namedFilter"))
88
+ return;
89
+ throw bootRuleError(NAMED_FILTER_RULES.projectionQueryAcceptsNamedFilter, {
90
+ message: `${prefix}: declares namedFilters but query "${screen.query}" has no "namedFilter" parameter in its ` +
91
+ `Zod schema — add namedFilter: z.string().optional() to the handler's schema and resolve it with resolveNamedListFilters.`,
92
+ });
93
+ }
81
94
  // facets send `payload.filters` — same failure mode as search/sort:
82
95
  // definePagedQueryHandler doesn't auto-merge params into the
83
96
  // handler's own Zod schema, so a declared facet would 422 on every query
@@ -45,6 +45,7 @@ export declare const entityListSchema: z.ZodObject<{
45
45
  }>;
46
46
  value: z.ZodUnknown;
47
47
  }, z.core.$strip>>>;
48
+ namedFilter: z.ZodOptional<z.ZodString>;
48
49
  aggregate: z.ZodOptional<z.ZodObject<{
49
50
  groupBy: z.ZodOptional<z.ZodString>;
50
51
  measures: z.ZodDefault<z.ZodArray<z.ZodObject<{
@@ -15,6 +15,7 @@ import { entityListRuntime, parentVisibilityFor } from "./entity-list-runtime-op
15
15
  import { assertReason } from "./escape-hatch-reason.js";
16
16
  import { dropEmptyWriteOnlyValues } from "./field-access.js";
17
17
  import { instructionFieldNames } from "./instruction-fields.js";
18
+ import { resolveNamedListFilters } from "./resolve-named-list-filters.js";
18
19
  import { buildInsertSchema, buildUpdateSchema } from "./schema-builder.js";
19
20
  import { parseTenantId } from "./types/identifiers.js";
20
21
  // Convention-based handler factories for event-sourced aggregates.
@@ -109,6 +110,8 @@ export const entityListSchema = z.object({
109
110
  value: z.unknown(),
110
111
  }))
111
112
  .optional(),
113
+ // Entity-defined work-queue rule (EntityDefinition.namedFilters); its conditions are appended to `filters`.
114
+ namedFilter: z.string().min(1).max(100).optional(),
112
115
  aggregate: listAggregatePayloadSchema.optional(),
113
116
  });
114
117
  function parseHandlerName(name, validVerbs) {
@@ -398,7 +401,16 @@ export function defineEntityQueryHandler(name, entity, options) {
398
401
  // against Meilisearch/InMem at runtime (remote combobox search). The
399
402
  // executor is built at definition time and cannot know the adapter, so the
400
403
  // runtime override supplies it.
401
- const listPayload = query.payload; // @cast-boundary engine-payload
404
+ const requestPayload = query.payload; // @cast-boundary engine-payload
405
+ const listPayload = requestPayload.namedFilter === undefined
406
+ ? requestPayload
407
+ : {
408
+ ...requestPayload,
409
+ filters: [
410
+ ...(requestPayload.filters ?? []),
411
+ ...(await resolveNamedListFilters(ctx, query.user, entityName, requestPayload.namedFilter)),
412
+ ],
413
+ };
402
414
  const db = dbFor(ctx);
403
415
  const { runtimeOptions, eagerLoadViewer, resolveEntity } = entityListRuntime(ctx, entityName, query.user);
404
416
  if (listPayload.aggregate !== undefined) {
@@ -208,6 +208,8 @@ export declare function createEntity<const F, const T extends EntityTenancy = "t
208
208
  readonly readGate?: EntityDefinition["readGate"];
209
209
  readonly retention?: RetentionDef;
210
210
  readonly derivedFields?: EntityDefinition["derivedFields"];
211
+ /** See EntityDefinition.namedFilters. */
212
+ readonly namedFilters?: EntityDefinition["namedFilters"];
211
213
  readonly tenancy?: T;
212
214
  /** See EntityDefinition.transferable. */
213
215
  readonly transferable?: boolean;
@@ -80,6 +80,7 @@ export { defineApply, defineMspApply, setFields } from "./projection-helpers.ts"
80
80
  export { isValidQn, parseQn, QnTypes, qn, toKebab } from "./qualified-name.ts";
81
81
  export { readClaim } from "./read-claim.ts";
82
82
  export { createRegistry } from "./registry.ts";
83
+ export { resolveNamedListFilters } from "./resolve-named-list-filters.ts";
83
84
  export type { AssignableAppRoles } from "./role-assignment.ts";
84
85
  export { assignableAppRolesFromUsages, assignableAppRolesOf, BUILT_IN_MEMBERSHIP_ROLES, canActorAssignRole, findForbiddenRoleAssignment, isAssignableByRole, mergeAssignedRoles, } from "./role-assignment.ts";
85
86
  export { runPipeline, runStepList } from "./run-pipeline.ts";
@@ -64,6 +64,7 @@ export { defineApply, defineMspApply, setFields } from "./projection-helpers.js"
64
64
  export { isValidQn, parseQn, QnTypes, qn, toKebab } from "./qualified-name.js";
65
65
  export { readClaim } from "./read-claim.js";
66
66
  export { createRegistry } from "./registry.js";
67
+ export { resolveNamedListFilters } from "./resolve-named-list-filters.js";
67
68
  export { assignableAppRolesFromUsages, assignableAppRolesOf, BUILT_IN_MEMBERSHIP_ROLES, canActorAssignRole, findForbiddenRoleAssignment, isAssignableByRole, mergeAssignedRoles, } from "./role-assignment.js";
68
69
  export { runPipeline, runStepList } from "./run-pipeline.js";
69
70
  export { buildInsertSchema, buildUpdateSchema, fieldToZod, SYSTEM_ONLY_JSON_SCHEMA_KEY, } from "./schema-builder.js";
@@ -0,0 +1,3 @@
1
+ import type { NamedListFilterCondition } from "@cosmicdrift/kumiko-types/named-list-filters";
2
+ import type { HandlerContext, SessionUser } from "./types/index.ts";
3
+ export declare function resolveNamedListFilters(ctx: HandlerContext, user: SessionUser, entityName: string, name: string): Promise<readonly NamedListFilterCondition[]>;
@@ -0,0 +1,30 @@
1
+ import { AccessDeniedError, ValidationError } from "../errors/index.js";
2
+ import { hasAccess } from "./access.js";
3
+ export async function resolveNamedListFilters(ctx, user, entityName, name) {
4
+ const registered = ctx.registry.getEntity(entityName)?.namedFilters ?? {};
5
+ const def = Object.hasOwn(registered, name) ? registered[name] : undefined;
6
+ if (def === undefined) {
7
+ throw new ValidationError({
8
+ fields: [
9
+ {
10
+ path: "namedFilter",
11
+ code: "unknown_named_filter",
12
+ i18nKey: "errors.validation.invalid_value",
13
+ params: { entity: entityName, namedFilter: name },
14
+ },
15
+ ],
16
+ });
17
+ }
18
+ if (def.access !== undefined && !hasAccess(user, def.access)) {
19
+ throw new AccessDeniedError();
20
+ }
21
+ const conditions = await def.filters({ ctx, user, now: ctx.tz.now() });
22
+ const entityFields = ctx.registry.getEntity(entityName)?.fields ?? {};
23
+ for (const condition of conditions) {
24
+ // Fail closed: a condition on a non-column is dropped by the list filter and would widen the queue.
25
+ if (condition.field !== "id" && !Object.hasOwn(entityFields, condition.field)) {
26
+ throw new Error(`Named filter "${name}" of entity "${entityName}" filters on unknown field "${condition.field}".`);
27
+ }
28
+ }
29
+ return conditions;
30
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.402.0",
3
+ "version": "0.403.0",
4
4
  "description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
5
5
  "license": "MIT",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -203,7 +203,7 @@
203
203
  "./package.json": "./package.json"
204
204
  },
205
205
  "dependencies": {
206
- "@cosmicdrift/kumiko-types": "0.402.0",
206
+ "@cosmicdrift/kumiko-types": "0.403.0",
207
207
  "bullmq": "^5.76.7",
208
208
  "hono": "^4.13.14",
209
209
  "i18next": "^26.1.0",
@@ -225,7 +225,7 @@
225
225
  }
226
226
  },
227
227
  "devDependencies": {
228
- "@cosmicdrift/kumiko-testing": "0.402.0",
228
+ "@cosmicdrift/kumiko-testing": "0.403.0",
229
229
  "bun-types": "^1.4.4",
230
230
  "fflate": "^0.8.3",
231
231
  "meilisearch": "^0.58.0",
package/src/changes.json CHANGED
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.403.0",
4
+ "type": "improvement",
5
+ "title": "Entities define named list filters",
6
+ "detail": "createEntity takes namedFilters, a record of { access?, filters(scope) } where filters returns ListFilter conditions (sync or async). The default list query accepts namedFilter and appends the resolved conditions to filters, on the row path and the aggregate path. An unknown name is a validation error on namedFilter, a failed access rule is access_denied. resolveNamedListFilters is exported from the engine entry. The definition is function-valued and never reaches the client schema."
7
+ },
2
8
  {
3
9
  "version": "0.402.0",
4
10
  "type": "improvement",