@spooky-sync/core 0.0.1-canary.331 → 0.0.1-canary.333
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/AGENTS.md +1 -1
- package/dist/index.d.ts +19 -4
- package/dist/index.js +220 -41
- package/dist/otel/index.d.ts +1 -1
- package/dist/pure.d.ts +16 -0
- package/dist/types.d.ts +62 -11
- package/package.json +3 -3
- package/src/client/router.ts +1 -1
- package/src/client/services.ts +4 -3
- package/src/kernel/events.ts +2 -2
- package/src/kernel/interpreter.ts +3 -3
- package/src/modules/auth/auth.session-recheck.test.ts +140 -0
- package/src/modules/auth/index.ts +68 -1
- package/src/modules/crdt/crdt.test.ts +52 -6
- package/src/modules/crdt/index.ts +59 -23
- package/src/query/anon-query-isolation.integration.test.ts +7 -3
- package/src/query/membership.saga.test.ts +73 -1
- package/src/query/membership.saga.ts +39 -5
- package/src/query/membership.test.ts +33 -0
- package/src/query/membership.ts +33 -0
- package/src/query/session-guard.integration.test.ts +302 -0
- package/src/sync/live.saga.test.ts +51 -0
- package/src/sync/live.saga.ts +17 -8
- package/src/types.ts +29 -10
- package/src/utils/edges.ts +19 -1
- 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
|
+
});
|
|
@@ -6,6 +6,7 @@ import * as R from '../state/reducers';
|
|
|
6
6
|
import { defaultEnv } from '../query/env';
|
|
7
7
|
import { liveChange, liveInvalidate, liveStart } from './live.saga';
|
|
8
8
|
import { rowOfEdge } from '../client/services';
|
|
9
|
+
import { deltaOfEdge } from '../utils/edges';
|
|
9
10
|
|
|
10
11
|
const env = defaultEnv({ tables: [] } as any);
|
|
11
12
|
|
|
@@ -176,3 +177,53 @@ describe('liveChange with the body joined on', () => {
|
|
|
176
177
|
expect([...out.state.membershipDirty]).toEqual(['a']);
|
|
177
178
|
});
|
|
178
179
|
});
|
|
180
|
+
|
|
181
|
+
describe('deltaOfEdge', () => {
|
|
182
|
+
const q = new RecordId('_00_query', 'a');
|
|
183
|
+
const rid = new RecordId('thread__crdt_log', 'x1');
|
|
184
|
+
it('reads a joining row off a CREATE or UPDATE, with or without the body joined on', () => {
|
|
185
|
+
expect(deltaOfEdge('CREATE', { in: q, out: rid, version: 2 })).toEqual({ hash: 'a', id: 'thread__crdt_log:x1', version: 2 });
|
|
186
|
+
expect(deltaOfEdge('UPDATE', { in: q, out: { id: rid, data: 'b' }, version: 3 })).toEqual({ hash: 'a', id: 'thread__crdt_log:x1', version: 3 });
|
|
187
|
+
});
|
|
188
|
+
it('leaves everything else to the re-read', () => {
|
|
189
|
+
// A row leaving, or a view's edges being rewritten.
|
|
190
|
+
expect(deltaOfEdge('DELETE', { in: q, out: rid, version: 2 })).toBeNull();
|
|
191
|
+
// A subquery child.
|
|
192
|
+
expect(deltaOfEdge('CREATE', { in: q, out: rid, version: 2, parent: new RecordId('thread', 't') })).toBeNull();
|
|
193
|
+
// Nothing to place: no version, no target, a target the session may not read, no query.
|
|
194
|
+
expect(deltaOfEdge('CREATE', { in: q, out: rid })).toBeNull();
|
|
195
|
+
expect(deltaOfEdge('CREATE', { in: q, out: null, version: 2 })).toBeNull();
|
|
196
|
+
expect(deltaOfEdge('CREATE', { in: q, out: 'thread__crdt_log:x1', version: 2 })).toBeNull();
|
|
197
|
+
expect(deltaOfEdge('CREATE', { out: rid, version: 2 })).toBeNull();
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
describe('liveChange with edge deltas', () => {
|
|
202
|
+
const live = (hash: string) =>
|
|
203
|
+
buildEntry({ def: { hash, viewKey: `vk-${hash}` }, lifecycle: { phase: 'live', remote: 'registered' }, remoteArray: [['t:1', 1]] });
|
|
204
|
+
|
|
205
|
+
it('commits a row joining a live view at once, with no re-read', async () => {
|
|
206
|
+
const out = await runPure(liveChange(env, ['a'], undefined, [{ hash: 'a', id: 't:2', version: 1 }]), {
|
|
207
|
+
state: { ...buildState([live('a')]), tabRole: 'leader' },
|
|
208
|
+
handlers: { 'local.upsert': () => undefined },
|
|
209
|
+
});
|
|
210
|
+
expect(out.state.queries.get('a')!.remoteArray).toEqual([['t:1', 1], ['t:2', 1]]);
|
|
211
|
+
expect(out.state.membershipDirty.size).toBe(0);
|
|
212
|
+
expect(out.timers.has('membership')).toBe(false);
|
|
213
|
+
// Other tabs still hear about it.
|
|
214
|
+
expect(out.emitted).toContainEqual({ type: 'tabs:broadcast', message: { type: 'membership-dirty', hashes: ['a'] } });
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('re-reads what it could not settle, and ignores deltas for unknown queries', async () => {
|
|
218
|
+
const cold = buildEntry({ def: { hash: 'b' }, lifecycle: { phase: 'cold' } });
|
|
219
|
+
const out = await runPure(
|
|
220
|
+
liveChange(env, ['a', 'b'], undefined, [
|
|
221
|
+
{ hash: 'b', id: 't:2', version: 1 },
|
|
222
|
+
{ hash: 'zz', id: 't:9', version: 1 },
|
|
223
|
+
]),
|
|
224
|
+
{ state: buildState([live('a'), cold]) }
|
|
225
|
+
);
|
|
226
|
+
expect([...out.state.membershipDirty].sort()).toEqual(['a', 'b']);
|
|
227
|
+
expect(out.timers.get('membership')).toEqual({ ms: 50, event: { type: 'ReadDirtyMembership' } });
|
|
228
|
+
});
|
|
229
|
+
});
|
package/src/sync/live.saga.ts
CHANGED
|
@@ -1,17 +1,18 @@
|
|
|
1
|
-
import type { InlineRow, QueryHash } from '../types';
|
|
1
|
+
import type { EdgeDelta, InlineRow, QueryHash } from '../types';
|
|
2
2
|
import type { Saga } from '../kernel/saga';
|
|
3
3
|
import { fx } from '../kernel/effects';
|
|
4
4
|
import type { ClientState } from '../state/client-state';
|
|
5
5
|
import * as R from '../state/reducers';
|
|
6
6
|
import type { SagaEnv } from '../query/env';
|
|
7
7
|
import { listRefTable } from '../query/env';
|
|
8
|
-
import { markMembershipDirty } from '../query/membership.saga';
|
|
8
|
+
import { applyEdgeDeltas, markMembershipDirty } from '../query/membership.saga';
|
|
9
9
|
import { landChunk } from '../query/fetch.saga';
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
|
-
* LIVE on the session's `_00_list_ref` table.
|
|
13
|
-
*
|
|
14
|
-
*
|
|
12
|
+
* LIVE on the session's `_00_list_ref` table. A row joining a live view is
|
|
13
|
+
* committed straight from its edge; any other event marks its query's
|
|
14
|
+
* membership dirty and the batched re-read decides. Followers get the dirt
|
|
15
|
+
* relayed by the leader.
|
|
15
16
|
*
|
|
16
17
|
* When the subscription was opened with the body joined on
|
|
17
18
|
* (`liveInlineBodies`) the event ALSO carries the changed row. That is an
|
|
@@ -45,8 +46,12 @@ export function* liveInvalidate(): Saga<void> {
|
|
|
45
46
|
yield fx.state.update(R.patchSync({ liveUuid: null, liveTable: null }));
|
|
46
47
|
}
|
|
47
48
|
|
|
48
|
-
/**
|
|
49
|
-
|
|
49
|
+
/**
|
|
50
|
+
* One or more edges of these queries changed on the server. A row joining a
|
|
51
|
+
* live view (`deltas`) is committed at once; anything else marks the query
|
|
52
|
+
* dirty for the batched re-read.
|
|
53
|
+
*/
|
|
54
|
+
export function* liveChange(env: SagaEnv, hashes: QueryHash[], rows?: InlineRow[], deltas?: EdgeDelta[]): Saga<void> {
|
|
50
55
|
const [known, role, streak] = (yield fx.state.read((s) => [hashes.filter((h) => s.queries.has(h)), s.tabRole, s.sync.pollIdleStreak])) as [
|
|
51
56
|
QueryHash[],
|
|
52
57
|
ClientState['tabRole'],
|
|
@@ -73,7 +78,11 @@ export function* liveChange(env: SagaEnv, hashes: QueryHash[], rows?: InlineRow[
|
|
|
73
78
|
// Before marking membership dirty: recording the version here is what makes
|
|
74
79
|
// the re-read's `planFetch` find nothing left to pull for this row.
|
|
75
80
|
if (rows && rows.length > 0) yield* landInlineRows(env, rows);
|
|
76
|
-
|
|
81
|
+
// The edge says what a re-read would: commit it, and spare the round trip
|
|
82
|
+
// that used to sit between every remote edit and its render.
|
|
83
|
+
const settled = deltas && deltas.length > 0 ? yield* applyEdgeDeltas(deltas.filter((d) => known.includes(d.hash))) : new Set<QueryHash>();
|
|
84
|
+
const dirty = known.filter((h) => !settled.has(h));
|
|
85
|
+
if (dirty.length > 0) yield* markMembershipDirty(dirty);
|
|
77
86
|
if (role === 'leader') yield fx.emit({ type: 'tabs:broadcast', message: { type: 'membership-dirty', hashes: known } });
|
|
78
87
|
}
|
|
79
88
|
|
package/src/types.ts
CHANGED
|
@@ -258,14 +258,20 @@ export interface Sp00kyConfig<S extends SchemaStructure> {
|
|
|
258
258
|
*/
|
|
259
259
|
streamDebounceTime?: number;
|
|
260
260
|
/**
|
|
261
|
-
*
|
|
262
|
-
*
|
|
263
|
-
*
|
|
264
|
-
*
|
|
265
|
-
* Lower = snappier propagation and more rows; higher = fewer rows and more
|
|
266
|
-
* lag for other collaborators. Defaults to 300ms.
|
|
261
|
+
* How long in milliseconds the first edit to a collaborative (CRDT) field
|
|
262
|
+
* after a quiet spell waits before it goes out, so a burst of keystrokes
|
|
263
|
+
* travels as one edit-log row. Local state is kept on every change (so
|
|
264
|
+
* reload and offline work). Defaults to 50ms.
|
|
267
265
|
*/
|
|
268
266
|
crdtDebounceMs?: number;
|
|
267
|
+
/**
|
|
268
|
+
* The shortest gap in milliseconds between two edit-log rows of one CRDT
|
|
269
|
+
* field: while someone keeps typing, their edits go out on this cadence
|
|
270
|
+
* instead of waiting for a pause. Lower = other collaborators see the text
|
|
271
|
+
* sooner and more rows are written; higher = fewer rows and more lag.
|
|
272
|
+
* Defaults to 200ms.
|
|
273
|
+
*/
|
|
274
|
+
crdtSendIntervalMs?: number;
|
|
269
275
|
/**
|
|
270
276
|
* Enable collaborative CRDT fields. When `true`, the `loro-crdt` engine is
|
|
271
277
|
* preloaded at client startup (fetched as a separate chunk on page load) so
|
|
@@ -299,15 +305,16 @@ export interface Sp00kyConfig<S extends SchemaStructure> {
|
|
|
299
305
|
* back for it.
|
|
300
306
|
*
|
|
301
307
|
* The client holds exactly one live subscription, on its `_00_list_ref`
|
|
302
|
-
* table
|
|
303
|
-
*
|
|
308
|
+
* table. Without this, the notification names the row and the client then
|
|
309
|
+
* pulls it with `SELECT * FROM $ids`. The edge
|
|
304
310
|
* is a real graph edge, so `FETCH out` makes SurrealDB resolve the row into
|
|
305
311
|
* the notification and the body fetch disappears.
|
|
306
312
|
*
|
|
307
313
|
* The push stays advisory: the body is landed exactly the way a fetched body
|
|
308
314
|
* is (store write, circuit ingest, version recorded), so a notification the
|
|
309
|
-
* server drops costs nothing but the old round trip.
|
|
310
|
-
*
|
|
315
|
+
* server drops costs nothing but the old round trip. A row joining a live
|
|
316
|
+
* view is committed to membership from the notification either way (no
|
|
317
|
+
* re-read); with this on, the body fetch is saved as well.
|
|
311
318
|
*
|
|
312
319
|
* Default `false`. The cost is wire bytes: one edit bumps the edge in every
|
|
313
320
|
* view holding the row, and under `FETCH` each of those notifications
|
|
@@ -542,6 +549,18 @@ export interface InlineRow {
|
|
|
542
549
|
record: Record<string, unknown>;
|
|
543
550
|
}
|
|
544
551
|
|
|
552
|
+
/**
|
|
553
|
+
* A row that joined a query's view, or moved to a newer version, as one LIVE
|
|
554
|
+
* notification on `_00_list_ref` reports it: the edge's `out` and `version`.
|
|
555
|
+
* The client commits it to the query's membership straight away instead of
|
|
556
|
+
* re-reading the edges (see `sync/live.saga.ts`).
|
|
557
|
+
*/
|
|
558
|
+
export interface EdgeDelta {
|
|
559
|
+
hash: QueryHash;
|
|
560
|
+
id: string;
|
|
561
|
+
version: number;
|
|
562
|
+
}
|
|
563
|
+
|
|
545
564
|
// Flat array format: [[record-id, version], [record-id, version], ...]
|
|
546
565
|
export type RecordVersionArray = Array<[string, number]>;
|
|
547
566
|
|
package/src/utils/edges.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { RecordId } from 'surrealdb';
|
|
2
|
-
import type { InlineRow } from '../types';
|
|
2
|
+
import type { EdgeDelta, InlineRow } from '../types';
|
|
3
3
|
import { encodeRecordId } from './index';
|
|
4
4
|
|
|
5
5
|
/**
|
|
@@ -37,3 +37,21 @@ export function rowOfEdge(value: unknown): InlineRow | null {
|
|
|
37
37
|
if (version === null) return null;
|
|
38
38
|
return { id, version, record: out as Record<string, unknown> };
|
|
39
39
|
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The membership change a `CREATE` or `UPDATE` notification reports: the
|
|
43
|
+
* edge's target joined the view (or its version moved). Null for anything a
|
|
44
|
+
* re-read must settle instead: a `DELETE` (a row leaving, or every edge of a
|
|
45
|
+
* view being rewritten), a subquery child (`parent` set), or an edge missing
|
|
46
|
+
* its target or version. With `FETCH out` the target is the joined row, whose
|
|
47
|
+
* `id` is the record id.
|
|
48
|
+
*/
|
|
49
|
+
export function deltaOfEdge(action: string, value: unknown): EdgeDelta | null {
|
|
50
|
+
if (action !== 'CREATE' && action !== 'UPDATE') return null;
|
|
51
|
+
const hash = hashOfEdge(value);
|
|
52
|
+
const edge = value as { out?: unknown; version?: unknown; parent?: unknown };
|
|
53
|
+
if (!hash || edge.parent != null || typeof edge.version !== 'number') return null;
|
|
54
|
+
const out = edge.out instanceof RecordId ? edge.out : (edge.out as { id?: unknown } | null | undefined)?.id;
|
|
55
|
+
if (!(out instanceof RecordId)) return null;
|
|
56
|
+
return { hash, id: encodeRecordId(out), version: edge.version };
|
|
57
|
+
}
|
|
@@ -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
|
+
}
|