@1agh/maude 0.60.3 → 0.60.5
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 +68 -5
- package/apps/studio/build.ts +10 -0
- package/apps/studio/canvas-lib.tsx +16 -3
- package/apps/studio/canvas-shell.tsx +34 -39
- 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/dist/comment-mount.js +2 -2
- package/apps/studio/dom-selection.ts +67 -0
- package/apps/studio/http.ts +12 -1
- package/apps/studio/input-router.tsx +26 -2
- package/apps/studio/sync/agent.ts +64 -13
- package/apps/studio/sync/asset-pull.ts +210 -0
- package/apps/studio/sync/asset-push.ts +111 -98
- package/apps/studio/sync/codec.ts +47 -0
- package/apps/studio/sync/cold-start.ts +96 -0
- 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/migrate-seed.ts +52 -7
- package/apps/studio/sync/projection.ts +10 -1
- 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/canvas-meta-api.test.ts +114 -3
- package/apps/studio/test/canvas-zoom-floor.test.ts +73 -0
- package/apps/studio/test/cloud-managed-save-surfaces.test.ts +90 -0
- package/apps/studio/test/dom-selection.test.ts +69 -1
- 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/input-router.test.ts +69 -0
- package/apps/studio/test/sync-annotations-cold-start.test.ts +302 -0
- 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/apps/studio/whats-new.json +18 -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);
|
|
@@ -36,16 +36,19 @@ import type * as Y from 'yjs';
|
|
|
36
36
|
|
|
37
37
|
import { Y_TYPES } from '../collab/persistence.ts';
|
|
38
38
|
import {
|
|
39
|
+
annotationsEditAtFromDoc,
|
|
39
40
|
applyAnnotationsToDoc,
|
|
40
41
|
applyCommentsToDoc,
|
|
41
42
|
applyCssToDoc,
|
|
42
43
|
applyHtmlToDoc,
|
|
43
44
|
applyMetaToDoc,
|
|
44
45
|
bodyEditAtFromDoc,
|
|
46
|
+
isEmptyAnnotationsSvg,
|
|
47
|
+
stampAnnotationsEdit,
|
|
45
48
|
stampBodyEdit,
|
|
46
49
|
Y_SYNC_TYPES,
|
|
47
50
|
} from './codec.ts';
|
|
48
|
-
import { decideColdStart, unionCommentsById } from './cold-start.ts';
|
|
51
|
+
import { decideAnnotationsColdStart, decideColdStart, unionCommentsById } from './cold-start.ts';
|
|
49
52
|
import { hashBytes } from './echo-guard.ts';
|
|
50
53
|
import type { SyncJournal } from './journal.ts';
|
|
51
54
|
import { ORIGINS } from './origins.ts';
|
|
@@ -133,7 +136,17 @@ export async function migrateSeed(opts: MigrateSeedOptions): Promise<MigrateSeed
|
|
|
133
136
|
const parsed = tryParseJsonArray(localComments);
|
|
134
137
|
if (parsed) applyCommentsToDoc(doc, parsed, ORIGINS.MIGRATION);
|
|
135
138
|
}
|
|
136
|
-
if (localAnnotations)
|
|
139
|
+
if (localAnnotations) {
|
|
140
|
+
if (applyAnnotationsToDoc(doc, localAnnotations, ORIGINS.MIGRATION)) {
|
|
141
|
+
// Stamp with the FILE's mtime — adopt seeds pre-existing content,
|
|
142
|
+
// which must not claim apply-time freshness over a newer peer.
|
|
143
|
+
stampAnnotationsEdit(
|
|
144
|
+
doc,
|
|
145
|
+
ORIGINS.MIGRATION,
|
|
146
|
+
localMtimeMs(paths.annotations) ?? undefined
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
137
150
|
if (paths.meta && localMeta) applyMetaToDoc(doc, localMeta, ORIGINS.MIGRATION);
|
|
138
151
|
if (paths.css && localCss) applyCssToDoc(doc, localCss, ORIGINS.MIGRATION);
|
|
139
152
|
}, ORIGINS.MIGRATION);
|
|
@@ -164,17 +177,16 @@ export async function migrateSeed(opts: MigrateSeedOptions): Promise<MigrateSeed
|
|
|
164
177
|
|
|
165
178
|
let result: MigrateSeedResult = 'hub-wins';
|
|
166
179
|
|
|
167
|
-
/** Rebuild body (+ visually-coupled css
|
|
168
|
-
*
|
|
180
|
+
/** Rebuild body (+ visually-coupled css) from local, in ONE MIGRATION
|
|
181
|
+
* transaction — one source per type, never a two-history merge.
|
|
182
|
+
* Annotations are deliberately NOT here any more: they resolve per-lane
|
|
183
|
+
* below (decideAnnotationsColdStart), independent of the body winner. */
|
|
169
184
|
const rebuildBodyFromLocal = (): void => {
|
|
170
185
|
doc.transact(() => {
|
|
171
186
|
if (applyHtmlToDoc(doc, localHtml as string, ORIGINS.MIGRATION)) {
|
|
172
187
|
stampBodyEdit(doc, ORIGINS.MIGRATION);
|
|
173
188
|
}
|
|
174
189
|
if (paths.css && localCss !== null) applyCssToDoc(doc, localCss, ORIGINS.MIGRATION);
|
|
175
|
-
if (localAnnotations !== null) {
|
|
176
|
-
applyAnnotationsToDoc(doc, localAnnotations, ORIGINS.MIGRATION);
|
|
177
|
-
}
|
|
178
190
|
}, ORIGINS.MIGRATION);
|
|
179
191
|
opts.journal?.record(slug, {
|
|
180
192
|
bodyHash: hashBytes(localHtml as string),
|
|
@@ -246,6 +258,39 @@ export async function migrateSeed(opts: MigrateSeedOptions): Promise<MigrateSeed
|
|
|
246
258
|
}
|
|
247
259
|
}
|
|
248
260
|
|
|
261
|
+
// ---- annotations: PER-LANE newest-wins (the 2026-08-14 eraser fix; the
|
|
262
|
+
// same table as agent.ts reconcile). Under sharedDoc the collab room's
|
|
263
|
+
// persistJson materializes the doc's annotations to disk, so a stale hub
|
|
264
|
+
// EMPTY WRAPPER (non-'' but zero strokes) in the doc erased local strokes
|
|
265
|
+
// right after this seed ran. Resolve the lane here, before the room
|
|
266
|
+
// materializes: unstamped emptiness never beats content.
|
|
267
|
+
{
|
|
268
|
+
const docSvg = doc.getMap<unknown>(Y_TYPES.annotations).get('svg');
|
|
269
|
+
const docAnnotations = typeof docSvg === 'string' ? docSvg : '';
|
|
270
|
+
const annDecision = decideAnnotationsColdStart({
|
|
271
|
+
local: localAnnotations,
|
|
272
|
+
doc: docAnnotations,
|
|
273
|
+
isEmpty: isEmptyAnnotationsSvg,
|
|
274
|
+
localMtimeMs: localMtimeMs(paths.annotations),
|
|
275
|
+
docEditAtMs: annotationsEditAtFromDoc(doc),
|
|
276
|
+
bodyWinner: result === 'body-seed-up' || result === 'conflict-local-wins' ? 'local' : 'hub',
|
|
277
|
+
});
|
|
278
|
+
if (annDecision.winner === 'local' && localAnnotations !== null) {
|
|
279
|
+
console.warn(`[sync/${slug}] shared-doc cold-start annotations: ${annDecision.reason}`);
|
|
280
|
+
doc.transact(() => {
|
|
281
|
+
if (applyAnnotationsToDoc(doc, localAnnotations, ORIGINS.MIGRATION)) {
|
|
282
|
+
stampAnnotationsEdit(
|
|
283
|
+
doc,
|
|
284
|
+
ORIGINS.MIGRATION,
|
|
285
|
+
localMtimeMs(paths.annotations) ?? undefined
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
}, ORIGINS.MIGRATION);
|
|
289
|
+
}
|
|
290
|
+
// winner 'hub' → nothing to write here: the collab room owns the
|
|
291
|
+
// annotations doc→file half and materializes it (persistJson).
|
|
292
|
+
}
|
|
293
|
+
|
|
249
294
|
// Comments id-union (DDR-102): rebuild from merged JSON via the
|
|
250
295
|
// delete-then-insert codec — same-id entries keep the doc's version, so the
|
|
251
296
|
// duplication trap stays closed; local-only comments survive.
|
|
@@ -39,6 +39,7 @@ import {
|
|
|
39
39
|
htmlFromDoc,
|
|
40
40
|
mergeSharedMetaIntoLocal,
|
|
41
41
|
metaFromDoc,
|
|
42
|
+
stampAnnotationsEdit,
|
|
42
43
|
stampBodyEdit,
|
|
43
44
|
} from './codec.ts';
|
|
44
45
|
import { type EchoGuard, hashBytes } from './echo-guard.ts';
|
|
@@ -354,7 +355,15 @@ export function createDocProjection(opts: DocProjectionOptions): DocProjection {
|
|
|
354
355
|
}
|
|
355
356
|
if (evt.path === paths.annotations) {
|
|
356
357
|
clearStrike(evt.path);
|
|
357
|
-
|
|
358
|
+
// Apply + per-lane stamp in ONE transaction (mirrors agent.applyFromFs)
|
|
359
|
+
// so a deliberate delete-all (empty wrapper) carries its freshness and
|
|
360
|
+
// survives cold start on other peers (the 2026-08-14 eraser fix).
|
|
361
|
+
let changed = false;
|
|
362
|
+
doc.transact(() => {
|
|
363
|
+
changed = applyAnnotationsToDoc(doc, str, importOrigin);
|
|
364
|
+
if (changed) stampAnnotationsEdit(doc, importOrigin);
|
|
365
|
+
}, importOrigin);
|
|
366
|
+
return changed;
|
|
358
367
|
}
|
|
359
368
|
return false;
|
|
360
369
|
}
|
|
@@ -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
|
}
|