@spooky-sync/core 0.0.1-canary.350 → 0.0.1-canary.351
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +5 -0
- package/dist/index.js +117 -8
- package/dist/live.js +1 -1
- package/dist/pure.d.ts +13 -1
- package/dist/pure.js +2 -2
- package/dist/worker-edges.js +19 -1
- package/package.json +3 -3
- package/src/boot/auth-flip.saga.test.ts +39 -0
- package/src/boot/bucket-switch.saga.test.ts +3 -1
- package/src/boot/bucket-switch.saga.ts +17 -2
- package/src/client/router.ts +3 -1
- package/src/client/runtime.test.ts +64 -0
- package/src/client/services.ts +1 -0
- package/src/kernel/effects.ts +2 -0
- package/src/kernel/events.ts +2 -0
- package/src/modules/devtools/flags.ts +1 -1
- package/src/query/register.saga.test.ts +58 -1
- package/src/query/register.saga.ts +61 -3
- package/src/query/sql.ts +17 -0
package/dist/index.d.ts
CHANGED
|
@@ -919,6 +919,9 @@ type RuntimeEvent = {
|
|
|
919
919
|
type: 'RegisterRemote';
|
|
920
920
|
hash: QueryHash;
|
|
921
921
|
retry?: boolean;
|
|
922
|
+
} | {
|
|
923
|
+
type: 'ReleaseSuperseded';
|
|
924
|
+
ids: RecordId$1<string>[];
|
|
922
925
|
} | {
|
|
923
926
|
type: 'SyncOutcome';
|
|
924
927
|
ok: boolean;
|
|
@@ -1252,6 +1255,8 @@ interface ServiceCalls {
|
|
|
1252
1255
|
/** The platform reports no network at all (a browser's `navigator.onLine === false`). */
|
|
1253
1256
|
'net.offline': () => boolean;
|
|
1254
1257
|
'remote.releaseViews': (ids: unknown[]) => void;
|
|
1258
|
+
/** `session::id()` of every socket this page has had, oldest first. */
|
|
1259
|
+
'remote.sessionIds': () => readonly string[];
|
|
1255
1260
|
'supervisor.start': () => void;
|
|
1256
1261
|
'tabs.start': (bucketId: string) => Promise<'solo' | 'leader' | 'follower'>;
|
|
1257
1262
|
'tabs.moveToBucket': (bucketId: string) => Promise<'solo' | 'leader' | 'follower'>;
|
package/dist/index.js
CHANGED
|
@@ -2126,7 +2126,7 @@ var FlagsAdminService = class {
|
|
|
2126
2126
|
}
|
|
2127
2127
|
/**
|
|
2128
2128
|
* Add or remove a user from `$key`'s allowlist for `$variant`, then
|
|
2129
|
-
* re-
|
|
2129
|
+
* re-evaluate that one user (nobody else's variant can change). Defaults to the signed-in user, so the common case
|
|
2130
2130
|
* ("turn this on for me, for real") needs no user picker.
|
|
2131
2131
|
*/
|
|
2132
2132
|
async setFlagUserVariant(key, variant, remove, userId) {
|
|
@@ -2286,8 +2286,8 @@ function trimHistory(history, cap) {
|
|
|
2286
2286
|
|
|
2287
2287
|
//#endregion
|
|
2288
2288
|
//#region src/modules/devtools/index.ts
|
|
2289
|
-
const CORE_VERSION = "0.0.1-canary.
|
|
2290
|
-
const WASM_VERSION = "0.0.1-canary.
|
|
2289
|
+
const CORE_VERSION = "0.0.1-canary.351";
|
|
2290
|
+
const WASM_VERSION = "0.0.1-canary.351";
|
|
2291
2291
|
const SURREAL_VERSION = "3.1.0";
|
|
2292
2292
|
var DevToolsService = class DevToolsService {
|
|
2293
2293
|
version = CORE_VERSION;
|
|
@@ -4480,6 +4480,24 @@ function heartbeatBatch(ids) {
|
|
|
4480
4480
|
vars
|
|
4481
4481
|
};
|
|
4482
4482
|
}
|
|
4483
|
+
/**
|
|
4484
|
+
* Hand back view ids this page no longer answers to. Per id,
|
|
4485
|
+
* `fn::query::unsubscribe` drops this socket's entry and deletes the row when
|
|
4486
|
+
* nobody else watches it; `fn::query::leave` then drops the entries of this
|
|
4487
|
+
* page's earlier sockets (a reconnect re-registered under a new session id)
|
|
4488
|
+
* and pulls the expiry in when none remain. Result index 2i answers the
|
|
4489
|
+
* unsubscribe of id i, 2i + 1 its leave.
|
|
4490
|
+
*/
|
|
4491
|
+
function releaseBatch(ids, sessionIds) {
|
|
4492
|
+
const vars = { sids: [...sessionIds] };
|
|
4493
|
+
return {
|
|
4494
|
+
sql: ids.flatMap((id, i) => {
|
|
4495
|
+
vars[`id${i}`] = id;
|
|
4496
|
+
return [`fn::query::unsubscribe($id${i})`, `fn::query::leave($id${i}, $sids)`];
|
|
4497
|
+
}).join(";\n"),
|
|
4498
|
+
vars
|
|
4499
|
+
};
|
|
4500
|
+
}
|
|
4483
4501
|
/** A heartbeat answer whose statement matched no row: the view was reclaimed. */
|
|
4484
4502
|
const heartbeatRowGone = (result) => Array.isArray(result) && result.length === 0;
|
|
4485
4503
|
const QUERY_ID = /^_00_query:(?:[0-9a-f]{64}|⟨[0-9a-f]{64}⟩)$/;
|
|
@@ -5281,10 +5299,39 @@ const stmt = (results, i) => results[i];
|
|
|
5281
5299
|
*/
|
|
5282
5300
|
const isNotAllowlisted = (error) => /not_allowlisted|\b403\b|forbidden/i.test(String(error?.message ?? error));
|
|
5283
5301
|
/**
|
|
5302
|
+
* The entry no longer names the remote id this registration sent: a salt
|
|
5303
|
+
* rotation (`rebindQueries`) re-keyed it while the request was out, and
|
|
5304
|
+
* seeded a fresh lifecycle for the new id.
|
|
5305
|
+
*/
|
|
5306
|
+
const movedOff = (current, sent) => String(current.def.id) !== String(sent.def.id);
|
|
5307
|
+
/**
|
|
5308
|
+
* Release the id a superseded registration sent and register the new one.
|
|
5309
|
+
* The re-key's own `RegisterRemote` for this hash joined the run still in
|
|
5310
|
+
* flight (the lane dedupes per hash), so it is armed again on a timer, which
|
|
5311
|
+
* fires once this run has left the lane.
|
|
5312
|
+
*/
|
|
5313
|
+
function* handOff(hash, sent) {
|
|
5314
|
+
yield fx.dispatch({
|
|
5315
|
+
type: "ReleaseSuperseded",
|
|
5316
|
+
ids: [sent.def.id]
|
|
5317
|
+
});
|
|
5318
|
+
yield fx.timer.set(`register:${hash}`, 0, {
|
|
5319
|
+
type: "RegisterRemote",
|
|
5320
|
+
hash
|
|
5321
|
+
});
|
|
5322
|
+
}
|
|
5323
|
+
/**
|
|
5284
5324
|
* One remote registration: `fn::query::register` plus the edge/meta/children
|
|
5285
5325
|
* read in ONE request, then membership application. Concurrent with every
|
|
5286
5326
|
* other registration; retried on its own backoff; stops when the entry is
|
|
5287
5327
|
* gone.
|
|
5328
|
+
*
|
|
5329
|
+
* An answer that lands after the query moved to a new id applies nothing:
|
|
5330
|
+
* the membership and the `registered` flag belong to the id it sent, which
|
|
5331
|
+
* this page no longer uses, so it releases that id and leaves the new one to
|
|
5332
|
+
* the registration the re-key dispatched. Marking the new id registered from
|
|
5333
|
+
* the old id's answer meant the new id was never registered, and nothing
|
|
5334
|
+
* ever named the old one again.
|
|
5288
5335
|
*/
|
|
5289
5336
|
function* registerRemote(env, hash, retry = false) {
|
|
5290
5337
|
const state = yield fx.state.read((s) => s);
|
|
@@ -5293,6 +5340,7 @@ function* registerRemote(env, hash, retry = false) {
|
|
|
5293
5340
|
const { remote } = entry.lifecycle;
|
|
5294
5341
|
if (remote === "registered" || remote === "failed" || remote === "registering" && !retry) return;
|
|
5295
5342
|
const background = entry.lifecycle.phase !== "cold";
|
|
5343
|
+
let superseded = false;
|
|
5296
5344
|
yield fx.state.update(compose(applyLifecycle(hash, { type: "remote-registering" }), applyLifecycle(hash, {
|
|
5297
5345
|
type: "fetch-begin",
|
|
5298
5346
|
background
|
|
@@ -5306,7 +5354,13 @@ function* registerRemote(env, hash, retry = false) {
|
|
|
5306
5354
|
ttl: entry.def.ttl,
|
|
5307
5355
|
...state.ownerSecret ? { owner: state.ownerSecret } : {}
|
|
5308
5356
|
}), env.remoteTimeoutMs);
|
|
5309
|
-
|
|
5357
|
+
const answered = yield fx.state.read((s) => s.queries.get(hash));
|
|
5358
|
+
if (!answered) return;
|
|
5359
|
+
superseded = movedOff(answered, entry);
|
|
5360
|
+
if (superseded) {
|
|
5361
|
+
yield* handOff(hash, entry);
|
|
5362
|
+
return;
|
|
5363
|
+
}
|
|
5310
5364
|
const register = stmt(results, 0);
|
|
5311
5365
|
if (!register || register.status === "ERR") throw new Error(register?.status === "ERR" ? register.error : "register returned nothing");
|
|
5312
5366
|
const answer = (r) => r?.status === "OK" ? r.result : FAILED;
|
|
@@ -5340,6 +5394,11 @@ function* registerRemote(env, hash, retry = false) {
|
|
|
5340
5394
|
} catch (error) {
|
|
5341
5395
|
const current = yield fx.state.read((s) => s.queries.get(hash));
|
|
5342
5396
|
if (!current) return;
|
|
5397
|
+
superseded = movedOff(current, entry);
|
|
5398
|
+
if (superseded) {
|
|
5399
|
+
yield* handOff(hash, entry);
|
|
5400
|
+
return;
|
|
5401
|
+
}
|
|
5343
5402
|
if (isNotAllowlisted(error)) {
|
|
5344
5403
|
yield fx.state.update(applyLifecycle(hash, { type: "remote-failed" }));
|
|
5345
5404
|
yield fx.emit({
|
|
@@ -5385,12 +5444,39 @@ function* registerRemote(env, hash, retry = false) {
|
|
|
5385
5444
|
retry: true
|
|
5386
5445
|
});
|
|
5387
5446
|
} finally {
|
|
5388
|
-
if (yield fx.state.read((s) => s.queries.has(hash))) yield fx.state.update(applyLifecycle(hash, {
|
|
5447
|
+
if ((yield fx.state.read((s) => s.queries.has(hash))) && !superseded) yield fx.state.update(applyLifecycle(hash, {
|
|
5389
5448
|
type: "fetch-end",
|
|
5390
5449
|
background
|
|
5391
5450
|
}));
|
|
5392
5451
|
}
|
|
5393
5452
|
}
|
|
5453
|
+
/**
|
|
5454
|
+
* Hand back remote ids this page registered and no longer uses: a query
|
|
5455
|
+
* moved to a new salt (sign-up, sign-in, sign-out, impersonation) while its
|
|
5456
|
+
* old id was registered or on its way. Nothing names the old id again, not
|
|
5457
|
+
* the heartbeat, not the pagehide beacon, so it used to hold a live view
|
|
5458
|
+
* until its TTL ran out, and the SSP fanned every change to a row it held out
|
|
5459
|
+
* to it. Best-effort: a row the caller may no longer touch (another
|
|
5460
|
+
* principal's) or a failed request is left to the TTL sweep, as before.
|
|
5461
|
+
*/
|
|
5462
|
+
function* releaseSuperseded(env, ids) {
|
|
5463
|
+
if (ids.length === 0) return;
|
|
5464
|
+
const sessionIds = yield fx.service("remote.sessionIds");
|
|
5465
|
+
const batch = releaseBatch(ids, sessionIds);
|
|
5466
|
+
try {
|
|
5467
|
+
yield fx.remote.query(batch.sql, batch.vars, env.remoteTimeoutMs);
|
|
5468
|
+
} catch (error) {
|
|
5469
|
+
yield fx.emit({
|
|
5470
|
+
type: "log",
|
|
5471
|
+
level: "debug",
|
|
5472
|
+
message: "superseded view release failed; the TTL sweep reclaims it",
|
|
5473
|
+
data: {
|
|
5474
|
+
ids: ids.map(String),
|
|
5475
|
+
error
|
|
5476
|
+
}
|
|
5477
|
+
});
|
|
5478
|
+
}
|
|
5479
|
+
}
|
|
5394
5480
|
|
|
5395
5481
|
//#endregion
|
|
5396
5482
|
//#region src/mutation/write.saga.ts
|
|
@@ -7317,9 +7403,21 @@ function* dropImpersonatedBucket(left) {
|
|
|
7317
7403
|
});
|
|
7318
7404
|
}
|
|
7319
7405
|
}
|
|
7320
|
-
/**
|
|
7406
|
+
/**
|
|
7407
|
+
* Re-seed every active query from the new store and rebuild its SSP view.
|
|
7408
|
+
*
|
|
7409
|
+
* Under a new salt each query gets a new remote id, and the id it leaves
|
|
7410
|
+
* goes back to the server: nothing names it again (not the heartbeat, not
|
|
7411
|
+
* the pagehide beacon), so it otherwise held a live view for its whole TTL.
|
|
7412
|
+
* A sign-up left one such row per query, because the page's queries mount
|
|
7413
|
+
* as the sign-up resolves and register under the anonymous salt on a socket
|
|
7414
|
+
* that is already signed in. Ids still in flight are released by their
|
|
7415
|
+
* registration when it lands (`registerRemote`); releasing them here too
|
|
7416
|
+
* covers a request that timed out and landed anyway.
|
|
7417
|
+
*/
|
|
7321
7418
|
function* rebindQueries() {
|
|
7322
7419
|
const [entries, sessionId] = yield fx.state.read((s) => [[...s.queries.values()], s.sessionId]);
|
|
7420
|
+
const superseded = [];
|
|
7323
7421
|
for (const entry of entries) {
|
|
7324
7422
|
let view = null;
|
|
7325
7423
|
try {
|
|
@@ -7352,8 +7450,10 @@ function* rebindQueries() {
|
|
|
7352
7450
|
});
|
|
7353
7451
|
}
|
|
7354
7452
|
const lifecycle = seedLifecycle(isResolvedBefore(view));
|
|
7453
|
+
const id = new RecordId$1("_00_query", hash);
|
|
7454
|
+
if (entry.lifecycle.remote !== "unregistered" && String(id) !== String(entry.def.id)) superseded.push(entry.def.id);
|
|
7355
7455
|
yield fx.state.update(rebindQuery(entry.def.hash, {
|
|
7356
|
-
id
|
|
7456
|
+
id,
|
|
7357
7457
|
lifecycle,
|
|
7358
7458
|
remoteArray: view?.ids ?? [],
|
|
7359
7459
|
localArray
|
|
@@ -7364,6 +7464,10 @@ function* rebindQueries() {
|
|
|
7364
7464
|
known: false
|
|
7365
7465
|
});
|
|
7366
7466
|
}
|
|
7467
|
+
if (superseded.length > 0) yield fx.dispatch({
|
|
7468
|
+
type: "ReleaseSuperseded",
|
|
7469
|
+
ids: superseded
|
|
7470
|
+
});
|
|
7367
7471
|
}
|
|
7368
7472
|
|
|
7369
7473
|
//#endregion
|
|
@@ -7483,6 +7587,10 @@ function route(env, event) {
|
|
|
7483
7587
|
saga: registerRemote(env, event.hash, event.retry),
|
|
7484
7588
|
lane: dedupe(`register:${event.hash}`)
|
|
7485
7589
|
};
|
|
7590
|
+
case "ReleaseSuperseded": return {
|
|
7591
|
+
saga: releaseSuperseded(env, event.ids),
|
|
7592
|
+
lane: serial("release")
|
|
7593
|
+
};
|
|
7486
7594
|
case "RecoverLostView": return {
|
|
7487
7595
|
saga: recoverLostView(event.hash),
|
|
7488
7596
|
lane: dedupe(`view-lost:${event.hash}`)
|
|
@@ -15922,7 +16030,7 @@ function createTabsCoordinator(config, s, host) {
|
|
|
15922
16030
|
return new TabsCoordinator({
|
|
15923
16031
|
tabId: s.tabId,
|
|
15924
16032
|
fingerprint: computeTabsFingerprint({
|
|
15925
|
-
coreVersion: "0.0.1-canary.
|
|
16033
|
+
coreVersion: "0.0.1-canary.351",
|
|
15926
16034
|
schemaHash: hash53(config.schemaSurql),
|
|
15927
16035
|
endpoint: config.database.endpoint ?? "",
|
|
15928
16036
|
namespace: config.database.namespace,
|
|
@@ -16086,6 +16194,7 @@ function createAdapters(config, s, host, lateModules) {
|
|
|
16086
16194
|
const sql = leaveViewsBeacon(ids, s.remote.sessionIds());
|
|
16087
16195
|
if (sql) s.remote.beaconSql(sql);
|
|
16088
16196
|
},
|
|
16197
|
+
"remote.sessionIds": () => s.remote.sessionIds(),
|
|
16089
16198
|
"supervisor.start": () => s.connectionSupervisor.start(),
|
|
16090
16199
|
"net.offline": () => typeof navigator !== "undefined" && navigator.onLine === false,
|
|
16091
16200
|
"tabs.start": (bucketId) => {
|
package/dist/live.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { B as listRefTableFor, L as ANON_USER_ID, O as registerSelect, R as DEFAULT_REF_MODE, S as listRefLive, U as sha256Hex, W as queryHashInput, a as metaFromRow, c as mintOwnerSecret, d as withTimeout, f as parseQueryParams, g as bodySelect, j as singleSnapshotSelect, k as registerVars, l as parseDuration, n as rowOfEdge, s as encodeRecordId, t as hashOfEdge, u as parseRecordIdString, v as heartbeatBatch, y as heartbeatRowGone } 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
|
@@ -258,6 +258,18 @@ declare function heartbeatBatch(ids: ReadonlyArray<RecordId<string>>): {
|
|
|
258
258
|
sql: string;
|
|
259
259
|
vars: Vars;
|
|
260
260
|
};
|
|
261
|
+
/**
|
|
262
|
+
* Hand back view ids this page no longer answers to. Per id,
|
|
263
|
+
* `fn::query::unsubscribe` drops this socket's entry and deletes the row when
|
|
264
|
+
* nobody else watches it; `fn::query::leave` then drops the entries of this
|
|
265
|
+
* page's earlier sockets (a reconnect re-registered under a new session id)
|
|
266
|
+
* and pulls the expiry in when none remain. Result index 2i answers the
|
|
267
|
+
* unsubscribe of id i, 2i + 1 its leave.
|
|
268
|
+
*/
|
|
269
|
+
declare function releaseBatch(ids: ReadonlyArray<RecordId<string>>, sessionIds: ReadonlyArray<string>): {
|
|
270
|
+
sql: string;
|
|
271
|
+
vars: Vars;
|
|
272
|
+
};
|
|
261
273
|
/** A heartbeat answer whose statement matched no row: the view was reclaimed. */
|
|
262
274
|
declare const heartbeatRowGone: (result: unknown) => boolean;
|
|
263
275
|
/**
|
|
@@ -386,4 +398,4 @@ declare function sha256Hex(input: string): Promise<string>;
|
|
|
386
398
|
*/
|
|
387
399
|
declare function queryKey(input: QueryKeyInput, sessionId: string | null): Promise<string>;
|
|
388
400
|
//#endregion
|
|
389
|
-
export { ANON_LIST_REF_TABLE, 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, listRefLive, 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 };
|
|
401
|
+
export { ANON_LIST_REF_TABLE, 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, listRefLive, listRefSelect, listRefTableFor, metaFromRow, parseDuration, parseQueryParams, parseRecordIdString, queryHashInput, queryKey, queryRowCountBatchSelect, queryRowCountSelect, readLegacyViewRows, readViewRows, registerSelect, registerVars, releaseBatch, 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 releaseBatch, B as listRefTableFor, C as listRefSelect, D as readViewRows, E as readLegacyViewRows, F as viewRow, G as viewKeyInput, H as surql, I as ANON_LIST_REF_TABLE, L as ANON_USER_ID, M as subqueryListRefSelect, N as upsertBodiesTx, O as registerSelect, P as viewRecordId, R as DEFAULT_REF_MODE, S as listRefLive, T as queryRowCountSelect, U as sha256Hex, V as sanitizeUserId, W as queryHashInput, _ as countViewRows, a as metaFromRow, b as leaveViewsBeacon, f as parseQueryParams, g as bodySelect, h as batchSnapshotSelect, i as dedupeRecordVersions, j as singleSnapshotSelect, k as registerVars, l as parseDuration, m as VIEW_TABLE, n as rowOfEdge, o as snapshotFromSingle, p as LEGACY_VIEW_TABLE, r as FAILED, s as encodeRecordId, t as hashOfEdge, u as parseRecordIdString, v as heartbeatBatch, w as queryRowCountBatchSelect, x as listRefBatchSelect, y as heartbeatRowGone, z as bucketIdForUser } 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_LIST_REF_TABLE, 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, listRefLive, 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_LIST_REF_TABLE, 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, listRefLive, listRefSelect, listRefTableFor, metaFromRow, parseDuration, parseQueryParams, parseRecordIdString, queryHashInput, queryKey, queryRowCountBatchSelect, queryRowCountSelect, readLegacyViewRows, readViewRows, registerSelect, registerVars, releaseBatch, rowOfEdge, sameBytes, sanitizeUserId, sha256Hex, singleSnapshotSelect, snapshotFromSingle, subqueryListRefSelect, surql, upsertBodiesTx, viewKeyInput, viewRecordId, viewRow };
|
package/dist/worker-edges.js
CHANGED
|
@@ -239,6 +239,24 @@ function heartbeatBatch(ids) {
|
|
|
239
239
|
vars
|
|
240
240
|
};
|
|
241
241
|
}
|
|
242
|
+
/**
|
|
243
|
+
* Hand back view ids this page no longer answers to. Per id,
|
|
244
|
+
* `fn::query::unsubscribe` drops this socket's entry and deletes the row when
|
|
245
|
+
* nobody else watches it; `fn::query::leave` then drops the entries of this
|
|
246
|
+
* page's earlier sockets (a reconnect re-registered under a new session id)
|
|
247
|
+
* and pulls the expiry in when none remain. Result index 2i answers the
|
|
248
|
+
* unsubscribe of id i, 2i + 1 its leave.
|
|
249
|
+
*/
|
|
250
|
+
function releaseBatch(ids, sessionIds) {
|
|
251
|
+
const vars = { sids: [...sessionIds] };
|
|
252
|
+
return {
|
|
253
|
+
sql: ids.flatMap((id, i) => {
|
|
254
|
+
vars[`id${i}`] = id;
|
|
255
|
+
return [`fn::query::unsubscribe($id${i})`, `fn::query::leave($id${i}, $sids)`];
|
|
256
|
+
}).join(";\n"),
|
|
257
|
+
vars
|
|
258
|
+
};
|
|
259
|
+
}
|
|
242
260
|
/** A heartbeat answer whose statement matched no row: the view was reclaimed. */
|
|
243
261
|
const heartbeatRowGone = (result) => Array.isArray(result) && result.length === 0;
|
|
244
262
|
const QUERY_ID = /^_00_query:(?:[0-9a-f]{64}|⟨[0-9a-f]{64}⟩)$/;
|
|
@@ -477,4 +495,4 @@ function rowOfEdge(value) {
|
|
|
477
495
|
}
|
|
478
496
|
|
|
479
497
|
//#endregion
|
|
480
|
-
export {
|
|
498
|
+
export { releaseBatch as A, listRefTableFor as B, listRefSelect as C, readViewRows as D, readLegacyViewRows as E, viewRow as F, viewKeyInput as G, surql as H, ANON_LIST_REF_TABLE as I, ANON_USER_ID as L, subqueryListRefSelect as M, upsertBodiesTx as N, registerSelect as O, viewRecordId as P, DEFAULT_REF_MODE as R, listRefLive as S, queryRowCountSelect as T, sha256Hex as U, sanitizeUserId as V, queryHashInput as W, countViewRows as _, metaFromRow as a, leaveViewsBeacon as b, mintOwnerSecret as c, withTimeout as d, parseQueryParams as f, bodySelect as g, batchSnapshotSelect as h, dedupeRecordVersions as i, singleSnapshotSelect as j, registerVars as k, parseDuration as l, VIEW_TABLE as m, rowOfEdge as n, snapshotFromSingle as o, LEGACY_VIEW_TABLE as p, FAILED as r, encodeRecordId as s, hashOfEdge as t, parseRecordIdString as u, heartbeatBatch as v, queryRowCountBatchSelect as w, listRefBatchSelect as x, heartbeatRowGone as y, bucketIdForUser 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.351",
|
|
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.351",
|
|
79
|
+
"@spooky-sync/ssp-wasm": "0.0.1-canary.351",
|
|
80
80
|
"@sqlite.org/sqlite-wasm": "3.53.0-build1",
|
|
81
81
|
"@surrealdb/wasm": "^3.1.0",
|
|
82
82
|
"@surrealdb/wasm-native": "^3.3.4",
|
|
@@ -66,6 +66,45 @@ describe('authFlip', () => {
|
|
|
66
66
|
expect(id).toBe(await sha256Hex(queryHashInput(def, 'salt-1')));
|
|
67
67
|
expect(id).not.toBe(before);
|
|
68
68
|
});
|
|
69
|
+
it('a sign-up releases the id each query held under the anonymous salt, so the page owns one remote row per query', async () => {
|
|
70
|
+
// threads-e2e, canary.350: the list mounted as the sign-up resolved, so
|
|
71
|
+
// its queries registered under the anonymous salt on a socket already
|
|
72
|
+
// signed in, then the flip re-keyed them. Every query left a second row
|
|
73
|
+
// (same surql, same auth_id) that the pagehide beacon never named and
|
|
74
|
+
// that fanned out every change for its whole TTL.
|
|
75
|
+
const svc = fakeServices({
|
|
76
|
+
'auth.sessionAuthId': () => 'user:u1',
|
|
77
|
+
'auth.access': () => 'account',
|
|
78
|
+
'local.currentBucketId': () => 'anon',
|
|
79
|
+
'local.beginSwitch': () => () => undefined,
|
|
80
|
+
'local.usesSurqlSchema': () => false,
|
|
81
|
+
'auth.token': () => 'jwt',
|
|
82
|
+
'auth.currentUser': () => null,
|
|
83
|
+
});
|
|
84
|
+
const threads = { surql: 'SELECT * FROM thread', params: {} };
|
|
85
|
+
const user = { surql: 'SELECT * FROM user WHERE id = $id', params: { id: 'user:u1' } };
|
|
86
|
+
const anonId = async (def: typeof threads) => new RecordId('_00_query', await sha256Hex(queryHashInput(def, 'anon-salt')));
|
|
87
|
+
const [threadsId, userId] = [await anonId(threads), await anonId(user)];
|
|
88
|
+
const state = R.compose(
|
|
89
|
+
R.putQuery(buildEntry({ def: { hash: 't', ...threads, id: threadsId }, lifecycle: { phase: 'live', remote: 'registered' } })),
|
|
90
|
+
R.putQuery(buildEntry({ def: { hash: 'u', ...user, id: userId }, lifecycle: { phase: 'cold', remote: 'registering' } })),
|
|
91
|
+
R.putQuery(buildEntry({ def: { hash: 'n', surql: 'SELECT * FROM comment' }, lifecycle: { phase: 'cold', remote: 'unregistered' } }))
|
|
92
|
+
)({ ...buildState(), sessionId: 'anon-salt', saltUserId: null, bucketId: 'anon' });
|
|
93
|
+
const out = await runPure(authFlip(env, 'user:u1'), {
|
|
94
|
+
state,
|
|
95
|
+
handlers: {
|
|
96
|
+
service: svc.handler,
|
|
97
|
+
'local.getById': () => null,
|
|
98
|
+
'ssp.register': () => ({ localArray: [], timings: {} }),
|
|
99
|
+
'local.query': () => [],
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
expect(String(out.state.queries.get('t')!.def.id.id)).toBe(await sha256Hex(queryHashInput(threads, 'salt-1')));
|
|
103
|
+
expect(String(out.state.queries.get('u')!.def.id.id)).toBe(await sha256Hex(queryHashInput(user, 'salt-1')));
|
|
104
|
+
// Registered and in-flight ids may exist on the server; a never-registered one cannot.
|
|
105
|
+
expect(out.dispatched.filter((d) => d.type === 'ReleaseSuperseded')).toEqual([{ type: 'ReleaseSuperseded', ids: [threadsId, userId] }]);
|
|
106
|
+
expect(out.dispatched.map((d) => d.type)).toContain('EnsureRegistered');
|
|
107
|
+
});
|
|
69
108
|
it('a new principal on the same bucket still re-keys its queries', async () => {
|
|
70
109
|
const svc = fakeServices({
|
|
71
110
|
'auth.sessionAuthId': () => 'user:b',
|
|
@@ -72,7 +72,9 @@ describe('bucketSwitch', () => {
|
|
|
72
72
|
expect(String(e.def.id.table)).toBe('_00_query');
|
|
73
73
|
// PollTick is load-bearing: the switch cleared the poll timer, and only the
|
|
74
74
|
// tick re-arms it, so a sign-in without it leaves the client on LIVE alone.
|
|
75
|
-
expect(out.dispatched.map((d) => d.type)).toEqual(['PrimeCircuit', 'AdoptPendingWrites', 'EnsureRegistered', 'LiveStart', 'PollTick', 'Drain']);
|
|
75
|
+
expect(out.dispatched.map((d) => d.type)).toEqual(['PrimeCircuit', 'ReleaseSuperseded', 'AdoptPendingWrites', 'EnsureRegistered', 'LiveStart', 'PollTick', 'Drain']);
|
|
76
|
+
// The id the query held under the previous salt goes back to the server.
|
|
77
|
+
expect(out.dispatched.find((d) => d.type === 'ReleaseSuperseded')).toEqual({ type: 'ReleaseSuperseded', ids: [new RecordId('_00_query', 'a')] });
|
|
76
78
|
});
|
|
77
79
|
it('shared tabs: moves namespaces, falls back to solo on failure; blob clear on sign-out; failures are logged and the gate always reopens', async () => {
|
|
78
80
|
let gateReleased = false;
|
|
@@ -100,9 +100,21 @@ export function* dropImpersonatedBucket(left: string): Saga<void> {
|
|
|
100
100
|
}
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
-
/**
|
|
103
|
+
/**
|
|
104
|
+
* Re-seed every active query from the new store and rebuild its SSP view.
|
|
105
|
+
*
|
|
106
|
+
* Under a new salt each query gets a new remote id, and the id it leaves
|
|
107
|
+
* goes back to the server: nothing names it again (not the heartbeat, not
|
|
108
|
+
* the pagehide beacon), so it otherwise held a live view for its whole TTL.
|
|
109
|
+
* A sign-up left one such row per query, because the page's queries mount
|
|
110
|
+
* as the sign-up resolves and register under the anonymous salt on a socket
|
|
111
|
+
* that is already signed in. Ids still in flight are released by their
|
|
112
|
+
* registration when it lands (`registerRemote`); releasing them here too
|
|
113
|
+
* covers a request that timed out and landed anyway.
|
|
114
|
+
*/
|
|
104
115
|
export function* rebindQueries(): Saga<void> {
|
|
105
116
|
const [entries, sessionId] = (yield fx.state.read((s) => [[...s.queries.values()], s.sessionId])) as [QueryEntry[], string | null];
|
|
117
|
+
const superseded: QueryEntry['def']['id'][] = [];
|
|
106
118
|
for (const entry of entries) {
|
|
107
119
|
let view: ReturnType<typeof parseViewRow> = null;
|
|
108
120
|
try {
|
|
@@ -125,7 +137,9 @@ export function* rebindQueries(): Saga<void> {
|
|
|
125
137
|
yield fx.emit({ type: 'log', level: 'warn', message: 'local view rebuild failed after bucket switch', data: { hash: entry.def.hash, error } });
|
|
126
138
|
}
|
|
127
139
|
const lifecycle = seedLifecycle(isResolvedBefore(view));
|
|
128
|
-
|
|
140
|
+
const id = new RecordId('_00_query', hash);
|
|
141
|
+
if (entry.lifecycle.remote !== 'unregistered' && String(id) !== String(entry.def.id)) superseded.push(entry.def.id);
|
|
142
|
+
yield fx.state.update(R.rebindQuery(entry.def.hash, { id, lifecycle, remoteArray: view?.ids ?? [], localArray }));
|
|
129
143
|
// A re-seed is never answered, not even from a durable view: subscribers
|
|
130
144
|
// still hold the previous store's rows until `materialize` paints the new
|
|
131
145
|
// store's, and only that render flips authority back on.
|
|
@@ -133,4 +147,5 @@ export function* rebindQueries(): Saga<void> {
|
|
|
133
147
|
yield fx.emit({ type: 'query:authority', hash: entry.def.hash, known: false });
|
|
134
148
|
}
|
|
135
149
|
}
|
|
150
|
+
if (superseded.length > 0) yield fx.dispatch({ type: 'ReleaseSuperseded', ids: superseded });
|
|
136
151
|
}
|
package/src/client/router.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { RuntimeEvent } from '../kernel/events';
|
|
2
2
|
import type { Lane, Saga } from '../kernel/saga';
|
|
3
3
|
import type { SagaEnv } from '../query/env';
|
|
4
|
-
import { ensureRegistered, registerRemote } from '../query/register.saga';
|
|
4
|
+
import { ensureRegistered, registerRemote, releaseSuperseded } from '../query/register.saga';
|
|
5
5
|
import { readDirtyMembership, readMembership, recoverLostView } from '../query/membership.saga';
|
|
6
6
|
import { fetchRows } from '../query/fetch.saga';
|
|
7
7
|
import { materialize, streamUpdate } from '../query/materialize.saga';
|
|
@@ -37,6 +37,8 @@ export function route(env: SagaEnv, event: RuntimeEvent): RouteTarget {
|
|
|
37
37
|
return { saga: ensureRegistered(env, { requireAuth: event.requireAuth, attempt: event.attempt }), lane: serial('ensure') };
|
|
38
38
|
case 'RegisterRemote':
|
|
39
39
|
return { saga: registerRemote(env, event.hash, event.retry), lane: dedupe(`register:${event.hash}`) };
|
|
40
|
+
case 'ReleaseSuperseded':
|
|
41
|
+
return { saga: releaseSuperseded(env, event.ids), lane: serial('release') };
|
|
40
42
|
case 'RecoverLostView':
|
|
41
43
|
return { saga: recoverLostView(event.hash), lane: dedupe(`view-lost:${event.hash}`) };
|
|
42
44
|
case 'ReadDirtyMembership':
|
|
@@ -7,6 +7,7 @@ import type { Saga } from '../kernel/saga';
|
|
|
7
7
|
import * as R from '../state/reducers';
|
|
8
8
|
import { Runtime } from './runtime';
|
|
9
9
|
import { rebindQueries } from '../boot/bucket-switch.saga';
|
|
10
|
+
import { registerLocal } from '../query/register.saga';
|
|
10
11
|
|
|
11
12
|
const env = defaultEnv({ tables: [] } as any, { materializeDebounceMs: 5 });
|
|
12
13
|
const noop = () => {};
|
|
@@ -233,3 +234,66 @@ describe('Runtime state hooks', () => {
|
|
|
233
234
|
expect(rt.state.dirty.has('q')).toBe(true);
|
|
234
235
|
});
|
|
235
236
|
});
|
|
237
|
+
|
|
238
|
+
describe('Runtime sign-up', () => {
|
|
239
|
+
it('the page ends up owning one remote row per query when its registration races the auth flip', async () => {
|
|
240
|
+
// threads-e2e, canary.350: the list mounts as the sign-up resolves and
|
|
241
|
+
// registers under the anonymous salt on a socket already signed in; the
|
|
242
|
+
// flip re-keys it while that request is out. The fake server keeps the
|
|
243
|
+
// `_00_query` rows the way `fn::query::register` / `unsubscribe` do.
|
|
244
|
+
const rows = new Set<string>();
|
|
245
|
+
let land!: () => void;
|
|
246
|
+
const held = new Promise<void>((r) => (land = r));
|
|
247
|
+
let first = true;
|
|
248
|
+
const ok = (result: unknown) => ({ status: 'OK' as const, result });
|
|
249
|
+
let bucket = 'anon';
|
|
250
|
+
const { a, rt } = make(
|
|
251
|
+
{
|
|
252
|
+
remote: {
|
|
253
|
+
queryResponses: async (sql: string, vars?: Record<string, any>) => {
|
|
254
|
+
if (sql.startsWith('fn::query::register($config)')) {
|
|
255
|
+
rows.add(String(vars!.config.id));
|
|
256
|
+
if (first) {
|
|
257
|
+
first = false;
|
|
258
|
+
await held;
|
|
259
|
+
}
|
|
260
|
+
return [ok(null), ok([]), ok({ rowCount: 0, state: 'ready' }), ok([])];
|
|
261
|
+
}
|
|
262
|
+
if (sql.includes('fn::query::unsubscribe')) {
|
|
263
|
+
for (const [k, v] of Object.entries(vars ?? {})) if (k.startsWith('id')) rows.delete(String(v));
|
|
264
|
+
return [];
|
|
265
|
+
}
|
|
266
|
+
return [];
|
|
267
|
+
},
|
|
268
|
+
},
|
|
269
|
+
services: {
|
|
270
|
+
'auth.sessionAuthId': () => 'user:u1',
|
|
271
|
+
'auth.access': () => 'account',
|
|
272
|
+
'local.currentBucketId': () => bucket,
|
|
273
|
+
'local.beginSwitch': () => () => undefined,
|
|
274
|
+
'local.switchStore': async (target: string) => void (bucket = target),
|
|
275
|
+
'local.usesSurqlSchema': () => false,
|
|
276
|
+
'auth.currentUser': () => null,
|
|
277
|
+
'remote.sessionIds': () => ['s1'],
|
|
278
|
+
},
|
|
279
|
+
},
|
|
280
|
+
{ ...buildState(), sessionId: 'anon-salt', saltUserId: null, bucketId: 'anon', primed: true }
|
|
281
|
+
);
|
|
282
|
+
const hash = await rt.run(registerLocal(env, { tableName: 'thread', surql: 'SELECT * FROM thread', params: {}, ttl: '10m' }));
|
|
283
|
+
await tick();
|
|
284
|
+
expect(rows.size).toBe(1);
|
|
285
|
+
const anonId = [...rows][0];
|
|
286
|
+
|
|
287
|
+
await rt.dispatch({ type: 'AuthFlip', userId: 'user:u1' });
|
|
288
|
+
land();
|
|
289
|
+
for (let i = 0; i < 10; i++) {
|
|
290
|
+
await tick();
|
|
291
|
+
a.timers.fire(`register:${hash}`);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const entry = rt.state.queries.get(hash)!;
|
|
295
|
+
expect(String(entry.def.id)).not.toBe(anonId);
|
|
296
|
+
expect([...rows]).toEqual([String(entry.def.id)]);
|
|
297
|
+
expect(entry.lifecycle.remote).toBe('registered');
|
|
298
|
+
});
|
|
299
|
+
});
|
package/src/client/services.ts
CHANGED
|
@@ -330,6 +330,7 @@ export function createAdapters<S extends SchemaStructure>(config: Sp00kyConfig<S
|
|
|
330
330
|
const sql = leaveViewsBeacon(ids, s.remote.sessionIds());
|
|
331
331
|
if (sql) s.remote.beaconSql(sql);
|
|
332
332
|
},
|
|
333
|
+
'remote.sessionIds': () => s.remote.sessionIds(),
|
|
333
334
|
'supervisor.start': () => s.connectionSupervisor.start(),
|
|
334
335
|
'net.offline': () => typeof navigator !== 'undefined' && navigator.onLine === false,
|
|
335
336
|
'tabs.start': (bucketId) => {
|
package/src/kernel/effects.ts
CHANGED
|
@@ -103,6 +103,8 @@ export interface ServiceCalls {
|
|
|
103
103
|
/** The platform reports no network at all (a browser's `navigator.onLine === false`). */
|
|
104
104
|
'net.offline': () => boolean;
|
|
105
105
|
'remote.releaseViews': (ids: unknown[]) => void;
|
|
106
|
+
/** `session::id()` of every socket this page has had, oldest first. */
|
|
107
|
+
'remote.sessionIds': () => readonly string[];
|
|
106
108
|
'supervisor.start': () => void;
|
|
107
109
|
'tabs.start': (bucketId: string) => Promise<'solo' | 'leader' | 'follower'>;
|
|
108
110
|
'tabs.moveToBucket': (bucketId: string) => Promise<'solo' | 'leader' | 'follower'>;
|
package/src/kernel/events.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { RecordId } from 'surrealdb';
|
|
1
2
|
import type { ConnectionState, EdgeDelta, InlineRow, QueryHash, QueryStatus, SyncHealth } from '../types';
|
|
2
3
|
import type { StreamUpdate } from '../services/stream-processor/index';
|
|
3
4
|
|
|
@@ -9,6 +10,7 @@ import type { StreamUpdate } from '../services/stream-processor/index';
|
|
|
9
10
|
export type RuntimeEvent =
|
|
10
11
|
| { type: 'EnsureRegistered'; requireAuth?: boolean; attempt?: number }
|
|
11
12
|
| { type: 'RegisterRemote'; hash: QueryHash; retry?: boolean }
|
|
13
|
+
| { type: 'ReleaseSuperseded'; ids: RecordId<string>[] }
|
|
12
14
|
| { type: 'SyncOutcome'; ok: boolean; error?: unknown }
|
|
13
15
|
| { type: 'AckPrune' }
|
|
14
16
|
| { type: 'ReadDirtyMembership' }
|
|
@@ -242,7 +242,7 @@ export class FlagsAdminService {
|
|
|
242
242
|
|
|
243
243
|
/**
|
|
244
244
|
* Add or remove a user from `$key`'s allowlist for `$variant`, then
|
|
245
|
-
* re-
|
|
245
|
+
* re-evaluate that one user (nobody else's variant can change). Defaults to the signed-in user, so the common case
|
|
246
246
|
* ("turn this on for me, for real") needs no user picker.
|
|
247
247
|
*/
|
|
248
248
|
async setFlagUserVariant(
|
|
@@ -4,7 +4,9 @@ import { runPure, sha256Hex } from '../testing/run-pure';
|
|
|
4
4
|
import { buildEntry, buildState } from '../testing/build';
|
|
5
5
|
import { defaultEnv } from './env';
|
|
6
6
|
import { queryHashInput } from './hash';
|
|
7
|
-
import { ensureRegistered, isNotAllowlisted, registerLocal, registerRemote } from './register.saga';
|
|
7
|
+
import { ensureRegistered, isNotAllowlisted, registerLocal, registerRemote, releaseSuperseded } from './register.saga';
|
|
8
|
+
import { seedLifecycle } from '../state/lifecycle';
|
|
9
|
+
import { fakeServices } from '../testing/services';
|
|
8
10
|
import * as R from '../state/reducers';
|
|
9
11
|
import { emptyState } from '../state/client-state';
|
|
10
12
|
import type { StatementResult } from '../kernel/effects';
|
|
@@ -309,4 +311,59 @@ describe('registerRemote', () => {
|
|
|
309
311
|
});
|
|
310
312
|
expect(gone.dispatched).toEqual([]);
|
|
311
313
|
});
|
|
314
|
+
it('a registration that lands after its query moved to a new id releases the id it registered and leaves the new one to register', async () => {
|
|
315
|
+
// A sign-up: the page registered under the anonymous salt with the socket
|
|
316
|
+
// already signed in, and the auth flip re-keyed the query while that
|
|
317
|
+
// request was out. Before the fix the answer marked the NEW id registered,
|
|
318
|
+
// so it was never registered, and nothing ever released the old one.
|
|
319
|
+
const moved = new RecordId('_00_query', 'b'.repeat(64));
|
|
320
|
+
for (const fail of [false, true]) {
|
|
321
|
+
const out = await runPure(registerRemote(env, 'a'), {
|
|
322
|
+
state: buildState([entry]),
|
|
323
|
+
handlers: {
|
|
324
|
+
'remote.query': (_e, ctx) => {
|
|
325
|
+
ctx.state = R.rebindQuery('a', { id: moved, lifecycle: seedLifecycle(false), remoteArray: [], localArray: [] })(ctx.state);
|
|
326
|
+
if (fail) throw new Error('Remote request timed out');
|
|
327
|
+
return happy();
|
|
328
|
+
},
|
|
329
|
+
'local.upsert': () => undefined,
|
|
330
|
+
},
|
|
331
|
+
});
|
|
332
|
+
const e = out.state.queries.get('a')!;
|
|
333
|
+
expect(e.def.id).toBe(moved);
|
|
334
|
+
expect(e.lifecycle).toMatchObject({ remote: 'unregistered', fetchDepth: 0, backgroundDepth: 0 });
|
|
335
|
+
expect(e.remoteArray).toEqual([]);
|
|
336
|
+
expect(e.registerAttempts).toBe(0);
|
|
337
|
+
expect(out.dispatched).toEqual([{ type: 'ReleaseSuperseded', ids: [entry.def.id] }]);
|
|
338
|
+
// The re-key's RegisterRemote joined this run on the per-hash lane: arm it again.
|
|
339
|
+
expect([...out.timers]).toEqual([['register:a', { ms: 0, event: { type: 'RegisterRemote', hash: 'a' } }]]);
|
|
340
|
+
}
|
|
341
|
+
});
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
describe('releaseSuperseded', () => {
|
|
345
|
+
it("per id: unsubscribe as this socket, then leave for this page's earlier sockets, in one request; a failure is only logged", async () => {
|
|
346
|
+
const ids = [new RecordId('_00_query', 'a'.repeat(64)), new RecordId('_00_query', 'b'.repeat(64))];
|
|
347
|
+
const out = await runPure(releaseSuperseded(env, ids), {
|
|
348
|
+
handlers: {
|
|
349
|
+
service: fakeServices({ 'remote.sessionIds': () => ['s1', 's2'] }).handler,
|
|
350
|
+
'remote.query': () => [okStmt({ released: true }), okStmt({ leaving: false }), okStmt({ released: true }), okStmt({ leaving: false })],
|
|
351
|
+
},
|
|
352
|
+
});
|
|
353
|
+
const req = out.log.find((x) => x.kind === 'remote.query') as any;
|
|
354
|
+
expect(req.sql).toBe('fn::query::unsubscribe($id0);\nfn::query::leave($id0, $sids);\nfn::query::unsubscribe($id1);\nfn::query::leave($id1, $sids)');
|
|
355
|
+
expect(req.vars).toEqual({ id0: ids[0], id1: ids[1], sids: ['s1', 's2'] });
|
|
356
|
+
expect(req.timeoutMs).toBe(env.remoteTimeoutMs);
|
|
357
|
+
const failing = await runPure(releaseSuperseded(env, ids), {
|
|
358
|
+
handlers: {
|
|
359
|
+
service: fakeServices({ 'remote.sessionIds': () => [] }).handler,
|
|
360
|
+
'remote.query': () => {
|
|
361
|
+
throw new Error('offline');
|
|
362
|
+
},
|
|
363
|
+
},
|
|
364
|
+
});
|
|
365
|
+
expect(failing.emitted).toEqual([expect.objectContaining({ type: 'log', level: 'debug' })]);
|
|
366
|
+
const none = await runPure(releaseSuperseded(env, []), {});
|
|
367
|
+
expect(none.log).toEqual([]);
|
|
368
|
+
});
|
|
312
369
|
});
|
|
@@ -139,11 +139,36 @@ const stmt = (results: StatementResult[], i: number): StatementResult | undefine
|
|
|
139
139
|
export const isNotAllowlisted = (error: unknown): boolean =>
|
|
140
140
|
/not_allowlisted|\b403\b|forbidden/i.test(String((error as { message?: unknown })?.message ?? error));
|
|
141
141
|
|
|
142
|
+
/**
|
|
143
|
+
* The entry no longer names the remote id this registration sent: a salt
|
|
144
|
+
* rotation (`rebindQueries`) re-keyed it while the request was out, and
|
|
145
|
+
* seeded a fresh lifecycle for the new id.
|
|
146
|
+
*/
|
|
147
|
+
const movedOff = (current: QueryEntry, sent: QueryEntry): boolean => String(current.def.id) !== String(sent.def.id);
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Release the id a superseded registration sent and register the new one.
|
|
151
|
+
* The re-key's own `RegisterRemote` for this hash joined the run still in
|
|
152
|
+
* flight (the lane dedupes per hash), so it is armed again on a timer, which
|
|
153
|
+
* fires once this run has left the lane.
|
|
154
|
+
*/
|
|
155
|
+
function* handOff(hash: QueryHash, sent: QueryEntry): Saga<void> {
|
|
156
|
+
yield fx.dispatch({ type: 'ReleaseSuperseded', ids: [sent.def.id] });
|
|
157
|
+
yield fx.timer.set(`register:${hash}`, 0, { type: 'RegisterRemote', hash });
|
|
158
|
+
}
|
|
159
|
+
|
|
142
160
|
/**
|
|
143
161
|
* One remote registration: `fn::query::register` plus the edge/meta/children
|
|
144
162
|
* read in ONE request, then membership application. Concurrent with every
|
|
145
163
|
* other registration; retried on its own backoff; stops when the entry is
|
|
146
164
|
* gone.
|
|
165
|
+
*
|
|
166
|
+
* An answer that lands after the query moved to a new id applies nothing:
|
|
167
|
+
* the membership and the `registered` flag belong to the id it sent, which
|
|
168
|
+
* this page no longer uses, so it releases that id and leaves the new one to
|
|
169
|
+
* the registration the re-key dispatched. Marking the new id registered from
|
|
170
|
+
* the old id's answer meant the new id was never registered, and nothing
|
|
171
|
+
* ever named the old one again.
|
|
147
172
|
*/
|
|
148
173
|
export function* registerRemote(env: SagaEnv, hash: QueryHash, retry = false): Saga<void> {
|
|
149
174
|
const state = (yield fx.state.read((s) => s)) as ClientState;
|
|
@@ -154,6 +179,7 @@ export function* registerRemote(env: SagaEnv, hash: QueryHash, retry = false): S
|
|
|
154
179
|
// A query that already shows rows (cached or live) only re-verifies them
|
|
155
180
|
// here: the cycle holds `fetching` but is not traffic the user waits on.
|
|
156
181
|
const background = entry.lifecycle.phase !== 'cold';
|
|
182
|
+
let superseded = false;
|
|
157
183
|
yield fx.state.update(
|
|
158
184
|
R.compose(R.applyLifecycle(hash, { type: 'remote-registering' }), R.applyLifecycle(hash, { type: 'fetch-begin', background }))
|
|
159
185
|
);
|
|
@@ -170,8 +196,13 @@ export function* registerRemote(env: SagaEnv, hash: QueryHash, retry = false): S
|
|
|
170
196
|
}),
|
|
171
197
|
env.remoteTimeoutMs
|
|
172
198
|
)) as StatementResult[];
|
|
173
|
-
const
|
|
174
|
-
if (!
|
|
199
|
+
const answered = (yield fx.state.read((s) => s.queries.get(hash))) as QueryEntry | undefined;
|
|
200
|
+
if (!answered) return;
|
|
201
|
+
superseded = movedOff(answered, entry);
|
|
202
|
+
if (superseded) {
|
|
203
|
+
yield* handOff(hash, entry);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
175
206
|
const register = stmt(results, 0);
|
|
176
207
|
if (!register || register.status === 'ERR') throw new Error(register?.status === 'ERR' ? register.error : 'register returned nothing');
|
|
177
208
|
const answer = (r: StatementResult | undefined): unknown => (r?.status === 'OK' ? r.result : FAILED);
|
|
@@ -202,6 +233,12 @@ export function* registerRemote(env: SagaEnv, hash: QueryHash, retry = false): S
|
|
|
202
233
|
} catch (error) {
|
|
203
234
|
const current = (yield fx.state.read((s) => s.queries.get(hash))) as QueryEntry | undefined;
|
|
204
235
|
if (!current) return;
|
|
236
|
+
// A request that timed out may still land: release its id all the same.
|
|
237
|
+
superseded = movedOff(current, entry);
|
|
238
|
+
if (superseded) {
|
|
239
|
+
yield* handOff(hash, entry);
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
205
242
|
if (isNotAllowlisted(error)) {
|
|
206
243
|
yield fx.state.update(R.applyLifecycle(hash, { type: 'remote-failed' }));
|
|
207
244
|
yield fx.emit({
|
|
@@ -229,7 +266,28 @@ export function* registerRemote(env: SagaEnv, hash: QueryHash, retry = false): S
|
|
|
229
266
|
yield fx.timer.set(`register:${hash}`, backoffMs(attempts), { type: 'RegisterRemote', hash, retry: true });
|
|
230
267
|
}
|
|
231
268
|
} finally {
|
|
269
|
+
// A re-key seeded a fresh lifecycle: this cycle's begin is not in it.
|
|
232
270
|
const stillThere = (yield fx.state.read((s) => s.queries.has(hash))) as boolean;
|
|
233
|
-
if (stillThere) yield fx.state.update(R.applyLifecycle(hash, { type: 'fetch-end', background }));
|
|
271
|
+
if (stillThere && !superseded) yield fx.state.update(R.applyLifecycle(hash, { type: 'fetch-end', background }));
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Hand back remote ids this page registered and no longer uses: a query
|
|
277
|
+
* moved to a new salt (sign-up, sign-in, sign-out, impersonation) while its
|
|
278
|
+
* old id was registered or on its way. Nothing names the old id again, not
|
|
279
|
+
* the heartbeat, not the pagehide beacon, so it used to hold a live view
|
|
280
|
+
* until its TTL ran out, and the SSP fanned every change to a row it held out
|
|
281
|
+
* to it. Best-effort: a row the caller may no longer touch (another
|
|
282
|
+
* principal's) or a failed request is left to the TTL sweep, as before.
|
|
283
|
+
*/
|
|
284
|
+
export function* releaseSuperseded(env: SagaEnv, ids: ReadonlyArray<QueryEntry['def']['id']>): Saga<void> {
|
|
285
|
+
if (ids.length === 0) return;
|
|
286
|
+
const sessionIds = (yield fx.service('remote.sessionIds')) as readonly string[];
|
|
287
|
+
const batch = sql.releaseBatch(ids, sessionIds);
|
|
288
|
+
try {
|
|
289
|
+
yield fx.remote.query(batch.sql, batch.vars, env.remoteTimeoutMs);
|
|
290
|
+
} catch (error) {
|
|
291
|
+
yield fx.emit({ type: 'log', level: 'debug', message: 'superseded view release failed; the TTL sweep reclaims it', data: { ids: ids.map(String), error } });
|
|
234
292
|
}
|
|
235
293
|
}
|
package/src/query/sql.ts
CHANGED
|
@@ -118,6 +118,23 @@ export function heartbeatBatch(ids: ReadonlyArray<RecordId<string>>): { sql: str
|
|
|
118
118
|
return { sql: stmts.join(';\n'), vars };
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
+
/**
|
|
122
|
+
* Hand back view ids this page no longer answers to. Per id,
|
|
123
|
+
* `fn::query::unsubscribe` drops this socket's entry and deletes the row when
|
|
124
|
+
* nobody else watches it; `fn::query::leave` then drops the entries of this
|
|
125
|
+
* page's earlier sockets (a reconnect re-registered under a new session id)
|
|
126
|
+
* and pulls the expiry in when none remain. Result index 2i answers the
|
|
127
|
+
* unsubscribe of id i, 2i + 1 its leave.
|
|
128
|
+
*/
|
|
129
|
+
export function releaseBatch(ids: ReadonlyArray<RecordId<string>>, sessionIds: ReadonlyArray<string>): { sql: string; vars: Vars } {
|
|
130
|
+
const vars: Vars = { sids: [...sessionIds] };
|
|
131
|
+
const stmts = ids.flatMap((id, i) => {
|
|
132
|
+
vars[`id${i}`] = id;
|
|
133
|
+
return [`fn::query::unsubscribe($id${i})`, `fn::query::leave($id${i}, $sids)`];
|
|
134
|
+
});
|
|
135
|
+
return { sql: stmts.join(';\n'), vars };
|
|
136
|
+
}
|
|
137
|
+
|
|
121
138
|
/** A heartbeat answer whose statement matched no row: the view was reclaimed. */
|
|
122
139
|
export const heartbeatRowGone = (result: unknown): boolean => Array.isArray(result) && result.length === 0;
|
|
123
140
|
|