@narumitw/pi-sync 0.28.0 → 0.29.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/s3-client.ts CHANGED
@@ -10,7 +10,10 @@ function iso8601Basic(date: Date) {
10
10
  }
11
11
 
12
12
  function snapshotIncludesSessions(snapshot: Snapshot) {
13
- return snapshot.syncSessions === true || snapshot.files.some((file) => file.path.startsWith("sessions/"));
13
+ return (
14
+ snapshot.syncSessions === true ||
15
+ snapshot.files.some((file) => file.path.startsWith("sessions/"))
16
+ );
14
17
  }
15
18
 
16
19
  function isCloudflareR2Endpoint(endpoint: string | undefined) {
@@ -18,7 +21,9 @@ function isCloudflareR2Endpoint(endpoint: string | undefined) {
18
21
  if (!value) return false;
19
22
  try {
20
23
  const hostname = new URL(value).hostname.toLowerCase();
21
- return hostname === "r2.cloudflarestorage.com" || hostname.endsWith(".r2.cloudflarestorage.com");
24
+ return (
25
+ hostname === "r2.cloudflarestorage.com" || hostname.endsWith(".r2.cloudflarestorage.com")
26
+ );
22
27
  } catch {
23
28
  return false;
24
29
  }
@@ -27,11 +32,13 @@ function isCloudflareR2Endpoint(endpoint: string | undefined) {
27
32
  export class S3Client {
28
33
  private config: SyncConfig;
29
34
  private endpoint: URL;
35
+ private signal?: AbortSignal;
30
36
  private omitSessionTokenAfterRejection = false;
31
37
 
32
- constructor(config: SyncConfig) {
38
+ constructor(config: SyncConfig, signal?: AbortSignal) {
33
39
  this.config = config;
34
40
  this.endpoint = new URL(config.endpoint);
41
+ this.signal = signal;
35
42
  }
36
43
 
37
44
  async getJson<T>(key: string): Promise<RemoteObject<T>> {
@@ -48,11 +55,15 @@ export class S3Client {
48
55
  // Retry so the transient blip is absorbed instead of surfacing as a
49
56
  // "pi-sync auto sync skipped" warning on every session start.
50
57
  if (body.length > 0) {
51
- return { value: JSON.parse(body) as T, etag: normalizeEtag(object.headers.get("etag")), missing: false };
58
+ return {
59
+ value: JSON.parse(body) as T,
60
+ etag: normalizeEtag(object.headers.get("etag")),
61
+ missing: false,
62
+ };
52
63
  }
53
64
  lastError = new Error(`S3 GET returned an empty body for ${key}`);
54
65
  if (attempt < maxAttempts) {
55
- await sleep(250 * attempt);
66
+ await sleep(250 * attempt, undefined, { signal: this.signal });
56
67
  }
57
68
  }
58
69
  throw lastError;
@@ -78,7 +89,7 @@ export class S3Client {
78
89
  }
79
90
  lastError = new Error(`S3 GET returned an empty body for ${key}`);
80
91
  if (attempt < maxAttempts) {
81
- await sleep(250 * attempt);
92
+ await sleep(250 * attempt, undefined, { signal: this.signal });
82
93
  }
83
94
  }
84
95
  throw lastError;
@@ -92,7 +103,8 @@ export class S3Client {
92
103
  async putBuffer(key: string, body: Buffer, contentType: string) {
93
104
  const headers: Record<string, string> = { "content-type": contentType };
94
105
  const response = await this.request("PUT", key, body, headers);
95
- if (!response.ok) throw new Error(`S3 PUT failed (${response.status}): ${await response.text()}`);
106
+ if (!response.ok)
107
+ throw new Error(`S3 PUT failed (${response.status}): ${await response.text()}`);
96
108
  }
97
109
 
98
110
  private async request(
@@ -114,7 +126,12 @@ export class S3Client {
114
126
  sessionToken,
115
127
  region: this.config.region,
116
128
  });
117
- return fetch(url, { method, headers, body: body ? new Uint8Array(body) : undefined });
129
+ return fetch(url, {
130
+ method,
131
+ headers,
132
+ body: body ? new Uint8Array(body) : undefined,
133
+ signal: this.signal,
134
+ });
118
135
  };
119
136
  const sessionToken = this.omitSessionTokenAfterRejection ? undefined : this.config.sessionToken;
120
137
  const response = await send(sessionToken);
@@ -125,7 +142,10 @@ export class S3Client {
125
142
  return retry;
126
143
  }
127
144
 
128
- private async shouldRetryWithoutSessionToken(response: Response, sessionToken: string | undefined) {
145
+ private async shouldRetryWithoutSessionToken(
146
+ response: Response,
147
+ sessionToken: string | undefined,
148
+ ) {
129
149
  if (
130
150
  !sessionToken ||
131
151
  !isCloudflareR2Endpoint(this.config.endpoint) ||
@@ -160,7 +180,9 @@ async function signedHeaders(input: {
160
180
  };
161
181
  if (input.sessionToken) headers["x-amz-security-token"] = input.sessionToken;
162
182
  const signedHeaderNames = Object.keys(headers).sort();
163
- const canonicalHeaders = signedHeaderNames.map((name) => `${name}:${headers[name]?.trim()}\n`).join("");
183
+ const canonicalHeaders = signedHeaderNames
184
+ .map((name) => `${name}:${headers[name]?.trim()}\n`)
185
+ .join("");
164
186
  const canonicalRequest = [
165
187
  input.method,
166
188
  input.url.pathname,
@@ -170,7 +192,12 @@ async function signedHeaders(input: {
170
192
  payloadHash,
171
193
  ].join("\n");
172
194
  const scope = `${dateStamp}/${input.region}/s3/aws4_request`;
173
- const stringToSign = ["AWS4-HMAC-SHA256", amzDate, scope, sha256(Buffer.from(canonicalRequest))].join("\n");
195
+ const stringToSign = [
196
+ "AWS4-HMAC-SHA256",
197
+ amzDate,
198
+ scope,
199
+ sha256(Buffer.from(canonicalRequest)),
200
+ ].join("\n");
174
201
  const signingKey = hmac(
175
202
  hmac(hmac(hmac(Buffer.from(`AWS4${input.secretAccessKey}`), dateStamp), input.region), "s3"),
176
203
  "aws4_request",
@@ -206,7 +233,11 @@ export function profilePrefix(config: SyncConfig) {
206
233
  return posixJoin(config.prefix, "profiles", config.profile);
207
234
  }
208
235
 
209
- export function pointerFor(config: SyncConfig, snapshot: Snapshot, checksum: string): LatestPointer {
236
+ export function pointerFor(
237
+ config: SyncConfig,
238
+ snapshot: Snapshot,
239
+ checksum: string,
240
+ ): LatestPointer {
210
241
  return {
211
242
  version: VERSION,
212
243
  profile: config.profile,
@@ -227,5 +258,8 @@ function normalizeEtag(value: string | null) {
227
258
  }
228
259
 
229
260
  function isSecurityTokenInvalidArgument(text: string) {
230
- return text.includes("<Code>InvalidArgument</Code>") && text.includes("<Message>X-Amz-Security-Token</Message>");
261
+ return (
262
+ text.includes("<Code>InvalidArgument</Code>") &&
263
+ text.includes("<Message>X-Amz-Security-Token</Message>")
264
+ );
231
265
  }
@@ -0,0 +1,307 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ import {
5
+ effectiveTargetRemoteIdentity,
6
+ localConfigPath,
7
+ readLocalConfigObject,
8
+ stateDir,
9
+ statePathForConfig,
10
+ writeLocalConfigObject,
11
+ } from "./config.js";
12
+ import { withLock } from "./lock.js";
13
+ import { safeName } from "./paths.js";
14
+ import type { StorageProfileSettings, SyncConfig, SyncTargetSettings } from "./types.js";
15
+
16
+ const LEGACY_FIELDS = new Set([
17
+ "endpoint",
18
+ "bucket",
19
+ "region",
20
+ "accessKeyId",
21
+ "secretAccessKey",
22
+ "sessionToken",
23
+ "profile",
24
+ "prefix",
25
+ "autoSync",
26
+ "syncFiles",
27
+ "syncSessions",
28
+ "extraFiles",
29
+ ]);
30
+
31
+ export interface MigrationResult {
32
+ settings: Record<string, unknown>;
33
+ backupPath: string;
34
+ }
35
+
36
+ export async function migrateLegacySettings(
37
+ targetName: string,
38
+ storageProfileName: string,
39
+ ): Promise<MigrationResult> {
40
+ validateName(targetName, "target");
41
+ validateName(storageProfileName, "storage profile");
42
+ return withLock("settings-migration", async () => {
43
+ const configPath = localConfigPath();
44
+ const originalBytes = await fs.readFile(configPath);
45
+ const current = await readLocalConfigObject();
46
+ if (!current) throw new Error("No legacy pi-sync settings are available to migrate.");
47
+ if (current.version === 2) {
48
+ throw new Error("pi-sync settings already use profiles and targets.");
49
+ }
50
+ const next = legacySettingsAsV2(current, targetName, storageProfileName);
51
+ const backupPath = await writeMigrationBackup(originalBytes);
52
+ await adoptLegacyState(current, next, targetName);
53
+ const latestBytes = await fs.readFile(configPath);
54
+ if (!latestBytes.equals(originalBytes)) {
55
+ throw new Error("pi-sync settings changed during migration; no settings were replaced.");
56
+ }
57
+ await writeLocalConfigObject(next);
58
+ return { settings: next, backupPath };
59
+ });
60
+ }
61
+
62
+ export function legacySettingsAsV2(
63
+ legacy: Record<string, unknown>,
64
+ targetName: string,
65
+ storageProfileName: string,
66
+ ) {
67
+ validateName(targetName, "target");
68
+ validateName(storageProfileName, "storage profile");
69
+ const unknown = Object.fromEntries(
70
+ Object.entries(legacy).filter(([key]) => !LEGACY_FIELDS.has(key) && key !== "version"),
71
+ );
72
+ const profile = compactObject({
73
+ kind: inferKind(legacy.endpoint),
74
+ endpoint: legacy.endpoint,
75
+ region: legacy.region,
76
+ accessKeyId: legacy.accessKeyId,
77
+ secretAccessKey: legacy.secretAccessKey,
78
+ sessionToken: legacy.sessionToken,
79
+ });
80
+ const target = compactObject({
81
+ profile: storageProfileName,
82
+ bucket: legacy.bucket,
83
+ prefix: legacy.prefix,
84
+ namespace: legacy.profile ?? targetName,
85
+ autoSync: legacy.autoSync,
86
+ syncFiles: legacy.syncFiles,
87
+ syncSessions: legacy.syncSessions,
88
+ extraFiles: legacy.extraFiles,
89
+ legacyStateProfile: legacy.profile ?? "default",
90
+ });
91
+ return {
92
+ ...unknown,
93
+ version: 2,
94
+ activeTarget: targetName,
95
+ profiles: { [storageProfileName]: profile },
96
+ targets: { [targetName]: target },
97
+ };
98
+ }
99
+
100
+ export async function saveNewV2Settings(input: {
101
+ targetName: string;
102
+ storageProfileName: string;
103
+ profile: StorageProfileSettings;
104
+ target: SyncTargetSettings;
105
+ }) {
106
+ validateName(input.targetName, "target");
107
+ validateName(input.storageProfileName, "storage profile");
108
+ const current = (await readLocalConfigObject()) ?? {};
109
+ if (Object.keys(current).length > 0)
110
+ throw new Error(`Settings already exist: ${localConfigPath()}`);
111
+ const settings = {
112
+ version: 2,
113
+ activeTarget: input.targetName,
114
+ profiles: { [input.storageProfileName]: { ...input.profile } },
115
+ targets: {
116
+ [input.targetName]: { ...input.target, profile: input.storageProfileName },
117
+ },
118
+ };
119
+ await writeLocalConfigObject(settings);
120
+ return settings;
121
+ }
122
+
123
+ export async function addStorageProfile(name: string, profile: StorageProfileSettings) {
124
+ validateName(name, "storage profile");
125
+ await updateV2Settings((settings) => {
126
+ const profiles = requireObject(settings.profiles, "profiles");
127
+ if (Object.hasOwn(profiles, name)) throw new Error(`Storage profile already exists: ${name}`);
128
+ return { ...settings, profiles: { ...profiles, [name]: { ...profile } } };
129
+ });
130
+ }
131
+
132
+ export async function updateStorageProfile(
133
+ name: string,
134
+ update: (profile: Record<string, unknown>) => Record<string, unknown>,
135
+ ) {
136
+ validateName(name, "storage profile");
137
+ await updateV2Settings((settings) => {
138
+ const profiles = requireObject(settings.profiles, "profiles");
139
+ const profile = requireObject(profiles[name], "storage profile");
140
+ return { ...settings, profiles: { ...profiles, [name]: update(profile) } };
141
+ });
142
+ }
143
+
144
+ export async function addSyncTarget(name: string, target: SyncTargetSettings) {
145
+ validateName(name, "target");
146
+ await updateV2Settings((settings) => {
147
+ const targets = requireObject(settings.targets, "targets");
148
+ const profiles = requireObject(settings.profiles, "profiles");
149
+ if (Object.hasOwn(targets, name)) throw new Error(`Sync target already exists: ${name}`);
150
+ if (!target.profile || !Object.hasOwn(profiles, target.profile)) {
151
+ throw new Error(`Storage profile not found: ${target.profile ?? "missing"}`);
152
+ }
153
+ assertUniqueRemoteIdentity(targets, name, target);
154
+ return { ...settings, targets: { ...targets, [name]: { ...target } } };
155
+ });
156
+ }
157
+
158
+ export async function updateSyncTarget(
159
+ name: string,
160
+ update: (target: Record<string, unknown>) => Record<string, unknown>,
161
+ ) {
162
+ validateName(name, "target");
163
+ await updateV2Settings((settings) => {
164
+ const targets = requireObject(settings.targets, "targets");
165
+ const target = requireObject(targets[name], "target");
166
+ const nextTarget = update(target);
167
+ assertUniqueRemoteIdentity(targets, name, nextTarget);
168
+ return { ...settings, targets: { ...targets, [name]: nextTarget } };
169
+ });
170
+ }
171
+
172
+ export async function removeSyncTarget(name: string) {
173
+ validateName(name, "target");
174
+ await updateV2Settings((settings) => {
175
+ const targets = requireObject(settings.targets, "targets");
176
+ if (!Object.hasOwn(targets, name)) throw new Error(`Sync target not found: ${name}`);
177
+ if (settings.activeTarget === name && Object.keys(targets).length > 1) {
178
+ throw new Error("Switch to another target before removing the current target.");
179
+ }
180
+ const nextTargets = { ...targets };
181
+ delete nextTargets[name];
182
+ return {
183
+ ...settings,
184
+ targets: nextTargets,
185
+ activeTarget: Object.keys(nextTargets)[0],
186
+ };
187
+ });
188
+ }
189
+
190
+ export async function removeStorageProfile(name: string) {
191
+ validateName(name, "storage profile");
192
+ await updateV2Settings((settings) => {
193
+ const profiles = requireObject(settings.profiles, "profiles");
194
+ const targets = requireObject(settings.targets, "targets");
195
+ const referenced = Object.entries(targets).find(
196
+ ([, target]) => requireObject(target, "target").profile === name,
197
+ )?.[0];
198
+ if (referenced) throw new Error(`Storage profile “${name}” is used by target “${referenced}”.`);
199
+ if (!Object.hasOwn(profiles, name)) throw new Error(`Storage profile not found: ${name}`);
200
+ const nextProfiles = { ...profiles };
201
+ delete nextProfiles[name];
202
+ return { ...settings, profiles: nextProfiles };
203
+ });
204
+ }
205
+
206
+ async function updateV2Settings(
207
+ update: (settings: Record<string, unknown>) => Record<string, unknown>,
208
+ ) {
209
+ return withLock("settings", async () => {
210
+ const current = await readLocalConfigObject();
211
+ if (current?.version !== 2) {
212
+ throw new Error("Profiles and targets require version 2 pi-sync settings.");
213
+ }
214
+ const next = update(current);
215
+ await writeLocalConfigObject(next);
216
+ return next;
217
+ });
218
+ }
219
+
220
+ async function writeMigrationBackup(bytes: Buffer) {
221
+ const directory = path.join(stateDir(), "backups", "config");
222
+ await fs.mkdir(directory, { recursive: true });
223
+ const backupPath = path.join(
224
+ directory,
225
+ `pi-sync.local.${new Date().toISOString().replace(/[:.]/gu, "-")}.${randomUUID().slice(0, 8)}.json`,
226
+ );
227
+ await fs.writeFile(backupPath, bytes, { flag: "wx", mode: 0o600 });
228
+ if (process.platform !== "win32") await fs.chmod(backupPath, 0o600);
229
+ return backupPath;
230
+ }
231
+
232
+ async function adoptLegacyState(
233
+ legacy: Record<string, unknown>,
234
+ next: Record<string, unknown>,
235
+ targetName: string,
236
+ ) {
237
+ const legacyProfile = typeof legacy.profile === "string" ? legacy.profile : "default";
238
+ const source = path.join(stateDir(), `${safeName(legacyProfile)}.state.json`);
239
+ let bytes: Buffer;
240
+ try {
241
+ bytes = await fs.readFile(source);
242
+ } catch (error) {
243
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
244
+ throw error;
245
+ }
246
+ const targets = requireObject(next.targets, "targets");
247
+ const target = requireObject(targets[targetName], "target");
248
+ const profile = typeof target.namespace === "string" ? target.namespace : targetName;
249
+ const destination = statePathForConfig({
250
+ settingsVersion: 2,
251
+ target: targetName,
252
+ profile,
253
+ } as SyncConfig);
254
+ await fs.mkdir(path.dirname(destination), { recursive: true });
255
+ try {
256
+ await fs.writeFile(destination, bytes, { flag: "wx", mode: 0o600 });
257
+ } catch (error) {
258
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
259
+ }
260
+ }
261
+
262
+ function assertUniqueRemoteIdentity(
263
+ targets: Record<string, unknown>,
264
+ name: string,
265
+ target: SyncTargetSettings,
266
+ ) {
267
+ const identity = effectiveTargetRemoteIdentity(target as Record<string, unknown>, name);
268
+ for (const [otherName, value] of Object.entries(targets)) {
269
+ if (
270
+ otherName !== name &&
271
+ effectiveTargetRemoteIdentity(requireObject(value, "target"), otherName) === identity
272
+ ) {
273
+ throw new Error(`Target “${name}” duplicates the remote destination of “${otherName}”.`);
274
+ }
275
+ }
276
+ }
277
+
278
+ function compactObject(value: Record<string, unknown>) {
279
+ return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined));
280
+ }
281
+
282
+ function inferKind(endpoint: unknown) {
283
+ return typeof endpoint === "string" && endpoint.includes(".r2.cloudflarestorage.com")
284
+ ? "r2"
285
+ : "s3-compatible";
286
+ }
287
+
288
+ function requireObject(value: unknown, name: string): Record<string, unknown> {
289
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
290
+ throw new Error(`Invalid pi-sync settings: ${name} must be an object.`);
291
+ }
292
+ return value as Record<string, unknown>;
293
+ }
294
+
295
+ function validateName(value: string, label: string) {
296
+ if (
297
+ !value.trim() ||
298
+ value.length > 100 ||
299
+ value === "__proto__" ||
300
+ value === "prototype" ||
301
+ value === "constructor" ||
302
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: Stored identifiers cannot contain controls.
303
+ /[\u0000-\u001f\u007f-\u009f]/u.test(value)
304
+ ) {
305
+ throw new Error(`Invalid ${label} name.`);
306
+ }
307
+ }
@@ -19,6 +19,10 @@ import {
19
19
  snapshotIncludesSessions,
20
20
  snapshotTarget,
21
21
  } from "./snapshot.js";
22
+ import {
23
+ applySnapshotTransaction,
24
+ recoverPendingSnapshotTransactions,
25
+ } from "./snapshot-transaction.js";
22
26
  import type { Snapshot, SnapshotApplyPlan, SnapshotOptions } from "./types.js";
23
27
 
24
28
  function sha256(value: Buffer) {
@@ -36,6 +40,7 @@ export async function applySnapshot(
36
40
  ) {
37
41
  const root = agentDir();
38
42
  const { sessionDir } = options;
43
+ await recoverPendingSnapshotTransactions();
39
44
  const current = await createSnapshot(snapshot.profile, {
40
45
  syncFiles: options.syncFiles,
41
46
  syncSessions: snapshotIncludesSessions(snapshot),
@@ -53,12 +58,7 @@ export async function applySnapshot(
53
58
  snapshot,
54
59
  );
55
60
  await preflightSnapshotMutations(root, plan, sessionDir);
56
- for (const target of plan.deletes) {
57
- await fs.rm(target, { force: true, recursive: true });
58
- }
59
- for (const item of plan.writes) {
60
- await fs.writeFile(item.target, item.content);
61
- }
61
+ await applySnapshotTransaction(plan, { sessionDir });
62
62
  return appliedFileHashMap(snapshot, current, protectedRelativePaths);
63
63
  }
64
64
 
@@ -0,0 +1,201 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { agentDir, stateDir } from "./config.js";
5
+ import { assertWithinRoot, isPathInside } from "./paths.js";
6
+ import { sessionStorageRoot } from "./snapshot.js";
7
+ import type { SnapshotApplyPlan } from "./types.js";
8
+
9
+ const JOURNAL_VERSION = 1;
10
+
11
+ interface TransactionEntry {
12
+ target: string;
13
+ backupName: string;
14
+ kind: "missing" | "file" | "directory" | "symlink";
15
+ linkTarget?: string;
16
+ }
17
+
18
+ interface TransactionJournal {
19
+ version: number;
20
+ root: string;
21
+ sessionRoot?: string;
22
+ entries: TransactionEntry[];
23
+ }
24
+
25
+ export async function applySnapshotTransaction(
26
+ plan: SnapshotApplyPlan,
27
+ options: { sessionDir?: string } = {},
28
+ ) {
29
+ await recoverPendingSnapshotTransactions();
30
+ const transaction = await prepareTransaction(plan, options.sessionDir);
31
+ try {
32
+ for (const target of plan.deletes) {
33
+ await fs.rm(target, { force: true, recursive: true });
34
+ }
35
+ for (const item of plan.writes) {
36
+ await fs.writeFile(item.target, item.content);
37
+ }
38
+ await fs.rm(transaction.directory, { recursive: true, force: true });
39
+ } catch (error) {
40
+ try {
41
+ await restoreTransaction(transaction.directory, transaction.journal);
42
+ } catch (recoveryError) {
43
+ throw new AggregateError(
44
+ [error, recoveryError],
45
+ `Snapshot apply failed and automatic recovery also failed. Transaction retained at ${transaction.directory}.`,
46
+ );
47
+ }
48
+ throw error;
49
+ }
50
+ }
51
+
52
+ export async function recoverPendingSnapshotTransactions() {
53
+ const directory = transactionRoot();
54
+ let entries: import("node:fs").Dirent[];
55
+ try {
56
+ entries = await fs.readdir(directory, { withFileTypes: true });
57
+ } catch (error) {
58
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
59
+ throw error;
60
+ }
61
+ for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
62
+ if (!entry.isDirectory()) continue;
63
+ const transactionDirectory = path.join(directory, entry.name);
64
+ const journalPath = path.join(transactionDirectory, "journal.json");
65
+ let journal: TransactionJournal;
66
+ try {
67
+ journal = JSON.parse(await fs.readFile(journalPath, "utf8")) as TransactionJournal;
68
+ } catch (error) {
69
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
70
+ await fs.rm(transactionDirectory, { recursive: true, force: true });
71
+ continue;
72
+ }
73
+ throw new Error(`Cannot recover malformed pi-sync transaction: ${journalPath}`, {
74
+ cause: error,
75
+ });
76
+ }
77
+ await restoreTransaction(transactionDirectory, journal);
78
+ }
79
+ }
80
+
81
+ async function prepareTransaction(plan: SnapshotApplyPlan, sessionDir?: string) {
82
+ const root = path.resolve(agentDir());
83
+ const sessionRoot = sessionDir ? path.resolve(sessionStorageRoot(root, sessionDir)) : undefined;
84
+ const directory = path.join(transactionRoot(), randomUUID());
85
+ const backupDirectory = path.join(directory, "before");
86
+ await fs.mkdir(backupDirectory, { recursive: true, mode: 0o700 });
87
+ const targets = [...new Set([...plan.deletes, ...plan.writes.map((item) => item.target)])].sort();
88
+ const entries: TransactionEntry[] = [];
89
+ for (let index = 0; index < targets.length; index += 1) {
90
+ const target = targets[index];
91
+ if (!target) continue;
92
+ assertAllowedTarget(root, sessionRoot, target);
93
+ const backupName = `${index}`;
94
+ const backupPath = path.join(backupDirectory, backupName);
95
+ try {
96
+ const stat = await fs.lstat(target);
97
+ if (stat.isSymbolicLink()) {
98
+ entries.push({
99
+ target,
100
+ backupName,
101
+ kind: "symlink",
102
+ linkTarget: await fs.readlink(target),
103
+ });
104
+ } else if (stat.isDirectory()) {
105
+ await fs.cp(target, backupPath, {
106
+ recursive: true,
107
+ dereference: false,
108
+ preserveTimestamps: true,
109
+ });
110
+ entries.push({ target, backupName, kind: "directory" });
111
+ } else if (stat.isFile()) {
112
+ await fs.copyFile(target, backupPath);
113
+ await fs.chmod(backupPath, stat.mode);
114
+ entries.push({ target, backupName, kind: "file" });
115
+ } else {
116
+ throw new Error(`Unsupported existing snapshot target: ${target}`);
117
+ }
118
+ } catch (error) {
119
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
120
+ entries.push({ target, backupName, kind: "missing" });
121
+ }
122
+ }
123
+ const journal: TransactionJournal = {
124
+ version: JOURNAL_VERSION,
125
+ root,
126
+ sessionRoot,
127
+ entries,
128
+ };
129
+ await fs.writeFile(
130
+ path.join(directory, "journal.json"),
131
+ `${JSON.stringify(journal, null, "\t")}\n`,
132
+ {
133
+ mode: 0o600,
134
+ },
135
+ );
136
+ return { directory, journal };
137
+ }
138
+
139
+ async function restoreTransaction(directory: string, journal: TransactionJournal) {
140
+ validateJournal(directory, journal);
141
+ for (const entry of [...journal.entries].sort(
142
+ (left, right) => right.target.length - left.target.length,
143
+ )) {
144
+ await fs.rm(entry.target, { recursive: true, force: true });
145
+ }
146
+ for (const entry of journal.entries) {
147
+ if (entry.kind === "missing") continue;
148
+ await fs.mkdir(path.dirname(entry.target), { recursive: true });
149
+ const backupPath = path.join(directory, "before", entry.backupName);
150
+ assertWithinRoot(directory, backupPath);
151
+ if (entry.kind === "file") await fs.copyFile(backupPath, entry.target);
152
+ else if (entry.kind === "directory") {
153
+ await fs.cp(backupPath, entry.target, {
154
+ recursive: true,
155
+ dereference: false,
156
+ preserveTimestamps: true,
157
+ });
158
+ } else if (entry.kind === "symlink" && entry.linkTarget !== undefined) {
159
+ await fs.symlink(entry.linkTarget, entry.target);
160
+ }
161
+ }
162
+ await fs.rm(directory, { recursive: true, force: true });
163
+ }
164
+
165
+ function validateJournal(directory: string, journal: TransactionJournal) {
166
+ if (journal.version !== JOURNAL_VERSION || !Array.isArray(journal.entries)) {
167
+ throw new Error(`Unsupported pi-sync transaction journal: ${directory}`);
168
+ }
169
+ const expectedRoot = path.resolve(agentDir());
170
+ if (path.resolve(journal.root) !== expectedRoot) {
171
+ throw new Error(`Transaction root no longer matches the Pi agent directory: ${directory}`);
172
+ }
173
+ for (const entry of journal.entries) {
174
+ if (
175
+ !entry ||
176
+ typeof entry.target !== "string" ||
177
+ typeof entry.backupName !== "string" ||
178
+ !/^\d+$/u.test(entry.backupName)
179
+ ) {
180
+ throw new Error(`Invalid pi-sync transaction entry: ${directory}`);
181
+ }
182
+ assertAllowedTarget(journal.root, journal.sessionRoot, entry.target);
183
+ }
184
+ }
185
+
186
+ function assertAllowedTarget(root: string, sessionRoot: string | undefined, target: string) {
187
+ const resolved = path.resolve(target);
188
+ if (isPathInside(root, resolved)) {
189
+ assertWithinRoot(root, resolved);
190
+ return;
191
+ }
192
+ if (sessionRoot && isPathInside(sessionRoot, resolved)) {
193
+ assertWithinRoot(sessionRoot, resolved);
194
+ return;
195
+ }
196
+ throw new Error(`Transaction target is outside configured roots: ${target}`);
197
+ }
198
+
199
+ function transactionRoot() {
200
+ return path.join(stateDir(), "transactions");
201
+ }