@vectoriox/iox-ui 5.60.0 → 5.62.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.
@@ -3,9 +3,10 @@ import {
3
3
  } from './json-schema';
4
4
  import { patternById, patternOptionalParts, patternSlots, patternTakesBinding } from './patterns';
5
5
  import {
6
- contentTypeFromWire, contentTypeJsonSchema, dataSourceJsonSchema, fieldJsonSchema, pageJsonSchema, sectionFromWire, sectionJsonSchema,
7
- sectionPatternIds,
8
- } from './site-plan-schema';
6
+ contentTypeToWire, dataSourceToWire, expandWireContentType, expandWireDataSource, expandWireField, expandWirePage, expandWireSection, fieldToWire,
7
+ pageToWire, PLAN_PARTS, PlanPart, PlanWireShapeError, sectionPatternIds, sectionToWire, wireContentTypeJsonSchema, wireDataSourceJsonSchema,
8
+ wireFieldJsonSchema, WireOptions, wirePageJsonSchema, wireSectionJsonSchema,
9
+ } from './plan-wire';
9
10
  import {
10
11
  ContentFieldPlan, ContentTypePlan, DataSourcePlan, PagePlan, PageSectionPlan, PlanDataSourceMode, PlanDataSourceType, SitePlan,
11
12
  } from './site-plan.model';
@@ -67,10 +68,35 @@ export const PLAN_EDIT_SHAPE: Readonly<Record<`${PlanEditOp}`, ShapeEntry>> = {
67
68
  [PlanEditOp.SwapPattern]: { op: "'swapPattern'", page: 'string — a page key', index: 'integer — the section', pattern: 'string — the pattern it becomes (its slots kept where the new one has them)' },
68
69
  [PlanEditOp.SetSlot]: { op: "'setSlot'", page: 'string — a page key', index: 'integer — the section', slot: "string — one of the pattern's slots", text: "string — the words, in the owner's language and voice" },
69
70
  [PlanEditOp.AddContentType]: { op: "'addContentType'", contentType: 'contentType — a unique name', 'dataSources?': 'dataSource[] — the list / single sources its pages read' },
70
- [PlanEditOp.AddField]: { op: "'addField'", contentType: 'string — a content type of the plan', field: 'field', 'sample?': "string | string[] — the samples' value for it" },
71
+ [PlanEditOp.AddField]: { op: "'addField'", contentType: 'string — a content type of the plan', field: 'field', 'sample?': "string | string[] — the samples' value for it; absent: what the type's seed already holds for it, else ''" },
71
72
  [PlanEditOp.SetSectionData]: { op: "'setSectionData'", page: 'string — a page key', index: 'integer — the section', binding: 'string — the list source the section repeats' },
72
73
  };
73
74
 
