@spooky-sync/core 0.0.1-canary.33 → 0.0.1-canary.331

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (266) hide show
  1. package/AGENTS.md +84 -0
  2. package/README.md +56 -16
  3. package/dist/index.d.ts +2080 -142
  4. package/dist/index.js +16009 -2480
  5. package/dist/live.d.ts +149 -0
  6. package/dist/live.js +739 -0
  7. package/dist/otel/index.d.ts +1 -1
  8. package/dist/otel/index.js +3 -3
  9. package/dist/pure.d.ts +373 -0
  10. package/dist/pure.js +24 -0
  11. package/dist/sqlite-open.js +392 -0
  12. package/dist/sqlite-worker.d.ts +1 -0
  13. package/dist/sqlite-worker.js +439 -0
  14. package/dist/sw.d.ts +207 -0
  15. package/dist/sw.js +393 -0
  16. package/dist/tabs-broker-worker.d.ts +8 -0
  17. package/dist/tabs-broker-worker.js +477 -0
  18. package/dist/types.d.ts +1857 -38
  19. package/dist/worker-edges.js +480 -0
  20. package/dist/worker-impersonation.js +60 -0
  21. package/dist/worker-ref-tables.d.ts +48 -0
  22. package/dist/worker-types.d.ts +112 -0
  23. package/dist/worker-types.js +73 -0
  24. package/package.json +28 -7
  25. package/scripts/check-broker-bundle.mjs +33 -0
  26. package/scripts/check-worker-entries.mjs +173 -0
  27. package/skills/sp00ky-core/SKILL.md +32 -7
  28. package/skills/sp00ky-core/references/config.md +7 -1
  29. package/src/boot/auth-flip.saga.test.ts +135 -0
  30. package/src/boot/auth-flip.saga.ts +77 -0
  31. package/src/boot/boot.saga.test.ts +203 -0
  32. package/src/boot/boot.saga.ts +146 -0
  33. package/src/boot/bucket-switch.saga.test.ts +187 -0
  34. package/src/boot/bucket-switch.saga.ts +133 -0
  35. package/src/boot/preload.saga.test.ts +80 -0
  36. package/src/boot/preload.saga.ts +40 -0
  37. package/src/bucket-blurhash.test.ts +148 -0
  38. package/src/build-globals.d.ts +12 -0
  39. package/src/client/bucket-handle.test.ts +195 -0
  40. package/src/client/bucket-handle.ts +352 -0
  41. package/src/client/router.test.ts +58 -0
  42. package/src/client/router.ts +100 -0
  43. package/src/client/runtime.test.ts +195 -0
  44. package/src/client/runtime.ts +296 -0
  45. package/src/client/services.test.ts +74 -0
  46. package/src/client/services.ts +434 -0
  47. package/src/client/sp00ky-client.test.ts +341 -0
  48. package/src/client/sp00ky-client.ts +625 -0
  49. package/src/events/events.test.ts +2 -1
  50. package/src/events/index.ts +3 -0
  51. package/src/index.ts +69 -1
  52. package/src/kernel/constants.test.ts +19 -0
  53. package/src/kernel/constants.ts +80 -0
  54. package/src/kernel/effects.test.ts +40 -0
  55. package/src/kernel/effects.ts +167 -0
  56. package/src/kernel/events.ts +57 -0
  57. package/src/kernel/interpreter.test.ts +116 -0
  58. package/src/kernel/interpreter.ts +148 -0
  59. package/src/kernel/saga.test.ts +89 -0
  60. package/src/kernel/saga.ts +73 -0
  61. package/src/live/index.test.ts +512 -0
  62. package/src/live/index.ts +1059 -0
  63. package/src/modules/app-release/index.test.ts +125 -0
  64. package/src/modules/app-release/index.ts +209 -0
  65. package/src/modules/auth/auth.local-first.test.ts +228 -0
  66. package/src/modules/auth/events/index.ts +9 -2
  67. package/src/modules/auth/impersonation-banner.ts +443 -0
  68. package/src/modules/auth/impersonation.integration.test.ts +205 -0
  69. package/src/modules/auth/impersonation.test.ts +377 -0
  70. package/src/modules/auth/impersonation.ts +179 -0
  71. package/src/modules/auth/index.ts +476 -22
  72. package/src/modules/crdt/acceptance.ts +75 -0
  73. package/src/modules/crdt/bytes.ts +50 -0
  74. package/src/modules/crdt/crdt-field.ts +125 -0
  75. package/src/modules/crdt/crdt.test.ts +469 -0
  76. package/src/modules/crdt/hash.ts +103 -0
  77. package/src/modules/crdt/index.ts +757 -0
  78. package/src/modules/crdt/loro-loader.ts +25 -0
  79. package/src/modules/crdt/mirror.ts +37 -0
  80. package/src/modules/devtools/flags.ts +349 -0
  81. package/src/modules/devtools/index.ts +746 -95
  82. package/src/modules/devtools/mutations.test.ts +92 -0
  83. package/src/modules/devtools/mutations.ts +160 -0
  84. package/src/modules/devtools/notify-throttle.test.ts +166 -0
  85. package/src/modules/devtools/state-shape.test.ts +263 -0
  86. package/src/modules/devtools/storage-info.test.ts +79 -0
  87. package/src/modules/devtools/storage-info.ts +168 -0
  88. package/src/modules/devtools/versions.test.ts +74 -0
  89. package/src/modules/devtools/versions.ts +110 -0
  90. package/src/modules/feature-flag/index.test.ts +298 -0
  91. package/src/modules/feature-flag/index.ts +316 -0
  92. package/src/modules/ref-tables.test.ts +93 -0
  93. package/src/modules/ref-tables.ts +91 -0
  94. package/src/modules/web-push/index.test.ts +824 -0
  95. package/src/modules/web-push/index.ts +1027 -0
  96. package/src/mutation/jobs.test.ts +25 -0
  97. package/src/mutation/jobs.ts +41 -0
  98. package/src/mutation/mutation-id.test.ts +37 -0
  99. package/src/mutation/mutation-id.ts +35 -0
  100. package/src/mutation/pending-writes.integration.test.ts +175 -0
  101. package/src/mutation/push.saga.test.ts +301 -0
  102. package/src/mutation/push.saga.ts +225 -0
  103. package/src/mutation/rows.test.ts +231 -0
  104. package/src/mutation/rows.ts +427 -0
  105. package/src/mutation/tray.saga.test.ts +63 -0
  106. package/src/mutation/tray.saga.ts +48 -0
  107. package/src/mutation/write.saga.test.ts +316 -0
  108. package/src/mutation/write.saga.ts +268 -0
  109. package/src/otel/index.ts +10 -7
  110. package/src/pure.ts +66 -0
  111. package/src/push/types.ts +174 -0
  112. package/src/query/anon-query-isolation.integration.test.ts +527 -0
  113. package/src/query/env.test.ts +23 -0
  114. package/src/query/env.ts +65 -0
  115. package/src/query/fetch.saga.test.ts +199 -0
  116. package/src/query/fetch.saga.ts +120 -0
  117. package/src/query/hash.test.ts +14 -0
  118. package/src/query/hash.ts +18 -0
  119. package/src/query/id-set-plan.test.ts +132 -0
  120. package/src/query/lifecycle.saga.test.ts +274 -0
  121. package/src/query/lifecycle.saga.ts +186 -0
  122. package/src/query/list-ref-live.integration.test.ts +203 -0
  123. package/src/query/materialize.saga.test.ts +115 -0
  124. package/src/query/materialize.saga.ts +84 -0
  125. package/src/query/materialize.test.ts +58 -0
  126. package/src/query/materialize.ts +57 -0
  127. package/src/query/membership.saga.test.ts +407 -0
  128. package/src/query/membership.saga.ts +282 -0
  129. package/src/query/membership.test.ts +118 -0
  130. package/src/query/membership.ts +201 -0
  131. package/src/query/register.saga.test.ts +311 -0
  132. package/src/query/register.saga.ts +235 -0
  133. package/src/query/render-set.test.ts +47 -0
  134. package/src/query/render-set.ts +71 -0
  135. package/src/query/sql.test.ts +118 -0
  136. package/src/query/sql.ts +157 -0
  137. package/src/query/window-query.test.ts +70 -0
  138. package/src/query/window-query.ts +208 -0
  139. package/src/services/blobs/blob-cache.test.ts +359 -0
  140. package/src/services/blobs/blob-cache.ts +603 -0
  141. package/src/services/blobs/blob-manifest.ts +227 -0
  142. package/src/services/blobs/blob-store.test.ts +77 -0
  143. package/src/services/blobs/blob-store.ts +359 -0
  144. package/src/services/blobs/blob.fixture.ts +90 -0
  145. package/src/services/blobs/index.ts +70 -0
  146. package/src/services/database/cache-engine.ts +209 -0
  147. package/src/services/database/connection-supervisor.test.ts +318 -0
  148. package/src/services/database/connection-supervisor.ts +422 -0
  149. package/src/services/database/database.query-timeout.test.ts +140 -0
  150. package/src/services/database/database.responses.test.ts +43 -0
  151. package/src/services/database/database.ts +156 -61
  152. package/src/services/database/engine-factory.ts +33 -0
  153. package/src/services/database/errors.ts +34 -0
  154. package/src/services/database/events/index.ts +2 -1
  155. package/src/services/database/index.ts +7 -0
  156. package/src/services/database/landed-body-clear.integration.test.ts +236 -0
  157. package/src/services/database/local-migrator.integration.test.ts +110 -0
  158. package/src/services/database/local-migrator.ts +61 -16
  159. package/src/services/database/local.test.ts +150 -0
  160. package/src/services/database/local.ts +508 -63
  161. package/src/services/database/plan-render.test.ts +159 -0
  162. package/src/services/database/plan-render.ts +108 -0
  163. package/src/services/database/relation-resolver.test.ts +484 -0
  164. package/src/services/database/relation-resolver.ts +0 -0
  165. package/src/services/database/remote.ts +285 -6
  166. package/src/services/database/sqlite-cache-engine.test.ts +628 -0
  167. package/src/services/database/sqlite-cache-engine.timeout.test.ts +61 -0
  168. package/src/services/database/sqlite-cache-engine.ts +1401 -0
  169. package/src/services/database/sqlite-column-reset.integration.test.ts +100 -0
  170. package/src/services/database/sqlite-column-reset.ts +91 -0
  171. package/src/services/database/sqlite-devtools-queries.integration.test.ts +143 -0
  172. package/src/services/database/sqlite-devtools-queries.test.ts +154 -0
  173. package/src/services/database/sqlite-lock-verify.test.ts +33 -0
  174. package/src/services/database/sqlite-lock-verify.ts +45 -0
  175. package/src/services/database/sqlite-open.test.ts +150 -0
  176. package/src/services/database/sqlite-open.ts +164 -0
  177. package/src/services/database/sqlite-plan-sql.test.ts +164 -0
  178. package/src/services/database/sqlite-plan-sql.ts +196 -0
  179. package/src/services/database/sqlite-projection.test.ts +121 -0
  180. package/src/services/database/sqlite-select.integration.test.ts +185 -0
  181. package/src/services/database/sqlite-select.test.ts +261 -0
  182. package/src/services/database/sqlite-select.ts +131 -0
  183. package/src/services/database/sqlite-transport.fixture.ts +30 -0
  184. package/src/services/database/sqlite-transport.ts +224 -0
  185. package/src/services/database/sqlite-worker.ts +437 -0
  186. package/src/services/database/surql-translate.ts +417 -0
  187. package/src/services/database/surreal-cache-engine.ts +163 -0
  188. package/src/services/logger/index.test.ts +132 -0
  189. package/src/services/logger/index.ts +189 -7
  190. package/src/services/persistence/localstorage.ts +2 -2
  191. package/src/services/persistence/resilient.ts +10 -3
  192. package/src/services/persistence/surrealdb.ts +4 -4
  193. package/src/services/stream-processor/index.ts +806 -67
  194. package/src/services/stream-processor/permissions.test.ts +47 -0
  195. package/src/services/stream-processor/permissions.ts +53 -0
  196. package/src/services/stream-processor/stream-processor.batch.test.ts +186 -0
  197. package/src/services/stream-processor/stream-processor.prime.test.ts +234 -0
  198. package/src/services/stream-processor/stream-processor.reset.test.ts +226 -0
  199. package/src/services/stream-processor/stream-processor.test.ts +1 -1
  200. package/src/services/stream-processor/wasm-types.ts +58 -2
  201. package/src/services/tabs/broker-client.ts +284 -0
  202. package/src/services/tabs/broker.test.ts +381 -0
  203. package/src/services/tabs/coordinator.test.ts +386 -0
  204. package/src/services/tabs/coordinator.ts +646 -0
  205. package/src/services/tabs/fake-ports.fixture.ts +112 -0
  206. package/src/services/tabs/leader-locks.ts +75 -0
  207. package/src/services/tabs/protocol.ts +262 -0
  208. package/src/services/tabs/support.ts +36 -0
  209. package/src/services/tabs/tabs-broker-worker.ts +652 -0
  210. package/src/sp00ky.ts +5 -395
  211. package/src/state/client-state.ts +208 -0
  212. package/src/state/lifecycle.test.ts +141 -0
  213. package/src/state/lifecycle.ts +99 -0
  214. package/src/state/reducers.test.ts +306 -0
  215. package/src/state/reducers.ts +531 -0
  216. package/src/state/selectors.test.ts +188 -0
  217. package/src/state/selectors.ts +258 -0
  218. package/src/sw/index.test.ts +582 -0
  219. package/src/sw/index.ts +607 -0
  220. package/src/sw/store.ts +96 -0
  221. package/src/sync/connection.saga.test.ts +97 -0
  222. package/src/sync/connection.saga.ts +89 -0
  223. package/src/sync/live.saga.test.ts +178 -0
  224. package/src/sync/live.saga.ts +107 -0
  225. package/src/sync/policy.health.test.ts +56 -0
  226. package/src/sync/policy.test.ts +663 -0
  227. package/src/sync/policy.ts +515 -0
  228. package/src/sync/poll.saga.test.ts +51 -0
  229. package/src/sync/poll.saga.ts +36 -0
  230. package/src/sync/tabs.saga.test.ts +78 -0
  231. package/src/sync/tabs.saga.ts +96 -0
  232. package/src/testing/adapters.ts +137 -0
  233. package/src/testing/build.ts +63 -0
  234. package/src/testing/fake-services.ts +63 -0
  235. package/src/testing/run-pure.test.ts +92 -0
  236. package/src/testing/run-pure.ts +128 -0
  237. package/src/testing/services.ts +17 -0
  238. package/src/types.ts +633 -10
  239. package/src/utils/blurhash.ts +90 -0
  240. package/src/utils/edges.ts +39 -0
  241. package/src/utils/error-classification.test.ts +54 -0
  242. package/src/utils/error-classification.ts +25 -0
  243. package/src/utils/index.ts +89 -12
  244. package/src/utils/parser.test.ts +67 -102
  245. package/src/utils/parser.ts +64 -2
  246. package/src/utils/semver.test.ts +32 -0
  247. package/src/utils/semver.ts +30 -0
  248. package/src/utils/sha256.ts +15 -0
  249. package/src/utils/surql.ts +29 -18
  250. package/src/utils/utils.test.ts +3 -2
  251. package/tsdown.config.ts +117 -3
  252. package/vitest.config.ts +48 -0
  253. package/src/modules/cache/index.ts +0 -241
  254. package/src/modules/cache/types.ts +0 -19
  255. package/src/modules/data/data.test.ts +0 -58
  256. package/src/modules/data/index.ts +0 -879
  257. package/src/modules/sync/engine.ts +0 -173
  258. package/src/modules/sync/events/index.ts +0 -77
  259. package/src/modules/sync/index.ts +0 -3
  260. package/src/modules/sync/queue/index.ts +0 -2
  261. package/src/modules/sync/queue/queue-down.ts +0 -89
  262. package/src/modules/sync/queue/queue-up.ts +0 -223
  263. package/src/modules/sync/scheduler.ts +0 -84
  264. package/src/modules/sync/sync.ts +0 -421
  265. package/src/modules/sync/utils.test.ts +0 -311
  266. package/src/modules/sync/utils.ts +0 -167
