Skip to content

Commit a32cc05

Browse files
committed
implemented a simple draft of how optimistic operations could look like in DataSync
The latency from the US to the IHP Backend server is just too much for me
1 parent a707272 commit a32cc05

File tree

1 file changed

+130
-3
lines changed

1 file changed

+130
-3
lines changed

lib/IHP/DataSync/ihp-datasync.js

Lines changed: 130 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,11 @@ class DataSyncController {
3838
// We store messages here that cannot be send because the connection is not available
3939
this.outbox = [];
4040
this.reconnectTimeout = null;
41+
42+
this.dataSubscriptions = [];
43+
44+
// Ids of records that are visible in the UI already, but yet still have to be confirmed by the server
45+
this.optimisticCreatedPendingRecordIds = [];
4146
}
4247

4348
async startConnection() {
@@ -205,15 +210,16 @@ class DataSubscription {
205210
this.onDataSyncReconnect = this.onDataSyncReconnect.bind(this);
206211
this.onMessage = this.onMessage.bind(this);
207212

208-
209213
// When a new record is inserted, do we put it at the end or at the beginning?
210214
this.newRecordBehaviour = this.detectNewRecordBehaviour();
215+
216+
this.optimisticCreatedPendingRecordIds = [];
211217
}
212218

213219
detectNewRecordBehaviour() {
214220
// If the query is ordered by the createdAt column, and the latest is at the top
215221
// we want to prepend new record
216-
const isOrderByCreatedAtDesc = this.query.orderByClause.length > 0 && this.query.orderByClause[0].orderByColumn === 'createdAt' && this.query.orderByClause[0].orderByDirection === 'Desc';
222+
const isOrderByCreatedAtDesc = this.query.orderByClause.length > 0 && this.query.orderByClause[0].orderByColumn === 'createdAt' && this.query.orderByClause[0].orderByColumn === 'createdAt';
217223

218224
if (isOrderByCreatedAtDesc) {
219225
return PREPEND_NEW_RECORD;
@@ -236,6 +242,7 @@ class DataSubscription {
236242
dataSyncController.addEventListener('message', this.onMessage);
237243
dataSyncController.addEventListener('close', this.onDataSyncClosed);
238244
dataSyncController.addEventListener('reconnect', this.onDataSyncReconnect);
245+
dataSyncController.dataSubscriptions.push(this);
239246
}
240247

241248
this.isConnected = true;
@@ -289,6 +296,7 @@ class DataSubscription {
289296
dataSyncController.removeEventListener('message', this.onMessage);
290297
dataSyncController.removeEventListener('close', this.onDataSyncClosed);
291298
dataSyncController.removeEventListener('reconnect', this.onDataSyncReconnect);
299+
dataSyncController.dataSubscriptions.splice(dataSyncController.dataSubscriptions.indexOf(this), 1);
292300

293301
this.isClosed = true;
294302
this.isConnected = false;
@@ -319,10 +327,27 @@ class DataSubscription {
319327

320328
onCreate(newRecord) {
321329
const shouldAppend = this.newRecordBehaviour === APPEND_NEW_RECORD;
322-
this.records = shouldAppend ? [...this.records, newRecord] : [newRecord, ...this.records];
330+
331+
const isOptimisticallyCreatedAlready = this.optimisticCreatedPendingRecordIds.indexOf(newRecord.id) !== -1;
332+
if (isOptimisticallyCreatedAlready) {
333+
this.onUpdate(newRecord.id, newRecord);
334+
this.optimisticCreatedPendingRecordIds.slice(this.optimisticCreatedPendingRecordIds.indexOf(newRecord.id), 1);
335+
} else {
336+
this.records = shouldAppend ? [...this.records, newRecord] : [newRecord, ...this.records];
337+
}
338+
323339
this.updateSubscribers();
324340
}
325341

342+
onCreateOptimistic(newRecord) {
343+
if (!('id' in newRecord)) {
344+
throw new Error('Requires the record to have an id');
345+
}
346+
347+
this.onCreate(newRecord);
348+
this.optimisticCreatedPendingRecordIds.push(newRecord.id);
349+
}
350+
326351
onDelete(id) {
327352
this.records = this.records.filter(record => record.id !== id);
328353
this.updateSubscribers();
@@ -391,9 +416,14 @@ export async function createRecord(table, record, options = {}) {
391416
const request = { tag: 'CreateRecordMessage', table, record, transactionId };
392417

393418
try {
419+
createOptimisticRecord(table, record);
420+
await waitPendingChanges(table, record);
421+
394422
const response = await DataSyncController.getInstance().sendMessage(request);
395423
return response.record;
396424
} catch (e) {
425+
undoCreateOptimisticRecord(table, record);
426+
397427
// We rethrow the error here for improved callstacks
398428
// Without this, the error location in the callstack would show the error to be caused
399429
// somewhere in DataSyncController. But the user is not really using DataSyncController
@@ -459,11 +489,13 @@ export async function deleteRecord(table, id, options = {}) {
459489
const transactionId = 'transactionId' in options ? options.transactionId : null;
460490
const request = { tag: 'DeleteRecordMessage', table, id, transactionId };
461491

492+
let undoOptimisticDeleteRecord = deleteRecordOptimistic(table, id);;
462493
try {
463494
const response = await DataSyncController.getInstance().sendMessage(request);
464495

465496
return;
466497
} catch (e) {
498+
undoOptimisticDeleteRecord();
467499
throw new Error(e.message);
468500
}
469501
}
@@ -508,4 +540,99 @@ export async function createRecords(table, records, options = {}) {
508540
}
509541
}
510542

543+
function createOptimisticRecord(table, record) {
544+
// Ensure that the record has an ID
545+
if (record.id === null || record.id === undefined) {
546+
record.id = crypto.randomUUID();
547+
}
548+
if (record.createdAt === null || record.createdAt === undefined) {
549+
record.createdAt = new Date();
550+
}
551+
552+
const dataSyncController = DataSyncController.getInstance();
553+
for (const dataSubscription of dataSyncController.dataSubscriptions) {
554+
if (dataSubscription.query.table !== table) {
555+
continue;
556+
}
557+
558+
// TODO: we need to check that this data subscription's WHERE condition matches the new record
559+
dataSubscription.onCreateOptimistic(record);
560+
}
561+
562+
dataSyncController.optimisticCreatedPendingRecordIds.push(record.id);
563+
}
564+
565+
function undoCreateOptimisticRecord(table, record) {
566+
const dataSyncController = DataSyncController.getInstance();
567+
for (const dataSubscription of dataSyncController.dataSubscriptions) {
568+
if (dataSubscription.query.table !== table) {
569+
continue;
570+
}
571+
572+
dataSubscription.onDelete(record.id);
573+
}
574+
575+
dataSyncController.optimisticCreatedPendingRecordIds.slice(dataSyncController.optimisticCreatedPendingRecordIds.indexOf(record.id), 1);
576+
}
577+
578+
function deleteRecordOptimistic(table, id) {
579+
const dataSyncController = DataSyncController.getInstance();
580+
const undoOperations = [];
581+
for (const dataSubscription of dataSyncController.dataSubscriptions) {
582+
if (dataSubscription.query.table !== table) {
583+
continue;
584+
}
585+
586+
const deletedRecord = dataSubscription.records.find(record => record.id === id);
587+
if (deletedRecord) {
588+
dataSubscription.onDelete(id);
589+
undoOperations.push(() => dataSubscription.onCreate(deleteRecord));
590+
}
591+
}
592+
593+
return () => {
594+
for (const undoOperation of undoOperations) {
595+
undoOperation();
596+
}
597+
}
598+
}
599+
600+
601+
function doesRecordReferencePendingOptimisticRecord(record) {
602+
const dataSyncController = DataSyncController.getInstance();
603+
const optimisticIds = dataSyncController.optimisticCreatedPendingRecordIds;
604+
605+
for (const attribute in record) {
606+
if (attribute === 'id') {
607+
continue; // The current record's id is always optimistic
608+
}
609+
if (optimisticIds.indexOf(record[attribute]) !== -1) {
610+
return true;
611+
}
612+
}
613+
614+
return false;
615+
}
616+
617+
async function waitPendingChanges(table, record) {
618+
if (doesRecordReferencePendingOptimisticRecord(record)) {
619+
return waitForMessageMatching(message => message.tag === 'DidCreateRecord' && message.record && message.record.id === record.id);
620+
}
621+
}
622+
623+
function waitForMessageMatching(condition) {
624+
const dataSyncController = DataSyncController.getInstance();
625+
626+
return new Promise((resolve, reject) => {
627+
const callback = () => {
628+
if (condition()) {
629+
dataSyncController.removeEventListener('message', callback);
630+
resolve();
631+
}
632+
}
633+
634+
dataSyncController.addEventListener('message', callback);
635+
});
636+
}
637+
511638
export { DataSyncController, DataSubscription, initIHPBackend };

0 commit comments

Comments
 (0)