document-drive 0.0.26 → 0.0.28

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.
@@ -0,0 +1,63 @@
1
+ import stringify from 'json-stringify-deterministic';
2
+ import { gql, requestGraphql } from '../../../utils/graphql';
3
+ import {
4
+ BaseDocumentDriveServer,
5
+ Listener,
6
+ ListenerRevision,
7
+ StrandUpdate
8
+ } from '../../types';
9
+ import { ITransmitter } from './types';
10
+
11
+ export class SwitchboardPushTransmitter implements ITransmitter {
12
+ private drive: BaseDocumentDriveServer;
13
+ private listener: Listener;
14
+ private targetURL: string;
15
+
16
+ constructor(listener: Listener, drive: BaseDocumentDriveServer) {
17
+ this.listener = listener;
18
+ this.drive = drive;
19
+ this.targetURL = listener.callInfo!.data!;
20
+ }
21
+
22
+ async transmit(strands: StrandUpdate[]): Promise<ListenerRevision[]> {
23
+ // Send Graphql mutation to switchboard
24
+ try {
25
+ const { pushUpdates } = await requestGraphql<{
26
+ pushUpdates: ListenerRevision[];
27
+ }>(
28
+ this.targetURL,
29
+ gql`
30
+ mutation pushUpdates($strands: [InputStrandUpdate!]) {
31
+ pushUpdates(strands: $strands) {
32
+ driveId
33
+ documentId
34
+ scope
35
+ branch
36
+ status
37
+ revision
38
+ }
39
+ }
40
+ `,
41
+ {
42
+ strands: strands.map(strand => ({
43
+ ...strand,
44
+ operations: strand.operations.map(op => ({
45
+ ...op,
46
+ input: stringify(op.input)
47
+ }))
48
+ }))
49
+ }
50
+ );
51
+
52
+ if (!pushUpdates) {
53
+ throw new Error("Couldn't update listener revision");
54
+ }
55
+
56
+ return pushUpdates;
57
+ } catch (e) {
58
+ console.error(e);
59
+ throw e;
60
+ }
61
+ return [];
62
+ }
63
+ }
@@ -0,0 +1,18 @@
1
+ import {
2
+ PullResponderTriggerData,
3
+ Trigger
4
+ } from 'document-model-libs/document-drive';
5
+ import { ListenerRevision, StrandUpdate } from '../..';
6
+
7
+ export interface ITransmitter {
8
+ transmit(strands: StrandUpdate[]): Promise<ListenerRevision[]>;
9
+ }
10
+
11
+ export interface InternalTransmitterService extends ITransmitter {
12
+ getName(): string;
13
+ }
14
+
15
+ export type PullResponderTrigger = Omit<Trigger, 'data' | 'type'> & {
16
+ data: PullResponderTriggerData;
17
+ type: 'PullResponder';
18
+ };
@@ -2,68 +2,254 @@ import type {
2
2
  DocumentDriveAction,
3
3
  DocumentDriveDocument,
4
4
  DocumentDriveLocalState,
5
- DocumentDriveState
5
+ DocumentDriveState,
6
+ ListenerCallInfo,
7
+ ListenerFilter
6
8
  } from 'document-model-libs/document-drive';
7
9
  import type {
8
10
  BaseAction,
11
+ CreateChildDocumentInput,
9
12
  Document,
10
13
  Operation,
14
+ OperationScope,
11
15
  Signal,
12
16
  State
13
17
  } from 'document-model/document';
18
+ import { OperationError } from './error';
19
+ import { ITransmitter } from './listener/transmitter/types';
14
20
 
15
21
  export type DriveInput = State<
16
- Omit<DocumentDriveState, '__typename' | 'nodes'>,
22
+ Omit<DocumentDriveState, '__typename' | 'id' | 'nodes'> & { id?: string },
17
23
  DocumentDriveLocalState
18
24
  >;
19
25
 
