@spacelr/sdk 0.2.2 → 0.4.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.mts 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>);
@@ -448,6 +470,58 @@ declare class RealtimeClient {
448
470
  * event rather than the originally-subscribed `sinceId`.
449
471
  */
450
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;
451
525
  /**
452
526
  * Tear down the realtime client permanently. After this call the instance
453
527
  * is disposed — subsequent `subscribe()` calls will not re-establish a
@@ -733,6 +807,98 @@ interface SearchOptions {
733
807
  offset?: number;
734
808
  select?: string[];
735
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
+ }
736
902
  interface SubscribeEventsHandlers<T = Record<string, unknown>> {
737
903
  /** Cursor to resume from. Undefined = fresh subscription, deliver only new events. */
738
904
  sinceId?: string;
@@ -919,7 +1085,13 @@ declare class QueryBuilder<T = Record<string, unknown>, M extends 'offset' | 'cu
919
1085
  after(id: string): QueryBuilder<T, 'cursor'>;
920
1086
  select(fields: string[]): this;
921
1087
  populate(field: string, collection: string, foreignField?: string): this;
922
- 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>>;
923
1095
  }
924
1096
  declare class CollectionRef<T = Record<string, unknown>> {
925
1097
  private http;
@@ -955,6 +1127,13 @@ declare class CollectionRef<T = Record<string, unknown>> {
955
1127
  * cannot use a standard B-tree index — on very large collections consider
956
1128
  * narrowing with `filter` to scope the scan.
957
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
+ *
958
1137
  * Limits: `query` 1–200 chars, `fields` 1–10 entries (each matching
959
1138
  * `/^[a-zA-Z0-9_.]+$/`, max 64 chars), `limit` max 100.
960
1139
  */
@@ -967,6 +1146,16 @@ declare class CollectionRef<T = Record<string, unknown>> {
967
1146
  count(filter?: Record<string, unknown>): Promise<number>;
968
1147
  subscribe(handlers: SubscribeHandlers<T>): () => void;
969
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>;
970
1159
  }
971
1160
  declare class DatabaseModule {
972
1161
  private http;
@@ -1137,4 +1326,4 @@ interface SpacelrClient {
1137
1326
  }
1138
1327
  declare function createClient(config: SpacelrClientConfig): SpacelrClient;
1139
1328
 
1140
- 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 };
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>);
@@ -448,6 +470,58 @@ declare class RealtimeClient {
448
470
  * event rather than the originally-subscribed `sinceId`.
449
471
  */
450
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;
451
525
  /**
452
526
  * Tear down the realtime client permanently. After this call the instance
453
527
  * is disposed — subsequent `subscribe()` calls will not re-establish a
@@ -733,6 +807,98 @@ interface SearchOptions {
733
807
  offset?: number;
734
808
  select?: string[];
735
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
+ }
736
902
  interface SubscribeEventsHandlers<T = Record<string, unknown>> {
737
903
  /** Cursor to resume from. Undefined = fresh subscription, deliver only new events. */
738
904
  sinceId?: string;
@@ -919,7 +1085,13 @@ declare class QueryBuilder<T = Record<string, unknown>, M extends 'offset' | 'cu
919
1085
  after(id: string): QueryBuilder<T, 'cursor'>;
920
1086
  select(fields: string[]): this;
921
1087
  populate(field: string, collection: string, foreignField?: string): this;
922
- 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>>;
923
1095
  }
924
1096
  declare class CollectionRef<T = Record<string, unknown>> {
925
1097
  private http;
@@ -955,6 +1127,13 @@ declare class CollectionRef<T = Record<string, unknown>> {
955
1127
  * cannot use a standard B-tree index — on very large collections consider
956
1128
  * narrowing with `filter` to scope the scan.
957
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
+ *
958
1137
  * Limits: `query` 1–200 chars, `fields` 1–10 entries (each matching
959
1138
  * `/^[a-zA-Z0-9_.]+$/`, max 64 chars), `limit` max 100.
960
1139
  */
@@ -967,6 +1146,16 @@ declare class CollectionRef<T = Record<string, unknown>> {
967
1146
  count(filter?: Record<string, unknown>): Promise<number>;
968
1147
  subscribe(handlers: SubscribeHandlers<T>): () => void;
969
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>;
970
1159
  }
971
1160
  declare class DatabaseModule {
972
1161
  private http;
@@ -1137,4 +1326,4 @@ interface SpacelrClient {
1137
1326
  }
1138
1327
  declare function createClient(config: SpacelrClientConfig): SpacelrClient;
1139
1328
 
1140
- 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 };