@spooky-sync/core 0.0.1-canary.202 → 0.0.1-canary.204

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.
@@ -170,6 +170,33 @@ function reviveRow(json) {
170
170
  return v;
171
171
  });
172
172
  }
173
+ /**
174
+ * SELECT-clause expression yielding the row's `data` narrowed to `fields` (plus
175
+ * `id`), so SQLite never returns — and neither side ever parses — the fields the
176
+ * caller did not ask for. On a game list that is every row's `pgn`, which was
177
+ * being read, parsed and thrown away 50 rows at a time for eight rendered fields.
178
+ *
179
+ * Byte-identical to running {@link project} over the fully-parsed row, which is
180
+ * what both paths did before, and verified so against real SQLite:
181
+ * `json_each` walks the keys the row ACTUALLY has, so an ABSENT key stays absent
182
+ * rather than becoming an explicit null — the difference `json_object`/
183
+ * `json_extract` would have introduced. A stored null, nested objects and
184
+ * arrays, and `{__u8}` blob tags all round-trip unchanged, because
185
+ * `json_group_object` understands `json_each`'s `value` column as JSON rather
186
+ * than as text. `COALESCE` covers a row sharing none of the requested keys,
187
+ * where the subquery yields NULL and `reviveRow` would throw.
188
+ *
189
+ * Deliberately unaliased, so the emitted statement keeps the exact shape the
190
+ * callers already produce (`FROM "t" WHERE id IN (…)`).
191
+ *
192
+ * Binds one parameter per key, pushed onto `bind` — these land in the SELECT
193
+ * clause, so they must be bound BEFORE any WHERE parameters.
194
+ */
195
+ function projectedDataSql(fields, bind) {
196
+ const keys = ["id", ...fields];
197
+ for (const k of keys) bind.push(k);
198
+ return `COALESCE((SELECT json_group_object(je.key, je.value) FROM json_each(data) je WHERE je.key IN (${keys.map(() => "?").join(", ")})), '{}') AS data`;
199
+ }
173
200
  function project(row, fields) {
174
201
  const out = {};
175
202
  for (const f of ["id", ...fields]) if (f in row) out[f] = row[f];
@@ -273,4 +300,4 @@ async function openDb(sqlite3, dbName, useOpfs, opts = {}) {
273
300
  }
274
301
 
275
302
  //#endregion
276
- export { renderWhereSql as a, resolveRelations as c, renderOrderSql as i, stableKey as l, openDb as n, reviveRow as o, project as r, serializeRow as s, PROMOTION_OPEN_OPTIONS as t };
303
+ export { renderOrderSql as a, serializeRow as c, projectedDataSql as i, resolveRelations as l, openDb as n, renderWhereSql as o, project as r, reviveRow as s, PROMOTION_OPEN_OPTIONS as t, stableKey as u };
@@ -1,4 +1,4 @@
1
- import { a as renderWhereSql, c as resolveRelations, i as renderOrderSql, l as stableKey, n as openDb, o as reviveRow, r as project } from "./sqlite-open.js";
1
+ import { a as renderOrderSql, i as projectedDataSql, l as resolveRelations, n as openDb, o as renderWhereSql, r as project, s as reviveRow, u as stableKey } from "./sqlite-open.js";
2
2
  import sqlite3InitModule from "@sqlite.org/sqlite-wasm";
3
3
 
4
4
  //#region src/services/database/sqlite-select.ts
@@ -16,14 +16,18 @@ function selectByIds(db, table, ids, opts) {
16
16
  if (ids.length === 0) return [];
17
17
  ensureTable(db, table);
18
18
  const keys = ids.map(stableKey);
19
- let sql = `SELECT data FROM "${table}" WHERE id IN (${keys.map(() => "?").join(", ")})`;
19
+ const placeholders = keys.map(() => "?").join(", ");
20
+ const bind = [];
21
+ const dataCol = opts?.select ? projectedDataSql(opts.select, bind) : "data";
22
+ bind.push(...keys);
23
+ let sql = `SELECT ${dataCol} FROM "${table}" WHERE id IN (${placeholders})`;
20
24
  if (opts?.orderBy && opts.orderBy.length > 0) sql += renderOrderSql(opts.orderBy);
21
- let rows = execRows(db, sql, keys);
25
+ let rows = execRows(db, sql, bind);
22
26
  if (!opts?.orderBy || opts.orderBy.length === 0) {
23
27
  const pos = new Map(keys.map((k, i) => [k, i]));
24
28
  rows = rows.sort((a, b) => (pos.get(stableKey(a.id)) ?? 0) - (pos.get(stableKey(b.id)) ?? 0));
25
29
  }
26
- return opts?.select ? rows.map((r) => project(r, opts.select)) : rows;
30
+ return rows;
27
31
  }
28
32
  /** Mirrors the engine's `fetchRelation` SQL exactly. */
29
33
  function fetchRelation(db, req) {
@@ -57,17 +61,16 @@ async function executeSelect(plan, params, db) {
57
61
  }
58
62
  ensureTable(db, plan.table);
59
63
  const bind = [];
60
- let sql = `SELECT data FROM "${plan.table}"`;
64
+ let sql = `SELECT ${plan.select ? projectedDataSql(plan.select, bind) : "data"} FROM "${plan.table}"`;
61
65
  if (plan.where && plan.where.length > 0) sql += ` WHERE ${renderWhereSql(plan.where, bind, params)}`;
62
66
  if (plan.orderBy && plan.orderBy.length > 0) sql += renderOrderSql(plan.orderBy);
63
67
  else sql += ` ORDER BY id`;
64
68
  if (plan.limit !== void 0) sql += ` LIMIT ${Number(plan.limit)}`;
65
69
  if (plan.offset !== void 0) sql += ` OFFSET ${Number(plan.offset)}`;
66
70
  const rows = execRows(db, sql, bind);
67
- const projected = plan.select ? rows.map((r) => project(r, plan.select)) : rows;
68
- await resolveRelations(projected, plan.relations, fetcher);
71
+ await resolveRelations(rows, plan.relations, fetcher);
69
72
  return {
70
- rows: projected,
73
+ rows,
71
74
  relationFetches: counter.n
72
75
  };
73
76
  }
package/dist/types.d.ts CHANGED
@@ -654,6 +654,12 @@ interface Sp00kyConfig<S extends SchemaStructure> {
654
654
  * `0` disables. Defaults to `30_000`.
655
655
  */
656
656
  pushTimeoutMs?: number;
657
+ /**
658
+ * Max time a single down event (`register`/`sync`/`cleanup`) may take before
659
+ * it is retried. Mirror of {@link pushTimeoutMs} for the read side.
660
+ * Defaults to 30000; `0` disables the timeout.
661
+ */
662
+ downTimeoutMs?: number;
657
663
  }
658
664
  /** Tunables for sync-health reporting. See {@link Sp00kyConfig.syncHealth}. */
659
665
  interface SyncHealthConfig {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spooky-sync/core",
3
- "version": "0.0.1-canary.202",
3
+ "version": "0.0.1-canary.204",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "main": "./dist/index.js",
@@ -60,8 +60,8 @@
60
60
  }
61
61
  },
