@spooky-sync/core 0.0.1-canary.320 → 0.0.1-canary.322
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 +17 -7
- package/dist/index.js +133 -38
- package/dist/live.d.ts +9 -1
- package/dist/live.js +36 -6
- package/dist/pure.d.ts +38 -2
- package/dist/pure.js +2 -2
- package/dist/types.d.ts +10 -0
- package/dist/worker-edges.js +108 -67
- package/dist/worker-ref-tables.d.ts +3 -1
- package/package.json +3 -3
- package/src/boot/boot.saga.test.ts +30 -7
- package/src/boot/boot.saga.ts +10 -6
- package/src/client/services.test.ts +25 -2
- package/src/client/services.ts +41 -13
- package/src/kernel/effects.ts +8 -4
- package/src/kernel/interpreter.ts +3 -3
- package/src/live/index.test.ts +38 -4
- package/src/live/index.ts +52 -6
- package/src/modules/ref-tables.test.ts +3 -1
- package/src/modules/ref-tables.ts +4 -1
- package/src/pure.ts +1 -0
- package/src/query/anon-query-isolation.integration.test.ts +496 -0
- package/src/query/list-ref-live.integration.test.ts +203 -0
- package/src/query/membership.saga.test.ts +5 -5
- package/src/query/register.saga.ts +7 -1
- package/src/query/sql.test.ts +37 -0
- package/src/query/sql.ts +48 -3
- package/src/services/database/remote.ts +20 -2
- package/src/state/client-state.ts +8 -0
- package/src/state/reducers.ts +4 -1
- package/src/sync/connection.saga.test.ts +13 -2
- package/src/sync/connection.saga.ts +17 -13
- package/src/sync/policy.test.ts +13 -9
- package/src/sync/policy.ts +16 -3
- package/src/testing/adapters.ts +5 -1
- package/src/utils/index.ts +11 -0
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { Socket } from 'node:net';
|
|
2
|
+
import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
|
3
|
+
import { Surreal, type Uuid } from 'surrealdb';
|
|
4
|
+
import { ANON_LIST_REF_TABLE } from '../modules/ref-tables';
|
|
5
|
+
import { liveEndpoint } from '../live/index';
|
|
6
|
+
import { hashOfEdge } from '../utils/edges';
|
|
7
|
+
import { listRefLive } from './sql';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Every signed-out session shares `_00_list_ref_anon`. Each one's LIVE must
|
|
11
|
+
* carry only its own edges, including after a reconnect, when its views' edges
|
|
12
|
+
* still name the socket that registered them.
|
|
13
|
+
*
|
|
14
|
+
* Runs against a real SurrealDB, because the filter rests on how SurrealDB
|
|
15
|
+
* evaluates a LIVE WHERE (`session::id()` in the subscriber's session, a bound
|
|
16
|
+
* param kept with the LIVE). Boot one first, e.g.:
|
|
17
|
+
* docker run -d --name spooky-surreal-it -p 18011:8000 \
|
|
18
|
+
* surrealdb/surrealdb:v3.1.5 start --user root --pass root --allow-all memory
|
|
19
|
+
*
|
|
20
|
+
* Needs SurrealDB 3.1 or later: 3.0.x sends no LIVE notification for a write
|
|
21
|
+
* that follows a `LET` in the same request, which is how the SSP writes every
|
|
22
|
+
* edge (and how these tests write them).
|
|
23
|
+
*
|
|
24
|
+
* Override the endpoint with SURREAL_IT_ENDPOINT. Skips if unreachable. The
|
|
25
|
+
* Dart twin is `packages/spooky_core/test/integration/anon_live_isolation_test.dart`.
|
|
26
|
+
*/
|
|
27
|
+
const endpoint = liveEndpoint(process.env.SURREAL_IT_ENDPOINT ?? 'ws://127.0.0.1:18011');
|
|
28
|
+
const NS = 'it';
|
|
29
|
+
|
|
30
|
+
// What the SSP and the CLI define (`ensure_anon_table`, `_00_query`).
|
|
31
|
+
const SCHEMA = `
|
|
32
|
+
DEFINE TABLE OVERWRITE item SCHEMALESS PERMISSIONS FOR select WHERE true;
|
|
33
|
+
DEFINE TABLE OVERWRITE _00_query SCHEMALESS
|
|
34
|
+
PERMISSIONS FOR select, create, update, delete
|
|
35
|
+
WHERE auth_id = 'anon' OR ($auth.id != NONE AND auth_id = <string>$auth.id);
|
|
36
|
+
DEFINE TABLE OVERWRITE _00_list_ref_anon SCHEMALESS
|
|
37
|
+
PERMISSIONS FOR select WHERE true, FOR create, update WHERE false, FOR delete WHERE false;
|
|
38
|
+
DEFINE FIELD OVERWRITE in ON TABLE _00_list_ref_anon TYPE record;
|
|
39
|
+
DEFINE FIELD OVERWRITE out ON TABLE _00_list_ref_anon TYPE record;
|
|
40
|
+
DEFINE FIELD OVERWRITE clientId ON TABLE _00_list_ref_anon TYPE string;
|
|
41
|
+
DEFINE FIELD OVERWRITE auth_id ON TABLE _00_list_ref_anon TYPE string DEFAULT '';
|
|
42
|
+
DEFINE FIELD OVERWRITE version ON TABLE _00_list_ref_anon TYPE int;
|
|
43
|
+
CREATE item:1 SET name = 'one';
|
|
44
|
+
CREATE item:2 SET name = 'two';
|
|
45
|
+
CREATE item:3 SET name = 'three';
|
|
46
|
+
`;
|
|
47
|
+
|
|
48
|
+
// The SSP's own statements (`build_edge_batch`): the edge takes the view's
|
|
49
|
+
// clientId at write time.
|
|
50
|
+
const VIEW =
|
|
51
|
+
"LET $from0 = type::record('_00_query', $q); LET $cid0 = (SELECT VALUE clientId FROM $from0 LIMIT 1)[0];";
|
|
52
|
+
|
|
53
|
+
interface Heard {
|
|
54
|
+
action: string;
|
|
55
|
+
value: Record<string, unknown>;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const waitFor = async (pred: () => boolean, ms = 3_000): Promise<void> => {
|
|
59
|
+
const deadline = Date.now() + ms;
|
|
60
|
+
while (!pred() && Date.now() < deadline) await new Promise((r) => setTimeout(r, 25));
|
|
61
|
+
};
|
|
62
|
+
const settle = () => new Promise((r) => setTimeout(r, 300));
|
|
63
|
+
|
|
64
|
+
describe('anonymous list_ref LIVE (real SurrealDB)', () => {
|
|
65
|
+
let root: Surreal;
|
|
66
|
+
let database = '';
|
|
67
|
+
let reachable = false;
|
|
68
|
+
const open: Surreal[] = [];
|
|
69
|
+
|
|
70
|
+
// A plain TCP probe: the SDK never rejects a connect to a refused port.
|
|
71
|
+
beforeAll(async () => {
|
|
72
|
+
const url = new URL(endpoint);
|
|
73
|
+
reachable = await new Promise<boolean>((resolve) => {
|
|
74
|
+
const socket = new Socket();
|
|
75
|
+
const done = (ok: boolean) => {
|
|
76
|
+
socket.destroy();
|
|
77
|
+
resolve(ok);
|
|
78
|
+
};
|
|
79
|
+
socket.setTimeout(1_000, () => done(false));
|
|
80
|
+
socket.once('error', () => done(false));
|
|
81
|
+
socket.connect(Number(url.port || 80), url.hostname, () => done(true));
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
beforeEach(async () => {
|
|
86
|
+
if (!reachable) return;
|
|
87
|
+
database = `anon_live_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
88
|
+
root = new Surreal();
|
|
89
|
+
await root.connect(endpoint, { reconnect: false });
|
|
90
|
+
await root.signin({ username: 'root', password: 'root' });
|
|
91
|
+
await root.use({ namespace: NS, database });
|
|
92
|
+
await root.query(SCHEMA);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
afterEach(async () => {
|
|
96
|
+
if (!reachable) return;
|
|
97
|
+
for (const db of open.splice(0)) await db.close().catch(() => undefined);
|
|
98
|
+
await root.query(`REMOVE DATABASE ${database}`).catch(() => undefined);
|
|
99
|
+
await root.close();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
/** A signed-out socket and its `session::id()`. */
|
|
103
|
+
async function guest(): Promise<[Surreal, string]> {
|
|
104
|
+
const db = new Surreal();
|
|
105
|
+
open.push(db);
|
|
106
|
+
await db.connect(endpoint, { reconnect: false });
|
|
107
|
+
await db.use({ namespace: NS, database });
|
|
108
|
+
const [sid] = (await db.query('RETURN <string>session::id()')) as [string];
|
|
109
|
+
return [db, sid];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Open the client's anonymous LIVE and collect what it hears. */
|
|
113
|
+
async function listen(db: Surreal, earlier: string[] = []): Promise<Heard[]> {
|
|
114
|
+
const stmt = listRefLive(ANON_LIST_REF_TABLE, { inlineBodies: true, sessionIds: earlier });
|
|
115
|
+
const [uuid] = (await db.query(stmt.sql, stmt.vars)) as [Uuid];
|
|
116
|
+
const live = await db.liveOf(uuid);
|
|
117
|
+
const got: Heard[] = [];
|
|
118
|
+
live.subscribe((message) => {
|
|
119
|
+
if (message.action !== 'KILLED')
|
|
120
|
+
got.push({ action: message.action, value: message.value as Record<string, unknown> });
|
|
121
|
+
});
|
|
122
|
+
return got;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const register = (q: string, sid: string) =>
|
|
126
|
+
root.query("UPSERT type::record('_00_query', $q) SET clientId = $sid, auth_id = 'anon'", {
|
|
127
|
+
q,
|
|
128
|
+
sid,
|
|
129
|
+
});
|
|
130
|
+
const relate = (q: string, item: string) =>
|
|
131
|
+
root.query(
|
|
132
|
+
`${VIEW} RELATE $from0->_00_list_ref_anon->${item} SET version = 1, clientId = $cid0, auth_id = 'anon'`,
|
|
133
|
+
{ q }
|
|
134
|
+
);
|
|
135
|
+
const bump = (q: string, item: string, version: number, restamp = true) =>
|
|
136
|
+
root.query(
|
|
137
|
+
`${VIEW} UPDATE (SELECT VALUE id FROM ${item}<-_00_list_ref_anon WHERE in = $from0) SET version = ${version}${restamp ? ', clientId = $cid0 ?? clientId' : ''} RETURN NONE`,
|
|
138
|
+
{ q }
|
|
139
|
+
);
|
|
140
|
+
const remove = (q: string, item: string) =>
|
|
141
|
+
root.query(
|
|
142
|
+
`${VIEW} DELETE (SELECT VALUE id FROM ${item}<-_00_list_ref_anon WHERE in = $from0)`,
|
|
143
|
+
{ q }
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
const heard = (got: Heard[]) =>
|
|
147
|
+
got
|
|
148
|
+
.map((m) => `${m.action} ${hashOfEdge(m.value)} ${(m.value.out as { name?: string }).name}`)
|
|
149
|
+
.join(', ');
|
|
150
|
+
|
|
151
|
+
it("two anonymous sessions do not hear each other's edges", async (ctx) => {
|
|
152
|
+
if (!reachable) ctx.skip();
|
|
153
|
+
const [a, sidA] = await guest();
|
|
154
|
+
const [b, sidB] = await guest();
|
|
155
|
+
await register('qa', sidA);
|
|
156
|
+
await register('qb', sidB);
|
|
157
|
+
const gotA = await listen(a);
|
|
158
|
+
const gotB = await listen(b);
|
|
159
|
+
|
|
160
|
+
await relate('qa', 'item:1');
|
|
161
|
+
await relate('qb', 'item:2');
|
|
162
|
+
await bump('qa', 'item:1', 2);
|
|
163
|
+
await waitFor(() => gotA.length >= 2 && gotB.length >= 1);
|
|
164
|
+
await settle();
|
|
165
|
+
|
|
166
|
+
// The body still rides along (`FETCH out`).
|
|
167
|
+
expect(heard(gotA)).toBe('CREATE qa one, UPDATE qa one');
|
|
168
|
+
expect(heard(gotB)).toBe('CREATE qb two');
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('after a reconnect the new socket still hears the edges its old one registered, and a bystander hears none of it', async (ctx) => {
|
|
172
|
+
if (!reachable) ctx.skip();
|
|
173
|
+
const [a, sidA] = await guest();
|
|
174
|
+
const [b, sidB] = await guest();
|
|
175
|
+
await register('qa', sidA);
|
|
176
|
+
await register('qb', sidB);
|
|
177
|
+
await relate('qa', 'item:1');
|
|
178
|
+
await relate('qa', 'item:2');
|
|
179
|
+
const gotB = await listen(b);
|
|
180
|
+
|
|
181
|
+
// New socket, new session id. The view stays registered under the old one
|
|
182
|
+
// until the client re-registers it.
|
|
183
|
+
await a.close();
|
|
184
|
+
const [a2, sidA2] = await guest();
|
|
185
|
+
const gotA2 = await listen(a2, [sidA]);
|
|
186
|
+
|
|
187
|
+
// An SSP that does not restamp: the edge keeps naming the old socket.
|
|
188
|
+
await bump('qa', 'item:1', 2, false);
|
|
189
|
+
await waitFor(() => gotA2.length >= 1);
|
|
190
|
+
expect(heard(gotA2)).toBe('UPDATE qa one');
|
|
191
|
+
|
|
192
|
+
// Re-registered: the view, and from now on each edge it bumps, names the
|
|
193
|
+
// new socket. An edge nothing bumped still names the old one when it goes.
|
|
194
|
+
await register('qa', sidA2);
|
|
195
|
+
await bump('qa', 'item:1', 3);
|
|
196
|
+
await remove('qa', 'item:2');
|
|
197
|
+
await relate('qa', 'item:3');
|
|
198
|
+
await waitFor(() => gotA2.length >= 4);
|
|
199
|
+
await settle();
|
|
200
|
+
expect(heard(gotA2)).toBe('UPDATE qa one, UPDATE qa one, DELETE qa two, CREATE qa three');
|
|
201
|
+
expect(gotB).toEqual([]);
|
|
202
|
+
});
|
|
203
|
+
});
|
|
@@ -159,7 +159,7 @@ describe('readMembership', () => {
|
|
|
159
159
|
state: s,
|
|
160
160
|
handlers: {
|
|
161
161
|
'remote.query': (e: any) => {
|
|
162
|
-
expect(e.sql.startsWith('SELECT out, version FROM _00_list_ref WHERE
|
|
162
|
+
expect(e.sql.startsWith('SELECT out, version FROM $in->_00_list_ref WHERE parent IS NONE')).toBe(true);
|
|
163
163
|
return [ok([out1('1'), out1('2')]), ok({ rowCount: 2, state: 'ready' }), ok([])];
|
|
164
164
|
},
|
|
165
165
|
'local.upsert': () => undefined,
|
|
@@ -217,7 +217,7 @@ describe('readMembership', () => {
|
|
|
217
217
|
handlers: {
|
|
218
218
|
'remote.query': (e: any) => {
|
|
219
219
|
calls.push(e.sql.split('\n')[0]);
|
|
220
|
-
if (e.sql.includes('
|
|
220
|
+
if (e.sql.includes('$ins->')) {
|
|
221
221
|
return [
|
|
222
222
|
ok([{ in: qid('a'), out: new RecordId('thing', '1'), version: 1 }, { in: qid('a'), out: new RecordId('child', 'x'), version: 1, parent: qid('a') }]),
|
|
223
223
|
ok([{ id: qid('a'), rowCount: 1, state: 'ready' }]),
|
|
@@ -228,7 +228,7 @@ describe('readMembership', () => {
|
|
|
228
228
|
'local.upsert': () => undefined,
|
|
229
229
|
},
|
|
230
230
|
});
|
|
231
|
-
expect(calls).toEqual(['SELECT in, out, version, parent FROM
|
|
231
|
+
expect(calls).toEqual(['SELECT in, out, version, parent FROM $ins->_00_list_ref;', 'SELECT out, version FROM $in->_00_list_ref WHERE parent IS NONE;']);
|
|
232
232
|
expect(out.state.queries.get('a')!.subqueryRemoteArray).toEqual([['child:x', 1]]);
|
|
233
233
|
expect(out.state.queries.get('b')!.remoteArray).toEqual([['thing:2', 1], ['thing:3', 1]]);
|
|
234
234
|
expect(out.result.changed).toBe(true);
|
|
@@ -254,7 +254,7 @@ describe('readMembership', () => {
|
|
|
254
254
|
handlers: {
|
|
255
255
|
'remote.query': (e: any) => {
|
|
256
256
|
n++;
|
|
257
|
-
if (e.sql.includes('
|
|
257
|
+
if (e.sql.includes('$ins->')) return [ok([]), ok([])];
|
|
258
258
|
throw new Error('offline');
|
|
259
259
|
},
|
|
260
260
|
},
|
|
@@ -263,7 +263,7 @@ describe('readMembership', () => {
|
|
|
263
263
|
expect(n).toBe(3);
|
|
264
264
|
const rereadUnusable = await runPure(readMembership(env, ['a', 'b']), {
|
|
265
265
|
state: s,
|
|
266
|
-
handlers: { 'remote.query': (e: any) => (e.sql.includes('
|
|
266
|
+
handlers: { 'remote.query': (e: any) => (e.sql.includes('$ins->') ? [ok([]), ok([])] : [ok('x'), ok(null), ok(null)]) },
|
|
267
267
|
});
|
|
268
268
|
expect(rereadUnusable.result).toEqual({ changed: false, failed: true });
|
|
269
269
|
const single = buildState([buildEntry({ def: { hash: 'a', id: qid('a') }, lifecycle: { phase: 'live' }, remoteArray: [['thing:1', 1]] })]);
|
|
@@ -161,7 +161,13 @@ export function* registerRemote(env: SagaEnv, hash: QueryHash, retry = false): S
|
|
|
161
161
|
const table = listRefTable(env, state);
|
|
162
162
|
const results = (yield fx.remote.query(
|
|
163
163
|
sql.registerSelect(table),
|
|
164
|
-
sql.registerVars({
|
|
164
|
+
sql.registerVars({
|
|
165
|
+
id: entry.def.id,
|
|
166
|
+
surql: entry.def.surql,
|
|
167
|
+
params: { ...entry.def.params },
|
|
168
|
+
ttl: entry.def.ttl,
|
|
169
|
+
...(state.ownerSecret ? { owner: state.ownerSecret } : {}),
|
|
170
|
+
}),
|
|
165
171
|
env.remoteTimeoutMs
|
|
166
172
|
)) as StatementResult[];
|
|
167
173
|
const stillThere = (yield fx.state.read((s) => s.queries.has(hash))) as boolean;
|
package/src/query/sql.test.ts
CHANGED
|
@@ -17,6 +17,15 @@ describe('query sql builders (golden against the previous builders)', () => {
|
|
|
17
17
|
expect(sql.listRefBatchSelect('t')).toBe(buildListRefBatchSelect('t'));
|
|
18
18
|
expect(sql.queryRowCountBatchSelect()).toBe(buildQueryRowCountBatchSelect());
|
|
19
19
|
});
|
|
20
|
+
it('reads edges through the graph, byte-identical to spooky_core sql.dart', () => {
|
|
21
|
+
expect(sql.listRefSelect('_00_list_ref_user_u1')).toBe(
|
|
22
|
+
'SELECT out, version FROM $in->_00_list_ref_user_u1 WHERE parent IS NONE'
|
|
23
|
+
);
|
|
24
|
+
expect(sql.subqueryListRefSelect('_00_list_ref')).toBe(
|
|
25
|
+
'SELECT out, version FROM $in->_00_list_ref WHERE parent IS NOT NONE'
|
|
26
|
+
);
|
|
27
|
+
expect(sql.listRefBatchSelect('_00_list_ref')).toBe('SELECT in, out, version, parent FROM $ins->_00_list_ref');
|
|
28
|
+
});
|
|
20
29
|
it('composes the single, batch and register requests', () => {
|
|
21
30
|
expect(sql.singleSnapshotSelect('t').split(';\n')).toEqual([
|
|
22
31
|
sql.listRefSelect('t'),
|
|
@@ -30,6 +39,34 @@ describe('query sql builders (golden against the previous builders)', () => {
|
|
|
30
39
|
config: { id, surql: 's', params: { a: 1 }, ttl: '10m' },
|
|
31
40
|
in: id,
|
|
32
41
|
});
|
|
42
|
+
// The owner secret rides beside params, never inside them: params name
|
|
43
|
+
// the computation the SSP shares between views.
|
|
44
|
+
expect(sql.registerVars({ id, surql: 's', params: { a: 1 }, ttl: '10m', owner: 'k' })).toEqual({
|
|
45
|
+
config: { id, surql: 's', params: { a: 1 }, ttl: '10m', owner: 'k' },
|
|
46
|
+
in: id,
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
it('the LIVE on a per-user table is unfiltered; FETCH out only when bodies ride inline', () => {
|
|
50
|
+
expect(sql.listRefLive('_00_list_ref_user_x', { inlineBodies: true, sessionIds: ['s1'] })).toEqual({
|
|
51
|
+
sql: 'LIVE SELECT * FROM _00_list_ref_user_x FETCH out',
|
|
52
|
+
});
|
|
53
|
+
expect(sql.listRefLive('_00_list_ref', { inlineBodies: false, sessionIds: [] })).toEqual({
|
|
54
|
+
sql: 'LIVE SELECT * FROM _00_list_ref',
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
it("the anon LIVE hears only this client's sockets, current one by session::id()", () => {
|
|
58
|
+
const sids = ['earlier-1', 'earlier-2'];
|
|
59
|
+
const live = sql.listRefLive('_00_list_ref_anon', { inlineBodies: true, sessionIds: sids });
|
|
60
|
+
expect(live.sql).toBe(
|
|
61
|
+
'LIVE SELECT * FROM _00_list_ref_anon WHERE clientId = <string>session::id() OR clientId IN $sids FETCH out'
|
|
62
|
+
);
|
|
63
|
+
expect(live.vars).toEqual({ sids });
|
|
64
|
+
// A copy: the caller's list keeps growing after the LIVE is sent.
|
|
65
|
+
expect(live.vars!.sids).not.toBe(sids);
|
|
66
|
+
expect(sql.listRefLive('_00_list_ref_anon', { inlineBodies: false, sessionIds: [] })).toEqual({
|
|
67
|
+
sql: 'LIVE SELECT * FROM _00_list_ref_anon WHERE clientId = <string>session::id() OR clientId IN $sids',
|
|
68
|
+
vars: { sids: [] },
|
|
69
|
+
});
|
|
33
70
|
});
|
|
34
71
|
it('view rows and ids', () => {
|
|
35
72
|
expect(sql.viewRecordId('k')).toEqual(new RecordId('_00_view', 'k'));
|
package/src/query/sql.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type { RecordVersionArray } from '../types';
|
|
|
3
3
|
import type { Vars } from '../kernel/effects';
|
|
4
4
|
import { surql } from '../utils/surql';
|
|
5
5
|
import type { SealedQuery } from '../utils/surql';
|
|
6
|
+
import { ANON_LIST_REF_TABLE } from '../modules/ref-tables';
|
|
6
7
|
|
|
7
8
|
/** Every SurrealQL statement the query side sends, as pure builders. */
|
|
8
9
|
|
|
@@ -35,13 +36,16 @@ export const readViewRows = (): string => `SELECT * FROM ${VIEW_TABLE}`;
|
|
|
35
36
|
export const readLegacyViewRows = (): string => `SELECT * FROM ${LEGACY_VIEW_TABLE}`;
|
|
36
37
|
export const countViewRows = (): string => `SELECT count() FROM ${VIEW_TABLE} GROUP ALL`;
|
|
37
38
|
|
|
39
|
+
// Edges are read through the graph (`$in->table`): the `_00_list_ref*` tables
|
|
40
|
+
// have no index on `in`, so `WHERE in = $in` scanned the whole table, which on
|
|
41
|
+
// the shared `_00_list_ref_anon` is every signed-out visitor's edges.
|
|
38
42
|
export const listRefSelect = (table: string): string =>
|
|
39
|
-
`SELECT out, version FROM ${table} WHERE
|
|
43
|
+
`SELECT out, version FROM $in->${table} WHERE parent IS NONE`;
|
|
40
44
|
export const subqueryListRefSelect = (table: string): string =>
|
|
41
|
-
`SELECT out, version FROM ${table} WHERE
|
|
45
|
+
`SELECT out, version FROM $in->${table} WHERE parent IS NOT NONE`;
|
|
42
46
|
export const queryRowCountSelect = (): string => 'SELECT VALUE { rowCount: rowCount, state: state } FROM ONLY $in';
|
|
43
47
|
export const listRefBatchSelect = (table: string): string =>
|
|
44
|
-
`SELECT in, out, version, parent FROM ${table}
|
|
48
|
+
`SELECT in, out, version, parent FROM $ins->${table}`;
|
|
45
49
|
export const queryRowCountBatchSelect = (): string => 'SELECT VALUE { id: id, rowCount: rowCount, state: state } FROM $ins';
|
|
46
50
|
|
|
47
51
|
/** The single-query read: edges, meta, subquery children in one request. */
|
|
@@ -51,11 +55,52 @@ export const singleSnapshotSelect = (table: string): string =>
|
|
|
51
55
|
export const batchSnapshotSelect = (table: string): string =>
|
|
52
56
|
`${listRefBatchSelect(table)};\n${queryRowCountBatchSelect()}`;
|
|
53
57
|
|
|
58
|
+
/**
|
|
59
|
+
* The session's one LIVE subscription on its `_00_list_ref` table.
|
|
60
|
+
*
|
|
61
|
+
* `inlineBodies` adds `FETCH out`, which resolves the edge's target into the
|
|
62
|
+
* notification so the body arrives with the doorbell.
|
|
63
|
+
*
|
|
64
|
+
* On the anon table it is filtered to this client's own edges. Every
|
|
65
|
+
* signed-out session shares that table, so unfiltered each one received (and
|
|
66
|
+
* the server fetched a body for) every anonymous edge change of every other
|
|
67
|
+
* session, and the client then dropped all but its own. An edge carries the
|
|
68
|
+
* `session::id()` of whichever socket last registered its view, which is a
|
|
69
|
+
* socket of this client but not necessarily the current one: a view stays
|
|
70
|
+
* registered across a reconnect, and a reload keeps its salt and its views,
|
|
71
|
+
* so their older edges still name an earlier socket. `sessionIds` are those
|
|
72
|
+
* earlier sockets. The current one is matched by `session::id()` itself,
|
|
73
|
+
* which SurrealDB evaluates in the subscriber's own session, so the filter
|
|
74
|
+
* holds before the client has learned its new id.
|
|
75
|
+
*
|
|
76
|
+
* The cast is load-bearing: `session::id()` is a uuid, `clientId` a string,
|
|
77
|
+
* and without it nothing matches. Per-user tables need no filter.
|
|
78
|
+
*/
|
|
79
|
+
export function listRefLive(
|
|
80
|
+
table: string,
|
|
81
|
+
opts: { inlineBodies: boolean; sessionIds: ReadonlyArray<string> }
|
|
82
|
+
): { sql: string; vars?: Vars } {
|
|
83
|
+
const join = opts.inlineBodies ? ' FETCH out' : '';
|
|
84
|
+
if (table !== ANON_LIST_REF_TABLE) return { sql: `LIVE SELECT * FROM ${table}${join}` };
|
|
85
|
+
return {
|
|
86
|
+
sql: `LIVE SELECT * FROM ${table} WHERE clientId = <string>session::id() OR clientId IN $sids${join}`,
|
|
87
|
+
vars: { sids: [...opts.sessionIds] },
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
54
91
|
export interface RegisterPayload {
|
|
55
92
|
id: RecordId<string>;
|
|
56
93
|
surql: string;
|
|
57
94
|
params: Record<string, unknown>;
|
|
58
95
|
ttl: string;
|
|
96
|
+
/**
|
|
97
|
+
* The registering page's secret. `fn::query::register` forwards only its
|
|
98
|
+
* hash, and the SSP lets a registration onto an existing anonymous view
|
|
99
|
+
* only with the hash that view was registered with: every signed-out
|
|
100
|
+
* visitor is `anon`, and anyone can read an anonymous view's id. Kept out
|
|
101
|
+
* of `params`, which name the computation the SSP shares between views.
|
|
102
|
+
*/
|
|
103
|
+
owner?: string;
|
|
59
104
|
}
|
|
60
105
|
|
|
61
106
|
/** Register + read back edges/meta/children in ONE request. */
|
|
@@ -38,8 +38,12 @@ export type RemoteConnectionEvent = ConnectionState | 'error';
|
|
|
38
38
|
const REMOTE_MAX_CONCURRENT_QUERIES = 6;
|
|
39
39
|
|
|
40
40
|
/**
|
|
41
|
-
* Session ids a page remembers for its pagehide release
|
|
42
|
-
*
|
|
41
|
+
* Session ids a page remembers for its pagehide release and its anonymous LIVE
|
|
42
|
+
* filter. The release only needs the sessions of the last ttl; this many
|
|
43
|
+
* reconnects inside one ttl is a broken network. The LIVE filter would like
|
|
44
|
+
* every session that stamped an edge still held, but the SSP restamps an edge
|
|
45
|
+
* whenever its row changes, so an id that falls off costs at most the LIVE
|
|
46
|
+
* removal of a row untouched since, which the membership poll still catches.
|
|
43
47
|
*/
|
|
44
48
|
const MAX_SESSION_IDS = 16;
|
|
45
49
|
|
|
@@ -82,6 +86,10 @@ export class RemoteDatabaseService extends AbstractDatabaseService {
|
|
|
82
86
|
* whose own session id matches none of them, so it names these instead. Kept
|
|
83
87
|
* for the page, not the socket: a view registered before a reconnect still
|
|
84
88
|
* carries the old id until its next heartbeat.
|
|
89
|
+
*
|
|
90
|
+
* The anonymous LIVE filters on the same ids (see `listRefLive`): its edges
|
|
91
|
+
* name the socket that registered their view, which may be an earlier one.
|
|
92
|
+
* A reload hands its ids to the next page with the salt.
|
|
85
93
|
*/
|
|
86
94
|
private sessionIdsSeen: string[] = [];
|
|
87
95
|
|
|
@@ -123,6 +131,16 @@ export class RemoteDatabaseService extends AbstractDatabaseService {
|
|
|
123
131
|
return this.sessionIdsSeen;
|
|
124
132
|
}
|
|
125
133
|
|
|
134
|
+
/**
|
|
135
|
+
* Take on the session ids of the page this one reloaded from. It kept that
|
|
136
|
+
* page's salt, so the views it re-registers still carry edges stamped by
|
|
137
|
+
* those sockets. Older than anything this page has seen, so they go first.
|
|
138
|
+
*/
|
|
139
|
+
adoptSessionIds(ids: ReadonlyArray<string>): void {
|
|
140
|
+
const earlier = ids.filter((sid) => typeof sid === 'string' && sid !== '' && !this.sessionIdsSeen.includes(sid));
|
|
141
|
+
this.sessionIdsSeen = [...earlier, ...this.sessionIdsSeen].slice(-MAX_SESSION_IDS);
|
|
142
|
+
}
|
|
143
|
+
|
|
126
144
|
/**
|
|
127
145
|
* Ask the socket that just connected for its session id. Straight to the
|
|
128
146
|
* SDK: it needs no namespace or identity, so it need not wait behind an
|
|
@@ -114,6 +114,13 @@ export interface ClientState {
|
|
|
114
114
|
readonly userId: string | null;
|
|
115
115
|
/** The principal the current session salt was minted for. */
|
|
116
116
|
readonly saltUserId: string | null;
|
|
117
|
+
/**
|
|
118
|
+
* This page's secret, sent with every registration (`$config.owner`). An
|
|
119
|
+
* anonymous view answers only to the secret it was registered with, so it
|
|
120
|
+
* lives as long as the page's views: a reconnect keeps it, and a reload
|
|
121
|
+
* that keeps the salt keeps it too.
|
|
122
|
+
*/
|
|
123
|
+
readonly ownerSecret: string | null;
|
|
117
124
|
/** Latest bucket-switch target; an older switch that wakes up to a newer target skips. */
|
|
118
125
|
readonly pendingBucket: string | null;
|
|
119
126
|
readonly tabId: string;
|
|
@@ -167,6 +174,7 @@ export function emptyState(init: { tabId: string }): ClientState {
|
|
|
167
174
|
sessionId: null,
|
|
168
175
|
userId: null,
|
|
169
176
|
saltUserId: null,
|
|
177
|
+
ownerSecret: null,
|
|
170
178
|
pendingBucket: null,
|
|
171
179
|
tabId: init.tabId,
|
|
172
180
|
tabRole: 'solo',
|
package/src/state/reducers.ts
CHANGED
|
@@ -444,7 +444,10 @@ export const setFailedCount =
|
|
|
444
444
|
export const setIdentity =
|
|
445
445
|
(
|
|
446
446
|
patch: Partial<
|
|
447
|
-
Pick<
|
|
447
|
+
Pick<
|
|
448
|
+
ClientState,
|
|
449
|
+
'sessionId' | 'userId' | 'saltUserId' | 'ownerSecret' | 'pendingBucket' | 'bucketId' | 'tabRole' | 'localReady' | 'primed'
|
|
450
|
+
>
|
|
448
451
|
>
|
|
449
452
|
): Reducer =>
|
|
450
453
|
(s) => ({ ...s, ...patch });
|
|
@@ -30,11 +30,22 @@ describe('connectionChanged', () => {
|
|
|
30
30
|
expect(out.state.sync).toMatchObject({ needsResubscribe: false, lastReconnectRefetchAt: 1000 });
|
|
31
31
|
expect(out.dispatched).toEqual([{ type: 'EnsureRegistered', requireAuth: true }, { type: 'LiveStart' }, { type: 'Drain' }]);
|
|
32
32
|
const burst = await runPure(connectionChanged(env, 'connected'), { state: R.patchSync({ needsResubscribe: true })(out.state), now: 5000 });
|
|
33
|
-
expect(burst.dispatched).toEqual([]);
|
|
34
|
-
expect(burst.state.sync
|
|
33
|
+
expect(burst.dispatched).toEqual([{ type: 'LiveStart' }, { type: 'Drain' }]);
|
|
34
|
+
expect(burst.state.sync).toMatchObject({ needsResubscribe: false, lastReconnectRefetchAt: 1000 });
|
|
35
35
|
const later = await runPure(connectionChanged(env, 'connected'), { state: R.patchSync({ needsResubscribe: true })(out.state), now: 20_000 });
|
|
36
36
|
expect(later.dispatched).toHaveLength(3);
|
|
37
37
|
});
|
|
38
|
+
it('a reconnect inside the cooldown restarts the LIVE the drop killed, keeping the registrations', async () => {
|
|
39
|
+
const live = R.patchSync({ liveUuid: 'u', liveTable: 't', lastReconnectRefetchAt: 1000 })(
|
|
40
|
+
buildState([buildEntry({ def: { hash: 'a' }, lifecycle: { remote: 'registered' } })])
|
|
41
|
+
);
|
|
42
|
+
const dropped = await runPure(connectionChanged(env, 'disconnected'), { state: live });
|
|
43
|
+
expect(dropped.state.sync.liveUuid).toBeNull();
|
|
44
|
+
const back = await runPure(connectionChanged(env, 'connected'), { state: dropped.state, now: 3000 });
|
|
45
|
+
expect(back.dispatched).toContainEqual({ type: 'LiveStart' });
|
|
46
|
+
expect(back.dispatched).not.toContainEqual(expect.objectContaining({ type: 'EnsureRegistered' }));
|
|
47
|
+
expect(back.state.queries.get('a')!.lifecycle.remote).toBe('registered');
|
|
48
|
+
});
|
|
38
49
|
});
|
|
39
50
|
|
|
40
51
|
describe('syncOutcome', () => {
|
|
@@ -11,9 +11,9 @@ import { nextHealth, selfHealDelayMs } from './policy';
|
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
13
|
* Transport state changes. A drop invalidates the live query and arms a
|
|
14
|
-
* resubscribe
|
|
15
|
-
*
|
|
16
|
-
* `$auth.id` gate
|
|
14
|
+
* resubscribe. The next `connected` restarts LIVE and drains the outbox;
|
|
15
|
+
* outside the burst cooldown it also drops every remote registration so
|
|
16
|
+
* `ensureRegistered` rebuilds them behind the `$auth.id` gate.
|
|
17
17
|
*/
|
|
18
18
|
export function* connectionChanged(env: SagaEnv, connection: ConnectionState): Saga<void> {
|
|
19
19
|
const state = (yield fx.state.read((s) => s)) as ClientState;
|
|
@@ -27,18 +27,22 @@ export function* connectionChanged(env: SagaEnv, connection: ConnectionState): S
|
|
|
27
27
|
const now = (yield fx.now()) as number;
|
|
28
28
|
const last = state.sync.lastReconnectRefetchAt;
|
|
29
29
|
if (last !== null && now - last < RECONNECT_REFETCH_COOLDOWN_MS) {
|
|
30
|
+
// The cooldown only spares the server a re-registration storm. The views
|
|
31
|
+
// the last refetch registered are still on the server, but LIVE died with
|
|
32
|
+
// the socket and nothing else restarts it: skipping it here left the
|
|
33
|
+
// client on the membership poll until a reconnect outside the window.
|
|
30
34
|
yield fx.state.update(R.patchSync({ needsResubscribe: false }));
|
|
31
|
-
|
|
35
|
+
} else {
|
|
36
|
+
yield fx.state.update(
|
|
37
|
+
R.compose(
|
|
38
|
+
R.patchSync({ needsResubscribe: false, lastReconnectRefetchAt: now }),
|
|
39
|
+
...[...state.queries.values()]
|
|
40
|
+
.filter((e) => e.lifecycle.remote !== 'unregistered')
|
|
41
|
+
.map((e) => R.applyLifecycle(e.def.hash, { type: 'remote-dropped' }))
|
|
42
|
+
)
|
|
43
|
+
);
|
|
44
|
+
yield fx.dispatch({ type: 'EnsureRegistered', requireAuth: true });
|
|
32
45
|
}
|
|
33
|
-
yield fx.state.update(
|
|
34
|
-
R.compose(
|
|
35
|
-
R.patchSync({ needsResubscribe: false, lastReconnectRefetchAt: now }),
|
|
36
|
-
...[...state.queries.values()]
|
|
37
|
-
.filter((e) => e.lifecycle.remote !== 'unregistered')
|
|
38
|
-
.map((e) => R.applyLifecycle(e.def.hash, { type: 'remote-dropped' }))
|
|
39
|
-
)
|
|
40
|
-
);
|
|
41
|
-
yield fx.dispatch({ type: 'EnsureRegistered', requireAuth: true });
|
|
42
46
|
yield fx.dispatch({ type: 'LiveStart' });
|
|
43
47
|
yield fx.dispatch({ type: 'Drain' });
|
|
44
48
|
}
|
package/src/sync/policy.test.ts
CHANGED
|
@@ -384,14 +384,18 @@ describe('resolveListRefPollInterval', () => {
|
|
|
384
384
|
|
|
385
385
|
describe('buildListRefSelect', () => {
|
|
386
386
|
it('substitutes the table name', () => {
|
|
387
|
-
expect(buildListRefSelect('_00_list_ref')).toContain('
|
|
387
|
+
expect(buildListRefSelect('_00_list_ref')).toContain('->_00_list_ref ');
|
|
388
388
|
expect(buildListRefSelect('_00_list_ref_user_abc')).toContain(
|
|
389
|
-
'
|
|
389
|
+
'->_00_list_ref_user_abc '
|
|
390
390
|
);
|
|
391
391
|
});
|
|
392
392
|
|
|
393
|
-
it('
|
|
394
|
-
|
|
393
|
+
it('traverses from the bound query id instead of scanning the table', () => {
|
|
394
|
+
// `_00_list_ref*` has no index on `in`: `FROM table WHERE in = $in` is a
|
|
395
|
+
// full scan, which on the shared anon table is every visitor's edges.
|
|
396
|
+
const sql = buildListRefSelect('_00_list_ref');
|
|
397
|
+
expect(sql).toContain('FROM $in->_00_list_ref ');
|
|
398
|
+
expect(sql).not.toContain('in = $in');
|
|
395
399
|
});
|
|
396
400
|
|
|
397
401
|
it('excludes subquery rows via parent IS NONE', () => {
|
|
@@ -411,14 +415,14 @@ describe('buildListRefSelect', () => {
|
|
|
411
415
|
|
|
412
416
|
describe('buildSubqueryListRefSelect', () => {
|
|
413
417
|
it('substitutes the table name', () => {
|
|
414
|
-
expect(buildSubqueryListRefSelect('_00_list_ref')).toContain('
|
|
418
|
+
expect(buildSubqueryListRefSelect('_00_list_ref')).toContain('->_00_list_ref ');
|
|
415
419
|
expect(buildSubqueryListRefSelect('_00_list_ref_user_abc')).toContain(
|
|
416
|
-
'
|
|
420
|
+
'->_00_list_ref_user_abc '
|
|
417
421
|
);
|
|
418
422
|
});
|
|
419
423
|
|
|
420
|
-
it('
|
|
421
|
-
expect(buildSubqueryListRefSelect('_00_list_ref')).toContain('
|
|
424
|
+
it('traverses from the bound query id', () => {
|
|
425
|
+
expect(buildSubqueryListRefSelect('_00_list_ref')).toContain('FROM $in->_00_list_ref ');
|
|
422
426
|
});
|
|
423
427
|
|
|
424
428
|
it('selects ONLY subquery child rows via parent IS NOT NONE', () => {
|
|
@@ -611,7 +615,7 @@ describe('recordVersionArraysEqual', () => {
|
|
|
611
615
|
describe('list_ref poll batching', () => {
|
|
612
616
|
it('reads many queries in one statement and their rowCounts keyed by id', () => {
|
|
613
617
|
expect(buildListRefBatchSelect('_00_list_ref_user_abc')).toBe(
|
|
614
|
-
'SELECT in, out, version, parent FROM
|
|
618
|
+
'SELECT in, out, version, parent FROM $ins->_00_list_ref_user_abc'
|
|
615
619
|
);
|
|
616
620
|
expect(buildQueryRowCountBatchSelect()).toBe(
|
|
617
621
|
'SELECT VALUE { id: id, rowCount: rowCount, state: state } FROM $ins'
|
package/src/sync/policy.ts
CHANGED
|
@@ -186,9 +186,18 @@ export const DEFAULT_LIST_REF_POLL_INTERVAL_MS = 500;
|
|
|
186
186
|
* because the client's `RecordVersionArray` only tracks primary rows;
|
|
187
187
|
* including subquery rows would surface them as spurious "added"
|
|
188
188
|
* diffs every tick.
|
|
189
|
+
*
|
|
190
|
+
* Read through the graph (`$in->table`), not `FROM table WHERE in = $in`:
|
|
191
|
+
* the edge tables have no index on `in`, so the WHERE form is a full table
|
|
192
|
+
* scan. On a per-user table that is one user's edges; on the shared
|
|
193
|
+
* `_00_list_ref_anon` it is every signed-out visitor's, on every poll of
|
|
194
|
+
* every anonymous client. The traversal walks only this view's edge keys
|
|
195
|
+
* (`GraphEdgeScan` on SurrealDB v3) and the edge table's select permission
|
|
196
|
+
* still applies to each row it returns. Edges are written with `RELATE`, so
|
|
197
|
+
* the graph keys always exist.
|
|
189
198
|
*/
|
|
190
199
|
export function buildListRefSelect(table: string): string {
|
|
191
|
-
return `SELECT out, version FROM ${table} WHERE
|
|
200
|
+
return `SELECT out, version FROM $in->${table} WHERE parent IS NONE`;
|
|
192
201
|
}
|
|
193
202
|
|
|
194
203
|
/**
|
|
@@ -231,7 +240,7 @@ export function buildQueryRowCountSelect(): string {
|
|
|
231
240
|
* store SEPARATELY from the primary window array.
|
|
232
241
|
*/
|
|
233
242
|
export function buildSubqueryListRefSelect(table: string): string {
|
|
234
|
-
return `SELECT out, version FROM ${table} WHERE
|
|
243
|
+
return `SELECT out, version FROM $in->${table} WHERE parent IS NOT NONE`;
|
|
235
244
|
}
|
|
236
245
|
|
|
237
246
|
/**
|
|
@@ -239,9 +248,13 @@ export function buildSubqueryListRefSelect(table: string): string {
|
|
|
239
248
|
* together, split client-side on `parent`. Replaces two selects per query per
|
|
240
249
|
* poll tick, which on a page with 20+ live queries was ~50 serialized round
|
|
241
250
|
* trips per cycle and kept the remote busy full-time.
|
|
251
|
+
*
|
|
252
|
+
* Traverses from every id in `$ins` (see {@link buildListRefSelect}); the
|
|
253
|
+
* result is one flat list of edge rows, and `in` names the query each belongs
|
|
254
|
+
* to.
|
|
242
255
|
*/
|
|
243
256
|
export function buildListRefBatchSelect(table: string): string {
|
|
244
|
-
return `SELECT in, out, version, parent FROM ${table}
|
|
257
|
+
return `SELECT in, out, version, parent FROM $ins->${table}`;
|
|
245
258
|
}
|
|
246
259
|
|
|
247
260
|
/**
|
package/src/testing/adapters.ts
CHANGED
|
@@ -78,7 +78,11 @@ export function fakeAdapters(over: {
|
|
|
78
78
|
clear: (key) => void timers.pending.delete(key),
|
|
79
79
|
},
|
|
80
80
|
clock: { now: () => now },
|
|
81
|
-
ids: {
|
|
81
|
+
ids: {
|
|
82
|
+
mutation: () => `_00_pending_mutations:${String(++ids).padStart(13, '0')}_0001_tab`,
|
|
83
|
+
salt: () => `salt-${++ids}`,
|
|
84
|
+
owner: () => `owner-${++ids}`,
|
|
85
|
+
},
|
|
82
86
|
hash: sha256Hex,
|
|
83
87
|
services,
|
|
84
88
|
};
|