@spooky-sync/core 0.0.1-canary.331 → 0.0.1-canary.333

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/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.
@@ -1594,14 +1627,20 @@ interface Sp00kyConfig<S extends SchemaStructure> {
1594
1627
  */
1595
1628
  streamDebounceTime?: number;
1596
1629
  /**
1597
- * Debounce time in milliseconds for syncing collaborative (CRDT) field
1598
- * edits to the server. Local state is kept on every change (so reload and
1599
- * offline work), but a field's own new ops go out as one edit-log row per
1600
- * pause of this length (at most 2 s apart while typing continues).
1601
- * Lower = snappier propagation and more rows; higher = fewer rows and more
1602
- * lag for other collaborators. Defaults to 300ms.
1630
+ * How long in milliseconds the first edit to a collaborative (CRDT) field
1631
+ * after a quiet spell waits before it goes out, so a burst of keystrokes
1632
+ * travels as one edit-log row. Local state is kept on every change (so
1633
+ * reload and offline work). Defaults to 50ms.
1603
1634
  */
1604
1635
  crdtDebounceMs?: number;
1636
+ /**
1637
+ * The shortest gap in milliseconds between two edit-log rows of one CRDT
1638
+ * field: while someone keeps typing, their edits go out on this cadence
1639
+ * instead of waiting for a pause. Lower = other collaborators see the text
1640
+ * sooner and more rows are written; higher = fewer rows and more lag.
1641
+ * Defaults to 200ms.
1642
+ */
1643
+ crdtSendIntervalMs?: number;
1605
1644
  /**
1606
1645
  * Enable collaborative CRDT fields. When `true`, the `loro-crdt` engine is
1607
1646
  * preloaded at client startup (fetched as a separate chunk on page load) so
@@ -1635,15 +1674,16 @@ interface Sp00kyConfig<S extends SchemaStructure> {
1635
1674
  * back for it.
1636
1675
  *
1637
1676
  * The client holds exactly one live subscription, on its `_00_list_ref`
1638
- * table, and today it is a doorbell: the notification is reduced to the
1639
- * query hash and the row is then pulled with `SELECT * FROM $ids`. The edge
1677
+ * table. Without this, the notification names the row and the client then
1678
+ * pulls it with `SELECT * FROM $ids`. The edge
1640
1679
  * is a real graph edge, so `FETCH out` makes SurrealDB resolve the row into
1641
1680
  * the notification and the body fetch disappears.
1642
1681
  *
1643
1682
  * The push stays advisory: the body is landed exactly the way a fetched body
1644
1683
  * is (store write, circuit ingest, version recorded), so a notification the
1645
- * server drops costs nothing but the old round trip. Membership is still
1646
- * read back; only the body fetch is saved.
1684
+ * server drops costs nothing but the old round trip. A row joining a live
1685
+ * view is committed to membership from the notification either way (no
1686
+ * re-read); with this on, the body fetch is saved as well.
1647
1687
  *
1648
1688
  * Default `false`. The cost is wire bytes: one edit bumps the edge in every
1649
1689
  * view holding the row, and under `FETCH` each of those notifications
@@ -1873,6 +1913,17 @@ interface InlineRow {
1873
1913
  version: number;
1874
1914
  record: Record<string, unknown>;
1875
1915
  }
1916
+ /**
1917
+ * A row that joined a query's view, or moved to a newer version, as one LIVE
1918
+ * notification on `_00_list_ref` reports it: the edge's `out` and `version`.
1919
+ * The client commits it to the query's membership straight away instead of
1920
+ * re-reading the edges (see `sync/live.saga.ts`).
1921
+ */
1922
+ interface EdgeDelta {
1923
+ hash: QueryHash;
1924
+ id: string;
1925
+ version: number;
1926
+ }
1876
1927
  type RecordVersionArray = Array<[string, number]>;
1877
1928
  /**
1878
1929
  * Represents the difference between two record version sets.
@@ -2175,4 +2226,4 @@ interface DebounceOptions {
2175
2226
  flushOnHide?: boolean;
2176
2227
  }
2177
2228
  //#endregion
2178
- export { WebPushSubscribeOptions as $, RunOptions as A, Logger$1 as At, SyncHealthStatus as B, SESSION_POLL_MS as Bt, QueryTimeToLive as C, SignOutHook as Ct, RecordVersionArray as D, createAuthEventSystem as Dt, ReconnectConfig as E, AuthEventTypes as Et, StorageHealth as F, IMPERSONATION_ACCESS as Ft, WebPushConfig as G, sameImpersonation as Gt, UpdateOptions as H, decodeTokenClaims as Ht, StorageHealthStatus as I, ImpersonationInfo as It, WebPushErrorCode as J, WebPushDevice as K, StoreType as L, ImpersonationTimerHost as Lt, Sp00kyConfig as M, SealedQuery as Mt, Sp00kyQueryResult as N, EventDefinition as Nt, RecordVersionDiff as O, RemoteDatabaseService as Ot, Sp00kyQueryResultPromise as P, EventSystem as Pt, WebPushModule as Q, SyncHealth as R, ImpersonationTimers as Rt, QueryStatusCallback as S, SignOutContext as St, QueryUpdateCallback as T, AuthEventTypeMap as Tt, PUSH_ENDPOINT_KEY as U, impersonationFromToken as Ut, TimingPhase as V, TokenClaims as Vt, PushDeviceKind as W, renewDelayMs as Wt, WebPushMessage as X, WebPushInfo as Y, WebPushMessageInput as Z, QueryConfig as _, isPushPayload as _t, Level$1 as a, WebPushUpdateOptions as at, QueryState as b, ImpersonationCandidate as bt, MutationCallback as c, BridgeTokenMessage as ct, PersistenceClient as d, PushNotificationAction as dt, WebPushSupport as et, PhaseStat as f, PushOp as ft, QueryAuthorityCallback as g, WebPushClientMessage as gt, PreloadRefresh as h, PushUrgency as ht, InlineRow as i, WebPushUnsupportedReason as it, ServerViewMeta as j, LocalStore as jt, RegistrationTimings as k, LogTap as kt, MutationEvent as l, PUSH_PAYLOAD_VERSION as lt, PreloadOptions as m, PushPayloadKind as mt, DebounceOptions as n, WebPushSyncStatus as nt, MATERIALIZATION_SAMPLE_WINDOW as o, detectWebPushSupport as ot, PinoTransmit as p, PushPayload as pt, WebPushError as q, EventSubscriptionOptions as r, WebPushUnsubscribeOptions as rt, MembershipOutcome as s, BridgeSignOutMessage as st, ConnectionState as t, WebPushSyncOptions as tt, MutationEventType as u, PushNotification as ut, QueryConfigRecord as v, ActiveImpersonation as vt, QueryTimings as w, AuthEventSystem as wt, QueryStatus as x, SIGN_OUT_HOOK_TIMEOUT_MS as xt, QueryHash as y, AuthService as yt, SyncHealthConfig as z, ORIGIN_TOKEN_KEY as zt };
2229
+ 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.331",
3
+ "version": "0.0.1-canary.333",
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.331",
79
- "@spooky-sync/ssp-wasm": "0.0.1-canary.331",
78
+ "@spooky-sync/query-builder": "0.0.1-canary.333",
79
+ "@spooky-sync/ssp-wasm": "0.0.1-canary.333",
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",
@@ -77,7 +77,7 @@ export function route(env: SagaEnv, event: RuntimeEvent): RouteTarget {
77
77
  case 'LiveStart':
78
78
  return { saga: liveStart(env), lane: serial('live') };
79
79
  case 'LiveChange':
80
- return { saga: liveChange(env, event.hashes, event.rows), lane: serial('live-change') };
80
+ return { saga: liveChange(env, event.hashes, event.rows, event.deltas), lane: serial('live-change') };
81
81
  case 'TabRole':
82
82
  return { saga: setRole(env, event.role), lane: serial('tabs') };
83
83
  case 'TabMessage':
@@ -30,7 +30,7 @@ import type { ServiceCalls } from '../kernel/effects';
30
30
  import { encodeRecordId, mintOwnerSecret } from '../utils/index';
31
31
  import { sha256Hex } from '../utils/sha256';
32
32
  import { leaveViewsBeacon, listRefLive } from '../query/sql';
33
- import { hashOfEdge, rowOfEdge } from '../utils/edges';
33
+ import { deltaOfEdge, hashOfEdge, rowOfEdge } from '../utils/edges';
34
34
  import { mintMutationId } from '../mutation/mutation-id';
35
35
  import { BucketHandle, bucketContentToBlob, bucketSettings } from './bucket-handle';
36
36
 
@@ -176,7 +176,7 @@ export function createServices<S extends SchemaStructure>(config: Sp00kyConfig<S
176
176
  streamProcessor.configureCircuitPersistence(config.persistCircuit ?? config.localEngine === 'sqlite', config.circuitCheckpointMs);
177
177
  streamProcessor.configureProjection(config.circuitProjection ?? true);
178
178
  const migrator = new LocalMigrator(local, logger);
179
- const crdt = new CrdtManager(config.schema, local, remote, logger, config.crdtDebounceMs ?? 300);
179
+ const crdt = new CrdtManager(config.schema, local, remote, logger, config.crdtDebounceMs ?? 50, config.crdtSendIntervalMs ?? 200);
180
180
  const auth = new AuthService(config.schema, remote, persistence, logger);
181
181
  const tabId = typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : `tab_${Math.random().toString(36).slice(2)}`;
182
182
  return {
@@ -395,7 +395,8 @@ export function createAdapters<S extends SchemaStructure>(config: Sp00kyConfig<S
395
395
  // that was actually deleted the join yields nothing anyway. Landing
396
396
  // a body there would only ever write back what is going away.
397
397
  const row = message.action === 'DELETE' ? null : rowOfEdge(message.value);
398
- onChange([hash], row ? [row] : undefined);
398
+ const delta = deltaOfEdge(message.action, message.value);
399
+ onChange([hash], row ? [row] : undefined, delta ? [delta] : undefined);
399
400
  });
400
401
  return String(uuid);
401
402
  },
@@ -1,4 +1,4 @@
1
- import type { ConnectionState, InlineRow, QueryHash, QueryStatus, SyncHealth } from '../types';
1
+ import type { ConnectionState, EdgeDelta, InlineRow, QueryHash, QueryStatus, SyncHealth } from '../types';
2
2
  import type { StreamUpdate } from '../services/stream-processor/index';
3
3
 
4
4
  /**
@@ -36,7 +36,7 @@ export type RuntimeEvent =
36
36
  | { type: 'ConnectionChanged'; state: ConnectionState }
37
37
  | { type: 'StreamUpdate'; update: StreamUpdate }
38
38
  | { type: 'LiveStart' }
39
- | { type: 'LiveChange'; hashes: QueryHash[]; rows?: InlineRow[] }
39
+ | { type: 'LiveChange'; hashes: QueryHash[]; rows?: InlineRow[]; deltas?: EdgeDelta[] }
40
40
  | { type: 'TabRole'; role: 'solo' | 'leader' | 'follower' }
41
41
  | { type: 'TabMessage'; message: unknown };
42
42
 
@@ -2,7 +2,7 @@ import { Duration } from 'surrealdb';
2
2
  import type { LocalStore } from '../services/database/cache-engine';
3
3
  import type { IngestRecord, StreamUpdate } from '../services/stream-processor/index';
4
4
  import type { ClientState } from '../state/client-state';
5
- import type { InlineRow } from '../types';
5
+ import type { EdgeDelta, InlineRow } from '../types';
6
6
  import { withTimeout } from '../utils/index';
7
7
  import type { Effect, IdScope, RegisterResult, ServiceCalls, Settled, StatementResult } from './effects';
8
8
  import type { OutEvent, RuntimeEvent } from './events';
@@ -17,7 +17,7 @@ export interface Adapters {
17
17
  * changed query hashes, plus the changed rows themselves when the
18
18
  * subscription was opened with the body joined on (`liveInlineBodies`).
19
19
  */
20
- live(table: string, onChange: (hashes: string[], rows?: InlineRow[]) => void): Promise<string>;
20
+ live(table: string, onChange: (hashes: string[], rows?: InlineRow[], deltas?: EdgeDelta[]) => void): Promise<string>;
21
21
  kill(uuid: string): Promise<void>;
22
22
  };
