@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/streams.ts ADDED
@@ -0,0 +1,301 @@
1
+ // An open live read over Server-Sent Events (D-154), ended with its client,
2
+ // its caller or the application. A live list whose client left is kept 10 s,
3
+ // what it sends held, so that the same user's request with its token takes
4
+ // it back, grown, even before the server saw it leave. A live list's first
5
+ // event is its token.
6
+ import { randomUUID } from 'node:crypto'
7
+ import { run } from '@ultimabase/http/internal'
8
+ import type {
9
+ LiveHandle,
10
+ LiveMessage,
11
+ RealtimeApi,
12
+ Viewer,
13
+ } from '@ultimabase/realtime'
14
+ import type { Logger } from 'ultimabase'
15
+ import type { Shape } from './fields.ts'
16
+ import { shaped } from './fields.ts'
17
+ import type { Events } from './sse.ts'
18
+ import { eventsTo } from './sse.ts'
19
+
20
+ /**
21
+ * The realtime engine, what ends each open stream, the kept lists, and what
22
+ * keeps each open list as if its client left, by token.
23
+ */
24
+ export type Live = {
25
+ readonly realtime: RealtimeApi
26
+ readonly streams: Set<() => void>
27
+ readonly kept: Map<string, Kept>
28
+ readonly open: Map<string, () => void>
29
+ readonly logger: Logger
30
+ }
31
+
32
+ /** Where a live read writes: an open stream of events, its response aside. */
33
+ export type Sink = Omit<Events, 'response'>
34
+
35
+ type Changed = Exclude<
36
+ LiveMessage,
37
+ { readonly kind: 'closed' | 'row' | 'value' }
38
+ >
39
+ /** What a live list's messages write. */
40
+ export type Write = (message: Changed) => void
41
+
42
+ /** A live read to open, given the viewer and what takes its messages. */
43
+ export type Open = (
44
+ viewer: Viewer,
45
+ send: (message: LiveMessage) => void,
46
+ ) => Promise<LiveHandle>
47
+
48
+ /** A live list: what writes it, what read it is, its rows, and its token. */
49
+ export type Listed = {
50
+ readonly write: (sink: Sink) => Write
51
+ readonly signature: string
52
+ readonly first: number
53
+ readonly resume: string | undefined
54
+ }
55
+
56
+ /** A sink that writes to its stream, or holds what it is sent while none. */
57
+ type Relay = Sink & {
58
+ readonly to: (sink: Sink) => void
59
+ /** Holds what it is sent from now on; `ended` runs if it ends. */
60
+ readonly park: (ended: () => void) => void
61
+ }
62
+
63
+ type Stream = {
64
+ readonly viewer: Viewer
65
+ readonly relay: Relay
66
+ readonly keep: Keep | undefined
67
+ readonly signature: string
68
+ handle?: LiveHandle
69
+ /** Whether its client left before its first read. */
70
+ ended: boolean
71
+ }
72
+
73
+ type Keep = { readonly token: string; first: number }
74
+
75
+ /** A list whose client left, until a resume takes it or its time is up. */
76
+ export type Kept = {
77
+ readonly stream: Stream
78
+ readonly handle: LiveHandle
79
+ readonly keep: Keep
80
+ /** Takes it out, still open. */
81
+ readonly take: () => void
82
+ readonly discard: () => void
83
+ }
84
+
85
+ /** How long a list whose client left waits for a resume. */
86
+ const keptFor = 10_000
87
+ const nothing = (): void => undefined
88
+
89
+ function relayOf(): Relay {
90
+ let current: Sink | undefined
91
+ let ended = nothing
92
+ const held: (readonly [string, unknown, number | undefined])[] = []
93
+ return {
94
+ send: (event, data, id) => {
95
+ if (current === undefined) held.push([event, data, id])
96
+ else current.send(event, data, id)
97
+ },
98
+ full: () => current?.full() ?? false,
99
+ drained: (then) => {
100
+ if (current === undefined) then()
101
+ else current.drained(then)
102
+ },
103
+ end: () => {
104
+ if (current === undefined) ended()
105
+ else current.end()
106
+ },
107
+ to: (sink) => {
108
+ current = sink
109
+ for (const [event, data, id] of held.splice(0)) sink.send(event, data, id)
110
+ },
111
+ park: (then) => {
112
+ current = undefined
113
+ ended = then
114
+ },
115
+ }
116
+ }
117
+
118
+ /**
119
+ * Follows the read `open` starts into `sink`; a list's changes go through
120
+ * `list`. Changes are dropped while the client's buffer is full, until the
121
+ * engine sends the window again. A closing error is sent as it comes: its
122
+ * read made it public.
123
+ */
124
+ export async function follow(
125
+ sink: Sink,
126
+ viewer: Viewer,
127
+ shape: Shape,
128
+ open: Open,
129
+ list?: (sink: Sink) => Write,
130
+ ): Promise<LiveHandle> {
131
+ const writes = list?.(sink)
132
+ let handle: LiveHandle | undefined
133
+ let dropping = false
134
+ let owed = false
135
+ const send = (message: LiveMessage) => {
136
+ if (message.kind === 'closed') {
137
+ if (message.error !== undefined) sink.send('error', message.error)
138
+ // A viewer closes when its caller changed: the client reconnects.
139
+ else if (!viewer.closed) {
140
+ sink.send('error', {
141
+ code: 'INTERNAL',
142
+ message: 'The live read closed.',
143
+ })
144
+ }
145
+ return sink.end()
146
+ }
147
+ if (dropping && message.kind === 'changes') return
148
+ if (!dropping && sink.full()) {
149
+ dropping = true
150
+ sink.drained(() => {
151
+ if (handle === undefined) owed = true
152
+ else void handle.resend()
153
+ })
154
+ return
155
+ }
156
+ dropping = false
157
+ if (message.kind === 'value') {
158
+ return sink.send('data', message.value, message.revision)
159
+ }
160
+ if (message.kind !== 'row') return writes?.(message)
161
+ const { row, revision } = message
162
+ sink.send('data', row && shaped(row, shape), revision)
163
+ }
164
+ handle = await open(viewer, send)
165
+ if (owed) void handle.resend()
166
+ return handle
167
+ }
168
+
169
+ /** Keeps `stream`'s list once its client left, until a resume takes it. */
170
+ function park(live: Live, stream: Stream, handle: LiveHandle, keep: Keep) {
171
+ const take = () => {
172
+ clearTimeout(timer)
173
+ live.kept.delete(keep.token)
174
+ live.streams.delete(discard)
175
+ }
176
+ const discard = () => {
177
+ take()
178
+ handle.close()
179
+ stream.viewer.close()
180
+ }
181
+ const timer = setTimeout(discard, keptFor)
182
+ timer.unref()
183
+ stream.relay.park(discard)
184
+ live.streams.add(discard)
185
+ live.kept.set(keep.token, { stream, handle, keep, take, discard })
186
+ }
187
+
188
+ /**
189
+ * `request`'s stream of events, which `stream` writes to from now on; the
190
+ * list is kept once the client leaves, unless the stream was ended.
191
+ */
192
+ function eventsFor(
193
+ live: Live,
194
+ request: Request,
195
+ stream: Stream,
196
+ ): { readonly response: Response; readonly finish: () => void } {
197
+ let over = false
198
+ let left = false
199
+ const leave = () => {
200
+ if (left) return
201
+ left = true
202
+ live.streams.delete(finish)
203
+ const { viewer, handle, keep } = stream
204
+ if (keep !== undefined) live.open.delete(keep.token)
205
+ if (over || handle === undefined || keep === undefined || viewer.closed) {
206
+ stream.ended = true
207
+ handle?.close()
208
+ return viewer.close()
209
+ }
210
+ park(live, stream, handle, keep)
211
+ }
212
+ const events = eventsTo(request, leave)
213
+ const finish = () => {
214
+ over = true
215
+ events.end()
216
+ }
217
+ live.streams.add(finish)
218
+ if (stream.keep !== undefined) {
219
+ // A resume may come before the server sees this client leave.
220
+ live.open.set(stream.keep.token, () => {
221
+ leave()
222
+ events.end()
223
+ })
224
+ // First, before what a kept list held.
225
+ events.send('resume', stream.keep.token)
226
+ }
227
+ stream.relay.to({ ...events, end: finish })
228
+ return { response: events.response, finish }
229
+ }
230
+
231
+ /** The list `list.resume` keeps, as the same read of the same user, grown. */
232
+ async function resumed(
233
+ live: Live,
234
+ request: Request,
235
+ list: Listed,
236
+ ): Promise<Response | undefined> {
237
+ const token = list.resume ?? ''
238
+ live.open.get(token)?.()
239
+ const kept = live.kept.get(token)
240
+ if (kept === undefined) return undefined
241
+ const { stream, handle, keep } = kept
242
+ if (stream.signature !== list.signature || list.first < keep.first) {
243
+ return undefined
244
+ }
245
+ // Out first, so that neither its time nor another request takes it.
246
+ kept.take()
247
+ // The kept read follows this request's caller; another user ends it.
248
+ if (!(await stream.viewer.adopt(request))) {
249
+ kept.discard()
250
+ return undefined
251
+ }
252
+ const { response, finish } = eventsFor(live, request, stream)
253
+ try {
254
+ if (list.first > keep.first) {
255
+ await run(async () => handle.grow(list.first - keep.first))
256
+ }
257
+ } catch (error) {
258
+ finish()
259
+ stream.viewer.close()
260
+ throw error
261
+ }
262
+ keep.first = list.first
263
+ return response
264
+ }
265
+
266
+ /**
267
+ * `request`'s stream of the read `open` starts, closed with the client; a
268
+ * list resumes the one its token keeps when it may.
269
+ */
270
+ export async function streamed(
271
+ live: Live,
272
+ request: Request,
273
+ shape: Shape,
274
+ open: Open,
275
+ list?: Listed,
276
+ ): Promise<Response> {
277
+ if (list?.resume !== undefined) {
278
+ const taken = await resumed(live, request, list)
279
+ if (taken !== undefined) return taken
280
+ }
281
+ const viewer = await live.realtime.connect(request)
282
+ const stream: Stream = {
283
+ viewer,
284
+ relay: relayOf(),
285
+ keep: list && { token: randomUUID(), first: list.first },
286
+ signature: list?.signature ?? '',
287
+ ended: false,
288
+ }
289
+ const { response, finish } = eventsFor(live, request, stream)
290
+ try {
291
+ stream.handle = await run(async () =>
292
+ follow(stream.relay, viewer, shape, open, list?.write),
293
+ )
294
+ } catch (error) {
295
+ finish()
296
+ viewer.close()
297
+ throw error
298
+ }
299
+ if (stream.ended) stream.handle.close()
300
+ return response
301
+ }
package/src/values.ts ADDED
@@ -0,0 +1,163 @@
1
+ // Values as GraphQL's JSON (D-129): a date is ISO 8601 text, read back to a
2
+ // `Date` from its field's type, in filters and in data, as `DateTime` is.
3
+ import type { SchemaField } from 'ultimabase'
4
+ import { InvalidQuery } from 'ultimabase'
5
+ import type { Api, Args } from './entries.ts'
6
+ import { entryOf } from './entries.ts'
7
+
8
+ const iso =
9
+ /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:\d{2})$/
10
+
11
+ export const isArgs = (value: unknown): value is Args =>
12
+ typeof value === 'object' && value !== null && !Array.isArray(value)
13
+
14
+ /** A string as a `Date`; any other value left for the kernel to check. */
15
+ function date(value: unknown, path: string): unknown {
16
+ if (typeof value !== 'string') return value
17
+ const parsed = iso.test(value) ? new Date(value) : undefined
18
+ if (parsed !== undefined && !Number.isNaN(parsed.getTime())) return parsed
19
+ throw new InvalidQuery(
20
+ path,
21
+ `${path} must be an ISO 8601 DateTime, such as 2026-01-31T12:00:00.000Z.`,
22
+ )
23
+ }
24
+
25
+ const isDate = (field: SchemaField | undefined, key: string): boolean =>
26
+ key === 'createdAt' ||
27
+ key === 'updatedAt' ||
28
+ (field?.kind === 'scalar' && field.type === 'dateTime')
29
+
30
+ /** A date field's filter: a value, or operators whose values are dates. */
31
+ function dateFilter(value: unknown, path: string): unknown {
32
+ if (!isArgs(value)) return date(value, path)
33
+ const filter: { [operator: string]: unknown } = {}
34
+ for (const [operator, operand] of Object.entries(value)) {
35
+ const at = `${path}.${operator}`
36
+ filter[operator] = Array.isArray(operand)
37
+ ? operand.map((item: unknown, index) => date(item, `${at}.${index}`))
38
+ : date(operand, at)
39
+ }
40
+ return filter
41
+ }
42
+
43
+ const each = (
44
+ value: unknown,
45
+ path: string,
46
+ map: (item: Args, at: string) => Args,
47
+ ): unknown =>
48
+ Array.isArray(value)
49
+ ? value.map((item: unknown, index) =>
50
+ isArgs(item) ? map(item, `${path}.${index}`) : item,
51
+ )
52
+ : isArgs(value)
53
+ ? map(value, path)
54
+ : value
55
+
56
+ /** A filter with its dates read, through `AND`, `OR`, `NOT` and relations. */
57
+ export function whereOf(
58
+ api: Api,
59
+ model: string,
60
+ where: Args,
61
+ path: string,
62
+ ): Args {
63
+ const fields = entryOf(api, model).model.fields
64
+ const read: { [key: string]: unknown } = {}
65
+ for (const [key, value] of Object.entries(where)) {
66
+ const at = `${path}.${key}`
67
+ const field = fields.find((candidate) => candidate.name === key)
68
+ if (key === 'AND' || key === 'OR' || key === 'NOT') {
69
+ read[key] = each(value, at, (inner, to) => whereOf(api, model, inner, to))
70
+ } else if (isDate(field, key)) read[key] = dateFilter(value, at)
71
+ else if (field !== undefined && field.kind !== 'scalar' && isArgs(value)) {
72
+ // `is`, `some` and the others each take the target's filter.
73
+ const { target } = field
74
+ read[key] = Object.fromEntries(
75
+ Object.entries(value).map(([operator, inner]) => [
76
+ operator,
77
+ isArgs(inner)
78
+ ? whereOf(api, target, inner, `${at}.${operator}`)
79
+ : inner,
80
+ ]),
81
+ )
82
+ } else read[key] = value
83
+ }
84
+ return read
85
+ }
86
+
87
+ type Read = (item: Args, path: string) => Args
88
+
89
+ /** `item` with its `key` read, when it is an object. */
90
+ function within(item: Args, key: string, path: string, read: Read): Args {
91
+ const value = item[key]
92
+ return isArgs(value)
93
+ ? { ...item, [key]: read(value, `${path}.${key}`) }
94
+ : item
95
+ }
96
+
97
+ // The operations that create, update or delete a target's row (D-138).
98
+ const reaching = new Set(['connectOrCreate', 'create', 'delete', 'update'])
99
+
100
+ /**
101
+ * A relation write with the dates of its rows and unique targets read
102
+ * (D-128, D-138). A hidden model is connected, disconnected or set, never
103
+ * created, updated or deleted, as over GraphQL.
104
+ */
105
+ function relationOf(
106
+ api: Api,
107
+ model: string,
108
+ field: Exclude<SchemaField, { readonly kind: 'scalar' }>,
109
+ value: Args,
110
+ path: string,
111
+ ): Args {
112
+ const { target } = field
113
+ const hidden = !entryOf(api, target).visible
114
+ const row: Read = (item, at) => dataOf(api, target, item, at)
115
+ const unique: Read = (item, at) => whereOf(api, target, item, at)
116
+ const read: { [key: string]: unknown } = { ...value }
117
+ for (const [key, operand] of Object.entries(value)) {
118
+ const at = `${path}.${key}`
119
+ if (hidden && reaching.has(key)) {
120
+ throw new InvalidQuery(at, `Unknown key '${at}' on ${model}.`)
121
+ }
122
+ if (key === 'create') read[key] = each(operand, at, row)
123
+ else if (key === 'connectOrCreate') {
124
+ read[key] = each(operand, at, (item, to) =>
125
+ within(within(item, 'where', to, unique), 'create', to, row),
126
+ )
127
+ } else if (key === 'update' && field.kind === 'many') {
128
+ read[key] = each(operand, at, (item, to) =>
129
+ within(within(item, 'where', to, unique), 'data', to, row),
130
+ )
131
+ } else if (key === 'update') read[key] = each(operand, at, row)
132
+ else if (key === 'delete' && field.kind === 'many') {
133
+ read[key] = each(operand, at, unique)
134
+ }
135
+ }
136
+ return read
137
+ }
138
+
139
+ /** Data with its dates read, in the rows its relation writes reach too. */
140
+ export function dataOf(
141
+ api: Api,
142
+ model: string,
143
+ data: Args,
144
+ path: string,
145
+ ): Args {
146
+ const read: { [field: string]: unknown } = { ...data }
147
+ for (const field of entryOf(api, model).model.fields) {
148
+ const value = data[field.name]
149
+ const at = `${path}.${field.name}`
150
+ if (field.kind === 'scalar') {
151
+ if (field.type === 'dateTime' && value !== undefined) {
152
+ read[field.name] = date(value, at)
153
+ }
154
+ // Number updates are the server API's only (D-132).
155
+ if (field.type === 'number' && isArgs(value)) {
156
+ throw new InvalidQuery(at, `${at} must be a number.`)
157
+ }
158
+ } else if (isArgs(value)) {
159
+ read[field.name] = relationOf(api, model, field, value, at)
160
+ }
161
+ }
162
+ return read
163
+ }