@spooky-sync/core 0.0.1-canary.81 → 0.0.1-canary.83

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
@@ -580,6 +580,12 @@ interface Sp00kySyncOptions {
580
580
  * {@link resolveListRefPollInterval}.
581
581
  */
582
582
  refSyncIntervalMs?: number;
583
+ /**
584
+ * Enable realtime sync for unauthenticated clients against the shared
585
+ * `_00_list_ref_anon` table. See {@link Sp00kyConfig.enableAnonymousLiveQueries}.
586
+ * Defaults to `false`.
587
+ */
588
+ anonymousLiveQueries?: boolean;
583
589
  }
584
590
  /**
585
591
  * The main synchronization engine for Sp00ky.
@@ -607,6 +613,7 @@ declare class Sp00kySync<S extends SchemaStructure> {
607
613
  events: SyncEventSystem;
608
614
  private currentUserId;
609
615
  private refMode;
616
+ private readonly anonLiveEnabled;
610
617
  private currentLiveQueryUuid;
611
618
  private liveQueryUnsubscribe;
612
619
  private listRefPollTimer;
package/dist/index.js CHANGED
@@ -2551,6 +2551,13 @@ var SyncScheduler = class {
2551
2551
  //#endregion
2552
2552
  //#region src/modules/ref-tables.ts
2553
2553
  /**
2554
+ * Sentinel user id for unauthenticated clients when anonymous live queries are
2555
+ * enabled. Mirrors `ssp_protocol::ANON_AUTH_ID`. It carries no `user:` prefix
2556
+ * so it can never collide with a real user id (those arrive as `user:<id>`);
2557
+ * both sides resolve it to the shared `_00_list_ref_anon` table.
2558
+ */
2559
+ const ANON_USER_ID = "anon";
2560
+ /**
2554
2561
  * Default ref-storage mode for this client build. Mirrors the SSP's
2555
2562
  * default (`RefMode::Dedicated`) so cross-session sync works out of the
2556
2563
  * box.
@@ -2582,6 +2589,7 @@ function sanitizeUserId(userId) {
2582
2589
  * single mode.
2583
2590
  */
