@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
@@ -1,407 +0,0 @@
1
- import { RecordId } from 'surrealdb';
2
- import type { LocalStore } from '../../../services/database/index';
3
- import type { SyncQueueEventSystem } from '../events/index';
4
- import { createSyncQueueEventSystem, SyncQueueEventTypes } from '../events/index';
5
- import {
6
- parseRecordIdString,
7
- extractTablePart,
8
- classifySyncError,
9
- encodeRecordId,
10
- } from '../../../utils/index';
11
- import type { Logger } from '../../../services/logger/index';
12
- import type { PushEventOptions } from '../../../events/index';
13
-
14
- export type CreateEvent = {
15
- type: 'create';
16
- mutation_id: RecordId;
17
- record_id: RecordId;
18
- data: Record<string, unknown>;
19
- record?: Record<string, unknown>;
20
- tableName?: string;
21
- options?: PushEventOptions;
22
- };
23
-
24
- export type UpdateEvent = {
25
- type: 'update';
26
- mutation_id: RecordId;
27
- record_id: RecordId;
28
- data: Record<string, unknown>;
29
- record?: Record<string, unknown>;
30
- beforeRecord?: Record<string, unknown>;
31
- options?: PushEventOptions;
32
- };
33
-
34
- export type DeleteEvent = {
35
- type: 'delete';
36
- mutation_id: RecordId;
37
- record_id: RecordId;
38
- options?: PushEventOptions;
39
- };
40
-
41
- export type UpEvent = CreateEvent | UpdateEvent | DeleteEvent;
42
-
43
- export type RollbackCallback = (event: UpEvent, error: Error) => Promise<void>;
44
-
45
- /**
46
- * A pending mutation that can never be sent, so it was discarded instead of
47
- * being retried forever at the head of the queue.
48
- */
49
- export type DroppedMutation = {
50
- mutationId: string;
51
- recordId?: string;
52
- mutationType?: string;
53
- reason: string;
54
- };
55
-
56
- export type DroppedCallback = (dropped: DroppedMutation) => void;
57
-
58
- export class UpQueue {
59
- private queue: UpEvent[] = [];
60
- private _events: SyncQueueEventSystem;
61
- private logger: Logger;
62
- private debouncedMutations: Map<
63
- string,
64
- { timer: any; firstBeforeRecord?: Record<string, unknown> }
65
- >;
66
-
67
- get events(): SyncQueueEventSystem {
68
- return this._events;
69
- }
70
-
71
- constructor(
72
- private local: LocalStore,
73
- logger: Logger,
74
- private onDropped?: DroppedCallback
75
- ) {
76
- this._events = createSyncQueueEventSystem();
77
- this.logger = logger.child({ service: 'UpQueue' });
78
- this.debouncedMutations = new Map();
79
- }
80
-
81
- /**
82
- * Discard an outbox row that can never be replayed and report it.
83
- *
84
- * Silently skipping such a row leaves it in the store to be re-read on every
85
- * boot; leaving it QUEUED is worse, since `next()` re-queues it on failure and
86
- * one unsendable row then blocks every later mutation for the whole app. A
87
- * lost write must also be loud: this is the only signal a caller gets.
88
- */
89
- private async discardUnreplayable(row: any, reason: string): Promise<void> {
90
- const mutationId = typeof row?.id === 'string' ? row.id : encodeRecordId(row?.id);
91
- this.logger.error(
92
- {
93
- mutationId,
94
- recordId: row?.recordId,
95
- mutationType: row?.mutationType,
96
- reason,
97
- Category: 'sp00ky-client::UpQueue::discardUnreplayable',
98
- },
99
- 'Discarding an unsendable pending mutation'
100
- );
101
- try {
102
- await this.local.query(`DELETE $mutation_id`, {
103
- mutation_id: parseStoredRecordId(mutationId),
104
- });
105
- } catch (error) {
106
- this.logger.error(
107
- { error, mutationId, Category: 'sp00ky-client::UpQueue::discardUnreplayable' },
108
- 'Failed to delete an unsendable pending mutation'
109
- );
110
- }
111
- this.onDropped?.({
112
- mutationId,
113
- recordId: row?.recordId,
114
- mutationType: row?.mutationType,
115
- reason,
116
- });
117
- }
118
-
119
- get size(): number {
120
- return this.queue.length;
121
- }
122
-
123
- push(event: UpEvent) {
124
- if (event.options?.debounced) {
125
- const { key, delay } = event.options.debounced;
126
- this.handleDebouncedMutation(event, key, delay);
127
- return;
128
- }
129
- this.addToQueue(event);
130
- }
131
-
132
- private addToQueue(event: UpEvent) {
133
- this.queue.push(event);
134
- this._events.addEvent({
135
- type: SyncQueueEventTypes.MutationEnqueued,
136
- payload: { queueSize: this.queue.length },
137
- });
138
- }
139
-
140
- private handleDebouncedMutation(event: UpEvent, key: string, delay: number) {
141
- const existing = this.debouncedMutations.get(key);
142
- let firstBeforeRecord: Record<string, unknown> | undefined;
143
-
144
- if (existing) {
145
- clearTimeout(existing.timer);
146
- // Preserve the beforeRecord from the first event in the debounce sequence
147
- firstBeforeRecord = existing.firstBeforeRecord;
148
- } else if (event.type === 'update') {
149
- firstBeforeRecord = event.beforeRecord;
150
- }
151
-
152
- const timer = setTimeout(() => {
153
- this.debouncedMutations.delete(key);
154
- // Attach the first beforeRecord to the final debounced event
155
- if (firstBeforeRecord && event.type === 'update') {
156
- event.beforeRecord = firstBeforeRecord;
157
- }
158
- this.addToQueue(event);
159
- }, delay);
160
-
161
- this.debouncedMutations.set(key, { timer, firstBeforeRecord });
162
- }
163
-
164
- /**
165
- * Cancel all pending debounce timers WITHOUT enqueueing their events. Used
166
- * on local-bucket switches: the mutation's `_00_pending_mutations` row was
167
- * already persisted at mutation time, so dropping the in-memory push only
168
- * defers it to that bucket's next `loadFromDatabase` — it must NOT be pushed
169
- * now, the remote session already belongs to the next user.
170
- */
171
- clearDebounceTimers(): void {
172
- for (const { timer } of this.debouncedMutations.values()) {
173
- clearTimeout(timer);
174
- }
175
- this.debouncedMutations.clear();
176
- }
177
-
178
- /**
179
- * @param onSettled Reports a mutation the server ACCEPTED, after its outbox
180
- * row is gone. Deliberately not called on the rollback path: a rejected
181
- * mutation must stop being rendered immediately, while an accepted one has
182
- * to stay visible until its membership arrives (see
183
- * `DataModule.noteWriteSettled`).
184
- */
185
- async next(
186
- fn: (event: UpEvent) => Promise<void>,
187
- onRollback?: RollbackCallback,
188
- onSettled?: (event: UpEvent) => void
189
- ): Promise<void> {
190
- const event = this.queue.shift();
191
- if (event) {
192
- try {
193
- await fn(event);
194
- } catch (error) {
195
- const errorType = classifySyncError(error);
196
-
197
- if (errorType === 'network') {
198
- this.logger.error(
199
- { error, event, Category: 'sp00ky-client::UpQueue::next' },
200
- 'Network error processing mutation, re-queuing'
201
- );
202
- this.queue.unshift(event);
203
- throw error;
204
- }
205
-
206
- // Application error — rollback instead of re-queuing
207
- this.logger.error(
208
- { error, event, Category: 'sp00ky-client::UpQueue::next' },
209
- 'Application error processing mutation, rolling back'
210
- );
211
- try {
212
- await this.removeEventFromDatabase(event.mutation_id);
213
- } catch (removeError) {
214
- this.logger.error(
215
- { error: removeError, event, Category: 'sp00ky-client::UpQueue::next' },
216
- 'Failed to remove rolled-back mutation from database'
217
- );
218
- }
219
- if (onRollback) {
220
- try {
221
- await onRollback(event, error instanceof Error ? error : new Error(String(error)));
222
- } catch (rollbackError) {
223
- this.logger.error(
224
- { error: rollbackError, event, Category: 'sp00ky-client::UpQueue::next' },
225
- 'Rollback handler failed'
226
- );
227
- }
228
- }
229
- this._events.addEvent({
230
- type: SyncQueueEventTypes.MutationDequeued,
231
- payload: { queueSize: this.queue.length },
232
- });
233
- return;
234
- }
235
- try {
236
- await this.removeEventFromDatabase(event.mutation_id);
237
- } catch (error) {
238
- this.logger.error(
239
- { error, event, Category: 'sp00ky-client::UpQueue::next' },
240
- 'Failed to remove mutation from database after successful processing'
241
- );
242
- }
243
- // Report AFTER the outbox row is gone: that delete is exactly what drops
244
- // the row out of the render set, so this is the moment the grace window
245
- // has to start covering.
246
- if (onSettled) {
247
- try {
248
- onSettled(event);
249
- } catch (error) {
250
- this.logger.error(
251
- { error, event, Category: 'sp00ky-client::UpQueue::next' },
252
- 'Settled-write handler failed'
253
- );
254
- }
255
- }
256
- this._events.addEvent({
257
- type: SyncQueueEventTypes.MutationDequeued,
258
- payload: { queueSize: this.queue.length },
259
- });
260
- }
261
- }
262
-
263
- async removeEventFromDatabase(mutation_id: RecordId) {
264
- return this.local.query(`DELETE $mutation_id`, { mutation_id });
265
- }
266
-
267
- /**
268
- * Shared-tabs: a follower committed an outbox row into the shared store and
269
- * notified the leader; enqueue exactly that row. Idempotent: a replayed
270
- * notify (failover) or a row already queued is a no-op, and a row that never
271
- * committed simply is not found (the follower's own write promise already
272
- * rejected in that case).
273
- */
274
- async enqueueFromDatabase(mutationId: string): Promise<void> {
275
- if (this.queue.some((e) => encodeUpEventId(e) === mutationId)) return;
276
- try {
277
- // ARRAY param, matching SyncEngine's `SELECT * FROM $idsToFetch`. A bare
278
- // `FROM $singleRecordId` looks fine against SurrealDB but the SQLite
279
- // engine lowers any `FROM $param` to `selectByIds` and calls `.map` on
280
- // the param (surql-translate.ts, SqliteCacheEngine.selectByIds), so a
281
- // single RecordId threw. The throw landed in the catch below, which logs
282
- // at `error` — invisible to an app running `logLevel: 'fatal'` — so every
283
- // forwarded mutation was silently dropped: the follower's optimistic
284
- // write stuck locally, was never pushed, and the next down-sync reverted it.
285
- const [records] = await this.local.query<any>(`SELECT * FROM $mutation_ids`, {
286
- mutation_ids: [parseRecordIdString(mutationId)],
287
- });
288
- const row = Array.isArray(records) ? records[0] : undefined;
289
- if (!row) return;
290
- const event = rowToUpEvent(row, this.logger);
291
- if (event) {
292
- this.addToQueue(event);
293
- return;
294
- }
295
- await this.discardUnreplayable(row, 'forwarded mutation is not replayable');
296
- } catch (error) {
297
- this.logger.error(
298
- { error, mutationId, Category: 'sp00ky-client::UpQueue::enqueueFromDatabase' },
299
- 'Failed to load a forwarded mutation'
300
- );
301
- this.onDropped?.({
302
- mutationId,
303
- reason: error instanceof Error ? error.message : String(error),
304
- });
305
- }
306
- }
307
-
308
- async loadFromDatabase() {
309
- try {
310
- // ORDER BY id: mutation ids are zero-padded-timestamp-prefixed (see
311
- // modules/data/mutation-id.ts), so lexicographic id order IS chronological
312
- // order. (The previous created_at was never a defined field on this
313
- // table, so the old ordering was undefined.)
314
- const [records] = await this.local.query<any>(
315
- `SELECT * FROM _00_pending_mutations ORDER BY id ASC`
316
- );
317
-
318
- const loaded: UpEvent[] = [];
319
- const unreplayable: any[] = [];
320
- for (const row of records as any[]) {
321
- const event = rowToUpEvent(row, this.logger);
322
- if (event) loaded.push(event);
323
- else unreplayable.push(row);
324
- }
325
- this.queue = loaded;
326
- // Drop them AFTER the queue is populated: one unsendable row must not
327
- // stop the rest of the backlog from draining, and leaving it in the store
328
- // would just re-poison the next boot.
329
- for (const row of unreplayable) {
330
- await this.discardUnreplayable(row, 'pending mutation is not replayable');
331
- }
332
- } catch (error) {
333
- this.logger.error(
334
- { error, Category: 'sp00ky-client::UpQueue::loadFromDatabase' },
335
- 'Failed to load pending mutations from database'
336
- );
337
- }
338
- }
339
- }
340
-
341
- /** The stable string id of an event's outbox row. */
342
- function encodeUpEventId(event: UpEvent): string {
343
- return encodeRecordId(event.mutation_id);
344
- }
345
-
346
- /**
347
- * Parse a record id READ BACK FROM THE STORE, stripping SurrealDB's `⟨⟩`
348
- * escaping.
349
- *
350
- * Outbox ids contain `_` and `-`, so the store hands them back in escaped
351
- * display form (`_00_pending_mutations:⟨1785…_0005_c960…⟩`). `parseRecordIdString`
352
- * keeps that verbatim, and re-encoding then escapes the brackets AGAIN, so a
353
- * `DELETE $mutation_id` built from a stored id targets
354
- * `_00_pending_mutations:⟨⟨1785…\⟩⟩` and matches nothing. Every mutation
355
- * replayed from the store was therefore un-deletable: its row survived a
356
- * SUCCESSFUL push and got re-sent on the next boot.
357
- */
358
- function parseStoredRecordId(id: string): RecordId<string> {
359
- const [table, ...rest] = id.split(':');
360
- let raw = rest.join(':');
361
- if (raw.startsWith('⟨') && raw.endsWith('⟩')) raw = raw.slice(1, -1);
362
- return new RecordId(table, raw);
363
- }
364
-
365
- /** Materialize one `_00_pending_mutations` row into an UpEvent. */
366
- function rowToUpEvent(r: any, logger: Logger): UpEvent | null {
367
- switch (r.mutationType) {
368
- case 'create':
369
- // `processUpEvent` does `Object.keys(event.data)`, so a create with no
370
- // payload throws before it reaches the network and can never succeed.
371
- // Rows written before the create branch of `surql.createMutation`
372
- // persisted `data` are exactly that, so refuse them here instead of
373
- // queueing a guaranteed failure.
374
- if (r.data === undefined || r.data === null) return null;
375
- return {
376
- type: 'create',
377
- mutation_id: parseStoredRecordId(r.id),
378
- record_id: parseRecordIdString(r.recordId),
379
- data: r.data,
380
- tableName: extractTablePart(r.recordId),
381
- };
382
- case 'update':
383
- return {
384
- type: 'update',
385
- mutation_id: parseStoredRecordId(r.id),
386
- record_id: parseRecordIdString(r.recordId),
387
- data: r.data,
388
- beforeRecord: r.beforeRecord,
389
- };
390
- case 'delete':
391
- return {
392
- type: 'delete',
393
- mutation_id: parseStoredRecordId(r.id),
394
- record_id: parseRecordIdString(r.recordId),
395
- };
396
- default:
397
- logger.warn(
398
- {
399
- mutationType: r.mutationType,
400
- record: r,
401
- Category: 'sp00ky-client::UpQueue::rowToUpEvent',
402
- },
403
- 'Unknown mutation type'
404
- );
405
- return null;
406
- }
407
- }
@@ -1,109 +0,0 @@
1
- import { describe, it, expect, vi } from 'vitest';
2
- import { SyncScheduler } from './scheduler';
3
- import type { UpQueue, DownQueue, UpEvent } from './queue/index';
4
-
5
- // `SyncScheduler.pause()` is the drain step of a local-bucket switch: it must
6
- // (a) let an IN-FLIGHT queue item finish — including its outbox-row delete,
7
- // which has to land in the old bucket — and only then resolve, and (b) refuse
8
- // to start new rounds until `resume()`. The pause point is between items.
9
-
10
- const silentLogger = {
11
- child: () => silentLogger,
12
- debug: () => {},
13
- info: () => {},
14
- warn: () => {},
15
- error: () => {},
16
- } as any;
17
-
18
- function makeQueues(items: UpEvent[]) {
19
- const upQueue = {
20
- queue: [...items],
21
- get size() {
22
- return this.queue.length;
23
- },
24
- events: { subscribe: () => {} },
25
- loadFromDatabase: async () => {},
26
- async next(fn: (event: UpEvent) => Promise<void>) {
27
- const event = this.queue.shift();
28
- if (event) await fn(event);
29
- },
30
- } as unknown as UpQueue & { queue: UpEvent[] };
31
-
32
- const downQueue = {
33
- queue: [] as unknown[],
34
- get size() {
35
- return this.queue.length;
36
- },
37
- events: { subscribe: () => {} },
38
- async next() {},
39
- clear() {
40
- this.queue = [];
41
- },
42
- } as unknown as DownQueue;
43
-
44
- return { upQueue, downQueue };
45
- }
46
-
47
- const upEvent = (n: number) => ({ type: 'delete', mutation_id: n, record_id: n }) as unknown as UpEvent;
48
-
49
- describe('SyncScheduler.pause', () => {
50
- it('waits for the in-flight item (push + outbox delete) before resolving', async () => {
51
- const { upQueue, downQueue } = makeQueues([upEvent(1), upEvent(2)]);
52
- let release!: () => void;
53
- const gate = new Promise<void>((r) => (release = r));
54
- const processed: unknown[] = [];
55
- const scheduler = new SyncScheduler(
56
- upQueue,
57
- downQueue,
58
- async (event) => {
59
- await gate; // simulate a slow remote push
60
- processed.push(event);
61
- },
62
- async () => {},
63
- silentLogger
64
- );
65
-
66
- const round = scheduler.syncUp();
67
- const pauseDone = vi.fn();
68
- const pause = scheduler.pause().then(pauseDone);
69
-
70
- // The in-flight item hasn't finished — pause must not have resolved.
71
- await Promise.resolve();
72
- expect(pauseDone).not.toHaveBeenCalled();
73
-
74
- release();
75
- await pause;
76
- await round;
77
- // Exactly the in-flight item completed; the second stayed queued.
78
- expect(processed).toHaveLength(1);
79
- expect(upQueue.size).toBe(1);
80
- });
81
-
82
- it('refuses new rounds while paused and drains them on resume', async () => {
83
- const { upQueue, downQueue } = makeQueues([upEvent(1)]);
84
- const processed: unknown[] = [];
85
- const scheduler = new SyncScheduler(
86
- upQueue,
87
- downQueue,
88
- async (event) => {
89
- processed.push(event);
90
- },
91
- async () => {},
92
- silentLogger
93
- );
94
-
95
- await scheduler.pause();
96
- await scheduler.syncUp();
97
- expect(processed).toHaveLength(0);
98
-
99
- scheduler.resume();
100
- await vi.waitFor(() => expect(processed).toHaveLength(1));
101
- expect(upQueue.size).toBe(0);
102
- });
103
-
104
- it('resolves immediately when nothing is in flight', async () => {
105
- const { upQueue, downQueue } = makeQueues([]);
106
- const scheduler = new SyncScheduler(upQueue, downQueue, async () => {}, async () => {}, silentLogger);
107
- await expect(scheduler.pause()).resolves.toBeUndefined();
108
- });
109
- });