mikser-io 9.45.0 → 9.46.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 +35 -0
- package/package.json +1 -1
- package/src/changeset.js +58 -16
- 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
|
|
@@ -617,6 +646,12 @@ happened, and losing them would be worse than not attributing them.
|
|
|
617
646
|
rest into unattributed ones, which is what lets it offer undo for the first and
|
|
618
647
|
not the second.
|
|
619
648
|
|
|
649
|
+
The engine records the grouping and takes no position on what is done with it —
|
|
650
|
+
it knows nothing about commits, branches or reverts. Versioning the paths
|
|
651
|
+
together is one use; a snapshot, an audit trail, a draft-then-publish gate or a
|
|
652
|
+
filesystem-level rollback all want the same fact. That is why change sets live
|
|
653
|
+
in core and git does not.
|
|
654
|
+
|
|
620
655
|
## Search
|
|
621
656
|
|
|
622
657
|
`queryEntities` sifts **meta**. `searchEntities` answers the other question —
|
package/package.json
CHANGED
package/src/changeset.js
CHANGED
|
@@ -8,8 +8,12 @@
|
|
|
8
8
|
// second are indistinguishable, so removing one removes the other.
|
|
9
9
|
//
|
|
10
10
|
// A change set is the missing grain. The caller names it, the writes
|
|
11
|
-
// accumulate under it, and a consumer
|
|
12
|
-
//
|
|
11
|
+
// accumulate under it, and a consumer can act on exactly those paths.
|
|
12
|
+
//
|
|
13
|
+
// The engine records the grouping and takes no position on what is done with
|
|
14
|
+
// it. Versioning the paths together is one use — mikser-io-git's — but a
|
|
15
|
+
// snapshot, an audit trail, a draft-then-publish gate or a filesystem-level
|
|
16
|
+
// rollback all want the same fact, and none of them is a commit.
|
|
13
17
|
//
|
|
14
18
|
// Deliberately NOT a transaction. Nothing is held back, nothing rolls back on
|
|
15
19
|
// failure, and a half-finished set is a real set containing what actually
|
|
@@ -21,15 +25,45 @@
|
|
|
21
25
|
// attribute them.
|
|
22
26
|
|
|
23
27
|
import path from 'node:path'
|
|
28
|
+
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
24
29
|
import runtime from './runtime.js'
|
|
25
30
|
|
|
31
|
+
// The change set in effect for the current call.
|
|
32
|
+
//
|
|
33
|
+
// Threading an id through every write is fine for one API and hopeless across
|
|
34
|
+
// a plugin ecosystem: mikser-io-drive writes with fs.writeFile, forms writes
|
|
35
|
+
// its own entities, and each new mutating tool would have to remember. An
|
|
36
|
+
// ambient context means a caller declares the set ONCE and everything written
|
|
37
|
+
// underneath is attributed, including by code that has never heard of change
|
|
38
|
+
// sets.
|
|
39
|
+
const changeSetContext = new AsyncLocalStorage()
|
|
40
|
+
|
|
41
|
+
// Run `fn` with a change set in effect. Writes inside it are attributed to
|
|
42
|
+
// that set unless they name a different one explicitly.
|
|
43
|
+
export function withChangeSet(set, fn) {
|
|
44
|
+
if (!set?.changeSet) return fn()
|
|
45
|
+
return changeSetContext.run({
|
|
46
|
+
changeSet: set.changeSet,
|
|
47
|
+
summary: set.summary ?? null,
|
|
48
|
+
principal: set.principal ?? null,
|
|
49
|
+
undoOf: set.undoOf ?? null,
|
|
50
|
+
}, fn)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function currentChangeSet() {
|
|
54
|
+
return changeSetContext.getStore() ?? null
|
|
55
|
+
}
|
|
56
|
+
|
|
26
57
|
function store() {
|
|
27
58
|
runtime.changeSets ??= new Map()
|
|
28
59
|
return runtime.changeSets
|
|
29
60
|
}
|
|
30
61
|
|
|
31
|
-
//
|
|
32
|
-
//
|
|
62
|
+
// Relative to the working folder, POSIX-separated.
|
|
63
|
+
//
|
|
64
|
+
// The working folder is the root every consumer already reasons in, and
|
|
65
|
+
// forward slashes are the separator entity ids use — so a path here matches
|
|
66
|
+
// the vocabulary of the rest of the engine rather than the host's.
|
|
33
67
|
function relativeToWorkingFolder(uri) {
|
|
34
68
|
const root = runtime.options?.workingFolder
|
|
35
69
|
if (!root || !uri) return null
|
|
@@ -45,11 +79,19 @@ function relativeToWorkingFolder(uri) {
|
|
|
45
79
|
// undo needs "changed the hero text on the devices page", not a file count.
|
|
46
80
|
// First one wins: later writes in the same set are the same request.
|
|
47
81
|
export function recordChangeSetWrite({ changeSet, summary, principal, uri, operation = 'write', undoOf } = {}) {
|
|
82
|
+
// An explicit id always wins; otherwise take whatever set is in effect.
|
|
83
|
+
// A write with neither stays unclaimed, which is the correct outcome for
|
|
84
|
+
// an API or human write that no request owns.
|
|
85
|
+
const ambient = currentChangeSet()
|
|
86
|
+
changeSet ??= ambient?.changeSet
|
|
87
|
+
summary ??= ambient?.summary
|
|
88
|
+
principal ??= ambient?.principal
|
|
89
|
+
undoOf ??= ambient?.undoOf
|
|
48
90
|
if (!changeSet || !uri) return null
|
|
49
91
|
const rel = relativeToWorkingFolder(uri)
|
|
50
|
-
// Outside the working folder there is nothing a
|
|
51
|
-
// do with the path, and silently keeping an absolute one would
|
|
52
|
-
//
|
|
92
|
+
// Outside the working folder there is nothing a consumer scoped to the
|
|
93
|
+
// project can do with the path, and silently keeping an absolute one would
|
|
94
|
+
// produce a selector that quietly matches nothing.
|
|
53
95
|
if (!rel) return null
|
|
54
96
|
|
|
55
97
|
const sets = store()
|
|
@@ -59,9 +101,9 @@ export function recordChangeSetWrite({ changeSet, summary, principal, uri, opera
|
|
|
59
101
|
id: changeSet,
|
|
60
102
|
summary: summary ?? null,
|
|
61
103
|
principal: principal ?? null,
|
|
62
|
-
// Set when this change set exists to take another one back, so
|
|
63
|
-
//
|
|
64
|
-
//
|
|
104
|
+
// Set when this change set exists to take another one back, so an
|
|
105
|
+
// undo is itself an ordinary, undoable change rather than a
|
|
106
|
+
// privileged operation that rewrites the record.
|
|
65
107
|
undoOf: undoOf ?? null,
|
|
66
108
|
startedAt: Date.now(),
|
|
67
109
|
paths: new Map(),
|
|
@@ -75,8 +117,8 @@ export function recordChangeSetWrite({ changeSet, summary, principal, uri, opera
|
|
|
75
117
|
return set.id
|
|
76
118
|
}
|
|
77
119
|
|
|
78
|
-
// Every set with writes not yet consumed, oldest first — the order
|
|
79
|
-
//
|
|
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.
|
|
80
122
|
export function pendingChangeSets() {
|
|
81
123
|
return [...store().values()]
|
|
82
124
|
.filter(set => set.paths.size)
|
|
@@ -94,10 +136,10 @@ export function pendingChangeSets() {
|
|
|
94
136
|
|
|
95
137
|
// Drop sets a consumer has dealt with.
|
|
96
138
|
//
|
|
97
|
-
// Called after the paths
|
|
98
|
-
// between loses the attribution but not the work, which
|
|
99
|
-
// consumer as an unclaimed write. That is the right way
|
|
100
|
-
// a convenience, the bytes are not.
|
|
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.
|
|
101
143
|
export function clearChangeSets(ids = []) {
|
|
102
144
|
const sets = store()
|
|
103
145
|
for (const id of ids) sets.delete(id)
|
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 }
|