mikser-io 9.43.0 → 9.44.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 +296 -0
- package/index.js +1 -0
- package/package.json +1 -1
- package/src/changeset.js +108 -0
- package/src/write.js +10 -0
package/docs/api-reference.md
CHANGED
|
@@ -552,6 +552,9 @@ exactly the one that needs telling.
|
|
|
552
552
|
|
|
553
553
|
`siblingDestinations(folder, relativePath)` reports files differing only by
|
|
554
554
|
extension, which may render to the same destination.
|
|
555
|
+
`locateEntityFile(id)` resolves a catalog id to its `{ collection, relativePath }`,
|
|
556
|
+
or `{ error }` — taken from the entity rather than by splitting the id, since the
|
|
557
|
+
prefix is configurable and the extension may have been stripped.
|
|
555
558
|
|
|
556
559
|
## Search
|
|
557
560
|
|
|
@@ -615,6 +618,299 @@ it, in any text format, with no per-language grammar involved. It returns the
|
|
|
615
618
|
line rather than a verdict about it, so where the heuristic is wrong the
|
|
616
619
|
evidence is in the result.
|
|
617
620
|
|
|
621
|
+
## Content
|
|
622
|
+
|
|
623
|
+
Reading an entity's source, and deciding what "source" even means for it.
|
|
624
|
+
|
|
625
|
+
### `readEntityContent(entity, { reload } = {})`
|
|
626
|
+
|
|
627
|
+
Returns one of `{ content }`, `{ contentError }`, `{ contentSkipped }` — an
|
|
628
|
+
object to `Object.assign` onto the entity, or use directly. Dispatches by URI
|
|
629
|
+
scheme: plain paths and `file://` read from disk, `http(s)://` goes through the
|
|
630
|
+
built-in provider, anything else dynamic-imports `mikser-io-provider-<scheme>`.
|
|
631
|
+
|
|
632
|
+
`entity.content` already being a string short-circuits the whole dispatch, which
|
|
633
|
+
spares re-fetching a remote document a source plugin eagerly pulled in. Pass
|
|
634
|
+
`reload: true` when you want the bytes **as they are now** — between builds the
|
|
635
|
+
catalog copy and the file on disk part ways, and a whole-file rewrite built from
|
|
636
|
+
the catalog copy silently discards whatever changed underneath. An entity with
|
|
637
|
+
no `uri` keeps what it has rather than erroring.
|
|
638
|
+
|
|
639
|
+
### `looksTextual(buffer)` / `isTextEntity(entity)`
|
|
640
|
+
|
|
641
|
+
`looksTextual` answers "is this text?" from the BYTES: no NUL and a clean UTF-8
|
|
642
|
+
decode. This is what decides whether content comes back, and it is why a
|
|
643
|
+
`.liquid`, `.njk`, `.toml` or a format nobody has written yet is readable
|
|
644
|
+
without being added to a list first.
|
|
645
|
+
|
|
646
|
+
`isTextEntity` is a cheap extension guess with no I/O. It is a **hint** — the
|
|
647
|
+
extension list behind it is hand-maintained and therefore wrong about anything
|
|
648
|
+
not yet added. Never gate a read on it.
|
|
649
|
+
|
|
650
|
+
### `mimeForEntity(entity)`
|
|
651
|
+
|
|
652
|
+
Content type for the entity's `destination`, from the IANA registry via
|
|
653
|
+
`mime-types`. Null when the entity has no destination or the extension is
|
|
654
|
+
unregistered.
|
|
655
|
+
|
|
656
|
+
### `checksumOf(content)` / `checksum(uri)`
|
|
657
|
+
|
|
658
|
+
`checksumOf` hashes a string; `checksum` hashes a file, sampling head and tail
|
|
659
|
+
for large ones rather than reading the whole thing.
|
|
660
|
+
|
|
661
|
+
## Collections and sources
|
|
662
|
+
|
|
663
|
+
### `useCollection(runtime, name)`
|
|
664
|
+
|
|
665
|
+
The folder behind a collection, and guarded writes into it.
|
|
666
|
+
|
|
667
|
+
```js
|
|
668
|
+
const documents = useCollection(runtime, 'documents')
|
|
669
|
+
documents.folder // absolute path
|
|
670
|
+
await documents.write('blog/post.md', text)
|
|
671
|
+
await documents.remove('blog/post.md')
|
|
672
|
+
documents.resolveWithin('blog/post.md') // absolute path, or throws
|
|
673
|
+
```
|
|
674
|
+
|
|
675
|
+
`write`, `remove` and `resolveWithin` refuse a path that resolves outside the
|
|
676
|
+
collection folder. This matters whenever the path comes from a request body or
|
|
677
|
+
a form: joining a folder with `../../x` lands outside it. The path is resolved
|
|
678
|
+
and then contained rather than rejected on a literal `..`, so `blog/../post.md`
|
|
679
|
+
still works.
|
|
680
|
+
|
|
681
|
+
### `useSource(core, options)`
|
|
682
|
+
|
|
683
|
+
Codifies the folder-of-files pattern: scan a folder, emit entities, watch for
|
|
684
|
+
changes, sweep deletions.
|
|
685
|
+
|
|
686
|
+
| Option | Meaning |
|
|
687
|
+
| --- | --- |
|
|
688
|
+
| `collection`, `type`, `folder` | Required. |
|
|
689
|
+
| `extensions` | Default `['*']`. |
|
|
690
|
+
| `ignore` | Glob patterns to skip. |
|
|
691
|
+
| `phase` | `'loaded'` (default) or another lifecycle phase. |
|
|
692
|
+
| `content` | Load file content into the entity at sync time. |
|
|
693
|
+
| `load` | `async (entity) => meta` — your parse step. |
|
|
694
|
+
| `idPrefix` | Defaults to `/<collection>`. |
|
|
695
|
+
| `stripExtensionFromId` | Default false (documents style). |
|
|
696
|
+
| `progress` | Progress label. |
|
|
697
|
+
|
|
698
|
+
### `sweepDeleted(collection, scanned, onDelete, ownerPrefix)`
|
|
699
|
+
|
|
700
|
+
Removes catalog entities whose files are gone. **`ownerPrefix` is mandatory and
|
|
701
|
+
load-bearing.** Collections are multi-emitter: the file source scans a folder,
|
|
702
|
+
but a CSV plugin fans rows into the same collection, and a remote sync emits
|
|
703
|
+
there with a `gdrive://` uri. The sweep only considers entities whose `uri` is
|
|
704
|
+
rooted under the prefix — without it, every cycle's file sweep wipes every
|
|
705
|
+
other emitter's entities.
|
|
706
|
+
|
|
707
|
+
### `useRenderer(runtime, { defaultTimeout } = {})`
|
|
708
|
+
|
|
709
|
+
Returns `{ render }` — the batching renderer the engine dispatches through,
|
|
710
|
+
with a per-task timeout (default 30s). A plugin that needs to render something
|
|
711
|
+
outside the normal cycle goes through this rather than importing a renderer
|
|
712
|
+
package directly.
|
|
713
|
+
|
|
714
|
+
## Query context
|
|
715
|
+
|
|
716
|
+
`queryContext` is the `AsyncLocalStorage` that lets a catalog query made during
|
|
717
|
+
a render record itself as a dependency, so an aggregate page invalidates when a
|
|
718
|
+
new matching entity lands.
|
|
719
|
+
|
|
720
|
+
It only works if the whole tree shares ONE module instance of `mikser-io`. In
|
|
721
|
+
the side-by-side dev layout that means the npm workspace at the parent folder
|
|
722
|
+
is not ergonomics but correctness: without it, npm installs a second copy into
|
|
723
|
+
a sibling's own `node_modules`, a plugin's `queryContext` is then a different
|
|
724
|
+
AsyncLocalStorage than the engine's, queries record no edges, and index pages,
|
|
725
|
+
sitemaps and feeds silently stop rebuilding. Production consumers resolve both
|
|
726
|
+
from their own tree, so the problem is local to the dev workspace.
|
|
727
|
+
|
|
728
|
+
## Auth
|
|
729
|
+
|
|
730
|
+
Building a token-gated or loopback-only route.
|
|
731
|
+
|
|
732
|
+
| Export | Does |
|
|
733
|
+
| --- | --- |
|
|
734
|
+
| `resolveAuth(config)` | build a verifier from endpoint config |
|
|
735
|
+
| `requireAuth(verifier, options)` | Express middleware |
|
|
736
|
+
| `authorize(req, verifier, { allowRemote, trustLoopback })` | the check itself |
|
|
737
|
+
| `bearer({ token, name, subject, capabilities, scope })` | a static-token verifier |
|
|
738
|
+
| `loopbackOnly({ message })` | middleware refusing non-loopback callers |
|
|
739
|
+
| `hasCapability(principal, capability)` | test a resolved principal |
|
|
740
|
+
|
|
741
|
+
A principal may carry a `scope` — a sift filter that narrows what it can see.
|
|
742
|
+
Anything reading content on a principal's behalf must apply it; see the warning
|
|
743
|
+
under [Search](#search) for why an unscoped read behind a scoped endpoint is
|
|
744
|
+
the failure mode to watch for.
|
|
745
|
+
|
|
746
|
+
## References
|
|
747
|
+
|
|
748
|
+
The `$`-keyed reference graph (ADR-0007), reachable at `runtime.refs` or via
|
|
749
|
+
`useRefsIndex()`.
|
|
750
|
+
|
|
751
|
+
| Method | Answers |
|
|
752
|
+
| --- | --- |
|
|
753
|
+
| `inboundFor(target)` / `outboundFor(source)` | static `$`-ref edges |
|
|
754
|
+
| `dynamicInboundFor` / `dynamicOutboundFor` | render-time edges (layout, partial, query, lookup) |
|
|
755
|
+
| `inverseClosureOf(seeds)` | everything reachable backwards — what invalidation walks |
|
|
756
|
+
| `resolveRefIds(ref)` | which entities a ref string resolves to |
|
|
757
|
+
| `rename({ from, to })` | rewrite refs across the catalog, as one cascade |
|
|
758
|
+
| `allRefs()` / `size()` | inventory |
|
|
759
|
+
|
|
760
|
+
### `refFilter(ref)` / `matchesRef(entity, ref)` / `lookupKeys(entity)`
|
|
761
|
+
|
|
762
|
+
One relation in three directions — as a catalog query, as a predicate, and in
|
|
763
|
+
reverse. **They must be changed together.** A key present in one and missing
|
|
764
|
+
from the others is silent: `meta.url` once lived only in `refFilter`, which
|
|
765
|
+
made every `$`-ref to a served path non-invalidating without any error
|
|
766
|
+
anywhere.
|
|
767
|
+
|
|
768
|
+
### `extractRefs(meta)` / `isRefKey(key)` / `expandEntity(entity, paths, options)` / `projectMeta(meta)`
|
|
769
|
+
|
|
770
|
+
Find the `$`-keys in a meta tree, test one key, inline referenced entities
|
|
771
|
+
along dotted paths, and drop `$`-keys for output.
|
|
772
|
+
|
|
773
|
+
## Provenance
|
|
774
|
+
|
|
775
|
+
Where a value was **written** — source file, field path, line, column.
|
|
776
|
+
|
|
777
|
+
```js
|
|
778
|
+
const positions = await useProvenance().positionsFor(entity)
|
|
779
|
+
// { 'items[2].label': { line, col }, … }
|
|
780
|
+
```
|
|
781
|
+
|
|
782
|
+
| Method | Answers |
|
|
783
|
+
| --- | --- |
|
|
784
|
+
| `positionsFor(entity)` | every leaf of the entity's meta |
|
|
785
|
+
| `locate(entity, fieldPath)` | one position, or null |
|
|
786
|
+
| `forget(id)` | drop a cached entry |
|
|
787
|
+
| `size()` | how many entries are cached |
|
|
788
|
+
|
|
789
|
+
Field paths are free — they come from walking meta, already in memory. Line and
|
|
790
|
+
column need one parse of the raw source, done **on demand** and cached against
|
|
791
|
+
the entity's checksum, so a build pays nothing.
|
|
792
|
+
|
|
793
|
+
`registerProvenanceFormat(name, { test, positions })` adds a format rather than
|
|
794
|
+
special-casing one. A format whose parser reports no ranges registers a
|
|
795
|
+
`probeFormat(name, { test, parse })` instead, which recovers positions in one
|
|
796
|
+
pass without the parser's help.
|
|
797
|
+
|
|
798
|
+
## Manifest and outputs
|
|
799
|
+
|
|
800
|
+
`runtime.manifest` holds render snapshots. Full treatment lives in
|
|
801
|
+
[diagnostics.md](diagnostics.md) — indexed by the question each surface
|
|
802
|
+
answers — but the ones an application reaches for:
|
|
803
|
+
|
|
804
|
+
| Method | Answers |
|
|
805
|
+
| --- | --- |
|
|
806
|
+
| `affectedBy(entity)` | which destinations would re-render if this changed |
|
|
807
|
+
| `collisions()` | destinations more than one entity writes to |
|
|
808
|
+
| `snapshotsFor(id)` / `snapshotsAt(destination)` | what rendered, and from what |
|
|
809
|
+
| `skipDecision(entity, …)` | the engine's own skip rule, with the reason |
|
|
810
|
+
|
|
811
|
+
`sourcesOf(destination)` is the reverse lookup: what produced this built file,
|
|
812
|
+
each tagged with how it got there. `sourcesBehind(snapshot)` does the same from
|
|
813
|
+
a snapshot you already hold. `resolveOutputPath(destination)` maps a
|
|
814
|
+
destination to a path on disk, and `writeOutput(file, bytes)` writes one.
|
|
815
|
+
|
|
816
|
+
## Tools
|
|
817
|
+
|
|
818
|
+
The tool registry — named, described, invokable capabilities. Two agent
|
|
819
|
+
workflows exist and are equally real: one speaking MCP over HTTP, one running
|
|
820
|
+
the CLI and reading its output. A tool registered here reaches both.
|
|
821
|
+
|
|
822
|
+
```js
|
|
823
|
+
registerTool('audit', {
|
|
824
|
+
description: 'What this answers, in prose an agent will actually read.',
|
|
825
|
+
inputSchema: { path: { type: 'string', required: true } },
|
|
826
|
+
}, async ({ path }) => ok({ … }))
|
|
827
|
+
```
|
|
828
|
+
|
|
829
|
+
`invokeTool(name, args)` runs one — it accepts the bare name or the `mikser_`
|
|
830
|
+
prefixed form. `toolNames()`, `toolSchema(name)` and `toolSchemas()` enumerate.
|
|
831
|
+
`toolResultText(result)` pulls the text back out of a tool result, and
|
|
832
|
+
`toolResultFailed(result)` says whether it failed.
|
|
833
|
+
|
|
834
|
+
The registry is deliberately zod-free: schemas use a neutral
|
|
835
|
+
`{ type, required?, description? }` vocabulary, because it must not depend on
|
|
836
|
+
one transport's schema library. `mikser-io-mcp` converts to zod at bind time.
|
|
837
|
+
|
|
838
|
+
## Routes
|
|
839
|
+
|
|
840
|
+
An Express router stack has the paths but not the intent. Plugins declare each
|
|
841
|
+
mount as they make it, so a facade generator, a healthcheck list or a
|
|
842
|
+
diagnostics view can read one inventory.
|
|
843
|
+
|
|
844
|
+
```js
|
|
845
|
+
registerRoute({
|
|
846
|
+
path: '/api',
|
|
847
|
+
plugin: 'api',
|
|
848
|
+
reachability: 'public', // 'public' | 'token' | 'loopback'
|
|
849
|
+
streaming: false, // true for SSE/WS, which a facade must not buffer
|
|
850
|
+
})
|
|
851
|
+
```
|
|
852
|
+
|
|
853
|
+
`registerRoute` also folds in the origin/URL building and the standard boot
|
|
854
|
+
log. `listRoutes()` returns the inventory; `reachabilityOf` and `routeLocation`
|
|
855
|
+
answer about one route. `isLoopback(ip)` is the check behind
|
|
856
|
+
`reachability: 'loopback'` — note that the server's trust-proxy default is
|
|
857
|
+
`'loopback'`, not Express's `false`, which is what keeps that gate correct
|
|
858
|
+
behind a same-host reverse proxy.
|
|
859
|
+
|
|
860
|
+
## Cycles and the build report
|
|
861
|
+
|
|
862
|
+
`nextCycleId()` reserves the id of the cycle a pending change will be picked up
|
|
863
|
+
by; `whenCycleCompletes(id)` resolves once it finishes, with its report.
|
|
864
|
+
Together they turn "write and guess" into one call that says what the edit
|
|
865
|
+
invalidated. `currentCycle()` and `buildReport()` read the cycle in progress and
|
|
866
|
+
the last completed report.
|
|
867
|
+
|
|
868
|
+
## Logging
|
|
869
|
+
|
|
870
|
+
`addLogTransport({ target, options, level })` adds a pino transport from a
|
|
871
|
+
plugin factory or any later hook. Called before the logger is built, it queues;
|
|
872
|
+
after, it live-rebuilds the multistream. This is what lets Better Stack,
|
|
873
|
+
Datadog, Loki, Axiom or Sentry ship as ordinary sibling plugins with no engine
|
|
874
|
+
change per vendor.
|
|
875
|
+
|
|
876
|
+
Prefer this over `runtime.config.logging.transports` from plugin code — the
|
|
877
|
+
declarative form is for user config.
|
|
878
|
+
|
|
879
|
+
## Junk
|
|
880
|
+
|
|
881
|
+
`registerJunk({ ignore, match })` teaches the engine to skip editor and OS
|
|
882
|
+
debris; `isJunkPath(filePath)` asks.
|
|
883
|
+
|
|
884
|
+
## What is not here
|
|
885
|
+
|
|
886
|
+
Plugin factories — `yaml()`, `json()`, `frontMatter()`, `assets()`,
|
|
887
|
+
`resources()`, `shares()`, `observer()`, `mapper()`, `commands()`,
|
|
888
|
+
`renderHbs()` — are configured rather than called, and live in
|
|
889
|
+
[configuration.md](configuration.md).
|
|
890
|
+
|
|
891
|
+
`mikser-io` exports more than this page covers, deliberately. Five kinds of
|
|
892
|
+
export are engine plumbing:
|
|
893
|
+
|
|
894
|
+
- **Factories the engine calls once** — `createManifest`, `createRefs`,
|
|
895
|
+
`createProvenance`, `createSqliteDatabase`, `createTrack`, `createIndex`,
|
|
896
|
+
`createSubscribers`.
|
|
897
|
+
- **Schema constants** the test suites build against rather than copying —
|
|
898
|
+
`SNAPSHOTS_SCHEMA`, `REFS_SCHEMA`, `FAILURES_SCHEMA`, `PROVENANCE_SCHEMA`.
|
|
899
|
+
- **Report and cycle internals** — `reportRendered`, `reportSkipped`,
|
|
900
|
+
`reportError`, `emitReport`, `resetReport`, `finishCycle`, `inputHashOf`.
|
|
901
|
+
- **Render-time tracking** — `recordReads`, `untrack`, `trackedInfo`,
|
|
902
|
+
`serializeTrack`, `mergeTrack`, `observeConsumed`. These implement
|
|
903
|
+
dependency recording; a plugin observes its RESULTS through
|
|
904
|
+
`runtime.manifest` and `runtime.refs` instead.
|
|
905
|
+
- **Template helper plumbing** — `assetUrlHelper`, `resourceUrlHelper`,
|
|
906
|
+
`hrefUrlHelpers`, `fileHelpers`, `renderPreset`, wired into renderers rather
|
|
907
|
+
than called.
|
|
908
|
+
|
|
909
|
+
They are exported because the engine's own modules and its test suite need them
|
|
910
|
+
across file boundaries, not as an invitation. If you find yourself reaching for
|
|
911
|
+
one from a plugin, that is worth raising — it usually means a capability is
|
|
912
|
+
missing from the surface above.
|
|
913
|
+
|
|
618
914
|
## Database
|
|
619
915
|
|
|
620
916
|
```js
|
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
package/src/changeset.js
ADDED
|
@@ -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
|
@@ -24,6 +24,7 @@ import runtime from './runtime.js'
|
|
|
24
24
|
import { readEntity, findEntities } from './catalog.js'
|
|
25
25
|
import { useCollection, checksum, readEntityContent } 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
|