@spooky-sync/core 0.0.1-canary.316 → 0.0.1-canary.317
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +1 -1
- package/dist/index.d.ts +8 -0
- package/dist/index.js +136 -16
- package/dist/live.js +1 -1
- package/dist/pure.d.ts +9 -1
- package/dist/pure.js +2 -2
- package/dist/types.d.ts +23 -0
- package/dist/worker-edges.js +17 -1
- package/package.json +3 -3
- package/src/boot/boot.saga.test.ts +22 -0
- package/src/boot/boot.saga.ts +14 -3
- package/src/client/services.test.ts +51 -0
- package/src/client/services.ts +39 -5
- package/src/kernel/effects.ts +2 -0
- package/src/query/register.saga.test.ts +18 -0
- package/src/query/register.saga.ts +5 -2
- package/src/query/sql.test.ts +18 -0
- package/src/query/sql.ts +19 -0
- package/src/services/database/remote.ts +42 -0
- package/src/state/lifecycle.test.ts +26 -3
- package/src/state/lifecycle.ts +19 -6
- package/src/state/reducers.test.ts +1 -1
- package/src/state/selectors.test.ts +8 -0
- package/src/state/selectors.ts +8 -2
package/AGENTS.md
CHANGED
|
@@ -33,7 +33,7 @@ re-materialized from state, never patched in place.
|
|
|
33
33
|
|
|
34
34
|
## Key exports (`src/index.ts`)
|
|
35
35
|
|
|
36
|
-
- `Sp00kyClient<S>` — main class. Methods: `init()`, `create(id, payload)`, `update(table, id, payload, options?)`, `delete(table, id)`, `query(table, opts?)`, `preload(query, { signal? })`, `run(backend, route, payload)`, `bucket(name)`, `remoteQuery(sql, vars?)`, `useRemote(fn)`, `authenticate(token)`, `auth.signOut()`. Admin impersonation (`impersonation.enabled` in sp00ky.yml, admins only, server-enforced): `auth.impersonate(targetId, reason)`, `auth.stopImpersonating()`, `auth.impersonation`, `auth.subscribeImpersonation(cb)`, `auth.searchImpersonationTargets(search)`; while active the client injects a warning bar with a Stop button and shifts the page down onto a hazard-striped layer. `impersonationBanner` in the client config restyles it (`theme`), replaces it (`mode: 'custom'` + `client.acknowledgeImpersonationBanner()`, else it falls back after ~2.5s) or turns it off (`mode: 'none'`). See `/docs/client/impersonation`. Traffic indicators: `pendingMutationCount` / `subscribeToPendingMutations(cb)` (outbox depth), `fetchingQueryCount` / `subscribeToFetchActivity(cb)
|
|
36
|
+
- `Sp00kyClient<S>` — main class. Methods: `init()`, `create(id, payload)`, `update(table, id, payload, options?)`, `delete(table, id)`, `query(table, opts?)`, `preload(query, { signal? })`, `run(backend, route, payload)`, `bucket(name)`, `remoteQuery(sql, vars?)`, `useRemote(fn)`, `authenticate(token)`, `auth.signOut()`. Admin impersonation (`impersonation.enabled` in sp00ky.yml, admins only, server-enforced): `auth.impersonate(targetId, reason)`, `auth.stopImpersonating()`, `auth.impersonation`, `auth.subscribeImpersonation(cb)`, `auth.searchImpersonationTargets(search)`; while active the client injects a warning bar with a Stop button and shifts the page down onto a hazard-striped layer. `impersonationBanner` in the client config restyles it (`theme`), replaces it (`mode: 'custom'` + `client.acknowledgeImpersonationBanner()`, else it falls back after ~2.5s) or turns it off (`mode: 'none'`). See `/docs/client/impersonation`. Traffic indicators: `pendingMutationCount` / `subscribeToPendingMutations(cb)` (outbox depth), `fetchingQueryCount` / `subscribeToFetchActivity(cb)` (queries a user waits on; a query that already shows rows and is only re-registering, as after a reload, is not counted). Failed-writes tray: `failedMutationCount`, `subscribeToFailedMutations(cb)`, `listFailedMutations()`, `retryFailedMutation(id)`, `discardFailedMutation(id)`.
|
|
37
37
|
- `FailedMutation` — a row of the `_00_failed_mutations` tray (`mutationType`, `recordId`, `data`, `beforeRecord`, `error`, `revert`).
|
|
38
38
|
- `BucketHandle` — file storage handle (`put`, `get`, `delete`, `exists`), plus the cache-aware `read`, `url`, `pin`/`unpin`, `evict`, `prefetch`. `get` is always remote; `read`/`url` go through the blob cache.
|
|
39
39
|
- `services/blobs/` — durable cache for bucket bytes: OPFS holds the files, `_00_blob` holds a manifest that reconcile rebuilds from disk (so a wiped local store costs metadata, not the offline cache). Nothing expires on a timer; eviction is LRU under a byte budget only, skipping pinned and on-screen entries. Config: `blobCache` in `types.ts`.
|
package/dist/index.d.ts
CHANGED
|
@@ -1011,6 +1011,11 @@ type OutEvent = {
|
|
|
1011
1011
|
* `remote` tracks the server-side registration, `fetchDepth` is the refcount
|
|
1012
1012
|
* behind `status: 'fetching'`, `notified` says whether subscribers received
|
|
1013
1013
|
* at least one materialization this registration.
|
|
1014
|
+
*
|
|
1015
|
+
* `backgroundDepth` is the part of `fetchDepth` that re-verifies rows the
|
|
1016
|
+
* query already shows: a registration of a query that is not `cold`. It still
|
|
1017
|
+
* holds `status: 'fetching'` (results are not yet confirmed), but it is not
|
|
1018
|
+
* traffic the user waits on, so the activity indicator leaves it out.
|
|
1014
1019
|
*/
|
|
1015
1020
|
type QueryPhase = 'cold' | 'cached' | 'live' | 'view-lost';
|
|
1016
1021
|
type RemotePhase = 'unregistered' | 'registering' | 'registered' | 'failed';
|
|
@@ -1018,6 +1023,7 @@ interface QueryLifecycle {
|
|
|
1018
1023
|
readonly phase: QueryPhase;
|
|
1019
1024
|
readonly remote: RemotePhase;
|
|
1020
1025
|
readonly fetchDepth: number;
|
|
1026
|
+
readonly backgroundDepth: number;
|
|
1021
1027
|
readonly notified: boolean;
|
|
1022
1028
|
}
|
|
1023
1029
|
//#endregion
|
|
@@ -1143,6 +1149,8 @@ type Effect = {
|
|
|
1143
1149
|
interface ServiceCalls {
|
|
1144
1150
|
'hint.read': () => string | null;
|
|
1145
1151
|
'hint.write': (bucketId: string) => void;
|
|
1152
|
+
'salt.take': (authId: string | null) => string | null;
|
|
1153
|
+
'salt.leave': (salt: string, authId: string | null) => void;
|
|
1146
1154
|
'local.connect': (bucketId: string) => Promise<void>;
|
|
1147
1155
|
'local.switchStore': (bucketId: string) => Promise<void>;
|
|
1148
1156
|
'local.beginSwitch': () => () => void;
|
package/dist/index.js
CHANGED
|
@@ -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.317";
|
|
2182
|
+
const WASM_VERSION = "0.0.1-canary.317";
|
|
2183
2183
|
const SURREAL_VERSION = "3.0.3";
|
|
2184
2184
|
var DevToolsService = class DevToolsService {
|
|
2185
2185
|
version = CORE_VERSION;
|
|
@@ -2910,6 +2910,7 @@ function seedLifecycle(resolvedBefore) {
|
|
|
2910
2910
|
phase: resolvedBefore ? "cached" : "cold",
|
|
2911
2911
|
remote: "unregistered",
|
|
2912
2912
|
fetchDepth: 0,
|
|
2913
|
+
backgroundDepth: 0,
|
|
2913
2914
|
notified: false
|
|
2914
2915
|
};
|
|
2915
2916
|
}
|
|
@@ -2946,12 +2947,19 @@ function transition(l, ev) {
|
|
|
2946
2947
|
};
|
|
2947
2948
|
case "fetch-begin": return {
|
|
2948
2949
|
...l,
|
|
2949
|
-
fetchDepth: l.fetchDepth + 1
|
|
2950
|
-
|
|
2951
|
-
case "fetch-end": return l.fetchDepth === 0 ? l : {
|
|
2952
|
-
...l,
|
|
2953
|
-
fetchDepth: l.fetchDepth - 1
|
|
2950
|
+
fetchDepth: l.fetchDepth + 1,
|
|
2951
|
+
backgroundDepth: l.backgroundDepth + (ev.background ? 1 : 0)
|
|
2954
2952
|
};
|
|
2953
|
+
case "fetch-end": {
|
|
2954
|
+
if (l.fetchDepth === 0) return l;
|
|
2955
|
+
const fetchDepth = l.fetchDepth - 1;
|
|
2956
|
+
const backgroundDepth = Math.min(fetchDepth, l.backgroundDepth - (ev.background && l.backgroundDepth > 0 ? 1 : 0));
|
|
2957
|
+
return {
|
|
2958
|
+
...l,
|
|
2959
|
+
fetchDepth,
|
|
2960
|
+
backgroundDepth
|
|
2961
|
+
};
|
|
2962
|
+
}
|
|
2955
2963
|
case "notified": return l.notified ? l : {
|
|
2956
2964
|
...l,
|
|
2957
2965
|
notified: true
|
|
@@ -2965,6 +2973,8 @@ function transition(l, ev) {
|
|
|
2965
2973
|
const isAuthoritative = (l) => l.phase !== "cold";
|
|
2966
2974
|
const hasServerMembership = (l) => l.phase === "live" || l.phase === "view-lost";
|
|
2967
2975
|
const deriveStatus = (l) => l.fetchDepth > 0 ? "fetching" : "idle";
|
|
2976
|
+
/** A fetch cycle the user is waiting on: rows to download, or a first registration. */
|
|
2977
|
+
const isForegroundFetching = (l) => l.fetchDepth > l.backgroundDepth;
|
|
2968
2978
|
|
|
2969
2979
|
//#endregion
|
|
2970
2980
|
//#region src/kernel/constants.ts
|
|
@@ -3571,7 +3581,13 @@ function retained(s) {
|
|
|
3571
3581
|
const pendingDeleteIds = (s) => new Set(s.outbox.filter((i) => i.type === "delete").map((i) => i.recordId));
|
|
3572
3582
|
const hasAckedWrites = (s) => s.outbox.some((i) => i.status === "acked");
|
|
3573
3583
|
const pendingMutationCount = (s) => s.outbox.filter((i) => i.status === "pending").length;
|
|
3574
|
-
|
|
3584
|
+
/**
|
|
3585
|
+
* Queries with traffic the user is waiting on. A re-registration of a query
|
|
3586
|
+
* that already shows rows (every mounted query after a reload) is left out:
|
|
3587
|
+
* it only confirms what the cache painted, and any newer body it finds is
|
|
3588
|
+
* fetched by `fetchRows`, which counts.
|
|
3589
|
+
*/
|
|
3590
|
+
const fetchingQueryCount = (s) => [...s.queries.values()].filter((e) => isForegroundFetching(e.lifecycle)).length;
|
|
3575
3591
|
/** Membership entries whose body is missing or older locally. */
|
|
3576
3592
|
function needed(s, hash) {
|
|
3577
3593
|
const e = s.queries.get(hash);
|
|
@@ -4362,6 +4378,22 @@ function heartbeatBatch(ids) {
|
|
|
4362
4378
|
}
|
|
4363
4379
|
/** A heartbeat answer whose statement matched no row: the view was reclaimed. */
|
|
4364
4380
|
const heartbeatRowGone = (result) => Array.isArray(result) && result.length === 0;
|
|
4381
|
+
const QUERY_ID = /^_00_query:(?:[0-9a-f]{64}|⟨[0-9a-f]{64}⟩)$/;
|
|
4382
|
+
const SESSION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
4383
|
+
/**
|
|
4384
|
+
* The pagehide release: one bare statement for `beaconSql`, which has no params
|
|
4385
|
+
* channel, so ids are inlined. Only shapes the client minted itself get in, a
|
|
4386
|
+
* `_00_query:<sha256>` id and a UUID session id, so nothing else is ever
|
|
4387
|
+
* interpolated. `null` when there is nothing the server could act on: with no
|
|
4388
|
+
* session ids it could not tell this page's entries from anyone else's.
|
|
4389
|
+
*/
|
|
4390
|
+
function leaveViewsBeacon(ids, sessionIds) {
|
|
4391
|
+
const list = ids.map((id) => String(id)).filter((id) => QUERY_ID.test(id));
|
|
4392
|
+
const sids = sessionIds.filter((sid) => SESSION_ID.test(sid));
|
|
4393
|
+
if (list.length === 0 || sids.length === 0) return null;
|
|
4394
|
+
const sidList = sids.map((sid) => `'${sid}'`).join(", ");
|
|
4395
|
+
return `FOR $id IN [${list.join(", ")}] { LET $_left = fn::query::leave($id, [${sidList}]); };`;
|
|
4396
|
+
}
|
|
4365
4397
|
const bodySelect = () => "SELECT * FROM $ids";
|
|
4366
4398
|
/** Local transaction that MERGEs many fetched bodies. */
|
|
4367
4399
|
function upsertBodiesTx(records) {
|
|
@@ -4751,7 +4783,11 @@ function* registerRemote(env, hash, retry = false) {
|
|
|
4751
4783
|
if (!entry) return;
|
|
4752
4784
|
const { remote } = entry.lifecycle;
|
|
4753
4785
|
if (remote === "registered" || remote === "failed" || remote === "registering" && !retry) return;
|
|
4754
|
-
|
|
4786
|
+
const background = entry.lifecycle.phase !== "cold";
|
|
4787
|
+
yield fx.state.update(compose(applyLifecycle(hash, { type: "remote-registering" }), applyLifecycle(hash, {
|
|
4788
|
+
type: "fetch-begin",
|
|
4789
|
+
background
|
|
4790
|
+
})));
|
|
4755
4791
|
try {
|
|
4756
4792
|
const table = listRefTable(env, state);
|
|
4757
4793
|
const results = yield fx.remote.query(registerSelect(table), registerVars({
|
|
@@ -4839,7 +4875,10 @@ function* registerRemote(env, hash, retry = false) {
|
|
|
4839
4875
|
retry: true
|
|
4840
4876
|
});
|
|
4841
4877
|
} finally {
|
|
4842
|
-
if (yield fx.state.read((s) => s.queries.has(hash))) yield fx.state.update(applyLifecycle(hash, {
|
|
4878
|
+
if (yield fx.state.read((s) => s.queries.has(hash))) yield fx.state.update(applyLifecycle(hash, {
|
|
4879
|
+
type: "fetch-end",
|
|
4880
|
+
background
|
|
4881
|
+
}));
|
|
4843
4882
|
}
|
|
4844
4883
|
}
|
|
4845
4884
|
|
|
@@ -5802,8 +5841,8 @@ function* boot(env, opts) {
|
|
|
5802
5841
|
yield fx.service("ssp.setPermissions");
|
|
5803
5842
|
yield fx.dispatch({ type: "PrimeCircuit" });
|
|
5804
5843
|
const restoredUserId = yield fx.service("auth.restoreSession");
|
|
5805
|
-
const salt = yield fx.id("salt");
|
|
5806
5844
|
const authId = yield fx.service("auth.sessionAuthId");
|
|
5845
|
+
const salt = (yield fx.service("salt.take", authId)) ?? (yield fx.id("salt"));
|
|
5807
5846
|
yield fx.state.update(setIdentity({
|
|
5808
5847
|
sessionId: salt,
|
|
5809
5848
|
userId: restoredUserId,
|
|
@@ -5895,9 +5934,19 @@ function* primeCircuit() {
|
|
|
5895
5934
|
function* versionsPrimed(entries) {
|
|
5896
5935
|
yield fx.state.update(setVersions(entries));
|
|
5897
5936
|
}
|
|
5898
|
-
/**
|
|
5937
|
+
/**
|
|
5938
|
+
* The page is going away for good: leave the salt for a reload of this tab,
|
|
5939
|
+
* then hand this tab's views back to the server. The release is deferred
|
|
5940
|
+
* server-side (`fn::query::leave`), so a reload that re-registers within the
|
|
5941
|
+
* grace finds its views still warm.
|
|
5942
|
+
*/
|
|
5899
5943
|
function* pageHide() {
|
|
5900
|
-
const ids = yield fx.state.read((s) => [
|
|
5944
|
+
const [salt, saltUserId, ids] = yield fx.state.read((s) => [
|
|
5945
|
+
s.sessionId,
|
|
5946
|
+
s.saltUserId,
|
|
5947
|
+
[...s.queries.values()].filter((e) => e.lifecycle.remote === "registered").map((e) => e.def.id)
|
|
5948
|
+
]);
|
|
5949
|
+
if (salt) yield fx.service("salt.leave", salt, saltUserId);
|
|
5901
5950
|
if (ids.length > 0) yield fx.service("remote.releaseViews", ids);
|
|
5902
5951
|
}
|
|
5903
5952
|
function* warmBlobs() {
|
|
@@ -8163,6 +8212,11 @@ function resolveReconnectConfig(input) {
|
|
|
8163
8212
|
* an app's own one-shot reads, small enough to stay polite to the server.
|
|
8164
8213
|
*/
|
|
8165
8214
|
const REMOTE_MAX_CONCURRENT_QUERIES = 6;
|
|
8215
|
+
/**
|
|
8216
|
+
* Session ids a page remembers for its pagehide release. Only the sessions of
|
|
8217
|
+
* the last ttl matter; this many reconnects inside one ttl is a broken network.
|
|
8218
|
+
*/
|
|
8219
|
+
const MAX_SESSION_IDS = 16;
|
|
8166
8220
|
var RemoteDatabaseService = class extends AbstractDatabaseService {
|
|
8167
8221
|
config;
|
|
8168
8222
|
eventType = DatabaseEventTypes.RemoteQuery;
|
|
@@ -8193,6 +8247,17 @@ var RemoteDatabaseService = class extends AbstractDatabaseService {
|
|
|
8193
8247
|
* returns nothing, on a page that still looks signed in.
|
|
8194
8248
|
*/
|
|
8195
8249
|
authToken = null;
|
|
8250
|
+
/**
|
|
8251
|
+
* Every `session::id()` this page's sockets have had, oldest first.
|
|
8252
|
+
*
|
|
8253
|
+
* The server stamps a view's `subscribers` entry with the session that
|
|
8254
|
+
* registered or heartbeated it, and every socket (an SDK reconnect, a
|
|
8255
|
+
* supervisor revive) is a new session. The pagehide release goes over HTTP,
|
|
8256
|
+
* whose own session id matches none of them, so it names these instead. Kept
|
|
8257
|
+
* for the page, not the socket: a view registered before a reconnect still
|
|
8258
|
+
* carries the old id until its next heartbeat.
|
|
8259
|
+
*/
|
|
8260
|
+
sessionIdsSeen = [];
|
|
8196
8261
|
constructor(config, logger) {
|
|
8197
8262
|
const events = createDatabaseEventSystem();
|
|
8198
8263
|
super(new Surreal({ engines: applyDiagnostics(createRemoteEngines(), ({ key, type, phase, ...other }) => {
|
|
@@ -8209,6 +8274,23 @@ var RemoteDatabaseService = class extends AbstractDatabaseService {
|
|
|
8209
8274
|
this.reconnectConfig = resolveReconnectConfig(config.reconnect);
|
|
8210
8275
|
this.queryTimeoutMs = Math.max(0, config.queryTimeoutMs ?? 6e4);
|
|
8211
8276
|
this.maxConcurrentQueries = REMOTE_MAX_CONCURRENT_QUERIES;
|
|
8277
|
+
this.client.subscribe("connected", () => void this.recordSessionId());
|
|
8278
|
+
}
|
|
8279
|
+
/** See {@link sessionIdsSeen}. */
|
|
8280
|
+
sessionIds() {
|
|
8281
|
+
return this.sessionIdsSeen;
|
|
8282
|
+
}
|
|
8283
|
+
/**
|
|
8284
|
+
* Ask the socket that just connected for its session id. Straight to the
|
|
8285
|
+
* SDK: it needs no namespace or identity, so it need not wait behind an
|
|
8286
|
+
* in-flight connect or take a slot from the query queue.
|
|
8287
|
+
*/
|
|
8288
|
+
async recordSessionId() {
|
|
8289
|
+
try {
|
|
8290
|
+
const [sid] = await this.client.query("RETURN <string>session::id()");
|
|
8291
|
+
if (typeof sid !== "string" || this.sessionIdsSeen.includes(sid)) return;
|
|
8292
|
+
this.sessionIdsSeen = [...this.sessionIdsSeen, sid].slice(-MAX_SESSION_IDS);
|
|
8293
|
+
} catch {}
|
|
8212
8294
|
}
|
|
8213
8295
|
getConfig() {
|
|
8214
8296
|
return this.config;
|
|
@@ -8225,6 +8307,10 @@ var RemoteDatabaseService = class extends AbstractDatabaseService {
|
|
|
8225
8307
|
*
|
|
8226
8308
|
* Best-effort by design: no await, no retry, errors swallowed. Every caller
|
|
8227
8309
|
* must have a server-side fallback that makes a lost beacon a non-event.
|
|
8310
|
+
*
|
|
8311
|
+
* The statement runs in a fresh HTTP session: `session::id()` there is a new
|
|
8312
|
+
* id, not this page's socket's, so anything keyed on it must be passed the
|
|
8313
|
+
* ids from {@link sessionIds} instead.
|
|
8228
8314
|
*/
|
|
8229
8315
|
beaconSql(sql) {
|
|
8230
8316
|
try {
|
|
@@ -14587,6 +14673,38 @@ function writeBootBucketHint(bucketId) {
|
|
|
14587
14673
|
if (typeof localStorage !== "undefined") localStorage.setItem(LAST_BUCKET_KEY, bucketId);
|
|
14588
14674
|
} catch {}
|
|
14589
14675
|
}
|
|
14676
|
+
const RELOAD_SALT_KEY = "sp00ky:salt";
|
|
14677
|
+
/**
|
|
14678
|
+
* Take the salt this tab's previous page left on its way out, if it was minted
|
|
14679
|
+
* for the same principal. A reload that keeps its salt re-registers every
|
|
14680
|
+
* query onto the `_00_query` row it already had, which the SSP answers from
|
|
14681
|
+
* the live view instead of building a new one.
|
|
14682
|
+
*
|
|
14683
|
+
* Taken, not read: the key is absent while a page lives, so a duplicated tab
|
|
14684
|
+
* (which copies sessionStorage) mints its own salt rather than sharing this
|
|
14685
|
+
* tab's remote views.
|
|
14686
|
+
*/
|
|
14687
|
+
function takeReloadSalt(authId) {
|
|
14688
|
+
try {
|
|
14689
|
+
if (typeof sessionStorage === "undefined") return null;
|
|
14690
|
+
const raw = sessionStorage.getItem(RELOAD_SALT_KEY);
|
|
14691
|
+
if (raw === null) return null;
|
|
14692
|
+
sessionStorage.removeItem(RELOAD_SALT_KEY);
|
|
14693
|
+
const left = JSON.parse(raw);
|
|
14694
|
+
return typeof left.salt === "string" && left.salt !== "" && (left.authId ?? null) === authId ? left.salt : null;
|
|
14695
|
+
} catch {
|
|
14696
|
+
return null;
|
|
14697
|
+
}
|
|
14698
|
+
}
|
|
14699
|
+
/** Leave this page's salt for the next page in this tab; see {@link takeReloadSalt}. */
|
|
14700
|
+
function leaveReloadSalt(salt, authId) {
|
|
14701
|
+
try {
|
|
14702
|
+
if (typeof sessionStorage !== "undefined") sessionStorage.setItem(RELOAD_SALT_KEY, JSON.stringify({
|
|
14703
|
+
salt,
|
|
14704
|
+
authId
|
|
14705
|
+
}));
|
|
14706
|
+
} catch {}
|
|
14707
|
+
}
|
|
14590
14708
|
function mintSalt() {
|
|
14591
14709
|
const c = globalThis.crypto;
|
|
14592
14710
|
if (c?.randomUUID) return c.randomUUID();
|
|
@@ -14648,7 +14766,7 @@ function createTabsCoordinator(config, s, host) {
|
|
|
14648
14766
|
return new TabsCoordinator({
|
|
14649
14767
|
tabId: s.tabId,
|
|
14650
14768
|
fingerprint: computeTabsFingerprint({
|
|
14651
|
-
coreVersion: "0.0.1-canary.
|
|
14769
|
+
coreVersion: "0.0.1-canary.317",
|
|
14652
14770
|
schemaHash: hash53(config.schemaSurql),
|
|
14653
14771
|
endpoint: config.database.endpoint ?? "",
|
|
14654
14772
|
namespace: config.database.namespace,
|
|
@@ -14752,6 +14870,8 @@ function createAdapters(config, s, host, lateModules) {
|
|
|
14752
14870
|
const services = {
|
|
14753
14871
|
"hint.read": readBootBucketHint,
|
|
14754
14872
|
"hint.write": writeBootBucketHint,
|
|
14873
|
+
"salt.take": takeReloadSalt,
|
|
14874
|
+
"salt.leave": leaveReloadSalt,
|
|
14755
14875
|
"local.connect": (bucketId) => s.local.connect(bucketId),
|
|
14756
14876
|
"local.switchStore": (bucketId) => s.local.switchStore(bucketId),
|
|
14757
14877
|
"local.beginSwitch": () => s.local.beginSwitch(),
|
|
@@ -14798,8 +14918,8 @@ function createAdapters(config, s, host, lateModules) {
|
|
|
14798
14918
|
"auth.consumeEndedImpersonation": () => s.auth.consumeEndedImpersonation(),
|
|
14799
14919
|
"remote.connect": () => s.remote.connect(),
|
|
14800
14920
|
"remote.releaseViews": (ids) => {
|
|
14801
|
-
const
|
|
14802
|
-
if (
|
|
14921
|
+
const sql = leaveViewsBeacon(ids, s.remote.sessionIds());
|
|
14922
|
+
if (sql) s.remote.beaconSql(sql);
|
|
14803
14923
|
},
|
|
14804
14924
|
"supervisor.start": () => s.connectionSupervisor.start(),
|
|
14805
14925
|
"tabs.start": (bucketId) => {
|
package/dist/live.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { B as queryHashInput, C as heartbeatRowGone, M as registerVars, N as singleSnapshotSelect, S as heartbeatBatch, a as metaFromRow, b as bodySelect, c as parseDuration, d as parseQueryParams, f as ANON_USER_ID, h as listRefTableFor, j as registerSelect, l as parseRecordIdString, n as rowOfEdge, p as DEFAULT_REF_MODE, s as encodeRecordId, t as hashOfEdge, u as withTimeout, z as sha256Hex } from "./worker-edges.js";
|
|
2
2
|
import { n as decodeTokenClaims } from "./worker-impersonation.js";
|
|
3
3
|
import { RecordId, Surreal } from "surrealdb";
|
|
4
4
|
|
package/dist/pure.d.ts
CHANGED
|
@@ -217,6 +217,14 @@ declare function heartbeatBatch(ids: ReadonlyArray<RecordId<string>>): {
|
|
|
217
217
|
};
|
|
218
218
|
/** A heartbeat answer whose statement matched no row: the view was reclaimed. */
|
|
219
219
|
declare const heartbeatRowGone: (result: unknown) => boolean;
|
|
220
|
+
/**
|
|
221
|
+
* The pagehide release: one bare statement for `beaconSql`, which has no params
|
|
222
|
+
* channel, so ids are inlined. Only shapes the client minted itself get in, a
|
|
223
|
+
* `_00_query:<sha256>` id and a UUID session id, so nothing else is ever
|
|
224
|
+
* interpolated. `null` when there is nothing the server could act on: with no
|
|
225
|
+
* session ids it could not tell this page's entries from anyone else's.
|
|
226
|
+
*/
|
|
227
|
+
declare function leaveViewsBeacon(ids: ReadonlyArray<unknown>, sessionIds: ReadonlyArray<string>): string | null;
|
|
220
228
|
declare const bodySelect: () => string;
|
|
221
229
|
/** Local transaction that MERGEs many fetched bodies. */
|
|
222
230
|
declare function upsertBodiesTx(records: ReadonlyArray<{
|
|
@@ -326,4 +334,4 @@ declare function sha256Hex(input: string): Promise<string>;
|
|
|
326
334
|
*/
|
|
327
335
|
declare function queryKey(input: QueryKeyInput, sessionId: string | null): Promise<string>;
|
|
328
336
|
//#endregion
|
|
329
|
-
export { ANON_USER_ID, type Answer, BridgeSignOutMessage, BridgeTokenMessage, DEFAULT_REF_MODE, FAILED, IMPERSONATION_ACCESS, LEGACY_VIEW_TABLE, type ListRefSnapshot, PUSH_PAYLOAD_VERSION, PushNotification, PushNotificationAction, PushOp, PushPayload, PushPayloadKind, PushUrgency, type QueryKeyInput, type QueryMetaRow, type RefMode, RegisterPayload, type SealOptions, type SealedQuery, type SurqlHelper, type TokenClaims, type TxQuery, VIEW_TABLE, WebPushClientMessage, base64UrlToBytes, batchSnapshotSelect, bodySelect, bucketIdForUser, bytesToBase64Url, countViewRows, decodeTokenClaims, dedupeRecordVersions, encodeRecordId, hashOfEdge, heartbeatBatch, heartbeatRowGone, isPushPayload, listRefBatchSelect, listRefSelect, listRefTableFor, metaFromRow, parseDuration, parseQueryParams, parseRecordIdString, queryHashInput, queryKey, queryRowCountBatchSelect, queryRowCountSelect, readLegacyViewRows, readViewRows, registerSelect, registerVars, rowOfEdge, sameBytes, sanitizeUserId, sha256Hex, singleSnapshotSelect, snapshotFromSingle, subqueryListRefSelect, surql, upsertBodiesTx, viewKeyInput, viewRecordId, viewRow };
|
|
337
|
+
export { ANON_USER_ID, type Answer, BridgeSignOutMessage, BridgeTokenMessage, DEFAULT_REF_MODE, FAILED, IMPERSONATION_ACCESS, LEGACY_VIEW_TABLE, type ListRefSnapshot, PUSH_PAYLOAD_VERSION, PushNotification, PushNotificationAction, PushOp, PushPayload, PushPayloadKind, PushUrgency, type QueryKeyInput, type QueryMetaRow, type RefMode, RegisterPayload, type SealOptions, type SealedQuery, type SurqlHelper, type TokenClaims, type TxQuery, VIEW_TABLE, WebPushClientMessage, base64UrlToBytes, batchSnapshotSelect, bodySelect, bucketIdForUser, bytesToBase64Url, countViewRows, decodeTokenClaims, dedupeRecordVersions, encodeRecordId, hashOfEdge, heartbeatBatch, heartbeatRowGone, isPushPayload, leaveViewsBeacon, listRefBatchSelect, listRefSelect, listRefTableFor, metaFromRow, parseDuration, parseQueryParams, parseRecordIdString, queryHashInput, queryKey, queryRowCountBatchSelect, queryRowCountSelect, readLegacyViewRows, readViewRows, registerSelect, registerVars, rowOfEdge, sameBytes, sanitizeUserId, sha256Hex, singleSnapshotSelect, snapshotFromSingle, subqueryListRefSelect, surql, upsertBodiesTx, viewKeyInput, viewRecordId, viewRow };
|
package/dist/pure.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { A as
|
|
1
|
+
import { A as readViewRows, B as queryHashInput, C as heartbeatRowGone, D as queryRowCountBatchSelect, E as listRefSelect, F as upsertBodiesTx, I as viewRecordId, L as viewRow, M as registerVars, N as singleSnapshotSelect, O as queryRowCountSelect, P as subqueryListRefSelect, R as surql, S as heartbeatBatch, T as listRefBatchSelect, V as viewKeyInput, _ as LEGACY_VIEW_TABLE, a as metaFromRow, b as bodySelect, c as parseDuration, d as parseQueryParams, f as ANON_USER_ID, g as sanitizeUserId, h as listRefTableFor, i as dedupeRecordVersions, j as registerSelect, k as readLegacyViewRows, l as parseRecordIdString, m as bucketIdForUser, n as rowOfEdge, o as snapshotFromSingle, p as DEFAULT_REF_MODE, r as FAILED, s as encodeRecordId, t as hashOfEdge, v as VIEW_TABLE, w as leaveViewsBeacon, x as countViewRows, y as batchSnapshotSelect, z as sha256Hex } from "./worker-edges.js";
|
|
2
2
|
import { n as decodeTokenClaims, t as IMPERSONATION_ACCESS } from "./worker-impersonation.js";
|
|
3
3
|
import { a as sameBytes, i as isPushPayload, n as base64UrlToBytes, r as bytesToBase64Url, t as PUSH_PAYLOAD_VERSION } from "./worker-types.js";
|
|
4
4
|
|
|
@@ -21,4 +21,4 @@ function queryKey(input, sessionId) {
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
//#endregion
|
|
24
|
-
export { ANON_USER_ID, DEFAULT_REF_MODE, FAILED, IMPERSONATION_ACCESS, LEGACY_VIEW_TABLE, PUSH_PAYLOAD_VERSION, VIEW_TABLE, base64UrlToBytes, batchSnapshotSelect, bodySelect, bucketIdForUser, bytesToBase64Url, countViewRows, decodeTokenClaims, dedupeRecordVersions, encodeRecordId, hashOfEdge, heartbeatBatch, heartbeatRowGone, isPushPayload, listRefBatchSelect, listRefSelect, listRefTableFor, metaFromRow, parseDuration, parseQueryParams, parseRecordIdString, queryHashInput, queryKey, queryRowCountBatchSelect, queryRowCountSelect, readLegacyViewRows, readViewRows, registerSelect, registerVars, rowOfEdge, sameBytes, sanitizeUserId, sha256Hex, singleSnapshotSelect, snapshotFromSingle, subqueryListRefSelect, surql, upsertBodiesTx, viewKeyInput, viewRecordId, viewRow };
|
|
24
|
+
export { ANON_USER_ID, DEFAULT_REF_MODE, FAILED, IMPERSONATION_ACCESS, LEGACY_VIEW_TABLE, PUSH_PAYLOAD_VERSION, VIEW_TABLE, base64UrlToBytes, batchSnapshotSelect, bodySelect, bucketIdForUser, bytesToBase64Url, countViewRows, decodeTokenClaims, dedupeRecordVersions, encodeRecordId, hashOfEdge, heartbeatBatch, heartbeatRowGone, isPushPayload, leaveViewsBeacon, listRefBatchSelect, listRefSelect, listRefTableFor, metaFromRow, parseDuration, parseQueryParams, parseRecordIdString, queryHashInput, queryKey, queryRowCountBatchSelect, queryRowCountSelect, readLegacyViewRows, readViewRows, registerSelect, registerVars, rowOfEdge, sameBytes, sanitizeUserId, sha256Hex, singleSnapshotSelect, snapshotFromSingle, subqueryListRefSelect, surql, upsertBodiesTx, viewKeyInput, viewRecordId, viewRow };
|
package/dist/types.d.ts
CHANGED
|
@@ -659,7 +659,26 @@ declare class RemoteDatabaseService extends AbstractDatabaseService {
|
|
|
659
659
|
* returns nothing, on a page that still looks signed in.
|
|
660
660
|
*/
|
|
661
661
|
private authToken;
|
|
662
|
+
/**
|
|
663
|
+
* Every `session::id()` this page's sockets have had, oldest first.
|
|
664
|
+
*
|
|
665
|
+
* The server stamps a view's `subscribers` entry with the session that
|
|
666
|
+
* registered or heartbeated it, and every socket (an SDK reconnect, a
|
|
667
|
+
* supervisor revive) is a new session. The pagehide release goes over HTTP,
|
|
668
|
+
* whose own session id matches none of them, so it names these instead. Kept
|
|
669
|
+
* for the page, not the socket: a view registered before a reconnect still
|
|
670
|
+
* carries the old id until its next heartbeat.
|
|
671
|
+
*/
|
|
672
|
+
private sessionIdsSeen;
|
|
662
673
|
constructor(config: Sp00kyConfig<any>['database'], logger: Logger$1);
|
|
674
|
+
/** See {@link sessionIdsSeen}. */
|
|
675
|
+
sessionIds(): readonly string[];
|
|
676
|
+
/**
|
|
677
|
+
* Ask the socket that just connected for its session id. Straight to the
|
|
678
|
+
* SDK: it needs no namespace or identity, so it need not wait behind an
|
|
679
|
+
* in-flight connect or take a slot from the query queue.
|
|
680
|
+
*/
|
|
681
|
+
private recordSessionId;
|
|
663
682
|
getConfig(): Sp00kyConfig<any>['database'];
|
|
664
683
|
/**
|
|
665
684
|
* Send one SurrealQL statement so that it survives the page going away.
|
|
@@ -673,6 +692,10 @@ declare class RemoteDatabaseService extends AbstractDatabaseService {
|
|
|
673
692
|
*
|
|
674
693
|
* Best-effort by design: no await, no retry, errors swallowed. Every caller
|
|
675
694
|
* must have a server-side fallback that makes a lost beacon a non-event.
|
|
695
|
+
*
|
|
696
|
+
* The statement runs in a fresh HTTP session: `session::id()` there is a new
|
|
697
|
+
* id, not this page's socket's, so anything keyed on it must be passed the
|
|
698
|
+
* ids from {@link sessionIds} instead.
|
|
676
699
|
*/
|
|
677
700
|
beaconSql(sql: string): void;
|
|
678
701
|
/**
|
package/dist/worker-edges.js
CHANGED
|
@@ -147,6 +147,22 @@ function heartbeatBatch(ids) {
|
|
|
147
147
|
}
|
|
148
148
|
/** A heartbeat answer whose statement matched no row: the view was reclaimed. */
|
|
149
149
|
const heartbeatRowGone = (result) => Array.isArray(result) && result.length === 0;
|
|
150
|
+
const QUERY_ID = /^_00_query:(?:[0-9a-f]{64}|⟨[0-9a-f]{64}⟩)$/;
|
|
151
|
+
const SESSION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
152
|
+
/**
|
|
153
|
+
* The pagehide release: one bare statement for `beaconSql`, which has no params
|
|
154
|
+
* channel, so ids are inlined. Only shapes the client minted itself get in, a
|
|
155
|
+
* `_00_query:<sha256>` id and a UUID session id, so nothing else is ever
|
|
156
|
+
* interpolated. `null` when there is nothing the server could act on: with no
|
|
157
|
+
* session ids it could not tell this page's entries from anyone else's.
|
|
158
|
+
*/
|
|
159
|
+
function leaveViewsBeacon(ids, sessionIds) {
|
|
160
|
+
const list = ids.map((id) => String(id)).filter((id) => QUERY_ID.test(id));
|
|
161
|
+
const sids = sessionIds.filter((sid) => SESSION_ID.test(sid));
|
|
162
|
+
if (list.length === 0 || sids.length === 0) return null;
|
|
163
|
+
const sidList = sids.map((sid) => `'${sid}'`).join(", ");
|
|
164
|
+
return `FOR $id IN [${list.join(", ")}] { LET $_left = fn::query::leave($id, [${sidList}]); };`;
|
|
165
|
+
}
|
|
150
166
|
const bodySelect = () => "SELECT * FROM $ids";
|
|
151
167
|
/** Local transaction that MERGEs many fetched bodies. */
|
|
152
168
|
function upsertBodiesTx(records) {
|
|
@@ -420,4 +436,4 @@ function rowOfEdge(value) {
|
|
|
420
436
|
}
|
|
421
437
|
|
|
422
438
|
//#endregion
|
|
423
|
-
export {
|
|
439
|
+
export { readViewRows as A, queryHashInput as B, heartbeatRowGone as C, queryRowCountBatchSelect as D, listRefSelect as E, upsertBodiesTx as F, viewRecordId as I, viewRow as L, registerVars as M, singleSnapshotSelect as N, queryRowCountSelect as O, subqueryListRefSelect as P, surql as R, heartbeatBatch as S, listRefBatchSelect as T, viewKeyInput as V, LEGACY_VIEW_TABLE as _, metaFromRow as a, bodySelect as b, parseDuration as c, parseQueryParams as d, ANON_USER_ID as f, sanitizeUserId as g, listRefTableFor as h, dedupeRecordVersions as i, registerSelect as j, readLegacyViewRows as k, parseRecordIdString as l, bucketIdForUser as m, rowOfEdge as n, snapshotFromSingle as o, DEFAULT_REF_MODE as p, FAILED as r, encodeRecordId as s, hashOfEdge as t, withTimeout as u, VIEW_TABLE as v, leaveViewsBeacon as w, countViewRows as x, batchSnapshotSelect as y, sha256Hex as z };
|
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.317",
|
|
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.317",
|
|
79
|
+
"@spooky-sync/ssp-wasm": "0.0.1-canary.317",
|
|
80
80
|
"@sqlite.org/sqlite-wasm": "3.53.0-build1",
|
|
81
81
|
"@surrealdb/wasm": "^3.0.3",
|
|
82
82
|
"blurhash": "^2.0.5",
|
|
@@ -34,6 +34,7 @@ describe('boot', () => {
|
|
|
34
34
|
'ssp.setPermissions',
|
|
35
35
|
'auth.restoreSession',
|
|
36
36
|
'auth.sessionAuthId',
|
|
37
|
+
'salt.take',
|
|
37
38
|
'crdt.setSessionId',
|
|
38
39
|
'auth.access',
|
|
39
40
|
'ssp.setSessionAuth',
|
|
@@ -49,6 +50,18 @@ describe('boot', () => {
|
|
|
49
50
|
expect(out.timers.get('gc')).toEqual({ ms: 30_000, event: { type: 'GcTick' } });
|
|
50
51
|
expect(svc.names()).not.toContain('remote.connect');
|
|
51
52
|
});
|
|
53
|
+
it('a reload keeps the salt its tab left for the same principal', async () => {
|
|
54
|
+
const svc = fakeServices({
|
|
55
|
+
'hint.read': () => 'u1',
|
|
56
|
+
'auth.restoreSession': async () => 'user:u1',
|
|
57
|
+
'auth.sessionAuthId': () => 'user:u1',
|
|
58
|
+
'salt.take': () => 'kept-salt',
|
|
59
|
+
});
|
|
60
|
+
const out = await runPure(boot(env, { sharedTabs: false }), { handlers: { service: svc.handler, 'local.query': () => [] } });
|
|
61
|
+
expect(svc.calls).toContainEqual(['salt.take', ['user:u1']]);
|
|
62
|
+
expect(svc.calls).toContainEqual(['crdt.setSessionId', ['kept-salt']]);
|
|
63
|
+
expect(out.state).toMatchObject({ sessionId: 'kept-salt', saltUserId: 'user:u1' });
|
|
64
|
+
});
|
|
52
65
|
it('shared tabs: takes the role from the coordinator, or falls back to solo; schemaless engines skip provisioning; anonymous skips session auth', async () => {
|
|
53
66
|
const leader = fakeServices({ 'hint.read': () => null, 'tabs.start': async () => 'leader', 'local.usesSurqlSchema': () => false, 'auth.restoreSession': async () => null, 'auth.sessionAuthId': () => null });
|
|
54
67
|
const out = await runPure(boot(env, { sharedTabs: true }), { handlers: { service: leader.handler, 'local.query': () => [] } });
|
|
@@ -133,6 +146,15 @@ describe('startRemote / primeCircuit / versionsPrimed / pageHide / warmBlobs', (
|
|
|
133
146
|
expect(f.state.primed).toBe(true);
|
|
134
147
|
expect(f.emitted).toEqual([expect.objectContaining({ level: 'warn' })]);
|
|
135
148
|
});
|
|
149
|
+
it('pageHide leaves the salt for a reload before releasing views', async () => {
|
|
150
|
+
const svc = fakeServices();
|
|
151
|
+
const s = { ...buildState([buildEntry({ def: { hash: 'a' }, lifecycle: { remote: 'registered' } })]), sessionId: 'salt-1', saltUserId: 'user:u1' };
|
|
152
|
+
await runPure(pageHide(), { state: s, handlers: { service: svc.handler } });
|
|
153
|
+
expect(svc.calls).toEqual([
|
|
154
|
+
['salt.leave', ['salt-1', 'user:u1']],
|
|
155
|
+
['remote.releaseViews', [[new RecordId('_00_query', 'a')]]],
|
|
156
|
+
]);
|
|
157
|
+
});
|
|
136
158
|
it('versionsPrimed records versions; pageHide releases registered views; warmBlobs is best-effort', async () => {
|
|
137
159
|
const v = await runPure(versionsPrimed([['thing:1', 3]]), { state: buildState() });
|
|
138
160
|
expect(v.state.versions.get('thing:1')).toBe(3);
|
package/src/boot/boot.saga.ts
CHANGED
|
@@ -43,8 +43,9 @@ export function* boot(env: SagaEnv, opts: BootOptions): Saga<void> {
|
|
|
43
43
|
yield fx.dispatch({ type: 'PrimeCircuit' });
|
|
44
44
|
|
|
45
45
|
const restoredUserId = (yield fx.service('auth.restoreSession')) as string | null;
|
|
46
|
-
const salt = (yield fx.id('salt')) as string;
|
|
47
46
|
const authId = (yield fx.service('auth.sessionAuthId')) as string | null;
|
|
47
|
+
// A reload keeps the salt its tab had, so registrations land on warm views.
|
|
48
|
+
const salt = (((yield fx.service('salt.take', authId)) as string | null) ?? (yield fx.id('salt'))) as string;
|
|
48
49
|
yield fx.state.update(R.setIdentity({ sessionId: salt, userId: restoredUserId, saltUserId: authId }));
|
|
49
50
|
yield fx.service('crdt.setSessionId', salt);
|
|
50
51
|
if (restoredUserId) yield fx.service('ssp.setSessionAuth', authId, (yield fx.service('auth.access')) as string | null);
|
|
@@ -115,9 +116,19 @@ export function* versionsPrimed(entries: Array<readonly [string, number]>): Saga
|
|
|
115
116
|
yield fx.state.update(R.setVersions(entries));
|
|
116
117
|
}
|
|
117
118
|
|
|
118
|
-
/**
|
|
119
|
+
/**
|
|
120
|
+
* The page is going away for good: leave the salt for a reload of this tab,
|
|
121
|
+
* then hand this tab's views back to the server. The release is deferred
|
|
122
|
+
* server-side (`fn::query::leave`), so a reload that re-registers within the
|
|
123
|
+
* grace finds its views still warm.
|
|
124
|
+
*/
|
|
119
125
|
export function* pageHide(): Saga<void> {
|
|
120
|
-
const ids = (yield fx.state.read((s) => [
|
|
126
|
+
const [salt, saltUserId, ids] = (yield fx.state.read((s) => [
|
|
127
|
+
s.sessionId,
|
|
128
|
+
s.saltUserId,
|
|
129
|
+
[...s.queries.values()].filter((e) => e.lifecycle.remote === 'registered').map((e) => e.def.id),
|
|
130
|
+
])) as [string | null, string | null, unknown[]];
|
|
131
|
+
if (salt) yield fx.service('salt.leave', salt, saltUserId);
|
|
121
132
|
if (ids.length > 0) yield fx.service('remote.releaseViews', ids);
|
|
122
133
|
}
|
|
123
134
|
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
2
|
+
import { leaveReloadSalt, takeReloadSalt } from './services';
|
|
3
|
+
|
|
4
|
+
class MemoryStorage {
|
|
5
|
+
private items = new Map<string, string>();
|
|
6
|
+
getItem(key: string): string | null {
|
|
7
|
+
return this.items.get(key) ?? null;
|
|
8
|
+
}
|
|
9
|
+
setItem(key: string, value: string): void {
|
|
10
|
+
this.items.set(key, value);
|
|
11
|
+
}
|
|
12
|
+
removeItem(key: string): void {
|
|
13
|
+
this.items.delete(key);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe('reload salt', () => {
|
|
18
|
+
const g = globalThis as { sessionStorage?: unknown };
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
g.sessionStorage = new MemoryStorage();
|
|
21
|
+
});
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
delete g.sessionStorage;
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('a salt left on the way out is taken once, by the same principal', () => {
|
|
27
|
+
leaveReloadSalt('s1', 'user:a');
|
|
28
|
+
expect(takeReloadSalt('user:a')).toBe('s1');
|
|
29
|
+
// Taken, not read: a tab duplicated from this page finds nothing.
|
|
30
|
+
expect(takeReloadSalt('user:a')).toBeNull();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('another principal, or none, mints a fresh salt', () => {
|
|
34
|
+
leaveReloadSalt('s1', 'user:a');
|
|
35
|
+
expect(takeReloadSalt('user:b')).toBeNull();
|
|
36
|
+
leaveReloadSalt('s2', null);
|
|
37
|
+
expect(takeReloadSalt(null)).toBe('s2');
|
|
38
|
+
leaveReloadSalt('s3', null);
|
|
39
|
+
expect(takeReloadSalt('user:a')).toBeNull();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('garbage or missing storage is a fresh salt, never a throw', () => {
|
|
43
|
+
(g.sessionStorage as MemoryStorage).setItem('sp00ky:salt', '{not json');
|
|
44
|
+
expect(takeReloadSalt(null)).toBeNull();
|
|
45
|
+
(g.sessionStorage as MemoryStorage).setItem('sp00ky:salt', JSON.stringify({ salt: '', authId: null }));
|
|
46
|
+
expect(takeReloadSalt(null)).toBeNull();
|
|
47
|
+
delete g.sessionStorage;
|
|
48
|
+
expect(takeReloadSalt(null)).toBeNull();
|
|
49
|
+
expect(() => leaveReloadSalt('s1', null)).not.toThrow();
|
|
50
|
+
});
|
|
51
|
+
});
|
package/src/client/services.ts
CHANGED
|
@@ -28,6 +28,7 @@ import type { RuntimeEvent } from '../kernel/events';
|
|
|
28
28
|
import type { ServiceCalls } from '../kernel/effects';
|
|
29
29
|
import { encodeRecordId } from '../utils/index';
|
|
30
30
|
import { sha256Hex } from '../utils/sha256';
|
|
31
|
+
import { leaveViewsBeacon } from '../query/sql';
|
|
31
32
|
import { hashOfEdge, rowOfEdge } from '../utils/edges';
|
|
32
33
|
import { mintMutationId } from '../mutation/mutation-id';
|
|
33
34
|
import { BucketHandle, bucketContentToBlob } from './bucket-handle';
|
|
@@ -50,6 +51,40 @@ export function writeBootBucketHint(bucketId: string): void {
|
|
|
50
51
|
}
|
|
51
52
|
}
|
|
52
53
|
|
|
54
|
+
const RELOAD_SALT_KEY = 'sp00ky:salt';
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Take the salt this tab's previous page left on its way out, if it was minted
|
|
58
|
+
* for the same principal. A reload that keeps its salt re-registers every
|
|
59
|
+
* query onto the `_00_query` row it already had, which the SSP answers from
|
|
60
|
+
* the live view instead of building a new one.
|
|
61
|
+
*
|
|
62
|
+
* Taken, not read: the key is absent while a page lives, so a duplicated tab
|
|
63
|
+
* (which copies sessionStorage) mints its own salt rather than sharing this
|
|
64
|
+
* tab's remote views.
|
|
65
|
+
*/
|
|
66
|
+
export function takeReloadSalt(authId: string | null): string | null {
|
|
67
|
+
try {
|
|
68
|
+
if (typeof sessionStorage === 'undefined') return null;
|
|
69
|
+
const raw = sessionStorage.getItem(RELOAD_SALT_KEY);
|
|
70
|
+
if (raw === null) return null;
|
|
71
|
+
sessionStorage.removeItem(RELOAD_SALT_KEY);
|
|
72
|
+
const left = JSON.parse(raw) as { salt?: unknown; authId?: unknown };
|
|
73
|
+
return typeof left.salt === 'string' && left.salt !== '' && (left.authId ?? null) === authId ? left.salt : null;
|
|
74
|
+
} catch {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Leave this page's salt for the next page in this tab; see {@link takeReloadSalt}. */
|
|
80
|
+
export function leaveReloadSalt(salt: string, authId: string | null): void {
|
|
81
|
+
try {
|
|
82
|
+
if (typeof sessionStorage !== 'undefined') sessionStorage.setItem(RELOAD_SALT_KEY, JSON.stringify({ salt, authId }));
|
|
83
|
+
} catch {
|
|
84
|
+
/* private-mode storage errors: the next page mints a fresh salt */
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
53
88
|
// Moved to a pure module so `@spooky-sync/core/pure` and the live feed share
|
|
54
89
|
// it; re-exported here for existing imports.
|
|
55
90
|
export { sha256Hex };
|
|
@@ -220,6 +255,8 @@ export function createAdapters<S extends SchemaStructure>(config: Sp00kyConfig<S
|
|
|
220
255
|
const services: ServiceCalls = {
|
|
221
256
|
'hint.read': readBootBucketHint,
|
|
222
257
|
'hint.write': writeBootBucketHint,
|
|
258
|
+
'salt.take': takeReloadSalt,
|
|
259
|
+
'salt.leave': leaveReloadSalt,
|
|
223
260
|
'local.connect': (bucketId) => s.local.connect(bucketId),
|
|
224
261
|
'local.switchStore': (bucketId) => s.local.switchStore(bucketId),
|
|
225
262
|
'local.beginSwitch': () => s.local.beginSwitch(),
|
|
@@ -260,11 +297,8 @@ export function createAdapters<S extends SchemaStructure>(config: Sp00kyConfig<S
|
|
|
260
297
|
'auth.consumeEndedImpersonation': () => s.auth.consumeEndedImpersonation(),
|
|
261
298
|
'remote.connect': () => s.remote.connect(),
|
|
262
299
|
'remote.releaseViews': (ids) => {
|
|
263
|
-
const
|
|
264
|
-
|
|
265
|
-
.filter((id) => /^_00_query:[0-9a-f]{64}$/.test(id))
|
|
266
|
-
.join(', ');
|
|
267
|
-
if (list) s.remote.beaconSql(`FOR $id IN [${list}] { LET $_released = fn::query::unsubscribe($id); };`);
|
|
300
|
+
const sql = leaveViewsBeacon(ids, s.remote.sessionIds());
|
|
301
|
+
if (sql) s.remote.beaconSql(sql);
|
|
268
302
|
},
|
|
269
303
|
'supervisor.start': () => s.connectionSupervisor.start(),
|
|
270
304
|
'tabs.start': (bucketId) => {
|
package/src/kernel/effects.ts
CHANGED
|
@@ -70,6 +70,8 @@ export type EffectKind = Effect['kind'];
|
|
|
70
70
|
export interface ServiceCalls {
|
|
71
71
|
'hint.read': () => string | null;
|
|
72
72
|
'hint.write': (bucketId: string) => void;
|
|
73
|
+
'salt.take': (authId: string | null) => string | null;
|
|
74
|
+
'salt.leave': (salt: string, authId: string | null) => void;
|
|
73
75
|
'local.connect': (bucketId: string) => Promise<void>;
|
|
74
76
|
'local.switchStore': (bucketId: string) => Promise<void>;
|
|
75
77
|
'local.beginSwitch': () => () => void;
|
|
@@ -156,6 +156,24 @@ describe('registerRemote', () => {
|
|
|
156
156
|
expect(out.dispatched).toEqual([{ type: 'FetchRows' }, { type: 'FetchRows' }, { type: 'SyncOutcome', ok: true }]);
|
|
157
157
|
expect(out.emitted).toContainEqual({ type: 'query:authority', hash: 'a', known: true });
|
|
158
158
|
});
|
|
159
|
+
it('a cold registration is foreground; a cached or live one is background', async () => {
|
|
160
|
+
for (const [phase, background] of [['cold', 0], ['cached', 1], ['live', 1]] as const) {
|
|
161
|
+
const seen: Array<[number, number]> = [];
|
|
162
|
+
const out = await runPure(registerRemote(env, 'a'), {
|
|
163
|
+
state: buildState([buildEntry({ def: { hash: 'a' }, lifecycle: { phase } })]),
|
|
164
|
+
handlers: {
|
|
165
|
+
'remote.query': (_e: unknown, ctx) => {
|
|
166
|
+
const l = ctx.state.queries.get('a')!.lifecycle;
|
|
167
|
+
seen.push([l.fetchDepth, l.backgroundDepth]);
|
|
168
|
+
return happy();
|
|
169
|
+
},
|
|
170
|
+
'local.upsert': () => undefined,
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
expect(seen).toEqual([[1, background]]);
|
|
174
|
+
expect(out.state.queries.get('a')!.lifecycle).toMatchObject({ fetchDepth: 0, backgroundDepth: 0 });
|
|
175
|
+
}
|
|
176
|
+
});
|
|
159
177
|
it('guards: missing entry, already registered / failed, registering without retry', async () => {
|
|
160
178
|
const none = await runPure(registerRemote(env, 'zzz'), { state: buildState([]) });
|
|
161
179
|
expect(none.log.filter((x) => x.kind === 'remote.query')).toHaveLength(0);
|
|
@@ -151,8 +151,11 @@ export function* registerRemote(env: SagaEnv, hash: QueryHash, retry = false): S
|
|
|
151
151
|
if (!entry) return;
|
|
152
152
|
const { remote } = entry.lifecycle;
|
|
153
153
|
if (remote === 'registered' || remote === 'failed' || (remote === 'registering' && !retry)) return;
|
|
154
|
+
// A query that already shows rows (cached or live) only re-verifies them
|
|
155
|
+
// here: the cycle holds `fetching` but is not traffic the user waits on.
|
|
156
|
+
const background = entry.lifecycle.phase !== 'cold';
|
|
154
157
|
yield fx.state.update(
|
|
155
|
-
R.compose(R.applyLifecycle(hash, { type: 'remote-registering' }), R.applyLifecycle(hash, { type: 'fetch-begin' }))
|
|
158
|
+
R.compose(R.applyLifecycle(hash, { type: 'remote-registering' }), R.applyLifecycle(hash, { type: 'fetch-begin', background }))
|
|
156
159
|
);
|
|
157
160
|
try {
|
|
158
161
|
const table = listRefTable(env, state);
|
|
@@ -221,6 +224,6 @@ export function* registerRemote(env: SagaEnv, hash: QueryHash, retry = false): S
|
|
|
221
224
|
}
|
|
222
225
|
} finally {
|
|
223
226
|
const stillThere = (yield fx.state.read((s) => s.queries.has(hash))) as boolean;
|
|
224
|
-
if (stillThere) yield fx.state.update(R.applyLifecycle(hash, { type: 'fetch-end' }));
|
|
227
|
+
if (stillThere) yield fx.state.update(R.applyLifecycle(hash, { type: 'fetch-end', background }));
|
|
225
228
|
}
|
|
226
229
|
}
|
package/src/query/sql.test.ts
CHANGED
|
@@ -50,6 +50,24 @@ describe('query sql builders (golden against the previous builders)', () => {
|
|
|
50
50
|
expect(sql.heartbeatRowGone([{ id: a }])).toBe(false);
|
|
51
51
|
expect(sql.heartbeatRowGone(undefined)).toBe(false);
|
|
52
52
|
});
|
|
53
|
+
it('the pagehide beacon names the page sessions and inlines only shapes it minted', () => {
|
|
54
|
+
const a = new RecordId('_00_query', 'a'.repeat(64));
|
|
55
|
+
const b = new RecordId('_00_query', 'b'.repeat(64));
|
|
56
|
+
const s1 = 'de81a067-f4bc-4351-a2a3-1ecf06b6305a';
|
|
57
|
+
const s2 = '8A14ED5A-32B5-4B4E-973D-D2B88860932F';
|
|
58
|
+
expect(sql.leaveViewsBeacon([a, b], [s1, s2])).toBe(
|
|
59
|
+
`FOR $id IN [${a}, ${b}] { LET $_left = fn::query::leave($id, ['${s1}', '${s2}']); };`
|
|
60
|
+
);
|
|
61
|
+
const digits = new RecordId('_00_query', '1'.repeat(64));
|
|
62
|
+
expect(sql.leaveViewsBeacon([digits], [s1])).toBe(`FOR $id IN [${digits}] { LET $_left = fn::query::leave($id, ['${s1}']); };`);
|
|
63
|
+
// Anything else is dropped rather than interpolated.
|
|
64
|
+
expect(sql.leaveViewsBeacon([a, new RecordId('_00_query', "x']; DELETE user; --"), 'user:1'], [s1, "x'; DELETE user; --"])).toBe(
|
|
65
|
+
`FOR $id IN [${a}] { LET $_left = fn::query::leave($id, ['${s1}']); };`
|
|
66
|
+
);
|
|
67
|
+
// No session ids: the server could not tell this page's entries from a reload's, so send nothing.
|
|
68
|
+
expect(sql.leaveViewsBeacon([a], [])).toBeNull();
|
|
69
|
+
expect(sql.leaveViewsBeacon([], [s1])).toBeNull();
|
|
70
|
+
});
|
|
53
71
|
it('upsertBodiesTx MERGEs each body inside one transaction', () => {
|
|
54
72
|
const { query, vars } = sql.upsertBodiesTx([
|
|
55
73
|
{ id: 'thing:1', content: { a: 1 } },
|
package/src/query/sql.ts
CHANGED
|
@@ -76,6 +76,25 @@ export function heartbeatBatch(ids: ReadonlyArray<RecordId<string>>): { sql: str
|
|
|
76
76
|
/** A heartbeat answer whose statement matched no row: the view was reclaimed. */
|
|
77
77
|
export const heartbeatRowGone = (result: unknown): boolean => Array.isArray(result) && result.length === 0;
|
|
78
78
|
|
|
79
|
+
// The SDK escapes an all-digit key as `⟨…⟩`; both forms parse back to the same id.
|
|
80
|
+
const QUERY_ID = /^_00_query:(?:[0-9a-f]{64}|⟨[0-9a-f]{64}⟩)$/;
|
|
81
|
+
const SESSION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The pagehide release: one bare statement for `beaconSql`, which has no params
|
|
85
|
+
* channel, so ids are inlined. Only shapes the client minted itself get in, a
|
|
86
|
+
* `_00_query:<sha256>` id and a UUID session id, so nothing else is ever
|
|
87
|
+
* interpolated. `null` when there is nothing the server could act on: with no
|
|
88
|
+
* session ids it could not tell this page's entries from anyone else's.
|
|
89
|
+
*/
|
|
90
|
+
export function leaveViewsBeacon(ids: ReadonlyArray<unknown>, sessionIds: ReadonlyArray<string>): string | null {
|
|
91
|
+
const list = ids.map((id) => String(id)).filter((id) => QUERY_ID.test(id));
|
|
92
|
+
const sids = sessionIds.filter((sid) => SESSION_ID.test(sid));
|
|
93
|
+
if (list.length === 0 || sids.length === 0) return null;
|
|
94
|
+
const sidList = sids.map((sid) => `'${sid}'`).join(', ');
|
|
95
|
+
return `FOR $id IN [${list.join(', ')}] { LET $_left = fn::query::leave($id, [${sidList}]); };`;
|
|
96
|
+
}
|
|
97
|
+
|
|
79
98
|
export const bodySelect = (): string => 'SELECT * FROM $ids';
|
|
80
99
|
|
|
81
100
|
/** Local transaction that MERGEs many fetched bodies. */
|
|
@@ -37,6 +37,12 @@ export type RemoteConnectionEvent = ConnectionState | 'error';
|
|
|
37
37
|
*/
|
|
38
38
|
const REMOTE_MAX_CONCURRENT_QUERIES = 6;
|
|
39
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Session ids a page remembers for its pagehide release. Only the sessions of
|
|
42
|
+
* the last ttl matter; this many reconnects inside one ttl is a broken network.
|
|
43
|
+
*/
|
|
44
|
+
const MAX_SESSION_IDS = 16;
|
|
45
|
+
|
|
40
46
|
export class RemoteDatabaseService extends AbstractDatabaseService {
|
|
41
47
|
private config: Sp00kyConfig<any>['database'];
|
|
42
48
|
protected eventType = DatabaseEventTypes.RemoteQuery;
|
|
@@ -67,6 +73,17 @@ export class RemoteDatabaseService extends AbstractDatabaseService {
|
|
|
67
73
|
* returns nothing, on a page that still looks signed in.
|
|
68
74
|
*/
|
|
69
75
|
private authToken: string | null = null;
|
|
76
|
+
/**
|
|
77
|
+
* Every `session::id()` this page's sockets have had, oldest first.
|
|
78
|
+
*
|
|
79
|
+
* The server stamps a view's `subscribers` entry with the session that
|
|
80
|
+
* registered or heartbeated it, and every socket (an SDK reconnect, a
|
|
81
|
+
* supervisor revive) is a new session. The pagehide release goes over HTTP,
|
|
82
|
+
* whose own session id matches none of them, so it names these instead. Kept
|
|
83
|
+
* for the page, not the socket: a view registered before a reconnect still
|
|
84
|
+
* carries the old id until its next heartbeat.
|
|
85
|
+
*/
|
|
86
|
+
private sessionIdsSeen: string[] = [];
|
|
70
87
|
|
|
71
88
|
constructor(config: Sp00kyConfig<any>['database'], logger: Logger) {
|
|
72
89
|
const events = createDatabaseEventSystem();
|
|
@@ -98,6 +115,27 @@ export class RemoteDatabaseService extends AbstractDatabaseService {
|
|
|
98
115
|
this.reconnectConfig = resolveReconnectConfig(config.reconnect);
|
|
99
116
|
this.queryTimeoutMs = Math.max(0, config.queryTimeoutMs ?? 60_000);
|
|
100
117
|
this.maxConcurrentQueries = REMOTE_MAX_CONCURRENT_QUERIES;
|
|
118
|
+
this.client.subscribe('connected', () => void this.recordSessionId());
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** See {@link sessionIdsSeen}. */
|
|
122
|
+
sessionIds(): readonly string[] {
|
|
123
|
+
return this.sessionIdsSeen;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Ask the socket that just connected for its session id. Straight to the
|
|
128
|
+
* SDK: it needs no namespace or identity, so it need not wait behind an
|
|
129
|
+
* in-flight connect or take a slot from the query queue.
|
|
130
|
+
*/
|
|
131
|
+
private async recordSessionId(): Promise<void> {
|
|
132
|
+
try {
|
|
133
|
+
const [sid] = (await this.client.query('RETURN <string>session::id()')) as unknown[];
|
|
134
|
+
if (typeof sid !== 'string' || this.sessionIdsSeen.includes(sid)) return;
|
|
135
|
+
this.sessionIdsSeen = [...this.sessionIdsSeen, sid].slice(-MAX_SESSION_IDS);
|
|
136
|
+
} catch {
|
|
137
|
+
// Without it this socket's views fall back to the TTL sweep.
|
|
138
|
+
}
|
|
101
139
|
}
|
|
102
140
|
|
|
103
141
|
getConfig(): Sp00kyConfig<any>['database'] {
|
|
@@ -116,6 +154,10 @@ export class RemoteDatabaseService extends AbstractDatabaseService {
|
|
|
116
154
|
*
|
|
117
155
|
* Best-effort by design: no await, no retry, errors swallowed. Every caller
|
|
118
156
|
* must have a server-side fallback that makes a lost beacon a non-event.
|
|
157
|
+
*
|
|
158
|
+
* The statement runs in a fresh HTTP session: `session::id()` there is a new
|
|
159
|
+
* id, not this page's socket's, so anything keyed on it must be passed the
|
|
160
|
+
* ids from {@link sessionIds} instead.
|
|
119
161
|
*/
|
|
120
162
|
beaconSql(sql: string): void {
|
|
121
163
|
try {
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
2
|
import type { LifecycleEvent, QueryLifecycle, QueryPhase } from './lifecycle';
|
|
3
|
-
import { deriveStatus, hasServerMembership, isAuthoritative, LifecycleError, seedLifecycle, transition } from './lifecycle';
|
|
3
|
+
import { deriveStatus, hasServerMembership, isAuthoritative, isForegroundFetching, LifecycleError, seedLifecycle, transition } from './lifecycle';
|
|
4
4
|
|
|
5
5
|
const at = (phase: QueryPhase, over: Partial<QueryLifecycle> = {}): QueryLifecycle => ({
|
|
6
6
|
phase,
|
|
7
7
|
remote: 'unregistered',
|
|
8
8
|
fetchDepth: 0,
|
|
9
|
+
backgroundDepth: 0,
|
|
9
10
|
notified: false,
|
|
10
11
|
...over,
|
|
11
12
|
});
|
|
@@ -33,7 +34,7 @@ describe('transition table (phase axis)', () => {
|
|
|
33
34
|
});
|
|
34
35
|
it('bucket-switch reseeds and clears fetch depth', () => {
|
|
35
36
|
const l = transition(at('live', { fetchDepth: 3, notified: true }), { type: 'bucket-switch', resolvedBefore: true });
|
|
36
|
-
expect(l).toEqual({ phase: 'cached', remote: 'unregistered', fetchDepth: 0, notified: false });
|
|
37
|
+
expect(l).toEqual({ phase: 'cached', remote: 'unregistered', fetchDepth: 0, backgroundDepth: 0, notified: false });
|
|
37
38
|
});
|
|
38
39
|
});
|
|
39
40
|
|
|
@@ -63,6 +64,24 @@ describe('remote / fetch / notified axes', () => {
|
|
|
63
64
|
const same = transition(l, { type: 'fetch-end' });
|
|
64
65
|
expect(same).toBe(l);
|
|
65
66
|
});
|
|
67
|
+
it('background cycles hold fetching but are not foreground', () => {
|
|
68
|
+
let l = at('cached');
|
|
69
|
+
l = transition(l, { type: 'fetch-begin', background: true });
|
|
70
|
+
expect(deriveStatus(l)).toBe('fetching');
|
|
71
|
+
expect(isForegroundFetching(l)).toBe(false);
|
|
72
|
+
l = transition(l, { type: 'fetch-begin' });
|
|
73
|
+
expect(isForegroundFetching(l)).toBe(true);
|
|
74
|
+
l = transition(l, { type: 'fetch-end' });
|
|
75
|
+
expect(l).toMatchObject({ fetchDepth: 1, backgroundDepth: 1 });
|
|
76
|
+
expect(isForegroundFetching(l)).toBe(false);
|
|
77
|
+
l = transition(l, { type: 'fetch-end', background: true });
|
|
78
|
+
expect(l).toMatchObject({ fetchDepth: 0, backgroundDepth: 0 });
|
|
79
|
+
expect(deriveStatus(l)).toBe('idle');
|
|
80
|
+
});
|
|
81
|
+
it('an unbalanced end never leaves more background cycles than cycles', () => {
|
|
82
|
+
const l = transition(at('live', { fetchDepth: 1, backgroundDepth: 1 }), { type: 'fetch-end' });
|
|
83
|
+
expect(l).toMatchObject({ fetchDepth: 0, backgroundDepth: 0 });
|
|
84
|
+
});
|
|
66
85
|
it('notified is idempotent', () => {
|
|
67
86
|
const l = transition(at('live'), { type: 'notified' });
|
|
68
87
|
expect(transition(l, { type: 'notified' })).toBe(l);
|
|
@@ -89,6 +108,8 @@ describe('invariants over random event sequences', () => {
|
|
|
89
108
|
{ type: 'remote-dropped' },
|
|
90
109
|
{ type: 'fetch-begin' },
|
|
91
110
|
{ type: 'fetch-end' },
|
|
111
|
+
{ type: 'fetch-begin', background: true },
|
|
112
|
+
{ type: 'fetch-end', background: true },
|
|
92
113
|
{ type: 'notified' },
|
|
93
114
|
{ type: 'bucket-switch', resolvedBefore: false },
|
|
94
115
|
{ type: 'seed', resolvedBefore: true },
|
|
@@ -97,7 +118,7 @@ describe('invariants over random event sequences', () => {
|
|
|
97
118
|
let seed = 42;
|
|
98
119
|
const rand = () => (seed = (seed * 1664525 + 1013904223) % 2 ** 32) / 2 ** 32;
|
|
99
120
|
|
|
100
|
-
it('
|
|
121
|
+
it('0 <= backgroundDepth <= fetchDepth, notified only after live-or-cached, view-lost never from cold', () => {
|
|
101
122
|
for (let run = 0; run < 200; run++) {
|
|
102
123
|
let l = seedLifecycle(rand() < 0.5);
|
|
103
124
|
for (let i = 0; i < 25; i++) {
|
|
@@ -110,6 +131,8 @@ describe('invariants over random event sequences', () => {
|
|
|
110
131
|
continue;
|
|
111
132
|
}
|
|
112
133
|
expect(l.fetchDepth).toBeGreaterThanOrEqual(0);
|
|
134
|
+
expect(l.backgroundDepth).toBeGreaterThanOrEqual(0);
|
|
135
|
+
expect(l.backgroundDepth).toBeLessThanOrEqual(l.fetchDepth);
|
|
113
136
|
if (ev.type === 'row-missing' && before.phase === 'cold') expect(l.phase).toBe('cold');
|
|
114
137
|
if (l.phase === 'view-lost') expect(before.phase).not.toBe('cold');
|
|
115
138
|
}
|
package/src/state/lifecycle.ts
CHANGED
|
@@ -14,6 +14,11 @@ import type { QueryStatus } from '../types';
|
|
|
14
14
|
* `remote` tracks the server-side registration, `fetchDepth` is the refcount
|
|
15
15
|
* behind `status: 'fetching'`, `notified` says whether subscribers received
|
|
16
16
|
* at least one materialization this registration.
|
|
17
|
+
*
|
|
18
|
+
* `backgroundDepth` is the part of `fetchDepth` that re-verifies rows the
|
|
19
|
+
* query already shows: a registration of a query that is not `cold`. It still
|
|
20
|
+
* holds `status: 'fetching'` (results are not yet confirmed), but it is not
|
|
21
|
+
* traffic the user waits on, so the activity indicator leaves it out.
|
|
17
22
|
*/
|
|
18
23
|
export type QueryPhase = 'cold' | 'cached' | 'live' | 'view-lost';
|
|
19
24
|
export type RemotePhase = 'unregistered' | 'registering' | 'registered' | 'failed';
|
|
@@ -22,6 +27,7 @@ export interface QueryLifecycle {
|
|
|
22
27
|
readonly phase: QueryPhase;
|
|
23
28
|
readonly remote: RemotePhase;
|
|
24
29
|
readonly fetchDepth: number;
|
|
30
|
+
readonly backgroundDepth: number;
|
|
25
31
|
readonly notified: boolean;
|
|
26
32
|
}
|
|
27
33
|
|
|
@@ -33,8 +39,8 @@ export type LifecycleEvent =
|
|
|
33
39
|
| { type: 'remote-registered' }
|
|
34
40
|
| { type: 'remote-failed' }
|
|
35
41
|
| { type: 'remote-dropped' }
|
|
36
|
-
| { type: 'fetch-begin' }
|
|
37
|
-
| { type: 'fetch-end' }
|
|
42
|
+
| { type: 'fetch-begin'; background?: boolean }
|
|
43
|
+
| { type: 'fetch-end'; background?: boolean }
|
|
38
44
|
| { type: 'notified' }
|
|
39
45
|
| { type: 'bucket-switch'; resolvedBefore: boolean };
|
|
40
46
|
|
|
@@ -46,7 +52,7 @@ export class LifecycleError extends Error {
|
|
|
46
52
|
}
|
|
47
53
|
|
|
48
54
|
export function seedLifecycle(resolvedBefore: boolean): QueryLifecycle {
|
|
49
|
-
return { phase: resolvedBefore ? 'cached' : 'cold', remote: 'unregistered', fetchDepth: 0, notified: false };
|
|
55
|
+
return { phase: resolvedBefore ? 'cached' : 'cold', remote: 'unregistered', fetchDepth: 0, backgroundDepth: 0, notified: false };
|
|
50
56
|
}
|
|
51
57
|
|
|
52
58
|
/** Total on every (lifecycle, event) pair the sagas can produce; throws on the rest. */
|
|
@@ -70,9 +76,14 @@ export function transition(l: QueryLifecycle, ev: LifecycleEvent): QueryLifecycl
|
|
|
70
76
|
case 'remote-dropped':
|
|
71
77
|
return { ...l, remote: 'unregistered', notified: false };
|
|
72
78
|
case 'fetch-begin':
|
|
73
|
-
return { ...l, fetchDepth: l.fetchDepth + 1 };
|
|
74
|
-
case 'fetch-end':
|
|
75
|
-
|
|
79
|
+
return { ...l, fetchDepth: l.fetchDepth + 1, backgroundDepth: l.backgroundDepth + (ev.background ? 1 : 0) };
|
|
80
|
+
case 'fetch-end': {
|
|
81
|
+
if (l.fetchDepth === 0) return l;
|
|
82
|
+
const fetchDepth = l.fetchDepth - 1;
|
|
83
|
+
// Never more background cycles than cycles: an unbalanced end clamps.
|
|
84
|
+
const backgroundDepth = Math.min(fetchDepth, l.backgroundDepth - (ev.background && l.backgroundDepth > 0 ? 1 : 0));
|
|
85
|
+
return { ...l, fetchDepth, backgroundDepth };
|
|
86
|
+
}
|
|
76
87
|
case 'notified':
|
|
77
88
|
return l.notified ? l : { ...l, notified: true };
|
|
78
89
|
case 'bucket-switch':
|
|
@@ -84,3 +95,5 @@ export const isAuthoritative = (l: QueryLifecycle): boolean => l.phase !== 'cold
|
|
|
84
95
|
export const hasServerMembership = (l: QueryLifecycle): boolean =>
|
|
85
96
|
l.phase === 'live' || l.phase === 'view-lost';
|
|
86
97
|
export const deriveStatus = (l: QueryLifecycle): QueryStatus => (l.fetchDepth > 0 ? 'fetching' : 'idle');
|
|
98
|
+
/** A fetch cycle the user is waiting on: rows to download, or a first registration. */
|
|
99
|
+
export const isForegroundFetching = (l: QueryLifecycle): boolean => l.fetchDepth > l.backgroundDepth;
|
|
@@ -247,7 +247,7 @@ describe('bucket switch reducers', () => {
|
|
|
247
247
|
R.outboxReplace([buildOutboxItem()])
|
|
248
248
|
);
|
|
249
249
|
const id = { table: '_00_query', id: 'new' } as any;
|
|
250
|
-
const s1 = R.rebindQuery('a', { id, lifecycle: { phase: 'cached', remote: 'unregistered', fetchDepth: 0, notified: false }, remoteArray: [['t:2', 1]], localArray: [] })({ ...s0, dirty: new Set() });
|
|
250
|
+
const s1 = R.rebindQuery('a', { id, lifecycle: { phase: 'cached', remote: 'unregistered', fetchDepth: 0, backgroundDepth: 0, notified: false }, remoteArray: [['t:2', 1]], localArray: [] })({ ...s0, dirty: new Set() });
|
|
251
251
|
const en = s1.queries.get('a')!;
|
|
252
252
|
expect(en.def.id).toBe(id);
|
|
253
253
|
expect(en.lifecycle.phase).toBe('cached');
|
|
@@ -81,6 +81,14 @@ describe('needed / planFetch / settled', () => {
|
|
|
81
81
|
const s = buildState([e('a', { lifecycle: { fetchDepth: 2 } }), e('b')]);
|
|
82
82
|
expect(S.fetchingQueryCount(s)).toBe(1);
|
|
83
83
|
});
|
|
84
|
+
it('fetchingQueryCount leaves out background re-verification', () => {
|
|
85
|
+
const s = buildState([
|
|
86
|
+
e('verifying', { lifecycle: { phase: 'cached', fetchDepth: 1, backgroundDepth: 1 } }),
|
|
87
|
+
e('downloading', { lifecycle: { phase: 'live', fetchDepth: 2, backgroundDepth: 1 } }),
|
|
88
|
+
]);
|
|
89
|
+
expect(S.fetchingQueryCount(s)).toBe(1);
|
|
90
|
+
expect(S.queryStatus(s, 'verifying')).toBe('fetching');
|
|
91
|
+
});
|
|
84
92
|
});
|
|
85
93
|
|
|
86
94
|
describe('registration / eviction / ttl', () => {
|
package/src/state/selectors.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { PhaseStat, QueryHash, QueryState, QueryStatus, QueryTimings, RecordVersionArray } from '../types';
|
|
2
2
|
import type { ClientState, QueryEntry } from './client-state';
|
|
3
|
-
import { deriveStatus, hasServerMembership, isAuthoritative } from './lifecycle';
|
|
3
|
+
import { deriveStatus, hasServerMembership, isAuthoritative, isForegroundFetching } from './lifecycle';
|
|
4
4
|
import { FETCH_CHUNK } from '../kernel/constants';
|
|
5
5
|
|
|
6
6
|
/** Pure reads over `ClientState`. Sagas use them through `state.read`. */
|
|
@@ -75,8 +75,14 @@ export const hasAckedWrites = (s: ClientState): boolean => s.outbox.some((i) =>
|
|
|
75
75
|
export const pendingMutationCount = (s: ClientState): number =>
|
|
76
76
|
s.outbox.filter((i) => i.status === 'pending').length;
|
|
77
77
|
|
|
78
|
+
/**
|
|
79
|
+
* Queries with traffic the user is waiting on. A re-registration of a query
|
|
80
|
+
* that already shows rows (every mounted query after a reload) is left out:
|
|
81
|
+
* it only confirms what the cache painted, and any newer body it finds is
|
|
82
|
+
* fetched by `fetchRows`, which counts.
|
|
83
|
+
*/
|
|
78
84
|
export const fetchingQueryCount = (s: ClientState): number =>
|
|
79
|
-
[...s.queries.values()].filter((e) => e.lifecycle
|
|
85
|
+
[...s.queries.values()].filter((e) => isForegroundFetching(e.lifecycle)).length;
|
|
80
86
|
|
|
81
87
|
/** Membership entries whose body is missing or older locally. */
|
|
82
88
|
export function needed(s: ClientState, hash: QueryHash): RecordVersionArray {
|