mikser-io 9.44.0 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikser-io",
3
- "version": "9.44.0",
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": {
package/src/write.js CHANGED
@@ -22,7 +22,7 @@ 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
27
  import { recordChangeSetWrite } from './changeset.js'
28
28
 
@@ -287,3 +287,131 @@ export async function writeEntitySource({
287
287
  if (awaitCycle) result.report = await whenCycleCompletes(cycleId)
288
288
  return result
289
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
+ }