mikser-io 9.49.0 → 9.49.1

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": "mikser-io",
3
- "version": "9.49.0",
3
+ "version": "9.49.1",
4
4
  "description": "<p align=\"center\"> <img src=\"mikser-lockup-stacked.svg\" alt=\"mikser\" width=\"198\" /> </p>",
5
5
  "main": "index.js",
6
6
  "exports": {
package/src/changeset.js CHANGED
@@ -185,6 +185,20 @@ function prune(handle) {
185
185
  `).run(KEEP_SETS)
186
186
  }
187
187
 
188
+ // Said once per process, not once per write: a broken log is one condition,
189
+ // and repeating it per write buries the builds that follow it.
190
+ let failureReported = false
191
+ function reportChangeSetFailure(err) {
192
+ if (failureReported) return
193
+ failureReported = true
194
+ const message = 'The change-set log could not be written (%s). Writes still land on disk, but they cannot be '
195
+ + 'listed or undone until this is fixed.'
196
+ try {
197
+ runtime.engine?.logger?.error(message, err.message)
198
+ } catch { /* no logger yet — the console is what is left */ }
199
+ if (!runtime.engine?.logger) console.error(message.replace('%s', err.message))
200
+ }
201
+
188
202
  function rowsToSets(handle, rows) {
189
203
  const stmt = handle.prepare(
190
204
  'SELECT path, operation FROM mikser_change_set_paths WHERE change_set = ? ORDER BY path')
@@ -283,7 +297,16 @@ export function recordChangeSetWrite({
283
297
 
284
298
  const handle = db()
285
299
  if (handle) {
286
- try { persist(handle, set, rel, operation, entityId) } catch { /* memory still holds it */ }
300
+ try {
301
+ persist(handle, set, rel, operation, entityId)
302
+ } catch (err) {
303
+ // Loud. A swallowed failure here is invisible in exactly the way
304
+ // that matters: the write succeeds, an id comes back, and the log
305
+ // it points at silently never gains a row — which is how a stale
306
+ // column shape turned the whole feature into a no-op that looked
307
+ // like it was working.
308
+ reportChangeSetFailure(err)
309
+ }
287
310
  }
288
311
  return set.id
289
312
  }
@@ -298,7 +321,9 @@ export function pendingChangeSets() {
298
321
  SELECT * FROM mikser_change_sets WHERE recorded_at IS NULL ORDER BY created_at ASC
299
322
  `).all()
300
323
  return rowsToSets(handle, rows).filter(set => set.paths.length)
301
- } catch { /* fall through to memory */ }
324
+ } catch (err) {
325
+ reportChangeSetFailure(err)
326
+ }
302
327
  }
303
328
  return memorySets(set => !set.recordedAt).sort((a, b) => a.startedAt - b.startedAt)
304
329
  }
