@nuxodin/item 0.6.5 → 0.6.8

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nuxodin/item",
3
- "version": "0.6.5",
3
+ "version": "0.6.8",
4
4
  "description": "Reactive state library",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,11 +1,11 @@
1
1
  // mysql/from-db.js
2
2
  import { schemaFromDb as _schemaFromDb } from '../shared/from-db.js'
3
- import { quoteId } from '../shared/sql.js'
3
+ import { queryRows, quoteId } from '../shared/sql.js'
4
4
  import { schemaFromField } from './from-field.js'
5
5
 
6
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)}`),
7
+ tables: async (query) => (await queryRows(query, 'SHOW TABLES')).map(r => Object.values(r)[0]),
8
+ fields: async (query, table) => queryRows(query, `SHOW FULL FIELDS FROM ${quoteId(table)}`),
9
9
  fromField: (row) => ({
10
10
  name: row.Field,
11
11
  prop: schemaFromField(row),
@@ -90,7 +90,7 @@ export function schemaFromField(row) {
90
90
  };
91
91
  } else prop.type = "string";
92
92
 
93
- if (row.Default != null) prop.default = row.Default;
93
+ if (row.Default != null) prop.default = parseDefault(row.Default, prop.type);
94
94
  if (row.Comment) prop["$comment"] = row.Comment;
95
95
  if (row.Key === "PRI") prop["x-index"] = "primary";
96
96
  if (row.Key === "UNI") prop["x-index"] = "unique";
@@ -99,3 +99,12 @@ export function schemaFromField(row) {
99
99
 
100
100
  return prop;
101
101
  }
102
+
103
+ /** SHOW FULL FIELDS hands the default back as text — read it as the value the schema would declare. */
104
+ function parseDefault(value, type) {
105
+ const v = String(value);
106
+ const n = Number(v);
107
+ if (type === "boolean") return v !== "0";
108
+ if ((type === "integer" || type === "number") && !isNaN(n)) return n;
109
+ return v;
110
+ }
@@ -2,6 +2,7 @@
2
2
  import { schemaFromDb } from "./from-db.js";
3
3
  import { quoteId, toFieldDef } from "./to-field.js";
4
4
  import { schemaDiff } from "../../diff.js";
5
+ import { fieldNeedsDdl } from "../shared/needs-ddl.js";
5
6
 
6
7
  function tableData(tableSchema) {
7
8
  const row = tableSchema.additionalProperties ?? {};
@@ -14,26 +15,10 @@ function primaryFields(fields) {
14
15
  return fields.filter(([, f]) => f["x-index"] === "primary").map(([n]) => n);
15
16
  }
16
17
 
17
- const columnProps = new Set([
18
- "type", "format", "contentEncoding", "maxLength", "minLength",
19
- "minimum", "maximum", "multipleOf", "$comment",
20
- ]);
21
-
22
18
  function hasOwn(obj, key) {
23
19
  return Object.prototype.hasOwnProperty.call(obj, key);
24
20
  }
25
21
 
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
22
  function preserveDefault(prop, currProp) {
38
23
  return hasOwn(prop, "default") || !hasOwn(currProp, "default")
39
24
  ? prop
@@ -153,7 +138,8 @@ export async function schemaToDb(
153
138
  const { fields: currFieldEntries, required: currRequired } = tableData(
154
139
  current.properties[table],
155
140
  );
156
- const currFields = Object.keys(Object.fromEntries(currFieldEntries));
141
+ const currFields = new Map(currFieldEntries);
142
+ const nextFieldNames = new Set(fields.map(([n]) => n));
157
143
  const indexStmts = await indexStatements(
158
144
  query,
159
145
  table,
@@ -165,20 +151,21 @@ export async function schemaToDb(
165
151
  const autoAfter = new Map();
166
152
  for (const [name, prop] of fields) {
167
153
  const isRequired = required.has(name);
168
- const curr = currFieldEntries.find(([n]) => n === name);
169
- const propWithDefault = curr ? preserveDefault(prop, curr[1]) : prop;
154
+ const currProp = currFields.get(name);
155
+ const propWithDefault = currProp ? preserveDefault(prop, currProp) : prop;
170
156
  const fieldProp = propWithDefault["x-autoincrement"] ? { ...propWithDefault, "x-autoincrement": false } : propWithDefault;
171
157
  const fieldDef = toFieldDef(name, fieldProp, { required: isRequired });
172
- if (!currFields.includes(name)) {
158
+ if (!currProp) {
173
159
  stmts.push(`ALTER TABLE ${tableId} ADD COLUMN ${fieldDef};`);
174
160
  if (prop["x-autoincrement"]) autoAfter.set(name, prop);
175
161
  } else {
176
- const hasAuto = !!curr?.[1]["x-autoincrement"];
162
+ const hasAuto = !!currProp["x-autoincrement"];
177
163
  const wantsAuto = !!prop["x-autoincrement"];
178
- const wantsChange = fieldChanged(diffs, table, name, prop) ||
164
+ // AUTO_INCREMENT is added and dropped by its own statements below, so it stays out of here
165
+ const wantsChange = fieldNeedsDdl(fieldProp, { ...currProp, "x-autoincrement": false }, ["multipleOf", "$comment"]) ||
179
166
  isRequired !== currRequired.has(name) ||
180
167
  hasAuto && !wantsAuto;
181
- const changed = wantsChange && fieldDef !== toFieldDef(name, curr[1], { required: currRequired.has(name) });
168
+ const changed = wantsChange && fieldDef !== toFieldDef(name, currProp, { required: currRequired.has(name) });
182
169
  if (changed) {
183
170
  stmts.push(`ALTER TABLE ${tableId} MODIFY COLUMN ${fieldDef};`);
184
171
  }
@@ -186,8 +173,8 @@ export async function schemaToDb(
186
173
  }
187
174
  }
188
175
  if (!patch) {
189
- for (const name of currFields) {
190
- if (!fields.find(([n]) => n === name)) {
176
+ for (const name of currFields.keys()) {
177
+ if (!nextFieldNames.has(name)) {
191
178
  stmts.push(`ALTER TABLE ${tableId} DROP COLUMN ${quoteId(name)};`);
192
179
  }
193
180
  }
@@ -46,7 +46,7 @@ export function schemaFromField(row) {
46
46
  } else if (type === 'json' || type === 'jsonb') prop.type = 'object'
47
47
  else prop.type = 'string'
48
48
 
49
- const def = defaultValue(row.column_default, prop)
49
+ const def = parseDefault(row.column_default, prop.type)
50
50
  if (def !== undefined) prop.default = def
51
51
  if (row.comment) prop['$comment'] = row.comment
52
52
  if (row.is_primary) prop['x-index'] = 'primary'
@@ -56,16 +56,13 @@ export function schemaFromField(row) {
56
56
  return prop
57
57
  }
58
58
 
59
- function defaultValue(value, prop) {
59
+ /** information_schema hands the default back as an SQL literal — read it as the value the schema would declare. */
60
+ function parseDefault(value, type) {
60
61
  if (value == null || /^nextval\(/i.test(value) || /\b(current_|now\()/i.test(value)) return undefined
61
62
  let v = String(value).replace(/::[\w\s.[\]"]+$/i, '')
62
63
  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))
64
+ const n = Number(v)
65
+ if (type === 'boolean') return /^(true|t|1)$/i.test(v)
66
+ if ((type === 'integer' || type === 'number') && !isNaN(n)) return n
66
67
  return v
67
68
  }
68
-
69
- function numberOr(fallback, value) {
70
- return Number.isNaN(value) ? fallback : value
71
- }
@@ -3,6 +3,7 @@ import { schemaFromDb } from './from-db.js'
3
3
  import { queryRows } from '../shared/sql.js'
4
4
  import { defaultLiteral, quoteId, quoteLit, toFieldDef, typeSql } from './to-field.js'
5
5
  import { schemaDiff } from '../../diff.js'
6
+ import { fieldNeedsDdl } from '../shared/needs-ddl.js'
6
7
 
7
8
  function tableData(tableSchema) {
8
9
  const row = tableSchema.additionalProperties ?? {}
@@ -15,11 +16,6 @@ function primaryFields(fields) {
15
16
  return fields.filter(([, f]) => f['x-index'] === 'primary').map(([n]) => n)
16
17
  }
17
18
 
18
- const columnProps = new Set([
19
- 'type', 'format', 'contentEncoding', 'maxLength', 'minLength',
20
- 'minimum', 'maximum', 'multipleOf',
21
- ])
22
-
23
19
  function hasOwn(obj, key) {
24
20
  return Object.prototype.hasOwnProperty.call(obj, key)
25
21
  }
@@ -30,14 +26,6 @@ function setEqual(a, b) {
30
26
  return true
31
27
  }
32
28
 
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
29
  function preserveDefault(prop, currProp) {
42
30
  return hasOwn(prop, 'default') || !hasOwn(currProp, 'default')
43
31
  ? prop
@@ -163,7 +151,8 @@ export async function schemaToDb(schema, query, { force = false, patch = false }
163
151
  continue
164
152
  }
165
153
  const propWithDefault = preserveDefault(prop, currProp)
166
- const changed = fieldChanged(diffs, table, name, prop) || isRequired !== currRequired.has(name) || propWithDefault['$comment'] !== currProp['$comment']
154
+ const changed = fieldNeedsDdl(propWithDefault, currProp, ['multipleOf', '$comment']) ||
155
+ isRequired !== currRequired.has(name)
167
156
  if (changed) stmts.push(...alterFieldStmts(table, name, propWithDefault, currProp, isRequired, currRequired.has(name), changed))
168
157
  }
169
158
  if (!patch) {
@@ -0,0 +1,28 @@
1
+ // shared/needs-ddl.js — does the existing column still satisfy the schema?
2
+ //
3
+ // The schema travels with the database and holds what no DDL expresses (pattern, enum, required,
4
+ // x-*). Reconstructing a schema from the database is lossy, so comparing the two for equality
5
+ // turns every gap into a change and rebuilds every table forever. Each property gets the question
6
+ // that fits it: capacity asks whether the column still holds what the schema wants (widen only),
7
+ // structural asks for equality, everything else is enforced by the application alone.
8
+ import { schemaType } from './sql.js'
9
+
10
+ const capacity = {
11
+ maximum: (need, have) => have >= need,
12
+ minimum: (need, have) => have <= need,
13
+ maxLength: (need, have) => have >= need,
14
+ }
15
+
16
+ const structural = ['format', 'contentEncoding', 'x-index', 'default']
17
+
18
+ /** @param dialectStructural properties this dialect renders too, e.g. 'multipleOf' on MySQL. */
19
+ export function fieldNeedsDdl(next, curr, dialectStructural = []) {
20
+ for (const [prop, holds] of Object.entries(capacity)) {
21
+ const need = next[prop], have = curr[prop]
22
+ // no requirement, or a bound the database never reported — nothing to widen towards
23
+ if (need !== undefined && have !== undefined && !holds(need, have)) return true
24
+ }
25
+ if (schemaType(next) !== schemaType(curr)) return true
26
+ if (!!next['x-autoincrement'] !== !!curr['x-autoincrement']) return true
27
+ return [...structural, ...dialectStructural].some(prop => next[prop] !== curr[prop])
28
+ }
@@ -1,14 +1,14 @@
1
1
  // sqlite/from-db.js
2
2
  import { schemaFromDb as _schemaFromDb } from '../shared/from-db.js'
3
- import { quoteId } from '../shared/sql.js'
3
+ import { queryRows, quoteId } from '../shared/sql.js'
4
4
  import { schemaFromField } from './from-field.js'
5
5
 
6
6
  /** Single-column, non-constraint indexes: [{ name, column, unique }]. */
7
7
  export async function singleColumnIndexes(query, table) {
8
8
  const out = []
9
- for (const row of await query(`PRAGMA index_list(${quoteId(table)})`)) {
9
+ for (const row of await queryRows(query, `PRAGMA index_list(${quoteId(table)})`)) {
10
10
  if (row.origin && row.origin !== 'c') continue
11
- const cols = await query(`PRAGMA index_info(${quoteId(row.name)})`)
11
+ const cols = await queryRows(query, `PRAGMA index_info(${quoteId(row.name)})`)
12
12
  if (cols.length === 1) out.push({ name: row.name, column: cols[0].name, unique: !!row.unique })
13
13
  }
14
14
  return out
@@ -22,11 +22,15 @@ async function indexes(query, table) {
22
22
  }
23
23
 
24
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),
25
+ tables: async (query) => (await queryRows(query, "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")).map(r => r.name),
26
26
  fields: async (query, table) => {
27
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) }
28
+ // AUTOINCREMENT survives only in the stored DDL — no PRAGMA reports it
29
+ const [master] = await queryRows(query, `SELECT sql FROM sqlite_master WHERE type='table' AND name='${table.replaceAll("'", "''")}'`)
30
+ const auto = /\bautoincrement\b/i.test(master?.sql ?? '')
31
+ return (await queryRows(query, `PRAGMA table_info(${quoteId(table)})`)).map(row =>
32
+ row.pk ? { ...row, autoincrement: auto }
33
+ : ix.has(row.name) ? { ...row, index: ix.get(row.name) } : row
30
34
  )
