@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.
@@ -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 in = $in')).toBe(true);
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('IN $ins')) {
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 _00_list_ref WHERE in IN $ins;', 'SELECT out, version FROM _00_list_ref WHERE in = $in AND parent IS NONE;']);
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('IN $ins')) return [ok([]), ok([])];
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('IN $ins') ? [ok([]), ok([])] : [ok('x'), ok(null), ok(null)]) },
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({ id: entry.def.id, surql: entry.def.surql, params: { ...entry.def.params }, ttl: entry.def.ttl }),
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;
@@ -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 in = $in AND parent IS NONE`;
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 in = $in AND parent IS NOT NONE`;
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} WHERE in IN $ins`;
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. Only the sessions of
42
- * the last ttl matter; this many reconnects inside one ttl is a broken network.
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',
@@ -444,7 +444,10 @@ export const setFailedCount =
444
444
  export const setIdentity =
445
445
  (
446
446
  patch: Partial<
447
- Pick<ClientState, 'sessionId' | 'userId' | 'saltUserId' | 'pendingBucket' | 'bucketId' | 'tabRole' | 'localReady' | 'primed'>
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.needsResubscribe).toBe(false);
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; the next `connected` (outside the burst cooldown) drops every
15
- * remote registration so `ensureRegistered` rebuilds them behind the
16
- * `$auth.id` gate, and restarts LIVE.
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
- return;
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
  }
@@ -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('FROM _00_list_ref ');
387
+ expect(buildListRefSelect('_00_list_ref')).toContain('->_00_list_ref ');
388
388
  expect(buildListRefSelect('_00_list_ref_user_abc')).toContain(
389
- 'FROM _00_list_ref_user_abc '
389
+ '->_00_list_ref_user_abc '
390
390
  );
391
391
  });
392
392
 
393
- it('filters by the bound query id', () => {
394
- expect(buildListRefSelect('_00_list_ref')).toContain('WHERE in = $in');
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('FROM _00_list_ref ');
418
+ expect(buildSubqueryListRefSelect('_00_list_ref')).toContain('->_00_list_ref ');
415
419
  expect(buildSubqueryListRefSelect('_00_list_ref_user_abc')).toContain(
416
- 'FROM _00_list_ref_user_abc '
420
+ '->_00_list_ref_user_abc '
417
421
  );
418
422
  });
419
423
 
420
- it('filters by the bound query id', () => {
421
- expect(buildSubqueryListRefSelect('_00_list_ref')).toContain('WHERE in = $in');
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 _00_list_ref_user_abc WHERE in IN $ins'
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'
@@ -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 in = $in AND parent IS NONE`;
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 in = $in AND parent IS NOT NONE`;
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} WHERE in IN $ins`;
257
+ return `SELECT in, out, version, parent FROM $ins->${table}`;
245
258
  }
246
259
 
247
260
  /**
@@ -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: { mutation: () => `_00_pending_mutations:${String(++ids).padStart(13, '0')}_0001_tab`, salt: () => `salt-${++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
  };