@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.
Files changed (39) hide show
  1. package/README.md +40 -41
  2. package/dist/client/index.d.ts +34 -26
  3. package/dist/client/index.js +904 -732
  4. package/dist/component/_generated/api.d.ts +2 -2
  5. package/dist/component/_generated/component.d.ts +84 -27
  6. package/dist/component/convex.config.d.ts +2 -2
  7. package/dist/component/mutations.d.ts +131 -0
  8. package/dist/component/mutations.js +493 -0
  9. package/dist/component/schema.d.ts +71 -31
  10. package/dist/component/schema.js +37 -14
  11. package/dist/server/index.d.ts +58 -47
  12. package/dist/server/index.js +227 -132
  13. package/package.json +3 -1
  14. package/src/client/collection.ts +334 -523
  15. package/src/client/errors.ts +1 -1
  16. package/src/client/index.ts +4 -7
  17. package/src/client/merge.ts +2 -2
  18. package/src/client/persistence/indexeddb.ts +10 -14
  19. package/src/client/prose.ts +147 -203
  20. package/src/client/services/awareness.ts +373 -0
  21. package/src/client/services/context.ts +114 -0
  22. package/src/client/services/seq.ts +78 -0
  23. package/src/client/services/session.ts +20 -0
  24. package/src/client/services/sync.ts +122 -0
  25. package/src/client/subdocs.ts +263 -0
  26. package/src/component/_generated/api.ts +2 -2
  27. package/src/component/_generated/component.ts +73 -28
  28. package/src/component/mutations.ts +734 -0
  29. package/src/component/schema.ts +31 -14
  30. package/src/server/collection.ts +98 -0
  31. package/src/server/index.ts +2 -2
  32. package/src/server/{storage.ts → replicate.ts} +214 -75
  33. package/dist/component/public.d.ts +0 -83
  34. package/dist/component/public.js +0 -325
  35. package/src/client/prose-schema.ts +0 -55
  36. package/src/client/services/cursor.ts +0 -109
  37. package/src/component/public.ts +0 -453
  38. package/src/server/builder.ts +0 -98
  39. /package/src/client/{replicate.ts → ops.ts} +0 -0
@@ -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 documentId: string;
29
+ readonly document: string;
30
30
  readonly field: string;
31
31
  readonly collection: string;
32
32
  }> {}
@@ -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-schema";
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, empty });
33
+ export const prose = Object.assign(proseSchema, { extract });
37
34
 
38
35
  export { persistence, type StorageAdapter, type Persistence } from "$/client/persistence/index";
@@ -360,10 +360,10 @@ export function serializeYMapValue(value: unknown): unknown {
360
360
  */
361
361
  export function getFragmentFromYMap(
362
362
  ymap: Y.Map<unknown>,
363
- documentId: string,
363
+ document: string,
364
364
  field: string,
365
365
  ): Y.XmlFragment | null {
366
- const doc = ymap.get(documentId);
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
- if (!db.objectStoreNames.contains(UPDATES_STORE)) {
16
- db.createObjectStore(UPDATES_STORE, { autoIncrement: true });
17
- }
18
- if (!db.objectStoreNames.contains(SNAPSHOTS_STORE)) {
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 updatesRequest = updatesStore.getAll();
89
+ const index = updatesStore.index("by_collection");
90
+ const updatesRequest = index.getAll(this.collection);
95
91
 
96
92
  updatesRequest.onsuccess = () => {
97
- const updates = updatesRequest.result as Uint8Array[];
98
- for (const update of updates) {
99
- Y.applyUpdate(this.ydoc, update, "idb");
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
  });
@@ -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 document-level tracking to prevent race conditions.
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
- // Document-Level State (keyed by "collection:documentId")
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
- * Check if a document is currently applying server data.
52
- * Used to prevent echo loops in onUpdate handlers.
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
- documentId: string,
36
+ document: string,
65
37
  value: boolean,
66
38
  ): void {
67
- const key = `${collection}:${documentId}`;
39
+ const state = getProseState(collection);
40
+ if (!state) return;
68
41
  if (value) {
69
- applyingFromServer.set(key, true);
42
+ state.applyingFromServer.set(document, true);
70
43
  }
71
44
  else {
72
- applyingFromServer.delete(key);
45
+ state.applyingFromServer.delete(document);
73
46
  }
74
47
  }
75
48
 
76
- // ============================================================================
77
- // Pending State Management
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(key, value);
88
- const listeners = pendingListeners.get(key);
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", { key, error: String(err) });
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
- * Get current pending state for a document.
104
- */
105
- export function isPending(collection: string, documentId: string): boolean {
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
- documentId: string,
78
+ document: string,
115
79
  callback: (pending: boolean) => void,
116
80
  ): () => void {
117
- const key = `${collection}:${documentId}`;
81
+ const state = getProseState(collection);
82
+ if (!state) return noop;
118
83
 
119
- let listeners = pendingListeners.get(key);
84
+ let listeners = state.pendingListeners.get(document);
120
85
  if (!listeners) {
121
86
  listeners = new Set();
122
- pendingListeners.set(key, listeners);
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(key);
94
+ state.pendingListeners.delete(document);
130
95
  }
131
96
  };
132
97
  }
133
98
 
134
- // ============================================================================
135
- // Cancel Pending Sync
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(key);
149
- setPendingInternal(key, false);
150
- logger.debug("Cancelled pending sync due to remote update", { collection, documentId });
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 prefix = `${collection}:`;
160
- for (const [key, timer] of debounceTimers) {
161
- if (key.startsWith(prefix)) {
162
- clearTimeout(timer);
163
- debounceTimers.delete(key);
164
- setPendingInternal(key, false);
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
- documentId: string;
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
- documentId,
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
- // Skip if already observing this document
204
- const existingCleanup = fragmentObservers.get(key);
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, documentId, field });
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
- // Clear existing timer
217
- const existing = debounceTimers.get(key);
164
+ const existing = state.debounceTimers.get(document);
218
165
  if (existing) clearTimeout(existing);
219
166
 
220
- // Mark as pending
221
- setPendingInternal(key, true);
167
+ setPendingInternal(collection, document, true);
222
168
 
223
- // Schedule sync
224
169
  const timer = setTimeout(async () => {
225
- debounceTimers.delete(key);
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
- // Compute delta since last sync
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, documentId });
243
- setPendingInternal(key, false);
179
+ logger.debug("No changes to sync", { collection, document });
180
+ setPendingInternal(collection, document, false);
244
181
  return;
245
182
  }
