@rebasepro/client 0.17.3 → 0.18.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.
Files changed (63) hide show
  1. package/README.md +4 -0
  2. package/dist/auth.d.ts +80 -0
  3. package/dist/functions.d.ts +6 -1
  4. package/dist/index.d.ts +8 -0
  5. package/dist/index.es.js +471 -94
  6. package/dist/index.es.js.map +1 -1
  7. package/dist/offline-connectivity.d.ts +12 -1
  8. package/dist/offline.d.ts +23 -1
  9. package/dist/query-contract.types.d.ts +30 -0
  10. package/dist/realtime-channel.d.ts +29 -1
  11. package/dist/sdk_query_builder.d.ts +21 -2
  12. package/dist/transport.d.ts +24 -0
  13. package/package.json +28 -15
  14. package/src/admin.ts +0 -90
  15. package/src/anonymous-client-guard.test.ts +0 -190
  16. package/src/api-keys.ts +0 -87
  17. package/src/auth-listener-errors.test.ts +0 -57
  18. package/src/auth-refresh-overflow.test.ts +0 -89
  19. package/src/auth.ts +0 -982
  20. package/src/backups.ts +0 -40
  21. package/src/client-close.test.ts +0 -80
  22. package/src/collection-listen-meta.test.ts +0 -105
  23. package/src/collection-observe.test.ts +0 -138
  24. package/src/collection.test.ts +0 -293
  25. package/src/collection.ts +0 -525
  26. package/src/cron.test.ts +0 -164
  27. package/src/cron.ts +0 -62
  28. package/src/data-proxy.test.ts +0 -183
  29. package/src/errors.ts +0 -9
  30. package/src/functions.ts +0 -82
  31. package/src/index.ts +0 -639
  32. package/src/like-pattern-redos.test.ts +0 -61
  33. package/src/offline-codec.ts +0 -79
  34. package/src/offline-connectivity.test.ts +0 -191
  35. package/src/offline-connectivity.ts +0 -255
  36. package/src/offline-idb-store.test.ts +0 -340
  37. package/src/offline-integration.test.ts +0 -180
  38. package/src/offline-query.test.ts +0 -431
  39. package/src/offline-query.ts +0 -529
  40. package/src/offline-store.ts +0 -357
  41. package/src/offline-sync-engine.test.ts +0 -857
  42. package/src/offline.test.ts +0 -897
  43. package/src/offline.ts +0 -1928
  44. package/src/query-contract.types.ts +0 -206
  45. package/src/query_builder.ts +0 -1
  46. package/src/realtime-channel.test.ts +0 -542
  47. package/src/realtime-channel.ts +0 -539
  48. package/src/realtime-concurrent-subscribe.test.ts +0 -102
  49. package/src/realtime-error-surfacing.test.ts +0 -105
  50. package/src/realtime-optout.test.ts +0 -279
  51. package/src/realtime-row-identity.test.ts +0 -254
  52. package/src/realtime-subscription-key.test.ts +0 -92
  53. package/src/reviver.ts +0 -39
  54. package/src/sdk_query_builder.ts +0 -206
  55. package/src/storage-key-encoding.test.ts +0 -65
  56. package/src/storage-registry.ts +0 -102
  57. package/src/storage.ts +0 -253
  58. package/src/transport-baseurl.test.ts +0 -101
  59. package/src/transport.ts +0 -505
  60. package/src/vector-search-listen.test.ts +0 -42
  61. package/src/vector-search-query.test.ts +0 -55
  62. package/src/websocket-url.test.ts +0 -97
  63. package/src/websocket.ts +0 -1837
@@ -10,7 +10,18 @@
10
10
  * store immediately, which is the difference between an app that freezes when
11
11
  * the wifi drops and one that does not.
12
12
  */
