@rebasepro/client 0.7.0 → 0.9.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/src/auth.ts CHANGED
@@ -1,32 +1,38 @@
1
1
  import { RebaseApiError, Transport } from "./transport";
2
- import { AuthChangeEvent } from "@rebasepro/types";
2
+ import type { AuthChangeEvent, RebaseSession, AuthTokens, DeviceSession, User } from "@rebasepro/types";
3
3
 
4
+ // Re-export canonical types so `import { RebaseSession } from "@rebasepro/client"` keeps working
5
+ export type { RebaseSession, AuthTokens, AuthChangeEvent, DeviceSession } from "@rebasepro/types";
4
6
 
5
- export interface RebaseUser {
7
+ /** @deprecated Use `User` from `@rebasepro/types` instead. */
8
+ export type RebaseUser = User;
9
+ /** @deprecated Use `AuthTokens` from `@rebasepro/types` instead. */
10
+ export type RebaseTokens = AuthTokens;
11
+
12
+ /** Minimal, non-sensitive user profile returned by {@link findUserByEmail}. */
13
+ export interface PublicUserProfile {
6
14
  uid: string;
7
- email: string | null;
8
15
  displayName: string | null;
9
16
  photoURL: string | null;
10
- emailVerified?: boolean;
11
- roles?: string[];
12
- providerId: string;
13
- isAnonymous: boolean;
14
- }
15
-
16
- export interface RebaseTokens {
17
- accessToken: string;
18
- refreshToken: string;
19
- accessTokenExpiresAt: number;
20
17
  }
21
18
 
22
- export interface RebaseSession {
23
- accessToken: string;
24
- refreshToken: string;
25
- expiresAt: number;
26
- user: RebaseUser;
19
+ /** Map a raw user object from an auth response (`/login`, `/refresh`, `/me`) to a `User`. */
20
+ function mapRawUser(raw: Record<string, unknown>): User {
21
+ return {
22
+ uid: raw.uid as string,
23
+ email: (raw.email as string | null) ?? null,
24
+ displayName: (raw.displayName as string | null) ?? null,
25
+ photoURL: (raw.photoURL as string | null) ?? null,
26
+ providerId: (raw.providerId as string | undefined) ?? "password",
27
+ isAnonymous: (raw.isAnonymous as boolean | undefined) ?? false,
28
+ emailVerified: raw.emailVerified as boolean | undefined,
29
+ roles: raw.roles as string[] | undefined,
30
+ metadata: raw.metadata as Record<string, unknown> | undefined,
31
+ };
27
32
  }
28
33
 
29
- export type { AuthChangeEvent };
34
+ /** Placeholder user, used only as a last resort when none can be resolved. */
35
+ const EMPTY_USER: User = { uid: "", email: null, displayName: null, photoURL: null, providerId: "password", isAnonymous: false };
30
36
 
31
37
 
32
38
  export interface AuthConfig {
@@ -70,6 +76,12 @@ export interface CreateAuthOptions {
70
76
  authPath?: string;
71
77
  autoRefresh?: boolean;
72
78
  persistSession?: boolean;
79
+ /**
80
+ * Authentication flow mode.
81
+ * - 'json' (default): Tokens are sent/received in JSON bodies. Refresh token is stored in local storage.
82
+ * - 'cookie': Refresh token is sent/received via httpOnly cookies. Access token remains in memory.
83
+ */
84
+ authFlowMode?: "json" | "cookie";
73
85
  }
74
86
 
75
87
  export function createAuth(transport: Transport, options?: CreateAuthOptions) {
@@ -78,13 +90,28 @@ export function createAuth(transport: Transport, options?: CreateAuthOptions) {
78
90
  const authPath = opts.authPath || "/auth";
79
91
  const autoRefresh = opts.autoRefresh !== false;
80
92
  const persistSession = opts.persistSession !== false;
93
+ const authFlowMode = opts.authFlowMode || "json";
81
94
 
82
95
  const STORAGE_KEY = "rebase_auth";
83
96
  const REFRESH_BUFFER_MS = 120000;
97
+ // Auto-refresh resilience: retry transient failures with exponential backoff
98
+ // (1s, 2s, 4s, … capped) before giving up and signing out.
99
+ const MAX_REFRESH_RETRIES = 5;
100
+ const REFRESH_RETRY_BASE_MS = 1000;
101
+ const REFRESH_RETRY_MAX_MS = 30000;
84
102
 
85
103
  let currentSession: RebaseSession | null = null;
86
104
  const listeners = new Set<(event: AuthChangeEvent, session: RebaseSession | null) => void>();
87
105
  let refreshTimeout: ReturnType<typeof setTimeout> | null = null;
106
+ // De-dupe concurrent refreshes. On boot (esp. cookie mode + React StrictMode)
107
+ // multiple callers can trigger refresh at once; without this they race — the
108
+ // server rotates the refresh token twice and the browser can end up with a
109
+ // cookie the DB no longer matches. A single in-flight promise is shared.
110
+ let inFlightRefresh: Promise<RebaseSession> | null = null;
111
+ let resolveInitialized: (value: void | PromiseLike<void>) => void;
112
+ const isInitialized = new Promise<void>((resolve) => {
113
+ resolveInitialized = resolve;
114
+ });
88
115
 
89
116
  function authUrl(endpoint: string) {
90
117
  return transport.baseUrl + transport.apiPath + authPath + endpoint;
@@ -96,10 +123,12 @@ export function createAuth(transport: Transport, options?: CreateAuthOptions) {
96
123
 
97
124
  function throwApiError(status: number, body: { error?: { message?: string; code?: string; details?: unknown }; message?: string; code?: string; details?: unknown } | undefined, statusText: string): never {
98
125
  throw new RebaseApiError(
99
- status,
100
126
  body?.error?.message || body?.message || statusText,
101
- body?.error?.code || body?.code,
102
- body?.error?.details || body?.details
127
+ {
128
+ status,
129
+ code: body?.error?.code || body?.code,
130
+ details: body?.error?.details || body?.details
131
+ }
103
132
  );
104
133
  }
105
134
 
@@ -110,7 +139,7 @@ export function createAuth(transport: Transport, options?: CreateAuthOptions) {
110
139
  }
111
140
 
112
141
  function saveSession(session: RebaseSession) {
113
- if (!persistSession) return;
142
+ if (!persistSession || authFlowMode === "cookie") return;
114
143
  try {
115
144
  storage.setItem(STORAGE_KEY, JSON.stringify(session));
116
145
  } catch (e) { /* ignore */ }
@@ -130,6 +159,37 @@ export function createAuth(transport: Transport, options?: CreateAuthOptions) {
130
159
  return null;
131
160
  }
132
161
 
162
+ /**
163
+ * A refresh failure is only fatal if the refresh token itself is rejected
164
+ * (expired / invalid / forbidden). Network blips, timeouts, and 5xx (e.g. a
165
+ * backend restart mid-session) are transient and must NOT log the user out.
166
+ */
167
+ function isFatalRefreshError(err: unknown): boolean {
168
+ if (!(err instanceof RebaseApiError)) return false; // network/other → transient
169
+ if (err.code === "INVALID_TOKEN" || err.code === "TOKEN_EXPIRED") return true;
170
+ // 401/403 are auth failures; other statuses (incl. 5xx, 0) are transient.
171
+ return err.status === 401 || err.status === 403;
172
+ }
173
+
174
+ async function attemptScheduledRefresh(attempt: number) {
175
+ try {
176
+ await refreshSession();
177
+ // On success, refreshSession() re-schedules the next refresh itself.
178
+ } catch (err) {
179
+ if (isFatalRefreshError(err)) {
180
+ signOut();
181
+ return;
182
+ }
183
+ if (attempt >= MAX_REFRESH_RETRIES) {
184
+ signOut();
185
+ return;
186
+ }
187
+ // Transient failure — back off and retry rather than dropping the session.
188
+ const backoff = Math.min(REFRESH_RETRY_BASE_MS * 2 ** attempt, REFRESH_RETRY_MAX_MS);
189
+ refreshTimeout = setTimeout(() => { void attemptScheduledRefresh(attempt + 1); }, backoff);
190
+ }
191
+ }
192
+
133
193
  function scheduleRefresh(expiresAt: number) {
134
194
  if (refreshTimeout) clearTimeout(refreshTimeout);
135
195
  if (!autoRefresh) return;
@@ -137,25 +197,20 @@ export function createAuth(transport: Transport, options?: CreateAuthOptions) {
137
197
  const delay = (expiresAt - REFRESH_BUFFER_MS) - Date.now();
138
198
 
139
199
  if (delay <= 0) {
140
- refreshSession().catch(() => signOut());
200
+ void attemptScheduledRefresh(0);
141
201
  return;
142
202
  }
143
203
 
144
- refreshTimeout = setTimeout(async () => {
145
- try {
146
- await refreshSession();
147
- } catch (e) {
148
- signOut();
149
- }
150
- }, delay);
204
+ refreshTimeout = setTimeout(() => { void attemptScheduledRefresh(0); }, delay);
151
205
  }
152
206
 
153
- function handleAuthResponse(data: { tokens: RebaseTokens, user: RebaseUser }, event?: AuthChangeEvent): RebaseSession {
207
+ function handleAuthResponse(data: { tokens: AuthTokens, user: Record<string, unknown> }, event?: AuthChangeEvent): RebaseSession {
208
+ const user: User = mapRawUser(data.user);
154
209
  const session: RebaseSession = {
155
210
  accessToken: data.tokens.accessToken,
156
- refreshToken: data.tokens.refreshToken,
211
+ refreshToken: data.tokens.refreshToken || (currentSession?.refreshToken) || "",
157
212
  expiresAt: data.tokens.accessTokenExpiresAt,
158
- user: data.user
213
+ user
159
214
  };
160
215
  currentSession = session;
161
216
  saveSession(session);
@@ -171,8 +226,9 @@ export function createAuth(transport: Transport, options?: CreateAuthOptions) {
171
226
  method: "POST",
172
227
  headers: { "Content-Type": "application/json" },
173
228
  body: JSON.stringify({ email,
174
- password })
175
- });
229
+ password }),
230
+ credentials: authFlowMode === "cookie" ? "include" : undefined
231
+ } as RequestInit);
176
232
  const body = await res.json().catch(() => ({}));
177
233
  if (!res.ok) throwApiError(res.status, body, res.statusText);
178
234
  const session = handleAuthResponse(body, "SIGNED_IN");
@@ -189,8 +245,9 @@ password };
189
245
  const res = await fetchFn(authUrl("/register"), {
190
246
  method: "POST",
191
247
  headers: { "Content-Type": "application/json" },
192
- body: JSON.stringify(payload)
193
- });
248
+ body: JSON.stringify(payload),
249
+ credentials: authFlowMode === "cookie" ? "include" : undefined
250
+ } as RequestInit);
194
251
  const body = await res.json().catch(() => ({}));
195
252
  if (!res.ok) throwApiError(res.status, body, res.statusText);
196
253
  const session = handleAuthResponse(body, "SIGNED_IN");
@@ -214,8 +271,9 @@ refreshToken: session.refreshToken };
214
271
  const res = await fetchFn(authUrl("/google"), {
215
272
  method: "POST",
216
273
  headers: { "Content-Type": "application/json" },
217
- body: JSON.stringify(payload)
218
- });
274
+ body: JSON.stringify(payload),
275
+ credentials: authFlowMode === "cookie" ? "include" : undefined
276
+ } as RequestInit);
219
277
  const responseBody = await res.json().catch(() => ({}));
