@verdant-web/server 3.3.3 → 3.3.4-alpha.1
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/LICENSE +21 -650
- package/dist/esm/ServerLibrary.d.ts +6 -0
- package/dist/esm/ServerLibrary.js +48 -14
- package/dist/esm/ServerLibrary.js.map +1 -1
- package/dist/esm/storage/Storage.d.ts +1 -1
- package/dist/esm/storage/sql/SqlOperations.d.ts +1 -1
- package/dist/esm/storage/sql/SqlOperations.js +17 -5
- package/dist/esm/storage/sql/SqlOperations.js.map +1 -1
- package/dist/esm/storage/sqlShard/SqlOperations.d.ts +1 -1
- package/dist/esm/storage/sqlShard/SqlOperations.js +23 -8
- package/dist/esm/storage/sqlShard/SqlOperations.js.map +1 -1
- package/package.json +2 -2
- package/src/ServerLibrary.ts +93 -22
- package/src/storage/Storage.ts +1 -1
- package/src/storage/sql/SqlOperations.ts +17 -8
- package/src/storage/sqlShard/SqlOperations.ts +31 -24
package/src/ServerLibrary.ts
CHANGED
|
@@ -13,7 +13,6 @@ import {
|
|
|
13
13
|
applyOperations,
|
|
14
14
|
isFileRef,
|
|
15
15
|
isObjectRef,
|
|
16
|
-
omit,
|
|
17
16
|
rewriteAuthzOriginator,
|
|
18
17
|
} from '@verdant-web/common';
|
|
19
18
|
import { MessageSender } from './MessageSender.js';
|
|
@@ -26,6 +25,7 @@ import {
|
|
|
26
25
|
LibraryInfo,
|
|
27
26
|
StoredDocumentBaseline,
|
|
28
27
|
StoredOperation,
|
|
28
|
+
StoredReplicaInfo,
|
|
29
29
|
} from './types.js';
|
|
30
30
|
|
|
31
31
|
export type ServerLibraryEvents = {
|
|
@@ -171,13 +171,27 @@ export class ServerLibrary extends EventSubscriber<ServerLibraryEvents> {
|
|
|
171
171
|
message.operations,
|
|
172
172
|
);
|
|
173
173
|
|
|
174
|
-
// TODO: can we defer this and rebroadcast in parallel?
|
|
175
174
|
// insert patches into history
|
|
176
|
-
|
|
175
|
+
|
|
176
|
+
// first, record the replica's serverOrder before insertion.
|
|
177
|
+
const replicaInfo = await this.storage.replicas.getOrCreate(
|
|
178
|
+
info.libraryId,
|
|
179
|
+
message.replicaId,
|
|
180
|
+
{
|
|
181
|
+
userId: info.userId,
|
|
182
|
+
type: info.type,
|
|
183
|
+
},
|
|
184
|
+
);
|
|
185
|
+
const newServerOrder = await this.storage.operations.insertAll(
|
|
177
186
|
info.libraryId,
|
|
178
187
|
message.replicaId,
|
|
179
188
|
message.operations,
|
|
180
189
|
);
|
|
190
|
+
this.preemptiveUpdateServerOrder(
|
|
191
|
+
replicaInfo.replicaInfo,
|
|
192
|
+
newServerOrder,
|
|
193
|
+
message.operations.length,
|
|
194
|
+
);
|
|
181
195
|
|
|
182
196
|
this.enqueueRebase(info.libraryId);
|
|
183
197
|
|
|
@@ -196,6 +210,35 @@ export class ServerLibrary extends EventSubscriber<ServerLibraryEvents> {
|
|
|
196
210
|
this.emit(`changes`, info, message.operations, []);
|
|
197
211
|
};
|
|
198
212
|
|
|
213
|
+
/**
|
|
214
|
+
* If a replica inserts operations when it's already
|
|
215
|
+
* up to date, we can preemptively update its ackedServerOrder
|
|
216
|
+
* to avoid a round trip.
|
|
217
|
+
*/
|
|
218
|
+
private preemptiveUpdateServerOrder = async (
|
|
219
|
+
replicaInfo: StoredReplicaInfo,
|
|
220
|
+
newServerOrder: number,
|
|
221
|
+
insertedOperationsCount: number,
|
|
222
|
+
) => {
|
|
223
|
+
if (
|
|
224
|
+
newServerOrder - replicaInfo.ackedServerOrder ===
|
|
225
|
+
insertedOperationsCount
|
|
226
|
+
) {
|
|
227
|
+
await this.storage.replicas.updateAckedServerOrder(
|
|
228
|
+
replicaInfo.libraryId,
|
|
229
|
+
replicaInfo.id,
|
|
230
|
+
newServerOrder,
|
|
231
|
+
);
|
|
232
|
+
this.log(
|
|
233
|
+
'debug',
|
|
234
|
+
'Preemptively updated ackedServerOrder for',
|
|
235
|
+
replicaInfo.id,
|
|
236
|
+
'to',
|
|
237
|
+
newServerOrder,
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
};
|
|
241
|
+
|
|
199
242
|
private rebroadcastOperations = async (
|
|
200
243
|
libraryId: string,
|
|
201
244
|
clientKey: string,
|
|
@@ -256,13 +299,26 @@ export class ServerLibrary extends EventSubscriber<ServerLibraryEvents> {
|
|
|
256
299
|
const { status, replicaInfo: clientReplicaInfo } =
|
|
257
300
|
await this.storage.replicas.getOrCreate(info.libraryId, replicaId, info);
|
|
258
301
|
|
|
259
|
-
const changesSince =
|
|
260
|
-
status === 'existing' ? clientReplicaInfo.ackedLogicalTime : null;
|
|
261
302
|
this.log(
|
|
262
303
|
'info',
|
|
263
|
-
`Sync from ${replicaId} (user: ${info.userId}) [
|
|
304
|
+
`Sync from ${replicaId} (user: ${info.userId}) [ackedServerOrder: ${clientReplicaInfo.ackedServerOrder}, status: ${status}}]`,
|
|
264
305
|
);
|
|
265
306
|
|
|
307
|
+
// for truant replicas -- if their ackedServerOrder is up-to-date, that's fine,
|
|
308
|
+
// restore them.
|
|
309
|
+
let overrideTruant = false;
|
|
310
|
+
if (status === 'truant') {
|
|
311
|
+
const latestServerOrder =
|
|
312
|
+
await this.storage.operations.getLatestServerOrder(info.libraryId);
|
|
313
|
+
if (clientReplicaInfo.ackedServerOrder >= latestServerOrder) {
|
|
314
|
+
overrideTruant = true;
|
|
315
|
+
this.log(
|
|
316
|
+
'debug',
|
|
317
|
+
'Overriding truant reset; truant replica has up-to-date server order',
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
266
322
|
// lookup operations after the last ack the client gave us
|
|
267
323
|
const ops = await this.storage.operations.getAfterServerOrder(
|
|
268
324
|
info.libraryId,
|
|
@@ -272,7 +328,8 @@ export class ServerLibrary extends EventSubscriber<ServerLibraryEvents> {
|
|
|
272
328
|
// new, unseen replicas should reset their existing storage
|
|
273
329
|
// to that of the server when joining a library.
|
|
274
330
|
// truant replicas should also reset their storage.
|
|
275
|
-
const replicaShouldReset =
|
|
331
|
+
const replicaShouldReset =
|
|
332
|
+
!!message.resyncAll || (!overrideTruant && status !== 'existing');
|
|
276
333
|
|
|
277
334
|
// only new replicas need baselines. by definition, a rebase only
|
|
278
335
|
// happens when all active replicas agree on history. every client
|
|
@@ -284,14 +341,17 @@ export class ServerLibrary extends EventSubscriber<ServerLibraryEvents> {
|
|
|
284
341
|
|
|
285
342
|
// We detect that a library is new by checking if it has any history.
|
|
286
343
|
// If there are no existing operations or baselines (and the requested
|
|
287
|
-
// history timerange was "everything", i.e. "from
|
|
344
|
+
// history timerange was "everything", i.e. "from server order 0"), then this is
|
|
288
345
|
// a fresh library which should receive the first client's history as its
|
|
289
346
|
// own. Otherwise, if the client requested a reset, we should send them
|
|
290
347
|
// the full history of the library.
|
|
291
348
|
// the definition of this field could be improved to be more explicit
|
|
292
349
|
// and not rely on seemingly unrelated data.
|
|
293
350
|
const isEmptyLibrary =
|
|
294
|
-
|
|
351
|
+
clientReplicaInfo.ackedServerOrder === 0 &&
|
|
352
|
+
ops.length === 0 &&
|
|
353
|
+
baselines.length === 0;
|
|
354
|
+
|
|
295
355
|
if (isEmptyLibrary) {
|
|
296
356
|
this.log('info', 'Received sync from new library', replicaId);
|
|
297
357
|
|
|
@@ -305,10 +365,12 @@ export class ServerLibrary extends EventSubscriber<ServerLibraryEvents> {
|
|
|
305
365
|
// don't reset replica if library is empty...
|
|
306
366
|
let overwriteReplicaData = replicaShouldReset && !isEmptyLibrary;
|
|
307
367
|
|
|
368
|
+
// EARLY RETURN --
|
|
308
369
|
// if the local library is empty and the replica is new to us,
|
|
309
370
|
// but the replica is providing a "since" timestamp, this
|
|
310
371
|
// suggests the local data is incomplete or gone. we request
|
|
311
|
-
// this replica should respond with a full history.
|
|
372
|
+
// this replica should respond with a full history. We will retry
|
|
373
|
+
// sync after replica has updated us with full history.
|
|
312
374
|
if (isEmptyLibrary && status === 'new' && message.since !== null) {
|
|
313
375
|
this.log(
|
|
314
376
|
'info',
|
|
@@ -360,11 +422,16 @@ export class ServerLibrary extends EventSubscriber<ServerLibraryEvents> {
|
|
|
360
422
|
message.operations.length,
|
|
361
423
|
'operations',
|
|
362
424
|
);
|
|
363
|
-
await this.storage.operations.insertAll(
|
|
425
|
+
const newServerOrder = await this.storage.operations.insertAll(
|
|
364
426
|
info.libraryId,
|
|
365
427
|
replicaId,
|
|
366
428
|
message.operations,
|
|
367
429
|
);
|
|
430
|
+
this.preemptiveUpdateServerOrder(
|
|
431
|
+
clientReplicaInfo,
|
|
432
|
+
newServerOrder,
|
|
433
|
+
message.operations.length,
|
|
434
|
+
);
|
|
368
435
|
await this.storage.replicas.updateAcknowledgedLogicalTime(
|
|
369
436
|
info.libraryId,
|
|
370
437
|
replicaId,
|
|
@@ -392,7 +459,13 @@ export class ServerLibrary extends EventSubscriber<ServerLibraryEvents> {
|
|
|
392
459
|
}
|
|
393
460
|
|
|
394
461
|
// create the nonce by encoding the server order of the last operation
|
|
395
|
-
const
|
|
462
|
+
const serverOrderToAck = ops.length
|
|
463
|
+
? ops[ops.length - 1].serverOrder
|
|
464
|
+
: undefined;
|
|
465
|
+
const ackThisNonce =
|
|
466
|
+
serverOrderToAck !== undefined
|
|
467
|
+
? this.createAckNonce(serverOrderToAck)
|
|
468
|
+
: undefined;
|
|
396
469
|
|
|
397
470
|
// respond to client
|
|
398
471
|
|
|
@@ -424,12 +497,8 @@ export class ServerLibrary extends EventSubscriber<ServerLibraryEvents> {
|
|
|
424
497
|
});
|
|
425
498
|
};
|
|
426
499
|
|
|
427
|
-
private createAckNonce = (
|
|
428
|
-
return
|
|
429
|
-
? Buffer.from(JSON.stringify(ops[ops.length - 1].serverOrder)).toString(
|
|
430
|
-
'base64',
|
|
431
|
-
)
|
|
432
|
-
: undefined;
|
|
500
|
+
private createAckNonce = (serverOrder: number): string | undefined => {
|
|
501
|
+
return Buffer.from(JSON.stringify(serverOrder)).toString('base64');
|
|
433
502
|
};
|
|
434
503
|
|
|
435
504
|
private handleAck = async (
|
|
@@ -656,8 +725,9 @@ export class ServerLibrary extends EventSubscriber<ServerLibraryEvents> {
|
|
|
656
725
|
this.log('debug', 'Database not open, skipping cleanup');
|
|
657
726
|
return;
|
|
658
727
|
}
|
|
659
|
-
const pendingDelete =
|
|
660
|
-
|
|
728
|
+
const pendingDelete = await this.storage.fileMetadata.getPendingDelete(
|
|
729
|
+
libraryId,
|
|
730
|
+
);
|
|
661
731
|
if (pendingDelete.length > 0) {
|
|
662
732
|
if (!this.fileStorage) {
|
|
663
733
|
throw new Error('File storage not configured, cannot delete files');
|
|
@@ -742,8 +812,9 @@ export class ServerLibrary extends EventSubscriber<ServerLibraryEvents> {
|
|
|
742
812
|
const data = {
|
|
743
813
|
id: libraryId,
|
|
744
814
|
replicas,
|
|
745
|
-
latestServerOrder:
|
|
746
|
-
|
|
815
|
+
latestServerOrder: await this.storage.operations.getLatestServerOrder(
|
|
816
|
+
libraryId,
|
|
817
|
+
),
|
|
747
818
|
operationsCount: await this.storage.operations.getCount(libraryId),
|
|
748
819
|
baselinesCount: await this.storage.baselines.getCount(libraryId),
|
|
749
820
|
globalAck: (await this.storage.replicas.getGlobalAck(libraryId)) ?? null,
|
package/src/storage/Storage.ts
CHANGED
|
@@ -69,7 +69,7 @@ export interface OperationStorage {
|
|
|
69
69
|
libraryId: string,
|
|
70
70
|
replicaId: string,
|
|
71
71
|
operations: Operation[],
|
|
72
|
-
): Promise<
|
|
72
|
+
): Promise<number>;
|
|
73
73
|
deleteAll(libraryId: string): Promise<void>;
|
|
74
74
|
delete(libraryId: string, operations: Operation[]): Promise<void>;
|
|
75
75
|
}
|
|
@@ -93,20 +93,21 @@ export class SqlOperations implements OperationStorage {
|
|
|
93
93
|
libraryId: string,
|
|
94
94
|
replicaId: string,
|
|
95
95
|
operations: Operation[],
|
|
96
|
-
): Promise<
|
|
96
|
+
): Promise<number> => {
|
|
97
97
|
// inserts all operations and updates server order
|
|
98
98
|
// FIXME: this whole thing is kinda sus
|
|
99
|
-
await this.db.transaction().execute(async (tx): Promise<
|
|
99
|
+
return await this.db.transaction().execute(async (tx): Promise<number> => {
|
|
100
100
|
let orderResult = await tx
|
|
101
101
|
.selectFrom('OperationHistory')
|
|
102
|
-
.select(
|
|
103
|
-
fn.coalesce(fn.max<number>('serverOrder'), val(0)).as('serverOrder'),
|
|
104
|
-
)
|
|
102
|
+
.select('serverOrder')
|
|
105
103
|
.where('libraryId', '=', libraryId)
|
|
104
|
+
.orderBy('serverOrder', 'desc')
|
|
105
|
+
.limit(1)
|
|
106
106
|
.executeTakeFirst();
|
|
107
107
|
let currentServerOrder = orderResult?.serverOrder ?? 0;
|
|
108
108
|
for (const item of operations) {
|
|
109
|
-
|
|
109
|
+
// utilizing returned serverOrder accommodates for conflicts
|
|
110
|
+
const result = await tx
|
|
110
111
|
.insertInto('OperationHistory')
|
|
111
112
|
.values({
|
|
112
113
|
libraryId,
|
|
@@ -114,7 +115,7 @@ export class SqlOperations implements OperationStorage {
|
|
|
114
115
|
data: JSON.stringify(item.data),
|
|
115
116
|
timestamp: item.timestamp,
|
|
116
117
|
replicaId,
|
|
117
|
-
serverOrder:
|
|
118
|
+
serverOrder: currentServerOrder + 1,
|
|
118
119
|
authz: item.authz,
|
|
119
120
|
})
|
|
120
121
|
.onConflict((cb) =>
|
|
@@ -122,8 +123,16 @@ export class SqlOperations implements OperationStorage {
|
|
|
122
123
|
.columns(['libraryId', 'replicaId', 'oid', 'timestamp'])
|
|
123
124
|
.doNothing(),
|
|
124
125
|
)
|
|
125
|
-
.
|
|
126
|
+
.returning('serverOrder')
|
|
127
|
+
.executeTakeFirst();
|
|
128
|
+
if (result) {
|
|
129
|
+
currentServerOrder = result.serverOrder;
|
|
130
|
+
} else {
|
|
131
|
+
// on conflict, nothing is returned.
|
|
132
|
+
// this would mean an operation was synced twice
|
|
133
|
+
}
|
|
126
134
|
}
|
|
135
|
+
return currentServerOrder;
|
|
127
136
|
});
|
|
128
137
|
};
|
|
129
138
|
|
|
@@ -1,15 +1,11 @@
|
|
|
1
1
|
import { Operation } from '@verdant-web/common';
|
|
2
2
|
import { StoredOperation } from '../../types.js';
|
|
3
3
|
import { OperationStorage } from '../Storage.js';
|
|
4
|
-
import {
|
|
5
|
-
import { Database, OperationHistoryRow } from './tables.js';
|
|
4
|
+
import { OperationHistoryRow } from './tables.js';
|
|
6
5
|
import { Databases } from './Databases.js';
|
|
7
6
|
|
|
8
7
|
export class SqlOperations implements OperationStorage {
|
|
9
|
-
constructor(
|
|
10
|
-
private dbs: Databases,
|
|
11
|
-
private dialect: 'postgres' | 'sqlite',
|
|
12
|
-
) {}
|
|
8
|
+
constructor(private dbs: Databases, private dialect: 'postgres' | 'sqlite') {}
|
|
13
9
|
|
|
14
10
|
private hydrate = (row: OperationHistoryRow) => {
|
|
15
11
|
// avoiding extra allocation from .map by type-asserting here
|
|
@@ -66,16 +62,17 @@ export class SqlOperations implements OperationStorage {
|
|
|
66
62
|
|
|
67
63
|
getLatestServerOrder = async (libraryId: string): Promise<number> => {
|
|
68
64
|
const db = await this.dbs.get(libraryId);
|
|
69
|
-
|
|
70
|
-
(
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
65
|
+
const result = await db
|
|
66
|
+
.selectFrom('OperationHistory')
|
|
67
|
+
.orderBy('serverOrder', 'desc')
|
|
68
|
+
.select('serverOrder')
|
|
69
|
+
.limit(1)
|
|
70
|
+
.executeTakeFirst();
|
|
71
|
+
if (result) {
|
|
72
|
+
return result.serverOrder;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return 0;
|
|
79
76
|
};
|
|
80
77
|
|
|
81
78
|
getCount = async (libraryId: string): Promise<number> => {
|
|
@@ -94,34 +91,44 @@ export class SqlOperations implements OperationStorage {
|
|
|
94
91
|
libraryId: string,
|
|
95
92
|
replicaId: string,
|
|
96
93
|
operations: Operation[],
|
|
97
|
-
): Promise<
|
|
94
|
+
): Promise<number> => {
|
|
98
95
|
const db = await this.dbs.get(libraryId);
|
|
99
96
|
// inserts all operations and updates server order
|
|
100
97
|
// FIXME: this whole thing is kinda sus
|
|
101
|
-
await db.transaction().execute(async (tx): Promise<
|
|
98
|
+
return await db.transaction().execute(async (tx): Promise<number> => {
|
|
102
99
|
let orderResult = await tx
|
|
103
100
|
.selectFrom('OperationHistory')
|
|
104
|
-
.select(
|
|
105
|
-
|
|
106
|
-
)
|
|
101
|
+
.select('serverOrder')
|
|
102
|
+
.orderBy('serverOrder', 'desc')
|
|
103
|
+
.limit(1)
|
|
107
104
|
.executeTakeFirst();
|
|
108
105
|
let currentServerOrder = orderResult?.serverOrder ?? 0;
|
|
109
106
|
for (const item of operations) {
|
|
110
|
-
|
|
107
|
+
// utilizing returned serverOrder accommodates for conflicts
|
|
108
|
+
const result = await tx
|
|
111
109
|
.insertInto('OperationHistory')
|
|
112
110
|
.values({
|
|
113
111
|
oid: item.oid,
|
|
114
112
|
data: JSON.stringify(item.data),
|
|
115
113
|
timestamp: item.timestamp,
|
|
116
114
|
replicaId,
|
|
117
|
-
serverOrder:
|
|
115
|
+
serverOrder: currentServerOrder + 1,
|
|
118
116
|
authz: item.authz,
|
|
119
117
|
})
|
|
120
118
|
.onConflict((cb) =>
|
|
121
119
|
cb.columns(['replicaId', 'oid', 'timestamp']).doNothing(),
|
|
122
120
|
)
|
|
123
|
-
.
|
|
121
|
+
.returning('serverOrder')
|
|
122
|
+
.executeTakeFirst();
|
|
123
|
+
if (result) {
|
|
124
|
+
currentServerOrder = result.serverOrder;
|
|
125
|
+
} else {
|
|
126
|
+
// on conflict, nothing is returned.
|
|
127
|
+
// this would mean an operation was synced twice.
|
|
128
|
+
}
|
|
124
129
|
}
|
|
130
|
+
|
|
131
|
+
return currentServerOrder;
|
|
125
132
|
});
|
|
126
133
|
};
|
|
127
134
|
|