@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
@@ -0,0 +1,399 @@
1
+ // The inputs of the document (D-144), named and typed as GraphQL's (D-129):
2
+ // each field's value, the filters, `where`, `orderBy`, the unique target
3
+ // in REST's flat form, and the data a write takes.
4
+ import { jsonSchemaOf, readable } from '@ultimabase/http/internal'
5
+ import type { SchemaField } from 'ultimabase'
6
+ import { Refused } from 'ultimabase'
7
+ import { decimalPattern } from 'ultimabase/internal'
8
+ import type { Api, Entry } from './entries.ts'
9
+ import { entryOf } from './entries.ts'
10
+ import type { Components, Schema } from './openapi.ts'
11
+ import {
12
+ arrayOf,
13
+ describe,
14
+ described,
15
+ hoisted,
16
+ objectOf,
17
+ orNull,
18
+ refTo,
19
+ } from './openapi.ts'
20
+
21
+ type Scalar = Extract<SchemaField, { readonly kind: 'scalar' }>
22
+ type Relation = Exclude<SchemaField, Scalar>
23
+
24
+ /** What the document is built from: its components and REST's models. */
25
+ export type Doc = { readonly components: Components; readonly api: Api }
26
+
27
+ const upper = (name: string): string =>
28
+ name.charAt(0).toUpperCase() + name.slice(1)
29
+
30
+ /** Whether a model has fields its `_search` matches (D-148). */
31
+ const searched = (fields: readonly SchemaField[]): boolean =>
32
+ fields.some((field) => field.kind === 'scalar' && field.search === true)
33
+
34
+ const id: Schema = { type: 'string' }
35
+ const dateTime: Schema = { type: 'string', format: 'date-time' }
36
+
37
+ /** One value of `field`, a list's item, by GraphQL's type name. */
38
+ function item(
39
+ doc: Doc,
40
+ model: string,
41
+ field: Scalar,
42
+ side: 'input' | 'output',
43
+ ): { readonly name: string; readonly schema: Schema } {
44
+ const named = `${model}${upper(field.name)}`
45
+ switch (field.type) {
46
+ case 'string':
47
+ return { name: 'String', schema: { type: 'string' } }
48
+ case 'number':
49
+ return field.integer === true
50
+ ? { name: 'Int', schema: { type: 'integer', format: 'int32' } }
51
+ : { name: 'Float', schema: { type: 'number' } }
52
+ case 'decimal':
53
+ return {
54
+ name: 'Decimal',
55
+ schema: { type: 'string', pattern: decimalPattern },
56
+ }
57
+ case 'boolean':
58
+ return { name: 'Boolean', schema: { type: 'boolean' } }
59
+ case 'dateTime':
60
+ return { name: 'DateTime', schema: dateTime }
61
+ case 'date':
62
+ return { name: 'Date', schema: { type: 'string', format: 'date' } }
63
+ case 'enum': {
64
+ const values = field.values ?? []
65
+ const shared = entryOf(doc.api, model).exposed.enums[field.name]
66
+ if (shared !== undefined) {
67
+ const { name, description } = shared
68
+ const schema = describe({ type: 'string', enum: values }, description)
69
+ const owner = `${model}.${field.name}`
70
+ return { name, schema: doc.components.shared(name, schema, owner) }
71
+ }
72
+ const schema = doc.components.ref(named, () => ({
73
+ type: 'string',
74
+ enum: values,
75
+ }))
76
+ return { name: named, schema }
77
+ }
78
+ case 'json':
79
+ break
80
+ }
81
+ return { name: 'JSON', schema: jsonOf(doc, model, field, side) }
82
+ }
83
+
84
+ /**
85
+ * A json field's value: its validator's JSON Schema, any JSON without
86
+ * (D-125); a plugin field's read value, its shape's (D-152).
87
+ */
88
+ function jsonOf(
89
+ doc: Doc,
90
+ model: string,
91
+ field: Scalar,
92
+ side: 'input' | 'output',
93
+ ): Schema {
94
+ const { validators, fieldTypes } = entryOf(doc.api, model).exposed
95
+ const validator = validators[field.name] ?? fieldTypes[field.name]?.shape
96
+ if (validator === undefined) return {}
97
+ const owner = `${model}.${field.name}`
98
+ let node
99
+ try {
100
+ node = jsonSchemaOf(validator, side, owner)
101
+ } catch (error) {
102
+ // As GraphQL types it: `JSON` without an export.
103
+ if (error instanceof Refused) return {}
104
+ throw error
105
+ }
106
+ const root = `${model}${upper(field.name)}${side === 'input' ? 'Input' : ''}`
107
+ return hoisted(doc.components, node, side, owner, root)
108
+ }
109
+
110
+ /** A field's value, a plugin field's as its shape says (D-122, D-152). */
111
+ export function valueOf(
112
+ doc: Doc,
113
+ entry: Entry,
114
+ field: Scalar,
115
+ side: 'input' | 'output',
116
+ ): Schema {
117
+ if (entry.exposed.fieldTypes[field.name] !== undefined) {
118
+ // What it takes is the field type's own.
119
+ return side === 'input' ? {} : jsonOf(doc, entry.model.name, field, side)
120
+ }
121
+ const { schema } = item(doc, entry.model.name, field, side)
122
+ return field.list ? arrayOf(schema) : schema
123
+ }
124
+
125
+ const base = ['equals', 'not', 'in', 'notIn']
126
+ const ordered = [...base, 'lt', 'lte', 'gt', 'gte']
127
+ const text = [...base, 'contains', 'startsWith', 'endsWith', 'mode']
128
+ const operators: { readonly [T in Scalar['type']]: readonly string[] } = {
129
+ string: text,
130
+ number: ordered,
131
+ decimal: ordered,
132
+ dateTime: ordered,
133
+ date: ordered,
134
+ boolean: base,
135
+ // Any JSON, a path into it or not, typed field or not (D-149).
136
+ json: [
137
+ 'path',
138
+ ...ordered,
139
+ 'stringContains',
140
+ 'stringStartsWith',
141
+ 'stringEndsWith',
142
+ 'mode',
143
+ 'arrayContains',
144
+ 'arrayStartsWith',
145
+ 'arrayEndsWith',
146
+ ],
147
+ enum: base,
148
+ }
149
+
150
+ /** `StringFilter`, `PostStatusFilter`: `equals` and `not` take `null` too. */
151
+ function filter(
152
+ doc: Doc,
153
+ name: string,
154
+ value: Schema,
155
+ list: readonly string[],
156
+ ): Schema {
157
+ return doc.components.ref(`${name}Filter`, () =>
158
+ objectOf(
159
+ Object.fromEntries(
160
+ list.map((operator) => {
161
+ if (operator === 'mode') {
162
+ const mode = doc.components.ref('QueryMode', () => ({
163
+ type: 'string',
164
+ enum: ['insensitive'],
165
+ }))
166
+ return [operator, mode]
167
+ }
168
+ if (operator === 'in' || operator === 'notIn') {
169
+ return [operator, arrayOf(value)]
170
+ }
171
+ if (operator === 'path')
172
+ return [operator, arrayOf({ type: 'string' })]
173
+ if (operator.startsWith('string')) {
174
+ return [operator, { type: 'string' }]
175
+ }
176
+ const nullable = operator === 'equals' || operator === 'not'
177
+ return [operator, nullable ? orNull(value) : value]
178
+ }),
179
+ ),
180
+ ),
181
+ )
182
+ }
183
+
184
+ function scalarFilter(doc: Doc, model: string, field: Scalar): Schema {
185
+ // A typed json field's operands are any JSON at a path (D-149).
186
+ if (field.type === 'json' && !field.list) {
187
+ return filter(doc, 'JSON', {}, operators.json)
188
+ }
189
+ const { name, schema } = item(doc, model, field, 'input')
190
+ if (field.list) {
191
+ return doc.components.ref(`${name}ListFilter`, () =>
192
+ objectOf({
193
+ has: schema,
194
+ hasSome: arrayOf(schema),
195
+ hasEvery: arrayOf(schema),
196
+ isEmpty: { type: 'boolean' },
197
+ }),
198
+ )
199
+ }
200
+ return filter(doc, name, schema, operators[field.type])
201
+ }
202
+
203
+ /** One value or a list: GraphQL takes either for a list argument. */
204
+ const oneOrMore = (schema: Schema): Schema => ({
205
+ anyOf: [schema, arrayOf(schema)],
206
+ })
207
+
208
+ /** `PostWhereInput`, its relation filters included. */
209
+ export function whereOf(doc: Doc, model: string): Schema {
210
+ return doc.components.ref(`${model}WhereInput`, () => {
211
+ const self = refTo(`${model}WhereInput`)
212
+ const entry = entryOf(doc.api, model)
213
+ const dates = filter(doc, 'DateTime', dateTime, ordered)
214
+ const fields: { [name: string]: Schema } = {
215
+ AND: oneOrMore(self),
216
+ OR: oneOrMore(self),
217
+ NOT: oneOrMore(self),
218
+ id: filter(doc, 'ID', id, base),
219
+ createdAt: dates,
220
+ updatedAt: dates,
221
+ }
222
+ if (searched(readable(entry))) fields['_search'] = { type: 'string' }
223
+ for (const field of readable(entry)) {
224
+ // Only a beforeFind filters a plugin field (D-122).
225
+ if (entry.exposed.fieldTypes[field.name] !== undefined) continue
226
+ fields[field.name] =
227
+ field.kind === 'scalar'
228
+ ? scalarFilter(doc, model, field)
229
+ : relationFilter(doc, field)
230
+ }
231
+ return objectOf(fields)
232
+ })
233
+ }
234
+
235
+ function relationFilter(doc: Doc, field: Relation): Schema {
236
+ const one = field.kind === 'one'
237
+ const name = `${field.target}${one ? 'Relation' : 'ListRelation'}Filter`
238
+ return doc.components.ref(name, () => {
239
+ const where = whereOf(doc, field.target)
240
+ const keys = one ? ['is', 'isNot'] : ['some', 'every', 'none']
241
+ return objectOf(Object.fromEntries(keys.map((key) => [key, where])))
242
+ })
243
+ }
244
+
245
+ /** `PostOrderByInput`, an object or a list of them (D-94, D-134). */
246
+ export function orderByOf(doc: Doc, model: string): Schema {
247
+ return doc.components.ref(`${model}OrderByInput`, () => {
248
+ const order = doc.components.ref('SortOrder', () => ({
249
+ type: 'string',
250
+ enum: ['asc', 'desc'],
251
+ }))
252
+ const entry = entryOf(doc.api, model)
253
+ const fields: { [name: string]: Schema } = {
254
+ id: order,
255
+ createdAt: order,
256
+ updatedAt: order,
257
+ }
258
+ // The kernel refuses it in a relation's: a read ranks by its own search.
259
+ if (searched(readable(entry))) fields['_relevance'] = order
260
+ for (const field of readable(entry)) {
261
+ if (field.kind === 'one')
262
+ fields[field.name] = orderByOf(doc, field.target)
263
+ else if (field.kind === 'many') {
264
+ fields[field.name] = doc.components.ref('CountOrderByInput', () =>
265
+ objectOf({ _count: order }),
266
+ )
267
+ } else if (
268
+ field.type !== 'json' &&
269
+ !field.list &&
270
+ entry.exposed.fieldTypes[field.name] === undefined
271
+ ) {
272
+ fields[field.name] = order
273
+ }
274
+ }
275
+ return objectOf(fields)
276
+ })
277
+ }
278
+
279
+ /**
280
+ * `PostWhereUniqueInput`, as REST takes it: the fields of one unique set,
281
+ * `{ id }` or `{ author, slug }`, a relation by its target's id.
282
+ */
283
+ export function uniqueOf(doc: Doc, model: string): Schema {
284
+ return doc.components.ref(`${model}WhereUniqueInput`, () => {
285
+ const entry = entryOf(doc.api, model)
286
+ const { readRules, uniques } = entry.exposed
287
+ // A field only Master reads is absent, as from the filters (D-44).
288
+ const sets = uniques.filter((unique) =>
289
+ unique.every((name) => readRules[name] !== 'private'),
290
+ )
291
+ const typeOf = (name: string): Schema => {
292
+ const field = entry.model.fields.find((each) => each.name === name)
293
+ return field?.kind === 'scalar' ? valueOf(doc, entry, field, 'input') : id
294
+ }
295
+ return {
296
+ anyOf: sets.map((unique) =>
297
+ objectOf(
298
+ Object.fromEntries(unique.map((name) => [name, typeOf(name)])),
299
+ unique,
300
+ ),
301
+ ),
302
+ }
303
+ })
304
+ }
305
+
306
+ /** Exactly one of an object's keys, as a GraphQL `@oneOf` input. */
307
+ const oneKey = (schema: Schema): Schema => ({
308
+ ...schema,
309
+ minProperties: 1,
310
+ maxProperties: 1,
311
+ })
312
+
313
+ /**
314
+ * A relation's write, as GraphQL names it (D-128, D-138): a hidden target is
315
+ * connected, disconnected or set, never created, updated or deleted.
316
+ */
317
+ function relationInput(doc: Doc, field: Relation, creating: boolean): Schema {
318
+ const many = field.kind === 'many'
319
+ const shape = many ? 'Many' : field.optional ? 'One' : 'OneRequired'
320
+ const facing = `Without${upper(field.inverse)}`
321
+ const name = creating
322
+ ? `${field.target}CreateNested${many ? 'Many' : 'One'}${facing}Input`
323
+ : `${field.target}Update${shape}${facing}NestedInput`
324
+ return doc.components.ref(name, () => {
325
+ const listed = (schema: Schema) => (many ? arrayOf(schema) : schema)
326
+ const unique = uniqueOf(doc, field.target)
327
+ const { visible } = entryOf(doc.api, field.target)
328
+ const updating = !creating && visible
329
+ const fields: { [key: string]: Schema } = { connect: listed(unique) }
330
+ if (visible) {
331
+ const created = dataOf(doc, field.target, true, field.inverse)
332
+ const orCreate = doc.components.ref(
333
+ `${field.target}CreateOrConnect${facing}Input`,
334
+ () => objectOf({ where: unique, create: created }, ['where', 'create']),
335
+ )
336
+ fields['connectOrCreate'] = listed(orCreate)
337
+ fields['create'] = listed(created)
338
+ }
339
+ if (updating && shape !== 'OneRequired') {
340
+ fields['delete'] = many ? listed(unique) : { type: 'boolean' }
341
+ }
342
+ if (!creating && shape !== 'OneRequired') {
343
+ fields['disconnect'] = many ? listed(unique) : { type: 'boolean' }
344
+ }
345
+ // A filtered relation takes no `set` (D-151).
346
+ if (!creating && many && field.filtered !== true) {
347
+ fields['set'] = listed(unique)
348
+ }
349
+ if (updating) {
350
+ const data = dataOf(doc, field.target, false, field.inverse)
351
+ fields['update'] = many
352
+ ? listed(
353
+ doc.components.ref(
354
+ `${field.target}UpdateWithWhereUnique${facing}Input`,
355
+ () => objectOf({ where: unique, data }, ['where', 'data']),
356
+ ),
357
+ )
358
+ : data
359
+ }
360
+ return many ? objectOf(fields) : oneKey(objectOf(fields))
361
+ })
362
+ }
363
+
364
+ /**
365
+ * `PostCreateInput` and `PostUpdateInput`: every field, those only Master
366
+ * reads included; a create needs the fields `create` needs (D-53). Through
367
+ * a relation, `CommentCreateWithoutPostInput`, without it (D-138).
368
+ */
369
+ export function dataOf(
370
+ doc: Doc,
371
+ model: string,
372
+ creating: boolean,
373
+ without?: string,
374
+ ): Schema {
375
+ const facing = without === undefined ? '' : `Without${upper(without)}`
376
+ const name = `${model}${creating ? 'Create' : 'Update'}${facing}Input`
377
+ return doc.components.ref(name, () => {
378
+ const entry = entryOf(doc.api, model)
379
+ const fields: { [name: string]: Schema } = {}
380
+ for (const field of entry.model.fields) {
381
+ // A view is read-only (D-151).
382
+ if (
383
+ field.name === without ||
384
+ (field.kind === 'many' && field.from !== undefined)
385
+ )
386
+ continue
387
+ if (field.kind !== 'scalar') {
388
+ fields[field.name] = relationInput(doc, field, creating)
389
+ continue
390
+ }
391
+ const value = valueOf(doc, entry, field, 'input')
392
+ fields[field.name] = field.optional && !field.list ? orNull(value) : value
393
+ }
394
+ const required = creating
395
+ ? entry.exposed.required.filter((each) => each !== without)
396
+ : []
397
+ return objectOf(described(fields, entry.exposed.descriptions), required)
398
+ })
399
+ }