Skip to content

Commit db0e2ec

Browse files
disconcisionclaude
andcommitted
Implement granular Automerge sync with before state
- Add 'before' field to EditorState message for granular CRDT ops - Track changed_before in delta computation (PatchworkComm.re) - Add SegmentValidator module for post-sync invariant checking - Validate shards/children, segment shape, and UUID uniqueness See docs/automerge-granular-sync.md for design rationale. Co-Authored-By: Claude Opus 4.5 <[email protected]>
1 parent 6bec064 commit db0e2ec

File tree

5 files changed

+196
-14
lines changed

5 files changed

+196
-14
lines changed

embed/src/types/patchworkmessages.d.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,8 @@ export interface Pong {
4646
/**
4747
* The main sync message - contains document state delta.
4848
*
49-
* - `state`: Changed/added pieces (partial HazelDoc with only affected pieces)
49+
* - `state`: Changed/added pieces (partial HazelDoc with only affected pieces, after state)
50+
* - `before`: Before state of changed pieces (for granular CRDT operations)
5051
* - `deleted`: IDs of pieces to remove from Automerge
5152
*
5253
* Why explicit deletion? Hazel uses a tree structure where deleted pieces
@@ -56,11 +57,19 @@ export interface Pong {
5657
* a piece, it's already in Automerge (unchanged), so it's not forwarded to
5758
* other clients, who then crash when the parent references a missing piece.
5859
*
60+
* Why `before` state? To enable granular CRDT operations in tool.tsx.
61+
* By comparing before/after for each piece, we can detect shard changes
62+
* (which require atomic replacement) vs. content changes (which can use
63+
* granular RGA operations on children arrays). The before state is used
64+
* solely to compute what operations Hazel intended to perform.
65+
*
5966
* See docs/patchwork-integration.md "Explicit Deletion Sync" for details.
67+
* See docs/automerge-granular-sync.md for the granular sync design.
6068
*/
6169
export interface EditorState {
6270
t: "state";
6371
state: HazelDoc;
72+
before?: HazelDoc;
6473
deleted?: string[];
6574
}
6675

src/haz3lcore/patchwork/PatchworkComm.re

Lines changed: 51 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -292,14 +292,11 @@ module JsConvert = {
292292
};
293293
};
294294

