@jxsuite/collab 0.3.0 → 0.4.0

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jxsuite/collab",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Realtime co-editing for Jx Studio — Y.Doc schema, op bridge, structural differ, wire envelope, and provider contracts shared by every collab backend",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -32,7 +32,7 @@
32
32
  "test:coverage": "bun test --isolate --coverage"
33
33
  },
34
34
  "dependencies": {
35
- "@jxsuite/schema": "^1.0.0",
35
+ "@jxsuite/schema": "^1.1.0",
36
36
  "lib0": "^0.2.99",
37
37
  "y-protocols": "^1.0.6",
38
38
  "yjs": "13.6.27"
package/src/envelope.ts CHANGED
@@ -47,6 +47,13 @@ export type ControlMessage =
47
47
  | { type: "flush"; path: string }
48
48
  /** Server → client: the flush landed. */
49
49
  | { type: "flush-ack"; path: string }
50
+ /**
51
+ * Server → client: the room's unsaved-since-last-persist state changed (drives the Save
52
+ * affordance). Room-level and authoritative: any peer's edit dirties the room for everyone; a
53
+ * persist clears it for everyone. Sent to a newly-subscribing connection during the open
54
+ * handshake so it learns the current state.
55
+ */
56
+ | { type: "doc-dirty"; path: string; dirty: boolean }
50
57
  /**
51
58
  * Server → client: a request was refused (codes: read-only, content-not-loaded, too-large,
52
59
  * rate-limited, binary-file, unknown-frame).
package/src/provider.ts CHANGED
@@ -39,6 +39,12 @@ export interface CollabHandle {
39
39
  * is dead: destroy it and re-acquire a fresh one via platform.collab.
40
40
  */
41
41
  onReset: (cb: () => void) => () => void;
42
+ /**
43
+ * Room-level unsaved state changed: any peer edited since the last persist, or a persist cleared
44
+ * it. Fires immediately with the current value on subscribe (the server sends it during the open
45
+ * handshake, so the callback must run synchronously to win that race). Returns an unsubscribe.
46
+ */
47
+ onDirty: (cb: (dirty: boolean) => void) => () => void;
42
48
  destroy: () => void;
43
49
  }
44
50
 
package/src/ws-client.ts CHANGED
@@ -67,6 +67,9 @@ interface DocEntry {
67
67
  resetCbs: Set<() => void>;
68
68
  pendingFlush: Set<() => void>;
69
69
  handleDestroyed: boolean;
70
+ /** Latest room-level dirty state from the server (survives reconnect on the same entry). */
71
+ dirty: boolean;
72
+ dirtyCbs: Set<(dirty: boolean) => void>;
70
73
  }
71
74
 
72
75
  export function createWsCollabConnection(options: WsCollabConnectionOptions): WsCollabConnection {
@@ -180,6 +183,16 @@ export function createWsCollabConnection(options: WsCollabConnectionOptions): Ws
180
183
  }
181
184
  return;
182
185
  }
186
+ case "doc-dirty": {
187
+ const entry = docs.get(message.path);
188
+ if (entry) {
189
+ entry.dirty = message.dirty;
190
+ for (const cb of entry.dirtyCbs) {
191
+ cb(message.dirty);
192
+ }
193
+ }
194
+ return;
195
+ }
183
196
  case "error": {
184
197
  if (!message.path) {
185
198
  return;
@@ -349,6 +362,8 @@ export function createWsCollabConnection(options: WsCollabConnectionOptions): Ws
349
362
  resolveSynced = resolve;
350
363
  });
351
364
  const entry: DocEntry = {
365
+ dirty: false,
366
+ dirtyCbs: new Set(),
352
367
  doc: new Y.Doc(),
353
368
  epoch: 0,
354
369
  handleDestroyed: false,
@@ -418,6 +433,13 @@ export function createWsCollabConnection(options: WsCollabConnectionOptions): Ws
418
433
  sendFrame({ message: { path, type: "flush" }, type: "control" });
419
434
  }),
420
435
  identity: () => identity,
