@spooky-sync/core 0.0.1-canary.332 → 0.0.1-canary.334
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 +14 -2
- package/dist/index.js +124 -26
- package/dist/types.d.ts +46 -4
- package/package.json +3 -3
- package/src/boot/bucket-switch.saga.test.ts +9 -7
- package/src/boot/bucket-switch.saga.ts +6 -3
- package/src/client/runtime.test.ts +40 -0
- package/src/client/runtime.ts +2 -2
- package/src/modules/auth/auth.session-recheck.test.ts +140 -0
- package/src/modules/auth/index.ts +68 -1
- package/src/modules/devtools/index.ts +3 -1
- package/src/query/anon-query-isolation.integration.test.ts +7 -3
- package/src/query/materialize.saga.test.ts +73 -0
- package/src/query/materialize.saga.ts +17 -5
- package/src/query/membership.saga.test.ts +5 -2
- package/src/query/membership.saga.ts +2 -3
- package/src/query/register.saga.test.ts +2 -1
- package/src/query/session-guard.integration.test.ts +302 -0
- package/src/state/lifecycle.test.ts +46 -3
- package/src/state/lifecycle.ts +32 -4
- package/src/state/reducers.test.ts +12 -2
- package/src/state/reducers.ts +14 -4
- package/src/state/selectors.ts +1 -0
- package/src/types.ts +12 -3
- package/src/utils/error-classification.test.ts +19 -1
- package/src/utils/error-classification.ts +12 -0
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { $ as WebPushModule, A as RegistrationTimings, At as
|
|
1
|
+
import { $ as WebPushModule, A as RegistrationTimings, At as RemoteDatabaseService, B as SyncHealthConfig, Bt as ImpersonationTimers, C as QueryStatusCallback, Ct as SIGN_OUT_HOOK_TIMEOUT_MS, D as ReconnectConfig, Dt as AuthEventTypeMap, E as QueryUpdateCallback, Et as AuthEventSystem, F as Sp00kyQueryResultPromise, Ft as EventDefinition, G as PushDeviceKind, Gt as impersonationFromToken, H as TimingPhase, Ht as SESSION_POLL_MS, I as StorageHealth, It as EventSystem, J as WebPushError, K as WebPushConfig, Kt as renewDelayMs, L as StorageHealthStatus, Lt as IMPERSONATION_ACCESS, M as ServerViewMeta, Mt as Logger$1, N as Sp00kyConfig, Nt as LocalStore, O as RecordVersionArray, Ot as AuthEventTypes, P as Sp00kyQueryResult, Pt as SealedQuery, Q as WebPushMessageInput, R as StoreType, Rt as ImpersonationInfo, S as QueryStatus, St as REFUSAL_RECHECK_MIN_MS, T as QueryTimings, Tt as SignOutHook, U as UpdateOptions, Ut as TokenClaims, V as SyncHealthStatus, Vt as ORIGIN_TOKEN_KEY, W as PUSH_ENDPOINT_KEY, Wt as decodeTokenClaims, X as WebPushInfo, Y as WebPushErrorCode, Z as WebPushMessage, _ as QueryAuthorityCallback, _t as WebPushClientMessage, a as InlineRow, at as WebPushUnsupportedReason, b as QueryHash, bt as AuthService, c as MembershipOutcome, ct as BridgeSignOutMessage, d as MutationEventType, dt as PushNotification, et as WebPushSubscribeOptions, f as PersistenceClient, ft as PushNotificationAction, g as PreloadRefresh, gt as PushUrgency, h as PreloadOptions, ht as PushPayloadKind, i as EventSubscriptionOptions, it as WebPushUnsubscribeOptions, j as RunOptions, jt as LogTap, k as RecordVersionDiff, kt as createAuthEventSystem, l as MutationCallback, lt as BridgeTokenMessage, m as PinoTransmit, mt as PushPayload, n as DebounceOptions, nt as WebPushSyncOptions, o as Level, ot as WebPushUpdateOptions, p as PhaseStat, pt as PushOp, q as WebPushDevice, qt as sameImpersonation, r as EdgeDelta, rt as WebPushSyncStatus, s as MATERIALIZATION_SAMPLE_WINDOW, st as detectWebPushSupport, t as ConnectionState, tt as WebPushSupport, u as MutationEvent, ut as PUSH_PAYLOAD_VERSION, v as QueryConfig, vt as isPushPayload, w as QueryTimeToLive, wt as SignOutContext, x as QueryState, xt as ImpersonationCandidate, y as QueryConfigRecord, yt as ActiveImpersonation, z as SyncHealth, zt as ImpersonationTimerHost } from "./types.js";
|
|
2
2
|
import { BackendNames, BackendRoutes, BucketNames, FinalQuery, GetTable, QueryBuilder, QueryOptions, QueryPlan, RecordId, RoutePayload, SchemaStructure, TableModel, TableNames } from "@spooky-sync/query-builder";
|
|
3
3
|
import * as surrealdb0 from "surrealdb";
|
|
4
4
|
import { Duration, RecordId as RecordId$1, Surreal } from "surrealdb";
|
|
@@ -1055,6 +1055,17 @@ type OutEvent = {
|
|
|
1055
1055
|
* behind `status: 'fetching'`, `notified` says whether subscribers received
|
|
1056
1056
|
* at least one materialization this registration.
|
|
1057
1057
|
*
|
|
1058
|
+
* `answered` says subscribers have been handed the rows of a known membership
|
|
1059
|
+
* (the server's, or the durable one of an earlier session) at least once:
|
|
1060
|
+
* a materialization ran past `cold`, with no body of a server set still to
|
|
1061
|
+
* download, and the membership it drew was still the one in state when it
|
|
1062
|
+
* committed. Only a re-seed (a new entry, a bucket switch) clears it, so a
|
|
1063
|
+
* reconnect or a lost view does not send a painted query back to loading.
|
|
1064
|
+
* It is what the bindings' `isAuthoritative()` mirrors (see `isAnswered`):
|
|
1065
|
+
* a `cached` seed or a freshly applied server set alone is not enough,
|
|
1066
|
+
* because `records` is still empty (or stale) until `materialize` paints, and
|
|
1067
|
+
* "known and empty" read in that beat made apps create rows that existed.
|
|
1068
|
+
*
|
|
1058
1069
|
* `backgroundDepth` is the part of `fetchDepth` that re-verifies rows the
|
|
1059
1070
|
* query already shows: a registration of a query that is not `cold`. It still
|
|
1060
1071
|
* holds `status: 'fetching'` (results are not yet confirmed), but it is not
|
|
@@ -1068,6 +1079,7 @@ interface QueryLifecycle {
|
|
|
1068
1079
|
readonly fetchDepth: number;
|
|
1069
1080
|
readonly backgroundDepth: number;
|
|
1070
1081
|
readonly notified: boolean;
|
|
1082
|
+
readonly answered: boolean;
|
|
1071
1083
|
}
|
|
1072
1084
|
//#endregion
|
|
1073
1085
|
//#region src/kernel/effects.d.ts
|
|
@@ -2212,4 +2224,4 @@ declare class LocalOpTimeoutError extends Error {
|
|
|
2212
2224
|
* select on a throttled tab is seconds, not tens of seconds. */
|
|
2213
2225
|
declare const DEFAULT_LOCAL_OP_TIMEOUT_MS = 30000;
|
|
2214
2226
|
//#endregion
|
|
2215
|
-
export { ActiveImpersonation, AppReleaseHandle, AppReleaseModule, type AppReleaseOptions, type AppReleaseSnapshot, AuthEventSystem, AuthEventTypeMap, AuthEventTypes, AuthService, BLURHASH_IMAGE_EXTENSIONS, type BlobCacheStats, type BlobEntry, type BlobKey, type BlobReadOptions, type BlobUrlLease, type BlurhashEncodeOptions, type BlurhashSetting, type BridgeSignOutMessage, type BridgeTokenMessage, BucketHandle, BucketPutOptions, BucketPutResult, CURSOR_COLORS, ConnectionState, CrdtField, CrdtManager, DEFAULT_LOCAL_OP_TIMEOUT_MS, DebounceOptions, EdgeDelta, EventSubscriptionOptions, type FailedMutationRow as FailedMutation, FeatureFlagHandle, FeatureFlagModule, type FeatureFlagOptions, type FeatureFlagOverride, type FeatureFlagSnapshot, IMPERSONATION_ACCESS, ImpersonationCandidate, ImpersonationInfo, ImpersonationTimerHost, ImpersonationTimers, InlineRow, Level, LocalOpTimeoutError, MATERIALIZATION_SAMPLE_WINDOW, MembershipOutcome, MutationCallback, MutationEvent, MutationEventType, ORIGIN_TOKEN_KEY, PUSH_ENDPOINT_KEY, PUSH_PAYLOAD_VERSION, PersistenceClient, PhaseStat, PinoTransmit, PreloadOptions, PreloadRefresh, type PushDeviceKind, type PushNotification, type PushNotificationAction, type PushOp, type PushPayload, type PushPayloadKind, type PushUrgency, QueryAuthorityCallback, QueryConfig, QueryConfigRecord, QueryHash, QueryState, QueryStatus, QueryStatusCallback, QueryTimeToLive, QueryTimings, QueryUpdateCallback, ReconnectConfig, RecordVersionArray, RecordVersionDiff, RegistrationTimings, RunOptions, SESSION_POLL_MS, SIGN_OUT_HOOK_TIMEOUT_MS, ServerViewMeta, SignOutContext, SignOutHook, Sp00kyClient, Sp00kyClientDeps, Sp00kyConfig, Sp00kyQueryResult, Sp00kyQueryResultPromise, StorageHealth, StorageHealthStatus, StoreType, SyncHealth, SyncHealthConfig, SyncHealthStatus, TimingPhase, TokenClaims, UpdateOptions, type WebPushClientMessage, type WebPushConfig, type WebPushDevice, WebPushError, type WebPushErrorCode, type WebPushInfo, type WebPushMessage, type WebPushMessageInput, WebPushModule, type WebPushSubscribeOptions, type WebPushSupport, type WebPushSyncOptions, type WebPushSyncStatus, type WebPushUnsubscribeOptions, type WebPushUnsupportedReason, type WebPushUpdateOptions, blurhashSidecarPath, bucketContentToBlob, createAuthEventSystem, cursorColorFromName, decode as decodeBlurhash, decodeTokenClaims, detectWebPushSupport, encode as encodeBlurhash, encodeImageToBlurhash, fileToUint8Array, impersonationFromToken, isBlurhashValid, isImagePath, isPushPayload, renewDelayMs, sameImpersonation, semverGt, textToHtml };
|
|
2227
|
+
export { ActiveImpersonation, AppReleaseHandle, AppReleaseModule, type AppReleaseOptions, type AppReleaseSnapshot, AuthEventSystem, AuthEventTypeMap, AuthEventTypes, AuthService, BLURHASH_IMAGE_EXTENSIONS, type BlobCacheStats, type BlobEntry, type BlobKey, type BlobReadOptions, type BlobUrlLease, type BlurhashEncodeOptions, type BlurhashSetting, type BridgeSignOutMessage, type BridgeTokenMessage, BucketHandle, BucketPutOptions, BucketPutResult, CURSOR_COLORS, ConnectionState, CrdtField, CrdtManager, DEFAULT_LOCAL_OP_TIMEOUT_MS, DebounceOptions, EdgeDelta, EventSubscriptionOptions, type FailedMutationRow as FailedMutation, FeatureFlagHandle, FeatureFlagModule, type FeatureFlagOptions, type FeatureFlagOverride, type FeatureFlagSnapshot, IMPERSONATION_ACCESS, ImpersonationCandidate, ImpersonationInfo, ImpersonationTimerHost, ImpersonationTimers, InlineRow, Level, LocalOpTimeoutError, MATERIALIZATION_SAMPLE_WINDOW, MembershipOutcome, MutationCallback, MutationEvent, MutationEventType, ORIGIN_TOKEN_KEY, PUSH_ENDPOINT_KEY, PUSH_PAYLOAD_VERSION, PersistenceClient, PhaseStat, PinoTransmit, PreloadOptions, PreloadRefresh, type PushDeviceKind, type PushNotification, type PushNotificationAction, type PushOp, type PushPayload, type PushPayloadKind, type PushUrgency, QueryAuthorityCallback, QueryConfig, QueryConfigRecord, QueryHash, QueryState, QueryStatus, QueryStatusCallback, QueryTimeToLive, QueryTimings, QueryUpdateCallback, REFUSAL_RECHECK_MIN_MS, ReconnectConfig, RecordVersionArray, RecordVersionDiff, RegistrationTimings, RunOptions, SESSION_POLL_MS, SIGN_OUT_HOOK_TIMEOUT_MS, ServerViewMeta, SignOutContext, SignOutHook, Sp00kyClient, Sp00kyClientDeps, Sp00kyConfig, Sp00kyQueryResult, Sp00kyQueryResultPromise, StorageHealth, StorageHealthStatus, StoreType, SyncHealth, SyncHealthConfig, SyncHealthStatus, TimingPhase, TokenClaims, UpdateOptions, type WebPushClientMessage, type WebPushConfig, type WebPushDevice, WebPushError, type WebPushErrorCode, type WebPushInfo, type WebPushMessage, type WebPushMessageInput, WebPushModule, type WebPushSubscribeOptions, type WebPushSupport, type WebPushSyncOptions, type WebPushSyncStatus, type WebPushUnsubscribeOptions, type WebPushUnsupportedReason, type WebPushUpdateOptions, blurhashSidecarPath, bucketContentToBlob, createAuthEventSystem, cursorColorFromName, decode as decodeBlurhash, decodeTokenClaims, detectWebPushSupport, encode as encodeBlurhash, encodeImageToBlurhash, fileToUint8Array, impersonationFromToken, isBlurhashValid, isImagePath, isPushPayload, renewDelayMs, sameImpersonation, semverGt, textToHtml };
|
package/dist/index.js
CHANGED
|
@@ -551,6 +551,16 @@ function classifySyncError(error) {
|
|
|
551
551
|
for (const pattern of NETWORK_ERROR_PATTERNS) if (message.includes(pattern)) return "network";
|
|
552
552
|
return "application";
|
|
553
553
|
}
|
|
554
|
+
/**
|
|
555
|
+
* Whether the server refused the SESSION rather than the statement: its own
|
|
556
|
+
* wording for a token it will not accept ("There was a problem with
|
|
557
|
+
* authentication"), which is also what an app's AUTHENTICATE clause and
|
|
558
|
+
* sp00ky's session guard throw, and a session past its `DURATION FOR SESSION`.
|
|
559
|
+
*/
|
|
560
|
+
function isRefusedSessionError(error) {
|
|
561
|
+
const message = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
|
|
562
|
+
return message.includes("problem with authentication") || message.includes("session has expired");
|
|
563
|
+
}
|
|
554
564
|
|
|
555
565
|
//#endregion
|
|
556
566
|
//#region src/utils/index.ts
|
|
@@ -2188,8 +2198,8 @@ function trimHistory(history, cap) {
|
|
|
2188
2198
|
|
|
2189
2199
|
//#endregion
|
|
2190
2200
|
//#region src/modules/devtools/index.ts
|
|
2191
|
-
const CORE_VERSION = "0.0.1-canary.
|
|
2192
|
-
const WASM_VERSION = "0.0.1-canary.
|
|
2201
|
+
const CORE_VERSION = "0.0.1-canary.334";
|
|
2202
|
+
const WASM_VERSION = "0.0.1-canary.334";
|
|
2193
2203
|
const SURREAL_VERSION = "3.1.0";
|
|
2194
2204
|
var DevToolsService = class DevToolsService {
|
|
2195
2205
|
version = CORE_VERSION;
|
|
@@ -2328,7 +2338,7 @@ var DevToolsService = class DevToolsService {
|
|
|
2328
2338
|
remoteIds: remoteArray.slice(0, DevToolsService.STATE_IDS_CAP).map(([id]) => id),
|
|
2329
2339
|
idsTruncated: localArray.length > DevToolsService.STATE_IDS_CAP || remoteArray.length > DevToolsService.STATE_IDS_CAP,
|
|
2330
2340
|
membershipKnown: q.config.membershipKnown === true,
|
|
2331
|
-
authoritative: q.
|
|
2341
|
+
authoritative: q.answered === true,
|
|
2332
2342
|
viewLost: q.viewLost === true,
|
|
2333
2343
|
serverState: q.serverState ?? null,
|
|
2334
2344
|
timings: this.dataManager.phaseTimings(q)
|
|
@@ -2921,7 +2931,8 @@ function seedLifecycle(resolvedBefore) {
|
|
|
2921
2931
|
remote: "unregistered",
|
|
2922
2932
|
fetchDepth: 0,
|
|
2923
2933
|
backgroundDepth: 0,
|
|
2924
|
-
notified: false
|
|
2934
|
+
notified: false,
|
|
2935
|
+
answered: false
|
|
2925
2936
|
};
|
|
2926
2937
|
}
|
|
2927
2938
|
/** Total on every (lifecycle, event) pair the sagas can produce; throws on the rest. */
|
|
@@ -2970,17 +2981,27 @@ function transition(l, ev) {
|
|
|
2970
2981
|
backgroundDepth
|
|
2971
2982
|
};
|
|
2972
2983
|
}
|
|
2973
|
-
case "notified":
|
|
2974
|
-
|
|
2975
|
-
notified:
|
|
2976
|
-
|
|
2984
|
+
case "notified": {
|
|
2985
|
+
const answered = l.answered || ev.answered === true && l.phase !== "cold";
|
|
2986
|
+
return l.notified && answered === l.answered ? l : {
|
|
2987
|
+
...l,
|
|
2988
|
+
notified: true,
|
|
2989
|
+
answered
|
|
2990
|
+
};
|
|
2991
|
+
}
|
|
2977
2992
|
case "bucket-switch": return {
|
|
2978
2993
|
...seedLifecycle(ev.resolvedBefore),
|
|
2979
2994
|
fetchDepth: 0
|
|
2980
2995
|
};
|
|
2981
2996
|
}
|
|
2982
2997
|
}
|
|
2998
|
+
/** Membership is known (server set or durable seed); its rows may not be painted yet. */
|
|
2983
2999
|
const isAuthoritative = (l) => l.phase !== "cold";
|
|
3000
|
+
/**
|
|
3001
|
+
* Subscribers hold the rows of a known membership: what the authority channel
|
|
3002
|
+
* (`subscribeQueryAuthority`, the bindings' `isAuthoritative()`) reports.
|
|
3003
|
+
*/
|
|
3004
|
+
const isAnswered = (l) => l.answered;
|
|
2984
3005
|
const hasServerMembership = (l) => l.phase === "live" || l.phase === "view-lost";
|
|
2985
3006
|
const deriveStatus = (l) => l.fetchDepth > 0 ? "fetching" : "idle";
|
|
2986
3007
|
/** A fetch cycle the user is waiting on: rows to download, or a first registration. */
|
|
@@ -3198,7 +3219,12 @@ const setSubqueryRemoteArray = (hash, subqueryRemoteArray) => (s) => {
|
|
|
3198
3219
|
if (!entry || entry.lifecycle.phase === "cold") return next;
|
|
3199
3220
|
return withView(next, entry.def.viewKey, vouchedIds(entry.remoteArray, subqueryRemoteArray));
|
|
3200
3221
|
};
|
|
3201
|
-
|
|
3222
|
+
/**
|
|
3223
|
+
* Commit a materialization. `answered`: it drew a known membership with no
|
|
3224
|
+
* body still to download, and that membership is still current (see
|
|
3225
|
+
* `QueryLifecycle`).
|
|
3226
|
+
*/
|
|
3227
|
+
const setRecords = (hash, records, changed, materializeMs, answered = false) => (s) => {
|
|
3202
3228
|
const next = withEntry(s, hash, (e) => {
|
|
3203
3229
|
const prev = e.telemetry.phaseSamples.localFetch ?? [];
|
|
3204
3230
|
const telemetry = {
|
|
@@ -3217,7 +3243,10 @@ const setRecords = (hash, records, changed, materializeMs) => (s) => {
|
|
|
3217
3243
|
...e,
|
|
3218
3244
|
records: changed ? records : e.records,
|
|
3219
3245
|
telemetry,
|
|
3220
|
-
lifecycle: transition(e.lifecycle, {
|
|
3246
|
+
lifecycle: transition(e.lifecycle, {
|
|
3247
|
+
type: "notified",
|
|
3248
|
+
answered
|
|
3249
|
+
})
|
|
3221
3250
|
};
|
|
3222
3251
|
});
|
|
3223
3252
|
return next === s ? s : {
|
|
@@ -3474,7 +3503,13 @@ const setIdentity = (patch) => (s) => ({
|
|
|
3474
3503
|
...s,
|
|
3475
3504
|
...patch
|
|
3476
3505
|
});
|
|
3477
|
-
/**
|
|
3506
|
+
/**
|
|
3507
|
+
* Re-home a query after a bucket switch: new store, same hash, fresh sync state.
|
|
3508
|
+
* `records` is kept: it is what subscribers were last handed, and the next
|
|
3509
|
+
* render diffs against it. Blanking it here (without telling anyone) let a
|
|
3510
|
+
* new store that renders `[]` read as unchanged, so the previous principal's
|
|
3511
|
+
* rows stayed on screen under an authority flip that vouched for them.
|
|
3512
|
+
*/
|
|
3478
3513
|
const rebindQuery = (hash, next) => (s) => {
|
|
3479
3514
|
const rebound = withEntry(s, hash, (e) => ({
|
|
3480
3515
|
...e,
|
|
@@ -3486,7 +3521,6 @@ const rebindQuery = (hash, next) => (s) => {
|
|
|
3486
3521
|
remoteArray: next.remoteArray,
|
|
3487
3522
|
localArray: next.localArray,
|
|
3488
3523
|
subqueryRemoteArray: [],
|
|
3489
|
-
records: [],
|
|
3490
3524
|
serverState: null,
|
|
3491
3525
|
lastHeartbeatAt: null,
|
|
3492
3526
|
lastPolledAt: null,
|
|
@@ -3719,6 +3753,7 @@ function toQueryState(e) {
|
|
|
3719
3753
|
viewLost: l.phase === "view-lost",
|
|
3720
3754
|
serverState: e.serverState,
|
|
3721
3755
|
syncNotified: l.notified,
|
|
3756
|
+
answered: l.answered,
|
|
3722
3757
|
ttlTimer: null,
|
|
3723
3758
|
ttlDurationMs: e.def.ttlMs,
|
|
3724
3759
|
lastHeartbeatAt: e.lastHeartbeatAt ?? void 0,
|
|
@@ -4810,14 +4845,8 @@ function* applyMembership(hash, remoteArray, meta, verifiedRemoval) {
|
|
|
4810
4845
|
});
|
|
4811
4846
|
return outcome;
|
|
4812
4847
|
}
|
|
4813
|
-
const wasAuthoritative = isAuthoritative(entry.lifecycle);
|
|
4814
4848
|
yield fx.state.update(commitMembership(hash, remoteArray, meta?.present ?? true));
|
|
4815
4849
|
yield* forgetDeparted(hash, departedIds(entry.remoteArray, remoteArray));
|
|
4816
|
-
if (!wasAuthoritative) yield fx.emit({
|
|
4817
|
-
type: "query:authority",
|
|
4818
|
-
hash,
|
|
4819
|
-
known: true
|
|
4820
|
-
});
|
|
4821
4850
|
const now = yield fx.now();
|
|
4822
4851
|
try {
|
|
4823
4852
|
yield fx.local.upsert(VIEW_TABLE, viewRecordId(entry.def.viewKey), viewRow(remoteArray, entry.subqueryRemoteArray, true, now), "replace");
|
|
@@ -6661,14 +6690,18 @@ const NONE = /* @__PURE__ */ new Set();
|
|
|
6661
6690
|
* only writer of `records`. Runs on the `mat:<hash>` dedupe lane whenever the
|
|
6662
6691
|
* query is dirty. Before the server has answered (`cold`) only bodies some
|
|
6663
6692
|
* durable view vouches for paint (see `paintsCold`).
|
|
6693
|
+
*
|
|
6694
|
+
* Also the only place a query becomes answered (see `QueryLifecycle`): the
|
|
6695
|
+
* authority flip is emitted here, after the rows it vouches for.
|
|
6664
6696
|
*/
|
|
6665
6697
|
function* materialize(hash) {
|
|
6666
|
-
const [entry, ov, known] = yield fx.state.read((s) => {
|
|
6698
|
+
const [entry, ov, known, complete] = yield fx.state.read((s) => {
|
|
6667
6699
|
const e = s.queries.get(hash);
|
|
6668
6700
|
return [
|
|
6669
6701
|
e,
|
|
6670
6702
|
overlay(s),
|
|
6671
|
-
e?.lifecycle.phase === "cold" ? vouched(s) : NONE
|
|
6703
|
+
e?.lifecycle.phase === "cold" ? vouched(s) : NONE,
|
|
6704
|
+
needed(s, hash).length === 0
|
|
6672
6705
|
];
|
|
6673
6706
|
});
|
|
6674
6707
|
if (!entry) return;
|
|
@@ -6708,12 +6741,18 @@ function* materialize(hash) {
|
|
|
6708
6741
|
if (!current) return;
|
|
6709
6742
|
const t1 = yield fx.now();
|
|
6710
6743
|
const changed = !rowsEqual(rows, current.records);
|
|
6711
|
-
|
|
6744
|
+
const answered = complete && entry.lifecycle.phase !== "cold" && current.lifecycle.phase !== "cold" && current.remoteArray === entry.remoteArray;
|
|
6745
|
+
yield fx.state.update(compose(setRecords(hash, rows, changed, t1 - t0, answered), changed ? stampUpdated(hash, t1) : (s) => s));
|
|
6712
6746
|
if (changed) yield fx.emit({
|
|
6713
6747
|
type: "query:records",
|
|
6714
6748
|
hash,
|
|
6715
6749
|
records: rows
|
|
6716
6750
|
});
|
|
6751
|
+
if (answered && !isAnswered(current.lifecycle)) yield fx.emit({
|
|
6752
|
+
type: "query:authority",
|
|
6753
|
+
hash,
|
|
6754
|
+
known: true
|
|
6755
|
+
});
|
|
6717
6756
|
}
|
|
6718
6757
|
/**
|
|
6719
6758
|
* A view update from the in-browser SSP: the query's local id-set moved.
|
|
@@ -7196,10 +7235,10 @@ function* rebindQueries() {
|
|
|
7196
7235
|
remoteArray: view?.ids ?? [],
|
|
7197
7236
|
localArray
|
|
7198
7237
|
}));
|
|
7199
|
-
if (
|
|
7238
|
+
if (isAnswered(entry.lifecycle)) yield fx.emit({
|
|
7200
7239
|
type: "query:authority",
|
|
7201
7240
|
hash: entry.def.hash,
|
|
7202
|
-
known:
|
|
7241
|
+
known: false
|
|
7203
7242
|
});
|
|
7204
7243
|
}
|
|
7205
7244
|
}
|
|
@@ -7678,7 +7717,7 @@ var Runtime = class {
|
|
|
7678
7717
|
this.authoritySubs.set(hash, set);
|
|
7679
7718
|
if (options.immediate) {
|
|
7680
7719
|
const entry = this.stateValue.queries.get(hash);
|
|
7681
|
-
if (entry) this.safely(() => cb(
|
|
7720
|
+
if (entry) this.safely(() => cb(isAnswered(entry.lifecycle)));
|
|
7682
7721
|
}
|
|
7683
7722
|
return () => {
|
|
7684
7723
|
set.delete(cb);
|
|
@@ -11901,6 +11940,12 @@ var ImpersonationTimers = class {
|
|
|
11901
11940
|
//#region src/modules/auth/index.ts
|
|
11902
11941
|
/** Upper bound on the before-sign-out hooks, so a dead socket never holds a sign-out. */
|
|
11903
11942
|
const SIGN_OUT_HOOK_TIMEOUT_MS = 2e3;
|
|
11943
|
+
/**
|
|
11944
|
+
* Floor between two session re-checks started by refused statements. A
|
|
11945
|
+
* refused session fails every statement at once, and a check that keeps the
|
|
11946
|
+
* session (the server was unreachable) must not be re-run by each of them.
|
|
11947
|
+
*/
|
|
11948
|
+
const REFUSAL_RECHECK_MIN_MS = 3e4;
|
|
11904
11949
|
function decodeAccessFromToken(token) {
|
|
11905
11950
|
return decodeTokenClaims(token).access;
|
|
11906
11951
|
}
|
|
@@ -11930,6 +11975,11 @@ var AuthService = class {
|
|
|
11930
11975
|
stopping = null;
|
|
11931
11976
|
/** A token renewal in flight, which a stop waits out (see `renewImpersonation`). */
|
|
11932
11977
|
renewing = null;
|
|
11978
|
+
/** The session re-check in flight (see `watchSession`), shared by every trigger. */
|
|
11979
|
+
reverifying = null;
|
|
11980
|
+
/** When a refused statement last started a re-check. */
|
|
11981
|
+
lastRefusalCheckAt = Number.NEGATIVE_INFINITY;
|
|
11982
|
+
watchingSession = false;
|
|
11933
11983
|
impersonationTimers = new ImpersonationTimers({
|
|
11934
11984
|
current: () => this.impersonation,
|
|
11935
11985
|
renew: () => this.renewImpersonation(),
|
|
@@ -11957,6 +12007,54 @@ var AuthService = class {
|
|
|
11957
12007
|
}
|
|
11958
12008
|
async init() {
|
|
11959
12009
|
await this.check();
|
|
12010
|
+
this.watchSession();
|
|
12011
|
+
}
|
|
12012
|
+
/**
|
|
12013
|
+
* Check the session again whenever the socket comes back, and whenever the
|
|
12014
|
+
* server refuses a statement because of the session.
|
|
12015
|
+
*
|
|
12016
|
+
* The server judges a token only when a socket authenticates: the SDK
|
|
12017
|
+
* replays `authenticate` on its own reconnects, `RemoteDatabaseService` on a
|
|
12018
|
+
* socket it rebuilds. When the replay is refused (the app ended the session,
|
|
12019
|
+
* say with a password reset) the SDK drops to an anonymous session, and the
|
|
12020
|
+
* supervisor swallows the rebuild's error, so the socket reports `connected`
|
|
12021
|
+
* while this service still announces the user. Every view registered from
|
|
12022
|
+
* then on carried no identity: the page looked signed in and showed nothing
|
|
12023
|
+
* of the user's. `check()` applies the token again and signs out when the
|
|
12024
|
+
* server refuses it.
|
|
12025
|
+
*
|
|
12026
|
+
* A socket that never reconnects is ended by the app's session guard
|
|
12027
|
+
* (`fn::sp00ky::session_live`) instead: its heartbeats stop finding rows,
|
|
12028
|
+
* and the re-registration that follows is refused with "There was a problem
|
|
12029
|
+
* with authentication", which starts a check here too.
|
|
12030
|
+
*/
|
|
12031
|
+
watchSession() {
|
|
12032
|
+
if (this.watchingSession) return;
|
|
12033
|
+
this.watchingSession = true;
|
|
12034
|
+
this.remote.subscribeConnection("connected", () => this.reverify());
|
|
12035
|
+
this.remote.getEvents().subscribe(DatabaseEventTypes.RemoteQuery, (event) => {
|
|
12036
|
+
const { success, error } = event.payload;
|
|
12037
|
+
if (success || !isRefusedSessionError(error ?? "")) return;
|
|
12038
|
+
const now = Date.now();
|
|
12039
|
+
if (now - this.lastRefusalCheckAt < REFUSAL_RECHECK_MIN_MS) return;
|
|
12040
|
+
this.lastRefusalCheckAt = now;
|
|
12041
|
+
this.reverify();
|
|
12042
|
+
});
|
|
12043
|
+
}
|
|
12044
|
+
/** One `check()` for every trigger that lands while it runs. */
|
|
12045
|
+
reverify() {
|
|
12046
|
+
if (!this.isAuthenticated || this.reverifying) return;
|
|
12047
|
+
this.reverifying = (async () => {
|
|
12048
|
+
await this.remote.whenConnected();
|
|
12049
|
+
if (this.isAuthenticated) await this.check();
|
|
12050
|
+
})().catch((error) => {
|
|
12051
|
+
this.logger.warn({
|
|
12052
|
+
error,
|
|
12053
|
+
Category: "sp00ky-client::AuthService::reverify"
|
|
12054
|
+
}, "Session re-check failed");
|
|
12055
|
+
}).finally(() => {
|
|
12056
|
+
this.reverifying = null;
|
|
12057
|
+
});
|
|
11960
12058
|
}
|
|
11961
12059
|
getAccessDefinition(name) {
|
|
11962
12060
|
return this.schema.access?.[name];
|
|
@@ -15676,7 +15774,7 @@ function createTabsCoordinator(config, s, host) {
|
|
|
15676
15774
|
return new TabsCoordinator({
|
|
15677
15775
|
tabId: s.tabId,
|
|
15678
15776
|
fingerprint: computeTabsFingerprint({
|
|
15679
|
-
coreVersion: "0.0.1-canary.
|
|
15777
|
+
coreVersion: "0.0.1-canary.334",
|
|
15680
15778
|
schemaHash: hash53(config.schemaSurql),
|
|
15681
15779
|
endpoint: config.database.endpoint ?? "",
|
|
15682
15780
|
namespace: config.database.namespace,
|
|
@@ -16796,4 +16894,4 @@ var Sp00kyClient = class {
|
|
|
16796
16894
|
};
|
|
16797
16895
|
|
|
16798
16896
|
//#endregion
|
|
16799
|
-
export { AppReleaseHandle, AppReleaseModule, AuthEventTypes, AuthService, BLURHASH_IMAGE_EXTENSIONS, BucketHandle, CURSOR_COLORS, CrdtField, CrdtManager, DEFAULT_LOCAL_OP_TIMEOUT_MS, FeatureFlagHandle, FeatureFlagModule, IMPERSONATION_ACCESS, ImpersonationTimers, LocalOpTimeoutError, MATERIALIZATION_SAMPLE_WINDOW, ORIGIN_TOKEN_KEY, PUSH_ENDPOINT_KEY, PUSH_PAYLOAD_VERSION, SESSION_POLL_MS, SIGN_OUT_HOOK_TIMEOUT_MS, Sp00kyClient, WebPushError, WebPushModule, blurhashSidecarPath, bucketContentToBlob, createAuthEventSystem, cursorColorFromName, decode as decodeBlurhash, decodeTokenClaims, detectWebPushSupport, encode as encodeBlurhash, encodeImageToBlurhash, fileToUint8Array, impersonationFromToken, isBlurhashValid, isImagePath, isPushPayload, renewDelayMs, sameImpersonation, semverGt, textToHtml };
|
|
16897
|
+
export { AppReleaseHandle, AppReleaseModule, AuthEventTypes, AuthService, BLURHASH_IMAGE_EXTENSIONS, BucketHandle, CURSOR_COLORS, CrdtField, CrdtManager, DEFAULT_LOCAL_OP_TIMEOUT_MS, FeatureFlagHandle, FeatureFlagModule, IMPERSONATION_ACCESS, ImpersonationTimers, LocalOpTimeoutError, MATERIALIZATION_SAMPLE_WINDOW, ORIGIN_TOKEN_KEY, PUSH_ENDPOINT_KEY, PUSH_PAYLOAD_VERSION, REFUSAL_RECHECK_MIN_MS, SESSION_POLL_MS, SIGN_OUT_HOOK_TIMEOUT_MS, Sp00kyClient, WebPushError, WebPushModule, blurhashSidecarPath, bucketContentToBlob, createAuthEventSystem, cursorColorFromName, decode as decodeBlurhash, decodeTokenClaims, detectWebPushSupport, encode as encodeBlurhash, encodeImageToBlurhash, fileToUint8Array, impersonationFromToken, isBlurhashValid, isImagePath, isPushPayload, renewDelayMs, sameImpersonation, semverGt, textToHtml };
|
package/dist/types.d.ts
CHANGED
|
@@ -822,6 +822,12 @@ interface SignOutContext {
|
|
|
822
822
|
type SignOutHook = (ctx: SignOutContext) => Promise<void> | void;
|
|
823
823
|
/** Upper bound on the before-sign-out hooks, so a dead socket never holds a sign-out. */
|
|
824
824
|
declare const SIGN_OUT_HOOK_TIMEOUT_MS = 2000;
|
|
825
|
+
/**
|
|
826
|
+
* Floor between two session re-checks started by refused statements. A
|
|
827
|
+
* refused session fails every statement at once, and a check that keeps the
|
|
828
|
+
* session (the server was unreachable) must not be re-run by each of them.
|
|
829
|
+
*/
|
|
830
|
+
declare const REFUSAL_RECHECK_MIN_MS = 30000;
|
|
825
831
|
type Prettify<T> = { [K in keyof T]: T[K] } & {};
|
|
826
832
|
type MapColumnType<T extends ColumnSchema> = T['optional'] extends true ? TypeNameToTypeMap[T['type']] | undefined : TypeNameToTypeMap[T['type']];
|
|
827
833
|
type ExtractAccessParams<S extends SchemaStructure, Name extends keyof S['access'], Method extends 'signIn' | 'signup'> = S['access'] extends undefined ? never : S['access'][Name] extends AccessDefinition ? Prettify<{ [K in keyof S['access'][Name][Method]['params']]: MapColumnType<S['access'][Name][Method]['params'][K]> }> : never;
|
|
@@ -855,10 +861,37 @@ declare class AuthService<S extends SchemaStructure> {
|
|
|
855
861
|
private stopping;
|
|
856
862
|
/** A token renewal in flight, which a stop waits out (see `renewImpersonation`). */
|
|
857
863
|
private renewing;
|
|
864
|
+
/** The session re-check in flight (see `watchSession`), shared by every trigger. */
|
|
865
|
+
private reverifying;
|
|
866
|
+
/** When a refused statement last started a re-check. */
|
|
867
|
+
private lastRefusalCheckAt;
|
|
868
|
+
private watchingSession;
|
|
858
869
|
private impersonationTimers;
|
|
859
870
|
get eventSystem(): AuthEventSystem;
|
|
860
871
|
constructor(schema: S, remote: RemoteDatabaseService, persistenceClient: PersistenceClient, logger: Logger$1);
|
|
861
872
|
init(): Promise<void>;
|
|
873
|
+
/**
|
|
874
|
+
* Check the session again whenever the socket comes back, and whenever the
|
|
875
|
+
* server refuses a statement because of the session.
|
|
876
|
+
*
|
|
877
|
+
* The server judges a token only when a socket authenticates: the SDK
|
|
878
|
+
* replays `authenticate` on its own reconnects, `RemoteDatabaseService` on a
|
|
879
|
+
* socket it rebuilds. When the replay is refused (the app ended the session,
|
|
880
|
+
* say with a password reset) the SDK drops to an anonymous session, and the
|
|
881
|
+
* supervisor swallows the rebuild's error, so the socket reports `connected`
|
|
882
|
+
* while this service still announces the user. Every view registered from
|
|
883
|
+
* then on carried no identity: the page looked signed in and showed nothing
|
|
884
|
+
* of the user's. `check()` applies the token again and signs out when the
|
|
885
|
+
* server refuses it.
|
|
886
|
+
*
|
|
887
|
+
* A socket that never reconnects is ended by the app's session guard
|
|
888
|
+
* (`fn::sp00ky::session_live`) instead: its heartbeats stop finding rows,
|
|
889
|
+
* and the re-registration that follows is refused with "There was a problem
|
|
890
|
+
* with authentication", which starts a check here too.
|
|
891
|
+
*/
|
|
892
|
+
private watchSession;
|
|
893
|
+
/** One `check()` for every trigger that lands while it runs. */
|
|
894
|
+
private reverify;
|
|
862
895
|
getAccessDefinition<Name extends keyof S['access']>(name: Name): AccessDefinition | undefined;
|
|
863
896
|
/**
|
|
864
897
|
* Subscribe to auth state changes.
|
|
@@ -2036,6 +2069,12 @@ interface QueryState {
|
|
|
2036
2069
|
* always emits at least once even when its records are unchanged — otherwise
|
|
2037
2070
|
* an empty re-registered window would never notify and stay "loading". */
|
|
2038
2071
|
syncNotified?: boolean;
|
|
2072
|
+
/**
|
|
2073
|
+
* Subscribers hold the rows of a known membership (see `QueryLifecycle`'s
|
|
2074
|
+
* `answered`): what the authority channel and the bindings'
|
|
2075
|
+
* `isAuthoritative()` report. Ephemeral.
|
|
2076
|
+
*/
|
|
2077
|
+
answered?: boolean;
|
|
2039
2078
|
/** Timer for TTL expiration. */
|
|
2040
2079
|
ttlTimer: NodeJS.Timeout | null;
|
|
2041
2080
|
/** TTL duration in milliseconds. */
|
|
@@ -2114,9 +2153,12 @@ interface QueryTimings {
|
|
|
2114
2153
|
type QueryUpdateCallback = (records: Record<string, any>[]) => void;
|
|
2115
2154
|
type QueryStatusCallback = (status: QueryStatus) => void;
|
|
2116
2155
|
/**
|
|
2117
|
-
* Observer for a query's authority flips: `true` once
|
|
2118
|
-
*
|
|
2119
|
-
*
|
|
2156
|
+
* Observer for a query's authority flips: `true` once subscribers hold the
|
|
2157
|
+
* rendered rows of a known membership (a server answer whose bodies are
|
|
2158
|
+
* local, or the durable membership of an earlier session), delivered after
|
|
2159
|
+
* the rows that vouch for it; `false` when it is reset (bucket switch). Knowing the set is
|
|
2160
|
+
* not enough: a `cached` re-seed or a just-applied server set has no rows
|
|
2161
|
+
* painted yet. What the bindings' `isAuthoritative()` mirrors.
|
|
2120
2162
|
*/
|
|
2121
2163
|
type QueryAuthorityCallback = (known: boolean) => void;
|
|
2122
2164
|
type MutationCallback = (mutations: MutationEvent[]) => void;
|
|
@@ -2193,4 +2235,4 @@ interface DebounceOptions {
|
|
|
2193
2235
|
flushOnHide?: boolean;
|
|
2194
2236
|
}
|
|
2195
2237
|
//#endregion
|
|
2196
|
-
export { WebPushModule as $, RegistrationTimings as A,
|
|
2238
|
+
export { WebPushModule as $, RegistrationTimings as A, RemoteDatabaseService as At, SyncHealthConfig as B, ImpersonationTimers as Bt, QueryStatusCallback as C, SIGN_OUT_HOOK_TIMEOUT_MS as Ct, ReconnectConfig as D, AuthEventTypeMap as Dt, QueryUpdateCallback as E, AuthEventSystem as Et, Sp00kyQueryResultPromise as F, EventDefinition as Ft, PushDeviceKind as G, impersonationFromToken as Gt, TimingPhase as H, SESSION_POLL_MS as Ht, StorageHealth as I, EventSystem as It, WebPushError as J, WebPushConfig as K, renewDelayMs as Kt, StorageHealthStatus as L, IMPERSONATION_ACCESS as Lt, ServerViewMeta as M, Logger$1 as Mt, Sp00kyConfig as N, LocalStore as Nt, RecordVersionArray as O, AuthEventTypes as Ot, Sp00kyQueryResult as P, SealedQuery as Pt, WebPushMessageInput as Q, StoreType as R, ImpersonationInfo as Rt, QueryStatus as S, REFUSAL_RECHECK_MIN_MS as St, QueryTimings as T, SignOutHook as Tt, UpdateOptions as U, TokenClaims as Ut, SyncHealthStatus as V, ORIGIN_TOKEN_KEY as Vt, PUSH_ENDPOINT_KEY as W, decodeTokenClaims as Wt, WebPushInfo as X, WebPushErrorCode as Y, WebPushMessage as Z, QueryAuthorityCallback as _, WebPushClientMessage as _t, InlineRow as a, WebPushUnsupportedReason as at, QueryHash as b, AuthService as bt, MembershipOutcome as c, BridgeSignOutMessage as ct, MutationEventType as d, PushNotification as dt, WebPushSubscribeOptions as et, PersistenceClient as f, PushNotificationAction as ft, PreloadRefresh as g, PushUrgency as gt, PreloadOptions as h, PushPayloadKind as ht, EventSubscriptionOptions as i, WebPushUnsubscribeOptions as it, RunOptions as j, LogTap as jt, RecordVersionDiff as k, createAuthEventSystem as kt, MutationCallback as l, BridgeTokenMessage as lt, PinoTransmit as m, PushPayload as mt, DebounceOptions as n, WebPushSyncOptions as nt, Level$1 as o, WebPushUpdateOptions as ot, PhaseStat as p, PushOp as pt, WebPushDevice as q, sameImpersonation as qt, EdgeDelta as r, WebPushSyncStatus as rt, MATERIALIZATION_SAMPLE_WINDOW as s, detectWebPushSupport as st, ConnectionState as t, WebPushSupport as tt, MutationEvent as u, PUSH_PAYLOAD_VERSION as ut, QueryConfig as v, isPushPayload as vt, QueryTimeToLive as w, SignOutContext as wt, QueryState as x, ImpersonationCandidate as xt, QueryConfigRecord as y, ActiveImpersonation as yt, SyncHealth as z, ImpersonationTimerHost as zt };
|
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.334",
|
|
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.334",
|
|
79
|
+
"@spooky-sync/ssp-wasm": "0.0.1-canary.334",
|
|
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",
|
|
@@ -158,9 +158,9 @@ describe('dropImpersonatedBucket', () => {
|
|
|
158
158
|
});
|
|
159
159
|
|
|
160
160
|
describe('rebindQueries', () => {
|
|
161
|
-
it('re-seeds from the new store, rebuilds the local view (tolerating failures),
|
|
161
|
+
it('re-seeds from the new store, rebuilds the local view (tolerating failures), withdraws authority it held', async () => {
|
|
162
162
|
const s = R.compose(
|
|
163
|
-
R.putQuery(buildEntry({ def: { hash: 'a' }, lifecycle: { phase: 'live' } })),
|
|
163
|
+
R.putQuery(buildEntry({ def: { hash: 'a' }, lifecycle: { phase: 'live', answered: true }, records: [{ id: 't:9' }] })),
|
|
164
164
|
R.putQuery(buildEntry({ def: { hash: 'b' }, lifecycle: { phase: 'cold' } }))
|
|
165
165
|
)({ ...buildState(), sessionId: 's' });
|
|
166
166
|
const out = await runPure(rebindQueries(), {
|
|
@@ -177,11 +177,13 @@ describe('rebindQueries', () => {
|
|
|
177
177
|
},
|
|
178
178
|
});
|
|
179
179
|
expect(out.state.queries.get('a')!.lifecycle.phase).toBe('cold');
|
|
180
|
-
expect(out.state.queries.get('b')!.lifecycle
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
]);
|
|
180
|
+
expect(out.state.queries.get('b')!.lifecycle).toMatchObject({ phase: 'cached', answered: false });
|
|
181
|
+
// `b` found a durable view, but its rows are not painted until the
|
|
182
|
+
// render: announcing "known" here is what let an app read the empty
|
|
183
|
+
// `records` of a re-seed as "the user has none" and create a duplicate.
|
|
184
|
+
expect(out.emitted.filter((e) => e.type === 'query:authority')).toEqual([{ type: 'query:authority', hash: 'a', known: false }]);
|
|
185
|
+
expect(out.state.queries.get('a')!.records).toEqual([{ id: 't:9' }]);
|
|
186
|
+
expect([...out.state.dirty].sort()).toEqual(['a', 'b']);
|
|
185
187
|
expect(out.emitted.some((e) => e.type === 'log')).toBe(true);
|
|
186
188
|
});
|
|
187
189
|
});
|
|
@@ -3,7 +3,7 @@ import type { Saga } from '../kernel/saga';
|
|
|
3
3
|
import type { RegisterResult } from '../kernel/effects';
|
|
4
4
|
import { fx } from '../kernel/effects';
|
|
5
5
|
import type { ClientState, QueryEntry } from '../state/client-state';
|
|
6
|
-
import { seedLifecycle,
|
|
6
|
+
import { seedLifecycle, isAnswered } from '../state/lifecycle';
|
|
7
7
|
import * as R from '../state/reducers';
|
|
8
8
|
import { ANON_USER_ID, bucketIdForUser } from '../modules/ref-tables';
|
|
9
9
|
import type { SagaEnv } from '../query/env';
|
|
@@ -126,8 +126,11 @@ export function* rebindQueries(): Saga<void> {
|
|
|
126
126
|
}
|
|
127
127
|
const lifecycle = seedLifecycle(isResolvedBefore(view));
|
|
128
128
|
yield fx.state.update(R.rebindQuery(entry.def.hash, { id: new RecordId('_00_query', hash), lifecycle, remoteArray: view?.ids ?? [], localArray }));
|
|
129
|
-
|
|
130
|
-
|
|
129
|
+
// A re-seed is never answered, not even from a durable view: subscribers
|
|
130
|
+
// still hold the previous store's rows until `materialize` paints the new
|
|
131
|
+
// store's, and only that render flips authority back on.
|
|
132
|
+
if (isAnswered(entry.lifecycle)) {
|
|
133
|
+
yield fx.emit({ type: 'query:authority', hash: entry.def.hash, known: false });
|
|
131
134
|
}
|
|
132
135
|
}
|
|
133
136
|
}
|
|
@@ -6,6 +6,7 @@ import { fx } from '../kernel/effects';
|
|
|
6
6
|
import type { Saga } from '../kernel/saga';
|
|
7
7
|
import * as R from '../state/reducers';
|
|
8
8
|
import { Runtime } from './runtime';
|
|
9
|
+
import { rebindQueries } from '../boot/bucket-switch.saga';
|
|
9
10
|
|
|
10
11
|
const env = defaultEnv({ tables: [] } as any, { materializeDebounceMs: 5 });
|
|
11
12
|
const noop = () => {};
|
|
@@ -88,6 +89,45 @@ describe('Runtime state hooks', () => {
|
|
|
88
89
|
expect(seen).toEqual([[{ id: 'thing:1' }]]);
|
|
89
90
|
expect(rt.state.dirty.has('q')).toBe(false);
|
|
90
91
|
});
|
|
92
|
+
it('the bucket-switch window: a re-seeded query never reads "known and idle" before its rows are painted', async () => {
|
|
93
|
+
// A sign-in: the query was registered in the anon bucket (cold, nothing
|
|
94
|
+
// painted), then the switch re-seeds it from the user's durable view.
|
|
95
|
+
// Before the fix the re-seed announced authority with `records` still
|
|
96
|
+
// empty, so a binding's `isSettled() && !row` held for a beat and the app
|
|
97
|
+
// created a row the user already had.
|
|
98
|
+
const { a, rt } = make(
|
|
99
|
+
{
|
|
100
|
+
local: {
|
|
101
|
+
getById: async () => ({ ids: [['thing:1', 1]], confirmed: true }),
|
|
102
|
+
select: async (plan: any) => (plan.ids ?? []).map((id: any) => ({ id: `${id.table}:${id.id}` })),
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
{ ...buildState([buildEntry({ def: { hash: 'q', plan: { table: 'thing' } as any } })]), sessionId: 's' }
|
|
106
|
+
);
|
|
107
|
+
// What a binding derives `isSettled()` and `data()` from.
|
|
108
|
+
let known = false;
|
|
109
|
+
let idle = false;
|
|
110
|
+
let rows: unknown[] = [];
|
|
111
|
+
const verdicts: string[] = [];
|
|
112
|
+
const look = () => {
|
|
113
|
+
if (known && idle) verdicts.push(rows.length > 0 ? 'settled with rows' : 'settled empty');
|
|
114
|
+
};
|
|
115
|
+
rt.subscribe('q', (r) => ((rows = r), look()), { immediate: true });
|
|
116
|
+
rt.subscribeAuthority('q', (k) => ((known = k), look()), { immediate: true });
|
|
117
|
+
rt.subscribeStatus('q', (st) => ((idle = st === 'idle'), look()), { immediate: true });
|
|
118
|
+
|
|
119
|
+
await rt.run(rebindQueries());
|
|
120
|
+
expect(rt.state.queries.get('q')!.lifecycle).toMatchObject({ phase: 'cached', fetchDepth: 0, answered: false });
|
|
121
|
+
expect(idle).toBe(true);
|
|
122
|
+
expect(known).toBe(false);
|
|
123
|
+
|
|
124
|
+
a.timers.fire('mat:q');
|
|
125
|
+
await tick();
|
|
126
|
+
await tick();
|
|
127
|
+
expect(rows).toEqual([{ id: 'thing:1' }]);
|
|
128
|
+
expect(known).toBe(true);
|
|
129
|
+
expect(verdicts).toEqual(['settled with rows']);
|
|
130
|
+
});
|
|
91
131
|
it('status and authority subscriptions fire on transitions, immediate delivers the current value', async () => {
|
|
92
132
|
const { rt } = make({}, buildState([buildEntry({ def: { hash: 'q' } })]));
|
|
93
133
|
const statuses: string[] = [];
|
package/src/client/runtime.ts
CHANGED
|
@@ -7,7 +7,7 @@ import type { Lane, Saga } from '../kernel/saga';
|
|
|
7
7
|
import { runSaga } from '../kernel/saga';
|
|
8
8
|
import type { ClientState } from '../state/client-state';
|
|
9
9
|
import { emptyState } from '../state/client-state';
|
|
10
|
-
import {
|
|
10
|
+
import { isAnswered } from '../state/lifecycle';
|
|
11
11
|
import * as R from '../state/reducers';
|
|
12
12
|
import { fetchingQueryCount, pendingMutationCount, queryStatus } from '../state/selectors';
|
|
13
13
|
import type { SagaEnv } from '../query/env';
|
|
@@ -274,7 +274,7 @@ export class Runtime {
|
|
|
274
274
|
this.authoritySubs.set(hash, set);
|
|
275
275
|
if (options.immediate) {
|
|
276
276
|
const entry = this.stateValue.queries.get(hash);
|
|
277
|
-
if (entry) this.safely(() => cb(
|
|
277
|
+
if (entry) this.safely(() => cb(isAnswered(entry.lifecycle)));
|
|
278
278
|
}
|
|
279
279
|
return () => {
|
|
280
280
|
set.delete(cb);
|