@unboundcx/sdk 4.1.3 → 4.4.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.
@@ -0,0 +1,17 @@
1
+ import { z } from 'zod';
2
+ import { ConditionSpec, MustacheTemplate } from './primitives.js';
3
+
4
+ // New primitive, per V2-PLAN's exact definition.
5
+ export const ActionSpec = z.object({
6
+ id: z.string().min(1),
7
+ type: z.enum(['create', 'edit', 'delete', 'custom']),
8
+ label: z.string().optional(),
9
+ target: z.string().optional(), // object name (create/edit/delete) or custom-action registry key
10
+ mode: z.enum(['modal', 'tab', 'inline']).default('modal'),
11
+ layout: z.string().optional(), // compactLayoutId, used when mode:'modal' for create/edit
12
+ placement: z.enum(['header', 'section', 'row', 'card']),
13
+ visibility: ConditionSpec.optional(),
14
+ prefill: z.record(MustacheTemplate).optional(), // e.g. {relatedId: '{{id}}'} — create-with-prefilled-relationship
15
+ }).refine((v) => v.type !== 'custom' || !!v.target, {
16
+ message: 'custom action requires target', path: ['target'],
17
+ });
@@ -0,0 +1,11 @@
1
+ import { z } from 'zod';
2
+ import { FieldSpec } from './field.js';
3
+
4
+ // Per V2-PLAN §1.4: "just { fields: FieldSpec[] } — capped list, no sections."
5
+ // Reused everywhere a short field summary is needed: kanban cardFields,
6
+ // hover cards, modal create/edit forms.
7
+ export const CompactLayoutDoc = z.object({
8
+ schemaVersion: z.literal(2).default(2),
9
+ objectName: z.string().min(1),
10
+ fields: z.array(FieldSpec).min(1).max(12),
11
+ });
@@ -0,0 +1,103 @@
1
+ import { z } from 'zod';
2
+ import { ConditionSpec } from './primitives.js';
3
+ import { MustacheTemplate } from './primitives.js';
4
+ import { FormatType } from './format.js';
5
+ import { JoinSpec } from './join.js';
6
+ import { SelectDynamicSpec } from './selectDynamic.js';
7
+
8
+ const SecurityConfigSpec = z.object({
9
+ editWithoutValue: z.boolean().default(false),
10
+ readOnly: z.boolean().default(false),
11
+ showChars: z.number().int().min(0).optional(),
12
+ hideLength: z.boolean().default(false),
13
+ });
14
+
15
+ const LinkSpec = z.object({
16
+ path: MustacheTemplate,
17
+ tabName: z.string().optional(),
18
+ tabIcon: z.string().optional(),
19
+ });
20
+
21
+ // DEVIATION from spec's literal DISPLAY_FIELD_TYPES list (see SPEC-PHASE-1.md
22
+ // A6): added 'select'/'selectDynamic'. Verified against real stored data
23
+ // (app1-api/src/services/layouts/examples.json) — display.fieldType commonly
24
+ // mirrors edit.fieldType for select-backed fields (5 occurrences of
25
+ // display.fieldType:'selectDynamic' in the two-doc fixture); the literal spec
26
+ // list omitted them, which would warn on effectively every select field in
27
+ // production data — a false-positive, not the genuine defect this schema is
28
+ // meant to surface. Flagged for reviewer per task instructions.
29
+ const DISPLAY_FIELD_TYPES = [
30
+ 'input', 'textArea', 'code', 'readOnly', 'spacer', 'composite',
31
+ 'select', 'selectDynamic',
32
+ 'securityBlurHover', 'securityBlurAlways', 'securityLastX', 'securityFirstX', 'securityFirstLastX',
33
+ ];
34
+
35
+ const FieldDisplaySpec = z.object({
36
+ label: z.string().default(''),
37
+ value: z.string().min(1), // dot-path ok, e.g. "company.name"
38
+ fieldType: z.enum(DISPLAY_FIELD_TYPES),
39
+ formatType: FormatType.optional(),
40
+ format: z.string().optional(),
41
+ relatedKey: z.string().optional(),
42
+ relatedObject: z.string().optional(),
43
+ code: z.object({
44
+ language: z.string().default('plaintext'),
45
+ width: z.string().optional(),
46
+ height: z.string().optional(),
47
+ }).optional(),
48
+ securityConfig: SecurityConfigSpec.optional(),
49
+ link: LinkSpec.optional(),
50
+ });
51
+
52
+ const SelectStaticSpec = z.object({
53
+ options: z.array(z.object({ name: z.string(), value: z.string() })).optional(),
54
+ optionSource: z.string().optional(), // e.g. 'local.timezones', resolved by client static-option registry
55
+ }).refine((v) => (v.options && v.options.length > 0) || v.optionSource, {
56
+ message: 'select requires options[] or optionSource',
57
+ });
58
+
59
+ const EditEntrySpec = z.object({
60
+ field: z.string().min(1),
61
+ label: z.string().optional(),
62
+ required: z.boolean().default(false),
63
+ editableOnCreateOnly: z.boolean().default(false),
64
+ hiddenOnCreate: z.boolean().default(false),
65
+ fieldType: z.enum(['input', 'select', 'selectDynamic', 'textArea', 'code', 'spacer']),
66
+ fieldTypeSub: z.enum(['text', 'email', 'tel', 'url', 'number', 'date', 'datetime-local', 'time']).optional(),
67
+ placeholder: z.string().optional(),
68
+ textArea: z.object({ rows: z.number().int().positive().default(4) }).optional(),
69
+ code: z.object({ language: z.string().default('plaintext') }).optional(),
70
+ select: z.union([SelectStaticSpec, SelectDynamicSpec]).optional(),
71
+ }).superRefine((v, ctx) => {
72
+ if (v.fieldType === 'select' && !v.select) {
73
+ ctx.addIssue({ code: 'custom', message: 'select fieldType requires select config', path: ['select'] });
74
+ }
75
+ if (v.fieldType === 'selectDynamic' && !(v.select && 'queryConfig' in v.select)) {
76
+ ctx.addIssue({ code: 'custom', message: 'selectDynamic fieldType requires SelectDynamicSpec (queryConfig)', path: ['select'] });
77
+ }
78
+ });
79
+
80
+ // `component` stays z.string() (open), not a closed enum — see ASSUMPTION in
81
+ // §7 re: Open Decision #4 (component roadmap not yet settled).
82
+ export const FieldSpec = z.object({
83
+ id: z.string().min(1),
84
+ type: z.enum(['content', 'component', 'empty']),
85
+ object: z.string().optional(),
86
+ hideOnCreate: z.boolean().default(false),
87
+ conditions: ConditionSpec.optional(),
88
+ join: JoinSpec.optional(), // only meaningful when type:'component'
89
+ component: z.string().optional(),
90
+ componentConfig: z.record(z.unknown()).optional(), // NOT type-checked here — see Risks §7
91
+ display: FieldDisplaySpec.optional(),
92
+ edit: z.union([EditEntrySpec, z.array(EditEntrySpec)]).optional(), // array = composite (defect #7 contract)
93
+ }).superRefine((v, ctx) => {
94
+ if (v.type === 'content' && !v.object) {
95
+ ctx.addIssue({ code: 'custom', message: 'content field requires object', path: ['object'] });
96
+ }
97
+ if (v.type === 'component' && !v.component) {
98
+ ctx.addIssue({ code: 'custom', message: 'component field requires component', path: ['component'] });
99
+ }
100
+ if (v.display?.fieldType === 'composite' && !Array.isArray(v.edit)) {
101
+ ctx.addIssue({ code: 'custom', message: 'composite fieldType requires edit as an array', path: ['edit'] });
102
+ }
103
+ });
@@ -0,0 +1,23 @@
1
+ import { z } from 'zod';
2
+
3
+ // Canonical set of display-formatting behaviors, unified across the three
4
+ // duplicated call sites (Column.svelte, ModernField.svelte,
5
+ // SectionTableRowColumn.svelte) plus formatDisplayValue.js's switch — see
6
+ // maps.md §1.8. 'user' renders a linked-user chip (relatedKey/relatedObject
7
+ // pair on the owning FieldSpec/TableFieldSpec resolves who to show).
8
+ export const FormatType = z.enum([
9
+ 'timestamp', 'phone', 'currency', 'number', 'percentage', 'boolean', 'user',
10
+ 'securityBlurHover', 'securityBlurAlways', 'securityLastX', 'securityFirstX', 'securityFirstLastX',
11
+ 'none',
12
+ ]);
13
+
14
+ // `format` is the free-form, formatType-specific option string (e.g. a
15
+ // date-fns pattern for 'timestamp', a comma/rounded mode for 'number') —
16
+ // deliberately left as z.string() rather than a per-type union: the shapes
17
+ // are too varied (date pattern vs. numeric mode vs. a related-object field
18
+ // name for 'user') to usefully close without duplicating
19
+ // formatDisplayValue.js's switch here. Resolution stays a client concern.
20
+ export const FormatSpec = z.object({
21
+ formatType: FormatType,
22
+ format: z.string().optional(),
23
+ });
@@ -0,0 +1,16 @@
1
+ export * from './primitives.js';
2
+ export * from './format.js';
3
+ export * from './join.js';
4
+ export * from './selectDynamic.js';
5
+ export * from './field.js';
6
+ export * from './kanban.js';
7
+ export * from './relatedList.js';
8
+ export * from './action.js';
9
+ export * from './section.js';
10
+ export * from './compact.js';
11
+ export * from './layoutDoc.js';
12
+ export { validateLayoutDoc } from './validate.js';
13
+ export {
14
+ migrateLayoutSchema, migrateToLatest, MIGRATIONS, CURRENT_SCHEMA_VERSION,
15
+ } from './migrations/index.js';
16
+ export { migrateV1toV2 } from './migrations/migrateV1toV2.js';
@@ -0,0 +1,33 @@
1
+ import { z } from 'zod';
2
+
3
+ // Today's shape is {column, value} reused by three consumers with different
4
+ // meanings (maps.md §2.6, confirmed): table/component joins use `column` =
5
+ // child FK, `value` = Mustache template against the *parent* record (e.g.
6
+ // "{{id}}"); kanban child-records joins use {childField, parentField} where
7
+ // `parentField` names the field on the parent record directly (no template
8
+ // wrapper). Canonical shape unifies on the kanban naming (it's already
9
+ // un-templated and clearer) — table/component's `value:"{{x}}"` form is
10
+ // normalized to `parentField: "x"` by normalizeJoinSpec below.
11
+ export const JoinSpec = z.object({
12
+ childField: z.string().min(1)
13
+ .describe('FK column on the child/related object that stores the parent id (legacy: table/component join.column, kanban join.childField)'),
14
+ parentField: z.string().min(1).default('id')
15
+ .describe('Field read off the parent record to match (legacy: kanban join.parentField; table/component join.value as "{{parentField}}")'),
16
+ });
17
+
18
+ const MUSTACHE_RE = /^\{\{\s*([\w.]+)\s*\}\}$/;
19
+
20
+ // legacyKind: 'table' | 'component' | 'kanbanChildRecords' — all three raw
21
+ // shapes observed in maps.md §1.6/§1.7/§1.10 map onto one JoinSpec.
22
+ export function normalizeJoinSpec(raw, legacyKind) {
23
+ if (!raw) return undefined;
24
+ if (legacyKind === 'kanbanChildRecords') {
25
+ return { childField: raw.childField, parentField: raw.parentField || 'id' };
26
+ }
27
+ // table / component: {column, value: "{{field}}"}
28
+ const match = typeof raw.value === 'string' ? raw.value.match(MUSTACHE_RE) : null;
29
+ return {
30
+ childField: raw.column,
31
+ parentField: match ? match[1] : (raw.value || 'id'),
32
+ };
33
+ }
@@ -0,0 +1,94 @@
1
+ import { z } from 'zod';
2
+ import { JoinSpec } from './join.js';
3
+ import { FormatType } from './format.js';
4
+
5
+ // Reconciles the 3-modes-described-3-different-ways problem (maps.md §1.7)
6
+ // into one discriminated union, and replaces ad hoc cardFields[] with
7
+ // compactLayoutId (inline cardFields[] still tolerated, per V2-PLAN §1.4:
8
+ // "with inline override still allowed").
9
+ const KanbanCardFieldRef = z.object({
10
+ field: z.string().min(1),
11
+ display: z.string().optional(),
12
+ formatType: FormatType.optional(),
13
+ format: z.string().optional(),
14
+ type: z.enum(['link']).optional(),
15
+ linkObject: z.string().optional(),
16
+ linkField: z.string().optional(),
17
+ });
18
+
19
+ const KanbanCardSourceSpec = z.object({
20
+ compactLayoutId: z.string().optional(), // canonical, v2-preferred
21
+ cardFields: z.array(KanbanCardFieldRef).optional(), // legacy inline fallback, tolerated indefinitely
22
+ }).refine((v) => v.compactLayoutId || (v.cardFields && v.cardFields.length > 0), {
23
+ message: 'kanban requires compactLayoutId or a non-empty cardFields[]',
24
+ });
25
+
26
+ const KanbanSummarySpec = z.object({
27
+ type: z.enum(['count', 'sum', 'avg', 'min', 'max']),
28
+ field: z.string().optional(),
29
+ enabled: z.boolean().default(true),
30
+ formatType: z.enum(['currency', 'number']).optional(),
31
+ format: z.string().optional(),
32
+ }).refine((v) => v.type === 'count' || !!v.field, {
33
+ message: 'field is required unless type is "count"', path: ['field'],
34
+ });
35
+
36
+ const KanbanActionsSpec = z.object({
37
+ create: z.boolean().default(false),
38
+ edit: z.boolean().default(true),
39
+ delete: z.boolean().default(false),
40
+ cardClick: z.enum(['tab', 'modal', 'none']).default('modal'),
41
+ });
42
+
43
+ const KanbanShared = z.object({
44
+ summaries: z.array(KanbanSummarySpec).default([]),
45
+ actions: KanbanActionsSpec.default({}),
46
+ });
47
+
48
+ const KanbanSimple = KanbanCardSourceSpec.and(KanbanShared).and(z.object({
49
+ mode: z.literal('simple'),
50
+ columnField: z.string().min(1),
51
+ columnSort: z.enum(['asc', 'desc']).default('asc'),
52
+ }));
53
+
54
+ const KanbanRelated = KanbanCardSourceSpec.and(KanbanShared).and(z.object({
55
+ mode: z.literal('related'),
56
+ configObject: z.string().min(1),
57
+ configObjectField: z.string().min(1),
58
+ stagesObject: z.string().min(1),
59
+ stagesColumnField: z.string().min(1),
60
+ stagesOrderField: z.string().optional(),
61
+ stagesSortDirection: z.enum(['asc', 'desc']).default('asc'),
62
+ relationship: z.object({
63
+ configToStages: z.object({ field: z.string().min(1), relatedField: z.string().min(1) }),
64
+ mainToConfig: z.object({ field: z.string().min(1), relatedField: z.string().min(1) }),
65
+ }),
66
+ stageMapping: z.object({ mainField: z.string().min(1), stageField: z.string().min(1) }),
67
+ autoSelect: z.object({
68
+ enabled: z.boolean().default(false),
69
+ field: z.string().optional(),
70
+ value: z.string().optional(),
71
+ }).default({ enabled: false }),
72
+ }));
73
+
74
+ const KanbanChildRecords = KanbanCardSourceSpec.and(KanbanShared).and(z.object({
75
+ mode: z.literal('child-records'),
76
+ childObject: z.string().min(1),
77
+ join: JoinSpec,
78
+ stageMapping: z.object({
79
+ field: z.string().min(1),
80
+ lookupObject: z.string().optional(),
81
+ lookupDisplayField: z.string().optional(),
82
+ lookupOrderField: z.string().optional(),
83
+ filterByParent: z.boolean().default(false),
84
+ stageParentField: z.string().optional(),
85
+ parentRecordField: z.string().optional(),
86
+ }),
87
+ }));
88
+
89
+ // z.union (not discriminatedUnion) because each branch is itself a .and()
90
+ // intersection — zod's discriminatedUnion requires each member to be a bare
91
+ // ZodObject with the literal at the top level, which intersections aren't.
92
+ // Runtime cost is negligible (3 candidates, small objects); revisit if this
93
+ // list grows.
94
+ export const KanbanConfigSpec = z.union([KanbanSimple, KanbanRelated, KanbanChildRecords]);
@@ -0,0 +1,35 @@
1
+ import { z } from 'zod';
2
+ import { MustacheTemplate } from './primitives.js';
3
+ import { SectionSpec } from './section.js';
4
+ import { ActionSpec } from './action.js';
5
+
6
+ const ToggleSpec = z.object({
7
+ enabled: z.boolean().default(false),
8
+ hideOnCreate: z.boolean().default(false),
9
+ });
10
+
11
+ export const LayoutDoc = z.object({
12
+ schemaVersion: z.literal(2).default(2),
13
+ objectName: z.string().min(1), // canonical key — legacy `object` normalized away by migrateV1toV2
14
+ type: z.enum(['list', 'detail']), // 'compact' docs validate against CompactLayoutDoc instead
15
+ tabName: MustacheTemplate.default('{{objectName}}'),
16
+ tabIcon: z.string().default('fa-database'),
17
+ sections: z.array(SectionSpec).default([]),
18
+ feeds: ToggleSpec.optional(),
19
+ notes: ToggleSpec.optional(),
20
+ aiInsights: ToggleSpec.optional(),
21
+ aiGoals: ToggleSpec.optional(),
22
+ googleDrive: z.object({
23
+ enabled: z.boolean().default(false),
24
+ hideOnCreate: z.boolean().default(false),
25
+ sharedDriveId: z.string().optional(),
26
+ folderPath: z.string().optional(),
27
+ }).optional(),
28
+ filterPanel: z.object({
29
+ enabled: z.boolean().default(true),
30
+ defaultCollapsed: z.boolean().default(false),
31
+ }).optional(),
32
+ // v2 canonical: array of ActionSpec, replaces v1's bespoke {create?, edit?}
33
+ // object — see migrations/deriveActions.js for the additive v1→v2 mapping.
34
+ actions: z.array(ActionSpec).default([]),
35
+ });
@@ -0,0 +1,70 @@
1
+ // Pure fn, no zod import. Maps legacy `layout.actions{create,edit}` (a
2
+ // bespoke boolean-flag object — real stored docs mostly have this as `{}`)
3
+ // plus per-table action booleans (TableSpec.actions.{create,edit,delete})
4
+ // into the v2-canonical ActionSpec[] shape (schemas/layouts/action.js).
5
+ // Additive only: if `doc.actions` is already an array (already v2-shaped),
6
+ // it's passed through unchanged rather than re-derived.
7
+ //
8
+ // `skipTableIds` (optional Set, Phase 5 addition): table ids already
9
+ // promoted to RelatedListSpec by promoteRelatedLists.js. A promoted table's
10
+ // create/delete already surface via the promoted section-placed ActionSpec
11
+ // (create) and RelatedListSpec.rowActions.delete (delete) — deriving a
12
+ // second, row-placed ActionSpec here for the same table would double the
13
+ // affordance. See migrateV1toV2.js for how the two derivations are composed.
14
+ export function deriveActions(doc, { skipTableIds } = {}) {
15
+ if (Array.isArray(doc.actions)) {
16
+ return doc.actions;
17
+ }
18
+
19
+ const legacy = doc.actions || {};
20
+ const objectName = doc.objectName || doc.object;
21
+ const skip = skipTableIds || new Set();
22
+ const derived = [];
23
+
24
+ if (legacy.create) {
25
+ derived.push({
26
+ id: 'header-create',
27
+ type: 'create',
28
+ target: objectName,
29
+ mode: 'modal',
30
+ placement: 'header',
31
+ });
32
+ }
33
+ if (legacy.edit) {
34
+ derived.push({
35
+ id: 'header-edit',
36
+ type: 'edit',
37
+ target: objectName,
38
+ mode: 'modal',
39
+ placement: 'header',
40
+ });
41
+ }
42
+
43
+ // Per-table create/delete booleans surface as row-placed ActionSpecs so
44
+ // Phase 4's action registry has one uniform ActionSpec[] to read,
45
+ // regardless of where the boolean originally lived. (Per-table `edit` is
46
+ // deliberately not derived: it's the default row-click behavior already,
47
+ // not a distinct affordance — see TableSpec.actions.edit default:true.)
48
+ for (const section of doc.sections || []) {
49
+ const tables = section.tables || (section.object ? [section] : []);
50
+ for (const table of tables) {
51
+ if (!table.actions) continue;
52
+ const tableId = table.id || section.id;
53
+ if (skip.has(tableId)) continue;
54
+ if (table.actions.create) {
55
+ derived.push({
56
+ id: `${tableId}-create`, type: 'create', target: table.object,
57
+ mode: 'modal', placement: 'row',
58
+ });
59
+ }
60
+ if (table.actions.delete) {
61
+ derived.push({
62
+ id: `${tableId}-delete`, type: 'delete', target: table.object,
63
+ mode: 'modal', placement: 'row',
64
+ });
65
+ }
66
+ }
67
+ }
68
+
69
+ return derived;
70
+ }
@@ -0,0 +1,19 @@
1
+ // Pure fn, no zod import. Flattens a detail-shaped `sections[]` doc into
2
+ // CompactLayoutDoc.fields[] when `type==='compact'` — only invoked by
3
+ // migrateV1toV2 when a compact-typed legacy doc has `sections` but no
4
+ // `fields` yet (see migrateV1toV2.js). CompactLayoutDoc caps at 12 fields
5
+ // (schemas/layouts/compact.js), so this stops collecting once it hits that.
6
+ export function deriveCompactFields(doc) {
7
+ const fields = [];
8
+
9
+ for (const section of doc.sections || []) {
10
+ for (const row of section.rows || []) {
11
+ for (const column of row.columns || []) {
12
+ fields.push(column);
13
+ if (fields.length >= 12) return fields;
14
+ }
15
+ }
16
+ }
17
+
18
+ return fields;
19
+ }
@@ -0,0 +1,43 @@
1
+ import { migrateV1toV2 } from './migrateV1toV2.js';
2
+
3
+ export const CURRENT_SCHEMA_VERSION = 2;
4
+
5
+ // v0 -> v1: no-op placeholder. No genuinely un-versioned document has ever
6
+ // existed in production (schemaVersion was introduced alongside v2 itself),
7
+ // but the step is defined so migrateToLatest's version-walking loop has no
8
+ // gap for the `doc.schemaVersion` absent/0 default case — see
9
+ // SPEC-PHASE-5.md §2.1's registry sample.
10
+ function v0ToV1(json) {
11
+ return { json, schemaVersion: 1, changes: [] };
12
+ }
13
+
14
+ export const MIGRATIONS = { 1: v0ToV1, 2: migrateV1toV2 };
15
+
16
+ /**
17
+ * Runs every migration step from `fromVersion` (exclusive) up to
18
+ * CURRENT_SCHEMA_VERSION (inclusive), threading `changes[]` through.
19
+ * @param {object} json
20
+ * @param {number} fromVersion
21
+ * @param {{ objectName?: string, layoutKind?: 'list'|'detail'|'compact' }} [ctx]
22
+ * @returns {{ json: object, schemaVersion: number, changes: string[] }}
23
+ */
24
+ export function migrateToLatest(json, fromVersion, ctx) {
25
+ let out = { json, schemaVersion: fromVersion || 0, changes: [] };
26
+ for (let v = (fromVersion || 0) + 1; v <= CURRENT_SCHEMA_VERSION; v++) {
27
+ const step = MIGRATIONS[v];
28
+ if (!step) continue;
29
+ const result = step(out.json, ctx);
30
+ out = { json: result.json, schemaVersion: v, changes: [...out.changes, ...result.changes] };
31
+ }
32
+ return out;
33
+ }
34
+
35
+ // Convenience wrapper: migrate a stored doc, reading its own schemaVersion
36
+ // (public API — announced in CHANGELOG.md 4.2.0 as
37
+ // `sdk.layouts.schema.migrateLayoutSchema(doc)`). Docs that predate the
38
+ // schemaVersion column entirely default to 0 (equivalent in practice to 1 —
39
+ // the v0->v1 step is a no-op — since either way only migrateV1toV2 does
40
+ // real work).
41
+ export function migrateLayoutSchema(doc, ctx = {}) {
42
+ return migrateToLatest(doc, doc?.schemaVersion ?? 0, ctx);
43
+ }
@@ -0,0 +1,162 @@
1
+ import { normalizeJoin } from './normalizeJoin.js';
2
+ import { normalizeKanban } from './normalizeKanban.js';
3
+ import { deriveActions } from './deriveActions.js';
4
+ import { deriveCompactFields } from './deriveCompactFields.js';
5
+ import { promoteRelatedLists } from './promoteRelatedLists.js';
6
+
7
+ /**
8
+ * @param {object} doc
9
+ * @param {string[]} changes
10
+ * @returns {{ rest: object, objectName: string }}
11
+ */
12
+ // SPEC-PHASE-5.md §2.1 step 1. `object`/`objectName` are written together,
13
+ // same value, by loadLayoutFromJson (client, builder) — canonicalize to
14
+ // `objectName`, drop `object`. On drift (both present, disagree), keep
15
+ // objectName's value per `edit/[id]/+page.svelte`'s own source-of-truth
16
+ // convention, and record the drift for backfill-sweep review rather than
17
+ // silently picking one.
18
+ function canonicalizeObjectName(doc, changes) {
19
+ const { object, objectName, ...rest } = doc;
20
+ if (object && objectName && object !== objectName) {
21
+ changes.push(`objectName: drift detected (object="${object}" vs objectName="${objectName}") — kept objectName`);
22
+ } else if (object && !objectName) {
23
+ changes.push('objectName: canonicalized from legacy "object" key');
24
+ }
25
+ return { rest, objectName: objectName || object };
26
+ }
27
+
28
+ // TableSpec.join (schemas/layouts/section.js) is canonical-JoinSpec-only —
29
+ // unlike the raw document's other legacy keys, there's no dual
30
+ // legacy-shape + relationship pair for a table's join, so a successful
31
+ // normalization REPLACES `join` in place. A skipped (carve-out) join is
32
+ // dropped entirely rather than left as a legacy shape that would fail
33
+ // JoinSpec validation — TableSpec.join is `.optional()` precisely for this
34
+ // ("absent = top-level list table, no parent record").
35
+ function normalizeTableJoin(table, changes, sectionId) {
36
+ if (!table.join) return table;
37
+ const { relationship, skipped } = normalizeJoin(table.join, 'table');
38
+ if (skipped) {
39
+ changes.push(`section "${sectionId}" table "${table.id}": join left as legacy shape (non-trivial template or missing column)`);
40
+ const { join, ...rest } = table;
41
+ return rest;
42
+ }
43
+ changes.push(`section "${sectionId}" table "${table.id}": join normalized to relationship`);
44
+ return { ...table, join: relationship };
45
+ }
46
+
47
+ // SPEC-PHASE-5.md §2.1 step 2. Wraps a legacy flat single-table section
48
+ // (section.object/fields/join/actions/orderBy/additionalWhere, no
49
+ // tables[]) into `section.tables = [{...}]`, field-for-field — does NOT
50
+ // reuse sectionOperations.js:initializeTablesArray (client-side), which
51
+ // builds a *blank* table config and would discard the existing values (see
52
+ // spec §2.1 step 2 and the sibling app1-client work item fixing that
53
+ // function for the same reason). `header` is carried into the new table
54
+ // (multi-table sections can give each table its own sub-header) but also
55
+ // LEFT at the section level, since BaseSection.header is required and this
56
+ // same value is what the section's own title already reads today.
57
+ function wrapLegacyTable(section, changes) {
58
+ const id = section.id ? `${section.id}-table` : 'legacy-table';
59
+ changes.push(`section "${section.id}": legacy single-table shape wrapped into tables[]`);
60
+ const { object, fields, join, actions, orderBy, additionalWhere, ...rest } = section;
61
+ const table = normalizeTableJoin({
62
+ id, object, fields: fields || [], join, actions: actions || {}, orderBy, additionalWhere,
63
+ header: section.header,
64
+ }, changes, section.id);
65
+ return { ...rest, tables: [table] };
66
+ }
67
+
68
+ // FieldSpec.join (component-typed fields) is likewise canonical-only — see
69
+ // normalizeTableJoin above. DEVIATION from SPEC-PHASE-5.md's literal step
70
+ // list, which doesn't call out component fields separately: real stored
71
+ // data (examples.json's company-detail "Related Engagements" section) has
72
+ // `field.join = {column, value}` on a type:'component' field, using the
73
+ // same 3-consumer semantics table row as component joins generally. Added
74
+ // per the same reasoning Phase 1 already documented for this exact gap.
75
+ function normalizeRows(rows, changes) {
76
+ if (!Array.isArray(rows)) return rows;
77
+ return rows.map((row) => ({
78
+ ...row,
79
+ columns: (row.columns || []).map((column) => {
80
+ if (column.type !== 'component' || !column.join) return column;
81
+ const { relationship, skipped } = normalizeJoin(column.join, 'component');
82
+ if (skipped) {
83
+ changes.push(`field "${column.id}": join left as legacy shape (non-trivial template or missing column)`);
84
+ return column;
85
+ }
86
+ changes.push(`field "${column.id}": component join normalized to relationship`);
87
+ return { ...column, join: relationship };
88
+ }),
89
+ }));
90
+ }
91
+
92
+ // DEVIATION (Phase 5, found reading real fixture data, not called out in
93
+ // SPEC-PHASE-5.md): a section can carry `type: "table"` while also having
94
+ // a `kanban` block, `tables[]`, and `defaultView` — i.e. it's actually a
95
+ // table-kanban hybrid, but the builder never updated `type` when the
96
+ // kanban toggle was added (examples.json's company-detail "Opportunities"
97
+ // section, id "section4"). SectionSpec's discriminated union has no
98
+ // `kanban` key on the `type:'table'` branch (TableSection), so leaving the
99
+ // tag wrong means a `.safeParse()` silently drops the kanban config from
100
+ // validated output. Retag before validation ever sees it.
101
+ function normalizeSectionType(section, changes) {
102
+ if (section.type === 'table' && section.kanban && (section.tables || section.object)) {
103
+ changes.push(`section "${section.id}": type normalized "table" -> "table-kanban" (kanban block present alongside tables)`);
104
+ return 'table-kanban';
105
+ }
106
+ return section.type;
107
+ }
108
+
109
+ /**
110
+ * @param {object} layoutJson - raw stored layout.layout / layoutVersions.layoutJson (schemaVersion 0/1)
111
+ * @param {{ objectName?: string, layoutKind?: 'list'|'detail'|'compact' }} [ctx]
112
+ * @returns {{ json: object, schemaVersion: 2, changes: string[] }}
113
+ */
114
+ export function migrateV1toV2(layoutJson, ctx = {}) {
115
+ const changes = [];
116
+ const { rest, objectName: docObjectName } = canonicalizeObjectName(layoutJson, changes);
117
+ const objectName = docObjectName || ctx.objectName;
118
+
119
+ const promotedCreateActions = [];
120
+ const allPromotedIds = new Set();
121
+
122
+ const sections = (rest.sections || []).map((section) => {
123
+ let next = { ...section, type: normalizeSectionType(section, changes), rows: normalizeRows(section.rows, changes) };
124
+
125
+ if (next.type === 'table' && !section.tables && section.object) {
126
+ next = wrapLegacyTable(next, changes);
127
+ } else if (section.tables) {
128
+ next.tables = section.tables.map((t) => normalizeTableJoin(t, changes, section.id));
129
+ }
130
+
131
+ if (section.kanban) {
132
+ next.kanban = normalizeKanban(section.kanban, changes, section.id);
133
+ }
134
+
135
+ if (next.tables && next.tables.length) {
136
+ const { relatedLists, createActions, promotedIds } = promoteRelatedLists(next, objectName, changes);
137
+ if (relatedLists.length) next.relatedLists = relatedLists;
138
+ promotedCreateActions.push(...createActions);
139
+ promotedIds.forEach((id) => allPromotedIds.add(id));
140
+ }
141
+
142
+ return next;
143
+ });
144
+
145
+ const migrated = {
146
+ ...rest,
147
+ schemaVersion: 2,
148
+ objectName,
149
+ sections,
150
+ actions: [
151
+ ...deriveActions({ ...rest, objectName, sections }, { skipTableIds: allPromotedIds }),
152
+ ...promotedCreateActions,
153
+ ],
154
+ };
155
+
156
+ if (rest.type === 'compact' && rest.sections && !rest.fields) {
157
+ migrated.fields = deriveCompactFields(rest);
158
+ changes.push('compact: derived fields[] from sections[] rows/columns');
159
+ }
160
+
161
+ return { json: migrated, schemaVersion: 2, changes };
162
+ }