@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/CHANGELOG.md +24 -0
- package/LICENSE +21 -676
- package/dist/index.js +22 -18
- package/dist/index.js.map +1 -1
- package/package.json +17 -9
- package/.turbo/turbo-build.log +0 -28
- package/.turbo/turbo-test.log +0 -14
- package/.turbo/turbo-typecheck.log +0 -4
- package/examples/snapshot-restore.ts +0 -39
- package/examples/tenant-isolation.ts +0 -42
- package/examples/zero-config-convergence.ts +0 -43
- package/src/__tests__/collab.test.ts +0 -240
- package/src/cli.ts +0 -27
- package/src/errors.ts +0 -42
- package/src/hub.ts +0 -0
- package/src/index.ts +0 -42
- package/src/room.ts +0 -130
- package/src/store/memory.ts +0 -43
- package/src/transport/loopback.ts +0 -0
- package/src/types.ts +0 -64
- package/tsconfig.json +0 -12
- package/tsup.config.ts +0 -11
package/src/errors.ts
DELETED
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Every failure surfaced by this package is a `CollabError`. The contract is
|
|
3
|
-
* deliberately strict: a machine-stable `code` and a human-actionable
|
|
4
|
-
* `suggestion` are MANDATORY, so no code path can throw a bare `Error` that
|
|
5
|
-
* leaves a caller without a remediation hint.
|
|
6
|
-
*
|
|
7
|
-
* Mechanics (code/suggestion/toJSON/empty-suggestion fallback) are inherited
|
|
8
|
-
* from the shared `@nebutra/capability-kit` `CapabilityError`; this subclass
|
|
9
|
-
* only pins collab's error name + its package-specific fallback wording, so
|
|
10
|
-
* the observable contract is unchanged.
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
import { CapabilityError } from "@nebutra/capability-kit";
|
|
14
|
-
|
|
15
|
-
export type CollabErrorCode =
|
|
16
|
-
| "COLLAB_INVALID_TENANT"
|
|
17
|
-
| "COLLAB_INVALID_ROOM"
|
|
18
|
-
| "COLLAB_SNAPSHOT_FAILED"
|
|
19
|
-
| "COLLAB_RESTORE_FAILED"
|
|
20
|
-
| "COLLAB_DESTROYED"
|
|
21
|
-
| "COLLAB_TEST"
|
|
22
|
-
| (string & {});
|
|
23
|
-
|
|
24
|
-
export interface CollabErrorInit {
|
|
25
|
-
readonly code: CollabErrorCode;
|
|
26
|
-
/** A non-empty, actionable remediation hint. */
|
|
27
|
-
readonly suggestion: string;
|
|
28
|
-
readonly cause?: unknown;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
export class CollabError extends CapabilityError {
|
|
32
|
-
declare readonly code: CollabErrorCode;
|
|
33
|
-
|
|
34
|
-
constructor(message: string, init: CollabErrorInit) {
|
|
35
|
-
super(message, init, {
|
|
36
|
-
name: "CollabError",
|
|
37
|
-
emptySuggestionFallback:
|
|
38
|
-
"No suggestion was provided. This is a bug in @nebutra/collab — " +
|
|
39
|
-
"report it with the failing operation.",
|
|
40
|
-
});
|
|
41
|
-
}
|
|
42
|
-
}
|
package/src/hub.ts
DELETED
|
Binary file
|
package/src/index.ts
DELETED
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @nebutra/collab — multi-tenant, transport-agnostic real-time collaborative
|
|
3
|
-
* sync layer.
|
|
4
|
-
*
|
|
5
|
-
* Sailor already had Pusher pub/sub (fire-and-forget broadcast) but no
|
|
6
|
-
* conflict-free concurrent editing. This package fills that gap with
|
|
7
|
-
* tenant-partitioned CRDT rooms built on Yjs (MIT). It is generic: a
|
|
8
|
-
* node-graph canvas, a rich-text document, or any shared structure binds to
|
|
9
|
-
* a `CollabRoom` and gets convergence for free.
|
|
10
|
-
*
|
|
11
|
-
* Multi-tenancy is NON-NEGOTIABLE. Rooms are hard-partitioned by `tenantId`:
|
|
12
|
-
* a room handle is only ever produced by passing an explicit tenant, the
|
|
13
|
-
* snapshot store and transport are addressed by (tenantId, roomId), and the
|
|
14
|
-
* composite key uses a NUL separator so partitions cannot collide. See
|
|
15
|
-
* `hub.ts` for the structural argument and `__tests__` for the proof.
|
|
16
|
-
*
|
|
17
|
-
* Zero-config: `getCollab()` with no args yields REAL (non-mock) CRDT
|
|
18
|
-
* behaviour via an in-memory snapshot store (composed from
|
|
19
|
-
* `@nebutra/tenant-store`) and an in-process loopback transport. Production
|
|
20
|
-
* swaps a Prisma/Redis `SnapshotStore` and a Pusher/WebSocket
|
|
21
|
-
* `CollabTransport` through the same interfaces (see README).
|
|
22
|
-
*
|
|
23
|
-
* This package keeps itself in the `active` tier of the three-tier module
|
|
24
|
-
* lifecycle by shipping a real in-package caller of the exported factory —
|
|
25
|
-
* see `examples/zero-config-convergence.ts`.
|
|
26
|
-
*/
|
|
27
|
-
|
|
28
|
-
export type { CollabErrorCode, CollabErrorInit } from "./errors";
|
|
29
|
-
export { CollabError } from "./errors";
|
|
30
|
-
export { createCollab, getCollab } from "./hub";
|
|
31
|
-
export { InMemorySnapshotStore } from "./store/memory";
|
|
32
|
-
export { LoopbackTransport } from "./transport/loopback";
|
|
33
|
-
export type {
|
|
34
|
-
CollabConfig,
|
|
35
|
-
CollabHub,
|
|
36
|
-
CollabRoom,
|
|
37
|
-
CollabTransport,
|
|
38
|
-
DoctorCheck,
|
|
39
|
-
DoctorReport,
|
|
40
|
-
SnapshotStore,
|
|
41
|
-
UpdateListener,
|
|
42
|
-
} from "./types";
|
package/src/room.ts
DELETED
|
@@ -1,130 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* A single tenant-scoped CRDT room. One `Y.Doc` per (tenant, room). The hub
|
|
3
|
-
* owns the partitioning; this class assumes its `tenantId`/`roomId` are
|
|
4
|
-
* already the partition it belongs to and never reaches outside them.
|
|
5
|
-
*
|
|
6
|
-
* Snapshot persistence is serialized through `withTenantLock(tenantId,
|
|
7
|
-
* roomId, ...)` borrowed from `@nebutra/tenant-store` rather than a
|
|
8
|
-
* hand-rolled mutex — same primitive used by canvas/reel, so a future swap
|
|
9
|
-
* to a distributed lock changes one place.
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
import { withTenantLock } from "@nebutra/tenant-store";
|
|
13
|
-
import * as Y from "yjs";
|
|
14
|
-
import { CollabError } from "./errors";
|
|
15
|
-
import type { CollabRoom, CollabTransport, SnapshotStore, UpdateListener } from "./types";
|
|
16
|
-
|
|
17
|
-
/** Origin tag used when applying remote updates so we don't echo them back. */
|
|
18
|
-
const REMOTE_ORIGIN = Symbol("collab.remote");
|
|
19
|
-
|
|
20
|
-
export class Room implements CollabRoom {
|
|
21
|
-
readonly doc: Y.Doc;
|
|
22
|
-
private readonly listeners = new Set<UpdateListener>();
|
|
23
|
-
private readonly unsubTransport: () => void;
|
|
24
|
-
private destroyed = false;
|
|
25
|
-
|
|
26
|
-
constructor(
|
|
27
|
-
readonly tenantId: string,
|
|
28
|
-
readonly roomId: string,
|
|
29
|
-
private readonly store: SnapshotStore,
|
|
30
|
-
private readonly transport: CollabTransport,
|
|
31
|
-
) {
|
|
32
|
-
this.doc = new Y.Doc();
|
|
33
|
-
|
|
34
|
-
// Fan local updates out to: registered listeners + the transport. The
|
|
35
|
-
// transport echo is guarded by origin so a remote-applied update is not
|
|
36
|
-
// re-broadcast into a loop.
|
|
37
|
-
this.doc.on("update", (update: Uint8Array, origin: unknown) => {
|
|
38
|
-
for (const cb of [...this.listeners]) cb(update, origin);
|
|
39
|
-
if (origin !== REMOTE_ORIGIN) {
|
|
40
|
-
void Promise.resolve(this.transport.broadcast(this.tenantId, this.roomId, update)).catch(
|
|
41
|
-
() => {
|
|
42
|
-
// Transport delivery is best-effort; CRDT state stays correct and
|
|
43
|
-
// converges on the next exchanged update. Swallowing here avoids
|
|
44
|
-
// an unhandled rejection from a flaky network adapter.
|
|
45
|
-
},
|
|
46
|
-
);
|
|
47
|
-
}
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
// Remote updates for THIS tenant-scoped channel only.
|
|
51
|
-
this.unsubTransport = this.transport.subscribe(this.tenantId, this.roomId, (update) => {
|
|
52
|
-
if (this.destroyed) return;
|
|
53
|
-
Y.applyUpdate(this.doc, update, REMOTE_ORIGIN);
|
|
54
|
-
});
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
applyUpdate(update: Uint8Array, origin?: unknown): void {
|
|
58
|
-
this.assertLive();
|
|
59
|
-
Y.applyUpdate(this.doc, update, origin);
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
encodeState(): Uint8Array {
|
|
63
|
-
this.assertLive();
|
|
64
|
-
return Y.encodeStateAsUpdate(this.doc);
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
onUpdate(cb: UpdateListener): () => void {
|
|
68
|
-
this.assertLive();
|
|
69
|
-
this.listeners.add(cb);
|
|
70
|
-
return () => {
|
|
71
|
-
this.listeners.delete(cb);
|
|
72
|
-
};
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
async snapshot(): Promise<void> {
|
|
76
|
-
this.assertLive();
|
|
77
|
-
const state = this.encodeState();
|
|
78
|
-
try {
|
|
79
|
-
// Serialize concurrent snapshots of the SAME room; different rooms (or
|
|
80
|
-
// the same room under another tenant) persist in parallel.
|
|
81
|
-
await withTenantLock(this.tenantId, this.roomId, () =>
|
|
82
|
-
this.store.save(this.tenantId, this.roomId, state),
|
|
83
|
-
);
|
|
84
|
-
} catch (cause) {
|
|
85
|
-
throw new CollabError(`Failed to persist snapshot for room "${this.roomId}".`, {
|
|
86
|
-
code: "COLLAB_SNAPSHOT_FAILED",
|
|
87
|
-
suggestion:
|
|
88
|
-
"Verify the configured SnapshotStore is reachable (DB/Redis up, " +
|
|
89
|
-
"credentials valid). The in-memory default never fails; a custom " +
|
|
90
|
-
"adapter likely threw.",
|
|
91
|
-
cause,
|
|
92
|
-
});
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/** Hydrate this doc from persisted state, if any. Internal to the hub. */
|
|
97
|
-
async _restore(): Promise<void> {
|
|
98
|
-
try {
|
|
99
|
-
const persisted = await this.store.load(this.tenantId, this.roomId);
|
|
100
|
-
if (persisted) Y.applyUpdate(this.doc, persisted, REMOTE_ORIGIN);
|
|
101
|
-
} catch (cause) {
|
|
102
|
-
throw new CollabError(`Failed to restore room "${this.roomId}" from snapshot store.`, {
|
|
103
|
-
code: "COLLAB_RESTORE_FAILED",
|
|
104
|
-
suggestion:
|
|
105
|
-
"Check the SnapshotStore adapter's load() — it should resolve " +
|
|
106
|
-
"null (not throw) when no snapshot exists for the tenant+room.",
|
|
107
|
-
cause,
|
|
108
|
-
});
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
destroy(): void {
|
|
113
|
-
if (this.destroyed) return;
|
|
114
|
-
this.destroyed = true;
|
|
115
|
-
this.unsubTransport();
|
|
116
|
-
this.listeners.clear();
|
|
117
|
-
this.doc.destroy();
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
private assertLive(): void {
|
|
121
|
-
if (this.destroyed) {
|
|
122
|
-
throw new CollabError(`Room "${this.roomId}" was destroyed and can no longer be used.`, {
|
|
123
|
-
code: "COLLAB_DESTROYED",
|
|
124
|
-
suggestion:
|
|
125
|
-
"Acquire a fresh room via hub.room(tenantId, roomId) instead of " +
|
|
126
|
-
"reusing a destroyed instance.",
|
|
127
|
-
});
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
}
|
package/src/store/memory.ts
DELETED
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Zero-config default `SnapshotStore`. The storage MECHANICS (composite-key
|
|
3
|
-
* map, never returning rows across tenants) are NOT re-implemented here —
|
|
4
|
-
* they are composed from `@nebutra/tenant-store`'s `InMemoryTenantStore`,
|
|
5
|
-
* which already enforces tenant isolation structurally via its
|
|
6
|
-
* `tenantId:id` key plus a defense-in-depth tenantId equality check.
|
|
7
|
-
*
|
|
8
|
-
* A production deployment swaps this for a Prisma/Redis adapter that
|
|
9
|
-
* implements the same `SnapshotStore` interface; the README documents that
|
|
10
|
-
* shape. The isolation property a Prisma adapter would get from RLS is here
|
|
11
|
-
* provided by the borrowed composite key.
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
import { InMemoryTenantStore, type TenantOwned } from "@nebutra/tenant-store";
|
|
15
|
-
import type { SnapshotStore } from "../types";
|
|
16
|
-
|
|
17
|
-
interface SnapshotRow extends TenantOwned {
|
|
18
|
-
readonly tenantId: string;
|
|
19
|
-
readonly state: Uint8Array;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export class InMemorySnapshotStore implements SnapshotStore {
|
|
23
|
-
private readonly inner = new InMemoryTenantStore<SnapshotRow>();
|
|
24
|
-
|
|
25
|
-
async load(tenantId: string, roomId: string): Promise<Uint8Array | null> {
|
|
26
|
-
const row = await this.inner.read(tenantId, roomId);
|
|
27
|
-
return row ? row.state : null;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
async save(tenantId: string, roomId: string, state: Uint8Array): Promise<void> {
|
|
31
|
-
// Copy so a later in-place mutation of the caller's buffer can't
|
|
32
|
-
// retroactively corrupt persisted state.
|
|
33
|
-
await this.inner.write(tenantId, roomId, {
|
|
34
|
-
tenantId,
|
|
35
|
-
state: Uint8Array.from(state),
|
|
36
|
-
});
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/** Test helper. */
|
|
40
|
-
clear(): void {
|
|
41
|
-
this.inner.clear();
|
|
42
|
-
}
|
|
43
|
-
}
|
|
Binary file
|
package/src/types.ts
DELETED
|
@@ -1,64 +0,0 @@
|
|
|
1
|
-
import type { DoctorCheck, DoctorReportBase } from "@nebutra/capability-kit";
|
|
2
|
-
import type * 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
|
-
export interface SnapshotStore {
|
|
11
|
-
load(tenantId: string, roomId: string): Promise<Uint8Array | null>;
|
|
12
|
-
save(tenantId: string, roomId: string, state: Uint8Array): Promise<void>;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* Transport-agnostic fan-out seam. The default is an in-memory loopback;
|
|
17
|
-
* a Pusher / WebSocket adapter implements the same two methods and plugs in
|
|
18
|
-
* via `createCollab({ transport })`. Updates are opaque Yjs update bytes.
|
|
19
|
-
*/
|
|
20
|
-
export interface CollabTransport {
|
|
21
|
-
broadcast(tenantId: string, roomId: string, update: Uint8Array): void | Promise<void>;
|
|
22
|
-
/** Subscribe to remote updates for one tenant-scoped room; returns unsub. */
|
|
23
|
-
subscribe(tenantId: string, roomId: string, cb: (update: Uint8Array) => void): () => void;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
export type UpdateListener = (update: Uint8Array, origin: unknown) => void;
|
|
27
|
-
|
|
28
|
-
export interface CollabConfig {
|
|
29
|
-
store?: SnapshotStore;
|
|
30
|
-
transport?: CollabTransport;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export interface CollabRoom {
|
|
34
|
-
readonly tenantId: string;
|
|
35
|
-
readonly roomId: string;
|
|
36
|
-
readonly doc: Y.Doc;
|
|
37
|
-
applyUpdate(update: Uint8Array, origin?: unknown): void;
|
|
38
|
-
encodeState(): Uint8Array;
|
|
39
|
-
onUpdate(cb: UpdateListener): () => void;
|
|
40
|
-
snapshot(): Promise<void>;
|
|
41
|
-
destroy(): void;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
// DoctorCheck + the {ok,durationMs} base come from the shared
|
|
45
|
-
// @nebutra/capability-kit contract; collab only adds its specific probe map.
|
|
46
|
-
export type { DoctorCheck };
|
|
47
|
-
|
|
48
|
-
export interface DoctorReport extends DoctorReportBase {
|
|
49
|
-
readonly checks: {
|
|
50
|
-
readonly yjs: DoctorCheck;
|
|
51
|
-
readonly store: DoctorCheck;
|
|
52
|
-
readonly transport: DoctorCheck;
|
|
53
|
-
};
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
export interface CollabHub {
|
|
57
|
-
/** Returns/creates a tenant-scoped CRDT room. Hard-partitioned by tenant. */
|
|
58
|
-
room(tenantId: string, roomId: string): CollabRoom;
|
|
59
|
-
/** Like `room`, but first hydrates from the snapshot store if present. */
|
|
60
|
-
roomRestored(tenantId: string, roomId: string): Promise<CollabRoom>;
|
|
61
|
-
doctor(): Promise<DoctorReport>;
|
|
62
|
-
/** Destroy every live room and release resources. */
|
|
63
|
-
destroy(): void;
|
|
64
|
-
}
|
package/tsconfig.json
DELETED
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"extends": "../../../tsconfig.base.json",
|
|
3
|
-
"compilerOptions": {
|
|
4
|
-
"module": "ESNext",
|
|
5
|
-
"moduleResolution": "bundler",
|
|
6
|
-
"target": "esnext",
|
|
7
|
-
"types": ["node"],
|
|
8
|
-
"incremental": false
|
|
9
|
-
},
|
|
10
|
-
"include": ["src", "examples"],
|
|
11
|
-
"exclude": ["node_modules", "dist"]
|
|
12
|
-
}
|
package/tsup.config.ts
DELETED
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
import { defineConfig } from "tsup";
|
|
2
|
-
|
|
3
|
-
export default defineConfig({
|
|
4
|
-
entry: ["src/index.ts", "src/store/memory.ts", "src/transport/loopback.ts"],
|
|
5
|
-
format: ["esm"],
|
|
6
|
-
dts: true,
|
|
7
|
-
sourcemap: true,
|
|
8
|
-
clean: true,
|
|
9
|
-
target: "es2022",
|
|
10
|
-
external: ["@nebutra/tenant-store"],
|
|
11
|
-
});
|