@cortexkit/antigravity-auth-core 3.0.16 → 3.0.17
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/dist/account-migration.d.ts +17 -0
- package/dist/account-migration.d.ts.map +1 -1
- package/dist/account-migration.js +765 -16
- package/dist/account-migration.js.map +1 -1
- package/dist/common-auth-embedded/fs/refresh-file-lock.d.ts +13 -0
- package/dist/common-auth-embedded/fs/refresh-file-lock.js +159 -7
- package/dist/common-auth-embedded/pi-slot/lock.js +26 -1
- package/dist/common-auth-embedded/source-output.json +15 -15
- package/package.json +2 -2
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { createHash, randomUUID } from 'node:crypto';
|
|
2
2
|
import { constants } from 'node:fs';
|
|
3
|
-
import { link, lstat, mkdir, open, realpath, unlink } from 'node:fs/promises';
|
|
3
|
+
import { link, lstat, mkdir, open, realpath, rename, unlink, } from 'node:fs/promises';
|
|
4
4
|
import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
|
|
5
5
|
import { isDeepStrictEqual } from 'node:util';
|
|
6
6
|
import { AccountCodecError, decodeProviderMetadata, decodeProviderState, decodeQuotaState, decodeRoutingSettings, encodeProviderMetadata, encodeProviderState, encodeQuotaState, encodeRoutingSettings, isValidProviderState, providerStateCredentialBound, QUOTA_CODEC, } from "./account-repository-codecs.js";
|
|
7
|
-
import { ACCOUNT_STORE_PROVIDER, legacyAccountsLock, MANAGEMENT_LOCK_NAME, MANAGEMENT_SETTINGS_KEY, ROUTING_SETTINGS_KEY, } from "./account-repository-types.js";
|
|
7
|
+
import { ACCOUNT_STORE_PROVIDER, legacyAccountsLock, MANAGEMENT_LOCK_NAME, MANAGEMENT_SETTINGS_KEY, ROUTING_SETTINGS_KEY, refreshProviderLock, } from "./account-repository-types.js";
|
|
8
|
+
import { createPrivateBytes, createPrivateDirectoryExclusive, verifySafeDirectory, windowsPrivateFiles, } from './common-auth-embedded/fs/private-files.js';
|
|
8
9
|
/** Derives store paths from the caller's absolute legacy file path, not HOME or host preferences. */
|
|
9
10
|
export function resolveAccountStorePaths(legacyPath, generationId) {
|
|
10
11
|
if (generationId !== undefined)
|
|
@@ -138,7 +139,9 @@ export async function resolveAccountStoreAnchor(legacyPath, options = {}) {
|
|
|
138
139
|
}
|
|
139
140
|
const record = await readPointerRecord(legacyPath);
|
|
140
141
|
if (record?.schemaVersion !== 2)
|
|
141
|
-
return {
|
|
142
|
+
return {
|
|
143
|
+
legacyPath: (await restoredRelocationAnchor(legacyPath, record)) ?? legacyPath,
|
|
144
|
+
};
|
|
142
145
|
const named = record.relocatedTo.legacyPath;
|
|
143
146
|
if (options.relocatedAnchor !== undefined &&
|
|
144
147
|
!sameFilesystemPath(options.relocatedAnchor, named))
|
|
@@ -749,8 +752,12 @@ export function decodeAccountMigrationJournal(value) {
|
|
|
749
752
|
const cancelled = raw.cancelled === true;
|
|
750
753
|
if (cancelled &&
|
|
751
754
|
(status === 'active' ||
|
|
752
|
-
|
|
753
|
-
|
|
755
|
+
(sourceKind === 'generation'
|
|
756
|
+
? operation !== 'migrate' ||
|
|
757
|
+
relocation?.sourceForwarded === true ||
|
|
758
|
+
phase === 'activate'
|
|
759
|
+
: operation !== 'rollback' ||
|
|
760
|
+
(status === 'inactive' && phase !== 'activate')) ||
|
|
754
761
|
rollback !== undefined ||
|
|
755
762
|
retiredSha256 !== undefined))
|
|
756
763
|
throw new AccountMigrationError('invalid pre-retirement cancellation');
|
|
@@ -820,7 +827,14 @@ function decodeRelocation(raw, sourceKind, legacyPath) {
|
|
|
820
827
|
return undefined;
|
|
821
828
|
if (sourceKind !== 'generation')
|
|
822
829
|
throw new AccountMigrationError('only a moved store records relocation evidence');
|
|
823
|
-
const r = exact(raw.relocation, [
|
|
830
|
+
const r = exact(raw.relocation, [
|
|
831
|
+
'source',
|
|
832
|
+
'rows',
|
|
833
|
+
'sourceTombstoned',
|
|
834
|
+
'sourceForwarded',
|
|
835
|
+
'reversed',
|
|
836
|
+
'reverseSha256',
|
|
837
|
+
], 'relocation');
|
|
824
838
|
const s = exact(r.source, [
|
|
825
839
|
'legacyPath',
|
|
826
840
|
'storeDir',
|
|
@@ -847,6 +861,11 @@ function decodeRelocation(raw, sourceKind, legacyPath) {
|
|
|
847
861
|
if (typeof r.sourceTombstoned !== 'boolean' ||
|
|
848
862
|
typeof r.sourceForwarded !== 'boolean')
|
|
849
863
|
throw new AccountMigrationError('relocation progress flags are invalid');
|
|
864
|
+
if (Object.hasOwn(r, 'reversed') &&
|
|
865
|
+
(r.reversed !== true || raw.status !== 'active' || !r.sourceForwarded))
|
|
866
|
+
throw new AccountMigrationError('reverse lacks an active forwarded generation');
|
|
867
|
+
if (Object.hasOwn(r, 'reverseSha256') !== (r.reversed === true))
|
|
868
|
+
throw new AccountMigrationError('reverse snapshot association is incomplete');
|
|
850
869
|
return {
|
|
851
870
|
source: {
|
|
852
871
|
legacyPath: source.legacyPath,
|
|
@@ -861,6 +880,12 @@ function decodeRelocation(raw, sourceKind, legacyPath) {
|
|
|
861
880
|
rows,
|
|
862
881
|
sourceTombstoned: r.sourceTombstoned,
|
|
863
882
|
sourceForwarded: r.sourceForwarded,
|
|
883
|
+
...(r.reversed === true
|
|
884
|
+
? {
|
|
885
|
+
reversed: true,
|
|
886
|
+
reverseSha256: text(r.reverseSha256, SHA256, 'reverse snapshot hash'),
|
|
887
|
+
}
|
|
888
|
+
: {}),
|
|
864
889
|
};
|
|
865
890
|
}
|
|
866
891
|
function pendingLockFailure(modules, error) {
|
|
@@ -908,7 +933,10 @@ function privateMode(mode, expected) {
|
|
|
908
933
|
}
|
|
909
934
|
async function privateDirectory(path) {
|
|
910
935
|
try {
|
|
911
|
-
|
|
936
|
+
if (windowsPrivateFiles)
|
|
937
|
+
await createPrivateDirectoryExclusive(path);
|
|
938
|
+
else
|
|
939
|
+
await mkdir(path, { mode: 0o700 });
|
|
912
940
|
}
|
|
913
941
|
catch (error) {
|
|
914
942
|
if (errno(error) !== 'EEXIST')
|
|
@@ -918,6 +946,8 @@ async function privateDirectory(path) {
|
|
|
918
946
|
if (!stat.isDirectory() || stat.isSymbolicLink())
|
|
919
947
|
throw new AccountMigrationError('successor directory is not a regular directory');
|
|
920
948
|
privateMode(stat.mode, 0o700);
|
|
949
|
+
if (windowsPrivateFiles)
|
|
950
|
+
await verifySafeDirectory(path);
|
|
921
951
|
if (!sameFilesystemPath(await realpath(path), path))
|
|
922
952
|
throw new AccountMigrationError('successor directory traverses a symbolic link');
|
|
923
953
|
}
|
|
@@ -1000,7 +1030,14 @@ async function syncDirectory(path, boundary) {
|
|
|
1000
1030
|
async function exclusiveBytes(path, bytes, expectedHash, boundary, pointerStage = false) {
|
|
1001
1031
|
let handle;
|
|
1002
1032
|
try {
|
|
1003
|
-
|
|
1033
|
+
if (windowsPrivateFiles) {
|
|
1034
|
+
// The backend accepts Uint8Array, although the embedded declaration only
|
|
1035
|
+
// lists strings. Preserve native JSON bytes without a UTF-8 round trip.
|
|
1036
|
+
const createBytes = createPrivateBytes;
|
|
1037
|
+
await createBytes(path, bytes);
|
|
1038
|
+
}
|
|
1039
|
+
else
|
|
1040
|
+
handle = await open(path, 'wx', 0o600);
|
|
1004
1041
|
}
|
|
1005
1042
|
catch (error) {
|
|
1006
1043
|
if (errno(error) !== 'EEXIST')
|
|
@@ -1020,8 +1057,9 @@ async function exclusiveBytes(path, bytes, expectedHash, boundary, pointerStage
|
|
|
1020
1057
|
const reopened = await secureRead(path);
|
|
1021
1058
|
if (digest(reopened) !== expectedHash || !reopened.equals(bytes))
|
|
1022
1059
|
throw new AccountMigrationError('exclusive backup or recovery copy differs');
|
|
1023
|
-
// A new pointer stage was synced through its write handle.
|
|
1024
|
-
//
|
|
1060
|
+
// A new POSIX pointer stage was synced through its write handle. A stage the
|
|
1061
|
+
// Windows private writer created has no handle here, so it is synced like
|
|
1062
|
+
// reused stages and every backup, bootstrap, retired and export copy.
|
|
1025
1063
|
if (handle === undefined || !pointerStage)
|
|
1026
1064
|
await syncFile(path);
|
|
1027
1065
|
await syncDirectory(dirname(path), boundary);
|
|
@@ -1343,7 +1381,9 @@ async function assertStateOwnership(paths, journal, complete) {
|
|
|
1343
1381
|
throw new AccountMigrationError('successor state contains unrelated or missing credentials');
|
|
1344
1382
|
}
|
|
1345
1383
|
async function management(store, journal, clear = false) {
|
|
1346
|
-
const targets = journal.
|
|
1384
|
+
const targets = journal.relocation?.rows ??
|
|
1385
|
+
journal.rollback?.rows ??
|
|
1386
|
+
journal.mapping.map((row) => row.id);
|
|
1347
1387
|
await store.updateSettings((settings) => {
|
|
1348
1388
|
const prior = settings[MANAGEMENT_SETTINGS_KEY];
|
|
1349
1389
|
if (prior !== undefined) {
|
|
@@ -1474,6 +1514,9 @@ export async function readAccountStoreBinding(legacyPath, modules, now, expected
|
|
|
1474
1514
|
// A forward at this anchor leads one step to the anchor the store moved
|
|
1475
1515
|
// to; the bound paths are then the moved generation's own.
|
|
1476
1516
|
const record = await readPointerRecord(legacyPath);
|
|
1517
|
+
const restored = await restoredRelocationAnchor(legacyPath, record);
|
|
1518
|
+
if (restored !== undefined)
|
|
1519
|
+
return await bindPublishedGeneration(restored, modules, now, expectedGeneration);
|
|
1477
1520
|
if (record?.schemaVersion === 2) {
|
|
1478
1521
|
if (await exists(legacyPath))
|
|
1479
1522
|
throw new AccountMigrationError(`a pool file reappeared at ${legacyPath} after the account store moved to ${record.relocatedTo.legacyPath}`);
|
|
@@ -1509,7 +1552,7 @@ async function assertSourceForwarded(journal, forwardedFrom) {
|
|
|
1509
1552
|
if (record === undefined)
|
|
1510
1553
|
throw new AccountMigrationError(`the forwarding pointer ${pointer} to the moved account store at ${journal.storeDir} is missing; restore it`);
|
|
1511
1554
|
if (record.schemaVersion === 1)
|
|
1512
|
-
throw new AccountMigrationError(`the account store in ${dirname(source.legacyPath)} was reactivated: ${pointer} publishes generation ${record.id} while the moved store at ${journal.storeDir} is active; restore the forwarding pointer
|
|
1555
|
+
throw new AccountMigrationError(`the account store in ${dirname(source.legacyPath)} was reactivated: ${pointer} publishes generation ${record.id} while the moved store at ${journal.storeDir} is active; restore the forwarding pointer`);
|
|
1513
1556
|
if (record.id !== source.journalId ||
|
|
1514
1557
|
record.relocatedTo.legacyPath !== journal.legacyPath ||
|
|
1515
1558
|
record.relocatedTo.id !== journal.id)
|
|
@@ -1557,7 +1600,7 @@ async function bindPublishedGeneration(legacyPath, modules, now, expectedGenerat
|
|
|
1557
1600
|
// Reaching it here means this anchor's pointer was set back from the
|
|
1558
1601
|
// forward to the old generation.
|
|
1559
1602
|
if (record.kind === 'migration' && record.id !== journal.id)
|
|
1560
|
-
throw new AccountMigrationError(`the account store at ${paths.storeDir} was moved to generation ${String(record.id)}, but ${accountStorePointerPath(legacyPath)} publishes the old store again; restore the forwarding pointer
|
|
1603
|
+
throw new AccountMigrationError(`the account store at ${paths.storeDir} was moved to generation ${String(record.id)}, but ${accountStorePointerPath(legacyPath)} publishes the old store again; restore the forwarding pointer`);
|
|
1561
1604
|
if (record.kind === 'migration')
|
|
1562
1605
|
return { status: 'pending', phase: 'activate', operation: 'migrate' };
|
|
1563
1606
|
if (record.kind !== 'clear' && record.kind !== 'replace-pool')
|
|
@@ -2004,6 +2047,695 @@ export function createAccountMigrationFactory(modules) {
|
|
|
2004
2047
|
}
|
|
2005
2048
|
};
|
|
2006
2049
|
}
|
|
2050
|
+
function relocationOf(journal) {
|
|
2051
|
+
if (!journal.relocation)
|
|
2052
|
+
throw new AccountMigrationError('relocation evidence missing');
|
|
2053
|
+
return journal.relocation;
|
|
2054
|
+
}
|
|
2055
|
+
function withGenerationLease(modules, path, name, body) {
|
|
2056
|
+
return modules.fs.withLock(path, { name, ttlMs: 10_000, timeoutMs: 2_000, renew: true }, body);
|
|
2057
|
+
}
|
|
2058
|
+
function withSaveLocks(modules, paths, body) {
|
|
2059
|
+
return withGenerationLease(modules, paths.configPath, 'save', async (configLease) => withGenerationLease(modules, paths.statePath, 'save', async (stateLease) => {
|
|
2060
|
+
const result = await body([configLease, stateLease]);
|
|
2061
|
+
await configLease.assertOwned();
|
|
2062
|
+
await stateLease.assertOwned();
|
|
2063
|
+
return result;
|
|
2064
|
+
}));
|
|
2065
|
+
}
|
|
2066
|
+
async function relocationSnapshot(paths, journal) {
|
|
2067
|
+
await verifyCopies(paths, journal, false);
|
|
2068
|
+
return decodeRelocationSnapshot(jsonBytes(await secureRead(join(paths.backupsDir, `${journal.sourceSha256}.json`))));
|
|
2069
|
+
}
|
|
2070
|
+
function withoutManagement(value) {
|
|
2071
|
+
const config = object(jsonBytes(value), 'source config');
|
|
2072
|
+
delete config[MANAGEMENT_SETTINGS_KEY];
|
|
2073
|
+
return config;
|
|
2074
|
+
}
|
|
2075
|
+
async function sourceMatches(modules, source, snapshot, journal) {
|
|
2076
|
+
return withSaveLocks(modules, source, async () => {
|
|
2077
|
+
const current = await synchronizedSnapshot(source);
|
|
2078
|
+
const config = object(jsonBytes(current.config), 'source config');
|
|
2079
|
+
const pending = config[MANAGEMENT_SETTINGS_KEY];
|
|
2080
|
+
if (pending !== undefined &&
|
|
2081
|
+
!isDeepStrictEqual(pending, sourceTombstone(journal)))
|
|
2082
|
+
throw new AccountMigrationError('source management record belongs to another operation');
|
|
2083
|
+
return (isDeepStrictEqual(withoutManagement(current.config), withoutManagement(snapshot.config)) &&
|
|
2084
|
+
(current.state === undefined
|
|
2085
|
+
? snapshot.state === undefined
|
|
2086
|
+
: snapshot.state !== undefined && current.state.equals(snapshot.state)));
|
|
2087
|
+
});
|
|
2088
|
+
}
|
|
2089
|
+
function sourceTombstone(journal) {
|
|
2090
|
+
return {
|
|
2091
|
+
id: journal.id,
|
|
2092
|
+
kind: 'migration',
|
|
2093
|
+
targets: relocationOf(journal).rows,
|
|
2094
|
+
progress: { step: 'activate', completedTargets: [] },
|
|
2095
|
+
};
|
|
2096
|
+
}
|
|
2097
|
+
async function captureRelocation(modules, source, paths, lease, options) {
|
|
2098
|
+
const captured = await withSaveLocks(modules, source, async () => {
|
|
2099
|
+
const files = await synchronizedSnapshot(source, options.onBoundary);
|
|
2100
|
+
return { ...files, journal: await secureRead(source.migrationPath) };
|
|
2101
|
+
});
|
|
2102
|
+
const sourceJournal = decodeAccountMigrationJournal(jsonBytes(captured.journal));
|
|
2103
|
+
if (sourceJournal.status !== 'active')
|
|
2104
|
+
throw new AccountMigrationError('relocation requires an active source');
|
|
2105
|
+
const config = object(jsonBytes(captured.config), 'source config');
|
|
2106
|
+
if (config[MANAGEMENT_SETTINGS_KEY] !== undefined)
|
|
2107
|
+
throw new AccountMigrationError('source management is pending');
|
|
2108
|
+
const sourceStore = openAdmissionStore(modules, source, options.now);
|
|
2109
|
+
const rows = await rowsOf(sourceStore);
|
|
2110
|
+
const bytes = Buffer.from(JSON.stringify({
|
|
2111
|
+
config: captured.config.toString('base64'),
|
|
2112
|
+
...(captured.state === undefined
|
|
2113
|
+
? {}
|
|
2114
|
+
: { state: captured.state.toString('base64') }),
|
|
2115
|
+
journal: captured.journal.toString('base64'),
|
|
2116
|
+
}));
|
|
2117
|
+
const journal = {
|
|
2118
|
+
schemaVersion: 1,
|
|
2119
|
+
id: pathGenerationId(paths),
|
|
2120
|
+
legacyPath: paths.legacyPath,
|
|
2121
|
+
storeDir: paths.storeDir,
|
|
2122
|
+
status: 'pending',
|
|
2123
|
+
operation: 'migrate',
|
|
2124
|
+
phase: 'capture',
|
|
2125
|
+
sourceKind: 'generation',
|
|
2126
|
+
sourceSha256: digest(bytes),
|
|
2127
|
+
manifest: {
|
|
2128
|
+
sourceVersion: null,
|
|
2129
|
+
normalizationClock: options.now(),
|
|
2130
|
+
accounts: [],
|
|
2131
|
+
routing: { schemaVersion: 1 },
|
|
2132
|
+
effectiveActiveIndex: 0,
|
|
2133
|
+
},
|
|
2134
|
+
mapping: [],
|
|
2135
|
+
completedRows: [],
|
|
2136
|
+
relocation: {
|
|
2137
|
+
source: {
|
|
2138
|
+
legacyPath: source.legacyPath,
|
|
2139
|
+
storeDir: source.storeDir,
|
|
2140
|
+
journalId: sourceJournal.id,
|
|
2141
|
+
configSha256: digest(captured.config),
|
|
2142
|
+
statePresent: captured.state !== undefined,
|
|
2143
|
+
...(captured.state === undefined
|
|
2144
|
+
? {}
|
|
2145
|
+
: { stateSha256: digest(captured.state) }),
|
|
2146
|
+
},
|
|
2147
|
+
rows: rows.map((row) => row.id),
|
|
2148
|
+
sourceTombstoned: false,
|
|
2149
|
+
sourceForwarded: false,
|
|
2150
|
+
},
|
|
2151
|
+
};
|
|
2152
|
+
await exclusiveBytes(join(paths.backupsDir, `${journal.sourceSha256}.json`), bytes, digest(bytes), options.onBoundary);
|
|
2153
|
+
await options.onBoundary?.('relocate:capture:after-backup');
|
|
2154
|
+
const capture = Buffer.from(`${JSON.stringify(captureJson(journal), null, 2)}\n`);
|
|
2155
|
+
journal.captureSha256 = digest(capture);
|
|
2156
|
+
await exclusiveBytes(join(paths.transfersDir, `${journal.id}.capture.json`), capture, digest(capture), options.onBoundary);
|
|
2157
|
+
await saveJournal(modules, paths, lease, journal, true, options.onBoundary);
|
|
2158
|
+
return journal;
|
|
2159
|
+
}
|
|
2160
|
+
async function buildRelocation(store, paths, snapshot, journal, options) {
|
|
2161
|
+
const target = generationJson(journal);
|
|
2162
|
+
const buildManagement = {
|
|
2163
|
+
id: journal.id,
|
|
2164
|
+
kind: 'migration',
|
|
2165
|
+
targets: relocationOf(journal).rows,
|
|
2166
|
+
progress: { step: 'build' },
|
|
2167
|
+
};
|
|
2168
|
+
const source = object(jsonBytes(snapshot.config), 'snapshot config')[ACCOUNT_STORE_GENERATION_SETTINGS_KEY];
|
|
2169
|
+
// A crash after updateSettings leaves a rewritten config, not the original clone.
|
|
2170
|
+
// Accept it only after proving its entire native config differs solely in our two keys.
|
|
2171
|
+
let rewritten = false;
|
|
2172
|
+
if (await exists(paths.configPath)) {
|
|
2173
|
+
const current = object(jsonBytes(await secureRead(paths.configPath)), 'build config');
|
|
2174
|
+
rewritten = isDeepStrictEqual(current[ACCOUNT_STORE_GENERATION_SETTINGS_KEY], target);
|
|
2175
|
+
if (rewritten) {
|
|
2176
|
+
const expected = object(jsonBytes(snapshot.config), 'snapshot config');
|
|
2177
|
+
expected[ACCOUNT_STORE_GENERATION_SETTINGS_KEY] = target;
|
|
2178
|
+
expected[MANAGEMENT_SETTINGS_KEY] = buildManagement;
|
|
2179
|
+
if (!isDeepStrictEqual(current, expected))
|
|
2180
|
+
throw new AccountMigrationError('partial clone config differs from snapshot');
|
|
2181
|
+
}
|
|
2182
|
+
}
|
|
2183
|
+
if (snapshot.state !== undefined)
|
|
2184
|
+
await exclusiveBytes(paths.statePath, snapshot.state, digest(snapshot.state), options.onBoundary);
|
|
2185
|
+
await options.onBoundary?.('relocate:build:after-state');
|
|
2186
|
+
if (!rewritten)
|
|
2187
|
+
await exclusiveBytes(paths.configPath, snapshot.config, digest(snapshot.config), options.onBoundary);
|
|
2188
|
+
await options.onBoundary?.('relocate:build:after-config');
|
|
2189
|
+
await store.updateSettings((settings) => {
|
|
2190
|
+
const prior = settings[ACCOUNT_STORE_GENERATION_SETTINGS_KEY];
|
|
2191
|
+
if (!isDeepStrictEqual(prior, source) && !isDeepStrictEqual(prior, target))
|
|
2192
|
+
throw new AccountMigrationError('clone generation differs from source and target');
|
|
2193
|
+
const management = settings[MANAGEMENT_SETTINGS_KEY];
|
|
2194
|
+
if (management !== undefined &&
|
|
2195
|
+
!isDeepStrictEqual(management, buildManagement))
|
|
2196
|
+
throw new AccountMigrationError('clone management belongs to another operation');
|
|
2197
|
+
settings[ACCOUNT_STORE_GENERATION_SETTINGS_KEY] = target;
|
|
2198
|
+
settings[MANAGEMENT_SETTINGS_KEY] = buildManagement;
|
|
2199
|
+
return settings;
|
|
2200
|
+
});
|
|
2201
|
+
}
|
|
2202
|
+
async function cancelRelocation(modules, sourceStore, paths, lease, journal, options) {
|
|
2203
|
+
const relocation = relocationOf(journal);
|
|
2204
|
+
const pointer = await readPointerRecord(relocation.source.legacyPath);
|
|
2205
|
+
if (relocation.sourceForwarded ||
|
|
2206
|
+
pointer?.schemaVersion !== 1 ||
|
|
2207
|
+
pointer.id !== relocation.source.journalId)
|
|
2208
|
+
throw new AccountMigrationError('nothing cancels after the source forward');
|
|
2209
|
+
await sourceStore.updateSettings((settings) => {
|
|
2210
|
+
const prior = settings[MANAGEMENT_SETTINGS_KEY];
|
|
2211
|
+
if (prior !== undefined) {
|
|
2212
|
+
if (!isDeepStrictEqual(prior, sourceTombstone(journal)))
|
|
2213
|
+
throw new AccountMigrationError("cancellation cannot remove another operation's tombstone");
|
|
2214
|
+
delete settings[MANAGEMENT_SETTINGS_KEY];
|
|
2215
|
+
}
|
|
2216
|
+
return settings;
|
|
2217
|
+
});
|
|
2218
|
+
relocation.sourceTombstoned = false;
|
|
2219
|
+
journal.status = 'inactive';
|
|
2220
|
+
journal.cancelled = true;
|
|
2221
|
+
await saveJournal(modules, paths, lease, journal, false, options.onBoundary);
|
|
2222
|
+
return { status: 'cancelled', id: journal.id };
|
|
2223
|
+
}
|
|
2224
|
+
async function replaceRelocationPointer(modules, anchor, prior, next, id, leases, boundary, prefix = 'relocate:forward') {
|
|
2225
|
+
const path = accountStorePointerPath(anchor);
|
|
2226
|
+
const stage = `${path}.${id}.tmp`;
|
|
2227
|
+
const assertOwned = async () => {
|
|
2228
|
+
for (const lease of leases)
|
|
2229
|
+
await lease.assertOwned();
|
|
2230
|
+
};
|
|
2231
|
+
if (await exists(stage)) {
|
|
2232
|
+
if (!isDeepStrictEqual(jsonBytes(await secureRead(stage)), next))
|
|
2233
|
+
throw new AccountMigrationError('foreign relocation pointer stage');
|
|
2234
|
+
await assertOwned();
|
|
2235
|
+
await unlink(stage);
|
|
2236
|
+
}
|
|
2237
|
+
const current = await readPointerRecord(anchor);
|
|
2238
|
+
if (!isDeepStrictEqual(current, next)) {
|
|
2239
|
+
if (!isDeepStrictEqual(current, prior))
|
|
2240
|
+
throw new AccountMigrationError('source pointer changed before relocation');
|
|
2241
|
+
await modules.fs.writeJsonAtomic(path, next, {
|
|
2242
|
+
stageName: () => id,
|
|
2243
|
+
beforeRename: async () => {
|
|
2244
|
+
await assertOwned();
|
|
2245
|
+
if (!isDeepStrictEqual(await readPointerRecord(anchor), prior))
|
|
2246
|
+
throw new AccountMigrationError('source pointer changed before rename');
|
|
2247
|
+
await syncFile(stage);
|
|
2248
|
+
await boundary?.(`${prefix}:before-rename`);
|
|
2249
|
+
},
|
|
2250
|
+
});
|
|
2251
|
+
await boundary?.(`${prefix}:after-rename-before-sync`);
|
|
2252
|
+
}
|
|
2253
|
+
// Also finish these barriers when recovering a rename that preceded a crash.
|
|
2254
|
+
await syncFile(path);
|
|
2255
|
+
await syncDirectory(dirname(anchor));
|
|
2256
|
+
if (!isDeepStrictEqual(await readPointerRecord(anchor), next))
|
|
2257
|
+
throw new AccountMigrationError('relocation pointer readback differs');
|
|
2258
|
+
await assertOwned();
|
|
2259
|
+
}
|
|
2260
|
+
/** Offline relocation only. The host must prevent forward and reverse relocations from running concurrently. */
|
|
2261
|
+
export function createAccountRelocationFactory(modules) {
|
|
2262
|
+
return async (options) => {
|
|
2263
|
+
if (options.offline?.processesStopped !== true)
|
|
2264
|
+
throw new AccountMigrationError('stopped processes and timers must be explicitly confirmed');
|
|
2265
|
+
resolveAccountStorePaths(options.legacyPath);
|
|
2266
|
+
resolveAccountStorePaths(options.targetPath);
|
|
2267
|
+
if (sameFilesystemPath(options.legacyPath, options.targetPath))
|
|
2268
|
+
throw new AccountMigrationError('relocation requires distinct anchors');
|
|
2269
|
+
try {
|
|
2270
|
+
return await withParentTopology(modules, options.legacyPath, (sourceParent) => withParentTopology(modules, options.targetPath, async (targetParent) => {
|
|
2271
|
+
if (options.back)
|
|
2272
|
+
return reverseRelocation(modules, options, sourceParent, targetParent);
|
|
2273
|
+
const sourceRecord = await readPointerRecord(options.legacyPath);
|
|
2274
|
+
if (!sourceRecord)
|
|
2275
|
+
throw new AccountMigrationError('relocation source pointer missing');
|
|
2276
|
+
if (sourceRecord.schemaVersion === 1)
|
|
2277
|
+
await finishPointerPublication(options.legacyPath, sourceRecord, sourceParent);
|
|
2278
|
+
const source = generationPaths(options.legacyPath, sourceRecord.id);
|
|
2279
|
+
if ((await exists(source.legacyPath)) ||
|
|
2280
|
+
(await exists(options.targetPath)))
|
|
2281
|
+
throw new AccountMigrationError('relocation refuses a reappeared pool file');
|
|
2282
|
+
return withGenerationLease(modules, source.configPath, MANAGEMENT_LOCK_NAME, async (sourceLease) => withGenerationLease(modules, await realpath(source.configPath), 'antigravity-topology', async (topologyLease) => {
|
|
2283
|
+
const sourceJournal = await readJournal(source);
|
|
2284
|
+
if (!sourceJournal)
|
|
2285
|
+
throw new AccountMigrationError('relocation source journal missing');
|
|
2286
|
+
const prior = await readPointer(options.targetPath);
|
|
2287
|
+
if (prior)
|
|
2288
|
+
await finishPointerPublication(options.targetPath, prior, targetParent);
|
|
2289
|
+
let paths = prior
|
|
2290
|
+
? generationPaths(options.targetPath, prior.id)
|
|
2291
|
+
: undefined;
|
|
2292
|
+
let existing = paths
|
|
2293
|
+
? await readJournal(paths)
|
|
2294
|
+
: undefined;
|
|
2295
|
+
if (prior &&
|
|
2296
|
+
(existing?.sourceKind !== 'generation' ||
|
|
2297
|
+
relocationOf(existing).source.legacyPath !==
|
|
2298
|
+
source.legacyPath ||
|
|
2299
|
+
relocationOf(existing).source.journalId !==
|
|
2300
|
+
sourceJournal.id))
|
|
2301
|
+
throw new AccountMigrationError('destination belongs to another source');
|
|
2302
|
+
if (sourceJournal.status !== 'active' &&
|
|
2303
|
+
!(existing &&
|
|
2304
|
+
sourceJournal.operation === 'rollback' &&
|
|
2305
|
+
isDeepStrictEqual((await settingsOf(openAdmissionStore(modules, source, options.now)))[MANAGEMENT_SETTINGS_KEY], sourceTombstone(existing))))
|
|
2306
|
+
throw new AccountMigrationError('relocation requires an active source journal or its own tombstone');
|
|
2307
|
+
if (existing?.relocation?.reversed &&
|
|
2308
|
+
(await restoredRelocationAnchor(options.targetPath, prior)) !== source.legacyPath)
|
|
2309
|
+
throw new AccountMigrationError('finish the reverse relocation before moving forward again');
|
|
2310
|
+
if (existing?.status === 'active' &&
|
|
2311
|
+
!existing.relocation?.reversed)
|
|
2312
|
+
await assertSourceForwarded(existing, undefined);
|
|
2313
|
+
if ((existing?.cancelled &&
|
|
2314
|
+
existing.status === 'inactive') ||
|
|
2315
|
+
existing?.relocation?.reversed) {
|
|
2316
|
+
paths = undefined;
|
|
2317
|
+
existing = undefined;
|
|
2318
|
+
}
|
|
2319
|
+
if (!paths) {
|
|
2320
|
+
if (sourceRecord.schemaVersion !== 1)
|
|
2321
|
+
throw new AccountMigrationError('forwarded source has no resumable destination');
|
|
2322
|
+
await currentSuccessor(openAdmissionStore(modules, source, options.now), source, sourceJournal);
|
|
2323
|
+
await verifyCopies(source, sourceJournal, true);
|
|
2324
|
+
if (sourceJournal.sourceKind === 'generation')
|
|
2325
|
+
await assertSourceForwarded(sourceJournal, undefined);
|
|
2326
|
+
await options.onBoundary?.('relocate:before-capture');
|
|
2327
|
+
paths = await allocateGeneration(options.targetPath);
|
|
2328
|
+
}
|
|
2329
|
+
const physical = paths;
|
|
2330
|
+
return withGenerationLease(modules, physical.configPath, MANAGEMENT_LOCK_NAME, async (lease) => {
|
|
2331
|
+
let journal = existing;
|
|
2332
|
+
if (!journal) {
|
|
2333
|
+
journal = await captureRelocation(modules, source, physical, lease, options);
|
|
2334
|
+
await publishGenerationPointer(modules, physical, journal, targetParent, lease, prior, options.onBoundary);
|
|
2335
|
+
await options.onBoundary?.('relocate:after-capture');
|
|
2336
|
+
}
|
|
2337
|
+
const relocation = relocationOf(journal);
|
|
2338
|
+
const sourceStore = openMigrationStore(modules, source, options.now, writeObserver(source, sourceLease, (point) => options.onBoundary?.(point.startsWith('public:')
|
|
2339
|
+
? `relocate:source:${point}`
|
|
2340
|
+
: point)));
|
|
2341
|
+
const store = openMigrationStore(modules, physical, options.now, writeObserver(physical, lease, options.onBoundary));
|
|
2342
|
+
const save = async () => {
|
|
2343
|
+
await sourceParent.assertOwned();
|
|
2344
|
+
await targetParent.assertOwned();
|
|
2345
|
+
await topologyLease.assertOwned();
|
|
2346
|
+
await saveJournal(modules, physical, lease, journal, false, options.onBoundary);
|
|
2347
|
+
};
|
|
2348
|
+
const forward = {
|
|
2349
|
+
schemaVersion: 2,
|
|
2350
|
+
id: sourceJournal.id,
|
|
2351
|
+
directoryBasename: basename(source.storeDir),
|
|
2352
|
+
relocatedTo: {
|
|
2353
|
+
legacyPath: physical.legacyPath,
|
|
2354
|
+
id: journal.id,
|
|
2355
|
+
},
|
|
2356
|
+
};
|
|
2357
|
+
const current = await readPointerRecord(source.legacyPath);
|
|
2358
|
+
if (isDeepStrictEqual(current, forward)) {
|
|
2359
|
+
// The rename may have completed without its journal acknowledgment.
|
|
2360
|
+
// Finish its barriers before recording the point of no return.
|
|
2361
|
+
await replaceRelocationPointer(modules, source.legacyPath, forward, forward, journal.id, [sourceParent, sourceLease, topologyLease], options.onBoundary);
|
|
2362
|
+
relocation.sourceForwarded = true;
|
|
2363
|
+
relocation.sourceTombstoned = true;
|
|
2364
|
+
journal.phase = 'activate';
|
|
2365
|
+
await save();
|
|
2366
|
+
}
|
|
2367
|
+
else if (current?.schemaVersion !== 1 ||
|
|
2368
|
+
current.id !== sourceJournal.id ||
|
|
2369
|
+
relocation.sourceForwarded) {
|
|
2370
|
+
throw new AccountMigrationError('source forward differs from relocation evidence');
|
|
2371
|
+
}
|
|
2372
|
+
if (options.cancel)
|
|
2373
|
+
return cancelRelocation(modules, sourceStore, physical, lease, journal, options);
|
|
2374
|
+
if (journal.status === 'active') {
|
|
2375
|
+
await assertSourceForwarded(journal, undefined);
|
|
2376
|
+
await verifyCopies(physical, journal, true);
|
|
2377
|
+
await currentSuccessor(store, physical, journal);
|
|
2378
|
+
await management(store, journal, true);
|
|
2379
|
+
await cleanupPointerStage(physical.legacyPath, {
|
|
2380
|
+
schemaVersion: 1,
|
|
2381
|
+
id: journal.id,
|
|
2382
|
+
directoryBasename: basename(physical.storeDir),
|
|
2383
|
+
}, targetParent);
|
|
2384
|
+
return {
|
|
2385
|
+
status: 'completed',
|
|
2386
|
+
receipt: receipt(journal),
|
|
2387
|
+
};
|
|
2388
|
+
}
|
|
2389
|
+
const snapshot = await relocationSnapshot(physical, journal);
|
|
2390
|
+
if (!relocation.sourceForwarded &&
|
|
2391
|
+
!(await sourceMatches(modules, source, snapshot, journal)))
|
|
2392
|
+
return cancelRelocation(modules, sourceStore, physical, lease, journal, options);
|
|
2393
|
+
if (journal.phase === 'capture' ||
|
|
2394
|
+
journal.phase === 'build') {
|
|
2395
|
+
journal.phase = 'build';
|
|
2396
|
+
await save();
|
|
2397
|
+
await buildRelocation(store, physical, snapshot, journal, options);
|
|
2398
|
+
journal.phase = 'verify';
|
|
2399
|
+
await save();
|
|
2400
|
+
await options.onBoundary?.('relocate:after-build');
|
|
2401
|
+
}
|
|
2402
|
+
if (journal.phase === 'verify') {
|
|
2403
|
+
await options.onBoundary?.('relocate:before-verify');
|
|
2404
|
+
await currentSuccessor(store, physical, journal);
|
|
2405
|
+
const sourceRows = await rowsOf(openAdmissionStore(modules, source, options.now));
|
|
2406
|
+
if (!isDeepStrictEqual(sourceRows, await rowsOf(store)) ||
|
|
2407
|
+
!isDeepStrictEqual(sourceRows.map((row) => row.id), relocation.rows))
|
|
2408
|
+
return cancelRelocation(modules, sourceStore, physical, lease, journal, options);
|
|
2409
|
+
const strip = (settings) => {
|
|
2410
|
+
const copy = { ...settings };
|
|
2411
|
+
delete copy[ACCOUNT_STORE_GENERATION_SETTINGS_KEY];
|
|
2412
|
+
delete copy[MANAGEMENT_SETTINGS_KEY];
|
|
2413
|
+
return copy;
|
|
2414
|
+
};
|
|
2415
|
+
if (!isDeepStrictEqual(strip(await settingsOf(store)), strip(await settingsOf(sourceStore))))
|
|
2416
|
+
throw new AccountMigrationError('cloned settings differ from source');
|
|
2417
|
+
const verified = await synchronizedSnapshot(physical, options.onBoundary);
|
|
2418
|
+
journal.verification = {
|
|
2419
|
+
manifestSha256: digest(canonical(manifestJson(journal.manifest))),
|
|
2420
|
+
configSha256: digest(verified.config),
|
|
2421
|
+
statePresent: verified.state !== undefined,
|
|
2422
|
+
...(verified.state === undefined
|
|
2423
|
+
? {}
|
|
2424
|
+
: { stateSha256: digest(verified.state) }),
|
|
2425
|
+
durability: accountMigrationDurability(),
|
|
2426
|
+
};
|
|
2427
|
+
if (!(await sourceMatches(modules, source, snapshot, journal)))
|
|
2428
|
+
return cancelRelocation(modules, sourceStore, physical, lease, journal, options);
|
|
2429
|
+
journal.phase = 'retire';
|
|
2430
|
+
await save();
|
|
2431
|
+
await options.onBoundary?.('relocate:after-verify');
|
|
2432
|
+
}
|
|
2433
|
+
if (journal.phase === 'retire') {
|
|
2434
|
+
await sourceStore.updateSettings((settings) => {
|
|
2435
|
+
if (!isDeepStrictEqual(settings[ACCOUNT_STORE_GENERATION_SETTINGS_KEY], generationJson(sourceJournal)))
|
|
2436
|
+
throw new AccountMigrationError('source generation changed before tombstone');
|
|
2437
|
+
const prior = settings[MANAGEMENT_SETTINGS_KEY];
|
|
2438
|
+
if (prior !== undefined &&
|
|
2439
|
+
!isDeepStrictEqual(prior, sourceTombstone(journal)))
|
|
2440
|
+
throw new AccountMigrationError('source management belongs to another operation');
|
|
2441
|
+
settings[MANAGEMENT_SETTINGS_KEY] =
|
|
2442
|
+
sourceTombstone(journal);
|
|
2443
|
+
return settings;
|
|
2444
|
+
});
|
|
2445
|
+
relocation.sourceTombstoned = true;
|
|
2446
|
+
await save();
|
|
2447
|
+
await options.onBoundary?.('relocate:retire:after-tombstone');
|
|
2448
|
+
const keys = [
|
|
2449
|
+
...new Set((await rowsOf(sourceStore)).map((row) => refreshProviderLock(source.statePath, row)
|
|
2450
|
+
.name)),
|
|
2451
|
+
].sort();
|
|
2452
|
+
const drain = async (index) => {
|
|
2453
|
+
const name = keys[index];
|
|
2454
|
+
if (name === undefined)
|
|
2455
|
+
return sourceMatches(modules, source, snapshot, journal);
|
|
2456
|
+
return modules.fs.withLock(source.statePath, {
|
|
2457
|
+
name,
|
|
2458
|
+
ttlMs: 10_000,
|
|
2459
|
+
timeoutMs: 15_000,
|
|
2460
|
+
renew: true,
|
|
2461
|
+
}, async (refreshLease) => {
|
|
2462
|
+
const matched = await drain(index + 1);
|
|
2463
|
+
await refreshLease.assertOwned();
|
|
2464
|
+
return matched;
|
|
2465
|
+
});
|
|
2466
|
+
};
|
|
2467
|
+
if (!(await drain(0)))
|
|
2468
|
+
return cancelRelocation(modules, sourceStore, physical, lease, journal, options);
|
|
2469
|
+
await options.onBoundary?.('relocate:retire:after-drain');
|
|
2470
|
+
await withGenerationLease(modules, source.legacyPath, 'accounts', (accountsLease) => replaceRelocationPointer(modules, source.legacyPath, {
|
|
2471
|
+
schemaVersion: 1,
|
|
2472
|
+
id: sourceJournal.id,
|
|
2473
|
+
directoryBasename: basename(source.storeDir),
|
|
2474
|
+
}, forward, journal.id, [
|
|
2475
|
+
sourceParent,
|
|
2476
|
+
targetParent,
|
|
2477
|
+
sourceLease,
|
|
2478
|
+
topologyLease,
|
|
2479
|
+
lease,
|
|
2480
|
+
accountsLease,
|
|
2481
|
+
], options.onBoundary));
|
|
2482
|
+
relocation.sourceForwarded = true;
|
|
2483
|
+
journal.phase = 'activate';
|
|
2484
|
+
await save();
|
|
2485
|
+
await options.onBoundary?.('relocate:after-retire');
|
|
2486
|
+
}
|
|
2487
|
+
await options.onBoundary?.('relocate:before-activate');
|
|
2488
|
+
await assertSourceForwarded(journal, undefined);
|
|
2489
|
+
await verifyCopies(physical, journal, true);
|
|
2490
|
+
await currentSuccessor(store, physical, journal);
|
|
2491
|
+
journal.status = 'active';
|
|
2492
|
+
await save();
|
|
2493
|
+
await management(store, journal, true);
|
|
2494
|
+
await cleanupPointerStage(physical.legacyPath, {
|
|
2495
|
+
schemaVersion: 1,
|
|
2496
|
+
id: journal.id,
|
|
2497
|
+
directoryBasename: basename(physical.storeDir),
|
|
2498
|
+
}, targetParent);
|
|
2499
|
+
await options.onBoundary?.('relocate:after-activate');
|
|
2500
|
+
return {
|
|
2501
|
+
status: 'completed',
|
|
2502
|
+
receipt: receipt(journal),
|
|
2503
|
+
};
|
|
2504
|
+
});
|
|
2505
|
+
}));
|
|
2506
|
+
}));
|
|
2507
|
+
}
|
|
2508
|
+
catch (error) {
|
|
2509
|
+
const pending = pendingLockFailure(modules, error);
|
|
2510
|
+
if (pending)
|
|
2511
|
+
return pending;
|
|
2512
|
+
throw error;
|
|
2513
|
+
}
|
|
2514
|
+
};
|
|
2515
|
+
}
|
|
2516
|
+
async function restoredRelocationAnchor(anchor, pointer) {
|
|
2517
|
+
if (pointer?.schemaVersion !== 1)
|
|
2518
|
+
return undefined;
|
|
2519
|
+
const paths = generationPaths(anchor, pointer.id);
|
|
2520
|
+
const journal = await readJournal(paths);
|
|
2521
|
+
if (!journal?.relocation?.reversed)
|
|
2522
|
+
return undefined;
|
|
2523
|
+
const source = journal.relocation.source;
|
|
2524
|
+
const restored = await readPointerRecord(source.legacyPath);
|
|
2525
|
+
if (restored?.schemaVersion !== 1)
|
|
2526
|
+
return undefined;
|
|
2527
|
+
if (restored.id !== source.journalId)
|
|
2528
|
+
throw new AccountMigrationError(`the reversed store at ${paths.storeDir} expects generation ${source.journalId} at ${source.legacyPath}, but that anchor publishes ${restored.id}`);
|
|
2529
|
+
await verifyCopies(paths, journal, true);
|
|
2530
|
+
await reverseEvidence(paths, journal);
|
|
2531
|
+
return source.legacyPath;
|
|
2532
|
+
}
|
|
2533
|
+
async function reverseEvidence(paths, journal) {
|
|
2534
|
+
const relocation = relocationOf(journal);
|
|
2535
|
+
const hash = relocation.reverseSha256;
|
|
2536
|
+
const bytes = await secureRead(join(paths.transfersDir, `${hash}.reverse-snapshot.json`));
|
|
2537
|
+
if (!hash || digest(bytes) !== hash)
|
|
2538
|
+
throw new AccountMigrationError('reverse snapshot digest differs');
|
|
2539
|
+
const evidence = exact(jsonBytes(bytes), ['config', 'state', 'journal', 'sourceId', 'targetId'], 'reverse snapshot');
|
|
2540
|
+
if (evidence.sourceId !== journal.id ||
|
|
2541
|
+
evidence.targetId !== relocation.source.journalId)
|
|
2542
|
+
throw new AccountMigrationError('reverse snapshot belongs to another generation');
|
|
2543
|
+
return decodeRelocationSnapshot({
|
|
2544
|
+
config: evidence.config,
|
|
2545
|
+
...(evidence.state === undefined ? {} : { state: evidence.state }),
|
|
2546
|
+
journal: evidence.journal,
|
|
2547
|
+
});
|
|
2548
|
+
}
|
|
2549
|
+
async function reverseRelocation(modules, options, sourceParent, targetParent) {
|
|
2550
|
+
if (options.cancel)
|
|
2551
|
+
throw new AccountMigrationError('reverse relocation cannot be cancelled');
|
|
2552
|
+
const pointer = await readPointer(options.legacyPath);
|
|
2553
|
+
if (!pointer)
|
|
2554
|
+
throw new AccountMigrationError('reverse source pointer missing');
|
|
2555
|
+
const paths = generationPaths(options.legacyPath, pointer.id);
|
|
2556
|
+
return withGenerationLease(modules, paths.configPath, MANAGEMENT_LOCK_NAME, (lease) => withGenerationLease(modules, paths.configPath, 'antigravity-topology', async (topologyLease) => {
|
|
2557
|
+
const journal = await readJournal(paths);
|
|
2558
|
+
if (journal?.status !== 'active' ||
|
|
2559
|
+
journal.sourceKind !== 'generation')
|
|
2560
|
+
throw new AccountMigrationError('reverse requires an active relocated store');
|
|
2561
|
+
const relocation = relocationOf(journal);
|
|
2562
|
+
if (options.targetPath !== relocation.source.legacyPath)
|
|
2563
|
+
throw new AccountMigrationError('reverse target is not the original anchor');
|
|
2564
|
+
const original = generationPaths(options.targetPath, relocation.source.journalId);
|
|
2565
|
+
if ((await exists(paths.legacyPath)) ||
|
|
2566
|
+
(await exists(original.legacyPath)))
|
|
2567
|
+
throw new AccountMigrationError('reverse relocation refuses a reappeared pool file');
|
|
2568
|
+
return withGenerationLease(modules, original.configPath, MANAGEMENT_LOCK_NAME, (originalLease) => withGenerationLease(modules, original.configPath, 'antigravity-topology', async (originalTopology) => {
|
|
2569
|
+
const oldBytes = await secureRead(original.migrationPath);
|
|
2570
|
+
const oldJournal = await readJournal(original);
|
|
2571
|
+
if (oldJournal?.status !== 'active' ||
|
|
2572
|
+
oldJournal.sourceKind === 'generation')
|
|
2573
|
+
throw new AccountMigrationError('reverse original journal is not released-compatible');
|
|
2574
|
+
await verifyCopies(original, oldJournal, true);
|
|
2575
|
+
const forward = {
|
|
2576
|
+
schemaVersion: 2,
|
|
2577
|
+
id: oldJournal.id,
|
|
2578
|
+
directoryBasename: basename(original.storeDir),
|
|
2579
|
+
relocatedTo: {
|
|
2580
|
+
legacyPath: paths.legacyPath,
|
|
2581
|
+
id: journal.id,
|
|
2582
|
+
},
|
|
2583
|
+
};
|
|
2584
|
+
const restored = {
|
|
2585
|
+
schemaVersion: 1,
|
|
2586
|
+
id: oldJournal.id,
|
|
2587
|
+
directoryBasename: basename(original.storeDir),
|
|
2588
|
+
};
|
|
2589
|
+
const current = await readPointerRecord(original.legacyPath);
|
|
2590
|
+
if (!isDeepStrictEqual(current, forward) &&
|
|
2591
|
+
!(relocation.reversed &&
|
|
2592
|
+
isDeepStrictEqual(current, restored)))
|
|
2593
|
+
throw new AccountMigrationError('reverse target forward changed');
|
|
2594
|
+
const store = openMigrationStore(modules, paths, options.now);
|
|
2595
|
+
const progressPath = join(paths.transfersDir, `${journal.id}.reverse.json`);
|
|
2596
|
+
const progress = async (phase, snapshotSha256) => {
|
|
2597
|
+
const record = {
|
|
2598
|
+
schemaVersion: 1,
|
|
2599
|
+
id: journal.id,
|
|
2600
|
+
sourcePath: paths.legacyPath,
|
|
2601
|
+
targetPath: original.legacyPath,
|
|
2602
|
+
targetId: oldJournal.id,
|
|
2603
|
+
snapshotSha256,
|
|
2604
|
+
phase,
|
|
2605
|
+
};
|
|
2606
|
+
const stage = `${progressPath}.${journal.id}.tmp`;
|
|
2607
|
+
if (await exists(stage)) {
|
|
2608
|
+
await secureRead(stage);
|
|
2609
|
+
await lease.assertOwned();
|
|
2610
|
+
await unlink(stage);
|
|
2611
|
+
}
|
|
2612
|
+
await modules.fs.writeJsonAtomic(progressPath, record, {
|
|
2613
|
+
stageName: () => journal.id,
|
|
2614
|
+
beforeRename: async () => {
|
|
2615
|
+
await lease.assertOwned();
|
|
2616
|
+
await syncFile(stage);
|
|
2617
|
+
},
|
|
2618
|
+
});
|
|
2619
|
+
await syncFile(progressPath);
|
|
2620
|
+
await syncDirectory(paths.transfersDir);
|
|
2621
|
+
if (!isDeepStrictEqual(jsonBytes(await secureRead(progressPath)), record))
|
|
2622
|
+
throw new AccountMigrationError('reverse progress readback differs');
|
|
2623
|
+
};
|
|
2624
|
+
await verifyCopies(paths, journal, true);
|
|
2625
|
+
await currentSuccessor(store, paths, journal);
|
|
2626
|
+
if (!relocation.reversed) {
|
|
2627
|
+
const settings = await settingsOf(store);
|
|
2628
|
+
if (settings[MANAGEMENT_SETTINGS_KEY] !== undefined)
|
|
2629
|
+
throw new AccountMigrationError('reverse source management is pending');
|
|
2630
|
+
await options.onBoundary?.('relocate:back:before-clone');
|
|
2631
|
+
await withSaveLocks(modules, paths, async (sourceLeases) => {
|
|
2632
|
+
const snapshot = await synchronizedSnapshot(paths, options.onBoundary);
|
|
2633
|
+
const evidence = Buffer.from(JSON.stringify({
|
|
2634
|
+
sourceId: journal.id,
|
|
2635
|
+
targetId: oldJournal.id,
|
|
2636
|
+
config: snapshot.config.toString('base64'),
|
|
2637
|
+
...(snapshot.state === undefined
|
|
2638
|
+
? {}
|
|
2639
|
+
: { state: snapshot.state.toString('base64') }),
|
|
2640
|
+
journal: oldBytes.toString('base64'),
|
|
2641
|
+
}));
|
|
2642
|
+
const evidencePath = join(paths.transfersDir, `${digest(evidence)}.reverse-snapshot.json`);
|
|
2643
|
+
await exclusiveBytes(evidencePath, evidence, digest(evidence), options.onBoundary);
|
|
2644
|
+
await progress('clone', digest(evidence));
|
|
2645
|
+
await withSaveLocks(modules, original, async (targetLeases) => {
|
|
2646
|
+
const assertOwned = async () => {
|
|
2647
|
+
for (const held of [
|
|
2648
|
+
lease,
|
|
2649
|
+
originalLease,
|
|
2650
|
+
...sourceLeases,
|
|
2651
|
+
...targetLeases,
|
|
2652
|
+
])
|
|
2653
|
+
await held.assertOwned();
|
|
2654
|
+
};
|
|
2655
|
+
const copy = async (path, bytes) => {
|
|
2656
|
+
const stage = `${path}.${journal.id}.tmp`;
|
|
2657
|
+
await exclusiveBytes(stage, bytes, digest(bytes), options.onBoundary);
|
|
2658
|
+
await assertOwned();
|
|
2659
|
+
await rename(stage, path);
|
|
2660
|
+
await syncFile(path);
|
|
2661
|
+
await syncDirectory(original.storeDir);
|
|
2662
|
+
};
|
|
2663
|
+
// Native files are copied, never exported through the lossy V4 representation.
|
|
2664
|
+
if (snapshot.state !== undefined)
|
|
2665
|
+
await copy(original.statePath, snapshot.state);
|
|
2666
|
+
else if (await exists(original.statePath)) {
|
|
2667
|
+
await assertOwned();
|
|
2668
|
+
await unlink(original.statePath);
|
|
2669
|
+
await syncDirectory(original.storeDir);
|
|
2670
|
+
}
|
|
2671
|
+
await options.onBoundary?.('relocate:back:after-state');
|
|
2672
|
+
await copy(original.configPath, snapshot.config);
|
|
2673
|
+
await progress('restore-generation', digest(evidence));
|
|
2674
|
+
await options.onBoundary?.('relocate:back:after-clone');
|
|
2675
|
+
});
|
|
2676
|
+
relocation.reverseSha256 = digest(evidence);
|
|
2677
|
+
});
|
|
2678
|
+
// The original journal is never rewritten. Only its generation key
|
|
2679
|
+
// is restored in the current native pool.
|
|
2680
|
+
const originalStore = openMigrationStore(modules, original, options.now, async (step, info) => {
|
|
2681
|
+
await originalLease.assertOwned();
|
|
2682
|
+
if (step.startsWith('after-')) {
|
|
2683
|
+
await syncFile(step.includes('config')
|
|
2684
|
+
? original.configPath
|
|
2685
|
+
: original.statePath);
|
|
2686
|
+
await syncDirectory(original.storeDir);
|
|
2687
|
+
}
|
|
2688
|
+
await options.onBoundary?.(`back:public:${info.operation}:${step}`);
|
|
2689
|
+
});
|
|
2690
|
+
await originalStore.updateSettings((settings) => {
|
|
2691
|
+
if (!isDeepStrictEqual(settings[ACCOUNT_STORE_GENERATION_SETTINGS_KEY], generationJson(journal)))
|
|
2692
|
+
throw new AccountMigrationError('reverse clone generation changed');
|
|
2693
|
+
if (settings[MANAGEMENT_SETTINGS_KEY] !== undefined)
|
|
2694
|
+
throw new AccountMigrationError('reverse clone retained management');
|
|
2695
|
+
settings[ACCOUNT_STORE_GENERATION_SETTINGS_KEY] =
|
|
2696
|
+
generationJson(oldJournal);
|
|
2697
|
+
return settings;
|
|
2698
|
+
});
|
|
2699
|
+
await currentSuccessor(originalStore, original, oldJournal);
|
|
2700
|
+
if (!isDeepStrictEqual(await rowsOf(originalStore), await rowsOf(store)))
|
|
2701
|
+
throw new AccountMigrationError('reverse clone rows differ');
|
|
2702
|
+
await options.onBoundary?.('relocate:back:after-management-clear');
|
|
2703
|
+
if (!relocation.reverseSha256)
|
|
2704
|
+
throw new AccountMigrationError('reverse snapshot missing');
|
|
2705
|
+
await progress('publish', relocation.reverseSha256);
|
|
2706
|
+
relocation.reversed = true;
|
|
2707
|
+
await saveJournal(modules, paths, lease, journal, false, options.onBoundary);
|
|
2708
|
+
await options.onBoundary?.('relocate:back:after-record');
|
|
2709
|
+
}
|
|
2710
|
+
const evidence = await reverseEvidence(paths, journal);
|
|
2711
|
+
if (!evidence.journal.equals(oldBytes))
|
|
2712
|
+
throw new AccountMigrationError('reverse original journal differs from snapshot');
|
|
2713
|
+
if (current?.schemaVersion === 2) {
|
|
2714
|
+
const native = object(jsonBytes(evidence.config), 'reverse config');
|
|
2715
|
+
native[ACCOUNT_STORE_GENERATION_SETTINGS_KEY] =
|
|
2716
|
+
generationJson(oldJournal);
|
|
2717
|
+
const actual = await withSaveLocks(modules, original, () => synchronizedSnapshot(original, options.onBoundary));
|
|
2718
|
+
if (!isDeepStrictEqual(jsonBytes(actual.config), native) ||
|
|
2719
|
+
!isDeepStrictEqual(actual.state, evidence.state))
|
|
2720
|
+
throw new AccountMigrationError('reverse clone differs before pointer publication');
|
|
2721
|
+
}
|
|
2722
|
+
if (!(await secureRead(original.migrationPath)).equals(oldBytes))
|
|
2723
|
+
throw new AccountMigrationError('reverse changed original journal');
|
|
2724
|
+
await withGenerationLease(modules, original.legacyPath, 'accounts', (accountsLease) => replaceRelocationPointer(modules, original.legacyPath, forward, restored, journal.id, [
|
|
2725
|
+
sourceParent,
|
|
2726
|
+
targetParent,
|
|
2727
|
+
lease,
|
|
2728
|
+
topologyLease,
|
|
2729
|
+
originalLease,
|
|
2730
|
+
originalTopology,
|
|
2731
|
+
accountsLease,
|
|
2732
|
+
], options.onBoundary, 'relocate:back:pointer'));
|
|
2733
|
+
await progress('complete', relocation.reverseSha256 ?? '');
|
|
2734
|
+
await options.onBoundary?.('relocate:back:after-pointer');
|
|
2735
|
+
return { status: 'completed', receipt: receipt(oldJournal) };
|
|
2736
|
+
}));
|
|
2737
|
+
}));
|
|
2738
|
+
}
|
|
2007
2739
|
function refMatches(ref, row) {
|
|
2008
2740
|
return (ref !== null && ref !== undefined && isDeepStrictEqual(ref, rowRef(row)));
|
|
2009
2741
|
}
|
|
@@ -2621,7 +3353,10 @@ async function allocateGeneration(legacyPath) {
|
|
|
2621
3353
|
if (await exists(resolveAccountStorePaths(legacyPath).storeDir))
|
|
2622
3354
|
throw new AccountMigrationError('foreign logical directory refuses generation allocation');
|
|
2623
3355
|
const paths = generationPaths(legacyPath, randomUUID());
|
|
2624
|
-
|
|
3356
|
+
if (windowsPrivateFiles)
|
|
3357
|
+
await createPrivateDirectoryExclusive(paths.storeDir);
|
|
3358
|
+
else
|
|
3359
|
+
await mkdir(paths.storeDir, { mode: 0o700 });
|
|
2625
3360
|
await prepareDirectories(paths);
|
|
2626
3361
|
return paths;
|
|
2627
3362
|
}
|
|
@@ -2656,6 +3391,9 @@ async function publishGenerationPointer(modules, paths, journal, parentLease, ma
|
|
|
2656
3391
|
throw new AccountMigrationError('foreign logical store directory appeared before publication');
|
|
2657
3392
|
if (journal.sourceKind === 'absent')
|
|
2658
3393
|
await assertSourceAbsent(paths);
|
|
3394
|
+
else if (journal.sourceKind === 'generation') {
|
|
3395
|
+
await assertSourceAbsent(paths);
|
|
3396
|
+
}
|
|
2659
3397
|
else if (digest(await secureRead(paths.legacyPath)) !== journal.sourceSha256)
|
|
2660
3398
|
throw new AccountMigrationError('captured source changed immediately before pointer link');
|
|
2661
3399
|
try {
|
|
@@ -2676,9 +3414,18 @@ async function publishGenerationPointer(modules, paths, journal, parentLease, ma
|
|
|
2676
3414
|
else {
|
|
2677
3415
|
const oldPaths = generationPaths(paths.legacyPath, prior.id);
|
|
2678
3416
|
const oldJournal = await readJournal(oldPaths);
|
|
2679
|
-
if (oldJournal
|
|
3417
|
+
if (!oldJournal)
|
|
3418
|
+
throw new AccountMigrationError('prior generation journal missing');
|
|
3419
|
+
const reversed = oldJournal?.relocation?.reversed === true &&
|
|
3420
|
+
journal.sourceKind === 'generation' &&
|
|
3421
|
+
isDeepStrictEqual(oldJournal.relocation.source.legacyPath, journal.relocation?.source.legacyPath) &&
|
|
3422
|
+
oldJournal.relocation.source.journalId ===
|
|
3423
|
+
journal.relocation?.source.journalId;
|
|
3424
|
+
if (oldJournal?.status !== 'inactive' && !reversed)
|
|
2680
3425
|
throw new AccountMigrationError('pointer transition requires an owned inactive prior generation');
|
|
2681
3426
|
await verifyCopies(oldPaths, oldJournal, !oldJournal.cancelled);
|
|
3427
|
+
if (reversed)
|
|
3428
|
+
await reverseEvidence(oldPaths, oldJournal);
|
|
2682
3429
|
const stageName = journal.id;
|
|
2683
3430
|
await modules.fs.writeJsonAtomic(path, pointer, {
|
|
2684
3431
|
stageName: () => stageName,
|
|
@@ -2686,7 +3433,9 @@ async function publishGenerationPointer(modules, paths, journal, parentLease, ma
|
|
|
2686
3433
|
await parentLease.assertOwned();
|
|
2687
3434
|
await managementLease.assertOwned();
|
|
2688
3435
|
if (!isDeepStrictEqual(await readPointer(paths.legacyPath), prior) ||
|
|
2689
|
-
(
|
|
3436
|
+
(reversed
|
|
3437
|
+
? !isDeepStrictEqual(await readJournal(oldPaths), oldJournal)
|
|
3438
|
+
: (await readJournal(oldPaths))?.status !== 'inactive'))
|
|
2690
3439
|
throw new AccountMigrationError('inactive pointer changed before transition');
|
|
2691
3440
|
await syncFile(`${path}.${stageName}.tmp`);
|
|
2692
3441
|
},
|