mikser-io 9.43.1 → 9.45.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,46 @@ 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
+ | `recordChangeSetWrite({ changeSet, summary, principal, uri, operation, undoOf })` | attach one path to a set |
602
+ | `pendingChangeSets()` | sets with unconsumed writes, oldest first |
603
+ | `clearChangeSets(ids)` | drop what a consumer has committed |
604
+
605
+ Paths come back repo-relative and POSIX-separated, ready for a git pathspec.
606
+
607
+ **Not a transaction.** Nothing is held back, nothing rolls back on failure, and
608
+ a half-finished set is a real set containing what actually landed. It is a
609
+ label on work that already happened, which is what makes it safe on a write
610
+ path that must never block. A path is claimed only after the write succeeds —
611
+ claiming on intent would make a write that never happened undoable, and undoing
612
+ it would delete whatever is actually at that path.
613
+
614
+ **Unclaimed writes stay unclaimed.** A consumer must still handle them; they
615
+ happened, and losing them would be worse than not attributing them.
616
+ `mikser-io-git` commits claimed sets to their own scoped commits and sweeps the
617
+ rest into unattributed ones, which is what lets it offer undo for the first and
618
+ not the second.
619
+
559
620
  ## Search
560
621
 
561
622
  `queryEntities` sifts **meta**. `searchEntities` answers the other question —
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.45.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
@@ -22,8 +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
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
@@ -277,3 +287,131 @@ export async function writeEntitySource({
277
287
  if (awaitCycle) result.report = await whenCycleCompletes(cycleId)
278
288
  return result
279
289
  }
290
+
291
+ // Remove a source file, with the same checks the write has plus the one that
292
+ // only matters for removal: what still points at it.
293
+ //
294
+ // Delete is the more destructive operation and had the fewest guards — no
295
+ // precondition, no preview, no advisory, and nothing at all about the
296
+ // references that break when an entity other documents point at disappears.
297
+ //
298
+ // Same contract as writeEntitySource: never throws for an expected outcome.
299
+ export async function deleteEntitySource({
300
+ id,
301
+ collection,
302
+ relativePath,
303
+ ifChecksum,
304
+ dryRun = false,
305
+ changeSet,
306
+ summary,
307
+ principal,
308
+ } = {}) {
309
+ if (id) {
310
+ const located = await locateEntityFile(id)
311
+ if (located.error) return { ok: false, refused: 'unresolvable-id', error: located.error }
312
+ if (collection && collection !== located.collection) {
313
+ return {
314
+ ok: false,
315
+ refused: 'collection-mismatch',
316
+ error: `id ${id} is in collection ${located.collection}, not ${collection}. Pass one or the other.`,
317
+ }
318
+ }
319
+ collection ??= located.collection
320
+ relativePath ??= located.relativePath
321
+ }
322
+ if (!collection || !relativePath) {
323
+ return {
324
+ ok: false,
325
+ refused: 'incomplete-target',
326
+ error: 'Pass either `id` (for an existing entity) or both `collection` and `relativePath`.',
327
+ }
328
+ }
329
+
330
+ let handle
331
+ let uri
332
+ try {
333
+ handle = useCollection(runtime, collection)
334
+ uri = handle.resolveWithin(relativePath)
335
+ } catch (err) {
336
+ return { ok: false, refused: 'invalid-target', collection, relativePath, error: err.message }
337
+ }
338
+
339
+ const existing = id ? await readEntity({ id }) : await findEntityAtUri(uri)
340
+ const currentChecksum = await fileChecksum(uri)
341
+ if (currentChecksum === null) {
342
+ return {
343
+ ok: false, refused: 'not-found', collection, relativePath,
344
+ error: 'Nothing on disk at that path.',
345
+ }
346
+ }
347
+
348
+ // Everything that would be left pointing at nothing. Asked of the
349
+ // reference index rather than of the text, so it sees a `$`-ref by served
350
+ // path exactly the way invalidation does.
351
+ const referencedBy = existing ? referrersOf(existing) : []
352
+ const wouldAffect = existing?.id ? (runtime.manifest?.affectedBy?.(existing) ?? []) : []
353
+
354
+ if (dryRun) {
355
+ return {
356
+ ok: true, dryRun: true, collection, relativePath,
357
+ id: existing?.id ?? null,
358
+ exists: true,
359
+ currentChecksum,
360
+ referencedBy,
361
+ wouldAffect,
362
+ wouldAffectCount: wouldAffect.length,
363
+ ...(referencedBy.length ? {
364
+ warning: `${referencedBy.length} entit${referencedBy.length === 1 ? 'y' : 'ies'} reference this. `
365
+ + 'Deleting it leaves those references pointing at nothing.',
366
+ } : {}),
367
+ }
368
+ }
369
+
370
+ if (ifChecksum !== undefined && ifChecksum !== currentChecksum) {
371
+ return {
372
+ ok: false,
373
+ refused: 'checksum-mismatch',
374
+ collection, relativePath,
375
+ expectedChecksum: ifChecksum,
376
+ currentChecksum,
377
+ hint: 'The file changed since you read it. Re-read it before deciding to delete it, and retry with '
378
+ + '`currentChecksum` from THIS response.',
379
+ }
380
+ }
381
+
382
+ await handle.remove(relativePath)
383
+ if (changeSet) recordChangeSetWrite({ changeSet, summary, principal, uri, operation: 'delete' })
384
+
385
+ return {
386
+ ok: true, collection, relativePath,
387
+ id: existing?.id ?? null,
388
+ deletedChecksum: currentChecksum,
389
+ cycleId: nextCycleId(),
390
+ ...(changeSet ? { changeSet } : {}),
391
+ referencedBy,
392
+ ...(referencedBy.length ? {
393
+ warning: `${referencedBy.length} entit${referencedBy.length === 1 ? 'y' : 'ies'} still reference this.`,
394
+ } : {}),
395
+ }
396
+ }
397
+
398
+ // Who points at this entity, by any of the keys it can be referenced by.
399
+ //
400
+ // `lookupKeys` rather than the id alone: content refers to served paths far
401
+ // more often than to catalog ids, and asking only about the id would report a
402
+ // widely-linked image as unreferenced.
403
+ function referrersOf(entity) {
404
+ const refs = runtime.refs
405
+ if (!refs?.inboundFor) return []
406
+ const found = new Map()
407
+ for (const key of [entity.id, ...(lookupKeys(entity) ?? [])]) {
408
+ if (!key) continue
409
+ let inbound = []
410
+ try { inbound = refs.inboundFor(key) ?? [] } catch { continue }
411
+ for (const ref of inbound) {
412
+ if (!ref?.id || ref.id === entity.id) continue
413
+ found.set(ref.id, { id: ref.id, ...(ref.field ? { field: ref.field } : {}) })
414
+ }
415
+ }
416
+ return [...found.values()]
417
+ }