@spooky-sync/core 0.0.0-canary.1 → 0.0.1-alpha.2

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 (228) hide show
  1. package/AGENTS.md +77 -0
  2. package/README.md +56 -16
  3. package/dist/index.d.ts +2179 -411
  4. package/dist/index.js +12509 -2475
  5. package/dist/otel/index.d.ts +21 -0
  6. package/dist/otel/index.js +86 -0
  7. package/dist/sqlite-open.js +322 -0
  8. package/dist/sqlite-worker.d.ts +1 -0
  9. package/dist/sqlite-worker.js +439 -0
  10. package/dist/tabs-broker-worker.d.ts +8 -0
  11. package/dist/tabs-broker-worker.js +472 -0
  12. package/dist/types.d.ts +1060 -0
  13. package/package.json +49 -12
  14. package/scripts/check-broker-bundle.mjs +33 -0
  15. package/skills/sp00ky-core/SKILL.md +276 -0
  16. package/skills/sp00ky-core/references/auth.md +98 -0
  17. package/skills/sp00ky-core/references/config.md +76 -0
  18. package/src/boot/auth-flip.saga.test.ts +82 -0
  19. package/src/boot/auth-flip.saga.ts +62 -0
  20. package/src/boot/boot.saga.test.ts +151 -0
  21. package/src/boot/boot.saga.ts +128 -0
  22. package/src/boot/bucket-switch.saga.test.ts +158 -0
  23. package/src/boot/bucket-switch.saga.ts +115 -0
  24. package/src/boot/preload.saga.test.ts +51 -0
  25. package/src/boot/preload.saga.ts +31 -0
  26. package/src/bucket-blurhash.test.ts +148 -0
  27. package/src/build-globals.d.ts +12 -0
  28. package/src/client/bucket-handle.test.ts +117 -0
  29. package/src/client/bucket-handle.ts +227 -0
  30. package/src/client/router.test.ts +56 -0
  31. package/src/client/router.ts +94 -0
  32. package/src/client/runtime.test.ts +192 -0
  33. package/src/client/runtime.ts +296 -0
  34. package/src/client/services.ts +349 -0
  35. package/src/client/sp00ky-client.test.ts +241 -0
  36. package/src/client/sp00ky-client.ts +482 -0
  37. package/src/events/events.test.ts +2 -1
  38. package/src/events/index.ts +3 -0
  39. package/src/index.ts +37 -1
  40. package/src/kernel/constants.test.ts +19 -0
  41. package/src/kernel/constants.ts +54 -0
  42. package/src/kernel/effects.test.ts +40 -0
  43. package/src/kernel/effects.ts +159 -0
  44. package/src/kernel/events.ts +55 -0
  45. package/src/kernel/interpreter.test.ts +116 -0
  46. package/src/kernel/interpreter.ts +143 -0
  47. package/src/kernel/saga.test.ts +89 -0
  48. package/src/kernel/saga.ts +73 -0
  49. package/src/modules/app-release/index.test.ts +125 -0
  50. package/src/modules/app-release/index.ts +209 -0
  51. package/src/modules/auth/auth.local-first.test.ts +141 -0
  52. package/src/modules/auth/events/index.ts +2 -1
  53. package/src/modules/auth/index.ts +138 -25
  54. package/src/modules/crdt/crdt-field.ts +294 -0
  55. package/src/modules/crdt/crdt-hydration.test.ts +210 -0
  56. package/src/modules/crdt/crdt-reconnect.test.ts +195 -0
  57. package/src/modules/crdt/index.ts +463 -0
  58. package/src/modules/crdt/loro-loader.ts +25 -0
  59. package/src/modules/devtools/flags.ts +349 -0
  60. package/src/modules/devtools/index.ts +459 -47
  61. package/src/modules/devtools/notify-throttle.test.ts +154 -0
  62. package/src/modules/devtools/state-shape.test.ts +149 -0
  63. package/src/modules/devtools/storage-info.test.ts +79 -0
  64. package/src/modules/devtools/storage-info.ts +168 -0
  65. package/src/modules/devtools/versions.test.ts +74 -0
  66. package/src/modules/devtools/versions.ts +110 -0
  67. package/src/modules/feature-flag/index.test.ts +251 -0
  68. package/src/modules/feature-flag/index.ts +316 -0
  69. package/src/modules/ref-tables.test.ts +91 -0
  70. package/src/modules/ref-tables.ts +88 -0
  71. package/src/mutation/jobs.test.ts +25 -0
  72. package/src/mutation/jobs.ts +41 -0
  73. package/src/mutation/mutation-id.test.ts +37 -0
  74. package/src/mutation/mutation-id.ts +35 -0
  75. package/src/mutation/push.saga.test.ts +285 -0
  76. package/src/mutation/push.saga.ts +215 -0
  77. package/src/mutation/rows.test.ts +201 -0
  78. package/src/mutation/rows.ts +377 -0
  79. package/src/mutation/tray.saga.test.ts +63 -0
  80. package/src/mutation/tray.saga.ts +48 -0
  81. package/src/mutation/write.saga.test.ts +163 -0
  82. package/src/mutation/write.saga.ts +166 -0
  83. package/src/otel/index.ts +127 -0
  84. package/src/query/env.test.ts +23 -0
  85. package/src/query/env.ts +56 -0
  86. package/src/query/fetch.saga.test.ts +156 -0
  87. package/src/query/fetch.saga.ts +109 -0
  88. package/src/query/hash.test.ts +14 -0
  89. package/src/query/hash.ts +18 -0
  90. package/src/query/id-set-plan.test.ts +132 -0
  91. package/src/query/lifecycle.saga.test.ts +122 -0
  92. package/src/query/lifecycle.saga.ts +101 -0
  93. package/src/query/materialize.saga.test.ts +82 -0
  94. package/src/query/materialize.saga.ts +75 -0
  95. package/src/query/materialize.test.ts +50 -0
  96. package/src/query/materialize.ts +44 -0
  97. package/src/query/membership.saga.test.ts +246 -0
  98. package/src/query/membership.saga.ts +208 -0
  99. package/src/query/membership.test.ts +118 -0
  100. package/src/query/membership.ts +158 -0
  101. package/src/query/register.saga.test.ts +251 -0
  102. package/src/query/register.saga.ts +190 -0
  103. package/src/query/render-set.test.ts +36 -0
  104. package/src/query/render-set.ts +60 -0
  105. package/src/query/sql.test.ts +62 -0
  106. package/src/query/sql.ts +79 -0
  107. package/src/query/window-query.test.ts +70 -0
  108. package/src/query/window-query.ts +195 -0
  109. package/src/services/blobs/blob-cache.test.ts +359 -0
  110. package/src/services/blobs/blob-cache.ts +603 -0
  111. package/src/services/blobs/blob-manifest.ts +227 -0
  112. package/src/services/blobs/blob-store.test.ts +77 -0
  113. package/src/services/blobs/blob-store.ts +359 -0
  114. package/src/services/blobs/blob.fixture.ts +90 -0
  115. package/src/services/blobs/index.ts +70 -0
  116. package/src/services/database/cache-engine.ts +193 -0
  117. package/src/services/database/connection-supervisor.test.ts +318 -0
  118. package/src/services/database/connection-supervisor.ts +422 -0
  119. package/src/services/database/database.query-timeout.test.ts +140 -0
  120. package/src/services/database/database.responses.test.ts +43 -0
  121. package/src/services/database/database.ts +145 -62
  122. package/src/services/database/engine-factory.ts +33 -0
  123. package/src/services/database/errors.ts +34 -0
  124. package/src/services/database/events/index.ts +2 -1
  125. package/src/services/database/index.ts +7 -0
  126. package/src/services/database/local-migrator.ts +30 -27
  127. package/src/services/database/local.test.ts +64 -0
  128. package/src/services/database/local.ts +484 -67
  129. package/src/services/database/plan-render.test.ts +159 -0
  130. package/src/services/database/plan-render.ts +108 -0
  131. package/src/services/database/relation-resolver.test.ts +467 -0
  132. package/src/services/database/relation-resolver.ts +0 -0
  133. package/src/services/database/remote.ts +222 -15
  134. package/src/services/database/sqlite-cache-engine.test.ts +617 -0
  135. package/src/services/database/sqlite-cache-engine.timeout.test.ts +61 -0
  136. package/src/services/database/sqlite-cache-engine.ts +1349 -0
  137. package/src/services/database/sqlite-devtools-queries.integration.test.ts +143 -0
  138. package/src/services/database/sqlite-devtools-queries.test.ts +154 -0
  139. package/src/services/database/sqlite-lock-verify.test.ts +33 -0
  140. package/src/services/database/sqlite-lock-verify.ts +45 -0
  141. package/src/services/database/sqlite-open.test.ts +150 -0
  142. package/src/services/database/sqlite-open.ts +164 -0
  143. package/src/services/database/sqlite-plan-sql.test.ts +104 -0
  144. package/src/services/database/sqlite-plan-sql.ts +138 -0
  145. package/src/services/database/sqlite-projection.test.ts +99 -0
  146. package/src/services/database/sqlite-select.integration.test.ts +185 -0
  147. package/src/services/database/sqlite-select.test.ts +246 -0
  148. package/src/services/database/sqlite-select.ts +131 -0
  149. package/src/services/database/sqlite-transport.fixture.ts +30 -0
  150. package/src/services/database/sqlite-transport.ts +224 -0
  151. package/src/services/database/sqlite-worker.ts +437 -0
  152. package/src/services/database/surql-translate.ts +416 -0
  153. package/src/services/database/surreal-cache-engine.ts +161 -0
  154. package/src/services/logger/index.ts +6 -101
  155. package/src/services/persistence/localstorage.ts +2 -2
  156. package/src/services/persistence/resilient.ts +41 -0
  157. package/src/services/persistence/surrealdb.ts +10 -10
  158. package/src/services/stream-processor/index.ts +796 -84
  159. package/src/services/stream-processor/permissions.test.ts +47 -0
  160. package/src/services/stream-processor/permissions.ts +53 -0
  161. package/src/services/stream-processor/stream-processor.batch.test.ts +186 -0
  162. package/src/services/stream-processor/stream-processor.prime.test.ts +198 -0
  163. package/src/services/stream-processor/stream-processor.reset.test.ts +226 -0
  164. package/src/services/stream-processor/stream-processor.test.ts +1 -1
  165. package/src/services/stream-processor/wasm-types.ts +59 -3
  166. package/src/services/tabs/broker-client.ts +283 -0
  167. package/src/services/tabs/broker.test.ts +327 -0
  168. package/src/services/tabs/coordinator.test.ts +365 -0
  169. package/src/services/tabs/coordinator.ts +633 -0
  170. package/src/services/tabs/fake-ports.fixture.ts +112 -0
  171. package/src/services/tabs/leader-locks.ts +75 -0
  172. package/src/services/tabs/protocol.ts +262 -0
  173. package/src/services/tabs/support.ts +36 -0
  174. package/src/services/tabs/tabs-broker-worker.ts +640 -0
  175. package/src/sp00ky.ts +5 -0
  176. package/src/state/client-state.ts +184 -0
  177. package/src/state/lifecycle.test.ts +118 -0
  178. package/src/state/lifecycle.ts +86 -0
  179. package/src/state/reducers.test.ts +280 -0
  180. package/src/state/reducers.ts +476 -0
  181. package/src/state/selectors.test.ts +149 -0
  182. package/src/state/selectors.ts +184 -0
  183. package/src/sync/connection.saga.test.ts +86 -0
  184. package/src/sync/connection.saga.ts +85 -0
  185. package/src/sync/live.saga.test.ts +69 -0
  186. package/src/sync/live.saga.ts +49 -0
  187. package/src/sync/policy.health.test.ts +56 -0
  188. package/src/sync/policy.test.ts +659 -0
  189. package/src/sync/policy.ts +502 -0
  190. package/src/sync/poll.saga.test.ts +51 -0
  191. package/src/sync/poll.saga.ts +36 -0
  192. package/src/sync/tabs.saga.test.ts +74 -0
  193. package/src/sync/tabs.saga.ts +92 -0
  194. package/src/testing/adapters.ts +133 -0
  195. package/src/testing/build.ts +61 -0
  196. package/src/testing/fake-services.ts +34 -0
  197. package/src/testing/run-pure.test.ts +92 -0
  198. package/src/testing/run-pure.ts +128 -0
  199. package/src/testing/services.ts +17 -0
  200. package/src/types.ts +557 -16
  201. package/src/utils/blurhash.ts +90 -0
  202. package/src/utils/error-classification.test.ts +54 -0
  203. package/src/utils/error-classification.ts +25 -0
  204. package/src/utils/index.ts +86 -13
  205. package/src/utils/parser.test.ts +49 -120
  206. package/src/utils/parser.ts +32 -2
  207. package/src/utils/semver.test.ts +32 -0
  208. package/src/utils/semver.ts +30 -0
  209. package/src/utils/surql.ts +30 -18
  210. package/src/utils/utils.test.ts +2 -1
  211. package/src/utils/withRetry.test.ts +1 -1
  212. package/tsdown.config.ts +86 -1
  213. package/vitest.config.ts +48 -0
  214. package/src/modules/cache/index.ts +0 -241
  215. package/src/modules/cache/types.ts +0 -19
  216. package/src/modules/data/data.test.ts +0 -58
  217. package/src/modules/data/index.ts +0 -777
  218. package/src/modules/sync/engine.ts +0 -163
  219. package/src/modules/sync/events/index.ts +0 -77
  220. package/src/modules/sync/index.ts +0 -3
  221. package/src/modules/sync/queue/index.ts +0 -2
  222. package/src/modules/sync/queue/queue-down.ts +0 -89
  223. package/src/modules/sync/queue/queue-up.ts +0 -223
  224. package/src/modules/sync/scheduler.ts +0 -84
  225. package/src/modules/sync/sync.ts +0 -407
  226. package/src/modules/sync/utils.test.ts +0 -311
  227. package/src/modules/sync/utils.ts +0 -171
  228. package/src/spooky.ts +0 -346
