@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/AGENTS.md +1 -1
- package/dist/index.d.ts +134 -5
- package/dist/index.js +498 -83
- package/dist/otel/index.d.ts +1 -1
- package/dist/types.d.ts +40 -27
- package/package.json +3 -3
- package/src/modules/data/data.activity.test.ts +109 -0
- package/src/modules/data/data.authority.test.ts +122 -0
- package/src/modules/data/data.heartbeat-wake.test.ts +115 -0
- package/src/modules/data/data.membership.test.ts +168 -68
- package/src/modules/data/index.ts +248 -83
- package/src/modules/devtools/index.ts +3 -2
- package/src/modules/sync/engine.ts +12 -7
- package/src/modules/sync/sync.health.test.ts +13 -3
- package/src/modules/sync/sync.list-ref-meta.test.ts +193 -0
- package/src/modules/sync/sync.live-removal.test.ts +137 -23
- package/src/modules/sync/sync.ts +326 -60
- package/src/modules/sync/utils.test.ts +1 -1
- package/src/modules/sync/utils.ts +10 -2
- package/src/sp00ky.ts +64 -1
- package/src/types.ts +41 -26
- package/src/utils/error-classification.test.ts +10 -0
- package/src/utils/error-classification.ts +18 -0
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
|
-
|
|
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' {
|