@abraca/dabra 0.6.0 → 0.8.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,247 @@
1
+ /**
2
+ * IndexedDB-backed trigram full-text search index.
3
+ *
4
+ * Generic: caller passes (docId, texts[]) — works for Y.Doc text content,
5
+ * file names, document titles, or any other text. No Y.js coupling.
6
+ *
7
+ * Algorithm: trigram inverted index.
8
+ * - Each document is decomposed into overlapping 3-character windows.
9
+ * - The "postings" store maps trigram → [docId, ...].
10
+ * - The "doc_trigrams" store maps docId → [trigram, ...] for efficient removal.
11
+ * - search() scores by number of query trigrams that match.
12
+ *
13
+ * IDB transactions that touch multiple stores use the callback-only pattern
14
+ * (not async/await inside a transaction) to avoid the transaction auto-commit
15
+ * issue across microtask boundaries.
16
+ */
17
+
18
+ import type { SearchResult } from "./types.ts";
19
+
20
+ const DB_VERSION = 1;
21
+
22
+ function idbAvailable(): boolean {
23
+ return typeof globalThis !== "undefined" && "indexedDB" in globalThis;
24
+ }
25
+
26
+ function openDb(origin: string): Promise<IDBDatabase> {
27
+ return new Promise((resolve, reject) => {
28
+ const req = globalThis.indexedDB.open(`abracadabra:search:${origin}`, DB_VERSION);
29
+
30
+ req.onupgradeneeded = (event) => {
31
+ const db = (event.target as IDBOpenDBRequest).result;
32
+ if (!db.objectStoreNames.contains("postings")) {
33
+ db.createObjectStore("postings");
34
+ }
35
+ if (!db.objectStoreNames.contains("doc_trigrams")) {
36
+ db.createObjectStore("doc_trigrams");
37
+ }
38
+ };
39
+
40
+ req.onsuccess = () => resolve(req.result);
41
+ req.onerror = () => reject(req.error);
42
+ });
43
+ }
44
+
45
+ /** Extract the set of trigrams for a piece of text. */
46
+ function extractTrigrams(text: string): Set<string> {
47
+ const trigrams = new Set<string>();
48
+ const padded = ` ${text.toLowerCase()} `;
49
+ for (let i = 0; i <= padded.length - 3; i++) {
50
+ trigrams.add(padded.slice(i, i + 3));
51
+ }
52
+ return trigrams;
53
+ }
54
+
55
+ /** Merge trigrams from multiple texts into a single set. */
56
+ function extractAllTrigrams(texts: string[]): Set<string> {
57
+ const result = new Set<string>();
58
+ for (const t of texts) {
59
+ for (const trigram of extractTrigrams(t)) {
60
+ result.add(trigram);
61
+ }
62
+ }
63
+ return result;
64
+ }
65
+
66
+ export class SearchIndex {
67
+ private readonly origin: string;
68
+ private dbPromise: Promise<IDBDatabase | null> | null = null;
69
+ private db: IDBDatabase | null = null;
70
+
71
+ constructor(serverOrigin: string) {
72
+ this.origin = serverOrigin;
73
+ }
74
+
75
+ private getDb(): Promise<IDBDatabase | null> {
76
+ if (!idbAvailable()) return Promise.resolve(null);
77
+ if (!this.dbPromise) {
78
+ this.dbPromise = openDb(this.origin)
79
+ .catch(() => null)
80
+ .then((db) => {
81
+ this.db = db;
82
+ return db;
83
+ });
84
+ }
85
+ return this.dbPromise;
86
+ }
87
+
88
+ /**
89
+ * Replace the index for docId with the given texts.
90
+ * Old trigram associations are removed before new ones are added.
91
+ */
92
+ async index(docId: string, texts: string[]): Promise<void> {
93
+ const db = await this.getDb();
94
+ if (!db) return;
95
+
96
+ const newTrigrams = extractAllTrigrams(texts);
97
+
98
+ return new Promise<void>((resolve, reject) => {
99
+ const tx = db.transaction(["postings", "doc_trigrams"], "readwrite");
100
+ tx.oncomplete = () => resolve();
101
+ tx.onerror = () => reject(tx.error);
102
+
103
+ const postings = tx.objectStore("postings");
104
+ const docTrigramsStore = tx.objectStore("doc_trigrams");
105
+
106
+ // Step 1: read old trigrams for this doc
107
+ const oldReq = docTrigramsStore.get(docId);
108
+ oldReq.onsuccess = () => {
109
+ const oldTrigrams: string[] = oldReq.result ?? [];
110
+ let pending = oldTrigrams.length + newTrigrams.size + 1; // +1 for doc_trigrams write
111
+
112
+ function done() {
113
+ pending--;
114
+ // tx.oncomplete fires naturally once all requests settle
115
+ }
116
+
117
+ // Step 2: remove docId from each old trigram's posting list
118
+ for (const trigram of oldTrigrams) {
119
+ const req = postings.get(trigram);
120
+ req.onsuccess = () => {
121
+ const list: string[] = req.result ?? [];
122
+ const updated = list.filter((id) => id !== docId);
123
+ if (updated.length === 0) {
124
+ postings.delete(trigram);
125
+ } else {
126
+ postings.put(updated, trigram);
127
+ }
128
+ done();
129
+ };
130
+ req.onerror = done;
131
+ }
132
+
133
+ // Step 3: add docId to each new trigram's posting list
134
+ for (const trigram of newTrigrams) {
135
+ const req = postings.get(trigram);
136
+ req.onsuccess = () => {
137
+ const list: string[] = req.result ?? [];
138
+ if (!list.includes(docId)) {
139
+ list.push(docId);
140
+ }
141
+ postings.put(list, trigram);
142
+ done();
143
+ };
144
+ req.onerror = done;
145
+ }
146
+
147
+ // Step 4: save new trigram set for this doc
148
+ const writeReq = docTrigramsStore.put([...newTrigrams], docId);
149
+ writeReq.onsuccess = done;
150
+ writeReq.onerror = done;
151
+ };
152
+ oldReq.onerror = () => reject(oldReq.error);
153
+ });
154
+ }
155
+
156
+ /** Remove all indexed content for a document. */
157
+ async remove(docId: string): Promise<void> {
158
+ const db = await this.getDb();
159
+ if (!db) return;
160
+
161
+ return new Promise<void>((resolve, reject) => {
162
+ const tx = db.transaction(["postings", "doc_trigrams"], "readwrite");
163
+ tx.oncomplete = () => resolve();
164
+ tx.onerror = () => reject(tx.error);
165
+
166
+ const postings = tx.objectStore("postings");
167
+ const docTrigramsStore = tx.objectStore("doc_trigrams");
168
+
169
+ const oldReq = docTrigramsStore.get(docId);
170
+ oldReq.onsuccess = () => {
171
+ const oldTrigrams: string[] = oldReq.result ?? [];
172
+
173
+ for (const trigram of oldTrigrams) {
174
+ const req = postings.get(trigram);
175
+ req.onsuccess = () => {
176
+ const list: string[] = req.result ?? [];
177
+ const updated = list.filter((id) => id !== docId);
178
+ if (updated.length === 0) {
179
+ postings.delete(trigram);
180
+ } else {
181
+ postings.put(updated, trigram);
182
+ }
183
+ };
184
+ }
185
+
186
+ docTrigramsStore.delete(docId);
187
+ };
188
+ oldReq.onerror = () => reject(oldReq.error);
189
+ });
190
+ }
191
+
192
+ /**
193
+ * Search for documents matching the query.
194
+ * Returns results sorted by score (matching trigram count) descending.
195
+ */
196
+ async search(query: string, limit = 20): Promise<SearchResult[]> {
197
+ const db = await this.getDb();
198
+ if (!db) return [];
199
+
200
+ const queryTrigrams = [...extractTrigrams(query)];
201
+ if (queryTrigrams.length === 0) return [];
202
+
203
+ return new Promise<SearchResult[]>((resolve, reject) => {
204
+ const tx = db.transaction("postings", "readonly");
205
+ const postings = tx.objectStore("postings");
206
+ const scores = new Map<string, number>();
207
+ let remaining = queryTrigrams.length;
208
+
209
+ for (const trigram of queryTrigrams) {
210
+ const req = postings.get(trigram);
211
+ req.onsuccess = () => {
212
+ const docIds: string[] = req.result ?? [];
213
+ for (const docId of docIds) {
214
+ scores.set(docId, (scores.get(docId) ?? 0) + 1);
215
+ }
216
+ remaining--;
217
+ if (remaining === 0) {
218
+ const results: SearchResult[] = [...scores.entries()]
219
+ .map(([docId, score]) => ({ docId, score }))
220
+ .sort((a, b) => b.score - a.score)
221
+ .slice(0, limit);
222
+ resolve(results);
223
+ }
224
+ };
225
+ req.onerror = () => {
226
+ remaining--;
227
+ if (remaining === 0) {
228
+ const results: SearchResult[] = [...scores.entries()]
229
+ .map(([docId, score]) => ({ docId, score }))
230
+ .sort((a, b) => b.score - a.score)
231
+ .slice(0, limit);
232
+ resolve(results);
233
+ }
234
+ };
235
+ }
236
+
237
+ tx.onerror = () => reject(tx.error);
238
+ });
239
+ }
240
+
241
+ // ── Lifecycle ─────────────────────────────────────────────────────────────
242
+
243
+ destroy(): void {
244
+ this.db?.close();
245
+ this.db = null;
246
+ }
247
+ }
package/src/auth.ts ADDED
@@ -0,0 +1,62 @@
1
+ import * as encoding from "lib0/encoding";
2
+ import * as decoding from "lib0/decoding";
3
+ import type { AuthorizedScope } from "./types.ts";
4
+
5
+ export enum AuthMessageType {
6
+ Token = 0,
7
+ PermissionDenied = 1,
8
+ Authenticated = 2,
9
+ }
10
+
11
+ export const writeAuthentication = (
12
+ encoder: encoding.Encoder,
13
+ auth: string,
14
+ ) => {
15
+ encoding.writeVarUint(encoder, AuthMessageType.Token);
16
+ encoding.writeVarString(encoder, auth);
17
+ };
18
+
19
+ export const writePermissionDenied = (
20
+ encoder: encoding.Encoder,
21
+ reason: string,
22
+ ) => {
23
+ encoding.writeVarUint(encoder, AuthMessageType.PermissionDenied);
24
+ encoding.writeVarString(encoder, reason);
25
+ };
26
+
27
+ export const writeAuthenticated = (
28
+ encoder: encoding.Encoder,
29
+ scope: AuthorizedScope,
30
+ ) => {
31
+ encoding.writeVarUint(encoder, AuthMessageType.Authenticated);
32
+ encoding.writeVarString(encoder, scope);
33
+ };
34
+
35
+ export const writeTokenSyncRequest = (
36
+ encoder: encoding.Encoder,
37
+ ) => {
38
+ encoding.writeVarUint(encoder, AuthMessageType.Token);
39
+ };
40
+
41
+ export const readAuthMessage = (
42
+ decoder: decoding.Decoder,
43
+ sendToken: () => void,
44
+ permissionDeniedHandler: (reason: string) => void,
45
+ authenticatedHandler: (scope: string) => void,
46
+ ) => {
47
+ switch (decoding.readVarUint(decoder)) {
48
+ case AuthMessageType.Token: {
49
+ sendToken();
50
+ break;
51
+ }
52
+ case AuthMessageType.PermissionDenied: {
53
+ permissionDeniedHandler(decoding.readVarString(decoder));
54
+ break;
55
+ }
56
+ case AuthMessageType.Authenticated: {
57
+ authenticatedHandler(decoding.readVarString(decoder));
58
+ break;
59
+ }
60
+ default:
61
+ }
62
+ };
@@ -0,0 +1,10 @@
1
+ export const awarenessStatesToArray = (
2
+ states: Map<number, Record<string, any>>,
3
+ ) => {
4
+ return Array.from(states.entries()).map(([key, value]) => {
5
+ return {
6
+ clientId: key,
7
+ ...value,
8
+ };
9
+ });
10
+ };
package/src/index.ts CHANGED
@@ -1,8 +1,15 @@
1
- export * from "./HocuspocusProvider.ts";
2
- export * from "./HocuspocusProviderWebsocket.ts";
1
+ export * from "./AbracadabraBaseProvider.ts";
2
+ export * from "./AbracadabraWS.ts";
3
3
  export * from "./types.ts";
