@spooky-sync/core 0.0.1-canary.235 → 0.0.1-canary.237
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 +3 -2
- package/dist/index.d.ts +95 -30
- package/dist/index.js +356 -111
- package/dist/types.d.ts +5 -0
- package/package.json +3 -3
- package/src/modules/data/index.ts +7 -0
- package/src/modules/sync/sync.health.test.ts +34 -26
- package/src/modules/sync/sync.ts +211 -30
- package/src/modules/sync/utils.test.ts +53 -0
- package/src/modules/sync/utils.ts +77 -0
- package/src/services/database/database.query-timeout.test.ts +58 -1
- package/src/services/database/database.ts +103 -73
- package/src/services/database/remote.ts +8 -0
- package/src/sp00ky.init-query.test.ts +10 -10
- package/src/sp00ky.ts +47 -28
- package/src/types.ts +5 -0
|
@@ -1121,6 +1121,10 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
1121
1121
|
const queryState = this.activeQueries.get(hash);
|
|
1122
1122
|
if (!queryState) return;
|
|
1123
1123
|
queryState.hydrated = true; // run-once, even when the remote returns nothing
|
|
1124
|
+
// The registration is enqueued BEFORE the hydrate and can land first; its
|
|
1125
|
+
// `_00_list_ref` set is authoritative and this one-shot must not replace
|
|
1126
|
+
// it. Checked again after the await below for the same reason.
|
|
1127
|
+
if (queryState.serverMembership) return;
|
|
1124
1128
|
if (rows.length === 0) return;
|
|
1125
1129
|
|
|
1126
1130
|
const epoch = this.local.epoch;
|
|
@@ -1131,6 +1135,7 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
1131
1135
|
// state; the rebind's re-registration refills it. (saveBatch's own epoch
|
|
1132
1136
|
// fence usually catches this, but the switch can land between it and here.)
|
|
1133
1137
|
if (epoch !== this.local.epoch) return;
|
|
1138
|
+
if (queryState.serverMembership) return;
|
|
1134
1139
|
|
|
1135
1140
|
// Prime remoteArray from the hydrated id+version pairs: `materializeRecords`
|
|
1136
1141
|
// renders from it and it feeds the version dedup. Registration later
|
|
@@ -1550,6 +1555,7 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
1550
1555
|
// the `_00_list_ref` poll both land here), so it is where "we now know the
|
|
1551
1556
|
// membership" is latched and where the durable mirror is written.
|
|
1552
1557
|
queryState.config.membershipKnown = true;
|
|
1558
|
+
queryState.serverMembership = true;
|
|
1553
1559
|
if (remoteArray.length > 0) {
|
|
1554
1560
|
// Earns the right to believe a later empty set — that transition is a
|
|
1555
1561
|
// genuine removal and must be honoured, or removed rows resurrect.
|
|
@@ -1635,6 +1641,7 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
1635
1641
|
}
|
|
1636
1642
|
}
|
|
1637
1643
|
queryState.hydrated = false;
|
|
1644
|
+
queryState.serverMembership = false;
|
|
1638
1645
|
queryState.syncNotified = false;
|
|
1639
1646
|
queryState.records = [];
|
|
1640
1647
|
// Via setQueryStatus (not a bare assignment) so status observers see the
|
|
@@ -21,7 +21,7 @@ function makeSync(hashes: string[]) {
|
|
|
21
21
|
trace: () => {},
|
|
22
22
|
};
|
|
23
23
|
const remote: any = { query: vi.fn().mockResolvedValue([true]) };
|
|
24
|
-
const dataModule: any = { getActiveQueryHashes: () => hashes };
|
|
24
|
+
const dataModule: any = { getActiveQueryHashes: () => hashes, getQueryByHash: () => undefined };
|
|
25
25
|
|
|
26
26
|
const sync = new Sp00kySync(
|
|
27
27
|
{} as any,
|
|
@@ -33,21 +33,28 @@ function makeSync(hashes: string[]) {
|
|
|
33
33
|
{ degradeAfterConsecutiveFailures: 3 }
|
|
34
34
|
);
|
|
35
35
|
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
|
|
39
|
-
|
|
36
|
+
// The poll reads every chunk of active hashes in one round trip
|
|
37
|
+
// (`fetchListRefSnapshots`) and lands each query's snapshot separately
|
|
38
|
+
// (`applyListRefSnapshot`). Stub both so the test controls per-cycle
|
|
39
|
+
// reachability. `poll` invokes the private method.
|
|
40
|
+
const fetch = vi.fn();
|
|
41
|
+
const apply = vi.fn().mockResolvedValue(false);
|
|
42
|
+
(sync as any).fetchListRefSnapshots = fetch;
|
|
43
|
+
(sync as any).applyListRefSnapshot = apply;
|
|
44
|
+
const snapshotsFor = (hs: string[]) =>
|
|
45
|
+
new Map(hs.map((h) => [h, { primary: [], subquery: [], rowCount: null }]));
|
|
46
|
+
fetch.mockImplementation(async (hs: string[]) => snapshotsFor(hs));
|
|
40
47
|
const poll = () => (sync as any).pollListRefForActiveQueries() as Promise<boolean>;
|
|
41
48
|
|
|
42
|
-
return { sync, remote,
|
|
49
|
+
return { sync, remote, fetch, apply, snapshotsFor, poll };
|
|
43
50
|
}
|
|
44
51
|
|
|
45
52
|
describe('sync health via idle poll', () => {
|
|
46
53
|
beforeEach(() => vi.clearAllMocks());
|
|
47
54
|
|
|
48
55
|
it('degrades after N consecutive network-failed poll cycles', async () => {
|
|
49
|
-
const { sync,
|
|
50
|
-
|
|
56
|
+
const { sync, fetch, poll } = makeSync(['h1']);
|
|
57
|
+
fetch.mockRejectedValue(new Error(CONNECTION_UNAVAILABLE));
|
|
51
58
|
|
|
52
59
|
await poll();
|
|
53
60
|
expect(sync.syncHealth.status).toBe('healthy');
|
|
@@ -59,22 +66,22 @@ describe('sync health via idle poll', () => {
|
|
|
59
66
|
});
|
|
60
67
|
|
|
61
68
|
it('recovers on the next clean poll cycle — no mutation needed', async () => {
|
|
62
|
-
const { sync,
|
|
63
|
-
|
|
69
|
+
const { sync, fetch, snapshotsFor, poll } = makeSync(['h1']);
|
|
70
|
+
fetch.mockRejectedValue(new Error(CONNECTION_UNAVAILABLE));
|
|
64
71
|
await poll();
|
|
65
72
|
await poll();
|
|
66
73
|
await poll();
|
|
67
74
|
expect(sync.syncHealth.status).toBe('degraded');
|
|
68
75
|
|
|
69
76
|
// Connectivity returns; a plain idle poll (no user action) clears it.
|
|
70
|
-
|
|
77
|
+
fetch.mockImplementation(async (hs: string[]) => snapshotsFor(hs));
|
|
71
78
|
await poll();
|
|
72
79
|
expect(sync.syncHealth.status).toBe('healthy');
|
|
73
80
|
});
|
|
74
81
|
|
|
75
82
|
it('does not degrade on application errors (server was reached)', async () => {
|
|
76
|
-
const { sync,
|
|
77
|
-
|
|
83
|
+
const { sync, fetch, poll } = makeSync(['h1']);
|
|
84
|
+
fetch.mockRejectedValue(new Error('Permission denied'));
|
|
78
85
|
await poll();
|
|
79
86
|
await poll();
|
|
80
87
|
await poll();
|
|
@@ -82,22 +89,24 @@ describe('sync health via idle poll', () => {
|
|
|
82
89
|
expect(sync.syncHealth.status).toBe('healthy');
|
|
83
90
|
});
|
|
84
91
|
|
|
85
|
-
it('counts a
|
|
86
|
-
const { sync,
|
|
92
|
+
it('counts a cycle whose round trip answered as reached even when a per-query apply fails', async () => {
|
|
93
|
+
const { sync, fetch, apply, snapshotsFor, poll } = makeSync(['h1', 'h2']);
|
|
87
94
|
// First degrade via all-network cycles.
|
|
88
|
-
|
|
95
|
+
fetch.mockRejectedValue(new Error(CONNECTION_UNAVAILABLE));
|
|
89
96
|
await poll();
|
|
90
97
|
await poll();
|
|
91
98
|
await poll();
|
|
92
99
|
expect(sync.syncHealth.status).toBe('degraded');
|
|
93
100
|
|
|
94
|
-
//
|
|
95
|
-
|
|
96
|
-
|
|
101
|
+
// The batched read answers (server reached); landing one of the two
|
|
102
|
+
// snapshots then fails on its own network error → still a reached cycle.
|
|
103
|
+
fetch.mockImplementation(async (hs: string[]) => snapshotsFor(hs));
|
|
104
|
+
apply
|
|
97
105
|
.mockResolvedValueOnce(true)
|
|
98
106
|
.mockRejectedValueOnce(new Error(CONNECTION_UNAVAILABLE));
|
|
99
|
-
await poll();
|
|
107
|
+
await expect(poll()).resolves.toBe(true);
|
|
100
108
|
expect(sync.syncHealth.status).toBe('healthy');
|
|
109
|
+
expect(fetch).toHaveBeenLastCalledWith(['h1', 'h2']);
|
|
101
110
|
});
|
|
102
111
|
|
|
103
112
|
it('probes RETURN true when there are no active queries', async () => {
|
|
@@ -115,12 +124,12 @@ describe('sync health via idle poll', () => {
|
|
|
115
124
|
});
|
|
116
125
|
|
|
117
126
|
it('leaves everConnected false through a cold-start failure run', async () => {
|
|
118
|
-
const { sync,
|
|
127
|
+
const { sync, fetch, poll } = makeSync(['h1']);
|
|
119
128
|
expect(sync.syncHealth.everConnected).toBe(false);
|
|
120
129
|
|
|
121
130
|
// Server never reached: 3 failed cycles degrade, but this is the initial
|
|
122
131
|
// "connecting" phase, not a lost connection — everConnected stays false.
|
|
123
|
-
|
|
132
|
+
fetch.mockRejectedValue(new Error(CONNECTION_UNAVAILABLE));
|
|
124
133
|
await poll();
|
|
125
134
|
await poll();
|
|
126
135
|
await poll();
|
|
@@ -129,17 +138,16 @@ describe('sync health via idle poll', () => {
|
|
|
129
138
|
});
|
|
130
139
|
|
|
131
140
|
it('latches everConnected on the first success and keeps it through a later degrade', async () => {
|
|
132
|
-
const { sync,
|
|
141
|
+
const { sync, fetch, poll } = makeSync(['h1']);
|
|
133
142
|
|
|
134
143
|
// First successful round reaches the server: connecting phase is over.
|
|
135
|
-
refetch.mockResolvedValue(true);
|
|
136
144
|
await poll();
|
|
137
145
|
expect(sync.syncHealth.status).toBe('healthy');
|
|
138
146
|
expect(sync.syncHealth.everConnected).toBe(true);
|
|
139
147
|
|
|
140
148
|
// Connection later drops: degraded now reflects a REAL lost connection.
|
|
141
|
-
|
|
142
|
-
|
|
149
|
+
fetch.mockReset();
|
|
150
|
+
fetch.mockRejectedValue(new Error(CONNECTION_UNAVAILABLE));
|
|
143
151
|
await poll();
|
|
144
152
|
await poll();
|
|
145
153
|
await poll();
|
package/src/modules/sync/sync.ts
CHANGED
|
@@ -18,16 +18,34 @@ import type { RecordId, Uuid } from 'surrealdb';
|
|
|
18
18
|
import {
|
|
19
19
|
applyRecordVersionDiff,
|
|
20
20
|
ArraySyncer,
|
|
21
|
+
buildListRefBatchSelect,
|
|
21
22
|
buildListRefSelect,
|
|
23
|
+
buildQueryRowCountBatchSelect,
|
|
22
24
|
buildQueryRowCountSelect,
|
|
23
25
|
buildSubqueryListRefSelect,
|
|
24
26
|
createDiffFromDbOp,
|
|
25
27
|
diffRecordVersionArray,
|
|
26
28
|
listRefPollDelayMs,
|
|
29
|
+
planListRefPollChunks,
|
|
27
30
|
recordVersionArraysEqual,
|
|
28
31
|
resolveListRefPollInterval,
|
|
29
32
|
} from './utils';
|
|
30
33
|
import { SyncEngine } from './engine';
|
|
34
|
+
|
|
35
|
+
/** One query's server-side `_00_list_ref` state as read by the poll. */
|
|
36
|
+
interface ListRefSnapshot {
|
|
37
|
+
primary: RecordVersionArray;
|
|
38
|
+
subquery: RecordVersionArray;
|
|
39
|
+
/** `_00_query.rowCount`; `null` when the row was not readable. */
|
|
40
|
+
rowCount: number | null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface ListRefEdgeRow {
|
|
44
|
+
in: RecordId<string>;
|
|
45
|
+
out: RecordId<string>;
|
|
46
|
+
version: number;
|
|
47
|
+
parent?: unknown;
|
|
48
|
+
}
|
|
31
49
|
import { SyncScheduler } from './scheduler';
|
|
32
50
|
import type { SchemaStructure } from '@spooky-sync/query-builder';
|
|
33
51
|
import type { CacheModule } from '../cache/index';
|
|
@@ -187,6 +205,10 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
187
205
|
// on a quiet page, thanks to the cross-session LIVE-permission gap).
|
|
188
206
|
private listRefIdleStreak: number = 0;
|
|
189
207
|
|
|
208
|
+
// `queryHash` -> wall-clock ms of its last poll refresh. Drives the per-cycle
|
|
209
|
+
// plan (`planListRefPollChunks`): oldest first, large views less often.
|
|
210
|
+
private listRefPolledAt: Map<string, number> = new Map();
|
|
211
|
+
|
|
190
212
|
// `${queryHash}:${recordId}` -> consecutive rounds the id has been "still
|
|
191
213
|
// remote" (left the query's list_ref but still exists upstream). Used to
|
|
192
214
|
// distinguish a PERSISTENT view-membership disagreement (the `job:` churn,
|
|
@@ -865,6 +887,7 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
865
887
|
this.listRefPollTimer = setTimeout(async () => {
|
|
866
888
|
if (!this.listRefPollRunning) return;
|
|
867
889
|
let changed = false;
|
|
890
|
+
const startedAt = Date.now();
|
|
868
891
|
const tick = (async () => {
|
|
869
892
|
changed = await this.pollListRefForActiveQueries();
|
|
870
893
|
})();
|
|
@@ -883,10 +906,18 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
883
906
|
// poll after it lands, not after the backoff has grown to the cap.
|
|
884
907
|
const awaitingMembership = this.dataModule.hasSettledWritesPending();
|
|
885
908
|
this.listRefIdleStreak = changed || awaitingMembership ? 0 : this.listRefIdleStreak + 1;
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
909
|
+
// Never let the poll take more than half the wall clock: a cycle that
|
|
910
|
+
// took longer than the cadence (many queries, slow link) waits at
|
|
911
|
+
// least its own duration before the next one, so it cannot occupy
|
|
912
|
+
// the remote back-to-back the way the old per-query poll did.
|
|
913
|
+
const cycleMs = Date.now() - startedAt;
|
|
914
|
+
const next = Math.max(
|
|
915
|
+
listRefPollDelayMs({
|
|
916
|
+
idleStreak: this.listRefIdleStreak,
|
|
917
|
+
baseIntervalMs: this.refSyncIntervalMs,
|
|
918
|
+
}),
|
|
919
|
+
cycleMs
|
|
920
|
+
);
|
|
890
921
|
schedule(next);
|
|
891
922
|
}
|
|
892
923
|
}, delayMs);
|
|
@@ -903,9 +934,10 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
903
934
|
}
|
|
904
935
|
|
|
905
936
|
/**
|
|
906
|
-
* One poll cycle: refetch `_00_list_ref` for
|
|
907
|
-
*
|
|
908
|
-
*
|
|
937
|
+
* One poll cycle: refetch `_00_list_ref` for the active queries that are due,
|
|
938
|
+
* one round trip per chunk (see `planListRefPollChunks`). Returns whether ANY
|
|
939
|
+
* query's remoteArray actually changed — the scheduler uses this to drive the
|
|
940
|
+
* adaptive idle backoff.
|
|
909
941
|
*
|
|
910
942
|
* Also the ONLY health signal that runs while the page is idle. Sync health is
|
|
911
943
|
* otherwise activity-driven (mutations/registrations via the scheduler,
|
|
@@ -930,6 +962,20 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
930
962
|
}
|
|
931
963
|
return false;
|
|
932
964
|
}
|
|
965
|
+
const now = Date.now();
|
|
966
|
+
// Forget queries that went away so the map cannot grow with the session.
|
|
967
|
+
const active = new Set(hashes);
|
|
968
|
+
for (const hash of this.listRefPolledAt.keys()) {
|
|
969
|
+
if (!active.has(hash)) this.listRefPolledAt.delete(hash);
|
|
970
|
+
}
|
|
971
|
+
const chunks = planListRefPollChunks(
|
|
972
|
+
hashes.map((hash) => ({
|
|
973
|
+
hash,
|
|
974
|
+
rows: this.dataModule.getQueryByHash(hash)?.config.remoteArray?.length ?? 0,
|
|
975
|
+
lastPolledAt: this.listRefPolledAt.get(hash) ?? 0,
|
|
976
|
+
})),
|
|
977
|
+
{ now }
|
|
978
|
+
);
|
|
933
979
|
let anyChanged = false;
|
|
934
980
|
// `reached` = the server answered at least once this cycle (a success, or an
|
|
935
981
|
// *application* error, which still proves reachability). `firstNetworkErr`
|
|
@@ -939,9 +985,10 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
939
985
|
// fault owned by the registration path, not a reachability signal).
|
|
940
986
|
let reached = false;
|
|
941
987
|
let firstNetworkErr: unknown;
|
|
942
|
-
for (const
|
|
988
|
+
for (const chunk of chunks) {
|
|
989
|
+
let snapshots: Map<string, ListRefSnapshot>;
|
|
943
990
|
try {
|
|
944
|
-
|
|
991
|
+
snapshots = await this.fetchListRefSnapshots(chunk);
|
|
945
992
|
reached = true;
|
|
946
993
|
} catch (err) {
|
|
947
994
|
if (classifySyncError(err) === 'network') {
|
|
@@ -952,11 +999,29 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
952
999
|
this.logger.debug(
|
|
953
1000
|
{
|
|
954
1001
|
err: (err as Error)?.message ?? err,
|
|
955
|
-
|
|
1002
|
+
hashes: chunk,
|
|
956
1003
|
Category: 'sp00ky-client::Sp00kySync::pollListRefForActiveQueries',
|
|
957
1004
|
},
|
|
958
|
-
'
|
|
1005
|
+
'list_ref poll round trip failed'
|
|
959
1006
|
);
|
|
1007
|
+
continue;
|
|
1008
|
+
}
|
|
1009
|
+
for (const hash of chunk) {
|
|
1010
|
+
const snapshot = snapshots.get(hash);
|
|
1011
|
+
if (!snapshot) continue;
|
|
1012
|
+
this.listRefPolledAt.set(hash, now);
|
|
1013
|
+
try {
|
|
1014
|
+
if (await this.applyListRefSnapshot(hash, snapshot)) anyChanged = true;
|
|
1015
|
+
} catch (err) {
|
|
1016
|
+
this.logger.debug(
|
|
1017
|
+
{
|
|
1018
|
+
err: (err as Error)?.message ?? err,
|
|
1019
|
+
hash,
|
|
1020
|
+
Category: 'sp00ky-client::Sp00kySync::pollListRefForActiveQueries',
|
|
1021
|
+
},
|
|
1022
|
+
'Per-query list_ref poll apply failed'
|
|
1023
|
+
);
|
|
1024
|
+
}
|
|
960
1025
|
}
|
|
961
1026
|
}
|
|
962
1027
|
// Call the private outcome recorder directly rather than routing through the
|
|
@@ -971,24 +1036,128 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
971
1036
|
}
|
|
972
1037
|
|
|
973
1038
|
/**
|
|
974
|
-
*
|
|
975
|
-
*
|
|
976
|
-
*
|
|
977
|
-
*
|
|
978
|
-
*
|
|
979
|
-
*
|
|
1039
|
+
* The server's current `_00_list_ref` state for a chunk of queries in ONE
|
|
1040
|
+
* round trip: primary edges, subquery-child edges and `_00_query.rowCount`.
|
|
1041
|
+
* Every hash whose query still exists gets an entry (empty arrays and
|
|
1042
|
+
* `rowCount: null` when nothing was readable), so a caller can tell "no
|
|
1043
|
+
* edges" from "not asked". A hash is left out only when the select did not
|
|
1044
|
+
* come back as an array at all — the old per-query code skipped the apply in
|
|
1045
|
+
* that case too, since a non-array is not a membership set.
|
|
1046
|
+
*
|
|
1047
|
+
* Guarded against a batch that silently matches nothing: if the select
|
|
1048
|
+
* returned no edge at all while the client holds edges for a query the
|
|
1049
|
+
* server still reports rows for, the batch is not trusted and the chunk is
|
|
1050
|
+
* re-read one query at a time with the single-query select. An empty set
|
|
1051
|
+
* taken at face value would be recorded as the removal of every row.
|
|
1052
|
+
*/
|
|
1053
|
+
private async fetchListRefSnapshots(hashes: string[]): Promise<Map<string, ListRefSnapshot>> {
|
|
1054
|
+
const listRefTbl = this.listRefTable();
|
|
1055
|
+
const ids: RecordId<string>[] = [];
|
|
1056
|
+
const hashById = new Map<string, string>();
|
|
1057
|
+
const out = new Map<string, ListRefSnapshot>();
|
|
1058
|
+
for (const hash of hashes) {
|
|
1059
|
+
const queryState = this.dataModule.getQueryByHash(hash);
|
|
1060
|
+
if (!queryState) continue;
|
|
1061
|
+
ids.push(queryState.config.id);
|
|
1062
|
+
hashById.set(encodeRecordId(queryState.config.id), hash);
|
|
1063
|
+
}
|
|
1064
|
+
if (ids.length === 0) return out;
|
|
1065
|
+
if (ids.length === 1) {
|
|
1066
|
+
const hash = hashById.get(encodeRecordId(ids[0]))!;
|
|
1067
|
+
const single = await this.fetchListRefSnapshot(ids[0]);
|
|
1068
|
+
if (single) out.set(hash, single);
|
|
1069
|
+
return out;
|
|
1070
|
+
}
|
|
1071
|
+
const [edges, counts] = await this.remote.query<
|
|
1072
|
+
[ListRefEdgeRow[] | null, ({ id?: RecordId<string>; rowCount?: number | null } | null)[] | null]
|
|
1073
|
+
>(`${buildListRefBatchSelect(listRefTbl)};\n${buildQueryRowCountBatchSelect()}`, { ins: ids });
|
|
1074
|
+
if (!Array.isArray(edges)) return out;
|
|
1075
|
+
for (const hash of hashById.values()) {
|
|
1076
|
+
out.set(hash, { primary: [], subquery: [], rowCount: null });
|
|
1077
|
+
}
|
|
1078
|
+
for (const row of edges) {
|
|
1079
|
+
const hash = hashById.get(encodeRecordId(row.in));
|
|
1080
|
+
if (!hash) continue;
|
|
1081
|
+
const snapshot = out.get(hash)!;
|
|
1082
|
+
const pair: [string, number] = [encodeRecordId(row.out), row.version];
|
|
1083
|
+
if (row.parent == null) snapshot.primary.push(pair);
|
|
1084
|
+
else snapshot.subquery.push(pair);
|
|
1085
|
+
}
|
|
1086
|
+
for (const count of Array.isArray(counts) ? counts : []) {
|
|
1087
|
+
if (!count || !count.id) continue;
|
|
1088
|
+
const hash = hashById.get(encodeRecordId(count.id));
|
|
1089
|
+
if (!hash) continue;
|
|
1090
|
+
out.get(hash)!.rowCount = typeof count.rowCount === 'number' ? count.rowCount : null;
|
|
1091
|
+
}
|
|
1092
|
+
const suspect =
|
|
1093
|
+
edges.length === 0 &&
|
|
1094
|
+
Array.from(out.entries()).some(([hash, snapshot]) => {
|
|
1095
|
+
const held = this.dataModule.getQueryByHash(hash)?.config.remoteArray?.length ?? 0;
|
|
1096
|
+
return held > 0 && (snapshot.rowCount ?? 0) > 0;
|
|
1097
|
+
});
|
|
1098
|
+
if (suspect) {
|
|
1099
|
+
this.logger.warn(
|
|
1100
|
+
{ hashes, Category: 'sp00ky-client::Sp00kySync::fetchListRefSnapshots' },
|
|
1101
|
+
'Batched list_ref select returned no edges for queries that hold rows; re-reading one query at a time'
|
|
1102
|
+
);
|
|
1103
|
+
for (const hash of Array.from(out.keys())) {
|
|
1104
|
+
const queryState = this.dataModule.getQueryByHash(hash);
|
|
1105
|
+
if (!queryState) {
|
|
1106
|
+
out.delete(hash);
|
|
1107
|
+
continue;
|
|
1108
|
+
}
|
|
1109
|
+
const single = await this.fetchListRefSnapshot(queryState.config.id);
|
|
1110
|
+
if (single) out.set(hash, single);
|
|
1111
|
+
else out.delete(hash);
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
return out;
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
/**
|
|
1118
|
+
* Single-query form of {@link fetchListRefSnapshots}: the selects the
|
|
1119
|
+
* registration path has always used, in one statement batch. `null` when
|
|
1120
|
+
* the primary select did not come back as an array.
|
|
1121
|
+
*/
|
|
1122
|
+
private async fetchListRefSnapshot(id: RecordId<string>): Promise<ListRefSnapshot | null> {
|
|
1123
|
+
const listRefTbl = this.listRefTable();
|
|
1124
|
+
const [items, serverRowCount, children] = await this.remote.query<
|
|
1125
|
+
[
|
|
1126
|
+
{ out: RecordId<string>; version: number }[] | null,
|
|
1127
|
+
number | null,
|
|
1128
|
+
{ out: RecordId<string>; version: number }[] | null,
|
|
1129
|
+
]
|
|
1130
|
+
>(
|
|
1131
|
+
`${buildListRefSelect(listRefTbl)};\n${buildQueryRowCountSelect()};\n${buildSubqueryListRefSelect(listRefTbl)}`,
|
|
1132
|
+
{ in: id }
|
|
1133
|
+
);
|
|
1134
|
+
if (!Array.isArray(items)) return null;
|
|
1135
|
+
const toPairs = (rows: { out: RecordId<string>; version: number }[] | null): RecordVersionArray =>
|
|
1136
|
+
Array.isArray(rows) ? rows.map((item) => [encodeRecordId(item.out), item.version]) : [];
|
|
1137
|
+
return {
|
|
1138
|
+
primary: toPairs(items),
|
|
1139
|
+
subquery: toPairs(children),
|
|
1140
|
+
rowCount: typeof serverRowCount === 'number' ? serverRowCount : null,
|
|
1141
|
+
};
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
/**
|
|
1145
|
+
* Land one poll snapshot for `queryHash`: diff the server's edges against the
|
|
1146
|
+
* cached `remoteArray`, sync any added/updated rows through the SyncEngine,
|
|
1147
|
+
* persist the new remoteArray and converge the subquery children. This is
|
|
1148
|
+
* the same shape `createRemoteQuery` does for its initial fetch and what
|
|
1149
|
+
* `handleRemoteListRefChange` does per-LIVE-event — we reuse it on a timer
|
|
1150
|
+
* as a fallback for missed LIVE notifications. Returns whether membership
|
|
1151
|
+
* changed.
|
|
980
1152
|
*/
|
|
981
|
-
private async
|
|
1153
|
+
private async applyListRefSnapshot(
|
|
1154
|
+
queryHash: string,
|
|
1155
|
+
snapshot: ListRefSnapshot
|
|
1156
|
+
): Promise<boolean> {
|
|
982
1157
|
const queryState = this.dataModule.getQueryByHash(queryHash);
|
|
983
1158
|
if (!queryState) return false;
|
|
984
|
-
const
|
|
985
|
-
const
|
|
986
|
-
[{ out: RecordId<string>; version: number }[], number | null]
|
|
987
|
-
>(`${buildListRefSelect(listRefTbl)};\n${buildQueryRowCountSelect()}`, {
|
|
988
|
-
in: queryState.config.id,
|
|
989
|
-
});
|
|
990
|
-
if (!Array.isArray(items)) return false;
|
|
991
|
-
const fresh: RecordVersionArray = items.map((item) => [encodeRecordId(item.out), item.version]);
|
|
1159
|
+
const fresh = snapshot.primary;
|
|
1160
|
+
const serverRowCount = snapshot.rowCount;
|
|
992
1161
|
// Capture which ids LEFT the query's window (present in the cached
|
|
993
1162
|
// remoteArray, absent from `fresh`) BEFORE we overwrite remoteArray — these
|
|
994
1163
|
// are cross-window deletes (or rows that scrolled out). They drive the
|
|
@@ -1026,7 +1195,7 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
1026
1195
|
{
|
|
1027
1196
|
err: (err as Error)?.message ?? err,
|
|
1028
1197
|
queryHash,
|
|
1029
|
-
Category: 'sp00ky-client::Sp00kySync::
|
|
1198
|
+
Category: 'sp00ky-client::Sp00kySync::applyListRefSnapshot',
|
|
1030
1199
|
},
|
|
1031
1200
|
'syncQuery failed during poll'
|
|
1032
1201
|
);
|
|
@@ -1039,12 +1208,12 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
1039
1208
|
// Cross-session fallback for `.related()` child rows: the LIVE-permission
|
|
1040
1209
|
// gap can drop child-edge notifications, so converge their bodies on the
|
|
1041
1210
|
// poll too (idempotent — no-op when nothing changed).
|
|
1042
|
-
await this.
|
|
1211
|
+
await this.applySubqueryChildren(queryHash, snapshot.subquery).catch((err) => {
|
|
1043
1212
|
this.logger.info(
|
|
1044
1213
|
{
|
|
1045
1214
|
err: (err as Error)?.message ?? err,
|
|
1046
1215
|
queryHash,
|
|
1047
|
-
Category: 'sp00ky-client::Sp00kySync::
|
|
1216
|
+
Category: 'sp00ky-client::Sp00kySync::applyListRefSnapshot',
|
|
1048
1217
|
},
|
|
1049
1218
|
'Subquery child sync failed during poll'
|
|
1050
1219
|
);
|
|
@@ -1062,7 +1231,7 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
1062
1231
|
{
|
|
1063
1232
|
err: (err as Error)?.message ?? err,
|
|
1064
1233
|
queryHash,
|
|
1065
|
-
Category: 'sp00ky-client::Sp00kySync::
|
|
1234
|
+
Category: 'sp00ky-client::Sp00kySync::applyListRefSnapshot',
|
|
1066
1235
|
},
|
|
1067
1236
|
'notifyQuerySynced failed during poll-removal re-render'
|
|
1068
1237
|
);
|
|
@@ -2054,6 +2223,18 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
2054
2223
|
if (!Array.isArray(items)) return;
|
|
2055
2224
|
|
|
2056
2225
|
const fresh: RecordVersionArray = items.map((item) => [encodeRecordId(item.out), item.version]);
|
|
2226
|
+
await this.applySubqueryChildren(queryHash, fresh);
|
|
2227
|
+
}
|
|
2228
|
+
|
|
2229
|
+
/**
|
|
2230
|
+
* Converge a `.related()` query's child bodies onto `fresh` (the server's
|
|
2231
|
+
* current child edges). Shared by the registration path above and the poll,
|
|
2232
|
+
* which reads the child edges in its batched round trip. See
|
|
2233
|
+
* {@link syncSubqueryChildren} for the deletion-safety rules.
|
|
2234
|
+
*/
|
|
2235
|
+
private async applySubqueryChildren(queryHash: string, fresh: RecordVersionArray): Promise<void> {
|
|
2236
|
+
const queryState = this.dataModule.getQueryByHash(queryHash);
|
|
2237
|
+
if (!queryState) return;
|
|
2057
2238
|
const prev = queryState.config.subqueryRemoteArray ?? [];
|
|
2058
2239
|
if (recordVersionArraysEqual(fresh, prev)) return; // idempotent: nothing new
|
|
2059
2240
|
|
|
@@ -9,6 +9,9 @@ import {
|
|
|
9
9
|
DEFAULT_LIST_REF_POLL_INTERVAL_MS,
|
|
10
10
|
buildListRefSelect,
|
|
11
11
|
buildSubqueryListRefSelect,
|
|
12
|
+
buildListRefBatchSelect,
|
|
13
|
+
buildQueryRowCountBatchSelect,
|
|
14
|
+
planListRefPollChunks,
|
|
12
15
|
nextPollDelayMs,
|
|
13
16
|
listRefPollDelayMs,
|
|
14
17
|
LIST_REF_POLL_MAX_INTERVAL_MS,
|
|
@@ -576,3 +579,53 @@ describe('recordVersionArraysEqual', () => {
|
|
|
576
579
|
expect(recordVersionArraysEqual([], [])).toBe(true);
|
|
577
580
|
});
|
|
578
581
|
});
|
|
582
|
+
|
|
583
|
+
describe('list_ref poll batching', () => {
|
|
584
|
+
it('reads many queries in one statement and their rowCounts keyed by id', () => {
|
|
585
|
+
expect(buildListRefBatchSelect('_00_list_ref_user_abc')).toBe(
|
|
586
|
+
'SELECT in, out, version, parent FROM _00_list_ref_user_abc WHERE in IN $ins'
|
|
587
|
+
);
|
|
588
|
+
expect(buildQueryRowCountBatchSelect()).toBe(
|
|
589
|
+
'SELECT VALUE { id: id, rowCount: rowCount } FROM $ins'
|
|
590
|
+
);
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
it('packs due queries oldest-first under the row budget', () => {
|
|
594
|
+
const chunks = planListRefPollChunks(
|
|
595
|
+
[
|
|
596
|
+
{ hash: 'a', rows: 600, lastPolledAt: 300 },
|
|
597
|
+
{ hash: 'b', rows: 600, lastPolledAt: 100 },
|
|
598
|
+
{ hash: 'c', rows: 0, lastPolledAt: 0 },
|
|
599
|
+
{ hash: 'd', rows: 900, lastPolledAt: 200 },
|
|
600
|
+
],
|
|
601
|
+
{ now: 1_000, rowBudget: 1_400 }
|
|
602
|
+
);
|
|
603
|
+
// c (never polled) first, then b, d, a by age; d does not fit next to b.
|
|
604
|
+
expect(chunks).toEqual([['c', 'b'], ['d'], ['a']]);
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
it('never splits below one query and always refreshes a lone huge view', () => {
|
|
608
|
+
expect(
|
|
609
|
+
planListRefPollChunks([{ hash: 'big', rows: 10_000, lastPolledAt: 0 }], {
|
|
610
|
+
now: 100_000,
|
|
611
|
+
rowBudget: 100,
|
|
612
|
+
})
|
|
613
|
+
).toEqual([['big']]);
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
it('defers large views until their minimum age has passed', () => {
|
|
617
|
+
const large = { hash: 'large', rows: 5_000, lastPolledAt: 990_000 };
|
|
618
|
+
const small = { hash: 'small', rows: 10, lastPolledAt: 990_000 };
|
|
619
|
+
expect(
|
|
620
|
+
planListRefPollChunks([large, small], { now: 1_000_000, largeViewRows: 1_000, largeViewMinAgeMs: 15_000 })
|
|
621
|
+
).toEqual([['small']]);
|
|
622
|
+
expect(
|
|
623
|
+
planListRefPollChunks([large, small], { now: 1_006_000, largeViewRows: 1_000, largeViewMinAgeMs: 15_000 })
|
|
624
|
+
// Same age: hash order breaks the tie, and the large view fills its own chunk.
|
|
625
|
+
).toEqual([['large'], ['small']]);
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
it('returns no chunks when nothing is due', () => {
|
|
629
|
+
expect(planListRefPollChunks([], { now: 1 })).toEqual([]);
|
|
630
|
+
});
|
|
631
|
+
});
|
|
@@ -223,6 +223,83 @@ export function buildSubqueryListRefSelect(table: string): string {
|
|
|
223
223
|
return `SELECT out, version FROM ${table} WHERE in = $in AND parent IS NOT NONE`;
|
|
224
224
|
}
|
|
225
225
|
|
|
226
|
+
/**
|
|
227
|
+
* One round trip for the edges of MANY queries: primary and subquery rows
|
|
228
|
+
* together, split client-side on `parent`. Replaces two selects per query per
|
|
229
|
+
* poll tick, which on a page with 20+ live queries was ~50 serialized round
|
|
230
|
+
* trips per cycle and kept the remote busy full-time.
|
|
231
|
+
*/
|
|
232
|
+
export function buildListRefBatchSelect(table: string): string {
|
|
233
|
+
return `SELECT in, out, version, parent FROM ${table} WHERE in IN $ins`;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* `rowCount` for many `_00_query` rows at once, keyed by id so the caller does
|
|
238
|
+
* not depend on the array order. Same semantics as
|
|
239
|
+
* {@link buildQueryRowCountSelect}: a row the session cannot read comes back
|
|
240
|
+
* without an id and must be treated as "unknown".
|
|
241
|
+
*/
|
|
242
|
+
export function buildQueryRowCountBatchSelect(): string {
|
|
243
|
+
return 'SELECT VALUE { id: id, rowCount: rowCount } FROM $ins';
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Known edges one poll round trip may carry before the cycle is split. */
|
|
247
|
+
export const LIST_REF_POLL_ROW_BUDGET = 1_500;
|
|
248
|
+
/** A view at or past this many edges is "large": it rides on LIVE and is only
|
|
249
|
+
* re-polled every {@link LIST_REF_POLL_LARGE_VIEW_MIN_AGE_MS}. */
|
|
250
|
+
export const LIST_REF_POLL_LARGE_VIEW_ROWS = 1_000;
|
|
251
|
+
export const LIST_REF_POLL_LARGE_VIEW_MIN_AGE_MS = 15_000;
|
|
252
|
+
|
|
253
|
+
export interface ListRefPollCandidate {
|
|
254
|
+
hash: string;
|
|
255
|
+
/** Edges the client currently holds for the query (`remoteArray.length`). */
|
|
256
|
+
rows: number;
|
|
257
|
+
/** When the query was last refreshed by the poll; `0` = never. */
|
|
258
|
+
lastPolledAt: number;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Split the active queries into the round trips of one poll cycle.
|
|
263
|
+
*
|
|
264
|
+
* Every query that is due is refreshed once per cycle, oldest refresh first,
|
|
265
|
+
* packed greedily into chunks of at most `rowBudget` known edges so one
|
|
266
|
+
* response never carries a whole page's worth of ids at once. A view with
|
|
267
|
+
* `largeViewRows` or more edges is only due once `largeViewMinAgeMs` has
|
|
268
|
+
* passed since its last refresh (LIVE remains its primary path); a chunk
|
|
269
|
+
* always holds at least one query, so a single huge view still refreshes.
|
|
270
|
+
*/
|
|
271
|
+
export function planListRefPollChunks(
|
|
272
|
+
candidates: ListRefPollCandidate[],
|
|
273
|
+
opts: { now: number; rowBudget?: number; largeViewRows?: number; largeViewMinAgeMs?: number }
|
|
274
|
+
): string[][] {
|
|
275
|
+
const {
|
|
276
|
+
now,
|
|
277
|
+
rowBudget = LIST_REF_POLL_ROW_BUDGET,
|
|
278
|
+
largeViewRows = LIST_REF_POLL_LARGE_VIEW_ROWS,
|
|
279
|
+
largeViewMinAgeMs = LIST_REF_POLL_LARGE_VIEW_MIN_AGE_MS,
|
|
280
|
+
} = opts;
|
|
281
|
+
const due = candidates
|
|
282
|
+
.filter((c) => c.rows < largeViewRows || now - c.lastPolledAt >= largeViewMinAgeMs)
|
|
283
|
+
.sort(
|
|
284
|
+
(a, b) => a.lastPolledAt - b.lastPolledAt || (a.hash < b.hash ? -1 : a.hash > b.hash ? 1 : 0)
|
|
285
|
+
);
|
|
286
|
+
const chunks: string[][] = [];
|
|
287
|
+
let current: string[] = [];
|
|
288
|
+
let currentRows = 0;
|
|
289
|
+
for (const c of due) {
|
|
290
|
+
const cost = Math.max(1, c.rows);
|
|
291
|
+
if (current.length > 0 && currentRows + cost > rowBudget) {
|
|
292
|
+
chunks.push(current);
|
|
293
|
+
current = [];
|
|
294
|
+
currentRows = 0;
|
|
295
|
+
}
|
|
296
|
+
current.push(c.hash);
|
|
297
|
+
currentRows += cost;
|
|
298
|
+
}
|
|
299
|
+
if (current.length > 0) chunks.push(current);
|
|
300
|
+
return chunks;
|
|
301
|
+
}
|
|
302
|
+
|
|
226
303
|
/**
|
|
227
304
|
* Resolve the effective list-ref poll interval. Negative or zero
|
|
228
305
|
* values fall back to the default — accepting them would either
|