document-drive 1.0.0-alpha.92 → 1.0.0-alpha.93

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "document-drive",
3
- "version": "1.0.0-alpha.92",
3
+ "version": "1.0.0-alpha.93",
4
4
  "license": "AGPL-3.0-only",
5
5
  "type": "module",
6
6
  "module": "./src/index.ts",
@@ -46,6 +46,7 @@
46
46
  "sqlite3": "^5.1.7"
47
47
  },
48
48
  "dependencies": {
49
+ "change-case": "^5.4.4",
49
50
  "exponential-backoff": "^3.1.1",
50
51
  "graphql": "^16.9.0",
51
52
  "graphql-request": "^6.1.0",
@@ -82,7 +83,8 @@
82
83
  "sqlite3": "^5.1.7",
83
84
  "typescript": "^5.5.3",
84
85
  "vitest": "^2.0.5",
85
- "webdriverio": "^9.0.9"
86
+ "webdriverio": "^9.0.9",
87
+ "vitest-fetch-mock": "^0.3.0"
86
88
  },
87
89
  "packageManager": "pnpm@9.1.4+sha256.30a1801ac4e723779efed13a21f4c39f9eb6c9fbb4ced101bce06b422593d7c9"
88
90
  }
@@ -0,0 +1,19 @@
1
+ export abstract class ReadDriveError extends Error {}
2
+
3
+ export class ReadDriveNotFoundError extends ReadDriveError {
4
+ constructor(driveId: string) {
5
+ super(`Read drive ${driveId} not found.`);
6
+ }
7
+ }
8
+
9
+ export class ReadDriveSlugNotFoundError extends ReadDriveError {
10
+ constructor(slug: string) {
11
+ super(`Read drive with slug ${slug} not found.`);
12
+ }
13
+ }
14
+
15
+ export class ReadDocumentNotFoundError extends ReadDriveError {
16
+ constructor(drive: string, id: string) {
17
+ super(`Document with id ${id} not found on read drive ${drive}.`);
18
+ }
19
+ }
@@ -0,0 +1,127 @@
1
+ import { ListenerFilter } from 'document-model-libs/document-drive';
2
+ import { Document } from 'document-model/document';
3
+ import { DocumentDriveServerConstructor, RemoteDriveOptions } from '../server';
4
+ import { logger } from '../utils/logger';
5
+ import { ReadDriveSlugNotFoundError } from './errors';
6
+ import { ReadModeService } from './service';
7
+ import {
8
+ IReadModeDriveServer,
9
+ IReadModeDriveService,
10
+ ReadDrive,
11
+ ReadDrivesListener,
12
+ ReadModeDriveServerMixin
13
+ } from './types';
14
+
15
+ export * from './errors';
16
+ export * from './types';
17
+
18
+ export function ReadModeServer<TBase extends DocumentDriveServerConstructor>(
19
+ Base: TBase
20
+ ): ReadModeDriveServerMixin {
21
+ return class ReadMode extends Base implements IReadModeDriveServer {
22
+ #readModeStorage: IReadModeDriveService;
23
+ #listeners = new Set<ReadDrivesListener>();
24
+
25
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
26
+ constructor(...args: any[]) {
27
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
28
+ super(...args);
29
+
30
+ this.#readModeStorage = new ReadModeService(
31
+ this.getDocumentModel.bind(this)
32
+ );
33
+
34
+ this.#buildDrives()
35
+ .then(drives => {
36
+ if (drives.length) {
37
+ this.#notifyListeners(drives, 'add');
38
+ }
39
+ })
40
+ .catch(logger.error);
41
+ }
42
+
43
+ async #buildDrives() {
44
+ const driveIds = await this.getReadDrives();
45
+ const drives = (
46
+ await Promise.all(
47
+ driveIds.map(driveId => this.getReadDrive(driveId))
48
+ )
49
+ ).filter(drive => !(drive instanceof Error)) as ReadDrive[];
50
+ return drives;
51
+ }
52
+
53
+ #notifyListeners(drives: ReadDrive[], operation: 'add' | 'delete') {
54
+ this.#listeners.forEach(listener => listener(drives, operation));
55
+ }
56
+
57
+ getReadDrives(): Promise<string[]> {
58
+ return this.#readModeStorage.getReadDrives();
59
+ }
60
+
61
+ getReadDrive(id: string) {
62
+ return this.#readModeStorage.getReadDrive(id);
63
+ }
64
+
65
+ getReadDriveBySlug(
66
+ slug: string
67
+ ): Promise<ReadDrive | ReadDriveSlugNotFoundError> {
68
+ return this.#readModeStorage.getReadDriveBySlug(slug);
69
+ }
70
+
71
+ getReadDriveContext(id: string) {
72
+ return this.#readModeStorage.getReadDriveContext(id);
73
+ }
74
+
75
+ async addReadDrive(url: string, filter?: ListenerFilter) {
76
+ await this.#readModeStorage.addReadDrive(url, filter);
77
+ this.#notifyListeners(await this.#buildDrives(), 'add');
78
+ }
79
+
80
+ fetchDrive(id: string) {
81
+ return this.#readModeStorage.fetchDrive(id);
82
+ }
83
+
84
+ fetchDocument<D extends Document>(
85
+ driveId: string,
86
+ documentId: string,
87
+ documentType: string
88
+ ) {
89
+ return this.#readModeStorage.fetchDocument<D>(
90
+ driveId,
91
+ documentId,
92
+ documentType
93
+ );
94
+ }
95
+
96
+ async deleteReadDrive(id: string) {
97
+ const error = await this.#readModeStorage.deleteReadDrive(id);
98
+ if (error) {
99
+ return error;
100
+ }
101
+
102
+ this.#notifyListeners(await this.#buildDrives(), 'delete');
103
+ }
104
+
105
+ async migrateReadDrive(id: string, options: RemoteDriveOptions) {
106
+ const result = await this.getReadDriveContext(id);
107
+ if (result instanceof Error) {
108
+ return result;
109
+ }
110
+
111
+ try {
112
+ const newDrive = await this.addRemoteDrive(result.url, options);
113
+ return newDrive;
114
+ } catch (error) {
115
+ // if an error is thrown, then add the read drive again
116
+ logger.error(error);
117
+ await this.addReadDrive(result.url, result.filter);
118
+ throw error;
119
+ }
120
+ }
121
+
122
+ onReadDrivesUpdate(listener: ReadDrivesListener) {
123
+ this.#listeners.add(listener);
124
+ return Promise.resolve(() => this.#listeners.delete(listener));
125
+ }
126
+ };
127
+ }
@@ -0,0 +1,212 @@
1
+ import type {
2
+ DocumentDriveDocument,
3
+ ListenerFilter
4
+ } from 'document-model-libs/document-drive';
5
+ import * as DocumentDrive from 'document-model-libs/document-drive';
6
+ import { Document, DocumentModel } from 'document-model/document';
7
+ import { GraphQLError } from 'graphql';
8
+ import { DocumentModelNotFoundError } from '../server/error';
9
+ import { fetchDocument, requestPublicDrive } from '../utils/graphql';
10
+ import {
11
+ ReadDocumentNotFoundError,
12
+ ReadDriveError,
13
+ ReadDriveNotFoundError,
14
+ ReadDriveSlugNotFoundError
15
+ } from './errors';
16
+ import {
17
+ GetDocumentModel,
18
+ InferDocumentLocalState,
19
+ InferDocumentOperation,
20
+ InferDocumentState,
21
+ IReadModeDriveService,
22
+ ReadDrive,
23
+ ReadDriveContext
24
+ } from './types';
25
+
26
+ export class ReadModeService implements IReadModeDriveService {
27
+ #getDocumentModel: GetDocumentModel;
28
+ #drives = new Map<
29
+ string,
30
+ { drive: Omit<ReadDrive, 'readContext'>; context: ReadDriveContext }
31
+ >();
32
+
33
+ constructor(getDocumentModel: GetDocumentModel) {
34
+ this.#getDocumentModel = getDocumentModel;
35
+ }
36
+
37
+ #parseGraphQLErrors(
38
+ errors: GraphQLError[],
39
+ driveId: string,
40
+ documentId?: string
41
+ ) {
42
+ for (const error of errors) {
43
+ if (error.message === `Drive with id ${driveId} not found`) {
44
+ return new ReadDriveNotFoundError(driveId);
45
+ } else if (
46
+ documentId &&
47
+ error.message === `Document with id ${documentId} not found`
48
+ ) {
49
+ return new ReadDocumentNotFoundError(driveId, documentId);
50
+ }
51
+ }
52
+ const firstError = errors.at(0);
53
+ if (firstError) {
54
+ return firstError;
55
+ }
56
+ }
57
+
58
+ async #fetchDrive(id: string, url: string) {
59
+ const { errors, document } = await fetchDocument<DocumentDriveDocument>(
60
+ url,
61
+ id,
62
+ DocumentDrive
63
+ );
64
+ const error = errors ? this.#parseGraphQLErrors(errors, id) : undefined;
65
+ return error || document;
66
+ }
67
+
68
+ async fetchDrive(id: string): Promise<ReadDrive | ReadDriveNotFoundError> {
69
+ const drive = this.#drives.get(id);
70
+ if (!drive) {
71
+ return new ReadDriveNotFoundError(id);
72
+ }
73
+ const document = await this.fetchDocument<DocumentDriveDocument>(
74
+ id,
75
+ id,
76
+ DocumentDrive.documentModel.id
77
+ );
78
+ if (document instanceof Error) {
79
+ return document;
80
+ }
81
+ const result = { ...document, readContext: drive.context };
82
+ drive.drive = result;
83
+ return result;
84
+ }
85
+
86
+ async fetchDocument<D extends Document>(
87
+ driveId: string,
88
+ documentId: string,
89
+ documentType: DocumentModel<
90
+ InferDocumentState<D>,
91
+ InferDocumentOperation<D>,
92
+ InferDocumentLocalState<D>
93
+ >['documentModel']['id']
94
+ ): Promise<
95
+ | Document<
96
+ InferDocumentState<D>,
97
+ InferDocumentOperation<D>,
98
+ InferDocumentLocalState<D>
99
+ >
100
+ | DocumentModelNotFoundError
101
+ | ReadDriveNotFoundError
102
+ | ReadDocumentNotFoundError
103
+ > {
104
+ const drive = this.#drives.get(driveId);
105
+ if (!drive) {
106
+ return new ReadDriveNotFoundError(driveId);
107
+ }
108
+
109
+ let documentModel:
110
+ | DocumentModel<
111
+ InferDocumentState<D>,
112
+ InferDocumentOperation<D>,
113
+ InferDocumentLocalState<D>
114
+ >
115
+ | undefined = undefined;
116
+ try {
117
+ documentModel = this.#getDocumentModel(
118
+ documentType
119
+ ) as unknown as DocumentModel<
120
+ InferDocumentState<D>,
121
+ InferDocumentOperation<D>,
122
+ InferDocumentLocalState<D>
123
+ >;
124
+ } catch (error) {
125
+ return new DocumentModelNotFoundError(documentType, error);
126
+ }
127
+
128
+ const { url } = drive.context;
129
+ const { errors, document } = await fetchDocument<D>(
130
+ url,
131
+ documentId,
132
+ documentModel
133
+ );
134
+
135
+ if (errors) {
136
+ const error = this.#parseGraphQLErrors(errors, driveId, documentId);
137
+ if (error instanceof ReadDriveError) {
138
+ return error;
139
+ } else if (error) {
140
+ throw error;
141
+ }
142
+ }
143
+
144
+ if (!document) {
145
+ return new ReadDocumentNotFoundError(driveId, documentId);
146
+ }
147
+
148
+ return document;
149
+ }
150
+
151
+ async addReadDrive(url: string, filter?: ListenerFilter): Promise<void> {
152
+ const { id } = await requestPublicDrive(url);
153
+
154
+ const result = await this.#fetchDrive(id, url);
155
+ if (result instanceof Error) {
156
+ throw result;
157
+ } else if (!result) {
158
+ throw new Error(`Drive "${id}" not found at ${url}`);
159
+ }
160
+ this.#drives.set(id, {
161
+ drive: result as unknown as ReadDrive,
162
+ context: {
163
+ url,
164
+ filter: filter ?? {
165
+ documentId: ['*'],
166
+ documentType: ['*'],
167
+ branch: ['*'],
168
+ scope: ['*']
169
+ }
170
+ }
171
+ });
172
+ }
173
+
174
+ async getReadDrives(): Promise<string[]> {
175
+ return Promise.resolve([...this.#drives.keys()]);
176
+ }
177
+
178
+ async getReadDrive(id: string) {
179
+ const result = this.#drives.get(id);
180
+ return Promise.resolve(
181
+ result
182
+ ? { ...result.drive, readContext: result.context }
183
+ : new ReadDriveNotFoundError(id)
184
+ );
185
+ }
186
+
187
+ async getReadDriveBySlug(
188
+ slug: string
189
+ ): Promise<ReadDrive | ReadDriveSlugNotFoundError> {
190
+ const readDrive = [...this.#drives.values()].find(
191
+ ({ drive }) => drive.state.global.slug === slug
192
+ );
193
+ return Promise.resolve(
194
+ readDrive
195
+ ? { ...readDrive.drive, readContext: readDrive.context }
196
+ : new ReadDriveSlugNotFoundError(slug)
197
+ );
198
+ }
199
+
200
+ getReadDriveContext(id: string) {
201
+ return Promise.resolve(
202
+ this.#drives.get(id)?.context ?? new ReadDriveNotFoundError(id)
203
+ );
204
+ }
205
+
206
+ deleteReadDrive(id: string): Promise<ReadDriveNotFoundError | undefined> {
207
+ const deleted = this.#drives.delete(id);
208
+ return Promise.resolve(
209
+ deleted ? undefined : new ReadDriveNotFoundError(id)
210
+ );
211
+ }
212
+ }
@@ -0,0 +1,103 @@
1
+ import {
2
+ DocumentDriveDocument,
3
+ ListenerFilter
4
+ } from 'document-model-libs/document-drive';
5
+ import { Action, Document, DocumentModel } from 'document-model/document';
6
+ import { DocumentDriveServerMixin, RemoteDriveOptions } from '../server';
7
+ import { DocumentModelNotFoundError } from '../server/error';
8
+ import {
9
+ ReadDocumentNotFoundError,
10
+ ReadDriveNotFoundError,
11
+ ReadDriveSlugNotFoundError
12
+ } from './errors';
13
+
14
+ // TODO: move these types to the document-model package
15
+ export type InferDocumentState<D extends Document> =
16
+ D extends Document<infer S> ? S : never;
17
+
18
+ export type InferDocumentOperation<D extends Document> =
19
+ D extends Document<unknown, infer A> ? A : never;
20
+
21
+ export type InferDocumentLocalState<D extends Document> =
22
+ D extends Document<unknown, Action, infer L> ? L : never;
23
+
24
+ export type InferDocumentGenerics<D extends Document> = {
25
+ state: InferDocumentState<D>;
26
+ action: InferDocumentOperation<D>;
27
+ logger: InferDocumentLocalState<D>;
28
+ };
29
+
30
+ export type ReadModeDriveServerMixin =
31
+ DocumentDriveServerMixin<IReadModeDriveServer>;
32
+
33
+ export type ReadDrivesListener = (
34
+ drives: ReadDrive[],
35
+ operation: 'add' | 'delete'
36
+ ) => void;
37
+
38
+ export type ReadDrivesListenerUnsubscribe = () => void;
39
+
40
+ export interface IReadModeDriveServer extends IReadModeDriveService {
41
+ migrateReadDrive(
42
+ id: string,
43
+ options: RemoteDriveOptions
44
+ ): Promise<DocumentDriveDocument | ReadDriveNotFoundError>;
45
+ onReadDrivesUpdate(
46
+ listener: ReadDrivesListener
47
+ ): Promise<ReadDrivesListenerUnsubscribe>; // TODO: make DriveEvents extensible and reuse event emitter
48
+ }
49
+
50
+ export type ReadDriveContext = {
51
+ url: string;
52
+ filter: ListenerFilter;
53
+ };
54
+
55
+ export type ReadDrive = DocumentDriveDocument & {
56
+ readContext: ReadDriveContext;
57
+ };
58
+
59
+ export type IsDocument<D extends Document> =
60
+ (<G>() => G extends D ? 1 : 2) extends <G>() => G extends Document ? 1 : 2
61
+ ? true
62
+ : false;
63
+
64
+ export interface IReadModeDriveService {
65
+ addReadDrive(url: string, filter?: ListenerFilter): Promise<void>;
66
+
67
+ getReadDrives(): Promise<string[]>;
68
+
69
+ getReadDriveBySlug(
70
+ slug: string
71
+ ): Promise<ReadDrive | ReadDriveSlugNotFoundError>;
72
+
73
+ getReadDrive(id: string): Promise<ReadDrive | ReadDriveNotFoundError>;
74
+
75
+ getReadDriveContext(
76
+ id: string
77
+ ): Promise<ReadDriveContext | ReadDriveNotFoundError>;
78
+
79
+ fetchDrive(id: string): Promise<ReadDrive | ReadDriveNotFoundError>;
80
+
81
+ fetchDocument<D extends Document>(
82
+ driveId: string,
83
+ documentId: string,
84
+ documentType: DocumentModel<
85
+ InferDocumentState<D>,
86
+ InferDocumentOperation<D>,
87
+ InferDocumentLocalState<D>
88
+ >['documentModel']['id']
89
+ ): Promise<
90
+ | Document<
91
+ InferDocumentState<D>,
92
+ InferDocumentOperation<D>,
93
+ InferDocumentLocalState<D>
94
+ >
95
+ | DocumentModelNotFoundError
96
+ | ReadDriveNotFoundError
97
+ | ReadDocumentNotFoundError
98
+ >;
99
+
100
+ deleteReadDrive(id: string): Promise<ReadDriveNotFoundError | undefined>;
101
+ }
102
+
103
+ export type GetDocumentModel = (documentType: string) => DocumentModel;
@@ -1,6 +1,14 @@
1
1
  import type { Operation } from 'document-model/document';
2
2
  import type { ErrorStatus } from './types';
3
3
 
4
+ export class DocumentModelNotFoundError extends Error {
5
+ constructor(
6
+ public id: string,
7
+ cause?: unknown
8
+ ) {
9
+ super(`Document model "${id}" not found`, { cause });
10
+ }
11
+ }
4
12
  export class OperationError extends Error {
5
13
  status: ErrorStatus;
6
14
  operation: Operation | undefined;
@@ -35,6 +35,7 @@ import {
35
35
  Job,
36
36
  OperationJob
37
37
  } from '../queue/types';
38
+ import { ReadModeServer } from '../read-mode';
38
39
  import { MemoryStorage } from '../storage/memory';
39
40
  import type {
40
41
  DocumentDriveStorage,
@@ -77,15 +78,17 @@ import {
77
78
  StrandUpdateSource
78
79
  } from './listener/transmitter';
79
80
  import {
81
+ AbstractDocumentDriveServer,
80
82
  AddOperationOptions,
81
- BaseDocumentDriveServer,
82
83
  DefaultListenerManagerOptions,
83
84
  DocumentDriveServerOptions,
84
85
  DriveEvents,
85
86
  GetDocumentOptions,
86
87
  GetStrandsOptions,
88
+ IBaseDocumentDriveServer,
87
89
  IOperationResult,
88
90
  ListenerState,
91
+ RemoteDriveAccessLevel,
89
92
  RemoteDriveOptions,
90
93
  StrandUpdate,
91
94
  SynchronizationUnitQuery,
@@ -102,8 +105,14 @@ import { filterOperationsByRevision } from './utils';
102
105
  export * from './listener';
103
106
  export type * from './types';
104
107
 
108
+ export * from '../read-mode';
109
+
105
110
  export const PULL_DRIVE_INTERVAL = 5000;
106
- export class DocumentDriveServer extends BaseDocumentDriveServer {
111
+
112
+ export class BaseDocumentDriveServer
113
+ extends AbstractDocumentDriveServer
114
+ implements IBaseDocumentDriveServer
115
+ {
107
116
  private emitter = createNanoEvents<DriveEvents>();
108
117
  private cache: ICache;
109
118
  private documentModels: DocumentModel[];
@@ -131,11 +140,10 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
131
140
  ) {
132
141
  super();
133
142
  this.options = {
134
- defaultRemoteDrives: [],
135
- removeOldRemoteDrives: {
136
- strategy: 'preserve-all'
137
- },
138
143
  ...options,
144
+ defaultDrives: {
145
+ ...options?.defaultDrives
146
+ },
139
147
  listenerManager: {
140
148
  ...DefaultListenerManagerOptions,
141
149
  ...options?.listenerManager
@@ -180,6 +188,13 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
180
188
  return this.defaultDrivesManager.getDefaultRemoteDrives();
181
189
  }
182
190
 
191
+ setDefaultDriveAccessLevel(url: string, level: RemoteDriveAccessLevel) {
192
+ return this.defaultDrivesManager.setDefaultDriveAccessLevel(url, level);
193
+ }
194
+ setAllDefaultDrivesAccessLevel(level: RemoteDriveAccessLevel) {
195
+ return this.defaultDrivesManager.setAllDefaultDrivesAccessLevel(level);
196
+ }
197
+
183
198
  private getOperationSource(source: StrandUpdateSource) {
184
199
  return source.type === 'local' ? 'push' : 'pull';
185
200
  }
@@ -899,7 +914,7 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
899
914
  }));
900
915
  }
901
916
 
902
- private _getDocumentModel(documentType: string) {
917
+ protected getDocumentModel(documentType: string) {
903
918
  const documentModel = this.documentModels.find(
904
919
  model => model.documentModel.id === documentType
905
920
  );
@@ -1093,7 +1108,7 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
1093
1108
  // if no document was provided then create a new one
1094
1109
  const document =
1095
1110
  input.document ??
1096
- this._getDocumentModel(input.documentType).utils.createDocument();
1111
+ this.getDocumentModel(input.documentType).utils.createDocument();
1097
1112
 
1098
1113
  // stores document information
1099
1114
  const documentStorage: DocumentStorage = {
@@ -1331,7 +1346,7 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
1331
1346
  return documentStorage as T;
1332
1347
  }
1333
1348
 
1334
- const documentModel = this._getDocumentModel(
1349
+ const documentModel = this.getDocumentModel(
1335
1350
  documentStorage.documentType
1336
1351
  );
1337
1352
 
@@ -1369,7 +1384,7 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
1369
1384
  operation: Operation,
1370
1385
  skipHashValidation = false
1371
1386
  ) {
1372
- const documentModel = this._getDocumentModel(document.documentType);
1387
+ const documentModel = this.getDocumentModel(document.documentType);
1373
1388
 
1374
1389
  const signalResults: SignalResult[] = [];
1375
1390
  let newDocument = document;
@@ -2205,7 +2220,7 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
2205
2220
  actions: (T | BaseAction)[]
2206
2221
  ): Operation<T | BaseAction>[] {
2207
2222
  const operations: Operation<T | BaseAction>[] = [];
2208
- const { reducer } = this._getDocumentModel(document.documentType);
2223
+ const { reducer } = this.getDocumentModel(document.documentType);
2209
2224
  for (const action of actions) {
2210
2225
  document = reducer(document, action);
2211
2226
  const operation = document.operations[action.scope].slice().pop();
@@ -2369,3 +2384,5 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
2369
2384
  return this.emitter.emit(event, ...args);
2370
2385
  }
2371
2386
  }
2387
+
2388
+ export const DocumentDriveServer = ReadModeServer(BaseDocumentDriveServer);
@@ -1,7 +1,7 @@
1
1
  import { Document, OperationScope } from 'document-model/document';
2
2
  import { logger } from '../../../utils/logger';
3
3
  import {
4
- BaseDocumentDriveServer,
4
+ IBaseDocumentDriveServer,
5
5
  Listener,
6
6
  ListenerRevision,
7
7
  OperationUpdate,
@@ -28,11 +28,11 @@ export type InternalTransmitterUpdate<
28
28
  };
29
29
 
30
30
  export class InternalTransmitter implements ITransmitter {
31
- private drive: BaseDocumentDriveServer;
31
+ private drive: IBaseDocumentDriveServer;
32
32
  private listener: Listener;
33
33
  private receiver: IReceiver | undefined;
34
34
 
35
- constructor(listener: Listener, drive: BaseDocumentDriveServer) {
35
+ constructor(listener: Listener, drive: IBaseDocumentDriveServer) {
36
36
  this.listener = listener;
37
37
  this.drive = drive;
38
38
  }
@@ -6,8 +6,8 @@ import { gql, requestGraphql } from '../../../utils/graphql';
6
6
  import { logger as defaultLogger } from '../../../utils/logger';
7
7
  import { OperationError } from '../../error';
8
8
  import {
9
- BaseDocumentDriveServer,
10
9
  GetStrandsOptions,
10
+ IBaseDocumentDriveServer,
11
11
  IOperationResult,
12
12
  Listener,
13
13
  ListenerRevision,
@@ -46,13 +46,13 @@ export interface IPullResponderTransmitter extends ITransmitter {
46
46
  }
47
47
 
48
48
  export class PullResponderTransmitter implements IPullResponderTransmitter {
49
- private drive: BaseDocumentDriveServer;
49
+ private drive: IBaseDocumentDriveServer;
50
50
  private listener: Listener;
51
51
  private manager: ListenerManager;
52
52
 
53
53
  constructor(
54
54
  listener: Listener,
55
- drive: BaseDocumentDriveServer,
55
+ drive: IBaseDocumentDriveServer,
56
56
  manager: ListenerManager
57
57
  ) {
58
58
  this.listener = listener;
@@ -68,6 +68,11 @@ export class PullResponderTransmitter implements IPullResponderTransmitter {
68
68
  );
69
69
  }
70
70
 
71
+ disconnect(): Promise<void> {
72
+ // TODO remove listener from switchboard
73
+ return Promise.resolve();
74
+ }
75
+
71
76
  async processAcknowledge(
72
77
  driveId: string,
73
78
  listenerId: string,
@@ -2,7 +2,7 @@ import stringify from 'json-stringify-deterministic';
2
2
  import { gql, requestGraphql } from '../../../utils/graphql';
3
3
  import { logger } from '../../../utils/logger';
4
4
  import {
5
- BaseDocumentDriveServer,
5
+ IBaseDocumentDriveServer,
6
6
  Listener,
7
7
  ListenerRevision,
8
8
  StrandUpdate
@@ -10,11 +10,11 @@ import {
10
10
  import { ITransmitter, StrandUpdateSource } from './types';
11
11
 
12
12
  export class SwitchboardPushTransmitter implements ITransmitter {
13
- private drive: BaseDocumentDriveServer;
13
+ private drive: IBaseDocumentDriveServer;
14
14
  private listener: Listener;
15
15
  private targetURL: string;
16
16
 
17
- constructor(listener: Listener, drive: BaseDocumentDriveServer) {
17
+ constructor(listener: Listener, drive: IBaseDocumentDriveServer) {
18
18
  this.listener = listener;
19
19
  this.drive = drive;
20
20
  this.targetURL = listener.callInfo!.data!;
@@ -13,6 +13,7 @@ import type {
13
13
  BaseAction,
14
14
  CreateChildDocumentInput,
15
15
  Document,
16
+ DocumentModel,
16
17
  Operation,
17
18
  OperationScope,
18
19
  ReducerOptions,
@@ -20,7 +21,10 @@ import type {
20
21
  State
21
22
  } from 'document-model/document';
22
23
  import { Unsubscribe } from 'nanoevents';
24
+ import { BaseDocumentDriveServer } from '.';
25
+ import { IReadModeDriveServer } from '../read-mode/types';
23
26
  import { RunAsap } from '../utils';
27
+ import { IDefaultDrivesManager } from '../utils/default-drives-manager';
24
28
  import { DriveInfo } from '../utils/graphql';
25
29
  import { OperationError, SynchronizationUnitNotFoundError } from './error';
26
30
  import {
@@ -29,16 +33,34 @@ import {
29
33
  StrandUpdateSource
30
34
  } from './listener/transmitter/types';
31
35
 
36
+ // eslint-disable-next-line @typescript-eslint/ban-types, @typescript-eslint/no-explicit-any
37
+ export type Constructor<T = object> = new (...args: any[]) => T;
38
+
39
+ export type DocumentDriveServerConstructor =
40
+ Constructor<BaseDocumentDriveServer>;
41
+
42
+ // Mixin type that returns a type extending both the base class and the interface
43
+ export type Mixin<T extends Constructor, I> = T &
44
+ Constructor<InstanceType<T> & I>;
45
+
46
+ export type DocumentDriveServerMixin<I> = Mixin<
47
+ typeof BaseDocumentDriveServer,
48
+ I
49
+ >;
50
+
32
51
  export type DriveInput = State<
33
52
  Omit<DocumentDriveState, '__typename' | 'id' | 'nodes'> & { id?: string },
34
53
  DocumentDriveLocalState
35
54
  >;
36
55
 
56
+ export type RemoteDriveAccessLevel = 'READ' | 'WRITE';
57
+
37
58
  export type RemoteDriveOptions = DocumentDriveLocalState & {
38
59
  // TODO make local state optional
39
60
  pullFilter?: ListenerFilter;
40
61
  pullInterval?: number;
41
62
  expectedDriveInfo?: DriveInfo;
63
+ accessLevel?: RemoteDriveAccessLevel;
42
64
  };
43
65
 
44
66
  export type CreateDocumentInput = CreateChildDocumentInput;
@@ -234,8 +256,10 @@ export type RemoveOldRemoteDrivesOption =
234
256
  };
235
257
 
236
258
  export type DocumentDriveServerOptions = {
237
- defaultRemoteDrives?: Array<DefaultRemoteDriveInput>;
238
- removeOldRemoteDrives?: RemoveOldRemoteDrivesOption;
259
+ defaultDrives: {
260
+ remoteDrives?: Array<DefaultRemoteDriveInput>;
261
+ removeOldRemoteDrives?: RemoveOldRemoteDrivesOption;
262
+ };
239
263
  /* method to queue heavy tasks that might block the event loop.
240
264
  * If set to null then it will queued as micro task.
241
265
  * Defaults to the most appropriate method according to the system
@@ -250,7 +274,7 @@ export type GetStrandsOptions = {
250
274
  fromRevision?: number;
251
275
  };
252
276
 
253
- export abstract class BaseDocumentDriveServer {
277
+ export abstract class AbstractDocumentDriveServer {
254
278
  /** Public methods **/
255
279
  abstract getDrives(): Promise<string[]>;
256
280
  abstract addDrive(drive: DriveInput): Promise<DocumentDriveDocument>;
@@ -416,6 +440,8 @@ export abstract class BaseDocumentDriveServer {
416
440
  ): Promise<Document>;
417
441
  protected abstract deleteDocument(drive: string, id: string): Promise<void>;
418
442
 
443
+ protected abstract getDocumentModel(documentType: string): DocumentModel;
444
+
419
445
  /** Event methods **/
420
446
  protected abstract emit<K extends keyof DriveEvents>(
421
447
  this: this,
@@ -450,17 +476,26 @@ export const DefaultListenerManagerOptions = {
450
476
  sequentialUpdates: true
451
477
  };
452
478
 
479
+ export type IBaseDocumentDriveServer = Pick<
480
+ AbstractDocumentDriveServer,
481
+ keyof AbstractDocumentDriveServer
482
+ >;
483
+
484
+ export type IDocumentDriveServer = IBaseDocumentDriveServer &
485
+ IDefaultDrivesManager &
486
+ IReadModeDriveServer;
487
+
453
488
  export abstract class BaseListenerManager {
454
- protected drive: BaseDocumentDriveServer;
455
- protected options: ListenerManagerOptions;
489
+ protected drive: IBaseDocumentDriveServer;
456
490
  protected listenerState = new Map<string, Map<string, ListenerState>>();
491
+ protected options: ListenerManagerOptions;
457
492
  protected transmitters: Record<
458
493
  DocumentDriveState['id'],
459
494
  Record<Listener['listenerId'], ITransmitter>
460
495
  > = {};
461
496
 
462
497
  constructor(
463
- drive: BaseDocumentDriveServer,
498
+ drive: IBaseDocumentDriveServer,
464
499
  listenerState = new Map<string, Map<string, ListenerState>>(),
465
500
  options: ListenerManagerOptions = DefaultListenerManagerOptions
466
501
  ) {
@@ -514,11 +549,6 @@ export abstract class BaseListenerManager {
514
549
  ): Promise<void>;
515
550
  }
516
551
 
517
- export type IDocumentDriveServer = Pick<
518
- BaseDocumentDriveServer,
519
- keyof BaseDocumentDriveServer
520
- >;
521
-
522
552
  export type ListenerStatus =
523
553
  | 'CREATED'
524
554
  | 'PENDING'
@@ -0,0 +1,84 @@
1
+ import { DocumentDriveAction } from 'document-model-libs/document-drive';
2
+ import { BaseAction, DocumentHeader, Operation } from 'document-model/document';
3
+ import { SynchronizationUnitQuery } from '../server';
4
+ import {
5
+ DocumentDriveStorage,
6
+ DocumentStorage,
7
+ IDriveStorage,
8
+ IStorage,
9
+ IStorageDelegate
10
+ } from './types';
11
+
12
+ abstract class BaseStorage implements IStorage {
13
+ abstract checkDocumentExists(drive: string, id: string): Promise<boolean>;
14
+
15
+ abstract getDocuments(drive: string): Promise<string[]>;
16
+
17
+ abstract getDocument(drive: string, id: string): Promise<DocumentStorage>;
18
+
19
+ abstract createDocument(
20
+ drive: string,
21
+ id: string,
22
+ document: DocumentStorage
23
+ ): Promise<void>;
24
+
25
+ abstract addDocumentOperations(
26
+ drive: string,
27
+ id: string,
28
+ operations: Operation[],
29
+ header: DocumentHeader
30
+ ): Promise<void>;
31
+
32
+ abstract addDocumentOperationsWithTransaction?(
33
+ drive: string,
34
+ id: string,
35
+ callback: (document: DocumentStorage) => Promise<{
36
+ operations: Operation[];
37
+ header: DocumentHeader;
38
+ }>
39
+ ): Promise<void>;
40
+
41
+ abstract deleteDocument(drive: string, id: string): Promise<void>;
42
+
43
+ abstract getOperationResultingState?(
44
+ drive: string,
45
+ id: string,
46
+ index: number,
47
+ scope: string,
48
+ branch: string
49
+ ): Promise<unknown>;
50
+
51
+ abstract setStorageDelegate?(delegate: IStorageDelegate): void;
52
+
53
+ abstract getSynchronizationUnitsRevision(
54
+ units: SynchronizationUnitQuery[]
55
+ ): Promise<
56
+ {
57
+ driveId: string;
58
+ documentId: string;
59
+ scope: string;
60
+ branch: string;
61
+ lastUpdated: string;
62
+ revision: number;
63
+ }[]
64
+ >;
65
+ }
66
+
67
+ export abstract class BaseDriveStorage
68
+ extends BaseStorage
69
+ implements IDriveStorage
70
+ {
71
+ abstract getDrives(): Promise<string[]>;
72
+ abstract getDrive(id: string): Promise<DocumentDriveStorage>;
73
+ abstract getDriveBySlug(slug: string): Promise<DocumentDriveStorage>;
74
+ abstract createDrive(
75
+ id: string,
76
+ drive: DocumentDriveStorage
77
+ ): Promise<void>;
78
+ abstract deleteDrive(id: string): Promise<void>;
79
+ abstract addDriveOperations(
80
+ id: string,
81
+ operations: Operation<DocumentDriveAction | BaseAction>[],
82
+ header: DocumentHeader
83
+ ): Promise<void>;
84
+ }
@@ -1 +1,2 @@
1
+ export * from './base';
1
2
  export type * from './types';
@@ -1,8 +1,10 @@
1
1
  import {
2
- BaseDocumentDriveServer,
3
2
  DefaultRemoteDriveInfo,
4
3
  DocumentDriveServerOptions,
5
4
  DriveEvents,
5
+ IBaseDocumentDriveServer,
6
+ IReadModeDriveServer,
7
+ RemoteDriveAccessLevel,
6
8
  RemoveOldRemoteDrivesOption
7
9
  } from '../server';
8
10
  import { DriveNotFoundError } from '../server/error';
@@ -13,20 +15,34 @@ export interface IServerDelegateDrivesManager {
13
15
  emit: (...args: Parameters<DriveEvents['defaultRemoteDrive']>) => void;
14
16
  }
15
17
 
16
- export class DefaultDrivesManager {
18
+ function isReadModeDriveServer(obj: unknown): obj is IReadModeDriveServer {
19
+ return typeof (obj as IReadModeDriveServer).getReadDrives === 'function';
20
+ }
21
+
22
+ export interface IDefaultDrivesManager {
23
+ getDefaultRemoteDrives(): Map<string, DefaultRemoteDriveInfo>;
24
+ setDefaultDriveAccessLevel(
25
+ url: string,
26
+ level: RemoteDriveAccessLevel
27
+ ): Promise<void>;
28
+ setAllDefaultDrivesAccessLevel(
29
+ level: RemoteDriveAccessLevel
30
+ ): Promise<void>;
31
+ }
32
+
33
+ export class DefaultDrivesManager implements IDefaultDrivesManager {
17
34
  private defaultRemoteDrives = new Map<string, DefaultRemoteDriveInfo>();
18
35
  private removeOldRemoteDrivesConfig: RemoveOldRemoteDrivesOption;
19
36
 
20
37
  constructor(
21
- private server: BaseDocumentDriveServer,
38
+ private server:
39
+ | IBaseDocumentDriveServer
40
+ | (IBaseDocumentDriveServer & IReadModeDriveServer),
22
41
  private delegate: IServerDelegateDrivesManager,
23
- options?: Pick<
24
- DocumentDriveServerOptions,
25
- 'defaultRemoteDrives' | 'removeOldRemoteDrives'
26
- >
42
+ options?: Pick<DocumentDriveServerOptions, 'defaultDrives'>
27
43
  ) {
28
- if (options?.defaultRemoteDrives) {
29
- for (const defaultDrive of options.defaultRemoteDrives) {
44
+ if (options?.defaultDrives.remoteDrives) {
45
+ for (const defaultDrive of options.defaultDrives.remoteDrives) {
30
46
  this.defaultRemoteDrives.set(defaultDrive.url, {
31
47
  ...defaultDrive,
32
48
  status: 'PENDING'
@@ -34,13 +50,18 @@ export class DefaultDrivesManager {
34
50
  }
35
51
  }
36
52
 
37
- this.removeOldRemoteDrivesConfig = options?.removeOldRemoteDrives || {
53
+ this.removeOldRemoteDrivesConfig = options?.defaultDrives
54
+ .removeOldRemoteDrives || {
38
55
  strategy: 'preserve-all'
39
56
  };
40
57
  }
41
58
 
42
59
  getDefaultRemoteDrives() {
43
- return this.defaultRemoteDrives;
60
+ return new Map(
61
+ JSON.parse(
62
+ JSON.stringify(Array.from(this.defaultRemoteDrives))
63
+ ) as Iterable<[string, DefaultRemoteDriveInfo]>
64
+ );
44
65
  }
45
66
 
46
67
  private async deleteDriveById(driveId: string) {
@@ -145,10 +166,40 @@ export class DefaultDrivesManager {
145
166
  }
146
167
  }
147
168
 
148
- async initializeDefaultRemoteDrives() {
169
+ async setAllDefaultDrivesAccessLevel(level: RemoteDriveAccessLevel) {
170
+ const drives = this.defaultRemoteDrives.values();
171
+ for (const drive of drives) {
172
+ await this.setDefaultDriveAccessLevel(drive.url, level);
173
+ }
174
+ }
175
+
176
+ async setDefaultDriveAccessLevel(
177
+ url: string,
178
+ level: RemoteDriveAccessLevel
179
+ ) {
180
+ const drive = this.defaultRemoteDrives.get(url);
181
+ if (drive && drive.options.accessLevel !== level) {
182
+ const newDriveValue = {
183
+ ...drive,
184
+ options: { ...drive.options, accessLevel: level }
185
+ };
186
+ this.defaultRemoteDrives.set(url, newDriveValue);
187
+ await this.initializeDefaultRemoteDrives([newDriveValue]);
188
+ }
189
+ }
190
+
191
+ async initializeDefaultRemoteDrives(
192
+ defaultDrives: DefaultRemoteDriveInfo[] = Array.from(
193
+ this.defaultRemoteDrives.values()
194
+ )
195
+ ) {
149
196
  const drives = await this.server.getDrives();
197
+ const readServer = isReadModeDriveServer(this.server)
198
+ ? (this.server as IReadModeDriveServer)
199
+ : undefined;
200
+ const readDrives = await readServer?.getReadDrives();
150
201
 
151
- for (const remoteDrive of this.defaultRemoteDrives.values()) {
202
+ for (const remoteDrive of defaultDrives) {
152
203
  let remoteDriveInfo = { ...remoteDrive };
153
204
 
154
205
  try {
@@ -158,7 +209,29 @@ export class DefaultDrivesManager {
158
209
 
159
210
  this.defaultRemoteDrives.set(remoteDrive.url, remoteDriveInfo);
160
211
 
161
- if (drives.includes(driveInfo.id)) {
212
+ const driveIsAdded = drives.includes(driveInfo.id);
213
+ const readDriveIsAdded = readDrives?.includes(driveInfo.id);
214
+
215
+ const readMode =
216
+ readServer && remoteDrive.options.accessLevel === 'READ';
217
+ const isAdded = readMode ? readDriveIsAdded : driveIsAdded;
218
+
219
+ // if the read mode has changed then existing drives
220
+ // in the previous mode should be deleted
221
+ const driveToDelete = readMode
222
+ ? driveIsAdded
223
+ : readDriveIsAdded;
224
+ if (driveToDelete) {
225
+ try {
226
+ await (readMode
227
+ ? this.server.deleteDrive(driveInfo.id)
228
+ : readServer?.deleteReadDrive(driveInfo.id));
229
+ } catch (e) {
230
+ logger.error(e);
231
+ }
232
+ }
233
+
234
+ if (isAdded) {
162
235
  remoteDriveInfo.status = 'ALREADY_ADDED';
163
236
 
164
237
  this.defaultRemoteDrives.set(
@@ -1,8 +1,60 @@
1
+ import { pascalCase } from 'change-case';
2
+ import {
3
+ DocumentDriveLocalState,
4
+ FileNode,
5
+ FolderNode
6
+ } from 'document-model-libs/document-drive';
7
+ import { Document, DocumentModel, Operation } from 'document-model/document';
8
+ import { DocumentModelState } from 'document-model/document-model';
9
+ import {
10
+ BuildSchemaOptions,
11
+ GraphQLError,
12
+ GraphQLList,
13
+ GraphQLNonNull,
14
+ GraphQLObjectType,
15
+ GraphQLOutputType,
16
+ GraphQLScalarType,
17
+ GraphQLUnionType,
18
+ ParseOptions,
19
+ buildSchema
20
+ } from 'graphql';
1
21
  import request, { GraphQLClient, gql } from 'graphql-request';
22
+ import {
23
+ InferDocumentLocalState,
24
+ InferDocumentOperation,
25
+ InferDocumentState
26
+ } from '../read-mode/types';
2
27
  import { logger } from './logger';
3
28
 
4
29
  export { gql } from 'graphql-request';
5
30
 
31
+ type ReqGraphQLError = {
32
+ message: string;
33
+ };
34
+
35
+ export type GraphQLResult<T> = { [K in keyof T]: T[K] | null } & {
36
+ errors?: GraphQLError[];
37
+ };
38
+
39
+ // replaces fetch so it can be used in Node and Browser envs
40
+ export async function requestGraphql<T>(
41
+ ...args: Parameters<typeof request>
42
+ ): Promise<GraphQLResult<T>> {
43
+ const [url, ...requestArgs] = args;
44
+ const client = new GraphQLClient(url, { fetch });
45
+ const { errors, ...response } = await client.request<
46
+ { [K in keyof T]: T[K] | null } & { errors?: ReqGraphQLError[] }
47
+ >(...requestArgs);
48
+
49
+ const result = { ...response } as GraphQLResult<T>;
50
+ if (errors?.length) {
51
+ result.errors = errors.map(
52
+ ({ message, ...options }) => new GraphQLError(message, options)
53
+ );
54
+ }
55
+ return result;
56
+ }
57
+
6
58
  export type DriveInfo = {
7
59
  id: string;
8
60
  name: string;
@@ -10,11 +62,80 @@ export type DriveInfo = {
10
62
  icon?: string;
11
63
  };
12
64
 
13
- // replaces fetch so it can be used in Node and Browser envs
14
- export async function requestGraphql<T>(...args: Parameters<typeof request>) {
15
- const [url, ...requestArgs] = args;
16
- const client = new GraphQLClient(url, { fetch });
17
- return client.request<T>(...requestArgs);
65
+ function getFields(type: GraphQLOutputType): string {
66
+ if (type instanceof GraphQLObjectType) {
67
+ return Object.entries(type.getFields())
68
+ .map(([fieldName, field]) => {
69
+ const fieldType =
70
+ field.type instanceof GraphQLNonNull
71
+ ? field.type.ofType
72
+ : field.type;
73
+
74
+ if (
75
+ fieldType instanceof GraphQLObjectType ||
76
+ fieldType instanceof GraphQLUnionType
77
+ ) {
78
+ return `${fieldName} { ${getFields(fieldType)} }`;
79
+ }
80
+
81
+ if (fieldType instanceof GraphQLList) {
82
+ const listItemType =
83
+ fieldType.ofType instanceof GraphQLNonNull
84
+ ? fieldType.ofType.ofType
85
+ : fieldType.ofType;
86
+
87
+ if (listItemType instanceof GraphQLScalarType) {
88
+ return fieldName;
89
+ } else if (
90
+ listItemType instanceof GraphQLObjectType ||
91
+ listItemType instanceof GraphQLUnionType
92
+ ) {
93
+ return `${fieldName} { ${getFields(listItemType)} }`;
94
+ } else {
95
+ throw new Error(
96
+ `List item type ${listItemType.toString()} is not handled`
97
+ );
98
+ }
99
+ }
100
+
101
+ return fieldName;
102
+ })
103
+ .join(' ');
104
+ } else if (type instanceof GraphQLUnionType) {
105
+ return type
106
+ .getTypes()
107
+ .map(unionType => {
108
+ return `... on ${unionType.name} { ${getFields(unionType)} }`;
109
+ })
110
+ .join(' ');
111
+ }
112
+ return '';
113
+ }
114
+
115
+ export function generateDocumentStateQueryFields(
116
+ documentModel: DocumentModelState,
117
+ options?: BuildSchemaOptions & ParseOptions
118
+ ): string {
119
+ const name = pascalCase(documentModel.name);
120
+ const spec = documentModel.specifications.at(-1);
121
+ if (!spec) {
122
+ throw new Error('No document model specification found');
123
+ }
124
+ const source = `${spec.state.global.schema} type Query { ${name}: ${name}State }`;
125
+ const schema = buildSchema(source, options);
126
+ const queryType = schema.getQueryType();
127
+ if (!queryType) {
128
+ throw new Error('No query type found');
129
+ }
130
+ const fields = queryType.getFields();
131
+ const stateQuery = fields[name];
132
+
133
+ if (!stateQuery) {
134
+ throw new Error('No state query found');
135
+ }
136
+
137
+ const queryFields = getFields(stateQuery.type);
138
+ return queryFields;
18
139
  }
19
140
 
20
141
  export async function requestPublicDrive(url: string): Promise<DriveInfo> {
@@ -33,15 +154,135 @@ export async function requestPublicDrive(url: string): Promise<DriveInfo> {
33
154
  }
34
155
  `
35
156
  );
157
+ if (result.errors?.length || !result.drive) {
158
+ throw result.errors?.at(0) ?? new Error('Drive not found');
159
+ }
36
160
  drive = result.drive;
37
161
  } catch (e) {
38
162
  logger.error(e);
39
163
  throw new Error("Couldn't find drive info");
40
164
  }
41
165
 
42
- if (!drive) {
43
- throw new Error('Drive not found');
44
- }
45
-
46
166
  return drive;
47
167
  }
168
+
169
+ export type DriveState = DriveInfo &
170
+ Pick<DocumentDriveLocalState, 'availableOffline' | 'sharingType'> & {
171
+ nodes: Array<FolderNode | Omit<FileNode, 'synchronizationUnits'>>;
172
+ };
173
+
174
+ export async function fetchDocument<D extends Document>(
175
+ url: string,
176
+ documentId: string,
177
+ documentModelLib: DocumentModel<
178
+ InferDocumentState<D>,
179
+ InferDocumentOperation<D>,
180
+ InferDocumentLocalState<D>
181
+ >
182
+ ): Promise<
183
+ GraphQLResult<{
184
+ document: Document<
185
+ InferDocumentState<D>,
186
+ InferDocumentOperation<D>,
187
+ InferDocumentLocalState<D>
188
+ >;
189
+ }>
190
+ > {
191
+ const { documentModel, utils } = documentModelLib;
192
+ const stateFields = generateDocumentStateQueryFields(documentModel);
193
+ const name = pascalCase(documentModel.name);
194
+ const result = await requestGraphql<{
195
+ document: Pick<
196
+ D,
197
+ 'name' | 'created' | 'documentType' | 'lastModified'
198
+ > & {
199
+ id: string;
200
+ revision: number;
201
+ state: InferDocumentState<D>;
202
+ initialState: InferDocumentState<D>;
203
+ operations: (Pick<
204
+ Operation,
205
+ | 'id'
206
+ | 'hash'
207
+ | 'index'
208
+ | 'skip'
209
+ | 'timestamp'
210
+ | 'type'
211
+ | 'error'
212
+ > & { inputText: string })[];
213
+ };
214
+ }>(
215
+ url,
216
+ gql`
217
+ query ($id: String!) {
218
+ document(id: $id) {
219
+ id
220
+ name
221
+ created
222
+ documentType
223
+ lastModified
224
+ revision
225
+ operations {
226
+ id
227
+ error
228
+ hash
229
+ index
230
+ skip
231
+ timestamp
232
+ type
233
+ inputText
234
+ }
235
+ ... on ${name} {
236
+ state {
237
+ ${stateFields}
238
+ }
239
+ initialState {
240
+ ${stateFields}
241
+ }
242
+ }
243
+ }
244
+ }
245
+ `,
246
+ { id: documentId }
247
+ );
248
+ const document: Document<
249
+ InferDocumentState<D>,
250
+ InferDocumentOperation<D>,
251
+ InferDocumentLocalState<D>
252
+ > | null = result.document
253
+ ? {
254
+ ...result.document,
255
+ revision: {
256
+ global: result.document.revision,
257
+ local: 0
258
+ },
259
+ state: utils.createState({ global: result.document.state }),
260
+ operations: {
261
+ global: result.document.operations.map(
262
+ ({ inputText, ...o }) => ({
263
+ ...o,
264
+ error: o.error ?? undefined,
265
+ scope: 'global',
266
+ input: JSON.parse(inputText) as D
267
+ })
268
+ ),
269
+ local: []
270
+ },
271
+ attachments: {},
272
+ initialState: utils.createExtendedState({
273
+ // TODO: getDocument should return all the initial state fields
274
+ created: result.document.created,
275
+ lastModified: result.document.created,
276
+ state: utils.createState({
277
+ global: result.document.initialState
278
+ })
279
+ }),
280
+ clipboard: []
281
+ }
282
+ : null;
283
+
284
+ return {
285
+ ...result,
286
+ document
287
+ };
288
+ }