@peerbit/document 1.0.2

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,439 @@
1
+ import {
2
+ AbstractType,
3
+ deserialize,
4
+ field,
5
+ serialize,
6
+ variant,
7
+ } from "@dao-xyz/borsh";
8
+ import { CanAppend, Change, Entry, EntryType, TrimOptions } from "@peerbit/log";
9
+ import { ComposableProgram, Program, ProgramEvents } from "@peerbit/program";
10
+ import { CanRead } from "@peerbit/rpc";
11
+ import { AccessError, DecryptedThing } from "@peerbit/crypto";
12
+ import { logger as loggerFn } from "@peerbit/logger";
13
+ import { AppendOptions } from "@peerbit/log";
14
+ import { CustomEvent } from "@libp2p/interfaces/events";
15
+ import { Replicator, SharedLog, SharedLogOptions } from "@peerbit/shared-log";
16
+ import {
17
+ Indexable,
18
+ BORSH_ENCODING_OPERATION,
19
+ DeleteOperation,
20
+ DocumentIndex,
21
+ Operation,
22
+ PutOperation,
23
+ } from "./document-index.js";
24
+ import { asString, checkKeyable, Keyable } from "./utils.js";
25
+ import { Context, Results } from "./query.js";
26
+
27
+ const logger = loggerFn({ module: "document" });
28
+
29
+ export class OperationError extends Error {
30
+ constructor(message?: string) {
31
+ super(message);
32
+ }
33
+ }
34
+ export interface DocumentsChange<T> {
35
+ added: T[];
36
+ removed: T[];
37
+ }
38
+ export interface DocumentEvents<T> {
39
+ change: CustomEvent<DocumentsChange<T>>;
40
+ }
41
+
42
+ export type SetupOptions<T> = {
43
+ type: AbstractType<T>;
44
+ canRead?: CanRead;
45
+ canAppend?: CanAppend<Operation<T>>;
46
+ canOpen?: (program: T) => Promise<boolean> | boolean;
47
+ index?: {
48
+ key?: string | string[];
49
+ fields?: Indexable<T>;
50
+ };
51
+ trim?: TrimOptions;
52
+ } & SharedLogOptions;
53
+
54
+ @variant("documents")
55
+ export class Documents<T extends Record<string, any>> extends ComposableProgram<
56
+ SetupOptions<T>,
57
+ DocumentEvents<T> & ProgramEvents
58
+ > {
59
+ @field({ type: SharedLog })
60
+ log: SharedLog<Operation<T>>;
61
+
62
+ @field({ type: "bool" })
63
+ immutable: boolean; // "Can I overwrite a document?"
64
+
65
+ @field({ type: DocumentIndex })
66
+ private _index: DocumentIndex<T>;
67
+
68
+ private _clazz?: AbstractType<T>;
69
+
70
+ private _optionCanAppend?: CanAppend<Operation<T>>;
71
+ canOpen?: (
72
+ program: T,
73
+ entry: Entry<Operation<T>>
74
+ ) => Promise<boolean> | boolean;
75
+
76
+ constructor(properties?: {
77
+ id?: Uint8Array;
78
+ immutable?: boolean;
79
+ index?: DocumentIndex<T>;
80
+ }) {
81
+ super();
82
+
83
+ this.log = new SharedLog(properties);
84
+ this.immutable = properties?.immutable ?? false;
85
+ this._index = properties?.index || new DocumentIndex();
86
+ }
87
+
88
+ get index(): DocumentIndex<T> {
89
+ return this._index;
90
+ }
91
+
92
+ async open(options: SetupOptions<T>) {
93
+ this._clazz = options.type;
94
+ this.canOpen = options.canOpen;
95
+
96
+ /* eslint-disable */
97
+ if (Program.isPrototypeOf(this._clazz)) {
98
+ if (!this.canOpen) {
99
+ throw new Error(
100
+ "setup needs to be called with the canOpen option when the document type is a Program"
101
+ );
102
+ }
103
+ }
104
+ if (options.canAppend) {
105
+ this._optionCanAppend = options.canAppend;
106
+ }
107
+
108
+ await this._index.open({
109
+ type: this._clazz,
110
+ log: this.log,
111
+ canRead: options.canRead || (() => Promise.resolve(true)),
112
+ fields: options.index?.fields || ((obj) => obj),
113
+ indexBy: options.index?.key,
114
+ sync: async (result: Results<T>) =>
115
+ this.log.log.join(result.results.map((x) => x.context.head)),
116
+ });
117
+
118
+ await this.log.open({
119
+ encoding: BORSH_ENCODING_OPERATION,
120
+ canAppend: this.canAppend.bind(this),
121
+ onChange: this.handleChanges.bind(this),
122
+ trim: options?.trim,
123
+ sync: options?.sync,
124
+ role: options?.role,
125
+ minReplicas: options?.minReplicas,
126
+ });
127
+ }
128
+
129
+ private async _resolveEntry(history: Entry<Operation<T>> | string) {
130
+ return typeof history === "string"
131
+ ? (await this.log.log.get(history)) ||
132
+ (await Entry.fromMultihash<Operation<T>>(
133
+ this.log.log.storage,
134
+ history
135
+ ))
136
+ : history;
137
+ }
138
+
139
+ async canAppend(entry: Entry<Operation<T>>): Promise<boolean> {
140
+ const l0 = await this._canAppend(entry);
141
+ if (!l0) {
142
+ return false;
143
+ }
144
+
145
+ if (this._optionCanAppend && !(await this._optionCanAppend(entry))) {
146
+ return false;
147
+ }
148
+ return true;
149
+ }
150
+
151
+ async _canAppend(entry: Entry<Operation<T>>): Promise<boolean> {
152
+ const resolve = async (history: Entry<Operation<T>> | string) => {
153
+ return typeof history === "string"
154
+ ? this.log.log.get(history) ||
155
+ (await Entry.fromMultihash(this.log.log.storage, history))
156
+ : history;
157
+ };
158
+ const pointsToHistory = async (history: Entry<Operation<T>> | string) => {
159
+ // make sure nexts only points to this document at some point in history
160
+ let current = await resolve(history);
161
+
162
+ const next = entry.next[0];
163
+ while (
164
+ current?.hash &&
165
+ next !== current?.hash &&
166
+ current.next.length > 0
167
+ ) {
168
+ current = await this.log.log.get(current.next[0])!;
169
+ }
170
+ if (current?.hash === next) {
171
+ return true; // Ok, we are pointing this new edit to some exising point in time of the old document
172
+ }
173
+ return false;
174
+ };
175
+
176
+ try {
177
+ entry.init({
178
+ encoding: this.log.log.encoding,
179
+ keychain: this.node.keychain,
180
+ });
181
+ const operation =
182
+ entry._payload instanceof DecryptedThing
183
+ ? entry.payload.getValue(entry.encoding)
184
+ : await entry.getPayloadValue();
185
+ if (operation instanceof PutOperation) {
186
+ // check nexts
187
+ const putOperation = operation as PutOperation<T>;
188
+
189
+ const key = this._index.indexByResolver(
190
+ putOperation.getValue(this.index.valueEncoding)
191
+ ) as Keyable;
192
+
193
+ checkKeyable(key);
194
+
195
+ const existingDocument = this.index.index.get(asString(key));
196
+ if (existingDocument) {
197
+ if (this.immutable) {
198
+ //Key already exist and this instance Documents can note overrite/edit'
199
+ return false;
200
+ }
201
+
202
+ if (entry.next.length !== 1) {
203
+ return false;
204
+ }
205
+ let doc = await this.log.log.get(existingDocument.context.head);
206
+ if (!doc) {
207
+ logger.error("Failed to find Document from head");
208
+ return false;
209
+ }
210
+ return pointsToHistory(doc);
211
+ } else {
212
+ if (entry.next.length !== 0) {
213
+ return false;
214
+ }
215
+ }
216
+ } else if (operation instanceof DeleteOperation) {
217
+ if (entry.next.length !== 1) {
218
+ return false;
219
+ }
220
+ const existingDocument = this._index.index.get(operation.key);
221
+ if (!existingDocument) {
222
+ // already deleted
223
+ return false;
224
+ }
225
+ let doc = await this.log.log.get(existingDocument.context.head);
226
+ if (!doc) {
227
+ logger.error("Failed to find Document from head");
228
+ return false;
229
+ }
230
+ return pointsToHistory(doc); // references the existing document
231
+ }
232
+ } catch (error) {
233
+ if (error instanceof AccessError) {
234
+ return false; // we cant index because we can not decrypt
235
+ }
236
+ throw error;
237
+ }
238
+ return true;
239
+ }
240
+
241
+ public async put(
242
+ doc: T,
243
+ options?: AppendOptions<Operation<T>> & { unique?: boolean }
244
+ ) {
245
+ const key = this._index.indexByResolver(doc as any as Keyable);
246
+ checkKeyable(key);
247
+ const ser = serialize(doc);
248
+ const existingDocument = options?.unique
249
+ ? undefined
250
+ : (
251
+ await this._index.getDetailed(key, {
252
+ local: true,
253
+ remote: { sync: true }, // only query remote if we know they exist
254
+ })
255
+ )?.[0]?.results[0];
256
+
257
+ return this.log.append(
258
+ new PutOperation({
259
+ key: asString(key),
260
+ data: ser,
261
+ value: doc,
262
+ }),
263
+ {
264
+ nexts: existingDocument
265
+ ? [await this._resolveEntry(existingDocument.context.head)]
266
+ : [], //
267
+ ...options,
268
+ }
269
+ );
270
+ }
271
+
272
+ async del(key: Keyable, options?: AppendOptions<Operation<T>>) {
273
+ const existing = (
274
+ await this._index.getDetailed(key, {
275
+ local: true,
276
+ remote: { sync: true },
277
+ })
278
+ )?.[0]?.results[0];
279
+ if (!existing) {
280
+ throw new Error(`No entry with key '${key}' in the database`);
281
+ }
282
+
283
+ return this.log.append(
284
+ new DeleteOperation({
285
+ key: asString(key),
286
+ }),
287
+ {
288
+ nexts: [await this._resolveEntry(existing.context.head)],
289
+ type: EntryType.CUT,
290
+ ...options,
291
+ } //
292
+ );
293
+ }
294
+
295
+ async handleChanges(change: Change<Operation<T>>): Promise<void> {
296
+ const removed = [...(change.removed || [])];
297
+ const removedSet = new Map<string, Entry<Operation<T>>>();
298
+ for (const r of removed) {
299
+ removedSet.set(r.hash, r);
300
+ }
301
+ const entries = [...change.added, ...(removed || [])]
302
+ .sort(this.log.log.sortFn)
303
+ .reverse(); // sort so we get newest to oldest
304
+
305
+ // There might be a case where change.added and change.removed contains the same document id. Usaully because you use the "trim" option
306
+ // in combination with inserting the same document. To mitigate this, we loop through the changes and modify the behaviour for this
307
+
308
+ let visited = new Map<string, Entry<Operation<T>>[]>();
309
+ for (const item of entries) {
310
+ const payload =
311
+ item._payload instanceof DecryptedThing
312
+ ? item.payload.getValue(item.encoding)
313
+ : await item.getPayloadValue();
314
+ let itemKey: string;
315
+ if (
316
+ payload instanceof PutOperation ||
317
+ payload instanceof DeleteOperation
318
+ ) {
319
+ itemKey = payload.key;
320
+ } else {
321
+ throw new Error("Unsupported operation type");
322
+ }
323
+
324
+ let arr = visited.get(itemKey);
325
+ if (!arr) {
326
+ arr = [];
327
+ visited.set(itemKey, arr);
328
+ }
329
+ arr.push(item);
330
+ }
331
+
332
+ let documentsChanged: DocumentsChange<T> = {
333
+ added: [],
334
+ removed: [],
335
+ };
336
+
337
+ for (const [_key, entries] of visited) {
338
+ try {
339
+ const item = entries[0];
340
+ const payload =
341
+ item._payload instanceof DecryptedThing
342
+ ? item.payload.getValue(item.encoding)
343
+ : await item.getPayloadValue();
344
+ if (payload instanceof PutOperation && !removedSet.has(item.hash)) {
345
+ const key = payload.key;
346
+ const value = this.deserializeOrPass(payload);
347
+
348
+ documentsChanged.added.push(value);
349
+
350
+ const context = new Context({
351
+ created:
352
+ this._index.index.get(key)?.context.created ||
353
+ item.metadata.clock.timestamp.wallTime,
354
+ modified: item.metadata.clock.timestamp.wallTime,
355
+ head: item.hash,
356
+ });
357
+
358
+ const valueToIndex = this._index.toIndex(value, context);
359
+ this._index.index.set(key, {
360
+ key: payload.key,
361
+ value: isPromise(valueToIndex) ? await valueToIndex : valueToIndex,
362
+ context,
363
+ });
364
+
365
+ // Program specific
366
+ if (value instanceof Program) {
367
+ // if replicator, then open
368
+ if (
369
+ (await this.canOpen!(value, item)) &&
370
+ this.log.role instanceof Replicator &&
371
+ (await this.log.replicator(item.gid)) // TODO types, throw runtime error if replicator is not provided
372
+ ) {
373
+ await this.node.open(value, { parent: this });
374
+ }
375
+ }
376
+ } else if (
377
+ (payload instanceof DeleteOperation && !removedSet.has(item.hash)) ||
378
+ payload instanceof PutOperation ||
379
+ removedSet.has(item.hash)
380
+ ) {
381
+ const key = (payload as DeleteOperation | PutOperation<T>).key;
382
+ if (!this.index.index.has(key)) {
383
+ continue;
384
+ }
385
+
386
+ let value: T;
387
+ if (payload instanceof PutOperation) {
388
+ value = this.deserializeOrPass(payload);
389
+ } else if (payload instanceof DeleteOperation) {
390
+ value = await this.getDocumentFromEntry(entries[1]!);
391
+ } else {
392
+ throw new Error("Unexpected");
393
+ }
394
+
395
+ documentsChanged.removed.push(value);
396
+
397
+ if (value instanceof Program) {
398
+ // TODO is this tested?
399
+ await value.close(this);
400
+ }
401
+
402
+ // update index
403
+ this._index.index.delete(key);
404
+ } else {
405
+ // Unknown operation
406
+ }
407
+ } catch (error) {
408
+ if (error instanceof AccessError) {
409
+ continue;
410
+ }
411
+ throw error;
412
+ }
413
+ }
414
+
415
+ this.events.dispatchEvent(
416
+ new CustomEvent("change", { detail: documentsChanged })
417
+ );
418
+ }
419
+
420
+ private async getDocumentFromEntry(entry: Entry<Operation<T>>) {
421
+ const payloadValue = await entry.getPayloadValue();
422
+ if (payloadValue instanceof PutOperation) {
423
+ return payloadValue.getValue(this.index.valueEncoding);
424
+ }
425
+ throw new Error("Unexpected");
426
+ }
427
+ deserializeOrPass(value: PutOperation<T>): T {
428
+ if (value._value) {
429
+ return value._value;
430
+ } else {
431
+ value._value = deserialize(value.data, this.index.type);
432
+ return value._value!;
433
+ }
434
+ }
435
+ }
436
+
437
+ function isPromise(value) {
438
+ return Boolean(value && typeof value.then === "function");
439
+ }
package/src/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ export * from "./document-store.js";
2
+ export * from "./document-index.js";
3
+ export * from "./query.js";