@spooky-sync/core 0.0.1-canary.32 → 0.0.1-canary.320

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