@jxsuite/collab 0.1.0 → 0.2.1

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/README.md CHANGED
@@ -68,7 +68,8 @@ one `error{read-only}`.
68
68
  ## Awareness (`./awareness-types`)
69
69
 
70
70
  One project-level `Awareness` per connection. State shape:
71
- `{ user: {login, name?, avatarUrl?, color}, focusedPath, mode?, selection? }` — file-tree presence
71
+ `{ user: {login, name?, avatarUrl?, color}, focusedPath, mode?, structuralSelection?, selection? }` —
72
+ file-tree presence (the plain `selection` field is y-monaco's in-buffer cursor)
72
73
  and per-document cursor overlays both filter the same states. Identity comes from the server
73
74
  `hello`; colors are deterministic (`colorForKey`).
74
75
 
@@ -77,3 +78,9 @@ and per-document cursor overlays both filter the same states. Identity comes fro
77
78
  `StudioPlatform.collab?: (docPath) => Promise<CollabHandle | null>` where a handle carries
78
79
  `{doc, awareness, whenSynced, identity(), flush(), onStatus(), onReset(), destroy()}`. `onReset`
79
80
  fires on a `doc-reset`: the handle is dead — destroy it and re-acquire.
81
+
82
+ ## Versioning
83
+
84
+ Published to npm as `@jxsuite/collab` — TypeScript source, like every `@jxsuite`
85
+ package, following the monorepo's release train. Within the monorepo, other
86
+ packages (`@jxsuite/server`, `@jxsuite/studio`) depend on it via `workspace:^`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jxsuite/collab",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
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": {
@@ -25,14 +25,14 @@
25
25
  "./room": "./src/ws-room.ts"
26
26
  },
27
27
  "publishConfig": {
28
- "provenance": false
28
+ "provenance": true
29
29
  },
30
30
  "scripts": {
31
31
  "test": "bun test --isolate",
32
32
  "test:coverage": "bun test --isolate --coverage"
33
33
  },
