document-drive 1.12.1 → 1.13.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "document-drive",
3
- "version": "1.12.1",
3
+ "version": "1.13.1",
4
4
  "license": "AGPL-3.0-only",
5
5
  "type": "module",
6
6
  "module": "./src/index.ts",
@@ -31,10 +31,11 @@
31
31
  "graphql-request": "^6.1.0",
32
32
  "json-stringify-deterministic": "^1.0.12",
33
33
  "nanoevents": "^9.0.0",
34
+ "prisma": "^5.18.0",
34
35
  "sanitize-filename": "^1.6.3",
35
- "@powerhousedao/scalars": "1.15.0",
36
- "document-model": "2.14.0",
37
- "document-model-libs": "1.124.0"
36
+ "@powerhousedao/scalars": "1.16.0",
37
+ "document-model": "2.15.0",
38
+ "document-model-libs": "1.125.1"
38
39
  },
39
40
  "optionalDependencies": {
40
41
  "@prisma/client": "^5.18.0",
@@ -43,7 +44,7 @@
43
44
  "redis": "^4.6.15",
44
45
  "sequelize": "^6.37.3",
45
46
  "sqlite3": "^5.1.7",
46
- "@powerhousedao/scalars": "1.15.0"
47
+ "@powerhousedao/scalars": "1.16.0"
47
48
  },
48
49
  "devDependencies": {
49
50
  "@prisma/client": "5.17.0",
@@ -64,8 +65,8 @@
64
65
  "sqlite3": "^5.1.7",
65
66
  "vitest-fetch-mock": "^0.3.0",
66
67
  "webdriverio": "^9.0.9",
67
- "document-model": "2.14.0",
68
- "document-model-libs": "1.124.0"
68
+ "document-model": "2.15.0",
69
+ "document-model-libs": "1.125.1"
69
70
  },
