@spooky-sync/core 0.0.1-canary.13 → 0.0.1-canary.130
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 +56 -0
- package/dist/index.d.ts +1171 -372
- package/dist/index.js +5716 -1278
- package/dist/otel/index.d.ts +21 -0
- package/dist/otel/index.js +86 -0
- package/dist/sqlite-worker.d.ts +1 -0
- package/dist/sqlite-worker.js +107 -0
- package/dist/types.d.ts +746 -0
- package/package.json +39 -8
- package/skills/sp00ky-core/SKILL.md +258 -0
- package/skills/sp00ky-core/references/auth.md +98 -0
- package/skills/sp00ky-core/references/config.md +76 -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 +9 -2
- package/src/modules/auth/events/index.ts +2 -1
- package/src/modules/auth/index.ts +59 -20
- package/src/modules/cache/index.ts +77 -32
- package/src/modules/cache/types.ts +2 -2
- package/src/modules/crdt/crdt-field.ts +288 -0
- package/src/modules/crdt/crdt-hydration.test.ts +206 -0
- package/src/modules/crdt/index.ts +357 -0
- package/src/modules/data/data.hydration.test.ts +150 -0
- package/src/modules/data/data.rebind.test.ts +147 -0
- package/src/modules/data/data.recurring.test.ts +137 -0
- package/src/modules/data/data.status.test.ts +249 -0
- package/src/modules/data/index.ts +1228 -127
- package/src/modules/data/window-query.test.ts +52 -0
- package/src/modules/data/window-query.ts +154 -0
- package/src/modules/devtools/index.ts +191 -30
- package/src/modules/devtools/versions.test.ts +74 -0
- package/src/modules/devtools/versions.ts +81 -0
- package/src/modules/feature-flag/index.test.ts +120 -0
- package/src/modules/feature-flag/index.ts +209 -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.ts +12 -5
- package/src/modules/sync/queue/queue-up.ts +29 -14
- package/src/modules/sync/scheduler.pause.test.ts +109 -0
- package/src/modules/sync/scheduler.ts +73 -7
- package/src/modules/sync/sync.health.test.ts +149 -0
- package/src/modules/sync/sync.subquery.test.ts +82 -0
- package/src/modules/sync/sync.ts +1017 -62
- package/src/modules/sync/utils.test.ts +269 -2
- package/src/modules/sync/utils.ts +182 -17
- package/src/otel/index.ts +127 -0
- package/src/services/database/cache-engine.ts +143 -0
- package/src/services/database/database.ts +11 -11
- package/src/services/database/engine-factory.ts +32 -0
- package/src/services/database/events/index.ts +2 -1
- package/src/services/database/index.ts +6 -0
- package/src/services/database/local-migrator.ts +28 -27
- package/src/services/database/local.test.ts +64 -0
- package/src/services/database/local.ts +452 -66
- package/src/services/database/plan-render.test.ts +133 -0
- package/src/services/database/plan-render.ts +100 -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 +13 -13
- package/src/services/database/sqlite-cache-engine.test.ts +85 -0
- package/src/services/database/sqlite-cache-engine.ts +699 -0
- package/src/services/database/sqlite-worker.ts +116 -0
- package/src/services/database/surql-translate.ts +291 -0
- package/src/services/database/surreal-cache-engine.ts +122 -0
- package/src/services/logger/index.ts +6 -101
- package/src/services/persistence/localstorage.ts +2 -2
- package/src/services/persistence/resilient.ts +41 -0
- package/src/services/persistence/surrealdb.ts +10 -10
- package/src/services/stream-processor/index.ts +295 -38
- 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 +104 -0
- package/src/services/stream-processor/stream-processor.test.ts +1 -1
- package/src/services/stream-processor/wasm-types.ts +18 -2
- package/src/sp00ky.auth-order.test.ts +92 -0
- package/src/sp00ky.init-query.test.ts +185 -0
- package/src/sp00ky.ts +1016 -0
- package/src/types.ts +258 -15
- package/src/utils/error-classification.test.ts +44 -0
- package/src/utils/error-classification.ts +7 -0
- package/src/utils/index.ts +35 -13
- package/src/utils/parser.ts +3 -2
- package/src/utils/surql.ts +24 -15
- package/src/utils/withRetry.test.ts +1 -1
- package/tsdown.config.ts +77 -1
- package/src/spooky.ts +0 -392
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { Logger } from 'pino';
|
|
2
|
+
import type { PersistenceClient } from '../../types';
|
|
3
|
+
|
|
4
|
+
export class ResilientPersistenceClient implements PersistenceClient {
|
|
5
|
+
private logger: Logger;
|
|
6
|
+
|
|
7
|
+
constructor(
|
|
8
|
+
private inner: PersistenceClient,
|
|
9
|
+
logger: Logger
|
|
10
|
+
) {
|
|
11
|
+
this.logger = logger.child({ service: 'ResilientPersistenceClient' });
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
set<T>(key: string, value: T): Promise<void> {
|
|
15
|
+
return this.inner.set(key, value);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async get<T>(key: string): Promise<T | null> {
|
|
19
|
+
try {
|
|
20
|
+
return await this.inner.get<T>(key);
|
|
21
|
+
} catch (e) {
|
|
22
|
+
this.logger.warn(
|
|
23
|
+
{ key, error: e, Category: 'sp00ky-client::ResilientPersistenceClient::get' },
|
|
24
|
+
'Persistence read failed, dropping key'
|
|
25
|
+
);
|
|
26
|
+
// Best-effort cleanup of the corrupt key; if removal also fails, the key
|
|
27
|
+
// just gets dropped again on the next failed read, so the failure is safe.
|
|
28
|
+
await this.inner.remove(key).catch((removeErr) => {
|
|
29
|
+
this.logger.debug(
|
|
30
|
+
{ key, error: removeErr, Category: 'sp00ky-client::ResilientPersistenceClient::get' },
|
|
31
|
+
'Failed to drop corrupt persistence key'
|
|
32
|
+
);
|
|
33
|
+
});
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
remove(key: string): Promise<void> {
|
|
39
|
+
return this.inner.remove(key);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
import { PersistenceClient } from '../../types';
|
|
1
|
+
import type { PersistenceClient } from '../../types';
|
|
2
2
|
import { parseRecordIdString, surql } from '../../utils/index';
|
|
3
|
-
import { Logger } from 'pino';
|
|
4
|
-
import {
|
|
3
|
+
import type { Logger } from 'pino';
|
|
4
|
+
import type { LocalStore } from '../database/cache-engine';
|
|
5
5
|
|
|
6
6
|
export class SurrealDBPersistenceClient implements PersistenceClient {
|
|
7
7
|
private logger: Logger;
|
|
8
8
|
|
|
9
9
|
constructor(
|
|
10
|
-
private db:
|
|
10
|
+
private db: LocalStore,
|
|
11
11
|
logger: Logger
|
|
12
12
|
) {
|
|
13
13
|
this.logger = logger.child({ service: 'PersistenceClient:SurrealDb' });
|
|
@@ -15,11 +15,11 @@ export class SurrealDBPersistenceClient implements PersistenceClient {
|
|
|
15
15
|
|
|
16
16
|
async set<T>(key: string, val: T) {
|
|
17
17
|
try {
|
|
18
|
-
const id = parseRecordIdString(`
|
|
18
|
+
const id = parseRecordIdString(`_00_kv:${key}`);
|
|
19
19
|
await this.db.query(surql.seal(surql.upsert('id', 'data')), { id, data: { val } });
|
|
20
20
|
} catch (error) {
|
|
21
21
|
this.logger.error(
|
|
22
|
-
{ error, Category: '
|
|
22
|
+
{ error, Category: 'sp00ky-client::SurrealDBPersistenceClient::set' },
|
|
23
23
|
'Failed to set KV'
|
|
24
24
|
);
|
|
25
25
|
throw error;
|
|
@@ -28,7 +28,7 @@ export class SurrealDBPersistenceClient implements PersistenceClient {
|
|
|
28
28
|
|
|
29
29
|
async get<T>(key: string) {
|
|
30
30
|
try {
|
|
31
|
-
const id = parseRecordIdString(`
|
|
31
|
+
const id = parseRecordIdString(`_00_kv:${key}`);
|
|
32
32
|
const [result] = await this.db.query<[{ val: T }]>(
|
|
33
33
|
surql.seal(surql.selectById('id', ['val'])),
|
|
34
34
|
{
|
|
@@ -41,7 +41,7 @@ export class SurrealDBPersistenceClient implements PersistenceClient {
|
|
|
41
41
|
return result.val;
|
|
42
42
|
} catch (error) {
|
|
43
43
|
this.logger.warn(
|
|
44
|
-
{ error, Category: '
|
|
44
|
+
{ error, Category: 'sp00ky-client::SurrealDBPersistenceClient::get' },
|
|
45
45
|
'Failed to get KV'
|
|
46
46
|
);
|
|
47
47
|
return null;
|
|
@@ -50,11 +50,11 @@ export class SurrealDBPersistenceClient implements PersistenceClient {
|
|
|
50
50
|
|
|
51
51
|
async remove(key: string) {
|
|
52
52
|
try {
|
|
53
|
-
const id = parseRecordIdString(`
|
|
53
|
+
const id = parseRecordIdString(`_00_kv:${key}`);
|
|
54
54
|
await this.db.query(surql.seal(surql.delete('id')), { id });
|
|
55
55
|
} catch (err) {
|
|
56
56
|
this.logger.info(
|
|
57
|
-
{ err, Category: '
|
|
57
|
+
{ err, Category: 'sp00ky-client::SurrealDBPersistenceClient::remove' },
|
|
58
58
|
'Failed to delete KV'
|
|
59
59
|
);
|
|
60
60
|
}
|
|
@@ -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)
|
|
@@ -46,10 +62,33 @@ export class StreamProcessorService {
|
|
|
46
62
|
private processor: WasmProcessor | undefined;
|
|
47
63
|
private isInitialized = false;
|
|
48
64
|
private receivers: StreamUpdateReceiver[] = [];
|
|
65
|
+
// When true, `notifyUpdates` coalesces updates into `batchBuffer` (keyed by
|
|
66
|
+
// queryHash) instead of dispatching them. Used to collapse the per-record
|
|
67
|
+
// stream updates produced by a batched ingest into a single notification per
|
|
68
|
+
// query, so the UI updates once after the whole batch rather than row-by-row.
|
|
69
|
+
private batching = false;
|
|
70
|
+
private batchBuffer: Map<string, StreamUpdate> = new Map();
|
|
71
|
+
// Current session's auth identity, injected into every `register_view`'s
|
|
72
|
+
// params so the in-browser SSP can resolve `$auth`/`$access` in table
|
|
73
|
+
// permission predicates (mirrors the server's `fn::query::register`). Empty
|
|
74
|
+
// strings when logged out — a non-null `auth` keeps `permission_inject` from
|
|
75
|
+
// rejecting `$auth`-gated tables; the predicate just degrades to its public
|
|
76
|
+
// branch. Set via `setSessionAuth` on every auth state change.
|
|
77
|
+
private sessionAuth: { authId: string; access: string } = { authId: '', access: '' };
|
|
78
|
+
// Persisted-state key suffix (the local bucket id) so each user's circuit
|
|
79
|
+
// snapshot lands in their own key. Only matters for localStorage-backed
|
|
80
|
+
// persistence — the surrealdb persistence client already writes into the
|
|
81
|
+
// per-user bucket itself.
|
|
82
|
+
private stateKeySuffix = '';
|
|
83
|
+
// Bumped by `reset()`. A `saveState` captured before a reset must not persist
|
|
84
|
+
// the OLD processor's circuit (holding the previous user's rows) after the
|
|
85
|
+
// switch — the fire-and-forget calls in `ingest`/`flushCoalescing` can land
|
|
86
|
+
// late.
|
|
87
|
+
private stateGeneration = 0;
|
|
49
88
|
|
|
50
89
|
constructor(
|
|
51
90
|
public events: EventSystem<StreamProcessorEvents>,
|
|
52
|
-
private db:
|
|
91
|
+
private db: LocalStore,
|
|
53
92
|
private persistenceClient: PersistenceClient,
|
|
54
93
|
logger: Logger
|
|
55
94
|
) {
|
|
@@ -65,6 +104,32 @@ export class StreamProcessorService {
|
|
|
65
104
|
}
|
|
66
105
|
|
|
67
106
|
private notifyUpdates(updates: StreamUpdate[]) {
|
|
107
|
+
if (this.batching) {
|
|
108
|
+
// Coalesce by queryHash instead of dispatching. The WASM `result_data`
|
|
109
|
+
// (localArray) is the full materialized array, so last-write-wins
|
|
110
|
+
// already reflects every prior ingest in the batch. We sum the
|
|
111
|
+
// materialization times so the single recorded sample reflects the
|
|
112
|
+
// batch's total work, and emit `op: 'CREATE'` on flush so the coalesced
|
|
113
|
+
// update takes DataModule's immediate (non-debounced) path.
|
|
114
|
+
for (const update of updates) {
|
|
115
|
+
const prev = this.batchBuffer.get(update.queryHash);
|
|
116
|
+
const sum = (a?: number, b?: number) => (a ?? 0) + (b ?? 0);
|
|
117
|
+
this.batchBuffer.set(update.queryHash, {
|
|
118
|
+
...update,
|
|
119
|
+
op: 'CREATE',
|
|
120
|
+
materializationTimeMs: sum(prev?.materializationTimeMs, update.materializationTimeMs),
|
|
121
|
+
storeApplyMs: sum(prev?.storeApplyMs, update.storeApplyMs),
|
|
122
|
+
circuitStepMs: sum(prev?.circuitStepMs, update.circuitStepMs),
|
|
123
|
+
transformMs: sum(prev?.transformMs, update.transformMs),
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
this.dispatchUpdates(updates);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
private dispatchUpdates(updates: StreamUpdate[]) {
|
|
68
133
|
for (const update of updates) {
|
|
69
134
|
for (const receiver of this.receivers) {
|
|
70
135
|
receiver.onStreamUpdate(update);
|
|
@@ -72,6 +137,72 @@ export class StreamProcessorService {
|
|
|
72
137
|
}
|
|
73
138
|
}
|
|
74
139
|
|
|
140
|
+
/**
|
|
141
|
+
* Ingest a batch of record changes as a single bulk operation, firing only
|
|
142
|
+
* one coalesced `StreamUpdate` per affected query once every record has been
|
|
143
|
+
* ingested (instead of one update per record). Use this whenever multiple
|
|
144
|
+
* records land at once — e.g. sync fetching N missing rows — so a list query
|
|
145
|
+
* re-runs and the UI re-renders once for the whole batch rather than
|
|
146
|
+
* row-by-row.
|
|
147
|
+
*
|
|
148
|
+
* Internally opens a coalescing window, ingests each record, then flushes;
|
|
149
|
+
* processor state is persisted once for the whole batch. No-op for an empty
|
|
150
|
+
* batch.
|
|
151
|
+
*/
|
|
152
|
+
ingestMany(
|
|
153
|
+
records: Array<{
|
|
154
|
+
table: string;
|
|
155
|
+
op: 'CREATE' | 'UPDATE' | 'DELETE';
|
|
156
|
+
id: string;
|
|
157
|
+
record: any;
|
|
158
|
+
}>
|
|
159
|
+
): void {
|
|
160
|
+
if (records.length === 0) return;
|
|
161
|
+
|
|
162
|
+
this.beginCoalescing();
|
|
163
|
+
try {
|
|
164
|
+
for (const record of records) {
|
|
165
|
+
this.ingest(record.table, record.op, record.id, record.record);
|
|
166
|
+
}
|
|
167
|
+
} finally {
|
|
168
|
+
this.flushCoalescing();
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Open a coalescing window. While open, the per-record stream updates
|
|
174
|
+
* emitted by `ingest` are buffered (one entry per queryHash) instead of
|
|
175
|
+
* dispatched. Always paired with `flushCoalescing()` in a try/finally by
|
|
176
|
+
* `ingestMany` so the window always closes — otherwise the processor stays
|
|
177
|
+
* stuck buffering forever.
|
|
178
|
+
*
|
|
179
|
+
* No-op if a window is already open (nested batches aren't expected here).
|
|
180
|
+
*/
|
|
181
|
+
private beginCoalescing() {
|
|
182
|
+
if (this.batching) return;
|
|
183
|
+
this.batching = true;
|
|
184
|
+
this.batchBuffer.clear();
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Close the coalescing window and flush: dispatch one coalesced
|
|
189
|
+
* `StreamUpdate` per buffered queryHash, then persist processor state once
|
|
190
|
+
* for the whole batch (instead of once per ingest).
|
|
191
|
+
*/
|
|
192
|
+
private flushCoalescing() {
|
|
193
|
+
if (!this.batching) return;
|
|
194
|
+
this.batching = false;
|
|
195
|
+
const buffered = Array.from(this.batchBuffer.values());
|
|
196
|
+
this.batchBuffer.clear();
|
|
197
|
+
if (buffered.length > 0) {
|
|
198
|
+
this.dispatchUpdates(buffered);
|
|
199
|
+
}
|
|
200
|
+
// The processor state after the last ingest is cumulative, so a single
|
|
201
|
+
// snapshot covers the whole batch. Kept fire-and-forget like the per-ingest
|
|
202
|
+
// call it replaces.
|
|
203
|
+
this.saveState();
|
|
204
|
+
}
|
|
205
|
+
|
|
75
206
|
/**
|
|
76
207
|
* Initialize the WASM module and processor.
|
|
77
208
|
* This must be called before using other methods.
|
|
@@ -80,35 +211,67 @@ export class StreamProcessorService {
|
|
|
80
211
|
if (this.isInitialized) return;
|
|
81
212
|
|
|
82
213
|
this.logger.info(
|
|
83
|
-
{ Category: '
|
|
214
|
+
{ Category: 'sp00ky-client::StreamProcessorService::init' },
|
|
84
215
|
'Initializing WASM...'
|
|
85
216
|
);
|
|
86
217
|
try {
|
|
87
218
|
await init(); // Initialize the WASM module (web target)
|
|
88
|
-
// We cast the generated
|
|
89
|
-
this.processor = new
|
|
219
|
+
// We cast the generated Sp00kyProcessor to our interface which is safer
|
|
220
|
+
this.processor = new Sp00kyProcessor() as unknown as WasmProcessor;
|
|
90
221
|
|
|
91
222
|
// Try to load state
|
|
92
223
|
await this.loadState();
|
|
93
224
|
|
|
94
225
|
this.isInitialized = true;
|
|
95
226
|
this.logger.info(
|
|
96
|
-
{ Category: '
|
|
227
|
+
{ Category: 'sp00ky-client::StreamProcessorService::init' },
|
|
97
228
|
'Initialized successfully'
|
|
98
229
|
);
|
|
99
230
|
} catch (e) {
|
|
100
231
|
this.logger.error(
|
|
101
|
-
{ error: e, Category: '
|
|
232
|
+
{ error: e, Category: 'sp00ky-client::StreamProcessorService::init' },
|
|
102
233
|
'Failed to initialize'
|
|
103
234
|
);
|
|
104
235
|
throw e;
|
|
105
236
|
}
|
|
106
237
|
}
|
|
107
238
|
|
|
239
|
+
/** Route the persisted circuit snapshot to a per-bucket key. */
|
|
240
|
+
setStateKeySuffix(bucketId: string) {
|
|
241
|
+
this.stateKeySuffix = bucketId;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
private stateKey(): string {
|
|
245
|
+
return this.stateKeySuffix
|
|
246
|
+
? `_00_stream_processor_state:${this.stateKeySuffix}`
|
|
247
|
+
: '_00_stream_processor_state';
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Drop the current WASM processor and start a fresh, empty circuit. Used on
|
|
252
|
+
* local-bucket switches: the old circuit holds the previous user's rows AND
|
|
253
|
+
* views registered with the previous `$auth` context, so neither may survive.
|
|
254
|
+
* Deliberately does NOT `loadState()` — a persisted snapshot references views
|
|
255
|
+
* under a dead sessionId salt; the DataModule rebind re-registers every live
|
|
256
|
+
* view against this fresh processor. Caller must re-seed `setPermissions`
|
|
257
|
+
* afterwards (a fresh circuit default-denies every table).
|
|
258
|
+
*/
|
|
259
|
+
async reset(): Promise<void> {
|
|
260
|
+
if (!this.isInitialized) return;
|
|
261
|
+
this.stateGeneration++;
|
|
262
|
+
this.batching = false;
|
|
263
|
+
this.batchBuffer.clear();
|
|
264
|
+
this.processor = new Sp00kyProcessor() as unknown as WasmProcessor;
|
|
265
|
+
this.logger.info(
|
|
266
|
+
{ Category: 'sp00ky-client::StreamProcessorService::reset' },
|
|
267
|
+
'Stream processor reset (fresh circuit)'
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
|
|
108
271
|
async loadState() {
|
|
109
272
|
if (!this.processor) return;
|
|
110
273
|
try {
|
|
111
|
-
const result = await this.persistenceClient.get(
|
|
274
|
+
const result = await this.persistenceClient.get(this.stateKey());
|
|
112
275
|
|
|
113
276
|
// Check if we have a valid result from the query
|
|
114
277
|
if (
|
|
@@ -122,51 +285,101 @@ export class StreamProcessorService {
|
|
|
122
285
|
this.logger.info(
|
|
123
286
|
{
|
|
124
287
|
stateLength: state.length,
|
|
125
|
-
Category: '
|
|
288
|
+
Category: 'sp00ky-client::StreamProcessorService::loadState',
|
|
126
289
|
},
|
|
127
290
|
'Loading state from DB'
|
|
128
291
|
);
|
|
129
292
|
// Assuming processor has a load_state method matching the save_state behavior
|
|
130
293
|
// If not, we might need to adjust based on the actual WASM API
|
|
131
|
-
if (typeof
|
|
132
|
-
|
|
294
|
+
if (typeof this.processor.load_state === 'function') {
|
|
295
|
+
this.processor.load_state(state);
|
|
133
296
|
} else {
|
|
134
297
|
this.logger.warn(
|
|
135
|
-
{ Category: '
|
|
298
|
+
{ Category: 'sp00ky-client::StreamProcessorService::loadState' },
|
|
136
299
|
'load_state method not found on processor'
|
|
137
300
|
);
|
|
138
301
|
}
|
|
139
302
|
} else {
|
|
140
303
|
this.logger.info(
|
|
141
|
-
{ Category: '
|
|
304
|
+
{ Category: 'sp00ky-client::StreamProcessorService::loadState' },
|
|
142
305
|
'No saved state found'
|
|
143
306
|
);
|
|
144
307
|
}
|
|
145
308
|
} catch (e) {
|
|
146
309
|
this.logger.error(
|
|
147
|
-
{ error: e, Category: '
|
|
310
|
+
{ error: e, Category: 'sp00ky-client::StreamProcessorService::loadState' },
|
|
148
311
|
'Failed to load state'
|
|
149
312
|
);
|
|
150
313
|
}
|
|
151
314
|
}
|
|
152
315
|
|
|
316
|
+
/**
|
|
317
|
+
* Seed per-table `select` permission predicates ({ [table]: whereText }).
|
|
318
|
+
* Must run after the processor exists and before any `register_view`, else
|
|
319
|
+
* non-`_00_` tables are default-denied and registration fails.
|
|
320
|
+
*/
|
|
321
|
+
setPermissions(permissions: Record<string, string>) {
|
|
322
|
+
if (!this.processor) return;
|
|
323
|
+
if (typeof this.processor.set_permissions !== 'function') {
|
|
324
|
+
this.logger.warn(
|
|
325
|
+
{ Category: 'sp00ky-client::StreamProcessorService::setPermissions' },
|
|
326
|
+
'set_permissions not found on processor (stale WASM build?)'
|
|
327
|
+
);
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
this.processor.set_permissions(permissions);
|
|
331
|
+
this.logger.info(
|
|
332
|
+
{
|
|
333
|
+
tables: Object.keys(permissions).length,
|
|
334
|
+
Category: 'sp00ky-client::StreamProcessorService::setPermissions',
|
|
335
|
+
},
|
|
336
|
+
'Seeded table permissions'
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Set the current session's auth identity for permission injection,
|
|
342
|
+
* mirroring the server's `fn::query::register`
|
|
343
|
+
* (`object::extend(params, { auth: { id: $auth.id }, access: $access })`).
|
|
344
|
+
* Stored as strings (empty when logged out) and applied to every
|
|
345
|
+
* `register_view` in {@link registerQueryPlan}. Must be set before a
|
|
346
|
+
* `$auth`-gated query registers (and re-set on auth state changes), or the
|
|
347
|
+
* in-browser SSP's `permission_inject` rejects it with
|
|
348
|
+
* "requires $auth but registration params lack it".
|
|
349
|
+
*/
|
|
350
|
+
setSessionAuth(authId: string | null, access: string | null) {
|
|
351
|
+
this.sessionAuth = { authId: authId ?? '', access: access ?? '' };
|
|
352
|
+
this.logger.debug(
|
|
353
|
+
{
|
|
354
|
+
authId: this.sessionAuth.authId,
|
|
355
|
+
access: this.sessionAuth.access,
|
|
356
|
+
Category: 'sp00ky-client::StreamProcessorService::setSessionAuth',
|
|
357
|
+
},
|
|
358
|
+
'Session auth context updated'
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
|
|
153
362
|
async saveState() {
|
|
154
363
|
if (!this.processor) return;
|
|
364
|
+
const generation = this.stateGeneration;
|
|
155
365
|
try {
|
|
156
366
|
// Assuming processor has a save_state method that returns the state string/bytes
|
|
157
|
-
if (typeof
|
|
158
|
-
const state =
|
|
367
|
+
if (typeof this.processor.save_state === 'function') {
|
|
368
|
+
const state = this.processor.save_state();
|
|
369
|
+
// A reset raced this snapshot — persisting it would write the previous
|
|
370
|
+
// user's circuit into the new bucket's key. Drop it.
|
|
371
|
+
if (generation !== this.stateGeneration) return;
|
|
159
372
|
if (state) {
|
|
160
|
-
await this.persistenceClient.set(
|
|
373
|
+
await this.persistenceClient.set(this.stateKey(), state);
|
|
161
374
|
this.logger.trace(
|
|
162
|
-
{ Category: '
|
|
375
|
+
{ Category: 'sp00ky-client::StreamProcessorService::saveState' },
|
|
163
376
|
'State saved'
|
|
164
377
|
);
|
|
165
378
|
}
|
|
166
379
|
}
|
|
167
380
|
} catch (e) {
|
|
168
381
|
this.logger.error(
|
|
169
|
-
{ error: e, Category: '
|
|
382
|
+
{ error: e, Category: 'sp00ky-client::StreamProcessorService::saveState' },
|
|
170
383
|
'Failed to save state'
|
|
171
384
|
);
|
|
172
385
|
}
|
|
@@ -188,14 +401,14 @@ export class StreamProcessorService {
|
|
|
188
401
|
table,
|
|
189
402
|
op,
|
|
190
403
|
id,
|
|
191
|
-
Category: '
|
|
404
|
+
Category: 'sp00ky-client::StreamProcessorService::ingest',
|
|
192
405
|
},
|
|
193
406
|
'Ingesting into ssp'
|
|
194
407
|
);
|
|
195
408
|
|
|
196
409
|
if (!this.processor) {
|
|
197
410
|
this.logger.warn(
|
|
198
|
-
{ Category: '
|
|
411
|
+
{ Category: 'sp00ky-client::StreamProcessorService::ingest' },
|
|
199
412
|
'Not initialized, skipping ingest'
|
|
200
413
|
);
|
|
201
414
|
return [];
|
|
@@ -204,14 +417,17 @@ export class StreamProcessorService {
|
|
|
204
417
|
try {
|
|
205
418
|
const normalizedRecord = this.normalizeValue(record);
|
|
206
419
|
|
|
420
|
+
const t0 = performance.now();
|
|
207
421
|
const rawUpdates = this.processor.ingest(table, op, id, normalizedRecord);
|
|
422
|
+
const materializationTimeMs = performance.now() - t0;
|
|
208
423
|
this.logger.debug(
|
|
209
424
|
{
|
|
210
425
|
table,
|
|
211
426
|
op,
|
|
212
427
|
id,
|
|
213
428
|
rawUpdates: rawUpdates.length,
|
|
214
|
-
|
|
429
|
+
materializationTimeMs,
|
|
430
|
+
Category: 'sp00ky-client::StreamProcessorService::ingest',
|
|
215
431
|
},
|
|
216
432
|
'Ingesting into ssp done'
|
|
217
433
|
);
|
|
@@ -221,15 +437,23 @@ export class StreamProcessorService {
|
|
|
221
437
|
queryHash: u.query_id,
|
|
222
438
|
localArray: u.result_data,
|
|
223
439
|
op: op,
|
|
440
|
+
materializationTimeMs,
|
|
441
|
+
storeApplyMs: u.timing_store_apply_ms,
|
|
442
|
+
circuitStepMs: u.timing_circuit_step_ms,
|
|
443
|
+
transformMs: u.timing_transform_ms,
|
|
224
444
|
}));
|
|
225
445
|
// Direct handler call instead of event
|
|
226
446
|
this.notifyUpdates(updates);
|
|
227
447
|
}
|
|
228
|
-
|
|
448
|
+
// While batching (inside `ingestMany`), `flushCoalescing` persists once
|
|
449
|
+
// for the whole batch — skip the redundant per-record snapshot here.
|
|
450
|
+
if (!this.batching) {
|
|
451
|
+
this.saveState();
|
|
452
|
+
}
|
|
229
453
|
return rawUpdates;
|
|
230
454
|
} catch (e) {
|
|
231
455
|
this.logger.error(
|
|
232
|
-
{ error: e, Category: '
|
|
456
|
+
{ error: e, Category: 'sp00ky-client::StreamProcessorService::ingest' },
|
|
233
457
|
'Ingesting into ssp failed'
|
|
234
458
|
);
|
|
235
459
|
}
|
|
@@ -243,7 +467,7 @@ export class StreamProcessorService {
|
|
|
243
467
|
registerQueryPlan(queryPlan: QueryPlanConfig) {
|
|
244
468
|
if (!this.processor) {
|
|
245
469
|
this.logger.warn(
|
|
246
|
-
{ Category: '
|
|
470
|
+
{ Category: 'sp00ky-client::StreamProcessorService::registerQueryPlan' },
|
|
247
471
|
'Not initialized, skipping registration'
|
|
248
472
|
);
|
|
249
473
|
return;
|
|
@@ -254,7 +478,7 @@ export class StreamProcessorService {
|
|
|
254
478
|
queryHash: queryPlan.queryHash,
|
|
255
479
|
surql: queryPlan.surql,
|
|
256
480
|
params: queryPlan.params,
|
|
257
|
-
Category: '
|
|
481
|
+
Category: 'sp00ky-client::StreamProcessorService::registerQueryPlan',
|
|
258
482
|
},
|
|
259
483
|
'Registering query plan'
|
|
260
484
|
);
|
|
@@ -262,17 +486,30 @@ export class StreamProcessorService {
|
|
|
262
486
|
try {
|
|
263
487
|
const normalizedParams = this.normalizeValue(queryPlan.params);
|
|
264
488
|
|
|
489
|
+
// Mirror the server's `fn::query::register` auth injection so the
|
|
490
|
+
// in-browser SSP can resolve `$auth`/`$access` in a table's permission
|
|
491
|
+
// predicate. Without this, any `$auth`-gated table (e.g. `thread`) is
|
|
492
|
+
// rejected by `permission_inject` and its local view never materializes.
|
|
493
|
+
// Injected only into the params handed to the in-browser SSP — never into
|
|
494
|
+
// the persisted `queryState.config.params` / query hash / server payload
|
|
495
|
+
// (the server does its own injection), so the view id stays shared.
|
|
496
|
+
const paramsWithAuth = {
|
|
497
|
+
...(normalizedParams as Record<string, unknown>),
|
|
498
|
+
auth: { id: this.sessionAuth.authId },
|
|
499
|
+
access: this.sessionAuth.access,
|
|
500
|
+
};
|
|
501
|
+
|
|
265
502
|
const initialUpdate = this.processor.register_view({
|
|
266
503
|
id: queryPlan.queryHash,
|
|
267
504
|
surql: queryPlan.surql,
|
|
268
|
-
params:
|
|
505
|
+
params: paramsWithAuth,
|
|
269
506
|
clientId: 'local',
|
|
270
507
|
ttl: queryPlan.ttl.toString(),
|
|
271
508
|
lastActiveAt: new Date().toISOString(),
|
|
272
509
|
});
|
|
273
510
|
|
|
274
511
|
this.logger.debug(
|
|
275
|
-
{ initialUpdate, Category: '
|
|
512
|
+
{ initialUpdate, Category: 'sp00ky-client::StreamProcessorService::registerQueryPlan' },
|
|
276
513
|
'register_view result'
|
|
277
514
|
);
|
|
278
515
|
|
|
@@ -282,6 +519,11 @@ export class StreamProcessorService {
|
|
|
282
519
|
const update: StreamUpdate = {
|
|
283
520
|
queryHash: initialUpdate.query_id,
|
|
284
521
|
localArray: initialUpdate.result_data,
|
|
522
|
+
registration: {
|
|
523
|
+
parseMs: initialUpdate.timing_parse_ms ?? 0,
|
|
524
|
+
planMs: initialUpdate.timing_plan_ms ?? 0,
|
|
525
|
+
snapshotMs: initialUpdate.timing_snapshot_ms ?? 0,
|
|
526
|
+
},
|
|
285
527
|
};
|
|
286
528
|
this.saveState();
|
|
287
529
|
this.logger.debug(
|
|
@@ -289,14 +531,14 @@ export class StreamProcessorService {
|
|
|
289
531
|
queryHash: queryPlan.queryHash,
|
|
290
532
|
surql: queryPlan.surql,
|
|
291
533
|
params: queryPlan.params,
|
|
292
|
-
Category: '
|
|
534
|
+
Category: 'sp00ky-client::StreamProcessorService::registerQueryPlan',
|
|
293
535
|
},
|
|
294
536
|
'Registered query plan'
|
|
295
537
|
);
|
|
296
538
|
return update;
|
|
297
539
|
} catch (e) {
|
|
298
540
|
this.logger.error(
|
|
299
|
-
{ error: e, Category: '
|
|
541
|
+
{ error: e, Category: 'sp00ky-client::StreamProcessorService::registerQueryPlan' },
|
|
300
542
|
'Error registering query plan'
|
|
301
543
|
);
|
|
302
544
|
throw e;
|
|
@@ -313,7 +555,7 @@ export class StreamProcessorService {
|
|
|
313
555
|
this.saveState();
|
|
314
556
|
} catch (e) {
|
|
315
557
|
this.logger.error(
|
|
316
|
-
{ error: e, Category: '
|
|
558
|
+
{ error: e, Category: 'sp00ky-client::StreamProcessorService::unregisterQueryPlan' },
|
|
317
559
|
'Error unregistering query plan'
|
|
318
560
|
);
|
|
319
561
|
}
|
|
@@ -323,6 +565,21 @@ export class StreamProcessorService {
|
|
|
323
565
|
if (value === null || value === undefined) return value;
|
|
324
566
|
|
|
325
567
|
if (typeof value === 'object') {
|
|
568
|
+
// CRDT snapshots arrive as `Uint8Array` (or `ArrayBuffer` /
|
|
569
|
+
// typed-array views). `serde_wasm_bindgen::from_value` rejects
|
|
570
|
+
// those when deserializing into `serde_json::Value` (JSON has no
|
|
571
|
+
// binary variant), and the SSP can't filter on opaque bytes
|
|
572
|
+
// anyway. Replace with `null` so the row still flows through the
|
|
573
|
+
// ingest path with its other columns intact, and downstream
|
|
574
|
+
// predicates referencing the bytes column simply don't match.
|
|
575
|
+
if (
|
|
576
|
+
value instanceof Uint8Array ||
|
|
577
|
+
value instanceof ArrayBuffer ||
|
|
578
|
+
ArrayBuffer.isView(value)
|
|
579
|
+
) {
|
|
580
|
+
return null;
|
|
581
|
+
}
|
|
582
|
+
|
|
326
583
|
// RecordId detection using duck typing (constructor.name may be minified)
|
|
327
584
|
// SurrealDB's RecordId has: table (getter returning Table), id, and toString()
|
|
328
585
|
// Check for table getter that has its own toString AND id property
|
|
@@ -334,7 +591,7 @@ export class StreamProcessorService {
|
|
|
334
591
|
if (hasTable && hasId && hasToString && isNotPlainObject) {
|
|
335
592
|
const result = value.toString();
|
|
336
593
|
this.logger.trace(
|
|
337
|
-
{ result, Category: '
|
|
594
|
+
{ result, Category: 'sp00ky-client::StreamProcessorService::normalizeValue' },
|
|
338
595
|
'RecordId detected'
|
|
339
596
|
);
|
|
340
597
|
return result;
|