@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
package/dist/otel/index.d.ts
CHANGED
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 {
|
|
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.
|
|
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.
|
|
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;
|
package/src/events/index.ts
CHANGED
|
@@ -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.
|
|
123
|
-
|
|
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
|
-
|
|
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): {
|
|
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
|
-
|
|
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
|
+
});
|