@spooky-sync/core 0.0.1-canary.332 → 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.
@@ -0,0 +1,140 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { AuthService, REFUSAL_RECHECK_MIN_MS } from './index';
3
+ import {
4
+ createDatabaseEventSystem,
5
+ DatabaseEventTypes,
6
+ } from '../../services/database/events/index';
7
+
8
+ // The session is re-checked after every reconnect and after a statement the
9
+ // server refused because of the session: a token refused on a socket's own
10
+ // handshake used to leave the page on an anonymous `connected` socket while
11
+ // it kept announcing the user.
12
+
13
+ function jwt(claims: Record<string, unknown>): string {
14
+ return `header.${Buffer.from(JSON.stringify(claims)).toString('base64url')}.signature`;
15
+ }
16
+
17
+ const silentLogger = () =>
18
+ ({ debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), child: () => silentLogger() }) as any;
19
+
20
+ const REFUSED = 'There was a problem with authentication: this session has ended, sign in again';
21
+
22
+ function makeAuth() {
23
+ const token = jwt({ AC: 'account', ID: 'user:abc' });
24
+ const store = new Map<string, unknown>([['sp00ky_auth_token', token]]);
25
+ const persistence = {
26
+ get: vi.fn(async (k: string) => store.get(k) ?? null),
27
+ set: vi.fn(async (k: string, v: unknown) => void store.set(k, v)),
28
+ remove: vi.fn(async (k: string) => void store.delete(k)),
29
+ } as any;
30
+ const connected = new Set<() => void>();
31
+ const events = createDatabaseEventSystem();
32
+ let refuse = false;
33
+ const authenticate = vi.fn(async () => {
34
+ if (refuse) throw new Error(REFUSED);
35
+ });
36
+ const remote = {
37
+ query: vi.fn(async () => [{ id: 'user:abc' }]),
38
+ setAuthToken: vi.fn(),
39
+ whenConnected: vi.fn(async () => undefined),
40
+ subscribeConnection: (_event: 'connected', cb: () => void) => {
41
+ connected.add(cb);
42
+ return () => connected.delete(cb);
43
+ },
44
+ getEvents: () => events,
45
+ getClient: () => ({ authenticate, invalidate: vi.fn(async () => undefined) }),
46
+ } as any;
47
+ const auth = new AuthService({} as any, remote, persistence, silentLogger());
48
+ const settle = () => new Promise((r) => setTimeout(r, 0));
49
+ return {
50
+ auth,
51
+ authenticate,
52
+ store,
53
+ refuseFromNowOn: () => void (refuse = true),
54
+ reconnect: async () => {
55
+ for (const cb of connected) cb();
56
+ await settle();
57
+ },
58
+ statementFails: async (error: string) => {
59
+ events.emit(DatabaseEventTypes.RemoteQuery, {
60
+ query: 'RETURN fn::query::register($config)',
61
+ duration: 1,
62
+ success: false,
63
+ error,
64
+ timestamp: Date.now(),
65
+ });
66
+ await settle();
67
+ },
68
+ };
69
+ }
70
+
71
+ describe('AuthService session re-check', () => {
72
+ it('signs out when a reconnected socket can no longer use the token', async () => {
73
+ const t = makeAuth();
74
+ await t.auth.init();
75
+ expect(t.auth.isAuthenticated).toBe(true);
76
+
77
+ t.refuseFromNowOn();
78
+ await t.reconnect();
79
+
80
+ expect(t.authenticate).toHaveBeenCalledTimes(2);
81
+ expect(t.auth.isAuthenticated).toBe(false);
82
+ expect(t.store.get('sp00ky_auth_token')).toBeUndefined();
83
+ });
84
+
85
+ it('keeps a session the server still accepts after a reconnect', async () => {
86
+ const t = makeAuth();
87
+ await t.auth.init();
88
+ await t.reconnect();
89
+
90
+ expect(t.authenticate).toHaveBeenCalledTimes(2);
91
+ expect(t.auth.isAuthenticated).toBe(true);
92
+ });
93
+
94
+ it('signs out when a statement on an open socket is refused for the session', async () => {
95
+ const t = makeAuth();
96
+ await t.auth.init();
97
+
98
+ t.refuseFromNowOn();
99
+ await t.statementFails(`An error occurred: ${REFUSED}`);
100
+
101
+ expect(t.auth.isAuthenticated).toBe(false);
102
+ });
103
+
104
+ it('ignores statement errors that are not about the session', async () => {
105
+ const t = makeAuth();
106
+ await t.auth.init();
107
+ await t.statementFails('Found 3 for field `n`, but expected a string');
108
+ await t.statementFails('There was a problem with the underlying connection');
109
+
110
+ expect(t.authenticate).toHaveBeenCalledTimes(1);
111
+ });
112
+
113
+ it('re-checks once per window however many statements are refused', async () => {
114
+ vi.useFakeTimers({ toFake: ['Date'] });
115
+ try {
116
+ const t = makeAuth();
117
+ await t.auth.init();
118
+ await t.statementFails(REFUSED);
119
+ await t.statementFails(REFUSED);
120
+ expect(t.authenticate).toHaveBeenCalledTimes(2);
121
+
122
+ vi.setSystemTime(Date.now() + REFUSAL_RECHECK_MIN_MS);
123
+ await t.statementFails(REFUSED);
124
+ expect(t.authenticate).toHaveBeenCalledTimes(3);
125
+ expect(t.auth.isAuthenticated).toBe(true);
126
+ } finally {
127
+ vi.useRealTimers();
128
+ }
129
+ });
130
+
131
+ it('does nothing for a signed-out page', async () => {
132
+ const t = makeAuth();
133
+ t.store.clear();
134
+ await t.auth.init();
135
+ await t.reconnect();
136
+ await t.statementFails(REFUSED);
137
+
138
+ expect(t.authenticate).not.toHaveBeenCalled();
139
+ });
140
+ });
@@ -9,7 +9,8 @@ import type { Logger } from '../../services/logger/index';
9
9
  export * from './events/index';
