@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
@@ -0,0 +1,92 @@
1
+ import { Store } from 'easy-signal';
2
+ import { SizeCalculator } from '../algorithms/ot/shared/changeBatching.js';
3
+ import { BranchAPI } from '../net/protocol/types.js';
4
+ import { Branch, ListBranchesOptions, CreateBranchMetadata } from '../types.js';
5
+ import { BranchClientStore } from './BranchClientStore.js';
6
+ import { Patches } from './Patches.js';
7
+ import { AlgorithmName } from './PatchesStore.js';
8
+ import '../json-patch/JSONPatch.js';
9
+ import '@dabble/delta';
10
+ import '../json-patch/types.js';
11
+ import './ClientAlgorithm.js';
12
+ import '../BaseDoc-BT18xPxU.js';
13
+
14
+ interface PatchesBranchClientOptions {
15
+ /** Maximum size in bytes for a single change in storage. Used to break large initial changes. */
16
+ maxStorageBytes?: number;
17
+ /** Custom size calculator for change size measurement. */
18
+ sizeCalculator?: SizeCalculator;
19
+ /** Algorithm to use for the branch document (defaults to the Patches instance default). */
20
+ algorithm?: AlgorithmName;
21
+ }
22
+ /**
23
+ * Client-side branch management for a document.
24
+ *
25
+ * Accepts either a `BranchAPI` (online, server does the work) or a `BranchClientStore`
26
+ * (offline-first, local store handles caching/pending/tombstones). The API shape
27
+ * determines merge behavior:
28
+ *
29
+ * - `BranchAPI` has `mergeBranch` — server performs the merge
30
+ * - `BranchClientStore` has `updateBranch` — client merges locally, updates `lastMergedRev`
31
+ */
32
+ declare class PatchesBranchClient {
33
+ private readonly api;
34
+ private readonly patches;
35
+ private readonly options?;
36
+ /** Document ID */
37
+ readonly id: string;
38
+ /** Store for the branches list */
39
+ readonly branches: Store<Branch[]>;
40
+ constructor(id: string, api: BranchAPI | BranchClientStore, patches: Patches, options?: PatchesBranchClientOptions | undefined);
41
+ /** Whether this client uses a local store (offline-first mode). */
42
+ private get isOffline();
43
+ /**
44
+ * Loads cached branches from the local store.
45
+ * Returns empty array when using online-only BranchAPI.
46
+ */
47
+ loadCached(): Promise<Branch[]>;
48
+ /**
49
+ * List all branches for this document.
50
+ * With a local store, returns cached data (server sync is handled by PatchesSync).
51
+ * With a BranchAPI, fetches directly from the server.
52
+ */
53
+ listBranches(options?: ListBranchesOptions): Promise<Branch[]>;
54
+ /**
55
+ * Create a new branch from a specific revision.
56
+ *
57
+ * When `initialState` is provided, the branch is created for offline-first sync:
58
+ * - Requires `metadata.id` to be set (used as the branch document ID)
59
+ * - Creates the initial root-replace change locally (broken into multiple if needed)
60
+ * - Saves the branch meta via the API (store marks it pending for later server sync)
61
+ * - Tracks the branch document and saves initial changes as pending through the algorithm
62
+ * - PatchesSync will create the branch on the server and flush the document changes
63
+ *
64
+ * When `initialState` is omitted, the branch is created directly via the API.
65
+ */
66
+ createBranch(rev: number, metadata?: CreateBranchMetadata, initialState?: any): Promise<string>;
67
+ /**
68
+ * Delete a branch.
69
+ * The API implementation handles tombstones (offline store) or direct deletion (online).
70
+ */
71
+ deleteBranch(branchId: string): Promise<void>;
72
+ /**
73
+ * Delete a branch and its document.
74
+ * Convenience method that deletes both the branch record and the branch document.
75
+ */
76
+ deleteBranchWithDoc(branchId: string): Promise<void>;
77
+ /**
78
+ * Merge a branch's changes back into this document.
79
+ *
80
+ * Online (BranchAPI with `mergeBranch`): server performs the merge.
81
+ * Offline (BranchClientStore with `updateBranch`): client reads branch changes,
82
+ * re-stamps them with `batchId: branchId`, submits via algorithm.handleDocChange
83
+ * on the source doc, then updates `lastMergedRev` locally.
84
+ */
85
+ mergeBranch(branchId: string): Promise<void>;
86
+ /** Clear state */
87
+ clear(): void;
88
+ private _createBranchOffline;
89
+ private _mergeBranchLocally;
90
+ }
91
+
92
+ export { PatchesBranchClient, type PatchesBranchClientOptions };
@@ -0,0 +1,170 @@
1
+ import "../chunk-IZ2YBCUP.js";
2
+ import { store } from "easy-signal";
3
+ import { breakChanges } from "../algorithms/ot/shared/changeBatching.js";
4
+ import { createChange } from "../data/change.js";
5
+ class PatchesBranchClient {
6
+ constructor(id, api, patches, options) {
7
+ this.api = api;
8
+ this.patches = patches;
9
+ this.options = options;
10
+ this.id = id;
11
+ this.branches = store([]);
12
+ }
13
+ /** Document ID */
14
+ id;
15
+ /** Store for the branches list */
16
+ branches;
17
+ /** Whether this client uses a local store (offline-first mode). */
18
+ get isOffline() {
19
+ return "loadBranch" in this.api;
20
+ }
21
+ /**
22
+ * Loads cached branches from the local store.
23
+ * Returns empty array when using online-only BranchAPI.
24
+ */
25
+ async loadCached() {
26
+ if (!this.isOffline) return [];
27
+ const cached = await this.api.listBranches(this.id);
28
+ this.branches.state = cached;
29
+ return cached;
30
+ }
31
+ /**
32
+ * List all branches for this document.
33
+ * With a local store, returns cached data (server sync is handled by PatchesSync).
34
+ * With a BranchAPI, fetches directly from the server.
35
+ */
36
+ async listBranches(options) {
37
+ const branches = await this.api.listBranches(this.id, options);
38
+ this.branches.state = branches;
39
+ return branches;
40
+ }
41
+ /**
42
+ * Create a new branch from a specific revision.
43
+ *
44
+ * When `initialState` is provided, the branch is created for offline-first sync:
45
+ * - Requires `metadata.id` to be set (used as the branch document ID)
46
+ * - Creates the initial root-replace change locally (broken into multiple if needed)
47
+ * - Saves the branch meta via the API (store marks it pending for later server sync)
48
+ * - Tracks the branch document and saves initial changes as pending through the algorithm
49
+ * - PatchesSync will create the branch on the server and flush the document changes
50
+ *
51
+ * When `initialState` is omitted, the branch is created directly via the API.
52
+ */
53
+ async createBranch(rev, metadata, initialState) {
54
+ if (initialState !== void 0) {
55
+ if (!metadata) {
56
+ throw new Error("metadata is required when creating a branch with initialState");
57
+ }
58
+ return this._createBranchOffline(rev, metadata, initialState);
59
+ }
60
+ const branchId = await this.api.createBranch(this.id, rev, metadata);
61
+ await this.listBranches();
62
+ return branchId;
63
+ }
64
+ /**
65
+ * Delete a branch.
66
+ * The API implementation handles tombstones (offline store) or direct deletion (online).
67
+ */
68
+ async deleteBranch(branchId) {
69
+ await this.api.deleteBranch(branchId);
70
+ this.branches.state = this.branches.state.filter((b) => b.id !== branchId);
71
+ }
72
+ /**
73
+ * Delete a branch and its document.
74
+ * Convenience method that deletes both the branch record and the branch document.
75
+ */
76
+ async deleteBranchWithDoc(branchId) {
77
+ await this.deleteBranch(branchId);
78
+ await this.patches.deleteDoc(branchId);
79
+ }
80
+ /**
81
+ * Merge a branch's changes back into this document.
82
+ *
83
+ * Online (BranchAPI with `mergeBranch`): server performs the merge.
84
+ * Offline (BranchClientStore with `updateBranch`): client reads branch changes,
85
+ * re-stamps them with `batchId: branchId`, submits via algorithm.handleDocChange
86
+ * on the source doc, then updates `lastMergedRev` locally.
87
+ */
88
+ async mergeBranch(branchId) {
89
+ if (!this.isOffline) {
90
+ await this.api.mergeBranch(branchId);
91
+ await this.listBranches();
92
+ return;
93
+ }
94
+ await this._mergeBranchLocally(branchId);
95
+ }
96
+ /** Clear state */
97
+ clear() {
98
+ this.branches.state = [];
99
+ }
100
+ // --- Private ---
101
+ async _createBranchOffline(rev, metadata, initialState) {
102
+ const branchDocId = metadata.id;
103
+ if (!branchDocId) {
104
+ throw new Error("metadata.id is required when creating a branch with initialState");
105
+ }
106
+ if ("loadBranch" in this.api) {
107
+ const maybeBranch = await this.api.loadBranch(this.id);
108
+ if (maybeBranch) {
109
+ throw new Error("Cannot create a branch from another branch.");
110
+ }
111
+ }
112
+ const now = Date.now();
113
+ const rootReplace = createChange(0, 1, [{ op: "replace", path: "", value: initialState }], {
114
+ createdAt: now,
115
+ committedAt: 0
116
+ });
117
+ let initChanges = [rootReplace];
118
+ if (this.options?.maxStorageBytes) {
119
+ initChanges = breakChanges(initChanges, this.options.maxStorageBytes, this.options.sizeCalculator);
120
+ }
121
+ const contentStartRev = initChanges[initChanges.length - 1].rev + 1;
122
+ const algorithmName = this.options?.algorithm ?? this.patches.defaultAlgorithm;
123
+ const algorithm = this.patches.algorithms[algorithmName];
124
+ if (!algorithm) {
125
+ throw new Error(`Algorithm '${algorithmName}' not found`);
126
+ }
127
+ await this.api.createBranch(this.id, rev, { ...metadata, contentStartRev });
128
+ try {
129
+ await this.patches.trackDocs([branchDocId], algorithmName);
130
+ for (const change of initChanges) {
131
+ await algorithm.handleDocChange(branchDocId, change.ops, void 0, {});
132
+ }
133
+ } catch (err) {
134
+ if (this.isOffline) {
135
+ await this.api.removeBranches([branchDocId]);
136
+ }
137
+ await this.patches.untrackDocs([branchDocId]);
138
+ throw err;
139
+ }
140
+ await this.listBranches();
141
+ this.patches.onChange.emit(branchDocId);
142
+ return branchDocId;
143
+ }
144
+ async _mergeBranchLocally(branchId) {
145
+ const offlineApi = this.api;
146
+ const branch = this.branches.state.find((b) => b.id === branchId);
147
+ if (!branch) throw new Error(`Branch ${branchId} not found`);
148
+ const sourceDocId = branch.docId;
149
+ const algorithmName = this.options?.algorithm ?? this.patches.defaultAlgorithm;
150
+ const algorithm = this.patches.algorithms[algorithmName];
151
+ if (!algorithm?.listChanges) {
152
+ throw new Error("Offline merge requires an algorithm with listChanges support");
153
+ }
154
+ const startAfter = branch.lastMergedRev ?? (branch.contentStartRev ?? 2) - 1;
155
+ const branchChanges = await algorithm.listChanges(branchId, { startAfter });
156
+ if (branchChanges.length === 0) return;
157
+ const lastBranchRev = branchChanges[branchChanges.length - 1].rev;
158
+ for (const change of branchChanges) {
159
+ await this.patches.submitDocChange(sourceDocId, change.ops, { batchId: branchId });
160
+ }
161
+ await offlineApi.updateBranch(branchId, { lastMergedRev: lastBranchRev });
162
+ this.patches.onChange.emit(sourceDocId);
163
+ this.branches.state = this.branches.state.map(
164
+ (b) => b.id === branchId ? { ...b, lastMergedRev: lastBranchRev } : b
165
+ );
166
+ }
167
+ }
168
+ export {
169
+ PatchesBranchClient
170
+ };
@@ -10,7 +10,9 @@ export { LWWAlgorithm } from './LWWAlgorithm.js';
10
10
  export { LWWBatcher } from './LWWBatcher.js';
