@xeplr/ui-factory 1.0.1 → 1.0.3

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/src/model.js CHANGED
@@ -3,13 +3,20 @@
3
3
  //
4
4
  // import { screenFromSpec, validateDocument, formSchema } from '@xeplr/ui-factory/model'
5
5
 
6
- export { CONTROLS, OPTION_ID, OPTION_LABEL, LABEL_VARIANTS, BUTTON_ACTIONS, controlGroups, withControls } from './controls.js'
6
+ export { CONTROLS, OPTION_ID, OPTION_LABEL, LABEL_VARIANTS, LABEL_PRESETS, LIST_ACTIONS, STYLE_KEYS, SCREEN_STYLE_KEYS, FONT_FAMILIES, controlGroups, withControls } from './controls.js'
7
7
  export {
8
- DOCUMENT_KIND, DOCUMENT_VERSION, DEFAULT_ASPECT, MARGIN, GAP,
9
- createScreen, renameScreen, addControl, moveNode, setNodeProperty, getNodeProperty, removeNodes,
8
+ DOCUMENT_KIND, DOCUMENT_VERSION, DEFAULT_ASPECT, DEFAULT_WIDTH, MARGIN, GAP,
9
+ createScreen, renameScreen, setScreenProperty, addControl, moveNode, setNodeProperty, getNodeProperty, removeNodes,
10
10
  inputNodes, readingOrder, contentBottom, camelName, slugify, uniqueFieldName, nextPosition
11
11
  } from './document.js'
12
12
  export { validateDocument, assertValidDocument } from './validateDocument.js'
13
- export { formSchema, initialValues, parseInput, validateValues, fieldError, sameId, optionValue } from './values.js'
14
- export { screenFromSpec, SPEC_HEIGHTS, ROW_GAP } from './generate.js'
13
+ export { formSchema, initialValues, parseInput, validateValues, fieldError, sameId, optionValue, saveState, recordValues, displayValue, listColumns, listSource } from './values.js'
14
+ export { screenFromSpec, screensFromSpec, entityNames, SPEC_HEIGHTS, ROW_GAP } from './generate.js'
15
+ export { scaffoldEntity } from './scaffold.js'
15
16
  export { getAtPath, setAtPath } from './propertyPath.js'
17
+ export { createFactoryApi } from './remote.js'
18
+ export {
19
+ STANDARD_COLUMNS, RESERVED_COLUMNS, MAX_IDENTIFIER,
20
+ columnForField, tableForScreen, columnSql, widening, diffTables, migrationFor, nextMigrationName,
21
+ createStatements, addStatements, alterStatement, dropStatement, columnFromDatabase, planTableChange
22
+ } from './tableSchema.js'
package/src/pages.jsx CHANGED
@@ -1,16 +1,20 @@
1
1
  import { useFactoryBuilder } from './useFactoryBuilder.js'
2
2
  import { useFactoryScreen } from './useFactoryScreen.js'
3
- import { BuilderSample, ScreenSample } from './designs/index.js'
3
+ import { BuilderSample, ScreenSample, ScreenModal } from './designs/index.js'
4
4
 
5
5
  /**
6
- * The builder, ready-made.
6
+ * The builder, ready-made. Saves itself — there is no Save button.
7
7
  *
8
8
  * @prop document the screen to edit (omit for a new one)
9
9
  * @prop name name for a new screen
10
- * @prop onSave async (document) → void — the app stores it; only called with a valid document
10
+ * @prop onSave async (document) → void — called automatically after edits, only with a valid document
11
11
  * @prop onChange (document) → void — every edit
12
- * @prop listTables async () → [{ id, name }] | string[] — for "dropdown from a table"
13
- * @prop fetchOptions async ({ table }) → [{ id, name }] — enables Preview with real options
12
+ * @prop onPublish async (document) → { version } — shows a Publish button; throw to refuse, with `detail` to show (e.g. the migration to run)
13
+ * @prop listTables async () → [{ id, name }] | string[] — for "Saves to", lists and table dropdowns
14
+ * @prop fetchOptions async ({ table }) → [{ id, name }] — Preview with real options
15
+ * @prop fetchRecords async ({ source }) → records — Preview with real records
16
+ * @prop lockedNames field names that are already columns of the table — shown read-only
17
+ * @prop screens [{ id, name, document? }] — other screens: "Edit in" choices, and Preview's popups
14
18
  * @prop controls control registry (default: built-ins)
15
19
  */