4
4
  export * from "./AbracadabraProvider.ts";
5
5
  export * from "./AbracadabraClient.ts";
6
6
  export * from "./OfflineStore.ts";
7
+ export * from "./auth.ts";
8
+ export * from "./CloseEvents.ts";
9
+ export * from "./awarenessStatesToArray.ts";
7
10
  export { SubdocMessage } from "./OutgoingMessages/SubdocMessage.ts";
8
11
  export { CryptoIdentityKeystore } from "./CryptoIdentityKeystore.ts";
12
+ export { DocumentCache } from "./DocumentCache.ts";
13
+ export type { DocumentCacheOptions } from "./DocumentCache.ts";
14
+ export { SearchIndex } from "./SearchIndex.ts";
15
+ export { FileBlobStore } from "./FileBlobStore.ts";
package/src/types.ts CHANGED
@@ -2,7 +2,7 @@ import type { Encoder } from "lib0/encoding";
2
2
  import type { Event, MessageEvent } from "ws";
3
3
  import type { Awareness } from "y-protocols/awareness";
4
4
  import type * as Y from "yjs";
5
- import type { CloseEvent } from "@abraca/dabra-common";
5
+ import type { CloseEvent } from "./CloseEvents.ts";
6
6
  import type { IncomingMessage } from "./IncomingMessage.ts";