295-
let js_of_flatdoc =
296-
(~deleted: list(Id.t)=[], map: FlatConvert.Doc.t): Ojs.t => {
297-
// Create empty JS object for pieces map
295+
/* Convert a flat doc to a JS HazelDoc object */
296+
let hazeldoc_of_flatdoc = (map: FlatConvert.Doc.t): HazelDoc.t_0 => {
298297
let pieces_obj = Ojs.empty_obj();
299298
let pieces =
300299
FlatDoc.HazelDoc.AnonymousInterface2.Pieces4.t_of_js(pieces_obj);
301-
302-
// Add each piece to the map using UUID as key
303300
map
304301
|> Id.Map.iter((id, piece) => {
305302
let id_str = Id.to_string(id);
@@ -310,15 +307,37 @@ module JsConvert = {
310307
js_piece,
311308
);
312309
});
310+
FlatDoc.HazelDoc.AnonymousInterface2.create(~title="", ~pieces, ());
311+
};
313312

314-
let state =
315-
FlatDoc.HazelDoc.AnonymousInterface2.create(~title="", ~pieces, ());
313+
let js_of_flatdoc =
314+
(
315+
~deleted: list(Id.t)=[],
316+
~before: option(FlatConvert.Doc.t)=?,
317+
map: FlatConvert.Doc.t,
318+
)
319+
: Ojs.t => {
320+
// Create JS HazelDoc for the after state
321+
let state = hazeldoc_of_flatdoc(map);
316322

317323
// Convert deleted IDs to string list for JS
318324
let deleted_strs = List.map(Id.to_string, deleted);
319325

326+
// Convert before state if provided
327+
let before_js =
328+
switch (before) {
329+
| Some(before_doc) => Some(hazeldoc_of_flatdoc(before_doc))
330+
| None => None
331+
};
332+
320333
EditorState.t_to_js(
321-
EditorState.create(~t=`L_s8_state, ~state, ~deleted=deleted_strs, ()),
334+
EditorState.create(
335+
~t=`L_s8_state,
336+
~state,
337+
~before=?before_js,
338+
~deleted=deleted_strs,
339+
(),
340+
),
322341
);
323342
};
324343

@@ -327,16 +346,23 @@ module JsConvert = {
327346
The deleted list is critical: Hazel's tree structure means deleted pieces
328347
simply disappear, but Automerge's flat map keeps pieces until explicitly
329348
removed. Without explicit deletion, deleted pieces become "orphans" in
330-
Automerge, breaking undo/redo sync. See docs/patchwork-integration.md. */
349+
Automerge, breaking undo/redo sync. See docs/patchwork-integration.md.
350+
351+
The changed_before field enables granular CRDT operations: by comparing
352+
before/after for each changed piece, tool.tsx can detect shard changes
353+
(requiring atomic replacement) vs. content changes (allowing granular
354+
RGA ops). See docs/automerge-granular-sync.md. */
331355
type delta = {
332356
changed: Id.Map.t(FlatConvert.Flat.piece),
357+
changed_before: Id.Map.t(FlatConvert.Flat.piece),
333358
added: Id.Map.t(FlatConvert.Flat.piece),
334359
deleted: list(Id.t),
335360
};
336361

337362
let compute_delta =
338363
(old_doc: FlatConvert.Doc.t, new_doc: FlatConvert.Doc.t): delta => {
339364
let changed = ref(Id.Map.empty);
365+
let changed_before = ref(Id.Map.empty);
340366
let added = ref(Id.Map.empty);
341367
let deleted = ref([]);
342368

@@ -425,6 +451,7 @@ module JsConvert = {
425451
// Piece exists, check if changed using structural inequality
426452
if (old_piece != new_piece) {
427453
changed := Id.Map.add(id, new_piece, changed^);
454+
changed_before := Id.Map.add(id, old_piece, changed_before^);
428455
};
429456
}
430457
});
@@ -440,6 +467,7 @@ module JsConvert = {
440467

441468
{
442469
changed: changed^,
470+
changed_before: changed_before^,
443471
added: added^,
444472
deleted: deleted^,
445473
};
@@ -796,10 +824,22 @@ let send_state =
796824
let affected_pieces =
797825
Id.Map.union((_, _, b) => Some(b), delta.changed, delta.added);
798826

799-
// Convert to JS with deleted IDs included
827+
// Only include before state if there are changed pieces (not just added)
828+
let before =
829+
if (Id.Map.is_empty(delta.changed_before)) {
830+
None;
831+
} else {
832+
Some(delta.changed_before);
833+
};
834+
835+
// Convert to JS with deleted IDs and before state included
800836
let js_obj =
801837
PerfLog.measure("js_of_state", () =>
802-
JsConvert.js_of_flatdoc(~deleted=delta.deleted, affected_pieces)
838+
JsConvert.js_of_flatdoc(
839+
~deleted=delta.deleted,
840+
~before?,
841+
affected_pieces,
842+
)
803843
);
804844

805845
// Measure payload size

src/haz3lcore/patchwork/PatchworkMessages.mli

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -236,7 +236,9 @@ end
236236

237237
(** The main sync message - contains document state delta.
238238
239-
- `state`: Changed/added pieces (partial HazelDoc with only affected pieces)
239+
- `state`: Changed/added pieces (partial HazelDoc with only affected pieces,
240+
after state)
241+
- `before`: Before state of changed pieces (for granular CRDT operations)
240242
- `deleted`: IDs of pieces to remove from Automerge
241243
242244
Why explicit deletion? Hazel uses a tree structure where deleted pieces
@@ -246,7 +248,14 @@ end
246248
piece, it's already in Automerge (unchanged), so it's not forwarded to other
247249
clients, who then crash when the parent references a missing piece.
248250
249-
See docs/patchwork-integration.md "Explicit Deletion Sync" for details. *)
251+
Why `before` state? To enable granular CRDT operations in tool.tsx. By
252+
comparing before/after for each piece, we can detect shard changes (which
253+
require atomic replacement) vs. content changes (which can use granular RGA
254+
operations on children arrays). The before state is used solely to compute
255+
what operations Hazel intended to perform.
256+
257+
See docs/patchwork-integration.md "Explicit Deletion Sync" for details. See
258+
docs/automerge-granular-sync.md for the granular sync design. *)
250259
module EditorState : sig
251260
type t = [ `EditorState ] intf
252261
[@@js.custom { of_js = Obj.magic; to_js = Obj.magic }]
@@ -279,12 +288,15 @@ module EditorState : sig
279288

280289
val get_state : 'tags this -> HazelDoc.t_0 [@@js.get "state"]
281290
val set_state : 'tags this -> HazelDoc.t_0 -> unit [@@js.set "state"]
291+
val get_before : 'tags this -> HazelDoc.t_0 option [@@js.get "before"]
292+
val set_before : 'tags this -> HazelDoc.t_0 -> unit [@@js.set "before"]
282293
val get_deleted : 'tags this -> string list option [@@js.get "deleted"]
283294
val set_deleted : 'tags this -> string list -> unit [@@js.set "deleted"]
284295

285296
val create :
286297
t:([ `L_s8_state [@js "state"] ][@js.enum]) ->
287298
state:HazelDoc.t_0 ->
299+
?before:HazelDoc.t_0 ->
288300
?deleted:string list ->
289301
unit ->
290302
t
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
/* SegmentValidator.re - Validation predicates for sync invariants.
2+
3+
This module provides development-time checks to detect invariant violations
4+
after sync operations. These checks are O(n) in segment size and should be
5+
disabled in production builds.
6+
7+
See docs/automerge-granular-sync.md "Invariants and Validation Framework". */
8+
9+
open Util;
10+
11+
/* Check shards/children relationship for all tiles.
12+
Invariant: length(shards) == length(children) + 1 */
13+
let validate_shards_children = (seg: Segment.t): list(string) => {
14+
let errors = ref([]);
15+
let rec check = (seg: Segment.t, path: string) => {
16+
seg
17+
|> List.iteri((i, p) =>
18+
switch (p) {
19+
| Piece.Tile(t) =>
20+
let expected = List.length(t.children) + 1;
21+
let actual = List.length(t.shards);
22+
if (actual != expected && actual > 0) {
23+
errors :=
24+
[
25+
path
26+
++ "/tile"
27+
++ string_of_int(i)
28+
++ ": shards="
29+
++ string_of_int(actual)
30+
++ " but children="
31+
++ string_of_int(List.length(t.children)),
32+
...errors^,
33+
];
34+
};
35+
t.children
36+
|> List.iteri((j, child) =>
37+
check(
38+
child,
39+
path
40+
++ "/tile"
41+
++ string_of_int(i)
42+
++ "/child"
43+
++ string_of_int(j),
44+
)
45+
);
46+
| _ => ()
47+
}
48+
);
49+
};
50+
check(seg, "root");
51+
errors^;
52+
};
53+
54+
/* Check segment shape consistency via skeleton generation.
55+
Invariant: Segment.skel(seg) doesn't throw Skel.Nonconvex_segment */
56+
let validate_shape = (seg: Segment.t): list(string) => {
57+
let errors = ref([]);
58+
let rec check = (seg: Segment.t, path: string) => {
59+
switch (Segment.skel(seg)) {
60+
| exception Skel.Nonconvex_segment =>
61+
errors := [path ++ ": Nonconvex segment (shape conflict)", ...errors^]
62+
| exception Skel.Input_contains_secondary => () /* Shouldn't happen - skel filters secondary */
63+
| _ => ()
64+
};
65+
/* Recursively check tile children */
66+
seg
67+
|> List.iter(p =>
68+
switch (p) {
69+
| Piece.Tile(t) =>
70+
t.children
71+
|> List.iteri((j, child) =>
72+
check(child, path ++ "/child" ++ string_of_int(j))
73+
)
74+
| _ => ()
75+
}
76+
);
77+
};
78+
check(seg, "root");
79+
errors^;
80+
};
81+
82+
/* Check UUID uniqueness across segment.
83+
Invariant: All piece IDs are unique across the entire edit state */
84+
let validate_unique_ids = (seg: Segment.t): list(string) => {
85+
let ids = Segment.ids(seg);
86+
let unique_ids = List.sort_uniq(Id.compare, ids);
87+
if (List.length(ids) != List.length(unique_ids)) {
88+
["Duplicate piece IDs detected in segment"];
89+
} else {
90+
[];
91+
};
92+
};
93+
94+
/* Run all validations, log any errors.
95+
Call this after sync operations to detect invariant violations.
96+
Disable in production for performance. */
97+
let validate_all = (seg: Segment.t): unit => {
98+
let errors =
99+
validate_shards_children(seg)
100+
@ validate_shape(seg)
101+
@ validate_unique_ids(seg);
102+
errors
103+
|> List.iter(e =>
104+
Js_of_ocaml.Firebug.console##warn(
105+
Js_of_ocaml.Js.string("[SYNC VALIDATION] " ++ e),
106+
)
107+
);
108+
};
109+
110+
/* Validate and return whether any errors were found */
111+
let has_errors = (seg: Segment.t): bool => {
112+
let errors =
113+
validate_shards_children(seg)
114+
@ validate_shape(seg)
115+
@ validate_unique_ids(seg);
116+
List.length(errors) > 0;
117+
};

src/haz3lcore/patchwork/SyncReplace.re

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -361,6 +361,10 @@ let sync_replace = (z: Zipper.t, delta_doc: FlatConvert.Doc.t): Zipper.t => {
361361
FlatConvert.doc_to_seg(merged_doc)
362362
);
363363

364+
// Validate invariants after sync - disable in production for performance
365+
// This helps detect any CRDT merge issues that violate Hazel's invariants
366+
SegmentValidator.validate_all(new_seg);
367+
364368
//let num_pieces = PerfLog.Count.pieces_in_segment_deep(new_seg);
365369
let num_pieces = (-1);
366370
let context = string_of_int(num_pieces) ++ " pieces";

0 commit comments

Comments
 (0)