@dabble/patches 0.7.9 → 0.7.11

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.
@@ -1,6 +1,6 @@
1
1
  import { Signal, Unsubscriber } from './event-signal.js';
2
2
  import { JSONPatchOp } from './json-patch/types.js';
3
- import { Change, PatchesSnapshot, SyncingState, ChangeMutator } from './types.js';
3
+ import { Change, PatchesSnapshot, SyncedDoc, DocSyncStatus, ChangeMutator } from './types.js';
4
4
 
5
5
  /**
6
6
  * OT (Operational Transformation) document implementation.
@@ -96,9 +96,9 @@ interface PatchesDocOptions {
96
96
  * This interface is implemented by both OTDoc (Operational Transformation)
97
97
  * and LWWDoc (Last-Write-Wins) implementations via BaseDoc.
98
98
  *
99
- * Internal methods (updateSyncing, applyChanges, import) are on BaseDoc, not this interface.
99
+ * Internal methods (updateSyncStatus, applyChanges, import) are on BaseDoc, not this interface.
100
100
  */
101
- interface PatchesDoc<T extends object = object> {
101
+ interface PatchesDoc<T extends object = object> extends SyncedDoc {
102
102
  /** The unique identifier for this document. */
103
103
  readonly id: string;
104
104
  /** Current local state (committed + pending merged). */
@@ -107,8 +107,12 @@ interface PatchesDoc<T extends object = object> {
107
107
  readonly committedRev: number;
108
108
  /** Are there local changes that haven't been committed yet? */
109
109
  readonly hasPending: boolean;
110
- /** Are we currently syncing this document? */
111
- readonly syncing: SyncingState;
110
+ /** Current sync status of this document. */
111
+ readonly syncStatus: DocSyncStatus;
112
+ /** Error from the last failed sync attempt, if any. */
113
+ readonly syncError: Error | null;
114
+ /** Whether the document has completed its initial load. Sticky: once true, never reverts to false. */
115
+ readonly isLoaded: boolean;
112
116
  /**
113
117
  * Subscribe to be notified when the user makes local changes.
114
118
  * Emits the JSON Patch ops captured from the change() call.
@@ -117,8 +121,8 @@ interface PatchesDoc<T extends object = object> {
117
121
  readonly onChange: Signal<(ops: JSONPatchOp[]) => void>;
118
122
  /** Subscribe to be notified whenever state changes from any source. */
119
123
  readonly onUpdate: Signal<(newState: T) => void>;
120
- /** Subscribe to be notified when syncing state changes. */
121
- readonly onSyncing: Signal<(newSyncing: SyncingState) => void>;
124
+ /** Subscribe to be notified when sync status changes. */
125
+ readonly onSyncStatus: Signal<(newStatus: DocSyncStatus) => void>;
122
126
  /** Subscribe to be notified whenever the state changes (calls immediately with current state). */
123
127
  subscribe(onUpdate: (newValue: T) => void): Unsubscriber;
124
128
  /**
@@ -137,13 +141,15 @@ interface PatchesDoc<T extends object = object> {
137
141
  * apply locally. The algorithm handles packaging ops, persisting them, and updating
138
142
  * the doc's state via `applyChanges()`.
139
143
  *
140
- * Internal methods (updateSyncing, applyChanges, import) are on this class but not
144
+ * Internal methods (updateSyncStatus, applyChanges, import) are on this class but not
141
145
  * on the PatchesDoc interface, as they're only used by Algorithm and PatchesSync.
142
146
  */
143
147
  declare abstract class BaseDoc<T extends object = object> implements PatchesDoc<T> {
144
148
  protected _id: string;
145
149
  protected _state: T;
146
- protected _syncing: SyncingState;
150
+ protected _syncStatus: DocSyncStatus;
151
+ protected _syncError: Error | null;
152
+ protected _isLoaded: boolean;
147
153
  /**
148
154
  * Subscribe to be notified when the user makes local changes.
149
155
  * Emits the JSON Patch ops captured from the change() call.
@@ -152,8 +158,8 @@ declare abstract class BaseDoc<T extends object = object> implements PatchesDoc<
152
158
  readonly onChange: Signal<(ops: JSONPatchOp[]) => void>;
153
159
  /** Subscribe to be notified whenever state changes from any source. */
154
160
  readonly onUpdate: Signal<(newState: T) => void>;
155
- /** Subscribe to be notified when syncing state changes. */
156
- readonly onSyncing: Signal<(newSyncing: SyncingState) => void>;
161
+ /** Subscribe to be notified when sync status changes. */
162
+ readonly onSyncStatus: Signal<(newStatus: DocSyncStatus) => void>;
157
163
  /**
158
164
  * Creates an instance of BaseDoc.
159
165
  * @param id The unique identifier for this document.
@@ -164,8 +170,12 @@ declare abstract class BaseDoc<T extends object = object> implements PatchesDoc<
164
170
  get id(): string;
165
171
  /** Current local state (committed + pending merged). */
166
172
  get state(): T;
167
- /** Are we currently syncing this document? */
168
- get syncing(): SyncingState;
173
+ /** Current sync status of this document. */
174
+ get syncStatus(): DocSyncStatus;
175
+ /** Error from the last failed sync attempt, if any. */
176
+ get syncError(): Error | null;
177
+ /** Whether the document has completed its initial load. Sticky: once true, never reverts to false. */
178
+ get isLoaded(): boolean;
169
179
  /** Last committed revision number from the server. */
170
180
  abstract get committedRev(): number;
171
181
  /** Are there local changes that haven't been committed yet? */
@@ -179,11 +189,14 @@ declare abstract class BaseDoc<T extends object = object> implements PatchesDoc<
179
189
  */
180
190
  change(mutator: ChangeMutator<T>): void;
181
191
  /**
182
- * Updates the syncing state of the document.
192
+ * Updates the sync status of the document.
183
193
  * Called by PatchesSync - not part of the app-facing PatchesDoc interface.
184
- * @param newSyncing The new syncing state.
194
+ * @param status The new sync status.
195
+ * @param error Optional error when status is 'error'.
185
196
  */
186
- updateSyncing(newSyncing: SyncingState): void;
197
+ updateSyncStatus(status: DocSyncStatus, error?: Error): void;
198
+ /** Latches _isLoaded to true when the doc has data or sync has resolved. */
199
+ protected _checkLoaded(): void;
187
200
  /**
188
201
  * Applies changes to the document state.
189
202
  * Called by Algorithm for local changes and broadcasts - not part of PatchesDoc interface.
@@ -1,6 +1,6 @@
1
1
  import '../event-signal.js';
2
2
  import '../json-patch/types.js';
3
3
  import '../types.js';
4
- export { B as BaseDoc } from '../BaseDoc-_Rsau70J.js';
4
+ export { B as BaseDoc } from '../BaseDoc-BfVJNeCi.js';
5
5
  import '../json-patch/JSONPatch.js';
6
6
  import '@dabble/delta';
@@ -1,10 +1,13 @@
1
1
  import "../chunk-IZ2YBCUP.js";
2
2
  import { signal } from "../event-signal.js";
3
3
  import { createJSONPatch } from "../json-patch/createJSONPatch.js";
4
+ import { isDocLoaded } from "../shared/utils.js";
4
5
  class BaseDoc {
5
6
  _id;
6
7
  _state;
7
- _syncing = null;
8
+ _syncStatus = "unsynced";
9
+ _syncError = null;
10
+ _isLoaded = false;
8
11
  /**
9
12
  * Subscribe to be notified when the user makes local changes.
10
13
  * Emits the JSON Patch ops captured from the change() call.
@@ -13,8 +16,8 @@ class BaseDoc {
13
16
  onChange = signal();
14
17
  /** Subscribe to be notified whenever state changes from any source. */
15
18
  onUpdate = signal();
16
- /** Subscribe to be notified when syncing state changes. */
17
- onSyncing = signal();
19
+ /** Subscribe to be notified when sync status changes. */
20
+ onSyncStatus = signal();
18
21
  /**
19
22
  * Creates an instance of BaseDoc.
20
23
  * @param id The unique identifier for this document.
@@ -32,9 +35,17 @@ class BaseDoc {
32
35
  get state() {
33
36
  return this._state;
34
37
  }
35
- /** Are we currently syncing this document? */
36
- get syncing() {
37
- return this._syncing;
38
+ /** Current sync status of this document. */
39
+ get syncStatus() {
40
+ return this._syncStatus;
41
+ }
42
+ /** Error from the last failed sync attempt, if any. */
43
+ get syncError() {
44
+ return this._syncError;
45
+ }
46
+ /** Whether the document has completed its initial load. Sticky: once true, never reverts to false. */
47
+ get isLoaded() {
48
+ return this._isLoaded;
38
49
  }
39
50
  /** Subscribe to be notified whenever the state changes (calls immediately with current state). */
40
51
  subscribe(onUpdate) {
@@ -56,13 +67,22 @@ class BaseDoc {
56
67
  }
57
68
  // --- Internal methods (not on PatchesDoc interface) ---
58
69
  /**
59
- * Updates the syncing state of the document.
70
+ * Updates the sync status of the document.
60
71
  * Called by PatchesSync - not part of the app-facing PatchesDoc interface.
61
- * @param newSyncing The new syncing state.
72
+ * @param status The new sync status.
73
+ * @param error Optional error when status is 'error'.
62
74
  */
63
- updateSyncing(newSyncing) {
64
- this._syncing = newSyncing;
65
- this.onSyncing.emit(newSyncing);
75
+ updateSyncStatus(status, error) {
76
+ this._syncStatus = status;
77
+ this._syncError = status === "error" ? error ?? null : null;
78
+ this._checkLoaded();
79
+ this.onSyncStatus.emit(status);
80
+ }
81
+ /** Latches _isLoaded to true when the doc has data or sync has resolved. */
82
+ _checkLoaded() {
83
+ if (!this._isLoaded && isDocLoaded(this)) {
84
+ this._isLoaded = true;
85
+ }
66
86
  }
67
87
  }
68
88
  export {
@@ -1,6 +1,6 @@
1
1
  import { JSONPatchOp } from '../json-patch/types.js';
2
2
  import { PatchesSnapshot, Change } from '../types.js';
3
- import { a as PatchesDoc } from '../BaseDoc-_Rsau70J.js';
3
+ import { a as PatchesDoc } from '../BaseDoc-BfVJNeCi.js';
4
4
  import { PatchesStore, TrackedDoc } from './PatchesStore.js';
5
5
  import '../json-patch/JSONPatch.js';
6
6
  import '@dabble/delta';
@@ -2,7 +2,7 @@ import { JSONPatchOp } from '../json-patch/types.js';
2
2
  import { PatchesSnapshot, Change } from '../types.js';
3
3
  import { ClientAlgorithm } from './ClientAlgorithm.js';
4
4
  import { LWWClientStore } from './LWWClientStore.js';
5
- import { a as PatchesDoc } from '../BaseDoc-_Rsau70J.js';
5
+ import { a as PatchesDoc } from '../BaseDoc-BfVJNeCi.js';
6
6
  import { TrackedDoc } from './PatchesStore.js';
7
7
  import '../json-patch/JSONPatch.js';
8
8
  import '@dabble/delta';
@@ -1,5 +1,5 @@
1
1
  import { PatchesSnapshot, Change } from '../types.js';
2
- import { B as BaseDoc } from '../BaseDoc-_Rsau70J.js';
2
+ import { B as BaseDoc } from '../BaseDoc-BfVJNeCi.js';
3
3
  import '../json-patch/JSONPatch.js';
4
4
  import '@dabble/delta';
5
5
  import '../json-patch/types.js';
@@ -21,6 +21,7 @@ class LWWDoc extends BaseDoc {
21
21
  }
22
22
  }
23
23
  }
24
+ this._checkLoaded();
24
25
  }
25
26
  /** Last committed revision number from the server. */
26
27
  get committedRev() {
@@ -45,6 +46,7 @@ class LWWDoc extends BaseDoc {
45
46
  }
46
47
  }
47
48
  }
49
+ this._checkLoaded();
48
50
  this.onUpdate.emit(this._state);
49
51
  }
50
52
  /**
@@ -78,6 +80,7 @@ class LWWDoc extends BaseDoc {
78
80
  }
79
81
  this._committedRev = lastCommittedRev;
80
82
  this._hasPending = hasPending ?? hasPendingChanges;
83
+ this._checkLoaded();
81
84
  this.onUpdate.emit(this._state);
82
85
  }
83
86
  }
@@ -2,7 +2,7 @@ import { JSONPatchOp } from '../json-patch/types.js';
2
2
  import { PatchesSnapshot, Change } from '../types.js';
3
3
  import { ClientAlgorithm } from './ClientAlgorithm.js';
4
4
  import { OTClientStore } from './OTClientStore.js';
5
- import { P as PatchesDocOptions, a as PatchesDoc } from '../BaseDoc-_Rsau70J.js';
5
+ import { P as PatchesDocOptions, a as PatchesDoc } from '../BaseDoc-BfVJNeCi.js';
6
6
  import { TrackedDoc } from './PatchesStore.js';
7
7
  import '../json-patch/JSONPatch.js';
8
8
  import '@dabble/delta';
@@ -1,5 +1,5 @@
1
1
  import '../types.js';
2
- export { O as OTDoc } from '../BaseDoc-_Rsau70J.js';
2
+ export { O as OTDoc } from '../BaseDoc-BfVJNeCi.js';
3
3
  import '../json-patch/JSONPatch.js';
4
4
  import '@dabble/delta';
5
5
  import '../json-patch/types.js';
@@ -23,6 +23,7 @@ class OTDoc extends BaseDoc {
23
23
  if (this._pendingChanges.length > 0) {
24
24
  this._state = applyChangesToState(this._committedState, this._pendingChanges);
25
25
  }
26
+ this._checkLoaded();
26
27
  }
27
28
  /** Last committed revision number from the server. */
28
29
  get committedRev() {
@@ -48,6 +49,7 @@ class OTDoc extends BaseDoc {
48
49
  this._committedRev = snapshot.rev;
49
50
  this._pendingChanges = snapshot.changes;
50
51
  this._state = createStateFromSnapshot(snapshot);
52
+ this._checkLoaded();
51
53
  this.onUpdate.emit(this._state);
52
54
  }
53
55
  /**
@@ -79,6 +81,7 @@ class OTDoc extends BaseDoc {
79
81
  this._state = applyChangesToState(this._state, changes);
80
82
  this._pendingChanges.push(...changes);
81
83
  }
84
+ this._checkLoaded();
82
85
  this.onUpdate.emit(this._state);
83
86
  }
84
87
  /**
@@ -2,7 +2,7 @@ import { Unsubscriber, Signal } from '../event-signal.js';
2
2
  import { JSONPatchOp } from '../json-patch/types.js';
3
3
  import { Change } from '../types.js';
4
4
  import { ClientAlgorithm } from './ClientAlgorithm.js';
5
- import { P as PatchesDocOptions, a as PatchesDoc } from '../BaseDoc-_Rsau70J.js';
5
+ import { P as PatchesDocOptions, a as PatchesDoc } from '../BaseDoc-BfVJNeCi.js';
6
6
  import { AlgorithmName } from './PatchesStore.js';
7
7
  import '../json-patch/JSONPatch.js';
8
8
  import '@dabble/delta';
@@ -1,6 +1,6 @@
1
1
  import '../event-signal.js';
2
2
  import '../json-patch/types.js';
3
3
  import '../types.js';
4
- export { O as OTDoc, a as PatchesDoc, O as PatchesDocClass, P as PatchesDocOptions } from '../BaseDoc-_Rsau70J.js';
4
+ export { O as OTDoc, a as PatchesDoc, O as PatchesDocClass, P as PatchesDocOptions } from '../BaseDoc-BfVJNeCi.js';
5
5
  import '../json-patch/JSONPatch.js';
6
6
  import '@dabble/delta';
@@ -1,6 +1,6 @@
1
1
  import { AlgorithmName } from './PatchesStore.js';
2
2
  import { Patches } from './Patches.js';
3
- import { P as PatchesDocOptions } from '../BaseDoc-_Rsau70J.js';
3
+ import { P as PatchesDocOptions } from '../BaseDoc-BfVJNeCi.js';
4
4
  import '../types.js';
5
5
  import '../json-patch/JSONPatch.js';
6
6
  import '@dabble/delta';
@@ -1,4 +1,4 @@
1
- export { B as BaseDoc, O as OTDoc, a as PatchesDoc, O as PatchesDocClass, P as PatchesDocOptions } from '../BaseDoc-_Rsau70J.js';
1
+ export { B as BaseDoc, O as OTDoc, a as PatchesDoc, O as PatchesDocClass, P as PatchesDocOptions } from '../BaseDoc-BfVJNeCi.js';
2
2
  export { IndexedDBFactoryOptions, MultiAlgorithmFactoryOptions, MultiAlgorithmIndexedDBFactoryOptions, PatchesFactoryOptions, createLWWIndexedDBPatches, createLWWPatches, createMultiAlgorithmIndexedDBPatches, createMultiAlgorithmPatches, createOTIndexedDBPatches, createOTPatches } from './factories.js';
3
3
  export { IDBStoreWrapper, IDBTransactionWrapper, IndexedDBStore } from './IndexedDBStore.js';
4
4
  export { OTIndexedDBStore } from './OTIndexedDBStore.js';
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export { Delta } from '@dabble/delta';
2
- export { B as BaseDoc, O as OTDoc, a as PatchesDoc, O as PatchesDocClass, P as PatchesDocOptions } from './BaseDoc-_Rsau70J.js';
2
+ export { B as BaseDoc, O as OTDoc, a as PatchesDoc, O as PatchesDocClass, P as PatchesDocOptions } from './BaseDoc-BfVJNeCi.js';
3
3
  export { IndexedDBFactoryOptions, MultiAlgorithmFactoryOptions, MultiAlgorithmIndexedDBFactoryOptions, PatchesFactoryOptions, createLWWIndexedDBPatches, createLWWPatches, createMultiAlgorithmIndexedDBPatches, createMultiAlgorithmPatches, createOTIndexedDBPatches, createOTPatches } from './client/factories.js';
4
4
  export { IDBStoreWrapper, IDBTransactionWrapper, IndexedDBStore } from './client/IndexedDBStore.js';
5
5
  export { OTIndexedDBStore } from './client/OTIndexedDBStore.js';
@@ -29,7 +29,7 @@ export { createPathProxy, pathProxy } from './json-patch/pathProxy.js';
29
29
  export { transformPatch } from './json-patch/transformPatch.js';
30
30
  export { JSONPatch, PathLike, WriteOptions } from './json-patch/JSONPatch.js';
31
31
  export { ApplyJSONPatchOptions, JSONPatchOpHandlerMap as JSONPatchCustomTypes, JSONPatchOp } from './json-patch/types.js';
32
- export { Branch, BranchStatus, Change, ChangeInput, ChangeMutator, CommitChangesOptions, DeleteDocOptions, DocumentTombstone, EditableBranchMetadata, EditableVersionMetadata, ListChangesOptions, ListVersionsOptions, PatchesSnapshot, PatchesState, PathProxy, SyncingState, VersionMetadata } from './types.js';
32
+ export { Branch, BranchStatus, Change, ChangeInput, ChangeMutator, CommitChangesOptions, DeleteDocOptions, DocSyncStatus, DocumentTombstone, EditableBranchMetadata, EditableVersionMetadata, ListChangesOptions, ListVersionsOptions, PatchesSnapshot, PatchesState, PathProxy, SyncedDoc, VersionMetadata } from './types.js';
33
33
  export { add } from './json-patch/ops/add.js';
34
34
  export { copy } from './json-patch/ops/copy.js';
35
35
  export { increment } from './json-patch/ops/increment.js';
@@ -1,6 +1,6 @@
1
1
  import { Signal } from '../event-signal.js';
2
2
  import { ConnectionState } from './protocol/types.js';
3
- import { SyncingState, Change } from '../types.js';
3
+ import { DocSyncStatus, SyncedDoc, Change } from '../types.js';
4
4
  import { JSONRPCClient } from './protocol/JSONRPCClient.js';
5
5
  import { PatchesWebSocket } from './websocket/PatchesWebSocket.js';
6
6
  import { WebSocketOptions } from './websocket/WebSocketTransport.js';
@@ -13,12 +13,13 @@ import '@dabble/delta';
13
13
  import '../json-patch/types.js';
14
14
  import './PatchesClient.js';
15
15
  import '../utils/deferred.js';
16
- import '../BaseDoc-_Rsau70J.js';
16
+ import '../BaseDoc-BfVJNeCi.js';
17
17
 
18
18
  interface PatchesSyncState {
19
19
  online: boolean;
20
20
  connected: boolean;
21
- syncing: SyncingState;
21
+ syncStatus: DocSyncStatus;
22
+ syncError: Error | null;
22
23
  }
23
24
  interface PatchesSyncOptions {
24
25
  subscribeFilter?: (docIds: string[]) => string[];
@@ -50,6 +51,7 @@ declare class PatchesSync {
50
51
  /** Maps docId to the algorithm name used for that doc */
51
52
  protected docAlgorithms: Map<string, AlgorithmName>;
52
53
  protected _state: PatchesSyncState;
54
+ protected _syncedDocs: Record<string, SyncedDoc>;
53
55
  /**
54
56
  * Signal emitted when the sync state changes.
55
57
  */
@@ -65,6 +67,10 @@ declare class PatchesSync {
65
67
  * Provides the pending changes that were discarded so the application can handle them.
66
68
  */
67
69
  readonly onRemoteDocDeleted: Signal<(docId: string, pendingChanges: Change[]) => void>;
70
+ /**
71
+ * Signal emitted when the synced doc map changes.
72
+ */
73
+ readonly onSyncedDocsChange: Signal<(synced: Record<string, SyncedDoc>) => void>;
68
74
  constructor(patches: Patches, url: string, options?: PatchesSyncOptions | undefined);
69
75
  /**
70
76
  * Gets the algorithm for a document. Uses the open doc's algorithm if available,
@@ -83,6 +89,11 @@ declare class PatchesSync {
83
89
  * Gets the current sync state.
84
90
  */
85
91
  get state(): PatchesSyncState;
92
+ /**
93
+ * Map of all tracked documents and their current sync status.
94
+ * Updated immutably — new object reference on every change.
95
+ */
96
+ get syncedDocs(): Record<string, SyncedDoc>;
86
97
  /**
87
98
  * Gets the JSON-RPC client for making custom RPC calls.
88
99
  * Useful for application-specific methods not part of the Patches protocol.
@@ -142,6 +153,21 @@ declare class PatchesSync {
142
153
  * Cleans up local state and notifies the application with any pending changes that were lost.
143
154
  */
144
155
  protected _handleRemoteDocDeleted(docId: string): Promise<void>;
156
+ /**
157
+ * Adds, updates, or removes a synced doc entry immutably and emits onSyncedChange.
158
+ * - Pass a full SyncedDoc to add a new entry or overwrite an existing one.
159
+ * - Pass a Partial<SyncedDoc> to merge into an existing entry (no-ops if doc not in map).
160
+ * - Pass undefined to remove the entry.
161
+ * No-ops if nothing actually changed.
162
+ */
163
+ protected _updateSyncedDoc(docId: string, updates: Partial<SyncedDoc> | undefined, emit?: boolean): void;
164
+ protected _emitSyncedChange(): void;
165
+ /**
166
+ * Resets any docs with status 'syncing' back to a stable state on disconnect.
167
+ * Uses hasPending to decide: pending docs become 'synced' (they have local data),
168
+ * docs with no pending and no committed rev become 'unsynced'.
169
+ */
170
+ protected _resetSyncingStatuses(): void;
145
171
  /**
146
172
  * Applies the subscribeFilter option to a list of doc IDs, returning the subset
147
173
  * that should be sent to ws.subscribe/unsubscribe. Returns the full list if no filter is set.
@@ -11,9 +11,17 @@ import { breakChangesIntoBatches } from "../algorithms/ot/shared/changeBatching.
11
11
  import { BaseDoc } from "../client/BaseDoc.js";
12
12
  import { Patches } from "../client/Patches.js";
13
13
  import { signal } from "../event-signal.js";
14
+ import { isDocLoaded } from "../shared/utils.js";
14
15
  import { blockable } from "../utils/concurrency.js";
15
16
  import { PatchesWebSocket } from "./websocket/PatchesWebSocket.js";
16
17
  import { onlineState } from "./websocket/onlineState.js";
18
+ const EMPTY_SYNCED_DOC = {
19
+ committedRev: 0,
20
+ hasPending: false,
21
+ syncStatus: "unsynced",
22
+ syncError: null,
23
+ isLoaded: false
24
+ };
17
25
  _syncDoc_dec = [blockable], __receiveCommittedChanges_dec = [blockable];
18
26
  class PatchesSync {
19
27
  constructor(patches, url, options) {
@@ -27,7 +35,8 @@ class PatchesSync {
27
35
  __publicField(this, "trackedDocs");
28
36
  /** Maps docId to the algorithm name used for that doc */
29
37
  __publicField(this, "docAlgorithms", /* @__PURE__ */ new Map());
30
- __publicField(this, "_state", { online: false, connected: false, syncing: null });
38
+ __publicField(this, "_state", { online: false, connected: false, syncStatus: "unsynced", syncError: null });
39
+ __publicField(this, "_syncedDocs", {});
31
40
  /**
32
41
  * Signal emitted when the sync state changes.
33
42
  */
@@ -41,6 +50,10 @@ class PatchesSync {
41
50
  * Provides the pending changes that were discarded so the application can handle them.
42
51
  */
43
52
  __publicField(this, "onRemoteDocDeleted", signal());
53
+ /**
54
+ * Signal emitted when the synced doc map changes.
55
+ */
56
+ __publicField(this, "onSyncedDocsChange", signal());
44
57
  this.patches = patches;
45
58
  this.maxPayloadBytes = options?.maxPayloadBytes;
46
59
  this.maxStorageBytes = options?.maxStorageBytes ?? patches.docOptions?.maxStorageBytes;
@@ -93,6 +106,13 @@ class PatchesSync {
93
106
  get state() {
94
107
  return this._state;
95
108
  }
109
+ /**
110
+ * Map of all tracked documents and their current sync status.
111
+ * Updated immutably — new object reference on every change.
112
+ */
113
+ get syncedDocs() {
114
+ return this._syncedDocs;
115
+ }
96
116
  /**
97
117
  * Gets the JSON-RPC client for making custom RPC calls.
98
118
  * Useful for application-specific methods not part of the Patches protocol.
@@ -106,6 +126,9 @@ class PatchesSync {
106
126
  */
107
127
  updateState(update) {
108
128
  const newState = { ...this._state, ...update };
129
+ if (newState.syncStatus !== "error" && newState.syncError) {
130
+ newState.syncError = null;
131
+ }
109
132
  if (!isEqual(this._state, newState)) {
110
133
  this._state = newState;
111
134
  this.onStateChange.emit(this._state);
@@ -119,8 +142,9 @@ class PatchesSync {
119
142
  await this.ws.connect();
120
143
  } catch (err) {
121
144
  console.error("PatchesSync connection failed:", err);
122
- this.updateState({ connected: false, syncing: err instanceof Error ? err : new Error(String(err)) });
123
- this.onError.emit(err);
145
+ const error = err instanceof Error ? err : new Error(String(err));
146
+ this.updateState({ connected: false, syncStatus: "error", syncError: error });
147
+ this.onError.emit(error);
124
148
  throw err;
125
149
  }
126
150
  }
@@ -129,14 +153,15 @@ class PatchesSync {
129
153
  */
130
154
  disconnect() {
131
155
  this.ws.disconnect();
132
- this.updateState({ connected: false, syncing: null });
156
+ this.updateState({ connected: false, syncStatus: "unsynced" });
157
+ this._resetSyncingStatuses();
133
158
  }
134
159
  /**
135
160
  * Syncs all known docs when initially connected.
136
161
  */
137
162
  async syncAllKnownDocs() {
138
163
  if (!this.state.connected) return;
139
- this.updateState({ syncing: "updating" });
164
+ this.updateState({ syncStatus: "syncing" });
140
165
  try {
141
166
  const allTracked = [];
142
167
  for (const algorithm of Object.values(this.patches.algorithms)) {
@@ -153,6 +178,23 @@ class PatchesSync {
153
178
  const deletedDocs = allTracked.filter((t) => t.deleted);
154
179
  const activeDocIds = activeDocs.map((t) => t.docId);
155
180
  this.trackedDocs = new Set(activeDocIds);
181
+ const syncedEntries = {};
182
+ for (const doc of activeDocs) {
183
+ const algorithm = this._getAlgorithm(doc.docId);
184
+ const pending = await algorithm.getPendingToSend(doc.docId);
185
+ const entry = {
186
+ committedRev: doc.committedRev,
187
+ hasPending: pending != null && pending.length > 0,
188
+ syncStatus: doc.committedRev === 0 ? "unsynced" : "synced",
189
+ syncError: null,
190
+ isLoaded: false
191
+ };
192
+ const existing = this._syncedDocs[doc.docId];
193
+ entry.isLoaded = existing?.isLoaded || isDocLoaded(entry);
194
+ syncedEntries[doc.docId] = entry;
195
+ }
196
+ this._syncedDocs = syncedEntries;
197
+ this.onSyncedDocsChange.emit(this._syncedDocs);
156
198
  if (activeDocIds.length > 0) {
157
199
  try {
158
200
  const subscribeIds = this._filterSubscribeIds(activeDocIds);
@@ -178,20 +220,22 @@ class PatchesSync {
178
220
  }
179
221
  });
180
222
  await Promise.all([...activeSyncPromises, ...deletePromises]);
181
- this.updateState({ syncing: null });
223
+ this.updateState({ syncStatus: "synced" });
182
224
  } catch (error) {
183
225
  console.error("Error during global sync:", error);
184
- this.updateState({ syncing: error instanceof Error ? error : new Error(String(error)) });
185
- this.onError.emit(error);
226
+ const syncError = error instanceof Error ? error : new Error(String(error));
227
+ this.updateState({ syncStatus: "error", syncError });
228
+ this.onError.emit(syncError);
186
229
  }
187
230
  }
188
231
  async syncDoc(docId) {
189
232
  if (!this.state.connected) return;
233
+ this._updateSyncedDoc(docId, { syncStatus: "syncing" });
190
234
  const doc = this.patches.getOpenDoc(docId);
191
235
  const algorithm = this._getAlgorithm(docId);
192
236
  const baseDoc = doc;
193
237
  if (baseDoc) {
194
- baseDoc.updateSyncing("updating");
238
+ baseDoc.updateSyncStatus("syncing");
195
239
  }
196
240
  try {
197
241
  const pending = await algorithm.getPendingToSend(docId);
@@ -207,23 +251,27 @@ class PatchesSync {
207
251
  } else {
208
252
  const snapshot = await this.ws.getDoc(docId);
209
253
  await algorithm.store.saveDoc(docId, snapshot);
254
+ this._updateSyncedDoc(docId, { committedRev: snapshot.rev });
210
255
  if (baseDoc) {
211
256
  baseDoc.import({ ...snapshot, changes: [] });
212
257
  }
213
258
  }
214
259
  }
260
+ this._updateSyncedDoc(docId, { syncStatus: "synced" });
215
261
  if (baseDoc) {
216
- baseDoc.updateSyncing(null);
262
+ baseDoc.updateSyncStatus("synced");
217
263
  }
218
264
  } catch (err) {
219
265
  if (this._isDocDeletedError(err)) {
220
266
  await this._handleRemoteDocDeleted(docId);
221
267
  return;
222
268
  }
269
+ const syncError = err instanceof Error ? err : new Error(String(err));
270
+ this._updateSyncedDoc(docId, { syncStatus: "error", syncError });
223
271
  console.error(`Error syncing doc ${docId}:`, err);
224
- this.onError.emit(err, { docId });
272
+ this.onError.emit(syncError, { docId });
225
273
  if (baseDoc) {
226
- baseDoc.updateSyncing(err instanceof Error ? err : new Error(String(err)));
274
+ baseDoc.updateSyncStatus("error", syncError);
227
275
  }
228
276
  }
229
277
  }
@@ -261,13 +309,17 @@ class PatchesSync {
261
309
  await algorithm.confirmSent(docId, batch);
262
310
  pending = await algorithm.getPendingToSend(docId) ?? [];
263
311
  }
312
+ const stillHasPending = pending != null && pending.length > 0;
313
+ this._updateSyncedDoc(docId, { hasPending: stillHasPending, syncStatus: "synced" });
264
314
  } catch (err) {
265
315
  if (this._isDocDeletedError(err)) {
266
316
  await this._handleRemoteDocDeleted(docId);
267
317
  return;
268
318
  }
319
+ const flushError = err instanceof Error ? err : new Error(String(err));
320
+ this._updateSyncedDoc(docId, { syncStatus: "error", syncError: flushError });
269
321
  console.error(`Flush failed for doc ${docId}:`, err);
270
- this.onError.emit(err, { docId });
322
+ this.onError.emit(flushError, { docId });
271
323
  throw err;
272
324
  }
273
325
  }
@@ -286,6 +338,10 @@ class PatchesSync {
286
338
  const doc = this.patches.getOpenDoc(docId);
287
339
  const algorithm = this._getAlgorithm(docId);
288
340
  await algorithm.applyServerChanges(docId, serverChanges, doc);
341
+ if (serverChanges.length > 0) {
342
+ const lastRev = serverChanges[serverChanges.length - 1].rev;
343
+ this._updateSyncedDoc(docId, { committedRev: lastRev });
344
+ }
289
345
  }
290
346
  /**
291
347
  * Initiates the deletion process for a document both locally and on the server.
@@ -309,10 +365,12 @@ class PatchesSync {
309
365
  _handleConnectionChange(connectionState) {
310
366
  const isConnected = connectionState === "connected";
311
367
  const isConnecting = connectionState === "connecting";
312
- const newSyncingState = isConnected ? this._state.syncing : isConnecting ? this._state.syncing : null;
313
- this.updateState({ connected: isConnected, syncing: newSyncingState });
368
+ const newSyncStatus = isConnected ? this._state.syncStatus : isConnecting ? this._state.syncStatus : "unsynced";
369
+ this.updateState({ connected: isConnected, syncStatus: newSyncStatus });
314
370
  if (isConnected) {
315
371
  void this.syncAllKnownDocs();
372
+ } else if (!isConnecting) {
373
+ this._resetSyncingStatuses();
316
374
  }
317
375
  }
318
376
  async _handleDocsTracked(docIds) {
@@ -331,6 +389,21 @@ class PatchesSync {
331
389
  }
332
390
  }
333
391
  }
392
+ for (const docId of newIds) {
393
+ const algorithm = this._getAlgorithm(docId);
394
+ const committedRev = await algorithm.getCommittedRev(docId);
395
+ const pending = await algorithm.getPendingToSend(docId);
396
+ this._updateSyncedDoc(
397
+ docId,
398
+ {
399
+ committedRev,
400
+ hasPending: pending != null && pending.length > 0,
401
+ syncStatus: committedRev === 0 ? "unsynced" : "synced"
402
+ },
403
+ false
404
+ );
405
+ }
406
+ this._emitSyncedChange();
334
407
  if (this.state.connected) {
335
408
  try {
336
409
  const subscribeIds = this._filterSubscribeIds(newIds).filter((id) => !alreadySubscribed.has(id));
@@ -349,6 +422,8 @@ class PatchesSync {
349
422
  if (!existingIds.length) return;
350
423
  const subscribedBefore = this._getActiveSubscriptions();
351
424
  existingIds.forEach((id) => this.trackedDocs.delete(id));
425
+ existingIds.forEach((id) => this._updateSyncedDoc(id, void 0, false));
426
+ this._emitSyncedChange();
352
427
  const subscribedAfter = this._getActiveSubscriptions();
353
428
  const unsubscribeIds = [...subscribedBefore].filter((id) => !subscribedAfter.has(id));
354
429
  if (this.state.connected && unsubscribeIds.length) {
@@ -360,8 +435,10 @@ class PatchesSync {
360
435
  }
361
436
  }
362
437
  async _handleDocChange(docId) {
363
- if (!this.state.connected) return;
364
438
  if (!this.trackedDocs.has(docId)) return;
439
+ this._updateSyncedDoc(docId, { hasPending: true }, !this.state.connected);
440
+ if (!this.state.connected) return;
441
+ this._updateSyncedDoc(docId, { syncStatus: "syncing" });
365
442
  await this.flushDoc(docId);
366
443
  }
367
444
  /**
@@ -376,9 +453,54 @@ class PatchesSync {
376
453
  await this.patches.closeDoc(docId);
377
454
  }
378
455
  this.trackedDocs.delete(docId);
456
+ this._updateSyncedDoc(docId, void 0);
379
457
  await algorithm.confirmDeleteDoc(docId);
380
458
  await this.onRemoteDocDeleted.emit(docId, pendingChanges);
381
459
  }
460
+ /**
461
+ * Adds, updates, or removes a synced doc entry immutably and emits onSyncedChange.
462
+ * - Pass a full SyncedDoc to add a new entry or overwrite an existing one.
463
+ * - Pass a Partial<SyncedDoc> to merge into an existing entry (no-ops if doc not in map).
464
+ * - Pass undefined to remove the entry.
465
+ * No-ops if nothing actually changed.
466
+ */
467
+ _updateSyncedDoc(docId, updates, emit = true) {
468
+ if (updates === void 0) {
469
+ if (!(docId in this._syncedDocs)) return;
470
+ this._syncedDocs = { ...this._syncedDocs };
471
+ delete this._syncedDocs[docId];
472
+ } else {
473
+ const updated = { ...EMPTY_SYNCED_DOC, ...this._syncedDocs[docId], ...updates };
474
+ if (updated.syncStatus !== "error" && updated.syncError) {
475
+ updated.syncError = null;
476
+ }
477
+ if (!updated.isLoaded) {
478
+ updated.isLoaded = isDocLoaded(updated);
479
+ }
480
+ if (isEqual(this._syncedDocs[docId], updated)) return;
481
+ this._syncedDocs = { ...this._syncedDocs, [docId]: updated };
482
+ }
483
+ if (emit) this._emitSyncedChange();
484
+ }
485
+ _emitSyncedChange() {
486
+ this.onSyncedDocsChange.emit(this._syncedDocs);
487
+ }
488
+ /**
489
+ * Resets any docs with status 'syncing' back to a stable state on disconnect.
490
+ * Uses hasPending to decide: pending docs become 'synced' (they have local data),
491
+ * docs with no pending and no committed rev become 'unsynced'.
492
+ */
493
+ _resetSyncingStatuses() {
494
+ let changed = false;
495
+ for (const [docId, doc] of Object.entries(this._syncedDocs)) {
496
+ if (doc.syncStatus === "syncing") {
497
+ const newStatus = doc.committedRev === 0 && !doc.hasPending ? "unsynced" : "synced";
498
+ this._updateSyncedDoc(docId, { syncStatus: newStatus }, false);
499
+ changed = true;
500
+ }
501
+ }
502
+ if (changed) this._emitSyncedChange();
503
+ }
382
504
  /**
383
505
  * Applies the subscribeFilter option to a list of doc IDs, returning the subset
384
506
  * that should be sent to ws.subscribe/unsubscribe. Returns the full list if no filter is set.
@@ -17,7 +17,7 @@ import '../event-signal.js';
17
17
  import '../algorithms/ot/shared/changeBatching.js';
18
18
  import '../client/ClientAlgorithm.js';
19
19
  import '../json-patch/types.js';
20
- import '../BaseDoc-_Rsau70J.js';
20
+ import '../BaseDoc-BfVJNeCi.js';
21
21
  import '../client/PatchesStore.js';
22
22
  import '../client/Patches.js';
23
23
  import '../server/types.js';
@@ -1,5 +1,5 @@
1
1
  import { Patches, OpenDocOptions } from '../client/Patches.js';
2
- import { a as PatchesDoc } from '../BaseDoc-_Rsau70J.js';
2
+ import { a as PatchesDoc } from '../BaseDoc-BfVJNeCi.js';
3
3
  import '../event-signal.js';
4
4
  import '../json-patch/types.js';
5
5
  import '../types.js';
@@ -1,3 +1,13 @@
1
+ import { SyncedDoc } from '../types.js';
2
+ import '../json-patch/JSONPatch.js';
3
+ import '@dabble/delta';
4
+ import '../json-patch/types.js';
5
+
6
+ /**
7
+ * Returns true if a document has completed its initial load — i.e., it has data
8
+ * to display (server data, cached data, or local changes) or sync has resolved.
9
+ */
10
+ declare function isDocLoaded(doc: Pick<SyncedDoc, 'committedRev' | 'hasPending' | 'syncStatus'>): boolean;
1
11
  /**
2
12
  * Resolves a path template by replacing `:param` placeholders with values.
3
13
  *
@@ -19,4 +29,4 @@ declare function fillPath(template: string, params: Record<string, string>): str
19
29
  */
20
30
  declare function areSetsEqual(a: Set<string>, b: Set<string>): boolean;
21
31
 
22
- export { areSetsEqual, fillPath };
32
+ export { areSetsEqual, fillPath, isDocLoaded };
@@ -1,4 +1,7 @@
1
1
  import "../chunk-IZ2YBCUP.js";
2
+ function isDocLoaded(doc) {
3
+ return doc.committedRev > 0 || doc.hasPending || doc.syncStatus === "synced" || doc.syncStatus === "error";
4
+ }
2
5
  function fillPath(template, params) {
3
6
  return template.replace(/:(\w+)/g, (match, name) => {
4
7
  const value = params[name];
@@ -18,5 +21,6 @@ function areSetsEqual(a, b) {
18
21
  }
19
22
  export {
20
23
  areSetsEqual,
21
- fillPath
24
+ fillPath,
25
+ isDocLoaded
22
26
  };
@@ -7,7 +7,7 @@ import '../types.js';
7
7
  import '../json-patch/JSONPatch.js';
8
8
  import '@dabble/delta';
9
9
  import '../client/ClientAlgorithm.js';
10
- import '../BaseDoc-_Rsau70J.js';
10
+ import '../BaseDoc-BfVJNeCi.js';
11
11
  import '../client/PatchesStore.js';
12
12
  import '../net/protocol/types.js';
13
13
  import '../net/protocol/JSONRPCClient.js';
@@ -6,5 +6,5 @@ import '../types.js';
6
6
  import '../json-patch/JSONPatch.js';
7
7
  import '@dabble/delta';
8
8
  import '../client/ClientAlgorithm.js';
9
- import '../BaseDoc-_Rsau70J.js';
9
+ import '../BaseDoc-BfVJNeCi.js';
10
10
  import '../client/PatchesStore.js';
@@ -11,7 +11,7 @@ import '../types.js';
11
11
  import '../json-patch/JSONPatch.js';
12
12
  import '@dabble/delta';
13
13
  import '../client/ClientAlgorithm.js';
14
- import '../BaseDoc-_Rsau70J.js';
14
+ import '../BaseDoc-BfVJNeCi.js';
15
15
  import '../client/PatchesStore.js';
16
16
  import '../net/PatchesSync.js';
17
17
  import '../net/protocol/types.js';
@@ -1,6 +1,6 @@
1
1
  import { Accessor } from 'solid-js';
2
2
  import { OpenDocOptions } from '../client/Patches.js';
3
- import { a as PatchesDoc } from '../BaseDoc-_Rsau70J.js';
3
+ import { a as PatchesDoc } from '../BaseDoc-BfVJNeCi.js';
4
4
  import { JSONPatch } from '../json-patch/JSONPatch.js';
5
5
  import { ChangeMutator } from '../types.js';
6
6
  import '../event-signal.js';
@@ -11,7 +11,7 @@ import { JSONPatch } from "../json-patch/JSONPatch.js";
11
11
  import { usePatchesContext } from "./context.js";
12
12
  import { getDocManager } from "./doc-manager.js";
13
13
  function createDocReactiveState(options) {
14
- const { initialLoading = true, transformState, changeBehavior } = options;
14
+ const { initialLoading = true, hasSyncContext = false, transformState, changeBehavior } = options;
15
15
  const [doc, setDoc] = createSignal(void 0);
16
16
  const [data, setData] = createSignal(void 0);
17
17
  const [loading, setLoading] = createSignal(initialLoading);
@@ -20,6 +20,19 @@ function createDocReactiveState(options) {
20
20
  const [hasPending, setHasPending] = createSignal(false);
21
21
  function setupDoc(patchesDoc) {
22
22
  setDoc(patchesDoc);
23
+ let loaded = false;
24
+ function updateLoading() {
25
+ if (loaded) return;
26
+ if (patchesDoc.isLoaded) {
27
+ loaded = true;
28
+ setLoading(false);
29
+ } else if (patchesDoc.syncStatus === "syncing") {
30
+ setLoading(true);
31
+ } else if (!hasSyncContext) {
32
+ loaded = true;
33
+ setLoading(false);
34
+ }
35
+ }
23
36
  const unsubState = patchesDoc.subscribe((state) => {
24
37
  if (transformState && state) {
25
38
  state = transformState(state, patchesDoc);
@@ -27,12 +40,12 @@ function createDocReactiveState(options) {
27
40
  setData(() => state);
28
41
  setRev(patchesDoc.committedRev);
29
42
  setHasPending(patchesDoc.hasPending);
43
+ updateLoading();
30
44
  });
31
- const unsubSync = patchesDoc.onSyncing((syncState) => {
32
- setLoading(syncState === "initial" || syncState === "updating");
33
- setError(syncState instanceof Error ? syncState : null);
45
+ const unsubSync = patchesDoc.onSyncStatus((status) => {
46
+ updateLoading();
47
+ setError(status === "error" ? patchesDoc.syncError : null);
34
48
  });
35
- setLoading(patchesDoc.syncing !== null);
36
49
  return () => {
37
50
  unsubState();
38
51
  unsubSync();
@@ -92,12 +105,16 @@ function usePatchesDoc(docIdOrOptions, options) {
92
105
  return _usePatchesDocLazy(docIdOrOptions ?? {});
93
106
  }
94
107
  function _usePatchesDocEager(docId, options) {
95
- const { patches } = usePatchesContext();
108
+ const { patches, sync } = usePatchesContext();
96
109
  const { autoClose = false, algorithm, metadata } = options;
97
110
  const shouldUntrack = autoClose === "untrack";
98
111
  const openDocOpts = { algorithm, metadata };
99
112
  const manager = getDocManager(patches);
100
- const { setupDoc, setError, setLoading, baseReturn } = createDocReactiveState({ changeBehavior: "throw" });
113
+ const { setupDoc, setError, setLoading, baseReturn } = createDocReactiveState({
114
+ initialLoading: !!autoClose,
115
+ hasSyncContext: !!sync,
116
+ changeBehavior: "throw"
117
+ });
101
118
  const docIdAccessor = toAccessor(docId);
102
119
  if (autoClose) {
103
120
  const [docResource] = createResource(docIdAccessor, async (id) => {
@@ -139,10 +156,11 @@ function _usePatchesDocEager(docId, options) {
139
156
  return baseReturn;
140
157
  }
141
158
  function _usePatchesDocLazy(options) {
142
- const { patches } = usePatchesContext();
159
+ const { patches, sync } = usePatchesContext();
143
160
  const { idProp } = options;
144
161
  const { setupDoc, resetSignals, setError, setLoading, baseReturn } = createDocReactiveState({
145
162
  initialLoading: false,
163
+ hasSyncContext: !!sync,
146
164
  changeBehavior: "noop",
147
165
  transformState: idProp ? (state, patchesDoc) => ({ ...state, [idProp]: patchesDoc.id }) : void 0
148
166
  });
@@ -160,6 +178,7 @@ function _usePatchesDocLazy(options) {
160
178
  await patches.closeDoc(prevPath);
161
179
  }
162
180
  setPath(docPath);
181
+ setLoading(true);
163
182
  try {
164
183
  const patchesDoc = await patches.openDoc(docPath, options2);
165
184
  unsubscribe = setupDoc(patchesDoc);
@@ -197,11 +216,11 @@ function usePatchesSync() {
197
216
  throw new Error("PatchesSync not found in context. Did you forget to pass sync to PatchesProvider?");
198
217
  }
199
218
  const [connected, setConnected] = createSignal(sync.state.connected);
200
- const [syncing, setSyncing] = createSignal(sync.state.syncing === "updating");
219
+ const [syncing, setSyncing] = createSignal(sync.state.syncStatus === "syncing");
201
220
  const [online, setOnline] = createSignal(sync.state.online);
202
221
  const unsubscribe = sync.onStateChange((state) => {
203
222
  setConnected(state.connected);
204
- setSyncing(state.syncing === "updating");
223
+ setSyncing(state.syncStatus === "syncing");
205
224
  setOnline(state.online);
206
225
  });
207
226
  onCleanup(() => {
@@ -219,12 +238,16 @@ function toAccessor(value) {
219
238
  function createPatchesDoc(name) {
220
239
  const Context = createContext();
221
240
  function Provider(props) {
222
- const { patches } = usePatchesContext();
241
+ const { patches, sync } = usePatchesContext();
223
242
  const manager = getDocManager(patches);
224
243
  const autoClose = props.autoClose ?? false;
225
244
  const shouldUntrack = autoClose === "untrack";
226
245
  const openDocOpts = { algorithm: props.algorithm, metadata: props.metadata };
227
- const { setupDoc, setError, setLoading, baseReturn } = createDocReactiveState({ changeBehavior: "throw" });
246
+ const { setupDoc, setError, setLoading, baseReturn } = createDocReactiveState({
247
+ initialLoading: !!autoClose,
248
+ hasSyncContext: !!sync,
249
+ changeBehavior: "throw"
250
+ });
228
251
  const docIdAccessor = toAccessor(props.docId);
229
252
  if (autoClose) {
230
253
  const [docResource] = createResource(docIdAccessor, async (id) => {
@@ -1 +1,5 @@
1
1
  export { areSetsEqual, fillPath } from '../shared/utils.js';
2
+ import '../types.js';
3
+ import '../json-patch/JSONPatch.js';
4
+ import '@dabble/delta';
5
+ import '../json-patch/types.js';
package/dist/types.d.ts CHANGED
@@ -57,13 +57,30 @@ interface PatchesSnapshot<T = any> extends PatchesState<T> {
57
57
  changes: Change[];
58
58
  }
59
59
  /**
60
- * Represents the syncing state of a document.
61
- * @property initial - The document is not syncing.
62
- * @property updating - The document is syncing.
63
- * @property null - The document is not syncing.
64
- * @property Error - The document is syncing with an error.
60
+ * Sync status for a document, used by both PatchesDoc and PatchesSync's SyncedDoc.
61
+ * - `'unsynced'` not yet synced (initial state, or disconnected)
62
+ * - `'syncing'` — actively syncing with the server
63
+ * - `'synced'` — up to date with the server
64
+ * - `'error'` — sync failed (see syncError for details)
65
65
  */
66
- type SyncingState = 'initial' | 'updating' | null | Error;
66
+ type DocSyncStatus = 'unsynced' | 'syncing' | 'synced' | 'error';
67
+ /**
68
+ * Represents the synced state of a document.
69
+ * @property committedRev - The last committed revision number from the server.
70
+ * @property hasPending - Whether there are local changes that haven't been committed yet.
71
+ * @property syncStatus - The current sync status of the document.
72
+ * @property syncError - The error from the last failed sync attempt, if any.
73
+ * @property isLoaded - Whether the document has completed its initial load. Sticky: once true, never reverts to false
74
+ * within a sync lifecycle. A document is considered loaded when it has data to display (server data, cached data,
75
+ * or local changes) or sync has resolved (successfully or with error).
76
+ */
77
+ interface SyncedDoc {
78
+ committedRev: number;
79
+ hasPending: boolean;
80
+ syncStatus: DocSyncStatus;
81
+ syncError: Error | null;
82
+ isLoaded: boolean;
83
+ }
67
84
  /** Status options for a branch */
68
85
  type BranchStatus = 'open' | 'closed' | 'merged' | 'archived' | 'abandoned';
69
86
  interface Branch {
@@ -220,4 +237,4 @@ type PathProxy<T = any> = IsAny<T> extends true ? DeepPathProxy : {
220
237
  */
221
238
  type ChangeMutator<T> = (patch: JSONPatch, root: PathProxy<T>) => void;
222
239
 
223
- export type { Branch, BranchStatus, Change, ChangeInput, ChangeMutator, CommitChangesOptions, DeleteDocOptions, DocumentTombstone, EditableBranchMetadata, EditableVersionMetadata, ListChangesOptions, ListVersionsOptions, PatchesSnapshot, PatchesState, PathProxy, SyncingState, VersionMetadata };
240
+ export type { Branch, BranchStatus, Change, ChangeInput, ChangeMutator, CommitChangesOptions, DeleteDocOptions, DocSyncStatus, DocumentTombstone, EditableBranchMetadata, EditableVersionMetadata, ListChangesOptions, ListVersionsOptions, PatchesSnapshot, PatchesState, PathProxy, SyncedDoc, VersionMetadata };
@@ -1,6 +1,6 @@
1
1
  import { ShallowRef, Ref, MaybeRef } from 'vue';
2
2
  import { OpenDocOptions } from '../client/Patches.js';
3
- import { a as PatchesDoc } from '../BaseDoc-_Rsau70J.js';
3
+ import { a as PatchesDoc } from '../BaseDoc-BfVJNeCi.js';
4
4
  import { JSONPatch } from '../json-patch/JSONPatch.js';
5
5
  import { ChangeMutator } from '../types.js';
6
6
  import '../event-signal.js';
@@ -12,7 +12,7 @@ import { JSONPatch } from "../json-patch/JSONPatch.js";
12
12
  import { usePatchesContext } from "./provider.js";
13
13
  import { getDocManager } from "./doc-manager.js";
14
14
  function createDocReactiveState(options) {
15
- const { initialLoading = true, transformState, changeBehavior } = options;
15
+ const { initialLoading = true, hasSyncContext = false, transformState, changeBehavior } = options;
16
16
  const doc = ref(void 0);
17
17
  const data = shallowRef(void 0);
18
18
  const loading = ref(initialLoading);
@@ -21,6 +21,19 @@ function createDocReactiveState(options) {
21
21
  const hasPending = ref(false);
22
22
  function setupDoc(patchesDoc) {
23
23
  doc.value = patchesDoc;
24
+ let loaded = false;
25
+ function updateLoading() {
26
+ if (loaded) return;
27
+ if (patchesDoc.isLoaded) {
28
+ loaded = true;
29
+ loading.value = false;
30
+ } else if (patchesDoc.syncStatus === "syncing") {
31
+ loading.value = true;
32
+ } else if (!hasSyncContext) {
33
+ loaded = true;
34
+ loading.value = false;
35
+ }
36
+ }
24
37
  const unsubState = patchesDoc.subscribe((state) => {
25
38
  if (transformState && state) {
26
39
  state = transformState(state, patchesDoc);
@@ -28,12 +41,12 @@ function createDocReactiveState(options) {
28
41
  data.value = state;
29
42
  rev.value = patchesDoc.committedRev;
30
43
  hasPending.value = patchesDoc.hasPending;
44
+ updateLoading();
31
45
  });
32
- const unsubSync = patchesDoc.onSyncing((syncState) => {
33
- loading.value = syncState === "initial" || syncState === "updating";
34
- error.value = syncState instanceof Error ? syncState : null;
46
+ const unsubSync = patchesDoc.onSyncStatus((status) => {
47
+ updateLoading();
48
+ error.value = status === "error" ? patchesDoc.syncError : null;
35
49
  });
36
- loading.value = patchesDoc.syncing !== null;
37
50
  return () => {
38
51
  unsubState();
39
52
  unsubSync();
@@ -75,12 +88,16 @@ function usePatchesDoc(docIdOrOptions, options) {
75
88
  return _usePatchesDocLazy(docIdOrOptions ?? {});
76
89
  }
77
90
  function _usePatchesDocEager(docId, options) {
78
- const { patches } = usePatchesContext();
91
+ const { patches, sync } = usePatchesContext();
79
92
  const { autoClose = false, algorithm, metadata } = options;
80
93
  const shouldUntrack = autoClose === "untrack";
81
94
  const openDocOpts = { algorithm, metadata };
82
95
  const manager = getDocManager(patches);
83
- const { setupDoc, baseReturn } = createDocReactiveState({ changeBehavior: "throw" });
96
+ const { setupDoc, baseReturn } = createDocReactiveState({
97
+ initialLoading: !!autoClose,
98
+ hasSyncContext: !!sync,
99
+ changeBehavior: "throw"
100
+ });
84
101
  let unsubscribe = null;
85
102
  if (autoClose) {
86
103
  manager.openDoc(patches, docId, openDocOpts).then((patchesDoc) => {
@@ -110,10 +127,11 @@ function _usePatchesDocEager(docId, options) {
110
127
  return baseReturn;
111
128
  }
112
129
  function _usePatchesDocLazy(options) {
113
- const { patches } = usePatchesContext();
130
+ const { patches, sync } = usePatchesContext();
114
131
  const { idProp } = options;
115
- const { setupDoc, resetRefs, baseReturn } = createDocReactiveState({
132
+ const { setupDoc, resetRefs, loading, baseReturn } = createDocReactiveState({
116
133
  initialLoading: false,
134
+ hasSyncContext: !!sync,
117
135
  changeBehavior: "noop",
118
136
  transformState: idProp ? (state, patchesDoc) => ({ ...state, [idProp]: patchesDoc.id }) : void 0
119
137
  });
@@ -131,12 +149,13 @@ function _usePatchesDocLazy(options) {
131
149
  await patches.closeDoc(prevPath);
132
150
  }
133
151
  path.value = docPath;
152
+ loading.value = true;
134
153
  try {
135
154
  const patchesDoc = await patches.openDoc(docPath, options2);
136
155
  unsubscribe = setupDoc(patchesDoc);
137
156
  } catch (err) {
138
157
  baseReturn.error.value = err;
139
- baseReturn.loading.value = false;
158
+ loading.value = false;
140
159
  }
141
160
  }
142
161
  async function close() {
@@ -168,11 +187,11 @@ function usePatchesSync() {
168
187
  throw new Error("PatchesSync not found in context. Did you forget to pass sync to providePatchesContext()?");
169
188
  }
170
189
  const connected = ref(sync.state.connected);
171
- const syncing = ref(sync.state.syncing === "updating");
190
+ const syncing = ref(sync.state.syncStatus === "syncing");
172
191
  const online = ref(sync.state.online);
173
192
  const unsubscribe = sync.onStateChange((state) => {
174
193
  connected.value = state.connected;
175
- syncing.value = state.syncing === "updating";
194
+ syncing.value = state.syncStatus === "syncing";
176
195
  online.value = state.online;
177
196
  });
178
197
  onBeforeUnmount(() => {
@@ -188,12 +207,16 @@ function createDocInjectionKey(name) {
188
207
  return Symbol(`patches-doc-${name}`);
189
208
  }
190
209
  function providePatchesDoc(name, docId, options = {}) {
191
- const { patches } = usePatchesContext();
210
+ const { patches, sync } = usePatchesContext();
192
211
  const { autoClose = false, algorithm, metadata } = options;
193
212
  const shouldUntrack = autoClose === "untrack";
194
213
  const openDocOpts = { algorithm, metadata };
195
214
  const manager = getDocManager(patches);
196
- const { setupDoc, baseReturn } = createDocReactiveState({ changeBehavior: "throw" });
215
+ const { setupDoc, baseReturn } = createDocReactiveState({
216
+ initialLoading: !!autoClose,
217
+ hasSyncContext: !!sync,
218
+ changeBehavior: "throw"
219
+ });
197
220
  const currentDocId = ref(unref(docId));
198
221
  let unsubscribe = null;
199
222
  async function initDoc(id) {
@@ -6,5 +6,5 @@ import '../types.js';
6
6
  import '../json-patch/JSONPatch.js';
7
7
  import '@dabble/delta';
8
8
  import '../client/ClientAlgorithm.js';
9
- import '../BaseDoc-_Rsau70J.js';
9
+ import '../BaseDoc-BfVJNeCi.js';
10
10
  import '../client/PatchesStore.js';
@@ -11,7 +11,7 @@ import '../types.js';
11
11
  import '../json-patch/JSONPatch.js';
12
12
  import '@dabble/delta';
13
13
  import '../client/ClientAlgorithm.js';
14
- import '../BaseDoc-_Rsau70J.js';
14
+ import '../BaseDoc-BfVJNeCi.js';
15
15
  import '../client/PatchesStore.js';
16
16
  import '../net/PatchesSync.js';
17
17
  import '../net/protocol/types.js';
@@ -6,7 +6,7 @@ import '../types.js';
6
6
  import '../json-patch/JSONPatch.js';
7
7
  import '@dabble/delta';
8
8
  import '../client/ClientAlgorithm.js';
9
- import '../BaseDoc-_Rsau70J.js';
9
+ import '../BaseDoc-BfVJNeCi.js';
10
10
  import '../client/PatchesStore.js';
11
11
 
12
12
  /**
@@ -7,7 +7,7 @@ import '../types.js';
7
7
  import '../json-patch/JSONPatch.js';
8
8
  import '@dabble/delta';
9
9
  import '../client/ClientAlgorithm.js';
10
- import '../BaseDoc-_Rsau70J.js';
10
+ import '../BaseDoc-BfVJNeCi.js';
11
11
  import '../client/PatchesStore.js';
12
12
  import '../net/protocol/types.js';
13
13
  import '../net/protocol/JSONRPCClient.js';
@@ -1 +1,5 @@
1
1
  export { areSetsEqual, fillPath } from '../shared/utils.js';
2
+ import '../types.js';
3
+ import '../json-patch/JSONPatch.js';
4
+ import '@dabble/delta';
5
+ import '../json-patch/types.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dabble/patches",
3
- "version": "0.7.9",
3
+ "version": "0.7.11",
4
4
  "description": "Immutable JSON Patch implementation based on RFC 6902 supporting operational transformation and last-writer-wins",
5
5
  "author": "Jacob Wright <jacwright@gmail.com>",
6
6
  "bugs": {