document-drive 1.0.0-experimental.21 → 1.0.0-experimental.23

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/CHANGELOG.md CHANGED
@@ -1,3 +1,73 @@
1
+ # [1.0.0-alpha.65](https://github.com/powerhouse-inc/document-drive/compare/v1.0.0-alpha.64...v1.0.0-alpha.65) (2024-05-30)
2
+
3
+
4
+ ### Features
5
+
6
+ * fetch resulting state for last unskipped operation ([8297353](https://github.com/powerhouse-inc/document-drive/commit/8297353dc8eaca107d8134580dee67c3265b05b5))
7
+
8
+ # [1.0.0-alpha.64](https://github.com/powerhouse-inc/document-drive/compare/v1.0.0-alpha.63...v1.0.0-alpha.64) (2024-05-30)
9
+
10
+
11
+ ### Features
12
+
13
+ * update document-model lib version ([#180](https://github.com/powerhouse-inc/document-drive/issues/180)) ([83cec58](https://github.com/powerhouse-inc/document-drive/commit/83cec58cb02388a3b2a643dd5af6c31cd850242d))
14
+
15
+ # [1.0.0-alpha.63](https://github.com/powerhouse-inc/document-drive/compare/v1.0.0-alpha.62...v1.0.0-alpha.63) (2024-05-30)
16
+
17
+
18
+ ### Bug Fixes
19
+
20
+ * operations query ([8dd11c7](https://github.com/powerhouse-inc/document-drive/commit/8dd11c72058452f3e8febac472d01ad56d959e17))
21
+
22
+ # [1.0.0-alpha.62](https://github.com/powerhouse-inc/document-drive/compare/v1.0.0-alpha.61...v1.0.0-alpha.62) (2024-05-30)
23
+
24
+
25
+ ### Bug Fixes
26
+
27
+ * added drive and documentId filter ([718405f](https://github.com/powerhouse-inc/document-drive/commit/718405febbe8e53a3fd392cdd4acf2b041347eaf))
28
+
29
+ # [1.0.0-alpha.61](https://github.com/powerhouse-inc/document-drive/compare/v1.0.0-alpha.60...v1.0.0-alpha.61) (2024-05-30)
30
+
31
+
32
+ ### Features
33
+
34
+ * store resulting state as bytes and only retrieve state for last op of each scope ([c6a5004](https://github.com/powerhouse-inc/document-drive/commit/c6a5004a3dd07ee1ce2f5521bb7a6aa5e970465e))
35
+
36
+ # [1.0.0-alpha.60](https://github.com/powerhouse-inc/document-drive/compare/v1.0.0-alpha.59...v1.0.0-alpha.60) (2024-05-29)
37
+
38
+
39
+ ### Features
40
+
41
+ * avoid duplicated getDocument call ([c0684bc](https://github.com/powerhouse-inc/document-drive/commit/c0684bc0746d8bafb9393cdaeebfb60b38a8a32f))
42
+
43
+ # [1.0.0-alpha.59](https://github.com/powerhouse-inc/document-drive/compare/v1.0.0-alpha.58...v1.0.0-alpha.59) (2024-05-28)
44
+
45
+
46
+ ### Features
47
+
48
+ * enable operation id ([#174](https://github.com/powerhouse-inc/document-drive/issues/174)) ([1d77fd2](https://github.com/powerhouse-inc/document-drive/commit/1d77fd2f6a4618371c6fc4c072d4eab7d27a662a))
49
+
50
+ # [1.0.0-alpha.58](https://github.com/powerhouse-inc/document-drive/compare/v1.0.0-alpha.57...v1.0.0-alpha.58) (2024-05-28)
51
+
52
+
53
+ ### Features
54
+
55
+ * don't save queue job result ([efbf239](https://github.com/powerhouse-inc/document-drive/commit/efbf239e5f77592267446d5c11d670a82f1f6e58))
56
+
57
+ # [1.0.0-alpha.57](https://github.com/powerhouse-inc/document-drive/compare/v1.0.0-alpha.56...v1.0.0-alpha.57) (2024-05-22)
58
+
59
+
60
+ ### Features
61
+
62
+ * new release ([17796e8](https://github.com/powerhouse-inc/document-drive/commit/17796e8577d16d3095a8adf3c40e7bd7146b6142))
63
+
64
+ # [1.0.0-alpha.56](https://github.com/powerhouse-inc/document-drive/compare/v1.0.0-alpha.55...v1.0.0-alpha.56) (2024-05-22)
65
+
66
+
67
+ ### Features
68
+
69
+ * add queues and append only conflict resolution ([d17abd6](https://github.com/powerhouse-inc/document-drive/commit/d17abd664a7381f80faa6530f83ca9e224282ba1)), closes [#153](https://github.com/powerhouse-inc/document-drive/issues/153)
70
+
1
71
  # 1.0.0-experimental.1 (2024-05-15)
2
72
 
3
73
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "document-drive",
3
- "version": "1.0.0-experimental.21",
3
+ "version": "1.0.0-experimental.23",
4
4
  "license": "AGPL-3.0-only",
5
5
  "type": "module",
6
6
  "module": "./src/index.ts",
@@ -34,8 +34,8 @@
34
34
  "test:watch": "vitest watch"
35
35
  },
36
36
  "peerDependencies": {
37
- "document-model": "1.1.0-experimental.4",
38
- "document-model-libs": "^1.52.0"
37
+ "document-model": "1.1.0-experimental.5",
38
+ "document-model-libs": "1.55.1-experimental"
39
39
  },
40
40
  "optionalDependencies": {
41
41
  "@prisma/client": "5.14.0",
@@ -65,8 +65,8 @@
65
65
  "@typescript-eslint/eslint-plugin": "^6.21.0",
66
66
  "@typescript-eslint/parser": "^6.21.0",
67
67
  "@vitest/coverage-v8": "^1.4.0",
68
- "document-model": "1.1.0-experimental.4",
69
- "document-model-libs": "1.53.0",
68
+ "document-model": "1.1.0-experimental.5",
69
+ "document-model-libs": "1.55.1-experimental",
70
70
  "eslint": "^8.57.0",
71
71
  "eslint-config-prettier": "^9.1.0",
72
72
  "fake-indexeddb": "^5.0.2",
package/src/queue/base.ts CHANGED
@@ -10,7 +10,6 @@ export class MemoryQueue<T, R> implements IQueue<T, R> {
10
10
  private blocked = false;
11
11
  private deleted = false;
12
12
  private items: IJob<T>[] = [];
13
- private results = new Map<JobId, R>();
14
13
  private dependencies = new Array<IJob<OperationJob>>();
15
14
 
16
15
  constructor(id: string) {
@@ -25,15 +24,6 @@ export class MemoryQueue<T, R> implements IQueue<T, R> {
25
24
  return this.deleted;
26
25
  }
27
26
 
28
- async setResult(jobId: string, result: R): Promise<void> {
29
- this.results.set(jobId, result);
30
- return Promise.resolve();
31
- }
32
-
33
- async getResult(jobId: string): Promise<R | undefined> {
34
- return Promise.resolve(this.results.get(jobId));
35
- }
36
-
37
27
  async addJob(data: IJob<T>) {
38
28
  this.items.push(data);
39
29
  return Promise.resolve();
@@ -157,11 +147,6 @@ export class BaseQueueManager implements IQueueManager {
157
147
  return jobId;
158
148
  }
159
149
 
160
- async getResult(driveId: string, documentId: string, jobId: JobId): Promise<IOperationResult | undefined> {
161
- const queue = this.getQueue(driveId, documentId);
162
- return queue.getResult(jobId);
163
- }
164
-
165
150
  getQueue(driveId: string, documentId?: string) {
166
151
  const queueId = this.getQueueId(driveId, documentId);
167
152
  let queue = this.queues.find((q) => q.getId() === queueId);
@@ -237,7 +222,6 @@ export class BaseQueueManager implements IQueueManager {
237
222
 
238
223
  try {
239
224
  const result = await this.delegate.processOperationJob(nextJob);
240
- await queue.setResult(nextJob.jobId, result);
241
225
 
242
226
  // unblock the document queues of each add_file operation
243
227
  const addFileOperations = nextJob.operations.filter((op) => op.type === "ADD_FILE");
@@ -0,0 +1,22 @@
1
+ import { Unsubscribe } from "nanoevents";
2
+ import { IOperationResult } from "../server";
3
+ import { IQueueManager, QueueEvents } from "./types";
4
+
5
+ export class BaseQueueManager implements IQueueManager {
6
+
7
+ constructor() {
8
+ }
9
+ addJob(job: OperationJob): Promise<string> {
10
+ throw new Error("Method not implemented.");
11
+ }
12
+ getResult(driveId: string, documentId: string, jobId: JobId): Promise<IOperationResult | undefined> {
13
+ throw new Error("Method not implemented.");
14
+ }
15
+ init(processor: OperationJobProcessor, onError: (error: Error) => void): Promise<void> {
16
+ throw new Error("Method not implemented.");
17
+ }
18
+ on<K extends keyof QueueEvents>(this: this, event: K, cb: QueueEvents[K]): Unsubscribe {
19
+ throw new Error("Method not implemented.");
20
+ }
21
+
22
+ }
@@ -13,17 +13,6 @@ export class RedisQueue<T, R> implements IQueue<T, R> {
13
13
 
14
14
  }
15
15
 
16
- async setResult(jobId: string, result: any): Promise<void> {
17
- await this.client.hSet(this.id + "-results", jobId, JSON.stringify(result));
18
- }
19
- async getResult(jobId: string): Promise<any> {
20
- const results = await this.client.hGet(this.id + "-results", jobId);
21
- if (!results) {
22
- return null;
23
- }
24
- return JSON.parse(results);
25
- }
26
-
27
16
  async addJob(data: any) {
28
17
  await this.client.lPush(this.id + "-jobs", JSON.stringify(data));
29
18
  }
@@ -23,7 +23,6 @@ export interface IServerDelegate {
23
23
 
24
24
  export interface IQueueManager {
25
25
  addJob(job: OperationJob): Promise<JobId>;
26
- getResult(driveId: string, documentId: string, jobId: JobId): Promise<IOperationResult | undefined>;
27
26
  getQueue(driveId: string, document?: string): IQueue<OperationJob, IOperationResult>;
28
27
  removeQueue(driveId: string, documentId?: string): void;
29
28
  getQueueByIndex(index: number): IQueue<OperationJob, IOperationResult> | null;
@@ -47,8 +46,6 @@ export interface IQueue<T, R> {
47
46
  isBlocked(): Promise<boolean>;
48
47
  isDeleted(): Promise<boolean>;
49
48
  setDeleted(deleted: boolean): Promise<void>;
50
- setResult(jobId: JobId, result: R): Promise<void>;
51
- getResult(jobId: JobId): Promise<R | undefined>;
52
49
  getJobs(): Promise<IJob<T>[]>;
53
50
  addDependencies(job: IJob<OperationJob>): Promise<void>;
54
51
  removeDependencies(job: IJob<OperationJob>): Promise<void>;
@@ -20,7 +20,8 @@ import {
20
20
  DocumentModel,
21
21
  Operation,
22
22
  OperationScope,
23
- State
23
+ utils as DocumentUtils,
24
+ Reducer
24
25
  } from 'document-model/document';
25
26
  import { createNanoEvents, Unsubscribe } from 'nanoevents';
26
27
  import { ICache } from '../cache';
@@ -425,7 +426,8 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
425
426
  type: operation.type,
426
427
  input: operation.input as object,
427
428
  skip: operation.skip,
428
- context: operation.context
429
+ context: operation.context,
430
+ id: operation.id
429
431
  }));
430
432
  }
431
433
 
@@ -630,6 +632,7 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
630
632
 
631
633
  async _processOperations<T extends Document, A extends Action>(
632
634
  drive: string,
635
+ documentId: string | undefined,
633
636
  storageDocument: DocumentStorage<T>,
634
637
  operations: Operation<A | BaseAction>[]
635
638
  ) {
@@ -667,7 +670,7 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
667
670
  : merge(trunk, invertedTrunk, reshuffleByTimestamp);
668
671
 
669
672
  const newOperations = newHistory.filter(
670
- (op: any) => trunk.length < 1 || precedes(trunk[trunk.length - 1]!, op)
673
+ (op) => trunk.length < 1 || precedes(trunk[trunk.length - 1]!, op)
671
674
  );
672
675
 
673
676
  for (const nextOperation of newOperations) {
@@ -676,15 +679,20 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
676
679
  // when dealing with a merge (tail.length > 0) we have to skip hash validation
677
680
  // for the operations that were re-indexed (previous hash becomes invalid due the new position in the history)
678
681
  if (tail.length > 0) {
679
- skipHashValidation = [...invertedTrunk, ...tail].some(
680
- invertedTrunkOp =>
681
- invertedTrunkOp.hash === nextOperation.hash
682
+ const sourceOperation = operations.find(
683
+ op => op.hash === nextOperation.hash
682
684
  );
685
+
686
+ skipHashValidation =
687
+ !sourceOperation ||
688
+ sourceOperation.index !== nextOperation.index ||
689
+ sourceOperation.skip !== nextOperation.skip;
683
690
  }
684
691
 
685
692
  try {
686
693
  const appliedResult = await this._performOperation(
687
694
  drive,
695
+ documentId,
688
696
  document,
689
697
  nextOperation,
690
698
  skipHashValidation
@@ -720,6 +728,10 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
720
728
  private _buildDocument<T extends Document>(
721
729
  documentStorage: DocumentStorage<T>, options?: GetDocumentOptions
722
730
  ): T {
731
+ if (documentStorage.state && (!options || options.checkHashes === false)) {
732
+ return documentStorage as T;
733
+ }
734
+
723
735
  const documentModel = this._getDocumentModel(
724
736
  documentStorage.documentType
725
737
  );
@@ -730,10 +742,6 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
730
742
  ) : documentStorage.operations;
731
743
  const operations = baseUtils.documentHelpers.garbageCollectDocumentOperations(revisionOperations);
732
744
 
733
- if (documentStorage.state && (!options || options.checkHashes === false)) {
734
- return documentStorage as T;
735
- }
736
-
737
745
  return baseUtils.replayDocument(
738
746
  documentStorage.initialState,
739
747
  operations,
@@ -741,12 +749,17 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
741
749
  undefined,
742
750
  documentStorage,
743
751
  undefined,
744
- { checkHashes: options?.checkHashes ?? true, reuseOperationResultingState: true }
752
+ {
753
+ ...options,
754
+ checkHashes: options?.checkHashes ?? true,
755
+ reuseOperationResultingState: options?.checkHashes ?? true
756
+ }
745
757
  ) as T;
746
758
  }
747
759
 
748
760
  private async _performOperation<T extends Document>(
749
761
  drive: string,
762
+ id: string | undefined,
750
763
  document: T,
751
764
  operation: Operation,
752
765
  skipHashValidation = false
@@ -756,8 +769,28 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
756
769
  const signalResults: SignalResult[] = [];
757
770
  let newDocument = document;
758
771
 
772
+ const scope = operation.scope;
773
+ const documentOperations = DocumentUtils.documentHelpers.garbageCollectDocumentOperations(
774
+ {
775
+ ...document.operations,
776
+ [scope]: DocumentUtils.documentHelpers.skipHeaderOperations(
777
+ document.operations[scope],
778
+ operation,
779
+ ),
780
+ },
781
+ );
782
+
783
+ const lastRemainingOperation = documentOperations[scope].at(-1);
784
+ // if the latest operation doesn't have a resulting state then tries
785
+ // to retrieve it from the db to avoid rerunning all the operations
786
+ if (lastRemainingOperation && !lastRemainingOperation.resultingState) {
787
+ lastRemainingOperation.resultingState = await (id ? this.storage.getOperationResultingState?.(drive, id, lastRemainingOperation.index, lastRemainingOperation.scope, "main") :
788
+ this.storage.getDriveOperationResultingState?.(drive, lastRemainingOperation.index, lastRemainingOperation.scope, "main"))
789
+ }
790
+
759
791
  const operationSignals: (() => Promise<SignalResult>)[] = [];
760
- newDocument = documentModel.reducer(
792
+ const reducer = (document.documentType === "powerhouse/document-drive") ? utils.unsafeReducer as Reducer<unknown, Action, unknown> : documentModel.reducer;
793
+ newDocument = reducer(
761
794
  newDocument,
762
795
  operation,
763
796
  signal => {
@@ -792,7 +825,7 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
792
825
  );
793
826
  }
794
827
  },
795
- { skip: operation.skip, reuseOperationResultingState: true, storeOperationResultingState: true }
828
+ { skip: operation.skip, reuseOperationResultingState: true }
796
829
  ) as T;
797
830
 
798
831
  const appliedOperation = newDocument.operations[operation.scope].filter(
@@ -837,7 +870,6 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
837
870
  callback: (document: DocumentStorage) => Promise<{
838
871
  operations: Operation[];
839
872
  header: DocumentHeader;
840
- newState: State<any, any> | undefined;
841
873
  }>
842
874
  ) {
843
875
  if (!this.storage.addDocumentOperationsWithTransaction) {
@@ -884,7 +916,6 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
884
916
  }
885
917
  });
886
918
  const unsubscribeError = this.queueManager.on('jobFailed', (job, error) => {
887
- console.log("test")
888
919
  if (job.jobId === jobId) {
889
920
  unsubscribe();
890
921
  unsubscribeError();
@@ -913,6 +944,7 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
913
944
  await this._addOperations(drive, id, async documentStorage => {
914
945
  const result = await this._processOperations(
915
946
  drive,
947
+ id,
916
948
  documentStorage,
917
949
  operations
918
950
  );
@@ -1098,7 +1130,7 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
1098
1130
  const result = await this._processOperations<
1099
1131
  DocumentDriveDocument,
1100
1132
  DocumentDriveAction
1101
- >(drive, documentStorage, operations.slice());
1133
+ >(drive, undefined, documentStorage, operations.slice());
1102
1134
 
1103
1135
  document = result.document;
1104
1136
  operationsApplied.push(...result.operationsApplied);
@@ -1259,7 +1291,7 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
1259
1291
  const document = await this.getDrive(drive);
1260
1292
  const operations = this._buildOperations(document, actions);
1261
1293
  const result = await this.queueDriveOperations(drive, operations);
1262
- return result as IOperationResult<DocumentDriveDocument>;
1294
+ return result;
1263
1295
  }
1264
1296
 
1265
1297
  async addInternalListener(
@@ -1,4 +1,4 @@
1
- import { ListenerFilter, Trigger, z } from 'document-model-libs/document-drive';
1
+ import { ListenerFilter, Trigger } from 'document-model-libs/document-drive';
2
2
  import { Operation, OperationScope } from 'document-model/document';
3
3
  import { PULL_DRIVE_INTERVAL } from '../..';
4
4
  import { generateUUID } from '../../../utils';
@@ -153,6 +153,7 @@ export class PullResponderTransmitter implements IPullResponderTransmitter {
153
153
  scope
154
154
  branch
155
155
  operations {
156
+ id
156
157
  timestamp
157
158
  skip
158
159
  type
@@ -237,13 +238,11 @@ export class PullResponderTransmitter implements IPullResponderTransmitter {
237
238
  const listenerRevisions: ListenerRevisionWithError[] = [];
238
239
 
239
240
  for (const strand of strands) {
240
- const operations: Operation[] = strand.operations.map(
241
- (op) => ({
242
- ...op,
243
- scope: strand.scope,
244
- branch: strand.branch
245
- })
246
- );
241
+ const operations: Operation[] = strand.operations.map(op => ({
242
+ ...op,
243
+ scope: strand.scope,
244
+ branch: strand.branch
245
+ }));
247
246
 
248
247
  let error: Error | undefined = undefined;
249
248
  try {
@@ -14,6 +14,7 @@ import type {
14
14
  Document,
15
15
  Operation,
16
16
  OperationScope,
17
+ ReducerOptions,
17
18
  Signal,
18
19
  State
19
20
  } from 'document-model/document';
@@ -116,6 +117,7 @@ export type OperationUpdate = {
116
117
  input: object;
117
118
  hash: string;
118
119
  context?: ActionContext;
120
+ id?: string;
119
121
  };
120
122
 
121
123
  export type StrandUpdate = {
@@ -140,7 +142,7 @@ export type PartialRecord<K extends keyof any, T> = {
140
142
 
141
143
  export type RevisionsFilter = PartialRecord<OperationScope, number>;
142
144
 
143
- export type GetDocumentOptions = {
145
+ export type GetDocumentOptions = ReducerOptions & {
144
146
  revisions?: RevisionsFilter;
145
147
  checkHashes?: boolean;
146
148
  };
@@ -1,30 +1,44 @@
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,
11
+ DocumentOperations,
12
+ FileRegistry,
10
13
  BaseAction,
14
+ Document,
11
15
  DocumentHeader,
12
16
  ExtendedState,
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';
18
23
  import { logger } from '../utils/logger';
19
24
  import { DocumentDriveStorage, DocumentStorage, IDriveStorage } from './types';
20
25
 
21
- type Transaction = Omit<
22
- PrismaClient<Prisma.PrismaClientOptions, never>,
23
- '$connect' | '$disconnect' | '$on' | '$transaction' | '$use' | '$extends'
24
- > | ExtendedPrismaClient;
26
+ type Transaction =
27
+ | Omit<
28
+ PrismaClient<Prisma.PrismaClientOptions, never>,
29
+ | '$connect'
30
+ | '$disconnect'
31
+ | '$on'
32
+ | '$transaction'
33
+ | '$use'
34
+ | '$extends'
35
+ >
36
+ | ExtendedPrismaClient;
25
37
 
26
38
  function storageToOperation(
27
- op: Prisma.$OperationPayload['scalars']
39
+ op: Prisma.$OperationPayload['scalars'] & {
40
+ attachments?: AttachmentInput[];
41
+ }
28
42
  ): Operation {
29
43
  const operation: Operation = {
30
44
  skip: op.skip,
@@ -34,8 +48,10 @@ function storageToOperation(
34
48
  input: JSON.parse(op.input),
35
49
  type: op.type,
36
50
  scope: op.scope as OperationScope,
37
- resultingState: op.resultingState ?? undefined
38
- // attachments: fileRegistry
51
+ resultingState: op.resultingState
52
+ ? op.resultingState.toString()
53
+ : undefined,
54
+ attachments: op.attachments
39
55
  };
40
56
  if (op.context) {
41
57
  operation.context = op.context as Prisma.JsonObject;
@@ -45,27 +61,32 @@ function storageToOperation(
45
61
 
46
62
  export type PrismaStorageOptions = {
47
63
  transactionRetryBackoff?: IBackOffOptions;
48
- }
64
+ };
49
65
 
50
- function getRetryTransactionsClient<T extends PrismaClient>(prisma: T, backOffOptions?: Partial<IBackOffOptions>) {
66
+ function getRetryTransactionsClient<T extends PrismaClient>(
67
+ prisma: T,
68
+ backOffOptions?: Partial<IBackOffOptions>
69
+ ) {
51
70
  return prisma.$extends({
52
71
  client: {
53
- $transaction: (...args: Parameters<T["$transaction"]>) => {
72
+ $transaction: (...args: Parameters<T['$transaction']>) => {
54
73
  // eslint-disable-next-line prefer-spread
55
74
  return backOff(() => prisma.$transaction.apply(prisma, args), {
56
- retry: (e) => {
75
+ retry: e => {
57
76
  // Retry the transaction only if the error was due to a write conflict or deadlock
58
77
  // See: https://www.prisma.io/docs/reference/api-reference/error-reference#p2034
59
- return (e as { code: string }).code === "P2034";
78
+ return (e as { code: string }).code === 'P2034';
60
79
  },
61
- ...backOffOptions,
80
+ ...backOffOptions
62
81
  });
63
82
  }
64
83
  }
65
84
  });
66
85
  }
67
86
 
68
- type ExtendedPrismaClient = ReturnType<typeof getRetryTransactionsClient<PrismaClient>>;
87
+ type ExtendedPrismaClient = ReturnType<
88
+ typeof getRetryTransactionsClient<PrismaClient>
89
+ >;
69
90
 
70
91
  export class PrismaStorage implements IDriveStorage {
71
92
  private db: ExtendedPrismaClient;
@@ -74,9 +95,8 @@ export class PrismaStorage implements IDriveStorage {
74
95
  const backOffOptions = options?.transactionRetryBackoff;
75
96
  this.db = getRetryTransactionsClient(db, {
76
97
  ...backOffOptions,
77
- jitter: backOffOptions?.jitter ?? "full"
98
+ jitter: backOffOptions?.jitter ?? 'full'
78
99
  });
79
-
80
100
  }
81
101
 
82
102
  async createDrive(id: string, drive: DocumentDriveStorage): Promise<void> {
@@ -137,7 +157,7 @@ export class PrismaStorage implements IDriveStorage {
137
157
  initialState: JSON.stringify(document.initialState),
138
158
  lastModified: document.lastModified,
139
159
  revision: JSON.stringify(document.revision),
140
- id,
160
+ id
141
161
  }
142
162
  });
143
163
  }
@@ -147,14 +167,8 @@ export class PrismaStorage implements IDriveStorage {
147
167
  drive: string,
148
168
  id: string,
149
169
  operations: Operation[],
150
- header: DocumentHeader,
151
- newState: State<any, any> | undefined = undefined
170
+ header: DocumentHeader
152
171
  ): 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
172
  try {
159
173
  await tx.operation.createMany({
160
174
  data: operations.map(op => ({
@@ -169,7 +183,9 @@ export class PrismaStorage implements IDriveStorage {
169
183
  branch: 'main',
170
184
  skip: op.skip,
171
185
  context: op.context,
172
- resultingState: JSON.stringify(op.resultingState) ?? undefined
186
+ resultingState: op.resultingState
187
+ ? Buffer.from(JSON.stringify(op.resultingState))
188
+ : undefined
173
189
  }))
174
190
  });
175
191
 
@@ -180,10 +196,34 @@ export class PrismaStorage implements IDriveStorage {
180
196
  },
181
197
  data: {
182
198
  lastModified: header.lastModified,
183
- revision: JSON.stringify(header.revision),
184
- state: JSON.stringify(newState)
199
+ revision: JSON.stringify(header.revision)
185
200
  }
186
201
  });
202
+
203
+ await Promise.all(
204
+ operations
205
+ .filter(o => o.attachments?.length)
206
+ .map(op => {
207
+ return tx.operation.update({
208
+ where: {
209
+ unique_operation: {
210
+ driveId: drive,
211
+ documentId: id,
212
+ index: op.index,
213
+ scope: op.scope,
214
+ branch: 'main'
215
+ }
216
+ },
217
+ data: {
218
+ attachments: {
219
+ createMany: {
220
+ data: op.attachments ?? []
221
+ }
222
+ }
223
+ }
224
+ });
225
+ })
226
+ );
187
227
  } catch (e) {
188
228
  // P2002: Unique constraint failed
189
229
  // Operation with existing index
@@ -210,6 +250,7 @@ export class PrismaStorage implements IDriveStorage {
210
250
  );
211
251
 
212
252
  if (!existingOperation || !conflictOp) {
253
+ console.error(e);
213
254
  throw e;
214
255
  } else {
215
256
  throw new ConflictOperationError(
@@ -229,7 +270,7 @@ export class PrismaStorage implements IDriveStorage {
229
270
  callback: (document: DocumentStorage) => Promise<{
230
271
  operations: Operation[];
231
272
  header: DocumentHeader;
232
- newState?: State<any, any> | undefined
273
+ newState?: State<any, any> | undefined;
233
274
  }>
234
275
  ) {
235
276
  let result: {
@@ -238,24 +279,25 @@ export class PrismaStorage implements IDriveStorage {
238
279
  newState?: State<any, any> | undefined;
239
280
  } | null = null;
240
281
 
241
- await this.db.$transaction(async tx => {
242
- const document = await this.getDocument(drive, id, tx);
243
- if (!document) {
244
- throw new Error(`Document with id ${id} not found`);
245
- }
246
- result = await callback(document);
247
-
248
- const { operations, header, newState } = result;
249
- return this._addDocumentOperations(
250
- tx,
251
- drive,
252
- id,
253
- operations,
254
- header,
255
- newState
256
- );
257
- }, { isolationLevel: "Serializable" });
282
+ await this.db.$transaction(
283
+ async tx => {
284
+ const document = await this.getDocument(drive, id, tx);
285
+ if (!document) {
286
+ throw new Error(`Document with id ${id} not found`);
287
+ }
288
+ result = await callback(document);
258
289
 
290
+ const { operations, header, newState } = result;
291
+ return this._addDocumentOperations(
292
+ tx,
293
+ drive,
294
+ id,
295
+ operations,
296
+ header
297
+ );
298
+ },
299
+ { isolationLevel: 'Serializable' }
300
+ );
259
301
 
260
302
  // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
261
303
  if (!result) {
@@ -269,14 +311,14 @@ export class PrismaStorage implements IDriveStorage {
269
311
  drive: string,
270
312
  id: string,
271
313
  operations: Operation[],
272
- header: DocumentHeader,
314
+ header: DocumentHeader
273
315
  ): Promise<void> {
274
316
  return this._addDocumentOperations(
275
317
  this.db,
276
318
  drive,
277
319
  id,
278
320
  operations,
279
- header,
321
+ header
280
322
  );
281
323
  }
282
324
 
@@ -300,25 +342,18 @@ export class PrismaStorage implements IDriveStorage {
300
342
  where: {
301
343
  id: id,
302
344
  driveId: driveId
303
- },
345
+ }
304
346
  });
305
347
  return count > 0;
306
348
  }
307
349
 
308
350
  async getDocument(driveId: string, id: string, tx?: Transaction) {
309
- const result = await (tx ?? this.db).document.findFirst({
351
+ const prisma = tx ?? this.db;
352
+ const result = await prisma.document.findUnique({
310
353
  where: {
311
- id: id,
312
- driveId: driveId
313
- },
314
- include: {
315
- operations: {
316
- orderBy: {
317
- index: 'asc'
318
- },
319
- include: {
320
- attachments: true
321
- }
354
+ id_driveId: {
355
+ driveId,
356
+ id
322
357
  }
323
358
  }
324
359
  });
@@ -327,8 +362,72 @@ export class PrismaStorage implements IDriveStorage {
327
362
  throw new Error(`Document with id ${id} not found`);
328
363
  }
329
364
 
365
+ // retrieves operations with resulting state
366
+ // for the last operation of each scope
367
+ const operations = await prisma.$queryRaw<
368
+ Prisma.$OperationPayload['scalars'][]
369
+ >`
370
+ WITH ranked_operations AS (
371
+ SELECT
372
+ *,
373
+ ROW_NUMBER() OVER (PARTITION BY scope ORDER BY index DESC) AS rn
374
+ FROM "Operation"
375
+ )
376
+ SELECT
377
+ "id",
378
+ "opId",
379
+ "scope",
380
+ "branch",
381
+ "index",
382
+ "skip",
383
+ "hash",
384
+ "timestamp",
385
+ "input",
386
+ "type",
387
+ "context",
388
+ CASE
389
+ WHEN rn = 1 THEN "resultingState"
390
+ ELSE NULL
391
+ END AS "resultingState"
392
+ FROM ranked_operations
393
+ WHERE "driveId" = ${driveId} AND "documentId" = ${id}
394
+ ORDER BY scope, index;
395
+ `;
396
+
397
+ const operationIds = operations.map(o => o.id);
398
+ const attachments = await prisma.attachment.findMany({
399
+ where: {
400
+ operationId: {
401
+ in: operationIds
402
+ }
403
+ }
404
+ });
405
+
406
+ const fileRegistry: FileRegistry = {};
407
+ const operationsByScope = operations.reduce<DocumentOperations<Action>>(
408
+ (acc, operation) => {
409
+ const scope = operation.scope as OperationScope;
410
+ if (!acc[scope]) {
411
+ acc[scope] = [];
412
+ }
413
+ const result = storageToOperation(operation);
414
+ result.attachments = attachments.filter(
415
+ a => a.operationId === operation.id
416
+ );
417
+ result.attachments.forEach(({ hash, ...file }) => {
418
+ fileRegistry[hash] = file;
419
+ });
420
+ acc[scope].push(result);
421
+ return acc;
422
+ },
423
+ {
424
+ global: [],
425
+ local: []
426
+ }
427
+ );
428
+
330
429
  const dbDoc = result;
331
- const doc = {
430
+ const doc: Document = {
332
431
  created: dbDoc.created.toISOString(),
333
432
  name: dbDoc.name ? dbDoc.name : '',
334
433
  documentType: dbDoc.documentType,
@@ -336,22 +435,19 @@ export class PrismaStorage implements IDriveStorage {
336
435
  DocumentDriveState,
337
436
  DocumentDriveLocalState
338
437
  >,
339
- state: JSON.parse(dbDoc.state!) as State<unknown, unknown>,
438
+ state: undefined,
340
439
  lastModified: new Date(dbDoc.lastModified).toISOString(),
341
- operations: {
342
- global: dbDoc.operations
343
- .filter(op => op.scope === 'global' && !op.clipboard)
344
- .map(storageToOperation),
345
- local: dbDoc.operations
346
- .filter(op => op.scope === 'local' && !op.clipboard)
347
- .map(storageToOperation)
348
- },
349
- clipboard: dbDoc.operations
350
- .filter(op => op.clipboard)
351
- .map(storageToOperation),
352
- revision: JSON.parse(dbDoc.revision) as Record<OperationScope, number>
440
+ operations: operationsByScope,
441
+ clipboard: [],
442
+ revision: JSON.parse(dbDoc.revision) as Record<
443
+ OperationScope,
444
+ number
445
+ >,
446
+ attachments: {}
353
447
  };
354
448
 
449
+
450
+
355
451
  return doc;
356
452
  }
357
453
 
@@ -409,4 +505,23 @@ export class PrismaStorage implements IDriveStorage {
409
505
  });
410
506
  await this.deleteDocument('drives', id);
411
507
  }
508
+
509
+ async getOperationResultingState(driveId: string, documentId: string, index: number, scope: string, branch: string): Promise<unknown> {
510
+ const operation = await this.db.operation.findUnique({
511
+ where: {
512
+ unique_operation: {
513
+ driveId,
514
+ documentId,
515
+ index,
516
+ scope,
517
+ branch
518
+ }
519
+ }
520
+ });
521
+ return operation?.resultingState?.toString();
522
+ }
523
+
524
+ getDriveOperationResultingState(drive: string, index: number, scope: string, branch: string): Promise<unknown> {
525
+ return this.getOperationResultingState("drives", drive, index, scope, branch);
526
+ }
412
527
  }
@@ -25,8 +25,8 @@ export class SequelizeStorage implements IDriveStorage {
25
25
  type: DataTypes.STRING,
26
26
  primaryKey: true
27
27
  },
28
- id: DataTypes.STRING,
29
- })
28
+ id: DataTypes.STRING
29
+ });
30
30
  const Document = this.db.define('document', {
31
31
  id: {
32
32
  type: DataTypes.STRING,
@@ -155,7 +155,7 @@ export class SequelizeStorage implements IDriveStorage {
155
155
  drive: string,
156
156
  id: string,
157
157
  operations: Operation[],
158
- header: DocumentHeader,
158
+ header: DocumentHeader
159
159
  ): Promise<void> {
160
160
  const document = await this.getDocument(drive, id);
161
161
  if (!document) {
@@ -177,7 +177,8 @@ export class SequelizeStorage implements IDriveStorage {
177
177
  timestamp: op.timestamp,
178
178
  type: op.type,
179
179
  scope: op.scope,
180
- branch: 'main'
180
+ branch: 'main',
181
+ opId: op.id
181
182
  }))
182
183
  );
183
184
 
@@ -284,8 +285,8 @@ export class SequelizeStorage implements IDriveStorage {
284
285
  where: {
285
286
  id: id,
286
287
  driveId: driveId
287
- },
288
- })
288
+ }
289
+ });
289
290
 
290
291
  return count > 0;
291
292
  }
@@ -323,6 +324,7 @@ export class SequelizeStorage implements IDriveStorage {
323
324
  input: JSON;
324
325
  type: string;
325
326
  scope: string;
327
+ opId?: string;
326
328
  }
327
329
  ];
328
330
  revision: Required<Record<OperationScope, number>>;
@@ -348,13 +350,15 @@ export class SequelizeStorage implements IDriveStorage {
348
350
  input: JSON;
349
351
  type: string;
350
352
  scope: string;
353
+ opId?: string;
351
354
  }) => ({
352
355
  hash: op.hash,
353
356
  index: op.index,
354
357
  timestamp: new Date(op.timestamp).toISOString(),
355
358
  input: op.input,
356
359
  type: op.type,
357
- scope: op.scope as OperationScope
360
+ scope: op.scope as OperationScope,
361
+ id: op.opId
358
362
  // attachments: fileRegistry
359
363
  })
360
364
  );
@@ -39,10 +39,10 @@ export interface IStorage {
39
39
  callback: (document: DocumentStorage) => Promise<{
40
40
  operations: Operation[];
41
41
  header: DocumentHeader;
42
- newState: State<any, any> | undefined
43
42
  }>
44
43
  ): Promise<void>;
45
44
  deleteDocument(drive: string, id: string): Promise<void>;
45
+ getOperationResultingState?(drive: string, id: string, index: number, scope: string, branch: string): Promise<unknown>;
46
46
  }
47
47
 
48
48
  export interface IDriveStorage extends IStorage {
@@ -64,4 +64,5 @@ export interface IDriveStorage extends IStorage {
64
64
  header: DocumentHeader;
65
65
  }>
66
66
  ): Promise<void>;
67
+ getDriveOperationResultingState?(drive: string, index: number, scope: string, branch: string): Promise<unknown>;
67
68
  }