@1agh/maude 0.60.3 → 0.60.4
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/apps/studio/api.ts +14 -0
- package/apps/studio/client/app.jsx +43 -9
- package/apps/studio/client/panels/SyncPanel.jsx +27 -0
- package/apps/studio/context.ts +9 -0
- package/apps/studio/dist/client.bundle.js +707 -707
- package/apps/studio/http.ts +12 -1
- package/apps/studio/sync/asset-pull.ts +210 -0
- package/apps/studio/sync/asset-push.ts +111 -98
- package/apps/studio/sync/file-membership.ts +290 -0
- package/apps/studio/sync/file-pull.ts +330 -0
- package/apps/studio/sync/index.ts +198 -10
- package/apps/studio/sync/remote-docs.ts +98 -4
- package/apps/studio/sync/status.ts +25 -0
- package/apps/studio/sync/tombstone-apply.ts +131 -0
- package/apps/studio/test/cloud-managed-save-surfaces.test.ts +90 -0
- package/apps/studio/test/exporters/jobs.test.ts +10 -4
- package/apps/studio/test/git-cloud-posture.test.ts +11 -2
- package/apps/studio/test/sync-asset-pull.test.ts +161 -0
- package/apps/studio/test/sync-asset-push.test.ts +52 -2
- package/apps/studio/test/sync-file-membership.test.ts +331 -0
- package/apps/studio/test/sync-file-pull.test.ts +333 -0
- package/apps/studio/test/sync-fresh-link-parity.test.ts +267 -0
- package/apps/studio/test/sync-remote-docs.test.ts +118 -8
- package/apps/studio/test/sync-status.test.ts +18 -0
- package/apps/studio/test/sync-tombstone-apply.test.ts +111 -0
- package/package.json +8 -8
|
@@ -29,6 +29,7 @@ import type { Context, LinkedHub } from '../context.ts';
|
|
|
29
29
|
import { createHistory } from '../history.ts';
|
|
30
30
|
import { SYNTHETIC_FS_DELAY_MS } from '../hmr-broadcast.ts';
|
|
31
31
|
import { type CanvasSyncAgent, createCanvasSyncAgent } from './agent.ts';
|
|
32
|
+
import { pullAssets } from './asset-pull.ts';
|
|
32
33
|
import { isPushableAssetRel } from './asset-push.ts';
|
|
33
34
|
import { type AssetSweepHandle, runAssetSweep } from './asset-sweep.ts';
|
|
34
35
|
import { atomicWrite } from './atomic-write.ts';
|
|
@@ -43,6 +44,7 @@ import {
|
|
|
43
44
|
import { createRescanScheduler, diffCanvasSet, type RescanScheduler } from './discovery.ts';
|
|
44
45
|
import { createDocNameResolver } from './doc-name.ts';
|
|
45
46
|
import { createEchoGuard } from './echo-guard.ts';
|
|
47
|
+
import { type FilePullResult, pullFiles } from './file-pull.ts';
|
|
46
48
|
import { createFsReader, type FsReader } from './fs-mirror.ts';
|
|
47
49
|
import { getHubRecord } from './hubs-config.ts';
|
|
48
50
|
import { loadJournal, type SyncJournal } from './journal.ts';
|
|
@@ -54,11 +56,16 @@ import { createDocProjection, type DocProjection } from './projection.ts';
|
|
|
54
56
|
import {
|
|
55
57
|
describeRemoteDiff,
|
|
56
58
|
diffRemoteDocs,
|
|
57
|
-
|
|
59
|
+
fetchRemoteListing,
|
|
58
60
|
pullTargets,
|
|
61
|
+
type RemoteTombstone,
|
|
59
62
|
resolvePulledTarget,
|
|
63
|
+
slugFromDocName,
|
|
64
|
+
stateDocumentGone,
|
|
65
|
+
tombstonedSlugs,
|
|
60
66
|
} from './remote-docs.ts';
|
|
61
67
|
import { createSyncStatusStore, type SyncStatusStore } from './status.ts';
|
|
68
|
+
import { quarantineCanvas } from './tombstone-apply.ts';
|
|
62
69
|
import { writeUntrustedMarkers } from './untrusted.ts';
|
|
63
70
|
|
|
64
71
|
/** A minimum-surface stand-in for the HocuspocusProvider's runtime API. */
|
|
@@ -250,7 +257,15 @@ export interface SyncRuntime {
|
|
|
250
257
|
* discovery without the user authorising anything.
|
|
251
258
|
*/
|
|
252
259
|
adopt(canvases: readonly CanvasDescriptor[]): Promise<number>;
|
|
253
|
-
/**
|
|
260
|
+
/**
|
|
261
|
+
* Give up canvases that left the project. Returns how many were released.
|
|
262
|
+
*
|
|
263
|
+
* NOT A DELETION, and the distinction is the whole delete lane: this says
|
|
264
|
+
* "this machine stopped carrying it", which the project is right to ignore.
|
|
265
|
+
* Stating that a canvas is GONE travels on the `canvas-deleted` bus event that
|
|
266
|
+
* `api.ts` emits from its privileged delete route — the one signal that
|
|
267
|
+
* carries intent rather than a filesystem observation.
|
|
268
|
+
*/
|
|
254
269
|
release(slugs: readonly string[]): Promise<number>;
|
|
255
270
|
/**
|
|
256
271
|
* Run the local canvas rescan immediately instead of waiting for the debounce.
|
|
@@ -502,6 +517,16 @@ export function createSyncRuntime(
|
|
|
502
517
|
// hub body, so a bogus/far-future stamp must not schedule a renewal at boot.
|
|
503
518
|
let tokenExpiresAt: number | null = validExpiry(storedRecord?.expiresAt);
|
|
504
519
|
|
|
520
|
+
// feature-sync-file-plane — Plane B, behind its flag. The flag gates ONLY
|
|
521
|
+
// the new plane (the downward file pull here + the widened sweep inside
|
|
522
|
+
// `listPushableAssets`); with it off, behavior is today's, byte-for-byte.
|
|
523
|
+
const syncFilesOn = linkedHub.syncFiles === true || process.env.MAUDE_SYNC_FILES === '1';
|
|
524
|
+
// The owner-hub gate for `code-module` entries, decided from LOCAL state
|
|
525
|
+
// only: the role this machine's credential store vouched for at sign-in
|
|
526
|
+
// (never a hub-supplied claim), or the hub being this cell's own loopback
|
|
527
|
+
// pairing — where the hub and the checkout are the same trust domain.
|
|
528
|
+
const allowCodeModules = cellPairing !== null || storedRecord?.role === 'owner';
|
|
529
|
+
|
|
505
530
|
// DDR-102 — the default factory multiplexes every provider over ONE shared
|
|
506
531
|
// WebSocket per hub URL; the runtime owns its disposal (stop(), after the
|
|
507
532
|
// providers detach). An injected test factory has no shared socket.
|
|
@@ -697,6 +722,9 @@ export function createSyncRuntime(
|
|
|
697
722
|
* `stop()`. See the block that creates it and `discovery.ts`. */
|
|
698
723
|
let discoveryRescan: RescanScheduler | null = null;
|
|
699
724
|
let discoveryUnsub: (() => void) | null = null;
|
|
725
|
+
/** The outbound delete-lane subscriptions — see `noteToHub`. */
|
|
726
|
+
let deletedUnsub: (() => void) | null = null;
|
|
727
|
+
let createdUnsub: (() => void) | null = null;
|
|
700
728
|
/** Periodic remote-document poll — the hub-side half of discovery. */
|
|
701
729
|
let remotePollTimer: ReturnType<typeof setInterval> | null = null;
|
|
702
730
|
/** Assigned by `start()`; the seam `pullRemoteNow()` and tests reach. */
|
|
@@ -724,6 +752,16 @@ export function createSyncRuntime(
|
|
|
724
752
|
* project" list accumulates instead of being replaced by the latest batch. */
|
|
725
753
|
const everPulled = new Set<string>();
|
|
726
754
|
|
|
755
|
+
/**
|
|
756
|
+
* Every slug the PROJECT has deleted, as this run has learned it.
|
|
757
|
+
*
|
|
758
|
+
* The hub drops its `documents` row best-effort while the tombstone is the
|
|
759
|
+
* durable part, so a deleted canvas can still appear in one more listing. This
|
|
760
|
+
* set is what stops the pull lane from treating that listing as an invitation
|
|
761
|
+
* to write the canvas back — the resurrection, arrived at from the other side.
|
|
762
|
+
*/
|
|
763
|
+
const tombstoned = new Set<string>();
|
|
764
|
+
|
|
727
765
|
/**
|
|
728
766
|
* The LIVE descriptor set, by slug.
|
|
729
767
|
*
|
|
@@ -889,10 +927,20 @@ export function createSyncRuntime(
|
|
|
889
927
|
// design root — see `pullTargets` for why flat); local-only canvases go up
|
|
890
928
|
// as they always did. Best-effort: an older hub without the listing route,
|
|
891
929
|
// or an unreachable one, syncs exactly as before.
|
|
892
|
-
const
|
|
930
|
+
const remoteListing = await fetchRemoteListing(linkedHub.url, resolvedToken);
|
|
931
|
+
// BOOT LEARNS THE DELETIONS BEFORE IT PULLS ANYTHING. The peer-side apply
|
|
932
|
+
// lives further down (it needs the live descriptor map), so this boot pass
|
|
933
|
+
// only has to make sure the pull does not fetch a canvas the project has
|
|
934
|
+
// deleted — the first poll then quarantines whatever is still on disk. Doing
|
|
935
|
+
// it in the other order would materialise a deleted canvas on every launch
|
|
936
|
+
// and delete it again seconds later, which is worse than the bug.
|
|
937
|
+
for (const stone of remoteListing?.tombstones ?? []) {
|
|
938
|
+
const slug = slugFromDocName(stone.name);
|
|
939
|
+
if (slug) tombstoned.add(slug);
|
|
940
|
+
}
|
|
893
941
|
const remoteDiff = diffRemoteDocs(
|
|
894
942
|
localCanvases.map((c) => docNameFor(c.slug)),
|
|
895
|
-
|
|
943
|
+
remoteListing?.documents ?? null
|
|
896
944
|
);
|
|
897
945
|
// PROVISIONAL targets. The listing carries names and byte counts only — a
|
|
898
946
|
// document's own `syncMeta.path` lives INSIDE it, so every target here is
|
|
@@ -1002,6 +1050,8 @@ export function createSyncRuntime(
|
|
|
1002
1050
|
// Both were missing here too; the incremental lane just made their absence
|
|
1003
1051
|
// permanent instead of momentary. See `admitPulledBody` and MAX_PULLS_PER_POLL.
|
|
1004
1052
|
const admittedPulls = pulled
|
|
1053
|
+
// A canvas the project deleted is never pulled, however it is still listed.
|
|
1054
|
+
.filter((t) => !tombstoned.has(t.slug))
|
|
1005
1055
|
.filter((t) => admitPullTarget(ctx, t.slug, t.bodyAbs))
|
|
1006
1056
|
.filter((t) => admitPulledBody(t.slug, t.bodyAbs))
|
|
1007
1057
|
.slice(0, Math.max(0, maxPinnedRooms() - localCanvases.length));
|
|
@@ -1172,7 +1222,7 @@ export function createSyncRuntime(
|
|
|
1172
1222
|
// nobody had sent: a permanent empty frame that looked like a broken path.
|
|
1173
1223
|
// Reported three times in one day on alligators, each time with a
|
|
1174
1224
|
// different asset, which is what finally named it.
|
|
1175
|
-
if (isPushableAssetRel(rel)) scheduleAssetSweep(linkedHub.url);
|
|
1225
|
+
if (isPushableAssetRel(rel, ctx.cfg.canvasGroups)) scheduleAssetSweep(linkedHub.url);
|
|
1176
1226
|
});
|
|
1177
1227
|
|
|
1178
1228
|
/**
|
|
@@ -2083,6 +2133,74 @@ export function createSyncRuntime(
|
|
|
2083
2133
|
discoveryRescan = rescan;
|
|
2084
2134
|
discoveryUnsub = ctx.bus.on('canvas-list-update', () => rescan.schedule());
|
|
2085
2135
|
|
|
2136
|
+
// The OUTBOUND half of the delete lane. `api.ts` emits these two only from
|
|
2137
|
+
// its privileged create/delete routes, never from the filesystem watcher —
|
|
2138
|
+
// see the comment at the emit site for why that distinction is what makes
|
|
2139
|
+
// them safe to act on.
|
|
2140
|
+
const noteToHub = (slug: unknown, revive: boolean): void => {
|
|
2141
|
+
if (typeof slug !== 'string' || !slug) return;
|
|
2142
|
+
if (revive) tombstoned.delete(slug);
|
|
2143
|
+
else tombstoned.add(slug);
|
|
2144
|
+
void stateDocumentGone(linkedHub.url, token, docNameFor(slug), { revive }).then((ok) => {
|
|
2145
|
+
if (!ok) {
|
|
2146
|
+
console.warn(
|
|
2147
|
+
`[sync] could not tell the project that ${slug} was ${revive ? 're-created' : 'deleted'} — it stays ${revive ? 'buried' : 'in the project'} for other peers until this succeeds.`
|
|
2148
|
+
);
|
|
2149
|
+
}
|
|
2150
|
+
});
|
|
2151
|
+
};
|
|
2152
|
+
deletedUnsub = ctx.bus.on('canvas-deleted', (p: { slug?: unknown }) =>
|
|
2153
|
+
noteToHub(p?.slug, false)
|
|
2154
|
+
);
|
|
2155
|
+
createdUnsub = ctx.bus.on('canvas-created', (p: { slug?: unknown }) =>
|
|
2156
|
+
noteToHub(p?.slug, true)
|
|
2157
|
+
);
|
|
2158
|
+
|
|
2159
|
+
/**
|
|
2160
|
+
* Apply the project's deletions to this machine.
|
|
2161
|
+
*
|
|
2162
|
+
* The runtime releases the canvas first and moves the bytes second: a live
|
|
2163
|
+
* agent flushing its Y.Doc onto a path we are about to rename is how a
|
|
2164
|
+
* "deleted" canvas comes back as a half-written file.
|
|
2165
|
+
*
|
|
2166
|
+
* `tombstoned` outlives the individual poll. The hub drops the `documents`
|
|
2167
|
+
* row on a best-effort basis, so a tombstone and a still-listed document can
|
|
2168
|
+
* coexist for a tick; without a local memory of what was deleted, that
|
|
2169
|
+
* window is enough for the pull lane to fetch the canvas straight back.
|
|
2170
|
+
*/
|
|
2171
|
+
const applyTombstones = async (stones: readonly RemoteTombstone[]): Promise<void> => {
|
|
2172
|
+
if (stones.length === 0) return;
|
|
2173
|
+
// Remember EVERY deletion, including names this peer never had — that is
|
|
2174
|
+
// what makes the pull lane below refuse a document the project deleted but
|
|
2175
|
+
// whose row has not gone yet.
|
|
2176
|
+
for (const stone of stones) {
|
|
2177
|
+
const slug = slugFromDocName(stone.name);
|
|
2178
|
+
if (slug) tombstoned.add(slug);
|
|
2179
|
+
}
|
|
2180
|
+
const gone = tombstonedSlugs(stones, descriptors.keys());
|
|
2181
|
+
if (gone.length === 0) return;
|
|
2182
|
+
for (const slug of gone) {
|
|
2183
|
+
const canvas = descriptors.get(slug);
|
|
2184
|
+
await releaseOne(slug);
|
|
2185
|
+
if (canvas) {
|
|
2186
|
+
quarantineCanvas({
|
|
2187
|
+
designRoot: ctx.paths.designRoot,
|
|
2188
|
+
slug,
|
|
2189
|
+
lanes: {
|
|
2190
|
+
html: canvas.html,
|
|
2191
|
+
meta: canvas.meta,
|
|
2192
|
+
css: canvas.css,
|
|
2193
|
+
annotations: canvas.annotations,
|
|
2194
|
+
},
|
|
2195
|
+
});
|
|
2196
|
+
}
|
|
2197
|
+
descriptors.delete(slug);
|
|
2198
|
+
}
|
|
2199
|
+
console.log(`[sync] the project deleted ${gone.length} canvas(es): ${gone.join(', ')}`);
|
|
2200
|
+
// The set the DDR-054 §3 F3 markers describe just shrank.
|
|
2201
|
+
markUntrusted();
|
|
2202
|
+
};
|
|
2203
|
+
|
|
2086
2204
|
// ─── THE HUB HALF OF DISCOVERY ────────────────────────────────────────
|
|
2087
2205
|
//
|
|
2088
2206
|
// The rescan above sees this DISK. A document that exists only on the hub
|
|
@@ -2102,13 +2220,19 @@ export function createSyncRuntime(
|
|
|
2102
2220
|
const pullRemoteOnce = async (): Promise<void> => {
|
|
2103
2221
|
if (stopped) return;
|
|
2104
2222
|
// Read `token` at call time: a silent renewal swaps it in place.
|
|
2105
|
-
const
|
|
2223
|
+
const listing = await fetchRemoteListing(linkedHub.url, token);
|
|
2106
2224
|
// null = unreachable, refused, or a hub without the route. Not an error
|
|
2107
2225
|
// here any more than it is at boot — sync continues, we ask again later.
|
|
2108
|
-
if (
|
|
2226
|
+
if (listing === null) return;
|
|
2227
|
+
// ABSENCE BEFORE PRESENCE. A canvas the project deleted must leave before
|
|
2228
|
+
// the pull runs, or a slug that is tombstoned AND still listed (the window
|
|
2229
|
+
// between the tombstone and the row actually going) would be trashed and
|
|
2230
|
+
// immediately pulled back — the resurrection this lane exists to end,
|
|
2231
|
+
// reintroduced inside one tick.
|
|
2232
|
+
await applyTombstones(listing.tombstones);
|
|
2109
2233
|
const diff = diffRemoteDocs(
|
|
2110
2234
|
[...descriptors.keys()].map((slug) => docNameFor(slug)),
|
|
2111
|
-
|
|
2235
|
+
listing.documents
|
|
2112
2236
|
);
|
|
2113
2237
|
if (diff.hubOnly.length === 0) return;
|
|
2114
2238
|
const targets = pullTargets(
|
|
@@ -2125,6 +2249,9 @@ export function createSyncRuntime(
|
|
|
2125
2249
|
}
|
|
2126
2250
|
);
|
|
2127
2251
|
const admitted = targets
|
|
2252
|
+
// A canvas the project deleted is not a canvas to fetch, even while the
|
|
2253
|
+
// hub is still listing it — see `tombstoned`.
|
|
2254
|
+
.filter((t) => !tombstoned.has(t.slug))
|
|
2128
2255
|
.filter((t) => admitPullTarget(ctx, t.slug, t.bodyAbs))
|
|
2129
2256
|
.filter((t) => admitPulledBody(t.slug, t.bodyAbs));
|
|
2130
2257
|
const fresh = admitted.filter(
|
|
@@ -2169,10 +2296,67 @@ export function createSyncRuntime(
|
|
|
2169
2296
|
notePulledAll();
|
|
2170
2297
|
markUntrusted();
|
|
2171
2298
|
};
|
|
2299
|
+
/**
|
|
2300
|
+
* Fetch the referenced assets this machine is missing.
|
|
2301
|
+
*
|
|
2302
|
+
* RUNS ON EVERY PEER, cell included — unlike the PUSH sweep, which is a
|
|
2303
|
+
* desktop job because the desktop is the side that has the bytes. Wanting an
|
|
2304
|
+
* asset you can see referenced and do not hold is symmetric, and so is the
|
|
2305
|
+
* fix; making this desktop-only would rebuild the same one-way street facing
|
|
2306
|
+
* the other way.
|
|
2307
|
+
*
|
|
2308
|
+
* After the document poll, deliberately: a canvas that arrives in this tick
|
|
2309
|
+
* brings its references with it, and this is the pass that resolves them.
|
|
2310
|
+
*/
|
|
2311
|
+
const pullAssetsOnce = async (): Promise<void> => {
|
|
2312
|
+
if (stopped) return;
|
|
2313
|
+
await pullAssets({
|
|
2314
|
+
designRoot: ctx.paths.designRoot,
|
|
2315
|
+
hubUrl: linkedHub.url,
|
|
2316
|
+
token: () => token,
|
|
2317
|
+
});
|
|
2318
|
+
};
|
|
2319
|
+
// Cumulative per boot — the Sync panel's one line. `synced` is the last
|
|
2320
|
+
// pass's converged count; `pulled`/`conflicts` accumulate.
|
|
2321
|
+
const fileTotals = { synced: 0, pulled: 0, conflicts: 0 };
|
|
2322
|
+
const noteFilePull = (result: FilePullResult): void => {
|
|
2323
|
+
fileTotals.synced = result.skipped + result.pulled.length;
|
|
2324
|
+
fileTotals.pulled += result.pulled.length;
|
|
2325
|
+
fileTotals.conflicts += result.conflicts.length;
|
|
2326
|
+
statusStore?.updateFiles?.({ ...fileTotals });
|
|
2327
|
+
};
|
|
2328
|
+
/**
|
|
2329
|
+
* Plane B's downward pass — after the doc poll and the asset pull, so a
|
|
2330
|
+
* canvas that arrived this tick has its design system resolved in the
|
|
2331
|
+
* same tick. Flag-gated; a no-op when off.
|
|
2332
|
+
*
|
|
2333
|
+
* On a cell the hub shares the checkout, so every manifest entry is
|
|
2334
|
+
* hash-equal by construction and the pass skips itself — deliberately
|
|
2335
|
+
* NOT special-cased: the invariant covers it, and a special case would
|
|
2336
|
+
* be one more branch that can drift.
|
|
2337
|
+
*/
|
|
2338
|
+
const pullFilesOnce = async (): Promise<void> => {
|
|
2339
|
+
if (stopped || !syncFilesOn) return;
|
|
2340
|
+
const result = await pullFiles({
|
|
2341
|
+
designRoot: ctx.paths.designRoot,
|
|
2342
|
+
hubUrl: linkedHub.url,
|
|
2343
|
+
token: () => token,
|
|
2344
|
+
canvasGroups: ctx.cfg.canvasGroups,
|
|
2345
|
+
allowCodeModules,
|
|
2346
|
+
});
|
|
2347
|
+
noteFilePull(result);
|
|
2348
|
+
};
|
|
2172
2349
|
const pollRemote = (): void => {
|
|
2173
|
-
void pullRemoteOnce()
|
|
2350
|
+
void pullRemoteOnce()
|
|
2351
|
+
.then(() => pullAssetsOnce())
|
|
2352
|
+
.then(() => pullFilesOnce())
|
|
2353
|
+
.catch((err) => console.error('[sync] remote poll failed:', err));
|
|
2354
|
+
};
|
|
2355
|
+
remotePull = async () => {
|
|
2356
|
+
await pullRemoteOnce();
|
|
2357
|
+
await pullAssetsOnce();
|
|
2358
|
+
await pullFilesOnce();
|
|
2174
2359
|
};
|
|
2175
|
-
remotePull = pullRemoteOnce;
|
|
2176
2360
|
remotePollTimer = setInterval(pollRemote, REMOTE_POLL_MS);
|
|
2177
2361
|
// `setInterval` keeps a Bun process alive; a poll is not a reason for the
|
|
2178
2362
|
// dev server to refuse to exit.
|
|
@@ -2192,6 +2376,10 @@ export function createSyncRuntime(
|
|
|
2192
2376
|
attachOne = null;
|
|
2193
2377
|
discoveryUnsub?.();
|
|
2194
2378
|
discoveryUnsub = null;
|
|
2379
|
+
deletedUnsub?.();
|
|
2380
|
+
deletedUnsub = null;
|
|
2381
|
+
createdUnsub?.();
|
|
2382
|
+
createdUnsub = null;
|
|
2195
2383
|
discoveryRescan?.stop();
|
|
2196
2384
|
discoveryRescan = null;
|
|
2197
2385
|
if (remotePollTimer !== null) clearInterval(remotePollTimer);
|
|
@@ -32,6 +32,29 @@ export interface RemoteDoc {
|
|
|
32
32
|
bytes: number;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
/** One deletion the project has stated — see the hub's `tombstones.mjs`. */
|
|
36
|
+
export interface RemoteTombstone {
|
|
37
|
+
name: string;
|
|
38
|
+
deletedAt: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The whole listing: what the project HAS, and what it has DELETED.
|
|
43
|
+
*
|
|
44
|
+
* Both halves are needed, and for symmetric reasons. Presence alone made a
|
|
45
|
+
* hub-only document authority to create a local file — correct, and the reason
|
|
46
|
+
* a project arrives in full. Absence alone was un-representable, so a delete was
|
|
47
|
+
* indistinguishable from "this peer has not discovered it yet" and got refilled
|
|
48
|
+
* on the next tick. One request carries both.
|
|
49
|
+
*/
|
|
50
|
+
export interface RemoteListing {
|
|
51
|
+
documents: RemoteDoc[];
|
|
52
|
+
/** Empty against a hub older than the tombstone route — never null, because
|
|
53
|
+
* "this hub cannot say" and "nothing was deleted" call for the same
|
|
54
|
+
* behaviour: change nothing on disk. */
|
|
55
|
+
tombstones: RemoteTombstone[];
|
|
56
|
+
}
|
|
57
|
+
|
|
35
58
|
export interface RemoteDocDiff {
|
|
36
59
|
/** Documents on both sides — the ones actually syncing. */
|
|
37
60
|
shared: string[];
|
|
@@ -54,11 +77,11 @@ const LIST_TIMEOUT_MS = 6000;
|
|
|
54
77
|
* reporting improvement, and making it load-bearing would trade a real feature
|
|
55
78
|
* for a nicer message.
|
|
56
79
|
*/
|
|
57
|
-
export async function
|
|
80
|
+
export async function fetchRemoteListing(
|
|
58
81
|
hubUrl: string,
|
|
59
82
|
token: string,
|
|
60
83
|
fetchImpl: typeof fetch = fetch
|
|
61
|
-
): Promise<
|
|
84
|
+
): Promise<RemoteListing | null> {
|
|
62
85
|
try {
|
|
63
86
|
const base = hubUrl.replace(/\/+$/, '');
|
|
64
87
|
const res = await fetchImpl(`${base}/api/documents`, {
|
|
@@ -66,19 +89,64 @@ export async function fetchRemoteDocs(
|
|
|
66
89
|
signal: AbortSignal.timeout(LIST_TIMEOUT_MS),
|
|
67
90
|
});
|
|
68
91
|
if (!res.ok) return null;
|
|
69
|
-
const body = (await res.json()) as { documents?: unknown };
|
|
92
|
+
const body = (await res.json()) as { documents?: unknown; tombstones?: unknown };
|
|
70
93
|
if (!Array.isArray(body?.documents)) return null;
|
|
71
|
-
|
|
94
|
+
const documents = body.documents
|
|
72
95
|
.filter(
|
|
73
96
|
(d): d is RemoteDoc =>
|
|
74
97
|
!!d && typeof (d as RemoteDoc).name === 'string' && (d as RemoteDoc).name.length > 0
|
|
75
98
|
)
|
|
76
99
|
.map((d) => ({ name: d.name, bytes: Number(d.bytes) || 0 }));
|
|
100
|
+
// A hub without the route omits the field entirely; a hostile one could send
|
|
101
|
+
// anything. Both land on "no deletions", which changes nothing on disk — the
|
|
102
|
+
// safe direction for a signal whose only effect is to REMOVE local files.
|
|
103
|
+
const tombstones = Array.isArray(body?.tombstones)
|
|
104
|
+
? (body.tombstones as unknown[])
|
|
105
|
+
.filter(
|
|
106
|
+
(t): t is RemoteTombstone =>
|
|
107
|
+
!!t &&
|
|
108
|
+
typeof (t as RemoteTombstone).name === 'string' &&
|
|
109
|
+
(t as RemoteTombstone).name.length > 0
|
|
110
|
+
)
|
|
111
|
+
.map((t) => ({ name: t.name, deletedAt: Number(t.deletedAt) || 0 }))
|
|
112
|
+
: [];
|
|
113
|
+
return { documents, tombstones };
|
|
77
114
|
} catch {
|
|
78
115
|
return null;
|
|
79
116
|
}
|
|
80
117
|
}
|
|
81
118
|
|
|
119
|
+
/**
|
|
120
|
+
* Tell the hub a document is gone (`DELETE`) or exists again (`POST`).
|
|
121
|
+
*
|
|
122
|
+
* BEST-EFFORT, LIKE EVERY OTHER HUB CALL HERE. A hub that is old, offline or
|
|
123
|
+
* refusing means the local delete still happened — the canvas is in `_trash/`
|
|
124
|
+
* and this peer has released it. What is lost is only the PROPAGATION, and the
|
|
125
|
+
* peer retries the statement on its next poll for anything it still sees on the
|
|
126
|
+
* hub but no longer has on disk. Returning false rather than throwing keeps a
|
|
127
|
+
* failed network call from turning a successful local delete into an error the
|
|
128
|
+
* user has to interpret.
|
|
129
|
+
*/
|
|
130
|
+
export async function stateDocumentGone(
|
|
131
|
+
hubUrl: string,
|
|
132
|
+
token: string,
|
|
133
|
+
docName: string,
|
|
134
|
+
opts: { revive?: boolean; fetchImpl?: typeof fetch } = {}
|
|
135
|
+
): Promise<boolean> {
|
|
136
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
137
|
+
try {
|
|
138
|
+
const base = hubUrl.replace(/\/+$/, '');
|
|
139
|
+
const res = await fetchImpl(`${base}/api/documents/${encodeURIComponent(docName)}`, {
|
|
140
|
+
method: opts.revive ? 'POST' : 'DELETE',
|
|
141
|
+
headers: { authorization: `Bearer ${token}` },
|
|
142
|
+
signal: AbortSignal.timeout(LIST_TIMEOUT_MS),
|
|
143
|
+
});
|
|
144
|
+
return res.ok;
|
|
145
|
+
} catch {
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
82
150
|
/**
|
|
83
151
|
* Diff the hub's documents against the ones this peer syncs.
|
|
84
152
|
*
|
|
@@ -146,6 +214,32 @@ export function slugFromDocName(docName: string): string | null {
|
|
|
146
214
|
return ns ? ns[1].toLowerCase() : null;
|
|
147
215
|
}
|
|
148
216
|
|
|
217
|
+
/**
|
|
218
|
+
* The local slugs a tombstone set names — validated, deduplicated, safe.
|
|
219
|
+
*
|
|
220
|
+
* A tombstone is the one hub-supplied signal whose effect is to REMOVE work from
|
|
221
|
+
* a person's disk, so it runs through exactly the same name gate a pull does
|
|
222
|
+
* (`slugFromDocName`: whole-name match, no dots, no traversal, no extension
|
|
223
|
+
* smuggling) and anything that does not survive is dropped silently. A hub
|
|
224
|
+
* cannot name a file this way that it could not already have created.
|
|
225
|
+
*
|
|
226
|
+
* `known` is the set of slugs this peer actually holds; a tombstone for anything
|
|
227
|
+
* else is not an error, just nothing to do — that is the normal steady state
|
|
228
|
+
* once both sides have converged.
|
|
229
|
+
*/
|
|
230
|
+
export function tombstonedSlugs(
|
|
231
|
+
tombstones: readonly RemoteTombstone[],
|
|
232
|
+
known: Iterable<string>
|
|
233
|
+
): string[] {
|
|
234
|
+
const have = new Set(known);
|
|
235
|
+
const out = new Set<string>();
|
|
236
|
+
for (const t of tombstones) {
|
|
237
|
+
const slug = slugFromDocName(t.name);
|
|
238
|
+
if (slug && have.has(slug)) out.add(slug);
|
|
239
|
+
}
|
|
240
|
+
return [...out].sort();
|
|
241
|
+
}
|
|
242
|
+
|
|
149
243
|
export interface PullTarget {
|
|
150
244
|
slug: string;
|
|
151
245
|
docName: string;
|
|
@@ -53,6 +53,22 @@ export interface SyncStatusPayload extends SyncStatusSnapshot {
|
|
|
53
53
|
* payload as the doc counts so the Sync panel has one source, not two.
|
|
54
54
|
*/
|
|
55
55
|
assets?: AssetPushProgress;
|
|
56
|
+
/**
|
|
57
|
+
* feature-sync-file-plane — Plane B's counts (additive; absent until the
|
|
58
|
+
* first flag-on file pull of a boot). `conflicts` names losers parked in
|
|
59
|
+
* `_trash/<rel>-conflict-<ts>` — the panel line points there, because a
|
|
60
|
+
* conflict a person cannot find is silent loss with extra steps.
|
|
61
|
+
*/
|
|
62
|
+
files?: FilePlaneStatus;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface FilePlaneStatus {
|
|
66
|
+
/** Plane files present and equal after the last pass — the steady state. */
|
|
67
|
+
synced: number;
|
|
68
|
+
/** Cumulative files landed this boot. */
|
|
69
|
+
pulled: number;
|
|
70
|
+
/** Cumulative conflicts this boot (either winner); losers are in _trash/. */
|
|
71
|
+
conflicts: number;
|
|
56
72
|
}
|
|
57
73
|
|
|
58
74
|
export interface SyncStatusStoreOptions {
|
|
@@ -78,6 +94,9 @@ export interface SyncStatusStore {
|
|
|
78
94
|
* broadcast. Kept in the store (not the monitor): assets are a push lane,
|
|
79
95
|
* not a connection, and the monitor's state machine must not learn them. */
|
|
80
96
|
updateAssets(progress: AssetPushProgress): void;
|
|
97
|
+
/** feature-sync-file-plane — merge Plane B counts + persist + broadcast.
|
|
98
|
+
* Same reasoning as `updateAssets`: a lane, not a connection. */
|
|
99
|
+
updateFiles(files: FilePlaneStatus): void;
|
|
81
100
|
/** Current payload (defensive copy). */
|
|
82
101
|
get(): SyncStatusPayload;
|
|
83
102
|
}
|
|
@@ -102,6 +121,7 @@ export function createSyncStatusStore(opts: SyncStatusStoreOptions): SyncStatusS
|
|
|
102
121
|
};
|
|
103
122
|
|
|
104
123
|
let assets: AssetPushProgress | undefined;
|
|
124
|
+
let files: FilePlaneStatus | undefined;
|
|
105
125
|
|
|
106
126
|
function payload(): SyncStatusPayload {
|
|
107
127
|
return {
|
|
@@ -111,6 +131,7 @@ export function createSyncStatusStore(opts: SyncStatusStoreOptions): SyncStatusS
|
|
|
111
131
|
conflicts: conflicts.slice(),
|
|
112
132
|
...(opts.sharedDoc ? { sharedDoc: true } : {}),
|
|
113
133
|
...(assets ? { assets } : {}),
|
|
134
|
+
...(files ? { files } : {}),
|
|
114
135
|
};
|
|
115
136
|
}
|
|
116
137
|
|
|
@@ -142,6 +163,10 @@ export function createSyncStatusStore(opts: SyncStatusStoreOptions): SyncStatusS
|
|
|
142
163
|
assets = progress;
|
|
143
164
|
flush();
|
|
144
165
|
},
|
|
166
|
+
updateFiles(next) {
|
|
167
|
+
files = next;
|
|
168
|
+
flush();
|
|
169
|
+
},
|
|
145
170
|
get: payload,
|
|
146
171
|
};
|
|
147
172
|
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// Acting on a tombstone — the peer-side half of the delete lane.
|
|
2
|
+
//
|
|
3
|
+
// The hub states that a canvas is gone (`tombstones.mjs`); this is what a peer
|
|
4
|
+
// does about it. One rule governs the whole file:
|
|
5
|
+
//
|
|
6
|
+
// QUARANTINE, NEVER DELETE.
|
|
7
|
+
//
|
|
8
|
+
// This is the only code path where a HUB-SUPPLIED signal removes work from a
|
|
9
|
+
// person's disk, and the hub is untrusted to peers (DDR-054). Moving the bundle
|
|
10
|
+
// to `_trash/<slug>-deleted-<ts>/` is what makes that acceptable: a hub that is
|
|
11
|
+
// hostile, buggy, or simply confused about which project it is costs the user a
|
|
12
|
+
// trip to `_trash/`, never their work. The same posture, and the same directory,
|
|
13
|
+
// `migrate-flat-fallback.ts` already established for a peer-driven move.
|
|
14
|
+
//
|
|
15
|
+
// WHY THE SIDECARS TRAVEL AND THE HISTORY DOES NOT. The lanes that ARE the
|
|
16
|
+
// canvas — body, `.meta.json`, `.css`, `.annotations.svg` — move together, so
|
|
17
|
+
// what lands in `_trash/` is a restorable canvas rather than a body stripped of
|
|
18
|
+
// its annotations. `_history/<slug>/`, `_comments/`, `_canvas-state/` stay: they
|
|
19
|
+
// are per-machine runtime state (DDR-115), regenerated or irrelevant, and
|
|
20
|
+
// sweeping them would make a recoverable delete quietly lossy.
|
|
21
|
+
|
|
22
|
+
import { existsSync, mkdirSync, renameSync } from 'node:fs';
|
|
23
|
+
import path from 'node:path';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Park ONE file in `_trash/<rel>-conflict-<ts>` — the file plane's LWW
|
|
27
|
+
* loser's parking spot (feature-sync-file-plane), generalized out of
|
|
28
|
+
* `quarantineCanvas` below: same posture (quarantine, never delete; never
|
|
29
|
+
* throw — a failed quarantine costs the overwrite, not the sync runtime),
|
|
30
|
+
* scoped to a single file rather than a canvas's lane bundle.
|
|
31
|
+
*
|
|
32
|
+
* Returns the design-root-relative destination, or null when nothing moved —
|
|
33
|
+
* and the CALLER must treat null as "do not overwrite": a conflict loser
|
|
34
|
+
* that could not be parked is a conflict loser that stays.
|
|
35
|
+
*/
|
|
36
|
+
export function quarantineFile(opts: {
|
|
37
|
+
designRoot: string;
|
|
38
|
+
/** Design-root-relative path of the file to park. */
|
|
39
|
+
rel: string;
|
|
40
|
+
now?: number;
|
|
41
|
+
log?: (line: string) => void;
|
|
42
|
+
}): string | null {
|
|
43
|
+
const { designRoot, rel } = opts;
|
|
44
|
+
const log = opts.log ?? ((line: string) => console.log(line));
|
|
45
|
+
const abs = path.join(designRoot, rel);
|
|
46
|
+
if (!existsSync(abs)) return null;
|
|
47
|
+
const trashedTo = `_trash/${rel}-conflict-${opts.now ?? Date.now()}`;
|
|
48
|
+
const trashAbs = path.join(designRoot, trashedTo);
|
|
49
|
+
try {
|
|
50
|
+
mkdirSync(path.dirname(trashAbs), { recursive: true });
|
|
51
|
+
renameSync(abs, trashAbs);
|
|
52
|
+
} catch (err) {
|
|
53
|
+
log(
|
|
54
|
+
`[sync/files] could not park ${rel} in _trash/: ${(err as Error).message} — keeping the local copy`
|
|
55
|
+
);
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
log(`[sync/files] conflict on ${rel} — the local copy moved to ${trashedTo}`);
|
|
59
|
+
return trashedTo;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface TombstoneMove {
|
|
63
|
+
slug: string;
|
|
64
|
+
/** Design-root-relative quarantine dir the canvas landed in. */
|
|
65
|
+
trashedTo: string;
|
|
66
|
+
/** Design-root-relative lanes that actually moved. */
|
|
67
|
+
moved: string[];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The absolute paths that make up one canvas on disk. */
|
|
71
|
+
export interface CanvasLanes {
|
|
72
|
+
html: string;
|
|
73
|
+
meta?: string;
|
|
74
|
+
css?: string;
|
|
75
|
+
annotations?: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Move one canvas's lanes into `_trash/`, best-effort.
|
|
80
|
+
*
|
|
81
|
+
* Returns the move, or null when there was nothing on disk to move (already
|
|
82
|
+
* gone — the idempotent steady state once both peers have converged) or when the
|
|
83
|
+
* quarantine directory could not be made. NEVER THROWS: a failed quarantine must
|
|
84
|
+
* cost the deletion, not the sync runtime that called it.
|
|
85
|
+
*
|
|
86
|
+
* `now` is injectable so a test does not have to reason about wall-clock.
|
|
87
|
+
*/
|
|
88
|
+
export function quarantineCanvas(opts: {
|
|
89
|
+
designRoot: string;
|
|
90
|
+
slug: string;
|
|
91
|
+
lanes: CanvasLanes;
|
|
92
|
+
now?: number;
|
|
93
|
+
log?: (line: string) => void;
|
|
94
|
+
}): TombstoneMove | null {
|
|
95
|
+
const { designRoot, slug, lanes } = opts;
|
|
96
|
+
const log = opts.log ?? ((line: string) => console.log(line));
|
|
97
|
+
const present = [lanes.html, lanes.meta, lanes.css, lanes.annotations].filter(
|
|
98
|
+
(p): p is string => !!p && existsSync(p)
|
|
99
|
+
);
|
|
100
|
+
if (present.length === 0) return null;
|
|
101
|
+
|
|
102
|
+
const trashedTo = `_trash/${slug}-deleted-${opts.now ?? Date.now()}`;
|
|
103
|
+
const trashAbs = path.join(designRoot, trashedTo);
|
|
104
|
+
try {
|
|
105
|
+
mkdirSync(trashAbs, { recursive: true });
|
|
106
|
+
} catch (err) {
|
|
107
|
+
log(
|
|
108
|
+
`[sync/tombstone] could not quarantine ${slug}: ${(err as Error).message} — leaving it in place`
|
|
109
|
+
);
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const moved: string[] = [];
|
|
114
|
+
for (const abs of present) {
|
|
115
|
+
try {
|
|
116
|
+
renameSync(abs, path.join(trashAbs, path.basename(abs)));
|
|
117
|
+
moved.push(path.relative(designRoot, abs));
|
|
118
|
+
} catch (err) {
|
|
119
|
+
// One stuck lane (an open handle on Windows, a permission quirk) must not
|
|
120
|
+
// abandon the lanes that CAN move — a half-moved canvas is still gone from
|
|
121
|
+
// the tree, and the remainder is named here so it can be found by hand.
|
|
122
|
+
log(
|
|
123
|
+
`[sync/tombstone] could not move ${path.basename(abs)} for ${slug}: ${(err as Error).message}`
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
if (moved.length === 0) return null;
|
|
128
|
+
|
|
129
|
+
log(`[sync/tombstone] ${slug} was deleted in the project — moved to ${trashedTo}/`);
|
|
130
|
+
return { slug, trashedTo, moved };
|
|
131
|
+
}
|