@spooky-sync/core 0.0.1-canary.333 → 0.0.1-canary.334

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -1055,6 +1055,17 @@ type OutEvent = {
1055
1055
  * behind `status: 'fetching'`, `notified` says whether subscribers received
1056
1056
  * at least one materialization this registration.
1057
1057
  *
1058
+ * `answered` says subscribers have been handed the rows of a known membership
1059
+ * (the server's, or the durable one of an earlier session) at least once:
1060
+ * a materialization ran past `cold`, with no body of a server set still to
1061
+ * download, and the membership it drew was still the one in state when it
1062
+ * committed. Only a re-seed (a new entry, a bucket switch) clears it, so a
1063
+ * reconnect or a lost view does not send a painted query back to loading.
1064
+ * It is what the bindings' `isAuthoritative()` mirrors (see `isAnswered`):
1065
+ * a `cached` seed or a freshly applied server set alone is not enough,
1066
+ * because `records` is still empty (or stale) until `materialize` paints, and
1067
+ * "known and empty" read in that beat made apps create rows that existed.
1068
+ *
1058
1069
  * `backgroundDepth` is the part of `fetchDepth` that re-verifies rows the
1059
1070
  * query already shows: a registration of a query that is not `cold`. It still
1060
1071
  * holds `status: 'fetching'` (results are not yet confirmed), but it is not
@@ -1068,6 +1079,7 @@ interface QueryLifecycle {
1068
1079
  readonly fetchDepth: number;
1069
1080
  readonly backgroundDepth: number;
1070
1081
  readonly notified: boolean;
1082
+ readonly answered: boolean;
1071
1083
  }
1072
1084
  //#endregion
1073
1085
  //#region src/kernel/effects.d.ts
package/dist/index.js CHANGED
@@ -2198,8 +2198,8 @@ function trimHistory(history, cap) {
2198
2198
 
2199
2199
  //#endregion
2200
2200
  //#region src/modules/devtools/index.ts
2201
- const CORE_VERSION = "0.0.1-canary.333";
2202
- const WASM_VERSION = "0.0.1-canary.333";
2201
+ const CORE_VERSION = "0.0.1-canary.334";
2202
+ const WASM_VERSION = "0.0.1-canary.334";
2203
2203
  const SURREAL_VERSION = "3.1.0";
2204
2204
  var DevToolsService = class DevToolsService {
2205
2205
  version = CORE_VERSION;
@@ -2338,7 +2338,7 @@ var DevToolsService = class DevToolsService {
2338
2338
  remoteIds: remoteArray.slice(0, DevToolsService.STATE_IDS_CAP).map(([id]) => id),
2339
2339
  idsTruncated: localArray.length > DevToolsService.STATE_IDS_CAP || remoteArray.length > DevToolsService.STATE_IDS_CAP,
2340
2340
  membershipKnown: q.config.membershipKnown === true,
2341
- authoritative: q.config.membershipKnown === true,
2341
+ authoritative: q.answered === true,
2342
2342
  viewLost: q.viewLost === true,
2343
2343
  serverState: q.serverState ?? null,
2344
2344
  timings: this.dataManager.phaseTimings(q)
@@ -2931,7 +2931,8 @@ function seedLifecycle(resolvedBefore) {
2931
2931
  remote: "unregistered",
2932
2932
  fetchDepth: 0,
2933
2933
  backgroundDepth: 0,
2934
- notified: false
2934
+ notified: false,
2935
+ answered: false
2935
2936
  };
2936
2937
  }
2937
2938
  /** Total on every (lifecycle, event) pair the sagas can produce; throws on the rest. */
@@ -2980,17 +2981,27 @@ function transition(l, ev) {
2980
2981
  backgroundDepth
2981
2982
  };
2982
2983
  }
2983
- case "notified": return l.notified ? l : {
2984
- ...l,
2985
- notified: true
2986
- };
2984
+ case "notified": {
2985
+ const answered = l.answered || ev.answered === true && l.phase !== "cold";
2986
+ return l.notified && answered === l.answered ? l : {
2987
+ ...l,
2988
+ notified: true,
2989
+ answered
2990
+ };
2991
+ }
2987
2992
  case "bucket-switch": return {
2988
2993
  ...seedLifecycle(ev.resolvedBefore),
2989
2994
  fetchDepth: 0
2990
2995
  };
2991
2996
  }
2992
2997
  }
2998
+ /** Membership is known (server set or durable seed); its rows may not be painted yet. */
2993
2999
  const isAuthoritative = (l) => l.phase !== "cold";
3000
+ /**
3001
+ * Subscribers hold the rows of a known membership: what the authority channel
3002
+ * (`subscribeQueryAuthority`, the bindings' `isAuthoritative()`) reports.
3003
+ */
3004
+ const isAnswered = (l) => l.answered;
2994
3005
  const hasServerMembership = (l) => l.phase === "live" || l.phase === "view-lost";
2995
3006
  const deriveStatus = (l) => l.fetchDepth > 0 ? "fetching" : "idle";
2996
3007
  /** A fetch cycle the user is waiting on: rows to download, or a first registration. */
@@ -3208,7 +3219,12 @@ const setSubqueryRemoteArray = (hash, subqueryRemoteArray) => (s) => {
3208
3219
  if (!entry || entry.lifecycle.phase === "cold") return next;
3209
3220
  return withView(next, entry.def.viewKey, vouchedIds(entry.remoteArray, subqueryRemoteArray));
3210
3221
  };
3211
- const setRecords = (hash, records, changed, materializeMs) => (s) => {
3222
+ /**
3223
+ * Commit a materialization. `answered`: it drew a known membership with no
3224
+ * body still to download, and that membership is still current (see
3225
+ * `QueryLifecycle`).
3226
+ */
3227
+ const setRecords = (hash, records, changed, materializeMs, answered = false) => (s) => {
3212
3228
  const next = withEntry(s, hash, (e) => {
3213
3229
  const prev = e.telemetry.phaseSamples.localFetch ?? [];
3214
3230
  const telemetry = {
@@ -3227,7 +3243,10 @@ const setRecords = (hash, records, changed, materializeMs) => (s) => {
3227
3243
  ...e,
3228
3244
  records: changed ? records : e.records,
3229
3245
  telemetry,
3230
- lifecycle: transition(e.lifecycle, { type: "notified" })
3246
+ lifecycle: transition(e.lifecycle, {
3247
+ type: "notified",
3248
+ answered
3249
+ })
3231
3250
  };
3232
3251
  });
3233
3252
  return next === s ? s : {
@@ -3484,7 +3503,13 @@ const setIdentity = (patch) => (s) => ({
3484
3503
  ...s,
3485
3504
  ...patch
3486
3505
  });
3487
- /** Re-home a query after a bucket switch: new store, same hash, fresh sync state. */
3506
+ /**
3507
+ * Re-home a query after a bucket switch: new store, same hash, fresh sync state.
3508
+ * `records` is kept: it is what subscribers were last handed, and the next
3509
+ * render diffs against it. Blanking it here (without telling anyone) let a
3510
+ * new store that renders `[]` read as unchanged, so the previous principal's
3511
+ * rows stayed on screen under an authority flip that vouched for them.
3512
+ */
3488
3513
  const rebindQuery = (hash, next) => (s) => {
3489
3514
  const rebound = withEntry(s, hash, (e) => ({
3490
3515
  ...e,
@@ -3496,7 +3521,6 @@ const rebindQuery = (hash, next) => (s) => {
3496
3521
  remoteArray: next.remoteArray,
3497
3522
  localArray: next.localArray,
3498
3523
  subqueryRemoteArray: [],
3499
- records: [],
3500
3524
  serverState: null,
3501
3525
  lastHeartbeatAt: null,
3502
3526
  lastPolledAt: null,
@@ -3729,6 +3753,7 @@ function toQueryState(e) {
3729
3753
  viewLost: l.phase === "view-lost",
3730
3754
  serverState: e.serverState,
3731
3755
  syncNotified: l.notified,
3756
+ answered: l.answered,
3732
3757
  ttlTimer: null,
3733
3758
  ttlDurationMs: e.def.ttlMs,
3734
3759
  lastHeartbeatAt: e.lastHeartbeatAt ?? void 0,
@@ -4820,14 +4845,8 @@ function* applyMembership(hash, remoteArray, meta, verifiedRemoval) {
4820
4845
  });
4821
4846
  return outcome;
4822
4847
  }
4823
- const wasAuthoritative = isAuthoritative(entry.lifecycle);
4824
4848
  yield fx.state.update(commitMembership(hash, remoteArray, meta?.present ?? true));
4825
4849
  yield* forgetDeparted(hash, departedIds(entry.remoteArray, remoteArray));
4826
- if (!wasAuthoritative) yield fx.emit({
4827
- type: "query:authority",
4828
- hash,
4829
- known: true
4830
- });
4831
4850
  const now = yield fx.now();
4832
4851
  try {
4833
4852
  yield fx.local.upsert(VIEW_TABLE, viewRecordId(entry.def.viewKey), viewRow(remoteArray, entry.subqueryRemoteArray, true, now), "replace");
@@ -6671,14 +6690,18 @@ const NONE = /* @__PURE__ */ new Set();
6671
6690
  * only writer of `records`. Runs on the `mat:<hash>` dedupe lane whenever the
6672
6691
  * query is dirty. Before the server has answered (`cold`) only bodies some
6673
6692
  * durable view vouches for paint (see `paintsCold`).
6693
+ *
6694
+ * Also the only place a query becomes answered (see `QueryLifecycle`): the
6695
+ * authority flip is emitted here, after the rows it vouches for.
6674
6696
  */
6675
6697
  function* materialize(hash) {
6676
- const [entry, ov, known] = yield fx.state.read((s) => {
6698
+ const [entry, ov, known, complete] = yield fx.state.read((s) => {
6677
6699
  const e = s.queries.get(hash);
6678
6700
  return [
6679
6701
  e,
6680
6702
  overlay(s),
6681
- e?.lifecycle.phase === "cold" ? vouched(s) : NONE
6703
+ e?.lifecycle.phase === "cold" ? vouched(s) : NONE,
6704
+ needed(s, hash).length === 0
6682
6705
  ];
6683
6706
  });
6684
6707
  if (!entry) return;
@@ -6718,12 +6741,18 @@ function* materialize(hash) {
6718
6741
  if (!current) return;
6719
6742
  const t1 = yield fx.now();
6720
6743
  const changed = !rowsEqual(rows, current.records);
6721
- yield fx.state.update(compose(setRecords(hash, rows, changed, t1 - t0), changed ? stampUpdated(hash, t1) : (s) => s));
6744
+ const answered = complete && entry.lifecycle.phase !== "cold" && current.lifecycle.phase !== "cold" && current.remoteArray === entry.remoteArray;
6745
+ yield fx.state.update(compose(setRecords(hash, rows, changed, t1 - t0, answered), changed ? stampUpdated(hash, t1) : (s) => s));
6722
6746
  if (changed) yield fx.emit({
6723
6747
  type: "query:records",
6724
6748
  hash,
6725
6749
  records: rows
6726
6750
  });
6751
+ if (answered && !isAnswered(current.lifecycle)) yield fx.emit({
6752
+ type: "query:authority",
6753
+ hash,
6754
+ known: true
6755
+ });
6727
6756
  }
6728
6757
  /**
6729
6758
  * A view update from the in-browser SSP: the query's local id-set moved.
@@ -7206,10 +7235,10 @@ function* rebindQueries() {
7206
7235
  remoteArray: view?.ids ?? [],
7207
7236
  localArray
7208
7237
  }));
7209
- if (isAuthoritative(entry.lifecycle) !== isAuthoritative(lifecycle)) yield fx.emit({
7238
+ if (isAnswered(entry.lifecycle)) yield fx.emit({
7210
7239
  type: "query:authority",
7211
7240
  hash: entry.def.hash,
7212
- known: isAuthoritative(lifecycle)
7241
+ known: false
7213
7242
  });
7214
7243
  }
7215
7244
  }
@@ -7688,7 +7717,7 @@ var Runtime = class {
7688
7717
  this.authoritySubs.set(hash, set);
7689
7718
  if (options.immediate) {
7690
7719
  const entry = this.stateValue.queries.get(hash);
7691
- if (entry) this.safely(() => cb(isAuthoritative(entry.lifecycle)));
7720
+ if (entry) this.safely(() => cb(isAnswered(entry.lifecycle)));
7692
7721
  }
7693
7722
  return () => {
7694
7723
  set.delete(cb);
@@ -15745,7 +15774,7 @@ function createTabsCoordinator(config, s, host) {
15745
15774
  return new TabsCoordinator({
15746
15775
  tabId: s.tabId,
15747
15776
  fingerprint: computeTabsFingerprint({
15748
- coreVersion: "0.0.1-canary.333",
15777
+ coreVersion: "0.0.1-canary.334",
15749
15778
  schemaHash: hash53(config.schemaSurql),
15750
15779
  endpoint: config.database.endpoint ?? "",
15751
15780
  namespace: config.database.namespace,
package/dist/types.d.ts CHANGED
@@ -2069,6 +2069,12 @@ interface QueryState {
2069
2069
  * always emits at least once even when its records are unchanged — otherwise
2070
2070
  * an empty re-registered window would never notify and stay "loading". */
2071
2071
  syncNotified?: boolean;
2072
+ /**
2073
+ * Subscribers hold the rows of a known membership (see `QueryLifecycle`'s
2074
+ * `answered`): what the authority channel and the bindings'
2075
+ * `isAuthoritative()` report. Ephemeral.
2076
+ */
2077
+ answered?: boolean;
2072
2078
  /** Timer for TTL expiration. */
2073
2079
  ttlTimer: NodeJS.Timeout | null;
2074
2080
  /** TTL duration in milliseconds. */
@@ -2147,9 +2153,12 @@ interface QueryTimings {
2147
2153
  type QueryUpdateCallback = (records: Record<string, any>[]) => void;
2148
2154
  type QueryStatusCallback = (status: QueryStatus) => void;
2149
2155
  /**
2150
- * Observer for a query's authority flips: `true` once server membership is
2151
- * known for it (registration, poll, durable seed), `false` when it is reset
2152
- * (bucket switch). What the bindings' `isAuthoritative()` mirrors.
2156
+ * Observer for a query's authority flips: `true` once subscribers hold the
2157
+ * rendered rows of a known membership (a server answer whose bodies are
2158
+ * local, or the durable membership of an earlier session), delivered after
2159
+ * the rows that vouch for it; `false` when it is reset (bucket switch). Knowing the set is
2160
+ * not enough: a `cached` re-seed or a just-applied server set has no rows
2161
+ * painted yet. What the bindings' `isAuthoritative()` mirrors.
2153
2162
  */
2154
2163
  type QueryAuthorityCallback = (known: boolean) => void;
2155
2164
  type MutationCallback = (mutations: MutationEvent[]) => void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spooky-sync/core",
3
- "version": "0.0.1-canary.333",
3
+ "version": "0.0.1-canary.334",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "main": "./dist/index.js",
@@ -75,8 +75,8 @@
75
75
  }
76
76
  },
77
77
  "dependencies": {
78
- "@spooky-sync/query-builder": "0.0.1-canary.333",
79
- "@spooky-sync/ssp-wasm": "0.0.1-canary.333",
78
+ "@spooky-sync/query-builder": "0.0.1-canary.334",
79
+ "@spooky-sync/ssp-wasm": "0.0.1-canary.334",
80
80
  "@sqlite.org/sqlite-wasm": "3.53.0-build1",
81
81
  "@surrealdb/wasm": "^3.1.0",
82
82
  "@surrealdb/wasm-native": "^3.3.4",
@@ -158,9 +158,9 @@ describe('dropImpersonatedBucket', () => {
158
158
  });
159
159
 
160
160
  describe('rebindQueries', () => {
161
- it('re-seeds from the new store, rebuilds the local view (tolerating failures), emits authority flips', async () => {
161
+ it('re-seeds from the new store, rebuilds the local view (tolerating failures), withdraws authority it held', async () => {
162
162
  const s = R.compose(
163
- R.putQuery(buildEntry({ def: { hash: 'a' }, lifecycle: { phase: 'live' } })),
163
+ R.putQuery(buildEntry({ def: { hash: 'a' }, lifecycle: { phase: 'live', answered: true }, records: [{ id: 't:9' }] })),
164
164
  R.putQuery(buildEntry({ def: { hash: 'b' }, lifecycle: { phase: 'cold' } }))
165
165
  )({ ...buildState(), sessionId: 's' });
166
166
  const out = await runPure(rebindQueries(), {
@@ -177,11 +177,13 @@ describe('rebindQueries', () => {
177
177
  },
178
178
  });
179
179
  expect(out.state.queries.get('a')!.lifecycle.phase).toBe('cold');
180
- expect(out.state.queries.get('b')!.lifecycle.phase).toBe('cached');
181
- expect(out.emitted.filter((e) => e.type === 'query:authority')).toEqual([
182
- { type: 'query:authority', hash: 'a', known: false },
183
- { type: 'query:authority', hash: 'b', known: true },
184
- ]);
180
+ expect(out.state.queries.get('b')!.lifecycle).toMatchObject({ phase: 'cached', answered: false });
181
+ // `b` found a durable view, but its rows are not painted until the
182
+ // render: announcing "known" here is what let an app read the empty
183
+ // `records` of a re-seed as "the user has none" and create a duplicate.
184
+ expect(out.emitted.filter((e) => e.type === 'query:authority')).toEqual([{ type: 'query:authority', hash: 'a', known: false }]);
185
+ expect(out.state.queries.get('a')!.records).toEqual([{ id: 't:9' }]);
186
+ expect([...out.state.dirty].sort()).toEqual(['a', 'b']);
185
187
  expect(out.emitted.some((e) => e.type === 'log')).toBe(true);
186
188
  });
187
189
  });
@@ -3,7 +3,7 @@ import type { Saga } from '../kernel/saga';
3
3
  import type { RegisterResult } from '../kernel/effects';
4
4
  import { fx } from '../kernel/effects';
5
5
  import type { ClientState, QueryEntry } from '../state/client-state';
6
- import { seedLifecycle, isAuthoritative } from '../state/lifecycle';
6
+ import { seedLifecycle, isAnswered } from '../state/lifecycle';
7
7
  import * as R from '../state/reducers';
8
8
  import { ANON_USER_ID, bucketIdForUser } from '../modules/ref-tables';
9
9
  import type { SagaEnv } from '../query/env';
@@ -126,8 +126,11 @@ export function* rebindQueries(): Saga<void> {
126
126
  }
127
127
  const lifecycle = seedLifecycle(isResolvedBefore(view));
128
128
  yield fx.state.update(R.rebindQuery(entry.def.hash, { id: new RecordId('_00_query', hash), lifecycle, remoteArray: view?.ids ?? [], localArray }));
129
- if (isAuthoritative(entry.lifecycle) !== isAuthoritative(lifecycle)) {
130
- yield fx.emit({ type: 'query:authority', hash: entry.def.hash, known: isAuthoritative(lifecycle) });
129
+ // A re-seed is never answered, not even from a durable view: subscribers
130
+ // still hold the previous store's rows until `materialize` paints the new
131
+ // store's, and only that render flips authority back on.
132
+ if (isAnswered(entry.lifecycle)) {
133
+ yield fx.emit({ type: 'query:authority', hash: entry.def.hash, known: false });
131
134
  }
132
135
  }
133
136
  }
@@ -6,6 +6,7 @@ import { fx } from '../kernel/effects';
6
6
  import type { Saga } from '../kernel/saga';
7
7
  import * as R from '../state/reducers';
8
8
  import { Runtime } from './runtime';
9
+ import { rebindQueries } from '../boot/bucket-switch.saga';
9
10
 
10
11
  const env = defaultEnv({ tables: [] } as any, { materializeDebounceMs: 5 });
11
12
  const noop = () => {};
@@ -88,6 +89,45 @@ describe('Runtime state hooks', () => {
88
89
  expect(seen).toEqual([[{ id: 'thing:1' }]]);
89
90
  expect(rt.state.dirty.has('q')).toBe(false);
90
91
  });
92
+ it('the bucket-switch window: a re-seeded query never reads "known and idle" before its rows are painted', async () => {
93
+ // A sign-in: the query was registered in the anon bucket (cold, nothing
94
+ // painted), then the switch re-seeds it from the user's durable view.
95
+ // Before the fix the re-seed announced authority with `records` still
96
+ // empty, so a binding's `isSettled() && !row` held for a beat and the app
97
+ // created a row the user already had.
98
+ const { a, rt } = make(
99
+ {
100
+ local: {
101
+ getById: async () => ({ ids: [['thing:1', 1]], confirmed: true }),
102
+ select: async (plan: any) => (plan.ids ?? []).map((id: any) => ({ id: `${id.table}:${id.id}` })),
103
+ },
104
+ },
105
+ { ...buildState([buildEntry({ def: { hash: 'q', plan: { table: 'thing' } as any } })]), sessionId: 's' }
106
+ );
107
+ // What a binding derives `isSettled()` and `data()` from.
108
+ let known = false;
109
+ let idle = false;
110
+ let rows: unknown[] = [];
111
+ const verdicts: string[] = [];
112
+ const look = () => {
113
+ if (known && idle) verdicts.push(rows.length > 0 ? 'settled with rows' : 'settled empty');
114
+ };
115
+ rt.subscribe('q', (r) => ((rows = r), look()), { immediate: true });
116
+ rt.subscribeAuthority('q', (k) => ((known = k), look()), { immediate: true });
117
+ rt.subscribeStatus('q', (st) => ((idle = st === 'idle'), look()), { immediate: true });
118
+
119
+ await rt.run(rebindQueries());
120
+ expect(rt.state.queries.get('q')!.lifecycle).toMatchObject({ phase: 'cached', fetchDepth: 0, answered: false });
121
+ expect(idle).toBe(true);
122
+ expect(known).toBe(false);
123
+
124
+ a.timers.fire('mat:q');
125
+ await tick();
126
+ await tick();
127
+ expect(rows).toEqual([{ id: 'thing:1' }]);
128
+ expect(known).toBe(true);
129
+ expect(verdicts).toEqual(['settled with rows']);
130
+ });
91
131
  it('status and authority subscriptions fire on transitions, immediate delivers the current value', async () => {
92
132
  const { rt } = make({}, buildState([buildEntry({ def: { hash: 'q' } })]));
93
133
  const statuses: string[] = [];
@@ -7,7 +7,7 @@ import type { Lane, Saga } from '../kernel/saga';
7
7
  import { runSaga } from '../kernel/saga';
8
8
  import type { ClientState } from '../state/client-state';
9
9
  import { emptyState } from '../state/client-state';
10
- import { isAuthoritative } from '../state/lifecycle';
10
+ import { isAnswered } from '../state/lifecycle';
11
11
  import * as R from '../state/reducers';
12
12
  import { fetchingQueryCount, pendingMutationCount, queryStatus } from '../state/selectors';
13
13
  import type { SagaEnv } from '../query/env';
@@ -274,7 +274,7 @@ export class Runtime {
274
274
  this.authoritySubs.set(hash, set);
275
275
  if (options.immediate) {
276
276
  const entry = this.stateValue.queries.get(hash);
277
- if (entry) this.safely(() => cb(isAuthoritative(entry.lifecycle)));
277
+ if (entry) this.safely(() => cb(isAnswered(entry.lifecycle)));
278
278
  }
279
279
  return () => {
280
280
  set.delete(cb);
@@ -318,7 +318,9 @@ export class DevToolsService implements StreamUpdateReceiver {
318
318
  // the panel: is the server's set known, has a non-empty one been seen
319
319
  // this session, how many empty reads were ignored.
320
320
  membershipKnown: q.config.membershipKnown === true,
321
- authoritative: q.config.membershipKnown === true,
321
+ // What the bindings' `isAuthoritative()` reads: the known set's rows
322
+ // are painted, not just the set read.
323
+ authoritative: q.answered === true,
322
324
  viewLost: q.viewLost === true,
323
325
  serverState: q.serverState ?? null,
324
326
  // Detailed per-phase processing-time breakdown (SSP sub-phases, local/
@@ -97,6 +97,79 @@ describe('materialize', () => {
97
97
  });
98
98
  });
99
99
 
100
+ describe('materialize answers the query', () => {
101
+ const plan = { table: 'thing' } as any;
102
+ const byIds = { 'local.select': (e: any) => (e.plan.ids ?? []).map((id: any) => ({ id: `${id.table}:${id.id}` })) };
103
+ const authority = (out: { emitted: Array<{ type: string }> }) => out.emitted.filter((e) => e.type === 'query:authority');
104
+
105
+ it('a cached re-seed: rows first, then authority; the next render flips nothing', async () => {
106
+ // The bucket-switch window: a durable view was found, `records` is not
107
+ // painted yet. Authority comes from this render, after the rows.
108
+ const s = R.markDirty(['a'])(
109
+ buildState([buildEntry({ def: { hash: 'a', plan }, lifecycle: { phase: 'cached' }, remoteArray: [['thing:1', 1]] })])
110
+ );
111
+ const out = await runPure(materialize('a'), { state: s, handlers: byIds });
112
+ expect(out.state.queries.get('a')!.lifecycle).toMatchObject({ phase: 'cached', notified: true, answered: true });
113
+ expect(out.emitted).toEqual([
114
+ { type: 'query:records', hash: 'a', records: [{ id: 'thing:1' }] },
115
+ { type: 'query:authority', hash: 'a', known: true },
116
+ ]);
117
+ const again = await runPure(materialize('a'), { state: R.markDirty(['a'])(out.state), handlers: byIds });
118
+ expect(again.emitted).toEqual([]);
119
+ });
120
+
121
+ it('a known empty set answers without a rows emission', async () => {
122
+ const s = buildState([buildEntry({ def: { hash: 'a', plan }, lifecycle: { phase: 'live' }, remoteArray: [] })]);
123
+ const out = await runPure(materialize('a'), { state: s, handlers: byIds });
124
+ expect(out.emitted).toEqual([{ type: 'query:authority', hash: 'a', known: true }]);
125
+ });
126
+
127
+ it('a cold render never answers', async () => {
128
+ const s = buildState([buildEntry({ def: { hash: 'a', plan } })], R.reloadViews(new Map([['v', ['thing:1']]])));
129
+ const out = await runPure(materialize('a'), { state: s, handlers: { 'local.select': () => [{ id: 'thing:1' }] } });
130
+ expect(out.state.queries.get('a')!.lifecycle).toMatchObject({ notified: true, answered: false });
131
+ expect(authority(out)).toEqual([]);
132
+ });
133
+
134
+ it('a server set whose bodies are still downloading is not an answer yet', async () => {
135
+ const s = buildState(
136
+ [buildEntry({ def: { hash: 'a', plan }, lifecycle: { phase: 'live' }, remoteArray: [['thing:1', 1], ['thing:2', 1]] })],
137
+ R.setVersions([['thing:1', 1]])
138
+ );
139
+ const out = await runPure(materialize('a'), { state: s, handlers: byIds });
140
+ expect(out.state.queries.get('a')!.lifecycle.answered).toBe(false);
141
+ expect(authority(out)).toEqual([]);
142
+ const landed = await runPure(materialize('a'), { state: R.setVersions([['thing:2', 1]])(out.state), handlers: byIds });
143
+ expect(landed.state.queries.get('a')!.lifecycle.answered).toBe(true);
144
+ expect(authority(landed)).toEqual([{ type: 'query:authority', hash: 'a', known: true }]);
145
+ });
146
+
147
+ it('a membership replaced while the read was out does not answer: the next render does', async () => {
148
+ const s = buildState([buildEntry({ def: { hash: 'a', plan }, lifecycle: { phase: 'cached' }, remoteArray: [['thing:1', 1]] })]);
149
+ const out = await runPure(materialize('a'), {
150
+ state: s,
151
+ handlers: {
152
+ 'local.select': (e: any, ctx) => {
153
+ ctx.state = R.rebindQuery('a', { id: new RecordId('_00_query', 'b'), lifecycle: { phase: 'cached', remote: 'unregistered', fetchDepth: 0, backgroundDepth: 0, notified: false, answered: false }, remoteArray: [['thing:2', 1]], localArray: [] })(ctx.state);
154
+ return byIds['local.select'](e);
155
+ },
156
+ },
157
+ });
158
+ expect(out.state.queries.get('a')!.lifecycle.answered).toBe(false);
159
+ expect(authority(out)).toEqual([]);
160
+ });
161
+
162
+ it('after a rebind, a new store that renders nothing clears what the old principal painted', async () => {
163
+ const s = buildState([buildEntry({ def: { hash: 'a', plan }, lifecycle: { phase: 'live', answered: true }, remoteArray: [['thing:1', 1]], records: [{ id: 'thing:1' }] })]);
164
+ const rebound = R.rebindQuery('a', { id: new RecordId('_00_query', 'b'), lifecycle: { phase: 'cached', remote: 'unregistered', fetchDepth: 0, backgroundDepth: 0, notified: false, answered: false }, remoteArray: [], localArray: [] })(s);
165
+ const out = await runPure(materialize('a'), { state: rebound, handlers: byIds });
166
+ expect(out.emitted).toEqual([
167
+ { type: 'query:records', hash: 'a', records: [] },
168
+ { type: 'query:authority', hash: 'a', known: true },
169
+ ]);
170
+ });
171
+ });
172
+
100
173
  describe('streamUpdate', () => {
101
174
  it('takes the local id-set (dirtying the query) and records ingest timings; unknown hashes are ignored', async () => {
102
175
  const s = buildState([buildEntry({ def: { hash: 'a' } })]);
@@ -4,7 +4,8 @@ import type { Saga } from '../kernel/saga';
4
4
  import { fx } from '../kernel/effects';
5
5
  import type { QueryEntry } from '../state/client-state';
6
6
  import * as R from '../state/reducers';
7
- import { overlay, vouched } from '../state/selectors';
7
+ import { needed, overlay, vouched } from '../state/selectors';
8
+ import { isAnswered } from '../state/lifecycle';
8
9
  import type { Overlay } from '../state/selectors';
9
10
  import { isWindowed, materializeEffect, rowKey, rowsEqual, rowsFromResult } from './materialize';
10
11
  import { buildRenderIds, paintsCold, resolveMembership } from './render-set';
@@ -17,12 +18,15 @@ const NONE: ReadonlySet<string> = new Set();
17
18
  * only writer of `records`. Runs on the `mat:<hash>` dedupe lane whenever the
18
19
  * query is dirty. Before the server has answered (`cold`) only bodies some
19
20
  * durable view vouches for paint (see `paintsCold`).
21
+ *
22
+ * Also the only place a query becomes answered (see `QueryLifecycle`): the
23
+ * authority flip is emitted here, after the rows it vouches for.
20
24
  */
21
25
  export function* materialize(hash: QueryHash): Saga<void> {
22
- const [entry, ov, known] = (yield fx.state.read((s) => {
26
+ const [entry, ov, known, complete] = (yield fx.state.read((s) => {
23
27
  const e = s.queries.get(hash);
24
- return [e, overlay(s), e?.lifecycle.phase === 'cold' ? vouched(s) : NONE];
25
- })) as [QueryEntry | undefined, Overlay, ReadonlySet<string>];
28
+ return [e, overlay(s), e?.lifecycle.phase === 'cold' ? vouched(s) : NONE, needed(s, hash).length === 0];
29
+ })) as [QueryEntry | undefined, Overlay, ReadonlySet<string>, boolean];
26
30
  if (!entry) return;
27
31
  const t0 = (yield fx.now()) as number;
28
32
  const isWindow = isWindowed(entry.def.surql);
@@ -54,10 +58,18 @@ export function* materialize(hash: QueryHash): Saga<void> {
54
58
  if (!current) return;
55
59
  const t1 = (yield fx.now()) as number;
56
60
  const changed = !rowsEqual(rows, current.records);
61
+ // These rows answer the query when they drew a known membership with no
62
+ // body of a server set still to download, and that membership is still
63
+ // the one in state. A server set applied, or a bucket switch, while the
64
+ // read was out dirtied the query again: the next render answers instead.
65
+ const answered =
66
+ complete && entry.lifecycle.phase !== 'cold' && current.lifecycle.phase !== 'cold' && current.remoteArray === entry.remoteArray;
57
67
  yield fx.state.update(
58
- R.compose(R.setRecords(hash, rows, changed, t1 - t0), changed ? R.stampUpdated(hash, t1) : (s) => s)
68
+ R.compose(R.setRecords(hash, rows, changed, t1 - t0, answered), changed ? R.stampUpdated(hash, t1) : (s) => s)
59
69
  );
60
70
  if (changed) yield fx.emit({ type: 'query:records', hash, records: rows });
71
+ // After the rows: a subscriber told "known" must already hold them.
72
+ if (answered && !isAnswered(current.lifecycle)) yield fx.emit({ type: 'query:authority', hash, known: true });
61
73
  }
62
74
 
63
75
  /**
@@ -72,7 +72,7 @@ describe('applyMembership', () => {
72
72
  expect(out.result).toBe('ignored');
73
73
  expect(out.state.queries.get('a')!).toMatchObject({ viewLostCount: 0, viewLostRetryAt: null });
74
74
  });
75
- it('applied: commits, writes the view row, flips authority once, releases acked items, asks for bodies', async () => {
75
+ it('applied: commits, writes the view row, releases acked items, asks for bodies; authority waits for the render', async () => {
76
76
  const s = buildState(
77
77
  [buildEntry({ def: { hash: 'a', viewKey: 'vk' }, lifecycle: { phase: 'cold' }, subqueryRemoteArray: [['c:1', 1]] })],
78
78
  R.outboxReplace([buildOutboxItem({ id: 'm', recordId: 't:1', status: 'acked', ackedAt: 1 })])
@@ -81,7 +81,10 @@ describe('applyMembership', () => {
81
81
  expect(out.result).toBe('applied');
82
82
  expect(out.state.queries.get('a')!.lifecycle.phase).toBe('live');
83
83
  expect(out.state.outbox).toEqual([]);
84
- expect(out.emitted).toEqual([{ type: 'query:authority', hash: 'a', known: true }]);
84
+ // The set is known, but no row of it is painted: `materialize` flips authority.
85
+ expect(out.emitted).toEqual([]);
86
+ expect(out.state.queries.get('a')!.lifecycle.answered).toBe(false);
87
+ expect(out.state.dirty.has('a')).toBe(true);
85
88
  expect(out.log.find((e) => e.kind === 'local.upsert')).toEqual({
86
89
  kind: 'local.upsert',
87
90
  table: '_00_view',
@@ -10,7 +10,6 @@ import {
10
10
  backoffMs,
11
11
  } from '../kernel/constants';
12
12
  import type { ClientState, QueryEntry } from '../state/client-state';
13
- import { isAuthoritative } from '../state/lifecycle';
14
13
  import * as R from '../state/reducers';
15
14
  import { encodeRecordId } from '../utils/index';
16
15
  import { departedIds } from '../state/selectors';
@@ -88,10 +87,10 @@ export function* applyMembership(
88
87
  yield fx.timer.set(`view-lost:${hash}`, delay, { type: 'RecoverLostView', hash });
89
88
  return outcome;
90
89
  }
91
- const wasAuthoritative = isAuthoritative(entry.lifecycle);
92
90
  yield fx.state.update(R.commitMembership(hash, remoteArray, meta?.present ?? true));
93
91
  yield* forgetDeparted(hash, departedIds(entry.remoteArray, remoteArray));
94
- if (!wasAuthoritative) yield fx.emit({ type: 'query:authority', hash, known: true });
92
+ // No authority flip here: the set is known but its rows are not painted
93
+ // yet (bodies may still be on their way). `materialize` flips it once they are.
95
94
  const now = (yield fx.now()) as number;
96
95
  try {
97
96
  yield fx.local.upsert(
@@ -154,7 +154,8 @@ describe('registerRemote', () => {
154
154
  expect(req.sql.startsWith('fn::query::register($config);')).toBe(true);
155
155
  expect(req.vars.config).toMatchObject({ surql: entry.def.surql, ttl: '10m' });
156
156
  expect(out.dispatched).toEqual([{ type: 'FetchRows' }, { type: 'FetchRows' }, { type: 'SyncOutcome', ok: true }]);
157
- expect(out.emitted).toContainEqual({ type: 'query:authority', hash: 'a', known: true });
157
+ // Authority follows the render of these rows, not the registration.
158
+ expect(out.emitted.some((ev) => ev.type === 'query:authority')).toBe(false);
158
159
  });
159
160
  it('a cold registration is foreground; a cached or live one is background', async () => {
160
161
  for (const [phase, background] of [['cold', 0], ['cached', 1], ['live', 1]] as const) {
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, it } from 'vitest';
2
2
  import type { LifecycleEvent, QueryLifecycle, QueryPhase } from './lifecycle';
3
- import { deriveStatus, hasServerMembership, isAuthoritative, isForegroundFetching, LifecycleError, seedLifecycle, transition } from './lifecycle';
3
+ import { deriveStatus, hasServerMembership, isAnswered, isAuthoritative, isForegroundFetching, LifecycleError, seedLifecycle, transition } from './lifecycle';
4
4
 
5
5
  const at = (phase: QueryPhase, over: Partial<QueryLifecycle> = {}): QueryLifecycle => ({
6
6
  phase,
@@ -8,6 +8,7 @@ const at = (phase: QueryPhase, over: Partial<QueryLifecycle> = {}): QueryLifecyc
8
8
  fetchDepth: 0,
9
9
  backgroundDepth: 0,
10
10
  notified: false,
11
+ answered: false,
11
12
  ...over,
12
13
  });
13
14
 
@@ -33,8 +34,48 @@ describe('transition table (phase axis)', () => {
33
34
  expect(transition(at('view-lost'), { type: 'row-missing' }).phase).toBe('view-lost');
34
35
  });
35
36
  it('bucket-switch reseeds and clears fetch depth', () => {
36
- const l = transition(at('live', { fetchDepth: 3, notified: true }), { type: 'bucket-switch', resolvedBefore: true });
37
- expect(l).toEqual({ phase: 'cached', remote: 'unregistered', fetchDepth: 0, backgroundDepth: 0, notified: false });
37
+ const l = transition(at('live', { fetchDepth: 3, notified: true, answered: true }), { type: 'bucket-switch', resolvedBefore: true });
38
+ expect(l).toEqual({ phase: 'cached', remote: 'unregistered', fetchDepth: 0, backgroundDepth: 0, notified: false, answered: false });
39
+ });
40
+ });
41
+
42
+ describe('answered', () => {
43
+ it('a seed is never answered, not even a cached one: its rows are not painted yet', () => {
44
+ expect(seedLifecycle(true)).toMatchObject({ phase: 'cached', answered: false });
45
+ expect(isAnswered(seedLifecycle(true))).toBe(false);
46
+ expect(isAuthoritative(seedLifecycle(true))).toBe(true);
47
+ });
48
+ it('only a render of a known membership answers; a cold render is a guess', () => {
49
+ expect(transition(at('cold'), { type: 'notified', answered: true }).answered).toBe(false);
50
+ expect(transition(at('cached'), { type: 'notified' }).answered).toBe(false);
51
+ expect(transition(at('cached'), { type: 'notified', answered: false })).toMatchObject({ notified: true, answered: false });
52
+ for (const p of ['cached', 'live', 'view-lost'] as const) {
53
+ expect(transition(at(p), { type: 'notified', answered: true })).toMatchObject({ notified: true, answered: true });
54
+ }
55
+ });
56
+ it('latched through membership, a reconnect and a lost view; cleared by a re-seed', () => {
57
+ let l = transition(at('cached'), { type: 'notified', answered: true });
58
+ l = transition(l, { type: 'membership-applied', present: true });
59
+ expect(l).toMatchObject({ phase: 'live', answered: true });
60
+ l = transition(l, { type: 'notified', answered: false });
61
+ expect(l.answered).toBe(true);
62
+ l = transition(l, { type: 'remote-dropped' });
63
+ expect(l).toMatchObject({ notified: false, answered: true });
64
+ l = transition(l, { type: 'row-missing' });
65
+ expect(l).toMatchObject({ phase: 'view-lost', answered: true });
66
+ expect(transition(l, { type: 'bucket-switch', resolvedBefore: true }).answered).toBe(false);
67
+ expect(transition(l, { type: 'seed', resolvedBefore: true }).answered).toBe(false);
68
+ });
69
+ it('a server set applied to a cold query is not an answer until its render', () => {
70
+ const l = transition(at('cold'), { type: 'membership-applied', present: true });
71
+ expect(l).toMatchObject({ phase: 'live', answered: false });
72
+ expect(isAuthoritative(l)).toBe(true);
73
+ expect(isAnswered(l)).toBe(false);
74
+ });
75
+ it('an unchanged notified keeps identity', () => {
76
+ const l = transition(at('live'), { type: 'notified', answered: true });
77
+ expect(transition(l, { type: 'notified', answered: true })).toBe(l);
78
+ expect(transition(l, { type: 'notified' })).toBe(l);
38
79
  });
39
80
  });
40
81
 
@@ -111,6 +152,7 @@ describe('invariants over random event sequences', () => {
111
152
  { type: 'fetch-begin', background: true },
112
153
  { type: 'fetch-end', background: true },
113
154
  { type: 'notified' },
155
+ { type: 'notified', answered: true },
114
156
  { type: 'bucket-switch', resolvedBefore: false },
115
157
  { type: 'seed', resolvedBefore: true },
116
158
  ];
@@ -135,6 +177,7 @@ describe('invariants over random event sequences', () => {
135
177
  expect(l.backgroundDepth).toBeLessThanOrEqual(l.fetchDepth);
136
178
  if (ev.type === 'row-missing' && before.phase === 'cold') expect(l.phase).toBe('cold');
137
179
  if (l.phase === 'view-lost') expect(before.phase).not.toBe('cold');
180
+ if (l.answered) expect(l.phase).not.toBe('cold');
138
181
  }
139
182
  }
140
183
  });
@@ -15,6 +15,17 @@ import type { QueryStatus } from '../types';
15
15
  * behind `status: 'fetching'`, `notified` says whether subscribers received
16
16
  * at least one materialization this registration.
17
17
  *
18
+ * `answered` says subscribers have been handed the rows of a known membership
19
+ * (the server's, or the durable one of an earlier session) at least once:
20
+ * a materialization ran past `cold`, with no body of a server set still to
21
+ * download, and the membership it drew was still the one in state when it
22
+ * committed. Only a re-seed (a new entry, a bucket switch) clears it, so a
23
+ * reconnect or a lost view does not send a painted query back to loading.
24
+ * It is what the bindings' `isAuthoritative()` mirrors (see `isAnswered`):
25
+ * a `cached` seed or a freshly applied server set alone is not enough,
26
+ * because `records` is still empty (or stale) until `materialize` paints, and
27
+ * "known and empty" read in that beat made apps create rows that existed.
28
+ *
18
29
  * `backgroundDepth` is the part of `fetchDepth` that re-verifies rows the
19
30
  * query already shows: a registration of a query that is not `cold`. It still
20
31
  * holds `status: 'fetching'` (results are not yet confirmed), but it is not
@@ -29,6 +40,7 @@ export interface QueryLifecycle {
29
40
  readonly fetchDepth: number;
30
41
  readonly backgroundDepth: number;
31
42
  readonly notified: boolean;
43
+ readonly answered: boolean;
32
44
  }
33
45
 
34
46
  export type LifecycleEvent =
@@ -41,7 +53,7 @@ export type LifecycleEvent =
41
53
  | { type: 'remote-dropped' }
42
54
  | { type: 'fetch-begin'; background?: boolean }
43
55
  | { type: 'fetch-end'; background?: boolean }
44
- | { type: 'notified' }
56
+ | { type: 'notified'; answered?: boolean }
45
57
  | { type: 'bucket-switch'; resolvedBefore: boolean };
46
58
 
47
59
  export class LifecycleError extends Error {
@@ -52,7 +64,14 @@ export class LifecycleError extends Error {
52
64
  }
53
65
 
54
66
  export function seedLifecycle(resolvedBefore: boolean): QueryLifecycle {
55
- return { phase: resolvedBefore ? 'cached' : 'cold', remote: 'unregistered', fetchDepth: 0, backgroundDepth: 0, notified: false };
67
+ return {
68
+ phase: resolvedBefore ? 'cached' : 'cold',
69
+ remote: 'unregistered',
70
+ fetchDepth: 0,
71
+ backgroundDepth: 0,
72
+ notified: false,
73
+ answered: false,
74
+ };
56
75
  }
57
76
 
58
77
  /** Total on every (lifecycle, event) pair the sagas can produce; throws on the rest. */
@@ -84,14 +103,23 @@ export function transition(l: QueryLifecycle, ev: LifecycleEvent): QueryLifecycl
84
103
  const backgroundDepth = Math.min(fetchDepth, l.backgroundDepth - (ev.background && l.backgroundDepth > 0 ? 1 : 0));
85
104
  return { ...l, fetchDepth, backgroundDepth };
86
105
  }
87
- case 'notified':
88
- return l.notified ? l : { ...l, notified: true };
106
+ case 'notified': {
107
+ // A cold render is a guess from the local store, never an answer.
108
+ const answered = l.answered || (ev.answered === true && l.phase !== 'cold');
109
+ return l.notified && answered === l.answered ? l : { ...l, notified: true, answered };
110
+ }
89
111
  case 'bucket-switch':
90
112
  return { ...seedLifecycle(ev.resolvedBefore), fetchDepth: 0 };
91
113
  }
92
114
  }
93
115
 
116
+ /** Membership is known (server set or durable seed); its rows may not be painted yet. */
94
117
  export const isAuthoritative = (l: QueryLifecycle): boolean => l.phase !== 'cold';
118
+ /**
119
+ * Subscribers hold the rows of a known membership: what the authority channel
120
+ * (`subscribeQueryAuthority`, the bindings' `isAuthoritative()`) reports.
121
+ */
122
+ export const isAnswered = (l: QueryLifecycle): boolean => l.answered;
95
123
  export const hasServerMembership = (l: QueryLifecycle): boolean =>
96
124
  l.phase === 'live' || l.phase === 'view-lost';
97
125
  export const deriveStatus = (l: QueryLifecycle): QueryStatus => (l.fetchDepth > 0 ? 'fetching' : 'idle');
@@ -79,6 +79,14 @@ describe('query reducers', () => {
79
79
  expect(s2.queries.get('a')!.telemetry.phaseSamples.localFetch).toEqual([12]);
80
80
  });
81
81
 
82
+ it('setRecords answers a query only when told to, and never a cold one', () => {
83
+ const cold = buildState([e('a')]);
84
+ expect(R.setRecords('a', [], false, null, true)(cold).queries.get('a')!.lifecycle.answered).toBe(false);
85
+ const cached = buildState([e('a', { lifecycle: { phase: 'cached' } })]);
86
+ expect(R.setRecords('a', [], false, null)(cached).queries.get('a')!.lifecycle).toMatchObject({ notified: true, answered: false });
87
+ expect(R.setRecords('a', [], false, null, true)(cached).queries.get('a')!.lifecycle).toMatchObject({ notified: true, answered: true });
88
+ });
89
+
82
90
  it('sample windows are capped', () => {
83
91
  let s = buildState([e('a')]);
84
92
  for (let i = 0; i < 105; i++) s = R.recordPhase('a', 'localFetch', i)(s);
@@ -249,12 +257,14 @@ describe('bucket switch reducers', () => {
249
257
  R.outboxReplace([buildOutboxItem()])
250
258
  );
251
259
  const id = { table: '_00_query', id: 'new' } as any;
252
- const s1 = R.rebindQuery('a', { id, lifecycle: { phase: 'cached', remote: 'unregistered', fetchDepth: 0, backgroundDepth: 0, notified: false }, remoteArray: [['t:2', 1]], localArray: [] })({ ...s0, dirty: new Set() });
260
+ const s1 = R.rebindQuery('a', { id, lifecycle: { phase: 'cached', remote: 'unregistered', fetchDepth: 0, backgroundDepth: 0, notified: false, answered: false }, remoteArray: [['t:2', 1]], localArray: [] })({ ...s0, dirty: new Set() });
253
261
  const en = s1.queries.get('a')!;
254
262
  expect(en.def.id).toBe(id);
255
263
  expect(en.lifecycle.phase).toBe('cached');
256
264
  expect(en.remoteArray).toEqual([['t:2', 1]]);
257
- expect(en.records).toEqual([]);
265
+ // What subscribers still hold: the next render diffs against it, so a new
266
+ // store that renders `[]` is a change they hear about.
267
+ expect(en.records).toEqual([{ id: 't:1' }]);
258
268
  expect(en.serverState).toBeNull();
259
269
  expect(en.registerAttempts).toBe(0);
260
270
  expect(s1.dirty.has('a')).toBe(true);
@@ -153,8 +153,13 @@ export const setSubqueryRemoteArray =
153
153
  return withView(next, entry.def.viewKey, vouchedIds(entry.remoteArray, subqueryRemoteArray));
154
154
  };
155
155
 
156
+ /**
157
+ * Commit a materialization. `answered`: it drew a known membership with no
158
+ * body still to download, and that membership is still current (see
159
+ * `QueryLifecycle`).
160
+ */
156
161
  export const setRecords =
157
- (hash: QueryHash, records: ReadonlyArray<Row>, changed: boolean, materializeMs: number | null): Reducer =>
162
+ (hash: QueryHash, records: ReadonlyArray<Row>, changed: boolean, materializeMs: number | null, answered = false): Reducer =>
158
163
  (s) => {
159
164
  const next = withEntry(s, hash, (e) => {
160
165
  const prev = e.telemetry.phaseSamples.localFetch ?? [];
@@ -171,7 +176,7 @@ export const setRecords =
171
176
  ...e,
172
177
  records: changed ? records : e.records,
173
178
  telemetry,
174
- lifecycle: transition(e.lifecycle, { type: 'notified' }),
179
+ lifecycle: transition(e.lifecycle, { type: 'notified', answered }),
175
180
  };
176
181
  });
177
182
  return next === s ? s : { ...next, dirty: withoutAll(next.dirty, [hash]) };
@@ -454,7 +459,13 @@ export const setIdentity =
454
459
  ): Reducer =>
455
460
  (s) => ({ ...s, ...patch });
456
461
 
457
- /** Re-home a query after a bucket switch: new store, same hash, fresh sync state. */
462
+ /**
463
+ * Re-home a query after a bucket switch: new store, same hash, fresh sync state.
464
+ * `records` is kept: it is what subscribers were last handed, and the next
465
+ * render diffs against it. Blanking it here (without telling anyone) let a
466
+ * new store that renders `[]` read as unchanged, so the previous principal's
467
+ * rows stayed on screen under an authority flip that vouched for them.
468
+ */
458
469
  export const rebindQuery =
459
470
  (
460
471
  hash: QueryHash,
@@ -468,7 +479,6 @@ export const rebindQuery =
468
479
  remoteArray: next.remoteArray,
469
480
  localArray: next.localArray,
470
481
  subqueryRemoteArray: [],
471
- records: [],
472
482
  serverState: null,
473
483
  lastHeartbeatAt: null,
474
484
  lastPolledAt: null,
@@ -217,6 +217,7 @@ export function toQueryState(e: QueryEntry): QueryState {
217
217
  viewLost: l.phase === 'view-lost',
218
218
  serverState: e.serverState,
219
219
  syncNotified: l.notified,
220
+ answered: l.answered,
220
221
  ttlTimer: null,
221
222
  ttlDurationMs: e.def.ttlMs,
222
223
  lastHeartbeatAt: e.lastHeartbeatAt ?? undefined,
package/src/types.ts CHANGED
@@ -706,6 +706,12 @@ export interface QueryState {
706
706
  * always emits at least once even when its records are unchanged — otherwise
707
707
  * an empty re-registered window would never notify and stay "loading". */
708
708
  syncNotified?: boolean;
709
+ /**
710
+ * Subscribers hold the rows of a known membership (see `QueryLifecycle`'s
711
+ * `answered`): what the authority channel and the bindings'
712
+ * `isAuthoritative()` report. Ephemeral.
713
+ */
714
+ answered?: boolean;
709
715
  /** Timer for TTL expiration. */
710
716
  ttlTimer: NodeJS.Timeout | null;
711
717
  /** TTL duration in milliseconds. */
@@ -798,9 +804,12 @@ export interface QueryTimings {
798
804
  export type QueryUpdateCallback = (records: Record<string, any>[]) => void;
799
805
  export type QueryStatusCallback = (status: QueryStatus) => void;
800
806
  /**
801
- * Observer for a query's authority flips: `true` once server membership is
802
- * known for it (registration, poll, durable seed), `false` when it is reset
803
- * (bucket switch). What the bindings' `isAuthoritative()` mirrors.
807
+ * Observer for a query's authority flips: `true` once subscribers hold the
808
+ * rendered rows of a known membership (a server answer whose bodies are
809
+ * local, or the durable membership of an earlier session), delivered after
810
+ * the rows that vouch for it; `false` when it is reset (bucket switch). Knowing the set is
811
+ * not enough: a `cached` re-seed or a just-applied server set has no rows
812
+ * painted yet. What the bindings' `isAuthoritative()` mirrors.
804
813
  */
805
814
  export type QueryAuthorityCallback = (known: boolean) => void;
806
815
  export type MutationCallback = (mutations: MutationEvent[]) => void;