@indigoai-us/hq-cloud 6.14.8 → 6.14.10
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-company.d.ts.map +1 -1
- package/dist/bin/sync-runner-company.js +8 -0
- package/dist/bin/sync-runner-company.js.map +1 -1
- package/dist/bin/sync-runner-planning.d.ts +1 -1
- package/dist/bin/sync-runner-planning.d.ts.map +1 -1
- package/dist/bin/sync-runner-planning.js +15 -1
- package/dist/bin/sync-runner-planning.js.map +1 -1
- package/dist/bin/sync-runner.d.ts +12 -0
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +26 -0
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +68 -1
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +89 -14
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +105 -3
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync-scope.test.js +3 -1
- package/dist/cli/sync-scope.test.js.map +1 -1
- package/dist/cli/sync.d.ts +7 -7
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +166 -82
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +292 -27
- 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 +22 -1
- package/dist/journal.js.map +1 -1
- package/dist/manifest-reconcile.d.ts +27 -0
- package/dist/manifest-reconcile.d.ts.map +1 -0
- package/dist/manifest-reconcile.js +120 -0
- package/dist/manifest-reconcile.js.map +1 -0
- package/dist/manifest-reconcile.test.d.ts +2 -0
- package/dist/manifest-reconcile.test.d.ts.map +1 -0
- package/dist/manifest-reconcile.test.js +97 -0
- package/dist/manifest-reconcile.test.js.map +1 -0
- package/dist/personal-vault.d.ts +6 -0
- package/dist/personal-vault.d.ts.map +1 -1
- package/dist/personal-vault.js +12 -0
- package/dist/personal-vault.js.map +1 -1
- package/dist/s3.d.ts +15 -2
- package/dist/s3.d.ts.map +1 -1
- package/dist/s3.js +54 -19
- package/dist/s3.js.map +1 -1
- package/dist/s3.test.js +12 -1
- package/dist/s3.test.js.map +1 -1
- package/dist/types.d.ts +13 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/bin/sync-runner-company.ts +7 -0
- package/src/bin/sync-runner-planning.ts +16 -2
- package/src/bin/sync-runner.test.ts +82 -1
- package/src/bin/sync-runner.ts +43 -0
- package/src/cli/share.test.ts +129 -3
- package/src/cli/share.ts +114 -15
- package/src/cli/sync-scope.test.ts +3 -1
- package/src/cli/sync.test.ts +352 -26
- package/src/cli/sync.ts +226 -103
- package/src/journal.ts +28 -0
- package/src/manifest-reconcile.test.ts +107 -0
- package/src/manifest-reconcile.ts +160 -0
- package/src/personal-vault.ts +15 -0
- package/src/s3.test.ts +18 -0
- package/src/s3.ts +67 -30
- package/src/types.ts +13 -0
package/src/cli/share.ts
CHANGED
|
@@ -41,6 +41,7 @@ import {
|
|
|
41
41
|
wrapFilterWithPersonalVaultDefaults,
|
|
42
42
|
type PersonalVaultExclusion,
|
|
43
43
|
} from "../personal-vault-exclusions.js";
|
|
44
|
+
import { isPersonalVaultDeletionExcluded } from "../personal-vault.js";
|
|
44
45
|
import { resolveConflict } from "./conflict.js";
|
|
45
46
|
import type { ConflictStrategy } from "./conflict.js";
|
|
46
47
|
import type { SyncProgressEvent } from "./sync.js";
|
|
@@ -1101,7 +1102,7 @@ async function buildSharePlans(run: PushRunContext): Promise<SharePlans> {
|
|
|
1101
1102
|
run.journal,
|
|
1102
1103
|
run.options.decommissionPrefixes ?? [],
|
|
1103
1104
|
run.propagateDeletePolicy,
|
|
1104
|
-
new Set([...deletePlan.toDelete, ...deletePlan.toTombstone]),
|
|
1105
|
+
new Set([...deletePlan.toDelete.map((item) => item.key), ...deletePlan.toTombstone]),
|
|
1105
1106
|
)
|
|
1106
1107
|
: [];
|
|
1107
1108
|
|
|
@@ -1240,7 +1241,11 @@ async function executeUploads(
|
|
|
1240
1241
|
// second HQ root that shares this machine's sync journal) would otherwise read as
|
|
1241
1242
|
// `localChanged && !remoteChanged`, slip past the conflict-scoped guard below, and
|
|
1242
1243
|
// rewind the server's value — the `.last-run` watermark clobber. Skip before HEAD.
|
|
1243
|
-
|
|
1244
|
+
// board.json is the one push-side exception: local board appends are
|
|
1245
|
+
// legitimate, but must flow through the conditional PUT fence below. It
|
|
1246
|
+
// remains cloud-authoritative on pull so server automation still wins a
|
|
1247
|
+
// pull conflict.
|
|
1248
|
+
if (relativePath !== "board.json" && isCloudAuthoritative(relativePath)) {
|
|
1244
1249
|
run.emit({ type: "reconciled", path: relativePath, direction: "push" });
|
|
1245
1250
|
counters.filesSkipped++;
|
|
1246
1251
|
return;
|
|
@@ -1293,6 +1298,8 @@ async function executeUploads(
|
|
|
1293
1298
|
"up",
|
|
1294
1299
|
remoteMeta.etag,
|
|
1295
1300
|
lstat.mtimeMs,
|
|
1301
|
+
undefined,
|
|
1302
|
+
item.kind,
|
|
1296
1303
|
);
|
|
1297
1304
|
run.emit({ type: "reconciled", path: relativePath, direction: "push" });
|
|
1298
1305
|
counters.filesSkipped++;
|
|
@@ -1350,7 +1357,17 @@ async function executeUploads(
|
|
|
1350
1357
|
? await uploadSymlink(run.ctx, item.target, relativePath, run.options.author, pc)
|
|
1351
1358
|
: await uploadFile(run.ctx, absolutePath, relativePath, run.options.author, pc);
|
|
1352
1359
|
|
|
1353
|
-
updateEntry(
|
|
1360
|
+
updateEntry(
|
|
1361
|
+
run.journal,
|
|
1362
|
+
relativePath,
|
|
1363
|
+
localHash,
|
|
1364
|
+
size,
|
|
1365
|
+
"up",
|
|
1366
|
+
etag,
|
|
1367
|
+
mtimeMs,
|
|
1368
|
+
undefined,
|
|
1369
|
+
item.kind,
|
|
1370
|
+
);
|
|
1354
1371
|
if (run.message) {
|
|
1355
1372
|
run.journal.files[relativePath] = {
|
|
1356
1373
|
...run.journal.files[relativePath],
|
|
@@ -1501,14 +1518,39 @@ async function executeDeletes(
|
|
|
1501
1518
|
counters: ShareCounters,
|
|
1502
1519
|
filesRefusedStalePaths: string[],
|
|
1503
1520
|
): Promise<void> {
|
|
1504
|
-
const
|
|
1521
|
+
const deleteItems = [
|
|
1522
|
+
...deletePlan.toDelete.map((item) => ({ ...item, decommission: false })),
|
|
1523
|
+
...decommissionPlan.map((key) => ({ key, intentVersion: null, decommission: true })),
|
|
1524
|
+
];
|
|
1525
|
+
const deleteKeys = deleteItems.map((item) => item.key);
|
|
1505
1526
|
await primeObjectTransport(run.ctx, "delete", deleteKeys);
|
|
1506
|
-
for (const
|
|
1527
|
+
for (const item of deleteItems) {
|
|
1528
|
+
const { key: relativePath } = item;
|
|
1507
1529
|
if (run.vaultConfig && isExpiringSoon(run.ctx.expiresAt)) {
|
|
1508
1530
|
run.ctx = await refreshEntityContext(run.companyRef, run.vaultConfig);
|
|
1509
1531
|
}
|
|
1510
1532
|
try {
|
|
1511
1533
|
const entry = run.journal.files[relativePath];
|
|
1534
|
+
if (
|
|
1535
|
+
!item.decommission &&
|
|
1536
|
+
item.intentVersion !== null &&
|
|
1537
|
+
(!hasCurrentLocalDeleteIntent(entry) ||
|
|
1538
|
+
entry!.localDeleteIntent!.version !== item.intentVersion ||
|
|
1539
|
+
!isLocallyAbsent(path.join(run.syncRoot, relativePath)))
|
|
1540
|
+
) {
|
|
1541
|
+
counters.filesRefusedStale++;
|
|
1542
|
+
if (filesRefusedStalePaths.length < REFUSED_STALE_PATH_CAP) {
|
|
1543
|
+
filesRefusedStalePaths.push(relativePath);
|
|
1544
|
+
}
|
|
1545
|
+
run.emit({
|
|
1546
|
+
type: "delete-refused-stale-etag",
|
|
1547
|
+
path: relativePath,
|
|
1548
|
+
journalEtag: entry?.remoteEtag ?? "<missing-delete-intent>",
|
|
1549
|
+
remoteEtag: "<not-checked>",
|
|
1550
|
+
reason: "intent-changed",
|
|
1551
|
+
});
|
|
1552
|
+
continue;
|
|
1553
|
+
}
|
|
1512
1554
|
const size = entry?.size ?? 0;
|
|
1513
1555
|
await deleteRemoteFile(run.ctx, relativePath);
|
|
1514
1556
|
removeEntry(run.journal, relativePath);
|
|
@@ -2027,7 +2069,9 @@ type RefusedStaleReason =
|
|
|
2027
2069
|
| "stale-etag"
|
|
2028
2070
|
| "legacy-no-etag"
|
|
2029
2071
|
| "bulk-asymmetry"
|
|
2030
|
-
| "divergent-local"
|
|
2072
|
+
| "divergent-local"
|
|
2073
|
+
| "missing-delete-intent"
|
|
2074
|
+
| "intent-changed";
|
|
2031
2075
|
|
|
2032
2076
|
/**
|
|
2033
2077
|
* Bulk-asymmetry circuit-breaker — refuses to convert a suspiciously-large
|
|
@@ -2091,7 +2135,7 @@ function isBulkAsymmetryOverride(): boolean {
|
|
|
2091
2135
|
* uses. Emitted as `delete-refused-stale-etag` events.
|
|
2092
2136
|
*/
|
|
2093
2137
|
interface DeletePlan {
|
|
2094
|
-
toDelete: string
|
|
2138
|
+
toDelete: Array<{ key: string; intentVersion: 1 | null }>;
|
|
2095
2139
|
toTombstone: string[];
|
|
2096
2140
|
refusedStale: Array<{
|
|
2097
2141
|
key: string;
|
|
@@ -2113,6 +2157,34 @@ interface DeletePlan {
|
|
|
2113
2157
|
};
|
|
2114
2158
|
}
|
|
2115
2159
|
|
|
2160
|
+
function hasCurrentLocalDeleteIntent(
|
|
2161
|
+
entry: SyncJournal["files"][string] | undefined,
|
|
2162
|
+
): boolean {
|
|
2163
|
+
const intent = entry?.localDeleteIntent;
|
|
2164
|
+
return !!(
|
|
2165
|
+
intent &&
|
|
2166
|
+
intent.version === 1 &&
|
|
2167
|
+
entry.remoteEtag &&
|
|
2168
|
+
entry.kind &&
|
|
2169
|
+
intent.remoteEtag === entry.remoteEtag &&
|
|
2170
|
+
intent.localHash === entry.hash &&
|
|
2171
|
+
intent.localKind === entry.kind
|
|
2172
|
+
);
|
|
2173
|
+
}
|
|
2174
|
+
|
|
2175
|
+
function isLocallyAbsent(localPath: string): boolean {
|
|
2176
|
+
try {
|
|
2177
|
+
fs.lstatSync(localPath);
|
|
2178
|
+
return false;
|
|
2179
|
+
} catch (err: unknown) {
|
|
2180
|
+
const code =
|
|
2181
|
+
err && typeof err === "object" && "code" in err
|
|
2182
|
+
? (err as { code?: string }).code
|
|
2183
|
+
: undefined;
|
|
2184
|
+
return code === "ENOENT";
|
|
2185
|
+
}
|
|
2186
|
+
}
|
|
2187
|
+
|
|
2116
2188
|
/**
|
|
2117
2189
|
* Concurrency cap for the per-file HEAD-O-meter (currency-gated). Sequential
|
|
2118
2190
|
* HEADs would add ~N×(50-200ms) to a sync — for the 261-mirror real-world
|
|
@@ -2216,7 +2288,7 @@ async function computeDeletePlan(
|
|
|
2216
2288
|
// all), or queue it for HEAD (currency-gated). Keeping this synchronous
|
|
2217
2289
|
// means the HEAD pass below sees a single, deduplicated candidate list
|
|
2218
2290
|
// and the journal-mutation buckets are already settled before any I/O.
|
|
2219
|
-
type HeadCandidate = { key: string; journalEtag: string };
|
|
2291
|
+
type HeadCandidate = { key: string; journalEtag: string; intentVersion: 1 };
|
|
2220
2292
|
const headCandidates: HeadCandidate[] = [];
|
|
2221
2293
|
// Litter drain bucket — kept separate from `plan.toDelete` so the
|
|
2222
2294
|
// bulk-asymmetry breaker (which moves toDelete + headCandidates into
|
|
@@ -2263,6 +2335,10 @@ async function computeDeletePlan(
|
|
|
2263
2335
|
}
|
|
2264
2336
|
if (presentLocally) continue;
|
|
2265
2337
|
|
|
2338
|
+
if (!companyScoped && isPersonalVaultDeletionExcluded(relativeKey)) {
|
|
2339
|
+
continue;
|
|
2340
|
+
}
|
|
2341
|
+
|
|
2266
2342
|
// Vault-litter drain (6.0.2): conflict mirrors + rescue drift markers
|
|
2267
2343
|
// ALWAYS drain, bypassing `shouldSync` (which would skip them when the
|
|
2268
2344
|
// parent path is in personal-vault default exclusions like
|
|
@@ -2316,10 +2392,23 @@ async function computeDeletePlan(
|
|
|
2316
2392
|
continue;
|
|
2317
2393
|
}
|
|
2318
2394
|
|
|
2395
|
+
if (!hasCurrentLocalDeleteIntent(entry)) {
|
|
2396
|
+
plan.refusedStale.push({
|
|
2397
|
+
key: relativeKey,
|
|
2398
|
+
journalEtag: entry.remoteEtag ?? "<missing-delete-intent>",
|
|
2399
|
+
remoteEtag: "<not-checked>",
|
|
2400
|
+
reason: "missing-delete-intent",
|
|
2401
|
+
});
|
|
2402
|
+
continue;
|
|
2403
|
+
}
|
|
2404
|
+
|
|
2319
2405
|
if (policy === "all") {
|
|
2320
2406
|
// policy:"all" is the explicit-opt-out emergency-reconcile mode; the
|
|
2321
2407
|
// bulk-asymmetry guard skips this branch (caller asserted intent).
|
|
2322
|
-
plan.toDelete.push(
|
|
2408
|
+
plan.toDelete.push({
|
|
2409
|
+
key: relativeKey,
|
|
2410
|
+
intentVersion: entry.localDeleteIntent!.version,
|
|
2411
|
+
});
|
|
2323
2412
|
continue;
|
|
2324
2413
|
}
|
|
2325
2414
|
bulkCandidatePicks++;
|
|
@@ -2332,7 +2421,10 @@ async function computeDeletePlan(
|
|
|
2332
2421
|
bulkCandidatePicks--;
|
|
2333
2422
|
continue;
|
|
2334
2423
|
}
|
|
2335
|
-
plan.toDelete.push(
|
|
2424
|
+
plan.toDelete.push({
|
|
2425
|
+
key: relativeKey,
|
|
2426
|
+
intentVersion: entry.localDeleteIntent!.version,
|
|
2427
|
+
});
|
|
2336
2428
|
continue;
|
|
2337
2429
|
}
|
|
2338
2430
|
// currency-gated: queue for HEAD unless the entry is legacy (no etag).
|
|
@@ -2346,7 +2438,11 @@ async function computeDeletePlan(
|
|
|
2346
2438
|
});
|
|
2347
2439
|
continue;
|
|
2348
2440
|
}
|
|
2349
|
-
headCandidates.push({
|
|
2441
|
+
headCandidates.push({
|
|
2442
|
+
key: relativeKey,
|
|
2443
|
+
journalEtag,
|
|
2444
|
+
intentVersion: entry.localDeleteIntent!.version,
|
|
2445
|
+
});
|
|
2350
2446
|
}
|
|
2351
2447
|
|
|
2352
2448
|
// Bulk-asymmetry circuit-breaker. See `BULK_ASYMMETRY_*` constants for
|
|
@@ -2375,7 +2471,7 @@ async function computeDeletePlan(
|
|
|
2375
2471
|
samplePaths.push(key);
|
|
2376
2472
|
}
|
|
2377
2473
|
};
|
|
2378
|
-
for (const
|
|
2474
|
+
for (const item of plan.toDelete) pushRefused(item.key);
|
|
2379
2475
|
for (const c of headCandidates) pushRefused(c.key);
|
|
2380
2476
|
plan.toDelete = [];
|
|
2381
2477
|
plan.bulkAsymmetry = {
|
|
@@ -2389,7 +2485,7 @@ async function computeDeletePlan(
|
|
|
2389
2485
|
// of user content; litter cleanup is orthogonal and should never be
|
|
2390
2486
|
// refused for the same reason new-litter producer-side exclusions
|
|
2391
2487
|
// shouldn't block it.
|
|
2392
|
-
plan.toDelete.push(...litterToDelete);
|
|
2488
|
+
plan.toDelete.push(...litterToDelete.map((key) => ({ key, intentVersion: null })));
|
|
2393
2489
|
return plan;
|
|
2394
2490
|
}
|
|
2395
2491
|
|
|
@@ -2436,7 +2532,10 @@ async function computeDeletePlan(
|
|
|
2436
2532
|
}
|
|
2437
2533
|
const currentEtag = normalizeEtag(remote.etag);
|
|
2438
2534
|
if (currentEtag === candidate.journalEtag) {
|
|
2439
|
-
plan.toDelete.push(
|
|
2535
|
+
plan.toDelete.push({
|
|
2536
|
+
key: candidate.key,
|
|
2537
|
+
intentVersion: candidate.intentVersion,
|
|
2538
|
+
});
|
|
2440
2539
|
} else {
|
|
2441
2540
|
plan.refusedStale.push({
|
|
2442
2541
|
key: candidate.key,
|
|
@@ -2453,7 +2552,7 @@ async function computeDeletePlan(
|
|
|
2453
2552
|
// delete loop tombstones each key identically (DeleteObject + remove from
|
|
2454
2553
|
// journal). Merged at the end so the bulk-asymmetry breaker above had its
|
|
2455
2554
|
// chance to NOT include litter in either the numerator or the sweep.
|
|
2456
|
-
plan.toDelete.push(...litterToDelete);
|
|
2555
|
+
plan.toDelete.push(...litterToDelete.map((key) => ({ key, intentVersion: null })));
|
|
2457
2556
|
|
|
2458
2557
|
return plan;
|
|
2459
2558
|
}
|
|
@@ -34,11 +34,13 @@ const REMOTE: { current: Array<{ key: string; size: number; lastModified: Date;
|
|
|
34
34
|
],
|
|
35
35
|
};
|
|
36
36
|
|
|
37
|
-
vi.mock("../s3.js", async () => {
|
|
37
|
+
vi.mock("../s3.js", async (importOriginal) => {
|
|
38
|
+
const actual = await importOriginal<typeof import("../s3.js")>();
|
|
38
39
|
const innerFs = await import("fs");
|
|
39
40
|
const innerPath = await import("path");
|
|
40
41
|
const { vi: innerVi } = await import("vitest");
|
|
41
42
|
return {
|
|
43
|
+
...actual,
|
|
42
44
|
toPosixKey: (key: string) => key.split("\\").join("/"),
|
|
43
45
|
uploadFile: innerVi.fn().mockResolvedValue(undefined),
|
|
44
46
|
downloadFile: innerVi
|