@1agh/maude 1.4.2 → 1.4.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.
@@ -1,14 +1,15 @@
1
1
  // Public surface of the collab module. Bundles the registry + persistence
2
2
  // wiring so ws.ts + server.ts don't need to know about the internal split.
3
3
 
4
- import { readFileSync } from 'node:fs';
4
+ import { readFileSync, statSync } from 'node:fs';
5
5
  import path from 'node:path';
6
6
 
7
7
  import type { Api } from '../api.ts';
8
8
  import type { Context } from '../context.ts';
9
9
 
10
+ import { applyCommentsToDoc } from '../sync/codec.ts';
10
11
  import { createPersistence, Y_TYPES } from './persistence.ts';
11
- import { createRegistry, type Registry } from './registry.ts';
12
+ import { applyAnnotationsToDoc, createRegistry, type Registry } from './registry.ts';
12
13
 
13
14
  export type { CollabConn } from './protocol.ts';
14
15
  export type { Registry } from './registry.ts';
@@ -63,6 +64,29 @@ export function createCollab(ctx: Context, api: Api): Collab {
63
64
  api,
64
65
  fileForSlug,
65
66
  shouldSeed: (slug) => !(ctx.sharedDoc && registryRef?.isPinned(slug)),
67
+ // A room restored from its own `.ydoc.bin` must not outrank a sidecar the
68
+ // hub (or an editor) wrote after that cache — see `reconcileAfterCache`.
69
+ // Only for rooms no hub provider owns: a pinned doc is the hub's replica
70
+ // and file→doc imports belong to the sync agent's diff-aware lane.
71
+ reconcileAfterCache: async (slug, doc, cachedAtMs) => {
72
+ if (registryRef?.isPinned(slug)) return;
73
+ const newerThanCache = (abs: string): boolean => {
74
+ try {
75
+ return statSync(abs).mtimeMs > cachedAtMs;
76
+ } catch {
77
+ return false;
78
+ }
79
+ };
80
+ const file = await fileForSlug(slug);
81
+ if (!file) return;
82
+ if (newerThanCache(path.join(ctx.paths.designRoot, `${slug}.annotations.svg`))) {
83
+ const svg = await api.loadAnnotations(file);
84
+ if (svg) applyAnnotationsToDoc(doc, svg, 'seed');
85
+ }
86
+ if (newerThanCache(path.join(ctx.paths.commentsDir, `${slug}.json`))) {
87
+ applyCommentsToDoc(doc, await api.loadCommentsForFile(file), 'seed');
88
+ }
89
+ },
66
90
  });
67
91
  // Accepted revisions: browser writes to a synced canvas's room are refused
68
92
  // (the room doc is the hub's accepted replica). Asked per frame — the save
