@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.
- package/dist/index.d.ts +14 -2
- package/dist/index.js +124 -26
- package/dist/types.d.ts +46 -4
- package/package.json +3 -3
- package/src/boot/bucket-switch.saga.test.ts +9 -7
- package/src/boot/bucket-switch.saga.ts +6 -3
- package/src/client/runtime.test.ts +40 -0
- package/src/client/runtime.ts +2 -2
- package/src/modules/auth/auth.session-recheck.test.ts +140 -0
- package/src/modules/auth/index.ts +68 -1
- package/src/modules/devtools/index.ts +3 -1
- package/src/query/anon-query-isolation.integration.test.ts +7 -3
- package/src/query/materialize.saga.test.ts +73 -0
- package/src/query/materialize.saga.ts +17 -5
- package/src/query/membership.saga.test.ts +5 -2
- package/src/query/membership.saga.ts +2 -3
- package/src/query/register.saga.test.ts +2 -1
- package/src/query/session-guard.integration.test.ts +302 -0
- package/src/state/lifecycle.test.ts +46 -3
- package/src/state/lifecycle.ts +32 -4
- package/src/state/reducers.test.ts +12 -2
- package/src/state/reducers.ts +14 -4
- package/src/state/selectors.ts +1 -0
- package/src/types.ts +12 -3
- package/src/utils/error-classification.test.ts +19 -1
- package/src/utils/error-classification.ts +12 -0
|
@@ -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
|
});
|
package/src/state/lifecycle.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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);
|
package/src/state/reducers.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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,
|
package/src/state/selectors.ts
CHANGED
|
@@ -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
|
|
802
|
-
*
|
|
803
|
-
*
|
|
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
|
+
}
|