@narumitw/pi-sync 0.31.0 → 0.34.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/src/snapshot.ts CHANGED
@@ -10,20 +10,25 @@ export { isDeniedPath } from "./paths.js";
10
10
 
11
11
  import {
12
12
  canonicalSnapshotPathForConfig,
13
- DEFAULT_SYNC_FILES,
14
- extraFilePathsByLower,
13
+ customIncludePathsByLower,
14
+ DEFAULT_SYNC_INCLUDE,
15
+ includeFromSelectionConfig,
15
16
  isConfiguredSnapshotPath,
16
17
  isPreservableUnmanagedSnapshotPath,
17
18
  normalizeExtraFiles,
18
19
  normalizeSyncFiles,
20
+ type SyncSelectionConfig,
21
+ syncIncludeSelection,
19
22
  } from "./sync-policy.js";
20
- import type { Snapshot, SnapshotFile, SnapshotOptions, SyncConfig } from "./types.js";
23
+ import type { Snapshot, SnapshotFile, SnapshotOptions } from "./types.js";
21
24
 
22
25
  export { canonicalSnapshotPathForConfig, isConfiguredSnapshotPath } from "./sync-policy.js";
23
26
 
24
27
  const VERSION = 1;
25
- const TOP_LEVEL_FILES: readonly string[] = DEFAULT_SYNC_FILES.filter((name) => name.includes("."));
26
- const TOP_LEVEL_DIRS = new Set<string>(DEFAULT_SYNC_FILES.filter((name) => !name.includes(".")));
28
+ const TOP_LEVEL_FILES: readonly string[] = DEFAULT_SYNC_INCLUDE.filter((name) =>
29
+ name.includes("."),
30
+ );
31
+ const TOP_LEVEL_DIRS = new Set<string>(DEFAULT_SYNC_INCLUDE.filter((name) => !name.includes(".")));
27
32
  const SECRET_PATTERNS = [
28
33
  /AWS_SECRET_ACCESS_KEY\s*[=:]\s*['"]?[A-Za-z0-9/+]{35,}/i,
29
34
  /(ANTHROPIC|OPENAI|GEMINI|GOOGLE|FIRECRAWL|GITHUB|CLOUDFLARE|R2|S3)_[A-Z0-9_]*(KEY|TOKEN|SECRET)\s*[=:]\s*['"]?[^\s'"]{12,}/i,
@@ -78,16 +83,24 @@ function snapshotId() {
78
83
  return `${new Date().toISOString().replace(/[:.]/g, "-")}-${randomUUID().slice(0, 8)}`;
79
84
  }
80
85
 
86
+ export function regenerateSnapshotIdentity(snapshot: Snapshot): Snapshot {
87
+ return {
88
+ ...snapshot,
89
+ id: snapshotId(),
90
+ createdAt: new Date().toISOString(),
91
+ machine: os.hostname(),
92
+ };
93
+ }
94
+
81
95
  export async function createSnapshot(
82
96
  profile: string,
83
97
  options: SnapshotOptions = {},
84
98
  ): Promise<Snapshot> {
85
- const syncSessions = Boolean(options.syncSessions);
99
+ const include = effectiveInclude(options);
100
+ const syncSessions = include.includes("sessions");
86
101
  const files = await collectFiles(agentDir(), {
87
- syncFiles: options.syncFiles,
88
- syncSessions,
102
+ include,
89
103
  sessionDir: options.sessionDir ?? (await configuredSessionDir()),
90
- extraFiles: options.extraFiles,
91
104
  });
92
105
  return {
93
106
  version: VERSION,
@@ -100,13 +113,23 @@ export async function createSnapshot(
100
113
  };
101
114
  }
102
115
 
116
+ function effectiveInclude(options: SnapshotOptions) {
117
+ if (options.include) return options.include;
118
+ return [
119
+ ...normalizeSyncFiles(options.syncFiles),
120
+ ...normalizeExtraFiles(options.extraFiles),
121
+ ...(options.syncSessions ? ["sessions"] : []),
122
+ ];
123
+ }
124
+
103
125
  export async function collectFiles(
104
126
  root: string,
105
127
  options: SnapshotOptions = {},
106
128
  ): Promise<SnapshotFile[]> {
107
129
  const results: SnapshotFile[] = [];
108
130
  const entries = await fs.readdir(root, { withFileTypes: true });
109
- const selectedFiles = new Set<string>(normalizeSyncFiles(options.syncFiles));
131
+ const selection = syncIncludeSelection(effectiveInclude(options));
132
+ const selectedFiles = new Set<string>(selection.builtIns);
110
133
  for (const entry of entries) {
111
134
  if (entry.isDirectory() && TOP_LEVEL_DIRS.has(entry.name) && selectedFiles.has(entry.name)) {
112
135
  await collectDirectory(results, root, entry.name);
@@ -117,11 +140,10 @@ export async function collectFiles(
117
140
  const entry = selectTopLevelFileEntry(entries, fileName);
118
141
  if (entry) await addFile(results, root, entry.name, fileName);
119
142
  }
120
- for (const extraFileName of normalizeExtraFiles(options.extraFiles)) {
121
- const entry = selectTopLevelFileEntry(entries, extraFileName);
122
- if (entry) await addFile(results, root, entry.name, extraFileName);
143
+ for (const relativePath of selection.custom) {
144
+ await collectIncludedPath(results, root, relativePath);
123
145
  }
124
- if (options.syncSessions) {
146
+ if (selection.sessions) {
125
147
  try {
126
148
  await collectDirectory(results, sessionStorageRoot(root, options.sessionDir), "", {
127
149
  sessionsOnly: true,
@@ -134,6 +156,22 @@ export async function collectFiles(
134
156
  return results.sort((left, right) => left.path.localeCompare(right.path));
135
157
  }
136
158
 
159
+ async function collectIncludedPath(results: SnapshotFile[], root: string, relativePath: string) {
160
+ const absolutePath = safeJoin(root, relativePath);
161
+ try {
162
+ const stat = await fs.lstat(absolutePath);
163
+ if (stat.isFile()) await addFile(results, root, relativePath);
164
+ else if (stat.isDirectory()) await collectDirectory(results, root, relativePath);
165
+ } catch (error) {
166
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
167
+ if (!relativePath.includes("/")) {
168
+ const entries = await fs.readdir(root, { withFileTypes: true });
169
+ const entry = selectTopLevelFileEntry(entries, relativePath);
170
+ if (entry) await addFile(results, root, entry.name, relativePath);
171
+ }
172
+ }
173
+ }
174
+
137
175
  async function collectDirectory(
138
176
  results: SnapshotFile[],
139
177
  root: string,
@@ -216,15 +254,15 @@ export function snapshotIncludesSessions(snapshot: Snapshot) {
216
254
 
217
255
  export function filterSnapshotForConfigPolicy(
218
256
  snapshot: Snapshot,
219
- config: Pick<SyncConfig, "syncFiles" | "syncSessions" | "extraFiles">,
257
+ config: SyncSelectionConfig,
220
258
  options: { regenerateId?: boolean } = {},
221
259
  ) {
222
- const extraFilePaths = extraFilePathsByLower(config.extraFiles);
223
- const extraFiles = new Set(extraFilePaths.keys());
260
+ const include = includeFromSelectionConfig(config);
261
+ const includePaths = customIncludePathsByLower(include);
224
262
  const filtered = {
225
263
  ...snapshot,
226
- syncSessions: config.syncSessions ? snapshot.syncSessions : false,
227
- files: canonicalizeSnapshotFilesForConfig(snapshot.files, config, extraFiles, extraFilePaths),
264
+ syncSessions: include.includes("sessions") ? snapshot.syncSessions : false,
265
+ files: canonicalizeSnapshotFilesForConfig(snapshot.files, config, includePaths),
228
266
  };
229
267
  if (!options.regenerateId || snapshotsMatch(snapshot, filtered)) return filtered;
230
268
  return {
@@ -237,9 +275,8 @@ export function filterSnapshotForConfigPolicy(
237
275
 
238
276
  function canonicalizeSnapshotFilesForConfig(
239
277
  files: SnapshotFile[],
240
- config: Pick<SyncConfig, "syncFiles" | "syncSessions">,
241
- extraFiles: Set<string>,
242
- extraFilePaths: Map<string, string>,
278
+ config: SyncSelectionConfig,
279
+ includePaths: Map<string, string>,
243
280
  ) {
244
281
  const configuredFiles: SnapshotFile[] = [];
245
282
  const extraCandidates = new Map<
@@ -248,17 +285,14 @@ function canonicalizeSnapshotFilesForConfig(
248
285
  >();
249
286
  for (const file of files) {
250
287
  const normalized = toPosix(file.path);
251
- if (
252
- !isSafeSnapshotPath(file.path) ||
253
- !isConfiguredSnapshotPath(normalized, config, extraFiles)
254
- ) {
288
+ if (!isSafeSnapshotPath(file.path) || !isConfiguredSnapshotPath(normalized, config)) {
255
289
  continue;
256
290
  }
257
291
  if (normalized.includes("/")) {
258
292
  configuredFiles.push(normalized === file.path ? file : { ...file, path: normalized });
259
293
  continue;
260
294
  }
261
- const topLevelPath = canonicalSnapshotPathForConfig(normalized, extraFilePaths);
295
+ const topLevelPath = canonicalSnapshotPathForConfig(normalized, includePaths);
262
296
  const candidate = {
263
297
  exact: normalized === topLevelPath,
264
298
  file: { ...file, path: topLevelPath },
@@ -315,12 +349,9 @@ export function scanSnapshot(snapshot: Snapshot) {
315
349
  export function mergeRemotePreservedFiles(
316
350
  local: Snapshot,
317
351
  remote: Snapshot,
318
- config: Pick<SyncConfig, "syncFiles" | "syncSessions" | "extraFiles">,
352
+ config: SyncSelectionConfig,
319
353
  ) {
320
354
  const localPathNames = new Set(local.files.map((file) => file.path.toLowerCase()));
321
- const configuredExtraFiles = new Set(
322
- normalizeExtraFiles(config.extraFiles).map((file) => file.toLowerCase()),
323
- );
324
355
  const preservedPathNames = new Set<string>();
325
356
  const preserved = remote.files.filter((file) => {
326
357
  const normalized = toPosix(file.path);
@@ -329,7 +360,7 @@ export function mergeRemotePreservedFiles(
329
360
  localPathNames.has(lower) ||
330
361
  preservedPathNames.has(lower) ||
331
362
  !isSafeSnapshotPath(file.path) ||
332
- isConfiguredSnapshotPath(normalized, config, configuredExtraFiles) ||
363
+ isConfiguredSnapshotPath(normalized, config) ||
333
364
  !isPreservableUnmanagedSnapshotPath(normalized)
334
365
  ) {
335
366
  return false;
@@ -0,0 +1,308 @@
1
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+ import { isCloudflareR2Endpoint, readLocalConfigObject } from "./config.js";
3
+ import { showAddGitStorageProfile, showEditGitStorageProfile } from "./git-ui.js";
4
+ import { errorMessage, ownRecord, requiredInput, safeTerminalText } from "./manager-helpers.js";
5
+ import {
6
+ applyS3CredentialUpdate,
7
+ chooseS3Credentials,
8
+ chooseS3CredentialUpdate,
9
+ } from "./s3-credentials-ui.js";
10
+ import {
11
+ addStorageConnection,
12
+ removeStorageConnection,
13
+ updateStorageConnection,
14
+ } from "./settings-management.js";
15
+ import { showAddWebDavStorageProfile, showEditWebDavStorageProfile } from "./webdav-ui.js";
16
+
17
+ const BACK = "Back";
18
+
19
+ export async function showStorageConnections(ctx: ExtensionCommandContext, signal?: AbortSignal) {
20
+ while (!signal?.aborted) {
21
+ const raw = await readLocalConfigObject();
22
+ if (signal?.aborted) return;
23
+ if (raw?.version !== 3) {
24
+ ctx.ui.notify("Create version 3 settings before managing storage connections.", "info");
25
+ return;
26
+ }
27
+ const profiles = ownRecord(raw.storageConnections) ?? {};
28
+ const labels = new Map(
29
+ Object.keys(profiles)
30
+ .sort((left, right) => left.localeCompare(right))
31
+ .map((name) => [safeTerminalText(name), name]),
32
+ );
33
+ const selected = await ctx.ui.select(
34
+ "Storage connections",
35
+ ["Add storage connection", ...labels.keys(), BACK],
36
+ { signal },
37
+ );
38
+ if (signal?.aborted || !selected || selected === BACK) return;
39
+ if (selected === "Add storage connection") {
40
+ try {
41
+ await showAddStorageConnection(ctx, signal);
42
+ } catch (error) {
43
+ if (signal?.aborted) return;
44
+ ctx.ui.notify(
45
+ `Storage connection was not added: ${safeTerminalText(errorMessage(error))} Retry from Add storage connection.`,
46
+ "error",
47
+ );
48
+ }
49
+ continue;
50
+ }
51
+ const name = labels.get(selected);
52
+ if (name) await showStorageConnectionDetail(ctx, name, signal);
53
+ }
54
+ }
55
+
56
+ async function showStorageConnectionDetail(
57
+ ctx: ExtensionCommandContext,
58
+ name: string,
59
+ signal?: AbortSignal,
60
+ ) {
61
+ while (!signal?.aborted) {
62
+ const raw = await readLocalConfigObject();
63
+ if (signal?.aborted) return;
64
+ const profiles = ownRecord(raw?.storageConnections) ?? {};
65
+ const profile = ownRecord(profiles[name]);
66
+ if (!profile) {
67
+ ctx.ui.notify(`Storage connection “${safeTerminalText(name)}” no longer exists.`, "warning");
68
+ return;
69
+ }
70
+ const usedBy = referencingSetups(raw, name);
71
+ const selected = await ctx.ui.select(
72
+ [
73
+ `Storage connection “${safeTerminalText(name)}”`,
74
+ "",
75
+ `Type: ${connectionType(profile)}`,
76
+ `Endpoint: ${connectionEndpoint(profile)}`,
77
+ `Credentials: ${credentialSource(profile)}`,
78
+ `Used by: ${usedBy.length > 0 ? usedBy.map(safeTerminalText).join(", ") : "No sync setups"}`,
79
+ ...(usedBy.length > 0
80
+ ? ["Remove unavailable: edit or remove the listed sync setups first."]
81
+ : []),
82
+ ].join("\n"),
83
+ [
84
+ "Edit storage connection…",
85
+ ...(usedBy.length === 0 ? ["Remove storage connection…"] : []),
86
+ BACK,
87
+ ],
88
+ { signal },
89
+ );
90
+ if (signal?.aborted || !selected || selected === BACK) return;
91
+ try {
92
+ if (selected === "Remove storage connection…") {
93
+ const confirmed = await ctx.ui.confirm(
94
+ "Remove storage connection?",
95
+ `Remove local storage connection “${safeTerminalText(name)}”? Remote data and history are not deleted.`,
96
+ { signal },
97
+ );
98
+ if (!confirmed || signal?.aborted) continue;
99
+ await removeStorageConnection(name, signal);
100
+ ctx.ui.notify(`Removed storage connection “${safeTerminalText(name)}”.`, "info");
101
+ return;
102
+ }
103
+ await editStorageConnection(ctx, name, profile, usedBy, signal);
104
+ } catch (error) {
105
+ if (signal?.aborted) return;
106
+ ctx.ui.notify(
107
+ `Storage connection “${safeTerminalText(name)}” was not changed: ${safeTerminalText(errorMessage(error))} Reopen it and retry.`,
108
+ "error",
109
+ );
110
+ }
111
+ }
112
+ }
113
+
114
+ async function editStorageConnection(
115
+ ctx: ExtensionCommandContext,
116
+ name: string,
117
+ profile: Record<string, unknown>,
118
+ usedBy: string[],
119
+ signal?: AbortSignal,
120
+ ) {
121
+ if (ctx.mode !== "tui") {
122
+ ctx.ui.notify(
123
+ "Editing storage connections requires TUI mode for safe credential handling. Edit the private version 3 settings file instead.",
124
+ "warning",
125
+ );
126
+ return;
127
+ }
128
+ if (profile.type === "webdav") {
129
+ await showEditWebDavStorageProfile(
130
+ ctx,
131
+ name,
132
+ { ...profile, kind: "webdav", ...(ownRecord(profile.credentials) ?? {}) },
133
+ signal,
134
+ usedBy,
135
+ );
136
+ return;
137
+ }
138
+ if (profile.type === "git") {
139
+ await showEditGitStorageProfile(ctx, name, { ...profile, kind: "git" }, signal, usedBy);
140
+ return;
141
+ }
142
+ const endpoint = await requiredInput(
143
+ ctx,
144
+ "Endpoint",
145
+ String(profile.endpoint ?? "https://s3.example.com"),
146
+ signal,
147
+ );
148
+ if (!endpoint || signal?.aborted) return;
149
+ const region = await requiredInput(ctx, "Region", String(profile.region ?? "auto"), signal);
150
+ if (!region || signal?.aborted) return;
151
+ const storedCredentials = ownRecord(profile.credentials) ?? {};
152
+ const credentials = await chooseS3CredentialUpdate(
153
+ ctx,
154
+ { ...profile, ...storedCredentials },
155
+ signal,
156
+ );
157
+ if (!credentials || signal?.aborted) return;
158
+ const save = await ctx.ui.select(
159
+ [
160
+ "Review storage connection",
161
+ "",
162
+ `Storage connection: ${safeTerminalText(name)}`,
163
+ `Endpoint: ${safeTerminalText(String(profile.endpoint ?? "missing"))} → ${safeTerminalText(endpoint)}`,
164
+ `Region: ${safeTerminalText(String(profile.region ?? "auto"))} → ${safeTerminalText(region)}`,
165
+ `Credentials: ${safeTerminalText(credentials.summary)}`,
166
+ `Affected sync setups: ${usedBy.length > 0 ? usedBy.map(safeTerminalText).join(", ") : "None"}`,
167
+ "Saving changes future storage access for every affected setup; it does not move remote data.",
168
+ ].join("\n"),
169
+ ["Save storage connection", "Cancel"],
170
+ { signal },
171
+ );
172
+ if (save !== "Save storage connection" || signal?.aborted) return;
173
+ await updateStorageConnection(
174
+ name,
175
+ (current) => {
176
+ if (current.type !== "s3") {
177
+ throw new Error("Storage connection type changed; reopen it.");
178
+ }
179
+ return {
180
+ ...current,
181
+ endpoint,
182
+ region,
183
+ credentials: applyS3CredentialUpdate(
184
+ current.credentials,
185
+ credentials,
186
+ ) as typeof current.credentials,
187
+ };
188
+ },
189
+ usedBy,
190
+ signal,
191
+ );
192
+ if (signal?.aborted) return;
193
+ ctx.ui.notify(`Saved storage connection “${safeTerminalText(name)}”.`, "info");
194
+ }
195
+
196
+ export async function showAddStorageConnection(ctx: ExtensionCommandContext, signal?: AbortSignal) {
197
+ if (ctx.mode !== "tui") {
198
+ ctx.ui.notify(
199
+ "Adding storage connections requires TUI mode for safe credential handling. Edit the private version 3 settings file instead.",
200
+ "warning",
201
+ );
202
+ return false;
203
+ }
204
+ const preset = await ctx.ui.select(
205
+ "Storage type",
206
+ ["Cloudflare R2", "Other S3-compatible storage", "WebDAV", "Git", "Cancel"],
207
+ { signal },
208
+ );
209
+ if (signal?.aborted || !preset || preset === "Cancel") return false;
210
+ if (preset === "WebDAV") return showAddWebDavStorageProfile(ctx, signal);
211
+ if (preset === "Git") return showAddGitStorageProfile(ctx, signal);
212
+ const name = await requiredInput(
213
+ ctx,
214
+ "Name this storage connection",
215
+ preset === "Cloudflare R2" ? "r2" : "s3",
216
+ signal,
217
+ );
218
+ if (!name || signal?.aborted) return false;
219
+ const endpoint = await requiredInput(
220
+ ctx,
221
+ "Endpoint",
222
+ preset === "Cloudflare R2"
223
+ ? "https://<account-id>.r2.cloudflarestorage.com"
224
+ : "https://s3.example.com",
225
+ signal,
226
+ );
227
+ if (!endpoint || signal?.aborted) return false;
228
+ const region =
229
+ preset === "Cloudflare R2" ? "auto" : await requiredInput(ctx, "Region", "us-east-1", signal);
230
+ if (!region || signal?.aborted) return false;
231
+ const credentials = await chooseS3Credentials(ctx, signal);
232
+ if (!credentials || signal?.aborted) return false;
233
+ const save = await ctx.ui.select(
234
+ [
235
+ "Review storage connection",
236
+ "",
237
+ `Name: ${safeTerminalText(name)}`,
238
+ `Type: ${preset}`,
239
+ `Endpoint: ${safeTerminalText(endpoint)}`,
240
+ `Region: ${safeTerminalText(region)}`,
241
+ `Credentials: ${safeTerminalText(credentials.summary)}`,
242
+ "Adding a connection does not contact remote storage or start syncing.",
243
+ ].join("\n"),
244
+ ["Add storage connection", "Cancel"],
245
+ { signal },
246
+ );
247
+ if (save !== "Add storage connection" || signal?.aborted) return false;
248
+ await addStorageConnection(
249
+ name,
250
+ {
251
+ type: "s3",
252
+ endpoint,
253
+ region,
254
+ credentials: {
255
+ accessKeyId: credentials.profileFields.accessKeyId ?? "",
256
+ secretAccessKey: credentials.profileFields.secretAccessKey ?? "",
257
+ },
258
+ },
259
+ signal,
260
+ );
261
+ if (signal?.aborted) return true;
262
+ ctx.ui.notify(`Added storage connection “${safeTerminalText(name)}”.`, "info");
263
+ return true;
264
+ }
265
+
266
+ function referencingSetups(raw: Record<string, unknown> | undefined, connection: string) {
267
+ return Object.entries(ownRecord(raw?.syncSetups) ?? {})
268
+ .filter(([, value]) => ownRecord(ownRecord(value)?.storage)?.connection === connection)
269
+ .map(([name]) => name)
270
+ .sort((left, right) => left.localeCompare(right));
271
+ }
272
+
273
+ function connectionType(profile: Record<string, unknown>) {
274
+ if (profile.type === "git") return "Git";
275
+ if (profile.type === "webdav") return "WebDAV";
276
+ if (
277
+ profile.type === "s3" &&
278
+ typeof profile.endpoint === "string" &&
279
+ isCloudflareR2Endpoint(profile.endpoint)
280
+ ) {
281
+ return "Cloudflare R2";
282
+ }
283
+ return "S3-compatible";
284
+ }
285
+
286
+ function connectionEndpoint(profile: Record<string, unknown>) {
287
+ const value =
288
+ profile.type === "git"
289
+ ? profile.remote
290
+ : profile.type === "webdav"
291
+ ? profile.url
292
+ : profile.endpoint;
293
+ if (typeof value !== "string" || value.length === 0) return "Missing";
294
+ if (profile.type === "git") return safeTerminalText(value);
295
+ try {
296
+ return safeTerminalText(new URL(value).host);
297
+ } catch {
298
+ return "Invalid";
299
+ }
300
+ }
301
+
302
+ function credentialSource(profile: Record<string, unknown>) {
303
+ if (profile.type === "git") return "Git credential helper or SSH configuration";
304
+ const credentials = ownRecord(profile.credentials);
305
+ if (profile.type === "webdav") return credentials?.password ? "Settings file" : "Missing";
306
+ if (credentials?.accessKeyId && credentials.secretAccessKey) return "Settings file";
307
+ return "Missing";
308
+ }
@@ -0,0 +1,97 @@
1
+ import type { Snapshot } from "./types.js";
2
+
3
+ export type PublicationCapability =
4
+ | "read-check-write-verify"
5
+ | "conditional-required"
6
+ | "atomic-conditional"
7
+ | "lease-protected";
8
+
9
+ export interface RemoteHead {
10
+ /** Backend-owned reference used only to retrieve this immutable snapshot. */
11
+ snapshotRef: string;
12
+ /** Backend-neutral snapshot content identity retained in local sync state. */
13
+ snapshotId: string;
14
+ revision: string;
15
+ createdAt: string;
16
+ machine: string;
17
+ syncSessions: boolean;
18
+ }
19
+
20
+ export interface RemoteHistoryEntry {
21
+ snapshotRef: string;
22
+ snapshotId: string;
23
+ createdAt: string;
24
+ machine: string;
25
+ syncSessions: boolean;
26
+ }
27
+
28
+ export type ExpectedRemoteHead = { kind: "missing" } | { kind: "revision"; revision: string };
29
+
30
+ export interface PublishSnapshotOptions {
31
+ signal?: AbortSignal;
32
+ /** Called at the backend's active-head commit boundary. */
33
+ onCommit?: () => void;
34
+ }
35
+
36
+ export interface PublishSnapshotResult {
37
+ head: RemoteHead;
38
+ warnings: string[];
39
+ }
40
+
41
+ export interface BackendDiagnostic {
42
+ key: string;
43
+ level: "info" | "warning" | "error";
44
+ message: string;
45
+ }
46
+
47
+ export interface SyncBackend {
48
+ readonly identity: string;
49
+ readonly destination: string;
50
+ readonly capability: PublicationCapability;
51
+ /** Compare opaque revisions produced by this backend identity. */
52
+ sameRevision(left: string, right: string): boolean;
53
+ readHead(signal?: AbortSignal): Promise<RemoteHead | undefined>;
54
+ readSnapshot(reference: string, signal?: AbortSignal): Promise<Snapshot>;
55
+ publishSnapshot(
56
+ snapshot: Snapshot,
57
+ expected: ExpectedRemoteHead,
58
+ options?: PublishSnapshotOptions,
59
+ ): Promise<PublishSnapshotResult>;
60
+ listHistory(signal?: AbortSignal): Promise<RemoteHistoryEntry[]>;
61
+ diagnose(signal?: AbortSignal): Promise<BackendDiagnostic[]>;
62
+ }
63
+
64
+ export class SyncBackendConflictError extends Error {
65
+ readonly code = "SYNC_BACKEND_CONFLICT";
66
+ readonly phase: "before-commit" | "after-commit";
67
+ readonly currentHead?: RemoteHead;
68
+ readonly candidateMayHaveBeenActive: boolean;
69
+
70
+ constructor(
71
+ message: string,
72
+ options: ErrorOptions & {
73
+ phase?: "before-commit" | "after-commit";
74
+ currentHead?: RemoteHead;
75
+ candidateMayHaveBeenActive?: boolean;
76
+ } = {},
77
+ ) {
78
+ super(message, options);
79
+ this.name = "SyncBackendConflictError";
80
+ this.phase = options.phase ?? "before-commit";
81
+ this.currentHead = options.currentHead;
82
+ this.candidateMayHaveBeenActive = options.candidateMayHaveBeenActive ?? false;
83
+ }
84
+ }
85
+
86
+ export class SyncBackendPublicationOutcomeUnknownError extends Error {
87
+ readonly code = "SYNC_BACKEND_PUBLICATION_OUTCOME_UNKNOWN";
88
+
89
+ constructor(message: string, options?: ErrorOptions) {
90
+ super(message, options);
91
+ this.name = "SyncBackendPublicationOutcomeUnknownError";
92
+ }
93
+ }
94
+
95
+ export function expectedRemoteHead(head: RemoteHead | undefined): ExpectedRemoteHead {
96
+ return head ? { kind: "revision", revision: head.revision } : { kind: "missing" };
97
+ }