@dabble/patches 0.2.21 → 0.2.23

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.
@@ -8,6 +8,7 @@ export * from './webrtc/WebRTCTransport.js';
8
8
  export * from './websocket/AuthorizationProvider.js';
9
9
  export * from './websocket/onlineState.js';
10
10
  export * from './websocket/PatchesWebSocket.js';
11
+ export * from './websocket/RPCServer.js';
11
12
  export * from './websocket/SignalingService.js';
12
13
  export * from './websocket/WebSocketServer.js';
13
14
  export * from './websocket/WebSocketTransport.js';
package/dist/net/index.js CHANGED
@@ -6,6 +6,7 @@ export * from './webrtc/WebRTCTransport.js';
6
6
  export * from './websocket/AuthorizationProvider.js';
7
7
  export * from './websocket/onlineState.js';
8
8
  export * from './websocket/PatchesWebSocket.js';
9
+ export * from './websocket/RPCServer.js';
9
10
  export * from './websocket/SignalingService.js';
10
11
  export * from './websocket/WebSocketServer.js';
11
12
  export * from './websocket/WebSocketTransport.js';
@@ -1,7 +1,8 @@
1
- import { type Unsubscriber } from '../../event-signal.js';
2
- import type { Notification, Request, ServerTransport } from './types.js';
3
- export type ConnectionSignalSubscriber = (connectionId: string, ...args: any[]) => any;
4
- export type MessageHandler<P = any, R = any> = (connectionId: string, params: P) => Promise<R> | R;
1
+ import { type Signal, type Unsubscriber } from '../../event-signal.js';
2
+ import type { AuthContext } from '../websocket/AuthorizationProvider.js';
3
+ import type { Notification } from './types.js';
4
+ export type ConnectionSignalSubscriber = (params: any, clientId?: string) => any;
5
+ export type MessageHandler<P = any, R = any> = (params: P, ctx?: AuthContext) => Promise<R> | R;
5
6
  /**
6
7
  * Lightweight JSON-RPC 2.0 server adapter for {@link PatchesServer}.
7
8
  *
@@ -19,12 +20,12 @@ export type MessageHandler<P = any, R = any> = (connectionId: string, params: P)
19
20
  * to the host application.
20
21
  */
21
22
  export declare class JSONRPCServer {
22
- protected transport: ServerTransport;
23
23
  /** Map of fully-qualified JSON-RPC method → handler function */
24
24
  private readonly handlers;
25
25
  /** Allow external callers to emit server-initiated notifications. */
26
26
  private readonly notificationSignals;
27
- constructor(transport: ServerTransport);
27
+ /** Allow external callers to emit server-initiated notifications. */
28
+ readonly onNotify: Signal<(msg: Notification, exceptConnectionId?: string) => void>;
28
29
  /**
29
30
  * Registers a JSON-RPC method.
30
31
  *
@@ -45,25 +46,20 @@ export declare class JSONRPCServer {
45
46
  * Sends a JSON-RPC notification (no `id`, therefore no response expected) to
46
47
  * the connected client.
47
48
  */
48
- notify(connectionIds: string[], method: string, params?: any): void;
49
- /**
50
- * Handles incoming messages from the client.
51
- * @param connectionId - The WebSocket transport object.
52
- * @param raw - The raw message string.
53
- */
54
- protected _onMessage(connectionId: string, raw: string): Promise<void>;
55
- /**
56
- * Handles incoming JSON-RPC requests from the client.
57
- * @param connectionId - The WebSocket transport object.
58
- * @param req - The JSON-RPC request object.
59
- */
60
- protected _handleRequest(connectionId: string, req: Request): Promise<void>;
49
+ notify(method: string, params?: any, exceptConnectionId?: string): Promise<void>;
61
50
  /**
62
- * Handles incoming JSON-RPC notifications from the client.
63
- * @param connectionId - The WebSocket transport object.
64
- * @param note - The JSON-RPC notification object.
51
+ * Synchronously processes a raw JSON-RPC frame from a client and returns the
52
+ * encoded response frame or `undefined` when the message is a notification
53
+ * (no response expected).
54
+ *
55
+ * This helper makes the RPC engine usable for stateless transports such as
56
+ * HTTP: the host simply passes the request body and sends back the returned
57
+ * string (if any).
58
+ *
59
+ * WebSocket and other bidirectional transports delegate to the same logic
60
+ * internally; the returned string is forwarded over the socket.
65
61
  */
66
- protected _handleNotification(connectionId: string, note: Notification): Promise<void>;
62
+ processMessage(raw: string, ctx?: AuthContext): Promise<string | undefined>;
67
63
  /**
68
64
  * Maps JSON-RPC method names to {@link PatchesServer} calls.
69
65
  * @param connectionId - The WebSocket transport object.
@@ -71,9 +67,5 @@ export declare class JSONRPCServer {
71
67
  * @param params - The JSON-RPC parameters.
72
68
  * @returns The result of the {@link PatchesServer} call.
73
69
  */
74
- protected _dispatch(connectionId: string, method: string, params: any): Promise<any>;
75
- /**
76
- * Sends a JSON-RPC error object back to the client.
77
- */
78
- private _sendError;
70
+ protected _dispatch(method: string, params: any, ctx?: AuthContext): Promise<any>;
79
71
  }
@@ -1,5 +1,4 @@
1
1
  import { signal } from '../../event-signal.js';
2
- import { PatchesServer } from '../../server/PatchesServer.js';
3
2
  /**
4
3
  * Lightweight JSON-RPC 2.0 server adapter for {@link PatchesServer}.
5
4
  *
@@ -17,13 +16,13 @@ import { PatchesServer } from '../../server/PatchesServer.js';
17
16
  * to the host application.
18
17
  */