246
183
 
247
- const crdtBytes = delta.buffer as ArrayBuffer;
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
- documentId,
189
+ document,
253
190
  deltaSize: delta.byteLength,
254
191
  });
255
192
 
256
- const materializedDoc = serializeYMapValue(itemYMap);
193
+ const material = serializeYMapValue(ymap);
257
194
 
258
- // Send via collection.update with contentSync metadata
259
195
  const result = collectionRef.update(
260
- documentId,
261
- { metadata: { contentSync: { crdtBytes, materializedDoc } } },
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
- // Update last synced vector
269
- lastSyncedVectors.set(key, currentVector);
270
- failedSyncQueue.delete(key);
271
- setPendingInternal(key, false);
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
- documentId,
212
+ document,
278
213
  error: String(err),
279
214
  });
280
- failedSyncQueue.set(key, true);
281
- // Keep pending=true for retry indication
215
+ state.failedSyncQueue.set(document, true);
282
216
  }
283
217
  }, debounceMs);
284
218
 
285
- debounceTimers.set(key, timer);
219
+ state.debounceTimers.set(document, timer);
286
220
 
287
- // Also retry any failed syncs for this document
288
- if (failedSyncQueue.has(key)) {
289
- failedSyncQueue.delete(key);
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, documentId);
300
- fragmentObservers.delete(key);
301
- lastSyncedVectors.delete(key);
302
- logger.debug("Fragment observer cleaned up", { collection, documentId, field });
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(key, cleanup);
306
- logger.debug("Fragment observer registered", { collection, documentId, field });
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 prefix = `${collection}:`;
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
- // Clear pending state and listeners
331
- for (const key of pendingState.keys()) {
332
- if (key.startsWith(prefix)) {
333
- pendingState.delete(key);
334
- }
247
+ for (const [, timer] of state.debounceTimers) {
248
+ clearTimeout(timer);
335
249
  }
336
- for (const key of pendingListeners.keys()) {
337
- if (key.startsWith(prefix)) {
338
- pendingListeners.delete(key);
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
- // Clear applying from server flags
343
- for (const key of applyingFromServer.keys()) {
344
- if (key.startsWith(prefix)) {
345
- applyingFromServer.delete(key);
346
- }
347
- }
262
+ logger.debug("Prose cleanup complete", { collection });
263
+ }
348
264
 
349
- // Clear last synced vectors
350
- for (const key of lastSyncedVectors.keys()) {
351
- if (key.startsWith(prefix)) {
352
- lastSyncedVectors.delete(key);
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
- // Clean up fragment observers
357
- for (const [key, cleanupFn] of fragmentObservers) {
358
- if (key.startsWith(prefix)) {
359
- cleanupFn();
360
- fragmentObservers.delete(key);
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
- // Clear failed sync queue
365
- for (const key of failedSyncQueue.keys()) {
366
- if (key.startsWith(prefix)) {
367
- failedSyncQueue.delete(key);
310
+ if (isProseSchema(unwrapped)) {
311
+ fields.push(key);
368
312
  }
369
313
  }
370
314
 
371
- logger.debug("Prose cleanup complete", { collection });
315
+ return fields;
372
316
  }