62
62
  "dependencies": {
63
- "@spooky-sync/query-builder": "0.0.1-canary.202",
64
- "@spooky-sync/ssp-wasm": "0.0.1-canary.202",
63
+ "@spooky-sync/query-builder": "0.0.1-canary.204",
64
+ "@spooky-sync/ssp-wasm": "0.0.1-canary.204",
65
65
  "@sqlite.org/sqlite-wasm": "3.53.0-build1",
66
66
  "@surrealdb/wasm": "^3.0.3",
67
67
  "blurhash": "^2.0.5",
@@ -0,0 +1,101 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { AuthService } from './index';
3
+
4
+ // A SurrealDB record-access JWT carries the access method as `AC` and the
5
+ // `$auth.id` record id as `ID`. Only the payload matters here - nothing in the
6
+ // client verifies the signature, the server does.
7
+ function jwt(claims: Record<string, unknown>): string {
8
+ const b64 = Buffer.from(JSON.stringify(claims)).toString('base64url');
9
+ return `header.${b64}.signature`;
10
+ }
11
+
12
+ const silentLogger = () =>
13
+ ({ debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), child: () => silentLogger() }) as any;
14
+
15
+ function makeAuth(opts: { token?: string | null; query?: any; authenticate?: any } = {}) {
16
+ const store = new Map<string, unknown>();
17
+ if (opts.token) store.set('sp00ky_auth_token', opts.token);
18
+ const persistence = {
19
+ get: vi.fn(async (k: string) => store.get(k) ?? null),
20
+ set: vi.fn(async (k: string, v: unknown) => void store.set(k, v)),
21
+ remove: vi.fn(async (k: string) => void store.delete(k)),
22
+ } as any;
23
+ const remote = {
24
+ query: opts.query ?? vi.fn(async () => [[]]),
25
+ getClient: () => ({ authenticate: opts.authenticate ?? vi.fn(async () => undefined) }),
26
+ } as any;
27
+ return { auth: new AuthService({} as any, remote, persistence, silentLogger()), remote, persistence, store };
28
+ }
29
+
30
+ describe('restoreSessionFromToken', () => {
31
+ it('restores the session from the cached token with NO network', async () => {
32
+ const { auth, remote } = makeAuth({ token: jwt({ AC: 'account', ID: 'user:abc' }) });
33
+
34
+ const userId = await auth.restoreSessionFromToken();
35
+
36
+ expect(userId).toBe('user:abc');
37
+ expect(auth.isAuthenticated).toBe(true);
38
+ expect(auth.currentUser?.id).toBe('user:abc');
39
+ expect(auth.access).toBe('account');
40
+ // The whole point: this is what makes a warm/offline boot paint.
41
+ expect(remote.query).not.toHaveBeenCalled();
42
+ });
43
+
44
+ it('notifies subscribers so query routing can be set before any registration', async () => {
45
+ const { auth } = makeAuth({ token: jwt({ AC: 'account', ID: 'user:abc' }) });
46
+ const seen: (string | null)[] = [];
47
+ auth.subscribe((uid) => seen.push(uid));
48
+
49
+ await auth.restoreSessionFromToken();
50
+
51
+ expect(seen).toContain('user:abc');
52
+ });
53
+
54
+ it('returns null when there is no token, and when the token carries no id', async () => {
55
+ expect(await makeAuth().auth.restoreSessionFromToken()).toBeNull();
56
+ const noId = makeAuth({ token: jwt({ AC: 'account' }) });
57
+ expect(await noId.auth.restoreSessionFromToken()).toBeNull();
58
+ expect(noId.auth.isAuthenticated).toBe(false);
59
+ });
60
+
61
+ it('survives a malformed token rather than throwing', async () => {
62
+ const { auth } = makeAuth({ token: 'not-a-jwt' });
63
+ expect(await auth.restoreSessionFromToken()).toBeNull();
64
+ });
65
+ });
66
+
67
+ describe('check() error handling', () => {
68
+ const token = jwt({ AC: 'account', ID: 'user:abc' });
69
+
70
+ it('KEEPS the cached session when the server is unreachable', async () => {
71
+ const { auth, persistence, store } = makeAuth({
72
+ token,
73
+ authenticate: vi.fn(async () => {
74
+ throw new Error('There was a problem with the underlying connection');
75
+ }),
76
+ });
77
+ await auth.restoreSessionFromToken();
78
+
79
+ await auth.check();
80
+
81
+ // A blip must not log the user out - that is what makes offline possible.
82
+ expect(auth.isAuthenticated).toBe(true);
83
+ expect(store.get('sp00ky_auth_token')).toBe(token);
84
+ expect(persistence.remove).not.toHaveBeenCalled();
85
+ });
86
+
87
+ it('signs out for real when the server REJECTS the token', async () => {
88
+ const { auth, store } = makeAuth({
89
+ token,
90
+ authenticate: vi.fn(async () => {
91
+ throw new Error('There was a problem with the database: Invalid token');
92
+ }),
93
+ });
94
+ await auth.restoreSessionFromToken();
95
+
96
+ await auth.check();
97
+
98
+ expect(auth.isAuthenticated).toBe(false);
99
+ expect(store.get('sp00ky_auth_token')).toBeUndefined();
100
+ });
101
+ });
@@ -9,6 +9,7 @@ import type { Logger } from '../../services/logger/index';
9
9
  export * from './events/index';
