@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.
- package/.turbo/turbo-lint.log +4 -0
- package/.turbo/turbo-transpileBrowser.log +5 -0
- package/.turbo/turbo-transpileCjs.log +5 -0
- package/.turbo/turbo-transpileEsm.log +5 -0
- package/.turbo/turbo-transpileTypes.log +5 -0
- package/.turbo/turbo-typecheck.log +4 -0
- package/LICENSE.txt +13 -0
- package/README.md +55 -0
- package/build/browser/index.js +1257 -0
- package/build/browser/index.js.map +1 -0
- package/build/cjs/index.cjs +1298 -0
- package/build/cjs/index.cjs.map +1 -0
- package/build/cjs/index.d.cts +272 -0
- package/build/esm/index.js +1257 -0
- package/build/esm/index.js.map +1 -0
- package/build/types/DocumentServiceModule.d.ts +6 -0
- package/build/types/DocumentServiceModule.d.ts.map +1 -0
- package/build/types/__tests__/DocumentStatusTracking.test.d.ts +1 -0
- package/build/types/__tests__/DocumentStatusTracking.test.d.ts.map +1 -0
- package/build/types/__tests__/RefStability.test.d.ts +1 -0
- package/build/types/__tests__/RefStability.test.d.ts.map +1 -0
- package/build/types/__tests__/StateModule.integration.test.d.ts +1 -0
- package/build/types/__tests__/StateModule.integration.test.d.ts.map +1 -0
- package/build/types/__tests__/testUtils.d.ts +7 -0
- package/build/types/__tests__/testUtils.d.ts.map +1 -0
- package/build/types/index.d.ts +11 -0
- package/build/types/index.d.ts.map +1 -0
- package/build/types/service/BaseYjsDocumentService.d.ts +155 -0
- package/build/types/service/BaseYjsDocumentService.d.ts.map +1 -0
- package/build/types/service/InMemoryDocumentService.d.ts +12 -0
- package/build/types/service/InMemoryDocumentService.d.ts.map +1 -0
- package/build/types/service/YjsSchemaMapper.d.ts +9 -0
- package/build/types/service/YjsSchemaMapper.d.ts.map +1 -0
- package/build/types/types/DocumentRefImpl.d.ts +5 -0
- package/build/types/types/DocumentRefImpl.d.ts.map +1 -0
- package/build/types/types/DocumentService.d.ts +62 -0
- package/build/types/types/DocumentService.d.ts.map +1 -0
- package/build/types/types/DocumentServiceConfig.d.ts +5 -0
- package/build/types/types/DocumentServiceConfig.d.ts.map +1 -0
- package/build/types/types/RecordCollectionRefImpl.d.ts +5 -0
- package/build/types/types/RecordCollectionRefImpl.d.ts.map +1 -0
- package/build/types/types/RecordRefImpl.d.ts +5 -0
- package/build/types/types/RecordRefImpl.d.ts.map +1 -0
- package/build/types/types/StateModule.d.ts +59 -0
- package/build/types/types/StateModule.d.ts.map +1 -0
- package/package.json +71 -0
- package/src/DocumentServiceModule.ts +53 -0
- package/src/__tests__/DocumentStatusTracking.test.ts +229 -0
- package/src/__tests__/RefStability.test.ts +441 -0
- package/src/__tests__/StateModule.integration.test.ts +1187 -0
- package/src/__tests__/testUtils.ts +106 -0
- package/src/index.ts +38 -0
- package/src/service/BaseYjsDocumentService.ts +1277 -0
- package/src/service/InMemoryDocumentService.ts +162 -0
- package/src/service/YjsSchemaMapper.ts +194 -0
- package/src/types/DocumentRefImpl.ts +98 -0
- package/src/types/DocumentService.ts +210 -0
- package/src/types/DocumentServiceConfig.ts +22 -0
- package/src/types/RecordCollectionRefImpl.ts +124 -0
- package/src/types/RecordRefImpl.ts +106 -0
- package/src/types/StateModule.ts +329 -0
- package/tsconfig.json +21 -0
- 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
|
+
}
|