@evolu/common 8.9.0 → 8.11.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.
Files changed (337) hide show
  1. package/dist/src/Bytes.d.ts +647 -0
  2. package/dist/src/Bytes.d.ts.map +1 -0
  3. package/dist/src/{Binary.js → Bytes.js} +266 -16
  4. package/dist/src/Config.d.ts +142 -0
  5. package/dist/src/Config.d.ts.map +1 -0
  6. package/dist/src/Config.js +181 -0
  7. package/dist/src/Console.d.ts +62 -7
  8. package/dist/src/Console.d.ts.map +1 -1
  9. package/dist/src/Console.js +20 -4
  10. package/dist/src/Crypto.d.ts +76 -4
  11. package/dist/src/Crypto.d.ts.map +1 -1
  12. package/dist/src/Crypto.js +55 -4
  13. package/dist/src/Error.d.ts +45 -0
  14. package/dist/src/Error.d.ts.map +1 -1
  15. package/dist/src/Error.js +69 -0
  16. package/dist/src/Fs.d.ts +376 -0
  17. package/dist/src/Fs.d.ts.map +1 -0
  18. package/dist/src/Fs.js +113 -0
  19. package/dist/src/Identicon.d.ts +2 -2
  20. package/dist/src/Identicon.js +2 -2
  21. package/dist/src/LeakDetector.d.ts +22 -3
  22. package/dist/src/LeakDetector.d.ts.map +1 -1
  23. package/dist/src/LeakDetector.js +12 -2
  24. package/dist/src/LockManager.d.ts +8 -0
  25. package/dist/src/LockManager.d.ts.map +1 -1
  26. package/dist/src/LockManager.js +6 -0
  27. package/dist/src/Number.d.ts +50 -7
  28. package/dist/src/Number.d.ts.map +1 -1
  29. package/dist/src/Number.js +47 -8
  30. package/dist/src/Object.d.ts +32 -0
  31. package/dist/src/Object.d.ts.map +1 -1
  32. package/dist/src/Object.js +46 -0
  33. package/dist/src/Platform.d.ts +47 -7
  34. package/dist/src/Platform.d.ts.map +1 -1
  35. package/dist/src/Platform.js +24 -5
  36. package/dist/src/Random.d.ts +25 -2
  37. package/dist/src/Random.d.ts.map +1 -1
  38. package/dist/src/Random.js +14 -2
  39. package/dist/src/Resource.d.ts +156 -1
  40. package/dist/src/Resource.d.ts.map +1 -1
  41. package/dist/src/Resource.js +201 -72
  42. package/dist/src/Schedule.d.ts +11 -10
  43. package/dist/src/Schedule.d.ts.map +1 -1
  44. package/dist/src/Schedule.js +1 -1
  45. package/dist/src/Sqlite.d.ts +132 -16
  46. package/dist/src/Sqlite.d.ts.map +1 -1
  47. package/dist/src/Sqlite.js +64 -10
  48. package/dist/src/Task.d.ts +15 -4
  49. package/dist/src/Task.d.ts.map +1 -1
  50. package/dist/src/Task.js +41 -15
  51. package/dist/src/Test.d.ts +9 -0
  52. package/dist/src/Test.d.ts.map +1 -1
  53. package/dist/src/Test.js +4 -0
  54. package/dist/src/Time.d.ts +179 -20
  55. package/dist/src/Time.d.ts.map +1 -1
  56. package/dist/src/Time.js +95 -6
  57. package/dist/src/Type.d.ts +3056 -1539
  58. package/dist/src/Type.d.ts.map +1 -1
  59. package/dist/src/Type.js +2548 -584
  60. package/dist/src/WebSocket.d.ts +164 -13
  61. package/dist/src/WebSocket.d.ts.map +1 -1
  62. package/dist/src/WebSocket.js +133 -24
  63. package/dist/src/Worker.d.ts +90 -8
  64. package/dist/src/Worker.d.ts.map +1 -1
  65. package/dist/src/Worker.js +28 -2
  66. package/dist/src/index.d.ts +9 -8
  67. package/dist/src/index.d.ts.map +1 -1
  68. package/dist/src/index.js +5 -4
  69. package/dist/src/intl/_en.d.ts +24 -1
  70. package/dist/src/intl/_en.d.ts.map +1 -1
  71. package/dist/src/intl/_en.js +20 -0
  72. package/dist/src/intl/ar.d.ts +24 -1
  73. package/dist/src/intl/ar.d.ts.map +1 -1
  74. package/dist/src/intl/ar.js +20 -0
  75. package/dist/src/intl/bn.d.ts +24 -1
  76. package/dist/src/intl/bn.d.ts.map +1 -1
  77. package/dist/src/intl/bn.js +20 -0
  78. package/dist/src/intl/ca.d.ts +24 -1
  79. package/dist/src/intl/ca.d.ts.map +1 -1
  80. package/dist/src/intl/ca.js +20 -0
  81. package/dist/src/intl/cs.d.ts +24 -1
  82. package/dist/src/intl/cs.d.ts.map +1 -1
  83. package/dist/src/intl/cs.js +20 -0
  84. package/dist/src/intl/da.d.ts +24 -1
  85. package/dist/src/intl/da.d.ts.map +1 -1
  86. package/dist/src/intl/da.js +20 -0
  87. package/dist/src/intl/de.d.ts +24 -1
  88. package/dist/src/intl/de.d.ts.map +1 -1
  89. package/dist/src/intl/de.js +20 -0
  90. package/dist/src/intl/el.d.ts +24 -1
  91. package/dist/src/intl/el.d.ts.map +1 -1
  92. package/dist/src/intl/el.js +20 -0
  93. package/dist/src/intl/es.d.ts +24 -1
  94. package/dist/src/intl/es.d.ts.map +1 -1
  95. package/dist/src/intl/es.js +20 -0
  96. package/dist/src/intl/fa.d.ts +24 -1
  97. package/dist/src/intl/fa.d.ts.map +1 -1
  98. package/dist/src/intl/fa.js +20 -0
  99. package/dist/src/intl/fi.d.ts +24 -1
  100. package/dist/src/intl/fi.d.ts.map +1 -1
  101. package/dist/src/intl/fi.js +20 -0
  102. package/dist/src/intl/fil.d.ts +24 -1
  103. package/dist/src/intl/fil.d.ts.map +1 -1
  104. package/dist/src/intl/fil.js +20 -0
  105. package/dist/src/intl/fr.d.ts +24 -1
  106. package/dist/src/intl/fr.d.ts.map +1 -1
  107. package/dist/src/intl/fr.js +20 -0
  108. package/dist/src/intl/he.d.ts +24 -1
  109. package/dist/src/intl/he.d.ts.map +1 -1
  110. package/dist/src/intl/he.js +20 -0
  111. package/dist/src/intl/hi.d.ts +24 -1
  112. package/dist/src/intl/hi.d.ts.map +1 -1
  113. package/dist/src/intl/hi.js +20 -0
  114. package/dist/src/intl/hr.d.ts +24 -1
  115. package/dist/src/intl/hr.d.ts.map +1 -1
  116. package/dist/src/intl/hr.js +20 -0
  117. package/dist/src/intl/hu.d.ts +22 -1
  118. package/dist/src/intl/hu.d.ts.map +1 -1
  119. package/dist/src/intl/hu.js +18 -0
  120. package/dist/src/intl/id.d.ts +24 -1
  121. package/dist/src/intl/id.d.ts.map +1 -1
  122. package/dist/src/intl/id.js +20 -0
  123. package/dist/src/intl/it.d.ts +24 -1
  124. package/dist/src/intl/it.d.ts.map +1 -1
  125. package/dist/src/intl/it.js +20 -0
  126. package/dist/src/intl/ja.d.ts +24 -1
  127. package/dist/src/intl/ja.d.ts.map +1 -1
  128. package/dist/src/intl/ja.js +20 -0
  129. package/dist/src/intl/ko.d.ts +24 -1
  130. package/dist/src/intl/ko.d.ts.map +1 -1
  131. package/dist/src/intl/ko.js +20 -0
  132. package/dist/src/intl/ml.d.ts +24 -1
  133. package/dist/src/intl/ml.d.ts.map +1 -1
  134. package/dist/src/intl/ml.js +20 -0
  135. package/dist/src/intl/mr.d.ts +24 -1
  136. package/dist/src/intl/mr.d.ts.map +1 -1
  137. package/dist/src/intl/mr.js +20 -0
  138. package/dist/src/intl/ms.d.ts +24 -1
  139. package/dist/src/intl/ms.d.ts.map +1 -1
  140. package/dist/src/intl/ms.js +20 -0
  141. package/dist/src/intl/nb.d.ts +22 -1
  142. package/dist/src/intl/nb.d.ts.map +1 -1
  143. package/dist/src/intl/nb.js +18 -0
  144. package/dist/src/intl/nl.d.ts +24 -1
  145. package/dist/src/intl/nl.d.ts.map +1 -1
  146. package/dist/src/intl/nl.js +20 -0
  147. package/dist/src/intl/pa.d.ts +24 -1
  148. package/dist/src/intl/pa.d.ts.map +1 -1
  149. package/dist/src/intl/pa.js +20 -0
  150. package/dist/src/intl/pl.d.ts +23 -0
  151. package/dist/src/intl/pl.d.ts.map +1 -1
  152. package/dist/src/intl/pl.js +20 -0
  153. package/dist/src/intl/pt-BR.d.ts +24 -1
  154. package/dist/src/intl/pt-BR.d.ts.map +1 -1
  155. package/dist/src/intl/pt-BR.js +20 -0
  156. package/dist/src/intl/pt.d.ts +24 -1
  157. package/dist/src/intl/pt.d.ts.map +1 -1
  158. package/dist/src/intl/pt.js +20 -0
  159. package/dist/src/intl/ro.d.ts +24 -1
  160. package/dist/src/intl/ro.d.ts.map +1 -1
  161. package/dist/src/intl/ro.js +20 -0
  162. package/dist/src/intl/sk.d.ts +24 -1
  163. package/dist/src/intl/sk.d.ts.map +1 -1
  164. package/dist/src/intl/sk.js +20 -0
  165. package/dist/src/intl/sl.d.ts +24 -1
  166. package/dist/src/intl/sl.d.ts.map +1 -1
  167. package/dist/src/intl/sl.js +20 -0
  168. package/dist/src/intl/sv.d.ts +24 -1
  169. package/dist/src/intl/sv.d.ts.map +1 -1
  170. package/dist/src/intl/sv.js +20 -0
  171. package/dist/src/intl/sw.d.ts +21 -0
  172. package/dist/src/intl/sw.d.ts.map +1 -1
  173. package/dist/src/intl/sw.js +18 -0
  174. package/dist/src/intl/ta.d.ts +24 -1
  175. package/dist/src/intl/ta.d.ts.map +1 -1
  176. package/dist/src/intl/ta.js +20 -0
  177. package/dist/src/intl/te.d.ts +24 -1
  178. package/dist/src/intl/te.d.ts.map +1 -1
  179. package/dist/src/intl/te.js +20 -0
  180. package/dist/src/intl/th.d.ts +24 -1
  181. package/dist/src/intl/th.d.ts.map +1 -1
  182. package/dist/src/intl/th.js +20 -0
  183. package/dist/src/intl/tr.d.ts +24 -1
  184. package/dist/src/intl/tr.d.ts.map +1 -1
  185. package/dist/src/intl/tr.js +20 -0
  186. package/dist/src/intl/uk.d.ts +80 -57
  187. package/dist/src/intl/uk.d.ts.map +1 -1
  188. package/dist/src/intl/uk.js +174 -149
  189. package/dist/src/intl/ur.d.ts +24 -1
  190. package/dist/src/intl/ur.d.ts.map +1 -1
  191. package/dist/src/intl/ur.js +20 -0
  192. package/dist/src/intl/vi.d.ts +24 -1
  193. package/dist/src/intl/vi.d.ts.map +1 -1
  194. package/dist/src/intl/vi.js +20 -0
  195. package/dist/src/intl/zh-CN.d.ts +24 -1
  196. package/dist/src/intl/zh-CN.d.ts.map +1 -1
  197. package/dist/src/intl/zh-CN.js +20 -0
  198. package/dist/src/intl/zh-TW.d.ts +24 -1
  199. package/dist/src/intl/zh-TW.d.ts.map +1 -1
  200. package/dist/src/intl/zh-TW.js +20 -0
  201. package/dist/src/local-first/Db.d.ts +52 -3
  202. package/dist/src/local-first/Db.d.ts.map +1 -1
  203. package/dist/src/local-first/Db.js +412 -137
  204. package/dist/src/local-first/Evolu.d.ts +336 -211
  205. package/dist/src/local-first/Evolu.d.ts.map +1 -1
  206. package/dist/src/local-first/Evolu.js +102 -15
  207. package/dist/src/local-first/Owner.d.ts +13 -30
  208. package/dist/src/local-first/Owner.d.ts.map +1 -1
  209. package/dist/src/local-first/Owner.js +13 -30
  210. package/dist/src/local-first/Protocol.d.ts +95 -17
  211. package/dist/src/local-first/Protocol.d.ts.map +1 -1
  212. package/dist/src/local-first/Protocol.js +119 -39
  213. package/dist/src/local-first/Query.d.ts +8 -15
  214. package/dist/src/local-first/Query.d.ts.map +1 -1
  215. package/dist/src/local-first/Schema.d.ts +345 -21
  216. package/dist/src/local-first/Schema.d.ts.map +1 -1
  217. package/dist/src/local-first/Schema.js +214 -17
  218. package/dist/src/local-first/Shared.d.ts +537 -22
  219. package/dist/src/local-first/Shared.d.ts.map +1 -1
  220. package/dist/src/local-first/Shared.js +1437 -234
  221. package/dist/src/local-first/Storage.d.ts +192 -14
  222. package/dist/src/local-first/Storage.d.ts.map +1 -1
  223. package/dist/src/local-first/Storage.js +82 -21
  224. package/dist/src/local-first/Timestamp.d.ts +392 -41
  225. package/dist/src/local-first/Timestamp.d.ts.map +1 -1
  226. package/dist/src/local-first/Timestamp.js +404 -82
  227. package/dist/src/local-first/index.d.ts +0 -1
  228. package/dist/src/local-first/index.d.ts.map +1 -1
  229. package/dist/src/local-first/index.js +0 -1
  230. package/package.json +1 -1
  231. package/src/Assert.test.ts +2 -5
  232. package/src/{Binary.test.ts → Bytes.test.ts} +286 -1
  233. package/src/{Binary.ts → Bytes.ts} +652 -21
  234. package/src/Config.test.ts +668 -0
  235. package/src/Config.ts +410 -0
  236. package/src/Console.ts +62 -7
  237. package/src/Crypto.ts +76 -4
  238. package/src/Eq.test.ts +2 -3
  239. package/src/Error.test.ts +76 -3
  240. package/src/Error.ts +71 -0
  241. package/src/Fs.test.ts +105 -0
  242. package/src/Fs.ts +488 -0
  243. package/src/Identicon.ts +2 -2
  244. package/src/LeakDetector.ts +22 -3
  245. package/src/LockManager.ts +8 -0
  246. package/src/Number.test.ts +82 -18
  247. package/src/Number.ts +76 -8
  248. package/src/Object.test.ts +139 -10
  249. package/src/Object.ts +49 -0
  250. package/src/Platform.ts +50 -8
  251. package/src/Random.ts +25 -2
  252. package/src/Resource.test.ts +837 -0
  253. package/src/Resource.ts +235 -15
  254. package/src/Schedule.test.ts +50 -12
  255. package/src/Schedule.ts +24 -14
  256. package/src/Sqlite.ts +138 -18
  257. package/src/Task.test.ts +189 -8
  258. package/src/Task.ts +56 -17
  259. package/src/Test.ts +9 -0
  260. package/src/Time.test.ts +82 -11
  261. package/src/Time.ts +246 -24
  262. package/src/Type.test.ts +3994 -1119
  263. package/src/Type.ts +7258 -3842
  264. package/src/Types.test.ts +4 -14
  265. package/src/WebSocket.ts +313 -40
  266. package/src/Worker.ts +90 -8
  267. package/src/index.ts +18 -7
  268. package/src/intl/_en.ts +70 -0
  269. package/src/intl/ar.ts +71 -0
  270. package/src/intl/bn.ts +70 -0
  271. package/src/intl/ca.ts +70 -0
  272. package/src/intl/cs.ts +70 -0
  273. package/src/intl/da.ts +70 -0
  274. package/src/intl/de.ts +70 -0
  275. package/src/intl/el.ts +70 -0
  276. package/src/intl/es.ts +70 -0
  277. package/src/intl/fa.ts +70 -0
  278. package/src/intl/fi.ts +70 -0
  279. package/src/intl/fil.ts +70 -0
  280. package/src/intl/fr.ts +70 -0
  281. package/src/intl/he.ts +70 -0
  282. package/src/intl/hi.ts +70 -0
  283. package/src/intl/hr.ts +70 -0
  284. package/src/intl/hu.ts +69 -0
  285. package/src/intl/id.ts +70 -0
  286. package/src/intl/intl.test.ts +819 -1
  287. package/src/intl/it.ts +70 -0
  288. package/src/intl/ja.ts +70 -0
  289. package/src/intl/ko.ts +68 -0
  290. package/src/intl/ml.ts +70 -0
  291. package/src/intl/mr.ts +70 -0
  292. package/src/intl/ms.ts +71 -0
  293. package/src/intl/nb.ts +69 -0
  294. package/src/intl/nl.ts +70 -0
  295. package/src/intl/pa.ts +70 -0
  296. package/src/intl/pl.ts +63 -0
  297. package/src/intl/pt-BR.ts +70 -0
  298. package/src/intl/pt.ts +71 -0
  299. package/src/intl/ro.ts +70 -0
  300. package/src/intl/sk.ts +71 -0
  301. package/src/intl/sl.ts +70 -0
  302. package/src/intl/sv.ts +70 -0
  303. package/src/intl/sw.ts +62 -0
  304. package/src/intl/ta.ts +70 -0
  305. package/src/intl/te.ts +70 -0
  306. package/src/intl/th.ts +68 -0
  307. package/src/intl/tr.ts +70 -0
  308. package/src/intl/uk.ts +228 -155
  309. package/src/intl/ur.ts +70 -0
  310. package/src/intl/vi.ts +70 -0
  311. package/src/intl/zh-CN.ts +68 -0
  312. package/src/intl/zh-TW.ts +68 -0
  313. package/src/local-first/Db.ts +644 -339
  314. package/src/local-first/Evolu.test.ts +686 -21
  315. package/src/local-first/Evolu.ts +450 -228
  316. package/src/local-first/Owner.ts +13 -30
  317. package/src/local-first/Protocol.test.ts +618 -11
  318. package/src/local-first/Protocol.ts +197 -73
  319. package/src/local-first/Query.ts +8 -15
  320. package/src/local-first/Schema.test.ts +143 -0
  321. package/src/local-first/Schema.ts +374 -24
  322. package/src/local-first/Shared.test.ts +7731 -559
  323. package/src/local-first/Shared.ts +2036 -267
  324. package/src/local-first/Storage.ts +219 -33
  325. package/src/local-first/Timestamp.test.ts +344 -70
  326. package/src/local-first/Timestamp.ts +435 -119
  327. package/src/local-first/index.ts +0 -1
  328. package/dist/src/Binary.d.ts +0 -254
  329. package/dist/src/Binary.d.ts.map +0 -1
  330. package/dist/src/local-first/Error.d.ts +0 -12
  331. package/dist/src/local-first/Error.d.ts.map +0 -1
  332. package/dist/src/local-first/Error.js +0 -6
  333. package/dist/src/local-first/LocalAuth.d.ts +0 -150
  334. package/dist/src/local-first/LocalAuth.d.ts.map +0 -1
  335. package/dist/src/local-first/LocalAuth.js +0 -179
  336. package/src/local-first/Error.ts +0 -17
  337. package/src/local-first/LocalAuth.ts +0 -457
