document-drive 1.0.0-experimental.10 → 1.0.0-experimental.100

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.10",
3
+ "version": "1.0.0-experimental.100",
4
4
  "license": "AGPL-3.0-only",
5
5
  "type": "module",
6
6
  "module": "./src/index.ts",
@@ -34,11 +34,11 @@
34
34
  "test:watch": "vitest watch"
35
35
  },
36
36
  "peerDependencies": {
37
- "document-model": "1.1.0-experimental.1",
38
- "document-model-libs": "^1.37.0"
37
+ "document-model": "1.3.0",
38
+ "document-model-libs": "^1.53.1"
39
39
  },
40
40
  "optionalDependencies": {
41
- "@prisma/client": "5.11.0",
41
+ "@prisma/client": "5.14.0",
42
42
  "localforage": "^1.10.0",
43
43
  "redis": "^4.6.13",
44
44
  "sequelize": "^6.35.2",
@@ -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.1",
69
- "document-model-libs": "1.50.0-arbitrum.1",
68
+ "document-model": "^1.3.0",
69
+ "document-model-libs": "^1.53.1",
70
70
  "eslint": "^8.57.0",
71
71
  "eslint-config-prettier": "^9.1.0",
72
72
  "fake-indexeddb": "^5.0.2",
@@ -74,7 +74,7 @@
74
74
  "msw": "^2.2.13",
75
75
  "prettier": "^3.2.5",
76
76
  "prettier-plugin-organize-imports": "^3.2.4",
77
- "prisma": "^5.12.1",
77
+ "prisma": "^5.14.0",
78
78
  "semantic-release": "^23.0.8",
79
79
  "sequelize": "^6.37.2",
80
80
  "sqlite3": "^5.1.7",
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");
@@ -252,7 +236,7 @@ export class BaseQueueManager implements IQueueManager {
252
236
  } catch (e) {
253
237
  this.emit("jobFailed", nextJob, e as Error);
254
238
  } finally {
255
- queue.setBlocked(false);
239
+ await queue.setBlocked(false);
256
240
  await this.processNextJob();
257
241
  }
258
242
  }
@@ -1,5 +1,5 @@
1
1
  import { RedisClientType } from "redis";
2
- import { IJob, IQueue, IQueueManager, OperationJob } from "./types";
2
+ import { IJob, IQueue, IQueueManager, IServerDelegate, OperationJob } from "./types";
3
3
  import { BaseQueueManager } from "./base";
4
4
 
5
5
  export class RedisQueue<T, R> implements IQueue<T, R> {
@@ -10,18 +10,7 @@ export class RedisQueue<T, R> implements IQueue<T, R> {
10
10
  this.client = client;
11
11
  this.id = id;
12
12
  this.client.hSet("queues", id, "true");
13
-
14
- }
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);
13
+ this.client.hSet(this.id, "blocked", "false");
25
14
  }
26
15
 