7
7
  import type { OutgoingMessage } from "./OutgoingMessage.ts";
8
8
  import type { AuthenticationMessage } from "./OutgoingMessages/AuthenticationMessage.ts";
@@ -12,6 +12,17 @@ import type { SyncStepOneMessage } from "./OutgoingMessages/SyncStepOneMessage.t
12
12
  import type { SyncStepTwoMessage } from "./OutgoingMessages/SyncStepTwoMessage.ts";
13
13
  import type { UpdateMessage } from "./OutgoingMessages/UpdateMessage.ts";
14
14
 
15
+ /**
16
+ * State of the WebSocket connection.
17
+ * https://developer.mozilla.org/de/docs/Web/API/WebSocket/readyState
18
+ */
19
+ export enum WsReadyStates {
20
+ Connecting = 0,
21
+ Open = 1,
22
+ Closing = 2,
23
+ Closed = 3,
24
+ }
25
+
15
26
  export enum MessageType {
16
27
  Sync = 0,
17
28
  Awareness = 1,
@@ -193,3 +204,50 @@ export interface HealthStatus {
193
204
  version: string;
194
205
  active_documents: number;
195
206
  }
207
+
208
+ export interface ServerInfo {
209
+ /** Human-readable server name set by the operator. */
210
+ name?: string;
211
+ /** Server version string. */
212
+ version?: string;
213
+ /** Entry-point document ID advertised by the server, if configured. */
214
+ index_doc_id?: string;
215
+ }
216
+
217
+ // ── Search ───────────────────────────────────────────────────────────────────
218
+
219
+ export interface SearchResult {
220
+ docId: string;
221
+ /** Number of matching trigrams — higher is better. */
222
+ score: number;
223
+ }
224
+
225
+ // ── Invites ──────────────────────────────────────────────────────────────────
226
+
227
+ export interface InviteRow {
228
+ code: string;
229
+ createdBy: string | null;
230
+ role: string;
231
+ maxUses: number;
232
+ useCount: number;
233
+ expiresAt: number | null;
234
+ revoked: boolean;
235
+ createdAt: number;
236
+ }
237
+
238
+ // ── Upload queue ─────────────────────────────────────────────────────────────
239
+
240
+ export type UploadQueueStatus = "pending" | "uploading" | "done" | "error";
241
+
242
+ export interface UploadQueueEntry {
243
+ /** Client-generated UUID. */
244
+ id: string;
245
+ docId: string;
246
+ /** File or Blob to upload. File extends Blob and survives IDB as Blob. */
247
+ file: Blob;
248
+ /** Eagerly captured filename (from File.name or explicit arg). */
249
+ filename: string;
250
+ status: UploadQueueStatus;
251
+ createdAt: number;
252
+ error?: string;
253
+ }