10
10
  import { AuthEventTypes, createAuthEventSystem } from './events/index';
11
11
  import type { PersistenceClient } from '../../types';
12
+ import { classifySyncError } from '../../utils/error-classification';
12
13
 
13
14
  // Helper to pretty print types
14
15
  type Prettify<T> = {
@@ -36,28 +37,41 @@ type ExtractAccessParams<
36
37
  : never;
37
38
 
38
39
  /**
39
- * Read the `AC` (access-method name) claim from a SurrealDB record-access
40
- * JWT without verifying it — we only need the claim, the server enforces the
41
- * token. Returns null on any malformed input. The in-browser SSP needs this
42
- * to resolve `$access` in table permission predicates (mirrors the session's
43
- * `$access` that the server's `fn::query::register` reads).
40
+ * Read the claims of a SurrealDB record-access JWT WITHOUT verifying it. The
41
+ * server still enforces the token on every request; this is only so the client
42
+ * can act on what it already holds before a round trip completes.
43
+ *
44
+ * `AC` is the access-method name — the in-browser SSP needs it to resolve
45
+ * `$access` in table permission predicates (mirrors the session's `$access`
46
+ * that the server's `fn::query::register` reads). `ID` is the `$auth.id` record
47
+ * id, which is what lets a warm boot restore a session locally.
48
+ *
49
+ * Returns nulls on any malformed input.
44
50
  */
45
- function decodeAccessFromToken(token: string): string | null {
51
+ function decodeTokenClaims(token: string): { access: string | null; userId: string | null } {
46
52
  try {
47
53
  const payload = token.split('.')[1];
48
- if (!payload) return null;
54
+ if (!payload) return { access: null, userId: null };
49
55
  let b64 = payload.replace(/-/g, '+').replace(/_/g, '/');
50
56
  b64 += '='.repeat((4 - (b64.length % 4)) % 4);
51
57
  const json =
52
58
  typeof atob === 'function' ? atob(b64) : Buffer.from(b64, 'base64').toString('binary');
53
59
  const claims = JSON.parse(json) as Record<string, unknown>;
54
60
  const ac = claims.AC ?? claims.ac;
55
- return typeof ac === 'string' ? ac : null;
61
+ const id = claims.ID ?? claims.id;
62
+ return {
63
+ access: typeof ac === 'string' ? ac : null,
64
+ userId: typeof id === 'string' ? id : null,
65
+ };
56
66
  } catch {
57
- return null;
67
+ return { access: null, userId: null };
58
68
  }
59
69
  }
60
70
 
71
+ function decodeAccessFromToken(token: string): string | null {
72
+ return decodeTokenClaims(token).access;
73
+ }
74
+
61
75
  export class AuthService<S extends SchemaStructure> {
62
76
  // State
63
77
  public token: string | null = null;
@@ -115,6 +129,44 @@ export class AuthService<S extends SchemaStructure> {
115
129
  this.events.emit(AuthEventTypes.AuthStateChanged, userId);
116
130
  }
117
131
 
132
+ /**
133
+ * Restore a session from the locally cached JWT, with NO network.
134
+ *
135
+ * This is what makes a warm boot paint instantly and what makes an offline
136
+ * boot possible at all: the token is in local storage, and it already carries
137
+ * both the access method and the `$auth.id` record id. Everything the client
138
+ * needs to route queries (`setCurrentUserId`) and to satisfy `$auth`-gated
139
+ * permission predicates in the in-browser SSP (`setSessionAuth`) is therefore
140
+ * available before a socket exists.
141
+ *
142
+ * The session is OPTIMISTIC: the token is unverified here. `check()` runs
143
+ * afterwards in the background and downgrades to a real sign-out if the
144
+ * server rejects it. Nothing is trusted that the server has not also seen -
145
+ * the local store only ever holds rows the server previously sent.
146
+ *
147
+ * Returns the restored user id, or null when there is no usable token.
148
+ */
149
+ async restoreSessionFromToken(): Promise<string | null> {
150
+ const token = await this.persistenceClient.get<string>('sp00ky_auth_token');
151
+ if (!token) return null;
152
+ const { access, userId } = decodeTokenClaims(token);
153
+ if (!userId) return null;
154
+
155
+ this.token = token;
156
+ // Only the id: the full row is not in the token. It lands from the local
157
+ // cache when the app's own `user` query paints, and is replaced wholesale
158
+ // by `check()` once the server answers.
159
+ this.currentUser = { id: userId };
160
+ this.isAuthenticated = true;
161
+ this.access = access ?? this.defaultAccessName();
162
+ this.notifyListeners();
163
+ this.logger.debug(
164
+ { userId, Category: 'sp00ky-client::AuthService::restoreSessionFromToken' },
165
+ 'Session restored optimistically from cached token'
166
+ );
167
+ return userId;
168
+ }
169
+
118
170
  /**
119
171
  * Check for existing session and validate
120
172
  */
@@ -180,11 +232,23 @@ export class AuthService<S extends SchemaStructure> {
180
232
  }
181
233
  }
182
234
  } catch (error) {
183
- this.logger.error(
184
- { error, stack: (error as Error).stack, Category: 'sp00ky-client::AuthService::check' },
185
- 'Auth check failed'
186
- );
187
- await this.signOut();
235
+ // A REACHABILITY failure is not a rejected token. This catch used to call
236
+ // signOut() unconditionally, which deletes `sp00ky_auth_token` - so a
237
+ // blip on boot silently logged the user out, and an offline boot could
238
+ // never stay signed in. Only an application error (the server answered,
239
+ // and the answer was "no") ends the session.
240
+ if (classifySyncError(error) === 'network') {
241
+ this.logger.warn(
242
+ { error, Category: 'sp00ky-client::AuthService::check' },
243
+ 'Auth check unreachable; keeping the cached session and retrying later'
244
+ );
245
+ } else {
246
+ this.logger.error(
247
+ { error, stack: (error as Error).stack, Category: 'sp00ky-client::AuthService::check' },
248
+ 'Auth check failed'
249
+ );
250
+ await this.signOut();
251
+ }
188
252
  } finally {
189
253
  this.isLoading = false;
190
254
  }
@@ -151,17 +151,18 @@ export class CacheModule implements StreamUpdateReceiver {
151
151
  )
152
152
  );