@@ -1,407 +0,0 @@
1
- import { LocalDatabaseService, RemoteDatabaseService } from '../../services/database/index';
2
- import { MutationEvent, RecordVersionArray } from '../../types';
3
- import { createSyncEventSystem, SyncEventTypes, SyncQueueEventTypes } from './events/index';
4
- import { Logger } from '../../services/logger/index';
5
- import { DownEvent, DownQueue, UpEvent, UpQueue } from './queue/index';
6
- import { RecordId, Uuid } from 'surrealdb';
7
- import { ArraySyncer, createDiffFromDbOp } from './utils';
8
- import { SyncEngine } from './engine';
9
- import { SyncScheduler } from './scheduler';
10
- import { SchemaStructure } from '@spooky-sync/query-builder';
11
- import { CacheModule } from '../cache/index';
12
- import { DataModule } from '../data/index';
13
- import { encodeRecordId, extractTablePart, parseDuration, surql } from '../../utils/index';
14
-
15
- /**
16
- * The main synchronization engine for Spooky.
17
- * Handles the bidirectional synchronization between the local database and the remote backend.
18
- * Uses a queue-based architecture with 'up' (local to remote) and 'down' (remote to local) queues.
19
- * @template S The schema structure type.
20
- */
21
- export class SpookySync<S extends SchemaStructure> {
22
- private clientId: string = '';
23
- private upQueue: UpQueue;
24
- private downQueue: DownQueue;
25
- private isInit: boolean = false;
26
- private logger: Logger;
27
- private syncEngine: SyncEngine;
28
- private scheduler: SyncScheduler;
29
- public events = createSyncEventSystem();
30
-
31
- get isSyncing() {
32
- return this.scheduler.isSyncing;
33
- }
34
-
35
- get pendingMutationCount(): number {
36
- return this.upQueue.size;
37
- }
38
-
39
- subscribeToPendingMutations(cb: (count: number) => void): () => void {
40
- const id1 = this.upQueue.events.subscribe(
41
- SyncQueueEventTypes.MutationEnqueued,
42
- (event) => cb(event.payload.queueSize)
43
- );
44
- const id2 = this.upQueue.events.subscribe(
45
- SyncQueueEventTypes.MutationDequeued,
46
- (event) => cb(event.payload.queueSize)
47
- );
48
- return () => {
49
- this.upQueue.events.unsubscribe(id1);
50
- this.upQueue.events.unsubscribe(id2);
51
- };
52
- }
53
-
54
- constructor(
55
- private local: LocalDatabaseService,
56
- private remote: RemoteDatabaseService,
57
- private cache: CacheModule,
58
- private dataModule: DataModule<S>,
59
- private schema: S,
60
- logger: Logger
61
- ) {
62
- this.logger = logger.child({ service: 'SpookySync' });
63
- this.upQueue = new UpQueue(this.local, this.logger);
64
- this.downQueue = new DownQueue(this.local, this.logger);
65
- this.syncEngine = new SyncEngine(this.remote, this.cache, this.schema, this.logger);
66
- this.scheduler = new SyncScheduler(
67
- this.upQueue,
68
- this.downQueue,
69
- this.processUpEvent.bind(this),
70
- this.processDownEvent.bind(this),
71
- this.logger,
72
- this.handleRollback.bind(this)
73
- );
74
- }
75
-
76
- /**
77
- * Initializes the synchronization system.
78
- * Starts the scheduler and initiates the initial sync cycles.
79
- * @param clientId The unique identifier for this client instance.
80
- * @throws Error if already initialized.
81
- */
82
- public async init(clientId: string) {
83
- if (this.isInit) throw new Error('SpookySync is already initialized');
84
- this.clientId = clientId;
85
- this.isInit = true;
86
- await this.scheduler.init();
87
- void this.scheduler.syncUp();
88
- void this.scheduler.syncUp();
89
- void this.scheduler.syncDown();
90
- void this.startRefLiveQueries();
91
- }
92
-
93
- private async startRefLiveQueries() {
94
- this.logger.debug(
95
- { clientId: this.clientId, Category: 'spooky-client::SpookySync::startRefLiveQueries' },
96
- 'Starting ref live queries'
97
- );
98
-
99
- const [queryUuid] = await this.remote.query<[Uuid]>(
100
- 'LIVE SELECT * FROM _spooky_list_ref'
101
- );
102
-
103
- (await this.remote.getClient().liveOf(queryUuid)).subscribe((message) => {
104
- this.logger.debug(
105
- { message, Category: 'spooky-client::SpookySync::startRefLiveQueries' },
106
- 'Live update received'
107
- );
108
- if (message.action === 'KILLED') return;
109
- this.handleRemoteListRefChange(
110
- message.action,
111
- message.value.in as RecordId<string>,
112
- message.value.out as RecordId<string>,
113
- message.value.version as number
114
- ).catch((err) => {
115
- this.logger.error(
116
- { err, Category: 'spooky-client::SpookySync::startRefLiveQueries' },
117
- 'Error handling remote list ref change'
118
- );
119
- });
120
- });
121
- }
122
-
123
- private async handleRemoteListRefChange(
124
- action: 'CREATE' | 'UPDATE' | 'DELETE',
125
- queryId: RecordId,
126
- recordId: RecordId,
127
- version: number
128
- ) {
129
- const existing = this.dataModule.getQueryById(queryId);
130
-
131
- if (!existing) {
132
- this.logger.warn(
133
- {
134
- queryId: queryId.toString(),
135
- Category: 'spooky-client::SpookySync::handleRemoteListRefChange',
136
- },
137
- 'Received remote update for unknown local query'
138
- );
139
- return;
140
- }
141
-
142
- const { localArray } = existing.config;
143
-
144
- this.logger.debug(
145
- {
146
- action,
147
- queryId,
148
- recordId,
149
- version,
150
- localArray,
151
- Category: 'spooky-client::SpookySync::handleRemoteListRefChange',
152
- },
153
- 'Live update is being processed'
154
- );
155
- const diff = createDiffFromDbOp(action, recordId, version, localArray);
156
- await this.syncEngine.syncRecords(diff);
157
- }
158
-
159
- /**
160
- * Enqueues a 'down' event (from remote to local) for processing.
161
- * @param event The DownEvent to enqueue.
162
- */
163
- public enqueueDownEvent(event: DownEvent) {
164
- this.scheduler.enqueueDownEvent(event);
165
- }
166
-
167
- private async processUpEvent(event: UpEvent) {
168
- this.logger.debug(
169
- { event, Category: 'spooky-client::SpookySync::processUpEvent' },
170
- 'Processing up event'
171
- );
172
- console.log('xx1', event);
173
- switch (event.type) {
174
- case 'create':
175
- const dataKeys = Object.keys(event.data).map((key) => ({ key, variable: `data_${key}` }));
176
- const prefixedParams = Object.fromEntries(
177
- dataKeys.map(({ key, variable }) => [variable, event.data[key]])
178
- );
179
- const query = surql.seal(surql.createSet('id', dataKeys));
180
- await this.remote.query(query, {
181
- id: event.record_id,
182
- ...prefixedParams,
183
- });
184
- break;
185
- case 'update':
186
- await this.remote.query(`UPDATE $id MERGE $data`, {
187
- id: event.record_id,
188
- data: event.data,
189
- });
190
- break;
191
- case 'delete':
192
- await this.remote.query(`DELETE $id`, {
193
- id: event.record_id,
194
- });
195
- break;
196
- default:
197
- this.logger.error(
198
- { event, Category: 'spooky-client::SpookySync::processUpEvent' },
199
- 'processUpEvent unknown event type'
200
- );
201
- return;
202
- }
203
- }
204
-
205
- private async handleRollback(event: UpEvent, error: Error): Promise<void> {
206
- const recordId = encodeRecordId(event.record_id);
207
- const tableName =
208
- event.type === 'create' && event.tableName
209
- ? event.tableName
210
- : extractTablePart(recordId);
211
-
212
- this.logger.warn(
213
- {
214
- type: event.type,
215
- recordId,
216
- tableName,
217
- error: error.message,
218
- Category: 'spooky-client::SpookySync::handleRollback',
219
- },
220
- 'Rolling back failed mutation'
221
- );
222
-
223
- switch (event.type) {
224
- case 'create':
225
- await this.dataModule.rollbackCreate(event.record_id, tableName);
226
- break;
227
- case 'update':
228
- if (event.beforeRecord) {
229
- await this.dataModule.rollbackUpdate(event.record_id, tableName, event.beforeRecord);
230
- } else {
231
- this.logger.warn(
232
- {
233
- recordId,
234
- Category: 'spooky-client::SpookySync::handleRollback',
235
- },
236
- 'Cannot rollback update: no beforeRecord available. Down-sync will reconcile.'
237
- );
238
- }
239
- break;
240
- case 'delete':
241
- this.logger.warn(
242
- {
243
- recordId,
244
- Category: 'spooky-client::SpookySync::handleRollback',
245
- },
246
- 'Delete rollback not implemented. Down-sync will reconcile.'
247
- );
248
- break;
249
- }
250
-
251
- this.events.emit(SyncEventTypes.MutationRolledBack, {
252
- eventType: event.type,
253
- recordId,
254
- error: error.message,
255
- });
256
- }
257
-
258
- private async processDownEvent(event: DownEvent) {
259
- this.logger.debug(
260
- { event, Category: 'spooky-client::SpookySync::processDownEvent' },
261
- 'Processing down event'
262
- );
263
- switch (event.type) {
264
- case 'register':
265
- return this.registerQuery(event.payload.hash);
266
- case 'sync':
267
- return this.syncQuery(event.payload.hash);
268
- case 'heartbeat':
269
- return this.heartbeatQuery(event.payload.hash);
270
- case 'cleanup':
271
- return this.cleanupQuery(event.payload.hash);
272
- }
273
- }
274
-
275
- /**
276
- * Synchronizes a specific query by hash.
277
- * Compares local and remote version arrays and fetches differences.
278
- * @param hash The hash of the query to sync.
279
- */
280
- public async syncQuery(hash: string) {
281
- const queryState = this.dataModule.getQueryByHash(hash);
282
- if (!queryState) {
283
- this.logger.warn(
284
- { hash, Category: 'spooky-client::SpookySync::syncQuery' },
285
- 'Query not found'
286
- );
287
- return;
288
- }
289
-
290
- const diff = new ArraySyncer(
291
- queryState.config.localArray,
292
- queryState.config.remoteArray
293
- ).nextSet();
294
-
295
- if (!diff) {
296
- return;
297
- }
298
- return this.syncEngine.syncRecords(diff);
299
- }
300
-
301
- /**
302
- * Enqueues a list of mutations (up events) to be sent to the remote.
303
- * @param mutations Array of UpEvents (create/update/delete) to enqueue.
304
- */
305
- public async enqueueMutation(mutations: UpEvent[]) {
306
- this.scheduler.enqueueMutation(mutations);
307
- }
308
-
309
- private async registerQuery(queryHash: string) {
310
- try {
311
- this.logger.debug(
312
- { queryHash, Category: 'spooky-client::SpookySync::registerQuery' },
313
- 'Register Query state'
314
- );
315
- await this.createRemoteQuery(queryHash);
316
- await this.syncQuery(queryHash);
317
- } catch (e) {
318
- this.logger.error(
319
- { err: e, Category: 'spooky-client::SpookySync::registerQuery' },
320
- 'registerQuery error'
321
- );
322
- throw e;
323
- }
324
- }
325
-
326
- private async createRemoteQuery(queryHash: string) {
327
- const queryState = this.dataModule.getQueryByHash(queryHash);
328
-
329
- if (!queryState) {
330
- this.logger.warn(
331
- { queryHash, Category: 'spooky-client::SpookySync::createRemoteQuery' },
332
- 'Query to register not found'
333
- );
334
- throw new Error('Query to register not found');
335
- }
336
- // Delegate to remote function which handles DBSP registration & persistence
337
- await this.remote.query('fn::query::register($config)', {
338
- config: {
339
- clientId: this.clientId,
340
- id: queryState.config.id,
341
- surql: queryState.config.surql,
342
- params: queryState.config.params,
343
- ttl: queryState.config.ttl,
344
- },
345
- });
346
-
347
- const [items] = await this.remote.query<[{ out: RecordId<string>; version: number }[]]>(
348
- surql.selectByFieldsAnd('_spooky_list_ref', ['in'], ['out', 'version']),
349
- {
350
- in: queryState.config.id,
351
- }
352
- );
353
-
354
- this.logger.trace(
355
- {
356
- queryId: encodeRecordId(queryState.config.id),
357
- items,
358
- Category: 'spooky-client::SpookySync::createRemoteQuery',
359
- },
360
- 'Got query record version array from remote'
361
- );
362
-
363
- const array: RecordVersionArray = items.map((item) => [encodeRecordId(item.out), item.version]);
364
-
365
- this.logger.debug(
366
- {
367
- queryId: encodeRecordId(queryState.config.id),
368
- array,
369
- Category: 'spooky-client::SpookySync::createRemoteQuery',
370
- },
371
- 'createdRemoteQuery'
372
- );
373
-
374
- if (array) {
375
- /// Incantation existed already
376
- await this.dataModule.updateQueryRemoteArray(queryHash, array);
377
- }
378
- }
379
-
380
- private async heartbeatQuery(queryHash: string) {
381
- const queryState = this.dataModule.getQueryByHash(queryHash);
382
- if (!queryState) {
383
- this.logger.warn(
384
- { queryHash, Category: 'spooky-client::SpookySync::heartbeatQuery' },
385
- 'Query to register not found'
386
- );
387
- throw new Error('Query to register not found');
388
- }
389
- await this.remote.query('fn::query::heartbeat($id)', {
390
- id: queryState.config.id,
391
- });
392
- }
393
-
394
- private async cleanupQuery(queryHash: string) {
395
- const queryState = this.dataModule.getQueryByHash(queryHash);
396
- if (!queryState) {
397
- this.logger.warn(
398
- { queryHash, Category: 'spooky-client::SpookySync::cleanupQuery' },
399
- 'Query to register not found'
400
- );
401
- throw new Error('Query to register not found');
402
- }
403
- await this.remote.query(`DELETE $id`, {
404
- id: queryState.config.id,
405
- });
406
- }
407
- }
@@ -1,311 +0,0 @@
1
- import { describe, it, expect } from 'vitest';
2
- import { RecordId } from 'surrealdb';
3
- import {
4
- diffRecordVersionArray,
5
- applyRecordVersionDiff,
6
- createDiffFromDbOp,
7
- ArraySyncer,
8
- } from './utils';
9
- import { RecordVersionArray, RecordVersionDiff } from '../../types';
10
- import { encodeRecordId } from '../../utils/index';
11
-
12
- function rid(table: string, id: string): RecordId<string> {
13
- return new RecordId(table, id);
14
- }
15
-
16
- describe('diffRecordVersionArray', () => {
17
- it('detects added records (in remote, not local)', () => {
18
- const local: RecordVersionArray = [['user:1', 1]];
19
- const remote: RecordVersionArray = [
20
- ['user:1', 1],
21
- ['user:2', 1],
22
- ];
23
- const diff = diffRecordVersionArray(local, remote);
24
-
25
- expect(diff.added).toHaveLength(1);
26
- expect(encodeRecordId(diff.added[0].id)).toBe('user:2');
27
- expect(diff.added[0].version).toBe(1);
28
- expect(diff.updated).toHaveLength(0);
29
- expect(diff.removed).toHaveLength(0);
30
- });
31
-
32
- it('detects updated records (remote version > local version)', () => {
33
- const local: RecordVersionArray = [['user:1', 1]];
34
- const remote: RecordVersionArray = [['user:1', 3]];
35
- const diff = diffRecordVersionArray(local, remote);
36
-
37
- expect(diff.updated).toHaveLength(1);
38
- expect(encodeRecordId(diff.updated[0].id)).toBe('user:1');
39
- expect(diff.updated[0].version).toBe(3);
40
- expect(diff.added).toHaveLength(0);
41
- expect(diff.removed).toHaveLength(0);
42
- });
43
-
44
- it('detects removed records (in local, not remote)', () => {
45
- const local: RecordVersionArray = [
46
- ['user:1', 1],
47
- ['user:2', 1],
48
- ];
49
- const remote: RecordVersionArray = [['user:1', 1]];
50
- const diff = diffRecordVersionArray(local, remote);
51
-
52
- expect(diff.removed).toHaveLength(1);
53
- expect(encodeRecordId(diff.removed[0])).toBe('user:2');
54
- expect(diff.added).toHaveLength(0);
55
- expect(diff.updated).toHaveLength(0);
56
- });
57
-
58
- it('handles null arrays', () => {
59
- const diff = diffRecordVersionArray(null, null);
60
- expect(diff.added).toHaveLength(0);
61
- expect(diff.updated).toHaveLength(0);
62
- expect(diff.removed).toHaveLength(0);
63
- });
64
-
65
- it('handles empty arrays', () => {
66
- const diff = diffRecordVersionArray([], []);
67
- expect(diff.added).toHaveLength(0);
68
- expect(diff.updated).toHaveLength(0);
69
- expect(diff.removed).toHaveLength(0);
70
- });
71
-
72
- it('no diff when arrays match', () => {
73
- const arr: RecordVersionArray = [
74
- ['user:1', 1],
75
- ['user:2', 2],
76
- ];
77
- const diff = diffRecordVersionArray(arr, arr);
78
- expect(diff.added).toHaveLength(0);
79
- expect(diff.updated).toHaveLength(0);
80
- expect(diff.removed).toHaveLength(0);
81
- });
82
-
83
- it('handles mixed adds/updates/removes', () => {
84
- const local: RecordVersionArray = [
85
- ['user:1', 1],
86
- ['user:2', 1],
87
- ['user:3', 1],
88
- ];
89
- const remote: RecordVersionArray = [
90
- ['user:1', 1], // same
91
- ['user:2', 3], // updated
92
- // user:3 removed
93
- ['user:4', 1], // added
94
- ];
95
- const diff = diffRecordVersionArray(local, remote);
96
-
97
- expect(diff.added).toHaveLength(1);
98
- expect(encodeRecordId(diff.added[0].id)).toBe('user:4');
99
- expect(diff.updated).toHaveLength(1);
100
- expect(encodeRecordId(diff.updated[0].id)).toBe('user:2');
101
- expect(diff.removed).toHaveLength(1);
102
- expect(encodeRecordId(diff.removed[0])).toBe('user:3');
103
- });
104
- });
105
-
106
- describe('applyRecordVersionDiff', () => {
107
- it('applies additions', () => {
108
- const current: RecordVersionArray = [['user:1', 1]];
109
- const diff: RecordVersionDiff = {
110
- added: [{ id: rid('user', '2'), version: 1 }],
111
- updated: [],
112
- removed: [],
113
- };
114
- const result = applyRecordVersionDiff(current, diff);
115
- expect(result).toEqual([
116
- ['user:1', 1],
117
- ['user:2', 1],
118
- ]);
119
- });
120
-
121
- it('applies updates', () => {
122
- const current: RecordVersionArray = [['user:1', 1]];
123
- const diff: RecordVersionDiff = {
124
- added: [],
125
- updated: [{ id: rid('user', '1'), version: 5 }],
126
- removed: [],
127
- };
128
- const result = applyRecordVersionDiff(current, diff);
129
- expect(result).toEqual([['user:1', 5]]);
130
- });
131
-
132
- it('applies removals', () => {
133
- const current: RecordVersionArray = [
134
- ['user:1', 1],
135
- ['user:2', 2],
136
- ];
137
- const diff: RecordVersionDiff = {
138
- added: [],
139
- updated: [],
140
- removed: [rid('user', '1')],
141
- };
142
- const result = applyRecordVersionDiff(current, diff);
143
- expect(result).toEqual([['user:2', 2]]);
144
- });
145
-
146
- it('result is sorted by record ID', () => {
147
- const current: RecordVersionArray = [['user:c', 1]];
148
- const diff: RecordVersionDiff = {
149
- added: [
150
- { id: rid('user', 'a'), version: 1 },
151
- { id: rid('user', 'z'), version: 1 },
152
- ],
153
- updated: [],
154
- removed: [],
155
- };
156
- const result = applyRecordVersionDiff(current, diff);
157
- expect(result).toEqual([
158
- ['user:a', 1],
159
- ['user:c', 1],
160
- ['user:z', 1],
161
- ]);
162
- });
163
-
164
- it('empty diff returns original (sorted)', () => {
165
- const current: RecordVersionArray = [
166
- ['user:b', 2],
167
- ['user:a', 1],
168
- ];
169
- const diff: RecordVersionDiff = { added: [], updated: [], removed: [] };
170
- const result = applyRecordVersionDiff(current, diff);
171
- expect(result).toEqual([
172
- ['user:a', 1],
173
- ['user:b', 2],
174
- ]);
175
- });
176
- });
177
-
178
- describe('createDiffFromDbOp', () => {
179
- it('CREATE populates added array', () => {
180
- const recordId = rid('user', '1');
181
- const diff = createDiffFromDbOp('CREATE', recordId, 1);
182
- expect(diff.added).toHaveLength(1);
183
- expect(diff.added[0].id).toBe(recordId);
184
- expect(diff.added[0].version).toBe(1);
185
- expect(diff.updated).toHaveLength(0);
186
- expect(diff.removed).toHaveLength(0);
187
- });
188
-
189
- it('UPDATE populates updated array', () => {
190
- const recordId = rid('user', '1');
191
- const diff = createDiffFromDbOp('UPDATE', recordId, 2);
192
- expect(diff.updated).toHaveLength(1);
193
- expect(diff.updated[0].id).toBe(recordId);
194
- expect(diff.updated[0].version).toBe(2);
195
- expect(diff.added).toHaveLength(0);
196
- expect(diff.removed).toHaveLength(0);
197
- });
198
-
199
- it('DELETE populates removed array', () => {
200
- const recordId = rid('user', '1');
201
- const diff = createDiffFromDbOp('DELETE', recordId, 1);
202
- expect(diff.removed).toHaveLength(1);
203
- expect(diff.removed[0]).toBe(recordId);
204
- expect(diff.added).toHaveLength(0);
205
- expect(diff.updated).toHaveLength(0);
206
- });
207
-
208
- it('skips if existing version >= new version', () => {
209
- const recordId = rid('user', '1');
210
- const versions: RecordVersionArray = [['user:1', 5]];
211
- const diff = createDiffFromDbOp('UPDATE', recordId, 3, versions);
212
- expect(diff.added).toHaveLength(0);
213
- expect(diff.updated).toHaveLength(0);
214
- expect(diff.removed).toHaveLength(0);
215
- });
216
-
217
- it('applies if existing version < new version', () => {
218
- const recordId = rid('user', '1');
219
- const versions: RecordVersionArray = [['user:1', 2]];
220
- const diff = createDiffFromDbOp('UPDATE', recordId, 5, versions);
221
- expect(diff.updated).toHaveLength(1);
222
- expect(diff.updated[0].version).toBe(5);
223
- });
224
- });
225
-
226
- describe('ArraySyncer', () => {
227
- it('insert adds to local array', () => {
228
- const syncer = new ArraySyncer(
229
- [['user:1', 1]],
230
- [['user:1', 1]]
231
- );
232
-
233
- syncer.insert('user:2', 1);
234
- const diff = syncer.nextSet();
235
- expect(diff).not.toBeNull();
236
- // local now has user:2 which remote does not → user:2 is in local removed from remote perspective
237
- // Actually: local=[user:1, user:2], remote=[user:1] → user:2 is "removed" (in local, not remote)
238
- expect(diff!.removed).toHaveLength(1);
239
- expect(encodeRecordId(diff!.removed[0])).toBe('user:2');
240
- });
241
-
242
- it('update modifies version in local array', () => {
243
- const syncer = new ArraySyncer(
244
- [['user:1', 1]],
245
- [['user:1', 1]]
246
- );
247
-
248
- syncer.update('user:1', 5);
249
- const diff = syncer.nextSet();
250
- // local version (5) > remote version (1), so no "updated" from diff perspective
251
- // diff finds remote additions/updates relative to local; local version higher means no remote update
252
- expect(diff).not.toBeNull();
253
- expect(diff!.added).toHaveLength(0);
254
- expect(diff!.updated).toHaveLength(0);
255
- expect(diff!.removed).toHaveLength(0);
256
- });
257
-
258
- it('delete removes from local array', () => {
259
- const syncer = new ArraySyncer(
260
- [
261
- ['user:1', 1],
262
- ['user:2', 1],
263
- ],
264
- [
265
- ['user:1', 1],
266
- ['user:2', 1],
267
- ]
268
- );
269
-
270
- syncer.delete('user:2');
271
- const diff = syncer.nextSet();
272
- expect(diff).not.toBeNull();
273
- // remote has user:2, local does not → added from remote perspective
274
- expect(diff!.added).toHaveLength(1);
275
- expect(encodeRecordId(diff!.added[0].id)).toBe('user:2');
276
- });
277
-
278
- it('nextSet returns correct diff against remote', () => {
279
- const syncer = new ArraySyncer(
280
- [['user:1', 1]],
281
- [
282
- ['user:1', 1],
283
- ['user:2', 1],
284
- ]
285
- );
286
-
287
- const diff = syncer.nextSet();
288
- expect(diff).not.toBeNull();
289
- expect(diff!.added).toHaveLength(1);
290
- expect(encodeRecordId(diff!.added[0].id)).toBe('user:2');
291
- });
292
-
293
- it('maintains sorting after mutations', () => {
294
- const syncer = new ArraySyncer(
295
- [['user:c', 1]],
296
- []
297
- );
298
-
299
- syncer.insert('user:a', 1);
300
- syncer.insert('user:z', 1);
301
-
302
- // nextSet triggers sort
303
- const diff = syncer.nextSet();
304
- // All 3 are in local but not remote → 3 removed items
305
- expect(diff!.removed).toHaveLength(3);
306
- // Check they come in sorted order
307
- const removedIds = diff!.removed.map((r) => encodeRecordId(r));
308
- const sorted = [...removedIds].sort();
309
- expect(removedIds).toEqual(sorted);
310
- });
311
- });