@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.
@@ -1,4 +1,4 @@
1
- import { l as PinoTransmit } from "../types.js";
1
+ import { u as PinoTransmit } from "../types.js";
2
2
  import { Level } from "pino";
3
3
 
4
4
  //#region src/otel/index.d.ts
package/dist/types.d.ts CHANGED
@@ -255,6 +255,16 @@ interface Sp00kyConfig<S extends SchemaStructure> {
255
255
  * values fall back to the default (500ms).
256
256
  */
257
257
  refSyncIntervalMs?: number;
258
+ /**
259
+ * Instant-hydrate cold queries: when a query is registered with no local
260
+ * data yet, first run its surql directly on the remote (one-shot) and display
261
+ * the result immediately, THEN do the full realtime registration in the
262
+ * background. The hydrated rows are ingested with their versions so the
263
+ * registration's `syncRecords` skips re-pulling unchanged bodies. Cuts cold
264
+ * first-paint from ~one full registration round-trip to ~one query.
265
+ * Defaults to `true`; set `false` to keep the old wait-for-registration path.
266
+ */
267
+ instantHydrate?: boolean;
258
268
  }
259
269
  type QueryHash = string;
260
270
  type RecordVersionArray = Array<[string, number]>;
@@ -301,6 +311,14 @@ interface QueryConfig {
301
311
  type QueryConfigRecord = QueryConfig & {
302
312
  id: string;
303
313
  };
314
+ /**
315
+ * Runtime fetch status of a live query.
316
+ * - `idle`: not currently fetching missing records.
317
+ * - `fetching`: the sync engine is fetching/ingesting missing records for this
318
+ * query. UI notifications are coalesced so the result lands as a single
319
+ * update once fetching completes.
320
+ */
321
+ type QueryStatus = 'idle' | 'fetching';
304
322
  /**
305
323
  * Internal state of a live query.
306
324
  */
@@ -309,6 +327,9 @@ interface QueryState {
309
327
  config: QueryConfig;
310
328
  /** The current cached records for this query. */
311
329
  records: Record<string, any>[];
330
+ /** Set once `applyHydration` has run for this query, so the cold instant-hydrate
331
+ * path fires at most once per query (see DataModule.isCold/applyHydration). */
332
+ hydrated?: boolean;
312
333
  /** Timer for TTL expiration. */
313
334
  ttlTimer: NodeJS.Timeout | null;
314
335
  /** TTL duration in milliseconds. */
@@ -325,10 +346,62 @@ interface QueryState {
325
346
  lastIngestLatencyMs: number | null;
326
347
  /** Cumulative count of ingest/materialization errors observed for this query. */
327
348
  errorCount: number;
349
+ /**
350
+ * Ephemeral runtime fetch status. Not persisted to `_00_query`; observable
351
+ * via DevTools and the `useQuery` hook. `fetching` while the sync engine is
352
+ * pulling missing records for this query, otherwise `idle`.
353
+ */
354
+ status: QueryStatus;
355
+ /**
356
+ * Rolling per-phase timing samples (ms), in addition to `materializationSamples`
357
+ * (which holds the SSP whole-ingest wall time). Keyed by `TimingPhase` minus
358
+ * `ssp`. Not persisted — surfaced live to DevTools + MCP via `phaseTimings`.
359
+ */
360
+ phaseSamples: Record<string, number[]>;
361
+ /** Most recent sample (ms) per phase, or null. */
362
+ phaseLast: Record<string, number | null>;
363
+ /** One-shot SSP registration timings (ms). */
364
+ registrationTimings: RegistrationTimings;
328
365
  }
329
366
  /** Cap on the rolling materialization-sample window kept per query in memory. */
330
367
  declare const MATERIALIZATION_SAMPLE_WINDOW = 100;
368
+ /** Timed processing phases surfaced per query. `ssp` is the WASM-ingest wall
369
+ * time; the `ssp*` phases are its internal breakdown from the SSP binding. */
370
+ type TimingPhase = 'ssp' | 'sspStoreApply' | 'sspCircuitStep' | 'sspTransform' | 'localFetch' | 'remoteFetch' | 'frontend';
371
+ /** One-shot registration timings (ms), captured once when a query registers. */
372
+ interface RegistrationTimings {
373
+ /** SSP surql→plan parse + permission injection. */
374
+ parseMs: number | null;
375
+ /** SSP operator-DAG build. */
376
+ planMs: number | null;
377
+ /** SSP initial snapshot evaluation. */
378
+ snapshotMs: number | null;
379
+ /** Wall time of `cache.registerQuery` (register_view round-trip). */
380
+ wallMs: number | null;
381
+ }
382
+ /** Percentile summary for one timed phase, surfaced to DevTools + MCP. */
383
+ interface PhaseStat {
384
+ lastMs: number | null;
385
+ p50: number | null;
386
+ p90: number | null;
387
+ p99: number | null;
388
+ count: number;
389
+ }
390
+ /** Per-query processing-time breakdown surfaced via DevTools panel + MCP. */
391
+ interface QueryTimings {
392
+ ssp: PhaseStat;
393
+ sspStoreApply: PhaseStat;
394
+ sspCircuitStep: PhaseStat;
395
+ sspTransform: PhaseStat;
396
+ localFetch: PhaseStat;
397
+ remoteFetch: PhaseStat;
398
+ frontend: PhaseStat;
399
+ registration: RegistrationTimings;
400
+ updateCount: number;
401
+ errorCount: number;
402
+ }
331
403
  type QueryUpdateCallback = (records: Record<string, any>[]) => void;
404
+ type QueryStatusCallback = (status: QueryStatus) => void;
332
405
  type MutationCallback = (mutations: UpEvent[]) => void;
333
406
  type MutationEventType = 'create' | 'update' | 'delete';
334
407
  /**
@@ -384,4 +457,4 @@ interface DebounceOptions {
384
457
  delay?: number;
385
458
  }
386
459
  //#endregion
387
- export { UpdateOptions as C, EventSystem as E, StoreType as S, EventDefinition as T, RecordVersionDiff as _, MutationCallback as a, Sp00kyQueryResult as b, PersistenceClient as c, QueryConfigRecord as d, QueryHash as f, RecordVersionArray as g, QueryUpdateCallback as h, MATERIALIZATION_SAMPLE_WINDOW as i, PinoTransmit as l, QueryTimeToLive as m, EventSubscriptionOptions as n, MutationEvent as o, QueryState as p, Level$1 as r, MutationEventType as s, DebounceOptions as t, QueryConfig as u, RunOptions as v, Logger$1 as w, Sp00kyQueryResultPromise as x, Sp00kyConfig as y };
460
+ export { Logger$1 as A, RunOptions as C, StoreType as D, Sp00kyQueryResultPromise as E, EventSystem as M, TimingPhase as O, RegistrationTimings as S, Sp00kyQueryResult as T, QueryTimeToLive as _, MutationCallback as a, RecordVersionArray as b, PersistenceClient as c, QueryConfig as d, QueryConfigRecord as f, QueryStatusCallback as g, QueryStatus as h, MATERIALIZATION_SAMPLE_WINDOW as i, EventDefinition as j, UpdateOptions as k, PhaseStat as l, QueryState as m, EventSubscriptionOptions as n, MutationEvent as o, QueryHash as p, Level$1 as r, MutationEventType as s, DebounceOptions as t, PinoTransmit as u, QueryTimings as v, Sp00kyConfig as w, RecordVersionDiff as x, QueryUpdateCallback as y };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spooky-sync/core",
3
- "version": "0.0.1-canary.69",
3
+ "version": "0.0.1-canary.70",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -61,7 +61,7 @@
61
61
  "dependencies": {
62
62
  "@spooky-sync/query-builder": "workspace:*",
63
63
  "@spooky-sync/ssp-wasm": "workspace:*",
64
- "@surrealdb/wasm": "^3.0.0",
64
+ "@surrealdb/wasm": "^3.0.3",
65
65
  "fast-json-patch": "^3.1.1",
66
66
  "loro-crdt": "^1.5.6",
67
67
  "pino": "^10.1.0",
@@ -0,0 +1,6 @@
1
+ // Build-time string constants injected by tsdown's `define` (see
2
+ // tsdown.config.ts). They carry the real bundled frontend package versions so
3
+ // the DevTools can surface frontend/backend version drift.
4
+ declare const __SP00KY_CORE_VERSION__: string;
5
+ declare const __SP00KY_WASM_VERSION__: string;
6
+ declare const __SP00KY_SURREAL_VERSION__: string;
@@ -172,6 +172,9 @@ export class EventSystem<E extends EventTypeMap> {
172
172
  * @param payload The data associated with the event.
173
173
  */
174
174
  emit<T extends EventType<E>, P extends EventPayload<E, T>>(type: T, payload: P): void {
175
+ // `{ type, payload }` structurally matches `Event<E, T>` (= the indexed
176
+ // access `E[T]`), but TS can't verify assignability to an indexed-access
177
+ // type over the generic `E`/`T`, so the constructed event is asserted.
175
178
  const event = {
176
179
  type,
177
180
  payload,
@@ -119,12 +119,16 @@ export class CacheModule implements StreamUpdateReceiver {
119
119
  await this.local.execute(query, params);
120
120
  }
121
121
 
122
- // 2. Batch ingest into DBSP (use populatedRecords which has _00_rv set)
123
- for (const record of populatedRecords) {
122
+ // 2. Bulk ingest into DBSP (use populatedRecords which has _00_rv set).
123
+ // ingestMany coalesces the per-record stream updates into a single
124
+ // notification per affected query — the UI then updates once, after the
125
+ // whole batch is ingested, instead of row-by-row.
126
+ const bulk = populatedRecords.map((record) => {
124
127
  const recordId = encodeRecordId(record.record.id);
125
128
  this.versionLookups[recordId] = record.version;
126
- this.streamProcessor.ingest(record.table, record.op, recordId, record.record);
127
- }
129
+ return { table: record.table, op: record.op, id: recordId, record: record.record };
130
+ });
131
+ this.streamProcessor.ingestMany(bulk);
128
132
 
129
133
  this.logger.debug(
130
134
  { count: records.length, Category: 'sp00ky-client::CacheModule::saveBatch' },
@@ -175,7 +179,10 @@ export class CacheModule implements StreamUpdateReceiver {
175
179
  * Register a query with DBSP to create a materialized view
176
180
  * Returns the initial result array
177
181
  */
178
- registerQuery(config: QueryConfig): { localArray: RecordVersionArray } {
182
+ registerQuery(config: QueryConfig): {
183
+ localArray: RecordVersionArray;
184
+ registrationTimings?: { parseMs: number; planMs: number; snapshotMs: number };
185
+ } {
179
186
  this.logger.debug(
180
187
  {
181
188
  queryHash: config.queryHash,
@@ -212,7 +219,7 @@ export class CacheModule implements StreamUpdateReceiver {
212
219
  'Query registered successfully'
213
220
  );
214
221
 
215
- return { localArray: update.localArray };
222
+ return { localArray: update.localArray, registrationTimings: update.registration };
216
223
  } catch (err) {
217
224
  this.logger.error(
218
225
  { err, queryHash: config.queryHash, Category: 'sp00ky-client::CacheModule::registerQuery' },
@@ -302,7 +302,15 @@ export class CrdtManager {
302
302
  private killTableSubscription(table: string): void {
303
303
  const uuid = this.liveByTable.get(table);
304
304
  if (uuid) {
305
- this.remote.query('KILL $uuid', { uuid }).catch(() => {});
305
+ // We're tearing down: KILL on an already-dead/closed LIVE throws, and the
306
+ // subscription may have ended on its own, so the failure is expected and
307
+ // safe to drop.
308
+ this.remote.query('KILL $uuid', { uuid }).catch((err) => {
309
+ this.logger.debug(
310
+ { err, table, Category: 'sp00ky-client::CrdtManager::killTableSubscription' },
311
+ 'KILL of table LIVE failed (already closed?)'
312
+ );
313
+ });
306
314
  this.liveByTable.delete(table);
307
315
  }
308
316
  }
@@ -0,0 +1,108 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import { RecordId } from 'surrealdb';
3
+ import { DataModule } from './index';
4
+ import type { QueryState, QueryStatus } from '../../types';
5
+
6
+ /**
7
+ * Tests for the per-query fetch status (idle/fetching) added to DataModule:
8
+ * setQueryStatus updates the query object and notifies both the DevTools
9
+ * observer hook and subscribeStatus listeners.
10
+ */
11
+
12
+ function makeLogger(): any {
13
+ const noop = () => {};
14
+ const logger: any = { debug: noop, info: noop, warn: noop, error: noop, trace: noop };
15
+ logger.child = () => logger;
16
+ return logger;
17
+ }
18
+
19
+ function makeDataModule(): DataModule<any> {
20
+ return new DataModule(
21
+ {} as any, // cache — unused by status methods
22
+ {} as any, // local — unused by status methods
23
+ { tables: [] } as any, // schema — unused by status methods
24
+ makeLogger(),
25
+ 100
26
+ );
27
+ }
28
+
29
+ function makeQueryState(hash: string): QueryState {
30
+ return {
31
+ config: {
32
+ id: new RecordId('_00_query', hash),
33
+ surql: 'SELECT * FROM user',
34
+ params: {},
35
+ localArray: [],
36
+ remoteArray: [],
37
+ ttl: '10m',
38
+ lastActiveAt: new Date(),
39
+ tableName: 'user',
40
+ },
41
+ records: [],
42
+ ttlTimer: null,
43
+ ttlDurationMs: 0,
44
+ updateCount: 0,
45
+ materializationSamples: [],
46
+ lastIngestLatencyMs: null,
47
+ errorCount: 0,
48
+ status: 'idle',
49
+ };
50
+ }
51
+
52
+ describe('DataModule query fetch status', () => {
53
+ let dm: DataModule<any>;
54
+ const hash = 'h1';
55
+
56
+ beforeEach(() => {
57
+ dm = makeDataModule();
58
+ (dm as any).activeQueries.set(hash, makeQueryState(hash));
59
+ });
60
+
61
+ it('subscribeStatus with immediate reports the current status', () => {
62
+ const seen: QueryStatus[] = [];
63
+ dm.subscribeStatus(hash, (s) => seen.push(s), { immediate: true });
64
+ expect(seen).toEqual(['idle']);
65
+ });
66
+
67
+ it('setQueryStatus updates the query object and notifies subscribers + observer', () => {
68
+ const observed: Array<[string, QueryStatus]> = [];
69
+ dm.onQueryStatusChange = (h, s) => observed.push([h, s]);
70
+
71
+ const seen: QueryStatus[] = [];
72
+ dm.subscribeStatus(hash, (s) => seen.push(s));
73
+
74
+ dm.setQueryStatus(hash, 'fetching');
75
+ expect((dm as any).activeQueries.get(hash).status).toBe('fetching');
76
+ expect(seen).toEqual(['fetching']);
77
+ expect(observed).toEqual([[hash, 'fetching']]);
78
+
79
+ dm.setQueryStatus(hash, 'idle');
80
+ expect(seen).toEqual(['fetching', 'idle']);
81
+ expect(observed).toEqual([[hash, 'fetching'], [hash, 'idle']]);
82
+ });
83
+
84
+ it('setQueryStatus is a no-op when the status is unchanged', () => {
85
+ const seen: QueryStatus[] = [];
86
+ dm.subscribeStatus(hash, (s) => seen.push(s));
87
+ dm.setQueryStatus(hash, 'idle'); // already idle
88
+ expect(seen).toEqual([]);
89
+ });
90
+
91
+ it('setQueryStatus is a no-op for an unknown query', () => {
92
+ let called = false;
93
+ dm.onQueryStatusChange = () => {
94
+ called = true;
95
+ };
96
+ dm.setQueryStatus('does-not-exist', 'fetching');
97
+ expect(called).toBe(false);
98
+ });
99
+
100
+ it('unsubscribe stops further status notifications', () => {
101
+ const seen: QueryStatus[] = [];
102
+ const unsub = dm.subscribeStatus(hash, (s) => seen.push(s));
103
+ dm.setQueryStatus(hash, 'fetching');
104
+ unsub();
105
+ dm.setQueryStatus(hash, 'idle');
106
+ expect(seen).toEqual(['fetching']);
107
+ });
108
+ });