@cosmicdrift/kumiko-framework 0.173.0 → 0.173.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 +3 -3
- package/src/__tests__/schema-cli.integration.test.ts +18 -0
- package/src/api/sse-broker.ts +2 -3
- package/src/db/__tests__/migrate-runner.test.ts +4 -0
- package/src/db/event-store-executor-write.ts +54 -12
- package/src/engine/__tests__/boot-validator.test.ts +87 -1
- package/src/engine/__tests__/entity-presave-wiring.integration.test.ts +53 -1
- package/src/engine/__tests__/schema-builder.test.ts +17 -0
- package/src/engine/__tests__/store-table.test.ts +15 -0
- package/src/engine/boot-validator/screens.ts +71 -27
- package/src/entrypoint/__tests__/split-deploy.integration.test.ts +28 -0
- package/src/jobs/__tests__/scheduler-id.test.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.173.
|
|
3
|
+
"version": "0.173.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>",
|
|
@@ -182,7 +182,7 @@
|
|
|
182
182
|
"./package.json": "./package.json"
|
|
183
183
|
},
|
|
184
184
|
"dependencies": {
|
|
185
|
-
"@cosmicdrift/kumiko-types": "0.173.
|
|
185
|
+
"@cosmicdrift/kumiko-types": "0.173.1",
|
|
186
186
|
"bullmq": "^5.76.7",
|
|
187
187
|
"bun-types": "^1.3.13",
|
|
188
188
|
"hono": "^4.12.27",
|
|
@@ -198,7 +198,7 @@
|
|
|
198
198
|
"zod": "^4.4.3"
|
|
199
199
|
},
|
|
200
200
|
"devDependencies": {
|
|
201
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.173.
|
|
201
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.173.1",
|
|
202
202
|
"bun-types": "^1.3.13",
|
|
203
203
|
"pino-pretty": "^13.1.3"
|
|
204
204
|
},
|
|
@@ -200,6 +200,24 @@ CREATE TABLE IF NOT EXISTS "read_widgets_v2" ("id" uuid PRIMARY KEY);
|
|
|
200
200
|
expect(cap.err.join("\n")).toContain("read_widgets");
|
|
201
201
|
});
|
|
202
202
|
|
|
203
|
+
test("migration file missing a column its snapshot entry claims → column-drift hint, not unexpected-table", async () => {
|
|
204
|
+
writeSchemaFile(appCwd, "read_widgets", "note");
|
|
205
|
+
await runSchemaCli(["generate", "init"], appCwd, captureOut().out);
|
|
206
|
+
writeFileSync(
|
|
207
|
+
join(appCwd, "kumiko/migrations/0001_init.sql"),
|
|
208
|
+
`-- oops: hand-edited to drop the "note" column the snapshot still claims
|
|
209
|
+
CREATE TABLE IF NOT EXISTS "read_widgets" ("id" uuid PRIMARY KEY);
|
|
210
|
+
`,
|
|
211
|
+
);
|
|
212
|
+
const cap = captureOut();
|
|
213
|
+
const code = await runSchemaCli(["validate"], appCwd, cap.out);
|
|
214
|
+
expect(code).toBe(1);
|
|
215
|
+
const err = cap.err.join("\n");
|
|
216
|
+
expect(err).toContain("missing columns: note");
|
|
217
|
+
expect(err).toContain("Fix (missing-table/column-drift)");
|
|
218
|
+
expect(err).not.toContain("Fix (unexpected-table)");
|
|
219
|
+
});
|
|
220
|
+
|
|
203
221
|
test("migration creates a table with no snapshot entry → unexpected-table hint points at r.storeTable, not hand-fix", async () => {
|
|
204
222
|
writeSchemaFile(appCwd, "read_widgets");
|
|
205
223
|
await runSchemaCli(["generate", "init"], appCwd, captureOut().out);
|
package/src/api/sse-broker.ts
CHANGED
|
@@ -25,9 +25,8 @@ export type SseBroker = {
|
|
|
25
25
|
};
|
|
26
26
|
|
|
27
27
|
export function createSseBroker(): SseBroker {
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
// (security control is single-node). Upgrade: Redis pub/sub on userAccessChannel.
|
|
28
|
+
// Cross-replica fanout lives one level up: the SSE + access-invalidation
|
|
29
|
+
// consumers (system-hooks.ts) run delivery: "per-instance" (#1718).
|
|
31
30
|
const channels = new Map<string, Map<string, SseClient>>();
|
|
32
31
|
const accessInvalidationListeners = new Map<string, Set<() => void>>();
|
|
33
32
|
|
|
@@ -38,6 +38,10 @@ describe("splitSqlStatements", () => {
|
|
|
38
38
|
expect(splitSqlStatements("SELECT a/*x*/AS b;")).toEqual(["SELECT a AS b;"]);
|
|
39
39
|
});
|
|
40
40
|
|
|
41
|
+
test("nested block comment also leaves a space at outer depth (#1599)", () => {
|
|
42
|
+
expect(splitSqlStatements("SELECT a/*x/*y*/z*/AS b;")).toEqual(["SELECT a AS b;"]);
|
|
43
|
+
});
|
|
44
|
+
|
|
41
45
|
test("nested block comments close only at matching depth (Postgres)", () => {
|
|
42
46
|
expect(splitSqlStatements("/* a /* b */ c */ SELECT 1;")).toEqual(["SELECT 1;"]);
|
|
43
47
|
});
|
|
@@ -48,6 +48,40 @@ function isUnchangedValue(a: unknown, b: unknown): boolean {
|
|
|
48
48
|
return JSON.stringify(a) === JSON.stringify(b);
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
type PreSaveFn = (
|
|
52
|
+
changes: Record<string, unknown>,
|
|
53
|
+
previous: Record<string, unknown>,
|
|
54
|
+
isNew: boolean,
|
|
55
|
+
) => Promise<Record<string, unknown>>;
|
|
56
|
+
|
|
57
|
+
// preSave runs before ownership checks: authorization must evaluate the row
|
|
58
|
+
// as it will actually be persisted, including hook-derived fields
|
|
59
|
+
// (kumiko-framework#1672). A throwing hook is an app-author bug (bad
|
|
60
|
+
// business rule, not a framework fault) — map it to a clean writeFailure
|
|
61
|
+
// instead of letting it propagate as an internal_error 500.
|
|
62
|
+
async function runPreSave(
|
|
63
|
+
preSave: PreSaveFn | undefined,
|
|
64
|
+
changes: Record<string, unknown>,
|
|
65
|
+
previous: Record<string, unknown>,
|
|
66
|
+
isNew: boolean,
|
|
67
|
+
entityName: string,
|
|
68
|
+
action: "create" | "update",
|
|
69
|
+
): Promise<{ readonly data: DbRow } | { readonly failure: ReturnType<typeof writeFailure> }> {
|
|
70
|
+
if (!preSave) return { data: changes as DbRow };
|
|
71
|
+
try {
|
|
72
|
+
return { data: (await preSave(changes, previous, isNew)) as DbRow };
|
|
73
|
+
} catch (e) {
|
|
74
|
+
return {
|
|
75
|
+
failure: writeFailure(
|
|
76
|
+
new UnprocessableError("presave_hook_failed", {
|
|
77
|
+
i18nKey: "errors.presaveHookFailed",
|
|
78
|
+
details: { entityName, action, message: e instanceof Error ? e.message : String(e) },
|
|
79
|
+
}),
|
|
80
|
+
),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
51
85
|
export function createWriteVerbs(
|
|
52
86
|
ctx: ExecutorContext,
|
|
53
87
|
): Pick<EventStoreExecutor, "create" | "update" | "delete" | "forget" | "restore"> {
|
|
@@ -74,12 +108,16 @@ export function createWriteVerbs(
|
|
|
74
108
|
const explicitId = typeof payload["id"] === "string" ? (payload["id"] as string) : undefined; // @cast-boundary engine-payload
|
|
75
109
|
const aggregateId = explicitId ?? generateId();
|
|
76
110
|
const { id: _id, ...payloadWithoutId } = payload;
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
111
|
+
const preSaveResult = await runPreSave(
|
|
112
|
+
options?.preSave,
|
|
113
|
+
applyDefaults(payloadWithoutId),
|
|
114
|
+
{},
|
|
115
|
+
true,
|
|
116
|
+
entityName,
|
|
117
|
+
"create",
|
|
118
|
+
);
|
|
119
|
+
if ("failure" in preSaveResult) return preSaveResult.failure;
|
|
120
|
+
const data = preSaveResult.data;
|
|
83
121
|
|
|
84
122
|
// H.2 — entity-level write-ownership on create. No oldRow exists, so
|
|
85
123
|
// only the new row is checked. No Straddle concern for creates.
|
|
@@ -226,12 +264,16 @@ export function createWriteVerbs(
|
|
|
226
264
|
const previous = await loadById(payload.id, db);
|
|
227
265
|
if (!previous) return writeFailure(new NotFoundError(entityName, payload.id));
|
|
228
266
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
267
|
+
const preSaveResult = await runPreSave(
|
|
268
|
+
updateOptions?.preSave,
|
|
269
|
+
payload.changes,
|
|
270
|
+
previous,
|
|
271
|
+
false,
|
|
272
|
+
entityName,
|
|
273
|
+
"update",
|
|
274
|
+
);
|
|
275
|
+
if ("failure" in preSaveResult) return preSaveResult.failure;
|
|
276
|
+
const changes = preSaveResult.data;
|
|
235
277
|
|
|
236
278
|
// H.2 — entity-level write-ownership on update. Load old row (already
|
|
237
279
|
// done above), build post-change row via shallow merge. Straddle-safe
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
1
|
+
import { describe, expect, spyOn, test } from "bun:test";
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import type { SchemaTable } from "../../db/dialect";
|
|
4
4
|
import { table, text } from "../../db/dialect";
|
|
@@ -590,6 +590,31 @@ describe("boot-validator", () => {
|
|
|
590
590
|
expect(() => validateBoot(features)).not.toThrow();
|
|
591
591
|
});
|
|
592
592
|
|
|
593
|
+
test("warns when a role is used by exactly one handler, reached through the real validateBoot wiring", () => {
|
|
594
|
+
const warnSpy = spyOn(console, "warn");
|
|
595
|
+
try {
|
|
596
|
+
const features = [
|
|
597
|
+
defineFeature("a", (r) => {
|
|
598
|
+
r.queryHandler("list", z.object({}), async () => [], {
|
|
599
|
+
access: { roles: ["OnlyHereRole"] },
|
|
600
|
+
});
|
|
601
|
+
}),
|
|
602
|
+
];
|
|
603
|
+
validateBoot(features);
|
|
604
|
+
// Not toHaveBeenCalledTimes(1): this file's tests share the process-global
|
|
605
|
+
// console.warn and run with the default concurrency (bunfig.toml) — other
|
|
606
|
+
// concurrently-running tests' own "role used by one handler" warnings can
|
|
607
|
+
// land on this spy too. Assert the wiring fired at least once instead.
|
|
608
|
+
expect(
|
|
609
|
+
warnSpy.mock.calls.some((call) =>
|
|
610
|
+
(call[0] as string | undefined)?.includes("OnlyHereRole"),
|
|
611
|
+
),
|
|
612
|
+
).toBe(true);
|
|
613
|
+
} finally {
|
|
614
|
+
warnSpy.mockRestore();
|
|
615
|
+
}
|
|
616
|
+
});
|
|
617
|
+
|
|
593
618
|
test("throws when a stream handler has no access rule", () => {
|
|
594
619
|
const features = [
|
|
595
620
|
defineFeature("a", (r) => {
|
|
@@ -2953,6 +2978,67 @@ describe("boot-validator — config key backing × scope", () => {
|
|
|
2953
2978
|
);
|
|
2954
2979
|
});
|
|
2955
2980
|
|
|
2981
|
+
// framework#1708: the same params-vs-update-mode check as entityList,
|
|
2982
|
+
// extended to projectionList rowActions (#1680 covered entityList only).
|
|
2983
|
+
test("projectionList rowAction: navigate with params to an entityEdit target + explicit entityId → Throw", () => {
|
|
2984
|
+
const feature = defineFeature("shop", (r) => {
|
|
2985
|
+
r.entity("product", createEntity({ fields: { name: createTextField() } }));
|
|
2986
|
+
r.screen({
|
|
2987
|
+
id: "product-projection",
|
|
2988
|
+
type: "projectionList",
|
|
2989
|
+
query: "shop:query:products",
|
|
2990
|
+
columns: ["name"],
|
|
2991
|
+
rowActions: [
|
|
2992
|
+
{
|
|
2993
|
+
kind: "navigate",
|
|
2994
|
+
id: "open",
|
|
2995
|
+
label: "actions.open",
|
|
2996
|
+
screen: "product-edit",
|
|
2997
|
+
entityId: "name",
|
|
2998
|
+
params: { pick: ["name"] },
|
|
2999
|
+
},
|
|
3000
|
+
],
|
|
3001
|
+
});
|
|
3002
|
+
r.screen({
|
|
3003
|
+
id: "product-edit",
|
|
3004
|
+
type: "entityEdit",
|
|
3005
|
+
entity: "product",
|
|
3006
|
+
layout: { sections: [{ columns: 1, fields: ["name"] }] },
|
|
3007
|
+
});
|
|
3008
|
+
});
|
|
3009
|
+
expect(() => validateBoot([feature])).toThrow(
|
|
3010
|
+
/\(projectionList\) rowAction "open".*resolves to UPDATE mode \(explicit entityId "name"\)/,
|
|
3011
|
+
);
|
|
3012
|
+
});
|
|
3013
|
+
|
|
3014
|
+
test("projectionList rowAction: navigate with params to an entityEdit-create target (no entityId) → no throw", () => {
|
|
3015
|
+
const feature = defineFeature("shop", (r) => {
|
|
3016
|
+
r.entity("product", createEntity({ fields: { name: createTextField() } }));
|
|
3017
|
+
r.screen({
|
|
3018
|
+
id: "product-projection",
|
|
3019
|
+
type: "projectionList",
|
|
3020
|
+
query: "shop:query:products",
|
|
3021
|
+
columns: ["name"],
|
|
3022
|
+
rowActions: [
|
|
3023
|
+
{
|
|
3024
|
+
kind: "navigate",
|
|
3025
|
+
id: "open",
|
|
3026
|
+
label: "actions.open",
|
|
3027
|
+
screen: "product-edit",
|
|
3028
|
+
params: { pick: ["name"] },
|
|
3029
|
+
},
|
|
3030
|
+
],
|
|
3031
|
+
});
|
|
3032
|
+
r.screen({
|
|
3033
|
+
id: "product-edit",
|
|
3034
|
+
type: "entityEdit",
|
|
3035
|
+
entity: "product",
|
|
3036
|
+
layout: { sections: [{ columns: 1, fields: ["name"] }] },
|
|
3037
|
+
});
|
|
3038
|
+
});
|
|
3039
|
+
expect(() => validateBoot([feature])).not.toThrow();
|
|
3040
|
+
});
|
|
3041
|
+
|
|
2956
3042
|
test("navigate with params targeting a cross-entity entityEdit screen (no explicit entityId) → no throw", () => {
|
|
2957
3043
|
// The issue's actual use case: a unit row navigates to "create contract"
|
|
2958
3044
|
// pre-filled with unitId. Different entity + no explicit entityId → the
|
|
@@ -8,6 +8,7 @@ import { asRawClient } from "../../db/query";
|
|
|
8
8
|
import { setupTestStack, type TestStack, TestUsers, unsafeCreateEntityTable } from "../../stack";
|
|
9
9
|
import { defineFeature } from "../define-feature";
|
|
10
10
|
import { createEntity, createTextField } from "../factories";
|
|
11
|
+
import { from } from "../ownership";
|
|
11
12
|
|
|
12
13
|
const contactEntity = createEntity({
|
|
13
14
|
table: "presave_wiring_contacts",
|
|
@@ -15,6 +16,12 @@ const contactEntity = createEntity({
|
|
|
15
16
|
firstName: createTextField({ required: true }),
|
|
16
17
|
lastName: createTextField({ required: true }),
|
|
17
18
|
displayName: createTextField(),
|
|
19
|
+
// authorId is never set by the client — only deriveAuthorId (a preSave
|
|
20
|
+
// hook) writes it. secretNote's ownership rule checks authorId, so
|
|
21
|
+
// create only succeeds if the hook ran BEFORE the field-ownership check
|
|
22
|
+
// (kumiko-framework#1672 — see also event-store-executor-write.ts).
|
|
23
|
+
authorId: createTextField(),
|
|
24
|
+
secretNote: createTextField({ access: { write: { User: from("user:id", "authorId") } } }),
|
|
18
25
|
},
|
|
19
26
|
});
|
|
20
27
|
|
|
@@ -30,6 +37,16 @@ const deriveDisplayName: import("../types").PreSaveHookFn = async (changes, ctx)
|
|
|
30
37
|
return { ...changes, displayName: `${first ?? ""} ${last ?? ""}`.trim() };
|
|
31
38
|
};
|
|
32
39
|
|
|
40
|
+
const deriveAuthorId: import("../types").PreSaveHookFn = async (changes) => ({
|
|
41
|
+
...changes,
|
|
42
|
+
authorId: TestUsers.user.id,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const THROWING_HOOK_MESSAGE = "business rule violated";
|
|
46
|
+
const throwOnPreSave: import("../types").PreSaveHookFn = async () => {
|
|
47
|
+
throw new Error(THROWING_HOOK_MESSAGE);
|
|
48
|
+
};
|
|
49
|
+
|
|
33
50
|
const contactFeature = defineFeature("presave-wiring", (r) => {
|
|
34
51
|
r.crud("contact", contactEntity, {
|
|
35
52
|
write: { access: { roles: ["User"] } },
|
|
@@ -41,17 +58,34 @@ const contactFeature = defineFeature("presave-wiring", (r) => {
|
|
|
41
58
|
// handlers, so both need their own target.
|
|
42
59
|
r.hook("preSave", "contact:create", deriveDisplayName);
|
|
43
60
|
r.hook("preSave", "contact:update", deriveDisplayName);
|
|
61
|
+
r.hook("preSave", "contact:create", deriveAuthorId);
|
|
62
|
+
r.hook("preSave", "contact:update", deriveAuthorId);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const throwingEntity = createEntity({
|
|
66
|
+
table: "presave_wiring_throwing",
|
|
67
|
+
fields: { name: createTextField({ required: true }) },
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const throwingFeature = defineFeature("presave-wiring-throw", (r) => {
|
|
71
|
+
r.crud("thing", throwingEntity, {
|
|
72
|
+
write: { access: { roles: ["User"] } },
|
|
73
|
+
read: { access: { openToAll: true } },
|
|
74
|
+
});
|
|
75
|
+
r.hook("preSave", "thing:create", throwOnPreSave);
|
|
44
76
|
});
|
|
45
77
|
|
|
46
78
|
const CREATE = "presave-wiring:write:contact:create";
|
|
47
79
|
const UPDATE = "presave-wiring:write:contact:update";
|
|
80
|
+
const THROWING_CREATE = "presave-wiring-throw:write:thing:create";
|
|
48
81
|
|
|
49
82
|
describe("preSave hooks — real dispatcher path (#1672)", () => {
|
|
50
83
|
let stack: TestStack;
|
|
51
84
|
|
|
52
85
|
beforeAll(async () => {
|
|
53
|
-
stack = await setupTestStack({ features: [contactFeature] });
|
|
86
|
+
stack = await setupTestStack({ features: [contactFeature, throwingFeature] });
|
|
54
87
|
await unsafeCreateEntityTable(stack.db, contactEntity);
|
|
88
|
+
await unsafeCreateEntityTable(stack.db, throwingEntity);
|
|
55
89
|
});
|
|
56
90
|
|
|
57
91
|
afterAll(async () => {
|
|
@@ -94,4 +128,22 @@ describe("preSave hooks — real dispatcher path (#1672)", () => {
|
|
|
94
128
|
expect(updated.data.displayName).toBe("Marc Kumiko");
|
|
95
129
|
expect(seenIsNew).toEqual([true, false]);
|
|
96
130
|
});
|
|
131
|
+
|
|
132
|
+
test("preSave runs before ownership checks: field authz sees the hook-derived owner id", async () => {
|
|
133
|
+
const res = await stack.http.write(
|
|
134
|
+
CREATE,
|
|
135
|
+
{ firstName: "Marc", lastName: "Ristone", secretNote: "psst" },
|
|
136
|
+
TestUsers.user,
|
|
137
|
+
);
|
|
138
|
+
expect(res.status).toBe(200);
|
|
139
|
+
const { data } = (await res.json()) as { data: { data: { secretNote: string } } };
|
|
140
|
+
expect(data.data.secretNote).toBe("psst");
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("a throwing preSave hook maps to a clean writeFailure, not a 500", async () => {
|
|
144
|
+
const res = await stack.http.write(THROWING_CREATE, { name: "x" }, TestUsers.user);
|
|
145
|
+
expect(res.status).toBe(422);
|
|
146
|
+
const body = (await res.json()) as { error?: { details?: { message?: string } } };
|
|
147
|
+
expect(body.error?.details?.message).toBe(THROWING_HOOK_MESSAGE);
|
|
148
|
+
});
|
|
97
149
|
});
|
|
@@ -449,6 +449,23 @@ describe("buildInsertSchema", () => {
|
|
|
449
449
|
expect(schema.safeParse({ locale: "en" }).success).toBe(true);
|
|
450
450
|
expect(schema.safeParse({ locale: "xx" }).success).toBe(false);
|
|
451
451
|
});
|
|
452
|
+
|
|
453
|
+
// #1712: pin omitted-key + explicit undefined — ZodPipe/optin path for defaults
|
|
454
|
+
test("optional select with default applies when key omitted or undefined", () => {
|
|
455
|
+
const entity = createEntity({
|
|
456
|
+
table: "Test",
|
|
457
|
+
fields: {
|
|
458
|
+
locale: createSelectField({ options: ["de", "en", "fr"] as const, default: "de" }),
|
|
459
|
+
},
|
|
460
|
+
});
|
|
461
|
+
const schema = buildInsertSchema(entity);
|
|
462
|
+
const omitted = schema.safeParse({});
|
|
463
|
+
expect(omitted.success).toBe(true);
|
|
464
|
+
if (omitted.success) expect(omitted.data["locale"]).toBe("de");
|
|
465
|
+
const undef = schema.safeParse({ locale: undefined });
|
|
466
|
+
expect(undef.success).toBe(true);
|
|
467
|
+
if (undef.success) expect(undef.data["locale"]).toBe("de");
|
|
468
|
+
});
|
|
452
469
|
});
|
|
453
470
|
|
|
454
471
|
// --- Update schema (all partial) ---
|
|
@@ -84,6 +84,21 @@ describe("r.storeTable — declaration", () => {
|
|
|
84
84
|
).toThrow(/the "read_" prefix is reserved/);
|
|
85
85
|
});
|
|
86
86
|
|
|
87
|
+
// #1598: registration guard — plain literal meta bypasses defineUnmanagedTable
|
|
88
|
+
test("r.storeTable rejects a plain literal meta with reserved read_ prefix", () => {
|
|
89
|
+
const literalMeta = {
|
|
90
|
+
tableName: "read_rt_probe",
|
|
91
|
+
columns: [{ name: "id", pgType: "text" as const, notNull: true, primaryKey: true }],
|
|
92
|
+
source: "unmanaged" as const,
|
|
93
|
+
indexes: [],
|
|
94
|
+
};
|
|
95
|
+
expect(() =>
|
|
96
|
+
defineFeature("probe", (r) => {
|
|
97
|
+
r.storeTable(literalMeta, { reason: "test" });
|
|
98
|
+
}),
|
|
99
|
+
).toThrow(/the "read_" prefix is reserved/);
|
|
100
|
+
});
|
|
101
|
+
|
|
87
102
|
test("accepts valid registration and stores meta + reason", () => {
|
|
88
103
|
const feature = defineFeature("probe", (r) => {
|
|
89
104
|
r.storeTable(probeMeta, {
|
|
@@ -22,6 +22,47 @@ import type {
|
|
|
22
22
|
ToolbarAction,
|
|
23
23
|
} from "../types/screen";
|
|
24
24
|
|
|
25
|
+
// Tier 2.7e navigate rowAction → target-screen params validity. Shared by
|
|
26
|
+
// entityList and projectionList (framework#1708) — projectionList has no
|
|
27
|
+
// `screen.entity`, so there's no same-entity row["id"] auto-fill case: any
|
|
28
|
+
// entityEdit target without an explicit entityId reaches create there.
|
|
29
|
+
function validateRowActionNavigateParams(
|
|
30
|
+
featureName: string,
|
|
31
|
+
screenId: string,
|
|
32
|
+
screenType: "entityList" | "projectionList",
|
|
33
|
+
screenEntity: string | undefined,
|
|
34
|
+
action: RowAction,
|
|
35
|
+
target: { readonly featureName: string; readonly screen: ScreenDefinition } | undefined,
|
|
36
|
+
): void {
|
|
37
|
+
// skip: not a navigate-with-params action — nothing to validate here.
|
|
38
|
+
if (action.kind !== "navigate" || action.params === undefined) return;
|
|
39
|
+
// skip: unresolvable/custom target already reported (or exempt) elsewhere.
|
|
40
|
+
if (target === undefined || target.screen.type === "custom") return;
|
|
41
|
+
|
|
42
|
+
const isEntityEditUpdate =
|
|
43
|
+
target.screen.type === "entityEdit" &&
|
|
44
|
+
(action.entityId !== undefined ||
|
|
45
|
+
(screenEntity !== undefined && target.screen.entity === screenEntity));
|
|
46
|
+
if (
|
|
47
|
+
(target.screen.type !== "actionForm" && target.screen.type !== "entityEdit") ||
|
|
48
|
+
isEntityEditUpdate
|
|
49
|
+
) {
|
|
50
|
+
const reason = isEntityEditUpdate
|
|
51
|
+
? `resolves to UPDATE mode (${
|
|
52
|
+
action.entityId !== undefined
|
|
53
|
+
? `explicit entityId "${action.entityId}"`
|
|
54
|
+
: `same entity "${screenEntity}" auto-fills row["id"]`
|
|
55
|
+
})`
|
|
56
|
+
: `screen type "${target.screen.type}"`;
|
|
57
|
+
throw new Error(
|
|
58
|
+
`[Feature ${featureName}] Screen "${screenId}" (${screenType}) rowAction "${action.id}" ` +
|
|
59
|
+
`sets params on navigate-target "${action.screen}" which ${reason} — only actionForm ` +
|
|
60
|
+
`and entityEdit-create targets read URL search params as initial values. Remove the ` +
|
|
61
|
+
`params extractor or retarget to an actionForm / cross-entity entityEdit-create screen.`,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
25
66
|
// --- Screen validation ---
|
|
26
67
|
//
|
|
27
68
|
// For every r.screen() declaration check what's locally knowable at boot:
|
|
@@ -169,6 +210,28 @@ export function validateScreens(
|
|
|
169
210
|
for (const col of screen.columns) {
|
|
170
211
|
validateColumnRendererForm(feature.name, screenId, normalizeListColumn(col));
|
|
171
212
|
}
|
|
213
|
+
if (screen.rowActions !== undefined) {
|
|
214
|
+
for (const action of screen.rowActions) {
|
|
215
|
+
if (action.kind === "navigate") {
|
|
216
|
+
const candidateQn = qualifyEntityName(feature.name, "screen", action.screen);
|
|
217
|
+
if (!allScreenQns.has(candidateQn) && !navTargetShortIds.has(action.screen)) {
|
|
218
|
+
throw new Error(
|
|
219
|
+
`[Feature ${feature.name}] Screen "${screenId}" (projectionList) rowAction "${action.id}" ` +
|
|
220
|
+
`navigate-target "${action.screen}" does not resolve to a registered screen in any feature.`,
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
const target = screensByShortId.get(action.screen)?.[0];
|
|
224
|
+
validateRowActionNavigateParams(
|
|
225
|
+
feature.name,
|
|
226
|
+
screenId,
|
|
227
|
+
"projectionList",
|
|
228
|
+
undefined,
|
|
229
|
+
action,
|
|
230
|
+
target,
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
172
235
|
continue;
|
|
173
236
|
}
|
|
174
237
|
|
|
@@ -590,33 +653,14 @@ export function validateScreens(
|
|
|
590
653
|
// explicit entityId) a same-entity target gets row["id"] auto-
|
|
591
654
|
// injected — only a cross-entity target with no explicit
|
|
592
655
|
// entityId reaches create.
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
if (
|
|
602
|
-
(target.screen.type !== "actionForm" && target.screen.type !== "entityEdit") ||
|
|
603
|
-
isEntityEditUpdate
|
|
604
|
-
) {
|
|
605
|
-
const reason = isEntityEditUpdate
|
|
606
|
-
? `resolves to UPDATE mode (${
|
|
607
|
-
action.entityId !== undefined
|
|
608
|
-
? `explicit entityId "${action.entityId}"`
|
|
609
|
-
: `same entity "${screen.entity}" auto-fills row["id"]`
|
|
610
|
-
})`
|
|
611
|
-
: `screen type "${target.screen.type}"`;
|
|
612
|
-
throw new Error(
|
|
613
|
-
`[Feature ${feature.name}] Screen "${screenId}" (entityList) rowAction "${action.id}" ` +
|
|
614
|
-
`sets params on navigate-target "${action.screen}" which ${reason} — only actionForm ` +
|
|
615
|
-
`and entityEdit-create targets read URL search params as initial values. Remove the ` +
|
|
616
|
-
`params extractor or retarget to an actionForm / cross-entity entityEdit-create screen.`,
|
|
617
|
-
);
|
|
618
|
-
}
|
|
619
|
-
}
|
|
656
|
+
validateRowActionNavigateParams(
|
|
657
|
+
feature.name,
|
|
658
|
+
screenId,
|
|
659
|
+
"entityList",
|
|
660
|
+
screen.entity,
|
|
661
|
+
action,
|
|
662
|
+
target,
|
|
663
|
+
);
|
|
620
664
|
} else {
|
|
621
665
|
if (!allWriteHandlerQns.has(action.handler)) {
|
|
622
666
|
throw new Error(
|
|
@@ -32,6 +32,11 @@ const splitFeature = defineFeature("split", (r) => {
|
|
|
32
32
|
});
|
|
33
33
|
});
|
|
34
34
|
|
|
35
|
+
// Consumed by the worker's OWN eventDispatcher after worker.start() — proves
|
|
36
|
+
// the write→afterCommit→MSP chain runs end-to-end inside the worker lane,
|
|
37
|
+
// not just that the write itself lands in the event store (framework#1720).
|
|
38
|
+
const consumedNotes: string[] = [];
|
|
39
|
+
|
|
35
40
|
const workerWriteFeature = defineFeature("workerWrite", (r) => {
|
|
36
41
|
const noted = r.defineEvent("noted", z.object({ note: z.string() }), { version: 1 });
|
|
37
42
|
r.writeHandler(
|
|
@@ -48,8 +53,24 @@ const workerWriteFeature = defineFeature("workerWrite", (r) => {
|
|
|
48
53
|
},
|
|
49
54
|
{ access: { openToAll: true } },
|
|
50
55
|
);
|
|
56
|
+
r.multiStreamProjection({
|
|
57
|
+
name: "consume-notes",
|
|
58
|
+
apply: {
|
|
59
|
+
[noted.name]: async (event) => {
|
|
60
|
+
consumedNotes.push((event.payload as { note: string }).note);
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
});
|
|
51
64
|
});
|
|
52
65
|
|
|
66
|
+
async function waitForCondition(check: () => boolean, timeoutMs = 5000): Promise<void> {
|
|
67
|
+
const deadline = Date.now() + timeoutMs;
|
|
68
|
+
while (!check()) {
|
|
69
|
+
if (Date.now() > deadline) throw new Error("waitForCondition: timed out");
|
|
70
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
53
74
|
const JWT = "split-deploy-test-secret-must-be-32-chars!!";
|
|
54
75
|
|
|
55
76
|
// Per-test queue-name with a random suffix. Date.now() alone collided
|
|
@@ -132,6 +153,8 @@ describe("entrypoint factories", () => {
|
|
|
132
153
|
queueNamePrefix: uniquePrefix("split-dispatch"),
|
|
133
154
|
});
|
|
134
155
|
|
|
156
|
+
consumedNotes.length = 0;
|
|
157
|
+
await worker.start();
|
|
135
158
|
try {
|
|
136
159
|
const result = await worker.dispatcher.write(
|
|
137
160
|
"worker-write:write:note",
|
|
@@ -147,6 +170,11 @@ describe("entrypoint factories", () => {
|
|
|
147
170
|
expect((rows[0] as { payload: { note: string } }).payload.note).toBe(
|
|
148
171
|
"written from the worker",
|
|
149
172
|
);
|
|
173
|
+
|
|
174
|
+
// The worker's own eventDispatcher (started above) picks the event
|
|
175
|
+
// back up and runs the MSP — proves the afterCommit/MSP chain works
|
|
176
|
+
// inside the worker lane, not just that the write itself succeeded.
|
|
177
|
+
await waitForCondition(() => consumedNotes.includes("written from the worker"));
|
|
150
178
|
} finally {
|
|
151
179
|
await worker.stop();
|
|
152
180
|
}
|
|
@@ -9,7 +9,7 @@ describe("schedulerIdForJobName", () => {
|
|
|
9
9
|
const id = schedulerIdForJobName("publicstatus:job:uptime-probe");
|
|
10
10
|
expect(id).toBe("scheduler-publicstatus-job-uptime-probe");
|
|
11
11
|
expect(id.includes(":")).toBe(false);
|
|
12
|
-
expect(`repeat:${id}:1784992080000`.split(":").length).
|
|
12
|
+
expect(`repeat:${id}:1784992080000`.split(":").length).toBe(3);
|
|
13
13
|
});
|
|
14
14
|
|
|
15
15
|
test("still collapses dotted QNs", () => {
|