document-drive 1.18.0 → 1.19.0

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.18.0",
3
+ "version": "1.19.0",
4
4
  "license": "AGPL-3.0-only",
5
5
  "type": "module",
6
6
  "module": "./src/index.ts",
@@ -35,7 +35,7 @@
35
35
  "sanitize-filename": "^1.6.3",
36
36
  "@powerhousedao/scalars": "1.23.0",
37
37
  "document-model": "2.20.0",
38
- "document-model-libs": "1.132.0"
38
+ "document-model-libs": "1.132.1"
39
39
  },
40
40
  "optionalDependencies": {
41
41
  "@prisma/client": "^5.18.0",
@@ -66,7 +66,7 @@
66
66
  "vitest-fetch-mock": "^0.3.0",
67
67
  "webdriverio": "^9.0.9",
68
68
  "document-model": "2.20.0",
69
- "document-model-libs": "1.132.0"
69
+ "document-model-libs": "1.132.1"
70
70
  },
71
71
  "scripts": {
72
72
  "check-types": "tsc --build",
@@ -662,7 +662,15 @@ export class BaseDocumentDriveServer implements IBaseDocumentDriveServer {
662
662
  for (const zodListener of drive.state.local.listeners) {
663
663
  const transmitter = this.transmitterFactory.instance(
664
664
  zodListener.callInfo?.transmitterType ?? "",
665
- zodListener as any,
665
+ {
666
+ driveId,
667
+ listenerId: zodListener.listenerId,
668
+ block: zodListener.block,
669
+ filter: zodListener.filter,
670
+ system: zodListener.system,
671
+ label: zodListener.label || undefined,
672
+ callInfo: zodListener.callInfo || undefined,
673
+ },
666
674
  this,
667
675
  );
668
676
 
@@ -2102,7 +2110,15 @@ export class BaseDocumentDriveServer implements IBaseDocumentDriveServer {
2102
2110
  // create the transmitter
2103
2111
  const transmitter = this.transmitterFactory.instance(
2104
2112
  zodListener.callInfo?.transmitterType ?? "",
2105
- zodListener as any,
2113
+ {
2114
+ driveId,
2115
+ listenerId: zodListener.listenerId,
2116
+ block: zodListener.block,
2117
+ filter: zodListener.filter,
2118
+ system: zodListener.system,
2119
+ label: zodListener.label || undefined,
2120
+ callInfo: zodListener.callInfo || undefined,
2121
+ },
2106
2122
  this,
2107
2123
  );
2108
2124
 
@@ -23,6 +23,8 @@ import {
23
23
  } from "../types";
24
24
  import { StrandUpdateSource } from "./transmitter/types";
25
25
 
26
+ const ENABLE_SYNC_DEBUG = false;
27
+
26
28
  function debounce<T extends unknown[], R>(
27
29
  func: (...args: T) => Promise<R>,
28
30
  delay = 250,
@@ -50,26 +52,43 @@ function debounce<T extends unknown[], R>(
50
52
 
51
53
  export class ListenerManager implements IListenerManager {
52
54
  static LISTENER_UPDATE_DELAY = 250;
53
-
55
+ private debugID = `[LM #${Math.floor(Math.random() * 999)}]`;
54
56
  protected driveServer: IBaseDocumentDriveServer;
57
+ protected options: ListenerManagerOptions;
58
+
55
59
  // driveId -> listenerId -> listenerState
56
60
  protected listenerStateByDriveId = new Map<
57
61
  string,
58
62
  Map<string, ListenerState>
59
63
  >();
60
- protected options: ListenerManagerOptions;
61
64
 
62
65
  constructor(
63
66
  drive: IBaseDocumentDriveServer,
64
67
  listenerState = new Map<string, Map<string, ListenerState>>(),
65
68
  options: ListenerManagerOptions = DefaultListenerManagerOptions,
66
69
  ) {
70
+ this.debugLog(`constructor(...)`);
67
71
  this.driveServer = drive;
68
72
  this.listenerStateByDriveId = listenerState;
69
73
  this.options = { ...DefaultListenerManagerOptions, ...options };
70
74
  }
71
75
 
76
+ private debugLog(...data: any[]) {
77
+ if (!ENABLE_SYNC_DEBUG) {
78
+ return;
79
+ }
80
+
81
+ if (data.length > 0 && typeof data[0] === "string") {
82
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
83
+ console.log(`${this.debugID} ${data[0]}`, ...data.slice(1));
84
+ } else {
85
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
86
+ console.log(this.debugID, ...data);
87
+ }
88
+ }
89
+
72
90
  async initialize(handler: DriveUpdateErrorHandler) {
91
+ this.debugLog("initialize(...)");
73
92
  // if network connect comes back online
74
93
  // then triggers the listeners update
75
94
  if (typeof window !== "undefined") {
@@ -86,6 +105,10 @@ export class ListenerManager implements IListenerManager {
86
105
  }
87
106
 
88
107
  async setListener(driveId: string, listener: Listener) {
108
+ this.debugLog(
109
+ `setListener(drive: ${driveId}, listener: ${listener.listenerId})`,
110
+ );
111
+
89
112
  // slight code smell -- drive id may not need to be on listener or not passed in
90
113
  if (driveId !== listener.driveId) {
91
114
  throw new Error("Drive ID mismatch");
@@ -113,6 +136,8 @@ export class ListenerManager implements IListenerManager {
113
136
  }
114
137
 
115
138
  async removeListener(driveId: string, listenerId: string) {
139
+ this.debugLog("setListener()");
140
+
116
141
  const driveMap = this.listenerStateByDriveId.get(driveId);
117
142
  if (!driveMap) {
118
143
  return false;
@@ -222,27 +247,46 @@ export class ListenerManager implements IListenerManager {
222
247
  private async _triggerUpdate(
223
248
  source: StrandUpdateSource,
224
249
  onError?: (error: Error, driveId: string, listener: ListenerState) => void,
250
+ maxContinues = 500,
225
251
  ) {
252
+ this.debugLog(
253
+ `_triggerUpdate(source: ${source.type}, maxContinues: ${maxContinues})`,
254
+ this.listenerStateByDriveId,
255
+ );
256
+
257
+ if (maxContinues < 0) {
258
+ throw new Error("Maximum retries exhausted.");
259
+ }
260
+
226
261
  const listenerUpdates: ListenerUpdate[] = [];
262
+
227
263
  for (const [driveId, drive] of this.listenerStateByDriveId) {
228
- for (const [_, listenerState] of drive) {
264
+ for (const [listenerId, listenerState] of drive) {
229
265
  const transmitter = listenerState.listener.transmitter;
266
+
230
267
  if (!transmitter?.transmit) {
268
+ this.debugLog(`Transmitter not set on listener: ${listenerId}`);
231
269
  continue;
232
270
  }
233
271
 
234
- const syncUnits = await this.getListenerSyncUnits(
235
- driveId,
236
- listenerState.listener.listenerId,
237
- );
238
-
272
+ const syncUnits = await this.getListenerSyncUnits(driveId, listenerId);
239
273
  const strandUpdates: StrandUpdate[] = [];
274
+
275
+ this.debugLog("syncUnits", syncUnits);
276
+
240
277
  // TODO change to push one after the other, reusing operation data
241
278
  const tasks = syncUnits.map((syncUnit) => async () => {
242
279
  const unitState = listenerState.syncUnits.get(syncUnit.syncId);
243
280
 
244
281
  if (unitState && unitState.listenerRev >= syncUnit.revision) {
282
+ this.debugLog(
283
+ `Abandoning push for sync unit ${syncUnit.syncId}: already up-to-date (${unitState.listenerRev} >= ${syncUnit.revision})`,
284
+ );
245
285
  return;
286
+ } else {
287
+ this.debugLog(
288
+ `Listener out-of-date for sync unit ${syncUnit.syncId}: ${unitState?.listenerRev} < ${syncUnit.revision}`,
289
+ );
246
290
  }
247
291
 
248
292
  const opData: OperationUpdate[] = [];
@@ -261,6 +305,9 @@ export class ListenerManager implements IListenerManager {
261
305
  }
262
306
 
263
307
  if (!opData.length) {
308
+ this.debugLog(
309
+ `Abandoning push for ${syncUnit.syncId}: no operations found`,
310
+ );
264
311
  return;
265
312
  }
266
313
 
@@ -272,34 +319,53 @@ export class ListenerManager implements IListenerManager {
272
319
  scope: syncUnit.scope as OperationScope,
273
320
  });
274
321
  });
322
+
275
323
  if (this.options.sequentialUpdates) {
324
+ this.debugLog(
325
+ `Collecting ${tasks.length} syncUnit strandUpdates in sequence`,
326
+ );
276
327
  for (const task of tasks) {
277
328
  await task();
278
329
  }
279
330
  } else {
331
+ this.debugLog(
332
+ `Collecting ${tasks.length} syncUnit strandUpdates in parallel`,
333
+ );
280
334
  await Promise.all(tasks.map((task) => task()));
281
335
  }
282
336
 
283
337
  if (strandUpdates.length == 0) {
338
+ this.debugLog(`No strandUpdates needed for listener ${listenerId}`);
284
339
  continue;
285
340
  }
286
341
 
287
342
  listenerState.pendingTimeout = new Date(
288
343
  new Date().getTime() / 1000 + 300,
289
344
  ).toISOString();
345
+
290
346
  listenerState.listenerStatus = "PENDING";
291
347
 
292
348
  // TODO update listeners in parallel, blocking for listeners with block=true
293
349
  try {
350
+ this.debugLog(
351
+ `_triggerUpdate(source: ${source.type}) > transmitter.transmit`,
352
+ );
353
+
294
354
  const listenerRevisions = await transmitter.transmit(
295
355
  strandUpdates,
296
356
  source,
297
357
  );
298
358
 
359
+ this.debugLog(
360
+ `_triggerUpdate(source: ${source.type}) > transmission succeeded`,
361
+ listenerRevisions,
362
+ );
363
+
299
364
  listenerState.pendingTimeout = "0";
300
365
  listenerState.listenerStatus = "PENDING";
301
366
 
302
367
  const lastUpdated = new Date().toISOString();
368
+ let continuationNeeded = false;
303
369
 
304
370
  for (const revision of listenerRevisions) {
305
371
  const syncUnit = syncUnits.find(
@@ -308,11 +374,42 @@ export class ListenerManager implements IListenerManager {
308
374
  revision.scope === unit.scope &&
309
375
  revision.branch === unit.branch,
310
376
  );
377
+
311
378
  if (syncUnit) {
312
379
  listenerState.syncUnits.set(syncUnit.syncId, {
313
380
  lastUpdated,
314
381
  listenerRev: revision.revision,
315
382
  });
383
+
384
+ // Check for revision status vv
385
+ const su = strandUpdates.find(
386
+ (su) =>
387
+ su.driveId === revision.driveId &&
388
+ su.documentId === revision.documentId &&
389
+ su.scope === revision.scope &&
390
+ su.branch === revision.branch,
391
+ );
392
+
393
+ if (su && su.operations.length > 0) {
394
+ const suIndex = su.operations.at(
395
+ su.operations.length - 1,
396
+ )?.index;
397
+ if (suIndex !== revision.revision) {
398
+ this.debugLog(
399
+ `Revision still out-of-date for ${su.documentId}:${su.scope}:${su.branch} ${suIndex} <> ${revision.revision}`,
400
+ );
401
+ continuationNeeded = true;
402
+ } else {
403
+ this.debugLog(
404
+ `Revision match for ${su.documentId}:${su.scope}:${su.branch} ${suIndex}`,
405
+ );
406
+ }
407
+ } else {
408
+ this.debugLog(
409
+ `Cannot find strand update for (${revision.documentId}:${revision.scope}:${revision.branch} in drive ${revision.driveId})`,
410
+ );
411
+ }
412
+ // Check for revision status ^^
316
413
  } else {
317
414
  logger.warn(
318
415
  `Received revision for untracked unit for listener ${listenerState.listener.listenerId}`,
@@ -324,23 +421,31 @@ export class ListenerManager implements IListenerManager {
324
421
  for (const revision of listenerRevisions) {
325
422
  const error = revision.status === "ERROR";
326
423
  if (revision.error?.includes("Missing operations")) {
327
- const updates = await this._triggerUpdate(source, onError);
328
- listenerUpdates.push(...updates);
329
- } else {
330
- listenerUpdates.push({
331
- listenerId: listenerState.listener.listenerId,
332
- listenerRevisions,
333
- });
334
- if (error) {
335
- throw new OperationError(
336
- revision.status as ErrorStatus,
337
- undefined,
338
- revision.error,
339
- revision.error,
340
- );
341
- }
424
+ continuationNeeded = true;
425
+ } else if (error) {
426
+ throw new OperationError(
427
+ revision.status as ErrorStatus,
428
+ undefined,
429
+ revision.error,
430
+ revision.error,
431
+ );
342
432
  }
343
433
  }
434
+
435
+ if (!continuationNeeded) {
436
+ listenerUpdates.push({
437
+ listenerId: listenerState.listener.listenerId,
438
+ listenerRevisions,
439
+ });
440
+ } else {
441
+ const updates = await this._triggerUpdate(
442
+ source,
443
+ onError,
444
+ maxContinues - 1,
445
+ );
446
+ listenerUpdates.push(...updates);
447
+ }
448
+
344
449
  listenerState.listenerStatus = "SUCCESS";
345
450
  } catch (e) {
346
451
  // TODO: Handle error based on listener params (blocking, retry, etc)
@@ -350,6 +455,12 @@ export class ListenerManager implements IListenerManager {
350
455
  }
351
456
  }
352
457
  }
458
+
459
+ this.debugLog(
460
+ `Returning listener updates (maxContinues: ${maxContinues})`,
461
+ listenerUpdates,
462
+ );
463
+
353
464
  return listenerUpdates;
354
465
  }
355
466
 
@@ -22,6 +22,8 @@ import {
22
22
  StrandUpdateSource,
23
23
  } from "./types";
24
24
 
25
+ const ENABLE_SYNC_DEBUG = false;
26
+
25
27
  export type OperationUpdateGraphQL = Omit<OperationUpdate, "input"> & {
26
28
  input: string;
27
29
  };
@@ -44,16 +46,52 @@ export interface IPullResponderTransmitter extends ITransmitter {
44
46
  getStrands(options?: GetStrandsOptions): Promise<StrandUpdate[]>;
45
47
  }
46
48
 
49
+ const STATIC_DEBUG_ID = `[PRT #static]`;
50
+
51
+ function staticDebugLog(...data: any[]) {
52
+ if (!ENABLE_SYNC_DEBUG) {
53
+ return;
54
+ }
55
+
56
+ if (data.length > 0 && typeof data[0] === "string") {
57
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
58
+ console.log(`${STATIC_DEBUG_ID} ${data[0]}`, ...data.slice(1));
59
+ } else {
60
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
61
+ console.log(STATIC_DEBUG_ID, ...data);
62
+ }
63
+ }
64
+
47
65
  export class PullResponderTransmitter implements IPullResponderTransmitter {
66
+ private debugID = `[PRT #${Math.floor(Math.random() * 999)}]`;
48
67
  private listener: Listener;
49
68
  private manager: IListenerManager;
50
69
 
51
70
  constructor(listener: Listener, manager: IListenerManager) {
52
71
  this.listener = listener;
53
72
  this.manager = manager;
73
+ this.debugLog(`constructor(listener: ${listener.listenerId})`);
74
+ }
75
+
76
+ private debugLog(...data: any[]) {
77
+ if (!ENABLE_SYNC_DEBUG) {
78
+ return;
79
+ }
80
+
81
+ if (data.length > 0 && typeof data[0] === "string") {
82
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
83
+ console.log(`${this.debugID} ${data[0]}`, ...data.slice(1));
84
+ } else {
85
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
86
+ console.log(this.debugID, ...data);
87
+ }
54
88
  }
55
89
 
56
90
  getStrands(options?: GetStrandsOptions): Promise<StrandUpdate[]> {
91
+ this.debugLog(
92
+ `getStrands(drive: ${this.listener.driveId}, listener: ${this.listener.listenerId})`,
93
+ );
94
+
57
95
  return this.manager.getStrands(
58
96
  this.listener.driveId,
59
97
  this.listener.listenerId,
@@ -71,6 +109,11 @@ export class PullResponderTransmitter implements IPullResponderTransmitter {
71
109
  listenerId: string,
72
110
  revisions: ListenerRevision[],
73
111
  ): Promise<boolean> {
112
+ this.debugLog(
113
+ `processAcknowledge(drive: ${driveId}, listener: ${listenerId})`,
114
+ revisions,
115
+ );
116
+
74
117
  const syncUnits = await this.manager.getListenerSyncUnitIds(
75
118
  driveId,
76
119
  listenerId,
@@ -106,6 +149,7 @@ export class PullResponderTransmitter implements IPullResponderTransmitter {
106
149
  url: string,
107
150
  filter: ListenerFilter,
108
151
  ): Promise<Listener["listenerId"]> {
152
+ staticDebugLog(`registerPullResponder(url: ${url})`, filter);
109
153
  // graphql request to switchboard
110
154
  const result = await requestGraphql<{
111
155
  registerPullResponderListener: {
@@ -141,6 +185,7 @@ export class PullResponderTransmitter implements IPullResponderTransmitter {
141
185
  listenerId: string,
142
186
  options?: GetStrandsOptions, // TODO add support for since
143
187
  ): Promise<StrandUpdate[]> {
188
+ staticDebugLog(`pullStrands(url: ${url}, listener: ${listenerId})`);
144
189
  const result = await requestGraphql<PullStrandsGraphQL>(
145
190
  url,
146
191
  gql`
@@ -207,6 +252,11 @@ export class PullResponderTransmitter implements IPullResponderTransmitter {
207
252
  listenerId: string,
208
253
  revisions: ListenerRevision[],
209
254
  ): Promise<boolean> {
255
+ staticDebugLog(
256
+ `acknowledgeStrands(url: ${url}, listener: ${listenerId})`,
257
+ revisions,
258
+ );
259
+
210
260
  const result = await requestGraphql<{ acknowledge: boolean }>(
211
261
  url,
212
262
  gql`
@@ -241,6 +291,8 @@ export class PullResponderTransmitter implements IPullResponderTransmitter {
241
291
  onRevisions?: (revisions: ListenerRevisionWithError[]) => void,
242
292
  onAcknowledge?: (success: boolean) => void,
243
293
  ) {
294
+ staticDebugLog(`executePull(driveId: ${driveId}), trigger:`, trigger);
295
+
244
296
  try {
245
297
  const { url, listenerId } = trigger.data;
246
298
  const strands = await PullResponderTransmitter.pullStrands(
@@ -323,6 +375,8 @@ export class PullResponderTransmitter implements IPullResponderTransmitter {
323
375
  onRevisions?: (revisions: ListenerRevisionWithError[]) => void,
324
376
  onAcknowledge?: (success: boolean) => void,
325
377
  ): CancelPullLoop {
378
+ staticDebugLog(`setupPull(drive: ${driveId}), trigger:`, trigger);
379
+
326
380
  const { interval } = trigger.data;
327
381
  let loopInterval = PULL_DRIVE_INTERVAL;
328
382
  if (interval) {
@@ -341,6 +395,7 @@ export class PullResponderTransmitter implements IPullResponderTransmitter {
341
395
 
342
396
  const executeLoop = async () => {
343
397
  while (!isCancelled) {
398
+ staticDebugLog("Execute loop...");
344
399
  await this.executePull(
345
400
  driveId,
346
401
  trigger,
@@ -350,6 +405,7 @@ export class PullResponderTransmitter implements IPullResponderTransmitter {
350
405
  onAcknowledge,
351
406
  );
352
407
  await new Promise((resolve) => {
408
+ staticDebugLog(`Scheduling next pull in ${loopInterval} ms`);
353
409
  timeout = setTimeout(resolve, loopInterval) as unknown as number;
354
410
  });
355
411
  }
@@ -370,6 +426,10 @@ export class PullResponderTransmitter implements IPullResponderTransmitter {
370
426
  url: string,
371
427
  options: Pick<RemoteDriveOptions, "pullInterval" | "pullFilter">,
372
428
  ): Promise<PullResponderTrigger> {
429
+ staticDebugLog(
430
+ `createPullResponderTrigger(drive: ${driveId}, url: ${url})`,
431
+ );
432
+
373
433
  const { pullFilter, pullInterval } = options;
374
434
  const listenerId = await PullResponderTransmitter.registerPullResponder(
375
435
  driveId,
@@ -4,13 +4,31 @@ import { logger } from "../../../utils/logger";
4
4
  import { ListenerRevision, StrandUpdate } from "../../types";
5
5
  import { ITransmitter, StrandUpdateSource } from "./types";
6
6
 
7
+ const ENABLE_SYNC_DEBUG = false;
8
+ const SYNC_OPS_BATCH_LIMIT = 10;
9
+
7
10
  export class SwitchboardPushTransmitter implements ITransmitter {
8
11
  private targetURL: string;
12
+ private debugID = `[SPT #${Math.floor(Math.random() * 999)}]`;
9
13
 
10
14
  constructor(targetURL: string) {
11
15
  this.targetURL = targetURL;
12
16
  }
13
17
 
18
+ private debugLog(...data: any[]) {
19
+ if (!ENABLE_SYNC_DEBUG) {
20
+ return false;
21
+ }
22
+
23
+ if (data.length > 0 && typeof data[0] === "string") {
24
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
25
+ console.log(`${this.debugID} ${data[0]}`, ...data.slice(1));
26
+ } else {
27
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
28
+ console.log(this.debugID, ...data);
29
+ }
30
+ }
31
+
14
32
  async transmit(
15
33
  strands: StrandUpdate[],
16
34
  source: StrandUpdateSource,
@@ -19,6 +37,7 @@ export class SwitchboardPushTransmitter implements ITransmitter {
19
37
  source.type === "trigger" &&
20
38
  source.trigger.data?.url === this.targetURL
21
39
  ) {
40
+ this.debugLog(`Cutting trigger loop from ${this.targetURL}.`);
22
41
  return strands.map((strand) => ({
23
42
  driveId: strand.driveId,
24
43
  documentId: strand.documentId,
@@ -29,6 +48,39 @@ export class SwitchboardPushTransmitter implements ITransmitter {
29
48
  }));
30
49
  }
31
50
 
51
+ const culledStrands: StrandUpdate[] = [];
52
+ let opsCounter = 0;
53
+
54
+ for (
55
+ let s = 0;
56
+ opsCounter <= SYNC_OPS_BATCH_LIMIT && s < strands.length;
57
+ s++
58
+ ) {
59
+ const currentStrand = strands.at(s);
60
+ if (!currentStrand) {
61
+ break;
62
+ }
63
+ const newOps = Math.min(
64
+ SYNC_OPS_BATCH_LIMIT - opsCounter,
65
+ currentStrand.operations.length,
66
+ );
67
+
68
+ culledStrands.push({
69
+ ...currentStrand,
70
+ operations: currentStrand.operations.slice(0, newOps),
71
+ });
72
+
73
+ opsCounter += newOps;
74
+ }
75
+
76
+ this.debugLog(
77
+ ` Total update: [${strands.map((s) => s.operations.length).join(", ")}] operations`,
78
+ );
79
+
80
+ this.debugLog(
81
+ `Culled update: [${culledStrands.map((s) => s.operations.length).join(", ")}] operations`,
82
+ );
83
+
32
84
  // Send Graphql mutation to switchboard
33
85
  try {
34
86
  const { pushUpdates } = await requestGraphql<{
@@ -49,7 +101,7 @@ export class SwitchboardPushTransmitter implements ITransmitter {
49
101
  }
50
102
  `,
51
103
  {
52
- strands: strands.map((strand) => ({
104
+ strands: culledStrands.map((strand) => ({
53
105
  ...strand,
54
106
  operations: strand.operations.map((op) => ({
55
107
  ...op,