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.
- package/docs/api-reference.md +61 -0
- package/package.json +1 -1
- package/src/write.js +129 -1
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,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
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
|
+
}
|