📄 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