23
23
  ssp: {
@@ -81,7 +81,7 @@ export function createInterpreter(adapters: Adapters, host: InterpreterHost): In
81
81
  : pending;
82
82
  }
83
83
  case 'remote.live':
84
- return adapters.remote.live(effect.table, (hashes, rows) => host.dispatch({ type: 'LiveChange', hashes, rows }));
84
+ return adapters.remote.live(effect.table, (hashes, rows, deltas) => host.dispatch({ type: 'LiveChange', hashes, rows, deltas }));
85
85
  case 'remote.kill':
86
86
  return adapters.remote.kill(effect.uuid);
87
87
  case 'ssp.register': {
@@ -0,0 +1,140 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { AuthService, REFUSAL_RECHECK_MIN_MS } from './index';
3
+ import {
4
+ createDatabaseEventSystem,
5
+ DatabaseEventTypes,
6
+ } from '../../services/database/events/index';
7
+
8
+ // The session is re-checked after every reconnect and after a statement the
9
+ // server refused because of the session: a token refused on a socket's own
10
+ // handshake used to leave the page on an anonymous `connected` socket while
11
+ // it kept announcing the user.
12
+
13
+ function jwt(claims: Record<string, unknown>): string {
14
+ return `header.${Buffer.from(JSON.stringify(claims)).toString('base64url')}.signature`;
15
+ }
16
+
17
+ const silentLogger = () =>
18
+ ({ debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), child: () => silentLogger() }) as any;
19
+
20
+ const REFUSED = 'There was a problem with authentication: this session has ended, sign in again';
21
+
22
+ function makeAuth() {
23
+ const token = jwt({ AC: 'account', ID: 'user:abc' });
24
+ const store = new Map<string, unknown>([['sp00ky_auth_token', token]]);
25
+ const persistence = {
26
+ get: vi.fn(async (k: string) => store.get(k) ?? null),
27
+ set: vi.fn(async (k: string, v: unknown) => void store.set(k, v)),
28
+ remove: vi.fn(async (k: string) => void store.delete(k)),
29
+ } as any;
30
+ const connected = new Set<() => void>();
31
+ const events = createDatabaseEventSystem();
32
+ let refuse = false;
33
+ const authenticate = vi.fn(async () => {
34
+ if (refuse) throw new Error(REFUSED);
35
+ });
36
+ const remote = {
37
+ query: vi.fn(async () => [{ id: 'user:abc' }]),
38
+ setAuthToken: vi.fn(),
39
+ whenConnected: vi.fn(async () => undefined),
40
+ subscribeConnection: (_event: 'connected', cb: () => void) => {
41
+ connected.add(cb);
42
+ return () => connected.delete(cb);
43
+ },
44
+ getEvents: () => events,
45
+ getClient: () => ({ authenticate, invalidate: vi.fn(async () => undefined) }),
46
+ } as any;
47
+ const auth = new AuthService({} as any, remote, persistence, silentLogger());
48
+ const settle = () => new Promise((r) => setTimeout(r, 0));
49
+ return {
50
+ auth,
51
+ authenticate,
52
+ store,
53
+ refuseFromNowOn: () => void (refuse = true),
54
+ reconnect: async () => {
55
+ for (const cb of connected) cb();
56
+ await settle();
57
+ },
58
+ statementFails: async (error: string) => {
59
+ events.emit(DatabaseEventTypes.RemoteQuery, {
60
+ query: 'RETURN fn::query::register($config)',
61
+ duration: 1,
62
+ success: false,
63
+ error,
64
+ timestamp: Date.now(),
65
+ });
66
+ await settle();
67
+ },
68
+ };
69
+ }
70
+
71
+ describe('AuthService session re-check', () => {
72
+ it('signs out when a reconnected socket can no longer use the token', async () => {
73
+ const t = makeAuth();
74
+ await t.auth.init();
75
+ expect(t.auth.isAuthenticated).toBe(true);
76
+
77
+ t.refuseFromNowOn();
78
+ await t.reconnect();
79
+
80
+ expect(t.authenticate).toHaveBeenCalledTimes(2);
81
+ expect(t.auth.isAuthenticated).toBe(false);
82
+ expect(t.store.get('sp00ky_auth_token')).toBeUndefined();
83
+ });
84
+
85
+ it('keeps a session the server still accepts after a reconnect', async () => {
86
+ const t = makeAuth();
87
+ await t.auth.init();
88
+ await t.reconnect();
89
+
90
+ expect(t.authenticate).toHaveBeenCalledTimes(2);
91
+ expect(t.auth.isAuthenticated).toBe(true);
92
+ });
93
+
94
+ it('signs out when a statement on an open socket is refused for the session', async () => {
95
+ const t = makeAuth();
96
+ await t.auth.init();
97
+
98
+ t.refuseFromNowOn();
99
+ await t.statementFails(`An error occurred: ${REFUSED}`);
100
+
101
+ expect(t.auth.isAuthenticated).toBe(false);
102
+ });
103
+
104
+ it('ignores statement errors that are not about the session', async () => {
105
+ const t = makeAuth();
106
+ await t.auth.init();
107
+ await t.statementFails('Found 3 for field `n`, but expected a string');
108
+ await t.statementFails('There was a problem with the underlying connection');
109
+
110
+ expect(t.authenticate).toHaveBeenCalledTimes(1);
111
+ });
112
+
113
+ it('re-checks once per window however many statements are refused', async () => {
114
+ vi.useFakeTimers({ toFake: ['Date'] });
115
+ try {
116
+ const t = makeAuth();
117
+ await t.auth.init();
118
+ await t.statementFails(REFUSED);
119
+ await t.statementFails(REFUSED);
120
+ expect(t.authenticate).toHaveBeenCalledTimes(2);
121
+
122
+ vi.setSystemTime(Date.now() + REFUSAL_RECHECK_MIN_MS);
123
+ await t.statementFails(REFUSED);
124
+ expect(t.authenticate).toHaveBeenCalledTimes(3);
125
+ expect(t.auth.isAuthenticated).toBe(true);
126
+ } finally {
127
+ vi.useRealTimers();
128
+ }
129
+ });
130
+
131
+ it('does nothing for a signed-out page', async () => {
132
+ const t = makeAuth();
133
+ t.store.clear();
134
+ await t.auth.init();
135
+ await t.reconnect();
136
+ await t.statementFails(REFUSED);
137
+
138
+ expect(t.authenticate).not.toHaveBeenCalled();
139
+ });
140
+ });
@@ -9,7 +9,8 @@ import type { Logger } from '../../services/logger/index';
9
9
  export * from './events/index';
