@spooky-sync/core 0.0.1-canary.69 → 0.0.1-canary.70
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/dist/index.d.ts +102 -2
- package/dist/index.js +1066 -129
- package/dist/otel/index.d.ts +1 -1
- package/dist/types.d.ts +74 -1
- package/package.json +2 -2
- package/src/build-globals.d.ts +6 -0
- package/src/events/index.ts +3 -0
- package/src/modules/cache/index.ts +13 -6
- package/src/modules/crdt/index.ts +9 -1
- package/src/modules/data/data.status.test.ts +108 -0
- package/src/modules/data/index.ts +436 -56
- package/src/modules/data/window-query.test.ts +52 -0
- package/src/modules/data/window-query.ts +130 -0
- package/src/modules/devtools/index.ts +54 -1
- package/src/modules/devtools/versions.test.ts +74 -0
- package/src/modules/devtools/versions.ts +81 -0
- package/src/modules/sync/engine.ts +43 -29
- package/src/modules/sync/sync.ts +247 -55
- package/src/modules/sync/utils.test.ts +80 -0
- package/src/modules/sync/utils.ts +72 -0
- package/src/services/database/local.test.ts +33 -0
- package/src/services/database/local.ts +182 -23
- package/src/services/persistence/resilient.ts +8 -1
- package/src/services/stream-processor/index.ts +148 -5
- 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/wasm-types.ts +16 -0
- package/src/sp00ky.ts +86 -6
- package/src/types.ts +85 -0
- package/src/utils/index.ts +13 -3
- package/tsdown.config.ts +54 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { StreamProcessorService } from './index';
|
|
3
|
+
import type { StreamUpdate, StreamUpdateReceiver } from './index';
|
|
4
|
+
import type { WasmProcessor, WasmStreamUpdate } from './wasm-types';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Tests for `ingestMany` bulk insert on StreamProcessorService.
|
|
8
|
+
*
|
|
9
|
+
* A batched ingest (e.g. sync fetching N missing records) used to emit one
|
|
10
|
+
* stream update per record, making the UI render row-by-row. ingestMany
|
|
11
|
+
* collapses those into a single coalesced update per affected query.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
function makeLogger(): any {
|
|
15
|
+
const noop = () => {};
|
|
16
|
+
const logger: any = {
|
|
17
|
+
debug: noop,
|
|
18
|
+
info: noop,
|
|
19
|
+
warn: noop,
|
|
20
|
+
error: noop,
|
|
21
|
+
trace: noop,
|
|
22
|
+
};
|
|
23
|
+
logger.child = () => logger;
|
|
24
|
+
return logger;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Records every dispatched update so we can count notifications. */
|
|
28
|
+
class RecordingReceiver implements StreamUpdateReceiver {
|
|
29
|
+
public received: StreamUpdate[] = [];
|
|
30
|
+
onStreamUpdate(update: StreamUpdate): void {
|
|
31
|
+
this.received.push(update);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Build a service with a stubbed WASM processor. The processor accumulates
|
|
37
|
+
* ingested ids and returns the *full* materialized array on every call (as the
|
|
38
|
+
* real WASM does), so coalescing's last-write-wins must yield the final array.
|
|
39
|
+
*/
|
|
40
|
+
function makeService(queryHashesFor: (id: string) => string[]) {
|
|
41
|
+
const svc = new StreamProcessorService(
|
|
42
|
+
{} as any,
|
|
43
|
+
{} as any,
|
|
44
|
+
{ get: async () => undefined, set: async () => {} } as any,
|
|
45
|
+
makeLogger()
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
const ingestedByQuery = new Map<string, Array<[string, number]>>();
|
|
49
|
+
const mockProcessor: Partial<WasmProcessor> = {
|
|
50
|
+
ingest: (_table, _op, id, record: any): WasmStreamUpdate[] => {
|
|
51
|
+
const version = record?._00_rv ?? 1;
|
|
52
|
+
const updates: WasmStreamUpdate[] = [];
|
|
53
|
+
for (const queryHash of queryHashesFor(id)) {
|
|
54
|
+
const arr = ingestedByQuery.get(queryHash) ?? [];
|
|
55
|
+
arr.push([id, version]);
|
|
56
|
+
ingestedByQuery.set(queryHash, arr);
|
|
57
|
+
updates.push({ query_id: queryHash, result_data: [...arr] });
|
|
58
|
+
}
|
|
59
|
+
return updates;
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
// processor is private and normally set by init() via WASM; inject the stub.
|
|
63
|
+
(svc as any).processor = mockProcessor;
|
|
64
|
+
return svc;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
describe('StreamProcessor ingestMany bulk insert', () => {
|
|
68
|
+
let receiver: RecordingReceiver;
|
|
69
|
+
|
|
70
|
+
beforeEach(() => {
|
|
71
|
+
receiver = new RecordingReceiver();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('emits one update per record when ingesting one-by-one (baseline)', () => {
|
|
75
|
+
const svc = makeService(() => ['q1']);
|
|
76
|
+
svc.addReceiver(receiver);
|
|
77
|
+
|
|
78
|
+
svc.ingest('user', 'CREATE', 'user:1', { id: 'user:1', _00_rv: 1 });
|
|
79
|
+
svc.ingest('user', 'CREATE', 'user:2', { id: 'user:2', _00_rv: 1 });
|
|
80
|
+
svc.ingest('user', 'CREATE', 'user:3', { id: 'user:3', _00_rv: 1 });
|
|
81
|
+
|
|
82
|
+
expect(receiver.received).toHaveLength(3);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('coalesces a bulk insert into a single update per query with the final array', () => {
|
|
86
|
+
const svc = makeService(() => ['q1']);
|
|
87
|
+
svc.addReceiver(receiver);
|
|
88
|
+
|
|
89
|
+
svc.ingestMany([
|
|
90
|
+
{ table: 'user', op: 'CREATE', id: 'user:1', record: { id: 'user:1', _00_rv: 1 } },
|
|
91
|
+
{ table: 'user', op: 'CREATE', id: 'user:2', record: { id: 'user:2', _00_rv: 1 } },
|
|
92
|
+
{ table: 'user', op: 'CREATE', id: 'user:3', record: { id: 'user:3', _00_rv: 1 } },
|
|
93
|
+
]);
|
|
94
|
+
|
|
95
|
+
// Nothing dispatched until the whole batch is ingested, then exactly one
|
|
96
|
+
// coalesced update.
|
|
97
|
+
expect(receiver.received).toHaveLength(1);
|
|
98
|
+
const update = receiver.received[0];
|
|
99
|
+
expect(update.queryHash).toBe('q1');
|
|
100
|
+
// Last-write-wins carries the full materialized array (all 3 records).
|
|
101
|
+
expect(update.localArray).toHaveLength(3);
|
|
102
|
+
// Coalesced updates take DataModule's immediate (non-debounced) path.
|
|
103
|
+
expect(update.op).toBe('CREATE');
|
|
104
|
+
expect(typeof update.materializationTimeMs).toBe('number');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('coalesces independently per affected query', () => {
|
|
108
|
+
// user:1 -> q1, user:2 -> q1 & q2, user:3 -> q2
|
|
109
|
+
const svc = makeService((id) =>
|
|
110
|
+
id === 'user:1' ? ['q1'] : id === 'user:2' ? ['q1', 'q2'] : ['q2']
|
|
111
|
+
);
|
|
112
|
+
svc.addReceiver(receiver);
|
|
113
|
+
|
|
114
|
+
svc.ingestMany([
|
|
115
|
+
{ table: 'user', op: 'CREATE', id: 'user:1', record: { id: 'user:1', _00_rv: 1 } },
|
|
116
|
+
{ table: 'user', op: 'CREATE', id: 'user:2', record: { id: 'user:2', _00_rv: 1 } },
|
|
117
|
+
{ table: 'user', op: 'CREATE', id: 'user:3', record: { id: 'user:3', _00_rv: 1 } },
|
|
118
|
+
]);
|
|
119
|
+
|
|
120
|
+
expect(receiver.received).toHaveLength(2);
|
|
121
|
+
const byHash = Object.fromEntries(receiver.received.map((u) => [u.queryHash, u]));
|
|
122
|
+
expect(Object.keys(byHash).sort()).toEqual(['q1', 'q2']);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('is a no-op for an empty batch and leaves the window closed', () => {
|
|
126
|
+
const svc = makeService(() => ['q1']);
|
|
127
|
+
svc.addReceiver(receiver);
|
|
128
|
+
|
|
129
|
+
svc.ingestMany([]);
|
|
130
|
+
|
|
131
|
+
expect(receiver.received).toHaveLength(0);
|
|
132
|
+
// A subsequent single ingest dispatches normally (window never opened).
|
|
133
|
+
svc.ingest('user', 'CREATE', 'user:1', { id: 'user:1', _00_rv: 1 });
|
|
134
|
+
expect(receiver.received).toHaveLength(1);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
@@ -4,6 +4,14 @@ export interface WasmStreamUpdate {
|
|
|
4
4
|
query_id: string;
|
|
5
5
|
result_hash: string;
|
|
6
6
|
result_data: RecordVersionArray; // Match Rust 'result_data' field
|
|
7
|
+
// Per-phase SSP processing time (ms). Ingest path: store_apply/circuit_step/
|
|
8
|
+
// transform. Register path: parse/plan/snapshot. Unused side is 0.
|
|
9
|
+
timing_store_apply_ms?: number;
|
|
10
|
+
timing_circuit_step_ms?: number;
|
|
11
|
+
timing_transform_ms?: number;
|
|
12
|
+
timing_parse_ms?: number;
|
|
13
|
+
timing_plan_ms?: number;
|
|
14
|
+
timing_snapshot_ms?: number;
|
|
7
15
|
}
|
|
8
16
|
|
|
9
17
|
export interface WasmQueryConfig {
|
|
@@ -28,4 +36,12 @@ export interface WasmProcessor {
|
|
|
28
36
|
ingest(table: string, op: string, id: string, record: any): WasmStreamUpdate[];
|
|
29
37
|
register_view(config: WasmQueryConfig): WasmStreamUpdate | undefined;
|
|
30
38
|
unregister_view(id: string): void;
|
|
39
|
+
// Seed per-table `select` permission predicates ({ [table]: whereText }) so
|
|
40
|
+
// register_view can inject them instead of default-denying the table.
|
|
41
|
+
set_permissions(permissions: Record<string, string>): void;
|
|
42
|
+
// Persistence hooks: present on current WASM builds, absent on stale ones.
|
|
43
|
+
// Always guarded with `typeof x === 'function'` before calling so an older
|
|
44
|
+
// build degrades gracefully instead of throwing.
|
|
45
|
+
load_state?(state: string): void;
|
|
46
|
+
save_state?(): string;
|
|
31
47
|
}
|
package/src/sp00ky.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { DataModule } from './modules/data/index';
|
|
|
2
2
|
import type {
|
|
3
3
|
Sp00kyConfig,
|
|
4
4
|
QueryTimeToLive,
|
|
5
|
+
QueryStatusCallback,
|
|
5
6
|
Sp00kyQueryResultPromise,
|
|
6
7
|
PersistenceClient,
|
|
7
8
|
UpdateOptions,
|
|
@@ -32,8 +33,10 @@ import { DevToolsService } from './modules/devtools/index';
|
|
|
32
33
|
import { createLogger } from './services/logger/index';
|
|
33
34
|
import { AuthService } from './modules/auth/index';
|
|
34
35
|
import { StreamProcessorService } from './services/stream-processor/index';
|
|
36
|
+
import { extractSelectPermissions } from './services/stream-processor/permissions';
|
|
35
37
|
import { EventSystem } from './events/index';
|
|
36
38
|
import { CacheModule } from './modules/cache/index';
|
|
39
|
+
import type { RecordWithId } from './modules/cache/index';
|
|
37
40
|
import { CrdtManager, CrdtField } from './modules/crdt/index';
|
|
38
41
|
import { LocalStoragePersistenceClient } from './services/persistence/localstorage';
|
|
39
42
|
import { parseParams } from './utils/index';
|
|
@@ -221,6 +224,32 @@ export class Sp00kyClient<S extends SchemaStructure> {
|
|
|
221
224
|
* Setup direct callbacks instead of event subscriptions
|
|
222
225
|
*/
|
|
223
226
|
private setupCallbacks() {
|
|
227
|
+
// Surface query fetch-status changes (idle/fetching) in DevTools. Logs a
|
|
228
|
+
// discrete event and triggers a state push so the active-queries panel
|
|
229
|
+
// reflects the flip immediately.
|
|
230
|
+
this.dataModule.onQueryStatusChange = (queryHash, status) => {
|
|
231
|
+
this.devTools.logEvent('QUERY_STATUS_CHANGED', { queryHash, status });
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
// Keep an actively-watched query's remote `_00_query.lastActiveAt` fresh so
|
|
235
|
+
// the server TTL sweep doesn't expire it out from under live subscribers.
|
|
236
|
+
// DataModule fires this only while the query still has ≥1 subscriber.
|
|
237
|
+
this.dataModule.onHeartbeat = (queryHash) => {
|
|
238
|
+
void this.sync.heartbeatQuery(queryHash).catch((err) => {
|
|
239
|
+
this.logger.warn(
|
|
240
|
+
{ err, queryHash, Category: 'sp00ky-client::Sp00kyClient::onHeartbeat' },
|
|
241
|
+
'TTL heartbeat failed'
|
|
242
|
+
);
|
|
243
|
+
});
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
// Eager teardown of an opt-in deregistered query: enqueue a `cleanup`
|
|
247
|
+
// down-event so it's serialized after any in-flight register/sync for the
|
|
248
|
+
// same query (avoids out-of-order delete-before-create).
|
|
249
|
+
this.dataModule.onDeregister = (queryHash) => {
|
|
250
|
+
this.sync.enqueueDownEvent({ type: 'cleanup', payload: { hash: queryHash } });
|
|
251
|
+
};
|
|
252
|
+
|
|
224
253
|
// Mutation callback for sync
|
|
225
254
|
this.dataModule.onMutation((mutations: UpEvent[]) => {
|
|
226
255
|
// Notify DevTools
|
|
@@ -295,6 +324,11 @@ export class Sp00kyClient<S extends SchemaStructure> {
|
|
|
295
324
|
);
|
|
296
325
|
|
|
297
326
|
await this.streamProcessor.init();
|
|
327
|
+
// Seed table `select` permissions from the schema before any query is
|
|
328
|
+
// registered — otherwise the SSP default-denies every non-`_00_` table.
|
|
329
|
+
this.streamProcessor.setPermissions(
|
|
330
|
+
extractSelectPermissions(this.config.schemaSurql)
|
|
331
|
+
);
|
|
298
332
|
this.logger.debug(
|
|
299
333
|
{ Category: 'sp00ky-client::Sp00kyClient::init' },
|
|
300
334
|
'StreamProcessor initialized'
|
|
@@ -424,12 +458,25 @@ export class Sp00kyClient<S extends SchemaStructure> {
|
|
|
424
458
|
throw new Error(`Table ${table} not found`);
|
|
425
459
|
}
|
|
426
460
|
|
|
427
|
-
const
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
461
|
+
const params = parseParams(tableSchema.columns, q.selectQuery.vars ?? {});
|
|
462
|
+
const hash = await this.dataModule.query(table, q.selectQuery.query, params, ttl);
|
|
463
|
+
|
|
464
|
+
// Instant-hydrate: for a cold query, fetch its rows one-shot from the remote
|
|
465
|
+
// (its own surql, run directly like useRemote) and display them NOW; the full
|
|
466
|
+
// realtime registration proceeds below. Hydrated rows carry their `_00_rv`
|
|
467
|
+
// versions so the registration's syncRecords skips re-pulling unchanged bodies.
|
|
468
|
+
// Best-effort: any failure (e.g. offline) just falls through to registration.
|
|
469
|
+
if (this.config.instantHydrate !== false && this.dataModule.isCold(hash)) {
|
|
470
|
+
try {
|
|
471
|
+
const [rows] = await this.remote.query<[RecordWithId[]]>(q.selectQuery.query, params);
|
|
472
|
+
await this.dataModule.applyHydration(hash, rows ?? []);
|
|
473
|
+
} catch (err) {
|
|
474
|
+
this.logger.warn(
|
|
475
|
+
{ err, hash, Category: 'sp00ky-client::Sp00kyClient::instantHydrate' },
|
|
476
|
+
'Instant hydrate failed; proceeding with registration'
|
|
477
|
+
);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
433
480
|
|
|
434
481
|
await this.sync.enqueueDownEvent({
|
|
435
482
|
type: 'register',
|
|
@@ -454,6 +501,39 @@ export class Sp00kyClient<S extends SchemaStructure> {
|
|
|
454
501
|
return this.dataModule.subscribe(queryHash, callback, options);
|
|
455
502
|
}
|
|
456
503
|
|
|
504
|
+
/**
|
|
505
|
+
* Opt-in eager teardown for a query whose last subscriber has gone away
|
|
506
|
+
* (e.g. a viewport-windowed list cancelling an off-screen window). No-op
|
|
507
|
+
* while any subscriber remains. Tears down the remote `_00_query` view +
|
|
508
|
+
* local WASM view instead of waiting for the TTL sweep. Default behavior
|
|
509
|
+
* (no call here) keeps the view resident for cheap re-subscription.
|
|
510
|
+
*/
|
|
511
|
+
deregisterQuery(queryHash: string): void {
|
|
512
|
+
this.dataModule.deregisterQuery(queryHash);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Subscribe to a query's fetch-status changes (idle/fetching). With
|
|
517
|
+
* `{ immediate: true }` the callback fires synchronously with the current
|
|
518
|
+
* status. Powers the `useQuery` hook's `isFetching()` accessor.
|
|
519
|
+
*/
|
|
520
|
+
subscribeQueryStatus(
|
|
521
|
+
queryHash: string,
|
|
522
|
+
callback: QueryStatusCallback,
|
|
523
|
+
options?: { immediate?: boolean }
|
|
524
|
+
): () => void {
|
|
525
|
+
return this.dataModule.subscribeStatus(queryHash, callback, options);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* Report the frontend processing time (ms) a client framework spent applying
|
|
530
|
+
* an update for a query (e.g. `useQuery`'s `reconcile()`), so DevTools/MCP can
|
|
531
|
+
* surface the "frontend" phase of the per-query timing breakdown.
|
|
532
|
+
*/
|
|
533
|
+
reportFrontendTiming(queryHash: string, ms: number): void {
|
|
534
|
+
this.dataModule.recordFrontendTiming(queryHash, ms);
|
|
535
|
+
}
|
|
536
|
+
|
|
457
537
|
run<
|
|
458
538
|
B extends BackendNames<S>,
|
|
459
539
|
R extends BackendRoutes<S, B>,
|
package/src/types.ts
CHANGED
|
@@ -133,6 +133,16 @@ export interface Sp00kyConfig<S extends SchemaStructure> {
|
|
|
133
133
|
* values fall back to the default (500ms).
|
|
134
134
|
*/
|
|
135
135
|
refSyncIntervalMs?: number;
|
|
136
|
+
/**
|
|
137
|
+
* Instant-hydrate cold queries: when a query is registered with no local
|
|
138
|
+
* data yet, first run its surql directly on the remote (one-shot) and display
|
|
139
|
+
* the result immediately, THEN do the full realtime registration in the
|
|
140
|
+
* background. The hydrated rows are ingested with their versions so the
|
|
141
|
+
* registration's `syncRecords` skips re-pulling unchanged bodies. Cuts cold
|
|
142
|
+
* first-paint from ~one full registration round-trip to ~one query.
|
|
143
|
+
* Defaults to `true`; set `false` to keep the old wait-for-registration path.
|
|
144
|
+
*/
|
|
145
|
+
instantHydrate?: boolean;
|
|
136
146
|
}
|
|
137
147
|
|
|
138
148
|
export type QueryHash = string;
|
|
@@ -178,6 +188,15 @@ export interface QueryConfig {
|
|
|
178
188
|
|
|
179
189
|
export type QueryConfigRecord = QueryConfig & { id: string };
|
|
180
190
|
|
|
191
|
+
/**
|
|
192
|
+
* Runtime fetch status of a live query.
|
|
193
|
+
* - `idle`: not currently fetching missing records.
|
|
194
|
+
* - `fetching`: the sync engine is fetching/ingesting missing records for this
|
|
195
|
+
* query. UI notifications are coalesced so the result lands as a single
|
|
196
|
+
* update once fetching completes.
|
|
197
|
+
*/
|
|
198
|
+
export type QueryStatus = 'idle' | 'fetching';
|
|
199
|
+
|
|
181
200
|
/**
|
|
182
201
|
* Internal state of a live query.
|
|
183
202
|
*/
|
|
@@ -186,6 +205,9 @@ export interface QueryState {
|
|
|
186
205
|
config: QueryConfig;
|
|
187
206
|
/** The current cached records for this query. */
|
|
188
207
|
records: Record<string, any>[];
|
|
208
|
+
/** Set once `applyHydration` has run for this query, so the cold instant-hydrate
|
|
209
|
+
* path fires at most once per query (see DataModule.isCold/applyHydration). */
|
|
210
|
+
hydrated?: boolean;
|
|
189
211
|
/** Timer for TTL expiration. */
|
|
190
212
|
ttlTimer: NodeJS.Timeout | null;
|
|
191
213
|
/** TTL duration in milliseconds. */
|
|
@@ -202,13 +224,76 @@ export interface QueryState {
|
|
|
202
224
|
lastIngestLatencyMs: number | null;
|
|
203
225
|
/** Cumulative count of ingest/materialization errors observed for this query. */
|
|
204
226
|
errorCount: number;
|
|
227
|
+
/**
|
|
228
|
+
* Ephemeral runtime fetch status. Not persisted to `_00_query`; observable
|
|
229
|
+
* via DevTools and the `useQuery` hook. `fetching` while the sync engine is
|
|
230
|
+
* pulling missing records for this query, otherwise `idle`.
|
|
231
|
+
*/
|
|
232
|
+
status: QueryStatus;
|
|
233
|
+
/**
|
|
234
|
+
* Rolling per-phase timing samples (ms), in addition to `materializationSamples`
|
|
235
|
+
* (which holds the SSP whole-ingest wall time). Keyed by `TimingPhase` minus
|
|
236
|
+
* `ssp`. Not persisted — surfaced live to DevTools + MCP via `phaseTimings`.
|
|
237
|
+
*/
|
|
238
|
+
phaseSamples: Record<string, number[]>;
|
|
239
|
+
/** Most recent sample (ms) per phase, or null. */
|
|
240
|
+
phaseLast: Record<string, number | null>;
|
|
241
|
+
/** One-shot SSP registration timings (ms). */
|
|
242
|
+
registrationTimings: RegistrationTimings;
|
|
205
243
|
}
|
|
206
244
|
|
|
207
245
|
/** Cap on the rolling materialization-sample window kept per query in memory. */
|
|
208
246
|
export const MATERIALIZATION_SAMPLE_WINDOW = 100;
|
|
209
247
|
|
|
248
|
+
/** Timed processing phases surfaced per query. `ssp` is the WASM-ingest wall
|
|
249
|
+
* time; the `ssp*` phases are its internal breakdown from the SSP binding. */
|
|
250
|
+
export type TimingPhase =
|
|
251
|
+
| 'ssp'
|
|
252
|
+
| 'sspStoreApply'
|
|
253
|
+
| 'sspCircuitStep'
|
|
254
|
+
| 'sspTransform'
|
|
255
|
+
| 'localFetch'
|
|
256
|
+
| 'remoteFetch'
|
|
257
|
+
| 'frontend';
|
|
258
|
+
|
|
259
|
+
/** One-shot registration timings (ms), captured once when a query registers. */
|
|
260
|
+
export interface RegistrationTimings {
|
|
261
|
+
/** SSP surql→plan parse + permission injection. */
|
|
262
|
+
parseMs: number | null;
|
|
263
|
+
/** SSP operator-DAG build. */
|
|
264
|
+
planMs: number | null;
|
|
265
|
+
/** SSP initial snapshot evaluation. */
|
|
266
|
+
snapshotMs: number | null;
|
|
267
|
+
/** Wall time of `cache.registerQuery` (register_view round-trip). */
|
|
268
|
+
wallMs: number | null;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Percentile summary for one timed phase, surfaced to DevTools + MCP. */
|
|
272
|
+
export interface PhaseStat {
|
|
273
|
+
lastMs: number | null;
|
|
274
|
+
p50: number | null;
|
|
275
|
+
p90: number | null;
|
|
276
|
+
p99: number | null;
|
|
277
|
+
count: number;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Per-query processing-time breakdown surfaced via DevTools panel + MCP. */
|
|
281
|
+
export interface QueryTimings {
|
|
282
|
+
ssp: PhaseStat;
|
|
283
|
+
sspStoreApply: PhaseStat;
|
|
284
|
+
sspCircuitStep: PhaseStat;
|
|
285
|
+
sspTransform: PhaseStat;
|
|
286
|
+
localFetch: PhaseStat;
|
|
287
|
+
remoteFetch: PhaseStat;
|
|
288
|
+
frontend: PhaseStat;
|
|
289
|
+
registration: RegistrationTimings;
|
|
290
|
+
updateCount: number;
|
|
291
|
+
errorCount: number;
|
|
292
|
+
}
|
|
293
|
+
|
|
210
294
|
// Callback types
|
|
211
295
|
export type QueryUpdateCallback = (records: Record<string, any>[]) => void;
|
|
296
|
+
export type QueryStatusCallback = (status: QueryStatus) => void;
|
|
212
297
|
export type MutationCallback = (mutations: UpEvent[]) => void;
|
|
213
298
|
|
|
214
299
|
export type MutationEventType = 'create' | 'update' | 'delete';
|
package/src/utils/index.ts
CHANGED
|
@@ -97,15 +97,25 @@ export function decodeFromSp00ky<S extends SchemaStructure, T extends TableNames
|
|
|
97
97
|
|
|
98
98
|
// ==================== TIME/DURATION UTILITIES ====================
|
|
99
99
|
|
|
100
|
+
/**
|
|
101
|
+
* Read the millisecond count off a surrealdb `Duration`. Different `surrealdb`
|
|
102
|
+
* releases expose it under `milliseconds` (current) or the older private
|
|
103
|
+
* `_milliseconds` field, so read whichever is set; returns 0 when neither is.
|
|
104
|
+
*/
|
|
105
|
+
function durationMillis(duration: Duration): number {
|
|
106
|
+
const d = duration as { milliseconds?: number | bigint; _milliseconds?: number | bigint };
|
|
107
|
+
return Number(d.milliseconds || d._milliseconds || 0);
|
|
108
|
+
}
|
|
109
|
+
|
|
100
110
|
/**
|
|
101
111
|
* Parse duration string or Duration object to milliseconds
|
|
102
112
|
*/
|
|
103
113
|
export function parseDuration(duration: QueryTimeToLive | Duration): number {
|
|
104
114
|
if (duration instanceof Duration) {
|
|
105
|
-
const ms = (duration
|
|
106
|
-
if (ms) return
|
|
115
|
+
const ms = durationMillis(duration);
|
|
116
|
+
if (ms) return ms;
|
|
107
117
|
const str = duration.toString();
|
|
108
|
-
if (str !== '[object Object]') return parseDuration(str as
|
|
118
|
+
if (str !== '[object Object]') return parseDuration(str as QueryTimeToLive);
|
|
109
119
|
return 600000;
|
|
110
120
|
}
|
|
111
121
|
|
package/tsdown.config.ts
CHANGED
|
@@ -1,4 +1,57 @@
|
|
|
1
1
|
import { defineConfig } from 'tsdown';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
|
|
4
|
+
const require = createRequire(import.meta.url);
|
|
5
|
+
|
|
6
|
+
// Resolve real package versions at build time so the DevTools can report the
|
|
7
|
+
// actual bundled frontend versions (used to detect frontend/backend drift).
|
|
8
|
+
const corePkg = require('./package.json');
|
|
9
|
+
const coreVersion: string = corePkg.version;
|
|
10
|
+
// ssp-wasm has no `exports` map; resolve its package.json directly, falling
|
|
11
|
+
// back to the relative workspace path if module resolution can't find it.
|
|
12
|
+
let wasmVersion: string;
|
|
13
|
+
try {
|
|
14
|
+
wasmVersion = require('@spooky-sync/ssp-wasm/package.json').version;
|
|
15
|
+
} catch {
|
|
16
|
+
wasmVersion = require('../ssp-wasm/package.json').version;
|
|
17
|
+
}
|
|
18
|
+
// Report the SurrealDB WASM ENGINE version (`@surrealdb/wasm`) — the engine the
|
|
19
|
+
// in-browser local DB actually runs — NOT the `surrealdb` JS client library
|
|
20
|
+
// version. The WASM engine is the one that must stay compatible with the server
|
|
21
|
+
// engine, so it's the meaningful number to compare in DevTools. Prefer the
|
|
22
|
+
// actually-installed version; fall back to our pinned range if its `exports`
|
|
23
|
+
// map blocks importing its package.json.
|
|
24
|
+
let surrealVersion: string;
|
|
25
|
+
try {
|
|
26
|
+
surrealVersion = require('@surrealdb/wasm/package.json').version;
|
|
27
|
+
} catch {
|
|
28
|
+
surrealVersion = String(corePkg.dependencies?.['@surrealdb/wasm'] ?? 'unknown').replace(
|
|
29
|
+
/^[\^~]/,
|
|
30
|
+
''
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// tsdown 0.12.x forwards a top-level `define` to rolldown's inputOptions, which
|
|
35
|
+
// rejects it — so we do the substitution ourselves with a tiny transform
|
|
36
|
+
// plugin. Only our own modules reference the `__SP00KY_*__` identifiers, and
|
|
37
|
+
// the early `includes` check skips everything else.
|
|
38
|
+
const replacements: Record<string, string> = {
|
|
39
|
+
__SP00KY_CORE_VERSION__: JSON.stringify(coreVersion),
|
|
40
|
+
__SP00KY_WASM_VERSION__: JSON.stringify(wasmVersion),
|
|
41
|
+
__SP00KY_SURREAL_VERSION__: JSON.stringify(surrealVersion),
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const versionDefinePlugin = {
|
|
45
|
+
name: 'sp00ky-version-define',
|
|
46
|
+
transform(code: string) {
|
|
47
|
+
if (!code.includes('__SP00KY_')) return null;
|
|
48
|
+
let out = code;
|
|
49
|
+
for (const [token, value] of Object.entries(replacements)) {
|
|
50
|
+
out = out.split(token).join(value);
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
},
|
|
54
|
+
};
|
|
2
55
|
|
|
3
56
|
export default defineConfig({
|
|
4
57
|
entry: ['src/index.ts', 'src/otel/index.ts'],
|
|
@@ -6,4 +59,5 @@ export default defineConfig({
|
|
|
6
59
|
dts: true,
|
|
7
60
|
clean: true,
|
|
8
61
|
hash: false,
|
|
62
|
+
plugins: [versionDefinePlugin],
|
|
9
63
|
});
|