@trestleinc/replicate 1.1.2 → 1.2.0-preview.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +40 -41
- package/dist/client/index.d.ts +34 -26
- package/dist/client/index.js +904 -732
- package/dist/component/_generated/api.d.ts +2 -2
- package/dist/component/_generated/component.d.ts +84 -27
- package/dist/component/convex.config.d.ts +2 -2
- package/dist/component/mutations.d.ts +131 -0
- package/dist/component/mutations.js +493 -0
- package/dist/component/schema.d.ts +71 -31
- package/dist/component/schema.js +37 -14
- package/dist/server/index.d.ts +58 -47
- package/dist/server/index.js +227 -132
- package/package.json +3 -1
- package/src/client/collection.ts +334 -523
- package/src/client/errors.ts +1 -1
- package/src/client/index.ts +4 -7
- package/src/client/merge.ts +2 -2
- package/src/client/persistence/indexeddb.ts +10 -14
- package/src/client/prose.ts +147 -203
- package/src/client/services/awareness.ts +373 -0
- package/src/client/services/context.ts +114 -0
- package/src/client/services/seq.ts +78 -0
- package/src/client/services/session.ts +20 -0
- package/src/client/services/sync.ts +122 -0
- package/src/client/subdocs.ts +263 -0
- package/src/component/_generated/api.ts +2 -2
- package/src/component/_generated/component.ts +73 -28
- package/src/component/mutations.ts +734 -0
- package/src/component/schema.ts +31 -14
- package/src/server/collection.ts +98 -0
- package/src/server/index.ts +2 -2
- package/src/server/{storage.ts → replicate.ts} +214 -75
- package/dist/component/public.d.ts +0 -83
- package/dist/component/public.js +0 -325
- package/src/client/prose-schema.ts +0 -55
- package/src/client/services/cursor.ts +0 -109
- package/src/component/public.ts +0 -453
- package/src/server/builder.ts +0 -98
- /package/src/client/{replicate.ts → ops.ts} +0 -0
package/src/client/errors.ts
CHANGED
|
@@ -26,7 +26,7 @@ export class ReconciliationError extends Data.TaggedError("ReconciliationError")
|
|
|
26
26
|
}> {}
|
|
27
27
|
|
|
28
28
|
export class ProseError extends Data.TaggedError("ProseError")<{
|
|
29
|
-
readonly
|
|
29
|
+
readonly document: string;
|
|
30
30
|
readonly field: string;
|
|
31
31
|
readonly collection: string;
|
|
32
32
|
}> {}
|
package/src/client/index.ts
CHANGED
|
@@ -5,6 +5,8 @@ export {
|
|
|
5
5
|
type Materialized,
|
|
6
6
|
} from "$/client/collection";
|
|
7
7
|
|
|
8
|
+
export { type Seq } from "$/client/services/seq";
|
|
9
|
+
|
|
8
10
|
import {
|
|
9
11
|
NetworkError,
|
|
10
12
|
IDBError,
|
|
@@ -25,14 +27,9 @@ export const errors = {
|
|
|
25
27
|
NonRetriable: NonRetriableError,
|
|
26
28
|
} as const;
|
|
27
29
|
|
|
28
|
-
import type { ProseValue } from "$/shared/types";
|
|
29
30
|
import { extract } from "$/client/merge";
|
|
30
|
-
import { prose as proseSchema } from "$/client/prose
|
|
31
|
-
|
|
32
|
-
function empty(): ProseValue {
|
|
33
|
-
return { type: "doc", content: [] } as unknown as ProseValue;
|
|
34
|
-
}
|
|
31
|
+
import { prose as proseSchema } from "$/client/prose";
|
|
35
32
|
|
|
36
|
-
export const prose = Object.assign(proseSchema, { extract
|
|
33
|
+
export const prose = Object.assign(proseSchema, { extract });
|
|
37
34
|
|
|
38
35
|
export { persistence, type StorageAdapter, type Persistence } from "$/client/persistence/index";
|
package/src/client/merge.ts
CHANGED
|
@@ -360,10 +360,10 @@ export function serializeYMapValue(value: unknown): unknown {
|
|
|
360
360
|
*/
|
|
361
361
|
export function getFragmentFromYMap(
|
|
362
362
|
ymap: Y.Map<unknown>,
|
|
363
|
-
|
|
363
|
+
document: string,
|
|
364
364
|
field: string,
|
|
365
365
|
): Y.XmlFragment | null {
|
|
366
|
-
const doc = ymap.get(
|
|
366
|
+
const doc = ymap.get(document);
|
|
367
367
|
if (!isYMap(doc)) {
|
|
368
368
|
return null;
|
|
369
369
|
}
|
|
@@ -12,15 +12,10 @@ function openDatabase(dbName: string): Promise<IDBDatabase> {
|
|
|
12
12
|
request.onsuccess = () => resolve(request.result);
|
|
13
13
|
request.onupgradeneeded = () => {
|
|
14
14
|
const db = request.result;
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
db.createObjectStore(SNAPSHOTS_STORE);
|
|
20
|
-
}
|
|
21
|
-
if (!db.objectStoreNames.contains(KV_STORE)) {
|
|
22
|
-
db.createObjectStore(KV_STORE);
|
|
23
|
-
}
|
|
15
|
+
db.createObjectStore(SNAPSHOTS_STORE);
|
|
16
|
+
db.createObjectStore(KV_STORE);
|
|
17
|
+
const updatesStore = db.createObjectStore(UPDATES_STORE, { autoIncrement: true });
|
|
18
|
+
updatesStore.createIndex("by_collection", "collection", { unique: false });
|
|
24
19
|
};
|
|
25
20
|
});
|
|
26
21
|
}
|
|
@@ -91,12 +86,13 @@ class IDBPersistenceProvider implements PersistenceProvider {
|
|
|
91
86
|
}
|
|
92
87
|
|
|
93
88
|
const updatesStore = tx.objectStore(UPDATES_STORE);
|
|
94
|
-
const
|
|
89
|
+
const index = updatesStore.index("by_collection");
|
|
90
|
+
const updatesRequest = index.getAll(this.collection);
|
|
95
91
|
|
|
96
92
|
updatesRequest.onsuccess = () => {
|
|
97
|
-
const
|
|
98
|
-
for (const
|
|
99
|
-
Y.applyUpdate(this.ydoc,
|
|
93
|
+
const records = updatesRequest.result as { collection: string; data: Uint8Array }[];
|
|
94
|
+
for (const record of records) {
|
|
95
|
+
Y.applyUpdate(this.ydoc, record.data, "idb");
|
|
100
96
|
}
|
|
101
97
|
resolve();
|
|
102
98
|
};
|
|
@@ -114,7 +110,7 @@ class IDBPersistenceProvider implements PersistenceProvider {
|
|
|
114
110
|
return new Promise((resolve, reject) => {
|
|
115
111
|
const tx = this.db.transaction(UPDATES_STORE, "readwrite");
|
|
116
112
|
const store = tx.objectStore(UPDATES_STORE);
|
|
117
|
-
const request = store.add(update);
|
|
113
|
+
const request = store.add({ collection: this.collection, data: update });
|
|
118
114
|
request.onerror = () => reject(request.error ?? new Error("IndexedDB save update failed"));
|
|
119
115
|
request.onsuccess = () => resolve();
|
|
120
116
|
});
|
package/src/client/prose.ts
CHANGED
|
@@ -2,179 +2,128 @@
|
|
|
2
2
|
* Prose Field Helpers - Document-level state management for rich text sync
|
|
3
3
|
*
|
|
4
4
|
* Manages Y.XmlFragment observation, debounced sync, and pending state.
|
|
5
|
-
* Uses
|
|
5
|
+
* Uses CollectionContext for state storage.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import * as Y from "yjs";
|
|
9
|
+
import { z } from "zod";
|
|
9
10
|
import type { Collection } from "@tanstack/db";
|
|
10
11
|
import { getLogger } from "$/client/logger";
|
|
11
12
|
import { serializeYMapValue } from "$/client/merge";
|
|
13
|
+
import { getContext, hasContext, type ProseState } from "$/client/services/context";
|
|
14
|
+
import type { ProseValue } from "$/shared/types";
|
|
12
15
|
|
|
13
|
-
/** Server origin - changes from server should not trigger local sync */
|
|
14
16
|
const SERVER_ORIGIN = "server";
|
|
17
|
+
const noop = (): void => undefined;
|
|
15
18
|
|
|
16
19
|
const logger = getLogger(["replicate", "prose"]);
|
|
17
20
|
|
|
18
|
-
// Default debounce time for prose sync
|
|
19
21
|
const DEFAULT_DEBOUNCE_MS = 1000;
|
|
20
22
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
// Track when applying server data to prevent echo loops - DOCUMENT-LEVEL
|
|
26
|
-
const applyingFromServer = new Map<string, boolean>();
|
|
27
|
-
|
|
28
|
-
// Debounce timers for prose sync
|
|
29
|
-
const debounceTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
30
|
-
|
|
31
|
-
// Last synced state vectors for computing deltas
|
|
32
|
-
const lastSyncedVectors = new Map<string, Uint8Array>();
|
|
33
|
-
|
|
34
|
-
// Pending sync state
|
|
35
|
-
const pendingState = new Map<string, boolean>();
|
|
36
|
-
|
|
37
|
-
// Pending state change listeners
|
|
38
|
-
const pendingListeners = new Map<string, Set<(pending: boolean) => void>>();
|
|
39
|
-
|
|
40
|
-
// Fragment observer cleanup functions
|
|
41
|
-
const fragmentObservers = new Map<string, () => void>();
|
|
42
|
-
|
|
43
|
-
// Failed sync queue for retry
|
|
44
|
-
const failedSyncQueue = new Map<string, boolean>();
|
|
45
|
-
|
|
46
|
-
// ============================================================================
|
|
47
|
-
// Applying From Server (Document-Level)
|
|
48
|
-
// ============================================================================
|
|
23
|
+
function getProseState(collection: string): ProseState | null {
|
|
24
|
+
if (!hasContext(collection)) return null;
|
|
25
|
+
return getContext(collection).prose;
|
|
26
|
+
}
|
|
49
27
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
export function isApplyingFromServer(collection: string, documentId: string): boolean {
|
|
55
|
-
const key = `${collection}:${documentId}`;
|
|
56
|
-
return applyingFromServer.get(key) ?? false;
|
|
28
|
+
export function isApplyingFromServer(collection: string, document: string): boolean {
|
|
29
|
+
const state = getProseState(collection);
|
|
30
|
+
if (!state) return false;
|
|
31
|
+
return state.applyingFromServer.get(document) ?? false;
|
|
57
32
|
}
|
|
58
33
|
|
|
59
|
-
/**
|
|
60
|
-
* Set whether a document is currently applying server data.
|
|
61
|
-
*/
|
|
62
34
|
export function setApplyingFromServer(
|
|
63
35
|
collection: string,
|
|
64
|
-
|
|
36
|
+
document: string,
|
|
65
37
|
value: boolean,
|
|
66
38
|
): void {
|
|
67
|
-
const
|
|
39
|
+
const state = getProseState(collection);
|
|
40
|
+
if (!state) return;
|
|
68
41
|
if (value) {
|
|
69
|
-
applyingFromServer.set(
|
|
42
|
+
state.applyingFromServer.set(document, true);
|
|
70
43
|
}
|
|
71
44
|
else {
|
|
72
|
-
applyingFromServer.delete(
|
|
45
|
+
state.applyingFromServer.delete(document);
|
|
73
46
|
}
|
|
74
47
|
}
|
|
75
48
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
/**
|
|
81
|
-
* Set pending state and notify listeners.
|
|
82
|
-
*/
|
|
83
|
-
function setPendingInternal(key: string, value: boolean): void {
|
|
84
|
-
const current = pendingState.get(key) ?? false;
|
|
49
|
+
function setPendingInternal(collection: string, document: string, value: boolean): void {
|
|
50
|
+
const state = getProseState(collection);
|
|
51
|
+
if (!state) return;
|
|
85
52
|
|
|
53
|
+
const current = state.pendingState.get(document) ?? false;
|
|
86
54
|
if (current !== value) {
|
|
87
|
-
pendingState.set(
|
|
88
|
-
const listeners = pendingListeners.get(
|
|
55
|
+
state.pendingState.set(document, value);
|
|
56
|
+
const listeners = state.pendingListeners.get(document);
|
|
89
57
|
if (listeners) {
|
|
90
58
|
for (const cb of listeners) {
|
|
91
59
|
try {
|
|
92
60
|
cb(value);
|
|
93
61
|
}
|
|
94
62
|
catch (err) {
|
|
95
|
-
logger.error("Pending listener error", {
|
|
63
|
+
logger.error("Pending listener error", { collection, document, error: String(err) });
|
|
96
64
|
}
|
|
97
65
|
}
|
|
98
66
|
}
|
|
99
67
|
}
|
|
100
68
|
}
|
|
101
69
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
return pendingState.get(`${collection}:${documentId}`) ?? false;
|
|
70
|
+
export function isPending(collection: string, document: string): boolean {
|
|
71
|
+
const state = getProseState(collection);
|
|
72
|
+
if (!state) return false;
|
|
73
|
+
return state.pendingState.get(document) ?? false;
|
|
107
74
|
}
|
|
108
75
|
|
|
109
|
-
/**
|
|
110
|
-
* Subscribe to pending state changes for a document.
|
|
111
|
-
*/
|
|
112
76
|
export function subscribePending(
|
|
113
77
|
collection: string,
|
|
114
|
-
|
|
78
|
+
document: string,
|
|
115
79
|
callback: (pending: boolean) => void,
|
|
116
80
|
): () => void {
|
|
117
|
-
const
|
|
81
|
+
const state = getProseState(collection);
|
|
82
|
+
if (!state) return noop;
|
|
118
83
|
|
|
119
|
-
let listeners = pendingListeners.get(
|
|
84
|
+
let listeners = state.pendingListeners.get(document);
|
|
120
85
|
if (!listeners) {
|
|
121
86
|
listeners = new Set();
|
|
122
|
-
pendingListeners.set(
|
|
87
|
+
state.pendingListeners.set(document, listeners);
|
|
123
88
|
}
|
|
124
89
|
|
|
125
90
|
listeners.add(callback);
|
|
126
91
|
return () => {
|
|
127
92
|
listeners?.delete(callback);
|
|
128
93
|
if (listeners?.size === 0) {
|
|
129
|
-
pendingListeners.delete(
|
|
94
|
+
state.pendingListeners.delete(document);
|
|
130
95
|
}
|
|
131
96
|
};
|
|
132
97
|
}
|
|
133
98
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
/**
|
|
139
|
-
* Cancel any pending debounced sync for a document.
|
|
140
|
-
* Called when receiving remote updates to avoid conflicts.
|
|
141
|
-
*/
|
|
142
|
-
export function cancelPending(collection: string, documentId: string): void {
|
|
143
|
-
const key = `${collection}:${documentId}`;
|
|
144
|
-
const timer = debounceTimers.get(key);
|
|
99
|
+
export function cancelPending(collection: string, document: string): void {
|
|
100
|
+
const state = getProseState(collection);
|
|
101
|
+
if (!state) return;
|
|
145
102
|
|
|
103
|
+
const timer = state.debounceTimers.get(document);
|
|
146
104
|
if (timer) {
|
|
147
105
|
clearTimeout(timer);
|
|
148
|
-
debounceTimers.delete(
|
|
149
|
-
setPendingInternal(
|
|
150
|
-
logger.debug("Cancelled pending sync due to remote update", { collection,
|
|
106
|
+
state.debounceTimers.delete(document);
|
|
107
|
+
setPendingInternal(collection, document, false);
|
|
108
|
+
logger.debug("Cancelled pending sync due to remote update", { collection, document });
|
|
151
109
|
}
|
|
152
110
|
}
|
|
153
111
|
|
|
154
|
-
/**
|
|
155
|
-
* Cancel all pending syncs for a collection.
|
|
156
|
-
* Called when receiving a snapshot that replaces all state.
|
|
157
|
-
*/
|
|
158
112
|
export function cancelAllPending(collection: string): void {
|
|
159
|
-
const
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
113
|
+
const state = getProseState(collection);
|
|
114
|
+
if (!state) return;
|
|
115
|
+
|
|
116
|
+
for (const [doc, timer] of state.debounceTimers) {
|
|
117
|
+
clearTimeout(timer);
|
|
118
|
+
state.debounceTimers.delete(doc);
|
|
119
|
+
setPendingInternal(collection, doc, false);
|
|
166
120
|
}
|
|
167
121
|
logger.debug("Cancelled all pending syncs", { collection });
|
|
168
122
|
}
|
|
169
123
|
|
|
170
|
-
// ============================================================================
|
|
171
|
-
// Fragment Observation
|
|
172
|
-
// ============================================================================
|
|
173
|
-
|
|
174
|
-
/** Configuration for fragment observation */
|
|
175
124
|
export interface ProseObserverConfig {
|
|
176
125
|
collection: string;
|
|
177
|
-
|
|
126
|
+
document: string;
|
|
178
127
|
field: string;
|
|
179
128
|
fragment: Y.XmlFragment;
|
|
180
129
|
ydoc: Y.Doc;
|
|
@@ -183,14 +132,10 @@ export interface ProseObserverConfig {
|
|
|
183
132
|
debounceMs?: number;
|
|
184
133
|
}
|
|
185
134
|
|
|
186
|
-
/**
|
|
187
|
-
* Set up observation for a prose field's Y.XmlFragment.
|
|
188
|
-
* Returns a cleanup function.
|
|
189
|
-
*/
|
|
190
135
|
export function observeFragment(config: ProseObserverConfig): () => void {
|
|
191
136
|
const {
|
|
192
137
|
collection,
|
|
193
|
-
|
|
138
|
+
document,
|
|
194
139
|
field,
|
|
195
140
|
fragment,
|
|
196
141
|
ydoc,
|
|
@@ -198,175 +143,174 @@ export function observeFragment(config: ProseObserverConfig): () => void {
|
|
|
198
143
|
collectionRef,
|
|
199
144
|
debounceMs = DEFAULT_DEBOUNCE_MS,
|
|
200
145
|
} = config;
|
|
201
|
-
const key = `${collection}:${documentId}`;
|
|
202
146
|
|
|
203
|
-
|
|
204
|
-
|
|
147
|
+
const state = getProseState(collection);
|
|
148
|
+
if (!state) {
|
|
149
|
+
logger.warn("Cannot observe fragment - collection not initialized", { collection, document });
|
|
150
|
+
return noop;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const existingCleanup = state.fragmentObservers.get(document);
|
|
205
154
|
if (existingCleanup) {
|
|
206
|
-
logger.debug("Fragment already being observed", { collection,
|
|
155
|
+
logger.debug("Fragment already being observed", { collection, document, field });
|
|
207
156
|
return existingCleanup;
|
|
208
157
|
}
|
|
209
158
|
|
|
210
159
|
const observerHandler = (_events: Y.YEvent<any>[], transaction: Y.Transaction) => {
|
|
211
|
-
// Skip server-originated changes (echo prevention via transaction origin)
|
|
212
160
|
if (transaction.origin === SERVER_ORIGIN) {
|
|
213
161
|
return;
|
|
214
162
|
}
|
|
215
163
|
|
|
216
|
-
|
|
217
|
-
const existing = debounceTimers.get(key);
|
|
164
|
+
const existing = state.debounceTimers.get(document);
|
|
218
165
|
if (existing) clearTimeout(existing);
|
|
219
166
|
|
|
220
|
-
|
|
221
|
-
setPendingInternal(key, true);
|
|
167
|
+
setPendingInternal(collection, document, true);
|
|
222
168
|
|
|
223
|
-
// Schedule sync
|
|
224
169
|
const timer = setTimeout(async () => {
|
|
225
|
-
debounceTimers.delete(
|
|
226
|
-
|
|
227
|
-
const itemYMap = ymap.get(documentId) as Y.Map<unknown> | undefined;
|
|
228
|
-
if (!itemYMap) {
|
|
229
|
-
logger.error("Document not found", { collection, documentId });
|
|
230
|
-
setPendingInternal(key, false);
|
|
231
|
-
return;
|
|
232
|
-
}
|
|
170
|
+
state.debounceTimers.delete(document);
|
|
233
171
|
|
|
234
172
|
try {
|
|
235
|
-
|
|
236
|
-
const lastVector = lastSyncedVectors.get(key);
|
|
173
|
+
const lastVector = state.lastSyncedVectors.get(document);
|
|
237
174
|
const delta = lastVector
|
|
238
175
|
? Y.encodeStateAsUpdateV2(ydoc, lastVector)
|
|
239
176
|
: Y.encodeStateAsUpdateV2(ydoc);
|
|
240
177
|
|
|
241
178
|
if (delta.length <= 2) {
|
|
242
|
-
logger.debug("No changes to sync", { collection,
|
|
243
|
-
setPendingInternal(
|
|
179
|
+
logger.debug("No changes to sync", { collection, document });
|
|
180
|
+
setPendingInternal(collection, document, false);
|
|
244
181
|
return;
|
|
245
182
|
}
|
|
246
183
|
|
|
247
|
-
const
|
|
184
|
+
const bytes = delta.buffer as ArrayBuffer;
|
|
248
185
|
const currentVector = Y.encodeStateVector(ydoc);
|
|
249
186
|
|
|
250
187
|
logger.debug("Syncing prose delta", {
|
|
251
188
|
collection,
|
|
252
|
-
|
|
189
|
+
document,
|
|
253
190
|
deltaSize: delta.byteLength,
|
|
254
191
|
});
|
|
255
192
|
|
|
256
|
-
const
|
|
193
|
+
const material = serializeYMapValue(ymap);
|
|
257
194
|
|
|
258
|
-
// Send via collection.update with contentSync metadata
|
|
259
195
|
const result = collectionRef.update(
|
|
260
|
-
|
|
261
|
-
{ metadata: { contentSync: {
|
|
196
|
+
document,
|
|
197
|
+
{ metadata: { contentSync: { bytes, material } } },
|
|
262
198
|
(draft: any) => {
|
|
263
199
|
draft.updatedAt = Date.now();
|
|
264
200
|
},
|
|
265
201
|
);
|
|
266
202
|
await result.isPersisted.promise;
|
|
267
203
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
logger.debug("Prose sync completed", { collection, documentId });
|
|
204
|
+
state.lastSyncedVectors.set(document, currentVector);
|
|
205
|
+
state.failedSyncQueue.delete(document);
|
|
206
|
+
setPendingInternal(collection, document, false);
|
|
207
|
+
logger.debug("Prose sync completed", { collection, document });
|
|
273
208
|
}
|
|
274
209
|
catch (err) {
|
|
275
210
|
logger.error("Prose sync failed, queued for retry", {
|
|
276
211
|
collection,
|
|
277
|
-
|
|
212
|
+
document,
|
|
278
213
|
error: String(err),
|
|
279
214
|
});
|
|
280
|
-
failedSyncQueue.set(
|
|
281
|
-
// Keep pending=true for retry indication
|
|
215
|
+
state.failedSyncQueue.set(document, true);
|
|
282
216
|
}
|
|
283
217
|
}, debounceMs);
|
|
284
218
|
|
|
285
|
-
debounceTimers.set(
|
|
219
|
+
state.debounceTimers.set(document, timer);
|
|
286
220
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
logger.debug("Retrying failed sync", { collection, documentId });
|
|
221
|
+
if (state.failedSyncQueue.has(document)) {
|
|
222
|
+
state.failedSyncQueue.delete(document);
|
|
223
|
+
logger.debug("Retrying failed sync", { collection, document });
|
|
291
224
|
}
|
|
292
225
|
};
|
|
293
226
|
|
|
294
|
-
// Set up deep observation on the fragment
|
|
295
227
|
fragment.observeDeep(observerHandler);
|
|
296
228
|
|
|
297
229
|
const cleanup = () => {
|
|
298
230
|
fragment.unobserveDeep(observerHandler);
|
|
299
|
-
cancelPending(collection,
|
|
300
|
-
fragmentObservers.delete(
|
|
301
|
-
lastSyncedVectors.delete(
|
|
302
|
-
logger.debug("Fragment observer cleaned up", { collection,
|
|
231
|
+
cancelPending(collection, document);
|
|
232
|
+
state.fragmentObservers.delete(document);
|
|
233
|
+
state.lastSyncedVectors.delete(document);
|
|
234
|
+
logger.debug("Fragment observer cleaned up", { collection, document, field });
|
|
303
235
|
};
|
|
304
236
|
|
|
305
|
-
fragmentObservers.set(
|
|
306
|
-
logger.debug("Fragment observer registered", { collection,
|
|
237
|
+
state.fragmentObservers.set(document, cleanup);
|
|
238
|
+
logger.debug("Fragment observer registered", { collection, document, field });
|
|
307
239
|
|
|
308
240
|
return cleanup;
|
|
309
241
|
}
|
|
310
242
|
|
|
311
|
-
// ============================================================================
|
|
312
|
-
// Cleanup
|
|
313
|
-
// ============================================================================
|
|
314
|
-
|
|
315
|
-
/**
|
|
316
|
-
* Clean up all prose state for a collection.
|
|
317
|
-
* Called when collection is destroyed.
|
|
318
|
-
*/
|
|
319
243
|
export function cleanup(collection: string): void {
|
|
320
|
-
const
|
|
321
|
-
|
|
322
|
-
// Cancel all pending syncs
|
|
323
|
-
for (const [key, timer] of debounceTimers) {
|
|
324
|
-
if (key.startsWith(prefix)) {
|
|
325
|
-
clearTimeout(timer);
|
|
326
|
-
debounceTimers.delete(key);
|
|
327
|
-
}
|
|
328
|
-
}
|
|
244
|
+
const state = getProseState(collection);
|
|
245
|
+
if (!state) return;
|
|
329
246
|
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
if (key.startsWith(prefix)) {
|
|
333
|
-
pendingState.delete(key);
|
|
334
|
-
}
|
|
247
|
+
for (const [, timer] of state.debounceTimers) {
|
|
248
|
+
clearTimeout(timer);
|
|
335
249
|
}
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
250
|
+
state.debounceTimers.clear();
|
|
251
|
+
state.pendingState.clear();
|
|
252
|
+
state.pendingListeners.clear();
|
|
253
|
+
state.applyingFromServer.clear();
|
|
254
|
+
state.lastSyncedVectors.clear();
|
|
255
|
+
|
|
256
|
+
for (const [, cleanupFn] of state.fragmentObservers) {
|
|
257
|
+
cleanupFn();
|
|
340
258
|
}
|
|
259
|
+
state.fragmentObservers.clear();
|
|
260
|
+
state.failedSyncQueue.clear();
|
|
341
261
|
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
if (key.startsWith(prefix)) {
|
|
345
|
-
applyingFromServer.delete(key);
|
|
346
|
-
}
|
|
347
|
-
}
|
|
262
|
+
logger.debug("Prose cleanup complete", { collection });
|
|
263
|
+
}
|
|
348
264
|
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
265
|
+
const PROSE_MARKER = Symbol.for("replicate:prose");
|
|
266
|
+
|
|
267
|
+
function createProseSchema(): z.ZodType<ProseValue> {
|
|
268
|
+
const schema = z.custom<ProseValue>(
|
|
269
|
+
(val) => {
|
|
270
|
+
if (val == null) return true;
|
|
271
|
+
if (typeof val !== "object") return false;
|
|
272
|
+
return (val as { type?: string }).type === "doc";
|
|
273
|
+
},
|
|
274
|
+
{ message: "Expected prose document with type \"doc\"" },
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
Object.defineProperty(schema, PROSE_MARKER, { value: true, writable: false });
|
|
278
|
+
|
|
279
|
+
return schema;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function emptyProse(): ProseValue {
|
|
283
|
+
return { type: "doc", content: [] } as unknown as ProseValue;
|
|
284
|
+
}
|
|
355
285
|
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
286
|
+
export function prose(): z.ZodType<ProseValue> {
|
|
287
|
+
return createProseSchema();
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
prose.empty = emptyProse;
|
|
291
|
+
|
|
292
|
+
export function isProseSchema(schema: unknown): boolean {
|
|
293
|
+
return (
|
|
294
|
+
schema != null
|
|
295
|
+
&& typeof schema === "object"
|
|
296
|
+
&& PROSE_MARKER in schema
|
|
297
|
+
&& (schema as Record<symbol, unknown>)[PROSE_MARKER] === true
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export function extractProseFields(schema: z.ZodObject<z.ZodRawShape>): string[] {
|
|
302
|
+
const fields: string[] = [];
|
|
303
|
+
|
|
304
|
+
for (const [key, fieldSchema] of Object.entries(schema.shape)) {
|
|
305
|
+
let unwrapped = fieldSchema;
|
|
306
|
+
while (unwrapped instanceof z.ZodOptional || unwrapped instanceof z.ZodNullable) {
|
|
307
|
+
unwrapped = unwrapped.unwrap();
|
|
361
308
|
}
|
|
362
|
-
}
|
|
363
309
|
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
if (key.startsWith(prefix)) {
|
|
367
|
-
failedSyncQueue.delete(key);
|
|
310
|
+
if (isProseSchema(unwrapped)) {
|
|
311
|
+
fields.push(key);
|
|
368
312
|
}
|
|
369
313
|
}
|
|
370
314
|
|
|
371
|
-
|
|
315
|
+
return fields;
|
|
372
316
|
}
|