70
71
  "scripts": {
71
72
  "check-types": "tsc --build",
@@ -1,51 +0,0 @@
1
- import { DocumentDriveStorage, DocumentStorage } from "../types";
2
- import { documentsTable, drivesTable } from "./schema";
3
- import { eq } from "drizzle-orm";
4
- import { NodePgDatabase } from "drizzle-orm/node-postgres";
5
- export const getDriveBySlug = async (db: NodePgDatabase, slug: string) => {
6
- const result = await db
7
- .select()
8
- .from(drivesTable)
9
- .where(eq(drivesTable.slug, slug));
10
- return result.length > 0 ? result[0] : null;
11
- };
12
-
13
- export const upsertDrive = async (
14
- db: NodePgDatabase,
15
- id: string,
16
- drive: DocumentDriveStorage,
17
- ) => {
18
- const [result] = await db
19
- .update(drivesTable)
20
- .set({
21
- id,
22
- })
23
- .where(eq(drivesTable.slug, drive.initialState.state.global.slug ?? id))
24
- .returning();
25
-
26
- if (result) {
27
- return result;
28
- }
29
-
30
- return db.insert(drivesTable).values({
31
- id,
32
- slug: drive.initialState.state.global.slug ?? id,
33
- });
34
- };
35
-
36
- export const createDocumentQuery = async (
37
- db: NodePgDatabase,
38
- driveId: string,
39
- documentId: string,
40
- document: DocumentStorage,
41
- ) => {
42
- return db.insert(documentsTable).values({
43
- name: document.name,
44
- documentType: document.documentType,
45
- driveId,
46
- initialState: JSON.stringify(document.initialState),
47
- lastModified: document.lastModified,
48
- revision: JSON.stringify(document.revision),
49
- id: documentId,
50
- });
51
- };
@@ -1,627 +0,0 @@
1
- import {
2
- DocumentDriveAction,
3
- DocumentDriveLocalState,
4
- DocumentDriveState,
5
- } from "document-model-libs/document-drive";
6
- import type {
7
- Action,
8
- AttachmentInput,
9
- BaseAction,
10
- Document,
11
- DocumentHeader,
12
- DocumentOperations,
13
- ExtendedState,
14
- FileRegistry,
15
- Operation,
16
- OperationScope,
17
- State,
18
- } from "document-model/document";
19
- import { IBackOffOptions } from "exponential-backoff";
20
- import { DriveNotFoundError } from "../server/error";
21
- import type { SynchronizationUnitQuery } from "../server/types";
22
- import { logger } from "../utils/logger";
23
- import {
24
- DocumentDriveStorage,
25
- DocumentStorage,
26
- IDriveStorage,
27
- IStorageDelegate,
28
- } from "./types";
29
-
30
- import {
31
- and,
32
- count,
33
- eq,
34
- ExtractTablesWithRelations,
35
- inArray,
36
- sql,
37
- } from "drizzle-orm";
38
- import {
39
- NodePgDatabase,
40
- NodePgQueryResultHKT,
41
- NodePgTransaction,
42
- } from "drizzle-orm/node-postgres";
43
- import {
44
- createDocumentQuery,
45
- getDriveBySlug,
46
- upsertDrive,
47
- } from "./drizzle/queries";
48
- import {
49
- attachmentsTable,
50
- documentsTable,
51
- drivesTable,
52
- operationsTable,
53
- } from "./drizzle/schema";
54
- import { randomUUID } from "crypto";
55
- import { PgQueryResultHKT, PgTransaction } from "drizzle-orm/pg-core";
56
-
57
- // type Transaction =
58
- // | Omit<
59
- // PrismaClient<Prisma.PrismaClientOptions, never>,
60
- // "$connect" | "$disconnect" | "$on" | "$transaction" | "$use" | "$extends"
61
- // >
62
- // | ExtendedPrismaClient;
63
-
64
- function storageToOperation(
65
- op: typeof operationsTable.$inferSelect & {
66
- attachments?: AttachmentInput[];
67
- },
68
- ): Operation {
69
- const operation: Operation = {
70
- id: op.opId || undefined,
71
- skip: op.skip,
72
- hash: op.hash,
73
- index: op.index,
74
- timestamp: new Date(op.timestamp).toISOString(),
75
- input: JSON.parse(op.input),
76
- type: op.type,
77
- scope: op.scope as OperationScope,
78
- resultingState: op.resultingState
79
- ? op.resultingState.toString()
80
- : undefined,
81
- attachments: op.attachments,
82
- };
83
- if (op.context) {
84
- operation.context = op.context;
85
- }
86
- return operation;
87
- }
88
-
89
- export type DrizzleStorageOptions = {
90
- transactionRetryBackoff?: IBackOffOptions;
91
- };
92
-
93
- function getRetryTransactionsClient<T extends NodePgDatabase>(
94
- db: T,
95
- backOffOptions?: Partial<IBackOffOptions>,
96
- ) {
97
- return db;
98
- }
99
-
100
- type ExtendedDrizzleClient = ReturnType<
101
- typeof getRetryTransactionsClient<NodePgDatabase>
102
- >;
103
-
104
- export class DrizzleStorage implements IDriveStorage {
105
- private db: NodePgDatabase;
106
- private delegate: IStorageDelegate | undefined;
107
-
108
- constructor(db: NodePgDatabase, options?: DrizzleStorageOptions) {
109
- const backOffOptions = options?.transactionRetryBackoff;
110
- this.db = getRetryTransactionsClient(db, {
111
- ...backOffOptions,
112
- jitter: backOffOptions?.jitter ?? "full",
113
- });
114
- }
115
-
116
- setStorageDelegate(delegate: IStorageDelegate): void {
117
- this.delegate = delegate;
118
- }
119
-
120
- async createDrive(id: string, drive: DocumentDriveStorage): Promise<void> {
121
- // drive for all drive documents
122
- await this.createDocument("drives", id, drive as DocumentStorage);
123
- await upsertDrive(this.db, id, drive);
124
- }
125
- async addDriveOperations(
126
- id: string,
127
- operations: Operation[],
128
- header: DocumentHeader,
129
- ): Promise<void> {
130
- await this.addDocumentOperations("drives", id, operations, header);
131
- }
132
-
133
- async addDriveOperationsWithTransaction(
134
- drive: string,
135
- callback: (document: DocumentDriveStorage) => Promise<{
136
- operations: Operation<DocumentDriveAction | BaseAction>[];
137
- header: DocumentHeader;
138
- }>,
139
- ) {
140
- return this.addDocumentOperationsWithTransaction(
141
- "drives",
142
- drive,
143
- (document) => callback(document as DocumentDriveStorage),
144
- );
145
- }
146
-
147
- async createDocument(
148
- drive: string,
149
- id: string,
150
- document: DocumentStorage,
151
- ): Promise<void> {
152
- await createDocumentQuery(this.db, drive, id, document);
153
- }
154
-
155
- private async _addDocumentOperations(
156
- tx: PgTransaction<
157
- NodePgQueryResultHKT,
158
- Record<string, never>,
159
- ExtractTablesWithRelations<Record<string, never>>
160
- >,
161
- drive: string,
162
- id: string,
163
- operations: Operation[],
164
- header: DocumentHeader,
165
- ): Promise<void> {
166
- try {
167
- await tx.insert(operationsTable).values(
168
- operations.map((op) => ({
169
- driveId: drive,
170
- id: randomUUID(),
171
- documentId: id,
172
- hash: op.hash,
173
- index: op.index,
174
- input: JSON.stringify(op.input),
175
- timestamp: op.timestamp,
176
- type: op.type,
177
- scope: op.scope,
178
- branch: "main",
179
- opId: op.id,
180
- skip: op.skip,
181
- context: op.context,
182
- resultingState: op.resultingState
183
- ? Buffer.from(JSON.stringify(op.resultingState))
184
- : undefined,
185
- })),
186
- );
187
-
188
- await tx
189
- .update(documentsTable)
190
- .set({
191
- lastModified: header.lastModified,
192
- revision: JSON.stringify(header.revision),
193
- })
194
- .where(
195
- and(eq(documentsTable.id, id), eq(documentsTable.driveId, drive)),
196
- );
197
-
198
- await Promise.all(
199
- operations
200
- .filter((o) => o.attachments?.length)
201
- .map((op) => {
202
- return tx
203
- .update(operationsTable)
204
- .set({
205
- driveId: drive,
206
- documentId: id,
207
- index: op.index,
208
- scope: op.scope,
209
- branch: "main",
210
- })
211
- .where(
212
- and(
213
- eq(operationsTable.documentId, id),
214
- eq(operationsTable.driveId, drive),
215
- ),
216
- );
217
- }),
218
- );
219
- } catch (e) {
220
- // P2002: Unique constraint failed
221
- // Operation with existing index
222
- // if (e instanceof PrismaClientKnownRequestError && e.code === "P2002") {
223
- // const existingOperation = await this.db.operation.findFirst({
224
- // where: {
225
- // AND: operations.map((op) => ({
226
- // driveId: drive,
227
- // documentId: id,
228
- // scope: op.scope,
229
- // branch: "main",
230
- // index: op.index,
231
- // })),
232
- // },
233
- // });
234
- // const conflictOp = operations.find(
235
- // (op) =>
236
- // existingOperation?.index === op.index &&
237
- // existingOperation.scope === op.scope
238
- // );
239
- // if (!existingOperation || !conflictOp) {
240
- // console.error(e);
241
- // throw e;
242
- // } else {
243
- // throw new ConflictOperationError(
244
- // storageToOperation(existingOperation),
245
- // conflictOp
246
- // );
247
- // }
248
- // } else {
249
- // throw e;
250
- // }
251
- console.error(e);
252
- throw e;
253
- }
254
- }
255
-
256
- async addDocumentOperationsWithTransaction(
257
- drive: string,
258
- id: string,
259
- callback: (document: DocumentStorage) => Promise<{
260
- operations: Operation[];
261
- header: DocumentHeader;
262
- newState?: State<any, any> | undefined;
263
- }>,
264
- ) {
265
- let result: {
266
- operations: Operation[];
267
- header: DocumentHeader;
268
- newState?: State<any, any> | undefined;
269
- } | null = null;
270
-
271
- await this.db.transaction(
272
- async (tx) => {
273
- const document = await this.getDocument(
274
- drive,
275
- id,
276
- tx as unknown as NodePgDatabase<Record<string, never>>,
277
- );
278
- if (!document) {
279
- throw new Error(`Document with id ${id} not found`);
280
- }
281
- result = await callback(document);
282
-
283
- const { operations, header, newState } = result;
284
- return this._addDocumentOperations(tx, drive, id, operations, header);
285
- },
286
- {
287
- accessMode: "read write",
288
- isolationLevel: "serializable",
289
- },
290
- );
291
-
292
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
293
- if (!result) {
294
- throw new Error("No operations were provided");
295
- }
296
-
297
- return result;
298
- }
299
-
300
- async addDocumentOperations(
301
- drive: string,
302
- id: string,
303
- operations: Operation[],
304
- header: DocumentHeader,
305
- ): Promise<void> {
306
- return this._addDocumentOperations(
307
- this.db as PgTransaction<
308
- NodePgQueryResultHKT,
309
- Record<string, never>,
310
- ExtractTablesWithRelations<Record<string, never>>
311
- >,
312
- drive,
313
- id,
314
- operations,
315
- header,
316
- );
317
- }
318
- async getDocuments(drive: string): Promise<string[]> {
319
- const docs: { id: string }[] = await this.db
320
- .select({ id: documentsTable.id })
321
- .from(documentsTable)
322
- .where(eq(documentsTable.driveId, drive));
323
-
324
- return docs.map((d) => d.id);
325
- }
326
-
327
- async checkDocumentExists(driveId: string, id: string) {
328
- const [result] = await this.db
329
- .select({ count: count() })
330
- .from(documentsTable)
331
- .where(
332
- and(eq(documentsTable.id, id), eq(documentsTable.driveId, driveId)),
333
- );
334
- if (!result) {
335
- return false;
336
- }
337
- return result.count > 0;
338
- }
339
-
340
- async getDocument(
341
- driveId: string,
342
- id: string,
343
- tx?: NodePgDatabase<Record<string, never>>,
344
- ) {
345
- const db = tx ?? this.db;
346
- const [result] = await db
347
- .select()
348
- .from(documentsTable)
349
- .where(
350
- and(eq(documentsTable.id, id), eq(documentsTable.driveId, driveId)),
351
- )
352
- .limit(1);
353
-
354
- if (result === null) {
355
- throw new Error(`Document with id ${id} not found`);
356
- }
357
-
358
- const cachedOperations = (await this.delegate?.getCachedOperations(
359
- driveId,
360
- id,
361
- )) ?? {
362
- global: [],
363
- local: [],
364
- };
365
- const scopeIndex = Object.keys(cachedOperations).reduceRight<
366
- Record<OperationScope, number>
367
- >(
368
- (acc, value) => {
369
- const scope = value as OperationScope;
370
- const lastIndex = cachedOperations[scope]?.at(-1)?.index ?? -1;
371
- acc[scope] = lastIndex;
372
- return acc;
373
- },
374
- { global: -1, local: -1 },
375
- );
376
-
377
- const conditions = Object.entries(scopeIndex).map(
378
- ([scope, index]) => `("scope" = '${scope}' AND "index" > ${index})`,
379
- );
380
- conditions.push(
381
- `("scope" NOT IN (${Object.keys(cachedOperations)
382
- .map((s) => `'${s}'`)
383
- .join(", ")}))`,
384
- );
385
-
386
- // retrieves operations with resulting state
387
- // for the last operation of each scope
388
- // TODO prevent SQL injection
389
- const queryOperations = await this.db.execute(
390
- sql`WITH ranked_operations AS (
391
- SELECT
392
- *,
393
- ROW_NUMBER() OVER (PARTITION BY scope ORDER BY index DESC) AS rn
394
- FROM "Operation"
395
- )
396
- SELECT
397
- "id",
398
- "opId",
399
- "scope",
400
- "branch",
401
- "index",
402
- "skip",
403
- "hash",
404
- "timestamp",
405
- "input",
406
- "type",
407
- "context",
408
- CASE
409
- WHEN rn = 1 THEN "resultingState"
410
- ELSE NULL
411
- END AS "resultingState"
412
- FROM ranked_operations
413
- WHERE "driveId" = ${driveId} AND "documentId" = ${id}
414
- AND (${conditions.join(" OR ")})
415
- ORDER BY scope, index;
416
- `,
417
- );
418
- const operationIds = queryOperations.map((o: Operation) => o.id);
419
- const attachments = await this.db
420
- .select()
421
- .from(attachmentsTable)
422
- .where(inArray(attachmentsTable.operationId, operationIds));
423
-
424
- // TODO add attachments from cached operations
425
- const fileRegistry: FileRegistry = {};
426
-
427
- const operationsByScope = queryOperations.reduce<
428
- DocumentOperations<Action>
429
- >(
430
- (
431
- acc: Record<string, Operation[]>,
432
- operation: typeof operationsTable.$inferSelect,
433
- ) => {
434
- const scope = operation.scope as OperationScope;
435
- if (!acc[scope]) {
436
- acc[scope] = [];
437
- }
438
- const result = storageToOperation(operation);
439
- result.attachments = attachments.filter(
440
- (a) => a.operationId === operation.id,
441
- );
442
- result.attachments.forEach(({ hash, ...file }) => {
443
- fileRegistry[hash] = file;
444
- });
445
- acc[scope].push(result);
446
- return acc;
447
- },
448
- cachedOperations,
449
- );
450
-
451
- const dbDoc = result;
452
- if (!dbDoc) {
453
- throw new Error("Document not found");
454
- }
455
- const doc: Document = {
456
- created: dbDoc.created,
457
- name: dbDoc.name ? dbDoc.name : "",
458
- documentType: dbDoc.documentType,
459
- initialState: JSON.parse(dbDoc.initialState) as ExtendedState<
460
- DocumentDriveState,
461
- DocumentDriveLocalState
462
- >,
463
- // @ts-expect-error TODO: fix as this should not be undefined
464
- state: undefined,
465
- lastModified: new Date(dbDoc.lastModified).toISOString(),
466
- operations: operationsByScope,
467
- clipboard: [],
468
- revision: JSON.parse(dbDoc.revision) as Record<OperationScope, number>,
469
- attachments: {},
470
- };
471
- return doc;
472
- }
473
-
474
- async deleteDocument(drive: string, id: string) {
475
- try {
476
- await this.db
477
- .delete(documentsTable)
478
- .where(
479
- and(eq(documentsTable.driveId, drive), eq(documentsTable.id, id)),
480
- );
481
- } catch (e: unknown) {
482
- console.error(e);
483
- throw e;
484
- }
485
- }
486
-
487
- async getDrives() {
488
- return this.getDocuments("drives");
489
- }
490
-
491
- async getDrive(id: string) {
492
- try {
493
- const doc = await this.getDocument("drives", id);
494
- return doc as DocumentDriveStorage;
495
- } catch (e) {
496
- logger.error(e);
497
- throw new DriveNotFoundError(id);
498
- }
499
- }
500
-
501
- async getDriveBySlug(slug: string) {
502
- const driveEntity = await getDriveBySlug(this.db, slug);
503
-
504
- if (!driveEntity) {
505
- throw new Error(`Drive with slug ${slug} not found`);
506
- }
507
-
508
- return this.getDrive(driveEntity.id);
509
- }
510
-
511
- async deleteDrive(id: string) {
512
- // delete drive and associated slug
513
- await this.db.delete(drivesTable).where(eq(drivesTable.id, id));
514
-
515
- // delete drive document and its operations
516
- await this.deleteDocument("drives", id);
517
-
518
- // deletes all documents of the drive
519
- await this.db.delete(documentsTable).where(eq(documentsTable.driveId, id));
520
- }
521
-
522
- async getOperationResultingState(
523
- driveId: string,
524
- documentId: string,
525
- index: number,
526
- scope: string,
527
- branch: string,
528
- ): Promise<unknown> {
529
- const [operation] = await this.db
530
- .select()
531
- .from(operationsTable)
532
- .where(
533
- and(
534
- eq(operationsTable.driveId, driveId),
535
- eq(operationsTable.documentId, documentId),
536
- eq(operationsTable.index, index),
537
- eq(operationsTable.scope, scope),
538
- eq(operationsTable.branch, branch),
539
- ),
540
- );
541
-
542
- return operation?.resultingState?.toString();
543
- }
544
-
545
- getDriveOperationResultingState(
546
- drive: string,
547
- index: number,
548
- scope: string,
549
- branch: string,
550
- ): Promise<unknown> {
551
- return this.getOperationResultingState(
552
- "drives",
553
- drive,
554
- index,
555
- scope,
556
- branch,
557
- );
558
- }
559
-
560
- async getSynchronizationUnitsRevision(
561
- units: SynchronizationUnitQuery[],
562
- ): Promise<
563
- {
564
- driveId: string;
565
- documentId: string;
566
- scope: string;
567
- branch: string;
568
- lastUpdated: string;
569
- revision: number;
570
- }[]
571
- > {
572
- // TODO add branch condition
573
- const whereClauses = units
574
- .map((_, index) => {
575
- return `("driveId" = $${index * 3 + 1} AND "documentId" = $${index * 3 + 2} AND "scope" = $${index * 3 + 3})`;
576
- })
577
- .join(" OR ");
578
-
579
- const query = `
580
- SELECT "driveId", "documentId", "scope", "branch", MAX("timestamp") as "lastUpdated", MAX("index") as revision FROM "Operation"
581
- WHERE ${whereClauses}
582
- GROUP BY "driveId", "documentId", "scope", "branch"
583
- `;
584
-
585
- const params = units
586
- .map((unit) => [
587
- unit.documentId ? unit.driveId : "drives",
588
- unit.documentId || unit.driveId,
589
- unit.scope,
590
- ])
591
- .flat();
592
- const results = await this.db.$queryRawUnsafe<
593
- {
594
- driveId: string;
595
- documentId: string;
596
- lastUpdated: string;
597
- scope: OperationScope;
598
- branch: string;
599
- revision: number;
600
- }[]
601
- >(query, ...params);
602
- return results.map((row) => ({
603
- ...row,
604
- driveId: row.driveId === "drives" ? row.documentId : row.driveId,
605
- documentId: row.driveId === "drives" ? "" : row.documentId,
606
- lastUpdated: new Date(row.lastUpdated).toISOString(),
607
- }));
608
- }
609
-
610
- // migrates all stored operations from legacy signature to signatures array
611
- async migrateOperationSignatures() {
612
- const count = await this.db.$executeRaw`
613
- UPDATE "Operation"
614
- SET context = jsonb_set(
615
- context #- '{signer,signature}', -- Remove the old 'signature' field
616
- '{signer,signatures}', -- Path to the new 'signatures' field
617
- CASE
618
- WHEN context->'signer'->>'signature' = '' THEN '[]'::jsonb
619
- ELSE to_jsonb(array[context->'signer'->>'signature'])
620
- END
621
- )
622
- WHERE context->'signer' ? 'signature' -- Check if the 'signature' key exists
623
- `;
624
- logger.info(`Migrated ${count} operations`);
625
- return;
626
- }
627
- }