@spooky-sync/core 0.0.1-canary.30 → 0.0.1-canary.300

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 (257) hide show
  1. package/AGENTS.md +81 -0
  2. package/README.md +56 -16
  3. package/dist/index.d.ts +2026 -137
  4. package/dist/index.js +14939 -2432
  5. package/dist/live.d.ts +141 -0
  6. package/dist/live.js +709 -0
  7. package/dist/otel/index.d.ts +1 -1
  8. package/dist/otel/index.js +3 -3
  9. package/dist/pure.d.ts +329 -0
  10. package/dist/pure.js +24 -0
  11. package/dist/sqlite-open.js +358 -0
  12. package/dist/sqlite-worker.d.ts +1 -0
  13. package/dist/sqlite-worker.js +439 -0
  14. package/dist/sw.d.ts +207 -0
  15. package/dist/sw.js +393 -0
  16. package/dist/tabs-broker-worker.d.ts +8 -0
  17. package/dist/tabs-broker-worker.js +472 -0
  18. package/dist/types.d.ts +1765 -38
  19. package/dist/worker-edges.js +423 -0
  20. package/dist/worker-impersonation.js +60 -0
  21. package/dist/worker-ref-tables.d.ts +46 -0
  22. package/dist/worker-types.d.ts +112 -0
  23. package/dist/worker-types.js +73 -0
  24. package/package.json +27 -7
  25. package/scripts/check-broker-bundle.mjs +33 -0
  26. package/scripts/check-worker-entries.mjs +172 -0
  27. package/skills/sp00ky-core/SKILL.md +31 -6
  28. package/skills/sp00ky-core/references/config.md +6 -0
  29. package/src/boot/auth-flip.saga.test.ts +82 -0
  30. package/src/boot/auth-flip.saga.ts +62 -0
  31. package/src/boot/boot.saga.test.ts +158 -0
  32. package/src/boot/boot.saga.ts +131 -0
  33. package/src/boot/bucket-switch.saga.test.ts +187 -0
  34. package/src/boot/bucket-switch.saga.ts +133 -0
  35. package/src/boot/preload.saga.test.ts +51 -0
  36. package/src/boot/preload.saga.ts +31 -0
  37. package/src/bucket-blurhash.test.ts +148 -0
  38. package/src/build-globals.d.ts +12 -0
  39. package/src/client/bucket-handle.test.ts +117 -0
  40. package/src/client/bucket-handle.ts +227 -0
  41. package/src/client/router.test.ts +58 -0
  42. package/src/client/router.ts +100 -0
  43. package/src/client/runtime.test.ts +195 -0
  44. package/src/client/runtime.ts +296 -0
  45. package/src/client/services.ts +370 -0
  46. package/src/client/sp00ky-client.test.ts +303 -0
  47. package/src/client/sp00ky-client.ts +586 -0
  48. package/src/events/events.test.ts +2 -1
  49. package/src/events/index.ts +3 -0
  50. package/src/index.ts +69 -1
  51. package/src/kernel/constants.test.ts +19 -0
  52. package/src/kernel/constants.ts +80 -0
  53. package/src/kernel/effects.test.ts +40 -0
  54. package/src/kernel/effects.ts +161 -0
  55. package/src/kernel/events.ts +57 -0
  56. package/src/kernel/interpreter.test.ts +116 -0
  57. package/src/kernel/interpreter.ts +148 -0
  58. package/src/kernel/saga.test.ts +89 -0
  59. package/src/kernel/saga.ts +73 -0
  60. package/src/live/index.test.ts +478 -0
  61. package/src/live/index.ts +1013 -0
  62. package/src/modules/app-release/index.test.ts +125 -0
  63. package/src/modules/app-release/index.ts +209 -0
  64. package/src/modules/auth/auth.local-first.test.ts +141 -0
  65. package/src/modules/auth/events/index.ts +9 -2
  66. package/src/modules/auth/impersonation-banner.ts +443 -0
  67. package/src/modules/auth/impersonation.test.ts +355 -0
  68. package/src/modules/auth/impersonation.ts +179 -0
  69. package/src/modules/auth/index.ts +386 -17
  70. package/src/modules/crdt/crdt-field.ts +294 -0
  71. package/src/modules/crdt/crdt-hydration.test.ts +210 -0
  72. package/src/modules/crdt/crdt-reconnect.test.ts +195 -0
  73. package/src/modules/crdt/index.ts +463 -0
  74. package/src/modules/crdt/loro-loader.ts +25 -0
  75. package/src/modules/devtools/flags.ts +349 -0
  76. package/src/modules/devtools/index.ts +746 -95
  77. package/src/modules/devtools/mutations.test.ts +92 -0
  78. package/src/modules/devtools/mutations.ts +160 -0
  79. package/src/modules/devtools/notify-throttle.test.ts +166 -0
  80. package/src/modules/devtools/state-shape.test.ts +263 -0
  81. package/src/modules/devtools/storage-info.test.ts +79 -0
  82. package/src/modules/devtools/storage-info.ts +168 -0
  83. package/src/modules/devtools/versions.test.ts +74 -0
  84. package/src/modules/devtools/versions.ts +110 -0
  85. package/src/modules/feature-flag/index.test.ts +251 -0
  86. package/src/modules/feature-flag/index.ts +316 -0
  87. package/src/modules/ref-tables.test.ts +91 -0
  88. package/src/modules/ref-tables.ts +88 -0
  89. package/src/modules/web-push/index.test.ts +824 -0
  90. package/src/modules/web-push/index.ts +1027 -0
  91. package/src/mutation/jobs.test.ts +25 -0
  92. package/src/mutation/jobs.ts +41 -0
  93. package/src/mutation/mutation-id.test.ts +37 -0
  94. package/src/mutation/mutation-id.ts +35 -0
  95. package/src/mutation/pending-writes.integration.test.ts +175 -0
  96. package/src/mutation/push.saga.test.ts +285 -0
  97. package/src/mutation/push.saga.ts +217 -0
  98. package/src/mutation/rows.test.ts +231 -0
  99. package/src/mutation/rows.ts +427 -0
  100. package/src/mutation/tray.saga.test.ts +63 -0
  101. package/src/mutation/tray.saga.ts +48 -0
  102. package/src/mutation/write.saga.test.ts +316 -0
  103. package/src/mutation/write.saga.ts +268 -0
  104. package/src/otel/index.ts +10 -7
  105. package/src/pure.ts +65 -0
  106. package/src/push/types.ts +174 -0
  107. package/src/query/env.test.ts +23 -0
  108. package/src/query/env.ts +56 -0
  109. package/src/query/fetch.saga.test.ts +199 -0
  110. package/src/query/fetch.saga.ts +120 -0
  111. package/src/query/hash.test.ts +14 -0
  112. package/src/query/hash.ts +18 -0
  113. package/src/query/id-set-plan.test.ts +132 -0
  114. package/src/query/lifecycle.saga.test.ts +274 -0
  115. package/src/query/lifecycle.saga.ts +159 -0
  116. package/src/query/materialize.saga.test.ts +115 -0
  117. package/src/query/materialize.saga.ts +84 -0
  118. package/src/query/materialize.test.ts +58 -0
  119. package/src/query/materialize.ts +57 -0
  120. package/src/query/membership.saga.test.ts +350 -0
  121. package/src/query/membership.saga.ts +278 -0
  122. package/src/query/membership.test.ts +118 -0
  123. package/src/query/membership.ts +201 -0
  124. package/src/query/register.saga.test.ts +293 -0
  125. package/src/query/register.saga.ts +226 -0
  126. package/src/query/render-set.test.ts +47 -0
  127. package/src/query/render-set.ts +71 -0
  128. package/src/query/sql.test.ts +63 -0
  129. package/src/query/sql.ts +93 -0
  130. package/src/query/window-query.test.ts +70 -0
  131. package/src/query/window-query.ts +195 -0
  132. package/src/services/blobs/blob-cache.test.ts +359 -0
  133. package/src/services/blobs/blob-cache.ts +603 -0
  134. package/src/services/blobs/blob-manifest.ts +227 -0
  135. package/src/services/blobs/blob-store.test.ts +77 -0
  136. package/src/services/blobs/blob-store.ts +359 -0
  137. package/src/services/blobs/blob.fixture.ts +90 -0
  138. package/src/services/blobs/index.ts +70 -0
  139. package/src/services/database/cache-engine.ts +199 -0
  140. package/src/services/database/connection-supervisor.test.ts +318 -0
  141. package/src/services/database/connection-supervisor.ts +422 -0
  142. package/src/services/database/database.query-timeout.test.ts +140 -0
  143. package/src/services/database/database.responses.test.ts +43 -0
  144. package/src/services/database/database.ts +146 -61
  145. package/src/services/database/engine-factory.ts +33 -0
  146. package/src/services/database/errors.ts +34 -0
  147. package/src/services/database/events/index.ts +2 -1
  148. package/src/services/database/index.ts +7 -0
  149. package/src/services/database/landed-body-clear.integration.test.ts +236 -0
  150. package/src/services/database/local-migrator.integration.test.ts +110 -0
  151. package/src/services/database/local-migrator.ts +59 -16
  152. package/src/services/database/local.test.ts +64 -0
  153. package/src/services/database/local.ts +487 -63
  154. package/src/services/database/plan-render.test.ts +159 -0
  155. package/src/services/database/plan-render.ts +108 -0
  156. package/src/services/database/relation-resolver.test.ts +467 -0
  157. package/src/services/database/relation-resolver.ts +0 -0
  158. package/src/services/database/remote.ts +213 -6
  159. package/src/services/database/sqlite-cache-engine.test.ts +617 -0
  160. package/src/services/database/sqlite-cache-engine.timeout.test.ts +61 -0
  161. package/src/services/database/sqlite-cache-engine.ts +1372 -0
  162. package/src/services/database/sqlite-devtools-queries.integration.test.ts +143 -0
  163. package/src/services/database/sqlite-devtools-queries.test.ts +154 -0
  164. package/src/services/database/sqlite-lock-verify.test.ts +33 -0
  165. package/src/services/database/sqlite-lock-verify.ts +45 -0
  166. package/src/services/database/sqlite-open.test.ts +150 -0
  167. package/src/services/database/sqlite-open.ts +164 -0
  168. package/src/services/database/sqlite-plan-sql.test.ts +139 -0
  169. package/src/services/database/sqlite-plan-sql.ts +179 -0
  170. package/src/services/database/sqlite-projection.test.ts +121 -0
  171. package/src/services/database/sqlite-select.integration.test.ts +185 -0
  172. package/src/services/database/sqlite-select.test.ts +246 -0
  173. package/src/services/database/sqlite-select.ts +131 -0
  174. package/src/services/database/sqlite-transport.fixture.ts +30 -0
  175. package/src/services/database/sqlite-transport.ts +224 -0
  176. package/src/services/database/sqlite-worker.ts +437 -0
  177. package/src/services/database/surql-translate.ts +416 -0
  178. package/src/services/database/surreal-cache-engine.ts +161 -0
  179. package/src/services/logger/index.test.ts +132 -0
  180. package/src/services/logger/index.ts +189 -7
  181. package/src/services/persistence/localstorage.ts +2 -2
  182. package/src/services/persistence/resilient.ts +10 -3
  183. package/src/services/persistence/surrealdb.ts +4 -4
  184. package/src/services/stream-processor/index.ts +779 -67
  185. package/src/services/stream-processor/permissions.test.ts +47 -0
  186. package/src/services/stream-processor/permissions.ts +53 -0
  187. package/src/services/stream-processor/stream-processor.batch.test.ts +186 -0
  188. package/src/services/stream-processor/stream-processor.prime.test.ts +198 -0
  189. package/src/services/stream-processor/stream-processor.reset.test.ts +226 -0
  190. package/src/services/stream-processor/stream-processor.test.ts +1 -1
  191. package/src/services/stream-processor/wasm-types.ts +58 -2
  192. package/src/services/tabs/broker-client.ts +283 -0
  193. package/src/services/tabs/broker.test.ts +327 -0
  194. package/src/services/tabs/coordinator.test.ts +386 -0
  195. package/src/services/tabs/coordinator.ts +638 -0
  196. package/src/services/tabs/fake-ports.fixture.ts +112 -0
  197. package/src/services/tabs/leader-locks.ts +75 -0
  198. package/src/services/tabs/protocol.ts +262 -0
  199. package/src/services/tabs/support.ts +36 -0
  200. package/src/services/tabs/tabs-broker-worker.ts +640 -0
  201. package/src/sp00ky.ts +5 -395
  202. package/src/state/client-state.ts +200 -0
  203. package/src/state/lifecycle.test.ts +118 -0
  204. package/src/state/lifecycle.ts +86 -0
  205. package/src/state/reducers.test.ts +304 -0
  206. package/src/state/reducers.ts +526 -0
  207. package/src/state/selectors.test.ts +167 -0
  208. package/src/state/selectors.ts +216 -0
  209. package/src/sw/index.test.ts +582 -0
  210. package/src/sw/index.ts +607 -0
  211. package/src/sw/store.ts +96 -0
  212. package/src/sync/connection.saga.test.ts +86 -0
  213. package/src/sync/connection.saga.ts +85 -0
  214. package/src/sync/live.saga.test.ts +178 -0
  215. package/src/sync/live.saga.ts +107 -0
  216. package/src/sync/policy.health.test.ts +56 -0
  217. package/src/sync/policy.test.ts +659 -0
  218. package/src/sync/policy.ts +502 -0
  219. package/src/sync/poll.saga.test.ts +51 -0
  220. package/src/sync/poll.saga.ts +36 -0
  221. package/src/sync/tabs.saga.test.ts +78 -0
  222. package/src/sync/tabs.saga.ts +96 -0
  223. package/src/testing/adapters.ts +133 -0
  224. package/src/testing/build.ts +63 -0
  225. package/src/testing/fake-services.ts +48 -0
  226. package/src/testing/run-pure.test.ts +92 -0
  227. package/src/testing/run-pure.ts +128 -0
  228. package/src/testing/services.ts +17 -0
  229. package/src/types.ts +624 -10
  230. package/src/utils/blurhash.ts +90 -0
  231. package/src/utils/edges.ts +39 -0
  232. package/src/utils/error-classification.test.ts +54 -0
  233. package/src/utils/error-classification.ts +25 -0
  234. package/src/utils/index.ts +75 -9
  235. package/src/utils/parser.test.ts +67 -102
  236. package/src/utils/parser.ts +64 -2
  237. package/src/utils/semver.test.ts +32 -0
  238. package/src/utils/semver.ts +30 -0
  239. package/src/utils/sha256.ts +15 -0
  240. package/src/utils/surql.ts +30 -18
  241. package/src/utils/utils.test.ts +2 -1
  242. package/tsdown.config.ts +117 -3
  243. package/vitest.config.ts +48 -0
  244. package/src/modules/cache/index.ts +0 -241
  245. package/src/modules/cache/types.ts +0 -19
  246. package/src/modules/data/data.test.ts +0 -58
  247. package/src/modules/data/index.ts +0 -879
  248. package/src/modules/sync/engine.ts +0 -173
  249. package/src/modules/sync/events/index.ts +0 -77
  250. package/src/modules/sync/index.ts +0 -3
  251. package/src/modules/sync/queue/index.ts +0 -2
  252. package/src/modules/sync/queue/queue-down.ts +0 -89
  253. package/src/modules/sync/queue/queue-up.ts +0 -223
  254. package/src/modules/sync/scheduler.ts +0 -84
  255. package/src/modules/sync/sync.ts +0 -421
  256. package/src/modules/sync/utils.test.ts +0 -311
  257. package/src/modules/sync/utils.ts +0 -167