13
- /** The request never reached the server, so nothing was decided by it. */
13
+ /**
14
+ * The request never reached the server, so nothing was decided by it.
15
+ *
16
+ * One question, one answer: `status === 0` is how a transport spells "there
17
+ * was no response at all", and the transport now wraps every `fetch` rejection
18
+ * into a `RebaseApiError` carrying it. This used to also sniff for the shapes
19
+ * a raw `fetch` rejects with — a `TypeError` in a browser, an `AbortError`, a
20
+ * `TimeoutError` — which is a list that has to be kept in step with three
21
+ * runtimes and was wrong in both directions: it missed anything they add, and
22
+ * it claimed a `TypeError` thrown by application code inside a callback was a
23
+ * network failure, which quietly served a cached answer in its place.
24
+ */
14
25
  export declare function isNetworkError(error: unknown): boolean;
15
26
  /**
16
27
  * Is the server still answering an earlier attempt of this same write?
package/dist/offline.d.ts CHANGED
@@ -119,7 +119,14 @@ export interface OfflineApi {
119
119
  /** Subscribe to queue-size changes (for a "pending changes" badge). */
120
120
  onQueueChange(listener: (count: number) => void): () => void;
121
121
  }
122
- /** True when a read failed because there was neither network nor local data. */
122
+ /**
123
+ * True when a read failed because there was neither network nor local data.
124
+ *
125
+ * The code is `OFFLINE`. It was `"offline"`, the one lowercase code in a
126
+ * surface whose every other code is `SCREAMING_SNAKE_CASE` — including the
127
+ * `NETWORK_ERROR` it sits next to — so `e.code === "OFFLINE"`, which is what
128
+ * anyone writes, never matched.
129
+ */
123
130
  export declare function isOfflineError(error: unknown): boolean;
124
131
  type AnyRow = Record<string, unknown>;
125
132
  type InnerFactory = (slug: string) => SDKCollectionClient<AnyRow>;
