@egildo/provenance 0.6.2 → 0.7.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/README.md CHANGED
@@ -109,7 +109,9 @@ the edit lands in exactly the bytes of that value, every other byte untouched, a
109
109
  file changing on disk in the meantime ([the design](docs/write-back.md),
110
110
  [the editing spec](specs/editing/spec.md)). Writing is granted, never ambient. Since 0.6.0 it
111
111
  also inserts and deletes entries ([insert-delete](specs/insert-delete/spec.md)), and an edit staged on a file that a
112
- colleague is mid-way through saving waits for it to parse again. Format plug-ins come from outside —
112
+ colleague is mid-way through saving waits for it to parse again. Since 0.7.0 a commit lands every file or
113
+ none ([atomic-commit](specs/atomic-commit/spec.md)), and the staged edits can be saved and restored across a
114
+ reload ([saveable-index](specs/saveable-index/spec.md)). Format plug-ins come from outside —
113
115
  Cascata's YAML and JSON writers are the first.
114
116
 
115
117
  What's next: **stamps** (outputs that carry their source address, so an edit on a rendered page
package/dist/index.d.ts CHANGED
@@ -21,16 +21,31 @@ export interface Session {
21
21
  index(): readonly StagedEdit[];
22
22
  /** Drops the staged edit at `path`, or every staged edit of the source. */
23
23
  unstage(location: string, path?: string): Promise<void>;
24
+ /**
25
+ * The index as plain data (specs/saveable-index/spec.md): per staged source its location, base version and base
26
+ * bytes, and each edit's operation, path, value and status, in staging order. It survives a structured clone.
27
+ * Writing it anywhere is the embedder's.
28
+ */
29
+ save(): SavedIndex;
30
+ /**
31
+ * Stages a saved index again (specs/saveable-index/spec.md): per source, its edits are staged against the saved base
32
+ * bytes through the writer, in saved order, and then rebased onto the version the session holds. A source that
33
+ * already has staged edits, or whose bytes do not hash to the saved base, is refused and the others restored.
34
+ */
35
+ restore(saved: SavedIndex): Promise<RestoreReport>;
24
36
  /** The source's base with every staged edit spliced in, or `undefined` when none is staged. */
25
37
  preview(id: string): Promise<Preview | undefined>;
26
38
  /**
27
- * Writes every source with staged edits, one file at a time and each atomically, rebasing first
28
- * where the disk moved. Not atomic across files: the report says exactly what landed.
39
+ * Writes every source with staged edits, or none: each is checked and rebased first, and one that cannot
40
+ * be written (or an edit dropped on rebase) aborts the commit; a write that fails puts back the files
41
+ * already written. Each file is written atomically by the host. The report says exactly what landed.
29
42
  */
30
43
  commit(): Promise<CommitReport>;
31
44
  close(): void;
32
45
  }
33
46
  export interface CommitReport {
47
+ /** `committed`: every source landed (an empty index is committed). `aborted`: nothing did, bar any `unrestored` source. */
48
+ readonly outcome: "committed" | "aborted";
34
49
  /** One entry per source that had staged edits, in the order they were first staged. */
35
50
  readonly sources: readonly SourceCommit[];
36
51
  }
