@lifestreamdynamics/vault-cli 1.3.10 → 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,7 +2,7 @@ import chalk from 'chalk';
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import os from 'node:os';
5
- import { AuditLogger } from '@lifestreamdynamics/vault-sdk';
5
+ import { AuditLogger } from '@lifestreamdynamics/vault-sdk/audit';
6
6
  import { addGlobalFlags, resolveFlags } from '../utils/flags.js';
7
7
  import { createOutput, handleError } from '../utils/output.js';
8
8
  const DEFAULT_LOG_PATH = path.join(os.homedir(), '.lsvault', 'audit.log');
@@ -9,7 +9,7 @@ import { formatUptime } from '../utils/format.js';
9
9
  import { loadSyncConfigs, createSyncConfig, deleteSyncConfig, getSyncConfig, } from '../sync/config.js';
10
10
  import { deleteSyncState, loadSyncState, saveSyncState, hashFileContent, buildRemoteFileState } from '../sync/state.js';
11
11
  import { resolveIgnorePatterns } from '../sync/ignore.js';
12
- import { scanLocalFiles, scanRemoteFiles, executePull, executePush, computePullDiff, computePushDiff, } from '../sync/engine.js';
12
+ import { scanLocalFiles, scanRemoteFiles, executePull, executePush, computePullDiff, computePushDiff, resolveConcurrency, } from '../sync/engine.js';
13
13
  import { formatDiff } from '../sync/diff.js';
14
14
  import { createWatcher } from '../sync/watcher.js';
15
15
  import { createRemotePoller } from '../sync/remote-poller.js';
@@ -153,7 +153,8 @@ Sync modes:
153
153
  // sync pull <syncId>