16
20
  export function FactoryBuilder(props) {
@@ -21,22 +25,70 @@ export function FactoryBuilder(props) {
21
25
  className={props.className}
22
26
  style={props.style}
23
27
  renderPreview={(doc) => (
24
- <FactoryScreen document={doc} fetchOptions={props.fetchOptions} controls={props.controls} />
28
+ // Preview never writes: onSave/onDelete are left out on purpose.
29
+ <FactoryScreen
30
+ document={doc}
31
+ fetchOptions={props.fetchOptions}
32
+ fetchRecords={props.fetchRecords}
33
+ screens={Object.fromEntries((props.screens || []).filter((s) => s.document).map((s) => [s.id, s.document]))}
34
+ controls={props.controls}
35
+ />
25
36
  )}
26
37
  />
27
38
  )
28
39
  }
29
40
 
30
41
  /**
31
- * A saved screen, running.
42
+ * A saved screen, running. Saves itself in the background — there is no submit.
32
43
  *
33
44
  * @prop document a screen document (an invalid one renders its problems, loudly)
45
+ * @prop record the record to open (omit for a new one)
46
+ * @prop recordKey the id field on a record (default 'id')
47
+ * @prop onSave async (values, { id, source, document }) → the saved record, with its id
34
48
  * @prop fetchOptions async ({ table }) → [{ id, name }] — rows for table dropdowns
35
- * @prop onSubmit async (values, { document }) → void
36
- * @prop values starting values, e.g. the record being edited
49
+ * @prop fetchRecords async ({ source }) → records — rows for lists
50
+ * @prop fetchRecord async ({ screen, id }) → record — Edit loads the record fresh
51
+ * @prop onDelete async ({ id, source, record }) → void
52
+ * @prop onChange (values) → void
53
+ * @prop screens { id → document } — the screens a list's Edit / New open in a popup
54
+ * @prop loadScreen async (id) → document — for screens not in `screens`
37
55
  * @prop controls control registry (default: built-ins)
38
56
  */
39
57
  export function FactoryScreen(props) {
40
58
  const ctrl = useFactoryScreen(props)
41
- return <ScreenSample ctrl={ctrl} className={props.className} style={props.style} />
59
+ return (
60
+ <ScreenSample
61
+ ctrl={ctrl}
62
+ className={props.className}
63
+ style={props.style}
64
+ renderPopup={(popup) => (
65
+ <ScreenModal
66
+ title={popup.document ? popup.document.name : 'Loading…'}
67
+ width={popup.document ? popup.document.width : undefined}
68
+ onClose={ctrl.closeEditor}
69
+ >
70
+ {popup.error && <div className="xeplr-factory-invalid" role="alert">{popup.error}</div>}
71
+ {popup.loading && <div className="xeplr-factory-hint">Loading…</div>}
72
+ {popup.document && (
73
+ // The same component, one level down: the edit screen saves itself,
74
+ // and every save refreshes the list behind the popup.
75
+ <FactoryScreen
76
+ key={`${popup.screenId}:${popup.record ? popup.record[ctrl.recordKey] : 'new'}`}
77
+ document={popup.document}
78
+ record={popup.record}
79
+ recordKey={props.recordKey}
80
+ onSave={ctrl.popupSave}
81
+ fetchOptions={props.fetchOptions}
82
+ fetchRecord={props.fetchRecord}
83
+ fetchRecords={props.fetchRecords}
84
+ onDelete={props.onDelete}
85
+ screens={props.screens}
86
+ loadScreen={props.loadScreen}
87
+ controls={props.controls}
88
+ />
89
+ )}
90
+ </ScreenModal>
91
+ )}
92
+ />
93
+ )
42
94
  }
