@cogcoin/client 0.5.10 → 0.5.12

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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # `@cogcoin/client`
2
2
 
3
- `@cogcoin/client@0.5.10` is the store-backed Cogcoin client package for applications that want a local wallet, durable SQLite-backed state, and a managed Bitcoin Core integration around `@cogcoin/indexer`. It publishes the reusable client APIs, the SQLite adapter, the managed `bitcoind` integration, and the first-party `cogcoin` CLI in one package.
3
+ `@cogcoin/client@0.5.12` is the store-backed Cogcoin client package for applications that want a local wallet, durable SQLite-backed state, and a managed Bitcoin Core integration around `@cogcoin/indexer`. It publishes the reusable client APIs, the SQLite adapter, the managed `bitcoind` integration, and the first-party `cogcoin` CLI in one package.
4
4
 
5
5
  Use Node 22 or newer.
6
6
 
@@ -81,6 +81,8 @@ The installed `cogcoin` command covers the first-party local wallet and node wor
81
81
  - mining and hook commands such as `mine`, `mine start`, `mine stop`, `mine status`, `mine log`, `mine setup`, and `hooks status`
82
82
 
83
83
  The CLI also supports stable `--output json` and `--output preview-json` envelopes on the commands that advertise machine-readable output.
84
+ Interactive text invocations periodically check the npm registry for newer `@cogcoin/client` releases and print `npm install -g @cogcoin/client` when a newer version is available.
85
+ Set `COGCOIN_DISABLE_UPDATE_CHECK=1` to disable the CLI update notice entirely.
84
86
  Ordinary `sync`, `follow`, and wallet-aware read/status flows detach from the managed Bitcoin and indexer services on exit instead of stopping them.
85
87
  Use the explicit `bitcoin ...` and `indexer ...` commands when you want direct service inspection or start/stop control.
86
88
  For provider-backed local wallets, normal reads, mutations, export, and mining setup flows auto-materialize a local unlock session when the wallet is not explicitly locked.
@@ -39,3 +39,4 @@ export declare function resolveCogcoinAppRootForTesting(resolution?: CogcoinPath
39
39
  export declare function resolveDefaultBitcoindDataDirForTesting(resolution?: CogcoinPathResolution): string;
40
40
  export declare function resolveDefaultClientDatabasePathForTesting(resolution?: CogcoinPathResolution): string;
41
41
  export declare function resolveDefaultClientDatabaseDirectoryForTesting(resolution?: CogcoinPathResolution): string;
42
+ export declare function resolveDefaultUpdateCheckStatePathForTesting(resolution?: CogcoinPathResolution): string;
package/dist/app-paths.js CHANGED
@@ -125,3 +125,6 @@ export function resolveDefaultClientDatabaseDirectoryForTesting(resolution = {})
125
125
  const dbPath = resolveDefaultClientDatabasePathForTesting(resolution);
126
126
  return platform === "win32" ? win32Path.dirname(dbPath) : dirname(dbPath);
127
127
  }
128
+ export function resolveDefaultUpdateCheckStatePathForTesting(resolution = {}) {
129
+ return joinForPlatform(resolution.platform ?? process.platform, resolveCogcoinPathsForTesting(resolution).stateRoot, "update-check.json");
130
+ }
@@ -3,6 +3,7 @@ import { ManagedProgressController } from "../progress.js";
3
3
  import { BitcoinRpcClient } from "../rpc.js";
4
4
  import { type ManagedRpcRetryState } from "../retryable-rpc.js";
5
5
  import type { BootstrapPhase, SnapshotMetadata, SnapshotChunkManifest } from "../types.js";
6
+ type ResumeDisplayMode = "sync" | "follow";
6
7
  export declare class AssumeUtxoBootstrapController {
7
8
  #private;
8
9
  constructor(options: {
@@ -18,6 +19,7 @@ export declare class AssumeUtxoBootstrapController {
18
19
  ensureReady(indexedTip: ClientTip | null, expectedChain: "main" | "regtest", options?: {
19
20
  signal?: AbortSignal;
20
21
  retryState?: ManagedRpcRetryState;
22
+ resumeDisplayMode?: ResumeDisplayMode;
21
23
  }): Promise<void>;
22
24
  getStateForTesting(): Promise<{
23
25
  metadataVersion: number;
@@ -35,3 +37,4 @@ export declare class AssumeUtxoBootstrapController {
35
37
  updatedAt: number;
36
38
  }>;
37
39
  }
40
+ export {};
@@ -44,11 +44,13 @@ export class AssumeUtxoBootstrapController {
44
44
  return;
45
45
  }
46
46
  if (indexedTip !== null) {
47
- await this.#progress.setPhase("follow_tip", {
48
- blocks: indexedTip.height,
49
- targetHeight: indexedTip.height,
50
- message: "Resuming from the persisted Cogcoin indexed tip.",
51
- });
47
+ if ((options.resumeDisplayMode ?? "sync") === "follow") {
48
+ await this.#progress.setPhase("follow_tip", {
49
+ blocks: indexedTip.height,
50
+ targetHeight: indexedTip.height,
51
+ message: "Resuming from the persisted Cogcoin indexed tip.",
52
+ });
53
+ }
52
54
  return;
53
55
  }
54
56
  const { state, snapshotIdentity } = await this.#loadStateRecord();
@@ -1,3 +1,11 @@
1
+ import { stopIndexerDaemonService, type IndexerDaemonClient } from "../indexer-daemon.js";
1
2
  import type { InternalManagedBitcoindOptions, ManagedBitcoindClient } from "../types.js";
3
+ export declare function pauseIndexerDaemonForForegroundClientForTesting(options: {
4
+ daemon: IndexerDaemonClient;
5
+ dataDir: string;
6
+ walletRootId: string;
7
+ shutdownTimeoutMs?: number;
8
+ stopDaemon?: typeof stopIndexerDaemonService;
9
+ }): Promise<IndexerDaemonClient | null>;
2
10
  export declare function openManagedBitcoindClient(options: Omit<InternalManagedBitcoindOptions, "chain" | "startHeight">): Promise<ManagedBitcoindClient>;
3
11
  export declare function openManagedBitcoindClientInternal(options: InternalManagedBitcoindOptions): Promise<ManagedBitcoindClient>;
@@ -2,13 +2,46 @@ import { loadBundledGenesisParameters } from "@cogcoin/indexer";
2
2
  import { resolveDefaultBitcoindDataDirForTesting } from "../../app-paths.js";
3
3
  import { openClient } from "../../client.js";
4
4
  import { AssumeUtxoBootstrapController, DEFAULT_SNAPSHOT_METADATA, resolveBootstrapPathsForTesting, } from "../bootstrap.js";
5
- import { attachOrStartIndexerDaemon } from "../indexer-daemon.js";
5
+ import { attachOrStartIndexerDaemon, stopIndexerDaemonService, } from "../indexer-daemon.js";
6
6
  import { createRpcClient } from "../node.js";
7
7
  import { assertCogcoinProcessingStartHeight, resolveCogcoinProcessingStartHeight, } from "../processing-start-height.js";
