@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/AUTHORING.md +101 -12
- package/README.md +73 -22
- package/bin/xeplr-factory.js +72 -1
- package/package.json +6 -2
- package/src/controls.js +142 -45
- package/src/designs/BuilderSample.jsx +80 -36
- package/src/designs/ControlView.jsx +43 -36
- package/src/designs/ListView.jsx +95 -0
- package/src/designs/PropertyPanel.jsx +187 -13
- package/src/designs/ScreenModal.jsx +42 -0
- package/src/designs/ScreenSample.jsx +49 -9
- package/src/designs/factory.css +72 -20
- package/src/designs/index.js +2 -0
- package/src/designs/styles.js +59 -0
- package/src/document.js +31 -4
- package/src/generate.js +136 -25
- package/src/index.js +2 -2
- package/src/model.js +12 -5
- package/src/pages.jsx +62 -10
- package/src/remote.js +66 -0
- package/src/scaffold.js +116 -0
- package/src/tableSchema.js +329 -0
- package/src/useFactoryBuilder.js +102 -17
- package/src/useFactoryScreen.js +290 -78
- package/src/validateDocument.js +73 -5
- package/src/values.js +61 -0
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,
|
|
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 —
|
|
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
|
|
13
|
-
* @prop
|
|
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
|
-
|
|
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
|
|
36
|
-
* @prop
|
|
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
|
|
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
|
+
}
|
package/src/scaffold.js
ADDED
|
@@ -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
|
+
}
|