@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
@@ -0,0 +1,737 @@
1
+ import type { Surreal } from 'surrealdb';
2
+ import type {
3
+ BackendNames,
4
+ BackendRoutes,
5
+ FinalQuery,
6
+ GetTable,
7
+ InnerQuery,
8
+ QueryOptions,
9
+ RoutePayload,
10
+ SchemaStructure,
11
+ TableModel,
12
+ TableNames,
13
+ BucketNames,
14
+ ChannelPeer,
15
+ ChannelTableNames,
16
+ } from '@spooky-sync/query-builder';
17
+ import type { ChannelQuery } from '../types';
18
+ import { QueryBuilder } from '@spooky-sync/query-builder';
19
+ import type {
20
+ PreloadOptions,
21
+ QueryAuthorityCallback,
22
+ QueryStatusCallback,
23
+ QueryTimeToLive,
24
+ RunOptions,
25
+ Sp00kyChannel,
26
+ Sp00kyConfig,
27
+ Sp00kyQueryResultPromise,
28
+ StorageHealth,
29
+ SyncHealth,
30
+ UpdateOptions,
31
+ } from '../types';
32
+ import type { TabRole } from '../services/tabs/protocol';
33
+ import { StreamProcessorService } from '../services/stream-processor/index';
34
+ import type { StreamUpdateReceiver } from '../services/stream-processor/index';
35
+ import { AuthService } from '../modules/auth/index';
36
+ import { CrdtField } from '../modules/crdt/index';
37
+ import { FeatureFlagModule, FeatureFlagHandle } from '../modules/feature-flag/index';
38
+ import type { FeatureFlagOptions, FeatureFlagOverride } from '../modules/feature-flag/index';
39
+ import { AppReleaseModule, AppReleaseHandle } from '../modules/app-release/index';
40
+ import type { AppReleaseOptions } from '../modules/app-release/index';
41
+ import { WebPushModule } from '../modules/web-push/index';
42
+ import { ChannelManager, type ChannelHandle } from '../modules/channel/index';
43
+ import { surrealSignal, webrtcTransport } from '../modules/channel/webrtc';
44
+ import { DevToolsService } from '../modules/devtools/index';
45
+ import type { DevToolsQuerySource } from '../modules/devtools/index';
46
+ import { parseQueryParams, generateId } from '../utils/index';
47
+ import type { OutEvent, RuntimeEvent } from '../kernel/events';
48
+ import type { ClientState } from '../state/client-state';
49
+ import * as R from '../state/reducers';
50
+ import { pendingMutationCount, fetchingQueryCount, phaseTimings, toQueryState } from '../state/selectors';
51
+ import { defaultEnv, type SagaEnv } from '../query/env';
52
+ import { registerLocal, type RegisterInput } from '../query/register.saga';
53
+ import { evictQuery } from '../query/lifecycle.saga';
54
+ import { peerCreate, peerDelete, readRow, write } from '../mutation/write.saga';
55
+ import { discardFailed, listFailed, retryFailed } from '../mutation/tray.saga';
56
+ import type { FailedMutationRow } from '../mutation/rows';
57
+ import { buildJobRecord } from '../mutation/jobs';
58
+ import { boot } from '../boot/boot.saga';
59
+ import { preload } from '../boot/preload.saga';
60
+ import { DEGRADE_AFTER_FAILURES, OUTBOX_BATCH_SIZE, PUSH_TIMEOUT_MS } from '../kernel/constants';
61
+ import { Runtime } from './runtime';
62
+ import { createAdapters, createServices, createTabsCoordinator, type ServiceHost, type Services } from './services';
63
+ import { BucketHandle, bucketSettings } from './bucket-handle';
64
+ import { ImpersonationBanner } from '../modules/auth/impersonation-banner';
65
+ import type { LeaderSyncHub, SyncForwarder } from '../services/tabs/coordinator';
66
+
67
+ const UNKNOWN_STORAGE_HEALTH: StorageHealth = Object.freeze({ status: 'unknown', fallback: false });
68
+ /** Longest SurrealQL a statement log line shows as its message. */
69
+ const STATEMENT_MSG_MAX = 160;
70
+
71
+ export interface Sp00kyClientDeps<S extends SchemaStructure> {
72
+ services?: Services<S>;
73
+ runtime?: Runtime;
74
+ env?: Partial<SagaEnv>;
75
+ }
76
+
77
+ /**
78
+ * The public client. Every method is a thin translation into a saga run, a
79
+ * state read, or a subscription on the runtime; the engine's behaviour lives
80
+ * in the sagas.
81
+ */
82
+ export class Sp00kyClient<S extends SchemaStructure> {
83
+ public readonly auth: AuthService<S>;
84
+ public readonly streamProcessor: StreamProcessorService;
85
+ /** Web Push for this browser: subscribe, devices, self-pushes, service worker bridge. */
86
+ public readonly webPush: WebPushModule;
87
+ /** Peer to peer channels of `-- @channel` tables. */
88
+ private readonly channels: ChannelManager;
89
+
90
+ private readonly services: Services<S>;
91
+ private readonly env: SagaEnv;
92
+ private readonly runtime: Runtime;
93
+ private readonly featureFlags: FeatureFlagModule<S>;
94
+ private readonly appReleases: AppReleaseModule<S>;
95
+ /** Teardown for subscriptions the client itself owns. */
96
+ private readonly disposers: Array<() => void> = [];
97
+ /** Assigned in `wireAdapters`, which the constructor always runs. */
98
+ private impersonationBanner!: ImpersonationBanner;
99
+ private readonly devTools: DevToolsService;
100
+ private hub: LeaderSyncHub | null = null;
101
+ private forwarder: SyncForwarder | null = null;
102
+ private detachWindow: (() => void) | null = null;
103
+ private sharedActive = false;
104
+
105
+ constructor(
106
+ private readonly config: Sp00kyConfig<S>,
107
+ deps: Sp00kyClientDeps<S> = {}
108
+ ) {
109
+ this.services = deps.services ?? createServices(config);
110
+ const s = this.services;
111
+ this.env = defaultEnv(config.schema, {
112
+ anonLive: config.enableAnonymousLiveQueries === true,
113
+ remoteTimeoutMs: Math.max(0, config.database.queryTimeoutMs ?? 60_000),
114
+ pushTimeoutMs: config.pushTimeoutMs ?? PUSH_TIMEOUT_MS,
115
+ outboxBatchSize: OUTBOX_BATCH_SIZE,
116
+ degradeAfter: config.syncHealth === false ? 0 : (config.syncHealth?.degradeAfterConsecutiveFailures ?? DEGRADE_AFTER_FAILURES),
117
+ materializeDebounceMs: config.streamDebounceTime ?? 50,
118
+ pollBaseMs: config.refSyncIntervalMs && config.refSyncIntervalMs > 0 ? config.refSyncIntervalMs : 500,
119
+ ...deps.env,
120
+ });
121
+ const host: ServiceHost = {
122
+ dispatch: (event) => void this.runtime.dispatch(event),
123
+ setTabsChannel: ({ hub, forwarder }) => {
124
+ this.hub = hub;
125
+ this.forwarder = forwarder;
126
+ this.sharedActive = hub !== null || forwarder !== null;
127
+ },
128
+ initModules: () => undefined,
129
+ detachWindow: () => this.detachWindow?.(),
130
+ setDetachWindow: (fn) => (this.detachWindow = fn),
131
+ };
132
+ if (!deps.runtime && s.tabs === null && s.tabsUnsupportedReason === undefined) {
133
+ s.tabs = createTabsCoordinator(config, s, host);
134
+ }
135
+ this.runtime =
136
+ deps.runtime ??
137
+ new Runtime({
138
+ env: this.env,
139
+ adapters: createAdapters(config, s, host, () => [this.featureFlags, this.appReleases]),
140
+ logger: s.logger,
141
+ tabId: s.tabId,
142
+ });
143
+ this.auth = s.auth;
144
+ this.streamProcessor = s.streamProcessor;
145
+
146
+ const liveQueryPort = {
147
+ query: (table: string, surql: string, params: Record<string, unknown>, ttl: QueryTimeToLive) =>
148
+ this.runtime.run(registerLocal(this.env, { tableName: table, surql, params, ttl })),
149
+ subscribe: (hash: string, cb: (records: Record<string, any>[]) => void, options?: { immediate?: boolean }) =>
150
+ this.runtime.subscribe(hash, cb, options),
151
+ };
152
+ const remoteRegisterPort = { enqueueDownEvent: () => void this.runtime.dispatch({ type: 'EnsureRegistered' }) };
153
+ // CRDT edit logs ride ordinary queries and the ordinary outbox.
154
+ s.crdt.attach({
155
+ watch: async (table, where, onRows) => {
156
+ const { hash } = await (this.query as any)(table, {}).where(where).build().run();
157
+ const stopRows = this.runtime.subscribe(hash, onRows, { immediate: true });
158
+ let known = false;
159
+ let idle = false;
160
+ let done!: () => void;
161
+ const settled = new Promise<void>((resolve) => (done = resolve));
162
+ const check = () => known && idle && done();
163
+ const stopAuthority = this.runtime.subscribeAuthority(hash, (k) => ((known = k), check()), { immediate: true });
164
+ const stopStatus = this.runtime.subscribeStatus(hash, (st) => ((idle = st === 'idle'), check()), { immediate: true });
165
+ return {
166
+ stop: () => {
167
+ stopRows();
168
+ stopAuthority();
169
+ stopStatus();
170
+ },
171
+ settled,
172
+ };
173
+ },
174
+ create: (id, data) => this.create(id, data),
175
+ });
176
+ this.featureFlags = new FeatureFlagModule<S>({ dataModule: liveQueryPort, sync: remoteRegisterPort, auth: s.auth, logger: s.logger });
177
+ this.appReleases = new AppReleaseModule<S>({ dataModule: liveQueryPort, sync: remoteRegisterPort, auth: s.auth, logger: s.logger });
178
+ this.webPush = new WebPushModule({
179
+ remote: s.remote,
180
+ auth: s.auth,
181
+ persistence: s.persistence,
182
+ logger: s.logger,
183
+ database: config.database,
184
+ connection: s.connectionSupervisor,
185
+ config: config.webPush,
186
+ });
187
+ this.disposers.push(this.webPush.attach());
188
+
189
+ const channelsConfig = config.channels;
190
+ this.channels = new ChannelManager(config.schema, {
191
+ config: channelsConfig,
192
+ logger: s.logger,
193
+ rows: {
194
+ create: (_table, id, row, ttlMs) => this.runtime.run(peerCreate(this.env, { recordId: id, data: row, ttlMs })),
195
+ delete: (_table, id, ttlMs) => this.runtime.run(peerDelete(this.env, { recordId: id, ttlMs })),
196
+ read: (id) => this.runtime.run(readRow(id)),
197
+ },
198
+ transport:
199
+ channelsConfig?.transport ??
200
+ ((hooks) =>
201
+ webrtcTransport(hooks, {
202
+ signal: surrealSignal(s.remote, s.connectionSupervisor, s.logger),
203
+ iceServers: channelsConfig?.iceServers,
204
+ logger: s.logger,
205
+ })),
206
+ });
207
+ if (this.channels.enabled) this.disposers.push(s.auth.subscribe((userId) => this.channels.setUser(userId)));
208
+
209
+ const querySource: DevToolsQuerySource = {
210
+ getActiveQueries: () => [...this.runtime.state.queries.values()].map(toQueryState),
211
+ getQueryById: (id) => {
212
+ const entry = [...this.runtime.state.queries.values()].find((e) => String(e.def.id) === String(id));
213
+ return entry ? toQueryState(entry) : undefined;
214
+ },
215
+ phaseTimings: (q) => {
216
+ const entry = this.runtime.state.queries.get(q.config.id.id as string) ?? [...this.runtime.state.queries.values()].find((e) => String(e.def.id) === String(q.config.id));
217
+ return entry ? phaseTimings(entry) : ({} as never);
218
+ },
219
+ };
220
+ this.devTools = new DevToolsService(s.local, s.remote, s.logger, config.schema, s.auth, querySource);
221
+ this.devTools.setFeatureFlagOverrides(this.featureFlags);
222
+ s.streamProcessor.addReceiver(this.devTools);
223
+ this.devTools.setBlobInfoProvider(() => s.blobs.stats());
224
+ this.devTools.setMutationSource({
225
+ state: () => this.runtime.state,
226
+ listFailed: () => this.listFailedMutations(),
227
+ retryFailed: (id) => this.retryFailedMutation(id),
228
+ discardFailed: (id) => this.discardFailedMutation(id),
229
+ });
230
+ if (s.logTap) this.devTools.setLogTap(s.logTap);
231
+ if (config.sharedTabs) {
232
+ this.devTools.setTabsInfoProvider(() => {
233
+ const c = s.tabs;
234
+ if (!this.sharedActive || !c) return { active: false, reason: s.tabsUnsupportedReason ?? 'fell-back' };
235
+ return {
236
+ active: true,
237
+ role: c.role,
238
+ tabId: c.tabId,
239
+ leadershipId: c.leadershipId,
240
+ leaderTabId: c.role === 'leader' ? c.tabId : c.leaderTabId,
241
+ ...(this.hub ? { followers: this.hub.followerCount, relayedBatches: this.hub.relayedBatches } : {}),
242
+ };
243
+ });
244
+ }
245
+ this.wireAdapters();
246
+ }
247
+
248
+ /** Adapter callbacks become runtime events; runtime events reach the legacy services. */
249
+ private wireAdapters(): void {
250
+ const s = this.services;
251
+ const receiver: StreamUpdateReceiver = { onStreamUpdate: (update) => void this.runtime.dispatch({ type: 'StreamUpdate', update }) };
252
+ s.streamProcessor.addReceiver(receiver);
253
+ s.connectionSupervisor.subscribe((state) => void this.runtime.dispatch({ type: 'ConnectionChanged', state }));
254
+ // Changes only. The immediate answer here is always "signed out" (nothing
255
+ // is restored before `init()`), and boot owns the first identity: it opens
256
+ // the bucket the hint names and announces the restored session itself. As
257
+ // a flip, that answer switched to the anon bucket while boot was still
258
+ // opening the user's, so two clients provisioned at once, boot failed, and
259
+ // a persistent store never got past the provider's fallback on reload.
260
+ s.auth.subscribe((userId) => void this.runtime.dispatch({ type: 'AuthFlip', userId }), { immediate: false });
261
+ // Whoever is looking at the page should be able to tell, and leave. The
262
+ // app can restyle this, replace it (`mode: 'custom'`, with a fallback if
263
+ // its own never renders) or take the job over entirely (`mode: 'none'`).
264
+ this.impersonationBanner = new ImpersonationBanner(
265
+ () => s.auth.stopImpersonating(),
266
+ this.config.impersonationBanner
267
+ );
268
+ const banner = this.impersonationBanner;
269
+ this.disposers.push(
270
+ s.auth.subscribeImpersonation((info) => banner.update(info)),
271
+ () => banner.unmount()
272
+ );
273
+ this.runtime.on('tabs:broadcast', (e) => {
274
+ if (e.type !== 'tabs:broadcast') return;
275
+ const msg = e.message as { type: string; records?: unknown[]; mutationId?: string };
276
+ if (this.hub) {
277
+ if (msg.type === 'ingest') this.hub.relayIngest(msg.records as never);
278
+ else this.hub.broadcast(msg as never);
279
+ } else if (this.forwarder) {
280
+ if (msg.type === 'ingest') this.forwarder.ingest(msg.records as never);
281
+ else if (msg.type === 'outbox-changed' && msg.mutationId) this.forwarder.mutationEnqueued(msg.mutationId);
282
+ }
283
+ });
284
+ this.runtime.on('tabs:sendTo', (e) => {
285
+ if (e.type === 'tabs:sendTo' && this.hub) this.hub.sendTo(e.tabId, e.message as never);
286
+ });
287
+ this.runtime.on('*', (e) => this.forwardToDevTools(e));
288
+ // One debug line per finished statement, for both stores and every engine
289
+ // (the SQLite engine logs none of its own). Guarded: this is the hottest
290
+ // path there is, and at the default `info` the line is never written.
291
+ // The message is the SurrealQL itself, so the Logs tab reads like a query
292
+ // trail; a long statement is cut there and kept whole in `query`.
293
+ const logStatement = (store: 'local' | 'remote') => (event: any) => {
294
+ if (!s.logger.isLevelEnabled('debug')) return;
295
+ const q = event.payload ?? {};
296
+ const sql = String(q.query ?? '').replace(/\s+/g, ' ').trim();
297
+ const cut = sql.length > STATEMENT_MSG_MAX;
298
+ s.logger.debug(
299
+ {
300
+ store,
301
+ ...(cut ? { query: q.query } : {}),
302
+ vars: q.vars,
303
+ ms: Math.round((q.duration ?? 0) * 10) / 10,
304
+ ok: q.success !== false,
305
+ error: q.error,
306
+ Category: `sp00ky-client::${store}`,
307
+ },
308
+ cut ? `${sql.slice(0, STATEMENT_MSG_MAX)}…` : sql
309
+ );
310
+ };
311
+ s.local.getEvents().subscribe('DATABASE_LOCAL_QUERY', logStatement('local'));
312
+ s.remote.getEvents().subscribe('DATABASE_REMOTE_QUERY', logStatement('remote'));
313
+ }
314
+
315
+ /** Runtime events the DevTools panel follows; query transitions also go to the log. */
316
+ private forwardToDevTools(e: OutEvent): void {
317
+ const log = this.services.logger;
318
+ switch (e.type) {
319
+ case 'query:status':
320
+ log.debug({ hash: e.hash, status: e.status, Category: 'sp00ky-client::query' }, 'Query status');
321
+ this.devTools.onStateChanged();
322
+ break;
323
+ case 'query:authority':
324
+ log.debug({ hash: e.hash, known: e.known, Category: 'sp00ky-client::query' }, e.known ? 'Query membership known' : 'Query membership unknown');
325
+ this.devTools.onStateChanged();
326
+ break;
327
+ case 'query:view-lost':
328
+ log.info({ hash: e.hash, Category: 'sp00ky-client::query' }, 'Query view lost on the server; re-registering');
329
+ this.devTools.onStateChanged();
330
+ break;
331
+ case 'mutation:event':
332
+ this.devTools.onMutation([e.event]);
333
+ break;
334
+ case 'mutation:settled':
335
+ this.devTools.onMutationOutcome({ mutationId: e.mutationId, recordId: e.recordId, eventType: e.eventType, status: 'synced' });
336
+ break;
337
+ case 'mutation:rolled-back':
338
+ this.devTools.onMutationOutcome({ mutationId: e.mutationId, recordId: e.recordId, eventType: e.eventType, status: 'rolled-back', error: e.error });
339
+ break;
340
+ case 'tray:changed':
341
+ case 'health:changed':
342
+ case 'activity:changed':
343
+ this.devTools.onStateChanged();
344
+ break;
345
+ default:
346
+ break;
347
+ }
348
+ }
349
+
350
+ // ---- lifecycle ---------------------------------------------------------------
351
+
352
+ async init(): Promise<void> {
353
+ await this.runtime.run(boot(this.env, { sharedTabs: this.services.tabs !== null }));
354
+ }
355
+
356
+ async close(): Promise<void> {
357
+ const s = this.services;
358
+ s.connectionSupervisor.dispose();
359
+ for (const dispose of this.disposers.splice(0)) dispose();
360
+ this.channels.close();
361
+ this.runtime.dispose();
362
+ await this.featureFlags.closeAll();
363
+ await this.appReleases.closeAll();
364
+ s.crdt.closeAll();
365
+ s.crdt.dispose();
366
+ await s.blobs.close().catch(() => {});
367
+ await s.streamProcessor.checkpoint('close').catch(() => {});
368
+ if (s.tabs) await s.tabs.stop();
369
+ this.detachWindow?.();
370
+ this.detachWindow = null;
371
+ await s.local.close();
372
+ await s.remote.close();
373
+ s.streamProcessor.dispose();
374
+ }
375
+
376
+ isLocalReady(): boolean {
377
+ return this.runtime.state.localReady;
378
+ }
379
+
380
+ // ---- queries -----------------------------------------------------------------
381
+
382
+ query<Table extends TableNames<S>>(
383
+ table: Table,
384
+ options: QueryOptions<TableModel<GetTable<S, Table>>, false>,
385
+ ttl: QueryTimeToLive = '10m'
386
+ ): QueryBuilder<S, Table, Sp00kyQueryResultPromise> {
387
+ return new QueryBuilder<S, Table, Sp00kyQueryResultPromise>(
388
+ this.config.schema,
389
+ table,
390
+ async (q) => ({ hash: await this.initQuery(table, q, ttl) }),
391
+ options
392
+ );
393
+ }
394
+
395
+ private registerInput(table: string, q: InnerQuery<any, any, any>, ttl: QueryTimeToLive): RegisterInput {
396
+ const tableSchema = this.config.schema.tables.find((t) => t.name === table);
397
+ if (!tableSchema) throw new Error(`Table ${table} not found`);
398
+ return {
399
+ tableName: table,
400
+ surql: q.selectQuery.query,
401
+ params: parseQueryParams(tableSchema.columns, q.selectQuery.vars ?? {}),
402
+ ttl,
403
+ plan: q.selectQuery.plan,
404
+ };
405
+ }
406
+
407
+ private initQuery(table: string, q: InnerQuery<any, any, any>, ttl: QueryTimeToLive): Promise<string> {
408
+ return this.runtime.run(registerLocal(this.env, this.registerInput(table, q, ttl)));
409
+ }
410
+
411
+ /**
412
+ * The raw remote escape hatches bypass the query builder, so their shapes
413
+ * can never be on the server's query allowlist. When the generated schema
414
+ * says the allowlist is on, refuse them unless the app opted in explicitly.
415
+ */
416
+ private assertRawRemoteAllowed(method: string): void {
417
+ const mode = this.config.schema.policy?.queryAllowlist;
418
+ if ((mode === 'warn' || mode === 'enforce') && !this.config.allowRawRemote) {
419
+ throw new Error(
420
+ `sp00ky: ${method} is disabled because sync.queryAllowlist is on for this schema; pass allowRawRemote: true in the client config to opt in`
421
+ );
422
+ }
423
+ }
424
+
425
+ async queryRaw(sql: string, params: Record<string, any>, ttl: QueryTimeToLive): Promise<string> {
426
+ this.assertRawRemoteAllowed('queryRaw');
427
+ const tableName = sql.split('FROM ')[1]?.split(' ')[0] ?? '';
428
+ return this.runtime.run(registerLocal(this.env, { tableName, surql: sql, params, ttl }));
429
+ }
430
+
431
+ /**
432
+ * Preload = register a query nobody subscribes to. Resolved before on this
433
+ * device: returns at once. Never resolved: resolves when the server's
434
+ * membership and every body are local. `signal` aborts the wait.
435
+ */
436
+ async preload(finalQuery: FinalQuery<S, any, any, any, any, any>, options?: PreloadOptions): Promise<void> {
437
+ const q = finalQuery.innerQuery;
438
+ await this.runtime.run(preload(this.env, this.registerInput(q.tableName, q, '10m')), { signal: options?.signal });
439
+ }
440
+
441
+ async subscribe(queryHash: string, callback: (records: Record<string, any>[]) => void, options?: { immediate?: boolean }): Promise<() => void> {
442
+ if (!this.channels.enabled) return this.runtime.subscribe(queryHash, callback, options);
443
+ // Channel values (peer updates, ephemeral fields) are folded into what
444
+ // subscribers see, never into the store.
445
+ const tap = this.channels.tap(callback);
446
+ const stop = this.runtime.subscribe(queryHash, tap.feed, options);
447
+ return () => {
448
+ stop();
449
+ tap.stop();
450
+ };
451
+ }
452
+
453
+ /**
454
+ * Open a peer to peer channel to `peers` (your own id is skipped; a
455
+ * connection forms only when both sides list each other) for the rows
456
+ * `queries` match, each a query on a `-- @channel` table. Only its `where`
457
+ * counts, evaluated against each row; the query is never registered. Writes
458
+ * to matching rows (plain or the channel's own) reach the peers. Close it
459
+ * when done.
460
+ */
461
+ channel<T extends ChannelTableNames<S>>(
462
+ queries: ChannelQuery<S, T> | ReadonlyArray<ChannelQuery<S, T>>,
463
+ peers: Iterable<ChannelPeer>
464
+ ): Sp00kyChannel<S, T> {
465
+ const handle = this.channels.open(this.channelScopes(queries), peers);
466
+ const scoped = (table: string) => {
467
+ this.channels.assertCarries(handle, table);
468
+ return handle;
469
+ };
470
+ return {
471
+ tables: handle.tables as () => ReadonlyArray<T>,
472
+ state: handle.state,
473
+ subscribe: handle.subscribe,
474
+ setPeers: handle.setPeers,
475
+ setQueries: (next) => handle.setScopes(this.channelScopes(next)),
476
+ close: handle.close,
477
+ update: (table, id, data, options) => this.writeUpdate(table, id, data as Record<string, unknown>, options, scoped(table)) as never,
478
+ create: (id, data) => this.writeCreate(id, data as Record<string, unknown>, scoped(id.slice(0, id.indexOf(':')))) as never,
479
+ delete: (table, id) => this.writeDelete(table, id, scoped(table)),
480
+ };
481
+ }
482
+
483
+ private channelScopes(queries: ChannelQuery<S, any> | ReadonlyArray<ChannelQuery<S, any>>) {
484
+ const list = (Array.isArray(queries) ? queries : [queries]) as ReadonlyArray<ChannelQuery<S, any>>;
485
+ return list.map((q) => {
486
+ const final = 'build' in q && typeof q.build === 'function' ? q.build() : (q as FinalQuery<S, any, any, any, any, any>);
487
+ const plan = final.innerQuery?.selectQuery?.plan;
488
+ if (!plan) throw new Error('channel(): pass a query, e.g. db.query(\'message\').where({ conversation })');
489
+ return this.channels.scope(plan, { isOne: final.isOne, ignoreUnused: final.ignoresUnusedArgs });
490
+ });
491
+ }
492
+
493
+ deregisterQuery(queryHash: string): void {
494
+ const entry = this.runtime.state.queries.get(queryHash);
495
+ if (!entry || entry.subscribers > 0) return;
496
+ void this.runtime.run(evictQuery(queryHash), { lane: { kind: 'serial', key: `mat:${queryHash}` } });
497
+ }
498
+
499
+ subscribeQueryStatus(queryHash: string, callback: QueryStatusCallback, options?: { immediate?: boolean }): () => void {
500
+ return this.runtime.subscribeStatus(queryHash, callback, options);
501
+ }
502
+
503
+ subscribeQueryAuthority(queryHash: string, callback: QueryAuthorityCallback, options?: { immediate?: boolean }): () => void {
504
+ return this.runtime.subscribeAuthority(queryHash, callback, options);
505
+ }
506
+
507
+ reportFrontendTiming(queryHash: string, ms: number): void {
508
+ if (Number.isFinite(ms)) this.runtime.update(R.recordPhase(queryHash, 'frontend', ms));
509
+ }
510
+
511
+ // ---- mutations ---------------------------------------------------------------
512
+
513
+ async create<T extends Record<string, unknown>>(id: string, data: T): Promise<T> {
514
+ return (await this.writeCreate(id, data)) as T;
515
+ }
516
+
517
+ async update<T extends Record<string, unknown>>(table: string, id: string, data: Partial<T>, options?: UpdateOptions): Promise<T> {
518
+ return (await this.writeUpdate(table, id, data as Record<string, unknown>, options)) as T;
519
+ }
520
+
521
+ async delete(table: string, id: string): Promise<void> {
522
+ await this.writeDelete(table, id);
523
+ }
524
+
525
+ // Peers go first in all three: the local write costs a transaction they need
526
+ // not wait for. `@ephemeral` fields never reach the database.
527
+
528
+ private async writeCreate(id: string, data: Record<string, unknown>, scope?: ChannelHandle): Promise<Record<string, unknown>> {
529
+ const table = id.slice(0, id.indexOf(':'));
530
+ const { persistent, ephemeral } = this.channels.split(table, data);
531
+ this.channels.localCreate(table, id, data, scope);
532
+ const out = await this.runtime.run(write(this.env, { kind: 'create', recordId: id, data: persistent }));
533
+ return { ...(out.record ?? persistent), ...(ephemeral ?? {}) };
534
+ }
535
+
536
+ private async writeUpdate(
537
+ table: string,
538
+ id: string,
539
+ data: Record<string, unknown>,
540
+ options?: UpdateOptions,
541
+ scope?: ChannelHandle
542
+ ): Promise<Record<string, unknown>> {
543
+ const tableName = table || id.slice(0, id.indexOf(':'));
544
+ const { persistent, ephemeral } = this.channels.split(tableName, data);
545
+ this.channels.local(tableName, id, persistent, ephemeral, scope);
546
+ if (ephemeral && Object.keys(persistent).length === 0) return data;
547
+ const out = await this.runtime.run(write(this.env, { kind: 'update', recordId: id, data: persistent, options }));
548
+ return { ...(out.record ?? persistent), ...(ephemeral ?? {}) };
549
+ }
550
+
551
+ private async writeDelete(table: string, id: string, scope?: ChannelHandle): Promise<void> {
552
+ // Awaited: the filter is checked against the row, which the delete removes.
553
+ await this.channels.localDelete(table || id.slice(0, id.indexOf(':')), id, scope);
554
+ await this.runtime.run(write(this.env, { kind: 'delete', recordId: id }));
555
+ }
556
+
557
+ async run<B extends BackendNames<S>, R extends BackendRoutes<S, B>>(backend: B, path: R, payload: RoutePayload<S, B, R>, options?: RunOptions): Promise<void> {
558
+ const { tableName, record } = buildJobRecord(this.config.schema, String(backend), String(path), payload as Record<string, unknown>, options);
559
+ await this.create(`${tableName}:${generateId()}`, record);
560
+ }
561
+
562
+ // ---- failed-writes tray -------------------------------------------------------
563
+
564
+ get failedMutationCount(): number {
565
+ return this.runtime.state.failedCount;
566
+ }
567
+
568
+ subscribeToFailedMutations(cb: (count: number) => void): () => void {
569
+ cb(this.runtime.state.failedCount);
570
+ return this.runtime.on('tray:changed', (e) => {
571
+ if (e.type === 'tray:changed') cb(e.count);
572
+ });
573
+ }
574
+
575
+ listFailedMutations(): Promise<FailedMutationRow[]> {
576
+ return this.runtime.run(listFailed());
577
+ }
578
+
579
+ async retryFailedMutation(mutationId: string): Promise<boolean> {
580
+ return this.runtime.run(retryFailed(this.env, mutationId), { lane: { kind: 'serial', key: 'tray' } });
581
+ }
582
+
583
+ async discardFailedMutation(mutationId: string): Promise<boolean> {
584
+ return this.runtime.run(discardFailed(mutationId), { lane: { kind: 'serial', key: 'tray' } });
585
+ }
586
+
587
+ // ---- observability -----------------------------------------------------------
588
+
589
+ get tabRole(): TabRole | null {
590
+ return this.sharedActive && this.services.tabs ? this.services.tabs.role : null;
591
+ }
592
+
593
+ get remoteClient(): Surreal {
594
+ return this.services.remote.getClient();
595
+ }
596
+
597
+ get localClient(): unknown {
598
+ return this.services.local.getClient();
599
+ }
600
+
601
+ get pendingMutationCount(): number {
602
+ return pendingMutationCount(this.runtime.state);
603
+ }
604
+
605
+ /** Kept for the e2e suite; the saga core has no LIVE retry ladder. */
606
+ get liveRetryCount(): number {
607
+ return 0;
608
+ }
609
+
610
+ subscribeToPendingMutations(cb: (count: number) => void): () => void {
611
+ cb(this.pendingMutationCount);
612
+ return this.runtime.on('activity:changed', (e) => {
613
+ if (e.type === 'activity:changed') cb(e.pending);
614
+ });
615
+ }
616
+
617
+ get fetchingQueryCount(): number {
618
+ return fetchingQueryCount(this.runtime.state);
619
+ }
620
+
621
+ subscribeToFetchActivity(cb: (fetching: number) => void): () => void {
622
+ cb(this.fetchingQueryCount);
623
+ return this.runtime.on('activity:changed', (e) => {
624
+ if (e.type === 'activity:changed') cb(e.fetching);
625
+ });
626
+ }
627
+
628
+ get syncHealth(): SyncHealth {
629
+ return this.runtime.state.sync.health;
630
+ }
631
+
632
+ subscribeToSyncHealth(cb: (health: SyncHealth) => void): () => void {
633
+ cb(this.syncHealth);
634
+ return this.runtime.on('health:changed', (e) => {
635
+ if (e.type === 'health:changed') cb(e.health);
636
+ });
637
+ }
638
+
639
+ get storageHealth(): StorageHealth {
640
+ return this.services.local.storageHealth ?? UNKNOWN_STORAGE_HEALTH;
641
+ }
642
+
643
+ subscribeToStorageHealth(cb: (health: StorageHealth) => void): () => void {
644
+ if (this.services.local.subscribeToStorageHealth) return this.services.local.subscribeToStorageHealth(cb);
645
+ cb(UNKNOWN_STORAGE_HEALTH);
646
+ return () => {};
647
+ }
648
+
649
+ /** The engine state, read-only. For tests and DevTools. */
650
+ get state(): ClientState {
651
+ return this.runtime.state;
652
+ }
653
+
654
+ /** Feed an event into the engine. For adapters, tests and DevTools. */
655
+ dispatch(event: RuntimeEvent): Promise<void> {
656
+ return this.runtime.dispatch(event);
657
+ }
658
+
659
+ // ---- auth / remote / crdt / flags / buckets ------------------------------------
660
+
661
+ authenticate(token: string) {
662
+ this.services.remote.setAuthToken(token);
663
+ return this.services.remote.getClient().authenticate(token);
664
+ }
665
+
666
+ deauthenticate() {
667
+ return this.services.remote.getClient().invalidate();
668
+ }
669
+
670
+ async useRemote<T>(fn: (client: Surreal) => Promise<T> | T): Promise<T> {
671
+ this.assertRawRemoteAllowed('useRemote');
672
+ return fn(this.services.remote.getClient());
673
+ }
674
+
675
+ async remoteQuery<T extends unknown[]>(sql: string, vars?: Record<string, unknown>): Promise<T> {
676
+ this.assertRawRemoteAllowed('remoteQuery');
677
+ return this.services.remote.query<T>(sql, vars);
678
+ }
679
+
680
+ /**
681
+ * Tell the client that the app's own impersonation banner is on screen.
682
+ * Only meaningful with `impersonationBanner: { mode: 'custom' }`, where it
683
+ * cancels the fallback that would otherwise show the built-in banner. Call
684
+ * it while the custom banner is rendered (the Solid `useImpersonation`
685
+ * hook does it for you with `rendersBanner: true`).
686
+ */
687
+ acknowledgeImpersonationBanner(): void {
688
+ this.impersonationBanner.acknowledge();
689
+ }
690
+
691
+ feature(key: string, options?: FeatureFlagOptions): FeatureFlagHandle {
692
+ return this.featureFlags.feature(key, options);
693
+ }
694
+
695
+ setFeatureOverride(key: string, variant: string | null, payload?: unknown): void {
696
+ this.featureFlags.setLocalOverride(key, variant, payload);
697
+ }
698
+
699
+ clearFeatureOverrides(): void {
700
+ this.featureFlags.clearLocalOverrides();
701
+ }
702
+
703
+ getFeatureOverrides(): Record<string, FeatureFlagOverride> {
704
+ return this.featureFlags.getLocalOverrides();
705
+ }
706
+
707
+ appRelease(app: string, options?: AppReleaseOptions): AppReleaseHandle {
708
+ return this.appReleases.release(app, options);
709
+ }
710
+
711
+ /** Open a `@crdt` field for editing. `draft` (from {@link createCrdtDraft})
712
+ * hands over edits made before the record existed. */
713
+ async openCrdtField(table: string, recordId: string, field: string, draft?: CrdtField): Promise<CrdtField> {
714
+ return this.services.crdt.open(table, recordId, field, draft);
715
+ }
716
+
717
+ /** A detached `@crdt` document for a record not created yet; see
718
+ * {@link openCrdtField}. */
719
+ async createCrdtDraft(table: string, field: string): Promise<CrdtField> {
720
+ return this.services.crdt.createDraft(table, field);
721
+ }
722
+
723
+ /** Close a `@crdt` field. The last close resolves once its unsent edits
724
+ * are queued in the outbox; offline, once they are saved on the device. */
725
+ closeCrdtField(table: string, recordId: string, field: string): Promise<void> {
726
+ return this.services.crdt.close(table, recordId, field);
727
+ }
728
+
729
+ bucket<B extends BucketNames<S>>(name: B): BucketHandle {
730
+ return new BucketHandle(name, this.services.remote, this.services.blobs, bucketSettings(this.config, name, this.services.logger));
731
+ }
732
+
733
+ getBlobCacheStats() {
734
+ return this.services.blobs.stats();
735
+ }
736
+ }
737
+