@spooky-sync/core 0.0.1-canary.240 → 0.0.1-canary.242

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 (292) hide show
  1. package/AGENTS.md +34 -15
  2. package/README.md +56 -16
  3. package/dist/index.d.ts +1136 -1846
  4. package/dist/index.js +10168 -11267
  5. package/dist/sqlite-open.js +10 -1
  6. package/dist/types.d.ts +22 -79
  7. package/package.json +8 -4
  8. package/skills/sp00ky-core/SKILL.md +18 -0
  9. package/src/boot/auth-flip.saga.test.ts +82 -0
  10. package/src/boot/auth-flip.saga.ts +62 -0
  11. package/src/boot/boot.saga.test.ts +151 -0
  12. package/src/boot/boot.saga.ts +128 -0
  13. package/src/boot/bucket-switch.saga.test.ts +158 -0
  14. package/src/boot/bucket-switch.saga.ts +115 -0
  15. package/src/boot/preload.saga.test.ts +51 -0
  16. package/src/boot/preload.saga.ts +31 -0
  17. package/src/client/bucket-handle.test.ts +117 -0
  18. package/src/client/bucket-handle.ts +227 -0
  19. package/src/client/router.test.ts +56 -0
  20. package/src/client/router.ts +94 -0
  21. package/src/client/runtime.test.ts +192 -0
  22. package/src/client/runtime.ts +296 -0
  23. package/src/client/services.ts +349 -0
  24. package/src/client/sp00ky-client.test.ts +241 -0
  25. package/src/client/sp00ky-client.ts +482 -0
  26. package/src/index.ts +1 -0
  27. package/src/kernel/constants.test.ts +19 -0
  28. package/src/kernel/constants.ts +54 -0
  29. package/src/kernel/effects.test.ts +40 -0
  30. package/src/kernel/effects.ts +159 -0
  31. package/src/kernel/events.ts +55 -0
  32. package/src/kernel/interpreter.test.ts +116 -0
  33. package/src/kernel/interpreter.ts +143 -0
  34. package/src/kernel/saga.test.ts +89 -0
  35. package/src/kernel/saga.ts +73 -0
  36. package/src/modules/app-release/index.ts +13 -5
  37. package/src/modules/devtools/index.ts +10 -3
  38. package/src/modules/devtools/state-shape.test.ts +4 -1
  39. package/src/modules/feature-flag/index.ts +13 -5
  40. package/src/mutation/jobs.test.ts +25 -0
  41. package/src/mutation/jobs.ts +41 -0
  42. package/src/{modules/data → mutation}/mutation-id.test.ts +12 -0
  43. package/src/mutation/push.saga.test.ts +285 -0
  44. package/src/mutation/push.saga.ts +215 -0
  45. package/src/mutation/rows.test.ts +201 -0
  46. package/src/mutation/rows.ts +377 -0
  47. package/src/mutation/tray.saga.test.ts +63 -0
  48. package/src/mutation/tray.saga.ts +48 -0
  49. package/src/mutation/write.saga.test.ts +163 -0
  50. package/src/mutation/write.saga.ts +166 -0
  51. package/src/query/env.test.ts +23 -0
  52. package/src/query/env.ts +56 -0
  53. package/src/query/fetch.saga.test.ts +156 -0
  54. package/src/query/fetch.saga.ts +109 -0
  55. package/src/query/hash.test.ts +14 -0
  56. package/src/query/hash.ts +18 -0
  57. package/src/{modules/data → query}/id-set-plan.test.ts +12 -2
  58. package/src/query/lifecycle.saga.test.ts +122 -0
  59. package/src/query/lifecycle.saga.ts +101 -0
  60. package/src/query/materialize.saga.test.ts +82 -0
  61. package/src/query/materialize.saga.ts +75 -0
  62. package/src/query/materialize.test.ts +50 -0
  63. package/src/query/materialize.ts +44 -0
  64. package/src/query/membership.saga.test.ts +246 -0
  65. package/src/query/membership.saga.ts +208 -0
  66. package/src/query/membership.test.ts +118 -0
  67. package/src/query/membership.ts +158 -0
  68. package/src/query/register.saga.test.ts +251 -0
  69. package/src/query/register.saga.ts +190 -0
  70. package/src/query/render-set.test.ts +36 -0
  71. package/src/query/render-set.ts +60 -0
  72. package/src/query/sql.test.ts +62 -0
  73. package/src/query/sql.ts +79 -0
  74. package/src/{modules/data → query}/window-query.test.ts +18 -0
  75. package/src/{modules/data → query}/window-query.ts +2 -1
  76. package/src/services/database/connection-supervisor.test.ts +29 -0
  77. package/src/services/database/connection-supervisor.ts +7 -0
  78. package/src/services/database/database.responses.test.ts +43 -0
  79. package/src/services/database/database.ts +25 -10
  80. package/src/services/database/local-migrator.ts +2 -2
  81. package/src/services/database/plan-render.test.ts +1 -1
  82. package/src/services/database/sqlite-cache-engine.test.ts +4 -3
  83. package/src/services/database/sqlite-cache-engine.ts +2 -11
  84. package/src/services/database/sqlite-devtools-queries.integration.test.ts +1 -1
  85. package/src/services/database/sqlite-plan-sql.ts +16 -1
  86. package/src/services/database/sqlite-projection.test.ts +25 -3
  87. package/src/services/tabs/protocol.ts +7 -3
  88. package/src/sp00ky.ts +5 -1854
  89. package/src/state/client-state.ts +184 -0
  90. package/src/state/lifecycle.test.ts +118 -0
  91. package/src/state/lifecycle.ts +86 -0
  92. package/src/state/reducers.test.ts +280 -0
  93. package/src/state/reducers.ts +476 -0
  94. package/src/state/selectors.test.ts +149 -0
  95. package/src/state/selectors.ts +184 -0
  96. package/src/sync/connection.saga.test.ts +86 -0
  97. package/src/sync/connection.saga.ts +85 -0
  98. package/src/sync/live.saga.test.ts +69 -0
  99. package/src/sync/live.saga.ts +49 -0
  100. package/src/sync/policy.health.test.ts +56 -0
  101. package/src/{modules/sync/utils.test.ts → sync/policy.test.ts} +31 -3
  102. package/src/{modules/sync/utils.ts → sync/policy.ts} +67 -5
  103. package/src/sync/poll.saga.test.ts +51 -0
  104. package/src/sync/poll.saga.ts +36 -0
  105. package/src/sync/tabs.saga.test.ts +74 -0
  106. package/src/sync/tabs.saga.ts +92 -0
  107. package/src/testing/adapters.ts +133 -0
  108. package/src/testing/build.ts +61 -0
  109. package/src/testing/fake-services.ts +34 -0
  110. package/src/testing/run-pure.test.ts +92 -0
  111. package/src/testing/run-pure.ts +128 -0
  112. package/src/testing/services.ts +17 -0
  113. package/src/types.ts +8 -7
  114. package/src/utils/utils.test.ts +2 -1
  115. package/vitest.config.ts +48 -0
  116. package/coverage/lcov-report/base.css +0 -224
  117. package/coverage/lcov-report/block-navigation.js +0 -87
  118. package/coverage/lcov-report/favicon.png +0 -0
  119. package/coverage/lcov-report/index.html +0 -476
  120. package/coverage/lcov-report/prettify.css +0 -1
  121. package/coverage/lcov-report/prettify.js +0 -2
  122. package/coverage/lcov-report/sort-arrow-sprite.png +0 -0
  123. package/coverage/lcov-report/sorter.js +0 -210
  124. package/coverage/lcov-report/src/boot/auth-flip.saga.ts.html +0 -271
  125. package/coverage/lcov-report/src/boot/boot.saga.ts.html +0 -469
  126. package/coverage/lcov-report/src/boot/bucket-switch.saga.ts.html +0 -430
  127. package/coverage/lcov-report/src/boot/index.html +0 -161
  128. package/coverage/lcov-report/src/boot/preload.saga.ts.html +0 -178
  129. package/coverage/lcov-report/src/client/bucket-handle.ts.html +0 -766
  130. package/coverage/lcov-report/src/client/index.html +0 -161
  131. package/coverage/lcov-report/src/client/router.ts.html +0 -367
  132. package/coverage/lcov-report/src/client/runtime.ts.html +0 -940
  133. package/coverage/lcov-report/src/client/sp00ky-client.ts.html +0 -1531
  134. package/coverage/lcov-report/src/events/index.html +0 -116
  135. package/coverage/lcov-report/src/events/index.ts.html +0 -877
  136. package/coverage/lcov-report/src/index.html +0 -146
  137. package/coverage/lcov-report/src/index.ts.html +0 -202
  138. package/coverage/lcov-report/src/kernel/constants.ts.html +0 -247
  139. package/coverage/lcov-report/src/kernel/effects.ts.html +0 -562
  140. package/coverage/lcov-report/src/kernel/events.ts.html +0 -250
  141. package/coverage/lcov-report/src/kernel/index.html +0 -176
  142. package/coverage/lcov-report/src/kernel/interpreter.ts.html +0 -514
  143. package/coverage/lcov-report/src/kernel/saga.ts.html +0 -304
  144. package/coverage/lcov-report/src/modules/app-release/index.html +0 -116
  145. package/coverage/lcov-report/src/modules/app-release/index.ts.html +0 -712
  146. package/coverage/lcov-report/src/modules/auth/events/index.html +0 -116
  147. package/coverage/lcov-report/src/modules/auth/events/index.ts.html +0 -142
  148. package/coverage/lcov-report/src/modules/auth/index.html +0 -116
  149. package/coverage/lcov-report/src/modules/auth/index.ts.html +0 -1225
  150. package/coverage/lcov-report/src/modules/crdt/crdt-field.ts.html +0 -967
  151. package/coverage/lcov-report/src/modules/crdt/index.html +0 -146
  152. package/coverage/lcov-report/src/modules/crdt/index.ts.html +0 -1474
  153. package/coverage/lcov-report/src/modules/crdt/loro-loader.ts.html +0 -160
  154. package/coverage/lcov-report/src/modules/devtools/flags.ts.html +0 -1132
  155. package/coverage/lcov-report/src/modules/devtools/index.html +0 -161
  156. package/coverage/lcov-report/src/modules/devtools/index.ts.html +0 -2413
  157. package/coverage/lcov-report/src/modules/devtools/storage-info.ts.html +0 -589
  158. package/coverage/lcov-report/src/modules/devtools/versions.ts.html +0 -415
  159. package/coverage/lcov-report/src/modules/feature-flag/index.html +0 -116
  160. package/coverage/lcov-report/src/modules/feature-flag/index.ts.html +0 -1033
  161. package/coverage/lcov-report/src/modules/index.html +0 -116
  162. package/coverage/lcov-report/src/modules/ref-tables.ts.html +0 -349
  163. package/coverage/lcov-report/src/mutation/index.html +0 -191
  164. package/coverage/lcov-report/src/mutation/jobs.ts.html +0 -208
  165. package/coverage/lcov-report/src/mutation/mutation-id.ts.html +0 -190
  166. package/coverage/lcov-report/src/mutation/push.saga.ts.html +0 -730
  167. package/coverage/lcov-report/src/mutation/rows.ts.html +0 -1216
  168. package/coverage/lcov-report/src/mutation/tray.saga.ts.html +0 -229
  169. package/coverage/lcov-report/src/mutation/write.saga.ts.html +0 -583
  170. package/coverage/lcov-report/src/otel/index.html +0 -116
  171. package/coverage/lcov-report/src/otel/index.ts.html +0 -466
  172. package/coverage/lcov-report/src/query/env.ts.html +0 -253
  173. package/coverage/lcov-report/src/query/fetch.saga.ts.html +0 -412
  174. package/coverage/lcov-report/src/query/hash.ts.html +0 -139
  175. package/coverage/lcov-report/src/query/index.html +0 -281
  176. package/coverage/lcov-report/src/query/lifecycle.saga.ts.html +0 -388
  177. package/coverage/lcov-report/src/query/materialize.saga.ts.html +0 -310
  178. package/coverage/lcov-report/src/query/materialize.ts.html +0 -217
  179. package/coverage/lcov-report/src/query/membership.saga.ts.html +0 -709
  180. package/coverage/lcov-report/src/query/membership.ts.html +0 -559
  181. package/coverage/lcov-report/src/query/register.saga.ts.html +0 -655
  182. package/coverage/lcov-report/src/query/render-set.ts.html +0 -265
  183. package/coverage/lcov-report/src/query/sql.ts.html +0 -322
  184. package/coverage/lcov-report/src/query/window-query.ts.html +0 -670
  185. package/coverage/lcov-report/src/services/blobs/blob-cache.ts.html +0 -1894
  186. package/coverage/lcov-report/src/services/blobs/blob-manifest.ts.html +0 -766
  187. package/coverage/lcov-report/src/services/blobs/blob-store.ts.html +0 -1162
  188. package/coverage/lcov-report/src/services/blobs/index.html +0 -161
  189. package/coverage/lcov-report/src/services/blobs/index.ts.html +0 -295
  190. package/coverage/lcov-report/src/services/database/cache-engine.ts.html +0 -664
  191. package/coverage/lcov-report/src/services/database/connection-supervisor.ts.html +0 -1330
  192. package/coverage/lcov-report/src/services/database/database.ts.html +0 -658
  193. package/coverage/lcov-report/src/services/database/engine-factory.ts.html +0 -184
  194. package/coverage/lcov-report/src/services/database/errors.ts.html +0 -187
  195. package/coverage/lcov-report/src/services/database/events/index.html +0 -116
  196. package/coverage/lcov-report/src/services/database/events/index.ts.html +0 -184
  197. package/coverage/lcov-report/src/services/database/index.html +0 -386
  198. package/coverage/lcov-report/src/services/database/index.ts.html +0 -121
  199. package/coverage/lcov-report/src/services/database/local-migrator.ts.html +0 -703
  200. package/coverage/lcov-report/src/services/database/local.ts.html +0 -1633
  201. package/coverage/lcov-report/src/services/database/plan-render.ts.html +0 -409
  202. package/coverage/lcov-report/src/services/database/relation-resolver.ts.html +0 -682
  203. package/coverage/lcov-report/src/services/database/remote.ts.html +0 -1036
  204. package/coverage/lcov-report/src/services/database/sqlite-cache-engine.ts.html +0 -4132
  205. package/coverage/lcov-report/src/services/database/sqlite-lock-verify.ts.html +0 -220
  206. package/coverage/lcov-report/src/services/database/sqlite-open.ts.html +0 -577
  207. package/coverage/lcov-report/src/services/database/sqlite-plan-sql.ts.html +0 -499
  208. package/coverage/lcov-report/src/services/database/sqlite-select.ts.html +0 -478
  209. package/coverage/lcov-report/src/services/database/sqlite-transport.ts.html +0 -757
  210. package/coverage/lcov-report/src/services/database/surql-translate.ts.html +0 -1333
  211. package/coverage/lcov-report/src/services/database/surreal-cache-engine.ts.html +0 -568
  212. package/coverage/lcov-report/src/services/logger/index.html +0 -116
  213. package/coverage/lcov-report/src/services/logger/index.ts.html +0 -154
  214. package/coverage/lcov-report/src/services/persistence/index.html +0 -146
  215. package/coverage/lcov-report/src/services/persistence/localstorage.ts.html +0 -163
  216. package/coverage/lcov-report/src/services/persistence/resilient.ts.html +0 -208
  217. package/coverage/lcov-report/src/services/persistence/surrealdb.ts.html +0 -271
  218. package/coverage/lcov-report/src/services/stream-processor/index.html +0 -146
  219. package/coverage/lcov-report/src/services/stream-processor/index.ts.html +0 -3313
  220. package/coverage/lcov-report/src/services/stream-processor/permissions.ts.html +0 -244
  221. package/coverage/lcov-report/src/services/stream-processor/wasm-types.ts.html +0 -346
  222. package/coverage/lcov-report/src/services/tabs/broker-client.ts.html +0 -934
  223. package/coverage/lcov-report/src/services/tabs/coordinator.ts.html +0 -1984
  224. package/coverage/lcov-report/src/services/tabs/index.html +0 -191
  225. package/coverage/lcov-report/src/services/tabs/leader-locks.ts.html +0 -310
  226. package/coverage/lcov-report/src/services/tabs/protocol.ts.html +0 -871
  227. package/coverage/lcov-report/src/services/tabs/support.ts.html +0 -193
  228. package/coverage/lcov-report/src/services/tabs/tabs-broker-worker.ts.html +0 -2005
  229. package/coverage/lcov-report/src/sp00ky.ts.html +0 -100
  230. package/coverage/lcov-report/src/state/client-state.ts.html +0 -637
  231. package/coverage/lcov-report/src/state/index.html +0 -161
  232. package/coverage/lcov-report/src/state/lifecycle.ts.html +0 -343
  233. package/coverage/lcov-report/src/state/reducers.ts.html +0 -1471
  234. package/coverage/lcov-report/src/state/selectors.ts.html +0 -637
  235. package/coverage/lcov-report/src/sync/connection.saga.ts.html +0 -343
  236. package/coverage/lcov-report/src/sync/index.html +0 -176
  237. package/coverage/lcov-report/src/sync/live.saga.ts.html +0 -232
  238. package/coverage/lcov-report/src/sync/policy.ts.html +0 -1606
  239. package/coverage/lcov-report/src/sync/poll.saga.ts.html +0 -193
  240. package/coverage/lcov-report/src/sync/tabs.saga.ts.html +0 -361
  241. package/coverage/lcov-report/src/types.ts.html +0 -2419
  242. package/coverage/lcov-report/src/utils/blurhash.ts.html +0 -355
  243. package/coverage/lcov-report/src/utils/error-classification.ts.html +0 -244
  244. package/coverage/lcov-report/src/utils/index.html +0 -191
  245. package/coverage/lcov-report/src/utils/index.ts.html +0 -820
  246. package/coverage/lcov-report/src/utils/parser.ts.html +0 -313
  247. package/coverage/lcov-report/src/utils/semver.ts.html +0 -175
  248. package/coverage/lcov-report/src/utils/surql.ts.html +0 -667
  249. package/coverage/lcov.info +0 -19079
  250. package/src/modules/cache/cache.relay.test.ts +0 -95
  251. package/src/modules/cache/index.ts +0 -361
  252. package/src/modules/cache/types.ts +0 -19
  253. package/src/modules/data/data.activity.test.ts +0 -109
  254. package/src/modules/data/data.authority.test.ts +0 -122
  255. package/src/modules/data/data.heartbeat-wake.test.ts +0 -115
  256. package/src/modules/data/data.hydration.test.ts +0 -142
  257. package/src/modules/data/data.membership.test.ts +0 -623
  258. package/src/modules/data/data.notify-table.test.ts +0 -41
  259. package/src/modules/data/data.pending-ids.test.ts +0 -199
  260. package/src/modules/data/data.rebind.test.ts +0 -170
  261. package/src/modules/data/data.rematerialize.test.ts +0 -114
  262. package/src/modules/data/data.run.test.ts +0 -113
  263. package/src/modules/data/data.settled-writes.test.ts +0 -235
  264. package/src/modules/data/data.status.test.ts +0 -249
  265. package/src/modules/data/data.test.ts +0 -58
  266. package/src/modules/data/index.ts +0 -2745
  267. package/src/modules/sync/engine.ts +0 -250
  268. package/src/modules/sync/events/index.ts +0 -84
  269. package/src/modules/sync/index.ts +0 -3
  270. package/src/modules/sync/queue/index.ts +0 -2
  271. package/src/modules/sync/queue/queue-down.test.ts +0 -228
  272. package/src/modules/sync/queue/queue-down.ts +0 -184
  273. package/src/modules/sync/queue/queue-up.forwarded.test.ts +0 -164
  274. package/src/modules/sync/queue/queue-up.ts +0 -407
  275. package/src/modules/sync/scheduler.pause.test.ts +0 -109
  276. package/src/modules/sync/scheduler.retry.test.ts +0 -237
  277. package/src/modules/sync/scheduler.ts +0 -286
  278. package/src/modules/sync/sync.cleanup.test.ts +0 -116
  279. package/src/modules/sync/sync.health.test.ts +0 -167
  280. package/src/modules/sync/sync.heartbeat.test.ts +0 -80
  281. package/src/modules/sync/sync.list-ref-meta.test.ts +0 -193
  282. package/src/modules/sync/sync.live-removal.test.ts +0 -289
  283. package/src/modules/sync/sync.reconnect.test.ts +0 -145
  284. package/src/modules/sync/sync.rematerialize-after-fetch.test.ts +0 -93
  285. package/src/modules/sync/sync.settled-poll.test.ts +0 -58
  286. package/src/modules/sync/sync.subquery.test.ts +0 -82
  287. package/src/modules/sync/sync.tabs.test.ts +0 -249
  288. package/src/modules/sync/sync.ts +0 -2626
  289. package/src/sp00ky.auth-order.test.ts +0 -112
  290. package/src/sp00ky.init-query.test.ts +0 -183
  291. package/src/sp00ky.local-first.test.ts +0 -60
  292. /package/src/{modules/data → mutation}/mutation-id.ts +0 -0