154
154
  addGlobalFlags(sync.command('pull')
155
155
  .description('Pull remote changes to local directory')
156
- .argument('<syncId>', 'Sync configuration ID'))
156
+ .argument('<syncId>', 'Sync configuration ID')
157
+ .option('--concurrency <n>', 'Max concurrent file transfers (1-16, default 4)', (v) => parseInt(v, 10)))
157
158
  .action(async (syncId, _opts) => {
158
159
  const flags = resolveFlags(_opts);
159
160
  const out = createOutput(flags);
@@ -211,12 +212,13 @@ Sync modes:
211
212
  if (flags.verbose) {
212
213
  out.status(formatDiff(diff));
213
214
  }
215
+ const concurrency = resolveConcurrency(_opts.concurrency);
214
216
  out.startSpinner(`Pulling ${totalOps} file(s)...`);
215
217
  const result = await executePull(client, config, diff, (progress) => {
216
218
  if (progress.phase === 'transferring' && progress.currentFile) {
217
219
  out.startSpinner(`[${progress.current}/${progress.total}] ${progress.currentFile}`);
218
220
  }
219
- });
221
+ }, concurrency);
220
222
  if (result.errors.length > 0) {
221
223
  out.failSpinner(`Pull completed with ${result.errors.length} error(s)`);
222
224
  for (const err of result.errors) {
@@ -241,7 +243,8 @@ Sync modes:
241
243
  // sync push <syncId>
242
244
  addGlobalFlags(sync.command('push')
243
245
  .description('Push local changes to remote vault')
244
- .argument('<syncId>', 'Sync configuration ID'))
246
+ .argument('<syncId>', 'Sync configuration ID')
247
+ .option('--concurrency <n>', 'Max concurrent file transfers (1-16, default 4)', (v) => parseInt(v, 10)))
245
248
  .action(async (syncId, _opts) => {
246
249
  const flags = resolveFlags(_opts);
247
250
  const out = createOutput(flags);
@@ -299,12 +302,13 @@ Sync modes:
299
302
  if (flags.verbose) {
300
303
  out.status(formatDiff(diff));
301
304
  }
305
+ const concurrency = resolveConcurrency(_opts.concurrency);
302
306
  out.startSpinner(`Pushing ${totalOps} file(s)...`);
303
307
  const result = await executePush(client, config, diff, (progress) => {
304
308
  if (progress.phase === 'transferring' && progress.currentFile) {
305
309
  out.startSpinner(`[${progress.current}/${progress.total}] ${progress.currentFile}`);
306
310
  }
307
- });
311
+ }, concurrency);
308
312
  if (result.errors.length > 0) {
309
313
  out.failSpinner(`Push completed with ${result.errors.length} error(s)`);
310
314
  for (const err of result.errors) {
@@ -30,12 +30,17 @@ export declare function scanLocalFiles(localPath: string, ignorePatterns: string
30
30
  * Returns a map of doc paths -> FileState.
31
31
  */
32
32
  export declare function scanRemoteFiles(client: LifestreamVaultClient, vaultId: string, ignorePatterns: string[]): Promise<Record<string, FileState>>;
33
+ /**
34
+ * Validates and clamps a user-supplied concurrency value. Throws on invalid
35
+ * values so the CLI can surface a clear error before kicking off any I/O.
36
+ */
37
+ export declare function resolveConcurrency(value: number | undefined): number;
33
38
  /**
34
39
  * Execute a pull operation: download remote changes to local.
35
40
  */
36
- export declare function executePull(client: LifestreamVaultClient, config: SyncConfig, diff: SyncDiff, onProgress?: ProgressCallback): Promise<SyncResult>;
41
+ export declare function executePull(client: LifestreamVaultClient, config: SyncConfig, diff: SyncDiff, onProgress?: ProgressCallback, concurrency?: number): Promise<SyncResult>;
37
42
  /**
38
43
  * Execute a push operation: upload local changes to remote.
39
44
  */
40
- export declare function executePush(client: LifestreamVaultClient, config: SyncConfig, diff: SyncDiff, onProgress?: ProgressCallback): Promise<SyncResult>;
45
+ export declare function executePush(client: LifestreamVaultClient, config: SyncConfig, diff: SyncDiff, onProgress?: ProgressCallback, concurrency?: number): Promise<SyncResult>;
41
46
  export { computePullDiff, computePushDiff, type SyncDiff, type SyncDiffEntry };
@@ -54,7 +54,7 @@ export async function scanRemoteFiles(client, vaultId, ignorePatterns) {
54
54
  if (!shouldIgnore(doc.path, ignorePatterns)) {
55
55
  files[doc.path] = {
56
56
  path: doc.path,
57
- hash: '', // We don't have content hash from list; will use mtime for comparison
57
+ hash: doc.contentHash,
58
58
  mtime: doc.fileModifiedAt,
59
59
  size: doc.sizeBytes,
60
60
  };
@@ -71,12 +71,31 @@ function atomicWriteFileSync(targetPath, content, encoding = 'utf-8') {
71
71
  fs.writeFileSync(tmpFile, content, encoding);
72
72
  fs.renameSync(tmpFile, targetPath);
73
73
  }
74
+ /**
75
+ * Default in-flight transfer count. A small number flattens the load1 spike
76
+ * a full-vault `sync pull` causes on the API host without making single
77
+ * transfers measurably slower.
78
+ */
79
+ const DEFAULT_TRANSFER_CONCURRENCY = 4;
80
+ const MAX_TRANSFER_CONCURRENCY = 16;
81
+ /**
82
+ * Validates and clamps a user-supplied concurrency value. Throws on invalid
83
+ * values so the CLI can surface a clear error before kicking off any I/O.
84
+ */
85
+ export function resolveConcurrency(value) {
86
+ if (value === undefined)
87
+ return DEFAULT_TRANSFER_CONCURRENCY;
88
+ if (!Number.isInteger(value) || value < 1 || value > MAX_TRANSFER_CONCURRENCY) {
89
+ throw new Error(`--concurrency must be an integer between 1 and ${MAX_TRANSFER_CONCURRENCY} (got ${value})`);
90
+ }
91
+ return value;
92
+ }
74
93
  /**
75
94
  * Shared sync operation executor used by both pull and push.
76
95
  * Handles result initialization, state loading, progress callbacks,
77
96
  * quota error handling, state saving, and lastSync update.
78
97
  */
79
- async function executeSyncOperation(config, diff, handlers, onProgress) {
98
+ async function executeSyncOperation(config, diff, handlers, onProgress, concurrency = DEFAULT_TRANSFER_CONCURRENCY) {
80
99
  const result = {
81
100
  filesUploaded: 0,
82
101
  filesDownloaded: 0,
@@ -87,7 +106,12 @@ async function executeSyncOperation(config, diff, handlers, onProgress) {
87
106
  const state = loadSyncState(config.id);
88
107
  const allOps = [...handlers.transfers, ...handlers.deletes];
89
108
  let current = 0;
90
- for (const entry of handlers.transfers) {
109
+ // Once a quota error is hit anywhere in the pool we stop submitting new
110
+ // work but let in-flight transfers drain to keep state consistent.
111
+ let stopSubmitting = false;
112
+ async function runOne(entry) {
113
+ if (stopSubmitting)
114
+ return;
91
115
  current++;
92
116
  onProgress?.({
93
117
  phase: 'transferring',
@@ -112,13 +136,25 @@ async function executeSyncOperation(config, diff, handlers, onProgress) {
112
136
  }
113
137
  catch (err) {
114
138
  const message = err instanceof Error ? err.message : String(err);
139
+ result.errors.push({ path: entry.path, error: message });
115
140
  if (isQuotaError(message)) {
116
- result.errors.push({ path: entry.path, error: message });
117
- break; // Stop immediately on quota errors
141
+ stopSubmitting = true;
118
142
  }
119
- result.errors.push({ path: entry.path, error: message });
120
143
  }
121
144
  }
145
+ // Bounded async pool. Workers race for entries off the queue tail; once
146
+ // the queue is empty (or stopSubmitting is set), each worker exits and
147
+ // Promise.all resolves only after every in-flight transfer has settled.
148
+ const queue = handlers.transfers.slice();
149
+ const poolSize = Math.min(Math.max(1, concurrency), Math.max(1, queue.length));
150
+ await Promise.all(Array.from({ length: poolSize }, async () => {
151
+ while (!stopSubmitting) {
152
+ const entry = queue.shift();
153
+ if (!entry)
154
+ return;
155
+ await runOne(entry);
156
+ }
157
+ }));
122
158
  for (const entry of handlers.deletes) {
123
159
  current++;
124
160
  onProgress?.({
@@ -154,7 +190,7 @@ async function executeSyncOperation(config, diff, handlers, onProgress) {
154
190
  /**
155
191
  * Execute a pull operation: download remote changes to local.
156
192
  */
157
- export async function executePull(client, config, diff, onProgress) {
193
+ export async function executePull(client, config, diff, onProgress, concurrency) {
158
194
  return executeSyncOperation(config, diff, {
159
195
  transfers: diff.downloads,
160
196
  deletes: diff.deletes,
@@ -175,12 +211,12 @@ export async function executePull(client, config, diff, onProgress) {
175
211
  fs.unlinkSync(localFile);
176
212
  }
177
213
  },
178
- }, onProgress);
214
+ }, onProgress, concurrency);
179
215
  }
180
216
  /**
181
217
  * Execute a push operation: upload local changes to remote.
182
218
  */
183
- export async function executePush(client, config, diff, onProgress) {
219
+ export async function executePush(client, config, diff, onProgress, concurrency) {
184
220
  return executeSyncOperation(config, diff, {
185
221
  transfers: diff.uploads,
186
222
  deletes: diff.deletes,
@@ -194,7 +230,7 @@ export async function executePush(client, config, diff, onProgress) {
194
230
  async deleteFile(entry, cfg) {
195
231
  await retryWithBackoff(() => client.documents.delete(cfg.vaultId, entry.path));
196
232
  },
197
- }, onProgress);
233
+ }, onProgress, concurrency);
198
234
  }
199
235
  /**
200
236
  * Retry a function with exponential backoff (max 3 retries).
@@ -22,62 +22,91 @@ export function createRemotePoller(client, config, options) {
22
22
  return; // Skip if previous poll still in progress
23
23
  polling = true;
24
24
  try {
25
- const remoteDocs = await client.documents.list(config.vaultId);
26
25
  const state = loadSyncState(config.id);
27
26
  let changes = 0;
28
- for (const doc of remoteDocs) {
29
- if (shouldIgnore(doc.path, ignorePatterns))
30
- continue;
31
- const lastRemote = state.remote[doc.path];
32
- // Detect remote changes by comparing mtime
33
- const remoteChanged = !lastRemote || doc.fileModifiedAt !== lastRemote.mtime;
34
- if (!remoteChanged)
35
- continue;
36
- // Fetch the full content
37
- const { content } = await client.documents.get(config.vaultId, doc.path);
38
- const remoteHash = hashFileContent(content);
39
- // Skip if hash hasn't actually changed
40
- if (lastRemote && remoteHash === lastRemote.hash) {
41
- // Update mtime in state but skip file operations
42
- state.remote[doc.path] = buildRemoteFileState(doc.path, content, doc.fileModifiedAt);
27
+ let stateMutated = false;
28
+ // Build the known state from persisted remote hashes + stored list ETag.
29
+ const known = {
30
+ hashes: Object.fromEntries(Object.entries(state.remote).map(([k, v]) => [k, v.hash])),
31
+ listEtag: state.remoteListEtag,
32
+ };
33
+ const sync = await client.documents.syncList(config.vaultId, known);
34
+ // Steady-state: server confirmed nothing changed — skip all per-doc work.
35
+ if (sync.vaultUnchanged)
36
+ return;
37
+ // Persist the new list ETag so the next poll can 304.
38
+ state.remoteListEtag = sync.listEtag;
39
+ stateMutated = true;
40
+ // Process added/changed documents.
41
+ for (const change of sync.changes) {
42
+ if (shouldIgnore(change.path, ignorePatterns))
43
43
  continue;
44
+ const lastRemote = state.remote[change.path];
45
+ let content;
46
+ let remoteHash;
47
+ if (lastRemote) {
48
+ // Conditional GET — server can still 304 us if our hash is current
49
+ // (rare: syncList classified this as 'changed' but the GET hash matches
50
+ // our last-known hash — possible race between list and get).
51
+ const result = await client.documents.get(config.vaultId, change.path, {
52
+ ifNoneMatch: `"${lastRemote.hash}"`,
53
+ });
54
+ if (result.notModified) {
55
+ // Server confirmed our copy is current despite differing list hash.
56
+ // Update mtime only; no file write needed.
57
+ if (lastRemote.mtime !== change.fileModifiedAt) {
58
+ state.remote[change.path] = { ...lastRemote, mtime: change.fileModifiedAt };
59
+ stateMutated = true;
60
+ }
61
+ continue;
62
+ }
63
+ content = result.content;
64
+ remoteHash = hashFileContent(content);
44
65
  }
45
- const localFile = path.join(config.localPath, doc.path);
66
+ else {
67
+ // First-time entry: unconditional GET.
68
+ const fetched = await client.documents.get(config.vaultId, change.path);
69
+ content = fetched.content;
70
+ remoteHash = hashFileContent(content);
71
+ }
72
+ const localFile = path.join(config.localPath, change.path);
46
73
  const localExists = fs.existsSync(localFile);
47
74
  if (localExists) {
48
75
  const localContent = fs.readFileSync(localFile, 'utf-8');
49
76
  const localHash = hashFileContent(localContent);
50
77
  if (localHash === remoteHash) {
51
78
  // Content is already the same — just update state
52
- state.local[doc.path] = { path: doc.path, hash: localHash, mtime: new Date().toISOString(), size: Buffer.byteLength(localContent) };
53
- state.remote[doc.path] = buildRemoteFileState(doc.path, content, doc.fileModifiedAt);
79
+ state.local[change.path] = { path: change.path, hash: localHash, mtime: new Date().toISOString(), size: Buffer.byteLength(localContent) };
80
+ state.remote[change.path] = buildRemoteFileState(change.path, content, change.fileModifiedAt);
81
+ stateMutated = true;
54
82
  continue;
55
83
  }
56
84
  // Check for conflict
57
- const lastLocal = state.local[doc.path];
58
- const localState = { path: doc.path, hash: localHash, mtime: fs.statSync(localFile).mtime.toISOString(), size: Buffer.byteLength(localContent) };
59
- const remoteState = { path: doc.path, hash: remoteHash, mtime: doc.fileModifiedAt, size: Buffer.byteLength(content) };
85
+ const lastLocal = state.local[change.path];
86
+ const localState = { path: change.path, hash: localHash, mtime: fs.statSync(localFile).mtime.toISOString(), size: Buffer.byteLength(localContent) };
87
+ const remoteState = { path: change.path, hash: remoteHash, mtime: change.fileModifiedAt, size: Buffer.byteLength(content) };
60
88
  if (detectConflict(localState, remoteState, lastLocal, lastRemote)) {
61
89
  const resolution = resolveConflict(config.onConflict, localState, remoteState);
62
90
  let conflictFile = null;
63
91
  if (resolution === 'remote') {
64
- conflictFile = createConflictFile(config.localPath, doc.path, localContent, 'local');
65
- onLocalWrite?.(doc.path);
92
+ conflictFile = createConflictFile(config.localPath, change.path, localContent, 'local');
93
+ onLocalWrite?.(change.path);
66
94
  const tmpConflict = localFile + '.tmp';
67
95
  fs.writeFileSync(tmpConflict, content, 'utf-8');
68
96
  fs.renameSync(tmpConflict, localFile);
69
- log(`Conflict: ${doc.path} — used remote, saved local as ${conflictFile}`);
97
+ log(`Conflict: ${change.path} — used remote, saved local as ${conflictFile}`);
70
98
  }
71
99
  else {
72
- conflictFile = createConflictFile(config.localPath, doc.path, content, 'remote');
73
- await client.documents.put(config.vaultId, doc.path, localContent);
74
- log(`Conflict: ${doc.path} — used local, saved remote as ${conflictFile}`);
100
+ conflictFile = createConflictFile(config.localPath, change.path, content, 'remote');
101
+ await client.documents.put(config.vaultId, change.path, localContent);
102
+ log(`Conflict: ${change.path} — used local, saved remote as ${conflictFile}`);
75
103
  }
76
- onConflictLog?.(formatConflictLog(doc.path, resolution, conflictFile));
77
- state.local[doc.path] = resolution === 'remote' ? remoteState : localState;
78
- state.remote[doc.path] = resolution === 'remote'
79
- ? buildRemoteFileState(doc.path, content, doc.fileModifiedAt)
80
- : buildRemoteFileState(doc.path, localContent, new Date().toISOString());
104
+ onConflictLog?.(formatConflictLog(change.path, resolution, conflictFile));
105
+ state.local[change.path] = resolution === 'remote' ? remoteState : localState;
106
+ state.remote[change.path] = resolution === 'remote'
107
+ ? buildRemoteFileState(change.path, content, change.fileModifiedAt)
108
+ : buildRemoteFileState(change.path, localContent, new Date().toISOString());
109
+ stateMutated = true;
81
110
  changes++;
82
111
  continue;
83
112
  }
@@ -87,40 +116,41 @@ export function createRemotePoller(client, config, options) {
87
116
  if (!fs.existsSync(dir)) {
88
117
  fs.mkdirSync(dir, { recursive: true });
89
118
  }
90
- onLocalWrite?.(doc.path);
119
+ onLocalWrite?.(change.path);
91
120
  const tmpFile = localFile + '.tmp';
92
121
  fs.writeFileSync(tmpFile, content, 'utf-8');
93
122
  fs.renameSync(tmpFile, localFile);
94
- log(`Pulled: ${doc.path}`);
123
+ log(`Pulled: ${change.path}`);
95
124
  changes++;
96
- state.local[doc.path] = {
97
- path: doc.path,
125
+ state.local[change.path] = {
126
+ path: change.path,
98
127
  hash: remoteHash,
99
128
  mtime: new Date().toISOString(),
100
129
  size: Buffer.byteLength(content),
101
130
  };
102
- state.remote[doc.path] = buildRemoteFileState(doc.path, content, doc.fileModifiedAt);
131
+ state.remote[change.path] = buildRemoteFileState(change.path, content, change.fileModifiedAt);
132
+ stateMutated = true;
103
133
  }
104
- // Check for remote deletions
105
- for (const docPath of Object.keys(state.remote)) {
106
- if (shouldIgnore(docPath, ignorePatterns))
134
+ // Process remote deletions.
135
+ for (const removedPath of sync.removed) {
136
+ if (shouldIgnore(removedPath, ignorePatterns))
107
137
  continue;
108
- const stillExists = remoteDocs.some(d => d.path === docPath);
109
- if (!stillExists) {
110
- const localFile = path.join(config.localPath, docPath);
111
- if (fs.existsSync(localFile)) {
112
- fs.unlinkSync(localFile);
113
- log(`Deleted local: ${docPath} (removed from remote)`);
114
- changes++;
115
- }
116
- delete state.local[docPath];
117
- delete state.remote[docPath];
138
+ const localFile = path.join(config.localPath, removedPath);
139
+ if (fs.existsSync(localFile)) {
140
+ fs.unlinkSync(localFile);
141
+ log(`Deleted local: ${removedPath} (removed from remote)`);
142
+ changes++;
118
143
  }
144
+ delete state.local[removedPath];
145
+ delete state.remote[removedPath];
146
+ stateMutated = true;
119
147
  }
120
- if (changes > 0) {
148
+ if (changes > 0 || stateMutated) {
121
149
  saveSyncState(state);
122
- updateLastSync(config.id);
123
- log(`Poll complete: ${changes} change(s)`);
150
+ if (changes > 0) {
151
+ updateLastSync(config.id);
152
+ log(`Poll complete: ${changes} change(s)`);
153
+ }
124
154
  }
125
155
  }
126
156
  catch (err) {
@@ -51,6 +51,8 @@ export interface SyncState {
51
51
  local: Record<string, FileState>;
52
52
  /** Map of document path -> file state for remote files */
53
53
  remote: Record<string, FileState>;
54
+ /** ETag from the most recent successful list response (for conditional polling). */
55
+ remoteListEtag?: string;
54
56
  /** ISO 8601 timestamp when state was last updated */
55
57
  updatedAt: string;
56
58
  }
@@ -101,18 +101,25 @@ export function createWatcher(client, config, options) {
101
101
  // Check remote for conflicts in bidirectional mode
102
102
  if (config.mode === 'sync' && lastRemote) {
103
103
  try {
104
- const remote = await client.documents.get(config.vaultId, docPath);
105
- const remoteHash = hashFileContent(remote.content);
106
- if (remoteHash !== lastRemote.hash) {
107
- const result = await handleConflict({
108
- absPath, docPath, localContent: content, localHash,
109
- lastLocal, lastRemote,
110
- remoteContent: remote.content, remoteHash,
111
- remoteUpdatedAt: remote.document.updatedAt, state,
112
- });
113
- if (result !== 'skip')
114
- return;
104
+ const result = await client.documents.get(config.vaultId, docPath, {
105
+ ifNoneMatch: `"${lastRemote.hash}"`,
106
+ });
107
+ if (!result.notModified) {
108
+ // Server returned the body — check if hash differs from our last-known state.
109
+ const remoteHash = hashFileContent(result.content);
110
+ if (remoteHash !== lastRemote.hash) {
111
+ const conflictResult = await handleConflict({
112
+ absPath, docPath, localContent: content, localHash,
113
+ lastLocal, lastRemote,
114
+ remoteContent: result.content, remoteHash,
115
+ remoteUpdatedAt: result.document.updatedAt, state,
116
+ });
117
+ if (conflictResult !== 'skip')
118
+ return;
119
+ }
120
+ // 200 + matching hash: rare list/cache mismatch — fall through to push.
115
121
  }
122
+ // 304: remote unchanged since last sync — no conflict possible. Fall through to push.
116
123
  }
117
124
  catch {
118
125
  // Remote check failed — proceed with push
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lifestreamdynamics/vault-cli",
3
- "version": "1.3.10",
3
+ "version": "1.4.1",
4
4
  "description": "Command-line interface for Lifestream Vault",
5
5
  "engines": {
6
6
  "node": ">=22"
@@ -44,7 +44,7 @@
44
44
  "prepublishOnly": "npm run build && npm test"
45
45
  },
46
46
  "dependencies": {
47
- "@lifestreamdynamics/vault-sdk": "^2.1.0",
47
+ "@lifestreamdynamics/vault-sdk": "^2.2.0",
48
48
  "chalk": "^5.4.0",
49
49
  "chokidar": "^4.0.3",
50
50
  "commander": "^13.0.0",