31
35
  },
32
36
  fromField: (row) => ({
@@ -2,14 +2,11 @@
2
2
  // PRAGMA table_info returns: cid, name, type, notnull, dflt_value, pk
3
3
  // SQLite lengths are in characters — no byte conversion needed
4
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
- }
5
+ // SQLite does not enforce a declared integer width — a TINYINT column happily stores 5000.
6
+ // So every spelling reports the same, real bound: the INTEGER storage class, capped where a
7
+ // JS number stops being exact.
8
+ const intTypes = new Set(['tinyint', 'smallint', 'mediumint', 'int', 'integer', 'bigint'])
9
+ const intMax = 9007199254740991
13
10
 
14
11
  export function schemaFromField(row) {
15
12
  const prop = {}
@@ -18,9 +15,8 @@ export function schemaFromField(row) {
18
15
  const type = m?.[1]?.toLowerCase() ?? 'text'
19
16
  const len = m?.[2] ? parseInt(m[2]) : null
20
17
 
21
- if (type in intRanges) {
22
- const [min, max] = intRanges[type].signed
23
- prop.type = 'integer'; prop.minimum = min; prop.maximum = max
18
+ if (intTypes.has(type)) {
19
+ prop.type = 'integer'; prop.minimum = -intMax; prop.maximum = intMax
24
20
 
25
21
  } else if (type === 'boolean' || type === 'bool') { prop.type = 'boolean'
26
22
  } else if (type === 'real' || type === 'float' || type === 'double') { prop.type = 'number'
@@ -41,9 +37,20 @@ export function schemaFromField(row) {
41
37
  prop.type = 'string'
42
38
  }
43
39
 
44
- if (row.dflt_value != null) prop.default = row.dflt_value
40
+ if (row.dflt_value != null) prop.default = parseDefault(row.dflt_value, prop.type)
45
41
  if (row.pk) prop['x-index'] = 'primary'
46
42
  else if (row.index) prop['x-index'] = row.index
43
+ if (row.autoincrement) prop['x-autoincrement'] = true
47
44
 
48
45
  return prop
49
46
  }
47
+
48
+ /** PRAGMA hands back the raw SQL literal — read it as the value the schema would declare. */
49
+ function parseDefault(raw, type) {
50
+ const v = String(raw).trim()
51
+ const n = Number(v)
52
+ if (/^'[\s\S]*'$/.test(v)) return v.slice(1, -1).replaceAll("''", "'")
53
+ if (type === 'boolean') return v !== '0'
54
+ if ((type === 'integer' || type === 'number') && !isNaN(n)) return n
55
+ return v
56
+ }
@@ -4,6 +4,7 @@ import { schemaFromDb, singleColumnIndexes } from './from-db.js'
4
4
  import { toFieldDef } from './to-field.js'
5
5
  import { quoteId, schemaType } from '../shared/sql.js'
6
6
  import { schemaDiff } from '../../diff.js'
7
+ import { fieldNeedsDdl } from '../shared/needs-ddl.js'
7
8
 
8
9
  function tableFields(tableSchema) {
9
10
  return Object.entries(tableSchema.additionalProperties?.properties ?? {})
@@ -14,19 +15,32 @@ const indexName = (table, name) => `idx_${table}_${name}`
14
15
  // SQLite: a single integer primary key must be declared inline as
15
16
  // `INTEGER PRIMARY KEY [AUTOINCREMENT]`; AUTOINCREMENT is invalid elsewhere.
16
17
  // Composite or non-integer keys use a table-level PRIMARY KEY constraint.
18
+ function soloIntPrimary(fields, primaries) {
19
+ if (primaries.length !== 1) return
20
+ const prop = fields.find(([n]) => n === primaries[0])?.[1]
21
+ return prop && ['integer', 'boolean'].includes(schemaType(prop)) ? primaries[0] : undefined
22
+ }
23
+
24
+ // What SQLite never emits cannot be a difference either: a fulltext index needs an FTS virtual
25
+ // table, and AUTOINCREMENT exists only on the rowid alias.
26
+ function emittable(prop, isSoloPk) {
27
+ const out = { ...prop }
28
+ if (out['x-index'] === 'fulltext') delete out['x-index']
29
+ if (!isSoloPk) delete out['x-autoincrement']
30
+ return out
31
+ }
32
+
17
33
  function createBody(fields, primaries) {
18
- const pkProp = fields.find(([n]) => n === primaries[0])?.[1]
19
- const soloInt = primaries.length === 1 && pkProp &&
20
- ['integer', 'boolean'].includes(schemaType(pkProp))
34
+ const solo = soloIntPrimary(fields, primaries)
21
35
  const cols = fields.map(([n, f]) => {
22
- const isPk = soloInt && n === primaries[0]
36
+ const isPk = n === solo
23
37
  // Only `INTEGER PRIMARY KEY` is the rowid alias, and AUTOINCREMENT is legal nowhere else —
24
38
  // so a boolean key keeps the integer spelling, which SQLite would otherwise reject.
25
39
  let def = toFieldDef(n, isPk ? { ...f, type: 'integer' } : f)
26
40
  if (isPk) def += f['x-autoincrement'] ? ' PRIMARY KEY AUTOINCREMENT' : ' PRIMARY KEY'
27
41
  return ' ' + def
28
42
  }).join(',\n')
29
- const pk = (!soloInt && primaries.length) ? `,\n PRIMARY KEY (${primaries.map(quoteId).join(', ')})` : ''
43
+ const pk = (!solo && primaries.length) ? `,\n PRIMARY KEY (${primaries.map(quoteId).join(', ')})` : ''
30
44
  return `${cols}${pk}`
31
45
  }
32
46
 
@@ -88,13 +102,18 @@ export async function schemaToDb(schema, query, { force = false, patch = false }
88
102
  stmts.push(`CREATE TABLE ${quoteId(table)} (\n${createBody(fields, primaries)}\n);`)
89
103
  stmts.push(...indexStatements(table, fields, primaries))
90
104
  } else {
91
- const currFields = Object.keys(current.properties[table]?.additionalProperties?.properties ?? {})
92
- const hasChanges = diffs.some(d => d.path[0] === 'properties' && d.path[1] === table)
93
- const hasDrops = !patch && currFields.some(n => !fields.find(([fn]) => fn === n))
105
+ const currFields = new Map(tableFields(current.properties[table]))
106
+ const nextFieldNames = new Set(fields.map(([n]) => n))
107
+ // A new plain column is an ADD COLUMN; only a new key column needs the rebuild.
108
+ const solo = soloIntPrimary(fields, primaries)
109
+ const hasChanges = fields.some(([name, prop]) => currFields.has(name)
110
+ ? fieldNeedsDdl(emittable(prop, name === solo), currFields.get(name))
111
+ : prop['x-index'] === 'primary' || prop['x-autoincrement'])
112
+ const hasDrops = !patch && [...currFields.keys()].some(n => !nextFieldNames.has(n))
94
113
 
95
- if (hasChanges && (hasDrops || !patch)) {
114
+ if (hasDrops || hasChanges && !patch) {
96
115
  const tmp = `${table}_migration_tmp`
97
- const keep = fields.map(([n]) => n).filter(n => currFields.includes(n))
116
+ const keep = fields.map(([n]) => n).filter(n => currFields.has(n))
98
117
  const colsList = keep.map(quoteId).join(', ')
99
118
 
100
119
  stmts.push(`CREATE TABLE ${quoteId(tmp)} (\n${createBody(fields, primaries)}\n);`)
@@ -104,7 +123,7 @@ export async function schemaToDb(schema, query, { force = false, patch = false }
104
123
  stmts.push(...indexStatements(table, fields, primaries))
105
124
  } else {
106
125
  for (const [name, prop] of fields) {
107
- if (!currFields.includes(name))
126
+ if (!currFields.has(name))
108
127
  stmts.push(`ALTER TABLE ${quoteId(table)} ADD COLUMN ${toFieldDef(name, prop)};`)
109
128
  }
110
129
  stmts.push(...indexStatements(table, fields, primaries, await singleColumnIndexes(query, table), { patch }))
@@ -17,9 +17,11 @@ export function toFieldDef(name, prop) {
17
17
  else if (t === 'number') type = 'REAL'
18
18
  else if (t === 'object' || t === 'array') type = 'TEXT' // JSON as text
19
19
  else {
20
- if (prop.format === 'date') type = 'TEXT'
21
- else if (prop.format === 'time') type = 'TEXT'
22
- else if (prop.format === 'date-time') type = 'TEXT'
20
+ // Same reasoning as BOOLEAN: the declared name is the only carrier of the format, and
21
+ // schemaFromField reads these back. TEXT would round-trip to a plain string.
22
+ if (prop.format === 'date') type = 'DATE'
23
+ else if (prop.format === 'time') type = 'TIME'
24
+ else if (prop.format === 'date-time') type = 'DATETIME'
23
25
  else if (prop.contentEncoding === 'base64') type = 'BLOB'
24
26
  else if (prop.maxLength) type = `VARCHAR(${prop.maxLength})`
25
27
  else type = 'TEXT'