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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikser-io",
3
- "version": "9.48.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
@@ -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
- return changeSetContext.run({
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
- }, fn)
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 = COALESCE(mikser_change_sets.summary, excluded.summary),
116
- principal = COALESCE(mikser_change_sets.principal, excluded.principal),
117
- undo_of = COALESCE(mikser_change_sets.undo_of, excluded.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 { 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
+ }
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 { /* fall through to memory */ }
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 { /* fall through to memory */ }
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
  }
@@ -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() {