@@ -85,6 +109,10 @@ export function createCollab(ctx: Context, api: Api): Collab {
85
109
  // a hub-pushed comment shows up on the peer's sidebar without a reload.
86
110
  // The registry's no-op guards make an identical re-seed free, so this can't
87
111
  // loop against the room's own persist (which also writes the file).
112
+ // A mounted room with no hub provider takes external disk changes; a pinned
113
+ // one leaves them to the sync agent.
114
+ const ownsRoomFromDisk = (slug: string): boolean =>
115
+ registry.peek(slug) !== null && !registry.isPinned(slug);
88
116
  const reseedFromDisk = async (rel: string): Promise<void> => {
89
117
  const cm = /^_comments\/(.+)\.json$/.exec(rel);
90
118
  const am = /^(.+)\.annotations\.svg$/.exec(rel);
@@ -101,15 +129,17 @@ export function createCollab(ctx: Context, api: Api): Collab {
101
129
  ? await api.loadCommentsForFile(file)
102
130
  : JSON.parse(readFileSync(abs, 'utf8'));
103
131
  if (!Array.isArray(parsed)) return;
104
- // Phase 9.2 (DDR-064): under sharedDoc the hub provider is attached to
105
- // the single room doc, so a hub-pushed comment is ALREADY in the room —
106
- // the wholesale re-seed (last-writer-wins blob copy) is the retired
107
- // clobber path. The agent's diff-aware applyFromFs handles external
108
- // file→doc imports instead. The sidebar still needs the 'comments' bus
109
- // emit, so keep that unconditionally.
110
- if (!ctx.sharedDoc && registry.peek(slug)) registry.syncRoomFromComments(slug, parsed);
132
+ // Phase 9.2 (DDR-064): when a hub provider is attached to the room
133
+ // (pinned), a hub-pushed comment is ALREADY in the doc and the agent's
134
+ // diff-aware applyFromFs owns external file→doc imports — re-seeding
135
+ // would be the retired clobber path. The test is the PIN, not
136
+ // `ctx.sharedDoc`: sharedDoc defaults on everywhere, including a cell
137
+ // without live pairing, where no provider exists and disk is the only
138
+ // way the hub's accepted state reaches the room. The sidebar still
139
+ // needs the 'comments' bus emit, so keep that unconditionally.
140
+ if (ownsRoomFromDisk(slug)) registry.syncRoomFromComments(slug, parsed);
111
141
  if (file) ctx.bus.emit('comments', { file, comments: parsed });
112
- } else if (!ctx.sharedDoc && registry.peek(slug)) {
142
+ } else if (ownsRoomFromDisk(slug)) {
113
143
  registry.syncRoomFromAnnotations(slug, readFileSync(abs, 'utf8'));
114
144
  }
115
145
  } catch {
@@ -49,6 +49,17 @@ export interface PersistenceDeps {
49
49
  * slugs. Absent → always seed (flag-OFF behavior, unchanged).
50
50
  */
51
51
  shouldSeed?: (slug: string) => boolean;
52
+ /**
53
+ * Called after `seed` restored a room from its `.ydoc.bin` cache, with the
54
+ * cache's mtime. The cache is this process's own snapshot, so it can be older
55
+ * than the on-disk sidecars: in a cell without live pairing the hub projects
56
+ * accepted comments/annotations onto disk while the room is closed, and a room
57
+ * rebuilt from the cache alone would serve the pre-projection board forever.
58
+ * The callback brings the doc forward from any sidecar written after the cache
59
+ * (a causal update ON TOP of the cached state, never a second independent
60
+ * seed). Absent → cache-only restore, the previous behavior.
61
+ */
62
+ reconcileAfterCache?: (slug: string, doc: Y.Doc, cachedAtMs: number) => Promise<void>;
52
63
  }
53
64
 
54
65
  /**
@@ -173,12 +184,12 @@ export function createPersistence(deps: PersistenceDeps): RoomCallbacks {
173
184
  return path.join(stateDir, `${slug}.ydoc.bin`);
174
185
  }
175
186
 
176
- async function readBinary(slug: string): Promise<Uint8Array | null> {
187
+ async function readBinary(slug: string): Promise<{ bytes: Uint8Array; mtimeMs: number } | null> {
177
188
  try {
178
189
  const file = Bun.file(ydocBinPath(slug));
179
190
  if (!(await file.exists())) return null;
180
191
  const buf = await file.arrayBuffer();
181
- return new Uint8Array(buf);
192
+ return { bytes: new Uint8Array(buf), mtimeMs: file.lastModified };
182
193
  } catch {
183
194
  return null;
184
195
  }
@@ -201,8 +212,9 @@ export function createPersistence(deps: PersistenceDeps): RoomCallbacks {
201
212
 
202
213
  // Step 1 — try the binary cache.
203
214
  const binary = await readBinary(slug);
204
- if (binary && binary.byteLength > 0) {
205
- Y.applyUpdate(doc, binary);
215
+ if (binary && binary.bytes.byteLength > 0) {
216
+ Y.applyUpdate(doc, binary.bytes);
217
+ await deps.reconcileAfterCache?.(slug, doc, binary.mtimeMs);
206
218
  return;
207
219
  }
208
220
 
@@ -7,6 +7,7 @@
7
7
  // prompt — see DDR-051 §3.
8
8
 
9
9
  import type { Awareness } from 'y-protocols/awareness';
10
+ import type * as Y from 'yjs';
10
11
 
11
12
  import { ANNOTATION_WRITE_ID, validAnnotationWriteId } from '../annotations-sync.ts';
12
13
  import { applyCommentsToDoc } from '../sync/codec.ts';
@@ -108,6 +109,30 @@ export interface Registry {
108
109
  size(): number;
109
110
  }
110
111
 
112
+ /**
113
+ * Replace a doc's annotation SVG — the one write shape shared by the live
114
+ * disk→room bridge and the cache-restore reconcile (`collab/index.ts`).
115
+ * Identical disk notifications stay no-ops (no update → no persist loop); a UI
116
+ * operation may deliberately restore identical content under a new write id.
117
+ * Returns whether the doc changed.
118
+ */
119
+ export function applyAnnotationsToDoc(
120
+ doc: Y.Doc,
121
+ svg: string,
122
+ origin: unknown,
123
+ writeId?: string
124
+ ): boolean {
125
+ const map = doc.getMap<string>(Y_TYPES.annotations);
126
+ const id = validAnnotationWriteId(writeId) ? writeId : undefined;
127
+ if (map.get('svg') === svg && (!id || map.get(ANNOTATION_WRITE_ID) === id)) return false;
128
+ doc.transact(() => {
129
+ map.set('svg', svg);
130
+ if (id) map.set(ANNOTATION_WRITE_ID, id);
131
+ else map.delete(ANNOTATION_WRITE_ID);
132
+ }, origin);
133
+ return true;
134
+ }
135
+
111
136
  export function createRegistry(callbacks: RoomCallbacks): Registry {
112
137
  const rooms = new Map<string, Room>();
113
138
  // Hub-side Awareness per slug (lives as long as the sync provider). Rooms
@@ -196,16 +221,7 @@ export function createRegistry(callbacks: RoomCallbacks): Registry {
196
221
  function syncRoomFromAnnotations(slug: string, svg: string, writeId?: string): void {
197
222
  const room = rooms.get(slug);
198
223
  if (!room) return;
199
- const map = room.doc.getMap<string>(Y_TYPES.annotations);
200
- // Identical disk notifications remain no-ops. A new UI operation may
201
- // deliberately restore identical content while an author has pending work.
202
- const id = validAnnotationWriteId(writeId) ? writeId : undefined;
203
- if (map.get('svg') === svg && (!id || map.get(ANNOTATION_WRITE_ID) === id)) return;
204
- room.doc.transact(() => {
205
- map.set('svg', svg);
206
- if (id) map.set(ANNOTATION_WRITE_ID, id);
207
- else map.delete(ANNOTATION_WRITE_ID);
208
- }, 'inspector-write');
224
+ applyAnnotationsToDoc(room.doc, svg, 'inspector-write', writeId);
209
225
  }
210
226
 
211
227
  function setAgentEditing(slug: string, state: { name: string; since: number } | null): void {