@nuxodin/item 0.6.4
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/LICENSE +21 -0
- package/README.md +176 -0
- package/item.d.ts +116 -0
- package/item.js +361 -0
- package/package.json +24 -0
- package/src/AsyncDataPoint.d.ts +52 -0
- package/src/AsyncDataPoint.js +280 -0
- package/src/Collection.experiment.js +63 -0
- package/src/Emitter.js +103 -0
- package/src/proxy.js +77 -0
- package/src/signal.js +95 -0
- package/tools/AsyncChild.js +44 -0
- package/tools/access.js +36 -0
- package/tools/beta/collectChanges.js +88 -0
- package/tools/beta/syncWith.js +22 -0
- package/tools/condition.js +65 -0
- package/tools/csvDataItem.js +139 -0
- package/tools/db/connect.js +27 -0
- package/tools/db/dialect/mysql.js +12 -0
- package/tools/db/dialect/pg.js +15 -0
- package/tools/db/dialect/sqlite.js +12 -0
- package/tools/db/shared.js +7 -0
- package/tools/db/sql.d.ts +24 -0
- package/tools/db/sql.js +83 -0
- package/tools/httpRouter.js +66 -0
- package/tools/jsonDataItem.js +29 -0
- package/tools/schema/build/input.js +120 -0
- package/tools/schema/db/indexeddb/from-db.js +29 -0
- package/tools/schema/db/indexeddb/to-db.js +115 -0
- package/tools/schema/db/mysql/from-db.js +16 -0
- package/tools/schema/db/mysql/from-field.js +101 -0
- package/tools/schema/db/mysql/to-db.js +275 -0
- package/tools/schema/db/mysql/to-field.js +60 -0
- package/tools/schema/db/pg/from-db.js +48 -0
- package/tools/schema/db/pg/from-field.js +71 -0
- package/tools/schema/db/pg/to-db.js +223 -0
- package/tools/schema/db/pg/to-field.js +52 -0
- package/tools/schema/db/shared/from-db.js +29 -0
- package/tools/schema/db/shared/sql.js +26 -0
- package/tools/schema/db/sqlite/from-db.js +39 -0
- package/tools/schema/db/sqlite/from-field.js +49 -0
- package/tools/schema/db/sqlite/to-db.js +127 -0
- package/tools/schema/db/sqlite/to-field.js +34 -0
- package/tools/schema/diff.js +90 -0
- package/tools/schema/dom/toInput.js +6 -0
- package/tools/schema/render/dom.js +26 -0
- package/tools/schema/render/html.js +40 -0
- package/tools/schema/utils.js +131 -0
- package/tools/schema/validator.js +92 -0
- package/tools/view/dom.js +96 -0
- package/tools/view/uhtml.js +40 -0
- package/tools/wsDenoRouter.js +108 -0
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
|
|
2
|
+
export function inputNode(schema = {}, { value, required, disabled, name } = {}) {
|
|
3
|
+
if (value != null && typeof value === 'object') {
|
|
4
|
+
console.warn('inputNode: value is object', schema, value)
|
|
5
|
+
value = '-'
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const node = schema.enum ? selectNode(schema) : nativeNode(schema)
|
|
9
|
+
|
|
10
|
+
if (schema.examples?.[0] != null) node.attrs.placeholder = schema.examples[0]
|
|
11
|
+
else if (schema.description) node.attrs.placeholder = schema.description
|
|
12
|
+
|
|
13
|
+
if (required) node.attrs.required = true
|
|
14
|
+
if (disabled) node.attrs.disabled = true
|
|
15
|
+
if (schema.readOnly) node.attrs.readonly = true
|
|
16
|
+
if (schema.title) node.attrs.title = schema.title
|
|
17
|
+
if (name) node.attrs.name = name
|
|
18
|
+
|
|
19
|
+
applyValue(node, value ?? schema.default)
|
|
20
|
+
|
|
21
|
+
if (schema['x-html']) {
|
|
22
|
+
const { tag, ...attrs } = schema['x-html']
|
|
23
|
+
if (tag) node.tag = tag
|
|
24
|
+
Object.assign(node.attrs, attrs)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return node
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function resolveType(schema) {
|
|
31
|
+
const { type, format, contentEncoding, writeOnly, maxLength } = schema
|
|
32
|
+
if (writeOnly) return 'password'
|
|
33
|
+
if (schema.const != null) return 'text'
|
|
34
|
+
if (contentEncoding === 'base64') return 'file'
|
|
35
|
+
if (format === 'date') return 'date'
|
|
36
|
+
if (format === 'date-time') return 'datetime-local'
|
|
37
|
+
if (format === 'time') return 'time'
|
|
38
|
+
if (format === 'email') return 'email'
|
|
39
|
+
if (format === 'uri') return 'url'
|
|
40
|
+
if (format === 'color') return 'color'
|
|
41
|
+
if (type === 'integer') return 'number'
|
|
42
|
+
if (type === 'number') return 'number'
|
|
43
|
+
if (type === 'boolean') return 'checkbox'
|
|
44
|
+
if (schema['x-multiline'] != null) return schema['x-multiline'] ? 'textarea' : 'text'
|
|
45
|
+
if (type === 'string' && (maxLength??0) > 100) return 'textarea'
|
|
46
|
+
return 'text'
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ── internals ─────────────────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
function nativeNode(schema) {
|
|
52
|
+
const type = resolveType(schema)
|
|
53
|
+
|
|
54
|
+
if (type === 'textarea') {
|
|
55
|
+
const attrs = {}
|
|
56
|
+
if (schema.maxLength != null) attrs.maxlength = schema.maxLength
|
|
57
|
+
if (schema.minLength != null) attrs.minlength = schema.minLength
|
|
58
|
+
return { tag: 'textarea', attrs, children: [] }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const attrs = { type }
|
|
62
|
+
|
|
63
|
+
if (type === 'number') {
|
|
64
|
+
const isInt = schema.type === 'integer'
|
|
65
|
+
attrs.step = schema.multipleOf ?? (isInt ? 1 : 'any')
|
|
66
|
+
if (schema.minimum != null) attrs.min = schema.minimum
|
|
67
|
+
if (schema.maximum != null) attrs.max = schema.maximum
|
|
68
|
+
if (schema.exclusiveMinimum != null) attrs.min = isInt ? schema.exclusiveMinimum + 1 : schema.exclusiveMinimum + Number.EPSILON
|
|
69
|
+
if (schema.exclusiveMaximum != null) attrs.max = isInt ? schema.exclusiveMaximum - 1 : schema.exclusiveMaximum - Number.EPSILON
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (type === 'text') {
|
|
73
|
+
if (schema.maxLength != null) attrs.maxlength = schema.maxLength
|
|
74
|
+
if (schema.minLength != null) attrs.minlength = schema.minLength
|
|
75
|
+
if (schema.pattern) attrs.pattern = schema.pattern
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (type === 'file' && schema.contentMediaType) attrs.accept = schema.contentMediaType
|
|
79
|
+
|
|
80
|
+
if (schema.const != null) attrs.readonly = true
|
|
81
|
+
|
|
82
|
+
return { tag: 'input', attrs }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function selectNode(schema) {
|
|
86
|
+
const nullable = Array.isArray(schema.type)
|
|
87
|
+
? schema.type.includes('null')
|
|
88
|
+
: schema.type === 'null'
|
|
89
|
+
|
|
90
|
+
const children = []
|
|
91
|
+
if (nullable) children.push({ tag: 'option', attrs: { value: '' }, children: ['—'] })
|
|
92
|
+
for (const v of schema.enum) {
|
|
93
|
+
children.push({
|
|
94
|
+
tag: 'option',
|
|
95
|
+
attrs: { value: v ?? '' },
|
|
96
|
+
children: [v == null ? '—' : String(v)],
|
|
97
|
+
})
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return { tag: 'select', attrs: {}, children }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function applyValue(node, value) {
|
|
104
|
+
if (node.tag === 'input' && node.attrs.type === 'checkbox') {
|
|
105
|
+
if (value == null) node.attrs.indeterminate = true
|
|
106
|
+
else if (value) node.attrs.checked = true
|
|
107
|
+
return
|
|
108
|
+
}
|
|
109
|
+
if (node.tag === 'select') {
|
|
110
|
+
const str = value == null ? '' : String(value)
|
|
111
|
+
for (const opt of node.children) {
|
|
112
|
+
if (String(opt.attrs.value) === str) { opt.attrs.selected = true; break }
|
|
113
|
+
}
|
|
114
|
+
return
|
|
115
|
+
}
|
|
116
|
+
if (value == null) return
|
|
117
|
+
const str = String(value)
|
|
118
|
+
if (node.tag === 'textarea') node.children = [str]
|
|
119
|
+
else node.attrs.value = str
|
|
120
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// indexeddb/from-db.js
|
|
2
|
+
import { schemaFromDb as _schemaFromDb } from '../shared/from-db.js'
|
|
3
|
+
|
|
4
|
+
const dialect = {
|
|
5
|
+
tables: (db) => Array.from(db.objectStoreNames),
|
|
6
|
+
|
|
7
|
+
fields: (db, table) => {
|
|
8
|
+
const store = db.transaction(table).objectStore(table)
|
|
9
|
+
const fields = []
|
|
10
|
+
const keyPath = Array.isArray(store.keyPath) ? store.keyPath[0] : store.keyPath
|
|
11
|
+
if (keyPath) fields.push({ name: keyPath, isPrimary: true, autoIncrement: store.autoIncrement })
|
|
12
|
+
for (const name of store.indexNames) {
|
|
13
|
+
const idx = store.index(name)
|
|
14
|
+
fields.push({ name, isUnique: idx.unique })
|
|
15
|
+
}
|
|
16
|
+
return fields
|
|
17
|
+
},
|
|
18
|
+
|
|
19
|
+
fromField: (row) => {
|
|
20
|
+
const prop = {}
|
|
21
|
+
if (row.isPrimary) prop['x-index'] = 'primary'
|
|
22
|
+
if (row.isUnique) prop['x-index'] = 'unique'
|
|
23
|
+
if (row.autoIncrement) prop['x-autoincrement'] = true
|
|
24
|
+
// no type info available — IndexedDB is schemaless
|
|
25
|
+
return { name: row.name, prop, isRequired: !!row.isPrimary }
|
|
26
|
+
},
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const schemaFromDb = (db) => _schemaFromDb(db, dialect)
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// indexeddb/to-db.js
|
|
2
|
+
|
|
3
|
+
const STRUCTURAL = new Set([
|
|
4
|
+
'properties',
|
|
5
|
+
'additionalProperties',
|
|
6
|
+
'x-index',
|
|
7
|
+
'x-autoincrement',
|
|
8
|
+
])
|
|
9
|
+
|
|
10
|
+
function stripMeta(v, keepKeys = false) {
|
|
11
|
+
if (Array.isArray(v)) return v.map(x => stripMeta(x))
|
|
12
|
+
if (v !== null && typeof v === 'object') {
|
|
13
|
+
const entries = keepKeys ? Object.entries(v) : Object.entries(v).filter(([k]) => STRUCTURAL.has(k))
|
|
14
|
+
return Object.fromEntries(entries.map(([k, val]) => [k, stripMeta(val, k === 'properties')]))
|
|
15
|
+
}
|
|
16
|
+
return v
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function indexOptions(table, name, prop) {
|
|
20
|
+
if (prop['x-index'] === 'unique') return { unique: true }
|
|
21
|
+
if (prop['x-index'] === true) return { unique: false }
|
|
22
|
+
if (prop['x-index'] === 'fulltext')
|
|
23
|
+
console.warn(`Skip fulltext index ${table}.${name}: IndexedDB has no native fulltext index`)
|
|
24
|
+
return null
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function sameKeyPath(a, b) {
|
|
28
|
+
return JSON.stringify(a ?? null) === JSON.stringify(b ?? null)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function sameIndex(index, opts) {
|
|
32
|
+
return index.unique === opts.unique
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function schemaVersion(schema, dbName = 'default') {
|
|
36
|
+
const buf = await crypto.subtle.digest('SHA-1', new TextEncoder().encode(JSON.stringify(stripMeta(schema))))
|
|
37
|
+
const hash = Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2,'0')).join('')
|
|
38
|
+
const key = 'idb-schema-versions:' + dbName
|
|
39
|
+
const map = JSON.parse(localStorage.getItem(key) || '{}')
|
|
40
|
+
|
|
41
|
+
const vActual = Math.max(0, ...Object.values(map));
|
|
42
|
+
const vStored = map?.[hash] ?? 0;
|
|
43
|
+
|
|
44
|
+
if (!vStored || vActual > vStored) {
|
|
45
|
+
map[hash] = vActual + 1
|
|
46
|
+
localStorage.setItem(key, JSON.stringify(map))
|
|
47
|
+
}
|
|
48
|
+
return map[hash]
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* @param {string} name
|
|
53
|
+
* @param {object|null} schema
|
|
54
|
+
* @param {object} [options]
|
|
55
|
+
* @param {number|null} [options.version=null]
|
|
56
|
+
* @param {boolean} [options.patch=true]
|
|
57
|
+
* @param {function|null} [options.upgrade=null] - Called in onupgradeneeded after schema migration. (e: IDBVersionChangeEvent) => void
|
|
58
|
+
*/
|
|
59
|
+
export async function openDb(name, schema, { patch = true, version = null, upgrade = null } = {}) {
|
|
60
|
+
if (upgrade && version == null) throw new Error('openDb: "upgrade" requires a manual "version"')
|
|
61
|
+
if (version == null && schema) version = await schemaVersion(schema, name);
|
|
62
|
+
return new Promise((resolve, reject) => {
|
|
63
|
+
const req = indexedDB.open(name, version);
|
|
64
|
+
req.onupgradeneeded = (e) => {
|
|
65
|
+
if (schema) schemaToDb(schema, e.target.result, e.target.transaction, { patch });
|
|
66
|
+
upgrade?.(e)
|
|
67
|
+
}
|
|
68
|
+
req.onsuccess = (e) => resolve(e.target.result);
|
|
69
|
+
req.onerror = (e) => reject(e.target.error);
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// tx = upgrade transaction from onupgradeneeded (e.target.transaction)
|
|
74
|
+
export function schemaToDb(schema, db, tx, { patch = false } = {}) {
|
|
75
|
+
const tables = Object.keys(schema.properties ?? {})
|
|
76
|
+
|
|
77
|
+
for (const table of tables) {
|
|
78
|
+
const fields = Object.entries(schema.properties[table]?.additionalProperties?.properties ?? {})
|
|
79
|
+
const primaries = fields.filter(([, f]) => f['x-index'] === 'primary')
|
|
80
|
+
const keyPath = primaries.length > 1 ? primaries.map(([n]) => n) : primaries[0]?.[0]
|
|
81
|
+
|
|
82
|
+
if (!db.objectStoreNames.contains(table)) {
|
|
83
|
+
const store = db.createObjectStore(table, {
|
|
84
|
+
keyPath: keyPath,
|
|
85
|
+
autoIncrement: !!primaries[0]?.[1]?.['x-autoincrement'],
|
|
86
|
+
})
|
|
87
|
+
for (const [name, prop] of fields) {
|
|
88
|
+
const opts = indexOptions(table, name, prop)
|
|
89
|
+
if (opts) store.createIndex(name, name, opts)
|
|
90
|
+
}
|
|
91
|
+
} else {
|
|
92
|
+
const store = tx.objectStore(table)
|
|
93
|
+
if (!sameKeyPath(store.keyPath, keyPath) || store.autoIncrement !== !!primaries[0]?.[1]?.['x-autoincrement'])
|
|
94
|
+
throw new Error(`IndexedDB objectStore ${table}: changing keyPath/autoIncrement requires a manual migration`)
|
|
95
|
+
for (const [name, prop] of fields) {
|
|
96
|
+
const opts = indexOptions(table, name, prop)
|
|
97
|
+
if (!opts) continue
|
|
98
|
+
if (store.indexNames.contains(name)) {
|
|
99
|
+
if (sameIndex(store.index(name), opts)) continue
|
|
100
|
+
store.deleteIndex(name)
|
|
101
|
+
}
|
|
102
|
+
store.createIndex(name, name, opts)
|
|
103
|
+
}
|
|
104
|
+
if (!patch) {
|
|
105
|
+
for (const name of Array.from(store.indexNames))
|
|
106
|
+
if (!fields.find(([n]) => n === name)) store.deleteIndex(name)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (!patch) {
|
|
112
|
+
for (const name of Array.from(db.objectStoreNames))
|
|
113
|
+
if (!tables.includes(name)) db.deleteObjectStore(name)
|
|
114
|
+
}
|
|
115
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// mysql/from-db.js
|
|
2
|
+
import { schemaFromDb as _schemaFromDb } from '../shared/from-db.js'
|
|
3
|
+
import { quoteId } from '../shared/sql.js'
|
|
4
|
+
import { schemaFromField } from './from-field.js'
|
|
5
|
+
|
|
6
|
+
const dialect = {
|
|
7
|
+
tables: async (query) => (await query('SHOW TABLES')).map(r => Object.values(r)[0]),
|
|
8
|
+
fields: async (query, table) => query(`SHOW FULL FIELDS FROM ${quoteId(table)}`),
|
|
9
|
+
fromField: (row) => ({
|
|
10
|
+
name: row.Field,
|
|
11
|
+
prop: schemaFromField(row),
|
|
12
|
+
isRequired: row.Null === 'NO',
|
|
13
|
+
}),
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const schemaFromDb = (query) => _schemaFromDb(query, dialect)
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// mysql/schema-from-field.js
|
|
2
|
+
// Note: MySQL VARCHAR/CHAR lengths are in characters; TEXT sizes are byte caps.
|
|
3
|
+
|
|
4
|
+
const intRanges = {
|
|
5
|
+
tinyint: { signed: [-128, 127], unsigned: [0, 255] },
|
|
6
|
+
smallint: { signed: [-32768, 32767], unsigned: [0, 65535] },
|
|
7
|
+
mediumint: { signed: [-8388608, 8388607], unsigned: [0, 16777215] },
|
|
8
|
+
int: { signed: [], unsigned: [0, 4294967295] },
|
|
9
|
+
integer: { signed: [], unsigned: [0, 4294967295] },
|
|
10
|
+
bigint: {
|
|
11
|
+
signed: [-9007199254740991, 9007199254740991],
|
|
12
|
+
unsigned: [0, 9007199254740991],
|
|
13
|
+
}, // JS safe
|
|
14
|
+
};
|
|
15
|
+
const textBytes = {
|
|
16
|
+
tinytext: 255,
|
|
17
|
+
text: 65535,
|
|
18
|
+
mediumtext: 16777215,
|
|
19
|
+
longtext: 4294967295,
|
|
20
|
+
};
|
|
21
|
+
const B = 4; // bytes per char, worst-case utf8mb4
|
|
22
|
+
|
|
23
|
+
export function schemaFromField(row) {
|
|
24
|
+
const prop = {};
|
|
25
|
+
const unsigned = /unsigned/i.test(row.Type);
|
|
26
|
+
const isAuto = /auto_increment/i.test(row.Extra ?? "");
|
|
27
|
+
const [rawType, rawLen] = row.Type.replace(/\s*unsigned/i, "").split(/[()]/);
|
|
28
|
+
const type = rawType.trim().toLowerCase();
|
|
29
|
+
const len = rawLen ? parseInt(rawLen) : null;
|
|
30
|
+
|
|
31
|
+
if (type === "tinyint" && len === 1) {
|
|
32
|
+
prop.type = "boolean";
|
|
33
|
+
} else if (type in intRanges) {
|
|
34
|
+
const [min, max] = intRanges[type][unsigned ? "unsigned" : "signed"];
|
|
35
|
+
prop.type = "integer";
|
|
36
|
+
if (min != null && max != null && !(isAuto && (type === "int" || type === "integer"))) {
|
|
37
|
+
prop.minimum = min;
|
|
38
|
+
prop.maximum = max;
|
|
39
|
+
}
|
|
40
|
+
} else if (type === "float" || type === "double" || type === "real") {
|
|
41
|
+
prop.type = "number";
|
|
42
|
+
} else if (type === "decimal" || type === "numeric") {
|
|
43
|
+
prop.type = "number";
|
|
44
|
+
const scale = rawLen ? parseInt(rawLen.split(",")[1] ?? 0) : 0;
|
|
45
|
+
if (scale > 0) prop.multipleOf = Math.pow(10, -scale);
|
|
46
|
+
} else if (type === "char") {
|
|
47
|
+
prop.type = "string";
|
|
48
|
+
prop.minLength = len;
|
|
49
|
+
prop.maxLength = len;
|
|
50
|
+
} else if (type === "varchar") {
|
|
51
|
+
prop.type = "string";
|
|
52
|
+
prop.maxLength = len;
|
|
53
|
+
} else if (type in textBytes) {
|
|
54
|
+
prop.type = "string";
|
|
55
|
+
if (type !== "text") prop.maxLength = Math.floor(textBytes[type] / B);
|
|
56
|
+
} else if (type === "binary") {
|
|
57
|
+
prop.type = "string";
|
|
58
|
+
prop.contentEncoding = "base64";
|
|
59
|
+
prop.minLength = len;
|
|
60
|
+
prop.maxLength = len;
|
|
61
|
+
} else if (type === "varbinary") {
|
|
62
|
+
prop.type = "string";
|
|
63
|
+
prop.contentEncoding = "base64";
|
|
64
|
+
prop.maxLength = len;
|
|
65
|
+
} else if (/blob/.test(type)) {
|
|
66
|
+
prop.type = "string";
|
|
67
|
+
prop.contentEncoding = "base64";
|
|
68
|
+
} else if (type === "date") {
|
|
69
|
+
prop.type = "string";
|
|
70
|
+
prop.format = "date";
|
|
71
|
+
} else if (type === "time") {
|
|
72
|
+
prop.type = "string";
|
|
73
|
+
prop.format = "time";
|
|
74
|
+
} else if (type === "datetime" || type === "timestamp") {
|
|
75
|
+
prop.type = "string";
|
|
76
|
+
prop.format = "date-time";
|
|
77
|
+
} else if (type === "year") {
|
|
78
|
+
prop.type = "integer";
|
|
79
|
+
prop.minimum = 1901;
|
|
80
|
+
prop.maximum = 2155;
|
|
81
|
+
} else if (type === "json") prop.type = "object";
|
|
82
|
+
else if (type === "enum") {
|
|
83
|
+
prop.type = "string";
|
|
84
|
+
prop.enum = rawLen.split(",").map((s) => s.replace(/'/g, ""));
|
|
85
|
+
} else if (type === "set") {
|
|
86
|
+
prop.type = "array";
|
|
87
|
+
prop.items = {
|
|
88
|
+
type: "string",
|
|
89
|
+
enum: rawLen.split(",").map((s) => s.replace(/'/g, "")),
|
|
90
|
+
};
|
|
91
|
+
} else prop.type = "string";
|
|
92
|
+
|
|
93
|
+
if (row.Default != null) prop.default = row.Default;
|
|
94
|
+
if (row.Comment) prop["$comment"] = row.Comment;
|
|
95
|
+
if (row.Key === "PRI") prop["x-index"] = "primary";
|
|
96
|
+
if (row.Key === "UNI") prop["x-index"] = "unique";
|
|
97
|
+
if (row.Key === "MUL") prop["x-index"] = true;
|
|
98
|
+
if (isAuto) prop["x-autoincrement"] = true;
|
|
99
|
+
|
|
100
|
+
return prop;
|
|
101
|
+
}
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
// mysql/to-db.js
|
|
2
|
+
import { schemaFromDb } from "./from-db.js";
|
|
3
|
+
import { quoteId, toFieldDef } from "./to-field.js";
|
|
4
|
+
import { schemaDiff } from "../../diff.js";
|
|
5
|
+
|
|
6
|
+
function tableData(tableSchema) {
|
|
7
|
+
const row = tableSchema.additionalProperties ?? {};
|
|
8
|
+
const fields = Object.entries(row.properties ?? {});
|
|
9
|
+
const required = new Set(row.required ?? []);
|
|
10
|
+
return { fields, required };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function primaryFields(fields) {
|
|
14
|
+
return fields.filter(([, f]) => f["x-index"] === "primary").map(([n]) => n);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const columnProps = new Set([
|
|
18
|
+
"type", "format", "contentEncoding", "maxLength", "minLength",
|
|
19
|
+
"minimum", "maximum", "multipleOf", "$comment",
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
function hasOwn(obj, key) {
|
|
23
|
+
return Object.prototype.hasOwnProperty.call(obj, key);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function fieldChanged(diffs, table, field, prop) {
|
|
27
|
+
return diffs.some(d =>
|
|
28
|
+
d.path[0] === "properties" &&
|
|
29
|
+
d.path[1] === table &&
|
|
30
|
+
d.path[2] === "additionalProperties" &&
|
|
31
|
+
d.path[3] === "properties" &&
|
|
32
|
+
d.path[4] === field &&
|
|
33
|
+
(columnProps.has(d.property) || d.property === "default" && hasOwn(prop, "default"))
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function preserveDefault(prop, currProp) {
|
|
38
|
+
return hasOwn(prop, "default") || !hasOwn(currProp, "default")
|
|
39
|
+
? prop
|
|
40
|
+
: { ...prop, default: currProp.default };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function setEqual(a, b) {
|
|
44
|
+
if (a.size !== b.size) return false;
|
|
45
|
+
for (const v of a) if (!b.has(v)) return false;
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function secondaryIndexKind(prop) {
|
|
50
|
+
if (prop["x-index"] === "unique") return "UNIQUE KEY";
|
|
51
|
+
if (prop["x-index"] === "fulltext") return "FULLTEXT KEY";
|
|
52
|
+
if (prop["x-index"] === true) return "KEY";
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function usableIndexKind(table, name, prop) {
|
|
57
|
+
const kind = secondaryIndexKind(prop);
|
|
58
|
+
const issue = kind && indexIssue(table, name, prop, kind);
|
|
59
|
+
if (!issue) return kind;
|
|
60
|
+
console.warn(issue);
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function indexIssue(table, name, prop, kind) {
|
|
65
|
+
const schemaType = Array.isArray(prop.type) ? prop.type.find(t => t !== "null") : prop.type;
|
|
66
|
+
if (kind === "FULLTEXT KEY") return schemaType === "string" && !prop.format && !prop.contentEncoding ? "" : `Skip ${kind} ${table}.${name}: fulltext indexes require plain string fields`;
|
|
67
|
+
if (schemaType !== "string" || prop.format || (prop.maxLength ?? Infinity) <= 191) return "";
|
|
68
|
+
const max = prop.maxLength ?? "unknown";
|
|
69
|
+
return `Skip ${kind} ${table}.${name}: string maxLength ${max} is too long for a normal index; use maxLength <= 191, x-index:"fulltext", or remove x-index`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function secondaryIndexDef(table, name, prop) {
|
|
73
|
+
const kind = usableIndexKind(table, name, prop);
|
|
74
|
+
return kind && secondaryIndexSql(name, kind);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function secondaryIndexSql(name, kind) {
|
|
78
|
+
return `${kind} ${quoteId(name)} (${quoteId(name)})`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function tableIndexes(query, table) {
|
|
82
|
+
const rows = await query(`SHOW INDEX FROM ${quoteId(table)}`);
|
|
83
|
+
const primary = rows
|
|
84
|
+
.filter(row => row.Key_name === "PRIMARY")
|
|
85
|
+
.sort((a, b) => a.Seq_in_index - b.Seq_in_index)
|
|
86
|
+
.map(row => row.Column_name);
|
|
87
|
+
const grouped = new Map();
|
|
88
|
+
for (const row of rows) {
|
|
89
|
+
const key = row.Key_name;
|
|
90
|
+
if (key === "PRIMARY") continue;
|
|
91
|
+
if (!grouped.has(key)) grouped.set(key, []);
|
|
92
|
+
grouped.get(key).push(row);
|
|
93
|
+
}
|
|
94
|
+
const secondary = [...grouped.entries()]
|
|
95
|
+
.filter(([, rows]) => rows.length === 1)
|
|
96
|
+
.map(([key, [row]]) => ({
|
|
97
|
+
key,
|
|
98
|
+
column: row.Column_name,
|
|
99
|
+
nonUnique: row.Non_unique,
|
|
100
|
+
indexType: row.Index_type,
|
|
101
|
+
subPart: row.Sub_part,
|
|
102
|
+
}));
|
|
103
|
+
return { primary, secondary };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function autoIncrementField(fields) {
|
|
107
|
+
return fields.find(([, prop]) => prop["x-autoincrement"]);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export async function schemaToDb(
|
|
111
|
+
schema,
|
|
112
|
+
query,
|
|
113
|
+
{ force = false, patch = false } = {},
|
|
114
|
+
) {
|
|
115
|
+
const current = await schemaFromDb(query);
|
|
116
|
+
const diffs = schemaDiff(schema, current);
|
|
117
|
+
|
|
118
|
+
if (!patch) {
|
|
119
|
+
const destructive = diffs.filter((d) => d.destructive === true);
|
|
120
|
+
if (destructive.length && !force) {
|
|
121
|
+
const msgs = destructive.map((d) => ` ${d.path.join(".")} — ${d.msg}`).join("\n");
|
|
122
|
+
throw new Error(`Destructive schema changes detected (use force to apply):\n${msgs}`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const stmts = [];
|
|
127
|
+
const nextTables = Object.keys(schema.properties ?? {});
|
|
128
|
+
const currTables = Object.keys(current.properties ?? {});
|
|
129
|
+
|
|
130
|
+
for (const table of nextTables) {
|
|
131
|
+
const tableId = quoteId(table);
|
|
132
|
+
const { fields, required } = tableData(schema.properties[table]);
|
|
133
|
+
const primaries = primaryFields(fields);
|
|
134
|
+
for (const p of primaries) required.add(p); // primary key columns are always NOT NULL
|
|
135
|
+
|
|
136
|
+
if (!currTables.includes(table)) {
|
|
137
|
+
const cols = fields.map(([n, f]) =>
|
|
138
|
+
" " + toFieldDef(n, f, { required: required.has(n) })
|
|
139
|
+
).join(",\n");
|
|
140
|
+
const pk = primaries.length
|
|
141
|
+
? `,\n PRIMARY KEY (${primaries.map(quoteId).join(", ")})`
|
|
142
|
+
: "";
|
|
143
|
+
const keys = fields
|
|
144
|
+
.filter(([n]) => !primaries.includes(n))
|
|
145
|
+
.map(([n, f]) => secondaryIndexDef(table, n, f))
|
|
146
|
+
.filter(Boolean)
|
|
147
|
+
.map((def) => ",\n " + def)
|
|
148
|
+
.join("");
|
|
149
|
+
stmts.push(
|
|
150
|
+
`CREATE TABLE ${tableId} (\n${cols}${pk}${keys}\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;`,
|
|
151
|
+
);
|
|
152
|
+
} else {
|
|
153
|
+
const { fields: currFieldEntries, required: currRequired } = tableData(
|
|
154
|
+
current.properties[table],
|
|
155
|
+
);
|
|
156
|
+
const currFields = Object.keys(Object.fromEntries(currFieldEntries));
|
|
157
|
+
const indexStmts = await indexStatements(
|
|
158
|
+
query,
|
|
159
|
+
table,
|
|
160
|
+
fields,
|
|
161
|
+
currFieldEntries,
|
|
162
|
+
{ patch },
|
|
163
|
+
);
|
|
164
|
+
stmts.push(...indexStmts.before);
|
|
165
|
+
const autoAfter = new Map();
|
|
166
|
+
for (const [name, prop] of fields) {
|
|
167
|
+
const isRequired = required.has(name);
|
|
168
|
+
const curr = currFieldEntries.find(([n]) => n === name);
|
|
169
|
+
const propWithDefault = curr ? preserveDefault(prop, curr[1]) : prop;
|
|
170
|
+
const fieldProp = propWithDefault["x-autoincrement"] ? { ...propWithDefault, "x-autoincrement": false } : propWithDefault;
|
|
171
|
+
const fieldDef = toFieldDef(name, fieldProp, { required: isRequired });
|
|
172
|
+
if (!currFields.includes(name)) {
|
|
173
|
+
stmts.push(`ALTER TABLE ${tableId} ADD COLUMN ${fieldDef};`);
|
|
174
|
+
if (prop["x-autoincrement"]) autoAfter.set(name, prop);
|
|
175
|
+
} else {
|
|
176
|
+
const hasAuto = !!curr?.[1]["x-autoincrement"];
|
|
177
|
+
const wantsAuto = !!prop["x-autoincrement"];
|
|
178
|
+
const wantsChange = fieldChanged(diffs, table, name, prop) ||
|
|
179
|
+
isRequired !== currRequired.has(name) ||
|
|
180
|
+
hasAuto && !wantsAuto;
|
|
181
|
+
const changed = wantsChange && fieldDef !== toFieldDef(name, curr[1], { required: currRequired.has(name) });
|
|
182
|
+
if (changed) {
|
|
183
|
+
stmts.push(`ALTER TABLE ${tableId} MODIFY COLUMN ${fieldDef};`);
|
|
184
|
+
}
|
|
185
|
+
if (wantsAuto && (changed || !hasAuto)) autoAfter.set(name, propWithDefault);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
if (!patch) {
|
|
189
|
+
for (const name of currFields) {
|
|
190
|
+
if (!fields.find(([n]) => n === name)) {
|
|
191
|
+
stmts.push(`ALTER TABLE ${tableId} DROP COLUMN ${quoteId(name)};`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
stmts.push(...indexStmts.after);
|
|
196
|
+
if (indexStmts.primaryChanged) {
|
|
197
|
+
const nextAuto = autoIncrementField(fields);
|
|
198
|
+
if (nextAuto) autoAfter.set(nextAuto[0], nextAuto[1]);
|
|
199
|
+
}
|
|
200
|
+
for (const [name, prop] of autoAfter) {
|
|
201
|
+
stmts.push(`ALTER TABLE ${tableId} MODIFY COLUMN ${toFieldDef(name, prop, { required: true })};`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (!patch) {
|
|
207
|
+
for (const table of currTables) {
|
|
208
|
+
if (!nextTables.includes(table)) stmts.push(`DROP TABLE ${quoteId(table)};`);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
for (const stmt of stmts) {
|
|
213
|
+
console.log(stmt);
|
|
214
|
+
await query(stmt);
|
|
215
|
+
}
|
|
216
|
+
return { diffs, executed: stmts };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function indexStatements(query, table, nextFields, currFields, { patch = false } = {}) {
|
|
220
|
+
const before = [];
|
|
221
|
+
const after = [];
|
|
222
|
+
const tableId = quoteId(table);
|
|
223
|
+
const indexes = await tableIndexes(query, table);
|
|
224
|
+
const currPrimary = new Set(indexes.primary.length ? indexes.primary : primaryFields(currFields));
|
|
225
|
+
const nextPrimary = new Set(primaryFields(nextFields));
|
|
226
|
+
const currentSecondary = indexes.secondary;
|
|
227
|
+
|
|
228
|
+
const primaryChanged = !setEqual(currPrimary, nextPrimary) && (!patch || nextPrimary.size);
|
|
229
|
+
if (primaryChanged) {
|
|
230
|
+
const currAuto = autoIncrementField(currFields);
|
|
231
|
+
if (currAuto) {
|
|
232
|
+
const [name, prop] = currAuto;
|
|
233
|
+
before.push(`ALTER TABLE ${tableId} MODIFY COLUMN ${toFieldDef(name, { ...prop, "x-autoincrement": false }, { required: true })};`);
|
|
234
|
+
}
|
|
235
|
+
if (currPrimary.size) before.push(`ALTER TABLE ${tableId} DROP PRIMARY KEY;`);
|
|
236
|
+
if (nextPrimary.size) {
|
|
237
|
+
const cols = [...nextPrimary].map(quoteId).join(", ");
|
|
238
|
+
after.push(`ALTER TABLE ${tableId} ADD PRIMARY KEY (${cols});`);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
for (const [name, prop] of nextFields) {
|
|
243
|
+
if (nextPrimary.has(name)) continue;
|
|
244
|
+
const nextKind = usableIndexKind(table, name, prop);
|
|
245
|
+
const current = currentSecondary.find(i => i.column === name);
|
|
246
|
+
const currentKind = current
|
|
247
|
+
? current.indexType === "FULLTEXT"
|
|
248
|
+
? "FULLTEXT KEY"
|
|
249
|
+
: current.nonUnique
|
|
250
|
+
? "KEY"
|
|
251
|
+
: "UNIQUE KEY"
|
|
252
|
+
: null;
|
|
253
|
+
const changed = current && (currentKind !== nextKind || current.subPart != null);
|
|
254
|
+
if (patch && !nextKind) continue;
|
|
255
|
+
if (changed) {
|
|
256
|
+
const indexId = quoteId(current.key);
|
|
257
|
+
before.push(`ALTER TABLE ${tableId} DROP INDEX ${indexId};`);
|
|
258
|
+
}
|
|
259
|
+
if (nextKind && (!current || changed)) {
|
|
260
|
+
after.push(`ALTER TABLE ${tableId} ADD ${secondaryIndexSql(name, nextKind)};`);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (!patch) {
|
|
265
|
+
for (const current of currentSecondary) {
|
|
266
|
+
const stillWanted = nextFields.find(([name, prop]) => name === current.column && usableIndexKind(table, name, prop));
|
|
267
|
+
if (!stillWanted) {
|
|
268
|
+
const indexId = quoteId(current.key);
|
|
269
|
+
before.push(`ALTER TABLE ${tableId} DROP INDEX ${indexId};`);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return { before, after, primaryChanged };
|
|
275
|
+
}
|