@cosmicdrift/kumiko-framework 0.112.0 → 0.113.1
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 +2 -2
- package/src/api/routes.ts +4 -1
- package/src/engine/__tests__/registry.test.ts +28 -0
- package/src/engine/__tests__/screen.test.ts +40 -0
- package/src/engine/__tests__/soft-delete-cleanup.test.ts +3 -0
- package/src/engine/boot-validator/screens-nav.ts +21 -0
- package/src/engine/index.ts +0 -1
- package/src/engine/registry.ts +6 -20
- package/src/engine/soft-delete-cleanup.ts +6 -2
- package/src/engine/types/fields.ts +3 -1
- package/src/engine/types/index.ts +1 -0
- package/src/engine/types/screen.ts +29 -0
- package/src/event-store/__tests__/row-to-stored-event.test.ts +2 -2
- package/src/files/__tests__/provider-resolver.test.ts +70 -0
- package/src/files/provider-resolver.ts +28 -15
- package/src/i18n/required-surface-keys.ts +12 -0
- package/src/pipeline/projection-rebuild.ts +12 -0
- package/src/testing/e2e-generator.ts +3 -0
- package/src/time/__tests__/tz-dateline.test.ts +8 -10
- package/src/ui-types/index.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.113.1",
|
|
4
4
|
"description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -181,7 +181,7 @@
|
|
|
181
181
|
"zod": "^4.4.3"
|
|
182
182
|
},
|
|
183
183
|
"devDependencies": {
|
|
184
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
184
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.113.1",
|
|
185
185
|
"bun-types": "^1.3.13",
|
|
186
186
|
"pino-pretty": "^13.1.3"
|
|
187
187
|
},
|
package/src/api/routes.ts
CHANGED
|
@@ -90,7 +90,10 @@ export function createApiRoutes(dispatcher: Dispatcher) {
|
|
|
90
90
|
}
|
|
91
91
|
return c.json(result);
|
|
92
92
|
} catch (e) {
|
|
93
|
-
|
|
93
|
+
// 615/1: pass the batch's command types, not undefined — a single
|
|
94
|
+
// "type" doesn't apply to a batch, but logging none loses the fault
|
|
95
|
+
// context entirely.
|
|
96
|
+
return writeErrorResponse(c, toKumiko(e), body.commands?.map((cmd) => cmd.type).join(","));
|
|
94
97
|
}
|
|
95
98
|
});
|
|
96
99
|
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { z } from "zod";
|
|
2
3
|
import { createTenantConfig } from "../config-helpers";
|
|
3
4
|
import { defineFeature } from "../define-feature";
|
|
5
|
+
import { createEntity, createTextField } from "../factories";
|
|
4
6
|
import { createRegistry } from "../registry";
|
|
5
7
|
import type { FeatureDefinition } from "../types/feature";
|
|
6
8
|
|
|
@@ -130,4 +132,30 @@ describe("extensionSelector boot-validation", () => {
|
|
|
130
132
|
}),
|
|
131
133
|
).toThrow(/declared twice/);
|
|
132
134
|
});
|
|
135
|
+
|
|
136
|
+
// 437/2: createRegistry now delegates this check to
|
|
137
|
+
// validateExtensionPreSaveWiring (shared with validateBoot's standalone
|
|
138
|
+
// callers) instead of a duplicate inline computation — pins that
|
|
139
|
+
// createRegistry itself still enforces it, not just validateBoot.
|
|
140
|
+
test("throws when extension preSave targets entity with no mapped write handlers", () => {
|
|
141
|
+
const ext = defineFeature("cap-ext", (r) => {
|
|
142
|
+
r.extendsRegistrar("credit-cap", {
|
|
143
|
+
hooks: { preSave: async (changes) => changes },
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
const consumer = defineFeature("money-horse", (r) => {
|
|
147
|
+
r.requires("cap-ext");
|
|
148
|
+
r.entity("credit", createEntity({ table: "Credits", fields: { name: createTextField() } }));
|
|
149
|
+
r.writeHandler(
|
|
150
|
+
"doSomething",
|
|
151
|
+
z.object({}),
|
|
152
|
+
async () => ({ isSuccess: true as const, data: {} }),
|
|
153
|
+
{ access: { openToAll: true } },
|
|
154
|
+
);
|
|
155
|
+
r.useExtension("credit-cap", "credit");
|
|
156
|
+
});
|
|
157
|
+
expect(() => createRegistry([ext, consumer])).toThrow(
|
|
158
|
+
/no write handler is entity-mapped to "credit"/i,
|
|
159
|
+
);
|
|
160
|
+
});
|
|
133
161
|
});
|
|
@@ -45,6 +45,46 @@ describe("r.screen() — registration", () => {
|
|
|
45
45
|
expect(feature.screens["product-list"]?.type).toBe("entityList");
|
|
46
46
|
});
|
|
47
47
|
|
|
48
|
+
test("stores a projectionList screen bound to an explicit (cross-feature) query", () => {
|
|
49
|
+
const feature = defineFeature("app", (r) => {
|
|
50
|
+
r.screen({
|
|
51
|
+
id: "rent-list",
|
|
52
|
+
type: "projectionList",
|
|
53
|
+
query: "ledger:query:schedule:list",
|
|
54
|
+
columns: [
|
|
55
|
+
{ field: "description", label: "app:col.desc" },
|
|
56
|
+
{
|
|
57
|
+
field: "amount",
|
|
58
|
+
label: "app:col.amount",
|
|
59
|
+
renderer: { react: { __component: "EuroCell" } },
|
|
60
|
+
},
|
|
61
|
+
],
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
const screen = feature.screens["rent-list"];
|
|
65
|
+
expect(screen?.type).toBe("projectionList");
|
|
66
|
+
if (screen?.type !== "projectionList") throw new Error("type-narrow failed");
|
|
67
|
+
expect(screen.query).toBe("ledger:query:schedule:list");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("validateBoot rejects a projectionList with empty columns", () => {
|
|
71
|
+
const features = [
|
|
72
|
+
defineFeature("app", (r) => {
|
|
73
|
+
r.screen({ id: "x", type: "projectionList", query: "app:query:foo:list", columns: [] });
|
|
74
|
+
}),
|
|
75
|
+
];
|
|
76
|
+
expect(() => validateBoot(features)).toThrow(/empty columns list/i);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("validateBoot rejects a projectionList with an empty query", () => {
|
|
80
|
+
const features = [
|
|
81
|
+
defineFeature("app", (r) => {
|
|
82
|
+
r.screen({ id: "x", type: "projectionList", query: "", columns: ["name"] });
|
|
83
|
+
}),
|
|
84
|
+
];
|
|
85
|
+
expect(() => validateBoot(features)).toThrow(/empty or non-string query/i);
|
|
86
|
+
});
|
|
87
|
+
|
|
48
88
|
test("stores an entityEdit screen with sections + conditional fields", () => {
|
|
49
89
|
const feature = defineFeature("shop", (r) => {
|
|
50
90
|
r.entity("product", productEntity());
|
|
@@ -31,6 +31,9 @@ describe("registry soft-delete auto-wiring", () => {
|
|
|
31
31
|
const key = registry.getConfigKey(SOFT_DELETE_GRACE_DAYS_KEY);
|
|
32
32
|
expect(key?.type).toBe("number");
|
|
33
33
|
expect(key?.default).toBe(DEFAULT_GRACE_DAYS);
|
|
34
|
+
// 565/2: min must be 1, not 0 — graceDays: 0 means "hard-delete every
|
|
35
|
+
// currently soft-deleted row on the next cron run", not "no cleanup".
|
|
36
|
+
expect(key?.bounds?.min).toBe(1);
|
|
34
37
|
});
|
|
35
38
|
|
|
36
39
|
test("also injects the system-scope cleanup job (not perTenant)", () => {
|
|
@@ -141,6 +141,27 @@ export function validateScreens(
|
|
|
141
141
|
continue;
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
+
if (screen.type === "projectionList") {
|
|
145
|
+
// Query-getrieben, keine Entity → nur query + columns prüfen (die
|
|
146
|
+
// Column-Felder können nicht gegen eine Entity gecheckt werden; sie
|
|
147
|
+
// werden zur Render-Zeit gegen die Projection-Rows aufgelöst).
|
|
148
|
+
if (!screen.query || typeof screen.query !== "string") {
|
|
149
|
+
throw new Error(
|
|
150
|
+
`[Feature ${feature.name}] Screen "${screenId}" (projectionList) has empty or non-string query.`,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
if (screen.columns.length === 0) {
|
|
154
|
+
throw new Error(
|
|
155
|
+
`[Feature ${feature.name}] Screen "${screenId}" (projectionList) has an empty columns list — ` +
|
|
156
|
+
`declare at least one column.`,
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
for (const col of screen.columns) {
|
|
160
|
+
validateColumnRendererForm(feature.name, screenId, normalizeListColumn(col));
|
|
161
|
+
}
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
|
|
144
165
|
if (screen.type === "configEdit") {
|
|
145
166
|
// configEdit: layout/fields wie actionForm validieren, plus
|
|
146
167
|
// Cross-Check dass jeder qualifizierte Config-Key registriert
|
package/src/engine/index.ts
CHANGED
|
@@ -6,7 +6,6 @@ export {
|
|
|
6
6
|
validateAppCustomScreenWriteQns,
|
|
7
7
|
validateBoot,
|
|
8
8
|
} from "./boot-validator";
|
|
9
|
-
export { validateExtensionPreSaveWiring } from "./boot-validator/entity-handler";
|
|
10
9
|
export { type BuildAppSchemaOptions, buildAppSchema } from "./build-app-schema";
|
|
11
10
|
export type { ConfigFeatureSchema } from "./build-config-feature-schema";
|
|
12
11
|
export {
|
package/src/engine/registry.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
import { asEntityTableMeta } from "../db/query";
|
|
8
8
|
import { buildEntityTable } from "../db/table-builder";
|
|
9
9
|
import { buildMetricName, validateMetricName } from "../observability";
|
|
10
|
+
import { validateExtensionPreSaveWiring } from "./boot-validator/entity-handler";
|
|
10
11
|
import { type QnType, qualifyEntityName } from "./qualified-name";
|
|
11
12
|
import {
|
|
12
13
|
buildSoftDeleteCleanupJob,
|
|
@@ -1051,26 +1052,11 @@ export function createRegistry(features: readonly FeatureDefinition[]): Registry
|
|
|
1051
1052
|
}
|
|
1052
1053
|
}
|
|
1053
1054
|
|
|
1054
|
-
// Extension preSave: useExtension on an entity must have at least one mapped
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
}
|
|
1060
|
-
}
|
|
1061
|
-
for (const usage of extensionUsages) {
|
|
1062
|
-
if (!extensionsWithPreSave.has(usage.extensionName)) continue;
|
|
1063
|
-
const hasMapped = [...writeHandlerMap.keys()].some(
|
|
1064
|
-
(qn) => handlerEntityMap.get(qn) === usage.entityName,
|
|
1065
|
-
);
|
|
1066
|
-
if (!hasMapped) {
|
|
1067
|
-
throw new Error(
|
|
1068
|
-
`Feature "${usage.featureName}" uses extension "${usage.extensionName}" with preSave on entity "${usage.entityName}" ` +
|
|
1069
|
-
`but no write handler is entity-mapped to "${usage.entityName}". ` +
|
|
1070
|
-
`Use create/update/delete on a matching entity or name the handler "entity:verb".`,
|
|
1071
|
-
);
|
|
1072
|
-
}
|
|
1073
|
-
}
|
|
1055
|
+
// Extension preSave: useExtension on an entity must have at least one mapped
|
|
1056
|
+
// write handler. Shared with validateBoot's standalone callers (437/2) —
|
|
1057
|
+
// schema-cli's `validate` runs validateBoot on raw FEATURES without ever
|
|
1058
|
+
// building a Registry, so this can't live only here.
|
|
1059
|
+
validateExtensionPreSaveWiring(features);
|
|
1074
1060
|
|
|
1075
1061
|
// Validate: all relation targets must reference existing entities
|
|
1076
1062
|
for (const [entityName, rels] of relationMap) {
|
|
@@ -29,7 +29,11 @@ export const softDeleteGraceDaysConfig: ConfigKeyDefinition = {
|
|
|
29
29
|
default: DEFAULT_GRACE_DAYS,
|
|
30
30
|
scope: "tenant",
|
|
31
31
|
access: { read: ["TenantAdmin", "SystemAdmin"], write: ["SystemAdmin"] },
|
|
32
|
-
|
|
32
|
+
// min: 1, not 0 (565/2) — graceDays: 0 doesn't mean "no cleanup", it means
|
|
33
|
+
// "hard-delete every currently soft-deleted row on the next cron run".
|
|
34
|
+
// Clamping (not rejecting) keeps the resolve-config-or-param clamp path's
|
|
35
|
+
// existing silent-below-bound / audited-crossing behavior.
|
|
36
|
+
bounds: { min: 1 },
|
|
33
37
|
};
|
|
34
38
|
|
|
35
39
|
export const softDeleteCleanupJob: JobHandlerFn = async (_payload, ctx) => {
|
|
@@ -56,7 +60,7 @@ export const softDeleteCleanupJob: JobHandlerFn = async (_payload, ctx) => {
|
|
|
56
60
|
db,
|
|
57
61
|
)
|
|
58
62
|
: undefined;
|
|
59
|
-
const graceDays = typeof resolved === "number" && resolved >=
|
|
63
|
+
const graceDays = typeof resolved === "number" && resolved >= 1 ? resolved : DEFAULT_GRACE_DAYS;
|
|
60
64
|
const cutoff = Temporal.Now.instant().subtract({ hours: graceDays * 24 });
|
|
61
65
|
|
|
62
66
|
for (const proj of registry.getAllProjections().values()) {
|
|
@@ -524,7 +524,9 @@ export function isFileField(field: FieldDefinition | undefined): field is AnyFil
|
|
|
524
524
|
// --- Derived (computed) fields ---
|
|
525
525
|
//
|
|
526
526
|
// A derived field is read-time only: its value is computed from the stored row
|
|
527
|
-
// (and the clock) when
|
|
527
|
+
// (and the clock) when an entityList query runs (676/2 — NOT detail; only the
|
|
528
|
+
// "list" case in entity-handlers.ts calls augmentDerivedFields), never
|
|
529
|
+
// persisted. It lives in
|
|
528
530
|
// `EntityDefinition.derivedFields` — deliberately NOT in `fields`, so it
|
|
529
531
|
// produces no DB column, never enters a write schema, and can't be the target
|
|
530
532
|
// of an entityEdit. A declarative `entityList` can name it like any column and
|
|
@@ -296,6 +296,34 @@ export type EntityListScreenDefinition = {
|
|
|
296
296
|
readonly access?: AccessRule;
|
|
297
297
|
};
|
|
298
298
|
|
|
299
|
+
// --- projectionList ---
|
|
300
|
+
|
|
301
|
+
// Like entityList, but bound to an EXPLICIT query instead of an entity. The
|
|
302
|
+
// list-query is taken verbatim from `query` (a fully qualified QN like
|
|
303
|
+
// "ledger:query:schedule:list") — NOT derived from the screen's own feature —
|
|
304
|
+
// so a screen can render any read-projection, including one owned by a
|
|
305
|
+
// different feature (the entityList feature-local resolution can't). Columns
|
|
306
|
+
// carry their own labels (no entity to derive field-labels from); there is no
|
|
307
|
+
// auto create-navigation (a projection isn't an editable entity list). Row
|
|
308
|
+
// interaction is explicit via `rowActions`. The query must return the same
|
|
309
|
+
// paged envelope as an entity list-query: `{ rows, nextCursor, total? }`.
|
|
310
|
+
export type ProjectionListScreenDefinition = {
|
|
311
|
+
readonly id: string;
|
|
312
|
+
readonly type: "projectionList";
|
|
313
|
+
readonly query: string;
|
|
314
|
+
readonly columns: readonly ListColumnSpec[];
|
|
315
|
+
readonly rowRenderer?: PlatformComponent;
|
|
316
|
+
readonly cardRenderer?: PlatformComponent;
|
|
317
|
+
readonly rowActions?: readonly RowAction[];
|
|
318
|
+
readonly toolbarActions?: readonly ToolbarAction[];
|
|
319
|
+
readonly pagination?: ListPaginationMode;
|
|
320
|
+
readonly pageSize?: number;
|
|
321
|
+
readonly defaultSort?: ListSortSpec;
|
|
322
|
+
readonly searchable?: boolean;
|
|
323
|
+
readonly slots?: ScreenSlots;
|
|
324
|
+
readonly access?: AccessRule;
|
|
325
|
+
};
|
|
326
|
+
|
|
299
327
|
// --- entityEdit ---
|
|
300
328
|
|
|
301
329
|
// camelCase `readOnly` instead of the spec's lowercase `readonly`: TS's
|
|
@@ -526,6 +554,7 @@ export type ScreenSlots = {
|
|
|
526
554
|
|
|
527
555
|
export type ScreenDefinition =
|
|
528
556
|
| EntityListScreenDefinition
|
|
557
|
+
| ProjectionListScreenDefinition
|
|
529
558
|
| EntityEditScreenDefinition
|
|
530
559
|
| ActionFormScreenDefinition
|
|
531
560
|
| ConfigEditScreenDefinition
|
|
@@ -48,8 +48,8 @@ describe("toStoredEvent", () => {
|
|
|
48
48
|
});
|
|
49
49
|
|
|
50
50
|
test("Feld-Vollständigkeit: das Mapping deckt genau die StoredEvent-Keys ab", () => {
|
|
51
|
-
//
|
|
52
|
-
//
|
|
51
|
+
// Guards required fields staying in sync with StoredEvent (event-store.ts);
|
|
52
|
+
// optional fields consistently omitted on both sides must be added manually.
|
|
53
53
|
const expectedKeys: ReadonlyArray<keyof StoredEvent> = [
|
|
54
54
|
"id",
|
|
55
55
|
"aggregateId",
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { describe, expect, mock, test } from "bun:test";
|
|
2
|
+
import type { Registry } from "../../engine/types";
|
|
3
|
+
import { makeFileProviderResolver } from "../provider-resolver";
|
|
4
|
+
import type { FileStorageProvider } from "../types";
|
|
5
|
+
|
|
6
|
+
const fakeProvider = { name: "fake" } as unknown as FileStorageProvider;
|
|
7
|
+
|
|
8
|
+
function fakeRegistry(): Registry {
|
|
9
|
+
return {
|
|
10
|
+
getExtensionUsages: () => [
|
|
11
|
+
{
|
|
12
|
+
entityName: "fake",
|
|
13
|
+
options: { build: async () => fakeProvider },
|
|
14
|
+
},
|
|
15
|
+
],
|
|
16
|
+
} as unknown as Registry;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// 698/2: resolveProvider must not re-read config + secrets on every call —
|
|
20
|
+
// the provider is effectively static per tenant for the process lifetime.
|
|
21
|
+
describe("makeFileProviderResolver — per-tenant cache", () => {
|
|
22
|
+
test("a second resolve for the same tenant reuses the cached build, doesn't call configAccessorFactory again", async () => {
|
|
23
|
+
const configAccessorFactory = mock(() => async (_key: unknown) => "fake");
|
|
24
|
+
const resolver = makeFileProviderResolver({
|
|
25
|
+
registry: fakeRegistry(),
|
|
26
|
+
_configAccessorFactory: configAccessorFactory,
|
|
27
|
+
db: {} as never,
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const first = await resolver("tenant-a" as never);
|
|
31
|
+
const second = await resolver("tenant-a" as never);
|
|
32
|
+
|
|
33
|
+
expect(first).toBe(fakeProvider);
|
|
34
|
+
expect(second).toBe(fakeProvider);
|
|
35
|
+
expect(configAccessorFactory).toHaveBeenCalledTimes(1);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("different tenants get independent cache entries", async () => {
|
|
39
|
+
const configAccessorFactory = mock(() => async (_key: unknown) => "fake");
|
|
40
|
+
const resolver = makeFileProviderResolver({
|
|
41
|
+
registry: fakeRegistry(),
|
|
42
|
+
_configAccessorFactory: configAccessorFactory,
|
|
43
|
+
db: {} as never,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
await resolver("tenant-a" as never);
|
|
47
|
+
await resolver("tenant-b" as never);
|
|
48
|
+
|
|
49
|
+
expect(configAccessorFactory).toHaveBeenCalledTimes(2);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("a rejected build is evicted — the next call retries instead of staying poisoned", async () => {
|
|
53
|
+
let calls = 0;
|
|
54
|
+
const resolver = makeFileProviderResolver({
|
|
55
|
+
registry: {
|
|
56
|
+
getExtensionUsages: () => {
|
|
57
|
+
calls++;
|
|
58
|
+
if (calls === 1) throw new Error("transient failure");
|
|
59
|
+
return [{ entityName: "fake", options: { build: async () => fakeProvider } }];
|
|
60
|
+
},
|
|
61
|
+
} as unknown as Registry,
|
|
62
|
+
_configAccessorFactory: () => async (_key: unknown) => "fake",
|
|
63
|
+
db: {} as never,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
await expect(resolver("tenant-a" as never)).rejects.toThrow("transient failure");
|
|
67
|
+
const second = await resolver("tenant-a" as never);
|
|
68
|
+
expect(second).toBe(fakeProvider);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
@@ -138,21 +138,34 @@ export type FileProviderResolverDeps = {
|
|
|
138
138
|
// SYSTEM identity: provider construction is an infra read, distinct from the
|
|
139
139
|
// request user's authorization which stays at the route accessGuard.
|
|
140
140
|
export function makeFileProviderResolver(deps: FileProviderResolverDeps): FileProviderResolver {
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
141
|
+
// Per-tenant cache (698/2) — without it, every resolveProvider(tenantId)
|
|
142
|
+
// call re-reads config + secrets from the DB, even though the provider is
|
|
143
|
+
// effectively static per tenant for the process lifetime. A rejected
|
|
144
|
+
// promise is evicted so a transient failure (or a secret genuinely not
|
|
145
|
+
// configured yet) doesn't permanently poison this tenant's entry.
|
|
146
|
+
const cache = new Map<string, Promise<FileStorageProvider>>();
|
|
147
|
+
return (tenantId) => {
|
|
148
|
+
const cached = cache.get(tenantId);
|
|
149
|
+
if (cached) return cached;
|
|
150
|
+
const built = (async () => {
|
|
151
|
+
if (!deps.registry || !deps._configAccessorFactory || !deps.db) {
|
|
152
|
+
throw new Error(
|
|
153
|
+
"makeFileProviderResolver: registry/_configAccessorFactory/db missing — " +
|
|
154
|
+
"mount the config + file-foundation features and wire context.db",
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
const config = deps._configAccessorFactory({
|
|
158
|
+
user: { id: SYSTEM_USER_ID, tenantId },
|
|
159
|
+
db: deps.db,
|
|
160
|
+
secrets: deps.secrets,
|
|
161
|
+
});
|
|
162
|
+
return createFileProviderForTenant(
|
|
163
|
+
{ config, registry: deps.registry, secrets: deps.secrets, _userId: SYSTEM_USER_ID },
|
|
164
|
+
tenantId,
|
|
146
165
|
);
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
secrets: deps.secrets,
|
|
152
|
-
});
|
|
153
|
-
return createFileProviderForTenant(
|
|
154
|
-
{ config, registry: deps.registry, secrets: deps.secrets, _userId: SYSTEM_USER_ID },
|
|
155
|
-
tenantId,
|
|
156
|
-
);
|
|
166
|
+
})();
|
|
167
|
+
built.catch(() => cache.delete(tenantId));
|
|
168
|
+
cache.set(tenantId, built);
|
|
169
|
+
return built;
|
|
157
170
|
};
|
|
158
171
|
}
|
|
@@ -5,6 +5,7 @@ import type {
|
|
|
5
5
|
EntityListScreenDefinition,
|
|
6
6
|
FeatureDefinition,
|
|
7
7
|
NavDefinition,
|
|
8
|
+
ProjectionListScreenDefinition,
|
|
8
9
|
RowAction,
|
|
9
10
|
ScreenDefinition,
|
|
10
11
|
ToolbarAction,
|
|
@@ -76,6 +77,17 @@ export function requiredKeysFromScreen(
|
|
|
76
77
|
for (const action of list.toolbarActions ?? []) pushToolbarActionKeys(out, action);
|
|
77
78
|
break;
|
|
78
79
|
}
|
|
80
|
+
case "projectionList": {
|
|
81
|
+
const list = screen as ProjectionListScreenDefinition;
|
|
82
|
+
// Keine Entity → keine field-label-Fallbacks; nur explizite Column-Labels.
|
|
83
|
+
for (const col of list.columns) {
|
|
84
|
+
const normalized = normalizeListColumn(col);
|
|
85
|
+
if (normalized.label !== undefined) pushKey(out, normalized.label);
|
|
86
|
+
}
|
|
87
|
+
for (const action of list.rowActions ?? []) pushRowActionKeys(out, action);
|
|
88
|
+
for (const action of list.toolbarActions ?? []) pushToolbarActionKeys(out, action);
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
79
91
|
case "entityEdit": {
|
|
80
92
|
const edit = screen as EntityEditScreenDefinition;
|
|
81
93
|
pushKey(out, edit.submitLabel);
|
|
@@ -306,6 +306,18 @@ export async function rebuildProjection(
|
|
|
306
306
|
while ((await drainBatch(tx)) === REBUILD_BATCH_SIZE) {
|
|
307
307
|
// re-replay the full log into the fresh shadow
|
|
308
308
|
}
|
|
309
|
+
// 656/2: drainBatch increments eventsProcessed for every drained row
|
|
310
|
+
// regardless of quarantine (skipApplyErrors), so this must hold
|
|
311
|
+
// exactly under the fence — a mismatch means drainBatch silently
|
|
312
|
+
// dropped a row instead of counting it, which the rest of this
|
|
313
|
+
// function has no other way to detect.
|
|
314
|
+
if (eventsProcessed < Number(expectedEvents)) {
|
|
315
|
+
throw new Error(
|
|
316
|
+
`projection-rebuild "${projectionName}": fenced re-replay processed ` +
|
|
317
|
+
`${eventsProcessed} events but ${expectedEvents} were expected — drainBatch ` +
|
|
318
|
+
"silently dropped rows instead of counting them.",
|
|
319
|
+
);
|
|
320
|
+
}
|
|
309
321
|
}
|
|
310
322
|
}
|
|
311
323
|
|
|
@@ -112,6 +112,9 @@ export function generateE2ESpec(
|
|
|
112
112
|
// pro Field geschrieben, ohne CRUD-Zustand zu generieren wäre der
|
|
113
113
|
// Spec wertlos. Branding/SMTP/etc. sind Author-spezifisch.
|
|
114
114
|
if (screen.type === "configEdit") continue;
|
|
115
|
+
// projectionList: query-getrieben, Author-spezifische Projection —
|
|
116
|
+
// kein generischer CRUD-Spec ableitbar (wie actionForm/configEdit).
|
|
117
|
+
if (screen.type === "projectionList") continue;
|
|
115
118
|
const { scope: feature, name: short } = parseQn(screenQn);
|
|
116
119
|
const urlPath = `/t/${tenant}/${feature}/${short}`;
|
|
117
120
|
const title = `${feature}/${short}`;
|
|
@@ -1,9 +1,7 @@
|
|
|
1
|
-
// Pacific/Apia
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// unabhängig von der DST-Sicht der tzdata grün bleibt (UTC+13 wie +14 schieben
|
|
6
|
-
// 10:00 auf den Vortag).
|
|
1
|
+
// Pacific/Apia sits far east of the dateline at UTC+13/+14 — a morning wall
|
|
2
|
+
// clock there falls on the PREVIOUS UTC calendar day. Phrased as a date-
|
|
3
|
+
// ordering property (not a fixed offset) so the test stays green regardless
|
|
4
|
+
// of the tzdata DST view (UTC+13 and +14 both push 10:00 to the prior day).
|
|
7
5
|
|
|
8
6
|
import { beforeAll, describe, expect, test } from "bun:test";
|
|
9
7
|
import { ensureTemporalPolyfill } from "../polyfill";
|
|
@@ -13,8 +11,8 @@ beforeAll(async () => {
|
|
|
13
11
|
await ensureTemporalPolyfill();
|
|
14
12
|
});
|
|
15
13
|
|
|
16
|
-
describe("ctx.tz — Pacific/Apia
|
|
17
|
-
test("
|
|
14
|
+
describe("ctx.tz — Pacific/Apia dateline", () => {
|
|
15
|
+
test("a morning wall clock in Apia maps to the previous UTC calendar day", () => {
|
|
18
16
|
const tz = createTzContext();
|
|
19
17
|
const zdt = tz.parse("2026-01-15T10:00:00", "Pacific/Apia");
|
|
20
18
|
expect(zdt.timeZoneId).toBe("Pacific/Apia");
|
|
@@ -22,11 +20,11 @@ describe("ctx.tz — Pacific/Apia Datumsgrenze", () => {
|
|
|
22
20
|
const utcDate = zdt.toInstant().toZonedDateTimeISO("UTC").toPlainDate().toString();
|
|
23
21
|
expect(utcDate).toBe("2026-01-14");
|
|
24
22
|
|
|
25
|
-
// Offset
|
|
23
|
+
// Offset is a large positive value (UTC+13 or +14).
|
|
26
24
|
expect(zdt.offsetNanoseconds).toBeGreaterThan(12 * 3600 * 1e9);
|
|
27
25
|
});
|
|
28
26
|
|
|
29
|
-
test("
|
|
27
|
+
test("round-trip preserves wall clock + instant across the dateline", () => {
|
|
30
28
|
const tz = createTzContext();
|
|
31
29
|
const original = tz.parse("2026-01-15T10:00:00", "Pacific/Apia");
|
|
32
30
|
const restored = tz.fromLocatedJson(tz.toLocatedJson(original));
|