📄 src/models/test/relations.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 { describe, it, expect, beforeEach } from 'vitest';
16import {
17 writeSidecar, parseSidecar, colsFromSidecar, extractRecordCols,
18 ColumnType, type SidecarInput,
19} from '../enriched-index.ts';
20import { SidecarType } from '../enums.ts';
21import { Tag, NewTagWithId, type TagExtendedCols } from '../generated-src/Tag.ts';
22import { Book, NewBookWithId, type BookExtendedCols } from '../generated-src/Book.ts';
23import { clear, configureDb } from '../db.ts';
24import { setDefaultLanguage } from '../orm.ts';
25import { PatchInstance } from '../patcher.ts';
26
27beforeEach(() => {
28 clear();
29 configureDb({ cacheLimit: 10_000 });
30 setDefaultLanguage('eng');
31 PatchInstance.clearQueue();
32});
33
34// A zero-filled BookExtendedCols of the given UIDs, so mergeExtended (which
35// reads every scalar column) never dereferences undefined.
36const u8 = (n: number) => new Uint8Array(n);
37const emptyBookExtended = (ids: number[]): BookExtendedCols => ({
38 ids: new Uint16Array(ids),
39 descriptions: ids.map(() => null),
40 prerequisites: ids.map(() => null),
41 deweyDecimals: ids.map(() => null),
42 locCallNumbers: ids.map(() => null),
43 copyrightStatus: u8(ids.length), textQuality: u8(ids.length), illustrations: u8(ids.length),
44 educationQuality: u8(ids.length), practicality: u8(ids.length), accessibilityLevel: u8(ids.length),
45 teacherMaterials: u8(ids.length), pedagogicalScaffolding: u8(ids.length), clarityOfExplanation: u8(ids.length),
46 workedExamples: u8(ids.length), exercisesAndAssessment: u8(ids.length), selfStudyReadiness: u8(ids.length),
47 referenceApparatus: u8(ids.length), safetyGuidance: u8(ids.length), contentCurrency: u8(ids.length),
48 ecoRegeneration: u8(ids.length), communityLiving: u8(ids.length), promotesPeace: u8(ids.length),
49 businessDevelopment: u8(ids.length),
50});
51
52describe('Generalized relation serialization (option A)', () => {
53 it('round-trips multiple named has-relation tails, preserving per-record lists & order', () => {
54 const input: SidecarInput = {
55 sidecarType: SidecarType.Extended,
56 recordCount: 3,
57 columns: [
58 { name: 'ids', type: ColumnType.Uint16, data: new Uint16Array([100, 101, 102]) },
59 { name: 'descriptions', type: ColumnType.NullableString, data: ['a', null, 'c'] },
60 ],
61 relations: [
62 { name: 'tags', data: [[2, 16], [], [16]] },
63 { name: 'books', data: [[500], [501, 502], []] },
64 ],
65 };
66 const parsed = parseSidecar(writeSidecar(input));
67
68 expect(parsed.relations!.get('tags')).toEqual([[2, 16], [], [16]]);
69 expect(parsed.relations!.get('books')).toEqual([[500], [501, 502], []]);
70 // No `authors` relation present → the back-compat alias stays undefined.
71 expect(parsed.authorIds).toBeUndefined();
72 });
73
74 it('is self-describing: the parser needs no schema knowledge of the relation names', () => {
75 // A relation name the parser has never heard of still round-trips.
76 const parsed = parseSidecar(writeSidecar({
77 sidecarType: SidecarType.Extended,
78 recordCount: 1,
79 columns: [{ name: 'ids', type: ColumnType.Uint16, data: new Uint16Array([0]) }],
80 relations: [{ name: 'someFutureRelation', data: [[42, 43]] }],
81 }));
82 expect(parsed.relations!.get('someFutureRelation')).toEqual([[42, 43]]);
83 });
84
85 it('keeps authorIds sugar working alongside named relations', () => {
86 const parsed = parseSidecar(writeSidecar({
87 sidecarType: SidecarType.Extended,
88 recordCount: 2,
89 columns: [{ name: 'ids', type: ColumnType.Uint16, data: new Uint16Array([0, 1]) }],
90 authorIds: [[7], [8, 9]],
91 relations: [{ name: 'tags', data: [[1], [2]] }],
92 }));
93 expect(parsed.authorIds).toEqual([[7], [8, 9]]);
94 expect(parsed.relations!.get('authors')).toEqual([[7], [8, 9]]);
95 expect(parsed.relations!.get('tags')).toEqual([[1], [2]]);
96 });
97
98 it('exposes relations by name through colsFromSidecar and extractRecordCols', () => {
99 const parsed = parseSidecar(writeSidecar({
100 sidecarType: SidecarType.Extended,
101 recordCount: 3,
102 columns: [{ name: 'ids', type: ColumnType.Uint16, data: new Uint16Array([100, 101, 102]) }],
103 relations: [
104 { name: 'tags', data: [[2, 16], [], [16]] },
105 { name: 'books', data: [[500], [501, 502], []] },
106 ],
107 }));
108
109 const cols = colsFromSidecar(parsed);
110 expect(cols['tags']).toEqual([[2, 16], [], [16]]);
111 expect(cols['books']).toEqual([[500], [501, 502], []]);
112
113 const single = extractRecordCols(parsed, 1);
114 expect(single['tags']).toEqual([[]]);
115 expect(single['books']).toEqual([[501, 502]]);
116 });
117
118 it('an Extended sidecar with no relations leaves relations/authorIds undefined', () => {
119 const parsed = parseSidecar(writeSidecar({
120 sidecarType: SidecarType.Extended,
121 recordCount: 1,
122 columns: [{ name: 'ids', type: ColumnType.Uint16, data: new Uint16Array([0]) }],
123 }));
124 expect(parsed.relations).toBeUndefined();
125 expect(parsed.authorIds).toBeUndefined();
126 });
127});
128
129describe('Relation tails hydrate the ORM graph', () => {
130 it('Tag.mergeExtended populates the tags (10) and books (11) has-sets', () => {
131 const tag = NewTagWithId(16);
132 const cols: TagExtendedCols = {
133 ids: new Uint16Array([16]),
134 descriptions: ['Science'],
135 tags: [[2]], // Tag 16 has sub-tag 2
136 books: [[500, 501]], // Tag 16 has books 500, 501
137 ddcNotations: [null],
138 };
139 tag.mergeExtended(cols, 0, 'eng');
140
141 expect(tag.tagsIds).toEqual([2]);
142 expect([...tag.booksIds].sort((a, b) => a - b)).toEqual([500, 501]);
143 });
144
145 it('full pipeline: build → parse → colsFromSidecar → mergeExtended for a Tag', () => {
146 const parsed = parseSidecar(writeSidecar({
147 sidecarType: SidecarType.Extended,
148 recordCount: 1,
149 columns: [
150 { name: 'ids', type: ColumnType.Uint16, data: new Uint16Array([16]) },
151 { name: 'descriptions', type: ColumnType.NullableString, data: ['Science'] },
152 ],
153 relations: [
154 { name: 'tags', data: [[2]] },
155 { name: 'books', data: [[500, 501]] },
156 ],
157 }));
158 const cols = colsFromSidecar(parsed) as TagExtendedCols;
159
160 const tag = NewTagWithId(16);
161 tag.mergeExtended(cols, 0, 'eng');
162
163 expect(tag.tagsIds).toEqual([2]);
164 expect([...tag.booksIds].sort((a, b) => a - b)).toEqual([500, 501]);
165 expect(tag.description).toBe('Science');
166 });
167
168 it('regression: Book.authors still hydrates from the authorIds tail', () => {
169 const book = NewBookWithId(10);
170 book.mergeExtended({ ...emptyBookExtended([10]), authorIds: [[5, 7]] }, 0, 'eng');
171 expect([...book.authorsIds].sort((a, b) => a - b)).toEqual([5, 7]);
172 });
173});
174