mikser-io 9.50.0 → 9.50.2

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.50.0",
3
+ "version": "9.50.2",
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
@@ -61,7 +61,13 @@ registerSchema('change_sets', `
61
61
  -- permanent failure and a pending commit look identical, and both
62
62
  -- read as a null commit forever.
63
63
  commit_error TEXT,
64
- commit_attempts INTEGER NOT NULL DEFAULT 0
64
+ commit_attempts INTEGER NOT NULL DEFAULT 0,
65
+ -- How the set finished. A claimed set had exactly two exits,
66
+ -- committed or failed, and a set whose changes cancel out qualifies
67
+ -- for neither: there is genuinely nothing to write, which is not an
68
+ -- error. Without a third outcome it was re-claimed every pass forever
69
+ -- and reported a null commit that looked like a fault.
70
+ outcome TEXT
65
71
  );
66
72
  CREATE INDEX IF NOT EXISTS idx_mikser_change_sets_created
67
73
  ON mikser_change_sets (created_at DESC);
@@ -222,6 +228,7 @@ function rowsToSets(handle, rows) {
222
228
  recordedAs: row.recorded_as ?? null,
223
229
  commitError: row.commit_error ?? null,
224
230
  commitAttempts: row.commit_attempts ?? 0,
231
+ outcome: row.outcome ?? null,
225
232
  paths: paths.map(p => p.path),
226
233
  deletions: paths.filter(p => p.operation === 'delete').map(p => p.path),
227
234
  }
@@ -241,6 +248,7 @@ function memorySets(filter = () => true) {
241
248
  recordedAs: set.recordedAs ?? null,
242
249
  commitError: set.commitError ?? null,
243
250
  commitAttempts: set.commitAttempts ?? 0,
251
+ outcome: set.outcome ?? null,
244
252
  paths: [...set.paths.keys()],
245
253
  deletions: [...set.paths.entries()].filter(([, op]) => op === 'delete').map(([p]) => p),
246
254
  }))
@@ -375,13 +383,20 @@ export function markChangeSetsRecorded(ids = [], recordedAs = null) {
375
383
  const at = Date.now()
376
384
  for (const id of ids) {
377
385
  const set = memory.get(id)
378
- if (set) { set.recordedAt = at; set.recordedAs = recordedAs; set.commitError = null }
386
+ if (set) {
387
+ set.recordedAt = at
388
+ set.recordedAs = recordedAs
389
+ set.commitError = null
390
+ set.outcome = 'committed'
391
+ }
379
392
  }
380
393
  const handle = db()
381
394
  if (!handle) return
382
395
  try {
383
396
  const stmt = handle.prepare(
384
- 'UPDATE mikser_change_sets SET recorded_at = ?, recorded_as = ?, commit_error = NULL WHERE id = ?')
397
+ `UPDATE mikser_change_sets
398
+ SET recorded_at = ?, recorded_as = ?, commit_error = NULL, outcome = 'committed'
399
+ WHERE id = ?`)
385
400
  for (const id of ids) stmt.run(at, recordedAs, id)
386
401
  } catch { /* memory still holds it */ }
387
402
  }
@@ -416,6 +431,33 @@ export function markChangeSetFailed(id, error) {
416
431
  }
417
432
  }
418
433
 
434
+ // Finish a set that produced no commit, because there was nothing to write.
435
+ //
436
+ // The third outcome. A set whose adds and removals cancel out — an undo of a
437
+ // create, a probe that added and then deleted its own files — leaves an empty
438
+ // diff, and git correctly makes no commit for it. It is not pending and it did
439
+ // not fail: it is DONE, and saying so is what stops it being re-claimed every
440
+ // pass forever while a column of nulls suggests a broken pipeline.
441
+ //
442
+ // `outcome` keeps the distinction that matters to undo: reverting a set that
443
+ // never produced a commit is not the same operation as reverting one that did.
444
+ export function markChangeSetSettled(id, outcome = 'empty') {
445
+ const at = Date.now()
446
+ const set = memory.get(id)
447
+ if (set) { set.recordedAt = at; set.recordedAs = null; set.outcome = outcome }
448
+ const handle = db()
449
+ if (!handle) return
450
+ try {
451
+ handle.prepare(`
452
+ UPDATE mikser_change_sets
453
+ SET recorded_at = COALESCE(recorded_at, ?), outcome = COALESCE(outcome, ?)
454
+ WHERE id = ?
455
+ `).run(at, outcome, id)
456
+ } catch (err) {
457
+ reportChangeSetFailure(err)
458
+ }
459
+ }
460
+
419
461
  export function forgetAllChangeSets() {
420
462
  memory.clear()
421
463
  const handle = db()
@@ -104,26 +104,45 @@ 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
+ // Comments removed, so nothing downstream ever parses prose as DDL.
108
+ //
109
+ // Done to the WHOLE script before anything splits or counts, because both
110
+ // operations are wrong on a comment: a comma inside one ends a clause, and a
111
+ // bracket inside one unbalances the walk that finds a table body. Stripping
112
+ // per-clause after the split cannot work — by then the damage is done, and the
113
+ // fragment after the comma no longer starts with `--` so it never gets
114
+ // stripped at all. That produced real columns named `and`, `a` and `which` on
115
+ // a live deployment, from the prose of the schema's own comments, while the
116
+ // columns that comment described were silently omitted.
117
+ function stripSqlComments(sqlScript) {
118
+ return String(sqlScript ?? '')
119
+ // Block comments first: one may span the `--` of a line comment.
120
+ .replace(/\/\*[\s\S]*?\*\//g, ' ')
121
+ // To end of line, while the newlines are still there to end at.
122
+ .replace(/--[^\n]*/g, '')
123
+ }
124
+
107
125
  // Column names a CREATE TABLE body declares, in order.
108
126
  //
109
127
  // Only the leading identifier of each top-level comma-separated clause, and
110
128
  // only when it is not a table constraint. Good enough for the schemas this
111
129
  // engine registers, and deliberately not a SQL parser.
112
130
  function columnsFrom(sqlScript, table) {
131
+ const clean = stripSqlComments(sqlScript)
113
132
  const re = new RegExp(
114
133
  `CREATE\\s+TABLE\\s+(?:IF\\s+NOT\\s+EXISTS\\s+)?[\`"'\\[]?${table}[\`"'\\]]?\\s*\\(`, 'i')
115
- const m = re.exec(sqlScript)
134
+ const m = re.exec(clean)
116
135
  if (!m) return []
117
136
  // Walk to the matching close paren so nested types and CHECK(...) do not
118
137
  // end the body early.
119
138
  let depth = 1
120
139
  let i = m.index + m[0].length
121
140
  const start = i
122
- for (; i < sqlScript.length && depth > 0; i++) {
123
- if (sqlScript[i] === '(') depth++
124
- else if (sqlScript[i] === ')') depth--
141
+ for (; i < clean.length && depth > 0; i++) {
142
+ if (clean[i] === '(') depth++
143
+ else if (clean[i] === ')') depth--
125
144
  }
126
- const body = sqlScript.slice(start, i - 1)
145
+ const body = clean.slice(start, i - 1)
127
146
 
128
147
  const clauses = []
129
148
  let current = ''
@@ -137,9 +156,9 @@ function columnsFrom(sqlScript, table) {
137
156
 
138
157
  const CONSTRAINTS = new Set(['primary', 'unique', 'foreign', 'check', 'constraint'])
139
158
  return clauses
140
- .map(clause => clause.replace(/--[^\n]*/g, '').trim())
159
+ .map(clause => clause.trim())
141
160
  .filter(Boolean)
142
- .map(clause => clause.split(/\s+/)[0].replace(/["`\[\]]/g, ''))
161
+ .map(clause => clause.split(/\s+/)[0].replace(/["\`\[\]]/g, ''))
143
162
  .filter(name => name && !CONSTRAINTS.has(name.toLowerCase()))
144
163
  }
145
164
 
@@ -161,7 +180,8 @@ function migrateDurableColumns(handle, schemas, logger) {
161
180
  existing = new Set(handle.prepare(`PRAGMA table_info("${table}")`).all().map(c => c.name))
162
181
  } catch { continue }
163
182
  if (!existing.size) continue
164
- for (const column of columnsFrom(sql, table)) {
183
+ const declared = columnsFrom(sql, table)
184
+ for (const column of declared) {
165
185
  if (existing.has(column)) continue
166
186
  // Only ever ADD. Dropping or retyping a column in a durable
167
187
  // table would discard data the whole flag exists to keep.
@@ -169,9 +189,28 @@ function migrateDurableColumns(handle, schemas, logger) {
169
189
  handle.exec(`ALTER TABLE "${table}" ADD COLUMN "${column}"`)
170
190
  logger?.info('Durable table %s gained column %s', table, column)
171
191
  } catch (err) {
172
- logger?.warn('Could not add column %s to %s: %s', column, table, err.message)
192
+ logger?.error('Could not add column %s to %s: %s', column, table, err.message)
173
193
  }
174
194
  }
195
+
196
+ // Verify, rather than assume the loop above was enough.
197
+ //
198
+ // Silence was the dangerous half of the last bug here: the
199
+ // migration logged what it ADDED and never what it failed to find,
200
+ // so a parser that quietly omitted a column produced a clean-
201
+ // looking upgrade and a write that failed days later on a
202
+ // deployment. A declared column that is still missing after this
203
+ // runs is a fault in the migration itself, and has to say so here
204
+ // rather than surface as "no such column" at the first write.
205
+ const after = new Set(
206
+ handle.prepare(`PRAGMA table_info("${table}")`).all().map(c => c.name))
207
+ const missing = declared.filter(column => !after.has(column))
208
+ if (missing.length) {
209
+ logger?.error(
210
+ 'Durable table %s is missing declared column(s): %s. Writes naming them will fail — this is a '
211
+ + 'fault in the schema migration, not in the caller.',
212
+ table, missing.join(', '))
213
+ }
175
214
  }
176
215
  }
177
216
  }
@@ -189,9 +228,12 @@ function schemaEntry(value) {
189
228
 
190
229
  function tableNamesFrom(sqlScript) {
191
230
  const names = []
231
+ // Comments stripped for the same reason columnsFrom strips them: a
232
+ // comment that happens to mention CREATE TABLE would otherwise register a
233
+ // table that does not exist.
192
234
  const re = /CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?[`"'\[]?([A-Za-z_][\w$]*)/gi
193
235
  let m
194
- while ((m = re.exec(sqlScript))) names.push(m[1])
236
+ while ((m = re.exec(stripSqlComments(sqlScript)))) names.push(m[1])
195
237
  return names
196
238
  }
197
239