@spooky-sync/core 0.0.1-canary.280 → 0.0.1-canary.281
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +2 -0
- package/dist/index.d.ts +7 -329
- package/dist/index.js +748 -39
- package/dist/live.d.ts +141 -0
- package/dist/live.js +709 -0
- package/dist/pure.d.ts +322 -0
- package/dist/pure.js +24 -0
- package/dist/sw.d.ts +207 -0
- package/dist/sw.js +393 -0
- package/dist/types.d.ts +742 -3
- package/dist/worker-edges.js +415 -0
- package/dist/worker-impersonation.js +60 -0
- package/dist/worker-ref-tables.d.ts +46 -0
- package/dist/worker-types.d.ts +112 -0
- package/dist/worker-types.js +73 -0
- package/package.json +16 -4
- package/scripts/check-worker-entries.mjs +172 -0
- package/src/client/services.ts +10 -38
- package/src/client/sp00ky-client.ts +13 -0
- package/src/index.ts +32 -0
- package/src/live/index.test.ts +478 -0
- package/src/live/index.ts +1013 -0
- package/src/modules/auth/index.ts +52 -0
- package/src/modules/web-push/index.test.ts +797 -0
- package/src/modules/web-push/index.ts +997 -0
- package/src/pure.ts +65 -0
- package/src/push/types.ts +174 -0
- package/src/sw/index.test.ts +582 -0
- package/src/sw/index.ts +607 -0
- package/src/sw/store.ts +96 -0
- package/src/testing/fake-services.ts +1 -1
- package/src/types.ts +11 -0
- package/src/utils/edges.ts +39 -0
- package/src/utils/sha256.ts +15 -0
- package/tsdown.config.ts +31 -2
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
import { DateTime, Duration, RecordId } from "surrealdb";
|
|
2
|
+
import { RecordId as RecordId$1, baseFieldOfParam, cyrb53 } from "@spooky-sync/query-builder";
|
|
3
|
+
|
|
4
|
+
//#region src/query/hash.ts
|
|
5
|
+
/** Session-salted: two tabs of one user must not share a remote view. */
|
|
6
|
+
const queryHashInput = (input, sessionId) => JSON.stringify({
|
|
7
|
+
surql: input.surql,
|
|
8
|
+
params: input.params,
|
|
9
|
+
sessionId
|
|
10
|
+
});
|
|
11
|
+
/** Session-independent: survives reload and bucket switch. */
|
|
12
|
+
const viewKeyInput = (input) => JSON.stringify({
|
|
13
|
+
surql: input.surql,
|
|
14
|
+
params: input.params
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
//#endregion
|
|
18
|
+
//#region src/utils/sha256.ts
|
|
19
|
+
/**
|
|
20
|
+
* Lowercase hex SHA-256 of a UTF-8 string, through WebCrypto.
|
|
21
|
+
*
|
|
22
|
+
* Pure and dependency free so it can live in `@spooky-sync/core/pure` and run
|
|
23
|
+
* in a service worker, a dedicated worker or Node (`globalThis.crypto` is
|
|
24
|
+
* there on Node 19+). Query keys are `sha256Hex(JSON.stringify(..))`, see
|
|
25
|
+
* `query/hash.ts`.
|
|
26
|
+
*/
|
|
27
|
+
async function sha256Hex(input) {
|
|
28
|
+
const bytes = new TextEncoder().encode(input);
|
|
29
|
+
const digest = await crypto.subtle.digest("SHA-256", bytes);
|
|
30
|
+
return Array.from(new Uint8Array(digest)).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
//#endregion
|
|
34
|
+
//#region src/utils/surql.ts
|
|
35
|
+
const surql = {
|
|
36
|
+
seal(query, options) {
|
|
37
|
+
if (typeof query === "string") return `${query};`;
|
|
38
|
+
const txQuery = query;
|
|
39
|
+
const idx = options?.resultIndex ?? txQuery.statementCount - 1;
|
|
40
|
+
return {
|
|
41
|
+
sql: `${txQuery.sql};`,
|
|
42
|
+
extract(results) {
|
|
43
|
+
return results[idx + 1];
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
},
|
|
47
|
+
tx(queries) {
|
|
48
|
+
return {
|
|
49
|
+
__brand: "TxQuery",
|
|
50
|
+
sql: `BEGIN TRANSACTION;\n${queries.join(";")};\nCOMMIT TRANSACTION`,
|
|
51
|
+
statementCount: queries.length
|
|
52
|
+
};
|
|
53
|
+
},
|
|
54
|
+
selectById(idVar, returnValues) {
|
|
55
|
+
return `SELECT ${returnValues.join(",")} FROM ONLY $${idVar}`;
|
|
56
|
+
},
|
|
57
|
+
selectByFieldsAnd(table, whereVar, returnValues) {
|
|
58
|
+
return `SELECT ${returnValues.map((rv) => typeof rv === "string" ? rv : `${rv.field} as ${rv.alias}`).join(",")} FROM ${table} WHERE ${whereVar.map((wv) => typeof wv === "string" ? `${wv} = $${wv}` : `${wv.field} = $${wv.variable}`).join(" AND ")}`;
|
|
59
|
+
},
|
|
60
|
+
create(idVar, dataVar) {
|
|
61
|
+
return `CREATE ONLY $${idVar} CONTENT $${dataVar}`;
|
|
62
|
+
},
|
|
63
|
+
createSet(idVar, keyDataVars) {
|
|
64
|
+
return `CREATE ONLY $${idVar} SET ${keyDataVars.map((keyDataVar) => typeof keyDataVar === "string" ? `${keyDataVar} = $${keyDataVar}` : "statement" in keyDataVar ? keyDataVar.statement : `${keyDataVar.key} = $${keyDataVar.variable}`).join(", ")}`;
|
|
65
|
+
},
|
|
66
|
+
upsert(idVar, dataVar) {
|
|
67
|
+
return `UPSERT ONLY $${idVar} REPLACE $${dataVar}`;
|
|
68
|
+
},
|
|
69
|
+
upsertMerge(idVar, dataVar) {
|
|
70
|
+
return `UPSERT ONLY $${idVar} MERGE $${dataVar}`;
|
|
71
|
+
},
|
|
72
|
+
updateMerge(idVar, dataVar) {
|
|
73
|
+
return `UPDATE ONLY $${idVar} MERGE $${dataVar}`;
|
|
74
|
+
},
|
|
75
|
+
updateSet(idVar, keyDataVar) {
|
|
76
|
+
return `UPDATE $${idVar} SET ${keyDataVar.map((kdv) => typeof kdv === "string" ? `${kdv} = $${kdv}` : "statement" in kdv ? kdv.statement : `${kdv.key} = $${kdv.variable}`).join(", ")}`;
|
|
77
|
+
},
|
|
78
|
+
delete(idVar) {
|
|
79
|
+
return `DELETE $${idVar}`;
|
|
80
|
+
},
|
|
81
|
+
let(name, query) {
|
|
82
|
+
return `LET $${name} = (${query})`;
|
|
83
|
+
},
|
|
84
|
+
createMutation(t, mutationIdVar, recordIdVar, dataVar, beforeRecordVar) {
|
|
85
|
+
switch (t) {
|
|
86
|
+
case "create": return dataVar ? `CREATE ONLY $${mutationIdVar} SET mutationType = 'create', recordId = $${recordIdVar}, data = $${dataVar}` : `CREATE ONLY $${mutationIdVar} SET mutationType = 'create', recordId = $${recordIdVar}`;
|
|
87
|
+
case "update": {
|
|
88
|
+
let stmt = `CREATE ONLY $${mutationIdVar} SET mutationType = 'update', recordId = $${recordIdVar}, data = $${dataVar}`;
|
|
89
|
+
if (beforeRecordVar) stmt += `, beforeRecord = $${beforeRecordVar}`;
|
|
90
|
+
return stmt;
|
|
91
|
+
}
|
|
92
|
+
case "delete": return `CREATE ONLY $${mutationIdVar} SET mutationType = 'delete', recordId = $${recordIdVar}`;
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
returnObject(entries) {
|
|
96
|
+
return `RETURN {${entries.map(({ key, variable }) => `${key}: $${variable}`).join(",")}}`;
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
//#endregion
|
|
101
|
+
//#region src/query/sql.ts
|
|
102
|
+
/** Every SurrealQL statement the query side sends, as pure builders. */
|
|
103
|
+
const VIEW_TABLE = "_00_view";
|
|
104
|
+
const LEGACY_VIEW_TABLE = "_00_window";
|
|
105
|
+
const viewRecordId = (viewKey) => new RecordId(VIEW_TABLE, viewKey);
|
|
106
|
+
const viewRow = (ids, confirmed, now) => ({
|
|
107
|
+
ids,
|
|
108
|
+
confirmed,
|
|
109
|
+
updatedAt: now
|
|
110
|
+
});
|
|
111
|
+
/** Copy every legacy `_00_window` row into `_00_view` once. */
|
|
112
|
+
const readLegacyViewRows = () => `SELECT * FROM ${LEGACY_VIEW_TABLE}`;
|
|
113
|
+
const countViewRows = () => `SELECT count() FROM ${VIEW_TABLE} GROUP ALL`;
|
|
114
|
+
const listRefSelect = (table) => `SELECT out, version FROM ${table} WHERE in = $in AND parent IS NONE`;
|
|
115
|
+
const subqueryListRefSelect = (table) => `SELECT out, version FROM ${table} WHERE in = $in AND parent IS NOT NONE`;
|
|
116
|
+
const queryRowCountSelect = () => "SELECT VALUE { rowCount: rowCount, state: state } FROM ONLY $in";
|
|
117
|
+
const listRefBatchSelect = (table) => `SELECT in, out, version, parent FROM ${table} WHERE in IN $ins`;
|
|
118
|
+
const queryRowCountBatchSelect = () => "SELECT VALUE { id: id, rowCount: rowCount, state: state } FROM $ins";
|
|
119
|
+
/** The single-query read: edges, meta, subquery children in one request. */
|
|
120
|
+
const singleSnapshotSelect = (table) => `${listRefSelect(table)};\n${queryRowCountSelect()};\n${subqueryListRefSelect(table)}`;
|
|
121
|
+
/** The many-query read: all edges + all metas in one request. */
|
|
122
|
+
const batchSnapshotSelect = (table) => `${listRefBatchSelect(table)};\n${queryRowCountBatchSelect()}`;
|
|
123
|
+
/** Register + read back edges/meta/children in ONE request. */
|
|
124
|
+
const registerSelect = (table) => `fn::query::register($config);\n${singleSnapshotSelect(table)}`;
|
|
125
|
+
const registerVars = (payload) => ({
|
|
126
|
+
config: payload,
|
|
127
|
+
in: payload.id
|
|
128
|
+
});
|
|
129
|
+
/** One heartbeat statement per view id; result index i answers id i. */
|
|
130
|
+
function heartbeatBatch(ids) {
|
|
131
|
+
const vars = {};
|
|
132
|
+
return {
|
|
133
|
+
sql: ids.map((id, i) => {
|
|
134
|
+
vars[`id${i}`] = id;
|
|
135
|
+
return `fn::query::heartbeat($id${i})`;
|
|
136
|
+
}).join(";\n"),
|
|
137
|
+
vars
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
/** A heartbeat answer whose statement matched no row: the view was reclaimed. */
|
|
141
|
+
const heartbeatRowGone = (result) => Array.isArray(result) && result.length === 0;
|
|
142
|
+
const bodySelect = () => "SELECT * FROM $ids";
|
|
143
|
+
/** Local transaction that MERGEs many fetched bodies. */
|
|
144
|
+
function upsertBodiesTx(records) {
|
|
145
|
+
const vars = {};
|
|
146
|
+
const stmts = records.map((r, i) => {
|
|
147
|
+
vars[`id${i}`] = r.id;
|
|
148
|
+
vars[`content${i}`] = r.content;
|
|
149
|
+
return surql.upsertMerge(`id${i}`, `content${i}`);
|
|
150
|
+
});
|
|
151
|
+
return {
|
|
152
|
+
query: surql.seal(surql.tx(stmts)),
|
|
153
|
+
vars
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
//#endregion
|
|
158
|
+
//#region src/modules/ref-tables.ts
|
|
159
|
+
/**
|
|
160
|
+
* Sentinel user id for unauthenticated clients when anonymous live queries are
|
|
161
|
+
* enabled. Mirrors `ssp_protocol::ANON_AUTH_ID`. It carries no `user:` prefix
|
|
162
|
+
* so it can never collide with a real user id (those arrive as `user:<id>`);
|
|
163
|
+
* both sides resolve it to the shared `_00_list_ref_anon` table.
|
|
164
|
+
*/
|
|
165
|
+
const ANON_USER_ID = "anon";
|
|
166
|
+
/**
|
|
167
|
+
* Default ref-storage mode for this client build. Mirrors the SSP's
|
|
168
|
+
* default (`RefMode::Dedicated`) so cross-session sync works out of the
|
|
169
|
+
* box.
|
|
170
|
+
*/
|
|
171
|
+
const DEFAULT_REF_MODE = "dedicated";
|
|
172
|
+
/**
|
|
173
|
+
* Sanitize a user record id (e.g. `"user:abc"`) into the segment that
|
|
174
|
+
* goes into a dedicated table name (e.g. `"abc"`). Returns `null` if
|
|
175
|
+
* the id is missing the `user:` prefix or contains characters that
|
|
176
|
+
* aren't valid in a SurrealDB table identifier — the server-side
|
|
177
|
+
* `ssp_protocol::sanitize_user_id` uses the same predicate.
|
|
178
|
+
*
|
|
179
|
+
* Accepts both string ids (`"user:abc"`) and SurrealDB `RecordId`
|
|
180
|
+
* objects (which only stringify cleanly via `.toString()`), since
|
|
181
|
+
* `AuthService` passes the record-id object as-is to its subscribers.
|
|
182
|
+
*/
|
|
183
|
+
function sanitizeUserId(userId) {
|
|
184
|
+
if (userId === null || userId === void 0) return null;
|
|
185
|
+
const asString = typeof userId === "string" ? userId : typeof userId.toString === "function" ? userId.toString() : null;
|
|
186
|
+
if (!asString) return null;
|
|
187
|
+
const raw = asString.startsWith("user:") ? asString.slice(5) : asString;
|
|
188
|
+
if (raw.length === 0) return null;
|
|
189
|
+
if (!/^[A-Za-z0-9_]+$/.test(raw)) return null;
|
|
190
|
+
return raw;
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Resolve the LOCAL storage bucket id for a user. Every user gets their own
|
|
194
|
+
* IndexedDB-backed local store (`indxdb://sp00ky-<bucketId>`) so cached rows,
|
|
195
|
+
* query state, and the mutation outbox never leak across accounts on a shared
|
|
196
|
+
* device. Signed-out sessions share the `anon` bucket.
|
|
197
|
+
*
|
|
198
|
+
* An id that fails sanitization still gets a DETERMINISTIC per-user bucket
|
|
199
|
+
* (cyrb53 hex of the raw id) — falling back to `anon` here would put an
|
|
200
|
+
* authenticated user in the shared bucket and recreate the cross-user leak.
|
|
201
|
+
*/
|
|
202
|
+
function bucketIdForUser(userId) {
|
|
203
|
+
if (userId === null || userId === void 0 || userId === ANON_USER_ID) return ANON_USER_ID;
|
|
204
|
+
const uid = sanitizeUserId(userId);
|
|
205
|
+
if (uid) return uid;
|
|
206
|
+
return `u${cyrb53(String(userId)).toString(16)}`;
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Returns the `_00_list_ref` table name for `(mode, userId)`. Falls
|
|
210
|
+
* back to the global `_00_list_ref` when sanitization fails or in
|
|
211
|
+
* single mode.
|
|
212
|
+
*/
|
|
213
|
+
function listRefTableFor(mode, userId) {
|
|
214
|
+
if (userId === ANON_USER_ID) return "_00_list_ref_anon";
|
|
215
|
+
if (mode === "single") return "_00_list_ref";
|
|
216
|
+
const uid = sanitizeUserId(userId);
|
|
217
|
+
return uid ? `_00_list_ref_user_${uid}` : "_00_list_ref";
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
//#endregion
|
|
221
|
+
//#region src/utils/parser.ts
|
|
222
|
+
/**
|
|
223
|
+
* Parse a QUERY's params. Unlike a record's fields, a param name is not always a
|
|
224
|
+
* column: an `_or` branch binds under a synthetic `white__or0` (see
|
|
225
|
+
* `orParamName` in @spooky-sync/query-builder), and the surql that references it
|
|
226
|
+
* is already written. So resolve the column through the synthetic name, and keep
|
|
227
|
+
* a param we cannot type rather than dropping it.
|
|
228
|
+
*
|
|
229
|
+
* Dropping was the bug: `parseParams` kept only column-named params, so every
|
|
230
|
+
* `_or` query registered with `$or0`/`$or1` unbound and matched no rows at all,
|
|
231
|
+
* silently (rowCount 0, errorCount 0).
|
|
232
|
+
*/
|
|
233
|
+
function parseQueryParams(tableSchema, params) {
|
|
234
|
+
const parsedParams = {};
|
|
235
|
+
for (const [key, value] of Object.entries(params)) {
|
|
236
|
+
if (value === void 0) continue;
|
|
237
|
+
const column = tableSchema[key] ?? tableSchema[baseFieldOfParam(key)];
|
|
238
|
+
parsedParams[key] = column ? parseValue(key, column, value) : value;
|
|
239
|
+
}
|
|
240
|
+
return parsedParams;
|
|
241
|
+
}
|
|
242
|
+
function parseValue(name, column, value) {
|
|
243
|
+
if (column.recordId) {
|
|
244
|
+
if (value instanceof RecordId$1) return value;
|
|
245
|
+
if (typeof value === "string") return parseRecordIdString(value);
|
|
246
|
+
throw new Error(`Invalid value for ${name}: ${value}`);
|
|
247
|
+
}
|
|
248
|
+
if (column.dateTime) {
|
|
249
|
+
if (value instanceof Date) return value;
|
|
250
|
+
if (value instanceof DateTime) return value.toDate();
|
|
251
|
+
if (typeof value === "number" || typeof value === "string") return new Date(value);
|
|
252
|
+
throw new Error(`Invalid value for ${name}: ${value}`);
|
|
253
|
+
}
|
|
254
|
+
return value;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
//#endregion
|
|
258
|
+
//#region src/utils/index.ts
|
|
259
|
+
const encodeRecordId = (recordId) => {
|
|
260
|
+
return `${recordId.table.toString()}:${recordId.id}`;
|
|
261
|
+
};
|
|
262
|
+
const parseRecordIdString = (id) => {
|
|
263
|
+
const [table, ...idParts] = id.split(":");
|
|
264
|
+
return new RecordId(table, idParts.join(":"));
|
|
265
|
+
};
|
|
266
|
+
/**
|
|
267
|
+
* Read the millisecond count off a surrealdb `Duration`. Different `surrealdb`
|
|
268
|
+
* releases expose it under `milliseconds` (current) or the older private
|
|
269
|
+
* `_milliseconds` field, so read whichever is set; returns 0 when neither is.
|
|
270
|
+
*/
|
|
271
|
+
function durationMillis(duration) {
|
|
272
|
+
const d = duration;
|
|
273
|
+
return Number(d.milliseconds || d._milliseconds || 0);
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Parse duration string or Duration object to milliseconds
|
|
277
|
+
*/
|
|
278
|
+
function parseDuration(duration) {
|
|
279
|
+
if (duration instanceof Duration) {
|
|
280
|
+
const ms = durationMillis(duration);
|
|
281
|
+
if (ms) return ms;
|
|
282
|
+
const str = duration.toString();
|
|
283
|
+
if (str !== "[object Object]") return parseDuration(str);
|
|
284
|
+
return 6e5;
|
|
285
|
+
}
|
|
286
|
+
if (typeof duration === "bigint") return Number(duration);
|
|
287
|
+
if (typeof duration !== "string") return 6e5;
|
|
288
|
+
const match = duration.match(/^(\d+)([smh])$/);
|
|
289
|
+
if (!match) return 6e5;
|
|
290
|
+
const val = Number.parseInt(match[1], 10);
|
|
291
|
+
switch (match[2]) {
|
|
292
|
+
case "s": return val * 1e3;
|
|
293
|
+
case "h": return val * 36e5;
|
|
294
|
+
default: return val * 6e4;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* Reject after `timeoutMs` if `promise` hasn't settled.
|
|
299
|
+
*
|
|
300
|
+
* Exists because a WebSocket RPC has no deadline of its own: the SurrealDB SDK
|
|
301
|
+
* parks each call in a pending map and only rejects it when the socket reports
|
|
302
|
+
* a close. On a half-open socket (peer gone, no `close` event, `readyState`
|
|
303
|
+
* still OPEN) the call never settles at all, which wedges anything serialized
|
|
304
|
+
* behind it.
|
|
305
|
+
*
|
|
306
|
+
* `message` MUST contain "timed out" so `classifySyncError` treats the
|
|
307
|
+
* rejection as `network` — that's what makes the sync queues retry the
|
|
308
|
+
* operation instead of rolling the mutation back as an application error.
|
|
309
|
+
*
|
|
310
|
+
* Non-positive `timeoutMs` returns `promise` unchanged. The underlying promise
|
|
311
|
+
* is NOT cancelled (nothing can cancel an in-flight RPC); its eventual
|
|
312
|
+
* settlement is absorbed, so attach no expectations to it after a timeout.
|
|
313
|
+
*/
|
|
314
|
+
function withTimeout(promise, timeoutMs, message) {
|
|
315
|
+
if (!(timeoutMs > 0)) return promise;
|
|
316
|
+
return new Promise((resolve, reject) => {
|
|
317
|
+
const timer = setTimeout(() => reject(typeof message === "function" ? message() : new Error(message)), timeoutMs);
|
|
318
|
+
promise.then((value) => {
|
|
319
|
+
clearTimeout(timer);
|
|
320
|
+
resolve(value);
|
|
321
|
+
}, (err) => {
|
|
322
|
+
clearTimeout(timer);
|
|
323
|
+
reject(err);
|
|
324
|
+
});
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
//#endregion
|
|
329
|
+
//#region src/query/membership.ts
|
|
330
|
+
function metaFromRow(row) {
|
|
331
|
+
if (!row || typeof row !== "object") return {
|
|
332
|
+
present: false,
|
|
333
|
+
rowCount: null,
|
|
334
|
+
state: null
|
|
335
|
+
};
|
|
336
|
+
const state = row.state === "materializing" || row.state === "ready" ? row.state : null;
|
|
337
|
+
return {
|
|
338
|
+
present: true,
|
|
339
|
+
rowCount: typeof row.rowCount === "number" ? row.rowCount : null,
|
|
340
|
+
state
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
/** Collapse duplicate `(id, version)` pairs to one per id, keeping the highest version. */
|
|
344
|
+
function dedupeRecordVersions(pairs) {
|
|
345
|
+
if (pairs.length < 2) return pairs;
|
|
346
|
+
const best = /* @__PURE__ */ new Map();
|
|
347
|
+
for (const [id, version] of pairs) {
|
|
348
|
+
const prev = best.get(id);
|
|
349
|
+
if (prev === void 0 || version > prev) best.set(id, version);
|
|
350
|
+
}
|
|
351
|
+
if (best.size === pairs.length) return pairs;
|
|
352
|
+
return Array.from(best.entries());
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* A statement that did not answer (an ERR, or no result at all). Kept apart
|
|
356
|
+
* from a `null` result, which is the server saying "no such row": reading a
|
|
357
|
+
* failed statement as a missing row turned every timeout under load into a
|
|
358
|
+
* lost view and re-registered it, over and over (whitepawn, 2026-09-16).
|
|
359
|
+
*/
|
|
360
|
+
const FAILED = Symbol("statement failed");
|
|
361
|
+
const toPairs = (rows) => dedupeRecordVersions(Array.isArray(rows) ? rows.map((r) => [encodeRecordId(r.out), r.version]) : []);
|
|
362
|
+
/**
|
|
363
|
+
* Fold the single-query statement batch (edges, meta, children) into a
|
|
364
|
+
* snapshot. `null` when any of the three did not answer: half an answer is
|
|
365
|
+
* not one, and a missing meta would read as a lost view.
|
|
366
|
+
*/
|
|
367
|
+
function snapshotFromSingle(items, metaRow, children) {
|
|
368
|
+
if (!Array.isArray(items) || metaRow === FAILED || children === FAILED) return null;
|
|
369
|
+
return {
|
|
370
|
+
primary: toPairs(items),
|
|
371
|
+
subquery: toPairs(children),
|
|
372
|
+
meta: metaFromRow(metaRow)
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
//#endregion
|
|
377
|
+
//#region src/utils/edges.ts
|
|
378
|
+
/**
|
|
379
|
+
* Reading `_00_list_ref` edges as a LIVE notification delivers them. Pure, so
|
|
380
|
+
* both the full client and the wasm-free live feed (`@spooky-sync/core/live`)
|
|
381
|
+
* decode the same wire shape the same way.
|
|
382
|
+
*/
|
|
383
|
+
/** The hash a `_00_list_ref` edge belongs to: the id part of its `in` (`_00_query:<hash>`). */
|
|
384
|
+
function hashOfEdge(value) {
|
|
385
|
+
const inId = value?.in;
|
|
386
|
+
if (!inId) return null;
|
|
387
|
+
const str = inId instanceof RecordId ? String(inId.id) : String(inId).replace(/^_00_query:/, "");
|
|
388
|
+
return str.length > 0 ? str : null;
|
|
389
|
+
}
|
|
390
|
+
/**
|
|
391
|
+
* The row an edge notification carries, when the subscription joined it on.
|
|
392
|
+
*
|
|
393
|
+
* Without `FETCH out` the edge's `out` is a record id and there is nothing to
|
|
394
|
+
* land, so this returns null and the caller falls back to fetching the body.
|
|
395
|
+
* It also returns null for a row the session may not read (the join yields
|
|
396
|
+
* `out: null`) and for an edge whose target has been deleted.
|
|
397
|
+
*/
|
|
398
|
+
function rowOfEdge(value) {
|
|
399
|
+
const edge = value;
|
|
400
|
+
const out = edge?.out;
|
|
401
|
+
if (!out || typeof out !== "object" || Array.isArray(out) || out instanceof RecordId) return null;
|
|
402
|
+
const rid = out.id;
|
|
403
|
+
if (!(rid instanceof RecordId)) return null;
|
|
404
|
+
const id = encodeRecordId(rid);
|
|
405
|
+
const version = typeof edge?.version === "number" ? edge.version : null;
|
|
406
|
+
if (version === null) return null;
|
|
407
|
+
return {
|
|
408
|
+
id,
|
|
409
|
+
version,
|
|
410
|
+
record: out
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
//#endregion
|
|
415
|
+
export { registerVars as A, heartbeatRowGone as C, queryRowCountSelect as D, queryRowCountBatchSelect as E, viewRow as F, surql as I, sha256Hex as L, subqueryListRefSelect as M, upsertBodiesTx as N, readLegacyViewRows as O, viewRecordId as P, queryHashInput as R, heartbeatBatch as S, listRefSelect as T, LEGACY_VIEW_TABLE as _, metaFromRow as a, bodySelect as b, parseDuration as c, parseQueryParams as d, ANON_USER_ID as f, sanitizeUserId as g, listRefTableFor as h, dedupeRecordVersions as i, singleSnapshotSelect as j, registerSelect as k, parseRecordIdString as l, bucketIdForUser as m, rowOfEdge as n, snapshotFromSingle as o, DEFAULT_REF_MODE as p, FAILED as r, encodeRecordId as s, hashOfEdge as t, withTimeout as u, VIEW_TABLE as v, listRefBatchSelect as w, countViewRows as x, batchSnapshotSelect as y, viewKeyInput as z };
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
//#region src/modules/auth/impersonation.ts
|
|
2
|
+
/**
|
|
3
|
+
* Admin impersonation, client side.
|
|
4
|
+
*
|
|
5
|
+
* The server does the enforcing (see `impersonation_remote.surql` in the CLI):
|
|
6
|
+
* an admin calls `fn::_00_impersonate::start`, receives a token for the
|
|
7
|
+
* `_00_impersonate` access method, and authenticates with it, so `$auth.id` is
|
|
8
|
+
* the target user. Everything here is state and timers around that:
|
|
9
|
+
*
|
|
10
|
+
* - the admin's own token is kept under {@link ORIGIN_TOKEN_KEY} so Stop (and a
|
|
11
|
+
* reload in between) can return to it;
|
|
12
|
+
* - the impersonation state is derived from the token's claims, never stored
|
|
13
|
+
* separately, so it cannot disagree with the session actually in use;
|
|
14
|
+
* - the token is re-signed before it expires, and the session is polled so a
|
|
15
|
+
* stop or revocation made elsewhere reaches an open WebSocket quickly.
|
|
16
|
+
*/
|
|
17
|
+
/** Access method impersonation tokens are issued for. */
|
|
18
|
+
const IMPERSONATION_ACCESS = "_00_impersonate";
|
|
19
|
+
/**
|
|
20
|
+
* Read the claims of a SurrealDB record-access JWT WITHOUT verifying it. The
|
|
21
|
+
* server still enforces the token on every request; this is only so the client
|
|
22
|
+
* can act on what it already holds before a round trip completes.
|
|
23
|
+
*
|
|
24
|
+
* Returns nulls on any malformed input.
|
|
25
|
+
*/
|
|
26
|
+
function decodeTokenClaims(token) {
|
|
27
|
+
const none = {
|
|
28
|
+
access: null,
|
|
29
|
+
userId: null,
|
|
30
|
+
exp: null,
|
|
31
|
+
impersonation: null
|
|
32
|
+
};
|
|
33
|
+
try {
|
|
34
|
+
const payload = token.split(".")[1];
|
|
35
|
+
if (!payload) return none;
|
|
36
|
+
let b64 = payload.replace(/-/g, "+").replace(/_/g, "/");
|
|
37
|
+
b64 += "=".repeat((4 - b64.length % 4) % 4);
|
|
38
|
+
const json = typeof atob === "function" ? atob(b64) : Buffer.from(b64, "base64").toString("binary");
|
|
39
|
+
const claims = JSON.parse(json);
|
|
40
|
+
const ac = claims.AC ?? claims.ac;
|
|
41
|
+
const id = claims.ID ?? claims.id;
|
|
42
|
+
const access = typeof ac === "string" ? ac : null;
|
|
43
|
+
const session = claims.spky_imp;
|
|
44
|
+
const admin = claims.spky_admin;
|
|
45
|
+
return {
|
|
46
|
+
access,
|
|
47
|
+
userId: typeof id === "string" ? id : null,
|
|
48
|
+
exp: typeof claims.exp === "number" ? claims.exp : null,
|
|
49
|
+
impersonation: access === IMPERSONATION_ACCESS && typeof session === "string" && typeof admin === "string" ? {
|
|
50
|
+
session,
|
|
51
|
+
admin
|
|
52
|
+
} : null
|
|
53
|
+
};
|
|
54
|
+
} catch {
|
|
55
|
+
return none;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
//#endregion
|
|
60
|
+
export { decodeTokenClaims as n, IMPERSONATION_ACCESS as t };
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
//#region src/modules/ref-tables.d.ts
|
|
2
|
+
type RefMode = 'single' | 'dedicated';
|
|
3
|
+
/**
|
|
4
|
+
* Sentinel user id for unauthenticated clients when anonymous live queries are
|
|
5
|
+
* enabled. Mirrors `ssp_protocol::ANON_AUTH_ID`. It carries no `user:` prefix
|
|
6
|
+
* so it can never collide with a real user id (those arrive as `user:<id>`);
|
|
7
|
+
* both sides resolve it to the shared `_00_list_ref_anon` table.
|
|
8
|
+
*/
|
|
9
|
+
declare const ANON_USER_ID = "anon";
|
|
10
|
+
/**
|
|
11
|
+
* Default ref-storage mode for this client build. Mirrors the SSP's
|
|
12
|
+
* default (`RefMode::Dedicated`) so cross-session sync works out of the
|
|
13
|
+
* box.
|
|
14
|
+
*/
|
|
15
|
+
declare const DEFAULT_REF_MODE: RefMode;
|
|
16
|
+
/**
|
|
17
|
+
* Sanitize a user record id (e.g. `"user:abc"`) into the segment that
|
|
18
|
+
* goes into a dedicated table name (e.g. `"abc"`). Returns `null` if
|
|
19
|
+
* the id is missing the `user:` prefix or contains characters that
|
|
20
|
+
* aren't valid in a SurrealDB table identifier — the server-side
|
|
21
|
+
* `ssp_protocol::sanitize_user_id` uses the same predicate.
|
|
22
|
+
*
|
|
23
|
+
* Accepts both string ids (`"user:abc"`) and SurrealDB `RecordId`
|
|
24
|
+
* objects (which only stringify cleanly via `.toString()`), since
|
|
25
|
+
* `AuthService` passes the record-id object as-is to its subscribers.
|
|
26
|
+
*/
|
|
27
|
+
declare function sanitizeUserId(userId: unknown): string | null;
|
|
28
|
+
/**
|
|
29
|
+
* Resolve the LOCAL storage bucket id for a user. Every user gets their own
|
|
30
|
+
* IndexedDB-backed local store (`indxdb://sp00ky-<bucketId>`) so cached rows,
|
|
31
|
+
* query state, and the mutation outbox never leak across accounts on a shared
|
|
32
|
+
* device. Signed-out sessions share the `anon` bucket.
|
|
33
|
+
*
|
|
34
|
+
* An id that fails sanitization still gets a DETERMINISTIC per-user bucket
|
|
35
|
+
* (cyrb53 hex of the raw id) — falling back to `anon` here would put an
|
|
36
|
+
* authenticated user in the shared bucket and recreate the cross-user leak.
|
|
37
|
+
*/
|
|
38
|
+
declare function bucketIdForUser(userId: unknown): string;
|
|
39
|
+
/**
|
|
40
|
+
* Returns the `_00_list_ref` table name for `(mode, userId)`. Falls
|
|
41
|
+
* back to the global `_00_list_ref` when sanitization fails or in
|
|
42
|
+
* single mode.
|
|
43
|
+
*/
|
|
44
|
+
declare function listRefTableFor(mode: RefMode, userId: unknown): string;
|
|
45
|
+
//#endregion
|
|
46
|
+
export { listRefTableFor as a, bucketIdForUser as i, DEFAULT_REF_MODE as n, sanitizeUserId as o, RefMode as r, ANON_USER_ID as t };
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
//#region src/push/types.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Web Push wire types shared by the page (`db.webPush`), the service worker
|
|
4
|
+
* bridge (`@spooky-sync/core/sw`) and the live feed. Pure: types plus a few
|
|
5
|
+
* dependency-free helpers, safe in any context.
|
|
6
|
+
*
|
|
7
|
+
* `PushPayload` mirrors `packages/push-core/src/config.rs` (`PushPayload`,
|
|
8
|
+
* `PayloadKind`, `Op`, `NotificationTemplate`). Keep the two in step.
|
|
9
|
+
*/
|
|
10
|
+
/** Payload format version this build understands. */
|
|
11
|
+
declare const PUSH_PAYLOAD_VERSION = 1;
|
|
12
|
+
type PushPayloadKind = 'rule' | 'message';
|
|
13
|
+
type PushOp = 'create' | 'update' | 'delete';
|
|
14
|
+
type PushUrgency = 'very-low' | 'low' | 'normal' | 'high';
|
|
15
|
+
/** One button on a notification (`NotificationAction` plus a per-action `url`). */
|
|
16
|
+
interface PushNotificationAction {
|
|
17
|
+
action: string;
|
|
18
|
+
title: string;
|
|
19
|
+
icon?: string;
|
|
20
|
+
/** Opened instead of the notification's `url` when this action is clicked. */
|
|
21
|
+
url?: string;
|
|
22
|
+
[key: string]: unknown;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* What a content push shows. Every key other than `title` and `url` is passed
|
|
26
|
+
* to `showNotification` as is, unknown ones included (`vibrate`, `timestamp`,
|
|
27
|
+
* whatever a browser adds later).
|
|
28
|
+
*/
|
|
29
|
+
interface PushNotification {
|
|
30
|
+
title?: string;
|
|
31
|
+
body?: string;
|
|
32
|
+
icon?: string;
|
|
33
|
+
badge?: string;
|
|
34
|
+
image?: string;
|
|
35
|
+
lang?: string;
|
|
36
|
+
dir?: string;
|
|
37
|
+
/** Defaults to the push's `topic`. */
|
|
38
|
+
tag?: string;
|
|
39
|
+
/** Opened (or focused) on click. */
|
|
40
|
+
url?: string;
|
|
41
|
+
requireInteraction?: boolean;
|
|
42
|
+
renotify?: boolean;
|
|
43
|
+
silent?: boolean;
|
|
44
|
+
actions?: PushNotificationAction[];
|
|
45
|
+
/** Merged into the notification's `data` next to the bridge's own keys. */
|
|
46
|
+
data?: Record<string, unknown>;
|
|
47
|
+
[key: string]: unknown;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* The JSON every push carries (then RFC 8291 encrypted). `notification`
|
|
51
|
+
* present: a content push the service worker shows as is. Absent: a
|
|
52
|
+
* content-free nudge, the app renders from its own data.
|
|
53
|
+
*/
|
|
54
|
+
interface PushPayload {
|
|
55
|
+
v: number;
|
|
56
|
+
kind: PushPayloadKind;
|
|
57
|
+
/** Rule name (`kind: 'rule'`). */
|
|
58
|
+
rule?: string;
|
|
59
|
+
/** `_00_push_message` id (`kind: 'message'`). */
|
|
60
|
+
message?: string;
|
|
61
|
+
/** The row that fired the rule. */
|
|
62
|
+
table?: string;
|
|
63
|
+
id?: string;
|
|
64
|
+
op?: PushOp;
|
|
65
|
+
topic?: string;
|
|
66
|
+
notification?: PushNotification;
|
|
67
|
+
data?: Record<string, unknown>;
|
|
68
|
+
/** Epoch ms when the engine built it. */
|
|
69
|
+
ts: number;
|
|
70
|
+
}
|
|
71
|
+
/** Page -> service worker: the session to use for live rendering. */
|
|
72
|
+
interface BridgeTokenMessage {
|
|
73
|
+
type: 'sp00ky:token';
|
|
74
|
+
token: string;
|
|
75
|
+
userId: string;
|
|
76
|
+
/** The SurrealDB endpoint (`wss://host/rpc`), not the push endpoint. */
|
|
77
|
+
endpoint: string;
|
|
78
|
+
namespace: string;
|
|
79
|
+
database: string;
|
|
80
|
+
/** VAPID public key (base64url) the page subscribed with, for `pushsubscriptionchange`. */
|
|
81
|
+
publicKey?: string;
|
|
82
|
+
/** This browser's push endpoint, when subscribed. */
|
|
83
|
+
pushEndpoint?: string;
|
|
84
|
+
}
|
|
85
|
+
/** Page -> service worker: forget the session and close every notification. */
|
|
86
|
+
interface BridgeSignOutMessage {
|
|
87
|
+
type: 'sp00ky:signout';
|
|
88
|
+
}
|
|
89
|
+
/** Service worker -> page messages. */
|
|
90
|
+
type WebPushClientMessage = /** A push arrived while this page was visible, so nothing was shown. */
|
|
91
|
+
{
|
|
92
|
+
type: 'sp00ky:push';
|
|
93
|
+
payload: PushPayload;
|
|
94
|
+
}
|
|
95
|
+
/** A notification was clicked and the bridge was told to route in-app. */ | {
|
|
96
|
+
type: 'sp00ky:navigate';
|
|
97
|
+
url: string;
|
|
98
|
+
payload?: PushPayload;
|
|
99
|
+
action?: string;
|
|
100
|
+
}
|
|
101
|
+
/** The browser rotated the push subscription; the page should `sync()`. */ | {
|
|
102
|
+
type: 'sp00ky:subscriptionchange';
|
|
103
|
+
endpoint?: string;
|
|
104
|
+
};
|
|
105
|
+
/** `urlBase64ToUint8Array`: the VAPID key as `applicationServerKey` wants it. */
|
|
106
|
+
declare function base64UrlToBytes(value: string): Uint8Array;
|
|
107
|
+
declare function bytesToBase64Url(bytes: ArrayBuffer | Uint8Array): string;
|
|
108
|
+
declare function sameBytes(a: ArrayBuffer | Uint8Array | null | undefined, b: ArrayBuffer | Uint8Array | null | undefined): boolean;
|
|
109
|
+
/** A payload this build can act on (`v` known, `kind` set). */
|
|
110
|
+
declare function isPushPayload(value: unknown): value is PushPayload;
|
|
111
|
+
//#endregion
|
|
112
|
+
export { PushNotificationAction as a, PushPayloadKind as c, base64UrlToBytes as d, bytesToBase64Url as f, PushNotification as i, PushUrgency as l, sameBytes as m, BridgeTokenMessage as n, PushOp as o, isPushPayload as p, PUSH_PAYLOAD_VERSION as r, PushPayload as s, BridgeSignOutMessage as t, WebPushClientMessage as u };
|