@nebutra/collab 0.2.1 → 0.2.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.
package/dist/index.js CHANGED
@@ -41,6 +41,10 @@ var Room = class {
41
41
  Y.applyUpdate(this.doc, update, REMOTE_ORIGIN);
42
42
  });
43
43
  }
44
+ tenantId;
45
+ roomId;
46
+ store;
47
+ transport;
44
48
  doc;
45
49
  listeners = /* @__PURE__ */ new Set();
46
50
  unsubTransport;
@@ -148,7 +152,8 @@ var Hub = class {
148
152
  }
149
153
  async doctor() {
150
154
  const start = Date.now();
151
- let yjs = { ok: false, detail: "" };
155
+ let yjsOk = false;
156
+ let yjsDetail = "";
152
157
  try {
153
158
  const Y2 = await import("yjs");
154
159
  const probe = new Y2.Doc();
@@ -158,27 +163,27 @@ var Hub = class {
158
163
  const ok = restored.getMap("p").get("k") === 1;
159
164
  probe.destroy();
160
165
  restored.destroy();
161
- yjs = {
162
- ok,
163
- detail: ok ? "Yjs encode/apply round-trip succeeded." : "Yjs round-trip produced unexpected state."
164
- };
166
+ yjsOk = ok;
167
+ yjsDetail = ok ? "Yjs encode/apply round-trip succeeded." : "Yjs round-trip produced unexpected state.";
165
168
  } catch (e) {
166
- yjs = { ok: false, detail: `Yjs unavailable: ${String(e)}` };
169
+ yjsDetail = `Yjs unavailable: ${String(e)}`;
167
170
  }
168
- let store = { ok: false, detail: "" };
171
+ const yjs = { ok: yjsOk, detail: yjsDetail };
172
+ let storeOk = false;
173
+ let storeDetail = "";
169
174
  try {
170
175
  const probe = new Uint8Array([7, 7, 7]);
171
176
  await this.store.save("__collab_doctor__", "__probe__", probe);
172
177
  const back = await this.store.load("__collab_doctor__", "__probe__");
173
178
  const ok = !!back && back.length === probe.length && back[0] === 7;
174
- store = {
175
- ok,
176
- detail: ok ? "SnapshotStore save/load round-trip succeeded." : "SnapshotStore returned unexpected bytes."
177
- };
179
+ storeOk = ok;
180
+ storeDetail = ok ? "SnapshotStore save/load round-trip succeeded." : "SnapshotStore returned unexpected bytes.";
178
181
  } catch (e) {
179
- store = { ok: false, detail: `SnapshotStore error: ${String(e)}` };
182
+ storeDetail = `SnapshotStore error: ${String(e)}`;
180
183
  }
181
- let transport = { ok: false, detail: "" };
184
+ const store = { ok: storeOk, detail: storeDetail };
185
+ let transportOk = false;
186
+ let transportDetail = "";
182
187
  try {
183
188
  let received = false;
184
189
  const off = this.transport.subscribe("__collab_doctor__", "__probe__", () => {
@@ -186,13 +191,12 @@ var Hub = class {
186
191
  });
187
192
  await this.transport.broadcast("__collab_doctor__", "__probe__", new Uint8Array([1]));
188
193
  off();
189
- transport = {
190
- ok: received,
191
- detail: received ? "Transport broadcast/subscribe round-trip succeeded." : "Transport did not deliver the probe (network adapter may be async)."
192
- };
194
+ transportOk = received;
195
+ transportDetail = received ? "Transport broadcast/subscribe round-trip succeeded." : "Transport did not deliver the probe (network adapter may be async).";
193
196
  } catch (e) {
194
- transport = { ok: false, detail: `Transport error: ${String(e)}` };
197
+ transportDetail = `Transport error: ${String(e)}`;
195
198
  }
199
+ const transport = { ok: transportOk, detail: transportDetail };
196
200
  return {
197
201
  ok: yjs.ok && store.ok && transport.ok,
198
202
  durationMs: Date.now() - start,
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/errors.ts","../src/room.ts","../src/hub.ts"],"sourcesContent":["/**\n * Every failure surfaced by this package is a `CollabError`. The contract is\n * deliberately strict: a machine-stable `code` and a human-actionable\n * `suggestion` are MANDATORY, so no code path can throw a bare `Error` that\n * leaves a caller without a remediation hint.\n *\n * Mechanics (code/suggestion/toJSON/empty-suggestion fallback) are inherited\n * from the shared `@nebutra/capability-kit` `CapabilityError`; this subclass\n * only pins collab's error name + its package-specific fallback wording, so\n * the observable contract is unchanged.\n */\n\nimport { CapabilityError } from \"@nebutra/capability-kit\";\n\nexport type CollabErrorCode =\n | \"COLLAB_INVALID_TENANT\"\n | \"COLLAB_INVALID_ROOM\"\n | \"COLLAB_SNAPSHOT_FAILED\"\n | \"COLLAB_RESTORE_FAILED\"\n | \"COLLAB_DESTROYED\"\n | \"COLLAB_TEST\"\n | (string & {});\n\nexport interface CollabErrorInit {\n readonly code: CollabErrorCode;\n /** A non-empty, actionable remediation hint. */\n readonly suggestion: string;\n readonly cause?: unknown;\n}\n\nexport class CollabError extends CapabilityError {\n declare readonly code: CollabErrorCode;\n\n constructor(message: string, init: CollabErrorInit) {\n super(message, init, {\n name: \"CollabError\",\n emptySuggestionFallback:\n \"No suggestion was provided. This is a bug in @nebutra/collab — \" +\n \"report it with the failing operation.\",\n });\n }\n}\n","/**\n * A single tenant-scoped CRDT room. One `Y.Doc` per (tenant, room). The hub\n * owns the partitioning; this class assumes its `tenantId`/`roomId` are\n * already the partition it belongs to and never reaches outside them.\n *\n * Snapshot persistence is serialized through `withTenantLock(tenantId,\n * roomId, ...)` borrowed from `@nebutra/tenant-store` rather than a\n * hand-rolled mutex — same primitive used by canvas/reel, so a future swap\n * to a distributed lock changes one place.\n */\n\nimport { withTenantLock } from \"@nebutra/tenant-store\";\nimport * as Y from \"yjs\";\nimport { CollabError } from \"./errors\";\nimport type { CollabRoom, CollabTransport, SnapshotStore, UpdateListener } from \"./types\";\n\n/** Origin tag used when applying remote updates so we don't echo them back. */\nconst REMOTE_ORIGIN = Symbol(\"collab.remote\");\n\nexport class Room implements CollabRoom {\n readonly doc: Y.Doc;\n private readonly listeners = new Set<UpdateListener>();\n private readonly unsubTransport: () => void;\n private destroyed = false;\n\n constructor(\n readonly tenantId: string,\n readonly roomId: string,\n private readonly store: SnapshotStore,\n private readonly transport: CollabTransport,\n ) {\n this.doc = new Y.Doc();\n\n // Fan local updates out to: registered listeners + the transport. The\n // transport echo is guarded by origin so a remote-applied update is not\n // re-broadcast into a loop.\n this.doc.on(\"update\", (update: Uint8Array, origin: unknown) => {\n for (const cb of [...this.listeners]) cb(update, origin);\n if (origin !== REMOTE_ORIGIN) {\n void Promise.resolve(this.transport.broadcast(this.tenantId, this.roomId, update)).catch(\n () => {\n // Transport delivery is best-effort; CRDT state stays correct and\n // converges on the next exchanged update. Swallowing here avoids\n // an unhandled rejection from a flaky network adapter.\n },\n );\n }\n });\n\n // Remote updates for THIS tenant-scoped channel only.\n this.unsubTransport = this.transport.subscribe(this.tenantId, this.roomId, (update) => {\n if (this.destroyed) return;\n Y.applyUpdate(this.doc, update, REMOTE_ORIGIN);\n });\n }\n\n applyUpdate(update: Uint8Array, origin?: unknown): void {\n this.assertLive();\n Y.applyUpdate(this.doc, update, origin);\n }\n\n encodeState(): Uint8Array {\n this.assertLive();\n return Y.encodeStateAsUpdate(this.doc);\n }\n\n onUpdate(cb: UpdateListener): () => void {\n this.assertLive();\n this.listeners.add(cb);\n return () => {\n this.listeners.delete(cb);\n };\n }\n\n async snapshot(): Promise<void> {\n this.assertLive();\n const state = this.encodeState();\n try {\n // Serialize concurrent snapshots of the SAME room; different rooms (or\n // the same room under another tenant) persist in parallel.\n await withTenantLock(this.tenantId, this.roomId, () =>\n this.store.save(this.tenantId, this.roomId, state),\n );\n } catch (cause) {\n throw new CollabError(`Failed to persist snapshot for room \"${this.roomId}\".`, {\n code: \"COLLAB_SNAPSHOT_FAILED\",\n suggestion:\n \"Verify the configured SnapshotStore is reachable (DB/Redis up, \" +\n \"credentials valid). The in-memory default never fails; a custom \" +\n \"adapter likely threw.\",\n cause,\n });\n }\n }\n\n /** Hydrate this doc from persisted state, if any. Internal to the hub. */\n async _restore(): Promise<void> {\n try {\n const persisted = await this.store.load(this.tenantId, this.roomId);\n if (persisted) Y.applyUpdate(this.doc, persisted, REMOTE_ORIGIN);\n } catch (cause) {\n throw new CollabError(`Failed to restore room \"${this.roomId}\" from snapshot store.`, {\n code: \"COLLAB_RESTORE_FAILED\",\n suggestion:\n \"Check the SnapshotStore adapter's load() — it should resolve \" +\n \"null (not throw) when no snapshot exists for the tenant+room.\",\n cause,\n });\n }\n }\n\n destroy(): void {\n if (this.destroyed) return;\n this.destroyed = true;\n this.unsubTransport();\n this.listeners.clear();\n this.doc.destroy();\n }\n\n private assertLive(): void {\n if (this.destroyed) {\n throw new CollabError(`Room \"${this.roomId}\" was destroyed and can no longer be used.`, {\n code: \"COLLAB_DESTROYED\",\n suggestion:\n \"Acquire a fresh room via hub.room(tenantId, roomId) instead of \" +\n \"reusing a destroyed instance.\",\n });\n }\n }\n}\n","/**\n * `CollabHub` — the tenant-partition boundary.\n *\n * TENANT ISOLATION IS STRUCTURAL: rooms are stored in a `Map` keyed by a\n * composite `tenantId\u0000roomId` string with a NUL separator that cannot\n * appear in a normal id. There is no API that takes only a roomId, so a key\n * minted for tenant A is unreachable from tenant B — a room handle is only\n * ever produced by passing an explicit `tenantId`, and the snapshot store +\n * transport are likewise addressed by (tenantId, roomId). The same property\n * a Prisma adapter gets from RLS is enforced here by the composite key, with\n * no trust placed in caller-supplied payload.\n */\n\nimport { CollabError } from \"./errors\";\nimport { Room } from \"./room\";\nimport { InMemorySnapshotStore } from \"./store/memory\";\nimport { LoopbackTransport } from \"./transport/loopback\";\nimport type {\n CollabConfig,\n CollabHub,\n CollabRoom,\n CollabTransport,\n DoctorReport,\n SnapshotStore,\n} from \"./types\";\n\n// NUL separator: it cannot appear in a normal tenant/room id, so the pairs\n// (\"a\",\"bc\") and (\"ab\",\"c\") can never collide into the same room key — the\n// tenant partition is exact, not merely conventional.\nconst KEY_SEP = \"\u0000\";\n\nfunction roomKey(tenantId: string, roomId: string): string {\n return `${tenantId}${KEY_SEP}${roomId}`;\n}\n\nfunction assertId(value: string, kind: \"tenant\" | \"room\"): void {\n if (typeof value !== \"string\" || value.trim().length === 0) {\n throw new CollabError(`A non-empty ${kind}Id is required.`, {\n code: kind === \"tenant\" ? \"COLLAB_INVALID_TENANT\" : \"COLLAB_INVALID_ROOM\",\n suggestion:\n kind === \"tenant\"\n ? \"Pass the current tenant id, e.g. from getCurrentTenant().tenantId — \" +\n \"never call room() without an explicit tenant.\"\n : \"Pass a stable, non-empty roomId (document/canvas id).\",\n });\n }\n}\n\nclass Hub implements CollabHub {\n private readonly rooms = new Map<string, Room>();\n private readonly store: SnapshotStore;\n private readonly transport: CollabTransport;\n\n constructor(config: CollabConfig = {}) {\n this.store = config.store ?? new InMemorySnapshotStore();\n this.transport = config.transport ?? new LoopbackTransport();\n }\n\n room(tenantId: string, roomId: string): CollabRoom {\n assertId(tenantId, \"tenant\");\n assertId(roomId, \"room\");\n const key = roomKey(tenantId, roomId);\n let room = this.rooms.get(key);\n if (!room) {\n room = new Room(tenantId, roomId, this.store, this.transport);\n this.rooms.set(key, room);\n }\n return room;\n }\n\n async roomRestored(tenantId: string, roomId: string): Promise<CollabRoom> {\n const key = roomKey(tenantId, roomId);\n const existed = this.rooms.has(key);\n const room = this.room(tenantId, roomId) as Room;\n // Only hydrate a freshly created room; an already-live room is the\n // authoritative in-memory state.\n if (!existed) await room._restore();\n return room;\n }\n\n async doctor(): Promise<DoctorReport> {\n const start = Date.now();\n\n // 1. Yjs presence + a real round-trip (not just \"is it imported\").\n let yjs = { ok: false, detail: \"\" };\n try {\n const Y = await import(\"yjs\");\n const probe = new Y.Doc();\n probe.getMap(\"p\").set(\"k\", 1);\n const restored = new Y.Doc();\n Y.applyUpdate(restored, Y.encodeStateAsUpdate(probe));\n const ok = restored.getMap(\"p\").get(\"k\") === 1;\n probe.destroy();\n restored.destroy();\n yjs = {\n ok,\n detail: ok\n ? \"Yjs encode/apply round-trip succeeded.\"\n : \"Yjs round-trip produced unexpected state.\",\n };\n } catch (e) {\n yjs = { ok: false, detail: `Yjs unavailable: ${String(e)}` };\n }\n\n // 2. Store health — write+read under a reserved diagnostic tenant.\n let store = { ok: false, detail: \"\" };\n try {\n const probe = new Uint8Array([7, 7, 7]);\n await this.store.save(\"__collab_doctor__\", \"__probe__\", probe);\n const back = await this.store.load(\"__collab_doctor__\", \"__probe__\");\n const ok = !!back && back.length === probe.length && back[0] === 7;\n store = {\n ok,\n detail: ok\n ? \"SnapshotStore save/load round-trip succeeded.\"\n : \"SnapshotStore returned unexpected bytes.\",\n };\n } catch (e) {\n store = { ok: false, detail: `SnapshotStore error: ${String(e)}` };\n }\n\n // 3. Transport health — subscribe, broadcast, observe, unsubscribe.\n let transport = { ok: false, detail: \"\" };\n try {\n let received = false;\n const off = this.transport.subscribe(\"__collab_doctor__\", \"__probe__\", () => {\n received = true;\n });\n await this.transport.broadcast(\"__collab_doctor__\", \"__probe__\", new Uint8Array([1]));\n off();\n transport = {\n ok: received,\n detail: received\n ? \"Transport broadcast/subscribe round-trip succeeded.\"\n : \"Transport did not deliver the probe (network adapter may be async).\",\n };\n } catch (e) {\n transport = { ok: false, detail: `Transport error: ${String(e)}` };\n }\n\n return {\n ok: yjs.ok && store.ok && transport.ok,\n durationMs: Date.now() - start,\n checks: { yjs, store, transport },\n };\n }\n\n destroy(): void {\n for (const room of this.rooms.values()) room.destroy();\n this.rooms.clear();\n }\n}\n\n/** Synchronous factory. */\nexport function createCollab(config?: CollabConfig): CollabHub {\n return new Hub(config);\n}\n\n/**\n * Async factory mirroring other Sailor integration packages. There is no\n * env/credential negotiation for the zero-config defaults, so this resolves\n * immediately — the async shape is reserved for future credentialed\n * store/transport providers without a breaking signature change.\n */\nexport async function getCollab(config?: CollabConfig): Promise<CollabHub> {\n return createCollab(config);\n}\n"],"mappings":"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","names":["Y"]}
1
+ {"version":3,"sources":["../src/errors.ts","../src/room.ts","../src/hub.ts"],"sourcesContent":["/**\n * Every failure surfaced by this package is a `CollabError`. The contract is\n * deliberately strict: a machine-stable `code` and a human-actionable\n * `suggestion` are MANDATORY, so no code path can throw a bare `Error` that\n * leaves a caller without a remediation hint.\n *\n * Mechanics (code/suggestion/toJSON/empty-suggestion fallback) are inherited\n * from the shared `@nebutra/capability-kit` `CapabilityError`; this subclass\n * only pins collab's error name + its package-specific fallback wording, so\n * the observable contract is unchanged.\n */\n\nimport { CapabilityError } from \"@nebutra/capability-kit\";\n\nexport type CollabErrorCode =\n | \"COLLAB_INVALID_TENANT\"\n | \"COLLAB_INVALID_ROOM\"\n | \"COLLAB_SNAPSHOT_FAILED\"\n | \"COLLAB_RESTORE_FAILED\"\n | \"COLLAB_DESTROYED\"\n | \"COLLAB_TEST\"\n | (string & {});\n\nexport interface CollabErrorInit {\n readonly code: CollabErrorCode;\n /** A non-empty, actionable remediation hint. */\n readonly suggestion: string;\n readonly cause?: unknown;\n}\n\nexport class CollabError extends CapabilityError {\n declare readonly code: CollabErrorCode;\n\n constructor(message: string, init: CollabErrorInit) {\n super(message, init, {\n name: \"CollabError\",\n emptySuggestionFallback:\n \"No suggestion was provided. This is a bug in @nebutra/collab — \" +\n \"report it with the failing operation.\",\n });\n }\n}\n","/**\n * A single tenant-scoped CRDT room. One `Y.Doc` per (tenant, room). The hub\n * owns the partitioning; this class assumes its `tenantId`/`roomId` are\n * already the partition it belongs to and never reaches outside them.\n *\n * Snapshot persistence is serialized through `withTenantLock(tenantId,\n * roomId, ...)` borrowed from `@nebutra/tenant-store` rather than a\n * hand-rolled mutex — same primitive used by canvas/reel, so a future swap\n * to a distributed lock changes one place.\n */\n\nimport { withTenantLock } from \"@nebutra/tenant-store\";\nimport * as Y from \"yjs\";\nimport { CollabError } from \"./errors\";\nimport type { CollabRoom, CollabTransport, SnapshotStore, UpdateListener } from \"./types\";\n\n/** Origin tag used when applying remote updates so we don't echo them back. */\nconst REMOTE_ORIGIN = Symbol(\"collab.remote\");\n\nexport class Room implements CollabRoom {\n readonly doc: Y.Doc;\n private readonly listeners = new Set<UpdateListener>();\n private readonly unsubTransport: () => void;\n private destroyed = false;\n\n constructor(\n readonly tenantId: string,\n readonly roomId: string,\n private readonly store: SnapshotStore,\n private readonly transport: CollabTransport,\n ) {\n this.doc = new Y.Doc();\n\n // Fan local updates out to: registered listeners + the transport. The\n // transport echo is guarded by origin so a remote-applied update is not\n // re-broadcast into a loop.\n this.doc.on(\"update\", (update: Uint8Array, origin: unknown) => {\n for (const cb of [...this.listeners]) cb(update, origin);\n if (origin !== REMOTE_ORIGIN) {\n void Promise.resolve(this.transport.broadcast(this.tenantId, this.roomId, update)).catch(\n () => {\n // Transport delivery is best-effort; CRDT state stays correct and\n // converges on the next exchanged update. Swallowing here avoids\n // an unhandled rejection from a flaky network adapter.\n },\n );\n }\n });\n\n // Remote updates for THIS tenant-scoped channel only.\n this.unsubTransport = this.transport.subscribe(this.tenantId, this.roomId, (update) => {\n if (this.destroyed) return;\n Y.applyUpdate(this.doc, update, REMOTE_ORIGIN);\n });\n }\n\n applyUpdate(update: Uint8Array, origin?: unknown): void {\n this.assertLive();\n Y.applyUpdate(this.doc, update, origin);\n }\n\n encodeState(): Uint8Array {\n this.assertLive();\n return Y.encodeStateAsUpdate(this.doc);\n }\n\n onUpdate(cb: UpdateListener): () => void {\n this.assertLive();\n this.listeners.add(cb);\n return () => {\n this.listeners.delete(cb);\n };\n }\n\n async snapshot(): Promise<void> {\n this.assertLive();\n const state = this.encodeState();\n try {\n // Serialize concurrent snapshots of the SAME room; different rooms (or\n // the same room under another tenant) persist in parallel.\n await withTenantLock(this.tenantId, this.roomId, () =>\n this.store.save(this.tenantId, this.roomId, state),\n );\n } catch (cause) {\n throw new CollabError(`Failed to persist snapshot for room \"${this.roomId}\".`, {\n code: \"COLLAB_SNAPSHOT_FAILED\",\n suggestion:\n \"Verify the configured SnapshotStore is reachable (DB/Redis up, \" +\n \"credentials valid). The in-memory default never fails; a custom \" +\n \"adapter likely threw.\",\n cause,\n });\n }\n }\n\n /** Hydrate this doc from persisted state, if any. Internal to the hub. */\n async _restore(): Promise<void> {\n try {\n const persisted = await this.store.load(this.tenantId, this.roomId);\n if (persisted) Y.applyUpdate(this.doc, persisted, REMOTE_ORIGIN);\n } catch (cause) {\n throw new CollabError(`Failed to restore room \"${this.roomId}\" from snapshot store.`, {\n code: \"COLLAB_RESTORE_FAILED\",\n suggestion:\n \"Check the SnapshotStore adapter's load() — it should resolve \" +\n \"null (not throw) when no snapshot exists for the tenant+room.\",\n cause,\n });\n }\n }\n\n destroy(): void {\n if (this.destroyed) return;\n this.destroyed = true;\n this.unsubTransport();\n this.listeners.clear();\n this.doc.destroy();\n }\n\n private assertLive(): void {\n if (this.destroyed) {\n throw new CollabError(`Room \"${this.roomId}\" was destroyed and can no longer be used.`, {\n code: \"COLLAB_DESTROYED\",\n suggestion:\n \"Acquire a fresh room via hub.room(tenantId, roomId) instead of \" +\n \"reusing a destroyed instance.\",\n });\n }\n }\n}\n","/**\n * `CollabHub` — the tenant-partition boundary.\n *\n * TENANT ISOLATION IS STRUCTURAL: rooms are stored in a `Map` keyed by a\n * composite `tenantId\u0000roomId` string with a NUL separator that cannot\n * appear in a normal id. There is no API that takes only a roomId, so a key\n * minted for tenant A is unreachable from tenant B — a room handle is only\n * ever produced by passing an explicit `tenantId`, and the snapshot store +\n * transport are likewise addressed by (tenantId, roomId). The same property\n * a Prisma adapter gets from RLS is enforced here by the composite key, with\n * no trust placed in caller-supplied payload.\n */\n\nimport { CollabError } from \"./errors\";\nimport { Room } from \"./room\";\nimport { InMemorySnapshotStore } from \"./store/memory\";\nimport { LoopbackTransport } from \"./transport/loopback\";\nimport type {\n CollabConfig,\n CollabHub,\n CollabRoom,\n CollabTransport,\n DoctorReport,\n SnapshotStore,\n} from \"./types\";\n\n// NUL separator: it cannot appear in a normal tenant/room id, so the pairs\n// (\"a\",\"bc\") and (\"ab\",\"c\") can never collide into the same room key — the\n// tenant partition is exact, not merely conventional.\nconst KEY_SEP = \"\u0000\";\n\nfunction roomKey(tenantId: string, roomId: string): string {\n return `${tenantId}${KEY_SEP}${roomId}`;\n}\n\nfunction assertId(value: string, kind: \"tenant\" | \"room\"): void {\n if (typeof value !== \"string\" || value.trim().length === 0) {\n throw new CollabError(`A non-empty ${kind}Id is required.`, {\n code: kind === \"tenant\" ? \"COLLAB_INVALID_TENANT\" : \"COLLAB_INVALID_ROOM\",\n suggestion:\n kind === \"tenant\"\n ? \"Pass the current tenant id, e.g. from getCurrentTenant().tenantId — \" +\n \"never call room() without an explicit tenant.\"\n : \"Pass a stable, non-empty roomId (document/canvas id).\",\n });\n }\n}\n\nclass Hub implements CollabHub {\n private readonly rooms = new Map<string, Room>();\n private readonly store: SnapshotStore;\n private readonly transport: CollabTransport;\n\n constructor(config: CollabConfig = {}) {\n this.store = config.store ?? new InMemorySnapshotStore();\n this.transport = config.transport ?? new LoopbackTransport();\n }\n\n room(tenantId: string, roomId: string): CollabRoom {\n assertId(tenantId, \"tenant\");\n assertId(roomId, \"room\");\n const key = roomKey(tenantId, roomId);\n let room = this.rooms.get(key);\n if (!room) {\n room = new Room(tenantId, roomId, this.store, this.transport);\n this.rooms.set(key, room);\n }\n return room;\n }\n\n async roomRestored(tenantId: string, roomId: string): Promise<CollabRoom> {\n const key = roomKey(tenantId, roomId);\n const existed = this.rooms.has(key);\n const room = this.room(tenantId, roomId) as Room;\n // Only hydrate a freshly created room; an already-live room is the\n // authoritative in-memory state.\n if (!existed) await room._restore();\n return room;\n }\n\n async doctor(): Promise<DoctorReport> {\n const start = Date.now();\n\n // 1. Yjs presence + a real round-trip (not just \"is it imported\").\n let yjsOk = false;\n let yjsDetail = \"\";\n try {\n const Y = await import(\"yjs\");\n const probe = new Y.Doc();\n probe.getMap(\"p\").set(\"k\", 1);\n const restored = new Y.Doc();\n Y.applyUpdate(restored, Y.encodeStateAsUpdate(probe));\n const ok = restored.getMap(\"p\").get(\"k\") === 1;\n probe.destroy();\n restored.destroy();\n yjsOk = ok;\n yjsDetail = ok\n ? \"Yjs encode/apply round-trip succeeded.\"\n : \"Yjs round-trip produced unexpected state.\";\n } catch (e) {\n yjsDetail = `Yjs unavailable: ${String(e)}`;\n }\n const yjs = { ok: yjsOk, detail: yjsDetail };\n\n // 2. Store health — write+read under a reserved diagnostic tenant.\n let storeOk = false;\n let storeDetail = \"\";\n try {\n const probe = new Uint8Array([7, 7, 7]);\n await this.store.save(\"__collab_doctor__\", \"__probe__\", probe);\n const back = await this.store.load(\"__collab_doctor__\", \"__probe__\");\n const ok = !!back && back.length === probe.length && back[0] === 7;\n storeOk = ok;\n storeDetail = ok\n ? \"SnapshotStore save/load round-trip succeeded.\"\n : \"SnapshotStore returned unexpected bytes.\";\n } catch (e) {\n storeDetail = `SnapshotStore error: ${String(e)}`;\n }\n const store = { ok: storeOk, detail: storeDetail };\n\n // 3. Transport health — subscribe, broadcast, observe, unsubscribe.\n let transportOk = false;\n let transportDetail = \"\";\n try {\n let received = false;\n const off = this.transport.subscribe(\"__collab_doctor__\", \"__probe__\", () => {\n received = true;\n });\n await this.transport.broadcast(\"__collab_doctor__\", \"__probe__\", new Uint8Array([1]));\n off();\n transportOk = received;\n transportDetail = received\n ? \"Transport broadcast/subscribe round-trip succeeded.\"\n : \"Transport did not deliver the probe (network adapter may be async).\";\n } catch (e) {\n transportDetail = `Transport error: ${String(e)}`;\n }\n const transport = { ok: transportOk, detail: transportDetail };\n\n return {\n ok: yjs.ok && store.ok && transport.ok,\n durationMs: Date.now() - start,\n checks: { yjs, store, transport },\n };\n }\n\n destroy(): void {\n for (const room of this.rooms.values()) room.destroy();\n this.rooms.clear();\n }\n}\n\n/** Synchronous factory. */\nexport function createCollab(config?: CollabConfig): CollabHub {\n return new Hub(config);\n}\n\n/**\n * Async factory mirroring other Sailor integration packages. There is no\n * env/credential negotiation for the zero-config defaults, so this resolves\n * immediately — the async shape is reserved for future credentialed\n * store/transport providers without a breaking signature change.\n */\nexport async function getCollab(config?: CollabConfig): Promise<CollabHub> {\n return createCollab(config);\n}\n"],"mappings":"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","names":["Y"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nebutra/collab",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "Multi-tenant, transport-agnostic real-time collaborative sync layer: tenant-partitioned CRDT rooms (Yjs) with pluggable snapshot store + transport seams",
5
5
  "private": false,
6
6
  "license": "MIT",
@@ -10,20 +10,22 @@
10
10
  "category": "integrations",
11
11
  "summary": "Tenant-partitioned CRDT room manager (Yjs) with pluggable store + transport seams"
12
12
  },
13
- "main": "./src/index.ts",
14
- "types": "./src/index.ts",
13
+ "main": "./dist/index.js",
14
+ "types": "./dist/index.d.ts",
15
15
  "exports": {
16
- ".": "./src/index.ts",
17
- "./store/memory": "./src/store/memory.ts",
18
- "./transport/loopback": "./src/transport/loopback.ts"
16
+ ".": {
17
+ "types": "./dist/index.d.ts",
18
+ "import": "./dist/index.js",
19
+ "default": "./dist/index.js"
20
+ }
19
21
  },
20
22
  "dependencies": {
21
23
  "yjs": "^13.6.30",
22
- "@nebutra/capability-kit": "0.2.1",
23
- "@nebutra/tenant-store": "0.2.1"
24
+ "@nebutra/tenant-store": "0.2.2",
25
+ "@nebutra/capability-kit": "0.2.4"
24
26
  },
25
27
  "devDependencies": {
26
- "@types/node": "^22.19.15",
28
+ "@types/node": "^25.9.1",
27
29
  "@vitest/coverage-v8": "^4.0.18",
28
30
  "tsup": "^8.5.1",
29
31
  "typescript": "^5.9.3",
@@ -41,6 +43,12 @@
41
43
  "publishConfig": {
42
44
  "access": "public"
43
45
  },
46
+ "files": [
47
+ "dist",
48
+ "README.md",
49
+ "LICENSE",
50
+ "CHANGELOG.md"
51
+ ],
44
52
  "scripts": {
45
53
  "build": "tsup",
46
54
  "test": "vitest run",
@@ -1,28 +0,0 @@
1
-
2
- > @nebutra/collab@0.2.1 build /home/runner/work/Nebutra-Sailor/Nebutra-Sailor/packages/integrations/collab
3
- > tsup
4
-
5
- CLI Building entry: src/index.ts, src/store/memory.ts, src/transport/loopback.ts
6
- CLI Using tsconfig: tsconfig.json
7
- CLI tsup v8.5.1
8
- CLI Using tsup config: /home/runner/work/Nebutra-Sailor/Nebutra-Sailor/packages/integrations/collab/tsup.config.ts
9
- CLI Target: es2022
10
- CLI Cleaning output folder
11
- ESM Build start
12
- ESM dist/index.js 6.98 KB
13
- ESM dist/store/memory.js 134.00 B
14
- ESM dist/transport/loopback.js 128.00 B
15
- ESM dist/chunk-6XHNO2RP.js 581.00 B
16
- ESM dist/chunk-S2HTNEZN.js 817.00 B
17
- ESM dist/index.js.map 16.53 KB
18
- ESM dist/store/memory.js.map 71.00 B
19
- ESM dist/transport/loopback.js.map 71.00 B
20
- ESM dist/chunk-6XHNO2RP.js.map 2.04 KB
21
- ESM dist/chunk-S2HTNEZN.js.map 2.46 KB
22
- ESM ⚡️ Build success in 127ms
23
- DTS Build start
24
- DTS ⚡️ Build success in 15811ms
25
- DTS dist/index.d.ts 2.68 KB
26
- DTS dist/store/memory.d.ts 1.04 KB
27
- DTS dist/transport/loopback.d.ts 1005.00 B
28
- DTS dist/types-68zA3h9k.d.ts 2.27 KB
@@ -1,14 +0,0 @@
1
-
2
- > @nebutra/collab@0.2.1 test /home/runner/work/Nebutra-Sailor/Nebutra-Sailor/packages/integrations/collab
3
- > vitest run
4
-
5
-
6
-  RUN  v4.1.4 /home/runner/work/Nebutra-Sailor/Nebutra-Sailor/packages/integrations/collab
7
-
8
- ✓ src/__tests__/collab.test.ts (14 tests) 94ms
9
-
10
-  Test Files  1 passed (1)
11
-  Tests  14 passed (14)
12
-  Start at  08:15:01
13
-  Duration  1.74s (transform 596ms, setup 0ms, import 859ms, tests 94ms, environment 0ms)
14
-
@@ -1,4 +0,0 @@
1
-
2
- > @nebutra/collab@0.2.1 typecheck /home/runner/work/Nebutra-Sailor/Nebutra-Sailor/packages/integrations/collab
3
- > tsc --noEmit
4
-
@@ -1,39 +0,0 @@
1
- /**
2
- * Example 3 — snapshot + restore across hub lifetimes.
3
- *
4
- * Persist a room via the injected SnapshotStore, then hydrate a brand-new
5
- * hub from the same store. Demonstrates the persist-then-reload contract
6
- * used by long-lived documents/canvases.
7
- *
8
- * Run: pnpm --filter @nebutra/collab exec tsx examples/snapshot-restore.ts
9
- */
10
-
11
- import { createCollab } from "../src/index";
12
- import { InMemorySnapshotStore } from "../src/store/memory";
13
-
14
- export async function main(): Promise<void> {
15
- // A shared store stands in for Prisma/Redis in production.
16
- const store = new InMemorySnapshotStore();
17
-
18
- const hub1 = createCollab({ store });
19
- const r1 = hub1.room("t1", "doc-1");
20
- r1.doc.getText("body").insert(0, "persisted across restarts");
21
- await r1.snapshot();
22
- hub1.destroy();
23
-
24
- // New process / new hub, same store.
25
- const hub2 = createCollab({ store });
26
- const r2 = await hub2.roomRestored("t1", "doc-1");
27
- const restored = r2.doc.getText("body").toString();
28
- if (restored !== "persisted across restarts") {
29
- throw new Error(`restore failed, got: "${restored}"`);
30
- }
31
-
32
- // eslint-disable-next-line no-console
33
- console.warn(`[collab] restored after reload: "${restored}"`);
34
- hub2.destroy();
35
- }
36
-
37
- if (import.meta.url === `file://${process.argv[1]}`) {
38
- void main();
39
- }
@@ -1,42 +0,0 @@
1
- /**
2
- * Example 2 — tenant isolation (security-critical).
3
- *
4
- * Two tenants use the SAME roomId. Their docs, snapshot state and transport
5
- * channels are hard-partitioned: tenant A's edits are unreachable from
6
- * tenant B even though the room id string is identical.
7
- *
8
- * Run: pnpm --filter @nebutra/collab exec tsx examples/tenant-isolation.ts
9
- */
10
-
11
- import { getCollab } from "../src/index";
12
-
13
- export async function main(): Promise<void> {
14
- const hub = await getCollab();
15
-
16
- const roomId = "shared-room-id";
17
- const a = hub.room("tenantA", roomId);
18
- const b = hub.room("tenantB", roomId);
19
-
20
- if (a.doc === b.doc) throw new Error("tenant partition breached: shared doc");
21
-
22
- const leaked: unknown[] = [];
23
- b.onUpdate((u) => leaked.push(u));
24
-
25
- a.doc.getMap("m").set("secret", "A-only");
26
- await a.snapshot();
27
-
28
- if (b.doc.getMap("m").get("secret") !== undefined) {
29
- throw new Error("tenant partition breached: B saw A's value");
30
- }
31
- if (leaked.length !== 0) {
32
- throw new Error("tenant partition breached: B received A's update");
33
- }
34
-
35
- // eslint-disable-next-line no-console
36
- console.warn("[collab] tenant isolation holds: B never observed A");
37
- hub.destroy();
38
- }
39
-
40
- if (import.meta.url === `file://${process.argv[1]}`) {
41
- void main();
42
- }
@@ -1,43 +0,0 @@
1
- /**
2
- * Example 1 — zero-config CRDT convergence.
3
- *
4
- * No env, no config, real (non-mock) Yjs behaviour. This file is also the
5
- * in-package REAL CALLER of the exported `getCollab` factory that keeps
6
- * `@nebutra/collab` in the `active` tier of the three-tier module lifecycle.
7
- *
8
- * Run: pnpm --filter @nebutra/collab exec tsx examples/zero-config-convergence.ts
9
- */
10
-
11
- import * as Y from "yjs";
12
- import { getCollab } from "../src/index";
13
-
14
- export async function main(): Promise<void> {
15
- const hub = await getCollab();
16
-
17
- // Two clients on the SAME tenant + room.
18
- const a = hub.room("t1", "doc-1");
19
- const b = hub.room("t1", "doc-1");
20
- if (a !== b) throw new Error("same (tenant,room) must yield same instance");
21
-
22
- // A standalone peer doc simulating a second device.
23
- const peer = new Y.Doc();
24
- a.doc.getText("body").insert(0, "Hello ");
25
- Y.applyUpdate(peer, a.encodeState());
26
-
27
- peer.getText("body").insert(6, "collab");
28
- a.applyUpdate(Y.encodeStateAsUpdate(peer));
29
- Y.applyUpdate(peer, a.encodeState());
30
-
31
- const converged = a.doc.getText("body").toString();
32
- if (converged !== peer.getText("body").toString()) {
33
- throw new Error("clients did not converge");
34
- }
35
-
36
- // eslint-disable-next-line no-console
37
- console.warn(`[collab] converged text: "${converged}"`);
38
- hub.destroy();
39
- }
40
-
41
- if (import.meta.url === `file://${process.argv[1]}`) {
42
- void main();
43
- }
@@ -1,240 +0,0 @@
1
- import { _resetTenantLocks } from "@nebutra/tenant-store";
2
- import { beforeEach, describe, expect, it } from "vitest";
3
- import * as Y from "yjs";
4
- import { CollabError, createCollab } from "../index";
5
- import { InMemorySnapshotStore } from "../store/memory";
6
- import { LoopbackTransport } from "../transport/loopback";
7
-
8
- describe("CRDT convergence", () => {
9
- beforeEach(() => _resetTenantLocks());
10
-
11
- it("two clients editing the SAME room converge to identical state", async () => {
12
- const hub = createCollab();
13
- const a = hub.room("t1", "r1");
14
-
15
- // A separate client doc, simulating a remote peer on the same logical room.
16
- const peer = new Y.Doc();
17
-
18
- // Edit on hub side.
19
- a.doc.getMap("m").set("from", "a");
20
- // Ship A's update to the peer.
21
- peer.transact(() => Y.applyUpdate(peer, a.encodeState()));
22
-
23
- // Peer edits concurrently.
24
- peer.getMap("m").set("from", "b-peer");
25
- peer.getArray("list").push([1, 2, 3]);
26
-
27
- // Exchange peer -> A.
28
- a.applyUpdate(Y.encodeStateAsUpdate(peer));
29
- // Exchange A -> peer (A may have other state).
30
- Y.applyUpdate(peer, a.encodeState());
31
-
32
- // Both sides must converge to identical serialized state.
33
- const aState = JSON.stringify(a.doc.getMap("m").toJSON());
34
- const peerState = JSON.stringify(peer.getMap("m").toJSON());
35
- expect(aState).toBe(peerState);
36
- expect(a.doc.getArray("list").toJSON()).toEqual([1, 2, 3]);
37
- });
38
- });
39
-
40
- describe("tenant isolation (security-critical)", () => {
41
- beforeEach(() => _resetTenantLocks());
42
-
43
- it("room(tenantA) and room(tenantB) with same roomId are independent docs", () => {
44
- const hub = createCollab();
45
- const a = hub.room("tenantA", "r1");
46
- const b = hub.room("tenantB", "r1");
47
-
48
- expect(a.doc).not.toBe(b.doc);
49
-
50
- a.doc.getMap("m").set("secret", "A-only");
51
- // B's room must NEVER observe A's update.
52
- expect(b.doc.getMap("m").get("secret")).toBeUndefined();
53
- });
54
-
55
- it("an update applied to A's room never appears in B's room", () => {
56
- const hub = createCollab();
57
- const a = hub.room("tenantA", "r1");
58
- const b = hub.room("tenantB", "r1");
59
-
60
- const seenByB: Uint8Array[] = [];
61
- b.onUpdate((u) => seenByB.push(u));
62
-
63
- a.doc.getMap("m").set("x", 1);
64
- a.applyUpdate(Y.encodeStateAsUpdate(a.doc));
65
-
66
- expect(seenByB).toHaveLength(0);
67
- expect(b.doc.getMap("m").get("x")).toBeUndefined();
68
- });
69
-
70
- it("snapshot store for tenant A never returns tenant B state", async () => {
71
- const store = new InMemorySnapshotStore();
72
- const hub = createCollab({ store });
73
-
74
- const a = hub.room("tenantA", "shared-room-id");
75
- a.doc.getMap("m").set("owner", "A");
76
- await a.snapshot();
77
-
78
- // B asks for the SAME roomId — must get nothing from A.
79
- const bState = await store.load("tenantB", "shared-room-id");
80
- expect(bState).toBeNull();
81
-
82
- const aState = await store.load("tenantA", "shared-room-id");
83
- expect(aState).not.toBeNull();
84
- });
85
-
86
- it("returns the SAME room instance for repeated (tenant, room) calls", () => {
87
- const hub = createCollab();
88
- expect(hub.room("t1", "r1")).toBe(hub.room("t1", "r1"));
89
- expect(hub.room("t1", "r1")).not.toBe(hub.room("t2", "r1"));
90
- });
91
- });
92
-
93
- describe("transport-driven convergence", () => {
94
- beforeEach(() => _resetTenantLocks());
95
-
96
- it("separate hubs sharing a transport converge for the same tenant+room", () => {
97
- const transport = new LoopbackTransport();
98
- const hubA = createCollab({ transport });
99
- const hubB = createCollab({ transport });
100
- const ra = hubA.room("t1", "r1");
101
- const rb = hubB.room("t1", "r1");
102
-
103
- ra.doc.getMap("m").set("k", "v");
104
- expect(rb.doc.getMap("m").get("k")).toBe("v");
105
-
106
- // Reverse direction also converges (origin guard prevents a loop).
107
- rb.doc.getArray("l").push([42]);
108
- expect(ra.doc.getArray("l").toJSON()).toEqual([42]);
109
-
110
- hubA.destroy();
111
- hubB.destroy();
112
- });
113
-
114
- it("a destroyed room throws CollabError COLLAB_DESTROYED", () => {
115
- const hub = createCollab();
116
- const r = hub.room("t1", "r1");
117
- r.destroy();
118
- try {
119
- r.encodeState();
120
- throw new Error("should have thrown");
121
- } catch (e) {
122
- expect(e).toBeInstanceOf(CollabError);
123
- expect((e as CollabError).code).toBe("COLLAB_DESTROYED");
124
- expect((e as CollabError).suggestion.length).toBeGreaterThan(0);
125
- }
126
- });
127
- });
128
-
129
- describe("restore / snapshot failure surfaces CollabError", () => {
130
- beforeEach(() => _resetTenantLocks());
131
-
132
- it("roomRestored wraps a throwing store load", async () => {
133
- const store = {
134
- load: async () => {
135
- throw new Error("db down");
136
- },
137
- save: async () => {},
138
- };
139
- const hub = createCollab({ store });
140
- try {
141
- await hub.roomRestored("t1", "r1");
142
- throw new Error("should have thrown");
143
- } catch (e) {
144
- expect(e).toBeInstanceOf(CollabError);
145
- expect((e as CollabError).code).toBe("COLLAB_RESTORE_FAILED");
146
- expect((e as CollabError).suggestion.length).toBeGreaterThan(0);
147
- }
148
- });
149
-
150
- it("snapshot wraps a throwing store save", async () => {
151
- const store = {
152
- load: async () => null,
153
- save: async () => {
154
- throw new Error("disk full");
155
- },
156
- };
157
- const hub = createCollab({ store });
158
- const r = hub.room("t1", "r1");
159
- r.doc.getMap("m").set("a", 1);
160
- try {
161
- await r.snapshot();
162
- throw new Error("should have thrown");
163
- } catch (e) {
164
- expect(e).toBeInstanceOf(CollabError);
165
- expect((e as CollabError).code).toBe("COLLAB_SNAPSHOT_FAILED");
166
- expect((e as CollabError).suggestion.length).toBeGreaterThan(0);
167
- }
168
- });
169
- });
170
-
171
- describe("snapshot + restore", () => {
172
- beforeEach(() => _resetTenantLocks());
173
-
174
- it("snapshot() then reload restores doc state", async () => {
175
- const store = new InMemorySnapshotStore();
176
- const hub1 = createCollab({ store });
177
- const r1 = hub1.room("t1", "doc1");
178
- r1.doc.getText("body").insert(0, "hello world");
179
- await r1.snapshot();
180
-
181
- // Fresh hub, same store — must hydrate from snapshot.
182
- const hub2 = createCollab({ store });
183
- const r2 = await hub2.roomRestored("t1", "doc1");
184
- expect(r2.doc.getText("body").toString()).toBe("hello world");
185
- });
186
- });
187
-
188
- describe("onUpdate subscription", () => {
189
- beforeEach(() => _resetTenantLocks());
190
-
191
- it("unsubscribe stops further callbacks", () => {
192
- const hub = createCollab();
193
- const r = hub.room("t1", "r1");
194
- const seen: number[] = [];
195
- const off = r.onUpdate(() => seen.push(1));
196
-
197
- r.doc.getMap("m").set("a", 1);
198
- expect(seen).toHaveLength(1);
199
-
200
- off();
201
- r.doc.getMap("m").set("b", 2);
202
- expect(seen).toHaveLength(1);
203
- });
204
- });
205
-
206
- describe("CollabError", () => {
207
- it("carries a non-empty suggestion and a code", () => {
208
- const err = new CollabError("something failed", {
209
- code: "COLLAB_TEST",
210
- suggestion: "do the thing differently",
211
- });
212
- expect(err).toBeInstanceOf(Error);
213
- expect(err.code).toBe("COLLAB_TEST");
214
- expect(err.suggestion.length).toBeGreaterThan(0);
215
- });
216
-
217
- it("throws CollabError with suggestion on empty tenantId", () => {
218
- const hub = createCollab();
219
- try {
220
- hub.room("", "r1");
221
- throw new Error("should have thrown");
222
- } catch (e) {
223
- expect(e).toBeInstanceOf(CollabError);
224
- expect((e as CollabError).suggestion.length).toBeGreaterThan(0);
225
- }
226
- });
227
- });
228
-
229
- describe("doctor()", () => {
230
- it("returns a structured health report in <3s", async () => {
231
- const hub = createCollab();
232
- const start = Date.now();
233
- const report = await hub.doctor();
234
- expect(Date.now() - start).toBeLessThan(3000);
235
- expect(report.ok).toBe(true);
236
- expect(report.checks.yjs.ok).toBe(true);
237
- expect(report.checks.store.ok).toBe(true);
238
- expect(report.checks.transport.ok).toBe(true);
239
- });
240
- });
package/src/cli.ts DELETED
@@ -1,27 +0,0 @@
1
- /**
2
- * collab CLI — `doctor` (Yjs/store/transport health) and `debug <roomId>`
3
- * (open a real zero-config room, apply an update, report convergence state).
4
- * The doctor/debug argv switch is the shared `@nebutra/capability-kit`
5
- * runner; only the collab-specific probes live here.
6
- */
7
-
8
- import { runCapabilityCli } from "@nebutra/capability-kit";
9
- import { getCollab } from "./index";
10
-
11
- const hub = await getCollab();
12
-
13
- await runCapabilityCli({
14
- capability: "collab",
15
- doctor: () => hub.doctor(),
16
- debug: (roomId?: string) => {
17
- const tenantId = "debug-tenant";
18
- const id = roomId ?? "debug-room";
19
- const room = hub.room(tenantId, id);
20
- const before = room.encodeState().length;
21
- room.doc.getText("debug").insert(0, "hello canvas");
22
- const after = room.encodeState().length;
23
- const text = room.doc.getText("debug").toString();
24
- room.destroy();
25
- return { tenantId, roomId: id, encodedBytes: { before, after }, text };
26
- },
27
- });