@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.
- package/dist/index.d.ts +89 -6
- package/dist/index.js +353 -65
- package/dist/sqlite-open.js +28 -1
- package/dist/sqlite-worker.js +11 -8
- package/dist/types.d.ts +6 -0
- package/package.json +3 -3
- package/src/modules/auth/auth.local-first.test.ts +101 -0
- package/src/modules/auth/index.ts +78 -14
- package/src/modules/cache/index.ts +12 -11
- package/src/modules/data/data.pending-ids.test.ts +125 -0
- package/src/modules/data/index.ts +64 -0
- package/src/modules/sync/queue/queue-down.test.ts +73 -0
- package/src/modules/sync/queue/queue-down.ts +59 -21
- package/src/modules/sync/scheduler.retry.test.ts +82 -1
- package/src/modules/sync/scheduler.ts +59 -4
- package/src/modules/sync/sync.ts +36 -0
- package/src/services/database/sqlite-cache-engine.ts +13 -8
- package/src/services/database/sqlite-plan-sql.ts +32 -0
- package/src/services/database/sqlite-projection.test.ts +99 -0
- package/src/services/database/sqlite-select.ts +19 -9
- package/src/services/stream-processor/index.ts +66 -0
- package/src/services/stream-processor/stream-processor.batch.test.ts +60 -0
- package/src/services/stream-processor/wasm-types.ts +4 -0
- package/src/sp00ky.local-first.test.ts +60 -0
- package/src/sp00ky.ts +156 -50
- package/src/types.ts +6 -0
package/dist/sqlite-open.js
CHANGED
|
@@ -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 {
|
|
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 };
|
package/dist/sqlite-worker.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { a as
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
68
|
-
await resolveRelations(projected, plan.relations, fetcher);
|
|
71
|
+
await resolveRelations(rows, plan.relations, fetcher);
|
|
69
72
|
return {
|
|
70
|
-
rows
|
|
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.
|
|
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.
|
|
64
|
-
"@spooky-sync/ssp-wasm": "0.0.1-canary.
|
|
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
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
}
|
|
163
|
-
{}
|
|
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
|
}
|