11
11
  export { OTAlgorithm } from './OTAlgorithm.js';
12
12
  export { OpenDocOptions, Patches, PatchesOptions } from './Patches.js';
13
+ export { PatchesBranchClient, PatchesBranchClientOptions } from './PatchesBranchClient.js';
13
14
  export { PatchesHistoryClient } from './PatchesHistoryClient.js';
15
+ export { BranchClientStore } from './BranchClientStore.js';
14
16
  export { AlgorithmName, PatchesStore, TrackedDoc } from './PatchesStore.js';
15
17
  export { OTClientStore } from './OTClientStore.js';
16
18
  export { LWWClientStore } from './LWWClientStore.js';
@@ -21,4 +23,5 @@ import '../types.js';
21
23
  import '../json-patch/JSONPatch.js';
22
24
  import '@dabble/delta';
23
25
  import '../utils/deferred.js';
26
+ import '../algorithms/ot/shared/changeBatching.js';
24
27
  import '../net/protocol/types.js';
@@ -12,4 +12,5 @@ export * from "./OTDoc.js";
12
12
  export * from "./OTAlgorithm.js";
13
13
  export * from "./Patches.js";
14
14
  export * from "./PatchesDoc.js";
15
+ export * from "./PatchesBranchClient.js";
15
16
  export * from "./PatchesHistoryClient.js";