220
278
  if (!res.ok) throwApiError(res.status, responseBody, res.statusText);
221
279
  const session = handleAuthResponse(responseBody, "SIGNED_IN");
@@ -230,8 +288,9 @@ refreshToken: session.refreshToken };
230
288
  method: "POST",
231
289
  headers: { "Content-Type": "application/json" },
232
290
  body: JSON.stringify({ code,
233
- redirectUri })
234
- });
291
+ redirectUri }),
292
+ credentials: authFlowMode === "cookie" ? "include" : undefined
293
+ } as RequestInit);
235
294
  const body = await res.json().catch(() => ({}));
236
295
  if (!res.ok) throwApiError(res.status, body, res.statusText);
237
296
  const session = handleAuthResponse(body, "SIGNED_IN");
@@ -249,8 +308,9 @@ refreshToken: session.refreshToken };
249
308
  const res = await fetchFn(authUrl(`/${providerId}`), {
250
309
  method: "POST",
251
310
  headers: { "Content-Type": "application/json" },
252
- body: JSON.stringify(payload)
253
- });
311
+ body: JSON.stringify(payload),
312
+ credentials: authFlowMode === "cookie" ? "include" : undefined
313
+ } as RequestInit);
254
314
  const body = await res.json().catch(() => ({}));
255
315
  if (!res.ok) throwApiError(res.status, body, res.statusText);
