@procedures/store 0.6.0 → 0.8.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/CHANGELOG.md +40 -0
- package/README.md +10 -1
- package/dist/.tsbuildinfo +1 -1
- package/dist/storage/api.d.ts +10 -0
- package/dist/storage/api.d.ts.map +1 -1
- package/dist/storage/api.js +30 -0
- package/dist/storage/api.js.map +1 -1
- package/dist/storage/file-queue.d.ts.map +1 -1
- package/dist/storage/file-queue.js +2 -1
- package/dist/storage/file-queue.js.map +1 -1
- package/dist/storage/hybrid.d.ts +50 -2
- package/dist/storage/hybrid.d.ts.map +1 -1
- package/dist/storage/hybrid.js +146 -26
- package/dist/storage/hybrid.js.map +1 -1
- package/package.json +3 -3
- package/src/storage/api.ts +31 -0
- package/src/storage/file-queue.ts +2 -1
- package/src/storage/hybrid.ts +156 -26
package/src/storage/hybrid.ts
CHANGED
|
@@ -19,6 +19,8 @@
|
|
|
19
19
|
* - Each write takes a copy of the value (`structuredClone`), for the local copy, the queue and the
|
|
20
20
|
* remote. The reads give the copies of the local copy, or of the remote in `remote-only` mode.
|
|
21
21
|
* - A full sync reads the local ids with `getIds()`, so it does not copy each local item.
|
|
22
|
+
* - A pull reads a local item only when the result depends on it. With `versionOf`, the storage
|
|
23
|
+
* knows the version of each local item that it wrote or read.
|
|
22
24
|
* - After `close()`, each operation rejects, and a second `close()` does nothing.
|
|
23
25
|
*/
|
|
24
26
|
|
|
@@ -103,6 +105,9 @@ export interface HybridStorageOptions {
|
|
|
103
105
|
* The version of an item, for example its ETag or a revision field (roadmap R28). The sync
|
|
104
106
|
* compares the versions, not the JSON of the values. A remote change of an id with a queued
|
|
105
107
|
* local write is a conflict.
|
|
108
|
+
*
|
|
109
|
+
* The storage keeps the version of each local item that it wrote or read. A pull then reads a
|
|
110
|
+
* local item only for a conflict, or when it does not know the version of the item.
|
|
106
111
|
*/
|
|
107
112
|
versionOf?: (item: any) => string | undefined;
|
|
108
113
|
|
|
@@ -163,7 +168,8 @@ export interface HybridStorageOptions {
|
|
|
163
168
|
* The storage of the local copy (roadmap R45). A durable storage, for example `SqliteStorage`
|
|
164
169
|
* of `@procedures/sqlite`, keeps the local copy after a restart. Then a new storage in
|
|
165
170
|
* `local-only` mode reads the items of the last run. Give it with a durable `queueStore`.
|
|
166
|
-
* `close()` closes it.
|
|
171
|
+
* `close()` closes it. Only the storage writes to the local copy: with `versionOf`, a pull does
|
|
172
|
+
* not see a write from outside.
|
|
167
173
|
* @default a new `InMemoryStorage`
|
|
168
174
|
*/
|
|
169
175
|
local?: LocalCopyStorage<any>;
|
|
@@ -356,6 +362,16 @@ export class HybridStorage<T> implements CollectionStorage<T> {
|
|
|
356
362
|
private changesUnsupported = false;
|
|
357
363
|
/** With `versionOf`: the last known remote version of each id. `null`: the remote has no item. */
|
|
358
364
|
private readonly bases = new Map<string, string | null>();
|
|
365
|
+
/**
|
|
366
|
+
* With `versionOf`: the version of each local item that the storage wrote or read. A pull does
|
|
367
|
+
* not read an item whose version it knows. The version of an id that is not here is unknown.
|
|
368
|
+
*/
|
|
369
|
+
private readonly localVersions = new Map<string, string>();
|
|
370
|
+
/** The number of the last local write of each id that runs: only that write records a version */
|
|
371
|
+
private readonly localWrites = new Map<string, number>();
|
|
372
|
+
private localWriteCount = 0;
|
|
373
|
+
/** The number of clears of the local copy: a clear cancels the records of the writes that run */
|
|
374
|
+
private localClears = 0;
|
|
359
375
|
private stats = {
|
|
360
376
|
cacheHits: 0,
|
|
361
377
|
cacheMisses: 0,
|
|
@@ -455,7 +471,7 @@ export class HybridStorage<T> implements CollectionStorage<T> {
|
|
|
455
471
|
private async dropLocal(): Promise<void> {
|
|
456
472
|
this.clearVersion++;
|
|
457
473
|
this.cursor = undefined;
|
|
458
|
-
await this.
|
|
474
|
+
await this.localClear();
|
|
459
475
|
}
|
|
460
476
|
|
|
461
477
|
/** This method makes the local copy from the queue only, before a full sync. */
|
|
@@ -531,7 +547,7 @@ export class HybridStorage<T> implements CollectionStorage<T> {
|
|
|
531
547
|
const remote = await this.remote.get(id);
|
|
532
548
|
this.isOnline = true;
|
|
533
549
|
if (this.writeKey(id) === version && !this.hasPendingOp(id)) {
|
|
534
|
-
if (remote !== undefined) await this.
|
|
550
|
+
if (remote !== undefined) await this.localSet(id, remote);
|
|
535
551
|
this.noteBase(id, remote);
|
|
536
552
|
}
|
|
537
553
|
return remote;
|
|
@@ -605,21 +621,21 @@ export class HybridStorage<T> implements CollectionStorage<T> {
|
|
|
605
621
|
const value = structuredClone(written);
|
|
606
622
|
await this.write("set", { id, value }, [id], async () => {
|
|
607
623
|
this.bump(id);
|
|
608
|
-
await this.
|
|
624
|
+
await this.localSet(id, value);
|
|
609
625
|
}, () => this.remote.set(id, value));
|
|
610
626
|
}
|
|
611
627
|
|
|
612
628
|
async delete(id: string): Promise<boolean> {
|
|
613
629
|
return this.write("delete", { id }, [id], async () => {
|
|
614
630
|
this.bump(id);
|
|
615
|
-
return this.
|
|
631
|
+
return this.localDelete(id);
|
|
616
632
|
}, () => this.remote.delete(id));
|
|
617
633
|
}
|
|
618
634
|
|
|
619
635
|
async clear(): Promise<void> {
|
|
620
636
|
await this.write("clear", {}, "*", async () => {
|
|
621
637
|
this.clearVersion++;
|
|
622
|
-
await this.
|
|
638
|
+
await this.localClear();
|
|
623
639
|
}, () => this.remote.clear());
|
|
624
640
|
}
|
|
625
641
|
|
|
@@ -632,14 +648,14 @@ export class HybridStorage<T> implements CollectionStorage<T> {
|
|
|
632
648
|
const items = written.map(([id, value]): [string, T] => [id, structuredClone(value)]);
|
|
633
649
|
await this.write("setBatch", { items }, items.map(([id]) => id), async () => {
|
|
634
650
|
for (const [id] of items) this.bump(id);
|
|
635
|
-
await this.
|
|
651
|
+
await this.localSetBatch(items);
|
|
636
652
|
}, () => this.remote.setBatch(items));
|
|
637
653
|
}
|
|
638
654
|
|
|
639
655
|
async deleteBatch(ids: string[]): Promise<number> {
|
|
640
656
|
return this.write("deleteBatch", { ids }, ids, async () => {
|
|
641
657
|
for (const id of ids) this.bump(id);
|
|
642
|
-
return this.
|
|
658
|
+
return this.localDeleteBatch(ids);
|
|
643
659
|
}, () => this.remote.deleteBatch(ids));
|
|
644
660
|
}
|
|
645
661
|
|
|
@@ -754,8 +770,8 @@ export class HybridStorage<T> implements CollectionStorage<T> {
|
|
|
754
770
|
for (const id of touched) {
|
|
755
771
|
if ((this.writeVersions.get(id) ?? 0) !== versions.get(id)) continue;
|
|
756
772
|
const value = before.get(id);
|
|
757
|
-
if (value === undefined) await this.
|
|
758
|
-
else await this.
|
|
773
|
+
if (value === undefined) await this.localDelete(id);
|
|
774
|
+
else await this.localSet(id, value);
|
|
759
775
|
}
|
|
760
776
|
};
|
|
761
777
|
}
|
|
@@ -1006,23 +1022,114 @@ export class HybridStorage<T> implements CollectionStorage<T> {
|
|
|
1006
1022
|
const data = op.data as { id: string; value: T; ids: string[]; items: Array<[string, T]> };
|
|
1007
1023
|
switch (op.type) {
|
|
1008
1024
|
case "set":
|
|
1009
|
-
await this.
|
|
1025
|
+
await this.localSet(data.id, data.value);
|
|
1010
1026
|
break;
|
|
1011
1027
|
case "delete":
|
|
1012
|
-
await this.
|
|
1028
|
+
await this.localDelete(data.id);
|
|
1013
1029
|
break;
|
|
1014
1030
|
case "setBatch":
|
|
1015
|
-
await this.
|
|
1031
|
+
await this.localSetBatch(data.items);
|
|
1016
1032
|
break;
|
|
1017
1033
|
case "deleteBatch":
|
|
1018
|
-
await this.
|
|
1034
|
+
await this.localDeleteBatch(data.ids);
|
|
1019
1035
|
break;
|
|
1020
1036
|
case "clear":
|
|
1021
|
-
await this.
|
|
1037
|
+
await this.localClear();
|
|
1022
1038
|
break;
|
|
1023
1039
|
}
|
|
1024
1040
|
}
|
|
1025
1041
|
|
|
1042
|
+
//
|
|
1043
|
+
// ═══ The writes of the local copy ═══
|
|
1044
|
+
//
|
|
1045
|
+
|
|
1046
|
+
/**
|
|
1047
|
+
* This method writes to the local copy. With `versionOf`, it records the version of each item
|
|
1048
|
+
* that the write gives, so a pull does not read the item to know its version.
|
|
1049
|
+
*
|
|
1050
|
+
* - During the write, the versions of its ids are unknown. A failed write keeps them unknown.
|
|
1051
|
+
* - Only the last write of an id that started records its version, and a clear that started
|
|
1052
|
+
* after the write cancels the record.
|
|
1053
|
+
*/
|
|
1054
|
+
private writeLocal<R>(versionsOf: () => Array<[string, string | undefined]>, write: () => Promise<R>): Promise<R> {
|
|
1055
|
+
return this.options.versionOf ? this.recordedWrite(versionsOf(), write) : write();
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
/** The write of `writeLocal` with `versionOf`. */
|
|
1059
|
+
private async recordedWrite<R>(versions: Array<[string, string | undefined]>, write: () => Promise<R>): Promise<R> {
|
|
1060
|
+
const clears = this.localClears;
|
|
1061
|
+
const numbers = versions.map(([id]) => {
|
|
1062
|
+
this.localVersions.delete(id);
|
|
1063
|
+
const number = ++this.localWriteCount;
|
|
1064
|
+
this.localWrites.set(id, number);
|
|
1065
|
+
return number;
|
|
1066
|
+
});
|
|
1067
|
+
let written = false;
|
|
1068
|
+
try {
|
|
1069
|
+
const result = await write();
|
|
1070
|
+
written = true;
|
|
1071
|
+
return result;
|
|
1072
|
+
} finally {
|
|
1073
|
+
versions.forEach(([id, version], index) => {
|
|
1074
|
+
if (this.localWrites.get(id) !== numbers[index]) return;
|
|
1075
|
+
this.localWrites.delete(id);
|
|
1076
|
+
if (written && version !== undefined && clears === this.localClears) this.localVersions.set(id, version);
|
|
1077
|
+
});
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
/**
|
|
1082
|
+
* This method reads a local item for a pull. With `versionOf`, it records the version of the item
|
|
1083
|
+
* when no local write started or ran during the read.
|
|
1084
|
+
*/
|
|
1085
|
+
private async readLocal(id: string): Promise<T | undefined> {
|
|
1086
|
+
const writes = this.localWriteCount;
|
|
1087
|
+
const clears = this.localClears;
|
|
1088
|
+
const item = await this.local.get(id);
|
|
1089
|
+
if (
|
|
1090
|
+
item !== undefined &&
|
|
1091
|
+
this.options.versionOf &&
|
|
1092
|
+
writes === this.localWriteCount &&
|
|
1093
|
+
clears === this.localClears &&
|
|
1094
|
+
!this.localWrites.has(id)
|
|
1095
|
+
) {
|
|
1096
|
+
const version = this.recordedVersion(item);
|
|
1097
|
+
if (version !== undefined) this.localVersions.set(id, version);
|
|
1098
|
+
}
|
|
1099
|
+
return item;
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
/** The version of an item for the record of the local copy, or undefined when `versionOf` fails. */
|
|
1103
|
+
private recordedVersion(item: T): string | undefined {
|
|
1104
|
+
try {
|
|
1105
|
+
return this.versionOfItem(item);
|
|
1106
|
+
} catch {
|
|
1107
|
+
return undefined;
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
private localSet(id: string, value: T): Promise<void> {
|
|
1112
|
+
return this.writeLocal(() => [[id, this.recordedVersion(value)]], () => this.local.set(id, value));
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
private localDelete(id: string): Promise<boolean> {
|
|
1116
|
+
return this.writeLocal(() => [[id, undefined]], () => this.local.delete(id));
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
private localSetBatch(items: Array<[string, T]>): Promise<void> {
|
|
1120
|
+
return this.writeLocal(() => items.map(([id, value]) => [id, this.recordedVersion(value)]), () => this.local.setBatch(items));
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
private localDeleteBatch(ids: string[]): Promise<number> {
|
|
1124
|
+
return this.writeLocal(() => ids.map((id): [string, undefined] => [id, undefined]), () => this.local.deleteBatch(ids));
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
private localClear(): Promise<void> {
|
|
1128
|
+
this.localClears++;
|
|
1129
|
+
this.localVersions.clear();
|
|
1130
|
+
return this.local.clear();
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1026
1133
|
/**
|
|
1027
1134
|
* One pull. `full`: ask for all items (after the local copy was dropped).
|
|
1028
1135
|
*
|
|
@@ -1088,17 +1195,25 @@ export class HybridStorage<T> implements CollectionStorage<T> {
|
|
|
1088
1195
|
return { full: true, changed: await this.remoteEntries(), deleted: [] };
|
|
1089
1196
|
}
|
|
1090
1197
|
|
|
1091
|
-
/**
|
|
1198
|
+
/**
|
|
1199
|
+
* This method applies one remote item. It gives true when the queue changed.
|
|
1200
|
+
*
|
|
1201
|
+
* The pull reads the local item only when the result depends on it (roadmap R28):
|
|
1202
|
+
* - An id with a queued write: only when the remote version changed after the last known remote
|
|
1203
|
+
* version. Without versions, the queued write wins, and the pull reads nothing.
|
|
1204
|
+
* - An id without a queued write: not when the storage knows the version of the local item.
|
|
1205
|
+
* Without versions, the pull compares the JSON of the two items, so it reads the local item.
|
|
1206
|
+
* Before, the pull read each local item.
|
|
1207
|
+
*/
|
|
1092
1208
|
private async pullItem(
|
|
1093
1209
|
id: string,
|
|
1094
1210
|
item: T,
|
|
1095
1211
|
changedLocally: (id: string) => boolean,
|
|
1096
1212
|
conflicts: string[],
|
|
1097
1213
|
): Promise<boolean> {
|
|
1098
|
-
if (changedLocally(id)) return false;
|
|
1099
|
-
const localItem = await this.local.get(id);
|
|
1100
1214
|
if (changedLocally(id)) return false;
|
|
1101
1215
|
const remoteVersion = this.versionOfItem(item);
|
|
1216
|
+
const knownVersion = remoteVersion === undefined ? undefined : this.localVersions.get(id);
|
|
1102
1217
|
|
|
1103
1218
|
if (this.hasPendingOp(id)) {
|
|
1104
1219
|
// Without versions, the queued write wins, as before
|
|
@@ -1107,6 +1222,12 @@ export class HybridStorage<T> implements CollectionStorage<T> {
|
|
|
1107
1222
|
// of the id: the queued write wins
|
|
1108
1223
|
const base = this.bases.get(id);
|
|
1109
1224
|
if (base === undefined || base === remoteVersion) return false;
|
|
1225
|
+
if (knownVersion === remoteVersion) {
|
|
1226
|
+
this.bases.set(id, remoteVersion);
|
|
1227
|
+
return false;
|
|
1228
|
+
}
|
|
1229
|
+
const localItem = await this.readLocal(id);
|
|
1230
|
+
if (changedLocally(id)) return false;
|
|
1110
1231
|
if (localItem !== undefined && this.versionOfItem(localItem) === remoteVersion) {
|
|
1111
1232
|
this.bases.set(id, remoteVersion);
|
|
1112
1233
|
return false;
|
|
@@ -1114,12 +1235,21 @@ export class HybridStorage<T> implements CollectionStorage<T> {
|
|
|
1114
1235
|
return this.resolvePending(id, localItem, item, remoteVersion, conflicts);
|
|
1115
1236
|
}
|
|
1116
1237
|
|
|
1238
|
+
if (knownVersion !== undefined) {
|
|
1239
|
+
// Only the remote changed: it is not a conflict. The version of the local item is known.
|
|
1240
|
+
if (knownVersion !== remoteVersion) await this.localSet(id, item);
|
|
1241
|
+
this.bases.set(id, remoteVersion!);
|
|
1242
|
+
return false;
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
const localItem = await this.readLocal(id);
|
|
1246
|
+
if (changedLocally(id)) return false;
|
|
1117
1247
|
const localVersion = localItem === undefined ? undefined : this.versionOfItem(localItem);
|
|
1118
1248
|
if (localItem === undefined) {
|
|
1119
|
-
await this.
|
|
1249
|
+
await this.localSet(id, item);
|
|
1120
1250
|
} else if (remoteVersion !== undefined && localVersion !== undefined) {
|
|
1121
1251
|
// Only the remote changed: it is not a conflict
|
|
1122
|
-
if (localVersion !== remoteVersion) await this.
|
|
1252
|
+
if (localVersion !== remoteVersion) await this.localSet(id, item);
|
|
1123
1253
|
} else if (JSON.stringify(localItem) !== JSON.stringify(item)) {
|
|
1124
1254
|
// Conflict - resolve according to strategy
|
|
1125
1255
|
if (this.options.conflictResolution === "error") {
|
|
@@ -1128,7 +1258,7 @@ export class HybridStorage<T> implements CollectionStorage<T> {
|
|
|
1128
1258
|
return false;
|
|
1129
1259
|
}
|
|
1130
1260
|
const resolved = this.resolveConflict(localItem, item);
|
|
1131
|
-
await this.
|
|
1261
|
+
await this.localSet(id, resolved);
|
|
1132
1262
|
this.stats.conflicts++;
|
|
1133
1263
|
}
|
|
1134
1264
|
if (remoteVersion !== undefined) this.bases.set(id, remoteVersion);
|
|
@@ -1139,7 +1269,7 @@ export class HybridStorage<T> implements CollectionStorage<T> {
|
|
|
1139
1269
|
private async pullDelete(id: string, changedLocally: (id: string) => boolean, conflicts: string[]): Promise<boolean> {
|
|
1140
1270
|
if (changedLocally(id)) return false;
|
|
1141
1271
|
if (!this.hasPendingOp(id)) {
|
|
1142
|
-
await this.
|
|
1272
|
+
await this.localDelete(id);
|
|
1143
1273
|
if (this.options.versionOf) this.bases.set(id, null);
|
|
1144
1274
|
return false;
|
|
1145
1275
|
}
|
|
@@ -1147,7 +1277,7 @@ export class HybridStorage<T> implements CollectionStorage<T> {
|
|
|
1147
1277
|
// item only if the last sync saw a version of it.
|
|
1148
1278
|
const base = this.bases.get(id);
|
|
1149
1279
|
if (!this.options.versionOf || base === undefined || base === null) return false;
|
|
1150
|
-
const localItem = await this.
|
|
1280
|
+
const localItem = await this.readLocal(id);
|
|
1151
1281
|
if (changedLocally(id)) return false;
|
|
1152
1282
|
if (localItem === undefined) {
|
|
1153
1283
|
// Both sides deleted the item
|
|
@@ -1158,7 +1288,7 @@ export class HybridStorage<T> implements CollectionStorage<T> {
|
|
|
1158
1288
|
switch (this.options.conflictResolution) {
|
|
1159
1289
|
case "remote":
|
|
1160
1290
|
if (!this.dropQueued(id)) break;
|
|
1161
|
-
await this.
|
|
1291
|
+
await this.localDelete(id);
|
|
1162
1292
|
this.bases.set(id, null);
|
|
1163
1293
|
return true;
|
|
1164
1294
|
case "error":
|
|
@@ -1188,14 +1318,14 @@ export class HybridStorage<T> implements CollectionStorage<T> {
|
|
|
1188
1318
|
case "remote":
|
|
1189
1319
|
// The remote value replaces the queued write. A queued clear cannot lose one id.
|
|
1190
1320
|
if (!this.dropQueued(id)) break;
|
|
1191
|
-
await this.
|
|
1321
|
+
await this.localSet(id, remoteItem);
|
|
1192
1322
|
this.bases.set(id, remoteVersion);
|
|
1193
1323
|
return true;
|
|
1194
1324
|
case "merge": {
|
|
1195
1325
|
if (localItem === undefined) break;
|
|
1196
1326
|
const merged = this.resolveConflict(localItem, remoteItem);
|
|
1197
1327
|
if (!this.dropQueued(id)) break;
|
|
1198
|
-
await this.
|
|
1328
|
+
await this.localSet(id, merged);
|
|
1199
1329
|
this.bases.set(id, remoteVersion);
|
|
1200
1330
|
this.offlineOps.push({ type: "set", data: { id, value: merged }, timestamp: Date.now(), base: [[id, remoteVersion]] });
|
|
1201
1331
|
return true;
|