mikser-io 9.45.0 → 9.46.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.
@@ -598,10 +598,39 @@ clearChangeSets(['req-42'])
598
598
 
599
599
  | Export | Does |
600
600
  | --- | --- |
601
+ | `withChangeSet({ changeSet, summary, principal }, fn)` | run `fn` with a set in effect |
602
+ | `currentChangeSet()` | the set in effect, or null |
601
603
  | `recordChangeSetWrite({ changeSet, summary, principal, uri, operation, undoOf })` | attach one path to a set |
602
604
  | `pendingChangeSets()` | sets with unconsumed writes, oldest first |
603
605
  | `clearChangeSets(ids)` | drop what a consumer has committed |
604
606
 
607
+ ### Ambient, not threaded
608
+
609
+ Passing an id through every write is fine for one API and hopeless across a
610
+ plugin ecosystem — `mikser-io-drive` writes with `fs.writeFile`, a rename
611
+ cascade goes through `writeEntity`, and every new mutating tool would have to
612
+ remember. `withChangeSet` puts one in scope instead, and the write primitives
613
+ attribute themselves:
614
+
615
+ ```js
616
+ await withChangeSet({ changeSet: 'req-42', summary: 'Rewrite the hero copy' }, async () => {
617
+ await useCollection(runtime, 'documents').write('hero.md', text) // attributed
618
+ await writeEntity({ uri }, { title }) // attributed
619
+ })
620
+ ```
621
+
622
+ `useCollection().write` / `.remove` and `writeEntity` record automatically, so
623
+ code that has never heard of change sets still produces undoable work. A plugin
624
+ writing with raw `fs` calls `recordChangeSetWrite({ uri })` with no id and
625
+ picks up whatever is in effect. An explicit id always wins over the ambient
626
+ one, and a write with neither stays unclaimed.
627
+
628
+ In `mikser-io-mcp`, a tool registered with `mutates: true` gets `changeSet` and
629
+ `summary` added to its schema and its handler wrapped in `withChangeSet`
630
+ automatically — declared once per tool rather than threaded through each write,
631
+ because a new mutating tool that forgets the plumbing is one whose edits
632
+ silently cannot be undone.
633
+
605
634
  Paths come back repo-relative and POSIX-separated, ready for a git pathspec.
606
635
 
607
636
  **Not a transaction.** Nothing is held back, nothing rolls back on failure, and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikser-io",
3
- "version": "9.45.0",
3
+ "version": "9.46.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
@@ -21,8 +21,35 @@
21
21
  // attribute them.
22
22
 
23
23
  import path from 'node:path'
24
+ import { AsyncLocalStorage } from 'node:async_hooks'
24
25
  import runtime from './runtime.js'
25
26
 