256
316
  const session = handleAuthResponse(body, "SIGNED_IN");
@@ -316,12 +376,13 @@ redirectUri });
316
376
  async function signOut() {
317
377
  const fetchFn = getFetch();
318
378
  try {
319
- if (currentSession?.refreshToken) {
379
+ if (authFlowMode === "cookie" || currentSession?.refreshToken) {
320
380
  await fetchFn(authUrl("/logout"), {
321
381
  method: "POST",
322
382
  headers: { "Content-Type": "application/json" },
323
- body: JSON.stringify({ refreshToken: currentSession.refreshToken })
324
- });
383
+ body: JSON.stringify({ refreshToken: currentSession?.refreshToken }),
384
+ credentials: authFlowMode === "cookie" ? "include" : undefined
385
+ } as RequestInit);
325
386
  }
326
387
  } catch (e) { /* ignore */ }
327
388
  currentSession = null;
@@ -334,23 +395,52 @@ redirectUri });
334
395
  emit("SIGNED_OUT", null);
335
396
  }
336
397
 
337
- async function refreshSession() {
338
- if (!currentSession?.refreshToken) {
398
+ function refreshSession(): Promise<RebaseSession> {
399
+ // Share a single in-flight refresh across concurrent callers.
400
+ if (inFlightRefresh) return inFlightRefresh;
401
+ inFlightRefresh = doRefreshSession().finally(() => {
402
+ inFlightRefresh = null;
403
+ });
404
+ return inFlightRefresh;
405
+ }
406
+
407
+ async function doRefreshSession(): Promise<RebaseSession> {
408
+ if (authFlowMode !== "cookie" && !currentSession?.refreshToken) {
339
409
  throw new Error("No active session to refresh");
340
410
  }
341
411
  const fetchFn = getFetch();
342
412
  const res = await fetchFn(authUrl("/refresh"), {
343
413
  method: "POST",
344
414
  headers: { "Content-Type": "application/json" },
345
- body: JSON.stringify({ refreshToken: currentSession.refreshToken })
346
- });
415
+ body: JSON.stringify({ refreshToken: currentSession?.refreshToken }),
416
+ credentials: authFlowMode === "cookie" ? "include" : undefined
417
+ } as RequestInit);
347
418
  const body = await res.json().catch(() => ({}));
348
419
  if (!res.ok) throwApiError(res.status, body, res.statusText);
420
+
421
+ const accessToken = body.tokens.accessToken;
422
+ transport.setToken(accessToken);
423
+
424
+ // Resolve the user, in order of preference:
425
+ // 1. the user returned by /refresh (modern backends include it),
426
+ // 2. the user already in memory,
427
+ // 3. a fetch of /me — required to restore a session from an httpOnly
428
+ // cookie alone (cold start in cookie mode), where there is no
429
+ // in-memory user and the backend didn't echo one.
430
+ let user = currentSession?.user;
431
+ if (body.user && typeof body.user.uid === "string") {
432
+ user = mapRawUser(body.user as Record<string, unknown>);
433
+ } else if (!user || !user.uid) {
434
+ try {
435
+ user = await getUser();
436
+ } catch { /* fall through to the empty stub below */ }
437
+ }
438
+
349
439
  const session: RebaseSession = {
350
- accessToken: body.tokens.accessToken,
351
- refreshToken: body.tokens.refreshToken,
440
+ accessToken,
441
+ refreshToken: body.tokens.refreshToken || currentSession?.refreshToken || "",
352
442
  expiresAt: body.tokens.accessTokenExpiresAt,
353
- user: currentSession.user
443
+ user: user ?? EMPTY_USER
354
444
  };
355
445
  currentSession = session;
356
446
  saveSession(session);
@@ -361,12 +451,26 @@ redirectUri });
361
451
  }
