@crvouga/dev-fleet 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/state/sync.ts CHANGED
@@ -18,12 +18,29 @@ import { bucketIdentity, type Bucket } from './bucket';
18
18
  import { connection } from './connection';
19
19
  import { replicate, restore } from './litestream';
20
20
  import { databasePath, replicatedPath, stateRoot } from './paths';
21
- import { readHead, type Head } from './store';
21
+ import { managerHolder } from './holder';
22
+ import {
23
+ StateNewerError,
24
+ readHead,
25
+ readLease,
26
+ writeLease,
27
+ type Head,
28
+ type Lease,
29
+ } from './store';
30
+
31
+ export type { Lease } from './store';
22
32
 
23
33
  export interface StateContext {
24
34
  root: string;
25
35
  bucket: Bucket | null;
26
36
  run: Runner;
37
+ /** This machine, as the manager lease names it. Without one the lease is
38
+ * neither checked nor taken.
39
+ */
40
+ holder?: string | null;
41
+ /** Take the lease from another manager before it expired. */
42
+ takeOver?: boolean;
43
+ now?: () => Date;
27
44
  }
28
45
 
29
46
  export function stateContext(
@@ -34,12 +51,14 @@ export function stateContext(
34
51
  root: stateRoot(env),
35
52
  bucket: connection(env).bucket,
36
53
  run,
54
+ holder: managerHolder(env),
37
55
  };
38
56
  }
39
57
 
