@cosmicdrift/kumiko-framework 0.226.0 → 0.228.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.226.0",
3
+ "version": "0.228.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,7 +198,7 @@
198
198
  "./package.json": "./package.json"
199
199
  },
200
200
  "dependencies": {
201
- "@cosmicdrift/kumiko-types": "0.226.0",
201
+ "@cosmicdrift/kumiko-types": "0.228.0",
202
202
  "bullmq": "^5.76.7",
203
203
  "bun-types": "^1.3.13",
204
204
  "hono": "^4.13.1",
@@ -214,7 +214,7 @@
214
214
  "zod": "^4.4.3"
215
215
  },
216
216
  "devDependencies": {
217
- "@cosmicdrift/kumiko-dispatcher-live": "0.226.0",
217
+ "@cosmicdrift/kumiko-dispatcher-live": "0.228.0",
218
218
  "bun-types": "^1.3.13",
219
219
  "pino-pretty": "^13.1.3"
220
220
  },
@@ -149,7 +149,31 @@ describe("r.screen() — registration", () => {
149
149
  expect(() => validateBoot(features)).toThrow(/zero fields/i);
150
150
  });
151
151
 
152
- test("validateBoot rejects a projectionDetail with an extension section (no entity to persist against)", () => {
152
+ test("validateBoot rejects a projectionDetail extension section with contributesToFormSubmit: true (read-only screen, solon#264)", () => {
153
+ const features = [
154
+ defineFeature("app", (r) => {
155
+ r.screen({
156
+ id: "x",
157
+ type: "projectionDetail",
158
+ query: "app:query:foo:detail",
159
+ layout: {
160
+ sections: [
161
+ {
162
+ kind: "extension",
163
+ title: "s",
164
+ component: { react: { __component: "c" } },
165
+ entityName: "lease",
166
+ contributesToFormSubmit: true,
167
+ },
168
+ ],
169
+ },
170
+ });
171
+ }),
172
+ ];
173
+ expect(() => validateBoot(features)).toThrow(/no form submit to contribute to/i);
174
+ });
175
+
176
+ test("validateBoot rejects a projectionDetail extension section without entityName", () => {
153
177
  const features = [
154
178
  defineFeature("app", (r) => {
155
179
  r.screen({
@@ -164,7 +188,49 @@ describe("r.screen() — registration", () => {
164
188
  });
165
189
  }),
166
190
  ];
167
- expect(() => validateBoot(features)).toThrow(/extension section/i);
191
+ expect(() => validateBoot(features)).toThrow(/has no entityName/i);
192
+ });
193
+
194
+ test("validateBoot rejects a projectionDetail extension section with no component", () => {
195
+ const features = [
196
+ defineFeature("app", (r) => {
197
+ r.screen({
198
+ id: "x",
199
+ type: "projectionDetail",
200
+ query: "app:query:foo:detail",
201
+ layout: {
202
+ sections: [{ kind: "extension", title: "s", component: {}, entityName: "lease" }],
203
+ },
204
+ });
205
+ }),
206
+ ];
207
+ expect(() => validateBoot(features)).toThrow(/has no component/i);
208
+ });
209
+
210
+ test("validateBoot boots a projectionDetail extension section without contributesToFormSubmit (self-persisting, solon#264)", () => {
211
+ const features = [
212
+ defineFeature("app", (r) => {
213
+ r.queryHandler("foo:detail", z.object({}), async () => ({}), {
214
+ access: { openToAll: true },
215
+ });
216
+ r.screen({
217
+ id: "x",
218
+ type: "projectionDetail",
219
+ query: "app:query:foo:detail",
220
+ layout: {
221
+ sections: [
222
+ {
223
+ kind: "extension",
224
+ title: "s",
225
+ component: { react: { __component: "c" } },
226
+ entityName: "lease",
227
+ },
228
+ ],
229
+ },
230
+ });
231
+ }),
232
+ ];
233
+ expect(() => validateBoot(features)).not.toThrow();
168
234
  });
169
235
 
170
236
  test("validateBoot rejects a projectionDetail relatedList section with an empty query (fw#2166)", () => {
@@ -663,11 +663,37 @@ export function validateScreens(
663
663
  }
664
664
  for (const section of screen.layout.sections) {
665
665
  if (isExtensionEditSection(section)) {
666
- throw new Error(
667
- `[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) extension section ` +
668
- `"${section.title}" is not supported projectionDetail has no entity for an extension ` +
669
- `section to persist against.`,
670
- );
666
+ // projectionDetail is read-only (no composed form submit) — an
667
+ // extension that persists through the host's Save button has
668
+ // nothing to save against. Extensions that persist themselves
669
+ // (e.g. notes-history NotesSection, via their own dispatcher
670
+ // writes) are fine (solon#264).
671
+ if (section.contributesToFormSubmit === true) {
672
+ throw new Error(
673
+ `[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) extension section ` +
674
+ `"${section.title}" sets contributesToFormSubmit: true, but projectionDetail has no ` +
675
+ `form submit to contribute to (read-only screen). Omit contributesToFormSubmit and ` +
676
+ `persist through the extension's own dispatcher writes instead.`,
677
+ );
678
+ }
679
+ if (section.component?.react === undefined && section.component?.native === undefined) {
680
+ throw new Error(
681
+ `[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) extension section ` +
682
+ `"${section.title}" has no component — declare a react/native component marker.`,
683
+ );
684
+ }
685
+ // The host-derived entity name on projectionDetail is an internal
686
+ // placeholder (no real entity backs this screen) — a self-
687
+ // persisting extension needs the real domain entity name declared
688
+ // explicitly, or it silently reads/writes against the wrong type.
689
+ if (section.entityName === undefined || section.entityName.trim().length === 0) {
690
+ throw new Error(
691
+ `[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) extension section ` +
692
+ `"${section.title}" has no entityName — projectionDetail has no real entity, so the ` +
693
+ `extension must declare entityName explicitly (the domain entity it persists against).`,
694
+ );
695
+ }
696
+ continue;
671
697
  }
672
698
  if (section.kind === "relatedList") {
673
699
  if (!section.query || typeof section.query !== "string") {
@@ -226,7 +226,10 @@ export function requiredKeysFromScreen(
226
226
  case "projectionDetail": {
227
227
  const detail = screen as ProjectionDetailScreenDefinition;
228
228
  for (const section of detail.layout.sections) {
229
- if (isExtensionEditSection(section)) continue; // rejected at boot, unreachable here
229
+ if (isExtensionEditSection(section)) {
230
+ pushKey(out, section.title);
231
+ continue;
232
+ }
230
233
  if (section.kind === "relatedList") {
231
234
  pushKey(out, section.title);
232
235
  for (const col of section.columns) {