@narumitw/pi-sync 0.29.0 → 0.29.1
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/package.json +1 -1
- package/src/config.ts +75 -4
- package/src/lock.ts +22 -5
- package/src/settings-management.ts +9 -14
- package/src/snapshot-transaction.ts +16 -7
- package/src/sync.ts +2 -2
package/package.json
CHANGED
package/src/config.ts
CHANGED
|
@@ -133,7 +133,7 @@ export function deprecatedPiSyncEnvironmentWarnings() {
|
|
|
133
133
|
];
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
-
function resolveLegacyPartialConfig(fileConfig: PartialConfig): PartialConfig {
|
|
136
|
+
export function resolveLegacyPartialConfig(fileConfig: PartialConfig): PartialConfig {
|
|
137
137
|
return {
|
|
138
138
|
...fileConfig,
|
|
139
139
|
target: DEFAULT_PROFILE,
|
|
@@ -156,7 +156,7 @@ function resolveLegacyPartialConfig(fileConfig: PartialConfig): PartialConfig {
|
|
|
156
156
|
};
|
|
157
157
|
}
|
|
158
158
|
|
|
159
|
-
function resolveV2PartialConfig(
|
|
159
|
+
export function resolveV2PartialConfig(
|
|
160
160
|
settings: Record<string, unknown>,
|
|
161
161
|
targetName?: string,
|
|
162
162
|
): PartialConfig {
|
|
@@ -366,8 +366,11 @@ export async function writeState(profile: string, state: SyncState) {
|
|
|
366
366
|
|
|
367
367
|
export async function readStateForConfig(config: SyncConfig): Promise<SyncState> {
|
|
368
368
|
if (config.settingsVersion !== 2) return readState(config.profile);
|
|
369
|
+
const destination = statePathForConfig(config);
|
|
370
|
+
const state = await readJsonIfExists<SyncState>(destination);
|
|
371
|
+
if (state) return state;
|
|
369
372
|
return (
|
|
370
|
-
(await
|
|
373
|
+
(await migrateLegacyV2State(config, destination)) ?? {
|
|
371
374
|
version: VERSION,
|
|
372
375
|
profile: config.profile,
|
|
373
376
|
lastFileHashes: {},
|
|
@@ -382,10 +385,78 @@ export async function writeStateForConfig(config: SyncConfig, state: SyncState)
|
|
|
382
385
|
export function statePathForConfig(config: SyncConfig) {
|
|
383
386
|
if (config.settingsVersion !== 2) return statePath(config.profile);
|
|
384
387
|
const target = config.target ?? DEFAULT_PROFILE;
|
|
385
|
-
const
|
|
388
|
+
const identity = JSON.stringify([
|
|
389
|
+
target,
|
|
390
|
+
normalizeEndpointIdentity(config.endpoint),
|
|
391
|
+
normalizeRemoteKeySegment(config.bucket),
|
|
392
|
+
normalizeRemoteKeySegment(config.prefix),
|
|
393
|
+
normalizeRemoteKeySegment(config.profile),
|
|
394
|
+
]);
|
|
395
|
+
const hash = createHash("sha256").update(identity).digest("hex").slice(0, 10);
|
|
386
396
|
return path.join(stateDir(), "targets", `${safeName(target)}-${hash}.state.json`);
|
|
387
397
|
}
|
|
388
398
|
|
|
399
|
+
export function statePathForPartialConfig(partial: PartialConfig) {
|
|
400
|
+
const profile = normalizeOptionalString(partial.profile) ?? DEFAULT_PROFILE;
|
|
401
|
+
if (partial.settingsVersion !== 2) return statePath(profile);
|
|
402
|
+
return statePathForConfig({
|
|
403
|
+
settingsVersion: 2,
|
|
404
|
+
target: partial.target ?? DEFAULT_PROFILE,
|
|
405
|
+
endpoint: normalizeConfiguredString(partial.endpoint) ?? "",
|
|
406
|
+
bucket: normalizeConfiguredString(partial.bucket) ?? "",
|
|
407
|
+
prefix: trimSlashes(normalizeOptionalString(partial.prefix) ?? DEFAULT_PREFIX),
|
|
408
|
+
profile,
|
|
409
|
+
} as SyncConfig);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
async function migrateLegacyV2State(config: SyncConfig, destination: string) {
|
|
413
|
+
const target = config.target ?? DEFAULT_PROFILE;
|
|
414
|
+
const legacyHash = createHash("sha256").update(target).digest("hex").slice(0, 10);
|
|
415
|
+
const legacyPath = path.join(
|
|
416
|
+
stateDir(),
|
|
417
|
+
"targets",
|
|
418
|
+
`${safeName(target)}-${legacyHash}.state.json`,
|
|
419
|
+
);
|
|
420
|
+
const claimPath = `${legacyPath}.migrating-to-${path.basename(destination)}`;
|
|
421
|
+
if (!(await pathExists(claimPath))) {
|
|
422
|
+
try {
|
|
423
|
+
await fs.rename(legacyPath, claimPath);
|
|
424
|
+
} catch (error) {
|
|
425
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
if (!(await pathExists(claimPath))) return readJsonIfExists<SyncState>(destination);
|
|
429
|
+
|
|
430
|
+
await fs.mkdir(path.dirname(destination), { recursive: true });
|
|
431
|
+
try {
|
|
432
|
+
await fs.link(claimPath, destination);
|
|
433
|
+
} catch (error) {
|
|
434
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
435
|
+
}
|
|
436
|
+
const migrated = await readJsonIfExists<SyncState>(destination);
|
|
437
|
+
if (migrated) await fs.rm(claimPath, { force: true });
|
|
438
|
+
return migrated;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
async function pathExists(filePath: string) {
|
|
442
|
+
try {
|
|
443
|
+
await fs.lstat(filePath);
|
|
444
|
+
return true;
|
|
445
|
+
} catch (error) {
|
|
446
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
|
|
447
|
+
throw error;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function normalizeEndpointIdentity(endpoint: string) {
|
|
452
|
+
const normalized = endpoint.trim();
|
|
453
|
+
try {
|
|
454
|
+
return new URL(normalized).toString();
|
|
455
|
+
} catch {
|
|
456
|
+
return normalized;
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
389
460
|
export function agentDir() {
|
|
390
461
|
return getAgentDir();
|
|
391
462
|
}
|
package/src/lock.ts
CHANGED
|
@@ -59,7 +59,11 @@ export type LockInspection =
|
|
|
59
59
|
| { status: "unreadable" }
|
|
60
60
|
| { status: "valid"; lock: LockFile };
|
|
61
61
|
|
|
62
|
-
export async function withLock<T>(
|
|
62
|
+
export async function withLock<T>(
|
|
63
|
+
command: string,
|
|
64
|
+
fn: () => Promise<T>,
|
|
65
|
+
options: { reclaimStale?: boolean } = {},
|
|
66
|
+
): Promise<T> {
|
|
63
67
|
await ensureStateDir();
|
|
64
68
|
const lock: LockFile = {
|
|
65
69
|
id: randomUUID(),
|
|
@@ -79,11 +83,24 @@ export async function withLock<T>(command: string, fn: () => Promise<T>): Promis
|
|
|
79
83
|
throw await describeHeldLock();
|
|
80
84
|
}
|
|
81
85
|
|
|
82
|
-
|
|
86
|
+
let inspection = await inspectLock();
|
|
83
87
|
if (inspection.status === "valid" && isStaleLock(inspection.lock)) {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
88
|
+
if (!options.reclaimStale) {
|
|
89
|
+
throw new Error(
|
|
90
|
+
`pi-sync lock is stale (pid ${inspection.lock.pid}). Run /sync unlock --stale, then retry.`,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
guard.throwIfCompromised();
|
|
94
|
+
const rechecked = await inspectLock();
|
|
95
|
+
if (
|
|
96
|
+
rechecked.status !== "valid" ||
|
|
97
|
+
rechecked.lock.id !== inspection.lock.id ||
|
|
98
|
+
!isStaleLock(rechecked.lock)
|
|
99
|
+
) {
|
|
100
|
+
throw new Error("pi-sync lock changed while preparing transaction recovery; retry.");
|
|
101
|
+
}
|
|
102
|
+
await fs.rm(lockPath(), { force: true });
|
|
103
|
+
inspection = { status: "missing" };
|
|
87
104
|
}
|
|
88
105
|
if (inspection.status === "valid") {
|
|
89
106
|
throw new Error(
|
|
@@ -5,13 +5,14 @@ import {
|
|
|
5
5
|
effectiveTargetRemoteIdentity,
|
|
6
6
|
localConfigPath,
|
|
7
7
|
readLocalConfigObject,
|
|
8
|
+
resolveLegacyPartialConfig,
|
|
9
|
+
resolveV2PartialConfig,
|
|
8
10
|
stateDir,
|
|
9
|
-
|
|
11
|
+
statePathForPartialConfig,
|
|
10
12
|
writeLocalConfigObject,
|
|
11
13
|
} from "./config.js";
|
|
12
14
|
import { withLock } from "./lock.js";
|
|
13
|
-
import {
|
|
14
|
-
import type { StorageProfileSettings, SyncConfig, SyncTargetSettings } from "./types.js";
|
|
15
|
+
import type { PartialConfig, StorageProfileSettings, SyncTargetSettings } from "./types.js";
|
|
15
16
|
|
|
16
17
|
const LEGACY_FIELDS = new Set([
|
|
17
18
|
"endpoint",
|
|
@@ -182,7 +183,8 @@ export async function removeSyncTarget(name: string) {
|
|
|
182
183
|
return {
|
|
183
184
|
...settings,
|
|
184
185
|
targets: nextTargets,
|
|
185
|
-
activeTarget:
|
|
186
|
+
activeTarget:
|
|
187
|
+
settings.activeTarget === name ? Object.keys(nextTargets)[0] : settings.activeTarget,
|
|
186
188
|
};
|
|
187
189
|
});
|
|
188
190
|
}
|
|
@@ -234,8 +236,8 @@ async function adoptLegacyState(
|
|
|
234
236
|
next: Record<string, unknown>,
|
|
235
237
|
targetName: string,
|
|
236
238
|
) {
|
|
237
|
-
const
|
|
238
|
-
const source =
|
|
239
|
+
const legacyConfig = resolveLegacyPartialConfig(legacy as PartialConfig);
|
|
240
|
+
const source = statePathForPartialConfig(legacyConfig);
|
|
239
241
|
let bytes: Buffer;
|
|
240
242
|
try {
|
|
241
243
|
bytes = await fs.readFile(source);
|
|
@@ -243,14 +245,7 @@ async function adoptLegacyState(
|
|
|
243
245
|
if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
|
|
244
246
|
throw error;
|
|
245
247
|
}
|
|
246
|
-
const
|
|
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);
|
|
248
|
+
const destination = statePathForPartialConfig(resolveV2PartialConfig(next, targetName));
|
|
254
249
|
await fs.mkdir(path.dirname(destination), { recursive: true });
|
|
255
250
|
try {
|
|
256
251
|
await fs.writeFile(destination, bytes, { flag: "wx", mode: 0o600 });
|
|
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
|
|
|
2
2
|
import fs from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { agentDir, stateDir } from "./config.js";
|
|
5
|
+
import { withLock } from "./lock.js";
|
|
5
6
|
import { assertWithinRoot, isPathInside } from "./paths.js";
|
|
6
7
|
import { sessionStorageRoot } from "./snapshot.js";
|
|
7
8
|
import type { SnapshotApplyPlan } from "./types.js";
|
|
@@ -49,15 +50,14 @@ export async function applySnapshotTransaction(
|
|
|
49
50
|
}
|
|
50
51
|
}
|
|
51
52
|
|
|
53
|
+
export async function recoverSnapshotTransactionsOnStartup() {
|
|
54
|
+
if (!(await pendingTransactionEntries()).some((entry) => entry.isDirectory())) return;
|
|
55
|
+
await withLock("recovery", recoverPendingSnapshotTransactions, { reclaimStale: true });
|
|
56
|
+
}
|
|
57
|
+
|
|
52
58
|
export async function recoverPendingSnapshotTransactions() {
|
|
53
59
|
const directory = transactionRoot();
|
|
54
|
-
|
|
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
|
-
}
|
|
60
|
+
const entries = await pendingTransactionEntries();
|
|
61
61
|
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
|
|
62
62
|
if (!entry.isDirectory()) continue;
|
|
63
63
|
const transactionDirectory = path.join(directory, entry.name);
|
|
@@ -78,6 +78,15 @@ export async function recoverPendingSnapshotTransactions() {
|
|
|
78
78
|
}
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
+
async function pendingTransactionEntries() {
|
|
82
|
+
try {
|
|
83
|
+
return await fs.readdir(transactionRoot(), { withFileTypes: true });
|
|
84
|
+
} catch (error) {
|
|
85
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
|
|
86
|
+
throw error;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
81
90
|
async function prepareTransaction(plan: SnapshotApplyPlan, sessionDir?: string) {
|
|
82
91
|
const root = path.resolve(agentDir());
|
|
83
92
|
const sessionRoot = sessionDir ? path.resolve(sessionStorageRoot(root, sessionDir)) : undefined;
|
package/src/sync.ts
CHANGED
|
@@ -58,7 +58,7 @@ import {
|
|
|
58
58
|
snapshotWithoutSessions,
|
|
59
59
|
} from "./snapshot.js";
|
|
60
60
|
import { applySnapshot } from "./snapshot-apply.js";
|
|
61
|
-
import {
|
|
61
|
+
import { recoverSnapshotTransactionsOnStartup } from "./snapshot-transaction.js";
|
|
62
62
|
import {
|
|
63
63
|
countPreservedRemoteFiles,
|
|
64
64
|
errorMessage,
|
|
@@ -133,7 +133,7 @@ export default function sync(pi: ExtensionAPI) {
|
|
|
133
133
|
pi.on("session_start", async (_event, ctx) => {
|
|
134
134
|
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
135
135
|
try {
|
|
136
|
-
await
|
|
136
|
+
await recoverSnapshotTransactionsOnStartup();
|
|
137
137
|
} catch (error) {
|
|
138
138
|
ctx.ui.notify(`pi-sync recovery required: ${errorMessage(error)}`, "error");
|
|
139
139
|
return;
|