Skip to content

Commit eb81ee0

Browse files
author
Sprite
committed
fix: resolve Automerge v3 API compatibility and sync protocol issues
- Fix sync protocol: send full document on client init (required for shared history), separate receive from response generation to prevent sync state double-advancement - Strip undefined values from node data before inserting into Automerge (Automerge rejects undefined; Node-RED workspaces have undefined props) - Fix Automerge proxy array issues: use splice() instead of .length=0, remove .sort() on proxy arrays (not supported) - Add stripUndefined utility to both server schema and client module - All server-side tests pass: 30/30 schema, 16/16 sync protocol - Editor loads with 0 JS errors, WASM initializes, document loads from server successfully
1 parent 65e9f72 commit eb81ee0

3 files changed

Lines changed: 95 additions & 61 deletions

File tree

packages/node_modules/@node-red/editor-client/src/js/automerge.js

Lines changed: 50 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,18 @@
2525
RED.automerge = (function() {
2626
const SCHEMA_VERSION = "1.0";
2727

28+
function stripUndefined(obj) {
29+
if (obj === null || typeof obj !== 'object') return obj;
30+
if (Array.isArray(obj)) return obj.map(stripUndefined);
31+
var result = {};
32+
for (var key in obj) {
33+
if (obj.hasOwnProperty(key) && obj[key] !== undefined) {
34+
result[key] = stripUndefined(obj[key]);
35+
}
36+
}
37+
return result;
38+
}
39+
2840
let Automerge = null;
2941
let doc = null;
3042
let syncState = null;
@@ -69,14 +81,14 @@ RED.automerge = (function() {
6981

7082
for (const node of flows) {
7183
if (node.type === 'tab') {
72-
docData.workspaces[node.id] = { ...node };
84+
docData.workspaces[node.id] = stripUndefined({ ...node });
7385
workspaceOrder.push(node.id);
7486
} else if (node.type === 'subflow') {
75-
docData.subflows[node.id] = { ...node };
87+
docData.subflows[node.id] = stripUndefined({ ...node });
7688
} else if (node.type === 'group') {
77-
docData.groups[node.id] = { ...node };
89+
docData.groups[node.id] = stripUndefined({ ...node });
7890
} else {
79-
docData.nodes[node.id] = { ...node };
91+
docData.nodes[node.id] = stripUndefined({ ...node });
8092
}
8193
}
8294

@@ -172,9 +184,9 @@ RED.automerge = (function() {
172184
return;
173185
}
174186

175-
// Create initial empty document
176-
doc = Automerge.from(createEmptyDoc());
177-
syncState = Automerge.initSyncState();
187+
// Doc starts null — will be loaded from server's init response
188+
doc = null;
189+
syncState = null;
178190

179191
// Subscribe to sync messages
180192
RED.comms.subscribe("automerge/#", handleSyncMessage);
@@ -184,7 +196,7 @@ RED.automerge = (function() {
184196

185197
initialized = true;
186198

187-
// Request initial state
199+
// Request initial state (server will send full document)
188200
RED.comms.send('automerge/init', {});
189201
}
190202

@@ -210,7 +222,31 @@ RED.automerge = (function() {
210222
function handleSyncMessage(topic, data) {
211223
if (!enabled || !Automerge) return;
212224

213-
if (topic === 'automerge/sync') {
225+
if (topic === 'automerge/init') {
226+
// Server sent the full document — load it to share history
227+
try {
228+
const binaryString = atob(data.document);
229+
const binary = new Uint8Array(binaryString.length);
230+
for (let i = 0; i < binaryString.length; i++) {
231+
binary[i] = binaryString.charCodeAt(i);
232+
}
233+
234+
const oldDoc = doc;
235+
doc = Automerge.load(binary);
236+
syncState = Automerge.initSyncState();
237+
238+
console.log("Automerge: Loaded document from server (v" + data.version + ")");
239+
240+
// Apply any differences to the editor
241+
if (oldDoc) {
242+
applyRemoteChanges(oldDoc, doc);
243+
}
244+
} catch (err) {
245+
console.error("Failed to load Automerge document from server:", err);
246+
}
247+
} else if (topic === 'automerge/sync') {
248+
if (!doc || !syncState) return; // Not yet initialized
249+
214250
// Decode base64 message
215251
let syncMessage;
216252
try {
@@ -254,8 +290,6 @@ RED.automerge = (function() {
254290
} catch (err) {
255291
console.error("Error processing sync message:", err);
256292
}
257-
} else if (topic === 'automerge/state') {
258-
console.log("Automerge state:", data);
259293
}
260294
}
261295

@@ -472,16 +506,16 @@ RED.automerge = (function() {
472506
const type = node.type;
473507

474508
if (type === 'tab') {
475-
d.workspaces[node.id] = { ...node };
509+
d.workspaces[node.id] = stripUndefined({ ...node });
476510
if (!d.workspaceOrder.includes(node.id)) {
477511
d.workspaceOrder.push(node.id);
478512
}
479513
} else if (type === 'subflow') {
480-
d.subflows[node.id] = { ...node };
514+
d.subflows[node.id] = stripUndefined({ ...node });
481515
} else if (type === 'group') {
482-
d.groups[node.id] = { ...node };
516+
d.groups[node.id] = stripUndefined({ ...node });
483517
} else {
484-
d.nodes[node.id] = { ...node };
518+
d.nodes[node.id] = stripUndefined({ ...node });
485519
}
486520

487521
d.meta.lastModified = Date.now();
@@ -595,7 +629,6 @@ RED.automerge = (function() {
595629

596630
if (!node.wires[sourcePort].includes(targetId)) {
597631
node.wires[sourcePort].push(targetId);
598-
node.wires[sourcePort].sort();
599632
}
600633

601634
d.meta.lastModified = Date.now();
@@ -722,7 +755,7 @@ RED.automerge = (function() {
722755
for (const key of Object.keys(d.groups)) {
723756
delete d.groups[key];
724757
}
725-
d.workspaceOrder.length = 0;
758+
d.workspaceOrder.splice(0, d.workspaceOrder.length);
726759

727760
// Copy new data
728761
for (const [key, value] of Object.entries(newDocData.workspaces)) {

packages/node_modules/@node-red/runtime/lib/automerge/index.js

Lines changed: 20 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,7 @@ function setFlows(flows) {
171171
for (const key of Object.keys(d.groups)) {
172172
delete d.groups[key];
173173
}
174-
d.workspaceOrder.length = 0;
174+
d.workspaceOrder.splice(0, d.workspaceOrder.length);
175175

176176
// Copy new data
177177
for (const [key, value] of Object.entries(newDoc.workspaces)) {
@@ -205,7 +205,7 @@ function setFlows(flows) {
205205
* @returns {Uint8Array|null} Response sync message
206206
*/
207207
function receiveSyncMessage(session, syncMessage) {
208-
if (!enabled || !doc) return null;
208+
if (!enabled || !doc) return false;
209209

210210
let syncState = syncStates.get(session);
211211
if (!syncState) {
@@ -223,26 +223,18 @@ function receiveSyncMessage(session, syncMessage) {
223223
doc = newDoc;
224224
syncStates.set(session, newSyncState);
225225

226-
// Generate response message
227-
const [nextSyncState, responseMessage] = Automerge.generateSyncMessage(
228-
doc,
229-
newSyncState
230-
);
231-
232-
if (responseMessage) {
233-
syncStates.set(session, nextSyncState);
234-
}
226+
const changed = oldDoc !== newDoc;
235227

236228
// If the document changed, save and notify other clients
237-
if (oldDoc !== newDoc) {
229+
if (changed) {
238230
scheduleSave();
239231
broadcastSyncExcept(session);
240232
}
241233

242-
return responseMessage;
234+
return changed;
243235
} catch (err) {
244236
runtime.log.warn("Error processing sync message: " + err.message);
245-
return null;
237+
return false;
246238
}
247239
}
248240

@@ -283,8 +275,11 @@ function handleSyncMessage(opts) {
283275
return;
284276
}
285277

286-
const response = receiveSyncMessage(session, syncMessage);
278+
// Receive the sync message (updates doc and sync state)
279+
receiveSyncMessage(session, syncMessage);
287280

281+
// Generate and send response
282+
const response = generateSyncMessage(session);
288283
if (response) {
289284
runtime.events.emit('comms', {
290285
topic: 'automerge/sync',
@@ -301,35 +296,26 @@ function handleSyncMessage(opts) {
301296
* @param {Object} opts - Message options
302297
*/
303298
function handleInitRequest(opts) {
304-
if (!enabled) return;
299+
if (!enabled || !doc) return;
305300

306301
const { session } = opts;
307302

308-
// Reset sync state for this client
309-
syncStates.set(session, Automerge.initSyncState());
303+
// Send the full document binary so the client can load it
304+
// and share history (required for sync protocol to work)
305+
const docBinary = Automerge.save(doc);
310306

311-
// Generate initial sync message
312-
const message = generateSyncMessage(session);
313-
314-
if (message) {
315-
runtime.events.emit('comms', {
316-
topic: 'automerge/sync',
317-
data: {
318-
message: Buffer.from(message).toString('base64')
319-
},
320-
session: session
321-
});
322-
}
323-
324-
// Also send current state info
325307
runtime.events.emit('comms', {
326-
topic: 'automerge/state',
308+
topic: 'automerge/init',
327309
data: {
328310
enabled: true,
329-
version: schema.SCHEMA_VERSION
311+
version: schema.SCHEMA_VERSION,
312+
document: Buffer.from(docBinary).toString('base64')
330313
},
331314
session: session
332315
});
316+
317+
// Reset sync state for this client (now they share history)
318+
syncStates.set(session, Automerge.initSyncState());
333319
}
334320

335321
/**

packages/node_modules/@node-red/runtime/lib/automerge/schema.js

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,21 @@
3030

3131
const SCHEMA_VERSION = "1.0";
3232

33+
/**
34+
* Strip undefined values from an object (Automerge rejects undefined)
35+
*/
36+
function stripUndefined(obj) {
37+
if (obj === null || typeof obj !== 'object') return obj;
38+
if (Array.isArray(obj)) return obj.map(stripUndefined);
39+
const result = {};
40+
for (const [key, value] of Object.entries(obj)) {
41+
if (value !== undefined) {
42+
result[key] = stripUndefined(value);
43+
}
44+
}
45+
return result;
46+
}
47+
3348
/**
3449
* Create an empty Automerge document structure
3550
* @returns {Object} Empty document structure
@@ -65,14 +80,14 @@ function flowsToDoc(flows) {
6580

6681
for (const node of flows) {
6782
if (node.type === 'tab') {
68-
doc.workspaces[node.id] = { ...node };
83+
doc.workspaces[node.id] = stripUndefined({ ...node });
6984
workspaceOrder.push(node.id);
7085
} else if (node.type === 'subflow') {
71-
doc.subflows[node.id] = { ...node };
86+
doc.subflows[node.id] = stripUndefined({ ...node });
7287
} else if (node.type === 'group') {
73-
doc.groups[node.id] = { ...node };
88+
doc.groups[node.id] = stripUndefined({ ...node });
7489
} else {
75-
doc.nodes[node.id] = { ...node };
90+
doc.nodes[node.id] = stripUndefined({ ...node });
7691
}
7792
}
7893

@@ -171,16 +186,16 @@ function setNode(doc, node) {
171186
const type = node.type;
172187

173188
if (type === 'tab') {
174-
doc.workspaces[node.id] = { ...node };
189+
doc.workspaces[node.id] = stripUndefined({ ...node });
175190
if (!doc.workspaceOrder.includes(node.id)) {
176191
doc.workspaceOrder.push(node.id);
177192
}
178193
} else if (type === 'subflow') {
179-
doc.subflows[node.id] = { ...node };
194+
doc.subflows[node.id] = stripUndefined({ ...node });
180195
} else if (type === 'group') {
181-
doc.groups[node.id] = { ...node };
196+
doc.groups[node.id] = stripUndefined({ ...node });
182197
} else {
183-
doc.nodes[node.id] = { ...node };
198+
doc.nodes[node.id] = stripUndefined({ ...node });
184199
}
185200

186201
doc.meta.lastModified = Date.now();
@@ -286,7 +301,6 @@ function addWire(doc, sourceId, sourcePort, targetId) {
286301
// Add the target if not already present
287302
if (!node.wires[sourcePort].includes(targetId)) {
288303
node.wires[sourcePort].push(targetId);
289-
node.wires[sourcePort].sort(); // Maintain sorted order
290304
}
291305

292306
doc.meta.lastModified = Date.now();
@@ -396,5 +410,6 @@ module.exports = {
396410
removeWire,
397411
moveWorkspace,
398412
addNodeToGroup,
399-
removeNodeFromGroup
413+
removeNodeFromGroup,
414+
stripUndefined
400415
};

0 commit comments

Comments
 (0)