@nebutra/collab 0.2.1 → 0.2.2

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,235 @@
1
+
2
+ <!doctype html>
3
+ <html lang="en">
4
+
5
+ <head>
6
+ <title>Code coverage report for src/store/memory.ts</title>
7
+ <meta charset="utf-8" />
8
+ <link rel="stylesheet" href="../../prettify.css" />
9
+ <link rel="stylesheet" href="../../base.css" />
10
+ <link rel="shortcut icon" type="image/x-icon" href="../../favicon.png" />
11
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
12
+ <style type='text/css'>
13
+ .coverage-summary .sorter {
14
+ background-image: url(../../sort-arrow-sprite.png);
15
+ }
16
+ </style>
17
+ </head>
18
+
19
+ <body>
20
+ <div class='wrapper'>
21
+ <div class='pad1'>
22
+ <h1><a href="../../index.html">All files</a> / <a href="index.html">src/store</a> memory.ts</h1>
23
+ <div class='clearfix'>
24
+
25
+ <div class='fl pad1y space-right2'>
26
+ <span class="strong">80% </span>
27
+ <span class="quiet">Statements</span>
28
+ <span class='fraction'>4/5</span>
29
+ </div>
30
+
31
+
32
+ <div class='fl pad1y space-right2'>
33
+ <span class="strong">100% </span>
34
+ <span class="quiet">Branches</span>
35
+ <span class='fraction'>2/2</span>
36
+ </div>
37
+
38
+
39
+ <div class='fl pad1y space-right2'>
40
+ <span class="strong">66.66% </span>
41
+ <span class="quiet">Functions</span>
42
+ <span class='fraction'>2/3</span>
43
+ </div>
44
+
45
+
46
+ <div class='fl pad1y space-right2'>
47
+ <span class="strong">80% </span>
48
+ <span class="quiet">Lines</span>
49
+ <span class='fraction'>4/5</span>
50
+ </div>
51
+
52
+
53
+ </div>
54
+ <p class="quiet">
55
+ Press <em>n</em> or <em>j</em> to go to the next uncovered block, <em>b</em>, <em>p</em> or <em>k</em> for the previous block.
56
+ </p>
57
+ <template id="filterTemplate">
58
+ <div class="quiet">
59
+ Filter:
60
+ <input type="search" id="fileSearch">
61
+ </div>
62
+ </template>
63
+ </div>
64
+ <div class='status-line high'></div>
65
+ <pre><table class="coverage">
66
+ <tr><td class="line-count quiet"><a name='L1'></a><a href='#L1'>1</a>
67
+ <a name='L2'></a><a href='#L2'>2</a>
68
+ <a name='L3'></a><a href='#L3'>3</a>
69
+ <a name='L4'></a><a href='#L4'>4</a>
70
+ <a name='L5'></a><a href='#L5'>5</a>
71
+ <a name='L6'></a><a href='#L6'>6</a>
72
+ <a name='L7'></a><a href='#L7'>7</a>
73
+ <a name='L8'></a><a href='#L8'>8</a>
74
+ <a name='L9'></a><a href='#L9'>9</a>
75
+ <a name='L10'></a><a href='#L10'>10</a>
76
+ <a name='L11'></a><a href='#L11'>11</a>
77
+ <a name='L12'></a><a href='#L12'>12</a>
78
+ <a name='L13'></a><a href='#L13'>13</a>
79
+ <a name='L14'></a><a href='#L14'>14</a>
80
+ <a name='L15'></a><a href='#L15'>15</a>
81
+ <a name='L16'></a><a href='#L16'>16</a>
82
+ <a name='L17'></a><a href='#L17'>17</a>
83
+ <a name='L18'></a><a href='#L18'>18</a>
84
+ <a name='L19'></a><a href='#L19'>19</a>
85
+ <a name='L20'></a><a href='#L20'>20</a>
86
+ <a name='L21'></a><a href='#L21'>21</a>
87
+ <a name='L22'></a><a href='#L22'>22</a>
88
+ <a name='L23'></a><a href='#L23'>23</a>
89
+ <a name='L24'></a><a href='#L24'>24</a>
90
+ <a name='L25'></a><a href='#L25'>25</a>
91
+ <a name='L26'></a><a href='#L26'>26</a>
92
+ <a name='L27'></a><a href='#L27'>27</a>
93
+ <a name='L28'></a><a href='#L28'>28</a>
94
+ <a name='L29'></a><a href='#L29'>29</a>
95
+ <a name='L30'></a><a href='#L30'>30</a>
96
+ <a name='L31'></a><a href='#L31'>31</a>
97
+ <a name='L32'></a><a href='#L32'>32</a>
98
+ <a name='L33'></a><a href='#L33'>33</a>
99
+ <a name='L34'></a><a href='#L34'>34</a>
100
+ <a name='L35'></a><a href='#L35'>35</a>
101
+ <a name='L36'></a><a href='#L36'>36</a>
102
+ <a name='L37'></a><a href='#L37'>37</a>
103
+ <a name='L38'></a><a href='#L38'>38</a>
104
+ <a name='L39'></a><a href='#L39'>39</a>
105
+ <a name='L40'></a><a href='#L40'>40</a>
106
+ <a name='L41'></a><a href='#L41'>41</a>
107
+ <a name='L42'></a><a href='#L42'>42</a>
108
+ <a name='L43'></a><a href='#L43'>43</a>
109
+ <a name='L44'></a><a href='#L44'>44</a>
110
+ <a name='L45'></a><a href='#L45'>45</a>
111
+ <a name='L46'></a><a href='#L46'>46</a>
112
+ <a name='L47'></a><a href='#L47'>47</a>
113
+ <a name='L48'></a><a href='#L48'>48</a>
114
+ <a name='L49'></a><a href='#L49'>49</a>
115
+ <a name='L50'></a><a href='#L50'>50</a>
116
+ <a name='L51'></a><a href='#L51'>51</a></td><td class="line-coverage quiet"><span class="cline-any cline-neutral">&nbsp;</span>
117
+ <span class="cline-any cline-neutral">&nbsp;</span>
118
+ <span class="cline-any cline-neutral">&nbsp;</span>
119
+ <span class="cline-any cline-neutral">&nbsp;</span>
120
+ <span class="cline-any cline-neutral">&nbsp;</span>
121
+ <span class="cline-any cline-neutral">&nbsp;</span>
122
+ <span class="cline-any cline-neutral">&nbsp;</span>
123
+ <span class="cline-any cline-neutral">&nbsp;</span>
124
+ <span class="cline-any cline-neutral">&nbsp;</span>
125
+ <span class="cline-any cline-neutral">&nbsp;</span>
126
+ <span class="cline-any cline-neutral">&nbsp;</span>
127
+ <span class="cline-any cline-neutral">&nbsp;</span>
128
+ <span class="cline-any cline-neutral">&nbsp;</span>
129
+ <span class="cline-any cline-neutral">&nbsp;</span>
130
+ <span class="cline-any cline-neutral">&nbsp;</span>
131
+ <span class="cline-any cline-neutral">&nbsp;</span>
132
+ <span class="cline-any cline-neutral">&nbsp;</span>
133
+ <span class="cline-any cline-neutral">&nbsp;</span>
134
+ <span class="cline-any cline-neutral">&nbsp;</span>
135
+ <span class="cline-any cline-neutral">&nbsp;</span>
136
+ <span class="cline-any cline-neutral">&nbsp;</span>
137
+ <span class="cline-any cline-neutral">&nbsp;</span>
138
+ <span class="cline-any cline-neutral">&nbsp;</span>
139
+ <span class="cline-any cline-neutral">&nbsp;</span>
140
+ <span class="cline-any cline-neutral">&nbsp;</span>
141
+ <span class="cline-any cline-yes">12x</span>
142
+ <span class="cline-any cline-neutral">&nbsp;</span>
143
+ <span class="cline-any cline-neutral">&nbsp;</span>
144
+ <span class="cline-any cline-yes">4x</span>
145
+ <span class="cline-any cline-yes">4x</span>
146
+ <span class="cline-any cline-neutral">&nbsp;</span>
147
+ <span class="cline-any cline-neutral">&nbsp;</span>
148
+ <span class="cline-any cline-neutral">&nbsp;</span>
149
+ <span class="cline-any cline-neutral">&nbsp;</span>
150
+ <span class="cline-any cline-neutral">&nbsp;</span>
151
+ <span class="cline-any cline-neutral">&nbsp;</span>
152
+ <span class="cline-any cline-neutral">&nbsp;</span>
153
+ <span class="cline-any cline-neutral">&nbsp;</span>
154
+ <span class="cline-any cline-neutral">&nbsp;</span>
155
+ <span class="cline-any cline-yes">3x</span>
156
+ <span class="cline-any cline-neutral">&nbsp;</span>
157
+ <span class="cline-any cline-neutral">&nbsp;</span>
158
+ <span class="cline-any cline-neutral">&nbsp;</span>
159
+ <span class="cline-any cline-neutral">&nbsp;</span>
160
+ <span class="cline-any cline-neutral">&nbsp;</span>
161
+ <span class="cline-any cline-neutral">&nbsp;</span>
162
+ <span class="cline-any cline-neutral">&nbsp;</span>
163
+ <span class="cline-any cline-no">&nbsp;</span>
164
+ <span class="cline-any cline-neutral">&nbsp;</span>
165
+ <span class="cline-any cline-neutral">&nbsp;</span>
166
+ <span class="cline-any cline-neutral">&nbsp;</span></td><td class="text"><pre class="prettyprint lang-js">/**
167
+ * Zero-config default `SnapshotStore`. The storage MECHANICS (composite-key
168
+ * map, never returning rows across tenants) are NOT re-implemented here —
169
+ * they are composed from `@nebutra/tenant-store`'s `InMemoryTenantStore`,
170
+ * which already enforces tenant isolation structurally via its
171
+ * `tenantId:id` key plus a defense-in-depth tenantId equality check.
172
+ *
173
+ * A production deployment swaps this for a Prisma/Redis adapter that
174
+ * implements the same `SnapshotStore` interface; the README documents that
175
+ * shape. The isolation property a Prisma adapter would get from RLS is here
176
+ * provided by the borrowed composite key.
177
+ */
178
+ &nbsp;
179
+ import {
180
+ InMemoryTenantStore,
181
+ type TenantOwned,
182
+ } from "@nebutra/tenant-store";
183
+ import type { SnapshotStore } from "../types";
184
+ &nbsp;
185
+ interface SnapshotRow extends TenantOwned {
186
+ readonly tenantId: string;
187
+ readonly state: Uint8Array;
188
+ }
189
+ &nbsp;
190
+ export class InMemorySnapshotStore implements SnapshotStore {
191
+ private readonly inner = new InMemoryTenantStore&lt;SnapshotRow&gt;();
192
+ &nbsp;
193
+ async load(tenantId: string, roomId: string): Promise&lt;Uint8Array | null&gt; {
194
+ const row = await this.inner.read(tenantId, roomId);
195
+ return row ? row.state : null;
196
+ }
197
+ &nbsp;
198
+ async save(
199
+ tenantId: string,
200
+ roomId: string,
201
+ state: Uint8Array,
202
+ ): Promise&lt;void&gt; {
203
+ // Copy so a later in-place mutation of the caller's buffer can't
204
+ // retroactively corrupt persisted state.
205
+ await this.inner.write(tenantId, roomId, {
206
+ tenantId,
207
+ state: Uint8Array.from(state),
208
+ });
209
+ }
210
+ &nbsp;
211
+ /** Test helper. */
212
+ <span class="fstat-no" title="function not covered" > clear(): void {</span>
213
+ <span class="cstat-no" title="statement not covered" > this.inner.clear();</span>
214
+ }
215
+ }
216
+ &nbsp;</pre></td></tr></table></pre>
217
+
218
+ <div class='push'></div><!-- for sticky footer -->
219
+ </div><!-- /wrapper -->
220
+ <div class='footer quiet pad2 space-top1 center small'>
221
+ Code coverage generated by
222
+ <a href="https://istanbul.js.org/" target="_blank" rel="noopener noreferrer">istanbul</a>
223
+ at 2026-05-17T09:08:52.337Z
224
+ </div>
225
+ <script src="../../prettify.js"></script>
226
+ <script>
227
+ window.onload = function () {
228
+ prettyPrint();
229
+ };
230
+ </script>
231
+ <script src="../../sorter.js"></script>
232
+ <script src="../../block-navigation.js"></script>
233
+ </body>
234
+ </html>
235
+
@@ -0,0 +1,116 @@
1
+
2
+ <!doctype html>
3
+ <html lang="en">
4
+
5
+ <head>
6
+ <title>Code coverage report for src/transport</title>
7
+ <meta charset="utf-8" />
8
+ <link rel="stylesheet" href="../../prettify.css" />
9
+ <link rel="stylesheet" href="../../base.css" />
10
+ <link rel="shortcut icon" type="image/x-icon" href="../../favicon.png" />
11
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
12
+ <style type='text/css'>
13
+ .coverage-summary .sorter {
14
+ background-image: url(../../sort-arrow-sprite.png);
15
+ }
16
+ </style>
17
+ </head>
18
+
19
+ <body>
20
+ <div class='wrapper'>
21
+ <div class='pad1'>
22
+ <h1><a href="../../index.html">All files</a> src/transport</h1>
23
+ <div class='clearfix'>
24
+
25
+ <div class='fl pad1y space-right2'>
26
+ <span class="strong">90% </span>
27
+ <span class="quiet">Statements</span>
28
+ <span class='fraction'>18/20</span>
29
+ </div>
30
+
31
+
32
+ <div class='fl pad1y space-right2'>
33
+ <span class="strong">75% </span>
34
+ <span class="quiet">Branches</span>
35
+ <span class='fraction'>6/8</span>
36
+ </div>
37
+
38
+
39
+ <div class='fl pad1y space-right2'>
40
+ <span class="strong">100% </span>
41
+ <span class="quiet">Functions</span>
42
+ <span class='fraction'>4/4</span>
43
+ </div>
44
+
45
+
46
+ <div class='fl pad1y space-right2'>
47
+ <span class="strong">100% </span>
48
+ <span class="quiet">Lines</span>
49
+ <span class='fraction'>16/16</span>
50
+ </div>
51
+
52
+
53
+ </div>
54
+ <p class="quiet">
55
+ Press <em>n</em> or <em>j</em> to go to the next uncovered block, <em>b</em>, <em>p</em> or <em>k</em> for the previous block.
56
+ </p>
57
+ <template id="filterTemplate">
58
+ <div class="quiet">
59
+ Filter:
60
+ <input type="search" id="fileSearch">
61
+ </div>
62
+ </template>
63
+ </div>
64
+ <div class='status-line high'></div>
65
+ <div class="pad1">
66
+ <table class="coverage-summary">
67
+ <thead>
68
+ <tr>
69
+ <th data-col="file" data-fmt="html" data-html="true" class="file">File</th>
70
+ <th data-col="pic" data-type="number" data-fmt="html" data-html="true" class="pic"></th>
71
+ <th data-col="statements" data-type="number" data-fmt="pct" class="pct">Statements</th>
72
+ <th data-col="statements_raw" data-type="number" data-fmt="html" class="abs"></th>
73
+ <th data-col="branches" data-type="number" data-fmt="pct" class="pct">Branches</th>
74
+ <th data-col="branches_raw" data-type="number" data-fmt="html" class="abs"></th>
75
+ <th data-col="functions" data-type="number" data-fmt="pct" class="pct">Functions</th>
76
+ <th data-col="functions_raw" data-type="number" data-fmt="html" class="abs"></th>
77
+ <th data-col="lines" data-type="number" data-fmt="pct" class="pct">Lines</th>
78
+ <th data-col="lines_raw" data-type="number" data-fmt="html" class="abs"></th>
79
+ </tr>
80
+ </thead>
81
+ <tbody><tr>
82
+ <td class="file high" data-value="loopback.ts"><a href="loopback.ts.html">loopback.ts</a></td>
83
+ <td data-value="90" class="pic high">
84
+ <div class="chart"><div class="cover-fill" style="width: 90%"></div><div class="cover-empty" style="width: 10%"></div></div>
85
+ </td>
86
+ <td data-value="90" class="pct high">90%</td>
87
+ <td data-value="20" class="abs high">18/20</td>
88
+ <td data-value="75" class="pct medium">75%</td>
89
+ <td data-value="8" class="abs medium">6/8</td>
90
+ <td data-value="100" class="pct high">100%</td>
91
+ <td data-value="4" class="abs high">4/4</td>
92
+ <td data-value="100" class="pct high">100%</td>
93
+ <td data-value="16" class="abs high">16/16</td>
94
+ </tr>
95
+
96
+ </tbody>
97
+ </table>
98
+ </div>
99
+ <div class='push'></div><!-- for sticky footer -->
100
+ </div><!-- /wrapper -->
101
+ <div class='footer quiet pad2 space-top1 center small'>
102
+ Code coverage generated by
103
+ <a href="https://istanbul.js.org/" target="_blank" rel="noopener noreferrer">istanbul</a>
104
+ at 2026-05-17T09:08:52.337Z
105
+ </div>
106
+ <script src="../../prettify.js"></script>
107
+ <script>
108
+ window.onload = function () {
109
+ prettyPrint();
110
+ };
111
+ </script>
112
+ <script src="../../sorter.js"></script>
113
+ <script src="../../block-navigation.js"></script>
114
+ </body>
115
+ </html>
116
+
package/dist/index.js CHANGED
@@ -1,9 +1,9 @@
1
- import {
2
- InMemorySnapshotStore
3
- } from "./chunk-6XHNO2RP.js";
4
1
  import {
5
2
  LoopbackTransport
6
3
  } from "./chunk-S2HTNEZN.js";
4
+ import {
5
+ InMemorySnapshotStore
6
+ } from "./chunk-6XHNO2RP.js";
7
7
 
8
8
  // src/errors.ts
9
9
  import { CapabilityError } from "@nebutra/capability-kit";
@@ -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.2",
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",
@@ -19,11 +19,11 @@
19
19
  },
20
20
  "dependencies": {
21
21
  "yjs": "^13.6.30",
22
- "@nebutra/capability-kit": "0.2.1",
23
- "@nebutra/tenant-store": "0.2.1"
22
+ "@nebutra/capability-kit": "0.2.2",
23
+ "@nebutra/tenant-store": "0.2.2"
24
24
  },
25
25
  "devDependencies": {
26
- "@types/node": "^22.19.15",
26
+ "@types/node": "^25.9.1",
27
27
  "@vitest/coverage-v8": "^4.0.18",
28
28
  "tsup": "^8.5.1",
29
29
  "typescript": "^5.9.3",
package/src/hub.ts CHANGED
Binary file