@spooky-sync/core 0.0.1-canary.73 → 0.0.1-canary.75

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 { A as Logger$1, C as RunOptions, D as StoreType, E as Sp00kyQueryResultPromise, M as EventSystem, O as TimingPhase, S as RegistrationTimings, T as Sp00kyQueryResult, _ as QueryTimeToLive, a as MutationCallback, b as RecordVersionArray, c as PersistenceClient, d as QueryConfig, f as QueryConfigRecord, g as QueryStatusCallback, h as QueryStatus, i as MATERIALIZATION_SAMPLE_WINDOW, j as EventDefinition, k as UpdateOptions, l as PhaseStat, m as QueryState, n as EventSubscriptionOptions, o as MutationEvent, p as QueryHash, r as Level, s as MutationEventType, t as DebounceOptions, u as PinoTransmit, v as QueryTimings, w as Sp00kyConfig, x as RecordVersionDiff, y as QueryUpdateCallback } from "./types.js";
1
+ import { A as UpEvent, C as RunOptions, D as StoreType, E as Sp00kyQueryResultPromise, M as SyncEventSystem, N as EventDefinition, O as TimingPhase, P as EventSystem, S as RegistrationTimings, T as Sp00kyQueryResult, _ as QueryTimeToLive, a as MutationCallback, b as RecordVersionArray, c as PersistenceClient, d as QueryConfig, f as QueryConfigRecord, g as QueryStatusCallback, h as QueryStatus, i as MATERIALIZATION_SAMPLE_WINDOW, j as Logger$1, k as UpdateOptions, l as PhaseStat, m as QueryState, n as EventSubscriptionOptions, o as MutationEvent, p as QueryHash, r as Level, s as MutationEventType, t as DebounceOptions, u as PinoTransmit, v as QueryTimings, w as Sp00kyConfig, x as RecordVersionDiff, y as QueryUpdateCallback } from "./types.js";
2
2
  import * as surrealdb0 from "surrealdb";
3
3
  import { Duration, RecordId, Surreal as Surreal$1, SurrealTransaction } from "surrealdb";
4
4
  import { AccessDefinition, BackendNames, BackendRoutes, BucketNames, ColumnSchema, GetTable, QueryBuilder, QueryOptions, RoutePayload, SchemaStructure, TableModel, TableNames, TypeNameToTypeMap } from "@spooky-sync/query-builder";
@@ -88,6 +88,33 @@ declare class RemoteDatabaseService extends AbstractDatabaseService {
88
88
  invalidate(): Promise<void>;
89
89
  }
90
90
  //#endregion
91
+ //#region src/modules/sync/queue/queue-down.d.ts
92
+ type RegisterEvent = {
93
+ type: 'register';
94
+ payload: {
95
+ hash: string;
96
+ };
97
+ };
98
+ type SyncEvent = {
99
+ type: 'sync';
100
+ payload: {
101
+ hash: string;
102
+ };
103
+ };
104
+ type HeartbeatEvent = {
105
+ type: 'heartbeat';
106
+ payload: {
107
+ hash: string;
108
+ };
109
+ };
110
+ type CleanupEvent = {
111
+ type: 'cleanup';
112
+ payload: {
113
+ hash: string;
114
+ };
115
+ };
116
+ type DownEvent = RegisterEvent | SyncEvent | HeartbeatEvent | CleanupEvent;
117
+ //#endregion
91
118
  //#region src/services/stream-processor/wasm-types.d.ts