@@ -10,11 +10,11 @@ import { JSONPatchOp } from '../json-patch/types.js';
10
10
  *
11
11
  * @example Client: Size calculator for change splitting
12
12
  * ```typescript
13
- * import { compressedSizeBase64 } from '@dabble/patches/compression';
13
+ * import { compressedSizeUint8 } from '@dabble/patches/compression';
14
14
  *
15
15
  * new OTDoc(state, {}, {
16
- * sizeCalculator: compressedSizeBase64,
17
- * maxStorageBytes: 1_000_000
16
+ * sizeCalculator: compressedSizeUint8,
17
+ * maxStorageBytes: 1_000_000,
18
18
  * });
19
19
  * ```
20
20
  *
@@ -32,13 +32,21 @@ import { JSONPatchOp } from '../json-patch/types.js';
32
32
  */
33
33
  type SizeCalculator = (data: unknown) => number;
34
34
  /**
35
- * Calculate size after base64 LZ compression.
36
- * Use this when your server uses base64 compression format.
35
+ * Estimate the stored size of a change after base64 LZ compression.
36
+ *
37
+ * When passed a Change object (has an `ops` array), only the `ops` field is
38
+ * compressed — mirroring what `CompressedStoreBackend` does — so the estimate
39
+ * reflects the actual stored size rather than compressing everything together.
40
+ * For other data it falls back to compressing the whole value.
37
41
  */
