@lifestreamdynamics/vault-cli 1.4.1 → 1.4.6
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 +59 -55
- package/dist/commands/hooks.js +43 -9
- package/dist/commands/sync.js +35 -9
- package/dist/commands/webhooks.js +43 -8
- package/dist/sync/atomic-write.d.ts +25 -0
- package/dist/sync/atomic-write.js +98 -0
- package/dist/sync/daemon-worker.js +21 -4
- package/dist/sync/diff.d.ts +6 -0
- package/dist/sync/diff.js +4 -0
- package/dist/sync/engine.d.ts +49 -7
- package/dist/sync/engine.js +144 -43
- package/dist/sync/ignore.js +2 -1
- package/dist/sync/remote-poller.js +15 -7
- package/dist/sync/state.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -158,7 +158,7 @@ lsvault auth login --api-key lsv_k_your_api_key_here
|
|
|
158
158
|
|
|
159
159
|
**Create an API Key:**
|
|
160
160
|
```bash
|
|
161
|
-
lsvault keys create
|
|
161
|
+
lsvault keys create "CI/CD Pipeline" --scopes read,write
|
|
162
162
|
```
|
|
163
163
|
|
|
164
164
|
### Email/Password Authentication
|
|
@@ -313,46 +313,52 @@ lsvault search "meeting" --tags work,urgent --limit 10
|
|
|
313
313
|
| Command | Description |
|
|
314
314
|
|---------|-------------|
|
|
315
315
|
| `lsvault teams list` | List all teams |
|
|
316
|
-
| `lsvault teams create
|
|
316
|
+
| `lsvault teams create <name>` | Create a new team |
|
|
317
317
|
| `lsvault teams get <teamId>` | Get team details |
|
|
318
318
|
| `lsvault teams update <teamId>` | Update team settings |
|
|
319
319
|
| `lsvault teams delete <teamId>` | Delete a team |
|
|
320
|
-
| `lsvault teams members <teamId>` | List team members |
|
|
321
|
-
| `lsvault teams
|
|
322
|
-
| `lsvault teams remove <teamId> <userId>` | Remove member from team |
|
|
320
|
+
| `lsvault teams members list <teamId>` | List team members |
|
|
321
|
+
| `lsvault teams members update <teamId> <userId> --role <role>` | Update a member's role (admin/editor/viewer) |
|
|
322
|
+
| `lsvault teams members remove <teamId> <userId>` | Remove member from team |
|
|
323
|
+
| `lsvault teams invitations create <teamId> <email> --role <role>` | Invite a user to the team |
|
|
324
|
+
| `lsvault teams invitations list <teamId>` | List pending invitations |
|
|
325
|
+
| `lsvault teams invitations revoke <teamId> <invitationId>` | Revoke an invitation |
|
|
326
|
+
| `lsvault teams vaults list <teamId>` | List shared team vaults |
|
|
327
|
+
| `lsvault teams vaults create <teamId> <name>` | Create a shared team vault |
|
|
323
328
|
|
|
324
329
|
**Example:**
|
|
325
330
|
```bash
|
|
326
331
|
# Create a team
|
|
327
|
-
lsvault teams create
|
|
332
|
+
lsvault teams create "Engineering" --description "Dev team workspace"
|
|
328
333
|
|
|
329
334
|
# Invite a member
|
|
330
|
-
lsvault teams
|
|
335
|
+
lsvault teams invitations create team_abc123 engineer@example.com --role editor
|
|
331
336
|
|
|
332
337
|
# List members
|
|
333
|
-
lsvault teams members team_abc123
|
|
338
|
+
lsvault teams members list team_abc123
|
|
334
339
|
```
|
|
335
340
|
|
|
336
341
|
### Sharing & Publishing
|
|
337
342
|
|
|
338
343
|
| Command | Description |
|
|
339
344
|
|---------|-------------|
|
|
340
|
-
| `lsvault shares list
|
|
345
|
+
| `lsvault shares list <vaultId> <path>` | List share links for a document |
|
|
341
346
|
| `lsvault shares create <vaultId> <path>` | Create a share link for a document |
|
|
342
|
-
| `lsvault shares revoke <shareId>` | Revoke a share link |
|
|
343
|
-
| `lsvault publish list
|
|
344
|
-
| `lsvault publish create <vaultId> <path>` | Publish a document publicly |
|
|
345
|
-
| `lsvault publish
|
|
347
|
+
| `lsvault shares revoke <vaultId> <shareId>` | Revoke a share link |
|
|
348
|
+
| `lsvault publish list <vaultId>` | List published documents in a vault |
|
|
349
|
+
| `lsvault publish create <vaultId> <path> --slug <slug>` | Publish a document publicly |
|
|
350
|
+
| `lsvault publish delete <vaultId> <path>` | Unpublish a document |
|
|
346
351
|
|
|
347
352
|
**Example:**
|
|
348
353
|
```bash
|
|
349
|
-
# Create a password-protected share link
|
|
350
|
-
lsvault shares create vault_abc123
|
|
351
|
-
--
|
|
352
|
-
--
|
|
354
|
+
# Create a password-protected share link (prompts for the password)
|
|
355
|
+
lsvault shares create vault_abc123 reports/Q1.md \
|
|
356
|
+
--permission view \
|
|
357
|
+
--protect-with-password \
|
|
358
|
+
--expires 2026-12-31
|
|
353
359
|
|
|
354
360
|
# Publish a document
|
|
355
|
-
lsvault publish create vault_abc123
|
|
361
|
+
lsvault publish create vault_abc123 blog/post.md --slug my-first-post
|
|
356
362
|
```
|
|
357
363
|
|
|
358
364
|
### Publish Vault Commands
|
|
@@ -386,26 +392,25 @@ lsvault publish-vault unpublish vault_abc123
|
|
|
386
392
|
| Command | Description |
|
|
387
393
|
|---------|-------------|
|
|
388
394
|
| `lsvault hooks list <vaultId>` | List vault hooks |
|
|
389
|
-
| `lsvault hooks create <vaultId>` | Create a new hook |
|
|
390
|
-
| `lsvault hooks
|
|
391
|
-
| `lsvault hooks
|
|
392
|
-
| `lsvault webhooks list
|
|
393
|
-
| `lsvault webhooks create
|
|
394
|
-
| `lsvault webhooks update <webhookId>` | Update webhook configuration |
|
|
395
|
-
| `lsvault webhooks delete <webhookId>` | Delete a webhook |
|
|
395
|
+
| `lsvault hooks create <vaultId> <name>` | Create a new hook |
|
|
396
|
+
| `lsvault hooks delete <vaultId> <hookId>` | Delete a hook |
|
|
397
|
+
| `lsvault hooks executions <vaultId> <hookId>` | View hook execution history |
|
|
398
|
+
| `lsvault webhooks list <vaultId>` | List vault webhooks |
|
|
399
|
+
| `lsvault webhooks create <vaultId> <url>` | Create a new webhook |
|
|
400
|
+
| `lsvault webhooks update <vaultId> <webhookId>` | Update webhook configuration |
|
|
401
|
+
| `lsvault webhooks delete <vaultId> <webhookId>` | Delete a webhook |
|
|
396
402
|
|
|
397
403
|
**Example:**
|
|
398
404
|
```bash
|
|
399
|
-
# Create an
|
|
400
|
-
lsvault hooks create vault_abc123 \
|
|
401
|
-
--
|
|
402
|
-
--
|
|
405
|
+
# Create a hook that runs an AI prompt on document creation
|
|
406
|
+
lsvault hooks create vault_abc123 "Auto-tag" \
|
|
407
|
+
--trigger document.created \
|
|
408
|
+
--action ai_prompt \
|
|
409
|
+
--config '{"prompt":"Suggest tags"}'
|
|
403
410
|
|
|
404
411
|
# Create a webhook for document updates
|
|
405
|
-
lsvault webhooks create \
|
|
406
|
-
--
|
|
407
|
-
--events document.created,document.updated \
|
|
408
|
-
--secret webhook_secret_key
|
|
412
|
+
lsvault webhooks create vault_abc123 https://api.example.com/webhook \
|
|
413
|
+
--events document.created,document.updated
|
|
409
414
|
```
|
|
410
415
|
|
|
411
416
|
### Calendar
|
|
@@ -414,10 +419,11 @@ lsvault webhooks create \
|
|
|
414
419
|
|---------|-------------|
|
|
415
420
|
| `lsvault calendar view <vaultId>` | Browse calendar views and activity heatmap |
|
|
416
421
|
| `lsvault calendar due <vaultId>` | List documents by due date |
|
|
422
|
+
| `lsvault calendar set-due <vaultId> <path> --date <date>` | Set or clear a document due date (pass `--date clear` to clear) |
|
|
417
423
|
| `lsvault calendar events <vaultId>` | List calendar events |
|
|
418
|
-
| `lsvault calendar
|
|
419
|
-
| `lsvault calendar
|
|
420
|
-
| `lsvault calendar
|
|
424
|
+
| `lsvault calendar event create <vaultId> <title>` | Create a calendar event |
|
|
425
|
+
| `lsvault calendar event update <vaultId> <eventId>` | Update a calendar event |
|
|
426
|
+
| `lsvault calendar event delete <vaultId> <eventId>` | Delete a calendar event |
|
|
421
427
|
|
|
422
428
|
### Booking Commands
|
|
423
429
|
|
|
@@ -638,13 +644,13 @@ Manage multiple configurations with profiles:
|
|
|
638
644
|
# List profiles
|
|
639
645
|
lsvault config profiles
|
|
640
646
|
|
|
641
|
-
# Create a profile
|
|
642
|
-
lsvault config
|
|
647
|
+
# Create a profile by setting a value in it (profiles are created on first use)
|
|
648
|
+
lsvault config set apiUrl https://vault.lifestreamdynamics.com --profile production
|
|
643
649
|
|
|
644
650
|
# Switch profiles
|
|
645
651
|
lsvault config use production
|
|
646
652
|
|
|
647
|
-
# Set config values
|
|
653
|
+
# Set config values (in the active profile)
|
|
648
654
|
lsvault config set apiUrl https://vault.lifestreamdynamics.com
|
|
649
655
|
|
|
650
656
|
# Get config values
|
|
@@ -759,12 +765,13 @@ lsvault sync watch sync_xyz789
|
|
|
759
765
|
lsvault search "quarterly report" --vault vault_abc123 -o json
|
|
760
766
|
|
|
761
767
|
# Create a share link for the found document
|
|
762
|
-
lsvault shares create vault_abc123
|
|
763
|
-
--
|
|
764
|
-
--
|
|
768
|
+
lsvault shares create vault_abc123 reports/Q4-2025.md \
|
|
769
|
+
--permission view \
|
|
770
|
+
--protect-with-password \
|
|
771
|
+
--expires 2026-01-31
|
|
765
772
|
|
|
766
773
|
# Publish a document publicly
|
|
767
|
-
lsvault publish create vault_abc123
|
|
774
|
+
lsvault publish create vault_abc123 blog/announcement.md \
|
|
768
775
|
--slug new-features-2026
|
|
769
776
|
```
|
|
770
777
|
|
|
@@ -772,30 +779,27 @@ lsvault publish create vault_abc123 /blog/announcement.md \
|
|
|
772
779
|
|
|
773
780
|
```bash
|
|
774
781
|
# Create a team
|
|
775
|
-
lsvault teams create
|
|
782
|
+
lsvault teams create "Product Team" --description "Product docs"
|
|
776
783
|
|
|
777
784
|
# Create a vault
|
|
778
785
|
lsvault vaults create "Product Docs" --description "Product documentation"
|
|
779
786
|
|
|
780
787
|
# Invite team members
|
|
781
|
-
lsvault teams
|
|
782
|
-
lsvault teams
|
|
788
|
+
lsvault teams invitations create team_abc123 pm@example.com --role admin
|
|
789
|
+
lsvault teams invitations create team_abc123 dev@example.com --role editor
|
|
783
790
|
|
|
784
|
-
# Configure webhook for
|
|
785
|
-
lsvault webhooks create \
|
|
786
|
-
--
|
|
787
|
-
--events document.created,document.updated \
|
|
788
|
-
--filter '{"vaultId":"vault_xyz789"}'
|
|
791
|
+
# Configure a webhook for vault updates
|
|
792
|
+
lsvault webhooks create vault_xyz789 https://slack.example.com/webhook \
|
|
793
|
+
--events document.created,document.updated
|
|
789
794
|
```
|
|
790
795
|
|
|
791
796
|
### Automation with API Keys
|
|
792
797
|
|
|
793
798
|
```bash
|
|
794
799
|
# Create a read-only API key for monitoring
|
|
795
|
-
lsvault keys create \
|
|
796
|
-
--
|
|
797
|
-
--
|
|
798
|
-
--expires-in 90d
|
|
800
|
+
lsvault keys create "Monitoring Script" \
|
|
801
|
+
--scopes read \
|
|
802
|
+
--expires 2026-12-31
|
|
799
803
|
|
|
800
804
|
# Use API key in scripts
|
|
801
805
|
export LSVAULT_API_KEY=lsv_k_generated_key
|
package/dist/commands/hooks.js
CHANGED
|
@@ -3,6 +3,21 @@ import { getClientAsync } from '../client.js';
|
|
|
3
3
|
import { addGlobalFlags, resolveFlags } from '../utils/flags.js';
|
|
4
4
|
import { createOutput, handleError } from '../utils/output.js';
|
|
5
5
|
import { resolveVaultId } from '../utils/resolve-vault.js';
|
|
6
|
+
/**
|
|
7
|
+
* Valid hook trigger events. Unlike webhooks, hooks do NOT accept the `*`
|
|
8
|
+
* wildcard. Inlined from the internal vault-shared package (not published to
|
|
9
|
+
* npm) so the standalone CLI build has no unpublishable workspace dependency.
|
|
10
|
+
* Keep in sync with VAULT_EVENT_TYPES in packages/shared/src/constants.ts.
|
|
11
|
+
*/
|
|
12
|
+
const VAULT_EVENT_TYPES = [
|
|
13
|
+
'document.created', 'document.updated', 'document.deleted', 'document.moved', 'document.copied',
|
|
14
|
+
'directory.created', 'document.overdue', 'document.due-soon',
|
|
15
|
+
'calendar.event.created', 'calendar.event.updated', 'calendar.event.deleted', 'calendar.event.due',
|
|
16
|
+
'calendar.event.overdue', 'calendar.event.status_changed',
|
|
17
|
+
'booking.created', 'booking.confirmed', 'booking.cancelled', 'booking.no_show', 'booking.completed',
|
|
18
|
+
'booking.reminder', 'booking.rescheduled',
|
|
19
|
+
'calendar.event.participant.added', 'calendar.event.participant.responded',
|
|
20
|
+
];
|
|
6
21
|
export function registerHookCommands(program) {
|
|
7
22
|
const hooks = program.command('hooks').description('Manage vault event hooks');
|
|
8
23
|
addGlobalFlags(hooks.command('list')
|
|
@@ -49,17 +64,35 @@ export function registerHookCommands(program) {
|
|
|
49
64
|
.description('Create a new hook')
|
|
50
65
|
.argument('<vaultId>', 'Vault ID or slug')
|
|
51
66
|
.argument('<name>', 'Hook name')
|
|
52
|
-
.requiredOption('--trigger <event>', 'Trigger event (
|
|
67
|
+
.requiredOption('--trigger <event>', 'Trigger event (e.g. document.created, calendar.event.created, booking.confirmed)')
|
|
53
68
|
.requiredOption('--action <type>', 'Action type (webhook, ai_prompt, document_operation, auto_calendar_event, auto_booking_process)')
|
|
54
69
|
.requiredOption('--config <json>', 'Action configuration as JSON')
|
|
55
70
|
.option('--filter <json>', 'Trigger filter as JSON')
|
|
56
71
|
.addHelpText('after', `
|
|
57
|
-
VALID TRIGGER EVENTS
|
|
58
|
-
document.created
|
|
59
|
-
document.updated
|
|
60
|
-
document.deleted
|
|
61
|
-
document.moved
|
|
62
|
-
document.copied
|
|
72
|
+
VALID TRIGGER EVENTS (no wildcard — hooks fire on a single event type)
|
|
73
|
+
document.created Document was created
|
|
74
|
+
document.updated Document content was updated
|
|
75
|
+
document.deleted Document was deleted
|
|
76
|
+
document.moved Document was moved or renamed
|
|
77
|
+
document.copied Document was copied
|
|
78
|
+
directory.created Directory was created
|
|
79
|
+
document.overdue Document is past its due date
|
|
80
|
+
document.due-soon Document is due soon
|
|
81
|
+
calendar.event.created Calendar event was created
|
|
82
|
+
calendar.event.updated Calendar event was updated
|
|
83
|
+
calendar.event.deleted Calendar event was deleted
|
|
84
|
+
calendar.event.due Calendar event is due
|
|
85
|
+
calendar.event.overdue Calendar event is overdue
|
|
86
|
+
calendar.event.status_changed Calendar event status changed
|
|
87
|
+
booking.created Booking was created
|
|
88
|
+
booking.confirmed Booking was confirmed
|
|
89
|
+
booking.cancelled Booking was cancelled
|
|
90
|
+
booking.no_show Booking was marked no-show
|
|
91
|
+
booking.completed Booking was completed
|
|
92
|
+
booking.reminder Booking reminder sent
|
|
93
|
+
booking.rescheduled Booking was rescheduled
|
|
94
|
+
calendar.event.participant.added Participant added to event
|
|
95
|
+
calendar.event.participant.responded Participant responded to event
|
|
63
96
|
|
|
64
97
|
VALID ACTION TYPES
|
|
65
98
|
webhook Send an HTTP notification to a URL
|
|
@@ -71,6 +104,7 @@ VALID ACTION TYPES
|
|
|
71
104
|
EXAMPLES
|
|
72
105
|
lsvault hooks create <vaultId> my-hook --trigger document.created --action webhook --config '{"url":"https://example.com/hook"}'
|
|
73
106
|
lsvault hooks create <vaultId> ai-tag --trigger document.created --action ai_prompt --config '{"prompt":"Suggest tags"}'
|
|
107
|
+
lsvault hooks create <vaultId> booking-hook --trigger booking.confirmed --action webhook --config '{"url":"https://example.com/hook"}'
|
|
74
108
|
lsvault hooks create <vaultId> move-docs --trigger document.created --action document_operation --config '{"operation":"move","targetPath":"inbox/"}'`))
|
|
75
109
|
.action(async (vaultId, name, _opts) => {
|
|
76
110
|
const flags = resolveFlags(_opts);
|
|
@@ -95,12 +129,12 @@ EXAMPLES
|
|
|
95
129
|
return;
|
|
96
130
|
}
|
|
97
131
|
}
|
|
98
|
-
const VALID_TRIGGERS =
|
|
132
|
+
const VALID_TRIGGERS = VAULT_EVENT_TYPES;
|
|
99
133
|
const VALID_ACTIONS = ['webhook', 'ai_prompt', 'document_operation', 'auto_calendar_event', 'auto_booking_process'];
|
|
100
134
|
const trigger = String(_opts.trigger);
|
|
101
135
|
const action = String(_opts.action);
|
|
102
136
|
if (!VALID_TRIGGERS.includes(trigger)) {
|
|
103
|
-
out.error(`Invalid trigger "${trigger}". Valid values:
|
|
137
|
+
out.error(`Invalid trigger "${trigger}". Valid values: ${VALID_TRIGGERS.join(', ')}`);
|
|
104
138
|
process.exitCode = 1;
|
|
105
139
|
return;
|
|
106
140
|
}
|
package/dist/commands/sync.js
CHANGED
|
@@ -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, resolveConcurrency, } from '../sync/engine.js';
|
|
12
|
+
import { scanLocalFiles, scanRemoteFiles, executePull, executePush, computePullDiff, computePushDiff, resolveConcurrency, sweepOrphanedTempFiles, } 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';
|
|
@@ -168,17 +168,30 @@ Sync modes:
|
|
|
168
168
|
const client = await getClientAsync();
|
|
169
169
|
const ignorePatterns = resolveIgnorePatterns(config.ignore, config.localPath);
|
|
170
170
|
const lastState = loadSyncState(config.id);
|
|
171
|
+
// Clean up any orphaned temp files left by a prior interrupted pull.
|
|
172
|
+
const swept = sweepOrphanedTempFiles(config.localPath);
|
|
173
|
+
if (swept > 0) {
|
|
174
|
+
out.debug(`Removed ${swept} orphaned temp file(s) from ${config.localPath}`);
|
|
175
|
+
}
|
|
171
176
|
out.startSpinner('Scanning local files...');
|
|
172
|
-
const localFiles = scanLocalFiles(config.localPath, ignorePatterns);
|
|
177
|
+
const localFiles = scanLocalFiles(config.localPath, ignorePatterns, lastState);
|
|
173
178
|
out.debug(`Found ${Object.keys(localFiles).length} local files`);
|
|
174
179
|
out.startSpinner('Scanning remote files...');
|
|
175
|
-
const
|
|
180
|
+
const remoteResult = await scanRemoteFiles(client, config.vaultId, ignorePatterns, { remote: lastState.remote ?? {}, remoteListEtag: lastState.remoteListEtag });
|
|
181
|
+
const remoteFiles = remoteResult.files;
|
|
176
182
|
out.debug(`Found ${Object.keys(remoteFiles).length} remote files`);
|
|
177
183
|
out.startSpinner('Computing diff...');
|
|
178
184
|
const diff = computePullDiff(localFiles, remoteFiles, lastState);
|
|
179
185
|
const unchanged = Object.keys(remoteFiles).length - diff.downloads.length;
|
|
180
186
|
const totalOps = diff.downloads.length + diff.deletes.length;
|
|
187
|
+
// Persist the new list ETag regardless of whether there are changes
|
|
188
|
+
if (remoteResult.listEtag) {
|
|
189
|
+
lastState.remoteListEtag = remoteResult.listEtag;
|
|
190
|
+
}
|
|
181
191
|
if (totalOps === 0) {
|
|
192
|
+
if (remoteResult.listEtag) {
|
|
193
|
+
saveSyncState(lastState);
|
|
194
|
+
}
|
|
182
195
|
out.succeedSpinner('Everything is up to date');
|
|
183
196
|
if (flags.output === 'json') {
|
|
184
197
|
out.record({
|
|
@@ -218,7 +231,9 @@ Sync modes:
|
|
|
218
231
|
if (progress.phase === 'transferring' && progress.currentFile) {
|
|
219
232
|
out.startSpinner(`[${progress.current}/${progress.total}] ${progress.currentFile}`);
|
|
220
233
|
}
|
|
221
|
-
}, concurrency)
|
|
234
|
+
}, concurrency, (file) => {
|
|
235
|
+
out.startSpinner(`Rate limited — waiting and retrying… (${file})`);
|
|
236
|
+
});
|
|
222
237
|
if (result.errors.length > 0) {
|
|
223
238
|
out.failSpinner(`Pull completed with ${result.errors.length} error(s)`);
|
|
224
239
|
for (const err of result.errors) {
|
|
@@ -259,16 +274,24 @@ Sync modes:
|
|
|
259
274
|
const ignorePatterns = resolveIgnorePatterns(config.ignore, config.localPath);
|
|
260
275
|
const lastState = loadSyncState(config.id);
|
|
261
276
|
out.startSpinner('Scanning local files...');
|
|
262
|
-
const localFiles = scanLocalFiles(config.localPath, ignorePatterns);
|
|
277
|
+
const localFiles = scanLocalFiles(config.localPath, ignorePatterns, lastState);
|
|
263
278
|
out.debug(`Found ${Object.keys(localFiles).length} local files`);
|
|
264
279
|
out.startSpinner('Scanning remote files...');
|
|
265
|
-
const
|
|
280
|
+
const remoteResult = await scanRemoteFiles(client, config.vaultId, ignorePatterns, { remote: lastState.remote ?? {}, remoteListEtag: lastState.remoteListEtag });
|
|
281
|
+
const remoteFiles = remoteResult.files;
|
|
266
282
|
out.debug(`Found ${Object.keys(remoteFiles).length} remote files`);
|
|
267
283
|
out.startSpinner('Computing diff...');
|
|
268
284
|
const diff = computePushDiff(localFiles, remoteFiles, lastState);
|
|
269
285
|
const unchanged = Object.keys(localFiles).length - diff.uploads.length;
|
|
270
286
|
const totalOps = diff.uploads.length + diff.deletes.length;
|
|
287
|
+
// Persist the new list ETag regardless of whether there are changes
|
|
288
|
+
if (remoteResult.listEtag) {
|
|
289
|
+
lastState.remoteListEtag = remoteResult.listEtag;
|
|
290
|
+
}
|
|
271
291
|
if (totalOps === 0) {
|
|
292
|
+
if (remoteResult.listEtag) {
|
|
293
|
+
saveSyncState(lastState);
|
|
294
|
+
}
|
|
272
295
|
out.succeedSpinner('Everything is up to date');
|
|
273
296
|
if (flags.output === 'json') {
|
|
274
297
|
out.record({
|
|
@@ -308,7 +331,9 @@ Sync modes:
|
|
|
308
331
|
if (progress.phase === 'transferring' && progress.currentFile) {
|
|
309
332
|
out.startSpinner(`[${progress.current}/${progress.total}] ${progress.currentFile}`);
|
|
310
333
|
}
|
|
311
|
-
}, concurrency)
|
|
334
|
+
}, concurrency, (file) => {
|
|
335
|
+
out.startSpinner(`Rate limited — waiting and retrying… (${file})`);
|
|
336
|
+
});
|
|
312
337
|
if (result.errors.length > 0) {
|
|
313
338
|
out.failSpinner(`Push completed with ${result.errors.length} error(s)`);
|
|
314
339
|
for (const err of result.errors) {
|
|
@@ -348,8 +373,9 @@ Sync modes:
|
|
|
348
373
|
const ignorePatterns = resolveIgnorePatterns(config.ignore, config.localPath);
|
|
349
374
|
const lastState = loadSyncState(config.id);
|
|
350
375
|
out.startSpinner('Scanning...');
|
|
351
|
-
const localFiles = scanLocalFiles(config.localPath, ignorePatterns);
|
|
352
|
-
const
|
|
376
|
+
const localFiles = scanLocalFiles(config.localPath, ignorePatterns, lastState);
|
|
377
|
+
const remoteResult = await scanRemoteFiles(client, config.vaultId, ignorePatterns, { remote: lastState.remote ?? {}, remoteListEtag: lastState.remoteListEtag });
|
|
378
|
+
const remoteFiles = remoteResult.files;
|
|
353
379
|
const pullDiff = computePullDiff(localFiles, remoteFiles, lastState);
|
|
354
380
|
const pushDiff = computePushDiff(localFiles, remoteFiles, lastState);
|
|
355
381
|
out.stopSpinner();
|
|
@@ -3,6 +3,22 @@ import { getClientAsync } from '../client.js';
|
|
|
3
3
|
import { addGlobalFlags, resolveFlags } from '../utils/flags.js';
|
|
4
4
|
import { createOutput, handleError } from '../utils/output.js';
|
|
5
5
|
import { resolveVaultId } from '../utils/resolve-vault.js';
|
|
6
|
+
/**
|
|
7
|
+
* Valid webhook event names, including the `*` wildcard. Inlined from the
|
|
8
|
+
* internal vault-shared package (not published to npm) so the standalone CLI
|
|
9
|
+
* build has no unpublishable workspace dependency. Keep in sync with
|
|
10
|
+
* WEBHOOK_EVENT_TYPES in packages/shared/src/constants.ts.
|
|
11
|
+
*/
|
|
12
|
+
const WEBHOOK_EVENT_TYPES = [
|
|
13
|
+
'*',
|
|
14
|
+
'document.created', 'document.updated', 'document.deleted', 'document.moved', 'document.copied',
|
|
15
|
+
'directory.created', 'document.overdue', 'document.due-soon',
|
|
16
|
+
'calendar.event.created', 'calendar.event.updated', 'calendar.event.deleted', 'calendar.event.due',
|
|
17
|
+
'calendar.event.overdue', 'calendar.event.status_changed',
|
|
18
|
+
'booking.created', 'booking.confirmed', 'booking.cancelled', 'booking.no_show', 'booking.completed',
|
|
19
|
+
'booking.reminder', 'booking.rescheduled',
|
|
20
|
+
'calendar.event.participant.added', 'calendar.event.participant.responded',
|
|
21
|
+
];
|
|
6
22
|
export function registerWebhookCommands(program) {
|
|
7
23
|
const webhooks = program.command('webhooks').description('Manage vault webhooks');
|
|
8
24
|
addGlobalFlags(webhooks.command('list')
|
|
@@ -46,19 +62,38 @@ export function registerWebhookCommands(program) {
|
|
|
46
62
|
.description('Create a new webhook')
|
|
47
63
|
.argument('<vaultId>', 'Vault ID or slug')
|
|
48
64
|
.argument('<url>', 'Webhook endpoint URL')
|
|
49
|
-
.option('--events <events>', 'Comma-separated events (document.created,
|
|
65
|
+
.option('--events <events>', 'Comma-separated events (document.created, calendar.event.created, booking.created, *, etc.)', 'document.created,document.updated,document.deleted')
|
|
50
66
|
.addHelpText('after', `
|
|
51
67
|
VALID EVENT NAMES
|
|
52
|
-
document.created
|
|
53
|
-
document.updated
|
|
54
|
-
document.deleted
|
|
55
|
-
document.moved
|
|
56
|
-
document.copied
|
|
57
|
-
|
|
68
|
+
document.created Document was created
|
|
69
|
+
document.updated Document content was updated
|
|
70
|
+
document.deleted Document was deleted
|
|
71
|
+
document.moved Document was moved or renamed
|
|
72
|
+
document.copied Document was copied
|
|
73
|
+
directory.created Directory was created
|
|
74
|
+
document.overdue Document is past its due date
|
|
75
|
+
document.due-soon Document is due soon
|
|
76
|
+
calendar.event.created Calendar event was created
|
|
77
|
+
calendar.event.updated Calendar event was updated
|
|
78
|
+
calendar.event.deleted Calendar event was deleted
|
|
79
|
+
calendar.event.due Calendar event is due
|
|
80
|
+
calendar.event.overdue Calendar event is overdue
|
|
81
|
+
calendar.event.status_changed Calendar event status changed
|
|
82
|
+
booking.created Booking was created
|
|
83
|
+
booking.confirmed Booking was confirmed
|
|
84
|
+
booking.cancelled Booking was cancelled
|
|
85
|
+
booking.no_show Booking was marked no-show
|
|
86
|
+
booking.completed Booking was completed
|
|
87
|
+
booking.reminder Booking reminder sent
|
|
88
|
+
booking.rescheduled Booking was rescheduled
|
|
89
|
+
calendar.event.participant.added Participant added to event
|
|
90
|
+
calendar.event.participant.responded Participant responded to event
|
|
91
|
+
* All events
|
|
58
92
|
|
|
59
93
|
EXAMPLES
|
|
60
94
|
lsvault webhooks create <vaultId> https://example.com/hook
|
|
61
95
|
lsvault webhooks create <vaultId> https://example.com/hook --events "document.created,document.deleted"
|
|
96
|
+
lsvault webhooks create <vaultId> https://example.com/hook --events "calendar.event.created,booking.created"
|
|
62
97
|
lsvault webhooks create <vaultId> https://example.com/hook --events "*"`))
|
|
63
98
|
.action(async (vaultId, url, _opts) => {
|
|
64
99
|
const flags = resolveFlags(_opts);
|
|
@@ -68,7 +103,7 @@ EXAMPLES
|
|
|
68
103
|
process.exitCode = 1;
|
|
69
104
|
return;
|
|
70
105
|
}
|
|
71
|
-
const VALID_EVENTS =
|
|
106
|
+
const VALID_EVENTS = WEBHOOK_EVENT_TYPES;
|
|
72
107
|
const events = String(_opts.events || 'document.created,document.updated,document.deleted').split(',').map((e) => e.trim());
|
|
73
108
|
const invalid = events.filter(e => !VALID_EVENTS.includes(e));
|
|
74
109
|
if (invalid.length > 0) {
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Write `content` to `targetPath` atomically.
|
|
3
|
+
*
|
|
4
|
+
* A uniquely named temp file is created alongside the target. On success it
|
|
5
|
+
* is renamed to the target (atomic on POSIX). If the write or rename throws,
|
|
6
|
+
* the temp file is best-effort deleted before the original error is re-thrown,
|
|
7
|
+
* guaranteeing no orphaned `.tmp.<hash>` files are left behind.
|
|
8
|
+
*/
|
|
9
|
+
export declare function atomicWriteFileSync(targetPath: string, content: string, encoding?: BufferEncoding): void;
|
|
10
|
+
/**
|
|
11
|
+
* Recursively walk `rootDir` and delete orphaned temp files.
|
|
12
|
+
*
|
|
13
|
+
* A temp file is considered orphaned when its canonical counterpart — the
|
|
14
|
+
* path with the `.tmp[.<hash>]` suffix stripped — already exists on disk.
|
|
15
|
+
* This guard ensures only leftover cruft is removed, never unique content.
|
|
16
|
+
*
|
|
17
|
+
* Dirs named `.git`, `node_modules`, or starting with `.` (hidden dirs) and
|
|
18
|
+
* the `.lsvault` dir are skipped, matching the sync engine's own exclusions.
|
|
19
|
+
*
|
|
20
|
+
* Any individual fs error (unreadable dir, permission denied, etc.) is caught
|
|
21
|
+
* and skipped so a single bad entry cannot abort the sweep.
|
|
22
|
+
*
|
|
23
|
+
* @returns the number of orphaned temp files deleted.
|
|
24
|
+
*/
|
|
25
|
+
export declare function sweepOrphanedTempFiles(rootDir: string): number;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Atomic file-write helpers shared by the sync engine and remote poller.
|
|
3
|
+
*
|
|
4
|
+
* All writes use a temp-file + rename strategy so interrupted writes never
|
|
5
|
+
* leave a partial file at the target path. On any error the temp file is
|
|
6
|
+
* cleaned up before the error is re-thrown.
|
|
7
|
+
*/
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { randomBytes } from 'node:crypto';
|
|
11
|
+
/**
|
|
12
|
+
* Write `content` to `targetPath` atomically.
|
|
13
|
+
*
|
|
14
|
+
* A uniquely named temp file is created alongside the target. On success it
|
|
15
|
+
* is renamed to the target (atomic on POSIX). If the write or rename throws,
|
|
16
|
+
* the temp file is best-effort deleted before the original error is re-thrown,
|
|
17
|
+
* guaranteeing no orphaned `.tmp.<hash>` files are left behind.
|
|
18
|
+
*/
|
|
19
|
+
export function atomicWriteFileSync(targetPath, content, encoding = 'utf-8') {
|
|
20
|
+
const tmpFile = targetPath + '.tmp.' + randomBytes(4).toString('hex');
|
|
21
|
+
try {
|
|
22
|
+
fs.writeFileSync(tmpFile, content, encoding);
|
|
23
|
+
fs.renameSync(tmpFile, targetPath);
|
|
24
|
+
}
|
|
25
|
+
catch (err) {
|
|
26
|
+
try {
|
|
27
|
+
fs.unlinkSync(tmpFile);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
// Ignore — the file may not exist if writeFileSync was what threw.
|
|
31
|
+
}
|
|
32
|
+
throw err;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Regex that matches both forms of orphaned temp-file suffix:
|
|
37
|
+
* - `.tmp` (static suffix used by the legacy inline write in remote-poller)
|
|
38
|
+
* - `.tmp.<8hex>` (randomised suffix used by atomicWriteFileSync)
|
|
39
|
+
*/
|
|
40
|
+
const ORPHAN_TEMP_RE = /\.tmp(\.[0-9a-f]{8})?$/;
|
|
41
|
+
/**
|
|
42
|
+
* Recursively walk `rootDir` and delete orphaned temp files.
|
|
43
|
+
*
|
|
44
|
+
* A temp file is considered orphaned when its canonical counterpart — the
|
|
45
|
+
* path with the `.tmp[.<hash>]` suffix stripped — already exists on disk.
|
|
46
|
+
* This guard ensures only leftover cruft is removed, never unique content.
|
|
47
|
+
*
|
|
48
|
+
* Dirs named `.git`, `node_modules`, or starting with `.` (hidden dirs) and
|
|
49
|
+
* the `.lsvault` dir are skipped, matching the sync engine's own exclusions.
|
|
50
|
+
*
|
|
51
|
+
* Any individual fs error (unreadable dir, permission denied, etc.) is caught
|
|
52
|
+
* and skipped so a single bad entry cannot abort the sweep.
|
|
53
|
+
*
|
|
54
|
+
* @returns the number of orphaned temp files deleted.
|
|
55
|
+
*/
|
|
56
|
+
export function sweepOrphanedTempFiles(rootDir) {
|
|
57
|
+
let removed = 0;
|
|
58
|
+
function walk(dir) {
|
|
59
|
+
let entries;
|
|
60
|
+
try {
|
|
61
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
for (const entry of entries) {
|
|
67
|
+
const fullPath = path.join(dir, entry.name);
|
|
68
|
+
try {
|
|
69
|
+
if (entry.isDirectory()) {
|
|
70
|
+
// Skip hidden dirs, node_modules, .lsvault
|
|
71
|
+
if (entry.name.startsWith('.') ||
|
|
72
|
+
entry.name === 'node_modules') {
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
walk(fullPath);
|
|
76
|
+
}
|
|
77
|
+
else if (entry.isFile() && ORPHAN_TEMP_RE.test(entry.name)) {
|
|
78
|
+
// Derive the canonical sibling path by stripping the temp suffix.
|
|
79
|
+
const canonical = fullPath.replace(ORPHAN_TEMP_RE, '');
|
|
80
|
+
if (fs.existsSync(canonical)) {
|
|
81
|
+
try {
|
|
82
|
+
fs.unlinkSync(fullPath);
|
|
83
|
+
removed++;
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
// Ignore individual unlink failures.
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
// Ignore stat/access errors for individual entries.
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
walk(rootDir);
|
|
97
|
+
return removed;
|
|
98
|
+
}
|
|
@@ -10,8 +10,8 @@ import { createRemotePoller } from './remote-poller.js';
|
|
|
10
10
|
import { removePid } from './daemon.js';
|
|
11
11
|
import { loadConfigAsync } from '../config.js';
|
|
12
12
|
import { LifestreamVaultClient } from '@lifestreamdynamics/vault-sdk';
|
|
13
|
-
import { scanLocalFiles, scanRemoteFiles, computePushDiff, computePullDiff, executePush, executePull } from './engine.js';
|
|
14
|
-
import { loadSyncState } from './state.js';
|
|
13
|
+
import { scanLocalFiles, scanRemoteFiles, computePushDiff, computePullDiff, executePush, executePull, sweepOrphanedTempFiles } from './engine.js';
|
|
14
|
+
import { loadSyncState, saveSyncState } from './state.js';
|
|
15
15
|
const managed = [];
|
|
16
16
|
function log(msg) {
|
|
17
17
|
const ts = new Date().toISOString();
|
|
@@ -43,6 +43,18 @@ async function start() {
|
|
|
43
43
|
process.exit(0);
|
|
44
44
|
}
|
|
45
45
|
log(`Found ${configs.length} auto-sync configuration(s)`);
|
|
46
|
+
// One-time orphan sweep: remove any temp files left by prior interrupted pulls.
|
|
47
|
+
for (const config of configs) {
|
|
48
|
+
try {
|
|
49
|
+
const swept = sweepOrphanedTempFiles(config.localPath);
|
|
50
|
+
if (swept > 0) {
|
|
51
|
+
log(`Swept ${swept} orphaned temp file(s) from ${config.localPath}`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
// Non-fatal — continue startup.
|
|
56
|
+
}
|
|
57
|
+
}
|
|
46
58
|
const client = await createClient();
|
|
47
59
|
// Startup reconciliation: catch changes made while daemon was stopped
|
|
48
60
|
for (const config of configs) {
|
|
@@ -50,8 +62,13 @@ async function start() {
|
|
|
50
62
|
log(`Reconciling ${config.id.slice(0, 8)} (${config.mode} mode)...`);
|
|
51
63
|
const ignorePatterns = resolveIgnorePatterns(config.ignore, config.localPath);
|
|
52
64
|
const lastState = loadSyncState(config.id);
|
|
53
|
-
const localFiles = scanLocalFiles(config.localPath, ignorePatterns);
|
|
54
|
-
const
|
|
65
|
+
const localFiles = scanLocalFiles(config.localPath, ignorePatterns, lastState);
|
|
66
|
+
const remoteResult = await scanRemoteFiles(client, config.vaultId, ignorePatterns, { remote: lastState.remote ?? {}, remoteListEtag: lastState.remoteListEtag });
|
|
67
|
+
const remoteFiles = remoteResult.files;
|
|
68
|
+
if (remoteResult.listEtag) {
|
|
69
|
+
lastState.remoteListEtag = remoteResult.listEtag;
|
|
70
|
+
saveSyncState(lastState);
|
|
71
|
+
}
|
|
55
72
|
let pushed = 0;
|
|
56
73
|
let pulled = 0;
|
|
57
74
|
let deleted = 0;
|
package/dist/sync/diff.d.ts
CHANGED
|
@@ -16,6 +16,12 @@ export interface SyncDiffEntry {
|
|
|
16
16
|
sizeBytes: number;
|
|
17
17
|
/** Human-readable reason for this change */
|
|
18
18
|
reason: string;
|
|
19
|
+
/**
|
|
20
|
+
* SHA-256 hash of the remote file at diff-computation time.
|
|
21
|
+
* Present on download entries so executePull can issue a conditional GET
|
|
22
|
+
* (If-None-Match) and skip the write when the local file is already current.
|
|
23
|
+
*/
|
|
24
|
+
remoteHash?: string;
|
|
19
25
|
}
|
|
20
26
|
export interface SyncDiff {
|
|
21
27
|
/** Files to upload (local -> remote) */
|
package/dist/sync/diff.js
CHANGED
|
@@ -19,6 +19,7 @@ export function computePullDiff(localFiles, remoteFiles, lastState) {
|
|
|
19
19
|
direction: 'download',
|
|
20
20
|
sizeBytes: remote.size,
|
|
21
21
|
reason: 'Deleted locally, exists remotely (pull restores)',
|
|
22
|
+
remoteHash: remote.hash,
|
|
22
23
|
});
|
|
23
24
|
}
|
|
24
25
|
else {
|
|
@@ -29,6 +30,7 @@ export function computePullDiff(localFiles, remoteFiles, lastState) {
|
|
|
29
30
|
direction: 'download',
|
|
30
31
|
sizeBytes: remote.size,
|
|
31
32
|
reason: 'New remote file',
|
|
33
|
+
remoteHash: remote.hash,
|
|
32
34
|
});
|
|
33
35
|
}
|
|
34
36
|
}
|
|
@@ -40,6 +42,7 @@ export function computePullDiff(localFiles, remoteFiles, lastState) {
|
|
|
40
42
|
direction: 'download',
|
|
41
43
|
sizeBytes: remote.size,
|
|
42
44
|
reason: 'Remote file updated',
|
|
45
|
+
remoteHash: remote.hash,
|
|
43
46
|
});
|
|
44
47
|
}
|
|
45
48
|
else if (!lastRemote && remote.hash !== local.hash) {
|
|
@@ -50,6 +53,7 @@ export function computePullDiff(localFiles, remoteFiles, lastState) {
|
|
|
50
53
|
direction: 'download',
|
|
51
54
|
sizeBytes: remote.size,
|
|
52
55
|
reason: 'Content differs (first sync, pull prefers remote)',
|
|
56
|
+
remoteHash: remote.hash,
|
|
53
57
|
});
|
|
54
58
|
}
|
|
55
59
|
}
|
package/dist/sync/engine.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { LifestreamVaultClient } from '@lifestreamdynamics/vault-sdk';
|
|
2
|
-
import type { SyncConfig, FileState } from './types.js';
|
|
2
|
+
import type { SyncConfig, SyncState, FileState } from './types.js';
|
|
3
3
|
import { computePullDiff, computePushDiff, type SyncDiff, type SyncDiffEntry } from './diff.js';
|
|
4
|
+
import { sweepOrphanedTempFiles } from './atomic-write.js';
|
|
5
|
+
export { sweepOrphanedTempFiles };
|
|
4
6
|
export interface SyncProgress {
|
|
5
7
|
phase: 'scanning' | 'computing' | 'transferring' | 'complete';
|
|
6
8
|
current: number;
|
|
@@ -23,13 +25,40 @@ export interface SyncResult {
|
|
|
23
25
|
/**
|
|
24
26
|
* Scan local directory recursively for .md files.
|
|
25
27
|
* Returns a map of relative doc paths -> FileState.
|
|
28
|
+
*
|
|
29
|
+
* When `lastState` is provided, files whose stat mtime and size match the
|
|
30
|
+
* persisted entry skip the readFileSync + hash step entirely (D-2 fast-path).
|
|
26
31
|
*/
|
|
27
|
-
export declare function scanLocalFiles(localPath: string, ignorePatterns: string[]): Record<string, FileState>;
|
|
32
|
+
export declare function scanLocalFiles(localPath: string, ignorePatterns: string[], lastState?: SyncState): Record<string, FileState>;
|
|
33
|
+
/** Result type for {@link scanRemoteFiles}. */
|
|
34
|
+
export interface ScanRemoteResult {
|
|
35
|
+
/** Map of doc paths -> FileState for all non-ignored remote files. */
|
|
36
|
+
files: Record<string, FileState>;
|
|
37
|
+
/** List ETag from this response, for use in the next call. */
|
|
38
|
+
listEtag: string;
|
|
39
|
+
/** True when the server confirmed the vault is unchanged (304 fast-path). */
|
|
40
|
+
vaultUnchanged: boolean;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Scan remote vault for document list using the syncList fast-path.
|
|
44
|
+
*
|
|
45
|
+
* When `knownState` is provided (with hashes and optionally a prior listEtag),
|
|
46
|
+
* the server may respond 304 and `vaultUnchanged` will be true — in that case
|
|
47
|
+
* `files` is rebuilt from `knownState.remote` without any per-doc network call.
|
|
48
|
+
*
|
|
49
|
+
* When `knownState` is omitted a full list is always fetched (backward-compat).
|
|
50
|
+
*/
|
|
51
|
+
export declare function scanRemoteFiles(client: LifestreamVaultClient, vaultId: string, ignorePatterns: string[], knownState?: {
|
|
52
|
+
remote: Record<string, FileState>;
|
|
53
|
+
remoteListEtag?: string;
|
|
54
|
+
}): Promise<ScanRemoteResult>;
|
|
28
55
|
/**
|
|
29
|
-
*
|
|
30
|
-
*
|
|
56
|
+
* Called when the SDK signals a 429 response while transferring a file.
|
|
57
|
+
* The SDK will back off and retry automatically; the CLI uses this to
|
|
58
|
+
* update the spinner text so the user sees "Rate limited — waiting and retrying…"
|
|
59
|
+
* rather than an apparently frozen transfer.
|
|
31
60
|
*/
|
|
32
|
-
export
|
|
61
|
+
export type ThrottleCallback = (file: string) => void;
|
|
33
62
|
/**
|
|
34
63
|
* Validates and clamps a user-supplied concurrency value. Throws on invalid
|
|
35
64
|
* values so the CLI can surface a clear error before kicking off any I/O.
|
|
@@ -38,9 +67,22 @@ export declare function resolveConcurrency(value: number | undefined): number;
|
|
|
38
67
|
/**
|
|
39
68
|
* Execute a pull operation: download remote changes to local.
|
|
40
69
|
*/
|
|
41
|
-
export declare function executePull(client: LifestreamVaultClient, config: SyncConfig, diff: SyncDiff, onProgress?: ProgressCallback, concurrency?: number): Promise<SyncResult>;
|
|
70
|
+
export declare function executePull(client: LifestreamVaultClient, config: SyncConfig, diff: SyncDiff, onProgress?: ProgressCallback, concurrency?: number, onThrottle?: ThrottleCallback): Promise<SyncResult>;
|
|
42
71
|
/**
|
|
43
72
|
* Execute a push operation: upload local changes to remote.
|
|
44
73
|
*/
|
|
45
|
-
export declare function executePush(client: LifestreamVaultClient, config: SyncConfig, diff: SyncDiff, onProgress?: ProgressCallback, concurrency?: number): Promise<SyncResult>;
|
|
74
|
+
export declare function executePush(client: LifestreamVaultClient, config: SyncConfig, diff: SyncDiff, onProgress?: ProgressCallback, concurrency?: number, onThrottle?: ThrottleCallback): Promise<SyncResult>;
|
|
75
|
+
/**
|
|
76
|
+
* Returns true when an error represents an HTTP 429 (Too Many Requests / rate
|
|
77
|
+
* limited) response. The SDK retries 429s transparently; a 429 reaching here
|
|
78
|
+
* means all retry attempts were exhausted.
|
|
79
|
+
*
|
|
80
|
+
* Prefers the structured status code the SDK attaches (RateLimitError sets
|
|
81
|
+
* `statusCode = 429`) and only falls back to a narrow message match. The
|
|
82
|
+
* message fallback is intentionally strict — it does NOT match "rate limit" or
|
|
83
|
+
* "throttle" loosely, since those words appear in unrelated errors (e.g. an
|
|
84
|
+
* authorization message mentioning a rate-limited account) and a false positive
|
|
85
|
+
* would suppress a real error.
|
|
86
|
+
*/
|
|
87
|
+
export declare function isThrottleError(err: unknown): boolean;
|
|
46
88
|
export { computePullDiff, computePushDiff, type SyncDiff, type SyncDiffEntry };
|
package/dist/sync/engine.js
CHANGED
|
@@ -3,16 +3,20 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import fs from 'node:fs';
|
|
5
5
|
import path from 'node:path';
|
|
6
|
-
import { randomBytes } from 'node:crypto';
|
|
7
6
|
import { loadSyncState, saveSyncState, hashFileContent, buildRemoteFileState } from './state.js';
|
|
8
7
|
import { updateLastSync } from './config.js';
|
|
9
8
|
import { shouldIgnore } from './ignore.js';
|
|
10
9
|
import { computePullDiff, computePushDiff } from './diff.js';
|
|
10
|
+
import { atomicWriteFileSync, sweepOrphanedTempFiles } from './atomic-write.js';
|
|
11
|
+
export { sweepOrphanedTempFiles };
|
|
11
12
|
/**
|
|
12
13
|
* Scan local directory recursively for .md files.
|
|
13
14
|
* Returns a map of relative doc paths -> FileState.
|
|
15
|
+
*
|
|
16
|
+
* When `lastState` is provided, files whose stat mtime and size match the
|
|
17
|
+
* persisted entry skip the readFileSync + hash step entirely (D-2 fast-path).
|
|
14
18
|
*/
|
|
15
|
-
export function scanLocalFiles(localPath, ignorePatterns) {
|
|
19
|
+
export function scanLocalFiles(localPath, ignorePatterns, lastState) {
|
|
16
20
|
const files = {};
|
|
17
21
|
function walk(dir, prefix) {
|
|
18
22
|
if (!fs.existsSync(dir))
|
|
@@ -28,14 +32,21 @@ export function scanLocalFiles(localPath, ignorePatterns) {
|
|
|
28
32
|
else if (entry.isFile() && entry.name.endsWith('.md')) {
|
|
29
33
|
if (!shouldIgnore(relPath, ignorePatterns)) {
|
|
30
34
|
const absPath = path.join(dir, entry.name);
|
|
31
|
-
const content = fs.readFileSync(absPath);
|
|
32
35
|
const stat = fs.statSync(absPath);
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
hash
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
36
|
+
const stored = lastState?.local?.[relPath];
|
|
37
|
+
if (stored && stat.size === stored.size && stat.mtime.toISOString() === stored.mtime) {
|
|
38
|
+
// mtime+size unchanged — reuse persisted hash, skip file read
|
|
39
|
+
files[relPath] = stored;
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
const content = fs.readFileSync(absPath);
|
|
43
|
+
files[relPath] = {
|
|
44
|
+
path: relPath,
|
|
45
|
+
hash: hashFileContent(content),
|
|
46
|
+
mtime: stat.mtime.toISOString(),
|
|
47
|
+
size: stat.size,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
39
50
|
}
|
|
40
51
|
}
|
|
41
52
|
}
|
|
@@ -44,32 +55,61 @@ export function scanLocalFiles(localPath, ignorePatterns) {
|
|
|
44
55
|
return files;
|
|
45
56
|
}
|
|
46
57
|
/**
|
|
47
|
-
* Scan remote vault for document list.
|
|
48
|
-
*
|
|
58
|
+
* Scan remote vault for document list using the syncList fast-path.
|
|
59
|
+
*
|
|
60
|
+
* When `knownState` is provided (with hashes and optionally a prior listEtag),
|
|
61
|
+
* the server may respond 304 and `vaultUnchanged` will be true — in that case
|
|
62
|
+
* `files` is rebuilt from `knownState.remote` without any per-doc network call.
|
|
63
|
+
*
|
|
64
|
+
* When `knownState` is omitted a full list is always fetched (backward-compat).
|
|
49
65
|
*/
|
|
50
|
-
export async function scanRemoteFiles(client, vaultId, ignorePatterns) {
|
|
51
|
-
const
|
|
66
|
+
export async function scanRemoteFiles(client, vaultId, ignorePatterns, knownState) {
|
|
67
|
+
const sdkKnownState = {
|
|
68
|
+
hashes: knownState
|
|
69
|
+
? Object.fromEntries(Object.entries(knownState.remote).map(([k, v]) => [k, v.hash]))
|
|
70
|
+
: {},
|
|
71
|
+
listEtag: knownState?.remoteListEtag,
|
|
72
|
+
};
|
|
73
|
+
const sync = await client.documents.syncList(vaultId, sdkKnownState);
|
|
74
|
+
if (sync.vaultUnchanged) {
|
|
75
|
+
// Server confirmed nothing changed — rebuild files from persisted state.
|
|
76
|
+
const files = {};
|
|
77
|
+
if (knownState) {
|
|
78
|
+
for (const [docPath, fs_] of Object.entries(knownState.remote)) {
|
|
79
|
+
if (!shouldIgnore(docPath, ignorePatterns)) {
|
|
80
|
+
files[docPath] = fs_;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return { files, listEtag: sync.listEtag, vaultUnchanged: true };
|
|
85
|
+
}
|
|
86
|
+
// Build files from changes + unchanged paths.
|
|
52
87
|
const files = {};
|
|
53
|
-
for (const
|
|
54
|
-
if (!shouldIgnore(
|
|
55
|
-
files[
|
|
56
|
-
path:
|
|
57
|
-
hash:
|
|
58
|
-
mtime:
|
|
59
|
-
|
|
88
|
+
for (const change of sync.changes) {
|
|
89
|
+
if (!shouldIgnore(change.path, ignorePatterns)) {
|
|
90
|
+
files[change.path] = {
|
|
91
|
+
path: change.path,
|
|
92
|
+
hash: change.contentHash,
|
|
93
|
+
mtime: change.fileModifiedAt,
|
|
94
|
+
// sizeBytes is not provided by syncList change objects; use 0 as a
|
|
95
|
+
// fallback — size is only used for progress-bar estimation in the diff.
|
|
96
|
+
size: 0,
|
|
60
97
|
};
|
|
61
98
|
}
|
|
62
99
|
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
100
|
+
for (const unchangedPath of sync.unchanged) {
|
|
101
|
+
if (!shouldIgnore(unchangedPath, ignorePatterns)) {
|
|
102
|
+
// Reuse the persisted FileState so size is preserved for progress reporting.
|
|
103
|
+
const stored = knownState?.remote?.[unchangedPath];
|
|
104
|
+
files[unchangedPath] = stored ?? {
|
|
105
|
+
path: unchangedPath,
|
|
106
|
+
hash: sdkKnownState.hashes[unchangedPath] ?? '',
|
|
107
|
+
mtime: '',
|
|
108
|
+
size: 0,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return { files, listEtag: sync.listEtag, vaultUnchanged: false };
|
|
73
113
|
}
|
|
74
114
|
/**
|
|
75
115
|
* Default in-flight transfer count. A small number flattens the load1 spike
|
|
@@ -95,7 +135,7 @@ export function resolveConcurrency(value) {
|
|
|
95
135
|
* Handles result initialization, state loading, progress callbacks,
|
|
96
136
|
* quota error handling, state saving, and lastSync update.
|
|
97
137
|
*/
|
|
98
|
-
async function executeSyncOperation(config, diff, handlers, onProgress, concurrency = DEFAULT_TRANSFER_CONCURRENCY) {
|
|
138
|
+
async function executeSyncOperation(config, diff, handlers, onProgress, concurrency = DEFAULT_TRANSFER_CONCURRENCY, onThrottle) {
|
|
99
139
|
const result = {
|
|
100
140
|
filesUploaded: 0,
|
|
101
141
|
filesDownloaded: 0,
|
|
@@ -122,7 +162,7 @@ async function executeSyncOperation(config, diff, handlers, onProgress, concurre
|
|
|
122
162
|
totalBytes: diff.totalBytes,
|
|
123
163
|
});
|
|
124
164
|
try {
|
|
125
|
-
const content = await handlers.transferFile(entry, config);
|
|
165
|
+
const content = await handlers.transferFile(entry, config, onThrottle);
|
|
126
166
|
result[handlers.transferCounterKey]++;
|
|
127
167
|
result.bytesTransferred += entry.sizeBytes;
|
|
128
168
|
// Update state
|
|
@@ -140,6 +180,11 @@ async function executeSyncOperation(config, diff, handlers, onProgress, concurre
|
|
|
140
180
|
if (isQuotaError(message)) {
|
|
141
181
|
stopSubmitting = true;
|
|
142
182
|
}
|
|
183
|
+
// 429 errors that reach here have already exhausted SDK-level retries.
|
|
184
|
+
// Stop submitting new work to avoid hammering a still-throttled API.
|
|
185
|
+
if (isThrottleError(err)) {
|
|
186
|
+
stopSubmitting = true;
|
|
187
|
+
}
|
|
143
188
|
}
|
|
144
189
|
}
|
|
145
190
|
// Bounded async pool. Workers race for entries off the queue tail; once
|
|
@@ -190,15 +235,33 @@ async function executeSyncOperation(config, diff, handlers, onProgress, concurre
|
|
|
190
235
|
/**
|
|
191
236
|
* Execute a pull operation: download remote changes to local.
|
|
192
237
|
*/
|
|
193
|
-
export async function executePull(client, config, diff, onProgress, concurrency) {
|
|
238
|
+
export async function executePull(client, config, diff, onProgress, concurrency, onThrottle) {
|
|
194
239
|
return executeSyncOperation(config, diff, {
|
|
195
240
|
transfers: diff.downloads,
|
|
196
241
|
deletes: diff.deletes,
|
|
197
242
|
transferCounterKey: 'filesDownloaded',
|
|
198
|
-
async transferFile(entry, cfg) {
|
|
199
|
-
const { content } = await retryWithBackoff(() => client.documents.get(cfg.vaultId, entry.path));
|
|
243
|
+
async transferFile(entry, cfg, throttleCallback) {
|
|
200
244
|
const localFile = path.join(cfg.localPath, entry.path);
|
|
201
245
|
const localDir = path.dirname(localFile);
|
|
246
|
+
// Only use a conditional GET when (a) we have the remote hash AND (b)
|
|
247
|
+
// the local file already exists. For 'create' entries the local file
|
|
248
|
+
// does not exist yet — the server will always 304 (our remoteHash IS the
|
|
249
|
+
// server's current hash), which would send the 304 branch into a
|
|
250
|
+
// readFileSync on a non-existent path (ENOENT). Guarding on existsSync
|
|
251
|
+
// also makes the readFileSync in the 304 branch provably safe.
|
|
252
|
+
const useConditional = entry.remoteHash !== undefined && fs.existsSync(localFile);
|
|
253
|
+
const result = await retryWithBackoff(() => useConditional
|
|
254
|
+
? client.documents.get(cfg.vaultId, entry.path, { ifNoneMatch: `"${entry.remoteHash}"` })
|
|
255
|
+
: client.documents.get(cfg.vaultId, entry.path), throttleCallback ? () => throttleCallback(entry.path) : undefined);
|
|
256
|
+
// 304 Not Modified — local already has the right content; skip the write.
|
|
257
|
+
// Safe: only reached when useConditional === true, which requires the
|
|
258
|
+
// local file to exist.
|
|
259
|
+
if ('notModified' in result && result.notModified) {
|
|
260
|
+
const localContent = fs.readFileSync(localFile, 'utf-8');
|
|
261
|
+
return localContent;
|
|
262
|
+
}
|
|
263
|
+
// 200 response — result has `content` (handle both shapes of the union).
|
|
264
|
+
const content = result.content;
|
|
202
265
|
if (!fs.existsSync(localDir)) {
|
|
203
266
|
fs.mkdirSync(localDir, { recursive: true });
|
|
204
267
|
}
|
|
@@ -211,31 +274,40 @@ export async function executePull(client, config, diff, onProgress, concurrency)
|
|
|
211
274
|
fs.unlinkSync(localFile);
|
|
212
275
|
}
|
|
213
276
|
},
|
|
214
|
-
}, onProgress, concurrency);
|
|
277
|
+
}, onProgress, concurrency, onThrottle);
|
|
215
278
|
}
|
|
216
279
|
/**
|
|
217
280
|
* Execute a push operation: upload local changes to remote.
|
|
218
281
|
*/
|
|
219
|
-
export async function executePush(client, config, diff, onProgress, concurrency) {
|
|
282
|
+
export async function executePush(client, config, diff, onProgress, concurrency, onThrottle) {
|
|
220
283
|
return executeSyncOperation(config, diff, {
|
|
221
284
|
transfers: diff.uploads,
|
|
222
285
|
deletes: diff.deletes,
|
|
223
286
|
transferCounterKey: 'filesUploaded',
|
|
224
|
-
async transferFile(entry, cfg) {
|
|
287
|
+
async transferFile(entry, cfg, throttleCallback) {
|
|
225
288
|
const localFile = path.join(cfg.localPath, entry.path);
|
|
226
289
|
const content = fs.readFileSync(localFile, 'utf-8');
|
|
227
|
-
await retryWithBackoff(() => client.documents.put(cfg.vaultId, entry.path, content));
|
|
290
|
+
await retryWithBackoff(() => client.documents.put(cfg.vaultId, entry.path, content), throttleCallback ? () => throttleCallback(entry.path) : undefined);
|
|
228
291
|
return content;
|
|
229
292
|
},
|
|
230
293
|
async deleteFile(entry, cfg) {
|
|
231
294
|
await retryWithBackoff(() => client.documents.delete(cfg.vaultId, entry.path));
|
|
232
295
|
},
|
|
233
|
-
}, onProgress, concurrency);
|
|
296
|
+
}, onProgress, concurrency, onThrottle);
|
|
234
297
|
}
|
|
235
298
|
/**
|
|
236
|
-
* Retry a function with exponential backoff (max 3 retries)
|
|
299
|
+
* Retry a function with exponential backoff (max 3 retries) for transient
|
|
300
|
+
* network errors. Throttle (429) and quota/permission errors are NOT retried
|
|
301
|
+
* here — the SDK already retries 429s transparently (ky retry config), and
|
|
302
|
+
* quota/permission errors are not recoverable by retrying.
|
|
303
|
+
*
|
|
304
|
+
* @param onThrottle - Optional callback to invoke when a 429 is observed.
|
|
305
|
+
* The SDK will retry automatically; this is called so the CLI can update
|
|
306
|
+
* its spinner text to "Rate limited — waiting and retrying…".
|
|
307
|
+
* The parameter value is the file path being transferred.
|
|
237
308
|
*/
|
|
238
|
-
async function retryWithBackoff(fn,
|
|
309
|
+
async function retryWithBackoff(fn, onThrottle) {
|
|
310
|
+
const maxRetries = 3;
|
|
239
311
|
let lastError;
|
|
240
312
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
241
313
|
try {
|
|
@@ -244,7 +316,14 @@ async function retryWithBackoff(fn, maxRetries = 3) {
|
|
|
244
316
|
catch (err) {
|
|
245
317
|
lastError = err;
|
|
246
318
|
const message = err instanceof Error ? err.message : String(err);
|
|
247
|
-
//
|
|
319
|
+
// Throttle errors: the SDK already exhausted its own retry budget with
|
|
320
|
+
// proper Retry-After backoff. Don't layer another retry loop on top —
|
|
321
|
+
// that would ignore the server's backoff signal and hammer the API.
|
|
322
|
+
if (isThrottleError(err)) {
|
|
323
|
+
onThrottle?.('');
|
|
324
|
+
throw err;
|
|
325
|
+
}
|
|
326
|
+
// Don't retry on other non-transient errors
|
|
248
327
|
if (isQuotaError(message) || isPermissionError(message)) {
|
|
249
328
|
throw err;
|
|
250
329
|
}
|
|
@@ -256,6 +335,28 @@ async function retryWithBackoff(fn, maxRetries = 3) {
|
|
|
256
335
|
}
|
|
257
336
|
throw lastError;
|
|
258
337
|
}
|
|
338
|
+
/**
|
|
339
|
+
* Returns true when an error represents an HTTP 429 (Too Many Requests / rate
|
|
340
|
+
* limited) response. The SDK retries 429s transparently; a 429 reaching here
|
|
341
|
+
* means all retry attempts were exhausted.
|
|
342
|
+
*
|
|
343
|
+
* Prefers the structured status code the SDK attaches (RateLimitError sets
|
|
344
|
+
* `statusCode = 429`) and only falls back to a narrow message match. The
|
|
345
|
+
* message fallback is intentionally strict — it does NOT match "rate limit" or
|
|
346
|
+
* "throttle" loosely, since those words appear in unrelated errors (e.g. an
|
|
347
|
+
* authorization message mentioning a rate-limited account) and a false positive
|
|
348
|
+
* would suppress a real error.
|
|
349
|
+
*/
|
|
350
|
+
export function isThrottleError(err) {
|
|
351
|
+
if (err && typeof err === 'object') {
|
|
352
|
+
const code = err.statusCode
|
|
353
|
+
?? err.status;
|
|
354
|
+
if (code === 429)
|
|
355
|
+
return true;
|
|
356
|
+
}
|
|
357
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
358
|
+
return /\b429\b|too many requests/i.test(message);
|
|
359
|
+
}
|
|
259
360
|
function isQuotaError(message) {
|
|
260
361
|
return /quota|storage limit|limit exceeded/i.test(message);
|
|
261
362
|
}
|
package/dist/sync/ignore.js
CHANGED
|
@@ -12,6 +12,7 @@ export const DEFAULT_IGNORE_PATTERNS = [
|
|
|
12
12
|
'.hg/',
|
|
13
13
|
'node_modules/',
|
|
14
14
|
'*.tmp',
|
|
15
|
+
'*.tmp.*',
|
|
15
16
|
'.DS_Store',
|
|
16
17
|
'Thumbs.db',
|
|
17
18
|
'.lsvault/',
|
|
@@ -50,6 +51,7 @@ export function resolveIgnorePatterns(configIgnore, localPath) {
|
|
|
50
51
|
* The docPath should be a relative path using forward slashes.
|
|
51
52
|
*/
|
|
52
53
|
export function shouldIgnore(docPath, patterns) {
|
|
54
|
+
const basename = path.posix.basename(docPath);
|
|
53
55
|
for (const pattern of patterns) {
|
|
54
56
|
// Directory patterns (ending with /)
|
|
55
57
|
if (pattern.endsWith('/')) {
|
|
@@ -63,7 +65,6 @@ export function shouldIgnore(docPath, patterns) {
|
|
|
63
65
|
return true;
|
|
64
66
|
}
|
|
65
67
|
// Also check basename for file-level patterns (e.g., ".DS_Store" matches "sub/.DS_Store")
|
|
66
|
-
const basename = path.posix.basename(docPath);
|
|
67
68
|
if (minimatch(basename, pattern, { dot: true })) {
|
|
68
69
|
return true;
|
|
69
70
|
}
|
|
@@ -8,6 +8,8 @@ import { shouldIgnore } from './ignore.js';
|
|
|
8
8
|
import { loadSyncState, saveSyncState, hashFileContent, buildRemoteFileState } from './state.js';
|
|
9
9
|
import { updateLastSync } from './config.js';
|
|
10
10
|
import { resolveConflict, detectConflict, createConflictFile, formatConflictLog } from './conflict.js';
|
|
11
|
+
import { isThrottleError } from './engine.js';
|
|
12
|
+
import { atomicWriteFileSync } from './atomic-write.js';
|
|
11
13
|
/**
|
|
12
14
|
* Creates and starts a remote poller for a sync configuration.
|
|
13
15
|
* Returns a stop function.
|
|
@@ -91,9 +93,7 @@ export function createRemotePoller(client, config, options) {
|
|
|
91
93
|
if (resolution === 'remote') {
|
|
92
94
|
conflictFile = createConflictFile(config.localPath, change.path, localContent, 'local');
|
|
93
95
|
onLocalWrite?.(change.path);
|
|
94
|
-
|
|
95
|
-
fs.writeFileSync(tmpConflict, content, 'utf-8');
|
|
96
|
-
fs.renameSync(tmpConflict, localFile);
|
|
96
|
+
atomicWriteFileSync(localFile, content, 'utf-8');
|
|
97
97
|
log(`Conflict: ${change.path} — used remote, saved local as ${conflictFile}`);
|
|
98
98
|
}
|
|
99
99
|
else {
|
|
@@ -117,9 +117,7 @@ export function createRemotePoller(client, config, options) {
|
|
|
117
117
|
fs.mkdirSync(dir, { recursive: true });
|
|
118
118
|
}
|
|
119
119
|
onLocalWrite?.(change.path);
|
|
120
|
-
|
|
121
|
-
fs.writeFileSync(tmpFile, content, 'utf-8');
|
|
122
|
-
fs.renameSync(tmpFile, localFile);
|
|
120
|
+
atomicWriteFileSync(localFile, content, 'utf-8');
|
|
123
121
|
log(`Pulled: ${change.path}`);
|
|
124
122
|
changes++;
|
|
125
123
|
state.local[change.path] = {
|
|
@@ -154,7 +152,17 @@ export function createRemotePoller(client, config, options) {
|
|
|
154
152
|
}
|
|
155
153
|
}
|
|
156
154
|
catch (err) {
|
|
157
|
-
|
|
155
|
+
if (isThrottleError(err)) {
|
|
156
|
+
// The SDK already retried the request with Retry-After backoff and
|
|
157
|
+
// exhausted its retry budget. Log a warning rather than invoking
|
|
158
|
+
// onError so the daemon loop does NOT immediately re-poll on top of
|
|
159
|
+
// the backoff that the SDK already applied. The next scheduled poll
|
|
160
|
+
// (after intervalMs) will pick up the changes.
|
|
161
|
+
log('Rate limited by server — will retry on next scheduled poll');
|
|
162
|
+
}
|
|
163
|
+
else {
|
|
164
|
+
onError?.(err instanceof Error ? err : new Error(String(err)));
|
|
165
|
+
}
|
|
158
166
|
}
|
|
159
167
|
finally {
|
|
160
168
|
polling = false;
|
package/dist/sync/state.js
CHANGED
|
@@ -46,7 +46,7 @@ export function saveSyncState(state) {
|
|
|
46
46
|
fs.mkdirSync(STATE_DIR, { recursive: true, mode: 0o700 });
|
|
47
47
|
}
|
|
48
48
|
state.updatedAt = new Date().toISOString();
|
|
49
|
-
fs.writeFileSync(stateFilePath(state.syncId), JSON.stringify(state
|
|
49
|
+
fs.writeFileSync(stateFilePath(state.syncId), JSON.stringify(state) + '\n', { mode: 0o600 });
|
|
50
50
|
}
|
|
51
51
|
/**
|
|
52
52
|
* Delete sync state for a given sync configuration.
|