@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 CHANGED
@@ -1,4 +1,4 @@
1
- import { $ as WebPushModule, A as RegistrationTimings, At as LogTap, B as SyncHealthConfig, Bt as ORIGIN_TOKEN_KEY, C as QueryStatusCallback, Ct as SignOutContext, D as ReconnectConfig, Dt as AuthEventTypes, E as QueryUpdateCallback, Et as AuthEventTypeMap, F as Sp00kyQueryResultPromise, Ft as EventSystem, G as PushDeviceKind, Gt as renewDelayMs, H as TimingPhase, Ht as TokenClaims, I as StorageHealth, It as IMPERSONATION_ACCESS, J as WebPushError, K as WebPushConfig, Kt as sameImpersonation, L as StorageHealthStatus, Lt as ImpersonationInfo, M as ServerViewMeta, Mt as LocalStore, N as Sp00kyConfig, Nt as SealedQuery, O as RecordVersionArray, Ot as createAuthEventSystem, P as Sp00kyQueryResult, Pt as EventDefinition, Q as WebPushMessageInput, R as StoreType, Rt as ImpersonationTimerHost, S as QueryStatus, St as SIGN_OUT_HOOK_TIMEOUT_MS, T as QueryTimings, Tt as AuthEventSystem, U as UpdateOptions, Ut as decodeTokenClaims, V as SyncHealthStatus, Vt as SESSION_POLL_MS, W as PUSH_ENDPOINT_KEY, Wt as impersonationFromToken, 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 Logger$1, k as RecordVersionDiff, kt as RemoteDatabaseService, 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, 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 SignOutHook, x as QueryState, xt as ImpersonationCandidate, y as QueryConfigRecord, yt as ActiveImpersonation, z as SyncHealth, zt as ImpersonationTimers } from "./types.js";
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.332";
2192
- const WASM_VERSION = "0.0.1-canary.332";
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.config.membershipKnown === true,
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": return l.notified ? l : {
2974
- ...l,
2975
- notified: true
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
- const setRecords = (hash, records, changed, materializeMs) => (s) => {
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, { type: "notified" })
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
- /** Re-home a query after a bucket switch: new store, same hash, fresh sync state. */
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
- yield fx.state.update(compose(setRecords(hash, rows, changed, t1 - t0), changed ? stampUpdated(hash, t1) : (s) => s));
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 (isAuthoritative(entry.lifecycle) !== isAuthoritative(lifecycle)) yield fx.emit({
7238
+ if (isAnswered(entry.lifecycle)) yield fx.emit({
7200
7239
  type: "query:authority",
7201
7240
  hash: entry.def.hash,
7202
- known: isAuthoritative(lifecycle)
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(isAuthoritative(entry.lifecycle)));
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.332",
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 server membership is
2118
- * known for it (registration, poll, durable seed), `false` when it is reset
2119
- * (bucket switch). What the bindings' `isAuthoritative()` mirrors.
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, LogTap as At, SyncHealthConfig as B, ORIGIN_TOKEN_KEY as Bt, QueryStatusCallback as C, SignOutContext as Ct, ReconnectConfig as D, AuthEventTypes as Dt, QueryUpdateCallback as E, AuthEventTypeMap as Et, Sp00kyQueryResultPromise as F, EventSystem as Ft, PushDeviceKind as G, renewDelayMs as Gt, TimingPhase as H, TokenClaims as Ht, StorageHealth as I, IMPERSONATION_ACCESS as It, WebPushError as J, WebPushConfig as K, sameImpersonation as Kt, StorageHealthStatus as L, ImpersonationInfo as Lt, ServerViewMeta as M, LocalStore as Mt, Sp00kyConfig as N, SealedQuery as Nt, RecordVersionArray as O, createAuthEventSystem as Ot, Sp00kyQueryResult as P, EventDefinition as Pt, WebPushMessageInput as Q, StoreType as R, ImpersonationTimerHost as Rt, QueryStatus as S, SIGN_OUT_HOOK_TIMEOUT_MS as St, QueryTimings as T, AuthEventSystem as Tt, UpdateOptions as U, decodeTokenClaims as Ut, SyncHealthStatus as V, SESSION_POLL_MS as Vt, PUSH_ENDPOINT_KEY as W, impersonationFromToken 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, Logger$1 as jt, RecordVersionDiff as k, RemoteDatabaseService 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, 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, SignOutHook as wt, QueryState as x, ImpersonationCandidate as xt, QueryConfigRecord as y, ActiveImpersonation as yt, SyncHealth as z, ImpersonationTimers as zt };
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.332",
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.332",
79
- "@spooky-sync/ssp-wasm": "0.0.1-canary.332",
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), emits authority flips', async () => {
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.phase).toBe('cached');
181
- expect(out.emitted.filter((e) => e.type === 'query:authority')).toEqual([
182
- { type: 'query:authority', hash: 'a', known: false },
183
- { type: 'query:authority', hash: 'b', known: true },
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, isAuthoritative } from '../state/lifecycle';
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
- if (isAuthoritative(entry.lifecycle) !== isAuthoritative(lifecycle)) {
130
- yield fx.emit({ type: 'query:authority', hash: entry.def.hash, known: isAuthoritative(lifecycle) });
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[] = [];
@@ -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 { isAuthoritative } from '../state/lifecycle';
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(isAuthoritative(entry.lifecycle)));
277
+ if (entry) this.safely(() => cb(isAnswered(entry.lifecycle)));
278
278
  }
279
279
  return () => {
280
280
  set.delete(cb);