@palantir/pack.state.core 0.0.1-beta.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 (63) hide show
  1. package/.turbo/turbo-lint.log +4 -0
  2. package/.turbo/turbo-transpileBrowser.log +5 -0
  3. package/.turbo/turbo-transpileCjs.log +5 -0
  4. package/.turbo/turbo-transpileEsm.log +5 -0
  5. package/.turbo/turbo-transpileTypes.log +5 -0
  6. package/.turbo/turbo-typecheck.log +4 -0
  7. package/LICENSE.txt +13 -0
  8. package/README.md +55 -0
  9. package/build/browser/index.js +1257 -0
  10. package/build/browser/index.js.map +1 -0
  11. package/build/cjs/index.cjs +1298 -0
  12. package/build/cjs/index.cjs.map +1 -0
  13. package/build/cjs/index.d.cts +272 -0
  14. package/build/esm/index.js +1257 -0
  15. package/build/esm/index.js.map +1 -0
  16. package/build/types/DocumentServiceModule.d.ts +6 -0
  17. package/build/types/DocumentServiceModule.d.ts.map +1 -0
  18. package/build/types/__tests__/DocumentStatusTracking.test.d.ts +1 -0
  19. package/build/types/__tests__/DocumentStatusTracking.test.d.ts.map +1 -0
  20. package/build/types/__tests__/RefStability.test.d.ts +1 -0
  21. package/build/types/__tests__/RefStability.test.d.ts.map +1 -0
  22. package/build/types/__tests__/StateModule.integration.test.d.ts +1 -0
  23. package/build/types/__tests__/StateModule.integration.test.d.ts.map +1 -0
  24. package/build/types/__tests__/testUtils.d.ts +7 -0
  25. package/build/types/__tests__/testUtils.d.ts.map +1 -0
  26. package/build/types/index.d.ts +11 -0
  27. package/build/types/index.d.ts.map +1 -0
  28. package/build/types/service/BaseYjsDocumentService.d.ts +155 -0
  29. package/build/types/service/BaseYjsDocumentService.d.ts.map +1 -0
  30. package/build/types/service/InMemoryDocumentService.d.ts +12 -0
  31. package/build/types/service/InMemoryDocumentService.d.ts.map +1 -0
  32. package/build/types/service/YjsSchemaMapper.d.ts +9 -0
  33. package/build/types/service/YjsSchemaMapper.d.ts.map +1 -0
  34. package/build/types/types/DocumentRefImpl.d.ts +5 -0
  35. package/build/types/types/DocumentRefImpl.d.ts.map +1 -0
  36. package/build/types/types/DocumentService.d.ts +62 -0
  37. package/build/types/types/DocumentService.d.ts.map +1 -0
  38. package/build/types/types/DocumentServiceConfig.d.ts +5 -0
  39. package/build/types/types/DocumentServiceConfig.d.ts.map +1 -0
  40. package/build/types/types/RecordCollectionRefImpl.d.ts +5 -0
  41. package/build/types/types/RecordCollectionRefImpl.d.ts.map +1 -0
  42. package/build/types/types/RecordRefImpl.d.ts +5 -0
  43. package/build/types/types/RecordRefImpl.d.ts.map +1 -0
  44. package/build/types/types/StateModule.d.ts +59 -0
  45. package/build/types/types/StateModule.d.ts.map +1 -0
  46. package/package.json +71 -0
  47. package/src/DocumentServiceModule.ts +53 -0
  48. package/src/__tests__/DocumentStatusTracking.test.ts +229 -0
  49. package/src/__tests__/RefStability.test.ts +441 -0
  50. package/src/__tests__/StateModule.integration.test.ts +1187 -0
  51. package/src/__tests__/testUtils.ts +106 -0
  52. package/src/index.ts +38 -0
  53. package/src/service/BaseYjsDocumentService.ts +1277 -0
  54. package/src/service/InMemoryDocumentService.ts +162 -0
  55. package/src/service/YjsSchemaMapper.ts +194 -0
  56. package/src/types/DocumentRefImpl.ts +98 -0
  57. package/src/types/DocumentService.ts +210 -0
  58. package/src/types/DocumentServiceConfig.ts +22 -0
  59. package/src/types/RecordCollectionRefImpl.ts +124 -0
  60. package/src/types/RecordRefImpl.ts +106 -0
  61. package/src/types/StateModule.ts +329 -0
  62. package/tsconfig.json +21 -0
  63. package/vitest.config.mjs +26 -0