40
58
  /** How this machine's copy compares with the bucket.
41
59
  * - `local-only`: no bucket is configured; this copy is the only one.
42
60
  * - `unreachable`: the bucket did not answer, or did not take an upload.
61
+ * - `corrupt`: the bucket answered with a version that cannot be read.
43
62
  * - `empty`: neither side holds any state.
44
63
  * - `unpublished`: the bucket holds nothing yet; this copy can seed it.
45
64
  * - `in-sync`: both hold the same version.
@@ -50,6 +69,7 @@ export function stateContext(
50
69
  export type Standing =
51
70
  | 'local-only'
52
71
  | 'unreachable'
72
+ | 'corrupt'
53
73
  | 'empty'
54
74
  | 'unpublished'
55
75
  | 'in-sync'
@@ -109,19 +129,24 @@ const INCOMING = /^incoming-[0-9a-f-]{36}\.db/;
109
129
 
110
130
  type Remote =
111
131
  | { status: 'unreachable' }
132
+ | { status: 'corrupt' }
112
133
  | { status: 'absent' }
113
134
  | { status: 'present'; file: string; head: Head };
114
135
 
115
- /** Downloads the bucket's current version beside the local one. */
136
+ /** Downloads the bucket's current version beside the local one, or the
137
+ * version it held at `timestamp`.
138
+ */
116
139
  async function fetchRemote(
117
140
  context: StateContext & { bucket: Bucket },
118
- install = true
141
+ install = true,
142
+ timestamp?: string
119
143
  ): Promise<Remote> {
120
144
  const database = databasePath(context.root);
121
145
  const file = join(context.root, `incoming-${randomUUID()}.db`);
122
146
  const outcome = await restore(
123
147
  { database, bucket: context.bucket, run: context.run, install },
124
- file
148
+ file,
149
+ timestamp
125
150
  );
126
151
  if (outcome === 'absent') return { status: 'absent' };
127
152
  try {
@@ -130,10 +155,12 @@ async function fetchRemote(
130
155
  discard(file);
131
156
  // A replica that holds no version yet is the same as no replica.
132
157
  return { status: outcome === 'restored' ? 'absent' : 'unreachable' };
133
- } catch {
134
- // A download that cannot be read says nothing about the bucket.
158
+ } catch (error) {
135
159
  discard(file);
136
- return { status: 'unreachable' };
160
+ // A newer dev-fleet wrote it: refused, never taken for damage.
161
+ if (error instanceof StateNewerError) throw error;
162
+ // The bucket answered, and what it holds cannot be read.
163
+ return { status: 'corrupt' };
137
164
  }
138
165
  }
139
166
 
@@ -163,8 +190,8 @@ async function observe(
163
190
  const { bucket } = context;
164
191
  if (!bucket) return { standing: 'local-only', local, remote: null };
165
192
  const remote = await fetchRemote({ ...context, bucket }, install);
166
- if (remote.status === 'unreachable')
167
- return { standing: 'unreachable', local, remote };
193
+ if (remote.status === 'unreachable' || remote.status === 'corrupt')
194
+ return { standing: remote.status, local, remote };
168
195
  if (remote.status === 'absent')
169
196
  return { standing: local ? 'unpublished' : 'empty', local, remote };
170
197
  const replicated = readReplicated(context.root, bucket);
@@ -261,7 +288,9 @@ async function upload(
261
288
  */
262
289
  function settle(context: StateContext, before: Standing): Promise<Standing> {
263
290
  const { bucket } = context;
264
- if (!bucket || before === 'unreachable') return Promise.resolve(before);
291
+ // A damaged replica keeps its history for a point-in-time restore.
292
+ if (!bucket || before === 'unreachable' || before === 'corrupt')
293
+ return Promise.resolve(before);
265
294
  const head = readHead(databasePath(context.root));
266
295
  if (
267
296
  before === 'in-sync' &&
@@ -319,6 +348,40 @@ export function pull(context: StateContext): Promise<Standing> {
319
348
  return locked(context.root, () => observed(context, true));
320
349
  }
321
350
 
351
+ /** Replaces this copy with the version the bucket held at `timestamp`, then
352
+ * uploads it as the bucket's current version: the way back from a replica
353
+ * whose latest version is damaged. `unreachable` when the bucket holds no
354
+ * readable version at that time.
355
+ */
356
+ export function pullAt(
357
+ context: StateContext,
358
+ timestamp: string
359
+ ): Promise<Standing> {
360
+ if (Number.isNaN(Date.parse(timestamp)))
361
+ throw new FleetUsageError(
362
+ '--timestamp needs a time such as 2026-11-01T00:00:00Z.'
363
+ );
364
+ const { bucket } = context;
365
+ if (!bucket)
366
+ throw new FleetUsageError(
367
+ 'No bucket is configured, so there is no earlier version to restore.'
368
+ );
369
+ return locked(context.root, async () => {
370
+ const remote = await fetchRemote(
371
+ { ...context, bucket },
372
+ true,
373
+ new Date(timestamp).toISOString()
374
+ );
375
+ if (remote.status !== 'present')
376
+ return remote.status === 'absent' ? 'empty' : remote.status;
377
+ const database = databasePath(context.root);
378
+ discard(database);
379
+ renameSync(remote.file, database);
380
+ chmodSync(database, 0o600);
381
+ return upload({ ...context, bucket }, true);
382
+ });
383
+ }
384
+
322
385
  /** Uploads this copy's changes, only when the bucket has not moved since
323
386
  * this copy last matched it. A copy with no changes of its own is brought up
324
387
  * to the bucket instead; a diverged one is left alone and reported.
@@ -332,6 +395,62 @@ export function push(context: StateContext): Promise<Standing> {
332
395
  });
333
396
  }
334
397
 
398
+ /** How long a manager holds the lease after its last change. A machine that
399
+ * wakes up later than this may take it without being asked.
400
+ */
401
+ export const LEASE_DAYS = 7;
402
+ const LEASE_MS = LEASE_DAYS * 24 * 60 * 60 * 1000;
403
+
404
+ /** Another manager holds the lease: this one changes nothing. */
405
+ export class LeaseHeldError extends FleetUsageError {}
406
+
407
+ /** The lease another manager holds over this copy, when it is not expired
408
+ * and this machine was not told to take it over. Reads local files only: call
409
+ * it on the bucket's version, after a refresh.
410
+ */
411
+ export function leaseConflict(
412
+ context: StateContext,
413
+ now: Date = (context.now ?? (() => new Date()))()
414
+ ): Lease | null {
415
+ const lease = readLease(databasePath(context.root));
416
+ if (!context.holder || !lease || lease.holder === context.holder) return null;
417
+ if (Date.parse(lease.expiresAt) <= now.getTime() || context.takeOver)
418
+ return null;
419
+ return lease;
420
+ }
421
+
422
+ export function leaseHeldMessage(lease: Lease): string {
423
+ return `FLEET_MANAGER_LEASE_HELD: another manager (${lease.holder}) holds the fleet state until ${lease.expiresAt}. Nothing was changed. Run devfleet from that manager; to make this machine the manager instead, repeat with --take-over once the other one is gone (see docs/manager-recovery.md).`;
424
+ }
425
+
426
+ /** Refuses when another manager holds the lease. Call it before the first
427
+ * write of a session.
428
+ */
429
+ export function refuseHeldLease(context: StateContext): void {
430
+ const held = leaseConflict(context);
431
+ if (held) throw new LeaseHeldError(leaseHeldMessage(held));
432
+ }
433
+
434
+ /** Takes or renews the lease for this machine: when it is not this machine's
435
+ * yet, or half spent. Call it at the end of a session that may write.
436
+ */
437
+ export function claimLease(
438
+ context: StateContext,
439
+ now: Date = (context.now ?? (() => new Date()))()
440
+ ): void {
441
+ refuseHeldLease(context);
442
+ if (!context.holder) return;
443
+ const lease = readLease(databasePath(context.root));
444
+ const fresh =
445
+ lease?.holder === context.holder &&
446
+ Date.parse(lease.expiresAt) - now.getTime() > LEASE_MS / 2;
447
+ if (fresh) return;
448
+ writeLease(databasePath(context.root), {
449
+ holder: context.holder,
450
+ expiresAt: new Date(now.getTime() + LEASE_MS).toISOString(),
451
+ });
452
+ }
453
+
335
454
  export const DIVERGED_REMEDY =
336
455
  'This machine and the bucket both changed the fleet state. The bucket is the source of truth: keep a copy of anything you still need (devfleet fleet-state --export), then run devfleet fleet-state --restore --yes.';
337
456
 
@@ -346,7 +465,9 @@ export function mutateState<T>(
346
465
  return locked(context.root, async () => {
347
466
  const before = await observed(context, false);
348
467
  if (before === 'diverged') throw new FleetUsageError(DIVERGED_REMEDY);
468
+ refuseHeldLease(context);
349
469
  const value = await change(before);
470
+ claimLease(context);
350
471
  return { value, standing: await settle(context, before) };
351
472
  });
352
473
  }