@spooky-sync/core 0.0.1-canary.237 → 0.0.1-canary.239

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 { d as PinoTransmit } from "../types.js";
1
+ import { f 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
@@ -891,32 +891,6 @@ interface QueryConfig {
891
891
  * by an old client that mirrored unflushed reads still self-heals.
892
892
  */
893
893
  membershipKnown?: boolean;
894
- /**
895
- * Whether a NON-EMPTY id-set has arrived from the server for this query in
896
- * this session. Gates whether an empty read may be believed.
897
- *
898
- * The server publishes `_00_list_ref` asynchronously — the SSP queues a
899
- * view's initial edges to a coalescing flusher and returns from
900
- * `fn::query::register` before they land — so an empty read right after
901
- * registration says nothing about the query being empty. Believing it (and
902
- * mirroring it to the durable `_00_window` row) blanked lists and kept them
903
- * blank across reloads. Once a real set has been seen, a later empty one is a
904
- * genuine transition and must be honoured, or removed rows resurrect.
905
- *
906
- * In-memory only: a fresh session must re-earn the right to believe empties.
907
- * What does persist is the `confirmed` marker on the `_00_window` row, which
908
- * an empty set earns when it arrives with a server row count of zero or after
909
- * a non-empty set in the same session.
910
- */
911
- remoteSeen?: boolean;
912
- /**
913
- * Consecutive empty id-sets read from the server while `remoteSeen` is still
914
- * false. Bounds how long an unconfirmed empty may be ignored, so a window
915
- * that genuinely emptied while this device was away is believed on the second
916
- * read instead of rendering stale rows forever. Reset by any non-empty set.
917
- * In-memory only.
918
- */
919
- emptyReads?: number;
920
894
  /**
921
895
  * Key of this query's durable `_00_window` membership row: a hash of
922
896
  * `{surql, params}` WITHOUT the `session::id()` salt that `id` carries, so it
@@ -945,6 +919,28 @@ type QueryConfigRecord = QueryConfig & {
945
919
  * flip back to `idle`, so idle status never races ahead of the rows.
946
920
  */
947
921
  type QueryStatus = 'idle' | 'fetching';
922
+ /**
923
+ * What the server's `_00_query` row says about a view, read alongside its
924
+ * `_00_list_ref` edges (registration, the poll, a targeted re-read).
925
+ *
926
+ * - `present: false`: the row is not there (or not readable). The SSP no
927
+ * longer has this view: a TTL sweep, an SSP reset, a scheduler wipe. An
928
+ * empty edge set read at the same time is NOT "no rows", it is "no view".
929
+ * - `state: 'materializing'`: the SSP has (re)computed the set and its edges
930
+ * are in flight; an empty read is "not landed yet".
931
+ * - `state: 'ready'`: the edges are committed (flipped in the same
932
+ * transaction), so `rowCount === 0` with no edges is a real empty result.
933
+ * - `state: null`: a server that predates the marker; only `rowCount` can be
934
+ * used, with its `> 0` = flush window / `0` = empty ambiguity.
935
+ */
936
+ interface ServerViewMeta {
937
+ present: boolean;
938
+ /** `_00_query.rowCount`; `null` when unreadable. */
939
+ rowCount: number | null;
940
+ state: 'materializing' | 'ready' | null;
941
+ }
942
+ /** Outcome of handing a server id-set to `DataModule.updateQueryRemoteArray`. */
943
+ type MembershipOutcome = 'applied' | 'ignored' | 'view-lost';
948
944
  /**
949
945
  * Internal state of a live query.
950
946
  */
@@ -961,6 +957,15 @@ interface QueryState {
961
957
  * the instant-hydrate, so a hydrate that finishes later must not prime
962
958
  * membership over the authoritative set. Ephemeral. */
963
959
  serverMembership?: boolean;
960
+ /**
961
+ * Set when the server's `_00_query` row for this query went missing while
962
+ * we held membership (TTL sweep, SSP reset, scheduler wipe). The view is
963
+ * being re-registered; until a readable row comes back, empty reads are
964
+ * NOT the server's answer and nothing is dropped. Ephemeral.
965
+ */
966
+ viewLost?: boolean;
967
+ /** Last `_00_query.state` read from the server, for DevTools. Ephemeral. */
968
+ serverState?: 'materializing' | 'ready' | null;
964
969
  /** Set once `notifyQuerySynced` has emitted for this registration lifetime.
965
970
  * Ephemeral (unlike the persisted `updateCount`), so a re-registered query
966
971
  * always emits at least once even when its records are unchanged — otherwise
@@ -970,6 +975,8 @@ interface QueryState {
970
975
  ttlTimer: NodeJS.Timeout | null;
971
976
  /** TTL duration in milliseconds. */
972
977
  ttlDurationMs: number;
978
+ /** When the last TTL heartbeat for this query was sent (ms epoch); `undefined` before the first. */
979
+ lastHeartbeatAt?: number;
973
980
  /** Number of times the query has been updated. */
974
981
  updateCount: number;
975
982
  /** Timestamp (ms) of the last user-visible update, or null before the first
@@ -1041,6 +1048,12 @@ interface QueryTimings {
1041
1048
  }
1042
1049
  type QueryUpdateCallback = (records: Record<string, any>[]) => void;
1043
1050
  type QueryStatusCallback = (status: QueryStatus) => void;
1051
+ /**
1052
+ * Observer for a query's authority flips: `true` once server membership is
1053
+ * known for it (registration, poll, durable seed), `false` when it is reset
1054
+ * (bucket switch). What the bindings' `isAuthoritative()` mirrors.
1055
+ */
1056
+ type QueryAuthorityCallback = (known: boolean) => void;
1044
1057
  type MutationCallback = (mutations: UpEvent[]) => void;
1045
1058
  type MutationEventType = 'create' | 'update' | 'delete';
1046
1059
  /**
@@ -1101,4 +1114,4 @@ interface DebounceOptions {
1101
1114
  delay?: number;
1102
1115
  }
1103
1116
  //#endregion
1104
- export { Sp00kyQueryResultPromise as A, LocalStore as B, ReconnectConfig as C, RunOptions as D, RegistrationTimings as E, SyncHealthConfig as F, SyncEventSystem as G, DatabaseEventSystem as H, SyncHealthStatus as I, EventDefinition as K, TimingPhase as L, StorageHealthStatus as M, StoreType as N, Sp00kyConfig as O, SyncHealth as P, UpdateOptions as R, QueryUpdateCallback as S, RecordVersionDiff as T, DatabaseEventTypes as U, SealedQuery as V, Logger$1 as W, QueryState as _, MATERIALIZATION_SAMPLE_WINDOW as a, QueryTimeToLive as b, MutationEventType as c, PinoTransmit as d, PreloadOptions as f, QueryHash as g, QueryConfigRecord as h, Level$1 as i, StorageHealth as j, Sp00kyQueryResult as k, PersistenceClient as l, QueryConfig as m, DebounceOptions as n, MutationCallback as o, PreloadRefresh as p, EventSystem as q, EventSubscriptionOptions as r, MutationEvent as s, ConnectionState as t, PhaseStat as u, QueryStatus as v, RecordVersionArray as w, QueryTimings as x, QueryStatusCallback as y, UpEvent as z };
1117
+ export { ServerViewMeta as A, TimingPhase as B, QueryTimings as C, RecordVersionDiff as D, RecordVersionArray as E, StorageHealthStatus as F, DatabaseEventSystem as G, UpEvent as H, StoreType as I, SyncEventSystem as J, DatabaseEventTypes as K, SyncHealth as L, Sp00kyQueryResult as M, Sp00kyQueryResultPromise as N, RegistrationTimings as O, StorageHealth as P, SyncHealthConfig as R, QueryTimeToLive as S, ReconnectConfig as T, LocalStore as U, UpdateOptions as V, SealedQuery as W, EventSystem as X, EventDefinition as Y, QueryConfigRecord as _, MATERIALIZATION_SAMPLE_WINDOW as a, QueryStatus as b, MutationEvent as c, PhaseStat as d, PinoTransmit as f, QueryConfig as g, QueryAuthorityCallback as h, Level$1 as i, Sp00kyConfig as j, RunOptions as k, MutationEventType as l, PreloadRefresh as m, DebounceOptions as n, MembershipOutcome as o, PreloadOptions as p, Logger$1 as q, EventSubscriptionOptions as r, MutationCallback as s, ConnectionState as t, PersistenceClient as u, QueryHash as v, QueryUpdateCallback as w, QueryStatusCallback as x, QueryState as y, SyncHealthStatus as z };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spooky-sync/core",
3
- "version": "0.0.1-canary.237",
3
+ "version": "0.0.1-canary.239",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "main": "./dist/index.js",
@@ -60,8 +60,8 @@
60
60
  }
61
61
  },
62
62
  "dependencies": {
63
- "@spooky-sync/query-builder": "0.0.1-canary.237",
64
- "@spooky-sync/ssp-wasm": "0.0.1-canary.237",
63
+ "@spooky-sync/query-builder": "0.0.1-canary.239",
64
+ "@spooky-sync/ssp-wasm": "0.0.1-canary.239",
65
65
  "@sqlite.org/sqlite-wasm": "3.53.0-build1",
66
66
  "@surrealdb/wasm": "^3.0.3",
67
67
  "blurhash": "^2.0.5",
@@ -0,0 +1,109 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { RecordId } from 'surrealdb';
3
+ import { DataModule } from './index';
4
+ import type { QueryState } from '../../types';
5
+
6
+ /**
7
+ * The aggregate fetch-activity channel (`subscribeActivity`): one number, how
8
+ * many queries are inside their outermost fetch cycle, so an app-chrome
9
+ * "downloading" indicator needs a single subscription instead of one per query.
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({} as any, {} as any, { tables: [] } as any, makeLogger(), 100);
21
+ }
22
+
23
+ function makeQueryState(hash: string): QueryState {
24
+ return {
25
+ config: {
26
+ id: new RecordId('_00_query', hash),
27
+ surql: 'SELECT * FROM user',
28
+ params: {},
29
+ localArray: [],
30
+ remoteArray: [],
31
+ ttl: '10m',
32
+ lastActiveAt: new Date(),
33
+ tableName: 'user',
34
+ },
35
+ records: [],
36
+ ttlTimer: null,
37
+ ttlDurationMs: 0,
38
+ updateCount: 0,
39
+ lastUpdatedAt: null,
40
+ materializationSamples: [],
41
+ lastIngestLatencyMs: null,
42
+ errorCount: 0,
43
+ status: 'idle',
44
+ phaseSamples: {},
45
+ phaseLast: {},
46
+ registrationTimings: { parseMs: null, planMs: null, snapshotMs: null, wallMs: null },
47
+ };
48
+ }
49
+
50
+ describe('DataModule fetch activity', () => {
51
+ it('emits the current count immediately and on every change', () => {
52
+ const dm = makeDataModule();
53
+ (dm as any).activeQueries.set('a', makeQueryState('a'));
54
+ (dm as any).activeQueries.set('b', makeQueryState('b'));
55
+ const seen: number[] = [];
56
+ dm.subscribeActivity((n) => seen.push(n), { immediate: true });
57
+ expect(seen).toEqual([0]);
58
+
59
+ dm.beginFetching('a');
60
+ dm.beginFetching('b');
61
+ expect(seen).toEqual([0, 1, 2]);
62
+ expect(dm.fetchingQueryCount).toBe(2);
63
+
64
+ dm.endFetching('a');
65
+ dm.endFetching('b');
66
+ expect(seen).toEqual([0, 1, 2, 1, 0]);
67
+ });
68
+
69
+ it('counts a query once however deep its fetch cycles nest', () => {
70
+ const dm = makeDataModule();
71
+ (dm as any).activeQueries.set('a', makeQueryState('a'));
72
+ const seen: number[] = [];
73
+ dm.subscribeActivity((n) => seen.push(n));
74
+ dm.beginFetching('a');
75
+ dm.beginFetching('a'); // overlapping poll round on the same hash
76
+ dm.endFetching('a');
77
+ expect(seen).toEqual([1], 'the inner cycle is silent');
78
+ dm.endFetching('a');
79
+ expect(seen).toEqual([1, 0]);
80
+ });
81
+
82
+ it('stops notifying after unsubscribe and survives a throwing subscriber', () => {
83
+ const dm = makeDataModule();
84
+ (dm as any).activeQueries.set('a', makeQueryState('a'));
85
+ const bad = vi.fn(() => {
86
+ throw new Error('boom');
87
+ });
88
+ const good = vi.fn();
89
+ dm.subscribeActivity(bad);
90
+ const off = dm.subscribeActivity(good);
91
+ dm.beginFetching('a');
92
+ expect(good).toHaveBeenCalledWith(1);
93
+ off();
94
+ dm.endFetching('a');
95
+ expect(good).toHaveBeenCalledTimes(1);
96
+ expect(bad).toHaveBeenCalledTimes(2);
97
+ });
98
+
99
+ it('drops to zero when a bucket switch quiesces in-flight fetches', () => {
100
+ const dm = makeDataModule();
101
+ (dm as any).activeQueries.set('a', makeQueryState('a'));
102
+ const seen: number[] = [];
103
+ dm.subscribeActivity((n) => seen.push(n));
104
+ dm.beginFetching('a');
105
+ dm.quiesce();
106
+ expect(seen).toEqual([1, 0]);
107
+ expect(dm.fetchingQueryCount).toBe(0);
108
+ });
109
+ });
@@ -0,0 +1,122 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { RecordId } from 'surrealdb';
3
+ import { DataModule } from './index';
4
+ import type { QueryState } from '../../types';
5
+
6
+ /**
7
+ * A query's AUTHORITY is whether server membership is known for it. It is what
8
+ * the bindings' `isAuthoritative()` mirrors, and with `hasData` it is what
9
+ * separates "cold, still waiting for the server" from "the server answered and
10
+ * the answer is empty". `setMembershipKnown` is the one writer; it fans out to
11
+ * `subscribeAuthority` listeners and the DevTools hook only on a change.
12
+ */
13
+
14
+ function makeLogger(): any {
15
+ const noop = () => {};
16
+ const logger: any = { debug: noop, info: noop, warn: noop, error: noop, trace: noop };
17
+ logger.child = () => logger;
18
+ return logger;
19
+ }
20
+
21
+ function makeDataModule(): DataModule<any> {
22
+ return new DataModule(
23
+ { unregisterQuery: vi.fn() } as any,
24
+ {} as any,
25
+ { tables: [] } as any,
26
+ makeLogger(),
27
+ 100
28
+ );
29
+ }
30
+
31
+ function makeQueryState(hash: string, membershipKnown?: boolean): QueryState {
32
+ return {
33
+ config: {
34
+ id: new RecordId('_00_query', hash),
35
+ surql: 'SELECT * FROM user',
36
+ params: {},
37
+ localArray: [],
38
+ remoteArray: [],
39
+ membershipKnown,
40
+ ttl: '10m',
41
+ lastActiveAt: new Date(),
42
+ tableName: 'user',
43
+ },
44
+ records: [],
45
+ ttlTimer: null,
46
+ ttlDurationMs: 0,
47
+ updateCount: 0,
48
+ lastUpdatedAt: null,
49
+ materializationSamples: [],
50
+ lastIngestLatencyMs: null,
51
+ errorCount: 0,
52
+ status: 'idle',
53
+ phaseSamples: {},
54
+ phaseLast: {},
55
+ registrationTimings: { parseMs: null, planMs: null, snapshotMs: null, wallMs: null },
56
+ };
57
+ }
58
+
59
+ describe('DataModule query authority', () => {
60
+ const hash = 'h1';
61
+
62
+ it('reports the current value immediately, false for an unknown query', () => {
63
+ const dm = makeDataModule();
64
+ const unknown = vi.fn();
65
+ dm.subscribeAuthority('nope', unknown, { immediate: true });
66
+ expect(unknown).toHaveBeenCalledWith(false);
67
+
68
+ (dm as any).activeQueries.set(hash, makeQueryState(hash, true));
69
+ const known = vi.fn();
70
+ dm.subscribeAuthority(hash, known, { immediate: true });
71
+ expect(known).toHaveBeenCalledWith(true);
72
+ expect(dm.isAuthoritative(hash)).toBe(true);
73
+ expect(dm.isAuthoritative('nope')).toBe(false);
74
+ });
75
+
76
+ it('emits once per transition through the single writer', () => {
77
+ const dm = makeDataModule();
78
+ (dm as any).activeQueries.set(hash, makeQueryState(hash));
79
+ const cb = vi.fn();
80
+ const hook = vi.fn();
81
+ dm.onQueryAuthorityChange = hook;
82
+ dm.subscribeAuthority(hash, cb);
83
+
84
+ (dm as any).setMembershipKnown(hash, true);
85
+ (dm as any).setMembershipKnown(hash, true);
86
+ expect(cb).toHaveBeenCalledTimes(1);
87
+ expect(cb).toHaveBeenLastCalledWith(true);
88
+ expect(hook).toHaveBeenCalledWith(hash, true);
89
+
90
+ (dm as any).setMembershipKnown(hash, false);
91
+ expect(cb).toHaveBeenCalledTimes(2);
92
+ expect(cb).toHaveBeenLastCalledWith(false);
93
+ expect(dm.isAuthoritative(hash)).toBe(false);
94
+ });
95
+
96
+ it('is a no-op for an unknown query', () => {
97
+ const dm = makeDataModule();
98
+ const hook = vi.fn();
99
+ dm.onQueryAuthorityChange = hook;
100
+ (dm as any).setMembershipKnown('nope', true);
101
+ expect(hook).not.toHaveBeenCalled();
102
+ });
103
+
104
+ it('stops delivering after unsubscribe', () => {
105
+ const dm = makeDataModule();
106
+ (dm as any).activeQueries.set(hash, makeQueryState(hash));
107
+ const cb = vi.fn();
108
+ const off = dm.subscribeAuthority(hash, cb);
109
+ off();
110
+ (dm as any).setMembershipKnown(hash, true);
111
+ expect(cb).not.toHaveBeenCalled();
112
+ expect((dm as any).authoritySubscriptions.has(hash)).toBe(false);
113
+ });
114
+
115
+ it('drops the authority subscribers with the query on finalizeDeregister', () => {
116
+ const dm = makeDataModule();
117
+ (dm as any).activeQueries.set(hash, makeQueryState(hash));
118
+ dm.subscribeAuthority(hash, vi.fn());
119
+ dm.finalizeDeregister(hash);
120
+ expect((dm as any).authoritySubscriptions.has(hash)).toBe(false);
121
+ });
122
+ });
@@ -0,0 +1,115 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { RecordId } from 'surrealdb';
3
+ import { DataModule } from './index';
4
+ import type { QueryState } from '../../types';
5
+
6
+ /**
7
+ * The TTL heartbeat is the only thing keeping a view alive on the server, and
8
+ * browsers freeze timers in hidden tabs. `heartbeatOnWake` re-asserts liveness
9
+ * for every WATCHED query the moment the tab is back (visibilitychange /
10
+ * online) and re-anchors its timer. There is no client-side expiry anywhere:
11
+ * the TTL belongs to the server.
12
+ */
13
+
14
+ function makeLogger(): any {
15
+ const noop = () => {};
16
+ const logger: any = { debug: noop, info: noop, warn: noop, error: noop, trace: noop };
17
+ logger.child = () => logger;
18
+ return logger;
19
+ }
20
+
21
+ function makeDataModule(): DataModule<any> {
22
+ return new DataModule({} as any, {} as any, { tables: [] } as any, makeLogger(), 100);
23
+ }
24
+
25
+ const TTL_MS = 600_000;
26
+
27
+ function makeQueryState(hash: string): QueryState {
28
+ return {
29
+ config: {
30
+ id: new RecordId('_00_query', hash),
31
+ surql: 'SELECT * FROM user',
32
+ params: {},
33
+ localArray: [],
34
+ remoteArray: [['user:a', 1]],
35
+ membershipKnown: true,
36
+ ttl: '10m',
37
+ lastActiveAt: new Date(),
38
+ tableName: 'user',
39
+ },
40
+ records: [{ id: 'user:a' }],
41
+ ttlTimer: null,
42
+ ttlDurationMs: TTL_MS,
43
+ updateCount: 0,
44
+ lastUpdatedAt: null,
45
+ materializationSamples: [],
46
+ lastIngestLatencyMs: null,
47
+ errorCount: 0,
48
+ status: 'idle',
49
+ phaseSamples: {},
50
+ phaseLast: {},
51
+ registrationTimings: { parseMs: null, planMs: null, snapshotMs: null, wallMs: null },
52
+ };
53
+ }
54
+
55
+ function setup() {
56
+ const dm = makeDataModule();
57
+ const onHeartbeat = vi.fn();
58
+ dm.onHeartbeat = onHeartbeat;
59
+ const watched = makeQueryState('watched');
60
+ const idle = makeQueryState('idle');
61
+ (dm as any).activeQueries.set('watched', watched);
62
+ (dm as any).activeQueries.set('idle', idle);
63
+ (dm as any).subscriptions.set('watched', new Set([() => {}]));
64
+ (dm as any).startTTLHeartbeat(watched, 'watched');
65
+ (dm as any).startTTLHeartbeat(idle, 'idle');
66
+ return { dm, onHeartbeat, watched, idle };
67
+ }
68
+
69
+ describe('DataModule heartbeat on wake', () => {
70
+ beforeEach(() => vi.useFakeTimers());
71
+ afterEach(() => vi.useRealTimers());
72
+
73
+ it('beats every watched query now and re-anchors its timer', async () => {
74
+ const { dm, onHeartbeat, watched } = setup();
75
+ await vi.advanceTimersByTimeAsync(200_000);
76
+
77
+ expect(dm.heartbeatOnWake()).toBe(1);
78
+ expect(onHeartbeat).toHaveBeenCalledTimes(1);
79
+ expect(onHeartbeat).toHaveBeenCalledWith('watched');
80
+ expect(watched.ttlTimer).not.toBeNull();
81
+
82
+ // The regular beat would have been due at 300s; it is now due 300s after
83
+ // the wake beat instead.
84
+ await vi.advanceTimersByTimeAsync(TTL_MS / 2 - 1_000);
85
+ expect(onHeartbeat).toHaveBeenCalledTimes(1);
86
+ await vi.advanceTimersByTimeAsync(2_000);
87
+ expect(onHeartbeat).toHaveBeenCalledTimes(2);
88
+ });
89
+
90
+ it('skips queries nobody is watching', () => {
91
+ const { dm, onHeartbeat } = setup();
92
+ dm.heartbeatOnWake();
93
+ expect(onHeartbeat).not.toHaveBeenCalledWith('idle');
94
+ });
95
+
96
+ it('does not beat the same query twice within the gap', async () => {
97
+ const { dm, onHeartbeat } = setup();
98
+ expect(dm.heartbeatOnWake()).toBe(1);
99
+ expect(dm.heartbeatOnWake()).toBe(0);
100
+ await vi.advanceTimersByTimeAsync(31_000);
101
+ expect(dm.heartbeatOnWake()).toBe(1);
102
+ expect(onHeartbeat).toHaveBeenCalledTimes(2);
103
+ });
104
+
105
+ it('never expires local query state on its own', async () => {
106
+ const { dm, watched, idle } = setup();
107
+ // Well past the TTL, with no server in the picture.
108
+ await vi.advanceTimersByTimeAsync(TTL_MS * 3);
109
+ expect((dm as any).activeQueries.get('watched')).toBe(watched);
110
+ expect((dm as any).activeQueries.get('idle')).toBe(idle);
111
+ expect(watched.records).toEqual([{ id: 'user:a' }]);
112
+ expect(watched.config.remoteArray).toEqual([['user:a', 1]]);
113
+ expect(watched.config.membershipKnown).toBe(true);
114
+ });
115
+ });