@@ -0,0 +1,1277 @@
1
+ /*
2
+ * Copyright 2025 Palantir Technologies, Inc. All rights reserved.
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * http://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ */
16
+
17
+ /* eslint-disable no-console */
18
+
19
+ import type { Logger } from "@osdk/api";
20
+ import type { PackAppInternal, Unsubscribe } from "@palantir/pack.core";
21
+ import {
22
+ type DocumentId,
23
+ type DocumentMetadata,
24
+ type DocumentRef,
25
+ type DocumentSchema,
26
+ type DocumentState,
27
+ getMetadata,
28
+ type Model,
29
+ type ModelData,
30
+ type RecordCollectionRef,
31
+ type RecordId,
32
+ type RecordRef,
33
+ } from "@palantir/pack.document-schema.model-types";
34
+ import { isDeepEqual } from "remeda";
35
+ import invariant from "tiny-invariant";
36
+ import * as Y from "yjs";
37
+ import { createDocRef } from "../types/DocumentRefImpl.js";
38
+ import type {
39
+ DocumentMetadataChangeCallback,
40
+ DocumentService,
41
+ DocumentStateChangeCallback,
42
+ DocumentStatus,
43
+ DocumentStatusChangeCallback,
44
+ DocumentSyncStatus,
45
+ RecordChangeCallback,
46
+ RecordCollectionChangeCallback,
47
+ RecordDeleteCallback,
48
+ } from "../types/DocumentService.js";
49
+ import { DocumentLiveStatus, DocumentLoadStatus } from "../types/DocumentService.js";
50
+ import { createRecordCollectionRef } from "../types/RecordCollectionRefImpl.js";
51
+ import { createRecordRef } from "../types/RecordRefImpl.js";
52
+ import * as YjsSchemaMapper from "./YjsSchemaMapper.js";
53
+
54
+ export interface RecordCollectionSubscriptions<M extends Model = Model> {
55
+ added?: Set<RecordCollectionChangeCallback<M>>;
56
+ changed?: Set<RecordCollectionChangeCallback<M>>;
57
+ deleted?: Set<RecordCollectionChangeCallback<M>>;
58
+ }
59
+
60
+ export interface RecordSubscribers<M extends Model = Model> {
61
+ readonly ref: RecordRef<M>;
62
+ changed?: Set<RecordChangeCallback<M>>;
63
+ deleted?: Set<RecordDeleteCallback<M>>;
64
+ }
65
+
66
+ export interface InternalYjsDoc {
67
+ /**
68
+ * Holds a weak reference to this doc for two purposes:
69
+ * 1. Return stable createDocRef values for reference equality.
70
+ * 2. We can collect and clean up documents that are no longer referenced anywhere (although ideally
71
+ * we can do this via subscription tracking alone).
72
+ * Note that docRefs references are also held by RecordCollectionRef & RecordRef instances.
73
+ */
74
+ ref: WeakRef<DocumentRef>;
75
+ metadata?: DocumentMetadata;
76
+ readonly schema: DocumentSchema;
77
+ readonly yDoc: Y.Doc;
78
+ yDocUpdateHandler?: () => void;
79
+
80
+ // Status tracking
81
+ metadataStatus: DocumentSyncStatus;
82
+ dataStatus: DocumentSyncStatus;
83
+ metadataError?: unknown;
84
+ dataError?: unknown;
85
+
86
+ // Track if we have active subscriptions
87
+ // TODO: ref counts instead? Are these even necessary on the doc or should just be service calls?
88
+ hasMetadataSubscriptions: boolean;
89
+ hasDataSubscriptions: boolean;
90
+
91
+ // Ref caching for stable references
92
+ readonly collectionRefs: Map<string, WeakRef<RecordCollectionRef>>;
93
+ readonly recordRefs: Map<string, Map<RecordId, WeakRef<RecordRef>>>;
94
+
95
+ // TODO: add modelName type
96
+ readonly collectionSubscriptions: Map<string, RecordCollectionSubscriptions>;
97
+ readonly metadataSubscribers: Set<DocumentMetadataChangeCallback>;
98
+ readonly recordSubscriptions: Map<RecordId, RecordSubscribers>;
99
+ readonly docStateSubscribers: Set<DocumentStateChangeCallback>;
100
+ readonly statusSubscribers: Set<DocumentStatusChangeCallback>;
101
+
102
+ readonly yjsCollectionHandlers: Map<string, () => void>;
103
+ }
104
+
105
+ /**
106
+ * Base class for document services that use Y.js for local state management.
107
+ * Provides common Y.js operations for both in-memory and backend services.
108
+ *
109
+ * // TODO: Move this to an internal package
110
+ */
111
+ export abstract class BaseYjsDocumentService<TDoc extends InternalYjsDoc = InternalYjsDoc>
112
+ implements DocumentService
113
+ {
114
+ protected readonly documents: Map<DocumentId, TDoc> = new Map();
115
+
116
+ constructor(
117
+ protected readonly app: PackAppInternal,
118
+ protected readonly logger: Logger,
119
+ ) {}
120
+
121
+ abstract get hasMetadataSubscriptions(): boolean;
122
+ abstract get hasStateSubscriptions(): boolean;
123
+ abstract readonly createDocument: <T extends DocumentSchema>(
124
+ metadata: DocumentMetadata,
125
+ schema: T,
126
+ ) => Promise<DocumentRef<T>>;
127
+
128
+ readonly createDocRef = <const T extends DocumentSchema>(
129
+ id: DocumentId,
130
+ schema: T,
131
+ ): DocumentRef<T> => {
132
+ // Create a (likely) temporary doc ref - the internal doc will be created if it is unknown
133
+ // If it is known, we will return the stable ref from getCreateInternalDoc
134
+ const temporaryRef = createDocRef(this.app, id, schema);
135
+ const { internalDocRef } = this.getCreateInternalDoc(temporaryRef);
136
+ return internalDocRef;
137
+ };
138
+
139
+ readonly getCreateRecordCollectionRef = <const M extends Model>(
140
+ docRef: DocumentRef,
141
+ model: M,
142
+ ): RecordCollectionRef<M> => {
143
+ const { internalDoc } = this.getCreateInternalDoc(docRef);
144
+ const modelName = getMetadata(model).name;
145
+
146
+ const existingRef = internalDoc.collectionRefs.get(modelName)?.deref();
147
+ if (existingRef != null) {
148
+ return existingRef as RecordCollectionRef<M>;
149
+ }
150
+
151
+ const newRef = createRecordCollectionRef(this, docRef, model);
152
+ internalDoc.collectionRefs.set(modelName, new WeakRef(newRef));
153
+ return newRef;
154
+ };
155
+
156
+ readonly getCreateRecordRef = <const M extends Model>(
157
+ docRef: DocumentRef,
158
+ id: RecordId,
159
+ model: M,
160
+ ): RecordRef<M> => {
161
+ const { internalDoc } = this.getCreateInternalDoc(docRef);
162
+ const modelName = getMetadata(model).name;
163
+
164
+ let modelMap = internalDoc.recordRefs.get(modelName);
165
+ if (!modelMap) {
166
+ modelMap = new Map();
167
+ internalDoc.recordRefs.set(modelName, modelMap);
168
+ }
169
+
170
+ const existingRef = modelMap.get(id)?.deref();
171
+ if (existingRef != null) {
172
+ return existingRef as RecordRef<M>;
173
+ }
174
+
175
+ const newRef = createRecordRef(this, docRef, id, model);
176
+ modelMap.set(id, new WeakRef(newRef));
177
+ return newRef;
178
+ };
179
+
180
+ protected abstract createInternalDoc(
181
+ ref: DocumentRef,
182
+ metadata?: DocumentMetadata,
183
+ yDoc?: Y.Doc,
184
+ ): TDoc;
185
+
186
+ /**
187
+ * Called when the first metadata subscription is opened for a document.
188
+ * Implementation must:
189
+ * - Set status to LOADING immediately
190
+ * - Load/validate metadata asynchronously
191
+ * - Set status to LOADED or ERROR when complete
192
+ * - Handle all errors internally (never throw/reject)
193
+ */
194
+ protected abstract onMetadataSubscriptionOpened(
195
+ internalDoc: TDoc,
196
+ docRef: DocumentRef,
197
+ ): void;
198
+
199
+ /**
200
+ * Called when the first data subscription is opened for a document.
201
+ * Implementation must:
202
+ * - Set status to LOADING immediately
203
+ * - Set up data synchronization asynchronously
204
+ * - Set status to LOADED or ERROR when ready
205
+ * - Handle all errors internally (never throw/reject)
206
+ */
207
+ protected abstract onDataSubscriptionOpened(
208
+ internalDoc: TDoc,
209
+ docRef: DocumentRef,
210
+ ): void;
211
+
212
+ /**
213
+ * Called when the last metadata subscription is closed for a document.
214
+ * Implementation should clean up any resources related to metadata loading.
215
+ */
216
+ protected abstract onMetadataSubscriptionClosed(
217
+ internalDoc: TDoc,
218
+ docRef: DocumentRef,
219
+ ): void;
220
+
221
+ /**
222
+ * Called when the last data subscription is closed for a document.
223
+ * Implementation should clean up any resources related to data synchronization.
224
+ */
225
+ protected abstract onDataSubscriptionClosed(
226
+ internalDoc: TDoc,
227
+ docRef: DocumentRef,
228
+ ): void;
229
+
230
+ protected readonly createBaseInternalDoc = <T extends DocumentSchema>(
231
+ ref: DocumentRef<T>,
232
+ metadata: DocumentMetadata | undefined,
233
+ yDoc?: Y.Doc,
234
+ ): InternalYjsDoc => {
235
+ const schema = ref.schema;
236
+ return {
237
+ ref: new WeakRef(ref),
238
+ metadata,
239
+ schema,
240
+ metadataStatus: {
241
+ load: metadata ? DocumentLoadStatus.LOADED : DocumentLoadStatus.UNLOADED,
242
+ live: DocumentLiveStatus.DISCONNECTED,
243
+ },
244
+ dataStatus: {
245
+ load: DocumentLoadStatus.UNLOADED,
246
+ live: DocumentLiveStatus.DISCONNECTED,
247
+ },
248
+ metadataError: undefined,
249
+ dataError: undefined,
250
+ statusSubscribers: new Set(),
251
+ hasMetadataSubscriptions: false,
252
+ hasDataSubscriptions: false,
253
+ collectionRefs: new Map(),
254
+ recordRefs: new Map(),
255
+ collectionSubscriptions: new Map(),
256
+ docStateSubscribers: new Set(),
257
+ metadataSubscribers: new Set(),
258
+ recordSubscriptions: new Map(),
259
+ yDoc: yDoc || this.initializeYDoc(schema),
260
+ yDocUpdateHandler: undefined,
261
+ yjsCollectionHandlers: new Map(),
262
+ };
263
+ };
264
+
265
+ protected hasSubscriptions(internalDoc: TDoc): boolean {
266
+ if (
267
+ internalDoc.metadataSubscribers.size > 0
268
+ || internalDoc.docStateSubscribers.size > 0
269
+ ) {
270
+ return true;
271
+ }
272
+ for (const subs of internalDoc.recordSubscriptions.values()) {
273
+ if (!subs.changed?.size || !subs.deleted?.size) {
274
+ return true;
275
+ }
276
+ }
277
+ return false;
278
+ }
279
+
280
+ // Status helper methods
281
+ protected notifyStatusSubscribers(
282
+ internalDoc: TDoc,
283
+ docRef: DocumentRef,
284
+ ): void {
285
+ const status: DocumentStatus = {
286
+ metadata: internalDoc.metadataStatus,
287
+ data: internalDoc.dataStatus,
288
+ metadataError: internalDoc.metadataError,
289
+ dataError: internalDoc.dataError,
290
+ };
291
+ for (const callback of internalDoc.statusSubscribers) {
292
+ callback(docRef, status);
293
+ }
294
+ }
295
+
296
+ protected updateMetadataStatus(
297
+ internalDoc: TDoc,
298
+ docRef: DocumentRef,
299
+ update: {
300
+ load?: DocumentLoadStatus;
301
+ live?: DocumentLiveStatus;
302
+ error?: unknown;
303
+ },
304
+ ): void {
305
+ if (update.load != null || update.live != null) {
306
+ internalDoc.metadataStatus = {
307
+ load: update.load ?? internalDoc.metadataStatus.load,
308
+ live: update.live ?? internalDoc.metadataStatus.live,
309
+ };
310
+ }
311
+ if (update.error != null) {
312
+ internalDoc.metadataError = update.error;
313
+ } else if (update.load === DocumentLoadStatus.LOADED) {
314
+ // Clear error on successful load
315
+ internalDoc.metadataError = undefined;
316
+ }
317
+ this.notifyStatusSubscribers(internalDoc, docRef);
318
+ }
319
+
320
+ protected updateDataStatus(
321
+ internalDoc: TDoc,
322
+ docRef: DocumentRef,
323
+ update: {
324
+ load?: DocumentLoadStatus;
325
+ live?: DocumentLiveStatus;
326
+ error?: unknown;
327
+ },
328
+ ): void {
329
+ if (update.load != null || update.live != null) {
330
+ internalDoc.dataStatus = {
331
+ load: update.load ?? internalDoc.dataStatus.load,
332
+ live: update.live ?? internalDoc.dataStatus.live,
333
+ };
334
+ }
335
+ if (update.error != null) {
336
+ internalDoc.dataError = update.error;
337
+ } else if (update.load === DocumentLoadStatus.LOADED) {
338
+ // Clear error on successful load
339
+ internalDoc.dataError = undefined;
340
+ }
341
+ this.notifyStatusSubscribers(internalDoc, docRef);
342
+ }
343
+
344
+ /**
345
+ * Hook method called after a record is set. Subclasses can override to handle
346
+ * backend synchronization, logging, or other side effects.
347
+ */
348
+ protected onRecordSet?<R extends Model>(
349
+ recordRef: RecordRef<R>,
350
+ state: ModelData<R>,
351
+ ): void;
352
+
353
+ /**
354
+ * Initialize a Y.Doc with the given schema
355
+ */
356
+ protected initializeYDoc(schema: DocumentSchema): Y.Doc {
357
+ const yDoc = new Y.Doc();
358
+ YjsSchemaMapper.initializeDocumentStructure(yDoc, schema);
359
+ return yDoc;
360
+ }
361
+
362
+ /**
363
+ * Get existing internal doc or create one with placeholder metadata for lazy initialization
364
+ */
365
+ protected getCreateInternalDoc<T extends DocumentSchema>(
366
+ ref: DocumentRef<T>,
367
+ metadata?: DocumentMetadata,
368
+ initialYDoc?: Y.Doc,
369
+ ): { internalDocRef: DocumentRef<T>; internalDoc: TDoc; wasExisting: boolean } {
370
+ const { id, schema } = ref;
371
+ const existingDoc = this.documents.get(id);
372
+ if (existingDoc != null) {
373
+ // Use reference equality first (fast path), then deep equality for hot reload compatibility
374
+ invariant(
375
+ existingDoc.schema === schema || isDeepEqual(existingDoc.schema, schema),
376
+ "Schema mismatch for existing document",
377
+ );
378
+
379
+ // The caller has a strong ref - in most cases this will be the same instance as we already
380
+ // have stored in the ref field. If it is not, we want to return a stable ref so users can
381
+ // easily depend on reference equality.
382
+
383
+ // It's possible the previous weak ref was collected as all references were dropped. If so,
384
+ // we can update the weak ref.
385
+ const existingRef = existingDoc.ref.deref() as DocumentRef<T> | undefined;
386
+
387
+ if (existingRef == null) {
388
+ existingDoc.ref = new WeakRef(ref);
389
+ }
390
+
391
+ return { internalDocRef: existingRef ?? ref, internalDoc: existingDoc, wasExisting: true };
392
+ }
393
+
394
+ const internalDoc = this.createInternalDoc(ref, metadata, initialYDoc);
395
+ this.documents.set(id, internalDoc);
396
+ return { internalDocRef: ref, internalDoc, wasExisting: false };
397
+ }
398
+
399
+ readonly getDocumentSnapshot = <T extends DocumentSchema>(
400
+ docRef: DocumentRef<T>,
401
+ ): Promise<DocumentState<T>> => {
402
+ const { internalDoc } = this.getCreateInternalDoc(docRef);
403
+
404
+ // For now, return the Y.Doc as the state
405
+ // Later this can be enhanced to serialize Y.Doc content or return specific data
406
+ return Promise.resolve(internalDoc.yDoc as unknown as DocumentState<T>);
407
+ };
408
+
409
+ readonly getRecordSnapshot = <M extends Model>(
410
+ recordRef: RecordRef<M>,
411
+ ): Promise<ModelData<M>> => {
412
+ const { internalDoc } = this.getCreateInternalDoc(recordRef.docRef);
413
+ const snapshot = this.getRecordSnapshotInternal(internalDoc, recordRef);
414
+
415
+ // This is a promise interface for async loading for external API implementations to trigger a load first
416
+ // For this base implementation, we always return from the local Y.Doc
417
+
418
+ if (snapshot == null) {
419
+ // TODO: well known error types
420
+ return Promise.reject(new Error(`Record not found: ${recordRef.id}`));
421
+ }
422
+ return Promise.resolve(snapshot);
423
+ };
424
+
425
+ protected getRecordSnapshotInternal<M extends Model>(
426
+ internalDoc: TDoc,
427
+ recordRef: RecordRef<M>,
428
+ ): ModelData<M> {
429
+ return YjsSchemaMapper.getRecordSnapshot(
430
+ internalDoc.yDoc,
431
+ getMetadata(recordRef.model).name,
432
+ recordRef.id,
433
+ ) as ModelData<M>;
434
+ }
435
+
436
+ readonly setRecord = <R extends Model>(
437
+ recordRef: RecordRef<R>,
438
+ state: ModelData<R>,
439
+ ): Promise<void> => {
440
+ const internalDoc = this.documents.get(recordRef.docRef.id);
441
+ invariant(
442
+ internalDoc != null,
443
+ `Cannot set record as document not found: ${recordRef.docRef.id}`,
444
+ );
445
+
446
+ // TODO: you cannot resurrect tomb stoned records I think, so need to check for that before notify
447
+ // TODO: perhaps we just call this via onRecordSet instead?
448
+
449
+ YjsSchemaMapper.setRecord(
450
+ internalDoc.yDoc,
451
+ getMetadata(recordRef.model).name,
452
+ recordRef.id,
453
+ state,
454
+ );
455
+
456
+ // Call hook method for subclass-specific handling
457
+ this.onRecordSet?.(recordRef, state);
458
+
459
+ return Promise.resolve();
460
+ };
461
+
462
+ readonly updateRecord = <R extends Model>(
463
+ recordRef: RecordRef<R>,
464
+ partialState: Partial<ModelData<R>>,
465
+ ): Promise<void> => {
466
+ const internalDoc = this.documents.get(recordRef.docRef.id);
467
+ invariant(
468
+ internalDoc != null,
469
+ `Cannot update record as document not found: ${recordRef.docRef.id}`,
470
+ );
471
+
472
+ const wasUpdated = YjsSchemaMapper.updateRecord(
473
+ internalDoc.yDoc,
474
+ getMetadata(recordRef.model).name,
475
+ recordRef.id,
476
+ partialState,
477
+ );
478
+
479
+ if (!wasUpdated) {
480
+ return Promise.reject(new Error(`Record not found for update: ${recordRef.id}`));
481
+ }
482
+
483
+ // Call hook method for subclass-specific handling
484
+ this.onRecordSet?.(recordRef, partialState);
485
+
486
+ return Promise.resolve();
487
+ };
488
+
489
+ onMetadataChange<T extends DocumentSchema>(
490
+ docRef: DocumentRef<T>,
491
+ callback: DocumentMetadataChangeCallback<T>,
492
+ ): Unsubscribe {
493
+ const { internalDoc, internalDocRef } = this.getCreateInternalDoc(docRef);
494
+ const isFirstSubscription = !internalDoc.hasMetadataSubscriptions;
495
+
496
+ internalDoc.metadataSubscribers.add(callback as DocumentMetadataChangeCallback);
497
+ internalDoc.hasMetadataSubscriptions = true;
498
+
499
+ // Trigger remote load if this is the first subscription and not yet loaded
500
+ if (isFirstSubscription && internalDoc.metadataStatus.load === DocumentLoadStatus.UNLOADED) {
501
+ this.onMetadataSubscriptionOpened(internalDoc, internalDocRef);
502
+ }
503
+
504
+ // Immediately call back with current metadata if available
505
+ if (internalDoc.metadata != null) {
506
+ callback(docRef, internalDoc.metadata);
507
+ }
508
+
509
+ return () => {
510
+ const currentDoc = this.documents.get(docRef.id);
511
+ if (currentDoc) {
512
+ currentDoc.metadataSubscribers.delete(callback as DocumentMetadataChangeCallback);
513
+
514
+ // Check if this was the last metadata subscription
515
+ if (currentDoc.metadataSubscribers.size === 0) {
516
+ currentDoc.hasMetadataSubscriptions = false;
517
+ this.onMetadataSubscriptionClosed(currentDoc, internalDocRef);
518
+ }
519
+ }
520
+ };
521
+ }
522
+
523
+ readonly onStateChange = <T extends DocumentSchema>(
524
+ docRef: DocumentRef<T>,
525
+ callback: DocumentStateChangeCallback<T>,
526
+ ): Unsubscribe => {
527
+ const { internalDoc, internalDocRef } = this.getCreateInternalDoc(docRef);
528
+
529
+ const isFirstDataSubscription = !internalDoc.hasDataSubscriptions;
530
+ const isFirstStateSubscription = internalDoc.docStateSubscribers.size === 0;
531
+
532
+ internalDoc.docStateSubscribers.add(callback as DocumentStateChangeCallback);
533
+ internalDoc.hasDataSubscriptions = true;
534
+
535
+ // Trigger remote load if this is the first data subscription and not yet loaded
536
+ if (isFirstDataSubscription && internalDoc.dataStatus.load === DocumentLoadStatus.UNLOADED) {
537
+ this.onDataSubscriptionOpened(internalDoc, internalDocRef);
538
+ }
539
+
540
+ // Set up Y.Doc listener if this is the first state subscription
541
+ if (isFirstStateSubscription && !internalDoc.yDocUpdateHandler) {
542
+ const updateHandler = () => {
543
+ this.notifyStateSubscribers(internalDoc, docRef);
544
+ };
545
+ internalDoc.yDoc.on("update", updateHandler);
546
+ internalDoc.yDocUpdateHandler = updateHandler;
547
+ }
548
+
549
+ // Call callback immediately with current state
550
+ callback(internalDocRef);
551
+
552
+ return () => {
553
+ const currentDoc = this.documents.get(docRef.id);
554
+ if (!currentDoc) return;
555
+
556
+ currentDoc.docStateSubscribers.delete(callback as DocumentStateChangeCallback);
557
+
558
+ // Clean up Y.Doc listener if no more state subscriptions
559
+ if (currentDoc.docStateSubscribers.size === 0 && currentDoc.yDocUpdateHandler) {
560
+ currentDoc.yDoc.off("update", currentDoc.yDocUpdateHandler);
561
+ currentDoc.yDocUpdateHandler = undefined;
562
+ }
563
+
564
+ // Check if this removes all data subscriptions (state + record + collection)
565
+ const hasDataSubs = currentDoc.docStateSubscribers.size > 0
566
+ || currentDoc.recordSubscriptions.size > 0
567
+ || Array.from(currentDoc.collectionSubscriptions.values()).some(subs =>
568
+ subs.added?.size || subs.changed?.size || subs.deleted?.size
569
+ );
570
+
571
+ if (!hasDataSubs) {
572
+ currentDoc.hasDataSubscriptions = false;
573
+ this.onDataSubscriptionClosed(currentDoc, internalDocRef);
574
+ }
575
+ };
576
+ };
577
+
578
+ protected getDocumentRef(docId: DocumentId): DocumentRef | null {
579
+ const internalDoc = this.documents.get(docId);
580
+ if (!internalDoc) return null;
581
+
582
+ return createDocRef(this.app, docId, internalDoc.schema);
583
+ }
584
+
585
+ protected notifyMetadataSubscribers(
586
+ internalDoc: TDoc,
587
+ docRef: DocumentRef,
588
+ metadata: DocumentMetadata,
589
+ ): void {
590
+ for (const callback of internalDoc.metadataSubscribers) {
591
+ callback(docRef, metadata);
592
+ }
593
+ }
594
+
595
+ protected notifyStateSubscribers(internalDoc: TDoc, docRef: DocumentRef): void {
596
+ for (const callback of internalDoc.docStateSubscribers) {
597
+ callback(docRef);
598
+ }
599
+ }
600
+
601
+ updateMetadata(docId: DocumentId, metadata: DocumentMetadata): void {
602
+ const internalDoc = this.documents.get(docId);
603
+ if (internalDoc) {
604
+ internalDoc.metadata = metadata;
605
+ const docRef = this.getDocumentRef(docId);
606
+ if (docRef) {
607
+ this.notifyMetadataSubscribers(internalDoc, docRef, metadata);
608
+ }
609
+ }
610
+ }
611
+
612
+ protected notifyCollectionSubscribers<M extends Model>(
613
+ internalDoc: TDoc,
614
+ collection: RecordCollectionRef<M>,
615
+ recordId: RecordId,
616
+ changeType: "added" | "changed" | "deleted",
617
+ ): void {
618
+ const storageName = getMetadata(collection.model).name;
619
+ const subs = internalDoc.collectionSubscriptions.get(storageName) as
620
+ | RecordCollectionSubscriptions<M>
621
+ | undefined;
622
+ if (!subs) {
623
+ return;
624
+ }
625
+
626
+ const subscribers = subs[changeType];
627
+ if (subscribers == null || subscribers.size === 0) {
628
+ return;
629
+ }
630
+
631
+ const recordRefInstance = this.getCreateRecordRef(
632
+ collection.docRef,
633
+ recordId,
634
+ collection.model,
635
+ );
636
+ const records = [recordRefInstance];
637
+
638
+ for (const callback of subscribers) {
639
+ callback(records);
640
+ }
641
+ }
642
+
643
+ protected notifyRecordSubscribers<M extends Model>(
644
+ recordRef: RecordRef<M>,
645
+ changeType: "changed" | "deleted",
646
+ ): void {
647
+ const internalDoc = this.documents.get(recordRef.docRef.id);
648
+ invariant(internalDoc != null, "Document not found for record notifications");
649
+
650
+ const recordSubs = internalDoc.recordSubscriptions.get(recordRef.id);
651
+ if (recordSubs == null) {
652
+ return;
653
+ }
654
+
655
+ // Verify model consistency
656
+ invariant(
657
+ getMetadata(recordSubs.ref.model).name === getMetadata(recordRef.model).name,
658
+ `Model mismatch when notifying record subscribers for ${recordRef.id}: expected ${
659
+ getMetadata(recordSubs.ref.model).name
660
+ }, got ${getMetadata(recordRef.model).name}`,
661
+ );
662
+
663
+ switch (changeType) {
664
+ case "changed": {
665
+ const snapshot = this.getRecordSnapshotInternal(internalDoc, recordRef);
666
+ for (const callback of recordSubs.changed ?? []) {
667
+ try {
668
+ callback(snapshot, recordRef);
669
+ } catch (e) {
670
+ console.error("Record onChanged callback threw unhandled error", e, {
671
+ model: getMetadata(recordRef.model).name,
672
+ id: recordRef.id,
673
+ });
674
+ }
675
+ }
676
+ break;
677
+ }
678
+ case "deleted": {
679
+ for (const callback of recordSubs.deleted ?? []) {
680
+ try {
681
+ callback(recordRef);
682
+ } catch (e) {
683
+ console.error("Record onDeleted callback threw unhandled error", e, {
684
+ model: getMetadata(recordRef.model).name,
685
+ id: recordRef.id,
686
+ });
687
+ }
688
+ }
689
+ break;
690
+ }
691
+ }
692
+ }
693
+
694
+ // Collection methods implementation
695
+ readonly getRecord = <M extends Model>(
696
+ collection: RecordCollectionRef<M>,
697
+ id: RecordId,
698
+ ): RecordRef<M> | undefined => {
699
+ const internalDoc = this.documents.get(collection.docRef.id);
700
+ if (!internalDoc) return undefined;
701
+
702
+ const storageName = getMetadata(collection.model).name;
703
+ const recordExists = YjsSchemaMapper.getRecordData(internalDoc.yDoc, storageName, id);
704
+
705
+ return recordExists
706
+ ? this.getCreateRecordRef(collection.docRef, id, collection.model)
707
+ : undefined;
708
+ };
709
+
710
+ readonly hasRecord = <M extends Model>(
711
+ collection: RecordCollectionRef<M>,
712
+ id: RecordId,
713
+ ): boolean => {
714
+ const internalDoc = this.documents.get(collection.docRef.id);
715
+ if (!internalDoc) return false;
716
+
717
+ const storageName = getMetadata(collection.model).name;
718
+ return YjsSchemaMapper.getRecordData(internalDoc.yDoc, storageName, id) != null;
719
+ };
720
+
721
+ readonly setCollectionRecord = <M extends Model>(
722
+ collection: RecordCollectionRef<M>,
723
+ id: RecordId,
724
+ state: ModelData<M>,
725
+ ): Promise<void> => {
726
+ // Create a RecordRef and delegate to existing setRecord method
727
+ const recordRefInstance = this.getCreateRecordRef(collection.docRef, id, collection.model);
728
+ return this.setRecord(recordRefInstance, state);
729
+ };
730
+
731
+ readonly deleteRecord = <M extends Model>(
732
+ record: RecordRef<M>,
733
+ ): Promise<void> => {
734
+ const internalDoc = this.documents.get(record.docRef.id);
735
+ if (!internalDoc) {
736
+ // Document doesn't exist, record doesn't exist - this is a no-op
737
+ return Promise.resolve();
738
+ }
739
+
740
+ const storageName = getMetadata(record.model).name;
741
+ const recordsCollection = YjsSchemaMapper.getRecordsMap(internalDoc.yDoc, storageName);
742
+
743
+ const existed = recordsCollection.has(record.id as string);
744
+ if (existed) {
745
+ recordsCollection.delete(record.id as string);
746
+ }
747
+
748
+ return Promise.resolve();
749
+ };
750
+
751
+ readonly getCollectionSize = <M extends Model>(
752
+ collection: RecordCollectionRef<M>,
753
+ ): number => {
754
+ const internalDoc = this.documents.get(collection.docRef.id);
755
+ if (!internalDoc) return 0;
756
+
757
+ const storageName = getMetadata(collection.model).name;
758
+ return YjsSchemaMapper.getAllRecordIds(internalDoc.yDoc, storageName).length;
759
+ };
760
+
761
+ readonly getCollectionRecords = <M extends Model>(
762
+ collection: RecordCollectionRef<M>,
763
+ ): RecordRef<M>[] => {
764
+ const internalDoc = this.documents.get(collection.docRef.id);
765
+ if (!internalDoc) return [];
766
+
767
+ const storageName = getMetadata(collection.model).name;
768
+ const recordIds = YjsSchemaMapper.getAllRecordIds(internalDoc.yDoc, storageName);
769
+
770
+ return recordIds.map(id => this.getCreateRecordRef(collection.docRef, id, collection.model));
771
+ };
772
+
773
+ readonly onCollectionItemsAdded = <M extends Model>(
774
+ collection: RecordCollectionRef<M>,
775
+ callback: RecordCollectionChangeCallback<M>,
776
+ ): Unsubscribe => {
777
+ const { internalDoc, internalDocRef } = this.getCreateInternalDoc(collection.docRef);
778
+ return this.subscribeToCollectionChanges(
779
+ internalDoc,
780
+ internalDocRef,
781
+ collection,
782
+ "added",
783
+ callback,
784
+ );
785
+ };
786
+
787
+ readonly onCollectionItemsChanged = <M extends Model>(
788
+ collection: RecordCollectionRef<M>,
789
+ callback: RecordCollectionChangeCallback<M>,
790
+ ): Unsubscribe => {
791
+ const { internalDoc, internalDocRef } = this.getCreateInternalDoc(collection.docRef);
792
+ return this.subscribeToCollectionChanges(
793
+ internalDoc,
794
+ internalDocRef,
795
+ collection,
796
+ "changed",
797
+ callback,
798
+ );
799
+ };
800
+
801
+ readonly onCollectionItemsDeleted = <M extends Model>(
802
+ collection: RecordCollectionRef<M>,
803
+ callback: RecordCollectionChangeCallback<M>,
804
+ ): Unsubscribe => {
805
+ const { internalDoc, internalDocRef } = this.getCreateInternalDoc(collection.docRef);
806
+ return this.subscribeToCollectionChanges(
807
+ internalDoc,
808
+ internalDocRef,
809
+ collection,
810
+ "deleted",
811
+ callback,
812
+ );
813
+ };
814
+
815
+ // TODO: clearer naming of subscription vs handlers etc.
816
+ readonly onRecordChanged = <M extends Model>(
817
+ record: RecordRef<M>,
818
+ callback: RecordChangeCallback<M>,
819
+ ): Unsubscribe => {
820
+ const { internalDoc, internalDocRef } = this.getCreateInternalDoc(record.docRef);
821
+ const isFirstDataSubscription = !internalDoc.hasDataSubscriptions;
822
+ const storageName = getMetadata(record.model).name;
823
+ const collectionRef = this.getCreateRecordCollectionRef(record.docRef, record.model);
824
+ const needsCollectionListener = !internalDoc.yjsCollectionHandlers.has(storageName);
825
+
826
+ const recordSubs = this.getCreateRecordSubscriptions(internalDoc, record);
827
+ (recordSubs.changed ??= new Set()).add(callback);
828
+ internalDoc.hasDataSubscriptions = true;
829
+
830
+ // Trigger remote load if this is the first data subscription and not yet loaded
831
+ if (isFirstDataSubscription && internalDoc.dataStatus.load === DocumentLoadStatus.UNLOADED) {
832
+ // Call lifecycle method without awaiting
833
+ this.onDataSubscriptionOpened(internalDoc, internalDocRef);
834
+ }
835
+
836
+ if (needsCollectionListener) {
837
+ this.getCreateCollectionSubscriptions(internalDoc, collectionRef);
838
+ this.setupCollectionListener(internalDoc, collectionRef);
839
+ }
840
+
841
+ const snapshot = this.getRecordSnapshotInternal(
842
+ internalDoc,
843
+ record,
844
+ );
845
+ if (snapshot != null) {
846
+ callback(snapshot, record);
847
+ }
848
+
849
+ return () => {
850
+ recordSubs.changed?.delete(callback);
851
+ const currentDoc = this.documents.get(record.docRef.id);
852
+ if (!currentDoc) return;
853
+
854
+ if (isRecordSubscriptionsEmpty(recordSubs)) {
855
+ currentDoc.recordSubscriptions.delete(record.id);
856
+ }
857
+
858
+ this.cleanupCollectionListenerIfUnused(currentDoc, record.docRef.id, storageName);
859
+
860
+ const hasDataSubs = currentDoc.docStateSubscribers.size > 0
861
+ || currentDoc.recordSubscriptions.size > 0
862
+ || Array.from(currentDoc.collectionSubscriptions.values()).some(subs =>
863
+ subs.added?.size || subs.changed?.size || subs.deleted?.size
864
+ );
865
+
866
+ if (!hasDataSubs) {
867
+ currentDoc.hasDataSubscriptions = false;
868
+ this.onDataSubscriptionClosed(currentDoc, internalDocRef);
869
+ }
870
+ };
871
+ };
872
+
873
+ readonly onRecordDeleted = <M extends Model>(
874
+ record: RecordRef<M>,
875
+ callback: RecordDeleteCallback<M>,
876
+ ): Unsubscribe => {
877
+ const { internalDoc, internalDocRef } = this.getCreateInternalDoc(record.docRef);
878
+ const isFirstDataSubscription = !internalDoc.hasDataSubscriptions;
879
+ const storageName = getMetadata(record.model).name;
880
+ const collectionRef = this.getCreateRecordCollectionRef(record.docRef, record.model);
881
+ const needsCollectionListener = !internalDoc.yjsCollectionHandlers.has(storageName);
882
+
883
+ const recordSubs = this.getCreateRecordSubscriptions(internalDoc, record);
884
+ (recordSubs.deleted ??= new Set()).add(callback);
885
+ internalDoc.hasDataSubscriptions = true;
886
+
887
+ if (isFirstDataSubscription && internalDoc.dataStatus.load === DocumentLoadStatus.UNLOADED) {
888
+ this.onDataSubscriptionOpened(internalDoc, internalDocRef);
889
+ }
890
+
891
+ if (needsCollectionListener) {
892
+ this.getCreateCollectionSubscriptions(internalDoc, collectionRef);
893
+ this.setupCollectionListener(internalDoc, collectionRef);
894
+ }
895
+
896
+ return () => {
897
+ recordSubs.deleted?.delete(callback);
898
+ const currentDoc = this.documents.get(record.docRef.id);
899
+ if (!currentDoc) return;
900
+
901
+ if (isRecordSubscriptionsEmpty(recordSubs)) {
902
+ currentDoc.recordSubscriptions.delete(record.id);
903
+ }
904
+
905
+ this.cleanupCollectionListenerIfUnused(currentDoc, record.docRef.id, storageName);
906
+
907
+ const hasDataSubs = currentDoc.docStateSubscribers.size > 0
908
+ || currentDoc.recordSubscriptions.size > 0
909
+ || Array.from(currentDoc.collectionSubscriptions.values()).some(subs =>
910
+ subs.added?.size || subs.changed?.size || subs.deleted?.size
911
+ );
912
+
913
+ if (!hasDataSubs) {
914
+ currentDoc.hasDataSubscriptions = false;
915
+ this.onDataSubscriptionClosed(currentDoc, internalDocRef);
916
+ }
917
+ };
918
+ };
919
+
920
+ private getCreateCollectionSubscriptions<M extends Model>(
921
+ internalDoc: TDoc,
922
+ collection: RecordCollectionRef<M>,
923
+ ): RecordCollectionSubscriptions<M> {
924
+ const storageName = getMetadata(collection.model).name;
925
+
926
+ const docCollectionSubs = internalDoc.collectionSubscriptions.get(storageName)
927
+ ?? internalDoc.collectionSubscriptions.set(storageName, {}).get(storageName)!;
928
+
929
+ // Generic cast to specific types
930
+ return docCollectionSubs;
931
+ }
932
+
933
+ private getCreateRecordSubscriptions<M extends Model>(
934
+ internalDoc: TDoc,
935
+ record: RecordRef<M>,
936
+ ): RecordSubscribers<M> {
937
+ let recordSubs = internalDoc.recordSubscriptions.get(record.id);
938
+
939
+ if (!recordSubs) {
940
+ // Create new subscription entry with the record ref
941
+ recordSubs = { ref: record };
942
+ internalDoc.recordSubscriptions.set(record.id, recordSubs);
943
+ } else {
944
+ // Verify model consistency - records with same ID should have same model
945
+ if (getMetadata(recordSubs.ref.model).name !== getMetadata(record.model).name) {
946
+ throw new Error(
947
+ `Model mismatch for record ${record.id}: expected ${
948
+ getMetadata(recordSubs.ref.model).name
949
+ }, got ${getMetadata(record.model).name}`,
950
+ );
951
+ }
952
+ }
953
+
954
+ return recordSubs as unknown as RecordSubscribers<M>;
955
+ }
956
+
957
+ private subscribeToCollectionChanges<M extends Model>(
958
+ internalDoc: TDoc,
959
+ internalDocRef: DocumentRef,
960
+ collection: RecordCollectionRef<M>,
961
+ changeType: "added" | "changed" | "deleted",
962
+ callback: RecordCollectionChangeCallback<M>,
963
+ ): Unsubscribe {
964
+ const isFirstDataSubscription = !internalDoc.hasDataSubscriptions;
965
+ const modelSubscriptions = this.getCreateCollectionSubscriptions(internalDoc, collection);
966
+ const wasEmpty = isCollectionSubscriptionsEmpty(modelSubscriptions);
967
+
968
+ (modelSubscriptions[changeType] ??= new Set()).add(callback);
969
+ internalDoc.hasDataSubscriptions = true;
970
+
971
+ // Trigger remote load if this is the first data subscription and not yet loaded
972
+ if (isFirstDataSubscription && internalDoc.dataStatus.load === DocumentLoadStatus.UNLOADED) {
973
+ // Call lifecycle method without awaiting
974
+ this.onDataSubscriptionOpened(internalDoc, internalDocRef);
975
+ }
976
+
977
+ // Set up Y.Map listener if this is the first subscription for this collection
978
+ if (wasEmpty) {
979
+ this.setupCollectionListener(internalDoc, collection);
980
+ }
981
+
982
+ return () => {
983
+ modelSubscriptions[changeType]?.delete(callback);
984
+
985
+ const currentDoc = this.documents.get(collection.docRef.id);
986
+ if (!currentDoc) return;
987
+
988
+ const storageName = getMetadata(collection.model).name;
989
+ this.cleanupCollectionListenerIfUnused(currentDoc, collection.docRef.id, storageName);
990
+
991
+ const hasDataSubs = currentDoc.docStateSubscribers.size > 0
992
+ || currentDoc.recordSubscriptions.size > 0
993
+ || Array.from(currentDoc.collectionSubscriptions.values()).some(subs =>
994
+ subs.added?.size || subs.changed?.size || subs.deleted?.size
995
+ );
996
+
997
+ if (!hasDataSubs) {
998
+ currentDoc.hasDataSubscriptions = false;
999
+ this.onDataSubscriptionClosed(currentDoc, internalDocRef);
1000
+ }
1001
+ };
1002
+ }
1003
+
1004
+ private setupCollectionListener<M extends Model>(
1005
+ internalDoc: TDoc,
1006
+ collection: RecordCollectionRef<M>,
1007
+ ): void {
1008
+ const docId = collection.docRef.id;
1009
+ const storageName = getMetadata(collection.model).name;
1010
+ const yCollection = internalDoc.yDoc.getMap(storageName);
1011
+
1012
+ this.logger.debug("Setting up collection listener", {
1013
+ docId,
1014
+ storageName,
1015
+ existingKeys: Array.from(yCollection.keys()),
1016
+ allDocMaps: Array.from(internalDoc.yDoc.share.keys()),
1017
+ });
1018
+
1019
+ // TODO: tidy this up
1020
+ const eventHandler = (events: readonly Y.YEvent<Y.Map<unknown>>[]) => {
1021
+ this.logger.debug("Y.Map observeDeep fired", {
1022
+ docId,
1023
+ storageName,
1024
+ eventCount: events.length,
1025
+ });
1026
+
1027
+ const currentDoc = this.documents.get(docId);
1028
+ if (!currentDoc) return;
1029
+
1030
+ const subs = currentDoc.collectionSubscriptions.get(storageName);
1031
+ if (!subs) return;
1032
+
1033
+ const addedKeys = new Set<string>();
1034
+ const changedKeys = new Set<string>();
1035
+ const deletedKeys = new Set<string>();
1036
+
1037
+ for (const event of events) {
1038
+ // Shallow change, ie the collection itself was modified
1039
+ if (event.target === yCollection) {
1040
+ // Collection-level change (add/remove/replace keys in collection)
1041
+ for (const [key, change] of event.changes.keys) {
1042
+ switch (change.action) {
1043
+ case "add":
1044
+ addedKeys.add(key);
1045
+ break;
1046
+ case "update": // this is a replacement within the map
1047
+ changedKeys.add(key);
1048
+ break;
1049
+ case "delete":
1050
+ deletedKeys.add(key);
1051
+ break;
1052
+ }
1053
+ }
1054
+ } else {
1055
+ // Nested change (property change within a record)
1056
+ // path[0] is the record ID since path is relative to currentTarget (yCollection)
1057
+ const recordId = event.path[0];
1058
+ if (recordId != null) {
1059
+ changedKeys.add(recordId as string);
1060
+ }
1061
+ }
1062
+ }
1063
+
1064
+ // Notify subscribers
1065
+ if (addedKeys.size > 0) {
1066
+ const addedRecords = Array.from(addedKeys).map(id =>
1067
+ this.getCreateRecordRef(collection.docRef, id, collection.model)
1068
+ );
1069
+
1070
+ if (subs.added != null) {
1071
+ for (const callback of subs.added) {
1072
+ callback(addedRecords);
1073
+ }
1074
+ }
1075
+
1076
+ for (const record of addedRecords) {
1077
+ this.notifyRecordSubscribers(record, "changed");
1078
+ }
1079
+ }
1080
+
1081
+ if (changedKeys.size > 0) {
1082
+ const changedRecords = Array.from(changedKeys).map(id =>
1083
+ this.getCreateRecordRef(collection.docRef, id, collection.model)
1084
+ );
1085
+
1086
+ if (subs.changed != null) {
1087
+ for (const callback of subs.changed) {
1088
+ callback(changedRecords);
1089
+ }
1090
+ }
1091
+
1092
+ for (const record of changedRecords) {
1093
+ this.notifyRecordSubscribers(record, "changed");
1094
+ }
1095
+ }
1096
+
1097
+ if (deletedKeys.size > 0) {
1098
+ const deletedRecords = Array.from(deletedKeys).map(id =>
1099
+ this.getCreateRecordRef(collection.docRef, id, collection.model)
1100
+ );
1101
+
1102
+ if (subs.deleted?.size) {
1103
+ for (const callback of subs.deleted) {
1104
+ callback(deletedRecords);
1105
+ }
1106
+ }
1107
+
1108
+ for (const record of deletedRecords) {
1109
+ this.notifyRecordSubscribers(record, "deleted");
1110
+ }
1111
+ }
1112
+ };
1113
+
1114
+ yCollection.observeDeep(eventHandler);
1115
+
1116
+ // Store the handler for cleanup
1117
+ internalDoc.yjsCollectionHandlers.set(storageName, () => {
1118
+ yCollection.unobserveDeep(eventHandler);
1119
+ });
1120
+ }
1121
+
1122
+ private cleanupCollectionListener(docId: DocumentId, storageName: string): void {
1123
+ const internalDoc = this.documents.get(docId);
1124
+ if (internalDoc == null) {
1125
+ return;
1126
+ }
1127
+
1128
+ const cleanup = internalDoc.yjsCollectionHandlers.get(storageName);
1129
+ if (cleanup != null) {
1130
+ cleanup();
1131
+ internalDoc.yjsCollectionHandlers.delete(storageName);
1132
+ }
1133
+ }
1134
+
1135
+ private cleanupCollectionListenerIfUnused(
1136
+ internalDoc: TDoc,
1137
+ docId: DocumentId,
1138
+ storageName: string,
1139
+ ): void {
1140
+ const collectionSubs = internalDoc.collectionSubscriptions.get(storageName);
1141
+ const hasCollectionSubs = collectionSubs != null
1142
+ && (collectionSubs.added?.size || collectionSubs.changed?.size
1143
+ || collectionSubs.deleted?.size);
1144
+
1145
+ const hasRecordSubs = Array.from(internalDoc.recordSubscriptions.values()).some(
1146
+ recordSubs =>
1147
+ getMetadata(recordSubs.ref.model).name === storageName
1148
+ && (!isRecordSubscriptionsEmpty(recordSubs)),
1149
+ );
1150
+
1151
+ if (!hasCollectionSubs && !hasRecordSubs) {
1152
+ this.cleanupCollectionListener(docId, storageName);
1153
+ if (collectionSubs != null) {
1154
+ internalDoc.collectionSubscriptions.delete(storageName);
1155
+ }
1156
+ }
1157
+ }
1158
+
1159
+ // DocumentService status methods implementation
1160
+ readonly getDocumentStatus = <T extends DocumentSchema>(
1161
+ docRef: DocumentRef<T>,
1162
+ ): DocumentStatus => {
1163
+ const { internalDoc } = this.getCreateInternalDoc(docRef);
1164
+ return {
1165
+ metadata: internalDoc.metadataStatus,
1166
+ data: internalDoc.dataStatus,
1167
+ metadataError: internalDoc.metadataError,
1168
+ dataError: internalDoc.dataError,
1169
+ };
1170
+ };
1171
+
1172
+ readonly onStatusChange = <T extends DocumentSchema>(
1173
+ docRef: DocumentRef<T>,
1174
+ callback: DocumentStatusChangeCallback,
1175
+ ): Unsubscribe => {
1176
+ const { internalDoc } = this.getCreateInternalDoc(docRef);
1177
+ internalDoc.statusSubscribers.add(callback);
1178
+
1179
+ // Call callback immediately with current status
1180
+ const status: DocumentStatus = {
1181
+ metadata: internalDoc.metadataStatus,
1182
+ data: internalDoc.dataStatus,
1183
+ metadataError: internalDoc.metadataError,
1184
+ dataError: internalDoc.dataError,
1185
+ };
1186
+ callback(docRef, status);
1187
+
1188
+ return () => {
1189
+ const currentDoc = this.documents.get(docRef.id);
1190
+ if (currentDoc) {
1191
+ currentDoc.statusSubscribers.delete(callback);
1192
+ }
1193
+ };
1194
+ };
1195
+
1196
+ readonly waitForMetadataLoad = async <T extends DocumentSchema>(
1197
+ docRef: DocumentRef<T>,
1198
+ ): Promise<void> => {
1199
+ const { internalDoc } = this.getCreateInternalDoc(docRef);
1200
+
1201
+ if (internalDoc.metadataStatus.load === DocumentLoadStatus.LOADED) {
1202
+ return Promise.resolve();
1203
+ }
1204
+
1205
+ if (internalDoc.metadataStatus.load === DocumentLoadStatus.ERROR) {
1206
+ return Promise.reject(new Error("Metadata load error", { cause: internalDoc.metadataError }));
1207
+ }
1208
+
1209
+ // Wait for status to change to LOADED or ERROR
1210
+ return new Promise((resolve, reject) => {
1211
+ const unsubscribe = this.onStatusChange(docRef, (_, status) => {
1212
+ if (status.metadata.load === DocumentLoadStatus.LOADED) {
1213
+ unsubscribe();
1214
+ resolve();
1215
+ } else if (status.metadata.load === DocumentLoadStatus.ERROR) {
1216
+ unsubscribe();
1217
+ reject(new Error("Metadata load error", { cause: status.metadataError }));
1218
+ }
1219
+ });
1220
+ });
1221
+ };
1222
+
1223
+ readonly waitForDataLoad = async <T extends DocumentSchema>(
1224
+ docRef: DocumentRef<T>,
1225
+ ): Promise<void> => {
1226
+ const { internalDoc } = this.getCreateInternalDoc(docRef);
1227
+
1228
+ if (internalDoc.dataStatus.load === DocumentLoadStatus.LOADED) {
1229
+ return Promise.resolve();
1230
+ }
1231
+
1232
+ if (internalDoc.dataStatus.load === DocumentLoadStatus.ERROR) {
1233
+ return Promise.reject(new Error("Data load error", { cause: internalDoc.dataError }));
1234
+ }
1235
+
1236
+ // Wait for status to change to LOADED or ERROR
1237
+ return new Promise((resolve, reject) => {
1238
+ const unsubscribe = this.onStatusChange(docRef, (_, status) => {
1239
+ if (status.data.load === DocumentLoadStatus.LOADED) {
1240
+ unsubscribe();
1241
+ resolve();
1242
+ } else if (status.data.load === DocumentLoadStatus.ERROR) {
1243
+ unsubscribe();
1244
+ reject(new Error("Data load error", { cause: status.dataError }));
1245
+ }
1246
+ });
1247
+ });
1248
+ };
1249
+
1250
+ // FIXME: don't expose in production builds
1251
+ /**
1252
+ * @internal
1253
+ */
1254
+ public getYDocForTesting(docId: DocumentId): Y.Doc | null {
1255
+ const internalDoc = this.documents.get(docId);
1256
+ return internalDoc ? internalDoc.yDoc : null;
1257
+ }
1258
+ }
1259
+
1260
+ function isCollectionSubscriptionsEmpty<M extends Model>(
1261
+ subs: RecordCollectionSubscriptions<M>,
1262
+ ): boolean {
1263
+ return (
1264
+ !subs.added?.size
1265
+ && !subs.changed?.size
1266
+ && !subs.deleted?.size
1267
+ );
1268
+ }
1269
+
1270
+ function isRecordSubscriptionsEmpty<M extends Model>(
1271
+ subs: RecordSubscribers<M>,
1272
+ ): boolean {
1273
+ return (
1274
+ !subs.changed?.size
1275
+ && !subs.deleted?.size
1276
+ );
1277
+ }