document-drive 1.0.0-experimental.8 → 1.0.0-websockets

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.
@@ -1,32 +1,53 @@
1
- import { PrismaClient, Prisma } from '@prisma/client';
1
+ import { Prisma, PrismaClient } from '@prisma/client';
2
2
  import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library';
3
- import { backOff, IBackOffOptions } from "exponential-backoff";
4
3
  import {
5
4
  DocumentDriveAction,
6
5
  DocumentDriveLocalState,
7
6
  DocumentDriveState
8
7
  } from 'document-model-libs/document-drive';
9
8
  import type {
9
+ Action,
10
+ AttachmentInput,
10
11
  BaseAction,
12
+ Document,
11
13
  DocumentHeader,
14
+ DocumentOperations,
12
15
  ExtendedState,
16
+ FileRegistry,
13
17
  Operation,
14
18
  OperationScope,
15
19
  State
16
20
  } from 'document-model/document';
21
+ import { IBackOffOptions, backOff } from 'exponential-backoff';
17
22
  import { ConflictOperationError } from '../server/error';
23
+ import type { SynchronizationUnitQuery } from '../server/types';
18
24
  import { logger } from '../utils/logger';
19
- import { DocumentDriveStorage, DocumentStorage, IDriveStorage } from './types';
20
-
21
- type Transaction = Omit<
22
- PrismaClient<Prisma.PrismaClientOptions, never>,
23
- '$connect' | '$disconnect' | '$on' | '$transaction' | '$use' | '$extends'
24
- > | ExtendedPrismaClient;
25
+ import {
26
+ DocumentDriveStorage,
27
+ DocumentStorage,
28
+ IDriveStorage,
29
+ IStorageDelegate
30
+ } from './types';
31
+
32
+ type Transaction =
33
+ | Omit<
34
+ PrismaClient<Prisma.PrismaClientOptions, never>,
35
+ | '$connect'
36
+ | '$disconnect'
37
+ | '$on'
38
+ | '$transaction'
39
+ | '$use'
40
+ | '$extends'
41
+ >
42
+ | ExtendedPrismaClient;
25
43
 