@@ -215,6 +222,21 @@ export declare class OfflineManager {
215
222
  */
216
223
  private answer;
217
224
  private localFind;
225
+ /**
226
+ * A temporary id of the type this collection's ids actually have.
227
+ *
228
+ * There is no schema here to read it off — the generated `Database` types
229
+ * are erased at compile time, and the whole point of this path is that the
230
+ * server is unreachable. So it is read off the rows the local database
231
+ * already holds: a collection whose cached ids are numbers gets a negative
232
+ * number, and everything else gets a UUID.
233
+ *
234
+ * A collection with nothing cached falls back to a UUID, which is the same
235
+ * guess as before — but that is a first write on a device that has never
236
+ * read the collection, which is also the case where nothing local can tell
237
+ * the difference. Reading one row first is enough to fix it.
238
+ */
239
+ private mintOfflineId;
218
240
  private rawLocalRow;
219
241
  private localRow;
220
242
  private setLocalRow;
@@ -141,4 +141,34 @@ export declare const readUnknown: (result: FindResult<ContractRow>) => any;
141
141
  * nothing here noticed.
142
142
  */
143
143
  export declare const rowStaysIndexable: (result: FindResult<ContractRow>) => Record<string, unknown>[];
144
+ /**
145
+ * Four calls that `find(params)` took and the fluent builder refused.
146
+ *
147
+ * The two forms are one API with two spellings, and a caller who reaches for
148
+ * the chainable one should not discover it is a subset. Each of these is
149
+ * documented, works over HTTP, and was a compile error on a generated SDK —
150
+ * which is to say: on every project that took the trouble to be typed.
151
+ */
152
+ /** 1. A relation path. `find({ where: { "author.name": … } })` always compiled. */
153
+ export declare const fluentRelationPath: (qb: SDKQueryBuilderInterface<PostRow>) => SDKQueryBuilderInterface<PostRow>;
154
+ /** 2. A JSON path into a `jsonb` column — `?metadata->>tier=eq.gold` on the wire. */
155
+ export declare const fluentJsonPath: (qb: SDKQueryBuilderInterface<ContractRow>) => SDKQueryBuilderInterface<ContractRow>;
156
+ /** 3. An aggregate over a to-many relation, which `FindParams.orderBy` takes. */
157
+ export declare const fluentRelationAggregateSort: (qb: SDKQueryBuilderInterface<PostRow>) => SDKQueryBuilderInterface<PostRow>;
158
+ /** 4. Paging past the `limit` ceiling, which the client can do and the builder could not. */
159
+ export declare const fluentIterate: (qb: SDKQueryBuilderInterface<ContractRow>) => AsyncIterableIterator<ContractRow>;
160
+ export declare const fluentFindAll: (qb: SDKQueryBuilderInterface<ContractRow>) => Promise<ContractRow[]>;
161
+ /**
162
+ * The loosening is bounded. A path overload keyed on "anything that is not a
163
+ * column" would have caught a mistyped column too and typed its value as
164
+ * `unknown` — turning every one of the assertions above into a silent pass.
165
+ * These are the same refusals as before.
166
+ */
167
+ export declare const fluentPathTypo: (qb: SDKQueryBuilderInterface<ContractRow>) => SDKQueryBuilderInterface<ContractRow>;
168
+ export declare const fluentPathDoesNotLoosenValues: (qb: SDKQueryBuilderInterface<ContractRow>) => SDKQueryBuilderInterface<ContractRow>;
169
+ export declare const fluentAggregateSortTypo: (qb: SDKQueryBuilderInterface<PostRow>) => SDKQueryBuilderInterface<PostRow>;
170
+ /** And the object form still takes all four, unchanged. */
171
+ export declare const paramsRelationPath: FindParams<PostRow>;
172
+ export declare const paramsJsonPath: FindParams<ContractRow>;
173
+ export declare const paramsRelationAggregateSort: FindParams<PostRow>;
144
174
  export {};
@@ -57,6 +57,7 @@ export interface BroadcastEvent {
57
57
  */
58
58
  export type { ChannelHistoryEntry } from "@rebasepro/types";
59
59
  import type { ChannelHistoryEntry } from "@rebasepro/types";
60
+ import { RebaseApiError } from "@rebasepro/types";
60
61
  /** The answer to a catch-up request. */
61
62
  export interface ChannelHistoryResult {
62
63
  messages: ChannelHistoryEntry[];
@@ -96,6 +97,7 @@ export declare class RebaseRealtimeChannel {
96
97
  private transport;
97
98
  private presenceHandlers;
98
99
  private broadcastHandlers;
100
+ private errorHandlers;
99
101
  private unsubscribers;
100
102
  /** Last known roster, kept so handlers always get a full picture. */
101
103
  private presences;
@@ -205,8 +207,34 @@ export declare class RebaseRealtimeChannel {
205
207
  * known, then on every change.
206
208
  */
207
209
  onPresence(handler: (state: PresenceState, diff?: PresenceDiff) => void): () => void;
208
- /** Send a broadcast. The sender does not receive its own message. */
210
+ /**
211
+ * Send a broadcast. The sender does not receive its own message.
212
+ *
213
+ * Resolves when the frame is written to the socket, **not** when the server
214
+ * has accepted it. Channel frames are fire-and-forget by design — a
215
+ * collaborative app broadcasts a cursor position sixty times a second, and
216
+ * waiting for an acknowledgement on each would make that a round trip.
217
+ *
218
+ * A refusal therefore arrives on {@link onError}, not as a rejection here.
219
+ */
209
220
  broadcast(event: string, payload: unknown): Promise<void>;
221
+ /**
222
+ * Observe refusals about this channel.
223
+ *
224
+ * `CHANNEL_FORBIDDEN` when an authorizer refuses a join, a broadcast or a
225
+ * history read; `RATE_LIMITED` past the channel frame budget;
226
+ * `CHANNEL_HISTORY_WRITE_FAILED` / `CHANNEL_HISTORY_READ_FAILED` when
227
+ * retention cannot be served; `CHANNEL_BUS_PAYLOAD_TOO_LARGE` when a
228
+ * broadcast on an ephemeral channel is too big to cross the bus.
229
+ *
230
+ * These used to be dropped on the floor — there is no promise to reject on
231
+ * a fire-and-forget frame, so a forbidden broadcast looked exactly like a
232
+ * delivered one. With no handler attached they are logged as a warning,
233
+ * because an unobserved refusal is the failure this exists for.
234
+ *
235
+ * @returns An unsubscribe function.
236
+ */
237
+ onError(handler: (error: RebaseApiError) => void): () => void;
210
238
  /** Observe broadcasts. Pass an event name to filter. */
211
239
  onBroadcast(handler: (event: BroadcastEvent) => void): () => void;
212
240
  onBroadcast(event: string, handler: (payload: unknown) => void): () => void;
@@ -1,4 +1,4 @@
1
- import { FindResult, LogicalCondition, SDKCollectionClient, SDKQueryBuilderInterface, WhereFilterOp, WhereValueFor, type ComputedSortField } from "@rebasepro/types";
1
+ import { FindResult, LogicalCondition, PageWalkOptions, RelationAggregateSort, SDKCollectionClient, SDKQueryBuilderInterface, WhereFilterOp, WhereValueFor, type ComputedSortField, type FieldPath, type NonColumnFieldPath } from "@rebasepro/types";
2
2
  /**
3
3
  * SDK Query Builder — returns flat rows (`FindResult<M>`) instead of
4
4
  * Entity-wrapped results (`FindResponse<M>`).
@@ -22,6 +22,8 @@ export declare class SDKQueryBuilder<M extends Record<string, unknown> = Record<
22
22
  * client.data.users.where('age', '>=', 18).find()
23
23
  */
24
24
  where<K extends keyof M & string, Op extends WhereFilterOp>(column: K, operator: Op, value: WhereValueFor<Op, M[K]>): this;
25
+ /** A relation path (`author.name`) or a JSON path (`metadata->>tier`). */
26
+ where(column: NonColumnFieldPath, operator: WhereFilterOp, value: unknown): this;
25
27
  where(logicalCondition: LogicalCondition): this;
26
28
  /**
27
29
  * Order the results by a specific column.
@@ -31,7 +33,7 @@ export declare class SDKQueryBuilder<M extends Record<string, unknown> = Record<
31
33
  * `.orderBy("roles").orderBy("created_at", "desc")` sorts by role and
32
34
  * shows the newest first within each one.
33
35
  */
34
- orderBy(column: (keyof M & string) | ComputedSortField, direction?: "asc" | "desc"): this;
36
+ orderBy(column: FieldPath<M> | ComputedSortField | RelationAggregateSort, direction?: "asc" | "desc"): this;
35
37
  /**
36
38
  * Limit the number of results returned.
37
39
  */
@@ -99,12 +101,29 @@ export declare class SDKQueryBuilder<M extends Record<string, unknown> = Record<
99
101
  * Execute the find query and return the results as flat rows.
100
102
  */
101
103
  find(): Promise<FindResult<M>>;
104
+ /**
105
+ * Page through everything this query matches, one row at a time.
106
+ *
107
+ * `.limit()` on the builder becomes the page size, so the ceiling on a
108
+ * single `find()` is not a ceiling on what the query can read.
109
+ */
110
+ iterate(options?: PageWalkOptions<M>): AsyncIterableIterator<M>;
111
+ /** Collect everything this query matches into one array. */
112
+ findAll(options?: PageWalkOptions<M> & {
113
+ maxRows?: number;
114
+ }): Promise<M[]>;
102
115
  /**
103
116
  * Count the records matching this query.
104
117
  */
105
118
  count(): Promise<number>;
106
119
  /**
107
120
  * Listen to realtime updates matching this query.
121
+ *
122
+ * The guard that used to stand here — `if (!this.collection.listen) throw`
123
+ * — has moved onto the client itself, which is where the same failure had
124
+ * to be diagnosed anyway: `client.data.posts.listen(…)`, one call short of
125
+ * this builder, answered `undefined is not a function`. The message is now
126
+ * the same either way.
108
127
  */
109
128
  listen(onUpdate: (data: FindResult<M>) => void, onError?: (error: Error) => void): () => void;
110
129
  }
@@ -71,6 +71,30 @@ export interface RebaseClientConfig {
71
71
  * and nothing is ever warned about.
72
72
  */
73
73
  anonymous?: boolean;
74
+ /**
75
+ * The schema this client was generated against, sent as `x-rebase-schema`.
76
+ *
77
+ * `rebase generate-sdk` writes the value into `schema.meta.ts` as
78
+ * `SCHEMA_VERSION`; pass it here and every request carries it. Advisory —
79
+ * nothing is ever refused for it — but it is what lets a server say "this
80
+ * client was built against a schema three deploys old" instead of
81
+ * answering a renamed field with a bare 400.
82
+ *
83
+ * The compatibility matrix has listed this header as something the SDK
84
+ * sends since it was written. Nothing sent it: the constant existed, the
85
+ * server echoed it back on two routes, and no client ever put it on a
86
+ * request. This is the missing half.
87
+ */
88
+ schemaVersion?: string;
89
+ /**
90
+ * Headers added to every request, beneath any per-request `headers`.
91
+ *
92
+ * For identifying the caller, not for authenticating it — the CLI uses it
93
+ * to send `User-Agent: rebase-cli/<version>`, which is what a control plane
94
+ * needs before it can refuse a CLI that is too old by name. `Authorization`
95
+ * is not settable here; it always comes from the token.
96
+ */
97
+ headers?: Record<string, string>;
74
98
  }
75
99
  /**
76
100
  * Facts about the surrounding client that the transport cannot read off its own
package/package.json CHANGED
@@ -1,37 +1,49 @@
1
1
  {
2
2
  "name": "@rebasepro/client",
3
- "type": "module",
4
- "version": "0.17.3",
3
+ "version": "0.18.0",
5
4
  "description": "HTTP SDK client for the Rebase custom backend",
6
- "funding": {
7
- "url": "https://github.com/sponsors/rebaseco"
5
+ "keywords": [
6
+ "rebase",
7
+ "sdk",
8
+ "client",
9
+ "postgres",
10
+ "backend",
11
+ "typescript"
12
+ ],
13
+ "homepage": "https://rebase.pro",
14
+ "bugs": {
15
+ "url": "https://github.com/rebasepro/rebase/issues"
8
16
  },
9
- "author": "Rebase",
10
- "license": "MIT",
11
17
  "repository": {
12
18
  "type": "git",
13
19
  "url": "https://github.com/rebasepro/rebase.git",
14
20
  "directory": "packages/client"
15
21
  },
22
+ "funding": {
23
+ "url": "https://github.com/sponsors/rebasepro"
24
+ },
25
+ "license": "MIT",
26
+ "author": "Rebase",
27
+ "type": "module",
16
28
  "main": "./dist/index.es.js",
17
29
  "module": "./dist/index.es.js",
18
30
  "types": "./dist/index.d.ts",
19
- "source": "src/index.ts",
20
31
  "engines": {
21
- "node": ">=20"
32
+ "node": ">=22.22.0"
22
33
  },
23
34
  "exports": {
24
35
  ".": {
25
36
  "types": "./dist/index.d.ts",
26
37
  "development": "./dist/index.es.js",
27
- "import": "./dist/index.es.js"
38
+ "import": "./dist/index.es.js",
39
+ "default": "./dist/index.es.js"
28
40
  },
29
41
  "./package.json": "./package.json"
30
42
  },
31
43
  "dependencies": {
32
- "@rebasepro/common": "0.17.3",
33
- "@rebasepro/types": "0.17.3",
34
- "@rebasepro/utils": "0.17.3"
44
+ "@rebasepro/common": "0.18.0",
45
+ "@rebasepro/utils": "0.18.0",
46
+ "@rebasepro/types": "0.18.0"
35
47
  },
36
48
  "devDependencies": {
37
49
  "@jest/globals": "^30.4.1",
@@ -48,8 +60,7 @@
48
60
  "ws": "^8.21.1"
49
61
  },
50
62
  "files": [
51
- "dist",
52
- "src"
63
+ "dist"
53
64
  ],
54
65
  "gitHead": "d935eefa5aa8d1009a2398cfac2c1e4ee9aeb6b6",
55
66
  "publishConfig": {
@@ -74,13 +85,15 @@
74
85
  "^@rebasepro/common$": "<rootDir>/../common/src/index.ts",
75
86
  "^@rebasepro/types$": "<rootDir>/../types/src/index.ts",
76
87
  "^@rebasepro/utils$": "<rootDir>/../utils/src/index.ts"
77
- }
88
+ },
89
+ "testTimeout": 30000
78
90
  },
79
91
  "scripts": {
80
92
  "watch": "vite build --watch",
81
93
  "build": "vite build && tsc --emitDeclarationOnly -p tsconfig.prod.json && node ../../tooling/scripts/add-dts-extensions.mjs dist && node ../../tooling/scripts/assert-build-output.mjs",
82
94
  "test:lint": "eslint \"src/**\" --quiet",
83
95
  "test": "jest --passWithNoTests --forceExit",
96
+ "test:watch": "jest --watch",
84
97
  "clean": "rm -rf dist && find ./src -name '*.js' -type f | xargs rm -f"
85
98
  }
86
99
  }
package/src/admin.ts DELETED
@@ -1,90 +0,0 @@
1
- import type { Transport } from "./transport";
2
- import { AdminUser } from "@rebasepro/types";
3
-
4
- export type { AdminUser };
5
-
6
-
7
- export interface CreateAdminOptions {
8
- adminPath?: string;
9
- }
10
-
11
- export function createAdmin(transport: Transport, options?: CreateAdminOptions) {
12
- const opts = options || {};
13
- const adminPath = opts.adminPath || "/admin";
14
-
15
- async function listUsers() {
16
- return transport.request<{ users: AdminUser[] }>(adminPath + "/users", { method: "GET" });
17
- }
18
-
19
- async function listUsersPaginated(options?: { search?: string; limit?: number; offset?: number; orderBy?: string; orderDir?: "asc" | "desc" }) {
20
- const params = new URLSearchParams();
21
- if (options?.limit !== undefined) params.set("limit", String(options.limit));
22
- if (options?.offset !== undefined) params.set("offset", String(options.offset));
23
- if (options?.search) params.set("search", options.search);
24
- if (options?.orderBy) params.set("orderBy", options.orderBy);
25
- if (options?.orderDir) params.set("orderDir", options.orderDir);
26
- const qs = params.toString();
27
- return transport.request<{ users: AdminUser[]; total: number; limit: number; offset: number }>(
28
- adminPath + "/users" + (qs ? "?" + qs : ""), { method: "GET" }
29
- );
30
- }
31
-
32
- async function getUser(userId: string) {
33
- return transport.request<{ user: AdminUser }>(adminPath + "/users/" + encodeURIComponent(userId), { method: "GET" });
34
- }
35
-
36
- async function createUser(data: { email: string, displayName?: string, password?: string, roles?: string[] }) {
37
- return transport.request<{ user: AdminUser }>(adminPath + "/users", {
38
- method: "POST",
39
- body: JSON.stringify(data)
40
- });
41
- }
42
-
43
- async function updateUser(userId: string, data: { email?: string, displayName?: string, password?: string, roles?: string[] }) {
44
- return transport.request<{ user: AdminUser }>(adminPath + "/users/" + encodeURIComponent(userId), {
45
- method: "PUT",
46
- body: JSON.stringify(data)
47
- });
48
- }
49
-
50
- async function deleteUser(userId: string) {
51
- return transport.request<{ success: boolean }>(adminPath + "/users/" + encodeURIComponent(userId), {
52
- method: "DELETE"
53
- });
54
- }
55
-
56
- async function resetPassword(userId: string, options?: { password?: string }) {
57
- return transport.request<{ user: AdminUser; temporaryPassword?: string; invitationSent?: boolean; emailDeliveryFailed?: boolean }>(
58
- adminPath + "/users/" + encodeURIComponent(userId) + "/reset-password",
59
- {
60
- method: "POST",
61
- ...(options?.password ? { body: JSON.stringify({ password: options.password }) } : {})
62
- }
63
- );
64
- }
65
-
66
- async function listRoles() {
67
- return transport.request<{ roles: Array<{ id: string; name: string }> }>(
68
- adminPath + "/roles",
69
- { method: "GET" }
70
- );
71
- }
72
-
73
- async function bootstrap() {
74
- return transport.request<{ success: boolean; message: string; user: { uid: string; roles: string[] } }>(adminPath + "/bootstrap", {
75
- method: "POST"
76
- });
77
- }
78
-
79
- return {
80
- listUsers,
81
- listUsersPaginated,
82
- getUser,
83
- createUser,
84
- updateUser,
85
- deleteUser,
86
- resetPassword,
87
- listRoles,
88
- bootstrap
89
- };
90
- }
@@ -1,190 +0,0 @@
1
- import { afterEach, beforeEach, describe, expect, it, jest } from "@jest/globals";
2
- import { ANONYMOUS_SERVER_CLIENT_WARNING, createTransport } from "./transport";
3
- import { createRebaseClient } from "./index";
4
-
5
- /**
6
- * A client built off-browser with no credential is silently anonymous: RLS
7
- * answers it with whatever is public, which is usually nothing. A `scrape-jobs`
8
- * script hit this twice. These pin the *narrowness* of the guard as much as the
9
- * guard itself — a warning that fires on legitimate anonymous clients (browser
10
- * before sign-in, public reads) is noise that teaches people to ignore warnings.
11
- */
12
-
13
- type MockFetch = jest.Mock<(input: RequestInfo | URL, init?: RequestInit) => Promise<Partial<Response>>>;
14
-
15
- const okFetch = (): MockFetch => {
16
- const fetchMock = jest.fn() as MockFetch;
17
- fetchMock.mockResolvedValue({
18
- ok: true,
19
- status: 200,
20
- text: async () => JSON.stringify({ data: [] })
21
- });
22
- return fetchMock;
23
- };
24
-
25
- const setWindow = (defined: boolean) => {
26
- if (!defined) { delete (globalThis as never as { window?: unknown }).window; return; }
27
- (globalThis as never as { window: unknown }).window = { location: { origin: "https://app.example.com", href: "https://app.example.com/" } };
28
- };
29
-
30
- const setDocument = (defined: boolean) => {
31
- if (!defined) { delete (globalThis as never as { document?: unknown }).document; return; }
32
- (globalThis as never as { document: unknown }).document = { cookie: "" };
33
- };
34
-
35
- let warn: jest.SpiedFunction<typeof console.warn>;
36
-
37
- beforeEach(() => {
38
- warn = jest.spyOn(console, "warn").mockImplementation(() => undefined);
39
- });
40
-
41
- afterEach(() => {
42
- warn.mockRestore();
43
- setWindow(false);
44
- setDocument(false);
45
- });
46
-
47
- const anonymousWarnings = () => warn.mock.calls.filter(([first]) => first === ANONYMOUS_SERVER_CLIENT_WARNING);
48
-
49
- describe("anonymous server client guard", () => {
50
- it("warns when a Node-side client has no credential at all", async () => {
51
- const transport = createTransport({ baseUrl: "http://localhost:3001", fetch: okFetch() as typeof globalThis.fetch });
52
-
53
- await transport.request("/data/jobs");
54
-
55
- expect(anonymousWarnings()).toHaveLength(1);
56
- expect(anonymousWarnings()[0][0]).toContain("anonymous: true");
57
- });
58
-
59
- it("warns once per client, not once per request", async () => {
60
- const transport = createTransport({ baseUrl: "http://localhost:3001", fetch: okFetch() as typeof globalThis.fetch });
61
-
62
- await transport.request("/data/jobs");
63
- await transport.request("/data/jobs");
64
- await transport.request("/data/other");
65
-
66
- expect(anonymousWarnings()).toHaveLength(1);
67
- });
68
-
69
- it("warns per client, so a second offending client is still reported", async () => {
70
- const a = createTransport({ baseUrl: "http://localhost:3001", fetch: okFetch() as typeof globalThis.fetch });
71
- const b = createTransport({ baseUrl: "http://localhost:3001", fetch: okFetch() as typeof globalThis.fetch });
72
-
73
- await a.request("/data/jobs");
74
- await b.request("/data/jobs");
75
-
76
- expect(anonymousWarnings()).toHaveLength(2);
77
- });
78
-
79
- it("stays silent in a browser, where anonymous-before-sign-in is normal", async () => {
80
- setWindow(true);
81
- const transport = createTransport({ fetch: okFetch() as typeof globalThis.fetch });
82
-
83
- await transport.request("/data/posts");
84
-
85
- expect(anonymousWarnings()).toHaveLength(0);
86
- });
87
-
88
- it("stays silent when only `document` is defined, so an SSR/test shim is not mistaken for Node", async () => {
89
- setDocument(true);
90
- const transport = createTransport({ baseUrl: "http://localhost:3001", fetch: okFetch() as typeof globalThis.fetch });
91
-
92
- await transport.request("/data/posts");
93
-
94
- expect(anonymousWarnings()).toHaveLength(0);
95
- });
96
-
97
- it("stays silent when a token was passed", async () => {
98
- const transport = createTransport({ baseUrl: "http://localhost:3001", token: "service-key", fetch: okFetch() as typeof globalThis.fetch });
99
-
100
- await transport.request("/data/jobs");
101
-
102
- expect(anonymousWarnings()).toHaveLength(0);
103
- });
104
-
105
- it("stays silent when the token arrives after construction (the reason this is checked at first request)", async () => {
106
- const transport = createTransport({ baseUrl: "http://localhost:3001", fetch: okFetch() as typeof globalThis.fetch });
107
- transport.setToken("service-key");
108
-
109
- await transport.request("/data/jobs");
110
-
111
- expect(anonymousWarnings()).toHaveLength(0);
112
- });
113
-
114
- it("stays silent when an auth token getter is installed", async () => {
115
- const transport = createTransport({ baseUrl: "http://localhost:3001", fetch: okFetch() as typeof globalThis.fetch });
116
- transport.setAuthTokenGetter(async () => "fetched-token");
117
-
118
- await transport.request("/data/jobs");
119
-
120
- expect(anonymousWarnings()).toHaveLength(0);
121
- });
122
-
123
- it("stays silent for a token getter that has nothing yet — the credential path exists", async () => {
124
- const transport = createTransport({ baseUrl: "http://localhost:3001", fetch: okFetch() as typeof globalThis.fetch });
125
- transport.setAuthTokenGetter(async () => null);
126
-
127
- await transport.request("/data/jobs");
128
-
129
- expect(anonymousWarnings()).toHaveLength(0);
130
- });
131
-
132
- it("stays silent for the cookie auth flow, where the credential is not a header", async () => {
133
- const transport = createTransport(
134
- { baseUrl: "http://localhost:3001", fetch: okFetch() as typeof globalThis.fetch },
135
- { credentialOutOfBand: true }
136
- );
137
-
138
- await transport.request("/data/jobs");
139
-
140
- expect(anonymousWarnings()).toHaveLength(0);
141
- });
142
-
143
- it("stays silent when the caller opted in with `anonymous: true`", async () => {
144
- const transport = createTransport({ baseUrl: "http://localhost:3001", anonymous: true, fetch: okFetch() as typeof globalThis.fetch });
145
-
146
- await transport.request("/data/jobs");
147
-
148
- expect(anonymousWarnings()).toHaveLength(0);
149
- });
150
- });
151
-
152
- describe("anonymous server client guard, through createRebaseClient", () => {
153
- it("warns for a credential-less client built in a script", async () => {
154
- const client = createRebaseClient({
155
- baseUrl: "http://localhost:3001",
156
- realtime: false,
157
- fetch: okFetch() as typeof globalThis.fetch
158
- });
159
-
160
- await client.collection("jobs").find();
161
-
162
- expect(anonymousWarnings()).toHaveLength(1);
163
- });
164
-
165
- it("stays silent when `auth.authFlowMode` is cookie", async () => {
166
- const client = createRebaseClient({
167
- baseUrl: "http://localhost:3001",
168
- realtime: false,
169
- fetch: okFetch() as typeof globalThis.fetch,
170
- auth: { authFlowMode: "cookie", persistSession: false }
171
- });
172
-
173
- await client.collection("jobs").find();
174
-
175
- expect(anonymousWarnings()).toHaveLength(0);
176
- });
177
-
178
- it("stays silent when the caller opted in with `anonymous: true`", async () => {
179
- const client = createRebaseClient({
180
- baseUrl: "http://localhost:3001",
181
- realtime: false,
182
- anonymous: true,
183
- fetch: okFetch() as typeof globalThis.fetch
184
- });
185
-
186
- await client.collection("jobs").find();
187
-
188
- expect(anonymousWarnings()).toHaveLength(0);
189
- });
190
- });