@spooky-sync/core 0.0.1-canary.237 → 0.0.1-canary.239

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/src/sp00ky.ts CHANGED
@@ -10,6 +10,7 @@ import type {
10
10
  RunOptions,
11
11
  SyncHealth,
12
12
  StorageHealth,
13
+ QueryAuthorityCallback,
13
14
  } from './types';
14
15
  import {
15
16
  ConnectionSupervisor,
@@ -400,6 +401,21 @@ export class Sp00kyClient<S extends SchemaStructure> {
400
401
  return this.sync.subscribeToPendingMutations(cb);
401
402
  }
402
403
 
404
+ /** Number of queries mid-fetch right now. See {@link subscribeToFetchActivity}. */
405
+ get fetchingQueryCount(): number {
406
+ return this.dataModule.fetchingQueryCount;
407
+ }
408
+
409
+ /**
410
+ * Observe how many queries are fetching from the server. Fires immediately
411
+ * with the current count and again on every change, so one subscription can
412
+ * drive a "downloading" indicator for the whole app. Pair with
413
+ * {@link subscribeToPendingMutations} for the "uploading" half.
414
+ */
415
+ subscribeToFetchActivity(cb: (fetching: number) => void): () => void {
416
+ return this.dataModule.subscribeActivity(cb, { immediate: true });
417
+ }
418
+
403
419
  /** Current sync-health snapshot. See {@link Sp00kyConfig.syncHealth}. */
404
420
  get syncHealth(): SyncHealth {
405
421
  return this.sync.syncHealth;
@@ -737,6 +753,20 @@ export class Sp00kyClient<S extends SchemaStructure> {
737
753
  });
738
754
  };
739
755
 
756
+ // The server no longer has the `_00_query` row of a query we hold
757
+ // membership for (TTL sweep, SSP reset, scheduler wipe). Rebuild the view
758
+ // through the ordinary register path; `register` events coalesce in the
759
+ // down queue, so a lost view costs one registration however many reads
760
+ // noticed it. Local state is untouched until the new set lands.
761
+ this.dataModule.onViewLost = (queryHash) => {
762
+ this.devTools.logEvent('QUERY_VIEW_LOST', { queryHash });
763
+ this.sync.enqueueDownEvent({ type: 'register', payload: { hash: queryHash } });
764
+ };
765
+
766
+ this.dataModule.onQueryAuthorityChange = (queryHash, known) => {
767
+ this.devTools.logEvent('QUERY_AUTHORITY_CHANGED', { queryHash, known });
768
+ };
769
+
740
770
  // Eager teardown of an opt-in deregistered query: enqueue a `cleanup`
741
771
  // down-event so it's serialized after any in-flight register/sync for the
742
772
  // same query (avoids out-of-order delete-before-create).
@@ -1000,7 +1030,25 @@ export class Sp00kyClient<S extends SchemaStructure> {
1000
1030
  this.sync.releaseViewsOnUnload();
1001
1031
  };
1002
1032
  window.addEventListener('pagehide', onPageHide);
1003
- this.detachUnloadRelease = () => window.removeEventListener('pagehide', onPageHide);
1033
+ // Re-assert liveness the moment a throttled tab is back in front, or
1034
+ // the network is back: the sweep may have taken views while the TTL
1035
+ // timers were frozen, and `heartbeatQuery` rebuilds a swept view. Only
1036
+ // watched queries beat, and never twice within 30s.
1037
+ const onWake = () => {
1038
+ if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return;
1039
+ this.dataModule.heartbeatOnWake();
1040
+ };
1041
+ if (typeof document !== 'undefined' && typeof document.addEventListener === 'function') {
1042
+ document.addEventListener('visibilitychange', onWake);
1043
+ }
1044
+ window.addEventListener('online', onWake);
1045
+ this.detachUnloadRelease = () => {
1046
+ window.removeEventListener('pagehide', onPageHide);
1047
+ window.removeEventListener('online', onWake);
1048
+ if (typeof document !== 'undefined' && typeof document.removeEventListener === 'function') {
1049
+ document.removeEventListener('visibilitychange', onWake);
1050
+ }
1051
+ };
1004
1052
  }
1005
1053
 
1006
1054
  this.featureFlags.init();
@@ -1648,6 +1696,21 @@ export class Sp00kyClient<S extends SchemaStructure> {
1648
1696
  return this.dataModule.subscribeStatus(queryHash, callback, options);
1649
1697
  }
