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 +1 -1
- package/src/changeset.js +45 -3
- package/src/database/index.js +52 -10
package/package.json
CHANGED
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) {
|
|
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
|
-
|
|
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()
|
package/src/database/index.js
CHANGED
|
@@ -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(
|
|
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 <
|
|
123
|
-
if (
|
|
124
|
-
else if (
|
|
141
|
+
for (; i < clean.length && depth > 0; i++) {
|
|
142
|
+
if (clean[i] === '(') depth++
|
|
143
|
+
else if (clean[i] === ')') depth--
|
|
125
144
|
}
|
|
126
|
-
const body =
|
|
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.
|
|
159
|
+
.map(clause => clause.trim())
|
|
141
160
|
.filter(Boolean)
|
|
142
|
-
.map(clause => clause.split(/\s+/)[0].replace(/["
|
|
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
|
-
|
|
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?.
|
|
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
|
|