@spooky-sync/core 0.0.1-canary.315 → 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 +184 -19
- package/dist/live.js +1 -1
- package/dist/pure.d.ts +9 -1
- package/dist/pure.js +2 -2
- package/dist/types.d.ts +35 -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/modules/auth/impersonation.integration.test.ts +205 -0
- package/src/modules/auth/impersonation.test.ts +22 -0
- package/src/modules/auth/index.ts +62 -2
- package/src/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 {
|
|
@@ -11445,6 +11531,13 @@ var AuthService = class {
|
|
|
11445
11531
|
/** Target of an impersonation that just ended, until the client purges its
|
|
11446
11532
|
* local bucket (see `consumeEndedImpersonation`). */
|
|
11447
11533
|
endedImpersonationTarget = null;
|
|
11534
|
+
/** The way back to the admin in flight. The Stop button, the session poll,
|
|
11535
|
+
* a refused renewal and a rejected check can all end an impersonation, and
|
|
11536
|
+
* they share this one: a second stop running beside the first found the
|
|
11537
|
+
* admin token already taken and signed the admin out. */
|
|
11538
|
+
stopping = null;
|
|
11539
|
+
/** A token renewal in flight, which a stop waits out (see `renewImpersonation`). */
|
|
11540
|
+
renewing = null;
|
|
11448
11541
|
impersonationTimers = new ImpersonationTimers({
|
|
11449
11542
|
current: () => this.impersonation,
|
|
11450
11543
|
renew: () => this.renewImpersonation(),
|
|
@@ -11595,9 +11688,18 @@ var AuthService = class {
|
|
|
11595
11688
|
* a full sign-out rather than staying on the target.
|
|
11596
11689
|
*/
|
|
11597
11690
|
async stopImpersonating(opts = {}) {
|
|
11691
|
+
if (this.stopping) return this.stopping;
|
|
11692
|
+
if (!this.impersonation) return;
|
|
11693
|
+
this.stopping = this.leaveImpersonation(opts).finally(() => {
|
|
11694
|
+
this.stopping = null;
|
|
11695
|
+
});
|
|
11696
|
+
return this.stopping;
|
|
11697
|
+
}
|
|
11698
|
+
async leaveImpersonation(opts) {
|
|
11699
|
+
this.impersonationTimers.clear();
|
|
11700
|
+
await this.renewing?.catch(() => void 0);
|
|
11598
11701
|
const info = this.impersonation;
|
|
11599
11702
|
if (!info) return;
|
|
11600
|
-
this.impersonationTimers.clear();
|
|
11601
11703
|
if (!opts.serverEnded) try {
|
|
11602
11704
|
await this.remote.query("RETURN fn::_00_impersonate::stop($session)", { session: info.session });
|
|
11603
11705
|
} catch (error) {
|
|
@@ -11632,9 +11734,19 @@ var AuthService = class {
|
|
|
11632
11734
|
}
|
|
11633
11735
|
/** Re-sign the impersonation token before it expires. */
|
|
11634
11736
|
async renewImpersonation() {
|
|
11635
|
-
if (!this.impersonation) return;
|
|
11737
|
+
if (!this.impersonation || this.stopping) return;
|
|
11738
|
+
const renewal = this.applyRenewal();
|
|
11739
|
+
this.renewing = renewal;
|
|
11740
|
+
try {
|
|
11741
|
+
await renewal;
|
|
11742
|
+
} finally {
|
|
11743
|
+
if (this.renewing === renewal) this.renewing = null;
|
|
11744
|
+
}
|
|
11745
|
+
}
|
|
11746
|
+
async applyRenewal() {
|
|
11636
11747
|
const [renewed] = await this.remote.query("RETURN fn::_00_impersonate::renew()");
|
|
11637
11748
|
if (!renewed?.token) throw new Error("Impersonation renewal returned no token.");
|
|
11749
|
+
if (this.stopping || !this.impersonation) return;
|
|
11638
11750
|
this.remote.setAuthToken(renewed.token);
|
|
11639
11751
|
await this.remote.getClient().authenticate(renewed.token);
|
|
11640
11752
|
this.token = renewed.token;
|
|
@@ -11700,6 +11812,7 @@ var AuthService = class {
|
|
|
11700
11812
|
async check(accessToken) {
|
|
11701
11813
|
this.isLoading = true;
|
|
11702
11814
|
let token;
|
|
11815
|
+
let accepted = false;
|
|
11703
11816
|
try {
|
|
11704
11817
|
token = accessToken || await this.persistenceClient.get("sp00ky_auth_token") || void 0;
|
|
11705
11818
|
if (!token) {
|
|
@@ -11711,6 +11824,7 @@ var AuthService = class {
|
|
|
11711
11824
|
}
|
|
11712
11825
|
this.remote.setAuthToken(token);
|
|
11713
11826
|
await this.remote.getClient().authenticate(token);
|
|
11827
|
+
accepted = true;
|
|
11714
11828
|
const result = await this.remote.query("SELECT * FROM ONLY $auth.id");
|
|
11715
11829
|
const items = Array.isArray(result) && Array.isArray(result[0]) ? result[0] : result;
|
|
11716
11830
|
const user = Array.isArray(items) ? items[0] : items;
|
|
@@ -11741,7 +11855,13 @@ var AuthService = class {
|
|
|
11741
11855
|
error,
|
|
11742
11856
|
Category: "sp00ky-client::AuthService::check"
|
|
11743
11857
|
}, "Auth check unreachable; keeping the cached session and retrying later");
|
|
11744
|
-
else {
|
|
11858
|
+
else if (accepted && token) {
|
|
11859
|
+
this.logger.warn({
|
|
11860
|
+
error,
|
|
11861
|
+
Category: "sp00ky-client::AuthService::check"
|
|
11862
|
+
}, "Auth check accepted the token but could not read the user; keeping the session");
|
|
11863
|
+
await this.keepAcceptedSession(token);
|
|
11864
|
+
} else {
|
|
11745
11865
|
this.logger.error({
|
|
11746
11866
|
error,
|
|
11747
11867
|
stack: error.stack,
|
|
@@ -11753,6 +11873,17 @@ var AuthService = class {
|
|
|
11753
11873
|
this.isLoading = false;
|
|
11754
11874
|
}
|
|
11755
11875
|
}
|
|
11876
|
+
/** Hold a session the server accepted without its user row: the id comes
|
|
11877
|
+
* from the token, as on a warm boot. A token naming no user is refused. */
|
|
11878
|
+
async keepAcceptedSession(token) {
|
|
11879
|
+
if (this.isAuthenticated && this.token === token && this.currentUser) return;
|
|
11880
|
+
const { userId } = decodeTokenClaims(token);
|
|
11881
|
+
if (!userId) {
|
|
11882
|
+
await this.rejectToken(token);
|
|
11883
|
+
return;
|
|
11884
|
+
}
|
|
11885
|
+
await this.setSession(token, { id: userId });
|
|
11886
|
+
}
|
|
11756
11887
|
/**
|
|
11757
11888
|
* The server refused `token`. A refused impersonation token (stopped,
|
|
11758
11889
|
* expired, or revoked) returns to the admin session instead of signing the
|
|
@@ -14542,6 +14673,38 @@ function writeBootBucketHint(bucketId) {
|
|
|
14542
14673
|
if (typeof localStorage !== "undefined") localStorage.setItem(LAST_BUCKET_KEY, bucketId);
|
|
14543
14674
|
} catch {}
|
|
14544
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
|
+
}
|
|
14545
14708
|
function mintSalt() {
|
|
14546
14709
|
const c = globalThis.crypto;
|
|
14547
14710
|
if (c?.randomUUID) return c.randomUUID();
|
|
@@ -14603,7 +14766,7 @@ function createTabsCoordinator(config, s, host) {
|
|
|
14603
14766
|
return new TabsCoordinator({
|
|
14604
14767
|
tabId: s.tabId,
|
|
14605
14768
|
fingerprint: computeTabsFingerprint({
|
|
14606
|
-
coreVersion: "0.0.1-canary.
|
|
14769
|
+
coreVersion: "0.0.1-canary.317",
|
|
14607
14770
|
schemaHash: hash53(config.schemaSurql),
|
|
14608
14771
|
endpoint: config.database.endpoint ?? "",
|
|
14609
14772
|
namespace: config.database.namespace,
|
|
@@ -14707,6 +14870,8 @@ function createAdapters(config, s, host, lateModules) {
|
|
|
14707
14870
|
const services = {
|
|
14708
14871
|
"hint.read": readBootBucketHint,
|
|
14709
14872
|
"hint.write": writeBootBucketHint,
|
|
14873
|
+
"salt.take": takeReloadSalt,
|
|
14874
|
+
"salt.leave": leaveReloadSalt,
|
|
14710
14875
|
"local.connect": (bucketId) => s.local.connect(bucketId),
|
|
14711
14876
|
"local.switchStore": (bucketId) => s.local.switchStore(bucketId),
|
|
14712
14877
|
"local.beginSwitch": () => s.local.beginSwitch(),
|
|
@@ -14753,8 +14918,8 @@ function createAdapters(config, s, host, lateModules) {
|
|
|
14753
14918
|
"auth.consumeEndedImpersonation": () => s.auth.consumeEndedImpersonation(),
|
|
14754
14919
|
"remote.connect": () => s.remote.connect(),
|
|
14755
14920
|
"remote.releaseViews": (ids) => {
|
|
14756
|
-
const
|
|
14757
|
-
if (
|
|
14921
|
+
const sql = leaveViewsBeacon(ids, s.remote.sessionIds());
|
|
14922
|
+
if (sql) s.remote.beaconSql(sql);
|
|
14758
14923
|
},
|
|
14759
14924
|
"supervisor.start": () => s.connectionSupervisor.start(),
|
|
14760
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
|
/**
|
|
@@ -793,6 +816,13 @@ declare class AuthService<S extends SchemaStructure> {
|
|
|
793
816
|
/** Target of an impersonation that just ended, until the client purges its
|
|
794
817
|
* local bucket (see `consumeEndedImpersonation`). */
|
|
795
818
|
private endedImpersonationTarget;
|
|
819
|
+
/** The way back to the admin in flight. The Stop button, the session poll,
|
|
820
|
+
* a refused renewal and a rejected check can all end an impersonation, and
|
|
821
|
+
* they share this one: a second stop running beside the first found the
|
|
822
|
+
* admin token already taken and signed the admin out. */
|
|
823
|
+
private stopping;
|
|
824
|
+
/** A token renewal in flight, which a stop waits out (see `renewImpersonation`). */
|
|
825
|
+
private renewing;
|
|
796
826
|
private impersonationTimers;
|
|
797
827
|
get eventSystem(): AuthEventSystem;
|
|
798
828
|
constructor(schema: S, remote: RemoteDatabaseService, persistenceClient: PersistenceClient, logger: Logger$1);
|
|
@@ -839,8 +869,10 @@ declare class AuthService<S extends SchemaStructure> {
|
|
|
839
869
|
stopImpersonating(opts?: {
|
|
840
870
|
serverEnded?: boolean;
|
|
841
871
|
}): Promise<void>;
|
|
872
|
+
private leaveImpersonation;
|
|
842
873
|
/** Re-sign the impersonation token before it expires. */
|
|
843
874
|
renewImpersonation(): Promise<void>;
|
|
875
|
+
private applyRenewal;
|
|
844
876
|
/** Users an admin may pick from. Admin-only on the server. */
|
|
845
877
|
searchImpersonationTargets(search: string): Promise<ImpersonationCandidate[]>;
|
|
846
878
|
/** Open impersonation sessions. Admin-only on the server. */
|
|
@@ -872,6 +904,9 @@ declare class AuthService<S extends SchemaStructure> {
|
|
|
872
904
|
* Check for existing session and validate
|
|
873
905
|
*/
|
|
874
906
|
check(accessToken?: string): Promise<void>;
|
|
907
|
+
/** Hold a session the server accepted without its user row: the id comes
|
|
908
|
+
* from the token, as on a warm boot. A token naming no user is refused. */
|
|
909
|
+
private keepAcceptedSession;
|
|
875
910
|
/**
|
|
876
911
|
* The server refused `token`. A refused impersonation token (stopped,
|
|
877
912
|
* expired, or revoked) returns to the admin session instead of signing the
|
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);
|