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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikser-io",
3
- "version": "9.45.0",
3
+ "version": "9.46.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
@@ -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 mikser-io-git today can commit
12
- // exactly those paths and later remove exactly that contribution.
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
- // Repo-relative, POSIX-separated: these end up in a git pathspec, and a
32
- // consumer should not have to redo that conversion or guess the root.
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 repo-scoped consumer can
51
- // do with the path, and silently keeping an absolute one would produce a
52
- // pathspec that matches nothing.
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
- // the undo is itself an ordinary, undoable change rather than a
64
- // special history-rewriting operation.
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 a consumer
79
- // should commit them in, so history reads the way the work happened.
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 are committed, not after they are written: a crash in
98
- // between loses the attribution but not the work, which then reaches the
99
- // consumer as an unclaimed write. That is the right way round — attribution is
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
- 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 }