@spooky-sync/core 0.0.1-canary.38 → 0.0.1-canary.381

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