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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikser-io",
3
- "version": "9.44.0",
3
+ "version": "9.46.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": {
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
- 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
@@ -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
+ }