@@ -313,7 +338,9 @@ export function listChangeSets({ limit = 20 } = {}) {
313
338
  SELECT * FROM mikser_change_sets ORDER BY created_at DESC LIMIT ?
314
339
  `).all(Math.max(1, Math.min(limit, 200)))
315
340
  return rowsToSets(handle, rows)
316
- } catch { /* fall through to memory */ }
341
+ } catch (err) {
342
+ reportChangeSetFailure(err)
343
+ }
317
344
  }
318
345
  return memorySets().sort((a, b) => b.startedAt - a.startedAt).slice(0, limit)
319
346
  }
@@ -104,6 +104,78 @@ let db = null
104
104
  // duplicate detection. Same name twice = the later registration wins
105
105
  // (with a warning). Convention: `<owner>` matching the table prefix
106
106
  // (`catalog`, `manifest`, `vector`, etc.).
107
+ // Column names a CREATE TABLE body declares, in order.
108
+ //
109
+ // Only the leading identifier of each top-level comma-separated clause, and
110
+ // only when it is not a table constraint. Good enough for the schemas this
111
+ // engine registers, and deliberately not a SQL parser.
112
+ function columnsFrom(sqlScript, table) {
113
+ const re = new RegExp(
114
+ `CREATE\\s+TABLE\\s+(?:IF\\s+NOT\\s+EXISTS\\s+)?[\`"'\\[]?${table}[\`"'\\]]?\\s*\\(`, 'i')
115
+ const m = re.exec(sqlScript)
116
+ if (!m) return []
117
+ // Walk to the matching close paren so nested types and CHECK(...) do not
118
+ // end the body early.
119
+ let depth = 1
120
+ let i = m.index + m[0].length
121
+ const start = i
122
+ for (; i < sqlScript.length && depth > 0; i++) {
123
+ if (sqlScript[i] === '(') depth++
124
+ else if (sqlScript[i] === ')') depth--
125
+ }
126
+ const body = sqlScript.slice(start, i - 1)
127
+
128
+ const clauses = []
129
+ let current = ''
130
+ depth = 0
131
+ for (const ch of body) {
132
+ if (ch === '(') depth++
133
+ else if (ch === ')') depth--
134
+ if (ch === ',' && depth === 0) { clauses.push(current); current = '' } else current += ch
135
+ }
136
+ clauses.push(current)
137
+
138
+ const CONSTRAINTS = new Set(['primary', 'unique', 'foreign', 'check', 'constraint'])
139
+ return clauses
140
+ .map(clause => clause.replace(/--[^\n]*/g, '').trim())
141
+ .filter(Boolean)
142
+ .map(clause => clause.split(/\s+/)[0].replace(/["`\[\]]/g, ''))
143
+ .filter(name => name && !CONSTRAINTS.has(name.toLowerCase()))
144
+ }
145
+
146
+ // Add columns a DURABLE table has grown since it was created.
147
+ //
148
+ // `CREATE TABLE IF NOT EXISTS` does nothing to a table that already exists, so
149
+ // re-applying a schema never adds a column. For a cache table that is
150
+ // invisible: the version bump wipes and recreates it. A durable table is
151
+ // exactly the one that SURVIVES the wipe, so it is the only kind that can go
152
+ // stale — and it goes stale silently, with every insert naming the new column
153
+ // failing against a table that still has the old shape.
154
+ function migrateDurableColumns(handle, schemas, logger) {
155
+ for (const value of schemas?.values?.() ?? []) {
156
+ const { sql, durable } = schemaEntry(value)
157
+ if (!durable) continue
158
+ for (const table of tableNamesFrom(sql)) {
159
+ let existing
160
+ try {
161
+ existing = new Set(handle.prepare(`PRAGMA table_info("${table}")`).all().map(c => c.name))
162
+ } catch { continue }
163
+ if (!existing.size) continue
164
+ for (const column of columnsFrom(sql, table)) {
165
+ if (existing.has(column)) continue
166
+ // Only ever ADD. Dropping or retyping a column in a durable
167
+ // table would discard data the whole flag exists to keep.
168
+ try {
169
+ handle.exec(`ALTER TABLE "${table}" ADD COLUMN "${column}"`)
170
+ logger?.info('Durable table %s gained column %s', table, column)
171
+ } catch (err) {
172
+ logger?.warn('Could not add column %s to %s: %s', column, table, err.message)
173
+ }
174
+ }
175
+ }
176
+ }
177
+ }
178
+
107
179
  // Table names a schema script creates. Used to decide what a cache wipe
108
180
  // must leave alone — the registry knows schema NAMES, and the wipe works in
109
181
  // tables.
@@ -498,7 +570,8 @@ export function createSqliteDatabase({
498
570
  }
499
571
 
500
572
  // Apply each subsystem's registered schema script. Idempotent
501
- // CREATE statements mean replay-safe across opens.
573
+ // CREATE statements mean replay-safe across opens — but only for
574
+ // tables they can recreate; see migrateDurableColumns below.
502
575
  for (const [name, value] of schemas) {
503
576
  const { sql: sqlScript } = schemaEntry(value)
504
577
  try {
@@ -511,6 +584,11 @@ export function createSqliteDatabase({
511
584
  throw new Error(`Schema "${name}" failed to apply: ${err.message}`)
512
585
  }
513
586
  }
587
+
588
+ // A durable table survives the wipe, so re-applying its CREATE is not
589
+ // enough to give it a column the schema has grown. Reconciled here,
590
+ // additively.
591
+ migrateDurableColumns(handle, schemas, logger)
514
592
  }
515
593
 
516
594
  function close() {