@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.
Files changed (54) hide show
  1. package/dist/algorithms/ot/shared/changeBatching.js +30 -71
  2. package/dist/client/BranchClientStore.d.ts +71 -0
  3. package/dist/client/BranchClientStore.js +0 -0
  4. package/dist/client/ClientAlgorithm.d.ts +12 -0
  5. package/dist/client/IndexedDBStore.d.ts +13 -2
  6. package/dist/client/IndexedDBStore.js +125 -1
  7. package/dist/client/LWWInMemoryStore.js +5 -3
  8. package/dist/client/LWWIndexedDBStore.d.ts +1 -0
  9. package/dist/client/LWWIndexedDBStore.js +14 -5
  10. package/dist/client/OTAlgorithm.d.ts +3 -0
  11. package/dist/client/OTAlgorithm.js +4 -0
  12. package/dist/client/OTClientStore.d.ts +11 -0
  13. package/dist/client/OTIndexedDBStore.d.ts +9 -0
  14. package/dist/client/OTIndexedDBStore.js +15 -3
  15. package/dist/client/Patches.d.ts +6 -0
  16. package/dist/client/Patches.js +11 -0
  17. package/dist/client/PatchesBranchClient.d.ts +92 -0
  18. package/dist/client/PatchesBranchClient.js +170 -0
  19. package/dist/client/index.d.ts +3 -0
  20. package/dist/client/index.js +1 -0
  21. package/dist/compression/index.d.ts +15 -7
  22. package/dist/compression/index.js +13 -2
  23. package/dist/index.d.ts +4 -1
  24. package/dist/net/PatchesClient.d.ts +13 -8
  25. package/dist/net/PatchesClient.js +15 -8
  26. package/dist/net/PatchesSync.d.ts +36 -3
  27. package/dist/net/PatchesSync.js +72 -0
  28. package/dist/net/index.d.ts +3 -2
  29. package/dist/net/protocol/types.d.ts +9 -2
  30. package/dist/net/rest/PatchesREST.d.ts +8 -5
  31. package/dist/net/rest/PatchesREST.js +29 -19
  32. package/dist/net/rest/SSEServer.js +1 -1
  33. package/dist/net/rest/index.d.ts +1 -1
  34. package/dist/net/rest/index.js +1 -2
  35. package/dist/net/rest/utils.d.ts +1 -9
  36. package/dist/net/rest/utils.js +0 -4
  37. package/dist/server/BranchManager.d.ts +11 -7
  38. package/dist/server/LWWBranchManager.d.ts +10 -9
  39. package/dist/server/LWWBranchManager.js +47 -30
  40. package/dist/server/LWWMemoryStoreBackend.d.ts +5 -2
  41. package/dist/server/LWWMemoryStoreBackend.js +21 -3
  42. package/dist/server/OTBranchManager.d.ts +14 -10
  43. package/dist/server/OTBranchManager.js +65 -63
  44. package/dist/server/branchUtils.d.ts +7 -15
  45. package/dist/server/branchUtils.js +18 -14
  46. package/dist/server/index.d.ts +1 -1
  47. package/dist/server/index.js +2 -2
  48. package/dist/server/types.d.ts +10 -4
  49. package/dist/solid/context.d.ts +1 -0
  50. package/dist/solid/index.d.ts +1 -0
  51. package/dist/types.d.ts +43 -6
  52. package/dist/vue/index.d.ts +1 -0
  53. package/dist/vue/provider.d.ts +1 -0
  54. 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): Promise<Branch[]>;
65
- createBranch(docId: string, rev: number, metadata?: EditableVersionMetadata): Promise<string>;
66
- closeBranch(branchId: string): Promise<void>;
67
- mergeBranch(branchId: string): Promise<void>;
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 { encodeDocId, normalizeIds } from "./utils.js";
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/${encodeDocId(docId)}`);
117
+ return this._fetch(`/docs/${docId}`);
118
118
  }
119
119
  async getChangesSince(docId, rev) {
120
- return this._fetch(`/docs/${encodeDocId(docId)}/_changes?since=${rev}`);
120
+ return this._fetch(`/docs/${docId}/_changes?since=${rev}`);
121
121
  }
122
122
  async commitChanges(docId, changes, options) {
123
- return this._fetch(`/docs/${encodeDocId(docId)}/_changes`, {
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/${encodeDocId(docId)}`, { method: "DELETE" });
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/${encodeDocId(docId)}/_versions`, {
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/${encodeDocId(docId)}/_versions${params}`);
140
+ return this._fetch(`/docs/${docId}/_versions${params}`);
141
141
  }
142
142
  async getVersionState(docId, versionId) {
143
- return this._fetch(`/docs/${encodeDocId(docId)}/_versions/${encodeURIComponent(versionId)}`);
143
+ return this._fetch(`/docs/${docId}/_versions/${encodeURIComponent(versionId)}`);
144
144
  }
145
145
  async getVersionChanges(docId, versionId) {
146
- return this._fetch(`/docs/${encodeDocId(docId)}/_versions/${encodeURIComponent(versionId)}/_changes`);
146
+ return this._fetch(`/docs/${docId}/_versions/${encodeURIComponent(versionId)}/_changes`);
147
147
  }
