@spacelr/sdk 0.2.1 → 0.3.0

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
@@ -296,6 +296,15 @@ interface HttpRequestOptions {
296
296
  query?: Record<string, string | number | undefined>;
297
297
  /** Send cookies cross-origin (credentials: 'include'). Defaults to true for /auth/ paths. */
298
298
  withCredentials?: boolean;
299
+ /**
300
+ * External AbortSignal that lets callers cancel an in-flight request before
301
+ * the configured timeout elapses (e.g. `subscribeWithSnapshot` aborting its
302
+ * snapshot find() when the user calls unsubscribe()). Forwarded into the
303
+ * internal AbortController so cancellation surfaces as a DOMException with
304
+ * name 'AbortError' — the caller is responsible for distinguishing it from
305
+ * the timeout-driven abort if it matters.
306
+ */
307
+ signal?: AbortSignal;
299
308
  }
300
309
  declare class HttpClient {
301
310
  private config;
@@ -362,6 +371,19 @@ declare class SpacelrTwoFactorRequiredError extends SpacelrError {
362
371
  readonly twoFactorToken: string;
363
372
  constructor(twoFactorToken: string, details?: Record<string, unknown>);
364
373
  }
374
+ /**
375
+ * Thrown when `collection.search()` is called without the pre-filter the
376
+ * collection requires (issue #165). `details.missingFields` lists the
377
+ * top-level filter keys the server expected.
378
+ *
379
+ * Required filters protect against unindexable full-collection regex
380
+ * scans on large collections — see the collection's `searchConfig` for
381
+ * which fields are required.
382
+ */
383
+ declare class SpacelrSearchFilterRequiredError extends SpacelrError {
384
+ readonly missingFields: string[];
385
+ constructor(message: string, details?: Record<string, unknown>);
386
+ }
365
387
  declare class SpacelrEmailVerificationRequiredError extends SpacelrError {
366
388
  readonly emailSent: boolean;
367
389
  constructor(emailSent: boolean, details?: Record<string, unknown>);
@@ -381,28 +403,6 @@ interface DatabaseChangeEvent {
381
403
  * for pubsub-mode events. Used by function triggers as an idempotency key.
382
404
  */
383
405
  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;
406
406
  }
407
407
  interface RealtimeConfig {
408
408
  baseUrl: string;
@@ -470,6 +470,58 @@ declare class RealtimeClient {
470
470
  * event rather than the originally-subscribed `sinceId`.
471
471
  */
472
472
  subscribeWithCursor(options: StreamSubscriptionOptions): Promise<() => void>;
473
+ /**
474
+ * Metadata-aware variant of `subscribeWithCursor()` — see #99.
475
+ *
476
+ * Identical to `subscribeWithCursor` except it also returns the
477
+ * `realtimeMode` and `latestStreamId` from the server's
478
+ * subscribe-events ack so callers (notably `subscribeWithSnapshot`)
479
+ * can fail-fast on pubsub-mode collections and anchor the snapshot
480
+ * baseline atomically with the reader-attach.
481
+ *
482
+ * Defaults: if the server didn't send `realtimeMode` (older
483
+ * gateway), the SDK treats the collection as pubsub-mode so
484
+ * downstream fail-fasts still fire correctly. `latestStreamId`
485
+ * defaults to `null` when absent.
486
+ *
487
+ * Dedup-path note: when a sibling local subscription already holds
488
+ * the server-side reader slot for this streamKey, no new handshake
489
+ * is emitted — the SDK piggy-backs on the existing slot. In that
490
+ * case we synthesise `{ realtimeMode: 'stream', latestStreamId: null }`
491
+ * because the existing slot's prior ack is no longer available.
492
+ * Callers that need a fresh `latestStreamId` (e.g. snapshot anchor)
493
+ * MUST handle the `null` case.
494
+ */
495
+ subscribeWithCursorWithMeta(options: StreamSubscriptionOptions): Promise<{
496
+ unsubscribe: () => void;
497
+ realtimeMode: 'pubsub' | 'stream';
498
+ latestStreamId: string | null;
499
+ }>;
500
+ /**
501
+ * Shared body for `subscribeWithCursor` and
502
+ * `subscribeWithCursorWithMeta`. Owns the handshake, dedup path,
503
+ * pre-ack registration, and error eviction. Returns the unsubscribe
504
+ * function plus the subscribe-events ack so metadata-aware callers
505
+ * can read `realtimeMode` / `latestStreamId`.
506
+ *
507
+ * Behaviour-preserving contract — DO NOT change without re-running
508
+ * the singleflight tests in `realtime.spec.ts`:
509
+ *
510
+ * 1. Dedup branch BEFORE registration: if a sibling subscriber for
511
+ * this streamKey is already registered, the new caller piggy-backs
512
+ * on its server-side slot. Re-emitting the handshake here would
513
+ * tear the prior subscriber's reader down.
514
+ * 2. Registration BEFORE handshake: `streamSubscriptions.set` must
515
+ * fire before `emitSubscribeEvents` so replay/event-gap frames
516
+ * delivered DURING the handshake fanout find this subscriber.
517
+ * 3. evictStreamKey on ack-error MUST sweep dedup-path siblings:
518
+ * they have no independent server-side state and would silently
519
+ * miss every event until the next full reconnect.
520
+ * 4. The catch block is a safety net for the re-thrown ack-error
521
+ * only — `emitSubscribeEvents` itself always resolves, so this
522
+ * catch should never fire on the success path.
523
+ */
524
+ private _subscribeInternal;
473
525
  /**
474
526
  * Tear down the realtime client permanently. After this call the instance
475
527
  * is disposed — subsequent `subscribe()` calls will not re-establish a
@@ -755,6 +807,98 @@ interface SearchOptions {
755
807
  offset?: number;
756
808
  select?: string[];
757
809
  }
810
+ /**
811
+ * Options for `CollectionRef.subscribeWithSnapshot()` — see #99.
812
+ *
813
+ * The helper combines an initial query, a live stream subscription,
814
+ * and outside-retention-window gap recovery into one idempotent
815
+ * contract. Apps must implement `onChange` as state mutation keyed
816
+ * by `_id` (Map/Set semantics): events MAY arrive twice (snapshot
817
+ * vs stream overlap, gap recovery replay window, reconnect replay
818
+ * from persisted cursor).
819
+ *
820
+ * Performance: `onChange` is awaited; a slow handler stalls the
821
+ * stream for THIS subscriber. Keep it fast (synchronous state
822
+ * updates only).
823
+ *
824
+ * Requires a stream-mode collection. Calling on a pubsub collection
825
+ * throws SpacelrError with code SUBSCRIBE_WITH_SNAPSHOT_REQUIRES_STREAM
826
+ * BEFORE any find() runs.
827
+ */
828
+ interface SubscribeWithSnapshotOptions<T> {
829
+ /**
830
+ * Filter for both the initial snapshot AND the live `onChange` stream.
831
+ *
832
+ * **Snapshot semantics** (full MongoDB query): supports operators like
833
+ * `{ status: { $in: [...] } }`, `{ ts: { $gt: ... } }`, etc.
834
+ *
835
+ * **Live-stream semantics** (gateway-side equality only): the stream
836
+ * filter only honours top-level primitive equality
837
+ * (`string | number | boolean` values). Operator-shaped values are
838
+ * silently dropped from the stream filter at handshake time — the
839
+ * remaining primitive entries still apply, so a typical chat-style
840
+ * `{ chatId: 'c1' }` filter works identically on both sides. If you
841
+ * pass a mixed `{ chatId: 'c1', status: { $in: [...] } }`, the
842
+ * snapshot is fully filtered but the live stream filters by `chatId`
843
+ * only; you may receive `onChange` events for documents whose `status`
844
+ * is outside your `$in` set. Re-check in the handler if needed.
845
+ */
846
+ where?: Record<string, unknown>;
847
+ sort?: Record<string, 1 | -1>;
848
+ limit?: number;
849
+ select?: string[];
850
+ /**
851
+ * Called with the full snapshot:
852
+ * - once after the initial find()
853
+ * - again after every gap-exceeded recovery
854
+ * `cursor` is the stream position at the moment the snapshot was
855
+ * taken — opaque, may be persisted, may be passed to a later
856
+ * subscribeWithCursor as sinceId. Never compare cursor strings.
857
+ *
858
+ * Note on shared readers: when another subscriber on the SAME
859
+ * client+collection is already active, this subscriber piggy-backs
860
+ * on that reader. The returned `cursor` reflects the stream tip at
861
+ * THIS handshake; the shared reader may already have advanced past
862
+ * it, so the duplicate-delivery window between this `cursor` and
863
+ * the next `onChange` event can be wider than a fresh single-
864
+ * subscriber attach. The at-least-once contract still holds —
865
+ * idempotent `onChange` handlers absorb the extra duplicates.
866
+ */
867
+ onSnapshot: (docs: (T & {
868
+ _id: string;
869
+ })[], cursor: string | null) => void | Promise<void>;
870
+ /**
871
+ * Per-event callback. Awaited; cursor advances after this resolves.
872
+ * State mutations MUST be idempotent by `_id`.
873
+ */
874
+ onChange: (event: DatabaseChangeEvent) => void | Promise<void>;
875
+ /**
876
+ * Fatal stream/subscription errors AFTER the initial handshake has
877
+ * resolved. Subscription is torn down. Pre-handshake failures (e.g.
878
+ * pubsub-mode collection, ack-error from the gateway) DO NOT fire
879
+ * this callback — they reject the helper's promise instead, with a
880
+ * typed `SpacelrError` carrying a discriminator code.
881
+ *
882
+ * If this callback itself throws, the gap-recovery state machine
883
+ * still resets to `'idle'`, but `onError` may be invoked a second
884
+ * time (with the same error) by the dispatch IIFE's safety-net
885
+ * catch. Keep the callback total: log, set state, return.
886
+ */
887
+ onError?: (err: Error) => void;
888
+ /**
889
+ * Snapshot read (`find()`) failed. Default: forwards to `onError`.
890
+ * Aborted reads (caused by `unsubscribe()`) NEVER call this — they
891
+ * silently reject the helper's promise with `AbortError`.
892
+ *
893
+ * **Dual-notification on non-abort failures**: a snapshot failure
894
+ * fires this callback AND rejects the helper's outer promise with
895
+ * the same error. Callers that handle both `await ...catch()` AND
896
+ * register `onSnapshotError` will receive the error twice — pick
897
+ * one path. The dual surface is intentional so apps using fire-and-
898
+ * forget callback handlers don't need to also wrap the await.
899
+ */
900
+ onSnapshotError?: (err: Error) => void;
901
+ }
758
902
  interface SubscribeEventsHandlers<T = Record<string, unknown>> {
759
903
  /** Cursor to resume from. Undefined = fresh subscription, deliver only new events. */
760
904
  sinceId?: string;
@@ -825,6 +969,71 @@ interface SubscribeHandlers<T = Record<string, unknown>> {
825
969
  onDelete?: (documentId: string) => void;
826
970
  onError?: (error: Error) => void;
827
971
  }
972
+ interface PaginateOptions {
973
+ where?: Record<string, unknown>;
974
+ /** Sort direction by `_id`. Defaults to -1 (newest first — chat scroll-back). */
975
+ sort?: {
976
+ _id: 1 | -1;
977
+ };
978
+ /** Page size. Default 50. Server typically caps at 100. */
979
+ limit?: number;
980
+ /**
981
+ * Optional initial cursor — paginate from this `_id` exclusively. Useful
982
+ * when the caller already holds a boundary document (e.g. the oldest
983
+ * message currently rendered in a chat view paired with `subscribeEvents`).
984
+ */
985
+ cursor?: string;
986
+ }
987
+ interface PaginatorPage<T = Record<string, unknown>> {
988
+ documents: (T & {
989
+ _id: string;
990
+ })[];
991
+ /**
992
+ * True when more documents exist past this page. The paginator also flips
993
+ * `exhausted` to true when this returns false, so callers can poll either
994
+ * `page.hasMore` after each call or `paginator.exhausted` between calls.
995
+ */
996
+ hasMore: boolean;
997
+ }
998
+ /**
999
+ * Cursor-based scroll helper around `find()`. Tracks the last-seen `_id`
1000
+ * across calls and applies it as `.before()` (descending sort) or
1001
+ * `.after()` (ascending sort) on the next page so the keyset stays stable
1002
+ * under concurrent inserts. Use `subscribeEvents` for new docs and
1003
+ * `paginate` for older — the two compose into a chat-style timeline.
1004
+ */
1005
+ declare class Paginator<T = Record<string, unknown>> {
1006
+ private readonly http;
1007
+ private readonly basePath;
1008
+ private cursor?;
1009
+ private _exhausted;
1010
+ private readonly direction;
1011
+ private readonly pageSize;
1012
+ private readonly where?;
1013
+ /**
1014
+ * Tail of the serialized `next()` chain. Each call appends to this so
1015
+ * concurrent invocations from a UI scroll handler (e.g. user spam-tapping
1016
+ * "load more") don't issue parallel requests with the same cursor — which
1017
+ * would return duplicate pages and clobber the cursor based on whichever
1018
+ * response settles last. Calls execute strictly in invocation order.
1019
+ */
1020
+ private chain;
1021
+ constructor(http: HttpClient, basePath: string, opts: PaginateOptions);
1022
+ /**
1023
+ * True once a `next()` call has returned an empty page. Subsequent
1024
+ * `next()` calls return an empty page without hitting the server.
1025
+ *
1026
+ * Note: only the empty-page signal flips this flag; a server response
1027
+ * that returns documents but `hasMore: false` does NOT exhaust. This
1028
+ * lets ascending paginators (`sort: { _id: 1 }`) keep polling for
1029
+ * documents inserted after the caller caught up to the current tail —
1030
+ * the next `.after(lastSeen)` call will simply return zero documents
1031
+ * until something new lands.
1032
+ */
1033
+ get exhausted(): boolean;
1034
+ next(): Promise<PaginatorPage<T>>;
1035
+ private fetchNextPage;
1036
+ }
828
1037
  declare class QueryBuilder<T = Record<string, unknown>, M extends 'offset' | 'cursor' = 'offset'> {
829
1038
  private http;
830
1039
  private basePath;
@@ -876,7 +1085,13 @@ declare class QueryBuilder<T = Record<string, unknown>, M extends 'offset' | 'cu
876
1085
  after(id: string): QueryBuilder<T, 'cursor'>;
877
1086
  select(fields: string[]): this;
878
1087
  populate(field: string, collection: string, foreignField?: string): this;
879
- execute(): Promise<M extends 'cursor' ? CursorResult<T> : OffsetResult<T>>;
1088
+ /**
1089
+ * `signal` is an optional external AbortSignal forwarded into the underlying
1090
+ * HTTP request. Used by `subscribeWithSnapshot` so that an unsubscribe()
1091
+ * call cancels the in-flight snapshot find(); regular callers can leave it
1092
+ * undefined.
1093
+ */
1094
+ execute(signal?: AbortSignal): Promise<M extends 'cursor' ? CursorResult<T> : OffsetResult<T>>;
880
1095
  }
881
1096
  declare class CollectionRef<T = Record<string, unknown>> {
882
1097
  private http;
@@ -892,6 +1107,18 @@ declare class CollectionRef<T = Record<string, unknown>> {
892
1107
  _id?: string;
893
1108
  })[]): Promise<InsertResult>;
894
1109
  find(filter?: Record<string, unknown>): QueryBuilder<T>;
1110
+ /**
1111
+ * Cursor-based scroll-back helper. Returns a `Paginator` whose `.next()`
1112
+ * yields successive pages and tracks the last-seen `_id` internally.
1113
+ * Defaults to descending sort and 50 docs per page (chat scroll-back is
1114
+ * the canonical use case). See `PaginateOptions` for tuning.
1115
+ *
1116
+ * **Cursor constraint:** uses `_id` keyset pagination, which requires
1117
+ * 24-hex ObjectId `_id`s. Collections with custom string `_id` schemes
1118
+ * fall back to comparing strings as ObjectIds — the server's handler
1119
+ * documents this limitation on `before` / `after` directly.
1120
+ */
1121
+ paginate(opts?: PaginateOptions): Paginator<T>;
895
1122
  /**
896
1123
  * Server-side substring search across the specified fields.
897
1124
  *
@@ -900,6 +1127,13 @@ declare class CollectionRef<T = Record<string, unknown>> {
900
1127
  * cannot use a standard B-tree index — on very large collections consider
901
1128
  * narrowing with `filter` to scope the scan.
902
1129
  *
1130
+ * Required filters: collections may declare `searchConfig.requireFilter`
1131
+ * via the admin API. Calls without those keys at the top level of `filter`
1132
+ * (or inside a top-level `$and`) reject with
1133
+ * `SpacelrSearchFilterRequiredError` carrying `missingFields`. Allowed
1134
+ * shapes: `{ field: value }`, `{ field: { $eq: value } }`,
1135
+ * `{ field: { $in: [...] } }` (non-empty).
1136
+ *
903
1137
  * Limits: `query` 1–200 chars, `fields` 1–10 entries (each matching
904
1138
  * `/^[a-zA-Z0-9_.]+$/`, max 64 chars), `limit` max 100.
905
1139
  */
@@ -912,6 +1146,16 @@ declare class CollectionRef<T = Record<string, unknown>> {
912
1146
  count(filter?: Record<string, unknown>): Promise<number>;
913
1147
  subscribe(handlers: SubscribeHandlers<T>): () => void;
914
1148
  subscribeEvents(handlers: SubscribeEventsHandlers<T>): StreamSubscription;
1149
+ /**
1150
+ * Snapshot-aware subscribe — see #99 / SubscribeWithSnapshotOptions.
1151
+ *
1152
+ * Returns a Promise that resolves with the unsubscribe function only
1153
+ * after the first `onSnapshot` has run to completion (initial-load
1154
+ * signal). Calling unsubscribe() while the snapshot find() is in flight
1155
+ * aborts the request and silences the AbortError. Gap-recovery state
1156
+ * machine lands in Task 6.
1157
+ */
1158
+ subscribeWithSnapshot(opts: SubscribeWithSnapshotOptions<T>): Promise<() => void>;
915
1159
  }
916
1160
  declare class DatabaseModule {
917
1161
  private http;
@@ -1082,4 +1326,4 @@ interface SpacelrClient {
1082
1326
  }
1083
1327
  declare function createClient(config: SpacelrClientConfig): SpacelrClient;
1084
1328
 
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 };
1329
+ 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, SpacelrSearchFilterRequiredError, SpacelrTimeoutError, SpacelrTwoFactorRequiredError, type StoredTokens, type StreamGapInfo, type StreamSubscription, type SubscribeEventsHandlers, type SubscribeHandlers, type SubscribeWithSnapshotOptions, 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 };