2584
2591
  function listRefTableFor(mode, userId) {
2592
+ if (userId === ANON_USER_ID) return "_00_list_ref_anon";
2585
2593
  if (mode === "single") return "_00_list_ref";
2586
2594
  const uid = sanitizeUserId(userId);
2587
2595
  return uid ? `_00_list_ref_user_${uid}` : "_00_list_ref";
@@ -2612,6 +2620,7 @@ var Sp00kySync = class {
2612
2620
  events = createSyncEventSystem();
2613
2621
  currentUserId = null;
2614
2622
  refMode = DEFAULT_REF_MODE;
2623
+ anonLiveEnabled;
2615
2624
  currentLiveQueryUuid = null;
2616
2625
  liveQueryUnsubscribe = null;
2617
2626
  listRefPollTimer = null;
@@ -2650,6 +2659,7 @@ var Sp00kySync = class {
2650
2659
  this.syncEngine = new SyncEngine(this.remote, this.cache, this.schema, this.logger);
2651
2660
  this.scheduler = new SyncScheduler(this.upQueue, this.downQueue, this.processUpEvent.bind(this), this.processDownEvent.bind(this), this.logger, this.handleRollback.bind(this));
2652
2661
  this.refSyncIntervalMs = resolveListRefPollInterval(options?.refSyncIntervalMs);
2662
+ this.anonLiveEnabled = options?.anonymousLiveQueries ?? false;
2653
2663
  }
2654
2664
  /**
2655
2665
  * Initializes the synchronization system.
@@ -2663,6 +2673,15 @@ var Sp00kySync = class {
2663
2673
  this.subscribeToReconnect();
2664
2674
  this.scheduler.syncUp();
2665
2675
  this.scheduler.syncDown();
2676
+ if (this.anonLiveEnabled && !this.currentUserId) {
2677
+ this.startListRefPoll();
2678
+ this.restartRefLiveQuery().catch((err) => {
2679
+ this.logger.debug({
2680
+ err,
2681
+ Category: "sp00ky-client::Sp00kySync::init"
2682
+ }, "Anonymous ref LIVE start failed; relying on periodic poll fallback");
2683
+ });
2684
+ }
2666
2685
  }
2667
2686
  /**
2668
2687
  * Push the authenticated user's record id from the parent client's
@@ -2681,6 +2700,16 @@ var Sp00kySync = class {
2681
2700
  if (this.currentUserId === userId) return;
2682
2701
  this.currentUserId = userId;
2683
2702
  if (!userId) {
2703
+ if (this.anonLiveEnabled) {
2704
+ this.startListRefPoll();
2705
+ await this.restartRefLiveQuery().catch((err) => {
2706
+ this.logger.debug({
2707
+ err,
2708
+ Category: "sp00ky-client::Sp00kySync::setCurrentUserId"
2709
+ }, "Anonymous ref LIVE restart failed; relying on periodic poll fallback");
2710
+ });
2711
+ return;
2712
+ }
2684
2713
  await this.killRefLiveQuery();
2685
2714
  this.stopListRefPoll();
2686
2715
  return;
@@ -2821,7 +2850,9 @@ var Sp00kySync = class {
2821
2850
  * two points and we need the correct table name immediately.
2822
2851
  */
2823
2852
  listRefTable() {
2824
- return listRefTableFor(this.refMode, this.dataModule.getCurrentUserId());
2853
+ const userId = this.dataModule.getCurrentUserId();
2854
+ if (userId == null && this.anonLiveEnabled) return listRefTableFor(this.refMode, ANON_USER_ID);
2855
+ return listRefTableFor(this.refMode, userId);
2825
2856
  }
2826
2857
  async killRefLiveQuery() {
2827
2858
  if (this.liveQueryUnsubscribe) {
@@ -2860,7 +2891,7 @@ var Sp00kySync = class {
2860
2891
  type: "register",
2861
2892
  payload: { hash }
2862
2893
  });
2863
- if (this.currentUserId) this.restartRefLiveQuery().catch((err) => {
2894
+ if (this.currentUserId || this.anonLiveEnabled) this.restartRefLiveQuery().catch((err) => {
2864
2895
  this.logger.debug({
2865
2896
  err,
2866
2897
  Category: "sp00ky-client::Sp00kySync::onReconnect"
@@ -3312,8 +3343,8 @@ function parseBackendInfo(raw) {
3312
3343
 
3313
3344
  //#endregion
3314
3345
  //#region src/modules/devtools/index.ts
3315
- const CORE_VERSION = "0.0.1-canary.81";
3316
- const WASM_VERSION = "0.0.1-canary.81";
3346
+ const CORE_VERSION = "0.0.1-canary.83";
3347
+ const WASM_VERSION = "0.0.1-canary.83";
3317
3348
  const SURREAL_VERSION = "3.0.3";
3318
3349
  var DevToolsService = class {
3319
3350
  eventsHistory = [];
@@ -5164,7 +5195,10 @@ var Sp00kyClient = class {
5164
5195
  this.crdtManager = new CrdtManager(this.config.schema, this.local, this.remote, logger, config.crdtDebounceMs ?? 500);
5165
5196
  this.dataModule = new DataModule(this.cache, this.local, this.config.schema, logger, this.config.streamDebounceTime);
5166
5197
  this.auth = new AuthService(this.config.schema, this.remote, this.persistenceClient, logger);
5167
- this.sync = new Sp00kySync(this.local, this.remote, this.cache, this.dataModule, this.config.schema, this.logger, { refSyncIntervalMs: this.config.refSyncIntervalMs });
5198
+ this.sync = new Sp00kySync(this.local, this.remote, this.cache, this.dataModule, this.config.schema, this.logger, {
5199
+ refSyncIntervalMs: this.config.refSyncIntervalMs,
5200
+ anonymousLiveQueries: this.config.enableAnonymousLiveQueries
5201
+ });
5168
5202
  this.featureFlags = new FeatureFlagModule({
5169
5203
  dataModule: this.dataModule,
5170
5204
  sync: this.sync,
package/dist/types.d.ts CHANGED
@@ -291,6 +291,16 @@ interface Sp00kyConfig<S extends SchemaStructure> {
291
291
  * Defaults to `true`; set `false` to keep the old wait-for-registration path.
292
292
  */
293
293
  instantHydrate?: boolean;
294
+ /**
295
+ * Enable realtime sync while signed out. When `true`, the client starts its
296
+ * `_00_list_ref` poll (and a LIVE subscription) against the shared
297
+ * `_00_list_ref_anon` table even with no authenticated user, so a logged-out
298
+ * page gets live `useQuery` updates over world-readable tables. Requires the
299
+ * server to be deployed with `anonymousLiveQueries: true` in `sp00ky.yml`
300
+ * (this flag must match it). Defaults to `false`: anonymous clients can read
301
+ * one-shot but never sync live.
302
+ */
303
+ enableAnonymousLiveQueries?: boolean;
294
304
  }
295
305
  type QueryHash = string;
296
306
  type RecordVersionArray = Array<[string, number]>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spooky-sync/core",
3
- "version": "0.0.1-canary.81",
3
+ "version": "0.0.1-canary.83",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -59,8 +59,8 @@
59
59
  }
60
60
  },
61
61
  "dependencies": {
62
- "@spooky-sync/query-builder": "0.0.1-canary.81",
63
- "@spooky-sync/ssp-wasm": "0.0.1-canary.81",
62
+ "@spooky-sync/query-builder": "0.0.1-canary.83",
63
+ "@spooky-sync/ssp-wasm": "0.0.1-canary.83",
64
64
  "@surrealdb/wasm": "^3.0.3",
65
65
  "fast-json-patch": "^3.1.1",
66
66
  "loro-crdt": "^1.5.6",
@@ -1,6 +1,6 @@
1
1
  import { describe, it, expect } from 'vitest';
2
2
  import { RecordId } from 'surrealdb';
3
- import { listRefTableFor, sanitizeUserId } from './ref-tables';
3
+ import { ANON_USER_ID, listRefTableFor, sanitizeUserId } from './ref-tables';
4
4
 
5
5
  describe('listRefTableFor', () => {
6
6
  it('returns global table in single mode regardless of user', () => {
@@ -32,6 +32,16 @@ describe('listRefTableFor', () => {
32
32
  );
33
33
  expect(listRefTableFor('dedicated', 'user:abc.dot')).toBe('_00_list_ref');
34
34
  });
35
+
36
+ it('routes the anon sentinel to the shared anon table in both modes', () => {
37
+ expect(listRefTableFor('dedicated', ANON_USER_ID)).toBe('_00_list_ref_anon');
38
+ expect(listRefTableFor('single', ANON_USER_ID)).toBe('_00_list_ref_anon');
39
+ // A real user whose id sanitizes to "anon" still carries the user: prefix,
40
+ // so it never collides with the bare sentinel.
41
+ expect(listRefTableFor('dedicated', 'user:anon')).toBe(
42
+ '_00_list_ref_user_anon'
43
+ );
44
+ });
35
45
  });
36
46
 
37
47
  describe('sanitizeUserId', () => {
@@ -12,6 +12,14 @@
12
12
 
13
13
  export type RefMode = 'single' | 'dedicated';
14
14
 
15
+ /**
16
+ * Sentinel user id for unauthenticated clients when anonymous live queries are
17
+ * enabled. Mirrors `ssp_protocol::ANON_AUTH_ID`. It carries no `user:` prefix
18
+ * so it can never collide with a real user id (those arrive as `user:<id>`);
19
+ * both sides resolve it to the shared `_00_list_ref_anon` table.
20
+ */
21
+ export const ANON_USER_ID = 'anon';
22
+
15
23
  /**
16
24
  * Default ref-storage mode for this client build. Mirrors the SSP's
17
25
  * default (`RefMode::Dedicated`) so cross-session sync works out of the
@@ -51,6 +59,10 @@ export function sanitizeUserId(userId: unknown): string | null {
51
59
  * single mode.
52
60
  */
53
61
  export function listRefTableFor(mode: RefMode, userId: unknown): string {
62
+ // Anonymous clients (flag-enabled) share one dedicated table in both modes —
63
+ // checked before the mode split so it never lands on the per-user or the
64
+ // auth-gated global table. Matches `ssp_protocol::list_ref_table_for`.
65
+ if (userId === ANON_USER_ID) return '_00_list_ref_anon';
54
66
  if (mode === 'single') return '_00_list_ref';
55
67
  const uid = sanitizeUserId(userId);
56
68
  return uid ? `_00_list_ref_user_${uid}` : '_00_list_ref';
@@ -21,7 +21,7 @@ import type { SchemaStructure } from '@spooky-sync/query-builder';
21
21
  import type { CacheModule } from '../cache/index';
22
22
  import type { DataModule } from '../data/index';
23
23
  import { encodeRecordId, extractIdPart, extractTablePart, surql } from '../../utils/index';
24
- import { DEFAULT_REF_MODE, listRefTableFor, RefMode } from '../ref-tables';
24
+ import { ANON_USER_ID, DEFAULT_REF_MODE, listRefTableFor, RefMode } from '../ref-tables';
25
25
 
26
26
  /**
27
27
  * Tunables for `Sp00kySync` construction.
@@ -34,6 +34,12 @@ export interface Sp00kySyncOptions {
34
34
  * {@link resolveListRefPollInterval}.
35
35
  */
36
36
  refSyncIntervalMs?: number;
37
+ /**
38
+ * Enable realtime sync for unauthenticated clients against the shared
39
+ * `_00_list_ref_anon` table. See {@link Sp00kyConfig.enableAnonymousLiveQueries}.
40
+ * Defaults to `false`.
41
+ */
42
+ anonymousLiveQueries?: boolean;
37
43
  }
38
44
 
39
45
  /**
@@ -65,6 +71,11 @@ export class Sp00kySync<S extends SchemaStructure> {
65
71
 
66
72
  private refMode: RefMode = DEFAULT_REF_MODE;
67
73
 
74
+ // When true, an unauthenticated client still runs the `_00_list_ref` poll
75
+ // and LIVE subscription, routed to the shared `_00_list_ref_anon` table, so
76
+ // a logged-out page gets realtime `useQuery` updates. Off by default.
77
+ private readonly anonLiveEnabled: boolean;
78
+
68
79
  // Bookkeeping for the LIVE subscription on `_00_list_ref[_user_*]`.
69
80
  // SurrealDB binds the permission context at LIVE-registration time and
70
81
  // the table name in dedicated mode depends on the authenticated user,
@@ -165,6 +176,7 @@ export class Sp00kySync<S extends SchemaStructure> {
165
176
  this.handleRollback.bind(this)
166
177
  );
167
178
  this.refSyncIntervalMs = resolveListRefPollInterval(options?.refSyncIntervalMs);
179
+ this.anonLiveEnabled = options?.anonymousLiveQueries ?? false;
168
180
  }
169
181
 
170
182
  /**
@@ -183,6 +195,23 @@ export class Sp00kySync<S extends SchemaStructure> {
183
195
  // from the auth subscription. In dedicated mode the table name
184
196
  // depends on the authenticated user, and an unauthenticated
185
197
  // subscription wouldn't match any of the per-user tables anyway.
198
+ //
199
+ // Exception: when anonymous live queries are enabled, start realtime now
200
+ // against the shared `_00_list_ref_anon` table so a logged-out client
201
+ // syncs immediately. `setCurrentUserId` re-points LIVE to the per-user
202
+ // table on sign-in. Guard on `currentUserId` because the auth callback can
203
+ // fire (and authenticate) before `init()` runs — don't clobber that back
204
+ // to the anon table. `setCurrentUserId(null)` is a no-op on first load
205
+ // (it's already null), so this is the only place anon realtime starts.
206
+ if (this.anonLiveEnabled && !this.currentUserId) {
207
+ this.startListRefPoll();
208
+ this.restartRefLiveQuery().catch((err) => {
209
+ this.logger.debug(
210
+ { err, Category: 'sp00ky-client::Sp00kySync::init' },
211
+ 'Anonymous ref LIVE start failed; relying on periodic poll fallback'
212
+ );
213
+ });
214
+ }
186
215
  }
187
216
 
188
217
  /**
@@ -202,6 +231,20 @@ export class Sp00kySync<S extends SchemaStructure> {
202
231
  if (this.currentUserId === userId) return;
203
232
  this.currentUserId = userId;
204
233
  if (!userId) {
234
+ if (this.anonLiveEnabled) {
235
+ // Signed out but anonymous realtime is on: keep the poll running and
236
+ // re-point LIVE from the (now stale) per-user table to the shared
237
+ // `_00_list_ref_anon`. `startListRefPoll` is idempotent; the poll
238
+ // re-resolves `listRefTable()` each tick so it follows automatically.
239
+ this.startListRefPoll();
240
+ await this.restartRefLiveQuery().catch((err) => {
241
+ this.logger.debug(
242
+ { err, Category: 'sp00ky-client::Sp00kySync::setCurrentUserId' },
243
+ 'Anonymous ref LIVE restart failed; relying on periodic poll fallback'
244
+ );
245
+ });
246
+ return;
247
+ }
205
248
  await this.killRefLiveQuery();
206
249
  this.stopListRefPoll();
207
250
  return;
@@ -391,7 +434,12 @@ export class Sp00kySync<S extends SchemaStructure> {
391
434
  * two points and we need the correct table name immediately.
392
435
  */
393
436
  public listRefTable(): string {
394
- return listRefTableFor(this.refMode, this.dataModule.getCurrentUserId());
437
+ const userId = this.dataModule.getCurrentUserId();
438
+ // Unauthenticated with the flag on → the shared `_00_list_ref_anon` table.
439
+ if (userId == null && this.anonLiveEnabled) {
440
+ return listRefTableFor(this.refMode, ANON_USER_ID);
441
+ }
442
+ return listRefTableFor(this.refMode, userId);
395
443
  }
396
444
 
397
445
  private async killRefLiveQuery(): Promise<void> {
@@ -443,8 +491,9 @@ export class Sp00kySync<S extends SchemaStructure> {
443
491
  // re-enqueued `register` events only re-fetch initial state, they don't
444
492
  // re-subscribe. Without this, LIVE never recovers after a reconnect and
445
493
  // the poll silently becomes the sole sync path (and never backs off).
446
- // Only when authenticated: a signed-out reconnect has no per-user table.
447
- if (this.currentUserId) {
494
+ // Authenticated per-user table; signed-out with anon live enabled
495
+ // the shared `_00_list_ref_anon`. Otherwise there's no table to re-bind.
496
+ if (this.currentUserId || this.anonLiveEnabled) {
448
497
  this.restartRefLiveQuery().catch((err) => {
449
498
  this.logger.debug(
450
499
  { err, Category: 'sp00ky-client::Sp00kySync::onReconnect' },
package/src/sp00ky.ts CHANGED
@@ -203,7 +203,10 @@ export class Sp00kyClient<S extends SchemaStructure> {
203
203
  this.dataModule,
204
204
  this.config.schema,
205
205
  this.logger,
206
- { refSyncIntervalMs: this.config.refSyncIntervalMs }
206
+ {
207
+ refSyncIntervalMs: this.config.refSyncIntervalMs,
208
+ anonymousLiveQueries: this.config.enableAnonymousLiveQueries,
209
+ }
207
210
  );
208
211
 
209
212
  // Initialize feature flags. Reuses the down-queue to register SSP plans
package/src/types.ts CHANGED
@@ -143,6 +143,16 @@ export interface Sp00kyConfig<S extends SchemaStructure> {
143
143
  * Defaults to `true`; set `false` to keep the old wait-for-registration path.
144
144
  */
145
145
  instantHydrate?: boolean;
146
+ /**
147
+ * Enable realtime sync while signed out. When `true`, the client starts its
148
+ * `_00_list_ref` poll (and a LIVE subscription) against the shared
149
+ * `_00_list_ref_anon` table even with no authenticated user, so a logged-out
150
+ * page gets live `useQuery` updates over world-readable tables. Requires the
151
+ * server to be deployed with `anonymousLiveQueries: true` in `sp00ky.yml`
152
+ * (this flag must match it). Defaults to `false`: anonymous clients can read
153
+ * one-shot but never sync live.
154
+ */
155
+ enableAnonymousLiveQueries?: boolean;
146
156
  }
147
157
 
148
158
  export type QueryHash = string;