1650
1698
 
1699
+ /**
1700
+ * Subscribe to a query's authority flips: `true` once server membership is
1701
+ * known for it (a registration or poll landed, or a durable `_00_window`
1702
+ * seed was read on boot), `false` when it is reset (bucket switch). With
1703
+ * `{ immediate: true }` the callback fires synchronously with the current
1704
+ * value. Powers the bindings' `isAuthoritative()` / cold-only `isLoading()`.
1705
+ */
1706
+ subscribeQueryAuthority(
1707
+ queryHash: string,
1708
+ callback: QueryAuthorityCallback,
1709
+ options?: { immediate?: boolean }
1710
+ ): () => void {
1711
+ return this.dataModule.subscribeAuthority(queryHash, callback, options);
1712
+ }
1713
+
1651
1714
  /**
1652
1715
  * Report the frontend processing time (ms) a client framework spent applying
1653
1716
  * an update for a query (e.g. `useQuery`'s `reconcile()`), so DevTools/MCP can
package/src/types.ts CHANGED
@@ -530,32 +530,6 @@ export interface QueryConfig {
530
530
  * by an old client that mirrored unflushed reads still self-heals.
531
531
  */
532
532
  membershipKnown?: boolean;
533
- /**
534
- * Whether a NON-EMPTY id-set has arrived from the server for this query in
535
- * this session. Gates whether an empty read may be believed.
536
- *
537
- * The server publishes `_00_list_ref` asynchronously — the SSP queues a
538
- * view's initial edges to a coalescing flusher and returns from
539
- * `fn::query::register` before they land — so an empty read right after
540
- * registration says nothing about the query being empty. Believing it (and
541
- * mirroring it to the durable `_00_window` row) blanked lists and kept them
542
- * blank across reloads. Once a real set has been seen, a later empty one is a
543
- * genuine transition and must be honoured, or removed rows resurrect.
544
- *
545
- * In-memory only: a fresh session must re-earn the right to believe empties.
546
- * What does persist is the `confirmed` marker on the `_00_window` row, which
547
- * an empty set earns when it arrives with a server row count of zero or after
548
- * a non-empty set in the same session.
549
- */
550
- remoteSeen?: boolean;
551
- /**
552
- * Consecutive empty id-sets read from the server while `remoteSeen` is still
553
- * false. Bounds how long an unconfirmed empty may be ignored, so a window
554
- * that genuinely emptied while this device was away is believed on the second
555
- * read instead of rendering stale rows forever. Reset by any non-empty set.
556
- * In-memory only.
557
- */
558
- emptyReads?: number;
559
533
  /**
560
534
  * Key of this query's durable `_00_window` membership row: a hash of
561
535
  * `{surql, params}` WITHOUT the `session::id()` salt that `id` carries, so it
@@ -585,6 +559,30 @@ export type QueryConfigRecord = QueryConfig & { id: string };
585
559
  */
586
560
  export type QueryStatus = 'idle' | 'fetching';
587
561
 
562
+ /**
563
+ * What the server's `_00_query` row says about a view, read alongside its
564
+ * `_00_list_ref` edges (registration, the poll, a targeted re-read).
565
+ *
566
+ * - `present: false`: the row is not there (or not readable). The SSP no
567
+ * longer has this view: a TTL sweep, an SSP reset, a scheduler wipe. An
568
+ * empty edge set read at the same time is NOT "no rows", it is "no view".
569
+ * - `state: 'materializing'`: the SSP has (re)computed the set and its edges
570
+ * are in flight; an empty read is "not landed yet".
571
+ * - `state: 'ready'`: the edges are committed (flipped in the same
572
+ * transaction), so `rowCount === 0` with no edges is a real empty result.
573
+ * - `state: null`: a server that predates the marker; only `rowCount` can be
574
+ * used, with its `> 0` = flush window / `0` = empty ambiguity.
575
+ */
576
+ export interface ServerViewMeta {
577
+ present: boolean;
578
+ /** `_00_query.rowCount`; `null` when unreadable. */
579
+ rowCount: number | null;
580
+ state: 'materializing' | 'ready' | null;
581
+ }
582
+
583
+ /** Outcome of handing a server id-set to `DataModule.updateQueryRemoteArray`. */
584
+ export type MembershipOutcome = 'applied' | 'ignored' | 'view-lost';
585
+
588
586
  /**
589
587
  * Internal state of a live query.
590
588
  */