@@ -38,13 +53,15 @@ export interface SourceCommit {
38
53
  readonly source: string;
39
54
  readonly location: string;
40
55
  /**
41
- * `written`; `refused` (outside every writable root, or the host has no write verb); `failed`
42
- * (the host's reason; the edits are kept); `conflicted` (every edit's target was gone, so
43
- * nothing was written).
56
+ * `written`; `refused` (outside every writable root, the host has no write verb, or the disk version does not
57
+ * open); `failed` (the host's reason; the edits are kept); `conflicted` (at least one edit was dropped on
58
+ * rebase, and `edits` lists the dropped ones); `aborted` (this source was fine, but another one stopped the
59
+ * commit, and `reason` names it; nothing of this source is on disk and its edits stay staged); `unrestored`
60
+ * (this commit wrote it and could not put it back; `reason` says why, its edits are on disk and left the index).
44
61
  */
45
- readonly outcome: "written" | "refused" | "failed" | "conflicted";
62
+ readonly outcome: "written" | "refused" | "failed" | "conflicted" | "aborted" | "unrestored";
46
63
  readonly reason?: string;
47
- /** The version written. */
64
+ /** The version this commit wrote (also for `unrestored`). */
48
65
  readonly version?: string;
49
66
  /** What became of each edit that was written or dropped; empty when refused or failed. */
50
67
  readonly edits: readonly {
@@ -108,6 +125,51 @@ export interface StagedEdit {
108
125
  /** Present while the source's newest version does not open: the writer's words. The edit waits against its base (FIX-001). */
109
126
  readonly waiting?: string;
110
127
  }
128
+ /** What `session.save()` returns: the index as plain data, `Uint8Array` and `Map` the only objects in it. */
129
+ export interface SavedIndex {
130
+ /** Which shape this is; `restore` refuses a number it does not know. */
131
+ readonly format: 1;
132
+ readonly sources: readonly {
133
+ /** The canonical location the session staged under. */
134
+ readonly location: string;
135
+ /** The base version the edits were staged against, and the bytes that hash to it. */
136
+ readonly base: string;
137
+ readonly bytes: Uint8Array;
138
+ readonly edits: readonly SavedEdit[];
139
+ }[];
140
+ }
141
+ /** One saved edit: the semantic half only. Byte ranges are made again by the writer on restore. */
142
+ export interface SavedEdit {
143
+ readonly op: EditOperation["op"];
144
+ readonly path: string;
145
+ readonly value?: EditData;
146
+ readonly status: "staged" | "overrides";
147
+ readonly disk?: Uint8Array;
148
+ }
149
+ export interface RestoreReport {
150
+ /** `refused`: the whole save, for a `format` this version does not know (`reason` says so, and `sources` is empty). */
151
+ readonly outcome: "restored" | "refused";
152
+ readonly reason?: string;
153
+ /** One entry per saved source, in the save's order. */
154
+ readonly sources: readonly SourceRestore[];
155
+ }
156
+ export interface SourceRestore {
157
+ readonly location: string;
158
+ /**
159
+ * `refused`: the source is not in the session's graph, no writer claims it, it already has staged edits, its saved
160
+ * bytes do not hash to its base, or none of its edits could be staged; `reason` says which. Otherwise `restored`:
161
+ * what the rebase onto the session's version then did arrives as the usual `Change` notices.
162
+ */
163
+ readonly outcome: "restored" | "refused";
164
+ readonly reason?: string;
165
+ /** Per saved edit, in saved order; empty when the source was refused before its edits were tried. */
166
+ readonly edits: readonly {
167
+ readonly path: string;
168
+ readonly outcome: "restored" | "refused";
169
+ /** The writer's or the stage's words, as `StageResult` carries them. */
170
+ readonly reason?: string;
171
+ }[];
172
+ }
111
173
  /** A source's base plus its staged edits, with the content hash that names it as a version. */
112
174
  export interface Preview {
113
175
  readonly bytes: Uint8Array;
package/dist/session.js CHANGED
@@ -644,8 +644,8 @@ export async function openSession(options) {
644
644
  end: () => (manifest ??= finish()),
645
645
  };
646
646
  }
647
- async function stageEdit(location, operation) {
648
- const { path } = operation;
647
+ /** Whether the source at `location` can have edits staged: in the graph, readable, and claimed by a writer. */
648
+ async function stageable(location) {
649
649
  const key = await keyFor(location);
650
650
  const node = nodes.get(key);
651
651
  if (!node)
@@ -657,10 +657,22 @@ export async function openSession(options) {
657
657
  const writer = writers.find(w => w.claims(key));
658
658
  if (!writer)
659
659
  return { ok: false, reason: `no writer claims ${key}` };
660
+ return { ok: true, key, node, writer };
661
+ }
662
+ /**
663
+ * Stages one edit. `against` is a restore's (saved-index FR-006): the base to stage a source's first edit against
664
+ * in place of the one the session holds; a source already being staged keeps the base it has.
665
+ */
666
+ async function stageEdit(location, operation, against) {
667
+ const { path } = operation;
668
+ const found = await stageable(location);
669
+ if (!found.ok)
670
+ return found;
671
+ const { key, node, writer } = found;
660
672
  let staging = staged.get(key);
661
673
  // The base the session holds: the bytes of the version it last took in. A disk that has moved
662
674
  // since is the rebase's business, when the host reports it.
663
- const base = staging?.base ?? ("bytes" in node ? node.bytes : undefined);
675
+ const base = staging?.base ?? against?.bytes ?? ("bytes" in node ? node.bytes : undefined);
664
676
  if (!base)
665
677
  return { ok: false, reason: `${key} was not read` };
666
678
  let doc = staging?.doc;
@@ -729,13 +741,53 @@ export async function openSession(options) {
729
741
  if (clash)
730
742
  return { ok: false, reason: `the edit overlaps the staged edit at ${clash.path}` };
731
743
  if (!staging)
732
- staged.set(key, (staging = { writer, version: node.version, base, doc, edits: [] }));
744
+ staged.set(key, (staging = { writer, version: against?.version ?? node.version, base, doc, edits: [] }));
733
745
  const edit = { path, start, end, bytes, status: "staged", ...(effective.op === "remove" ? { op: "remove" } : { op: effective.op, value: effective.value }) };
734
746
  // Replaced, the edit goes last: the list is in the order edits were staged, which a rebase reads.
735
747
  staging.edits = [...staging.edits.filter(e => e.path !== path), edit];
736
748
  staging.preview = undefined;
737
749
  return { ok: true };
738
750
  }
751
+ /** Stages one saved source again against its saved base, then rebases it onto the version the session holds (saved-index FR-006). */
752
+ async function restoreSource(entry) {
753
+ const refused = (reason) => ({ location: entry.location, outcome: "refused", reason, edits: [] });
754
+ const found = await stageable(entry.location);
755
+ if (!found.ok)
756
+ return refused(found.reason);
757
+ const { key, node } = found;
758
+ if (staged.has(key))
759
+ return refused(`${key} already has staged edits`);
760
+ const held = "bytes" in node ? node.bytes : undefined;
761
+ if (!held)
762
+ return refused(`${key} was not read`);
763
+ if ((await sha256(entry.bytes)) !== entry.base)
764
+ return refused("the saved bytes do not hash to the saved base version");
765
+ const against = { version: entry.base, bytes: entry.bytes.slice() };
766
+ const edits = [];
767
+ for (const saved of entry.edits) {
768
+ const operation = saved.op === "remove" ? { op: "remove", path: saved.path } : saved.value === undefined ? undefined : { op: saved.op, path: saved.path, value: saved.value };
769
+ const result = operation === undefined ? { ok: false, reason: `a ${saved.op} needs a value` } : await stageEdit(entry.location, operation, against);
770
+ if (!result.ok) {
771
+ edits.push({ path: saved.path, outcome: "refused", reason: result.reason });
772
+ continue;
773
+ }
774
+ edits.push({ path: saved.path, outcome: "restored" });
775
+ // Its status is part of what was saved (FR-003): staging made it plain `staged`.
776
+ const put = staged.get(key)?.edits.find(e => e.path === saved.path);
777
+ if (put) {
778
+ put.status = saved.status;
779
+ if (saved.disk !== undefined)
780
+ put.disk = saved.disk.slice();
781
+ }
782
+ }
783
+ const staging = staged.get(key);
784
+ if (!staging)
785
+ return { location: entry.location, outcome: "refused", reason: "none of its edits could be restored", edits };
786
+ // The session's version is not the saved base: the usual rebase, which says what it did through the notices.
787
+ if (node.version !== entry.base)
788
+ rebase(key, staging, held, node.version);
789
+ return { location: entry.location, outcome: "restored", edits };
790
+ }
739
791
  /** Whether a commit may write here: under a writable root, judged on whole segments of canonical locations. */
740
792
  async function isWritable(key) {
741
793
  const { separator, resolve } = host.paths;
@@ -747,10 +799,14 @@ export async function openSession(options) {
747
799
  }
748
800
  return false;
749
801
  }
750
- /** Commits one source's staged edits (FR-009). The edits leave the index only when written or dropped. */
751
- async function commitSource(key, staging, changed) {
802
+ /**
803
+ * Prepares one source for a commit (atomic-commit FR-001): the checks, the read, the rebase, and the bytes to write.
804
+ * Touches no disk. A source that cannot be written, or that lost an edit, comes back as the report entry that
805
+ * says so (`cause`); it stops the commit.
806
+ */
807
+ async function prepare(key, staging) {
752
808
  const source = idFor(key);
753
- const nothing = (outcome, reason) => ({ source, location: key, outcome, reason, edits: [] });
809
+ const nothing = (outcome, reason) => ({ cause: { source, location: key, outcome, reason, edits: [] } });
754
810
  if (!(await isWritable(key)))
755
811
  return nothing("refused", `${key} is outside every writable root`);
756
812
  if (host.write === undefined)
@@ -765,23 +821,107 @@ export async function openSession(options) {
765
821
  if (staging.waiting !== undefined && staged.get(key) === staging)
766
822
  return nothing("refused", `the file on disk does not open: ${staging.waiting}`);
767
823
  const dropped = notices.slice(first).filter(n => n.outcome === "conflicted" && n.source === source);
768
- const edits = dropped.map(n => ({ path: n.path, outcome: "conflicted", ...(n.reason === undefined ? {} : { reason: n.reason }), ...(n.detail === undefined ? {} : { detail: n.detail }) }));
769
824
  const remaining = staged.get(key);
770
- if (!remaining)
771
- return { source, location: key, outcome: "conflicted", edits };
825
+ if (dropped.length > 0 || !remaining) {
826
+ const edits = dropped.map(n => ({ path: n.path, outcome: "conflicted", ...(n.reason === undefined ? {} : { reason: n.reason }), ...(n.detail === undefined ? {} : { detail: n.detail }) }));
827
+ return { cause: { source, location: key, outcome: "conflicted", edits } };
828
+ }
772
829
  const bytes = splice(remaining.base, remaining.edits);
773
- const written = await host.write(key, bytes);
774
- if (!written.ok)
775
- return nothing("failed", written.reason);
776
- for (const e of remaining.edits)
777
- edits.push(e.status === "overrides" && e.disk ? { path: e.path, outcome: "overrode", disk: e.disk } : { path: e.path, outcome: "written" });
778
- staged.delete(key);
779
- writtenKeys.push(key);
780
- const fresh = await load(key, false);
781
- if (versionOf(fresh) !== versionOf(nodes.get(key)))
782
- changed.push(key);
783
- nodes.set(key, fresh);
784
- return { source, location: key, outcome: "written", version: await sha256(bytes), edits };
830
+ // Analysed now, before any write (FR-012, ruled): a handler that throws on the new text stops the commit with
831
+ // nothing on disk. The findings are held for this piece of work, so the load after the write hits the cache.
832
+ const handler = handlers.find(h => h.claims(key));
833
+ if (handler) {
834
+ let text;
835
+ try {
836
+ text = decoder.decode(bytes);
837
+ }
838
+ catch {
839
+ text = undefined;
840
+ }
841
+ if (text !== undefined)
842
+ findingsFor(handler, await sha256(bytes), text);
843
+ }
844
+ const edits = remaining.edits.map((e) => (e.status === "overrides" && e.disk ? { path: e.path, outcome: "overrode", disk: e.disk } : { path: e.path, outcome: "written" }));
845
+ return { key, source, bytes, disk: read.bytes, version: await sha256(bytes), edits };
846
+ }
847
+ /**
848
+ * Commits a set of staged sources, all or none (atomic-commit): prepare every one, and only if none is a cause write
849
+ * them in order; a failed write puts the written ones back, newest first. `commit()` passes the whole index;
850
+ * a group would pass its sources.
851
+ */
852
+ async function commitSources(entries, changed) {
853
+ const put = (key, bytes) => (host.write ? host.write(key, bytes) : Promise.resolve({ ok: false, reason: "the host cannot write" }));
854
+ const prepared = [];
855
+ for (const [key, staging] of entries)
856
+ prepared.push(await prepare(key, staging));
857
+ let stopper = prepared.flatMap(p => ("cause" in p ? [p.cause.location] : []))[0];
858
+ const wrote = [];
859
+ const fresh = new Map();
860
+ let failed;
861
+ if (stopper === undefined) {
862
+ for (const p of prepared) {
863
+ if ("cause" in p)
864
+ continue;
865
+ const written = await put(p.key, p.bytes);
866
+ if (!written.ok) {
867
+ failed = { source: p.source, location: p.key, outcome: "failed", reason: written.reason, edits: [] };
868
+ stopper = p.key;
869
+ break;
870
+ }
871
+ writtenKeys.push(p.key);
872
+ wrote.push(p);
873
+ // Analysed before the next write: a handler that throws on the new text stops the commit here, as it always did.
874
+ fresh.set(p.key, await load(p.key, false));
875
+ }
876
+ }
877
+ // Undo (FR-004): the host's own write, after checking the disk still holds what this commit wrote (ruling 6).
878
+ const unrestored = new Map();
879
+ if (failed) {
880
+ for (const p of wrote.reverse()) {
881
+ const was = await host.read(p.key);
882
+ let why;
883
+ if (!was.ok)
884
+ why = `putting ${p.key} back failed: could not read it: ${was.reason}`;
885
+ else if ((await sha256(was.bytes)) !== p.version)
886
+ why = `${p.key} was saved by someone else after this commit wrote it; left as it is`;
887
+ else {
888
+ // ponytail: a colleague can still save between the read above and this write; closing it needs a host verb that writes only if unchanged (parked).
889
+ const back = await put(p.key, p.disk);
890
+ if (!back.ok)
891
+ why = `putting ${p.key} back failed: ${back.reason}`;
892
+ }
893
+ if (why === undefined)
894
+ writtenKeys.splice(writtenKeys.indexOf(p.key), 1);
895
+ else
896
+ unrestored.set(p.key, why);
897
+ }
898
+ }
899
+ const sources = [];
900
+ for (const p of prepared) {
901
+ if ("cause" in p)
902
+ sources.push(p.cause);
903
+ else if (failed && failed.location === p.key)
904
+ sources.push(failed);
905
+ else if (unrestored.has(p.key))
906
+ sources.push({ source: p.source, location: p.key, outcome: "unrestored", reason: unrestored.get(p.key), version: p.version, edits: p.edits });
907
+ else if (stopper !== undefined)
908
+ sources.push({ source: p.source, location: p.key, outcome: "aborted", reason: `the commit was aborted by ${stopper}`, edits: [] });
909
+ else
910
+ sources.push({ source: p.source, location: p.key, outcome: "written", version: p.version, edits: p.edits });
911
+ }
912
+ // What is on disk now leaves the index and the node catches up (a source put back keeps its edits and its node).
913
+ for (const p of prepared) {
914
+ if ("cause" in p || !writtenKeys.includes(p.key))
915
+ continue;
916
+ staged.delete(p.key);
917
+ const node = fresh.get(p.key);
918
+ if (!node)
919
+ continue;
920
+ if (versionOf(node) !== versionOf(nodes.get(p.key)))
921
+ changed.push(p.key);
922
+ nodes.set(p.key, node);
923
+ }
924
+ return { outcome: stopper === undefined ? "committed" : "aborted", sources };
785
925
  }
786
926
  const session = {
787
927
  read,
@@ -822,6 +962,33 @@ export async function openSession(options) {
822
962
  ...(e.disk === undefined ? {} : { disk: e.disk }),
823
963
  ...(staging.waiting === undefined ? {} : { waiting: staging.waiting }),
824
964
  }))),
965
+ save: () => ({
966
+ format: 1,
967
+ sources: [...staged].map(([key, staging]) => ({
968
+ location: key,
969
+ base: staging.version,
970
+ bytes: staging.base.slice(),
971
+ edits: staging.edits.map(e => ({
972
+ op: e.op,
973
+ path: e.path,
974
+ ...(e.value === undefined ? {} : { value: e.value }),
975
+ status: e.status,
976
+ ...(e.disk === undefined ? {} : { disk: e.disk.slice() }),
977
+ })),
978
+ })),
979
+ }),
980
+ restore(saved) {
981
+ let result = { outcome: "refused", reason: "the session is closed", sources: [] };
982
+ if (saved.format !== 1)
983
+ return Promise.resolve({ outcome: "refused", reason: `the saved index has format ${String(saved.format)}, and only format 1 is known`, sources: [] });
984
+ return serialize(async () => {
985
+ const sources = [];
986
+ for (const entry of saved.sources)
987
+ sources.push(await restoreSource(entry));
988
+ result = { outcome: "restored", sources };
989
+ return report([]);
990
+ }).then(() => result);
991
+ },
825
992
  unstage: (location, path) => serialize(async () => {
826
993
  const key = await keyFor(location);
827
994
  const staging = staged.get(key);
@@ -861,13 +1028,10 @@ export async function openSession(options) {
861
1028
  }).then(() => result);
862
1029
  },
863
1030
  commit() {
864
- let result = { sources: [] };
1031
+ let result = { outcome: "committed", sources: [] };
865
1032
  return serialize(async () => {
866
1033
  const changed = [];
867
- const sources = [];
868
- for (const [key, staging] of [...staged])
869
- sources.push(await commitSource(key, staging, changed));
870
- result = { sources };
1034
+ result = await commitSources([...staged], changed);
871
1035
  return report(changed);
872
1036
  }).then(() => result);
873
1037
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@egildo/provenance",
3
- "version": "0.6.2",
3
+ "version": "0.7.0",
4
4
  "description": "Knows, for one rendered document, every source involved, how the sources depend on each other, and where each came from.",
5
5
  "type": "module",
6
6
  "license": "MIT",