@1agh/maude 1.4.2 → 1.4.3
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):
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
// clobber path. The
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
|
|
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 (
|
|
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
|
-
|
|
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 {
|