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 +1 -0
- package/package.json +1 -1
- package/src/changeset.js +108 -0
- package/src/write.js +10 -0
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
package/src/changeset.js
ADDED
|
@@ -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
|