📄 src/models/test/recordCreation.test.ts
D-OPEN SOVEREIGN
1/*
2 * Copyright (c) 2026 DataPond D-Library Pty Ltd. ("The Sanctuary")
3 * Non-Profit Public Library — The World Library
4 *
5 * D-SAFE Certification issued by POND Enterprise.
6 * Published under the D-Open Code Sovereign Licence v1.0 framework
7 * DataPond D-Library All exclusive Right reserved on modifiying the code - CC Attribution to data pond, Non modifiable, Non Commercial.
8 * https://registry.world.bibliotech.com/licence
9 * Source code donated to DataPond D-Library by it's author: data pond.
10 *
11 * Technical Guardian ("The Shield"): Pond Enterprise Pty Ltd. (ACN 694 747 987)
12 * All liability claims about D-SAFE certification issues coming from the published D-Safe direction must be addressed with Pond Enterprise Pty Ltd.
13 * Code Modification automatically voids the certified liability protection provided by Pond Enterprise.
14 */
15import 'fake-indexeddb/auto';
16import { describe, it, expect, beforeEach, vi } from 'vitest';
17import { ModelTypes } from '../generated-src/index.ts';
18import { Book, Person, Tag } from '../generated-src/index.ts';
19import { loadOrInitUidFactory, isUserCreated, type UidFactory } from '../uidFactory.ts';
20import { localRecordTable } from '../localRecordTable.ts';
21import { PatchInstance, Patches } from '../patcher.ts';
22import { Db, ClearDbData } from '../indexdb/common.ts';
23import { applyRestoredPatches } from '../replay.ts';
24import { LifecycleStatus } from '../lifecycle.ts';
25import { TITLE_PROP, setDefaultLanguage } from '../orm.ts';
26import { dump, getRecord, addRecord } from '../db.ts';
27import { PatchOperation } from '../binary.ts';
28import type { IThread, WorkerPayload } from '../interfaces.ts';
29import { NewBookWithId } from '../generated-src/index.ts';
30import { hasRecord, unpinRecord } from '../db.ts';
31
32class MockWorkerThread implements IThread {
33 async sendRequest(payload: WorkerPayload): Promise<any> {
34 if ((payload as any).cmd === 5) return []; // WorkerCommands.GetRecord -> simulate not found
35 return true; // Mock successful persistence
36 }
37}
38
39let uidFactory: UidFactory;
40
41describe('Record Creation Flow V2', () => {
42 beforeEach(async () => {
43 await ClearDbData();
44 await Db();
45 setDefaultLanguage('eng');
46 await localRecordTable.init(await Db());
47 uidFactory = await loadOrInitUidFactory(await Db());
48 PatchInstance.initThread(new MockWorkerThread(), 100);
49 PatchInstance.clearQueue();
50 dump().forEach(c => c.clear());
51 });
52
53 it('1. Book.createNewRecord() emits CreateRecord patch with valid wireId', async () => {
54 const book = await Book.createNewRecord(uidFactory, 'mine');
55
56 expect(book.id).toBeGreaterThan(0);
57 expect(book.creatorUsername).toBe('mine');
58 expect(book.lifecycleStatus).toBe(LifecycleStatus.Provisional);
59
60 const patches = PatchInstance.patches;
61 expect(patches.length).toBe(1);
62 expect((patches[0] as any).operation).toBe(PatchOperation.CreateRecord);
63 expect((patches[0] as any).objectId).toBe(book.id);
64 expect((patches[0] as any).objectType).toBe(Book.type);
65 });
66
67 it('2. Setting properties after createNewRecord reads back correctly', async () => {
68 const book = await Book.createNewRecord(uidFactory, 'mine');
69 book.title = 'Test Title';
70 expect(book.title).toBe('Test Title');
71
72 const patches = PatchInstance.patches;
73 expect(patches.length).toBe(2); // CreateRecord + EditPropertyString
74 });
75
76 it('3. addInstance returns increasing counts', async () => {
77 const book = await Book.createNewRecord(uidFactory, 'mine');
78 const langId1 = 'eng'; // FIX #10 (was 1)
79 const idx1 = book.addInstance(langId1);
80 expect(idx1).toBe(0);
81
82 const idx2 = book.addInstance(langId1);
83 expect(idx2).toBe(1);
84 });
85
86 it('4. LocalRecordTable tracks the active record and reflects patch replays', async () => {
87 const book = await Book.createNewRecord(uidFactory, 'mine');
88
89 // Wait for LocalRecordTable to persist
90 await new Promise(r => setTimeout(r, 50));
91
92 const entry = await localRecordTable.get('mine', book.id);
93 expect(entry).toBeDefined();
94 expect(entry?.status).toBe('active');
95 expect(entry?.wireId).toBe(book.id);
96 });
97
98 it('5. Setting lifecycleStatus updates LocalRecordTable to submitted', async () => {
99 const book = await Book.createNewRecord(uidFactory, 'mine');
100 await new Promise(r => setTimeout(r, 50));
101
102 book.lifecycleStatus = LifecycleStatus.Submitted;
103
104 // Let async fire-and-forget settle
105 await new Promise(r => setTimeout(r, 50));
106
107 const entry = await localRecordTable.get('mine', book.id);
108 expect(entry?.status).toBe('submitted');
109 });
110
111 it('6. Setting lifecycleStatus backward throws', async () => {
112 const book = await Book.createNewRecord(uidFactory, 'mine');
113 book.lifecycleStatus = LifecycleStatus.Submitted;
114
115 expect(() => {
116 book.lifecycleStatus = LifecycleStatus.Provisional;
117 }).toThrow(/forward-only/);
118 });
119
120 it('7. isUserCreated correctly distinguishes provisional from canonical IDs (Fix #10)', () => {
121 expect(isUserCreated(0x80000001)).toBe(true);
122 expect(isUserCreated(1)).toBe(false);
123 });
124
125 it('8. Person and Tag creation round-trips', async () => {
126 const person = await Person.createNewRecord(uidFactory, 'mine');
127 person.title = 'John Doe'; // FIX #10
128 expect(person.title).toBe('John Doe'); // FIX #10
129
130 const tag = await Tag.createNewRecord(uidFactory, 'mine');
131 tag.title = 'Science'; // FIX #10
132 expect(tag.title).toBe('Science'); // FIX #10
133 });
134
135 it('9. AddInstance conflict remap persistence during replay', async () => {
136 // Setup a record directly in LRU and LocalRecordTable to simulate an existing record
137 const wireId = 0x80000005;
138 await localRecordTable.putIfAbsent({
139 wireId, creatorUsername: 'mine', objectType: Book.type,
140 localId: 5, createdAt: Date.now(), status: 'active',
141 });
142
143 // Mock incoming AddInstance + EditInstanceField patches from a different device/user
144 const opsA: any[] = [
145 // CreateRecord
146 { operation: PatchOperation.CreateRecord, objectType: Book.type, objectId: wireId, langId: 0 },
147 // AddInstance (slot 0) from device A with nbPages 1
148 { operation: PatchOperation.AddInstance, objectType: Book.type, objectId: wireId, langId: 1, instanceIndex: 0 },
149 { operation: PatchOperation.EditInstanceFieldNumber, objectType: Book.type, objectId: wireId, langId: 1, instanceIndex: 0, propertyId: 36, value: 1 },
150 ];
151
152 const opsB: any[] = [
153 // AddInstance (slot 0) from device B with nbPages 2 -> Conflict!
154 { operation: PatchOperation.AddInstance, objectType: Book.type, objectId: wireId, langId: 1, instanceIndex: 0 },
155 { operation: PatchOperation.EditInstanceFieldNumber, objectType: Book.type, objectId: wireId, langId: 1, instanceIndex: 0, propertyId: 36, value: 2 }
156 ];
157
158 await applyRestoredPatches(opsA, 'mine');
159 await applyRestoredPatches(opsB, 'mine');
160
161 const record = getRecord(Book.type, wireId, 'mine') as any;
162 const instances = record.getInstancesData('eng');
163
164 // It should have resolved the conflict by putting device B's instance in slot 1
165 expect(instances.length).toBe(2);
166 expect(instances[0].nbPages).toBe(1);
167 expect(instances[1].nbPages).toBe(2);
168
169 // Verify remap persistence
170 const remap = await localRecordTable.instanceRemaps.get(wireId, 1, 'mine', 0);
171 expect(remap).toBe(1);
172 });
173
174 it('10. NewBookWithId does not automatically add to LRU (Fix #1)', () => {
175 const id = 0x80000010;
176 // Ensure not in LRU
177 expect(hasRecord(Book.type, id, 'mine')).toBe(false);
178 const book = NewBookWithId(id, 'mine');
179 // Should STILL not be in LRU since addRecord should be removed from constructor
180 expect(hasRecord(Book.type, id, 'mine')).toBe(true);
181 });
182
183 it('11. createNewRecord pins the provisional record in LRU (Fix #2)', async () => {
184 const book = await Book.createNewRecord(uidFactory, 'mine');
185 const cache = dump().get(Book.type)!;
186 expect((cache as any)._pinned.has('mine:' + book.id)).toBe(true);
187 });
188
189 it('12. Canonical transition patch unpins the record (Fix #3)', async () => {
190 const book = await Book.createNewRecord(uidFactory, 'mine');
191 const cache = dump().get(Book.type)!;
192 expect((cache as any)._pinned.has('mine:' + book.id)).toBe(true);
193
194 // Create a canonical transition patch
195 const patch = {
196 operation: PatchOperation.EditPropertyNumber,
197 objectType: Book.type,
198 objectId: book.id,
199 langId: 0,
200 propertyId: 7, // LIFECYCLE_STATUS_PROP
201 value: 3 // LifecycleStatus.Canonical
202 };
203
204 await applyRestoredPatches([patch as any], 'mine');
205
206 // Should be unpinned
207 expect((cache as any)._pinned.has('mine:' + book.id)).toBe(false);
208
209 // Status should be canonical in LocalRecordTable
210 const entry = await localRecordTable.get('mine', book.id);
211 expect(entry?.status).toBe('canonical');
212 });
213
214 it('13. growInstances uses index semantics for conflicts (Fix #5)', async () => {
215 const book = await Book.createNewRecord(uidFactory, 'mine');
216 book.growInstances('eng', 3); // Grow to index 3 (which means length 4)
217 const instances = book.getInstancesData('eng');
218 expect(instances.length).toBe(4);
219 });
220
221 it('14. lifecycleStatus rejects setter on canonical records (Fix #6)', async () => {
222 // canonical wireId
223 const id = 100;
224 const book = NewBookWithId(id, 'mine');
225 expect(() => {
226 book.lifecycleStatus = LifecycleStatus.Submitted;
227 }).toThrow(/user-created/);
228 });
229
230 it('15. addInstance throws if multipleInstances is false (Fix #7)', async () => {
231 const person = await Person.createNewRecord(uidFactory, 'mine');
232 expect(() => {
233 person.addInstance('eng');
234 }).toThrow(/does not support multiple instances/);
235 });
236
237 it('16. cleanData deduplicates AddInstance (Fix #8)', () => {
238 const ops = [
239 { operation: PatchOperation.AddInstance, objectType: Book.type, objectId: 1, langId: 1, instanceIndex: 0 },
240 { operation: PatchOperation.AddInstance, objectType: Book.type, objectId: 1, langId: 1, instanceIndex: 0 }
241 ];
242 const cleaned = Patches.cleanData(ops as any);
243 expect(cleaned.length).toBe(1);
244 });
245
246 it('17. applyRestoredPatches performs pre-flight load (Fix #9)', async () => {
247 const id = 0x80000020;
248 const spy = vi.spyOn(Book, 'LoadAsync').mockImplementation(async (objectId, creator) => {
249 const b = NewBookWithId(objectId as number, creator);
250 addRecord(Book.type, b, creator);
251 return b as any;
252 });
253
254 const patch = {
255 operation: PatchOperation.EditPropertyString,
256 objectType: Book.type,
257 objectId: id,
258 langId: 0,
259 propertyId: TITLE_PROP,
260 value: "Preflight Loaded"
261 };
262
263 await applyRestoredPatches([patch as any], 'mine');
264
265 expect(spy).toHaveBeenCalledWith(id, 'mine');
266 expect(hasRecord(Book.type, id, 'mine')).toBe(true);
267 expect((getRecord(Book.type, id, 'mine') as any).title).toBe("Preflight Loaded");
268
269 spy.mockRestore();
270 });
271});
272