@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.
- package/dist/abracadabra-provider.cjs +747 -66
- package/dist/abracadabra-provider.cjs.map +1 -1
- package/dist/abracadabra-provider.esm.js +725 -62
- package/dist/abracadabra-provider.esm.js.map +1 -1
- package/dist/index.d.ts +269 -31
- package/package.json +1 -2
- package/src/{HocuspocusProvider.ts → AbracadabraBaseProvider.ts} +33 -22
- package/src/AbracadabraClient.ts +96 -3
- package/src/AbracadabraProvider.ts +11 -11
- package/src/{HocuspocusProviderWebsocket.ts → AbracadabraWS.ts} +36 -22
- package/src/CloseEvents.ts +49 -0
- package/src/DocumentCache.ts +210 -0
- package/src/FileBlobStore.ts +300 -0
- package/src/MessageReceiver.ts +8 -8
- package/src/OutgoingMessages/AuthenticationMessage.ts +1 -1
- package/src/SearchIndex.ts +247 -0
- package/src/auth.ts +62 -0
- package/src/awarenessStatesToArray.ts +10 -0
- package/src/index.ts +9 -2
- package/src/types.ts +59 -1
|
@@ -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
|
+
};
|
package/src/index.ts
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
|
-
export * from "./
|
|
2
|
-
export * from "./
|
|
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 "
|
|
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
|
+
}
|