@@ -1,10 +1,13 @@
1
+ // oxlint-disable-next-line no-named-as-default -- WASM module default export convention
1
2
  import init, { Sp00kyProcessor } from '@spooky-sync/ssp-wasm';
2
- import { EventDefinition, EventSystem } from '../../events/index';
3
- import { Logger } from 'pino';
4
- import { LocalDatabaseService } from '../database/index';
5
- import { WasmProcessor, WasmStreamUpdate } from './wasm-types';
6
- import { Duration } from 'surrealdb';
7
- import { PersistenceClient, QueryTimeToLive, RecordVersionArray } from '../../types';
3
+ import type { EventDefinition, EventSystem } from '../../events/index';
4
+ import type { Logger } from 'pino';
5
+ import type { LocalStore } from '../database/index';
6
+ import type { WasmProcessor, WasmStreamUpdate } from './wasm-types';
7
+ import type { Duration } from 'surrealdb';
8
+ import type { QueryTimeToLive, RecordVersionArray } from '../../types';
9
+ import { encodeRecordId } from '../../utils/index';
10
+ import { recordIdKey } from '../database/relation-resolver';
8
11
 
9
12
  // Simple interface for query plan registration (replaces Incantation class)
10
13
  interface QueryPlanConfig {
@@ -27,6 +30,29 @@ export interface StreamUpdate {
27
30
  queryHash: string;
28
31
  localArray: RecordVersionArray;
29
32
  op?: 'CREATE' | 'UPDATE' | 'DELETE'; // Operation type for conditional debouncing
33
+ /**
34
+ * Client-internal: not from the circuit. A membership-only change that
35
+ * needed no fetch is re-materialized through this same path so it cannot
36
+ * race a real update (DataModule.scheduleRematerialize). Carries the last
37
+ * known `localArray`; consumers that describe an INGEST (persist, metrics,
38
+ * devtools events) skip it.
39
+ */
40
+ synthetic?: boolean;
41
+ /**
42
+ * End-to-end ingest latency for the WASM call that produced this update,
43
+ * in milliseconds. Populated by StreamProcessorService.ingest. Undefined
44
+ * for the initial register_view snapshot.
45
+ */
46
+ materializationTimeMs?: number;
47
+ /** SSP internal sub-phase timings (ms) for this ingest, from the WASM binding. */
48
+ storeApplyMs?: number;
49
+ circuitStepMs?: number;
50
+ transformMs?: number;
51
+ /**
52
+ * One-shot registration timings (ms). Only set on the StreamUpdate returned
53
+ * by `registerQueryPlan` (the register_view snapshot), not on ingest updates.
54
+ */
55
+ registration?: { parseMs: number; planMs: number; snapshotMs: number };
30
56
  }
31
57
 
32
58
  // Define events map (kept for DevTools compatibility)
@@ -41,16 +67,116 @@ export interface StreamUpdateReceiver {
41
67
  onStreamUpdate(update: StreamUpdate): void;
42
68
  }
43
69
 
70
+ /** One row change in the shape `ingestMany` consumes. */
71
+ export interface IngestRecord {
72
+ table: string;
73
+ /** `MERGE` overlays the given fields on the stored row (projection widening). */
74
+ op: 'CREATE' | 'UPDATE' | 'DELETE' | 'MERGE';
75
+ id: string;
76
+ record: any;
77
+ }
78
+
79
+ /**
80
+ * What the boot-time prime needs from the client: which tables to walk, how
81
+ * to recognise a snapshot written under a different schema, and which rows'
82
+ * local `_00_rv` must not be reported as the server's.
83
+ */
84
+ export interface CircuitPrimeContext {
85
+ tables: string[];
86
+ schemaHash: string;
87
+ /** Encoded ids with an unsettled local mutation (their `_00_rv` was bumped
88
+ * locally and may exceed the server's next version). */
89
+ pendingIds: Set<string>;
90
+ /** Each table's client column set (`columnFingerprints`). An engine that
91
+ * keeps bodies across schema changes resets the versions of a table whose
92
+ * set changed before they are read, so its rows are fetched again whole. */
93
+ columnFingerprints?: Record<string, string>;
94
+ /** Receives every `(id, rv)` the prime put into the circuit, per table, so
95
+ * the sync layer can skip re-downloading bodies it already has. */
96
+ onVersions?: (table: string, entries: [string, number][]) => void;
97
+ }
98
+
99
+ /** Storage key of the circuit snapshot inside the local store. */
100
+ export const CIRCUIT_SNAPSHOT_KEY = 'circuit';
101
+ /** Bump when the bytes `load_store_state` reads change shape. */
102
+ export const CIRCUIT_SNAPSHOT_FORMAT = 1;
103
+ /** Rows per `ingest_many` call. Bounds the transient the wasm side allocates
104
+ * to parse a batch (measured: one 7700-row call of 20 KB bodies peaked at
105
+ * 500 MB, 128-row chunks at 335 MB, and under projection at 24 MB). */
106
+ const INGEST_CHUNK = 128;
107
+ /** Ids per `selectByIds` when priming bodies out of the local store. */
108
+ const PRIME_CHUNK = 256;
109
+ /** Rows the SurrealDB (main-thread, IndexedDB) engine is allowed to prime
110
+ * through; above this the circuit boots empty as it always did. */
111
+ const SURREAL_PRIME_ROW_CAP = 20_000;
112
+ /** Below this many changed rows the checkpoint timer stays quiet. */
113
+ const CHECKPOINT_MIN_ROWS = 50;
114
+
115
+ /** Stable `table:id` for a row id that may already be a string. */
116
+ function idString(id: unknown): string {
117
+ return typeof id === 'string' ? id : encodeRecordId(id as any);
118
+ }
119
+
120
+ function chunks<T>(items: T[], size: number): T[][] {
121
+ const out: T[][] = [];
122
+ for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
123
+ return out;
124
+ }
125
+
44
126
  export class StreamProcessorService {
45
127
  private logger: Logger;
46
128
  private processor: WasmProcessor | undefined;
47
129
  private isInitialized = false;
48
130
  private receivers: StreamUpdateReceiver[] = [];
131
+ // When true, `notifyUpdates` coalesces updates into `batchBuffer` (keyed by
132
+ // queryHash) instead of dispatching them. Used to collapse the per-record
133
+ // stream updates produced by a batched ingest into a single notification per
134
+ // query, so the UI updates once after the whole batch rather than row-by-row.
135
+ private batching = false;
136
+ private batchBuffer: Map<string, StreamUpdate> = new Map();
137
+ // Current session's auth identity, injected into every `register_view`'s
138
+ // params so the in-browser SSP can resolve `$auth`/`$access` in table
139
+ // permission predicates (mirrors the server's `fn::query::register`). Empty
140
+ // strings when logged out — a non-null `auth` keeps `permission_inject` from
141
+ // rejecting `$auth`-gated tables; the predicate just degrades to its public
142
+ // branch. Set via `setSessionAuth` on every auth state change.
143
+ private sessionAuth: { authId: string; access: string } = { authId: '', access: '' };
144
+ // Bumped by `reset()`. Anything captured before a reset (a checkpoint's
145
+ // bytes, a prime's chunk, a widening read) must not land on the NEW
146
+ // processor, which belongs to a different bucket.
147
+ private stateGeneration = 0;
148
+ // Shared-tabs: follower circuits are in-memory ONLY. Only the tab that owns
149
+ // the store writes its snapshot; a promoted follower flips this back on.
150
+ private persistState = true;
151
+ // Snapshot persistence. Store-only bytes, written by the checkpoint timer
152
+ // and on the page going hidden, never per ingest: `save_store_state` walks
153
+ // every row. The snapshot is what lets a reload restore the circuit and
154
+ // step in only what changed since (`reconcile`), instead of re-downloading
155
+ // and re-ingesting the whole working set.
156
+ private persistCircuit = false;
157
+ private checkpointMs = 30_000;
158
+ private checkpointTimer: ReturnType<typeof setInterval> | null = null;
159
+ private snapshotDirty = false;
160
+ private dirtyRows = 0;
161
+ private hideHandler: (() => void) | null = null;
162
+ private checkpointInFlight: Promise<void> | null = null;
163
+ // Keep only the fields registered plans evaluate per stored row. The client
164
+ // renders bodies from the local store, so a 20 KB game row in the circuit
165
+ // is ~150 B of predicate/sort fields under projection.
166
+ private projection = true;
167
+ // Prime state: `primed` resolves when the boot-time prime (snapshot restore
168
+ // + reconcile, or a full read of the local store) has finished, aborted or
169
+ // been skipped. Sync waits on it before its first diff.
170
+ private primed: Promise<void> = Promise.resolve();
171
+ private schemaHash: string | null = null;
172
+ // Projection widening, serialised: fields a new view evaluates that stored
173
+ // rows lack are merged in table by table.
174
+ private widenQueue: Promise<void> = Promise.resolve();
175
+ private widenPending: Map<string, Set<string>> = new Map();
49
176
 
50
177
  constructor(
51
178
  public events: EventSystem<StreamProcessorEvents>,
52
- private db: LocalDatabaseService,
53
- private persistenceClient: PersistenceClient,
179
+ private db: LocalStore,
54
180
  logger: Logger
55
181
  ) {
56
182
  this.logger = logger.child({ name: 'StreamProcessorService' });
@@ -65,6 +191,32 @@ export class StreamProcessorService {
65
191
  }
66
192
 
67
193
  private notifyUpdates(updates: StreamUpdate[]) {
194
+ if (this.batching) {
195
+ // Coalesce by queryHash instead of dispatching. The WASM `result_data`
196
+ // (localArray) is the full materialized array, so last-write-wins
197
+ // already reflects every prior ingest in the batch. We sum the
198
+ // materialization times so the single recorded sample reflects the
199
+ // batch's total work, and emit `op: 'CREATE'` on flush so the coalesced
200
+ // update takes DataModule's immediate (non-debounced) path.
201
+ for (const update of updates) {
202
+ const prev = this.batchBuffer.get(update.queryHash);
203
+ const sum = (a?: number, b?: number) => (a ?? 0) + (b ?? 0);
204
+ this.batchBuffer.set(update.queryHash, {
205
+ ...update,
206
+ op: 'CREATE',
207
+ materializationTimeMs: sum(prev?.materializationTimeMs, update.materializationTimeMs),
208
+ storeApplyMs: sum(prev?.storeApplyMs, update.storeApplyMs),
209
+ circuitStepMs: sum(prev?.circuitStepMs, update.circuitStepMs),
210
+ transformMs: sum(prev?.transformMs, update.transformMs),
211
+ });
212
+ }
213
+ return;
214
+ }
215
+
216
+ this.dispatchUpdates(updates);
217
+ }
218
+
219
+ private dispatchUpdates(updates: StreamUpdate[]) {
68
220
  for (const update of updates) {
69
221
  for (const receiver of this.receivers) {
70
222
  receiver.onStreamUpdate(update);
@@ -72,6 +224,122 @@ export class StreamProcessorService {
72
224
  }
73
225
  }
74
226
 
227
+ /**
228
+ * Ingest a batch of record changes, firing one coalesced `StreamUpdate` per
229
+ * affected query once every record has been ingested. Use this whenever
230
+ * multiple records land at once (sync fetching N rows, the boot prime).
231
+ *
232
+ * The batch is fed to the wasm side in chunks of {@link INGEST_CHUNK}: one
233
+ * circuit step per chunk (a step walks every registered view, so per-record
234
+ * ingest paid that fixed cost N times), but never the whole batch at once,
235
+ * because the wasm side has to hold every parsed row of a call at the same
236
+ * time and wasm32 dlmalloc never returns that peak.
237
+ *
238
+ * Returns the records that were ingested. A chunk that fails is reported
239
+ * and skipped, not retried (a retry would double-apply whatever the failed
240
+ * step already committed), and the remaining chunks still run.
241
+ */
242
+ ingestMany(records: IngestRecord[]): IngestRecord[] {
243
+ if (records.length === 0) return [];
244
+
245
+ if (!this.processor) {
246
+ this.logger.warn(
247
+ { Category: 'sp00ky-client::StreamProcessorService::ingestMany' },
248
+ 'Not initialized, skipping ingest'
249
+ );
250
+ return [];
251
+ }
252
+
253
+ const bulkIngest = this.processor.ingest_many;
254
+ if (typeof bulkIngest !== 'function') {
255
+ // Stale wasm build: per-record steps, still coalesced into one update.
256
+ this.beginCoalescing();
257
+ try {
258
+ for (const record of records) {
259
+ this.ingest(record.table, record.op, record.id, record.record);
260
+ }
261
+ } finally {
262
+ this.flushCoalescing();
263
+ }
264
+ return records;
265
+ }
266
+
267
+ const ingested: IngestRecord[] = [];
268
+ this.beginCoalescing();
269
+ try {
270
+ for (const chunk of chunks(records, INGEST_CHUNK)) {
271
+ try {
272
+ const items = chunk.map((record) => ({
273
+ table: record.table,
274
+ op: record.op,
275
+ id: record.id,
276
+ record: this.normalizeValue(record.record),
277
+ }));
278
+ const t0 = performance.now();
279
+ const rawUpdates = bulkIngest.call(this.processor, items) ?? [];
280
+ const materializationTimeMs = performance.now() - t0;
281
+ if (rawUpdates.length > 0) {
282
+ this.notifyUpdates(
283
+ rawUpdates.map((u: WasmStreamUpdate) => ({
284
+ queryHash: u.query_id,
285
+ localArray: u.result_data,
286
+ op: 'CREATE' as const,
287
+ materializationTimeMs,
288
+ storeApplyMs: u.timing_store_apply_ms,
289
+ circuitStepMs: u.timing_circuit_step_ms,
290
+ transformMs: u.timing_transform_ms,
291
+ }))
292
+ );
293
+ }
294
+ ingested.push(...chunk);
295
+ } catch (e) {
296
+ this.logger.error(
297
+ { error: e, count: chunk.length, Category: 'sp00ky-client::StreamProcessorService::ingestMany' },
298
+ 'Ingesting chunk into ssp failed'
299
+ );
300
+ }
301
+ }
302
+ } finally {
303
+ this.flushCoalescing();
304
+ }
305
+ this.logger.debug(
306
+ { count: records.length, ingested: ingested.length, Category: 'sp00ky-client::StreamProcessorService::ingestMany' },
307
+ 'Ingested batch into ssp'
308
+ );
309
+ this.markSnapshotDirty(ingested.length);
310
+ return ingested;
311
+ }
312
+
313
+ /**
314
+ * Open a coalescing window. While open, the per-record stream updates
315
+ * emitted by `ingest` are buffered (one entry per queryHash) instead of
316
+ * dispatched. Always paired with `flushCoalescing()` in a try/finally by
317
+ * `ingestMany` so the window always closes — otherwise the processor stays
318
+ * stuck buffering forever.
319
+ *
320
+ * No-op if a window is already open (nested batches aren't expected here).
321
+ */
322
+ private beginCoalescing() {
323
+ if (this.batching) return;
324
+ this.batching = true;
325
+ this.batchBuffer.clear();
326
+ }
327
+
328
+ /**
329
+ * Close the coalescing window and flush: dispatch one coalesced
330
+ * `StreamUpdate` per buffered queryHash, then persist processor state once
331
+ * for the whole batch (instead of once per ingest).
332
+ */
333
+ private flushCoalescing() {
334
+ if (!this.batching) return;
335
+ this.batching = false;
336
+ const buffered = Array.from(this.batchBuffer.values());
337
+ this.batchBuffer.clear();
338
+ if (buffered.length > 0) {
339
+ this.dispatchUpdates(buffered);
340
+ }
341
+ }
342
+
75
343
  /**
76
344
  * Initialize the WASM module and processor.
77
345
  * This must be called before using other methods.
@@ -87,9 +355,7 @@ export class StreamProcessorService {
87
355
  await init(); // Initialize the WASM module (web target)
88
356
  // We cast the generated Sp00kyProcessor to our interface which is safer
89
357
  this.processor = new Sp00kyProcessor() as unknown as WasmProcessor;
90
-
91
- // Try to load state
92
- await this.loadState();
358
+ this.applyProjection();
93
359
 
94
360
  this.isInitialized = true;
95
361
  this.logger.info(
@@ -105,73 +371,498 @@ export class StreamProcessorService {
105
371
  }
106
372
  }
107
373
 
108
- async loadState() {
109
- if (!this.processor) return;
374
+ /**
375
+ * Drop the current WASM processor and start a fresh, empty circuit. Used on
376
+ * local-bucket switches: the old circuit holds the previous user's rows AND
377
+ * views registered with the previous `$auth` context, so neither may survive.
378
+ * Deliberately loads nothing: the snapshot in the store being swapped away
379
+ * from belongs to the previous bucket; the caller primes the new bucket's
380
+ * circuit (`primeFromLocal`) once its store is open, and the DataModule
381
+ * rebind re-registers every live view against this fresh processor. Caller
382
+ * must re-seed `setPermissions` afterwards (a fresh circuit default-denies
383
+ * every table).
384
+ */
385
+ async reset(): Promise<void> {
386
+ if (!this.isInitialized) return;
387
+ this.stateGeneration++;
388
+ this.batching = false;
389
+ this.batchBuffer.clear();
390
+ this.snapshotDirty = false;
391
+ this.dirtyRows = 0;
392
+ this.widenPending.clear();
393
+ const previous = this.processor;
394
+ this.processor = new Sp00kyProcessor() as unknown as WasmProcessor;
395
+ this.applyProjection();
396
+ this.freeProcessor(previous);
397
+ this.logger.info(
398
+ { Category: 'sp00ky-client::StreamProcessorService::reset' },
399
+ 'Stream processor reset (fresh circuit)'
400
+ );
401
+ }
402
+
403
+ /**
404
+ * Release the wasm circuit and stop checkpointing. Call when the client is
405
+ * torn down; a recreated client (provider remount, HMR) would otherwise stack
406
+ * one full circuit per instance.
407
+ */
408
+ dispose(): void {
409
+ this.stopCheckpoints();
410
+ const previous = this.processor;
411
+ this.processor = undefined;
412
+ this.isInitialized = false;
413
+ this.batching = false;
414
+ this.batchBuffer.clear();
415
+ this.receivers = [];
416
+ this.freeProcessor(previous);
417
+ }
418
+
419
+ /**
420
+ * Explicitly run the wasm-bindgen destructor. Guarded: stale wasm builds may
421
+ * not expose `free`, and a double free must not take the app down.
422
+ */
423
+ private freeProcessor(processor: WasmProcessor | undefined): void {
424
+ if (!processor || typeof processor.free !== 'function') return;
110
425
  try {
111
- const result = await this.persistenceClient.get('_00_stream_processor_state');
426
+ processor.free();
427
+ } catch (e) {
428
+ this.logger.debug(
429
+ { error: e, Category: 'sp00ky-client::StreamProcessorService::freeProcessor' },
430
+ 'Failed to free previous wasm circuit'
431
+ );
432
+ }
433
+ }
112
434
 
113
- // Check if we have a valid result from the query
114
- if (
115
- Array.isArray(result) &&
116
- result.length > 0 &&
117
- Array.isArray(result[0]) &&
118
- result[0].length > 0 &&
119
- result[0][0]?.state
120
- ) {
121
- const state = result[0][0].state;
122
- this.logger.info(
123
- {
124
- stateLength: state.length,
125
- Category: 'sp00ky-client::StreamProcessorService::loadState',
126
- },
127
- 'Loading state from DB'
435
+ /** Toggle circuit-state persistence (shared-tabs follower/leader role). */
436
+ setPersistenceEnabled(enabled: boolean): void {
437
+ this.persistState = enabled;
438
+ if (!enabled) this.stopCheckpoints();
439
+ }
440
+
441
+ /**
442
+ * Snapshot persistence (`persistCircuit`). When on, the circuit's store is
443
+ * written to the local store on a checkpoint interval and when the page
444
+ * goes hidden, and restored by {@link primeFromLocal} on the next boot.
445
+ */
446
+ configureCircuitPersistence(enabled: boolean, checkpointMs?: number): void {
447
+ this.persistCircuit = enabled;
448
+ if (checkpointMs && checkpointMs > 0) this.checkpointMs = checkpointMs;
449
+ if (!enabled) this.stopCheckpoints();
450
+ }
451
+
452
+ /**
453
+ * Field projection (`circuitProjection`, default on). Takes effect on the
454
+ * next processor (`init`/`reset`) and on rows written after that.
455
+ */
456
+ configureProjection(enabled: boolean): void {
457
+ this.projection = enabled;
458
+ this.applyProjection();
459
+ }
460
+
461
+ private applyProjection(): void {
462
+ if (!this.processor || typeof this.processor.set_projection !== 'function') return;
463
+ try {
464
+ this.processor.set_projection(this.projection);
465
+ } catch (e) {
466
+ this.logger.warn(
467
+ { error: e, Category: 'sp00ky-client::StreamProcessorService::applyProjection' },
468
+ 'set_projection failed'
469
+ );
470
+ }
471
+ }
472
+
473
+ /** Resolves once the boot-time prime has finished (or was skipped). */
474
+ whenPrimed(): Promise<void> {
475
+ return this.primed;
476
+ }
477
+
478
+ /**
479
+ * Fill the circuit from the LOCAL store, in the background.
480
+ *
481
+ * With a usable snapshot: install it under whatever views have registered
482
+ * meanwhile (`load_store_state` re-primes them), then `reconcile` each table
483
+ * against the store's `(id, rv)` list so rows deleted since the checkpoint
484
+ * are stepped out and only rows added or changed since are read back and
485
+ * ingested. Without one: read every row and ingest it, chunked.
486
+ *
487
+ * Either way the circuit ends up equal to the local store without touching
488
+ * the network, so the first sync diff is a real delta rather than "fetch
489
+ * everything". The returned promise never rejects; `whenPrimed` gates on it.
490
+ */
491
+ primeFromLocal(ctx: CircuitPrimeContext): Promise<void> {
492
+ this.schemaHash = ctx.schemaHash;
493
+ const run = this.runPrime(ctx).catch((e) => {
494
+ this.logger.warn(
495
+ { error: e, Category: 'sp00ky-client::StreamProcessorService::primeFromLocal' },
496
+ 'Circuit prime failed; the circuit fills from sync instead'
497
+ );
498
+ });
499
+ this.primed = run;
500
+ return run;
501
+ }
502
+
503
+ private async runPrime(ctx: CircuitPrimeContext): Promise<void> {
504
+ const processor = this.processor;
505
+ if (!processor || typeof this.db.scanVersions !== 'function') return;
506
+ const generation = this.stateGeneration;
507
+ const epoch = this.db.epoch;
508
+ // A bucket switch replaces the processor and the store under us; every
509
+ // step re-checks so a stale chunk never lands in the new bucket.
510
+ const alive = () =>
511
+ this.processor === processor && generation === this.stateGeneration && epoch === this.db.epoch;
512
+ const t0 = performance.now();
513
+
514
+ // Before anything reads a version: a table whose columns changed since its
515
+ // bodies were cached reads as stale from here on (see sqlite-column-reset).
516
+ if (ctx.columnFingerprints && typeof this.db.resetChangedTables === 'function') {
517
+ try {
518
+ const reset = await this.db.resetChangedTables(ctx.columnFingerprints, ctx.pendingIds);
519
+ if (!alive()) return;
520
+ if (reset.length > 0) {
521
+ this.logger.info(
522
+ { tables: reset, Category: 'sp00ky-client::StreamProcessorService::primeFromLocal' },
523
+ 'Columns changed since these rows were cached; they will be fetched again'
524
+ );
525
+ }
526
+ } catch (e) {
527
+ this.logger.warn(
528
+ { error: e, Category: 'sp00ky-client::StreamProcessorService::primeFromLocal' },
529
+ 'Column change check failed; cached versions kept'
128
530
  );
129
- // Assuming processor has a load_state method matching the save_state behavior
130
- // If not, we might need to adjust based on the actual WASM API
131
- if (typeof (this.processor as any).load_state === 'function') {
132
- (this.processor as any).load_state(state);
133
- } else {
134
- this.logger.warn(
135
- { Category: 'sp00ky-client::StreamProcessorService::loadState' },
136
- 'load_state method not found on processor'
531
+ }
532
+ }
533
+
534
+ let restored = false;
535
+ if (
536
+ this.persistCircuit &&
537
+ typeof this.db.getSnapshot === 'function' &&
538
+ typeof processor.load_store_state === 'function'
539
+ ) {
540
+ const snapshot = await this.db.getSnapshot(CIRCUIT_SNAPSHOT_KEY);
541
+ if (!alive()) return;
542
+ if (snapshot) {
543
+ const { meta } = snapshot;
544
+ if (meta.formatVersion !== CIRCUIT_SNAPSHOT_FORMAT || meta.schemaHash !== ctx.schemaHash) {
545
+ this.logger.info(
546
+ { meta, Category: 'sp00ky-client::StreamProcessorService::primeFromLocal' },
547
+ 'Circuit snapshot is from another format or schema; priming from rows'
137
548
  );
549
+ } else {
550
+ try {
551
+ const tLoad = performance.now();
552
+ const updates = processor.load_store_state(snapshot.bytes);
553
+ this.dispatchWasmUpdates(updates);
554
+ restored = true;
555
+ this.logger.info(
556
+ {
557
+ bytes: snapshot.bytes.byteLength,
558
+ views: updates.length,
559
+ loadMs: Math.round(performance.now() - tLoad),
560
+ Category: 'sp00ky-client::StreamProcessorService::primeFromLocal',
561
+ },
562
+ 'Circuit snapshot restored'
563
+ );
564
+ } catch (e) {
565
+ this.logger.warn(
566
+ { error: e, Category: 'sp00ky-client::StreamProcessorService::primeFromLocal' },
567
+ 'Circuit snapshot unreadable; priming from rows'
568
+ );
569
+ }
138
570
  }
571
+ }
572
+ }
573
+
574
+ // Read the versions AFTER the snapshot: a batch the leader writes between
575
+ // the two can only make the store side newer, which reconcile handles.
576
+ const versions = await this.db.scanVersions(ctx.tables);
577
+ if (!alive()) return;
578
+ const total = Object.values(versions).reduce((n, v) => n + v.length, 0);
579
+ if (!restored && this.db.engineKind !== 'sqlite' && total > SURREAL_PRIME_ROW_CAP) {
580
+ this.logger.warn(
581
+ { total, cap: SURREAL_PRIME_ROW_CAP, Category: 'sp00ky-client::StreamProcessorService::primeFromLocal' },
582
+ 'Too many cached rows to prime through the main-thread engine; circuit fills from sync'
583
+ );
584
+ return;
585
+ }
586
+
587
+ let ingested = 0;
588
+ let fetched = 0;
589
+ let deleted = 0;
590
+ for (const table of ctx.tables) {
591
+ const entries = versions[table] ?? [];
592
+ let toFetch: string[];
593
+ if (restored && typeof processor.reconcile === 'function') {
594
+ const result = processor.reconcile(table, entries);
595
+ this.dispatchWasmUpdates(result.updates);
596
+ deleted += result.deleted;
597
+ toFetch = result.fetch;
139
598
  } else {
140
- this.logger.info(
141
- { Category: 'sp00ky-client::StreamProcessorService::loadState' },
142
- 'No saved state found'
599
+ toFetch = entries.map(([id]) => id);
600
+ }
601
+ for (const chunk of chunks(toFetch, PRIME_CHUNK)) {
602
+ const rows = await this.db.selectByIds(table, chunk);
603
+ if (!alive()) return;
604
+ fetched += rows.length;
605
+ ingested += this.ingestMany(
606
+ rows.map((row) => ({
607
+ table,
608
+ op: 'CREATE' as const,
609
+ id: idString(row.id),
610
+ record: row,
611
+ }))
612
+ ).length;
613
+ }
614
+ if (entries.length > 0) {
615
+ ctx.onVersions?.(
616
+ table,
617
+ entries.filter(([id]) => !ctx.pendingIds.has(id))
143
618
  );
144
619
  }
145
- } catch (e) {
146
- this.logger.error(
147
- { error: e, Category: 'sp00ky-client::StreamProcessorService::loadState' },
148
- 'Failed to load state'
149
- );
150
620
  }
621
+ // A prime from rows leaves the store with no snapshot to fall back on;
622
+ // let the next checkpoint write one even if nothing else changes.
623
+ if (!restored && ingested > 0) this.markSnapshotDirty(Math.max(ingested, CHECKPOINT_MIN_ROWS));
624
+ this.logger.info(
625
+ {
626
+ restored,
627
+ rows: total,
628
+ fetched,
629
+ ingested,
630
+ deleted,
631
+ ms: Math.round(performance.now() - t0),
632
+ Category: 'sp00ky-client::StreamProcessorService::primeFromLocal',
633
+ },
634
+ 'Circuit primed from the local store'
635
+ );
151
636
  }
152
637
 
153
- async saveState() {
154
- if (!this.processor) return;
638
+ /** Publish wasm updates produced outside an ingest (restore, reconcile). */
639
+ private dispatchWasmUpdates(updates: WasmStreamUpdate[] | undefined): void {
640
+ if (!updates || updates.length === 0) return;
641
+ this.dispatchUpdates(
642
+ updates.map((u) => ({
643
+ queryHash: u.query_id,
644
+ localArray: u.result_data,
645
+ op: 'CREATE' as const,
646
+ }))
647
+ );
648
+ }
649
+
650
+ /**
651
+ * Record that the circuit changed by `rows` rows. Cheap; the snapshot is
652
+ * deferred to the checkpoint timer and skipped entirely when `persistCircuit`
653
+ * is off.
654
+ */
655
+ private markSnapshotDirty(rows = 1): void {
656
+ if (!this.persistCircuit || !this.persistState) return;
657
+ this.snapshotDirty = true;
658
+ this.dirtyRows += rows;
659
+ this.startCheckpoints();
660
+ }
661
+
662
+ private startCheckpoints(): void {
663
+ if (this.checkpointTimer) return;
664
+ this.checkpointTimer = setInterval(() => {
665
+ if (!this.snapshotDirty || this.dirtyRows < CHECKPOINT_MIN_ROWS) return;
666
+ void this.checkpoint('interval');
667
+ }, this.checkpointMs);
668
+ // Node/test environments have no `window`; the interval alone is enough there.
669
+ if (typeof window !== 'undefined' && !this.hideHandler) {
670
+ // `visibilitychange: hidden` is the reliable last chance: a 100 MB write
671
+ // cannot finish inside `pagehide`, which is kept only as best effort.
672
+ this.hideHandler = () => {
673
+ if (!this.snapshotDirty) return;
674
+ if (typeof document !== 'undefined' && document.visibilityState === 'visible') return;
675
+ void this.checkpoint('hidden');
676
+ };
677
+ window.addEventListener('visibilitychange', this.hideHandler);
678
+ window.addEventListener('pagehide', this.hideHandler);
679
+ }
680
+ }
681
+
682
+ /** Stop checkpointing and drop the visibility listeners. */
683
+ stopCheckpoints(): void {
684
+ if (this.checkpointTimer) {
685
+ clearInterval(this.checkpointTimer);
686
+ this.checkpointTimer = null;
687
+ }
688
+ if (this.hideHandler && typeof window !== 'undefined') {
689
+ window.removeEventListener('visibilitychange', this.hideHandler);
690
+ window.removeEventListener('pagehide', this.hideHandler);
691
+ }
692
+ this.hideHandler = null;
693
+ this.snapshotDirty = false;
694
+ this.dirtyRows = 0;
695
+ }
696
+
697
+ /**
698
+ * Write the circuit's store to the local store as a snapshot. Compacts the
699
+ * row arena first when dead bytes outweigh live ones. Serialised: a second
700
+ * call while one is in flight joins it. No-op unless persistence is on, this
701
+ * tab owns the store, and the engine can hold a snapshot.
702
+ */
703
+ checkpoint(reason = 'manual'): Promise<void> {
704
+ if (this.checkpointInFlight) return this.checkpointInFlight;
705
+ const run = this.runCheckpoint(reason).finally(() => {
706
+ this.checkpointInFlight = null;
707
+ });
708
+ this.checkpointInFlight = run;
709
+ return run;
710
+ }
711
+
712
+ private async runCheckpoint(reason: string): Promise<void> {
713
+ const processor = this.processor;
714
+ if (!processor || !this.persistState || !this.persistCircuit || !this.schemaHash) return;
715
+ if (typeof this.db.putSnapshot !== 'function' || typeof processor.save_store_state !== 'function') {
716
+ return;
717
+ }
718
+ const generation = this.stateGeneration;
719
+ const epoch = this.db.epoch;
720
+ const rows = this.dirtyRows;
721
+ this.snapshotDirty = false;
722
+ this.dirtyRows = 0;
155
723
  try {
156
- // Assuming processor has a save_state method that returns the state string/bytes
157
- if (typeof (this.processor as any).save_state === 'function') {
158
- const state = (this.processor as any).save_state();
159
- if (state) {
160
- await this.persistenceClient.set('_00_stream_processor_state', state);
161
- this.logger.trace(
162
- { Category: 'sp00ky-client::StreamProcessorService::saveState' },
163
- 'State saved'
164
- );
165
- }
724
+ const t0 = performance.now();
725
+ let reclaimed = 0;
726
+ if (
727
+ typeof processor.compact === 'function' &&
728
+ typeof processor.dead_bytes === 'function' &&
729
+ typeof processor.live_bytes === 'function'
730
+ ) {
731
+ const dead = processor.dead_bytes();
732
+ if (dead > 1_000_000 && dead > processor.live_bytes()) reclaimed = processor.compact();
166
733
  }
734
+ const bytes = processor.save_store_state();
735
+ const meta = {
736
+ formatVersion: CIRCUIT_SNAPSHOT_FORMAT,
737
+ schemaHash: this.schemaHash,
738
+ savedAt: Date.now(),
739
+ maxRv: typeof processor.max_row_versions === 'function' ? processor.max_row_versions() : undefined,
740
+ };
741
+ // A reset or bucket switch landed while we serialised: this snapshot
742
+ // describes the OLD bucket's circuit and must not be written into the
743
+ // new bucket's store.
744
+ if (generation !== this.stateGeneration || epoch !== this.db.epoch) return;
745
+ await this.db.putSnapshot(CIRCUIT_SNAPSHOT_KEY, bytes, meta);
746
+ this.logger.info(
747
+ {
748
+ reason,
749
+ rows,
750
+ bytes: bytes.byteLength,
751
+ reclaimed,
752
+ ms: Math.round(performance.now() - t0),
753
+ Category: 'sp00ky-client::StreamProcessorService::checkpoint',
754
+ },
755
+ 'Circuit snapshot written'
756
+ );
167
757
  } catch (e) {
168
- this.logger.error(
169
- { error: e, Category: 'sp00ky-client::StreamProcessorService::saveState' },
170
- 'Failed to save state'
758
+ this.logger.warn(
759
+ { error: e, reason, Category: 'sp00ky-client::StreamProcessorService::checkpoint' },
760
+ 'Circuit snapshot failed'
761
+ );
762
+ }
763
+ }
764
+
765
+ /**
766
+ * Projection widening: a newly registered view evaluates fields that rows
767
+ * already in the circuit were stored without. Merge just those fields in,
768
+ * table by table, from the local store. The view registered against what
769
+ * was present and converges as the merges step through.
770
+ */
771
+ private scheduleWiden(missing: Record<string, string[]>): void {
772
+ for (const [table, fields] of Object.entries(missing)) {
773
+ const set = this.widenPending.get(table) ?? new Set<string>();
774
+ for (const f of fields) set.add(f);
775
+ this.widenPending.set(table, set);
776
+ }
777
+ this.widenQueue = this.widenQueue.then(() => this.runWiden()).catch((e) => {
778
+ this.logger.warn(
779
+ { error: e, Category: 'sp00ky-client::StreamProcessorService::widen' },
780
+ 'Projection widening failed'
781
+ );
782
+ });
783
+ }
784
+
785
+ private async runWiden(): Promise<void> {
786
+ const processor = this.processor;
787
+ if (!processor || typeof this.db.scanVersions !== 'function') {
788
+ this.widenPending.clear();
789
+ return;
790
+ }
791
+ const generation = this.stateGeneration;
792
+ const epoch = this.db.epoch;
793
+ while (this.widenPending.size > 0) {
794
+ const [table, fields] = this.widenPending.entries().next().value as [string, Set<string>];
795
+ this.widenPending.delete(table);
796
+ const select = Array.from(fields);
797
+ const versions = await this.db.scanVersions([table]);
798
+ if (this.processor !== processor || generation !== this.stateGeneration || epoch !== this.db.epoch) return;
799
+ const ids = (versions[table] ?? []).map(([id]) => id);
800
+ let merged = 0;
801
+ for (const chunk of chunks(ids, PRIME_CHUNK)) {
802
+ const rows = await this.db.selectByIds(table, chunk, { select });
803
+ if (this.processor !== processor || generation !== this.stateGeneration || epoch !== this.db.epoch) return;
804
+ merged += this.ingestMany(
805
+ rows.map((row) => ({
806
+ table,
807
+ op: 'MERGE' as const,
808
+ id: idString(row.id),
809
+ record: row,
810
+ }))
811
+ ).length;
812
+ }
813
+ this.logger.info(
814
+ { table, fields: select, merged, Category: 'sp00ky-client::StreamProcessorService::widen' },
815
+ 'Widened projected rows with newly evaluated fields'
171
816
  );
172
817
  }
173
818
  }
174
819
 
820
+ /**
821
+ * Seed per-table `select` permission predicates ({ [table]: whereText }).
822
+ * Must run after the processor exists and before any `register_view`, else
823
+ * non-`_00_` tables are default-denied and registration fails.
824
+ */
825
+ setPermissions(permissions: Record<string, string>) {
826
+ if (!this.processor) return;
827
+ if (typeof this.processor.set_permissions !== 'function') {
828
+ this.logger.warn(
829
+ { Category: 'sp00ky-client::StreamProcessorService::setPermissions' },
830
+ 'set_permissions not found on processor (stale WASM build?)'
831
+ );
832
+ return;
833
+ }
834
+ this.processor.set_permissions(permissions);
835
+ this.logger.info(
836
+ {
837
+ tables: Object.keys(permissions).length,
838
+ Category: 'sp00ky-client::StreamProcessorService::setPermissions',
839
+ },
840
+ 'Seeded table permissions'
841
+ );
842
+ }
843
+
844
+ /**
845
+ * Set the current session's auth identity for permission injection,
846
+ * mirroring the server's `fn::query::register`
847
+ * (`object::extend(params, { auth: { id: $auth.id }, access: $access })`).
848
+ * Stored as strings (empty when logged out) and applied to every
849
+ * `register_view` in {@link registerQueryPlan}. Must be set before a
850
+ * `$auth`-gated query registers (and re-set on auth state changes), or the
851
+ * in-browser SSP's `permission_inject` rejects it with
852
+ * "requires $auth but registration params lack it".
853
+ */
854
+ setSessionAuth(authId: string | null, access: string | null) {
855
+ this.sessionAuth = { authId: authId ?? '', access: access ?? '' };
856
+ this.logger.debug(
857
+ {
858
+ authId: this.sessionAuth.authId,
859
+ access: this.sessionAuth.access,
860
+ Category: 'sp00ky-client::StreamProcessorService::setSessionAuth',
861
+ },
862
+ 'Session auth context updated'
863
+ );
864
+ }
865
+
175
866
  /**
176
867
  * Ingest a record change into the processor.
177
868
  * Emits 'stream_update' event if materialized views are affected.
@@ -179,7 +870,7 @@ export class StreamProcessorService {
179
870
  */
180
871
  ingest(
181
872
  table: string,
182
- op: 'CREATE' | 'UPDATE' | 'DELETE',
873
+ op: IngestRecord['op'],
183
874
  id: string,
184
875
  record: any
185
876
  ): WasmStreamUpdate[] {
@@ -204,13 +895,16 @@ export class StreamProcessorService {
204
895
  try {
205
896
  const normalizedRecord = this.normalizeValue(record);
206
897
 
898
+ const t0 = performance.now();
207
899
  const rawUpdates = this.processor.ingest(table, op, id, normalizedRecord);
900
+ const materializationTimeMs = performance.now() - t0;
208
901
  this.logger.debug(
209
902
  {
210
903
  table,
211
904
  op,
212
905
  id,
213
906
  rawUpdates: rawUpdates.length,
907
+ materializationTimeMs,
214
908
  Category: 'sp00ky-client::StreamProcessorService::ingest',
215
909
  },
216
910
  'Ingesting into ssp done'
@@ -220,12 +914,21 @@ export class StreamProcessorService {
220
914
  const updates: StreamUpdate[] = rawUpdates.map((u: WasmStreamUpdate) => ({
221
915
  queryHash: u.query_id,
222
916
  localArray: u.result_data,
223
- op: op,
917
+ // A MERGE is a content update as far as consumers are concerned.
918
+ op: op === 'MERGE' ? 'UPDATE' : op,
919
+ materializationTimeMs,
920
+ storeApplyMs: u.timing_store_apply_ms,
921
+ circuitStepMs: u.timing_circuit_step_ms,
922
+ transformMs: u.timing_transform_ms,
224
923
  }));
225
924
  // Direct handler call instead of event
226
925
  this.notifyUpdates(updates);
227
926
  }
228
- this.saveState();
927
+ // While batching (inside `ingestMany`), `flushCoalescing` marks dirty once
928
+ // for the whole batch, skip the redundant per-record mark here.
929
+ if (!this.batching) {
930
+ this.markSnapshotDirty();
931
+ }
229
932
  return rawUpdates;
230
933
  } catch (e) {
231
934
  this.logger.error(
@@ -262,10 +965,23 @@ export class StreamProcessorService {
262
965
  try {
263
966
  const normalizedParams = this.normalizeValue(queryPlan.params);
264
967
 
968
+ // Mirror the server's `fn::query::register` auth injection so the
969
+ // in-browser SSP can resolve `$auth`/`$access` in a table's permission
970
+ // predicate. Without this, any `$auth`-gated table (e.g. `thread`) is
971
+ // rejected by `permission_inject` and its local view never materializes.
972
+ // Injected only into the params handed to the in-browser SSP — never into
973
+ // the persisted `queryState.config.params` / query hash / server payload
974
+ // (the server does its own injection), so the view id stays shared.
975
+ const paramsWithAuth = {
976
+ ...(normalizedParams as Record<string, unknown>),
977
+ auth: { id: this.sessionAuth.authId },
978
+ access: this.sessionAuth.access,
979
+ };
980
+
265
981
  const initialUpdate = this.processor.register_view({
266
982
  id: queryPlan.queryHash,
267
983
  surql: queryPlan.surql,
268
- params: normalizedParams,
984
+ params: paramsWithAuth,
269
985
  clientId: 'local',
270
986
  ttl: queryPlan.ttl.toString(),
271
987
  lastActiveAt: new Date().toISOString(),
@@ -282,8 +998,15 @@ export class StreamProcessorService {
282
998
  const update: StreamUpdate = {
283
999
  queryHash: initialUpdate.query_id,
284
1000
  localArray: initialUpdate.result_data,
1001
+ registration: {
1002
+ parseMs: initialUpdate.timing_parse_ms ?? 0,
1003
+ planMs: initialUpdate.timing_plan_ms ?? 0,
1004
+ snapshotMs: initialUpdate.timing_snapshot_ms ?? 0,
1005
+ },
285
1006
  };
286
- this.saveState();
1007
+ if (initialUpdate.missing_fields && Object.keys(initialUpdate.missing_fields).length > 0) {
1008
+ this.scheduleWiden(initialUpdate.missing_fields);
1009
+ }
287
1010
  this.logger.debug(
288
1011
  {
289
1012
  queryHash: queryPlan.queryHash,
@@ -310,7 +1033,6 @@ export class StreamProcessorService {
310
1033
  if (!this.processor) return;
311
1034
  try {
312
1035
  this.processor.unregister_view(queryHash);
313
- this.saveState();
314
1036
  } catch (e) {
315
1037
  this.logger.error(
316
1038
  { error: e, Category: 'sp00ky-client::StreamProcessorService::unregisterQueryPlan' },
@@ -323,6 +1045,21 @@ export class StreamProcessorService {
323
1045
  if (value === null || value === undefined) return value;
324
1046
 
325
1047
  if (typeof value === 'object') {
1048
+ // CRDT snapshots arrive as `Uint8Array` (or `ArrayBuffer` /
1049
+ // typed-array views). `serde_wasm_bindgen::from_value` rejects
1050
+ // those when deserializing into `serde_json::Value` (JSON has no
1051
+ // binary variant), and the SSP can't filter on opaque bytes
1052
+ // anyway. Replace with `null` so the row still flows through the
1053
+ // ingest path with its other columns intact, and downstream
1054
+ // predicates referencing the bytes column simply don't match.
1055
+ if (
1056
+ value instanceof Uint8Array ||
1057
+ value instanceof ArrayBuffer ||
1058
+ ArrayBuffer.isView(value)
1059
+ ) {
1060
+ return null;
1061
+ }
1062
+
326
1063
  // RecordId detection using duck typing (constructor.name may be minified)
327
1064
  // SurrealDB's RecordId has: table (getter returning Table), id, and toString()
328
1065
  // Check for table getter that has its own toString AND id property
@@ -332,7 +1069,9 @@ export class StreamProcessorService {
332
1069
  const isNotPlainObject = value.constructor !== Object;
333
1070
 
334
1071
  if (hasTable && hasId && hasToString && isNotPlainObject) {
335
- const result = value.toString();
1072
+ // The raw `table:id`, as the store spells it. `toString()` is escaped
1073
+ // SurrealQL (SDK 2.1.0: `game:⟨476g…⟩`); only a complex key uses it.
1074
+ const result = recordIdKey(value) ?? value.toString();
336
1075
  this.logger.trace(
337
1076
  { result, Category: 'sp00ky-client::StreamProcessorService::normalizeValue' },
338
1077
  'RecordId detected'