@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.
@@ -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
- fetchRemoteDocs,
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
- /** Give up canvases that left the project. Returns how many were released. */
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 remoteDocs = await fetchRemoteDocs(linkedHub.url, resolvedToken);
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
- remoteDocs
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 docs = await fetchRemoteDocs(linkedHub.url, token);
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 (docs === null) return;
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
- docs
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().catch((err) => console.error('[sync] remote poll failed:', err));
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 fetchRemoteDocs(
80
+ export async function fetchRemoteListing(
58
81
  hubUrl: string,
59
82
  token: string,
60
83
  fetchImpl: typeof fetch = fetch
61
- ): Promise<RemoteDoc[] | null> {
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
- return body.documents
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
+ }