@spooky-sync/core 0.0.1-canary.74 → 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 +520 -2
- package/dist/index.js +154 -3
- package/dist/types.d.ts +27 -1
- package/package.json +3 -3
- package/src/index.ts +6 -0
- package/src/modules/feature-flag/index.ts +166 -0
- package/src/sp00ky.ts +33 -0
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { A as
|
|
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.
|
|
3175
|
-
const WASM_VERSION = "0.0.1-canary.
|
|
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 {
|
|
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.
|
|
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.
|
|
63
|
-
"@spooky-sync/ssp-wasm": "0.0.1-canary.
|
|
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
|
}
|