document-drive 1.0.0-websockets.1 → 1.0.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.
Files changed (43) hide show
  1. package/README.md +1 -0
  2. package/package.json +74 -88
  3. package/src/cache/index.ts +2 -2
  4. package/src/cache/memory.ts +22 -13
  5. package/src/cache/redis.ts +43 -16
  6. package/src/cache/types.ts +4 -4
  7. package/src/index.ts +6 -3
  8. package/src/queue/base.ts +276 -214
  9. package/src/queue/index.ts +2 -2
  10. package/src/queue/redis.ts +138 -127
  11. package/src/queue/types.ts +44 -38
  12. package/src/read-mode/errors.ts +19 -0
  13. package/src/read-mode/index.ts +125 -0
  14. package/src/read-mode/service.ts +207 -0
  15. package/src/read-mode/types.ts +108 -0
  16. package/src/server/error.ts +61 -26
  17. package/src/server/index.ts +2160 -1785
  18. package/src/server/listener/index.ts +2 -2
  19. package/src/server/listener/manager.ts +475 -437
  20. package/src/server/listener/transmitter/index.ts +4 -5
  21. package/src/server/listener/transmitter/internal.ts +77 -79
  22. package/src/server/listener/transmitter/pull-responder.ts +363 -329
  23. package/src/server/listener/transmitter/switchboard-push.ts +72 -55
  24. package/src/server/listener/transmitter/types.ts +19 -25
  25. package/src/server/types.ts +536 -349
  26. package/src/server/utils.ts +26 -27
  27. package/src/storage/base.ts +81 -0
  28. package/src/storage/browser.ts +233 -216
  29. package/src/storage/filesystem.ts +257 -256
  30. package/src/storage/index.ts +2 -1
  31. package/src/storage/memory.ts +206 -214
  32. package/src/storage/prisma.ts +575 -568
  33. package/src/storage/sequelize.ts +460 -471
  34. package/src/storage/types.ts +83 -67
  35. package/src/utils/default-drives-manager.ts +341 -0
  36. package/src/utils/document-helpers.ts +19 -18
  37. package/src/utils/graphql.ts +288 -34
  38. package/src/utils/index.ts +61 -59
  39. package/src/utils/logger.ts +39 -37
  40. package/src/utils/migrations.ts +58 -0
  41. package/src/utils/run-asap.ts +156 -0
  42. package/CHANGELOG.md +0 -818
  43. package/src/server/listener/transmitter/subscription.ts +0 -364
@@ -1,417 +1,402 @@
1
- import { Prisma, PrismaClient } from '@prisma/client';
2
- import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library';
1
+ import { Prisma, PrismaClient } from "@prisma/client";
2
+ import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
3
3
  import {
4
- DocumentDriveAction,
5
- DocumentDriveLocalState,
6
- DocumentDriveState
7
- } from 'document-model-libs/document-drive';
4
+ DocumentDriveAction,
5
+ DocumentDriveLocalState,
6
+ DocumentDriveState,
7
+ } from "document-model-libs/document-drive";
8
8
  import type {
9
- Action,
10
- AttachmentInput,
11
- BaseAction,
12
- Document,
13
- DocumentHeader,
14
- DocumentOperations,
15
- ExtendedState,
16
- FileRegistry,
17
- Operation,
18
- OperationScope,
19
- State
20
- } from 'document-model/document';
21
- import { IBackOffOptions, backOff } from 'exponential-backoff';
22
- import { ConflictOperationError } from '../server/error';
23
- import type { SynchronizationUnitQuery } from '../server/types';
24
- import { logger } from '../utils/logger';
9
+ Action,
10
+ AttachmentInput,
11
+ BaseAction,
12
+ Document,
13
+ DocumentHeader,
14
+ DocumentOperations,
15
+ ExtendedState,
16
+ FileRegistry,
17
+ Operation,
18
+ OperationScope,
19
+ State,
20
+ } from "document-model/document";
21
+ import { IBackOffOptions, backOff } from "exponential-backoff";
22
+ import { ConflictOperationError, DriveNotFoundError } from "../server/error";
23
+ import type { SynchronizationUnitQuery } from "../server/types";
24
+ import { logger } from "../utils/logger";
25
25
  import {
26
- DocumentDriveStorage,
27
- DocumentStorage,
28
- IDriveStorage,
29
- IStorageDelegate
30
- } from './types';
26
+ DocumentDriveStorage,
27
+ DocumentStorage,
28
+ IDriveStorage,
29
+ IStorageDelegate,
30
+ } from "./types";
31
31
 
