@nebutra/collab 0.2.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.
@@ -0,0 +1,24 @@
1
+ // src/store/memory.ts
2
+ import { InMemoryTenantStore } from "@nebutra/tenant-store";
3
+ var InMemorySnapshotStore = class {
4
+ inner = new InMemoryTenantStore();
5
+ async load(tenantId, roomId) {
6
+ const row = await this.inner.read(tenantId, roomId);
7
+ return row ? row.state : null;
8
+ }
9
+ async save(tenantId, roomId, state) {
10
+ await this.inner.write(tenantId, roomId, {
11
+ tenantId,
12
+ state: Uint8Array.from(state)
13
+ });
14
+ }
15
+ /** Test helper. */
16
+ clear() {
17
+ this.inner.clear();
18
+ }
19
+ };
20
+
21
+ export {
22
+ InMemorySnapshotStore
23
+ };
24
+ //# sourceMappingURL=chunk-6XHNO2RP.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/store/memory.ts"],"sourcesContent":["/**\n * Zero-config default `SnapshotStore`. The storage MECHANICS (composite-key\n * map, never returning rows across tenants) are NOT re-implemented here —\n * they are composed from `@nebutra/tenant-store`'s `InMemoryTenantStore`,\n * which already enforces tenant isolation structurally via its\n * `tenantId:id` key plus a defense-in-depth tenantId equality check.\n *\n * A production deployment swaps this for a Prisma/Redis adapter that\n * implements the same `SnapshotStore` interface; the README documents that\n * shape. The isolation property a Prisma adapter would get from RLS is here\n * provided by the borrowed composite key.\n */\n\nimport { InMemoryTenantStore, type TenantOwned } from \"@nebutra/tenant-store\";\nimport type { SnapshotStore } from \"../types\";\n\ninterface SnapshotRow extends TenantOwned {\n readonly tenantId: string;\n readonly state: Uint8Array;\n}\n\nexport class InMemorySnapshotStore implements SnapshotStore {\n private readonly inner = new InMemoryTenantStore<SnapshotRow>();\n\n async load(tenantId: string, roomId: string): Promise<Uint8Array | null> {\n const row = await this.inner.read(tenantId, roomId);\n return row ? row.state : null;\n }\n\n async save(tenantId: string, roomId: string, state: Uint8Array): Promise<void> {\n // Copy so a later in-place mutation of the caller's buffer can't\n // retroactively corrupt persisted state.\n await this.inner.write(tenantId, roomId, {\n tenantId,\n state: Uint8Array.from(state),\n });\n }\n\n /** Test helper. */\n clear(): void {\n this.inner.clear();\n }\n}\n"],"mappings":";AAaA,SAAS,2BAA6C;AAQ/C,IAAM,wBAAN,MAAqD;AAAA,EACzC,QAAQ,IAAI,oBAAiC;AAAA,EAE9D,MAAM,KAAK,UAAkB,QAA4C;AACvE,UAAM,MAAM,MAAM,KAAK,MAAM,KAAK,UAAU,MAAM;AAClD,WAAO,MAAM,IAAI,QAAQ;AAAA,EAC3B;AAAA,EAEA,MAAM,KAAK,UAAkB,QAAgB,OAAkC;AAG7E,UAAM,KAAK,MAAM,MAAM,UAAU,QAAQ;AAAA,MACvC;AAAA,MACA,OAAO,WAAW,KAAK,KAAK;AAAA,IAC9B,CAAC;AAAA,EACH;AAAA;AAAA,EAGA,QAAc;AACZ,SAAK,MAAM,MAAM;AAAA,EACnB;AACF;","names":[]}
@@ -0,0 +1,32 @@
1
+ // src/transport/loopback.ts
2
+ function channel(tenantId, roomId) {
3
+ return `${tenantId}\0${roomId}`;
4
+ }
5
+ var LoopbackTransport = class {
6
+ channels = /* @__PURE__ */ new Map();
7
+ broadcast(tenantId, roomId, update) {
8
+ const set = this.channels.get(channel(tenantId, roomId));
9
+ if (!set) return;
10
+ for (const cb of [...set]) cb(update);
11
+ }
12
+ subscribe(tenantId, roomId, cb) {
13
+ const key = channel(tenantId, roomId);
14
+ let set = this.channels.get(key);
15
+ if (!set) {
16
+ set = /* @__PURE__ */ new Set();
17
+ this.channels.set(key, set);
18
+ }
19
+ set.add(cb);
20
+ return () => {
21
+ const s = this.channels.get(key);
22
+ if (!s) return;
23
+ s.delete(cb);
24
+ if (s.size === 0) this.channels.delete(key);
25
+ };
26
+ }
27
+ };
28
+
29
+ export {
30
+ LoopbackTransport
31
+ };
32
+ //# sourceMappingURL=chunk-S2HTNEZN.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/transport/loopback.ts"],"sourcesContent":["/**\n * Zero-config default `CollabTransport`. An in-process loopback bus keyed by\n * `tenantId\u0000roomId`, so a subscriber for tenant A's room can never be\n * reached by a broadcast for tenant B's room of the same id (the tenant is\n * part of the channel key, not trusted from payload).\n *\n * A real network transport (Pusher channels, a WebSocket relay) implements\n * the same `CollabTransport` interface and is injected via\n * `createCollab({ transport })`. This file intentionally ships NO network\n * code — only the loopback so zero-config single-process usage works.\n */\n\nimport type { CollabTransport } from \"../types\";\n\ntype Listener = (update: Uint8Array) => void;\n\nfunction channel(tenantId: string, roomId: string): string {\n // NUL separator: cannot occur in normal ids, so \"a\",\"bc\" and \"ab\",\"c\"\n // can never collide into the same channel.\n return `${tenantId}\u0000${roomId}`;\n}\n\nexport class LoopbackTransport implements CollabTransport {\n private readonly channels = new Map<string, Set<Listener>>();\n\n broadcast(tenantId: string, roomId: string, update: Uint8Array): void {\n const set = this.channels.get(channel(tenantId, roomId));\n if (!set) return;\n // Snapshot listeners so unsubscribing during dispatch is safe.\n for (const cb of [...set]) cb(update);\n }\n\n subscribe(tenantId: string, roomId: string, cb: Listener): () => void {\n const key = channel(tenantId, roomId);\n let set = this.channels.get(key);\n if (!set) {\n set = new Set();\n this.channels.set(key, set);\n }\n set.add(cb);\n return () => {\n const s = this.channels.get(key);\n if (!s) return;\n s.delete(cb);\n if (s.size === 0) this.channels.delete(key);\n };\n }\n}\n"],"mappings":";AAgBA,SAAS,QAAQ,UAAkB,QAAwB;AAGzD,SAAO,GAAG,QAAQ,KAAI,MAAM;AAC9B;AAEO,IAAM,oBAAN,MAAmD;AAAA,EACvC,WAAW,oBAAI,IAA2B;AAAA,EAE3D,UAAU,UAAkB,QAAgB,QAA0B;AACpE,UAAM,MAAM,KAAK,SAAS,IAAI,QAAQ,UAAU,MAAM,CAAC;AACvD,QAAI,CAAC,IAAK;AAEV,eAAW,MAAM,CAAC,GAAG,GAAG,EAAG,IAAG,MAAM;AAAA,EACtC;AAAA,EAEA,UAAU,UAAkB,QAAgB,IAA0B;AACpE,UAAM,MAAM,QAAQ,UAAU,MAAM;AACpC,QAAI,MAAM,KAAK,SAAS,IAAI,GAAG;AAC/B,QAAI,CAAC,KAAK;AACR,YAAM,oBAAI,IAAI;AACd,WAAK,SAAS,IAAI,KAAK,GAAG;AAAA,IAC5B;AACA,QAAI,IAAI,EAAE;AACV,WAAO,MAAM;AACX,YAAM,IAAI,KAAK,SAAS,IAAI,GAAG;AAC/B,UAAI,CAAC,EAAG;AACR,QAAE,OAAO,EAAE;AACX,UAAI,EAAE,SAAS,EAAG,MAAK,SAAS,OAAO,GAAG;AAAA,IAC5C;AAAA,EACF;AACF;","names":[]}
Binary file
package/dist/index.js ADDED
@@ -0,0 +1,220 @@
1
+ import {
2
+ InMemorySnapshotStore
3
+ } from "./chunk-6XHNO2RP.js";
4
+ import {
5
+ LoopbackTransport
6
+ } from "./chunk-S2HTNEZN.js";
7
+
8
+ // src/errors.ts
9
+ import { CapabilityError } from "@nebutra/capability-kit";
10
+ var CollabError = class extends CapabilityError {
11
+ constructor(message, init) {
12
+ super(message, init, {
13
+ name: "CollabError",
14
+ emptySuggestionFallback: "No suggestion was provided. This is a bug in @nebutra/collab \u2014 report it with the failing operation."
15
+ });
16
+ }
17
+ };
18
+
19
+ // src/room.ts
20
+ import { withTenantLock } from "@nebutra/tenant-store";
21
+ import * as Y from "yjs";
22
+ var REMOTE_ORIGIN = /* @__PURE__ */ Symbol("collab.remote");
23
+ var Room = class {
24
+ constructor(tenantId, roomId, store, transport) {
25
+ this.tenantId = tenantId;
26
+ this.roomId = roomId;
27
+ this.store = store;
28
+ this.transport = transport;
29
+ this.doc = new Y.Doc();
30
+ this.doc.on("update", (update, origin) => {
31
+ for (const cb of [...this.listeners]) cb(update, origin);
32
+ if (origin !== REMOTE_ORIGIN) {
33
+ void Promise.resolve(this.transport.broadcast(this.tenantId, this.roomId, update)).catch(
34
+ () => {
35
+ }
36
+ );
37
+ }
38
+ });
39
+ this.unsubTransport = this.transport.subscribe(this.tenantId, this.roomId, (update) => {
40
+ if (this.destroyed) return;
41
+ Y.applyUpdate(this.doc, update, REMOTE_ORIGIN);
42
+ });
43
+ }
44
+ doc;
45
+ listeners = /* @__PURE__ */ new Set();
46
+ unsubTransport;
47
+ destroyed = false;
48
+ applyUpdate(update, origin) {
49
+ this.assertLive();
50
+ Y.applyUpdate(this.doc, update, origin);
51
+ }
52
+ encodeState() {
53
+ this.assertLive();
54
+ return Y.encodeStateAsUpdate(this.doc);
55
+ }
56
+ onUpdate(cb) {
57
+ this.assertLive();
58
+ this.listeners.add(cb);
59
+ return () => {
60
+ this.listeners.delete(cb);
61
+ };
62
+ }
63
+ async snapshot() {
64
+ this.assertLive();
65
+ const state = this.encodeState();
66
+ try {
67
+ await withTenantLock(
68
+ this.tenantId,
69
+ this.roomId,
70
+ () => this.store.save(this.tenantId, this.roomId, state)
71
+ );
72
+ } catch (cause) {
73
+ throw new CollabError(`Failed to persist snapshot for room "${this.roomId}".`, {
74
+ code: "COLLAB_SNAPSHOT_FAILED",
75
+ suggestion: "Verify the configured SnapshotStore is reachable (DB/Redis up, credentials valid). The in-memory default never fails; a custom adapter likely threw.",
76
+ cause
77
+ });
78
+ }
79
+ }
80
+ /** Hydrate this doc from persisted state, if any. Internal to the hub. */
81
+ async _restore() {
82
+ try {
83
+ const persisted = await this.store.load(this.tenantId, this.roomId);
84
+ if (persisted) Y.applyUpdate(this.doc, persisted, REMOTE_ORIGIN);
85
+ } catch (cause) {
86
+ throw new CollabError(`Failed to restore room "${this.roomId}" from snapshot store.`, {
87
+ code: "COLLAB_RESTORE_FAILED",
88
+ suggestion: "Check the SnapshotStore adapter's load() \u2014 it should resolve null (not throw) when no snapshot exists for the tenant+room.",
89
+ cause
90
+ });
91
+ }
92
+ }
93
+ destroy() {
94
+ if (this.destroyed) return;
95
+ this.destroyed = true;
96
+ this.unsubTransport();
97
+ this.listeners.clear();
98
+ this.doc.destroy();
99
+ }
100
+ assertLive() {
101
+ if (this.destroyed) {
102
+ throw new CollabError(`Room "${this.roomId}" was destroyed and can no longer be used.`, {
103
+ code: "COLLAB_DESTROYED",
104
+ suggestion: "Acquire a fresh room via hub.room(tenantId, roomId) instead of reusing a destroyed instance."
105
+ });
106
+ }
107
+ }
108
+ };
109
+
110
+ // src/hub.ts
111
+ var KEY_SEP = "\0";
112
+ function roomKey(tenantId, roomId) {
113
+ return `${tenantId}${KEY_SEP}${roomId}`;
114
+ }
115
+ function assertId(value, kind) {
116
+ if (typeof value !== "string" || value.trim().length === 0) {
117
+ throw new CollabError(`A non-empty ${kind}Id is required.`, {
118
+ code: kind === "tenant" ? "COLLAB_INVALID_TENANT" : "COLLAB_INVALID_ROOM",
119
+ suggestion: kind === "tenant" ? "Pass the current tenant id, e.g. from getCurrentTenant().tenantId \u2014 never call room() without an explicit tenant." : "Pass a stable, non-empty roomId (document/canvas id)."
120
+ });
121
+ }
122
+ }
123
+ var Hub = class {
124
+ rooms = /* @__PURE__ */ new Map();
125
+ store;
126
+ transport;
127
+ constructor(config = {}) {
128
+ this.store = config.store ?? new InMemorySnapshotStore();
129
+ this.transport = config.transport ?? new LoopbackTransport();
130
+ }
131
+ room(tenantId, roomId) {
132
+ assertId(tenantId, "tenant");
133
+ assertId(roomId, "room");
134
+ const key = roomKey(tenantId, roomId);
135
+ let room = this.rooms.get(key);
136
+ if (!room) {
137
+ room = new Room(tenantId, roomId, this.store, this.transport);
138
+ this.rooms.set(key, room);
139
+ }
140
+ return room;
141
+ }
142
+ async roomRestored(tenantId, roomId) {
143
+ const key = roomKey(tenantId, roomId);
144
+ const existed = this.rooms.has(key);
145
+ const room = this.room(tenantId, roomId);
146
+ if (!existed) await room._restore();
147
+ return room;
148
+ }
149
+ async doctor() {
150
+ const start = Date.now();
151
+ let yjs = { ok: false, detail: "" };
152
+ try {
153
+ const Y2 = await import("yjs");
154
+ const probe = new Y2.Doc();
155
+ probe.getMap("p").set("k", 1);
156
+ const restored = new Y2.Doc();
157
+ Y2.applyUpdate(restored, Y2.encodeStateAsUpdate(probe));
158
+ const ok = restored.getMap("p").get("k") === 1;
159
+ probe.destroy();
160
+ restored.destroy();
161
+ yjs = {
162
+ ok,
163
+ detail: ok ? "Yjs encode/apply round-trip succeeded." : "Yjs round-trip produced unexpected state."
164
+ };
165
+ } catch (e) {
166
+ yjs = { ok: false, detail: `Yjs unavailable: ${String(e)}` };
167
+ }
168
+ let store = { ok: false, detail: "" };
169
+ try {
170
+ const probe = new Uint8Array([7, 7, 7]);
171
+ await this.store.save("__collab_doctor__", "__probe__", probe);
172
+ const back = await this.store.load("__collab_doctor__", "__probe__");
173
+ 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
+ };
178
+ } catch (e) {
179
+ store = { ok: false, detail: `SnapshotStore error: ${String(e)}` };
180
+ }
181
+ let transport = { ok: false, detail: "" };
182
+ try {
183
+ let received = false;
184
+ const off = this.transport.subscribe("__collab_doctor__", "__probe__", () => {
185
+ received = true;
186
+ });
187
+ await this.transport.broadcast("__collab_doctor__", "__probe__", new Uint8Array([1]));
188
+ 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
+ };
193
+ } catch (e) {
194
+ transport = { ok: false, detail: `Transport error: ${String(e)}` };
195
+ }
196
+ return {
197
+ ok: yjs.ok && store.ok && transport.ok,
198
+ durationMs: Date.now() - start,
199
+ checks: { yjs, store, transport }
200
+ };
201
+ }
202
+ destroy() {
203
+ for (const room of this.rooms.values()) room.destroy();
204
+ this.rooms.clear();
205
+ }
206
+ };
207
+ function createCollab(config) {
208
+ return new Hub(config);
209
+ }
210
+ async function getCollab(config) {
211
+ return createCollab(config);
212
+ }
213
+ export {
214
+ CollabError,
215
+ InMemorySnapshotStore,
216
+ LoopbackTransport,
217
+ createCollab,
218
+ getCollab
219
+ };
220
+ //# sourceMappingURL=index.js.map
@@ -0,0 +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"]}
@@ -0,0 +1,26 @@
1
+ import { S as SnapshotStore } from '../types-68zA3h9k.js';
2
+ import '@nebutra/capability-kit';
3
+ import 'yjs';
4
+
5
+ /**
6
+ * Zero-config default `SnapshotStore`. The storage MECHANICS (composite-key
7
+ * map, never returning rows across tenants) are NOT re-implemented here —
8
+ * they are composed from `@nebutra/tenant-store`'s `InMemoryTenantStore`,
9
+ * which already enforces tenant isolation structurally via its
10
+ * `tenantId:id` key plus a defense-in-depth tenantId equality check.
11
+ *
12
+ * A production deployment swaps this for a Prisma/Redis adapter that
13
+ * implements the same `SnapshotStore` interface; the README documents that
14
+ * shape. The isolation property a Prisma adapter would get from RLS is here
15
+ * provided by the borrowed composite key.
16
+ */
17
+
18
+ declare class InMemorySnapshotStore implements SnapshotStore {
19
+ private readonly inner;
20
+ load(tenantId: string, roomId: string): Promise<Uint8Array | null>;
21
+ save(tenantId: string, roomId: string, state: Uint8Array): Promise<void>;
22
+ /** Test helper. */
23
+ clear(): void;
24
+ }
25
+
26
+ export { InMemorySnapshotStore };
@@ -0,0 +1,7 @@
1
+ import {
2
+ InMemorySnapshotStore
3
+ } from "../chunk-6XHNO2RP.js";
4
+ export {
5
+ InMemorySnapshotStore
6
+ };
7
+ //# sourceMappingURL=memory.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
Binary file
@@ -0,0 +1,7 @@
1
+ import {
2
+ LoopbackTransport
3
+ } from "../chunk-S2HTNEZN.js";
4
+ export {
5
+ LoopbackTransport
6
+ };
7
+ //# sourceMappingURL=loopback.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
@@ -0,0 +1,57 @@
1
+ import { DoctorReportBase, DoctorCheck } from '@nebutra/capability-kit';
2
+ import * as Y from 'yjs';
3
+
4
+ /**
5
+ * Pluggable persistence for encoded room state. State is an opaque Yjs
6
+ * update (`Uint8Array`); this layer never interprets it. The tenant+room
7
+ * pair is the partition key — adapters MUST scope reads/writes by it and
8
+ * MUST NOT return one tenant's bytes for another's key.
9
+ */
10
+ interface SnapshotStore {
11
+ load(tenantId: string, roomId: string): Promise<Uint8Array | null>;
12
+ save(tenantId: string, roomId: string, state: Uint8Array): Promise<void>;
13
+ }
14
+ /**
15
+ * Transport-agnostic fan-out seam. The default is an in-memory loopback;
16
+ * a Pusher / WebSocket adapter implements the same two methods and plugs in
17
+ * via `createCollab({ transport })`. Updates are opaque Yjs update bytes.
18
+ */
19
+ interface CollabTransport {
20
+ broadcast(tenantId: string, roomId: string, update: Uint8Array): void | Promise<void>;
21
+ /** Subscribe to remote updates for one tenant-scoped room; returns unsub. */
22
+ subscribe(tenantId: string, roomId: string, cb: (update: Uint8Array) => void): () => void;
23
+ }
24
+ type UpdateListener = (update: Uint8Array, origin: unknown) => void;
25
+ interface CollabConfig {
26
+ store?: SnapshotStore;
27
+ transport?: CollabTransport;
28
+ }
29
+ interface CollabRoom {
30
+ readonly tenantId: string;
31
+ readonly roomId: string;
32
+ readonly doc: Y.Doc;
33
+ applyUpdate(update: Uint8Array, origin?: unknown): void;
34
+ encodeState(): Uint8Array;
35
+ onUpdate(cb: UpdateListener): () => void;
36
+ snapshot(): Promise<void>;
37
+ destroy(): void;
38
+ }
39
+
40
+ interface DoctorReport extends DoctorReportBase {
41
+ readonly checks: {
42
+ readonly yjs: DoctorCheck;
43
+ readonly store: DoctorCheck;
44
+ readonly transport: DoctorCheck;
45
+ };
46
+ }
47
+ interface CollabHub {
48
+ /** Returns/creates a tenant-scoped CRDT room. Hard-partitioned by tenant. */
49
+ room(tenantId: string, roomId: string): CollabRoom;
50
+ /** Like `room`, but first hydrates from the snapshot store if present. */
51
+ roomRestored(tenantId: string, roomId: string): Promise<CollabRoom>;
52
+ doctor(): Promise<DoctorReport>;
53
+ /** Destroy every live room and release resources. */
54
+ destroy(): void;
55
+ }
56
+
57
+ export type { CollabConfig as C, DoctorReport as D, SnapshotStore as S, UpdateListener as U, CollabHub as a, CollabRoom as b, CollabTransport as c };
@@ -0,0 +1,39 @@
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
+ }
@@ -0,0 +1,42 @@
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
+ }
@@ -0,0 +1,43 @@
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
+ }
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@nebutra/collab",
3
+ "version": "0.2.0",
4
+ "description": "Multi-tenant, transport-agnostic real-time collaborative sync layer: tenant-partitioned CRDT rooms (Yjs) with pluggable snapshot store + transport seams",
5
+ "private": false,
6
+ "license": "MIT",
7
+ "type": "module",
8
+ "nebutra": {
9
+ "featureId": "collab",
10
+ "category": "integrations",
11
+ "summary": "Tenant-partitioned CRDT room manager (Yjs) with pluggable store + transport seams"
12
+ },
13
+ "main": "./src/index.ts",
14
+ "types": "./src/index.ts",
15
+ "exports": {
16
+ ".": "./src/index.ts",
17
+ "./store/memory": "./src/store/memory.ts",
18
+ "./transport/loopback": "./src/transport/loopback.ts"
19
+ },
20
+ "dependencies": {
21
+ "yjs": "^13.6.30",
22
+ "@nebutra/capability-kit": "0.2.0",
23
+ "@nebutra/tenant-store": "0.2.0"
24
+ },
25
+ "devDependencies": {
26
+ "@types/node": "^22.19.15",
27
+ "@vitest/coverage-v8": "^4.0.18",
28
+ "tsup": "^8.5.1",
29
+ "typescript": "^5.9.3",
30
+ "vitest": "^4.0.18"
31
+ },
32
+ "homepage": "https://github.com/Nebutra/Nebutra-Sailor/tree/main/packages/integrations/collab#readme",
33
+ "repository": {
34
+ "type": "git",
35
+ "url": "git+https://github.com/Nebutra/Nebutra-Sailor.git",
36
+ "directory": "packages/integrations/collab"
37
+ },
38
+ "bugs": {
39
+ "url": "https://github.com/Nebutra/Nebutra-Sailor/issues"
40
+ },
41
+ "publishConfig": {
42
+ "access": "public"
43
+ },
44
+ "scripts": {
45
+ "build": "tsup",
46
+ "test": "vitest run",
47
+ "test:coverage": "vitest run --coverage",
48
+ "typecheck": "tsc --noEmit"
49
+ }
50
+ }