@ultimabase/rest 0.0.0-stage → 0.1.1

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.
Files changed (67) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +4 -2
  3. package/dist/entries.d.ts +38 -0
  4. package/dist/entries.d.ts.map +1 -0
  5. package/dist/entries.js +47 -0
  6. package/dist/fields.d.ts +77 -0
  7. package/dist/fields.d.ts.map +1 -0
  8. package/dist/fields.js +288 -0
  9. package/dist/functions.d.ts +28 -0
  10. package/dist/functions.d.ts.map +1 -0
  11. package/dist/functions.js +156 -0
  12. package/dist/index.d.ts +39 -0
  13. package/dist/index.d.ts.map +1 -0
  14. package/dist/index.js +62 -0
  15. package/dist/json-schema.d.ts +8 -0
  16. package/dist/json-schema.d.ts.map +1 -0
  17. package/dist/json-schema.js +44 -0
  18. package/dist/limits.d.ts +6 -0
  19. package/dist/limits.d.ts.map +1 -0
  20. package/dist/limits.js +17 -0
  21. package/dist/live.d.ts +15 -0
  22. package/dist/live.d.ts.map +1 -0
  23. package/dist/live.js +168 -0
  24. package/dist/models.d.ts +8 -0
  25. package/dist/models.d.ts.map +1 -0
  26. package/dist/models.js +136 -0
  27. package/dist/openapi-functions.d.ts +11 -0
  28. package/dist/openapi-functions.d.ts.map +1 -0
  29. package/dist/openapi-functions.js +85 -0
  30. package/dist/openapi-inputs.d.ts +30 -0
  31. package/dist/openapi-inputs.d.ts.map +1 -0
  32. package/dist/openapi-inputs.js +316 -0
  33. package/dist/openapi-models.d.ts +14 -0
  34. package/dist/openapi-models.d.ts.map +1 -0
  35. package/dist/openapi-models.js +280 -0
  36. package/dist/openapi.d.ts +47 -0
  37. package/dist/openapi.d.ts.map +1 -0
  38. package/dist/openapi.js +150 -0
  39. package/dist/params.d.ts +11 -0
  40. package/dist/params.d.ts.map +1 -0
  41. package/dist/params.js +27 -0
  42. package/dist/sse.d.ts +17 -0
  43. package/dist/sse.d.ts.map +1 -0
  44. package/dist/sse.js +63 -0
  45. package/dist/streams.d.ts +73 -0
  46. package/dist/streams.d.ts.map +1 -0
  47. package/dist/streams.js +218 -0
  48. package/dist/values.d.ts +7 -0
  49. package/dist/values.d.ts.map +1 -0
  50. package/dist/values.js +123 -0
  51. package/package.json +47 -3
  52. package/src/entries.ts +82 -0
  53. package/src/fields.ts +391 -0
  54. package/src/functions.ts +205 -0
  55. package/src/index.ts +105 -0
  56. package/src/json-schema.ts +51 -0
  57. package/src/limits.ts +28 -0
  58. package/src/live.ts +223 -0
  59. package/src/models.ts +180 -0
  60. package/src/openapi-functions.ts +127 -0
  61. package/src/openapi-inputs.ts +399 -0
  62. package/src/openapi-models.ts +367 -0
  63. package/src/openapi.ts +204 -0
  64. package/src/params.ts +39 -0
  65. package/src/sse.ts +79 -0
  66. package/src/streams.ts +301 -0
  67. package/src/values.ts +163 -0