32
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;
33
+ | Omit<
34
+ PrismaClient<Prisma.PrismaClientOptions, never>,
35
+ "$connect" | "$disconnect" | "$on" | "$transaction" | "$use" | "$extends"
36
+ >
37
+ | ExtendedPrismaClient;
43
38
 
44
39
  function storageToOperation(
45
- op: Prisma.$OperationPayload['scalars'] & {
46
- attachments?: AttachmentInput[];
47
- }
40
+ op: Prisma.$OperationPayload["scalars"] & {
41
+ attachments?: AttachmentInput[];
42
+ },
48
43
  ): Operation {
49
- const operation: Operation = {
50
- id: op.opId || undefined,
51
- skip: op.skip,
52
- hash: op.hash,
53
- index: op.index,
54
- timestamp: new Date(op.timestamp).toISOString(),
55
- input: JSON.parse(op.input),
56
- type: op.type,
57
- scope: op.scope as OperationScope,
58
- resultingState: op.resultingState
59
- ? op.resultingState.toString()
60
- : undefined,
61
- attachments: op.attachments
62
- };
63
- if (op.context) {
64
- operation.context = op.context as Prisma.JsonObject;
65
- }
66
- return operation;
44
+ const operation: Operation = {
45
+ id: op.opId || undefined,
46
+ skip: op.skip,
47
+ hash: op.hash,
48
+ index: op.index,
49
+ timestamp: new Date(op.timestamp).toISOString(),
50
+ input: JSON.parse(op.input),
51
+ type: op.type,
52
+ scope: op.scope as OperationScope,
53
+ resultingState: op.resultingState
54
+ ? op.resultingState.toString()
55
+ : undefined,
56
+ attachments: op.attachments,
57
+ };
58
+ if (op.context) {
59
+ operation.context = op.context as Prisma.JsonObject;
60
+ }
61
+ return operation;
67
62
  }
68
63
 
69
64
  export type PrismaStorageOptions = {
70
- transactionRetryBackoff?: IBackOffOptions;
65
+ transactionRetryBackoff?: IBackOffOptions;
71
66
  };
72
67
 