10
10
  import { AuthEventTypes, createAuthEventSystem } from './events/index';
11
11
  import type { PersistenceClient } from '../../types';
12
- import { classifySyncError } from '../../utils/error-classification';
12
+ import { classifySyncError, isRefusedSessionError } from '../../utils/error-classification';
13
+ import { DatabaseEventTypes } from '../../services/database/events/index';
13
14
  import { encodeRecordId } from '../../utils/index';
14
15
  import { RecordId } from 'surrealdb';
15
16
  import {
@@ -53,6 +54,13 @@ export type SignOutHook = (ctx: SignOutContext) => Promise<void> | void;
53
54
  /** Upper bound on the before-sign-out hooks, so a dead socket never holds a sign-out. */
54
55
  export const SIGN_OUT_HOOK_TIMEOUT_MS = 2_000;
55
56
 
57
+ /**
58
+ * Floor between two session re-checks started by refused statements. A
59
+ * refused session fails every statement at once, and a check that keeps the
60
+ * session (the server was unreachable) must not be re-run by each of them.
61
+ */
62
+ export const REFUSAL_RECHECK_MIN_MS = 30_000;
63
+
56
64
  // Helper to pretty print types
57
65
  type Prettify<T> = {
58
66
  [K in keyof T]: T[K];
@@ -111,6 +119,11 @@ export class AuthService<S extends SchemaStructure> {
111
119
  private stopping: Promise<void> | null = null;
112
120
  /** A token renewal in flight, which a stop waits out (see `renewImpersonation`). */
113
121
  private renewing: Promise<void> | null = null;
122
+ /** The session re-check in flight (see `watchSession`), shared by every trigger. */
123
+ private reverifying: Promise<void> | null = null;
124
+ /** When a refused statement last started a re-check. */
125
+ private lastRefusalCheckAt = Number.NEGATIVE_INFINITY;
126
+ private watchingSession = false;
114
127
  private impersonationTimers = new ImpersonationTimers({
115
128
  current: () => this.impersonation,
116
129
  renew: () => this.renewImpersonation(),
@@ -141,6 +154,60 @@ export class AuthService<S extends SchemaStructure> {
141
154
 
142
155
  async init() {
143
156
  await this.check();
157
+ this.watchSession();
158
+ }
159
+
160
+ /**
161
+ * Check the session again whenever the socket comes back, and whenever the
162
+ * server refuses a statement because of the session.
163
+ *
164
+ * The server judges a token only when a socket authenticates: the SDK
165
+ * replays `authenticate` on its own reconnects, `RemoteDatabaseService` on a
166
+ * socket it rebuilds. When the replay is refused (the app ended the session,
167
+ * say with a password reset) the SDK drops to an anonymous session, and the
168
+ * supervisor swallows the rebuild's error, so the socket reports `connected`
169
+ * while this service still announces the user. Every view registered from
170
+ * then on carried no identity: the page looked signed in and showed nothing
171
+ * of the user's. `check()` applies the token again and signs out when the
172
+ * server refuses it.
173
+ *
174
+ * A socket that never reconnects is ended by the app's session guard
175
+ * (`fn::sp00ky::session_live`) instead: its heartbeats stop finding rows,
176
+ * and the re-registration that follows is refused with "There was a problem
177
+ * with authentication", which starts a check here too.
178
+ */
179
+ private watchSession(): void {
180
+ if (this.watchingSession) return;
181
+ this.watchingSession = true;
182
+ this.remote.subscribeConnection('connected', () => this.reverify());
183
+ this.remote.getEvents().subscribe(DatabaseEventTypes.RemoteQuery, (event) => {
184
+ const { success, error } = event.payload;
185
+ if (success || !isRefusedSessionError(error ?? '')) return;
186
+ const now = Date.now();
187
+ if (now - this.lastRefusalCheckAt < REFUSAL_RECHECK_MIN_MS) return;
188
+ this.lastRefusalCheckAt = now;
189
+ this.reverify();
190
+ });
191
+ }
192
+
193
+ /** One `check()` for every trigger that lands while it runs. */
194
+ private reverify(): void {
195
+ if (!this.isAuthenticated || this.reverifying) return;
196
+ this.reverifying = (async () => {
197
+ // A rebuilt socket reports `connected` before its namespace and token
198
+ // are applied: check against the finished handshake.
199
+ await this.remote.whenConnected();
200
+ if (this.isAuthenticated) await this.check();
201
+ })()
202
+ .catch((error) => {
203
+ this.logger.warn(
204
+ { error, Category: 'sp00ky-client::AuthService::reverify' },
205
+ 'Session re-check failed'
206
+ );
207
+ })
208
+ .finally(() => {
209
+ this.reverifying = null;
210
+ });
144
211
  }
145
212
 
146
213
  getAccessDefinition<Name extends keyof S['access']>(name: Name): AccessDefinition | undefined {
@@ -36,6 +36,8 @@ class World {
36
36
  for (const go of this.held.splice(0)) go();
37
37
  }
38
38
  private watchers = new Set<{ table: string; where: Record<string, unknown>; cb: (rows: any[]) => void }>();
39
+ /** Every query a client registered, in order. */
40
+ watched: string[] = [];
39
41
 
40
42
  client(user: string, store = new Map<string, any>(), logger: any = silent) {
41
43
  const local = {
@@ -64,9 +66,10 @@ class World {
64
66
  return [[]];
65
67
  },
66
68
  } as any;
67
- const manager = new CrdtManager(schema, local, remote, logger, 300);
69
+ const manager = new CrdtManager(schema, local, remote, logger);
68
70
  const ports: CrdtPorts = {
69
71
  watch: async (table, where, cb) => {
72
+ this.watched.push(table);
70
73
  const w = { table, where, cb };
71
74
  this.watchers.add(w);
72
75
  if (this.slow) {
@@ -158,17 +161,60 @@ describe('CRDT fields', () => {
158
161
  expect(text(fb)).toBe(text(fa));
159
162
  });
160
163
 
161
- it('a burst of typing goes out after the pause, or after the max wait at the latest', async () => {
164
+ it('while typing continues, edits go out on a steady cadence instead of waiting for a pause', async () => {
162
165
  const world = new World();
163
166
  const a = world.client('user:a');
164
167
  const f = await a.manager.open('doc', 'doc:1', 'body');
165
- for (let i = 0; i < 10; i++) {
168
+ // A key every 50 ms for a second: a trailing debounce would send nothing
169
+ // until the typing stopped. The first edit goes out after 50 ms, the rest
170
+ // every 200 ms.
171
+ for (let i = 0; i < 20; i++) {
166
172
  f.getDoc().getText('body').insert(i, 'x');
167
173
  f.getDoc().commit();
168
- await vi.advanceTimersByTimeAsync(250);
174
+ await vi.advanceTimersByTimeAsync(50);
175
+ if (i === 0) expect(world.log).toHaveLength(1);
169
176
  }
170
- expect(world.log.length).toBeGreaterThanOrEqual(1);
171
- expect(world.log.length).toBeLessThan(10);
177
+ expect(world.log.length).toBeGreaterThanOrEqual(4);
178
+ expect(world.log.length).toBeLessThanOrEqual(6);
179
+ await vi.advanceTimersByTimeAsync(300);
180
+ const copy = new LoroDoc();
181
+ for (const row of world.log) copy.import(row.data);
182
+ expect(copy.getText('body').toString()).toBe('x'.repeat(20));
183
+ });
184
+
185
+ it('edits made while a send waits on the server go out right after it', async () => {
186
+ const world = new World();
187
+ const a = world.client('user:a');
188
+ const f = await a.manager.open('doc', 'doc:1', 'body');
189
+ world.slow = true;
190
+ // A fresh peer: the next send claims it first, and that claim hangs.
191
+ a.manager.setSessionId('');
192
+ f.getDoc().getText('body').insert(0, 'a');
193
+ f.getDoc().commit();
194
+ await vi.advanceTimersByTimeAsync(100);
195
+ f.getDoc().getText('body').insert(1, 'b');
196
+ f.getDoc().commit();
197
+ await vi.advanceTimersByTimeAsync(300);
198
+ world.slow = false;
199
+ world.release();
200
+ await vi.advanceTimersByTimeAsync(10);
201
+ const copy = new LoroDoc();
202
+ for (const row of world.log) copy.import(row.data);
203
+ expect(copy.getText('body').toString()).toBe('ab');
204
+ });
205
+
206
+ it('a first open reads the published document while its log query registers, not before', async () => {
207
+ const world = new World();
208
+ world.slow = true;
209
+ const a = world.client('user:a');
210
+ const opening = a.manager.open('doc', 'doc:1', 'body');
211
+ await vi.advanceTimersByTimeAsync(0);
212
+ // The published-document read is still out, and the log query is already registered.
213
+ expect(world.watched).toEqual(['doc__crdt_log']);
214
+ world.slow = false;
215
+ world.release();
216
+ await vi.advanceTimersByTimeAsync(10);
217
+ expect((await opening).isLoaded()).toBe(true);
172
218
  });
173
219
 
174
220
  it('send nothing until the peer is claimed, then everything at once', async () => {