38
42
  declare const compressedSizeBase64: SizeCalculator;
39
43
  /**
40
- * Calculate size after uint8array LZ compression.
41
- * Use this when your server uses binary compression format.
44
+ * Estimate the stored size of a change after uint8array LZ compression.
45
+ *
46
+ * When passed a Change object (has an `ops` array), only the `ops` field is
47
+ * compressed — mirroring what `CompressedStoreBackend` does — so the estimate
48
+ * reflects the actual stored size rather than compressing everything together.
49
+ * For other data it falls back to compressing the whole value.
42
50
  */
43
51
  declare const compressedSizeUint8: SizeCalculator;
44
52
  /**
@@ -1,8 +1,13 @@
1
1
  import "../chunk-IZ2YBCUP.js";
2
2
  import { compressToBase64, compressToUint8Array, decompressFromBase64, decompressFromUint8Array } from "./lz.js";
3
3
  const compressedSizeBase64 = (data) => {
4
- if (data === void 0) return 0;
4
+ if (data === void 0 || data === null) return 0;
5
5
  try {
6
+ if (typeof data === "object" && "ops" in data && Array.isArray(data.ops)) {
7
+ const { ops, ...rest } = data;
8
+ const compressedOps = compressToBase64(JSON.stringify(ops));
9
+ return new TextEncoder().encode(JSON.stringify({ ...rest, ops: compressedOps })).length;
10
+ }
6
11
  const json = JSON.stringify(data);
7
12
  if (!json) return 0;
8
13
  return compressToBase64(json).length;
@@ -11,8 +16,14 @@ const compressedSizeBase64 = (data) => {
11
16
  }
12
17
  };
13
18
  const compressedSizeUint8 = (data) => {
14
- if (data === void 0) return 0;
19
+ if (data === void 0 || data === null) return 0;
15
20
  try {
21
+ if (typeof data === "object" && "ops" in data && Array.isArray(data.ops)) {
22
+ const { ops, ...rest } = data;
23
+ const compressedOps = compressToUint8Array(JSON.stringify(ops));
24
+ const nonOpsSize = new TextEncoder().encode(JSON.stringify(rest)).length;
25
+ return nonOpsSize + compressedOps.length;
26
+ }
16
27
  const json = JSON.stringify(data);
17
28
  if (!json) return 0;
18
29
  return compressToUint8Array(json).length;
package/dist/index.d.ts CHANGED
@@ -11,7 +11,9 @@ export { LWWAlgorithm } from './client/LWWAlgorithm.js';
11
11
  export { LWWBatcher } from './client/LWWBatcher.js';
12
12
  export { OTAlgorithm } from './client/OTAlgorithm.js';
13
13
  export { OpenDocOptions, Patches, PatchesOptions } from './client/Patches.js';
14
+ export { PatchesBranchClient, PatchesBranchClientOptions } from './client/PatchesBranchClient.js';
14
15
  export { PatchesHistoryClient } from './client/PatchesHistoryClient.js';
16
+ export { BranchClientStore } from './client/BranchClientStore.js';
15
17
  export { AlgorithmName, PatchesStore, TrackedDoc } from './client/PatchesStore.js';
16
18
  export { OTClientStore } from './client/OTClientStore.js';
17
19
  export { LWWClientStore } from './client/LWWClientStore.js';
@@ -29,8 +31,9 @@ export { createPathProxy, pathProxy } from './json-patch/pathProxy.js';
29
31
  export { transformPatch } from './json-patch/transformPatch.js';
30
32
  export { JSONPatch, PathLike, WriteOptions } from './json-patch/JSONPatch.js';
31
33
  export { ApplyJSONPatchOptions, JSONPatchOp, JSONPatchOpHandlerMap } from './json-patch/types.js';
32
- export { Branch, BranchStatus, Change, ChangeInput, ChangeMutator, CommitChangesOptions, DeleteDocOptions, DocSyncState, DocSyncStatus, DocumentTombstone, EditableBranchMetadata, EditableVersionMetadata, ListChangesOptions, ListVersionsOptions, PatchesSnapshot, PatchesState, PathProxy, VersionMetadata } from './types.js';
34
+ export { Branch, Change, ChangeInput, ChangeMutator, CommitChangesOptions, CreateBranchMetadata, DeleteDocOptions, DocSyncState, DocSyncStatus, DocumentTombstone, EditableBranchMetadata, EditableVersionMetadata, ListBranchesOptions, ListChangesOptions, ListVersionsOptions, PatchesSnapshot, PatchesState, PathProxy, VersionMetadata } from './types.js';
33
35
  import './utils/deferred.js';
36
+ import './algorithms/ot/shared/changeBatching.js';
34
37
  import './net/protocol/types.js';
35
38
  import './json-patch/ops/add.js';
36
39
  import './json-patch/ops/copy.js';
@@ -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, ListBranchesOptions, Branch, CreateBranchMetadata, EditableBranchMetadata } from '../types.js';
3
3
  import { JSONRPCClient } from './protocol/JSONRPCClient.js';
4
4
  import { PatchesAPI, ClientTransport } from './protocol/types.js';
5
5
  import '../json-patch/JSONPatch.js';
@@ -109,21 +109,26 @@ declare class PatchesClient implements PatchesAPI {
109
109
  * @param docId - The ID of the document.
110
110
  * @returns A promise resolving with an array of branch metadata objects.
111
111
  */
