@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.
@@ -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
+ });
@@ -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. An event marks its query's
13
- * membership dirty and the batched re-read decides; followers get the same
14
- * dirt relayed by the leader.
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
- /** One or more edges of these queries changed on the server. */
49
- export function* liveChange(env: SagaEnv, hashes: QueryHash[], rows?: InlineRow[]): Saga<void> {
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
- yield* markMembershipDirty(known);
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
- * Debounce time in milliseconds for syncing collaborative (CRDT) field
262
- * edits to the server. Local state is kept on every change (so reload and
263
- * offline work), but a field's own new ops go out as one edit-log row per
264
- * pause of this length (at most 2 s apart while typing continues).
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, and today it is a doorbell: the notification is reduced to the
303
- * query hash and the row is then pulled with `SELECT * FROM $ids`. The edge
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. Membership is still
310
- * read back; only the body fetch is saved.
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
 
@@ -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
+ }