backd-js 0.1.15 → 0.3.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/data.js ADDED
@@ -0,0 +1,293 @@
1
+ import { Job } from './functions.js'
2
+
3
+ /**
4
+ * @typedef {import('./client.js').Client} Client
5
+ * @typedef {import('./client.js').RequestOptions} RequestOptions
6
+ * @typedef {import('./functions.js').JobData} JobData
7
+ */
8
+
9
+ /**
10
+ * Server-owned fields of a document.
11
+ * @typedef {object} Meta
12
+ * @property {string} created_at RFC3339 timestamp.
13
+ * @property {string} updated_at RFC3339 timestamp.
14
+ * @property {number} [version] Increases on every write; missing only on very old documents.
15
+ * @property {string | null} [owner] Id of the user who created it (realms with auth enabled).
16
+ * @property {string} [created_by] `user:<id>`, `key:<name>` or `anonymous`.
17
+ * @property {string} [updated_by] `user:<id>`, `key:<name>` or `anonymous`.
18
+ */
19
+
20
+ /**
21
+ * A stored document: the collection's fields plus `id` and `_meta`.
22
+ * @template {object} [T=Record<string, any>]
23
+ * @typedef {T & { id: string, _meta: Meta }} Doc
24
+ */
25
+
26
+ /**
27
+ * @typedef {object} ListParams
28
+ * @property {object | string} [where] Filter in backd's query language, e.g. `{ price: { $lt: 20 } }`
29
+ * or `{ $or: [{ title: { $icontains: 'go' } }, { body: { $icontains: 'go' } }] }`.
30
+ * @property {string | string[]} [orderBy] Fields, `-` prefix for descending, e.g. `'-_meta.created_at'`.
31
+ * @property {number} [limit] 1–100; default 20.
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`.
35
+ * @property {boolean} [count] Also return `total`.
36
+ */
37
+
38
+ /**
39
+ * A page of documents, as the API returns it.
40
+ * @template {object} [T=Record<string, any>]
41
+ * @typedef {object} Page
42
+ * @property {Doc<T>[]} items
43
+ * @property {number} limit
44
+ * @property {number} skip
45
+ * @property {boolean} has_more
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`.
49
+ */
50
+
51
+ /**
52
+ * Options for writes. `ifMatch` makes the write fail with a
53
+ * VersionMismatchError unless the document is still at that version.
54
+ * @typedef {RequestOptions & { ifMatch?: number | string }} WriteOptions
55
+ */
56
+
57
+ /**
58
+ * One write for `db.batch(...)`, applied atomically with the others.
59
+ * `create` and `replace` need `document`; `patch` needs `patch` (a JSON
60
+ * Merge Patch). `replace`, `patch` and `delete` need `id`, and accept
61
+ * `ifMatch` (like `WriteOptions`).
62
+ * @typedef {object} BatchOperation
63
+ * @property {'create' | 'replace' | 'patch' | 'delete'} op
64
+ * @property {string} collection
65
+ * @property {string} [id]
66
+ * @property {Record<string, any>} [document]
67
+ * @property {Record<string, any>} [patch]
68
+ * @property {number | string} [ifMatch]
69
+ */
70
+
71
+ /** A database of the realm: `client.db(name)`. */
72
+ export class Database {
73
+ /**
74
+ * @param {Client} client
75
+ * @param {string} name
76
+ */
77
+ constructor(client, name) {
78
+ /** @internal */
79
+ this.client = client
80
+ /** @readonly */
81
+ this.name = name
82
+ }
83
+
84
+ /**
85
+ * A collection of this database.
86
+ * @template {object} [T=Record<string, any>]
87
+ * @param {string} name
88
+ * @returns {Collection<T>}
89
+ */
90
+ collection(name) {
91
+ return new Collection(this.client, this.name, name)
92
+ }
93
+
94
+ /**
95
+ * Creates, replaces, patches and deletes documents across this
96
+ * database's collections in one MongoDB transaction: either every
97
+ * operation applies, or none does (up to 100 operations). Rejects with
98
+ * a `BackdError` naming the first operation that failed; a version
99
+ * mismatch (`ifMatch`) is a `VersionMismatchError`, same as a single
100
+ * write.
101
+ * @param {BatchOperation[]} operations
102
+ * @param {RequestOptions} [opts]
103
+ * @returns {Promise<(Doc | { id: string })[]>} one entry per operation, in order
104
+ */
105
+ async batch(operations, opts) {
106
+ const body = {
107
+ operations: operations.map(({ ifMatch, ...op }) => (ifMatch === undefined ? op : { ...op, if_match: ifMatchValue(ifMatch) })),
108
+ }
109
+ const { data } = await this.client.request({ method: 'POST', path: [this.name, '_batch'], body, ...opts })
110
+ return data.results
111
+ }
112
+
113
+ /**
114
+ * Calls a function. Returns its output directly for a `sync`
115
+ * function; for `async`, a `Job` handle to poll instead of the
116
+ * output (`job.status()`, or `job.wait()` for the output). A
117
+ * function's own error (`ctx.error(status, code, message)`) arrives
118
+ * as a `BackdError` with that status and code — for `async`, only
119
+ * once `job.wait()` resolves, not from this call itself.
120
+ *
121
+ * `webhook` functions can't be called through this method: their
122
+ * caller is whatever service sends the webhook, never this client.
123
+ * @param {string} name
124
+ * @param {unknown} [input] Any JSON value; omitted is sent as `null`.
125
+ * @param {RequestOptions & { idempotencyKey?: string }} [opts]
126
+ * @returns {Promise<unknown | Job>}
127
+ */
128
+ async fn(name, input, opts = {}) {
129
+ const { idempotencyKey, headers, ...rest } = opts
130
+ const reqHeaders = idempotencyKey === undefined ? headers : { ...headers, 'Idempotency-Key': idempotencyKey }
131
+ const { status, data } = await this.client.request({
132
+ method: 'POST',
133
+ path: [this.name, '_func', name],
134
+ body: input === undefined ? null : input,
135
+ headers: reqHeaders,
136
+ ...rest,
137
+ })
138
+ if (status === 202) return new Job(this.client, this.name, /** @type {JobData} */ (data))
139
+ return data
140
+ }
141
+ }
142
+
143
+ /**
144
+ * A collection: `client.db(db).collection(name)`. `T` describes the
145
+ * documents' own fields.
146
+ * @template {object} [T=Record<string, any>]
147
+ */
148
+ export class Collection {
149
+ /**
150
+ * @param {Client} client
151
+ * @param {string} database
152
+ * @param {string} name
153
+ */
154
+ constructor(client, database, name) {
155
+ /** @internal */
156
+ this.client = client
157
+ /** @internal */
158
+ this.path = [database, name]
159
+ }
160
+
161
+ /**
162
+ * One page of documents the caller may read.
163
+ * @param {ListParams} [params]
164
+ * @param {RequestOptions} [opts]
165
+ * @returns {Promise<Page<T>>}
166
+ */
167
+ async list(params = {}, opts) {
168
+ const { data } = await this.client.request({ method: 'GET', path: this.path, query: listQuery(params), ...opts })
169
+ return data
170
+ }
171
+
172
+ /**
173
+ * Every matching document, fetching pages as needed:
174
+ * `for await (const doc of posts.iterate({ where }))`. Pages follow a
175
+ * cursor (`next_cursor`), so documents created, changed or deleted
176
+ * meanwhile never shift a page, and it is as fast at the end as at the
177
+ * start. An `orderBy` on arrays, objects or mixed types has no cursor:
178
+ * those lists are fetched by offset instead, where documents created or
179
+ * deleted meanwhile can be skipped or repeated.
180
+ * @param {Omit<ListParams, 'skip' | 'count'>} [params] `limit` is the page size (default 100).
181
+ * @param {RequestOptions} [opts]
182
+ * @returns {AsyncGenerator<Doc<T>, void, undefined>}
183
+ */
184
+ async *iterate(params = {}, opts) {
185
+ const limit = params.limit ?? 100
186
+ let after = params.after
187
+ for (let skip = 0; ; skip += limit) {
188
+ // With a cursor the next request carries it; without one, the offset.
189
+ const page = await this.list(after === undefined ? { ...params, limit, skip } : { ...params, limit, after }, opts)
190
+ yield* page.items
191
+ if (!page.has_more) return
192
+ if (page.next_cursor === undefined) {
193
+ if (after !== undefined) throw new Error('backd: the list has no next_cursor to continue after')
194
+ } else {
195
+ after = page.next_cursor
196
+ }
197
+ }
198
+ }
199
+
200
+ /**
201
+ * One document; NotFoundError if it doesn't exist or the caller may not read it.
202
+ * @param {string} id
203
+ * @param {RequestOptions} [opts]
204
+ * @returns {Promise<Doc<T>>}
205
+ */
206
+ async get(id, opts) {
207
+ return (await this.client.request({ method: 'GET', path: [...this.path, id], ...opts })).data
208
+ }
209
+
210
+ /**
211
+ * Creates a document. `id` and `_meta` are set by the server.
212
+ * @param {T} doc
213
+ * @param {RequestOptions} [opts]
214
+ * @returns {Promise<Doc<T>>}
215
+ */
216
+ async create(doc, opts) {
217
+ return (await this.client.request({ method: 'POST', path: this.path, body: doc, ...opts })).data
218
+ }
219
+
220
+ /**
221
+ * Replaces the whole document: fields not in `doc` are removed.
222
+ * @param {string} id
223
+ * @param {T} doc
224
+ * @param {WriteOptions} [opts]
225
+ * @returns {Promise<Doc<T>>}
226
+ */
227
+ async replace(id, doc, opts = {}) {
228
+ return (await this.client.request({ method: 'PUT', path: [...this.path, id], body: doc, ...write(opts) })).data
229
+ }
230
+
231
+ /**
232
+ * Updates some fields (JSON Merge Patch): fields in `patch` are set,
233
+ * nested objects are merged, and `null` removes a field.
234
+ * @param {string} id
235
+ * @param {Partial<T> | Record<string, unknown>} patch
236
+ * @param {WriteOptions} [opts]
237
+ * @returns {Promise<Doc<T>>}
238
+ */
239
+ async patch(id, patch, opts = {}) {
240
+ return (
241
+ await this.client.request({
242
+ method: 'PATCH',
243
+ path: [...this.path, id],
244
+ body: patch,
245
+ contentType: 'application/merge-patch+json',
246
+ ...write(opts),
247
+ })
248
+ ).data
249
+ }
250
+
251
+ /**
252
+ * Deletes a document.
253
+ * @param {string} id
254
+ * @param {WriteOptions} [opts]
255
+ * @returns {Promise<void>}
256
+ */
257
+ async delete(id, opts = {}) {
258
+ await this.client.request({ method: 'DELETE', path: [...this.path, id], ...write(opts) })
259
+ }
260
+ }
261
+
262
+ /**
263
+ * @param {ListParams} p
264
+ * @returns {Record<string, string | number | boolean | undefined>}
265
+ */
266
+ function listQuery(p) {
267
+ return {
268
+ where: p.where === undefined ? undefined : typeof p.where === 'string' ? p.where : JSON.stringify(p.where),
269
+ order_by: Array.isArray(p.orderBy) ? p.orderBy.join(',') : p.orderBy,
270
+ limit: p.limit,
271
+ skip: p.skip,
272
+ after: p.after,
273
+ count: p.count || undefined,
274
+ }
275
+ }
276
+
277
+ /**
278
+ * Turns `ifMatch` into an If-Match header.
279
+ * @param {WriteOptions} opts
280
+ * @returns {RequestOptions}
281
+ */
282
+ function write({ ifMatch, ...opts }) {
283
+ if (ifMatch === undefined) return opts
284
+ return { ...opts, headers: { ...opts.headers, 'If-Match': ifMatchValue(ifMatch) } }
285
+ }
286
+
287
+ /**
288
+ * `3` → `"3"`; strings (`*`, `"3"`, `"2", "3"`) are sent as given.
289
+ * @param {number | string} v
290
+ */
291
+ export function ifMatchValue(v) {
292
+ return typeof v === 'number' ? `"${v}"` : v
293
+ }
package/src/errors.js ADDED
@@ -0,0 +1,135 @@
1
+ /**
2
+ * @typedef {object} ErrorDetail
3
+ * @property {string} path Field or parameter the problem is about.
4
+ * @property {string} reason What's wrong with it.
5
+ */
6
+
7
+ /**
8
+ * An error answered by backd, or a network failure (status 0).
9
+ * `code` is stable and meant for programs; `message` is for humans.
10
+ */
11
+ export class BackdError extends Error {
12
+ /**
13
+ * @param {object} init
14
+ * @param {number} init.status HTTP status; 0 for network failures.
15
+ * @param {string} init.code backd error code, e.g. "validation_error".
16
+ * @param {string} init.message
17
+ * @param {ErrorDetail[]} [init.details]
18
+ * @param {string} [init.requestId] The request ID, to match server logs.
19
+ * @param {unknown} [init.cause]
20
+ */
21
+ constructor({ status, code, message, details = [], requestId, cause }) {
22
+ super(message, cause === undefined ? undefined : { cause })
23
+ this.name = new.target.name
24
+ /** @type {number} */
25
+ this.status = status
26
+ /** @type {string} */
27
+ this.code = code
28
+ /** @type {ErrorDetail[]} */
29
+ this.details = details
30
+ /** @type {string | undefined} */
31
+ this.requestId = requestId
32
+ }
33
+ }
34
+
35
+ /** 400: invalid body, query or header (`validation_error`, `invalid_json`, `invalid_query`, `invalid_header`). */
36
+ export class ValidationError extends BackdError {}
37
+
38
+ /** 401: missing, invalid or expired credentials, or a wrong email or password. */
39
+ export class AuthenticationError extends BackdError {}
40
+
41
+ /** 403: the caller isn't allowed to do this. */
42
+ export class ForbiddenError extends BackdError {}
43
+
44
+ /**
45
+ * Not an HTTP error: a sign-up in a realm that requires verified addresses
46
+ * answers `202`, because the account exists but there is no session. Thrown
47
+ * by `auth.signup()` so the code after it, which expects a session, doesn't run.
48
+ */
49
+ export class VerificationRequiredError extends BackdError {}
50
+
51
+ /** 404: unknown realm, collection, document, user or session. */
52
+ export class NotFoundError extends BackdError {}
53
+
54
+ /** 409: a unique value already exists (`conflict`, `email_taken`) or concurrent writes (`write_conflict`). */
55
+ export class ConflictError extends BackdError {}
56
+
57
+ /** 412: `If-Match` didn't match the current version. */
58
+ export class VersionMismatchError extends BackdError {}
59
+
60
+ /** 429 and 503: try again later; `retryAfter` is in milliseconds when the server said. */
61
+ export class RetryableError extends BackdError {
62
+ /**
63
+ * @param {ConstructorParameters<typeof BackdError>[0] & { retryAfter?: number }} init
64
+ */
65
+ constructor(init) {
66
+ super(init)
67
+ /** @type {number | undefined} */
68
+ this.retryAfter = init.retryAfter
69
+ }
70
+ }
71
+
72
+ /** 0: the request didn't get an answer (network error, abort, timeout). */
73
+ export class NetworkError extends BackdError {}
74
+
75
+ /** @type {Record<number, typeof BackdError>} */
76
+ const byStatus = {
77
+ 400: ValidationError,
78
+ 401: AuthenticationError,
79
+ 403: ForbiddenError,
80
+ 404: NotFoundError,
81
+ 409: ConflictError,
82
+ 412: VersionMismatchError,
83
+ 429: RetryableError,
84
+ 503: RetryableError,
85
+ }
86
+
87
+ /**
88
+ * The `BackdError` subclass for a status, or the base class if there's
89
+ * no specific one for it. Used for real responses (`errorFromResponse`)
90
+ * and to reconstruct the same shape from an async job's stored result,
91
+ * which never went through an HTTP response of its own (roadmap F14).
92
+ * @param {number} status
93
+ * @returns {typeof BackdError}
94
+ */
95
+ export function errorClassFor(status) {
96
+ return byStatus[status] ?? BackdError
97
+ }
98
+
99
+ /**
100
+ * Builds the error for a non-2xx response.
101
+ * @param {Response} res
102
+ * @returns {Promise<BackdError>}
103
+ */
104
+ export async function errorFromResponse(res) {
105
+ /** @type {{ error?: { code?: string, message?: string, details?: ErrorDetail[], request_id?: string } }} */
106
+ let body = {}
107
+ try {
108
+ body = await res.json()
109
+ } catch {
110
+ // Not JSON (for example from a proxy): keep the defaults below.
111
+ }
112
+ const e = body.error ?? {}
113
+ const Class = errorClassFor(res.status)
114
+ const retryAfter = parseRetryAfter(res.headers.get('Retry-After'))
115
+ return new Class({
116
+ status: res.status,
117
+ code: e.code ?? 'http_' + res.status,
118
+ message: e.message ?? res.statusText ?? 'request failed',
119
+ details: e.details ?? [],
120
+ requestId: e.request_id ?? res.headers.get('X-Request-ID') ?? undefined,
121
+ ...(Class === RetryableError ? { retryAfter } : {}),
122
+ })
123
+ }
124
+
125
+ /**
126
+ * Parses a Retry-After header (seconds or an HTTP date) into milliseconds.
127
+ * @param {string | null} value
128
+ * @returns {number | undefined}
129
+ */
130
+ export function parseRetryAfter(value) {
131
+ if (!value) return undefined
132
+ if (/^\d+$/.test(value)) return Number(value) * 1000
133
+ const at = Date.parse(value)
134
+ return Number.isNaN(at) ? undefined : Math.max(0, at - Date.now())
135
+ }
@@ -0,0 +1,143 @@
1
+ import { errorClassFor } from './errors.js'
2
+
3
+ /**
4
+ * @typedef {import('./client.js').Client} Client
5
+ * @typedef {import('./client.js').RequestOptions} RequestOptions
6
+ * @typedef {import('./errors.js').ErrorDetail} ErrorDetail
7
+ */
8
+
9
+ /**
10
+ * How an `async` job ended, once `status` is `'done'` — the same shape
11
+ * a `sync` call's own answer would carry for the same outcome.
12
+ * @typedef {object} JobResult
13
+ * @property {string} status `ok`, `function_error`, `timeout`, `memory`, `cpu`, `crash`, `output_too_large` or `bundle`.
14
+ * @property {unknown} [output] The function's output, when `status` is `ok`.
15
+ * @property {number} [http_status] The status a `sync` call would have answered with; absent only when `status` is `ok`.
16
+ * @property {string} [code] The function's own code (`function_error`) or one of backd's own; absent only when `status` is `ok`.
17
+ * @property {string} [message] Absent only when `status` is `ok`.
18
+ * @property {ErrorDetail[]} [details]
19
+ * @property {number} duration_ms
20
+ */
21
+
22
+ /**
23
+ * A job as the API returns it: `POST .../_func/{name}`'s `202` body, or `GET .../_jobs/{id}`.
24
+ * @typedef {object} JobData
25
+ * @property {string} id
26
+ * @property {string} function `<database>/<name>`.
27
+ * @property {'queued' | 'running' | 'done'} status
28
+ * @property {string} created_at
29
+ * @property {number} [attempts] How many times a worker has started it.
30
+ * @property {string | null} [next_attempt_at] When a failed attempt will be retried (the function's `retry` policy); null otherwise.
31
+ * @property {JobResult | null} result
32
+ */
33
+
34
+ /**
35
+ * `job.wait()` gave up before the job finished (roadmap F14) — the job
36
+ * itself is unaffected and still running; call `wait()` again, or poll
37
+ * with `status()`, whenever you like.
38
+ */
39
+ export class JobTimeoutError extends Error {
40
+ /** @param {string} jobId */
41
+ constructor(jobId) {
42
+ super(`job ${jobId} did not finish within the given timeout`)
43
+ this.name = 'JobTimeoutError'
44
+ /** @readonly */
45
+ this.jobId = jobId
46
+ }
47
+ }
48
+
49
+ /**
50
+ * A handle to an `async` function's job (roadmap F11, this client since
51
+ * F14): `db.fn(name, input)` returns one instead of the output directly
52
+ * when the function is `async`. Poll with `status()`, or use `wait()`
53
+ * to get the output the same way a `sync` call's return value works.
54
+ */
55
+ export class Job {
56
+ /**
57
+ * @param {Client} client
58
+ * @param {string} database
59
+ * @param {JobData} data
60
+ */
61
+ constructor(client, database, data) {
62
+ /** @internal */
63
+ this.client = client
64
+ /** @internal */
65
+ this.database = database
66
+ /** @readonly */
67
+ this.id = data.id
68
+ /** @readonly The function this job runs, as `<database>/<name>`. */
69
+ this.function = data.function
70
+ /** @internal */
71
+ this.data = data
72
+ }
73
+
74
+ /** The job's data as of the last `status()` or `wait()` call, or when it was created. */
75
+ get raw() {
76
+ return this.data
77
+ }
78
+
79
+ /**
80
+ * Polls the job's current status.
81
+ * @param {RequestOptions} [opts]
82
+ * @returns {Promise<'queued' | 'running' | 'done'>}
83
+ */
84
+ async status(opts) {
85
+ this.data = await this._fetch(opts)
86
+ return this.data.status
87
+ }
88
+
89
+ /**
90
+ * Polls until the job is done, then returns its output — the same
91
+ * value a `sync` call would have returned — or throws a `BackdError`
92
+ * matching what a `sync` call would have thrown for the same outcome
93
+ * (the function's own `code`, when it threw `ctx.error(...)`).
94
+ * @param {RequestOptions & { pollIntervalMs?: number, timeoutMs?: number }} [opts]
95
+ * `pollIntervalMs` default 500. Without `timeoutMs`, waits indefinitely.
96
+ * @returns {Promise<unknown>}
97
+ */
98
+ async wait(opts = {}) {
99
+ const { pollIntervalMs = 500, timeoutMs, ...rest } = opts
100
+ const deadline = timeoutMs === undefined ? undefined : Date.now() + timeoutMs
101
+ for (;;) {
102
+ this.data = await this._fetch(rest)
103
+ if (this.data.status === 'done') return outputOrThrow(this.data.result, this.id)
104
+ if (deadline !== undefined && Date.now() >= deadline) throw new JobTimeoutError(this.id)
105
+ await sleep(pollIntervalMs)
106
+ }
107
+ }
108
+
109
+ /**
110
+ * @internal
111
+ * @param {RequestOptions} [opts]
112
+ * @returns {Promise<JobData>}
113
+ */
114
+ async _fetch(opts) {
115
+ const { data } = await this.client.request({ method: 'GET', path: [this.database, '_jobs', this.id], ...opts })
116
+ return data
117
+ }
118
+ }
119
+
120
+ /**
121
+ * @param {JobResult | null} result
122
+ * @param {string} jobId
123
+ * @returns {unknown}
124
+ */
125
+ function outputOrThrow(result, jobId) {
126
+ if (!result) throw new Error(`job ${jobId} is done but has no result`)
127
+ if (result.status === 'ok') return result.output
128
+ const Class = errorClassFor(result.http_status ?? 500)
129
+ throw new Class({
130
+ status: result.http_status ?? 500,
131
+ code: result.code ?? 'function_failed',
132
+ message: result.message ?? 'the function failed',
133
+ details: result.details ?? [],
134
+ })
135
+ }
136
+
137
+ /**
138
+ * @param {number} ms
139
+ * @returns {Promise<void>}
140
+ */
141
+ function sleep(ms) {
142
+ return new Promise((resolve) => setTimeout(resolve, ms))
143
+ }
package/src/index.js ADDED
@@ -0,0 +1,51 @@
1
+ export { createClient, Client } from './client.js'
2
+ export { Auth } from './auth.js'
3
+ export { Database, Collection, ifMatchValue } from './data.js'
4
+ export { Admin } from './admin.js'
5
+ export { Job, JobTimeoutError } from './functions.js'
6
+ export {
7
+ BackdError,
8
+ ValidationError,
9
+ AuthenticationError,
10
+ ForbiddenError,
11
+ NotFoundError,
12
+ ConflictError,
13
+ VersionMismatchError,
14
+ RetryableError,
15
+ NetworkError,
16
+ VerificationRequiredError,
17
+ } from './errors.js'
18
+ export { memoryStorage, localStorageStorage } from './storage.js'
19
+
20
+ /**
21
+ * @typedef {import('./client.js').ClientOptions} ClientOptions
22
+ * @typedef {import('./client.js').RequestOptions} RequestOptions
23
+ * @typedef {import('./client.js').RetryOptions} RetryOptions
24
+ * @typedef {import('./storage.js').TokenStorage} TokenStorage
25
+ * @typedef {import('./auth.js').User} User
26
+ * @typedef {import('./auth.js').Session} Session
27
+ * @typedef {import('./auth.js').SessionInfo} SessionInfo
28
+ * @typedef {import('./auth.js').AuthEvent} AuthEvent
29
+ * @typedef {import('./errors.js').ErrorDetail} ErrorDetail
30
+ * @typedef {import('./data.js').Meta} Meta
31
+ * @typedef {import('./data.js').ListParams} ListParams
32
+ * @typedef {import('./data.js').WriteOptions} WriteOptions
33
+ * @typedef {import('./admin.js').AdminUser} AdminUser
34
+ * @typedef {import('./admin.js').UserPage} UserPage
35
+ * @typedef {import('./admin.js').Invitation} Invitation
36
+ * @typedef {import('./admin.js').NewInvitation} NewInvitation
37
+ * @typedef {import('./admin.js').SentInvitation} SentInvitation
38
+ * @typedef {import('./admin.js').OwnedReport} OwnedReport
39
+ * @typedef {import('./functions.js').JobData} JobData
40
+ * @typedef {import('./functions.js').JobResult} JobResult
41
+ */
42
+
43
+ /**
44
+ * @template {object} [T=Record<string, any>]
45
+ * @typedef {import('./data.js').Doc<T>} Doc
46
+ */
47
+
48
+ /**
49
+ * @template {object} [T=Record<string, any>]
50
+ * @typedef {import('./data.js').Page<T>} Page
51
+ */
package/src/storage.js ADDED
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Where the client keeps the session token. Methods may be async.
3
+ * @typedef {object} TokenStorage
4
+ * @property {() => string | null | undefined | Promise<string | null | undefined>} get
5
+ * @property {(token: string) => void | Promise<void>} set
6
+ * @property {() => void | Promise<void>} remove
7
+ */
8
+
9
+ /**
10
+ * Keeps the token in memory: it is lost on reload, and scripts on the page
11
+ * can't read it from storage. The default.
12
+ * @returns {TokenStorage}
13
+ */
14
+ export function memoryStorage() {
15
+ /** @type {string | null} */
16
+ let token = null
17
+ return {
18
+ get: () => token,
19
+ set: (t) => {
20
+ token = t
21
+ },
22
+ remove: () => {
23
+ token = null
24
+ },
25
+ }
26
+ }
27
+
28
+ /**
29
+ * Keeps the token in `localStorage`, so sessions survive reloads. Any
30
+ * script running on the page (including injected ones) can read it:
31
+ * protect the app against cross-site scripting.
32
+ * @param {string} [key] Storage key; defaults to "backd.session".
33
+ * @returns {TokenStorage}
34
+ */
35
+ export function localStorageStorage(key = 'backd.session') {
36
+ const ls = globalThis.localStorage
37
+ if (!ls) throw new Error('localStorage is not available in this runtime')
38
+ return {
39
+ get: () => ls.getItem(key),
40
+ set: (t) => ls.setItem(key, t),
41
+ remove: () => ls.removeItem(key),
42
+ }
43
+ }