@ultimabase/graphql 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 (99) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +4 -2
  3. package/dist/data-types.d.ts +15 -0
  4. package/dist/data-types.d.ts.map +1 -0
  5. package/dist/data-types.js +53 -0
  6. package/dist/data.d.ts +34 -0
  7. package/dist/data.d.ts.map +1 -0
  8. package/dist/data.js +207 -0
  9. package/dist/depth.d.ts +6 -0
  10. package/dist/depth.d.ts.map +1 -0
  11. package/dist/depth.js +67 -0
  12. package/dist/errors.d.ts +7 -0
  13. package/dist/errors.d.ts.map +1 -0
  14. package/dist/errors.js +42 -0
  15. package/dist/functions.d.ts +28 -0
  16. package/dist/functions.d.ts.map +1 -0
  17. package/dist/functions.js +195 -0
  18. package/dist/graphiql.d.ts +3 -0
  19. package/dist/graphiql.d.ts.map +1 -0
  20. package/dist/graphiql.js +46 -0
  21. package/dist/index.d.ts +47 -0
  22. package/dist/index.d.ts.map +1 -0
  23. package/dist/index.js +165 -0
  24. package/dist/inputs.d.ts +20 -0
  25. package/dist/inputs.d.ts.map +1 -0
  26. package/dist/inputs.js +236 -0
  27. package/dist/json-fields.d.ts +12 -0
  28. package/dist/json-fields.d.ts.map +1 -0
  29. package/dist/json-fields.js +66 -0
  30. package/dist/json-schema.d.ts +7 -0
  31. package/dist/json-schema.d.ts.map +1 -0
  32. package/dist/json-schema.js +16 -0
  33. package/dist/live-stream.d.ts +59 -0
  34. package/dist/live-stream.d.ts.map +1 -0
  35. package/dist/live-stream.js +136 -0
  36. package/dist/mutations.d.ts +9 -0
  37. package/dist/mutations.d.ts.map +1 -0
  38. package/dist/mutations.js +228 -0
  39. package/dist/names.d.ts +8 -0
  40. package/dist/names.d.ts.map +1 -0
  41. package/dist/names.js +14 -0
  42. package/dist/outputs.d.ts +16 -0
  43. package/dist/outputs.d.ts.map +1 -0
  44. package/dist/outputs.js +177 -0
  45. package/dist/overlap.d.ts +5 -0
  46. package/dist/overlap.d.ts.map +1 -0
  47. package/dist/overlap.js +82 -0
  48. package/dist/pages.d.ts +11 -0
  49. package/dist/pages.d.ts.map +1 -0
  50. package/dist/pages.js +22 -0
  51. package/dist/registry.d.ts +80 -0
  52. package/dist/registry.d.ts.map +1 -0
  53. package/dist/registry.js +97 -0
  54. package/dist/reread.d.ts +9 -0
  55. package/dist/reread.d.ts.map +1 -0
  56. package/dist/reread.js +116 -0
  57. package/dist/rows.d.ts +23 -0
  58. package/dist/rows.d.ts.map +1 -0
  59. package/dist/rows.js +106 -0
  60. package/dist/scalars.d.ts +13 -0
  61. package/dist/scalars.d.ts.map +1 -0
  62. package/dist/scalars.js +88 -0
  63. package/dist/schema.d.ts +17 -0
  64. package/dist/schema.d.ts.map +1 -0
  65. package/dist/schema.js +130 -0
  66. package/dist/select.d.ts +24 -0
  67. package/dist/select.d.ts.map +1 -0
  68. package/dist/select.js +152 -0
  69. package/dist/sockets.d.ts +25 -0
  70. package/dist/sockets.d.ts.map +1 -0
  71. package/dist/sockets.js +234 -0
  72. package/dist/subscriptions.d.ts +14 -0
  73. package/dist/subscriptions.d.ts.map +1 -0
  74. package/dist/subscriptions.js +242 -0
  75. package/package.json +53 -3
  76. package/src/data-types.ts +100 -0
  77. package/src/data.ts +366 -0
  78. package/src/depth.ts +98 -0
  79. package/src/errors.ts +48 -0
  80. package/src/functions.ts +291 -0
  81. package/src/graphiql.ts +54 -0
  82. package/src/index.ts +231 -0
  83. package/src/inputs.ts +348 -0
  84. package/src/json-fields.ts +95 -0
  85. package/src/json-schema.ts +21 -0
  86. package/src/live-stream.ts +192 -0
  87. package/src/mutations.ts +317 -0
  88. package/src/names.ts +25 -0
  89. package/src/outputs.ts +267 -0
  90. package/src/overlap.ts +97 -0
  91. package/src/pages.ts +39 -0
  92. package/src/registry.ts +189 -0
  93. package/src/reread.ts +159 -0
  94. package/src/rows.ts +151 -0
  95. package/src/scalars.ts +110 -0
  96. package/src/schema.ts +189 -0
  97. package/src/select.ts +230 -0
  98. package/src/sockets.ts +291 -0
  99. package/src/subscriptions.ts +334 -0