10
10
  import { AuthEventTypes, createAuthEventSystem } from './events/index';
11
11
  import type { PersistenceClient } from '../../types';
12
- import { classifySyncError } from '../../utils/error-classification';
12
+ import { classifySyncError, isRefusedSessionError } from '../../utils/error-classification';
13
+ import { DatabaseEventTypes } from '../../services/database/events/index';
13
14
  import { encodeRecordId } from '../../utils/index';
14
15
  import { RecordId } from 'surrealdb';
15
16
  import {
@@ -53,6 +54,13 @@ export type SignOutHook = (ctx: SignOutContext) => Promise<void> | void;
53
54
  /** Upper bound on the before-sign-out hooks, so a dead socket never holds a sign-out. */
54
55
  export const SIGN_OUT_HOOK_TIMEOUT_MS = 2_000;
55
56
 
57
+ /**
58
+ * Floor between two session re-checks started by refused statements. A
59
+ * refused session fails every statement at once, and a check that keeps the
60
+ * session (the server was unreachable) must not be re-run by each of them.
61
+ */
62
+ export const REFUSAL_RECHECK_MIN_MS = 30_000;
63
+
56
64
  // Helper to pretty print types
57
65
  type Prettify<T> = {
58
66
  [K in keyof T]: T[K];
@@ -111,6 +119,11 @@ export class AuthService<S extends SchemaStructure> {
111
119
  private stopping: Promise<void> | null = null;
112
120
  /** A token renewal in flight, which a stop waits out (see `renewImpersonation`). */
113
121
  private renewing: Promise<void> | null = null;
122
+ /** The session re-check in flight (see `watchSession`), shared by every trigger. */
123
+ private reverifying: Promise<void> | null = null;
124
+ /** When a refused statement last started a re-check. */
125
+ private lastRefusalCheckAt = Number.NEGATIVE_INFINITY;
126
+ private watchingSession = false;
114
127
  private impersonationTimers = new ImpersonationTimers({
115
128
  current: () => this.impersonation,
116
129
  renew: () => this.renewImpersonation(),
@@ -141,6 +154,60 @@ export class AuthService<S extends SchemaStructure> {
141
154
 
142
155
  async init() {
143
156
  await this.check();
157
+ this.watchSession();
158
+ }
159
+
160
+ /**
161
+ * Check the session again whenever the socket comes back, and whenever the
162
+ * server refuses a statement because of the session.
163
+ *
164
+ * The server judges a token only when a socket authenticates: the SDK
165
+ * replays `authenticate` on its own reconnects, `RemoteDatabaseService` on a
166
+ * socket it rebuilds. When the replay is refused (the app ended the session,
167
+ * say with a password reset) the SDK drops to an anonymous session, and the
168
+ * supervisor swallows the rebuild's error, so the socket reports `connected`
169
+ * while this service still announces the user. Every view registered from
170
+ * then on carried no identity: the page looked signed in and showed nothing
171
+ * of the user's. `check()` applies the token again and signs out when the
172
+ * server refuses it.
173
+ *
174
+ * A socket that never reconnects is ended by the app's session guard
175
+ * (`fn::sp00ky::session_live`) instead: its heartbeats stop finding rows,
176
+ * and the re-registration that follows is refused with "There was a problem
177
+ * with authentication", which starts a check here too.
178
+ */
179
+ private watchSession(): void {
180
+ if (this.watchingSession) return;
181
+ this.watchingSession = true;
182
+ this.remote.subscribeConnection('connected', () => this.reverify());
183
+ this.remote.getEvents().subscribe(DatabaseEventTypes.RemoteQuery, (event) => {
184
+ const { success, error } = event.payload;
185
+ if (success || !isRefusedSessionError(error ?? '')) return;
186
+ const now = Date.now();
187
+ if (now - this.lastRefusalCheckAt < REFUSAL_RECHECK_MIN_MS) return;
188
+ this.lastRefusalCheckAt = now;
189
+ this.reverify();
190
+ });
191
+ }
192
+
193
+ /** One `check()` for every trigger that lands while it runs. */
194
+ private reverify(): void {
195
+ if (!this.isAuthenticated || this.reverifying) return;
196
+ this.reverifying = (async () => {
197
+ // A rebuilt socket reports `connected` before its namespace and token
198
+ // are applied: check against the finished handshake.
199
+ await this.remote.whenConnected();
200
+ if (this.isAuthenticated) await this.check();
201
+ })()
202
+ .catch((error) => {
203
+ this.logger.warn(
204
+ { error, Category: 'sp00ky-client::AuthService::reverify' },
205
+ 'Session re-check failed'
206
+ );
207
+ })
208
+ .finally(() => {
209
+ this.reverifying = null;
210
+ });
144
211
  }
145
212
 
146
213
  getAccessDefinition<Name extends keyof S['access']>(name: Name): AccessDefinition | undefined {
@@ -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/
@@ -58,14 +58,18 @@ function ownerGuard(): string {
58
58
 
59
59
  const sha256 = (text: string) => createHash('sha256').update(text).digest('hex');
60
60
 
61
- /** Just the `_00_query` block: the rest of the file needs tables and modules this test does not. */
61
+ /**
62
+ * Just the session guard (its rule's user branch asks it) and the `_00_query`
63
+ * block: the rest of the file needs tables and modules this test does not.
64
+ */
62
65
  function queryTableSchema(): string {
63
66
  const meta = cliSource('meta_tables_remote.surql');
67
+ const guard = meta.indexOf('-- SPOOKY SESSION GUARD');
64
68
  const start = meta.indexOf('DEFINE TABLE OVERWRITE _00_query SCHEMALESS');
65
69
  const end = meta.indexOf('-- SPOOKY LIST REF');
66
- if (start < 0 || end < start)
70
+ if (guard < 0 || start < guard || end < start)
67
71
  throw new Error('_00_query block not found in meta_tables_remote.surql');
68
- return meta.slice(start, end);
72
+ return meta.slice(guard, meta.indexOf('-- SPOOKY INCANTATION')) + meta.slice(start, end);
69
73
  }
70
74
 
71
75
  const ACCESS = `
@@ -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) {