@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.
- package/LICENSE +202 -0
- package/README.md +111 -0
- package/lib/esm/document-index.d.ts +126 -0
- package/lib/esm/document-index.js +800 -0
- package/lib/esm/document-index.js.map +1 -0
- package/lib/esm/document-store.d.ts +60 -0
- package/lib/esm/document-store.js +349 -0
- package/lib/esm/document-store.js.map +1 -0
- package/lib/esm/index.d.ts +3 -0
- package/lib/esm/index.js +4 -0
- package/lib/esm/index.js.map +1 -0
- package/lib/esm/package.json +3 -0
- package/lib/esm/query.d.ts +184 -0
- package/lib/esm/query.js +579 -0
- package/lib/esm/query.js.map +1 -0
- package/lib/esm/utils.d.ts +3 -0
- package/lib/esm/utils.js +12 -0
- package/lib/esm/utils.js.map +1 -0
- package/package.json +46 -0
- package/src/document-index.ts +1029 -0
- package/src/document-store.ts +439 -0
- package/src/index.ts +3 -0
- package/src/query.ts +510 -0
- package/src/utils.ts +17 -0
|
@@ -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