@spooky-sync/core 0.0.1-canary.20 → 0.0.1-canary.201
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.
- package/AGENTS.md +57 -0
- package/dist/index.d.ts +2184 -54
- package/dist/index.js +11515 -2399
- package/dist/otel/index.d.ts +2 -2
- package/dist/otel/index.js +6 -6
- package/dist/sqlite-open.js +276 -0
- package/dist/sqlite-worker.d.ts +1 -0
- package/dist/sqlite-worker.js +421 -0
- package/dist/tabs-broker-worker.d.ts +8 -0
- package/dist/tabs-broker-worker.js +434 -0
- package/dist/types.d.ts +688 -11
- package/package.json +11 -7
- package/scripts/check-broker-bundle.mjs +33 -0
- package/skills/{spooky-core → sp00ky-core}/SKILL.md +12 -12
- package/skills/{spooky-core → sp00ky-core}/references/auth.md +1 -1
- package/skills/{spooky-core → sp00ky-core}/references/config.md +2 -2
- package/src/bucket-blurhash.test.ts +148 -0
- package/src/build-globals.d.ts +12 -0
- package/src/events/events.test.ts +2 -1
- package/src/events/index.ts +3 -0
- package/src/index.ts +35 -2
- package/src/modules/app-release/index.test.ts +125 -0
- package/src/modules/app-release/index.ts +201 -0
- package/src/modules/auth/events/index.ts +2 -1
- package/src/modules/auth/index.ts +59 -20
- package/src/modules/cache/index.ts +112 -32
- package/src/modules/cache/types.ts +2 -2
- package/src/modules/crdt/crdt-field.ts +294 -0
- package/src/modules/crdt/crdt-hydration.test.ts +210 -0
- package/src/modules/crdt/crdt-reconnect.test.ts +195 -0
- package/src/modules/crdt/index.ts +463 -0
- package/src/modules/crdt/loro-loader.ts +25 -0
- package/src/modules/data/data.hydration.test.ts +142 -0
- package/src/modules/data/data.membership.test.ts +462 -0
- package/src/modules/data/data.rebind.test.ts +147 -0
- package/src/modules/data/data.run.test.ts +113 -0
- package/src/modules/data/data.settled-writes.test.ts +206 -0
- package/src/modules/data/data.status.test.ts +249 -0
- package/src/modules/data/id-set-plan.test.ts +122 -0
- package/src/modules/data/index.ts +1580 -130
- package/src/modules/data/mutation-id.test.ts +25 -0
- package/src/modules/data/mutation-id.ts +35 -0
- package/src/modules/data/window-query.test.ts +52 -0
- package/src/modules/data/window-query.ts +194 -0
- package/src/modules/devtools/flags.ts +349 -0
- package/src/modules/devtools/index.ts +386 -37
- package/src/modules/devtools/notify-throttle.test.ts +149 -0
- package/src/modules/devtools/storage-info.test.ts +79 -0
- package/src/modules/devtools/storage-info.ts +168 -0
- package/src/modules/devtools/versions.test.ts +74 -0
- package/src/modules/devtools/versions.ts +110 -0
- package/src/modules/feature-flag/index.test.ts +251 -0
- package/src/modules/feature-flag/index.ts +308 -0
- package/src/modules/ref-tables.test.ts +91 -0
- package/src/modules/ref-tables.ts +88 -0
- package/src/modules/sync/engine.ts +101 -37
- package/src/modules/sync/events/index.ts +9 -2
- package/src/modules/sync/queue/queue-down.test.ts +107 -0
- package/src/modules/sync/queue/queue-down.ts +35 -6
- package/src/modules/sync/queue/queue-up.forwarded.test.ts +164 -0
- package/src/modules/sync/queue/queue-up.ts +241 -57
- package/src/modules/sync/scheduler.pause.test.ts +109 -0
- package/src/modules/sync/scheduler.retry.test.ts +156 -0
- package/src/modules/sync/scheduler.ts +158 -11
- package/src/modules/sync/sync.cleanup.test.ts +116 -0
- package/src/modules/sync/sync.health.test.ts +149 -0
- package/src/modules/sync/sync.heartbeat.test.ts +80 -0
- package/src/modules/sync/sync.live-removal.test.ts +134 -0
- package/src/modules/sync/sync.reconnect.test.ts +145 -0
- package/src/modules/sync/sync.subquery.test.ts +82 -0
- package/src/modules/sync/sync.ts +1558 -99
- package/src/modules/sync/utils.test.ts +269 -2
- package/src/modules/sync/utils.ts +201 -17
- package/src/otel/index.ts +13 -10
- package/src/services/blobs/blob-cache.test.ts +359 -0
- package/src/services/blobs/blob-cache.ts +603 -0
- package/src/services/blobs/blob-manifest.ts +227 -0
- package/src/services/blobs/blob-store.test.ts +77 -0
- package/src/services/blobs/blob-store.ts +359 -0
- package/src/services/blobs/blob.fixture.ts +90 -0
- package/src/services/blobs/index.ts +70 -0
- package/src/services/database/cache-engine.ts +160 -0
- package/src/services/database/connection-supervisor.test.ts +289 -0
- package/src/services/database/connection-supervisor.ts +415 -0
- package/src/services/database/database.query-timeout.test.ts +83 -0
- package/src/services/database/database.ts +32 -12
- package/src/services/database/engine-factory.ts +33 -0
- package/src/services/database/events/index.ts +2 -1
- package/src/services/database/index.ts +7 -0
- package/src/services/database/local-migrator.ts +30 -27
- package/src/services/database/local.test.ts +64 -0
- package/src/services/database/local.ts +478 -67
- package/src/services/database/plan-render.test.ts +159 -0
- package/src/services/database/plan-render.ts +108 -0
- package/src/services/database/relation-resolver.test.ts +413 -0
- package/src/services/database/relation-resolver.ts +0 -0
- package/src/services/database/remote.ts +110 -14
- package/src/services/database/sqlite-cache-engine.test.ts +558 -0
- package/src/services/database/sqlite-cache-engine.ts +1257 -0
- package/src/services/database/sqlite-devtools-queries.integration.test.ts +143 -0
- package/src/services/database/sqlite-devtools-queries.test.ts +154 -0
- package/src/services/database/sqlite-open.test.ts +150 -0
- package/src/services/database/sqlite-open.ts +164 -0
- package/src/services/database/sqlite-plan-sql.test.ts +104 -0
- package/src/services/database/sqlite-plan-sql.ts +106 -0
- package/src/services/database/sqlite-select.integration.test.ts +185 -0
- package/src/services/database/sqlite-select.test.ts +246 -0
- package/src/services/database/sqlite-select.ts +121 -0
- package/src/services/database/sqlite-transport.fixture.ts +30 -0
- package/src/services/database/sqlite-transport.ts +221 -0
- package/src/services/database/sqlite-worker.ts +437 -0
- package/src/services/database/surql-translate.ts +416 -0
- package/src/services/database/surreal-cache-engine.ts +141 -0
- package/src/services/logger/index.ts +3 -2
- package/src/services/persistence/localstorage.ts +2 -2
- package/src/services/persistence/resilient.ts +11 -4
- package/src/services/persistence/surrealdb.ts +10 -10
- package/src/services/stream-processor/index.ts +444 -52
- package/src/services/stream-processor/permissions.test.ts +47 -0
- package/src/services/stream-processor/permissions.ts +53 -0
- package/src/services/stream-processor/stream-processor.batch.test.ts +136 -0
- package/src/services/stream-processor/stream-processor.reset.test.ts +216 -0
- package/src/services/stream-processor/stream-processor.test.ts +1 -1
- package/src/services/stream-processor/wasm-types.ts +23 -2
- package/src/services/tabs/broker-client.ts +283 -0
- package/src/services/tabs/broker.test.ts +278 -0
- package/src/services/tabs/coordinator.test.ts +244 -0
- package/src/services/tabs/coordinator.ts +576 -0
- package/src/services/tabs/fake-ports.fixture.ts +112 -0
- package/src/services/tabs/leader-locks.ts +75 -0
- package/src/services/tabs/protocol.ts +242 -0
- package/src/services/tabs/support.ts +36 -0
- package/src/services/tabs/tabs-broker-worker.ts +586 -0
- package/src/sp00ky.auth-order.test.ts +92 -0
- package/src/sp00ky.init-query.test.ts +183 -0
- package/src/sp00ky.ts +1543 -0
- package/src/types.ts +496 -13
- package/src/utils/blurhash.ts +90 -0
- package/src/utils/error-classification.test.ts +44 -0
- package/src/utils/error-classification.ts +7 -0
- package/src/utils/index.ts +73 -13
- package/src/utils/parser.ts +3 -2
- package/src/utils/semver.test.ts +32 -0
- package/src/utils/semver.ts +30 -0
- package/src/utils/surql.ts +30 -18
- package/src/utils/withRetry.test.ts +1 -1
- package/tsdown.config.ts +86 -1
- package/src/spooky.ts +0 -395
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
1
|
+
// oxlint-disable-next-line no-named-as-default -- WASM module default export convention
|
|
2
|
+
import init, { Sp00kyProcessor } from '@spooky-sync/ssp-wasm';
|
|
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 { PersistenceClient, QueryTimeToLive, RecordVersionArray } from '../../types';
|
|
8
9
|
|
|
9
10
|
// Simple interface for query plan registration (replaces Incantation class)
|
|
10
11
|
interface QueryPlanConfig {
|
|
@@ -27,6 +28,21 @@ export interface StreamUpdate {
|
|
|
27
28
|
queryHash: string;
|
|
28
29
|
localArray: RecordVersionArray;
|
|
29
30
|
op?: 'CREATE' | 'UPDATE' | 'DELETE'; // Operation type for conditional debouncing
|
|
31
|
+
/**
|
|
32
|
+
* End-to-end ingest latency for the WASM call that produced this update,
|
|
33
|
+
* in milliseconds. Populated by StreamProcessorService.ingest. Undefined
|
|
34
|
+
* for the initial register_view snapshot.
|
|
35
|
+
*/
|
|
36
|
+
materializationTimeMs?: number;
|
|
37
|
+
/** SSP internal sub-phase timings (ms) for this ingest, from the WASM binding. */
|
|
38
|
+
storeApplyMs?: number;
|
|
39
|
+
circuitStepMs?: number;
|
|
40
|
+
transformMs?: number;
|
|
41
|
+
/**
|
|
42
|
+
* One-shot registration timings (ms). Only set on the StreamUpdate returned
|
|
43
|
+
* by `registerQueryPlan` (the register_view snapshot), not on ingest updates.
|
|
44
|
+
*/
|
|
45
|
+
registration?: { parseMs: number; planMs: number; snapshotMs: number };
|
|
30
46
|
}
|
|
31
47
|
|
|
32
48
|
// Define events map (kept for DevTools compatibility)
|
|
@@ -41,15 +57,83 @@ export interface StreamUpdateReceiver {
|
|
|
41
57
|
onStreamUpdate(update: StreamUpdate): void;
|
|
42
58
|
}
|
|
43
59
|
|
|
60
|
+
/**
|
|
61
|
+
* Read a circuit snapshot out of the pre-`persistCircuit` persisted shape
|
|
62
|
+
* (`[[{ state }]]`, a raw SurrealDB result). Returns null for anything else.
|
|
63
|
+
*/
|
|
64
|
+
function extractLegacyState(result: unknown): string | null {
|
|
65
|
+
if (
|
|
66
|
+
Array.isArray(result) &&
|
|
67
|
+
Array.isArray(result[0]) &&
|
|
68
|
+
typeof result[0][0]?.state === 'string'
|
|
69
|
+
) {
|
|
70
|
+
return result[0][0].state;
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
44
75
|
export class StreamProcessorService {
|
|
45
76
|
private logger: Logger;
|
|
46
77
|
private processor: WasmProcessor | undefined;
|
|
47
78
|
private isInitialized = false;
|
|
48
79
|
private receivers: StreamUpdateReceiver[] = [];
|
|
80
|
+
// When true, `notifyUpdates` coalesces updates into `batchBuffer` (keyed by
|
|
81
|
+
// queryHash) instead of dispatching them. Used to collapse the per-record
|
|
82
|
+
// stream updates produced by a batched ingest into a single notification per
|
|
83
|
+
// query, so the UI updates once after the whole batch rather than row-by-row.
|
|
84
|
+
private batching = false;
|
|
85
|
+
private batchBuffer: Map<string, StreamUpdate> = new Map();
|
|
86
|
+
// Current session's auth identity, injected into every `register_view`'s
|
|
87
|
+
// params so the in-browser SSP can resolve `$auth`/`$access` in table
|
|
88
|
+
// permission predicates (mirrors the server's `fn::query::register`). Empty
|
|
89
|
+
// strings when logged out — a non-null `auth` keeps `permission_inject` from
|
|
90
|
+
// rejecting `$auth`-gated tables; the predicate just degrades to its public
|
|
91
|
+
// branch. Set via `setSessionAuth` on every auth state change.
|
|
92
|
+
private sessionAuth: { authId: string; access: string } = { authId: '', access: '' };
|
|
93
|
+
// Persisted-state key suffix (the local bucket id) so each user's circuit
|
|
94
|
+
// snapshot lands in their own key. Only matters for localStorage-backed
|
|
95
|
+
// persistence — the surrealdb persistence client already writes into the
|
|
96
|
+
// per-user bucket itself.
|
|
97
|
+
private stateKeySuffix = '';
|
|
98
|
+
// Bumped by `reset()`. A `saveState` captured before a reset must not persist
|
|
99
|
+
// the OLD processor's circuit (holding the previous user's rows) after the
|
|
100
|
+
// switch — the fire-and-forget calls in `ingest`/`flushCoalescing` can land
|
|
101
|
+
// late.
|
|
102
|
+
private stateGeneration = 0;
|
|
103
|
+
// Shared-tabs: follower circuits are in-memory ONLY. Persisting them would
|
|
104
|
+
// stomp the leader's snapshot under the same key (the pre-existing cross-tab
|
|
105
|
+
// localStorage hazard); a promoted follower flips this back on.
|
|
106
|
+
private persistState = true;
|
|
107
|
+
// Snapshot persistence is OPT-IN and off by default (`persistCircuit`).
|
|
108
|
+
//
|
|
109
|
+
// `Circuit::save` deep-clones the WHOLE store (every row of every ingested
|
|
110
|
+
// table, full bodies) plus every view cache and JSON-encodes the result. It
|
|
111
|
+
// used to run from `ingest`, `flushCoalescing`, `registerQueryPlan` and
|
|
112
|
+
// `unregisterQueryPlan`, i.e. once per sync batch AND once per query
|
|
113
|
+
// register/unregister. On a 3.7k-game collection whose list registers and
|
|
114
|
+
// drops one windowed query per 50 rows scrolled, that measured 220 whole-store
|
|
115
|
+
// serializations and ~1 GB of transient JSON for a single scroll, 5.5x the
|
|
116
|
+
// wall time of the same ingests without it, and it doubled the wasm heap
|
|
117
|
+
// high-water mark (wasm32 dlmalloc never returns pages, so the peak is
|
|
118
|
+
// permanent). It also ran the encode a second time in
|
|
119
|
+
// `LocalStoragePersistenceClient.set` and wrote it synchronously on the main
|
|
120
|
+
// thread. All of it was dead weight: `loadState`'s shape check could never
|
|
121
|
+
// match what either shipped persistence client returns, so the browser never
|
|
122
|
+
// restored a snapshot, and first paint comes from the local SQLite store
|
|
123
|
+
// anyway.
|
|
124
|
+
//
|
|
125
|
+
// When enabled, we mark the circuit dirty and let a checkpoint timer (plus a
|
|
126
|
+
// `pagehide` flush) do at most one snapshot per interval, mirroring
|
|
127
|
+
// `ssp-node`'s "NEVER per-ingest" rule.
|
|
128
|
+
private persistCircuit = false;
|
|
129
|
+
private checkpointMs = 30_000;
|
|
130
|
+
private checkpointTimer: ReturnType<typeof setInterval> | null = null;
|
|
131
|
+
private snapshotDirty = false;
|
|
132
|
+
private pagehideHandler: (() => void) | null = null;
|
|
49
133
|
|
|
50
134
|
constructor(
|
|
51
135
|
public events: EventSystem<StreamProcessorEvents>,
|
|
52
|
-
private db:
|
|
136
|
+
private db: LocalStore,
|
|
53
137
|
private persistenceClient: PersistenceClient,
|
|
54
138
|
logger: Logger
|
|
55
139
|
) {
|
|
@@ -65,6 +149,32 @@ export class StreamProcessorService {
|
|
|
65
149
|
}
|
|
66
150
|
|
|
67
151
|
private notifyUpdates(updates: StreamUpdate[]) {
|
|
152
|
+
if (this.batching) {
|
|
153
|
+
// Coalesce by queryHash instead of dispatching. The WASM `result_data`
|
|
154
|
+
// (localArray) is the full materialized array, so last-write-wins
|
|
155
|
+
// already reflects every prior ingest in the batch. We sum the
|
|
156
|
+
// materialization times so the single recorded sample reflects the
|
|
157
|
+
// batch's total work, and emit `op: 'CREATE'` on flush so the coalesced
|
|
158
|
+
// update takes DataModule's immediate (non-debounced) path.
|
|
159
|
+
for (const update of updates) {
|
|
160
|
+
const prev = this.batchBuffer.get(update.queryHash);
|
|
161
|
+
const sum = (a?: number, b?: number) => (a ?? 0) + (b ?? 0);
|
|
162
|
+
this.batchBuffer.set(update.queryHash, {
|
|
163
|
+
...update,
|
|
164
|
+
op: 'CREATE',
|
|
165
|
+
materializationTimeMs: sum(prev?.materializationTimeMs, update.materializationTimeMs),
|
|
166
|
+
storeApplyMs: sum(prev?.storeApplyMs, update.storeApplyMs),
|
|
167
|
+
circuitStepMs: sum(prev?.circuitStepMs, update.circuitStepMs),
|
|
168
|
+
transformMs: sum(prev?.transformMs, update.transformMs),
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
this.dispatchUpdates(updates);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
private dispatchUpdates(updates: StreamUpdate[]) {
|
|
68
178
|
for (const update of updates) {
|
|
69
179
|
for (const receiver of this.receivers) {
|
|
70
180
|
receiver.onStreamUpdate(update);
|
|
@@ -72,6 +182,69 @@ export class StreamProcessorService {
|
|
|
72
182
|
}
|
|
73
183
|
}
|
|
74
184
|
|
|
185
|
+
/**
|
|
186
|
+
* Ingest a batch of record changes as a single bulk operation, firing only
|
|
187
|
+
* one coalesced `StreamUpdate` per affected query once every record has been
|
|
188
|
+
* ingested (instead of one update per record). Use this whenever multiple
|
|
189
|
+
* records land at once — e.g. sync fetching N missing rows — so a list query
|
|
190
|
+
* re-runs and the UI re-renders once for the whole batch rather than
|
|
191
|
+
* row-by-row.
|
|
192
|
+
*
|
|
193
|
+
* Internally opens a coalescing window, ingests each record, then flushes;
|
|
194
|
+
* processor state is persisted once for the whole batch. No-op for an empty
|
|
195
|
+
* batch.
|
|
196
|
+
*/
|
|
197
|
+
ingestMany(
|
|
198
|
+
records: Array<{
|
|
199
|
+
table: string;
|
|
200
|
+
op: 'CREATE' | 'UPDATE' | 'DELETE';
|
|
201
|
+
id: string;
|
|
202
|
+
record: any;
|
|
203
|
+
}>
|
|
204
|
+
): void {
|
|
205
|
+
if (records.length === 0) return;
|
|
206
|
+
|
|
207
|
+
this.beginCoalescing();
|
|
208
|
+
try {
|
|
209
|
+
for (const record of records) {
|
|
210
|
+
this.ingest(record.table, record.op, record.id, record.record);
|
|
211
|
+
}
|
|
212
|
+
} finally {
|
|
213
|
+
this.flushCoalescing();
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Open a coalescing window. While open, the per-record stream updates
|
|
219
|
+
* emitted by `ingest` are buffered (one entry per queryHash) instead of
|
|
220
|
+
* dispatched. Always paired with `flushCoalescing()` in a try/finally by
|
|
221
|
+
* `ingestMany` so the window always closes — otherwise the processor stays
|
|
222
|
+
* stuck buffering forever.
|
|
223
|
+
*
|
|
224
|
+
* No-op if a window is already open (nested batches aren't expected here).
|
|
225
|
+
*/
|
|
226
|
+
private beginCoalescing() {
|
|
227
|
+
if (this.batching) return;
|
|
228
|
+
this.batching = true;
|
|
229
|
+
this.batchBuffer.clear();
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Close the coalescing window and flush: dispatch one coalesced
|
|
234
|
+
* `StreamUpdate` per buffered queryHash, then persist processor state once
|
|
235
|
+
* for the whole batch (instead of once per ingest).
|
|
236
|
+
*/
|
|
237
|
+
private flushCoalescing() {
|
|
238
|
+
if (!this.batching) return;
|
|
239
|
+
this.batching = false;
|
|
240
|
+
const buffered = Array.from(this.batchBuffer.values());
|
|
241
|
+
this.batchBuffer.clear();
|
|
242
|
+
if (buffered.length > 0) {
|
|
243
|
+
this.dispatchUpdates(buffered);
|
|
244
|
+
}
|
|
245
|
+
this.markSnapshotDirty();
|
|
246
|
+
}
|
|
247
|
+
|
|
75
248
|
/**
|
|
76
249
|
* Initialize the WASM module and processor.
|
|
77
250
|
* This must be called before using other methods.
|
|
@@ -80,93 +253,268 @@ export class StreamProcessorService {
|
|
|
80
253
|
if (this.isInitialized) return;
|
|
81
254
|
|
|
82
255
|
this.logger.info(
|
|
83
|
-
{ Category: '
|
|
256
|
+
{ Category: 'sp00ky-client::StreamProcessorService::init' },
|
|
84
257
|
'Initializing WASM...'
|
|
85
258
|
);
|
|
86
259
|
try {
|
|
87
260
|
await init(); // Initialize the WASM module (web target)
|
|
88
|
-
// We cast the generated
|
|
89
|
-
this.processor = new
|
|
261
|
+
// We cast the generated Sp00kyProcessor to our interface which is safer
|
|
262
|
+
this.processor = new Sp00kyProcessor() as unknown as WasmProcessor;
|
|
90
263
|
|
|
91
264
|
// Try to load state
|
|
92
265
|
await this.loadState();
|
|
93
266
|
|
|
94
267
|
this.isInitialized = true;
|
|
95
268
|
this.logger.info(
|
|
96
|
-
{ Category: '
|
|
269
|
+
{ Category: 'sp00ky-client::StreamProcessorService::init' },
|
|
97
270
|
'Initialized successfully'
|
|
98
271
|
);
|
|
99
272
|
} catch (e) {
|
|
100
273
|
this.logger.error(
|
|
101
|
-
{ error: e, Category: '
|
|
274
|
+
{ error: e, Category: 'sp00ky-client::StreamProcessorService::init' },
|
|
102
275
|
'Failed to initialize'
|
|
103
276
|
);
|
|
104
277
|
throw e;
|
|
105
278
|
}
|
|
106
279
|
}
|
|
107
280
|
|
|
108
|
-
|
|
109
|
-
|
|
281
|
+
/** Route the persisted circuit snapshot to a per-bucket key. */
|
|
282
|
+
setStateKeySuffix(bucketId: string) {
|
|
283
|
+
this.stateKeySuffix = bucketId;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
private stateKey(): string {
|
|
287
|
+
return this.stateKeySuffix
|
|
288
|
+
? `_00_stream_processor_state:${this.stateKeySuffix}`
|
|
289
|
+
: '_00_stream_processor_state';
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Drop the current WASM processor and start a fresh, empty circuit. Used on
|
|
294
|
+
* local-bucket switches: the old circuit holds the previous user's rows AND
|
|
295
|
+
* views registered with the previous `$auth` context, so neither may survive.
|
|
296
|
+
* Deliberately does NOT `loadState()` — a persisted snapshot references views
|
|
297
|
+
* under a dead sessionId salt; the DataModule rebind re-registers every live
|
|
298
|
+
* view against this fresh processor. Caller must re-seed `setPermissions`
|
|
299
|
+
* afterwards (a fresh circuit default-denies every table).
|
|
300
|
+
*/
|
|
301
|
+
async reset(): Promise<void> {
|
|
302
|
+
if (!this.isInitialized) return;
|
|
303
|
+
this.stateGeneration++;
|
|
304
|
+
this.batching = false;
|
|
305
|
+
this.batchBuffer.clear();
|
|
306
|
+
this.snapshotDirty = false;
|
|
307
|
+
const previous = this.processor;
|
|
308
|
+
this.processor = new Sp00kyProcessor() as unknown as WasmProcessor;
|
|
309
|
+
this.freeProcessor(previous);
|
|
310
|
+
this.logger.info(
|
|
311
|
+
{ Category: 'sp00ky-client::StreamProcessorService::reset' },
|
|
312
|
+
'Stream processor reset (fresh circuit)'
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Release the wasm circuit and stop checkpointing. Call when the client is
|
|
318
|
+
* torn down; a recreated client (provider remount, HMR) would otherwise stack
|
|
319
|
+
* one full circuit per instance.
|
|
320
|
+
*/
|
|
321
|
+
dispose(): void {
|
|
322
|
+
this.stopCheckpoints();
|
|
323
|
+
const previous = this.processor;
|
|
324
|
+
this.processor = undefined;
|
|
325
|
+
this.isInitialized = false;
|
|
326
|
+
this.batching = false;
|
|
327
|
+
this.batchBuffer.clear();
|
|
328
|
+
this.receivers = [];
|
|
329
|
+
this.freeProcessor(previous);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Explicitly run the wasm-bindgen destructor. Guarded: stale wasm builds may
|
|
334
|
+
* not expose `free`, and a double free must not take the app down.
|
|
335
|
+
*/
|
|
336
|
+
private freeProcessor(processor: WasmProcessor | undefined): void {
|
|
337
|
+
if (!processor || typeof processor.free !== 'function') return;
|
|
110
338
|
try {
|
|
111
|
-
|
|
339
|
+
processor.free();
|
|
340
|
+
} catch (e) {
|
|
341
|
+
this.logger.debug(
|
|
342
|
+
{ error: e, Category: 'sp00ky-client::StreamProcessorService::freeProcessor' },
|
|
343
|
+
'Failed to free previous wasm circuit'
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
112
347
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
348
|
+
/** Toggle circuit-state persistence (shared-tabs follower/leader role). */
|
|
349
|
+
setPersistenceEnabled(enabled: boolean): void {
|
|
350
|
+
this.persistState = enabled;
|
|
351
|
+
if (!enabled) this.stopCheckpoints();
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Opt into snapshot persistence (`persistCircuit`). Off by default: see the
|
|
356
|
+
* `persistCircuit` field comment for why per-ingest snapshots were removed.
|
|
357
|
+
* Must be called before `init()` for a snapshot to be restored at boot.
|
|
358
|
+
*/
|
|
359
|
+
configureCircuitPersistence(enabled: boolean, checkpointMs?: number): void {
|
|
360
|
+
this.persistCircuit = enabled;
|
|
361
|
+
if (checkpointMs && checkpointMs > 0) this.checkpointMs = checkpointMs;
|
|
362
|
+
if (!enabled) this.stopCheckpoints();
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Record that the circuit changed. Cheap and O(1), the expensive snapshot is
|
|
367
|
+
* deferred to the checkpoint timer, and skipped entirely when
|
|
368
|
+
* `persistCircuit` is off (the default).
|
|
369
|
+
*/
|
|
370
|
+
private markSnapshotDirty(): void {
|
|
371
|
+
if (!this.persistCircuit || !this.persistState) return;
|
|
372
|
+
this.snapshotDirty = true;
|
|
373
|
+
this.startCheckpoints();
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
private startCheckpoints(): void {
|
|
377
|
+
if (this.checkpointTimer) return;
|
|
378
|
+
this.checkpointTimer = setInterval(() => {
|
|
379
|
+
if (!this.snapshotDirty) return;
|
|
380
|
+
this.snapshotDirty = false;
|
|
381
|
+
void this.saveState();
|
|
382
|
+
}, this.checkpointMs);
|
|
383
|
+
// Node/test environments have no `window`; the interval alone is enough there.
|
|
384
|
+
if (typeof window !== 'undefined' && !this.pagehideHandler) {
|
|
385
|
+
this.pagehideHandler = () => {
|
|
386
|
+
if (!this.snapshotDirty) return;
|
|
387
|
+
this.snapshotDirty = false;
|
|
388
|
+
void this.saveState();
|
|
389
|
+
};
|
|
390
|
+
window.addEventListener('pagehide', this.pagehideHandler);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** Stop checkpointing and drop the `pagehide` listener. */
|
|
395
|
+
stopCheckpoints(): void {
|
|
396
|
+
if (this.checkpointTimer) {
|
|
397
|
+
clearInterval(this.checkpointTimer);
|
|
398
|
+
this.checkpointTimer = null;
|
|
399
|
+
}
|
|
400
|
+
if (this.pagehideHandler && typeof window !== 'undefined') {
|
|
401
|
+
window.removeEventListener('pagehide', this.pagehideHandler);
|
|
402
|
+
}
|
|
403
|
+
this.pagehideHandler = null;
|
|
404
|
+
this.snapshotDirty = false;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
async loadState() {
|
|
408
|
+
if (!this.processor || !this.persistState || !this.persistCircuit) return;
|
|
409
|
+
try {
|
|
410
|
+
const result = await this.persistenceClient.get(this.stateKey());
|
|
411
|
+
|
|
412
|
+
// `save_state` returns a JSON string and every PersistenceClient round-trips
|
|
413
|
+
// it as one: localStorage via JSON.parse(getItem(...)), surrealdb via
|
|
414
|
+
// `_00_kv:<key>.val`. This used to test for a raw SurrealDB result shape
|
|
415
|
+
// (`result[0][0].state`), which no shipped client can ever produce, so the
|
|
416
|
+
// browser wrote a snapshot on every ingest and never restored one. The legacy
|
|
417
|
+
// shape is still accepted so an old persisted row still loads.
|
|
418
|
+
const state = typeof result === 'string' ? result : extractLegacyState(result);
|
|
419
|
+
if (state) {
|
|
122
420
|
this.logger.info(
|
|
123
421
|
{
|
|
124
422
|
stateLength: state.length,
|
|
125
|
-
Category: '
|
|
423
|
+
Category: 'sp00ky-client::StreamProcessorService::loadState',
|
|
126
424
|
},
|
|
127
425
|
'Loading state from DB'
|
|
128
426
|
);
|
|
129
427
|
// Assuming processor has a load_state method matching the save_state behavior
|
|
130
428
|
// If not, we might need to adjust based on the actual WASM API
|
|
131
|
-
if (typeof
|
|
132
|
-
|
|
429
|
+
if (typeof this.processor.load_state === 'function') {
|
|
430
|
+
this.processor.load_state(state);
|
|
133
431
|
} else {
|
|
134
432
|
this.logger.warn(
|
|
135
|
-
{ Category: '
|
|
433
|
+
{ Category: 'sp00ky-client::StreamProcessorService::loadState' },
|
|
136
434
|
'load_state method not found on processor'
|
|
137
435
|
);
|
|
138
436
|
}
|
|
139
437
|
} else {
|
|
140
438
|
this.logger.info(
|
|
141
|
-
{ Category: '
|
|
439
|
+
{ Category: 'sp00ky-client::StreamProcessorService::loadState' },
|
|
142
440
|
'No saved state found'
|
|
143
441
|
);
|
|
144
442
|
}
|
|
145
443
|
} catch (e) {
|
|
146
444
|
this.logger.error(
|
|
147
|
-
{ error: e, Category: '
|
|
445
|
+
{ error: e, Category: 'sp00ky-client::StreamProcessorService::loadState' },
|
|
148
446
|
'Failed to load state'
|
|
149
447
|
);
|
|
150
448
|
}
|
|
151
449
|
}
|
|
152
450
|
|
|
153
|
-
|
|
451
|
+
/**
|
|
452
|
+
* Seed per-table `select` permission predicates ({ [table]: whereText }).
|
|
453
|
+
* Must run after the processor exists and before any `register_view`, else
|
|
454
|
+
* non-`_00_` tables are default-denied and registration fails.
|
|
455
|
+
*/
|
|
456
|
+
setPermissions(permissions: Record<string, string>) {
|
|
154
457
|
if (!this.processor) return;
|
|
458
|
+
if (typeof this.processor.set_permissions !== 'function') {
|
|
459
|
+
this.logger.warn(
|
|
460
|
+
{ Category: 'sp00ky-client::StreamProcessorService::setPermissions' },
|
|
461
|
+
'set_permissions not found on processor (stale WASM build?)'
|
|
462
|
+
);
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
this.processor.set_permissions(permissions);
|
|
466
|
+
this.logger.info(
|
|
467
|
+
{
|
|
468
|
+
tables: Object.keys(permissions).length,
|
|
469
|
+
Category: 'sp00ky-client::StreamProcessorService::setPermissions',
|
|
470
|
+
},
|
|
471
|
+
'Seeded table permissions'
|
|
472
|
+
);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* Set the current session's auth identity for permission injection,
|
|
477
|
+
* mirroring the server's `fn::query::register`
|
|
478
|
+
* (`object::extend(params, { auth: { id: $auth.id }, access: $access })`).
|
|
479
|
+
* Stored as strings (empty when logged out) and applied to every
|
|
480
|
+
* `register_view` in {@link registerQueryPlan}. Must be set before a
|
|
481
|
+
* `$auth`-gated query registers (and re-set on auth state changes), or the
|
|
482
|
+
* in-browser SSP's `permission_inject` rejects it with
|
|
483
|
+
* "requires $auth but registration params lack it".
|
|
484
|
+
*/
|
|
485
|
+
setSessionAuth(authId: string | null, access: string | null) {
|
|
486
|
+
this.sessionAuth = { authId: authId ?? '', access: access ?? '' };
|
|
487
|
+
this.logger.debug(
|
|
488
|
+
{
|
|
489
|
+
authId: this.sessionAuth.authId,
|
|
490
|
+
access: this.sessionAuth.access,
|
|
491
|
+
Category: 'sp00ky-client::StreamProcessorService::setSessionAuth',
|
|
492
|
+
},
|
|
493
|
+
'Session auth context updated'
|
|
494
|
+
);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
async saveState() {
|
|
498
|
+
if (!this.processor || !this.persistState || !this.persistCircuit) return;
|
|
499
|
+
const generation = this.stateGeneration;
|
|
155
500
|
try {
|
|
156
501
|
// Assuming processor has a save_state method that returns the state string/bytes
|
|
157
|
-
if (typeof
|
|
158
|
-
const state =
|
|
502
|
+
if (typeof this.processor.save_state === 'function') {
|
|
503
|
+
const state = this.processor.save_state();
|
|
504
|
+
// A reset raced this snapshot — persisting it would write the previous
|
|
505
|
+
// user's circuit into the new bucket's key. Drop it.
|
|
506
|
+
if (generation !== this.stateGeneration) return;
|
|
159
507
|
if (state) {
|
|
160
|
-
await this.persistenceClient.set(
|
|
508
|
+
await this.persistenceClient.set(this.stateKey(), state);
|
|
161
509
|
this.logger.trace(
|
|
162
|
-
{ Category: '
|
|
510
|
+
{ Category: 'sp00ky-client::StreamProcessorService::saveState' },
|
|
163
511
|
'State saved'
|
|
164
512
|
);
|
|
165
513
|
}
|
|
166
514
|
}
|
|
167
515
|
} catch (e) {
|
|
168
516
|
this.logger.error(
|
|
169
|
-
{ error: e, Category: '
|
|
517
|
+
{ error: e, Category: 'sp00ky-client::StreamProcessorService::saveState' },
|
|
170
518
|
'Failed to save state'
|
|
171
519
|
);
|
|
172
520
|
}
|
|
@@ -188,14 +536,14 @@ export class StreamProcessorService {
|
|
|
188
536
|
table,
|
|
189
537
|
op,
|
|
190
538
|
id,
|
|
191
|
-
Category: '
|
|
539
|
+
Category: 'sp00ky-client::StreamProcessorService::ingest',
|
|
192
540
|
},
|
|
193
541
|
'Ingesting into ssp'
|
|
194
542
|
);
|
|
195
543
|
|
|
196
544
|
if (!this.processor) {
|
|
197
545
|
this.logger.warn(
|
|
198
|
-
{ Category: '
|
|
546
|
+
{ Category: 'sp00ky-client::StreamProcessorService::ingest' },
|
|
199
547
|
'Not initialized, skipping ingest'
|
|
200
548
|
);
|
|
201
549
|
return [];
|
|
@@ -204,14 +552,17 @@ export class StreamProcessorService {
|
|
|
204
552
|
try {
|
|
205
553
|
const normalizedRecord = this.normalizeValue(record);
|
|
206
554
|
|
|
555
|
+
const t0 = performance.now();
|
|
207
556
|
const rawUpdates = this.processor.ingest(table, op, id, normalizedRecord);
|
|
557
|
+
const materializationTimeMs = performance.now() - t0;
|
|
208
558
|
this.logger.debug(
|
|
209
559
|
{
|
|
210
560
|
table,
|
|
211
561
|
op,
|
|
212
562
|
id,
|
|
213
563
|
rawUpdates: rawUpdates.length,
|
|
214
|
-
|
|
564
|
+
materializationTimeMs,
|
|
565
|
+
Category: 'sp00ky-client::StreamProcessorService::ingest',
|
|
215
566
|
},
|
|
216
567
|
'Ingesting into ssp done'
|
|
217
568
|
);
|
|
@@ -221,15 +572,23 @@ export class StreamProcessorService {
|
|
|
221
572
|
queryHash: u.query_id,
|
|
222
573
|
localArray: u.result_data,
|
|
223
574
|
op: op,
|
|
575
|
+
materializationTimeMs,
|
|
576
|
+
storeApplyMs: u.timing_store_apply_ms,
|
|
577
|
+
circuitStepMs: u.timing_circuit_step_ms,
|
|
578
|
+
transformMs: u.timing_transform_ms,
|
|
224
579
|
}));
|
|
225
580
|
// Direct handler call instead of event
|
|
226
581
|
this.notifyUpdates(updates);
|
|
227
582
|
}
|
|
228
|
-
|
|
583
|
+
// While batching (inside `ingestMany`), `flushCoalescing` marks dirty once
|
|
584
|
+
// for the whole batch, skip the redundant per-record mark here.
|
|
585
|
+
if (!this.batching) {
|
|
586
|
+
this.markSnapshotDirty();
|
|
587
|
+
}
|
|
229
588
|
return rawUpdates;
|
|
230
589
|
} catch (e) {
|
|
231
590
|
this.logger.error(
|
|
232
|
-
{ error: e, Category: '
|
|
591
|
+
{ error: e, Category: 'sp00ky-client::StreamProcessorService::ingest' },
|
|
233
592
|
'Ingesting into ssp failed'
|
|
234
593
|
);
|
|
235
594
|
}
|
|
@@ -243,7 +602,7 @@ export class StreamProcessorService {
|
|
|
243
602
|
registerQueryPlan(queryPlan: QueryPlanConfig) {
|
|
244
603
|
if (!this.processor) {
|
|
245
604
|
this.logger.warn(
|
|
246
|
-
{ Category: '
|
|
605
|
+
{ Category: 'sp00ky-client::StreamProcessorService::registerQueryPlan' },
|
|
247
606
|
'Not initialized, skipping registration'
|
|
248
607
|
);
|
|
249
608
|
return;
|
|
@@ -254,7 +613,7 @@ export class StreamProcessorService {
|
|
|
254
613
|
queryHash: queryPlan.queryHash,
|
|
255
614
|
surql: queryPlan.surql,
|
|
256
615
|
params: queryPlan.params,
|
|
257
|
-
Category: '
|
|
616
|
+
Category: 'sp00ky-client::StreamProcessorService::registerQueryPlan',
|
|
258
617
|
},
|
|
259
618
|
'Registering query plan'
|
|
260
619
|
);
|
|
@@ -262,17 +621,30 @@ export class StreamProcessorService {
|
|
|
262
621
|
try {
|
|
263
622
|
const normalizedParams = this.normalizeValue(queryPlan.params);
|
|
264
623
|
|
|
624
|
+
// Mirror the server's `fn::query::register` auth injection so the
|
|
625
|
+
// in-browser SSP can resolve `$auth`/`$access` in a table's permission
|
|
626
|
+
// predicate. Without this, any `$auth`-gated table (e.g. `thread`) is
|
|
627
|
+
// rejected by `permission_inject` and its local view never materializes.
|
|
628
|
+
// Injected only into the params handed to the in-browser SSP — never into
|
|
629
|
+
// the persisted `queryState.config.params` / query hash / server payload
|
|
630
|
+
// (the server does its own injection), so the view id stays shared.
|
|
631
|
+
const paramsWithAuth = {
|
|
632
|
+
...(normalizedParams as Record<string, unknown>),
|
|
633
|
+
auth: { id: this.sessionAuth.authId },
|
|
634
|
+
access: this.sessionAuth.access,
|
|
635
|
+
};
|
|
636
|
+
|
|
265
637
|
const initialUpdate = this.processor.register_view({
|
|
266
638
|
id: queryPlan.queryHash,
|
|
267
639
|
surql: queryPlan.surql,
|
|
268
|
-
params:
|
|
640
|
+
params: paramsWithAuth,
|
|
269
641
|
clientId: 'local',
|
|
270
642
|
ttl: queryPlan.ttl.toString(),
|
|
271
643
|
lastActiveAt: new Date().toISOString(),
|
|
272
644
|
});
|
|
273
645
|
|
|
274
646
|
this.logger.debug(
|
|
275
|
-
{ initialUpdate, Category: '
|
|
647
|
+
{ initialUpdate, Category: 'sp00ky-client::StreamProcessorService::registerQueryPlan' },
|
|
276
648
|
'register_view result'
|
|
277
649
|
);
|
|
278
650
|
|
|
@@ -282,21 +654,26 @@ export class StreamProcessorService {
|
|
|
282
654
|
const update: StreamUpdate = {
|
|
283
655
|
queryHash: initialUpdate.query_id,
|
|
284
656
|
localArray: initialUpdate.result_data,
|
|
657
|
+
registration: {
|
|
658
|
+
parseMs: initialUpdate.timing_parse_ms ?? 0,
|
|
659
|
+
planMs: initialUpdate.timing_plan_ms ?? 0,
|
|
660
|
+
snapshotMs: initialUpdate.timing_snapshot_ms ?? 0,
|
|
661
|
+
},
|
|
285
662
|
};
|
|
286
|
-
this.
|
|
663
|
+
this.markSnapshotDirty();
|
|
287
664
|
this.logger.debug(
|
|
288
665
|
{
|
|
289
666
|
queryHash: queryPlan.queryHash,
|
|
290
667
|
surql: queryPlan.surql,
|
|
291
668
|
params: queryPlan.params,
|
|
292
|
-
Category: '
|
|
669
|
+
Category: 'sp00ky-client::StreamProcessorService::registerQueryPlan',
|
|
293
670
|
},
|
|
294
671
|
'Registered query plan'
|
|
295
672
|
);
|
|
296
673
|
return update;
|
|
297
674
|
} catch (e) {
|
|
298
675
|
this.logger.error(
|
|
299
|
-
{ error: e, Category: '
|
|
676
|
+
{ error: e, Category: 'sp00ky-client::StreamProcessorService::registerQueryPlan' },
|
|
300
677
|
'Error registering query plan'
|
|
301
678
|
);
|
|
302
679
|
throw e;
|
|
@@ -310,10 +687,10 @@ export class StreamProcessorService {
|
|
|
310
687
|
if (!this.processor) return;
|
|
311
688
|
try {
|
|
312
689
|
this.processor.unregister_view(queryHash);
|
|
313
|
-
this.
|
|
690
|
+
this.markSnapshotDirty();
|
|
314
691
|
} catch (e) {
|
|
315
692
|
this.logger.error(
|
|
316
|
-
{ error: e, Category: '
|
|
693
|
+
{ error: e, Category: 'sp00ky-client::StreamProcessorService::unregisterQueryPlan' },
|
|
317
694
|
'Error unregistering query plan'
|
|
318
695
|
);
|
|
319
696
|
}
|
|
@@ -323,6 +700,21 @@ export class StreamProcessorService {
|
|
|
323
700
|
if (value === null || value === undefined) return value;
|
|
324
701
|
|
|
325
702
|
if (typeof value === 'object') {
|
|
703
|
+
// CRDT snapshots arrive as `Uint8Array` (or `ArrayBuffer` /
|
|
704
|
+
// typed-array views). `serde_wasm_bindgen::from_value` rejects
|
|
705
|
+
// those when deserializing into `serde_json::Value` (JSON has no
|
|
706
|
+
// binary variant), and the SSP can't filter on opaque bytes
|
|
707
|
+
// anyway. Replace with `null` so the row still flows through the
|
|
708
|
+
// ingest path with its other columns intact, and downstream
|
|
709
|
+
// predicates referencing the bytes column simply don't match.
|
|
710
|
+
if (
|
|
711
|
+
value instanceof Uint8Array ||
|
|
712
|
+
value instanceof ArrayBuffer ||
|
|
713
|
+
ArrayBuffer.isView(value)
|
|
714
|
+
) {
|
|
715
|
+
return null;
|
|
716
|
+
}
|
|
717
|
+
|
|
326
718
|
// RecordId detection using duck typing (constructor.name may be minified)
|
|
327
719
|
// SurrealDB's RecordId has: table (getter returning Table), id, and toString()
|
|
328
720
|
// Check for table getter that has its own toString AND id property
|
|
@@ -334,7 +726,7 @@ export class StreamProcessorService {
|
|
|
334
726
|
if (hasTable && hasId && hasToString && isNotPlainObject) {
|
|
335
727
|
const result = value.toString();
|
|
336
728
|
this.logger.trace(
|
|
337
|
-
{ result, Category: '
|
|
729
|
+
{ result, Category: 'sp00ky-client::StreamProcessorService::normalizeValue' },
|
|
338
730
|
'RecordId detected'
|
|
339
731
|
);
|
|
340
732
|
return result;
|