@dabble/patches 0.8.7 → 0.8.9
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/algorithms/ot/shared/changeBatching.js +30 -71
- package/dist/client/BranchClientStore.d.ts +71 -0
- package/dist/client/BranchClientStore.js +0 -0
- package/dist/client/ClientAlgorithm.d.ts +12 -0
- package/dist/client/IndexedDBStore.d.ts +13 -2
- package/dist/client/IndexedDBStore.js +125 -1
- package/dist/client/LWWInMemoryStore.js +5 -3
- package/dist/client/LWWIndexedDBStore.d.ts +1 -0
- package/dist/client/LWWIndexedDBStore.js +14 -5
- package/dist/client/OTAlgorithm.d.ts +3 -0
- package/dist/client/OTAlgorithm.js +4 -0
- package/dist/client/OTClientStore.d.ts +11 -0
- package/dist/client/OTIndexedDBStore.d.ts +9 -0
- package/dist/client/OTIndexedDBStore.js +15 -3
- package/dist/client/Patches.d.ts +6 -0
- package/dist/client/Patches.js +11 -0
- package/dist/client/PatchesBranchClient.d.ts +92 -0
- package/dist/client/PatchesBranchClient.js +170 -0
- package/dist/client/index.d.ts +3 -0
- package/dist/client/index.js +1 -0
- package/dist/compression/index.d.ts +15 -7
- package/dist/compression/index.js +13 -2
- package/dist/index.d.ts +4 -1
- package/dist/net/PatchesClient.d.ts +13 -8
- package/dist/net/PatchesClient.js +15 -8
- package/dist/net/PatchesSync.d.ts +36 -3
- package/dist/net/PatchesSync.js +72 -0
- package/dist/net/index.d.ts +3 -2
- package/dist/net/protocol/types.d.ts +9 -2
- package/dist/net/rest/PatchesREST.d.ts +8 -5
- package/dist/net/rest/PatchesREST.js +29 -19
- package/dist/net/rest/SSEServer.js +1 -1
- package/dist/net/rest/index.d.ts +1 -1
- package/dist/net/rest/index.js +1 -2
- package/dist/net/rest/utils.d.ts +1 -9
- package/dist/net/rest/utils.js +0 -4
- package/dist/server/BranchManager.d.ts +11 -7
- package/dist/server/LWWBranchManager.d.ts +10 -9
- package/dist/server/LWWBranchManager.js +47 -30
- package/dist/server/LWWMemoryStoreBackend.d.ts +5 -2
- package/dist/server/LWWMemoryStoreBackend.js +21 -3
- package/dist/server/OTBranchManager.d.ts +14 -10
- package/dist/server/OTBranchManager.js +65 -63
- package/dist/server/branchUtils.d.ts +7 -15
- package/dist/server/branchUtils.js +18 -14
- package/dist/server/index.d.ts +1 -1
- package/dist/server/index.js +2 -2
- package/dist/server/types.d.ts +10 -4
- package/dist/solid/context.d.ts +1 -0
- package/dist/solid/index.d.ts +1 -0
- package/dist/types.d.ts +43 -6
- package/dist/vue/index.d.ts +1 -0
- package/dist/vue/provider.d.ts +1 -0
- package/package.json +1 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Unsubscriber } from 'easy-signal';
|
|
2
|
-
import { PatchesState, Change, ChangeInput, CommitChangesOptions, EditableVersionMetadata, ListVersionsOptions, VersionMetadata } from '../../types.js';
|
|
2
|
+
import { ListBranchesOptions, Branch, CreateBranchMetadata, EditableBranchMetadata, PatchesState, Change, ChangeInput, CommitChangesOptions, EditableVersionMetadata, ListVersionsOptions, VersionMetadata } from '../../types.js';
|
|
3
3
|
import '../../json-patch/JSONPatch.js';
|
|
4
4
|
import '@dabble/delta';
|
|
5
5
|
import '../../json-patch/types.js';
|
|
@@ -131,6 +131,13 @@ interface PatchesAPI {
|
|
|
131
131
|
/** Update the name and other metadata of a specific version. */
|
|
132
132
|
updateVersion(docId: string, versionId: string, metadata: EditableVersionMetadata): Promise<void>;
|
|
133
133
|
}
|
|
134
|
+
interface BranchAPI {
|
|
135
|
+
listBranches(docId: string, options?: ListBranchesOptions): Promise<Branch[]>;
|
|
136
|
+
createBranch(docId: string, rev: number, metadata?: CreateBranchMetadata): Promise<string>;
|
|
137
|
+
updateBranch(branchId: string, metadata: EditableBranchMetadata): Promise<void>;
|
|
138
|
+
deleteBranch(branchId: string): Promise<void>;
|
|
139
|
+
mergeBranch(branchId: string): Promise<void>;
|
|
140
|
+
}
|
|
134
141
|
interface PatchesNotificationParams {
|
|
135
142
|
docId: string;
|
|
136
143
|
changes: Change[];
|
|
@@ -148,4 +155,4 @@ interface SignalNotificationParams {
|
|
|
148
155
|
data: any;
|
|
149
156
|
}
|
|
150
157
|
|
|
151
|
-
export { type AwarenessUpdateNotificationParams, type ClientTransport, CommitChangesOptions, type ConnectionState, type JsonRpcNotification, type JsonRpcRequest, type JsonRpcResponse, type Message, type PatchesAPI, type PatchesNotificationParams, type ServerTransport, type SignalNotificationParams };
|
|
158
|
+
export { type AwarenessUpdateNotificationParams, type BranchAPI, type ClientTransport, CommitChangesOptions, type ConnectionState, type JsonRpcNotification, type JsonRpcRequest, type JsonRpcResponse, type Message, type PatchesAPI, type PatchesNotificationParams, type ServerTransport, type SignalNotificationParams };
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as easy_signal from 'easy-signal';
|
|
2
|
-
import { Change, CommitChangesOptions, PatchesState, ChangeInput, DeleteDocOptions, EditableVersionMetadata, ListVersionsOptions, VersionMetadata, PatchesSnapshot, Branch } from '../../types.js';
|
|
2
|
+
import { Change, CommitChangesOptions, PatchesState, ChangeInput, DeleteDocOptions, EditableVersionMetadata, ListVersionsOptions, VersionMetadata, PatchesSnapshot, Branch, CreateBranchMetadata, EditableBranchMetadata } from '../../types.js';
|
|
3
3
|
import { PatchesConnection } from '../PatchesConnection.js';
|
|
4
4
|
import { ConnectionState } from '../protocol/types.js';
|
|
5
5
|
import '../../json-patch/JSONPatch.js';
|
|
@@ -61,10 +61,13 @@ declare class PatchesREST implements PatchesConnection {
|
|
|
61
61
|
getVersionState(docId: string, versionId: string): Promise<PatchesSnapshot>;
|
|
62
62
|
getVersionChanges(docId: string, versionId: string): Promise<Change[]>;
|
|
63
63
|
updateVersion(docId: string, versionId: string, metadata: EditableVersionMetadata): Promise<void>;
|
|
64
|
-
listBranches(docId: string
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
64
|
+
listBranches(docId: string, options?: {
|
|
65
|
+
since?: number;
|
|
66
|
+
}): Promise<Branch[]>;
|
|
67
|
+
createBranch(docId: string, rev: number, metadata?: CreateBranchMetadata): Promise<string>;
|
|
68
|
+
updateBranch(docId: string, branchId: string, metadata: EditableBranchMetadata): Promise<void>;
|
|
69
|
+
deleteBranch(docId: string, branchId: string): Promise<void>;
|
|
70
|
+
mergeBranch(docId: string, branchId: string): Promise<void>;
|
|
68
71
|
private _setState;
|
|
69
72
|
private _getHeaders;
|
|
70
73
|
private _fetch;
|
|
@@ -2,7 +2,7 @@ import "../../chunk-IZ2YBCUP.js";
|
|
|
2
2
|
import { signal } from "easy-signal";
|
|
3
3
|
import { StatusError } from "../error.js";
|
|
4
4
|
import { onlineState } from "../websocket/onlineState.js";
|
|
5
|
-
import {
|
|
5
|
+
import { normalizeIds } from "./utils.js";
|
|
6
6
|
const SESSION_STORAGE_KEY = "patches-clientId";
|
|
7
7
|
class PatchesREST {
|
|
8
8
|
/** The client ID used for SSE connection and subscription management. */
|
|
@@ -114,58 +114,68 @@ class PatchesREST {
|
|
|
114
114
|
}
|
|
115
115
|
// --- PatchesAPI: Documents ---
|
|
116
116
|
async getDoc(docId) {
|
|
117
|
-
return this._fetch(`/docs/${
|
|
117
|
+
return this._fetch(`/docs/${docId}`);
|
|
118
118
|
}
|
|
119
119
|
async getChangesSince(docId, rev) {
|
|
120
|
-
return this._fetch(`/docs/${
|
|
120
|
+
return this._fetch(`/docs/${docId}/_changes?since=${rev}`);
|
|
121
121
|
}
|
|
122
122
|
async commitChanges(docId, changes, options) {
|
|
123
|
-
return this._fetch(`/docs/${
|
|
123
|
+
return this._fetch(`/docs/${docId}/_changes`, {
|
|
124
124
|
method: "POST",
|
|
125
125
|
body: { changes, options }
|
|
126
126
|
});
|
|
127
127
|
}
|
|
128
128
|
async deleteDoc(docId, _options) {
|
|
129
|
-
await this._fetch(`/docs/${
|
|
129
|
+
await this._fetch(`/docs/${docId}`, { method: "DELETE" });
|
|
130
130
|
}
|
|
131
131
|
// --- PatchesAPI: Versions ---
|
|
132
132
|
async createVersion(docId, metadata) {
|
|
133
|
-
return this._fetch(`/docs/${
|
|
133
|
+
return this._fetch(`/docs/${docId}/_versions`, {
|
|
134
134
|
method: "POST",
|
|
135
135
|
body: metadata
|
|
136
136
|
});
|
|
137
137
|
}
|
|
138
138
|
async listVersions(docId, options) {
|
|
139
139
|
const params = options ? `?${new URLSearchParams(options)}` : "";
|
|
140
|
-
return this._fetch(`/docs/${
|
|
140
|
+
return this._fetch(`/docs/${docId}/_versions${params}`);
|
|
141
141
|
}
|
|
142
142
|
async getVersionState(docId, versionId) {
|
|
143
|
-
return this._fetch(`/docs/${
|
|
143
|
+
return this._fetch(`/docs/${docId}/_versions/${encodeURIComponent(versionId)}`);
|
|
144
144
|
}
|
|
145
145
|
async getVersionChanges(docId, versionId) {
|
|
146
|
-
return this._fetch(`/docs/${
|
|
146
|
+
return this._fetch(`/docs/${docId}/_versions/${encodeURIComponent(versionId)}/_changes`);
|
|
147
147
|
}
|
|
148
148
|
async updateVersion(docId, versionId, metadata) {
|
|
149
|
-
await this._fetch(`/docs/${
|
|
149
|
+
await this._fetch(`/docs/${docId}/_versions/${encodeURIComponent(versionId)}`, {
|
|
150
150
|
method: "PUT",
|
|
151
151
|
body: metadata
|
|
152
152
|
});
|
|
153
153
|
}
|
|
154
|
-
// --- Branch Operations
|
|
155
|
-
|
|
156
|
-
|
|
154
|
+
// --- Branch Operations ---
|
|
155
|
+
// Note: updateBranch, deleteBranch, and mergeBranch take (docId, branchId) rather than
|
|
156
|
+
// matching BranchAPI signatures, because the REST URL pattern is /docs/:docId/_branches/:branchId.
|
|
157
|
+
// Apps using PatchesREST as a branchApi need a thin adapter to bridge the difference.
|
|
158
|
+
async listBranches(docId, options) {
|
|
159
|
+
const params = options?.since ? `?since=${encodeURIComponent(String(options.since))}` : "";
|
|
160
|
+
return this._fetch(`/docs/${docId}/_branches${params}`);
|
|
157
161
|
}
|
|
158
162
|
async createBranch(docId, rev, metadata) {
|
|
159
|
-
return this._fetch(`/docs/${
|
|
163
|
+
return this._fetch(`/docs/${docId}/_branches`, {
|
|
160
164
|
method: "POST",
|
|
161
|
-
body: { rev, ...metadata }
|
|
165
|
+
body: { branchedAtRev: rev, ...metadata }
|
|
162
166
|
});
|
|
163
167
|
}
|
|
164
|
-
async
|
|
165
|
-
await this._fetch(`/docs/${
|
|
168
|
+
async updateBranch(docId, branchId, metadata) {
|
|
169
|
+
await this._fetch(`/docs/${docId}/_branches/${encodeURIComponent(branchId)}`, {
|
|
170
|
+
method: "PUT",
|
|
171
|
+
body: metadata
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
async deleteBranch(docId, branchId) {
|
|
175
|
+
await this._fetch(`/docs/${docId}/_branches/${encodeURIComponent(branchId)}`, { method: "DELETE" });
|
|
166
176
|
}
|
|
167
|
-
async mergeBranch(branchId) {
|
|
168
|
-
await this._fetch(`/docs/${
|
|
177
|
+
async mergeBranch(docId, branchId) {
|
|
178
|
+
await this._fetch(`/docs/${docId}/_branches/${encodeURIComponent(branchId)}/_merge`, { method: "POST" });
|
|
169
179
|
}
|
|
170
180
|
// --- Private Helpers ---
|
|
171
181
|
_setState(state) {
|
|
@@ -52,7 +52,7 @@ class SSEServer {
|
|
|
52
52
|
}
|
|
53
53
|
const { readable, writable } = new TransformStream();
|
|
54
54
|
client.writer = writable.getWriter();
|
|
55
|
-
client.writer.write(client.encoder.encode(":
|
|
55
|
+
client.writer.write(client.encoder.encode("retry: 5000\n\n"));
|
|
56
56
|
if (lastEventId) {
|
|
57
57
|
const lastId = parseInt(lastEventId, 10);
|
|
58
58
|
if (isNaN(lastId)) {
|
package/dist/net/rest/index.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export { PatchesREST, PatchesRESTOptions } from './PatchesREST.js';
|
|
2
2
|
export { BufferedEvent, SSEServer, SSEServerOptions } from './SSEServer.js';
|
|
3
|
-
export {
|
|
3
|
+
export { normalizeIds } from './utils.js';
|
|
4
4
|
import 'easy-signal';
|
|
5
5
|
import '../../types.js';
|
|
6
6
|
import '../../json-patch/JSONPatch.js';
|
package/dist/net/rest/index.js
CHANGED
package/dist/net/rest/utils.d.ts
CHANGED
|
@@ -1,14 +1,6 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Encode a hierarchical doc ID for use in URL path segments.
|
|
3
|
-
* Each segment is individually encoded so slashes are preserved as path separators.
|
|
4
|
-
*
|
|
5
|
-
* @example encodeDocId('users/abc/stats/2026-01') => 'users/abc/stats/2026-01'
|
|
6
|
-
* @example encodeDocId('docs/hello world') => 'docs/hello%20world'
|
|
7
|
-
*/
|
|
8
|
-
declare function encodeDocId(docId: string): string;
|
|
9
1
|
/**
|
|
10
2
|
* Normalize a string or string array to a string array.
|
|
11
3
|
*/
|
|
12
4
|
declare function normalizeIds(ids: string | string[]): string[];
|
|
13
5
|
|
|
14
|
-
export {
|
|
6
|
+
export { normalizeIds };
|
package/dist/net/rest/utils.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Branch,
|
|
1
|
+
import { ListBranchesOptions, Branch, CreateBranchMetadata, EditableBranchMetadata, Change } from '../types.js';
|
|
2
2
|
import '../json-patch/JSONPatch.js';
|
|
3
3
|
import '@dabble/delta';
|
|
4
4
|
import '../json-patch/types.js';
|
|
@@ -15,17 +15,20 @@ interface BranchManager {
|
|
|
15
15
|
/**
|
|
16
16
|
* Lists all branches for a document.
|
|
17
17
|
* @param docId - The source document ID.
|
|
18
|
+
* @param options - Optional filtering options (e.g. `since` for incremental sync).
|
|
18
19
|
* @returns Array of branch metadata.
|
|
19
20
|
*/
|
|
20
|
-
listBranches(docId: string): Promise<Branch[]>;
|
|
21
|
+
listBranches(docId: string, options?: ListBranchesOptions): Promise<Branch[]>;
|
|
21
22
|
/**
|
|
22
23
|
* Creates a new branch from a document.
|
|
23
24
|
* @param docId - The source document ID.
|
|
24
25
|
* @param atPoint - Algorithm-specific branching point (revision for OT, typically current rev for LWW).
|
|
25
26
|
* @param metadata - Optional branch metadata (name, custom fields).
|
|
27
|
+
* When `contentStartRev` is set, the server skips generating initial changes
|
|
28
|
+
* (the client has already created them and will sync them as regular document changes).
|
|
26
29
|
* @returns The new branch document ID.
|
|
27
30
|
*/
|
|
28
|
-
createBranch(docId: string, atPoint: number, metadata?:
|
|
31
|
+
createBranch(docId: string, atPoint: number, metadata?: CreateBranchMetadata): Promise<string>;
|
|
29
32
|
/**
|
|
30
33
|
* Updates branch metadata.
|
|
31
34
|
* @param branchId - The branch document ID.
|
|
@@ -33,11 +36,12 @@ interface BranchManager {
|
|
|
33
36
|
*/
|
|
34
37
|
updateBranch(branchId: string, metadata: EditableBranchMetadata): Promise<void>;
|
|
35
38
|
/**
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
+
* Deletes a branch, replacing it with a tombstone record.
|
|
40
|
+
* The tombstone preserves `id`, `docId`, `modifiedAt`, and `deleted: true`
|
|
41
|
+
* so that clients using incremental sync (`since`) can clean up their local cache.
|
|
42
|
+
* @param branchId - The branch document ID to delete.
|
|
39
43
|
*/
|
|
40
|
-
|
|
44
|
+
deleteBranch(branchId: string): Promise<void>;
|
|
41
45
|
/**
|
|
42
46
|
* Merges a branch back into its source document.
|
|
43
47
|
* Algorithm-specific: OT uses fast-forward or flattened merge, LWW uses timestamp resolution.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { ApiDefinition } from '../net/protocol/JSONRPCServer.js';
|
|
2
|
-
import { Branch,
|
|
2
|
+
import { ListBranchesOptions, Branch, CreateBranchMetadata, EditableBranchMetadata, Change } from '../types.js';
|
|
3
3
|
import { BranchManager } from './BranchManager.js';
|
|
4
4
|
import { LWWServer } from './LWWServer.js';
|
|
5
5
|
import { LWWStoreBackend, BranchingStoreBackend } from './types.js';
|
|
@@ -37,9 +37,10 @@ declare class LWWBranchManager implements BranchManager {
|
|
|
37
37
|
/**
|
|
38
38
|
* Lists all branches for a document.
|
|
39
39
|
* @param docId - The source document ID.
|
|
40
|
+
* @param options - Optional filtering options (e.g. `since` for incremental sync).
|
|
40
41
|
* @returns Array of branch metadata.
|
|
41
42
|
*/
|
|
42
|
-
listBranches(docId: string): Promise<Branch[]>;
|
|
43
|
+
listBranches(docId: string, options?: ListBranchesOptions): Promise<Branch[]>;
|
|
43
44
|
/**
|
|
44
45
|
* Creates a new branch from a document's current state.
|
|
45
46
|
*
|
|
@@ -49,10 +50,9 @@ declare class LWWBranchManager implements BranchManager {
|
|
|
49
50
|
*
|
|
50
51
|
* @param docId - The source document ID.
|
|
51
52
|
* @param atPoint - The revision number (recorded for tracking).
|
|
52
|
-
* @param metadata - Optional branch metadata.
|
|
53
53
|
* @returns The new branch document ID.
|
|
54
54
|
*/
|
|
55
|
-
createBranch(docId: string, atPoint: number, metadata?:
|
|
55
|
+
createBranch(docId: string, atPoint: number, metadata?: CreateBranchMetadata): Promise<string>;
|
|
56
56
|
/**
|
|
57
57
|
* Updates branch metadata.
|
|
58
58
|
* @param branchId - The branch document ID.
|
|
@@ -60,16 +60,17 @@ declare class LWWBranchManager implements BranchManager {
|
|
|
60
60
|
*/
|
|
61
61
|
updateBranch(branchId: string, metadata: EditableBranchMetadata): Promise<void>;
|
|
62
62
|
/**
|
|
63
|
-
*
|
|
64
|
-
* @param branchId - The branch document ID.
|
|
65
|
-
* @param status - The status to set (defaults to 'closed').
|
|
63
|
+
* Deletes a branch, replacing the record with a tombstone.
|
|
66
64
|
*/
|
|
67
|
-
|
|
65
|
+
deleteBranch(branchId: string): Promise<void>;
|
|
68
66
|
/**
|
|
69
67
|
* Merges a branch back into its source document.
|
|
70
68
|
*
|
|
69
|
+
* Supports multiple merges — the branch stays open and `lastMergedRev` tracks
|
|
70
|
+
* which branch revision was last merged. Subsequent merges only pick up new changes.
|
|
71
|
+
*
|
|
71
72
|
* LWW merge algorithm:
|
|
72
|
-
* 1. Get
|
|
73
|
+
* 1. Get ops changes made on the branch since last merge (or since creation)
|
|
73
74
|
* 2. Apply those changes to the source document
|
|
74
75
|
* 3. Timestamps automatically resolve any conflicts (later wins)
|
|
75
76
|
*
|
|
@@ -2,7 +2,7 @@ import "../chunk-IZ2YBCUP.js";
|
|
|
2
2
|
import { JSONPatch } from "../json-patch/JSONPatch.js";
|
|
3
3
|
import {
|
|
4
4
|
assertBranchMetadata,
|
|
5
|
-
|
|
5
|
+
assertBranchExists,
|
|
6
6
|
assertNotABranch,
|
|
7
7
|
branchManagerApi,
|
|
8
8
|
createBranchRecord,
|
|
@@ -19,10 +19,11 @@ class LWWBranchManager {
|
|
|
19
19
|
/**
|
|
20
20
|
* Lists all branches for a document.
|
|
21
21
|
* @param docId - The source document ID.
|
|
22
|
+
* @param options - Optional filtering options (e.g. `since` for incremental sync).
|
|
22
23
|
* @returns Array of branch metadata.
|
|
23
24
|
*/
|
|
24
|
-
async listBranches(docId) {
|
|
25
|
-
return await this.store.listBranches(docId);
|
|
25
|
+
async listBranches(docId, options) {
|
|
26
|
+
return await this.store.listBranches(docId, options);
|
|
26
27
|
}
|
|
27
28
|
/**
|
|
28
29
|
* Creates a new branch from a document's current state.
|
|
@@ -33,26 +34,39 @@ class LWWBranchManager {
|
|
|
33
34
|
*
|
|
34
35
|
* @param docId - The source document ID.
|
|
35
36
|
* @param atPoint - The revision number (recorded for tracking).
|
|
36
|
-
* @param metadata - Optional branch metadata.
|
|
37
37
|
* @returns The new branch document ID.
|
|
38
38
|
*/
|
|
39
39
|
async createBranch(docId, atPoint, metadata) {
|
|
40
|
-
await
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
40
|
+
const branchDocId = metadata?.id ?? await generateBranchId(this.store, docId);
|
|
41
|
+
if (metadata?.id) {
|
|
42
|
+
const existing = await this.store.loadBranch(branchDocId);
|
|
43
|
+
if (existing) {
|
|
44
|
+
if (existing.docId !== docId) {
|
|
45
|
+
throw new Error(`Branch ${branchDocId} already exists for a different document`);
|
|
46
|
+
}
|
|
47
|
+
return branchDocId;
|
|
48
|
+
}
|
|
48
49
|
}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
50
|
+
await assertNotABranch(this.store, docId);
|
|
51
|
+
if (!metadata?.contentStartRev) {
|
|
52
|
+
const snapshot = await this.store.getSnapshot(docId);
|
|
53
|
+
const baseRev = snapshot?.rev ?? 0;
|
|
54
|
+
let state = snapshot ? JSON.parse(await readStreamAsString(snapshot.state)) : {};
|
|
55
|
+
const ops = await this.store.listOps(docId);
|
|
56
|
+
const opsAfterSnapshot = ops.filter((op) => (op.rev ?? 0) > baseRev);
|
|
57
|
+
if (opsAfterSnapshot.length > 0) {
|
|
58
|
+
state = new JSONPatch(opsAfterSnapshot).apply(state);
|
|
59
|
+
}
|
|
60
|
+
const rev = ops.length > 0 ? Math.max(baseRev, ...ops.map((op) => op.rev ?? 0)) : baseRev;
|
|
61
|
+
await this.store.saveSnapshot(branchDocId, state, rev);
|
|
62
|
+
if (ops.length > 0) {
|
|
63
|
+
await this.store.saveOps(branchDocId, ops);
|
|
64
|
+
}
|
|
65
|
+
const branch2 = createBranchRecord(branchDocId, docId, atPoint, rev + 1, metadata);
|
|
66
|
+
await this.store.createBranch(branch2);
|
|
67
|
+
return branchDocId;
|
|
54
68
|
}
|
|
55
|
-
const branch = createBranchRecord(branchDocId, docId, atPoint, metadata);
|
|
69
|
+
const branch = createBranchRecord(branchDocId, docId, atPoint, metadata.contentStartRev, metadata);
|
|
56
70
|
await this.store.createBranch(branch);
|
|
57
71
|
return branchDocId;
|
|
58
72
|
}
|
|
@@ -63,21 +77,22 @@ class LWWBranchManager {
|
|
|
63
77
|
*/
|
|
64
78
|
async updateBranch(branchId, metadata) {
|
|
65
79
|
assertBranchMetadata(metadata);
|
|
66
|
-
await this.store.updateBranch(branchId, metadata);
|
|
80
|
+
await this.store.updateBranch(branchId, { ...metadata, modifiedAt: Date.now() });
|
|
67
81
|
}
|
|
68
82
|
/**
|
|
69
|
-
*
|
|
70
|
-
* @param branchId - The branch document ID.
|
|
71
|
-
* @param status - The status to set (defaults to 'closed').
|
|
83
|
+
* Deletes a branch, replacing the record with a tombstone.
|
|
72
84
|
*/
|
|
73
|
-
async
|
|
74
|
-
await this.store.
|
|
85
|
+
async deleteBranch(branchId) {
|
|
86
|
+
await this.store.deleteBranch(branchId);
|
|
75
87
|
}
|
|
76
88
|
/**
|
|
77
89
|
* Merges a branch back into its source document.
|
|
78
90
|
*
|
|
91
|
+
* Supports multiple merges — the branch stays open and `lastMergedRev` tracks
|
|
92
|
+
* which branch revision was last merged. Subsequent merges only pick up new changes.
|
|
93
|
+
*
|
|
79
94
|
* LWW merge algorithm:
|
|
80
|
-
* 1. Get
|
|
95
|
+
* 1. Get ops changes made on the branch since last merge (or since creation)
|
|
81
96
|
* 2. Apply those changes to the source document
|
|
82
97
|
* 3. Timestamps automatically resolve any conflicts (later wins)
|
|
83
98
|
*
|
|
@@ -86,20 +101,22 @@ class LWWBranchManager {
|
|
|
86
101
|
*/
|
|
87
102
|
async mergeBranch(branchId) {
|
|
88
103
|
const branch = await this.store.loadBranch(branchId);
|
|
89
|
-
|
|
104
|
+
assertBranchExists(branch, branchId);
|
|
90
105
|
const sourceDocId = branch.docId;
|
|
91
|
-
const
|
|
106
|
+
const sinceRev = branch.lastMergedRev ?? (branch.contentStartRev ?? 1) - 1;
|
|
107
|
+
const branchChanges = await this.lwwServer.getChangesSince(branchId, sinceRev);
|
|
92
108
|
if (branchChanges.length === 0) {
|
|
93
|
-
console.log(`Branch ${branchId} has no changes to merge.`);
|
|
94
|
-
await this.closeBranch(branchId, "merged");
|
|
95
109
|
return [];
|
|
96
110
|
}
|
|
111
|
+
const lastBranchRev = branchChanges[branchChanges.length - 1].rev;
|
|
97
112
|
const { changes: committedChanges } = await wrapMergeCommit(
|
|
98
113
|
branchId,
|
|
99
114
|
sourceDocId,
|
|
100
115
|
() => this.lwwServer.commitChanges(sourceDocId, branchChanges)
|
|
101
116
|
);
|
|
102
|
-
await this.
|
|
117
|
+
const currentBranch = await this.store.loadBranch(branchId);
|
|
118
|
+
const effectiveLastMergedRev = Math.max(lastBranchRev, currentBranch?.lastMergedRev ?? 0);
|
|
119
|
+
await this.store.updateBranch(branchId, { lastMergedRev: effectiveLastMergedRev, modifiedAt: Date.now() });
|
|
103
120
|
return committedChanges;
|
|
104
121
|
}
|
|
105
122
|
}
|
|
@@ -49,10 +49,13 @@ declare class LWWMemoryStoreBackend implements LWWStoreBackend, VersioningStoreB
|
|
|
49
49
|
loadVersion(docId: string, versionId: string): Promise<VersionMetadata | undefined>;
|
|
50
50
|
loadVersionState(_docId: string, _versionId: string): Promise<string | undefined>;
|
|
51
51
|
updateVersion(docId: string, versionId: string, metadata: EditableVersionMetadata): Promise<void>;
|
|
52
|
-
listBranches(docId: string
|
|
52
|
+
listBranches(docId: string, options?: {
|
|
53
|
+
since?: number;
|
|
54
|
+
}): Promise<Branch[]>;
|
|
53
55
|
loadBranch(branchId: string): Promise<Branch | null>;
|
|
54
56
|
createBranch(branch: Branch): Promise<void>;
|
|
55
|
-
updateBranch(branchId: string, updates: Partial<Pick<Branch, '
|
|
57
|
+
updateBranch(branchId: string, updates: Partial<Pick<Branch, 'name' | 'metadata'>>): Promise<void>;
|
|
58
|
+
deleteBranch(branchId: string): Promise<void>;
|
|
56
59
|
/**
|
|
57
60
|
* Clears all data from the store. Useful for test cleanup.
|
|
58
61
|
*/
|
|
@@ -129,11 +129,15 @@ class LWWMemoryStoreBackend {
|
|
|
129
129
|
}
|
|
130
130
|
}
|
|
131
131
|
// === Branching ===
|
|
132
|
-
async listBranches(docId) {
|
|
132
|
+
async listBranches(docId, options) {
|
|
133
133
|
const result = [];
|
|
134
|
+
const since = options?.since ?? 0;
|
|
134
135
|
for (const branch of this.branches.values()) {
|
|
135
|
-
if (branch.docId
|
|
136
|
-
|
|
136
|
+
if (branch.docId !== docId) continue;
|
|
137
|
+
if (since) {
|
|
138
|
+
if (branch.modifiedAt > since) result.push(branch);
|
|
139
|
+
} else {
|
|
140
|
+
if (!branch.deleted) result.push(branch);
|
|
137
141
|
}
|
|
138
142
|
}
|
|
139
143
|
return result;
|
|
@@ -150,6 +154,20 @@ class LWWMemoryStoreBackend {
|
|
|
150
154
|
Object.assign(branch, updates);
|
|
151
155
|
}
|
|
152
156
|
}
|
|
157
|
+
async deleteBranch(branchId) {
|
|
158
|
+
const branch = this.branches.get(branchId);
|
|
159
|
+
if (branch) {
|
|
160
|
+
this.branches.set(branchId, {
|
|
161
|
+
id: branch.id,
|
|
162
|
+
docId: branch.docId,
|
|
163
|
+
branchedAtRev: branch.branchedAtRev,
|
|
164
|
+
createdAt: branch.createdAt,
|
|
165
|
+
modifiedAt: Date.now(),
|
|
166
|
+
contentStartRev: branch.contentStartRev,
|
|
167
|
+
deleted: true
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
}
|
|
153
171
|
// === Testing utilities ===
|
|
154
172
|
/**
|
|
155
173
|
* Clears all data from the store. Useful for test cleanup.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { ApiDefinition } from '../net/protocol/JSONRPCServer.js';
|
|
2
|
-
import { Branch,
|
|
2
|
+
import { ListBranchesOptions, Branch, CreateBranchMetadata, EditableBranchMetadata, Change } from '../types.js';
|
|
3
3
|
import { BranchManager } from './BranchManager.js';
|
|
4
4
|
import { PatchesServer } from './PatchesServer.js';
|
|
5
5
|
import { OTStoreBackend, BranchingStoreBackend } from './types.js';
|
|
@@ -22,7 +22,7 @@ type OTBranchStore = OTStoreBackend & BranchingStoreBackend;
|
|
|
22
22
|
* Manages branches for documents using Operational Transformation semantics:
|
|
23
23
|
* - Creates branches at specific revision points
|
|
24
24
|
* - Uses fast-forward merge when possible (no concurrent changes on source)
|
|
25
|
-
* -
|
|
25
|
+
* - Transforms individual branch changes against concurrent source changes for divergent histories
|
|
26
26
|
*
|
|
27
27
|
* A branch is a document that originates from another document at a specific revision.
|
|
28
28
|
* Its first version represents the source document's state at that revision.
|
|
@@ -37,18 +37,17 @@ declare class OTBranchManager implements BranchManager {
|
|
|
37
37
|
/**
|
|
38
38
|
* Lists all open branches for a document.
|
|
39
39
|
* @param docId - The ID of the document.
|
|
40
|
+
* @param options - Optional filtering options (e.g. `since` for incremental sync).
|
|
40
41
|
* @returns The branches.
|
|
41
42
|
*/
|
|
42
|
-
listBranches(docId: string): Promise<Branch[]>;
|
|
43
|
+
listBranches(docId: string, options?: ListBranchesOptions): Promise<Branch[]>;
|
|
43
44
|
/**
|
|
44
45
|
* Creates a new branch for a document.
|
|
45
46
|
* @param docId - The ID of the document to branch from.
|
|
46
47
|
* @param rev - The revision of the document to branch from.
|
|
47
|
-
* @param branchName - Optional name for the branch.
|
|
48
|
-
* @param metadata - Additional optional metadata to store with the branch.
|
|
49
48
|
* @returns The ID of the new branch document.
|
|
50
49
|
*/
|
|
51
|
-
createBranch(docId: string, rev: number, metadata?:
|
|
50
|
+
createBranch(docId: string, rev: number, metadata?: CreateBranchMetadata): Promise<string>;
|
|
52
51
|
/**
|
|
53
52
|
* Updates a branch's metadata.
|
|
54
53
|
* @param branchId - The ID of the branch to update.
|
|
@@ -56,13 +55,18 @@ declare class OTBranchManager implements BranchManager {
|
|
|
56
55
|
*/
|
|
57
56
|
updateBranch(branchId: string, metadata: EditableBranchMetadata): Promise<void>;
|
|
58
57
|
/**
|
|
59
|
-
*
|
|
60
|
-
* @param branchId - The ID of the branch to close.
|
|
61
|
-
* @param status - The status to set for the branch.
|
|
58
|
+
* Deletes a branch, replacing the record with a tombstone.
|
|
62
59
|
*/
|
|
63
|
-
|
|
60
|
+
deleteBranch(branchId: string): Promise<void>;
|
|
64
61
|
/**
|
|
65
62
|
* Merges changes from a branch back into its source document.
|
|
63
|
+
*
|
|
64
|
+
* Supports multiple merges — the branch stays open and `lastMergedRev` tracks
|
|
65
|
+
* which branch revision was last merged. Subsequent merges only pick up new changes.
|
|
66
|
+
*
|
|
67
|
+
* All merge changes use `batchId: branchId` so that `commitChanges` never transforms
|
|
68
|
+
* branch changes against each other (they share the same causal context).
|
|
69
|
+
*
|
|
66
70
|
* @param branchId - The ID of the branch document to merge.
|
|
67
71
|
* @returns The server commit change(s) applied to the source document.
|
|
68
72
|
* @throws Error if branch not found, already closed/merged, or merge fails.
|