@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
|
@@ -47,7 +47,7 @@ import type {
|
|
|
47
47
|
PendingInviteByEmail,
|
|
48
48
|
TelemetryEventsBatch,
|
|
49
49
|
} from "../vault-client.js";
|
|
50
|
-
import { VaultAuthError } from "../vault-client.js";
|
|
50
|
+
import { VaultAuthError, VaultNotFoundError } from "../vault-client.js";
|
|
51
51
|
import type { PushEvent } from "../sync/push-event.js";
|
|
52
52
|
|
|
53
53
|
// ---------------------------------------------------------------------------
|
|
@@ -219,6 +219,7 @@ function makeDeps(overrides: Partial<RunnerDeps> = {}): TestDeps {
|
|
|
219
219
|
embedded: false,
|
|
220
220
|
pendingDirty: false,
|
|
221
221
|
})),
|
|
222
|
+
reconcileManifest: vi.fn().mockResolvedValue(undefined),
|
|
222
223
|
...overrides,
|
|
223
224
|
stdout,
|
|
224
225
|
stderr,
|
|
@@ -926,6 +927,86 @@ describe("fanout-plan", () => {
|
|
|
926
927
|
expect(plan.companies).toEqual([{ uid: "cmp_ghost", slug: "cmp_ghost" }]);
|
|
927
928
|
});
|
|
928
929
|
|
|
930
|
+
it("skips a ghost membership whose entity is gone so it cannot force exit 2", async () => {
|
|
931
|
+
const sync = vi.fn().mockResolvedValue(defaultSyncResult());
|
|
932
|
+
const deps = makeDeps({
|
|
933
|
+
createVaultClient: () =>
|
|
934
|
+
makeVaultStub({
|
|
935
|
+
memberships: [
|
|
936
|
+
{ companyUid: "cmp_live" },
|
|
937
|
+
{ companyUid: "cmp_ghost" },
|
|
938
|
+
],
|
|
939
|
+
entityGet: (uid: string) => {
|
|
940
|
+
if (uid === "cmp_ghost") {
|
|
941
|
+
return Promise.reject(new VaultNotFoundError("company deleted"));
|
|
942
|
+
}
|
|
943
|
+
return Promise.resolve({
|
|
944
|
+
uid,
|
|
945
|
+
slug: "live",
|
|
946
|
+
type: "company",
|
|
947
|
+
status: "active",
|
|
948
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
949
|
+
});
|
|
950
|
+
},
|
|
951
|
+
}),
|
|
952
|
+
sync,
|
|
953
|
+
});
|
|
954
|
+
|
|
955
|
+
const code = await runRunner(["--companies"], deps);
|
|
956
|
+
|
|
957
|
+
expect(code).toBe(0);
|
|
958
|
+
expect(sync).toHaveBeenCalledTimes(1);
|
|
959
|
+
expect(sync).toHaveBeenCalledWith(
|
|
960
|
+
expect.objectContaining({ company: "cmp_live" }),
|
|
961
|
+
);
|
|
962
|
+
const plan = deps.stdout
|
|
963
|
+
.events()
|
|
964
|
+
.find((e) => e.type === "fanout-plan") as Extract<
|
|
965
|
+
RunnerEvent,
|
|
966
|
+
{ type: "fanout-plan" }
|
|
967
|
+
>;
|
|
968
|
+
expect(plan.companies).toEqual([{ uid: "cmp_live", slug: "live" }]);
|
|
969
|
+
});
|
|
970
|
+
|
|
971
|
+
it("reconciles after the personal leg completes", async () => {
|
|
972
|
+
const order: string[] = [];
|
|
973
|
+
const deps = makeDeps({
|
|
974
|
+
createVaultClient: () =>
|
|
975
|
+
makeVaultStub({
|
|
976
|
+
memberships: [{ companyUid: "cmp_acme" }],
|
|
977
|
+
entityGet: (uid: string) =>
|
|
978
|
+
Promise.resolve({
|
|
979
|
+
uid,
|
|
980
|
+
slug: "acme",
|
|
981
|
+
type: "company",
|
|
982
|
+
status: "active",
|
|
983
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
984
|
+
}),
|
|
985
|
+
listPersons: () =>
|
|
986
|
+
Promise.resolve([
|
|
987
|
+
{
|
|
988
|
+
uid: "prs_me",
|
|
989
|
+
slug: "me",
|
|
990
|
+
type: "person",
|
|
991
|
+
status: "active",
|
|
992
|
+
bucketName: "hq-vault-prs-me",
|
|
993
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
994
|
+
} as EntityInfo,
|
|
995
|
+
]),
|
|
996
|
+
}),
|
|
997
|
+
sync: vi.fn().mockImplementation(async (options: SyncOptions) => {
|
|
998
|
+
order.push(options.personalMode === true ? "personal" : "company");
|
|
999
|
+
return defaultSyncResult();
|
|
1000
|
+
}),
|
|
1001
|
+
reconcileManifest: vi.fn().mockImplementation(async () => {
|
|
1002
|
+
order.push("reconcile");
|
|
1003
|
+
}),
|
|
1004
|
+
});
|
|
1005
|
+
|
|
1006
|
+
expect(await runRunner(["--companies"], deps)).toBe(0);
|
|
1007
|
+
expect(order).toEqual(["company", "personal", "reconcile"]);
|
|
1008
|
+
});
|
|
1009
|
+
|
|
929
1010
|
it("includes entity.name on plan entries when available", async () => {
|
|
930
1011
|
const deps = makeDeps({
|
|
931
1012
|
createVaultClient: () =>
|
package/src/bin/sync-runner.ts
CHANGED
|
@@ -139,6 +139,10 @@ import {
|
|
|
139
139
|
emitFanoutPlan,
|
|
140
140
|
resolveMembershipsForRun,
|
|
141
141
|
} from "./sync-runner-planning.js";
|
|
142
|
+
import {
|
|
143
|
+
reconcileCompanyManifest,
|
|
144
|
+
type ManifestReconcileOptions,
|
|
145
|
+
} from "../manifest-reconcile.js";
|
|
142
146
|
import { executeCompanyFanout } from "./sync-runner-company.js";
|
|
143
147
|
import { rollupAllComplete } from "./sync-runner-rollup.js";
|
|
144
148
|
import { emitTelemetry } from "./sync-runner-telemetry.js";
|
|
@@ -351,6 +355,10 @@ export type RunnerEvent =
|
|
|
351
355
|
| { type: "new-files"; company: string; files: Array<{ path: string; bytes: number; addedBy: string | null }> }
|
|
352
356
|
| { type: "scope-excluded"; company: string; count: number; samplePaths: string[] }
|
|
353
357
|
| { type: "ignore-excluded"; company: string; count: number; totalExcluded: number; samplePaths: string[] }
|
|
358
|
+
| ({ type: "skip-invalid-scoped-key"; company: string } & Omit<
|
|
359
|
+
Extract<SyncProgressEvent, { type: "skip-invalid-scoped-key" }>,
|
|
360
|
+
"type"
|
|
361
|
+
>)
|
|
354
362
|
| ({
|
|
355
363
|
type: "complete";
|
|
356
364
|
company: string;
|
|
@@ -603,6 +611,14 @@ export interface RunnerDeps {
|
|
|
603
611
|
hqRoot: string,
|
|
604
612
|
options: QmdReindexOptions,
|
|
605
613
|
) => ReturnType<typeof reindexAfterSync>;
|
|
614
|
+
/**
|
|
615
|
+
* Merge successfully pulled cloud companies into the local manifest after
|
|
616
|
+
* the complete fanout. Injectable so runner tests do not touch a developer's
|
|
617
|
+
* HQ root.
|
|
618
|
+
*/
|
|
619
|
+
reconcileManifest?: (
|
|
620
|
+
options: ManifestReconcileOptions,
|
|
621
|
+
) => Promise<void>;
|
|
606
622
|
/** Internal: set when runRunner is invoked under the per-root operation lock. */
|
|
607
623
|
operationLockAlreadyHeld?: boolean;
|
|
608
624
|
/** Share function (push phase). Defaults to `cli/share.share`. */
|
|
@@ -1311,6 +1327,33 @@ export async function runRunner(
|
|
|
1311
1327
|
const { errors, allConflicts, meaningfulOutcomeCount } = fanout;
|
|
1312
1328
|
const rollup = rollupAllComplete(plan, fanout.stateByCompany);
|
|
1313
1329
|
|
|
1330
|
+
// The personal-vault target runs last and can pull an older copy of the
|
|
1331
|
+
// manifest. Reconcile only after the entire pull fanout has settled so the
|
|
1332
|
+
// successfully materialized cloud directories restore their own entries.
|
|
1333
|
+
if (parsed.direction !== "push") {
|
|
1334
|
+
try {
|
|
1335
|
+
const completedCompanySlugs = new Set(
|
|
1336
|
+
Array.from(fanout.stateByCompany.entries())
|
|
1337
|
+
.filter(([, state]) => state.status === "complete")
|
|
1338
|
+
.map(([slug]) => slug),
|
|
1339
|
+
);
|
|
1340
|
+
await (deps.reconcileManifest ?? reconcileCompanyManifest)({
|
|
1341
|
+
hqRoot: parsed.hqRoot,
|
|
1342
|
+
targets: plan,
|
|
1343
|
+
completedCompanySlugs,
|
|
1344
|
+
getEntity: client.entity.get,
|
|
1345
|
+
});
|
|
1346
|
+
} catch (err) {
|
|
1347
|
+
reportDiagnostic({
|
|
1348
|
+
component: "manifest-reconcile",
|
|
1349
|
+
event: "runner.manifest_reconcile.failed",
|
|
1350
|
+
message: "post-fanout manifest reconciliation failed",
|
|
1351
|
+
err,
|
|
1352
|
+
context: { hqRoot: parsed.hqRoot },
|
|
1353
|
+
});
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1314
1357
|
if (rollup.needsReindex) {
|
|
1315
1358
|
try {
|
|
1316
1359
|
const reindexOptions = {
|
package/src/cli/share.test.ts
CHANGED
|
@@ -71,6 +71,19 @@ function makeEntityContext(overrides: Partial<EntityContext> = {}): EntityContex
|
|
|
71
71
|
};
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
function deleteIntent(
|
|
75
|
+
remoteEtag: string,
|
|
76
|
+
localHash: string,
|
|
77
|
+
localKind: "file" | "symlink" = "file",
|
|
78
|
+
) {
|
|
79
|
+
return {
|
|
80
|
+
version: 1 as const,
|
|
81
|
+
remoteEtag,
|
|
82
|
+
localHash,
|
|
83
|
+
localKind,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
74
87
|
describe("wrapFilterWithIgnoreVisibility", () => {
|
|
75
88
|
it("records noteworthy base-ignore exclusions while counting all rejections", () => {
|
|
76
89
|
const hqRoot = path.join("/tmp", "hqroot");
|
|
@@ -1950,6 +1963,12 @@ describe("share", () => {
|
|
|
1950
1963
|
syncedAt: new Date().toISOString(),
|
|
1951
1964
|
direction: "up",
|
|
1952
1965
|
remoteEtag: "knowledge-etag",
|
|
1966
|
+
kind: "symlink",
|
|
1967
|
+
localDeleteIntent: deleteIntent(
|
|
1968
|
+
"knowledge-etag",
|
|
1969
|
+
"irrelevant-not-checked",
|
|
1970
|
+
"symlink",
|
|
1971
|
+
),
|
|
1953
1972
|
},
|
|
1954
1973
|
},
|
|
1955
1974
|
}),
|
|
@@ -2189,6 +2208,11 @@ describe("share", () => {
|
|
|
2189
2208
|
syncedAt: new Date().toISOString(),
|
|
2190
2209
|
direction: "up",
|
|
2191
2210
|
remoteEtag: "gone-etag",
|
|
2211
|
+
kind: "file",
|
|
2212
|
+
localDeleteIntent: deleteIntent(
|
|
2213
|
+
"gone-etag",
|
|
2214
|
+
"irrelevant-not-checked-here",
|
|
2215
|
+
),
|
|
2192
2216
|
},
|
|
2193
2217
|
},
|
|
2194
2218
|
}),
|
|
@@ -2231,6 +2255,9 @@ describe("share", () => {
|
|
|
2231
2255
|
size: 42,
|
|
2232
2256
|
syncedAt: new Date().toISOString(),
|
|
2233
2257
|
direction: "up",
|
|
2258
|
+
remoteEtag: "removed-etag",
|
|
2259
|
+
kind: "file",
|
|
2260
|
+
localDeleteIntent: deleteIntent("removed-etag", "h"),
|
|
2234
2261
|
},
|
|
2235
2262
|
},
|
|
2236
2263
|
}),
|
|
@@ -2318,6 +2345,9 @@ describe("share", () => {
|
|
|
2318
2345
|
size: 1,
|
|
2319
2346
|
syncedAt: new Date().toISOString(),
|
|
2320
2347
|
direction: "up",
|
|
2348
|
+
remoteEtag: "in-scope-etag",
|
|
2349
|
+
kind: "file",
|
|
2350
|
+
localDeleteIntent: deleteIntent("in-scope-etag", "h"),
|
|
2321
2351
|
},
|
|
2322
2352
|
"other/also-gone.md": {
|
|
2323
2353
|
hash: "h",
|
|
@@ -2371,6 +2401,9 @@ describe("share", () => {
|
|
|
2371
2401
|
size: 5,
|
|
2372
2402
|
syncedAt: new Date().toISOString(),
|
|
2373
2403
|
direction: "up",
|
|
2404
|
+
remoteEtag: "flaky-etag",
|
|
2405
|
+
kind: "file",
|
|
2406
|
+
localDeleteIntent: deleteIntent("flaky-etag", "h"),
|
|
2374
2407
|
},
|
|
2375
2408
|
},
|
|
2376
2409
|
}),
|
|
@@ -2446,6 +2479,9 @@ describe("share", () => {
|
|
|
2446
2479
|
"i-uploaded.md": {
|
|
2447
2480
|
hash: "h", size: 1, syncedAt: new Date().toISOString(),
|
|
2448
2481
|
direction: "up",
|
|
2482
|
+
remoteEtag: "uploaded-etag",
|
|
2483
|
+
kind: "file",
|
|
2484
|
+
localDeleteIntent: deleteIntent("uploaded-etag", "h"),
|
|
2449
2485
|
},
|
|
2450
2486
|
},
|
|
2451
2487
|
}),
|
|
@@ -2482,6 +2518,9 @@ describe("share", () => {
|
|
|
2482
2518
|
"pulled.md": {
|
|
2483
2519
|
hash: "h", size: 1, syncedAt: new Date().toISOString(),
|
|
2484
2520
|
direction: "down",
|
|
2521
|
+
remoteEtag: "pulled-etag",
|
|
2522
|
+
kind: "file",
|
|
2523
|
+
localDeleteIntent: deleteIntent("pulled-etag", "h"),
|
|
2485
2524
|
},
|
|
2486
2525
|
},
|
|
2487
2526
|
}),
|
|
@@ -2526,6 +2565,9 @@ describe("share", () => {
|
|
|
2526
2565
|
"active/current.md": {
|
|
2527
2566
|
hash: "h", size: 1, syncedAt: new Date().toISOString(),
|
|
2528
2567
|
direction: "up",
|
|
2568
|
+
remoteEtag: "active-etag",
|
|
2569
|
+
kind: "file",
|
|
2570
|
+
localDeleteIntent: deleteIntent("active-etag", "h"),
|
|
2529
2571
|
},
|
|
2530
2572
|
},
|
|
2531
2573
|
}),
|
|
@@ -2586,6 +2628,8 @@ describe("share", () => {
|
|
|
2586
2628
|
hash: "h", size: 100, syncedAt: new Date().toISOString(),
|
|
2587
2629
|
direction: "down",
|
|
2588
2630
|
remoteEtag: "abc123",
|
|
2631
|
+
kind: "file",
|
|
2632
|
+
localDeleteIntent: deleteIntent("abc123", "h"),
|
|
2589
2633
|
},
|
|
2590
2634
|
},
|
|
2591
2635
|
}),
|
|
@@ -2637,6 +2681,8 @@ describe("share", () => {
|
|
|
2637
2681
|
syncedAt: new Date().toISOString(),
|
|
2638
2682
|
direction: "down",
|
|
2639
2683
|
remoteEtag: "abc123",
|
|
2684
|
+
kind: "file",
|
|
2685
|
+
localDeleteIntent: deleteIntent("abc123", "h"),
|
|
2640
2686
|
removedAt: new Date().toISOString(),
|
|
2641
2687
|
removedReason: "local-delete",
|
|
2642
2688
|
},
|
|
@@ -2688,6 +2734,8 @@ describe("share", () => {
|
|
|
2688
2734
|
hash: "h", size: 50, syncedAt: new Date().toISOString(),
|
|
2689
2735
|
direction: "down",
|
|
2690
2736
|
remoteEtag: "stale-etag",
|
|
2737
|
+
kind: "file",
|
|
2738
|
+
localDeleteIntent: deleteIntent("stale-etag", "h"),
|
|
2691
2739
|
},
|
|
2692
2740
|
},
|
|
2693
2741
|
}),
|
|
@@ -2733,6 +2781,64 @@ describe("share", () => {
|
|
|
2733
2781
|
expect(result.filesTombstoned).toBe(0);
|
|
2734
2782
|
});
|
|
2735
2783
|
|
|
2784
|
+
it("currency-gated: preserves a file recreated after the delete plan", async () => {
|
|
2785
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
2786
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
2787
|
+
const recreatedPath = path.join(companyRoot, "racy.md");
|
|
2788
|
+
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
2789
|
+
fs.writeFileSync(
|
|
2790
|
+
journalPath,
|
|
2791
|
+
JSON.stringify({
|
|
2792
|
+
version: "2",
|
|
2793
|
+
lastSync: new Date().toISOString(),
|
|
2794
|
+
files: {
|
|
2795
|
+
"racy.md": {
|
|
2796
|
+
hash: "synced-hash",
|
|
2797
|
+
size: 10,
|
|
2798
|
+
syncedAt: new Date().toISOString(),
|
|
2799
|
+
direction: "up",
|
|
2800
|
+
remoteEtag: "racy-etag",
|
|
2801
|
+
kind: "file",
|
|
2802
|
+
localDeleteIntent: deleteIntent("racy-etag", "synced-hash"),
|
|
2803
|
+
},
|
|
2804
|
+
},
|
|
2805
|
+
pulls: [],
|
|
2806
|
+
}),
|
|
2807
|
+
);
|
|
2808
|
+
|
|
2809
|
+
// The currency HEAD establishes the remote revision, then a local writer
|
|
2810
|
+
// recreates the file before the planned DeleteObject can run.
|
|
2811
|
+
vi.mocked(headRemoteFile).mockImplementationOnce(async () => {
|
|
2812
|
+
fs.writeFileSync(recreatedPath, "recreated after planning");
|
|
2813
|
+
return { lastModified: new Date(), etag: '"racy-etag"', size: 10 };
|
|
2814
|
+
});
|
|
2815
|
+
|
|
2816
|
+
const events: Array<{ type: string; path?: string; reason?: string }> = [];
|
|
2817
|
+
const result = await share({
|
|
2818
|
+
paths: [companyRoot],
|
|
2819
|
+
company: "acme",
|
|
2820
|
+
vaultConfig: mockConfig,
|
|
2821
|
+
hqRoot: tmpDir,
|
|
2822
|
+
skipUnchanged: true,
|
|
2823
|
+
propagateDeletes: true,
|
|
2824
|
+
propagateDeletePolicy: "currency-gated",
|
|
2825
|
+
onEvent: (e) => events.push(e as { type: string }),
|
|
2826
|
+
});
|
|
2827
|
+
|
|
2828
|
+
expect(result.filesDeleted).toBe(0);
|
|
2829
|
+
expect(deleteRemoteFile).not.toHaveBeenCalled();
|
|
2830
|
+
expect(fs.readFileSync(recreatedPath, "utf-8")).toBe("recreated after planning");
|
|
2831
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
2832
|
+
expect(journal.files["racy.md"]).toBeDefined();
|
|
2833
|
+
expect(events).toContainEqual(
|
|
2834
|
+
expect.objectContaining({
|
|
2835
|
+
type: "delete-refused-stale-etag",
|
|
2836
|
+
path: "racy.md",
|
|
2837
|
+
reason: "intent-changed",
|
|
2838
|
+
}),
|
|
2839
|
+
);
|
|
2840
|
+
});
|
|
2841
|
+
|
|
2736
2842
|
it("currency-gated: refuses delete for a divergent-local entry (journal-honesty — never delete a remote the local never genuinely matched)", async () => {
|
|
2737
2843
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
2738
2844
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
@@ -2814,6 +2920,8 @@ describe("share", () => {
|
|
|
2814
2920
|
hash: "h", size: 25, syncedAt: new Date().toISOString(),
|
|
2815
2921
|
direction: "down",
|
|
2816
2922
|
remoteEtag: "doesnt-matter",
|
|
2923
|
+
kind: "file",
|
|
2924
|
+
localDeleteIntent: deleteIntent("doesnt-matter", "h"),
|
|
2817
2925
|
},
|
|
2818
2926
|
},
|
|
2819
2927
|
}),
|
|
@@ -2894,12 +3002,13 @@ describe("share", () => {
|
|
|
2894
3002
|
|
|
2895
3003
|
expect(result.filesDeleted).toBe(0);
|
|
2896
3004
|
expect(deleteRemoteFile).not.toHaveBeenCalled();
|
|
2897
|
-
// HEAD must NOT be called —
|
|
3005
|
+
// HEAD must NOT be called — the explicit-intent gate rejects legacy
|
|
3006
|
+
// entries before any remote operation.
|
|
2898
3007
|
expect(headRemoteFile).not.toHaveBeenCalled();
|
|
2899
3008
|
const refused = events.find((e) => e.type === "delete-refused-stale-etag");
|
|
2900
3009
|
expect(refused).toMatchObject({
|
|
2901
|
-
journalEtag: "<
|
|
2902
|
-
reason: "
|
|
3010
|
+
journalEtag: "<missing-delete-intent>",
|
|
3011
|
+
reason: "missing-delete-intent",
|
|
2903
3012
|
});
|
|
2904
3013
|
expect(result.filesRefusedStale).toBe(1);
|
|
2905
3014
|
});
|
|
@@ -2925,6 +3034,8 @@ describe("share", () => {
|
|
|
2925
3034
|
hash: "h", size: 200, syncedAt: new Date().toISOString(),
|
|
2926
3035
|
direction: "down",
|
|
2927
3036
|
remoteEtag: "upstream-etag",
|
|
3037
|
+
kind: "file",
|
|
3038
|
+
localDeleteIntent: deleteIntent("upstream-etag", "h"),
|
|
2928
3039
|
},
|
|
2929
3040
|
},
|
|
2930
3041
|
}),
|
|
@@ -3059,6 +3170,8 @@ describe("share", () => {
|
|
|
3059
3170
|
hash: "h", size: 5, syncedAt: new Date().toISOString(),
|
|
3060
3171
|
direction: "up",
|
|
3061
3172
|
remoteEtag: "regular-etag",
|
|
3173
|
+
kind: "file",
|
|
3174
|
+
localDeleteIntent: deleteIntent("regular-etag", "h"),
|
|
3062
3175
|
},
|
|
3063
3176
|
},
|
|
3064
3177
|
}),
|
|
@@ -3385,6 +3498,8 @@ describe("share", () => {
|
|
|
3385
3498
|
hash: "h", size: 1, syncedAt: new Date().toISOString(),
|
|
3386
3499
|
direction: "up",
|
|
3387
3500
|
remoteEtag: "abc",
|
|
3501
|
+
kind: "file",
|
|
3502
|
+
localDeleteIntent: deleteIntent("abc", "h"),
|
|
3388
3503
|
},
|
|
3389
3504
|
},
|
|
3390
3505
|
}),
|
|
@@ -3892,6 +4007,8 @@ describe("share", () => {
|
|
|
3892
4007
|
syncedAt: string;
|
|
3893
4008
|
direction: "up" | "down";
|
|
3894
4009
|
remoteEtag: string;
|
|
4010
|
+
kind: "file";
|
|
4011
|
+
localDeleteIntent: ReturnType<typeof deleteIntent>;
|
|
3895
4012
|
}
|
|
3896
4013
|
> = {};
|
|
3897
4014
|
for (let i = 0; i < total; i++) {
|
|
@@ -3902,6 +4019,8 @@ describe("share", () => {
|
|
|
3902
4019
|
syncedAt: new Date().toISOString(),
|
|
3903
4020
|
direction,
|
|
3904
4021
|
remoteEtag: `etag-${i}`,
|
|
4022
|
+
kind: "file",
|
|
4023
|
+
localDeleteIntent: deleteIntent(`etag-${i}`, "h"),
|
|
3905
4024
|
};
|
|
3906
4025
|
if (i >= missing) {
|
|
3907
4026
|
fs.writeFileSync(path.join(companyRoot, key), "hi");
|
|
@@ -4135,17 +4254,22 @@ describe("share", () => {
|
|
|
4135
4254
|
const files: Record<string, {
|
|
4136
4255
|
hash: string; size: number; syncedAt: string;
|
|
4137
4256
|
direction: "up" | "down"; remoteEtag: string;
|
|
4257
|
+
kind: "file"; localDeleteIntent: ReturnType<typeof deleteIntent>;
|
|
4138
4258
|
}> = {};
|
|
4139
4259
|
const now = new Date().toISOString();
|
|
4140
4260
|
for (let i = 0; i < 50; i++) {
|
|
4141
4261
|
files[`d-${i.toString().padStart(4, "0")}.md`] = {
|
|
4142
4262
|
hash: "h", size: 5, syncedAt: now,
|
|
4143
4263
|
direction: "down", remoteEtag: `down-etag-${i}`,
|
|
4264
|
+
kind: "file",
|
|
4265
|
+
localDeleteIntent: deleteIntent(`down-etag-${i}`, "h"),
|
|
4144
4266
|
};
|
|
4145
4267
|
}
|
|
4146
4268
|
files["only-up.md"] = {
|
|
4147
4269
|
hash: "h", size: 5, syncedAt: now,
|
|
4148
4270
|
direction: "up", remoteEtag: "up-etag",
|
|
4271
|
+
kind: "file",
|
|
4272
|
+
localDeleteIntent: deleteIntent("up-etag", "h"),
|
|
4149
4273
|
};
|
|
4150
4274
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
4151
4275
|
fs.writeFileSync(journalPath, JSON.stringify({ version: "1", lastSync: now, files }));
|
|
@@ -4293,6 +4417,8 @@ describe("currency-gated: journal version 2 fixtures", () => {
|
|
|
4293
4417
|
syncedAt: new Date().toISOString(),
|
|
4294
4418
|
direction: "down",
|
|
4295
4419
|
remoteEtag: "v2-etag",
|
|
4420
|
+
kind: "file",
|
|
4421
|
+
localDeleteIntent: deleteIntent("v2-etag", "h"),
|
|
4296
4422
|
},
|
|
4297
4423
|
},
|
|
4298
4424
|
pulls: [],
|