362
452
 
363
453
  async function getUser() {
364
- const data = await transport.request<{ user: RebaseUser }>(authPath + "/me", { method: "GET" });
454
+ const data = await transport.request<{ user: User }>(authPath + "/me", { method: "GET" });
455
+ return data.user;
456
+ }
457
+
458
+ /**
459
+ * Resolve an email to a minimal public profile (`uid`, `displayName`,
460
+ * `photoURL`) for invite-by-email flows. Returns `null` when no account
461
+ * matches. Requires the backend to opt in via `auth.allowUserLookup`;
462
+ * otherwise the endpoint is absent and this rejects.
463
+ */
464
+ async function findUserByEmail(email: string): Promise<PublicUserProfile | null> {
465
+ const data = await transport.request<{ user: PublicUserProfile | null }>(authPath + "/find-user", {
466
+ method: "POST",
467
+ body: JSON.stringify({ email })
468
+ });
365
469
  return data.user;
366
470
  }
367
471
 
368
472
  async function updateUser(updates: { displayName?: string, photoURL?: string }) {
369
- const data = await transport.request<{ user: RebaseUser }>(authPath + "/me", {
473
+ const data = await transport.request<{ user: User }>(authPath + "/me", {
370
474
  method: "PATCH",
371
475
  body: JSON.stringify(updates)
372
476
  });
@@ -446,8 +550,9 @@ newPassword })
446
550
  const res = await fetchFn(authUrl("/magic-link/verify"), {
447
551
  method: "POST",
448
552
  headers: { "Content-Type": "application/json" },
449
- body: JSON.stringify({ token })
450
- });
553
+ body: JSON.stringify({ token }),
554
+ credentials: authFlowMode === "cookie" ? "include" : undefined
555
+ } as RequestInit);
451
556
  const body = await res.json().catch(() => ({}));