8
8
  import { ManagedProgressController } from "../progress.js";
9
9
  import { attachOrStartManagedBitcoindService, } from "../service.js";
10
10
  import { DefaultManagedBitcoindClient } from "./managed-client.js";
11
11
  const DEFAULT_SYNC_DEBOUNCE_MS = 250;
12
+ function isRecoverableIndexerDaemonPauseError(error) {
13
+ if (!(error instanceof Error)) {
14
+ return false;
15
+ }
16
+ if (error.message === "indexer_daemon_request_timeout"
17
+ || error.message === "indexer_daemon_connection_closed"
18
+ || error.message === "indexer_daemon_protocol_error") {
19
+ return true;
20
+ }
21
+ if ("code" in error) {
22
+ const code = error.code;
23
+ return code === "ENOENT" || code === "ECONNREFUSED" || code === "ECONNRESET";
24
+ }
25
+ return false;
26
+ }
27
+ export async function pauseIndexerDaemonForForegroundClientForTesting(options) {
28
+ try {
29
+ await options.daemon.pauseBackgroundFollow();
30
+ return options.daemon;
31
+ }
32
+ catch (error) {
33
+ await options.daemon.close().catch(() => undefined);
34
+ if (!isRecoverableIndexerDaemonPauseError(error)) {
35
+ throw error;
36
+ }
37
+ await (options.stopDaemon ?? stopIndexerDaemonService)({
38
+ dataDir: options.dataDir,
39
+ walletRootId: options.walletRootId,
40
+ shutdownTimeoutMs: options.shutdownTimeoutMs,
41
+ });
42
+ return null;
43
+ }
44
+ }
12
45
  async function createManagedBitcoindClient(options) {
13
46
  const genesisParameters = options.genesisParameters ?? await loadBundledGenesisParameters();
14
47
  assertCogcoinProcessingStartHeight({
@@ -51,14 +84,18 @@ async function createManagedBitcoindClient(options) {
51
84
  snapshotInterval: options.snapshotInterval,
52
85
  });
53
86
  const indexerDaemon = options.databasePath
54
- ? await attachOrStartIndexerDaemon({
87
+ ? await pauseIndexerDaemonForForegroundClientForTesting({
88
+ daemon: await attachOrStartIndexerDaemon({
89
+ dataDir,
90
+ databasePath: options.databasePath,
91
+ walletRootId: options.walletRootId,
92
+ startupTimeoutMs: options.startupTimeoutMs,
93
+ }),
55
94
  dataDir,
56
- databasePath: options.databasePath,
57
- walletRootId: options.walletRootId,
58
- startupTimeoutMs: options.startupTimeoutMs,
95
+ walletRootId,
96
+ shutdownTimeoutMs: options.shutdownTimeoutMs,
59
97
  })
60
98
  : null;
61
- await indexerDaemon?.pauseBackgroundFollow();
62
99
  // The persistent service may already exist from a non-processing attach path
63
100
  // that used startHeight 0. Cogcoin replay still begins at the requested
64
101
  // processing boundary for this managed client.
@@ -292,7 +292,10 @@ export class DefaultManagedBitcoindClient {
292
292
  async #ensureBootstrapReady(signal) {
293
293
  await this.#node.validate();
294
294
  const indexedTipBeforeBootstrap = await this.#client.getTip();
295
- await this.#bootstrap.ensureReady(indexedTipBeforeBootstrap, this.#node.expectedChain, { signal });
295
+ await this.#bootstrap.ensureReady(indexedTipBeforeBootstrap, this.#node.expectedChain, {
296
+ signal,
297
+ resumeDisplayMode: this.#following ? "follow" : "sync",
298
+ });
296
299
  }
297
300
  async #restartManagedNodeWithRange(readyRange, abortSignal) {
298
301
  if (abortSignal.aborted) {
@@ -372,19 +375,25 @@ export class DefaultManagedBitcoindClient {
372
375
  }
373
376
  }
374
377
  async #resumeIndexerBackgroundFollow() {
375
- if (this.#indexerDaemon === null) {
378
+ if (this.#indexerDaemon === null && this.#reattachIndexerDaemon === null) {
376
379
  return;
377
380
  }
378
- try {
379
- await this.#indexerDaemon.resumeBackgroundFollow();
380
- return;
381
- }
382
- catch (error) {
383
- if (this.#reattachIndexerDaemon === null) {
384
- throw error;
381
+ if (this.#indexerDaemon !== null) {
382
+ try {
383
+ await this.#indexerDaemon.resumeBackgroundFollow();
384
+ return;
385
385
  }
386
+ catch (error) {
387
+ if (this.#reattachIndexerDaemon === null) {
388
+ throw error;
389
+ }
390
+ }
391
+ }
392
+ const reattachIndexerDaemon = this.#reattachIndexerDaemon;
393
+ if (reattachIndexerDaemon === null) {
394
+ return;
386
395
  }
387
- const replacementDaemon = await this.#reattachIndexerDaemon();
396
+ const replacementDaemon = await reattachIndexerDaemon();
388
397
  this.#indexerDaemon = replacementDaemon;
389
398
  await replacementDaemon?.resumeBackgroundFollow();
390
399
  }
@@ -4,6 +4,7 @@ import { normalizeRpcBlock } from "../normalize.js";
4
4
  import { MANAGED_RPC_RETRY_MESSAGE, consumeManagedRpcRetryDelayMs, createManagedRpcRetryState, describeManagedRpcRetryError, isRetryableManagedRpcError, resetManagedRpcRetryState, } from "../retryable-rpc.js";
5
5
  import { estimateEtaSeconds } from "./rate-tracker.js";
6
6
  const DEFAULT_SYNC_CATCH_UP_POLL_MS = 2_000;
7
+ const BITCOIN_SYNC_PHASE_DEBOUNCE_MS = DEFAULT_SYNC_CATCH_UP_POLL_MS * 3;
7
8
  function createAbortError(signal) {
8
9
  const reason = signal?.reason;
9
10
  if (reason instanceof Error) {
@@ -53,6 +54,22 @@ async function setBitcoinSyncProgress(dependencies, info, targetHeightCap) {
53
54
  : "Reading blocks from the managed Bitcoin node.",
54
55
  });
55
56
  }
57
+ function resolveIndexedHeightForReplayWindow(tip, startHeight) {
58
+ return tip?.height ?? (startHeight - 1);
59
+ }
60
+ function hasPendingCogcoinReplay(tip, startHeight, bestHeight) {
61
+ if (bestHeight < startHeight) {
62
+ return false;
63
+ }
64
+ return resolveIndexedHeightForReplayWindow(tip, startHeight) < bestHeight;
65
+ }
66
+ function shouldPreserveCogcoinSyncPhase(dependencies) {
67
+ const status = dependencies.progress.getStatusSnapshot();
68
+ // Keep the TTY on Cogcoin replay through brief idle gaps so it does not
69
+ // bounce back to Bitcoin sync between closely spaced replay passes.
70
+ return status.bootstrapPhase === "cogcoin_sync"
71
+ && Date.now() - status.bootstrapProgress.updatedAt < BITCOIN_SYNC_PHASE_DEBOUNCE_MS;
72
+ }
56
73
  async function setRetryingProgress(dependencies, error) {
57
74
  const status = dependencies.progress.getStatusSnapshot();
58
75
  const { phase: _phase, updatedAt: _updatedAt, ...progress } = status.bootstrapProgress;
@@ -152,6 +169,7 @@ export async function syncToTip(dependencies) {
152
169
  await runRpc(() => dependencies.bootstrap.ensureReady(indexedTipBeforeBootstrap, dependencies.node.expectedChain, {
153
170
  signal: dependencies.abortSignal,
154
171
  retryState,
172
+ resumeDisplayMode: dependencies.isFollowing() ? "follow" : "sync",
155
173
  }));
156
174
  if (dependencies.node.expectedChain === "main"
157
175
  && dependencies.node.getblockArchiveEndHeight !== null) {
@@ -175,7 +193,11 @@ export async function syncToTip(dependencies) {
175
193
  const cappedBestHeight = dependencies.targetHeightCap === null || dependencies.targetHeightCap === undefined
176
194
  ? startInfo.blocks
177
195
  : Math.min(startInfo.blocks, dependencies.targetHeightCap);
178
- await setBitcoinSyncProgress(dependencies, startInfo, dependencies.targetHeightCap ?? null);
196
+ const tipBeforePass = await dependencies.client.getTip();
197
+ if (!hasPendingCogcoinReplay(tipBeforePass, dependencies.startHeight, cappedBestHeight)
198
+ && !shouldPreserveCogcoinSyncPhase(dependencies)) {
199
+ await setBitcoinSyncProgress(dependencies, startInfo, dependencies.targetHeightCap ?? null);
200
+ }
179
201
  const pass = await syncAgainstBestHeight(dependencies, cappedBestHeight, runRpc);
180
202
  aggregate.appliedBlocks += pass.appliedBlocks;
181
203
  aggregate.rewoundBlocks += pass.rewoundBlocks;
@@ -193,8 +215,10 @@ export async function syncToTip(dependencies) {
193
215
  aggregate.endingHeight = finalTip?.height ?? null;
194
216
  aggregate.bestHeight = endBestHeight;
195
217
  aggregate.bestHashHex = endInfo.bestblockhash;
196
- if ((dependencies.targetHeightCap !== null && dependencies.targetHeightCap !== undefined && caughtUpCogcoin)
197
- || (endInfo.blocks === endInfo.headers && caughtUpCogcoin)) {
218
+ if (dependencies.targetHeightCap !== null && dependencies.targetHeightCap !== undefined && caughtUpCogcoin) {
219
+ return aggregate;
220
+ }
221
+ if (endInfo.blocks === endInfo.headers && caughtUpCogcoin) {
198
222
  if (dependencies.isFollowing()) {
199
223
  dependencies.progress.replaceFollowBlockTimes(await runRpc(() => dependencies.loadVisibleFollowBlockTimes(finalTip)));
200
224
  }
@@ -209,10 +233,12 @@ export async function syncToTip(dependencies) {
209
233
  });
210
234
  return aggregate;
211
235
  }
212
- await setBitcoinSyncProgress(dependencies, endInfo, dependencies.targetHeightCap ?? null);
213
236
  if (endBestHeight >= dependencies.startHeight && finalTip?.height !== endBestHeight) {
214
237
  continue;
215
238
  }
239
+ if (!shouldPreserveCogcoinSyncPhase(dependencies)) {
240
+ await setBitcoinSyncProgress(dependencies, endInfo, dependencies.targetHeightCap ?? null);
241
+ }
216
242
  await sleep(DEFAULT_SYNC_CATCH_UP_POLL_MS, dependencies.abortSignal);
217
243
  }
218
244
  }
@@ -144,7 +144,7 @@ export function resolveStatusFieldText(progress, snapshotHeight, now = 0) {
144
144
  case "follow_tip":
145
145
  return `Waiting for next block to be mined${animateStatusEllipsis(now)}`;
146
146
  case "complete":
147
- return `Syncing Cogcoin Blocks${animateStatusEllipsis(now)}`;
147
+ return `Sync complete${animateStatusEllipsis(now)}`;
148
148
  case "error":
149
149
  return progress.lastError === null
150
150
  ? progress.message
@@ -1,5 +1,6 @@
1
1
  export { openManagedBitcoindClientInternal } from "./client.js";
2
2
  export { DefaultManagedBitcoindClient } from "./client/managed-client.js";
3
+ export { pauseIndexerDaemonForForegroundClientForTesting } from "./client/factory.js";
3
4
  export { attachOrStartIndexerDaemon, readIndexerDaemonStatusForTesting, stopIndexerDaemonService, shutdownIndexerDaemonForTesting, } from "./indexer-daemon.js";
4
5
  export { normalizeRpcBlock } from "./normalize.js";
5
6
  export { BitcoinRpcClient } from "./rpc.js";
@@ -1,5 +1,6 @@
1
1
  export { openManagedBitcoindClientInternal } from "./client.js";
2
2
  export { DefaultManagedBitcoindClient } from "./client/managed-client.js";
3
+ export { pauseIndexerDaemonForForegroundClientForTesting } from "./client/factory.js";
3
4
  export { attachOrStartIndexerDaemon, readIndexerDaemonStatusForTesting, stopIndexerDaemonService, shutdownIndexerDaemonForTesting, } from "./indexer-daemon.js";
4
5
  export { normalizeRpcBlock } from "./normalize.js";
5
6
  export { BitcoinRpcClient } from "./rpc.js";
@@ -1,22 +1,38 @@
1
1
  import { dirname } from "node:path";
2
+ import { FileLockBusyError, acquireFileLock } from "../../wallet/fs/lock.js";
2
3
  import { resolveWalletRootIdFromLocalArtifacts } from "../../wallet/root-resolution.js";
3
4
  import { usesTtyProgress, writeLine } from "../io.js";
4
- import { classifyCliError } from "../output.js";
5
+ import { classifyCliError, formatCliTextError } from "../output.js";
5
6
  import { createStopSignalWatcher } from "../signals.js";
6
7
  export async function runFollowCommand(parsed, context) {
7
8
  const dbPath = parsed.dbPath ?? context.resolveDefaultClientDatabasePath();
8
9
  const dataDir = parsed.dataDir ?? context.resolveDefaultBitcoindDataDir();
9
- const walletRoot = await resolveWalletRootIdFromLocalArtifacts({
10
- paths: context.resolveWalletRuntimePaths(),
11
- provider: context.walletSecretProvider,
12
- loadRawWalletStateEnvelope: context.loadRawWalletStateEnvelope,
13
- loadUnlockSession: context.loadUnlockSession,
14
- loadWalletExplicitLock: context.loadWalletExplicitLock,
15
- });
16
- await context.ensureDirectory(dirname(dbPath));
17
- const store = await context.openSqliteStore({ filename: dbPath });
10
+ const runtimePaths = context.resolveWalletRuntimePaths();
11
+ let controlLock = null;
12
+ let store = null;
18
13
  let storeOwned = true;
19
14
  try {
15
+ const walletRoot = await resolveWalletRootIdFromLocalArtifacts({
16
+ paths: runtimePaths,
17
+ provider: context.walletSecretProvider,
18
+ loadRawWalletStateEnvelope: context.loadRawWalletStateEnvelope,
19
+ loadUnlockSession: context.loadUnlockSession,
20
+ loadWalletExplicitLock: context.loadWalletExplicitLock,
21
+ });
22
+ try {
23
+ controlLock = await acquireFileLock(runtimePaths.walletControlLockPath, {
24
+ purpose: "managed-follow",
25
+ walletRootId: walletRoot.walletRootId,
26
+ });
27
+ }
28
+ catch (error) {
29
+ if (error instanceof FileLockBusyError) {
30
+ throw new Error("wallet_control_lock_busy");
31
+ }
32
+ throw error;
33
+ }
34
+ await context.ensureDirectory(dirname(dbPath));
35
+ store = await context.openSqliteStore({ filename: dbPath });
20
36
  const client = await context.openManagedBitcoindClient({
21
37
  store,
22
38
  databasePath: dbPath,
@@ -25,7 +41,7 @@ export async function runFollowCommand(parsed, context) {
25
41
  progressOutput: parsed.progressOutput,
26
42
  });
27
43
  storeOwned = false;
28
- const stopWatcher = createStopSignalWatcher(context.signalSource, context.stderr, client, context.forceExit);
44
+ const stopWatcher = createStopSignalWatcher(context.signalSource, context.stderr, client, context.forceExit, [runtimePaths.walletControlLockPath]);
29
45
  try {
30
46
  await client.startFollowingTip();
31
47
  if (!usesTtyProgress(parsed.progressOutput, context.stderr)) {
@@ -43,10 +59,27 @@ export async function runFollowCommand(parsed, context) {
43
59
  }
44
60
  }
45
61
  catch (error) {
46
- writeLine(context.stderr, `follow failed: ${error instanceof Error ? error.message : String(error)}`);
47
- if (storeOwned) {
62
+ const classified = classifyCliError(error);
63
+ if (classified.errorCode === "wallet_control_lock_busy") {
64
+ const formatted = formatCliTextError(error);
65
+ if (formatted !== null) {
66
+ for (const line of formatted) {
67
+ writeLine(context.stderr, line);
68
+ }
69
+ }
70
+ else {
71
+ writeLine(context.stderr, classified.message);
72
+ }
73
+ }
74
+ else {
75
+ writeLine(context.stderr, `follow failed: ${error instanceof Error ? error.message : String(error)}`);
76
+ }
77
+ if (storeOwned && store !== null) {
48
78
  await store.close().catch(() => undefined);
49
79
  }
50
- return classifyCliError(error).exitCode;
80
+ return classified.exitCode;
81
+ }
82
+ finally {
83
+ await controlLock?.release().catch(() => undefined);
51
84
  }
52
85
  }
@@ -1,23 +1,39 @@
1
1
  import { dirname } from "node:path";
2
2
  import { formatManagedSyncErrorMessage } from "../../bitcoind/errors.js";
3
+ import { FileLockBusyError, acquireFileLock } from "../../wallet/fs/lock.js";
3
4
  import { resolveWalletRootIdFromLocalArtifacts } from "../../wallet/root-resolution.js";
4
5
  import { writeLine } from "../io.js";
5
- import { classifyCliError } from "../output.js";
6
+ import { classifyCliError, formatCliTextError } from "../output.js";
6
7
  import { createStopSignalWatcher, waitForCompletionOrStop } from "../signals.js";
7
8
  export async function runSyncCommand(parsed, context) {
8
9
  const dbPath = parsed.dbPath ?? context.resolveDefaultClientDatabasePath();
9
10
  const dataDir = parsed.dataDir ?? context.resolveDefaultBitcoindDataDir();
10
- const walletRoot = await resolveWalletRootIdFromLocalArtifacts({
11
- paths: context.resolveWalletRuntimePaths(),
12
- provider: context.walletSecretProvider,
13
- loadRawWalletStateEnvelope: context.loadRawWalletStateEnvelope,
14
- loadUnlockSession: context.loadUnlockSession,
15
- loadWalletExplicitLock: context.loadWalletExplicitLock,
16
- });
17
- await context.ensureDirectory(dirname(dbPath));
18
- const store = await context.openSqliteStore({ filename: dbPath });
11
+ const runtimePaths = context.resolveWalletRuntimePaths();
12
+ let controlLock = null;
13
+ let store = null;
19
14
  let storeOwned = true;
20
15
  try {
16
+ const walletRoot = await resolveWalletRootIdFromLocalArtifacts({
17
+ paths: runtimePaths,
18
+ provider: context.walletSecretProvider,
19
+ loadRawWalletStateEnvelope: context.loadRawWalletStateEnvelope,
20
+ loadUnlockSession: context.loadUnlockSession,
21
+ loadWalletExplicitLock: context.loadWalletExplicitLock,
22
+ });
23
+ try {
24
+ controlLock = await acquireFileLock(runtimePaths.walletControlLockPath, {
25
+ purpose: "managed-sync",
26
+ walletRootId: walletRoot.walletRootId,
27
+ });
28
+ }
29
+ catch (error) {
30
+ if (error instanceof FileLockBusyError) {
31
+ throw new Error("wallet_control_lock_busy");
32
+ }
33
+ throw error;
34
+ }
35
+ await context.ensureDirectory(dirname(dbPath));
36
+ store = await context.openSqliteStore({ filename: dbPath });
21
37
  const client = await context.openManagedBitcoindClient({
22
38
  store,
23
39
  databasePath: dbPath,
@@ -26,7 +42,7 @@ export async function runSyncCommand(parsed, context) {
26
42
  progressOutput: parsed.progressOutput,
27
43
  });
28
44
  storeOwned = false;
29
- const stopWatcher = createStopSignalWatcher(context.signalSource, context.stderr, client, context.forceExit);
45
+ const stopWatcher = createStopSignalWatcher(context.signalSource, context.stderr, client, context.forceExit, [runtimePaths.walletControlLockPath]);
30
46
  try {
31
47
  const syncOutcome = await waitForCompletionOrStop(client.syncToTip(), stopWatcher);
32
48
  if (syncOutcome.kind === "stopped") {
@@ -51,11 +67,28 @@ export async function runSyncCommand(parsed, context) {
51
67
  }
52
68
  }
53
69
  catch (error) {
54
- const message = formatManagedSyncErrorMessage(error instanceof Error ? error.message : String(error));
55
- writeLine(context.stderr, `sync failed: ${message}`);
56
- if (storeOwned) {
70
+ const classified = classifyCliError(error);
71
+ if (classified.errorCode === "wallet_control_lock_busy") {
72
+ const formatted = formatCliTextError(error);
73
+ if (formatted !== null) {
74
+ for (const line of formatted) {
75
+ writeLine(context.stderr, line);
76
+ }
77
+ }
78
+ else {
79
+ writeLine(context.stderr, classified.message);
80
+ }
81
+ }
82
+ else {
83
+ const message = formatManagedSyncErrorMessage(error instanceof Error ? error.message : String(error));
84
+ writeLine(context.stderr, `sync failed: ${message}`);
85
+ }
86
+ if (storeOwned && store !== null) {
57
87
  await store.close().catch(() => undefined);
58
88
  }
59
- return classifyCliError(error).exitCode;
89
+ return classified.exitCode;
90
+ }
91
+ finally {
92
+ await controlLock?.release().catch(() => undefined);
60
93
  }
61
94
  }
@@ -2,7 +2,7 @@ import { mkdir, readFile } from "node:fs/promises";
2
2
  import { attachOrStartIndexerDaemon, probeIndexerDaemon, readObservedIndexerDaemonStatus, stopIndexerDaemonService, } from "../bitcoind/indexer-daemon.js";
3
3
  import { createRpcClient } from "../bitcoind/node.js";
4
4
  import { attachOrStartManagedBitcoindService, probeManagedBitcoindService, stopManagedBitcoindService, } from "../bitcoind/service.js";
5
- import { resolveDefaultBitcoindDataDirForTesting, resolveDefaultClientDatabasePathForTesting, } from "../app-paths.js";
5
+ import { resolveDefaultBitcoindDataDirForTesting, resolveDefaultClientDatabasePathForTesting, resolveDefaultUpdateCheckStatePathForTesting, } from "../app-paths.js";
6
6
  import { openManagedBitcoindClient } from "../bitcoind/index.js";
7
7
  import { inspectPassiveClientStatus } from "../passive-status.js";
8
8
  import { openSqliteStore } from "../sqlite/index.js";
@@ -26,10 +26,13 @@ export function createDefaultContext(overrides = {}) {
26
26
  stdout: overrides.stdout ?? process.stdout,
27
27
  stderr: overrides.stderr ?? process.stderr,
28
28
  stdin: overrides.stdin ?? process.stdin,
29
+ env: overrides.env ?? process.env,
30
+ now: overrides.now ?? (() => Date.now()),
29
31
  signalSource: overrides.signalSource ?? process,
30
32
  forceExit: overrides.forceExit ?? ((code) => {
31
33
  process.exit(code);
32
34
  }),
35
+ fetchImpl: overrides.fetchImpl ?? fetch,
33
36
  openSqliteStore: overrides.openSqliteStore ?? openSqliteStore,
34
37
  openManagedBitcoindClient: overrides.openManagedBitcoindClient ?? openManagedBitcoindClient,
35
38
  inspectPassiveClientStatus: overrides.inspectPassiveClientStatus ?? inspectPassiveClientStatus,
@@ -96,6 +99,7 @@ export function createDefaultContext(overrides = {}) {
96
99
  loadWalletExplicitLock: overrides.loadWalletExplicitLock ?? loadWalletExplicitLock,
97
100
  resolveDefaultBitcoindDataDir: overrides.resolveDefaultBitcoindDataDir ?? resolveDefaultBitcoindDataDirForTesting,
98
101
  resolveDefaultClientDatabasePath: overrides.resolveDefaultClientDatabasePath ?? resolveDefaultClientDatabasePathForTesting,
102
+ resolveUpdateCheckStatePath: overrides.resolveUpdateCheckStatePath ?? resolveDefaultUpdateCheckStatePathForTesting,
99
103
  resolveWalletRuntimePaths: overrides.resolveWalletRuntimePaths ?? ((seedName) => resolveWalletRuntimePathsForTesting({ seedName })),
100
104
  };
101
105
  }
@@ -187,6 +187,10 @@ export function classifyCliError(error) {
187
187
  return { exitCode: 5, errorCode: message, message };
188
188
  }
189
189
  function isBlockedError(message) {
190
+ if (message === "wallet_control_lock_busy"
191
+ || message.startsWith("file_lock_busy_")) {
192
+ return true;
193
+ }
190
194
  if (message === "wallet_locked"
191
195
  || message === "wallet_uninitialized"
192
196
  || message === "local-state-corrupt"
@@ -239,6 +243,13 @@ export function createCliErrorPresentation(errorCode, fallbackMessage) {
239
243
  next: "Run `cogcoin unlock --for 15m` and retry.",
240
244
  };
241
245
  }
246
+ if (errorCode === "wallet_control_lock_busy") {
247
+ return {
248
+ what: "Another Cogcoin command is already controlling this wallet.",
249
+ why: "Commands that sync, follow, or mutate the local index take an exclusive wallet control lock so they do not write the same sqlite store concurrently.",
250
+ next: "Wait for the other Cogcoin command to finish, or stop it cleanly before retrying.",
251
+ };
252
+ }
242
253
  if (errorCode === "reset_wallet_choice_invalid") {
243
254
  return {
244
255
  what: "Wallet reset choice is invalid.",
@@ -12,6 +12,7 @@ import { runSyncCommand } from "./commands/sync.js";
12
12
  import { runWalletAdminCommand } from "./commands/wallet-admin.js";
13
13
  import { runWalletMutationCommand } from "./commands/wallet-mutation.js";
14
14
  import { runWalletReadCommand } from "./commands/wallet-read.js";
15
+ import { maybeNotifyAboutCliUpdate } from "./update-notifier.js";
15
16
  import { findWalletSeedRecord, loadWalletSeedIndex } from "../wallet/state/seed-index.js";
16
17
  function commandUsesExistingWalletSeed(parsed) {
17
18
  return parsed.seedName !== null
@@ -48,6 +49,7 @@ export async function runCli(argv, contextOverrides = {}) {
48
49
  writeLine(context.stdout, HELP_TEXT.trimEnd());
49
50
  return parsed.help ? 0 : 2;
50
51
  }
52
+ await maybeNotifyAboutCliUpdate(parsed, context);
51
53
  try {
52
54
  if (commandUsesExistingWalletSeed(parsed)) {
53
55
  const mainPaths = context.resolveWalletRuntimePaths("main");
@@ -1,4 +1,4 @@
1
1
  import type { InterruptibleOutcome, ManagedClientLike, SignalSource, StopSignalWatcher, WritableLike } from "./types.js";
2
- export declare function createStopSignalWatcher(signalSource: SignalSource, stderr: WritableLike, client: ManagedClientLike, forceExit: (code: number) => never | void): StopSignalWatcher;
2
+ export declare function createStopSignalWatcher(signalSource: SignalSource, stderr: WritableLike, client: ManagedClientLike, forceExit: (code: number) => never | void, lockPaths?: readonly string[]): StopSignalWatcher;
3
3
  export declare function createOwnedLockCleanupSignalWatcher(signalSource: SignalSource, forceExit: (code: number) => never | void, lockPaths: readonly string[]): StopSignalWatcher;
4
4
  export declare function waitForCompletionOrStop<T>(promise: Promise<T>, stopWatcher: StopSignalWatcher): Promise<InterruptibleOutcome<T>>;
@@ -1,6 +1,6 @@
1
1
  import { writeLine } from "./io.js";
2
2
  import { clearLockIfOwnedByCurrentProcess } from "../wallet/fs/lock.js";
3
- export function createStopSignalWatcher(signalSource, stderr, client, forceExit) {
3
+ export function createStopSignalWatcher(signalSource, stderr, client, forceExit, lockPaths = []) {
4
4
  let closing = false;
5
5
  let resolved = false;
6
6
  let onSignal = () => { };
@@ -17,16 +17,23 @@ export function createStopSignalWatcher(signalSource, stderr, client, forceExit)
17
17
  cleanup();
18
18
  resolve(code);
19
19
  };
20
+ const releaseOwnedLocks = async () => {
21
+ await Promise.allSettled([...new Set(lockPaths)].map(async (lockPath) => {
22
+ await clearLockIfOwnedByCurrentProcess(lockPath);
23
+ }));
24
+ };
20
25
  const onFirstSignal = () => {
21
26
  closing = true;
22
27
  writeLine(stderr, "Detaching from managed Cogcoin client and resuming background indexer follow...");
23
- void client.close().then(() => {
28
+ void client.close().then(async () => {
29
+ await releaseOwnedLocks();
24
30
  if (resolved) {
25
31
  return;
26
32
  }
27
33
  writeLine(stderr, "Detached cleanly; background indexer follow resumed.");
28
34
  settle(0);
29
- }, () => {
35
+ }, async () => {
36
+ await releaseOwnedLocks();
30
37
  if (resolved) {
31
38
  return;
32
39
  }
@@ -40,7 +47,13 @@ export function createStopSignalWatcher(signalSource, stderr, client, forceExit)
40
47
  return;
41
48
  }
42
49
  settle(130);
43
- forceExit(130);
50
+ if (lockPaths.length === 0) {
51
+ forceExit(130);
52
+ return;
53
+ }
54
+ void releaseOwnedLocks().finally(() => {
55
+ forceExit(130);
56
+ });
44
57
  };
45
58
  });
46
59
  signalSource.on("SIGINT", onSignal);
@@ -87,8 +87,11 @@ export interface CliRunnerContext {
87
87
  stdout?: WritableLike;
88
88
  stderr?: WritableLike;
89
89
  stdin?: ReadableLike;
90
+ env?: NodeJS.ProcessEnv;
91
+ now?: () => number;
90
92
  signalSource?: SignalSource;
91
93
  forceExit?: (code: number) => never | void;
94
+ fetchImpl?: typeof fetch;
92
95
  openSqliteStore?: typeof openSqliteStore;
93
96
  openManagedBitcoindClient?: (options: {
94
97
  store: ClientStoreAdapter;
@@ -163,6 +166,7 @@ export interface CliRunnerContext {
163
166
  readPackageVersion?: () => Promise<string>;
164
167
  resolveDefaultBitcoindDataDir?: () => string;
165
168
  resolveDefaultClientDatabasePath?: () => string;
169
+ resolveUpdateCheckStatePath?: () => string;
166
170
  resolveWalletRuntimePaths?: (seedName?: string | null) => WalletRuntimePaths;
167
171
  }
168
172
  export interface StopSignalWatcher {
@@ -0,0 +1,2 @@
1
+ import type { ParsedCliArgs, RequiredCliRunnerContext } from "./types.js";
2
+ export declare function maybeNotifyAboutCliUpdate(parsed: ParsedCliArgs, context: RequiredCliRunnerContext): Promise<void>;
@@ -0,0 +1,276 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { writeJsonFileAtomic } from "../wallet/fs/atomic.js";
3
+ import { writeLine } from "./io.js";
4
+ const UPDATE_CHECK_CACHE_SCHEMA_VERSION = 1;
5
+ const UPDATE_CHECK_MAX_AGE_MS = 24 * 60 * 60 * 1000;
6
+ const UPDATE_CHECK_TIMEOUT_MS = 500;
7
+ const UPDATE_CHECK_URL = "https://registry.npmjs.org/@cogcoin/client/latest";
8
+ function createEmptyUpdateCheckCache() {
9
+ return {
10
+ schemaVersion: UPDATE_CHECK_CACHE_SCHEMA_VERSION,
11
+ lastCheckedAtUnixMs: 0,
12
+ latestVersion: null,
13
+ lastNotifiedCurrentVersion: null,
14
+ lastNotifiedLatestVersion: null,
15
+ lastNotifiedAtUnixMs: null,
16
+ };
17
+ }
18
+ function parseSemver(version) {
19
+ const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/.exec(version.trim());
20
+ if (match === null) {
21
+ return null;
22
+ }
23
+ const prerelease = match[4] === undefined
24
+ ? []
25
+ : match[4].split(".").map((raw) => ({
26
+ raw,
27
+ numeric: /^(0|[1-9]\d*)$/.test(raw),
28
+ numericValue: /^(0|[1-9]\d*)$/.test(raw) ? Number(raw) : null,
29
+ }));
30
+ return {
31
+ major: Number(match[1]),
32
+ minor: Number(match[2]),
33
+ patch: Number(match[3]),
34
+ prerelease,
35
+ };
36
+ }
37
+ function compareSemver(left, right) {
38
+ const leftParsed = parseSemver(left);
39
+ const rightParsed = parseSemver(right);
40
+ if (leftParsed === null || rightParsed === null) {
41
+ return null;
42
+ }
43
+ if (leftParsed.major !== rightParsed.major) {
44
+ return leftParsed.major > rightParsed.major ? 1 : -1;
45
+ }
46
+ if (leftParsed.minor !== rightParsed.minor) {
47
+ return leftParsed.minor > rightParsed.minor ? 1 : -1;
48
+ }
49
+ if (leftParsed.patch !== rightParsed.patch) {
50
+ return leftParsed.patch > rightParsed.patch ? 1 : -1;
51
+ }
52
+ if (leftParsed.prerelease.length === 0 && rightParsed.prerelease.length === 0) {
53
+ return 0;
54
+ }
55
+ if (leftParsed.prerelease.length === 0) {
56
+ return 1;
57
+ }
58
+ if (rightParsed.prerelease.length === 0) {
59
+ return -1;
60
+ }
61
+ const maxLength = Math.max(leftParsed.prerelease.length, rightParsed.prerelease.length);
62
+ for (let index = 0; index < maxLength; index += 1) {
63
+ const leftIdentifier = leftParsed.prerelease[index];
64
+ const rightIdentifier = rightParsed.prerelease[index];
65
+ if (leftIdentifier === undefined) {
66
+ return -1;
67
+ }
68
+ if (rightIdentifier === undefined) {
69
+ return 1;
70
+ }
71
+ if (leftIdentifier.numeric && rightIdentifier.numeric) {
72
+ if (leftIdentifier.numericValue !== rightIdentifier.numericValue) {
73
+ return leftIdentifier.numericValue > rightIdentifier.numericValue ? 1 : -1;
74
+ }
75
+ continue;
76
+ }
77
+ if (leftIdentifier.numeric !== rightIdentifier.numeric) {
78
+ return leftIdentifier.numeric ? -1 : 1;
79
+ }
80
+ if (leftIdentifier.raw !== rightIdentifier.raw) {
81
+ return leftIdentifier.raw > rightIdentifier.raw ? 1 : -1;
82
+ }
83
+ }
84
+ return 0;
85
+ }
86
+ function isUpdateCheckDisabled(env) {
87
+ const raw = env.COGCOIN_DISABLE_UPDATE_CHECK;
88
+ if (raw === undefined) {
89
+ return false;
90
+ }
91
+ const normalized = raw.trim().toLowerCase();
92
+ return normalized === "1" || normalized === "true" || normalized === "yes";
93
+ }
94
+ function isEligibleForUpdateNotification(parsed, context) {
95
+ if (parsed.outputMode !== "text" || parsed.help || parsed.version) {
96
+ return false;
97
+ }
98
+ return context.stdout.isTTY === true || context.stderr.isTTY === true;
99
+ }
100
+ function normalizeUpdateCheckCache(parsed) {
101
+ if (typeof parsed !== "object" || parsed === null) {
102
+ return null;
103
+ }
104
+ const candidate = parsed;
105
+ if (candidate.schemaVersion !== UPDATE_CHECK_CACHE_SCHEMA_VERSION) {
106
+ return null;
107
+ }
108
+ return {
109
+ schemaVersion: UPDATE_CHECK_CACHE_SCHEMA_VERSION,
110
+ lastCheckedAtUnixMs: typeof candidate.lastCheckedAtUnixMs === "number" ? candidate.lastCheckedAtUnixMs : 0,
111
+ latestVersion: typeof candidate.latestVersion === "string" ? candidate.latestVersion : null,
112
+ lastNotifiedCurrentVersion: typeof candidate.lastNotifiedCurrentVersion === "string"
113
+ ? candidate.lastNotifiedCurrentVersion
114
+ : null,
115
+ lastNotifiedLatestVersion: typeof candidate.lastNotifiedLatestVersion === "string"
116
+ ? candidate.lastNotifiedLatestVersion
117
+ : null,
118
+ lastNotifiedAtUnixMs: typeof candidate.lastNotifiedAtUnixMs === "number"
119
+ ? candidate.lastNotifiedAtUnixMs
120
+ : null,
121
+ lastCheckErrorKind: typeof candidate.lastCheckErrorKind === "string"
122
+ ? candidate.lastCheckErrorKind
123
+ : undefined,
124
+ };
125
+ }
126
+ async function loadUpdateCheckCache(cachePath) {
127
+ try {
128
+ const raw = await readFile(cachePath, "utf8");
129
+ return normalizeUpdateCheckCache(JSON.parse(raw));
130
+ }
131
+ catch (error) {
132
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") {
133
+ return null;
134
+ }
135
+ return null;
136
+ }
137
+ }
138
+ function shouldRefreshUpdateCheck(cache, now) {
139
+ return now - cache.lastCheckedAtUnixMs >= UPDATE_CHECK_MAX_AGE_MS;
140
+ }
141
+ function shouldNotifyForVersionPair(cache, currentVersion, latestVersion, now) {
142
+ const versionComparison = compareSemver(latestVersion, currentVersion);
143
+ if (versionComparison === null || versionComparison <= 0) {
144
+ return false;
145
+ }
146
+ if (cache.lastNotifiedCurrentVersion !== currentVersion
147
+ || cache.lastNotifiedLatestVersion !== latestVersion) {
148
+ return true;
149
+ }
150
+ if (cache.lastNotifiedAtUnixMs === null) {
151
+ return true;
152
+ }
153
+ return now - cache.lastNotifiedAtUnixMs >= UPDATE_CHECK_MAX_AGE_MS;
154
+ }
155
+ function recordUpdateNotification(cache, currentVersion, latestVersion, now) {
156
+ return {
157
+ ...cache,
158
+ lastNotifiedCurrentVersion: currentVersion,
159
+ lastNotifiedLatestVersion: latestVersion,
160
+ lastNotifiedAtUnixMs: now,
161
+ };
162
+ }
163
+ async function fetchLatestPublishedVersion(fetchImpl) {
164
+ const controller = new AbortController();
165
+ const timer = setTimeout(() => {
166
+ controller.abort();
167
+ }, UPDATE_CHECK_TIMEOUT_MS);
168
+ try {
169
+ const response = await fetchImpl(UPDATE_CHECK_URL, {
170
+ headers: {
171
+ accept: "application/json",
172
+ },
173
+ signal: controller.signal,
174
+ });
175
+ if (!response.ok) {
176
+ return {
177
+ kind: "failure",
178
+ errorKind: `http_${response.status}`,
179
+ };
180
+ }
181
+ let payload;
182
+ try {
183
+ payload = await response.json();
184
+ }
185
+ catch {
186
+ return {
187
+ kind: "failure",
188
+ errorKind: "invalid_json",
189
+ };
190
+ }
191
+ const latestVersion = typeof payload.version === "string"
192
+ ? payload.version
193
+ : null;
194
+ if (latestVersion === null) {
195
+ return {
196
+ kind: "failure",
197
+ errorKind: "invalid_payload",
198
+ };
199
+ }
200
+ if (parseSemver(latestVersion) === null) {
201
+ return {
202
+ kind: "failure",
203
+ errorKind: "invalid_semver",
204
+ };
205
+ }
206
+ return {
207
+ kind: "success",
208
+ latestVersion,
209
+ };
210
+ }
211
+ catch (error) {
212
+ if (error instanceof Error && error.name === "AbortError") {
213
+ return {
214
+ kind: "failure",
215
+ errorKind: "timeout",
216
+ };
217
+ }
218
+ return {
219
+ kind: "failure",
220
+ errorKind: "network",
221
+ };
222
+ }
223
+ finally {
224
+ clearTimeout(timer);
225
+ }
226
+ }
227
+ function writeUpdateNotice(context, currentVersion, latestVersion) {
228
+ writeLine(context.stderr, `Update available: Cogcoin ${currentVersion} -> ${latestVersion}`);
229
+ writeLine(context.stderr, "Run: npm install -g @cogcoin/client");
230
+ }
231
+ async function persistUpdateCheckCache(cachePath, cache) {
232
+ await writeJsonFileAtomic(cachePath, cache);
233
+ }
234
+ export async function maybeNotifyAboutCliUpdate(parsed, context) {
235
+ try {
236
+ if (!isEligibleForUpdateNotification(parsed, context) || isUpdateCheckDisabled(context.env)) {
237
+ return;
238
+ }
239
+ const currentVersion = await context.readPackageVersion();
240
+ if (parseSemver(currentVersion) === null) {
241
+ return;
242
+ }
243
+ const cachePath = context.resolveUpdateCheckStatePath();
244
+ const now = context.now();
245
+ let cache = await loadUpdateCheckCache(cachePath) ?? createEmptyUpdateCheckCache();
246
+ let cacheChanged = false;
247
+ if (shouldRefreshUpdateCheck(cache, now)) {
248
+ const updateResult = await fetchLatestPublishedVersion(context.fetchImpl);
249
+ cache = {
250
+ ...cache,
251
+ lastCheckedAtUnixMs: now,
252
+ latestVersion: updateResult.kind === "success"
253
+ ? updateResult.latestVersion
254
+ : cache.latestVersion,
255
+ lastCheckErrorKind: updateResult.kind === "success"
256
+ ? undefined
257
+ : updateResult.errorKind,
258
+ };
259
+ cacheChanged = true;
260
+ }
261
+ if (cache.latestVersion !== null
262
+ && shouldNotifyForVersionPair(cache, currentVersion, cache.latestVersion, now)) {
263
+ writeUpdateNotice(context, currentVersion, cache.latestVersion);
264
+ cache = recordUpdateNotification(cache, currentVersion, cache.latestVersion, now);
265
+ cacheChanged = true;
266
+ await persistUpdateCheckCache(cachePath, cache);
267
+ return;
268
+ }
269
+ if (cacheChanged) {
270
+ await persistUpdateCheckCache(cachePath, cache);
271
+ }
272
+ }
273
+ catch {
274
+ // Update checks are best-effort only.
275
+ }
276
+ }
@@ -2,7 +2,7 @@ import type { BitcoinBlock, GenesisParameters, IndexerState } from "@cogcoin/ind
2
2
  import type { ApplyBlockResult, Client, ClientStoreAdapter, ClientTip } from "../types.js";
3
3
  export declare class DefaultClient implements Client {
4
4
  #private;
5
- constructor(store: ClientStoreAdapter, genesisParameters: GenesisParameters, state: IndexerState, tip: ClientTip | null, snapshotInterval: number);
5
+ constructor(store: ClientStoreAdapter, genesisParameters: GenesisParameters, state: IndexerState, tip: ClientTip | null, snapshotInterval: number, blockRecordRetention: number);
6
6
  getTip(): Promise<ClientTip | null>;
7
7
  getState(): Promise<IndexerState>;
8
8
  applyBlock(block: BitcoinBlock): Promise<ApplyBlockResult>;
@@ -4,16 +4,18 @@ export class DefaultClient {
4
4
  #store;
5
5
  #genesisParameters;
6
6
  #snapshotInterval;
7
+ #blockRecordRetention;
7
8
  #state;
8
9
  #tip;
9
10
  #closed = false;
10
11
  #queue = Promise.resolve();
11
- constructor(store, genesisParameters, state, tip, snapshotInterval) {
12
+ constructor(store, genesisParameters, state, tip, snapshotInterval, blockRecordRetention) {
12
13
  this.#store = store;
13
14
  this.#genesisParameters = genesisParameters;
14
15
  this.#state = state;
15
16
  this.#tip = tip;
16
17
  this.#snapshotInterval = snapshotInterval;
18
+ this.#blockRecordRetention = blockRecordRetention;
17
19
  }
18
20
  async getTip() {
19
21
  await this.#queue;
@@ -39,6 +41,7 @@ export class DefaultClient {
39
41
  blockRecord: createStoredBlockRecord(applied.blockRecord, createdAt),
40
42
  checkpoint,
41
43
  deleteAboveHeight: null,
44
+ deleteBelowHeight: this.#blockRecordLowerBound(block.height),
42
45
  };
43
46
  await this.#store.writeAppliedBlock(writeEntry);
44
47
  this.#state = applied.state;
@@ -110,6 +113,9 @@ export class DefaultClient {
110
113
  #shouldCheckpoint(height) {
111
114
  return this.#snapshotInterval > 0 && height % this.#snapshotInterval === 0;
112
115
  }
116
+ #blockRecordLowerBound(height) {
117
+ return height - this.#blockRecordRetention + 1;
118
+ }
113
119
  #enqueue(operation) {
114
120
  const next = this.#queue.then(operation, operation);
115
121
  this.#queue = next.then(() => undefined, () => undefined);
@@ -3,13 +3,18 @@ import { DefaultClient } from "./default-client.js";
3
3
  import { initializeState } from "./initialization.js";
4
4
  import { createClientStoreAdapter } from "./store-adapter.js";
5
5
  const DEFAULT_SNAPSHOT_INTERVAL = 1000;
6
+ const DEFAULT_BLOCK_RECORD_RETENTION = 1000;
6
7
  export async function openClient(options) {
7
8
  const store = createClientStoreAdapter(options.store);
8
9
  const genesisParameters = options.genesisParameters ?? await loadBundledGenesisParameters();
9
10
  const snapshotInterval = options.snapshotInterval ?? DEFAULT_SNAPSHOT_INTERVAL;
11
+ const blockRecordRetention = options.blockRecordRetention ?? DEFAULT_BLOCK_RECORD_RETENTION;
10
12
  if (!Number.isInteger(snapshotInterval) || snapshotInterval < 1) {
11
13
  throw new RangeError("client_snapshot_interval_invalid");
12
14
  }
15
+ if (!Number.isInteger(blockRecordRetention) || blockRecordRetention < 1) {
16
+ throw new RangeError("client_block_record_retention_invalid");
17
+ }
13
18
  const { state, tip } = await initializeState(store, genesisParameters);
14
- return new DefaultClient(store, genesisParameters, state, tip, snapshotInterval);
19
+ return new DefaultClient(store, genesisParameters, state, tip, snapshotInterval, blockRecordRetention);
15
20
  }
@@ -66,6 +66,9 @@ export function createSqliteStoreAdapter(database) {
66
66
  await database.run(`DELETE FROM block_records WHERE height > ?`, [entry.deleteAboveHeight]);
67
67
  await deleteCheckpointsAbove(database, entry.deleteAboveHeight);
68
68
  }
69
+ if (entry.deleteBelowHeight !== null && entry.deleteBelowHeight !== undefined) {
70
+ await database.run(`DELETE FROM block_records WHERE height < ?`, [entry.deleteBelowHeight]);
71
+ }
69
72
  if (entry.blockRecord !== null && entry.blockRecord !== undefined) {
70
73
  await database.run(`INSERT INTO block_records (height, block_hash, previous_hash, record_bytes, state_hash_hex, created_at)
71
74
  VALUES (?, ?, ?, ?, ?, ?)`, [
package/dist/types.d.ts CHANGED
@@ -25,6 +25,7 @@ export interface WriteAppliedBlockEntry {
25
25
  blockRecord?: StoredBlockRecord | null;
26
26
  checkpoint?: ClientCheckpoint | null;
27
27
  deleteAboveHeight?: number | null;
28
+ deleteBelowHeight?: number | null;
28
29
  }
29
30
  export interface ClientStoreAdapter {
30
31
  loadTip(): Promise<ClientTip | null>;
@@ -39,6 +40,7 @@ export interface ClientOptions {
39
40
  store: ClientStoreAdapter;
40
41
  genesisParameters?: GenesisParameters;
41
42
  snapshotInterval?: number;
43
+ blockRecordRetention?: number;
42
44
  }
43
45
  export interface ApplyBlockResult {
44
46
  tip: ClientTip;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cogcoin/client",
3
- "version": "0.5.10",
3
+ "version": "0.5.12",
4
4
  "description": "Store-backed Cogcoin client with wallet flows, SQLite persistence, and managed Bitcoin Core integration.",
5
5
  "license": "MIT",
6
6
  "type": "module",