27
16
  async addJob(data: any) {
@@ -44,13 +33,13 @@ export class RedisQueue<T, R> implements IQueue<T, R> {
44
33
  if (blocked) {
45
34
  await this.client.hSet(this.id, "blocked", "true");
46
35
  } else {
47
- await this.client.hDel(this.id, "blocked");
36
+ await this.client.hSet(this.id, "blocked", "false");
48
37
  }
49
38
  }
50
39
 
51
40
  async isBlocked() {
52
41
  const blockedResult = await this.client.hGet(this.id, "blocked");
53
- if (blockedResult) {
42
+ if (blockedResult === "true") {
54
43
  return true;
55
44
  }
56
45
 
@@ -80,7 +69,6 @@ export class RedisQueue<T, R> implements IQueue<T, R> {
80
69
  }
81
70
 
82
71
  async removeDependencies(job: IJob<OperationJob>) {
83
- const allDeps1 = await this.client.lLen(this.id + "-deps");
84
72
  await this.client.lRem(this.id + "-deps", 1, JSON.stringify(job));
85
73
  const allDeps = await this.client.lLen(this.id + "-deps");
86
74
  if (allDeps > 0) {
@@ -91,15 +79,15 @@ export class RedisQueue<T, R> implements IQueue<T, R> {
91
79
  }
92
80
 
93
81
  async isDeleted() {
94
- const deleted = await this.client.hGet(this.id, "deleted");
95
- return deleted === "true";
82
+ const active = await this.client.hGet("queues", this.id);
83
+ return active === "false";
96
84
  }
97
85
 
98
86
  async setDeleted(deleted: boolean) {
99
87
  if (deleted) {
100
- await this.client.hSet(this.id, "deleted", "true");
88
+ await this.client.hSet("queues", this.id, "false");
101
89
  } else {
102
- await this.client.hDel(this.id, "deleted");
90
+ await this.client.hSet("queues", this.id, "true");
103
91
  }
104
92
  }
105
93
  }
@@ -115,10 +103,12 @@ export class RedisQueueManager extends BaseQueueManager implements IQueueManager
115
103
 
116
104
  async init(delegate: IServerDelegate, onError: (error: Error) => void): Promise<void> {
117
105
  await super.init(delegate, onError);
118
- // load all queues
119
106
  const queues = await this.client.hGetAll("queues");
120
107
  for (const queueId in queues) {
121
- this.queues.push(new RedisQueue(queueId, this.client));
108
+ const active = await this.client.hGet("queues", queueId);
109
+ if (active === "true") {
110
+ this.queues.push(new RedisQueue(queueId, this.client));
111
+ }
122
112
  }
123
113
  }
124
114
 
@@ -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,7 @@ import {
20
20
  DocumentModel,
21
21
  Operation,
22
22
  OperationScope,
23
- State
23
+ utils as DocumentUtils
24
24
  } from 'document-model/document';
25
25
  import { createNanoEvents, Unsubscribe } from 'nanoevents';
26
26
  import { ICache } from '../cache';
@@ -425,7 +425,8 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
425
425
  type: operation.type,
426
426
  input: operation.input as object,
427
427
  skip: operation.skip,
428
- context: operation.context
428
+ context: operation.context,
429
+ id: operation.id
429
430
  }));
430
431
  }
431
432
 
@@ -630,6 +631,7 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
630
631
 
