@statewalker/webrun-files-sync 0.2.2

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/src/execute.ts ADDED
@@ -0,0 +1,154 @@
1
+ /**
2
+ * {@link execute} — applies a {@link SyncPlan}, emitting {@link SyncEvent}s.
3
+ *
4
+ * - Every copy/update is verified at the destination per {@link VerificationMode}.
5
+ * - `move` never deletes a source until its destination is verified (the delete
6
+ * action re-verifies before removing).
7
+ * - Interruptible + resumable: completed action indices are recorded in the
8
+ * injected {@link CheckpointStore} before each `done` event, so a second call
9
+ * with the same store completes the remainder with no duplicate work.
10
+ * - `check` mutates nothing.
11
+ * - After a fully-clean `bisync`, a fresh anchor is written from the merged state.
12
+ */
13
+
14
+ import type { FileStats, FilesApi } from "@statewalker/webrun-files";
15
+ import { buildAnchor } from "./anchor.js";
16
+ import { createStreamingTransfer } from "./transfer.js";
17
+ import type {
18
+ ByteStream,
19
+ SyncAction,
20
+ SyncEvent,
21
+ SyncOp,
22
+ SyncOptions,
23
+ SyncPlan,
24
+ Transfer,
25
+ VerificationMode,
26
+ } from "./types.js";
27
+
28
+ async function verifyCopy(
29
+ mode: VerificationMode,
30
+ source: FilesApi,
31
+ dest: FilesApi,
32
+ path: string,
33
+ hashContent: (b: ByteStream) => Promise<string>,
34
+ ): Promise<boolean> {
35
+ if (mode === "content-hash" || mode === "backend-hash" || mode === "read-after-write") {
36
+ const dstat = await dest.stats(path);
37
+ if (!dstat) return false;
38
+ const [hs, hd] = await Promise.all([
39
+ hashContent(source.read(path)),
40
+ hashContent(dest.read(path)),
41
+ ]);
42
+ return hs === hd;
43
+ }
44
+ // "size" (default) and "mtime" — cross-endpoint mtime is not comparable, so
45
+ // both settle on the byte-count check.
46
+ const [s, d] = await Promise.all([source.stats(path), dest.stats(path)]);
47
+ return !!s && !!d && sizeOf(s) === sizeOf(d);
48
+ }
49
+
50
+ /** Byte length of a file; `undefined` for a directory (two directories compare equal). */
51
+ function sizeOf(stats: FileStats): number | undefined {
52
+ return stats.kind === "file" ? stats.size : undefined;
53
+ }
54
+
55
+ interface Outcome {
56
+ ok: boolean;
57
+ skipped?: boolean;
58
+ reason?: string;
59
+ }
60
+
61
+ async function applyAction(
62
+ action: SyncAction,
63
+ a: FilesApi,
64
+ b: FilesApi,
65
+ op: SyncOp,
66
+ transfer: Transfer,
67
+ verify: VerificationMode,
68
+ opts: SyncOptions,
69
+ ): Promise<Outcome> {
70
+ const pick = (s: "a" | "b") => (s === "a" ? a : b);
71
+ switch (action.kind) {
72
+ case "mkdir": {
73
+ // One-way ops create missing directories on the destination (B).
74
+ await b.mkdir(action.path);
75
+ return { ok: true };
76
+ }
77
+ case "copy":
78
+ case "update": {
79
+ const from = pick(action.from);
80
+ const to = action.from === "a" ? b : a;
81
+ await transfer.run(action, from, to);
82
+ const ok = await verifyCopy(verify, from, to, action.path, opts.hashContent);
83
+ return ok ? { ok: true } : { ok: false, reason: "destination verification failed" };
84
+ }
85
+ case "move": {
86
+ await pick(action.side).move(action.from, action.to);
87
+ return { ok: true };
88
+ }
89
+ case "delete": {
90
+ // A `move` source cleanup: never delete until the destination verifies.
91
+ if (op === "move" && action.side === "a") {
92
+ const verified = await verifyCopy(verify, a, b, action.path, opts.hashContent);
93
+ if (!verified) {
94
+ return { ok: false, skipped: true, reason: "destination not verified; source retained" };
95
+ }
96
+ await a.remove(action.path);
97
+ return { ok: true };
98
+ }
99
+ await pick(action.side).remove(action.path);
100
+ return { ok: true };
101
+ }
102
+ }
103
+ }
104
+
105
+ export async function* execute(
106
+ plan: SyncPlan,
107
+ a: FilesApi,
108
+ b: FilesApi,
109
+ opts: SyncOptions,
110
+ ): AsyncIterable<SyncEvent> {
111
+ const transfer = opts.transfer ?? createStreamingTransfer();
112
+ const verify = opts.verify ?? "size";
113
+
114
+ for (const message of plan.warnings ?? []) yield { type: "warning", message };
115
+ for (const conflict of plan.conflicts) yield { type: "conflict", conflict };
116
+
117
+ if (plan.op === "check") return; // compare-only: transfers nothing, mutates nothing.
118
+
119
+ const done = (await opts.checkpoint?.read()) ?? new Set<number>();
120
+ let anyFailure = false;
121
+
122
+ for (let i = 0; i < plan.actions.length; i++) {
123
+ if (done.has(i)) continue;
124
+ const action = plan.actions[i];
125
+ yield { type: "start", index: i, action };
126
+ try {
127
+ const outcome = await applyAction(action, a, b, plan.op, transfer, verify, opts);
128
+ if (!outcome.ok) {
129
+ anyFailure = true;
130
+ const reason = outcome.reason ?? "failed";
131
+ yield outcome.skipped
132
+ ? { type: "skipped", index: i, action, reason }
133
+ : { type: "failed", index: i, action, reason };
134
+ continue;
135
+ }
136
+ await opts.checkpoint?.add(i);
137
+ yield { type: "done", index: i, action };
138
+ } catch (error) {
139
+ anyFailure = true;
140
+ yield {
141
+ type: "failed",
142
+ index: i,
143
+ action,
144
+ reason: String((error as Error)?.message ?? error),
145
+ };
146
+ }
147
+ }
148
+
149
+ // A clean bisync converges both endpoints; record the merged state as the
150
+ // next base. Skipped if anything failed or any conflict was left unresolved.
151
+ if (plan.op === "bisync" && !anyFailure && plan.conflicts.length === 0 && opts.anchorStore) {
152
+ await opts.anchorStore.write(opts.pairKey ?? "default", await buildAnchor(a, opts.hashContent));
153
+ }
154
+ }
package/src/index.ts ADDED
@@ -0,0 +1,35 @@
1
+ /**
2
+ * @statewalker/webrun-files-sync
3
+ *
4
+ * rclone-like file-synchronisation engine (copy / sync / bisync / move / check)
5
+ * over two `@statewalker/webrun-files` endpoints. Plan-then-execute: {@link plan}
6
+ * is pure and produces a serializable {@link SyncPlan}; {@link execute} applies
7
+ * it, verifying and resuming. Bidirectional sync is a three-way merge over a
8
+ * sync-owned anchor, delegated to `@statewalker/webrun-merge`.
9
+ *
10
+ * Domain-neutral: this package imports ONLY webrun-files + webrun-merge and knows
11
+ * nothing about git or versioning (Axis A / Axis B ban).
12
+ */
13
+
14
+ export { buildAnchor } from "./anchor.js";
15
+ export { isChanged, snapshot } from "./change-detection.js";
16
+ export { execute } from "./execute.js";
17
+ export { plan } from "./plan.js";
18
+ export { createStreamingTransfer } from "./transfer.js";
19
+ export type {
20
+ AnchorStore,
21
+ ByteStream,
22
+ CheckpointStore,
23
+ FilesApi,
24
+ PathFilter,
25
+ Resolution,
26
+ SyncAction,
27
+ SyncAnchor,
28
+ SyncConflict,
29
+ SyncEvent,
30
+ SyncOp,
31
+ SyncOptions,
32
+ SyncPlan,
33
+ Transfer,
34
+ VerificationMode,
35
+ } from "./types.js";
package/src/plan.ts ADDED
@@ -0,0 +1,60 @@
1
+ /**
2
+ * {@link plan} — pure planning for all sync operations. It reads both endpoints
3
+ * (listing, and hashing only when the ladder demands it) but MUTATES NEITHER.
4
+ *
5
+ * copy — new/changed A files → B; extraneous B files KEPT.
6
+ * sync — copy, plus extraneous B files DELETED.
7
+ * move — copy each A file, then (at execute, after the destination is
8
+ * verified) delete the source; source is never lost.
9
+ * check — the copy diff, transferring nothing (execute is a no-op).
10
+ * bisync — three-way merge over the anchor (see bisync.ts).
11
+ */
12
+
13
+ import type { FilesApi } from "@statewalker/webrun-files";
14
+ import { planBisync } from "./bisync.js";
15
+ import { isChanged, snapshot } from "./change-detection.js";
16
+ import type { SyncAction, SyncOp, SyncOptions, SyncPlan } from "./types.js";
17
+
18
+ export async function plan(
19
+ a: FilesApi,
20
+ b: FilesApi,
21
+ op: SyncOp,
22
+ opts: SyncOptions,
23
+ ): Promise<SyncPlan> {
24
+ if (op === "bisync") return planBisync(a, b, opts);
25
+
26
+ const [snapA, snapB] = await Promise.all([snapshot(a, opts.filter), snapshot(b, opts.filter)]);
27
+ const actions: SyncAction[] = [];
28
+
29
+ // A → B: create missing directories, copy new files, update changed files.
30
+ for (const [path, entry] of snapA) {
31
+ const other = snapB.get(path);
32
+ if (entry.kind === "directory") {
33
+ if (!other) actions.push({ kind: "mkdir", path });
34
+ continue;
35
+ }
36
+ if (!other) {
37
+ actions.push({ kind: "copy", path, from: "a" });
38
+ } else if (await isChanged(a, b, path, entry, other, opts)) {
39
+ actions.push({ kind: "update", path, from: "a" });
40
+ }
41
+ }
42
+
43
+ // move: schedule source cleanup after each copy (execute verifies first).
44
+ if (op === "move") {
45
+ for (const [path, entry] of snapA) {
46
+ if (entry.kind === "file") actions.push({ kind: "delete", path, side: "a" });
47
+ }
48
+ }
49
+
50
+ // sync: delete files in B that no longer exist in A.
51
+ if (op === "sync") {
52
+ for (const [path, entry] of snapB) {
53
+ if (entry.kind === "file" && !snapA.has(path)) {
54
+ actions.push({ kind: "delete", path, side: "b" });
55
+ }
56
+ }
57
+ }
58
+
59
+ return { op, actions, conflicts: [] };
60
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * The default {@link Transfer}: a whole-file copy.
3
+ *
4
+ * When source and destination are the SAME backend instance, it uses the
5
+ * backend's native server-side `copy` ("capabilities allow"). Across two
6
+ * distinct backends — the normal sync case, and all MemFilesApi pairs — it falls
7
+ * back to streaming the bytes through `to.write(from.read(...))`.
8
+ *
9
+ * A later phase can plug a chunk-dedup Transfer into this same seam.
10
+ */
11
+
12
+ import type { FilesApi } from "@statewalker/webrun-files";
13
+ import type { SyncAction, Transfer } from "./types.js";
14
+
15
+ export function createStreamingTransfer(): Transfer {
16
+ return {
17
+ async run(action: SyncAction, from: FilesApi, to: FilesApi): Promise<void> {
18
+ if (action.kind !== "copy" && action.kind !== "update") {
19
+ throw new Error(`streaming transfer only handles copy/update, got ${action.kind}`);
20
+ }
21
+ const path = action.path;
22
+ if (from === to) {
23
+ // Same backend: server-side copy avoids round-tripping the bytes.
24
+ await from.copy(path, path);
25
+ return;
26
+ }
27
+ await to.write(path, from.read(path));
28
+ },
29
+ };
30
+ }
package/src/types.ts ADDED
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Public contract for the rclone-like file-synchronisation engine.
3
+ *
4
+ * The engine plans-then-executes over two {@link FilesApi} endpoints. {@link plan}
5
+ * is pure (mutates neither endpoint) and returns a serializable {@link SyncPlan};
6
+ * {@link execute} applies it, emitting {@link SyncEvent}s and resuming from an
7
+ * optional checkpoint after interruption.
8
+ */
9
+
10
+ import type { FilesApi } from "@statewalker/webrun-files";
11
+ // webrun-merge is the authority for the three-way merge result and conflict shapes.
12
+ // It exports the conflict type as `Conflict`; we surface it under the sync name.
13
+ import type { Resolution, Conflict as SyncConflict } from "@statewalker/webrun-merge";
14
+
15
+ export type { FilesApi, Resolution, SyncConflict };
16
+
17
+ /** A stream of bytes, as produced by {@link FilesApi.read}. */
18
+ export type ByteStream = AsyncIterable<Uint8Array>;
19
+
20
+ /**
21
+ * Path predicate: return `true` to INCLUDE a path in the sync, `false` to skip.
22
+ * `webrun-files` does not export a `PathFilter`, so it is defined here.
23
+ */
24
+ export type PathFilter = (path: string) => boolean;
25
+
26
+ export type SyncOp = "copy" | "sync" | "bisync" | "move" | "check";
27
+
28
+ /**
29
+ * How a transferred file is verified at the destination.
30
+ * `size` compares byte counts (cheap default); `content-hash`/`backend-hash`/
31
+ * `read-after-write` re-hash the destination bytes; `mtime` falls back to size
32
+ * (cross-endpoint mtime is not comparable).
33
+ */
34
+ export type VerificationMode =
35
+ | "size"
36
+ | "mtime"
37
+ | "backend-hash"
38
+ | "content-hash"
39
+ | "read-after-write";
40
+
41
+ /**
42
+ * A single typed step of a {@link SyncPlan}. Serializable by construction.
43
+ * `copy`/`update` read `path` from the `from` endpoint and write it to the other;
44
+ * `move` renames within one `side`; `delete` removes `path` from one `side`;
45
+ * `mkdir` creates an (empty) directory on the destination of a one-way op.
46
+ *
47
+ * (The grilled note also listed a `metadata` action; it is intentionally omitted
48
+ * — the same note puts "arbitrary metadata beyond FilesApi size/mtime" out of
49
+ * scope, and MemFilesApi exposes no metadata setter, so it would be dead code.)
50
+ */
51
+ export type SyncAction =
52
+ | { kind: "mkdir"; path: string }
53
+ | { kind: "copy"; path: string; from: "a" | "b" }
54
+ | { kind: "update"; path: string; from: "a" | "b" }
55
+ | { kind: "move"; from: string; to: string; side: "a" | "b" }
56
+ | { kind: "delete"; path: string; side: "a" | "b" };
57
+
58
+ /** A serializable, dry-runnable, auditable plan. */
59
+ export interface SyncPlan {
60
+ op: SyncOp;
61
+ actions: SyncAction[];
62
+ conflicts: SyncConflict[];
63
+ /** Non-fatal notices raised while planning (e.g. first bisync with no anchor). */
64
+ warnings?: string[];
65
+ }
66
+
67
+ /** Events streamed by {@link execute}. */
68
+ export type SyncEvent =
69
+ | { type: "warning"; message: string }
70
+ | { type: "conflict"; conflict: SyncConflict }
71
+ | { type: "start"; index: number; action: SyncAction }
72
+ | { type: "done"; index: number; action: SyncAction }
73
+ | { type: "skipped"; index: number; action: SyncAction; reason: string }
74
+ | { type: "failed"; index: number; action: SyncAction; reason: string };
75
+
76
+ /**
77
+ * Executes a single copy/update. Injected so a later phase can plug a
78
+ * chunk-dedup transfer into the SAME seam; the default streams whole files.
79
+ */
80
+ export interface Transfer {
81
+ run(action: SyncAction, from: FilesApi, to: FilesApi): Promise<void>;
82
+ }
83
+
84
+ /** Content-id + metadata manifest of the last successful sync (bisync base). */
85
+ export interface SyncAnchor {
86
+ entries: Record<string, { contentId: string; size: number; mtime: number }>;
87
+ }
88
+
89
+ /** Persistence for the {@link SyncAnchor} of an endpoint pair (bisync). */
90
+ export interface AnchorStore {
91
+ read(pairKey: string): Promise<SyncAnchor | undefined>;
92
+ write(pairKey: string, anchor: SyncAnchor): Promise<void>;
93
+ }
94
+
95
+ /**
96
+ * Progress checkpoint enabling resume: records the indices of completed actions.
97
+ * `execute` skips completed indices and records each new one before yielding its
98
+ * `done` event, so an interrupted run resumes with no duplicate work.
99
+ */
100
+ export interface CheckpointStore {
101
+ read(): Promise<Set<number>>;
102
+ add(index: number): Promise<void>;
103
+ }
104
+
105
+ export interface SyncOptions {
106
+ /** Content identity — the ladder terminal and the id shared with webrun-merge. */
107
+ hashContent: (input: ByteStream) => Promise<string>;
108
+ filter?: PathFilter;
109
+ /** Copy/update executor; defaults to whole-file streaming copy. */
110
+ transfer?: Transfer;
111
+ /** Destination verification mode; defaults to `size`. */
112
+ verify?: VerificationMode;
113
+ /** SyncAnchor persistence (bisync only). */
114
+ anchorStore?: AnchorStore;
115
+ /** Identifies the endpoint pair for the anchor store (bisync); defaults to "default". */
116
+ pairKey?: string;
117
+ /** Injected conflict-resolution policy (bisync). */
118
+ resolve?: (c: SyncConflict) => Resolution | undefined;
119
+ /** Enable the head/tail sample rung of the change-detection ladder. */
120
+ quickFingerprint?: boolean;
121
+ /** Resume support for {@link execute}. */
122
+ checkpoint?: CheckpointStore;
123
+ }