@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.
- package/CHANGELOG.md +24 -0
- package/README.md +4 -2
- package/dist/data-types.d.ts +15 -0
- package/dist/data-types.d.ts.map +1 -0
- package/dist/data-types.js +53 -0
- package/dist/data.d.ts +34 -0
- package/dist/data.d.ts.map +1 -0
- package/dist/data.js +207 -0
- package/dist/depth.d.ts +6 -0
- package/dist/depth.d.ts.map +1 -0
- package/dist/depth.js +67 -0
- package/dist/errors.d.ts +7 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +42 -0
- package/dist/functions.d.ts +28 -0
- package/dist/functions.d.ts.map +1 -0
- package/dist/functions.js +195 -0
- package/dist/graphiql.d.ts +3 -0
- package/dist/graphiql.d.ts.map +1 -0
- package/dist/graphiql.js +46 -0
- package/dist/index.d.ts +47 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +165 -0
- package/dist/inputs.d.ts +20 -0
- package/dist/inputs.d.ts.map +1 -0
- package/dist/inputs.js +236 -0
- package/dist/json-fields.d.ts +12 -0
- package/dist/json-fields.d.ts.map +1 -0
- package/dist/json-fields.js +66 -0
- package/dist/json-schema.d.ts +7 -0
- package/dist/json-schema.d.ts.map +1 -0
- package/dist/json-schema.js +16 -0
- package/dist/live-stream.d.ts +59 -0
- package/dist/live-stream.d.ts.map +1 -0
- package/dist/live-stream.js +136 -0
- package/dist/mutations.d.ts +9 -0
- package/dist/mutations.d.ts.map +1 -0
- package/dist/mutations.js +228 -0
- package/dist/names.d.ts +8 -0
- package/dist/names.d.ts.map +1 -0
- package/dist/names.js +14 -0
- package/dist/outputs.d.ts +16 -0
- package/dist/outputs.d.ts.map +1 -0
- package/dist/outputs.js +177 -0
- package/dist/overlap.d.ts +5 -0
- package/dist/overlap.d.ts.map +1 -0
- package/dist/overlap.js +82 -0
- package/dist/pages.d.ts +11 -0
- package/dist/pages.d.ts.map +1 -0
- package/dist/pages.js +22 -0
- package/dist/registry.d.ts +80 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +97 -0
- package/dist/reread.d.ts +9 -0
- package/dist/reread.d.ts.map +1 -0
- package/dist/reread.js +116 -0
- package/dist/rows.d.ts +23 -0
- package/dist/rows.d.ts.map +1 -0
- package/dist/rows.js +106 -0
- package/dist/scalars.d.ts +13 -0
- package/dist/scalars.d.ts.map +1 -0
- package/dist/scalars.js +88 -0
- package/dist/schema.d.ts +17 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +130 -0
- package/dist/select.d.ts +24 -0
- package/dist/select.d.ts.map +1 -0
- package/dist/select.js +152 -0
- package/dist/sockets.d.ts +25 -0
- package/dist/sockets.d.ts.map +1 -0
- package/dist/sockets.js +234 -0
- package/dist/subscriptions.d.ts +14 -0
- package/dist/subscriptions.d.ts.map +1 -0
- package/dist/subscriptions.js +242 -0
- package/package.json +53 -3
- package/src/data-types.ts +100 -0
- package/src/data.ts +366 -0
- package/src/depth.ts +98 -0
- package/src/errors.ts +48 -0
- package/src/functions.ts +291 -0
- package/src/graphiql.ts +54 -0
- package/src/index.ts +231 -0
- package/src/inputs.ts +348 -0
- package/src/json-fields.ts +95 -0
- package/src/json-schema.ts +21 -0
- package/src/live-stream.ts +192 -0
- package/src/mutations.ts +317 -0
- package/src/names.ts +25 -0
- package/src/outputs.ts +267 -0
- package/src/overlap.ts +97 -0
- package/src/pages.ts +39 -0
- package/src/registry.ts +189 -0
- package/src/reread.ts +159 -0
- package/src/rows.ts +151 -0
- package/src/scalars.ts +110 -0
- package/src/schema.ts +189 -0
- package/src/select.ts +230 -0
- package/src/sockets.ts +291 -0
- package/src/subscriptions.ts +334 -0
package/src/schema.ts
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// The schema (D-107): for each visible model, `post(where)` and `posts(...)`,
|
|
2
|
+
// each read in one kernel query as its caller, and its mutations.
|
|
3
|
+
import { connection, rowsOf, run } from '@ultimabase/http/internal'
|
|
4
|
+
import type { GraphQLFieldConfig, GraphQLNamedType } from 'graphql'
|
|
5
|
+
import {
|
|
6
|
+
GraphQLList,
|
|
7
|
+
GraphQLNonNull,
|
|
8
|
+
GraphQLObjectType,
|
|
9
|
+
GraphQLSchema,
|
|
10
|
+
} from 'graphql'
|
|
11
|
+
import { objectOf, uniqueInput, uniqueWhere } from './inputs.ts'
|
|
12
|
+
import { connectionType, isMaster, listArgs, objectType } from './outputs.ts'
|
|
13
|
+
import { listRead, whereOf } from './pages.ts'
|
|
14
|
+
import type { Args, Context, Entry, Registry } from './registry.ts'
|
|
15
|
+
import { as } from './registry.ts'
|
|
16
|
+
import { rowNodes, selectOf } from './select.ts'
|
|
17
|
+
import { mutationFields } from './mutations.ts'
|
|
18
|
+
import type { RootField } from './functions.ts'
|
|
19
|
+
import { Refused } from 'ultimabase'
|
|
20
|
+
|
|
21
|
+
type Fields = { [field: string]: GraphQLFieldConfig<unknown, Context, Args> }
|
|
22
|
+
|
|
23
|
+
function rootFields(registry: Registry, entry: Entry): Fields {
|
|
24
|
+
const name = entry.model.name
|
|
25
|
+
const type = objectType(registry, name)
|
|
26
|
+
const { queries } = entry.exposed
|
|
27
|
+
const list =
|
|
28
|
+
registry.settings.pagination === 'list'
|
|
29
|
+
? new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(type)))
|
|
30
|
+
: new GraphQLNonNull(connectionType(registry, `${name}Connection`, name))
|
|
31
|
+
return {
|
|
32
|
+
[entry.single]: {
|
|
33
|
+
type,
|
|
34
|
+
args: {
|
|
35
|
+
where: { type: new GraphQLNonNull(uniqueInput(registry, name)) },
|
|
36
|
+
},
|
|
37
|
+
resolve: async (_, args, context, info) => {
|
|
38
|
+
const where = uniqueWhere(registry, name, objectOf(args['where']))
|
|
39
|
+
const select = selectOf(
|
|
40
|
+
registry,
|
|
41
|
+
info,
|
|
42
|
+
type,
|
|
43
|
+
info.fieldNodes,
|
|
44
|
+
isMaster(context),
|
|
45
|
+
)
|
|
46
|
+
return run(() => queries.findUnique({ where, select }, as(context)))
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
[entry.list]: {
|
|
50
|
+
type: list,
|
|
51
|
+
args: listArgs(registry, name),
|
|
52
|
+
resolve: async (_, args, context, info) => {
|
|
53
|
+
const master = isMaster(context)
|
|
54
|
+
const { read, page } = listRead(registry, entry, args, master)
|
|
55
|
+
const select = selectOf(
|
|
56
|
+
registry,
|
|
57
|
+
info,
|
|
58
|
+
type,
|
|
59
|
+
rowNodes(registry, info),
|
|
60
|
+
master,
|
|
61
|
+
)
|
|
62
|
+
const rows = await run(() =>
|
|
63
|
+
queries.findMany({ ...read, select }, as(context)),
|
|
64
|
+
)
|
|
65
|
+
if (page === undefined) return rows
|
|
66
|
+
let counted: Promise<unknown> | undefined
|
|
67
|
+
return {
|
|
68
|
+
...connection(rowsOf(rows), page, (row) => row),
|
|
69
|
+
// Called by the default resolver, so counted only when selected,
|
|
70
|
+
// and once however many aliases ask.
|
|
71
|
+
totalCount: async () => {
|
|
72
|
+
counted ??= run(() =>
|
|
73
|
+
queries.count(whereOf(registry, name, args), as(context)),
|
|
74
|
+
)
|
|
75
|
+
return counted
|
|
76
|
+
},
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const taken = (name: string, owner: string): Error =>
|
|
84
|
+
new Refused(`${owner}: the type ${name} is a generated type's name.`)
|
|
85
|
+
|
|
86
|
+
/** The models' types, a json field's among them, built once to name them. */
|
|
87
|
+
function generatedTypes(
|
|
88
|
+
fields: Fields,
|
|
89
|
+
mutations: Fields,
|
|
90
|
+
typeOwners: ReadonlyMap<string, string>,
|
|
91
|
+
): ReturnType<GraphQLSchema['getTypeMap']> {
|
|
92
|
+
try {
|
|
93
|
+
return new GraphQLSchema({
|
|
94
|
+
query: new GraphQLObjectType({ name: 'Query', fields: { ...fields } }),
|
|
95
|
+
mutation: new GraphQLObjectType({
|
|
96
|
+
name: 'Mutation',
|
|
97
|
+
fields: { ...mutations },
|
|
98
|
+
}),
|
|
99
|
+
}).getTypeMap()
|
|
100
|
+
} catch (error) {
|
|
101
|
+
// A json field's type named like a model's, as `PostStatus`.
|
|
102
|
+
const name = /multiple types named "(\w+)"/.exec(String(error))?.[1]
|
|
103
|
+
const owner = name === undefined ? undefined : typeOwners.get(name)
|
|
104
|
+
if (name === undefined || owner === undefined) throw error
|
|
105
|
+
throw taken(name, owner)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Refuses two root fields of one name, naming both owners. */
|
|
110
|
+
export function schemaOf(
|
|
111
|
+
registry: Registry,
|
|
112
|
+
called: {
|
|
113
|
+
readonly fields: readonly RootField[]
|
|
114
|
+
readonly owners: ReadonlyMap<string, string>
|
|
115
|
+
readonly named: (name: string) => GraphQLNamedType | undefined
|
|
116
|
+
/** The live models' subscriptions, named as their queries (D-154). */
|
|
117
|
+
readonly subscriptions: Fields
|
|
118
|
+
},
|
|
119
|
+
): GraphQLSchema {
|
|
120
|
+
const { fields: functions, owners: typeOwners, named, subscriptions } = called
|
|
121
|
+
const roots = {
|
|
122
|
+
query: new Map<string, string>(),
|
|
123
|
+
mutation: new Map<string, string>(),
|
|
124
|
+
}
|
|
125
|
+
const fields: Fields = {}
|
|
126
|
+
const mutations: Fields = {}
|
|
127
|
+
const add = (
|
|
128
|
+
into: Fields,
|
|
129
|
+
owners: Map<string, string>,
|
|
130
|
+
owner: string,
|
|
131
|
+
hint: string,
|
|
132
|
+
added: Fields,
|
|
133
|
+
) => {
|
|
134
|
+
for (const [name, field] of Object.entries(added)) {
|
|
135
|
+
const other = owners.get(name)
|
|
136
|
+
if (other !== undefined) {
|
|
137
|
+
throw new Refused(
|
|
138
|
+
`${owner}: the root field ${name} is ${other}'s; set .with({ graphql: { ${hint} } }).`,
|
|
139
|
+
)
|
|
140
|
+
}
|
|
141
|
+
owners.set(name, owner)
|
|
142
|
+
into[name] = field
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
for (const entry of registry.entries.values()) {
|
|
146
|
+
if (!entry.visible) continue
|
|
147
|
+
const owner = entry.model.name
|
|
148
|
+
add(
|
|
149
|
+
mutations,
|
|
150
|
+
roots.mutation,
|
|
151
|
+
owner,
|
|
152
|
+
'plural',
|
|
153
|
+
mutationFields(registry, entry),
|
|
154
|
+
)
|
|
155
|
+
add(fields, roots.query, owner, 'plural', rootFields(registry, entry))
|
|
156
|
+
}
|
|
157
|
+
// The models' types, which no function's or field's type may be named like.
|
|
158
|
+
const generated = generatedTypes(fields, mutations, typeOwners)
|
|
159
|
+
for (const [name, owner] of typeOwners) {
|
|
160
|
+
if (name in generated && generated[name] !== named(name)) {
|
|
161
|
+
throw taken(name, owner)
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
for (const { query, name, owner, field } of functions) {
|
|
165
|
+
const [into, owners] = query
|
|
166
|
+
? [fields, roots.query]
|
|
167
|
+
: [mutations, roots.mutation]
|
|
168
|
+
add(into, owners, owner, 'name', { [name]: field })
|
|
169
|
+
}
|
|
170
|
+
if (Object.keys(fields).length === 0) {
|
|
171
|
+
throw new Refused(
|
|
172
|
+
'graphqlPlugin: no query to expose; each model has .with({ graphql: false }) and no function is a query.',
|
|
173
|
+
)
|
|
174
|
+
}
|
|
175
|
+
return new GraphQLSchema({
|
|
176
|
+
query: new GraphQLObjectType({ name: 'Query', fields }),
|
|
177
|
+
mutation:
|
|
178
|
+
Object.keys(mutations).length === 0
|
|
179
|
+
? undefined
|
|
180
|
+
: new GraphQLObjectType({ name: 'Mutation', fields: mutations }),
|
|
181
|
+
subscription:
|
|
182
|
+
Object.keys(subscriptions).length === 0
|
|
183
|
+
? undefined
|
|
184
|
+
: new GraphQLObjectType({
|
|
185
|
+
name: 'Subscription',
|
|
186
|
+
fields: subscriptions,
|
|
187
|
+
}),
|
|
188
|
+
})
|
|
189
|
+
}
|
package/src/select.ts
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
// A query's selection as one kernel read (D-107): the fields asked for, and
|
|
2
|
+
// each `many` relation's own page and count, read with its parent's rows.
|
|
3
|
+
import { badInput, readable } from '@ultimabase/http/internal'
|
|
4
|
+
import type {
|
|
5
|
+
FieldNode,
|
|
6
|
+
GraphQLObjectType,
|
|
7
|
+
GraphQLResolveInfo,
|
|
8
|
+
SelectionSetNode,
|
|
9
|
+
} from 'graphql'
|
|
10
|
+
import type { ExposedVirtual } from 'ultimabase'
|
|
11
|
+
import {
|
|
12
|
+
getArgumentValues,
|
|
13
|
+
getDirectiveValues,
|
|
14
|
+
getNamedType,
|
|
15
|
+
GraphQLIncludeDirective,
|
|
16
|
+
GraphQLSkipDirective,
|
|
17
|
+
isObjectType,
|
|
18
|
+
Kind,
|
|
19
|
+
} from 'graphql'
|
|
20
|
+
import { objectType } from './outputs.ts'
|
|
21
|
+
import { plain } from './scalars.ts'
|
|
22
|
+
import { listRead } from './pages.ts'
|
|
23
|
+
import type { Args, Registry } from './registry.ts'
|
|
24
|
+
|
|
25
|
+
export type Info = Pick<GraphQLResolveInfo, 'fragments' | 'variableValues'>
|
|
26
|
+
/** A field's name, or its alias: the key of its value in the response. */
|
|
27
|
+
export type Key = (node: FieldNode) => string
|
|
28
|
+
/** Fields under one key, the first of them included. */
|
|
29
|
+
export type Fields = readonly [FieldNode, ...FieldNode[]]
|
|
30
|
+
/** A field or an operation: what selects fields. */
|
|
31
|
+
export type Selecting = { readonly selectionSet?: SelectionSetNode | undefined }
|
|
32
|
+
|
|
33
|
+
function included(info: Info, node: Parameters<typeof getDirectiveValues>[1]) {
|
|
34
|
+
const skip = getDirectiveValues(
|
|
35
|
+
GraphQLSkipDirective,
|
|
36
|
+
node,
|
|
37
|
+
info.variableValues,
|
|
38
|
+
)
|
|
39
|
+
if (skip?.['if'] === true) return false
|
|
40
|
+
const include = getDirectiveValues(
|
|
41
|
+
GraphQLIncludeDirective,
|
|
42
|
+
node,
|
|
43
|
+
info.variableValues,
|
|
44
|
+
)
|
|
45
|
+
return include?.['if'] !== false
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// A fragment spread again adds nothing the first spread did not, so each is
|
|
49
|
+
// collected once: spreading each fragment twice into the next would otherwise
|
|
50
|
+
// cost 2^n visits.
|
|
51
|
+
function collect(
|
|
52
|
+
info: Info,
|
|
53
|
+
set: SelectionSetNode | undefined,
|
|
54
|
+
into: Map<string, [FieldNode, ...FieldNode[]]>,
|
|
55
|
+
key: Key,
|
|
56
|
+
spread: Set<string>,
|
|
57
|
+
): void {
|
|
58
|
+
for (const selection of set?.selections ?? []) {
|
|
59
|
+
if (!included(info, selection)) continue
|
|
60
|
+
if (selection.kind === Kind.FIELD) {
|
|
61
|
+
const found = into.get(key(selection))
|
|
62
|
+
if (found === undefined) into.set(key(selection), [selection])
|
|
63
|
+
else found.push(selection)
|
|
64
|
+
} else if (selection.kind === Kind.INLINE_FRAGMENT) {
|
|
65
|
+
collect(info, selection.selectionSet, into, key, spread)
|
|
66
|
+
} else if (!spread.has(selection.name.value)) {
|
|
67
|
+
spread.add(selection.name.value)
|
|
68
|
+
const fragment = info.fragments[selection.name.value]
|
|
69
|
+
collect(info, fragment?.selectionSet, into, key, spread)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const byName: Key = (node) => node.name.value
|
|
75
|
+
|
|
76
|
+
/** The fields under some nodes, by name, through fragments and directives. */
|
|
77
|
+
export function grouped(
|
|
78
|
+
info: Info,
|
|
79
|
+
nodes: readonly Selecting[],
|
|
80
|
+
key: Key = byName,
|
|
81
|
+
): ReadonlyMap<string, Fields> {
|
|
82
|
+
const into = new Map<string, [FieldNode, ...FieldNode[]]>()
|
|
83
|
+
const spread = new Set<string>()
|
|
84
|
+
for (const node of nodes) collect(info, node.selectionSet, into, key, spread)
|
|
85
|
+
return into
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** One relation's arguments, the same under every alias. */
|
|
89
|
+
function argsOf(
|
|
90
|
+
type: GraphQLObjectType,
|
|
91
|
+
name: string,
|
|
92
|
+
nodes: readonly FieldNode[],
|
|
93
|
+
info: Info,
|
|
94
|
+
): Args {
|
|
95
|
+
const field = type.getFields()[name]
|
|
96
|
+
const [first, ...others] = nodes.map((node) =>
|
|
97
|
+
field === undefined
|
|
98
|
+
? {}
|
|
99
|
+
: getArgumentValues(field, node, info.variableValues),
|
|
100
|
+
)
|
|
101
|
+
const key = JSON.stringify(first)
|
|
102
|
+
if (others.some((args) => JSON.stringify(args) !== key)) {
|
|
103
|
+
throw badInput(
|
|
104
|
+
`${name} is selected twice with different arguments; read it once.`,
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
return first ?? {}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Under a connection, the nodes its edges select. */
|
|
111
|
+
export function nodesOf(info: Info, nodes: readonly FieldNode[]): FieldNode[] {
|
|
112
|
+
const edges = grouped(info, nodes).get('edges') ?? []
|
|
113
|
+
return [...(grouped(info, edges).get('node') ?? [])]
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** What a root list selects of its rows: under `edges { node }`. */
|
|
117
|
+
export function rowNodes(
|
|
118
|
+
registry: Registry,
|
|
119
|
+
info: GraphQLResolveInfo,
|
|
120
|
+
): readonly FieldNode[] {
|
|
121
|
+
return registry.settings.pagination === 'list'
|
|
122
|
+
? info.fieldNodes
|
|
123
|
+
: nodesOf(info, info.fieldNodes)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const system = new Set(['id', 'createdAt', 'updatedAt'])
|
|
127
|
+
|
|
128
|
+
/** A virtual's read: `true`, its arguments, and a model's `select` (D-151). */
|
|
129
|
+
function virtualRead(
|
|
130
|
+
registry: Registry,
|
|
131
|
+
info: Info,
|
|
132
|
+
type: GraphQLObjectType,
|
|
133
|
+
virtual: ExposedVirtual,
|
|
134
|
+
nodes: readonly FieldNode[],
|
|
135
|
+
master: boolean,
|
|
136
|
+
): unknown {
|
|
137
|
+
const { output } = virtual
|
|
138
|
+
const args = plain(argsOf(type, virtual.name, nodes, info))
|
|
139
|
+
const input = typeof args === 'object' && args !== null ? args : {}
|
|
140
|
+
if (output.kind !== 'model') return virtual.input === undefined || input
|
|
141
|
+
const target = objectType(registry, output.name)
|
|
142
|
+
return { ...input, select: selectOf(registry, info, target, nodes, master) }
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** The kernel's `select` for what a query asks of a model's rows. */
|
|
146
|
+
export function selectOf(
|
|
147
|
+
registry: Registry,
|
|
148
|
+
info: Info,
|
|
149
|
+
type: GraphQLObjectType,
|
|
150
|
+
nodes: readonly FieldNode[],
|
|
151
|
+
master: boolean,
|
|
152
|
+
): Args {
|
|
153
|
+
const entry = registry.entry(type.name)
|
|
154
|
+
const select: { [field: string]: unknown } = { id: true }
|
|
155
|
+
// The connections' `totalCount`, each the count of its `where` (D-134).
|
|
156
|
+
const counts: { [field: string]: unknown } = {}
|
|
157
|
+
for (const [name, fieldNodes] of grouped(info, nodes)) {
|
|
158
|
+
if (name === '__typename') continue
|
|
159
|
+
if (system.has(name)) {
|
|
160
|
+
select[name] = true
|
|
161
|
+
continue
|
|
162
|
+
}
|
|
163
|
+
const virtual = entry.exposed.virtuals.find((v) => v.name === name)
|
|
164
|
+
if (virtual !== undefined) {
|
|
165
|
+
select[name] = virtualRead(
|
|
166
|
+
registry,
|
|
167
|
+
info,
|
|
168
|
+
type,
|
|
169
|
+
virtual,
|
|
170
|
+
fieldNodes,
|
|
171
|
+
master,
|
|
172
|
+
)
|
|
173
|
+
continue
|
|
174
|
+
}
|
|
175
|
+
const field = entry.model.fields.find((f) => f.name === name)
|
|
176
|
+
if (field === undefined) {
|
|
177
|
+
// A field `extend` added: its resolver gets the row's scalars.
|
|
178
|
+
for (const scalar of readable(entry)) {
|
|
179
|
+
if (scalar.kind === 'scalar') select[scalar.name] = true
|
|
180
|
+
}
|
|
181
|
+
for (const key of system) select[key] = true
|
|
182
|
+
continue
|
|
183
|
+
}
|
|
184
|
+
if (field.kind === 'scalar') {
|
|
185
|
+
select[name] = true
|
|
186
|
+
continue
|
|
187
|
+
}
|
|
188
|
+
const target = registry.entry(field.target)
|
|
189
|
+
const targetObject = objectType(registry, field.target)
|
|
190
|
+
if (field.kind === 'one') {
|
|
191
|
+
select[name] = {
|
|
192
|
+
select: selectOf(registry, info, targetObject, fieldNodes, master),
|
|
193
|
+
}
|
|
194
|
+
continue
|
|
195
|
+
}
|
|
196
|
+
const args = argsOf(type, name, fieldNodes, info)
|
|
197
|
+
const { read } = listRead(registry, target, args, master)
|
|
198
|
+
const list = registry.settings.pagination === 'list'
|
|
199
|
+
const below = list ? fieldNodes : nodesOf(info, fieldNodes)
|
|
200
|
+
select[name] = {
|
|
201
|
+
...read,
|
|
202
|
+
select: selectOf(registry, info, targetObject, below, master),
|
|
203
|
+
}
|
|
204
|
+
if (!list && grouped(info, fieldNodes).has('totalCount')) {
|
|
205
|
+
counts[name] =
|
|
206
|
+
read['where'] === undefined ? true : { where: read['where'] }
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
if (Object.keys(counts).length > 0) select['_count'] = { select: counts }
|
|
210
|
+
return select
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** `context.select(info)`, for a field that returns a model or a list of one. */
|
|
214
|
+
export function selectFor(
|
|
215
|
+
registry: Registry,
|
|
216
|
+
master: boolean,
|
|
217
|
+
): (info: GraphQLResolveInfo) => Args & { readonly id: true } {
|
|
218
|
+
return (info) => {
|
|
219
|
+
const type = getNamedType(info.returnType)
|
|
220
|
+
if (!isObjectType(type) || !registry.entries.has(type.name)) {
|
|
221
|
+
throw new Error(
|
|
222
|
+
`context.select(info) reads a model's rows; ${info.parentType.name}.${info.fieldName} returns ${type.name}.`,
|
|
223
|
+
)
|
|
224
|
+
}
|
|
225
|
+
return {
|
|
226
|
+
...selectOf(registry, info, type, info.fieldNodes, master),
|
|
227
|
+
id: true,
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
package/src/sockets.ts
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
// GraphQL over WebSocket (D-154): the `graphql-ws` protocol on the GraphQL
|
|
2
|
+
// path, each connection one viewer of its caller, its subscriptions live
|
|
3
|
+
// reads and its queries and mutations run as over HTTP.
|
|
4
|
+
import type { HttpApi, Upgrade } from '@ultimabase/http'
|
|
5
|
+
import type { RealtimeApi, Viewer } from '@ultimabase/realtime'
|
|
6
|
+
import type {
|
|
7
|
+
DocumentNode,
|
|
8
|
+
ExecutionArgs,
|
|
9
|
+
ExecutionResult,
|
|
10
|
+
GraphQLSchema,
|
|
11
|
+
Source,
|
|
12
|
+
ValidationRule,
|
|
13
|
+
} from 'graphql'
|
|
14
|
+
import {
|
|
15
|
+
createSourceEventStream,
|
|
16
|
+
executeSubscriptionEvent,
|
|
17
|
+
GraphQLError,
|
|
18
|
+
mapSourceToResponseEvent,
|
|
19
|
+
validateSubscriptionArgs,
|
|
20
|
+
} from 'graphql'
|
|
21
|
+
import { CloseCode, handleProtocols, makeServer } from 'graphql-ws'
|
|
22
|
+
import type { RawData, WebSocket } from 'ws'
|
|
23
|
+
import { WebSocketServer } from 'ws'
|
|
24
|
+
import type { Caller, Client, Logger } from 'ultimabase'
|
|
25
|
+
import { PublicError } from 'ultimabase'
|
|
26
|
+
import type { formatter } from './errors.ts'
|
|
27
|
+
import type { Channel } from './live-stream.ts'
|
|
28
|
+
import { LiveEvent, lives } from './live-stream.ts'
|
|
29
|
+
import type { Context } from './registry.ts'
|
|
30
|
+
|
|
31
|
+
/** How the GraphQL plugin runs a document, over HTTP as over WebSocket. */
|
|
32
|
+
export type Operations = {
|
|
33
|
+
readonly schema: GraphQLSchema
|
|
34
|
+
readonly parse: (source: string | Source) => DocumentNode
|
|
35
|
+
readonly rules: (master: boolean) => readonly ValidationRule[]
|
|
36
|
+
readonly validate: (
|
|
37
|
+
document: DocumentNode,
|
|
38
|
+
rules?: readonly ValidationRule[],
|
|
39
|
+
) => readonly GraphQLError[]
|
|
40
|
+
readonly execute: (args: ExecutionArgs) => Promise<ExecutionResult>
|
|
41
|
+
readonly context: (caller: Caller, client: Client) => Context
|
|
42
|
+
readonly hideSuggestions: boolean
|
|
43
|
+
readonly format: ReturnType<typeof formatter>
|
|
44
|
+
readonly logger: Logger
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** A connection's socket, and its viewer once `connection_init` came. */
|
|
48
|
+
type Extra = {
|
|
49
|
+
readonly upgrade: Upgrade
|
|
50
|
+
readonly socket: WebSocket
|
|
51
|
+
viewer?: Viewer
|
|
52
|
+
channel?: Channel
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** A client's buffer past which its changes are dropped, and the most it sends us. */
|
|
56
|
+
const maxBuffer = 1024 * 1024
|
|
57
|
+
/** Between pings; a socket that has not answered the last one is cut. */
|
|
58
|
+
const keepAlive = 12_000
|
|
59
|
+
|
|
60
|
+
const unset = () => {
|
|
61
|
+
throw new Error('A cookie is set over HTTP: a WebSocket cannot set one.')
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The upgrade request's cookies, which a WebSocket cannot set. */
|
|
65
|
+
function cookiesOf(request: Request): Client['cookies'] {
|
|
66
|
+
const cookies = new Map<string, string>()
|
|
67
|
+
for (const part of (request.headers.get('cookie') ?? '').split(';')) {
|
|
68
|
+
const at = part.indexOf('=')
|
|
69
|
+
if (at <= 0) continue
|
|
70
|
+
const name = part.slice(0, at).trim()
|
|
71
|
+
if (cookies.has(name)) continue
|
|
72
|
+
try {
|
|
73
|
+
cookies.set(name, decodeURIComponent(part.slice(at + 1).trim()))
|
|
74
|
+
} catch {
|
|
75
|
+
// A value that is no URI component is no cookie this API wrote.
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return { get: (name) => cookies.get(name), set: unset, clear: unset }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// How each socket sends, counted by its channel once it has one.
|
|
82
|
+
const sends = new WeakMap<WebSocket, (data: string) => void>()
|
|
83
|
+
|
|
84
|
+
/** The socket's channel: what it holds unsent, and when that is written. */
|
|
85
|
+
function channelOf(viewer: Viewer, socket: WebSocket): Channel {
|
|
86
|
+
let pending = 0
|
|
87
|
+
const waiters: (() => void)[] = []
|
|
88
|
+
const written = () => {
|
|
89
|
+
pending -= 1
|
|
90
|
+
if (pending > 0) return
|
|
91
|
+
for (const then of waiters.splice(0)) then()
|
|
92
|
+
}
|
|
93
|
+
sends.set(socket, (data) => {
|
|
94
|
+
pending += 1
|
|
95
|
+
socket.send(data, written)
|
|
96
|
+
})
|
|
97
|
+
return {
|
|
98
|
+
viewer,
|
|
99
|
+
full: () => socket.bufferedAmount > maxBuffer,
|
|
100
|
+
drained: (then) => {
|
|
101
|
+
if (pending === 0) then()
|
|
102
|
+
else waiters.push(then)
|
|
103
|
+
},
|
|
104
|
+
forbid: () => socket.close(CloseCode.Forbidden, 'Forbidden'),
|
|
105
|
+
kept: new Map(),
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const text = (data: RawData): string =>
|
|
110
|
+
Buffer.concat([data].flat().map((part) => new Uint8Array(part))).toString()
|
|
111
|
+
|
|
112
|
+
const isLive = (
|
|
113
|
+
value: unknown,
|
|
114
|
+
): value is { readonly mode?: unknown; readonly resume?: unknown } =>
|
|
115
|
+
typeof value === 'object' && value !== null
|
|
116
|
+
|
|
117
|
+
/** The authorization `connection_init` gives, over the upgrade's own. */
|
|
118
|
+
function requestOf(upgrade: Upgrade, params: unknown): Request {
|
|
119
|
+
const headers = new Headers(upgrade.request.headers)
|
|
120
|
+
const authorization: unknown =
|
|
121
|
+
typeof params === 'object' && params !== null
|
|
122
|
+
? Reflect.get(params, 'authorization')
|
|
123
|
+
: undefined
|
|
124
|
+
if (typeof authorization === 'string') {
|
|
125
|
+
headers.set('authorization', authorization)
|
|
126
|
+
}
|
|
127
|
+
return new Request(upgrade.request.url, { headers })
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Serves `path`'s WebSocket upgrades with `operations`, each connection read
|
|
132
|
+
* by `realtime`; returns what closes them.
|
|
133
|
+
*/
|
|
134
|
+
export function serveSockets(
|
|
135
|
+
http: HttpApi,
|
|
136
|
+
path: string,
|
|
137
|
+
realtime: RealtimeApi,
|
|
138
|
+
operations: Operations,
|
|
139
|
+
): () => Promise<void> {
|
|
140
|
+
const { schema, logger, format, hideSuggestions } = operations
|
|
141
|
+
const formatted = (errors: readonly GraphQLError[]) =>
|
|
142
|
+
errors.map((error) => {
|
|
143
|
+
const shown = format(error)
|
|
144
|
+
return shown instanceof GraphQLError
|
|
145
|
+
? shown.toJSON()
|
|
146
|
+
: { message: shown.message }
|
|
147
|
+
})
|
|
148
|
+
const server = makeServer<Record<string, unknown> | undefined, Extra>({
|
|
149
|
+
schema,
|
|
150
|
+
onConnect: async (ctx) => {
|
|
151
|
+
const { upgrade, socket } = ctx.extra
|
|
152
|
+
try {
|
|
153
|
+
const request = requestOf(upgrade, ctx.connectionParams)
|
|
154
|
+
const viewer = await realtime.connect(request)
|
|
155
|
+
if (socket.readyState !== socket.OPEN) {
|
|
156
|
+
viewer.close()
|
|
157
|
+
return false
|
|
158
|
+
}
|
|
159
|
+
ctx.extra.viewer = viewer
|
|
160
|
+
ctx.extra.channel = channelOf(viewer, socket)
|
|
161
|
+
return true
|
|
162
|
+
} catch (error) {
|
|
163
|
+
if (!(error instanceof PublicError)) {
|
|
164
|
+
logger.error({ err: error }, 'A WebSocket caller was not read.')
|
|
165
|
+
}
|
|
166
|
+
return false
|
|
167
|
+
}
|
|
168
|
+
},
|
|
169
|
+
onSubscribe: (ctx, _, payload) => {
|
|
170
|
+
const { viewer, channel, upgrade, socket } = ctx.extra
|
|
171
|
+
if (viewer === undefined || channel === undefined || viewer.closed) {
|
|
172
|
+
socket.close(CloseCode.Forbidden, 'Forbidden')
|
|
173
|
+
return [new GraphQLError('Forbidden')]
|
|
174
|
+
}
|
|
175
|
+
let document: DocumentNode
|
|
176
|
+
try {
|
|
177
|
+
document = operations.parse(payload.query)
|
|
178
|
+
} catch (error) {
|
|
179
|
+
if (error instanceof GraphQLError) return [error]
|
|
180
|
+
throw error
|
|
181
|
+
}
|
|
182
|
+
const { caller } = viewer
|
|
183
|
+
const master = caller.kind === 'master'
|
|
184
|
+
const refused = operations.validate(document, operations.rules(master))
|
|
185
|
+
if (refused.length > 0) return refused
|
|
186
|
+
const client = {
|
|
187
|
+
address: upgrade.address,
|
|
188
|
+
cookies: cookiesOf(upgrade.request),
|
|
189
|
+
}
|
|
190
|
+
const contextValue = operations.context(caller, client)
|
|
191
|
+
const live: unknown = payload.extensions?.['live']
|
|
192
|
+
const page = isLive(live) && live.mode === 'page'
|
|
193
|
+
const resume = isLive(live) ? live.resume : undefined
|
|
194
|
+
lives.set(contextValue, {
|
|
195
|
+
channel,
|
|
196
|
+
page,
|
|
197
|
+
...(typeof resume === 'string' && { resume }),
|
|
198
|
+
})
|
|
199
|
+
return {
|
|
200
|
+
schema,
|
|
201
|
+
document,
|
|
202
|
+
operationName: payload.operationName,
|
|
203
|
+
variableValues: payload.variables,
|
|
204
|
+
contextValue,
|
|
205
|
+
}
|
|
206
|
+
},
|
|
207
|
+
execute: async (args) => operations.execute(args),
|
|
208
|
+
subscribe: async (args) => {
|
|
209
|
+
const validated = validateSubscriptionArgs({ ...args, hideSuggestions })
|
|
210
|
+
if (!('schema' in validated)) return { errors: validated }
|
|
211
|
+
const source = await createSourceEventStream(validated)
|
|
212
|
+
if (!(Symbol.asyncIterator in source)) return source
|
|
213
|
+
return mapSourceToResponseEvent(validated, source, async (event) => {
|
|
214
|
+
const result = await executeSubscriptionEvent(event)
|
|
215
|
+
const { rootValue } = event
|
|
216
|
+
return rootValue instanceof LiveEvent
|
|
217
|
+
? rootValue.finish(result)
|
|
218
|
+
: result
|
|
219
|
+
})
|
|
220
|
+
},
|
|
221
|
+
onNext: (_ctx, _id, _payload, _args, { data, errors, extensions }) => ({
|
|
222
|
+
...(data === undefined ? {} : { data }),
|
|
223
|
+
...(errors === undefined ? {} : { errors: formatted(errors) }),
|
|
224
|
+
...(extensions === undefined ? {} : { extensions }),
|
|
225
|
+
}),
|
|
226
|
+
onError: (_ctx, _id, _payload, errors) => formatted(errors),
|
|
227
|
+
})
|
|
228
|
+
const sockets = new WebSocketServer({
|
|
229
|
+
noServer: true,
|
|
230
|
+
maxPayload: maxBuffer,
|
|
231
|
+
perMessageDeflate: { threshold: 1024 },
|
|
232
|
+
handleProtocols,
|
|
233
|
+
})
|
|
234
|
+
const beats = new WeakMap<WebSocket, () => void>()
|
|
235
|
+
const opened = (socket: WebSocket, upgrade: Upgrade) => {
|
|
236
|
+
const extra: Extra = { upgrade, socket }
|
|
237
|
+
let alive = true
|
|
238
|
+
socket.on('pong', () => {
|
|
239
|
+
alive = true
|
|
240
|
+
})
|
|
241
|
+
beats.set(socket, () => {
|
|
242
|
+
if (!alive) return socket.terminate()
|
|
243
|
+
alive = false
|
|
244
|
+
socket.ping()
|
|
245
|
+
})
|
|
246
|
+
const closed = server.opened(
|
|
247
|
+
{
|
|
248
|
+
protocol: socket.protocol,
|
|
249
|
+
send: async (data) => {
|
|
250
|
+
if (socket.readyState !== socket.OPEN) return
|
|
251
|
+
const send = sends.get(socket)
|
|
252
|
+
if (send === undefined) socket.send(data)
|
|
253
|
+
else send(data)
|
|
254
|
+
},
|
|
255
|
+
close: (code, reason) => socket.close(code, reason),
|
|
256
|
+
onMessage: (received) => {
|
|
257
|
+
socket.on('message', (data: RawData) => {
|
|
258
|
+
received(text(data)).catch((error: unknown) => {
|
|
259
|
+
logger.error({ err: error }, 'A WebSocket message failed.')
|
|
260
|
+
socket.close(CloseCode.InternalServerError, 'Internal error')
|
|
261
|
+
})
|
|
262
|
+
})
|
|
263
|
+
},
|
|
264
|
+
},
|
|
265
|
+
extra,
|
|
266
|
+
)
|
|
267
|
+
socket.once('close', (code, reason) => {
|
|
268
|
+
void closed(code, String(reason))
|
|
269
|
+
extra.viewer?.close()
|
|
270
|
+
})
|
|
271
|
+
}
|
|
272
|
+
const beating = setInterval(() => {
|
|
273
|
+
for (const socket of sockets.clients) beats.get(socket)?.()
|
|
274
|
+
}, keepAlive)
|
|
275
|
+
beating.unref()
|
|
276
|
+
http.upgrade(path, (upgrade) => {
|
|
277
|
+
sockets.handleUpgrade(
|
|
278
|
+
upgrade.incoming,
|
|
279
|
+
upgrade.socket,
|
|
280
|
+
upgrade.head,
|
|
281
|
+
(socket) => opened(socket, upgrade),
|
|
282
|
+
)
|
|
283
|
+
})
|
|
284
|
+
return async () => {
|
|
285
|
+
clearInterval(beating)
|
|
286
|
+
for (const socket of sockets.clients) socket.close(1001, 'Going away')
|
|
287
|
+
await new Promise<void>((done) => {
|
|
288
|
+
sockets.close(() => done())
|
|
289
|
+
})
|
|
290
|
+
}
|
|
291
|
+
}
|