92
119
  interface WasmStreamUpdate {
93
120
  query_id: string;
@@ -232,6 +259,438 @@ declare class StreamProcessorService {
232
259
  private normalizeValue;
233
260
  }
234
261
  //#endregion
262
+ //#region src/modules/cache/types.d.ts
263
+ type RecordWithId = Record<string, any> & {
264
+ id: RecordId<string>;
265
+ };
266
+ interface QueryConfig$1 {
267
+ queryHash: string;
268
+ surql: string;
269
+ params: Record<string, any>;
270
+ ttl: QueryTimeToLive | Duration;
271
+ lastActiveAt: Date;
272
+ }
273
+ interface CacheRecord {
274
+ table: string;
275
+ op: 'CREATE' | 'UPDATE' | 'DELETE';
276
+ record: RecordWithId;
277
+ version: number;
278
+ }
279
+ //#endregion
280
+ //#region src/modules/cache/index.d.ts
281
+ /**
282
+ * CacheModule - Centralized storage and DBSP ingestion
283
+ *
284
+ * Single responsibility: Handle all local storage operations and DBSP ingestion.
285
+ * This module acts as the bridge between data operations and persistence.
286
+ */
287
+ declare class CacheModule implements StreamUpdateReceiver {
288
+ private local;
289
+ private streamProcessor;
290
+ private logger;
291
+ private streamUpdateCallback;
292
+ private versionLookups;
293
+ constructor(local: LocalDatabaseService, streamProcessor: StreamProcessorService, streamUpdateCallback: (update: StreamUpdate) => void, logger: Logger$1);
294
+ /**
295
+ * Implements StreamUpdateReceiver interface
296
+ * Called directly by StreamProcessor when views change
297
+ */
298
+ onStreamUpdate(update: StreamUpdate): void;
299
+ lookup(recordId: string): number;
300
+ /**
301
+ * Save a single record to local DB and ingest into DBSP
302
+ * Used by mutations (create/update)
303
+ */
304
+ save(cacheRecord: CacheRecord, skipDbInsert?: boolean): Promise<void>;
305
+ /**
306
+ * Save multiple records in a batch
307
+ * More efficient than calling save() multiple times
308
+ * Used by sync operations
309
+ */
310
+ saveBatch(records: CacheRecord[], skipDbInsert?: boolean): Promise<void>;
311
+ /**
312
+ * Delete a record from local DB and ingest deletion into DBSP
313
+ */
314
+ delete(table: string, id: string, skipDbDelete?: boolean, recordData?: Record<string, any>): Promise<void>;
315
+ /**
316
+ * Register a query with DBSP to create a materialized view
317
+ * Returns the initial result array
318
+ */
319
+ registerQuery(config: QueryConfig$1): {
320
+ localArray: RecordVersionArray;
321
+ registrationTimings?: {
322
+ parseMs: number;
323
+ planMs: number;
324
+ snapshotMs: number;
325
+ };
326
+ };
327
+ /**
328
+ * Unregister a query from DBSP
329
+ */
330
+ unregisterQuery(queryHash: string): void;
331
+ }
332
+ //#endregion
333
+ //#region src/modules/data/index.d.ts
334
+ /**
335
+ * DataModule - Unified query and mutation management
336
+ *
337
+ * Merges the functionality of QueryManager and MutationManager.
338
+ * Uses CacheModule for all storage operations.
339
+ */
340
+ declare class DataModule<S extends SchemaStructure> {
341
+ private cache;
342
+ private local;
343
+ private schema;
344
+ private streamDebounceTime;
345
+ private activeQueries;
346
+ private pendingQueries;
347
+ private subscriptions;
348
+ private statusSubscriptions;
349
+ private mutationCallbacks;
350
+ private debounceTimers;
351
+ private logger;
352
+ /**
353
+ * Optional observer notified whenever a query's fetch status changes.
354
+ * Wired by Sp00kyClient to push status changes into DevTools. Kept as a
355
+ * settable field (rather than a constructor arg) because DevTools is
356
+ * constructed after DataModule.
357
+ */
358
+ onQueryStatusChange?: (hash: QueryHash, status: QueryStatus) => void;
359
+ /**
360
+ * Optional observer invoked when a still-subscribed query's TTL heartbeat
361
+ * fires (~90% of the TTL). Wired by Sp00kyClient to
362
+ * `Sp00kySync.heartbeatQuery`, which refreshes the remote `_00_query`
363
+ * row's `lastActiveAt` so an actively-watched query never expires. Settable
364
+ * field (not a constructor arg) because the sync engine is wired after
365
+ * DataModule is constructed — mirrors `onQueryStatusChange`.
366
+ */
367
+ onHeartbeat?: (hash: QueryHash) => void;
368
+ /**
369
+ * Optional hook fired by {@link deregisterQuery} when an opt-in query (e.g. a
370
+ * viewport-windowed list cancelling an off-screen window) loses its last
371
+ * subscriber. Wired by Sp00kyClient to enqueue a `cleanup` down-event, which
372
+ * tears the remote `_00_query` view down (releasing its `_00_list_ref` edges)
373
+ * instead of leaving it for the TTL sweep. The local view + state are freed in
374
+ * {@link finalizeDeregister} only after that remote delete, so a fast
375
+ * re-subscribe (scroll back) can abort/heal the teardown — see `cleanupQuery`.
376
+ */
377
+ onDeregister?: (hash: QueryHash) => void;
378
+ private sessionId;
379
+ private currentUserId;
380
+ constructor(cache: CacheModule, local: LocalDatabaseService, schema: S, logger: Logger$1, streamDebounceTime?: number);
381
+ init(sessionId: string): Promise<void>;
382
+ /**
383
+ * Update the session salt used in query-id hashing. Call this when the
384
+ * SurrealDB session changes (sign-in, sign-out, reconnect). Subsequently
385
+ * registered queries will get fresh, session-scoped IDs.
386
+ */
387
+ setSessionId(sessionId: string): void;
388
+ /**
389
+ * Update the authenticated user record id. Pass `null` on sign-out.
390
+ * Read by `Sp00kySync.listRefTable()` so the LIVE subscription and
391
+ * the poll route to the same per-user `_00_list_ref_user_<id>` the
392
+ * SSP writes to.
393
+ */
394
+ setCurrentUserId(userId: string | null): void;
395
+ /** Read-only view of the authenticated user id used for per-user
396
+ * `_00_list_ref` routing. Other modules consult this so they pick the
397
+ * same table name DataModule does. */
398
+ getCurrentUserId(): string | null;
399
+ /**
400
+ * Register a query and return its hash for subscriptions
401
+ */
402
+ query<T extends TableNames<S>>(tableName: T, surqlString: string, params: Record<string, any>, ttl: QueryTimeToLive): Promise<QueryHash>;
403
+ /**
404
+ * Subscribe to query updates
405
+ */
406
+ subscribe(queryHash: string, callback: QueryUpdateCallback, options?: {
407
+ immediate?: boolean;
408
+ }): () => void;
409
+ /**
410
+ * Subscribe to a query's fetch-status changes (idle/fetching).
411
+ * With `{ immediate: true }` the callback fires synchronously with the
412
+ * current status (defaults to `idle` if the query isn't registered yet).
413
+ */
414
+ subscribeStatus(queryHash: string, callback: QueryStatusCallback, options?: {
415
+ immediate?: boolean;
416
+ }): () => void;
417
+ /**
418
+ * Set a query's fetch status and notify status observers (DevTools +
419
+ * `subscribeStatus` listeners). No-op when the status is unchanged or the
420
+ * query is unknown.
421
+ */
422
+ setQueryStatus(queryHash: string, status: QueryStatus): void;
423
+ /**
424
+ * Subscribe to mutations (for sync)
425
+ */
426
+ onMutation(callback: MutationCallback): () => void;
427
+ /**
428
+ * Handle stream updates from DBSP (via CacheModule)
429
+ */
430
+ onStreamUpdate(update: StreamUpdate): Promise<void>;
431
+ private materializeRecords;
432
+ private processStreamUpdate;
433
+ /**
434
+ * Compute p55/p90/p99 from a rolling window of materialization samples.
435
+ * Returns nulls for any percentile that has no samples yet so SurrealDB
436
+ * `option<float>` columns stay NONE rather than 0 before the first ingest.
437
+ */
438
+ private computeMaterializationPercentiles;
439
+ /** Record a per-phase timing sample (ms) on a query's rolling window. */
440
+ private recordPhase;
441
+ /** Record the remote record-fetch time (ms) for a query. Called by the sync engine. */
442
+ recordRemoteFetch(hash: string, ms: number): void;
443
+ /**
444
+ * Record the frontend reconcile time (ms) for a query. Called from `useQuery`
445
+ * via `Sp00kyClient.reportFrontendTiming` after it applies an update to its store.
446
+ */
447
+ recordFrontendTiming(hash: string, ms: number): void;
448
+ /**
449
+ * Build the per-query processing-time breakdown surfaced to the DevTools panel
450
+ * and the MCP. `ssp` is the WASM-ingest wall time (from `materializationSamples`);
451
+ * the rest come from the per-phase rolling windows + one-shot registration timings.
452
+ */
453
+ phaseTimings(q: QueryState): QueryTimings;
454
+ /**
455
+ * Get query state (for sync and devtools)
456
+ */
457
+ getQueryByHash(hash: string): QueryState | undefined;
458
+ /**
459
+ * Cold-query guard for instant-hydrate: true when the query exists, hasn't been
460
+ * hydrated, and has NOT yet fetched its server result (`remoteArray` empty).
461
+ * We gate on `remoteArray`, not local `records`: a windowed query is often
462
+ * partially pre-seeded from the circuit (e.g. the dashboard's 5-row preview),
463
+ * but it still hasn't loaded its own full window from the server — so it should
464
+ * still hydrate. A warm re-subscribe (remoteArray already populated) is skipped.
465
+ */
466
+ isCold(hash: string): boolean;
467
+ /**
468
+ * Instant-hydrate: ingest rows fetched one-shot from the remote (the query's own
469
+ * surql run directly) so the query DISPLAYS immediately, while the full realtime
470
+ * registration proceeds in the background. Ingests with versions (`_00_rv`) so the
471
+ * later `syncRecords` dedup skips re-pulling unchanged bodies, and seeds
472
+ * `remoteArray` so windowed queries materialize the correct window (no sparse
473
+ * local-circuit issue). Runs at most once per query (the `hydrated` flag).
474
+ */
475
+ applyHydration(hash: string, rows: RecordWithId[]): Promise<void>;
476
+ /** True while ≥1 live subscriber is watching this query (refcount guard). */
477
+ hasSubscribers(hash: string): boolean;
478
+ /**
479
+ * Opt-in eager teardown for a query whose LAST subscriber just left — used by
480
+ * viewport-windowed lists to cancel off-screen windows instead of leaving
481
+ * their remote views to expire on the TTL sweep. No-op while any subscriber
482
+ * remains (refcount). Only enqueues the remote cleanup here; the local WASM
483
+ * view + in-memory state are freed in {@link finalizeDeregister} after the
484
+ * remote delete completes, so a re-subscribe in between aborts/heals it.
485
+ *
486
+ * NOTE: most queries should NOT use this — the default keep-alive on
487
+ * unsubscribe avoids re-registration churn on navigation.
488
+ */
489
+ deregisterQuery(hash: string): void;
490
+ /**
491
+ * Final local teardown after the remote `_00_query` row was deleted: free the
492
+ * WASM view, heartbeat timer, debounce timer, and in-memory state. Caller
493
+ * (`cleanupQuery`) guarantees no subscriber remains.
494
+ */
495
+ finalizeDeregister(hash: string): void;
496
+ /**
497
+ * Get query state by id (for sync and devtools)
498
+ */
499
+ getQueryById(id: RecordId<string>): QueryState | undefined;
500
+ /**
501
+ * Get all active queries (for devtools)
502
+ */
503
+ getActiveQueries(): QueryState[];
504
+ getActiveQueryHashes(): QueryHash[];
505
+ updateQueryLocalArray(id: string, localArray: RecordVersionArray): Promise<void>;
506
+ updateQueryRemoteArray(hash: string, remoteArray: RecordVersionArray): Promise<void>;
507
+ /**
508
+ * Called after a query's initial sync completes.
509
+ * Ensures subscribers are notified even if no stream updates fired (e.g. empty result set).
510
+ */
511
+ notifyQuerySynced(queryHash: string): Promise<void>;
512
+ run<B extends BackendNames<S>, R extends BackendRoutes<S, B>>(backend: B, path: R, data: RoutePayload<S, B, R>, options?: RunOptions): Promise<void>;
513
+ /**
514
+ * Create a new record
515
+ */
516
+ create<T extends Record<string, unknown>>(id: string, data: T): Promise<T>;
517
+ /**
518
+ * Update an existing record
519
+ */
520
+ update<T extends Record<string, unknown>>(table: string, id: string, data: Partial<T>, options?: UpdateOptions): Promise<T>;
521
+ /**
522
+ * Delete a record
523
+ */
524
+ delete(table: string, id: string): Promise<void>;
525
+ /**
526
+ * Rollback a failed optimistic create by deleting the record locally
527
+ */
528
+ rollbackCreate(recordId: RecordId, tableName: string): Promise<void>;
529
+ /**
530
+ * Rollback a failed optimistic update by restoring the previous record state
531
+ */
532
+ rollbackUpdate(recordId: RecordId, tableName: string, beforeRecord: Record<string, unknown>): Promise<void>;
533
+ /**
534
+ * Remove a record from all active query states and notify subscribers
535
+ */
536
+ private removeRecordFromQueries;
537
+ private createAndRegisterQuery;
538
+ private createNewQuery;
539
+ private calculateHash;
540
+ private startTTLHeartbeat;
541
+ private replaceRecordInQueries;
542
+ }
543
+ /**
544
+ * Parse update options to generate push event options
545
+ */
546
+ //#endregion
547
+ //#region src/modules/sync/sync.d.ts
548
+ /**
549
+ * Tunables for `Sp00kySync` construction.
550
+ */
551
+ interface Sp00kySyncOptions {
552
+ /**
553
+ * Cadence (ms) for the `_00_list_ref` poll fallback that catches
554
+ * cross-session UPDATEs the LIVE-permission gap drops. Non-positive
555
+ * values fall back to the default; see
556
+ * {@link resolveListRefPollInterval}.
557
+ */
558
+ refSyncIntervalMs?: number;
559
+ }
560
+ /**
561
+ * The main synchronization engine for Sp00ky.
562
+ * Handles the bidirectional synchronization between the local database and the remote backend.
563
+ * Uses a queue-based architecture with 'up' (local to remote) and 'down' (remote to local) queues.
564
+ * @template S The schema structure type.
565
+ */
566
+ declare class Sp00kySync<S extends SchemaStructure> {
567
+ private local;
568
+ private remote;
569
+ private cache;
570
+ private dataModule;
571
+ private schema;
572
+ private upQueue;
573
+ private downQueue;
574
+ private isInit;
575
+ private logger;
576
+ private syncEngine;
577
+ /** Engine-level events (e.g. `SYNC_REMOTE_DATA_INGESTED`). Distinct
578
+ * from `this.events`, which carries Sp00kySync-level events like
579
+ * `SYNC_QUERY_UPDATED` and `SYNC_MUTATION_ROLLED_BACK`. */
580
+ get engineEvents(): SyncEventSystem;
581
+ private scheduler;
582
+ private wasDisconnected;
583
+ events: SyncEventSystem;
584
+ private currentUserId;
585
+ private refMode;
586
+ private currentLiveQueryUuid;
587
+ private liveQueryUnsubscribe;
588
+ private listRefPollTimer;
589
+ private listRefPollRunning;
590
+ readonly refSyncIntervalMs: number;
591
+ private listRefIdleStreak;
592
+ private stillRemoteStreaks;
593
+ private lastLiveEventAt;
594
+ private _liveRetryCount;
595
+ get liveRetryCount(): number;
596
+ get isSyncing(): boolean;
597
+ get pendingMutationCount(): number;
598
+ subscribeToPendingMutations(cb: (count: number) => void): () => void;
599
+ constructor(local: LocalDatabaseService, remote: RemoteDatabaseService, cache: CacheModule, dataModule: DataModule<S>, schema: S, logger: Logger$1, options?: Sp00kySyncOptions);
600
+ /**
601
+ * Initializes the synchronization system.
602
+ * Starts the scheduler and initiates the initial sync cycles.
603
+ * @throws Error if already initialized.
604
+ */
605
+ init(): Promise<void>;
606
+ /**
607
+ * Push the authenticated user's record id from the parent client's
608
+ * auth subscription. Tears down the existing `_00_list_ref` LIVE (if
609
+ * any) and re-registers it under the new user's dedicated table so
610
+ * SurrealDB binds the permission rule under the post-flip auth
611
+ * context. Pass `null` on sign-out.
612
+ *
613
+ * The dedicated `_00_list_ref_user_<id>` table is created lazily by
614
+ * the SSP when the first query registration arrives, which may be
615
+ * concurrent with this call. We retry the LIVE registration with a
616
+ * short backoff so a "table not found" race resolves without
617
+ * surfacing as a permanent auth-loading hang.
618
+ */
619
+ setCurrentUserId(userId: string | null): Promise<void>;
620
+ private startListRefPoll;
621
+ private stopListRefPoll;
622
+ /**
623
+ * One poll cycle: refetch `_00_list_ref` for every active query. Returns
624
+ * whether ANY query's remoteArray actually changed — the scheduler uses this
625
+ * to drive the adaptive idle backoff.
626
+ */
627
+ private pollListRefForActiveQueries;
628
+ /**
629
+ * Pull the upstream list_ref entries for `queryHash`, diff them
630
+ * against the local `remoteArray` cache, sync any added/updated rows
631
+ * through the SyncEngine, then persist the new remoteArray. This is
632
+ * the same shape `createRemoteQuery` does for its initial fetch and
633
+ * what `handleRemoteListRefChange` does per-LIVE-event — we reuse
634
+ * it on a timer as a fallback for missed LIVE notifications.
635
+ */
636
+ private refetchListRefForQuery;
637
+ /**
638
+ * Resolve the current `_00_list_ref` table name for the active auth
639
+ * context. Public so the `createRemoteQuery` initial-fetch path can
640
+ * read from the right per-user table.
641
+ *
642
+ * Reads the user id from `DataModule` rather than the local mirror,
643
+ * because `DataModule.setCurrentUserId` runs synchronously from the
644
+ * auth callback (before any `await`), whereas `sync.setCurrentUserId`
645
+ * is async — the userQuery's initial fetch can fire between those
646
+ * two points and we need the correct table name immediately.
647
+ */
648
+ listRefTable(): string;
649
+ private killRefLiveQuery;
650
+ private restartRefLiveQuery;
651
+ private subscribeToReconnect;
652
+ private startRefLiveQueries;
653
+ private handleRemoteListRefChange;
654
+ /**
655
+ * Enqueues a 'down' event (from remote to local) for processing.
656
+ * @param event The DownEvent to enqueue.
657
+ */
658
+ enqueueDownEvent(event: DownEvent): void;
659
+ private processUpEvent;
660
+ private handleRollback;
661
+ private processDownEvent;
662
+ /**
663
+ * Synchronizes a specific query by hash.
664
+ * Compares local and remote version arrays and fetches differences.
665
+ * @param hash The hash of the query to sync.
666
+ */
667
+ syncQuery(hash: string): Promise<void>;
668
+ /**
669
+ * Run a sync for a single query while reflecting its fetch status. Marks the
670
+ * query `fetching` for the duration when the diff actually pulls records
671
+ * (added/updated), then resets to `idle` in a `finally` so a failed sync
672
+ * never leaves a query stuck `fetching`. Part A's notification coalescing
673
+ * means the single resulting UI update lands after this completes.
674
+ */
675
+ private runSyncForQuery;
676
+ /**
677
+ * Record ids with a pending local DELETE in the outbox (`_00_pending_mutations`).
678
+ * Sync must not re-fetch/re-insert these — the remote delete is async, so the
679
+ * server's `_00_list_ref` still lists them until it's processed, and the diff
680
+ * would otherwise resurrect a just-deleted record.
681
+ */
682
+ private getPendingDeleteIds;
683
+ /**
684
+ * Enqueues a list of mutations (up events) to be sent to the remote.
685
+ * @param mutations Array of UpEvents (create/update/delete) to enqueue.
686
+ */
687
+ enqueueMutation(mutations: UpEvent[]): Promise<void>;
688
+ private registerQuery;
689
+ private createRemoteQuery;
690
+ heartbeatQuery(queryHash: string): Promise<void>;
691
+ private cleanupQuery;
692
+ }
693
+ //#endregion
235
694
  //#region src/modules/auth/events/index.d.ts
236
695
  declare const AuthEventTypes: {
237
696
  readonly AuthStateChanged: "AUTH_STATE_CHANGED";
@@ -423,6 +882,54 @@ declare class CrdtManager {
423
882
  private assertCrdtField;
424
883
  }
425
884
  //#endregion
885
+ //#region src/modules/feature-flag/index.d.ts
886
+ interface FeatureFlagSnapshot {
887
+ variant: string | undefined;
888
+ payload: unknown | undefined;
889
+ }
890
+ interface FeatureFlagOptions {
891
+ fallback?: string;
892
+ ttl?: QueryTimeToLive;
893
+ }
894
+ declare class FeatureFlagHandle {
895
+ readonly key: string;
896
+ readonly fallback: string | undefined;
897
+ private latest;
898
+ private listeners;
899
+ private unsubscribeFn;
900
+ private onCloseFn;
901
+ private closed;
902
+ constructor(key: string, fallback: string | undefined);
903
+ attach(unsubscribe: () => void): void;
904
+ detach(): void;
905
+ set(snapshot: FeatureFlagSnapshot): void;
906
+ variant(): string | undefined;
907
+ payload<T = unknown>(): T | undefined;
908
+ enabled(): boolean;
909
+ subscribe(cb: (s: FeatureFlagSnapshot) => void): () => void;
910
+ onClose(cb: () => void): void;
911
+ close(): void;
912
+ }
913
+ interface FeatureFlagModuleDeps<S extends SchemaStructure> {
914
+ dataModule: DataModule<S>;
915
+ sync: Sp00kySync<S>;
916
+ auth: AuthService<S>;
917
+ logger: Logger$1;
918
+ }
919
+ declare class FeatureFlagModule<S extends SchemaStructure> {
920
+ private deps;
921
+ private logger;
922
+ private active;
923
+ private authUnsubscribe;
924
+ private lastUserId;
925
+ constructor(deps: FeatureFlagModuleDeps<S>);
926
+ init(): void;
927
+ feature(key: string, options?: FeatureFlagOptions): FeatureFlagHandle;
928
+ closeAll(): Promise<void>;
929
+ private refreshAll;
930
+ private register;
931
+ }
932
+ //#endregion
426
933
  //#region src/sp00ky.d.ts
427
934
  declare class BucketHandle {
428
935
  private bucketName;
@@ -448,6 +955,7 @@ declare class Sp00kyClient<S extends SchemaStructure> {
448
955
  private sync;
449
956
  private devTools;
450
957
  private crdtManager;
958
+ private featureFlags;
451
959
  private logger;
452
960
  auth: AuthService<S>;
453
961
  streamProcessor: StreamProcessorService;
@@ -468,6 +976,16 @@ declare class Sp00kyClient<S extends SchemaStructure> {
468
976
  private setupCallbacks;
469
977
  init(): Promise<void>;
470
978
  close(): Promise<void>;
979
+ /**
980
+ * Subscribe to a feature flag for the current user. Returns a
981
+ * `FeatureFlagHandle` whose `variant()`, `payload()` and `enabled()`
982
+ * accessors reflect the latest assignment from `_00_user_feature`,
983
+ * and whose `subscribe(cb)` fires whenever that assignment changes.
984
+ *
985
+ * Permissions are enforced by SurrealDB: a client can only ever see
986
+ * its own row, and cannot create or modify assignments.
987
+ */
988
+ feature(key: string, options?: FeatureFlagOptions): FeatureFlagHandle;
471
989
  authenticate(token: string): Promise<surrealdb0.Tokens>;
472
990
  /**
473
991
  * Open a CRDT field for collaborative editing.
@@ -538,4 +1056,4 @@ declare function textToHtml(text: string): string;
538
1056
  */
539
1057
 
540
1058
  //#endregion
541
- export { AuthEventSystem, AuthEventTypeMap, AuthEventTypes, AuthService, BucketHandle, CURSOR_COLORS, CrdtField, CrdtManager, DebounceOptions, EventSubscriptionOptions, Level, MATERIALIZATION_SAMPLE_WINDOW, MutationCallback, MutationEvent, MutationEventType, PersistenceClient, PhaseStat, PinoTransmit, QueryConfig, QueryConfigRecord, QueryHash, QueryState, QueryStatus, QueryStatusCallback, QueryTimeToLive, QueryTimings, QueryUpdateCallback, RecordVersionArray, RecordVersionDiff, RegistrationTimings, RunOptions, Sp00kyClient, Sp00kyConfig, Sp00kyQueryResult, Sp00kyQueryResultPromise, StoreType, TimingPhase, UpdateOptions, createAuthEventSystem, cursorColorFromName, fileToUint8Array, textToHtml };
1059
+ export { AuthEventSystem, AuthEventTypeMap, AuthEventTypes, AuthService, BucketHandle, CURSOR_COLORS, CrdtField, CrdtManager, DebounceOptions, EventSubscriptionOptions, FeatureFlagHandle, FeatureFlagModule, type FeatureFlagOptions, type FeatureFlagSnapshot, Level, MATERIALIZATION_SAMPLE_WINDOW, MutationCallback, MutationEvent, MutationEventType, PersistenceClient, PhaseStat, PinoTransmit, QueryConfig, QueryConfigRecord, QueryHash, QueryState, QueryStatus, QueryStatusCallback, QueryTimeToLive, QueryTimings, QueryUpdateCallback, RecordVersionArray, RecordVersionDiff, RegistrationTimings, RunOptions, Sp00kyClient, Sp00kyConfig, Sp00kyQueryResult, Sp00kyQueryResultPromise, StoreType, TimingPhase, UpdateOptions, createAuthEventSystem, cursorColorFromName, fileToUint8Array, textToHtml };
package/dist/index.js CHANGED
@@ -3171,8 +3171,8 @@ function parseBackendInfo(raw) {
3171
3171
 
3172
3172
  //#endregion
3173
3173
  //#region src/modules/devtools/index.ts
3174
- const CORE_VERSION = "0.0.1-canary.73";
3175
- const WASM_VERSION = "0.0.1-canary.73";
3174
+ const CORE_VERSION = "0.0.1-canary.75";
3175
+ const WASM_VERSION = "0.0.1-canary.75";
3176
3176
  const SURREAL_VERSION = "3.0.3";
3177
3177
  var DevToolsService = class {
3178
3178
  eventsHistory = [];
@@ -4626,6 +4626,135 @@ var CrdtManager = class {
4626
4626
  }
4627
4627
  };
4628
4628
 
4629
+ //#endregion
4630
+ //#region src/modules/feature-flag/index.ts
4631
+ const FEATURE_QUERY = "SELECT key, variant, payload FROM _00_user_feature WHERE key = $key";
4632
+ var FeatureFlagHandle = class {
4633
+ latest = {
4634
+ variant: void 0,
4635
+ payload: void 0
4636
+ };
4637
+ listeners = /* @__PURE__ */ new Set();
4638
+ unsubscribeFn = null;
4639
+ onCloseFn = null;
4640
+ closed = false;
4641
+ constructor(key, fallback) {
4642
+ this.key = key;
4643
+ this.fallback = fallback;
4644
+ }
4645
+ attach(unsubscribe) {
4646
+ this.unsubscribeFn?.();
4647
+ this.unsubscribeFn = unsubscribe;
4648
+ }
4649
+ detach() {
4650
+ this.unsubscribeFn?.();
4651
+ this.unsubscribeFn = null;
4652
+ }
4653
+ set(snapshot) {
4654
+ if (this.closed) return;
4655
+ this.latest = snapshot;
4656
+ for (const cb of this.listeners) cb(snapshot);
4657
+ }
4658
+ variant() {
4659
+ return this.latest.variant ?? this.fallback;
4660
+ }
4661
+ payload() {
4662
+ return this.latest.payload;
4663
+ }
4664
+ enabled() {
4665
+ const v = this.variant();
4666
+ return v !== void 0 && v !== "off";
4667
+ }
4668
+ subscribe(cb) {
4669
+ this.listeners.add(cb);
4670
+ cb({
4671
+ variant: this.variant(),
4672
+ payload: this.latest.payload
4673
+ });
4674
+ return () => {
4675
+ this.listeners.delete(cb);
4676
+ };
4677
+ }
4678
+ onClose(cb) {
4679
+ this.onCloseFn = cb;
4680
+ }
4681
+ close() {
4682
+ if (this.closed) return;
4683
+ this.closed = true;
4684
+ this.listeners.clear();
4685
+ this.detach();
4686
+ this.onCloseFn?.();
4687
+ }
4688
+ };
4689
+ var FeatureFlagModule = class {
4690
+ logger;
4691
+ active = /* @__PURE__ */ new Set();
4692
+ authUnsubscribe = null;
4693
+ lastUserId = null;
4694
+ constructor(deps) {
4695
+ this.deps = deps;
4696
+ this.logger = deps.logger.child({ service: "FeatureFlagModule" });
4697
+ }
4698
+ init() {
4699
+ if (this.authUnsubscribe) return;
4700
+ this.authUnsubscribe = this.deps.auth.subscribe((userId) => {
4701
+ if (userId === this.lastUserId) return;
4702
+ this.lastUserId = userId;
4703
+ this.refreshAll();
4704
+ });
4705
+ }
4706
+ feature(key, options = {}) {
4707
+ const handle = new FeatureFlagHandle(key, options.fallback);
4708
+ const entry = {
4709
+ handle,
4710
+ ttl: options.ttl ?? "10m"
4711
+ };
4712
+ this.active.add(entry);
4713
+ handle.onClose(() => this.active.delete(entry));
4714
+ this.register(entry);
4715
+ return handle;
4716
+ }
4717
+ async closeAll() {
4718
+ this.authUnsubscribe?.();
4719
+ this.authUnsubscribe = null;
4720
+ for (const entry of [...this.active]) entry.handle.close();
4721
+ }
4722
+ async refreshAll() {
4723
+ for (const entry of this.active) {
4724
+ entry.handle.detach();
4725
+ entry.handle.set({
4726
+ variant: void 0,
4727
+ payload: void 0
4728
+ });
4729
+ await this.register(entry);
4730
+ }
4731
+ }
4732
+ async register(entry) {
4733
+ const { handle, ttl } = entry;
4734
+ try {
4735
+ const hash = await this.deps.dataModule.query("_00_user_feature", FEATURE_QUERY, { key: handle.key }, ttl);
4736
+ this.deps.sync.enqueueDownEvent({
4737
+ type: "register",
4738
+ payload: { hash }
4739
+ });
4740
+ const unsub = this.deps.dataModule.subscribe(hash, (records) => {
4741
+ const row = records[0];
4742
+ handle.set({
4743
+ variant: row?.variant,
4744
+ payload: row?.payload
4745
+ });
4746
+ }, { immediate: true });
4747
+ handle.attach(unsub);
4748
+ } catch (err) {
4749
+ this.logger.warn({
4750
+ err,
4751
+ key: handle.key,
4752
+ Category: "sp00ky-client::FeatureFlagModule::register"
4753
+ }, "Failed to register feature flag query");
4754
+ }
4755
+ }
4756
+ };
4757
+
4629
4758
  //#endregion
4630
4759
  //#region src/services/persistence/localstorage.ts
4631
4760
  var LocalStoragePersistenceClient = class {
@@ -4780,6 +4909,7 @@ var Sp00kyClient = class {
4780
4909
  sync;
4781
4910
  devTools;
4782
4911
  crdtManager;
4912
+ featureFlags;
4783
4913
  logger;
4784
4914
  auth;
4785
4915
  streamProcessor;
@@ -4829,6 +4959,12 @@ var Sp00kyClient = class {
4829
4959
  this.dataModule = new DataModule(this.cache, this.local, this.config.schema, logger, this.config.streamDebounceTime);
4830
4960
  this.auth = new AuthService(this.config.schema, this.remote, this.persistenceClient, logger);
4831
4961
  this.sync = new Sp00kySync(this.local, this.remote, this.cache, this.dataModule, this.config.schema, this.logger, { refSyncIntervalMs: this.config.refSyncIntervalMs });
4962
+ this.featureFlags = new FeatureFlagModule({
4963
+ dataModule: this.dataModule,
4964
+ sync: this.sync,
4965
+ auth: this.auth,
4966
+ logger
4967
+ });
4832
4968
  this.devTools = new DevToolsService(this.local, this.remote, logger, this.config.schema, this.auth, this.dataModule);
4833
4969
  this.streamProcessor.addReceiver(this.devTools);
4834
4970
  this.setupCallbacks();
@@ -4925,6 +5061,8 @@ var Sp00kyClient = class {
4925
5061
  });
4926
5062
  await this.sync.init();
4927
5063
  this.logger.debug({ Category: "sp00ky-client::Sp00kyClient::init" }, "Sync initialized");
5064
+ this.featureFlags.init();
5065
+ this.logger.debug({ Category: "sp00ky-client::Sp00kyClient::init" }, "FeatureFlagModule initialized");
4928
5066
  this.logger.info({ Category: "sp00ky-client::Sp00kyClient::init" }, "Sp00kyClient initialization completed successfully");
4929
5067
  } catch (e) {
4930
5068
  this.logger.error({
@@ -4935,10 +5073,23 @@ var Sp00kyClient = class {
4935
5073
  }
4936
5074
  }
4937
5075
  async close() {
5076
+ await this.featureFlags.closeAll();
4938
5077
  this.crdtManager.closeAll();
4939
5078
  await this.local.close();
4940
5079
  await this.remote.close();
4941
5080
  }
5081
+ /**
5082
+ * Subscribe to a feature flag for the current user. Returns a
5083
+ * `FeatureFlagHandle` whose `variant()`, `payload()` and `enabled()`
5084
+ * accessors reflect the latest assignment from `_00_user_feature`,
5085
+ * and whose `subscribe(cb)` fires whenever that assignment changes.
5086
+ *
5087
+ * Permissions are enforced by SurrealDB: a client can only ever see
5088
+ * its own row, and cannot create or modify assignments.
5089
+ */
5090
+ feature(key, options) {
5091
+ return this.featureFlags.feature(key, options);
5092
+ }
4942
5093
  authenticate(token) {
4943
5094
  return this.remote.getClient().authenticate(token);
4944
5095
  }
@@ -5056,4 +5207,4 @@ var Sp00kyClient = class {
5056
5207
  };
5057
5208
 
5058
5209
  //#endregion
5059
- export { AuthEventTypes, AuthService, BucketHandle, CURSOR_COLORS, CrdtField, CrdtManager, MATERIALIZATION_SAMPLE_WINDOW, Sp00kyClient, createAuthEventSystem, cursorColorFromName, fileToUint8Array, textToHtml };
5210
+ export { AuthEventTypes, AuthService, BucketHandle, CURSOR_COLORS, CrdtField, CrdtManager, FeatureFlagHandle, FeatureFlagModule, MATERIALIZATION_SAMPLE_WINDOW, Sp00kyClient, createAuthEventSystem, cursorColorFromName, fileToUint8Array, textToHtml };
package/dist/types.d.ts CHANGED
@@ -123,6 +123,32 @@ declare class EventSystem<E extends EventTypeMap> {
123
123
  private broadcastEvent;
124
124
  }
125
125
  //#endregion
126
+ //#region src/modules/sync/events/index.d.ts
127
+ declare const SyncEventTypes: {
128
+ readonly QueryUpdated: "SYNC_QUERY_UPDATED";
129
+ readonly RemoteDataIngested: "SYNC_REMOTE_DATA_INGESTED";
130
+ readonly MutationRolledBack: "SYNC_MUTATION_ROLLED_BACK";
131
+ };
132
+ type SyncEventTypeMap = {
133
+ [SyncEventTypes.QueryUpdated]: EventDefinition<typeof SyncEventTypes.QueryUpdated, {
134
+ queryId: any;
135
+ localHash?: string;
136
+ localArray?: RecordVersionArray;
137
+ remoteHash?: string;
138
+ remoteArray?: RecordVersionArray;
139
+ records: Record<string, any>[];
140
+ }>;
141
+ [SyncEventTypes.RemoteDataIngested]: EventDefinition<typeof SyncEventTypes.RemoteDataIngested, {
142
+ records: Record<string, any>[];
143
+ }>;
144
+ [SyncEventTypes.MutationRolledBack]: EventDefinition<typeof SyncEventTypes.MutationRolledBack, {
145
+ eventType: string;
146
+ recordId: string;
147
+ error: string;
148
+ }>;
149
+ };
150
+ type SyncEventSystem = EventSystem<SyncEventTypeMap>;
151
+ //#endregion
126
152
  //#region src/services/logger/index.d.ts
127
153
  type Logger$1 = Logger;
128
154
  //#endregion
@@ -457,4 +483,4 @@ interface DebounceOptions {
457
483
  delay?: number;
458
484
  }
459
485
  //#endregion
460
- export { Logger$1 as A, RunOptions as C, StoreType as D, Sp00kyQueryResultPromise as E, EventSystem as M, TimingPhase as O, RegistrationTimings as S, Sp00kyQueryResult as T, QueryTimeToLive as _, MutationCallback as a, RecordVersionArray as b, PersistenceClient as c, QueryConfig as d, QueryConfigRecord as f, QueryStatusCallback as g, QueryStatus as h, MATERIALIZATION_SAMPLE_WINDOW as i, EventDefinition as j, UpdateOptions as k, PhaseStat as l, QueryState as m, EventSubscriptionOptions as n, MutationEvent as o, QueryHash as p, Level$1 as r, MutationEventType as s, DebounceOptions as t, PinoTransmit as u, QueryTimings as v, Sp00kyConfig as w, RecordVersionDiff as x, QueryUpdateCallback as y };
486
+ export { UpEvent as A, RunOptions as C, StoreType as D, Sp00kyQueryResultPromise as E, SyncEventSystem as M, EventDefinition as N, TimingPhase as O, EventSystem as P, RegistrationTimings as S, Sp00kyQueryResult as T, QueryTimeToLive as _, MutationCallback as a, RecordVersionArray as b, PersistenceClient as c, QueryConfig as d, QueryConfigRecord as f, QueryStatusCallback as g, QueryStatus as h, MATERIALIZATION_SAMPLE_WINDOW as i, Logger$1 as j, UpdateOptions as k, PhaseStat as l, QueryState as m, EventSubscriptionOptions as n, MutationEvent as o, QueryHash as p, Level$1 as r, MutationEventType as s, DebounceOptions as t, PinoTransmit as u, QueryTimings as v, Sp00kyConfig as w, RecordVersionDiff as x, QueryUpdateCallback as y };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spooky-sync/core",
3
- "version": "0.0.1-canary.73",
3
+ "version": "0.0.1-canary.75",
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.73",
63
- "@spooky-sync/ssp-wasm": "0.0.1-canary.73",
62
+ "@spooky-sync/query-builder": "0.0.1-canary.75",
63
+ "@spooky-sync/ssp-wasm": "0.0.1-canary.75",
64
64
  "@surrealdb/wasm": "^3.0.3",
65
65
  "fast-json-patch": "^3.1.1",
66
66
  "loro-crdt": "^1.5.6",
package/src/index.ts CHANGED
@@ -2,4 +2,10 @@ export * from './types';
2
2
  export * from './sp00ky';
3
3
  export * from './modules/auth/index';
4
4
  export { CrdtField, CrdtManager, cursorColorFromName, CURSOR_COLORS } from './modules/crdt/index';
5
+ export {
6
+ FeatureFlagModule,
7
+ FeatureFlagHandle,
8
+ type FeatureFlagOptions,
9
+ type FeatureFlagSnapshot,
10
+ } from './modules/feature-flag/index';
5
11
  export { fileToUint8Array, textToHtml } from './utils/index';
@@ -0,0 +1,166 @@
1
+ import type { SchemaStructure } from '@spooky-sync/query-builder';
2
+ import type { DataModule } from '../data/index';
3
+ import type { Sp00kySync } from '../sync/index';
4
+ import type { AuthService } from '../auth/index';
5
+ import type { Logger } from '../../services/logger/index';
6
+ import type { QueryTimeToLive } from '../../types';
7
+
8
+ const FEATURE_QUERY =
9
+ 'SELECT key, variant, payload FROM _00_user_feature WHERE key = $key';
10
+
11
+ export interface FeatureFlagSnapshot {
12
+ variant: string | undefined;
13
+ payload: unknown | undefined;
14
+ }
15
+
16
+ export interface FeatureFlagOptions {
17
+ fallback?: string;
18
+ ttl?: QueryTimeToLive;
19
+ }
20
+
21
+ export class FeatureFlagHandle {
22
+ private latest: FeatureFlagSnapshot = { variant: undefined, payload: undefined };
23
+ private listeners = new Set<(s: FeatureFlagSnapshot) => void>();
24
+ private unsubscribeFn: (() => void) | null = null;
25
+ private onCloseFn: (() => void) | null = null;
26
+ private closed = false;
27
+
28
+ constructor(
29
+ public readonly key: string,
30
+ public readonly fallback: string | undefined,
31
+ ) {}
32
+
33
+ attach(unsubscribe: () => void): void {
34
+ this.unsubscribeFn?.();
35
+ this.unsubscribeFn = unsubscribe;
36
+ }
37
+
38
+ detach(): void {
39
+ this.unsubscribeFn?.();
40
+ this.unsubscribeFn = null;
41
+ }
42
+
43
+ set(snapshot: FeatureFlagSnapshot): void {
44
+ if (this.closed) return;
45
+ this.latest = snapshot;
46
+ for (const cb of this.listeners) cb(snapshot);
47
+ }
48
+
49
+ variant(): string | undefined {
50
+ return this.latest.variant ?? this.fallback;
51
+ }
52
+
53
+ payload<T = unknown>(): T | undefined {
54
+ return this.latest.payload as T | undefined;
55
+ }
56
+
57
+ enabled(): boolean {
58
+ const v = this.variant();
59
+ return v !== undefined && v !== 'off';
60
+ }
61
+
62
+ subscribe(cb: (s: FeatureFlagSnapshot) => void): () => void {
63
+ this.listeners.add(cb);
64
+ cb({ variant: this.variant(), payload: this.latest.payload });
65
+ return () => {
66
+ this.listeners.delete(cb);
67
+ };
68
+ }
69
+
70
+ onClose(cb: () => void): void {
71
+ this.onCloseFn = cb;
72
+ }
73
+
74
+ close(): void {
75
+ if (this.closed) return;
76
+ this.closed = true;
77
+ this.listeners.clear();
78
+ this.detach();
79
+ this.onCloseFn?.();
80
+ }
81
+ }
82
+
83
+ export interface FeatureFlagModuleDeps<S extends SchemaStructure> {
84
+ dataModule: DataModule<S>;
85
+ sync: Sp00kySync<S>;
86
+ auth: AuthService<S>;
87
+ logger: Logger;
88
+ }
89
+
90
+ interface ActiveHandle {
91
+ handle: FeatureFlagHandle;
92
+ ttl: QueryTimeToLive;
93
+ }
94
+
95
+ export class FeatureFlagModule<S extends SchemaStructure> {
96
+ private logger: Logger;
97
+ private active = new Set<ActiveHandle>();
98
+ private authUnsubscribe: (() => void) | null = null;
99
+ private lastUserId: string | null = null;
100
+
101
+ constructor(private deps: FeatureFlagModuleDeps<S>) {
102
+ this.logger = deps.logger.child({ service: 'FeatureFlagModule' });
103
+ }
104
+
105
+ init(): void {
106
+ if (this.authUnsubscribe) return;
107
+ this.authUnsubscribe = this.deps.auth.subscribe((userId) => {
108
+ if (userId === this.lastUserId) return;
109
+ this.lastUserId = userId;
110
+ void this.refreshAll();
111
+ });
112
+ }
113
+
114
+ feature(key: string, options: FeatureFlagOptions = {}): FeatureFlagHandle {
115
+ const handle = new FeatureFlagHandle(key, options.fallback);
116
+ const entry: ActiveHandle = { handle, ttl: options.ttl ?? '10m' };
117
+ this.active.add(entry);
118
+ handle.onClose(() => this.active.delete(entry));
119
+ void this.register(entry);
120
+ return handle;
121
+ }
122
+
123
+ async closeAll(): Promise<void> {
124
+ this.authUnsubscribe?.();
125
+ this.authUnsubscribe = null;
126
+ for (const entry of [...this.active]) entry.handle.close();
127
+ }
128
+
129
+ private async refreshAll(): Promise<void> {
130
+ for (const entry of this.active) {
131
+ entry.handle.detach();
132
+ entry.handle.set({ variant: undefined, payload: undefined });
133
+ await this.register(entry);
134
+ }
135
+ }
136
+
137
+ private async register(entry: ActiveHandle): Promise<void> {
138
+ const { handle, ttl } = entry;
139
+ try {
140
+ const hash = await this.deps.dataModule.query(
141
+ '_00_user_feature' as any,
142
+ FEATURE_QUERY,
143
+ { key: handle.key },
144
+ ttl,
145
+ );
146
+
147
+ this.deps.sync.enqueueDownEvent({ type: 'register', payload: { hash } });
148
+
149
+ const unsub = this.deps.dataModule.subscribe(
150
+ hash,
151
+ (records) => {
152
+ const row = records[0] as { variant?: string; payload?: unknown } | undefined;
153
+ handle.set({ variant: row?.variant, payload: row?.payload });
154
+ },
155
+ { immediate: true },
156
+ );
157
+
158
+ handle.attach(unsub);
159
+ } catch (err) {
160
+ this.logger.warn(
161
+ { err, key: handle.key, Category: 'sp00ky-client::FeatureFlagModule::register' },
162
+ 'Failed to register feature flag query',
163
+ );
164
+ }
165
+ }
166
+ }
package/src/sp00ky.ts CHANGED
@@ -38,6 +38,8 @@ import { EventSystem } from './events/index';
38
38
  import { CacheModule } from './modules/cache/index';
39
39
  import type { RecordWithId } from './modules/cache/index';
40
40
  import { CrdtManager, CrdtField } from './modules/crdt/index';
41
+ import { FeatureFlagModule, FeatureFlagHandle } from './modules/feature-flag/index';
42
+ import type { FeatureFlagOptions } from './modules/feature-flag/index';
41
43
  import { LocalStoragePersistenceClient } from './services/persistence/localstorage';
42
44
  import { parseParams } from './utils/index';
43
45
  import { SurrealDBPersistenceClient } from './services/persistence/surrealdb';
@@ -95,6 +97,7 @@ export class Sp00kyClient<S extends SchemaStructure> {
95
97
  private sync: Sp00kySync<S>;
96
98
  private devTools: DevToolsService;
97
99
  private crdtManager: CrdtManager;
100
+ private featureFlags!: FeatureFlagModule<S>;
98
101
 
99
102
  private logger: ReturnType<typeof createLogger>;
100
103
  public auth: AuthService<S>;
@@ -203,6 +206,16 @@ export class Sp00kyClient<S extends SchemaStructure> {
203
206
  { refSyncIntervalMs: this.config.refSyncIntervalMs }
204
207
  );
205
208
 
209
+ // Initialize feature flags. Reuses the down-queue to register SSP plans
210
+ // on `_00_user_feature` and the auth subscription to re-register handles
211
+ // when the signed-in user changes.
212
+ this.featureFlags = new FeatureFlagModule({
213
+ dataModule: this.dataModule,
214
+ sync: this.sync,
215
+ auth: this.auth,
216
+ logger,
217
+ });
218
+
206
219
  // Initialize DevTools
207
220
  this.devTools = new DevToolsService(
208
221
  this.local,
@@ -384,6 +397,12 @@ export class Sp00kyClient<S extends SchemaStructure> {
384
397
  await this.sync.init();
385
398
  this.logger.debug({ Category: 'sp00ky-client::Sp00kyClient::init' }, 'Sync initialized');
386
399
 
400
+ this.featureFlags.init();
401
+ this.logger.debug(
402
+ { Category: 'sp00ky-client::Sp00kyClient::init' },
403
+ 'FeatureFlagModule initialized'
404
+ );
405
+
387
406
  this.logger.info(
388
407
  { Category: 'sp00ky-client::Sp00kyClient::init' },
389
408
  'Sp00kyClient initialization completed successfully'
@@ -398,11 +417,25 @@ export class Sp00kyClient<S extends SchemaStructure> {
398
417
  }
399
418
 
400
419
  async close() {
420
+ await this.featureFlags.closeAll();
401
421
  this.crdtManager.closeAll();
402
422
  await this.local.close();
403
423
  await this.remote.close();
404
424
  }
405
425
 
426
+ /**
427
+ * Subscribe to a feature flag for the current user. Returns a
428
+ * `FeatureFlagHandle` whose `variant()`, `payload()` and `enabled()`
429
+ * accessors reflect the latest assignment from `_00_user_feature`,
430
+ * and whose `subscribe(cb)` fires whenever that assignment changes.
431
+ *
432
+ * Permissions are enforced by SurrealDB: a client can only ever see
433
+ * its own row, and cannot create or modify assignments.
434
+ */
435
+ feature(key: string, options?: FeatureFlagOptions): FeatureFlagHandle {
436
+ return this.featureFlags.feature(key, options);
437
+ }
438
+
406
439
  authenticate(token: string) {
407
440
  return this.remote.getClient().authenticate(token);
408
441
  }