@@ -1,6 +1,39 @@
1
1
  /**
2
2
  * Platform-agnostic Evolu DbWorker.
3
3
  *
4
+ * ### Database version
5
+ *
6
+ * Every database records `dbVersion` in `evolu_version`. The version describes
7
+ * Evolu's internal persisted format: the layout of its system tables and the
8
+ * meaning of the data stored in them. It is independent of the application
9
+ * schema, which evolves append-only through {@link ensureSqliteSchema}, and of
10
+ * the network protocol version, which is checked per message. Many Evolu
11
+ * releases can share one database version.
12
+ *
13
+ * Bump `dbVersion` for any change that older code would misread, not only for
14
+ * changed SQL. A new quarantine reason, for example, changes what startup may
15
+ * replay even when its columns are additive. Each bump ships with a migration
16
+ * from the previous version; fresh databases are created at the latest layout
17
+ * directly.
18
+ *
19
+ * Startup holds the database leader lock, then checks the stored version record
20
+ * before it reads the clock, ensures the application schema, or replays
21
+ * quarantine. Databases written before the version record existed hold one
22
+ * `protocolVersion` row instead; that known legacy layout is converted to
23
+ * version 1. A newer stored version refuses startup with
24
+ * {@link UnsupportedDbVersionError}. The refusal returns from the startup
25
+ * transaction before anything is written and is posted to the SharedWorker; the
26
+ * worker then exits and releases its resources. The single version row is
27
+ * created in the same transaction as the other system tables.
28
+ *
29
+ * Code released before the version record existed never reads it. It recognizes
30
+ * an initialized database by the `evolu_version` table alone, so it opens a
31
+ * newer database and replays all quarantine regardless of reason, applying
32
+ * drift quarantine at once without advancing the clock. Renaming the table
33
+ * would make such code fail at startup instead. That was declined: the replay
34
+ * needs drift quarantine and an earlier release on the same device at once, and
35
+ * failing would break a rollback to an earlier release outright.
36
+ *
4
37
  * @module
5
38
  */
