mikser-io 9.43.1 → 9.44.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/index.js CHANGED
@@ -13,6 +13,7 @@ export * from './src/journal.js'
13
13
  export * from './src/catalog.js'
14
14
  export * from './src/search.js'
15
15
  export * from './src/write.js'
16
+ export * from './src/changeset.js'
16
17
  export * from './src/refs.js'
17
18
  export * from './src/manifest.js'
18
19
  export * from './src/provenance.js'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikser-io",
3
- "version": "9.43.1",
3
+ "version": "9.44.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": {
@@ -0,0 +1,108 @@
1
+ // Which writes belong together, and who asked for them.
2
+ //
3
+ // The engine deliberately does not care who wrote a file — files are the
4
+ // source of truth and a write is a write. That holds right up until something
5
+ // wants to UNDO one request without touching everything else that happened
6
+ // around it, and then "a write is a write" is exactly the wrong resolution:
7
+ // an agent's three edits and a document created through the API in the same
8
+ // second are indistinguishable, so removing one removes the other.
9
+ //
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.
13
+ //
14
+ // Deliberately NOT a transaction. Nothing is held back, nothing rolls back on
15
+ // failure, and a half-finished set is a real set containing what actually
16
+ // landed. It is a label on work that already happened, which is what makes it
17
+ // safe to add to a write path that must never block on it.
18
+ //
19
+ // Unclaimed writes stay unclaimed. A consumer is expected to handle them —
20
+ // they still happened, and losing them would be worse than not being able to
21
+ // attribute them.
22
+
23
+ import path from 'node:path'
24
+ import runtime from './runtime.js'
25
+
26
+ function store() {
27
+ runtime.changeSets ??= new Map()
28
+ return runtime.changeSets
29
+ }
30
+
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.
33
+ function relativeToWorkingFolder(uri) {
34
+ const root = runtime.options?.workingFolder
35
+ if (!root || !uri) return null
36
+ const rel = path.relative(root, uri)
37
+ if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return null
38
+ return rel.split(path.sep).join('/')
39
+ }
40
+
41
+ // Attach one write to a change set.
42
+ //
43
+ // `summary` is the caller's own description of what it is doing, kept because
44
+ // nothing downstream will ever know it as well — a reader choosing what to
45
+ // undo needs "changed the hero text on the devices page", not a file count.
46
+ // First one wins: later writes in the same set are the same request.
47
+ export function recordChangeSetWrite({ changeSet, summary, principal, uri, operation = 'write', undoOf } = {}) {
48
+ if (!changeSet || !uri) return null
49
+ 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.
53
+ if (!rel) return null
54
+
55
+ const sets = store()
56
+ let set = sets.get(changeSet)
57
+ if (!set) {
58
+ set = {
59
+ id: changeSet,
60
+ summary: summary ?? null,
61
+ 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.
65
+ undoOf: undoOf ?? null,
66
+ startedAt: Date.now(),
67
+ paths: new Map(),
68
+ }
69
+ sets.set(changeSet, set)
70
+ }
71
+ if (!set.summary && summary) set.summary = summary
72
+ if (!set.principal && principal) set.principal = principal
73
+ if (!set.undoOf && undoOf) set.undoOf = undoOf
74
+ set.paths.set(rel, operation)
75
+ return set.id
76
+ }
77
+
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.
80
+ export function pendingChangeSets() {
81
+ return [...store().values()]
82
+ .filter(set => set.paths.size)
83
+ .sort((a, b) => a.startedAt - b.startedAt)
84
+ .map(set => ({
85
+ id: set.id,
86
+ summary: set.summary,
87
+ principal: set.principal,
88
+ undoOf: set.undoOf,
89
+ startedAt: set.startedAt,
90
+ paths: [...set.paths.keys()],
91
+ deletions: [...set.paths.entries()].filter(([, op]) => op === 'delete').map(([p]) => p),
92
+ }))
93
+ }
94
+
95
+ // Drop sets a consumer has dealt with.
96
+ //
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.
101
+ export function clearChangeSets(ids = []) {
102
+ const sets = store()
103
+ for (const id of ids) sets.delete(id)
104
+ }
105
+
106
+ export function forgetAllChangeSets() {
107
+ store().clear()
108
+ }
package/src/write.js CHANGED
@@ -24,6 +24,7 @@ import runtime from './runtime.js'
24
24
  import { readEntity, findEntities } from './catalog.js'
25
25
  import { useCollection, checksum, readEntityContent } from './utils.js'
26
26
  import { nextCycleId, whenCycleCompletes } from './report.js'
27
+ import { recordChangeSetWrite } from './changeset.js'
27
28
 
28
29
  // How far into a file to look for a marker. A header nobody reads is not a
29
30
  // header; one buried 200 lines down is not either.
@@ -167,6 +168,9 @@ export async function writeEntitySource({
167
168
  ifChecksum,
168
169
  dryRun = false,
169
170
  awaitCycle = false,
171
+ changeSet,
172
+ summary,
173
+ principal,
170
174
  } = {}) {
171
175
  if (id) {
172
176
  const located = await locateEntityFile(id)
@@ -260,11 +264,17 @@ export async function writeEntitySource({
260
264
  const cycleId = nextCycleId()
261
265
  await handle.write(relativePath, content)
262
266
 
267
+ // AFTER the write, so a set only ever claims paths that actually moved.
268
+ // Claiming on intent would make a failed write undoable, and undoing a
269
+ // write that never happened is a way to delete someone else's file.
270
+ if (changeSet) recordChangeSetWrite({ changeSet, summary, principal, uri })
271
+
263
272
  const result = {
264
273
  ok: true, collection, relativePath,
265
274
  checksum: await fileChecksum(uri),
266
275
  bytes: Buffer.byteLength(content),
267
276
  cycleId,
277
+ ...(changeSet ? { changeSet } : {}),
268
278
  siblingDestinations: await siblingDestinations(handle.folder, relativePath),
269
279
  }
270
280
  // Echoed on the way out, not only on read. A caller that never read the