@dabble/patches 0.2.26 → 0.2.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client/PatchesHistoryClient.d.ts +2 -2
- package/dist/client/PatchesHistoryClient.js +4 -4
- package/dist/net/error.d.ts +4 -0
- package/dist/net/error.js +6 -0
- package/dist/net/protocol/JSONRPCServer.js +1 -1
- package/dist/net/websocket/RPCServer.d.ts +3 -3
- package/dist/net/websocket/RPCServer.js +8 -7
- package/dist/server/PatchesServer.d.ts +11 -3
- package/dist/server/PatchesServer.js +39 -8
- package/dist/types.d.ts +1 -1
- package/dist/utils/getJSONByteSize.js +0 -1
- package/dist/utils.d.ts +0 -9
- package/dist/utils.js +0 -12
- package/package.json +1 -1
|
@@ -27,9 +27,9 @@ export declare class PatchesHistoryClient<T = any> {
|
|
|
27
27
|
/** Update the name of a specific version. */
|
|
28
28
|
updateVersion(versionId: string, metadata: EditableVersionMetadata): Promise<void>;
|
|
29
29
|
/** Load the state for a specific version */
|
|
30
|
-
|
|
30
|
+
getVersionState(versionId: string): Promise<any>;
|
|
31
31
|
/** Load the changes for a specific version */
|
|
32
|
-
|
|
32
|
+
getVersionChanges(versionId: string): Promise<Change[]>;
|
|
33
33
|
/** Scrub to a specific change within a version where changeIndex is 1-based and 0 is the parent version */
|
|
34
34
|
scrubTo(versionId: string, changeIndex: number): Promise<void>;
|
|
35
35
|
/** Clear caches and listeners */
|
|
@@ -73,7 +73,7 @@ export class PatchesHistoryClient {
|
|
|
73
73
|
await this.listVersions(); // Refresh the list of versions
|
|
74
74
|
}
|
|
75
75
|
/** Load the state for a specific version */
|
|
76
|
-
async
|
|
76
|
+
async getVersionState(versionId) {
|
|
77
77
|
let data = this.cache.get(versionId);
|
|
78
78
|
if (!data || data.state === undefined) {
|
|
79
79
|
const { state } = await this.api.getVersionState(this.id, versionId);
|
|
@@ -85,7 +85,7 @@ export class PatchesHistoryClient {
|
|
|
85
85
|
return data.state;
|
|
86
86
|
}
|
|
87
87
|
/** Load the changes for a specific version */
|
|
88
|
-
async
|
|
88
|
+
async getVersionChanges(versionId) {
|
|
89
89
|
let data = this.cache.get(versionId);
|
|
90
90
|
if (!data || data.changes === undefined) {
|
|
91
91
|
const changes = await this.api.getVersionChanges(this.id, versionId);
|
|
@@ -99,8 +99,8 @@ export class PatchesHistoryClient {
|
|
|
99
99
|
const version = this.versions.find(v => v.id === versionId);
|
|
100
100
|
// Load state and changes for the version
|
|
101
101
|
const [state, changes] = await Promise.all([
|
|
102
|
-
version?.parentId ? this.
|
|
103
|
-
this.
|
|
102
|
+
version?.parentId ? this.getVersionState(version.parentId) : undefined,
|
|
103
|
+
this.getVersionChanges(versionId),
|
|
104
104
|
]);
|
|
105
105
|
// Apply changes up to changeIndex to the state (if needed)
|
|
106
106
|
if (changeIndex > 0) {
|
|
@@ -95,7 +95,7 @@ export class JSONRPCServer {
|
|
|
95
95
|
return respond(response);
|
|
96
96
|
}
|
|
97
97
|
catch (err) {
|
|
98
|
-
return respond(rpcError(err?.code ?? -32000, err?.message ?? 'Server error', err?.stack));
|
|
98
|
+
return respond(rpcError(err?.code ?? -32000, err?.message ?? 'Server error', err?.code ? undefined : err?.stack));
|
|
99
99
|
}
|
|
100
100
|
}
|
|
101
101
|
else {
|
|
@@ -39,7 +39,7 @@ export declare class RPCServer {
|
|
|
39
39
|
getDoc(params: {
|
|
40
40
|
docId: string;
|
|
41
41
|
atRev?: number;
|
|
42
|
-
}, ctx?: AuthContext): Promise<import("../../types.js").
|
|
42
|
+
}, ctx?: AuthContext): Promise<import("../../types.js").PatchesState<any>>;
|
|
43
43
|
/**
|
|
44
44
|
* Gets changes that occurred for a document after a specific revision number.
|
|
45
45
|
* @param connectionId - The ID of the connection making the request
|
|
@@ -84,11 +84,11 @@ export declare class RPCServer {
|
|
|
84
84
|
versionId: string;
|
|
85
85
|
metadata: EditableVersionMetadata;
|
|
86
86
|
}, ctx?: AuthContext): Promise<void>;
|
|
87
|
-
|
|
87
|
+
getVersionState(params: {
|
|
88
88
|
docId: string;
|
|
89
89
|
versionId: string;
|
|
90
90
|
}, ctx?: AuthContext): Promise<any>;
|
|
91
|
-
|
|
91
|
+
getVersionChanges(params: {
|
|
92
92
|
docId: string;
|
|
93
93
|
versionId: string;
|
|
94
94
|
}, ctx?: AuthContext): Promise<Change[]>;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { StatusError } from '../error.js';
|
|
1
2
|
import { JSONRPCServer } from '../protocol/JSONRPCServer.js';
|
|
2
3
|
import { allowAll } from './AuthorizationProvider.js';
|
|
3
4
|
export class RPCServer {
|
|
@@ -24,8 +25,8 @@ export class RPCServer {
|
|
|
24
25
|
this.rpc.registerMethod('listVersions', this.listVersions.bind(this));
|
|
25
26
|
this.rpc.registerMethod('createVersion', this.createVersion.bind(this));
|
|
26
27
|
this.rpc.registerMethod('updateVersion', this.updateVersion.bind(this));
|
|
27
|
-
this.rpc.registerMethod('getVersionState', this.
|
|
28
|
-
this.rpc.registerMethod('getVersionChanges', this.
|
|
28
|
+
this.rpc.registerMethod('getVersionState', this.getVersionState.bind(this));
|
|
29
|
+
this.rpc.registerMethod('getVersionChanges', this.getVersionChanges.bind(this));
|
|
29
30
|
this.rpc.registerMethod('listServerChanges', this.listServerChanges.bind(this));
|
|
30
31
|
}
|
|
31
32
|
// Branch manager operations (if provided)
|
|
@@ -114,13 +115,13 @@ export class RPCServer {
|
|
|
114
115
|
await this.assertWrite(ctx, docId, 'updateVersion', params);
|
|
115
116
|
return this.history.updateVersion(docId, versionId, metadata);
|
|
116
117
|
}
|
|
117
|
-
async
|
|
118
|
+
async getVersionState(params, ctx) {
|
|
118
119
|
this.assertHistoryEnabled();
|
|
119
120
|
const { docId, versionId } = params;
|
|
120
121
|
await this.assertRead(ctx, docId, 'getStateAtVersion', params);
|
|
121
122
|
return this.history.getStateAtVersion(docId, versionId);
|
|
122
123
|
}
|
|
123
|
-
async
|
|
124
|
+
async getVersionChanges(params, ctx) {
|
|
124
125
|
this.assertHistoryEnabled();
|
|
125
126
|
const { docId, versionId } = params;
|
|
126
127
|
await this.assertRead(ctx, docId, 'getChangesForVersion', params);
|
|
@@ -165,7 +166,7 @@ export class RPCServer {
|
|
|
165
166
|
async assertAccess(ctx, docId, kind, method, params) {
|
|
166
167
|
const ok = await this.auth.canAccess(ctx, docId, kind, method, params);
|
|
167
168
|
if (!ok) {
|
|
168
|
-
throw new
|
|
169
|
+
throw new StatusError(401, `${kind.toUpperCase()}_FORBIDDEN:${docId}`);
|
|
169
170
|
}
|
|
170
171
|
}
|
|
171
172
|
assertRead(ctx, docId, method, params) {
|
|
@@ -176,12 +177,12 @@ export class RPCServer {
|
|
|
176
177
|
}
|
|
177
178
|
assertHistoryEnabled() {
|
|
178
179
|
if (!this.history) {
|
|
179
|
-
throw new
|
|
180
|
+
throw new StatusError(404, 'History is not enabled');
|
|
180
181
|
}
|
|
181
182
|
}
|
|
182
183
|
assertBranchingEnabled() {
|
|
183
184
|
if (!this.branches) {
|
|
184
|
-
throw new
|
|
185
|
+
throw new StatusError(404, 'Branching is not enabled');
|
|
185
186
|
}
|
|
186
187
|
}
|
|
187
188
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { JSONPatch } from '../json-patch/JSONPatch.js';
|
|
1
2
|
import type { Change, EditableVersionMetadata, PatchesSnapshot, PatchesState, VersionMetadata } from '../types.js';
|
|
2
3
|
import type { PatchesStoreBackend } from './types.js';
|
|
3
4
|
/**
|
|
@@ -25,11 +26,12 @@ export declare class PatchesServer {
|
|
|
25
26
|
readonly onDocDeleted: import("../event-signal.js").Signal<(docId: string, originClientId?: string) => void>;
|
|
26
27
|
constructor(store: PatchesStoreBackend, options?: PatchesServerOptions);
|
|
27
28
|
/**
|
|
28
|
-
* Get the
|
|
29
|
+
* Get the state of a document at a specific revision (or the latest state if no revision is provided).
|
|
29
30
|
* @param docId - The ID of the document.
|
|
30
|
-
* @
|
|
31
|
+
* @param rev - The revision number.
|
|
32
|
+
* @returns The state of the document at the specified revision.
|
|
31
33
|
*/
|
|
32
|
-
getDoc(docId: string, atRev?: number): Promise<
|
|
34
|
+
getDoc(docId: string, atRev?: number): Promise<PatchesState>;
|
|
33
35
|
/**
|
|
34
36
|
* Get changes that occurred after a specific revision.
|
|
35
37
|
* @param docId - The ID of the document.
|
|
@@ -47,6 +49,12 @@ export declare class PatchesServer {
|
|
|
47
49
|
* - transformedChanges: The client's changes after being transformed against concurrent changes
|
|
48
50
|
*/
|
|
49
51
|
commitChanges(docId: string, changes: Change[], originClientId?: string): Promise<[Change[], Change[]]>;
|
|
52
|
+
/**
|
|
53
|
+
* Make a server-side change to a document.
|
|
54
|
+
* @param mutator
|
|
55
|
+
* @returns
|
|
56
|
+
*/
|
|
57
|
+
change<T = Record<string, any>>(docId: string, mutator: (draft: T, patch: JSONPatch) => void, metadata?: Record<string, any>): Promise<Change | null>;
|
|
50
58
|
/**
|
|
51
59
|
* Deletes a document.
|
|
52
60
|
* @param docId The document ID.
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createId } from 'crypto-id';
|
|
2
2
|
import { signal } from '../event-signal.js';
|
|
3
3
|
import { applyPatch } from '../json-patch/applyPatch.js';
|
|
4
|
+
import { createJSONPatch } from '../json-patch/createJSONPatch.js';
|
|
4
5
|
import { transformPatch } from '../json-patch/transformPatch.js';
|
|
5
6
|
import { applyChanges } from '../utils.js';
|
|
6
7
|
/**
|
|
@@ -18,12 +19,13 @@ export class PatchesServer {
|
|
|
18
19
|
this.sessionTimeoutMillis = (options.sessionTimeoutMinutes ?? 30) * 60 * 1000;
|
|
19
20
|
}
|
|
20
21
|
/**
|
|
21
|
-
* Get the
|
|
22
|
+
* Get the state of a document at a specific revision (or the latest state if no revision is provided).
|
|
22
23
|
* @param docId - The ID of the document.
|
|
23
|
-
* @
|
|
24
|
+
* @param rev - The revision number.
|
|
25
|
+
* @returns The state of the document at the specified revision.
|
|
24
26
|
*/
|
|
25
27
|
async getDoc(docId, atRev) {
|
|
26
|
-
return this.
|
|
28
|
+
return this.getStateAtRevision(docId, atRev);
|
|
27
29
|
}
|
|
28
30
|
/**
|
|
29
31
|
* Get changes that occurred after a specific revision.
|
|
@@ -55,7 +57,7 @@ export class PatchesServer {
|
|
|
55
57
|
}
|
|
56
58
|
// Add check for inconsistent baseRev within the batch if needed
|
|
57
59
|
if (changes.some(c => c.baseRev !== baseRev)) {
|
|
58
|
-
throw new Error(`Client changes must have consistent baseRev for doc ${docId}.`);
|
|
60
|
+
throw new Error(`Client changes must have consistent baseRev in all changes for doc ${docId}.`);
|
|
59
61
|
}
|
|
60
62
|
// 1. Load server state details (assuming store methods exist)
|
|
61
63
|
let { state: currentState, rev: currentRev, changes: currentChanges } = await this._getSnapshotAtRevision(docId);
|
|
@@ -66,14 +68,12 @@ export class PatchesServer {
|
|
|
66
68
|
throw new Error(`Client baseRev (${baseRev}) is ahead of server revision (${currentRev}) for doc ${docId}. Client needs to reload the document.`);
|
|
67
69
|
}
|
|
68
70
|
const partOfInitialBatch = batchId && changes[0].rev > 1;
|
|
69
|
-
if (baseRev === 0 && currentRev > 0 && !partOfInitialBatch) {
|
|
71
|
+
if (baseRev === 0 && currentRev > 0 && !partOfInitialBatch && changes[0].ops[0].path === '') {
|
|
70
72
|
throw new Error(`Client baseRev is 0 but server has already been created for doc ${docId}. Client needs to load the existing document.`);
|
|
71
73
|
}
|
|
72
74
|
// Ensure all new changes' `created` field is in the past, that each `rev` is correct, and that `baseRev` is set
|
|
73
|
-
let rev = baseRev + 1;
|
|
74
75
|
changes.forEach(c => {
|
|
75
76
|
c.created = Math.min(c.created, Date.now());
|
|
76
|
-
c.rev = rev++;
|
|
77
77
|
c.baseRev = baseRev;
|
|
78
78
|
});
|
|
79
79
|
// 2. Check if we need to create a new version - if the last change was created more than a session ago
|
|
@@ -103,6 +103,7 @@ export class PatchesServer {
|
|
|
103
103
|
// against committed changes that happened *after* the client's baseRev.
|
|
104
104
|
// The state used for transformation should be the server state *at the client's baseRev*.
|
|
105
105
|
let stateAtBaseRev = (await this.getStateAtRevision(docId, baseRev)).state;
|
|
106
|
+
let rev = currentRev + 1;
|
|
106
107
|
const committedOps = committedChanges.flatMap(c => c.ops);
|
|
107
108
|
// Apply transformation based on state at baseRev
|
|
108
109
|
const transformedChanges = changes
|
|
@@ -113,14 +114,19 @@ export class PatchesServer {
|
|
|
113
114
|
return null; // Change is obsolete after transformation
|
|
114
115
|
}
|
|
115
116
|
try {
|
|
117
|
+
const previous = stateAtBaseRev;
|
|
116
118
|
stateAtBaseRev = applyPatch(stateAtBaseRev, change.ops, { strict: true });
|
|
119
|
+
if (previous === stateAtBaseRev) {
|
|
120
|
+
// Changes were no-ops, we can skip this change
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
117
123
|
}
|
|
118
124
|
catch (error) {
|
|
119
125
|
console.error(`Error applying change ${change.id} to state:`, error);
|
|
120
126
|
return null;
|
|
121
127
|
}
|
|
122
128
|
// Return a new change object with transformed ops and original metadata
|
|
123
|
-
return { ...change, ops: transformedOps };
|
|
129
|
+
return { ...change, rev: rev++, ops: transformedOps };
|
|
124
130
|
})
|
|
125
131
|
.filter(Boolean);
|
|
126
132
|
// Persist the newly transformed changes
|
|
@@ -134,6 +140,31 @@ export class PatchesServer {
|
|
|
134
140
|
// Return committed changes and newly transformed changes separately
|
|
135
141
|
return [committedChanges, transformedChanges];
|
|
136
142
|
}
|
|
143
|
+
/**
|
|
144
|
+
* Make a server-side change to a document.
|
|
145
|
+
* @param mutator
|
|
146
|
+
* @returns
|
|
147
|
+
*/
|
|
148
|
+
async change(docId, mutator, metadata) {
|
|
149
|
+
const { state, rev } = await this.getDoc(docId);
|
|
150
|
+
const patch = createJSONPatch(state, mutator);
|
|
151
|
+
if (patch.ops.length === 0) {
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
// It's the baseRev that matters for sending.
|
|
155
|
+
const change = {
|
|
156
|
+
id: createId(),
|
|
157
|
+
ops: patch.ops,
|
|
158
|
+
baseRev: rev,
|
|
159
|
+
rev: rev + 1,
|
|
160
|
+
created: Date.now(),
|
|
161
|
+
...metadata,
|
|
162
|
+
};
|
|
163
|
+
// Apply to local state to ensure no errors are thrown
|
|
164
|
+
patch.apply(state);
|
|
165
|
+
await this.commitChanges(docId, [change]);
|
|
166
|
+
return change;
|
|
167
|
+
}
|
|
137
168
|
/**
|
|
138
169
|
* Deletes a document.
|
|
139
170
|
* @param docId The document ID.
|
package/dist/types.d.ts
CHANGED
|
@@ -7,7 +7,7 @@ export interface Change {
|
|
|
7
7
|
/** The revision number assigned on the client to the optimistic revision and updated by the server after commit. */
|
|
8
8
|
rev: number;
|
|
9
9
|
/** The server revision this change was based on. Required for client->server changes. */
|
|
10
|
-
baseRev
|
|
10
|
+
baseRev: number;
|
|
11
11
|
/** Client-side timestamp when the change was created. */
|
|
12
12
|
created: number;
|
|
13
13
|
/** Optional batch identifier for grouping changes that belong to the same client batch (for multi-batch offline/large edits). */
|
package/dist/utils.d.ts
CHANGED
|
@@ -1,13 +1,4 @@
|
|
|
1
1
|
import type { Change, Deferred } from './types.js';
|
|
2
|
-
/**
|
|
3
|
-
* Splits an array of changes into two arrays based on the presence of a baseRev.
|
|
4
|
-
* The first array contains changes before the first change with a baseRev,
|
|
5
|
-
* and the second array contains the change with baseRev and all subsequent changes.
|
|
6
|
-
*
|
|
7
|
-
* @param changes - Array of changes to split
|
|
8
|
-
* @returns A tuple containing [changes before baseRev, changes with and after baseRev]
|
|
9
|
-
*/
|
|
10
|
-
export declare function splitChanges(changes: Change[]): [Change[], Change[]];
|
|
11
2
|
/**
|
|
12
3
|
* Applies a sequence of changes to a state object.
|
|
13
4
|
* Each change is applied in sequence using the applyPatch function.
|
package/dist/utils.js
CHANGED
|
@@ -1,17 +1,5 @@
|
|
|
1
1
|
import { applyPatch } from './json-patch/applyPatch.js';
|
|
2
2
|
import { JSONPatch } from './json-patch/JSONPatch.js';
|
|
3
|
-
/**
|
|
4
|
-
* Splits an array of changes into two arrays based on the presence of a baseRev.
|
|
5
|
-
* The first array contains changes before the first change with a baseRev,
|
|
6
|
-
* and the second array contains the change with baseRev and all subsequent changes.
|
|
7
|
-
*
|
|
8
|
-
* @param changes - Array of changes to split
|
|
9
|
-
* @returns A tuple containing [changes before baseRev, changes with and after baseRev]
|
|
10
|
-
*/
|
|
11
|
-
export function splitChanges(changes) {
|
|
12
|
-
const index = changes.findIndex(c => c.baseRev);
|
|
13
|
-
return [changes.slice(0, index), changes.slice(index)];
|
|
14
|
-
}
|
|
15
3
|
/**
|
|
16
4
|
* Applies a sequence of changes to a state object.
|
|
17
5
|
* Each change is applied in sequence using the applyPatch function.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dabble/patches",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.28",
|
|
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": {
|