mikser-io 9.48.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/docs/api-reference.md +10 -0
- package/package.json +1 -1
- package/src/changeset.js +88 -11
- package/src/database/index.js +79 -1
package/docs/api-reference.md
CHANGED
|
@@ -605,6 +605,16 @@ clearChangeSets(['req-42'])
|
|
|
605
605
|
| `findChangeSet(id)` | resolve one id |
|
|
606
606
|
| `pendingChangeSets()` | sets no consumer has recorded yet, oldest first |
|
|
607
607
|
| `markChangeSetsRecorded(ids, recordedAs)` | mark recorded, and say what as |
|
|
608
|
+
| `closeChangeSet(id)` | the writer is finished with this set |
|
|
609
|
+
|
|
610
|
+
`withChangeSet` takes `closeOnReturn` for the case where the call IS the whole
|
|
611
|
+
request — true whenever the id was minted for it rather than supplied. That is
|
|
612
|
+
exact, not a heuristic: an id nobody else can name cannot grow after the call
|
|
613
|
+
that owns it returns, so a consumer can act on it at once instead of waiting to
|
|
614
|
+
see whether more writes arrive. A caller-supplied id exists so several calls
|
|
615
|
+
can join one set, so it stays open and closes on going quiet. Closing happens
|
|
616
|
+
even when the request throws — work that landed before the failure is real, and
|
|
617
|
+
a set left open forever holds it out of reach.
|
|
608
618
|
|
|
609
619
|
The log is **durable** and survives a restart: nothing else can reconstruct
|
|
610
620
|
which writes belonged to one request. Not the files, which show the result and
|
package/package.json
CHANGED
package/src/changeset.js
CHANGED
|
@@ -41,6 +41,15 @@ registerSchema('change_sets', `
|
|
|
41
41
|
principal TEXT,
|
|
42
42
|
undo_of TEXT,
|
|
43
43
|
created_at INTEGER NOT NULL,
|
|
44
|
+
-- Set when the writer said it was finished. A set that closed is
|
|
45
|
+
-- committable now; one still open is waiting to see whether more
|
|
46
|
+
-- writes join it.
|
|
47
|
+
closed_at INTEGER,
|
|
48
|
+
-- When the set last grew. A set is one request, and a request is
|
|
49
|
+
-- finished when it stops writing — which is the only signal available,
|
|
50
|
+
-- since a caller grouping several tool calls under one id has not said
|
|
51
|
+
-- which call is the last.
|
|
52
|
+
updated_at INTEGER,
|
|
44
53
|
-- Set when a consumer has durably recorded the set somewhere of its
|
|
45
54
|
-- own — a commit, a snapshot. Until then the set is real and listable
|
|
46
55
|
-- but there is nothing to revert FROM, which is a different answer
|
|
@@ -77,14 +86,48 @@ const changeSetContext = new AsyncLocalStorage()
|
|
|
77
86
|
|
|
78
87
|
// Run `fn` with a change set in effect. Writes inside it are attributed to
|
|
79
88
|
// that set unless they name a different one explicitly.
|
|
89
|
+
//
|
|
90
|
+
// `closeOnReturn` says this call IS the whole request — which is true whenever
|
|
91
|
+
// the id was minted for it rather than supplied by the caller. That is a
|
|
92
|
+
// precise signal, not a heuristic: a set nobody else can name cannot grow
|
|
93
|
+
// after the call that owns it returns, so it is committable immediately.
|
|
94
|
+
//
|
|
95
|
+
// A caller-supplied id is the opposite: it exists so several calls can join
|
|
96
|
+
// one set, and nothing in this call knows whether another is coming. Those
|
|
97
|
+
// close on going quiet instead.
|
|
80
98
|
export function withChangeSet(set, fn) {
|
|
81
99
|
if (!set?.changeSet) return fn()
|
|
82
|
-
|
|
100
|
+
const context = {
|
|
83
101
|
changeSet: set.changeSet,
|
|
84
102
|
summary: set.summary ?? null,
|
|
85
103
|
principal: set.principal ?? null,
|
|
86
104
|
undoOf: set.undoOf ?? null,
|
|
87
|
-
}
|
|
105
|
+
}
|
|
106
|
+
if (!set.closeOnReturn) return changeSetContext.run(context, fn)
|
|
107
|
+
return changeSetContext.run(context, async () => {
|
|
108
|
+
try {
|
|
109
|
+
return await fn()
|
|
110
|
+
} finally {
|
|
111
|
+
// In `finally`: a request that failed part way still wrote what it
|
|
112
|
+
// wrote, and leaving that set open forever would hold real work
|
|
113
|
+
// out of the log's committable half.
|
|
114
|
+
closeChangeSet(set.changeSet)
|
|
115
|
+
}
|
|
116
|
+
})
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Mark a set finished. Idempotent, and silent for an id nothing recorded —
|
|
120
|
+
// a request that wrote nothing has no set to close.
|
|
121
|
+
export function closeChangeSet(id) {
|
|
122
|
+
if (!id) return
|
|
123
|
+
const at = Date.now()
|
|
124
|
+
const set = memory.get(id)
|
|
125
|
+
if (set) set.closedAt = at
|
|
126
|
+
const handle = db()
|
|
127
|
+
if (!handle) return
|
|
128
|
+
try {
|
|
129
|
+
handle.prepare('UPDATE mikser_change_sets SET closed_at = COALESCE(closed_at, ?) WHERE id = ?').run(at, id)
|
|
130
|
+
} catch { /* memory still holds it */ }
|
|
88
131
|
}
|
|
89
132
|
|
|
90
133
|
export function currentChangeSet() {
|
|
@@ -109,15 +152,16 @@ function db() {
|
|
|
109
152
|
|
|
110
153
|
function persist(handle, set, rel, operation, entityId) {
|
|
111
154
|
handle.prepare(`
|
|
112
|
-
INSERT INTO mikser_change_sets (id, summary, principal, undo_of, created_at)
|
|
113
|
-
VALUES (@id, @summary, @principal, @undoOf, @createdAt)
|
|
155
|
+
INSERT INTO mikser_change_sets (id, summary, principal, undo_of, created_at, updated_at)
|
|
156
|
+
VALUES (@id, @summary, @principal, @undoOf, @createdAt, @updatedAt)
|
|
114
157
|
ON CONFLICT(id) DO UPDATE SET
|
|
115
|
-
summary
|
|
116
|
-
principal
|
|
117
|
-
undo_of
|
|
158
|
+
summary = COALESCE(mikser_change_sets.summary, excluded.summary),
|
|
159
|
+
principal = COALESCE(mikser_change_sets.principal, excluded.principal),
|
|
160
|
+
undo_of = COALESCE(mikser_change_sets.undo_of, excluded.undo_of),
|
|
161
|
+
updated_at = excluded.updated_at
|
|
118
162
|
`).run({
|
|
119
163
|
id: set.id, summary: set.summary, principal: set.principal,
|
|
120
|
-
undoOf: set.undoOf, createdAt: set.startedAt,
|
|
164
|
+
undoOf: set.undoOf, createdAt: set.startedAt, updatedAt: set.updatedAt,
|
|
121
165
|
})
|
|
122
166
|
handle.prepare(`
|
|
123
167
|
INSERT INTO mikser_change_set_paths (change_set, path, operation, entity_id)
|
|
@@ -141,6 +185,20 @@ function prune(handle) {
|
|
|
141
185
|
`).run(KEEP_SETS)
|
|
142
186
|
}
|
|
143
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
|
+
|
|
144
202
|
function rowsToSets(handle, rows) {
|
|
145
203
|
const stmt = handle.prepare(
|
|
146
204
|
'SELECT path, operation FROM mikser_change_set_paths WHERE change_set = ? ORDER BY path')
|
|
@@ -152,6 +210,8 @@ function rowsToSets(handle, rows) {
|
|
|
152
210
|
principal: row.principal,
|
|
153
211
|
undoOf: row.undo_of,
|
|
154
212
|
startedAt: row.created_at,
|
|
213
|
+
updatedAt: row.updated_at ?? row.created_at,
|
|
214
|
+
closed: row.closed_at != null,
|
|
155
215
|
recordedAt: row.recorded_at ?? null,
|
|
156
216
|
recordedAs: row.recorded_as ?? null,
|
|
157
217
|
paths: paths.map(p => p.path),
|
|
@@ -167,6 +227,8 @@ function memorySets(filter = () => true) {
|
|
|
167
227
|
principal: set.principal,
|
|
168
228
|
undoOf: set.undoOf,
|
|
169
229
|
startedAt: set.startedAt,
|
|
230
|
+
updatedAt: set.updatedAt ?? set.startedAt,
|
|
231
|
+
closed: Boolean(set.closedAt),
|
|
170
232
|
recordedAt: set.recordedAt ?? null,
|
|
171
233
|
recordedAs: set.recordedAs ?? null,
|
|
172
234
|
paths: [...set.paths.keys()],
|
|
@@ -222,6 +284,7 @@ export function recordChangeSetWrite({
|
|
|
222
284
|
// privileged operation that rewrites the record.
|
|
223
285
|
undoOf: undoOf ?? null,
|
|
224
286
|
startedAt: Date.now(),
|
|
287
|
+
updatedAt: Date.now(),
|
|
225
288
|
paths: new Map(),
|
|
226
289
|
}
|
|
227
290
|
memory.set(changeSet, set)
|
|
@@ -229,11 +292,21 @@ export function recordChangeSetWrite({
|
|
|
229
292
|
if (!set.summary && summary) set.summary = summary
|
|
230
293
|
if (!set.principal && principal) set.principal = principal
|
|
231
294
|
if (!set.undoOf && undoOf) set.undoOf = undoOf
|
|
295
|
+
set.updatedAt = Date.now()
|
|
232
296
|
set.paths.set(rel, operation)
|
|
233
297
|
|
|
234
298
|
const handle = db()
|
|
235
299
|
if (handle) {
|
|
236
|
-
try {
|
|
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
|
+
}
|
|
237
310
|
}
|
|
238
311
|
return set.id
|
|
239
312
|
}
|
|
@@ -248,7 +321,9 @@ export function pendingChangeSets() {
|
|
|
248
321
|
SELECT * FROM mikser_change_sets WHERE recorded_at IS NULL ORDER BY created_at ASC
|
|
249
322
|
`).all()
|
|
250
323
|
return rowsToSets(handle, rows).filter(set => set.paths.length)
|
|
251
|
-
} catch {
|
|
324
|
+
} catch (err) {
|
|
325
|
+
reportChangeSetFailure(err)
|
|
326
|
+
}
|
|
252
327
|
}
|
|
253
328
|
return memorySets(set => !set.recordedAt).sort((a, b) => a.startedAt - b.startedAt)
|
|
254
329
|
}
|
|
@@ -263,7 +338,9 @@ export function listChangeSets({ limit = 20 } = {}) {
|
|
|
263
338
|
SELECT * FROM mikser_change_sets ORDER BY created_at DESC LIMIT ?
|
|
264
339
|
`).all(Math.max(1, Math.min(limit, 200)))
|
|
265
340
|
return rowsToSets(handle, rows)
|
|
266
|
-
} catch {
|
|
341
|
+
} catch (err) {
|
|
342
|
+
reportChangeSetFailure(err)
|
|
343
|
+
}
|
|
267
344
|
}
|
|
268
345
|
return memorySets().sort((a, b) => b.startedAt - a.startedAt).slice(0, limit)
|
|
269
346
|
}
|
package/src/database/index.js
CHANGED
|
@@ -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() {
|