112
- listBranches(docId: string): Promise<Branch[]>;
112
+ listBranches(docId: string, options?: ListBranchesOptions): Promise<Branch[]>;
113
113
  /**
114
114
  * Creates a new branch for a document.
115
115
  * @param docId - The ID of the document.
116
116
  * @param rev - The revision number to base the new branch on.
117
- * @param metadata - Optional metadata for the new branch.
117
+ * @param options - Optional branch creation options.
118
118
  * @returns A promise resolving with the unique ID of the newly created branch.
119
119
  */
120
- createBranch(docId: string, rev: number, metadata?: EditableVersionMetadata): Promise<string>;
120
+ createBranch(docId: string, rev: number, metadata?: CreateBranchMetadata): Promise<string>;
121
121
  /**
122
- * Closes a branch on the server.
123
- * @param branchId - The ID of the branch to close.
124
- * @returns A promise resolving when the branch is closed.
122
+ * Updates a branch's metadata on the server.
123
+ * @param branchId - The ID of the branch to update.
124
+ * @param metadata - The metadata to update.
125
125
  */
126
- closeBranch(branchId: string): Promise<void>;
126
+ updateBranch(branchId: string, metadata: EditableBranchMetadata): Promise<void>;
127
+ /**
128
+ * Deletes a branch on the server, replacing it with a tombstone.
129
+ * @param branchId - The ID of the branch to delete.
130
+ */
131
+ deleteBranch(branchId: string): Promise<void>;
127
132
  /**
128
133
  * Merges a branch on the server.
129
134
  * @param branchId - The ID of the branch to merge.
@@ -133,26 +133,33 @@ class PatchesClient {
133
133
  * @param docId - The ID of the document.
134
134
  * @returns A promise resolving with an array of branch metadata objects.
135
135
  */
