@cortexkit/antigravity-auth-core 3.0.16 → 3.0.18

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.
@@ -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 { legacyPath };
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
- operation !== 'rollback' ||
753
- (status === 'inactive' && phase !== 'activate') ||
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, ['source', 'rows', 'sourceTombstoned', 'sourceForwarded'], '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
- await mkdir(path, { mode: 0o700 });
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
- handle = await open(path, 'wx', 0o600);
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. Reused stages and
1024
- // all backup, bootstrap, retired and export copies retain the reopened barrier.
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.rollback?.rows ?? journal.mapping.map((row) => row.id);
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, or run \`antigravity-auth store relocate\``);
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, or run \`antigravity-auth store relocate\``);
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
- await mkdir(paths.storeDir, { mode: 0o700 });
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?.status !== 'inactive')
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
- (await readJournal(oldPaths))?.status !== 'inactive')
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
  },