backd-js 0.1.15 → 0.2.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,278 @@
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 {boolean} [count] Also return `total`.
34
+ */
35
+
36
+ /**
37
+ * A page of documents, as the API returns it.
38
+ * @template {object} [T=Record<string, any>]
39
+ * @typedef {object} Page
40
+ * @property {Doc<T>[]} items
41
+ * @property {number} limit
42
+ * @property {number} skip
43
+ * @property {boolean} has_more
44
+ * @property {number} [total] With `count: true`.
45
+ */
46
+
47
+ /**
48
+ * Options for writes. `ifMatch` makes the write fail with a
49
+ * VersionMismatchError unless the document is still at that version.
50
+ * @typedef {RequestOptions & { ifMatch?: number | string }} WriteOptions
51
+ */
52
+
53
+ /**
54
+ * One write for `db.batch(...)`, applied atomically with the others.
55
+ * `create` and `replace` need `document`; `patch` needs `patch` (a JSON
56
+ * Merge Patch). `replace`, `patch` and `delete` need `id`, and accept
57
+ * `ifMatch` (like `WriteOptions`).
58
+ * @typedef {object} BatchOperation
59
+ * @property {'create' | 'replace' | 'patch' | 'delete'} op
60
+ * @property {string} collection
61
+ * @property {string} [id]
62
+ * @property {Record<string, any>} [document]
63
+ * @property {Record<string, any>} [patch]
64
+ * @property {number | string} [ifMatch]
65
+ */
66
+
67
+ /** A database of the realm: `client.db(name)`. */
68
+ export class Database {
69
+ /**
70
+ * @param {Client} client
71
+ * @param {string} name
72
+ */
73
+ constructor(client, name) {
74
+ /** @internal */
75
+ this.client = client
76
+ /** @readonly */
77
+ this.name = name
78
+ }
79
+
80
+ /**
81
+ * A collection of this database.
82
+ * @template {object} [T=Record<string, any>]
83
+ * @param {string} name
84
+ * @returns {Collection<T>}
85
+ */
86
+ collection(name) {
87
+ return new Collection(this.client, this.name, name)
88
+ }
89
+
90
+ /**
91
+ * Creates, replaces, patches and deletes documents across this
92
+ * database's collections in one MongoDB transaction: either every
93
+ * operation applies, or none does (up to 100 operations). Rejects with
94
+ * a `BackdError` naming the first operation that failed; a version
95
+ * mismatch (`ifMatch`) is a `VersionMismatchError`, same as a single
96
+ * write.
97
+ * @param {BatchOperation[]} operations
98
+ * @param {RequestOptions} [opts]
99
+ * @returns {Promise<(Doc | { id: string })[]>} one entry per operation, in order
100
+ */
101
+ async batch(operations, opts) {
102
+ const body = {
103
+ operations: operations.map(({ ifMatch, ...op }) => (ifMatch === undefined ? op : { ...op, if_match: ifMatchValue(ifMatch) })),
104
+ }
105
+ const { data } = await this.client.request({ method: 'POST', path: [this.name, '_batch'], body, ...opts })
106
+ return data.results
107
+ }
108
+
109
+ /**
110
+ * Calls a function. Returns its output directly for a `sync`
111
+ * function; for `async`, a `Job` handle to poll instead of the
112
+ * output (`job.status()`, or `job.wait()` for the output). A
113
+ * function's own error (`ctx.error(status, code, message)`) arrives
114
+ * as a `BackdError` with that status and code — for `async`, only
115
+ * once `job.wait()` resolves, not from this call itself.
116
+ *
117
+ * `webhook` functions can't be called through this method: their
118
+ * caller is whatever service sends the webhook, never this client.
119
+ * @param {string} name
120
+ * @param {unknown} [input] Any JSON value; omitted is sent as `null`.
121
+ * @param {RequestOptions & { idempotencyKey?: string }} [opts]
122
+ * @returns {Promise<unknown | Job>}
123
+ */
124
+ async fn(name, input, opts = {}) {
125
+ const { idempotencyKey, headers, ...rest } = opts
126
+ const reqHeaders = idempotencyKey === undefined ? headers : { ...headers, 'Idempotency-Key': idempotencyKey }
127
+ const { status, data } = await this.client.request({
128
+ method: 'POST',
129
+ path: [this.name, '_func', name],
130
+ body: input === undefined ? null : input,
131
+ headers: reqHeaders,
132
+ ...rest,
133
+ })
134
+ if (status === 202) return new Job(this.client, this.name, /** @type {JobData} */ (data))
135
+ return data
136
+ }
137
+ }
138
+
139
+ /**
140
+ * A collection: `client.db(db).collection(name)`. `T` describes the
141
+ * documents' own fields.
142
+ * @template {object} [T=Record<string, any>]
143
+ */
144
+ export class Collection {
145
+ /**
146
+ * @param {Client} client
147
+ * @param {string} database
148
+ * @param {string} name
149
+ */
150
+ constructor(client, database, name) {
151
+ /** @internal */
152
+ this.client = client
153
+ /** @internal */
154
+ this.path = [database, name]
155
+ }
156
+
157
+ /**
158
+ * One page of documents the caller may read.
159
+ * @param {ListParams} [params]
160
+ * @param {RequestOptions} [opts]
161
+ * @returns {Promise<Page<T>>}
162
+ */
163
+ async list(params = {}, opts) {
164
+ const { data } = await this.client.request({ method: 'GET', path: this.path, query: listQuery(params), ...opts })
165
+ return data
166
+ }
167
+
168
+ /**
169
+ * 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.
173
+ * @param {Omit<ListParams, 'skip' | 'count'>} [params] `limit` is the page size (default 100).
174
+ * @param {RequestOptions} [opts]
175
+ * @returns {AsyncGenerator<Doc<T>, void, undefined>}
176
+ */
177
+ async *iterate(params = {}, opts) {
178
+ const limit = params.limit ?? 100
179
+ for (let skip = 0; ; skip += limit) {
180
+ const page = await this.list({ ...params, limit, skip }, opts)
181
+ yield* page.items
182
+ if (!page.has_more) return
183
+ }
184
+ }
185
+
186
+ /**
187
+ * One document; NotFoundError if it doesn't exist or the caller may not read it.
188
+ * @param {string} id
189
+ * @param {RequestOptions} [opts]
190
+ * @returns {Promise<Doc<T>>}
191
+ */
192
+ async get(id, opts) {
193
+ return (await this.client.request({ method: 'GET', path: [...this.path, id], ...opts })).data
194
+ }
195
+
196
+ /**
197
+ * Creates a document. `id` and `_meta` are set by the server.
198
+ * @param {T} doc
199
+ * @param {RequestOptions} [opts]
200
+ * @returns {Promise<Doc<T>>}
201
+ */
202
+ async create(doc, opts) {
203
+ return (await this.client.request({ method: 'POST', path: this.path, body: doc, ...opts })).data
204
+ }
205
+
206
+ /**
207
+ * Replaces the whole document: fields not in `doc` are removed.
208
+ * @param {string} id
209
+ * @param {T} doc
210
+ * @param {WriteOptions} [opts]
211
+ * @returns {Promise<Doc<T>>}
212
+ */
213
+ async replace(id, doc, opts = {}) {
214
+ return (await this.client.request({ method: 'PUT', path: [...this.path, id], body: doc, ...write(opts) })).data
215
+ }
216
+
217
+ /**
218
+ * Updates some fields (JSON Merge Patch): fields in `patch` are set,
219
+ * nested objects are merged, and `null` removes a field.
220
+ * @param {string} id
221
+ * @param {Partial<T> | Record<string, unknown>} patch
222
+ * @param {WriteOptions} [opts]
223
+ * @returns {Promise<Doc<T>>}
224
+ */
225
+ async patch(id, patch, opts = {}) {
226
+ return (
227
+ await this.client.request({
228
+ method: 'PATCH',
229
+ path: [...this.path, id],
230
+ body: patch,
231
+ contentType: 'application/merge-patch+json',
232
+ ...write(opts),
233
+ })
234
+ ).data
235
+ }
236
+
237
+ /**
238
+ * Deletes a document.
239
+ * @param {string} id
240
+ * @param {WriteOptions} [opts]
241
+ * @returns {Promise<void>}
242
+ */
243
+ async delete(id, opts = {}) {
244
+ await this.client.request({ method: 'DELETE', path: [...this.path, id], ...write(opts) })
245
+ }
246
+ }
247
+
248
+ /**
249
+ * @param {ListParams} p
250
+ * @returns {Record<string, string | number | boolean | undefined>}
251
+ */
252
+ function listQuery(p) {
253
+ return {
254
+ where: p.where === undefined ? undefined : typeof p.where === 'string' ? p.where : JSON.stringify(p.where),
255
+ order_by: Array.isArray(p.orderBy) ? p.orderBy.join(',') : p.orderBy,
256
+ limit: p.limit,
257
+ skip: p.skip,
258
+ count: p.count || undefined,
259
+ }
260
+ }
261
+
262
+ /**
263
+ * Turns `ifMatch` into an If-Match header.
264
+ * @param {WriteOptions} opts
265
+ * @returns {RequestOptions}
266
+ */
267
+ function write({ ifMatch, ...opts }) {
268
+ if (ifMatch === undefined) return opts
269
+ return { ...opts, headers: { ...opts.headers, 'If-Match': ifMatchValue(ifMatch) } }
270
+ }
271
+
272
+ /**
273
+ * `3` → `"3"`; strings (`*`, `"3"`, `"2", "3"`) are sent as given.
274
+ * @param {number | string} v
275
+ */
276
+ export function ifMatchValue(v) {
277
+ return typeof v === 'number' ? `"${v}"` : v
278
+ }
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
+ }