27
+ // The change set in effect for the current call.
28
+ //
29
+ // Threading an id through every write is fine for one API and hopeless across
30
+ // a plugin ecosystem: mikser-io-drive writes with fs.writeFile, forms writes
31
+ // its own entities, and each new mutating tool would have to remember. An
32
+ // ambient context means a caller declares the set ONCE and everything written
33
+ // underneath is attributed, including by code that has never heard of change
34
+ // sets.
35
+ const changeSetContext = new AsyncLocalStorage()
36
+
37
+ // Run `fn` with a change set in effect. Writes inside it are attributed to
38
+ // that set unless they name a different one explicitly.
39
+ export function withChangeSet(set, fn) {
40
+ if (!set?.changeSet) return fn()
41
+ return changeSetContext.run({
42
+ changeSet: set.changeSet,
43
+ summary: set.summary ?? null,
44
+ principal: set.principal ?? null,
45
+ undoOf: set.undoOf ?? null,
46
+ }, fn)
47
+ }
48
+
49
+ export function currentChangeSet() {
50
+ return changeSetContext.getStore() ?? null
51
+ }
52
+
26
53
  function store() {
27
54
  runtime.changeSets ??= new Map()
28
55
  return runtime.changeSets
@@ -45,6 +72,14 @@ function relativeToWorkingFolder(uri) {
45
72
  // undo needs "changed the hero text on the devices page", not a file count.
46
73
  // First one wins: later writes in the same set are the same request.
47
74
  export function recordChangeSetWrite({ changeSet, summary, principal, uri, operation = 'write', undoOf } = {}) {
75
+ // An explicit id always wins; otherwise take whatever set is in effect.
76
+ // A write with neither stays unclaimed, which is the correct outcome for
77
+ // an API or human write that no request owns.
78
+ const ambient = currentChangeSet()
79
+ changeSet ??= ambient?.changeSet
80
+ summary ??= ambient?.summary
81
+ principal ??= ambient?.principal
82
+ undoOf ??= ambient?.undoOf
48
83
  if (!changeSet || !uri) return null
49
84
  const rel = relativeToWorkingFolder(uri)
50
85
  // Outside the working folder there is nothing a repo-scoped consumer can
package/src/utils.js CHANGED
@@ -11,6 +11,7 @@ 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'
14
15
 
15
16
  // Stable content fingerprint for entities — used by manifest snapshots,
16
17
  // engine mutation tracking, and the layouts dispatcher's hash-aware
@@ -790,6 +791,10 @@ export async function writeEntity(entity, patch = {}) {
790
791
 
791
792
  await mkdir(path.dirname(entity.uri), { recursive: true })
792
793
  await writeFile(entity.uri, newContent, 'utf8')
794
+ // The other file-writing primitive. A rename cascade rewrites every
795
+ // referring file through here, which is the largest fan-out any single
796
+ // request has and therefore the one most worth being able to take back.
797
+ recordChangeSetWrite({ uri: entity.uri })
793
798
 
794
799
  return entity.uri
795
800
  }
@@ -1104,11 +1109,19 @@ export function useCollection(runtime, name) {
1104
1109
  const uri = resolveWithin(relativePath)
1105
1110
  await mkdir(path.dirname(uri), { recursive: true })
1106
1111
  await writeFile(uri, content, 'utf8')
1112
+ // Attributed to whatever change set is in effect, if any. Hooked
1113
+ // at the lowest write primitive so a plugin that has never heard
1114
+ // of change sets still produces undoable work — the alternative is
1115
+ // every writer remembering, and the one that forgets is the one
1116
+ // whose edit cannot be taken back.
1117
+ recordChangeSetWrite({ uri })
1107
1118
  return uri
1108
1119
  },
1109
1120
 
1110
1121
  async remove(relativePath) {
1111
- await unlink(resolveWithin(relativePath))
1122
+ const uri = resolveWithin(relativePath)
1123
+ await unlink(uri)
1124
+ recordChangeSetWrite({ uri, operation: 'delete' })
1112
1125
  },
1113
1126
  }
1114
1127
  }
package/src/write.js CHANGED
@@ -24,7 +24,7 @@ import runtime from './runtime.js'
24
24
  import { readEntity, findEntities } from './catalog.js'
25
25
  import { useCollection, checksum, readEntityContent, lookupKeys } from './utils.js'
26
26
  import { nextCycleId, whenCycleCompletes } from './report.js'
27
- import { recordChangeSetWrite } from './changeset.js'
27
+ import { recordChangeSetWrite, currentChangeSet } from './changeset.js'
28
28
 
29
29
  // How far into a file to look for a marker. A header nobody reads is not a
30
30
  // header; one buried 200 lines down is not either.
@@ -172,6 +172,14 @@ export async function writeEntitySource({
172
172
  summary,
173
173
  principal,
174
174
  } = {}) {
175
+ // An explicit id wins; otherwise inherit whatever set is in effect, so the
176
+ // response can name the set a caller would undo even when the caller never
177
+ // passed one.
178
+ const ambient = currentChangeSet()
179
+ changeSet ??= ambient?.changeSet
180
+ summary ??= ambient?.summary
181
+ principal ??= ambient?.principal
182
+
175
183
  if (id) {
176
184
  const located = await locateEntityFile(id)
177
185
  if (located.error) return { ok: false, refused: 'unresolvable-id', error: located.error }
@@ -306,6 +314,14 @@ export async function deleteEntitySource({
306
314
  summary,
307
315
  principal,
308
316
  } = {}) {
317
+ // An explicit id wins; otherwise inherit whatever set is in effect, so the
318
+ // response can name the set a caller would undo even when the caller never
319
+ // passed one.
320
+ const ambient = currentChangeSet()
321
+ changeSet ??= ambient?.changeSet
322
+ summary ??= ambient?.summary
323
+ principal ??= ambient?.principal
324
+
309
325
  if (id) {
310
326
  const located = await locateEntityFile(id)
311
327
  if (located.error) return { ok: false, refused: 'unresolvable-id', error: located.error }