package/src/inputs.ts ADDED
@@ -0,0 +1,348 @@
1
+ // The read inputs (D-107), as the query API spells them (D-20): filters,
2
+ // `where`, `orderBy` and the `@oneOf` unique target.
3
+ import { readable } from '@ultimabase/http/internal'
4
+ import type {
5
+ GraphQLInputFieldConfigMap,
6
+ GraphQLInputType,
7
+ GraphQLNamedInputType,
8
+ GraphQLScalarType,
9
+ } from 'graphql'
10
+ import {
11
+ GraphQLBoolean,
12
+ GraphQLEnumType,
13
+ GraphQLID,
14
+ GraphQLInputObjectType,
15
+ GraphQLList,
16
+ GraphQLNonNull,
17
+ GraphQLString,
18
+ } from 'graphql'
19
+ import type { SchemaField } from 'ultimabase'
20
+ import { upper } from './names.ts'
21
+ import type { Args, Registry, Scalar } from './registry.ts'
22
+ import { scalarType } from './registry.ts'
23
+ import { DateTime } from './scalars.ts'
24
+
25
+ /** An input object's value, as the kernel's arguments. */
26
+ export const objectOf = (value: unknown): Args =>
27
+ typeof value === 'object' && value !== null ? { ...value } : {}
28
+
29
+ /** Whether a model has fields its `_search` matches (D-148). */
30
+ const searched = (fields: readonly SchemaField[]): boolean =>
31
+ fields.some((field) => field.kind === 'scalar' && field.search === true)
32
+
33
+ const list = <T extends GraphQLNamedInputType>(
34
+ type: T,
35
+ ): GraphQLList<GraphQLNonNull<T>> => new GraphQLList(new GraphQLNonNull(type))
36
+
37
+ const base = ['equals', 'not', 'in', 'notIn']
38
+ const ordered = [...base, 'lt', 'lte', 'gt', 'gte']
39
+ const text = [...base, 'contains', 'startsWith', 'endsWith']
40
+
41
+ /** `QueryMode`: a string filter's operators ignore case (D-140). */
42
+ const queryMode = (registry: Registry): GraphQLEnumType =>
43
+ registry.enum(
44
+ 'QueryMode',
45
+ () =>
46
+ new GraphQLEnumType({
47
+ name: 'QueryMode',
48
+ values: { insensitive: { value: 'insensitive' } },
49
+ }),
50
+ )
51
+
52
+ /** The input type of a filter's operator. */
53
+ function operand(
54
+ registry: Registry,
55
+ type: GraphQLNamedInputType,
56
+ operator: string,
57
+ ): GraphQLInputType {
58
+ if (operator === 'mode') return queryMode(registry)
59
+ if (operator === 'path') return list(GraphQLString)
60
+ if (operator.startsWith('string')) return GraphQLString
61
+ return operator === 'in' || operator === 'notIn' ? list(type) : type
62
+ }
63
+
64
+ /** `StringFilter`, `FloatFilter`, `PostStatusFilter`: the kernel's operators. */
65
+ function filter(
66
+ registry: Registry,
67
+ type: GraphQLNamedInputType,
68
+ operators: readonly string[],
69
+ ): GraphQLInputObjectType {
70
+ const name = `${type.name}Filter`
71
+ return registry.input(
72
+ name,
73
+ () =>
74
+ new GraphQLInputObjectType({
75
+ name,
76
+ fields: Object.fromEntries(
77
+ operators.map((operator) => [
78
+ operator,
79
+ { type: operand(registry, type, operator) },
80
+ ]),
81
+ ),
82
+ }),
83
+ )
84
+ }
85
+
86
+ /** Each scalar type's operators: no order on strings, as the kernel. */
87
+ const operators: { readonly [T in Scalar['type']]: readonly string[] } = {
88
+ string: [...text, 'mode'],
89
+ number: ordered,
90
+ decimal: ordered,
91
+ dateTime: ordered,
92
+ date: ordered,
93
+ boolean: base,
94
+ // Any JSON, a path into it or not, typed field or not (D-149).
95
+ json: [
96
+ 'path',
97
+ ...ordered,
98
+ 'stringContains',
99
+ 'stringStartsWith',
100
+ 'stringEndsWith',
101
+ 'mode',
102
+ 'arrayContains',
103
+ 'arrayStartsWith',
104
+ 'arrayEndsWith',
105
+ ],
106
+ enum: base,
107
+ }
108
+
109
+ /**
110
+ * `StringListFilter`, `FloatListFilter`, `ProductSizesListFilter`: no
111
+ * shorthand, `NOT` gives the opposite (D-120, D-127, D-149).
112
+ */
113
+ const listFilter = (
114
+ registry: Registry,
115
+ item: GraphQLScalarType | GraphQLEnumType,
116
+ ): GraphQLInputObjectType => {
117
+ const name = `${item.name}ListFilter`
118
+ return registry.input(
119
+ name,
120
+ () =>
121
+ new GraphQLInputObjectType({
122
+ name,
123
+ fields: {
124
+ has: { type: item },
125
+ hasSome: { type: list(item) },
126
+ hasEvery: { type: list(item) },
127
+ isEmpty: { type: GraphQLBoolean },
128
+ },
129
+ }),
130
+ )
131
+ }
132
+
133
+ const scalarFilter = (
134
+ registry: Registry,
135
+ model: string,
136
+ field: Scalar,
137
+ ): GraphQLInputObjectType => {
138
+ const item = scalarType(registry, model, field)
139
+ if (field.list) return listFilter(registry, item)
140
+ return filter(registry, item, operators[field.type])
141
+ }
142
+
143
+ const relationFilter = (
144
+ registry: Registry,
145
+ field: Exclude<SchemaField, Scalar>,
146
+ ): GraphQLInputObjectType => {
147
+ const one = field.kind === 'one'
148
+ const name = `${field.target}${one ? 'Relation' : 'ListRelation'}Filter`
149
+ return registry.input(
150
+ name,
151
+ () =>
152
+ new GraphQLInputObjectType({
153
+ name,
154
+ fields: () => {
155
+ const type = whereInput(registry, field.target)
156
+ return Object.fromEntries(
157
+ (one ? ['is', 'isNot'] : ['some', 'every', 'none']).map((key) => [
158
+ key,
159
+ { type },
160
+ ]),
161
+ )
162
+ },
163
+ }),
164
+ )
165
+ }
166
+
167
+ /** `PostWhereInput`. */
168
+ export function whereInput(
169
+ registry: Registry,
170
+ model: string,
171
+ ): GraphQLInputObjectType {
172
+ const name = `${model}WhereInput`
173
+ return registry.input(
174
+ name,
175
+ () =>
176
+ new GraphQLInputObjectType({
177
+ name,
178
+ fields: () => {
179
+ const self = whereInput(registry, model)
180
+ const fields: GraphQLInputFieldConfigMap = {
181
+ AND: { type: list(self) },
182
+ OR: { type: list(self) },
183
+ NOT: { type: list(self) },
184
+ id: { type: filter(registry, GraphQLID, base) },
185
+ createdAt: { type: filter(registry, DateTime, ordered) },
186
+ updatedAt: { type: filter(registry, DateTime, ordered) },
187
+ }
188
+ const entry = registry.entry(model)
189
+ if (searched(readable(entry))) {
190
+ fields['_search'] = { type: GraphQLString }
191
+ }
192
+ for (const field of readable(entry)) {
193
+ // Only a beforeFind filters a plugin field (D-122).
194
+ if (entry.exposed.fieldTypes[field.name] !== undefined) continue
195
+ fields[field.name] = {
196
+ type:
197
+ field.kind === 'scalar'
198
+ ? scalarFilter(registry, model, field)
199
+ : relationFilter(registry, field),
200
+ }
201
+ }
202
+ return fields
203
+ },
204
+ }),
205
+ )
206
+ }
207
+
208
+ const sortOrder = (registry: Registry): GraphQLEnumType =>
209
+ registry.enum(
210
+ 'SortOrder',
211
+ () =>
212
+ new GraphQLEnumType({
213
+ name: 'SortOrder',
214
+ values: { asc: { value: 'asc' }, desc: { value: 'desc' } },
215
+ }),
216
+ )
217
+
218
+ /** `CountOrderByInput`: a `many` relation sorted by its rows (D-134). */
219
+ const countOrderBy = (registry: Registry): GraphQLInputObjectType =>
220
+ registry.input(
221
+ 'CountOrderByInput',
222
+ () =>
223
+ new GraphQLInputObjectType({
224
+ name: 'CountOrderByInput',
225
+ fields: { _count: { type: sortOrder(registry) } },
226
+ }),
227
+ )
228
+
229
+ /**
230
+ * `PostOrderByInput`: scalar fields but json, lists and plugin fields, as
231
+ * the kernel sorts (D-94, D-120, D-122); a `one` relation as its target's,
232
+ * a `many` one by its count (D-134).
233
+ */
234
+ export function orderByInput(
235
+ registry: Registry,
236
+ model: string,
237
+ ): GraphQLInputObjectType {
238
+ const name = `${model}OrderByInput`
239
+ return registry.input(
240
+ name,
241
+ () =>
242
+ new GraphQLInputObjectType({
243
+ name,
244
+ fields: () => {
245
+ const type = sortOrder(registry)
246
+ const fields: GraphQLInputFieldConfigMap = {
247
+ id: { type },
248
+ createdAt: { type },
249
+ updatedAt: { type },
250
+ }
251
+ const entry = registry.entry(model)
252
+ // The kernel refuses it in a relation's: a read ranks by its own search.
253
+ if (searched(readable(entry))) fields['_relevance'] = { type }
254
+ for (const field of readable(entry)) {
255
+ if (field.kind === 'one') {
256
+ fields[field.name] = {
257
+ type: orderByInput(registry, field.target),
258
+ }
259
+ } else if (field.kind === 'many') {
260
+ fields[field.name] = { type: countOrderBy(registry) }
261
+ } else if (
262
+ field.type !== 'json' &&
263
+ !field.list &&
264
+ entry.exposed.fieldTypes[field.name] === undefined
265
+ ) {
266
+ fields[field.name] = { type }
267
+ }
268
+ }
269
+ return fields
270
+ },
271
+ }),
272
+ )
273
+ }
274
+
275
+ /** A unique field's type: a `one` relation's is its target's `ID`. */
276
+ function uniqueType(
277
+ registry: Registry,
278
+ model: string,
279
+ name: string,
280
+ ): GraphQLNamedInputType {
281
+ const field = registry.entry(model).model.fields.find((f) => f.name === name)
282
+ return field?.kind === 'scalar'
283
+ ? scalarType(registry, model, field)
284
+ : GraphQLID
285
+ }
286
+
287
+ const compositeName = (fields: readonly string[]): string => fields.join('_')
288
+
289
+ /**
290
+ * `PostWhereUniqueInput @oneOf`: `id`, each unique field, and each unique
291
+ * index as `author_slug: { author, slug }`.
292
+ */
293
+ export function uniqueInput(
294
+ registry: Registry,
295
+ model: string,
296
+ ): GraphQLInputObjectType {
297
+ const name = `${model}WhereUniqueInput`
298
+ return registry.input(name, () => {
299
+ const fields: GraphQLInputFieldConfigMap = {}
300
+ const { exposed } = registry.entry(model)
301
+ // A field only Master reads is absent, as from the filters (D-44).
302
+ const uniques = exposed.uniques.filter((unique) =>
303
+ unique.every((field) => exposed.readRules[field] !== 'private'),
304
+ )
305
+ for (const unique of uniques) {
306
+ const [only] = unique
307
+ if (unique.length === 1 && only !== undefined) {
308
+ fields[only] = { type: uniqueType(registry, model, only) }
309
+ continue
310
+ }
311
+ const key = compositeName(unique)
312
+ const composite = `${model}${unique.map(upper).join('')}UniqueInput`
313
+ fields[key] = {
314
+ type: new GraphQLInputObjectType({
315
+ name: composite,
316
+ fields: Object.fromEntries(
317
+ unique.map((field) => [
318
+ field,
319
+ { type: new GraphQLNonNull(uniqueType(registry, model, field)) },
320
+ ]),
321
+ ),
322
+ }),
323
+ }
324
+ }
325
+ return new GraphQLInputObjectType({ name, fields, isOneOf: true })
326
+ })
327
+ }
328
+
329
+ /** A `@oneOf` unique target as the kernel's flat `where`. */
330
+ export function uniqueWhere(
331
+ registry: Registry,
332
+ model: string,
333
+ where: Args,
334
+ ): Args {
335
+ const composites = new Set(
336
+ registry
337
+ .entry(model)
338
+ .exposed.uniques.filter((unique) => unique.length > 1)
339
+ .map(compositeName),
340
+ )
341
+ const flat: { [field: string]: unknown } = {}
342
+ for (const [key, value] of Object.entries(where)) {
343
+ if (composites.has(key) && typeof value === 'object') {
344
+ Object.assign(flat, value)
345
+ } else flat[key] = value
346
+ }
347
+ return flat
348
+ }
@@ -0,0 +1,95 @@
1
+ // A json field's types (D-125): exact from its schema's JSON Schema export,
2
+ // `JSON` without one. Read as `UserPrefs`, written as `UserPrefsInput`,
3
+ // filtered by `JSONFilter` as an untyped field (D-149). A plugin field reads
4
+ // as its shape says (D-152).
5
+ import type {
6
+ GraphQLNullableInputType,
7
+ GraphQLNullableOutputType,
8
+ } from 'graphql'
9
+ import type { Path, Typed } from './data.ts'
10
+ import { fieldInput, fieldOutput } from './data.ts'
11
+ import { upper } from './names.ts'
12
+ import type { Args, Registry, Scalar } from './registry.ts'
13
+ import { JSONScalar, plain } from './scalars.ts'
14
+
15
+ const pathOf = (model: string, field: Scalar, suffix: string): Path => ({
16
+ owner: `${model}.${field.name}`,
17
+ name: `${model}${upper(field.name)}`,
18
+ suffix,
19
+ at: field.name,
20
+ })
21
+
22
+ const untyped = { type: JSONScalar, nullable: false }
23
+
24
+ /** `UserPrefs` or `PostCover`, and whether the schema reads `null`. */
25
+ export function jsonOutput(
26
+ registry: Registry,
27
+ model: string,
28
+ field: Scalar,
29
+ ): Typed<GraphQLNullableOutputType> {
30
+ const { validators, fieldTypes } = registry.entry(model).exposed
31
+ const schema = validators[field.name] ?? fieldTypes[field.name]?.shape
32
+ const path = pathOf(model, field, '')
33
+ return (schema && fieldOutput(schema, path, registry.data)) ?? untyped
34
+ }
35
+
36
+ /** `UserPrefsInput`, and whether the schema takes `null`. */
37
+ export function jsonInput(
38
+ registry: Registry,
39
+ model: string,
40
+ field: Scalar,
41
+ ): Typed<GraphQLNullableInputType> {
42
+ const schema = registry.entry(model).exposed.validators[field.name]
43
+ const path = pathOf(model, field, 'Input')
44
+ return (schema && fieldInput(schema, path, registry.data)) ?? untyped
45
+ }
46
+
47
+ /** A written row's json and plugin values, their dates back to text. */
48
+ export function plainData(registry: Registry, model: string, data: Args): Args {
49
+ const plained: { [field: string]: unknown } = { ...data }
50
+ const { exposed, model: schema } = registry.entry(model)
51
+ for (const field of schema.fields) {
52
+ const value = plained[field.name]
53
+ if (value === undefined || field.kind !== 'scalar') continue
54
+ if (field.type === 'json' || exposed.fieldTypes[field.name] !== undefined) {
55
+ plained[field.name] = plain(value)
56
+ }
57
+ }
58
+ return plained
59
+ }
60
+
61
+ const each = (value: unknown, map: (where: Args) => Args): unknown =>
62
+ Array.isArray(value)
63
+ ? value.map((item) => each(item, map))
64
+ : typeof value === 'object' && value !== null
65
+ ? map({ ...value })
66
+ : value
67
+
68
+ /** A filter's json operands, their dates back to text, through relations. */
69
+ export function plainWhere(
70
+ registry: Registry,
71
+ model: string,
72
+ where: Args,
73
+ ): Args {
74
+ const fields = registry.entry(model).model.fields
75
+ const plained: { [key: string]: unknown } = {}
76
+ for (const [key, value] of Object.entries(where)) {
77
+ const field = fields.find((candidate) => candidate.name === key)
78
+ if (key === 'AND' || key === 'OR' || key === 'NOT') {
79
+ plained[key] = each(value, (inner) => plainWhere(registry, model, inner))
80
+ } else if (field?.kind === 'scalar') {
81
+ plained[key] = field.type === 'json' ? plain(value) : value
82
+ } else if (field !== undefined) {
83
+ // `is`, `some` and the others each take the target's filter.
84
+ plained[key] = each(value, (operators) =>
85
+ Object.fromEntries(
86
+ Object.entries(operators).map(([operator, inner]) => [
87
+ operator,
88
+ each(inner, (target) => plainWhere(registry, field.target, target)),
89
+ ]),
90
+ ),
91
+ )
92
+ } else plained[key] = value
93
+ }
94
+ return plained
95
+ }
@@ -0,0 +1,21 @@
1
+ // A schema's standard JSON Schema export, which `data.ts` types (D-45).
2
+
3
+ import type { Node } from '@ultimabase/http/internal'
4
+ import { isNode, jsonSchemaOf } from '@ultimabase/http/internal'
5
+ import { Refused } from 'ultimabase'
6
+
7
+ export type Side = 'input' | 'output'
8
+
9
+ /** A json field's JSON Schema, `undefined` without one (D-125). */
10
+ export function exportOf(schema: object, side: Side): Node | undefined {
11
+ try {
12
+ return jsonSchemaOf(schema, side, '')
13
+ } catch (error) {
14
+ if (error instanceof Refused) return undefined
15
+ throw error
16
+ }
17
+ }
18
+
19
+ /** The JSON Schema's shared nodes, by id. */
20
+ export const defsOf = (root: Node): Node =>
21
+ isNode(root['$defs']) ? root['$defs'] : {}
@@ -0,0 +1,192 @@
1
+ // A live read as graphql-ws streams it (D-154): the engine's messages in
2
+ // order, each run against the subscription's selection, and those the client
3
+ // cannot take while its socket is full dropped until the window is sent again.
4
+ // A completed live list is kept 10 s for a resume, its later events waiting.
5
+ import type { ExecutionResult } from 'graphql'
6
+ import type { LiveHandle, LiveMessage, Viewer } from '@ultimabase/realtime'
7
+
8
+ /** A message to run against the selection, and how its result is sent. */
9
+ export class LiveEvent {
10
+ readonly root: unknown
11
+ readonly finish: (result: ExecutionResult) => ExecutionResult
12
+
13
+ constructor(
14
+ root: unknown,
15
+ finish: (result: ExecutionResult) => ExecutionResult,
16
+ ) {
17
+ this.root = root
18
+ this.finish = finish
19
+ }
20
+ }
21
+
22
+ /** The socket a connection's subscriptions write to. */
23
+ export type Channel = {
24
+ readonly viewer: Viewer
25
+ /** Whether the client's buffer holds more than it may. */
26
+ readonly full: () => boolean
27
+ /** Runs `then` once everything sent is written. */
28
+ readonly drained: (then: () => void) => void
29
+ /** Closes the socket: its caller is no longer the same user. */
30
+ readonly forbid: () => void
31
+ /** The completed live lists a resume may take back, by token. */
32
+ readonly kept: Map<string, Kept>
33
+ }
34
+
35
+ /**
36
+ * A subscription's channel, whether it gets the whole window each time, and
37
+ * the token of the completed list it resumes.
38
+ */
39
+ export type Live = {
40
+ readonly channel: Channel
41
+ readonly page: boolean
42
+ readonly resume?: string
43
+ }
44
+
45
+ /** A live list's token, what read it is, and the rows it shows. */
46
+ export type Keep = {
47
+ readonly token: string
48
+ readonly signature: string
49
+ first: number
50
+ }
51
+
52
+ /** A completed live list, until a resume takes it or its time is up. */
53
+ export type Kept = {
54
+ readonly keep: Keep
55
+ readonly stream: AsyncIterableIterator<LiveEvent>
56
+ readonly handle: LiveHandle
57
+ readonly timer: NodeJS.Timeout
58
+ readonly end: () => void
59
+ }
60
+
61
+ /** How long a completed live list waits for a resume. */
62
+ const keptFor = 10_000
63
+
64
+ /** Each WebSocket operation's, by its context. */
65
+ export const lives: WeakMap<object, Live> = new WeakMap()
66
+
67
+ const finished = (): IteratorResult<LiveEvent> => ({
68
+ done: true,
69
+ value: undefined,
70
+ })
71
+
72
+ /**
73
+ * The list `token` keeps, taken back when it is the same read and grown to
74
+ * `first` rows; a resume that grows past a limit ends it.
75
+ */
76
+ export async function resumed(
77
+ channel: Channel,
78
+ token: string,
79
+ signature: string,
80
+ first: number,
81
+ ): Promise<AsyncIterableIterator<LiveEvent> | undefined> {
82
+ const kept = channel.kept.get(token)
83
+ if (kept?.keep.signature !== signature || first < kept.keep.first) {
84
+ return undefined
85
+ }
86
+ channel.kept.delete(token)
87
+ clearTimeout(kept.timer)
88
+ try {
89
+ if (first > kept.keep.first) await kept.handle.grow(first - kept.keep.first)
90
+ } catch (error) {
91
+ kept.end()
92
+ throw error
93
+ }
94
+ kept.keep.first = first
95
+ return kept.stream
96
+ }
97
+
98
+ /**
99
+ * Follows the read `open` starts; `event` turns each message the client
100
+ * gets into what it runs, and `closed`, given a live function's error, into
101
+ * the last one, unless the viewer ended, which closes the socket. A list given `keep` is kept once completed.
102
+ */
103
+ export async function liveStream(
104
+ channel: Channel,
105
+ open: (send: (message: LiveMessage) => void) => Promise<LiveHandle>,
106
+ event: (message: Exclude<LiveMessage, { kind: 'closed' }>) => LiveEvent,
107
+ closed: (error?: unknown) => LiveEvent,
108
+ keep?: Keep,
109
+ ): Promise<AsyncIterableIterator<LiveEvent>> {
110
+ const queue: LiveEvent[] = []
111
+ let waiting: ((next: IteratorResult<LiveEvent>) => void) | undefined
112
+ let ending = false
113
+ let done = false
114
+ // Changes are dropped from a full buffer until the window comes whole.
115
+ let dropping = false
116
+ let owed = false
117
+ let handle: LiveHandle | undefined
118
+ const push = (value: LiveEvent) => {
119
+ const taker = waiting
120
+ waiting = undefined
121
+ if (taker === undefined) queue.push(value)
122
+ else taker({ done: false, value })
123
+ }
124
+ const end = () => {
125
+ done = true
126
+ queue.length = 0
127
+ handle?.close()
128
+ const taker = waiting
129
+ waiting = undefined
130
+ taker?.(finished())
131
+ }
132
+ const send = (message: LiveMessage) => {
133
+ if (done || ending) return
134
+ if (message.kind === 'closed') {
135
+ if (channel.viewer.closed) {
136
+ end()
137
+ return channel.forbid()
138
+ }
139
+ ending = true
140
+ return push(closed(message.error))
141
+ }
142
+ if (dropping && message.kind === 'changes') return
143
+ if (!dropping && channel.full()) {
144
+ queue.length = 0
145
+ dropping = true
146
+ channel.drained(() => {
147
+ if (handle === undefined) owed = true
148
+ else void handle.resend()
149
+ })
150
+ return
151
+ }
152
+ dropping = false
153
+ push(event(message))
154
+ }
155
+ const opened = await open(send)
156
+ handle = opened
157
+ if (owed) void opened.resend()
158
+ const stream: AsyncIterableIterator<LiveEvent> = {
159
+ next: async () => {
160
+ const value = queue.shift()
161
+ if (value !== undefined) return { done: false, value }
162
+ if (done || ending) {
163
+ end()
164
+ return finished()
165
+ }
166
+ return new Promise((taker) => {
167
+ waiting = taker
168
+ })
169
+ },
170
+ return: async () => {
171
+ if (channel.kept.get(keep?.token ?? '')?.stream === stream) {
172
+ return finished()
173
+ }
174
+ if (keep === undefined || done || ending || channel.viewer.closed) {
175
+ end()
176
+ return finished()
177
+ }
178
+ const taker = waiting
179
+ waiting = undefined
180
+ taker?.(finished())
181
+ const timer = setTimeout(() => {
182
+ channel.kept.delete(keep.token)
183
+ end()
184
+ }, keptFor)
185
+ timer.unref()
186
+ channel.kept.set(keep.token, { keep, stream, handle: opened, timer, end })
187
+ return finished()
188
+ },
189
+ [Symbol.asyncIterator]: () => stream,
190
+ }
191
+ return stream
192
+ }