mikser-io 9.47.0 → 9.49.0

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.
@@ -601,8 +601,31 @@ clearChangeSets(['req-42'])
601
601
  | `withChangeSet({ changeSet, summary, principal }, fn)` | run `fn` with a set in effect |
602
602
  | `currentChangeSet()` | the set in effect, or null |
603
603
  | `recordChangeSetWrite({ changeSet, summary, principal, uri, operation, undoOf })` | attach one path to a set |
604
- | `pendingChangeSets()` | sets with unconsumed writes, oldest first |
605
- | `clearChangeSets(ids)` | drop what a consumer has committed |
604
+ | `listChangeSets({ limit })` | the log, newest first |
605
+ | `findChangeSet(id)` | resolve one id |
606
+ | `pendingChangeSets()` | sets no consumer has recorded yet, oldest first |
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.
618
+
619
+ The log is **durable** and survives a restart: nothing else can reconstruct
620
+ which writes belonged to one request. Not the files, which show the result and
621
+ not the grouping — and not a consumer's own history, which may not exist yet,
622
+ or at all. It keeps the most recent 200 sets.
623
+
624
+ `recordedAs` is what a consumer recorded the set as — a commit sha. Its absence
625
+ is meaningful: the set is real and listable, but there is nothing to revert
626
+ from yet. `mikser_undo` reports that as `not-yet-committed`, which is a
627
+ different answer from `unknown-change-set` and sends a reader somewhere
628
+ different.
606
629
 
607
630
  ### Ambient, not threaded
608
631
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikser-io",
3
- "version": "9.47.0",
3
+ "version": "9.49.0",
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
@@ -27,6 +27,52 @@
27
27
  import path from 'node:path'
28
28
  import { AsyncLocalStorage } from 'node:async_hooks'
29
29
  import runtime from './runtime.js'
30
+ import { registerSchema } from './database/index.js'
31
+
32
+ // The log is DURABLE. It records which writes belonged to which request, and
33
+ // nothing can reconstruct that: not the files, which show the result and not
34
+ // the grouping, and not a consumer's own history, which may not exist yet or
35
+ // at all. Losing it turns every id already handed to an agent into a dangling
36
+ // handle.
37
+ registerSchema('change_sets', `
38
+ CREATE TABLE IF NOT EXISTS mikser_change_sets (
39
+ id TEXT PRIMARY KEY,
40
+ summary TEXT,
41
+ principal TEXT,
42
+ undo_of TEXT,
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,
53
+ -- Set when a consumer has durably recorded the set somewhere of its
54
+ -- own — a commit, a snapshot. Until then the set is real and listable
55
+ -- but there is nothing to revert FROM, which is a different answer
56
+ -- from "no such change set".
57
+ recorded_at INTEGER,
58
+ recorded_as TEXT
59
+ );
60
+ CREATE INDEX IF NOT EXISTS idx_mikser_change_sets_created
61
+ ON mikser_change_sets (created_at DESC);
62
+
63
+ CREATE TABLE IF NOT EXISTS mikser_change_set_paths (
64
+ change_set TEXT NOT NULL,
65
+ path TEXT NOT NULL,
66
+ operation TEXT NOT NULL,
67
+ entity_id TEXT,
68
+ PRIMARY KEY (change_set, path)
69
+ );
70
+ `, { durable: true })
71
+
72
+ // How many sets to keep. An undo log is only useful while the change is
73
+ // recent enough to be worth taking back, and unbounded growth in a durable
74
+ // table is a leak nothing cleans up.
75
+ const KEEP_SETS = 200
30
76
 
31
77
  // The change set in effect for the current call.
32
78
  //
@@ -40,23 +86,140 @@ const changeSetContext = new AsyncLocalStorage()
40
86
 
41
87
  // Run `fn` with a change set in effect. Writes inside it are attributed to
