@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,277 @@
1
+ import { DataClient, PassageRowDTO } from '../types';
2
+
3
+ type ApiPaginationDirection = 'FORWARD' | 'BACKWARD' | 'AROUND';
4
+
5
+ export type PassageConnectionNode = {
6
+ uuid: string;
7
+ workUuid: string;
8
+ content: string;
9
+ label: string | null;
10
+ sort: number;
11
+ type: string;
12
+ toh: string | null;
13
+ xmlId: string | null;
14
+ };
15
+
16
+ export type PassageConnectionPage = {
17
+ nodes: PassageConnectionNode[];
18
+ nextCursor: string | null;
19
+ prevCursor: string | null;
20
+ hasMoreAfter: boolean;
21
+ hasMoreBefore: boolean;
22
+ };
23
+
24
+ const EMPTY_PASSAGE_CONNECTION: PassageConnectionPage = {
25
+ nodes: [],
26
+ nextCursor: null,
27
+ prevCursor: null,
28
+ hasMoreAfter: false,
29
+ hasMoreBefore: false,
30
+ };
31
+
32
+ const DEFAULT_PASSAGE_CONNECTION_LIMIT = 20;
33
+ const MAX_PASSAGE_CONNECTION_LIMIT = 100;
34
+
35
+ function buildPassageConnection(
36
+ nodes: PassageConnectionNode[],
37
+ nextCursor: string | null,
38
+ prevCursor: string | null,
39
+ hasMoreAfter: boolean,
40
+ hasMoreBefore: boolean,
41
+ ): PassageConnectionPage {
42
+ return {
43
+ nodes,
44
+ nextCursor,
45
+ prevCursor,
46
+ hasMoreAfter,
47
+ hasMoreBefore,
48
+ };
49
+ }
50
+
51
+ function rowToPassageConnectionNode(
52
+ row: PassageRowDTO,
53
+ workUuid: string,
54
+ ): PassageConnectionNode {
55
+ return {
56
+ uuid: row.uuid,
57
+ workUuid,
58
+ content: row.content,
59
+ label: row.label,
60
+ sort: row.sort,
61
+ type: row.type,
62
+ toh: row.toh ?? null,
63
+ xmlId: row.xmlId ?? null,
64
+ };
65
+ }
66
+
67
+ export const getWorkPassagesConnection = async ({
68
+ client,
69
+ workUuid,
70
+ cursor,
71
+ limit = DEFAULT_PASSAGE_CONNECTION_LIMIT,
72
+ filter,
73
+ direction = 'FORWARD',
74
+ }: {
75
+ client: DataClient;
76
+ workUuid: string;
77
+ cursor?: string;
78
+ limit?: number;
79
+ filter?: { type?: string; types?: string[]; label?: string };
80
+ direction?: ApiPaginationDirection;
81
+ }): Promise<PassageConnectionPage> => {
82
+ const clampedLimit = Math.min(
83
+ Math.max(limit, 1),
84
+ MAX_PASSAGE_CONNECTION_LIMIT,
85
+ );
86
+
87
+ if (direction === 'AROUND') {
88
+ return getWorkPassagesAround({
89
+ client,
90
+ workUuid,
91
+ cursor,
92
+ limit: clampedLimit,
93
+ filter,
94
+ });
95
+ }
96
+
97
+ const isForward = direction === 'FORWARD';
98
+ let cursorSort: number | null = null;
99
+ if (cursor) {
100
+ const { data: cursorPassage } = await client
101
+ .from('passages')
102
+ .select('sort')
103
+ .eq('uuid', cursor)
104
+ .single();
105
+
106
+ if (cursorPassage) {
107
+ cursorSort = cursorPassage.sort;
108
+ }
109
+ }
110
+
111
+ let query = client
112
+ .from('passages')
113
+ .select('uuid, content, label, sort, type, toh, xmlId, work_uuid')
114
+ .eq('work_uuid', workUuid)
115
+ .order('sort', { ascending: isForward })
116
+ .limit(clampedLimit + 1);
117
+
118
+ if (cursorSort !== null) {
119
+ query = isForward
120
+ ? query.gt('sort', cursorSort)
121
+ : query.lt('sort', cursorSort);
122
+ }
123
+
124
+ if (filter?.types && filter.types.length > 0) {
125
+ query = query.in('type', filter.types);
126
+ } else if (filter?.type) {
127
+ query = query.filter('type', 'match', `${filter.type}.*`);
128
+ }
129
+
130
+ if (filter?.label) {
131
+ query = query.ilike('label', filter.label);
132
+ }
133
+
134
+ const { data, error } = await query;
135
+
136
+ if (error) {
137
+ console.error('Error fetching passages:', error);
138
+ return EMPTY_PASSAGE_CONNECTION;
139
+ }
140
+
141
+ const passages = (data ?? []) as PassageRowDTO[];
142
+ const hasMore = passages.length > clampedLimit;
143
+ let resultPassages = hasMore ? passages.slice(0, clampedLimit) : passages;
144
+
145
+ if (!isForward) {
146
+ resultPassages = resultPassages.reverse();
147
+ }
148
+
149
+ const hasMoreAfter = isForward ? hasMore : cursorSort !== null;
150
+ const hasMoreBefore = isForward ? cursorSort !== null : hasMore;
151
+
152
+ if (resultPassages.length === 0) {
153
+ return buildPassageConnection([], null, null, false, hasMoreBefore);
154
+ }
155
+
156
+ const nodes = resultPassages.map((row) =>
157
+ rowToPassageConnectionNode(row, workUuid),
158
+ );
159
+ const firstPassage = resultPassages[0];
160
+ const lastPassage = resultPassages[resultPassages.length - 1];
161
+
162
+ return buildPassageConnection(
163
+ nodes,
164
+ hasMoreAfter ? lastPassage.uuid : null,
165
+ hasMoreBefore ? firstPassage.uuid : null,
166
+ hasMoreAfter,
167
+ hasMoreBefore,
168
+ );
169
+ };
170
+
171
+ export const getWorkPassagesAround = async ({
172
+ client,
173
+ workUuid,
174
+ cursor,
175
+ limit,
176
+ filter,
177
+ }: {
178
+ client: DataClient;
179
+ workUuid: string;
180
+ cursor?: string;
181
+ limit: number;
182
+ filter?: { type?: string; types?: string[]; label?: string };
183
+ }): Promise<PassageConnectionPage> => {
184
+ if (!cursor) {
185
+ console.error('AROUND direction requires a cursor');
186
+ return EMPTY_PASSAGE_CONNECTION;
187
+ }
188
+
189
+ const { data: cursorPassage } = await client
190
+ .from('passages')
191
+ .select('sort')
192
+ .eq('uuid', cursor)
193
+ .single();
194
+
195
+ if (!cursorPassage) {
196
+ console.error('Cursor passage not found');
197
+ return EMPTY_PASSAGE_CONNECTION;
198
+ }
199
+
200
+ const cursorSort = cursorPassage.sort;
201
+ const limitBefore = Math.floor(limit / 2);
202
+ const limitAfter = limit - limitBefore;
203
+ const baseSelect = 'uuid, content, label, sort, type, toh, xmlId, work_uuid';
204
+
205
+ let beforeQuery = client
206
+ .from('passages')
207
+ .select(baseSelect)
208
+ .eq('work_uuid', workUuid)
209
+ .lt('sort', cursorSort)
210
+ .order('sort', { ascending: false })
211
+ .limit(limitBefore + 1);
212
+
213
+ let afterQuery = client
214
+ .from('passages')
215
+ .select(baseSelect)
216
+ .eq('work_uuid', workUuid)
217
+ .gte('sort', cursorSort)
218
+ .order('sort', { ascending: true })
219
+ .limit(limitAfter + 1);
220
+
221
+ if (filter?.types && filter.types.length > 0) {
222
+ beforeQuery = beforeQuery.in('type', filter.types);
223
+ afterQuery = afterQuery.in('type', filter.types);
224
+ } else if (filter?.type) {
225
+ const pattern = `${filter.type}.*`;
226
+ beforeQuery = beforeQuery.filter('type', 'match', pattern);
227
+ afterQuery = afterQuery.filter('type', 'match', pattern);
228
+ }
229
+
230
+ if (filter?.label) {
231
+ beforeQuery = beforeQuery.ilike('label', filter.label);
232
+ afterQuery = afterQuery.ilike('label', filter.label);
233
+ }
234
+
235
+ const [beforeResult, afterResult] = await Promise.all([
236
+ beforeQuery,
237
+ afterQuery,
238
+ ]);
239
+
240
+ if (beforeResult.error || afterResult.error) {
241
+ console.error(
242
+ 'Error fetching passages around:',
243
+ beforeResult.error || afterResult.error,
244
+ );
245
+ return EMPTY_PASSAGE_CONNECTION;
246
+ }
247
+
248
+ const passagesBefore = (beforeResult.data ?? []) as PassageRowDTO[];
249
+ const passagesAfter = (afterResult.data ?? []) as PassageRowDTO[];
250
+ const hasMoreBefore = passagesBefore.length > limitBefore;
251
+ const hasMoreAfter = passagesAfter.length > limitAfter;
252
+ const trimmedBefore = hasMoreBefore
253
+ ? passagesBefore.slice(0, limitBefore)
254
+ : passagesBefore;
255
+ const trimmedAfter = hasMoreAfter
256
+ ? passagesAfter.slice(0, limitAfter)
257
+ : passagesAfter;
258
+ const resultPassages = [...trimmedBefore.reverse(), ...trimmedAfter];
259
+
260
+ if (resultPassages.length === 0) {
261
+ return EMPTY_PASSAGE_CONNECTION;
262
+ }
263
+
264
+ const nodes = resultPassages.map((row) =>
265
+ rowToPassageConnectionNode(row, workUuid),
266
+ );
267
+ const firstPassage = resultPassages[0];
268
+ const lastPassage = resultPassages[resultPassages.length - 1];
269
+
270
+ return buildPassageConnection(
271
+ nodes,
272
+ hasMoreAfter ? lastPassage.uuid : null,
273
+ hasMoreBefore ? firstPassage.uuid : null,
274
+ hasMoreAfter,
275
+ hasMoreBefore,
276
+ );
277
+ };
@@ -0,0 +1,100 @@
1
+ import {
2
+ DataClient,
3
+ PassageDTO,
4
+ annotationsFromDTO,
5
+ passageFromDTO,
6
+ } from '../types';
7
+
8
+ import { PassageConnectionNode } from './pagination';
9
+
10
+ export const getPassage = async ({
11
+ client,
12
+ uuid,
13
+ }: {
14
+ client: DataClient;
15
+ uuid: string;
16
+ }) => {
17
+ const { data } = await client
18
+ .rpc('get_passage_with_annotations', {
19
+ uuid_input: uuid,
20
+ })
21
+ .single();
22
+
23
+ if (!data) {
24
+ console.warn(`No passage found for uuid: ${uuid}`);
25
+ return undefined;
26
+ }
27
+
28
+ const dto = data as PassageDTO;
29
+ return passageFromDTO(
30
+ dto,
31
+ annotationsFromDTO(dto?.annotations || [], dto?.content.length || 0),
32
+ );
33
+ };
34
+
35
+ export const getPassageByUuidOrXmlId = async ({
36
+ client,
37
+ uuid,
38
+ xmlId,
39
+ }: {
40
+ client: DataClient;
41
+ uuid?: string;
42
+ xmlId?: string;
43
+ }): Promise<PassageConnectionNode | null> => {
44
+ if (!uuid && !xmlId) {
45
+ return null;
46
+ }
47
+
48
+ let query = client
49
+ .from('passages')
50
+ .select('uuid, content, label, sort, type, xmlId, toh, work_uuid');
51
+
52
+ if (uuid) {
53
+ query = query.eq('uuid', uuid);
54
+ } else if (xmlId) {
55
+ query = query.eq('xmlId', xmlId);
56
+ }
57
+
58
+ const { data, error } = await query.single();
59
+
60
+ if (error) {
61
+ console.error(`Error fetching passage ${uuid || xmlId}:`, error);
62
+ return null;
63
+ }
64
+
65
+ if (!data) {
66
+ return null;
67
+ }
68
+
69
+ return {
70
+ uuid: data.uuid,
71
+ workUuid: data.work_uuid,
72
+ content: data.content,
73
+ label: data.label,
74
+ sort: data.sort,
75
+ type: data.type,
76
+ toh: data.toh ?? null,
77
+ xmlId: data.xmlId ?? null,
78
+ };
79
+ };
80
+
81
+ export const getPassageUuidByXmlId = async ({
82
+ client,
83
+ xmlId,
84
+ }: {
85
+ client: DataClient;
86
+ xmlId: string;
87
+ }) => {
88
+ const { data, error } = await client
89
+ .from('passages')
90
+ .select('uuid, workUuid:work_uuid')
91
+ .eq('xmlId', xmlId)
92
+ .single();
93
+
94
+ if (error) {
95
+ console.error(`Error fetching passage uuid for xmlId: ${xmlId}`, error);
96
+ return;
97
+ }
98
+
99
+ return data?.uuid;
100
+ };
@@ -0,0 +1,153 @@
1
+ import {
2
+ ANNOTATIONS_TO_IGNORE,
3
+ AnnotationDTO,
4
+ DataClient,
5
+ Passage,
6
+ passagesToDTO,
7
+ passagesToRowDTO,
8
+ } from '../types';
9
+
10
+ export type ReplacePassageRow = {
11
+ content: string;
12
+ label: string | null;
13
+ sort: number;
14
+ toh: string | null;
15
+ type: string;
16
+ uuid: string;
17
+ work_uuid: string;
18
+ xmlId: string | null;
19
+ };
20
+
21
+ export const fetchReplaceRows = async ({
22
+ client,
23
+ targetUuids,
24
+ }: {
25
+ client: DataClient;
26
+ targetUuids: string[];
27
+ }) => {
28
+ const { data, error } = await client
29
+ .from('passages')
30
+ .select('uuid, work_uuid, content, label, sort, type, toh, xmlId')
31
+ .in('uuid', targetUuids);
32
+
33
+ if (error) {
34
+ console.error('Error fetching replace targets:', error);
35
+ return {
36
+ ok: false as const,
37
+ error: `Failed to fetch replace targets: ${error.message}`,
38
+ };
39
+ }
40
+
41
+ const rowsByUuid = new Map((data ?? []).map((row) => [row.uuid, row]));
42
+ const missingUuids = targetUuids.filter((uuid) => !rowsByUuid.has(uuid));
43
+ if (missingUuids.length > 0) {
44
+ return {
45
+ ok: false as const,
46
+ error: `Unknown target UUIDs: ${missingUuids.join(', ')}`,
47
+ };
48
+ }
49
+
50
+ return {
51
+ ok: true as const,
52
+ orderedRows: targetUuids
53
+ .map((uuid) => rowsByUuid.get(uuid))
54
+ .filter((row): row is ReplacePassageRow => Boolean(row)),
55
+ };
56
+ };
57
+
58
+ export const fetchReplaceAnnotations = async ({
59
+ client,
60
+ targetUuids,
61
+ }: {
62
+ client: DataClient;
63
+ targetUuids: string[];
64
+ }) => {
65
+ const { data, error } = await client
66
+ .from('passage_annotations')
67
+ .select('uuid, content, end, start, type, passage_uuid, toh')
68
+ .in('passage_uuid', targetUuids)
69
+ .not('type', 'in', `(${ANNOTATIONS_TO_IGNORE.join(',')})`);
70
+
71
+ if (error) {
72
+ console.error('Error fetching annotations for replace:', error);
73
+ return {
74
+ ok: false as const,
75
+ error: `Failed to fetch annotations: ${error.message}`,
76
+ };
77
+ }
78
+
79
+ return {
80
+ ok: true as const,
81
+ rawAnnotations: (data ?? []) as AnnotationDTO[],
82
+ };
83
+ };
84
+
85
+ export const persistReplaceChanges = async ({
86
+ client,
87
+ rawAnnotations,
88
+ updatedPassages,
89
+ }: {
90
+ client: DataClient;
91
+ rawAnnotations: AnnotationDTO[];
92
+ updatedPassages: Passage[];
93
+ }) => {
94
+ const passageRowDtos = passagesToRowDTO(updatedPassages);
95
+ const annotationDtos = passagesToDTO(updatedPassages).flatMap(
96
+ (passage) => passage.annotations || [],
97
+ );
98
+ const updatedPassageUuids = updatedPassages.map((passage) => passage.uuid);
99
+ const updatedAnnotationUuids = new Set(annotationDtos.map((a) => a.uuid));
100
+ const annotationsToDelete = rawAnnotations.filter(
101
+ (annotation) =>
102
+ updatedPassageUuids.includes(annotation.passage_uuid || '') &&
103
+ !updatedAnnotationUuids.has(annotation.uuid),
104
+ );
105
+
106
+ const { error: passageError } = await client
107
+ .from('passages')
108
+ .upsert(passageRowDtos, { onConflict: 'uuid' });
109
+
110
+ if (passageError) {
111
+ console.error('Error saving replaced passages:', passageError);
112
+ return {
113
+ ok: false as const,
114
+ error: `Failed to save replaced passages: ${passageError.message}`,
115
+ };
116
+ }
117
+
118
+ if (annotationDtos.length > 0) {
119
+ const { error: annotationError } = await client
120
+ .from('passage_annotations')
121
+ .upsert(annotationDtos, { onConflict: 'uuid' });
122
+
123
+ if (annotationError) {
124
+ console.error('Error saving replaced annotations:', annotationError);
125
+ return {
126
+ ok: false as const,
127
+ error: `Failed to save replaced annotations: ${annotationError.message}`,
128
+ };
129
+ }
130
+ }
131
+
132
+ if (annotationsToDelete.length > 0) {
133
+ const { error: deleteError } = await client
134
+ .from('passage_annotations')
135
+ .delete()
136
+ .in(
137
+ 'uuid',
138
+ annotationsToDelete.map((annotation) => annotation.uuid),
139
+ );
140
+
141
+ if (deleteError) {
142
+ console.error('Error deleting replaced annotations:', deleteError);
143
+ return {
144
+ ok: false as const,
145
+ error: `Failed to delete replaced annotations: ${deleteError.message}`,
146
+ };
147
+ }
148
+ }
149
+
150
+ return {
151
+ ok: true as const,
152
+ };
153
+ };