@spacelr/sdk 0.1.10 → 0.2.1

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.mts CHANGED
@@ -375,6 +375,34 @@ interface DatabaseChangeEvent {
375
375
  documentId: string;
376
376
  document?: Record<string, unknown>;
377
377
  timestamp: number;
378
+ /**
379
+ * Redis Stream entry ID (e.g. "1745234123-0") for stream-mode events, or a
380
+ * synthesised `${projectId}:${collectionName}:${timestamp}-${documentId}`
381
+ * for pubsub-mode events. Used by function triggers as an idempotency key.
382
+ */
383
+ eventId?: string;
384
+ /**
385
+ * Identifies the origin of the write. Stamped on the envelope (never in the
386
+ * document body) so function-service can run a tamper-resistant recursion guard
387
+ * (internal TCP caller can spoof — see PR notes). Absent on legacy events
388
+ * published before this field existed.
389
+ */
390
+ _source?: 'user' | 'function';
391
+ /**
392
+ * When `_source === 'function'`, the id of the function whose runtime wrote
393
+ * the document. Used to skip self-chain events without blocking legitimate
394
+ * cross-function chains.
395
+ */
396
+ _sourceFunctionId?: string;
397
+ /**
398
+ * Hop-counter on function-originated events. User writes have no value.
399
+ * Each function-write increments the incoming envelope's depth by one,
400
+ * so an A→B→A loop bumps the counter on every hop. The reader rejects
401
+ * dispatch when depth >= MAX_CHAIN_DEPTH so mutual chains terminate
402
+ * deterministically rather than relying on the bounded queue to drop
403
+ * events.
404
+ */
405
+ _chainDepth?: number;
378
406
  }