42
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.
43
98
  export function withChangeSet(set, fn) {
44
99
  if (!set?.changeSet) return fn()
45
- return changeSetContext.run({
100
+ const context = {
46
101
  changeSet: set.changeSet,
47
102
  summary: set.summary ?? null,
48
103
  principal: set.principal ?? null,
49
104
  undoOf: set.undoOf ?? null,
50
- }, 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 */ }
51
131
  }
52
132
 
53
133
  export function currentChangeSet() {
54
134
  return changeSetContext.getStore() ?? null
55
135
  }
56
136
 
57
- function store() {
58
- runtime.changeSets ??= new Map()
59
- return runtime.changeSets
137
+ // The database when there is one, memory when there is not.
138
+ //
139
+ // A write can happen before the engine opens its database — a plugin acting at
140
+ // load time, a unit test — and losing the attribution then would be worse than
141
+ // keeping it somewhere weaker. Both back ends answer the same questions, so no
142
+ // caller has to know which is in play.
143
+ const memory = new Map()
144
+
145
+ function db() {
146
+ // Read off the runtime rather than calling useDatabase(): this module is
147
+ // reached from utils.js, which loads before the database module can be
148
+ // imported without closing a cycle.
149
+ const handle = runtime.database?.handle
150
+ return handle?.prepare ? handle : null
151
+ }
152
+
153
+ function persist(handle, set, rel, operation, entityId) {
154
+ handle.prepare(`
155
+ INSERT INTO mikser_change_sets (id, summary, principal, undo_of, created_at, updated_at)
156
+ VALUES (@id, @summary, @principal, @undoOf, @createdAt, @updatedAt)
157
+ ON CONFLICT(id) DO UPDATE SET
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
162
+ `).run({
163
+ id: set.id, summary: set.summary, principal: set.principal,
164
+ undoOf: set.undoOf, createdAt: set.startedAt, updatedAt: set.updatedAt,
165
+ })
166
+ handle.prepare(`
167
+ INSERT INTO mikser_change_set_paths (change_set, path, operation, entity_id)
168
+ VALUES (?, ?, ?, ?)
169
+ ON CONFLICT(change_set, path) DO UPDATE SET operation = excluded.operation
170
+ `).run(set.id, rel, operation, entityId ?? null)
171
+ prune(handle)
172
+ }
173
+
174
+ function prune(handle) {
175
+ handle.prepare(`
176
+ DELETE FROM mikser_change_set_paths WHERE change_set IN (
177
+ SELECT id FROM mikser_change_sets
178
+ ORDER BY created_at DESC LIMIT -1 OFFSET ?
179
+ )
180
+ `).run(KEEP_SETS)
181
+ handle.prepare(`
182
+ DELETE FROM mikser_change_sets WHERE id IN (
183
+ SELECT id FROM mikser_change_sets ORDER BY created_at DESC LIMIT -1 OFFSET ?
184
+ )
185
+ `).run(KEEP_SETS)
186
+ }
187
+
188
+ function rowsToSets(handle, rows) {
189
+ const stmt = handle.prepare(
190
+ 'SELECT path, operation FROM mikser_change_set_paths WHERE change_set = ? ORDER BY path')
191
+ return rows.map(row => {
192
+ const paths = stmt.all(row.id)
193
+ return {
194
+ id: row.id,
195
+ summary: row.summary,
196
+ principal: row.principal,
197
+ undoOf: row.undo_of,
198
+ startedAt: row.created_at,
199
+ updatedAt: row.updated_at ?? row.created_at,
200
+ closed: row.closed_at != null,
201
+ recordedAt: row.recorded_at ?? null,
202
+ recordedAs: row.recorded_as ?? null,
203
+ paths: paths.map(p => p.path),
204
+ deletions: paths.filter(p => p.operation === 'delete').map(p => p.path),
205
+ }
206
+ })
207
+ }
208
+
209
+ function memorySets(filter = () => true) {
210
+ return [...memory.values()].filter(set => set.paths.size).filter(filter).map(set => ({
211
+ id: set.id,
212
+ summary: set.summary,
213
+ principal: set.principal,
214
+ undoOf: set.undoOf,
215
+ startedAt: set.startedAt,
216
+ updatedAt: set.updatedAt ?? set.startedAt,
217
+ closed: Boolean(set.closedAt),
218
+ recordedAt: set.recordedAt ?? null,
219
+ recordedAs: set.recordedAs ?? null,
220
+ paths: [...set.paths.keys()],
221
+ deletions: [...set.paths.entries()].filter(([, op]) => op === 'delete').map(([p]) => p),
222
+ }))
60
223
  }
61
224
 
62
225
  // Relative to the working folder, POSIX-separated.
@@ -78,7 +241,9 @@ function relativeToWorkingFolder(uri) {
78
241
  // nothing downstream will ever know it as well — a reader choosing what to
79
242
  // undo needs "changed the hero text on the devices page", not a file count.
80
243
  // First one wins: later writes in the same set are the same request.
81
- export function recordChangeSetWrite({ changeSet, summary, principal, uri, operation = 'write', undoOf } = {}) {
244
+ export function recordChangeSetWrite({
245
+ changeSet, summary, principal, uri, operation = 'write', undoOf, entityId,
246
+ } = {}) {
82
247
  // An explicit id always wins; otherwise take whatever set is in effect.
83
248
  // A write with neither stays unclaimed, which is the correct outcome for
84
249
  // an API or human write that no request owns.
@@ -94,8 +259,7 @@ export function recordChangeSetWrite({ changeSet, summary, principal, uri, opera
94
259
  // produce a selector that quietly matches nothing.
95
260
  if (!rel) return null
96
261
 
97
- const sets = store()
98
- let set = sets.get(changeSet)
262
+ let set = memory.get(changeSet)
99
263
  if (!set) {
100
264
  set = {
101
265
  id: changeSet,
@@ -106,45 +270,102 @@ export function recordChangeSetWrite({ changeSet, summary, principal, uri, opera
106
270
  // privileged operation that rewrites the record.
107
271
  undoOf: undoOf ?? null,
108
272
  startedAt: Date.now(),
273
+ updatedAt: Date.now(),
109
274
  paths: new Map(),
110
275
  }
111
- sets.set(changeSet, set)
276
+ memory.set(changeSet, set)
112
277
  }
113
278
  if (!set.summary && summary) set.summary = summary
114
279
  if (!set.principal && principal) set.principal = principal
115
280
  if (!set.undoOf && undoOf) set.undoOf = undoOf
281
+ set.updatedAt = Date.now()
116
282
  set.paths.set(rel, operation)
283
+
284
+ const handle = db()
285
+ if (handle) {
286
+ try { persist(handle, set, rel, operation, entityId) } catch { /* memory still holds it */ }
287
+ }
117
288
  return set.id
118
289
  }
119
290
 
120
- // Every set with writes not yet consumed, oldest first — the order the work
121
- // actually happened in, which is the order a consumer should record it in.
291
+ // Sets a consumer has not yet durably recorded, oldest first — the order the
292
+ // work actually happened in, which is the order it should be recorded in.
122
293
  export function pendingChangeSets() {
123
- return [...store().values()]
124
- .filter(set => set.paths.size)
125
- .sort((a, b) => a.startedAt - b.startedAt)
126
- .map(set => ({
127
- id: set.id,
128
- summary: set.summary,
129
- principal: set.principal,
130
- undoOf: set.undoOf,
131
- startedAt: set.startedAt,
132
- paths: [...set.paths.keys()],
133
- deletions: [...set.paths.entries()].filter(([, op]) => op === 'delete').map(([p]) => p),
134
- }))
135
- }
136
-
137
- // Drop sets a consumer has dealt with.
138
- //
139
- // Called after a consumer has durably recorded the paths, not after they are
140
- // written: a crash in between loses the attribution but not the work, which
141
- // then reaches the consumer as an unclaimed write. That is the right way
142
- // round attribution is a convenience, the bytes are not.
143
- export function clearChangeSets(ids = []) {
144
- const sets = store()
145
- for (const id of ids) sets.delete(id)
294
+ const handle = db()
295
+ if (handle) {
296
+ try {
297
+ const rows = handle.prepare(`
298
+ SELECT * FROM mikser_change_sets WHERE recorded_at IS NULL ORDER BY created_at ASC
299
+ `).all()
300
+ return rowsToSets(handle, rows).filter(set => set.paths.length)
301
+ } catch { /* fall through to memory */ }
302
+ }
303
+ return memorySets(set => !set.recordedAt).sort((a, b) => a.startedAt - b.startedAt)
304
+ }
305
+
306
+ // The log an agent reads: every set, newest first, whether or not a consumer
307
+ // has recorded it anywhere.
308
+ export function listChangeSets({ limit = 20 } = {}) {
309
+ const handle = db()
310
+ if (handle) {
311
+ try {
312
+ const rows = handle.prepare(`
313
+ SELECT * FROM mikser_change_sets ORDER BY created_at DESC LIMIT ?
314
+ `).all(Math.max(1, Math.min(limit, 200)))
315
+ return rowsToSets(handle, rows)
316
+ } catch { /* fall through to memory */ }
317
+ }
318
+ return memorySets().sort((a, b) => b.startedAt - a.startedAt).slice(0, limit)
319
+ }
320
+
321
+ export function findChangeSet(id) {
322
+ if (!id) return null
323
+ const handle = db()
324
+ if (handle) {
325
+ try {
326
+ const row = handle.prepare('SELECT * FROM mikser_change_sets WHERE id = ?').get(id)
327
+ return row ? rowsToSets(handle, [row])[0] : null
328
+ } catch { /* fall through to memory */ }
329
+ }
330
+ return memorySets(set => set.id === id)[0] ?? null
331
+ }
332
+
333
+ // Mark sets a consumer has durably recorded, and say what it recorded them AS
334
+ // — a commit sha, a snapshot id. That reference is what an undo reverts from,
335
+ // and its absence is why "recorded but not yet committed" is a different
336
+ // answer from "no such change set".
337
+ export function markChangeSetsRecorded(ids = [], recordedAs = null) {
338
+ const at = Date.now()
339
+ for (const id of ids) {
340
+ const set = memory.get(id)
341
+ if (set) { set.recordedAt = at; set.recordedAs = recordedAs }
342
+ }
343
+ const handle = db()
344
+ if (!handle) return
345
+ try {
346
+ const stmt = handle.prepare(
347
+ 'UPDATE mikser_change_sets SET recorded_at = ?, recorded_as = ? WHERE id = ?')
348
+ for (const id of ids) stmt.run(at, recordedAs, id)
349
+ } catch { /* memory still holds it */ }
350
+ }
351
+
352
+ // Kept as the name consumers already call. Marking recorded is what "done
353
+ // with it" means now — the set stays in the log so it can still be undone.
354
+ export function clearChangeSets(ids = [], recordedAs = null) {
355
+ markChangeSetsRecorded(ids, recordedAs)
146
356
  }
147
357
 
148
358
  export function forgetAllChangeSets() {
149
- store().clear()
359
+ memory.clear()
360
+ const handle = db()
361
+ if (!handle) return
362
+ try {
363
+ handle.exec('DELETE FROM mikser_change_set_paths; DELETE FROM mikser_change_sets;')
364
+ } catch { /* nothing to clear */ }
150
365
  }
366
+
367
+ // Published on the runtime so the write primitives in utils.js can record
368
+ // without importing this module. utils.js loads early — before the database
369
+ // module can be imported here without closing a cycle — and an import purely
370
+ // to reach one function is what would close it.
371
+ runtime.recordChangeSetWrite = recordChangeSetWrite
package/src/utils.js CHANGED
@@ -11,7 +11,6 @@ import yaml from 'yaml'
11
11
  import { contentType } from 'mime-types'
12
12
  import runtime from './runtime.js'
13
13
  import { trackedInfo, untrack, recordReads } from './track.js'
14
- import { recordChangeSetWrite } from './changeset.js'
15
14
 
16
15
  // Stable content fingerprint for entities — used by manifest snapshots,
17
16
  // engine mutation tracking, and the layouts dispatcher's hash-aware
@@ -794,7 +793,7 @@ export async function writeEntity(entity, patch = {}) {
794
793
  // The other file-writing primitive. A rename cascade rewrites every
795
794
  // referring file through here, which is the largest fan-out any single
796
795
  // request has and therefore the one most worth being able to take back.
797
- recordChangeSetWrite({ uri: entity.uri })
796
+ runtime.recordChangeSetWrite?.({ uri: entity.uri })
798
797
 
799
798
  return entity.uri
800
799
  }
@@ -1114,14 +1113,14 @@ export function useCollection(runtime, name) {
1114
1113
  // of change sets still produces undoable work — the alternative is
1115
1114
  // every writer remembering, and the one that forgets is the one
1116
1115
  // whose edit cannot be taken back.
1117
- recordChangeSetWrite({ uri })
1116
+ runtime.recordChangeSetWrite?.({ uri })
1118
1117
  return uri
1119
1118
  },
1120
1119
 
1121
1120
  async remove(relativePath) {
1122
1121
  const uri = resolveWithin(relativePath)
1123
1122
  await unlink(uri)
1124
- recordChangeSetWrite({ uri, operation: 'delete' })
1123
+ runtime.recordChangeSetWrite?.({ uri, operation: 'delete' })
1125
1124
  },
1126
1125
  }
1127
1126
  }