153
153
 
154
- const params = populatedRecords.reduce(
155
- (acc, record, i) => {
156
- const { id, ...content } = record.record;
157
- return {
158
- ...acc,
159
- [`id${i}`]: id,
160
- [`content${i}`]: content,
161
- };
162
- },
163
- {} as Record<string, any>
164
- );
154
+ // Filled in place, NOT with a spread-per-iteration reduce: spreading the
155
+ // accumulator copies every key written so far on each record, which is
156
+ // O(n^2) in the batch size. On a cold start that batches thousands of
157
+ // rows (a game library, a player-name registry) it was the single
158
+ // biggest main-thread cost of the whole boot - ~36% of samples, seconds
159
+ // of blocking - for a loop that does no real work.
160
+ const params: Record<string, any> = {};
161
+ for (let i = 0; i < populatedRecords.length; i++) {
162
+ const { id, ...content } = populatedRecords[i].record;
163
+ params[`id${i}`] = id;
164
+ params[`content${i}`] = content;
165
+ }
165
166
 
166
167
  await this.local.execute(query, params, { epoch });
167
168
  }
@@ -0,0 +1,125 @@
1
+ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
2
+ import { RecordId } from 'surrealdb';
3
+ import { DataModule } from './index';
4
+
5
+ /**
6
+ * `getPendingRecordIds` used to run a full `SELECT ... FROM
7
+ * _00_pending_mutations` on EVERY materialization of EVERY query — a round trip
8
+ * down the local engine's single-flight op queue, paid tens of times per ingest
9
+ * against an outbox that is usually empty. It is now cached, invalidated when
10
+ * the outbox actually changes, with a short TTL as a backstop.
11
+ */
12
+
13
+ function makeLogger(): any {
14
+ const noop = () => {};
15
+ const logger: any = { debug: noop, info: noop, warn: noop, error: noop, trace: noop };
16
+ logger.child = () => logger;
17
+ return logger;
18
+ }
19
+
20
+ /** DataModule over a local store that counts outbox reads and can be told what
21
+ * to return. */
22
+ function makeModule(rows: { recordId: RecordId<string>; mutationType: string }[] = []) {
23
+ const state = { rows, reads: 0 };
24
+ const local = {
25
+ query: vi.fn(async () => {
26
+ state.reads++;
27
+ return [state.rows];
28
+ }),
29
+ };
30
+ const dm = new DataModule(
31
+ {} as any,
32
+ local as any,
33
+ { tables: [] } as any,
34
+ makeLogger(),
35
+ 100
36
+ );
37
+ return { dm, state };
38
+ }
39
+
40
+ const pending = (id: string, mutationType = 'update') => ({
41
+ recordId: new RecordId('game', id),
42
+ mutationType,
43
+ });
44
+
45
+ describe('DataModule pending-id cache', () => {
46
+ beforeEach(() => vi.useFakeTimers());
47
+ afterEach(() => vi.useRealTimers());
48
+
49
+ it('reads the outbox once for repeated calls', async () => {
50
+ const { dm, state } = makeModule([pending('a')]);
51
+ await dm.getPendingRecordIds();
52
+ await dm.getPendingRecordIds();
53
+ await dm.getPendingRecordIds();
54
+ expect(state.reads).toBe(1);
55
+ });
56
+
57
+ it('collapses a burst of CONCURRENT calls into one read', async () => {
58
+ // The real shape: one ingest fans out to many queries materializing at once.
59
+ const { dm, state } = makeModule([pending('a')]);
60
+ await Promise.all([
61
+ dm.getPendingRecordIds(),
62
+ dm.getPendingRecordIds(),
63
+ dm.getPendingRecordIds(),
64
+ dm.getPendingRecordIds(),
65
+ ]);
66
+ expect(state.reads).toBe(1);
67
+ });
68
+
69
+ it('hands out COPIES, so a caller mutating the sets cannot poison the cache', async () => {
70
+ // buildRenderIds merges the settled-write ids into what it gets back.
71
+ const { dm } = makeModule([pending('a')]);
72
+ const first = await dm.getPendingRecordIds();
73
+ first.writes.add('game:injected');
74
+ const second = await dm.getPendingRecordIds();
75
+ expect(second.writes.has('game:injected')).toBe(false);
76
+ expect([...second.writes]).toEqual(['game:a']);
77
+ });
78
+
79
+ it('re-reads after a mutation settles (its outbox row is gone)', async () => {
80
+ const { dm, state } = makeModule([pending('a')]);
81
+ await dm.getPendingRecordIds();
82
+ expect(state.reads).toBe(1);
83
+
84
+ state.rows = [];
85
+ dm.noteWriteSettled('game:a', 'update');
86
+ const after = await dm.getPendingRecordIds();
87
+
88
+ expect(state.reads).toBe(2);
89
+ expect(after.writes.size).toBe(0);
90
+ });
91
+
92
+ it('re-reads once the TTL backstop lapses', async () => {
93
+ // Covers any path that removes an outbox row without telling us: staleness
94
+ // is bounded to a tick rather than lasting the session.
95
+ const { dm, state } = makeModule([pending('a')]);
96
+ await dm.getPendingRecordIds();
97
+ expect(state.reads).toBe(1);
98
+
99
+ vi.setSystemTime(Date.now() + 1_000);
100
+ await dm.getPendingRecordIds();
101
+ expect(state.reads).toBe(2);
102
+ });
103
+
104
+ it('does NOT cache a failed read', async () => {
105
+ // Empty sets are this call's fallback, not a claim that the outbox is empty.
106
+ const { dm, state } = makeModule([pending('a')]);
107
+ (dm as any).local.query = vi.fn(async () => {
108
+ state.reads++;
109
+ throw new Error('engine down');
110
+ });
111
+ const failed = await dm.getPendingRecordIds();
112
+ expect(failed.writes.size).toBe(0);
113
+ expect(state.reads).toBe(1);
114
+
115
+ await dm.getPendingRecordIds();
116
+ expect(state.reads).toBe(2);
117
+ });
118
+
119
+ it('splits writes from deletes', async () => {
120
+ const { dm } = makeModule([pending('a'), pending('b', 'delete')]);
121
+ const { writes, deletes } = await dm.getPendingRecordIds();
122
+ expect([...writes]).toEqual(['game:a']);
123
+ expect([...deletes]).toEqual(['game:b']);
124
+ });
125
+ });
@@ -587,6 +587,32 @@ export class DataModule<S extends SchemaStructure> {
587
587
  private readonly settledWrites = new Map<string, number>();
588
588
  private readonly settledDeletes = new Map<string, number>();
589
589
 
590
+ // ---- Outbox id cache -----------------------------------------------------
591
+ //
592
+ // `getPendingRecordIds` runs a full `SELECT ... FROM _00_pending_mutations`,
593
+ // and `buildRenderIds` calls it on EVERY materialization of EVERY query. That
594
+ // is a round trip down the local engine's single-flight op queue, so with tens
595
+ // of live queries one ingest fanned out into tens of them — in a 4-second
596
+ // capture, 11 of 25 local queries were that one statement, against an outbox
597
+ // holding zero rows.
598
+ //
599
+ // Cached, and invalidated at the two points that change the outbox: enqueueing
600
+ // a mutation (below) and `noteWriteSettled` (the row is deleted before it is
601
+ // called). The TTL is a BACKSTOP, not the mechanism: should some other path
602
+ // ever remove a row without telling us, this bounds the staleness to one tick
603
+ // instead of the session. Well under the grace windows the render set already
604
+ // tolerates deliberately (SETTLED_WRITE_GRACE_MS is 10s).
605
+ private pendingIds: { writes: Set<string>; deletes: Set<string> } | null = null;
606
+ private pendingIdsAt = 0;
607
+ private pendingIdsInflight: Promise<{ writes: Set<string>; deletes: Set<string> }> | null = null;
608
+ private static readonly PENDING_IDS_TTL_MS = 250;
609
+
610
+ /** Drop the cached outbox ids. Cheap; call it on anything that could change
611
+ * `_00_pending_mutations`. */
612
+ private invalidatePendingIds(): void {
613
+ this.pendingIds = null;
614
+ }
615
+
590
616
  /**
591
617
  * Grace period for a settled write. Long enough to cover an SSP round trip
592
618
  * that is running slowly (seconds, not milliseconds, when the edge path is
@@ -606,6 +632,9 @@ export class DataModule<S extends SchemaStructure> {
606
632
  * disappear immediately, which is what makes this safe.
607
633
  */
608
634
  noteWriteSettled(recordId: string, mutationType: string): void {
635
+ // The outbox row is already gone by the time this is called — that is what
636
+ // makes it the delete-side invalidation point for the cache above.
637
+ this.invalidatePendingIds();
609
638
  const until = Date.now() + DataModule.SETTLED_WRITE_GRACE_MS;
610
639
  if (mutationType === 'delete') this.settledDeletes.set(recordId, until);
611
640
  else this.settledWrites.set(recordId, until);
@@ -1181,6 +1210,30 @@ export class DataModule<S extends SchemaStructure> {
1181
1210
  * briefly hide an optimistic write but never resurrects a deleted row.
1182
1211
  */
1183
1212
  async getPendingRecordIds(): Promise<{ writes: Set<string>; deletes: Set<string> }> {
1213
+ const now = Date.now();
1214
+ const cached = this.pendingIds;
1215
+ if (cached && now - this.pendingIdsAt < DataModule.PENDING_IDS_TTL_MS) {
1216
+ // COPIES: `buildRenderIds` merges the settled-write ids into these sets,
1217
+ // and handing out the cached instances would let it grow them permanently.
1218
+ return { writes: new Set(cached.writes), deletes: new Set(cached.deletes) };
1219
+ }
1220
+ // Single-flight: one ingest fans out to many queries materializing at once,
1221
+ // and without this they all queue their own identical read behind each other.
1222
+ if (!this.pendingIdsInflight) {
1223
+ this.pendingIdsInflight = this.readPendingRecordIds().finally(() => {
1224
+ this.pendingIdsInflight = null;
1225
+ });
1226
+ }
1227
+ const fresh = await this.pendingIdsInflight;
1228
+ return { writes: new Set(fresh.writes), deletes: new Set(fresh.deletes) };
1229
+ }
1230
+
1231
+ /** The uncached read. Also the reload path after an invalidation, so the ids
1232
+ * still survive a reload exactly as before. */
1233
+ private async readPendingRecordIds(): Promise<{
1234
+ writes: Set<string>;
1235
+ deletes: Set<string>;
1236
+ }> {
1184
1237
  const writes = new Set<string>();
1185
1238
  const deletes = new Set<string>();
1186
1239
  try {
@@ -1198,7 +1251,12 @@ export class DataModule<S extends SchemaStructure> {
1198
1251
  { err, Category: 'sp00ky-client::DataModule::getPendingRecordIds' },
1199
1252
  'Failed to read pending mutations; optimistic writes may be briefly hidden'
1200
1253
  );
1254
+ // Do NOT cache a failed read: the empty sets are a fallback for this call,
1255
+ // not a statement that the outbox is empty.
1256
+ return { writes, deletes };
1201
1257
  }
1258
+ this.pendingIds = { writes, deletes };
1259
+ this.pendingIdsAt = Date.now();
1202
1260
  return { writes, deletes };
1203
1261
  }
1204
1262
 
@@ -1685,6 +1743,8 @@ export class DataModule<S extends SchemaStructure> {
1685
1743
  tableName,
1686
1744
  };
1687
1745
 
1746
+ // A new outbox row: the cached pending-id sets no longer describe it.
1747
+ this.invalidatePendingIds();
1688
1748
  for (const callback of this.mutationCallbacks) {
1689
1749
  callback([mutationEvent]);
1690
1750
  }
@@ -1778,6 +1838,8 @@ export class DataModule<S extends SchemaStructure> {
1778
1838
  options: pushEventOptions,
1779
1839
  };
1780
1840
 
1841
+ // A new outbox row: the cached pending-id sets no longer describe it.
1842
+ this.invalidatePendingIds();
1781
1843
  for (const callback of this.mutationCallbacks) {
1782
1844
  callback([mutationEvent]);
1783
1845
  }
@@ -1853,6 +1915,8 @@ export class DataModule<S extends SchemaStructure> {
1853
1915
  record_id: rid,
1854
1916
  };
1855
1917
 
1918
+ // A new outbox row: the cached pending-id sets no longer describe it.
1919
+ this.invalidatePendingIds();
1856
1920
  for (const callback of this.mutationCallbacks) {
1857
1921
  callback([mutationEvent]);
1858
1922
  }