20
- export type CreateDocumentInput = {
21
- id: string;
22
- documentType: string;
23
- document?: Document;
26
+ export type RemoteDriveOptions = DocumentDriveLocalState & {
27
+ // TODO make local state optional
28
+ pullFilter?: ListenerFilter;
29
+ pullInterval?: number;
24
30
  };
25
31
 
32
+ export type CreateDocumentInput = CreateChildDocumentInput;
33
+
26
34
  export type SignalResult = {
27
35
  signal: Signal;
28
36
  result: unknown; // infer from return types on document-model
29
37
  };
30
38
 
31
39
  export type IOperationResult<T extends Document = Document> = {
32
- success: boolean;
33
- error?: Error;
40
+ status: UpdateStatus;
41
+ error?: OperationError;
34
42
  operations: Operation[];
35
43
  document: T | undefined;
36
44
  signals: SignalResult[];
37
45
  };
38
46
 
39
- export interface IDocumentDriveServer {
40
- getDrives(): Promise<string[]>;
41
- addDrive(drive: DriveInput): Promise<void>;
42
- deleteDrive(id: string): Promise<void>;
43
- getDrive(id: string): Promise<DocumentDriveDocument>;
47
+ export type SynchronizationUnit = {
48
+ syncId: string;
49
+ driveId: string;
50
+ documentId: string;
51
+ documentType: string;
52
+ scope: string;
53
+ branch: string;
54
+ lastUpdated: string;
55
+ revision: number;
56
+ };
57
+
58
+ export type Listener = {
59
+ driveId: string;
60
+ listenerId: string;
61
+ label?: string;
62
+ block: boolean;
63
+ system: boolean;
64
+ filter: ListenerFilter;
65
+ callInfo?: ListenerCallInfo;
66
+ };
67
+
68
+ export type CreateListenerInput = {
69
+ driveId: string;
70
+ label?: string;
71
+ block: boolean;
72
+ system: boolean;
73
+ filter: ListenerFilter;
74
+ callInfo?: ListenerCallInfo;
75
+ };
76
+
77
+ export enum TransmitterType {
78
+ Internal,
79
+ SwitchboardPush,
80
+ PullResponder,
81
+ SecureConnect,
82
+ MatrixConnect,
83
+ RESTWebhook
84
+ }
85
+
86
+ export type ListenerRevision = {
87
+ driveId: string;
88
+ documentId: string;
89
+ scope: string;
90
+ branch: string;
91
+ status: UpdateStatus;
92
+ revision: number;
93
+ };
94
+
95
+ export type UpdateStatus = 'SUCCESS' | 'CONFLICT' | 'MISSING' | 'ERROR';
96
+ export type ErrorStatus = Exclude<UpdateStatus, 'SUCCESS'>;
97
+
98
+ export type OperationUpdate = {
99
+ timestamp: string;
100
+ index: number;
101
+ skip: number;
102
+ type: string;
103
+ input: object;
104
+ hash: string;
105
+ };
106
+
107
+ export type StrandUpdate = {
108
+ driveId: string;
109
+ documentId: string;
110
+ scope: OperationScope;
111
+ branch: string;
112
+ operations: OperationUpdate[];
113
+ };
114
+
115
+ export type SyncStatus = 'SYNCING' | UpdateStatus;
116
+
117
+ export abstract class BaseDocumentDriveServer {
118
+ /** Public methods **/
119
+ abstract getDrives(): Promise<string[]>;
120
+ abstract addDrive(drive: DriveInput): Promise<void>;
121
+ abstract addRemoteDrive(
122
+ url: string,
123
+ options: RemoteDriveOptions
124
+ ): Promise<void>;
125
+ abstract deleteDrive(id: string): Promise<void>;
126
+ abstract getDrive(id: string): Promise<DocumentDriveDocument>;
44
127
 
45
- getDocuments: (drive: string) => Promise<string[]>;
46
- getDocument: (drive: string, id: string) => Promise<Document>;
47
- createDocument(drive: string, document: CreateDocumentInput): Promise<void>;
48
- deleteDocument(drive: string, id: string): Promise<void>;
128
+ abstract getDocuments(drive: string): Promise<string[]>;
129
+ abstract getDocument(drive: string, id: string): Promise<Document>;
49
130
 
50
- addOperation(
131
+ abstract addOperation(
51
132
  drive: string,
52
133
  id: string,
53
134
  operation: Operation
54
- ): Promise<IOperationResult>;
55
- addOperations(
135
+ ): Promise<IOperationResult<Document>>;
136
+ abstract addOperations(
56
137
  drive: string,
57
138
  id: string,
58
139
  operations: Operation[]
59
- ): Promise<IOperationResult>;
140
+ ): Promise<IOperationResult<Document>>;
60
141
 
61
- addDriveOperation(
142
+ abstract addDriveOperation(
62
143
  drive: string,
63
144
  operation: Operation<DocumentDriveAction | BaseAction>
64
145
  ): Promise<IOperationResult<DocumentDriveDocument>>;
65
- addDriveOperations(
146
+ abstract addDriveOperations(
66
147
  drive: string,
67
148
  operations: Operation<DocumentDriveAction | BaseAction>[]
68
149
  ): Promise<IOperationResult<DocumentDriveDocument>>;
150
+
151
+ abstract getSyncStatus(drive: string): SyncStatus;
152
+
153
+ /** Synchronization methods */
154
+ abstract getSynchronizationUnits(
155
+ driveId: string,
156
+ documentId?: string[],
157
+ scope?: string[],
158
+ branch?: string[]
159
+ ): Promise<SynchronizationUnit[]>;
160
+
161
+ abstract getSynchronizationUnit(
162
+ driveId: string,
163
+ syncId: string
164
+ ): Promise<SynchronizationUnit>;
165
+
166
+ abstract getOperationData(
167
+ driveId: string,
168
+ syncId: string,
169
+ filter: {
170
+ since?: string;
171
+ fromRevision?: number;
172
+ }
173
+ ): Promise<OperationUpdate[]>;
174
+
175
+ /** Internal methods **/
176
+ protected abstract createDocument(
177
+ drive: string,
178
+ document: CreateDocumentInput
179
+ ): Promise<Document>;
180
+ protected abstract deleteDocument(drive: string, id: string): Promise<void>;
181
+
182
+ abstract getTransmitter(
183
+ driveId: string,
184
+ listenerId: string
185
+ ): Promise<ITransmitter | undefined>;
186
+ }
187
+
188
+ export abstract class BaseListenerManager {
189
+ protected drive: BaseDocumentDriveServer;
190
+ protected listenerState: Map<string, Map<string, ListenerState>> =
191
+ new Map();
192
+ protected transmitters: Record<
193
+ DocumentDriveState['id'],
194
+ Record<Listener['listenerId'], ITransmitter>
195
+ > = {};
196
+
197
+ constructor(
198
+ drive: BaseDocumentDriveServer,
199
+ listenerState: Map<string, Map<string, ListenerState>> = new Map()
200
+ ) {
201
+ this.drive = drive;
202
+ this.listenerState = listenerState;
203
+ }
204
+
205
+ abstract init(): Promise<void>;
206
+ abstract addListener(listener: Listener): Promise<ITransmitter>;
207
+ abstract removeListener(
208
+ driveId: string,
209
+ listenerId: string
210
+ ): Promise<boolean>;
211
+ abstract getTransmitter(
212
+ driveId: string,
213
+ listenerId: string
214
+ ): Promise<ITransmitter | undefined>;
215
+ abstract updateSynchronizationRevision(
216
+ driveId: string,
217
+ syncId: string,
218
+ syncRev: number,
219
+ lastUpdated: string
220
+ ): Promise<void>;
221
+
222
+ abstract updateListenerRevision(
223
+ listenerId: string,
224
+ driveId: string,
225
+ syncId: string,
226
+ listenerRev: number
227
+ ): Promise<void>;
228
+ }
229
+
230
+ export type IDocumentDriveServer = Pick<
231
+ BaseDocumentDriveServer,
232
+ keyof BaseDocumentDriveServer
233
+ >;
234
+
235
+ export type ListenerStatus =
236
+ | 'CREATED'
237
+ | 'PENDING'
238
+ | 'SUCCESS'
239
+ | 'MISSING'
240
+ | 'CONFLICT'
241
+ | 'ERROR';
242
+
243
+ export interface ListenerState {
244
+ driveId: string;
245
+ block: boolean;
246
+ pendingTimeout: string;
247
+ listener: Listener;
248
+ syncUnits: SyncronizationUnitState[];
249
+ listenerStatus: ListenerStatus;
250
+ }
251
+
252
+ export interface SyncronizationUnitState extends SynchronizationUnit {
253
+ listenerRev: number;
254
+ syncRev: number;
69
255
  }
@@ -9,6 +9,7 @@ import {
9
9
  writeFileSync
10
10
  } from 'fs';
11
11
  import fs from 'fs/promises';
12
+ import stringify from 'json-stringify-deterministic';
12
13
  import path from 'path';
13
14
  import sanitize from 'sanitize-filename';
14
15
  import { mergeOperations } from '..';
@@ -83,7 +84,6 @@ export class FilesystemStorage implements IDriveStorage {
83
84
  });
84
85
  return JSON.parse(content) as Promise<DocumentStorage>;
85
86
  } catch (error) {
86
- console.error(error);
87
87
  throw new Error(`Document with id ${id} not found`);
88
88
  }
89
89
  }
@@ -91,7 +91,7 @@ export class FilesystemStorage implements IDriveStorage {
91
91
  async createDocument(drive: string, id: string, document: DocumentStorage) {
92
92
  const documentPath = this._buildDocumentPath(drive, id);
93
93
  await ensureDir(path.dirname(documentPath));
94
- await writeFileSync(documentPath, JSON.stringify(document), {
94
+ await writeFileSync(documentPath, stringify(document), {
95
95
  encoding: 'utf-8'
96
96
  });
97
97
  }
@@ -1,5 +1 @@
1
- export { BrowserStorage } from './browser';
2
- export { FilesystemStorage } from './filesystem';
3
- export { MemoryStorage } from './memory';
4
- export { PrismaStorage } from './prisma';
5
1
  export type * from './types';
@@ -30,6 +30,7 @@ export class MemoryStorage implements IDriveStorage {
30
30
  if (!document) {
31
31
  throw new Error(`Document with id ${id} not found`);
32
32
  }
33
+
33
34
  return document;
34
35
  }
35
36
 
@@ -47,7 +48,8 @@ export class MemoryStorage implements IDriveStorage {
47
48
  revision,
48
49
  documentType,
49
50
  created,
50
- lastModified
51
+ lastModified,
52
+ clipboard
51
53
  } = document;
52
54
  this.documents[drive]![id] = {
53
55
  operations,
@@ -56,7 +58,8 @@ export class MemoryStorage implements IDriveStorage {
56
58
  revision,
57
59
  documentType,
58
60
  created,
59
- lastModified
61
+ lastModified,
62
+ clipboard
60
63
  };
61
64
  }
62
65
 
@@ -17,9 +17,11 @@ export class PrismaStorage implements IDriveStorage {
17
17
  constructor(db: Prisma.TransactionClient) {
18
18
  this.db = db;
19
19
  }
20
+
20
21
  async createDrive(id: string, drive: DocumentDriveStorage): Promise<void> {
21
22
  await this.createDocument('drives', id, drive as DocumentStorage);
22
23
  }
24
+
23
25
  async addDriveOperations(
24
26
  id: string,
25
27
  operations: Operation[],
@@ -27,6 +29,7 @@ export class PrismaStorage implements IDriveStorage {
27
29
  ): Promise<void> {
28
30
  await this.addDocumentOperations('drives', id, operations, header);
29
31
  }
32
+
30
33
  async createDocument(
31
34
  drive: string,
32
35
  id: string,
@@ -84,7 +87,8 @@ export class PrismaStorage implements IDriveStorage {
84
87
  timestamp: op.timestamp,
85
88
  type: op.type,
86
89
  scope: op.scope,
87
- branch: 'main'
90
+ branch: 'main',
91
+ skip: op.skip
88
92
  },
89
93
  update: {
90
94
  driveId: drive,
@@ -95,18 +99,17 @@ export class PrismaStorage implements IDriveStorage {
95
99
  timestamp: op.timestamp,
96
100
  type: op.type,
97
101
  scope: op.scope,
98
- branch: 'main'
102
+ branch: 'main',
103
+ skip: op.skip
99
104
  }
100
105
  });
101
106
  })
102
107
  );
103
108
 
104
- await this.db.document.update({
109
+ await this.db.document.updateMany({
105
110
  where: {
106
- id_driveId: {
107
- id,
108
- driveId: 'drives'
109
- }
111
+ id,
112
+ driveId: drive
110
113
  },
111
114
  data: {
112
115
  lastModified: header.lastModified,
@@ -175,7 +178,6 @@ export class PrismaStorage implements IDriveStorage {
175
178
  }
176
179
 
177
180
  const dbDoc = result;
178
-
179
181
  const doc = {
180
182
  created: dbDoc.created.toISOString(),
181
183
  name: dbDoc.name ? dbDoc.name : '',
@@ -184,11 +186,12 @@ export class PrismaStorage implements IDriveStorage {
184
186
  DocumentDriveState,
185
187
  DocumentDriveLocalState
186
188
  >,
187
- lastModified: dbDoc.lastModified.toISOString(),
189
+ lastModified: new Date(dbDoc.lastModified).toISOString(),
188
190
  operations: {
189
191
  global: dbDoc.operations
190
- .filter(op => op.scope === 'global')
192
+ .filter(op => op.scope === 'global' && !op.clipboard)
191
193
  .map(op => ({
194
+ skip: op.skip,
192
195
  hash: op.hash,
193
196
  index: op.index,
194
197
  timestamp: new Date(op.timestamp).toISOString(),
@@ -198,8 +201,9 @@ export class PrismaStorage implements IDriveStorage {
198
201
  // attachments: fileRegistry
199
202
  })),
200
203
  local: dbDoc.operations
201
- .filter(op => op.scope === 'local')
204
+ .filter(op => op.scope === 'local' && !op.clipboard)
202
205
  .map(op => ({
206
+ skip: op.skip,
203
207
  hash: op.hash,
204
208
  index: op.index,
205
209
  timestamp: new Date(op.timestamp).toISOString(),
@@ -209,6 +213,18 @@ export class PrismaStorage implements IDriveStorage {
209
213
  // attachments: fileRegistry
210
214
  }))
211
215
  },
216
+ clipboard: dbDoc.operations
217
+ .filter(op => op.clipboard)
218
+ .map(op => ({
219
+ skip: op.skip,
220
+ hash: op.hash,
221
+ index: op.index,
222
+ timestamp: new Date(op.timestamp).toISOString(),
223
+ input: op.input,
224
+ type: op.type,
225
+ scope: op.scope as OperationScope
226
+ // attachments: fileRegistry
227
+ })),
212
228
  revision: dbDoc.revision as Required<Record<OperationScope, number>>
213
229
  };
214
230
 
@@ -216,12 +232,48 @@ export class PrismaStorage implements IDriveStorage {
216
232
  }
217
233
 
218
234
  async deleteDocument(drive: string, id: string) {
219
- await this.db.document.deleteMany({
235
+ await this.db.attachment.deleteMany({
220
236
  where: {
221
237
  driveId: drive,
222
- id: id
238
+ documentId: id
239
+ }
240
+ });
241
+
242
+ await this.db.operation.deleteMany({
243
+ where: {
244
+ driveId: drive,
245
+ documentId: id
246
+ }
247
+ });
248
+
249
+ await this.db.document.delete({
250
+ where: {
251
+ id_driveId: {
252
+ driveId: drive,
253
+ id: id
254
+ }
223
255
  }
224
256
  });
257
+
258
+ if (drive === 'drives') {
259
+ await this.db.attachment.deleteMany({
260
+ where: {
261
+ driveId: id
262
+ }
263
+ });
264
+
265
+ await this.db.operation.deleteMany({
266
+ where: {
267
+ driveId: id
268
+ }
269
+ });
270
+
271
+ await this.db.document.deleteMany({
272
+ where: {
273
+ driveId: id
274
+ }
275
+ });
276
+ }
225
277
  }
226
278
 
227
279
  async getDrives() {
@@ -234,10 +286,5 @@ export class PrismaStorage implements IDriveStorage {
234
286
 
235
287
  async deleteDrive(id: string) {
236
288
  await this.deleteDocument('drives', id);
237
- await this.db.document.deleteMany({
238
- where: {
239
- driveId: id
240
- }
241
- });
242
289
  }
243
290
  }
@@ -0,0 +1,46 @@
1
+ import request, { GraphQLClient, gql } from 'graphql-request';
2
+
3
+ export { gql } from 'graphql-request';
4
+
5
+ export type DriveInfo = {
6
+ id: string;
7
+ name: string;
8
+ slug: string;
9
+ icon?: string;
10
+ };
11
+
12
+ // replaces fetch so it can be used in Node and Browser envs
13
+ export async function requestGraphql<T>(...args: Parameters<typeof request>) {
14
+ const [url, ...requestArgs] = args;
15
+ const client = new GraphQLClient(url, { fetch });
16
+ return client.request<T>(...requestArgs);
17
+ }
18
+
19
+ export async function requestPublicDrive(url: string): Promise<DriveInfo> {
20
+ let drive: DriveInfo;
21
+ try {
22
+ const result = await requestGraphql<{ drive: DriveInfo }>(
23
+ url,
24
+ gql`
25
+ query getDrive {
26
+ drive {
27
+ id
28
+ name
29
+ icon
30
+ slug
31
+ }
32
+ }
33
+ `
34
+ );
35
+ drive = result.drive;
36
+ } catch (e) {
37
+ console.error(e);
38
+ throw new Error("Couldn't find drive info");
39
+ }
40
+
41
+ if (!drive) {
42
+ throw new Error('Drive not found');
43
+ }
44
+
45
+ return drive;
46
+ }
@@ -30,3 +30,11 @@ export function mergeOperations<A extends Action = Action>(
30
30
  return acc;
31
31
  }, currentOperations);
32
32
  }
33
+
34
+ export function generateUUID(): string {
35
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
36
+ const crypto =
37
+ typeof window !== 'undefined' ? window.crypto : require('crypto');
38
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
39
+ return crypto.randomUUID() as string;
40
+ }