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/LICENSE +21 -201
- package/README.md +32 -26
- package/package.json +47 -40
- package/src/admin.js +597 -0
- package/src/auth.js +374 -0
- package/src/client.js +214 -0
- package/src/data.js +293 -0
- package/src/errors.js +135 -0
- package/src/functions.js +143 -0
- package/src/index.js +51 -0
- package/src/storage.js +43 -0
- package/types/admin.d.ts +709 -0
- package/types/auth.d.ts +281 -0
- package/types/client.d.ts +132 -0
- package/types/data.d.ts +277 -0
- package/types/errors.d.ts +106 -0
- package/types/functions.d.ts +135 -0
- package/types/index.d.ts +59 -0
- package/types/storage.d.ts +26 -0
- package/.babelrc +0 -4
- package/.editorconfig +0 -12
- package/.eslintrc.js +0 -28
- package/.npmignore +0 -9
- package/.nvmrc +0 -1
- package/.travis.yml +0 -33
- package/lib/backd.js +0 -13625
- package/lib/backd.js.map +0 -1
- package/lib/backd.min.js +0 -7
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
|
+
}
|
package/src/functions.js
ADDED
|
@@ -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
|
+
}
|