@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/share.ts
CHANGED
|
@@ -7,15 +7,109 @@
|
|
|
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 { uploadFile, headRemoteFile } from "../s3.js";
|
|
13
|
-
import { readJournal, writeJournal, hashFile, updateEntry } from "../journal.js";
|
|
13
|
+
import { readJournal, writeJournal, hashFile, updateEntry, normalizeEtag } from "../journal.js";
|
|
14
14
|
import { createIgnoreFilter, isWithinSizeLimit } from "../ignore.js";
|
|
15
15
|
import { resolveConflict } from "./conflict.js";
|
|
16
16
|
import type { ConflictStrategy } from "./conflict.js";
|
|
17
17
|
import type { SyncProgressEvent } from "./sync.js";
|
|
18
18
|
|
|
19
|
+
/**
|
|
20
|
+
* Stage-1 classification for a single local file in a push run. Pre-HEAD —
|
|
21
|
+
* only inputs we can evaluate locally (size limit, journal hash, optional
|
|
22
|
+
* skip-unchanged) determine the action. Files that pass classification as
|
|
23
|
+
* `upload` are still subject to a per-file HEAD + 3-way conflict check in
|
|
24
|
+
* Stage 2 before the actual PUT, so the `filesToUpload` count in the plan
|
|
25
|
+
* event is an upper bound: it includes files that may turn out to be
|
|
26
|
+
* conflicts. V1.5 follow-up: replace per-file HEAD with a single LIST so
|
|
27
|
+
* conflicts can be classified up-front and reported in the plan.
|
|
28
|
+
*/
|
|
29
|
+
type PushPlanItem =
|
|
30
|
+
| {
|
|
31
|
+
action: "upload";
|
|
32
|
+
absolutePath: string;
|
|
33
|
+
relativePath: string;
|
|
34
|
+
localHash: string;
|
|
35
|
+
size: number;
|
|
36
|
+
}
|
|
37
|
+
| {
|
|
38
|
+
action: "skip-size-limit";
|
|
39
|
+
absolutePath: string;
|
|
40
|
+
relativePath: string;
|
|
41
|
+
}
|
|
42
|
+
| {
|
|
43
|
+
action: "skip-unchanged";
|
|
44
|
+
absolutePath: string;
|
|
45
|
+
relativePath: string;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
interface PushPlan {
|
|
49
|
+
items: PushPlanItem[];
|
|
50
|
+
filesToUpload: number;
|
|
51
|
+
bytesToUpload: number;
|
|
52
|
+
filesToSkip: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Pure Stage-1 pass for push: walk the candidate file list, hash each one,
|
|
57
|
+
* apply the size-limit and skip-unchanged gates, and return a classified
|
|
58
|
+
* plan plus aggregate counts. No S3 calls, no journal writes, no event
|
|
59
|
+
* emission.
|
|
60
|
+
*
|
|
61
|
+
* The conflict count is intentionally absent from the returned `PushPlan` —
|
|
62
|
+
* detecting a push conflict requires a remote HEAD that we defer to Stage 2.
|
|
63
|
+
* Consumers that want a conflict count get it from the `complete` event.
|
|
64
|
+
*/
|
|
65
|
+
function computePushPlan(
|
|
66
|
+
filesToShare: { absolutePath: string; relativePath: string }[],
|
|
67
|
+
journal: SyncJournal,
|
|
68
|
+
skipUnchanged: boolean,
|
|
69
|
+
): PushPlan {
|
|
70
|
+
const items: PushPlanItem[] = [];
|
|
71
|
+
|
|
72
|
+
for (const { absolutePath, relativePath } of filesToShare) {
|
|
73
|
+
if (!isWithinSizeLimit(absolutePath)) {
|
|
74
|
+
items.push({ action: "skip-size-limit", absolutePath, relativePath });
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const localHash = hashFile(absolutePath);
|
|
79
|
+
|
|
80
|
+
if (skipUnchanged) {
|
|
81
|
+
const existing = journal.files[relativePath];
|
|
82
|
+
if (existing && existing.hash === localHash) {
|
|
83
|
+
items.push({ action: "skip-unchanged", absolutePath, relativePath });
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const size = fs.statSync(absolutePath).size;
|
|
89
|
+
items.push({
|
|
90
|
+
action: "upload",
|
|
91
|
+
absolutePath,
|
|
92
|
+
relativePath,
|
|
93
|
+
localHash,
|
|
94
|
+
size,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
let filesToUpload = 0;
|
|
99
|
+
let bytesToUpload = 0;
|
|
100
|
+
let filesToSkip = 0;
|
|
101
|
+
for (const item of items) {
|
|
102
|
+
if (item.action === "upload") {
|
|
103
|
+
filesToUpload++;
|
|
104
|
+
bytesToUpload += item.size;
|
|
105
|
+
} else {
|
|
106
|
+
filesToSkip++;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return { items, filesToUpload, bytesToUpload, filesToSkip };
|
|
111
|
+
}
|
|
112
|
+
|
|
19
113
|
export interface ShareOptions {
|
|
20
114
|
/** Path(s) to share (files or directories) */
|
|
21
115
|
paths: string[];
|
|
@@ -94,45 +188,68 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
|
|
|
94
188
|
// Collect all files to share
|
|
95
189
|
const filesToShare = collectFiles(paths, hqRoot, syncRoot, shouldSync);
|
|
96
190
|
|
|
97
|
-
|
|
98
|
-
|
|
191
|
+
// Stage 1: classify each file. Pre-HEAD — only inputs we can evaluate
|
|
192
|
+
// locally (size limit, journal hash, optional skip-unchanged) are
|
|
193
|
+
// considered. The plan event below carries an upper-bound `filesToUpload`
|
|
194
|
+
// (true conflicts emerge from the per-file HEAD in Stage 2 and aren't
|
|
195
|
+
// knowable here). The final `complete` event reports authoritative counts.
|
|
196
|
+
const plan = computePushPlan(filesToShare, journal, skipUnchanged === true);
|
|
197
|
+
|
|
198
|
+
emit({
|
|
199
|
+
type: "plan",
|
|
200
|
+
// share() is push-only; pull counts are sourced from sync()'s plan event.
|
|
201
|
+
filesToDownload: 0,
|
|
202
|
+
bytesToDownload: 0,
|
|
203
|
+
filesToUpload: plan.filesToUpload,
|
|
204
|
+
bytesToUpload: plan.bytesToUpload,
|
|
205
|
+
filesToSkip: plan.filesToSkip,
|
|
206
|
+
// Push conflicts require a remote HEAD; we don't yet do that in Stage 1,
|
|
207
|
+
// so this stays 0. V1.5 (single LIST) will let us classify them up-front.
|
|
208
|
+
filesToConflict: 0,
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
// Stage 2: execute. Skip items pre-classified as no-ops, then for each
|
|
212
|
+
// upload candidate run the HEAD + 3-way conflict check + actual PUT.
|
|
213
|
+
for (const item of plan.items) {
|
|
214
|
+
if (item.action === "skip-size-limit") {
|
|
99
215
|
emit({
|
|
100
216
|
type: "error",
|
|
101
|
-
path: relativePath,
|
|
217
|
+
path: item.relativePath,
|
|
102
218
|
message: "file exceeds size limit",
|
|
103
219
|
});
|
|
104
220
|
filesSkipped++;
|
|
105
221
|
continue;
|
|
106
222
|
}
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
// every file every tick. Off by default so `hq share <file>` keeps its
|
|
111
|
-
// explicit-intent semantics (user named it, user wants it sent).
|
|
112
|
-
const localHash = hashFile(absolutePath);
|
|
113
|
-
if (skipUnchanged) {
|
|
114
|
-
const existing = journal.files[relativePath];
|
|
115
|
-
if (existing && existing.hash === localHash) {
|
|
116
|
-
filesSkipped++;
|
|
117
|
-
continue;
|
|
118
|
-
}
|
|
223
|
+
if (item.action === "skip-unchanged") {
|
|
224
|
+
filesSkipped++;
|
|
225
|
+
continue;
|
|
119
226
|
}
|
|
120
227
|
|
|
228
|
+
const { absolutePath, relativePath, localHash } = item;
|
|
229
|
+
|
|
121
230
|
// Auto-refresh context if credentials expiring
|
|
122
231
|
if (isExpiringSoon(ctx.expiresAt)) {
|
|
123
232
|
ctx = await refreshEntityContext(companyRef, vaultConfig);
|
|
124
233
|
}
|
|
125
234
|
|
|
126
|
-
// Check for remote conflict — refuse to overwrite newer remote version
|
|
235
|
+
// Check for remote conflict — refuse to overwrite newer remote version.
|
|
236
|
+
//
|
|
237
|
+
// A real conflict requires BOTH sides to have moved since the last sync.
|
|
238
|
+
// The previous predicate only checked `journalEntry.hash !== localHash`,
|
|
239
|
+
// which mislabelled every local edit as a conflict and (combined with
|
|
240
|
+
// `--on-conflict keep`) silently dropped the user's edit. We now compare
|
|
241
|
+
// the current remote ETag against the one captured at last sync; when
|
|
242
|
+
// missing (legacy entries), we fall back to the same `lastModified >
|
|
243
|
+
// syncedAt` heuristic the pull side uses.
|
|
127
244
|
const remoteMeta = await headRemoteFile(ctx, relativePath);
|
|
128
245
|
if (remoteMeta) {
|
|
129
246
|
const journalEntry = journal.files[relativePath];
|
|
247
|
+
const localChanged = !!journalEntry && journalEntry.hash !== localHash;
|
|
248
|
+
const remoteChanged = !!journalEntry && hasRemoteChanged(remoteMeta, journalEntry);
|
|
130
249
|
|
|
131
|
-
|
|
132
|
-
if (journalEntry && journalEntry.hash !== localHash) {
|
|
250
|
+
if (localChanged && remoteChanged) {
|
|
133
251
|
conflictPaths.push(relativePath);
|
|
134
252
|
|
|
135
|
-
// Local has changes — check if remote also changed
|
|
136
253
|
const resolution = await resolveConflict(
|
|
137
254
|
{
|
|
138
255
|
path: relativePath,
|
|
@@ -171,10 +288,12 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
|
|
|
171
288
|
try {
|
|
172
289
|
const stat = fs.statSync(absolutePath);
|
|
173
290
|
|
|
174
|
-
await uploadFile(ctx, absolutePath, relativePath);
|
|
291
|
+
const { etag } = await uploadFile(ctx, absolutePath, relativePath);
|
|
175
292
|
|
|
176
|
-
// Update journal with optional message
|
|
177
|
-
|
|
293
|
+
// Update journal with optional message; capture the post-upload ETag
|
|
294
|
+
// so the next sync can distinguish "remote moved since we last wrote"
|
|
295
|
+
// from "user edited locally" without conflating the two.
|
|
296
|
+
updateEntry(journal, relativePath, localHash, stat.size, "up", etag);
|
|
178
297
|
if (message) {
|
|
179
298
|
journal.files[relativePath] = {
|
|
180
299
|
...journal.files[relativePath],
|
|
@@ -215,7 +334,13 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
|
|
|
215
334
|
* emitted before `onEvent` was added — tty users see no change.
|
|
216
335
|
*/
|
|
217
336
|
function defaultConsoleLogger(event: SyncProgressEvent): void {
|
|
218
|
-
if (event.type === "
|
|
337
|
+
if (event.type === "plan") {
|
|
338
|
+
if (event.filesToUpload > 0) {
|
|
339
|
+
console.log(
|
|
340
|
+
`Plan: ${event.filesToUpload} to upload (${event.bytesToUpload} bytes), ${event.filesToSkip} unchanged`,
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
} else if (event.type === "progress") {
|
|
219
344
|
if (event.message) {
|
|
220
345
|
console.log(` ✓ ${event.path} — "${event.message}"`);
|
|
221
346
|
} else {
|
|
@@ -225,7 +350,7 @@ function defaultConsoleLogger(event: SyncProgressEvent): void {
|
|
|
225
350
|
console.error(
|
|
226
351
|
` ⚠ conflict (${event.direction}): ${event.path} — ${event.resolution}`,
|
|
227
352
|
);
|
|
228
|
-
} else {
|
|
353
|
+
} else if (event.type === "error") {
|
|
229
354
|
console.error(` ✗ ${event.path} — ${event.message}`);
|
|
230
355
|
}
|
|
231
356
|
}
|
|
@@ -318,3 +443,21 @@ function isWithin(parent: string, child: string): boolean {
|
|
|
318
443
|
const rel = path.relative(parent, child);
|
|
319
444
|
return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
|
|
320
445
|
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Returns true when the remote object appears to have moved since the
|
|
449
|
+
* journal entry's last-recorded sync. Prefers ETag equality; falls back to
|
|
450
|
+
* `lastModified > syncedAt` for legacy entries written before remoteEtag
|
|
451
|
+
* was tracked. Conservative on tie (`<=` skews "remote unchanged") so an
|
|
452
|
+
* S3-side mtime that exactly equals our syncedAt is not treated as drift.
|
|
453
|
+
*/
|
|
454
|
+
function hasRemoteChanged(
|
|
455
|
+
remote: { lastModified: Date; etag: string },
|
|
456
|
+
entry: { syncedAt: string; remoteEtag?: string },
|
|
457
|
+
): boolean {
|
|
458
|
+
if (entry.remoteEtag) {
|
|
459
|
+
return normalizeEtag(remote.etag) !== entry.remoteEtag;
|
|
460
|
+
}
|
|
461
|
+
const syncedAt = new Date(entry.syncedAt).getTime();
|
|
462
|
+
return remote.lastModified.getTime() > syncedAt;
|
|
463
|
+
}
|
package/src/cli/sync.test.ts
CHANGED
|
@@ -330,4 +330,155 @@ describe("sync", () => {
|
|
|
330
330
|
// File should be overwritten with mock content
|
|
331
331
|
expect(fs.readFileSync(path.join(companyDocs, "handoff.md"), "utf-8")).toBe("mock file content");
|
|
332
332
|
});
|
|
333
|
+
|
|
334
|
+
it("does NOT flag a pull conflict when only local changed since last sync", async () => {
|
|
335
|
+
// Regression: previously, any local edit to a file that also existed on
|
|
336
|
+
// S3 produced a pull conflict because the predicate only checked
|
|
337
|
+
// `journalEntry.hash !== localHash`. With `--on-conflict keep` this
|
|
338
|
+
// silently dropped local edits during the round-trip. With remoteEtag
|
|
339
|
+
// matching the journal, the remote is known unchanged and the pull
|
|
340
|
+
// phase should leave the local edit alone for the push phase to upload.
|
|
341
|
+
const companyDocs = path.join(tmpDir, "companies", "acme", "docs");
|
|
342
|
+
fs.mkdirSync(companyDocs, { recursive: true });
|
|
343
|
+
fs.writeFileSync(path.join(companyDocs, "handoff.md"), "local edit");
|
|
344
|
+
|
|
345
|
+
fs.writeFileSync(
|
|
346
|
+
journalPath,
|
|
347
|
+
JSON.stringify({
|
|
348
|
+
version: "1",
|
|
349
|
+
lastSync: new Date().toISOString(),
|
|
350
|
+
files: {
|
|
351
|
+
"docs/handoff.md": {
|
|
352
|
+
hash: "stale-hash-from-pre-edit",
|
|
353
|
+
size: 20,
|
|
354
|
+
syncedAt: new Date(Date.now() - 3600000).toISOString(),
|
|
355
|
+
direction: "down",
|
|
356
|
+
// Matches the listRemoteFiles mock's etag for handoff.md.
|
|
357
|
+
remoteEtag: "abc123",
|
|
358
|
+
},
|
|
359
|
+
},
|
|
360
|
+
}),
|
|
361
|
+
);
|
|
362
|
+
|
|
363
|
+
const result = await sync({
|
|
364
|
+
company: "acme",
|
|
365
|
+
onConflict: "keep",
|
|
366
|
+
vaultConfig: mockConfig,
|
|
367
|
+
hqRoot: tmpDir,
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
expect(result.conflicts).toBe(0);
|
|
371
|
+
expect(result.conflictPaths).toEqual([]);
|
|
372
|
+
// Local edit must be preserved (not clobbered by download)
|
|
373
|
+
expect(fs.readFileSync(path.join(companyDocs, "handoff.md"), "utf-8")).toBe("local edit");
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it("records remoteEtag from listRemoteFiles on the journal entry after download", async () => {
|
|
377
|
+
await sync({
|
|
378
|
+
company: "acme",
|
|
379
|
+
vaultConfig: mockConfig,
|
|
380
|
+
hqRoot: tmpDir,
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
384
|
+
expect(journal.files["docs/handoff.md"].remoteEtag).toBe("abc123");
|
|
385
|
+
expect(journal.files["knowledge/readme.md"].remoteEtag).toBe("def456");
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
// ── Stage-1 plan event ─────────────────────────────────────────────────
|
|
389
|
+
|
|
390
|
+
it("emits a plan event before any progress events", async () => {
|
|
391
|
+
const events: { type: string }[] = [];
|
|
392
|
+
await sync({
|
|
393
|
+
company: "acme",
|
|
394
|
+
vaultConfig: mockConfig,
|
|
395
|
+
hqRoot: tmpDir,
|
|
396
|
+
onEvent: (e) => events.push({ type: e.type }),
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
// Plan must be the first event so consumers can use its totals as
|
|
400
|
+
// the progress denominator before any per-file events arrive.
|
|
401
|
+
expect(events.length).toBeGreaterThan(0);
|
|
402
|
+
expect(events[0].type).toBe("plan");
|
|
403
|
+
const planIndex = events.findIndex((e) => e.type === "plan");
|
|
404
|
+
const firstProgressIndex = events.findIndex((e) => e.type === "progress");
|
|
405
|
+
expect(firstProgressIndex).toBeGreaterThan(planIndex);
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
it("plan event totals reflect the upcoming Stage-2 work (all-new case)", async () => {
|
|
409
|
+
// Both mock remote files are new locally → both counted as downloads,
|
|
410
|
+
// bytes summed from listRemoteFiles, no conflicts, no skips.
|
|
411
|
+
const planEvents: unknown[] = [];
|
|
412
|
+
await sync({
|
|
413
|
+
company: "acme",
|
|
414
|
+
vaultConfig: mockConfig,
|
|
415
|
+
hqRoot: tmpDir,
|
|
416
|
+
onEvent: (e) => {
|
|
417
|
+
if (e.type === "plan") {
|
|
418
|
+
planEvents.push(e);
|
|
419
|
+
}
|
|
420
|
+
},
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
expect(planEvents).toHaveLength(1);
|
|
424
|
+
expect(planEvents[0]).toMatchObject({
|
|
425
|
+
type: "plan",
|
|
426
|
+
filesToDownload: 2,
|
|
427
|
+
bytesToDownload: 142, // 42 + 100 from the s3 mock
|
|
428
|
+
filesToUpload: 0, // sync() never plans uploads
|
|
429
|
+
bytesToUpload: 0,
|
|
430
|
+
filesToSkip: 0,
|
|
431
|
+
filesToConflict: 0,
|
|
432
|
+
});
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
it("plan event counts a 3-way conflict separately from downloads", async () => {
|
|
436
|
+
// Local edit + journal-tracked + remote ETag drifted → conflict.
|
|
437
|
+
const companyDocs = path.join(tmpDir, "companies", "acme", "docs");
|
|
438
|
+
fs.mkdirSync(companyDocs, { recursive: true });
|
|
439
|
+
fs.writeFileSync(path.join(companyDocs, "handoff.md"), "local edit");
|
|
440
|
+
|
|
441
|
+
fs.writeFileSync(
|
|
442
|
+
journalPath,
|
|
443
|
+
JSON.stringify({
|
|
444
|
+
version: "1",
|
|
445
|
+
lastSync: new Date().toISOString(),
|
|
446
|
+
files: {
|
|
447
|
+
"docs/handoff.md": {
|
|
448
|
+
hash: "stale-hash-from-pre-edit",
|
|
449
|
+
size: 20,
|
|
450
|
+
syncedAt: new Date(Date.now() - 3600000).toISOString(),
|
|
451
|
+
direction: "down",
|
|
452
|
+
// Mismatched ETag — listRemoteFiles mock returns "abc123",
|
|
453
|
+
// we record a stale one so remoteChanged is true.
|
|
454
|
+
remoteEtag: "stale-remote-etag",
|
|
455
|
+
},
|
|
456
|
+
},
|
|
457
|
+
}),
|
|
458
|
+
);
|
|
459
|
+
|
|
460
|
+
const planEvents: Array<{
|
|
461
|
+
type: string;
|
|
462
|
+
filesToDownload?: number;
|
|
463
|
+
filesToConflict?: number;
|
|
464
|
+
filesToSkip?: number;
|
|
465
|
+
}> = [];
|
|
466
|
+
await sync({
|
|
467
|
+
company: "acme",
|
|
468
|
+
onConflict: "keep",
|
|
469
|
+
vaultConfig: mockConfig,
|
|
470
|
+
hqRoot: tmpDir,
|
|
471
|
+
onEvent: (e) => {
|
|
472
|
+
if (e.type === "plan") planEvents.push(e);
|
|
473
|
+
},
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
expect(planEvents).toHaveLength(1);
|
|
477
|
+
// Conflict is counted separately; only the new file is in toDownload.
|
|
478
|
+
expect(planEvents[0]).toMatchObject({
|
|
479
|
+
filesToDownload: 1,
|
|
480
|
+
filesToConflict: 1,
|
|
481
|
+
filesToSkip: 0,
|
|
482
|
+
});
|
|
483
|
+
});
|
|
333
484
|
});
|