436
+ onDirty: (cb) => {
437
+ entry.dirtyCbs.add(cb);
438
+ // Deliver current state synchronously: the server's open-handshake `doc-dirty` may have
439
+ // Already landed before the caller subscribes, and it must not miss the initial value.
440
+ cb(entry.dirty);
441
+ return () => entry.dirtyCbs.delete(cb);
442
+ },
421
443
  onReset: (cb) => {
422
444
  entry.resetCbs.add(cb);
423
445
  return () => entry.resetCbs.delete(cb);
package/src/ws-room.ts CHANGED
@@ -44,6 +44,10 @@ interface Room {
44
44
  epoch: number;
45
45
  subscribers: Set<HostConnection>;
46
46
  ready: Promise<void>;
47
+ /** The source text as of the last persist (seed or markPersisted) — the room-dirty baseline. */
48
+ baseline: string;
49
+ /** True while the live source differs from `baseline`; mirrored to peers via `doc-dirty`. */
50
+ dirty: boolean;
47
51
  }
48
52
 
49
53
  export interface HostConnection {
@@ -64,6 +68,12 @@ export interface CollabHost {
64
68
  subscriberCount: (path: string) => number;
65
69
  /** Destroy an empty room (embedder teardown after its grace period). */
66
70
  destroyRoomIfEmpty: (path: string) => void;
71
+ /**
72
+ * The embedder folded the room's source to durable storage: reset the dirty baseline to the
73
+ * current source and broadcast `doc-dirty{false}` to every subscriber. Called from `onFlush`
74
+ * after a successful write.
75
+ */
76
+ markPersisted: (path: string) => void;
67
77
  destroy: () => void;
68
78
  }
69
79
 
@@ -114,27 +124,32 @@ export function createCollabHost(options: CollabHostOptions): CollabHost {
114
124
  };
115
125
 
116
126
  const ensureRoom = (path: string): Room => {
117
- let room = rooms.get(path);
118
- if (room) {
119
- return room;
127
+ const existing = rooms.get(path);
128
+ if (existing) {
129
+ return existing;
120
130
  }
121
131
  const doc = new Y.Doc();
122
132
  const epoch = epochs.get(path) ?? 0;
123
133
  epochs.set(path, epoch);
124
- room = {
134
+ const room: Room = {
135
+ baseline: "",
136
+ dirty: false,
125
137
  doc,
126
138
  epoch,
127
- ready: (async () => {
128
- const source = await options.loadSource(path);
129
- if (source === null) {
130
- throw new Error("content-not-loaded");
131
- }
132
- doc.transact(() => {
133
- sourceText(doc).insert(0, source);
134
- }, "seed");
135
- })(),
139
+ ready: Promise.resolve(),
136
140
  subscribers: new Set(),
137
141
  };
142
+ room.ready = (async () => {
143
+ const source = await options.loadSource(path);
144
+ if (source === null) {
145
+ throw new Error("content-not-loaded");
146
+ }
147
+ // The seeded text IS the on-disk state — the initial clean baseline.
148
+ room.baseline = source;
149
+ doc.transact(() => {
150
+ sourceText(doc).insert(0, source);
151
+ }, "seed");
152
+ })();
138
153
  doc.on("update", (update: Uint8Array, origin: unknown) => {
139
154
  const current = rooms.get(path);
140
155
  if (!current || current.doc !== doc) {
@@ -148,6 +163,15 @@ export function createCollabHost(options: CollabHostOptions): CollabHost {
148
163
  origin instanceof Object && "socket" in origin ? (origin as InternalConnection) : undefined,
149
164
  );
150
165
  options.onSourceChange?.(path);
166
+ // Room-level dirty: broadcast on transition so every peer's Save affordance stays in sync.
167
+ const nowDirty = sourceText(current.doc).toString() !== current.baseline;
168
+ if (nowDirty !== current.dirty) {
169
+ current.dirty = nowDirty;
170
+ broadcastToRoom(current, {
171
+ message: { dirty: nowDirty, path, type: "doc-dirty" },
172
+ type: "control",
173
+ });
174
+ }
151
175
  });
152
176
  rooms.set(path, room);
153
177
  return room;
@@ -178,6 +202,8 @@ export function createCollabHost(options: CollabHostOptions): CollabHost {
178
202
  room.subscribers.add(conn);
179
203
  conn.subscribed.add(path);
180
204
  sendControl(conn, { epoch: room.epoch, path, type: "opened" });
205
+ // The joiner learns the room's current unsaved state (it may join a room others have dirtied).
206
+ sendControl(conn, { dirty: room.dirty, path, type: "doc-dirty" });
181
207
  // Server-initiated step1: learn the client's state (it replies step2 + its own step1).
182
208
  const body = encoding.createEncoder();
183
209
  syncProtocol.writeSyncStep1(body, room.doc);
@@ -394,6 +420,21 @@ export function createCollabHost(options: CollabHostOptions): CollabHost {
394
420
  }
395
421
  },
396
422
 
423
+ markPersisted(path) {
424
+ const room = rooms.get(path);
425
+ if (!room) {
426
+ return;
427
+ }
428
+ room.baseline = sourceText(room.doc).toString();
429
+ if (room.dirty) {
430
+ room.dirty = false;
431
+ broadcastToRoom(room, {
432
+ message: { dirty: false, path, type: "doc-dirty" },
433
+ type: "control",
434
+ });
435
+ }
436
+ },
437
+
397
438
  resetDoc(path) {
398
439
  const nextEpoch = (epochs.get(path) ?? 0) + 1;
399
440
  epochs.set(path, nextEpoch);