package/src/fields.ts ADDED
@@ -0,0 +1,391 @@
1
+ // `select` and `include` (D-129): what a request asks of a model's rows, as
2
+ // one kernel read, and the rows shaped as GraphQL returns them. Without
3
+ // either, a row holds its readable scalar fields and no relation.
4
+ import type { Relay } from '@ultimabase/http/internal'
5
+ import {
6
+ connection,
7
+ pageArgs,
8
+ pageOf,
9
+ readable,
10
+ readableVirtuals,
11
+ rowOf,
12
+ rowsOf,
13
+ } from '@ultimabase/http/internal'
14
+ import type { ExposedVirtual, Row } from 'ultimabase'
15
+ import { InvalidQuery } from 'ultimabase'
16
+ import type { Api, Args, Entry } from './entries.ts'
17
+ import { entryOf, system } from './entries.ts'
18
+ import { isArgs, whereOf } from './values.ts'
19
+
20
+ /** A field of the response, in the order the request wrote it. */
21
+ type Out =
22
+ | {
23
+ readonly kind: 'scalar'
24
+ readonly name: string
25
+ readonly toJSON: ((value: unknown) => unknown) | undefined
26
+ }
27
+ | { readonly kind: 'one'; readonly name: string; readonly shape: Shape }
28
+ | {
29
+ readonly kind: 'many'
30
+ readonly name: string
31
+ readonly shape: Shape
32
+ readonly relay: Relay | undefined
33
+ /** The rows it asks for, which the row budget counts. */
34
+ readonly size: number
35
+ /** Whether it answers its `totalCount` (D-134). */
36
+ readonly counted: boolean
37
+ }
38
+ | {
39
+ /** A virtual's rows of a model, re-read by the kernel (D-151). */
40
+ readonly kind: 'returned'
41
+ readonly name: string
42
+ readonly owner: string
43
+ readonly shape: Shape
44
+ readonly list: boolean
45
+ /** The rows it counts: `take` for a list, as a function's (D-109). */
46
+ readonly size: number
47
+ }
48
+
49
+ /**
50
+ * What a row of the response holds. `sorted` is the relations its list's
51
+ * `orderBy` goes through, each a level of `maxDepth` (D-134).
52
+ */
53
+ export type Shape = {
54
+ readonly fields: readonly Out[]
55
+ readonly sorted?: number
56
+ }
57
+
58
+ /** A row's fields and its relations' reads, as the kernel takes them. */
59
+ export type Read = {
60
+ readonly read: Args
61
+ readonly shape: Shape
62
+ }
63
+
64
+ /** The relations an `orderBy` goes through, the deepest path's. */
65
+ function sortDepth(orderBy: unknown): number {
66
+ if (Array.isArray(orderBy)) return Math.max(0, ...orderBy.map(sortDepth))
67
+ if (typeof orderBy !== 'object' || orderBy === null) return 0
68
+ return Math.max(
69
+ 0,
70
+ ...Object.values(orderBy).map((value: unknown) =>
71
+ typeof value === 'object' && value !== null ? 1 + sortDepth(value) : 0,
72
+ ),
73
+ )
74
+ }
75
+
76
+ const unknownKey = (path: string, model: string): InvalidQuery =>
77
+ new InvalidQuery(path, `Unknown key '${path}' on ${model}.`)
78
+
79
+ const scalar = (entry: Entry, name: string): Out => ({
80
+ kind: 'scalar',
81
+ name,
82
+ // A plugin field reads as its toJSON gives (D-122).
83
+ toJSON: entry.exposed.fieldTypes[name]?.toJSON,
84
+ })
85
+
86
+ /**
87
+ * `id`, the dates, each scalar field a caller other than Master reads, and
88
+ * each virtual of a scalar without an input (D-151).
89
+ */
90
+ export const defaultShape = (entry: Entry): Shape => ({
91
+ fields: [
92
+ ...system.map((name) => scalar(entry, name)),
93
+ ...readable(entry)
94
+ .filter((field) => field.kind === 'scalar')
95
+ .map((field) => scalar(entry, field.name)),
96
+ ...defaultVirtuals(entry).map((name) => scalar(entry, name)),
97
+ ],
98
+ })
99
+
100
+ /** The virtuals a row holds without `select` (D-151). */
101
+ export const defaultVirtuals = (entry: Entry): readonly string[] =>
102
+ readableVirtuals(entry)
103
+ .filter((virtual) => virtual.default)
104
+ .map((virtual) => virtual.name)
105
+
106
+ // `take` for none or a negative number; above `maxTake`, the list refuses.
107
+ const sizeOf = (entry: Entry, asked: unknown): number =>
108
+ typeof asked === 'number' && asked >= 0
109
+ ? Math.min(asked, entry.maxTake)
110
+ : entry.take
111
+
112
+ /** A list's `where`, its dates read; `null` is absent, as in GraphQL. */
113
+ export const filterOf = (
114
+ api: Api,
115
+ entry: Entry,
116
+ where: unknown,
117
+ path: string,
118
+ ): Args =>
119
+ where === undefined || where === null
120
+ ? {}
121
+ : {
122
+ where: isArgs(where)
123
+ ? whereOf(api, entry.model.name, where, path)
124
+ : where,
125
+ }
126
+
127
+ /** Reads `select` or `include` as the request wrote them. */
128
+ class Reader {
129
+ readonly #api: Api
130
+ readonly #master: boolean
131
+
132
+ constructor(api: Api, master: boolean) {
133
+ this.#api = api
134
+ this.#master = master
135
+ }
136
+
137
+ /** A row's `select` or `include`, under `path` (`''` at the root). */
138
+ fields(entry: Entry, args: Args, path: string): Read {
139
+ const at = (key: string): string => (path === '' ? key : `${path}.${key}`)
140
+ const { select, include } = args
141
+ if (select !== undefined && include !== undefined) {
142
+ throw new InvalidQuery(
143
+ path,
144
+ `${at('select')} and include cannot be used together.`,
145
+ )
146
+ }
147
+ const base = select === undefined ? 'include' : 'select'
148
+ const chosen = select ?? include
149
+ if (chosen === undefined) return { read: {}, shape: defaultShape(entry) }
150
+ if (!isArgs(chosen)) {
151
+ throw new InvalidQuery(at(base), `${at(base)} must be an object.`)
152
+ }
153
+ const kernel: { [field: string]: unknown } =
154
+ base === 'select' ? { id: true } : {}
155
+ const fields: Out[] =
156
+ base === 'select' ? [] : [...defaultShape(entry).fields]
157
+ // The relations' `totalCount`, each the count of its `where` (D-134).
158
+ const counts: { [field: string]: unknown } = {}
159
+ const model = entry.model.name
160
+ for (const [name, value] of Object.entries(chosen)) {
161
+ const where = `${at(base)}.${name}`
162
+ const virtual = readableVirtuals(entry).find((each) => each.name === name)
163
+ if (virtual !== undefined) {
164
+ if (value === false) continue
165
+ const read = this.#virtual(entry, virtual, value, where)
166
+ kernel[name] = read.read
167
+ // `include` reads a default virtual again, where it is.
168
+ const known = fields.findIndex((each) => each.name === name)
169
+ if (known === -1) fields.push(read.field)
170
+ else fields[known] = read.field
171
+ continue
172
+ }
173
+ const field = readable(entry).find((each) => each.name === name)
174
+ const isScalar =
175
+ field === undefined ? system.includes(name) : field.kind === 'scalar'
176
+ if (
177
+ (field === undefined && !isScalar) ||
178
+ (base === 'include' && isScalar)
179
+ ) {
180
+ throw unknownKey(where, model)
181
+ }
182
+ if (value === false) continue
183
+ if (field === undefined || field.kind === 'scalar') {
184
+ if (value !== true) {
185
+ throw new InvalidQuery(where, `${where} must be a boolean.`)
186
+ }
187
+ kernel[name] = true
188
+ fields.push(scalar(entry, name))
189
+ continue
190
+ }
191
+ const target = entryOf(this.#api, field.target)
192
+ const given = value === true ? {} : value
193
+ if (!isArgs(given)) {
194
+ throw new InvalidQuery(where, `${where} must be an object.`)
195
+ }
196
+ if (field.kind === 'one') {
197
+ this.#keys(given, ['select', 'include'], where, model)
198
+ const nested = this.fields(target, given, where)
199
+ kernel[name] = nested.read
200
+ fields.push({ kind: 'one', name, shape: nested.shape })
201
+ continue
202
+ }
203
+ // A list has no count (D-134): `totalCount` is unknown there.
204
+ const pages = this.#api.pagination === 'connection'
205
+ const { totalCount, ...listed } = given
206
+ const many = this.list(target, pages ? listed : given, where)
207
+ const counted = pages && totalCount === true
208
+ if (
209
+ pages &&
210
+ totalCount !== undefined &&
211
+ typeof totalCount !== 'boolean'
212
+ ) {
213
+ const key = `${where}.totalCount`
214
+ throw new InvalidQuery(key, `${key} must be true or false.`)
215
+ }
216
+ if (counted) {
217
+ const filter = many.read['where']
218
+ counts[name] = filter === undefined ? true : { where: filter }
219
+ }
220
+ kernel[name] = many.read
221
+ fields.push({
222
+ kind: 'many',
223
+ name,
224
+ shape: many.shape,
225
+ relay: many.relay,
226
+ // A count reads the rows it counts, however few the page returns.
227
+ size: counted ? Math.max(many.size, target.take) : many.size,
228
+ counted,
229
+ })
230
+ }
231
+ if (Object.keys(counts).length > 0) kernel['_count'] = { select: counts }
232
+ return { read: { [base]: kernel }, shape: { fields } }
233
+ }
234
+
235
+ /** A virtual's read: `true` or its input, a model's fields too (D-151). */
236
+ #virtual(
237
+ entry: Entry,
238
+ virtual: ExposedVirtual,
239
+ value: unknown,
240
+ where: string,
241
+ ): { readonly read: unknown; readonly field: Out } {
242
+ const { name, output } = virtual
243
+ if (output.kind !== 'model') {
244
+ return { read: value, field: scalar(entry, name) }
245
+ }
246
+ if (value !== true && !isArgs(value)) {
247
+ throw new InvalidQuery(where, `${where} must be true or an object.`)
248
+ }
249
+ const { select, include, ...input } = value === true ? {} : value
250
+ const target = entryOf(this.#api, output.name)
251
+ const nested = this.fields(target, { select, include }, where)
252
+ const list = output.form === 'list'
253
+ return {
254
+ read: value === true ? true : { ...input, ...nested.read },
255
+ field: {
256
+ kind: 'returned',
257
+ name,
258
+ owner: `${entry.model.name}.${name}`,
259
+ shape: nested.shape,
260
+ list,
261
+ size: list ? this.#api.take : 1,
262
+ },
263
+ }
264
+ }
265
+
266
+ /** A list's arguments: its page, filter, order and fields. */
267
+ list(
268
+ entry: Entry,
269
+ args: Args,
270
+ path: string,
271
+ ): Read & { readonly relay: Relay | undefined; readonly size: number } {
272
+ const at = (key: string): string => (path === '' ? key : `${path}.${key}`)
273
+ const paging = pageArgs(this.#api.pagination)
274
+ const keys = ['select', 'include', 'where', 'orderBy', ...paging]
275
+ this.#keys(args, keys, path, entry.model.name)
276
+ const page = pageOf(this.#api.pagination, entry, args, this.#master, at)
277
+ const { where, orderBy } = args
278
+ const nested = this.fields(entry, args, path)
279
+ const asked =
280
+ this.#api.pagination === 'list'
281
+ ? args['take']
282
+ : (args['last'] ?? args['first'])
283
+ return {
284
+ read: {
285
+ ...nested.read,
286
+ ...page.read,
287
+ ...filterOf(this.#api, entry, where, at('where')),
288
+ ...(orderBy === undefined || orderBy === null ? {} : { orderBy }),
289
+ },
290
+ shape: { ...nested.shape, sorted: sortDepth(orderBy) },
291
+ relay: page.relay,
292
+ size: sizeOf(entry, asked),
293
+ }
294
+ }
295
+
296
+ #keys(args: Args, keys: readonly string[], path: string, model: string) {
297
+ for (const key of Object.keys(args)) {
298
+ if (!keys.includes(key)) {
299
+ throw unknownKey(path === '' ? key : `${path}.${key}`, model)
300
+ }
301
+ }
302
+ }
303
+ }
304
+
305
+ /** The reader of a request's `select` and `include`. */
306
+ export const readerOf = (api: Api, master: boolean): Reader =>
307
+ new Reader(api, master)
308
+
309
+ /** The levels of relations a row nests, the row counting one. */
310
+ export function depthOf(shape: Shape): number {
311
+ let deepest = shape.sorted ?? 0
312
+ for (const field of shape.fields) {
313
+ if (field.kind !== 'scalar')
314
+ deepest = Math.max(deepest, depthOf(field.shape))
315
+ }
316
+ return 1 + deepest
317
+ }
318
+
319
+ /** The rows the relations of `times` rows may return, as D-107 counts them. */
320
+ export function rowsBelow(shape: Shape, times: number): number {
321
+ let rows = 0
322
+ for (const field of shape.fields) {
323
+ if (field.kind === 'scalar') continue
324
+ const each = field.kind === 'one' ? times : times * field.size
325
+ rows += each
326
+ if (each > 0) rows += rowsBelow(field.shape, each)
327
+ }
328
+ return rows
329
+ }
330
+
331
+ /** The count the kernel read of a row's relation (D-134). */
332
+ function countOf(row: Row, name: string): unknown {
333
+ const counts = row['_count']
334
+ return isArgs(counts) ? counts[name] : undefined
335
+ }
336
+
337
+ /** A virtual's rows of a model, at most `take` under a relation (D-109). */
338
+ function returned(
339
+ field: Extract<Out, { readonly kind: 'returned' }>,
340
+ value: unknown,
341
+ ): unknown {
342
+ if (!field.list) {
343
+ const row = rowOf(value)
344
+ return row === undefined ? null : shaped(row, field.shape)
345
+ }
346
+ const rows = rowsOf(value)
347
+ const relates = field.shape.fields.some((each) => each.kind !== 'scalar')
348
+ if (rows.length > field.size && relates) {
349
+ throw new RangeError(
350
+ `${field.owner} returned ${rows.length} rows; a list counts take (${field.size}) rows, so it holds no more under a selected relation.`,
351
+ )
352
+ }
353
+ return rows.map((row) => shaped(row, field.shape))
354
+ }
355
+
356
+ /** A row as GraphQL returns it: its fields, its relations' pages. */
357
+ export function shaped(row: Row, shape: Shape): { [field: string]: unknown } {
358
+ const out: { [field: string]: unknown } = {}
359
+ for (const field of shape.fields) {
360
+ const value = row[field.name]
361
+ if (field.kind === 'scalar') {
362
+ out[field.name] =
363
+ value === undefined || value === null
364
+ ? null
365
+ : field.toJSON === undefined
366
+ ? value
367
+ : field.toJSON(value)
368
+ } else if (field.kind === 'one') {
369
+ const target = rowOf(value)
370
+ out[field.name] =
371
+ target === undefined ? null : shaped(target, field.shape)
372
+ } else if (field.kind === 'returned') {
373
+ out[field.name] = returned(field, value)
374
+ } else if (value === undefined || value === null) {
375
+ // Absent when the field's read rule hides it (D-44).
376
+ out[field.name] = null
377
+ } else {
378
+ const rows = rowsOf(value)
379
+ const node = (each: Row) => shaped(each, field.shape)
380
+ if (field.relay === undefined) {
381
+ out[field.name] = rows.map(node)
382
+ continue
383
+ }
384
+ const page = connection(rows, field.relay, node)
385
+ out[field.name] = field.counted
386
+ ? { ...page, totalCount: countOf(row, field.name) }
387
+ : page
388
+ }
389
+ }
390
+ return out
391
+ }
@@ -0,0 +1,205 @@
1
+ // The application's functions (D-129): a query at `GET /api/functions/:name`,
2
+ // its input keys as query parameters, a mutation at `POST`, its body as its
3
+ // input, each answered with the output GraphQL's field returns; a live query
4
+ // over Server-Sent Events, with `Accept: text/event-stream` (D-154).
5
+ import type { Routes } from '@ultimabase/http'
6
+ import { clientOf, validate } from '@ultimabase/http'
7
+ import type { Node } from '@ultimabase/http/internal'
8
+ import { isNode, jsonSchemaOf, rowOf } from '@ultimabase/http/internal'
9
+ import type { Caller, Client, ExposedFunction, ExposedOutput } from 'ultimabase'
10
+ import { Refused } from 'ultimabase'
11
+ import type { Api, Entry } from './entries.ts'
12
+ import { checkName, entryOf } from './entries.ts'
13
+ import { defaultShape, defaultVirtuals, shaped } from './fields.ts'
14
+ import { acceptsNull, isText, resolved } from './json-schema.ts'
15
+ import { anyBody } from './models.ts'
16
+ import type { Accepted } from './params.ts'
17
+ import { liveValue, wantsEvents } from './live.ts'
18
+ import { paramsOf } from './params.ts'
19
+ import type { Live } from './streams.ts'
20
+
21
+ /** A function's key of `.with` (D-129): its route's name, or hidden. */
22
+ export type RestFunctionWith = { readonly name?: string } | false
23
+
24
+ const none: Accepted = { json: [], text: [] }
25
+
26
+ /** A function REST routes, named, and its input's JSON Schema. */
27
+ export type Routed = {
28
+ readonly fn: ExposedFunction<RestFunctionWith>
29
+ readonly owner: string
30
+ readonly name: string
31
+ readonly input: Node | undefined
32
+ }
33
+
34
+ /**
35
+ * A GET's parameters: an object input's keys, as GraphQL's arguments, or
36
+ * one `input`; a string as is, any other value as JSON.
37
+ */
38
+ export function getParams(root: Node | undefined): {
39
+ readonly accepted: Accepted
40
+ readonly flat: boolean
41
+ } {
42
+ if (root === undefined) return { accepted: none, flat: false }
43
+ const object = resolved(root, root)
44
+ const { properties } = object
45
+ if (object['type'] === 'object' && isNode(properties)) {
46
+ const keys = Object.entries(properties)
47
+ const text = (node: unknown) => isNode(node) && isText(node, root)
48
+ return {
49
+ accepted: {
50
+ json: keys.filter(([, node]) => !text(node)).map(([key]) => key),
51
+ text: keys.filter(([, node]) => text(node)).map(([key]) => key),
52
+ },
53
+ flat: true,
54
+ }
55
+ }
56
+ const as = isText(root, root) ? 'text' : 'json'
57
+ return { accepted: { ...none, [as]: ['input'] }, flat: false }
58
+ }
59
+
60
+ /** The rows of each model whose default virtuals are read again. */
61
+ type Pending = Map<Entry, { id: string; copy: { [field: string]: unknown } }[]>
62
+
63
+ const nonNull = (where: string): TypeError =>
64
+ new TypeError(`Cannot return null for non-nullable field ${where}.`)
65
+
66
+ /** A value as GraphQL returns a function's output, a model's row its scalars. */
67
+ function outputOf(
68
+ api: Api,
69
+ output: ExposedOutput,
70
+ value: unknown,
71
+ where: string,
72
+ pending: Pending,
73
+ ): unknown {
74
+ if (output.kind === 'schema') return value ?? null
75
+ if (value === null || value === undefined) {
76
+ if (output.form === 'nullable') return null
77
+ throw nonNull(where)
78
+ }
79
+ if (output.form === 'list') {
80
+ const items: readonly unknown[] = Array.isArray(value) ? value : []
81
+ const item = { ...output, form: 'one' } as const
82
+ return items.map((each) => outputOf(api, item, each, where, pending))
83
+ }
84
+ if (output.kind === 'model') {
85
+ const row = rowOf(value)
86
+ if (row === undefined) {
87
+ throw new TypeError(`A ${output.name} was returned without its id.`)
88
+ }
89
+ const entry = entryOf(api, output.name)
90
+ const copy = shaped(row, defaultShape(entry))
91
+ if (defaultVirtuals(entry).length > 0) {
92
+ pending.set(entry, [...(pending.get(entry) ?? []), { id: row.id, copy }])
93
+ }
94
+ return copy
95
+ }
96
+ const given: Node = isNode(value) ? value : {}
97
+ const { type } = output
98
+ return Object.fromEntries(
99
+ [...type.fields].map(([name, field]) => [
100
+ name,
101
+ outputOf(api, field, given[name], `${type.name}.${name}`, pending),
102
+ ]),
103
+ )
104
+ }
105
+
106
+ /** The default virtuals of a function's rows, read by id as the caller (D-151). */
107
+ async function withVirtuals(pending: Pending, as: Caller): Promise<void> {
108
+ for (const [entry, rows] of pending) {
109
+ const names = defaultVirtuals(entry)
110
+ const ids = [...new Set(rows.map(({ id }) => id))]
111
+ const found = await entry.exposed.queries.findMany(
112
+ {
113
+ where: { id: { in: ids } },
114
+ take: ids.length,
115
+ select: Object.fromEntries([
116
+ ['id', true],
117
+ ...names.map((name) => [name, true]),
118
+ ]),
119
+ },
120
+ { as },
121
+ )
122
+ const byId = new Map(found.map((row) => [row.id, row]))
123
+ for (const { id, copy } of rows) {
124
+ // A row the caller cannot read has none (D-109).
125
+ for (const name of names) copy[name] = byId.get(id)?.[name] ?? null
126
+ }
127
+ }
128
+ }
129
+
130
+ /** Each routed function under `${base}/functions` (D-109, D-113). */
131
+ export function functionRoutes(
132
+ app: Routes,
133
+ api: Api,
134
+ functions: readonly ExposedFunction<RestFunctionWith>[],
135
+ base: string,
136
+ live: Live | undefined,
137
+ ): readonly Routed[] {
138
+ const routed: Routed[] = []
139
+ const owners = new Map<string, string>()
140
+ for (const fn of functions) {
141
+ if (fn.with === false) continue
142
+ // A plugin's function is named with its plugin (D-113).
143
+ const owner = fn.plugin === undefined ? fn.name : `${fn.plugin}.${fn.name}`
144
+ const name = checkName(fn.with?.name ?? fn.name, owner)
145
+ const other = owners.get(name)
146
+ if (other !== undefined) {
147
+ throw new Refused(
148
+ `${owner}: the route functions/${name} is ${other}'s; set .with({ rest: { name } }).`,
149
+ )
150
+ }
151
+ owners.set(name, owner)
152
+ // GraphQL types both sides from their exports, and refuses without (D-45).
153
+ const { input, output } = fn
154
+ const root =
155
+ input === undefined
156
+ ? undefined
157
+ : jsonSchemaOf(input, 'input', `${owner}: input`)
158
+ routed.push({ fn, owner, name, input: root })
159
+ const nullable =
160
+ output?.kind === 'schema'
161
+ ? acceptsNull(jsonSchemaOf(output.schema, 'output', `${owner}: output`))
162
+ : output?.form === 'nullable'
163
+ const answer = async (as: Caller, client: Client, sent: unknown) => {
164
+ const value = await fn.run(sent, { as }, client)
165
+ if (output === undefined) return true
166
+ if (value === undefined && !nullable) {
167
+ throw new TypeError(
168
+ `${fn.name} returned undefined; make its output .nullable() and return null.`,
169
+ )
170
+ }
171
+ const pending: Pending = new Map()
172
+ const answered = outputOf(api, output, value, fn.name, pending)
173
+ await withVirtuals(pending, as)
174
+ return answered
175
+ }
176
+ const path = `${base}/functions/${name}`
177
+ if (fn.query) {
178
+ const { accepted, flat } = getParams(root)
179
+ app.get(path, async (c) => {
180
+ const params = paramsOf(c.req.url, accepted)
181
+ const { client, sent } = clientOf(c)
182
+ const given = flat ? params : params['input']
183
+ if (wantsEvents(c.req.raw)) {
184
+ const compute = async (as: Caller) => answer(as, client, given)
185
+ return sent(await liveValue(live, owner, c.req.raw, compute))
186
+ }
187
+ return sent(c.json(await answer(c.var.caller, client, given)))
188
+ })
189
+ } else if (input === undefined) {
190
+ app.post(path, async (c) => {
191
+ paramsOf(c.req.url, none)
192
+ const { client, sent } = clientOf(c)
193
+ return sent(c.json(await answer(c.var.caller, client, undefined)))
194
+ })
195
+ } else {
196
+ app.post(path, validate('json', anyBody), async (c) => {
197
+ paramsOf(c.req.url, none)
198
+ const { client, sent } = clientOf(c)
199
+ const body: unknown = c.req.valid('json')
200
+ return sent(c.json(await answer(c.var.caller, client, body)))
201
+ })
202
+ }
203
+ }
204
+ return routed
205
+ }
package/src/index.ts ADDED
@@ -0,0 +1,105 @@
1
+ // `restPlugin()` (D-129): GraphQL's arguments and responses over resource
2
+ // routes, mounted on the HTTP plugin and run as each request's caller; with
3
+ // the realtime plugin, its live models' reads as Server-Sent Events (D-154).
4
+ import type { Http } from '@ultimabase/http'
5
+ import { httpPlugin, routes } from '@ultimabase/http'
6
+ import type { Realtime } from '@ultimabase/realtime'
7
+ import { realtimePlugin } from '@ultimabase/realtime'
8
+ import type { PluginOf, PluginTypes, UseOf } from 'ultimabase'
9
+ import { definePlugin } from 'ultimabase'
10
+ import manifest from '../package.json' with { type: 'json' }
11
+ import type { RestWith } from './entries.ts'
12
+ import { entriesOf } from './entries.ts'
13
+ import type { RestFunctionWith } from './functions.ts'
14
+ import { functionRoutes } from './functions.ts'
15
+ import { modelRoutes } from './models.ts'
16
+ import { openapiOf } from './openapi.ts'
17
+
18
+ export type { RestWith } from './entries.ts'
19
+ export type { RestFunctionWith } from './functions.ts'
20
+
21
+ /** What `restPlugin` takes: `graphqlPlugin`'s options, by name (D-129). */
22
+ export interface RestOptions {
23
+ /** `/api` by default. */
24
+ readonly path?: string
25
+ /** Relay connections, or `take` and `skip` lists. */
26
+ readonly pagination?: 'connection' | 'list'
27
+ /** The rows a list returns when none is asked for; 100 by default (D-55). */
28
+ readonly take?: number
29
+ /** The rows a caller other than Master may ask for; 100 by default. */
30
+ readonly maxTake?: number
31
+ /** The deepest a request of a caller other than Master nests; 10 by default. */
32
+ readonly maxDepth?: number
33
+ /**
34
+ * The most rows a request of a caller other than Master may return, each
35
+ * list times the lists above it; 20 000 by default.
36
+ */
37
+ readonly maxRows?: number
38
+ }
39
+
40
+ /** The REST plugin's types (D-57). */
41
+ export interface Rest extends PluginTypes {
42
+ readonly name: 'rest'
43
+ readonly appOptions: RestOptions
44
+ readonly requires: Http
45
+ readonly optional: Realtime
46
+ readonly with: RestWith
47
+ readonly functionWith: RestFunctionWith
48
+ }
49
+
50
+ const baseOf = (options: RestOptions): string =>
51
+ (options.path ?? '/api').replace(/\/+$/, '')
52
+
53
+ // What ends each running application's event streams, by its plugin's context.
54
+ const streams = new WeakMap<object, Set<() => void>>()
55
+
56
+ const plugin = definePlugin<Rest>({
57
+ name: 'rest',
58
+ manifest,
59
+ requires: { http: httpPlugin },
60
+ optional: { realtime: realtimePlugin },
61
+ api: (context) => {
62
+ const { plugins, models, functions, options, logger } = context
63
+ const { realtime } = plugins
64
+ const ends = new Set<() => void>()
65
+ streams.set(context, ends)
66
+ const live = realtime && {
67
+ realtime,
68
+ streams: ends,
69
+ kept: new Map(),
70
+ open: new Map(),
71
+ logger,
72
+ }
73
+ const api = {
74
+ entries: entriesOf(models, options),
75
+ pagination: options.pagination ?? 'connection',
76
+ take: options.take ?? 100,
77
+ maxDepth: options.maxDepth ?? 10,
78
+ maxRows: options.maxRows ?? 20_000,
79
+ }
80
+ const base = baseOf(options)
81
+ const app = routes()
82
+ const routed = functionRoutes(app, api, functions, base, live)
83
+ for (const entry of api.entries.values()) {
84
+ if (entry.visible) modelRoutes(app, api, entry, base, live)
85
+ }
86
+ // Built at startup, so a name clash refuses to start (D-144).
87
+ const document = openapiOf(api, routed, base, manifest.version)
88
+ app.get(`${base}/openapi.json`, (c) => c.json(document))
89
+ plugins.http.mount(app)
90
+ },
91
+ start: ({ plugins, options, logger }) => {
92
+ if (process.env['NODE_ENV'] === 'production') return
93
+ logger.info(`REST on ${plugins.http.url()}${baseOf(options)}.`)
94
+ },
95
+ // Before the HTTP plugin's stop, which waits for the streams to end.
96
+ stop: (context) => {
97
+ for (const end of streams.get(context) ?? []) end()
98
+ },
99
+ })
100
+
101
+ /** The application's REST API, served by `httpPlugin` (D-129). */
102
+ export const restPlugin: PluginOf<Rest> = plugin.plugin
103
+
104
+ /** A model's `.with({ rest })`, from its `use` (D-129). */
105
+ export const rest: UseOf<Rest> = plugin.use