6
39
  var __addDisposableResource = (this && this.__addDisposableResource) || function (env, value, async) {
@@ -56,25 +89,28 @@ var __disposeResources = (this && this.__disposeResources) || (function (Suppres
56
89
  return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
57
90
  });
58
91
  import { appendToArray, firstInArray, } from "../Array.js";
59
- import { assertNonEmptyReadonlyArray, assertNonNullable, assertNotUndefined, } from "../Assert.js";
60
- import { EncryptionKey, } from "../Crypto.js";
92
+ import { assert, assertNonEmptyReadonlyArray, assertNonNullable, assertNotUndefined, } from "../Assert.js";
93
+ import { EncryptionKey } from "../Crypto.js";
61
94
  import { constFalse, constVoid } from "../Function.js";
62
95
  import { acquireLeaderLock } from "../LockManager.js";
63
96
  import { createMutableRecord, getOwnProp, objectToEntries } from "../Object.js";
64
- import { ok } from "../Result.js";
97
+ import { err, getOk, ok } from "../Result.js";
65
98
  import { booleanToSqliteBoolean, createSqlite, sql, SqliteBoolean, sqliteBooleanToBoolean, sqliteQueryStringToSqliteQuery, SqliteValue, } from "../Sqlite.js";
66
99
  import { callback } from "../Task.js";
67
- import { Millis, millisToDateIso } from "../Time.js";
68
- import { assertType, Id, IdBytes, idBytesToId, idToIdBytes, onePositiveInt, } from "../Type.js";
100
+ import { millisToDateIso, saturateMillis, } from "../Time.js";
101
+ import { assertType, Id, IdBytes, idBytesToId, idToIdBytes, NonNaNNumber, onePositiveInt, PositiveInt, } from "../Type.js";
69
102
  import { ownerIdBytesToOwnerId, ownerIdToOwnerIdBytes } from "./Owner.js";
70
- import { applyProtocolMessageAsClient, createProtocolMessageForSync, decryptAndDecodeDbChange, encodeAndEncryptDbChange, protocolVersion, SubscriptionFlags, } from "./Protocol.js";
71
- import { ensureSqliteSchema, getEvoluSqliteSchema, systemColumns, } from "./Schema.js";
103
+ import { applyProtocolMessageAsClient, createProtocolMessageForSync, decryptAndDecodeDbChange, encodeAndEncryptDbChange, SubscriptionFlags, } from "./Protocol.js";
104
+ import { ensureSqliteSchema, isLocalOnlyTable, QuarantineOrigin, QuarantineReason, systemColumns, } from "./Schema.js";
72
105
  import { consoleEntryOrErrorBroadcastChannelName } from "./Shared.js";
73
106
  import { createBaseSqliteStorage, createBaseSqliteStorageTables, DbChange, getTimestampInsertStrategy, readOwnerUsageOrDefault, updateOwnerUsage, } from "./Storage.js";
74
- import { createInitialTimestamp, defaultTimestampMaxDrift, receiveTimestamp, sendTimestamp, TimestampBytes, timestampBytesToTimestamp, timestampToTimestampBytes, } from "./Timestamp.js";
107
+ import { createInitialTimestamp, defaultTimestampMaxDrift, isTimestampBeyondMaxDrift, maxCounter, maxNodeId, receiveTimestamp, sendTimestamp, TimestampBytes, timestampBytesToTimestamp, timestampToTimestampBytes, } from "./Timestamp.js";
108
+ /** The database version this code creates and supports; see the module doc. */
109
+ const dbVersion = PositiveInt.orThrow(2);
75
110
  /**
76
- * Starts the platform-agnostic Evolu DbWorker and owns its resources until the
77
- * worker receives a dispose message or its {@link Run} is aborted.
111
+ * Starts the platform-agnostic Evolu DbWorker and owns its resources until
112
+ * startup is refused, the worker receives a dispose message, or its {@link Run}
113
+ * is aborted.
78
114
  */
79
115
  export const startDbWorker = (self) => async (run) => {
80
116
  const env_1 = { stack: [], error: void 0, hasError: false };
@@ -107,46 +143,89 @@ export const startDbWorker = (self) => async (run) => {
107
143
  baseSqliteStorage,
108
144
  timestampConfig: { maxDrift: defaultTimestampMaxDrift },
109
145
  };
110
- const currentSchema = getEvoluSqliteSchema(dbDeps)();
111
- const dbIsInitialized = "evolu_version" in currentSchema.tables;
112
- const clock = createClock(dbDeps)(dbIsInitialized);
113
- sqlite.transaction(() => {
114
- if (!dbIsInitialized)
115
- initializeDb(dbDeps)(clock.get());
116
- ensureSqliteSchema(dbDeps)(initMessage.sqliteSchema, currentSchema);
146
+ const startup = sqlite.transaction(() => {
147
+ // Only the version record is read before the version check, because a
148
+ // newer database can hold schema objects this code cannot read.
149
+ const { rows: versionColumnRows } = sqlite.exec(sql `
150
+ select "name" from pragma_table_info('evolu_version');
151
+ `);
152
+ const versionColumns = new Set(versionColumnRows.map((row) => row.name));
153
+ let initialClock;
154
+ if (versionColumns.size === 0) {
155
+ initialClock = createInitialTimestamp(dbDeps);
156
+ initializeDb(dbDeps)(initialClock);
157
+ }
158
+ else {
159
+ const version = ensureDbVersion(dbDeps)(versionColumns);
160
+ if (!version.ok)
161
+ return version;
162
+ const { rows } = sqlite.exec(sql `
163
+ select "clock" from evolu_config limit 1;
164
+ `);
165
+ assertNonEmptyReadonlyArray(rows);
166
+ initialClock = timestampBytesToTimestamp(firstInArray(rows).clock);
167
+ }
168
+ ensureSqliteSchema(dbDeps)(initMessage.sqliteSchema);
169
+ const released = releaseDriftQuarantine(dbDeps)(initialClock);
170
+ if (released) {
171
+ initialClock = released;
172
+ saveClock(dbDeps)(released);
173
+ }
117
174
  tryApplyQuarantinedMessages(dbDeps);
175
+ return ok(initialClock);
118
176
  });
119
- const storage = createClientStorage({ ...dbDeps, clock })({
120
- onError: (error) => {
121
- consoleEntryOrErrorBroadcastChannel.postMessage({
122
- type: "Error",
123
- error,
124
- });
125
- },
126
- });
177
+ if (!startup.ok) {
178
+ // Nothing was written. Returning lets the disposer close SQLite and
179
+ // release the database lock, which the SharedWorker's tenant disposal
180
+ // waits on. The tab leader lock is unaffected.
181
+ port.postMessage({
182
+ type: "LeaderRefused",
183
+ name: initMessage.name,
184
+ error: startup.error,
185
+ });
186
+ return ok();
187
+ }
188
+ const initialClock = startup.value;
189
+ const storage = createClientStorage(dbDeps);
127
190
  const dbWorkerRun = disposer.use(run.create({ storage }));
128
- port.postMessage({ type: "LeaderAcquired", name: initMessage.name });
191
+ port.postMessage({
192
+ type: "LeaderAcquired",
193
+ name: initMessage.name,
194
+ clock: initialClock,
195
+ });
129
196
  await run.ok(callback(({ resolve }) => {
130
197
  port.onMessage = (input) => {
131
198
  if (input.type === "Dispose") {
132
199
  resolve(ok());
133
200
  return;
134
201
  }
135
- const { callbackId, request } = input;
202
+ const { attemptId } = input;
136
203
  const postQueuedResponse = (response) => {
137
204
  port.postMessage({
138
205
  type: "OnQueuedResponse",
139
- callbackId,
206
+ attemptId,
140
207
  response,
141
208
  }, response.type === "ForEvolu" && response.message.type === "Export"
142
209
  ? [response.message.file.buffer]
143
210
  : undefined);
144
211
  };
145
- if (request.type === "ForSharedWorker") {
146
- if (request.message.type === "ApplySyncMessage") {
212
+ if ("clock" in input) {
213
+ const { clock: inputClock, now } = input;
214
+ let committedClock = inputClock;
215
+ const context = {
216
+ now,
217
+ clock: {
218
+ get: () => committedClock,
219
+ set: (timestamp) => {
220
+ committedClock = timestamp;
221
+ },
222
+ },
223
+ };
224
+ const request = input.request;
225
+ if (request.type === "ForSharedWorker") {
147
226
  const { owner, inputMessage } = request.message;
148
227
  void dbWorkerRun(async (run) => {
149
- storage.setRequestContext(owner.encryptionKey);
228
+ storage.setRequestContext(owner.encryptionKey, context);
150
229
  const result = await run.abortable(applyProtocolMessageAsClient(inputMessage, {
151
230
  writeKey: owner.writeKey,
152
231
  }));
@@ -154,6 +233,7 @@ export const startDbWorker = (self) => async (run) => {
154
233
  type: "ForSharedWorker",
155
234
  message: {
156
235
  type: "ApplySyncMessage",
236
+ clock: context.clock.get(),
157
237
  ownerId: owner.id,
158
238
  didWriteMessages: storage.didWriteMessages(),
159
239
  result,
@@ -161,21 +241,41 @@ export const startDbWorker = (self) => async (run) => {
161
241
  });
162
242
  return ok();
163
243
  });
164
- return;
165
244
  }
245
+ else {
246
+ postQueuedResponse({
247
+ type: "ForEvolu",
248
+ id: request.id,
249
+ message: handleMutation({
250
+ ...dbDeps,
251
+ clock: context.clock,
252
+ })(request.message, now),
253
+ });
254
+ }
255
+ return;
256
+ }
257
+ const request = input.request;
258
+ if (request.type === "ForSharedWorker") {
166
259
  const protocolMessagesByOwnerId = new Map();
260
+ const failedOwnerIds = new Set();
261
+ // An unanswered attempt would block the tenant queue, so a failed
262
+ // owner is logged and reported instead of thrown.
167
263
  for (const owner of request.message.owners) {
168
264
  storage.setRequestContext(owner.encryptionKey);
169
- protocolMessagesByOwnerId.set(owner.id, createProtocolMessageForSync({
170
- storage,
171
- console: deps.console,
172
- })(owner.id, SubscriptionFlags.Subscribe));
265
+ try {
266
+ protocolMessagesByOwnerId.set(owner.id, createProtocolMessageForSync({ storage })(owner.id, SubscriptionFlags.Subscribe));
267
+ }
268
+ catch (error) {
269
+ deps.console.error(error);
270
+ failedOwnerIds.add(owner.id);
271
+ }
173
272
  }
174
273
  postQueuedResponse({
175
274
  type: "ForSharedWorker",
176
275
  message: {
177
276
  type: "CreateSyncMessages",
178
277
  protocolMessagesByOwnerId,
278
+ failedOwnerIds,
179
279
  },
180
280
  });
181
281
  return;
@@ -200,21 +300,7 @@ export const startDbWorker = (self) => async (run) => {
200
300
  file: sqlite.export(),
201
301
  },
202
302
  });
203
- return;
204
- }
205
- const result = handleMutation({ ...dbDeps, clock })(request.message);
206
- if (!result.ok) {
207
- consoleEntryOrErrorBroadcastChannel.postMessage({
208
- type: "Error",
209
- error: result.error,
210
- });
211
- return;
212
303
  }
213
- postQueuedResponse({
214
- type: "ForEvolu",
215
- id: request.id,
216
- message: result.value,
217
- });
218
304
  };
219
305
  return () => {
220
306
  port.onMessage = null;
@@ -232,42 +318,105 @@ export const startDbWorker = (self) => async (run) => {
232
318
  await result_1;
233
319
  }
234
320
  };
235
- const createClock = (deps) => (dbIsInitialized) => {
236
- let currentTimestamp;
237
- if (dbIsInitialized) {
238
- const { rows } = deps.sqlite.exec(sql `
239
- select clock
240
- from evolu_config
241
- limit 1;
321
+ /**
322
+ * Persists `timestamp` only when it is greater than the stored clock.
323
+ *
324
+ * Requests report their computed clock, which can be older than the stored
325
+ * clock when replayed after startup release. The SQL guard keeps the stored
326
+ * clock from moving backwards; the SharedWorker adopts response clocks only
327
+ * when newer than its session clock.
328
+ *
329
+ * Timestamp bytes sort like their timestamps and SQLite compares blobs byte by
330
+ * byte, so persistence takes one statement even when the clock does not
331
+ * advance.
332
+ *
333
+ * Local-only mutations and sync requests that do not invoke `writeMessages`
334
+ * skip this. Successfully processed batches in `writeMessages` call this even
335
+ * when every message is duplicated or quarantined. Duplicate receipts within
336
+ * the drift limit can advance the clock without storing new messages.
337
+ */
338
+ const saveClock = (deps) => (timestamp) => {
339
+ const bytes = timestampToTimestampBytes(timestamp);
340
+ deps.sqlite.exec(sql.prepared `
341
+ update evolu_config
342
+ set "clock" = ${bytes}
343
+ where "clock" < ${bytes};
344
+ `);
345
+ };
346
+ /**
347
+ * Checks the stored database version inside the startup transaction, before any
348
+ * other read. The legacy layout, one `protocolVersion` row that was always 1,
349
+ * is converted to database version 1 first. An older database is migrated one
350
+ * version at a time, and the record is updated in the same transaction, so a
351
+ * failed migration leaves both data and version unchanged.
352
+ */
353
+ const ensureDbVersion = ({ sqlite }) => (versionColumns) => {
354
+ if (!versionColumns.has("dbVersion")) {
355
+ sqlite.exec(sql `
356
+ alter table evolu_version
357
+ rename column "protocolVersion" to "dbVersion";
242
358
  `);
243
- assertNonEmptyReadonlyArray(rows);
244
- currentTimestamp = timestampBytesToTimestamp(firstInArray(rows).clock);
245
359
  }
246
- else {
247
- currentTimestamp = createInitialTimestamp(deps);
360
+ const { rows } = sqlite.exec(sql `
361
+ select "dbVersion" from evolu_version;
362
+ `);
363
+ const storedVersion = rows[0].dbVersion;
364
+ if (storedVersion > dbVersion) {
365
+ return err({
366
+ type: "UnsupportedDbVersionError",
367
+ storedVersion,
368
+ supportedVersion: dbVersion,
369
+ });
370
+ }
371
+ if (storedVersion < dbVersion) {
372
+ if (storedVersion < 2)
373
+ migrateToVersion2({ sqlite });
374
+ sqlite.exec(sql `update evolu_version set "dbVersion" = ${dbVersion};`);
375
+ }
376
+ return ok();
377
+ };
378
+ /**
379
+ * Version 2 records why a message is quarantined, whether this database stamped
380
+ * or received it, and when, and adds the index that startup release reads. Rows
381
+ * from version 1 get the defaults: schema quarantine of a received message with
382
+ * an unknown quarantine time. See {@link QuarantineReason}.
383
+ */
384
+ const migrateToVersion2 = ({ sqlite }) => {
385
+ for (const query of [
386
+ sql `
387
+ alter table evolu_message_quarantine
388
+ add column "reason" integer not null default ${sql.raw(String(QuarantineReason.Schema))};
389
+ `,
390
+ sql `
391
+ alter table evolu_message_quarantine
392
+ add column "origin" integer not null default ${sql.raw(String(QuarantineOrigin.ReceivedMessage))};
393
+ `,
394
+ sql `
395
+ alter table evolu_message_quarantine
396
+ add column "quarantinedAt" integer;
397
+ `,
398
+ sql `
399
+ create index evolu_message_quarantine_reason_timestamp on evolu_message_quarantine (
400
+ "reason",
401
+ "timestamp"
402
+ );
403
+ `,
404
+ ]) {
405
+ sqlite.exec(query);
248
406
  }
249
- return {
250
- get: () => currentTimestamp,
251
- save: (timestamp) => {
252
- currentTimestamp = timestamp;
253
- deps.sqlite.exec(sql.prepared `
254
- update evolu_config
255
- set "clock" = ${timestampToTimestampBytes(timestamp)};
256
- `);
257
- },
258
- };
259
407
  };
260
408
  const initializeDb = ({ sqlite }) => (initialClock) => {
261
409
  for (const query of [
410
+ // The database version record; see the module documentation.
262
411
  sql `
263
412
  create table evolu_version (
264
- "protocolVersion" integer not null
413
+ "dbVersion" integer not null
265
414
  )
266
415
  strict;
267
416
  `,
268
417
  sql `
269
- insert into evolu_version ("protocolVersion")
270
- values (${protocolVersion});
418
+ insert into evolu_version ("dbVersion")
419
+ values (${dbVersion});
271
420
  `,
272
421
  sql `
273
422
  create table evolu_config (
@@ -314,7 +463,7 @@ const initializeDb = ({ sqlite }) => (initialClock) => {
314
463
  );
315
464
  `,
316
465
  /**
317
- * Stores messages with unknown schema in a quarantine table.
466
+ * Stores unapplied messages with their quarantine reason.
318
467
  *
319
468
  * When a device receives sync messages containing tables or columns that
320
469
  * don't exist in its current schema (e.g., from a newer app version),
@@ -326,6 +475,16 @@ const initializeDb = ({ sqlite }) => (initialClock) => {
326
475
  * 3. Partial messages work - known columns go to app tables, unknown to
327
476
  * quarantine
328
477
  *
478
+ * Clock-drift quarantine preserves every column of the affected message.
479
+ * It is released at startup once system time comes within the drift limit
480
+ * of the message's timestamp; see the Timestamp module.
481
+ *
482
+ * Each row records why it was not applied (`reason`), whether this
483
+ * database stamped the message for a local mutation or received it
484
+ * (`origin`), and the captured system time of the request that
485
+ * quarantined it (`quarantinedAt`). Quarantine is not reported as an
486
+ * error; applications watch this table through queries.
487
+ *
329
488
  * The `union all` query in `readDbChange` combines `evolu_history` and
330
489
  * this table, ensuring all data (known and unknown) is included when
331
490
  * syncing to other devices.
@@ -338,6 +497,9 @@ const initializeDb = ({ sqlite }) => (initialClock) => {
338
497
  "id" blob not null,
339
498
  "column" text not null,
340
499
  "value" any,
500
+ "reason" integer not null default ${sql.raw(String(QuarantineReason.Schema))},
501
+ "origin" integer not null default ${sql.raw(String(QuarantineOrigin.ReceivedMessage))},
502
+ "quarantinedAt" integer,
341
503
  primary key ("ownerId", "timestamp", "table", "id", "column")
342
504
  )
343
505
  strict;
@@ -346,17 +508,27 @@ const initializeDb = ({ sqlite }) => (initialClock) => {
346
508
  sqlite.exec(query);
347
509
  }
348
510
  createBaseSqliteStorageTables({ sqlite });
511
+ // Startup release reads drift quarantine by reason and timestamp. Created
512
+ // last, as the migration creates it, so Evolu's own indexes are listed in one
513
+ // order in fresh and migrated databases.
514
+ sqlite.exec(sql `
515
+ create index evolu_message_quarantine_reason_timestamp on evolu_message_quarantine (
516
+ "reason",
517
+ "timestamp"
518
+ );
519
+ `);
349
520
  };
350
521
  const tryApplyQuarantinedMessages = (deps) => {
351
- const rows = deps.sqlite.exec(sql `
522
+ const { rows } = deps.sqlite.exec(sql `
352
523
  select "ownerId", "timestamp", "table", "id", "column", "value"
353
- from evolu_message_quarantine;
524
+ from evolu_message_quarantine
525
+ where "reason" = ${QuarantineReason.Schema};
354
526
  `);
355
- for (const row of rows.rows) {
527
+ for (const row of rows) {
356
528
  if (!validateColumnValue(deps)(row.table, row.column, row.value))
357
529
  continue;
358
530
  applyColumnChange(deps)(row.ownerId, ownerIdBytesToOwnerId(row.ownerId), row.table, row.id, idBytesToId(row.id), row.column, row.value, row.timestamp);
359
- deps.sqlite.exec(sql `
531
+ deps.sqlite.exec(sql.prepared `
360
532
  delete from evolu_message_quarantine
361
533
  where
362
534
  "ownerId" = ${row.ownerId}
@@ -367,7 +539,65 @@ const tryApplyQuarantinedMessages = (deps) => {
367
539
  `);
368
540
  }
369
541
  };
542
+ /**
543
+ * Moves drift quarantine within the drift limit to schema quarantine for
544
+ * application. Advances `clock` once per distinct timestamp in timestamp order,
545
+ * using one captured system time, so later local changes sort after released
546
+ * messages. Only timestamps within the drift limit are loaded. Returns the
547
+ * advanced clock, or `null` when nothing was released. Runs inside the startup
548
+ * transaction, before saving the clock and applying schema quarantine, so the
549
+ * SharedWorker learns the clock only after release commits.
550
+ */
551
+ const releaseDriftQuarantine = (deps) => (clock) => {
552
+ const now = deps.time.now();
553
+ // Milliseconds are integers; floor the allowance before adding it so a
554
+ // fractional allowance cannot round up near the timestamp range ceiling.
555
+ const maxReleaseMillis = now + Math.floor(deps.timestampConfig.maxDrift);
556
+ assertType(NonNaNNumber, maxReleaseMillis);
557
+ const bound = timestampToTimestampBytes({
558
+ millis: saturateMillis(maxReleaseMillis),
559
+ counter: maxCounter,
560
+ nodeId: maxNodeId,
561
+ });
562
+ const { rows } = deps.sqlite.exec(sql `
563
+ select distinct "timestamp"
564
+ from evolu_message_quarantine
565
+ where
566
+ "reason" = ${QuarantineReason.TimestampDrift}
567
+ and "timestamp" <= ${bound}
568
+ order by "timestamp";
569
+ `);
570
+ if (rows.length === 0)
571
+ return null;
572
+ const receive = receiveTimestamp(deps);
573
+ let nextClock = clock;
574
+ for (const { timestamp } of rows) {
575
+ const remote = timestampBytesToTimestamp(timestamp);
576
+ const next = receive(nextClock, remote, now);
577
+ if (next.ok) {
578
+ nextClock = next.value;
579
+ }
580
+ else {
581
+ assert(next.error.cause === "local", "The query bound excludes remote drift at the captured time.");
582
+ nextClock = next.error.timestamp;
583
+ }
584
+ }
585
+ // Drift checks passed; mark these rows for the next schema pass.
586
+ deps.sqlite.exec(sql `
587
+ update evolu_message_quarantine
588
+ set "reason" = ${QuarantineReason.Schema}
589
+ where
590
+ "reason" = ${QuarantineReason.TimestampDrift}
591
+ and "timestamp" <= ${bound};
592
+ `);
593
+ return nextClock;
594
+ };
370
595
  const validateColumnValue = (deps) => (table, column, _value) => {
596
+ // Local-only tables never sync, so a received change to one comes from
597
+ // non-standard code. It stays in quarantine, stored for sync but never
598
+ // applied.
599
+ if (isLocalOnlyTable(table))
600
+ return false;
371
601
  const schemaColumns = getOwnProp(deps.sqliteSchema.tables, table);
372
602
  return (schemaColumns != null &&
373
603
  (systemColumnsWithoutOwnerId.has(column) || schemaColumns.has(column)));
@@ -410,21 +640,21 @@ const applyColumnChange = (deps) => (ownerIdBytes, ownerId, table, idBytes, id,
410
640
  on conflict do nothing;
411
641
  `);
412
642
  };
413
- const createClientStorage = (deps) => ({ onError, }) => {
643
+ const createClientStorage = (deps) => {
414
644
  let encryptionKey = null;
415
645
  let didWriteMessages = false;
646
+ let writeContext;
416
647
  const getEncryptionKey = () => {
417
648
  assertNonNullable(encryptionKey, "ClientStorage encryption key must be set");
418
649
  return encryptionKey;
419
650
  };
420
651
  return {
421
652
  ...deps.baseSqliteStorage,
422
- // DEV: ClientStorage was designed when Storage and Sync lived in the
423
- // same file.
424
- // This is safe because the worker handles one message at a time. We will
425
- // refactor it later, we will probably have to change Protocol API.
426
- setRequestContext: (nextEncryptionKey) => {
653
+ // SharedWorker waits for the response before dispatching another request,
654
+ // so asynchronous sync processing cannot overlap this request context.
655
+ setRequestContext: (nextEncryptionKey, nextWriteContext) => {
427
656
  encryptionKey = nextEncryptionKey;
657
+ writeContext = nextWriteContext;
428
658
  didWriteMessages = false;
429
659
  },
430
660
  didWriteMessages: () => didWriteMessages,
@@ -441,39 +671,49 @@ const createClientStorage = (deps) => ({ onError, }) => {
441
671
  const currentEncryptionKey = getEncryptionKey();
442
672
  for (const message of encryptedMessages) {
443
673
  const change = decryptAndDecodeDbChange(message, currentEncryptionKey);
444
- if (!change.ok) {
445
- onError(change.error);
446
- return ok();
447
- }
674
+ if (!change.ok)
675
+ return err(change.error);
448
676
  messages.push({ timestamp: message.timestamp, change: change.value });
449
677
  }
450
- let clockTimestamp = deps.clock.get();
678
+ assertNonNullable(writeContext);
679
+ const { clock, now } = writeContext;
680
+ let clockTimestamp = clock.get();
681
+ const receive = receiveTimestamp(deps);
682
+ // The clock is computed over every message, duplicates included, so a
683
+ // retry with the same inputs reports the same clock. Writes for
684
+ // timestamps already in the owner's set are skipped by applyMessages.
451
685
  for (const message of messages) {
452
- const nextTimestamp = receiveTimestamp(deps)(clockTimestamp, message.timestamp);
686
+ const nextTimestamp = receive(clockTimestamp, message.timestamp, now);
453
687
  if (!nextTimestamp.ok) {
454
- onError(nextTimestamp.error);
455
- return ok();
688
+ if (nextTimestamp.error.cause === "remote")
689
+ continue;
690
+ clockTimestamp = nextTimestamp.error.timestamp;
456
691
  }
457
- clockTimestamp = nextTimestamp.value;
692
+ else
693
+ clockTimestamp = nextTimestamp.value;
458
694
  }
459
695
  assertNonEmptyReadonlyArray(messages);
460
- return deps.sqlite.transaction(() => {
461
- applyMessages(deps)(ownerIdBytesToOwnerId(ownerIdBytes), messages);
462
- deps.clock.save(clockTimestamp);
463
- didWriteMessages = true;
464
- return ok();
696
+ let wroteNewMessages = false;
697
+ deps.sqlite.transaction(() => {
698
+ wroteNewMessages = applyMessages(deps)(ownerIdBytesToOwnerId(ownerIdBytes), messages, QuarantineOrigin.ReceivedMessage, now);
699
+ saveClock(deps)(clockTimestamp);
465
700
  });
701
+ clock.set(clockTimestamp);
702
+ // A batch of duplicates changes no table, so queries need no refresh.
703
+ if (wroteNewMessages)
704
+ didWriteMessages = true;
705
+ return ok();
466
706
  },
467
707
  readDbChange: (ownerId, timestamp) => {
468
708
  const result = deps.sqlite.exec(sql `
469
- select "table", "id", "column", "value"
470
- from evolu_history
471
- where "ownerId" = ${ownerId} and "timestamp" = ${timestamp}
472
- union all
473
- select "table", "id", "column", "value"
474
- from evolu_message_quarantine
475
- where "ownerId" = ${ownerId} and "timestamp" = ${timestamp};
476
- `);
709
+ select "table", "id", "column", "value"
710
+ from evolu_history
711
+ where "ownerId" = ${ownerId} and "timestamp" = ${timestamp}
712
+ union all
713
+ select "table", "id", "column", "value"
714
+ from evolu_message_quarantine
715
+ where "ownerId" = ${ownerId} and "timestamp" = ${timestamp};
716
+ `);
477
717
  const { rows } = result;
478
718
  assertNonEmptyReadonlyArray(rows, "Every timestamp must have rows");
479
719
  const firstRow = firstInArray(rows);
@@ -510,20 +750,20 @@ const createClientStorage = (deps) => ({ onError, }) => {
510
750
  },
511
751
  };
512
752
  };
513
- const handleMutation = (deps) => (message) => deps.sqlite.transaction(() => {
753
+ const handleMutation = (deps) => (message, now) => getOk(deps.sqlite.transaction(() => {
514
754
  const messagesByOwnerId = new Map();
515
755
  let clockTimestamp = deps.clock.get();
516
- let clockChanged = false;
517
756
  for (const change of message.changes) {
518
- if (change.table.startsWith("_")) {
519
- applyLocalOnlyChange(deps)(change);
757
+ if (isLocalOnlyTable(change.table)) {
758
+ applyLocalOnlyChange(deps)(change, now);
520
759
  continue;
521
760
  }
522
- const nextTimestamp = sendTimestamp(deps)(clockTimestamp);
523
- if (!nextTimestamp.ok)
524
- return nextTimestamp;
525
- clockTimestamp = nextTimestamp.value;
526
- clockChanged = true;
761
+ // A drifted change still receives the next timestamp; applyMessages
762
+ // stores it in quarantine instead of its table.
763
+ const nextTimestamp = sendTimestamp(deps)(clockTimestamp, now);
764
+ clockTimestamp = nextTimestamp.ok
765
+ ? nextTimestamp.value
766
+ : nextTimestamp.error.timestamp;
527
767
  const { ownerId, ...dbChange } = change;
528
768
  const message = {
529
769
  timestamp: clockTimestamp,
@@ -536,17 +776,18 @@ const handleMutation = (deps) => (message) => deps.sqlite.transaction(() => {
536
776
  messagesByOwnerId.set(ownerId, [message]);
537
777
  }
538
778
  for (const [ownerId, messages] of messagesByOwnerId) {
539
- applyMessages(deps)(ownerId, messages);
779
+ applyMessages(deps)(ownerId, messages, QuarantineOrigin.LocalMutation, now);
540
780
  }
541
- if (clockChanged)
542
- deps.clock.save(clockTimestamp);
781
+ if (messagesByOwnerId.size > 0)
782
+ saveClock(deps)(clockTimestamp);
543
783
  return ok({
544
784
  type: "Mutate",
785
+ clock: clockTimestamp,
545
786
  messagesByOwnerId,
546
787
  rowsByQuery: loadQueries(deps)(message.subscribedQueries),
547
788
  });
548
- });
549
- const applyLocalOnlyChange = (deps) => (change) => {
789
+ }));
790
+ const applyLocalOnlyChange = (deps) => (change, now) => {
550
791
  if (change.isDelete) {
551
792
  deps.sqlite.exec(sql `
552
793
  delete from ${sql.identifier(change.table)}
@@ -555,7 +796,7 @@ const applyLocalOnlyChange = (deps) => (change) => {
555
796
  }
556
797
  else {
557
798
  const ownerId = change.ownerId;
558
- const columns = dbChangeToColumns(change, deps.time.now());
799
+ const columns = dbChangeToColumns(change, now);
559
800
  for (const [column, value] of columns) {
560
801
  assertNotUndefined(value);
561
802
  deps.sqlite.exec(sql.prepared `
@@ -568,23 +809,52 @@ const applyLocalOnlyChange = (deps) => (change) => {
568
809
  }
569
810
  }
570
811
  };
571
- const applyMessages = (deps) => (ownerId, messages) => {
812
+ /**
813
+ * Stores messages for an owner and applies them to their tables. Drifted
814
+ * messages and columns the schema does not define go to quarantine instead.
815
+ * Uses the request's captured time to classify drift, matching timestamp
816
+ * generation. Returns whether any message was new; the rest were stored
817
+ * before.
818
+ */
819
+ const applyMessages = (deps) => (ownerId, messages, origin, now) => {
572
820
  const ownerIdBytes = ownerIdToOwnerIdBytes(ownerId);
821
+ let wroteNewMessages = false;
573
822
  const usage = readOwnerUsageOrDefault(deps)(ownerIdBytes, timestampToTimestampBytes(firstInArray(messages).timestamp));
574
823
  let { firstTimestamp, lastTimestamp } = usage;
575
824
  for (const { timestamp, change } of messages) {
825
+ const timestampBytes = timestampToTimestampBytes(timestamp);
826
+ let strategy;
827
+ [strategy, firstTimestamp, lastTimestamp] = getTimestampInsertStrategy(timestampBytes, firstTimestamp, lastTimestamp);
828
+ // A timestamp already in the set was applied or quarantined before.
829
+ // Skipping it preserves that decision and makes duplicate delivery and
830
+ // retries idempotent without a separate lookup.
831
+ const isNew = deps.baseSqliteStorage.insertTimestamp(ownerIdBytes, timestampBytes, strategy);
832
+ if (!isNew)
833
+ continue;
834
+ wroteNewMessages = true;
835
+ const hasDrift = isTimestampBeyondMaxDrift(deps)(timestamp.millis, now);
576
836
  const columns = dbChangeToColumns(change, timestamp.millis);
577
837
  const idBytes = idToIdBytes(change.id);
578
- const timestampBytes = timestampToTimestampBytes(timestamp);
579
838
  for (const [column, value] of columns) {
580
839
  assertNotUndefined(value);
581
- if (validateColumnValue(deps)(change.table, column, value)) {
840
+ if (!hasDrift &&
841
+ validateColumnValue(deps)(change.table, column, value)) {
582
842
  applyColumnChange(deps)(ownerIdBytes, ownerId, change.table, idBytes, change.id, column, value, timestampBytes);
583
843
  }
584
844
  else {
585
845
  deps.sqlite.exec(sql.prepared `
586
846
  insert into evolu_message_quarantine
587
- ("ownerId", "timestamp", "table", "id", "column", "value")
847
+ (
848
+ "ownerId",
849
+ "timestamp",
850
+ "table",
851
+ "id",
852
+ "column",
853
+ "value",
854
+ "reason",
855
+ "origin",
856
+ "quarantinedAt"
857
+ )
588
858
  values
589
859
  (
590
860
  ${ownerIdBytes},
@@ -592,23 +862,28 @@ const applyMessages = (deps) => (ownerId, messages) => {
592
862
  ${change.table},
593
863
  ${idBytes},
594
864
  ${column},
595
- ${value}
865
+ ${value},
866
+ ${hasDrift
867
+ ? QuarantineReason.TimestampDrift
868
+ : QuarantineReason.Schema},
869
+ ${origin},
870
+ ${now}
596
871
  )
597
872
  on conflict do nothing;
598
873
  `);
599
874
  }
600
875
  }
601
- let strategy;
602
- [strategy, firstTimestamp, lastTimestamp] = getTimestampInsertStrategy(timestampBytes, firstTimestamp, lastTimestamp);
603
- deps.baseSqliteStorage.insertTimestamp(ownerIdBytes, timestampBytes, strategy);
604
876
  }
605
- /**
606
- * TODO: Implement proper storedBytes tracking for client using received and
607
- * sent encrypted message sizes.
608
- */
609
- updateOwnerUsage(deps)(ownerIdBytes,
610
- // Placeholder until proper tracking implemented
611
- onePositiveInt, firstTimestamp, lastTimestamp);
877
+ if (wroteNewMessages) {
878
+ /**
879
+ * TODO: Implement proper storedBytes tracking for client using received
880
+ * and sent encrypted message sizes.
881
+ */
882
+ updateOwnerUsage(deps)(ownerIdBytes,
883
+ // Placeholder until proper tracking implemented
884
+ onePositiveInt, firstTimestamp, lastTimestamp);
885
+ }
886
+ return wroteNewMessages;
612
887
  };
613
888
  const dbChangeToColumns = (change, now) => {
614
889
  let values = objectToEntries(change.values);