mikser-io 9.44.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 +90 -0
- package/package.json +1 -1
- package/src/changeset.js +35 -0
- package/src/utils.js +14 -1
- package/src/write.js +146 -2
package/docs/api-reference.md
CHANGED
|
@@ -538,6 +538,27 @@ write back a whole file built from a copy that is now stale.
|
|
|
538
538
|
catalog's, which lags between builds — pass `diskChecksum`, or the
|
|
539
539
|
`currentChecksum` a refusal hands back.
|
|
540
540
|
|
|
541
|
+
### `deleteEntitySource(options)`
|
|
542
|
+
|
|
543
|
+
Removes a source file with the same guards, plus the one only removal needs.
|
|
544
|
+
|
|
545
|
+
```js
|
|
546
|
+
const preview = await deleteEntitySource({ id: '/documents/old.md', dryRun: true })
|
|
547
|
+
// preview.referencedBy → [{ id: '/documents/page.md', field: 'hero' }]
|
|
548
|
+
```
|
|
549
|
+
|
|
550
|
+
Takes the same addressing, `ifChecksum` and `dryRun` as the write. `referencedBy`
|
|
551
|
+
reports everything still pointing at the entity — asked of the reference index
|
|
552
|
+
through `lookupKeys`, so a ref by served path counts exactly as invalidation
|
|
553
|
+
counts it, not just a ref by id. A delete is the one write with no content to
|
|
554
|
+
inspect afterwards, which makes the preview the only chance to see its cost.
|
|
555
|
+
|
|
556
|
+
### Change sets
|
|
557
|
+
|
|
558
|
+
Both take `changeSet` and `summary`, grouping several writes into one unit a
|
|
559
|
+
consumer can commit together and later take back together. See
|
|
560
|
+
[Change sets](#change-sets-1) below.
|
|
561
|
+
|
|
541
562
|
### Advisories
|
|
542
563
|
|
|
543
564
|
`contentAdvisories(entity, content)` names files a caller must not edit blind,
|
|
@@ -556,6 +577,75 @@ extension, which may render to the same destination.
|
|
|
556
577
|
or `{ error }` — taken from the entity rather than by splitting the id, since the
|
|
557
578
|
prefix is configurable and the extension may have been stripped.
|
|
558
579
|
|
|
580
|
+
## Change sets
|
|
581
|
+
|
|
582
|
+
Which writes belong together, and who asked for them.
|
|
583
|
+
|
|
584
|
+
The engine does not otherwise care who wrote a file — a write is a write. That
|
|
585
|
+
holds until something wants to undo one request without touching everything
|
|
586
|
+
around it, and then it is the wrong resolution: an agent's three edits and a
|
|
587
|
+
document created through the API a second later are indistinguishable, so
|
|
588
|
+
removing one removes the other.
|
|
589
|
+
|
|
590
|
+
```js
|
|
591
|
+
await writeEntitySource({ id, content, changeSet: 'req-42', summary: 'Rewrite the hero copy' })
|
|
592
|
+
await deleteEntitySource({ id: other, changeSet: 'req-42' })
|
|
593
|
+
|
|
594
|
+
pendingChangeSets()
|
|
595
|
+
// [{ id: 'req-42', summary: 'Rewrite the hero copy', paths: [...], deletions: [...] }]
|
|
596
|
+
clearChangeSets(['req-42'])
|
|
597
|
+
```
|
|
598
|
+
|
|
599
|
+
| Export | Does |
|
|
600
|
+
| --- | --- |
|
|
601
|
+
| `withChangeSet({ changeSet, summary, principal }, fn)` | run `fn` with a set in effect |
|
|
602
|
+
| `currentChangeSet()` | the set in effect, or null |
|
|
603
|
+
| `recordChangeSetWrite({ changeSet, summary, principal, uri, operation, undoOf })` | attach one path to a set |
|
|
604
|
+
| `pendingChangeSets()` | sets with unconsumed writes, oldest first |
|
|
605
|
+
| `clearChangeSets(ids)` | drop what a consumer has committed |
|
|
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
|
+
|
|
634
|
+
Paths come back repo-relative and POSIX-separated, ready for a git pathspec.
|
|
635
|
+
|
|
636
|
+
**Not a transaction.** Nothing is held back, nothing rolls back on failure, and
|
|
637
|
+
a half-finished set is a real set containing what actually landed. It is a
|
|
638
|
+
label on work that already happened, which is what makes it safe on a write
|
|
639
|
+
path that must never block. A path is claimed only after the write succeeds —
|
|
640
|
+
claiming on intent would make a write that never happened undoable, and undoing
|
|
641
|
+
it would delete whatever is actually at that path.
|
|
642
|
+
|
|
643
|
+
**Unclaimed writes stay unclaimed.** A consumer must still handle them; they
|
|
644
|
+
happened, and losing them would be worse than not attributing them.
|
|
645
|
+
`mikser-io-git` commits claimed sets to their own scoped commits and sweeps the
|
|
646
|
+
rest into unattributed ones, which is what lets it offer undo for the first and
|
|
647
|
+
not the second.
|
|
648
|
+
|
|
559
649
|
## Search
|
|
560
650
|
|
|
561
651
|
`queryEntities` sifts **meta**. `searchEntities` answers the other question —
|
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
|
@@ -22,9 +22,9 @@ import { readdir } from 'node:fs/promises'
|
|
|
22
22
|
|
|
23
23
|
import runtime from './runtime.js'
|
|
24
24
|
import { readEntity, findEntities } from './catalog.js'
|
|
25
|
-
import { useCollection, checksum, readEntityContent } from './utils.js'
|
|
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 }
|
|
@@ -287,3 +295,139 @@ export async function writeEntitySource({
|
|
|
287
295
|
if (awaitCycle) result.report = await whenCycleCompletes(cycleId)
|
|
288
296
|
return result
|
|
289
297
|
}
|
|
298
|
+
|
|
299
|
+
// Remove a source file, with the same checks the write has plus the one that
|
|
300
|
+
// only matters for removal: what still points at it.
|
|
301
|
+
//
|
|
302
|
+
// Delete is the more destructive operation and had the fewest guards — no
|
|
303
|
+
// precondition, no preview, no advisory, and nothing at all about the
|
|
304
|
+
// references that break when an entity other documents point at disappears.
|
|
305
|
+
//
|
|
306
|
+
// Same contract as writeEntitySource: never throws for an expected outcome.
|
|
307
|
+
export async function deleteEntitySource({
|
|
308
|
+
id,
|
|
309
|
+
collection,
|
|
310
|
+
relativePath,
|
|
311
|
+
ifChecksum,
|
|
312
|
+
dryRun = false,
|
|
313
|
+
changeSet,
|
|
314
|
+
summary,
|
|
315
|
+
principal,
|
|
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
|
+
|
|
325
|
+
if (id) {
|
|
326
|
+
const located = await locateEntityFile(id)
|
|
327
|
+
if (located.error) return { ok: false, refused: 'unresolvable-id', error: located.error }
|
|
328
|
+
if (collection && collection !== located.collection) {
|
|
329
|
+
return {
|
|
330
|
+
ok: false,
|
|
331
|
+
refused: 'collection-mismatch',
|
|
332
|
+
error: `id ${id} is in collection ${located.collection}, not ${collection}. Pass one or the other.`,
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
collection ??= located.collection
|
|
336
|
+
relativePath ??= located.relativePath
|
|
337
|
+
}
|
|
338
|
+
if (!collection || !relativePath) {
|
|
339
|
+
return {
|
|
340
|
+
ok: false,
|
|
341
|
+
refused: 'incomplete-target',
|
|
342
|
+
error: 'Pass either `id` (for an existing entity) or both `collection` and `relativePath`.',
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
let handle
|
|
347
|
+
let uri
|
|
348
|
+
try {
|
|
349
|
+
handle = useCollection(runtime, collection)
|
|
350
|
+
uri = handle.resolveWithin(relativePath)
|
|
351
|
+
} catch (err) {
|
|
352
|
+
return { ok: false, refused: 'invalid-target', collection, relativePath, error: err.message }
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const existing = id ? await readEntity({ id }) : await findEntityAtUri(uri)
|
|
356
|
+
const currentChecksum = await fileChecksum(uri)
|
|
357
|
+
if (currentChecksum === null) {
|
|
358
|
+
return {
|
|
359
|
+
ok: false, refused: 'not-found', collection, relativePath,
|
|
360
|
+
error: 'Nothing on disk at that path.',
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Everything that would be left pointing at nothing. Asked of the
|
|
365
|
+
// reference index rather than of the text, so it sees a `$`-ref by served
|
|
366
|
+
// path exactly the way invalidation does.
|
|
367
|
+
const referencedBy = existing ? referrersOf(existing) : []
|
|
368
|
+
const wouldAffect = existing?.id ? (runtime.manifest?.affectedBy?.(existing) ?? []) : []
|
|
369
|
+
|
|
370
|
+
if (dryRun) {
|
|
371
|
+
return {
|
|
372
|
+
ok: true, dryRun: true, collection, relativePath,
|
|
373
|
+
id: existing?.id ?? null,
|
|
374
|
+
exists: true,
|
|
375
|
+
currentChecksum,
|
|
376
|
+
referencedBy,
|
|
377
|
+
wouldAffect,
|
|
378
|
+
wouldAffectCount: wouldAffect.length,
|
|
379
|
+
...(referencedBy.length ? {
|
|
380
|
+
warning: `${referencedBy.length} entit${referencedBy.length === 1 ? 'y' : 'ies'} reference this. `
|
|
381
|
+
+ 'Deleting it leaves those references pointing at nothing.',
|
|
382
|
+
} : {}),
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
if (ifChecksum !== undefined && ifChecksum !== currentChecksum) {
|
|
387
|
+
return {
|
|
388
|
+
ok: false,
|
|
389
|
+
refused: 'checksum-mismatch',
|
|
390
|
+
collection, relativePath,
|
|
391
|
+
expectedChecksum: ifChecksum,
|
|
392
|
+
currentChecksum,
|
|
393
|
+
hint: 'The file changed since you read it. Re-read it before deciding to delete it, and retry with '
|
|
394
|
+
+ '`currentChecksum` from THIS response.',
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
await handle.remove(relativePath)
|
|
399
|
+
if (changeSet) recordChangeSetWrite({ changeSet, summary, principal, uri, operation: 'delete' })
|
|
400
|
+
|
|
401
|
+
return {
|
|
402
|
+
ok: true, collection, relativePath,
|
|
403
|
+
id: existing?.id ?? null,
|
|
404
|
+
deletedChecksum: currentChecksum,
|
|
405
|
+
cycleId: nextCycleId(),
|
|
406
|
+
...(changeSet ? { changeSet } : {}),
|
|
407
|
+
referencedBy,
|
|
408
|
+
...(referencedBy.length ? {
|
|
409
|
+
warning: `${referencedBy.length} entit${referencedBy.length === 1 ? 'y' : 'ies'} still reference this.`,
|
|
410
|
+
} : {}),
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// Who points at this entity, by any of the keys it can be referenced by.
|
|
415
|
+
//
|
|
416
|
+
// `lookupKeys` rather than the id alone: content refers to served paths far
|
|
417
|
+
// more often than to catalog ids, and asking only about the id would report a
|
|
418
|
+
// widely-linked image as unreferenced.
|
|
419
|
+
function referrersOf(entity) {
|
|
420
|
+
const refs = runtime.refs
|
|
421
|
+
if (!refs?.inboundFor) return []
|
|
422
|
+
const found = new Map()
|
|
423
|
+
for (const key of [entity.id, ...(lookupKeys(entity) ?? [])]) {
|
|
424
|
+
if (!key) continue
|
|
425
|
+
let inbound = []
|
|
426
|
+
try { inbound = refs.inboundFor(key) ?? [] } catch { continue }
|
|
427
|
+
for (const ref of inbound) {
|
|
428
|
+
if (!ref?.id || ref.id === entity.id) continue
|
|
429
|
+
found.set(ref.id, { id: ref.id, ...(ref.field ? { field: ref.field } : {}) })
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
return [...found.values()]
|
|
433
|
+
}
|