@cosmicdrift/kumiko-framework 0.402.0 → 0.404.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/agents/api/framework-engine.md +8 -1
- package/agents/api/framework-ui-types.md +4 -0
- package/agents/api/renderer-web.md +2 -1
- package/agents/api/types.md +56 -0
- package/agents/api.md +1 -1
- package/agents/boot-rules.md +12 -0
- package/agents/examples/dashboard-stats.md +58 -3
- package/agents/examples/lane-routing.md +40 -1
- package/dist/api/redis-sse-broker.d.ts +1 -0
- package/dist/api/redis-sse-broker.js +5 -3
- package/dist/engine/boot-validator/boot-phases-app.js +5 -0
- package/dist/engine/boot-validator/boot-rules.d.ts +36 -0
- package/dist/engine/boot-validator/boot-rules.js +2 -0
- package/dist/engine/boot-validator/dashboard-stat-navigate.d.ts +2 -0
- package/dist/engine/boot-validator/dashboard-stat-navigate.js +77 -0
- package/dist/engine/boot-validator/entity-list-screens-rules.d.ts +5 -0
- package/dist/engine/boot-validator/entity-list-screens-rules.js +5 -0
- package/dist/engine/boot-validator/entity-list-screens.js +12 -0
- package/dist/engine/boot-validator/named-filter-rules.d.ts +32 -0
- package/dist/engine/boot-validator/named-filter-rules.js +32 -0
- package/dist/engine/boot-validator/projection-list-screens.js +13 -0
- package/dist/engine/entity-handlers.d.ts +1 -0
- package/dist/engine/entity-handlers.js +13 -1
- package/dist/engine/factories.d.ts +2 -0
- package/dist/engine/feature-config-events-jobs.js +11 -7
- package/dist/engine/index.d.ts +1 -0
- package/dist/engine/index.js +1 -0
- package/dist/engine/resolve-named-list-filters.d.ts +3 -0
- package/dist/engine/resolve-named-list-filters.js +30 -0
- package/dist/engine/soft-delete-cleanup.js +2 -2
- package/dist/entrypoint/index.d.ts +5 -2
- package/dist/entrypoint/index.js +15 -6
- package/dist/entrypoint/internals.d.ts +2 -0
- package/dist/files/feature.js +1 -1
- package/dist/jobs/cron-repeat-options.d.ts +9 -0
- package/dist/jobs/cron-repeat-options.js +9 -0
- package/dist/jobs/index.d.ts +1 -0
- package/dist/jobs/index.js +1 -0
- package/dist/jobs/job-runner-context.js +4 -1
- package/dist/jobs/job-runner-types.d.ts +5 -0
- package/dist/jobs/job-runner.js +14 -5
- package/dist/jobs/maintenance-window.d.ts +7 -0
- package/dist/jobs/maintenance-window.js +14 -0
- package/dist/redis/cache-sync-bus.d.ts +1 -0
- package/dist/redis/cache-sync-bus.js +15 -6
- package/dist/redis/client.d.ts +12 -0
- package/dist/redis/client.js +30 -5
- package/dist/redis/index.d.ts +2 -2
- package/dist/redis/index.js +1 -1
- package/package.json +3 -3
- package/src/changes.json +18 -0
|
@@ -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`).
|
|
@@ -968,6 +971,9 @@ type JobTrigger =
|
|
|
968
971
|
cron: string;
|
|
969
972
|
tz?: string;
|
|
970
973
|
}
|
|
974
|
+
| {
|
|
975
|
+
maintenance: true;
|
|
976
|
+
}
|
|
971
977
|
| {
|
|
972
978
|
manual: true;
|
|
973
979
|
}
|
|
@@ -1351,6 +1357,7 @@ type ProjectionListScreenDefinition = {
|
|
|
1351
1357
|
paginated?: boolean; // Derived by buildAppSchema from the query handler's Zod schema (`cursor` or `offset` param present).
|
|
1352
1358
|
filter?: ScreenFilter; // Server-side filter, fixed on the screen — see `ScreenFilter` doc above (entityList).
|
|
1353
1359
|
facets?: ListFacetSpec[]; // User-toggleable facet dropdowns — see `ListFacetSpec` doc.
|
|
1360
|
+
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
1361
|
slots?: ScreenSlots;
|
|
1355
1362
|
access?: AccessRule;
|
|
1356
1363
|
};
|
|
@@ -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.
|
|
@@ -338,6 +338,7 @@ type CreateEventSourceLiveEventsOptions = {
|
|
|
338
338
|
url?: string; // URL of the SSE endpoint.
|
|
339
339
|
onSessionEnded?: () => void; // Fires once per connection when the session ended: the server refused the SSE handshake, or opened the stream with a `session-ended` frame (stale auth cookie downgraded to anonymous).
|
|
340
340
|
timers?: LiveEventTimers; // Reconnect-timer seam; defaults to the global setTimeout/clearTimeout.
|
|
341
|
+
stallTimers?: LiveEventTimers; // Stall-watchdog timer seam; defaults to the global setTimeout/clearTimeout.
|
|
341
342
|
random?: () => number; // Jitter source in [0, 1); defaults to Math.random.
|
|
342
343
|
now?: () => number; // Clock for synthetic resync events; defaults to Date.now.
|
|
343
344
|
};
|
package/agents/api/types.md
CHANGED
|
@@ -1247,6 +1247,7 @@ type JobQueuedMeta = {
|
|
|
1247
1247
|
type JobRunner = {
|
|
1248
1248
|
start(): Promise<void>;
|
|
1249
1249
|
stop(): Promise<void>;
|
|
1250
|
+
stopConsuming?(): Promise<void>;
|
|
1250
1251
|
dispatch(jobName: string, payload?: Record<string, unknown>, meta?: JobMeta): Promise<string>;
|
|
1251
1252
|
handleEvent(eventName: string, payload: Record<string, unknown>, user?: SessionUser, triggerEvent?: JobTriggerEvent): Promise<void>;
|
|
1252
1253
|
handleCatchUpEvent(jobName: string, eventName: string, payload: Record<string, unknown>, user: SessionUser, triggerEvent: JobTriggerEvent): Promise<void>;
|
|
@@ -1259,6 +1260,8 @@ type JobRunnerOptions = {
|
|
|
1259
1260
|
redisUrl: string;
|
|
1260
1261
|
consumerLane?: JobRunIn | undefined;
|
|
1261
1262
|
queueNamePrefix?: string | undefined;
|
|
1263
|
+
redisNamespace?: Pick<RedisNamespace, "keyPrefix" | "bullmqPrefix"> | undefined;
|
|
1264
|
+
maintenanceWindow?: MaintenanceWindow | undefined;
|
|
1262
1265
|
bootRedisTimeoutMs?: number | undefined;
|
|
1263
1266
|
schedulerSeenRefreshMs?: number | undefined;
|
|
1264
1267
|
jobRetention?: { completedAgeSec?: number | undefined; failedAgeSec?: number | undefined; } | undefined;
|
|
@@ -1272,6 +1275,15 @@ type JobRunnerOptions = {
|
|
|
1272
1275
|
};
|
|
1273
1276
|
```
|
|
1274
1277
|
|
|
1278
|
+
## packages/framework/src/jobs/maintenance-window.ts
|
|
1279
|
+
|
|
1280
|
+
```ts
|
|
1281
|
+
type MaintenanceWindow = {
|
|
1282
|
+
hour: number;
|
|
1283
|
+
minute: number;
|
|
1284
|
+
};
|
|
1285
|
+
```
|
|
1286
|
+
|
|
1275
1287
|
## packages/framework/src/lifecycle/lifecycle.ts
|
|
1276
1288
|
|
|
1277
1289
|
```ts
|
|
@@ -1436,6 +1448,17 @@ type SystemHooks = {
|
|
|
1436
1448
|
};
|
|
1437
1449
|
```
|
|
1438
1450
|
|
|
1451
|
+
## packages/framework/src/redis/client.ts
|
|
1452
|
+
|
|
1453
|
+
```ts
|
|
1454
|
+
type RedisNamespace = {
|
|
1455
|
+
keyPrefix: string; // ioredis keyPrefix for every non-BullMQ key; "" when unnamespaced.
|
|
1456
|
+
bullmqPrefix: string; // BullMQ `prefix` option ("bull" is BullMQ's own default).
|
|
1457
|
+
channelPrefix: string; // Prepended to every Pub/Sub channel (ioredis keyPrefix does not apply to Pub/Sub).
|
|
1458
|
+
channelSubscribePattern: string | undefined; // Set only with KUMIKO_REDIS_PREFIX.
|
|
1459
|
+
};
|
|
1460
|
+
```
|
|
1461
|
+
|
|
1439
1462
|
## packages/framework/src/secrets/dek-cache.ts
|
|
1440
1463
|
|
|
1441
1464
|
```ts
|
|
@@ -2676,6 +2699,15 @@ interface ResultTableFooterRow {
|
|
|
2676
2699
|
}
|
|
2677
2700
|
```
|
|
2678
2701
|
|
|
2702
|
+
## packages/renderer/src/web/widgets/stat.tsx
|
|
2703
|
+
|
|
2704
|
+
```ts
|
|
2705
|
+
type StatLink = { // Makes a tile a link; `onPress` is the SPA navigation, `href` serves modified clicks and a11y.
|
|
2706
|
+
href: string;
|
|
2707
|
+
onPress: () => void;
|
|
2708
|
+
};
|
|
2709
|
+
```
|
|
2710
|
+
|
|
2679
2711
|
## packages/testing/src/bundled-injection.ts
|
|
2680
2712
|
|
|
2681
2713
|
```ts
|
|
@@ -4377,6 +4409,30 @@ type MultiStreamApplyContext<TMap extends object = KumikoEventTypeMap> = {
|
|
|
4377
4409
|
};
|
|
4378
4410
|
```
|
|
4379
4411
|
|
|
4412
|
+
## packages/types/src/named-list-filters.ts
|
|
4413
|
+
|
|
4414
|
+
```ts
|
|
4415
|
+
type NamedListFilterCondition = {
|
|
4416
|
+
field: string;
|
|
4417
|
+
op: NamedListFilterOp;
|
|
4418
|
+
value: unknown;
|
|
4419
|
+
};
|
|
4420
|
+
type NamedListFilterDef = { // A work-queue rule defined once on the entity: the list and every count query resolve the same conditions.
|
|
4421
|
+
access?: AccessRule;
|
|
4422
|
+
filters: (scope: NamedListFilterScope) => NamedListFilterCondition[] | Promise<NamedListFilterCondition[]>;
|
|
4423
|
+
};
|
|
4424
|
+
type NamedListFilterOp = "eq" | "ne" | "lt" | "gt" | "lte" | "gte" | "in";
|
|
4425
|
+
type NamedListFilterRef = { // Screen-side reference to an entity-defined named filter; `label` is an i18n key.
|
|
4426
|
+
id: string;
|
|
4427
|
+
label: string;
|
|
4428
|
+
};
|
|
4429
|
+
type NamedListFilterScope = {
|
|
4430
|
+
ctx: HandlerContext;
|
|
4431
|
+
user: SessionUser;
|
|
4432
|
+
now: Temporal.Instant;
|
|
4433
|
+
};
|
|
4434
|
+
```
|
|
4435
|
+
|
|
4380
4436
|
## packages/types/src/nav.ts
|
|
4381
4437
|
|
|
4382
4438
|
```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` |
|
|
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 |
|
package/agents/boot-rules.md
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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",
|
|
@@ -13,6 +13,7 @@ Pin jobs to deploy lanes (`api` | `worker`) with event-triggered fan-out.
|
|
|
13
13
|
## What it shows
|
|
14
14
|
|
|
15
15
|
- `r.job({ runIn: "worker" })` after an HTTP write
|
|
16
|
+
- `trigger: { maintenance: true }` for a daily housekeeping job in the app's maintenance window (`KUMIKO_MAINTENANCE_WINDOW`)
|
|
16
17
|
- End-to-end verification via `createAllInOneEntrypoint`
|
|
17
18
|
|
|
18
19
|
## Source
|
|
@@ -65,7 +66,12 @@ import {
|
|
|
65
66
|
type TestDb,
|
|
66
67
|
type TestRedis,
|
|
67
68
|
} from "@cosmicdrift/kumiko-testing/stack";
|
|
68
|
-
import {
|
|
69
|
+
import {
|
|
70
|
+
createLaneRoutingFeature,
|
|
71
|
+
pruneRuns,
|
|
72
|
+
renderedReceipts,
|
|
73
|
+
sentConfirmations,
|
|
74
|
+
} from "../feature";
|
|
69
75
|
|
|
70
76
|
const JWT = "lane-routing-sample-secret-minimum-32-chars!";
|
|
71
77
|
const adminUser = TestUsers.admin;
|
|
@@ -87,6 +93,7 @@ afterAll(async () => {
|
|
|
87
93
|
afterEach(() => {
|
|
88
94
|
renderedReceipts.length = 0;
|
|
89
95
|
sentConfirmations.length = 0;
|
|
96
|
+
pruneRuns.length = 0;
|
|
90
97
|
});
|
|
91
98
|
|
|
92
99
|
describe("lane-routing sample", () => {
|
|
@@ -132,6 +139,30 @@ describe("lane-routing sample", () => {
|
|
|
132
139
|
await entry.stop();
|
|
133
140
|
}
|
|
134
141
|
});
|
|
142
|
+
|
|
143
|
+
test("a maintenance job is registered for the daily window and runs on the worker lane", async () => {
|
|
144
|
+
const registry = createRegistry([createLaneRoutingFeature()]);
|
|
145
|
+
expect(registry.getJob("orders:job:prune-old-receipts")?.trigger).toEqual({
|
|
146
|
+
maintenance: true,
|
|
147
|
+
});
|
|
148
|
+
const redisUrl = `redis://${testRedis.redis.options.host}:${testRedis.redis.options.port}/${testRedis.redis.options.db}`;
|
|
149
|
+
const entry = createAllInOneEntrypoint({
|
|
150
|
+
rowLevelSecurity: "off",
|
|
151
|
+
registry,
|
|
152
|
+
context: { db: testDb.db, redis: testRedis.redis },
|
|
153
|
+
jwtSecret: JWT,
|
|
154
|
+
redisUrl,
|
|
155
|
+
queueNamePrefix: `lane-routing-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
156
|
+
maintenanceWindow: { hour: 5, minute: 30 },
|
|
157
|
+
});
|
|
158
|
+
await entry.start();
|
|
159
|
+
try {
|
|
160
|
+
await entry.jobRunner.dispatch("orders:job:prune-old-receipts", {});
|
|
161
|
+
await waitFor(() => expect(pruneRuns).toHaveLength(1));
|
|
162
|
+
} finally {
|
|
163
|
+
await entry.stop();
|
|
164
|
+
}
|
|
165
|
+
});
|
|
135
166
|
});
|
|
136
167
|
```
|
|
137
168
|
|
|
@@ -163,6 +194,7 @@ import { z } from "zod";
|
|
|
163
194
|
// opaque to the framework.
|
|
164
195
|
export const renderedReceipts: Array<{ customerName: string; amount: number }> = [];
|
|
165
196
|
export const sentConfirmations: Array<{ customerName: string; amount: number }> = [];
|
|
197
|
+
export const pruneRuns: number[] = [];
|
|
166
198
|
|
|
167
199
|
// The entity declaration secures the qualified-name prefix "orders:write:order:create"
|
|
168
200
|
// for the write handler below and thereby the job trigger on this event.
|
|
@@ -240,6 +272,13 @@ export function createLaneRoutingFeature(): FeatureDefinition {
|
|
|
240
272
|
});
|
|
241
273
|
},
|
|
242
274
|
);
|
|
275
|
+
|
|
276
|
+
// Housekeeping: `maintenance` runs once a day in the app's maintenance window
|
|
277
|
+
// (KUMIKO_MAINTENANCE_WINDOW, UTC, default 03:00) instead of a cron time of its own, so a
|
|
278
|
+
// sleeping app wakes once a day for all its cleanup jobs.
|
|
279
|
+
r.job("prune-old-receipts", { trigger: { maintenance: true }, runIn: "worker" }, async () => {
|
|
280
|
+
pruneRuns.push(Date.now());
|
|
281
|
+
});
|
|
243
282
|
});
|
|
244
283
|
}
|
|
245
284
|
```
|
|
@@ -4,6 +4,7 @@ export type RedisSseBrokerOptions = {
|
|
|
4
4
|
readonly redisUrl: string;
|
|
5
5
|
readonly clientOptions?: RedisClientOptions;
|
|
6
6
|
readonly channelPrefix?: string;
|
|
7
|
+
readonly channelSubscribePattern?: string;
|
|
7
8
|
};
|
|
8
9
|
export type RedisSseBroker = SseBroker & {
|
|
9
10
|
readonly kind: "redis";
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
|
|
2
2
|
import { createFallbackLogger } from "../logging/utils.js";
|
|
3
|
-
import {
|
|
3
|
+
import { redisClientOptionsFromEnv, redisNamespaceFromEnv, } from "../redis/client.js";
|
|
4
4
|
import { createRedisPubSubSignal } from "../redis/pubsub-signal.js";
|
|
5
5
|
import { createSseBroker, readNonEmptyStringArray, } from "./sse-broker.js";
|
|
6
6
|
const log = createFallbackLogger("kumiko:sse-broker");
|
|
@@ -28,11 +28,13 @@ export function isRedisSseBroker(broker) {
|
|
|
28
28
|
// REDIS_URL-based default never ran.
|
|
29
29
|
export function createDefaultSseBroker(env = process.env) {
|
|
30
30
|
const redisUrl = env["REDIS_URL"];
|
|
31
|
+
const { channelPrefix, channelSubscribePattern } = redisNamespaceFromEnv(env);
|
|
31
32
|
const ownedRedisSseBroker = redisUrl
|
|
32
33
|
? createRedisSseBroker({
|
|
33
34
|
redisUrl,
|
|
34
35
|
clientOptions: redisClientOptionsFromEnv(env),
|
|
35
|
-
channelPrefix
|
|
36
|
+
channelPrefix,
|
|
37
|
+
...(channelSubscribePattern !== undefined && { channelSubscribePattern }),
|
|
36
38
|
})
|
|
37
39
|
: undefined;
|
|
38
40
|
return { sseBroker: ownedRedisSseBroker ?? createSseBroker(), ownedRedisSseBroker };
|
|
@@ -104,7 +106,7 @@ export function createRedisSseBroker(opts) {
|
|
|
104
106
|
const invalidationPrefix = `${opts.channelPrefix ?? ""}${INVALIDATION_PREFIX}`;
|
|
105
107
|
const signal = createRedisPubSubSignal({
|
|
106
108
|
redisUrl: opts.redisUrl,
|
|
107
|
-
channelPattern: `${opts.channelPrefix ?? ""}${PSUBSCRIBE_SUFFIX}`,
|
|
109
|
+
channelPattern: opts.channelSubscribePattern ?? `${opts.channelPrefix ?? ""}${PSUBSCRIBE_SUFFIX}`,
|
|
108
110
|
label: "sse-broker",
|
|
109
111
|
...(opts.clientOptions ? { clientOptions: opts.clientOptions } : {}),
|
|
110
112
|
});
|
|
@@ -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,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.";
|