@spooky-sync/core 0.0.1-canary.26 → 0.0.1-canary.261

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 (231) hide show
  1. package/AGENTS.md +78 -0
  2. package/README.md +56 -16
  3. package/dist/index.d.ts +2355 -81
  4. package/dist/index.js +13247 -2595
  5. package/dist/otel/index.d.ts +2 -2
  6. package/dist/otel/index.js +6 -6
  7. package/dist/sqlite-open.js +331 -0
  8. package/dist/sqlite-worker.d.ts +1 -0
  9. package/dist/sqlite-worker.js +439 -0
  10. package/dist/tabs-broker-worker.d.ts +8 -0
  11. package/dist/tabs-broker-worker.js +472 -0
  12. package/dist/types.d.ts +793 -46
  13. package/package.json +16 -8
  14. package/scripts/check-broker-bundle.mjs +33 -0
  15. package/skills/{spooky-core → sp00ky-core}/SKILL.md +30 -12
  16. package/skills/{spooky-core → sp00ky-core}/references/auth.md +1 -1
  17. package/skills/{spooky-core → sp00ky-core}/references/config.md +2 -2
  18. package/src/boot/auth-flip.saga.test.ts +82 -0
  19. package/src/boot/auth-flip.saga.ts +62 -0
  20. package/src/boot/boot.saga.test.ts +151 -0
  21. package/src/boot/boot.saga.ts +128 -0
  22. package/src/boot/bucket-switch.saga.test.ts +187 -0
  23. package/src/boot/bucket-switch.saga.ts +131 -0
  24. package/src/boot/preload.saga.test.ts +51 -0
  25. package/src/boot/preload.saga.ts +31 -0
  26. package/src/bucket-blurhash.test.ts +148 -0
  27. package/src/build-globals.d.ts +12 -0
  28. package/src/client/bucket-handle.test.ts +117 -0
  29. package/src/client/bucket-handle.ts +227 -0
  30. package/src/client/router.test.ts +56 -0
  31. package/src/client/router.ts +96 -0
  32. package/src/client/runtime.test.ts +192 -0
  33. package/src/client/runtime.ts +296 -0
  34. package/src/client/services.ts +386 -0
  35. package/src/client/sp00ky-client.test.ts +264 -0
  36. package/src/client/sp00ky-client.ts +507 -0
  37. package/src/events/events.test.ts +2 -1
  38. package/src/events/index.ts +3 -0
  39. package/src/index.ts +37 -2
  40. package/src/kernel/constants.test.ts +19 -0
  41. package/src/kernel/constants.ts +61 -0
  42. package/src/kernel/effects.test.ts +40 -0
  43. package/src/kernel/effects.ts +161 -0
  44. package/src/kernel/events.ts +56 -0
  45. package/src/kernel/interpreter.test.ts +116 -0
  46. package/src/kernel/interpreter.ts +148 -0
  47. package/src/kernel/saga.test.ts +89 -0
  48. package/src/kernel/saga.ts +73 -0
  49. package/src/modules/app-release/index.test.ts +125 -0
  50. package/src/modules/app-release/index.ts +209 -0
  51. package/src/modules/auth/auth.local-first.test.ts +141 -0
  52. package/src/modules/auth/events/index.ts +9 -2
  53. package/src/modules/auth/impersonation-banner.ts +112 -0
  54. package/src/modules/auth/impersonation.test.ts +282 -0
  55. package/src/modules/auth/impersonation.ts +179 -0
  56. package/src/modules/auth/index.ts +344 -27
  57. package/src/modules/crdt/crdt-field.ts +294 -0
  58. package/src/modules/crdt/crdt-hydration.test.ts +210 -0
  59. package/src/modules/crdt/crdt-reconnect.test.ts +195 -0
  60. package/src/modules/crdt/index.ts +463 -0
  61. package/src/modules/crdt/loro-loader.ts +25 -0
  62. package/src/modules/devtools/flags.ts +349 -0
  63. package/src/modules/devtools/index.ts +529 -47
  64. package/src/modules/devtools/notify-throttle.test.ts +154 -0
  65. package/src/modules/devtools/state-shape.test.ts +149 -0
  66. package/src/modules/devtools/storage-info.test.ts +79 -0
  67. package/src/modules/devtools/storage-info.ts +168 -0
  68. package/src/modules/devtools/versions.test.ts +74 -0
  69. package/src/modules/devtools/versions.ts +110 -0
  70. package/src/modules/feature-flag/index.test.ts +251 -0
  71. package/src/modules/feature-flag/index.ts +316 -0
  72. package/src/modules/ref-tables.test.ts +91 -0
  73. package/src/modules/ref-tables.ts +88 -0
  74. package/src/mutation/jobs.test.ts +25 -0
  75. package/src/mutation/jobs.ts +41 -0
  76. package/src/mutation/mutation-id.test.ts +37 -0
  77. package/src/mutation/mutation-id.ts +35 -0
  78. package/src/mutation/push.saga.test.ts +285 -0
  79. package/src/mutation/push.saga.ts +215 -0
  80. package/src/mutation/rows.test.ts +201 -0
  81. package/src/mutation/rows.ts +377 -0
  82. package/src/mutation/tray.saga.test.ts +63 -0
  83. package/src/mutation/tray.saga.ts +48 -0
  84. package/src/mutation/write.saga.test.ts +163 -0
  85. package/src/mutation/write.saga.ts +166 -0
  86. package/src/otel/index.ts +13 -10
  87. package/src/query/env.test.ts +23 -0
  88. package/src/query/env.ts +56 -0
  89. package/src/query/fetch.saga.test.ts +156 -0
  90. package/src/query/fetch.saga.ts +115 -0
  91. package/src/query/hash.test.ts +14 -0
  92. package/src/query/hash.ts +18 -0
  93. package/src/query/id-set-plan.test.ts +132 -0
  94. package/src/query/lifecycle.saga.test.ts +122 -0
  95. package/src/query/lifecycle.saga.ts +101 -0
  96. package/src/query/materialize.saga.test.ts +82 -0
  97. package/src/query/materialize.saga.ts +75 -0
  98. package/src/query/materialize.test.ts +50 -0
  99. package/src/query/materialize.ts +44 -0
  100. package/src/query/membership.saga.test.ts +317 -0
  101. package/src/query/membership.saga.ts +262 -0
  102. package/src/query/membership.test.ts +118 -0
  103. package/src/query/membership.ts +171 -0
  104. package/src/query/register.saga.test.ts +293 -0
  105. package/src/query/register.saga.ts +226 -0
  106. package/src/query/render-set.test.ts +36 -0
  107. package/src/query/render-set.ts +60 -0
  108. package/src/query/sql.test.ts +62 -0
  109. package/src/query/sql.ts +79 -0
  110. package/src/query/window-query.test.ts +70 -0
  111. package/src/query/window-query.ts +195 -0
  112. package/src/services/blobs/blob-cache.test.ts +359 -0
  113. package/src/services/blobs/blob-cache.ts +603 -0
  114. package/src/services/blobs/blob-manifest.ts +227 -0
  115. package/src/services/blobs/blob-store.test.ts +77 -0
  116. package/src/services/blobs/blob-store.ts +359 -0
  117. package/src/services/blobs/blob.fixture.ts +90 -0
  118. package/src/services/blobs/index.ts +70 -0
  119. package/src/services/database/cache-engine.ts +199 -0
  120. package/src/services/database/connection-supervisor.test.ts +318 -0
  121. package/src/services/database/connection-supervisor.ts +422 -0
  122. package/src/services/database/database.query-timeout.test.ts +140 -0
  123. package/src/services/database/database.responses.test.ts +43 -0
  124. package/src/services/database/database.ts +145 -62
  125. package/src/services/database/engine-factory.ts +33 -0
  126. package/src/services/database/errors.ts +34 -0
  127. package/src/services/database/events/index.ts +2 -1
  128. package/src/services/database/index.ts +7 -0
  129. package/src/services/database/local-migrator.ts +30 -27
  130. package/src/services/database/local.test.ts +64 -0
  131. package/src/services/database/local.ts +491 -67
  132. package/src/services/database/plan-render.test.ts +159 -0
  133. package/src/services/database/plan-render.ts +108 -0
  134. package/src/services/database/relation-resolver.test.ts +467 -0
  135. package/src/services/database/relation-resolver.ts +0 -0
  136. package/src/services/database/remote.ts +222 -15
  137. package/src/services/database/sqlite-cache-engine.test.ts +617 -0
  138. package/src/services/database/sqlite-cache-engine.timeout.test.ts +61 -0
  139. package/src/services/database/sqlite-cache-engine.ts +1366 -0
  140. package/src/services/database/sqlite-devtools-queries.integration.test.ts +143 -0
  141. package/src/services/database/sqlite-devtools-queries.test.ts +154 -0
  142. package/src/services/database/sqlite-lock-verify.test.ts +33 -0
  143. package/src/services/database/sqlite-lock-verify.ts +45 -0
  144. package/src/services/database/sqlite-open.test.ts +150 -0
  145. package/src/services/database/sqlite-open.ts +164 -0
  146. package/src/services/database/sqlite-plan-sql.test.ts +104 -0
  147. package/src/services/database/sqlite-plan-sql.ts +153 -0
  148. package/src/services/database/sqlite-projection.test.ts +121 -0
  149. package/src/services/database/sqlite-select.integration.test.ts +185 -0
  150. package/src/services/database/sqlite-select.test.ts +246 -0
  151. package/src/services/database/sqlite-select.ts +131 -0
  152. package/src/services/database/sqlite-transport.fixture.ts +30 -0
  153. package/src/services/database/sqlite-transport.ts +224 -0
  154. package/src/services/database/sqlite-worker.ts +437 -0
  155. package/src/services/database/surql-translate.ts +416 -0
  156. package/src/services/database/surreal-cache-engine.ts +161 -0
  157. package/src/services/logger/index.ts +3 -2
  158. package/src/services/persistence/localstorage.ts +2 -2
  159. package/src/services/persistence/resilient.ts +11 -4
  160. package/src/services/persistence/surrealdb.ts +10 -10
  161. package/src/services/stream-processor/index.ts +796 -84
  162. package/src/services/stream-processor/permissions.test.ts +47 -0
  163. package/src/services/stream-processor/permissions.ts +53 -0
  164. package/src/services/stream-processor/stream-processor.batch.test.ts +186 -0
  165. package/src/services/stream-processor/stream-processor.prime.test.ts +198 -0
  166. package/src/services/stream-processor/stream-processor.reset.test.ts +226 -0
  167. package/src/services/stream-processor/stream-processor.test.ts +1 -1
  168. package/src/services/stream-processor/wasm-types.ts +59 -3
  169. package/src/services/tabs/broker-client.ts +283 -0
  170. package/src/services/tabs/broker.test.ts +327 -0
  171. package/src/services/tabs/coordinator.test.ts +365 -0
  172. package/src/services/tabs/coordinator.ts +633 -0
  173. package/src/services/tabs/fake-ports.fixture.ts +112 -0
  174. package/src/services/tabs/leader-locks.ts +75 -0
  175. package/src/services/tabs/protocol.ts +262 -0
  176. package/src/services/tabs/support.ts +36 -0
  177. package/src/services/tabs/tabs-broker-worker.ts +640 -0
  178. package/src/sp00ky.ts +5 -0
  179. package/src/state/client-state.ts +191 -0
  180. package/src/state/lifecycle.test.ts +118 -0
  181. package/src/state/lifecycle.ts +86 -0
  182. package/src/state/reducers.test.ts +280 -0
  183. package/src/state/reducers.ts +488 -0
  184. package/src/state/selectors.test.ts +149 -0
  185. package/src/state/selectors.ts +184 -0
  186. package/src/sync/connection.saga.test.ts +86 -0
  187. package/src/sync/connection.saga.ts +85 -0
  188. package/src/sync/live.saga.test.ts +178 -0
  189. package/src/sync/live.saga.ts +107 -0
  190. package/src/sync/policy.health.test.ts +56 -0
  191. package/src/sync/policy.test.ts +659 -0
  192. package/src/sync/policy.ts +502 -0
  193. package/src/sync/poll.saga.test.ts +51 -0
  194. package/src/sync/poll.saga.ts +36 -0
  195. package/src/sync/tabs.saga.test.ts +74 -0
  196. package/src/sync/tabs.saga.ts +93 -0
  197. package/src/testing/adapters.ts +133 -0
  198. package/src/testing/build.ts +63 -0
  199. package/src/testing/fake-services.ts +34 -0
  200. package/src/testing/run-pure.test.ts +92 -0
  201. package/src/testing/run-pure.ts +128 -0
  202. package/src/testing/services.ts +17 -0
  203. package/src/types.ts +591 -14
  204. package/src/utils/blurhash.ts +90 -0
  205. package/src/utils/error-classification.test.ts +54 -0
  206. package/src/utils/error-classification.ts +25 -0
  207. package/src/utils/index.ts +79 -13
  208. package/src/utils/parser.test.ts +49 -120
  209. package/src/utils/parser.ts +32 -2
  210. package/src/utils/semver.test.ts +32 -0
  211. package/src/utils/semver.ts +30 -0
  212. package/src/utils/surql.ts +30 -18
  213. package/src/utils/utils.test.ts +2 -1
  214. package/src/utils/withRetry.test.ts +1 -1
  215. package/tsdown.config.ts +86 -1
  216. package/vitest.config.ts +48 -0
  217. package/src/modules/cache/index.ts +0 -241
  218. package/src/modules/cache/types.ts +0 -19
  219. package/src/modules/data/data.test.ts +0 -58
  220. package/src/modules/data/index.ts +0 -879
  221. package/src/modules/sync/engine.ts +0 -173
  222. package/src/modules/sync/events/index.ts +0 -77
  223. package/src/modules/sync/index.ts +0 -3
  224. package/src/modules/sync/queue/index.ts +0 -2
  225. package/src/modules/sync/queue/queue-down.ts +0 -89
  226. package/src/modules/sync/queue/queue-up.ts +0 -223
  227. package/src/modules/sync/scheduler.ts +0 -84
  228. package/src/modules/sync/sync.ts +0 -421
  229. package/src/modules/sync/utils.test.ts +0 -311
  230. package/src/modules/sync/utils.ts +0 -167
  231. package/src/spooky.ts +0 -395
package/dist/index.d.ts CHANGED
@@ -1,81 +1,411 @@
1
- import { C as Logger$1, S as UpdateOptions, T as EventSystem, _ as RunOptions, a as MutationEvent, b as SpookyQueryResultPromise, c as PinoTransmit, d as QueryHash, f as QueryState, g as RecordVersionDiff, h as RecordVersionArray, i as MutationCallback, l as QueryConfig, m as QueryUpdateCallback, n as EventSubscriptionOptions, o as MutationEventType, p as QueryTimeToLive, r as Level, s as PersistenceClient, t as DebounceOptions, u as QueryConfigRecord, v as SpookyConfig, w as EventDefinition, x as StoreType, y as SpookyQueryResult } from "./types.js";
1
+ import { A as RunOptions, B as SyncHealthStatus, C as QueryTimeToLive, D as RecordVersionArray, E as ReconnectConfig, F as StorageHealth, G as DatabaseEventTypes, H as UpdateOptions, I as StorageHealthStatus, J as EventSystem, K as SealedQuery, L as StoreType, M as Sp00kyConfig, N as Sp00kyQueryResult, O as RecordVersionDiff, P as Sp00kyQueryResultPromise, R as SyncHealth, S as QueryStatusCallback, T as QueryUpdateCallback, U as LocalStore, V as TimingPhase, W as DatabaseEventSystem, _ as QueryConfig, a as Level, b as QueryState, c as MutationCallback, d as PersistenceClient, f as PhaseStat, g as QueryAuthorityCallback, h as PreloadRefresh, i as InlineRow, j as ServerViewMeta, k as RegistrationTimings, l as MutationEvent, m as PreloadOptions, n as DebounceOptions, o as MATERIALIZATION_SAMPLE_WINDOW, p as PinoTransmit, q as EventDefinition, r as EventSubscriptionOptions, s as MembershipOutcome, t as ConnectionState, u as MutationEventType, v as QueryConfigRecord, w as QueryTimings, x as QueryStatus, y as QueryHash, z as SyncHealthConfig } from "./types.js";
2
+ import { AccessDefinition, BackendNames, BackendRoutes, BucketNames, ColumnSchema, FinalQuery, GetTable, QueryBuilder, QueryOptions, QueryPlan, RecordId, RoutePayload, SchemaStructure, TableModel, TableNames, TypeNameToTypeMap } from "@spooky-sync/query-builder";
2
3
  import * as surrealdb0 from "surrealdb";
3
- import { Duration, RecordId, Surreal, SurrealTransaction } from "surrealdb";
4
- import { AccessDefinition, BackendNames, BackendRoutes, BucketNames, ColumnSchema, GetTable, QueryBuilder, QueryOptions, RoutePayload, SchemaStructure, TableModel, TableNames, TypeNameToTypeMap } from "@spooky-sync/query-builder";
5
- import { Logger } from "pino";
4
+ import { Duration, RecordId as RecordId$1, Surreal, SurrealEvents, SurrealTransaction } from "surrealdb";
5
+ import { Level as Level$1, Logger } from "pino";
6
+ import { decode, encode, isBlurhashValid } from "blurhash";
7
+ import { LoroDoc } from "loro-crdt";
6
8
 