631
632
  async _processOperations<T extends Document, A extends Action>(
632
633
  drive: string,
634
+ documentId: string | undefined,
633
635
  storageDocument: DocumentStorage<T>,
634
636
  operations: Operation<A | BaseAction>[]
635
637
  ) {
@@ -667,7 +669,7 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
667
669
  : merge(trunk, invertedTrunk, reshuffleByTimestamp);
668
670
 
669
671
  const newOperations = newHistory.filter(
670
- (op: any) => trunk.length < 1 || precedes(trunk[trunk.length - 1]!, op)
672
+ (op) => trunk.length < 1 || precedes(trunk[trunk.length - 1]!, op)
671
673
  );
672
674
 
673
675
  for (const nextOperation of newOperations) {
@@ -676,15 +678,20 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
676
678
  // when dealing with a merge (tail.length > 0) we have to skip hash validation
677
679
  // for the operations that were re-indexed (previous hash becomes invalid due the new position in the history)
678
680
  if (tail.length > 0) {
679
- skipHashValidation = [...invertedTrunk, ...tail].some(
680
- invertedTrunkOp =>
681
- invertedTrunkOp.hash === nextOperation.hash
681
+ const sourceOperation = operations.find(
682
+ op => op.hash === nextOperation.hash
682
683
  );
684
+
685
+ skipHashValidation =
686
+ !sourceOperation ||
687
+ sourceOperation.index !== nextOperation.index ||
688
+ sourceOperation.skip !== nextOperation.skip;
683
689
  }
684
690
 
685
691
  try {
686
692
  const appliedResult = await this._performOperation(
687
693
  drive,
694
+ documentId,
688
695
  document,
689
696
  nextOperation,
690
697
  skipHashValidation
@@ -720,6 +727,10 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
720
727
  private _buildDocument<T extends Document>(
721
728
  documentStorage: DocumentStorage<T>, options?: GetDocumentOptions
722
729
  ): T {
730
+ if (documentStorage.state && (!options || options.checkHashes === false)) {
731
+ return documentStorage as T;
732
+ }
733
+
723
734
  const documentModel = this._getDocumentModel(
724
735
  documentStorage.documentType
725
736
  );
@@ -728,11 +739,7 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
728
739
  documentStorage.operations,
729
740
  options.revisions
730
741
  ) : documentStorage.operations;
731
- const operations = baseUtils.documentHelpers.grabageCollectDocumentOperations(revisionOperations);
732
-
733
- if (documentStorage.state && (!options || options.checkHashes === false)) {
734
- return documentStorage as T;
735
- }
742
+ const operations = baseUtils.documentHelpers.garbageCollectDocumentOperations(revisionOperations);
736
743
 
737
744
  return baseUtils.replayDocument(
738
745
  documentStorage.initialState,
@@ -741,12 +748,17 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
741
748
  undefined,
742
749
  documentStorage,
743
750
  undefined,
744
- { checkHashes: options?.checkHashes ?? true }
751
+ {
752
+ ...options,
753
+ checkHashes: options?.checkHashes ?? true,
754
+ reuseOperationResultingState: options?.checkHashes ?? true
755
+ }
745
756
  ) as T;
746
757
  }
747
758
 
748
759
  private async _performOperation<T extends Document>(
749
760
  drive: string,
761
+ id: string | undefined,
750
762
  document: T,
751
763
  operation: Operation,
752
764
  skipHashValidation = false
@@ -756,6 +768,25 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
756
768
  const signalResults: SignalResult[] = [];
757
769
  let newDocument = document;
758
770
 
771
+ const scope = operation.scope;
772
+ const documentOperations = DocumentUtils.documentHelpers.garbageCollectDocumentOperations(
773
+ {
774
+ ...document.operations,
775
+ [scope]: DocumentUtils.documentHelpers.skipHeaderOperations(
776
+ document.operations[scope],
777
+ operation,
778
+ ),
779
+ },
780
+ );
781
+
782
+ const lastRemainingOperation = documentOperations[scope].at(-1);
783
+ // if the latest operation doesn't have a resulting state then tries
784
+ // to retrieve it from the db to avoid rerunning all the operations
785
+ if (lastRemainingOperation && !lastRemainingOperation.resultingState) {
786
+ lastRemainingOperation.resultingState = await (id ? this.storage.getOperationResultingState?.(drive, id, lastRemainingOperation.index, lastRemainingOperation.scope, "main") :
787
+ this.storage.getDriveOperationResultingState?.(drive, lastRemainingOperation.index, lastRemainingOperation.scope, "main"))
788
+ }
789
+
759
790
  const operationSignals: (() => Promise<SignalResult>)[] = [];
760
791
  newDocument = documentModel.reducer(
761
792
  newDocument,
@@ -792,7 +823,7 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
792
823
  );
793
824
  }
794
825
  },
795
- { skip: operation.skip }
826
+ { skip: operation.skip, reuseOperationResultingState: true }
796
827
  ) as T;
797
828
 
798
829
  const appliedOperation = newDocument.operations[operation.scope].filter(
@@ -837,7 +868,6 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
837
868
  callback: (document: DocumentStorage) => Promise<{
838
869
  operations: Operation[];
839
870
  header: DocumentHeader;
840
- newState: State<any, any> | undefined;
841
871
  }>
842
872
  ) {
843
873
  if (!this.storage.addDocumentOperationsWithTransaction) {
@@ -884,7 +914,6 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
884
914
  }
885
915
  });
886
916
  const unsubscribeError = this.queueManager.on('jobFailed', (job, error) => {
887
- console.log("test")
888
917
  if (job.jobId === jobId) {
889
918
  unsubscribe();
890
919
  unsubscribeError();
@@ -913,6 +942,7 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
913
942
  await this._addOperations(drive, id, async documentStorage => {
914
943
  const result = await this._processOperations(
915
944
  drive,
945
+ id,
916
946
  documentStorage,
917
947
  operations
918
948
  );
@@ -1098,7 +1128,7 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
1098
1128
  const result = await this._processOperations<
1099
1129
  DocumentDriveDocument,
1100
1130
  DocumentDriveAction
1101
- >(drive, documentStorage, operations.slice());
1131
+ >(drive, undefined, documentStorage, operations.slice());
1102
1132
 
1103
1133
  document = result.document;
1104
1134
  operationsApplied.push(...result.operationsApplied);
@@ -1259,7 +1289,7 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
1259
1289
  const document = await this.getDrive(drive);
1260
1290
  const operations = this._buildOperations(document, actions);
1261
1291
  const result = await this.queueDriveOperations(drive, operations);
1262
- return result as IOperationResult<DocumentDriveDocument>;
1292
+ return result;
1263
1293
  }
1264
1294
 
1265
1295
  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,7 +48,10 @@ function storageToOperation(
34
48
  input: JSON.parse(op.input),
35
49
  type: op.type,
36
50
  scope: op.scope as OperationScope,
37
- // attachments: fileRegistry
51
+ resultingState: op.resultingState
52
+ ? op.resultingState.toString()
53
+ : undefined,
54
+ attachments: op.attachments
38
55
  };
39
56
  if (op.context) {
40
57
  operation.context = op.context as Prisma.JsonObject;
@@ -44,27 +61,32 @@ function storageToOperation(
44
61
 
45
62
  export type PrismaStorageOptions = {
46
63
  transactionRetryBackoff?: IBackOffOptions;
47
- }
64
+ };
48
65
 
49
- function getRetryTransactionsClient<T extends PrismaClient>(prisma: T, backOffOptions?: Partial<IBackOffOptions>) {
66
+ function getRetryTransactionsClient<T extends PrismaClient>(
67
+ prisma: T,
68
+ backOffOptions?: Partial<IBackOffOptions>
69
+ ) {
50
70
  return prisma.$extends({
51
71
  client: {
52
- $transaction: (...args: Parameters<T["$transaction"]>) => {
72
+ $transaction: (...args: Parameters<T['$transaction']>) => {
53
73
  // eslint-disable-next-line prefer-spread
54
74
  return backOff(() => prisma.$transaction.apply(prisma, args), {
55
- retry: (e) => {
75
+ retry: e => {
56
76
  // Retry the transaction only if the error was due to a write conflict or deadlock
57
77
  // See: https://www.prisma.io/docs/reference/api-reference/error-reference#p2034
58
- return (e as { code: string }).code === "P2034";
78
+ return (e as { code: string }).code === 'P2034';
59
79
  },
60
- ...backOffOptions,
80
+ ...backOffOptions
61
81
  });
62
82
  }
63
83
  }
64
84
  });
65
85
  }
66
86
 
67
- type ExtendedPrismaClient = ReturnType<typeof getRetryTransactionsClient<PrismaClient>>;
87
+ type ExtendedPrismaClient = ReturnType<
88
+ typeof getRetryTransactionsClient<PrismaClient>
89
+ >;
68
90
 
69
91
  export class PrismaStorage implements IDriveStorage {
70
92
  private db: ExtendedPrismaClient;
@@ -73,9 +95,8 @@ export class PrismaStorage implements IDriveStorage {
73
95
  const backOffOptions = options?.transactionRetryBackoff;
74
96
  this.db = getRetryTransactionsClient(db, {
75
97
  ...backOffOptions,
76
- jitter: backOffOptions?.jitter ?? "full"
98
+ jitter: backOffOptions?.jitter ?? 'full'
77
99
  });
78
-
79
100
  }
80
101
 
81
102
  async createDrive(id: string, drive: DocumentDriveStorage): Promise<void> {
@@ -136,8 +157,7 @@ export class PrismaStorage implements IDriveStorage {
136
157
  initialState: JSON.stringify(document.initialState),
137
158
  lastModified: document.lastModified,
138
159
  revision: JSON.stringify(document.revision),
139
- id,
140
- state: JSON.stringify(document.state)
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 => ({
@@ -168,7 +182,10 @@ export class PrismaStorage implements IDriveStorage {
168
182
  scope: op.scope,
169
183
  branch: 'main',
170
184
  skip: op.skip,
171
- context: op.context
185
+ context: op.context,
186
+ resultingState: op.resultingState
187
+ ? Buffer.from(JSON.stringify(op.resultingState))
188
+ : undefined
172
189
  }))
173
190
  });
174
191
 
@@ -179,10 +196,34 @@ export class PrismaStorage implements IDriveStorage {
179
196
  },
180
197
  data: {
181
198
  lastModified: header.lastModified,
182
- revision: JSON.stringify(header.revision),
183
- state: JSON.stringify(newState)
199
+ revision: JSON.stringify(header.revision)
184
200
  }
185
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
+ );
186
227
  } catch (e) {
187
228
  // P2002: Unique constraint failed
188
229
  // Operation with existing index
@@ -209,6 +250,7 @@ export class PrismaStorage implements IDriveStorage {
209
250
  );
210
251
 
211
252
  if (!existingOperation || !conflictOp) {
253
+ console.error(e);
212
254
  throw e;
213
255
  } else {
214
256
  throw new ConflictOperationError(
@@ -228,7 +270,7 @@ export class PrismaStorage implements IDriveStorage {
228
270
  callback: (document: DocumentStorage) => Promise<{
229
271
  operations: Operation[];
230
272
  header: DocumentHeader;
231
- newState?: State<any, any> | undefined
273
+ newState?: State<any, any> | undefined;
232
274
  }>
233
275
  ) {
234
276
  let result: {
@@ -237,24 +279,25 @@ export class PrismaStorage implements IDriveStorage {
237
279
  newState?: State<any, any> | undefined;
238
280
  } | null = null;
239
281
 
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" });
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);
257
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
+ );
258
301
 
259
302
  // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
260
303
  if (!result) {
@@ -268,14 +311,14 @@ export class PrismaStorage implements IDriveStorage {
268
311
  drive: string,
269
312
  id: string,
270
313
  operations: Operation[],
271
- header: DocumentHeader,
314
+ header: DocumentHeader
272
315
  ): Promise<void> {
273
316
  return this._addDocumentOperations(
274
317
  this.db,
275
318
  drive,
276
319
  id,
277
320
  operations,
278
- header,
321
+ header
279
322
  );
280
323
  }
281
324
 
@@ -299,25 +342,18 @@ export class PrismaStorage implements IDriveStorage {
299
342
  where: {
300
343
  id: id,
301
344
  driveId: driveId
302
- },
345
+ }
303
346
  });
304
347
  return count > 0;
305
348
  }
306
349
 
307
350
  async getDocument(driveId: string, id: string, tx?: Transaction) {
308
- const result = await (tx ?? this.db).document.findFirst({
351
+ const prisma = tx ?? this.db;
352
+ const result = await prisma.document.findUnique({
309
353
  where: {
310
- id: id,
311
- driveId: driveId
312
- },
313
- include: {
314
- operations: {
315
- orderBy: {
316
- index: 'asc'
317
- },
318
- include: {
319
- attachments: true
320
- }
354
+ id_driveId: {
355
+ driveId,
356
+ id
321
357
  }
322
358
  }
323
359
  });
@@ -326,8 +362,72 @@ export class PrismaStorage implements IDriveStorage {
326
362
  throw new Error(`Document with id ${id} not found`);
327
363
  }
328
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
+
329
429
  const dbDoc = result;
330
- const doc = {
430
+ const doc: Document = {
331
431
  created: dbDoc.created.toISOString(),
332
432
  name: dbDoc.name ? dbDoc.name : '',
333
433
  documentType: dbDoc.documentType,
@@ -335,22 +435,19 @@ export class PrismaStorage implements IDriveStorage {
335
435
  DocumentDriveState,
336
436
  DocumentDriveLocalState
337
437
  >,
338
- state: JSON.parse(dbDoc.state) as State<unknown, unknown>,
438
+ state: undefined,
339
439
  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: 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: {}
352
447
  };
353
448
 
449
+
450
+
354
451
  return doc;
355
452
  }
356
453
 
@@ -408,4 +505,23 @@ export class PrismaStorage implements IDriveStorage {
408
505
  });
409
506
  await this.deleteDocument('drives', id);
410
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
+ }
411
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
  }
@@ -1,22 +0,0 @@
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
- }