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.
- package/docs/api-reference.md +29 -0
- package/package.json +1 -1
- package/src/changeset.js +35 -0
- package/src/utils.js +14 -1
- package/src/write.js +17 -1
package/docs/api-reference.md
CHANGED
|
@@ -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
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
|
-
|
|
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 }
|