@procedures/store 0.7.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.
@@ -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.local.clear();
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.local.set(id, remote);
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.local.set(id, value);
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.local.delete(id);
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.local.clear();
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.local.setBatch(items);
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.local.deleteBatch(ids);
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.local.delete(id);
758
- else await this.local.set(id, value);
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.local.set(data.id, data.value);
1025
+ await this.localSet(data.id, data.value);
1010
1026
  break;
1011
1027
  case "delete":
1012
- await this.local.delete(data.id);
1028
+ await this.localDelete(data.id);
1013
1029
  break;
1014
1030
  case "setBatch":
1015
- await this.local.setBatch(data.items);
1031
+ await this.localSetBatch(data.items);
1016
1032
  break;
1017
1033
  case "deleteBatch":
1018
- await this.local.deleteBatch(data.ids);
1034
+ await this.localDeleteBatch(data.ids);
1019
1035
  break;
1020
1036
  case "clear":
1021
- await this.local.clear();
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
- /** This method applies one remote item. It gives true when the queue changed. */
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.local.set(id, item);
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.local.set(id, item);
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.local.set(id, resolved);
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.local.delete(id);
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.local.get(id);
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.local.delete(id);
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.local.set(id, remoteItem);
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.local.set(id, merged);
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;