@@ -1,879 +0,0 @@
1
- import { RecordId, Duration } from 'surrealdb';
2
- import {
3
- SchemaStructure,
4
- TableNames,
5
- BackendNames,
6
- BackendRoutes,
7
- RoutePayload,
8
- } from '@spooky-sync/query-builder';
9
- import { LocalDatabaseService } from '../../services/database/index';
10
- import { CacheModule, RecordWithId } from '../cache/index';
11
- import { Logger } from '../../services/logger/index';
12
- import { StreamUpdate } from '../../services/stream-processor/index';
13
- import {
14
- MutationEvent,
15
- QueryConfig,
16
- QueryHash,
17
- QueryState,
18
- QueryTimeToLive,
19
- QueryUpdateCallback,
20
- MutationCallback,
21
- RecordVersionArray,
22
- QueryConfigRecord,
23
- UpdateOptions,
24
- RunOptions,
25
- } from '../../types';
26
- import {
27
- parseRecordIdString,
28
- extractIdPart,
29
- encodeRecordId,
30
- parseDuration,
31
- withRetry,
32
- surql,
33
- parseParams,
34
- extractTablePart,
35
- generateId,
36
- } from '../../utils/index';
37
- import { CreateEvent, DeleteEvent, UpdateEvent } from '../sync/index';
38
- import { PushEventOptions } from '../../events/index';
39
-
40
- /**
41
- * DataModule - Unified query and mutation management
42
- *
43
- * Merges the functionality of QueryManager and MutationManager.
44
- * Uses CacheModule for all storage operations.
45
- */
46
- export class DataModule<S extends SchemaStructure> {
47
- private activeQueries: Map<QueryHash, QueryState> = new Map();
48
- private pendingQueries: Map<QueryHash, Promise<QueryHash>> = new Map();
49
- private subscriptions: Map<QueryHash, Set<QueryUpdateCallback>> = new Map();
50
- private mutationCallbacks: Set<MutationCallback> = new Set();
51
- private debounceTimers: Map<QueryHash, NodeJS.Timeout> = new Map();
52
- private logger: Logger;
53
-
54
- constructor(
55
- private cache: CacheModule,
56
- private local: LocalDatabaseService,
57
- private schema: S,
58
- logger: Logger,
59
- private streamDebounceTime: number = 100
60
- ) {
61
- this.logger = logger.child({ service: 'DataModule' });
62
- }
63
-
64
- async init(): Promise<void> {
65
- this.logger.info({ Category: 'sp00ky-client::DataModule::init' }, 'DataModule initialized');
66
- }
67
-
68
- // ==================== QUERY MANAGEMENT ====================
69
-
70
- /**
71
- * Register a query and return its hash for subscriptions
72
- */
73
- async query<T extends TableNames<S>>(
74
- tableName: T,
75
- surqlString: string,
76
- params: Record<string, any>,
77
- ttl: QueryTimeToLive
78
- ): Promise<QueryHash> {
79
- const hash = await this.calculateHash({ surql: surqlString, params });
80
- this.logger.debug(
81
- { hash, Category: 'sp00ky-client::DataModule::query' },
82
- 'Query Initialization: started'
83
- );
84
-
85
- const recordId = new RecordId('_00_query', hash);
86
-
87
- if (this.activeQueries.has(hash)) {
88
- this.logger.debug(
89
- { hash, Category: 'sp00ky-client::DataModule::query' },
90
- 'Query Initialization: exists, returning'
91
- );
92
- return hash;
93
- }
94
-
95
- // Another call is already creating this query — wait for it
96
- if (this.pendingQueries.has(hash)) {
97
- this.logger.debug(
98
- { hash, Category: 'sp00ky-client::DataModule::query' },
99
- 'Query Initialization: pending, waiting for existing creation'
100
- );
101
- await this.pendingQueries.get(hash);
102
- return hash;
103
- }
104
-
105
- this.logger.debug(
106
- { hash, Category: 'sp00ky-client::DataModule::query' },
107
- 'Query Initialization: not found, creating new query'
108
- );
109
-
110
- // Create the query and track the pending promise
111
- const promise = this.createAndRegisterQuery<T>(hash, recordId, surqlString, params, ttl, tableName);
112
- this.pendingQueries.set(hash, promise);
113
- try {
114
- await promise;
115
- } finally {
116
- this.pendingQueries.delete(hash);
117
- }
118
-
119
- return hash;
120
- }
121
-
122
- /**
123
- * Subscribe to query updates
124
- */
125
- subscribe(
126
- queryHash: string,
127
- callback: QueryUpdateCallback,
128
- options: { immediate?: boolean } = {}
129
- ): () => void {
130
- if (!this.subscriptions.has(queryHash)) {
131
- this.subscriptions.set(queryHash, new Set());
132
- }
133
-
134
- this.subscriptions.get(queryHash)?.add(callback);
135
-
136
- if (options.immediate) {
137
- const query = this.activeQueries.get(queryHash);
138
- if (query) {
139
- callback(query.records);
140
- }
141
- }
142
-
143
- // Return unsubscribe function
144
- return () => {
145
- const subs = this.subscriptions.get(queryHash);
146
- if (subs) {
147
- subs.delete(callback);
148
- if (subs.size === 0) {
149
- this.subscriptions.delete(queryHash);
150
- }
151
- }
152
- };
153
- }
154
-
155
- /**
156
- * Subscribe to mutations (for sync)
157
- */
158
- onMutation(callback: MutationCallback): () => void {
159
- this.mutationCallbacks.add(callback);
160
- return () => {
161
- this.mutationCallbacks.delete(callback);
162
- };
163
- }
164
-
165
- /**
166
- * Handle stream updates from DBSP (via CacheModule)
167
- */
168
- async onStreamUpdate(update: StreamUpdate): Promise<void> {
169
- const { queryHash, op } = update;
170
-
171
- // Only debounce UPDATE operations
172
- // CREATE and DELETE should propagate immediately
173
- if (op === 'UPDATE') {
174
- // Clear existing timer if any
175
- if (this.debounceTimers.has(queryHash)) {
176
- clearTimeout(this.debounceTimers.get(queryHash)!);
177
- }
178
-
179
- // Set new timer
180
- const timer = setTimeout(async () => {
181
- this.debounceTimers.delete(queryHash);
182
- await this.processStreamUpdate(update);
183
- }, this.streamDebounceTime);
184
-
185
- this.debounceTimers.set(queryHash, timer);
186
- } else {
187
- // CREATE and DELETE - process immediately
188
- await this.processStreamUpdate(update);
189
- }
190
- }
191
-
192
- private async processStreamUpdate(update: StreamUpdate): Promise<void> {
193
- const { queryHash, localArray } = update;
194
- const queryState = this.activeQueries.get(queryHash);
195
- if (!queryState) {
196
- this.logger.warn(
197
- { queryHash, Category: 'sp00ky-client::DataModule::onStreamUpdate' },
198
- 'Received update for unknown query. Skipping...'
199
- );
200
- return;
201
- }
202
-
203
- try {
204
- // Fetch updated records
205
- const [records] = await this.local.query<[Record<string, any>[]]>(
206
- queryState.config.surql,
207
- queryState.config.params
208
- );
209
-
210
- // Update state
211
- const newRecords = records || [];
212
- queryState.config.localArray = localArray;
213
- await this.local.query(surql.seal(surql.updateSet('id', ['localArray'])), {
214
- id: queryState.config.id,
215
- localArray,
216
- });
217
-
218
- // Skip notification if records haven't changed
219
- const prevJson = JSON.stringify(queryState.records);
220
- const newJson = JSON.stringify(newRecords);
221
- queryState.records = newRecords;
222
- if (prevJson === newJson) {
223
- this.logger.debug(
224
- { queryHash, Category: 'sp00ky-client::DataModule::onStreamUpdate' },
225
- 'Query records unchanged, skipping notification'
226
- );
227
- return;
228
- }
229
-
230
- queryState.updateCount++;
231
-
232
- // Notify subscribers
233
- const subscribers = this.subscriptions.get(queryHash);
234
- if (subscribers) {
235
- for (const callback of subscribers) {
236
- callback(queryState.records);
237
- }
238
- }
239
-
240
- this.logger.debug(
241
- {
242
- queryHash,
243
- recordCount: records?.length,
244
- Category: 'sp00ky-client::DataModule::onStreamUpdate',
245
- },
246
- 'Query updated from stream'
247
- );
248
- } catch (err) {
249
- this.logger.error(
250
- { err, queryHash, Category: 'sp00ky-client::DataModule::onStreamUpdate' },
251
- 'Failed to fetch records for stream update'
252
- );
253
- }
254
- }
255
-
256
- /**
257
- * Get query state (for sync and devtools)
258
- */
259
- getQueryByHash(hash: string): QueryState | undefined {
260
- return this.activeQueries.get(hash);
261
- }
262
-
263
- /**
264
- * Get query state by id (for sync and devtools)
265
- */
266
- getQueryById(id: RecordId<string>): QueryState | undefined {
267
- return this.activeQueries.get(extractIdPart(id));
268
- }
269
-
270
- /**
271
- * Get all active queries (for devtools)
272
- */
273
- getActiveQueries(): QueryState[] {
274
- return Array.from(this.activeQueries.values());
275
- }
276
-
277
- async updateQueryLocalArray(id: string, localArray: RecordVersionArray): Promise<void> {
278
- const queryState = this.activeQueries.get(id);
279
- if (!queryState) {
280
- this.logger.warn(
281
- { id, Category: 'sp00ky-client::DataModule::updateQueryLocalArray' },
282
- 'Query to update local array not found'
283
- );
284
- return;
285
- }
286
- queryState.config.localArray = localArray;
287
- await this.local.query(surql.seal(surql.updateSet('id', ['localArray'])), {
288
- id: queryState.config.id,
289
- localArray,
290
- });
291
- }
292
-
293
- async updateQueryRemoteArray(hash: string, remoteArray: RecordVersionArray): Promise<void> {
294
- const queryState = this.getQueryByHash(hash);
295
- if (!queryState) {
296
- this.logger.warn(
297
- { hash, Category: 'sp00ky-client::DataModule::updateQueryRemoteArray' },
298
- 'Query to update remote array not found'
299
- );
300
- return;
301
- }
302
- queryState.config.remoteArray = remoteArray;
303
- await this.local.query(surql.seal(surql.updateSet('id', ['remoteArray'])), {
304
- id: queryState.config.id,
305
- remoteArray,
306
- });
307
- }
308
-
309
- /**
310
- * Called after a query's initial sync completes.
311
- * Ensures subscribers are notified even if no stream updates fired (e.g. empty result set).
312
- */
313
- async notifyQuerySynced(queryHash: string): Promise<void> {
314
- const queryState = this.activeQueries.get(queryHash);
315
- if (!queryState) return;
316
-
317
- // Re-query local DB for latest data
318
- const [records] = await this.local.query<[Record<string, any>[]]>(
319
- queryState.config.surql,
320
- queryState.config.params
321
- );
322
- const newRecords = records || [];
323
- const changed = JSON.stringify(queryState.records) !== JSON.stringify(newRecords);
324
- queryState.records = newRecords;
325
-
326
- // Notify if data changed OR if this is the first sync (updateCount === 0)
327
- // The latter handles "query truly has no results" so UI can stop loading
328
- if (changed || queryState.updateCount === 0) {
329
- queryState.updateCount++;
330
- const subscribers = this.subscriptions.get(queryHash);
331
- if (subscribers) {
332
- for (const callback of subscribers) {
333
- callback(queryState.records);
334
- }
335
- }
336
- }
337
- }
338
-
339
- // ==================== RUN JOBS ====================
340
-
341
- async run<B extends BackendNames<S>, R extends BackendRoutes<S, B>>(
342
- backend: B,
343
- path: R,
344
- data: RoutePayload<S, B, R>,
345
- options?: RunOptions
346
- ): Promise<void> {
347
- const route = this.schema.backends?.[backend]?.routes?.[path];
348
- if (!route) {
349
- throw new Error(`Route ${backend}.${path} not found`);
350
- }
351
-
352
- const tableName = this.schema.backends?.[backend]?.outboxTable;
353
- if (!tableName) {
354
- throw new Error(`Outbox table for backend ${backend} not found`);
355
- }
356
-
357
- const payload: Record<string, unknown> = {};
358
- for (const argName of Object.keys(route.args)) {
359
- const arg = route.args[argName];
360
- if ((data as Record<string, unknown>)[argName] === undefined && arg.optional === false) {
361
- throw new Error(`Missing required argument ${argName}`);
362
- }
363
- payload[argName] = (data as Record<string, unknown>)[argName];
364
- }
365
-
366
- const record: Record<string, unknown> = {
367
- path,
368
- payload: JSON.stringify(payload),
369
- max_retries: options?.max_retries ?? 3,
370
- retry_strategy: options?.retry_strategy ?? 'linear',
371
- };
372
-
373
- if (options?.assignedTo) {
374
- record.assigned_to = options.assignedTo;
375
- }
376
-
377
- const recordId = `${tableName}:${generateId()}`;
378
- await this.create(recordId, record);
379
- }
380
-
381
- // ==================== MUTATION MANAGEMENT ====================
382
-
383
- /**
384
- * Create a new record
385
- */
386
- async create<T extends Record<string, unknown>>(id: string, data: T): Promise<T> {
387
- const tableName = extractTablePart(id);
388
- const tableSchema = this.schema.tables.find((t) => t.name === tableName);
389
- if (!tableSchema) {
390
- throw new Error(`Table ${tableName} not found`);
391
- }
392
-
393
- const rid = parseRecordIdString(id);
394
- const params = parseParams(tableSchema.columns, data);
395
- const mutationId = parseRecordIdString(`_00_pending_mutations:${Date.now()}`);
396
-
397
- const dataKeys = Object.keys(params).map((key) => ({ key, variable: `data_${key}` }));
398
- const prefixedParams = Object.fromEntries(
399
- dataKeys.map(({ key, variable }) => [variable, params[key]])
400
- );
401
- const query = surql.seal<T>(
402
- surql.tx([
403
- surql.createSet('id', dataKeys),
404
- surql.createMutation('create', 'mid', 'id', 'data'),
405
- ]),
406
- { resultIndex: 0 }
407
- );
408
-
409
- const target = await withRetry(this.logger, () =>
410
- this.local.execute(query, {
411
- id: rid,
412
- mid: mutationId,
413
- ...prefixedParams,
414
- })
415
- );
416
-
417
- const parsedRecord = parseParams(tableSchema.columns, target) as RecordWithId;
418
-
419
- // Save to cache (which handles DBSP ingestion)
420
- await this.cache.save(
421
- {
422
- table: tableName,
423
- op: 'CREATE',
424
- record: parsedRecord,
425
- version: 1,
426
- },
427
- true
428
- );
429
-
430
- // Emit mutation event for sync
431
- const mutationEvent: CreateEvent = {
432
- type: 'create',
433
- mutation_id: mutationId,
434
- record_id: rid,
435
- data: params,
436
- record: target,
437
- tableName,
438
- };
439
-
440
- for (const callback of this.mutationCallbacks) {
441
- callback([mutationEvent]);
442
- }
443
-
444
- this.logger.debug({ id, Category: 'sp00ky-client::DataModule::create' }, 'Record created');
445
-
446
- return target;
447
- }
448
-
449
- /**
450
- * Update an existing record
451
- */
452
- async update<T extends Record<string, unknown>>(
453
- table: string,
454
- id: string,
455
- data: Partial<T>,
456
- options?: UpdateOptions
457
- ): Promise<T> {
458
- const tableName = extractTablePart(id);
459
- const tableSchema = this.schema.tables.find((t) => t.name === tableName);
460
- if (!tableSchema) {
461
- throw new Error(`Table ${tableName} not found`);
462
- }
463
-
464
- const rid = parseRecordIdString(id);
465
- const params = parseParams(tableSchema.columns, data);
466
- const mutationId = parseRecordIdString(`_00_pending_mutations:${Date.now()}`);
467
-
468
- // Capture current record state before mutation for rollback support
469
- const [beforeRecord] = await withRetry(this.logger, () =>
470
- this.local.query<[Record<string, any>]>('SELECT * FROM ONLY $id', { id: rid })
471
- );
472
-
473
- const query = surql.seal<{ target: T }>(
474
- surql.tx([
475
- surql.updateSet('id', [{ statement: '_00_rv += 1' }]),
476
- surql.let('updated', surql.updateMerge('id', 'data')),
477
- surql.createMutation('update', 'mid', 'id', 'data'),
478
- surql.returnObject([{ key: 'target', variable: 'updated' }]),
479
- ])
480
- );
481
-
482
- const { target } = await withRetry(this.logger, () =>
483
- this.local.execute(query, {
484
- id: rid,
485
- mid: mutationId,
486
- data: params,
487
- })
488
- );
489
-
490
- // Build a partial record with only the fields the user actually changed
491
- // This avoids overwriting rich relation objects (e.g. author: {id, name, ...})
492
- // with flat RecordIds from the UPDATE...MERGE result
493
- const updatedFields: Record<string, any> = { id: target.id };
494
- for (const key of Object.keys(data)) {
495
- if (key in target) {
496
- updatedFields[key] = (target as Record<string, any>)[key];
497
- }
498
- }
499
- if ('_00_rv' in (target as Record<string, any>)) {
500
- updatedFields._00_rv = (target as Record<string, any>)._00_rv;
501
- }
502
- this.replaceRecordInQueries(updatedFields);
503
-
504
- const parsedRecord = parseParams(tableSchema.columns, target) as RecordWithId;
505
-
506
- // Save to cache
507
- await this.cache.save(
508
- {
509
- table: table,
510
- op: 'UPDATE',
511
- record: parsedRecord,
512
- version: target._00_rv as number,
513
- },
514
- true
515
- );
516
-
517
- const pushEventOptions = parseUpdateOptions(id, data, options);
518
-
519
- // Emit mutation event
520
- const mutationEvent: UpdateEvent = {
521
- type: 'update',
522
- mutation_id: mutationId,
523
- record_id: rid,
524
- data: params,
525
- record: target,
526
- beforeRecord: beforeRecord || undefined,
527
- options: pushEventOptions,
528
- };
529
-
530
- for (const callback of this.mutationCallbacks) {
531
- callback([mutationEvent]);
532
- }
533
-
534
- this.logger.debug({ id, Category: 'sp00ky-client::DataModule::update' }, 'Record updated');
535
-
536
- return target;
537
- }
538
-
539
- /**
540
- * Delete a record
541
- */
542
- async delete(table: string, id: string): Promise<void> {
543
- const tableName = extractTablePart(id);
544
- const tableSchema = this.schema.tables.find((t) => t.name === tableName);
545
- if (!tableSchema) {
546
- throw new Error(`Table ${tableName} not found`);
547
- }
548
-
549
- const rid = parseRecordIdString(id);
550
- const mutationId = parseRecordIdString(`_00_pending_mutations:${Date.now()}`);
551
-
552
- // Fetch the record before deleting so DBSP can match it against query predicates
553
- const [beforeRecords] = await this.local.query<[Record<string, any>[]]>(
554
- 'SELECT * FROM ONLY $id',
555
- { id: rid }
556
- );
557
- const beforeRecord = beforeRecords ?? {};
558
-
559
- const query = surql.seal<void>(
560
- surql.tx([surql.delete('id'), surql.createMutation('delete', 'mid', 'id')])
561
- );
562
-
563
- await withRetry(this.logger, () => this.local.execute(query, { id: rid, mid: mutationId }));
564
- await this.cache.delete(table, id, true, beforeRecord);
565
-
566
- // DBSP may not emit view updates for DELETE ops —
567
- // manually notify all queries that reference this table
568
- for (const [queryHash, queryState] of this.activeQueries) {
569
- if (queryState.config.tableName === tableName) {
570
- await this.notifyQuerySynced(queryHash);
571
- }
572
- }
573
-
574
- // Emit mutation event
575
- const mutationEvent: DeleteEvent = {
576
- type: 'delete',
577
- mutation_id: mutationId,
578
- record_id: rid,
579
- };
580
-
581
- for (const callback of this.mutationCallbacks) {
582
- callback([mutationEvent]);
583
- }
584
-
585
- this.logger.debug({ id, Category: 'sp00ky-client::DataModule::delete' }, 'Record deleted');
586
- }
587
-
588
- // ==================== ROLLBACK METHODS ====================
589
-
590
- /**
591
- * Rollback a failed optimistic create by deleting the record locally
592
- */
593
- async rollbackCreate(recordId: RecordId, tableName: string): Promise<void> {
594
- const id = encodeRecordId(recordId);
595
-
596
- try {
597
- await withRetry(this.logger, () =>
598
- this.local.query('DELETE $id', { id: recordId })
599
- );
600
- await this.cache.delete(tableName, id, true);
601
- this.removeRecordFromQueries(recordId);
602
-
603
- this.logger.info(
604
- { id, tableName, Category: 'sp00ky-client::DataModule::rollbackCreate' },
605
- 'Rolled back optimistic create'
606
- );
607
- } catch (err) {
608
- this.logger.error(
609
- { err, id, tableName, Category: 'sp00ky-client::DataModule::rollbackCreate' },
610
- 'Failed to rollback create'
611
- );
612
- }
613
- }
614
-
615
- /**
616
- * Rollback a failed optimistic update by restoring the previous record state
617
- */
618
- async rollbackUpdate(
619
- recordId: RecordId,
620
- tableName: string,
621
- beforeRecord: Record<string, unknown>
622
- ): Promise<void> {
623
- const id = encodeRecordId(recordId);
624
-
625
- try {
626
- const { id: _recordId, ...content } = beforeRecord;
627
- await withRetry(this.logger, () =>
628
- this.local.query(surql.seal(surql.upsert('id', 'content')), {
629
- id: recordId,
630
- content,
631
- })
632
- );
633
-
634
- const tableSchema = this.schema.tables.find((t) => t.name === tableName);
635
- const parsedRecord = tableSchema
636
- ? (parseParams(tableSchema.columns, beforeRecord) as RecordWithId)
637
- : (beforeRecord as RecordWithId);
638
-
639
- await this.cache.save(
640
- {
641
- table: tableName,
642
- op: 'UPDATE',
643
- record: parsedRecord,
644
- version: (beforeRecord._00_rv as number) || 1,
645
- },
646
- true
647
- );
648
-
649
- // Replace in active queries for immediate UI update
650
- await this.replaceRecordInQueries(beforeRecord);
651
-
652
- this.logger.info(
653
- { id, tableName, Category: 'sp00ky-client::DataModule::rollbackUpdate' },
654
- 'Rolled back optimistic update'
655
- );
656
- } catch (err) {
657
- this.logger.error(
658
- { err, id, tableName, Category: 'sp00ky-client::DataModule::rollbackUpdate' },
659
- 'Failed to rollback update'
660
- );
661
- }
662
- }
663
-
664
- /**
665
- * Remove a record from all active query states and notify subscribers
666
- */
667
- private removeRecordFromQueries(recordId: RecordId): void {
668
- const encodedId = encodeRecordId(recordId);
669
-
670
- for (const [queryHash, queryState] of this.activeQueries.entries()) {
671
- const index = queryState.records.findIndex((r) => {
672
- const rId = r.id instanceof RecordId ? encodeRecordId(r.id) : String(r.id);
673
- return rId === encodedId;
674
- });
675
-
676
- if (index !== -1) {
677
- queryState.records.splice(index, 1);
678
- const subscribers = this.subscriptions.get(queryHash);
679
- if (subscribers) {
680
- for (const callback of subscribers) {
681
- callback(queryState.records);
682
- }
683
- }
684
- }
685
- }
686
- }
687
-
688
- // ==================== PRIVATE HELPERS ====================
689
-
690
- private async createAndRegisterQuery<T extends TableNames<S>>(
691
- hash: QueryHash,
692
- recordId: RecordId,
693
- surqlString: string,
694
- params: Record<string, any>,
695
- ttl: QueryTimeToLive,
696
- tableName: T
697
- ): Promise<QueryHash> {
698
- const queryState = await this.createNewQuery<T>({
699
- recordId,
700
- surql: surqlString,
701
- params,
702
- ttl,
703
- tableName,
704
- });
705
-
706
- const { localArray } = this.cache.registerQuery({
707
- queryHash: hash,
708
- surql: surqlString,
709
- params,
710
- ttl: new Duration(ttl),
711
- lastActiveAt: new Date(),
712
- });
713
-
714
- await withRetry(this.logger, () =>
715
- this.local.query(surql.seal(surql.updateSet('id', ['localArray'])), {
716
- id: recordId,
717
- localArray,
718
- })
719
- );
720
-
721
- this.activeQueries.set(hash, queryState);
722
- this.startTTLHeartbeat(queryState);
723
- this.logger.debug(
724
- {
725
- hash,
726
- tableName,
727
- recordCount: queryState.records.length,
728
- Category: 'sp00ky-client::DataModule::query',
729
- },
730
- 'Query registered'
731
- );
732
-
733
- return hash;
734
- }
735
-
736
- private async createNewQuery<T extends TableNames<S>>({
737
- recordId,
738
- surql: surqlString,
739
- params,
740
- ttl,
741
- tableName,
742
- }: {
743
- recordId: RecordId;
744
- surql: string;
745
- params: Record<string, any>;
746
- ttl: QueryTimeToLive;
747
- tableName: T;
748
- }): Promise<QueryState> {
749
- const tableSchema = this.schema.tables.find((t) => t.name === tableName);
750
- if (!tableSchema) {
751
- throw new Error(`Table ${tableName} not found`);
752
- }
753
-
754
- let [configRecord] = await withRetry(this.logger, () =>
755
- this.local.query<[QueryConfigRecord]>('SELECT * FROM ONLY $id', {
756
- id: recordId,
757
- })
758
- );
759
-
760
- if (!configRecord) {
761
- const [createdRecord] = await withRetry(this.logger, () =>
762
- this.local.query<[QueryConfigRecord]>(surql.seal(surql.create('id', 'data')), {
763
- id: recordId,
764
- data: {
765
- surql: surqlString,
766
- params: params,
767
- localArray: [],
768
- remoteArray: [],
769
- lastActiveAt: new Date(),
770
- ttl,
771
- tableName,
772
- },
773
- })
774
- );
775
- configRecord = createdRecord;
776
- }
777
-
778
- const config: QueryConfig = {
779
- ...configRecord,
780
- id: recordId,
781
- params: parseParams(tableSchema.columns, configRecord.params),
782
- };
783
-
784
- let records: Record<string, any>[] = [];
785
- try {
786
- const [result] = await this.local.query<[Record<string, any>[]]>(surqlString, params);
787
- records = result || [];
788
- } catch (err) {
789
- this.logger.warn(
790
- { err, Category: 'sp00ky-client::DataModule::createNewQuery' },
791
- 'Failed to load initial cached records'
792
- );
793
- }
794
-
795
- return {
796
- config,
797
- records,
798
- ttlTimer: null,
799
- ttlDurationMs: parseDuration(ttl),
800
- updateCount: 0,
801
- };
802
- }
803
-
804
- private async calculateHash(data: any): Promise<string> {
805
- const content = JSON.stringify(data);
806
- const msgBuffer = new TextEncoder().encode(content);
807
- const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer);
808
- const hashArray = Array.from(new Uint8Array(hashBuffer));
809
- return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
810
- }
811
-
812
- private startTTLHeartbeat(queryState: QueryState): void {
813
- if (queryState.ttlTimer) return;
814
-
815
- const heartbeatTime = Math.floor(queryState.ttlDurationMs * 0.9);
816
-
817
- queryState.ttlTimer = setTimeout(() => {
818
- // TODO: Emit heartbeat event for sync
819
- this.logger.debug(
820
- {
821
- id: encodeRecordId(queryState.config.id),
822
- Category: 'sp00ky-client::DataModule::startTTLHeartbeat',
823
- },
824
- 'TTL heartbeat'
825
- );
826
- this.startTTLHeartbeat(queryState);
827
- }, heartbeatTime);
828
- }
829
-
830
- private stopTTLHeartbeat(queryState: QueryState): void {
831
- if (queryState.ttlTimer) {
832
- clearTimeout(queryState.ttlTimer);
833
- queryState.ttlTimer = null;
834
- }
835
- }
836
-
837
- private async replaceRecordInQueries(record: Record<string, any>): Promise<void> {
838
- for (const [queryHash, queryState] of this.activeQueries.entries()) {
839
- const index = queryState.records.findIndex((r) => r.id === record.id);
840
- if (index !== -1) {
841
- queryState.records[index] = { ...queryState.records[index], ...record };
842
- // Notify subscribers so UI updates immediately
843
- const subscribers = this.subscriptions.get(queryHash);
844
- if (subscribers) {
845
- for (const callback of subscribers) {
846
- callback(queryState.records);
847
- }
848
- }
849
- }
850
- }
851
- }
852
- }
853
-
854
- // ==================== HELPER FUNCTIONS ====================
855
-
856
- /**
857
- * Parse update options to generate push event options
858
- */
859
- export function parseUpdateOptions(
860
- id: string,
861
- data: any,
862
- options?: UpdateOptions
863
- ): PushEventOptions {
864
- let pushEventOptions: PushEventOptions = {};
865
- if (options?.debounced) {
866
- const delay = options.debounced !== true ? (options.debounced?.delay ?? 200) : 200;
867
- const keyType = options.debounced !== true ? (options.debounced?.key ?? id) : id;
868
- const key =
869
- keyType === 'recordId_x_fields' ? `${id}::${Object.keys(data).sort().join('#')}` : id;
870
-
871
- pushEventOptions = {
872
- debounced: {
873
- delay,
874
- key,
875
- },
876
- };
877
- }
878
- return pushEventOptions;
879
- }