@spooky-sync/core 0.0.1-canary.201 → 0.0.1-canary.203
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 +67 -6
- package/dist/index.js +221 -51
- 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/sync/sync.ts +7 -0
- 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 +155 -50
package/dist/index.d.ts
CHANGED
|
@@ -1471,6 +1471,24 @@ declare class AuthService<S extends SchemaStructure> {
|
|
|
1471
1471
|
*/
|
|
1472
1472
|
subscribe(cb: (userId: string | null) => void): () => void;
|
|
1473
1473
|
private notifyListeners;
|
|
1474
|
+
/**
|
|
1475
|
+
* Restore a session from the locally cached JWT, with NO network.
|
|
1476
|
+
*
|
|
1477
|
+
* This is what makes a warm boot paint instantly and what makes an offline
|
|
1478
|
+
* boot possible at all: the token is in local storage, and it already carries
|
|
1479
|
+
* both the access method and the `$auth.id` record id. Everything the client
|
|
1480
|
+
* needs to route queries (`setCurrentUserId`) and to satisfy `$auth`-gated
|
|
1481
|
+
* permission predicates in the in-browser SSP (`setSessionAuth`) is therefore
|
|
1482
|
+
* available before a socket exists.
|
|
1483
|
+
*
|
|
1484
|
+
* The session is OPTIMISTIC: the token is unverified here. `check()` runs
|
|
1485
|
+
* afterwards in the background and downgrades to a real sign-out if the
|
|
1486
|
+
* server rejects it. Nothing is trusted that the server has not also seen -
|
|
1487
|
+
* the local store only ever holds rows the server previously sent.
|
|
1488
|
+
*
|
|
1489
|
+
* Returns the restored user id, or null when there is no usable token.
|
|
1490
|
+
*/
|
|
1491
|
+
restoreSessionFromToken(): Promise<string | null>;
|
|
1474
1492
|
/**
|
|
1475
1493
|
* Check for existing session and validate
|
|
1476
1494
|
*/
|
|
@@ -2166,6 +2184,15 @@ declare class Sp00kyClient<S extends SchemaStructure> {
|
|
|
2166
2184
|
private sync;
|
|
2167
2185
|
private devTools;
|
|
2168
2186
|
private crdtManager;
|
|
2187
|
+
/**
|
|
2188
|
+
* True once the LOCAL half of boot is done and the client can serve reads
|
|
2189
|
+
* from the local store. Distinct from being connected: `syncHealth` covers
|
|
2190
|
+
* reaching the server and `storageHealth` covers whether the local store is
|
|
2191
|
+
* durable, but neither says "usable". Consumers gate their first paint on
|
|
2192
|
+
* this, which is what makes a warm boot instant and an offline boot possible.
|
|
2193
|
+
*/
|
|
2194
|
+
private localReady;
|
|
2195
|
+
private saltUserId;
|
|
2169
2196
|
private featureFlags;
|
|
2170
2197
|
private appReleases;
|
|
2171
2198
|
private preloadedHashes;
|
|
@@ -2211,6 +2238,16 @@ declare class Sp00kyClient<S extends SchemaStructure> {
|
|
|
2211
2238
|
*/
|
|
2212
2239
|
private setupCallbacks;
|
|
2213
2240
|
init(): Promise<void>;
|
|
2241
|
+
/**
|
|
2242
|
+
* The network half of boot: connect, verify the restored session, and let the
|
|
2243
|
+
* sync engine catch up. Runs in the background after `init()` has already
|
|
2244
|
+
* resolved, so nothing here is on the paint path.
|
|
2245
|
+
*
|
|
2246
|
+
* Every step is best-effort. A failure leaves the client in exactly the state
|
|
2247
|
+
* a warm offline boot is in - local reads working, writes queued in the
|
|
2248
|
+
* outbox - and the connection supervisor keeps retrying underneath.
|
|
2249
|
+
*/
|
|
2250
|
+
private initRemote;
|
|
2214
2251
|
private bucketSwitchChain;
|
|
2215
2252
|
private pendingBucketTarget;
|
|
2216
2253
|
/**
|
|
@@ -2363,14 +2400,38 @@ declare class Sp00kyClient<S extends SchemaStructure> {
|
|
|
2363
2400
|
[x: string]: /*elided*/any;
|
|
2364
2401
|
}>;
|
|
2365
2402
|
delete(table: string, id: string): Promise<void>;
|
|
2366
|
-
useRemote<T>(fn: (client: Surreal) => Promise<T> | T): Promise<T>;
|
|
2367
2403
|
/**
|
|
2368
|
-
*
|
|
2369
|
-
*
|
|
2370
|
-
* `_00_query` rows. Returns empty string if the query fails (we still
|
|
2371
|
-
* boot, just without session scoping for IDs).
|
|
2404
|
+
* Whether the local store is initialized and reads can be served. See the
|
|
2405
|
+
* `localReady` field: this is deliberately independent of connectivity.
|
|
2372
2406
|
*/
|
|
2373
|
-
|
|
2407
|
+
isLocalReady(): boolean;
|
|
2408
|
+
useRemote<T>(fn: (client: Surreal) => Promise<T> | T): Promise<T>;
|
|
2409
|
+
/**
|
|
2410
|
+
* Mint the salt used for query-id hashing, so two sessions registering the
|
|
2411
|
+
* same logical query get distinct `_00_query` rows.
|
|
2412
|
+
*
|
|
2413
|
+
* Generated LOCALLY, deliberately. This used to be `RETURN <string>session::id()`,
|
|
2414
|
+
* which cost a serial round trip on the critical boot path and resolved to
|
|
2415
|
+
* `''` offline. The value never needed to come from the server: the server
|
|
2416
|
+
* derives its own `clientId` inside `fn::query::register` and *ignores*
|
|
2417
|
+
* whatever the caller passed, and the permission rules that matter gate on
|
|
2418
|
+
* `auth_id = $auth.id` rather than the session (`_00_list_ref`). Session
|
|
2419
|
+
* scoping via `clientId = session::id()` was in fact removed upstream because
|
|
2420
|
+
* it broke a user with two tabs open. All this value has to be is unique per
|
|
2421
|
+
* browser session, which `randomUUID` gives us for free and offline.
|
|
2422
|
+
*/
|
|
2423
|
+
/**
|
|
2424
|
+
* The current principal as the `"table:id"` string the in-browser SSP wants
|
|
2425
|
+
* for `$auth.id`, or null when signed out.
|
|
2426
|
+
*
|
|
2427
|
+
* Tolerates BOTH shapes `currentUser.id` can take, which is the point:
|
|
2428
|
+
* a session restored from the cached token carries a plain string (the JWT's
|
|
2429
|
+
* `ID` claim), while one verified by the server carries a RecordId. Passing
|
|
2430
|
+
* the former to `encodeRecordId` reads `.table` off a string and throws
|
|
2431
|
+
* during boot.
|
|
2432
|
+
*/
|
|
2433
|
+
private sessionAuthId;
|
|
2434
|
+
private mintSessionSalt;
|
|
2374
2435
|
}
|
|
2375
2436
|
//#endregion
|
|
2376
2437
|
//#region src/utils/semver.d.ts
|
package/dist/index.js
CHANGED
|
@@ -5873,6 +5873,7 @@ var Sp00kySync = class Sp00kySync {
|
|
|
5873
5873
|
if (this.isInit) throw new Error("Sp00kySync is already initialized");
|
|
5874
5874
|
this.isInit = true;
|
|
5875
5875
|
await this.scheduler.init({ loadOutbox: this.tabRole !== "follower" });
|
|
5876
|
+
if (this.remote.getStatus() !== "connected") this.needsResubscribe = true;
|
|
5876
5877
|
this.subscribeToReconnect();
|
|
5877
5878
|
this.subscribeToConnectionState();
|
|
5878
5879
|
this.scheduler.syncUp();
|
|
@@ -7093,8 +7094,8 @@ function selfAllowlistedVariant(flag, userId) {
|
|
|
7093
7094
|
|
|
7094
7095
|
//#endregion
|
|
7095
7096
|
//#region src/modules/devtools/index.ts
|
|
7096
|
-
const CORE_VERSION = "0.0.1-canary.
|
|
7097
|
-
const WASM_VERSION = "0.0.1-canary.
|
|
7097
|
+
const CORE_VERSION = "0.0.1-canary.203";
|
|
7098
|
+
const WASM_VERSION = "0.0.1-canary.203";
|
|
7098
7099
|
const SURREAL_VERSION = "3.0.3";
|
|
7099
7100
|
var DevToolsService = class DevToolsService {
|
|
7100
7101
|
eventsHistory = [];
|
|
@@ -7579,26 +7580,44 @@ var DevToolsService = class DevToolsService {
|
|
|
7579
7580
|
//#endregion
|
|
7580
7581
|
//#region src/modules/auth/index.ts
|
|
7581
7582
|
/**
|
|
7582
|
-
* Read the
|
|
7583
|
-
*
|
|
7584
|
-
*
|
|
7585
|
-
*
|
|
7586
|
-
*
|
|
7583
|
+
* Read the claims of a SurrealDB record-access JWT WITHOUT verifying it. The
|
|
7584
|
+
* server still enforces the token on every request; this is only so the client
|
|
7585
|
+
* can act on what it already holds before a round trip completes.
|
|
7586
|
+
*
|
|
7587
|
+
* `AC` is the access-method name — the in-browser SSP needs it to resolve
|
|
7588
|
+
* `$access` in table permission predicates (mirrors the session's `$access`
|
|
7589
|
+
* that the server's `fn::query::register` reads). `ID` is the `$auth.id` record
|
|
7590
|
+
* id, which is what lets a warm boot restore a session locally.
|
|
7591
|
+
*
|
|
7592
|
+
* Returns nulls on any malformed input.
|
|
7587
7593
|
*/
|
|
7588
|
-
function
|
|
7594
|
+
function decodeTokenClaims(token) {
|
|
7589
7595
|
try {
|
|
7590
7596
|
const payload = token.split(".")[1];
|
|
7591
|
-
if (!payload) return
|
|
7597
|
+
if (!payload) return {
|
|
7598
|
+
access: null,
|
|
7599
|
+
userId: null
|
|
7600
|
+
};
|
|
7592
7601
|
let b64 = payload.replace(/-/g, "+").replace(/_/g, "/");
|
|
7593
7602
|
b64 += "=".repeat((4 - b64.length % 4) % 4);
|
|
7594
7603
|
const json = typeof atob === "function" ? atob(b64) : Buffer.from(b64, "base64").toString("binary");
|
|
7595
7604
|
const claims = JSON.parse(json);
|
|
7596
7605
|
const ac = claims.AC ?? claims.ac;
|
|
7597
|
-
|
|
7606
|
+
const id = claims.ID ?? claims.id;
|
|
7607
|
+
return {
|
|
7608
|
+
access: typeof ac === "string" ? ac : null,
|
|
7609
|
+
userId: typeof id === "string" ? id : null
|
|
7610
|
+
};
|
|
7598
7611
|
} catch {
|
|
7599
|
-
return
|
|
7612
|
+
return {
|
|
7613
|
+
access: null,
|
|
7614
|
+
userId: null
|
|
7615
|
+
};
|
|
7600
7616
|
}
|
|
7601
7617
|
}
|
|
7618
|
+
function decodeAccessFromToken(token) {
|
|
7619
|
+
return decodeTokenClaims(token).access;
|
|
7620
|
+
}
|
|
7602
7621
|
var AuthService = class {
|
|
7603
7622
|
token = null;
|
|
7604
7623
|
currentUser = null;
|
|
@@ -7645,6 +7664,39 @@ var AuthService = class {
|
|
|
7645
7664
|
this.events.emit(AuthEventTypes.AuthStateChanged, userId);
|
|
7646
7665
|
}
|
|
7647
7666
|
/**
|
|
7667
|
+
* Restore a session from the locally cached JWT, with NO network.
|
|
7668
|
+
*
|
|
7669
|
+
* This is what makes a warm boot paint instantly and what makes an offline
|
|
7670
|
+
* boot possible at all: the token is in local storage, and it already carries
|
|
7671
|
+
* both the access method and the `$auth.id` record id. Everything the client
|
|
7672
|
+
* needs to route queries (`setCurrentUserId`) and to satisfy `$auth`-gated
|
|
7673
|
+
* permission predicates in the in-browser SSP (`setSessionAuth`) is therefore
|
|
7674
|
+
* available before a socket exists.
|
|
7675
|
+
*
|
|
7676
|
+
* The session is OPTIMISTIC: the token is unverified here. `check()` runs
|
|
7677
|
+
* afterwards in the background and downgrades to a real sign-out if the
|
|
7678
|
+
* server rejects it. Nothing is trusted that the server has not also seen -
|
|
7679
|
+
* the local store only ever holds rows the server previously sent.
|
|
7680
|
+
*
|
|
7681
|
+
* Returns the restored user id, or null when there is no usable token.
|
|
7682
|
+
*/
|
|
7683
|
+
async restoreSessionFromToken() {
|
|
7684
|
+
const token = await this.persistenceClient.get("sp00ky_auth_token");
|
|
7685
|
+
if (!token) return null;
|
|
7686
|
+
const { access, userId } = decodeTokenClaims(token);
|
|
7687
|
+
if (!userId) return null;
|
|
7688
|
+
this.token = token;
|
|
7689
|
+
this.currentUser = { id: userId };
|
|
7690
|
+
this.isAuthenticated = true;
|
|
7691
|
+
this.access = access ?? this.defaultAccessName();
|
|
7692
|
+
this.notifyListeners();
|
|
7693
|
+
this.logger.debug({
|
|
7694
|
+
userId,
|
|
7695
|
+
Category: "sp00ky-client::AuthService::restoreSessionFromToken"
|
|
7696
|
+
}, "Session restored optimistically from cached token");
|
|
7697
|
+
return userId;
|
|
7698
|
+
}
|
|
7699
|
+
/**
|
|
7648
7700
|
* Check for existing session and validate
|
|
7649
7701
|
*/
|
|
7650
7702
|
async check(accessToken) {
|
|
@@ -7685,12 +7737,18 @@ var AuthService = class {
|
|
|
7685
7737
|
}
|
|
7686
7738
|
}
|
|
7687
7739
|
} catch (error) {
|
|
7688
|
-
this.logger.
|
|
7740
|
+
if (classifySyncError(error) === "network") this.logger.warn({
|
|
7689
7741
|
error,
|
|
7690
|
-
stack: error.stack,
|
|
7691
7742
|
Category: "sp00ky-client::AuthService::check"
|
|
7692
|
-
}, "Auth check
|
|
7693
|
-
|
|
7743
|
+
}, "Auth check unreachable; keeping the cached session and retrying later");
|
|
7744
|
+
else {
|
|
7745
|
+
this.logger.error({
|
|
7746
|
+
error,
|
|
7747
|
+
stack: error.stack,
|
|
7748
|
+
Category: "sp00ky-client::AuthService::check"
|
|
7749
|
+
}, "Auth check failed");
|
|
7750
|
+
await this.signOut();
|
|
7751
|
+
}
|
|
7694
7752
|
} finally {
|
|
7695
7753
|
this.isLoading = false;
|
|
7696
7754
|
}
|
|
@@ -7836,6 +7894,51 @@ var StreamProcessorService = class {
|
|
|
7836
7894
|
*/
|
|
7837
7895
|
ingestMany(records) {
|
|
7838
7896
|
if (records.length === 0) return;
|
|
7897
|
+
if (!this.processor) {
|
|
7898
|
+
this.logger.warn({ Category: "sp00ky-client::StreamProcessorService::ingestMany" }, "Not initialized, skipping ingest");
|
|
7899
|
+
return;
|
|
7900
|
+
}
|
|
7901
|
+
const bulkIngest = this.processor.ingest_many;
|
|
7902
|
+
if (typeof bulkIngest === "function") {
|
|
7903
|
+
this.logger.debug({
|
|
7904
|
+
count: records.length,
|
|
7905
|
+
Category: "sp00ky-client::StreamProcessorService::ingestMany"
|
|
7906
|
+
}, "Ingesting batch into ssp");
|
|
7907
|
+
try {
|
|
7908
|
+
const items = records.map((record) => ({
|
|
7909
|
+
table: record.table,
|
|
7910
|
+
op: record.op,
|
|
7911
|
+
id: record.id,
|
|
7912
|
+
record: this.normalizeValue(record.record)
|
|
7913
|
+
}));
|
|
7914
|
+
const t0 = performance.now();
|
|
7915
|
+
const rawUpdates = bulkIngest.call(this.processor, items) ?? [];
|
|
7916
|
+
const materializationTimeMs = performance.now() - t0;
|
|
7917
|
+
this.logger.debug({
|
|
7918
|
+
count: records.length,
|
|
7919
|
+
rawUpdates: rawUpdates.length,
|
|
7920
|
+
materializationTimeMs,
|
|
7921
|
+
Category: "sp00ky-client::StreamProcessorService::ingestMany"
|
|
7922
|
+
}, "Ingesting batch into ssp done");
|
|
7923
|
+
if (rawUpdates.length > 0) this.dispatchUpdates(rawUpdates.map((u) => ({
|
|
7924
|
+
queryHash: u.query_id,
|
|
7925
|
+
localArray: u.result_data,
|
|
7926
|
+
op: "CREATE",
|
|
7927
|
+
materializationTimeMs,
|
|
7928
|
+
storeApplyMs: u.timing_store_apply_ms,
|
|
7929
|
+
circuitStepMs: u.timing_circuit_step_ms,
|
|
7930
|
+
transformMs: u.timing_transform_ms
|
|
7931
|
+
})));
|
|
7932
|
+
} catch (e) {
|
|
7933
|
+
this.logger.error({
|
|
7934
|
+
error: e,
|
|
7935
|
+
count: records.length,
|
|
7936
|
+
Category: "sp00ky-client::StreamProcessorService::ingestMany"
|
|
7937
|
+
}, "Ingesting batch into ssp failed");
|
|
7938
|
+
}
|
|
7939
|
+
this.markSnapshotDirty();
|
|
7940
|
+
return;
|
|
7941
|
+
}
|
|
7839
7942
|
this.beginCoalescing();
|
|
7840
7943
|
try {
|
|
7841
7944
|
for (const record of records) this.ingest(record.table, record.op, record.id, record.record);
|
|
@@ -8363,14 +8466,12 @@ var CacheModule = class {
|
|
|
8363
8466
|
const query = surql.seal(surql.tx(populatedRecords.map((_, i) => {
|
|
8364
8467
|
return surql.upsertMerge(`id${i}`, `content${i}`);
|
|
8365
8468
|
})));
|
|
8366
|
-
const params =
|
|
8367
|
-
|
|
8368
|
-
|
|
8369
|
-
|
|
8370
|
-
|
|
8371
|
-
|
|
8372
|
-
};
|
|
8373
|
-
}, {});
|
|
8469
|
+
const params = {};
|
|
8470
|
+
for (let i = 0; i < populatedRecords.length; i++) {
|
|
8471
|
+
const { id, ...content } = populatedRecords[i].record;
|
|
8472
|
+
params[`id${i}`] = id;
|
|
8473
|
+
params[`content${i}`] = content;
|
|
8474
|
+
}
|
|
8374
8475
|
await this.local.execute(query, params, { epoch });
|
|
8375
8476
|
}
|
|
8376
8477
|
if (this.local.epoch !== epoch) throw new StaleEpochError();
|
|
@@ -11506,6 +11607,15 @@ var Sp00kyClient = class {
|
|
|
11506
11607
|
sync;
|
|
11507
11608
|
devTools;
|
|
11508
11609
|
crdtManager;
|
|
11610
|
+
/**
|
|
11611
|
+
* True once the LOCAL half of boot is done and the client can serve reads
|
|
11612
|
+
* from the local store. Distinct from being connected: `syncHealth` covers
|
|
11613
|
+
* reaching the server and `storageHealth` covers whether the local store is
|
|
11614
|
+
* durable, but neither says "usable". Consumers gate their first paint on
|
|
11615
|
+
* this, which is what makes a warm boot instant and an offline boot possible.
|
|
11616
|
+
*/
|
|
11617
|
+
localReady = false;
|
|
11618
|
+
saltUserId = null;
|
|
11509
11619
|
featureFlags;
|
|
11510
11620
|
appReleases;
|
|
11511
11621
|
preloadedHashes = /* @__PURE__ */ new Set();
|
|
@@ -11662,7 +11772,7 @@ var Sp00kyClient = class {
|
|
|
11662
11772
|
return new TabsCoordinator({
|
|
11663
11773
|
tabId,
|
|
11664
11774
|
fingerprint: computeTabsFingerprint({
|
|
11665
|
-
coreVersion: "0.0.1-canary.
|
|
11775
|
+
coreVersion: "0.0.1-canary.203",
|
|
11666
11776
|
schemaHash: hash53(this.config.schemaSurql),
|
|
11667
11777
|
endpoint: this.config.database.endpoint ?? "",
|
|
11668
11778
|
namespace: this.config.database.namespace,
|
|
@@ -11805,9 +11915,6 @@ var Sp00kyClient = class {
|
|
|
11805
11915
|
await this.migrator.provision(this.config.schemaSurql);
|
|
11806
11916
|
this.logger.debug({ Category: "sp00ky-client::Sp00kyClient::init" }, "Schema provisioned");
|
|
11807
11917
|
}
|
|
11808
|
-
await this.remote.connect();
|
|
11809
|
-
this.connectionSupervisor.start();
|
|
11810
|
-
this.logger.debug({ Category: "sp00ky-client::Sp00kyClient::init" }, "Remote database connected");
|
|
11811
11918
|
(async () => {
|
|
11812
11919
|
try {
|
|
11813
11920
|
this.blobs.setMaxBytes(await resolveBlobBudget(this.config.blobCache?.maxBytes));
|
|
@@ -11823,23 +11930,31 @@ var Sp00kyClient = class {
|
|
|
11823
11930
|
await this.streamProcessor.init();
|
|
11824
11931
|
this.streamProcessor.setPermissions(extractSelectPermissions(this.config.schemaSurql));
|
|
11825
11932
|
this.logger.debug({ Category: "sp00ky-client::Sp00kyClient::init" }, "StreamProcessor initialized");
|
|
11826
|
-
await this.auth.
|
|
11827
|
-
this.
|
|
11828
|
-
|
|
11933
|
+
const restoredUserId = await this.auth.restoreSessionFromToken();
|
|
11934
|
+
const sessionId = this.mintSessionSalt();
|
|
11935
|
+
this.saltUserId = restoredUserId;
|
|
11829
11936
|
await this.dataModule.init(sessionId);
|
|
11830
11937
|
this.crdtManager.setSessionId(sessionId);
|
|
11938
|
+
if (restoredUserId) {
|
|
11939
|
+
this.dataModule.setCurrentUserId(restoredUserId);
|
|
11940
|
+
this.streamProcessor.setSessionAuth(this.sessionAuthId(), this.auth.access);
|
|
11941
|
+
}
|
|
11831
11942
|
this.logger.debug({
|
|
11832
11943
|
sessionId,
|
|
11833
11944
|
Category: "sp00ky-client::Sp00kyClient::init"
|
|
11834
11945
|
}, "DataModule initialized");
|
|
11835
11946
|
this.auth.subscribe(async (userId) => {
|
|
11836
11947
|
this.dataModule.setCurrentUserId(userId);
|
|
11837
|
-
this.streamProcessor.setSessionAuth(this.
|
|
11948
|
+
this.streamProcessor.setSessionAuth(this.sessionAuthId(), this.auth.access);
|
|
11838
11949
|
writeBootBucketHint(bucketIdForUser(userId));
|
|
11839
11950
|
await this.ensureLocalBucket(userId);
|
|
11840
|
-
const
|
|
11841
|
-
this.
|
|
11842
|
-
|
|
11951
|
+
const saltFor = this.sessionAuthId();
|
|
11952
|
+
if (saltFor !== this.saltUserId) {
|
|
11953
|
+
this.saltUserId = saltFor;
|
|
11954
|
+
const next = this.mintSessionSalt();
|
|
11955
|
+
this.dataModule.setSessionId(next);
|
|
11956
|
+
this.crdtManager.setSessionId(next);
|
|
11957
|
+
}
|
|
11843
11958
|
try {
|
|
11844
11959
|
await this.sync.setCurrentUserId(userId);
|
|
11845
11960
|
} catch (e) {
|
|
@@ -11855,7 +11970,9 @@ var Sp00kyClient = class {
|
|
|
11855
11970
|
this.logger.debug({ Category: "sp00ky-client::Sp00kyClient::init" }, "FeatureFlagModule initialized");
|
|
11856
11971
|
this.appReleases.init();
|
|
11857
11972
|
this.logger.debug({ Category: "sp00ky-client::Sp00kyClient::init" }, "AppReleaseModule initialized");
|
|
11858
|
-
this.
|
|
11973
|
+
this.localReady = true;
|
|
11974
|
+
this.logger.info({ Category: "sp00ky-client::Sp00kyClient::init" }, "Sp00kyClient local initialization completed; connecting in the background");
|
|
11975
|
+
this.initRemote();
|
|
11859
11976
|
} catch (e) {
|
|
11860
11977
|
this.logger.error({
|
|
11861
11978
|
error: e,
|
|
@@ -11864,6 +11981,36 @@ var Sp00kyClient = class {
|
|
|
11864
11981
|
throw e;
|
|
11865
11982
|
}
|
|
11866
11983
|
}
|
|
11984
|
+
/**
|
|
11985
|
+
* The network half of boot: connect, verify the restored session, and let the
|
|
11986
|
+
* sync engine catch up. Runs in the background after `init()` has already
|
|
11987
|
+
* resolved, so nothing here is on the paint path.
|
|
11988
|
+
*
|
|
11989
|
+
* Every step is best-effort. A failure leaves the client in exactly the state
|
|
11990
|
+
* a warm offline boot is in - local reads working, writes queued in the
|
|
11991
|
+
* outbox - and the connection supervisor keeps retrying underneath.
|
|
11992
|
+
*/
|
|
11993
|
+
async initRemote() {
|
|
11994
|
+
try {
|
|
11995
|
+
await this.remote.connect();
|
|
11996
|
+
this.logger.debug({ Category: "sp00ky-client::Sp00kyClient::initRemote" }, "Remote database connected");
|
|
11997
|
+
} catch (e) {
|
|
11998
|
+
this.logger.warn({
|
|
11999
|
+
err: e,
|
|
12000
|
+
Category: "sp00ky-client::Sp00kyClient::initRemote"
|
|
12001
|
+
}, "Remote connect failed; running from the local store and retrying in the background");
|
|
12002
|
+
}
|
|
12003
|
+
this.connectionSupervisor.start();
|
|
12004
|
+
try {
|
|
12005
|
+
await this.auth.init();
|
|
12006
|
+
this.logger.debug({ Category: "sp00ky-client::Sp00kyClient::initRemote" }, "Auth verified");
|
|
12007
|
+
} catch (e) {
|
|
12008
|
+
this.logger.warn({
|
|
12009
|
+
err: e,
|
|
12010
|
+
Category: "sp00ky-client::Sp00kyClient::initRemote"
|
|
12011
|
+
}, "Auth verification failed; keeping the restored session");
|
|
12012
|
+
}
|
|
12013
|
+
}
|
|
11867
12014
|
bucketSwitchChain = Promise.resolve();
|
|
11868
12015
|
pendingBucketTarget = null;
|
|
11869
12016
|
/**
|
|
@@ -12238,26 +12385,49 @@ var Sp00kyClient = class {
|
|
|
12238
12385
|
delete(table, id) {
|
|
12239
12386
|
return this.dataModule.delete(table, id);
|
|
12240
12387
|
}
|
|
12388
|
+
/**
|
|
12389
|
+
* Whether the local store is initialized and reads can be served. See the
|
|
12390
|
+
* `localReady` field: this is deliberately independent of connectivity.
|
|
12391
|
+
*/
|
|
12392
|
+
isLocalReady() {
|
|
12393
|
+
return this.localReady;
|
|
12394
|
+
}
|
|
12241
12395
|
async useRemote(fn) {
|
|
12242
12396
|
return fn(this.remote.getClient());
|
|
12243
12397
|
}
|
|
12244
12398
|
/**
|
|
12245
|
-
*
|
|
12246
|
-
*
|
|
12247
|
-
*
|
|
12248
|
-
*
|
|
12249
|
-
|
|
12250
|
-
|
|
12251
|
-
|
|
12252
|
-
|
|
12253
|
-
|
|
12254
|
-
|
|
12255
|
-
|
|
12256
|
-
|
|
12257
|
-
|
|
12258
|
-
|
|
12259
|
-
|
|
12260
|
-
|
|
12399
|
+
* Mint the salt used for query-id hashing, so two sessions registering the
|
|
12400
|
+
* same logical query get distinct `_00_query` rows.
|
|
12401
|
+
*
|
|
12402
|
+
* Generated LOCALLY, deliberately. This used to be `RETURN <string>session::id()`,
|
|
12403
|
+
* which cost a serial round trip on the critical boot path and resolved to
|
|
12404
|
+
* `''` offline. The value never needed to come from the server: the server
|
|
12405
|
+
* derives its own `clientId` inside `fn::query::register` and *ignores*
|
|
12406
|
+
* whatever the caller passed, and the permission rules that matter gate on
|
|
12407
|
+
* `auth_id = $auth.id` rather than the session (`_00_list_ref`). Session
|
|
12408
|
+
* scoping via `clientId = session::id()` was in fact removed upstream because
|
|
12409
|
+
* it broke a user with two tabs open. All this value has to be is unique per
|
|
12410
|
+
* browser session, which `randomUUID` gives us for free and offline.
|
|
12411
|
+
*/
|
|
12412
|
+
/**
|
|
12413
|
+
* The current principal as the `"table:id"` string the in-browser SSP wants
|
|
12414
|
+
* for `$auth.id`, or null when signed out.
|
|
12415
|
+
*
|
|
12416
|
+
* Tolerates BOTH shapes `currentUser.id` can take, which is the point:
|
|
12417
|
+
* a session restored from the cached token carries a plain string (the JWT's
|
|
12418
|
+
* `ID` claim), while one verified by the server carries a RecordId. Passing
|
|
12419
|
+
* the former to `encodeRecordId` reads `.table` off a string and throws
|
|
12420
|
+
* during boot.
|
|
12421
|
+
*/
|
|
12422
|
+
sessionAuthId() {
|
|
12423
|
+
const id = this.auth.currentUser?.id;
|
|
12424
|
+
if (!id) return null;
|
|
12425
|
+
return typeof id === "string" ? id : encodeRecordId(id);
|
|
12426
|
+
}
|
|
12427
|
+
mintSessionSalt() {
|
|
12428
|
+
const c = typeof globalThis !== "undefined" ? globalThis.crypto : void 0;
|
|
12429
|
+
if (c?.randomUUID) return c.randomUUID();
|
|
12430
|
+
return `s${Date.now().toString(36)}${Math.random().toString(36).slice(2, 12)}`;
|
|
12261
12431
|
}
|
|
12262
12432
|
};
|
|
12263
12433
|
|
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.203",
|
|
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.203",
|
|
64
|
+
"@spooky-sync/ssp-wasm": "0.0.1-canary.203",
|
|
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
|
}
|
package/src/modules/sync/sync.ts
CHANGED
|
@@ -473,6 +473,13 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
473
473
|
if (this.isInit) throw new Error('Sp00kySync is already initialized');
|
|
474
474
|
this.isInit = true;
|
|
475
475
|
await this.scheduler.init({ loadOutbox: this.tabRole !== 'follower' });
|
|
476
|
+
// Boot is local-first now, so init() routinely runs BEFORE the socket is
|
|
477
|
+
// up. Treat that as "the socket we registered on is gone": otherwise the
|
|
478
|
+
// first `connected` takes the initial-connect branch, returns early, and
|
|
479
|
+
// never re-enqueues `register` for queries that registered while offline.
|
|
480
|
+
// They would still heal via the down-queue backoff, but slowly and only
|
|
481
|
+
// because every query happens to enqueue its own register.
|
|
482
|
+
if (this.remote.getStatus() !== 'connected') this.needsResubscribe = true;
|
|
476
483
|
this.subscribeToReconnect();
|
|
477
484
|
this.subscribeToConnectionState();
|
|
478
485
|
void this.scheduler.syncUp();
|
|
@@ -204,6 +204,72 @@ export class StreamProcessorService {
|
|
|
204
204
|
): void {
|
|
205
205
|
if (records.length === 0) return;
|
|
206
206
|
|
|
207
|
+
if (!this.processor) {
|
|
208
|
+
this.logger.warn(
|
|
209
|
+
{ Category: 'sp00ky-client::StreamProcessorService::ingestMany' },
|
|
210
|
+
'Not initialized, skipping ingest'
|
|
211
|
+
);
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// One circuit step for the whole batch when the WASM build offers it. A
|
|
216
|
+
// step walks every registered view, so the per-record path pays that fixed
|
|
217
|
+
// cost N times: a cold sync landing a few thousand rows spent seconds of
|
|
218
|
+
// main-thread time almost entirely on step overhead. Older builds have no
|
|
219
|
+
// `ingest_many`, hence the loop below.
|
|
220
|
+
const bulkIngest = this.processor.ingest_many;
|
|
221
|
+
if (typeof bulkIngest === 'function') {
|
|
222
|
+
this.logger.debug(
|
|
223
|
+
{ count: records.length, Category: 'sp00ky-client::StreamProcessorService::ingestMany' },
|
|
224
|
+
'Ingesting batch into ssp'
|
|
225
|
+
);
|
|
226
|
+
try {
|
|
227
|
+
const items = records.map((record) => ({
|
|
228
|
+
table: record.table,
|
|
229
|
+
op: record.op,
|
|
230
|
+
id: record.id,
|
|
231
|
+
record: this.normalizeValue(record.record),
|
|
232
|
+
}));
|
|
233
|
+
const t0 = performance.now();
|
|
234
|
+
const rawUpdates = bulkIngest.call(this.processor, items) ?? [];
|
|
235
|
+
const materializationTimeMs = performance.now() - t0;
|
|
236
|
+
this.logger.debug(
|
|
237
|
+
{
|
|
238
|
+
count: records.length,
|
|
239
|
+
rawUpdates: rawUpdates.length,
|
|
240
|
+
materializationTimeMs,
|
|
241
|
+
Category: 'sp00ky-client::StreamProcessorService::ingestMany',
|
|
242
|
+
},
|
|
243
|
+
'Ingesting batch into ssp done'
|
|
244
|
+
);
|
|
245
|
+
if (rawUpdates.length > 0) {
|
|
246
|
+
// `op: 'CREATE'` for the same reason the coalesced flush uses it: the
|
|
247
|
+
// batch's update takes DataModule's immediate (non-debounced) path.
|
|
248
|
+
this.dispatchUpdates(
|
|
249
|
+
rawUpdates.map((u: WasmStreamUpdate) => ({
|
|
250
|
+
queryHash: u.query_id,
|
|
251
|
+
localArray: u.result_data,
|
|
252
|
+
op: 'CREATE' as const,
|
|
253
|
+
materializationTimeMs,
|
|
254
|
+
storeApplyMs: u.timing_store_apply_ms,
|
|
255
|
+
circuitStepMs: u.timing_circuit_step_ms,
|
|
256
|
+
transformMs: u.timing_transform_ms,
|
|
257
|
+
}))
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
} catch (e) {
|
|
261
|
+
// Same contract as the single-record path: report and move on rather
|
|
262
|
+
// than re-running the batch, which would double-apply whatever the
|
|
263
|
+
// failed step already committed to the store.
|
|
264
|
+
this.logger.error(
|
|
265
|
+
{ error: e, count: records.length, Category: 'sp00ky-client::StreamProcessorService::ingestMany' },
|
|
266
|
+
'Ingesting batch into ssp failed'
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
this.markSnapshotDirty();
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
|
|
207
273
|
this.beginCoalescing();
|
|
208
274
|
try {
|
|
209
275
|
for (const record of records) {
|
|
@@ -133,4 +133,64 @@ describe('StreamProcessor ingestMany bulk insert', () => {
|
|
|
133
133
|
svc.ingest('user', 'CREATE', 'user:1', { id: 'user:1', _00_rv: 1 });
|
|
134
134
|
expect(receiver.received).toHaveLength(1);
|
|
135
135
|
});
|
|
136
|
+
|
|
137
|
+
// A WASM build that exposes `ingest_many` takes one circuit step for the whole
|
|
138
|
+
// batch. The per-record loop above stays as the fallback for older builds.
|
|
139
|
+
describe('with a WASM build that supports bulk ingest', () => {
|
|
140
|
+
function makeBulkService() {
|
|
141
|
+
const svc = new StreamProcessorService(
|
|
142
|
+
{} as any,
|
|
143
|
+
{} as any,
|
|
144
|
+
{ get: async () => undefined, set: async () => {} } as any,
|
|
145
|
+
makeLogger()
|
|
146
|
+
);
|
|
147
|
+
const calls: { items: any[] }[] = [];
|
|
148
|
+
const mockProcessor: Partial<WasmProcessor> = {
|
|
149
|
+
ingest: () => {
|
|
150
|
+
throw new Error('per-record ingest must not be used when ingest_many exists');
|
|
151
|
+
},
|
|
152
|
+
ingest_many: (items): WasmStreamUpdate[] => {
|
|
153
|
+
calls.push({ items });
|
|
154
|
+
return [
|
|
155
|
+
{
|
|
156
|
+
query_id: 'q1',
|
|
157
|
+
result_data: items.map((i) => [i.id, 1] as [string, number]),
|
|
158
|
+
timing_circuit_step_ms: 4,
|
|
159
|
+
} as WasmStreamUpdate,
|
|
160
|
+
];
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
(svc as any).processor = mockProcessor;
|
|
164
|
+
return { svc, calls };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
it('takes a single bulk call and dispatches one update per affected query', () => {
|
|
168
|
+
const { svc, calls } = makeBulkService();
|
|
169
|
+
svc.addReceiver(receiver);
|
|
170
|
+
|
|
171
|
+
svc.ingestMany([
|
|
172
|
+
{ table: 'user', op: 'CREATE', id: 'user:1', record: { id: 'user:1', _00_rv: 1 } },
|
|
173
|
+
{ table: 'user', op: 'CREATE', id: 'user:2', record: { id: 'user:2', _00_rv: 1 } },
|
|
174
|
+
{ table: 'user', op: 'CREATE', id: 'user:3', record: { id: 'user:3', _00_rv: 1 } },
|
|
175
|
+
]);
|
|
176
|
+
|
|
177
|
+
expect(calls).toHaveLength(1);
|
|
178
|
+
expect(calls[0].items.map((i) => i.id)).toEqual(['user:1', 'user:2', 'user:3']);
|
|
179
|
+
expect(receiver.received).toHaveLength(1);
|
|
180
|
+
expect(receiver.received[0].queryHash).toBe('q1');
|
|
181
|
+
expect(receiver.received[0].localArray).toHaveLength(3);
|
|
182
|
+
expect(receiver.received[0].op).toBe('CREATE');
|
|
183
|
+
expect(receiver.received[0].circuitStepMs).toBe(4);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('never calls the bulk path for an empty batch', () => {
|
|
187
|
+
const { svc, calls } = makeBulkService();
|
|
188
|
+
svc.addReceiver(receiver);
|
|
189
|
+
|
|
190
|
+
svc.ingestMany([]);
|
|
191
|
+
|
|
192
|
+
expect(calls).toHaveLength(0);
|
|
193
|
+
expect(receiver.received).toHaveLength(0);
|
|
194
|
+
});
|
|
195
|
+
});
|
|
136
196
|
});
|
|
@@ -34,6 +34,10 @@ export interface WasmIngestItem {
|
|
|
34
34
|
// Interface matching the Sp00kyProcessor class from WASM
|
|
35
35
|
export interface WasmProcessor {
|
|
36
36
|
ingest(table: string, op: string, id: string, record: any): WasmStreamUpdate[];
|
|
37
|
+
// Bulk ingest: ONE circuit step for the whole array, returning the coalesced
|
|
38
|
+
// updates. Optional because a stale WASM build won't have it — callers guard
|
|
39
|
+
// with `typeof x === 'function'` and fall back to a loop over `ingest`.
|
|
40
|
+
ingest_many?(items: WasmIngestItem[]): WasmStreamUpdate[];
|
|
37
41
|
register_view(config: WasmQueryConfig): WasmStreamUpdate | undefined;
|
|
38
42
|
unregister_view(id: string): void;
|
|
39
43
|
// Seed per-table `select` permission predicates ({ [table]: whereText }) so
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
// Structural guard, in the spirit of sp00ky.init-query.test.ts: the paint path
|
|
6
|
+
// must stay network-free. `init()` resolves as soon as the LOCAL store is
|
|
7
|
+
// usable, and every consumer gates its first render on that, so anything that
|
|
8
|
+
// reaches the network belongs in `initRemote()` instead.
|
|
9
|
+
//
|
|
10
|
+
// This is the invariant that makes a warm reload instant and an offline boot
|
|
11
|
+
// possible at all. It is easy to undo by adding one innocent-looking `await`,
|
|
12
|
+
// hence a test rather than a comment.
|
|
13
|
+
const source = readFileSync(join(__dirname, 'sp00ky.ts'), 'utf8');
|
|
14
|
+
|
|
15
|
+
function methodBody(name: string): string {
|
|
16
|
+
const re = new RegExp(`(?:private )?async ${name}\\([^)]*\\)[^{]*\\{[\\s\\S]*?\\n \\}`);
|
|
17
|
+
const m = re.exec(source);
|
|
18
|
+
if (!m) throw new Error(`could not locate ${name}()`);
|
|
19
|
+
return m[0];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe('local-first boot', () => {
|
|
23
|
+
const init = methodBody('init');
|
|
24
|
+
|
|
25
|
+
it('init() never awaits the remote', () => {
|
|
26
|
+
expect(init).not.toMatch(/await this\.remote\./);
|
|
27
|
+
expect(init).not.toMatch(/await this\.auth\.init\(\)/);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('init() hands the network half off without awaiting it', () => {
|
|
31
|
+
expect(init).toMatch(/void this\.initRemote\(\)/);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('init() restores the session locally and marks the client ready', () => {
|
|
35
|
+
expect(init).toMatch(/await this\.auth\.restoreSessionFromToken\(\)/);
|
|
36
|
+
expect(init).toMatch(/this\.localReady = true/);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('the query-id salt is minted locally, not fetched from the server', () => {
|
|
40
|
+
// No remaining CALL - the surviving mentions are comments explaining why.
|
|
41
|
+
expect(source).not.toMatch(/query[^\n]*RETURN <string>session::id\(\)/);
|
|
42
|
+
expect(init).toMatch(/this\.mintSessionSalt\(\)/);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('permissions are seeded before anything can register a view', () => {
|
|
46
|
+
const perms = init.indexOf('setPermissions(');
|
|
47
|
+
const restore = init.indexOf('restoreSessionFromToken');
|
|
48
|
+
expect(perms).toBeGreaterThan(-1);
|
|
49
|
+
expect(perms).toBeLessThan(restore);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('initRemote() tolerates an unreachable server and still supervises', () => {
|
|
53
|
+
const remote = methodBody('initRemote');
|
|
54
|
+
// connect is wrapped, not fatal
|
|
55
|
+
expect(remote).toMatch(/try\s*\{[\s\S]*await this\.remote\.connect\(\)[\s\S]*\}\s*catch/);
|
|
56
|
+
// and the supervisor starts regardless, or a boot-time failure never heals
|
|
57
|
+
const connectCatchEnd = remote.indexOf('connectionSupervisor.start()');
|
|
58
|
+
expect(connectCatchEnd).toBeGreaterThan(-1);
|
|
59
|
+
});
|
|
60
|
+
});
|
package/src/sp00ky.ts
CHANGED
|
@@ -334,6 +334,17 @@ export class Sp00kyClient<S extends SchemaStructure> {
|
|
|
334
334
|
private sync: Sp00kySync<S>;
|
|
335
335
|
private devTools: DevToolsService;
|
|
336
336
|
private crdtManager: CrdtManager;
|
|
337
|
+
/**
|
|
338
|
+
* True once the LOCAL half of boot is done and the client can serve reads
|
|
339
|
+
* from the local store. Distinct from being connected: `syncHealth` covers
|
|
340
|
+
* reaching the server and `storageHealth` covers whether the local store is
|
|
341
|
+
* durable, but neither says "usable". Consumers gate their first paint on
|
|
342
|
+
* this, which is what makes a warm boot instant and an offline boot possible.
|
|
343
|
+
*/
|
|
344
|
+
private localReady = false;
|
|
345
|
+
// Principal the current query-id salt was minted for (`null` = signed out).
|
|
346
|
+
// Rotated only on a real auth flip: see the call sites in `init`.
|
|
347
|
+
private saltUserId: string | null = null;
|
|
337
348
|
private featureFlags!: FeatureFlagModule<S>;
|
|
338
349
|
private appReleases!: AppReleaseModule<S>;
|
|
339
350
|
// Query hashes already preloaded this session — skip redundant one-shot
|
|
@@ -831,16 +842,6 @@ export class Sp00kyClient<S extends SchemaStructure> {
|
|
|
831
842
|
this.logger.debug({ Category: 'sp00ky-client::Sp00kyClient::init' }, 'Schema provisioned');
|
|
832
843
|
}
|
|
833
844
|
|
|
834
|
-
await this.remote.connect();
|
|
835
|
-
// Start supervising only after the first connect succeeds, so a boot-time
|
|
836
|
-
// failure surfaces as a thrown init() rather than being silently absorbed
|
|
837
|
-
// into a background retry loop.
|
|
838
|
-
this.connectionSupervisor.start();
|
|
839
|
-
this.logger.debug(
|
|
840
|
-
{ Category: 'sp00ky-client::Sp00kyClient::init' },
|
|
841
|
-
'Remote database connected'
|
|
842
|
-
);
|
|
843
|
-
|
|
844
845
|
// Warm the blob cache in the background. Deliberately NOT awaited, and
|
|
845
846
|
// deliberately after `remote.connect()`: this walks the OPFS directory to
|
|
846
847
|
// rebuild the manifest, and awaiting it ahead of the socket delayed the
|
|
@@ -870,26 +871,31 @@ export class Sp00kyClient<S extends SchemaStructure> {
|
|
|
870
871
|
'StreamProcessor initialized'
|
|
871
872
|
);
|
|
872
873
|
|
|
873
|
-
await this.auth.init();
|
|
874
|
-
this.logger.debug({ Category: 'sp00ky-client::Sp00kyClient::init' }, 'Auth initialized');
|
|
875
874
|
|
|
876
|
-
//
|
|
877
|
-
//
|
|
878
|
-
//
|
|
879
|
-
//
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
//
|
|
883
|
-
//
|
|
884
|
-
//
|
|
885
|
-
//
|
|
886
|
-
//
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
// principal, not the same session on a new socket.
|
|
890
|
-
const sessionId = await this.fetchSessionId();
|
|
875
|
+
// Restore the session from the cached JWT, with no network. This is what
|
|
876
|
+
// lets the rest of the boot - and the app on top of it - proceed as a
|
|
877
|
+
// signed-in user before a socket exists. `initRemote()` verifies the
|
|
878
|
+
// token afterwards and signs out for real if the server rejects it.
|
|
879
|
+
const restoredUserId = await this.auth.restoreSessionFromToken();
|
|
880
|
+
|
|
881
|
+
// Salt for query-id hashing, minted locally (see `mintSessionSalt`). It
|
|
882
|
+
// is stable for the life of this client: the salt keys `_00_query` rows
|
|
883
|
+
// and local cache entries, so rotating it would invalidate every query
|
|
884
|
+
// hash and force a full re-register. Auth flips are the one case that
|
|
885
|
+
// must rotate it - a sign-in is a different principal.
|
|
886
|
+
const sessionId = this.mintSessionSalt();
|
|
887
|
+
this.saltUserId = restoredUserId;
|
|
891
888
|
await this.dataModule.init(sessionId);
|
|
892
889
|
this.crdtManager.setSessionId(sessionId);
|
|
890
|
+
|
|
891
|
+
// Route queries and satisfy `$auth`-gated permission predicates from the
|
|
892
|
+
// restored identity, BEFORE anything can register. Without this a query
|
|
893
|
+
// registering pre-verification would target the wrong
|
|
894
|
+
// `_00_query_user_<id>` table and register a permission-dead SSP view.
|
|
895
|
+
if (restoredUserId) {
|
|
896
|
+
this.dataModule.setCurrentUserId(restoredUserId);
|
|
897
|
+
this.streamProcessor.setSessionAuth(this.sessionAuthId(), this.auth.access);
|
|
898
|
+
}
|
|
893
899
|
this.logger.debug(
|
|
894
900
|
{ sessionId, Category: 'sp00ky-client::Sp00kyClient::init' },
|
|
895
901
|
'DataModule initialized'
|
|
@@ -920,10 +926,7 @@ export class Sp00kyClient<S extends SchemaStructure> {
|
|
|
920
926
|
// locally instead of being rejected. Set synchronously BEFORE the
|
|
921
927
|
// first `await` (like `setCurrentUserId` above) so queries that
|
|
922
928
|
// re-register on this auth flip see the fresh context, not a stale one.
|
|
923
|
-
this.streamProcessor.setSessionAuth(
|
|
924
|
-
this.auth.currentUser?.id ? encodeRecordId(this.auth.currentUser.id) : null,
|
|
925
|
-
this.auth.access
|
|
926
|
-
);
|
|
929
|
+
this.streamProcessor.setSessionAuth(this.sessionAuthId(), this.auth.access);
|
|
927
930
|
// Record the target bucket synchronously (still before the first
|
|
928
931
|
// `await`) so a reload mid-switch boots straight into the right store.
|
|
929
932
|
writeBootBucketHint(bucketIdForUser(userId));
|
|
@@ -931,9 +934,25 @@ export class Sp00kyClient<S extends SchemaStructure> {
|
|
|
931
934
|
// + latest-target-wins internally; no-op when the bucket already
|
|
932
935
|
// matches (the boot-hint warm path).
|
|
933
936
|
await this.ensureLocalBucket(userId);
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
this
|
|
937
|
+
// Only rotate the salt when the PRINCIPAL actually changed: a sign-in or
|
|
938
|
+
// sign-out is a different principal and must not keep the old
|
|
939
|
+
// principal's query ids, but the first fire of this callback after boot
|
|
940
|
+
// carries the same user the salt was already minted for, and rotating
|
|
941
|
+
// there would invalidate every query hash for no change in value.
|
|
942
|
+
// Canonicalize with the SAME encoding the restore path used, NOT
|
|
943
|
+
// String(): after background verification this callback carries a
|
|
944
|
+
// RecordId, while a session restored from the token carries the plain
|
|
945
|
+
// "table:id" string. Comparing their raw stringifications made every
|
|
946
|
+
// warm boot look like a principal change, which rotated the salt and
|
|
947
|
+
// re-registered every mounted query - the list painted from cache and
|
|
948
|
+
// then emptied a second later.
|
|
949
|
+
const saltFor = this.sessionAuthId();
|
|
950
|
+
if (saltFor !== this.saltUserId) {
|
|
951
|
+
this.saltUserId = saltFor;
|
|
952
|
+
const next = this.mintSessionSalt();
|
|
953
|
+
this.dataModule.setSessionId(next);
|
|
954
|
+
this.crdtManager.setSessionId(next);
|
|
955
|
+
}
|
|
937
956
|
try {
|
|
938
957
|
await this.sync.setCurrentUserId(userId);
|
|
939
958
|
} catch (e) {
|
|
@@ -959,10 +978,21 @@ export class Sp00kyClient<S extends SchemaStructure> {
|
|
|
959
978
|
'AppReleaseModule initialized'
|
|
960
979
|
);
|
|
961
980
|
|
|
981
|
+
// LOCAL BOOT IS DONE — the client can serve reads. Consumers gate their
|
|
982
|
+
// UI on this resolving, so everything above must stay network-free.
|
|
983
|
+
this.localReady = true;
|
|
962
984
|
this.logger.info(
|
|
963
985
|
{ Category: 'sp00ky-client::Sp00kyClient::init' },
|
|
964
|
-
'Sp00kyClient initialization completed
|
|
986
|
+
'Sp00kyClient local initialization completed; connecting in the background'
|
|
965
987
|
);
|
|
988
|
+
|
|
989
|
+
// The network half, deliberately NOT awaited. Nothing above needed it:
|
|
990
|
+
// queries paint from the local store, `sync.init()` tolerates a closed
|
|
991
|
+
// socket, and the session was restored from the cached token. Awaiting
|
|
992
|
+
// this was the entire reason a warm reload sat on a loading screen for
|
|
993
|
+
// seconds over data the browser already had on disk - and the reason an
|
|
994
|
+
// offline boot never completed at all.
|
|
995
|
+
void this.initRemote();
|
|
966
996
|
} catch (e) {
|
|
967
997
|
this.logger.error(
|
|
968
998
|
{ error: e, Category: 'sp00ky-client::Sp00kyClient::init' },
|
|
@@ -972,6 +1002,54 @@ export class Sp00kyClient<S extends SchemaStructure> {
|
|
|
972
1002
|
}
|
|
973
1003
|
}
|
|
974
1004
|
|
|
1005
|
+
/**
|
|
1006
|
+
* The network half of boot: connect, verify the restored session, and let the
|
|
1007
|
+
* sync engine catch up. Runs in the background after `init()` has already
|
|
1008
|
+
* resolved, so nothing here is on the paint path.
|
|
1009
|
+
*
|
|
1010
|
+
* Every step is best-effort. A failure leaves the client in exactly the state
|
|
1011
|
+
* a warm offline boot is in - local reads working, writes queued in the
|
|
1012
|
+
* outbox - and the connection supervisor keeps retrying underneath.
|
|
1013
|
+
*/
|
|
1014
|
+
private async initRemote(): Promise<void> {
|
|
1015
|
+
try {
|
|
1016
|
+
await this.remote.connect();
|
|
1017
|
+
this.logger.debug(
|
|
1018
|
+
{ Category: 'sp00ky-client::Sp00kyClient::initRemote' },
|
|
1019
|
+
'Remote database connected'
|
|
1020
|
+
);
|
|
1021
|
+
} catch (e) {
|
|
1022
|
+
// NOT fatal. This used to throw out of init() and leave the consuming app
|
|
1023
|
+
// on its loading screen forever with no network. The supervisor (started
|
|
1024
|
+
// unconditionally below) owns the retry from here.
|
|
1025
|
+
this.logger.warn(
|
|
1026
|
+
{ err: e, Category: 'sp00ky-client::Sp00kyClient::initRemote' },
|
|
1027
|
+
'Remote connect failed; running from the local store and retrying in the background'
|
|
1028
|
+
);
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
// Started whether or not the first connect succeeded - it is the thing that
|
|
1032
|
+
// revives the socket, so gating it on a successful connect would mean a
|
|
1033
|
+
// boot-time failure never recovered.
|
|
1034
|
+
this.connectionSupervisor.start();
|
|
1035
|
+
|
|
1036
|
+
try {
|
|
1037
|
+
// Verifies the optimistically restored token against the server. On a
|
|
1038
|
+
// rejected token this signs out for real; on an unreachable server it
|
|
1039
|
+
// keeps the cached session (see AuthService.check).
|
|
1040
|
+
await this.auth.init();
|
|
1041
|
+
this.logger.debug(
|
|
1042
|
+
{ Category: 'sp00ky-client::Sp00kyClient::initRemote' },
|
|
1043
|
+
'Auth verified'
|
|
1044
|
+
);
|
|
1045
|
+
} catch (e) {
|
|
1046
|
+
this.logger.warn(
|
|
1047
|
+
{ err: e, Category: 'sp00ky-client::Sp00kyClient::initRemote' },
|
|
1048
|
+
'Auth verification failed; keeping the restored session'
|
|
1049
|
+
);
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
|
|
975
1053
|
// Serializes bucket switches from rapid auth flips; `pendingBucketTarget`
|
|
976
1054
|
// makes intermediate targets collapse (A→anon→B never opens the anon bucket).
|
|
977
1055
|
private bucketSwitchChain: Promise<void> = Promise.resolve();
|
|
@@ -1518,26 +1596,53 @@ export class Sp00kyClient<S extends SchemaStructure> {
|
|
|
1518
1596
|
return this.dataModule.delete(table, id);
|
|
1519
1597
|
}
|
|
1520
1598
|
|
|
1599
|
+
/**
|
|
1600
|
+
* Whether the local store is initialized and reads can be served. See the
|
|
1601
|
+
* `localReady` field: this is deliberately independent of connectivity.
|
|
1602
|
+
*/
|
|
1603
|
+
isLocalReady(): boolean {
|
|
1604
|
+
return this.localReady;
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1521
1607
|
async useRemote<T>(fn: (client: Surreal) => Promise<T> | T): Promise<T> {
|
|
1522
1608
|
return fn(this.remote.getClient());
|
|
1523
1609
|
}
|
|
1524
1610
|
|
|
1525
1611
|
/**
|
|
1526
|
-
*
|
|
1527
|
-
*
|
|
1528
|
-
*
|
|
1529
|
-
*
|
|
1612
|
+
* Mint the salt used for query-id hashing, so two sessions registering the
|
|
1613
|
+
* same logical query get distinct `_00_query` rows.
|
|
1614
|
+
*
|
|
1615
|
+
* Generated LOCALLY, deliberately. This used to be `RETURN <string>session::id()`,
|
|
1616
|
+
* which cost a serial round trip on the critical boot path and resolved to
|
|
1617
|
+
* `''` offline. The value never needed to come from the server: the server
|
|
1618
|
+
* derives its own `clientId` inside `fn::query::register` and *ignores*
|
|
1619
|
+
* whatever the caller passed, and the permission rules that matter gate on
|
|
1620
|
+
* `auth_id = $auth.id` rather than the session (`_00_list_ref`). Session
|
|
1621
|
+
* scoping via `clientId = session::id()` was in fact removed upstream because
|
|
1622
|
+
* it broke a user with two tabs open. All this value has to be is unique per
|
|
1623
|
+
* browser session, which `randomUUID` gives us for free and offline.
|
|
1530
1624
|
*/
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1625
|
+
/**
|
|
1626
|
+
* The current principal as the `"table:id"` string the in-browser SSP wants
|
|
1627
|
+
* for `$auth.id`, or null when signed out.
|
|
1628
|
+
*
|
|
1629
|
+
* Tolerates BOTH shapes `currentUser.id` can take, which is the point:
|
|
1630
|
+
* a session restored from the cached token carries a plain string (the JWT's
|
|
1631
|
+
* `ID` claim), while one verified by the server carries a RecordId. Passing
|
|
1632
|
+
* the former to `encodeRecordId` reads `.table` off a string and throws
|
|
1633
|
+
* during boot.
|
|
1634
|
+
*/
|
|
1635
|
+
private sessionAuthId(): string | null {
|
|
1636
|
+
const id = this.auth.currentUser?.id;
|
|
1637
|
+
if (!id) return null;
|
|
1638
|
+
return typeof id === 'string' ? id : encodeRecordId(id);
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1641
|
+
private mintSessionSalt(): string {
|
|
1642
|
+
const c: Crypto | undefined =
|
|
1643
|
+
typeof globalThis !== 'undefined' ? (globalThis as { crypto?: Crypto }).crypto : undefined;
|
|
1644
|
+
if (c?.randomUUID) return c.randomUUID();
|
|
1645
|
+
// Older browsers / non-secure contexts: uniqueness is all that is required.
|
|
1646
|
+
return `s${Date.now().toString(36)}${Math.random().toString(36).slice(2, 12)}`;
|
|
1542
1647
|
}
|
|
1543
1648
|
}
|