452
557
  if (!res.ok) throwApiError(res.status, body, res.statusText);
453
558
  const session = handleAuthResponse(body, "SIGNED_IN");
@@ -456,8 +561,8 @@ accessToken: session.accessToken,
456
561
  refreshToken: session.refreshToken };
457
562
  }
458
563
 
459
- async function getSessions() {
460
- const data = await transport.request<{ sessions: Record<string, unknown>[] }>(authPath + "/sessions", { method: "GET" });
564
+ async function getSessions(): Promise<DeviceSession[]> {
565
+ const data = await transport.request<{ sessions: DeviceSession[] }>(authPath + "/sessions", { method: "GET" });
461
566
  return data.sessions;
462
567
  }
463
568
 
@@ -504,20 +609,37 @@ refreshToken: session.refreshToken };
504
609
 
505
610
  if (persistSession) {
506
611
  const stored = loadStoredSession();
507
- if (stored && stored.accessToken && stored.refreshToken) {
612
+ if (stored && stored.accessToken) {
508
613
  if (stored.expiresAt > Date.now()) {
509
614
  currentSession = stored;
510
615
  transport.setToken(stored.accessToken);
511
616
  scheduleRefresh(stored.expiresAt);
512
- } else if (stored.refreshToken) {
617
+ resolveInitialized!();
618
+ } else if (authFlowMode === "cookie" || stored.refreshToken) {
513
619
  currentSession = stored;
514
- refreshSession().catch(() => {
620
+ refreshSession().then(() => {
621
+ resolveInitialized!();
622
+ }).catch(() => {
515
623
  currentSession = null;
516
624
  clearStoredSession();
517
625
  transport.setToken(null);
626
+ resolveInitialized!();
518
627
  });
628
+ } else {
629
+ resolveInitialized!();
519
630
  }
631
+ } else if (authFlowMode === "cookie") {
632
+ // Silent refresh on boot to pick up httpOnly session
633
+ refreshSession().then(() => {
634
+ resolveInitialized!();
635
+ }).catch(() => {
636
+ resolveInitialized!();
637
+ });
638
+ } else {
639
+ resolveInitialized!();
520
640
  }
641
+ } else {
642
+ resolveInitialized!();
521
643
  }
522
644
 
523
645
  return {
@@ -539,6 +661,7 @@ refreshToken: session.refreshToken };
539
661
  signOut,
540
662
  refreshSession,
541
663
  getUser,
664
+ findUserByEmail,
542
665
  updateUser,
543
666
  resetPasswordForEmail,
544
667
  resetPassword,
@@ -552,7 +675,8 @@ refreshToken: session.refreshToken };
552
675
  revokeAllSessions,
553
676
  getAuthConfig,
554
677
  getSession,
555
- onAuthStateChange
678
+ onAuthStateChange,
679
+ isInitialized: () => isInitialized
556
680
  };
557
681
  }