package/src/remote.js ADDED
@@ -0,0 +1,66 @@
1
+ // The browser side of @xeplr/factory: every call FactoryScreen and
2
+ // FactoryBuilder need, as requests to its routes. Records live in REAL tables
3
+ // in the app's database; this only sends and receives.
4
+ //
5
+ // import { authFetch } from '@xeplr/ui-account'
6
+ // const factory = createFactoryApi({ fetch: authFetch })
7
+ // <FactoryScreen document={screen} {...factory.screenProps} />
8
+ // <FactoryBuilder document={screen} {...factory.builderProps} lockedNames={…} />
9
+ //
10
+ // Responses are xeplr's { code, message, error, dataArray }.
11
+
12
+ /**
13
+ * @param options.fetch a fetch that adds auth and tenant headers (e.g. @xeplr/ui-account's authFetch)
14
+ * @param options.base prefix before /factory, if the router is mounted elsewhere (default '')
15
+ */
16
+ export function createFactoryApi({ fetch: doFetch, base = '' } = {}) {
17
+ if (typeof doFetch !== 'function') throw new Error('createFactoryApi: pass { fetch } — usually authFetch from @xeplr/ui-account')
18
+
19
+ async function call(method, path, body) {
20
+ const res = await doFetch(`${base}/factory${path}`, {
21
+ method,
22
+ headers: body === undefined ? undefined : { 'Content-Type': 'application/json' },
23
+ body: body === undefined ? undefined : JSON.stringify(body)
24
+ })
25
+ let json = null
26
+ try { json = await res.json() } catch (_) { /* not JSON — reported below */ }
27
+ if (!res.ok) {
28
+ const err = new Error((json && json.message) || `${method} ${path} failed (${res.status})`)
29
+ err.status = res.status
30
+ err.fields = json && json.error && json.error.fields
31
+ const extra = (json && json.dataArray && json.dataArray[0]) || {}
32
+ // Publish: columns it would drop, waiting for a yes — and what it keeps.
33
+ err.confirm = extra.confirm
34
+ err.keep = extra.keep
35
+ err.refused = extra.refused
36
+ if (extra.statements) err.detail = extra.statements.join('\n')
37
+ throw err
38
+ }
39
+ return json ? json.dataArray : []
40
+ }
41
+ const one = (rows) => (Array.isArray(rows) ? rows[0] : rows)
42
+ const enc = encodeURIComponent
43
+
44
+ const api = {
45
+ // screens
46
+ listScreens: () => call('GET', '/screens'),
47
+ loadScreen: async (key) => one(await call('GET', `/screens/${enc(key)}`)).document,
48
+ loadDraft: async (key) => one(await call('GET', `/screens/${enc(key)}?draft=true`)),
49
+ saveDraft: (doc) => call('PUT', `/screens/${enc(doc.id)}/draft`, { document: doc }),
50
+ // Changes the screen's table directly. Rejects with err.confirm = [{ column, records }]
51
+ // when a removed field would drop a column; call again with { confirmDrop: [names] }.
52
+ publish: async (doc, options) => one(await call('POST', `/screens/${enc(doc.id)}/publish`, { confirmDrop: (options && options.confirmDrop) || [] })),
53
+ listTables: () => call('GET', '/tables'),
54
+
55
+ // records
56
+ fetchRecords: ({ screen }) => call('GET', `/records/${enc(screen)}`),
57
+ // One record, through the screen's get hooks — what Edit opens.
58
+ fetchRecord: async ({ screen, id }) => one(await call('GET', `/records/${enc(screen)}/${enc(id)}`)),
59
+ onSave: async (values, { id, screen }) => one(await call('POST', `/records/${enc(screen)}/save`, { id, values })),
60
+ onDelete: ({ id, screen }) => call('POST', `/records/${enc(screen)}/delete`, { id }),
61
+ fetchOptions: ({ table }) => call('GET', `/options/${enc(table)}`)
62
+ }
63
+ api.screenProps = { loadScreen: api.loadScreen, onSave: api.onSave, fetchRecords: api.fetchRecords, fetchRecord: api.fetchRecord, onDelete: api.onDelete, fetchOptions: api.fetchOptions }
64
+ api.builderProps = { onSave: api.saveDraft, onPublish: api.publish, listTables: api.listTables, fetchOptions: api.fetchOptions, fetchRecords: api.fetchRecords }
65
+ return api
66
+ }
@@ -0,0 +1,116 @@
1
+ // THE FILES an entity becomes in an app.
2
+ //
3
+ // Asked for "a form for employees", Claude produces two screens and two
4
+ // pages that show them:
5
+ //
6
+ // employee-list.screen.json EmployeeList.jsx — the list; Edit / New open a popup
7
+ // employee-edit.screen.json EditEmployee.jsx — the add / edit form, also a page on its own
8
+ // employee.hooks.js — the server's hooks for its records (all empty)
9
+ //
10
+ // The pages are thin on purpose: a screen document and the app's data calls,
11
+ // nothing else. Changing what a screen shows is editing its JSON in the
12
+ // designer, never these files.
13
+ //
14
+ // Pure: returns { path → contents }. Writing them is the caller's (the CLI).
15
+
16
+ import { screensFromSpec, entityNames } from './generate.js'
17
+
18
+ /**
19
+ * @param spec as screensFromSpec
20
+ * @returns {{ files: Object<string, string>, names, list, edit }}
21
+ */
22
+ export function scaffoldEntity(spec, controls) {
23
+ const { list, edit } = screensFromSpec(spec, controls)
24
+ const names = entityNames(spec.entity, spec.plural)
25
+ const listJson = `${names.file}-list.screen.json`
26
+ const editJson = `${names.file}-edit.screen.json`
27
+
28
+ const files = {
29
+ [listJson]: JSON.stringify(list, null, 2) + '\n',
30
+ [editJson]: JSON.stringify(edit, null, 2) + '\n',
31
+ [`${names.listComponent}.jsx`]: listPage(names, listJson, editJson, list, edit),
32
+ [`${names.editComponent}.jsx`]: editPage(names, editJson, edit),
33
+ [`${names.file}.hooks.js`]: hooksFile(names, edit)
34
+ }
35
+ return { files, names, list, edit }
36
+ }
37
+
38
+ function listPage(n, listJson, editJson, list, edit) {
39
+ return `import { FactoryScreen } from '@xeplr/ui-factory'
40
+ import listScreen from './${listJson}'
41
+ import editScreen from './${editJson}'
42
+
43
+ // ${n.pluralTitle} — the saved ${list.source}, with New / Edit / Delete.
44
+ // Edit and New open "${edit.name}" (${edit.id}) in a popup.
45
+ //
46
+ // \`api\` is the app's data calls — the factory never touches a database:
47
+ // onSave(values, { id, source }) → the saved record, with its id
48
+ // fetchRecords({ source }) → records
49
+ // onDelete({ id, source, record })
50
+ // fetchOptions({ table }) → [{ id, name }] for dropdowns
51
+ export default function ${n.listComponent}({ api }) {
52
+ return (
53
+ <FactoryScreen
54
+ document={listScreen}
55
+ screens={{ [editScreen.id]: editScreen }}
56
+ {...api}
57
+ />
58
+ )
59
+ }
60
+ `
61
+ }
62
+
63
+ function hooksFile(n, edit) {
64
+ return `// Server hooks for ${n.singular} records — screen "${edit.id}" (and "${n.key}_list", which edits in it).
65
+ //
66
+ // Register with @xeplr/factory:
67
+ // factory.init({ knex, hooks: { ${edit.id}: require('./${n.file}.hooks') } })
68
+ //
69
+ // Each operation has four hooks. Delete the ones you do not need.
70
+ // before(ctx) first — save: return new values to replace them (they are then checked
71
+ // against the screen's rules); get: narrow ctx.query; any: ctx.reject(message, { field })
72
+ // after(ctx) once it succeeded — ctx.id, ctx.result; return a value to replace the result.
73
+ // If it throws, the operation still happened (and error is told).
74
+ // error(ctx, err) when the operation failed
75
+ // override(ctx) does the WHOLE operation instead — no rules, no before / after / error,
76
+ // no generic query. What it returns is the response.
77
+ //
78
+ // ctx: op, screenKey, screen, table, id, isNew (save), many (get), input (what the UI sent, read-only),
79
+ // values (save), previous (save / delete: the row before), result, user, tenant, knex, query (get), reject
80
+
81
+ module.exports = {
82
+ save: {
83
+ // before: async function(ctx) { return ctx.values },
84
+ // after: async function(ctx) {},
85
+ // error: async function(ctx, err) {},
86
+ // override: async function(ctx) { return savedRecord },
87
+ },
88
+ get: {
89
+ // before: async function(ctx) { /* ctx.query.where(...) */ },
90
+ // after: async function(ctx) { return ctx.result },
91
+ // error: async function(ctx, err) {},
92
+ // override: async function(ctx) { return ctx.id ? record : records },
93
+ },
94
+ delete: {
95
+ // before: async function(ctx) {},
96
+ // after: async function(ctx) {},
97
+ // error: async function(ctx, err) {},
98
+ // override: async function(ctx) { return { id: ctx.id } },
99
+ }
100
+ }
101
+ `
102
+ }
103
+
104
+ function editPage(n, editJson, edit) {
105
+ return `import { FactoryScreen } from '@xeplr/ui-factory'
106
+ import editScreen from './${editJson}'
107
+
108
+ // ${edit.name} — saves itself as it is filled in; there is no submit.
109
+ // Opened in a popup from ${n.listComponent}, or on its own page:
110
+ // <${n.editComponent} api={api} /> a new ${n.singular}
111
+ // <${n.editComponent} api={api} record={row} /> an existing one
112
+ export default function ${n.editComponent}({ api, record }) {
113
+ return <FactoryScreen document={editScreen} record={record} {...api} />
114
+ }
115
+ `
116
+ }
@@ -0,0 +1,329 @@
1
+ // A FORM IS A REAL TABLE.
2
+ //
3
+ // Records are never stored as JSON: every entity has its own table, and every
4
+ // field of its edit screen is a column of that table, named exactly as the
5
+ // field. `select * from employees` is how you read them, and reporting is plain
6
+ // SQL.
7
+ //
8
+ // This file derives that table from a screen and writes the migration that
9
+ // gets the database there — the first time a CREATE TABLE, after that only what
10
+ // changed. The migrations are ordinary files in the app's migrations folder,
11
+ // written (usually by Claude) and reviewed like any other; nothing here touches
12
+ // a database.
13
+ //
14
+ // Conventions are the xeplr ones (see xeplr-bi 0066_dashboard_groups.sql):
15
+ // varchar(25) ids, quoted camelCase columns, isActive for soft delete, mtId1–4
16
+ // for tenancy, recordCreated/Modified Date/By for audit.
17
+ //
18
+ // ── CHANGES THAT ARE SAFE, AND CHANGES THAT ARE REFUSED ──────────────────
19
+ // new field ADD COLUMN
20
+ // wider (varchar 80 → 120, varchar → text, integer → numeric) ALTER TYPE
21
+ // narrower, or a different type (text → number) REFUSED — data would be
22
+ // lost or rejected; write that migration by hand
23
+ // field removed column KEPT, reported as unused — dropping data
24
+ // is a deliberate migration, never a side effect
25
+ // field renamed seen as a NEW column beside an unused old one, with
26
+ // the data left behind — which is why a field's name is
27
+ // locked once its table exists (see the builder's
28
+ // lockedNames). A real rename is a manual migration.
29
+
30
+ import { CONTROLS } from './controls.js'
31
+ import { inputNodes } from './document.js'
32
+
33
+ /** Columns every entity table has, in this order. Fields may not use these names. */
34
+ export const STANDARD_COLUMNS = [
35
+ { name: 'id', sql: 'varchar(25) PRIMARY KEY' },
36
+ { name: 'isActive', sql: 'boolean DEFAULT true' },
37
+ { name: 'mtId1', sql: 'varchar(25)' },
38
+ { name: 'mtId2', sql: 'varchar(25)' },
39
+ { name: 'mtId3', sql: 'varchar(25)' },
40
+ { name: 'mtId4', sql: 'varchar(25)' },
41
+ { name: 'recordCreatedDate', sql: 'timestamp DEFAULT now()' },
42
+ { name: 'recordModifiedDate', sql: 'timestamp DEFAULT now()' },
43
+ { name: 'recordCreatedBy', sql: 'varchar(25)' },
44
+ { name: 'recordModifiedBy', sql: 'varchar(25)' }
45
+ ]
46
+
47
+ export const RESERVED_COLUMNS = STANDARD_COLUMNS.map((c) => c.name)
48
+
49
+ /** Postgres truncates identifiers past this — two long fields would collide silently. */
50
+ export const MAX_IDENTIFIER = 63
51
+
52
+ const TEXT_DEFAULT_LENGTH = 255
53
+ const OPTION_MIN_LENGTH = 50
54
+
55
+ /**
56
+ * The column a field is stored in.
57
+ * @returns {{ name, type: 'varchar'|'text'|'integer'|'numeric'|'date'|'boolean', length?, references?, notNull?, default? }}
58
+ */
59
+ export function columnForField(node) {
60
+ const p = node.props || {}
61
+ const v = p.validation || {}
62
+ const base = { name: p.name, label: p.label }
63
+ switch (node.type) {
64
+ case 'text':
65
+ return { ...base, type: 'varchar', length: v.maxLength || TEXT_DEFAULT_LENGTH }
66
+ case 'textarea':
67
+ return { ...base, type: 'text' }
68
+ case 'number':
69
+ return { ...base, type: v.integer ? 'integer' : 'numeric' }
70
+ case 'date':
71
+ return { ...base, type: 'date' }
72
+ case 'checkbox':
73
+ return { ...base, type: 'boolean', notNull: true, default: 'false' }
74
+ case 'dropdown': {
75
+ const data = p.data || {}
76
+ if (data.source === 'table') {
77
+ // A real foreign key to the other entity's id.
78
+ return { ...base, type: 'varchar', length: 25, references: data.table }
79
+ }
80
+ const longest = (data.options || []).reduce((m, o) => Math.max(m, String(o.id).length), 0)
81
+ return { ...base, type: 'varchar', length: Math.max(OPTION_MIN_LENGTH, longest) }
82
+ }
83
+ default:
84
+ return null
85
+ }
86
+ }
87
+
88
+ /**
89
+ * The table an edit screen saves to.
90
+ * @returns {{ table, columns }} — columns are the fields, in reading order
91
+ */
92
+ export function tableForScreen(doc, controls = CONTROLS) {
93
+ if (!doc || !doc.source) throw new Error('The screen has no "source" — which table does it save to?')
94
+ const columns = inputNodes(doc, controls).map(columnForField).filter(Boolean)
95
+ if (!columns.length) throw new Error(`Screen "${doc.id}" has no fields, so it defines no table columns — generate the migration from the edit screen`)
96
+ return { table: doc.source, columns }
97
+ }
98
+
99
+ export function columnSql(col) {
100
+ const type = col.type === 'varchar' ? `varchar(${col.length})` : col.type
101
+ const parts = [type]
102
+ if (col.references) parts.push(`REFERENCES ${q(col.references)}("id")`)
103
+ if (col.notNull) parts.push('NOT NULL')
104
+ if (col.default !== undefined) parts.push(`DEFAULT ${col.default}`)
105
+ return parts.join(' ')
106
+ }
107
+
108
+ /** Is changing `from` to `to` safe for data already in the column? */
109
+ export function widening(from, to) {
110
+ if (from.references || to.references) {
111
+ if (from.references !== to.references) return { ok: false, reason: `it points at "${from.references || 'no table'}" and would point at "${to.references || 'no table'}"` }
112
+ }
113
+ if (from.type === to.type) {
114
+ if (from.type !== 'varchar') return { ok: true, same: true }
115
+ if (to.length === from.length) return { ok: true, same: true }
116
+ if (to.length > from.length) return { ok: true }
117
+ return { ok: false, reason: `varchar(${from.length}) → varchar(${to.length}) would cut longer values` }
118
+ }
119
+ if (from.type === 'varchar' && to.type === 'text') return { ok: true }
120
+ if (from.type === 'integer' && to.type === 'numeric') return { ok: true }
121
+ return { ok: false, reason: `${describe(from)} → ${describe(to)} is a different kind of value` }
122
+ }
123
+
124
+ /**
125
+ * What it takes to go from the table as it was to the table as the screen now
126
+ * describes it.
127
+ * @param before { table, columns } or null for a new table
128
+ * @returns {{ create, add, alter, unused, refused, same }}
129
+ */
130
+ export function diffTables(before, after) {
131
+ if (before && before.table !== after.table) {
132
+ return { create: false, add: [], alter: [], unused: [], same: false, refused: [{ column: null, reason: `the screen now saves to "${after.table}" instead of "${before.table}" — moving records to another table is a manual migration` }] }
133
+ }
134
+ if (!before) return { create: true, add: after.columns, alter: [], unused: [], refused: [], same: false }
135
+ const was = Object.fromEntries(before.columns.map((c) => [c.name, c]))
136
+ const now = Object.fromEntries(after.columns.map((c) => [c.name, c]))
137
+ const add = []
138
+ const alter = []
139
+ const refused = []
140
+ after.columns.forEach((c) => {
141
+ const old = was[c.name]
142
+ if (!old) { add.push(c); return }
143
+ const w = widening(old, c)
144
+ if (!w.ok) refused.push({ column: c.name, reason: w.reason })
145
+ else if (!w.same) alter.push({ from: old, to: c })
146
+ })
147
+ const unused = before.columns.filter((c) => !now[c.name])
148
+ return { create: false, add, alter, unused, refused, same: !add.length && !alter.length && !unused.length && !refused.length }
149
+ }
150
+
151
+ /**
152
+ * The migration SQL from one version of an edit screen to the next.
153
+ * @param previous the edit screen as last migrated, or null
154
+ * @param next the edit screen now
155
+ * @returns {{ sql, diff, table, empty }} — throws if a change is refused
156
+ */
157
+ export function migrationFor(previous, next, controls = CONTROLS) {
158
+ const after = tableForScreen(next, controls)
159
+ const before = previous ? tableForScreen(previous, controls) : null
160
+ const diff = diffTables(before, after)
161
+ if (diff.refused.length) {
162
+ const e = new Error(`Cannot migrate "${after.table}" safely:\n` + diff.refused.map((r) => ` ${r.column ? `"${r.column}": ` : ''}${r.reason}`).join('\n'))
163
+ e.refused = diff.refused
164
+ throw e
165
+ }
166
+ if (diff.same || (!diff.create && !diff.add.length && !diff.alter.length)) {
167
+ return { sql: '', diff, table: after.table, empty: true }
168
+ }
169
+
170
+ const t = after.table
171
+ const lines = []
172
+ const header = [
173
+ `-- ${diff.create ? 'Create' : 'Update'} "${t}" for screen "${next.id}" (${next.name}).`,
174
+ '--',
175
+ '-- Written from the screen by @xeplr/ui-factory: one column per field, named'
176
+ , '-- exactly as the field. Review before applying.'
177
+ ]
178
+ if (diff.unused.length) {
179
+ header.push('--', `-- Kept, no longer on the screen (drop by hand if the data is not needed): ${diff.unused.map((c) => `"${c.name}"`).join(', ')}`)
180
+ }
181
+ lines.push(...header, '')
182
+
183
+ if (diff.create) {
184
+ lines.push(...createStatements(after, { annotate: true }))
185
+ } else {
186
+ diff.add.forEach((c) => lines.push(...addStatements(t, c, { annotate: true })))
187
+ diff.alter.forEach(({ from, to }) => lines.push(alterStatement(t, from, to, { annotate: true })))
188
+ }
189
+ return { sql: lines.join('\n') + '\n', diff, table: t, empty: false }
190
+ }
191
+
192
+ // ── statements ───────────────────────────────────────────────────────────
193
+
194
+ /** CREATE TABLE with the fields and the standard columns, and its indexes. */
195
+ export function createStatements(after, { annotate } = {}) {
196
+ const t = after.table
197
+ const cols = [...STANDARD_COLUMNS.slice(0, 1), ...after.columns.map((c) => ({ name: c.name, sql: columnSql(c), label: c.label })), ...STANDARD_COLUMNS.slice(1)]
198
+ const width = Math.max(...cols.map((c) => c.name.length)) + 2
199
+ const body = cols.map((c, i) => {
200
+ const comma = i < cols.length - 1 ? ',' : ''
201
+ const note = annotate && c.label ? ` -- ${c.label}` : ''
202
+ return ` ${q(c.name).padEnd(width)} ${c.sql}${comma}${note}`
203
+ })
204
+ const out = [`CREATE TABLE IF NOT EXISTS ${q(t)} (\n${body.join('\n')}\n);`]
205
+ out.push(`CREATE INDEX IF NOT EXISTS ${q(`${t}_mt_index`)} ON ${q(t)} ("mtId1", "mtId2");`)
206
+ after.columns.filter((c) => c.references).forEach((c) => {
207
+ out.push(`CREATE INDEX IF NOT EXISTS ${q(`${t}_${c.name}_index`)} ON ${q(t)} (${q(c.name)});`)
208
+ })
209
+ return out
210
+ }
211
+
212
+ export function addStatements(t, c, { annotate } = {}) {
213
+ const out = [`ALTER TABLE ${q(t)} ADD COLUMN IF NOT EXISTS ${q(c.name)} ${columnSql(c)};${annotate && c.label ? ` -- ${c.label}` : ''}`]
214
+ if (c.references) out.push(`CREATE INDEX IF NOT EXISTS ${q(`${t}_${c.name}_index`)} ON ${q(t)} (${q(c.name)});`)
215
+ return out
216
+ }
217
+
218
+ export function alterStatement(t, from, to, { annotate } = {}) {
219
+ const type = to.type === 'varchar' ? `varchar(${to.length})` : to.type
220
+ return `ALTER TABLE ${q(t)} ALTER COLUMN ${q(to.name)} TYPE ${type};${annotate ? ` -- was ${describe(from)}` : ''}`
221
+ }
222
+
223
+ /** Drops the column and every value in it. Its foreign key and index go with it. */
224
+ export function dropStatement(t, name) {
225
+ return `ALTER TABLE ${q(t)} DROP COLUMN IF EXISTS ${q(name)};`
226
+ }
227
+
228
+ // ── publish: apply directly ──────────────────────────────────────────────
229
+ //
230
+ // Publishing a screen changes its table straight away — no migration files.
231
+ // It compares the screen with the table AS IT IS in the database (not with
232
+ // the previous version), so a table changed by hand, or a publish that failed
233
+ // halfway, is still seen for what it is.
234
+ //
235
+ // A REMOVED FIELD DROPS ITS COLUMN AND ITS DATA — only after the person
236
+ // confirms, and never for a column this factory did not create:
237
+ // managed names that were fields of a published version of a screen on this
238
+ // table. A column somebody added by hand is not in it, and is left alone.
239
+ // inUse names another published screen on the same table still has —
240
+ // another company's screen, say. The column is shared; it stays.
241
+
242
+ /** A column as Postgres describes it → the shape columnForField returns. */
243
+ export function columnFromDatabase(c) {
244
+ const base = { name: c.name }
245
+ if (c.references) base.references = c.references
246
+ switch (c.udtName) {
247
+ case 'varchar': return { ...base, type: 'varchar', length: c.maxLength }
248
+ case 'text': return { ...base, type: 'text' }
249
+ case 'int2':
250
+ case 'int4': return { ...base, type: 'integer' }
251
+ case 'numeric': return { ...base, type: 'numeric' }
252
+ case 'date': return { ...base, type: 'date' }
253
+ case 'bool': return { ...base, type: 'boolean' }
254
+ // Anything else (a timestamp, a bigint someone chose) is not a type a field
255
+ // makes — so any change to it is refused rather than guessed at.
256
+ default: return { ...base, type: c.udtName }
257
+ }
258
+ }
259
+
260
+ /**
261
+ * What publishing `next` does to its table.
262
+ *
263
+ * @param current { table, columns: [{ name, udtName, maxLength, references }] }
264
+ * as the database describes it, or null when the table does not exist
265
+ * @param options.managed names ever published as fields on this table
266
+ * @param options.inUse names other published screens on this table still use
267
+ * @param options.confirmDrop names the person has confirmed dropping
268
+ * @returns {{
269
+ * table, create,
270
+ * add, alter, what will change
271
+ * drop, columns to drop: [{ name }]
272
+ * unconfirmed, of those, the ones not yet confirmed — nothing runs until this is empty
273
+ * keep, columns left in place, with why: [{ name, reason }]
274
+ * refused, changes that cannot be made safely: [{ column, reason }] — nothing runs
275
+ * statements the SQL, in order — empty while anything is refused or unconfirmed
276
+ * }}
277
+ */
278
+ export function planTableChange(current, next, options = {}, controls = CONTROLS) {
279
+ const after = tableForScreen(next, controls)
280
+ const managed = new Set(options.managed || [])
281
+ const inUse = new Set(options.inUse || [])
282
+ const confirmed = new Set(options.confirmDrop || [])
283
+ const plan = { table: after.table, create: false, add: [], alter: [], drop: [], unconfirmed: [], keep: [], refused: [], statements: [] }
284
+
285
+ if (!current) {
286
+ plan.create = true
287
+ plan.add = after.columns
288
+ plan.statements = createStatements(after)
289
+ return plan
290
+ }
291
+
292
+ const existing = current.columns
293
+ .filter((c) => !RESERVED_COLUMNS.includes(c.name))
294
+ .map(columnFromDatabase)
295
+ const diff = diffTables({ table: after.table, columns: existing }, after)
296
+ plan.add = diff.add
297
+ plan.alter = diff.alter
298
+ plan.refused = diff.refused
299
+
300
+ diff.unused.forEach((c) => {
301
+ if (!managed.has(c.name)) plan.keep.push({ name: c.name, reason: 'not created by a screen — left as it is' })
302
+ else if (inUse.has(c.name)) plan.keep.push({ name: c.name, reason: 'another published screen on this table still uses it' })
303
+ else plan.drop.push({ name: c.name })
304
+ })
305
+ plan.unconfirmed = plan.drop.filter((d) => !confirmed.has(d.name)).map((d) => d.name)
306
+
307
+ if (plan.refused.length || plan.unconfirmed.length) return plan
308
+ plan.add.forEach((c) => plan.statements.push(...addStatements(after.table, c)))
309
+ plan.alter.forEach(({ from, to }) => plan.statements.push(alterStatement(after.table, from, to)))
310
+ plan.drop.forEach((d) => plan.statements.push(dropStatement(after.table, d.name)))
311
+ return plan
312
+ }
313
+
314
+ /** Next migration file name in a folder of NNNN_*.sql files. */
315
+ export function nextMigrationName(existingFiles, table, create) {
316
+ const top = (existingFiles || []).reduce((m, f) => {
317
+ const n = /^(\d+)_/.exec(f)
318
+ return n ? Math.max(m, Number(n[1])) : m
319
+ }, 0)
320
+ return `${String(top + 1).padStart(4, '0')}_factory_${table}_${create ? 'create' : 'update'}.sql`
321
+ }
322
+
323
+ function describe(c) {
324
+ return c.type === 'varchar' ? `varchar(${c.length})` : c.type
325
+ }
326
+
327
+ function q(identifier) {
328
+ return `"${String(identifier).replace(/"/g, '""')}"`
329
+ }