@@ -205,6 +205,15 @@ function reviveRow(json) {
205
205
  * than as text. `COALESCE` covers a row sharing none of the requested keys,
206
206
  * where the subquery yields NULL and `reviveRow` would throw.
207
207
  *
208
+ * The one value `je.value` does NOT carry back is a TOP-LEVEL boolean. SQLite
209
+ * has no boolean type, so `json_each` hands `true`/`false` out as the integers
210
+ * 1/0 and `json_group_object` writes integers back. A projected row therefore
211
+ * read `published: 1` where the same row read whole read `published: true`,
212
+ * and every `=== true` in an app turned false the moment a screen painted from
213
+ * the local cache. (A boolean NESTED in an object or array survives: those come
214
+ * back as whole JSON documents, untouched.) `je.type` still knows what it was,
215
+ * so the two boolean types are rebuilt as JSON literals on the way out.
216
+ *
208
217
  * Deliberately unaliased, so the emitted statement keeps the exact shape the
209
218
  * callers already produce (`FROM "t" WHERE id IN (…)`).
210
219
  *
@@ -214,7 +223,7 @@ function reviveRow(json) {
214
223
  function projectedDataSql(fields, bind) {
215
224
  const keys = ["id", ...fields];
216
225
  for (const k of keys) bind.push(k);
217
- return `COALESCE((SELECT json_group_object(je.key, je.value) FROM json_each(data) je WHERE je.key IN (${keys.map(() => "?").join(", ")})), '{}') AS data`;
226
+ return `COALESCE((SELECT json_group_object(je.key, CASE je.type WHEN 'true' THEN json('true') WHEN 'false' THEN json('false') ELSE je.value END) FROM json_each(data) je WHERE je.key IN (${keys.map(() => "?").join(", ")})), '{}') AS data`;
218
227
  }
219
228
  function project(row, fields) {
220
229
  const out = {};
package/dist/types.d.ts CHANGED
@@ -1,6 +1,5 @@
1
- import { RecordId } from "surrealdb";
2
- import { QueryPlan, QueryPlan as QueryPlan$1, RecordId as RecordId$1, SchemaStructure, WhereNode } from "@spooky-sync/query-builder";
3
- import { Level, Level as Level$1, Logger, LoggerOptions } from "pino";
1
+ import { QueryPlan, QueryPlan as QueryPlan$1, RecordId, SchemaStructure, WhereNode } from "@spooky-sync/query-builder";
2
+ import { Level as Level$1, LoggerOptions } from "pino";
4
3
 
5
4
  //#region src/events/index.d.ts
6
5
  /**
@@ -123,36 +122,11 @@ declare class EventSystem<E extends EventTypeMap> {
123
122
  private broadcastEvent;
124
123
  }
125
124
  //#endregion
126
- //#region src/modules/sync/events/index.d.ts
127
- declare const SyncEventTypes: {
128
- readonly QueryUpdated: "SYNC_QUERY_UPDATED";
129
- readonly RemoteDataIngested: "SYNC_REMOTE_DATA_INGESTED";
130
- readonly MutationRolledBack: "SYNC_MUTATION_ROLLED_BACK";
131
- readonly SyncHealthChanged: "SYNC_HEALTH_CHANGED";
132
- };
133
- type SyncEventTypeMap = {
134
- [SyncEventTypes.QueryUpdated]: EventDefinition<typeof SyncEventTypes.QueryUpdated, {
135
- queryId: any;
136
- localHash?: string;
137
- localArray?: RecordVersionArray;
138
- remoteHash?: string;
139
- remoteArray?: RecordVersionArray;
140
- records: Record<string, any>[];
141
- }>;
142
- [SyncEventTypes.RemoteDataIngested]: EventDefinition<typeof SyncEventTypes.RemoteDataIngested, {
143
- records: Record<string, any>[];
144
- }>;
145
- [SyncEventTypes.MutationRolledBack]: EventDefinition<typeof SyncEventTypes.MutationRolledBack, {
146
- eventType: string;
147
- recordId: string;
148
- error: string;
149
- }>;
150
- [SyncEventTypes.SyncHealthChanged]: EventDefinition<typeof SyncEventTypes.SyncHealthChanged, SyncHealth>;
151
- };
152
- type SyncEventSystem = EventSystem<SyncEventTypeMap>;
153
- //#endregion
154
- //#region src/services/logger/index.d.ts
155
- type Logger$1 = Logger;
125
+ //#region src/utils/surql.d.ts
126
+ interface SealedQuery<T = void> {
127
+ readonly sql: string;
128
+ readonly extract: (results: unknown[]) => T;
129
+ }
156
130
  //#endregion
157
131
  //#region src/services/database/events/index.d.ts
158
132
  declare const DatabaseEventTypes: {
@@ -173,12 +147,6 @@ type DatabaseEventTypeMap = {
173
147
  };
174
148
  type DatabaseEventSystem = EventSystem<DatabaseEventTypeMap>;
175
149
  //#endregion
176
- //#region src/utils/surql.d.ts
177
- interface SealedQuery<T = void> {
178
- readonly sql: string;
179
- readonly extract: (results: unknown[]) => T;
180
- }
181
- //#endregion
182
150
  //#region src/modules/devtools/storage-info.d.ts
183
151
  /** Engine-side numbers only the engine can produce (worker round-trips). */
184
152
  interface EngineStorageDiagnostics {
@@ -371,33 +339,6 @@ type LocalEngineChoice = 'surrealdb' | 'sqlite' | LocalStore;
371
339
  /** Thrown when relation decomposition nests past {@link MAX_RELATION_DEPTH} —
372
340
  * a guard against a cyclic schema producing unbounded fan-out. */
373
341
  //#endregion
374
- //#region src/modules/sync/queue/queue-up.d.ts
375
- type CreateEvent = {
376
- type: 'create';
377
- mutation_id: RecordId;
378
- record_id: RecordId;
379
- data: Record<string, unknown>;
380
- record?: Record<string, unknown>;
381
- tableName?: string;
382
- options?: PushEventOptions;
383
- };
384
- type UpdateEvent = {
385
- type: 'update';
386
- mutation_id: RecordId;
387
- record_id: RecordId;
388
- data: Record<string, unknown>;
389
- record?: Record<string, unknown>;
390
- beforeRecord?: Record<string, unknown>;
391
- options?: PushEventOptions;
392
- };
393
- type DeleteEvent = {
394
- type: 'delete';
395
- mutation_id: RecordId;
396
- record_id: RecordId;
397
- options?: PushEventOptions;
398
- };
399
- type UpEvent = CreateEvent | UpdateEvent | DeleteEvent;
400
- //#endregion
401
342
  //#region src/types.d.ts
402
343
  /**
403
344
  * A pino browser transmit object for forwarding logs to an external sink (e.g. OpenTelemetry).
@@ -448,9 +389,11 @@ type QueryTimeToLive = '1m' | '5m' | '10m' | '15m' | '20m' | '25m' | '30m' | '1h
448
389
  */
449
390
  type PreloadRefresh = 'onUse' | 'background' | 'stale';
450
391
  interface PreloadOptions {
451
- /** How to refresh when the query is already cached locally. Default `onUse`. */
392
+ /** Abort a cold preload that is still waiting for the server. */
393
+ signal?: AbortSignal;
394
+ /** @deprecated Preload is a registered query now; a warm preload never refetches by itself. Ignored. */
452
395
  refresh?: PreloadRefresh;
453
- /** For `refresh: 'stale'` — max age before a warm copy is refetched. Default `1h`. */
396
+ /** @deprecated Ignored. */
454
397
  staleTime?: QueryTimeToLive;
455
398
  }
456
399
  /**
@@ -834,16 +777,16 @@ type RecordVersionArray = Array<[string, number]>;
834
777
  interface RecordVersionDiff {
835
778
  /** List of records added. */
836
779
  added: Array<{
837
- id: RecordId$1<string>;
780
+ id: RecordId<string>;
838
781
  version: number;
839
782
  }>;
840
783
  /** List of records updated. */
841
784
  updated: Array<{
842
- id: RecordId$1<string>;
785
+ id: RecordId<string>;
843
786
  version: number;
844
787
  }>;
845
788
  /** List of record IDs removed. */
846
- removed: RecordId$1<string>[];
789
+ removed: RecordId<string>[];
847
790
  }
848
791
  /**
849
792
  * Configuration for a specific query instance.
@@ -851,7 +794,7 @@ interface RecordVersionDiff {
851
794
  */
852
795
  interface QueryConfig {
853
796
  /** The unique ID of the query config record. */
854
- id: RecordId$1<string>;
797
+ id: RecordId<string>;
855
798
  /** The SURQL query string. */
856
799
  surql: string;
857
800
  /**
@@ -878,21 +821,21 @@ interface QueryConfig {
878
821
  /**
879
822
  * Whether authoritative membership (`remoteArray`) has ever been established
880
823
  * for this query — either fetched from `_00_list_ref` this session, or read
881
- * back from the durable `_00_window` row on a cold start.
824
+ * back from the durable `_00_view` row on a cold start.
882
825
  *
883
826
  * Tri-state matters: "known and empty" must render an empty list, while
884
827
  * "never established" has to fall back to a predicate scan of the local store
885
828
  * so a query first run on this device still paints offline. A
886
829
  * `remoteArray.length === 0` check cannot tell those apart.
887
830
  *
888
- * On a cold start it is seeded from the durable `_00_window` row when that
831
+ * On a cold start it is seeded from the durable `_00_view` row when that
889
832
  * row is non-empty, or empty but `confirmed` (the server reported zero rows
890
833
  * for the query). An unconfirmed empty row is ignored, so a device poisoned
891
834
  * by an old client that mirrored unflushed reads still self-heals.
892
835
  */
893
836
  membershipKnown?: boolean;
894
837
  /**
895
- * Key of this query's durable `_00_window` membership row: a hash of
838
+ * Key of this query's durable `_00_view` membership row: a hash of
896
839
  * `{surql, params}` WITHOUT the `session::id()` salt that `id` carries, so it
897
840
  * survives a reload (which mints a new session id) and a bucket switch.
898
841
  * In-memory only.
@@ -1054,7 +997,7 @@ type QueryStatusCallback = (status: QueryStatus) => void;
1054
997
  * (bucket switch). What the bindings' `isAuthoritative()` mirrors.
1055
998
  */
1056
999
  type QueryAuthorityCallback = (known: boolean) => void;
1057
- type MutationCallback = (mutations: UpEvent[]) => void;
1000
+ type MutationCallback = (mutations: MutationEvent[]) => void;
1058
1001
  type MutationEventType = 'create' | 'update' | 'delete';
1059
1002
  /**
1060
1003
  * Represents a mutation event (create, update, delete) to be synchronized.
@@ -1063,9 +1006,9 @@ interface MutationEvent {
1063
1006
  /** Example: 'create', 'update', or 'delete'. */
1064
1007
  type: MutationEventType;
1065
1008
  /** unique id of the mutation */
1066
- mutation_id: RecordId$1<string>;
1009
+ mutation_id: RecordId<string>;
1067
1010
  /** The ID of the record being mutated. */
1068
- record_id: RecordId$1<string>;
1011
+ record_id: RecordId<string>;
1069
1012
  /** The data payload for create/update operations. */
1070
1013
  data?: any;
1071
1014
  /** The full record data (optional context). */
@@ -1114,4 +1057,4 @@ interface DebounceOptions {
1114
1057
  delay?: number;
1115
1058
  }
1116
1059
  //#endregion
1117
- export { ServerViewMeta as A, TimingPhase as B, QueryTimings as C, RecordVersionDiff as D, RecordVersionArray as E, StorageHealthStatus as F, DatabaseEventSystem as G, UpEvent as H, StoreType as I, SyncEventSystem as J, DatabaseEventTypes as K, SyncHealth as L, Sp00kyQueryResult as M, Sp00kyQueryResultPromise as N, RegistrationTimings as O, StorageHealth as P, SyncHealthConfig as R, QueryTimeToLive as S, ReconnectConfig as T, LocalStore as U, UpdateOptions as V, SealedQuery as W, EventSystem as X, EventDefinition as Y, QueryConfigRecord as _, MATERIALIZATION_SAMPLE_WINDOW as a, QueryStatus as b, MutationEvent as c, PhaseStat as d, PinoTransmit as f, QueryConfig as g, QueryAuthorityCallback as h, Level$1 as i, Sp00kyConfig as j, RunOptions as k, MutationEventType as l, PreloadRefresh as m, DebounceOptions as n, MembershipOutcome as o, PreloadOptions as p, Logger$1 as q, EventSubscriptionOptions as r, MutationCallback as s, ConnectionState as t, PersistenceClient as u, QueryHash as v, QueryUpdateCallback as w, QueryStatusCallback as x, QueryState as y, SyncHealthStatus as z };
1060
+ export { ServerViewMeta as A, TimingPhase as B, QueryTimings as C, RecordVersionDiff as D, RecordVersionArray as E, StorageHealthStatus as F, SealedQuery as G, LocalStore as H, StoreType as I, EventDefinition as K, SyncHealth as L, Sp00kyQueryResult as M, Sp00kyQueryResultPromise as N, RegistrationTimings as O, StorageHealth as P, SyncHealthConfig as R, QueryTimeToLive as S, ReconnectConfig as T, DatabaseEventSystem as U, UpdateOptions as V, DatabaseEventTypes as W, QueryConfigRecord as _, MATERIALIZATION_SAMPLE_WINDOW as a, QueryStatus as b, MutationEvent as c, PhaseStat as d, PinoTransmit as f, QueryConfig as g, QueryAuthorityCallback as h, Level$1 as i, Sp00kyConfig as j, RunOptions as k, MutationEventType as l, PreloadRefresh as m, DebounceOptions as n, MembershipOutcome as o, PreloadOptions as p, EventSystem as q, EventSubscriptionOptions as r, MutationCallback as s, ConnectionState as t, PersistenceClient as u, QueryHash as v, QueryUpdateCallback as w, QueryStatusCallback as x, QueryState as y, SyncHealthStatus as z };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spooky-sync/core",
3
- "version": "0.0.1-canary.240",
3
+ "version": "0.0.1-canary.242",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "main": "./dist/index.js",
@@ -19,7 +19,10 @@
19
19
  "dev": "tsdown --watch",
20
20
  "dev:example": "pnpm dev",
21
21
  "build": "tsdown && node scripts/check-broker-bundle.mjs",
22
- "test": "vitest"
22
+ "test": "vitest",
23
+ "test:run": "vitest run",
24
+ "test:coverage": "vitest run --coverage",
25
+ "type-check": "tsc --noEmit"
23
26
  },
24
27
  "repository": {
25
28
  "type": "git",
@@ -60,8 +63,8 @@
60
63
  }
61
64
  },
62
65
  "dependencies": {
63
- "@spooky-sync/query-builder": "0.0.1-canary.240",
64
- "@spooky-sync/ssp-wasm": "0.0.1-canary.240",
66
+ "@spooky-sync/query-builder": "0.0.1-canary.242",
67
+ "@spooky-sync/ssp-wasm": "0.0.1-canary.242",
65
68
  "@sqlite.org/sqlite-wasm": "3.53.0-build1",
66
69
  "@surrealdb/wasm": "^3.0.3",
67
70
  "blurhash": "^2.0.5",
@@ -72,6 +75,7 @@
72
75
  },
73
76
  "devDependencies": {
74
77
  "@types/node": "^22.5.2",
78
+ "@vitest/coverage-v8": "^3.2.4",
75
79
  "tsdown": "^0.12.4",
76
80
  "typescript": "^5.6.2",
77
81
  "vitest": "^3.2.0"
@@ -82,6 +82,15 @@ unsubscribe();
82
82
 
83
83
  Queries have a time-to-live. Supported values: `'1m'`, `'5m'`, `'10m'` (default), `'15m'`, `'20m'`, `'25m'`, `'30m'`, `'1h'`–`'12h'`, `'1d'`.
84
84
 
85
+ ### Preloading
86
+
87
+ `db.preload(query, { signal? })` registers a query without subscribing to it. If
88
+ the server answered this query on this device before (a durable `_00_view` row
89
+ exists) it resolves at once and the rows paint from the local store; otherwise
90
+ it resolves once the server's membership and every record are local. Nothing
91
+ to tear down: the entry is evicted a ttl after registration unless a view mounts
92
+ the same query. `refresh` / `staleTime` are accepted but ignored.
93
+
85
94
  ## Mutations
86
95
 
87
96
  Mutations are optimistic and automatically synced to the remote SurrealDB instance.
@@ -112,6 +121,15 @@ Use `debounced` option for frequent updates (e.g., live typing):
112
121
  - `key: 'recordId'` — Debounce by record ID only (latest write wins, no merge)
113
122
  - `key: 'recordId_x_fields'` — Debounce by record ID + changed fields (recommended)
114
123
 
124
+ ### Failed writes
125
+
126
+ A push the server rejects (permission, validation) is rolled back locally
127
+ (create, update and delete) and moved to the `_00_failed_mutations` tray in the
128
+ same local transaction. Network errors never do this; they retry. Inspect and
129
+ act on the tray with `db.failedMutationCount`, `db.subscribeToFailedMutations(cb)`,
130
+ `db.listFailedMutations()`, `db.retryFailedMutation(id)` (a fresh optimistic
131
+ write) and `db.discardFailedMutation(id)`.
132
+
115
133
  ## Authentication
116
134
 
117
135
  See [references/auth.md](references/auth.md) for the full auth API.
@@ -0,0 +1,82 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { RecordId } from 'surrealdb';
3
+ import { runPure } from '../testing/run-pure';
4
+ import { fakeServices } from '../testing/services';
5
+ import { buildEntry, buildState } from '../testing/build';
6
+ import * as R from '../state/reducers';
7
+ import { defaultEnv } from '../query/env';
8
+ import { authFlip, persistVerifiedUser } from './auth-flip.saga';
9
+
10
+ const env = defaultEnv({ tables: [{ name: 'user', columns: { id: {}, email_verified: {} } }] } as any);
11
+
12
+ describe('authFlip', () => {
13
+ it('sets identity and $auth before the bucket work, writes the hint, switches, rotates the salt for a new principal, persists the user row', async () => {
14
+ const released: string[] = [];
15
+ const svc = fakeServices({
16
+ 'auth.sessionAuthId': () => 'user:abc',
17
+ 'auth.access': () => 'account',
18
+ 'local.currentBucketId': () => 'anon',
19
+ 'local.beginSwitch': () => () => void released.push('gate'),
20
+ 'local.usesSurqlSchema': () => false,
21
+ 'auth.token': () => 'jwt',
22
+ 'auth.currentUser': () => ({ id: new RecordId('user', 'abc'), email_verified: true }),
23
+ });
24
+ const state = { ...buildState(), sessionId: 'old', saltUserId: null };
25
+ const out = await runPure(authFlip(env, 'user:abc'), {
26
+ state,
27
+ handlers: { service: svc.handler, 'local.query': () => [], 'local.execute': () => undefined, 'ssp.ingest': () => undefined },
28
+ });
29
+ const names = svc.names();
30
+ expect(names.slice(0, 5)).toEqual(['auth.sessionAuthId', 'auth.access', 'ssp.setSessionAuth', 'hint.write', 'local.currentBucketId']);
31
+ expect(svc.calls[3]).toEqual(['hint.write', ['abc']]);
32
+ expect(names).toContain('local.switchStore');
33
+ expect(names).toContain('crdt.setSessionId');
34
+ expect(released).toEqual(['gate']);
35
+ expect(out.state).toMatchObject({ userId: 'user:abc', saltUserId: 'user:abc', sessionId: 'salt-1', bucketId: 'abc' });
36
+ expect(out.state.versions.get('user:abc')).toBe(1);
37
+ });
38
+ it('same principal on the same bucket: no switch, no salt rotation', async () => {
39
+ const svc = fakeServices({
40
+ 'auth.sessionAuthId': () => 'user:abc',
41
+ 'auth.access': () => 'account',
42
+ 'local.currentBucketId': () => 'abc',
43
+ 'auth.currentUser': () => null,
44
+ });
45
+ const state = { ...buildState(), sessionId: 'keep', saltUserId: 'user:abc', bucketId: 'abc' };
46
+ const out = await runPure(authFlip(env, 'user:abc'), { state, handlers: { service: svc.handler } });
47
+ expect(svc.names()).not.toContain('local.switchStore');
48
+ expect(svc.names()).not.toContain('local.beginSwitch');
49
+ expect(out.state.sessionId).toBe('keep');
50
+ });
51
+ });
52
+
53
+ describe('persistVerifiedUser', () => {
54
+ it('skips when there is no verified row, an unknown table, or a bare id; writes otherwise and logs failures', async () => {
55
+ for (const row of [null, { id: 'user:x', a: 1 }, { id: new RecordId('user', 'x') }, { id: new RecordId('nope', 'x'), a: 1 }]) {
56
+ const svc = fakeServices({ 'auth.currentUser': () => row as any });
57
+ const out = await runPure(persistVerifiedUser(env), { state: buildState(), handlers: { service: svc.handler } });
58
+ expect(out.log.filter((e) => e.kind === 'local.execute')).toHaveLength(0);
59
+ }
60
+ const svc = fakeServices({ 'auth.currentUser': () => ({ id: new RecordId('user', 'x'), email_verified: true, junk: 1 }) });
61
+ const s = buildState([buildEntry({ def: { hash: 'q', tableName: 'user' } })], R.setVersions([['user:x', 4]]));
62
+ const out = await runPure(persistVerifiedUser(env), {
63
+ state: s,
64
+ handlers: {
65
+ service: svc.handler,
66
+ 'local.execute': (e: any) => expect(e.vars.content0).toEqual({ email_verified: true, _00_rv: 4 }),
67
+ 'ssp.ingest': (e: any) => expect(e.records[0].record.junk).toBeUndefined(),
68
+ },
69
+ });
70
+ expect(out.state.dirty.has('q')).toBe(true);
71
+ const failing = await runPure(persistVerifiedUser(env), {
72
+ state: s,
73
+ handlers: {
74
+ service: svc.handler,
75
+ 'local.execute': () => {
76
+ throw new Error('locked');
77
+ },
78
+ },
79
+ });
80
+ expect(failing.emitted).toEqual([expect.objectContaining({ level: 'warn' })]);
81
+ });
82
+ });
@@ -0,0 +1,62 @@
1
+ import { RecordId } from 'surrealdb';
2
+ import type { Saga } from '../kernel/saga';
3
+ import { fx } from '../kernel/effects';
4
+ import * as R from '../state/reducers';
5
+ import { bucketIdForUser } from '../modules/ref-tables';
6
+ import type { SagaEnv } from '../query/env';
7
+ import { columnsFor } from '../query/env';
8
+ import * as sql from '../query/sql';
9
+ import { encodeRecordId } from '../utils/index';
10
+ import { cleanRecord } from '../utils/parser';
11
+ import { bucketSwitch } from './bucket-switch.saga';
12
+
13
+ /**
14
+ * The signed-in principal changed (sign-in, sign-out, boot verification).
15
+ * Identity first (routing and `$auth` for the local SSP), then the bucket,
16
+ * then the salt if the principal really changed, then the verified user row.
17
+ */
18
+ export function* authFlip(env: SagaEnv, userId: string | null): Saga<void> {
19
+ const authId = (yield fx.service('auth.sessionAuthId')) as string | null;
20
+ const access = (yield fx.service('auth.access')) as string | null;
21
+ yield fx.state.update(R.setIdentity({ userId }));
22
+ yield fx.service('ssp.setSessionAuth', authId, access);
23
+ const target = bucketIdForUser(userId);
24
+ yield fx.service('hint.write', target);
25
+ yield fx.state.update(R.setIdentity({ pendingBucket: target }));
26
+ const current = (yield fx.service('local.currentBucketId')) as string;
27
+ const release = current === target ? null : ((yield fx.service('local.beginSwitch')) as () => void);
28
+ yield* bucketSwitch(env, target, release);
29
+ const saltUserId = (yield fx.state.read((s) => s.saltUserId)) as string | null;
30
+ if (authId !== saltUserId) {
31
+ const salt = (yield fx.id('salt')) as string;
32
+ yield fx.state.update(R.setIdentity({ sessionId: salt, saltUserId: authId }));
33
+ yield fx.service('crdt.setSessionId', salt);
34
+ }
35
+ yield* persistVerifiedUser(env);
36
+ }
37
+
38
+ /**
39
+ * Write the user's own row, as the server just returned it, into the local
40
+ * store so `user` queries see `email_verified` and friends without waiting
41
+ * for membership. A field refresh, not a version claim.
42
+ */
43
+ export function* persistVerifiedUser(env: SagaEnv): Saga<void> {
44
+ const row = (yield fx.service('auth.currentUser')) as Record<string, unknown> | null;
45
+ if (!row || !(row.id instanceof RecordId) || Object.keys(row).length <= 1) return;
46
+ const rid = row.id as RecordId<string>;
47
+ const table = String(rid.table);
48
+ const columns = columnsFor(env, table);
49
+ if (!columns) return;
50
+ const id = encodeRecordId(rid);
51
+ const version = ((yield fx.state.read((s) => s.versions.get(id))) as number | undefined) || 1;
52
+ const cleaned = cleanRecord(columns as never, row);
53
+ const { id: _id, ...content } = cleaned;
54
+ try {
55
+ const tx = sql.upsertBodiesTx([{ id: rid, content: { ...content, _00_rv: version } }]);
56
+ yield fx.local.execute(tx.query, tx.vars);
57
+ yield fx.ssp.ingest([{ table, op: 'UPDATE', id, record: { ...cleaned, _00_rv: version } }]);
58
+ yield fx.state.update(R.compose(R.setVersions([[id, version]]), R.markTableDirty(table)));
59
+ } catch (error) {
60
+ yield fx.emit({ type: 'log', level: 'warn', message: 'could not persist the verified user row', data: { error } });
61
+ }
62
+ }
@@ -0,0 +1,151 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { RecordId } from 'surrealdb';
3
+ import { runPure } from '../testing/run-pure';
4
+ import { fakeServices } from '../testing/services';
5
+ import { buildEntry, buildOutboxItem, buildState } from '../testing/build';
6
+ import * as R from '../state/reducers';
7
+ import { defaultEnv } from '../query/env';
8
+ import { boot, migrateWindowToView, pageHide, primeCircuit, startRemote, versionsPrimed, warmBlobs } from './boot.saga';
9
+
10
+ const env = defaultEnv({ tables: [] } as any);
11
+
12
+ describe('boot', () => {
13
+ it('solo: opens the hinted bucket, provisions, starts the SSP, restores the session, mirrors the outbox, flips localReady, hands off to the network', async () => {
14
+ const svc = fakeServices({
15
+ 'hint.read': () => 'u1',
16
+ 'local.usesSurqlSchema': () => true,
17
+ 'auth.restoreSession': async () => 'user:u1',
18
+ 'auth.sessionAuthId': () => 'user:u1',
19
+ 'auth.access': () => 'account',
20
+ });
21
+ const out = await runPure(boot(env, { sharedTabs: false }), {
22
+ handlers: { service: svc.handler, 'local.query': (e: any) => (e.sql.startsWith('SELECT count()') ? [[{ count: 1 }]] : []) },
23
+ });
24
+ expect(svc.names()).toEqual([
25
+ 'hint.read',
26
+ 'local.connect',
27
+ 'local.usesSurqlSchema',
28
+ 'migrator.provision',
29
+ 'ssp.init',
30
+ 'ssp.setPermissions',
31
+ 'auth.restoreSession',
32
+ 'auth.sessionAuthId',
33
+ 'crdt.setSessionId',
34
+ 'auth.access',
35
+ 'ssp.setSessionAuth',
36
+ 'window.attach',
37
+ 'features.init',
38
+ 'releases.init',
39
+ ]);
40
+ expect(svc.calls[1]).toEqual(['local.connect', ['u1']]);
41
+ expect(out.state).toMatchObject({ bucketId: 'u1', userId: 'user:u1', saltUserId: 'user:u1', sessionId: 'salt-1', localReady: true });
42
+ expect(out.dispatched.map((d) => d.type)).toEqual(['WarmBlobs', 'PrimeCircuit', 'LifecycleTick', 'GcTick', 'StartRemote']);
43
+ expect(svc.names()).not.toContain('remote.connect');
44
+ });
45
+ it('shared tabs: takes the role from the coordinator, or falls back to solo; schemaless engines skip provisioning; anonymous skips session auth', async () => {
46
+ const leader = fakeServices({ 'hint.read': () => null, 'tabs.start': async () => 'leader', 'local.usesSurqlSchema': () => false, 'auth.restoreSession': async () => null, 'auth.sessionAuthId': () => null });
47
+ const out = await runPure(boot(env, { sharedTabs: true }), { handlers: { service: leader.handler, 'local.query': () => [] } });
48
+ expect(out.state.tabRole).toBe('leader');
49
+ expect(out.state.bucketId).toBe('anon');
50
+ expect(leader.names()).not.toContain('local.connect');
51
+ expect(leader.names()).not.toContain('migrator.provision');
52
+ expect(leader.names()).not.toContain('ssp.setSessionAuth');
53
+ const fallback = fakeServices({
54
+ 'hint.read': () => 'x',
55
+ 'tabs.start': async () => {
56
+ throw new Error('no broker');
57
+ },
58
+ 'local.usesSurqlSchema': () => false,
59
+ 'auth.restoreSession': async () => null,
60
+ 'auth.sessionAuthId': () => null,
61
+ });
62
+ const fb = await runPure(boot(env, { sharedTabs: true }), { handlers: { service: fallback.handler, 'local.query': () => [] } });
63
+ expect(fallback.calls).toContainEqual(['local.connect', ['x']]);
64
+ expect(fb.emitted).toContainEqual(expect.objectContaining({ level: 'warn' }));
65
+ });
66
+ });
67
+
68
+ describe('migrateWindowToView', () => {
69
+ it('copies legacy rows once, skips when _00_view has rows, tolerates errors and junk', async () => {
70
+ const upserts: any[] = [];
71
+ await runPure(migrateWindowToView(), {
72
+ handlers: {
73
+ 'local.query': (e: any) =>
74
+ e.sql.startsWith('SELECT count()')
75
+ ? [[]]
76
+ : [[{ id: '_00_window:k1', ids: [['t:1', 1]], confirmed: true, updatedAt: 5 }, { id: new RecordId('_00_window', 'k2'), ids: [] }, { id: 'x', ids: 'bad' }, { ids: [] }]],
77
+ 'local.upsert': (e: any) => void upserts.push(e),
78
+ },
79
+ });
80
+ expect(upserts).toEqual([
81
+ { kind: 'local.upsert', table: '_00_view', id: new RecordId('_00_view', 'k1'), data: { ids: [['t:1', 1]], confirmed: true, updatedAt: 5 }, mode: 'replace' },
82
+ { kind: 'local.upsert', table: '_00_view', id: new RecordId('_00_view', 'k2'), data: { ids: [], confirmed: false, updatedAt: 0 }, mode: 'replace' },
83
+ ]);
84
+ const skipped = await runPure(migrateWindowToView(), { handlers: { 'local.query': () => [[{ count: 3 }]] } });
85
+ expect(skipped.log.filter((e) => e.kind === 'local.query')).toHaveLength(1);
86
+ const failing = await runPure(migrateWindowToView(), {
87
+ handlers: {
88
+ 'local.query': () => {
89
+ throw new Error('no table');
90
+ },
91
+ },
92
+ });
93
+ expect(failing.emitted).toEqual([expect.objectContaining({ level: 'debug' })]);
94
+ });
95
+ });
96
+
97
+ describe('startRemote / primeCircuit / versionsPrimed / pageHide / warmBlobs', () => {
98
+ it('startRemote tolerates connect and auth failures and starts the loops', async () => {
99
+ const svc = fakeServices({
100
+ 'remote.connect': async () => {
101
+ throw new Error('offline');
102
+ },
103
+ 'auth.init': async () => {
104
+ throw new Error('401');
105
+ },
106
+ });
107
+ const out = await runPure(startRemote(), { handlers: { service: svc.handler } });
108
+ expect(svc.names()).toEqual(['remote.connect', 'supervisor.start', 'auth.init']);
109
+ expect(out.emitted.filter((e) => e.type === 'log')).toHaveLength(2);
110
+ expect(out.dispatched.map((d) => d.type)).toEqual(['EnsureRegistered', 'LiveStart', 'PollTick', 'Drain']);
111
+ const ok = await runPure(startRemote(), { handlers: { service: fakeServices().handler } });
112
+ expect(ok.emitted).toEqual([]);
113
+ });
114
+ it('primeCircuit passes outbox ids and always flips primed', async () => {
115
+ const svc = fakeServices({ 'ssp.prime': async () => undefined });
116
+ const s = buildState([], R.outboxReplace([buildOutboxItem({ recordId: 'thing:1' })]));
117
+ const out = await runPure(primeCircuit(), { state: s, handlers: { service: svc.handler } });
118
+ expect(svc.calls).toEqual([['ssp.prime', [['thing:1']]]]);
119
+ expect(out.state.primed).toBe(true);
120
+ const failing = fakeServices({
121
+ 'ssp.prime': async () => {
122
+ throw new Error('snapshot');
123
+ },
124
+ });
125
+ const f = await runPure(primeCircuit(), { state: buildState(), handlers: { service: failing.handler } });
126
+ expect(f.state.primed).toBe(true);
127
+ expect(f.emitted).toEqual([expect.objectContaining({ level: 'warn' })]);
128
+ });
129
+ it('versionsPrimed records versions; pageHide releases registered views; warmBlobs is best-effort', async () => {
130
+ const v = await runPure(versionsPrimed([['thing:1', 3]]), { state: buildState() });
131
+ expect(v.state.versions.get('thing:1')).toBe(3);
132
+ const svc = fakeServices();
133
+ const s = buildState([buildEntry({ def: { hash: 'a' }, lifecycle: { remote: 'registered' } }), buildEntry({ def: { hash: 'b' } })]);
134
+ await runPure(pageHide(), { state: s, handlers: { service: svc.handler } });
135
+ expect(svc.calls).toEqual([['remote.releaseViews', [[new RecordId('_00_query', 'a')]]]]);
136
+ const none = fakeServices();
137
+ await runPure(pageHide(), { state: buildState(), handlers: { service: none.handler } });
138
+ expect(none.calls).toEqual([]);
139
+ const blobs = fakeServices({
140
+ 'blobs.start': async () => {
141
+ throw new Error('opfs');
142
+ },
143
+ });
144
+ const w = await runPure(warmBlobs(), { state: { ...buildState(), bucketId: 'u9' }, handlers: { service: blobs.handler } });
145
+ expect(blobs.calls).toEqual([['blobs.start', ['u9']]]);
146
+ expect(w.emitted).toEqual([expect.objectContaining({ level: 'warn' })]);
147
+ const anon = fakeServices({ 'blobs.start': async () => undefined });
148
+ await runPure(warmBlobs(), { state: buildState(), handlers: { service: anon.handler } });
149
+ expect(anon.calls).toEqual([['blobs.start', ['anon']]]);
150
+ });
151
+ });