@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,1187 @@
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
+ import type { PackAppInternal } from "@palantir/pack.core";
18
+ import type {
19
+ DocumentId,
20
+ DocumentMetadata,
21
+ DocumentSchema,
22
+ Model,
23
+ RecordId,
24
+ } from "@palantir/pack.document-schema.model-types";
25
+ import { Metadata } from "@palantir/pack.document-schema.model-types";
26
+ import { beforeEach, describe, expect, it } from "vitest";
27
+ import * as Y from "yjs";
28
+ import { z } from "zod";
29
+ import { DOCUMENT_SERVICE_MODULE_KEY } from "../DocumentServiceModule.js";
30
+ import type { BaseYjsDocumentService } from "../service/BaseYjsDocumentService.js";
31
+ import { createDocRef } from "../types/DocumentRefImpl.js";
32
+ import { getStateModule } from "../types/StateModule.js";
33
+ import { createTestApp, createTestAppNoAutocreate } from "./testUtils.js";
34
+
35
+ const TEST_SECURITY = {
36
+ mandatory: {
37
+ classification: ["MU"],
38
+ markings: [],
39
+ },
40
+ discretionary: {
41
+ owners: [],
42
+ editors: [],
43
+ viewers: [],
44
+ },
45
+ };
46
+
47
+ const createTestSchema = (): DocumentSchema => ({
48
+ [Metadata]: {
49
+ version: 1,
50
+ },
51
+ } as const satisfies DocumentSchema);
52
+
53
+ // Test data types
54
+ interface User {
55
+ id: string;
56
+ name: string;
57
+ email: string;
58
+ age: number;
59
+ }
60
+
61
+ interface Address {
62
+ street: string;
63
+ city: string;
64
+ zipCode: string;
65
+ }
66
+
67
+ const createSchemaWithRecords = () => {
68
+ const userSchema = z.object({
69
+ id: z.string(),
70
+ name: z.string(),
71
+ email: z.email(),
72
+ age: z.number().int().positive(),
73
+ });
74
+
75
+ const addressSchema = z.object({
76
+ street: z.string(),
77
+ city: z.string(),
78
+ zipCode: z.string(),
79
+ });
80
+
81
+ const UserModel: Model<User, typeof userSchema> = {
82
+ __type: {} as User,
83
+ zodSchema: userSchema,
84
+ [Metadata]: {
85
+ name: "User",
86
+ },
87
+ };
88
+
89
+ const AddressModel: Model<Address, typeof addressSchema> = {
90
+ __type: {} as Address,
91
+ zodSchema: addressSchema,
92
+ [Metadata]: {
93
+ name: "Address",
94
+ },
95
+ };
96
+
97
+ return {
98
+ User: UserModel,
99
+ Address: AddressModel,
100
+ [Metadata]: {
101
+ version: 1,
102
+ },
103
+ } as const satisfies DocumentSchema;
104
+ };
105
+
106
+ describe("State Module Integration", () => {
107
+ let app: PackAppInternal;
108
+ beforeEach(() => {
109
+ app = createTestAppNoAutocreate();
110
+ });
111
+
112
+ it("should initialize app with state module and create/load document", async () => {
113
+ const stateModule = getStateModule(app);
114
+ expect(stateModule).toBeDefined();
115
+
116
+ const metadata: DocumentMetadata = {
117
+ name: "Test Document",
118
+ documentTypeName: "TestType",
119
+ ontologyRid: "test-ontology-rid",
120
+ security: TEST_SECURITY,
121
+ };
122
+
123
+ const schema = createTestSchema();
124
+ const docRef = await stateModule.createDocument(metadata, schema);
125
+
126
+ expect(docRef).toBeDefined();
127
+ expect(docRef.id).toBeDefined();
128
+ expect(typeof docRef.id).toBe("string");
129
+
130
+ let metadataCallbackCalled = false;
131
+ let stateCallbackCalled = false;
132
+
133
+ const unsubscribeMetadata = stateModule.onMetadataChange(
134
+ docRef,
135
+ (doc, receivedMetadata) => {
136
+ expect(doc.id).toBe(docRef.id);
137
+ expect(receivedMetadata.name).toBe("Test Document");
138
+ metadataCallbackCalled = true;
139
+ },
140
+ );
141
+
142
+ const unsubscribeState = stateModule.onStateChange(docRef, doc => {
143
+ expect(doc.id).toBe(docRef.id);
144
+ stateCallbackCalled = true;
145
+ });
146
+
147
+ await new Promise(resolve => setTimeout(resolve, 10));
148
+
149
+ expect(metadataCallbackCalled).toBe(true);
150
+ expect(stateCallbackCalled).toBe(true);
151
+
152
+ unsubscribeMetadata();
153
+ unsubscribeState();
154
+ });
155
+
156
+ it("should load via docRef", async () => {
157
+ const stateModule = getStateModule(app);
158
+ const metadata: DocumentMetadata = {
159
+ name: "Persistent Document",
160
+ documentTypeName: "TestType",
161
+ ontologyRid: "test-ontology-rid",
162
+ security: TEST_SECURITY,
163
+ };
164
+
165
+ // Create document and capture the ID, then discard the ref
166
+ const schema = createTestSchema();
167
+ const originalDocRef = await stateModule.createDocument(metadata, schema);
168
+ const documentId = originalDocRef.id;
169
+
170
+ // Create new docRef using the same ID and schema
171
+ const newDocRef = createDocRef(app, documentId, schema);
172
+
173
+ // Use the new docRef to load metadata via subscription
174
+ let loadedMetadata: DocumentMetadata | null = null;
175
+ let metadataLoadComplete = false;
176
+
177
+ const unsubscribeMetadata = newDocRef.onMetadataChange(
178
+ (doc, receivedMetadata) => {
179
+ expect(doc.id).toBe(documentId);
180
+ expect(receivedMetadata.name).toBe("Persistent Document");
181
+ loadedMetadata = receivedMetadata;
182
+ metadataLoadComplete = true;
183
+ },
184
+ );
185
+
186
+ // Use the new docRef to subscribe to state changes
187
+ let stateLoadComplete = false;
188
+ const unsubscribeState = newDocRef.onStateChange(doc => {
189
+ expect(doc.id).toBe(documentId);
190
+ stateLoadComplete = true;
191
+ });
192
+
193
+ await new Promise(resolve => setTimeout(resolve, 10));
194
+
195
+ expect(metadataLoadComplete).toBe(true);
196
+ expect(stateLoadComplete).toBe(true);
197
+ expect(loadedMetadata).toEqual(metadata);
198
+
199
+ unsubscribeMetadata();
200
+ unsubscribeState();
201
+ });
202
+
203
+ it("should set and get record values", async () => {
204
+ const stateModule = getStateModule(app);
205
+ const schema = createSchemaWithRecords();
206
+ const metadata: DocumentMetadata = {
207
+ name: "Document with Records",
208
+ documentTypeName: "TestType",
209
+ ontologyRid: "test-ontology-rid",
210
+ security: TEST_SECURITY,
211
+ };
212
+
213
+ const docRef = await stateModule.createDocument(metadata, schema);
214
+
215
+ const userSchema = schema.User;
216
+ const recordsCollection = docRef.getRecords(userSchema);
217
+
218
+ const userId = "user_1" as RecordId;
219
+ const userData = {
220
+ id: "user_1",
221
+ name: "John Doe",
222
+ email: "john@example.com",
223
+ age: 30,
224
+ };
225
+
226
+ // Set a record
227
+ await recordsCollection.set(userId, userData);
228
+
229
+ // Get the record back
230
+ const recordRef = recordsCollection.get(userId);
231
+ expect(recordRef).toBeDefined();
232
+
233
+ if (recordRef) {
234
+ const snapshot = await recordRef.getSnapshot();
235
+ expect(snapshot).toEqual(userData);
236
+ }
237
+ });
238
+
239
+ it("should trigger onStateChange when record is modified", async () => {
240
+ const stateModule = getStateModule(app);
241
+ const schema = createSchemaWithRecords();
242
+ const metadata: DocumentMetadata = {
243
+ name: "Document with State Changes",
244
+ documentTypeName: "TestType",
245
+ ontologyRid: "test-ontology-rid",
246
+ security: TEST_SECURITY,
247
+ };
248
+
249
+ const docRef = await stateModule.createDocument(metadata, schema);
250
+
251
+ let stateChangeCount = 0;
252
+ const unsubscribe = stateModule.onStateChange(docRef, () => {
253
+ stateChangeCount++;
254
+ });
255
+
256
+ // Initial callback should fire immediately
257
+ await new Promise(resolve => setTimeout(resolve, 10));
258
+ expect(stateChangeCount).toBe(1);
259
+
260
+ const userSchema = schema.User;
261
+ const recordsCollection = docRef.getRecords(userSchema);
262
+
263
+ const userId = "user_1" as RecordId;
264
+ const userData = {
265
+ id: "user_1",
266
+ name: "Jane Doe",
267
+ email: "jane@example.com",
268
+ age: 28,
269
+ };
270
+
271
+ // Setting a record should trigger state change
272
+ await recordsCollection.set(userId, userData);
273
+
274
+ // Verify the data was actually stored
275
+ const recordRef = recordsCollection.get(userId);
276
+ expect(recordRef).toBeDefined();
277
+ const snapshot = await recordRef!.getSnapshot();
278
+ expect(snapshot).toEqual(userData);
279
+
280
+ // Wait for async callback
281
+ await new Promise(resolve => setTimeout(resolve, 10));
282
+ // Y.Doc may fire multiple events during record creation
283
+ expect(stateChangeCount).toBeGreaterThanOrEqual(2);
284
+
285
+ const countBeforeUpdate = stateChangeCount;
286
+
287
+ // Update the record
288
+ const updatedData = { ...userData, age: 29 };
289
+ await recordsCollection.set(userId, updatedData);
290
+
291
+ // Verify the update was stored
292
+ const updatedSnapshot = await recordRef!.getSnapshot();
293
+ expect(updatedSnapshot).toEqual(updatedData);
294
+ expect(updatedSnapshot.age).toBe(29);
295
+
296
+ await new Promise(resolve => setTimeout(resolve, 10));
297
+ expect(stateChangeCount).toBeGreaterThan(countBeforeUpdate);
298
+
299
+ unsubscribe();
300
+ });
301
+
302
+ it("should handle multiple records and collections", async () => {
303
+ const stateModule = getStateModule(app);
304
+ const schema = createSchemaWithRecords();
305
+ const metadata: DocumentMetadata = {
306
+ name: "Multi-Record Document",
307
+ documentTypeName: "TestType",
308
+ ontologyRid: "test-ontology-rid",
309
+ security: TEST_SECURITY,
310
+ };
311
+
312
+ const docRef = await stateModule.createDocument(metadata, schema);
313
+
314
+ const userSchema = schema.User;
315
+ const addressSchema = schema.Address;
316
+
317
+ const usersCollection = docRef.getRecords(userSchema);
318
+ const addressesCollection = docRef.getRecords(addressSchema);
319
+
320
+ // Add multiple users
321
+ const user1Id = "user_1" as RecordId;
322
+ const user2Id = "user_2" as RecordId;
323
+
324
+ const user1Data = {
325
+ id: "user_1",
326
+ name: "Alice",
327
+ email: "alice@example.com",
328
+ age: 25,
329
+ };
330
+
331
+ const user2Data = {
332
+ id: "user_2",
333
+ name: "Bob",
334
+ email: "bob@example.com",
335
+ age: 32,
336
+ };
337
+
338
+ await usersCollection.set(user1Id, user1Data);
339
+ await usersCollection.set(user2Id, user2Data);
340
+
341
+ // Add an address
342
+ const addressId = "addr_1" as RecordId;
343
+ const addressData = {
344
+ street: "123 Main St",
345
+ city: "New York",
346
+ zipCode: "10001",
347
+ };
348
+ await addressesCollection.set(addressId, addressData);
349
+
350
+ // Verify collections maintain separate data
351
+ expect(usersCollection.size).toBe(2);
352
+ expect(addressesCollection.size).toBe(1);
353
+
354
+ // Verify we can retrieve each user correctly
355
+ const user1Ref = usersCollection.get(user1Id);
356
+ const user2Ref = usersCollection.get(user2Id);
357
+ expect(user1Ref).toBeDefined();
358
+ expect(user2Ref).toBeDefined();
359
+
360
+ const user1Snapshot = await user1Ref!.getSnapshot();
361
+ const user2Snapshot = await user2Ref!.getSnapshot();
362
+ expect(user1Snapshot).toEqual(user1Data);
363
+ expect(user2Snapshot).toEqual(user2Data);
364
+
365
+ // Verify address data
366
+ const addressRef = addressesCollection.get(addressId);
367
+ expect(addressRef).toBeDefined();
368
+ const addressSnapshot = await addressRef!.getSnapshot();
369
+ expect(addressSnapshot).toEqual(addressData);
370
+
371
+ // Verify we can iterate over records
372
+ const userIds: string[] = [];
373
+ for (const recordRef of usersCollection) {
374
+ userIds.push(recordRef.id as string);
375
+ // Also verify each iterated record can be read
376
+ const snapshot = await recordRef.getSnapshot();
377
+ expect(snapshot).toBeDefined();
378
+ expect(snapshot.id).toBeDefined();
379
+ }
380
+ expect(userIds).toContain("user_1");
381
+ expect(userIds).toContain("user_2");
382
+ });
383
+ });
384
+
385
+ describe("Lazy Document Creation", () => {
386
+ let app: PackAppInternal;
387
+
388
+ beforeEach(() => {
389
+ app = createTestAppNoAutocreate();
390
+ });
391
+
392
+ it("should create document lazily when metadata subscription is made", async () => {
393
+ const schema = createTestSchema();
394
+ const documentId = "lazy-metadata-doc" as DocumentId;
395
+ const testDocRef = createDocRef(app, documentId, schema);
396
+
397
+ let callbackCalled = false;
398
+
399
+ // Register metadata listener - this should create the document lazily
400
+ const unsubscribe = testDocRef.onMetadataChange((doc, metadata) => {
401
+ callbackCalled = true;
402
+ expect(doc.id).toBe(documentId);
403
+ // Initially no metadata since document was created lazily
404
+ expect(metadata).toBeUndefined();
405
+ });
406
+
407
+ // Wait for async operations
408
+ await new Promise(resolve => setTimeout(resolve, 10));
409
+
410
+ // Callback should not be called since metadata is undefined
411
+ expect(callbackCalled).toBe(false);
412
+
413
+ // Verify the document was created internally (getDocSnapshot should work)
414
+ const snapshot = await testDocRef.getDocSnapshot();
415
+ expect(snapshot).toBeDefined();
416
+
417
+ unsubscribe();
418
+ });
419
+
420
+ it("should create document lazily when state subscription is made", async () => {
421
+ const schema = createTestSchema();
422
+ const documentId = "lazy-state-doc" as DocumentId;
423
+ const testDocRef = createDocRef(app, documentId, schema);
424
+
425
+ let callbackCalled = false;
426
+
427
+ // Register state listener - this should create the document lazily
428
+ const unsubscribe = testDocRef.onStateChange(doc => {
429
+ callbackCalled = true;
430
+ expect(doc.id).toBe(documentId);
431
+ });
432
+
433
+ // Wait for async operations
434
+ await new Promise(resolve => setTimeout(resolve, 10));
435
+
436
+ // Callback should be called immediately
437
+ expect(callbackCalled).toBe(true);
438
+
439
+ // Verify the document was created internally
440
+ const snapshot = await testDocRef.getDocSnapshot();
441
+ expect(snapshot).toBeDefined();
442
+
443
+ unsubscribe();
444
+ });
445
+
446
+ it("should create document lazily when collection subscription is made", async () => {
447
+ const schema = createSchemaWithRecords();
448
+ const documentId = "lazy-collection-doc" as DocumentId;
449
+ const testDocRef = createDocRef(app, documentId, schema);
450
+
451
+ const usersCollection = testDocRef.getRecords(schema.User);
452
+
453
+ let addedCallbackCalled = false;
454
+ let changedCallbackCalled = false;
455
+ let deletedCallbackCalled = false;
456
+
457
+ // Register collection listeners - these should create the document lazily
458
+ const unsubscribeAdded = usersCollection.onItemsAdded(() => {
459
+ addedCallbackCalled = true;
460
+ });
461
+
462
+ const unsubscribeChanged = usersCollection.onItemsChanged(() => {
463
+ changedCallbackCalled = true;
464
+ });
465
+
466
+ const unsubscribeDeleted = usersCollection.onItemsDeleted(() => {
467
+ deletedCallbackCalled = true;
468
+ });
469
+
470
+ // Verify the document was created internally
471
+ const snapshot = await testDocRef.getDocSnapshot();
472
+ expect(snapshot).toBeDefined();
473
+
474
+ // Add a record to test the subscriptions work
475
+ const userId = "user_1" as RecordId;
476
+ const userData = {
477
+ id: "user_1",
478
+ name: "John Doe",
479
+ email: "john@example.com",
480
+ age: 30,
481
+ };
482
+
483
+ await usersCollection.set(userId, userData);
484
+
485
+ // Wait for async operations
486
+ await new Promise(resolve => setTimeout(resolve, 10));
487
+
488
+ expect(addedCallbackCalled).toBe(true);
489
+
490
+ // Update the record
491
+ await usersCollection.set(userId, { ...userData, age: 31 });
492
+ await new Promise(resolve => setTimeout(resolve, 10));
493
+
494
+ expect(changedCallbackCalled).toBe(true);
495
+
496
+ // Delete the record
497
+ await usersCollection.delete(userId);
498
+ await new Promise(resolve => setTimeout(resolve, 10));
499
+
500
+ expect(deletedCallbackCalled).toBe(true);
501
+
502
+ unsubscribeAdded();
503
+ unsubscribeChanged();
504
+ unsubscribeDeleted();
505
+ });
506
+
507
+ it("should create document lazily when record subscription is made", async () => {
508
+ const schema = createSchemaWithRecords();
509
+ const documentId = "lazy-record-doc" as DocumentId;
510
+ const testDocRef = createDocRef(app, documentId, schema);
511
+
512
+ const usersCollection = testDocRef.getRecords(schema.User);
513
+ const userId = "user_1" as RecordId;
514
+ const userRecord = usersCollection.get(userId);
515
+
516
+ // This should return undefined since record doesn't exist yet
517
+ expect(userRecord).toBeUndefined();
518
+
519
+ // Create a record ref directly using the recordRef function
520
+ const stateModule = getStateModule(app);
521
+ const directRecordRef = stateModule.createRecordRef(testDocRef, userId, schema.User);
522
+
523
+ let changedCallbackCalled = false;
524
+ let deletedCallbackCalled = false;
525
+
526
+ // Register record listeners - these should create the document lazily
527
+ const unsubscribeChanged = stateModule.onRecordChanged(directRecordRef, record => {
528
+ changedCallbackCalled = true;
529
+ expect(record.id).toBe(userId);
530
+ });
531
+
532
+ const unsubscribeDeleted = stateModule.onRecordDeleted(directRecordRef, record => {
533
+ deletedCallbackCalled = true;
534
+ expect(record.id).toBe(userId);
535
+ });
536
+
537
+ // Verify the document was created internally
538
+ const snapshot = await testDocRef.getDocSnapshot();
539
+ expect(snapshot).toBeDefined();
540
+
541
+ // Add a record to test the subscriptions work
542
+ const userData = {
543
+ id: "user_1",
544
+ name: "Jane Doe",
545
+ email: "jane@example.com",
546
+ age: 25,
547
+ };
548
+
549
+ await stateModule.setRecord(directRecordRef, userData);
550
+
551
+ // Wait for async operations
552
+ await new Promise(resolve => setTimeout(resolve, 10));
553
+
554
+ expect(changedCallbackCalled).toBe(true);
555
+
556
+ // Delete the record
557
+ await usersCollection.delete(userId);
558
+ await new Promise(resolve => setTimeout(resolve, 10));
559
+
560
+ expect(deletedCallbackCalled).toBe(true);
561
+
562
+ unsubscribeChanged();
563
+ unsubscribeDeleted();
564
+ });
565
+
566
+ it("should handle mixed subscription scenarios with lazy creation", async () => {
567
+ const schema = createSchemaWithRecords();
568
+ const documentId = "mixed-lazy-doc" as DocumentId;
569
+ const testDocRef = createDocRef(app, documentId, schema);
570
+
571
+ const usersCollection = testDocRef.getRecords(schema.User);
572
+ const userId = "user_1" as RecordId;
573
+
574
+ let stateChangeCount = 0;
575
+ let collectionAddedCount = 0;
576
+ let recordChangedCount = 0;
577
+
578
+ // Subscribe to state changes first
579
+ const unsubscribeState = testDocRef.onStateChange(() => {
580
+ stateChangeCount++;
581
+ });
582
+
583
+ // Subscribe to collection changes
584
+ const unsubscribeCollection = usersCollection.onItemsAdded(() => {
585
+ collectionAddedCount++;
586
+ });
587
+
588
+ // Create record ref and subscribe to it using StateModule API
589
+ const stateModule = getStateModule(app);
590
+ const directRecordRef = stateModule.createRecordRef(testDocRef, userId, schema.User);
591
+ const unsubscribeRecord = stateModule.onRecordChanged(directRecordRef, () => {
592
+ recordChangedCount++;
593
+ });
594
+
595
+ // Wait for initial callbacks
596
+ await new Promise(resolve => setTimeout(resolve, 10));
597
+
598
+ // State change should be called immediately
599
+ expect(stateChangeCount).toBe(1);
600
+
601
+ // Verify the document was created internally
602
+ const snapshot = await testDocRef.getDocSnapshot();
603
+ expect(snapshot).toBeDefined();
604
+
605
+ // Add a record - this should trigger multiple subscriptions
606
+ const userData = {
607
+ id: "user_1",
608
+ name: "Mixed Test User",
609
+ email: "mixed@example.com",
610
+ age: 40,
611
+ };
612
+
613
+ await usersCollection.set(userId, userData);
614
+
615
+ // Wait for async operations
616
+ await new Promise(resolve => setTimeout(resolve, 10));
617
+
618
+ // All subscriptions should have been notified
619
+ expect(stateChangeCount).toBeGreaterThan(1); // State change from record addition
620
+ expect(collectionAddedCount).toBeGreaterThanOrEqual(1); // Collection item added (may be multiple events)
621
+ expect(recordChangedCount).toBe(1); // Record changed
622
+
623
+ unsubscribeState();
624
+ unsubscribeCollection();
625
+ unsubscribeRecord();
626
+ });
627
+
628
+ it("should preserve existing behavior when document already exists", async () => {
629
+ const schema = createTestSchema();
630
+ const metadata: DocumentMetadata = {
631
+ name: "Pre-existing Document",
632
+ documentTypeName: "TestType",
633
+ ontologyRid: "test-ontology-rid",
634
+ security: TEST_SECURITY,
635
+ };
636
+
637
+ // Create document normally first
638
+ const stateModule = getStateModule(app);
639
+ const docRef = await stateModule.createDocument(metadata, schema);
640
+
641
+ let metadataCallbackCount = 0;
642
+ let receivedMetadata: DocumentMetadata | null = null;
643
+
644
+ // Subscribe to existing document - should get immediate callback with metadata
645
+ const unsubscribe = docRef.onMetadataChange((doc, meta) => {
646
+ metadataCallbackCount++;
647
+ receivedMetadata = meta;
648
+ expect(doc.id).toBe(docRef.id);
649
+ });
650
+
651
+ // Wait for async operations
652
+ await new Promise(resolve => setTimeout(resolve, 10));
653
+
654
+ // Should get immediate callback with existing metadata
655
+ expect(metadataCallbackCount).toBe(1);
656
+ expect(receivedMetadata).toEqual(metadata);
657
+
658
+ unsubscribe();
659
+ });
660
+
661
+ it("should handle updateRecord with partial updates", async () => {
662
+ const stateModule = getStateModule(app);
663
+ const schema = createSchemaWithRecords();
664
+ const metadata: DocumentMetadata = {
665
+ name: "Update Record Test",
666
+ documentTypeName: "TestType",
667
+ ontologyRid: "test-ontology-rid",
668
+ security: TEST_SECURITY,
669
+ };
670
+
671
+ const docRef = await stateModule.createDocument(metadata, schema);
672
+ const usersCollection = docRef.getRecords(schema.User);
673
+
674
+ const userId = "user_1" as RecordId;
675
+ const initialData = {
676
+ id: "user_1",
677
+ name: "John Doe",
678
+ email: "john@example.com",
679
+ age: 30,
680
+ };
681
+
682
+ // Set initial record
683
+ await usersCollection.set(userId, initialData);
684
+
685
+ const recordRef = usersCollection.get(userId)!;
686
+ let changeNotifications = 0;
687
+ let lastSnapshot: User | null = null;
688
+
689
+ // Subscribe to record changes
690
+ const unsubscribe = stateModule.onRecordChanged(recordRef, snapshot => {
691
+ changeNotifications++;
692
+ lastSnapshot = snapshot;
693
+ });
694
+
695
+ // Wait for initial callback
696
+ await new Promise(resolve => setTimeout(resolve, 10));
697
+ expect(changeNotifications).toBe(1);
698
+ expect(lastSnapshot).toEqual(initialData);
699
+
700
+ // Update only age using updateRecord
701
+ await stateModule.updateRecord(recordRef, { age: 31 });
702
+
703
+ await new Promise(resolve => setTimeout(resolve, 10));
704
+ expect(changeNotifications).toBe(2);
705
+ expect(lastSnapshot).toEqual({
706
+ id: "user_1",
707
+ name: "John Doe", // Should remain unchanged
708
+ email: "john@example.com", // Should remain unchanged
709
+ age: 31, // Should be updated
710
+ });
711
+
712
+ // Update multiple fields
713
+ await stateModule.updateRecord(recordRef, { name: "Jane Doe", age: 32 });
714
+
715
+ await new Promise(resolve => setTimeout(resolve, 10));
716
+ expect(changeNotifications).toBe(3);
717
+ expect(lastSnapshot).toEqual({
718
+ id: "user_1",
719
+ name: "Jane Doe", // Updated
720
+ email: "john@example.com", // Unchanged
721
+ age: 32, // Updated
722
+ });
723
+
724
+ // Verify final snapshot via direct get
725
+ const finalSnapshot = await recordRef.getSnapshot();
726
+ expect(finalSnapshot).toEqual(lastSnapshot);
727
+
728
+ unsubscribe();
729
+ });
730
+
731
+ it("should handle updateRecord on non-existent record", async () => {
732
+ const stateModule = getStateModule(app);
733
+ const schema = createSchemaWithRecords();
734
+ const metadata: DocumentMetadata = {
735
+ name: "Update Non-Existent Record Test",
736
+ documentTypeName: "TestType",
737
+ ontologyRid: "test-ontology-rid",
738
+ security: TEST_SECURITY,
739
+ };
740
+
741
+ const docRef = await stateModule.createDocument(metadata, schema);
742
+
743
+ const userId = "non_existent_user" as RecordId;
744
+ const nonExistentRecord = stateModule.createRecordRef(docRef, userId, schema.User);
745
+
746
+ // Try to update non-existent record - should reject
747
+ await expect(stateModule.updateRecord(nonExistentRecord, { age: 25 }))
748
+ .rejects.toThrow("Record not found for update");
749
+ });
750
+
751
+ it("should handle deleteRecord method", async () => {
752
+ const stateModule = getStateModule(app);
753
+ const schema = createSchemaWithRecords();
754
+ const metadata: DocumentMetadata = {
755
+ name: "Delete Record Test",
756
+ documentTypeName: "TestType",
757
+ ontologyRid: "test-ontology-rid",
758
+ security: TEST_SECURITY,
759
+ };
760
+
761
+ const docRef = await stateModule.createDocument(metadata, schema);
762
+ const usersCollection = docRef.getRecords(schema.User);
763
+
764
+ const userId = "user_to_delete" as RecordId;
765
+ const userData = {
766
+ id: "user_to_delete",
767
+ name: "Delete Me",
768
+ email: "delete@example.com",
769
+ age: 25,
770
+ };
771
+
772
+ // Create record
773
+ await usersCollection.set(userId, userData);
774
+ expect(usersCollection.has(userId)).toBe(true);
775
+ expect(usersCollection.size).toBe(1);
776
+
777
+ // Get record ref and delete it
778
+ const recordRef = usersCollection.get(userId)!;
779
+ let deletedCallbackCalled = false;
780
+
781
+ const unsubscribe = stateModule.onRecordDeleted(recordRef, deletedRecord => {
782
+ deletedCallbackCalled = true;
783
+ expect(deletedRecord.id).toBe(userId);
784
+ });
785
+
786
+ // Delete using StateModule deleteRecord method
787
+ await stateModule.deleteRecord(recordRef);
788
+
789
+ // Verify deletion
790
+ expect(usersCollection.has(userId)).toBe(false);
791
+ expect(usersCollection.size).toBe(0);
792
+ expect(usersCollection.get(userId)).toBeUndefined();
793
+
794
+ await new Promise(resolve => setTimeout(resolve, 10));
795
+ expect(deletedCallbackCalled).toBe(true);
796
+
797
+ // Try to delete again - should be a no-op (no error)
798
+ await stateModule.deleteRecord(recordRef);
799
+
800
+ unsubscribe();
801
+ });
802
+
803
+ it("should handle setRecord as full replacement (clearing missing fields)", async () => {
804
+ const stateModule = getStateModule(app);
805
+
806
+ // Use a schema with optional fields to test field removal
807
+ const userWithOptionalFields = z.object({
808
+ id: z.string(),
809
+ name: z.string(),
810
+ email: z.string().optional(),
811
+ age: z.number().optional(),
812
+ bio: z.string().optional(),
813
+ });
814
+
815
+ type UserWithOptional = z.infer<typeof userWithOptionalFields>;
816
+
817
+ const UserOptionalModel: Model<UserWithOptional, typeof userWithOptionalFields> = {
818
+ __type: {} as UserWithOptional,
819
+ zodSchema: userWithOptionalFields,
820
+ [Metadata]: {
821
+ name: "UserOptional",
822
+ },
823
+ };
824
+
825
+ const schema = {
826
+ UserOptional: UserOptionalModel,
827
+ [Metadata]: { version: 1 },
828
+ } as const satisfies DocumentSchema;
829
+
830
+ const metadata: DocumentMetadata = {
831
+ name: "Full Replacement Test",
832
+ documentTypeName: "TestType",
833
+ ontologyRid: "test-ontology-rid",
834
+ security: TEST_SECURITY,
835
+ };
836
+
837
+ const docRef = await stateModule.createDocument(metadata, schema);
838
+ const usersCollection = docRef.getRecords(schema.UserOptional);
839
+
840
+ const userId = "user_replace" as RecordId;
841
+ const initialData: UserWithOptional = {
842
+ id: "user_replace",
843
+ name: "John Doe",
844
+ email: "john@example.com",
845
+ age: 30,
846
+ bio: "Software Engineer",
847
+ };
848
+
849
+ // Set initial record with all fields
850
+ await usersCollection.set(userId, initialData);
851
+
852
+ let snapshot = await usersCollection.get(userId)!.getSnapshot();
853
+ expect(snapshot).toEqual(initialData);
854
+
855
+ // Replace with record missing some fields - setRecord should clear missing fields
856
+ const replacementData: UserWithOptional = {
857
+ id: "user_replace",
858
+ name: "Jane Doe", // Changed
859
+ email: "jane@example.com", // Changed
860
+ // age and bio are missing - should be cleared by setRecord
861
+ };
862
+
863
+ await usersCollection.set(userId, replacementData);
864
+
865
+ snapshot = await usersCollection.get(userId)!.getSnapshot();
866
+ expect(snapshot).toEqual({
867
+ id: "user_replace",
868
+ name: "Jane Doe",
869
+ email: "jane@example.com",
870
+ // age and bio should be undefined/missing
871
+ });
872
+ expect(snapshot.age).toBeUndefined();
873
+ expect(snapshot.bio).toBeUndefined();
874
+ });
875
+ });
876
+
877
+ describe("InMemoryDocumentService with autoCreateDocuments", () => {
878
+ let app: PackAppInternal;
879
+
880
+ beforeEach(() => {
881
+ app = createTestApp();
882
+ });
883
+
884
+ it("should auto-create document when metadata listener is registered for non-existent document", async () => {
885
+ const schema = createTestSchema();
886
+
887
+ // Create a document reference but don't create the actual document
888
+ const documentId = "auto-created-doc" as DocumentId;
889
+ const testDocRef = createDocRef(app, documentId, schema);
890
+
891
+ let callbackCalled = false;
892
+
893
+ // Register metadata listener - document should be created but callback should NOT be called
894
+ // since autoCreateDocuments is enabled but no metadata is set initially
895
+ const unsubscribe = testDocRef.onMetadataChange((doc, metadata) => {
896
+ callbackCalled = true;
897
+ expect(doc.id).toBe(documentId);
898
+ expect(metadata).toBeDefined(); // If callback is called, metadata should exist
899
+ });
900
+
901
+ // Wait for async operations
902
+ await new Promise(resolve => setTimeout(resolve, 10));
903
+
904
+ // With the new base implementation, callback is not called unless metadata exists
905
+ expect(callbackCalled).toBe(false);
906
+
907
+ // But the document should still be created internally
908
+ const snapshot = await testDocRef.getDocSnapshot();
909
+ expect(snapshot).toBeDefined();
910
+
911
+ // Now actually create the document with metadata to trigger the callback
912
+ const stateModule = getStateModule(app);
913
+ const metadata: DocumentMetadata = {
914
+ name: "Auto-created document",
915
+ documentTypeName: "auto-generated",
916
+ ontologyRid: "auto-ontology",
917
+ security: {
918
+ discretionary: {
919
+ owners: [{
920
+ "type": "userId",
921
+ "userId": "system",
922
+ }],
923
+ },
924
+ mandatory: {},
925
+ },
926
+ };
927
+
928
+ // Create document properly which should trigger metadata callback
929
+ const docRef2 = await stateModule.createDocument(metadata, schema);
930
+
931
+ // Register listener on properly created document
932
+ let callbackCalled2 = false;
933
+ let metadataReceived2: DocumentMetadata | null = null;
934
+
935
+ const unsubscribe2 = docRef2.onMetadataChange((doc, meta) => {
936
+ callbackCalled2 = true;
937
+ metadataReceived2 = meta;
938
+ expect(doc.id).toBe(docRef2.id);
939
+ });
940
+
941
+ await new Promise(resolve => setTimeout(resolve, 10));
942
+
943
+ expect(callbackCalled2).toBe(true);
944
+ expect(metadataReceived2).toBeDefined();
945
+ expect(metadataReceived2!.name).toBe("Auto-created document");
946
+
947
+ unsubscribe();
948
+ unsubscribe2();
949
+ });
950
+
951
+ it("should not auto-create document when autoCreateDocuments is disabled", async () => {
952
+ // Create a new app with autoCreateDocuments disabled
953
+ const appWithoutAutoCreate = createTestAppNoAutocreate();
954
+
955
+ const schema = createTestSchema();
956
+
957
+ const documentId = "non-auto-created-doc" as DocumentId;
958
+ const testDocRef = createDocRef(appWithoutAutoCreate, documentId, schema);
959
+
960
+ let callbackCalled = false;
961
+
962
+ // Register metadata listener - this should NOT trigger auto-creation
963
+ const unsubscribe = testDocRef.onMetadataChange(() => {
964
+ callbackCalled = true;
965
+ });
966
+
967
+ // Wait for async operations
968
+ await new Promise(resolve => setTimeout(resolve, 10));
969
+
970
+ // Callback should not be called because document doesn't exist
971
+ expect(callbackCalled).toBe(false);
972
+
973
+ unsubscribe();
974
+ });
975
+
976
+ it("should receive Y.js updates via record subscription without collection subscription", async () => {
977
+ const schema = createSchemaWithRecords();
978
+ const documentId = "yjs-update-test-doc" as DocumentId;
979
+ const testDocRef = createDocRef(app, documentId, schema);
980
+ const stateModule = getStateModule(app);
981
+
982
+ const userId = "user-yjs-test" as RecordId;
983
+ const userRecord = stateModule.createRecordRef(testDocRef, userId, schema.User);
984
+
985
+ let changeCallbackCount = 0;
986
+ let lastReceivedData: User | null = null;
987
+
988
+ const unsubscribe = userRecord.onChange(data => {
989
+ changeCallbackCount++;
990
+ lastReceivedData = data;
991
+ });
992
+
993
+ await new Promise(resolve => setTimeout(resolve, 10));
994
+ expect(changeCallbackCount).toBe(0);
995
+
996
+ const userData: User = {
997
+ id: userId,
998
+ name: "Test User",
999
+ email: "test@example.com",
1000
+ age: 30,
1001
+ };
1002
+
1003
+ await userRecord.set(userData);
1004
+ await new Promise(resolve => setTimeout(resolve, 10));
1005
+ expect(changeCallbackCount).toBe(1);
1006
+ expect(lastReceivedData).toMatchObject(userData);
1007
+
1008
+ const updatedData: User = {
1009
+ ...userData,
1010
+ age: 31,
1011
+ };
1012
+
1013
+ await userRecord.set(updatedData);
1014
+ await new Promise(resolve => setTimeout(resolve, 10));
1015
+ expect(changeCallbackCount).toBe(2);
1016
+ expect(lastReceivedData).toMatchObject(updatedData);
1017
+ expect((lastReceivedData as User | null)?.age).toBe(31);
1018
+
1019
+ unsubscribe();
1020
+ });
1021
+ });
1022
+
1023
+ describe("Remote Y.js Updates", () => {
1024
+ let app: PackAppInternal;
1025
+
1026
+ beforeEach(() => {
1027
+ app = createTestApp();
1028
+ });
1029
+
1030
+ function simulateRemoteUpdate(
1031
+ app: PackAppInternal,
1032
+ documentId: DocumentId,
1033
+ updateFn: (yDoc: Y.Doc) => void,
1034
+ ) {
1035
+ const docService = app.getModule(DOCUMENT_SERVICE_MODULE_KEY);
1036
+ const yDoc = (docService as BaseYjsDocumentService).getYDocForTesting(documentId);
1037
+ if (!yDoc) {
1038
+ throw new Error("Y.Doc not found for document ID: " + documentId);
1039
+ }
1040
+ yDoc.transact(() => {
1041
+ updateFn(yDoc);
1042
+ }, "remote");
1043
+ }
1044
+
1045
+ it("should handle remote record property updates", async () => {
1046
+ const schema = createSchemaWithRecords();
1047
+ const documentId = "remote-update-doc" as DocumentId;
1048
+ const testDocRef = createDocRef(app, documentId, schema);
1049
+ const stateModule = getStateModule(app);
1050
+
1051
+ const userId = "user-1" as RecordId;
1052
+ const userRecord = stateModule.createRecordRef(testDocRef, userId, schema.User);
1053
+
1054
+ await userRecord.set({
1055
+ id: userId,
1056
+ name: "Alice",
1057
+ email: "alice@example.com",
1058
+ age: 25,
1059
+ });
1060
+
1061
+ let changeCount = 0;
1062
+ let lastAge: number | undefined;
1063
+
1064
+ const unsubscribe = userRecord.onChange(data => {
1065
+ changeCount++;
1066
+ lastAge = data.age;
1067
+ });
1068
+
1069
+ await new Promise(resolve => setTimeout(resolve, 10));
1070
+ expect(changeCount).toBe(1);
1071
+ expect(lastAge).toBe(25);
1072
+
1073
+ simulateRemoteUpdate(app, documentId, yDoc => {
1074
+ const collection = yDoc.getMap("User");
1075
+ const record = collection.get(userId) as Y.Map<unknown>;
1076
+ record.set("age", 26);
1077
+ });
1078
+
1079
+ await new Promise(resolve => setTimeout(resolve, 10));
1080
+ expect(changeCount).toBe(2);
1081
+ expect(lastAge).toBe(26);
1082
+
1083
+ unsubscribe();
1084
+ });
1085
+
1086
+ it("should handle remote record additions", async () => {
1087
+ const schema = createSchemaWithRecords();
1088
+ const documentId = "remote-add-doc" as DocumentId;
1089
+ const testDocRef = createDocRef(app, documentId, schema);
1090
+
1091
+ const usersCollection = testDocRef.getRecords(schema.User);
1092
+
1093
+ let addedCount = 0;
1094
+ const unsubscribe = usersCollection.onItemsAdded(records => {
1095
+ addedCount += records.length;
1096
+ });
1097
+
1098
+ const userId = "user-remote" as RecordId;
1099
+ simulateRemoteUpdate(app, documentId, yDoc => {
1100
+ const collection = yDoc.getMap("User");
1101
+ const record = new Y.Map();
1102
+ record.set("id", userId);
1103
+ record.set("name", "Bob");
1104
+ record.set("email", "bob@example.com");
1105
+ record.set("age", 30);
1106
+ collection.set(userId, record);
1107
+ });
1108
+
1109
+ await new Promise(resolve => setTimeout(resolve, 10));
1110
+ expect(addedCount).toBe(1);
1111
+
1112
+ unsubscribe();
1113
+ });
1114
+
1115
+ it("should handle remote record deletions", async () => {
1116
+ const schema = createSchemaWithRecords();
1117
+ const documentId = "remote-delete-doc" as DocumentId;
1118
+ const testDocRef = createDocRef(app, documentId, schema);
1119
+ const stateModule = getStateModule(app);
1120
+
1121
+ const userId = "user-to-delete" as RecordId;
1122
+ const userRecord = stateModule.createRecordRef(testDocRef, userId, schema.User);
1123
+
1124
+ await userRecord.set({
1125
+ id: userId,
1126
+ name: "Charlie",
1127
+ email: "charlie@example.com",
1128
+ age: 28,
1129
+ });
1130
+
1131
+ const usersCollection = testDocRef.getRecords(schema.User);
1132
+
1133
+ let deletedCount = 0;
1134
+ const unsubscribe = usersCollection.onItemsDeleted(records => {
1135
+ deletedCount += records.length;
1136
+ });
1137
+
1138
+ simulateRemoteUpdate(app, documentId, yDoc => {
1139
+ const collection = yDoc.getMap("User");
1140
+ collection.delete(userId);
1141
+ });
1142
+
1143
+ await new Promise(resolve => setTimeout(resolve, 10));
1144
+ expect(deletedCount).toBe(1);
1145
+
1146
+ unsubscribe();
1147
+ });
1148
+
1149
+ it("should handle multiple property updates in single transaction", async () => {
1150
+ const schema = createSchemaWithRecords();
1151
+ const documentId = "multi-prop-doc" as DocumentId;
1152
+ const testDocRef = createDocRef(app, documentId, schema);
1153
+ const stateModule = getStateModule(app);
1154
+
1155
+ const userId = "user-multi" as RecordId;
1156
+ const userRecord = stateModule.createRecordRef(testDocRef, userId, schema.User);
1157
+
1158
+ await userRecord.set({
1159
+ id: userId,
1160
+ name: "Diana",
1161
+ email: "diana@example.com",
1162
+ age: 32,
1163
+ });
1164
+
1165
+ let changeCount = 0;
1166
+
1167
+ const unsubscribe = userRecord.onChange(() => {
1168
+ changeCount++;
1169
+ });
1170
+
1171
+ await new Promise(resolve => setTimeout(resolve, 10));
1172
+ expect(changeCount).toBe(1);
1173
+
1174
+ simulateRemoteUpdate(app, documentId, yDoc => {
1175
+ const collection = yDoc.getMap("User");
1176
+ const record = collection.get(userId) as Y.Map<unknown>;
1177
+ record.set("name", "Diana Updated");
1178
+ record.set("age", 33);
1179
+ record.set("email", "diana.updated@example.com");
1180
+ });
1181
+
1182
+ await new Promise(resolve => setTimeout(resolve, 10));
1183
+ expect(changeCount).toBe(2);
1184
+
1185
+ unsubscribe();
1186
+ });
1187
+ });