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

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 (289) 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/types.d.ts +22 -79
  6. package/package.json +8 -4
  7. package/skills/sp00ky-core/SKILL.md +18 -0
  8. package/src/boot/auth-flip.saga.test.ts +82 -0
  9. package/src/boot/auth-flip.saga.ts +62 -0
  10. package/src/boot/boot.saga.test.ts +151 -0
  11. package/src/boot/boot.saga.ts +128 -0
  12. package/src/boot/bucket-switch.saga.test.ts +158 -0
  13. package/src/boot/bucket-switch.saga.ts +115 -0
  14. package/src/boot/preload.saga.test.ts +51 -0
  15. package/src/boot/preload.saga.ts +31 -0
  16. package/src/client/bucket-handle.test.ts +117 -0
  17. package/src/client/bucket-handle.ts +227 -0
  18. package/src/client/router.test.ts +56 -0
  19. package/src/client/router.ts +94 -0
  20. package/src/client/runtime.test.ts +192 -0
  21. package/src/client/runtime.ts +296 -0
  22. package/src/client/services.ts +349 -0
  23. package/src/client/sp00ky-client.test.ts +241 -0
  24. package/src/client/sp00ky-client.ts +482 -0
  25. package/src/index.ts +1 -0
  26. package/src/kernel/constants.test.ts +19 -0
  27. package/src/kernel/constants.ts +54 -0
  28. package/src/kernel/effects.test.ts +40 -0
  29. package/src/kernel/effects.ts +159 -0
  30. package/src/kernel/events.ts +55 -0
  31. package/src/kernel/interpreter.test.ts +116 -0
  32. package/src/kernel/interpreter.ts +143 -0
  33. package/src/kernel/saga.test.ts +89 -0
  34. package/src/kernel/saga.ts +73 -0
  35. package/src/modules/app-release/index.ts +13 -5
  36. package/src/modules/devtools/index.ts +10 -3
  37. package/src/modules/devtools/state-shape.test.ts +4 -1
  38. package/src/modules/feature-flag/index.ts +13 -5
  39. package/src/mutation/jobs.test.ts +25 -0
  40. package/src/mutation/jobs.ts +41 -0
  41. package/src/{modules/data → mutation}/mutation-id.test.ts +12 -0
  42. package/src/mutation/push.saga.test.ts +285 -0
  43. package/src/mutation/push.saga.ts +215 -0
  44. package/src/mutation/rows.test.ts +201 -0
  45. package/src/mutation/rows.ts +377 -0
  46. package/src/mutation/tray.saga.test.ts +63 -0
  47. package/src/mutation/tray.saga.ts +48 -0
  48. package/src/mutation/write.saga.test.ts +163 -0
  49. package/src/mutation/write.saga.ts +166 -0
  50. package/src/query/env.test.ts +23 -0
  51. package/src/query/env.ts +56 -0
  52. package/src/query/fetch.saga.test.ts +156 -0
  53. package/src/query/fetch.saga.ts +109 -0
  54. package/src/query/hash.test.ts +14 -0
  55. package/src/query/hash.ts +18 -0
  56. package/src/{modules/data → query}/id-set-plan.test.ts +12 -2
  57. package/src/query/lifecycle.saga.test.ts +122 -0
  58. package/src/query/lifecycle.saga.ts +101 -0
  59. package/src/query/materialize.saga.test.ts +82 -0
  60. package/src/query/materialize.saga.ts +75 -0
  61. package/src/query/materialize.test.ts +50 -0
  62. package/src/query/materialize.ts +44 -0
  63. package/src/query/membership.saga.test.ts +246 -0
  64. package/src/query/membership.saga.ts +208 -0
  65. package/src/query/membership.test.ts +118 -0
  66. package/src/query/membership.ts +158 -0
  67. package/src/query/register.saga.test.ts +251 -0
  68. package/src/query/register.saga.ts +190 -0
  69. package/src/query/render-set.test.ts +36 -0
  70. package/src/query/render-set.ts +60 -0
  71. package/src/query/sql.test.ts +62 -0
  72. package/src/query/sql.ts +79 -0
  73. package/src/{modules/data → query}/window-query.test.ts +18 -0
  74. package/src/{modules/data → query}/window-query.ts +2 -1
  75. package/src/services/database/connection-supervisor.test.ts +29 -0
  76. package/src/services/database/connection-supervisor.ts +7 -0
  77. package/src/services/database/database.responses.test.ts +43 -0
  78. package/src/services/database/database.ts +25 -10
  79. package/src/services/database/local-migrator.ts +2 -2
  80. package/src/services/database/plan-render.test.ts +1 -1
  81. package/src/services/database/sqlite-cache-engine.test.ts +4 -3
  82. package/src/services/database/sqlite-cache-engine.ts +2 -11
  83. package/src/services/database/sqlite-devtools-queries.integration.test.ts +1 -1
  84. package/src/services/tabs/protocol.ts +7 -3
  85. package/src/sp00ky.ts +5 -1854
  86. package/src/state/client-state.ts +184 -0
  87. package/src/state/lifecycle.test.ts +118 -0
  88. package/src/state/lifecycle.ts +86 -0
  89. package/src/state/reducers.test.ts +280 -0
  90. package/src/state/reducers.ts +476 -0
  91. package/src/state/selectors.test.ts +149 -0
  92. package/src/state/selectors.ts +184 -0
  93. package/src/sync/connection.saga.test.ts +86 -0
  94. package/src/sync/connection.saga.ts +85 -0
  95. package/src/sync/live.saga.test.ts +69 -0
  96. package/src/sync/live.saga.ts +49 -0
  97. package/src/sync/policy.health.test.ts +56 -0
  98. package/src/{modules/sync/utils.test.ts → sync/policy.test.ts} +31 -3
  99. package/src/{modules/sync/utils.ts → sync/policy.ts} +67 -5
  100. package/src/sync/poll.saga.test.ts +51 -0
  101. package/src/sync/poll.saga.ts +36 -0
  102. package/src/sync/tabs.saga.test.ts +74 -0
  103. package/src/sync/tabs.saga.ts +92 -0
  104. package/src/testing/adapters.ts +133 -0
  105. package/src/testing/build.ts +61 -0
  106. package/src/testing/fake-services.ts +34 -0
  107. package/src/testing/run-pure.test.ts +92 -0
  108. package/src/testing/run-pure.ts +128 -0
  109. package/src/testing/services.ts +17 -0
  110. package/src/types.ts +8 -7
  111. package/src/utils/utils.test.ts +2 -1
  112. package/vitest.config.ts +48 -0
  113. package/coverage/lcov-report/base.css +0 -224
  114. package/coverage/lcov-report/block-navigation.js +0 -87
  115. package/coverage/lcov-report/favicon.png +0 -0
  116. package/coverage/lcov-report/index.html +0 -476
  117. package/coverage/lcov-report/prettify.css +0 -1
  118. package/coverage/lcov-report/prettify.js +0 -2
  119. package/coverage/lcov-report/sort-arrow-sprite.png +0 -0
  120. package/coverage/lcov-report/sorter.js +0 -210
  121. package/coverage/lcov-report/src/boot/auth-flip.saga.ts.html +0 -271
  122. package/coverage/lcov-report/src/boot/boot.saga.ts.html +0 -469
  123. package/coverage/lcov-report/src/boot/bucket-switch.saga.ts.html +0 -430
  124. package/coverage/lcov-report/src/boot/index.html +0 -161
  125. package/coverage/lcov-report/src/boot/preload.saga.ts.html +0 -178
  126. package/coverage/lcov-report/src/client/bucket-handle.ts.html +0 -766
  127. package/coverage/lcov-report/src/client/index.html +0 -161
  128. package/coverage/lcov-report/src/client/router.ts.html +0 -367
  129. package/coverage/lcov-report/src/client/runtime.ts.html +0 -940
  130. package/coverage/lcov-report/src/client/sp00ky-client.ts.html +0 -1531
  131. package/coverage/lcov-report/src/events/index.html +0 -116
  132. package/coverage/lcov-report/src/events/index.ts.html +0 -877
  133. package/coverage/lcov-report/src/index.html +0 -146
  134. package/coverage/lcov-report/src/index.ts.html +0 -202
  135. package/coverage/lcov-report/src/kernel/constants.ts.html +0 -247
  136. package/coverage/lcov-report/src/kernel/effects.ts.html +0 -562
  137. package/coverage/lcov-report/src/kernel/events.ts.html +0 -250
  138. package/coverage/lcov-report/src/kernel/index.html +0 -176
  139. package/coverage/lcov-report/src/kernel/interpreter.ts.html +0 -514
  140. package/coverage/lcov-report/src/kernel/saga.ts.html +0 -304
  141. package/coverage/lcov-report/src/modules/app-release/index.html +0 -116
  142. package/coverage/lcov-report/src/modules/app-release/index.ts.html +0 -712
  143. package/coverage/lcov-report/src/modules/auth/events/index.html +0 -116
  144. package/coverage/lcov-report/src/modules/auth/events/index.ts.html +0 -142
  145. package/coverage/lcov-report/src/modules/auth/index.html +0 -116
  146. package/coverage/lcov-report/src/modules/auth/index.ts.html +0 -1225
  147. package/coverage/lcov-report/src/modules/crdt/crdt-field.ts.html +0 -967
  148. package/coverage/lcov-report/src/modules/crdt/index.html +0 -146
  149. package/coverage/lcov-report/src/modules/crdt/index.ts.html +0 -1474
  150. package/coverage/lcov-report/src/modules/crdt/loro-loader.ts.html +0 -160
  151. package/coverage/lcov-report/src/modules/devtools/flags.ts.html +0 -1132
  152. package/coverage/lcov-report/src/modules/devtools/index.html +0 -161
  153. package/coverage/lcov-report/src/modules/devtools/index.ts.html +0 -2413
  154. package/coverage/lcov-report/src/modules/devtools/storage-info.ts.html +0 -589
  155. package/coverage/lcov-report/src/modules/devtools/versions.ts.html +0 -415
  156. package/coverage/lcov-report/src/modules/feature-flag/index.html +0 -116
  157. package/coverage/lcov-report/src/modules/feature-flag/index.ts.html +0 -1033
  158. package/coverage/lcov-report/src/modules/index.html +0 -116
  159. package/coverage/lcov-report/src/modules/ref-tables.ts.html +0 -349
  160. package/coverage/lcov-report/src/mutation/index.html +0 -191
  161. package/coverage/lcov-report/src/mutation/jobs.ts.html +0 -208
  162. package/coverage/lcov-report/src/mutation/mutation-id.ts.html +0 -190
  163. package/coverage/lcov-report/src/mutation/push.saga.ts.html +0 -730
  164. package/coverage/lcov-report/src/mutation/rows.ts.html +0 -1216
  165. package/coverage/lcov-report/src/mutation/tray.saga.ts.html +0 -229
  166. package/coverage/lcov-report/src/mutation/write.saga.ts.html +0 -583
  167. package/coverage/lcov-report/src/otel/index.html +0 -116
  168. package/coverage/lcov-report/src/otel/index.ts.html +0 -466
  169. package/coverage/lcov-report/src/query/env.ts.html +0 -253
  170. package/coverage/lcov-report/src/query/fetch.saga.ts.html +0 -412
  171. package/coverage/lcov-report/src/query/hash.ts.html +0 -139
  172. package/coverage/lcov-report/src/query/index.html +0 -281
  173. package/coverage/lcov-report/src/query/lifecycle.saga.ts.html +0 -388
  174. package/coverage/lcov-report/src/query/materialize.saga.ts.html +0 -310
  175. package/coverage/lcov-report/src/query/materialize.ts.html +0 -217
  176. package/coverage/lcov-report/src/query/membership.saga.ts.html +0 -709
  177. package/coverage/lcov-report/src/query/membership.ts.html +0 -559
  178. package/coverage/lcov-report/src/query/register.saga.ts.html +0 -655
  179. package/coverage/lcov-report/src/query/render-set.ts.html +0 -265
  180. package/coverage/lcov-report/src/query/sql.ts.html +0 -322
  181. package/coverage/lcov-report/src/query/window-query.ts.html +0 -670
  182. package/coverage/lcov-report/src/services/blobs/blob-cache.ts.html +0 -1894
  183. package/coverage/lcov-report/src/services/blobs/blob-manifest.ts.html +0 -766
  184. package/coverage/lcov-report/src/services/blobs/blob-store.ts.html +0 -1162
  185. package/coverage/lcov-report/src/services/blobs/index.html +0 -161
  186. package/coverage/lcov-report/src/services/blobs/index.ts.html +0 -295
  187. package/coverage/lcov-report/src/services/database/cache-engine.ts.html +0 -664
  188. package/coverage/lcov-report/src/services/database/connection-supervisor.ts.html +0 -1330
  189. package/coverage/lcov-report/src/services/database/database.ts.html +0 -658
  190. package/coverage/lcov-report/src/services/database/engine-factory.ts.html +0 -184
  191. package/coverage/lcov-report/src/services/database/errors.ts.html +0 -187
  192. package/coverage/lcov-report/src/services/database/events/index.html +0 -116
  193. package/coverage/lcov-report/src/services/database/events/index.ts.html +0 -184
  194. package/coverage/lcov-report/src/services/database/index.html +0 -386
  195. package/coverage/lcov-report/src/services/database/index.ts.html +0 -121
  196. package/coverage/lcov-report/src/services/database/local-migrator.ts.html +0 -703
  197. package/coverage/lcov-report/src/services/database/local.ts.html +0 -1633
  198. package/coverage/lcov-report/src/services/database/plan-render.ts.html +0 -409
  199. package/coverage/lcov-report/src/services/database/relation-resolver.ts.html +0 -682
  200. package/coverage/lcov-report/src/services/database/remote.ts.html +0 -1036
  201. package/coverage/lcov-report/src/services/database/sqlite-cache-engine.ts.html +0 -4132
  202. package/coverage/lcov-report/src/services/database/sqlite-lock-verify.ts.html +0 -220
  203. package/coverage/lcov-report/src/services/database/sqlite-open.ts.html +0 -577
  204. package/coverage/lcov-report/src/services/database/sqlite-plan-sql.ts.html +0 -499
  205. package/coverage/lcov-report/src/services/database/sqlite-select.ts.html +0 -478
  206. package/coverage/lcov-report/src/services/database/sqlite-transport.ts.html +0 -757
  207. package/coverage/lcov-report/src/services/database/surql-translate.ts.html +0 -1333
  208. package/coverage/lcov-report/src/services/database/surreal-cache-engine.ts.html +0 -568
  209. package/coverage/lcov-report/src/services/logger/index.html +0 -116
  210. package/coverage/lcov-report/src/services/logger/index.ts.html +0 -154
  211. package/coverage/lcov-report/src/services/persistence/index.html +0 -146
  212. package/coverage/lcov-report/src/services/persistence/localstorage.ts.html +0 -163
  213. package/coverage/lcov-report/src/services/persistence/resilient.ts.html +0 -208
  214. package/coverage/lcov-report/src/services/persistence/surrealdb.ts.html +0 -271
  215. package/coverage/lcov-report/src/services/stream-processor/index.html +0 -146
  216. package/coverage/lcov-report/src/services/stream-processor/index.ts.html +0 -3313
  217. package/coverage/lcov-report/src/services/stream-processor/permissions.ts.html +0 -244
  218. package/coverage/lcov-report/src/services/stream-processor/wasm-types.ts.html +0 -346
  219. package/coverage/lcov-report/src/services/tabs/broker-client.ts.html +0 -934
  220. package/coverage/lcov-report/src/services/tabs/coordinator.ts.html +0 -1984
  221. package/coverage/lcov-report/src/services/tabs/index.html +0 -191
  222. package/coverage/lcov-report/src/services/tabs/leader-locks.ts.html +0 -310
  223. package/coverage/lcov-report/src/services/tabs/protocol.ts.html +0 -871
  224. package/coverage/lcov-report/src/services/tabs/support.ts.html +0 -193
  225. package/coverage/lcov-report/src/services/tabs/tabs-broker-worker.ts.html +0 -2005
  226. package/coverage/lcov-report/src/sp00ky.ts.html +0 -100
  227. package/coverage/lcov-report/src/state/client-state.ts.html +0 -637
  228. package/coverage/lcov-report/src/state/index.html +0 -161
  229. package/coverage/lcov-report/src/state/lifecycle.ts.html +0 -343
  230. package/coverage/lcov-report/src/state/reducers.ts.html +0 -1471
  231. package/coverage/lcov-report/src/state/selectors.ts.html +0 -637
  232. package/coverage/lcov-report/src/sync/connection.saga.ts.html +0 -343
  233. package/coverage/lcov-report/src/sync/index.html +0 -176
  234. package/coverage/lcov-report/src/sync/live.saga.ts.html +0 -232
  235. package/coverage/lcov-report/src/sync/policy.ts.html +0 -1606
  236. package/coverage/lcov-report/src/sync/poll.saga.ts.html +0 -193
  237. package/coverage/lcov-report/src/sync/tabs.saga.ts.html +0 -361
  238. package/coverage/lcov-report/src/types.ts.html +0 -2419
  239. package/coverage/lcov-report/src/utils/blurhash.ts.html +0 -355
  240. package/coverage/lcov-report/src/utils/error-classification.ts.html +0 -244
  241. package/coverage/lcov-report/src/utils/index.html +0 -191
  242. package/coverage/lcov-report/src/utils/index.ts.html +0 -820
  243. package/coverage/lcov-report/src/utils/parser.ts.html +0 -313
  244. package/coverage/lcov-report/src/utils/semver.ts.html +0 -175
  245. package/coverage/lcov-report/src/utils/surql.ts.html +0 -667
  246. package/coverage/lcov.info +0 -19079
  247. package/src/modules/cache/cache.relay.test.ts +0 -95
  248. package/src/modules/cache/index.ts +0 -361
  249. package/src/modules/cache/types.ts +0 -19
  250. package/src/modules/data/data.activity.test.ts +0 -109
  251. package/src/modules/data/data.authority.test.ts +0 -122
  252. package/src/modules/data/data.heartbeat-wake.test.ts +0 -115
  253. package/src/modules/data/data.hydration.test.ts +0 -142
  254. package/src/modules/data/data.membership.test.ts +0 -623
  255. package/src/modules/data/data.notify-table.test.ts +0 -41
  256. package/src/modules/data/data.pending-ids.test.ts +0 -199
  257. package/src/modules/data/data.rebind.test.ts +0 -170
  258. package/src/modules/data/data.rematerialize.test.ts +0 -114
  259. package/src/modules/data/data.run.test.ts +0 -113
  260. package/src/modules/data/data.settled-writes.test.ts +0 -235
  261. package/src/modules/data/data.status.test.ts +0 -249
  262. package/src/modules/data/data.test.ts +0 -58
  263. package/src/modules/data/index.ts +0 -2745
  264. package/src/modules/sync/engine.ts +0 -250
  265. package/src/modules/sync/events/index.ts +0 -84
  266. package/src/modules/sync/index.ts +0 -3
  267. package/src/modules/sync/queue/index.ts +0 -2
  268. package/src/modules/sync/queue/queue-down.test.ts +0 -228
  269. package/src/modules/sync/queue/queue-down.ts +0 -184
  270. package/src/modules/sync/queue/queue-up.forwarded.test.ts +0 -164
  271. package/src/modules/sync/queue/queue-up.ts +0 -407
  272. package/src/modules/sync/scheduler.pause.test.ts +0 -109
  273. package/src/modules/sync/scheduler.retry.test.ts +0 -237
  274. package/src/modules/sync/scheduler.ts +0 -286
  275. package/src/modules/sync/sync.cleanup.test.ts +0 -116
  276. package/src/modules/sync/sync.health.test.ts +0 -167
  277. package/src/modules/sync/sync.heartbeat.test.ts +0 -80
  278. package/src/modules/sync/sync.list-ref-meta.test.ts +0 -193
  279. package/src/modules/sync/sync.live-removal.test.ts +0 -289
  280. package/src/modules/sync/sync.reconnect.test.ts +0 -145
  281. package/src/modules/sync/sync.rematerialize-after-fetch.test.ts +0 -93
  282. package/src/modules/sync/sync.settled-poll.test.ts +0 -58
  283. package/src/modules/sync/sync.subquery.test.ts +0 -82
  284. package/src/modules/sync/sync.tabs.test.ts +0 -249
  285. package/src/modules/sync/sync.ts +0 -2626
  286. package/src/sp00ky.auth-order.test.ts +0 -112
  287. package/src/sp00ky.init-query.test.ts +0 -183
  288. package/src/sp00ky.local-first.test.ts +0 -60
  289. /package/src/{modules/data → mutation}/mutation-id.ts +0 -0
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.241",
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.241",
67
+ "@spooky-sync/ssp-wasm": "0.0.1-canary.241",
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
+ });