19
18
  export class JSONRPCServer {
20
- constructor(transport) {
21
- this.transport = transport;
19
+ constructor() {
22
20
  /** Map of fully-qualified JSON-RPC method → handler function */
23
21
  this.handlers = new Map();
24
22
  /** Allow external callers to emit server-initiated notifications. */
25
23
  this.notificationSignals = new Map();
26
- transport.onMessage(this._onMessage.bind(this));
24
+ /** Allow external callers to emit server-initiated notifications. */
25
+ this.onNotify = signal();
27
26
  }
28
27
  // -------------------------------------------------------------------------
29
28
  // Registration API
@@ -63,63 +62,56 @@ export class JSONRPCServer {
63
62
  * Sends a JSON-RPC notification (no `id`, therefore no response expected) to
64
63
  * the connected client.
65
64
  */
66
- notify(connectionIds, method, params) {
65
+ async notify(method, params, exceptConnectionId) {
67
66
  const msg = { jsonrpc: '2.0', method, params };
68
- const msgStr = JSON.stringify(msg);
69
- connectionIds.forEach(id => this.transport.send(id, msgStr));
67
+ this.onNotify.emit(msg, exceptConnectionId);
70
68
  }
71
69
  /**
72
- * Handles incoming messages from the client.
73
- * @param connectionId - The WebSocket transport object.
74
- * @param raw - The raw message string.
70
+ * Synchronously processes a raw JSON-RPC frame from a client and returns the
71
+ * encoded response frame or `undefined` when the message is a notification
72
+ * (no response expected).
73
+ *
74
+ * This helper makes the RPC engine usable for stateless transports such as
75
+ * HTTP: the host simply passes the request body and sends back the returned
76
+ * string (if any).
77
+ *
78
+ * WebSocket and other bidirectional transports delegate to the same logic
79
+ * internally; the returned string is forwarded over the socket.
75
80
  */
76
- async _onMessage(connectionId, raw) {
81
+ async processMessage(raw, ctx) {
77
82
  let message;
83
+ // --- Parse & basic validation ------------------------------------------------
78
84
  try {
79
85
  message = JSON.parse(raw);
80
86
  }
81
87
  catch (err) {
82
- this._sendError(connectionId, null, -32700, 'Parse error', err);
83
- return;
88
+ return rpcError(-32700, 'Parse error', err);
89
+ }
90
+ // Ensure it looks like a JSON-RPC call (must have a method field)
91
+ if (!message || typeof message !== 'object' || !('method' in message)) {
92
+ const invalidId = message?.id ?? null;
93
+ return rpcError(-32600, 'Invalid Request', invalidId);
84
94
  }
85
- if (message && typeof message === 'object' && 'method' in message) {
86
- // Notification or request either way--delegate.
87
- if ('id' in message && message.id !== undefined) {
88
- await this._handleRequest(connectionId, message);
95
+ // --- Distinguish request vs. notification -----------------------------------
96
+ if ('id' in message && message.id !== undefined) {
97
+ // -> Request ----------------------------------------------------------------
98
+ try {
99
+ const result = await this._dispatch(message.method, message.params, ctx);
100
+ const response = { jsonrpc: '2.0', id: message.id, result };
101
+ return JSON.stringify(response);
89
102
  }
90
- else {
91
- await this._handleNotification(connectionId, message);
103
+ catch (err) {
104
+ return rpcError(err?.code ?? -32000, err?.message ?? 'Server error', err?.stack);
92
105
  }
93
106
  }
94
107
  else {
95
- // Client sent something we do not understand.
96
- this._sendError(connectionId, message?.id ?? null, -32600, 'Invalid Request');
97
- }
98
- }
99
- /**
100
- * Handles incoming JSON-RPC requests from the client.
101
- * @param connectionId - The WebSocket transport object.
102
- * @param req - The JSON-RPC request object.
103
- */
104
- async _handleRequest(connectionId, req) {
105
- try {
106
- const result = await this._dispatch(connectionId, req.method, req.params);
107
- const response = { jsonrpc: '2.0', id: req.id, result };
108
- this.transport.send(connectionId, JSON.stringify(response));
109
- }
110
- catch (err) {
111
- this._sendError(connectionId, req.id, -32000, err?.message ?? 'Server error', err?.stack);
112
- }
113
- }
114
- /**
115
- * Handles incoming JSON-RPC notifications from the client.
116
- * @param connectionId - The WebSocket transport object.
117
- * @param note - The JSON-RPC notification object.
118
- */
119
- async _handleNotification(connectionId, note) {
120
- const thisSignal = this.notificationSignals.get(note.method);
121
- if (thisSignal) {
122
- thisSignal.emit(connectionId, note.params);
108
+ // -> Notification -----------------------------------------------------------
109
+ // Forward the notification to any listeners and return nothing.
110
+ const thisSignal = this.notificationSignals.get(message.method);
111
+ if (thisSignal) {
112
+ thisSignal.emit(message.params, ctx?.clientId);
113
+ }
114
+ return undefined;
123
115
  }
124
116
  }
125
117
  /**
@@ -129,7 +121,7 @@ export class JSONRPCServer {
129
121
  * @param params - The JSON-RPC parameters.
130
122
  * @returns The result of the {@link PatchesServer} call.
131
123
  */
132
- async _dispatch(connectionId, method, params) {
124
+ async _dispatch(method, params, ctx) {
133
125
  const handler = this.handlers.get(method);
134
126
  if (!handler) {
135
127
  throw new Error(`Unknown method '${method}'.`);
@@ -137,17 +129,9 @@ export class JSONRPCServer {
137
129
  if (!params || typeof params !== 'object' || Array.isArray(params)) {
138
130
  throw new Error(`Invalid parameters for method '${method}'.`);
139
131
  }
140
- return handler(connectionId, params);
141
- }
142
- /**
143
- * Sends a JSON-RPC error object back to the client.
144
- */
145
- _sendError(connectionId, id, code, message, data) {
146
- const errorObj = {
147
- jsonrpc: '2.0',
148
- id: id,
149
- error: { code, message, data },
150
- }; // type cast because TS cannot narrow when error present
151
- this.transport.send(connectionId, JSON.stringify(errorObj));
132
+ return handler(params, ctx);
152
133
  }
153
134
  }
135
+ function rpcError(code, message, data) {
136
+ return JSON.stringify({ jsonrpc: '2.0', id: null, error: { code, message, data } });
137
+ }
@@ -39,6 +39,12 @@ export interface ServerTransport {
39
39
  send(toConnectionId: string, raw: string): void | Promise<void>;
40
40
  /** Subscribe to incoming raw frames from any client */
41
41
  onMessage(cb: (fromConnectionId: string, raw: string) => void): Unsubscriber;
42
+ /** Lists all subscriptions for a document. */
43
+ listSubscriptions(docId: string): Promise<string[]>;
44
+ /** Adds a subscription for a client to one or more documents. */
45
+ addSubscription(clientId: string, docIds: string[]): Promise<string[]>;
46
+ /** Removes a subscription for a client from one or more documents. */
47
+ removeSubscription(clientId: string, docIds: string[]): Promise<string[]>;
42
48
  }
43
49
  /**
44
50
  * Represents a JSON-RPC 2.0 request object.
@@ -5,13 +5,22 @@
5
5
  * "write" – mutating operations (commitChanges, deleteDoc, …)
6
6
  */
7
7
  export type Access = 'read' | 'write';
8
+ /**
9
+ * Context object for authorization providers.
10
+ * @property {string} clientId - The ID of the client making the request.
11
+ * @property {any} [data] - Additional data associated with the request.
12
+ */
13
+ export interface AuthContext {
14
+ clientId?: string;
15
+ [k: string]: any;
16
+ }
8
17
  /**
9
18
  * Allows the host application to decide whether a given connection can perform
10
19
  * a certain action on a document. Implementations are entirely application-
11
20
  * specific – they may look at a JWT decoded during the WebSocket handshake,
12
21
  * consult an ACL service, inspect the actual RPC method, etc.
13
22
  */
14
- export interface AuthorizationProvider {
23
+ export interface AuthorizationProvider<T extends AuthContext = AuthContext> {
15
24
  /**
16
25
  * General-purpose hook executed for every JSON-RPC call that targets a
17
26
  * document. Implementations are free to look only at the first three
@@ -22,13 +31,13 @@ export interface AuthorizationProvider {
22
31
  * Returning `true` (or a resolved promise with `true`) permits the action.
23
32
  * Returning `false` or throwing will cause the RPC to fail with an error.
24
33
  *
25
- * @param connectionId WebSocket connection ID of the caller
34
+ * @param ctx Context object containing client ID and additional data
26
35
  * @param docId Logical document to be accessed (branch IDs count)
27
36
  * @param kind High-level access category – `'read' | 'write'`
28
37
  * @param method JSON-RPC method name (e.g. `getDoc`, `branch.merge`)
29
38
  * @param params The exact parameter object supplied by the client
30
39
  */
31
- canAccess(connectionId: string, docId: string, kind: Access, method: string, params?: Record<string, any>): boolean | Promise<boolean>;
40
+ canAccess(ctx: T | undefined, docId: string, kind: Access, method: string, params?: Record<string, any>): boolean | Promise<boolean>;
32
41
  }
33
42
  /**
34
43
  * A permissive provider that authorises every action. Used as the default so
@@ -0,0 +1,118 @@
1
+ import type { PatchesBranchManager } from '../../server/PatchesBranchManager.js';
2
+ import type { PatchesHistoryManager } from '../../server/PatchesHistoryManager.js';
3
+ import type { PatchesServer } from '../../server/PatchesServer.js';
4
+ import type { Change, EditableVersionMetadata, ListChangesOptions, ListVersionsOptions } from '../../types.js';
5
+ import { JSONRPCServer } from '../protocol/JSONRPCServer.js';
6
+ import type { AuthContext, AuthorizationProvider } from './AuthorizationProvider.js';
7
+ /**
8
+ * High-level client for the Patches real-time collaboration service.
9
+ * This class provides document subscription, patch notification handling,
10
+ * versioning, and other OT-specific functionality over a JSON RPC interface.
11
+ */
12
+ export interface RPCServerOptions {
13
+ patches: PatchesServer;
14
+ history?: PatchesHistoryManager;
15
+ branches?: PatchesBranchManager;
16
+ auth?: AuthorizationProvider;
17
+ }
18
+ export declare class RPCServer {
19
+ rpc: JSONRPCServer;
20
+ auth: AuthorizationProvider;
21
+ protected patches: PatchesServer;
22
+ protected history?: PatchesHistoryManager;
23
+ protected branches?: PatchesBranchManager;
24
+ /**
25
+ * Creates a new Patches WebSocket client instance.
26
+ * @param patches - The patches server instance to handle document operations
27
+ * @param history - (Optional) History manager instance to handle versioning operations
28
+ * @param branches - (Optional) Branch manager instance to handle branching operations
29
+ * @param auth - (Optional) Authorization provider implementation. Defaults to a permissive provider.
30
+ */
31
+ constructor({ patches, history, branches, auth }: RPCServerOptions);
32
+ /**
33
+ * Gets the latest state (content and revision) of a document.
34
+ * @param connectionId - The ID of the connection making the request
35
+ * @param params - The document parameters
36
+ * @param params.docId - The ID of the document
37
+ * @param params.atRev - Optional revision number to get document state at
38
+ */
39
+ getDoc(params: {
40
+ docId: string;
41
+ atRev?: number;
42
+ }, ctx?: AuthContext): Promise<import("../../types.js").PatchesSnapshot<any>>;
43
+ /**
44
+ * Gets changes that occurred for a document after a specific revision number.
45
+ * @param connectionId - The ID of the connection making the request
46
+ * @param params - The change request parameters
47
+ * @param params.docId - The ID of the document
48
+ * @param params.rev - The revision number after which to fetch changes
49
+ */
50
+ getChangesSince(params: {
51
+ docId: string;
52
+ rev: number;
53
+ }, ctx?: AuthContext): Promise<Change[]>;
54
+ /**
55
+ * Applies a set of client-generated changes to a document on the server.
56
+ * @param connectionId - The ID of the connection making the request
57
+ * @param params - The change parameters
58
+ * @param params.docId - The ID of the document
59
+ * @param params.changes - An array of changes to apply
60
+ */
61
+ commitChanges(params: {
62
+ docId: string;
63
+ changes: Change[];
64
+ }, ctx?: AuthContext): Promise<Change[]>;
65
+ /**
66
+ * Deletes a document on the server.
67
+ * @param connectionId - The ID of the connection making the request
68
+ * @param params - The deletion parameters
69
+ * @param params.docId - The ID of the document to delete
70
+ */
71
+ deleteDoc(params: {
72
+ docId: string;
73
+ }, ctx?: AuthContext): Promise<void>;
74
+ listVersions(params: {
75
+ docId: string;
76
+ options?: ListVersionsOptions;
77
+ }, ctx?: AuthContext): Promise<import("../../types.js").VersionMetadata[]>;
78
+ createVersion(params: {
79
+ docId: string;
80
+ metadata: EditableVersionMetadata;
81
+ }, ctx?: AuthContext): Promise<string>;
82
+ updateVersion(params: {
83
+ docId: string;
84
+ versionId: string;
85
+ metadata: EditableVersionMetadata;
86
+ }, ctx?: AuthContext): Promise<void>;
87
+ getStateAtVersion(params: {
88
+ docId: string;
89
+ versionId: string;
90
+ }, ctx?: AuthContext): Promise<any>;
91
+ getChangesForVersion(params: {
92
+ docId: string;
93
+ versionId: string;
94
+ }, ctx?: AuthContext): Promise<Change[]>;
95
+ listServerChanges(params: {
96
+ docId: string;
97
+ options?: ListChangesOptions;
98
+ }, ctx?: AuthContext): Promise<Change[]>;
99
+ listBranches(params: {
100
+ docId: string;
101
+ }, ctx?: AuthContext): Promise<import("../../types.js").Branch[]>;
102
+ createBranch(params: {
103
+ docId: string;
104
+ rev: number;
105
+ metadata?: EditableVersionMetadata;
106
+ }, ctx?: AuthContext): Promise<string>;
107
+ closeBranch(params: {
108
+ branchId: string;
109
+ }, ctx?: AuthContext): Promise<void>;
110
+ mergeBranch(params: {
111
+ branchId: string;
112
+ }, ctx?: AuthContext): Promise<Change[]>;
113
+ protected assertAccess(ctx: AuthContext | undefined, docId: string, kind: 'read' | 'write', method: string, params?: Record<string, any>): Promise<void>;
114
+ assertRead(ctx: AuthContext | undefined, docId: string, method: string, params?: Record<string, any>): Promise<void>;
115
+ assertWrite(ctx: AuthContext | undefined, docId: string, method: string, params?: Record<string, any>): Promise<void>;
116
+ protected assertHistoryEnabled(): void;
117
+ protected assertBranchingEnabled(): void;
118
+ }
@@ -0,0 +1,187 @@
1
+ import { JSONRPCServer } from '../protocol/JSONRPCServer.js';
2
+ import { allowAll } from './AuthorizationProvider.js';
3
+ export class RPCServer {
4
+ /**
5
+ * Creates a new Patches WebSocket client instance.
6
+ * @param patches - The patches server instance to handle document operations
7
+ * @param history - (Optional) History manager instance to handle versioning operations
8
+ * @param branches - (Optional) Branch manager instance to handle branching operations
9
+ * @param auth - (Optional) Authorization provider implementation. Defaults to a permissive provider.
10
+ */
11
+ constructor({ patches, history, branches, auth = allowAll }) {
12
+ this.rpc = new JSONRPCServer();
13
+ this.patches = patches;
14
+ this.history = history;
15
+ this.branches = branches;
16
+ this.auth = auth;
17
+ // Document operations
18
+ this.rpc.registerMethod('getDoc', this.getDoc.bind(this));
19
+ this.rpc.registerMethod('getChangesSince', this.getChangesSince.bind(this));
20
+ this.rpc.registerMethod('commitChanges', this.commitChanges.bind(this));
21
+ this.rpc.registerMethod('deleteDoc', this.deleteDoc.bind(this));
22
+ // History manager operations (if provided)
23
+ if (this.history) {
24
+ this.rpc.registerMethod('listVersions', this.listVersions.bind(this));
25
+ this.rpc.registerMethod('createVersion', this.createVersion.bind(this));
26
+ this.rpc.registerMethod('updateVersion', this.updateVersion.bind(this));
27
+ this.rpc.registerMethod('getVersionState', this.getStateAtVersion.bind(this));
28
+ this.rpc.registerMethod('getVersionChanges', this.getChangesForVersion.bind(this));
29
+ this.rpc.registerMethod('listServerChanges', this.listServerChanges.bind(this));
30
+ }
31
+ // Branch manager operations (if provided)
32
+ if (this.branches) {
33
+ this.rpc.registerMethod('listBranches', this.listBranches.bind(this));
34
+ this.rpc.registerMethod('createBranch', this.createBranch.bind(this));
35
+ this.rpc.registerMethod('closeBranch', this.closeBranch.bind(this));
36
+ this.rpc.registerMethod('mergeBranch', this.mergeBranch.bind(this));
37
+ }
38
+ // -------------------------------------------------------------------------
39
+ // Listen to core server events and forward as JSON-RPC notifications
40
+ // -------------------------------------------------------------------------
41
+ this.patches.onChangesCommitted((docId, changes, originClientId) => {
42
+ this.rpc.notify('changesCommitted', { docId, changes }, originClientId);
43
+ });
44
+ this.patches.onDocDeleted((docId, originClientId) => {
45
+ this.rpc.notify('docDeleted', { docId }, originClientId);
46
+ });
47
+ }
48
+ /**
49
+ * Gets the latest state (content and revision) of a document.
50
+ * @param connectionId - The ID of the connection making the request
51
+ * @param params - The document parameters
52
+ * @param params.docId - The ID of the document
53
+ * @param params.atRev - Optional revision number to get document state at
54
+ */
55
+ async getDoc(params, ctx) {
56
+ const { docId, atRev } = params;
57
+ await this.assertRead(ctx, docId, 'getDoc', params);
58
+ return this.patches.getDoc(docId, atRev);
59
+ }
60
+ /**
61
+ * Gets changes that occurred for a document after a specific revision number.
62
+ * @param connectionId - The ID of the connection making the request
63
+ * @param params - The change request parameters
64
+ * @param params.docId - The ID of the document
65
+ * @param params.rev - The revision number after which to fetch changes
66
+ */
67
+ async getChangesSince(params, ctx) {
68
+ const { docId, rev } = params;
69
+ await this.assertRead(ctx, docId, 'getChangesSince', params);
70
+ return this.patches.getChangesSince(docId, rev);
71
+ }
72
+ /**
73
+ * Applies a set of client-generated changes to a document on the server.
74
+ * @param connectionId - The ID of the connection making the request
75
+ * @param params - The change parameters
76
+ * @param params.docId - The ID of the document
77
+ * @param params.changes - An array of changes to apply
78
+ */
79
+ async commitChanges(params, ctx) {
80
+ const { docId, changes } = params;
81
+ await this.assertWrite(ctx, docId, 'commitChanges', params);
82
+ const [priorChanges, newChanges] = await this.patches.commitChanges(docId, changes, ctx?.clientId);
83
+ return [...priorChanges, ...newChanges];
84
+ }
85
+ /**
86
+ * Deletes a document on the server.
87
+ * @param connectionId - The ID of the connection making the request
88
+ * @param params - The deletion parameters
89
+ * @param params.docId - The ID of the document to delete
90
+ */
91
+ async deleteDoc(params, ctx) {
92
+ const { docId } = params;
93
+ await this.assertWrite(ctx, docId, 'deleteDoc', params);
94
+ await this.patches.deleteDoc(docId, ctx?.clientId);
95
+ }
96
+ // ---------------------------------------------------------------------------
97
+ // History Manager wrappers
98
+ // ---------------------------------------------------------------------------
99
+ async listVersions(params, ctx) {
100
+ this.assertHistoryEnabled();
101
+ const { docId, options } = params;
102
+ await this.assertRead(ctx, docId, 'listVersions', params);
103
+ return this.history.listVersions(docId, options ?? {});
104
+ }
105
+ async createVersion(params, ctx) {
106
+ this.assertHistoryEnabled();
107
+ const { docId, metadata } = params;
108
+ await this.assertWrite(ctx, docId, 'createVersion', params);
109
+ return this.history.createVersion(docId, metadata);
110
+ }
111
+ async updateVersion(params, ctx) {
112
+ this.assertHistoryEnabled();
113
+ const { docId, versionId, metadata } = params;
114
+ await this.assertWrite(ctx, docId, 'updateVersion', params);
115
+ return this.history.updateVersion(docId, versionId, metadata);
116
+ }
117
+ async getStateAtVersion(params, ctx) {
118
+ this.assertHistoryEnabled();
119
+ const { docId, versionId } = params;
120
+ await this.assertRead(ctx, docId, 'getStateAtVersion', params);
121
+ return this.history.getStateAtVersion(docId, versionId);
122
+ }
123
+ async getChangesForVersion(params, ctx) {
124
+ this.assertHistoryEnabled();
125
+ const { docId, versionId } = params;
126
+ await this.assertRead(ctx, docId, 'getChangesForVersion', params);
127
+ return this.history.getChangesForVersion(docId, versionId);
128
+ }
129
+ async listServerChanges(params, ctx) {
130
+ this.assertHistoryEnabled();
131
+ const { docId, options } = params;
132
+ await this.assertRead(ctx, docId, 'listServerChanges', params);
133
+ return this.history.listServerChanges(docId, options ?? {});
134
+ }
135
+ // ---------------------------------------------------------------------------
136
+ // Branch Manager wrappers
137
+ // ---------------------------------------------------------------------------
138
+ async listBranches(params, ctx) {
139
+ this.assertBranchingEnabled();
140
+ const { docId } = params;
141
+ await this.assertRead(ctx, docId, 'listBranches', params);
142
+ return this.branches.listBranches(docId);
143
+ }
144
+ async createBranch(params, ctx) {
145
+ this.assertBranchingEnabled();
146
+ const { docId, rev, metadata } = params;
147
+ await this.assertWrite(ctx, docId, 'createBranch', params);
148
+ return this.branches.createBranch(docId, rev, metadata);
149
+ }
150
+ async closeBranch(params, ctx) {
151
+ this.assertBranchingEnabled();
152
+ const { branchId } = params;
153
+ await this.assertWrite(ctx, branchId, 'closeBranch', params);
154
+ return this.branches.closeBranch(branchId, 'closed');
155
+ }
156
+ async mergeBranch(params, ctx) {
157
+ this.assertBranchingEnabled();
158
+ const { branchId } = params;
159
+ await this.assertWrite(ctx, branchId, 'mergeBranch', params);
160
+ return this.branches.mergeBranch(branchId);
161
+ }
162
+ // ---------------------------------------------------------------------------
163
+ // Authorization helpers
164
+ // ---------------------------------------------------------------------------
165
+ async assertAccess(ctx, docId, kind, method, params) {
166
+ const ok = await this.auth.canAccess(ctx, docId, kind, method, params);
167
+ if (!ok) {
168
+ throw new Error(`${kind.toUpperCase()}_FORBIDDEN:${docId}`);
169
+ }
170
+ }
171
+ assertRead(ctx, docId, method, params) {
172
+ return this.assertAccess(ctx, docId, 'read', method, params);
173
+ }
174
+ assertWrite(ctx, docId, method, params) {
175
+ return this.assertAccess(ctx, docId, 'write', method, params);
176
+ }
177
+ assertHistoryEnabled() {
178
+ if (!this.history) {
179
+ throw new Error('History is not enabled');
180
+ }
181
+ }
182
+ assertBranchingEnabled() {
183
+ if (!this.branches) {
184
+ throw new Error('Branching is not enabled');
185
+ }
186
+ }
187
+ }
@@ -1,144 +1,36 @@
1
- import type { PatchesBranchManager } from '../../server/PatchesBranchManager.js';
2
- import type { PatchesHistoryManager } from '../../server/PatchesHistoryManager.js';
3
- import type { PatchesServer } from '../../server/PatchesServer.js';
4
- import type { Change, EditableVersionMetadata, ListVersionsOptions } from '../../types.js';
5
- import { JSONRPCServer } from '../protocol/JSONRPCServer.js';
6
1
  import type { ServerTransport } from '../protocol/types.js';
7
- import type { AuthorizationProvider } from './AuthorizationProvider.js';
2
+ import type { AuthContext, AuthorizationProvider } from './AuthorizationProvider.js';
3
+ import type { RPCServer } from './RPCServer.js';
8
4
  /**
9
5
  * High-level client for the Patches real-time collaboration service.
10
6
  * This class provides document subscription, patch notification handling,
11
- * versioning, and other OT-specific functionality
12
- * over a WebSocket connection.
7
+ * versioning, and other OT-specific functionality over a WebSocket connection.
13
8
  */
14
- export interface WebSocketServerOptions {
15
- transport: ServerTransport;
16
- patches: PatchesServer;
17
- history?: PatchesHistoryManager;
18
- branches?: PatchesBranchManager;
19
- auth?: AuthorizationProvider;
20
- }
21
9
  export declare class WebSocketServer {
22
10
  protected transport: ServerTransport;
23
- protected rpc: JSONRPCServer;
24
- protected patches: PatchesServer;
25
- protected history?: PatchesHistoryManager;
26
- protected branches?: PatchesBranchManager;
27
11
  protected auth: AuthorizationProvider;
28
12
  /**
29
13
  * Creates a new Patches WebSocket client instance.
14
+ *
30
15
  * @param transport - The transport layer implementation that will be used for sending/receiving messages
31
- * @param patches - The patches server instance to handle document operations
32
- * @param auth - (Optional) Authorization provider implementation. Defaults to a permissive provider.
33
16
  */
34
- constructor({ transport, patches, history, branches, auth }: WebSocketServerOptions);
35
- protected assertAccess(connectionId: string, docId: string, kind: 'read' | 'write', method: string, params?: Record<string, any>): Promise<void>;
36
- protected assertRead(connectionId: string, docId: string, method: string, params?: Record<string, any>): Promise<void>;
37
- protected assertWrite(connectionId: string, docId: string, method: string, params?: Record<string, any>): Promise<void>;
38
- protected assertHistoryEnabled(): void;
39
- protected assertBranchingEnabled(): void;
17
+ constructor(transport: ServerTransport, rpcServer: RPCServer);
40
18
  /**
41
19
  * Subscribes the client to one or more documents to receive real-time updates.
42
20
  * @param connectionId - The ID of the connection making the request
43
21
  * @param params - The subscription parameters
44
22
  * @param params.ids - Document ID or IDs to subscribe to
45
23
  */
46
- subscribe(connectionId: string, params: {
24
+ subscribe(params: {
47
25
  ids: string | string[];
48
- }): Promise<string[]>;
26
+ }, ctx?: AuthContext): Promise<string[]>;
49
27
  /**
50
28
  * Unsubscribes the client from one or more documents.
51
29
  * @param connectionId - The ID of the connection making the request
52
30
  * @param params - The unsubscription parameters
53
31
  * @param params.ids - Document ID or IDs to unsubscribe from
54
32
  */
55
- unsubscribe(connectionId: string, params: {
33
+ unsubscribe(params: {
56
34
  ids: string | string[];
57
- }): Promise<string[]>;
58
- /**
59
- * Gets the latest state (content and revision) of a document.
60
- * @param _connectionId - The ID of the connection making the request
61
- * @param params - The document parameters
62
- * @param params.docId - The ID of the document
63
- * @param params.atRev - Optional revision number to get document state at
64
- */
65
- getDoc(_connectionId: string, params: {
66
- docId: string;
67
- atRev?: number;
68
- }): Promise<import("../../types.js").PatchesSnapshot<any>>;
69
- /**
70
- * Gets changes that occurred for a document after a specific revision number.
71
- * @param _connectionId - The ID of the connection making the request
72
- * @param params - The change request parameters
73
- * @param params.docId - The ID of the document
74
- * @param params.rev - The revision number after which to fetch changes
75
- */
76
- getChangesSince(_connectionId: string, params: {
77
- docId: string;
78
- rev: number;
79
- }): Promise<Change[]>;
80
- /**
81
- * Applies a set of client-generated changes to a document on the server.
82
- * @param connectionId - The ID of the connection making the request
83
- * @param params - The change parameters
84
- * @param params.docId - The ID of the document
85
- * @param params.changes - An array of changes to apply
86
- */
87
- commitChanges(connectionId: string, params: {
88
- docId: string;
89
- changes: Change[];
90
- }): Promise<Change[]>;
91
- /**
92
- * Deletes a document on the server.
93
- * @param connectionId - The ID of the connection making the request
94
- * @param params - The deletion parameters
95
- * @param params.docId - The ID of the document to delete
96
- */
97
- deleteDoc(connectionId: string, params: {
98
- docId: string;
99
- }): Promise<void>;
100
- listVersions(connectionId: string, params: {
101
- docId: string;
102
- options?: ListVersionsOptions;
103
- }): Promise<import("../../types.js").VersionMetadata[]>;
104
- createVersion(connectionId: string, params: {
105
- docId: string;
106
- metadata: EditableVersionMetadata;
107
- }): Promise<string>;
108
- updateVersion(connectionId: string, params: {
109
- docId: string;
110
- versionId: string;
111
- metadata: EditableVersionMetadata;
112
- }): Promise<void>;
113
- getStateAtVersion(connectionId: string, params: {
114
- docId: string;
115
- versionId: string;
116
- }): Promise<any>;
117
- getChangesForVersion(connectionId: string, params: {
118
- docId: string;
119
- versionId: string;
120
- }): Promise<Change[]>;
121
- listServerChanges(connectionId: string, params: {
122
- docId: string;
123
- options?: {
124
- limit?: number;
125
- startAfterRev?: number;
126
- endBeforeRev?: number;
127
- reverse?: boolean;
128
- };
129
- }): Promise<Change[]>;
130
- listBranches(connectionId: string, params: {
131
- docId: string;
132
- }): Promise<import("../../types.js").Branch[]>;
133
- createBranch(connectionId: string, params: {
134
- docId: string;
135
- rev: number;
136
- metadata?: EditableVersionMetadata;
137
- }): Promise<string>;
138
- closeBranch(connectionId: string, params: {
139
- branchId: string;
140
- }): Promise<void>;
141
- mergeBranch(connectionId: string, params: {
142
- branchId: string;
143
- }): Promise<Change[]>;
35
+ }, ctx?: AuthContext): Promise<string[]>;
144
36
  }
@@ -1,84 +1,48 @@
1
- import { JSONRPCServer } from '../protocol/JSONRPCServer.js';
2
1
  import { allowAll } from './AuthorizationProvider.js';
2
+ /**
3
+ * High-level client for the Patches real-time collaboration service.
4
+ * This class provides document subscription, patch notification handling,
5
+ * versioning, and other OT-specific functionality over a WebSocket connection.
6
+ */
3
7
  export class WebSocketServer {
4
8
  /**
5
9
  * Creates a new Patches WebSocket client instance.
10
+ *
6
11
  * @param transport - The transport layer implementation that will be used for sending/receiving messages
7
- * @param patches - The patches server instance to handle document operations
8
- * @param auth - (Optional) Authorization provider implementation. Defaults to a permissive provider.
9
12
  */
10
- constructor({ transport, patches, history, branches, auth = allowAll }) {
13
+ constructor(transport, rpcServer) {
11
14
  this.transport = transport;
12
- this.rpc = new JSONRPCServer(this.transport);
13
- this.patches = patches;
14
- this.history = history;
15
- this.branches = branches;
16
- this.auth = auth;
15
+ const { rpc, auth } = rpcServer;
16
+ this.auth = auth || allowAll;
17
17
  // Subscription operations
18
- this.rpc.registerMethod('subscribe', this.subscribe.bind(this));
19
- this.rpc.registerMethod('unsubscribe', this.unsubscribe.bind(this));
20
- // Document operations
21
- this.rpc.registerMethod('getDoc', this.getDoc.bind(this));
22
- this.rpc.registerMethod('getChangesSince', this.getChangesSince.bind(this));
23
- this.rpc.registerMethod('commitChanges', this.commitChanges.bind(this));
24
- this.rpc.registerMethod('deleteDoc', this.deleteDoc.bind(this));
25
- // History manager operations (if provided)
26
- if (this.history) {
27
- this.rpc.registerMethod('listVersions', this.listVersions.bind(this));
28
- this.rpc.registerMethod('createVersion', this.createVersion.bind(this));
29
- this.rpc.registerMethod('updateVersion', this.updateVersion.bind(this));
30
- this.rpc.registerMethod('getVersionState', this.getStateAtVersion.bind(this));
31
- this.rpc.registerMethod('getVersionChanges', this.getChangesForVersion.bind(this));
32
- this.rpc.registerMethod('listServerChanges', this.listServerChanges.bind(this));
33
- }
34
- // Branch manager operations (if provided)
35
- if (this.branches) {
36
- this.rpc.registerMethod('listBranches', this.listBranches.bind(this));
37
- this.rpc.registerMethod('createBranch', this.createBranch.bind(this));
38
- this.rpc.registerMethod('closeBranch', this.closeBranch.bind(this));
39
- this.rpc.registerMethod('mergeBranch', this.mergeBranch.bind(this));
40
- }
41
- }
42
- // ---------------------------------------------------------------------------
43
- // Authorization helpers
44
- // ---------------------------------------------------------------------------
45
- async assertAccess(connectionId, docId, kind, method, params) {
46
- const ok = await this.auth.canAccess(connectionId, docId, kind, method, params);
47
- if (!ok) {
48
- throw new Error(`${kind.toUpperCase()}_FORBIDDEN:${docId}`);
49
- }
50
- }
51
- assertRead(connectionId, docId, method, params) {
52
- return this.assertAccess(connectionId, docId, 'read', method, params);
53
- }
54
- assertWrite(connectionId, docId, method, params) {
55
- return this.assertAccess(connectionId, docId, 'write', method, params);
56
- }
57
- assertHistoryEnabled() {
58
- if (!this.history) {
59
- throw new Error('History is not enabled');
60
- }
61
- }
62
- assertBranchingEnabled() {
63
- if (!this.branches) {
64
- throw new Error('Branching is not enabled');
65
- }
18
+ rpc.registerMethod('subscribe', this.subscribe.bind(this));
19
+ rpc.registerMethod('unsubscribe', this.unsubscribe.bind(this));
20
+ rpc.onNotify(async (msg) => {
21
+ if (!msg.params?.docId)
22
+ return;
23
+ const { docId } = msg.params;
24
+ const msgString = JSON.stringify(msg);
25
+ const clientIds = await this.transport.listSubscriptions(docId);
26
+ clientIds.forEach(clientId => {
27
+ this.transport.send(clientId, msgString);
28
+ });
29
+ });
66
30
  }
67
- // --- Patches API Methods ---
68
- // === Subscription Operations ===
69
31
  /**
70
32
  * Subscribes the client to one or more documents to receive real-time updates.
71
33
  * @param connectionId - The ID of the connection making the request
72
34
  * @param params - The subscription parameters
73
35
  * @param params.ids - Document ID or IDs to subscribe to
74
36
  */
75
- async subscribe(connectionId, params) {
37
+ async subscribe(params, ctx) {
38
+ if (!ctx?.clientId)
39
+ return [];
76
40
  const { ids } = params;
77
41
  const allIds = Array.isArray(ids) ? ids : [ids];
78
42
  const allowed = [];
79
43
  await Promise.all(allIds.map(async (id) => {
80
44
  try {
81
- if (await this.auth.canAccess(connectionId, id, 'read', 'subscribe', params)) {
45
+ if (await this.auth.canAccess(ctx, id, 'read', 'subscribe', params)) {
82
46
  allowed.push(id);
83
47
  }
84
48
  }
@@ -89,9 +53,7 @@ export class WebSocketServer {
89
53
  if (allowed.length === 0) {
90
54
  return [];
91
55
  }
92
- // retain original input shape for call but we only pass allowed
93
- const input = Array.isArray(ids) ? allowed : allowed[0];
94
- return this.patches.subscribe(connectionId, input);
56
+ return this.transport.addSubscription(ctx.clientId, allowed);
95
57
  }
96
58
  /**
97
59
  * Unsubscribes the client from one or more documents.
@@ -99,136 +61,13 @@ export class WebSocketServer {
99
61
  * @param params - The unsubscription parameters
100
62
  * @param params.ids - Document ID or IDs to unsubscribe from
101
63
  */
102
- async unsubscribe(connectionId, params) {
64
+ async unsubscribe(params, ctx) {
65
+ if (!ctx?.clientId)
66
+ return [];
103
67
  const { ids } = params;
104
68
  // We deliberately do **not** enforce authorization here –
105
69
  // removing a subscription doesn't leak information and helps
106
70
  // clean up server-side state if a client has lost access mid-session.
107
- return this.patches.unsubscribe(connectionId, ids);
108
- }
109
- // === Document Operations ===
110
- /**
111
- * Gets the latest state (content and revision) of a document.
112
- * @param _connectionId - The ID of the connection making the request
113
- * @param params - The document parameters
114
- * @param params.docId - The ID of the document
115
- * @param params.atRev - Optional revision number to get document state at
116
- */
117
- async getDoc(_connectionId, params) {
118
- const { docId, atRev } = params;
119
- await this.assertRead(_connectionId, docId, 'getDoc', params);
120
- return this.patches.getDoc(docId, atRev);
121
- }
122
- /**
123
- * Gets changes that occurred for a document after a specific revision number.
124
- * @param _connectionId - The ID of the connection making the request
125
- * @param params - The change request parameters
126
- * @param params.docId - The ID of the document
127
- * @param params.rev - The revision number after which to fetch changes
128
- */
129
- async getChangesSince(_connectionId, params) {
130
- const { docId, rev } = params;
131
- await this.assertRead(_connectionId, docId, 'getChangesSince', params);
132
- return this.patches.getChangesSince(docId, rev);
133
- }
134
- /**
135
- * Applies a set of client-generated changes to a document on the server.
136
- * @param connectionId - The ID of the connection making the request
137
- * @param params - The change parameters
138
- * @param params.docId - The ID of the document
139
- * @param params.changes - An array of changes to apply
140
- */
141
- async commitChanges(connectionId, params) {
142
- const { docId, changes } = params;
143
- await this.assertWrite(connectionId, docId, 'commitChanges', params);
144
- const [priorChanges, newChanges] = await this.patches.commitChanges(docId, changes);
145
- // Notify other clients that the new changes have been committed
146
- const connectionIds = this.transport.getConnectionIds().filter(id => id !== connectionId);
147
- if (connectionIds.length > 0) {
148
- this.rpc.notify(connectionIds, 'changesCommitted', { docId, changes: newChanges });
149
- }
150
- return [...priorChanges, ...newChanges];
151
- }
152
- /**
153
- * Deletes a document on the server.
154
- * @param connectionId - The ID of the connection making the request
155
- * @param params - The deletion parameters
156
- * @param params.docId - The ID of the document to delete
157
- */
158
- async deleteDoc(connectionId, params) {
159
- const { docId } = params;
160
- await this.assertWrite(connectionId, docId, 'deleteDoc', params);
161
- await this.patches.deleteDoc(docId);
162
- // Notify other clients that the document has been deleted
163
- const connectionIds = this.transport.getConnectionIds().filter(id => id !== connectionId);
164
- if (connectionIds.length > 0) {
165
- this.rpc.notify(connectionIds, 'docDeleted', { docId });
166
- }
167
- }
168
- // ---------------------------------------------------------------------------
169
- // History Manager wrappers
170
- // ---------------------------------------------------------------------------
171
- async listVersions(connectionId, params) {
172
- this.assertHistoryEnabled();
173
- const { docId, options } = params;
174
- await this.assertRead(connectionId, docId, 'listVersions', params);
175
- return this.history.listVersions(docId, options ?? {});
176
- }
177
- async createVersion(connectionId, params) {
178
- this.assertHistoryEnabled();
179
- const { docId, metadata } = params;
180
- await this.assertWrite(connectionId, docId, 'createVersion', params);
181
- return this.history.createVersion(docId, metadata);
182
- }
183
- async updateVersion(connectionId, params) {
184
- this.assertHistoryEnabled();
185
- const { docId, versionId, metadata } = params;
186
- await this.assertWrite(connectionId, docId, 'updateVersion', params);
187
- return this.history.updateVersion(docId, versionId, metadata);
188
- }
189
- async getStateAtVersion(connectionId, params) {
190
- this.assertHistoryEnabled();
191
- const { docId, versionId } = params;
192
- await this.assertRead(connectionId, docId, 'getStateAtVersion', params);
193
- return this.history.getStateAtVersion(docId, versionId);
194
- }
195
- async getChangesForVersion(connectionId, params) {
196
- this.assertHistoryEnabled();
197
- const { docId, versionId } = params;
198
- await this.assertRead(connectionId, docId, 'getChangesForVersion', params);
199
- return this.history.getChangesForVersion(docId, versionId);
200
- }
201
- async listServerChanges(connectionId, params) {
202
- this.assertHistoryEnabled();
203
- const { docId, options } = params;
204
- await this.assertRead(connectionId, docId, 'listServerChanges', params);
205
- return this.history.listServerChanges(docId, options ?? {});
206
- }
207
- // ---------------------------------------------------------------------------
208
- // Branch Manager wrappers
209
- // ---------------------------------------------------------------------------
210
- async listBranches(connectionId, params) {
211
- this.assertBranchingEnabled();
212
- const { docId } = params;
213
- await this.assertRead(connectionId, docId, 'listBranches', params);
214
- return this.branches.listBranches(docId);
215
- }
216
- async createBranch(connectionId, params) {
217
- this.assertBranchingEnabled();
218
- const { docId, rev, metadata } = params;
219
- await this.assertWrite(connectionId, docId, 'createBranch', params);
220
- return this.branches.createBranch(docId, rev, metadata);
221
- }
222
- async closeBranch(connectionId, params) {
223
- this.assertBranchingEnabled();
224
- const { branchId } = params;
225
- await this.assertWrite(connectionId, branchId, 'closeBranch', params);
226
- return this.branches.closeBranch(branchId, 'closed');
227
- }
228
- async mergeBranch(connectionId, params) {
229
- this.assertBranchingEnabled();
230
- const { branchId } = params;
231
- await this.assertWrite(connectionId, branchId, 'mergeBranch', params);
232
- return this.branches.mergeBranch(branchId);
71
+ return this.transport.removeSubscription(ctx?.clientId, Array.isArray(ids) ? ids : [ids]);
233
72
  }
234
73
  }
@@ -19,21 +19,11 @@ export interface PatchesServerOptions {
19
19
  export declare class PatchesServer {
20
20
  readonly store: PatchesStoreBackend;
21
21
  private readonly sessionTimeoutMillis;
22
+ /** Notifies listeners whenever a batch of changes is *successfully* committed. */
23
+ readonly onChangesCommitted: import("../event-signal.js").Signal<(docId: string, changes: Change[], originClientId?: string) => void>;
24
+ /** Notifies listeners when a document is deleted. */
25
+ readonly onDocDeleted: import("../event-signal.js").Signal<(docId: string, originClientId?: string) => void>;
22
26
  constructor(store: PatchesStoreBackend, options?: PatchesServerOptions);
23
- /**
24
- * Subscribes the connected client to one or more documents.
25
- * @param clientId - The ID of the client.
26
- * @param ids Document ID(s) to subscribe to.
27
- * @returns A list of document IDs the client is now successfully subscribed to.
28
- */
29
- subscribe(clientId: string, ids: string | string[]): Promise<string[]>;
30
- /**
31
- * Unsubscribes the connected client from one or more documents.
32
- * @param clientId - The ID of the client.
33
- * @param ids Document ID(s) to unsubscribe from.
34
- * @returns A list of document IDs the client is now successfully unsubscribed from.
35
- */
36
- unsubscribe(clientId: string, ids: string | string[]): Promise<string[]>;
37
27
  /**
38
28
  * Get the latest version of a document and changes since the last version.
39
29
  * @param docId - The ID of the document.
@@ -51,16 +41,18 @@ export declare class PatchesServer {
51
41
  * Commits a set of changes to a document, applying operational transformation as needed.
52
42
  * @param docId - The ID of the document.
53
43
  * @param changes - The changes to commit.
44
+ * @param originClientId - The ID of the client that initiated the commit.
54
45
  * @returns A tuple of [committedChanges, transformedChanges] where:
55
46
  * - committedChanges: Changes that were already committed to the server after the client's base revision
56
47
  * - transformedChanges: The client's changes after being transformed against concurrent changes
57
48
  */
58
- commitChanges(docId: string, changes: Change[]): Promise<[Change[], Change[]]>;
49
+ commitChanges(docId: string, changes: Change[], originClientId?: string): Promise<[Change[], Change[]]>;
59
50
  /**
60
51
  * Deletes a document.
61
52
  * @param docId The document ID.
53
+ * @param originClientId - The ID of the client that initiated the delete operation.
62
54
  */
63
- deleteDoc(docId: string): Promise<void>;
55
+ deleteDoc(docId: string, originClientId?: string): Promise<void>;
64
56
  /**
65
57
  * Create a new named version snapshot of a document's current state.
66
58
  * @param docId The document ID.
@@ -1,4 +1,5 @@
1
1
  import { createId } from 'crypto-id';
2
+ import { signal } from '../event-signal.js';
2
3
  import { applyPatch } from '../json-patch/applyPatch.js';
3
4
  import { transformPatch } from '../json-patch/transformPatch.js';
4
5
  import { applyChanges } from '../utils.js';
@@ -10,29 +11,12 @@ import { applyChanges } from '../utils.js';
10
11
  export class PatchesServer {
11
12
  constructor(store, options = {}) {
12
13
  this.store = store;
14
+ /** Notifies listeners whenever a batch of changes is *successfully* committed. */
15
+ this.onChangesCommitted = signal();
16
+ /** Notifies listeners when a document is deleted. */
17
+ this.onDocDeleted = signal();
13
18
  this.sessionTimeoutMillis = (options.sessionTimeoutMinutes ?? 30) * 60 * 1000;
14
19
  }
15
- // --- Patches API Methods ---
16
- // === Subscription Operations ===
17
- /**
18
- * Subscribes the connected client to one or more documents.
19
- * @param clientId - The ID of the client.
20
- * @param ids Document ID(s) to subscribe to.
21
- * @returns A list of document IDs the client is now successfully subscribed to.
22
- */
23
- subscribe(clientId, ids) {
24
- return this.store.addSubscription(clientId, Array.isArray(ids) ? ids : [ids]);
25
- }
26
- /**
27
- * Unsubscribes the connected client from one or more documents.
28
- * @param clientId - The ID of the client.
29
- * @param ids Document ID(s) to unsubscribe from.
30
- * @returns A list of document IDs the client is now successfully unsubscribed from.
31
- */
32
- unsubscribe(clientId, ids) {
33
- return this.store.removeSubscription(clientId, Array.isArray(ids) ? ids : [ids]);
34
- }
35
- // === Document Operations ===
36
20
  /**
37
21
  * Get the latest version of a document and changes since the last version.
38
22
  * @param docId - The ID of the document.
@@ -54,11 +38,12 @@ export class PatchesServer {
54
38
  * Commits a set of changes to a document, applying operational transformation as needed.
55
39
  * @param docId - The ID of the document.
56
40
  * @param changes - The changes to commit.
41
+ * @param originClientId - The ID of the client that initiated the commit.
57
42
  * @returns A tuple of [committedChanges, transformedChanges] where:
58
43
  * - committedChanges: Changes that were already committed to the server after the client's base revision
59
44
  * - transformedChanges: The client's changes after being transformed against concurrent changes
60
45
  */
61
- async commitChanges(docId, changes) {
46
+ async commitChanges(docId, changes, originClientId) {
62
47
  if (changes.length === 0) {
63
48
  return [[], []];
64
49
  }
@@ -142,15 +127,21 @@ export class PatchesServer {
142
127
  if (transformedChanges.length > 0) {
143
128
  await this.store.saveChanges(docId, transformedChanges);
144
129
  }
130
+ // Fire event for realtime transports (WebSocket, etc.)
131
+ if (transformedChanges.length > 0) {
132
+ await this.onChangesCommitted.emit(docId, transformedChanges, originClientId);
133
+ }
145
134
  // Return committed changes and newly transformed changes separately
146
135
  return [committedChanges, transformedChanges];
147
136
  }
148
137
  /**
149
138
  * Deletes a document.
150
139
  * @param docId The document ID.
140
+ * @param originClientId - The ID of the client that initiated the delete operation.
151
141
  */
152
- deleteDoc(docId) {
153
- return this.store.deleteDoc(docId);
142
+ async deleteDoc(docId, originClientId) {
143
+ await this.store.deleteDoc(docId);
144
+ await this.onDocDeleted.emit(docId, originClientId);
154
145
  }
155
146
  // === Version Operations ===
156
147
  /**
@@ -1,17 +1,17 @@
1
- import type { Branch, Change, EditableVersionMetadata, ListChangesOptions, ListVersionsOptions, VersionMetadata } from '../types';
1
+ import type { Branch, Change, EditableVersionMetadata, ListChangesOptions, ListVersionsOptions, PatchesState, VersionMetadata } from '../types';
2
2
  /**
3
3
  * Interface for a backend storage system for patch synchronization.
4
4
  * Defines methods needed by PatchesServer, PatchesHistoryManager, etc.
5
5
  */
6
6
  export interface PatchesStoreBackend {
7
- /** Adds a subscription for a client to one or more documents. */
8
- addSubscription(clientId: string, docIds: string[]): Promise<string[]>;
9
- /** Removes a subscription for a client from one or more documents. */
10
- removeSubscription(clientId: string, docIds: string[]): Promise<string[]>;
11
7
  /** Saves a batch of committed server changes. */
12
8
  saveChanges(docId: string, changes: Change[]): Promise<void>;
13
9
  /** Lists committed server changes based on revision numbers. */
14
10
  listChanges(docId: string, options: ListChangesOptions): Promise<Change[]>;
11
+ /** Loads the last version state for a document. Optional method for performance. */
12
+ loadLastVersionState?: (docId: string) => Promise<PatchesState | undefined>;
13
+ /** Saves the last version state for a document. Optional method for performance. */
14
+ saveLastVersionState?: (docId: string, rev: number, state: any) => Promise<void>;
15
15
  /**
16
16
  * Saves version metadata, its state snapshot, and the original changes that constitute it.
17
17
  * State and changes are stored separately from the core metadata.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dabble/patches",
3
- "version": "0.2.21",
3
+ "version": "0.2.23",
4
4
  "description": "Immutable JSON Patch implementation based on RFC 6902 supporting operational transformation and last-writer-wins",
5
5
  "author": "Jacob Wright <jacwright@gmail.com>",
6
6
  "bugs": {