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