75
+ /**
76
+ * The plan part each op changes (vectoriox/iox-cms#47): a sentence is answered in the plan's two parts, one request each —
77
+ * the structure's edits first, then the pages' written knowing them.
78
+ */
79
+ export const PLAN_EDIT_OP_PART: Readonly<Record<`${PlanEditOp}`, PlanPart>> = {
80
+ [PlanEditOp.AddPage]: PlanPart.Pages,
81
+ [PlanEditOp.RemovePage]: PlanPart.Pages,
82
+ [PlanEditOp.RenamePage]: PlanPart.Pages,
83
+ [PlanEditOp.MergePages]: PlanPart.Pages,
84
+ [PlanEditOp.MovePage]: PlanPart.Pages,
85
+ [PlanEditOp.AddSection]: PlanPart.Pages,
86
+ [PlanEditOp.RemoveSection]: PlanPart.Pages,
87
+ [PlanEditOp.MoveSection]: PlanPart.Pages,
88
+ [PlanEditOp.SwapPattern]: PlanPart.Pages,
89
+ [PlanEditOp.SetSlot]: PlanPart.Pages,
90
+ [PlanEditOp.SetSectionData]: PlanPart.Pages,
91
+ [PlanEditOp.AddContentType]: PlanPart.Structure,
92
+ [PlanEditOp.AddField]: PlanPart.Structure,
93
+ };
94
+
95
+ /** The ops written in one part, in `PlanEditOp` order. */
96
+ export function planEditOps(part: PlanPart): PlanEditOp[] {
97
+ return Object.values(PlanEditOp).filter(op => PLAN_EDIT_OP_PART[op] === part);
98
+ }
99
+
74
100
  export enum PlanChangeKind {
75
101
  Page = 'page',
76
102
  Section = 'section',
@@ -206,10 +232,14 @@ function findSection(d: Draft, pageAt: number, index: unknown, c: Collector, pat
206
232
 
207
233
  const setSections = (d: Draft, at: number, sections: PageSectionPlan[]) => { d.pages[at] = { ...d.pages[at], sections }; };
208
234
 
235
+ /** A child added to a page or type the same edits added is part of that addition — the map marks the parent only. */
236
+ const addedHere = (changed: ReadonlyArray<PlanChange>, kind: PlanChangeKind, at: { page?: string; name?: string }) =>
237
+ changed.some(ch => ch.kind === kind && ch.action === PlanChangeAction.Added && (kind === PlanChangeKind.Page ? ch.page === at.page : ch.name === at.name));
238
+
209
239
  /** One handler per op — a data table, never an if-chain. */
210
240
  const APPLY: Readonly<Record<`${PlanEditOp}`, Apply>> = {
211
241
  [PlanEditOp.AddPage]: (d, e, c, path, changed) => {
212
- const page = { ...(e.page as PagePlan), sections: (e.page.sections ?? []).map(sectionFromWire) } as PagePlan;
242
+ const page = e.page as PagePlan;
213
243
  if (pageIndex(d, page.key) >= 0) return c.add(PlanEditIssueCode.Duplicate, `${path}.page.key`, `page "${page.key}" is already in the plan`);
214
244
  const after = e.after === undefined ? d.pages.length - 1 : findPage(d, e.after, c, `${path}.after`);
215
245
  if (after < 0 && e.after !== undefined) return;
@@ -257,9 +287,9 @@ const APPLY: Readonly<Record<`${PlanEditOp}`, Apply>> = {
257
287
  if (at < 0) return;
258
288
  const sections = [...d.pages[at].sections];
259
289
  const index = e.at === undefined ? sections.length : clamp(e.at, sections.length);
260
- sections.splice(index, 0, sectionFromWire(e.section) as PageSectionPlan);
290
+ sections.splice(index, 0, e.section as PageSectionPlan);
261
291
  setSections(d, at, sections);
262
- changed.push({ kind: PlanChangeKind.Section, action: PlanChangeAction.Added, page: d.pages[at].key, index });
292
+ if (!addedHere(changed, PlanChangeKind.Page, { page: d.pages[at].key })) changed.push({ kind: PlanChangeKind.Section, action: PlanChangeAction.Added, page: d.pages[at].key, index });
263
293
  },
264
294
  [PlanEditOp.RemoveSection]: (d, e, c, path, changed) => {
265
295
  const at = findPage(d, e.page, c, `${path}.page`);
@@ -304,7 +334,7 @@ const APPLY: Readonly<Record<`${PlanEditOp}`, Apply>> = {
304
334
  changed.push({ kind: PlanChangeKind.Section, action: PlanChangeAction.Changed, page: d.pages[at].key, index: e.index });
305
335
  },
306
336
  [PlanEditOp.AddContentType]: (d, e, c, path, changed) => {
307
- const type = contentTypeFromWire(e.contentType) as ContentTypePlan;
337
+ const type = e.contentType as ContentTypePlan;
308
338
  if (d.contentTypes.some(t => t.name === type.name)) return c.add(PlanEditIssueCode.Duplicate, `${path}.contentType.name`, `content type "${type.name}" is already in the plan`);
309
339
  d.contentTypes.push(type);
310
340
  changed.push({ kind: PlanChangeKind.ContentType, action: PlanChangeAction.Added, name: type.name });
@@ -319,9 +349,9 @@ const APPLY: Readonly<Record<`${PlanEditOp}`, Apply>> = {
319
349
  if (at < 0) return c.add(PlanEditIssueCode.UnknownContentType, `${path}.contentType`, `no content type "${String(e.contentType)}" in the plan`);
320
350
  const type = d.contentTypes[at];
321
351
  if (type.fields.some(f => f.name === e.field.name)) return c.add(PlanEditIssueCode.Duplicate, `${path}.field.name`, `"${type.name}" already has a field "${e.field.name}"`);
322
- const seed = type.seed ? { ...type.seed, [e.field.name]: e.sample ?? '' } : undefined;
352
+ const seed = type.seed ? { ...type.seed, [e.field.name]: e.sample ?? type.seed[e.field.name] ?? '' } : undefined;
323
353
  d.contentTypes[at] = { ...type, fields: [...type.fields, e.field], ...(seed ? { seed } : {}) };
324
- changed.push({ kind: PlanChangeKind.Field, action: PlanChangeAction.Added, name: `${type.name}.${e.field.name}` });
354
+ if (!addedHere(changed, PlanChangeKind.ContentType, { name: type.name })) changed.push({ kind: PlanChangeKind.Field, action: PlanChangeAction.Added, name: `${type.name}.${e.field.name}` });
325
355
  },
326
356
  [PlanEditOp.SetSectionData]: (d, e, c, path, changed) => {
327
357
  const at = findPage(d, e.page, c, `${path}.page`);
@@ -360,7 +390,11 @@ export function applyPlanEdits(plan: SitePlan, edits: ReadonlyArray<PlanEdit>):
360
390
 
361
391
  // ── The format a model writes edits in ─────────────────────────────────────────────────────────────────
362
392
 
363
- export const PLAN_EDITS_SCHEMA_NAME = 'plan_edits';
393
+ /** Each part's answer format name. */
394
+ export const PLAN_EDITS_PART_SCHEMA_NAMES: Readonly<Record<PlanPart, string>> = {
395
+ [PlanPart.Structure]: 'plan_structure_edits',
396
+ [PlanPart.Pages]: 'plan_page_edits',
397
+ };
364
398
 
365
399
  /** A sentence's answer: the edits, or why the ops cannot say it (one line, in the owner's language). */
366
400
  export enum PlanEditsOutcome {
@@ -375,54 +409,219 @@ export interface PlanEditsAnswer {
375
409
  reason: string;
376
410
  }
377
411
 
378
- /** The answer's own keys, explained. */
379
- export const PLAN_EDITS_ANSWER_SHAPE: ShapeEntry = {
380
- outcome: "'edits' | 'cannot' — cannot: the operations cannot do what the sentence asks",
381
- edits: 'edit[] — applied in order, all or none; empty when cannot',
382
- reason: "string — when cannot, ONE short line for the owner in their language (what to do instead); else ''",
412
+ /**
413
+ * Each part's answer, explained. The structure is asked first, so it says whether the sentence is a plan change at all
414
+ * (`outcome`); the pages are asked only when it is, and write only their edits.
415
+ */
416
+ export const PLAN_EDITS_ANSWER_SHAPES: Readonly<Record<PlanPart, ShapeEntry>> = {
417
+ [PlanPart.Structure]: {
418
+ outcome: "'edits' | 'cannot' — cannot: no operation of either part (below) can do what the sentence asks",
419
+ edits: 'edit[] — the content types and fields the sentence adds, in order; empty when it adds none, or cannot',
420
+ reason: "string — when cannot, ONE short line for the owner in their language (what to do instead); else ''",
421
+ },
422
+ [PlanPart.Pages]: {
423
+ edits: 'edit[] — the page and section edits, in order, made after the structure edits (already applied); empty when none',
424
+ },
383
425
  };
384
426
 
385
- const one = (value: string): JsonSchema => jsonOneOf([value]);
386
-
387
- /** The schema of each op's value types (what `PLAN_EDIT_SHAPE` names). */
388
- function opSchema(op: `${PlanEditOp}`): JsonSchema {
389
- const entry = PLAN_EDIT_SHAPE[op];
390
- const valueOf = (key: string): JsonSchema => {
391
- const text = entry[key] ?? entry[`${key}?`];
392
- if (key === 'op') return one(op);
393
- if (key === 'pattern') return jsonOneOf(sectionPatternIds());
394
- const head = text.split(' ')[0];
395
- const table: Record<string, () => JsonSchema> = {
396
- page: pageJsonSchema, section: sectionJsonSchema, contentType: contentTypeJsonSchema, field: fieldJsonSchema,
397
- 'dataSource[]': () => jsonListOf(dataSourceJsonSchema()), integer: jsonInteger, string: jsonText,
398
- };
399
- if (text.startsWith('string | string[]')) return jsonAnyOf([jsonText(), jsonListOf(jsonText())]);
400
- return (table[head] ?? jsonText)();
427
+ /**
428
+ * ONE flat edit per part on the wire (vectoriox/iox-cms#45, #47): an object whose keys are ALL required — the 13 ops as
429
+ * `anyOf` branches came to 395 optional parameters against the Claude API's 24, and one flat edit for all of them to 95
430
+ * properties, a grammar Claude refuses (≤ 60). `op` says which keys are read (`PLAN_EDIT_WIRE_KEYS`, from
431
+ * `PLAN_EDIT_SHAPE`); every other key holds its none. A new page, section, content type or field travels as a list of
432
+ * exactly one, in the plan wire's format — and an added page or content type travels WITHOUT its children: each of its
433
+ * sections is an `addSection` after it, each of its fields an `addField` (a new type's samples ride on its `seed`).
434
+ */
435
+ interface EditWireKey {
436
+ text: string;
437
+ none: unknown;
438
+ schema(): JsonSchema;
439
+ /** The wire value → the edit's (default: as written). */
440
+ expand?(value: unknown): unknown;
441
+ /** The edit's value → the wire's (default: as is). */
442
+ toWire?(value: any): unknown;
443
+ }
444
+
445
+ const NO_INDEX = -1;
446
+ /** A new page or content type on the wire: its children are edits of their own. */
447
+ const CHILDLESS: WireOptions = { children: false };
448
+
449
+ /** A list holding exactly one object: none → absent, one → it (expanded), more → the list (the gate refuses it). */
450
+ const oneOf = (schema: () => JsonSchema, expand: (v: unknown) => unknown, toWire: (v: any) => unknown, what: string): EditWireKey => ({
451
+ text: `${what}[] — exactly one when the op takes it`,
452
+ none: [],
453
+ schema: () => jsonListOf(schema()),
454
+ expand: v => (Array.isArray(v) ? (v.length === 0 ? undefined : v.length === 1 ? expand(v[0]) : v) : v),
455
+ toWire: v => [toWire(v)],
456
+ });
457
+
458
+ const textKey = (text: string): EditWireKey => ({ text: `string — ${text}`, none: '', schema: jsonText });
459
+ const indexKey = (text: string): EditWireKey => ({ text: `integer — ${text}`, none: NO_INDEX, schema: jsonInteger });
460
+
461
+ const EDIT_WIRE: Readonly<Record<string, EditWireKey>> = {
462
+ page: textKey('a page key'),
463
+ name: textKey('renamePage: its new name (the menu label)'),
464
+ route: textKey('renamePage: its new route'),
465
+ after: textKey('addPage: the key of the page it follows in the menu'),
466
+ into: textKey('mergePages: the page that stays'),
467
+ from: textKey('mergePages: the page whose sections move into it, then removed'),
468
+ index: indexKey('the section, 0-based on its page (addSection: the place the new one goes)'),
469
+ to: indexKey('movePage / moveSection: the new 0-based place'),
470
+ pattern: { text: 'string — swapPattern: the section pattern it becomes', none: '', schema: () => jsonOneOf(['', ...sectionPatternIds()]) },
471
+ slot: textKey("setSlot: one of the pattern's slots"),
472
+ text: textKey("setSlot: the words, in the owner's language and voice"),
473
+ binding: textKey('setSectionData: the list source the section repeats'),
474
+ contentType: textKey('addField: a content type of the plan'),
475
+ newPage: {
476
+ ...oneOf(() => wirePageJsonSchema(CHILDLESS), v => expandWirePage(v, CHILDLESS), p => pageToWire(p, CHILDLESS), 'page'),
477
+ text: 'page[] — exactly one when the op takes it, WITHOUT sections: each is an addSection after it',
478
+ },
479
+ newSection: oneOf(wireSectionJsonSchema, expandWireSection, sectionToWire, 'section'),
480
+ newContentType: {
481
+ ...oneOf(() => wireContentTypeJsonSchema(CHILDLESS), v => expandWireContentType(v, CHILDLESS), t => contentTypeToWire(t, CHILDLESS), 'contentType'),
482
+ text: "contentType[] — exactly one when the op takes it, WITHOUT fields: each is an addField after it (the samples' values stay in its seed)",
483
+ },
484
+ dataSources: {
485
+ text: 'dataSource[] — addContentType: the list / single sources its pages read', none: [],
486
+ schema: () => jsonListOf(wireDataSourceJsonSchema()),
487
+ expand: v => (Array.isArray(v) ? v.map(expandWireDataSource) : v),
488
+ toWire: (v: DataSourcePlan[]) => v.map(dataSourceToWire),
489
+ },
490
+ newField: oneOf(wireFieldJsonSchema, expandWireField, fieldToWire, 'field'),
491
+ sample: { text: "string | string[] — addField: the samples' value for it", none: '', schema: () => jsonAnyOf([jsonText(), jsonListOf(jsonText())]) },
492
+ };
493
+
494
+ /** Each op's keys (`PLAN_EDIT_SHAPE`) → the wire key that carries it. */
495
+ export const PLAN_EDIT_WIRE_KEYS: Readonly<Record<`${PlanEditOp}`, Readonly<Record<string, string>>>> = {
496
+ [PlanEditOp.AddPage]: { page: 'newPage', after: 'after' },
497
+ [PlanEditOp.RemovePage]: { page: 'page' },
498
+ [PlanEditOp.RenamePage]: { page: 'page', name: 'name', route: 'route' },
499
+ [PlanEditOp.MergePages]: { into: 'into', from: 'from' },
500
+ [PlanEditOp.MovePage]: { page: 'page', to: 'to' },
501
+ [PlanEditOp.AddSection]: { page: 'page', section: 'newSection', at: 'index' },
502
+ [PlanEditOp.RemoveSection]: { page: 'page', index: 'index' },
503
+ [PlanEditOp.MoveSection]: { page: 'page', from: 'index', to: 'to' },
504
+ [PlanEditOp.SwapPattern]: { page: 'page', index: 'index', pattern: 'pattern' },
505
+ [PlanEditOp.SetSlot]: { page: 'page', index: 'index', slot: 'slot', text: 'text' },
506
+ [PlanEditOp.AddContentType]: { contentType: 'newContentType', dataSources: 'dataSources' },
507
+ [PlanEditOp.AddField]: { contentType: 'contentType', field: 'newField', sample: 'sample' },
508
+ [PlanEditOp.SetSectionData]: { page: 'page', index: 'index', binding: 'binding' },
509
+ };
510
+
511
+ /** The wire keys a part's edit carries: every key its ops read, in `EDIT_WIRE` order. */
512
+ export function planEditWireKeys(part: PlanPart): string[] {
513
+ const read = new Set(planEditOps(part).flatMap(op => Object.values(PLAN_EDIT_WIRE_KEYS[op])));
514
+ return Object.keys(EDIT_WIRE).filter(k => read.has(k));
515
+ }
516
+
517
+ const isEditWireNone = (wireKey: string, v: unknown) => {
518
+ const none = EDIT_WIRE[wireKey].none;
519
+ return v === undefined || (Array.isArray(none) ? Array.isArray(v) && v.length === 0 : v === none) || (wireKey === 'sample' && Array.isArray(v) && v.length === 0);
520
+ };
521
+ const noneText = (none: unknown) => (Array.isArray(none) ? '[]' : typeof none === 'string' ? "''" : String(none));
522
+ const opsText = (part: PlanPart) => planEditOps(part).map(op => `'${op}'`).join(' | ');
523
+
524
+ /** A part's flat edit, as a shape table: every key required, each with its none. */
525
+ export function planEditWireShape(part: PlanPart): ShapeEntry {
526
+ return {
527
+ op: `${opsText(part)} — which keys below are read`,
528
+ ...Object.fromEntries(planEditWireKeys(part).map(k => [k, `${EDIT_WIRE[k].text} — ${noneText(EDIT_WIRE[k].none)} when the op does not read it`])),
401
529
  };
402
- return jsonShapedObject(entry, Object.fromEntries(shapeKeys(entry).map(key => [key, valueOf(key)])), op);
403
530
  }
404
531
 
405
- /** The format a sentence's answer is written in: the outcome, the edits (one `anyOf` branch per op), the reason. */
406
- export function planEditsJsonSchema(): JsonSchema {
407
- return jsonShapedObject(PLAN_EDITS_ANSWER_SHAPE, {
408
- outcome: jsonOneOf(Object.values(PlanEditsOutcome)),
409
- edits: jsonListOf(jsonAnyOf(OPS.map(op => opSchema(op as PlanEditOp)))),
410
- reason: jsonText(),
411
- }, 'planEdits');
532
+ /** Per op of a part, the wire keys it reads, in `PLAN_EDIT_SHAPE`'s words (`?` = may hold its none) — what the model reads. */
533
+ export function planEditWireOps(part: PlanPart): Record<string, Record<string, string>> {
534
+ return Object.fromEntries(planEditOps(part).map(op => {
535
+ const entry = PLAN_EDIT_SHAPE[op];
536
+ const keys = PLAN_EDIT_WIRE_KEYS[op];
537
+ return [op, Object.fromEntries(Object.entries(keys).map(([planKey, wireKey]) => {
538
+ const optionalKey = `${planKey}?` in entry;
539
+ return [`${wireKey}${optionalKey ? '?' : ''}`, entry[optionalKey ? `${planKey}?` : planKey]];
540
+ }))];
541
+ }));
542
+ }
543
+
544
+ /** Op keys no wire key carries, or wire keys that do not exist — always empty (a spec and the builder hold it). */
545
+ export function planEditWireGaps(): string[] {
546
+ const gaps: string[] = [];
547
+ for (const op of OPS) {
548
+ const keys = PLAN_EDIT_WIRE_KEYS[op as PlanEditOp] ?? {};
549
+ for (const planKey of shapeKeys(PLAN_EDIT_SHAPE[op as PlanEditOp])) {
550
+ if (planKey === 'op') continue;
551
+ if (!keys[planKey]) gaps.push(`${op}.${planKey}`);
552
+ else if (!EDIT_WIRE[keys[planKey]]) gaps.push(`${op}.${planKey} → ${keys[planKey]}`);
553
+ }
554
+ for (const planKey of Object.keys(keys)) if (!shapeKeys(PLAN_EDIT_SHAPE[op as PlanEditOp]).includes(planKey)) gaps.push(`${op}.${planKey} (not an op key)`);
555
+ }
556
+ return gaps;
557
+ }
558
+
559
+ const ANSWER_SCHEMAS: Readonly<Record<string, (part: PlanPart) => JsonSchema>> = {
560
+ outcome: () => jsonOneOf(Object.values(PlanEditsOutcome)),
561
+ reason: () => jsonText(),
562
+ edits: part => jsonListOf(jsonShapedObject(planEditWireShape(part), {
563
+ op: jsonOneOf(planEditOps(part)),
564
+ ...Object.fromEntries(planEditWireKeys(part).map(k => [k, EDIT_WIRE[k].schema()])),
565
+ }, `edit ${part}`)),
566
+ };
567
+
568
+ /** The format one part of a sentence's answer is written in — one request each (vectoriox/iox-cms#47). */
569
+ export function planEditsJsonSchema(part: PlanPart): JsonSchema {
570
+ const gaps = planEditWireGaps();
571
+ if (gaps.length) throw new PlanWireShapeError(`the plan edits wire does not carry ${gaps.join(', ')}`);
572
+ const shape = PLAN_EDITS_ANSWER_SHAPES[part];
573
+ return jsonShapedObject(shape, Object.fromEntries(shapeKeys(shape).map(k => [k, ANSWER_SCHEMAS[k](part)])), `planEdits ${part}`);
574
+ }
575
+
576
+ /** One flat edit → the op's edit: the keys it reads, a none left out where the op may go without it. Never repaired. */
577
+ export function expandWireEdit(wire: unknown): PlanEdit | unknown {
578
+ if (!isPlainObject(wire)) return wire;
579
+ const keys = PLAN_EDIT_WIRE_KEYS[wire['op'] as PlanEditOp];
580
+ if (!keys) return wire;
581
+ const entry = PLAN_EDIT_SHAPE[wire['op'] as PlanEditOp];
582
+ const out: PlainObject = Object.fromEntries(Object.entries(wire).filter(([k]) => k === 'op' || !(k in EDIT_WIRE)));
583
+ for (const [planKey, wireKey] of Object.entries(keys)) {
584
+ const raw = wire[wireKey];
585
+ if (`${planKey}?` in entry && isEditWireNone(wireKey, raw)) continue;
586
+ const value = EDIT_WIRE[wireKey].expand ? EDIT_WIRE[wireKey].expand!(raw) : raw;
587
+ if (value !== undefined) out[planKey] = value;
588
+ }
589
+ return out;
590
+ }
591
+
592
+ /** An edit's own keys → its flat wire edit, in its part's keys. */
593
+ function flatEdit(edit: PlanEdit): PlainObject {
594
+ const part = PLAN_EDIT_OP_PART[edit.op];
595
+ const out: PlainObject = { op: edit.op, ...Object.fromEntries(planEditWireKeys(part).map(k => [k, Array.isArray(EDIT_WIRE[k].none) ? [] : EDIT_WIRE[k].none])) };
596
+ for (const [planKey, wireKey] of Object.entries(PLAN_EDIT_WIRE_KEYS[edit.op])) {
597
+ const value = (edit as unknown as PlainObject)[planKey];
598
+ if (value === undefined) continue;
599
+ out[wireKey] = EDIT_WIRE[wireKey].toWire ? EDIT_WIRE[wireKey].toWire!(value) : value;
600
+ }
601
+ return out;
412
602
  }
413
603
 
414
- /** A model's answer → edits (sections and content types off the wire); never repaired. */
604
+ /** The children an added page or content type travels without, as the edits that add them after it. */
605
+ function childEdits(edit: PlanEdit): PlanEdit[] {
606
+ if (edit.op === PlanEditOp.AddPage) return edit.page.sections.map(section => ({ op: PlanEditOp.AddSection, page: edit.page.key, section }));
607
+ if (edit.op === PlanEditOp.AddContentType) return edit.contentType.fields.map(field => ({ op: PlanEditOp.AddField, contentType: edit.contentType.name, field }));
608
+ return [];
609
+ }
610
+
611
+ /** An edit → the flat wire edits a model would write for it: itself, then its children (fakes and specs write them). */
612
+ export function planEditToWire(edit: PlanEdit): PlainObject[] {
613
+ return [flatEdit(edit), ...childEdits(edit).map(flatEdit)];
614
+ }
615
+
616
+ /** Edits → each part's flat wire edits, in order (what each part's answer would hold). */
617
+ export function planEditsToWire(edits: ReadonlyArray<PlanEdit>): Record<PlanPart, PlainObject[]> {
618
+ const out = Object.fromEntries(PLAN_PARTS.map(part => [part, [] as PlainObject[]])) as Record<PlanPart, PlainObject[]>;
619
+ for (const edit of edits) out[PLAN_EDIT_OP_PART[edit.op]].push(...planEditToWire(edit));
620
+ return out;
621
+ }
622
+
623
+ /** A model's answer (either part's) → edits (each flat edit expanded); never repaired. */
415
624
  export function planEditsFromWire(answer: unknown): PlanEditsAnswer | unknown {
416
625
  if (!isPlainObject(answer) || !Array.isArray(answer['edits'])) return answer;
417
- const edits = (answer['edits'] as unknown[]).map(raw => {
418
- if (!isPlainObject(raw)) return raw;
419
- const e: PlainObject = { ...raw };
420
- if (isPlainObject(e['page']) && Array.isArray((e['page'] as PlainObject)['sections'])) {
421
- e['page'] = { ...(e['page'] as PlainObject), sections: ((e['page'] as PlainObject)['sections'] as unknown[]).map(sectionFromWire) };
422
- }
423
- if (isPlainObject(e['section'])) e['section'] = sectionFromWire(e['section']);
424
- if (isPlainObject(e['contentType'])) e['contentType'] = contentTypeFromWire(e['contentType']);
425
- return e;
426
- });
427
- return { ...answer, edits };
626
+ return { ...answer, edits: (answer['edits'] as unknown[]).map(expandWireEdit) };
428
627
  }