@@ -601,6 +599,15 @@ export interface QueryState {
601
599
  * the instant-hydrate, so a hydrate that finishes later must not prime
602
600
  * membership over the authoritative set. Ephemeral. */
603
601
  serverMembership?: boolean;
602
+ /**
603
+ * Set when the server's `_00_query` row for this query went missing while
604
+ * we held membership (TTL sweep, SSP reset, scheduler wipe). The view is
605
+ * being re-registered; until a readable row comes back, empty reads are
606
+ * NOT the server's answer and nothing is dropped. Ephemeral.
607
+ */
608
+ viewLost?: boolean;
609
+ /** Last `_00_query.state` read from the server, for DevTools. Ephemeral. */
610
+ serverState?: 'materializing' | 'ready' | null;
604
611
  /** Set once `notifyQuerySynced` has emitted for this registration lifetime.
605
612
  * Ephemeral (unlike the persisted `updateCount`), so a re-registered query
606
613
  * always emits at least once even when its records are unchanged — otherwise
@@ -610,6 +617,8 @@ export interface QueryState {
610
617
  ttlTimer: NodeJS.Timeout | null;
611
618
  /** TTL duration in milliseconds. */
612
619
  ttlDurationMs: number;
620
+ /** When the last TTL heartbeat for this query was sent (ms epoch); `undefined` before the first. */
621
+ lastHeartbeatAt?: number;
613
622
  /** Number of times the query has been updated. */
614
623
  updateCount: number;
615
624
  /** Timestamp (ms) of the last user-visible update, or null before the first
@@ -695,6 +704,12 @@ export interface QueryTimings {
695
704
  // Callback types
696
705
  export type QueryUpdateCallback = (records: Record<string, any>[]) => void;
697
706
  export type QueryStatusCallback = (status: QueryStatus) => void;
707
+ /**
708
+ * Observer for a query's authority flips: `true` once server membership is
709
+ * known for it (registration, poll, durable seed), `false` when it is reset
710
+ * (bucket switch). What the bindings' `isAuthoritative()` mirrors.
711
+ */
712
+ export type QueryAuthorityCallback = (known: boolean) => void;
698
713
  export type MutationCallback = (mutations: UpEvent[]) => void;
699
714
 
700
715
  export type MutationEventType = 'create' | 'update' | 'delete';
@@ -29,6 +29,16 @@ describe('classifySyncError', () => {
29
29
  expect(classifySyncError(new Error(message))).toBe('network');
30
30
  });
31
31
 
32
+ it.each([
33
+ // SurrealDB optimistic-concurrency conflict: retryable by definition.
34
+ 'Transaction conflict: Resource busy: . This transaction can be retried',
35
+ // Session state not re-applied yet after the SDK's reconnect handshake.
36
+ 'Specify a namespace to use',
37
+ 'Specify a database to use',
38
+ ])('classifies transient server state %j as network (retry, never rollback)', (message) => {
39
+ expect(classifySyncError(new Error(message))).toBe('network');
40
+ });
41
+
32
42
  it.each([
33
43
  'Permission denied',
34
44
  'There was a problem with the database: record already exists',
@@ -19,6 +19,24 @@ const NETWORK_ERROR_PATTERNS = [
19
19
  'enotfound',
20
20
  'epipe',
21
21
  'abort',
22
+ // Transient server-side states that are NOT the mutation's fault and clear
23
+ // on their own, so the outbox must retry them rather than roll the write
24
+ // back. Seen 2026-09-08 on whitepawn: a 1200-game import lost 752 games and
25
+ // ~1500 player_name rows because every queued CREATE that hit one of these
26
+ // during a DB stall + WebSocket reconnect was classified `application`,
27
+ // rolled back locally and discarded - silent data loss.
28
+ //
29
+ // - SurrealDB optimistic-concurrency conflicts: "Transaction conflict:
30
+ // Resource busy: . This transaction can be retried".
31
+ // - A socket whose namespace/identity has not been re-applied yet after the
32
+ // SDK's own reconnect handshake: "Specify a namespace to use" /
33
+ // "Specify a database to use". The statement is fine; the session is not
34
+ // ready.
35
+ 'transaction conflict',
36
+ 'resource busy',
37
+ 'can be retried',
38
+ 'specify a namespace',
39
+ 'specify a database',
22
40
  ];
23
41
 
24
42
  export function classifySyncError(error: unknown): 'network' | 'application' {