@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,60 @@
|
|
|
1
|
+
// mysql/to-field.js — JSON Schema property → MySQL column definition
|
|
2
|
+
import { quoteId, defaultLiteral } from "../shared/sql.js";
|
|
3
|
+
export { quoteId };
|
|
4
|
+
|
|
5
|
+
const B = 4; // bytes per char, worst-case utf8mb4
|
|
6
|
+
|
|
7
|
+
const formats = { date: "DATE", time: "TIME", "date-time": "DATETIME" };
|
|
8
|
+
|
|
9
|
+
export function toFieldDef(name, prop, { required = false } = {}) {
|
|
10
|
+
let type, unsigned = false;
|
|
11
|
+
const types = Array.isArray(prop.type) ? prop.type : [prop.type];
|
|
12
|
+
const schemaType = types.find(t => t !== "null");
|
|
13
|
+
const nullable = !required || types.includes("null");
|
|
14
|
+
|
|
15
|
+
if (schemaType === "boolean") type = "TINYINT(1)";
|
|
16
|
+
else if (schemaType === "integer") ({ type, unsigned } = integerType(prop));
|
|
17
|
+
else if (schemaType === "number") type = prop.multipleOf ? "DECIMAL" : "DOUBLE";
|
|
18
|
+
else if (schemaType === "object" || schemaType === "array") type = "JSON";
|
|
19
|
+
else type = stringType(prop);
|
|
20
|
+
|
|
21
|
+
let sql = `${quoteId(name)} ${type}`;
|
|
22
|
+
if (unsigned) sql += " UNSIGNED";
|
|
23
|
+
sql += !nullable || prop["x-autoincrement"] ? " NOT NULL" : " NULL";
|
|
24
|
+
// TEXT/BLOB/JSON can't carry a literal default in MySQL; emitting one never round-trips → endless ALTERs.
|
|
25
|
+
if (prop.default != null && !/TEXT|BLOB|JSON/.test(type)) sql += ` DEFAULT ${defaultLiteral(prop)}`;
|
|
26
|
+
if (prop["x-autoincrement"]) sql += " AUTO_INCREMENT";
|
|
27
|
+
if (prop["$comment"]) {
|
|
28
|
+
sql += ` COMMENT '${prop["$comment"].replace(/'/g, "''")}'`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return sql;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function integerType(prop) {
|
|
35
|
+
if (prop.minimum == null && prop.maximum == null) return { type: "INT", unsigned: !!prop["x-autoincrement"] };
|
|
36
|
+
const min = prop.minimum ?? -Infinity, max = prop.maximum ?? Infinity;
|
|
37
|
+
const unsigned = min >= 0;
|
|
38
|
+
if (unsigned) {
|
|
39
|
+
if (max <= 255) return { type: "TINYINT", unsigned };
|
|
40
|
+
if (max <= 65535) return { type: "SMALLINT", unsigned };
|
|
41
|
+
if (max <= 16777215) return { type: "MEDIUMINT", unsigned };
|
|
42
|
+
if (max <= 4294967295) return { type: "INT", unsigned };
|
|
43
|
+
return { type: "BIGINT", unsigned };
|
|
44
|
+
}
|
|
45
|
+
if (min >= -128 && max <= 127) return { type: "TINYINT", unsigned };
|
|
46
|
+
if (min >= -32768 && max <= 32767) return { type: "SMALLINT", unsigned };
|
|
47
|
+
if (min >= -8388608 && max <= 8388607) return { type: "MEDIUMINT", unsigned };
|
|
48
|
+
if (min >= -2147483648 && max <= 2147483647) return { type: "INT", unsigned };
|
|
49
|
+
return { type: "BIGINT", unsigned };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function stringType(prop) {
|
|
53
|
+
if (prop.format in formats) return formats[prop.format];
|
|
54
|
+
if (prop.contentEncoding === "base64") return prop.maxLength ? `VARBINARY(${prop.maxLength})` : "BLOB";
|
|
55
|
+
const maxLength = prop.maxLength ?? Math.floor(65535 / B), maxBytes = maxLength * B;
|
|
56
|
+
if (maxLength <= 255) return `VARCHAR(${maxLength})`;
|
|
57
|
+
if (maxBytes <= 65535) return "TEXT";
|
|
58
|
+
if (maxBytes <= 16777215) return "MEDIUMTEXT";
|
|
59
|
+
return "LONGTEXT";
|
|
60
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// pg/from-db.js
|
|
2
|
+
import { schemaFromDb as _schemaFromDb } from '../shared/from-db.js'
|
|
3
|
+
import { queryRows } from '../shared/sql.js'
|
|
4
|
+
import { schemaFromField } from './from-field.js'
|
|
5
|
+
import { quoteLit } from './to-field.js'
|
|
6
|
+
|
|
7
|
+
const dialect = {
|
|
8
|
+
tables: async (query) => (await queryRows(query, `
|
|
9
|
+
SELECT table_name
|
|
10
|
+
FROM information_schema.tables
|
|
11
|
+
WHERE table_schema = current_schema() AND table_type = 'BASE TABLE'
|
|
12
|
+
ORDER BY table_name
|
|
13
|
+
`)).map(r => r.table_name),
|
|
14
|
+
|
|
15
|
+
fields: async (query, table) => queryRows(query, `
|
|
16
|
+
WITH ix AS (
|
|
17
|
+
SELECT a.attname AS column_name,
|
|
18
|
+
bool_or(i.indisprimary) AS is_primary,
|
|
19
|
+
bool_or(i.indisunique AND NOT i.indisprimary AND array_length(i.indkey, 1) = 1) AS is_unique,
|
|
20
|
+
bool_or(NOT i.indisunique AND NOT i.indisprimary AND array_length(i.indkey, 1) = 1) AS is_index
|
|
21
|
+
FROM pg_class t
|
|
22
|
+
JOIN pg_namespace n ON n.oid = t.relnamespace
|
|
23
|
+
JOIN pg_index i ON i.indrelid = t.oid
|
|
24
|
+
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = ANY(i.indkey)
|
|
25
|
+
WHERE n.nspname = current_schema() AND t.relname = ${quoteLit(table)}
|
|
26
|
+
GROUP BY a.attname
|
|
27
|
+
)
|
|
28
|
+
SELECT c.column_name, c.data_type, c.udt_name, c.character_maximum_length,
|
|
29
|
+
c.numeric_precision, c.numeric_scale, c.is_nullable, c.column_default,
|
|
30
|
+
c.is_identity, d.description AS comment, ix.is_primary, ix.is_unique, ix.is_index
|
|
31
|
+
FROM information_schema.columns c
|
|
32
|
+
LEFT JOIN pg_namespace n ON n.nspname = c.table_schema
|
|
33
|
+
LEFT JOIN pg_class t ON t.relnamespace = n.oid AND t.relname = c.table_name
|
|
34
|
+
LEFT JOIN pg_attribute a ON a.attrelid = t.oid AND a.attname = c.column_name
|
|
35
|
+
LEFT JOIN pg_description d ON d.objoid = t.oid AND d.objsubid = a.attnum
|
|
36
|
+
LEFT JOIN ix ON ix.column_name = c.column_name
|
|
37
|
+
WHERE c.table_schema = current_schema() AND c.table_name = ${quoteLit(table)}
|
|
38
|
+
ORDER BY c.ordinal_position
|
|
39
|
+
`),
|
|
40
|
+
|
|
41
|
+
fromField: (row) => ({
|
|
42
|
+
name: row.column_name,
|
|
43
|
+
prop: schemaFromField(row),
|
|
44
|
+
isRequired: row.is_nullable === 'NO',
|
|
45
|
+
}),
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export const schemaFromDb = (query) => _schemaFromDb(query, dialect)
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// pg/from-field.js
|
|
2
|
+
|
|
3
|
+
const intRanges = {
|
|
4
|
+
int2: [-32768, 32767],
|
|
5
|
+
smallint: [-32768, 32767],
|
|
6
|
+
int4: [-2147483648, 2147483647],
|
|
7
|
+
integer: [-2147483648, 2147483647],
|
|
8
|
+
int8: [-9007199254740991, 9007199254740991], // JS safe
|
|
9
|
+
bigint: [-9007199254740991, 9007199254740991],
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const floats = new Set(['float4', 'float8', 'real', 'double precision'])
|
|
13
|
+
const strings = new Set(['varchar', 'bpchar'])
|
|
14
|
+
|
|
15
|
+
export function schemaFromField(row) {
|
|
16
|
+
const prop = {}
|
|
17
|
+
const type = String(row.udt_name || row.data_type || '').toLowerCase()
|
|
18
|
+
const dataType = String(row.data_type || '').toLowerCase()
|
|
19
|
+
const isAuto = row.is_identity === 'YES' || /^nextval\(/i.test(row.column_default ?? '')
|
|
20
|
+
|
|
21
|
+
if (type === 'bool' || dataType === 'boolean') prop.type = 'boolean'
|
|
22
|
+
else if (type in intRanges) {
|
|
23
|
+
const [min, max] = intRanges[type]
|
|
24
|
+
prop.type = 'integer'
|
|
25
|
+
if (!isAuto) { prop.minimum = min; prop.maximum = max }
|
|
26
|
+
} else if (floats.has(type) || floats.has(dataType)) prop.type = 'number'
|
|
27
|
+
else if (type === 'numeric' || dataType === 'numeric') {
|
|
28
|
+
prop.type = 'number'
|
|
29
|
+
if (row.numeric_scale > 0) prop.multipleOf = Math.pow(10, -row.numeric_scale)
|
|
30
|
+
} else if (strings.has(type)) {
|
|
31
|
+
prop.type = 'string'
|
|
32
|
+
if (row.character_maximum_length) prop.maxLength = row.character_maximum_length
|
|
33
|
+
if (type === 'bpchar' && row.character_maximum_length) prop.minLength = row.character_maximum_length
|
|
34
|
+
} else if (type === 'bytea') {
|
|
35
|
+
prop.type = 'string'
|
|
36
|
+
prop.contentEncoding = 'base64'
|
|
37
|
+
} else if (type === 'date') {
|
|
38
|
+
prop.type = 'string'
|
|
39
|
+
prop.format = 'date'
|
|
40
|
+
} else if (type.startsWith('time') || dataType.startsWith('time')) {
|
|
41
|
+
prop.type = 'string'
|
|
42
|
+
prop.format = 'time'
|
|
43
|
+
} else if (type.startsWith('timestamp') || dataType.startsWith('timestamp')) {
|
|
44
|
+
prop.type = 'string'
|
|
45
|
+
prop.format = 'date-time'
|
|
46
|
+
} else if (type === 'json' || type === 'jsonb') prop.type = 'object'
|
|
47
|
+
else prop.type = 'string'
|
|
48
|
+
|
|
49
|
+
const def = defaultValue(row.column_default, prop)
|
|
50
|
+
if (def !== undefined) prop.default = def
|
|
51
|
+
if (row.comment) prop['$comment'] = row.comment
|
|
52
|
+
if (row.is_primary) prop['x-index'] = 'primary'
|
|
53
|
+
else if (row.is_unique) prop['x-index'] = 'unique'
|
|
54
|
+
else if (row.is_index) prop['x-index'] = true
|
|
55
|
+
if (isAuto) prop['x-autoincrement'] = true
|
|
56
|
+
return prop
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function defaultValue(value, prop) {
|
|
60
|
+
if (value == null || /^nextval\(/i.test(value) || /\b(current_|now\()/i.test(value)) return undefined
|
|
61
|
+
let v = String(value).replace(/::[\w\s.[\]"]+$/i, '')
|
|
62
|
+
if (/^'.*'$/.test(v)) v = v.slice(1, -1).replace(/''/g, "'")
|
|
63
|
+
if (prop.type === 'boolean') return /^(true|t|1)$/i.test(v)
|
|
64
|
+
if (prop.type === 'integer') return numberOr(v, Number.parseInt(v, 10))
|
|
65
|
+
if (prop.type === 'number') return numberOr(v, Number(v))
|
|
66
|
+
return v
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function numberOr(fallback, value) {
|
|
70
|
+
return Number.isNaN(value) ? fallback : value
|
|
71
|
+
}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
// pg/to-db.js
|
|
2
|
+
import { schemaFromDb } from './from-db.js'
|
|
3
|
+
import { queryRows } from '../shared/sql.js'
|
|
4
|
+
import { defaultLiteral, quoteId, quoteLit, toFieldDef, typeSql } from './to-field.js'
|
|
5
|
+
import { schemaDiff } from '../../diff.js'
|
|
6
|
+
|
|
7
|
+
function tableData(tableSchema) {
|
|
8
|
+
const row = tableSchema.additionalProperties ?? {}
|
|
9
|
+
const fields = Object.entries(row.properties ?? {})
|
|
10
|
+
const required = new Set(row.required ?? [])
|
|
11
|
+
return { fields, required }
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function primaryFields(fields) {
|
|
15
|
+
return fields.filter(([, f]) => f['x-index'] === 'primary').map(([n]) => n)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const columnProps = new Set([
|
|
19
|
+
'type', 'format', 'contentEncoding', 'maxLength', 'minLength',
|
|
20
|
+
'minimum', 'maximum', 'multipleOf',
|
|
21
|
+
])
|
|
22
|
+
|
|
23
|
+
function hasOwn(obj, key) {
|
|
24
|
+
return Object.prototype.hasOwnProperty.call(obj, key)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function setEqual(a, b) {
|
|
28
|
+
if (a.size !== b.size) return false
|
|
29
|
+
for (const v of a) if (!b.has(v)) return false
|
|
30
|
+
return true
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function fieldChanged(diffs, table, field, prop) {
|
|
34
|
+
return diffs.some(d => {
|
|
35
|
+
const [a, b, c, e, f] = d.path
|
|
36
|
+
return a === 'properties' && b === table && c === 'additionalProperties' && e === 'properties' && f === field &&
|
|
37
|
+
(columnProps.has(d.property) || d.property === 'default' && hasOwn(prop, 'default') || d.property === 'x-autoincrement')
|
|
38
|
+
})
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function preserveDefault(prop, currProp) {
|
|
42
|
+
return hasOwn(prop, 'default') || !hasOwn(currProp, 'default')
|
|
43
|
+
? prop
|
|
44
|
+
: { ...prop, default: currProp.default }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function secondaryIndexKind(table, name, prop) {
|
|
48
|
+
if (prop['x-index'] === 'unique') return 'UNIQUE INDEX'
|
|
49
|
+
if (prop['x-index'] === true) return 'INDEX'
|
|
50
|
+
if (prop['x-index'] === 'fulltext')
|
|
51
|
+
console.warn(`Skip FULLTEXT INDEX ${table}.${name}: PostgreSQL fulltext requires an expression index`)
|
|
52
|
+
return null
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function indexName(table, name) {
|
|
56
|
+
return `idx_${table}_${name}`
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function createIndex(table, name, kind) {
|
|
60
|
+
return `CREATE ${kind} ${quoteId(indexName(table, name))} ON ${quoteId(table)} (${quoteId(name)});`
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function tableIndexes(query, table) {
|
|
64
|
+
const primaryRows = await queryRows(query, `
|
|
65
|
+
SELECT con.conname, array_agg(a.attname ORDER BY u.ord) AS columns
|
|
66
|
+
FROM pg_constraint con
|
|
67
|
+
JOIN pg_class t ON t.oid = con.conrelid
|
|
68
|
+
JOIN pg_namespace n ON n.oid = t.relnamespace
|
|
69
|
+
JOIN unnest(con.conkey) WITH ORDINALITY AS u(attnum, ord) ON true
|
|
70
|
+
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = u.attnum
|
|
71
|
+
WHERE con.contype = 'p' AND n.nspname = current_schema() AND t.relname = ${quoteLit(table)}
|
|
72
|
+
GROUP BY con.conname
|
|
73
|
+
`)
|
|
74
|
+
const secondary = await queryRows(query, `
|
|
75
|
+
SELECT ci.relname AS key, a.attname AS column, i.indisunique AS unique
|
|
76
|
+
FROM pg_index i
|
|
77
|
+
JOIN pg_class t ON t.oid = i.indrelid
|
|
78
|
+
JOIN pg_namespace n ON n.oid = t.relnamespace
|
|
79
|
+
JOIN pg_class ci ON ci.oid = i.indexrelid
|
|
80
|
+
JOIN unnest(i.indkey) WITH ORDINALITY AS k(attnum, ord) ON true
|
|
81
|
+
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = k.attnum
|
|
82
|
+
WHERE NOT i.indisprimary
|
|
83
|
+
AND array_length(i.indkey, 1) = 1
|
|
84
|
+
AND n.nspname = current_schema()
|
|
85
|
+
AND t.relname = ${quoteLit(table)}
|
|
86
|
+
`)
|
|
87
|
+
const primary = primaryRows[0]
|
|
88
|
+
return { primaryName: primary?.conname, primary: primary?.columns ?? [], secondary }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function commentStmt(table, name, comment) {
|
|
92
|
+
return `COMMENT ON COLUMN ${quoteId(table)}.${quoteId(name)} IS ${comment ? quoteLit(comment) : 'NULL'};`
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function alterFieldStmts(table, name, prop, currProp, required, currRequired, changed) {
|
|
96
|
+
const stmts = [], tableId = quoteId(table), colId = quoteId(name)
|
|
97
|
+
if (currProp['x-autoincrement'] && !prop['x-autoincrement'])
|
|
98
|
+
stmts.push(`ALTER TABLE ${tableId} ALTER COLUMN ${colId} DROP IDENTITY IF EXISTS;`)
|
|
99
|
+
if (changed && typeSql(prop) !== typeSql(currProp))
|
|
100
|
+
stmts.push(`ALTER TABLE ${tableId} ALTER COLUMN ${colId} TYPE ${typeSql(prop)} USING ${colId}::${typeSql(prop)};`)
|
|
101
|
+
if (prop['x-autoincrement'] && !currProp['x-autoincrement'] && hasOwn(currProp, 'default'))
|
|
102
|
+
stmts.push(`ALTER TABLE ${tableId} ALTER COLUMN ${colId} DROP DEFAULT;`)
|
|
103
|
+
if (prop['x-autoincrement'] && !currProp['x-autoincrement'])
|
|
104
|
+
stmts.push(`ALTER TABLE ${tableId} ALTER COLUMN ${colId} ADD GENERATED BY DEFAULT AS IDENTITY;`)
|
|
105
|
+
if (hasOwn(prop, 'default') && !prop['x-autoincrement'])
|
|
106
|
+
stmts.push(`ALTER TABLE ${tableId} ALTER COLUMN ${colId} SET DEFAULT ${defaultLiteral(prop)};`)
|
|
107
|
+
if (required !== currRequired)
|
|
108
|
+
stmts.push(`ALTER TABLE ${tableId} ALTER COLUMN ${colId} ${required ? 'SET' : 'DROP'} NOT NULL;`)
|
|
109
|
+
if (prop['$comment'] !== currProp['$comment'])
|
|
110
|
+
stmts.push(commentStmt(table, name, prop['$comment']))
|
|
111
|
+
return stmts
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export async function schemaToDb(schema, query, { force = false, patch = false } = {}) {
|
|
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)
|
|
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 primarySet = new Set(primaries)
|
|
144
|
+
stmts.push(`CREATE TABLE ${tableId} (\n${cols}${pk}\n);`)
|
|
145
|
+
for (const [n, f] of fields) if (f['$comment']) stmts.push(commentStmt(table, n, f['$comment']))
|
|
146
|
+
for (const [n, f] of fields) {
|
|
147
|
+
if (primarySet.has(n)) continue
|
|
148
|
+
const kind = secondaryIndexKind(table, n, f)
|
|
149
|
+
if (kind) stmts.push(createIndex(table, n, kind))
|
|
150
|
+
}
|
|
151
|
+
} else {
|
|
152
|
+
const { fields: currFieldEntries, required: currRequired } = tableData(current.properties[table])
|
|
153
|
+
const currFields = new Map(currFieldEntries)
|
|
154
|
+
const nextFieldNames = new Set(fields.map(([n]) => n))
|
|
155
|
+
const indexStmts = await indexStatements(query, table, fields, currFieldEntries, { patch })
|
|
156
|
+
stmts.push(...indexStmts.before)
|
|
157
|
+
for (const [name, prop] of fields) {
|
|
158
|
+
const isRequired = required.has(name)
|
|
159
|
+
const currProp = currFields.get(name)
|
|
160
|
+
if (!currProp) {
|
|
161
|
+
stmts.push(`ALTER TABLE ${tableId} ADD COLUMN ${toFieldDef(name, prop, { required: isRequired })};`)
|
|
162
|
+
if (prop['$comment']) stmts.push(commentStmt(table, name, prop['$comment']))
|
|
163
|
+
continue
|
|
164
|
+
}
|
|
165
|
+
const propWithDefault = preserveDefault(prop, currProp)
|
|
166
|
+
const changed = fieldChanged(diffs, table, name, prop) || isRequired !== currRequired.has(name) || propWithDefault['$comment'] !== currProp['$comment']
|
|
167
|
+
if (changed) stmts.push(...alterFieldStmts(table, name, propWithDefault, currProp, isRequired, currRequired.has(name), changed))
|
|
168
|
+
}
|
|
169
|
+
if (!patch) {
|
|
170
|
+
for (const name of currFields.keys()) {
|
|
171
|
+
if (!nextFieldNames.has(name))
|
|
172
|
+
stmts.push(`ALTER TABLE ${tableId} DROP COLUMN ${quoteId(name)};`)
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
stmts.push(...indexStmts.after)
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (!patch) {
|
|
180
|
+
for (const table of currTables) {
|
|
181
|
+
if (!nextTables.includes(table)) stmts.push(`DROP TABLE ${quoteId(table)};`)
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
for (const stmt of stmts) {
|
|
186
|
+
console.log(stmt)
|
|
187
|
+
await query(stmt)
|
|
188
|
+
}
|
|
189
|
+
return { diffs, executed: stmts }
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function indexStatements(query, table, nextFields, currFields, { patch = false } = {}) {
|
|
193
|
+
const before = [], after = [], tableId = quoteId(table)
|
|
194
|
+
const indexes = await tableIndexes(query, table)
|
|
195
|
+
const currPrimary = new Set(indexes.primary.length ? indexes.primary : primaryFields(currFields))
|
|
196
|
+
const nextPrimary = new Set(primaryFields(nextFields))
|
|
197
|
+
const primaryChanged = !setEqual(currPrimary, nextPrimary) && (!patch || nextPrimary.size)
|
|
198
|
+
|
|
199
|
+
if (primaryChanged) {
|
|
200
|
+
if (indexes.primaryName) before.push(`ALTER TABLE ${tableId} DROP CONSTRAINT ${quoteId(indexes.primaryName)};`)
|
|
201
|
+
if (nextPrimary.size) after.push(`ALTER TABLE ${tableId} ADD PRIMARY KEY (${[...nextPrimary].map(quoteId).join(', ')});`)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
for (const [name, prop] of nextFields) {
|
|
205
|
+
if (nextPrimary.has(name)) continue
|
|
206
|
+
const nextKind = secondaryIndexKind(table, name, prop)
|
|
207
|
+
const curr = indexes.secondary.find(i => i.column === name)
|
|
208
|
+
const currKind = curr ? curr.unique ? 'UNIQUE INDEX' : 'INDEX' : null
|
|
209
|
+
const changed = curr && currKind !== nextKind
|
|
210
|
+
if (patch && !nextKind) continue
|
|
211
|
+
if (changed) before.push(`DROP INDEX ${quoteId(curr.key)};`)
|
|
212
|
+
if (nextKind && (!curr || changed)) after.push(createIndex(table, name, nextKind))
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (!patch) {
|
|
216
|
+
for (const curr of indexes.secondary) {
|
|
217
|
+
const wanted = nextFields.find(([name, prop]) => name === curr.column && secondaryIndexKind(table, name, prop))
|
|
218
|
+
if (!wanted) before.push(`DROP INDEX ${quoteId(curr.key)};`)
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return { before, after }
|
|
223
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// pg/to-field.js - JSON Schema property -> PostgreSQL column definition
|
|
2
|
+
import { schemaType } from '../shared/sql.js'
|
|
3
|
+
|
|
4
|
+
const formats = { date: 'DATE', time: 'TIME', 'date-time': 'TIMESTAMP' }
|
|
5
|
+
const maxVarchar = 10485760
|
|
6
|
+
|
|
7
|
+
export function quoteId(name) {
|
|
8
|
+
return `"${String(name).replaceAll('"', '""')}"`
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function quoteLit(value) {
|
|
12
|
+
return `'${String(value).replace(/'/g, "''")}'`
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function typeSql(prop) {
|
|
16
|
+
const t = schemaType(prop)
|
|
17
|
+
if (t === 'boolean') return 'BOOLEAN'
|
|
18
|
+
if (t === 'integer') return integerType(prop)
|
|
19
|
+
if (t === 'number') return prop.multipleOf ? 'NUMERIC' : 'DOUBLE PRECISION'
|
|
20
|
+
if (t === 'object' || t === 'array') return 'JSONB'
|
|
21
|
+
if (prop.format in formats) return formats[prop.format]
|
|
22
|
+
if (prop.contentEncoding === 'base64') return 'BYTEA'
|
|
23
|
+
return prop.maxLength && prop.maxLength <= maxVarchar ? `VARCHAR(${prop.maxLength})` : 'TEXT'
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function defaultLiteral(prop) {
|
|
27
|
+
const t = schemaType(prop)
|
|
28
|
+
if (t === 'boolean') return prop.default ? 'TRUE' : 'FALSE'
|
|
29
|
+
if (t === 'integer' || t === 'number') return Number(prop.default)
|
|
30
|
+
if (typeof prop.default === 'boolean') return "''"
|
|
31
|
+
if (t === 'object' || t === 'array') return `${quoteLit(JSON.stringify(prop.default))}::jsonb`
|
|
32
|
+
return quoteLit(prop.default)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function toFieldDef(name, prop, { required = false } = {}) {
|
|
36
|
+
const types = Array.isArray(prop.type) ? prop.type : [prop.type]
|
|
37
|
+
const nullable = !required || types.includes('null')
|
|
38
|
+
let sql = `${quoteId(name)} ${typeSql(prop)}`
|
|
39
|
+
if (prop['x-autoincrement']) sql += ' GENERATED BY DEFAULT AS IDENTITY'
|
|
40
|
+
sql += !nullable || prop['x-autoincrement'] ? ' NOT NULL' : ' NULL'
|
|
41
|
+
if (prop.default != null && !prop['x-autoincrement']) sql += ` DEFAULT ${defaultLiteral(prop)}`
|
|
42
|
+
return sql
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function integerType(prop) {
|
|
46
|
+
if (prop['x-autoincrement']) return 'INTEGER'
|
|
47
|
+
if (prop.minimum == null && prop.maximum == null) return 'INTEGER'
|
|
48
|
+
const min = prop.minimum ?? -Infinity, max = prop.maximum ?? Infinity
|
|
49
|
+
if (min >= -32768 && max <= 32767) return 'SMALLINT'
|
|
50
|
+
if (min >= -2147483648 && max <= 2147483647) return 'INTEGER'
|
|
51
|
+
return 'BIGINT'
|
|
52
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// shared/from-db.js
|
|
2
|
+
|
|
3
|
+
export function schemaFromTable(fields, fromField) {
|
|
4
|
+
const properties = {}, required = [];
|
|
5
|
+
for (const f of fields) {
|
|
6
|
+
const { name, prop, isRequired } = fromField(f);
|
|
7
|
+
properties[name] = prop;
|
|
8
|
+
if (isRequired) required.push(name);
|
|
9
|
+
}
|
|
10
|
+
return {
|
|
11
|
+
additionalProperties: {
|
|
12
|
+
properties,
|
|
13
|
+
...(required.length && { required }),
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function schemaFromDb(db, dialect) {
|
|
19
|
+
const tables = await dialect.tables(db);
|
|
20
|
+
const entries = await Promise.all(
|
|
21
|
+
tables.map(
|
|
22
|
+
async (t) => [
|
|
23
|
+
t,
|
|
24
|
+
schemaFromTable(await dialect.fields(db, t), dialect.fromField),
|
|
25
|
+
],
|
|
26
|
+
),
|
|
27
|
+
);
|
|
28
|
+
return { properties: Object.fromEntries(entries) };
|
|
29
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// shared/sql.js — helpers shared by SQL dialects
|
|
2
|
+
|
|
3
|
+
export function quoteId(name) {
|
|
4
|
+
return `\`${String(name).replaceAll('`', '``')}\``;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export async function queryRows(query, sql) {
|
|
8
|
+
const res = await query(sql);
|
|
9
|
+
return Array.isArray(res) ? res : res.rows ?? [];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** First non-null JSON-Schema type: 'integer' | ['string','null'] → 'string'. */
|
|
13
|
+
export function schemaType(prop) {
|
|
14
|
+
return Array.isArray(prop.type) ? prop.type.find(t => t !== 'null') : prop.type;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const numericTypes = new Set(['boolean', 'integer', 'number']);
|
|
18
|
+
|
|
19
|
+
/** JSON-Schema default → SQL literal: numeric columns get an unquoted number
|
|
20
|
+
* (boolean false→0, true→1), string columns a quoted literal ('' for a boolean).
|
|
21
|
+
* Used by mysql/sqlite; dialects with native literals can keep their own renderer. */
|
|
22
|
+
export function defaultLiteral(prop) {
|
|
23
|
+
if (numericTypes.has(schemaType(prop))) return Number(prop.default);
|
|
24
|
+
if (typeof prop.default === 'boolean') return "''";
|
|
25
|
+
return `'${String(prop.default).replace(/'/g, "''")}'`;
|
|
26
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// sqlite/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
|
+
/** Single-column, non-constraint indexes: [{ name, column, unique }]. */
|
|
7
|
+
export async function singleColumnIndexes(query, table) {
|
|
8
|
+
const out = []
|
|
9
|
+
for (const row of await query(`PRAGMA index_list(${quoteId(table)})`)) {
|
|
10
|
+
if (row.origin && row.origin !== 'c') continue
|
|
11
|
+
const cols = await query(`PRAGMA index_info(${quoteId(row.name)})`)
|
|
12
|
+
if (cols.length === 1) out.push({ name: row.name, column: cols[0].name, unique: !!row.unique })
|
|
13
|
+
}
|
|
14
|
+
return out
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function indexes(query, table) {
|
|
18
|
+
const map = new Map()
|
|
19
|
+
for (const { column, unique } of await singleColumnIndexes(query, table))
|
|
20
|
+
map.set(column, unique ? 'unique' : true)
|
|
21
|
+
return map
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const dialect = {
|
|
25
|
+
tables: async (query) => (await query("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")).map(r => r.name),
|
|
26
|
+
fields: async (query, table) => {
|
|
27
|
+
const ix = await indexes(query, table)
|
|
28
|
+
return (await query(`PRAGMA table_info(${quoteId(table)})`)).map(row =>
|
|
29
|
+
row.pk || !ix.has(row.name) ? row : { ...row, index: ix.get(row.name) }
|
|
30
|
+
)
|
|
31
|
+
},
|
|
32
|
+
fromField: (row) => ({
|
|
33
|
+
name: row.name,
|
|
34
|
+
prop: schemaFromField(row),
|
|
35
|
+
isRequired: row.notnull === 1 && !row.pk,
|
|
36
|
+
}),
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const schemaFromDb = (query) => _schemaFromDb(query, dialect)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// sqlite/schema-from-field.js
|
|
2
|
+
// PRAGMA table_info returns: cid, name, type, notnull, dflt_value, pk
|
|
3
|
+
// SQLite lengths are in characters — no byte conversion needed
|
|
4
|
+
|
|
5
|
+
const intRanges = {
|
|
6
|
+
tinyint: { signed: [-128, 127] },
|
|
7
|
+
smallint: { signed: [-32768, 32767] },
|
|
8
|
+
mediumint: { signed: [-8388608, 8388607] },
|
|
9
|
+
int: { signed: [-2147483648, 2147483647] },
|
|
10
|
+
integer: { signed: [-2147483648, 2147483647] },
|
|
11
|
+
bigint: { signed: [-9007199254740991, 9007199254740991] }, // JS safe
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function schemaFromField(row) {
|
|
15
|
+
const prop = {}
|
|
16
|
+
const raw = (row.type ?? '').trim()
|
|
17
|
+
const m = raw.match(/^(\w+)\s*(?:\(([^)]+)\))?/)
|
|
18
|
+
const type = m?.[1]?.toLowerCase() ?? 'text'
|
|
19
|
+
const len = m?.[2] ? parseInt(m[2]) : null
|
|
20
|
+
|
|
21
|
+
if (type in intRanges) {
|
|
22
|
+
const [min, max] = intRanges[type].signed
|
|
23
|
+
prop.type = 'integer'; prop.minimum = min; prop.maximum = max
|
|
24
|
+
|
|
25
|
+
} else if (type === 'boolean' || type === 'bool') { prop.type = 'boolean'
|
|
26
|
+
} else if (type === 'real' || type === 'float' || type === 'double') { prop.type = 'number'
|
|
27
|
+
} else if (type === 'numeric' || type === 'decimal') { prop.type = 'number'
|
|
28
|
+
|
|
29
|
+
} else if (type === 'varchar' || type === 'nvarchar') {
|
|
30
|
+
prop.type = 'string'; if (len) prop.maxLength = len
|
|
31
|
+
|
|
32
|
+
} else if (type === 'char' || type === 'nchar') {
|
|
33
|
+
prop.type = 'string'; if (len) { prop.minLength = len; prop.maxLength = len }
|
|
34
|
+
|
|
35
|
+
} else if (type === 'blob') { prop.type = 'string'; prop.contentEncoding = 'base64'
|
|
36
|
+
} else if (type === 'date') { prop.type = 'string'; prop.format = 'date'
|
|
37
|
+
} else if (type === 'time') { prop.type = 'string'; prop.format = 'time'
|
|
38
|
+
} else if (type === 'datetime' || type === 'timestamp') { prop.type = 'string'; prop.format = 'date-time'
|
|
39
|
+
} else if (type === 'json') { prop.type = 'object'
|
|
40
|
+
} else {
|
|
41
|
+
prop.type = 'string'
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (row.dflt_value != null) prop.default = row.dflt_value
|
|
45
|
+
if (row.pk) prop['x-index'] = 'primary'
|
|
46
|
+
else if (row.index) prop['x-index'] = row.index
|
|
47
|
+
|
|
48
|
+
return prop
|
|
49
|
+
}
|