@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.173.0",
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.0",
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.0",
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);
@@ -25,9 +25,8 @@ export type SseBroker = {
25
25
  };
26
26
 
27
27
  export function createSseBroker(): SseBroker {
28
- // ponytail: in-process only publishAccessInvalidation does not fan out via
29
- // Redis. Multi-replica deployments will not revoke SSE streams on other pods
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
- // preSave runs before ownership checks: authorization must evaluate the
78
- // row as it will actually be persisted, including hook-derived fields
79
- // (kumiko-framework#1672).
80
- const data = options?.preSave
81
- ? await options.preSave(applyDefaults(payloadWithoutId), {}, true)
82
- : applyDefaults(payloadWithoutId);
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
- // preSave runs before ownership checks: authorization must evaluate the
230
- // row as it will actually be persisted, including hook-derived fields
231
- // (kumiko-framework#1672).
232
- const changes = updateOptions?.preSave
233
- ? await updateOptions.preSave(payload.changes, previous, false)
234
- : payload.changes;
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
- if (
594
- action.params !== undefined &&
595
- target !== undefined &&
596
- target.screen.type !== "custom"
597
- ) {
598
- const isEntityEditUpdate =
599
- target.screen.type === "entityEdit" &&
600
- (action.entityId !== undefined || target.screen.entity === screen.entity);
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).toBeLessThan(5);
12
+ expect(`repeat:${id}:1784992080000`.split(":").length).toBe(3);
13
13
  });
14
14
 
15
15
  test("still collapses dotted QNs", () => {