@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.
- package/AGENTS.md +1 -1
- package/dist/index.d.ts +134 -5
- package/dist/index.js +498 -83
- package/dist/otel/index.d.ts +1 -1
- package/dist/types.d.ts +40 -27
- package/package.json +3 -3
- package/src/modules/data/data.activity.test.ts +109 -0
- package/src/modules/data/data.authority.test.ts +122 -0
- package/src/modules/data/data.heartbeat-wake.test.ts +115 -0
- package/src/modules/data/data.membership.test.ts +168 -68
- package/src/modules/data/index.ts +248 -83
- package/src/modules/devtools/index.ts +3 -2
- package/src/modules/sync/engine.ts +12 -7
- package/src/modules/sync/sync.health.test.ts +13 -3
- package/src/modules/sync/sync.list-ref-meta.test.ts +193 -0
- package/src/modules/sync/sync.live-removal.test.ts +137 -23
- package/src/modules/sync/sync.ts +326 -60
- package/src/modules/sync/utils.test.ts +1 -1
- package/src/modules/sync/utils.ts +10 -2
- package/src/sp00ky.ts +64 -1
- package/src/types.ts +41 -26
- package/src/utils/error-classification.test.ts +10 -0
- package/src/utils/error-classification.ts +18 -0
package/dist/otel/index.d.ts
CHANGED
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 {
|
|
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.
|
|
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.
|
|
64
|
-
"@spooky-sync/ssp-wasm": "0.0.1-canary.
|
|
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
|
+
});
|