@cosmicdrift/kumiko-framework 0.291.0 → 0.292.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -4
- package/src/changes.json +12 -0
- package/src/engine/__tests__/boot-validator-projection-list.test.ts +93 -0
- package/src/engine/boot-validator/index.ts +3 -2
- package/src/engine/boot-validator/projection-list-screens.ts +52 -2
- package/src/engine/extension-names.ts +10 -0
- package/src/engine/index.ts +2 -0
- package/src/engine/system-user.ts +3 -5
- package/src/files/provider-resolver.ts +9 -2
- package/src/ui-types/list-row-meta.ts +4 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.292.0",
|
|
4
4
|
"description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -198,8 +198,8 @@
|
|
|
198
198
|
"./package.json": "./package.json"
|
|
199
199
|
},
|
|
200
200
|
"dependencies": {
|
|
201
|
-
"@cosmicdrift/kumiko-http": "0.
|
|
202
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
201
|
+
"@cosmicdrift/kumiko-http": "0.292.0",
|
|
202
|
+
"@cosmicdrift/kumiko-types": "0.292.0",
|
|
203
203
|
"bullmq": "^5.76.7",
|
|
204
204
|
"bun-types": "^1.3.13",
|
|
205
205
|
"hono": "^4.13.1",
|
|
@@ -215,7 +215,7 @@
|
|
|
215
215
|
"zod": "^4.4.3"
|
|
216
216
|
},
|
|
217
217
|
"devDependencies": {
|
|
218
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
218
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.292.0",
|
|
219
219
|
"bun-types": "^1.3.13",
|
|
220
220
|
"pino-pretty": "^13.1.3"
|
|
221
221
|
},
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,16 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.292.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "Validate changeset folding in PR CI",
|
|
6
|
+
"detail": "The release-time `changes fold` now also runs as a dry run on every PR, so a changeset with an unresolvable feature fails its own PR instead of the next release."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.292.0",
|
|
10
|
+
"type": "improvement",
|
|
11
|
+
"title": "A projectionList can declare a time-range filter",
|
|
12
|
+
"detail": "A list bound to a query that already accepts time bounds had no way to expose them: `ListFacetSpec` knew `select`, `boolean` and `reference`, so every list with a timestamp — which, through `createdAt`, is practically every list — could be searched but not narrowed to \"the week the incident happened\". The audit log shipped a `description` promising date filters that no control backed.\n`{ type: \"dateRange\", field, label, params: { from, to } }` closes that. The renderer maps it to two native `<input type=\"date\">` next to the facet dropdowns (no date dependency; the browser supplies the calendar, the locale and the keyboard handling) and sends the picked bounds as the two query params the facet names — explicit rather than a `from`/`to` convention, since a query is free to call them anything, and checked against the handler's Zod schema at boot. Filtering stays server-side; either bound alone is a valid open interval; changing the range resets the page like every other facet; an inverted range is clamped in the UI instead of reaching the handler's `from <= to` refine.\nA calendar date covers a whole day in the viewer's time zone: \"to the 14th\" includes everything through the last instant of the 14th, computed across DST boundaries rather than by adding 24 hours. `audit:screen:audit-log` now declares the facet on `createdAt`, so its description holds."
|
|
13
|
+
},
|
|
2
14
|
{
|
|
3
15
|
"version": "0.291.0",
|
|
4
16
|
"type": "breaking",
|
|
@@ -443,6 +443,99 @@ describe("validateBoot — projectionList screens", () => {
|
|
|
443
443
|
expect(() => validateBoot([feature])).not.toThrow();
|
|
444
444
|
});
|
|
445
445
|
|
|
446
|
+
// fw#3104: a dateRange facet sends its two bounds as the top-level params
|
|
447
|
+
// it names, so `filters` is the wrong thing to require — the declared
|
|
448
|
+
// param names are.
|
|
449
|
+
test("a dateRange facet on a query that can't narrow by time is rejected at boot", () => {
|
|
450
|
+
const feature = defineFeature("ledger", (r) => {
|
|
451
|
+
r.queryHandler(
|
|
452
|
+
"schedule:list",
|
|
453
|
+
z.object({ from: z.iso.datetime().optional() }),
|
|
454
|
+
async () => ({ rows: [], nextCursor: null }),
|
|
455
|
+
{ access: { openToAll: { reason: "test handler callable by any signed-in test user" } } },
|
|
456
|
+
);
|
|
457
|
+
r.screen({
|
|
458
|
+
id: "schedule-list",
|
|
459
|
+
type: "projectionList",
|
|
460
|
+
query: "ledger:query:schedule:list",
|
|
461
|
+
columns: ["dueAt"],
|
|
462
|
+
facets: [
|
|
463
|
+
{
|
|
464
|
+
field: "dueAt",
|
|
465
|
+
type: "dateRange",
|
|
466
|
+
label: "Due",
|
|
467
|
+
params: { from: "from", to: "to" },
|
|
468
|
+
},
|
|
469
|
+
],
|
|
470
|
+
});
|
|
471
|
+
r.translations({
|
|
472
|
+
keys: { "screen:schedule-list.title": { de: "Liste", en: "List" } },
|
|
473
|
+
});
|
|
474
|
+
});
|
|
475
|
+
expect(() => validateBoot([feature])).toThrow(/no "to" parameter/);
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
test('a dateRange facet does NOT require a "filters" parameter', () => {
|
|
479
|
+
const feature = defineFeature("ledger", (r) => {
|
|
480
|
+
r.queryHandler(
|
|
481
|
+
"schedule:list",
|
|
482
|
+
z.object({
|
|
483
|
+
since: z.iso.datetime().optional(),
|
|
484
|
+
until: z.iso.datetime().optional(),
|
|
485
|
+
}),
|
|
486
|
+
async () => ({ rows: [], nextCursor: null }),
|
|
487
|
+
{ access: { openToAll: { reason: "test handler callable by any signed-in test user" } } },
|
|
488
|
+
);
|
|
489
|
+
r.screen({
|
|
490
|
+
id: "schedule-list",
|
|
491
|
+
type: "projectionList",
|
|
492
|
+
query: "ledger:query:schedule:list",
|
|
493
|
+
columns: ["dueAt"],
|
|
494
|
+
facets: [
|
|
495
|
+
{
|
|
496
|
+
field: "dueAt",
|
|
497
|
+
type: "dateRange",
|
|
498
|
+
label: "Due",
|
|
499
|
+
params: { from: "since", to: "until" },
|
|
500
|
+
},
|
|
501
|
+
],
|
|
502
|
+
});
|
|
503
|
+
r.translations({
|
|
504
|
+
keys: { "screen:schedule-list.title": { de: "Liste", en: "List" } },
|
|
505
|
+
});
|
|
506
|
+
});
|
|
507
|
+
expect(() => validateBoot([feature])).not.toThrow();
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
test("a dateRange facet naming a reserved list-payload key is rejected", () => {
|
|
511
|
+
const feature = defineFeature("ledger", (r) => {
|
|
512
|
+
r.queryHandler(
|
|
513
|
+
"schedule:list",
|
|
514
|
+
z.object({ limit: z.number().optional(), to: z.iso.datetime().optional() }),
|
|
515
|
+
async () => ({ rows: [], nextCursor: null }),
|
|
516
|
+
{ access: { openToAll: { reason: "test handler callable by any signed-in test user" } } },
|
|
517
|
+
);
|
|
518
|
+
r.screen({
|
|
519
|
+
id: "schedule-list",
|
|
520
|
+
type: "projectionList",
|
|
521
|
+
query: "ledger:query:schedule:list",
|
|
522
|
+
columns: ["dueAt"],
|
|
523
|
+
facets: [
|
|
524
|
+
{
|
|
525
|
+
field: "dueAt",
|
|
526
|
+
type: "dateRange",
|
|
527
|
+
label: "Due",
|
|
528
|
+
params: { from: "limit", to: "to" },
|
|
529
|
+
},
|
|
530
|
+
],
|
|
531
|
+
});
|
|
532
|
+
r.translations({
|
|
533
|
+
keys: { "screen:schedule-list.title": { de: "Liste", en: "List" } },
|
|
534
|
+
});
|
|
535
|
+
});
|
|
536
|
+
expect(() => validateBoot([feature])).toThrow(/reserved list-payload key/);
|
|
537
|
+
});
|
|
538
|
+
|
|
446
539
|
test("a reference facet targeting an unknown entity is rejected", () => {
|
|
447
540
|
const feature = defineFeature("ledger", (r) => {
|
|
448
541
|
r.queryHandler(
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { validateEntityFieldEncryptionAvailable } from "../../db/entity-field-encryption";
|
|
2
2
|
import { dedupeFeatures } from "../dedupe-features";
|
|
3
|
+
import { FILE_STORAGE_PROVIDER_ENV } from "../extension-names";
|
|
3
4
|
import { QnTypes, qualifyEntityName } from "../qualified-name";
|
|
4
5
|
import type { FeatureDefinition } from "../types";
|
|
5
6
|
import { validateAccessDeclarations } from "./access-declarations";
|
|
@@ -278,9 +279,9 @@ export function validateBoot(
|
|
|
278
279
|
validateEntityFieldEncryptionAvailable();
|
|
279
280
|
}
|
|
280
281
|
|
|
281
|
-
if (hasFileFields && !process.env[
|
|
282
|
+
if (hasFileFields && !process.env[FILE_STORAGE_PROVIDER_ENV]) {
|
|
282
283
|
throw new Error(
|
|
283
|
-
|
|
284
|
+
`${FILE_STORAGE_PROVIDER_ENV} environment variable is required (file/image fields in use)`,
|
|
284
285
|
);
|
|
285
286
|
}
|
|
286
287
|
|
|
@@ -105,13 +105,63 @@ function validateProjectionListFilterSchemaAcceptance(
|
|
|
105
105
|
// (fw#2165): definePagedQueryHandler doesn't auto-merge params into the
|
|
106
106
|
// handler's own Zod schema, so a declared facet would 422 on every query
|
|
107
107
|
// unless the author added `filters` themselves.
|
|
108
|
+
// A dateRange facet (fw#3104) sends its two bounds as the top-level payload
|
|
109
|
+
// keys it names in `params`, not as a `filters` entry — so the query has to
|
|
110
|
+
// accept exactly those keys. Catches the facet pointed at a field the query
|
|
111
|
+
// can't narrow by, which would otherwise 422 on the first date the user picks.
|
|
112
|
+
// Keys buildListQueryPayload owns — a facet param naming one of them would
|
|
113
|
+
// silently replace the list's own paging/sorting on every pick.
|
|
114
|
+
const RESERVED_LIST_PAYLOAD_KEYS: ReadonlySet<string> = new Set([
|
|
115
|
+
"limit",
|
|
116
|
+
"search",
|
|
117
|
+
"sort",
|
|
118
|
+
"sortDirection",
|
|
119
|
+
"offset",
|
|
120
|
+
"totalCount",
|
|
121
|
+
"cursor",
|
|
122
|
+
"filter",
|
|
123
|
+
"filters",
|
|
124
|
+
]);
|
|
125
|
+
|
|
126
|
+
function validateProjectionListDateRangeFacets(
|
|
127
|
+
prefix: string,
|
|
128
|
+
screen: ProjectionListScreenDefinition,
|
|
129
|
+
schema: QueryHandlerDef["schema"] | undefined,
|
|
130
|
+
): void {
|
|
131
|
+
for (const facet of screen.facets ?? []) {
|
|
132
|
+
if (facet.type !== "dateRange") continue;
|
|
133
|
+
if (facet.params.from === facet.params.to) {
|
|
134
|
+
throw new Error(
|
|
135
|
+
`${prefix}: dateRange facet on "${facet.field}" names the same param ` +
|
|
136
|
+
`"${facet.params.from}" for both bounds.`,
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
for (const param of [facet.params.from, facet.params.to]) {
|
|
140
|
+
if (RESERVED_LIST_PAYLOAD_KEYS.has(param)) {
|
|
141
|
+
throw new Error(
|
|
142
|
+
`${prefix}: dateRange facet on "${facet.field}" names "${param}" as a bound, ` +
|
|
143
|
+
`which is a reserved list-payload key — pick the query's own time-bound param names.`,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
if (schemaAccepts(schema, param)) continue;
|
|
147
|
+
throw new Error(
|
|
148
|
+
`${prefix}: dateRange facet on "${facet.field}" sends "${param}" but query ` +
|
|
149
|
+
`"${screen.query}" has no "${param}" parameter in its Zod schema — add ` +
|
|
150
|
+
`${param}: z.iso.datetime().optional() to the handler's schema, or point ` +
|
|
151
|
+
`params at the keys it already accepts.`,
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
108
157
|
function validateProjectionListFacetsSchemaAcceptance(
|
|
109
158
|
prefix: string,
|
|
110
159
|
screen: ProjectionListScreenDefinition,
|
|
111
160
|
schema: QueryHandlerDef["schema"] | undefined,
|
|
112
161
|
): void {
|
|
113
|
-
|
|
114
|
-
|
|
162
|
+
validateProjectionListDateRangeFacets(prefix, screen, schema);
|
|
163
|
+
// skip: no facets that travel via `filters` — nothing to reject.
|
|
164
|
+
if (screen.facets === undefined || !screen.facets.some((f) => f.type !== "dateRange")) return;
|
|
115
165
|
// skip: the schema already accepts filters — nothing to reject.
|
|
116
166
|
if (schemaAccepts(schema, "filters")) return;
|
|
117
167
|
throw new Error(
|
|
@@ -87,6 +87,16 @@ export const EXT_FILE_PROVIDER = "fileProvider" as const;
|
|
|
87
87
|
// des file-foundation-Features MUSS diese Konstante mitziehen.
|
|
88
88
|
export const FILE_PROVIDER_CONFIG_KEY = "file-foundation:config:provider" as const;
|
|
89
89
|
|
|
90
|
+
// Two roles: boot gate (validateBoot requires its presence once file/image
|
|
91
|
+
// fields are in use) AND the ENV source of the config key above, bridged via
|
|
92
|
+
// keyDef.env. Tenant rows keep overriding the bridged value.
|
|
93
|
+
export const FILE_STORAGE_PROVIDER_ENV = "FILE_STORAGE_PROVIDER" as const;
|
|
94
|
+
|
|
95
|
+
// Presence placeholder runDevApp writes when an explicitly wired provider
|
|
96
|
+
// (options.files) already satisfies the boot gate. It names no plugin, so
|
|
97
|
+
// provider resolution treats it like an unset key.
|
|
98
|
+
export const FILE_STORAGE_PROVIDER_BOOT_SENTINEL = "configured" as const;
|
|
99
|
+
|
|
90
100
|
/**
|
|
91
101
|
* `derivativeRenderer` — File-Derivative-Renderer-Plugin-Selection
|
|
92
102
|
* (file-derivatives).
|
package/src/engine/index.ts
CHANGED
|
@@ -113,6 +113,8 @@ export {
|
|
|
113
113
|
EXT_USER_DATA,
|
|
114
114
|
EXT_USER_DATA_ORDER,
|
|
115
115
|
FILE_PROVIDER_CONFIG_KEY,
|
|
116
|
+
FILE_STORAGE_PROVIDER_BOOT_SENTINEL,
|
|
117
|
+
FILE_STORAGE_PROVIDER_ENV,
|
|
116
118
|
TENANT_MEMBERSHIPS_QUERY,
|
|
117
119
|
} from "./extension-names";
|
|
118
120
|
export { extensionUsageEscapeHatchReason } from "./extensions/escape-hatch-usage";
|
|
@@ -1,10 +1,8 @@
|
|
|
1
1
|
import type { SessionUser } from "./types";
|
|
2
|
-
import type
|
|
2
|
+
import { SYSTEM_USER_ID, type TenantId } from "./types/identifiers";
|
|
3
|
+
|
|
4
|
+
export { SYSTEM_USER_ID };
|
|
3
5
|
|
|
4
|
-
// Stringified so it round-trips through SessionUser.id (string UUID-shape).
|
|
5
|
-
// Not a real UUID — SYSTEM acts as an alias for "no human caller" and event-
|
|
6
|
-
// store createdBy is text, so the literal suffices.
|
|
7
|
-
export const SYSTEM_USER_ID = "00000000-0000-0000-0000-000000000000";
|
|
8
6
|
export const SYSTEM_ROLE = "system" as const;
|
|
9
7
|
|
|
10
8
|
// extraRoles: hasAccess kennt keinen System-Bypass — Handler gaten auf
|
|
@@ -15,7 +15,11 @@
|
|
|
15
15
|
import type { FileProviderResolver } from "@cosmicdrift/kumiko-types/file-provider-resolver-types";
|
|
16
16
|
import type { DbConnection } from "../db/connection";
|
|
17
17
|
import type { TenantDb } from "../db/tenant-db";
|
|
18
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
EXT_FILE_PROVIDER,
|
|
20
|
+
FILE_PROVIDER_CONFIG_KEY,
|
|
21
|
+
FILE_STORAGE_PROVIDER_BOOT_SENTINEL,
|
|
22
|
+
} from "../engine/extension-names";
|
|
19
23
|
import { SYSTEM_USER_ID } from "../engine/system-user";
|
|
20
24
|
import type { ConfigAccessor, ConfigAccessorFactory, Registry } from "../engine/types";
|
|
21
25
|
import type { SecretsContext } from "../secrets";
|
|
@@ -93,7 +97,10 @@ export async function createFileProviderForTenant(
|
|
|
93
97
|
|
|
94
98
|
const raw = await ctxConfig(FILE_PROVIDER_CONFIG_KEY);
|
|
95
99
|
const provider = typeof raw === "string" ? raw : raw == null ? "" : String(raw);
|
|
96
|
-
|
|
100
|
+
// The boot-gate placeholder names no plugin: treated as a provider name it
|
|
101
|
+
// would let FILE_STORAGE_PROVIDER=configured fake a selection through the
|
|
102
|
+
// ENV bridge.
|
|
103
|
+
if (provider.length === 0 || provider === FILE_STORAGE_PROVIDER_BOOT_SENTINEL) {
|
|
97
104
|
const usages = ctx.registry.getExtensionUsages(EXT_FILE_PROVIDER);
|
|
98
105
|
const known = usages.map((u) => u.entityName).join(", ") || "<none>";
|
|
99
106
|
throw new Error(
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
// deletedById) — this is also the SAME set the boot-validator's entityList
|
|
13
13
|
// column checks accept, so a softDelete column stays a boot-time error
|
|
14
14
|
// instead of a renderer-side throw (see screens.ts / entity-list-screens.ts).
|
|
15
|
-
import { SYSTEM_TENANT_ID } from "../engine/types/identifiers";
|
|
15
|
+
import { SYSTEM_TENANT_ID, SYSTEM_USER_ID } from "../engine/types/identifiers";
|
|
16
16
|
|
|
17
17
|
export type ListRowMetaColumnType = "text" | "number" | "timestamp";
|
|
18
18
|
|
|
@@ -56,4 +56,7 @@ export type SystemReferenceLabel = {
|
|
|
56
56
|
// entity, not just delivery-log (fw#2662).
|
|
57
57
|
export const SYSTEM_REFERENCE_LABELS: Readonly<Record<string, SystemReferenceLabel>> = {
|
|
58
58
|
"tenant:tenant": { id: SYSTEM_TENANT_ID, labelKey: "kumiko.reference.system-tenant" },
|
|
59
|
+
// createdBy on a system write (fw#3103) — SYSTEM_USER_ID is an alias for
|
|
60
|
+
// "no human caller", so read_users never holds a matching row.
|
|
61
|
+
"user:user": { id: SYSTEM_USER_ID, labelKey: "kumiko.reference.system-user" },
|
|
59
62
|
};
|