34
34
  "dependencies": {
35
- "@jxsuite/schema": "workspace:^",
35
+ "@jxsuite/schema": "^0.35.0",
36
36
  "lib0": "^0.2.99",
37
37
  "y-protocols": "^1.0.6",
38
38
  "yjs": "13.6.27"
@@ -21,8 +21,14 @@ export interface CollabAwarenessState {
21
21
  focusedPath: string | null;
22
22
  /** Which representation the client is editing (drives the Phase-B canonical lock). */
23
23
  mode?: "structure" | "source";
24
- /** The client's structural selection in the focused document. */
25
- selection?: JxPath | null;
24
+ /** The client's structural (canvas) selection in the focused document. */
25
+ structuralSelection?: JxPath | null;
26
+ /**
27
+ * RESERVED for y-monaco: the in-buffer text cursor as {anchor, head} Y.RelativePosition JSON.
28
+ * MonacoBinding owns this field via setLocalStateField("selection", …) while a code view is
29
+ * bound; nothing else may write it.
30
+ */
31
+ selection?: { anchor: unknown; head: unknown } | null;
26
32
  /**
27
33
  * Whether this client may write (from the server hello). Drives reconciler election — the lowest
28
34
  * write-capable clientID mirrors derived representations. Advisory only: the server drops updates
package/src/index.ts CHANGED
@@ -29,6 +29,15 @@ export {
29
29
  yEventsToDocOps,
30
30
  } from "./op-bridge.ts";
31
31
  export { deepEqual, diffDocs, replaceYStructure } from "./diff.ts";
32
+ export {
33
+ acquireSourceCanonical,
34
+ canonicalOf,
35
+ canonicalRevOf,
36
+ isSourceReconciler,
37
+ otherSourceEditors,
38
+ releaseSourceCanonical,
39
+ } from "./source-lock.ts";
40
+ export type { CanonicalRepresentation } from "./source-lock.ts";
32
41
  export type { DiffOptions } from "./diff.ts";
33
42
  export {
34
43
  decodeFrame,
@@ -0,0 +1,98 @@
1
+ /**
2
+ * The canonical-representation lock for source-mode co-editing (the Phase-B dual-representation
3
+ * design). `meta.canonical` says which representation is authoritative: "structure" (the default —
4
+ * canvas/inspector edits flow through the op bridge and the elected reconciler mirrors them into
5
+ * source text) or "source" (Monaco co-edits the Y.Text character-level; structural surfaces
6
+ * soft-freeze and the source reconciler mirrors parses back into the structure tree for live
7
+ * previews).
8
+ *
9
+ * Every flip bumps `meta.canonicalRev`; derived-representation writers stamp the rev they computed
10
+ * against and observers discard stale mirrors. Flips are LWW-idempotent under concurrency: both
11
+ * flippers serialize the same structure, so the merged text converges.
12
+ */
13
+
14
+ import type { Doc } from "yjs";
15
+ import type { Awareness } from "y-protocols/awareness";
16
+ import { metaMap, updateSourceText } from "./schema.ts";
17
+ import type { CollabAwarenessState } from "./awareness-types.ts";
18
+
19
+ export type CanonicalRepresentation = "structure" | "source";
20
+
21
+ export function canonicalOf(doc: Doc): CanonicalRepresentation {
22
+ return metaMap(doc).get("canonical") === "source" ? "source" : "structure";
23
+ }
24
+
25
+ export function canonicalRevOf(doc: Doc): number {
26
+ return Number(metaMap(doc).get("canonicalRev") ?? 0);
27
+ }
28
+
29
+ /**
30
+ * Flip canonical to "source", seeding the text from `serialized` (the flipper's serialization of
31
+ * the current structure). No-op (false) when source already holds the lock.
32
+ */
33
+ export function acquireSourceCanonical(doc: Doc, serialized: string, origin: unknown): boolean {
34
+ if (canonicalOf(doc) === "source") {
35
+ return false;
36
+ }
37
+ doc.transact(() => {
38
+ updateSourceText(doc, serialized, origin);
39
+ const meta = metaMap(doc);
40
+ meta.set("canonical", "source");
41
+ meta.set("canonicalRev", canonicalRevOf(doc) + 1);
42
+ }, origin);
43
+ return true;
44
+ }
45
+
46
+ /** Flip canonical back to "structure" (the mirror is the caller's responsibility beforehand). */
47
+ export function releaseSourceCanonical(doc: Doc, origin: unknown): boolean {
48
+ if (canonicalOf(doc) !== "source") {
49
+ return false;
50
+ }
51
+ doc.transact(() => {
52
+ const meta = metaMap(doc);
53
+ meta.set("canonical", "structure");
54
+ meta.set("canonicalRev", canonicalRevOf(doc) + 1);
55
+ }, origin);
56
+ return true;
57
+ }
58
+
59
+ /** Peers (excluding `selfClientId`) currently source-editing this doc, per awareness. */
60
+ export function otherSourceEditors(
61
+ awareness: Awareness,
62
+ path: string,
63
+ selfClientId: number,
64
+ ): number[] {
65
+ const editors: number[] = [];
66
+ for (const [clientId, raw] of awareness.getStates()) {
67
+ const state = raw as Partial<CollabAwarenessState>;
68
+ if (clientId !== selfClientId && state.mode === "source" && state.focusedPath === path) {
69
+ editors.push(clientId);
70
+ }
71
+ }
72
+ return editors;
73
+ }
74
+
75
+ /**
76
+ * The source reconciler — the lowest write-capable clientID among this doc's source editors
77
+ * (including self when in source mode) — owns parsing Y.Text back into the structure tree.
78
+ */
79
+ export function isSourceReconciler(awareness: Awareness, path: string): boolean {
80
+ const self = awareness.clientID;
81
+ const selfState = awareness.getStates().get(self) as Partial<CollabAwarenessState> | undefined;
82
+ if (selfState?.mode !== "source" || selfState.focusedPath !== path) {
83
+ return false;
84
+ }
85
+ let lowest = self;
86
+ for (const [clientId, raw] of awareness.getStates()) {
87
+ const state = raw as Partial<CollabAwarenessState>;
88
+ if (
89
+ state.mode === "source" &&
90
+ state.focusedPath === path &&
91
+ state.canWrite !== false &&
92
+ clientId < lowest
93
+ ) {
94
+ lowest = clientId;
95
+ }
96
+ }
97
+ return lowest === self;
98
+ }