@spooky-sync/core 0.0.1-canary.314 → 0.0.1-canary.316
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.js +53 -8
- package/dist/sqlite-open.js +31 -10
- package/dist/sqlite-worker.js +1 -1
- package/dist/types.d.ts +12 -0
- package/package.json +3 -3
- package/src/modules/auth/impersonation.integration.test.ts +205 -0
- package/src/modules/auth/impersonation.test.ts +22 -0
- package/src/modules/auth/index.ts +62 -2
- package/src/services/database/relation-resolver.test.ts +17 -0
- package/src/services/database/relation-resolver.ts +0 -0
- package/src/services/database/sqlite-plan-sql.test.ts +6 -0
- package/src/services/database/sqlite-plan-sql.ts +8 -5
- package/src/services/database/sqlite-select.test.ts +20 -5
- package/src/services/stream-processor/index.ts +4 -1
package/dist/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { a as renderOrderSql, c as serializeMergeRow, d as stableKey, i as projectedDataSql, l as serializeRow, o as renderWhereSql, r as project, s as reviveRow, t as PROMOTION_OPEN_OPTIONS, u as
|
|
1
|
+
import { a as renderOrderSql, c as serializeMergeRow, d as resolveRelations, f as stableKey, i as projectedDataSql, l as serializeRow, o as renderWhereSql, r as project, s as reviveRow, t as PROMOTION_OPEN_OPTIONS, u as recordIdKey } from "./sqlite-open.js";
|
|
2
2
|
import { QueryBuilder, RecordId, baseFieldOfParam, cyrb53 } from "@spooky-sync/query-builder";
|
|
3
3
|
import { DateTime, Duration, RecordId as RecordId$1, Surreal, Uuid, applyDiagnostics, createRemoteEngines } from "surrealdb";
|
|
4
4
|
import pino from "pino";
|
|
@@ -2178,8 +2178,8 @@ function trimHistory(history, cap) {
|
|
|
2178
2178
|
|
|
2179
2179
|
//#endregion
|
|
2180
2180
|
//#region src/modules/devtools/index.ts
|
|
2181
|
-
const CORE_VERSION = "0.0.1-canary.
|
|
2182
|
-
const WASM_VERSION = "0.0.1-canary.
|
|
2181
|
+
const CORE_VERSION = "0.0.1-canary.316";
|
|
2182
|
+
const WASM_VERSION = "0.0.1-canary.316";
|
|
2183
2183
|
const SURREAL_VERSION = "3.0.3";
|
|
2184
2184
|
var DevToolsService = class DevToolsService {
|
|
2185
2185
|
version = CORE_VERSION;
|
|
@@ -11212,7 +11212,7 @@ var StreamProcessorService = class {
|
|
|
11212
11212
|
const hasToString = typeof value.toString === "function";
|
|
11213
11213
|
const isNotPlainObject = value.constructor !== Object;
|
|
11214
11214
|
if (hasTable && hasId && hasToString && isNotPlainObject) {
|
|
11215
|
-
const result = value.toString();
|
|
11215
|
+
const result = recordIdKey(value) ?? value.toString();
|
|
11216
11216
|
this.logger.trace({
|
|
11217
11217
|
result,
|
|
11218
11218
|
Category: "sp00ky-client::StreamProcessorService::normalizeValue"
|
|
@@ -11445,6 +11445,13 @@ var AuthService = class {
|
|
|
11445
11445
|
/** Target of an impersonation that just ended, until the client purges its
|
|
11446
11446
|
* local bucket (see `consumeEndedImpersonation`). */
|
|
11447
11447
|
endedImpersonationTarget = null;
|
|
11448
|
+
/** The way back to the admin in flight. The Stop button, the session poll,
|
|
11449
|
+
* a refused renewal and a rejected check can all end an impersonation, and
|
|
11450
|
+
* they share this one: a second stop running beside the first found the
|
|
11451
|
+
* admin token already taken and signed the admin out. */
|
|
11452
|
+
stopping = null;
|
|
11453
|
+
/** A token renewal in flight, which a stop waits out (see `renewImpersonation`). */
|
|
11454
|
+
renewing = null;
|
|
11448
11455
|
impersonationTimers = new ImpersonationTimers({
|
|
11449
11456
|
current: () => this.impersonation,
|
|
11450
11457
|
renew: () => this.renewImpersonation(),
|
|
@@ -11595,9 +11602,18 @@ var AuthService = class {
|
|
|
11595
11602
|
* a full sign-out rather than staying on the target.
|
|
11596
11603
|
*/
|
|
11597
11604
|
async stopImpersonating(opts = {}) {
|
|
11605
|
+
if (this.stopping) return this.stopping;
|
|
11606
|
+
if (!this.impersonation) return;
|
|
11607
|
+
this.stopping = this.leaveImpersonation(opts).finally(() => {
|
|
11608
|
+
this.stopping = null;
|
|
11609
|
+
});
|
|
11610
|
+
return this.stopping;
|
|
11611
|
+
}
|
|
11612
|
+
async leaveImpersonation(opts) {
|
|
11613
|
+
this.impersonationTimers.clear();
|
|
11614
|
+
await this.renewing?.catch(() => void 0);
|
|
11598
11615
|
const info = this.impersonation;
|
|
11599
11616
|
if (!info) return;
|
|
11600
|
-
this.impersonationTimers.clear();
|
|
11601
11617
|
if (!opts.serverEnded) try {
|
|
11602
11618
|
await this.remote.query("RETURN fn::_00_impersonate::stop($session)", { session: info.session });
|
|
11603
11619
|
} catch (error) {
|
|
@@ -11632,9 +11648,19 @@ var AuthService = class {
|
|
|
11632
11648
|
}
|
|
11633
11649
|
/** Re-sign the impersonation token before it expires. */
|
|
11634
11650
|
async renewImpersonation() {
|
|
11635
|
-
if (!this.impersonation) return;
|
|
11651
|
+
if (!this.impersonation || this.stopping) return;
|
|
11652
|
+
const renewal = this.applyRenewal();
|
|
11653
|
+
this.renewing = renewal;
|
|
11654
|
+
try {
|
|
11655
|
+
await renewal;
|
|
11656
|
+
} finally {
|
|
11657
|
+
if (this.renewing === renewal) this.renewing = null;
|
|
11658
|
+
}
|
|
11659
|
+
}
|
|
11660
|
+
async applyRenewal() {
|
|
11636
11661
|
const [renewed] = await this.remote.query("RETURN fn::_00_impersonate::renew()");
|
|
11637
11662
|
if (!renewed?.token) throw new Error("Impersonation renewal returned no token.");
|
|
11663
|
+
if (this.stopping || !this.impersonation) return;
|
|
11638
11664
|
this.remote.setAuthToken(renewed.token);
|
|
11639
11665
|
await this.remote.getClient().authenticate(renewed.token);
|
|
11640
11666
|
this.token = renewed.token;
|
|
@@ -11700,6 +11726,7 @@ var AuthService = class {
|
|
|
11700
11726
|
async check(accessToken) {
|
|
11701
11727
|
this.isLoading = true;
|
|
11702
11728
|
let token;
|
|
11729
|
+
let accepted = false;
|
|
11703
11730
|
try {
|
|
11704
11731
|
token = accessToken || await this.persistenceClient.get("sp00ky_auth_token") || void 0;
|
|
11705
11732
|
if (!token) {
|
|
@@ -11711,6 +11738,7 @@ var AuthService = class {
|
|
|
11711
11738
|
}
|
|
11712
11739
|
this.remote.setAuthToken(token);
|
|
11713
11740
|
await this.remote.getClient().authenticate(token);
|
|
11741
|
+
accepted = true;
|
|
11714
11742
|
const result = await this.remote.query("SELECT * FROM ONLY $auth.id");
|
|
11715
11743
|
const items = Array.isArray(result) && Array.isArray(result[0]) ? result[0] : result;
|
|
11716
11744
|
const user = Array.isArray(items) ? items[0] : items;
|
|
@@ -11741,7 +11769,13 @@ var AuthService = class {
|
|
|
11741
11769
|
error,
|
|
11742
11770
|
Category: "sp00ky-client::AuthService::check"
|
|
11743
11771
|
}, "Auth check unreachable; keeping the cached session and retrying later");
|
|
11744
|
-
else {
|
|
11772
|
+
else if (accepted && token) {
|
|
11773
|
+
this.logger.warn({
|
|
11774
|
+
error,
|
|
11775
|
+
Category: "sp00ky-client::AuthService::check"
|
|
11776
|
+
}, "Auth check accepted the token but could not read the user; keeping the session");
|
|
11777
|
+
await this.keepAcceptedSession(token);
|
|
11778
|
+
} else {
|
|
11745
11779
|
this.logger.error({
|
|
11746
11780
|
error,
|
|
11747
11781
|
stack: error.stack,
|
|
@@ -11753,6 +11787,17 @@ var AuthService = class {
|
|
|
11753
11787
|
this.isLoading = false;
|
|
11754
11788
|
}
|
|
11755
11789
|
}
|
|
11790
|
+
/** Hold a session the server accepted without its user row: the id comes
|
|
11791
|
+
* from the token, as on a warm boot. A token naming no user is refused. */
|
|
11792
|
+
async keepAcceptedSession(token) {
|
|
11793
|
+
if (this.isAuthenticated && this.token === token && this.currentUser) return;
|
|
11794
|
+
const { userId } = decodeTokenClaims(token);
|
|
11795
|
+
if (!userId) {
|
|
11796
|
+
await this.rejectToken(token);
|
|
11797
|
+
return;
|
|
11798
|
+
}
|
|
11799
|
+
await this.setSession(token, { id: userId });
|
|
11800
|
+
}
|
|
11756
11801
|
/**
|
|
11757
11802
|
* The server refused `token`. A refused impersonation token (stopped,
|
|
11758
11803
|
* expired, or revoked) returns to the admin session instead of signing the
|
|
@@ -14603,7 +14648,7 @@ function createTabsCoordinator(config, s, host) {
|
|
|
14603
14648
|
return new TabsCoordinator({
|
|
14604
14649
|
tabId: s.tabId,
|
|
14605
14650
|
fingerprint: computeTabsFingerprint({
|
|
14606
|
-
coreVersion: "0.0.1-canary.
|
|
14651
|
+
coreVersion: "0.0.1-canary.316",
|
|
14607
14652
|
schemaHash: hash53(config.schemaSurql),
|
|
14608
14653
|
endpoint: config.database.endpoint ?? "",
|
|
14609
14654
|
namespace: config.database.namespace,
|
package/dist/sqlite-open.js
CHANGED
|
@@ -88,13 +88,32 @@ async function resolveOne(parents, relation, fetcher, depth, path) {
|
|
|
88
88
|
function looksLikeRecordId(value) {
|
|
89
89
|
if (value == null) return false;
|
|
90
90
|
if (typeof value === "string") return /^[A-Za-z_][A-Za-z0-9_]*:.+/.test(value);
|
|
91
|
-
if (typeof value === "object")
|
|
92
|
-
const rid = value;
|
|
93
|
-
return rid.tb !== void 0 && rid.id !== void 0;
|
|
94
|
-
}
|
|
91
|
+
if (typeof value === "object") return recordIdKey(value) !== null;
|
|
95
92
|
return false;
|
|
96
93
|
}
|
|
97
94
|
/**
|
|
95
|
+
* The raw `table:id` key of a RecordId, duck-typed: the SDK 1.x shape
|
|
96
|
+
* `{ tb, id }` or the SDK 2.x one, whose `table` is a `Table` (both behind
|
|
97
|
+
* getters). `null` for anything else.
|
|
98
|
+
*
|
|
99
|
+
* Never the SDK's `toString()`: that is SurrealQL, escaped. SDK 2.1.0 escapes
|
|
100
|
+
* every key that starts with a digit (`game:⟨476g…⟩`), while the store and
|
|
101
|
+
* server membership spell the same id `game:476g…`, so a key built from
|
|
102
|
+
* `toString()` silently missed about a quarter of random ids. A complex
|
|
103
|
+
* (array/object) key has no raw spelling and returns `null`.
|
|
104
|
+
*/
|
|
105
|
+
function recordIdKey(value) {
|
|
106
|
+
const rid = value;
|
|
107
|
+
if (rid.id === void 0) return null;
|
|
108
|
+
if (rid.tb !== void 0) return `${String(rid.tb)}:${stableKey(rid.id)}`;
|
|
109
|
+
const proto = Object.getPrototypeOf(value);
|
|
110
|
+
if (proto === Object.prototype || proto === null) return null;
|
|
111
|
+
const name = rid.table?.name;
|
|
112
|
+
if (typeof rid.table !== "object" || typeof name !== "string") return null;
|
|
113
|
+
if (typeof rid.id === "string" || typeof rid.id === "number" || typeof rid.id === "bigint") return `${name}:${String(rid.id)}`;
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
98
117
|
* Stable grouping key for a correlation value. A `RecordId` (duck-typed to
|
|
99
118
|
* avoid a hard surrealdb dependency here) and its `table:id` string form must
|
|
100
119
|
* collapse to the same key so a parent FK stored either way matches the child.
|
|
@@ -104,8 +123,9 @@ function stableKey(value) {
|
|
|
104
123
|
if (typeof value === "string") return value;
|
|
105
124
|
if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") return String(value);
|
|
106
125
|
if (typeof value === "object") {
|
|
126
|
+
const key = recordIdKey(value);
|
|
127
|
+
if (key !== null) return key;
|
|
107
128
|
const rid = value;
|
|
108
|
-
if (rid.tb !== void 0 && rid.id !== void 0) return `${String(rid.tb)}:${stableKey(rid.id)}`;
|
|
109
129
|
if (typeof rid.toString === "function") {
|
|
110
130
|
const s = rid.toString();
|
|
111
131
|
if (s !== "[object Object]") return s;
|
|
@@ -173,11 +193,12 @@ function scalar(value) {
|
|
|
173
193
|
return value;
|
|
174
194
|
}
|
|
175
195
|
function serializeRow(row) {
|
|
176
|
-
return JSON.stringify(row, (
|
|
196
|
+
return JSON.stringify(row, function(k, v) {
|
|
177
197
|
if (v instanceof Uint8Array) return { __u8: toBase64(v) };
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
198
|
+
const raw = this[k];
|
|
199
|
+
if (raw && typeof raw === "object") {
|
|
200
|
+
const key = recordIdKey(raw);
|
|
201
|
+
if (key !== null) return key;
|
|
181
202
|
}
|
|
182
203
|
return v;
|
|
183
204
|
});
|
|
@@ -355,4 +376,4 @@ async function openDb(sqlite3, dbName, useOpfs, opts = {}) {
|
|
|
355
376
|
}
|
|
356
377
|
|
|
357
378
|
//#endregion
|
|
358
|
-
export { renderOrderSql as a, serializeMergeRow as c,
|
|
379
|
+
export { renderOrderSql as a, serializeMergeRow as c, resolveRelations as d, stableKey as f, projectedDataSql as i, serializeRow as l, openDb as n, renderWhereSql as o, project as r, reviveRow as s, PROMOTION_OPEN_OPTIONS as t, recordIdKey as u };
|
package/dist/sqlite-worker.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { a as renderOrderSql, d as stableKey, i as projectedDataSql, n as openDb, o as renderWhereSql, r as project, s as reviveRow
|
|
1
|
+
import { a as renderOrderSql, d as resolveRelations, f as stableKey, i as projectedDataSql, n as openDb, o as renderWhereSql, r as project, s as reviveRow } from "./sqlite-open.js";
|
|
2
2
|
import sqlite3InitModule from "@sqlite.org/sqlite-wasm";
|
|
3
3
|
|
|
4
4
|
//#region src/services/database/sqlite-lock-verify.ts
|
package/dist/types.d.ts
CHANGED
|
@@ -793,6 +793,13 @@ declare class AuthService<S extends SchemaStructure> {
|
|
|
793
793
|
/** Target of an impersonation that just ended, until the client purges its
|
|
794
794
|
* local bucket (see `consumeEndedImpersonation`). */
|
|
795
795
|
private endedImpersonationTarget;
|
|
796
|
+
/** The way back to the admin in flight. The Stop button, the session poll,
|
|
797
|
+
* a refused renewal and a rejected check can all end an impersonation, and
|
|
798
|
+
* they share this one: a second stop running beside the first found the
|
|
799
|
+
* admin token already taken and signed the admin out. */
|
|
800
|
+
private stopping;
|
|
801
|
+
/** A token renewal in flight, which a stop waits out (see `renewImpersonation`). */
|
|
802
|
+
private renewing;
|
|
796
803
|
private impersonationTimers;
|
|
797
804
|
get eventSystem(): AuthEventSystem;
|
|
798
805
|
constructor(schema: S, remote: RemoteDatabaseService, persistenceClient: PersistenceClient, logger: Logger$1);
|
|
@@ -839,8 +846,10 @@ declare class AuthService<S extends SchemaStructure> {
|
|
|
839
846
|
stopImpersonating(opts?: {
|
|
840
847
|
serverEnded?: boolean;
|
|
841
848
|
}): Promise<void>;
|
|
849
|
+
private leaveImpersonation;
|
|
842
850
|
/** Re-sign the impersonation token before it expires. */
|
|
843
851
|
renewImpersonation(): Promise<void>;
|
|
852
|
+
private applyRenewal;
|
|
844
853
|
/** Users an admin may pick from. Admin-only on the server. */
|
|
845
854
|
searchImpersonationTargets(search: string): Promise<ImpersonationCandidate[]>;
|
|
846
855
|
/** Open impersonation sessions. Admin-only on the server. */
|
|
@@ -872,6 +881,9 @@ declare class AuthService<S extends SchemaStructure> {
|
|
|
872
881
|
* Check for existing session and validate
|
|
873
882
|
*/
|
|
874
883
|
check(accessToken?: string): Promise<void>;
|
|
884
|
+
/** Hold a session the server accepted without its user row: the id comes
|
|
885
|
+
* from the token, as on a warm boot. A token naming no user is refused. */
|
|
886
|
+
private keepAcceptedSession;
|
|
875
887
|
/**
|
|
876
888
|
* The server refused `token`. A refused impersonation token (stopped,
|
|
877
889
|
* expired, or revoked) returns to the admin session instead of signing the
|
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.316",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"sideEffects": false,
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -75,8 +75,8 @@
|
|
|
75
75
|
}
|
|
76
76
|
},
|
|
77
77
|
"dependencies": {
|
|
78
|
-
"@spooky-sync/query-builder": "0.0.1-canary.
|
|
79
|
-
"@spooky-sync/ssp-wasm": "0.0.1-canary.
|
|
78
|
+
"@spooky-sync/query-builder": "0.0.1-canary.316",
|
|
79
|
+
"@spooky-sync/ssp-wasm": "0.0.1-canary.316",
|
|
80
80
|
"@sqlite.org/sqlite-wasm": "3.53.0-build1",
|
|
81
81
|
"@surrealdb/wasm": "^3.0.3",
|
|
82
82
|
"blurhash": "^2.0.5",
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { createHmac } from 'node:crypto';
|
|
5
|
+
import { Surreal } from 'surrealdb';
|
|
6
|
+
import { AuthService } from './index';
|
|
7
|
+
import { ORIGIN_TOKEN_KEY } from './impersonation';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Impersonation start and stop against a real SurrealDB (the WASM engine) and
|
|
11
|
+
* the real SDK: record access for the admin, the `_00_impersonate` access with
|
|
12
|
+
* the same AUTHENTICATE revocation block the CLI generates, and the session
|
|
13
|
+
* functions minus the HTTP mint (the token is signed here, the way
|
|
14
|
+
* `ssp_protocol::impersonation::mint` signs it).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const KEY = 'test-impersonation-key';
|
|
18
|
+
const b64 = (v: string | Buffer) => Buffer.from(v).toString('base64url');
|
|
19
|
+
|
|
20
|
+
function mintImpersonation(session: string, target: string, admin: string, ttlSecs = 900): string {
|
|
21
|
+
const now = Math.floor(Date.now() / 1000);
|
|
22
|
+
const header = b64(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
|
|
23
|
+
const claims = b64(
|
|
24
|
+
JSON.stringify({
|
|
25
|
+
iss: 'SurrealDB',
|
|
26
|
+
iat: now,
|
|
27
|
+
nbf: now,
|
|
28
|
+
exp: now + ttlSecs,
|
|
29
|
+
NS: 'n',
|
|
30
|
+
DB: 'd',
|
|
31
|
+
AC: '_00_impersonate',
|
|
32
|
+
ID: target,
|
|
33
|
+
spky_imp: session,
|
|
34
|
+
spky_admin: admin,
|
|
35
|
+
spky_as_access: 'account',
|
|
36
|
+
})
|
|
37
|
+
);
|
|
38
|
+
const sig = createHmac('sha256', KEY).update(`${header}.${claims}`).digest('base64url');
|
|
39
|
+
return `${header}.${claims}.${sig}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const SCHEMA = `
|
|
43
|
+
DEFINE TABLE user SCHEMALESS PERMISSIONS FOR select WHERE true FOR create, update, delete NONE;
|
|
44
|
+
DEFINE ACCESS account ON DATABASE TYPE RECORD
|
|
45
|
+
SIGNIN (SELECT * FROM user WHERE email = $email AND pass = $pass)
|
|
46
|
+
DURATION FOR TOKEN 365d, FOR SESSION 365d;
|
|
47
|
+
DEFINE TABLE _00_admin SCHEMALESS PERMISSIONS NONE;
|
|
48
|
+
DEFINE TABLE _00_impersonation SCHEMALESS PERMISSIONS FOR select, create, update WHERE true;
|
|
49
|
+
DEFINE ACCESS _00_impersonate ON DATABASE TYPE RECORD
|
|
50
|
+
WITH JWT ALGORITHM HS256 KEY '${KEY}'
|
|
51
|
+
AUTHENTICATE {
|
|
52
|
+
LET $s = (SELECT * FROM ONLY type::record($token.spky_imp));
|
|
53
|
+
IF $s = NONE OR $s.ended_at != NONE OR <string>$s.target != <string>$auth.id {
|
|
54
|
+
THROW 'Impersonation session is no longer valid.';
|
|
55
|
+
};
|
|
56
|
+
IF array::len((SELECT VALUE id FROM _00_admin WHERE user = $s.admin LIMIT 1)) = 0 {
|
|
57
|
+
THROW 'Impersonation session is no longer valid.';
|
|
58
|
+
};
|
|
59
|
+
RETURN $auth;
|
|
60
|
+
}
|
|
61
|
+
DURATION FOR TOKEN 15m, FOR SESSION 15m;
|
|
62
|
+
DEFINE FUNCTION fn::_00_impersonate::start($target: string, $reason: string) {
|
|
63
|
+
IF $access = '_00_impersonate' { THROW 'Cannot start impersonation from an impersonated session.'; };
|
|
64
|
+
UPSERT _00_impersonation:s1 SET target = type::record($target), admin = $auth.id, reason = $reason, ended_at = NONE;
|
|
65
|
+
RETURN { token: $test_imp_token, session: '_00_impersonation:s1' };
|
|
66
|
+
} PERMISSIONS FULL;
|
|
67
|
+
DEFINE FUNCTION fn::_00_impersonate::stop($session: string) {
|
|
68
|
+
UPDATE type::record($session) SET ended_at = time::now();
|
|
69
|
+
RETURN NONE;
|
|
70
|
+
} PERMISSIONS FULL;
|
|
71
|
+
DEFINE FUNCTION fn::_00_impersonate::renew() {
|
|
72
|
+
IF $access != '_00_impersonate' { THROW 'Not an impersonated session.'; };
|
|
73
|
+
RETURN { token: $test_imp_token_renewed };
|
|
74
|
+
} PERMISSIONS FULL;
|
|
75
|
+
DEFINE FUNCTION fn::_00_impersonate::current() {
|
|
76
|
+
IF $access != '_00_impersonate' { RETURN NONE; };
|
|
77
|
+
LET $s = (SELECT * FROM ONLY type::record($token.spky_imp));
|
|
78
|
+
RETURN IF $s = NONE OR $s.ended_at != NONE { NONE } ELSE { { session: <string>$s.id } };
|
|
79
|
+
} PERMISSIONS FULL;
|
|
80
|
+
CREATE user:alice SET email = 'alice@x', pass = 'pw';
|
|
81
|
+
CREATE user:bob SET email = 'bob@x', pass = 'pw';
|
|
82
|
+
CREATE _00_admin:alice SET user = user:alice;
|
|
83
|
+
`;
|
|
84
|
+
|
|
85
|
+
const silentLogger = (): any => {
|
|
86
|
+
const l: any = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), trace: vi.fn() };
|
|
87
|
+
l.child = () => l;
|
|
88
|
+
return l;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
describe('impersonation on the SurrealDB-WASM engine', () => {
|
|
92
|
+
let db: Surreal;
|
|
93
|
+
let adminToken: string;
|
|
94
|
+
|
|
95
|
+
beforeAll(async () => {
|
|
96
|
+
const realFetch = globalThis.fetch;
|
|
97
|
+
vi.stubGlobal('fetch', async (input: unknown, init?: RequestInit) => {
|
|
98
|
+
const url = input instanceof URL ? input : new URL(String(input));
|
|
99
|
+
if (url.protocol === 'file:') return new Response(await readFile(fileURLToPath(url)));
|
|
100
|
+
return realFetch(input as RequestInfo, init);
|
|
101
|
+
});
|
|
102
|
+
const { createWasmEngines } = await import('@surrealdb/wasm');
|
|
103
|
+
db = new Surreal({ engines: createWasmEngines() });
|
|
104
|
+
await db.connect('mem://');
|
|
105
|
+
await db.use({ namespace: 'n', database: 'd' });
|
|
106
|
+
await db.query(SCHEMA);
|
|
107
|
+
await db.query(`DEFINE PARAM $test_imp_token VALUE '${mintImpersonation('_00_impersonation:s1', 'user:bob', 'user:alice')}' PERMISSIONS FULL;`);
|
|
108
|
+
await db.query(`DEFINE PARAM $test_imp_token_renewed VALUE '${mintImpersonation('_00_impersonation:s1', 'user:bob', 'user:alice', 901)}' PERMISSIONS FULL;`);
|
|
109
|
+
const tokens = await db.signin({ access: 'account', variables: { email: 'alice@x', pass: 'pw' } });
|
|
110
|
+
adminToken = typeof tokens === 'string' ? tokens : (tokens as { access: string }).access;
|
|
111
|
+
}, 60_000);
|
|
112
|
+
|
|
113
|
+
afterAll(async () => {
|
|
114
|
+
await db?.close();
|
|
115
|
+
vi.unstubAllGlobals();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
function makeAuth(opts: { failUserRead?: () => boolean; holdStop?: Promise<void> } = {}) {
|
|
119
|
+
const store = new Map<string, unknown>();
|
|
120
|
+
const persistence = {
|
|
121
|
+
get: async (k: string) => store.get(k) ?? null,
|
|
122
|
+
set: async (k: string, v: unknown) => void store.set(k, v),
|
|
123
|
+
remove: async (k: string) => void store.delete(k),
|
|
124
|
+
} as any;
|
|
125
|
+
const remote = {
|
|
126
|
+
query: async (sql: string, vars?: Record<string, unknown>) => {
|
|
127
|
+
if (sql.includes('$auth.id') && opts.failUserRead?.()) {
|
|
128
|
+
throw new Error('There was a problem with the database: the query was cancelled');
|
|
129
|
+
}
|
|
130
|
+
if (sql.includes('fn::_00_impersonate::stop') && opts.holdStop) await opts.holdStop;
|
|
131
|
+
return db.query(sql, vars);
|
|
132
|
+
},
|
|
133
|
+
setAuthToken: () => undefined,
|
|
134
|
+
getClient: () => db,
|
|
135
|
+
} as any;
|
|
136
|
+
return { auth: new AuthService({} as any, remote, persistence, silentLogger()), store };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
it('stop returns to the admin', async () => {
|
|
140
|
+
const { auth, store } = makeAuth();
|
|
141
|
+
await auth.check(adminToken);
|
|
142
|
+
expect(String(auth.currentUser.id)).toBe('user:alice');
|
|
143
|
+
|
|
144
|
+
await auth.impersonate('user:bob', 'support');
|
|
145
|
+
expect(String(auth.currentUser.id)).toBe('user:bob');
|
|
146
|
+
|
|
147
|
+
await auth.stopImpersonating();
|
|
148
|
+
expect(auth.isAuthenticated).toBe(true);
|
|
149
|
+
expect(String(auth.currentUser.id)).toBe('user:alice');
|
|
150
|
+
expect(store.get('sp00ky_auth_token')).toBe(adminToken);
|
|
151
|
+
expect(store.has(ORIGIN_TOKEN_KEY)).toBe(false);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('an explicit stop racing a server-ended stop still returns to the admin', async () => {
|
|
155
|
+
let release!: () => void;
|
|
156
|
+
const holdStop = new Promise<void>((r) => (release = r));
|
|
157
|
+
const { auth } = makeAuth({ holdStop });
|
|
158
|
+
await auth.check(adminToken);
|
|
159
|
+
await auth.impersonate('user:bob', 'support');
|
|
160
|
+
|
|
161
|
+
// The Stop button's stop is waiting on the server when the poll notices
|
|
162
|
+
// the end (or a renewal is refused). Run apart, the poll's stop returned
|
|
163
|
+
// to the admin and the button's, finding the admin token taken, signed
|
|
164
|
+
// the admin out. Now the second joins the first.
|
|
165
|
+
const explicit = auth.stopImpersonating();
|
|
166
|
+
const fromPoll = auth.stopImpersonating({ serverEnded: true });
|
|
167
|
+
release();
|
|
168
|
+
await Promise.all([explicit, fromPoll]);
|
|
169
|
+
|
|
170
|
+
expect(auth.isAuthenticated).toBe(true);
|
|
171
|
+
expect(String(auth.currentUser.id)).toBe('user:alice');
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('a failed read of the admin row on the way back keeps the admin signed in', async () => {
|
|
175
|
+
let failNext = false;
|
|
176
|
+
const { auth, store } = makeAuth({ failUserRead: () => (failNext ? ((failNext = false), true) : false) });
|
|
177
|
+
await auth.check(adminToken);
|
|
178
|
+
await auth.impersonate('user:bob', 'support');
|
|
179
|
+
|
|
180
|
+
failNext = true;
|
|
181
|
+
await auth.stopImpersonating();
|
|
182
|
+
|
|
183
|
+
expect(auth.isAuthenticated).toBe(true);
|
|
184
|
+
expect(String(auth.currentUser.id)).toBe('user:alice');
|
|
185
|
+
expect(store.get('sp00ky_auth_token')).toBe(adminToken);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('stop after half an hour of renewals and polls returns to the admin', async () => {
|
|
189
|
+
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'] });
|
|
190
|
+
try {
|
|
191
|
+
const { auth } = makeAuth();
|
|
192
|
+
await auth.check(adminToken);
|
|
193
|
+
await auth.impersonate('user:bob', 'support');
|
|
194
|
+
await vi.advanceTimersByTimeAsync(30 * 60 * 1000);
|
|
195
|
+
expect(String(auth.currentUser.id)).toBe('user:bob');
|
|
196
|
+
|
|
197
|
+
await auth.stopImpersonating();
|
|
198
|
+
|
|
199
|
+
expect(auth.isAuthenticated).toBe(true);
|
|
200
|
+
expect(String(auth.currentUser.id)).toBe('user:alice');
|
|
201
|
+
} finally {
|
|
202
|
+
vi.useRealTimers();
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
});
|
|
@@ -260,6 +260,28 @@ describe('timers', () => {
|
|
|
260
260
|
expect(auth.impersonation).toBeNull();
|
|
261
261
|
});
|
|
262
262
|
|
|
263
|
+
it('a renewal in flight when stop begins does not put the target back on the socket', async () => {
|
|
264
|
+
const { auth, server, store } = await signedInAdmin();
|
|
265
|
+
await auth.impersonate('user:bob', 'support ticket');
|
|
266
|
+
let releaseRenew!: () => void;
|
|
267
|
+
const held = new Promise<void>((r) => (releaseRenew = r));
|
|
268
|
+
const answer = server.query.getMockImplementation()!;
|
|
269
|
+
server.query.mockImplementation(async (sql: string, vars?: Record<string, unknown>) => {
|
|
270
|
+
if (sql.includes('fn::_00_impersonate::renew')) await held;
|
|
271
|
+
return answer(sql, vars);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
const renewal = auth.renewImpersonation();
|
|
275
|
+
const stop = auth.stopImpersonating();
|
|
276
|
+
releaseRenew();
|
|
277
|
+
await Promise.all([renewal, stop]);
|
|
278
|
+
|
|
279
|
+
expect(auth.currentUser.id).toBe('user:alice');
|
|
280
|
+
expect(auth.token).toBe(ADMIN);
|
|
281
|
+
expect(server.state.socketToken).toBe(ADMIN);
|
|
282
|
+
expect(store.get('sp00ky_auth_token')).toBe(ADMIN);
|
|
283
|
+
});
|
|
284
|
+
|
|
263
285
|
it('keeps going through network failures on the poll', async () => {
|
|
264
286
|
let calls = 0;
|
|
265
287
|
const host = {
|
|
@@ -103,6 +103,13 @@ export class AuthService<S extends SchemaStructure> {
|
|
|
103
103
|
/** Target of an impersonation that just ended, until the client purges its
|
|
104
104
|
* local bucket (see `consumeEndedImpersonation`). */
|
|
105
105
|
private endedImpersonationTarget: string | null = null;
|
|
106
|
+
/** The way back to the admin in flight. The Stop button, the session poll,
|
|
107
|
+
* a refused renewal and a rejected check can all end an impersonation, and
|
|
108
|
+
* they share this one: a second stop running beside the first found the
|
|
109
|
+
* admin token already taken and signed the admin out. */
|
|
110
|
+
private stopping: Promise<void> | null = null;
|
|
111
|
+
/** A token renewal in flight, which a stop waits out (see `renewImpersonation`). */
|
|
112
|
+
private renewing: Promise<void> | null = null;
|
|
106
113
|
private impersonationTimers = new ImpersonationTimers({
|
|
107
114
|
current: () => this.impersonation,
|
|
108
115
|
renew: () => this.renewImpersonation(),
|
|
@@ -272,9 +279,22 @@ export class AuthService<S extends SchemaStructure> {
|
|
|
272
279
|
* a full sign-out rather than staying on the target.
|
|
273
280
|
*/
|
|
274
281
|
async stopImpersonating(opts: { serverEnded?: boolean } = {}): Promise<void> {
|
|
282
|
+
if (this.stopping) return this.stopping;
|
|
283
|
+
if (!this.impersonation) return;
|
|
284
|
+
this.stopping = this.leaveImpersonation(opts).finally(() => {
|
|
285
|
+
this.stopping = null;
|
|
286
|
+
});
|
|
287
|
+
return this.stopping;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
private async leaveImpersonation(opts: { serverEnded?: boolean }): Promise<void> {
|
|
291
|
+
this.impersonationTimers.clear();
|
|
292
|
+
// A renewal already sent finishes first, so it cannot put the target's
|
|
293
|
+
// token back on the socket after the admin's (it sees `stopping` and
|
|
294
|
+
// stands down).
|
|
295
|
+
await this.renewing?.catch(() => undefined);
|
|
275
296
|
const info = this.impersonation;
|
|
276
297
|
if (!info) return;
|
|
277
|
-
this.impersonationTimers.clear();
|
|
278
298
|
if (!opts.serverEnded) {
|
|
279
299
|
try {
|
|
280
300
|
await this.remote.query('RETURN fn::_00_impersonate::stop($session)', { session: info.session });
|
|
@@ -314,9 +334,22 @@ export class AuthService<S extends SchemaStructure> {
|
|
|
314
334
|
|
|
315
335
|
/** Re-sign the impersonation token before it expires. */
|
|
316
336
|
async renewImpersonation(): Promise<void> {
|
|
317
|
-
if (!this.impersonation) return;
|
|
337
|
+
if (!this.impersonation || this.stopping) return;
|
|
338
|
+
const renewal = this.applyRenewal();
|
|
339
|
+
this.renewing = renewal;
|
|
340
|
+
try {
|
|
341
|
+
await renewal;
|
|
342
|
+
} finally {
|
|
343
|
+
if (this.renewing === renewal) this.renewing = null;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
private async applyRenewal(): Promise<void> {
|
|
318
348
|
const [renewed] = await this.remote.query<[{ token: string }]>('RETURN fn::_00_impersonate::renew()');
|
|
319
349
|
if (!renewed?.token) throw new Error('Impersonation renewal returned no token.');
|
|
350
|
+
// A stop began while the server was signing: the session is being left,
|
|
351
|
+
// so a fresh token for it must not go on the socket or into storage.
|
|
352
|
+
if (this.stopping || !this.impersonation) return;
|
|
320
353
|
this.remote.setAuthToken(renewed.token);
|
|
321
354
|
await this.remote.getClient().authenticate(renewed.token);
|
|
322
355
|
this.token = renewed.token;
|
|
@@ -399,6 +432,9 @@ export class AuthService<S extends SchemaStructure> {
|
|
|
399
432
|
this.isLoading = true;
|
|
400
433
|
|
|
401
434
|
let token: string | undefined;
|
|
435
|
+
// Set once the server has accepted the token: from then on a failure is a
|
|
436
|
+
// failure to read the user row, not a verdict on the session.
|
|
437
|
+
let accepted = false;
|
|
402
438
|
try {
|
|
403
439
|
token = accessToken || (await this.persistenceClient.get<string>('sp00ky_auth_token')) || undefined;
|
|
404
440
|
|
|
@@ -418,6 +454,7 @@ export class AuthService<S extends SchemaStructure> {
|
|
|
418
454
|
// `RemoteDatabaseService.setAuthToken`).
|
|
419
455
|
this.remote.setAuthToken(token);
|
|
420
456
|
await this.remote.getClient().authenticate(token);
|
|
457
|
+
accepted = true;
|
|
421
458
|
|
|
422
459
|
// Verify the session by fetching the full user record using $auth.id
|
|
423
460
|
const result = await this.remote.query('SELECT * FROM ONLY $auth.id');
|
|
@@ -471,6 +508,17 @@ export class AuthService<S extends SchemaStructure> {
|
|
|
471
508
|
{ error, Category: 'sp00ky-client::AuthService::check' },
|
|
472
509
|
'Auth check unreachable; keeping the cached session and retrying later'
|
|
473
510
|
);
|
|
511
|
+
} else if (accepted && token) {
|
|
512
|
+
// The server took the token and only the read of the user row failed
|
|
513
|
+
// (a busy database, a statement error). Signing out here logged an
|
|
514
|
+
// admin out on the way back from an impersonation while the database
|
|
515
|
+
// was saturated. Keep the session on the id the token names; the row
|
|
516
|
+
// arrives with the next check or the app's own user query.
|
|
517
|
+
this.logger.warn(
|
|
518
|
+
{ error, Category: 'sp00ky-client::AuthService::check' },
|
|
519
|
+
'Auth check accepted the token but could not read the user; keeping the session'
|
|
520
|
+
);
|
|
521
|
+
await this.keepAcceptedSession(token);
|
|
474
522
|
} else {
|
|
475
523
|
this.logger.error(
|
|
476
524
|
{ error, stack: (error as Error).stack, Category: 'sp00ky-client::AuthService::check' },
|
|
@@ -483,6 +531,18 @@ export class AuthService<S extends SchemaStructure> {
|
|
|
483
531
|
}
|
|
484
532
|
}
|
|
485
533
|
|
|
534
|
+
/** Hold a session the server accepted without its user row: the id comes
|
|
535
|
+
* from the token, as on a warm boot. A token naming no user is refused. */
|
|
536
|
+
private async keepAcceptedSession(token: string): Promise<void> {
|
|
537
|
+
if (this.isAuthenticated && this.token === token && this.currentUser) return;
|
|
538
|
+
const { userId } = decodeTokenClaims(token);
|
|
539
|
+
if (!userId) {
|
|
540
|
+
await this.rejectToken(token);
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
await this.setSession(token, { id: userId });
|
|
544
|
+
}
|
|
545
|
+
|
|
486
546
|
/**
|
|
487
547
|
* The server refused `token`. A refused impersonation token (stopped,
|
|
488
548
|
* expired, or revoked) returns to the admin session instead of signing the
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { RecordId } from 'surrealdb';
|
|
2
3
|
import type { RelationPlan, WhereNode } from '@spooky-sync/query-builder';
|
|
3
4
|
import { looksLikeRecordId, resolveRelations, sortRows, stableKey } from './relation-resolver';
|
|
4
5
|
import {
|
|
@@ -306,6 +307,22 @@ describe('stableKey', () => {
|
|
|
306
307
|
expect(stableKey({ tb: 'user', id: '1' })).toBe('user:1');
|
|
307
308
|
expect(stableKey('user:1')).toBe('user:1');
|
|
308
309
|
});
|
|
310
|
+
|
|
311
|
+
it('spells an SDK RecordId raw, never as escaped SurrealQL', () => {
|
|
312
|
+
// SDK 2.1.0 `toString()`s these as `game:⟨476g…⟩`, `t:⟨123⟩`, `t:⟨a-b⟩`.
|
|
313
|
+
expect(stableKey(new RecordId('game', '476gsdlvro7cme8nwied'))).toBe('game:476gsdlvro7cme8nwied');
|
|
314
|
+
expect(stableKey(new RecordId('t', '123'))).toBe('t:123');
|
|
315
|
+
expect(stableKey(new RecordId('t', 'a-b'))).toBe('t:a-b');
|
|
316
|
+
expect(stableKey(new RecordId('t', 7))).toBe('t:7');
|
|
317
|
+
expect(looksLikeRecordId(new RecordId('game', '476gsdlvro7cme8nwied'))).toBe(true);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it('leaves a complex key to toString and a plain row with table/id columns alone', () => {
|
|
321
|
+
const complex = new RecordId('t', [1, 'x']);
|
|
322
|
+
expect(stableKey(complex)).toBe(complex.toString());
|
|
323
|
+
const row = { table: { name: 'x' }, id: 'y' };
|
|
324
|
+
expect(stableKey(row)).toBe(JSON.stringify(row));
|
|
325
|
+
});
|
|
309
326
|
});
|
|
310
327
|
|
|
311
328
|
/**
|
|
Binary file
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { RecordId } from 'surrealdb';
|
|
2
3
|
import type { WhereNode } from '@spooky-sync/query-builder';
|
|
3
4
|
import {
|
|
4
5
|
comparisonSql,
|
|
@@ -96,6 +97,11 @@ describe('serializeRow / reviveRow round-trip', () => {
|
|
|
96
97
|
const json = serializeRow({ id: 't:1', author: { tb: 'user', id: 'u1' }, n: 2 });
|
|
97
98
|
expect(reviveRow(json)).toEqual({ id: 't:1', author: 'user:u1', n: 2 });
|
|
98
99
|
});
|
|
100
|
+
|
|
101
|
+
it('stores an SDK RecordId link raw, not as its escaped toJSON', () => {
|
|
102
|
+
const json = serializeRow({ id: 'game:1', white: new RecordId('player_name', '3abc'), tags: [new RecordId('t', '9z')] });
|
|
103
|
+
expect(reviveRow(json)).toEqual({ id: 'game:1', white: 'player_name:3abc', tags: ['t:9z'] });
|
|
104
|
+
});
|
|
99
105
|
});
|
|
100
106
|
|
|
101
107
|
describe('project', () => {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { WhereComparison, WhereNode } from '@spooky-sync/query-builder';
|
|
2
|
-
import { stableKey } from './relation-resolver';
|
|
2
|
+
import { recordIdKey, stableKey } from './relation-resolver';
|
|
3
3
|
import type { OrderBy, Row } from './cache-engine';
|
|
4
4
|
|
|
5
5
|
/**
|
|
@@ -60,11 +60,14 @@ export function scalar(value: unknown): unknown {
|
|
|
60
60
|
}
|
|
61
61
|
|
|
62
62
|
export function serializeRow(row: Row): string {
|
|
63
|
-
return JSON.stringify(row, (
|
|
63
|
+
return JSON.stringify(row, function (this: Record<string, unknown>, k, v) {
|
|
64
64
|
if (v instanceof Uint8Array) return { __u8: toBase64(v) };
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
65
|
+
// `v` has already been through `toJSON()`, and a RecordId's is escaped
|
|
66
|
+
// SurrealQL (`game:⟨476g…⟩`), so key the original value instead.
|
|
67
|
+
const raw = this[k];
|
|
68
|
+
if (raw && typeof raw === 'object') {
|
|
69
|
+
const key = recordIdKey(raw);
|
|
70
|
+
if (key !== null) return key;
|
|
68
71
|
}
|
|
69
72
|
return v;
|
|
70
73
|
});
|
|
@@ -45,6 +45,10 @@ const FIXTURE: Record<string, Row[]> = {
|
|
|
45
45
|
{ id: 'comment:2', thread: 'thread:B', body: 'second' },
|
|
46
46
|
{ id: 'comment:3', thread: 'thread:A', body: 'other thread' },
|
|
47
47
|
],
|
|
48
|
+
game: [
|
|
49
|
+
{ id: 'game:476gsdlvro7cme8nwied', white: 'player_name:a' },
|
|
50
|
+
{ id: 'game:stgyqp7fm3sc4tmyc8fm', white: 'player_name:b' },
|
|
51
|
+
],
|
|
48
52
|
};
|
|
49
53
|
|
|
50
54
|
/** The `.one()` detail-view plan shape: id baked as a literal AND slaved to
|
|
@@ -187,12 +191,23 @@ describe('SqliteCacheEngine.select via worker (one hop)', () => {
|
|
|
187
191
|
const rid = new RecordId('comment', '2');
|
|
188
192
|
const { engine, calls } = makeEngine({ workerSelect: true, answerSelect: true });
|
|
189
193
|
await engine.connect('anon');
|
|
190
|
-
await engine.select({ table: 'comment', ids: [rid, 'comment:1'] }, {});
|
|
194
|
+
const rows = await engine.select({ table: 'comment', ids: [rid, 'comment:1'] }, {});
|
|
191
195
|
const sel = calls.find((c) => c.type === 'select')!;
|
|
192
|
-
//
|
|
193
|
-
//
|
|
194
|
-
expect(sel.payload.plan.ids).toEqual([
|
|
195
|
-
expect(
|
|
196
|
+
// The raw spelling the rows are stored under, never the SDK's escaped
|
|
197
|
+
// `comment:⟨2⟩`: that one matched no stored row.
|
|
198
|
+
expect(sel.payload.plan.ids).toEqual(['comment:2', 'comment:1']);
|
|
199
|
+
expect(rows.map((r) => r.id)).toEqual(['comment:2', 'comment:1']);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('finds a window row whose id starts with a digit (SDK 2.1.0 escapes it)', async () => {
|
|
203
|
+
// `materializeEffect` parses every window id into a RecordId, and SDK
|
|
204
|
+
// 2.1.0's `toString()` spells this one `game:⟨476g…⟩`. Keyed that way the
|
|
205
|
+
// row went missing, the window came back short and read as the list end.
|
|
206
|
+
const { engine } = makeEngine({ workerSelect: true, answerSelect: true });
|
|
207
|
+
await engine.connect('anon');
|
|
208
|
+
const ids = [new RecordId('game', '476gsdlvro7cme8nwied'), new RecordId('game', 'stgyqp7fm3sc4tmyc8fm')];
|
|
209
|
+
const rows = await engine.select({ table: 'game', ids }, {});
|
|
210
|
+
expect(rows.map((r) => r.id)).toEqual(['game:476gsdlvro7cme8nwied', 'game:stgyqp7fm3sc4tmyc8fm']);
|
|
196
211
|
});
|
|
197
212
|
|
|
198
213
|
it('normalizes RecordId values baked inside the plan\'s where tree', async () => {
|
|
@@ -7,6 +7,7 @@ import type { WasmProcessor, WasmStreamUpdate } from './wasm-types';
|
|
|
7
7
|
import type { Duration } from 'surrealdb';
|
|
8
8
|
import type { QueryTimeToLive, RecordVersionArray } from '../../types';
|
|
9
9
|
import { encodeRecordId } from '../../utils/index';
|
|
10
|
+
import { recordIdKey } from '../database/relation-resolver';
|
|
10
11
|
|
|
11
12
|
// Simple interface for query plan registration (replaces Incantation class)
|
|
12
13
|
interface QueryPlanConfig {
|
|
@@ -1044,7 +1045,9 @@ export class StreamProcessorService {
|
|
|
1044
1045
|
const isNotPlainObject = value.constructor !== Object;
|
|
1045
1046
|
|
|
1046
1047
|
if (hasTable && hasId && hasToString && isNotPlainObject) {
|
|
1047
|
-
|
|
1048
|
+
// The raw `table:id`, as the store spells it. `toString()` is escaped
|
|
1049
|
+
// SurrealQL (SDK 2.1.0: `game:⟨476g…⟩`); only a complex key uses it.
|
|
1050
|
+
const result = recordIdKey(value) ?? value.toString();
|
|
1048
1051
|
this.logger.trace(
|
|
1049
1052
|
{ result, Category: 'sp00ky-client::StreamProcessorService::normalizeValue' },
|
|
1050
1053
|
'RecordId detected'
|