558
682
 
@@ -42,7 +42,7 @@ describe("createCollectionClient", () => {
42
42
 
43
43
  const client = createCollectionClient(transport, "products");
44
44
  const result = await client.count({
45
- where: { status: "eq.published" }
45
+ where: { status: ["==", "published"] }
46
46
  });
47
47
 
48
48
  expect(transport.request).toHaveBeenCalledWith(
@@ -60,7 +60,7 @@ describe("createCollectionClient", () => {
60
60
 
61
61
  const client = createCollectionClient(transport, "items");
62
62
  const result = await client.count({
63
- orderBy: "created_at:desc"
63
+ orderBy: ["created_at", "desc"]
64
64
  });
65
65
 
66
66
  const calledUrl = (transport.request as ReturnType<typeof jest.fn>).mock.calls[0][0] as string;
@@ -111,7 +111,7 @@ offset: 10 });
111
111
  const client = createCollectionClient(transport, "orders");
112
112
  await client.count({
113
113
  where: {
114
- status: "eq.active",
114
+ status: ["==", "active"],
115
115
  total: [">=", 100]
116
116
  }
117
117
  });
@@ -123,7 +123,7 @@ offset: 10 });
123
123
  });
124
124
 
125
125
  describe("find()", () => {
126
- it("should call the list endpoint and return entities", async () => {
126
+ it("should call the list endpoint and return flat rows", async () => {
127
127
  (transport.request as ReturnType<typeof jest.fn>).mockResolvedValue({
128
128
  data: [{ id: "1",
129
129
  name: "Product A" }],
@@ -141,12 +141,101 @@ hasMore: false }
141
141
  { method: "GET" }
142
142
  );
143
143
  expect(result.data).toHaveLength(1);
144
+ // Flat row access — no .values wrapper
144
145
  expect(result.data[0].id).toBe("1");
145
- expect(result.data[0].path).toBe("products");
146
+ expect((result.data[0] as Record<string, unknown>).name).toBe("Product A");
147
+ // No path field — that's CMS leakage
148
+ expect((result.data[0] as Record<string, unknown>).path).toBeUndefined();
146
149
  expect(result.meta.total).toBe(1);
147
150
  });
148
151
  });
149
152
 
153
+ describe("findById()", () => {
154
+ it("should return a flat row directly", async () => {
155
+ (transport.request as ReturnType<typeof jest.fn>).mockResolvedValue({ id: "42", title: "Hello" });
156
+
157
+ const client = createCollectionClient(transport, "posts");
158
+ const result = await client.findById("42");
159
+
160
+ expect(transport.request).toHaveBeenCalledWith(
161
+ "/data/posts/42",
162
+ { method: "GET" }
163
+ );
164
+ expect(result).toBeDefined();
165
+ expect(result!.id).toBe("42");
166
+ expect((result as Record<string, unknown>).title).toBe("Hello");
167
+ });
168
+
169
+ it("should return undefined for 404", async () => {
170
+ const { RebaseApiError } = await import("./transport");
171
+ (transport.request as ReturnType<typeof jest.fn>).mockRejectedValue(
172
+ new RebaseApiError("Not Found", { status: 404 })
173
+ );
174
+
175
+ const client = createCollectionClient(transport, "posts");
176
+ const result = await client.findById("999");
177
+ expect(result).toBeUndefined();
178
+ });
179
+ });
180
+
181
+ describe("create()", () => {
182
+ it("should return a flat row", async () => {
183
+ (transport.request as ReturnType<typeof jest.fn>).mockResolvedValue({ id: "new-1", title: "Created" });
184
+
185
+ const client = createCollectionClient(transport, "posts");
186
+ const result = await client.create({ title: "Created" } as Record<string, unknown>);
187
+
188
+ expect(result.id).toBe("new-1");
189
+ expect((result as Record<string, unknown>).title).toBe("Created");
190
+ });
191
+ });
192
+
193
+ describe("update()", () => {
194
+ it("should return a flat row", async () => {
195
+ (transport.request as ReturnType<typeof jest.fn>).mockResolvedValue({ id: "1", title: "Updated" });
196
+
197
+ const client = createCollectionClient(transport, "posts");
198
+ const result = await client.update("1", { title: "Updated" } as Record<string, unknown>);
199
+
200
+ expect(result.id).toBe("1");
201
+ expect((result as Record<string, unknown>).title).toBe("Updated");
202
+ });
203
+
204
+ it("should throw RebaseApiError for 404", async () => {
205
+ const { RebaseApiError } = await import("./transport");
206
+ (transport.request as ReturnType<typeof jest.fn>).mockRejectedValue(
207
+ new RebaseApiError("Not Found", { status: 404 })
208
+ );
209
+
210
+ const client = createCollectionClient(transport, "posts");
211
+ await expect(
212
+ client.update("999", { title: "Nope" } as Record<string, unknown>)
213
+ ).rejects.toMatchObject({ status: 404 });
214
+ });
215
+ });
216
+
217
+ describe("delete()", () => {
218
+ it("should throw RebaseApiError for 404", async () => {
219
+ const { RebaseApiError } = await import("./transport");
220
+ (transport.request as ReturnType<typeof jest.fn>).mockRejectedValue(
221
+ new RebaseApiError("Not Found", { status: 404 })
222
+ );
223
+
224
+ const client = createCollectionClient(transport, "posts");
225
+ await expect(client.delete("999")).rejects.toMatchObject({ status: 404 });
226
+ });
227
+
228
+ it("should propagate non-404 errors", async () => {
229
+ const { RebaseApiError } = await import("./transport");
230
+ (transport.request as ReturnType<typeof jest.fn>).mockRejectedValue(
231
+ new RebaseApiError("Internal Server Error", { status: 500 })
232
+ );
233
+
234
+ const client = createCollectionClient(transport, "posts");
235
+ await expect(client.delete("1")).rejects.toThrow("Internal Server Error");
236
+ });
237
+ });
238
+
150
239
  describe("count() is defined", () => {
151
240
  it("should have count as a defined function on the accessor", () => {
152
241
  const client = createCollectionClient(transport, "products");