73
68
  function getRetryTransactionsClient<T extends PrismaClient>(
74
- prisma: T,
75
- backOffOptions?: Partial<IBackOffOptions>
69
+ prisma: T,
70
+ backOffOptions?: Partial<IBackOffOptions>,
76
71
  ) {
77
- return prisma.$extends({
78
- client: {
79
- $transaction: (...args: Parameters<T['$transaction']>) => {
80
- // eslint-disable-next-line prefer-spread
81
- return backOff(() => prisma.$transaction.apply(prisma, args), {
82
- retry: e => {
83
- // Retry the transaction only if the error was due to a write conflict or deadlock
84
- // See: https://www.prisma.io/docs/reference/api-reference/error-reference#p2034
85
- return (e as { code: string }).code === 'P2034';
86
- },
87
- ...backOffOptions
88
- });
72
+ return prisma.$extends({
73
+ client: {
74
+ $transaction: (...args: Parameters<T["$transaction"]>) => {
75
+ // eslint-disable-next-line prefer-spread
76
+ return backOff(() => prisma.$transaction.apply(prisma, args), {
77
+ retry: (e) => {
78
+ const code = (e as { code: string }).code;
79
+ // Retry the transaction only if the error was due to a write conflict or deadlock
80
+ // See: https://www.prisma.io/docs/reference/api-reference/error-reference#p2034
81
+ if (code !== "P2034") {
82
+ logger.error("TRANSACTION ERROR", e);
89
83
  }
90
- }
91
- });
84
+ return code === "P2034";
85
+ },
86
+ ...backOffOptions,
87
+ });
88
+ },
89
+ },
90
+ });
92
91
  }
93
92
 
94
93
  type ExtendedPrismaClient = ReturnType<
95
- typeof getRetryTransactionsClient<PrismaClient>
94
+ typeof getRetryTransactionsClient<PrismaClient>
96
95
  >;
97
96
 
98
97
  export class PrismaStorage implements IDriveStorage {
99
- private db: ExtendedPrismaClient;
100
- private delegate: IStorageDelegate | undefined;
101
-
102
- constructor(db: PrismaClient, options?: PrismaStorageOptions) {
103
- const backOffOptions = options?.transactionRetryBackoff;
104
- this.db = getRetryTransactionsClient(db, {
105
- ...backOffOptions,
106
- jitter: backOffOptions?.jitter ?? 'full'
107
- });
108
- }
109
-
110
- setStorageDelegate(delegate: IStorageDelegate): void {
111
- this.delegate = delegate;
112
- }
113
-
114
- async createDrive(id: string, drive: DocumentDriveStorage): Promise<void> {
115
- // drive for all drive documents
116
- await this.createDocument('drives', id, drive as DocumentStorage);
117
- await this.db.drive.upsert({
118
- where: {
119
- slug: drive.initialState.state.global.slug ?? id
120
- },
121
- create: {
122
- id: id,
123
- slug: drive.initialState.state.global.slug ?? id
124
- },
125
- update: {
126
- id
127
- }
128
- });
129
- }
130
- async addDriveOperations(
131
- id: string,
132
- operations: Operation[],
133
- header: DocumentHeader
134
- ): Promise<void> {
135
- await this.addDocumentOperations('drives', id, operations, header);
136
- }
137
-
138
- async addDriveOperationsWithTransaction(
139
- drive: string,
140
- callback: (document: DocumentDriveStorage) => Promise<{
141
- operations: Operation<DocumentDriveAction | BaseAction>[];
142
- header: DocumentHeader;
143
- }>
144
- ) {
145
- return this.addDocumentOperationsWithTransaction(
146
- 'drives',
147
- drive,
148
- document => callback(document as DocumentDriveStorage)
149
- );
150
- }
151
-
152
- async createDocument(
153
- drive: string,
154
- id: string,
155
- document: DocumentStorage
156
- ): Promise<void> {
157
- await this.db.document.upsert({
158
- where: {
159
- id_driveId: {
160
- id,
161
- driveId: drive
162
- }
163
- },
164
- update: {},
165
- create: {
166
- name: document.name,
167
- documentType: document.documentType,
168
- driveId: drive,
169
- initialState: JSON.stringify(document.initialState),
170
- lastModified: document.lastModified,
171
- revision: JSON.stringify(document.revision),
172
- id
173
- }
174
- });
175
- }
176
-
177
- private async _addDocumentOperations(
178
- tx: Transaction,
179
- drive: string,
180
- id: string,
181
- operations: Operation[],
182
- header: DocumentHeader
183
- ): Promise<void> {
184
- try {
185
- await tx.operation.createMany({
186
- data: operations.map(op => ({
187
- driveId: drive,
188
- documentId: id,
189
- hash: op.hash,
190
- index: op.index,
191
- input: JSON.stringify(op.input),
192
- timestamp: op.timestamp,
193
- type: op.type,
194
- scope: op.scope,
195
- branch: 'main',
196
- skip: op.skip,
197
- context: op.context,
198
- resultingState: op.resultingState
199
- ? Buffer.from(JSON.stringify(op.resultingState))
200
- : undefined
201
- }))
202
- });
203
-
204
- await tx.document.updateMany({
205
- where: {
206
- id,
207
- driveId: drive
98
+ private db: ExtendedPrismaClient;
99
+ private delegate: IStorageDelegate | undefined;
100
+
101
+ constructor(db: PrismaClient, options?: PrismaStorageOptions) {
102
+ const backOffOptions = options?.transactionRetryBackoff;
103
+ this.db = getRetryTransactionsClient(db, {
104
+ ...backOffOptions,
105
+ jitter: backOffOptions?.jitter ?? "full",
106
+ });
107
+ }
108
+
109
+ setStorageDelegate(delegate: IStorageDelegate): void {
110
+ this.delegate = delegate;
111
+ }
112
+
113
+ async createDrive(id: string, drive: DocumentDriveStorage): Promise<void> {
114
+ // drive for all drive documents
115
+ await this.createDocument("drives", id, drive as DocumentStorage);
116
+ await this.db.drive.upsert({
117
+ where: {
118
+ slug: drive.initialState.state.global.slug ?? id,
119
+ },
120
+ create: {
121
+ id: id,
122
+ slug: drive.initialState.state.global.slug ?? id,
123
+ },
124
+ update: {
125
+ id,
126
+ },
127
+ });
128
+ }
129
+ async addDriveOperations(
130
+ id: string,
131
+ operations: Operation[],
132
+ header: DocumentHeader,
133
+ ): Promise<void> {
134
+ await this.addDocumentOperations("drives", id, operations, header);
135
+ }
136
+
137
+ async addDriveOperationsWithTransaction(
138
+ drive: string,
139
+ callback: (document: DocumentDriveStorage) => Promise<{
140
+ operations: Operation<DocumentDriveAction | BaseAction>[];
141
+ header: DocumentHeader;
142
+ }>,
143
+ ) {
144
+ return this.addDocumentOperationsWithTransaction(
145
+ "drives",
146
+ drive,
147
+ (document) => callback(document as DocumentDriveStorage),
148
+ );
149
+ }
150
+
151
+ async createDocument(
152
+ drive: string,
153
+ id: string,
154
+ document: DocumentStorage,
155
+ ): Promise<void> {
156
+ await this.db.document.upsert({
157
+ where: {
158
+ id_driveId: {
159
+ id,
160
+ driveId: drive,
161
+ },
162
+ },
163
+ update: {},
164
+ create: {
165
+ name: document.name,
166
+ documentType: document.documentType,
167
+ driveId: drive,
168
+ initialState: JSON.stringify(document.initialState),
169
+ lastModified: document.lastModified,
170
+ revision: JSON.stringify(document.revision),
171
+ id,
172
+ },
173
+ });
174
+ }
175
+
176
+ private async _addDocumentOperations(
177
+ tx: Transaction,
178
+ drive: string,
179
+ id: string,
180
+ operations: Operation[],
181
+ header: DocumentHeader,
182
+ ): Promise<void> {
183
+ try {
184
+ await tx.operation.createMany({
185
+ data: operations.map((op) => ({
186
+ driveId: drive,
187
+ documentId: id,
188
+ hash: op.hash,
189
+ index: op.index,
190
+ input: JSON.stringify(op.input),
191
+ timestamp: op.timestamp,
192
+ type: op.type,
193
+ scope: op.scope,
194
+ branch: "main",
195
+ opId: op.id,
196
+ skip: op.skip,
197
+ context: op.context,
198
+ resultingState: op.resultingState
199
+ ? Buffer.from(JSON.stringify(op.resultingState))
200
+ : undefined,
201
+ })),
202
+ });
203
+
204
+ await tx.document.updateMany({
205
+ where: {
206
+ id,
207
+ driveId: drive,
208
+ },
209
+ data: {
210
+ lastModified: header.lastModified,
211
+ revision: JSON.stringify(header.revision),
212
+ },
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
+ },
208
234
  },
209
- data: {
210
- lastModified: header.lastModified,
211
- revision: JSON.stringify(header.revision)
212
- }
235
+ },
213
236
  });
237
+ }),
238
+ );
239
+ } catch (e) {
240
+ // P2002: Unique constraint failed
241
+ // Operation with existing index
242
+ if (e instanceof PrismaClientKnownRequestError && e.code === "P2002") {
243
+ const existingOperation = await this.db.operation.findFirst({
244
+ where: {
245
+ AND: operations.map((op) => ({
246
+ driveId: drive,
247
+ documentId: id,
248
+ scope: op.scope,
249
+ branch: "main",
250
+ index: op.index,
251
+ })),
252
+ },
253
+ });
214
254
 
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
- );
239
- } catch (e) {
240
- // P2002: Unique constraint failed
241
- // Operation with existing index
242
- if (
243
- e instanceof PrismaClientKnownRequestError &&
244
- e.code === 'P2002'
245
- ) {
246
- const existingOperation = await this.db.operation.findFirst({
247
- where: {
248
- AND: operations.map(op => ({
249
- driveId: drive,
250
- documentId: id,
251
- scope: op.scope,
252
- branch: 'main',
253
- index: op.index
254
- }))
255
- }
256
- });
257
-
258
- const conflictOp = operations.find(
259
- op =>
260
- existingOperation?.index === op.index &&
261
- existingOperation.scope === op.scope
262
- );
263
-
264
- if (!existingOperation || !conflictOp) {
265
- console.error(e);
266
- throw e;
267
- } else {
268
- throw new ConflictOperationError(
269
- storageToOperation(existingOperation),
270
- conflictOp
271
- );
272
- }
273
- } else {
274
- throw e;
275
- }
276
- }
277
- }
278
-
279
- async addDocumentOperationsWithTransaction(
280
- drive: string,
281
- id: string,
282
- callback: (document: DocumentStorage) => Promise<{
283
- operations: Operation[];
284
- header: DocumentHeader;
285
- newState?: State<any, any> | undefined;
286
- }>
287
- ) {
288
- let result: {
289
- operations: Operation[];
290
- header: DocumentHeader;
291
- newState?: State<any, any> | undefined;
292
- } | null = null;
293
-
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);
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' }
255
+ const conflictOp = operations.find(
256
+ (op) =>
257
+ existingOperation?.index === op.index &&
258
+ existingOperation.scope === op.scope,
312
259
  );
313
260
 
314
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
315
- if (!result) {
316
- throw new Error('No operations were provided');
261
+ if (!existingOperation || !conflictOp) {
262
+ console.error(e);
263
+ throw e;
264
+ } else {
265
+ throw new ConflictOperationError(
266
+ storageToOperation(existingOperation),
267
+ conflictOp,
268
+ );
317
269
  }
318
-
319
- return result;
270
+ } else {
271
+ throw e;
272
+ }
320
273
  }
