@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,302 @@
1
+ import { Socket } from 'node:net';
2
+ import { createHash, randomUUID } from 'node:crypto';
3
+ import { readFileSync } from 'node:fs';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
6
+ import { RecordId, Surreal } from 'surrealdb';
7
+ import { liveEndpoint } from '../live/index';
8
+ import { isRefusedSessionError } from '../utils/error-classification';
9
+ import { heartbeatBatch, heartbeatRowGone, queryRowCountSelect } from './sql';
10
+
11
+ /**
12
+ * The app's session guard (`sessionGuard` in sp00ky.yml, the SESSION GUARD
13
+ * block of `meta_tables_remote.surql`) ends sync for a WebSocket that is still
14
+ * open. SurrealDB checks a token only when a socket authenticates, so before
15
+ * the guard a socket that authenticated before a password reset kept reading
16
+ * its user's edges, heartbeating its views and registering new ones until it
17
+ * happened to reconnect.
18
+ *
19
+ * Runs the shipped guard, `_00_query` and `_00_list_ref` blocks, the
20
+ * `fn::query::*` functions and the per-user edge-table rule ssp-node defines,
21
+ * against a real SurrealDB, because the guarantee is the engine's evaluation
22
+ * of those rules, LIVE notifications included. Boot one first, e.g.:
23
+ * docker run -d --name spooky-surreal-it -p 18011:8000 \
24
+ * surrealdb/surrealdb:v3.1.5 start --user root --pass root --allow-all memory
25
+ *
26
+ * Override the endpoint with SURREAL_IT_ENDPOINT. Skips if unreachable.
27
+ */
28
+ const endpoint = liveEndpoint(process.env.SURREAL_IT_ENDPOINT ?? 'ws://127.0.0.1:18011');
29
+ const NS = 'it';
30
+
31
+ /** The guard WhitePawn ships: a password reset stamps `session_epoch`. */
32
+ const GUARD = '$auth.session_epoch = NONE OR ($token.iat ?? 0) >= $auth.session_epoch';
33
+
34
+ const repoFile = (path: string) =>
35
+ readFileSync(fileURLToPath(new URL(`../../../../${path}`, import.meta.url)), 'utf8');
36
+ const cliSource = (file: string) => repoFile(`apps/cli/src/${file}`);
37
+
38
+ function slice(src: string, from: string, to: string): string {
39
+ const start = src.indexOf(from);
40
+ const end = src.indexOf(to, start);
41
+ if (start < 0 || end < start) throw new Error(`block ${from} .. ${to} not found`);
42
+ return src.slice(start, end);
43
+ }
44
+
45
+ /**
46
+ * The SESSION GUARD block, with `expr` put in the way `spky migrate` does
47
+ * (`schema_builder::apply_session_guard`, whose marker is read from the Rust
48
+ * source so the two cannot drift).
49
+ */
50
+ function guardSchema(expr?: string): string {
51
+ const block = slice(
52
+ cliSource('meta_tables_remote.surql'),
53
+ '-- SPOOKY SESSION GUARD',
54
+ '-- SPOOKY INCANTATION'
55
+ );
56
+ const m = /pub const SESSION_GUARD_DEFAULT: &str = "([^"]+)";/.exec(
57
+ repoFile('apps/cli/src/schema_builder.rs')
58
+ );
59
+ if (!m || !block.includes(m[1])) throw new Error('session guard marker not found');
60
+ return expr ? block.replace(m[1], `RETURN (${expr}) == true;`) : block;
61
+ }
62
+
63
+ /** Just the tables this test needs: the rest of the file needs modules. */
64
+ const queryTableSchema = () =>
65
+ slice(
66
+ cliSource('meta_tables_remote.surql'),
67
+ 'DEFINE TABLE OVERWRITE _00_query SCHEMALESS',
68
+ '-- SPOOKY LIST REF'
69
+ );
70
+ const listRefSchema = () =>
71
+ slice(
72
+ cliSource('meta_tables_remote.surql'),
73
+ 'DEFINE TABLE OVERWRITE _00_list_ref SCHEMALESS',
74
+ '-- SPOOKY STREAM PROCESSOR STATE'
75
+ );
76
+
77
+ /** The rule ssp-node puts on `_00_list_ref_user_<uid>` (`user_list_ref_ddl` in tables.rs). */
78
+ function userListRefTable(uid: string): string {
79
+ const m = /PERMISSIONS FOR select WHERE (\$auth\.id = user:\{uid\}[^,]*),/.exec(
80
+ repoFile('packages/ssp-node/src/tables.rs')
81
+ );
82
+ if (!m) throw new Error('per-user list_ref rule not found in ssp-node/src/tables.rs');
83
+ return `DEFINE TABLE OVERWRITE _00_list_ref_user_${uid} SCHEMALESS
84
+ PERMISSIONS FOR select WHERE ${m[1].replace('{uid}', uid)}, FOR create, update, delete WHERE false;`;
85
+ }
86
+
87
+ /** An app schema: a hidden `session_epoch` and an app table that asks the guard too. */
88
+ const APP = `
89
+ DEFINE TABLE OVERWRITE user SCHEMAFULL PERMISSIONS FOR select WHERE id = $auth.id;
90
+ DEFINE FIELD OVERWRITE name ON TABLE user TYPE string;
91
+ DEFINE FIELD OVERWRITE session_epoch ON TABLE user TYPE option<int>
92
+ PERMISSIONS FOR select WHERE false, FOR create, update WHERE false;
93
+ DEFINE ACCESS OVERWRITE account ON DATABASE TYPE RECORD
94
+ SIGNUP (CREATE ONLY type::record('user', $name) SET name = $name)
95
+ SIGNIN (SELECT * FROM user WHERE name = $name)
96
+ DURATION FOR TOKEN 1h, FOR SESSION 1h;
97
+ DEFINE TABLE OVERWRITE game SCHEMALESS
98
+ PERMISSIONS FOR select WHERE owner = $auth.id AND fn::sp00ky::session_live();
99
+ CREATE game:1 SET owner = user:alice;
100
+ `;
101
+
102
+ /** What the SSP writes, as root, when a session registers a view (`node.rs`, cold path). */
103
+ const SSP_REGISTER = `
104
+ UPSERT $id SET clientId = $sid, auth_id = 'user:alice', surql = 'SELECT * FROM game',
105
+ params = { auth: { id: 'user:alice' }, access: 'account' }, ttl = 1h, lastActiveAt = time::now(),
106
+ rowCount = 1, state = 'ready',
107
+ subscribers = array::append(
108
+ array::filter(subscribers ?? [], |$s| <string>$s.id != $sid AND <datetime>$s.seenAt + 1h > time::now()),
109
+ { id: $sid, seenAt: time::now() })`;
110
+
111
+ const viewId = () =>
112
+ new RecordId('_00_query', createHash('sha256').update(randomUUID()).digest('hex'));
113
+
114
+ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
115
+
116
+ describe('session guard ends sync for an open socket (real SurrealDB)', () => {
117
+ let root: Surreal;
118
+ let database = '';
119
+ let reachable = false;
120
+ const open: Surreal[] = [];
121
+
122
+ beforeAll(async () => {
123
+ const url = new URL(endpoint);
124
+ reachable = await new Promise<boolean>((resolve) => {
125
+ const socket = new Socket();
126
+ const done = (ok: boolean) => {
127
+ socket.destroy();
128
+ resolve(ok);
129
+ };
130
+ socket.setTimeout(1_000, () => done(false));
131
+ socket.once('error', () => done(false));
132
+ socket.connect(Number(url.port || 80), url.hostname, () => done(true));
133
+ });
134
+ });
135
+
136
+ /** Root, a fresh database, the app and sp00ky's rules with `guard` in place. */
137
+ async function boot(guard?: string): Promise<void> {
138
+ database = `session_guard_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
139
+ root = new Surreal();
140
+ await root.connect(endpoint, { reconnect: false });
141
+ await root.signin({ username: 'root', password: 'root' });
142
+ await root.use({ namespace: NS, database });
143
+ await root.query(guardSchema(guard));
144
+ await root.query(APP);
145
+ await root.query(queryTableSchema());
146
+ await root.query(listRefSchema());
147
+ await root.query(userListRefTable('alice'));
148
+ await root.query(cliSource('functions_remote.surql'));
149
+ // The guard throws before the post; a live session's post fails on this
150
+ // closed port, which is not a refused session.
151
+ await root.query(
152
+ cliSource('functions_remote_singlenode.surql')
153
+ .replaceAll('{{ENDPOINT}}', 'http://127.0.0.1:9')
154
+ .replaceAll('{{SECRET}}', 'it-secret')
155
+ );
156
+ }
157
+
158
+ afterEach(async () => {
159
+ if (!reachable || !root) return;
160
+ for (const db of open.splice(0)) await db.close().catch(() => undefined);
161
+ await root.query(`REMOVE DATABASE ${database}`).catch(() => undefined);
162
+ await root.close();
163
+ });
164
+
165
+ /** Alice on a socket of her own, and that socket's `session::id()`. */
166
+ async function alice(how: 'signup' | 'signin'): Promise<[Surreal, string]> {
167
+ const db = new Surreal();
168
+ open.push(db);
169
+ await db.connect(endpoint, { reconnect: false });
170
+ await db.use({ namespace: NS, database });
171
+ await db[how]({ namespace: NS, database, access: 'account', variables: { name: 'alice' } });
172
+ const [sid] = (await db.query('RETURN <string>session::id()')) as [string];
173
+ return [db, sid];
174
+ }
175
+
176
+ /** What a password reset does: every token issued before now stops counting. */
177
+ async function resetPassword(stale: Surreal): Promise<void> {
178
+ const [iat] = (await stale.query('RETURN $token.iat')) as [number];
179
+ const epoch = iat + 1;
180
+ await root.query('UPDATE user:alice SET session_epoch = $epoch', { epoch });
181
+ // `iat` has second precision: the next sign-in must land at or past the epoch.
182
+ while (Date.now() / 1000 < epoch) await sleep(50);
183
+ }
184
+
185
+ /** LIVE on the per-user edge table; returns the notifications seen so far. */
186
+ async function watchEdges(db: Surreal): Promise<unknown[]> {
187
+ const seen: unknown[] = [];
188
+ const [id] = (await db.query('LIVE SELECT * FROM _00_list_ref_user_alice')) as [unknown];
189
+ const stream = await db.liveOf(id as never);
190
+ stream.subscribe((message: { action: string; value: unknown }) => {
191
+ if (message.action !== 'KILLED') seen.push(message.value);
192
+ });
193
+ return seen;
194
+ }
195
+
196
+ const edge = (n: number) =>
197
+ root.query(
198
+ `CREATE type::record('_00_list_ref_user_alice', $n) SET in = _00_query:q, out = game:1, version = $n`,
199
+ { n }
200
+ );
201
+
202
+ /** The client's register read-back, as `registerSelect` sends it. */
203
+ const readBack = async (db: Surreal, id: RecordId<string>) =>
204
+ ((await db.query(queryRowCountSelect(), { in: id })) as [unknown])[0] ?? null;
205
+
206
+ const heartbeatFound = async (db: Surreal, id: RecordId<string>) => {
207
+ const beat = heartbeatBatch([id]);
208
+ const [result] = (await db.query(beat.sql, beat.vars)) as [unknown];
209
+ return !heartbeatRowGone(result);
210
+ };
211
+
212
+ const registerError = async (db: Surreal): Promise<unknown> =>
213
+ db
214
+ .query('RETURN fn::query::register($config)', {
215
+ config: { id: viewId(), surql: 'SELECT * FROM game', params: {}, ttl: '1h' },
216
+ })
217
+ .then(
218
+ () => null,
219
+ (error: unknown) => error
220
+ );
221
+
222
+ it('a socket from before the reset stops receiving edges, a fresh sign-in keeps them', async (ctx) => {
223
+ if (!reachable) ctx.skip();
224
+ await boot(GUARD);
225
+ const [stale] = await alice('signup');
226
+ const staleSeen = await watchEdges(stale);
227
+ await edge(1);
228
+ await expect.poll(() => staleSeen.length).toBe(1);
229
+
230
+ await resetPassword(stale);
231
+ const [fresh] = await alice('signin');
232
+ const freshSeen = await watchEdges(fresh);
233
+ await edge(2);
234
+ await edge(3);
235
+
236
+ await expect.poll(() => freshSeen.length).toBe(2);
237
+ await sleep(200);
238
+ expect(staleSeen.length).toBe(1);
239
+ });
240
+
241
+ it('a socket from before the reset reads none of its user\'s edges, views or rows', async (ctx) => {
242
+ if (!reachable) ctx.skip();
243
+ await boot(GUARD);
244
+ const [stale, staleSid] = await alice('signup');
245
+ await edge(1);
246
+ await root.query(
247
+ `CREATE _00_list_ref:1 SET in = _00_query:q, out = game:1, clientId = 'x', auth_id = 'user:alice', version = 1`
248
+ );
249
+ await resetPassword(stale);
250
+ const [fresh, freshSid] = await alice('signin');
251
+ const q = viewId();
252
+ await root.query(SSP_REGISTER, { id: q, sid: staleSid });
253
+ await root.query(SSP_REGISTER, { id: q, sid: freshSid });
254
+
255
+ for (const table of ['_00_list_ref_user_alice', '_00_list_ref', 'game']) {
256
+ expect(await stale.query(`SELECT VALUE id FROM ${table}`), table).toEqual([[]]);
257
+ expect(((await fresh.query(`SELECT VALUE id FROM ${table}`)) as [unknown[]])[0], table).toHaveLength(1);
258
+ }
259
+ expect(await readBack(stale, q)).toBeNull();
260
+ expect(await heartbeatFound(stale, q)).toBe(false);
261
+ expect(await readBack(fresh, q)).toEqual({ rowCount: 1, state: 'ready' });
262
+ expect(await heartbeatFound(fresh, q)).toBe(true);
263
+ });
264
+
265
+ it('fn::query::register refuses the stale socket as a refused session, and only that one', async (ctx) => {
266
+ if (!reachable) ctx.skip();
267
+ await boot(GUARD);
268
+ const [stale] = await alice('signup');
269
+ expect(isRefusedSessionError(await registerError(stale))).toBe(false);
270
+
271
+ await resetPassword(stale);
272
+ const [fresh] = await alice('signin');
273
+
274
+ const refused = await registerError(stale);
275
+ expect(isRefusedSessionError(refused), String(refused)).toBe(true);
276
+ expect(isRefusedSessionError(await registerError(fresh))).toBe(false);
277
+ });
278
+
279
+ it('without a sessionGuard every session stays live, as before the guard', async (ctx) => {
280
+ if (!reachable) ctx.skip();
281
+ await boot();
282
+ const [stale, staleSid] = await alice('signup');
283
+ await edge(1);
284
+ const q = viewId();
285
+ await root.query(SSP_REGISTER, { id: q, sid: staleSid });
286
+ await resetPassword(stale);
287
+
288
+ expect(((await stale.query('SELECT VALUE id FROM _00_list_ref_user_alice')) as [unknown[]])[0]).toHaveLength(1);
289
+ expect(await heartbeatFound(stale, q)).toBe(true);
290
+ expect(isRefusedSessionError(await registerError(stale))).toBe(false);
291
+ });
292
+
293
+ it('a database without the probe row lets every registration through', async (ctx) => {
294
+ if (!reachable) ctx.skip();
295
+ await boot(GUARD);
296
+ const [stale] = await alice('signup');
297
+ await resetPassword(stale);
298
+ await root.query('DELETE _00_session_guard:probe');
299
+
300
+ expect(isRefusedSessionError(await registerError(stale))).toBe(false);
301
+ });
302
+ });
@@ -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;
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from 'vitest';
2
- import { classifySyncError } from './error-classification';
2
+ import { classifySyncError, isRefusedSessionError } from './error-classification';
3
3
 
4
4
  describe('classifySyncError', () => {
5
5
  it('classifies surreal ConnectionUnavailableError as network', () => {
@@ -52,3 +52,21 @@ describe('classifySyncError', () => {
52
52
  expect(classifySyncError('boom')).toBe('application');
53
53
  });
54
54
  });
55
+
56
+ describe('isRefusedSessionError', () => {
57
+ it.each([
58
+ 'There was a problem with authentication',
59
+ 'An error occurred: There was a problem with authentication: this session has ended, sign in again',
60
+ 'The session has expired',
61
+ ])('takes %j as a refused session', (message) => {
62
+ expect(isRefusedSessionError(new Error(message))).toBe(true);
63
+ });
64
+
65
+ it.each([
66
+ 'There was a problem with the database: Invalid token',
67
+ 'There was a problem with the underlying connection',
68
+ 'Permission denied',
69
+ ])('does not take %j as one', (message) => {
70
+ expect(isRefusedSessionError(new Error(message))).toBe(false);
71
+ });
72
+ });
@@ -51,3 +51,15 @@ export function classifySyncError(error: unknown): 'network' | 'application' {
51
51
 
52
52
  return 'application';
53
53
  }
54
+
55
+ /**
56
+ * Whether the server refused the SESSION rather than the statement: its own
57
+ * wording for a token it will not accept ("There was a problem with
58
+ * authentication"), which is also what an app's AUTHENTICATE clause and
59
+ * sp00ky's session guard throw, and a session past its `DURATION FOR SESSION`.
60
+ */
61
+ export function isRefusedSessionError(error: unknown): boolean {
62
+ const message =
63
+ error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
64
+ return message.includes('problem with authentication') || message.includes('session has expired');
65
+ }