379
407
  interface RealtimeConfig {
380
408
  baseUrl: string;
@@ -387,6 +415,34 @@ interface RealtimeConfig {
387
415
  */
388
416
  onTokenRefreshed?: (listener: (accessToken: string) => void) => () => void;
389
417
  }
418
+ type GapReason = 'outside-retention-window' | 'redis-unavailable' | 'replay-error' | 'replay-truncated';
419
+ interface StreamGapInfo {
420
+ projectId: string;
421
+ collectionName: string;
422
+ /** Configured retention as a human-readable string (e.g. "maxLen: 10000", "24h", or "unknown"). */
423
+ retentionWindow: string;
424
+ reason: GapReason;
425
+ }
426
+ interface StreamSubscriptionOptions {
427
+ projectId: string;
428
+ collectionName: string;
429
+ sinceId?: string;
430
+ where?: Record<string, string | number | boolean>;
431
+ /**
432
+ * Called for each delivered event. Awaited — the cursor does NOT advance
433
+ * until this resolves. If the callback throws, the cursor is NOT advanced
434
+ * and `onError` is called; the event is not retried (at-most-once on
435
+ * handler failure, consistent with the server-side StreamReaderService).
436
+ */
437
+ onEvent: (entry: {
438
+ eventId: string;
439
+ event: DatabaseChangeEvent;
440
+ }) => void | Promise<void>;
441
+ /** Called when the server emits an event-gap. See GapReason for the four codes. */
442
+ onGap?: (info: StreamGapInfo) => void;
443
+ /** Called on handshake rejection (not-stream-collection, invalid sinceId, etc.) and handler failures. */
444
+ onError?: (error: Error) => void;
445
+ }
390
446
  declare class RealtimeClient {
391
447
  private socket;
392
448
  private config;
@@ -400,8 +456,20 @@ declare class RealtimeClient {
400
456
  private rebuildRetryTimer;
401
457
  private connectionState;
402
458
  private connectionStateListeners;
459
+ private streamSubscriptions;
403
460
  constructor(config: RealtimeConfig);
404
461
  subscribe(projectId: string, collectionName: string, callback: (event: DatabaseChangeEvent) => void, onError?: (error: Error) => void, where?: Record<string, string | number | boolean>): Promise<() => void>;
462
+ /**
463
+ * Subscribe to a stream-mode collection using Redis Streams replay +
464
+ * cursor-based delivery. Parallel to `subscribe()` (which targets
465
+ * pubsub-mode collections). The server validates that the collection is
466
+ * configured in stream mode and rejects the handshake otherwise.
467
+ *
468
+ * The per-subscription cursor (`lastDeliveredId`) advances after each
469
+ * `onEvent` promise resolves, so reconnects resume from the last delivered
470
+ * event rather than the originally-subscribed `sinceId`.
471
+ */
472
+ subscribeWithCursor(options: StreamSubscriptionOptions): Promise<() => void>;
405
473
  /**
406
474
  * Tear down the realtime client permanently. After this call the instance
407
475
  * is disposed — subsequent `subscribe()` calls will not re-establish a
@@ -430,7 +498,59 @@ declare class RealtimeClient {
430
498
  private ensureWakeListeners;
431
499
  private detachWakeListeners;
432
500
  private resubscribeAll;
501
+ private emitSubscribeEvents;
502
+ private buildStreamPayload;
503
+ private unsubscribeStream;
504
+ private dispatchStreamEvent;
505
+ private resubscribeAllStreams;
506
+ private resubscribeOne;
507
+ private pickEarliestCursorState;
508
+ /**
509
+ * Evict the primary subscriber AND every sibling that joined the same
510
+ * streamKey via the dedup path. Fires `onError` on all siblings, then
511
+ * removes the entire streamKey entry from `streamSubscriptions`.
512
+ */
513
+ private evictStreamKey;
514
+ }
515
+
516
+ /**
517
+ * Persist the stream-mode resume cursor across app restarts and reconnects.
518
+ *
519
+ * Pass an implementation to `subscribeEvents({ cursorStorage })` and the SDK
520
+ * will load the previous cursor before subscribing and save after each event
521
+ * is handled. All methods may be sync or async — the SDK awaits the returned
522
+ * value so React Native's AsyncStorage and other async backends work without
523
+ * extra adapter code.
524
+ */
525
+ interface CursorStorage {
526
+ /**
527
+ * Return the saved cursor for this key, or `null` if none.
528
+ *
529
+ * An empty string is also treated as "no cursor" — Redis stream entry ids
530
+ * are never empty, so an empty value is always the result of a misconfigured
531
+ * adapter or a zero-length write. Returning `null` is preferred for clarity.
532
+ */
533
+ load(key: string): Promise<string | null> | string | null;
534
+ /** Persist the cursor for this key. Called after each delivered event. */
535
+ save(key: string, cursor: string): Promise<void> | void;
433
536
  }
537
+ /**
538
+ * In-memory cursor store. Useful for tests and short-lived scripts; state is
539
+ * lost on process exit. Pass this to `subscribeEvents({ cursorStorage })`
540
+ * when you want non-persistent in-memory resume behaviour within a single
541
+ * session — omitting `cursorStorage` entirely disables persistence instead.
542
+ */
543
+ declare function memoryCursorStorage(): CursorStorage;
544
+ /**
545
+ * Browser `localStorage`-backed cursor store. Gracefully no-ops in
546
+ * non-browser environments (Node without a `localStorage` polyfill), so
547
+ * isomorphic code can use this factory unconditionally.
548
+ *
549
+ * @param prefix Key prefix applied to every stored entry. Defaults to
550
+ * `spacelr:cursor:` so multiple SDK consumers on the same origin don't
551
+ * collide.
552
+ */
553
+ declare function localStorageCursorStorage(prefix?: string): CursorStorage;
434
554
 
435
555
  type AuthState = 'authenticated' | 'unauthenticated';
436
556
  type AuthStateListener = (state: AuthState) => void | Promise<void>;
@@ -571,7 +691,34 @@ interface PopulateOption {
571
691
  collection: string;
572
692
  foreignField?: string;
573
693
  }
574
- interface FindResult<T = Record<string, unknown>> {
694
+ interface OffsetResult<T = Record<string, unknown>> {
695
+ mode: 'offset';
696
+ documents: (T & {
697
+ _id: string;
698
+ })[];
699
+ total: number;
700
+ limit: number;
701
+ offset: number;
702
+ }
703
+ interface CursorResult<T = Record<string, unknown>> {
704
+ mode: 'cursor';
705
+ documents: (T & {
706
+ _id: string;
707
+ })[];
708
+ limit: number;
709
+ /**
710
+ * `_id` of the last document in `documents`, or `null` if `documents` is empty.
711
+ * Independent of `hasMore` — the cursor reflects the page contents, not
712
+ * whether more pages exist.
713
+ */
714
+ nextCursor: string | null;
715
+ /**
716
+ * True when more documents exist past the returned page. Computed via the
717
+ * `limit + 1` server-side fetch trick.
718
+ */
719
+ hasMore: boolean;
720
+ }
721
+ interface SearchResult<T = Record<string, unknown>> {
575
722
  documents: (T & {
576
723
  _id: string;
577
724
  })[];
@@ -608,6 +755,65 @@ interface SearchOptions {
608
755
  offset?: number;
609
756
  select?: string[];
610
757
  }
758
+ interface SubscribeEventsHandlers<T = Record<string, unknown>> {
759
+ /** Cursor to resume from. Undefined = fresh subscription, deliver only new events. */
760
+ sinceId?: string;
761
+ where?: Record<string, string | number | boolean>;
762
+ /**
763
+ * Optional resume-cursor store. When set, the SDK loads the previous cursor
764
+ * before subscribing and persists the new cursor after each delivered
765
+ * event. An explicit `sinceId` takes precedence over a loaded cursor —
766
+ * `load()` is skipped entirely and saves resume from `sinceId` forward,
767
+ * overwriting any previously persisted cursor.
768
+ *
769
+ * Pair `where`-filtered subscriptions with a unique `cursorKey`; subscribers
770
+ * with different filters must not share a cursor since the stream position
771
+ * represents "events seen" — filtered-out entries still advance it.
772
+ */
773
+ cursorStorage?: CursorStorage;
774
+ /**
775
+ * Override the default cursor key. Defaults to `{projectId}:{collectionName}`.
776
+ * Useful when multiple subscriptions on the same collection (e.g. different
777
+ * `where` filters or per-user feeds) must persist independent cursors.
778
+ */
779
+ cursorKey?: string;
780
+ /** Called on inserts. Receives the document merged with `_eventId`. */
781
+ onInsert?: (doc: T & {
782
+ _id: string;
783
+ _eventId: string;
784
+ }) => void | Promise<void>;
785
+ onUpdate?: (doc: T & {
786
+ _id: string;
787
+ _eventId: string;
788
+ }) => void | Promise<void>;
789
+ /** Called on deletes. `document` may be absent on tombstones — only documentId + eventId are guaranteed. */
790
+ onDelete?: (documentId: string, eventId: string) => void | Promise<void>;
791
+ /** Called when the server emits an event-gap. See GapReason for the four codes. */
792
+ onGap?: (info: StreamGapInfo) => void;
793
+ /** Called on handshake rejection (not-stream-collection, invalid sinceId, etc.) and handler failures. */
794
+ onError?: (error: Error) => void;
795
+ }
796
+ interface StreamSubscription {
797
+ /**
798
+ * Stop receiving events and release the server-side reader slot. Idempotent.
799
+ *
800
+ * When `cursorStorage` is set, an in-flight async `save()` may still deliver
801
+ * `onError` asynchronously after `unsubscribe()` returns — the fire-and-forget
802
+ * save chain is not cancelled. Callers whose `onError` is tied to a component
803
+ * lifecycle should guard for post-unmount delivery.
804
+ */
805
+ unsubscribe(): void;
806
+ /**
807
+ * Most recently processed eventId — advances after the subscription's
808
+ * onEvent pipeline completes for each event, whether or not a user
809
+ * handler was registered for that event type. `undefined` before any
810
+ * event arrives.
811
+ *
812
+ * Persist this across app restarts and pass it back as `sinceId` on
813
+ * the next `subscribeEvents()` call to catch up on missed events.
814
+ */
815
+ getCursor(): string | undefined;
816
+ }
611
817
  interface SubscribeHandlers<T = Record<string, unknown>> {
612
818
  where?: Record<string, string | number | boolean>;
613
819
  onInsert?: (doc: T & {
@@ -619,22 +825,58 @@ interface SubscribeHandlers<T = Record<string, unknown>> {
619
825
  onDelete?: (documentId: string) => void;
620
826
  onError?: (error: Error) => void;
621
827
  }
622
- declare class QueryBuilder<T = Record<string, unknown>> {
828
+ declare class QueryBuilder<T = Record<string, unknown>, M extends 'offset' | 'cursor' = 'offset'> {
623
829
  private http;
624
830
  private basePath;
625
831
  private _filter?;
626
832
  private _sort?;
627
833
  private _limit?;
628
834
  private _offset?;
835
+ private _before?;
836
+ private _after?;
629
837
  private _fields?;
630
838
  private _populate;
631
839
  constructor(http: HttpClient, basePath: string, filter?: Record<string, unknown>);
632
- sort(sort: Record<string, 1 | -1>): QueryBuilder<T>;
633
- limit(limit: number): QueryBuilder<T>;
634
- offset(offset: number): QueryBuilder<T>;
635
- select(fields: string[]): QueryBuilder<T>;
636
- populate(field: string, collection: string, foreignField?: string): QueryBuilder<T>;
637
- execute(): Promise<FindResult<T>>;
840
+ sort(sort: Record<string, 1 | -1>): this;
841
+ limit(limit: number): this;
842
+ offset(offset: number): this;
843
+ /**
844
+ * **Constraint:** the cursor value must be a 24-hex ObjectId string.
845
+ * Collections using custom non-ObjectId `_id` strings will not work
846
+ * correctly with cursor pagination — the server's `$lt`/`$gt` comparison
847
+ * uses BSON type ordering, mixing string `_id`s with ObjectId comparison
848
+ * produces undefined behaviour. Documented limitation; future work may
849
+ * add opaque cursor tokens that abstract over `_id` types.
850
+ *
851
+ * Switch to cursor-pagination mode: return documents with `_id < id`
852
+ * (in the sort-defined order). The cursor refers to the cursor *value*,
853
+ * not visual UI direction. Requires `.sort()` to be `{ _id: 1 }` or
854
+ * `{ _id: -1 }` (or omitted — server defaults to `{ _id: 1 }`).
855
+ *
856
+ * Narrows the builder's mode parameter so subsequent `.execute()` returns
857
+ * a `CursorResult<T>` instead of `OffsetResult<T>`.
858
+ *
859
+ * **For paginating further:** the `nextCursor` field returned by
860
+ * `execute()` is the `_id` of the last document on the page. To load the
861
+ * next older page, pass it again to `.before()`. (Do NOT pass it to
862
+ * `.after()` — that would request docs newer than this page.)
863
+ *
864
+ * **Cannot be combined with `.offset()`.** Type system allows the chain
865
+ * for ergonomics, but the server rejects it with HTTP 400.
866
+ */
867
+ before(id: string): QueryBuilder<T, 'cursor'>;
868
+ /**
869
+ * Switch to cursor-pagination mode: return documents with `_id > id`.
870
+ * See `.before()` for full semantics — including the ObjectId-only cursor
871
+ * constraint and the `.offset()` incompatibility (server-enforced 400).
872
+ *
873
+ * **For paginating further:** pass the returned `nextCursor` to
874
+ * `.after()` again to load the next newer page.
875
+ */
876
+ after(id: string): QueryBuilder<T, 'cursor'>;
877
+ select(fields: string[]): this;
878
+ populate(field: string, collection: string, foreignField?: string): this;
879
+ execute(): Promise<M extends 'cursor' ? CursorResult<T> : OffsetResult<T>>;
638
880
  }
639
881
  declare class CollectionRef<T = Record<string, unknown>> {
640
882
  private http;
@@ -661,7 +903,7 @@ declare class CollectionRef<T = Record<string, unknown>> {
661
903
  * Limits: `query` 1–200 chars, `fields` 1–10 entries (each matching
662
904
  * `/^[a-zA-Z0-9_.]+$/`, max 64 chars), `limit` max 100.
663
905
  */
664
- search(opts: SearchOptions): Promise<FindResult<T>>;
906
+ search(opts: SearchOptions): Promise<SearchResult<T>>;
665
907
  findById(id: string, options?: FindByIdOptions): Promise<T & {
666
908
  _id: string;
667
909
  }>;
@@ -669,6 +911,7 @@ declare class CollectionRef<T = Record<string, unknown>> {
669
911
  delete(id: string): Promise<DeleteResult>;
670
912
  count(filter?: Record<string, unknown>): Promise<number>;
671
913
  subscribe(handlers: SubscribeHandlers<T>): () => void;
914
+ subscribeEvents(handlers: SubscribeEventsHandlers<T>): StreamSubscription;
672
915
  }
673
916
  declare class DatabaseModule {
674
917
  private http;
@@ -839,4 +1082,4 @@ interface SpacelrClient {
839
1082
  }
840
1083
  declare function createClient(config: SpacelrClientConfig): SpacelrClient;
841
1084
 
842
- export { type ApiResponse, type AuthLostReason, type AuthState, type AuthStateListener, type AuthorizationUrlParams, BrowserTokenStorage, CodeChallengeMethod, type ConnectionState, type DatabaseChangeEvent, type DownloadUrlResponse, type ExchangeCodeParams, type FileInfo, type FileListResponse, FileVisibility, type FunctionInvokeOptions, type FunctionInvokeResult, GrantType, type InitMultipartUploadParams, type InitMultipartUploadResponse, type JWK, type JWKSResponse, type ListFilesParams, type LoginParams, type LoginResponse, MemoryTokenStorage, type OpenIDConfiguration, type PKCEChallenge, type PartEtag, type PushSubscriptionInfo, type QuotaInfo, type RegisterParams, type RegisterResponse, type SearchOptions, type ShareFileParams, SharePermission, SpacelrAuthError, type SpacelrClient, type SpacelrClientConfig, SpacelrEmailVerificationRequiredError, SpacelrError, SpacelrNetworkError, SpacelrTimeoutError, SpacelrTwoFactorRequiredError, type StoredTokens, type SubscribeHandlers, type TokenResponse, type TokenStorage, type TwoFactorResponse, type TwoFactorVerifyParams, type UnshareFileParams, type UploadFileParams, type UploadLargeFileParams, type UploadProgress, type UserInfo, type UserProfile, type VapidKeyResponse, createClient, generatePKCEChallenge };
1085
+ export { type ApiResponse, type AuthLostReason, type AuthState, type AuthStateListener, type AuthorizationUrlParams, BrowserTokenStorage, CodeChallengeMethod, type ConnectionState, type CursorStorage, type DatabaseChangeEvent, type DownloadUrlResponse, type ExchangeCodeParams, type FileInfo, type FileListResponse, FileVisibility, type FunctionInvokeOptions, type FunctionInvokeResult, type GapReason, GrantType, type InitMultipartUploadParams, type InitMultipartUploadResponse, type JWK, type JWKSResponse, type ListFilesParams, type LoginParams, type LoginResponse, MemoryTokenStorage, type OpenIDConfiguration, type PKCEChallenge, type PartEtag, type PushSubscriptionInfo, type QuotaInfo, type RegisterParams, type RegisterResponse, type SearchOptions, type ShareFileParams, SharePermission, SpacelrAuthError, type SpacelrClient, type SpacelrClientConfig, SpacelrEmailVerificationRequiredError, SpacelrError, SpacelrNetworkError, SpacelrTimeoutError, SpacelrTwoFactorRequiredError, type StoredTokens, type StreamGapInfo, type StreamSubscription, type SubscribeEventsHandlers, type SubscribeHandlers, type TokenResponse, type TokenStorage, type TwoFactorResponse, type TwoFactorVerifyParams, type UnshareFileParams, type UploadFileParams, type UploadLargeFileParams, type UploadProgress, type UserInfo, type UserProfile, type VapidKeyResponse, createClient, generatePKCEChallenge, localStorageCursorStorage, memoryCursorStorage };