@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.
Files changed (52) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +176 -0
  3. package/item.d.ts +116 -0
  4. package/item.js +361 -0
  5. package/package.json +24 -0
  6. package/src/AsyncDataPoint.d.ts +52 -0
  7. package/src/AsyncDataPoint.js +280 -0
  8. package/src/Collection.experiment.js +63 -0
  9. package/src/Emitter.js +103 -0
  10. package/src/proxy.js +77 -0
  11. package/src/signal.js +95 -0
  12. package/tools/AsyncChild.js +44 -0
  13. package/tools/access.js +36 -0
  14. package/tools/beta/collectChanges.js +88 -0
  15. package/tools/beta/syncWith.js +22 -0
  16. package/tools/condition.js +65 -0
  17. package/tools/csvDataItem.js +139 -0
  18. package/tools/db/connect.js +27 -0
  19. package/tools/db/dialect/mysql.js +12 -0
  20. package/tools/db/dialect/pg.js +15 -0
  21. package/tools/db/dialect/sqlite.js +12 -0
  22. package/tools/db/shared.js +7 -0
  23. package/tools/db/sql.d.ts +24 -0
  24. package/tools/db/sql.js +83 -0
  25. package/tools/httpRouter.js +66 -0
  26. package/tools/jsonDataItem.js +29 -0
  27. package/tools/schema/build/input.js +120 -0
  28. package/tools/schema/db/indexeddb/from-db.js +29 -0
  29. package/tools/schema/db/indexeddb/to-db.js +115 -0
  30. package/tools/schema/db/mysql/from-db.js +16 -0
  31. package/tools/schema/db/mysql/from-field.js +101 -0
  32. package/tools/schema/db/mysql/to-db.js +275 -0
  33. package/tools/schema/db/mysql/to-field.js +60 -0
  34. package/tools/schema/db/pg/from-db.js +48 -0
  35. package/tools/schema/db/pg/from-field.js +71 -0
  36. package/tools/schema/db/pg/to-db.js +223 -0
  37. package/tools/schema/db/pg/to-field.js +52 -0
  38. package/tools/schema/db/shared/from-db.js +29 -0
  39. package/tools/schema/db/shared/sql.js +26 -0
  40. package/tools/schema/db/sqlite/from-db.js +39 -0
  41. package/tools/schema/db/sqlite/from-field.js +49 -0
  42. package/tools/schema/db/sqlite/to-db.js +127 -0
  43. package/tools/schema/db/sqlite/to-field.js +34 -0
  44. package/tools/schema/diff.js +90 -0
  45. package/tools/schema/dom/toInput.js +6 -0
  46. package/tools/schema/render/dom.js +26 -0
  47. package/tools/schema/render/html.js +40 -0
  48. package/tools/schema/utils.js +131 -0
  49. package/tools/schema/validator.js +92 -0
  50. package/tools/view/dom.js +96 -0
  51. package/tools/view/uhtml.js +40 -0
  52. 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
+ }