7
- //#region src/services/database/events/index.d.ts
8
- declare const DatabaseEventTypes: {
9
- readonly LocalQuery: "DATABASE_LOCAL_QUERY";
10
- readonly RemoteQuery: "DATABASE_REMOTE_QUERY";
9
+ //#region src/services/tabs/protocol.d.ts
10
+
11
+ type TabId = string;
12
+ type TabRole$1 = 'solo' | 'leader' | 'follower';
13
+ /** Broker pings every tab at this cadence. */
14
+
15
+ /** Matches `CacheIngestTuple` (modules/cache): exactly what `ingestMany`
16
+ * consumes, so relayed batches feed follower circuits without reshaping. */
17
+ interface IngestTuple {
18
+ table: string;
19
+ op: 'CREATE' | 'UPDATE' | 'DELETE';
20
+ id: string;
21
+ record: Record<string, unknown>;
22
+ }
23
+ type FollowerToLeaderMessage = {
24
+ type: 'sync-hello';
25
+ tabId: TabId;
26
+ }
27
+ /** The follower committed an outbox row (through the shared store) and the
28
+ * leader should drain it. Idempotent; a new leader's loadFromDatabase is
29
+ * the backstop for a notify lost in a failover window. */ | {
30
+ type: 'mutation-enqueued';
31
+ mutationId: string;
32
+ } | {
33
+ type: 'request-poll';
34
+ }
35
+ /** An optimistic write this follower committed to the SHARED store and
36
+ * ingested into its own circuit. The leader ingests it (no DB write, the
37
+ * row is already there) and fans it out to every OTHER follower as
38
+ * `ingest-relay`, so a follower's write lands in every tab in one hop
39
+ * instead of after the server round-trip. */ | {
40
+ type: 'ingest';
41
+ tuples: IngestTuple[];
11
42
  };
12
- interface DatabaseQueryEventPayload {
13
- query: string;
14
- vars?: Record<string, unknown>;
15
- duration: number;
16
- success: boolean;
17
- error?: string;
18
- timestamp: number;
19
- }
20
- type DatabaseEventTypeMap = {
21
- [DatabaseEventTypes.LocalQuery]: EventDefinition<typeof DatabaseEventTypes.LocalQuery, DatabaseQueryEventPayload>;
22
- [DatabaseEventTypes.RemoteQuery]: EventDefinition<typeof DatabaseEventTypes.RemoteQuery, DatabaseQueryEventPayload>;
43
+ type LeaderToFollowerMessage = {
44
+ type: 'db-ready';
45
+ leadershipId: number;
46
+ bucketId: string;
47
+ storageHealth: StorageHealth;
48
+ }
49
+ /** Every ingest the leader's CacheModule committed, so follower circuits
50
+ * stay live without their own fetch. seq detects gaps. */ | {
51
+ type: 'ingest-relay';
52
+ tuples: IngestTuple[];
53
+ leadershipId: number;
54
+ seq: number;
55
+ }
56
+ /** A `_00_list_ref` LIVE event, relayed verbatim. Each follower resolves the
57
+ * queryId against its own DataModule and ignores foreign queries. */ | {
58
+ type: 'list-ref-change';
59
+ action: 'CREATE' | 'UPDATE' | 'DELETE';
60
+ queryId: string;
61
+ recordId: string;
62
+ version: number;
63
+ parent: boolean;
64
+ }
65
+ /** The leader's drain rolled back a mutation owned by this tab. */ | {
66
+ type: 'mutation-rolled-back';
67
+ mutationId: string;
68
+ recordId: string;
69
+ eventType: 'create' | 'update' | 'delete';
70
+ error: string;
71
+ }
72
+ /** The leader's drain pushed a mutation and deleted its outbox row from the
73
+ * SHARED store. Every follower keeps the row in its overlay until its
74
+ * `_00_list_ref` membership arrives. */ | {
75
+ type: 'mutation-settled';
76
+ mutationId: string;
77
+ recordId: string;
78
+ eventType: 'create' | 'update' | 'delete';
79
+ }
80
+ /** The leader's LIVE saw edges of these queries change; followers re-read
81
+ * membership for the ones they hold. */ | {
82
+ type: 'membership-dirty';
83
+ hashes: string[];
84
+ }
85
+ /** The failed-writes tray changed size on the shared store. */ | {
86
+ type: 'failed-mutations-changed';
87
+ count: number;
23
88
  };
24
- type DatabaseEventSystem = EventSystem<DatabaseEventTypeMap>;
25
89
  //#endregion
26
- //#region src/utils/surql.d.ts
27
- interface SealedQuery<T = void> {
28
- readonly sql: string;
29
- readonly extract: (results: unknown[]) => T;
30
- }
90
+ //#region src/services/logger/index.d.ts
91
+ type Logger$1 = Logger;
31
92
  //#endregion
32
93
  //#region src/services/database/database.d.ts
33
94
  declare abstract class AbstractDatabaseService {
34
95
  protected client: Surreal;
35
96
  protected logger: Logger$1;
36
97
  protected events: DatabaseEventSystem;
98
+ /**
99
+ * Per-query deadline in ms; `0` disables. The remote service sets it from
100
+ * `queryTimeoutMs` (see `RemoteDatabaseService`), the local one from
101
+ * `localOpTimeoutMs` (see `LocalDatabaseService`): a local query can be
102
+ * legitimately slow, but it must never be endless - every query waits on the
103
+ * previous link of {@link query}'s chain, and one that never settled wedged
104
+ * every later local op behind it.
105
+ */
106
+ protected queryTimeoutMs: number;
107
+ /** The error a deadline expiry rejects with; the local service substitutes
108
+ * its typed `LocalOpTimeoutError`. "timed out" in the message is
109
+ * load-bearing either way: `classifySyncError` keys off it. */
110
+ protected timeoutError(_query: string): Error;
37
111
  protected abstract eventType: typeof DatabaseEventTypes.LocalQuery | typeof DatabaseEventTypes.RemoteQuery;
38
112
  constructor(client: Surreal, logger: Logger$1, events: DatabaseEventSystem);
39
113
  abstract connect(): Promise<void>;
40
114
  getClient(): Surreal;
41
115
  getEvents(): DatabaseEventSystem;
42
116
  tx(): Promise<SurrealTransaction>;
43
- private queryQueue;
44
117
  /**
45
- * Execute a query with serialized execution to prevent WASM transaction issues.
118
+ * How many statements may be in flight at once.
119
+ *
120
+ * `1` serializes every statement behind the previous one. The local engines
121
+ * need that: overlapping ops trip the WASM / SQLite transaction layer. The
122
+ * remote service raises it (`RemoteDatabaseService`): a SurrealDB socket
123
+ * multiplexes requests by id, and serializing them capped the client at one
124
+ * statement per round trip (~5/s at 170ms RTT), so the `_00_list_ref` poll
125
+ * alone could hold every registration behind it and one slow one-shot read
126
+ * stalled the whole client. The scheduler's `MAX_CONCURRENT_DOWN` only means
127
+ * something when this is above 1.
128
+ */
129
+ protected maxConcurrentQueries: number;
130
+ private inFlightQueries;
131
+ private queryWaiters;
132
+ /** FIFO slot acquisition, so `maxConcurrentQueries = 1` keeps strict order. */
133
+ private acquireQuerySlot;
134
+ private releaseQuerySlot;
135
+ /**
136
+ * Hook run right before a statement is sent, inside its slot. The remote
137
+ * service uses it to hold statements while a connect is in flight, so a
138
+ * query never runs on a socket that is open but has not had its namespace
139
+ * and token applied yet. The default does nothing.
140
+ */
141
+ protected beforeQuery(): Promise<void>;
142
+ /**
143
+ * Execute a statement under {@link maxConcurrentQueries}.
144
+ *
145
+ * A statement that never settles would hold its slot forever, and with a
146
+ * limit of 1 that blocks every later statement behind it. {@link
147
+ * queryTimeoutMs} bounds each one: on expiry this promise rejects and the
148
+ * slot is released, even though the underlying RPC is still parked in the
149
+ * SDK's pending map.
46
150
  */
47
151
  query<T extends unknown[]>(query: string, vars?: Record<string, unknown>): Promise<T>;
152
+ /**
153
+ * Like {@link query} but never rejects on a statement error: every
154
+ * statement answers on its own as `{ status: 'OK', result }` or
155
+ * `{ status: 'ERR', error }`. This is what lets a batched outbox push judge
156
+ * each mutation separately.
157
+ */
158
+ queryResponses(query: string, vars?: Record<string, unknown>): Promise<Array<{
159
+ status: 'OK';
160
+ result: unknown;
161
+ } | {
162
+ status: 'ERR';
163
+ error: string;
164
+ }>>;
165
+ private runStatement;
48
166
  execute<T>(query: SealedQuery<T>, vars?: Record<string, unknown>): Promise<T>;
49
167
  close(): Promise<void>;
50
168
  }
51
169
  //#endregion
52
- //#region src/services/database/local.d.ts
53
- declare class LocalDatabaseService extends AbstractDatabaseService {
54
- private config;
55
- protected eventType: "DATABASE_LOCAL_QUERY";
56
- constructor(config: SpookyConfig<any>['database'], logger: Logger$1);
57
- getConfig(): SpookyConfig<any>['database'];
58
- connect(): Promise<void>;
59
- }
60
- //#endregion
61
170
  //#region src/services/database/remote.d.ts
171
+ /** Transport events the SDK publishes, mapped 1:1 to {@link ConnectionState}. */
172
+ type RemoteConnectionEvent = ConnectionState | 'error';
62
173
  declare class RemoteDatabaseService extends AbstractDatabaseService {
63
174
  private config;
64
175
  protected eventType: "DATABASE_REMOTE_QUERY";
65
- constructor(config: SpookyConfig<any>['database'], logger: Logger$1);
66
- getConfig(): SpookyConfig<any>['database'];
176
+ private readonly reconnectConfig;
177
+ /**
178
+ * In-flight `connect()`, so concurrent callers (boot + supervisor revive +
179
+ * an `online` event landing at the same moment) share one attempt instead of
180
+ * racing two sockets. Cleared on settle, so a later call always reconnects.
181
+ */
182
+ private connecting;
183
+ /**
184
+ * The token to re-authenticate a freshly opened socket with, kept current by
185
+ * {@link setAuthToken}.
186
+ *
187
+ * `config.token` is fixed at construction and most apps never set it: they
188
+ * sign in later, which authenticates the socket that happens to be open at
189
+ * the time. That is enough for the SDK's OWN reconnects (it replays
190
+ * `version`/`use`/`authenticate` itself), but not for the supervisor's revive
191
+ * loop, which builds a socket from scratch. Without this the revived socket
192
+ * came back UNAUTHENTICATED and stayed that way for the life of the page,
193
+ * while the client's `currentUserId` — restored from local storage — kept
194
+ * reporting the user as signed in.
195
+ *
196
+ * The visible damage is silent and total: `fn::query::register` sends
197
+ * `<string>($auth.id OR '')`, so every view registered afterwards is stamped
198
+ * with an empty identity, and every `$auth.id` predicate in it resolves
199
+ * false. Public tables keep returning rows while everything owned by the user
200
+ * returns nothing, on a page that still looks signed in.
201
+ */
202
+ private authToken;
203
+ constructor(config: Sp00kyConfig<any>['database'], logger: Logger$1);
204
+ getConfig(): Sp00kyConfig<any>['database'];
205
+ /**
206
+ * Send one SurrealQL statement so that it survives the page going away.
207
+ *
208
+ * A WebSocket `send()` during `pagehide` is not guaranteed to flush — the
209
+ * browser may tear the socket down first, and the frame is simply lost.
210
+ * Measured: an unload-time release over the live socket reached the server
211
+ * zero times out of one. `fetch` with `keepalive` is the primitive the
212
+ * platform actually guarantees here, so this goes over SurrealDB's HTTP
213
+ * `/sql` endpoint instead of the RPC socket.
214
+ *
215
+ * Best-effort by design: no await, no retry, errors swallowed. Every caller
216
+ * must have a server-side fallback that makes a lost beacon a non-event.
217
+ */
218
+ beaconSql(sql: string): void;
219
+ /**
220
+ * Record the token every future connect should authenticate with, or `null`
221
+ * on sign-out. See {@link authToken}.
222
+ *
223
+ * Does not touch the CURRENT socket: callers authenticate that themselves
224
+ * (sign-in and session restore both already do). This only makes the next
225
+ * from-scratch connect reproduce that state.
226
+ */
227
+ setAuthToken(token: string | null): void;
228
+ /** Resolved reconnect tunables; the supervisor reads its own knobs here. */
229
+ getReconnectConfig(): Required<ReconnectConfig>;
230
+ /** Current transport state as reported by the SDK. */
231
+ getStatus(): ConnectionState;
232
+ /**
233
+ * Observe transport events. Thin passthrough so callers (the supervisor,
234
+ * sync, CRDT) don't have to reach through `getClient()`.
235
+ */
236
+ subscribeConnection<K extends RemoteConnectionEvent>(event: K, cb: (...payload: SurrealEvents[K]) => void): () => void;
237
+ /**
238
+ * Tear the socket down on purpose. Used by the heartbeat watchdog when a
239
+ * socket stops answering but never closes: `close()` makes the SDK publish
240
+ * `disconnected`, which is what drives the supervisor's revive loop.
241
+ */
242
+ forceClose(): Promise<void>;
243
+ /**
244
+ * Hold statements while a connect is in flight.
245
+ *
246
+ * `connect()` opens the socket, then selects the namespace/database, then
247
+ * authenticates - three round trips. The SDK considers the connection ready
248
+ * after the first, so a query queued during the other two ran on a socket
249
+ * with no namespace ("Specify a namespace to use") or, worse, no identity.
250
+ * Seen 2026-09-06 after the supervisor rebuilt a socket: a collection
251
+ * recount failed with exactly that error. Waiting on the in-flight connect
252
+ * costs nothing when no connect is running; a failed connect is not
253
+ * swallowed here - the query then fails on its own, as before.
254
+ */
255
+ protected beforeQuery(): Promise<void>;
256
+ /**
257
+ * Open (or re-open) the remote connection.
258
+ *
259
+ * Safe to call repeatedly: concurrent calls share the in-flight attempt, and
260
+ * a call after a `disconnected` builds a fresh socket. `use()` and
261
+ * `authenticate()` are re-applied here for the cold path; the SDK also
262
+ * replays them itself on its own internal reconnects.
263
+ */
67
264
  connect(): Promise<void>;
265
+ private doConnect;
68
266
  signin(params: any): Promise<any>;
69
267
  signup(params: any): Promise<any>;
70
268
  authenticate(token: string): Promise<any>;
71
269
  invalidate(): Promise<void>;
72
270
  }
73
271
  //#endregion
272
+ //#region src/services/database/connection-supervisor.d.ts
273
+ /**
274
+ * Keeps the remote WebSocket alive for the whole life of the page.
275
+ *
276
+ * The SurrealDB SDK reconnects on its own after a socket `close`, but that
277
+ * covers only one of three ways the connection dies:
278
+ *
279
+ * 1. **Socket closes, SDK recovers.** Handled entirely by the SDK. This
280
+ * supervisor only observes it (to report `reconnecting` upward).
281
+ * 2. **Socket closes, SDK gives up.** With `attempts: -1` this shouldn't happen
282
+ * from exhaustion — but the SDK also terminates the engine permanently when
283
+ * its post-reconnect handshake throws (it re-runs `version()`, `use()`,
284
+ * `authenticate()` on every reconnect and closes the engine on any error).
285
+ * One transient hiccup there would otherwise kill the page's connection for
286
+ * good. The revive loop re-opens from scratch.
287
+ * 3. **Socket never closes at all.** A half-open connection: the peer is gone
288
+ * (NAT timeout, wifi switch, laptop sleep) but no FIN ever arrives, so
289
+ * `readyState` stays OPEN and the SDK's own 30s ping — fire-and-forget, no
290
+ * response deadline — never notices. Nothing ever fires a `close` event, so
291
+ * nothing ever triggers a reconnect. The heartbeat detects this and forces
292
+ * the teardown that case 2's loop then repairs.
293
+ *
294
+ * Plus wake triggers: coming back `online` or un-hiding the tab probes
295
+ * immediately rather than waiting out a backoff that was scheduled while the
296
+ * network was known-down.
297
+ */
298
+ declare class ConnectionSupervisor {
299
+ private readonly remote;
300
+ private readonly logger;
301
+ private readonly config;
302
+ private state;
303
+ private subscribers;
304
+ private started;
305
+ private disposed;
306
+ private heartbeatTimer;
307
+ private heartbeatInFlight;
308
+ /** Consecutive failed probes. See {@link FAILURES_BEFORE_TEARDOWN}. */
309
+ private heartbeatFailures;
310
+ private reviveTimer;
311
+ private reviveAttempts;
312
+ /** Timestamp of the last wake-triggered probe, for rate limiting. */
313
+ private lastWakeProbeAt;
314
+ private reviving;
315
+ /**
316
+ * Set while the browser reports itself offline. Retrying a socket against a
317
+ * down interface only burns backoff, so the loop parks until `online` fires.
318
+ */
319
+ private suspended;
320
+ private teardown;
321
+ private static readonly REVIVE_BASE_MS;
322
+ /**
323
+ * How many consecutive heartbeat failures it takes to tear the socket down.
324
+ *
325
+ * The probe rides the same serialized queue as every other RPC (deliberately
326
+ * — see {@link beat}), which means it cannot distinguish a WEDGED queue from
327
+ * a merely BUSY one. A single slow window (a large sync burst, one heavy
328
+ * app query) used to be enough to force-close a perfectly healthy socket,
329
+ * and the resulting reconnect re-registered every active query about a
330
+ * second later. That self-inflicted teardown manufactured the very reconnect
331
+ * storms this class exists to survive. A genuinely dead socket still fails
332
+ * every probe, so it is torn down one interval later than before.
333
+ */
334
+ private static readonly FAILURES_BEFORE_TEARDOWN;
335
+ /** Retry delay after an inconclusive (first) heartbeat failure. */
336
+ private static readonly HEARTBEAT_RETRY_MS;
337
+ /** Floor between probes triggered by wake events (tab focus, pageshow). */
338
+ private static readonly WAKE_PROBE_MIN_INTERVAL_MS;
339
+ constructor(remote: RemoteDatabaseService, logger: Logger$1, config?: Required<ReconnectConfig>);
340
+ /** Latest observed transport state. */
341
+ get connection(): ConnectionState;
342
+ /**
343
+ * Observe transport state. Fires immediately with the current value and again
344
+ * on every change. Returns an unsubscribe.
345
+ */
346
+ subscribe(cb: (state: ConnectionState) => void): () => void;
347
+ /**
348
+ * Begin supervising. Call once, after the initial {@link
349
+ * RemoteDatabaseService.connect}. Idempotent.
350
+ */
351
+ start(): void;
352
+ /** Stop all timers and listeners. Safe to call more than once. */
353
+ dispose(): void;
354
+ private setState;
355
+ private clearReviveTimer;
356
+ /**
357
+ * Queue the next `connect()` attempt on exponential backoff, capped at
358
+ * `superviseRetryDelayMaxMs`. Never gives up — the page is expected to
359
+ * outlive any outage.
360
+ */
361
+ private scheduleRevive;
362
+ private revive;
363
+ private stopHeartbeat;
364
+ private startHeartbeat;
365
+ /**
366
+ * Probe the server end-to-end. Deliberately goes through
367
+ * `remote.query` — the same serialized queue every other remote call uses —
368
+ * so a queue wedged behind a stuck RPC also fails the heartbeat instead of
369
+ * being invisible to it.
370
+ */
371
+ private beat;
372
+ /**
373
+ * A restored network or an un-hidden tab is the strongest available hint that
374
+ * a reconnect will now succeed, so probe immediately instead of waiting out a
375
+ * backoff scheduled under worse conditions.
376
+ */
377
+ private installWakeTriggers;
378
+ /**
379
+ * Reset the backoff and act on whichever problem is present: reconnect if the
380
+ * socket is gone, otherwise probe it (it may be half-open — which is exactly
381
+ * what a sleep/wake cycle produces).
382
+ */
383
+ private wake;
384
+ }
385
+ //#endregion
386
+ //#region src/services/database/local-migrator.d.ts
387
+ declare class LocalMigrator {
388
+ private localDb;
389
+ private logger;
390
+ constructor(localDb: LocalStore, logger: Logger$1);
391
+ provision(schemaSurql: string): Promise<void>;
392
+ private isSchemaUpToDate;
393
+ private recreateDatabase;
394
+ private splitStatements;
395
+ private createHashRecord;
396
+ }
397
+ //#endregion
74
398
  //#region src/services/stream-processor/wasm-types.d.ts
75
399
  interface WasmStreamUpdate {
76
400
  query_id: string;
77
401
  result_hash: string;
78
402
  result_data: RecordVersionArray;
403
+ timing_store_apply_ms?: number;
404
+ timing_circuit_step_ms?: number;
405
+ timing_transform_ms?: number;
406
+ timing_parse_ms?: number;
407
+ timing_plan_ms?: number;
408
+ timing_snapshot_ms?: number;
79
409
  }
80
410
  //#endregion
81
411
  //#region src/services/stream-processor/index.d.ts
@@ -96,6 +426,33 @@ interface StreamUpdate {
96
426
  queryHash: string;
97
427
  localArray: RecordVersionArray;
98
428
  op?: 'CREATE' | 'UPDATE' | 'DELETE';
429
+ /**
430
+ * Client-internal: not from the circuit. A membership-only change that
431
+ * needed no fetch is re-materialized through this same path so it cannot
432
+ * race a real update (DataModule.scheduleRematerialize). Carries the last
433
+ * known `localArray`; consumers that describe an INGEST (persist, metrics,
434
+ * devtools events) skip it.
435
+ */
436
+ synthetic?: boolean;
437
+ /**
438
+ * End-to-end ingest latency for the WASM call that produced this update,
439
+ * in milliseconds. Populated by StreamProcessorService.ingest. Undefined
440
+ * for the initial register_view snapshot.
441
+ */
442
+ materializationTimeMs?: number;
443
+ /** SSP internal sub-phase timings (ms) for this ingest, from the WASM binding. */
444
+ storeApplyMs?: number;
445
+ circuitStepMs?: number;
446
+ transformMs?: number;
447
+ /**
448
+ * One-shot registration timings (ms). Only set on the StreamUpdate returned
449
+ * by `registerQueryPlan` (the register_view snapshot), not on ingest updates.
450
+ */
451
+ registration?: {
452
+ parseMs: number;
453
+ planMs: number;
454
+ snapshotMs: number;
455
+ };
99
456
  }
100
457
  type StreamProcessorEvents = {
101
458
  stream_update: EventDefinition<'stream_update', StreamUpdate[]>;
@@ -107,34 +464,204 @@ type StreamProcessorEvents = {
107
464
  interface StreamUpdateReceiver {
108
465
  onStreamUpdate(update: StreamUpdate): void;
109
466
  }
467
+ /** One row change in the shape `ingestMany` consumes. */
468
+ interface IngestRecord {
469
+ table: string;
470
+ /** `MERGE` overlays the given fields on the stored row (projection widening). */
471
+ op: 'CREATE' | 'UPDATE' | 'DELETE' | 'MERGE';
472
+ id: string;
473
+ record: any;
474
+ }
475
+ /**
476
+ * What the boot-time prime needs from the client: which tables to walk, how
477
+ * to recognise a snapshot written under a different schema, and which rows'
478
+ * local `_00_rv` must not be reported as the server's.
479
+ */
480
+ interface CircuitPrimeContext {
481
+ tables: string[];
482
+ schemaHash: string;
483
+ /** Encoded ids with an unsettled local mutation (their `_00_rv` was bumped
484
+ * locally and may exceed the server's next version). */
485
+ pendingIds: Set<string>;
486
+ /** Receives every `(id, rv)` the prime put into the circuit, per table, so
487
+ * the sync layer can skip re-downloading bodies it already has. */
488
+ onVersions?: (table: string, entries: [string, number][]) => void;
489
+ }
490
+ /** Storage key of the circuit snapshot inside the local store. */
491
+
110
492
  declare class StreamProcessorService {
111
493
  events: EventSystem<StreamProcessorEvents>;
112
494
  private db;
113
- private persistenceClient;
114
495
  private logger;
115
496
  private processor;
116
497
  private isInitialized;
117
498
  private receivers;
118
- constructor(events: EventSystem<StreamProcessorEvents>, db: LocalDatabaseService, persistenceClient: PersistenceClient, logger: Logger);
499
+ private batching;
500
+ private batchBuffer;
501
+ private sessionAuth;
502
+ private stateGeneration;
503
+ private persistState;
504
+ private persistCircuit;
505
+ private checkpointMs;
506
+ private checkpointTimer;
507
+ private snapshotDirty;
508
+ private dirtyRows;
509
+ private hideHandler;
510
+ private checkpointInFlight;
511
+ private projection;
512
+ private primed;
513
+ private schemaHash;
514
+ private widenQueue;
515
+ private widenPending;
516
+ constructor(events: EventSystem<StreamProcessorEvents>, db: LocalStore, logger: Logger);
119
517
  /**
120
518
  * Add a receiver for stream updates.
121
519
  * Multiple receivers can be registered (DataManager, DevTools, etc.)
122
520
  */
123
521
  addReceiver(receiver: StreamUpdateReceiver): void;
124
522
  private notifyUpdates;
523
+ private dispatchUpdates;
524
+ /**
525
+ * Ingest a batch of record changes, firing one coalesced `StreamUpdate` per
526
+ * affected query once every record has been ingested. Use this whenever
527
+ * multiple records land at once (sync fetching N rows, the boot prime).
528
+ *
529
+ * The batch is fed to the wasm side in chunks of {@link INGEST_CHUNK}: one
530
+ * circuit step per chunk (a step walks every registered view, so per-record
531
+ * ingest paid that fixed cost N times), but never the whole batch at once,
532
+ * because the wasm side has to hold every parsed row of a call at the same
533
+ * time and wasm32 dlmalloc never returns that peak.
534
+ *
535
+ * Returns the records that were ingested. A chunk that fails is reported
536
+ * and skipped, not retried (a retry would double-apply whatever the failed
537
+ * step already committed), and the remaining chunks still run.
538
+ */
539
+ ingestMany(records: IngestRecord[]): IngestRecord[];
540
+ /**
541
+ * Open a coalescing window. While open, the per-record stream updates
542
+ * emitted by `ingest` are buffered (one entry per queryHash) instead of
543
+ * dispatched. Always paired with `flushCoalescing()` in a try/finally by
544
+ * `ingestMany` so the window always closes — otherwise the processor stays
545
+ * stuck buffering forever.
546
+ *
547
+ * No-op if a window is already open (nested batches aren't expected here).
548
+ */
549
+ private beginCoalescing;
550
+ /**
551
+ * Close the coalescing window and flush: dispatch one coalesced
552
+ * `StreamUpdate` per buffered queryHash, then persist processor state once
553
+ * for the whole batch (instead of once per ingest).
554
+ */
555
+ private flushCoalescing;
125
556
  /**
126
557
  * Initialize the WASM module and processor.
127
558
  * This must be called before using other methods.
128
559
  */
129
560
  init(): Promise<void>;
130
- loadState(): Promise<void>;
131
- saveState(): Promise<void>;
561
+ /**
562
+ * Drop the current WASM processor and start a fresh, empty circuit. Used on
563
+ * local-bucket switches: the old circuit holds the previous user's rows AND
564
+ * views registered with the previous `$auth` context, so neither may survive.
565
+ * Deliberately loads nothing: the snapshot in the store being swapped away
566
+ * from belongs to the previous bucket; the caller primes the new bucket's
567
+ * circuit (`primeFromLocal`) once its store is open, and the DataModule
568
+ * rebind re-registers every live view against this fresh processor. Caller
569
+ * must re-seed `setPermissions` afterwards (a fresh circuit default-denies
570
+ * every table).
571
+ */
572
+ reset(): Promise<void>;
573
+ /**
574
+ * Release the wasm circuit and stop checkpointing. Call when the client is
575
+ * torn down; a recreated client (provider remount, HMR) would otherwise stack
576
+ * one full circuit per instance.
577
+ */
578
+ dispose(): void;
579
+ /**
580
+ * Explicitly run the wasm-bindgen destructor. Guarded: stale wasm builds may
581
+ * not expose `free`, and a double free must not take the app down.
582
+ */
583
+ private freeProcessor;
584
+ /** Toggle circuit-state persistence (shared-tabs follower/leader role). */
585
+ setPersistenceEnabled(enabled: boolean): void;
586
+ /**
587
+ * Snapshot persistence (`persistCircuit`). When on, the circuit's store is
588
+ * written to the local store on a checkpoint interval and when the page
589
+ * goes hidden, and restored by {@link primeFromLocal} on the next boot.
590
+ */
591
+ configureCircuitPersistence(enabled: boolean, checkpointMs?: number): void;
592
+ /**
593
+ * Field projection (`circuitProjection`, default on). Takes effect on the
594
+ * next processor (`init`/`reset`) and on rows written after that.
595
+ */
596
+ configureProjection(enabled: boolean): void;
597
+ private applyProjection;
598
+ /** Resolves once the boot-time prime has finished (or was skipped). */
599
+ whenPrimed(): Promise<void>;
600
+ /**
601
+ * Fill the circuit from the LOCAL store, in the background.
602
+ *
603
+ * With a usable snapshot: install it under whatever views have registered
604
+ * meanwhile (`load_store_state` re-primes them), then `reconcile` each table
605
+ * against the store's `(id, rv)` list so rows deleted since the checkpoint
606
+ * are stepped out and only rows added or changed since are read back and
607
+ * ingested. Without one: read every row and ingest it, chunked.
608
+ *
609
+ * Either way the circuit ends up equal to the local store without touching
610
+ * the network, so the first sync diff is a real delta rather than "fetch
611
+ * everything". The returned promise never rejects; `whenPrimed` gates on it.
612
+ */
613
+ primeFromLocal(ctx: CircuitPrimeContext): Promise<void>;
614
+ private runPrime;
615
+ /** Publish wasm updates produced outside an ingest (restore, reconcile). */
616
+ private dispatchWasmUpdates;
617
+ /**
618
+ * Record that the circuit changed by `rows` rows. Cheap; the snapshot is
619
+ * deferred to the checkpoint timer and skipped entirely when `persistCircuit`
620
+ * is off.
621
+ */
622
+ private markSnapshotDirty;
623
+ private startCheckpoints;
624
+ /** Stop checkpointing and drop the visibility listeners. */
625
+ stopCheckpoints(): void;
626
+ /**
627
+ * Write the circuit's store to the local store as a snapshot. Compacts the
628
+ * row arena first when dead bytes outweigh live ones. Serialised: a second
629
+ * call while one is in flight joins it. No-op unless persistence is on, this
630
+ * tab owns the store, and the engine can hold a snapshot.
631
+ */
632
+ checkpoint(reason?: string): Promise<void>;
633
+ private runCheckpoint;
634
+ /**
635
+ * Projection widening: a newly registered view evaluates fields that rows
636
+ * already in the circuit were stored without. Merge just those fields in,
637
+ * table by table, from the local store. The view registered against what
638
+ * was present and converges as the merges step through.
639
+ */
640
+ private scheduleWiden;
641
+ private runWiden;
642
+ /**
643
+ * Seed per-table `select` permission predicates ({ [table]: whereText }).
644
+ * Must run after the processor exists and before any `register_view`, else
645
+ * non-`_00_` tables are default-denied and registration fails.
646
+ */
647
+ setPermissions(permissions: Record<string, string>): void;
648
+ /**
649
+ * Set the current session's auth identity for permission injection,
650
+ * mirroring the server's `fn::query::register`
651
+ * (`object::extend(params, { auth: { id: $auth.id }, access: $access })`).
652
+ * Stored as strings (empty when logged out) and applied to every
653
+ * `register_view` in {@link registerQueryPlan}. Must be set before a
654
+ * `$auth`-gated query registers (and re-set on auth state changes), or the
655
+ * in-browser SSP's `permission_inject` rejects it with
656
+ * "requires $auth but registration params lack it".
657
+ */
658
+ setSessionAuth(authId: string | null, access: string | null): void;
132
659
  /**
133
660
  * Ingest a record change into the processor.
134
661
  * Emits 'stream_update' event if materialized views are affected.
135
662
  * @param isOptimistic true = local mutation (increment versions), false = remote sync (keep versions)
136
663
  */
137
- ingest(table: string, op: 'CREATE' | 'UPDATE' | 'DELETE', id: string, record: any): WasmStreamUpdate[];
664
+ ingest(table: string, op: IngestRecord['op'], id: string, record: any): WasmStreamUpdate[];
138
665
  /**
139
666
  * Register a new query plan.
140
667
  * Emits 'stream_update' with the initial result.
@@ -147,17 +674,120 @@ declare class StreamProcessorService {
147
674
  private normalizeValue;
148
675
  }
149
676
  //#endregion
677
+ //#region src/modules/auth/impersonation.d.ts
678
+ /**
679
+ * Admin impersonation, client side.
680
+ *
681
+ * The server does the enforcing (see `impersonation_remote.surql` in the CLI):
682
+ * an admin calls `fn::_00_impersonate::start`, receives a token for the
683
+ * `_00_impersonate` access method, and authenticates with it, so `$auth.id` is
684
+ * the target user. Everything here is state and timers around that:
685
+ *
686
+ * - the admin's own token is kept under {@link ORIGIN_TOKEN_KEY} so Stop (and a
687
+ * reload in between) can return to it;
688
+ * - the impersonation state is derived from the token's claims, never stored
689
+ * separately, so it cannot disagree with the session actually in use;
690
+ * - the token is re-signed before it expires, and the session is polled so a
691
+ * stop or revocation made elsewhere reaches an open WebSocket quickly.
692
+ */
693
+ /** Access method impersonation tokens are issued for. */
694
+ declare const IMPERSONATION_ACCESS = "_00_impersonate";
695
+ /** Persistence key holding the admin's own token while impersonating. */
696
+ declare const ORIGIN_TOKEN_KEY = "sp00ky_auth_token_origin";
697
+ /** How often an active impersonation re-checks its session on the server. */
698
+ declare const SESSION_POLL_MS = 60000;
699
+ interface ImpersonationInfo {
700
+ /** `_00_impersonation:<id>` the session is bound to. */
701
+ session: string;
702
+ /** Record id being impersonated. */
703
+ target: string;
704
+ /** Record id of the admin. */
705
+ admin: string;
706
+ /** When the current token expires (it is renewed before then). */
707
+ tokenExpiresAt: Date | null;
708
+ }
709
+ interface TokenClaims {
710
+ access: string | null;
711
+ userId: string | null;
712
+ exp: number | null;
713
+ impersonation: {
714
+ session: string;
715
+ admin: string;
716
+ } | null;
717
+ }
718
+ /**
719
+ * Read the claims of a SurrealDB record-access JWT WITHOUT verifying it. The
720
+ * server still enforces the token on every request; this is only so the client
721
+ * can act on what it already holds before a round trip completes.
722
+ *
723
+ * Returns nulls on any malformed input.
724
+ */
725
+ declare function decodeTokenClaims(token: string): TokenClaims;
726
+ /** The impersonation a token represents, or null for a regular session. */
727
+ declare function impersonationFromToken(token: string | null): ImpersonationInfo | null;
728
+ /** Same impersonation, for change detection (renewals only move the expiry). */
729
+ declare function sameImpersonation(a: ImpersonationInfo | null, b: ImpersonationInfo | null): boolean;
730
+ /**
731
+ * When to renew a token expiring at `expSecs`: at 80% of its remaining life,
732
+ * never sooner than 5s from now, and immediately once it is inside that.
733
+ */
734
+ declare function renewDelayMs(expSecs: number, nowMs: number): number;
735
+ interface ImpersonationTimerHost {
736
+ /** Current impersonation, or null. */
737
+ current(): ImpersonationInfo | null;
738
+ /** Re-sign the token. Rejects when the session has ended. */
739
+ renew(): Promise<void>;
740
+ /** Whether the server still considers the session live. */
741
+ stillActive(): Promise<boolean>;
742
+ /** Leave the impersonation because the server ended it. */
743
+ endedRemotely(reason: string): Promise<void>;
744
+ /** Whether a failure was a reachability problem (retry) rather than an answer. */
745
+ isNetworkError(error: unknown): boolean;
746
+ }
747
+ /**
748
+ * The two timers an active impersonation needs. `sync()` is called whenever
749
+ * the auth state changes and arms or clears them to match.
750
+ */
751
+ declare class ImpersonationTimers {
752
+ private host;
753
+ private renewTimer;
754
+ private pollTimer;
755
+ private armedFor;
756
+ constructor(host: ImpersonationTimerHost);
757
+ sync(): void;
758
+ clear(): void;
759
+ private renew;
760
+ private poll;
761
+ }
762
+ //#endregion
150
763
  //#region src/modules/auth/events/index.d.ts
151
764
  declare const AuthEventTypes: {
152
765
  readonly AuthStateChanged: "AUTH_STATE_CHANGED";
766
+ readonly ImpersonationChanged: "IMPERSONATION_CHANGED";
153
767
  };
154
768
  type AuthEventTypeMap = {
155
769
  [AuthEventTypes.AuthStateChanged]: EventDefinition<typeof AuthEventTypes.AuthStateChanged, string | null>;
770
+ [AuthEventTypes.ImpersonationChanged]: EventDefinition<typeof AuthEventTypes.ImpersonationChanged, ImpersonationInfo | null>;
156
771
  };
157
772
  type AuthEventSystem = EventSystem<AuthEventTypeMap>;
158
773
  declare function createAuthEventSystem(): AuthEventSystem;
159
774
  //#endregion
160
775
  //#region src/modules/auth/index.d.ts
776
+ /** What `fn::_00_impersonate::list_users` returns per row. */
777
+ interface ImpersonationCandidate {
778
+ id: string;
779
+ is_admin: boolean;
780
+ [field: string]: unknown;
781
+ }
782
+ /** An open session, as `fn::_00_impersonate::active` lists it. */
783
+ interface ActiveImpersonation {
784
+ session: string;
785
+ target: string;
786
+ admin: string;
787
+ reason: string;
788
+ started_at: unknown;
789
+ expires_at: unknown;
790
+ }
161
791
  type Prettify<T> = { [K in keyof T]: T[K] } & {};
162
792
  type MapColumnType<T extends ColumnSchema> = T['optional'] extends true ? TypeNameToTypeMap[T['type']] | undefined : TypeNameToTypeMap[T['type']];
163
793
  type ExtractAccessParams<S extends SchemaStructure, Name extends keyof S['access'], Method extends 'signIn' | 'signup'> = S['access'] extends undefined ? never : S['access'][Name] extends AccessDefinition ? Prettify<{ [K in keyof S['access'][Name][Method]['params']]: MapColumnType<S['access'][Name][Method]['params'][K]> }> : never;
@@ -169,8 +799,21 @@ declare class AuthService<S extends SchemaStructure> {
169
799
  token: string | null;
170
800
  currentUser: any | null;
171
801
  isAuthenticated: boolean;
802
+ /**
803
+ * The record-access method name for the current session (e.g. `"account"`),
804
+ * derived from the token's `AC` claim. Consumed by the in-browser SSP's
805
+ * permission injection so `$access`-gated table predicates resolve locally,
806
+ * mirroring the server's `$access`. Null when logged out.
807
+ */
808
+ access: string | null;
172
809
  isLoading: boolean;
173
810
  private events;
811
+ /** The impersonation last announced, for change detection. */
812
+ private announcedImpersonation;
813
+ /** Target of an impersonation that just ended, until the client purges its
814
+ * local bucket (see `consumeEndedImpersonation`). */
815
+ private endedImpersonationTarget;
816
+ private impersonationTimers;
174
817
  get eventSystem(): AuthEventSystem;
175
818
  constructor(schema: S, remote: RemoteDatabaseService, persistenceClient: PersistenceClient, logger: Logger$1);
176
819
  init(): Promise<void>;
@@ -181,26 +824,1559 @@ declare class AuthService<S extends SchemaStructure> {
181
824
  */
182
825
  subscribe(cb: (userId: string | null) => void): () => void;
183
826
  private notifyListeners;
827
+ /**
828
+ * The impersonation the current session is, or null. Derived from the
829
+ * token in use, so it always describes the identity the server sees.
830
+ */
831
+ get impersonation(): ImpersonationInfo | null;
832
+ /** Subscribe to impersonation changes; called immediately with the current value. */
833
+ subscribeImpersonation(cb: (info: ImpersonationInfo | null) => void): () => void;
834
+ /**
835
+ * Act as `targetId` (a record id such as `user:abc`). Admins only, and only
836
+ * when the project enabled `impersonation` in sp00ky.yml: the server refuses
837
+ * everything else. `reason` is recorded in the audit log.
838
+ *
839
+ * The admin's session is kept aside and restored by
840
+ * {@link stopImpersonating}. Local data switches to the target's bucket
841
+ * exactly as on a sign-in, and that bucket is removed on stop.
842
+ */
843
+ impersonate(targetId: string, reason: string): Promise<ImpersonationInfo>;
844
+ /**
845
+ * Return to the admin session. Always leaves the impersonated identity,
846
+ * even when the server cannot be reached: the admin token is restored
847
+ * locally first and verified afterwards, and a rejected admin token ends in
848
+ * a full sign-out rather than staying on the target.
849
+ */
850
+ stopImpersonating(opts?: {
851
+ serverEnded?: boolean;
852
+ }): Promise<void>;
853
+ /** Re-sign the impersonation token before it expires. */
854
+ renewImpersonation(): Promise<void>;
855
+ /** Users an admin may pick from. Admin-only on the server. */
856
+ searchImpersonationTargets(search: string): Promise<ImpersonationCandidate[]>;
857
+ /** Open impersonation sessions. Admin-only on the server. */
858
+ listActiveImpersonations(): Promise<ActiveImpersonation[]>;
859
+ /**
860
+ * The target of an impersonation that ended, once. The bucket switch calls
861
+ * this after leaving a bucket so it can delete that user's local data.
862
+ */
863
+ consumeEndedImpersonation(): string | null;
864
+ /**
865
+ * Restore a session from the locally cached JWT, with NO network.
866
+ *
867
+ * This is what makes a warm boot paint instantly and what makes an offline
868
+ * boot possible at all: the token is in local storage, and it already carries
869
+ * both the access method and the `$auth.id` record id. Everything the client
870
+ * needs to route queries (`setCurrentUserId`) and to satisfy `$auth`-gated
871
+ * permission predicates in the in-browser SSP (`setSessionAuth`) is therefore
872
+ * available before a socket exists.
873
+ *
874
+ * The session is OPTIMISTIC: the token is unverified here. `check()` runs
875
+ * afterwards in the background and downgrades to a real sign-out if the
876
+ * server rejects it. Nothing is trusted that the server has not also seen -
877
+ * the local store only ever holds rows the server previously sent.
878
+ *
879
+ * Returns the restored user id, or null when there is no usable token.
880
+ */
881
+ restoreSessionFromToken(): Promise<string | null>;
184
882
  /**
185
883
  * Check for existing session and validate
186
884
  */
187
885
  check(accessToken?: string): Promise<void>;
188
886
  /**
189
- * Sign out and clear session
887
+ * The server refused `token`. A refused impersonation token (stopped,
888
+ * expired, or revoked) returns to the admin session instead of signing the
889
+ * admin out; anything else signs out.
890
+ */
891
+ private rejectToken;
892
+ /**
893
+ * Sign out and clear session. While impersonating this ends the
894
+ * impersonation and signs the admin out too.
190
895
  */
191
896
  signOut(): Promise<void>;
192
897
  private setSession;
898
+ /** Fallback when the token carries no `AC` claim: if the schema defines
899
+ * exactly one record-access method, assume the session used it. */
900
+ private defaultAccessName;
193
901
  signUp<Name extends keyof S['access'] & string>(accessName: Name, params: ExtractAccessParams<S, Name, 'signup'>): Promise<void>;
194
902
  signIn<Name extends keyof S['access'] & string>(accessName: Name, params: ExtractAccessParams<S, Name, 'signIn'>): Promise<void>;
195
903
  }
196
904
  //#endregion
197
- //#region src/spooky.d.ts
905
+ //#region src/modules/crdt/crdt-field.d.ts
906
+ declare const CURSOR_COLORS: string[];
907
+ declare function cursorColorFromName(name: string): string;
908
+ declare class CrdtField {
909
+ private fieldName;
910
+ private doc;
911
+ private pushTimer;
912
+ private local;
913
+ private remote;
914
+ private recordId;
915
+ private sessionId;
916
+ private unsubscribe;
917
+ private lastPushTime;
918
+ private lastCursorPushTime;
919
+ private loadedFromCrdt;
920
+ private pushRetryCount;
921
+ private logger;
922
+ private cursorsEnabled;
923
+ /** Remote-push debounce. Local writes happen immediately on every Loro
924
+ * update; the remote UPSERT is coalesced over this window. Configured
925
+ * via `Sp00kyConfig.crdtDebounceMs`, default 500. */
926
+ private remoteDebounceMs;
927
+ private _onCursorUpdate;
928
+ private pendingCursorUpdate;
929
+ /** Callback set by the editor to receive remote cursor updates.
930
+ * Any cursor data that arrived before this callback was set will be replayed. */
931
+ set onCursorUpdate(cb: ((data: Uint8Array) => void) | null);
932
+ get onCursorUpdate(): ((data: Uint8Array) => void) | null;
933
+ /**
934
+ * @param LoroDocClass the `LoroDoc` constructor, injected by the caller after
935
+ * awaiting {@link loadLoro} — keeps `loro-crdt` out of this module's static
936
+ * import graph so it only ships to apps that use CRDT fields.
937
+ */
938
+ constructor(fieldName: string, cursorsEnabled: boolean, LoroDocClass: typeof LoroDoc, initialState?: Uint8Array, logger?: Logger$1 | null);
939
+ getDoc(): LoroDoc;
940
+ /** Whether the LoroDoc was loaded from saved CRDT state */
941
+ hasContent(): boolean;
942
+ startSync(local: LocalStore, remote: RemoteDatabaseService, recordId: string, sessionId: string, debounceMs: number): void;
943
+ /**
944
+ * Stop syncing this field. Flushes one final remote push by default so the
945
+ * last keystrokes aren't lost. Pass `{ flush: false }` on a bucket switch —
946
+ * the remote session already belongs to the NEXT user, and pushing this
947
+ * (previous user's) snapshot under it would clobber the record remotely.
948
+ */
949
+ stopSync(options?: {
950
+ flush?: boolean;
951
+ }): void;
952
+ importRemote(state: Uint8Array): void;
953
+ exportSnapshot(): Uint8Array;
954
+ /** Push this session's cursor blob into the parent row at
955
+ * `<field>.cursors[$sid]`. No-op when cursors aren't enabled on this
956
+ * field — the editor still calls this method optimistically, but
957
+ * without `@cursor` on the schema there's nowhere to store the blob.
958
+ * The UPDATE itself fires the parent table's LIVE feed, so other
959
+ * browsers receive the cursor change without a separate `_00_rv` bump. */
960
+ pushCursorState(encoded: Uint8Array): Promise<void>;
961
+ /** Import remote cursor state (called by CrdtManager from LIVE SELECT) */
962
+ importRemoteCursor(base64State: string): void;
963
+ private scheduleRemotePush;
964
+ /** SET path inside a parent row for the current snapshot. `@crdt`-only
965
+ * fields hold the snapshot directly (`<field>`); `@crdt @cursor`
966
+ * fields hold a `{ state, cursors }` object so the snapshot lives at
967
+ * `<field>.state` next to per-session cursor blobs. */
968
+ private statePath;
969
+ /** Mirror the LoroDoc snapshot into the parent row locally. Runs on
970
+ * every local update and every remote import so reloads (online or
971
+ * offline) see the freshest content immediately. Failures are
972
+ * swallowed — a stale local write must never block user input. */
973
+ private persistLocal;
974
+ private pushToRemote;
975
+ }
976
+ //#endregion
977
+ //#region src/modules/crdt/index.d.ts
978
+ /**
979
+ * CrdtManager manages active CrdtField instances and their sync channels.
980
+ *
981
+ * Collaborative state lives in two dedicated tables (defined in
982
+ * `apps/cli/src/meta_tables_remote.surql`):
983
+ * - `_00_crdt` { record_id, field, state } — one row per (record, field)
984
+ * - `_00_cursor` { record_id, session_id, field, state } — one row per
985
+ * (record, session, field)
986
+ *
987
+ * Splitting them off the parent row is what makes offline edits mergeable:
988
+ * each (record, field) gets its own row, so concurrent offline writes don't
989
+ * collide on the parent's last-write-wins semantics.
990
+ *
991
+ * Cross-browser delivery still rides the parent table's existing LIVE feed
992
+ * to avoid SurrealDB v3 LIVE bugs around dereference-based permission rules
993
+ * (issues 3602, 4026). On every meta UPSERT the writer also bumps the
994
+ * parent's `_00_rv` (a no-op assignment); that fires the parent's LIVE
995
+ * feed, and the receiver pulls the matching `_00_crdt` / `_00_cursor` rows
996
+ * via subquery. Permission inheritance happens server-side via
997
+ * `record_id.id != NONE` (SELECT) and `fn::can_update_record` (UPDATE).
998
+ */
999
+ declare class CrdtManager {
1000
+ private schema;
1001
+ private local;
1002
+ private remote;
1003
+ private debounceMs;
1004
+ private fields;
1005
+ private liveByTable;
1006
+ private pendingLive;
1007
+ private staleTables;
1008
+ private connectionGeneration;
1009
+ private connectionUnsubscribes;
1010
+ private logger;
1011
+ private sessionId;
1012
+ constructor(schema: SchemaStructure, local: LocalStore, remote: RemoteDatabaseService, logger: Logger$1, debounceMs?: number);
1013
+ /**
1014
+ * Re-establish table LIVEs after a socket drop.
1015
+ *
1016
+ * A LIVE subscription lives and dies with its WebSocket session, and
1017
+ * `ensureTableSubscription` is memoized on `liveByTable` — so without this,
1018
+ * the first reconnect leaves CRDT realtime permanently dead: the map still
1019
+ * holds a uuid for a subscription the server has forgotten, so every later
1020
+ * `open()` short-circuits and no LIVE is ever re-issued.
1021
+ *
1022
+ * Both drop events matter: the SDK publishes `reconnecting` (not
1023
+ * `disconnected`) when it intends to recover on its own, and `disconnected`
1024
+ * only once it has given up.
1025
+ */
1026
+ private subscribeToReconnect;
1027
+ /** Stop observing transport events. Separate from {@link closeAll}, which also
1028
+ * runs on a bucket switch where the manager keeps being used. */
1029
+ dispose(): void;
1030
+ private hasOpenFieldFor;
1031
+ /** Set the session id that scopes this client's cursor entries. Must be
1032
+ * called before `open()` for cursors to be pushed under a stable key.
1033
+ * Passed in from `sp00ky.ts` at boot (it already fetches `session::id()`
1034
+ * for the data-module salt). */
1035
+ setSessionId(sessionId: string): void;
1036
+ /**
1037
+ * Open a CRDT field for collaborative editing.
1038
+ *
1039
+ * @param table - Table name
1040
+ * @param recordId - Full record ID (e.g., "thread:abc")
1041
+ * @param field - Field name (e.g., "title", "content")
1042
+ * @param fallbackText - Current plain text from the record, used to seed the
1043
+ * LoroDoc if no CRDT state exists yet (migration path)
1044
+ */
1045
+ open(table: string, recordId: string, field: string, fallbackText?: string): Promise<CrdtField>;
1046
+ close(table: string, recordId: string, field: string): void;
1047
+ /**
1048
+ * Close every open field + table LIVE. Fields flush a final remote push by
1049
+ * default; pass `{ flush: false }` on a bucket switch, where that flush
1050
+ * would push the previous user's snapshot under the next user's session.
1051
+ */
1052
+ closeAll(options?: {
1053
+ flush?: boolean;
1054
+ }): void;
1055
+ /** Ensure a single `LIVE SELECT * FROM <table>` is running, shared across
1056
+ * every open CrdtField on `table`. */
1057
+ private ensureTableSubscription;
1058
+ /** Apply a parent-row payload from a non-LIVE source (e.g. the
1059
+ * list_ref-driven sync engine, when the cross-user LIVE on the
1060
+ * parent table is filtered out by the SurrealDB cross-session
1061
+ * permission gap). Same semantics as the internal `dispatchRow`. */
1062
+ applyRow(table: string, row: Record<string, unknown>): void;
1063
+ /** Dispatch a parent-row LIVE event to every open CrdtField on that
1064
+ * record. Each open field reads its slice of the row directly — the
1065
+ * CRDT snapshot is a column on the parent now, so there is no
1066
+ * follow-up subquery. */
1067
+ private dispatchRow;
1068
+ /** One-shot remote fetch for a row whose CRDT field hasn't synced
1069
+ * locally yet (fresh device, memory-backed local DB after reload, …).
1070
+ * Used by `open()` when the local read came up empty. Subsequent
1071
+ * cross-browser updates ride `dispatchRow` via the parent LIVE feed. */
1072
+ private fetchAndDispatchRow;
1073
+ /** Schema lookup: does `<table>.<field>` carry a `@cursor` annotation?
1074
+ * Determines the on-disk shape (plain snapshot vs. `{ state, cursors }`). */
1075
+ private fieldHasCursor;
1076
+ /** Pull the LoroDoc snapshot bytes out of a row slice. For `@crdt`-only
1077
+ * the slice IS the snapshot (Uint8Array); for `@crdt @cursor` it's
1078
+ * `{ state, cursors }` where `state` carries the snapshot bytes. */
1079
+ private extractSnapshot;
1080
+ private killTableSubscription;
1081
+ private makeKey;
1082
+ /**
1083
+ * Throws if `<table>.<field>` is not annotated `@crdt` in the schema. Catches
1084
+ * typos, removed annotations, and stale schema codegen at the call site instead
1085
+ * of silently producing a non-CRDT writer.
1086
+ */
1087
+ private assertCrdtField;
1088
+ }
1089
+ //#endregion
1090
+ //#region src/modules/feature-flag/index.d.ts
1091
+ interface FeatureFlagSnapshot {
1092
+ variant: string | undefined;
1093
+ payload: unknown | undefined;
1094
+ }
1095
+ interface FeatureFlagOptions {
1096
+ fallback?: string;
1097
+ ttl?: QueryTimeToLive;
1098
+ }
1099
+ /**
1100
+ * A locally forced variant. Applies to THIS browser only and is never sent to
1101
+ * the server — the assignment in `_00_user_feature` is untouched, so clearing
1102
+ * the override restores whatever the server says.
1103
+ */
1104
+ interface FeatureFlagOverride {
1105
+ variant: string;
1106
+ payload?: unknown;
1107
+ }
1108
+ declare class FeatureFlagHandle {
1109
+ readonly key: string;
1110
+ readonly fallback: string | undefined;
1111
+ private latest;
1112
+ private listeners;
1113
+ private unsubscribeFn;
1114
+ private onCloseFn;
1115
+ private closed;
1116
+ constructor(key: string, fallback: string | undefined);
1117
+ attach(unsubscribe: () => void): void;
1118
+ detach(): void;
1119
+ set(snapshot: FeatureFlagSnapshot): void;
1120
+ variant(): string | undefined;
1121
+ payload<T = unknown>(): T | undefined;
1122
+ enabled(): boolean;
1123
+ subscribe(cb: (s: FeatureFlagSnapshot) => void): () => void;
1124
+ onClose(cb: () => void): void;
1125
+ close(): void;
1126
+ }
1127
+ /** The slice of the query engine this module drives: register a live query
1128
+ * locally, ask for its remote registration, subscribe to its rows. */
1129
+ interface LiveQueryPort$1 {
1130
+ query(table: string, surql: string, params: Record<string, unknown>, ttl: QueryTimeToLive): Promise<string>;
1131
+ subscribe(hash: string, cb: (records: Record<string, any>[]) => void, options?: {
1132
+ immediate?: boolean;
1133
+ }): () => void;
1134
+ }
1135
+ interface RemoteRegisterPort$1 {
1136
+ enqueueDownEvent(event: {
1137
+ type: 'register';
1138
+ payload: {
1139
+ hash: string;
1140
+ };
1141
+ }): void;
1142
+ }
1143
+ interface FeatureFlagModuleDeps<S extends SchemaStructure> {
1144
+ dataModule: LiveQueryPort$1;
1145
+ sync: RemoteRegisterPort$1;
1146
+ auth: Pick<AuthService<S>, 'subscribe'>;
1147
+ logger: Logger$1;
1148
+ }
1149
+ declare class FeatureFlagModule<S extends SchemaStructure> {
1150
+ private deps;
1151
+ private logger;
1152
+ private handles;
1153
+ private authUnsubscribe;
1154
+ private lastUserId;
1155
+ private querySubscription;
1156
+ private starting;
1157
+ private ttl;
1158
+ private snapshots;
1159
+ private loaded;
1160
+ private overrides;
1161
+ constructor(deps: FeatureFlagModuleDeps<S>);
1162
+ init(): void;
1163
+ feature(key: string, options?: FeatureFlagOptions): FeatureFlagHandle;
1164
+ closeAll(): Promise<void>;
1165
+ /** Auth changed: drop the old user's query/snapshots and re-observe. */
1166
+ private refresh;
1167
+ private teardownQuery;
1168
+ /** Start the single shared live query (idempotent; no-op with no handles). */
1169
+ private ensureStarted;
1170
+ /** Live query result → per-key snapshots → push to every active handle. */
1171
+ private applyRecords;
1172
+ /**
1173
+ * Force `key` to `variant` in THIS browser. Pass `null` to clear.
1174
+ *
1175
+ * Nothing is written to the server: the `_00_user_feature` assignment is
1176
+ * untouched, so clearing restores whatever the server says. Persisted to
1177
+ * localStorage on the page origin, so it survives a reload.
1178
+ */
1179
+ setLocalOverride(key: string, variant: string | null, payload?: unknown): void;
1180
+ clearLocalOverrides(): void;
1181
+ getLocalOverrides(): Record<string, FeatureFlagOverride>;
1182
+ /** The assignment for `key`, with any local override taking precedence. */
1183
+ private resolve;
1184
+ private pushAll;
1185
+ private loadOverrides;
1186
+ private persistOverrides;
1187
+ }
1188
+ //#endregion
1189
+ //#region src/modules/app-release/index.d.ts
1190
+ interface AppReleaseSnapshot {
1191
+ /** Latest announced version for the app, or undefined when no row exists. */
1192
+ version: string | undefined;
1193
+ /** Clients should clear SW/caches when reloading onto this version. */
1194
+ cacheBust: boolean;
1195
+ /** Clients should reload/update immediately instead of asking. */
1196
+ mandatory: boolean;
1197
+ releasedAt: string | undefined;
1198
+ }
1199
+ interface AppReleaseOptions {
1200
+ ttl?: QueryTimeToLive;
1201
+ }
1202
+ declare class AppReleaseHandle {
1203
+ readonly app: string;
1204
+ private latest;
1205
+ private listeners;
1206
+ private onCloseFn;
1207
+ private closed;
1208
+ constructor(app: string);
1209
+ set(snapshot: AppReleaseSnapshot): void;
1210
+ snapshot(): AppReleaseSnapshot;
1211
+ version(): string | undefined;
1212
+ /** True when the announced version is semver-newer than `currentVersion`. */
1213
+ updateAvailable(currentVersion: string): boolean;
1214
+ subscribe(cb: (s: AppReleaseSnapshot) => void): () => void;
1215
+ onClose(cb: () => void): void;
1216
+ close(): void;
1217
+ }
1218
+ /** The slice of the query engine this module drives: register a live query
1219
+ * locally, ask for its remote registration, subscribe to its rows. */
1220
+ interface LiveQueryPort {
1221
+ query(table: string, surql: string, params: Record<string, unknown>, ttl: QueryTimeToLive): Promise<string>;
1222
+ subscribe(hash: string, cb: (records: Record<string, any>[]) => void, options?: {
1223
+ immediate?: boolean;
1224
+ }): () => void;
1225
+ }
1226
+ interface RemoteRegisterPort {
1227
+ enqueueDownEvent(event: {
1228
+ type: 'register';
1229
+ payload: {
1230
+ hash: string;
1231
+ };
1232
+ }): void;
1233
+ }
1234
+ interface AppReleaseModuleDeps<S extends SchemaStructure> {
1235
+ dataModule: LiveQueryPort;
1236
+ sync: RemoteRegisterPort;
1237
+ auth: Pick<AuthService<S>, 'subscribe'>;
1238
+ logger: Logger$1;
1239
+ }
1240
+ declare class AppReleaseModule<S extends SchemaStructure> {
1241
+ private deps;
1242
+ private logger;
1243
+ private handles;
1244
+ private authUnsubscribe;
1245
+ private lastUserId;
1246
+ private querySubscription;
1247
+ private starting;
1248
+ private ttl;
1249
+ private snapshots;
1250
+ private loaded;
1251
+ constructor(deps: AppReleaseModuleDeps<S>);
1252
+ init(): void;
1253
+ release(app: string, options?: AppReleaseOptions): AppReleaseHandle;
1254
+ closeAll(): Promise<void>;
1255
+ private refresh;
1256
+ private teardownQuery;
1257
+ private ensureStarted;
1258
+ private applyRecords;
1259
+ }
1260
+ //#endregion
1261
+ //#region src/kernel/events.d.ts
1262
+ /**
1263
+ * Inbound events. Everything that can start a saga arrives as one of these
1264
+ * through the runtime router: public API calls, adapter callbacks, timers,
1265
+ * and `dispatch` effects from other sagas.
1266
+ */
1267
+ type RuntimeEvent = {
1268
+ type: 'EnsureRegistered';
1269
+ requireAuth?: boolean;
1270
+ attempt?: number;
1271
+ } | {
1272
+ type: 'RegisterRemote';
1273
+ hash: QueryHash;
1274
+ retry?: boolean;
1275
+ } | {
1276
+ type: 'SyncOutcome';
1277
+ ok: boolean;
1278
+ error?: unknown;
1279
+ } | {
1280
+ type: 'AckPrune';
1281
+ } | {
1282
+ type: 'ReadDirtyMembership';
1283
+ } | {
1284
+ type: 'RecoverLostView';
1285
+ hash: QueryHash;
1286
+ } | {
1287
+ type: 'ReadMembership';
1288
+ hashes: QueryHash[];
1289
+ } | {
1290
+ type: 'FetchRows';
1291
+ } | {
1292
+ type: 'Materialize';
1293
+ hash: QueryHash;
1294
+ } | {
1295
+ type: 'MaterializeDirty';
1296
+ } | {
1297
+ type: 'LifecycleTick';
1298
+ } | {
1299
+ type: 'GcTick';
1300
+ } | {
1301
+ type: 'Drain';
1302
+ } | {
1303
+ type: 'FlushWrite';
1304
+ key: string;
1305
+ } | {
1306
+ type: 'PollTick';
1307
+ } | {
1308
+ type: 'SelfHealTick';
1309
+ } | {
1310
+ type: 'HeartbeatNow';
1311
+ } | {
1312
+ type: 'StartRemote';
1313
+ } | {
1314
+ type: 'PrimeCircuit';
1315
+ } | {
1316
+ type: 'VersionsPrimed';
1317
+ entries: Array<readonly [string, number]>;
1318
+ } | {
1319
+ type: 'WarmBlobs';
1320
+ } | {
1321
+ type: 'AuthFlip';
1322
+ userId: string | null;
1323
+ } | {
1324
+ type: 'BucketSwitch';
1325
+ target: string;
1326
+ } | {
1327
+ type: 'PageHide';
1328
+ } | {
1329
+ type: 'ConnectionChanged';
1330
+ state: ConnectionState;
1331
+ } | {
1332
+ type: 'StreamUpdate';
1333
+ update: StreamUpdate;
1334
+ } | {
1335
+ type: 'LiveStart';
1336
+ } | {
1337
+ type: 'LiveChange';
1338
+ hashes: QueryHash[];
1339
+ rows?: InlineRow[];
1340
+ } | {
1341
+ type: 'TabRole';
1342
+ role: 'solo' | 'leader' | 'follower';
1343
+ } | {
1344
+ type: 'TabMessage';
1345
+ message: unknown;
1346
+ };
1347
+ type OutEvent = {
1348
+ type: 'query:records';
1349
+ hash: QueryHash;
1350
+ records: ReadonlyArray<Record<string, unknown>>;
1351
+ } | {
1352
+ type: 'query:status';
1353
+ hash: QueryHash;
1354
+ status: QueryStatus;
1355
+ } | {
1356
+ type: 'query:authority';
1357
+ hash: QueryHash;
1358
+ known: boolean;
1359
+ } | {
1360
+ type: 'query:view-lost';
1361
+ hash: QueryHash;
1362
+ } | {
1363
+ type: 'query:evicted';
1364
+ hash: QueryHash;
1365
+ } | {
1366
+ type: 'mutation:event';
1367
+ event: unknown;
1368
+ } | {
1369
+ type: 'mutation:settled';
1370
+ mutationId: string;
1371
+ recordId: string;
1372
+ eventType: string;
1373
+ } | {
1374
+ type: 'mutation:rolled-back';
1375
+ mutationId: string;
1376
+ recordId: string;
1377
+ eventType: string;
1378
+ error: string;
1379
+ } | {
1380
+ type: 'tray:changed';
1381
+ count: number;
1382
+ } | {
1383
+ type: 'health:changed';
1384
+ health: SyncHealth;
1385
+ } | {
1386
+ type: 'activity:changed';
1387
+ fetching: number;
1388
+ pending: number;
1389
+ } | {
1390
+ type: 'tabs:broadcast';
1391
+ message: unknown;
1392
+ } | {
1393
+ type: 'tabs:sendTo';
1394
+ tabId: string;
1395
+ message: unknown;
1396
+ } | {
1397
+ type: 'devtools';
1398
+ name: string;
1399
+ data?: unknown;
1400
+ } | {
1401
+ type: 'log';
1402
+ level: 'debug' | 'info' | 'warn' | 'error';
1403
+ message: string;
1404
+ data?: unknown;
1405
+ };
1406
+ //#endregion
1407
+ //#region src/state/lifecycle.d.ts
1408
+ /**
1409
+ * The query lifecycle as one explicit machine.
1410
+ *
1411
+ * `phase` answers "where do this query's rows come from":
1412
+ * - `cold` never resolved on this device: rows come from a local
1413
+ * predicate scan (or the SSP local window), bindings show a loader.
1414
+ * - `cached` a durable `_00_view` row was found: rows come from that id-set.
1415
+ * - `live` a server membership set was accepted this session.
1416
+ * - `view-lost` the server's `_00_query` row vanished while we held
1417
+ * membership; rows are kept, a re-registration is under way.
1418
+ *
1419
+ * `remote` tracks the server-side registration, `fetchDepth` is the refcount
1420
+ * behind `status: 'fetching'`, `notified` says whether subscribers received
1421
+ * at least one materialization this registration.
1422
+ */
1423
+ type QueryPhase = 'cold' | 'cached' | 'live' | 'view-lost';
1424
+ type RemotePhase = 'unregistered' | 'registering' | 'registered' | 'failed';
1425
+ interface QueryLifecycle {
1426
+ readonly phase: QueryPhase;
1427
+ readonly remote: RemotePhase;
1428
+ readonly fetchDepth: number;
1429
+ readonly notified: boolean;
1430
+ }
1431
+ //#endregion
1432
+ //#region src/kernel/effects.d.ts
1433
+ type Vars = Record<string, unknown>;
1434
+ /** What the in-browser SSP needs to build a local view. */
1435
+ interface RegisterPlan {
1436
+ queryHash: string;
1437
+ surql: string;
1438
+ params: Vars;
1439
+ ttl: string;
1440
+ tableName: string;
1441
+ involvedTables?: string[];
1442
+ }
1443
+ /** One statement's outcome of a multi-statement remote request. */
1444
+ type StatementResult = {
1445
+ status: 'OK';
1446
+ result: unknown;
1447
+ } | {
1448
+ status: 'ERR';
1449
+ error: string;
1450
+ };
1451
+ /** One entry of an `all` fan-out result (allSettled semantics). */
1452
+
1453
+ /**
1454
+ * Effects are data. A saga yields one and gets its result back on the next
1455
+ * `next()`; the interpreter (client/runtime) is the only place they execute.
1456
+ */
1457
+ type Effect = {
1458
+ kind: 'local.query';
1459
+ sql: string;
1460
+ vars?: Vars;
1461
+ epoch?: number;
1462
+ } | {
1463
+ kind: 'local.select';
1464
+ plan: QueryPlan;
1465
+ params?: Vars;
1466
+ } | {
1467
+ kind: 'local.execute';
1468
+ query: SealedQuery<unknown>;
1469
+ vars?: Vars;
1470
+ epoch?: number;
1471
+ } | {
1472
+ kind: 'local.getById';
1473
+ table: string;
1474
+ id: unknown;
1475
+ } | {
1476
+ kind: 'local.upsert';
1477
+ table: string;
1478
+ id: unknown;
1479
+ data: Record<string, unknown>;
1480
+ mode: 'replace' | 'merge';
1481
+ } | {
1482
+ kind: 'local.delete';
1483
+ table: string;
1484
+ id: unknown;
1485
+ } | {
1486
+ kind: 'local.epoch';
1487
+ } | {
1488
+ kind: 'remote.query';
1489
+ sql: string;
1490
+ vars?: Vars;
1491
+ timeoutMs?: number;
1492
+ } | {
1493
+ kind: 'remote.live';
1494
+ table: string;
1495
+ } | {
1496
+ kind: 'remote.kill';
1497
+ uuid: string;
1498
+ } | {
1499
+ kind: 'ssp.register';
1500
+ plan: RegisterPlan;
1501
+ } | {
1502
+ kind: 'ssp.unregister';
1503
+ hash: string;
1504
+ } | {
1505
+ kind: 'ssp.ingest';
1506
+ records: IngestRecord[];
1507
+ } | {
1508
+ kind: 'timer.set';
1509
+ key: string;
1510
+ ms: number;
1511
+ event: RuntimeEvent;
1512
+ } | {
1513
+ kind: 'timer.clear';
1514
+ key: string;
1515
+ } | {
1516
+ kind: 'state.read';
1517
+ select: (s: ClientState) => unknown;
1518
+ } | {
1519
+ kind: 'state.update';
1520
+ fn: (s: ClientState) => ClientState;
1521
+ } | {
1522
+ kind: 'state.wait';
1523
+ until: (s: ClientState) => boolean;
1524
+ } | {
1525
+ kind: 'now';
1526
+ } | {
1527
+ kind: 'id';
1528
+ scope: 'mutation' | 'salt';
1529
+ } | {
1530
+ kind: 'hash';
1531
+ input: string;
1532
+ } | {
1533
+ kind: 'emit';
1534
+ event: OutEvent;
1535
+ } | {
1536
+ kind: 'dispatch';
1537
+ event: RuntimeEvent;
1538
+ } | {
1539
+ kind: 'all';
1540
+ effects: Effect[];
1541
+ } | {
1542
+ kind: 'service';
1543
+ name: ServiceName;
1544
+ args: unknown[];
1545
+ };
1546
+ /**
1547
+ * Calls into the legacy services that are adapters, not state: auth, tabs,
1548
+ * blobs, migrator, crdt, persistence, the SSP lifecycle, the remote socket.
1549
+ * A saga names the call; the runtime binds it to the real service.
1550
+ */
1551
+ interface ServiceCalls {
1552
+ 'hint.read': () => string | null;
1553
+ 'hint.write': (bucketId: string) => void;
1554
+ 'local.connect': (bucketId: string) => Promise<void>;
1555
+ 'local.switchStore': (bucketId: string) => Promise<void>;
1556
+ 'local.beginSwitch': () => () => void;
1557
+ 'local.currentBucketId': () => string;
1558
+ 'local.usesSurqlSchema': () => boolean;
1559
+ 'local.dropBucket': (bucketId: string) => Promise<void>;
1560
+ 'migrator.provision': () => Promise<void>;
1561
+ 'blobs.start': (bucketId: string) => Promise<void>;
1562
+ 'blobs.setNamespace': (bucketId: string) => Promise<void>;
1563
+ 'blobs.clear': () => Promise<void>;
1564
+ 'ssp.init': () => Promise<void>;
1565
+ 'ssp.setPermissions': () => void;
1566
+ 'ssp.setSessionAuth': (authId: string | null, access: string | null) => void;
1567
+ 'ssp.prime': (pendingIds: string[]) => Promise<void>;
1568
+ 'ssp.reset': () => Promise<void>;
1569
+ 'ssp.setPersistence': (enabled: boolean) => void;
1570
+ 'auth.restoreSession': () => Promise<string | null>;
1571
+ 'auth.init': () => Promise<void>;
1572
+ 'auth.sessionAuthId': () => string | null;
1573
+ 'auth.access': () => string | null;
1574
+ 'auth.token': () => string | null;
1575
+ 'auth.currentUser': () => Record<string, unknown> | null;
1576
+ 'auth.consumeEndedImpersonation': () => string | null;
1577
+ 'remote.connect': () => Promise<void>;
1578
+ 'remote.releaseViews': (ids: unknown[]) => void;
1579
+ 'supervisor.start': () => void;
1580
+ 'tabs.start': (bucketId: string) => Promise<'solo' | 'leader' | 'follower'>;
1581
+ 'tabs.moveToBucket': (bucketId: string) => Promise<'solo' | 'leader' | 'follower'>;
1582
+ 'crdt.setSessionId': (sessionId: string) => void;
1583
+ 'crdt.closeAll': (flush: boolean) => void;
1584
+ 'persistence.set': (key: string, value: unknown) => Promise<void>;
1585
+ 'features.init': () => void;
1586
+ 'releases.init': () => void;
1587
+ 'window.attach': () => void;
1588
+ }
1589
+ type ServiceName = keyof ServiceCalls;
1590
+ /** Typed constructors. Each is one line so the saga reads like the plan. */
1591
+ //#endregion
1592
+ //#region src/kernel/saga.d.ts
1593
+ /**
1594
+ * A saga is a generator that yields effects and receives their results. It
1595
+ * holds no references to adapters or state, so it can be driven by the real
1596
+ * interpreter or by `testing/run-pure` with canned results.
1597
+ */
1598
+ type Saga<R> = Generator<Effect, R, any>;
1599
+ /**
1600
+ * Lanes bound concurrency without a queue object per lane:
1601
+ * - `serial`: runs strictly one at a time per key, in arrival order.
1602
+ * - `dedupe`: a request while one run is active joins that run instead of
1603
+ * starting another.
1604
+ * The bookkeeping is a pure value so the policy is unit-testable; the runtime
1605
+ * owns the promises.
1606
+ */
1607
+ type Lane = {
1608
+ kind: 'serial';
1609
+ key: string;
1610
+ } | {
1611
+ kind: 'dedupe';
1612
+ key: string;
1613
+ };
1614
+ interface LaneState {
1615
+ readonly running: ReadonlySet<string>;
1616
+ readonly waiting: ReadonlyMap<string, number>;
1617
+ }
1618
+ //#endregion
1619
+ //#region src/state/client-state.d.ts
1620
+ type Row = Record<string, unknown>;
1621
+ /** Everything a query is, minus anything that changes after registration. */
1622
+ interface QueryDefinition {
1623
+ /** Session-salted `_00_query:<hash>` record id: the remote view id. */
1624
+ readonly id: RecordId<string>;
1625
+ readonly hash: QueryHash;
1626
+ /** Unsalted `sha256({surql, params})`: key of the durable `_00_view` row. */
1627
+ readonly viewKey: string;
1628
+ readonly surql: string;
1629
+ readonly params: Readonly<Record<string, unknown>>;
1630
+ readonly plan?: QueryPlan;
1631
+ readonly ttl: QueryTimeToLive;
1632
+ readonly ttlMs: number;
1633
+ readonly tableName: string;
1634
+ readonly createdAt: number;
1635
+ }
1636
+ type ServerViewState = 'materializing' | 'ready' | null;
1637
+ interface QueryTelemetry {
1638
+ readonly updateCount: number;
1639
+ readonly errorCount: number;
1640
+ readonly lastUpdatedAt: number | null;
1641
+ readonly materializationSamples: ReadonlyArray<number>;
1642
+ readonly lastIngestLatencyMs: number | null;
1643
+ readonly phaseSamples: Readonly<Record<string, ReadonlyArray<number>>>;
1644
+ readonly phaseLast: Readonly<Record<string, number | null>>;
1645
+ readonly registrationTimings: RegistrationTimings;
1646
+ }
1647
+ interface QueryEntry {
1648
+ readonly def: QueryDefinition;
1649
+ readonly lifecycle: QueryLifecycle;
1650
+ /** Server membership (ids + versions) or the durable seed. */
1651
+ readonly remoteArray: RecordVersionArray;
1652
+ /** The in-browser SSP view's id-set: local truth for "does this row match". */
1653
+ readonly localArray: RecordVersionArray;
1654
+ readonly subqueryRemoteArray: RecordVersionArray;
1655
+ readonly records: ReadonlyArray<Row>;
1656
+ readonly serverState: ServerViewState;
1657
+ readonly subscribers: number;
1658
+ readonly lastSubscriberLeftAt: number | null;
1659
+ readonly lastHeartbeatAt: number | null;
1660
+ readonly lastPolledAt: number | null;
1661
+ readonly registerAttempts: number;
1662
+ /**
1663
+ * Re-registrations issued because the `_00_query` row read as gone, since
1664
+ * the row was last seen. Paces the next one (see `applyMembership`).
1665
+ */
1666
+ readonly viewLostCount: number;
1667
+ /** When the paced re-registration is due; null when none is pending. */
1668
+ readonly viewLostRetryAt: number | null;
1669
+ readonly telemetry: QueryTelemetry;
1670
+ }
1671
+ type OutboxStatus = 'pending' | 'acked';
1672
+ /** In-memory mirror of one `_00_pending_mutations` row plus its push progress. */
1673
+ interface OutboxItem {
1674
+ readonly id: string;
1675
+ readonly type: MutationEventType;
1676
+ readonly recordId: string;
1677
+ readonly table: string;
1678
+ readonly status: OutboxStatus;
1679
+ readonly ackedAt: number | null;
1680
+ readonly attempts: number;
1681
+ }
1682
+ type TabRole = 'solo' | 'leader' | 'follower';
1683
+ /** A debounced update accumulating locally until its flush writes the outbox row. */
1684
+ interface PendingWrite {
1685
+ readonly key: string;
1686
+ readonly table: string;
1687
+ readonly recordId: string;
1688
+ readonly data: Readonly<Record<string, unknown>>;
1689
+ readonly before: Readonly<Record<string, unknown>> | null;
1690
+ readonly firstAt: number;
1691
+ }
1692
+ interface SyncSlice {
1693
+ readonly health: SyncHealth;
1694
+ readonly consecutiveFailures: number;
1695
+ readonly hasSyncedOnce: boolean;
1696
+ readonly selfHealAttempts: number;
1697
+ readonly pollIdleStreak: number;
1698
+ readonly lastReconnectRefetchAt: number | null;
1699
+ readonly needsResubscribe: boolean;
1700
+ readonly fetchAttempts: number;
1701
+ readonly liveUuid: string | null;
1702
+ readonly liveTable: string | null;
1703
+ readonly lanes: LaneState;
1704
+ }
1705
+ interface ClientState {
1706
+ readonly sessionId: string | null;
1707
+ readonly userId: string | null;
1708
+ /** The principal the current session salt was minted for. */
1709
+ readonly saltUserId: string | null;
1710
+ /** Latest bucket-switch target; an older switch that wakes up to a newer target skips. */
1711
+ readonly pendingBucket: string | null;
1712
+ readonly tabId: string;
1713
+ readonly tabRole: TabRole;
1714
+ readonly bucketId: string | null;
1715
+ readonly localReady: boolean;
1716
+ readonly primed: boolean;
1717
+ readonly queries: ReadonlyMap<QueryHash, QueryEntry>;
1718
+ /** Hashes whose local registration is in flight (dedupes concurrent `query()` calls). */
1719
+ readonly registering: ReadonlySet<QueryHash>;
1720
+ /** Re-read attempts per hash while the server reports a view as `materializing`. */
1721
+ readonly membershipReread: ReadonlyMap<QueryHash, number>;
1722
+ /** Local body versions (`_00_rv`) by encoded record id. */
1723
+ readonly versions: ReadonlyMap<string, number>;
1724
+ readonly outbox: ReadonlyArray<OutboxItem>;
1725
+ readonly pendingWrites: ReadonlyMap<string, PendingWrite>;
1726
+ readonly failedCount: number;
1727
+ /** Queries whose render inputs changed since their last materialization. */
1728
+ readonly dirty: ReadonlySet<QueryHash>;
1729
+ /** Queries whose server membership must be re-read. */
1730
+ readonly membershipDirty: ReadonlySet<QueryHash>;
1731
+ readonly sync: SyncSlice;
1732
+ }
1733
+ //#endregion
1734
+ //#region src/modules/ref-tables.d.ts
1735
+ type RefMode = 'single' | 'dedicated';
1736
+ /**
1737
+ * Sentinel user id for unauthenticated clients when anonymous live queries are
1738
+ * enabled. Mirrors `ssp_protocol::ANON_AUTH_ID`. It carries no `user:` prefix
1739
+ * so it can never collide with a real user id (those arrive as `user:<id>`);
1740
+ * both sides resolve it to the shared `_00_list_ref_anon` table.
1741
+ */
1742
+ //#endregion
1743
+ //#region src/query/env.d.ts
1744
+ /**
1745
+ * Static configuration the sagas read. Plain data, built once by the runtime
1746
+ * from `Sp00kyConfig`; sagas never see the config object itself.
1747
+ */
1748
+ interface SagaEnv {
1749
+ readonly schema: SchemaStructure;
1750
+ readonly refMode: RefMode;
1751
+ readonly anonLive: boolean;
1752
+ readonly remoteTimeoutMs: number;
1753
+ readonly pushTimeoutMs: number;
1754
+ readonly outboxBatchSize: number;
1755
+ readonly degradeAfter: number;
1756
+ readonly materializeDebounceMs: number;
1757
+ readonly pollBaseMs: number;
1758
+ readonly defaultTtlMs: number;
1759
+ }
1760
+ //#endregion
1761
+ //#region src/mutation/rows.d.ts
1762
+ interface FailedMutationRow {
1763
+ id: string;
1764
+ mutationType: MutationEventType;
1765
+ recordId: string;
1766
+ tableName: string;
1767
+ data?: Record<string, unknown>;
1768
+ beforeRecord?: Record<string, unknown> | null;
1769
+ error: {
1770
+ message: string;
1771
+ kind: 'application' | 'unreplayable';
1772
+ };
1773
+ attempts: number;
1774
+ createdAt: number;
1775
+ failedAt: number;
1776
+ revert: 'full' | 'partial' | 'none';
1777
+ }
1778
+ /**
1779
+ * Parse a record id READ BACK FROM THE STORE, stripping SurrealDB's `⟨⟩`
1780
+ * escaping (outbox ids contain `_`, so they come back escaped).
1781
+ */
1782
+ //#endregion
1783
+ //#region src/kernel/interpreter.d.ts
1784
+ /** The five adapters plus the pure ports, as the interpreter sees them. */
1785
+ interface Adapters {
1786
+ local: Pick<LocalStore, 'query' | 'execute' | 'select' | 'getById' | 'upsert' | 'delete'> & {
1787
+ readonly epoch: number;
1788
+ };
1789
+ remote: {
1790
+ queryResponses(sql: string, vars?: Record<string, unknown>): Promise<StatementResult[]>;
1791
+ /**
1792
+ * Subscribe to `LIVE SELECT * FROM <table>`; the callback receives the
1793
+ * changed query hashes, plus the changed rows themselves when the
1794
+ * subscription was opened with the body joined on (`liveInlineBodies`).
1795
+ */
1796
+ live(table: string, onChange: (hashes: string[], rows?: InlineRow[]) => void): Promise<string>;
1797
+ kill(uuid: string): Promise<void>;
1798
+ };
1799
+ ssp: {
1800
+ registerQueryPlan(plan: {
1801
+ queryHash: string;
1802
+ surql: string;
1803
+ params: Record<string, unknown>;
1804
+ ttl: Duration;
1805
+ lastActiveAt: Date;
1806
+ localArray: [];
1807
+ remoteArray: [];
1808
+ meta: {
1809
+ tableName: string;
1810
+ involvedTables?: string[];
1811
+ };
1812
+ }): StreamUpdate | undefined;
1813
+ unregisterQueryPlan(hash: string): void;
1814
+ ingestMany(records: IngestRecord[]): unknown;
1815
+ };
1816
+ timers: {
1817
+ set(key: string, ms: number, fire: () => void): void;
1818
+ clear(key: string): void;
1819
+ };
1820
+ clock: {
1821
+ now(): number;
1822
+ };
1823
+ ids: {
1824
+ mutation(): string;
1825
+ salt(): string;
1826
+ };
1827
+ hash(input: string): Promise<string>;
1828
+ services: ServiceCalls;
1829
+ }
1830
+ /** What the interpreter needs from the runtime: state, waiting, emitting, dispatching. */
1831
+ //#endregion
1832
+ //#region src/client/runtime.d.ts
1833
+ interface RuntimeOptions {
1834
+ env: SagaEnv;
1835
+ adapters: Adapters;
1836
+ logger: Logger$1;
1837
+ tabId: string;
1838
+ initialState?: ClientState;
1839
+ }
1840
+ /**
1841
+ * The one effectful object of the engine: holds the state, runs sagas on
1842
+ * lanes, fires timers, fans events out to subscribers and schedules
1843
+ * materialization for dirty queries. No decision logic lives here.
1844
+ */
1845
+ declare class Runtime {
1846
+ private stateValue;
1847
+ private readonly interpret;
1848
+ private readonly env;
1849
+ private readonly adapters;
1850
+ private readonly logger;
1851
+ private readonly serialLanes;
1852
+ private readonly dedupeLanes;
1853
+ private readonly waiters;
1854
+ private readonly recordSubs;
1855
+ private readonly statusSubs;
1856
+ private readonly authoritySubs;
1857
+ private readonly lastStatus;
1858
+ private readonly listeners;
1859
+ private readonly scheduledMaterialize;
1860
+ private readonly timerKeys;
1861
+ private lastActivity;
1862
+ private lastHealth;
1863
+ private disposed;
1864
+ constructor(opts: RuntimeOptions);
1865
+ get state(): ClientState;
1866
+ /** Run a saga, optionally on a lane. Rejections propagate to the caller. */
1867
+ run<R>(saga: Saga<R>, opts?: {
1868
+ lane?: Lane;
1869
+ signal?: AbortSignal;
1870
+ }): Promise<R>;
1871
+ /** Route an event to its saga. Never rejects: failures are logged. */
1872
+ dispatch(event: RuntimeEvent): Promise<void>;
1873
+ private setState;
1874
+ waitFor(until: (s: ClientState) => boolean, signal?: AbortSignal): Promise<void>;
1875
+ /** Deliver an outbound event to its subscribers and listeners. */
1876
+ emit(event: OutEvent): void;
1877
+ private notify;
1878
+ private safely;
1879
+ /** Observe outbound events by type (`'*'` for all). */
1880
+ on(type: OutEvent['type'] | '*', cb: (event: OutEvent) => void): () => void;
1881
+ subscribe(hash: QueryHash, cb: QueryUpdateCallback, options?: {
1882
+ immediate?: boolean;
1883
+ }): () => void;
1884
+ subscribeStatus(hash: QueryHash, cb: QueryStatusCallback, options?: {
1885
+ immediate?: boolean;
1886
+ }): () => void;
1887
+ subscribeAuthority(hash: QueryHash, cb: QueryAuthorityCallback, options?: {
1888
+ immediate?: boolean;
1889
+ }): () => void;
1890
+ /** Apply a reducer outside a saga (facade conveniences such as timing reports). */
1891
+ update(reducer: (s: ClientState) => ClientState): void;
1892
+ dispose(): void;
1893
+ }
1894
+ //#endregion
1895
+ //#region src/services/tabs/coordinator.d.ts
1896
+ interface CoordinatorHooks {
1897
+ /** Become the store owner: spawn the worker, open the pool under the given
1898
+ * per-leadership lock. Returns the resulting health for db-ready relays.
1899
+ * `resumeHeld` = keep the existing live worker (broker-restart path). */
1900
+ adoptOwner(bucketId: string, opts: {
1901
+ workerLockName: string;
1902
+ allowMemoryFallback: boolean;
1903
+ forceTakeover: boolean;
1904
+ resumeHeld: boolean;
1905
+ }): Promise<StorageHealth>;
1906
+ /** Attach to a leader's worker through `dbPort`. */
1907
+ adoptAttached(dbPort: MessagePort, snapshot: {
1908
+ bucketId: string;
1909
+ storageHealth: StorageHealth;
1910
+ leadershipId: number;
1911
+ }): Promise<void>;
1912
+ /** We were leader and got demoted (zombie thaw / stale promotion): tear the
1913
+ * owned worker down so its SAH handles free up. */
1914
+ releaseOwnership(): Promise<void>;
1915
+ /** The leader (or its port) is gone; queue/park ops until a new role lands. */
1916
+ onLeaderLost(reason: string): void;
1917
+ /** Leader side: forward a follower's dbPort into the owned worker. */
1918
+ exposeClientPort(clientId: string, port: MessagePort): Promise<void>;
1919
+ removeClientPort(clientId: string): Promise<void>;
1920
+ /** Sync layer role changes (implemented in the sync module). */
1921
+ /** Synchronous leader WIRING. Must not block: the role is published and
1922
+ * `leader-ready` is sent right after it returns. */
1923
+ becomeSyncLeader(hub: LeaderSyncHub): void;
1924
+ /** The blocking half of the leader handover (outbox drain, LIVE restart),
1925
+ * run in the background once the tab is already a working leader. */
1926
+ resumeSyncLeaderDuties(): Promise<void>;
1927
+ becomeSyncFollower(forwarder: SyncForwarder): void;
1928
+ becomeSyncSolo(): void;
1929
+ /** Current storage health, for db-ready sent to late-joining followers. */
1930
+ currentStorageHealth(): StorageHealth;
1931
+ }
1932
+ /** Leader-side fan-out surface handed to the sync layer. The sync router
1933
+ * (modules/sync/tab-router.ts) registers itself as the message handler. */
1934
+ declare class LeaderSyncHub {
1935
+ readonly leadershipId: number;
1936
+ private logger;
1937
+ private followers;
1938
+ private seq;
1939
+ onFollowerMessage: ((tabId: TabId, msg: FollowerToLeaderMessage) => void) | null;
1940
+ onFollowerDetached: ((tabId: TabId) => void) | null;
1941
+ constructor(leadershipId: number, logger: Logger$1);
1942
+ attach(tabId: TabId, port: MessagePort): void;
1943
+ detach(tabId: TabId): void;
1944
+ detachAll(): void;
1945
+ sendTo(tabId: TabId, msg: LeaderToFollowerMessage): void;
1946
+ broadcast(msg: LeaderToFollowerMessage, exceptTabId?: TabId): void;
1947
+ /** Stamped ingest relay; seq lets followers detect gaps. */
1948
+ relayIngest(tuples: IngestTuple[], exceptTabId?: TabId): void;
1949
+ get followerCount(): number;
1950
+ get relayedBatches(): number;
1951
+ }
1952
+ /** Follower half of the syncPort. Queues while detached (leaderless window)
1953
+ * and flushes on rebind; a lost-in-flight mutation notify is additionally
1954
+ * backstopped by the new leader reloading the shared outbox from the store. */
1955
+ declare class SyncForwarder {
1956
+ private tabId;
1957
+ private port;
1958
+ private queued;
1959
+ onLeaderMessage: ((msg: LeaderToFollowerMessage) => void) | null;
1960
+ constructor(tabId: TabId);
1961
+ rebind(port: MessagePort): void;
1962
+ unbind(): void;
1963
+ private post;
1964
+ mutationEnqueued(mutationId: string): void;
1965
+ /** An optimistic write this tab just ingested. Deliberately NOT queued while
1966
+ * detached: a new leader primes its circuit from the shared store, which
1967
+ * already holds the row, and replaying a stale tuple at it later would put
1968
+ * an older `_00_rv` in its version memo. */
1969
+ ingest(tuples: IngestTuple[]): void;
1970
+ requestPoll(): void;
1971
+ }
1972
+ declare class TabsCoordinator {
1973
+ private deps;
1974
+ role: TabRole$1;
1975
+ leadershipId: number;
1976
+ leaderTabId: TabId | null;
1977
+ brokerRole: 'pending' | TabRole$1;
1978
+ /** Wall time from `become-leader` to `leader-ready` on the last promotion.
1979
+ * Reported in the DevTools role-change event: a slow store adopt (OPFS pool
1980
+ * contention after the previous leader died) shows up here as seconds. */
1981
+ lastPromotionMs: number | null;
1982
+ private fingerprint;
1983
+ private bucketId;
1984
+ private broker;
1985
+ private hub;
1986
+ private forwarder;
1987
+ private roleListeners;
1988
+ private startResolve;
1989
+ private startReject;
1990
+ /** Ports received before start() resolves or between roles. */
1991
+ private promotionChain;
1992
+ private closed;
1993
+ constructor(deps: {
1994
+ tabId: TabId;
1995
+ fingerprint: string;
1996
+ hooks: CoordinatorHooks;
1997
+ logger: Logger$1;
1998
+ /** Fired on pagehide while this tab leads: last-chance OPFS release. */
1999
+ onLeaderPageHide?: () => void;
2000
+ });
2001
+ /** Serialize role transitions; each is small but async (worker opens). */
2002
+ private enqueue;
2003
+ onRoleChange(cb: (role: TabRole$1) => void): () => void;
2004
+ private setRole;
2005
+ /** Connect the broker and resolve once this tab has a usable store role.
2006
+ * Rejects when no role lands in time; the caller then boots solo. */
2007
+ start(bucketId: string): Promise<TabRole$1>;
2008
+ /** Bucket switch: leave the old namespace, join the new one. Resolves when
2009
+ * a role lands in the new namespace. */
2010
+ moveToBucket(bucketId: string): Promise<TabRole$1>;
2011
+ private promote;
2012
+ private demote;
2013
+ private tabLock;
2014
+ private teardownLeader;
2015
+ private teardownFollower;
2016
+ private serveFollower;
2017
+ private attachToLeader;
2018
+ private handleLeaderGone;
2019
+ private fallbackToSolo;
2020
+ get syncHub(): LeaderSyncHub | null;
2021
+ get syncForwarder(): SyncForwarder | null;
2022
+ get tabId(): TabId;
2023
+ stop(): Promise<void>;
2024
+ }
2025
+ //#endregion
2026
+ //#region src/services/blobs/blob-store.d.ts
2027
+ /**
2028
+ * Byte storage for cached bucket files.
2029
+ *
2030
+ * The default implementation is OPFS. Bucket files never arrive over HTTP in
2031
+ * this client — `BucketHandle.get()` is a SurrealQL RPC on the sync socket — so
2032
+ * neither the browser's HTTP cache nor the Cache API can hold them. We persist
2033
+ * the bytes ourselves, and OPFS is the cheapest place to put them: a read is
2034
+ * `getFile()` → a disk-backed lazy `File` that `URL.createObjectURL` can serve
2035
+ * without ever moving the bytes through the JS heap.
2036
+ *
2037
+ * Layout is real nested directories rather than one hashed filename:
2038
+ *
2039
+ * sp00ky-blobs/<namespace>/<bucket>/<...path segments>
2040
+ *
2041
+ * That costs a `getDirectoryHandle` per segment on write, and buys the property
2042
+ * the whole orphan story rests on: the full `(bucket, path)` key is recoverable
2043
+ * from a directory walk alone. The `_00_blob` manifest can therefore be wiped
2044
+ * (memory fallback, SQLite pool wipe, IndexedDB corruption recovery) and be
2045
+ * rebuilt from disk instead of taking the cached bytes down with it.
2046
+ */
2047
+ /** Identifies one cached file: the bucket it lives in and its path within. */
2048
+ interface BlobKey {
2049
+ bucket: string;
2050
+ path: string;
2051
+ }
2052
+ /** What a directory walk can tell us about a stored file, with no manifest. */
2053
+ interface BlobStat {
2054
+ key: BlobKey;
2055
+ size: number;
2056
+ /** File mtime. Seeds `lastAccess` when a manifest row has to be rebuilt. */
2057
+ mtime: number;
2058
+ }
2059
+ interface BlobStore {
2060
+ /** False for {@link MemoryBlobStore} and for OPFS-less environments: the
2061
+ * cache still dedupes and serves within a tab, but nothing survives reload. */
2062
+ readonly persistent: boolean;
2063
+ /** Namespace (the local bucketId) all keys are resolved under. */
2064
+ readonly namespace: string;
2065
+ read(key: BlobKey): Promise<Blob | null>;
2066
+ /** Returns the number of bytes written. Throws on quota exhaustion. */
2067
+ write(key: BlobKey, bytes: Blob): Promise<number>;
2068
+ remove(key: BlobKey): Promise<void>;
2069
+ /** Every committed file under the current namespace. Sweeps torn writes. */
2070
+ list(): Promise<BlobStat[]>;
2071
+ /** Drop the whole namespace (sign-out with `clearOnSignOut`, or a reset). */
2072
+ clear(): Promise<void>;
2073
+ /** Point at another namespace. Does not touch the bytes of the old one. */
2074
+ setNamespace(namespace: string): void;
2075
+ }
2076
+ //#endregion
2077
+ //#region src/services/blobs/blob-manifest.d.ts
2078
+ interface BlobEntry {
2079
+ /** `${bucket}/${path}` — also the `_00_blob` row id. */
2080
+ id: string;
2081
+ bucket: string;
2082
+ path: string;
2083
+ size: number;
2084
+ contentType: string;
2085
+ createdAt: number;
2086
+ lastAccess: number;
2087
+ hits: number;
2088
+ /** Exempt from pressure eviction. Never expires on its own. */
2089
+ pinned: boolean;
2090
+ }
2091
+ declare class BlobManifest {
2092
+ private local;
2093
+ private entries;
2094
+ /** Ids whose in-memory state has not been written back yet. */
2095
+ private dirty;
2096
+ private removed;
2097
+ private flushing;
2098
+ constructor(local: LocalStore);
2099
+ /**
2100
+ * Hydrate from the rows matching `keys`. Ids come from the OPFS listing, so
2101
+ * this never needs a full-table scan (and therefore never needs a QueryPlan).
2102
+ * Any read failure yields an empty manifest: reconcile then rebuilds every
2103
+ * row from disk, which is exactly the desired degradation.
2104
+ */
2105
+ load(ids: string[]): Promise<void>;
2106
+ get(key: BlobKey): BlobEntry | undefined;
2107
+ getById(id: string): BlobEntry | undefined;
2108
+ all(): BlobEntry[];
2109
+ totalBytes(): number;
2110
+ pinnedBytes(): number;
2111
+ put(entry: BlobEntry): void;
2112
+ touch(id: string, now: number): void;
2113
+ setPinned(id: string, pinned: boolean): boolean;
2114
+ remove(id: string): void;
2115
+ /** Forget everything without scheduling deletes — for a bucket switch, where
2116
+ * the rows belong to the store we are leaving and must stay put. */
2117
+ reset(): void;
2118
+ hasPendingWrites(): boolean;
2119
+ /**
2120
+ * Write back pending changes. Serialized: a second concurrent flush awaits
2121
+ * the first rather than racing it into the same rows. Failures are swallowed
2122
+ * on purpose — a lost metadata write costs an LRU timestamp, and the entry is
2123
+ * rebuilt from disk on the next reconcile.
2124
+ */
2125
+ flush(): Promise<void>;
2126
+ private doFlush;
2127
+ }
2128
+ //#endregion
2129
+ //#region src/services/blobs/blob-cache.d.ts
2130
+ interface BlobUrlLease {
2131
+ url: string;
2132
+ release(): void;
2133
+ }
2134
+ interface BlobReadOptions {
2135
+ /** Write through to L1 on a miss. Default true. */
2136
+ persist?: boolean;
2137
+ /** Mark the entry exempt from pressure eviction. */
2138
+ pin?: boolean;
2139
+ /**
2140
+ * Default `'never'`: a bucket path is treated as immutable, which is how the
2141
+ * client writes them (`crypto.randomUUID() + ext`). `'head'` spends a remote
2142
+ * `head()` to compare sizes before trusting L1.
2143
+ */
2144
+ revalidate?: 'never' | 'head';
2145
+ /** Skip L0/L1 entirely and refill from remote. Backs `refetch()`. */
2146
+ reload?: boolean;
2147
+ }
2148
+ interface BlobCacheStats {
2149
+ entries: number;
2150
+ totalBytes: number;
2151
+ budgetBytes: number;
2152
+ pinnedBytes: number;
2153
+ evictedEntries: number;
2154
+ evictedBytes: number;
2155
+ reconciledEntries: number;
2156
+ hits: number;
2157
+ misses: number;
2158
+ persistent: boolean;
2159
+ /** True when pinned bytes alone exceed the budget: new entries stop being
2160
+ * written rather than pinned ones being thrown away. */
2161
+ persistPaused: boolean;
2162
+ }
2163
+ interface BlobCacheOptions {
2164
+ store: BlobStore;
2165
+ manifest: BlobManifest;
2166
+ /** L2 read. Resolves to null when the file does not exist remotely. */
2167
+ fetchRemote(key: BlobKey): Promise<Blob | null>;
2168
+ /** L2 metadata, for `revalidate: 'head'`. */
2169
+ headRemote?(key: BlobKey): Promise<Record<string, unknown> | null>;
2170
+ logger: Logger$1;
2171
+ maxBytes: number;
2172
+ now?: () => number;
2173
+ /** Injected so the URL layer is exercisable off a DOM (node tests). */
2174
+ urls?: {
2175
+ create(blob: Blob): string;
2176
+ revoke(url: string): void;
2177
+ };
2178
+ }
2179
+ declare class BlobCache {
2180
+ private readonly store;
2181
+ private readonly manifest;
2182
+ private readonly fetchRemote;
2183
+ private readonly headRemote?;
2184
+ private readonly logger;
2185
+ private readonly now;
2186
+ private readonly urlFactory;
2187
+ private maxBytes;
2188
+ private persistPaused;
2189
+ /** Set after a quota failure survives one forced eviction. */
2190
+ private persistDisabled;
2191
+ private readonly urls;
2192
+ /** Ids at zero references, oldest first — the hot-URL window. */
2193
+ private idleUrls;
2194
+ private readonly inflight;
2195
+ private flushTimer;
2196
+ private readonly onPageHide;
2197
+ /**
2198
+ * Resolves once the manifest has been reconciled against disk. Reads await
2199
+ * it, so `start()` does NOT have to be awaited on the boot path — blocking
2200
+ * boot on an OPFS directory walk delayed the WebSocket connect (and with it
2201
+ * the connection supervisor) for no benefit.
2202
+ */
2203
+ private ready;
2204
+ private hits;
2205
+ private misses;
2206
+ private evictedEntries;
2207
+ private evictedBytes;
2208
+ private reconciledEntries;
2209
+ constructor(opts: BlobCacheOptions);
2210
+ /** Coalesce manifest write-back. Metadata only, so losing the tail costs an
2211
+ * LRU timestamp that reconcile reseeds from the file mtime. */
2212
+ private scheduleFlush;
2213
+ /**
2214
+ * Resolve the bytes for `key`, filling L1 on the way when `persist` is on.
2215
+ * Returns null when the file does not exist remotely and is not cached.
2216
+ */
2217
+ read(key: BlobKey, options?: BlobReadOptions): Promise<Blob | null>;
2218
+ /**
2219
+ * An object URL for `key`, refcounted. Callers MUST `release()`; the URL is
2220
+ * revoked once the last holder lets go and it falls out of the hot window.
2221
+ */
2222
+ acquireUrl(key: BlobKey, options?: BlobReadOptions): Promise<BlobUrlLease | null>;
2223
+ private lease;
2224
+ private releaseUrl;
2225
+ private revokeUrl;
2226
+ /** L1 lookup with the size check that catches torn and cross-tab writes. */
2227
+ private readLocal;
2228
+ /** True when the remote agrees with the cached size, or cannot be reached. */
2229
+ private headMatches;
2230
+ private fetchDeduped;
2231
+ private persist;
2232
+ private writeThrough;
2233
+ /** Forget one path everywhere. Called on `bucket.put()`/`bucket.delete()`. */
2234
+ invalidate(key: BlobKey): Promise<void>;
2235
+ private dropLocal;
2236
+ setPinned(key: BlobKey, pinned: boolean): void;
2237
+ /**
2238
+ * Bring total bytes under budget by dropping the least recently used
2239
+ * entries. Pinned entries and anything with a live object URL are skipped —
2240
+ * evicting bytes that a mounted `<img>` is displaying would blank it.
2241
+ */
2242
+ private enforceBudget;
2243
+ /** Evict LRU-first until at or below `target`. Returns the resulting total. */
2244
+ private evictTo;
2245
+ /**
2246
+ * Rebuild the manifest from what is actually on disk. OPFS wins on existence
2247
+ * in both directions: files with no row get a row (seeded from mtime), rows
2248
+ * are only loaded for files that exist, and torn `.part-` writes are swept by
2249
+ * the walk itself.
2250
+ *
2251
+ * Rows whose file vanished outside our control (a browser origin eviction)
2252
+ * are left in `_00_blob`. They are inert — `load()` only ever asks for ids it
2253
+ * found on disk — and are overwritten if that path is cached again.
2254
+ */
2255
+ reconcile(): Promise<void>;
2256
+ /** Warm the cache for offline use. Skips anything already cached. */
2257
+ prefetch(keys: BlobKey[]): Promise<void>;
2258
+ /** Bind to the boot bucket and hydrate the manifest from disk. Separate from
2259
+ * {@link setNamespace} because boot must reconcile even when the namespace
2260
+ * it lands on is the one the store was constructed with. */
2261
+ start(namespace: string): Promise<void>;
2262
+ /** Repoint at another local bucket. The bytes of the old one stay on disk so
2263
+ * switching back (or signing back in) is still warm. */
2264
+ setNamespace(namespace: string): Promise<void>;
2265
+ setMaxBytes(maxBytes: number): void;
2266
+ /** Delete every cached byte in the current namespace. */
2267
+ clear(): Promise<void>;
2268
+ flush(): Promise<void>;
2269
+ /** Flush metadata and drop every object URL. Must run before the local store
2270
+ * closes — the flush writes through it. */
2271
+ close(): Promise<void>;
2272
+ stats(): BlobCacheStats;
2273
+ }
2274
+ //#endregion
2275
+ //#region src/client/services.d.ts
2276
+ /** Everything the facade talks to besides the runtime. */
2277
+ interface Services<S extends SchemaStructure> {
2278
+ logger: Logger$1;
2279
+ local: LocalStore;
2280
+ remote: RemoteDatabaseService;
2281
+ connectionSupervisor: ConnectionSupervisor;
2282
+ blobs: BlobCache;
2283
+ persistence: PersistenceClient;
2284
+ streamProcessor: StreamProcessorService;
2285
+ migrator: LocalMigrator;
2286
+ crdt: CrdtManager;
2287
+ auth: AuthService<S>;
2288
+ tabs: TabsCoordinator | null;
2289
+ tabsUnsupportedReason: string | undefined;
2290
+ tabId: string;
2291
+ }
2292
+ /** What the services need back from the runtime once it exists. */
2293
+ //#endregion
2294
+ //#region src/utils/blurhash.d.ts
2295
+ /**
2296
+ * Blurhash generation settings. `true` enables with the defaults below, `false`
2297
+ * disables. Resolution order for a put: per-call option > client config >
2298
+ * default ON. See {@link Sp00kyConfig.blurhash}.
2299
+ */
2300
+ type BlurhashSetting = boolean | BlurhashEncodeOptions;
2301
+ interface BlurhashEncodeOptions {
2302
+ /** Horizontal detail components, 1-9. Defaults to 4. */
2303
+ componentX?: number;
2304
+ /** Vertical detail components, 1-9. Defaults to 3. */
2305
+ componentY?: number;
2306
+ }
2307
+ /**
2308
+ * Where an image's blurhash lives: a tiny sidecar object in the same bucket.
2309
+ * Buckets have no per-object metadata channel (`put` is just `.put($content)`),
2310
+ * so the hash for `covers/x_t.webp` is the text object `covers/x_t.webp.bh`.
2311
+ */
2312
+ declare function blurhashSidecarPath(path: string): string;
2313
+ /** Extensions `bucket.put` treats as images worth hashing. */
2314
+ declare const BLURHASH_IMAGE_EXTENSIONS: readonly ["webp", "png", "jpg", "jpeg", "gif", "avif", "bmp"];
2315
+ declare function isImagePath(path: string): boolean;
2316
+ /**
2317
+ * Decode `content` as an image and compute its blurhash. Browser-only: returns
2318
+ * null (never throws) when image decoding is unavailable (node, workers without
2319
+ * canvas), when the bytes are not a decodable image, or on any other failure —
2320
+ * a missing hash must never break the upload that triggered it.
2321
+ */
2322
+ declare function encodeImageToBlurhash(content: string | Uint8Array | Blob, options?: BlurhashEncodeOptions): Promise<string | null>;
2323
+ //#endregion
2324
+ //#region src/client/bucket-handle.d.ts
2325
+ /** Coerce whatever the `.get()` RPC hands back into a Blob. */
2326
+ declare function bucketContentToBlob(content: unknown): Blob | null;
2327
+ interface BucketPutOptions {
2328
+ /** Override the client-level {@link Sp00kyConfig.blurhash} setting for this put. */
2329
+ blurhash?: BlurhashSetting;
2330
+ }
2331
+ interface BucketPutResult {
2332
+ /** The computed blurhash when the content was a hashable image; else null. */
2333
+ blurhash: string | null;
2334
+ }
2335
+ interface BucketHandleSettings {
2336
+ blurhash?: BlurhashSetting;
2337
+ logger?: {
2338
+ warn: (obj: unknown, msg?: string) => void;
2339
+ };
2340
+ }
198
2341
  declare class BucketHandle {
199
2342
  private bucketName;
200
2343
  private remote;
201
- constructor(bucketName: string, remote: RemoteDatabaseService);
202
- put(path: string, content: string | Uint8Array | Blob): Promise<void>;
2344
+ /** Absent on the raw handle the cache itself reads through. */
2345
+ private blobs?;
2346
+ private settings?;
2347
+ constructor(bucketName: string, remote: RemoteDatabaseService, /** Absent on the raw handle the cache itself reads through. */
2348
+ blobs?: (BlobCache | null) | undefined, settings?: BucketHandleSettings | undefined);
2349
+ /** Effective blurhash setting: per-call option > client config > default ON. */
2350
+ private resolveBlurhash;
2351
+ put(path: string, content: string | Uint8Array | Blob, options?: BucketPutOptions): Promise<BucketPutResult>;
2352
+ /**
2353
+ * The blurhash stored alongside an uploaded image (see
2354
+ * {@link blurhashSidecarPath}), or null when there is none. Reads through the
2355
+ * blob cache, so a warm client answers from OPFS without a network hop, and
2356
+ * misses are remembered per tab so a hashless image costs at most one
2357
+ * serialized remote read per session.
2358
+ */
2359
+ blurhash(path: string): Promise<string | null>;
203
2360
  get(path: string): Promise<unknown>;
2361
+ /**
2362
+ * Read through the local blob cache: OPFS first, the bucket second. Unlike
2363
+ * {@link get} this survives a reload and works offline. Returns null when the
2364
+ * file exists in neither place.
2365
+ */
2366
+ read(path: string, options?: BlobReadOptions): Promise<Blob | null>;
2367
+ /**
2368
+ * A refcounted object URL for `path`, suitable for `<img src>`. The caller
2369
+ * MUST call `release()` when the URL goes off screen. Returns null when the
2370
+ * file does not exist, or when object URLs are unavailable (non-browser).
2371
+ */
2372
+ url(path: string, options?: BlobReadOptions): Promise<BlobUrlLease | null>;
2373
+ /** Exempt `path` from pressure eviction. Pinned bytes never expire. */
2374
+ pin(path: string): void;
2375
+ unpin(path: string): void;
2376
+ /** Drop `path` from the local cache without touching the remote file. */
2377
+ evict(path: string): Promise<void>;
2378
+ /** Warm the cache for offline use. Already-cached paths are skipped. */
2379
+ prefetch(paths: string[]): Promise<void>;
204
2380
  delete(path: string): Promise<void>;
205
2381
  exists(path: string): Promise<boolean>;
206
2382
  head(path: string): Promise<Record<string, unknown>>;
@@ -208,55 +2384,153 @@ declare class BucketHandle {
208
2384
  rename(sourcePath: string, targetPath: string): Promise<void>;
209
2385
  list(prefix?: string): Promise<string[]>;
210
2386
  }
211
- declare class SpookyClient<S extends SchemaStructure> {
212
- private config;
213
- private local;
214
- private remote;
215
- private persistenceClient;
216
- private migrator;
217
- private cache;
218
- private dataModule;
219
- private sync;
220
- private devTools;
221
- private logger;
222
- auth: AuthService<S>;
223
- streamProcessor: StreamProcessorService;
224
- get remoteClient(): Surreal;
225
- get localClient(): Surreal;
226
- get pendingMutationCount(): number;
227
- subscribeToPendingMutations(cb: (count: number) => void): () => void;
228
- constructor(config: SpookyConfig<S>);
229
- /**
230
- * Setup direct callbacks instead of event subscriptions
231
- */
232
- private setupCallbacks;
2387
+ //#endregion
2388
+ //#region src/client/sp00ky-client.d.ts
2389
+ interface Sp00kyClientDeps<S extends SchemaStructure> {
2390
+ services?: Services<S>;
2391
+ runtime?: Runtime;
2392
+ env?: Partial<SagaEnv>;
2393
+ }
2394
+ /**
2395
+ * The public client. Every method is a thin translation into a saga run, a
2396
+ * state read, or a subscription on the runtime; the engine's behaviour lives
2397
+ * in the sagas.
2398
+ */
2399
+ declare class Sp00kyClient<S extends SchemaStructure> {
2400
+ private readonly config;
2401
+ readonly auth: AuthService<S>;
2402
+ readonly streamProcessor: StreamProcessorService;
2403
+ private readonly services;
2404
+ private readonly env;
2405
+ private readonly runtime;
2406
+ private readonly featureFlags;
2407
+ private readonly appReleases;
2408
+ /** Teardown for subscriptions the client itself owns. */
2409
+ private readonly disposers;
2410
+ private readonly devTools;
2411
+ private hub;
2412
+ private forwarder;
2413
+ private detachWindow;
2414
+ private sharedActive;
2415
+ constructor(config: Sp00kyConfig<S>, deps?: Sp00kyClientDeps<S>);
2416
+ /** Adapter callbacks become runtime events; runtime events reach the legacy services. */
2417
+ private wireAdapters;
2418
+ private forwardToDevTools;
233
2419
  init(): Promise<void>;
234
2420
  close(): Promise<void>;
235
- authenticate(token: string): Promise<surrealdb0.Tokens>;
236
- deauthenticate(): Promise<void>;
237
- query<Table extends TableNames<S>>(table: Table, options: QueryOptions<TableModel<GetTable<S, Table>>, false>, ttl?: QueryTimeToLive): QueryBuilder<S, Table, SpookyQueryResultPromise>;
2421
+ isLocalReady(): boolean;
2422
+ query<Table extends TableNames<S>>(table: Table, options: QueryOptions<TableModel<GetTable<S, Table>>, false>, ttl?: QueryTimeToLive): QueryBuilder<S, Table, Sp00kyQueryResultPromise>;
2423
+ private registerInput;
238
2424
  private initQuery;
2425
+ /**
2426
+ * The raw remote escape hatches bypass the query builder, so their shapes
2427
+ * can never be on the server's query allowlist. When the generated schema
2428
+ * says the allowlist is on, refuse them unless the app opted in explicitly.
2429
+ */
2430
+ private assertRawRemoteAllowed;
239
2431
  queryRaw(sql: string, params: Record<string, any>, ttl: QueryTimeToLive): Promise<string>;
2432
+ /**
2433
+ * Preload = register a query nobody subscribes to. Resolved before on this
2434
+ * device: returns at once. Never resolved: resolves when the server's
2435
+ * membership and every body are local. `signal` aborts the wait.
2436
+ */
2437
+ preload(finalQuery: FinalQuery<S, any, any, any, any, any>, options?: PreloadOptions): Promise<void>;
240
2438
  subscribe(queryHash: string, callback: (records: Record<string, any>[]) => void, options?: {
241
2439
  immediate?: boolean;
242
2440
  }): Promise<() => void>;
2441
+ deregisterQuery(queryHash: string): void;
2442
+ subscribeQueryStatus(queryHash: string, callback: QueryStatusCallback, options?: {
2443
+ immediate?: boolean;
2444
+ }): () => void;
2445
+ subscribeQueryAuthority(queryHash: string, callback: QueryAuthorityCallback, options?: {
2446
+ immediate?: boolean;
2447
+ }): () => void;
2448
+ reportFrontendTiming(queryHash: string, ms: number): void;
2449
+ create<T extends Record<string, unknown>>(id: string, data: T): Promise<T>;
2450
+ update<T extends Record<string, unknown>>(_table: string, id: string, data: Partial<T>, options?: UpdateOptions): Promise<T>;
2451
+ delete(_table: string, id: string): Promise<void>;
243
2452
  run<B extends BackendNames<S>, R extends BackendRoutes<S, B>>(backend: B, path: R, payload: RoutePayload<S, B, R>, options?: RunOptions): Promise<void>;
244
- bucket<B extends BucketNames<S>>(name: B): BucketHandle;
245
- create(id: string, data: Record<string, unknown>): Promise<Record<string, unknown>>;
246
- update(table: string, id: string, data: Record<string, unknown>, options?: UpdateOptions): Promise<{
247
- [x: string]: /*elided*/any;
248
- }>;
249
- delete(table: string, id: string): Promise<void>;
2453
+ get failedMutationCount(): number;
2454
+ subscribeToFailedMutations(cb: (count: number) => void): () => void;
2455
+ listFailedMutations(): Promise<FailedMutationRow[]>;
2456
+ retryFailedMutation(mutationId: string): Promise<boolean>;
2457
+ discardFailedMutation(mutationId: string): Promise<boolean>;
2458
+ get tabRole(): TabRole$1 | null;
2459
+ get remoteClient(): Surreal;
2460
+ get localClient(): unknown;
2461
+ get pendingMutationCount(): number;
2462
+ /** Kept for the e2e suite; the saga core has no LIVE retry ladder. */
2463
+ get liveRetryCount(): number;
2464
+ subscribeToPendingMutations(cb: (count: number) => void): () => void;
2465
+ get fetchingQueryCount(): number;
2466
+ subscribeToFetchActivity(cb: (fetching: number) => void): () => void;
2467
+ get syncHealth(): SyncHealth;
2468
+ subscribeToSyncHealth(cb: (health: SyncHealth) => void): () => void;
2469
+ get storageHealth(): StorageHealth;
2470
+ subscribeToStorageHealth(cb: (health: StorageHealth) => void): () => void;
2471
+ /** The engine state, read-only. For tests and DevTools. */
2472
+ get state(): ClientState;
2473
+ /** Feed an event into the engine. For adapters, tests and DevTools. */
2474
+ dispatch(event: RuntimeEvent): Promise<void>;
2475
+ authenticate(token: string): Promise<surrealdb0.Tokens>;
2476
+ deauthenticate(): Promise<void>;
250
2477
  useRemote<T>(fn: (client: Surreal) => Promise<T> | T): Promise<T>;
251
- private persistClientId;
252
- private loadOrGenerateClientId;
2478
+ remoteQuery<T extends unknown[]>(sql: string, vars?: Record<string, unknown>): Promise<T>;
2479
+ feature(key: string, options?: FeatureFlagOptions): FeatureFlagHandle;
2480
+ setFeatureOverride(key: string, variant: string | null, payload?: unknown): void;
2481
+ clearFeatureOverrides(): void;
2482
+ getFeatureOverrides(): Record<string, FeatureFlagOverride>;
2483
+ appRelease(app: string, options?: AppReleaseOptions): AppReleaseHandle;
2484
+ openCrdtField(table: string, recordId: string, field: string, fallbackText?: string): Promise<CrdtField>;
2485
+ closeCrdtField(table: string, recordId: string, field: string): void;
2486
+ bucket<B extends BucketNames<S>>(name: B): BucketHandle;
2487
+ getBlobCacheStats(): BlobCacheStats;
2488
+ }
2489
+ //#endregion
2490
+ //#region src/utils/semver.d.ts
2491
+ /** True when `a` is a valid version strictly greater than valid version `b`. */
2492
+ declare function semverGt(a: unknown, b: unknown): boolean;
2493
+ //#endregion
2494
+ //#region src/services/database/errors.d.ts
2495
+ /**
2496
+ * A local-store operation that did not answer within its deadline.
2497
+ *
2498
+ * The local write path (`db.create` / `db.update` / `db.delete`, every local
2499
+ * query behind them) used to have no deadline anywhere: the SQLite worker
2500
+ * transport parks a call until the worker replies, the surrealdb engine's
2501
+ * query chain waits on the previous link, and `withRetry` retries without a
2502
+ * clock. One op that never settled (a worker starved behind a long select, a
2503
+ * lock verification awaiting `navigator.locks.query()` forever) left the
2504
+ * caller's promise pending for the tab's lifetime - a chat composer that never
2505
+ * re-enabled, a call that never got past "Connecting".
2506
+ *
2507
+ * The message says "timed out" on purpose: `classifySyncError` keys off it and
2508
+ * treats the failure as transient (re-queue), never as an application error
2509
+ * that rolls the mutation back. `retryable: false` keeps `withRetry` from
2510
+ * spinning on it: the op is still running in the engine, retrying queues a
2511
+ * second copy behind it.
2512
+ */
2513
+ declare class LocalOpTimeoutError extends Error {
2514
+ readonly name = "LocalOpTimeoutError";
2515
+ readonly retryable = false;
2516
+ readonly op: string;
2517
+ readonly timeoutMs: number;
2518
+ constructor(op: string, timeoutMs: number);
253
2519
  }
2520
+ /** Default deadline for one local-store operation. Generous: a cold 4k-row
2521
+ * select on a throttled tab is seconds, not tens of seconds. */
2522
+ declare const DEFAULT_LOCAL_OP_TIMEOUT_MS = 30000;
254
2523
  //#endregion
255
2524
  //#region src/utils/index.d.ts
256
2525
  declare function fileToUint8Array(file: File | Blob): Promise<Uint8Array>;
2526
+ /**
2527
+ * Convert plain text to simple HTML paragraphs.
2528
+ * Useful for seeding a rich-text editor (e.g. TipTap/ProseMirror) with fallback content.
2529
+ */
2530
+ declare function textToHtml(text: string): string;
257
2531
  /**
258
2532
  * Helper for retrying DB operations with exponential backoff
259
2533
  */
260
2534
 
261
2535
  //#endregion
262
- export { AuthEventSystem, AuthEventTypeMap, AuthEventTypes, AuthService, BucketHandle, DebounceOptions, EventSubscriptionOptions, Level, MutationCallback, MutationEvent, MutationEventType, PersistenceClient, PinoTransmit, QueryConfig, QueryConfigRecord, QueryHash, QueryState, QueryTimeToLive, QueryUpdateCallback, RecordVersionArray, RecordVersionDiff, RunOptions, SpookyClient, SpookyConfig, SpookyQueryResult, SpookyQueryResultPromise, StoreType, UpdateOptions, createAuthEventSystem, fileToUint8Array };
2536
+ export { ActiveImpersonation, AppReleaseHandle, AppReleaseModule, type AppReleaseOptions, type AppReleaseSnapshot, AuthEventSystem, AuthEventTypeMap, AuthEventTypes, AuthService, BLURHASH_IMAGE_EXTENSIONS, type BlobCacheStats, type BlobEntry, type BlobKey, type BlobReadOptions, type BlobUrlLease, type BlurhashEncodeOptions, type BlurhashSetting, BucketHandle, BucketPutOptions, BucketPutResult, CURSOR_COLORS, ConnectionState, CrdtField, CrdtManager, DEFAULT_LOCAL_OP_TIMEOUT_MS, DebounceOptions, EventSubscriptionOptions, type FailedMutationRow as FailedMutation, FeatureFlagHandle, FeatureFlagModule, type FeatureFlagOptions, type FeatureFlagOverride, type FeatureFlagSnapshot, IMPERSONATION_ACCESS, ImpersonationCandidate, ImpersonationInfo, ImpersonationTimerHost, ImpersonationTimers, InlineRow, Level, LocalOpTimeoutError, MATERIALIZATION_SAMPLE_WINDOW, MembershipOutcome, MutationCallback, MutationEvent, MutationEventType, ORIGIN_TOKEN_KEY, PersistenceClient, PhaseStat, PinoTransmit, PreloadOptions, PreloadRefresh, QueryAuthorityCallback, QueryConfig, QueryConfigRecord, QueryHash, QueryState, QueryStatus, QueryStatusCallback, QueryTimeToLive, QueryTimings, QueryUpdateCallback, ReconnectConfig, RecordVersionArray, RecordVersionDiff, RegistrationTimings, RunOptions, SESSION_POLL_MS, ServerViewMeta, Sp00kyClient, Sp00kyClientDeps, Sp00kyConfig, Sp00kyQueryResult, Sp00kyQueryResultPromise, StorageHealth, StorageHealthStatus, StoreType, SyncHealth, SyncHealthConfig, SyncHealthStatus, TimingPhase, TokenClaims, UpdateOptions, blurhashSidecarPath, bucketContentToBlob, createAuthEventSystem, cursorColorFromName, decode as decodeBlurhash, decodeTokenClaims, encode as encodeBlurhash, encodeImageToBlurhash, fileToUint8Array, impersonationFromToken, isBlurhashValid, isImagePath, renewDelayMs, sameImpersonation, semverGt, textToHtml };