148
148
  async updateVersion(docId, versionId, metadata) {
149
- await this._fetch(`/docs/${encodeDocId(docId)}/_versions/${encodeURIComponent(versionId)}`, {
149
+ await this._fetch(`/docs/${docId}/_versions/${encodeURIComponent(versionId)}`, {
150
150
  method: "PUT",
151
151
  body: metadata
152
152
  });
153
153
  }
154
- // --- Branch Operations (not in PatchesAPI but matches PatchesClient feature parity) ---
155
- async listBranches(docId) {
156
- return this._fetch(`/docs/${encodeDocId(docId)}/_branches`);
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/${encodeDocId(docId)}/_branches`, {
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 closeBranch(branchId) {
165
- await this._fetch(`/docs/${encodeDocId(branchId)}`, { method: "DELETE" });
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/${encodeDocId(branchId)}/_merge`, { method: "POST" });
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(": connected\n\n"));
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)) {
@@ -1,6 +1,6 @@
1
1
  export { PatchesREST, PatchesRESTOptions } from './PatchesREST.js';
2
2
  export { BufferedEvent, SSEServer, SSEServerOptions } from './SSEServer.js';
3
- export { encodeDocId, normalizeIds } from './utils.js';
3
+ export { normalizeIds } from './utils.js';
4
4
  import 'easy-signal';
5
5
  import '../../types.js';
6
6
  import '../../json-patch/JSONPatch.js';
@@ -1,8 +1,7 @@
1
1
  import "../../chunk-IZ2YBCUP.js";
2
2
  export * from "./PatchesREST.js";
3
3
  export * from "./SSEServer.js";
4
- import { encodeDocId, normalizeIds } from "./utils.js";
4
+ import { normalizeIds } from "./utils.js";
5
5
  export {
6
- encodeDocId,
7
6
  normalizeIds
8
7
  };
@@ -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 { encodeDocId, normalizeIds };
6
+ export { normalizeIds };
@@ -1,11 +1,7 @@
1
1
  import "../../chunk-IZ2YBCUP.js";
2
- function encodeDocId(docId) {
3
- return docId.split("/").map(encodeURIComponent).join("/");
4
- }
5
2
  function normalizeIds(ids) {
6
3
  return Array.isArray(ids) ? ids : [ids];
7
4
  }
8
5
  export {
9
- encodeDocId,
10
6
  normalizeIds
11
7
  };
@@ -1,4 +1,4 @@
1
- import { Branch, EditableBranchMetadata, BranchStatus, Change } from '../types.js';
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?: EditableBranchMetadata): Promise<string>;
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
- * Closes a branch with the specified status.
37
- * @param branchId - The branch document ID.
38
- * @param status - The status to set (defaults to 'closed').
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
- closeBranch(branchId: string, status?: Exclude<BranchStatus, 'open'>): Promise<void>;
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, EditableBranchMetadata, BranchStatus, Change } from '../types.js';
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?: EditableBranchMetadata): Promise<string>;
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
- * Closes a branch with the specified status.
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
- closeBranch(branchId: string, status?: Exclude<BranchStatus, 'open'> | null): Promise<void>;
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 all ops changes made on the branch since it was created
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
- assertBranchOpenForMerge,
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 assertNotABranch(this.store, docId);
41
- const snapshot = await this.store.getSnapshot(docId);
42
- const baseRev = snapshot?.rev ?? 0;
43
- let state = snapshot ? JSON.parse(await readStreamAsString(snapshot.state)) : {};
44
- const ops = await this.store.listOps(docId);
45
- const opsAfterSnapshot = ops.filter((op) => (op.rev ?? 0) > baseRev);
46
- if (opsAfterSnapshot.length > 0) {
47
- state = new JSONPatch(opsAfterSnapshot).apply(state);
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
- const rev = ops.length > 0 ? Math.max(baseRev, ...ops.map((op) => op.rev ?? 0)) : baseRev;
50
- const branchDocId = await generateBranchId(this.store, docId);
51
- await this.store.saveSnapshot(branchDocId, state, rev);
52
- if (ops.length > 0) {
53
- await this.store.saveOps(branchDocId, ops);
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
- * Closes a branch with the specified status.
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 closeBranch(branchId, status) {
74
- await this.store.updateBranch(branchId, { status: status ?? "closed" });
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 all ops changes made on the branch since it was created
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
- assertBranchOpenForMerge(branch, branchId);
104
+ assertBranchExists(branch, branchId);
90
105
  const sourceDocId = branch.docId;
91
- const branchChanges = await this.lwwServer.getChangesSince(branchId, branch.branchedAtRev);
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.closeBranch(branchId, "merged");
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): Promise<Branch[]>;
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, 'status' | 'name' | 'metadata'>>): Promise<void>;
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 === docId) {
136
- result.push(branch);
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, EditableBranchMetadata, BranchStatus, Change } from '../types.js';
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
- * - Falls back to flattened merge for divergent histories
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?: EditableBranchMetadata): Promise<string>;
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
- * Closes a branch, marking it as merged or deleted.
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
- closeBranch(branchId: string, status?: Exclude<BranchStatus, 'open'>): Promise<void>;
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.