136
- async listBranches(docId) {
137
- return this.rpc.call("listBranches", docId);
136
+ async listBranches(docId, options) {
137
+ return this.rpc.call("listBranches", docId, options);
138
138
  }
139
139
  /**
140
140
  * Creates a new branch for a document.
141
141
  * @param docId - The ID of the document.
142
142
  * @param rev - The revision number to base the new branch on.
143
- * @param metadata - Optional metadata for the new branch.
143
+ * @param options - Optional branch creation options.
144
144
  * @returns A promise resolving with the unique ID of the newly created branch.
145
145
  */
146
146
  async createBranch(docId, rev, metadata) {
147
147
  return this.rpc.call("createBranch", docId, rev, metadata);
148
148
  }
149
149
  /**
150
- * Closes a branch on the server.
151
- * @param branchId - The ID of the branch to close.
152
- * @returns A promise resolving when the branch is closed.
150
+ * Updates a branch's metadata on the server.
151
+ * @param branchId - The ID of the branch to update.
152
+ * @param metadata - The metadata to update.
153
153
  */
154
- async closeBranch(branchId) {
155
- return this.rpc.call("closeBranch", branchId);
154
+ async updateBranch(branchId, metadata) {
155
+ return this.rpc.call("updateBranch", branchId, metadata);
156
+ }
157
+ /**
158
+ * Deletes a branch on the server, replacing it with a tombstone.
159
+ * @param branchId - The ID of the branch to delete.
160
+ */
161
+ async deleteBranch(branchId) {
162
+ return this.rpc.call("deleteBranch", branchId);
156
163
  }
157
164
  /**
158
165
  * Merges a branch on the server.
@@ -1,18 +1,19 @@
1
1
  import * as easy_signal from 'easy-signal';
2
2
  import { ReadonlyStoreClass, Store } from 'easy-signal';
3
3
  import { SizeCalculator } from '../algorithms/ot/shared/changeBatching.js';
4
+ import { BranchClientStore } from '../client/BranchClientStore.js';
4
5
  import { ClientAlgorithm } from '../client/ClientAlgorithm.js';
5
6
  import { Patches } from '../client/Patches.js';
6
7
  import { AlgorithmName } from '../client/PatchesStore.js';
7
8
  import { DocSyncStatus, DocSyncState, Change } from '../types.js';
8
9
  import { PatchesConnection } from './PatchesConnection.js';
9
10
  import { JSONRPCClient } from './protocol/JSONRPCClient.js';
10
- import { ConnectionState } from './protocol/types.js';
11
+ import { BranchAPI, ConnectionState } from './protocol/types.js';
11
12
  import { WebSocketOptions } from './websocket/WebSocketTransport.js';
12
- import '../json-patch/types.js';
13
- import '../BaseDoc-BT18xPxU.js';
14
13
  import '../json-patch/JSONPatch.js';
15
14
  import '@dabble/delta';
15
+ import '../json-patch/types.js';
16
+ import '../BaseDoc-BT18xPxU.js';
16
17
  import '../utils/deferred.js';
17
18
 
18
19
  interface PatchesSyncState {
@@ -31,6 +32,20 @@ interface PatchesSyncOptions {
31
32
  maxStorageBytes?: number;
32
33
  /** Custom size calculator for storage limit. Falls back to patches.docOptions.sizeCalculator. */
33
34
  sizeCalculator?: SizeCalculator;
35
+ /**
36
+ * Local store for branch metadata.
37
+ * When provided, enables offline branch support:
38
+ * - Branch metas are cached locally
39
+ * - Branches with `pendingOp` are synced to the server during sync
40
+ * - Branch document content flows through the standard doc sync pipeline
41
+ */
42
+ branchStore?: BranchClientStore;
43
+ /**
44
+ * Server-side Branch API for syncing pending branch operations.
45
+ * Required when branchStore is provided. Typically the same RPC client
46
+ * used by PatchesBranchClient instances in online mode.
47
+ */
48
+ branchApi?: BranchAPI;
34
49
  }
35
50
  /**
36
51
  * Handles server connection, document subscriptions, and syncing logic between
@@ -69,6 +84,12 @@ declare class PatchesSync extends ReadonlyStoreClass<PatchesSyncState> {
69
84
  * Provides the pending changes that were discarded so the application can handle them.
70
85
  */
71
86
  readonly onRemoteDocDeleted: easy_signal.Signal<(docId: string, pendingChanges: Change[]) => void>;
87
+ /**
88
+ * Signal emitted after pending branch metas have been synced to the server.
89
+ * Consumers should use this to refresh in-memory branch state (e.g. call `loadCached()`
90
+ * on their PatchesBranchClient instances).
91
+ */
92
+ readonly onBranchMetasSynced: easy_signal.Signal<easy_signal.SignalSubscriber>;
72
93
  constructor(patches: Patches, url: string, options?: PatchesSyncOptions);
73
94
  constructor(patches: Patches, connection: PatchesConnection, options?: PatchesSyncOptions);
74
95
  private _unsubs;
@@ -109,6 +130,18 @@ declare class PatchesSync extends ReadonlyStoreClass<PatchesSyncState> {
109
130
  * After calling destroy(), this instance should not be reused.
110
131
  */
111
132
  destroy(): void;
133
+ /**
134
+ * Syncs pending branch metas to the server.
135
+ *
136
+ * Pending branches come in two flavors:
137
+ * - **Created offline** (`pending: true`, no `deleted`): created on the server via `createBranch`.
138
+ * - **Deleted offline** (`pending: true`, `deleted: true`): deleted on the server via `deleteBranch`,
139
+ * then physically removed from the local store.
140
+ *
141
+ * The server skips initial change creation when `contentStartRev` is set in the metadata,
142
+ * so their document content flows through the standard doc sync pipeline.
143
+ */
144
+ protected syncPendingBranchMetas(): Promise<void>;
112
145
  /**
113
146
  * Syncs all known docs when initially connected.
114
147
  */
@@ -52,8 +52,17 @@ class PatchesSync extends (_a = ReadonlyStoreClass, _syncDoc_dec = [serialGate],
52
52
  * Provides the pending changes that were discarded so the application can handle them.
53
53
  */
54
54
  __publicField(this, "onRemoteDocDeleted", signal());
55
+ /**
56
+ * Signal emitted after pending branch metas have been synced to the server.
57
+ * Consumers should use this to refresh in-memory branch state (e.g. call `loadCached()`
58
+ * on their PatchesBranchClient instances).
59
+ */
60
+ __publicField(this, "onBranchMetasSynced", signal());
55
61
  __publicField(this, "_unsubs", []);
56
62
  this.patches = patches;
63
+ if (options?.branchStore && !options?.branchApi) {
64
+ throw new Error("branchApi is required when branchStore is provided");
65
+ }
57
66
  this.maxPayloadBytes = options?.maxPayloadBytes;
58
67
  this.maxStorageBytes = options?.maxStorageBytes ?? patches.docOptions?.maxStorageBytes;
59
68
  this.sizeCalculator = options?.sizeCalculator ?? patches.docOptions?.sizeCalculator;
@@ -160,6 +169,68 @@ class PatchesSync extends (_a = ReadonlyStoreClass, _syncDoc_dec = [serialGate],
160
169
  for (const unsub of this._unsubs) unsub();
161
170
  this._unsubs.length = 0;
162
171
  }
172
+ /**
173
+ * Syncs pending branch metas to the server.
174
+ *
175
+ * Pending branches come in two flavors:
176
+ * - **Created offline** (`pending: true`, no `deleted`): created on the server via `createBranch`.
177
+ * - **Deleted offline** (`pending: true`, `deleted: true`): deleted on the server via `deleteBranch`,
178
+ * then physically removed from the local store.
179
+ *
180
+ * The server skips initial change creation when `contentStartRev` is set in the metadata,
181
+ * so their document content flows through the standard doc sync pipeline.
182
+ */
183
+ async syncPendingBranchMetas() {
184
+ const branchStore = this.options?.branchStore;
185
+ const branchApi = this.options?.branchApi;
186
+ if (!branchStore || !branchApi) return;
187
+ const pendingBranches = await branchStore.listPendingBranches();
188
+ const creates = pendingBranches.filter((b) => b.pendingOp === "create");
189
+ const updates = pendingBranches.filter((b) => b.pendingOp === "update");
190
+ const deletes = pendingBranches.filter((b) => b.pendingOp === "delete");
191
+ for (const branch of creates) {
192
+ if (!this.state.connected) break;
193
+ try {
194
+ const { docId: sourceDocId, branchedAtRev, createdAt: _1, modifiedAt: _2, pendingOp: _3, deleted: _4, ...metadata } = branch;
195
+ await branchApi.createBranch(sourceDocId, branchedAtRev, metadata);
196
+ const synced = { ...branch, pendingOp: void 0 };
197
+ delete synced.pendingOp;
198
+ await branchStore.saveBranches(sourceDocId, [synced]);
199
+ } catch (err) {
200
+ console.error("Failed to sync pending branch create:", branch.id, err);
201
+ this.onError.emit(err instanceof Error ? err : new Error(String(err)));
202
+ break;
203
+ }
204
+ }
205
+ for (const branch of updates) {
206
+ if (!this.state.connected) break;
207
+ try {
208
+ const { id: _id, docId: _did, branchedAtRev: _bar, createdAt: _ca, modifiedAt: _ma, contentStartRev: _csr, pendingOp: _po, deleted: _del, ...metadata } = branch;
209
+ await branchApi.updateBranch(branch.id, metadata);
210
+ const synced = { ...branch, pendingOp: void 0 };
211
+ delete synced.pendingOp;
212
+ await branchStore.saveBranches(branch.docId, [synced]);
213
+ } catch (err) {
214
+ console.error("Failed to sync pending branch update:", branch.id, err);
215
+ this.onError.emit(err instanceof Error ? err : new Error(String(err)));
216
+ break;
217
+ }
218
+ }
219
+ for (const branch of deletes) {
220
+ if (!this.state.connected) break;
221
+ try {
222
+ await branchApi.deleteBranch(branch.id);
223
+ await branchStore.removeBranches([branch.id]);
224
+ } catch (err) {
225
+ console.error("Failed to sync pending branch deletion:", branch.id, err);
226
+ this.onError.emit(err instanceof Error ? err : new Error(String(err)));
227
+ break;
228
+ }
229
+ }
230
+ if (pendingBranches.length > 0) {
231
+ this.onBranchMetasSynced.emit();
232
+ }
233
+ }
163
234
  /**
164
235
  * Syncs all known docs when initially connected.
165
236
  */
@@ -167,6 +238,7 @@ class PatchesSync extends (_a = ReadonlyStoreClass, _syncDoc_dec = [serialGate],
167
238
  if (!this.state.connected) return;
168
239
  this.updateState({ syncStatus: "syncing" });
169
240
  try {
241
+ await this.syncPendingBranchMetas();
170
242
  const allTracked = [];
171
243
  for (const algorithm of Object.values(this.patches.algorithms)) {
172
244
  if (!algorithm) continue;
@@ -4,11 +4,11 @@ export { PatchesConnection } from './PatchesConnection.js';
4
4
  export { PatchesSync, PatchesSyncOptions, PatchesSyncState } from './PatchesSync.js';
5
5
  export { PatchesREST, PatchesRESTOptions } from './rest/PatchesREST.js';
6
6
  export { BufferedEvent, SSEServer, SSEServerOptions } from './rest/SSEServer.js';
7
- export { encodeDocId, normalizeIds } from './rest/utils.js';
7
+ export { normalizeIds } from './rest/utils.js';
8
8
  export { JSONRPCClient } from './protocol/JSONRPCClient.js';
9
9
  export { ApiDefinition, ConnectionSignalSubscriber, JSONRPCServer, JSONRPCServerOptions, MessageHandler } from './protocol/JSONRPCServer.js';
10
10
  export { getAuthContext, getClientId } from './serverContext.js';
11
- export { AwarenessUpdateNotificationParams, ClientTransport, ConnectionState, JsonRpcNotification, JsonRpcRequest, JsonRpcResponse, Message, PatchesAPI, PatchesNotificationParams, ServerTransport, SignalNotificationParams } from './protocol/types.js';
11
+ export { AwarenessUpdateNotificationParams, BranchAPI, ClientTransport, ConnectionState, JsonRpcNotification, JsonRpcRequest, JsonRpcResponse, Message, PatchesAPI, PatchesNotificationParams, ServerTransport, SignalNotificationParams } from './protocol/types.js';
12
12
  export { rpcError, rpcNotification, rpcResponse } from './protocol/utils.js';
13
13
  export { Access, AuthContext, AuthorizationProvider, allowAll, assertNotDeleted, denyAll } from './websocket/AuthorizationProvider.js';
14
14
  export { onlineState } from './websocket/onlineState.js';
@@ -19,6 +19,7 @@ export { WebSocketOptions, WebSocketTransport } from './websocket/WebSocketTrans
19
19
  export { CommitChangesOptions } from '../types.js';
20
20
  import 'easy-signal';
21
21
  import '../algorithms/ot/shared/changeBatching.js';
22
+ import '../client/BranchClientStore.js';
22
23
  import '../client/ClientAlgorithm.js';
23
24
  import '../json-patch/types.js';
24
25
  import '../BaseDoc-BT18xPxU.js';