@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.
@@ -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
- // `refetchListRefForQuery` is what the poll calls per active hash; stub it so
37
- // the test controls per-cycle reachability. `poll` invokes the private method.
38
- const refetch = vi.fn();
39
- (sync as any).refetchListRefForQuery = refetch;
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, refetch, poll };
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, refetch, poll } = makeSync(['h1']);
50
- refetch.mockRejectedValue(new Error(CONNECTION_UNAVAILABLE));
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, refetch, poll } = makeSync(['h1']);
63
- refetch.mockRejectedValue(new Error(CONNECTION_UNAVAILABLE));
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
- refetch.mockResolvedValue(true);
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, refetch, poll } = makeSync(['h1']);
77
- refetch.mockRejectedValue(new Error('Permission denied'));
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 mixed cycle (one reachable hash) as reached', async () => {
86
- const { sync, refetch, poll } = makeSync(['h1', 'h2']);
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
- refetch.mockRejectedValue(new Error(CONNECTION_UNAVAILABLE));
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
- // Now one hash succeeds, the other still network-fails → reached → healthy.
95
- refetch.mockReset();
96
- refetch
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, refetch, poll } = makeSync(['h1']);
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
- refetch.mockRejectedValue(new Error(CONNECTION_UNAVAILABLE));
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, refetch, poll } = makeSync(['h1']);
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
- refetch.mockReset();
142
- refetch.mockRejectedValue(new Error(CONNECTION_UNAVAILABLE));
149
+ fetch.mockReset();
150
+ fetch.mockRejectedValue(new Error(CONNECTION_UNAVAILABLE));
143
151
  await poll();
144
152
  await poll();
145
153
  await poll();
@@ -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
- const next = listRefPollDelayMs({
887
- idleStreak: this.listRefIdleStreak,
888
- baseIntervalMs: this.refSyncIntervalMs,
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 every active query. Returns
907
- * whether ANY query's remoteArray actually changed — the scheduler uses this
908
- * to drive the adaptive idle backoff.
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 hash of hashes) {
988
+ for (const chunk of chunks) {
989
+ let snapshots: Map<string, ListRefSnapshot>;
943
990
  try {
944
- if (await this.refetchListRefForQuery(hash)) anyChanged = true;
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
- hash,
1002
+ hashes: chunk,
956
1003
  Category: 'sp00ky-client::Sp00kySync::pollListRefForActiveQueries',
957
1004
  },
958
- 'Per-query list_ref poll failed'
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
- * Pull the upstream list_ref entries for `queryHash`, diff them
975
- * against the local `remoteArray` cache, sync any added/updated rows
976
- * through the SyncEngine, then persist the new remoteArray. This is
977
- * the same shape `createRemoteQuery` does for its initial fetch and
978
- * what `handleRemoteListRefChange` does per-LIVE-event — we reuse
979
- * it on a timer as a fallback for missed LIVE notifications.
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 refetchListRefForQuery(queryHash: string): Promise<boolean> {
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 listRefTbl = this.listRefTable();
985
- const [items, serverRowCount] = await this.remote.query<
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::refetchListRefForQuery',
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.syncSubqueryChildren(queryHash).catch((err) => {
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::refetchListRefForQuery',
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::refetchListRefForQuery',
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