@eightyfourthousand/data-access 2026.3.0 → 2026.4.0

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.
@@ -0,0 +1,471 @@
1
+ import { DataClient, Passage, PassageDTO, passageFromDTO } from '../types';
2
+
3
+ type ApiPaginationDirection = 'FORWARD' | 'BACKWARD' | 'AROUND';
4
+
5
+ type GlossaryTermNode = {
6
+ uuid: string;
7
+ authority: string;
8
+ definition: string | null;
9
+ termNumber: number;
10
+ names: {
11
+ english: string | null;
12
+ tibetan: string | null;
13
+ sanskrit: string | null;
14
+ pali: string | null;
15
+ chinese: string | null;
16
+ wylie: string | null;
17
+ alternatives: string | null;
18
+ };
19
+ };
20
+
21
+ type GlossaryTermIndexRow = {
22
+ glossary_uuid: string;
23
+ authority_uuid: string;
24
+ term_number: number | string;
25
+ definition: string | null;
26
+ english: string | null;
27
+ wylie: string | null;
28
+ tibetan: string | null;
29
+ sanskrit_plain: string | null;
30
+ sanskrit_attested: string | null;
31
+ chinese: string | null;
32
+ pali: string | null;
33
+ alternatives: string | null;
34
+ };
35
+
36
+ type GlossaryPageInfo = {
37
+ nextCursor: string | null;
38
+ prevCursor: string | null;
39
+ hasMoreAfter: boolean;
40
+ hasMoreBefore: boolean;
41
+ };
42
+
43
+ export type GlossaryTermConnection = {
44
+ nodes: GlossaryTermNode[];
45
+ pageInfo: GlossaryPageInfo;
46
+ totalCount: number;
47
+ };
48
+
49
+ export type GlossaryPassagesPage = {
50
+ items: Passage[];
51
+ nextCursor: string | null;
52
+ hasMore: boolean;
53
+ };
54
+
55
+ const DEFAULT_GLOSSARY_LIMIT = 50;
56
+ const MAX_GLOSSARY_LIMIT = 200;
57
+ const DEFAULT_GLOSSARY_PASSAGES_LIMIT = 10;
58
+
59
+ function buildGlossaryTermConnection(
60
+ nodes: GlossaryTermNode[],
61
+ nextCursor: string | null,
62
+ prevCursor: string | null,
63
+ hasMoreAfter: boolean,
64
+ hasMoreBefore: boolean,
65
+ totalCount: number,
66
+ ): GlossaryTermConnection {
67
+ return {
68
+ nodes,
69
+ pageInfo: {
70
+ nextCursor,
71
+ prevCursor,
72
+ hasMoreAfter,
73
+ hasMoreBefore,
74
+ },
75
+ totalCount,
76
+ };
77
+ }
78
+
79
+ function parseOffsetCursor(after?: string) {
80
+ if (!after) return 0;
81
+
82
+ const parsed = Number.parseInt(after, 10);
83
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0;
84
+ }
85
+
86
+ function parseCount(value: number | string | null | undefined) {
87
+ if (typeof value === 'number') return value;
88
+
89
+ const parsed = Number.parseInt(value ?? '', 10);
90
+ return Number.isFinite(parsed) ? parsed : 0;
91
+ }
92
+
93
+ function rowToGlossaryTermNode(
94
+ row: Pick<
95
+ GlossaryTermIndexRow,
96
+ | 'glossary_uuid'
97
+ | 'authority_uuid'
98
+ | 'definition'
99
+ | 'term_number'
100
+ | 'english'
101
+ | 'wylie'
102
+ | 'tibetan'
103
+ | 'sanskrit_plain'
104
+ | 'sanskrit_attested'
105
+ | 'chinese'
106
+ | 'pali'
107
+ | 'alternatives'
108
+ > & { withAttestations: boolean },
109
+ ): GlossaryTermNode {
110
+ return {
111
+ uuid: row.glossary_uuid,
112
+ authority: row.authority_uuid,
113
+ definition: row.definition,
114
+ termNumber: parseCount(row.term_number),
115
+ names: {
116
+ english: row.english,
117
+ alternatives: row.alternatives,
118
+ wylie: row.wylie,
119
+ tibetan: row.tibetan,
120
+ sanskrit: row.withAttestations
121
+ ? row.sanskrit_attested
122
+ : row.sanskrit_plain,
123
+ chinese: row.chinese,
124
+ pali: row.pali,
125
+ },
126
+ };
127
+ }
128
+
129
+ export const getGlossaryTermPassagesPage = async ({
130
+ client,
131
+ uuid,
132
+ first,
133
+ after,
134
+ }: {
135
+ client: DataClient;
136
+ uuid: string;
137
+ first?: number;
138
+ after?: string;
139
+ }): Promise<GlossaryPassagesPage> => {
140
+ const limit = Math.max(first ?? DEFAULT_GLOSSARY_PASSAGES_LIMIT, 1);
141
+ const offset = parseOffsetCursor(after);
142
+
143
+ const { data: annotations, error: annotationsError } = await client
144
+ .from('passage_annotations')
145
+ .select('passage_uuid')
146
+ .eq('type', 'glossary-instance')
147
+ .filter('content', 'cs', JSON.stringify([{ uuid }]));
148
+
149
+ if (annotationsError) {
150
+ console.error(
151
+ 'Error fetching glossary passage annotations:',
152
+ annotationsError,
153
+ );
154
+ return { items: [], nextCursor: null, hasMore: false };
155
+ }
156
+
157
+ const passageUuids = (annotations ?? []).map(
158
+ (annotation: { passage_uuid: string }) => annotation.passage_uuid,
159
+ );
160
+
161
+ if (passageUuids.length === 0) {
162
+ return { items: [], nextCursor: null, hasMore: false };
163
+ }
164
+
165
+ const { data: passages, error: passagesError } = await client
166
+ .from('passages')
167
+ .select('uuid, content, label, sort, type, xmlId, work_uuid, toh')
168
+ .in('uuid', passageUuids)
169
+ .order('sort', { ascending: true })
170
+ .range(offset, offset + limit);
171
+
172
+ if (passagesError) {
173
+ console.error('Error fetching glossary passages:', passagesError);
174
+ return { items: [], nextCursor: null, hasMore: false };
175
+ }
176
+
177
+ const rows = passages ?? [];
178
+ const hasMore = rows.length > limit;
179
+ const items = hasMore ? rows.slice(0, limit) : rows;
180
+
181
+ return {
182
+ items: items.map((passage: PassageDTO) => passageFromDTO(passage)),
183
+ nextCursor: hasMore ? String(offset + limit) : null,
184
+ hasMore,
185
+ };
186
+ };
187
+
188
+ export const getWorkGlossaryTermsPage = async ({
189
+ client,
190
+ workUuid,
191
+ limit = DEFAULT_GLOSSARY_LIMIT,
192
+ cursor,
193
+ direction = 'FORWARD',
194
+ withAttestations = false,
195
+ }: {
196
+ client: DataClient;
197
+ workUuid: string;
198
+ limit?: number;
199
+ cursor?: string | null;
200
+ direction?: ApiPaginationDirection;
201
+ withAttestations?: boolean;
202
+ }): Promise<GlossaryTermConnection> => {
203
+ const clampedLimit = Math.min(Math.max(limit, 1), MAX_GLOSSARY_LIMIT);
204
+
205
+ if (direction === 'AROUND') {
206
+ return getWorkGlossaryTermsAround({
207
+ client,
208
+ workUuid,
209
+ limit: clampedLimit,
210
+ cursor,
211
+ withAttestations,
212
+ });
213
+ }
214
+
215
+ const [
216
+ { count, error: countError },
217
+ { data: cursorRows, error: cursorError },
218
+ ] = await Promise.all([
219
+ client
220
+ .from('glossary_term_index')
221
+ .select('authority_uuid', { count: 'exact', head: true })
222
+ .eq('work_uuid', workUuid),
223
+ cursor
224
+ ? client
225
+ .from('glossary_term_index')
226
+ .select('authority_uuid, term_number')
227
+ .eq('work_uuid', workUuid)
228
+ .eq('authority_uuid', cursor)
229
+ .limit(1)
230
+ : Promise.resolve({ data: [], error: null }),
231
+ ]);
232
+
233
+ if (countError) {
234
+ console.error('Error counting glossary terms:', countError);
235
+ return buildGlossaryTermConnection([], null, null, false, false, 0);
236
+ }
237
+
238
+ if (cursorError) {
239
+ console.error('Error fetching glossary cursor term:', cursorError);
240
+ return buildGlossaryTermConnection([], null, null, false, false, 0);
241
+ }
242
+
243
+ const totalCount = count ?? 0;
244
+ if (totalCount === 0) {
245
+ return buildGlossaryTermConnection([], null, null, false, false, 0);
246
+ }
247
+
248
+ const cursorRow = cursor
249
+ ? ((cursorRows ?? [])[0] as
250
+ | { authority_uuid: string; term_number: number | string }
251
+ | undefined)
252
+ : undefined;
253
+
254
+ if (cursor && !cursorRow) {
255
+ return buildGlossaryTermConnection(
256
+ [],
257
+ null,
258
+ null,
259
+ false,
260
+ false,
261
+ totalCount,
262
+ );
263
+ }
264
+
265
+ const cursorTermNumber =
266
+ cursorRow !== undefined ? parseCount(cursorRow.term_number) : null;
267
+ let query = client
268
+ .from('glossary_term_index')
269
+ .select(
270
+ `glossary_uuid,
271
+ authority_uuid,
272
+ term_number,
273
+ definition,
274
+ english,
275
+ wylie,
276
+ tibetan,
277
+ sanskrit_plain,
278
+ sanskrit_attested,
279
+ chinese,
280
+ pali,
281
+ alternatives`,
282
+ )
283
+ .eq('work_uuid', workUuid);
284
+
285
+ if (cursorTermNumber !== null) {
286
+ query =
287
+ direction === 'FORWARD'
288
+ ? query.gt('term_number', cursorTermNumber)
289
+ : query.lt('term_number', cursorTermNumber);
290
+ }
291
+
292
+ const ascending = direction === 'FORWARD';
293
+ const { data, error } = await query
294
+ .order('term_number', { ascending })
295
+ .limit(clampedLimit);
296
+
297
+ if (error) {
298
+ console.error('Error fetching paginated glossary terms:', error);
299
+ return buildGlossaryTermConnection(
300
+ [],
301
+ null,
302
+ null,
303
+ false,
304
+ false,
305
+ totalCount,
306
+ );
307
+ }
308
+
309
+ const pageRows = (data ?? []) as GlossaryTermIndexRow[];
310
+ const rows = ascending ? pageRows : [...pageRows].reverse();
311
+ if (rows.length === 0) {
312
+ return buildGlossaryTermConnection(
313
+ [],
314
+ null,
315
+ null,
316
+ false,
317
+ false,
318
+ totalCount,
319
+ );
320
+ }
321
+
322
+ const nodes = rows.map((row) =>
323
+ rowToGlossaryTermNode({ ...row, withAttestations }),
324
+ );
325
+ const firstRow = rows[0];
326
+ const lastRow = rows[rows.length - 1];
327
+ const hasMoreBefore = parseCount(firstRow.term_number) > 1;
328
+ const hasMoreAfter = parseCount(lastRow.term_number) < totalCount;
329
+
330
+ return buildGlossaryTermConnection(
331
+ nodes,
332
+ hasMoreAfter ? lastRow.authority_uuid : null,
333
+ hasMoreBefore ? firstRow.authority_uuid : null,
334
+ hasMoreAfter,
335
+ hasMoreBefore,
336
+ totalCount,
337
+ );
338
+ };
339
+
340
+ export const getWorkGlossaryTermsAround = async ({
341
+ client,
342
+ workUuid,
343
+ limit,
344
+ cursor,
345
+ withAttestations,
346
+ }: {
347
+ client: DataClient;
348
+ workUuid: string;
349
+ limit: number;
350
+ cursor?: string | null;
351
+ withAttestations: boolean;
352
+ }): Promise<GlossaryTermConnection> => {
353
+ if (!cursor) {
354
+ return getWorkGlossaryTermsPage({
355
+ client,
356
+ workUuid,
357
+ limit,
358
+ cursor: null,
359
+ direction: 'FORWARD',
360
+ withAttestations,
361
+ });
362
+ }
363
+
364
+ const [
365
+ { count, error: countError },
366
+ { data: cursorRows, error: cursorError },
367
+ ] = await Promise.all([
368
+ client
369
+ .from('glossary_term_index')
370
+ .select('authority_uuid', { count: 'exact', head: true })
371
+ .eq('work_uuid', workUuid),
372
+ client
373
+ .from('glossary_term_index')
374
+ .select('authority_uuid, term_number')
375
+ .eq('work_uuid', workUuid)
376
+ .eq('authority_uuid', cursor)
377
+ .limit(1),
378
+ ]);
379
+
380
+ if (countError) {
381
+ console.error('Error counting glossary terms:', countError);
382
+ return buildGlossaryTermConnection([], null, null, false, false, 0);
383
+ }
384
+
385
+ if (cursorError) {
386
+ console.error('Error fetching glossary cursor term:', cursorError);
387
+ return getWorkGlossaryTermsPage({
388
+ client,
389
+ workUuid,
390
+ limit,
391
+ cursor: null,
392
+ direction: 'FORWARD',
393
+ withAttestations,
394
+ });
395
+ }
396
+
397
+ const totalCount = count ?? 0;
398
+ const cursorRow = (cursorRows ?? [])[0] as
399
+ | { authority_uuid: string; term_number: number | string }
400
+ | undefined;
401
+
402
+ if (!cursorRow) {
403
+ return getWorkGlossaryTermsPage({
404
+ client,
405
+ workUuid,
406
+ limit,
407
+ cursor: null,
408
+ direction: 'FORWARD',
409
+ withAttestations,
410
+ });
411
+ }
412
+
413
+ const cursorTermNumber = parseCount(cursorRow.term_number);
414
+ let startTerm = Math.max(1, cursorTermNumber - Math.floor(limit / 2));
415
+ let endTerm = startTerm + limit - 1;
416
+
417
+ if (endTerm > totalCount) {
418
+ endTerm = totalCount;
419
+ startTerm = Math.max(1, endTerm - limit + 1);
420
+ }
421
+
422
+ const { data, error } = await client
423
+ .from('glossary_term_index')
424
+ .select(
425
+ `glossary_uuid,
426
+ authority_uuid,
427
+ term_number,
428
+ definition,
429
+ english,
430
+ wylie,
431
+ tibetan,
432
+ sanskrit_plain,
433
+ sanskrit_attested,
434
+ chinese,
435
+ pali`,
436
+ )
437
+ .eq('work_uuid', workUuid)
438
+ .gte('term_number', startTerm)
439
+ .lte('term_number', endTerm)
440
+ .order('term_number', { ascending: true });
441
+
442
+ if (error) {
443
+ console.error('Error fetching glossary terms around cursor:', error);
444
+ return buildGlossaryTermConnection(
445
+ [],
446
+ null,
447
+ null,
448
+ false,
449
+ false,
450
+ totalCount,
451
+ );
452
+ }
453
+
454
+ const rows = (data ?? []) as GlossaryTermIndexRow[];
455
+ const nodes = rows.map((row) =>
456
+ rowToGlossaryTermNode({ ...row, withAttestations }),
457
+ );
458
+ const hasMoreBefore = startTerm > 1;
459
+ const hasMoreAfter = endTerm < totalCount;
460
+ const firstRow = rows[0];
461
+ const lastRow = rows[rows.length - 1];
462
+
463
+ return buildGlossaryTermConnection(
464
+ nodes,
465
+ hasMoreAfter && lastRow ? lastRow.authority_uuid : null,
466
+ hasMoreBefore && firstRow ? firstRow.authority_uuid : null,
467
+ hasMoreAfter,
468
+ hasMoreBefore,
469
+ totalCount,
470
+ );
471
+ };
@@ -0,0 +1,229 @@
1
+ import {
2
+ AlignmentDTO,
3
+ AnnotationDTO,
4
+ DataClient,
5
+ Passage,
6
+ PassageDTO,
7
+ Passages,
8
+ passageFromDTO,
9
+ } from '../types';
10
+
11
+ export const getAnnotationsByPassageUuids = async ({
12
+ client,
13
+ passageUuids,
14
+ }: {
15
+ client: DataClient;
16
+ passageUuids: readonly string[];
17
+ }): Promise<Map<string, AnnotationDTO[]>> => {
18
+ const annotationsByPassage = new Map<string, AnnotationDTO[]>();
19
+
20
+ if (passageUuids.length === 0) {
21
+ return annotationsByPassage;
22
+ }
23
+
24
+ const pageSize = 1000;
25
+ let allData: AnnotationDTO[] = [];
26
+ let offset = 0;
27
+ let hasMore = true;
28
+
29
+ while (hasMore) {
30
+ const { data, error } = await client
31
+ .from('passage_annotations')
32
+ .select('uuid, passage_uuid, type, start, end, content, toh')
33
+ .in('passage_uuid', passageUuids as string[])
34
+ .not('type', 'like', 'deprecated%')
35
+ .range(offset, offset + pageSize - 1);
36
+
37
+ if (error) {
38
+ console.error('Error batch loading annotations:', error);
39
+ return new Map();
40
+ }
41
+
42
+ allData = allData.concat((data ?? []) as AnnotationDTO[]);
43
+ hasMore = (data?.length ?? 0) === pageSize;
44
+ offset += pageSize;
45
+ }
46
+
47
+ for (const annotation of allData) {
48
+ const passageUuid = annotation.passage_uuid;
49
+ if (!passageUuid) continue;
50
+ const existing = annotationsByPassage.get(passageUuid);
51
+ if (existing) {
52
+ existing.push(annotation);
53
+ } else {
54
+ annotationsByPassage.set(passageUuid, [annotation]);
55
+ }
56
+ }
57
+
58
+ for (const annotations of annotationsByPassage.values()) {
59
+ annotations.sort((a, b) => {
60
+ if (a.start !== b.start) return a.start - b.start;
61
+ return b.end - a.end;
62
+ });
63
+ }
64
+
65
+ return annotationsByPassage;
66
+ };
67
+
68
+ export const getAlignmentsByPassageUuids = async ({
69
+ client,
70
+ passageUuids,
71
+ }: {
72
+ client: DataClient;
73
+ passageUuids: readonly string[];
74
+ }): Promise<Map<string, AlignmentDTO[]>> => {
75
+ const alignmentsByPassage = new Map<string, AlignmentDTO[]>();
76
+
77
+ if (passageUuids.length === 0) {
78
+ return alignmentsByPassage;
79
+ }
80
+
81
+ const pageSize = 1000;
82
+ let allData: (AlignmentDTO & { passage_uuid: string })[] = [];
83
+ let offset = 0;
84
+ let hasMore = true;
85
+
86
+ while (hasMore) {
87
+ const { data, error } = await client
88
+ .from('passage_alignments')
89
+ .select(
90
+ 'passage_uuid, folio_uuid, toh, tibetan, folio_number, volume_number',
91
+ )
92
+ .in('passage_uuid', passageUuids as string[])
93
+ .range(offset, offset + pageSize - 1);
94
+
95
+ if (error) {
96
+ console.error('Error batch loading alignments:', error);
97
+ return new Map();
98
+ }
99
+
100
+ allData = allData.concat((data ?? []) as AlignmentDTO[]);
101
+ hasMore = (data?.length ?? 0) === pageSize;
102
+ offset += pageSize;
103
+ }
104
+
105
+ for (const row of allData) {
106
+ const existing = alignmentsByPassage.get(row.passage_uuid);
107
+ if (existing) {
108
+ existing.push(row);
109
+ } else {
110
+ alignmentsByPassage.set(row.passage_uuid, [row]);
111
+ }
112
+ }
113
+
114
+ return alignmentsByPassage;
115
+ };
116
+
117
+ export const getPassageLabelsByUuids = async ({
118
+ client,
119
+ passageUuids,
120
+ }: {
121
+ client: DataClient;
122
+ passageUuids: readonly string[];
123
+ }): Promise<Map<string, string>> => {
124
+ const labelsByUuid = new Map<string, string>();
125
+
126
+ if (passageUuids.length === 0) {
127
+ return labelsByUuid;
128
+ }
129
+
130
+ const { data, error } = await client
131
+ .from('passages')
132
+ .select('uuid, label')
133
+ .in('uuid', passageUuids as string[]);
134
+
135
+ if (error) {
136
+ console.error('Error batch loading passage labels:', error);
137
+ return labelsByUuid;
138
+ }
139
+
140
+ for (const passage of data ?? []) {
141
+ if (passage.label) {
142
+ labelsByUuid.set(passage.uuid, passage.label);
143
+ }
144
+ }
145
+
146
+ return labelsByUuid;
147
+ };
148
+
149
+ export const getPassageReferencesByTargetUuids = async ({
150
+ client,
151
+ passageUuids,
152
+ }: {
153
+ client: DataClient;
154
+ passageUuids: readonly string[];
155
+ }): Promise<Map<string, Passages>> => {
156
+ const referencesByTargetUuid = new Map<string, Passages>();
157
+
158
+ if (passageUuids.length === 0) {
159
+ return referencesByTargetUuid;
160
+ }
161
+
162
+ const { data: annotations, error: annotationsError } = await client.rpc(
163
+ 'get_passage_annotations_by_content_uuids',
164
+ {
165
+ annotation_type: 'end-note-link',
166
+ target_uuids: passageUuids as string[],
167
+ },
168
+ );
169
+
170
+ if (annotationsError) {
171
+ console.error(
172
+ 'Error batch loading passage reference annotations:',
173
+ annotationsError,
174
+ );
175
+ return referencesByTargetUuid;
176
+ }
177
+
178
+ const targetToSourceUuids = new Map<string, Set<string>>();
179
+ const allSourceUuids = new Set<string>();
180
+
181
+ for (const row of (annotations ?? []) as Array<{
182
+ passage_uuid: string;
183
+ target_uuid: string;
184
+ }>) {
185
+ if (!row.target_uuid || !row.passage_uuid) continue;
186
+ allSourceUuids.add(row.passage_uuid);
187
+ const sourceSet =
188
+ targetToSourceUuids.get(row.target_uuid) ?? new Set<string>();
189
+ sourceSet.add(row.passage_uuid);
190
+ targetToSourceUuids.set(row.target_uuid, sourceSet);
191
+ }
192
+
193
+ const sourceUuidArray = Array.from(allSourceUuids);
194
+ const passageMap = new Map<string, Passage>();
195
+ const inBatchSize = 300;
196
+
197
+ for (let i = 0; i < sourceUuidArray.length; i += inBatchSize) {
198
+ const batch = sourceUuidArray.slice(i, i + inBatchSize);
199
+ const { data, error } = await client
200
+ .from('passages')
201
+ .select('uuid, content, label, sort, type, toh, xmlId, work_uuid')
202
+ .in('uuid', batch);
203
+
204
+ if (error) {
205
+ console.error('Error batch loading passage reference data:', error);
206
+ return new Map();
207
+ }
208
+
209
+ for (const row of (data ?? []) as PassageDTO[]) {
210
+ passageMap.set(row.uuid, passageFromDTO(row));
211
+ }
212
+ }
213
+
214
+ for (const targetUuid of passageUuids) {
215
+ const sourceUuids = targetToSourceUuids.get(targetUuid);
216
+ if (!sourceUuids) continue;
217
+ const references: Passages = [];
218
+ for (const sourceUuid of sourceUuids) {
219
+ const passage = passageMap.get(sourceUuid);
220
+ if (passage) references.push(passage);
221
+ }
222
+ referencesByTargetUuid.set(
223
+ targetUuid,
224
+ references.sort((a, b) => a.sort - b.sort),
225
+ );
226
+ }
227
+
228
+ return referencesByTargetUuid;
229
+ };
@@ -0,0 +1,5 @@
1
+ export * from './batch';
2
+ export * from './pagination';
3
+ export * from './read';
4
+ export * from './replace-persistence';
5
+ export * from './save';