274
+ }
275
+
276
+ async addDocumentOperationsWithTransaction(
277
+ drive: string,
278
+ id: string,
279
+ callback: (document: DocumentStorage) => Promise<{
280
+ operations: Operation[];
281
+ header: DocumentHeader;
282
+ newState?: State<any, any> | undefined;
283
+ }>,
284
+ ) {
285
+ let result: {
286
+ operations: Operation[];
287
+ header: DocumentHeader;
288
+ newState?: State<any, any> | undefined;
289
+ } | null = null;
290
+
291
+ await this.db.$transaction(
292
+ async (tx) => {
293
+ const document = await this.getDocument(drive, id, tx);
294
+ if (!document) {
295
+ throw new Error(`Document with id ${id} not found`);
296
+ }
297
+ result = await callback(document);
321
298
 
322
- async addDocumentOperations(
323
- drive: string,
324
- id: string,
325
- operations: Operation[],
326
- header: DocumentHeader
327
- ): Promise<void> {
328
- return this._addDocumentOperations(
329
- this.db,
330
- drive,
331
- id,
332
- operations,
333
- header
334
- );
335
- }
336
-
337
- async getDocuments(drive: string) {
338
- const docs = await this.db.document.findMany({
339
- select: {
340
- id: true
341
- },
342
- where: {
343
- AND: {
344
- driveId: drive,
345
- NOT: {
346
- id: 'drives'
347
- }
348
- }
349
- }
350
- });
351
-
352
- return docs.map(doc => doc.id);
353
- }
299
+ const { operations, header, newState } = result;
300
+ return this._addDocumentOperations(tx, drive, id, operations, header);
301
+ },
302
+ { isolationLevel: "Serializable", maxWait: 10000, timeout: 20000 },
303
+ );
354
304
 
355
- async checkDocumentExists(driveId: string, id: string) {
356
- const count = await this.db.document.count({
357
- where: {
358
- id: id,
359
- driveId: driveId
360
- }
361
- });
362
- return count > 0;
305
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
306
+ if (!result) {
307
+ throw new Error("No operations were provided");
363
308
  }
364
309
 
365
- async getDocument(driveId: string, id: string, tx?: Transaction) {
366
- const prisma = tx ?? this.db;
367
- const result = await prisma.document.findUnique({
368
- where: {
369
- id_driveId: {
370
- driveId,
371
- id
372
- }
373
- }
374
- });
310
+ return result;
311
+ }
312
+
313
+ async addDocumentOperations(
314
+ drive: string,
315
+ id: string,
316
+ operations: Operation[],
317
+ header: DocumentHeader,
318
+ ): Promise<void> {
319
+ return this._addDocumentOperations(this.db, drive, id, operations, header);
320
+ }
321
+
322
+ async getDocuments(drive: string) {
323
+ const docs = await this.db.document.findMany({
324
+ select: {
325
+ id: true,
326
+ },
327
+ where: {
328
+ AND: {
329
+ driveId: drive,
330
+ NOT: {
331
+ id: "drives",
332
+ },
333
+ },
334
+ },
335
+ });
375
336
 
376
- if (result === null) {
377
- throw new Error(`Document with id ${id} not found`);
378
- }
337
+ return docs.map((doc) => doc.id);
338
+ }
379
339
 
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
- );
340
+ async checkDocumentExists(driveId: string, id: string) {
341
+ const count = await this.db.document.count({
342
+ where: {
343
+ id: id,
344
+ driveId: driveId,
345
+ },
346
+ });
347
+ return count > 0;
348
+ }
349
+
350
+ async getDocument(driveId: string, id: string, tx?: Transaction) {
351
+ const prisma = tx ?? this.db;
352
+ const result = await prisma.document.findUnique({
353
+ where: {
354
+ id_driveId: {
355
+ driveId,
356
+ id,
357
+ },
358
+ },
359
+ });
398
360
 
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
- );
361
+ if (result === null) {
362
+ throw new Error(`Document with id ${id} not found`);
363
+ }
407
364
 
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 (
365
+ const cachedOperations = (await this.delegate?.getCachedOperations(
366
+ driveId,
367
+ id,
368
+ )) ?? {
369
+ global: [],
370
+ local: [],
371
+ };
372
+ const scopeIndex = Object.keys(cachedOperations).reduceRight<
373
+ Record<OperationScope, number>
374
+ >(
375
+ (acc, value) => {
376
+ const scope = value as OperationScope;
377
+ const lastIndex = cachedOperations[scope].at(-1)?.index ?? -1;
378
+ acc[scope] = lastIndex;
379
+ return acc;
380
+ },
381
+ { global: -1, local: -1 },
382
+ );
383
+
384
+ const conditions = Object.entries(scopeIndex).map(
385
+ ([scope, index]) => `("scope" = '${scope}' AND "index" > ${index})`,
386
+ );
387
+ conditions.push(
388
+ `("scope" NOT IN (${Object.keys(cachedOperations)
389
+ .map((s) => `'${s}'`)
390
+ .join(", ")}))`,
391
+ );
392
+
393
+ // retrieves operations with resulting state
394
+ // for the last operation of each scope
395
+ // TODO prevent SQL injection
396
+ const queryOperations = await prisma.$queryRawUnsafe<
397
+ Prisma.$OperationPayload["scalars"][]
398
+ >(
399
+ `WITH ranked_operations AS (
415
400
  SELECT
416
401
  *,
417
402
  ROW_NUMBER() OVER (PARTITION BY scope ORDER BY index DESC) AS rn
@@ -435,212 +420,234 @@ export class PrismaStorage implements IDriveStorage {
435
420
  END AS "resultingState"
436
421
  FROM ranked_operations
437
422
  WHERE "driveId" = $1 AND "documentId" = $2
438
- AND (${conditions.join(' OR ')})
423
+ AND (${conditions.join(" OR ")})
439
424
  ORDER BY scope, index;
440
425
  `,
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
-
474
- const dbDoc = result;
475
- const doc: Document = {
476
- created: dbDoc.created.toISOString(),
477
- name: dbDoc.name ? dbDoc.name : '',
478
- documentType: dbDoc.documentType,
479
- initialState: JSON.parse(dbDoc.initialState) as ExtendedState<
480
- DocumentDriveState,
481
- DocumentDriveLocalState
482
- >,
483
- state: undefined,
484
- lastModified: new Date(dbDoc.lastModified).toISOString(),
485
- operations: operationsByScope,
486
- clipboard: [],
487
- revision: JSON.parse(dbDoc.revision) as Record<
488
- OperationScope,
489
- number
490
- >,
491
- attachments: {}
492
- };
493
- return doc;
494
- }
495
-
496
- async deleteDocument(drive: string, id: string) {
497
- await this.db.document.delete({
498
- where: {
499
- id_driveId: {
500
- driveId: drive,
501
- id: id
502
- }
503
- },
504
- include: {
505
- operations: {
506
- include: {
507
- attachments: true
508
- }
509
- }
510
- }
511
- });
512
- }
426
+ driveId,
427
+ id,
428
+ );
429
+ const operationIds = queryOperations.map((o) => o.id);
430
+ const attachments = await prisma.attachment.findMany({
431
+ where: {
432
+ operationId: {
433
+ in: operationIds,
434
+ },
435
+ },
436
+ });
513
437
 
514
- async getDrives() {
515
- return this.getDocuments('drives');
438
+ // TODO add attachments from cached operations
439
+ const fileRegistry: FileRegistry = {};
440
+
441
+ const operationsByScope = queryOperations.reduce<
442
+ DocumentOperations<Action>
443
+ >((acc, operation) => {
444
+ const scope = operation.scope as OperationScope;
445
+ if (!acc[scope]) {
446
+ acc[scope] = [];
447
+ }
448
+ const result = storageToOperation(operation);
449
+ result.attachments = attachments.filter(
450
+ (a) => a.operationId === operation.id,
451
+ );
452
+ result.attachments.forEach(({ hash, ...file }) => {
453
+ fileRegistry[hash] = file;
454
+ });
455
+ acc[scope].push(result);
456
+ return acc;
457
+ }, cachedOperations);
458
+
459
+ const dbDoc = result;
460
+ const doc: Document = {
461
+ created: dbDoc.created.toISOString(),
462
+ name: dbDoc.name ? dbDoc.name : "",
463
+ documentType: dbDoc.documentType,
464
+ initialState: JSON.parse(dbDoc.initialState) as ExtendedState<
465
+ DocumentDriveState,
466
+ DocumentDriveLocalState
467
+ >,
468
+ // @ts-expect-error TODO: fix as this should not be undefined
469
+ state: undefined,
470
+ lastModified: new Date(dbDoc.lastModified).toISOString(),
471
+ operations: operationsByScope,
472
+ clipboard: [],
473
+ revision: JSON.parse(dbDoc.revision) as Record<OperationScope, number>,
474
+ attachments: {},
475
+ };
476
+ return doc;
477
+ }
478
+
479
+ async deleteDocument(drive: string, id: string) {
480
+ try {
481
+ await this.db.document.deleteMany({
482
+ where: {
483
+ driveId: drive,
484
+ id: id,
485
+ },
486
+ });
487
+ } catch (e: unknown) {
488
+ const prismaError = e as { code?: string; message?: string };
489
+ // Ignore Error: P2025: An operation failed because it depends on one or more records that were required but not found.
490
+ if (
491
+ (prismaError.code && prismaError.code === "P2025") ||
492
+ prismaError.message?.includes(
493
+ "An operation failed because it depends on one or more records that were required but not found.",
494
+ )
495
+ ) {
496
+ return;
497
+ }
498
+
499
+ throw e;
516
500
  }
517
-
518
- async getDrive(id: string) {
519
- try {
520
- const doc = await this.getDocument('drives', id);
521
- return doc as DocumentDriveStorage;
522
- } catch (e) {
523
- logger.error(e);
524
- throw new Error(`Drive with id ${id} not found`);
525
- }
501
+ }
502
+
503
+ async getDrives() {
504
+ return this.getDocuments("drives");
505
+ }
506
+
507
+ async getDrive(id: string) {
508
+ try {
509
+ const doc = await this.getDocument("drives", id);
510
+ return doc as DocumentDriveStorage;
511
+ } catch (e) {
512
+ logger.error(e);
513
+ throw new DriveNotFoundError(id);
526
514
  }
515
+ }
527
516
 
528
- async getDriveBySlug(slug: string) {
529
- const driveEntity = await this.db.drive.findFirst({
530
- where: {
531
- slug
532
- }
533
- });
534
-
535
- if (!driveEntity) {
536
- throw new Error(`Drive with slug ${slug} not found`);
537
- }
517
+ async getDriveBySlug(slug: string) {
518
+ const driveEntity = await this.db.drive.findFirst({
519
+ where: {
520
+ slug,
521
+ },
522
+ });
538
523
 
539
- return this.getDrive(driveEntity.id);
524
+ if (!driveEntity) {
525
+ throw new Error(`Drive with slug ${slug} not found`);
540
526
  }
541
527
 
542
- async deleteDrive(id: string) {
543
- // delete drive documents and operations
544
- await this.db.document.deleteMany({
545
- where: {
546
- driveId: id
547
- }
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
558
- await this.deleteDocument('drives', id);
559
- }
528
+ return this.getDrive(driveEntity.id);
529
+ }
560
530
 
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
- }
531
+ async deleteDrive(id: string) {
532
+ // delete drive and associated slug
533
+ await this.db.drive.deleteMany({
534
+ where: {
535
+ id,
536
+ },
537
+ });
581
538
 
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
- }
539
+ // delete drive document and its operations
540
+ await this.deleteDocument("drives", id);
596
541
 
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 = `
542
+ // deletes all documents of the drive
543
+ await this.db.document.deleteMany({
544
+ where: {
545
+ driveId: id,
546
+ },
547
+ });
548
+ }
549
+
550
+ async getOperationResultingState(
551
+ driveId: string,
552
+ documentId: string,
553
+ index: number,
554
+ scope: string,
555
+ branch: string,
556
+ ): Promise<unknown> {
557
+ const operation = await this.db.operation.findUnique({
558
+ where: {
559
+ unique_operation: {
560
+ driveId,
561
+ documentId,
562
+ index,
563
+ scope,
564
+ branch,
565
+ },
566
+ },
567
+ });
568
+ return operation?.resultingState?.toString();
569
+ }
570
+
571
+ getDriveOperationResultingState(
572
+ drive: string,
573
+ index: number,
574
+ scope: string,
575
+ branch: string,
576
+ ): Promise<unknown> {
577
+ return this.getOperationResultingState(
578
+ "drives",
579
+ drive,
580
+ index,
581
+ scope,
582
+ branch,
583
+ );
584
+ }
585
+
586
+ async getSynchronizationUnitsRevision(
587
+ units: SynchronizationUnitQuery[],
588
+ ): Promise<
589
+ {
590
+ driveId: string;
591
+ documentId: string;
592
+ scope: string;
593
+ branch: string;
594
+ lastUpdated: string;
595
+ revision: number;
596
+ }[]
597
+ > {
598
+ // TODO add branch condition
599
+ const whereClauses = units
600
+ .map((_, index) => {
601
+ return `("driveId" = $${index * 3 + 1} AND "documentId" = $${index * 3 + 2} AND "scope" = $${index * 3 + 3})`;
602
+ })
603
+ .join(" OR ");
604
+
605
+ const query = `
617
606
  SELECT "driveId", "documentId", "scope", "branch", MAX("timestamp") as "lastUpdated", MAX("index") as revision FROM "Operation"
618
607
  WHERE ${whereClauses}
619
608
  GROUP BY "driveId", "documentId", "scope", "branch"
620
609
  `;
621
610
 
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
- }
611
+ const params = units
612
+ .map((unit) => [
613
+ unit.documentId ? unit.driveId : "drives",
614
+ unit.documentId || unit.driveId,
615
+ unit.scope,
616
+ ])
617
+ .flat();
618
+ const results = await this.db.$queryRawUnsafe<
619
+ {
620
+ driveId: string;
621
+ documentId: string;
622
+ lastUpdated: string;
623
+ scope: OperationScope;
624
+ branch: string;
625
+ revision: number;
626
+ }[]
627
+ >(query, ...params);
628
+ return results.map((row) => ({
629
+ ...row,
630
+ driveId: row.driveId === "drives" ? row.documentId : row.driveId,
631
+ documentId: row.driveId === "drives" ? "" : row.documentId,
632
+ lastUpdated: new Date(row.lastUpdated).toISOString(),
633
+ }));
634
+ }
635
+
636
+ // migrates all stored operations from legacy signature to signatures array
637
+ async migrateOperationSignatures() {
638
+ const count = await this.db.$executeRaw`
639
+ UPDATE "Operation"
640
+ SET context = jsonb_set(
641
+ context #- '{signer,signature}', -- Remove the old 'signature' field
642
+ '{signer,signatures}', -- Path to the new 'signatures' field
643
+ CASE
644
+ WHEN context->'signer'->>'signature' = '' THEN '[]'::jsonb
645
+ ELSE to_jsonb(array[context->'signer'->>'signature'])
646
+ END
647
+ )
648
+ WHERE context->'signer' ? 'signature' -- Check if the 'signature' key exists
649
+ `;
650
+ logger.info(`Migrated ${count} operations`);
651
+ return;
652
+ }
646
653
  }