@spacelr/sdk 0.9.2 → 0.10.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
@@ -516,6 +516,28 @@ declare class SpacelrEmailVerificationRequiredError extends SpacelrError {
516
516
  }
517
517
 
518
518
  type ConnectionState = 'connected' | 'reconnecting' | 'disconnected';
519
+ /** A single primitive `where` candidate. */
520
+ type WhereScalar = string | number | boolean;
521
+ /**
522
+ * Multi-value `where` operators (Firestore-style) for realtime subscriptions:
523
+ * - `in`: the document field is a **scalar** equal to any candidate.
524
+ * - `array-contains-any`: the document field is an **array** sharing at least
525
+ * one element with the candidates.
526
+ *
527
+ * The single-value form (`where: { field: scalar }`) stays polymorphic — it
528
+ * matches a scalar field by equality OR an array field by element membership.
529
+ *
530
+ * Mirrors the server definition in `@spacelr-workspace/shared-types`
531
+ * (`realtime-where.ts`); the two must stay structurally identical.
532
+ */
533
+ type WhereOperator = {
534
+ in: WhereScalar[];
535
+ } | {
536
+ 'array-contains-any': WhereScalar[];
537
+ };
538
+ type WhereValue = WhereScalar | WhereOperator;
539
+ /** A realtime subscription `where` filter: field name → scalar or operator. */
540
+ type WhereFilter = Record<string, WhereValue>;
519
541
  interface DatabaseChangeEvent {
520
542
  type: 'insert' | 'update' | 'delete';
521
543
  projectId: string;
@@ -553,7 +575,7 @@ interface StreamSubscriptionOptions {
553
575
  projectId: string;
554
576
  collectionName: string;
555
577
  sinceId?: string;
556
- where?: Record<string, string | number | boolean>;
578
+ where?: WhereFilter;
557
579
  /**
558
580
  * Called for each delivered event. Awaited — the cursor does NOT advance
559
581
  * until this resolves. If the callback throws, the cursor is NOT advanced
@@ -575,6 +597,10 @@ declare class RealtimeClient {
575
597
  private subscriptions;
576
598
  private connecting;
577
599
  private roomWhereMap;
600
+ private roomErrorMap;
601
+ private roomGeneration;
602
+ private subEpoch;
603
+ private resubscribingRooms;
578
604
  private unsubscribeFromTokenRefreshed;
579
605
  private onVisibilityChange;
580
606
  private onOnline;
@@ -584,7 +610,9 @@ declare class RealtimeClient {
584
610
  private connectionStateListeners;
585
611
  private streamSubscriptions;
586
612
  constructor(config: RealtimeConfig);
587
- subscribe(projectId: string, collectionName: string, callback: (event: DatabaseChangeEvent) => void, onError?: (error: Error) => void, where?: Record<string, string | number | boolean>): Promise<() => void>;
613
+ subscribe(projectId: string, collectionName: string, callback: (event: DatabaseChangeEvent) => void, onError?: (error: Error & {
614
+ code?: string;
615
+ }) => void, where?: WhereFilter): Promise<() => void>;
588
616
  /**
589
617
  * Subscribe to a stream-mode collection using Redis Streams replay +
590
618
  * cursor-based delivery. Parallel to `subscribe()` (which targets
@@ -676,6 +704,31 @@ declare class RealtimeClient {
676
704
  private ensureWakeListeners;
677
705
  private detachWakeListeners;
678
706
  private resubscribeAll;
707
+ /**
708
+ * Parse a pubsub room key back into its projectId + collectionName.
709
+ * Room format: `db:{projectId}:{collectionName}` or that base plus `?filter`.
710
+ * Returns null for anything that isn't a well-formed `db:` room.
711
+ */
712
+ private parseRoom;
713
+ /** Build an Error carrying the gateway's typed `errorCode` (if any) as `.code`. */
714
+ private toSubscribeError;
715
+ /** Call every subscriber's error handler registered for `room` (#661). */
716
+ private notifyRoomError;
717
+ /**
718
+ * Re-run the subscribe handshake for one already-tracked room. Shared by
719
+ * reconnect resubscription (`resubscribeAll`) and the #661
720
+ * `subscription-invalidated` handler.
721
+ *
722
+ * - Deduped per room via `resubscribingRooms` so the duplicate invalidation
723
+ * events the gateway emits (one per evicted room) collapse to one handshake.
724
+ * - Snapshots the room's generation; the ack acts only if it is still current,
725
+ * so a late ack cannot clobber an unsubscribe/re-subscribe that happened in
726
+ * the meantime — and a success that arrives after the room was removed emits
727
+ * a compensating `unsubscribe` so no ghost server room is left behind.
728
+ * - On a genuine denial the subscription is evicted and its stored `onError`
729
+ * is called (with the typed `.code`), instead of silently going quiet.
730
+ */
731
+ private resubscribeRoom;
679
732
  private emitSubscribeEvents;
680
733
  private buildStreamPayload;
681
734
  private unsubscribeStream;
@@ -1089,16 +1142,18 @@ interface SubscribeWithSnapshotOptions<T> {
1089
1142
  * **Snapshot semantics** (full MongoDB query): supports operators like
1090
1143
  * `{ status: { $in: [...] } }`, `{ ts: { $gt: ... } }`, etc.
1091
1144
  *
1092
- * **Live-stream semantics** (gateway-side equality only): the stream
1093
- * filter only honours top-level primitive equality
1094
- * (`string | number | boolean` values). Operator-shaped values are
1095
- * silently dropped from the stream filter at handshake time the
1096
- * remaining primitive entries still apply, so a typical chat-style
1097
- * `{ chatId: 'c1' }` filter works identically on both sides. If you
1098
- * pass a mixed `{ chatId: 'c1', status: { $in: [...] } }`, the
1099
- * snapshot is fully filtered but the live stream filters by `chatId`
1100
- * only; you may receive `onChange` events for documents whose `status`
1101
- * is outside your `$in` set. Re-check in the handler if needed.
1145
+ * **Live-stream semantics** (top-level primitives only): this helper
1146
+ * forwards only top-level primitive values (`string | number | boolean`)
1147
+ * from the Mongo-query `where` to the live filter. MongoDB operators
1148
+ * (`$in`, `$gt`, …) are silently dropped at handshake time and are NOT
1149
+ * translated into the gateway's realtime `in` / `array-contains-any`
1150
+ * operators the remaining primitive entries still apply, so a typical
1151
+ * chat-style `{ chatId: 'c1' }` filter works identically on both sides. If
1152
+ * you pass a mixed `{ chatId: 'c1', status: { $in: [...] } }`, the snapshot
1153
+ * is fully filtered but the live stream filters by `chatId` only; you may
1154
+ * receive `onChange` events for documents whose `status` is outside your
1155
+ * `$in` set. Re-check in the handler, or use `subscribeEvents()` directly
1156
+ * with a realtime `{ status: { in: [...] } }` operator for live filtering.
1102
1157
  */
1103
1158
  where?: Record<string, unknown>;
1104
1159
  sort?: Record<string, 1 | -1>;
@@ -1159,7 +1214,7 @@ interface SubscribeWithSnapshotOptions<T> {
1159
1214
  interface SubscribeEventsHandlers<T = Record<string, unknown>> {
1160
1215
  /** Cursor to resume from. Undefined = fresh subscription, deliver only new events. */
1161
1216
  sinceId?: string;
1162
- where?: Record<string, string | number | boolean>;
1217
+ where?: WhereFilter;
1163
1218
  /**
1164
1219
  * Optional resume-cursor store. When set, the SDK loads the previous cursor
1165
1220
  * before subscribing and persists the new cursor after each delivered
@@ -1216,7 +1271,7 @@ interface StreamSubscription {
1216
1271
  getCursor(): string | undefined;
1217
1272
  }
1218
1273
  interface SubscribeHandlers<T = Record<string, unknown>> {
1219
- where?: Record<string, string | number | boolean>;
1274
+ where?: WhereFilter;
1220
1275
  onInsert?: (doc: T & {
1221
1276
  _id: string;
1222
1277
  }) => void;
@@ -1546,6 +1601,11 @@ interface FunctionInvokeOptions {
1546
1601
  *
1547
1602
  * If `true` but the user is not signed in, the header is simply omitted —
1548
1603
  * safe for `public` invokeMode.
1604
+ *
1605
+ * Note: for `authenticated`/`hybrid` invokeMode, the server additionally
1606
+ * requires the token's user to be a member of the target project — this
1607
+ * flag only controls whether the header is attached, not who the server
1608
+ * accepts.
1549
1609
  */
1550
1610
  authenticated?: boolean;
1551
1611
  payload?: Record<string, unknown>;
@@ -1564,10 +1624,14 @@ declare class FunctionsModule {
1564
1624
  * `config.apiUrl` (which already carries the `/api/v1` prefix).
1565
1625
  *
1566
1626
  * Auth defaults, based on `invokeMode` semantics:
1567
- * - webhook: pass `secret` → Authorization is NOT attached
1568
- * - authenticated: pass nothing → Authorization IS attached (from token manager)
1569
- * - public: pass nothing → Authorization is attached if logged in, else omitted
1570
- * - hybrid: pass both `secret` and `authenticated: true`
1627
+ * - webhook: pass `secret` → Authorization is NOT attached
1628
+ * - authenticated: pass nothing → Authorization IS attached (from token manager);
1629
+ * caller must be a member of the target project
1630
+ * - public: pass nothing → Authorization is attached if logged in, else omitted
1631
+ * - hybrid: pass both `secret` and `authenticated: true`; JWT path
1632
+ * requires project membership like `authenticated`
1633
+ * - platform-authenticated: pass nothing → Authorization IS attached (from token manager);
1634
+ * any signed-in user is accepted, still just attaches the bearer token
1571
1635
  *
1572
1636
  * To force a specific behaviour, set `authenticated` explicitly — it wins
1573
1637
  * over the `secret`-based default.
@@ -1763,4 +1827,4 @@ interface SpacelrClient {
1763
1827
  }
1764
1828
  declare function createClient(config: SpacelrClientConfig): SpacelrClient;
1765
1829
 
1766
- export { type ApiResponse, type AuthLostReason, type AuthState, type AuthStateListener, type AuthorizationUrlParams, BrowserTokenStorage, CodeChallengeMethod, type ConnectionState, CursorInvalidError, type CursorStorage, type DatabaseChangeEvent, type DownloadUrlResponse, type ExchangeCodeParams, type FileInfo, type FileListResponse, FileVisibility, ForbiddenError, type FunctionInvokeOptions, type FunctionInvokeResult, type GapReason, GrantType, type InitMultipartUploadParams, type InitMultipartUploadResponse, type JWK, type JWKSResponse, type ListFilesParams, type LoginParams, type LoginResponse, MemoryTokenStorage, NotFoundError, type OpenIDConfiguration, type PKCEChallenge, type PartEtag, type PasskeyAuthenticationOptionsJSON, type PasskeyAuthenticationResponseJSON, type PasskeyCredential, type PasskeyCredentialDescriptorJSON, type PasskeyLoginResponse, type PasskeyRegistrationOptionsJSON, type PasskeyRegistrationResponseJSON, type PasskeyRegistrationResult, type PushSubscriptionInfo, type QuotaInfo, type RegisterParams, type RegisterResponse, type Schedule, type ScheduleInvokeOptions, type ScheduleListOptions, type ScheduleStatus, type SearchOptions, ServerConfigError, 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 TimelineAndFilter, TimelineError, type TimelineFieldFilter, type TimelineFilter, type TimelineLeafFilter, TimelineModule, type TimelineOrderBy, type TimelineQueryOptions, type TimelineQueryResponse, type TimelineScalar, type TimelineSourceStats, TimeoutError, type TokenResponse, type TokenStorage, type TwoFactorResponse, type TwoFactorVerifyParams, type UnshareFileParams, type UploadFileParams, type UploadLargeFileParams, type UploadProgress, type UserInfo, type UserProfile, ValidationError, type VapidKeyResponse, createClient, generatePKCEChallenge, localStorageCursorStorage, memoryCursorStorage };
1830
+ export { type ApiResponse, type AuthLostReason, type AuthState, type AuthStateListener, type AuthorizationUrlParams, BrowserTokenStorage, CodeChallengeMethod, type ConnectionState, CursorInvalidError, type CursorStorage, type DatabaseChangeEvent, type DownloadUrlResponse, type ExchangeCodeParams, type FileInfo, type FileListResponse, FileVisibility, ForbiddenError, type FunctionInvokeOptions, type FunctionInvokeResult, type GapReason, GrantType, type InitMultipartUploadParams, type InitMultipartUploadResponse, type JWK, type JWKSResponse, type ListFilesParams, type LoginParams, type LoginResponse, MemoryTokenStorage, NotFoundError, type OpenIDConfiguration, type PKCEChallenge, type PartEtag, type PasskeyAuthenticationOptionsJSON, type PasskeyAuthenticationResponseJSON, type PasskeyCredential, type PasskeyCredentialDescriptorJSON, type PasskeyLoginResponse, type PasskeyRegistrationOptionsJSON, type PasskeyRegistrationResponseJSON, type PasskeyRegistrationResult, type PushSubscriptionInfo, type QuotaInfo, type RegisterParams, type RegisterResponse, type Schedule, type ScheduleInvokeOptions, type ScheduleListOptions, type ScheduleStatus, type SearchOptions, ServerConfigError, 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 TimelineAndFilter, TimelineError, type TimelineFieldFilter, type TimelineFilter, type TimelineLeafFilter, TimelineModule, type TimelineOrderBy, type TimelineQueryOptions, type TimelineQueryResponse, type TimelineScalar, type TimelineSourceStats, TimeoutError, type TokenResponse, type TokenStorage, type TwoFactorResponse, type TwoFactorVerifyParams, type UnshareFileParams, type UploadFileParams, type UploadLargeFileParams, type UploadProgress, type UserInfo, type UserProfile, ValidationError, type VapidKeyResponse, type WhereFilter, type WhereOperator, type WhereScalar, type WhereValue, createClient, generatePKCEChallenge, localStorageCursorStorage, memoryCursorStorage };
package/dist/index.d.ts CHANGED
@@ -516,6 +516,28 @@ declare class SpacelrEmailVerificationRequiredError extends SpacelrError {
516
516
  }
517
517
 
518
518
  type ConnectionState = 'connected' | 'reconnecting' | 'disconnected';
519
+ /** A single primitive `where` candidate. */
520
+ type WhereScalar = string | number | boolean;
521
+ /**
522
+ * Multi-value `where` operators (Firestore-style) for realtime subscriptions:
523
+ * - `in`: the document field is a **scalar** equal to any candidate.
524
+ * - `array-contains-any`: the document field is an **array** sharing at least
525
+ * one element with the candidates.
526
+ *
527
+ * The single-value form (`where: { field: scalar }`) stays polymorphic — it
528
+ * matches a scalar field by equality OR an array field by element membership.
529
+ *
530
+ * Mirrors the server definition in `@spacelr-workspace/shared-types`
531
+ * (`realtime-where.ts`); the two must stay structurally identical.
532
+ */
533
+ type WhereOperator = {
534
+ in: WhereScalar[];
535
+ } | {
536
+ 'array-contains-any': WhereScalar[];
537
+ };
538
+ type WhereValue = WhereScalar | WhereOperator;
539
+ /** A realtime subscription `where` filter: field name → scalar or operator. */
540
+ type WhereFilter = Record<string, WhereValue>;
519
541
  interface DatabaseChangeEvent {
520
542
  type: 'insert' | 'update' | 'delete';
521
543
  projectId: string;
@@ -553,7 +575,7 @@ interface StreamSubscriptionOptions {
553
575
  projectId: string;
554
576
  collectionName: string;
555
577
  sinceId?: string;
556
- where?: Record<string, string | number | boolean>;
578
+ where?: WhereFilter;
557
579
  /**
558
580
  * Called for each delivered event. Awaited — the cursor does NOT advance
559
581
  * until this resolves. If the callback throws, the cursor is NOT advanced
@@ -575,6 +597,10 @@ declare class RealtimeClient {
575
597
  private subscriptions;
576
598
  private connecting;
577
599
  private roomWhereMap;
600
+ private roomErrorMap;
601
+ private roomGeneration;
602
+ private subEpoch;
603
+ private resubscribingRooms;
578
604
  private unsubscribeFromTokenRefreshed;
579
605
  private onVisibilityChange;
580
606
  private onOnline;
@@ -584,7 +610,9 @@ declare class RealtimeClient {
584
610
  private connectionStateListeners;
585
611
  private streamSubscriptions;
586
612
  constructor(config: RealtimeConfig);
587
- subscribe(projectId: string, collectionName: string, callback: (event: DatabaseChangeEvent) => void, onError?: (error: Error) => void, where?: Record<string, string | number | boolean>): Promise<() => void>;
613
+ subscribe(projectId: string, collectionName: string, callback: (event: DatabaseChangeEvent) => void, onError?: (error: Error & {
614
+ code?: string;
615
+ }) => void, where?: WhereFilter): Promise<() => void>;
588
616
  /**
589
617
  * Subscribe to a stream-mode collection using Redis Streams replay +
590
618
  * cursor-based delivery. Parallel to `subscribe()` (which targets
@@ -676,6 +704,31 @@ declare class RealtimeClient {
676
704
  private ensureWakeListeners;
677
705
  private detachWakeListeners;
678
706
  private resubscribeAll;
707
+ /**
708
+ * Parse a pubsub room key back into its projectId + collectionName.
709
+ * Room format: `db:{projectId}:{collectionName}` or that base plus `?filter`.
710
+ * Returns null for anything that isn't a well-formed `db:` room.
711
+ */
712
+ private parseRoom;
713
+ /** Build an Error carrying the gateway's typed `errorCode` (if any) as `.code`. */
714
+ private toSubscribeError;
715
+ /** Call every subscriber's error handler registered for `room` (#661). */
716
+ private notifyRoomError;
717
+ /**
718
+ * Re-run the subscribe handshake for one already-tracked room. Shared by
719
+ * reconnect resubscription (`resubscribeAll`) and the #661
720
+ * `subscription-invalidated` handler.
721
+ *
722
+ * - Deduped per room via `resubscribingRooms` so the duplicate invalidation
723
+ * events the gateway emits (one per evicted room) collapse to one handshake.
724
+ * - Snapshots the room's generation; the ack acts only if it is still current,
725
+ * so a late ack cannot clobber an unsubscribe/re-subscribe that happened in
726
+ * the meantime — and a success that arrives after the room was removed emits
727
+ * a compensating `unsubscribe` so no ghost server room is left behind.
728
+ * - On a genuine denial the subscription is evicted and its stored `onError`
729
+ * is called (with the typed `.code`), instead of silently going quiet.
730
+ */
731
+ private resubscribeRoom;
679
732
  private emitSubscribeEvents;
680
733
  private buildStreamPayload;
681
734
  private unsubscribeStream;
@@ -1089,16 +1142,18 @@ interface SubscribeWithSnapshotOptions<T> {
1089
1142
  * **Snapshot semantics** (full MongoDB query): supports operators like
1090
1143
  * `{ status: { $in: [...] } }`, `{ ts: { $gt: ... } }`, etc.
1091
1144
  *
1092
- * **Live-stream semantics** (gateway-side equality only): the stream
1093
- * filter only honours top-level primitive equality
1094
- * (`string | number | boolean` values). Operator-shaped values are
1095
- * silently dropped from the stream filter at handshake time the
1096
- * remaining primitive entries still apply, so a typical chat-style
1097
- * `{ chatId: 'c1' }` filter works identically on both sides. If you
1098
- * pass a mixed `{ chatId: 'c1', status: { $in: [...] } }`, the
1099
- * snapshot is fully filtered but the live stream filters by `chatId`
1100
- * only; you may receive `onChange` events for documents whose `status`
1101
- * is outside your `$in` set. Re-check in the handler if needed.
1145
+ * **Live-stream semantics** (top-level primitives only): this helper
1146
+ * forwards only top-level primitive values (`string | number | boolean`)
1147
+ * from the Mongo-query `where` to the live filter. MongoDB operators
1148
+ * (`$in`, `$gt`, …) are silently dropped at handshake time and are NOT
1149
+ * translated into the gateway's realtime `in` / `array-contains-any`
1150
+ * operators the remaining primitive entries still apply, so a typical
1151
+ * chat-style `{ chatId: 'c1' }` filter works identically on both sides. If
1152
+ * you pass a mixed `{ chatId: 'c1', status: { $in: [...] } }`, the snapshot
1153
+ * is fully filtered but the live stream filters by `chatId` only; you may
1154
+ * receive `onChange` events for documents whose `status` is outside your
1155
+ * `$in` set. Re-check in the handler, or use `subscribeEvents()` directly
1156
+ * with a realtime `{ status: { in: [...] } }` operator for live filtering.
1102
1157
  */
1103
1158
  where?: Record<string, unknown>;
1104
1159
  sort?: Record<string, 1 | -1>;
@@ -1159,7 +1214,7 @@ interface SubscribeWithSnapshotOptions<T> {
1159
1214
  interface SubscribeEventsHandlers<T = Record<string, unknown>> {
1160
1215
  /** Cursor to resume from. Undefined = fresh subscription, deliver only new events. */
1161
1216
  sinceId?: string;
1162
- where?: Record<string, string | number | boolean>;
1217
+ where?: WhereFilter;
1163
1218
  /**
1164
1219
  * Optional resume-cursor store. When set, the SDK loads the previous cursor
1165
1220
  * before subscribing and persists the new cursor after each delivered
@@ -1216,7 +1271,7 @@ interface StreamSubscription {
1216
1271
  getCursor(): string | undefined;
1217
1272
  }
1218
1273
  interface SubscribeHandlers<T = Record<string, unknown>> {
1219
- where?: Record<string, string | number | boolean>;
1274
+ where?: WhereFilter;
1220
1275
  onInsert?: (doc: T & {
1221
1276
  _id: string;
1222
1277
  }) => void;
@@ -1546,6 +1601,11 @@ interface FunctionInvokeOptions {
1546
1601
  *
1547
1602
  * If `true` but the user is not signed in, the header is simply omitted —
1548
1603
  * safe for `public` invokeMode.
1604
+ *
1605
+ * Note: for `authenticated`/`hybrid` invokeMode, the server additionally
1606
+ * requires the token's user to be a member of the target project — this
1607
+ * flag only controls whether the header is attached, not who the server
1608
+ * accepts.
1549
1609
  */
1550
1610
  authenticated?: boolean;
1551
1611
  payload?: Record<string, unknown>;
@@ -1564,10 +1624,14 @@ declare class FunctionsModule {
1564
1624
  * `config.apiUrl` (which already carries the `/api/v1` prefix).
1565
1625
  *
1566
1626
  * Auth defaults, based on `invokeMode` semantics:
1567
- * - webhook: pass `secret` → Authorization is NOT attached
1568
- * - authenticated: pass nothing → Authorization IS attached (from token manager)
1569
- * - public: pass nothing → Authorization is attached if logged in, else omitted
1570
- * - hybrid: pass both `secret` and `authenticated: true`
1627
+ * - webhook: pass `secret` → Authorization is NOT attached
1628
+ * - authenticated: pass nothing → Authorization IS attached (from token manager);
1629
+ * caller must be a member of the target project
1630
+ * - public: pass nothing → Authorization is attached if logged in, else omitted
1631
+ * - hybrid: pass both `secret` and `authenticated: true`; JWT path
1632
+ * requires project membership like `authenticated`
1633
+ * - platform-authenticated: pass nothing → Authorization IS attached (from token manager);
1634
+ * any signed-in user is accepted, still just attaches the bearer token
1571
1635
  *
1572
1636
  * To force a specific behaviour, set `authenticated` explicitly — it wins
1573
1637
  * over the `secret`-based default.
@@ -1763,4 +1827,4 @@ interface SpacelrClient {
1763
1827
  }
1764
1828
  declare function createClient(config: SpacelrClientConfig): SpacelrClient;
1765
1829
 
1766
- export { type ApiResponse, type AuthLostReason, type AuthState, type AuthStateListener, type AuthorizationUrlParams, BrowserTokenStorage, CodeChallengeMethod, type ConnectionState, CursorInvalidError, type CursorStorage, type DatabaseChangeEvent, type DownloadUrlResponse, type ExchangeCodeParams, type FileInfo, type FileListResponse, FileVisibility, ForbiddenError, type FunctionInvokeOptions, type FunctionInvokeResult, type GapReason, GrantType, type InitMultipartUploadParams, type InitMultipartUploadResponse, type JWK, type JWKSResponse, type ListFilesParams, type LoginParams, type LoginResponse, MemoryTokenStorage, NotFoundError, type OpenIDConfiguration, type PKCEChallenge, type PartEtag, type PasskeyAuthenticationOptionsJSON, type PasskeyAuthenticationResponseJSON, type PasskeyCredential, type PasskeyCredentialDescriptorJSON, type PasskeyLoginResponse, type PasskeyRegistrationOptionsJSON, type PasskeyRegistrationResponseJSON, type PasskeyRegistrationResult, type PushSubscriptionInfo, type QuotaInfo, type RegisterParams, type RegisterResponse, type Schedule, type ScheduleInvokeOptions, type ScheduleListOptions, type ScheduleStatus, type SearchOptions, ServerConfigError, 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 TimelineAndFilter, TimelineError, type TimelineFieldFilter, type TimelineFilter, type TimelineLeafFilter, TimelineModule, type TimelineOrderBy, type TimelineQueryOptions, type TimelineQueryResponse, type TimelineScalar, type TimelineSourceStats, TimeoutError, type TokenResponse, type TokenStorage, type TwoFactorResponse, type TwoFactorVerifyParams, type UnshareFileParams, type UploadFileParams, type UploadLargeFileParams, type UploadProgress, type UserInfo, type UserProfile, ValidationError, type VapidKeyResponse, createClient, generatePKCEChallenge, localStorageCursorStorage, memoryCursorStorage };
1830
+ export { type ApiResponse, type AuthLostReason, type AuthState, type AuthStateListener, type AuthorizationUrlParams, BrowserTokenStorage, CodeChallengeMethod, type ConnectionState, CursorInvalidError, type CursorStorage, type DatabaseChangeEvent, type DownloadUrlResponse, type ExchangeCodeParams, type FileInfo, type FileListResponse, FileVisibility, ForbiddenError, type FunctionInvokeOptions, type FunctionInvokeResult, type GapReason, GrantType, type InitMultipartUploadParams, type InitMultipartUploadResponse, type JWK, type JWKSResponse, type ListFilesParams, type LoginParams, type LoginResponse, MemoryTokenStorage, NotFoundError, type OpenIDConfiguration, type PKCEChallenge, type PartEtag, type PasskeyAuthenticationOptionsJSON, type PasskeyAuthenticationResponseJSON, type PasskeyCredential, type PasskeyCredentialDescriptorJSON, type PasskeyLoginResponse, type PasskeyRegistrationOptionsJSON, type PasskeyRegistrationResponseJSON, type PasskeyRegistrationResult, type PushSubscriptionInfo, type QuotaInfo, type RegisterParams, type RegisterResponse, type Schedule, type ScheduleInvokeOptions, type ScheduleListOptions, type ScheduleStatus, type SearchOptions, ServerConfigError, 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 TimelineAndFilter, TimelineError, type TimelineFieldFilter, type TimelineFilter, type TimelineLeafFilter, TimelineModule, type TimelineOrderBy, type TimelineQueryOptions, type TimelineQueryResponse, type TimelineScalar, type TimelineSourceStats, TimeoutError, type TokenResponse, type TokenStorage, type TwoFactorResponse, type TwoFactorVerifyParams, type UnshareFileParams, type UploadFileParams, type UploadLargeFileParams, type UploadProgress, type UserInfo, type UserProfile, ValidationError, type VapidKeyResponse, type WhereFilter, type WhereOperator, type WhereScalar, type WhereValue, createClient, generatePKCEChallenge, localStorageCursorStorage, memoryCursorStorage };
package/dist/index.js CHANGED
@@ -657,6 +657,57 @@ var PERMANENT_STREAM_ACK_ERRORS = /* @__PURE__ */ new Set([
657
657
  "Not a member of this project",
658
658
  "Subscribe denied"
659
659
  ]);
660
+ function isWhereScalar(value) {
661
+ const t = typeof value;
662
+ return t === "string" || t === "number" || t === "boolean";
663
+ }
664
+ function isWhereOperator(value) {
665
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
666
+ return false;
667
+ }
668
+ const keys = Object.keys(value);
669
+ if (keys.length !== 1) return false;
670
+ const op = keys[0];
671
+ if (op !== "in" && op !== "array-contains-any") return false;
672
+ const candidates = value[op];
673
+ return Array.isArray(candidates) && candidates.every(isWhereScalar);
674
+ }
675
+ var WHERE_PATH_ABSENT = /* @__PURE__ */ Symbol("where-path-absent");
676
+ var WHERE_PATH_UNRESOLVABLE = /* @__PURE__ */ Symbol("where-path-unresolvable");
677
+ var RESERVED_PATH_SEGMENTS = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
678
+ var MAX_WHERE_PATH_SEGMENTS = 16;
679
+ function resolveWherePath(document2, key) {
680
+ const segments = key.split(".");
681
+ if (segments.length > MAX_WHERE_PATH_SEGMENTS) return WHERE_PATH_ABSENT;
682
+ let cur = document2;
683
+ for (const segment of segments) {
684
+ if (RESERVED_PATH_SEGMENTS.has(segment)) return WHERE_PATH_ABSENT;
685
+ if (cur === null || cur === void 0) return WHERE_PATH_ABSENT;
686
+ if (Array.isArray(cur)) return WHERE_PATH_UNRESOLVABLE;
687
+ if (typeof cur !== "object") return WHERE_PATH_ABSENT;
688
+ if (!Object.prototype.hasOwnProperty.call(cur, segment)) {
689
+ return WHERE_PATH_ABSENT;
690
+ }
691
+ cur = cur[segment];
692
+ }
693
+ return cur === void 0 ? WHERE_PATH_ABSENT : cur;
694
+ }
695
+ function whereValueMatches(actual, expected) {
696
+ if (actual === WHERE_PATH_ABSENT || actual === WHERE_PATH_UNRESOLVABLE) {
697
+ return false;
698
+ }
699
+ if (isWhereOperator(expected)) {
700
+ if ("in" in expected) {
701
+ if (Array.isArray(actual)) return false;
702
+ return expected.in.includes(actual);
703
+ }
704
+ if (!Array.isArray(actual)) return false;
705
+ const candidates = expected["array-contains-any"];
706
+ return actual.some((el) => candidates.includes(el));
707
+ }
708
+ if (Array.isArray(actual)) return actual.includes(expected);
709
+ return actual === expected;
710
+ }
660
711
  var RealtimeClient = class {
661
712
  constructor(config) {
662
713
  this.socket = null;
@@ -664,6 +715,21 @@ var RealtimeClient = class {
664
715
  this.connecting = null;
665
716
  // Store original where objects per room for reconnect resubscription
666
717
  this.roomWhereMap = /* @__PURE__ */ new Map();
718
+ // Per-room, per-callback error handlers so EVERY subscriber to a room (not
719
+ // just the first) is notified on a denial, and a departed subscriber's handler
720
+ // is never left dangling for its still-active siblings (#661).
721
+ this.roomErrorMap = /* @__PURE__ */ new Map();
722
+ // Per-room generation stamp, taken from a global monotonic counter each time a
723
+ // room becomes active. A re-subscribe snapshots the room's stamp and acts on
724
+ // the ack only if it is still current — so a late ack cannot notify or clean
725
+ // up after the app has unsubscribed/re-subscribed (#661). Entries are deleted
726
+ // on last-unsubscribe (bounded); the global counter guarantees a re-subscribe
727
+ // always gets a strictly higher stamp, so there is no ABA hole.
728
+ this.roomGeneration = /* @__PURE__ */ new Map();
729
+ this.subEpoch = 0;
730
+ // Rooms with a re-subscribe handshake in flight — dedupes the duplicate
731
+ // `subscription-invalidated` events the gateway emits per evicted room (#661).
732
+ this.resubscribingRooms = /* @__PURE__ */ new Set();
667
733
  this.unsubscribeFromTokenRefreshed = null;
668
734
  // Wake-up listeners (browser only) — recover from long OS suspends where
669
735
  // socket.io's internal reconnect loop may have already given up.
@@ -692,7 +758,17 @@ var RealtimeClient = class {
692
758
  }
693
759
  const callbacks = this.subscriptions.get(room);
694
760
  callbacks?.add(callback);
761
+ if (onError) {
762
+ let handlers = this.roomErrorMap.get(room);
763
+ if (!handlers) {
764
+ handlers = /* @__PURE__ */ new Map();
765
+ this.roomErrorMap.set(room, handlers);
766
+ }
767
+ handlers.set(callback, onError);
768
+ }
695
769
  if (callbacks?.size === 1) {
770
+ const generation = ++this.subEpoch;
771
+ this.roomGeneration.set(room, generation);
696
772
  if (where && Object.keys(where).length > 0) {
697
773
  this.roomWhereMap.set(room, where);
698
774
  }
@@ -704,8 +780,8 @@ var RealtimeClient = class {
704
780
  "subscribe",
705
781
  payload,
706
782
  (response) => {
707
- if (response.error && onError) {
708
- onError(new Error(response.error));
783
+ if (response.error && this.roomGeneration.get(room) === generation) {
784
+ this.notifyRoomError(room, this.toSubscribeError(response));
709
785
  }
710
786
  }
711
787
  );
@@ -714,9 +790,12 @@ var RealtimeClient = class {
714
790
  const callbacks2 = this.subscriptions.get(room);
715
791
  if (callbacks2) {
716
792
  callbacks2.delete(callback);
793
+ this.roomErrorMap.get(room)?.delete(callback);
717
794
  if (callbacks2.size === 0) {
718
795
  this.subscriptions.delete(room);
719
796
  this.roomWhereMap.delete(room);
797
+ this.roomErrorMap.delete(room);
798
+ this.roomGeneration.delete(room);
720
799
  const payload = { projectId, collectionName };
721
800
  if (where && Object.keys(where).length > 0) {
722
801
  payload["where"] = where;
@@ -869,6 +948,9 @@ var RealtimeClient = class {
869
948
  }
870
949
  this.subscriptions.clear();
871
950
  this.roomWhereMap.clear();
951
+ this.roomErrorMap.clear();
952
+ this.roomGeneration.clear();
953
+ this.resubscribingRooms.clear();
872
954
  this.streamSubscriptions.clear();
873
955
  this.connecting = null;
874
956
  }
@@ -1018,6 +1100,18 @@ var RealtimeClient = class {
1018
1100
  }
1019
1101
  }
1020
1102
  });
1103
+ this.socket.on(
1104
+ "subscription-invalidated",
1105
+ (payload) => {
1106
+ if (!payload?.projectId || !payload?.collectionName) return;
1107
+ const base = `db:${payload.projectId}:${payload.collectionName}`;
1108
+ for (const room of this.subscriptions.keys()) {
1109
+ if (room === base || room.startsWith(`${base}?`)) {
1110
+ this.resubscribeRoom(room);
1111
+ }
1112
+ }
1113
+ }
1114
+ );
1021
1115
  this.socket.on("event", (payload) => {
1022
1116
  this.dispatchStreamEvent(payload).catch(() => void 0);
1023
1117
  });
@@ -1045,12 +1139,7 @@ var RealtimeClient = class {
1045
1139
  if (!where) return false;
1046
1140
  if (!event.document) return false;
1047
1141
  for (const [key, value] of Object.entries(where)) {
1048
- const docValue = event.document[key];
1049
- if (Array.isArray(docValue)) {
1050
- if (!docValue.includes(value)) {
1051
- return false;
1052
- }
1053
- } else if (docValue !== value) {
1142
+ if (!whereValueMatches(resolveWherePath(event.document, key), value)) {
1054
1143
  return false;
1055
1144
  }
1056
1145
  }
@@ -1124,31 +1213,88 @@ var RealtimeClient = class {
1124
1213
  this.onOnline = null;
1125
1214
  }
1126
1215
  resubscribeAll() {
1216
+ this.resubscribingRooms.clear();
1127
1217
  for (const [room] of this.subscriptions) {
1128
- const queryIdx = room.indexOf("?");
1129
- const basePart = queryIdx >= 0 ? room.substring(0, queryIdx) : room;
1130
- const parts = basePart.split(":");
1131
- if (parts.length >= 3) {
1132
- const projectId = parts[1];
1133
- const collectionName = parts.slice(2).join(":");
1134
- const payload = { projectId, collectionName };
1135
- const where = this.roomWhereMap.get(room);
1136
- if (where) {
1137
- payload["where"] = where;
1138
- }
1139
- this.socket?.emit(
1140
- "subscribe",
1141
- payload,
1142
- (response) => {
1143
- if (response?.error) {
1144
- this.subscriptions.delete(room);
1145
- this.roomWhereMap.delete(room);
1146
- }
1147
- }
1148
- );
1218
+ this.resubscribeRoom(room);
1219
+ }
1220
+ }
1221
+ /**
1222
+ * Parse a pubsub room key back into its projectId + collectionName.
1223
+ * Room format: `db:{projectId}:{collectionName}` or that base plus `?filter`.
1224
+ * Returns null for anything that isn't a well-formed `db:` room.
1225
+ */
1226
+ parseRoom(room) {
1227
+ const queryIdx = room.indexOf("?");
1228
+ const basePart = queryIdx >= 0 ? room.substring(0, queryIdx) : room;
1229
+ const parts = basePart.split(":");
1230
+ if (parts.length < 3 || parts[0] !== "db") return null;
1231
+ return { projectId: parts[1], collectionName: parts.slice(2).join(":") };
1232
+ }
1233
+ /** Build an Error carrying the gateway's typed `errorCode` (if any) as `.code`. */
1234
+ toSubscribeError(response) {
1235
+ const err = new Error(response.error ?? "Subscribe denied");
1236
+ if (response.errorCode) err.code = response.errorCode;
1237
+ return err;
1238
+ }
1239
+ /** Call every subscriber's error handler registered for `room` (#661). */
1240
+ notifyRoomError(room, error) {
1241
+ const handlers = this.roomErrorMap.get(room);
1242
+ if (!handlers) return;
1243
+ for (const handler of handlers.values()) {
1244
+ try {
1245
+ handler(error);
1246
+ } catch {
1149
1247
  }
1150
1248
  }
1151
1249
  }
1250
+ /**
1251
+ * Re-run the subscribe handshake for one already-tracked room. Shared by
1252
+ * reconnect resubscription (`resubscribeAll`) and the #661
1253
+ * `subscription-invalidated` handler.
1254
+ *
1255
+ * - Deduped per room via `resubscribingRooms` so the duplicate invalidation
1256
+ * events the gateway emits (one per evicted room) collapse to one handshake.
1257
+ * - Snapshots the room's generation; the ack acts only if it is still current,
1258
+ * so a late ack cannot clobber an unsubscribe/re-subscribe that happened in
1259
+ * the meantime — and a success that arrives after the room was removed emits
1260
+ * a compensating `unsubscribe` so no ghost server room is left behind.
1261
+ * - On a genuine denial the subscription is evicted and its stored `onError`
1262
+ * is called (with the typed `.code`), instead of silently going quiet.
1263
+ */
1264
+ resubscribeRoom(room) {
1265
+ if (!this.socket) return;
1266
+ if (this.resubscribingRooms.has(room)) return;
1267
+ if (!this.subscriptions.has(room)) return;
1268
+ const parsed = this.parseRoom(room);
1269
+ if (!parsed) return;
1270
+ const { projectId, collectionName } = parsed;
1271
+ const where = this.roomWhereMap.get(room);
1272
+ const generation = this.roomGeneration.get(room) ?? 0;
1273
+ const payload = { projectId, collectionName };
1274
+ if (where) payload["where"] = where;
1275
+ this.resubscribingRooms.add(room);
1276
+ this.socket.emit(
1277
+ "subscribe",
1278
+ payload,
1279
+ (response) => {
1280
+ this.resubscribingRooms.delete(room);
1281
+ if ((this.roomGeneration.get(room) ?? 0) !== generation) {
1282
+ if (!this.subscriptions.has(room) && !response?.error) {
1283
+ this.socket?.emit("unsubscribe", payload);
1284
+ }
1285
+ return;
1286
+ }
1287
+ if (response?.error) {
1288
+ const error = this.toSubscribeError(response);
1289
+ this.notifyRoomError(room, error);
1290
+ this.subscriptions.delete(room);
1291
+ this.roomWhereMap.delete(room);
1292
+ this.roomErrorMap.delete(room);
1293
+ this.roomGeneration.delete(room);
1294
+ }
1295
+ }
1296
+ );
1297
+ }
1152
1298
  emitSubscribeEvents(state) {
1153
1299
  return new Promise((resolve) => {
1154
1300
  if (!this.socket) {
@@ -3011,10 +3157,14 @@ var FunctionsModule = class {
3011
3157
  * `config.apiUrl` (which already carries the `/api/v1` prefix).
3012
3158
  *
3013
3159
  * Auth defaults, based on `invokeMode` semantics:
3014
- * - webhook: pass `secret` → Authorization is NOT attached
3015
- * - authenticated: pass nothing → Authorization IS attached (from token manager)
3016
- * - public: pass nothing → Authorization is attached if logged in, else omitted
3017
- * - hybrid: pass both `secret` and `authenticated: true`
3160
+ * - webhook: pass `secret` → Authorization is NOT attached
3161
+ * - authenticated: pass nothing → Authorization IS attached (from token manager);
3162
+ * caller must be a member of the target project
3163
+ * - public: pass nothing → Authorization is attached if logged in, else omitted
3164
+ * - hybrid: pass both `secret` and `authenticated: true`; JWT path
3165
+ * requires project membership like `authenticated`
3166
+ * - platform-authenticated: pass nothing → Authorization IS attached (from token manager);
3167
+ * any signed-in user is accepted, still just attaches the bearer token
3018
3168
  *
3019
3169
  * To force a specific behaviour, set `authenticated` explicitly — it wins
3020
3170
  * over the `secret`-based default.