26
44
  function storageToOperation(
27
- op: Prisma.$OperationPayload['scalars']
45
+ op: Prisma.$OperationPayload['scalars'] & {
46
+ attachments?: AttachmentInput[];
47
+ }
28
48
  ): Operation {
29
49
  const operation: Operation = {
50
+ id: op.opId || undefined,
30
51
  skip: op.skip,
31
52
  hash: op.hash,
32
53
  index: op.index,
@@ -34,7 +55,10 @@ function storageToOperation(
34
55
  input: JSON.parse(op.input),
35
56
  type: op.type,
36
57
  scope: op.scope as OperationScope,
37
- // attachments: fileRegistry
58
+ resultingState: op.resultingState
59
+ ? op.resultingState.toString()
60
+ : undefined,
61
+ attachments: op.attachments
38
62
  };
39
63
  if (op.context) {
40
64
  operation.context = op.context as Prisma.JsonObject;
@@ -44,38 +68,47 @@ function storageToOperation(
44
68
 
45
69
  export type PrismaStorageOptions = {
46
70
  transactionRetryBackoff?: IBackOffOptions;
47
- }
71
+ };
48
72
 
49
- function getRetryTransactionsClient<T extends PrismaClient>(prisma: T, backOffOptions?: Partial<IBackOffOptions>) {
73
+ function getRetryTransactionsClient<T extends PrismaClient>(
74
+ prisma: T,
75
+ backOffOptions?: Partial<IBackOffOptions>
76
+ ) {
50
77
  return prisma.$extends({
51
78
  client: {
52
- $transaction: (...args: Parameters<T["$transaction"]>) => {
79
+ $transaction: (...args: Parameters<T['$transaction']>) => {
53
80
  // eslint-disable-next-line prefer-spread
54
81
  return backOff(() => prisma.$transaction.apply(prisma, args), {
55
- retry: (e) => {
82
+ retry: e => {
56
83
  // Retry the transaction only if the error was due to a write conflict or deadlock
57
84
  // See: https://www.prisma.io/docs/reference/api-reference/error-reference#p2034
58
- return (e as { code: string }).code === "P2034";
85
+ return (e as { code: string }).code === 'P2034';
59
86
  },
60
- ...backOffOptions,
87
+ ...backOffOptions
61
88
  });
62
89
  }
63
90
  }
64
91
  });
65
92
  }
66
93
 
67
- type ExtendedPrismaClient = ReturnType<typeof getRetryTransactionsClient<PrismaClient>>;
94
+ type ExtendedPrismaClient = ReturnType<
95
+ typeof getRetryTransactionsClient<PrismaClient>
96
+ >;
68
97
 
69
98
  export class PrismaStorage implements IDriveStorage {
70
99
  private db: ExtendedPrismaClient;
100
+ private delegate: IStorageDelegate | undefined;
71
101
 
72
102
  constructor(db: PrismaClient, options?: PrismaStorageOptions) {
73
103
  const backOffOptions = options?.transactionRetryBackoff;
74
104
  this.db = getRetryTransactionsClient(db, {
75
105
  ...backOffOptions,
76
- jitter: backOffOptions?.jitter ?? "full"
106
+ jitter: backOffOptions?.jitter ?? 'full'
77
107
  });
108
+ }
78
109
 
110
+ setStorageDelegate(delegate: IStorageDelegate): void {
111
+ this.delegate = delegate;
79
112
  }
80
113
 
81
114
  async createDrive(id: string, drive: DocumentDriveStorage): Promise<void> {
@@ -133,11 +166,10 @@ export class PrismaStorage implements IDriveStorage {
133
166
  name: document.name,
134
167
  documentType: document.documentType,
135
168
  driveId: drive,
136
- initialState: document.initialState as Prisma.InputJsonObject,
169
+ initialState: JSON.stringify(document.initialState),
137
170
  lastModified: document.lastModified,
138
- revision: document.revision,
139
- id,
140
- state: document.initialState
171
+ revision: JSON.stringify(document.revision),
172
+ id
141
173
  }
142
174
  });
143
175
  }
@@ -147,14 +179,8 @@ export class PrismaStorage implements IDriveStorage {
147
179
  drive: string,
148
180
  id: string,
149
181
  operations: Operation[],
150
- header: DocumentHeader,
151
- newState: State<any, any> | undefined = undefined
182
+ header: DocumentHeader
152
183
  ): Promise<void> {
153
- const document = await this.getDocument(drive, id, tx);
154
- if (!document) {
155
- throw new Error(`Document with id ${id} not found`);
156
- }
157
-
158
184
  try {
159
185
  await tx.operation.createMany({
160
186
  data: operations.map(op => ({
@@ -168,7 +194,10 @@ export class PrismaStorage implements IDriveStorage {
168
194
  scope: op.scope,
169
195
  branch: 'main',
170
196
  skip: op.skip,
171
- context: op.context
197
+ context: op.context,
198
+ resultingState: op.resultingState
199
+ ? Buffer.from(JSON.stringify(op.resultingState))
200
+ : undefined
172
201
  }))
173
202
  });
174
203
 
@@ -179,10 +208,34 @@ export class PrismaStorage implements IDriveStorage {
179
208
  },
180
209
  data: {
181
210
  lastModified: header.lastModified,
182
- revision: header.revision,
183
- state: newState
211
+ revision: JSON.stringify(header.revision)
184
212
  }
185
213
  });
214
+
215
+ await Promise.all(
216
+ operations
217
+ .filter(o => o.attachments?.length)
218
+ .map(op => {
219
+ return tx.operation.update({
220
+ where: {
221
+ unique_operation: {
222
+ driveId: drive,
223
+ documentId: id,
224
+ index: op.index,
225
+ scope: op.scope,
226
+ branch: 'main'
227
+ }
228
+ },
229
+ data: {
230
+ attachments: {
231
+ createMany: {
232
+ data: op.attachments ?? []
233
+ }
234
+ }
235
+ }
236
+ });
237
+ })
238
+ );
186
239
  } catch (e) {
187
240
  // P2002: Unique constraint failed
188
241
  // Operation with existing index
@@ -209,6 +262,7 @@ export class PrismaStorage implements IDriveStorage {
209
262
  );
210
263
 
211
264
  if (!existingOperation || !conflictOp) {
265
+ console.error(e);
212
266
  throw e;
213
267
  } else {
214
268
  throw new ConflictOperationError(
@@ -228,7 +282,7 @@ export class PrismaStorage implements IDriveStorage {
228
282
  callback: (document: DocumentStorage) => Promise<{
229
283
  operations: Operation[];
230
284
  header: DocumentHeader;
231
- newState?: State<any, any> | undefined
285
+ newState?: State<any, any> | undefined;
232
286
  }>
233
287
  ) {
234
288
  let result: {
@@ -237,24 +291,25 @@ export class PrismaStorage implements IDriveStorage {
237
291
  newState?: State<any, any> | undefined;
238
292
  } | null = null;
239
293
 
240
- await this.db.$transaction(async tx => {
241
- const document = await this.getDocument(drive, id, tx);
242
- if (!document) {
243
- throw new Error(`Document with id ${id} not found`);
244
- }
245
- result = await callback(document);
246
-
247
- const { operations, header, newState } = result;
248
- return this._addDocumentOperations(
249
- tx,
250
- drive,
251
- id,
252
- operations,
253
- header,
254
- newState
255
- );
256
- }, { isolationLevel: "Serializable" });
294
+ await this.db.$transaction(
295
+ async tx => {
296
+ const document = await this.getDocument(drive, id, tx);
297
+ if (!document) {
298
+ throw new Error(`Document with id ${id} not found`);
299
+ }
300
+ result = await callback(document);
257
301
 
302
+ const { operations, header, newState } = result;
303
+ return this._addDocumentOperations(
304
+ tx,
305
+ drive,
306
+ id,
307
+ operations,
308
+ header
309
+ );
310
+ },
311
+ { isolationLevel: 'Serializable' }
312
+ );
258
313
 
259
314
  // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
260
315
  if (!result) {
@@ -268,19 +323,22 @@ export class PrismaStorage implements IDriveStorage {
268
323
  drive: string,
269
324
  id: string,
270
325
  operations: Operation[],
271
- header: DocumentHeader,
326
+ header: DocumentHeader
272
327
  ): Promise<void> {
273
328
  return this._addDocumentOperations(
274
329
  this.db,
275
330
  drive,
276
331
  id,
277
332
  operations,
278
- header,
333
+ header
279
334
  );
280
335
  }
281
336
 
282
337
  async getDocuments(drive: string) {
283
338
  const docs = await this.db.document.findMany({
339
+ select: {
340
+ id: true
341
+ },
284
342
  where: {
285
343
  AND: {
286
344
  driveId: drive,
@@ -299,25 +357,18 @@ export class PrismaStorage implements IDriveStorage {
299
357
  where: {
300
358
  id: id,
301
359
  driveId: driveId
302
- },
360
+ }
303
361
  });
304
362
  return count > 0;
305
363
  }
306
364
 
307
365
  async getDocument(driveId: string, id: string, tx?: Transaction) {
308
- const result = await (tx ?? this.db).document.findFirst({
366
+ const prisma = tx ?? this.db;
367
+ const result = await prisma.document.findUnique({
309
368
  where: {
310
- id: id,
311
- driveId: driveId
312
- },
313
- include: {
314
- operations: {
315
- orderBy: {
316
- index: 'asc'
317
- },
318
- include: {
319
- attachments: true
320
- }
369
+ id_driveId: {
370
+ driveId,
371
+ id
321
372
  }
322
373
  }
323
374
  });
@@ -326,31 +377,119 @@ export class PrismaStorage implements IDriveStorage {
326
377
  throw new Error(`Document with id ${id} not found`);
327
378
  }
328
379
 
380
+ const cachedOperations = (await this.delegate?.getCachedOperations(
381
+ driveId,
382
+ id
383
+ )) ?? {
384
+ global: [],
385
+ local: []
386
+ };
387
+ const scopeIndex = Object.keys(cachedOperations).reduceRight<
388
+ Record<OperationScope, number>
389
+ >(
390
+ (acc, value) => {
391
+ const scope = value as OperationScope;
392
+ const lastIndex = cachedOperations[scope]?.at(-1)?.index ?? -1;
393
+ acc[scope] = lastIndex;
394
+ return acc;
395
+ },
396
+ { global: -1, local: -1 }
397
+ );
398
+
399
+ const conditions = Object.entries(scopeIndex).map(
400
+ ([scope, index]) => `("scope" = '${scope}' AND "index" > ${index})`
401
+ );
402
+ conditions.push(
403
+ `("scope" NOT IN (${Object.keys(cachedOperations)
404
+ .map(s => `'${s}'`)
405
+ .join(', ')}))`
406
+ );
407
+
408
+ // retrieves operations with resulting state
409
+ // for the last operation of each scope
410
+ // TODO prevent SQL injection
411
+ const queryOperations = await prisma.$queryRawUnsafe<
412
+ Prisma.$OperationPayload['scalars'][]
413
+ >(
414
+ `WITH ranked_operations AS (
415
+ SELECT
416
+ *,
417
+ ROW_NUMBER() OVER (PARTITION BY scope ORDER BY index DESC) AS rn
418
+ FROM "Operation"
419
+ )
420
+ SELECT
421
+ "id",
422
+ "opId",
423
+ "scope",
424
+ "branch",
425
+ "index",
426
+ "skip",
427
+ "hash",
428
+ "timestamp",
429
+ "input",
430
+ "type",
431
+ "context",
432
+ CASE
433
+ WHEN rn = 1 THEN "resultingState"
434
+ ELSE NULL
435
+ END AS "resultingState"
436
+ FROM ranked_operations
437
+ WHERE "driveId" = $1 AND "documentId" = $2
438
+ AND (${conditions.join(' OR ')})
439
+ ORDER BY scope, index;
440
+ `,
441
+ driveId,
442
+ id
443
+ );
444
+ const operationIds = queryOperations.map(o => o.id);
445
+ const attachments = await prisma.attachment.findMany({
446
+ where: {
447
+ operationId: {
448
+ in: operationIds
449
+ }
450
+ }
451
+ });
452
+
453
+ // TODO add attachments from cached operations
454
+ const fileRegistry: FileRegistry = {};
455
+
456
+ const operationsByScope = queryOperations.reduce<
457
+ DocumentOperations<Action>
458
+ >((acc, operation) => {
459
+ const scope = operation.scope as OperationScope;
460
+ if (!acc[scope]) {
461
+ acc[scope] = [];
462
+ }
463
+ const result = storageToOperation(operation);
464
+ result.attachments = attachments.filter(
465
+ a => a.operationId === operation.id
466
+ );
467
+ result.attachments.forEach(({ hash, ...file }) => {
468
+ fileRegistry[hash] = file;
469
+ });
470
+ acc[scope].push(result);
471
+ return acc;
472
+ }, cachedOperations);
473
+
329
474
  const dbDoc = result;
330
- const doc = {
475
+ const doc: Document = {
331
476
  created: dbDoc.created.toISOString(),
332
477
  name: dbDoc.name ? dbDoc.name : '',
333
478
  documentType: dbDoc.documentType,
334
- initialState: dbDoc.initialState as ExtendedState<
479
+ initialState: JSON.parse(dbDoc.initialState) as ExtendedState<
335
480
  DocumentDriveState,
336
481
  DocumentDriveLocalState
337
482
  >,
338
- state: dbDoc.state as State<unknown, unknown>,
483
+ state: undefined,
339
484
  lastModified: new Date(dbDoc.lastModified).toISOString(),
340
- operations: {
341
- global: dbDoc.operations
342
- .filter(op => op.scope === 'global' && !op.clipboard)
343
- .map(storageToOperation),
344
- local: dbDoc.operations
345
- .filter(op => op.scope === 'local' && !op.clipboard)
346
- .map(storageToOperation)
347
- },
348
- clipboard: dbDoc.operations
349
- .filter(op => op.clipboard)
350
- .map(storageToOperation),
351
- revision: dbDoc.revision as Record<OperationScope, number>
485
+ operations: operationsByScope,
486
+ clipboard: [],
487
+ revision: JSON.parse(dbDoc.revision) as Record<
488
+ OperationScope,
489
+ number
490
+ >,
491
+ attachments: {}
352
492
  };
353
-
354
493
  return doc;
355
494
  }
356
495
 
@@ -401,11 +540,107 @@ export class PrismaStorage implements IDriveStorage {
401
540
  }
402
541
 
403
542
  async deleteDrive(id: string) {
543
+ // delete drive documents and operations
404
544
  await this.db.document.deleteMany({
405
545
  where: {
406
546
  driveId: id
407
547
  }
408
548
  });
549
+
550
+ // delete drive and associated slug
551
+ await this.db.drive.deleteMany({
552
+ where: {
553
+ id
554
+ }
555
+ });
556
+
557
+ // delete drive itself
409
558
  await this.deleteDocument('drives', id);
410
559
  }
560
+
561
+ async getOperationResultingState(
562
+ driveId: string,
563
+ documentId: string,
564
+ index: number,
565
+ scope: string,
566
+ branch: string
567
+ ): Promise<unknown> {
568
+ const operation = await this.db.operation.findUnique({
569
+ where: {
570
+ unique_operation: {
571
+ driveId,
572
+ documentId,
573
+ index,
574
+ scope,
575
+ branch
576
+ }
577
+ }
578
+ });
579
+ return operation?.resultingState?.toString();
580
+ }
581
+
582
+ getDriveOperationResultingState(
583
+ drive: string,
584
+ index: number,
585
+ scope: string,
586
+ branch: string
587
+ ): Promise<unknown> {
588
+ return this.getOperationResultingState(
589
+ 'drives',
590
+ drive,
591
+ index,
592
+ scope,
593
+ branch
594
+ );
595
+ }
596
+
597
+ async getSynchronizationUnitsRevision(
598
+ units: SynchronizationUnitQuery[]
599
+ ): Promise<
600
+ {
601
+ driveId: string;
602
+ documentId: string;
603
+ scope: string;
604
+ branch: string;
605
+ lastUpdated: string;
606
+ revision: number;
607
+ }[]
608
+ > {
609
+ // TODO add branch condition
610
+ const whereClauses = units
611
+ .map((_, index) => {
612
+ return `("driveId" = $${index * 3 + 1} AND "documentId" = $${index * 3 + 2} AND "scope" = $${index * 3 + 3})`;
613
+ })
614
+ .join(' OR ');
615
+
616
+ const query = `
617
+ SELECT "driveId", "documentId", "scope", "branch", MAX("timestamp") as "lastUpdated", MAX("index") as revision FROM "Operation"
618
+ WHERE ${whereClauses}
619
+ GROUP BY "driveId", "documentId", "scope", "branch"
620
+ `;
621
+
622
+ const params = units
623
+ .map(unit => [
624
+ unit.documentId ? unit.driveId : 'drives',
625
+ unit.documentId || unit.driveId,
626
+ unit.scope
627
+ ])
628
+ .flat();
629
+ const results = await this.db.$queryRawUnsafe<
630
+ {
631
+ driveId: string;
632
+ documentId: string;
633
+ lastUpdated: string;
634
+ scope: OperationScope;
635
+ branch: string;
636
+ revision: number;
637
+ }[]
638
+ >(query, ...params);
639
+ return results.map(row => ({
640
+ ...row,
641
+ driveId: row.driveId === 'drives' ? row.documentId : row.driveId,
642
+ documentId: row.driveId === 'drives' ? '' : row.documentId,
643
+ lastUpdated: new Date(row.lastUpdated).toISOString()
644
+ }));
645
+ }
411
646
  }
@@ -11,6 +11,7 @@ import {
11
11
  } from 'document-model/document';
12
12
  import { DataTypes, Options, Sequelize } from 'sequelize';
13
13
  import { DocumentDriveStorage, DocumentStorage, IDriveStorage } from './types';
14
+ import type { SynchronizationUnitQuery } from '../server/types';
14
15
 
15
16
  export class SequelizeStorage implements IDriveStorage {
16
17
  private db: Sequelize;
@@ -25,8 +26,8 @@ export class SequelizeStorage implements IDriveStorage {
25
26
  type: DataTypes.STRING,
26
27
  primaryKey: true
27
28
  },
28
- id: DataTypes.STRING,
29
- })
29
+ id: DataTypes.STRING
30
+ });
30
31
  const Document = this.db.define('document', {
31
32
  id: {
32
33
  type: DataTypes.STRING,
@@ -155,7 +156,7 @@ export class SequelizeStorage implements IDriveStorage {
155
156
  drive: string,
156
157
  id: string,
157
158
  operations: Operation[],
158
- header: DocumentHeader,
159
+ header: DocumentHeader
159
160
  ): Promise<void> {
160
161
  const document = await this.getDocument(drive, id);
161
162
  if (!document) {
@@ -177,7 +178,8 @@ export class SequelizeStorage implements IDriveStorage {
177
178
  timestamp: op.timestamp,
178
179
  type: op.type,
179
180
  scope: op.scope,
180
- branch: 'main'
181
+ branch: 'main',
182
+ opId: op.id
181
183
  }))
182
184
  );
183
185
 
@@ -284,8 +286,8 @@ export class SequelizeStorage implements IDriveStorage {
284
286
  where: {
285
287
  id: id,
286
288
  driveId: driveId
287
- },
288
- })
289
+ }
290
+ });
289
291
 
290
292
  return count > 0;
291
293
  }
@@ -323,6 +325,7 @@ export class SequelizeStorage implements IDriveStorage {
323
325
  input: JSON;
324
326
  type: string;
325
327
  scope: string;
328
+ opId?: string;
326
329
  }
327
330
  ];
328
331
  revision: Required<Record<OperationScope, number>>;
@@ -348,13 +351,15 @@ export class SequelizeStorage implements IDriveStorage {
348
351
  input: JSON;
349
352
  type: string;
350
353
  scope: string;
354
+ opId?: string;
351
355
  }) => ({
352
356
  hash: op.hash,
353
357
  index: op.index,
354
358
  timestamp: new Date(op.timestamp).toISOString(),
355
359
  input: op.input,
356
360
  type: op.type,
357
- scope: op.scope as OperationScope
361
+ scope: op.scope as OperationScope,
362
+ id: op.opId
358
363
  // attachments: fileRegistry
359
364
  })
360
365
  );
@@ -444,4 +449,61 @@ export class SequelizeStorage implements IDriveStorage {
444
449
  });
445
450
  }
446
451
  }
452
+
453
+ async getSynchronizationUnitsRevision(
454
+ units: SynchronizationUnitQuery[]
455
+ ): Promise<
456
+ {
457
+ driveId: string;
458
+ documentId: string;
459
+ scope: string;
460
+ branch: string;
461
+ lastUpdated: string;
462
+ revision: number;
463
+ }[]
464
+ > {
465
+ const results = await Promise.allSettled(
466
+ units.map(async unit => {
467
+ try {
468
+ const document = await (unit.documentId
469
+ ? this.getDocument(unit.driveId, unit.documentId)
470
+ : this.getDrive(unit.driveId));
471
+ if (!document) {
472
+ return undefined;
473
+ }
474
+ const operation =
475
+ document.operations[unit.scope as OperationScope]?.at(
476
+ -1
477
+ );
478
+ if (operation) {
479
+ return {
480
+ driveId: unit.driveId,
481
+ documentId: unit.documentId,
482
+ scope: unit.scope,
483
+ branch: unit.branch,
484
+ lastUpdated: operation.timestamp,
485
+ revision: operation.index
486
+ };
487
+ }
488
+ } catch {
489
+ return undefined;
490
+ }
491
+ })
492
+ );
493
+ return results.reduce<
494
+ {
495
+ driveId: string;
496
+ documentId: string;
497
+ scope: string;
498
+ branch: string;
499
+ lastUpdated: string;
500
+ revision: number;
501
+ }[]
502
+ >((acc, curr) => {
503
+ if (curr.status === 'fulfilled' && curr.value !== undefined) {
504
+ acc.push(curr.value);
505
+ }
506
+ return acc;
507
+ }, []);
508
+ }
447
509
  }