@indigoai-us/hq-cloud 5.4.5 → 5.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin/sync-runner.d.ts +13 -0
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +14 -2
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +37 -0
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +114 -24
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +212 -7
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync.d.ts +22 -0
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +174 -62
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +126 -0
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/journal.d.ts +7 -1
- package/dist/journal.d.ts.map +1 -1
- package/dist/journal.js +16 -2
- package/dist/journal.js.map +1 -1
- package/dist/s3.d.ts +3 -1
- package/dist/s3.d.ts.map +1 -1
- package/dist/s3.js +2 -1
- package/dist/s3.js.map +1 -1
- package/dist/types.d.ts +8 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/watcher.js +2 -2
- package/dist/watcher.js.map +1 -1
- package/package.json +1 -1
- package/src/bin/sync-runner.test.ts +43 -0
- package/src/bin/sync-runner.ts +25 -2
- package/src/cli/share.test.ts +257 -7
- package/src/cli/share.ts +169 -26
- package/src/cli/sync.test.ts +151 -0
- package/src/cli/sync.ts +256 -67
- package/src/journal.ts +16 -1
- package/src/s3.ts +4 -2
- package/src/types.ts +8 -0
- package/src/watcher.ts +2 -2
package/src/cli/sync.ts
CHANGED
|
@@ -7,10 +7,11 @@
|
|
|
7
7
|
|
|
8
8
|
import * as fs from "fs";
|
|
9
9
|
import * as path from "path";
|
|
10
|
-
import type { VaultServiceConfig } from "../types.js";
|
|
10
|
+
import type { VaultServiceConfig, SyncJournal } from "../types.js";
|
|
11
11
|
import { resolveEntityContext, isExpiringSoon, refreshEntityContext } from "../context.js";
|
|
12
12
|
import { downloadFile, listRemoteFiles } from "../s3.js";
|
|
13
|
-
import {
|
|
13
|
+
import type { RemoteFile } from "../s3.js";
|
|
14
|
+
import { readJournal, writeJournal, hashFile, updateEntry, getEntry, normalizeEtag } from "../journal.js";
|
|
14
15
|
import { createIgnoreFilter } from "../ignore.js";
|
|
15
16
|
import { resolveConflict } from "./conflict.js";
|
|
16
17
|
import type { ConflictStrategy, ConflictResolution } from "./conflict.js";
|
|
@@ -25,8 +26,31 @@ import type { ConflictStrategy, ConflictResolution } from "./conflict.js";
|
|
|
25
26
|
*
|
|
26
27
|
* The human CLI (`hq sync`) leaves `onEvent` undefined and falls through to
|
|
27
28
|
* `defaultConsoleLogger` below, which preserves the existing tty output.
|
|
29
|
+
*
|
|
30
|
+
* A single `plan` event is emitted once at the start of every run, before
|
|
31
|
+
* any `progress`/`conflict`/`error` events. It carries the totals derived
|
|
32
|
+
* from a Stage-1 classification pass so consumers can render an accurate
|
|
33
|
+
* progress denominator before transfers begin (the menubar's "Preparing
|
|
34
|
+
* sync…" pre-pass becomes obsolete once the runner forwards this).
|
|
28
35
|
*/
|
|
29
36
|
export type SyncProgressEvent =
|
|
37
|
+
| {
|
|
38
|
+
type: "plan";
|
|
39
|
+
/** Files this run intends to download (pull-only; 0 from share). */
|
|
40
|
+
filesToDownload: number;
|
|
41
|
+
bytesToDownload: number;
|
|
42
|
+
/** Files this run intends to upload (push-only; 0 from sync). */
|
|
43
|
+
filesToUpload: number;
|
|
44
|
+
bytesToUpload: number;
|
|
45
|
+
/** Files classified as no-op (ignored, unchanged, local-only on pull). */
|
|
46
|
+
filesToSkip: number;
|
|
47
|
+
/**
|
|
48
|
+
* Files known up-front to be conflicts. Pull-side fills this from the
|
|
49
|
+
* 3-way merge against the journal; push-side leaves it 0 because
|
|
50
|
+
* conflict detection requires a remote HEAD that runs in Stage 2.
|
|
51
|
+
*/
|
|
52
|
+
filesToConflict: number;
|
|
53
|
+
}
|
|
30
54
|
| { type: "progress"; path: string; bytes: number; message?: string }
|
|
31
55
|
| { type: "error"; path: string; message: string }
|
|
32
56
|
| {
|
|
@@ -123,93 +147,104 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
|
|
|
123
147
|
// List all remote files (IAM session policy filters at the AWS layer)
|
|
124
148
|
const remoteFiles = await listRemoteFiles(ctx);
|
|
125
149
|
|
|
126
|
-
|
|
127
|
-
|
|
150
|
+
// Stage 1: classify every remote file against the journal + local disk.
|
|
151
|
+
// Hashing happens here (not in the transfer loop) so the plan event below
|
|
152
|
+
// carries an accurate denominator before any progress events fire.
|
|
153
|
+
const plan = computePullPlan(
|
|
154
|
+
remoteFiles,
|
|
155
|
+
journal,
|
|
156
|
+
companyRoot,
|
|
157
|
+
shouldSync,
|
|
158
|
+
options.personalMode === true,
|
|
159
|
+
);
|
|
128
160
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
161
|
+
emit({
|
|
162
|
+
type: "plan",
|
|
163
|
+
filesToDownload: plan.filesToDownload,
|
|
164
|
+
bytesToDownload: plan.bytesToDownload,
|
|
165
|
+
// sync() is pull-only; push counts are sourced from share()'s plan event.
|
|
166
|
+
filesToUpload: 0,
|
|
167
|
+
bytesToUpload: 0,
|
|
168
|
+
filesToSkip: plan.filesToSkip,
|
|
169
|
+
filesToConflict: plan.filesToConflict,
|
|
170
|
+
});
|
|
133
171
|
|
|
134
|
-
|
|
135
|
-
|
|
172
|
+
// Stage 2: execute the plan. Per-item branching mirrors the pre-refactor
|
|
173
|
+
// inline loop; the only structural change is that classification has
|
|
174
|
+
// already happened (so `localHash` is reused instead of re-hashing).
|
|
175
|
+
for (const item of plan.items) {
|
|
176
|
+
if (
|
|
177
|
+
item.action === "skip-ignored" ||
|
|
178
|
+
item.action === "skip-personal-mode" ||
|
|
179
|
+
item.action === "skip-unchanged" ||
|
|
180
|
+
item.action === "skip-local-only"
|
|
181
|
+
) {
|
|
136
182
|
filesSkipped++;
|
|
137
183
|
continue;
|
|
138
184
|
}
|
|
139
185
|
|
|
140
|
-
|
|
186
|
+
const { remoteFile, localPath } = item;
|
|
187
|
+
|
|
188
|
+
// Auto-refresh context if credentials expiring (kept in execute phase
|
|
189
|
+
// because Stage 1 is fast — no need to refresh just to classify).
|
|
141
190
|
if (isExpiringSoon(ctx.expiresAt)) {
|
|
142
191
|
ctx = await refreshEntityContext(companyRef, vaultConfig);
|
|
143
192
|
}
|
|
144
193
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
if (fs.existsSync(localPath)) {
|
|
149
|
-
const localHash = hashFile(localPath);
|
|
150
|
-
|
|
151
|
-
// If local file has changed since last sync, it's a conflict
|
|
152
|
-
if (journalEntry && journalEntry.hash !== localHash) {
|
|
153
|
-
conflicts++;
|
|
154
|
-
conflictPaths.push(remoteFile.key);
|
|
155
|
-
|
|
156
|
-
const resolution = await resolveConflict(
|
|
157
|
-
{
|
|
158
|
-
path: remoteFile.key,
|
|
159
|
-
localHash,
|
|
160
|
-
remoteModified: remoteFile.lastModified,
|
|
161
|
-
localModified: fs.statSync(localPath).mtime,
|
|
162
|
-
direction: "pull",
|
|
163
|
-
},
|
|
164
|
-
onConflict,
|
|
165
|
-
);
|
|
194
|
+
if (item.action === "conflict") {
|
|
195
|
+
conflicts++;
|
|
196
|
+
conflictPaths.push(remoteFile.key);
|
|
166
197
|
|
|
167
|
-
|
|
168
|
-
|
|
198
|
+
const resolution = await resolveConflict(
|
|
199
|
+
{
|
|
169
200
|
path: remoteFile.key,
|
|
201
|
+
localHash: item.localHash,
|
|
202
|
+
remoteModified: remoteFile.lastModified,
|
|
203
|
+
localModified: fs.statSync(localPath).mtime,
|
|
170
204
|
direction: "pull",
|
|
171
|
-
|
|
172
|
-
|
|
205
|
+
},
|
|
206
|
+
onConflict,
|
|
207
|
+
);
|
|
173
208
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
filesSkipped
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
// Local unchanged since last sync — check if remote changed
|
|
192
|
-
// by comparing etag/timestamp
|
|
193
|
-
const lastSyncTime = new Date(journalEntry.syncedAt).getTime();
|
|
194
|
-
const remoteModTime = remoteFile.lastModified.getTime();
|
|
195
|
-
if (remoteModTime <= lastSyncTime) {
|
|
196
|
-
// Remote hasn't changed either — skip
|
|
197
|
-
filesSkipped++;
|
|
198
|
-
continue;
|
|
199
|
-
}
|
|
209
|
+
emit({
|
|
210
|
+
type: "conflict",
|
|
211
|
+
path: remoteFile.key,
|
|
212
|
+
direction: "pull",
|
|
213
|
+
resolution,
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
if (resolution === "abort") {
|
|
217
|
+
writeJournal(journalSlug, journal);
|
|
218
|
+
return {
|
|
219
|
+
filesDownloaded,
|
|
220
|
+
bytesDownloaded,
|
|
221
|
+
filesSkipped,
|
|
222
|
+
conflicts,
|
|
223
|
+
conflictPaths,
|
|
224
|
+
aborted: true,
|
|
225
|
+
};
|
|
200
226
|
}
|
|
227
|
+
if (resolution === "keep" || resolution === "skip") {
|
|
228
|
+
filesSkipped++;
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
// "overwrite" falls through to download
|
|
201
232
|
}
|
|
202
233
|
|
|
203
|
-
// Download
|
|
234
|
+
// Download (action === "download" or conflict resolved to "overwrite")
|
|
204
235
|
try {
|
|
205
236
|
await downloadFile(ctx, remoteFile.key, localPath);
|
|
206
237
|
|
|
207
238
|
const hash = hashFile(localPath);
|
|
208
239
|
const stat = fs.statSync(localPath);
|
|
209
|
-
|
|
240
|
+
// Capture the listing's ETag so subsequent syncs can detect remote
|
|
241
|
+
// drift independently of mtime drift.
|
|
242
|
+
updateEntry(journal, remoteFile.key, hash, stat.size, "down", remoteFile.etag);
|
|
210
243
|
|
|
211
|
-
// Attach message from journal entry if present
|
|
212
|
-
|
|
244
|
+
// Attach message from the prior journal entry if present (set by a
|
|
245
|
+
// previous `share` operation that included a --message).
|
|
246
|
+
const priorEntry = getEntry(journal, remoteFile.key);
|
|
247
|
+
const remoteJournalMessage = (priorEntry as { message?: string } | undefined)?.message;
|
|
213
248
|
emit({
|
|
214
249
|
type: "progress",
|
|
215
250
|
path: remoteFile.key,
|
|
@@ -262,6 +297,141 @@ function resolveActiveCompany(hqRoot: string): string | undefined {
|
|
|
262
297
|
return undefined;
|
|
263
298
|
}
|
|
264
299
|
|
|
300
|
+
/**
|
|
301
|
+
* Returns true when the remote object appears to have moved since the
|
|
302
|
+
* journal entry's last-recorded sync. Prefers ETag equality; falls back to
|
|
303
|
+
* `lastModified > syncedAt` for legacy entries written before remoteEtag
|
|
304
|
+
* was tracked. Conservative on tie (`<=` skews "remote unchanged").
|
|
305
|
+
*/
|
|
306
|
+
function hasRemoteChanged(
|
|
307
|
+
remote: { lastModified: Date; etag: string },
|
|
308
|
+
entry: { syncedAt: string; remoteEtag?: string },
|
|
309
|
+
): boolean {
|
|
310
|
+
if (entry.remoteEtag) {
|
|
311
|
+
return normalizeEtag(remote.etag) !== entry.remoteEtag;
|
|
312
|
+
}
|
|
313
|
+
const syncedAt = new Date(entry.syncedAt).getTime();
|
|
314
|
+
return remote.lastModified.getTime() > syncedAt;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Stage-1 classification for a single remote object. Each remote file falls
|
|
319
|
+
* into exactly one bucket; the executor in `sync()` switches on `action` to
|
|
320
|
+
* decide what to do. `localHash` is carried on `conflict` items so the
|
|
321
|
+
* executor can hand it to `resolveConflict` without re-hashing.
|
|
322
|
+
*/
|
|
323
|
+
type PullPlanItem =
|
|
324
|
+
| { action: "download"; remoteFile: RemoteFile; localPath: string }
|
|
325
|
+
| { action: "skip-ignored"; remoteFile: RemoteFile; localPath: string }
|
|
326
|
+
| { action: "skip-personal-mode"; remoteFile: RemoteFile; localPath: string }
|
|
327
|
+
| { action: "skip-unchanged"; remoteFile: RemoteFile; localPath: string }
|
|
328
|
+
| { action: "skip-local-only"; remoteFile: RemoteFile; localPath: string }
|
|
329
|
+
| {
|
|
330
|
+
action: "conflict";
|
|
331
|
+
remoteFile: RemoteFile;
|
|
332
|
+
localPath: string;
|
|
333
|
+
localHash: string;
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
interface PullPlan {
|
|
337
|
+
items: PullPlanItem[];
|
|
338
|
+
filesToDownload: number;
|
|
339
|
+
bytesToDownload: number;
|
|
340
|
+
filesToSkip: number;
|
|
341
|
+
filesToConflict: number;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Stage-1 planning pass: classify every remote file into download / skip /
|
|
346
|
+
* conflict buckets without performing any S3 transfers. Local hashes are
|
|
347
|
+
* computed here (not in the transfer loop) so the totals returned reflect
|
|
348
|
+
* the real outcome of the upcoming Stage-2 execution rather than an
|
|
349
|
+
* upper-bound guess.
|
|
350
|
+
*
|
|
351
|
+
* Pure function: no S3 calls, no journal writes, no event emission. The
|
|
352
|
+
* caller (`sync()`) is responsible for emitting the resulting plan event
|
|
353
|
+
* before iterating `items`.
|
|
354
|
+
*/
|
|
355
|
+
function computePullPlan(
|
|
356
|
+
remoteFiles: RemoteFile[],
|
|
357
|
+
journal: SyncJournal,
|
|
358
|
+
companyRoot: string,
|
|
359
|
+
shouldSync: (filePath: string) => boolean,
|
|
360
|
+
personalMode: boolean,
|
|
361
|
+
): PullPlan {
|
|
362
|
+
const items: PullPlanItem[] = [];
|
|
363
|
+
|
|
364
|
+
for (const remoteFile of remoteFiles) {
|
|
365
|
+
const localPath = path.join(companyRoot, remoteFile.key);
|
|
366
|
+
|
|
367
|
+
if (personalMode && remoteFile.key.startsWith("companies/")) {
|
|
368
|
+
items.push({ action: "skip-personal-mode", remoteFile, localPath });
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (!shouldSync(localPath)) {
|
|
373
|
+
items.push({ action: "skip-ignored", remoteFile, localPath });
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const journalEntry = getEntry(journal, remoteFile.key);
|
|
378
|
+
|
|
379
|
+
if (fs.existsSync(localPath)) {
|
|
380
|
+
const localHash = hashFile(localPath);
|
|
381
|
+
const localChanged = !!journalEntry && journalEntry.hash !== localHash;
|
|
382
|
+
const remoteChanged =
|
|
383
|
+
!!journalEntry && hasRemoteChanged(remoteFile, journalEntry);
|
|
384
|
+
|
|
385
|
+
// Mirror the original 3-way merge from the inline loop. Tested by
|
|
386
|
+
// `does NOT flag a pull conflict when only local changed since last
|
|
387
|
+
// sync` and `detects conflicts with local changes…`.
|
|
388
|
+
if (localChanged && remoteChanged) {
|
|
389
|
+
items.push({
|
|
390
|
+
action: "conflict",
|
|
391
|
+
remoteFile,
|
|
392
|
+
localPath,
|
|
393
|
+
localHash,
|
|
394
|
+
});
|
|
395
|
+
continue;
|
|
396
|
+
}
|
|
397
|
+
if (journalEntry && localChanged && !remoteChanged) {
|
|
398
|
+
items.push({ action: "skip-local-only", remoteFile, localPath });
|
|
399
|
+
continue;
|
|
400
|
+
}
|
|
401
|
+
if (journalEntry && !localChanged && !remoteChanged) {
|
|
402
|
+
items.push({ action: "skip-unchanged", remoteFile, localPath });
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
405
|
+
// No journal entry, or remote-only changed → fall through to download.
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
items.push({ action: "download", remoteFile, localPath });
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
let filesToDownload = 0;
|
|
412
|
+
let bytesToDownload = 0;
|
|
413
|
+
let filesToSkip = 0;
|
|
414
|
+
let filesToConflict = 0;
|
|
415
|
+
for (const item of items) {
|
|
416
|
+
if (item.action === "download") {
|
|
417
|
+
filesToDownload++;
|
|
418
|
+
bytesToDownload += item.remoteFile.size;
|
|
419
|
+
} else if (item.action === "conflict") {
|
|
420
|
+
filesToConflict++;
|
|
421
|
+
} else {
|
|
422
|
+
filesToSkip++;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
return {
|
|
427
|
+
items,
|
|
428
|
+
filesToDownload,
|
|
429
|
+
bytesToDownload,
|
|
430
|
+
filesToSkip,
|
|
431
|
+
filesToConflict,
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
|
|
265
435
|
/**
|
|
266
436
|
* Check if an error is an S3 access denied (expected for filtered guests).
|
|
267
437
|
*/
|
|
@@ -278,7 +448,26 @@ function isAccessDenied(err: unknown): boolean {
|
|
|
278
448
|
* without an `onEvent` see no behavioral change.
|
|
279
449
|
*/
|
|
280
450
|
function defaultConsoleLogger(event: SyncProgressEvent): void {
|
|
281
|
-
if (event.type === "
|
|
451
|
+
if (event.type === "plan") {
|
|
452
|
+
// Terse single line so humans see what's about to happen without
|
|
453
|
+
// drowning the per-file output that follows. Skip when there's
|
|
454
|
+
// nothing to do — no signal, no noise.
|
|
455
|
+
const movement = event.filesToDownload + event.filesToUpload + event.filesToConflict;
|
|
456
|
+
if (movement > 0) {
|
|
457
|
+
const parts: string[] = [];
|
|
458
|
+
if (event.filesToDownload > 0) {
|
|
459
|
+
parts.push(`${event.filesToDownload} to download (${event.bytesToDownload} bytes)`);
|
|
460
|
+
}
|
|
461
|
+
if (event.filesToUpload > 0) {
|
|
462
|
+
parts.push(`${event.filesToUpload} to upload (${event.bytesToUpload} bytes)`);
|
|
463
|
+
}
|
|
464
|
+
if (event.filesToConflict > 0) {
|
|
465
|
+
parts.push(`${event.filesToConflict} conflict(s)`);
|
|
466
|
+
}
|
|
467
|
+
parts.push(`${event.filesToSkip} unchanged`);
|
|
468
|
+
console.log(`Plan: ${parts.join(", ")}`);
|
|
469
|
+
}
|
|
470
|
+
} else if (event.type === "progress") {
|
|
282
471
|
if (event.message) {
|
|
283
472
|
console.log(` ✓ ${event.path} — "${event.message}"`);
|
|
284
473
|
} else {
|
package/src/journal.ts
CHANGED
|
@@ -80,16 +80,31 @@ export function updateEntry(
|
|
|
80
80
|
hash: string,
|
|
81
81
|
size: number,
|
|
82
82
|
direction: "up" | "down",
|
|
83
|
+
remoteEtag?: string,
|
|
83
84
|
): void {
|
|
84
|
-
|
|
85
|
+
const entry: JournalEntry = {
|
|
85
86
|
hash,
|
|
86
87
|
size,
|
|
87
88
|
syncedAt: new Date().toISOString(),
|
|
88
89
|
direction,
|
|
89
90
|
};
|
|
91
|
+
if (remoteEtag !== undefined && remoteEtag !== "") {
|
|
92
|
+
entry.remoteEtag = normalizeEtag(remoteEtag);
|
|
93
|
+
}
|
|
94
|
+
journal.files[relativePath] = entry;
|
|
90
95
|
journal.lastSync = new Date().toISOString();
|
|
91
96
|
}
|
|
92
97
|
|
|
98
|
+
/**
|
|
99
|
+
* S3 returns ETags wrapped in literal double-quotes (e.g. `"d41d8cd9..."`).
|
|
100
|
+
* Strip them so equality comparisons across HEAD / GET / PUT responses are
|
|
101
|
+
* stable regardless of which AWS SDK call surfaced the value.
|
|
102
|
+
*/
|
|
103
|
+
export function normalizeEtag(etag: string): string {
|
|
104
|
+
if (!etag) return "";
|
|
105
|
+
return etag.replace(/^"|"$/g, "");
|
|
106
|
+
}
|
|
107
|
+
|
|
93
108
|
export function getEntry(
|
|
94
109
|
journal: SyncJournal,
|
|
95
110
|
relativePath: string,
|
package/src/s3.ts
CHANGED
|
@@ -38,11 +38,11 @@ export async function uploadFile(
|
|
|
38
38
|
ctx: EntityContext,
|
|
39
39
|
localPath: string,
|
|
40
40
|
key: string,
|
|
41
|
-
): Promise<
|
|
41
|
+
): Promise<{ etag: string }> {
|
|
42
42
|
const client = buildClient(ctx);
|
|
43
43
|
const body = fs.readFileSync(localPath);
|
|
44
44
|
|
|
45
|
-
await client.send(
|
|
45
|
+
const response = await client.send(
|
|
46
46
|
new PutObjectCommand({
|
|
47
47
|
Bucket: ctx.bucketName,
|
|
48
48
|
Key: key,
|
|
@@ -50,6 +50,8 @@ export async function uploadFile(
|
|
|
50
50
|
ContentType: getMimeType(key),
|
|
51
51
|
}),
|
|
52
52
|
);
|
|
53
|
+
|
|
54
|
+
return { etag: response.ETag || "" };
|
|
53
55
|
}
|
|
54
56
|
|
|
55
57
|
export async function downloadFile(
|
package/src/types.ts
CHANGED
|
@@ -26,6 +26,14 @@ export interface JournalEntry {
|
|
|
26
26
|
size: number;
|
|
27
27
|
syncedAt: string;
|
|
28
28
|
direction: "up" | "down";
|
|
29
|
+
/**
|
|
30
|
+
* S3 ETag of the remote object as of last successful sync, normalized (no
|
|
31
|
+
* surrounding quotes). Optional for backwards compatibility: entries
|
|
32
|
+
* written before this field existed won't have it, in which case
|
|
33
|
+
* conflict detection falls back to comparing remote `lastModified`
|
|
34
|
+
* against `syncedAt`.
|
|
35
|
+
*/
|
|
36
|
+
remoteEtag?: string;
|
|
29
37
|
}
|
|
30
38
|
|
|
31
39
|
export interface SyncJournal {
|
package/src/watcher.ts
CHANGED
|
@@ -113,8 +113,8 @@ export class SyncWatcher {
|
|
|
113
113
|
const existing = journal.files[relativePath];
|
|
114
114
|
if (existing && existing.hash === hash) continue;
|
|
115
115
|
|
|
116
|
-
await uploadFile(this.ctx, change.absolutePath, relativePath);
|
|
117
|
-
updateEntry(journal, relativePath, hash, stat.size, "up");
|
|
116
|
+
const { etag } = await uploadFile(this.ctx, change.absolutePath, relativePath);
|
|
117
|
+
updateEntry(journal, relativePath, hash, stat.size, "up", etag);
|
|
118
118
|
}
|
|
119
119
|
} catch (err) {
|
|
120
120
|
console.error(
|