backd-js 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backd-js",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "JavaScript client for backd: auth, sessions and data for browsers, Node and edge runtimes.",
5
5
  "keywords": [
6
6
  "backd",
package/src/admin.js CHANGED
@@ -27,6 +27,7 @@ import { Job } from './functions.js'
27
27
  * @property {number} limit
28
28
  * @property {number} skip
29
29
  * @property {boolean} has_more
30
+ * @property {string} [next_cursor] With `has_more`: pass it as `after` for the next page.
30
31
  */
31
32
 
32
33
  /**
@@ -65,6 +66,7 @@ import { Job } from './functions.js'
65
66
  * @property {'data' | 'admin'} role
66
67
  * @property {string} prefix First characters of the key.
67
68
  * @property {string[]} networks Where it may be used from; empty: anywhere.
69
+ * @property {string[]} scopes What it reaches (`read:blog/posts`, `call:main/export`…); empty: everything.
68
70
  * @property {string} created_at
69
71
  * @property {string | null} last_used_at
70
72
  * @property {string | null} expires_at
@@ -225,13 +227,14 @@ class AdminUsers {
225
227
  }
226
228
 
227
229
  /**
228
- * A page of users, sorted by email.
229
- * @param {{ limit?: number, skip?: number }} [params]
230
+ * A page of users, sorted by email. `after` is the `next_cursor` of the
231
+ * previous page (not combinable with `skip`).
232
+ * @param {{ limit?: number, skip?: number, after?: string }} [params]
230
233
  * @param {RequestOptions} [opts]
231
234
  * @returns {Promise<UserPage>}
232
235
  */
233
236
  async list(params = {}, opts) {
234
- return (await this.admin._request({ method: 'GET', path: ['users'], query: { limit: params.limit, skip: params.skip }, ...opts })).data
237
+ return (await this.admin._request({ method: 'GET', path: ['users'], query: { limit: params.limit, skip: params.skip, after: params.after }, ...opts })).data
235
238
  }
236
239
 
237
240
  /**
@@ -388,17 +391,20 @@ class AdminAPIKeys {
388
391
 
389
392
  /**
390
393
  * Creates a key; store its `key` now, it is never shown again.
391
- * @param {{ name: string, role?: 'data' | 'admin', expiresIn?: string, networks?: string[] }} input
392
- * `role` defaults to data; `expiresIn`: days (`90d`) or Go durations (`12h`).
394
+ * @param {{ name: string, role?: 'data' | 'admin', expiresIn?: string, networks?: string[], scopes?: string[] }} input
395
+ * `role` defaults to data; `expiresIn`: days (`90d`) or Go durations (`12h`). `scopes` limit what a
396
+ * data key reaches: `read`, `write` or `call`, optionally followed by `:<database>` or
397
+ * `:<database>/<collection or function>`; none means everything.
393
398
  * @param {RequestOptions} [opts]
394
399
  * @returns {Promise<NewAPIKey>}
395
400
  */
396
- async create({ name, role, expiresIn, networks }, opts) {
401
+ async create({ name, role, expiresIn, networks, scopes }, opts) {
397
402
  /** @type {Record<string, unknown>} */
398
403
  const body = { name }
399
404
  if (role !== undefined) body.role = role
400
405
  if (expiresIn !== undefined) body.expires_in = expiresIn
401
406
  if (networks !== undefined) body.networks = networks
407
+ if (scopes !== undefined) body.scopes = scopes
402
408
  return (await this.admin._request({ method: 'POST', path: ['apikeys'], body, ...opts })).data
403
409
  }
404
410
 
package/src/auth.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { AuthenticationError, VerificationRequiredError } from './errors.js'
2
+ import { COOKIE_SESSION } from './storage.js'
2
3
 
3
4
  /**
4
5
  * @typedef {import('./client.js').Client} Client
@@ -19,8 +20,9 @@ import { AuthenticationError, VerificationRequiredError } from './errors.js'
19
20
  /**
20
21
  * A new session.
21
22
  * @typedef {object} Session
22
- * @property {string} token Session token (`bds_…`); already stored by the client.
23
- * @property {'Bearer'} token_type
23
+ * @property {string} [token] Session token (`bds_…`); already stored by the client. Absent with `cookies: true`:
24
+ * the session is in an HttpOnly cookie, out of reach of scripts.
25
+ * @property {'Bearer'} [token_type]
24
26
  * @property {string} session_id
25
27
  * @property {string} expires_at RFC3339; pushed forward as the session is used.
26
28
  * @property {User} user
@@ -81,8 +83,9 @@ export class Auth {
81
83
  * @returns {Promise<Session>}
82
84
  */
83
85
  async signup({ email, password, invitation, locale, redirectTo }, opts) {
84
- /** @type {Record<string, string>} */
86
+ /** @type {Record<string, string | boolean>} */
85
87
  const body = { email, password }
88
+ if (this.client.cookies) body.cookie = true
86
89
  if (invitation) body.invitation = invitation
87
90
  if (locale) body.locale = locale
88
91
  if (redirectTo) body.redirect_to = redirectTo
@@ -100,7 +103,8 @@ export class Auth {
100
103
  * @returns {Promise<Session>}
101
104
  */
102
105
  async login({ email, password }, opts) {
103
- const { data } = await this.client.request({ method: 'POST', path: ['_auth', 'login'], body: { email, password }, auth: false, ...opts })
106
+ const body = this.client.cookies ? { email, password, cookie: true } : { email, password }
107
+ const { data } = await this.client.request({ method: 'POST', path: ['_auth', 'login'], body, auth: false, ...opts })
104
108
  return this.signedIn(data)
105
109
  }
106
110
 
@@ -311,11 +315,23 @@ export class Auth {
311
315
  }
312
316
 
313
317
  /**
314
- * The stored session token, if any.
318
+ * The stored session token, if any. Always null with `cookies: true`: the
319
+ * token is in a cookie that scripts can't read.
315
320
  * @returns {Promise<string | null>}
316
321
  */
317
322
  async token() {
318
- return (await this.client.storage.get()) ?? null
323
+ const token = (await this.client.storage.get()) ?? null
324
+ return token === COOKIE_SESSION ? null : token
325
+ }
326
+
327
+ /**
328
+ * Whether this client believes it is signed in: it holds a token, or (with
329
+ * `cookies: true`) it signed in and hasn't signed out or been refused since.
330
+ * A page that has just loaded knows nothing: ask the server with {@link Auth#me}.
331
+ * @returns {Promise<boolean>}
332
+ */
333
+ async hasSession() {
334
+ return Boolean(await this.client.storage.get())
319
335
  }
320
336
 
321
337
  /**
@@ -334,7 +350,7 @@ export class Auth {
334
350
  * @returns {Promise<Session>}
335
351
  */
336
352
  async signedIn(session) {
337
- await this.client.storage.set(session.token)
353
+ await this.client.storage.set(session.token ?? COOKIE_SESSION)
338
354
  this.emit('SIGNED_IN', session)
339
355
  return session
340
356
  }
package/src/client.js CHANGED
@@ -2,7 +2,7 @@ import { Admin } from './admin.js'
2
2
  import { Auth } from './auth.js'
3
3
  import { Database } from './data.js'
4
4
  import { NetworkError, RetryableError, errorFromResponse } from './errors.js'
5
- import { memoryStorage } from './storage.js'
5
+ import { COOKIE_SESSION, memoryStorage } from './storage.js'
6
6
 
7
7
  /**
8
8
  * @typedef {import('./storage.js').TokenStorage} TokenStorage
@@ -23,6 +23,9 @@ import { memoryStorage } from './storage.js'
23
23
  * @property {string} [apiKey] Server-side only: an API key (`bdk_…`). Full access to the realm.
24
24
  * @property {boolean} [dangerouslyAllowBrowser] Allow `apiKey` in a browser. Anyone who loads the page gets the key.
25
25
  * @property {TokenStorage} [storage] Where the session token lives; memory by default.
26
+ * @property {boolean} [cookies] Browser apps: keep the session in an HttpOnly cookie that scripts can't read,
27
+ * instead of a token. The realm must enable `sessions.cookie` and list the app's origin in `cors.origins`; the
28
+ * app's page (or the API) must be on a site the cookie's SameSite setting allows. The admin API doesn't take cookies.
26
29
  * @property {RetryOptions} [retry] Retries for 429/503; off by default.
27
30
  * @property {typeof fetch} [fetch] A fetch implementation; the global one by default.
28
31
  * @property {Record<string, string>} [headers] Extra headers for every request.
@@ -81,11 +84,16 @@ export class Client {
81
84
  '`dangerouslyAllowBrowser: true` if you really mean it.',
82
85
  )
83
86
  }
87
+ if (options.cookies && options.apiKey) {
88
+ throw new TypeError('createClient: `cookies` is for sessions in a browser; an API key is sent in a header')
89
+ }
84
90
  /** @readonly */
85
91
  this.url = options.url.replace(/\/+$/, '')
86
92
  /** @readonly */
87
93
  this.realm = options.realm
88
94
  /** @internal */
95
+ this.cookies = Boolean(options.cookies)
96
+ /** @internal */
89
97
  this.apiKey = options.apiKey
90
98
  /** @internal */
91
99
  this.fetchImpl = options.fetch ?? globalThis.fetch.bind(globalThis)
@@ -138,14 +146,15 @@ export class Client {
138
146
  } else {
139
147
  const token = await this.storage.get()
140
148
  if (token) {
141
- headers.Authorization = 'Bearer ' + token
149
+ if (token !== COOKIE_SESSION) headers.Authorization = 'Bearer ' + token // a cookie session is sent by the browser
142
150
  sentSession = true
143
151
  }
144
152
  }
145
153
  }
146
154
 
147
155
  const retry = req.retry ?? this.retry
148
- const safe = req.method === 'GET' || Object.keys(headers).some((h) => h.toLowerCase() === 'if-match')
156
+ // Safe to repeat: reads, writes conditional on a version, and requests the server deduplicates by key.
157
+ const safe = req.method === 'GET' || Object.keys(headers).some((h) => ['if-match', 'idempotency-key'].includes(h.toLowerCase()))
149
158
  const attempts = safe ? Math.max(0, retry.attempts) : 0
150
159
  for (let attempt = 0; ; attempt++) {
151
160
  /** @type {Response} */
@@ -156,6 +165,7 @@ export class Client {
156
165
  headers,
157
166
  body: req.body === undefined ? undefined : JSON.stringify(req.body),
158
167
  signal: req.signal,
168
+ credentials: this.cookies ? 'include' : undefined,
159
169
  })
160
170
  } catch (cause) {
161
171
  throw new NetworkError({
package/src/data.js CHANGED
@@ -30,6 +30,8 @@ import { Job } from './functions.js'
30
30
  * @property {string | string[]} [orderBy] Fields, `-` prefix for descending, e.g. `'-_meta.created_at'`.
31
31
  * @property {number} [limit] 1–100; default 20.
32
32
  * @property {number} [skip]
33
+ * @property {string} [after] The `next_cursor` of the previous page: continues the list after it
34
+ * (same `where` and `orderBy`). Can't be combined with `skip`.
33
35
  * @property {boolean} [count] Also return `total`.
34
36
  */
35
37
 
@@ -41,7 +43,9 @@ import { Job } from './functions.js'
41
43
  * @property {number} limit
42
44
  * @property {number} skip
43
45
  * @property {boolean} has_more
44
- * @property {number} [total] With `count: true`.
46
+ * @property {string} [next_cursor] With `has_more`: pass it as `after` to get the next page. Absent when the
47
+ * `orderBy` can't be followed by a cursor (arrays, objects, mixed types): use `skip` then.
48
+ * @property {number} [total] With `count: true`; counts the whole list, not what is after `after`.
45
49
  */
46
50
 
47
51
  /**
@@ -50,6 +54,15 @@ import { Job } from './functions.js'
50
54
  * @typedef {RequestOptions & { ifMatch?: number | string }} WriteOptions
51
55
  */
52
56
 
57
+ /**
58
+ * Options for creates and batches. `idempotencyKey` makes a retry safe: the
59
+ * server remembers the answer of the first request with a key for 24 hours and
60
+ * returns it again for the same key and body, instead of creating a second
61
+ * document. The client also retries such a request itself after a network
62
+ * error, `429` or `503` (see `retry`).
63
+ * @typedef {RequestOptions & { idempotencyKey?: string }} CreateOptions
64
+ */
65
+
53
66
  /**
54
67
  * One write for `db.batch(...)`, applied atomically with the others.
55
68
  * `create` and `replace` need `document`; `patch` needs `patch` (a JSON
@@ -95,14 +108,14 @@ export class Database {
95
108
  * mismatch (`ifMatch`) is a `VersionMismatchError`, same as a single
96
109
  * write.
97
110
  * @param {BatchOperation[]} operations
98
- * @param {RequestOptions} [opts]
111
+ * @param {CreateOptions} [opts]
99
112
  * @returns {Promise<(Doc | { id: string })[]>} one entry per operation, in order
100
113
  */
101
114
  async batch(operations, opts) {
102
115
  const body = {
103
116
  operations: operations.map(({ ifMatch, ...op }) => (ifMatch === undefined ? op : { ...op, if_match: ifMatchValue(ifMatch) })),
104
117
  }
105
- const { data } = await this.client.request({ method: 'POST', path: [this.name, '_batch'], body, ...opts })
118
+ const { data } = await this.client.request({ method: 'POST', path: [this.name, '_batch'], body, ...keyed(opts) })
106
119
  return data.results
107
120
  }
108
121
 
@@ -167,19 +180,29 @@ export class Collection {
167
180
 
168
181
  /**
169
182
  * Every matching document, fetching pages as needed:
170
- * `for await (const doc of posts.iterate({ where }))`. Pages are
171
- * fetched by offset, so documents created or deleted meanwhile can be
172
- * skipped or repeated.
183
+ * `for await (const doc of posts.iterate({ where }))`. Pages follow a
184
+ * cursor (`next_cursor`), so documents created, changed or deleted
185
+ * meanwhile never shift a page, and it is as fast at the end as at the
186
+ * start. An `orderBy` on arrays, objects or mixed types has no cursor:
187
+ * those lists are fetched by offset instead, where documents created or
188
+ * deleted meanwhile can be skipped or repeated.
173
189
  * @param {Omit<ListParams, 'skip' | 'count'>} [params] `limit` is the page size (default 100).
174
190
  * @param {RequestOptions} [opts]
175
191
  * @returns {AsyncGenerator<Doc<T>, void, undefined>}
176
192
  */
177
193
  async *iterate(params = {}, opts) {
178
194
  const limit = params.limit ?? 100
195
+ let after = params.after
179
196
  for (let skip = 0; ; skip += limit) {
180
- const page = await this.list({ ...params, limit, skip }, opts)
197
+ // With a cursor the next request carries it; without one, the offset.
198
+ const page = await this.list(after === undefined ? { ...params, limit, skip } : { ...params, limit, after }, opts)
181
199
  yield* page.items
182
200
  if (!page.has_more) return
201
+ if (page.next_cursor === undefined) {
202
+ if (after !== undefined) throw new Error('backd: the list has no next_cursor to continue after')
203
+ } else {
204
+ after = page.next_cursor
205
+ }
183
206
  }
184
207
  }
185
208
 
@@ -194,13 +217,15 @@ export class Collection {
194
217
  }
195
218
 
196
219
  /**
197
- * Creates a document. `id` and `_meta` are set by the server.
220
+ * Creates a document. `id` and `_meta` are set by the server. With an
221
+ * `idempotencyKey`, a repeated request returns the document created by the
222
+ * first one.
198
223
  * @param {T} doc
199
- * @param {RequestOptions} [opts]
224
+ * @param {CreateOptions} [opts]
200
225
  * @returns {Promise<Doc<T>>}
201
226
  */
202
227
  async create(doc, opts) {
203
- return (await this.client.request({ method: 'POST', path: this.path, body: doc, ...opts })).data
228
+ return (await this.client.request({ method: 'POST', path: this.path, body: doc, ...keyed(opts) })).data
204
229
  }
205
230
 
206
231
  /**
@@ -255,10 +280,21 @@ function listQuery(p) {
255
280
  order_by: Array.isArray(p.orderBy) ? p.orderBy.join(',') : p.orderBy,
256
281
  limit: p.limit,
257
282
  skip: p.skip,
283
+ after: p.after,
258
284
  count: p.count || undefined,
259
285
  }
260
286
  }
261
287
 
288
+ /**
289
+ * Turns `idempotencyKey` into an Idempotency-Key header.
290
+ * @param {CreateOptions} [opts]
291
+ * @returns {RequestOptions}
292
+ */
293
+ function keyed(opts = {}) {
294
+ const { idempotencyKey, ...rest } = opts
295
+ return idempotencyKey === undefined ? rest : { ...rest, headers: { ...rest.headers, 'Idempotency-Key': idempotencyKey } }
296
+ }
297
+
262
298
  /**
263
299
  * Turns `ifMatch` into an If-Match header.
264
300
  * @param {WriteOptions} opts
package/src/index.js CHANGED
@@ -30,6 +30,7 @@ export { memoryStorage, localStorageStorage } from './storage.js'
30
30
  * @typedef {import('./data.js').Meta} Meta
31
31
  * @typedef {import('./data.js').ListParams} ListParams
32
32
  * @typedef {import('./data.js').WriteOptions} WriteOptions
33
+ * @typedef {import('./data.js').CreateOptions} CreateOptions
33
34
  * @typedef {import('./admin.js').AdminUser} AdminUser
34
35
  * @typedef {import('./admin.js').UserPage} UserPage
35
36
  * @typedef {import('./admin.js').Invitation} Invitation
package/src/storage.js CHANGED
@@ -6,6 +6,13 @@
6
6
  * @property {() => void | Promise<void>} remove
7
7
  */
8
8
 
9
+ /**
10
+ * What the client stores, instead of a token, when the session lives in an
11
+ * HttpOnly cookie (`cookies: true`): a hint that there is a session, with
12
+ * nothing secret in it.
13
+ */
14
+ export const COOKIE_SESSION = 'cookie-session'
15
+
9
16
  /**
10
17
  * Keeps the token in memory: it is lost on reload, and scripts on the page
11
18
  * can't read it from storage. The default.
package/types/admin.d.ts CHANGED
@@ -28,6 +28,10 @@ export type UserPage = {
28
28
  limit: number;
29
29
  skip: number;
30
30
  has_more: boolean;
31
+ /**
32
+ * With `has_more`: pass it as `after` for the next page.
33
+ */
34
+ next_cursor?: string;
31
35
  };
32
36
  export type Invitation = {
33
37
  id: string;
@@ -79,6 +83,10 @@ export type APIKeyInfo = {
79
83
  * Where it may be used from; empty: anywhere.
80
84
  */
81
85
  networks: string[];
86
+ /**
87
+ * What it reaches (`read:blog/posts`, `call:main/export`…); empty: everything.
88
+ */
89
+ scopes: string[];
82
90
  created_at: string;
83
91
  last_used_at: string | null;
84
92
  expires_at: string | null;
@@ -241,6 +249,7 @@ export type JobsPage = {
241
249
  * @property {number} limit
242
250
  * @property {number} skip
243
251
  * @property {boolean} has_more
252
+ * @property {string} [next_cursor] With `has_more`: pass it as `after` for the next page.
244
253
  */
245
254
  /**
246
255
  * @typedef {object} Invitation
@@ -274,6 +283,7 @@ export type JobsPage = {
274
283
  * @property {'data' | 'admin'} role
275
284
  * @property {string} prefix First characters of the key.
276
285
  * @property {string[]} networks Where it may be used from; empty: anywhere.
286
+ * @property {string[]} scopes What it reaches (`read:blog/posts`, `call:main/export`…); empty: everything.
277
287
  * @property {string} created_at
278
288
  * @property {string | null} last_used_at
279
289
  * @property {string | null} expires_at
@@ -408,14 +418,16 @@ declare class AdminUsers {
408
418
  /** @param {Admin} admin */
409
419
  constructor(admin: Admin);
410
420
  /**
411
- * A page of users, sorted by email.
412
- * @param {{ limit?: number, skip?: number }} [params]
421
+ * A page of users, sorted by email. `after` is the `next_cursor` of the
422
+ * previous page (not combinable with `skip`).
423
+ * @param {{ limit?: number, skip?: number, after?: string }} [params]
413
424
  * @param {RequestOptions} [opts]
414
425
  * @returns {Promise<UserPage>}
415
426
  */
416
427
  list(params?: {
417
428
  limit?: number;
418
429
  skip?: number;
430
+ after?: string;
419
431
  }, opts?: RequestOptions): Promise<UserPage>;
420
432
  /**
421
433
  * The user with this email (case-insensitive), or null.
@@ -536,16 +548,19 @@ declare class AdminAPIKeys {
536
548
  list(opts?: RequestOptions): Promise<APIKeyInfo[]>;
537
549
  /**
538
550
  * Creates a key; store its `key` now, it is never shown again.
539
- * @param {{ name: string, role?: 'data' | 'admin', expiresIn?: string, networks?: string[] }} input
540
- * `role` defaults to data; `expiresIn`: days (`90d`) or Go durations (`12h`).
551
+ * @param {{ name: string, role?: 'data' | 'admin', expiresIn?: string, networks?: string[], scopes?: string[] }} input
552
+ * `role` defaults to data; `expiresIn`: days (`90d`) or Go durations (`12h`). `scopes` limit what a
553
+ * data key reaches: `read`, `write` or `call`, optionally followed by `:<database>` or
554
+ * `:<database>/<collection or function>`; none means everything.
541
555
  * @param {RequestOptions} [opts]
542
556
  * @returns {Promise<NewAPIKey>}
543
557
  */
544
- create({ name, role, expiresIn, networks }: {
558
+ create({ name, role, expiresIn, networks, scopes }: {
545
559
  name: string;
546
560
  role?: 'data' | 'admin';
547
561
  expiresIn?: string;
548
562
  networks?: string[];
563
+ scopes?: string[];
549
564
  }, opts?: RequestOptions): Promise<NewAPIKey>;
550
565
  /**
551
566
  * Revokes a key: it stops working at once.
package/types/auth.d.ts CHANGED
@@ -16,10 +16,11 @@ export type User = {
16
16
  };
17
17
  export type Session = {
18
18
  /**
19
- * Session token (`bds_…`); already stored by the client.
19
+ * Session token (`bds_…`); already stored by the client. Absent with `cookies: true`:
20
+ * the session is in an HttpOnly cookie, out of reach of scripts.
20
21
  */
21
- token: string;
22
- token_type: 'Bearer';
22
+ token?: string;
23
+ token_type?: 'Bearer';
23
24
  session_id: string;
24
25
  /**
25
26
  * RFC3339; pushed forward as the session is used.
@@ -246,10 +247,18 @@ export declare class Auth {
246
247
  */
247
248
  revokeSession(id: string, opts?: RequestOptions): Promise<void>;
248
249
  /**
249
- * The stored session token, if any.
250
+ * The stored session token, if any. Always null with `cookies: true`: the
251
+ * token is in a cookie that scripts can't read.
250
252
  * @returns {Promise<string | null>}
251
253
  */
252
254
  token(): Promise<string | null>;
255
+ /**
256
+ * Whether this client believes it is signed in: it holds a token, or (with
257
+ * `cookies: true`) it signed in and hasn't signed out or been refused since.
258
+ * A page that has just loaded knows nothing: ask the server with {@link Auth#me}.
259
+ * @returns {Promise<boolean>}
260
+ */
261
+ hasSession(): Promise<boolean>;
253
262
  /**
254
263
  * Calls listener on sign-in, sign-out and session expiry.
255
264
  * @param {AuthListener} listener
package/types/client.d.ts CHANGED
@@ -33,6 +33,12 @@ export type ClientOptions = {
33
33
  * Where the session token lives; memory by default.
34
34
  */
35
35
  storage?: TokenStorage;
36
+ /**
37
+ * Browser apps: keep the session in an HttpOnly cookie that scripts can't read,
38
+ * instead of a token. The realm must enable `sessions.cookie` and list the app's origin in `cors.origins`; the
39
+ * app's page (or the API) must be on a site the cookie's SameSite setting allows. The admin API doesn't take cookies.
40
+ */
41
+ cookies?: boolean;
36
42
  /**
37
43
  * Retries for 429/503; off by default.
38
44
  */
@@ -98,6 +104,8 @@ export declare class Client {
98
104
  /** @readonly */
99
105
  realm: string;
100
106
  /** @internal */
107
+ cookies: boolean;
108
+ /** @internal */
101
109
  apiKey: string | undefined;
102
110
  /** @internal */
103
111
  fetchImpl: typeof fetch;
package/types/data.d.ts CHANGED
@@ -47,6 +47,11 @@ export type ListParams = {
47
47
  */
48
48
  limit?: number;
49
49
  skip?: number;
50
+ /**
51
+ * The `next_cursor` of the previous page: continues the list after it
52
+ * (same `where` and `orderBy`). Can't be combined with `skip`.
53
+ */
54
+ after?: string;
50
55
  /**
51
56
  * Also return `total`.
52
57
  */
@@ -58,13 +63,21 @@ export type Page<T extends object = Record<string, any>> = {
58
63
  skip: number;
59
64
  has_more: boolean;
60
65
  /**
61
- * With `count: true`.
66
+ * With `has_more`: pass it as `after` to get the next page. Absent when the
67
+ * `orderBy` can't be followed by a cursor (arrays, objects, mixed types): use `skip` then.
68
+ */
69
+ next_cursor?: string;
70
+ /**
71
+ * With `count: true`; counts the whole list, not what is after `after`.
62
72
  */
63
73
  total?: number;
64
74
  };
65
75
  export type WriteOptions = RequestOptions & {
66
76
  ifMatch?: number | string;
67
77
  };
78
+ export type CreateOptions = RequestOptions & {
79
+ idempotencyKey?: string;
80
+ };
68
81
  export type BatchOperation = {
69
82
  op: 'create' | 'replace' | 'patch' | 'delete';
70
83
  collection: string;
@@ -100,6 +113,8 @@ export type BatchOperation = {
100
113
  * @property {string | string[]} [orderBy] Fields, `-` prefix for descending, e.g. `'-_meta.created_at'`.
101
114
  * @property {number} [limit] 1–100; default 20.
102
115
  * @property {number} [skip]
116
+ * @property {string} [after] The `next_cursor` of the previous page: continues the list after it
117
+ * (same `where` and `orderBy`). Can't be combined with `skip`.
103
118
  * @property {boolean} [count] Also return `total`.
104
119
  */
105
120
  /**
@@ -110,13 +125,23 @@ export type BatchOperation = {
110
125
  * @property {number} limit
111
126
  * @property {number} skip
112
127
  * @property {boolean} has_more
113
- * @property {number} [total] With `count: true`.
128
+ * @property {string} [next_cursor] With `has_more`: pass it as `after` to get the next page. Absent when the
129
+ * `orderBy` can't be followed by a cursor (arrays, objects, mixed types): use `skip` then.
130
+ * @property {number} [total] With `count: true`; counts the whole list, not what is after `after`.
114
131
  */
115
132
  /**
116
133
  * Options for writes. `ifMatch` makes the write fail with a
117
134
  * VersionMismatchError unless the document is still at that version.
118
135
  * @typedef {RequestOptions & { ifMatch?: number | string }} WriteOptions
119
136
  */
137
+ /**
138
+ * Options for creates and batches. `idempotencyKey` makes a retry safe: the
139
+ * server remembers the answer of the first request with a key for 24 hours and
140
+ * returns it again for the same key and body, instead of creating a second
141
+ * document. The client also retries such a request itself after a network
142
+ * error, `429` or `503` (see `retry`).
143
+ * @typedef {RequestOptions & { idempotencyKey?: string }} CreateOptions
144
+ */
120
145
  /**
121
146
  * One write for `db.batch(...)`, applied atomically with the others.
122
147
  * `create` and `replace` need `document`; `patch` needs `patch` (a JSON
@@ -156,10 +181,10 @@ export declare class Database {
156
181
  * mismatch (`ifMatch`) is a `VersionMismatchError`, same as a single
157
182
  * write.
158
183
  * @param {BatchOperation[]} operations
159
- * @param {RequestOptions} [opts]
184
+ * @param {CreateOptions} [opts]
160
185
  * @returns {Promise<(Doc | { id: string })[]>} one entry per operation, in order
161
186
  */
162
- batch(operations: BatchOperation[], opts?: RequestOptions): Promise<(Doc | {
187
+ batch(operations: BatchOperation[], opts?: CreateOptions): Promise<(Doc | {
163
188
  id: string;
164
189
  })[]>;
165
190
  /**
@@ -206,9 +231,12 @@ export declare class Collection<T extends object = Record<string, any>> {
206
231
  list(params?: ListParams, opts?: RequestOptions): Promise<Page<T>>;
207
232
  /**
208
233
  * Every matching document, fetching pages as needed:
209
- * `for await (const doc of posts.iterate({ where }))`. Pages are
210
- * fetched by offset, so documents created or deleted meanwhile can be
211
- * skipped or repeated.
234
+ * `for await (const doc of posts.iterate({ where }))`. Pages follow a
235
+ * cursor (`next_cursor`), so documents created, changed or deleted
236
+ * meanwhile never shift a page, and it is as fast at the end as at the
237
+ * start. An `orderBy` on arrays, objects or mixed types has no cursor:
238
+ * those lists are fetched by offset instead, where documents created or
239
+ * deleted meanwhile can be skipped or repeated.
212
240
  * @param {Omit<ListParams, 'skip' | 'count'>} [params] `limit` is the page size (default 100).
213
241
  * @param {RequestOptions} [opts]
214
242
  * @returns {AsyncGenerator<Doc<T>, void, undefined>}
@@ -222,12 +250,14 @@ export declare class Collection<T extends object = Record<string, any>> {
222
250
  */
223
251
  get(id: string, opts?: RequestOptions): Promise<Doc<T>>;
224
252
  /**
225
- * Creates a document. `id` and `_meta` are set by the server.
253
+ * Creates a document. `id` and `_meta` are set by the server. With an
254
+ * `idempotencyKey`, a repeated request returns the document created by the
255
+ * first one.
226
256
  * @param {T} doc
227
- * @param {RequestOptions} [opts]
257
+ * @param {CreateOptions} [opts]
228
258
  * @returns {Promise<Doc<T>>}
229
259
  */
230
- create(doc: T, opts?: RequestOptions): Promise<Doc<T>>;
260
+ create(doc: T, opts?: CreateOptions): Promise<Doc<T>>;
231
261
  /**
232
262
  * Replaces the whole document: fields not in `doc` are removed.
233
263
  * @param {string} id
package/types/index.d.ts CHANGED
@@ -17,6 +17,7 @@ export type ErrorDetail = import('./errors.js').ErrorDetail;
17
17
  export type Meta = import('./data.js').Meta;
18
18
  export type ListParams = import('./data.js').ListParams;
19
19
  export type WriteOptions = import('./data.js').WriteOptions;
20
+ export type CreateOptions = import('./data.js').CreateOptions;
20
21
  export type AdminUser = import('./admin.js').AdminUser;
21
22
  export type UserPage = import('./admin.js').UserPage;
22
23
  export type Invitation = import('./admin.js').Invitation;
@@ -40,6 +41,7 @@ export type Page<T extends object = Record<string, any>> = import('./data.js').P
40
41
  * @typedef {import('./data.js').Meta} Meta
41
42
  * @typedef {import('./data.js').ListParams} ListParams
42
43
  * @typedef {import('./data.js').WriteOptions} WriteOptions
44
+ * @typedef {import('./data.js').CreateOptions} CreateOptions
43
45
  * @typedef {import('./admin.js').AdminUser} AdminUser
44
46
  * @typedef {import('./admin.js').UserPage} UserPage
45
47
  * @typedef {import('./admin.js').Invitation} Invitation
@@ -10,6 +10,12 @@ export type TokenStorage = {
10
10
  set: (token: string) => void | Promise<void>;
11
11
  remove: () => void | Promise<void>;
12
12
  };
13
+ /**
14
+ * What the client stores, instead of a token, when the session lives in an
15
+ * HttpOnly cookie (`cookies: true`): a hint that there is a session, with
16
+ * nothing secret in it.
17
+ */
18
+ export declare const COOKIE_SESSION = "cookie-session";
13
19
  /**
14
20
  * Keeps the token in memory: it is lost on reload, and scripts on the page
15
21
  * can't read it from storage. The default.