elasticsearch-based configurable generic appview for prototyping ideas
1import { AppConfig } from "./config.ts";
2
3function atUriParts(uri: string) {
4 const parts = uri.split("/");
5 return {
6 did: parts[2],
7 collection: parts[3],
8 rkey: parts[4],
9 };
10}
11
12function flattenValues(input: unknown): unknown[] {
13 const result: unknown[] = [];
14
15 function recurse(value: unknown) {
16 if (value === null || typeof value !== "object") {
17 result.push(value);
18 } else if (Array.isArray(value)) {
19 for (const item of value) recurse(item);
20 } else if (value) {
21 for (const key in value) {
22 recurse((value as Record<string, unknown>)[key]);
23 }
24 }
25 }
26
27 recurse(input);
28 return result;
29}
30
31export async function ensureIndexMapping(config: AppConfig) {
32 const properties: Record<string, any> = {};
33
34 for (const fields of Object.values(config.index_fields)) {
35 for (const { id, type } of Object.values(fields)) {
36 properties[id] = {
37 type: type === "text" ? "text" : "keyword"
38 };
39 }
40 }
41
42 for (const key of [
43 "$metadata.uri",
44 "$metadata.cid",
45 "$metadata.indexedAt",
46 "$metadata.did",
47 "$metadata.collection",
48 "$metadata.rkey",
49 ]) {
50 properties[key] = { type: "keyword" };
51 }
52
53 await fetch(`${config.es_url}/${config.index_name}`, {
54 method: "PUT",
55 headers: { "Content-Type": "application/json" },
56 body: JSON.stringify({
57 mappings: { properties }
58 }),
59 });
60}
61
62function extractIndexedFields(
63 record: any,
64 indexSpec: Record<string, { id: string; type: string }>
65): Record<string, unknown> {
66 const result: Record<string, unknown> = {};
67
68 for (const [rawPath, { id }] of Object.entries(indexSpec)) {
69 const [path, modifier] = rawPath.split("#");
70 const pathSegments = path.split(".");
71
72 let value = record;
73 for (const segment of pathSegments) {
74 if (typeof value !== "object" || value === null) {
75 value = undefined;
76 break;
77 }
78 value = value[segment];
79 }
80
81 if (value === undefined) continue;
82
83 if (modifier === "flatten") {
84 const flattened = flattenValues(value);
85 if (flattened.length > 0) {
86 result[id] = flattened;
87 }
88 } else {
89 result[id] = value;
90 }
91 }
92
93 return result;
94}
95
96export type IndexerEvent =
97 | { type: 'index'; uri: string; data: Record<string, unknown> }
98 | { type: 'delete'; uri: string };
99
100export type OnEventCallback = (event: IndexerEvent) => Promise<void> | void;
101
102export async function indexDocument(
103 config: AppConfig,
104 onEvent: OnEventCallback,
105 uri: string,
106 record: Record<string, unknown>,
107 cid: string,
108 indexedAt: string
109) {
110 const { did, collection, rkey } = atUriParts(uri);
111 const recordType = collection;
112
113 const indexSpec = config.index_fields?.[recordType];
114 //if (!indexSpec) return;
115
116 const indexedFields = extractIndexedFields(record, indexSpec);
117
118 const metadata = {
119 "$metadata.uri": uri,
120 "$metadata.cid": cid,
121 "$metadata.indexedAt": indexedAt,
122 "$metadata.did": did,
123 "$metadata.collection": collection,
124 "$metadata.rkey": rkey,
125 };
126
127 const body = {
128 ...metadata,
129 ...indexedFields,
130 "$raw": record,
131 };
132
133 const res = await fetch(
134 `${config.es_url}/${config.index_name}/_doc/${encodeURIComponent(uri)}`,
135 {
136 method: "PUT",
137 headers: { "Content-Type": "application/json" },
138 body: JSON.stringify(body),
139 }
140 );
141 await onEvent({
142 type: 'index',
143 uri,
144 data: body
145 });
146
147 if (!res.ok) {
148 console.error("Indexing failed:", await res.text());
149 }
150}
151
152export async function deleteDocument(
153 config: AppConfig,
154 onEvent: OnEventCallback,
155 uri: string
156) {
157 const res = await fetch(
158 `${config.es_url}/${config.index_name}/_doc/${encodeURIComponent(uri)}`,
159 {
160 method: "DELETE",
161 }
162 );
163 await onEvent({
164 type: 'delete',
165 uri
166 });
167
168 if (!res.ok && res.status !== 404) {
169 console.error("Delete failed:", await res.text());
170 }
171}