@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,280 @@
1
+ // A model's part of the document (D-144): its row, as GraphQL's type, its
2
+ // pages, its `select` and `include`, and its six routes.
3
+ import { isNode, jsonSchemaOf, readable, readableVirtuals, } from '@ultimabase/http/internal';
4
+ import { entryOf, system } from './entries.js';
5
+ import { dataOf, orderByOf, uniqueOf, valueOf, whereOf, } from './openapi-inputs.js';
6
+ import { outputOf } from './openapi-functions.js';
7
+ import { arrayOf, describe, described, failed, hoisted, json, objectOf, orNull, } from './openapi.js';
8
+ const upper = (name) => name.charAt(0).toUpperCase() + name.slice(1);
9
+ const text = { type: 'string' };
10
+ const flag = { type: 'boolean' };
11
+ /** `PageInfo`, `PostEdge` and a connection of its edges. */
12
+ function connectionOf(doc, name, target) {
13
+ const { components } = doc;
14
+ return components.ref(name, () => {
15
+ const pageInfo = components.ref('PageInfo', () => objectOf({
16
+ hasNextPage: flag,
17
+ hasPreviousPage: flag,
18
+ startCursor: orNull(text),
19
+ endCursor: orNull(text),
20
+ }, ['hasNextPage', 'hasPreviousPage', 'startCursor', 'endCursor']));
21
+ const edge = components.ref(`${target}Edge`, () => objectOf({ cursor: text, node: rowOf(doc, target) }, ['cursor', 'node']));
22
+ return objectOf({
23
+ edges: arrayOf(edge),
24
+ pageInfo,
25
+ totalCount: { type: 'integer', minimum: 0 },
26
+ }, ['edges', 'pageInfo']);
27
+ });
28
+ }
29
+ /**
30
+ * `Post`: its default fields, which a row always holds without `select`,
31
+ * and its relations, which `include` adds. A field with a read rule may be
32
+ * `null`, a `one` relation always may (D-44).
33
+ */
34
+ export function rowOf(doc, model) {
35
+ return doc.components.ref(model, () => {
36
+ const entry = entryOf(doc.api, model);
37
+ const date = { type: 'string', format: 'date-time' };
38
+ const fields = {
39
+ id: text,
40
+ createdAt: date,
41
+ updatedAt: date,
42
+ };
43
+ const required = [...system];
44
+ for (const field of readable(entry)) {
45
+ const ruled = entry.exposed.readRules[field.name] === 'rule';
46
+ if (field.kind === 'scalar') {
47
+ const value = valueOf(doc, entry, field, 'output');
48
+ const empty = ruled || (field.optional && !field.list);
49
+ fields[field.name] = empty ? orNull(value) : value;
50
+ required.push(field.name);
51
+ }
52
+ else if (field.kind === 'one') {
53
+ fields[field.name] = orNull(rowOf(doc, field.target));
54
+ }
55
+ else {
56
+ const many = doc.api.pagination === 'connection'
57
+ ? connectionOf(doc, `${model}${upper(field.name)}Connection`, field.target)
58
+ : arrayOf(rowOf(doc, field.target));
59
+ fields[field.name] = ruled ? orNull(many) : many;
60
+ }
61
+ }
62
+ // A virtual, computed: in the default row without an input (D-151).
63
+ for (const virtual of readableVirtuals(entry)) {
64
+ const { name } = virtual;
65
+ const owner = `${model}.${name}`;
66
+ const value = {
67
+ ...outputOf(doc, virtual.output, owner, `${model}${upper(name)}Output`),
68
+ readOnly: true,
69
+ };
70
+ const ruled = entry.exposed.readRules[name] === 'rule';
71
+ fields[name] = describe(ruled ? orNull(value) : value, virtual.description);
72
+ if (virtual.default)
73
+ required.push(name);
74
+ }
75
+ const { description, descriptions } = entry.exposed;
76
+ return describe(objectOf(described(fields, descriptions), required), description);
77
+ });
78
+ }
79
+ /** A list's page arguments, within the model's `maxTake`. */
80
+ function pageOf(doc, entry) {
81
+ const rows = { type: 'integer', minimum: 0, maximum: entry.maxTake };
82
+ return doc.api.pagination === 'list'
83
+ ? { take: rows, skip: { type: 'integer', minimum: 0 } }
84
+ : { first: rows, after: text, last: rows, before: text };
85
+ }
86
+ /** `PostSelect`, `PostInclude`, a relation's arguments (D-129). */
87
+ function fieldsOf(doc, model, select) {
88
+ const name = `${model}${select ? 'Select' : 'Include'}`;
89
+ return doc.components.ref(name, () => {
90
+ const entry = entryOf(doc.api, model);
91
+ const fields = {};
92
+ if (select)
93
+ for (const key of system)
94
+ fields[key] = flag;
95
+ for (const field of readable(entry)) {
96
+ if (field.kind === 'scalar') {
97
+ if (select)
98
+ fields[field.name] = flag;
99
+ continue;
100
+ }
101
+ const args = field.kind === 'one'
102
+ ? argsOf(doc, field.target)
103
+ : listArgsOf(doc, field.target);
104
+ fields[field.name] = { anyOf: [flag, args] };
105
+ }
106
+ for (const virtual of readableVirtuals(entry)) {
107
+ fields[virtual.name] = virtualArgsOf(doc, model, virtual);
108
+ }
109
+ return objectOf(fields);
110
+ });
111
+ }
112
+ const either = { not: { required: ['select', 'include'] } };
113
+ /**
114
+ * A virtual in `select` and `include` (D-151): `true` unless its input
115
+ * needs a property, its input, and a model's `select` or `include`.
116
+ */
117
+ function virtualArgsOf(doc, model, virtual) {
118
+ const { name, input, output } = virtual;
119
+ const owner = `${model}.${name}`;
120
+ const base = `${model}${upper(name)}`;
121
+ const node = input === undefined
122
+ ? undefined
123
+ : hoisted(doc.components, jsonSchemaOf(input, 'input', `${owner}: input`), 'input', owner, `${base}Input`);
124
+ const listed = node?.['required'];
125
+ const required = Array.isArray(listed)
126
+ ? listed.filter((key) => typeof key === 'string')
127
+ : [];
128
+ const needed = required.length > 0;
129
+ const or = (schema) => (needed ? schema : { anyOf: [flag, schema] });
130
+ if (output.kind !== 'model')
131
+ return node === undefined ? flag : or(node);
132
+ const properties = node?.['properties'];
133
+ return or(doc.components.ref(`${base}Args`, () => ({
134
+ ...objectOf({
135
+ ...(isNode(properties) ? properties : {}),
136
+ select: fieldsOf(doc, output.name, true),
137
+ include: fieldsOf(doc, output.name, false),
138
+ }, required),
139
+ ...either,
140
+ })));
141
+ }
142
+ /** `PostArgs`: a `one` relation's `select` or `include`. */
143
+ function argsOf(doc, model) {
144
+ return doc.components.ref(`${model}Args`, () => ({
145
+ ...objectOf({
146
+ select: fieldsOf(doc, model, true),
147
+ include: fieldsOf(doc, model, false),
148
+ }),
149
+ ...either,
150
+ }));
151
+ }
152
+ /** `PostListArgs`: a `many` relation's, its page and filter too. */
153
+ function listArgsOf(doc, model) {
154
+ return doc.components.ref(`${model}ListArgs`, () => {
155
+ const entry = entryOf(doc.api, model);
156
+ const counts = doc.api.pagination === 'connection';
157
+ return {
158
+ ...objectOf({
159
+ select: fieldsOf(doc, model, true),
160
+ include: fieldsOf(doc, model, false),
161
+ where: whereOf(doc, model),
162
+ orderBy: ordering(doc, model),
163
+ ...pageOf(doc, entry),
164
+ ...(counts && { totalCount: flag }),
165
+ }),
166
+ ...either,
167
+ };
168
+ });
169
+ }
170
+ const ordering = (doc, model) => {
171
+ const order = orderByOf(doc, model);
172
+ return { anyOf: [order, arrayOf(order)] };
173
+ };
174
+ /** A query parameter: JSON, or a string or a number as is. */
175
+ const param = (name, schema, description) => {
176
+ const plain = schema['type'] === 'string' || schema['type'] === 'integer';
177
+ return {
178
+ name,
179
+ in: 'query',
180
+ ...(description !== undefined && { description }),
181
+ ...(plain || schema === flag ? { schema } : { content: json(schema) }),
182
+ };
183
+ };
184
+ function fieldParams(doc, model) {
185
+ return [
186
+ param('select', fieldsOf(doc, model, true), 'The fields to return, and only those; not with include.'),
187
+ param('include', fieldsOf(doc, model, false), 'The relations to add to the default fields.'),
188
+ ];
189
+ }
190
+ const answer = (description, schema) => ({
191
+ description,
192
+ content: json(schema),
193
+ });
194
+ const body = (schema) => ({
195
+ required: true,
196
+ content: json(schema),
197
+ });
198
+ /** `/posts` and `/posts/{id}`, as REST serves them (D-129, D-142). */
199
+ export function modelPaths(doc, entry) {
200
+ const { components } = doc;
201
+ const model = entry.model.name;
202
+ const own = (id) => components.operation(id, model, 'plural');
203
+ const tags = [model];
204
+ const row = rowOf(doc, model);
205
+ const counts = doc.api.pagination === 'connection';
206
+ const list = counts
207
+ ? connectionOf(doc, `${model}Connection`, model)
208
+ : arrayOf(row);
209
+ const pages = Object.entries(pageOf(doc, entry)).map(([name, schema]) => param(name, schema));
210
+ const one = model.charAt(0).toLowerCase() + model.slice(1);
211
+ const id = {
212
+ name: 'id',
213
+ in: 'path',
214
+ required: true,
215
+ schema: text,
216
+ };
217
+ return {
218
+ [`/${entry.plural}`]: {
219
+ get: {
220
+ operationId: own(entry.plural),
221
+ tags,
222
+ parameters: [
223
+ param('where', whereOf(doc, model)),
224
+ param('orderBy', ordering(doc, model)),
225
+ ...fieldParams(doc, model),
226
+ ...pages,
227
+ ...(counts ? [param('totalCount', flag)] : []),
228
+ ],
229
+ responses: { 200: answer(`The ${model} rows.`, list), ...failed },
230
+ },
231
+ post: {
232
+ operationId: own(`create${model}`),
233
+ tags,
234
+ parameters: fieldParams(doc, model),
235
+ requestBody: body(dataOf(doc, model, true)),
236
+ responses: { 201: answer(`The ${model} created.`, row), ...failed },
237
+ },
238
+ put: {
239
+ operationId: own(`upsert${model}`),
240
+ tags,
241
+ description: `Updates the ${model} where matches, or creates it.`,
242
+ parameters: fieldParams(doc, model),
243
+ requestBody: body(objectOf({
244
+ where: uniqueOf(doc, model),
245
+ create: dataOf(doc, model, true),
246
+ update: dataOf(doc, model, false),
247
+ }, ['where', 'create', 'update'])),
248
+ responses: {
249
+ 200: answer(`The ${model} updated.`, row),
250
+ 201: answer(`The ${model} created.`, row),
251
+ ...failed,
252
+ },
253
+ },
254
+ },
255
+ [`/${entry.plural}/{id}`]: {
256
+ parameters: [id],
257
+ get: {
258
+ operationId: own(one),
259
+ tags,
260
+ parameters: fieldParams(doc, model),
261
+ responses: { 200: answer(`The ${model}.`, row), ...failed },
262
+ },
263
+ patch: {
264
+ operationId: own(`update${model}`),
265
+ tags,
266
+ parameters: fieldParams(doc, model),
267
+ requestBody: body(dataOf(doc, model, false)),
268
+ responses: { 200: answer(`The ${model} updated.`, row), ...failed },
269
+ },
270
+ delete: {
271
+ operationId: own(`delete${model}`),
272
+ tags,
273
+ responses: {
274
+ 200: answer(`The ${model} as it was, without relations.`, row),
275
+ ...failed,
276
+ },
277
+ },
278
+ },
279
+ };
280
+ }
@@ -0,0 +1,47 @@
1
+ import type { Node } from '@ultimabase/http/internal';
2
+ import type { Api } from './entries.ts';
3
+ import type { Routed } from './functions.ts';
4
+ export type Schema = Node;
5
+ export declare const refTo: (name: string) => Schema;
6
+ /** `schema` or `null`; any value already takes `null`. */
7
+ export declare const orNull: (schema: Schema) => Schema;
8
+ export declare const arrayOf: (items: Schema) => Schema;
9
+ /** `schema` with its `description`, when it has one (D-150). */
10
+ export declare const describe: (schema: Schema, description: string | undefined) => Schema;
11
+ /** Each property with its field's `description` (D-150). */
12
+ export declare const described: (properties: {
13
+ readonly [key: string]: Schema;
14
+ }, descriptions: {
15
+ readonly [field: string]: string;
16
+ }) => {
17
+ readonly [key: string]: Schema;
18
+ };
19
+ /** An object of these properties, and no other. */
20
+ export declare const objectOf: (properties: {
21
+ readonly [key: string]: Schema;
22
+ }, required?: readonly string[]) => Schema;
23
+ /** A JSON value under `name`, as a parameter or a body holds it. */
24
+ export declare const json: (schema: Schema) => Schema;
25
+ /** The answer of each failed request (D-106). */
26
+ export declare const failed: Schema;
27
+ /** The document's named schemas, each name of one shape. */
28
+ export declare class Components {
29
+ #private;
30
+ readonly schemas: {
31
+ [name: string]: Schema;
32
+ };
33
+ /** The schema REST names `name`, built on its first use. */
34
+ ref(name: string, build: () => Schema): Schema;
35
+ /** A schema a JSON Schema names, its id `name`. */
36
+ shared(name: string, schema: Schema, owner: string): Schema;
37
+ /** An operation's id, which no other operation has. */
38
+ operation(id: string, owner: string, setting: 'plural' | 'name'): string;
39
+ }
40
+ /**
41
+ * A JSON Schema with its `$defs` among the components, named by their ids,
42
+ * `Input`-suffixed on the input side as in GraphQL; `#` is `root`'s own.
43
+ */
44
+ export declare function hoisted(components: Components, node: Node, side: 'input' | 'output', owner: string, root: string): Schema;
45
+ /** The document `GET {path}/openapi.json` answers. */
46
+ export declare function openapiOf(api: Api, functions: readonly Routed[], base: string, version: string): Schema;
47
+ //# sourceMappingURL=openapi.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"openapi.d.ts","sourceRoot":"","sources":["../src/openapi.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,2BAA2B,CAAA;AAGrD,OAAO,KAAK,EAAE,GAAG,EAAE,MAAM,cAAc,CAAA;AACvC,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,gBAAgB,CAAA;AAI5C,MAAM,MAAM,MAAM,GAAG,IAAI,CAAA;AAEzB,eAAO,MAAM,KAAK,SAAU,MAAM,KAAG,MAEnC,CAAA;AAEF,0DAA0D;AAC1D,eAAO,MAAM,MAAM,WAAY,MAAM,KAAG,MAGC,CAAA;AAEzC,eAAO,MAAM,OAAO,UAAW,MAAM,KAAG,MAAoC,CAAA;AAE5E,gEAAgE;AAChE,eAAO,MAAM,QAAQ,WACZ,MAAM,eACD,MAAM,GAAG,SAAS,KAC7B,MAA2E,CAAA;AAE9E,4DAA4D;AAC5D,eAAO,MAAM,SAAS,eACT;IAAE,QAAQ,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,CAAA;CAAE,gBAChC;IAAE,QAAQ,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAAA;CAAE,KAChD;IAAE,QAAQ,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,CAAA;CAMjC,CAAA;AAEF,mDAAmD;AACnD,eAAO,MAAM,QAAQ,eACR;IAAE,QAAQ,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,CAAA;CAAE,aACpC,SAAS,MAAM,EAAE,KACzB,MAKD,CAAA;AAEF,oEAAoE;AACpE,eAAO,MAAM,IAAI,WAAY,MAAM,KAAG,MAEpC,CAAA;AAEF,iDAAiD;AACjD,eAAO,MAAM,MAAM,EAAE,MAKpB,CAAA;AAoBD,4DAA4D;AAC5D,qBAAa,UAAU;;IACtB,QAAQ,CAAC,OAAO,EAAE;QAAE,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAAA;KAAE,CAAK;IAIjD,4DAA4D;IAC5D,GAAG,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,MAAM,GAAG,MAAM,CAW7C;IAED,mDAAmD;IACnD,MAAM,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAoB1D;IAED,uDAAuD;IACvD,SAAS,CAAC,EAAE,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,QAAQ,GAAG,MAAM,GAAG,MAAM,CASvE;CACD;AAED;;;GAGG;AACH,wBAAgB,OAAO,CACtB,UAAU,EAAE,UAAU,EACtB,IAAI,EAAE,IAAI,EACV,IAAI,EAAE,OAAO,GAAG,QAAQ,EACxB,KAAK,EAAE,MAAM,EACb,IAAI,EAAE,MAAM,GACV,MAAM,CA4BR;AAED,sDAAsD;AACtD,wBAAgB,SAAS,CACxB,GAAG,EAAE,GAAG,EACR,SAAS,EAAE,SAAS,MAAM,EAAE,EAC5B,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE,MAAM,GACb,MAAM,CAgBR"}
@@ -0,0 +1,150 @@
1
+ import { isNode } from '@ultimabase/http/internal';
2
+ import { Refused } from 'ultimabase';
3
+ import { functionPaths } from './openapi-functions.js';
4
+ import { modelPaths } from './openapi-models.js';
5
+ export const refTo = (name) => ({
6
+ $ref: `#/components/schemas/${name}`,
7
+ });
8
+ /** `schema` or `null`; any value already takes `null`. */
9
+ export const orNull = (schema) => Object.keys(schema).length === 0
10
+ ? schema
11
+ : { anyOf: [schema, { type: 'null' }] };
12
+ export const arrayOf = (items) => ({ type: 'array', items });
13
+ /** `schema` with its `description`, when it has one (D-150). */
14
+ export const describe = (schema, description) => (description === undefined ? schema : { ...schema, description });
15
+ /** Each property with its field's `description` (D-150). */
16
+ export const described = (properties, descriptions) => Object.fromEntries(Object.entries(properties).map(([key, schema]) => [
17
+ key,
18
+ describe(schema, descriptions[key]),
19
+ ]));
20
+ /** An object of these properties, and no other. */
21
+ export const objectOf = (properties, required = []) => ({
22
+ type: 'object',
23
+ properties,
24
+ ...(required.length > 0 && { required }),
25
+ additionalProperties: false,
26
+ });
27
+ /** A JSON value under `name`, as a parameter or a body holds it. */
28
+ export const json = (schema) => ({
29
+ 'application/json': { schema },
30
+ });
31
+ /** The answer of each failed request (D-106). */
32
+ export const failed = {
33
+ default: {
34
+ description: 'The request failed.',
35
+ content: json(refTo('Error')),
36
+ },
37
+ };
38
+ const errorSchema = {
39
+ type: 'object',
40
+ properties: {
41
+ code: { type: 'string' },
42
+ message: { type: 'string' },
43
+ details: {},
44
+ correlationId: { type: 'string' },
45
+ },
46
+ required: ['code', 'message'],
47
+ };
48
+ const rest = 'REST';
49
+ const clash = (owner, name) => new Refused(`${owner}: REST names a schema ${name}; give yours another name.`);
50
+ /** The document's named schemas, each name of one shape. */
51
+ export class Components {
52
+ schemas = {};
53
+ #known = new Map();
54
+ #operations = new Map();
55
+ /** The schema REST names `name`, built on its first use. */
56
+ ref(name, build) {
57
+ const known = this.#known.get(name);
58
+ if (known !== undefined && known.owner !== rest)
59
+ throw clash(known.owner, name);
60
+ if (known === undefined) {
61
+ this.#known.set(name, { owner: rest, key: undefined });
62
+ // Set first: a schema may refer to itself.
63
+ this.schemas[name] = {};
64
+ this.schemas[name] = build();
65
+ }
66
+ return refTo(name);
67
+ }
68
+ /** A schema a JSON Schema names, its id `name`. */
69
+ shared(name, schema, owner) {
70
+ if (!/^[\w.-]+$/.test(name)) {
71
+ throw new Refused(`${owner}: ${name} is not a schema name, which takes letters, digits, '.', '-' and '_'.`);
72
+ }
73
+ // One shape whatever it says of itself: its first description stays.
74
+ const { description: _, ...shape } = schema;
75
+ const key = JSON.stringify(shape);
76
+ const known = this.#known.get(name);
77
+ if (known?.owner === rest)
78
+ throw clash(owner, name);
79
+ if (known === undefined) {
80
+ this.#known.set(name, { owner, key });
81
+ this.schemas[name] = schema;
82
+ }
83
+ else if (known.key !== key) {
84
+ throw new Refused(`${known.owner} and ${owner} give the schema ${name} two shapes; name each once.`);
85
+ }
86
+ return refTo(name);
87
+ }
88
+ /** An operation's id, which no other operation has. */
89
+ operation(id, owner, setting) {
90
+ const other = this.#operations.get(id);
91
+ if (other !== undefined) {
92
+ throw new Refused(`${owner}: the operation ${id} is ${other}'s; set .with({ rest: { ${setting} } }).`);
93
+ }
94
+ this.#operations.set(id, owner);
95
+ return id;
96
+ }
97
+ }
98
+ /**
99
+ * A JSON Schema with its `$defs` among the components, named by their ids,
100
+ * `Input`-suffixed on the input side as in GraphQL; `#` is `root`'s own.
101
+ */
102
+ export function hoisted(components, node, side, owner, root) {
103
+ const suffix = side === 'input' ? 'Input' : '';
104
+ let recursive = false;
105
+ const rewrite = (value) => {
106
+ if (Array.isArray(value))
107
+ return value.map(rewrite);
108
+ if (!isNode(value))
109
+ return value;
110
+ const ref = value['$ref'];
111
+ if (ref === '#') {
112
+ recursive = true;
113
+ return { ...value, $ref: refTo(root)['$ref'] };
114
+ }
115
+ if (typeof ref === 'string' && ref.startsWith('#/$defs/')) {
116
+ return { ...value, $ref: refTo(`${ref.slice(8)}${suffix}`)['$ref'] };
117
+ }
118
+ return Object.fromEntries(Object.entries(value).map(([key, inner]) => [key, rewrite(inner)]));
119
+ };
120
+ const { $defs: defs, $schema: _, ...own } = node;
121
+ if (isNode(defs)) {
122
+ for (const [id, def] of Object.entries(defs)) {
123
+ const schema = rewrite(def);
124
+ if (isNode(schema))
125
+ components.shared(`${id}${suffix}`, schema, owner);
126
+ }
127
+ }
128
+ const schema = rewrite(own);
129
+ const result = isNode(schema) ? schema : {};
130
+ return recursive ? components.shared(root, result, owner) : result;
131
+ }
132
+ /** The document `GET {path}/openapi.json` answers. */
133
+ export function openapiOf(api, functions, base, version) {
134
+ const components = new Components();
135
+ components.ref('Error', () => errorSchema);
136
+ const doc = { components, api };
137
+ const paths = {};
138
+ for (const entry of api.entries.values()) {
139
+ if (entry.visible)
140
+ Object.assign(paths, modelPaths(doc, entry));
141
+ }
142
+ Object.assign(paths, functionPaths(doc, functions));
143
+ return {
144
+ openapi: '3.1.0',
145
+ info: { title: 'UltimaBase REST API', version },
146
+ servers: [{ url: base === '' ? '/' : base }],
147
+ paths,
148
+ components: { schemas: components.schemas },
149
+ };
150
+ }
@@ -0,0 +1,11 @@
1
+ import type { Args } from './entries.ts';
2
+ /** What a route reads from its query string. */
3
+ export type Accepted = {
4
+ /** Parameters written as JSON. */
5
+ readonly json: readonly string[];
6
+ /** Parameters written as is. */
7
+ readonly text: readonly string[];
8
+ };
9
+ /** The parameters of a request, each once and each one the route takes. */
10
+ export declare function paramsOf(url: string, accepted: Accepted): Args;
11
+ //# sourceMappingURL=params.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"params.d.ts","sourceRoot":"","sources":["../src/params.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,cAAc,CAAA;AAExC,gDAAgD;AAChD,MAAM,MAAM,QAAQ,GAAG;IACtB,kCAAkC;IAClC,QAAQ,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,CAAA;IAChC,gCAAgC;IAChC,QAAQ,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,CAAA;CAChC,CAAA;AAUD,2EAA2E;AAC3E,wBAAgB,QAAQ,CAAC,GAAG,EAAE,MAAM,EAAE,QAAQ,EAAE,QAAQ,GAAG,IAAI,CAgB9D"}
package/dist/params.js ADDED
@@ -0,0 +1,27 @@
1
+ // Query parameters (D-129): each the JSON of the GraphQL argument of its
2
+ // name, a string argument as is.
3
+ import { badInput } from '@ultimabase/http/internal';
4
+ function parsed(name, value) {
5
+ try {
6
+ return JSON.parse(value);
7
+ }
8
+ catch (error) {
9
+ throw badInput(`The query parameter ${name} is not JSON.`, error);
10
+ }
11
+ }
12
+ /** The parameters of a request, each once and each one the route takes. */
13
+ export function paramsOf(url, accepted) {
14
+ const params = {};
15
+ const taken = [...accepted.json, ...accepted.text];
16
+ for (const [name, value] of new URL(url).searchParams) {
17
+ if (Object.hasOwn(params, name)) {
18
+ throw badInput(`The query parameter ${name} is given twice.`);
19
+ }
20
+ if (!taken.includes(name)) {
21
+ const takes = taken.length === 0 ? 'none' : taken.join(', ');
22
+ throw badInput(`Unknown query parameter '${name}'; this route takes ${takes}.`);
23
+ }
24
+ params[name] = accepted.text.includes(name) ? value : parsed(name, value);
25
+ }
26
+ return params;
27
+ }
package/dist/sse.d.ts ADDED
@@ -0,0 +1,17 @@
1
+ /** An open stream of events, and the response that sends it. */
2
+ export type Events = {
3
+ readonly response: Response;
4
+ /** Writes one event; `id` is the revision it carries. */
5
+ readonly send: (event: string, data: unknown, id?: number) => void;
6
+ /** Whether the client's buffer holds more than it may. */
7
+ readonly full: () => boolean;
8
+ /** Runs `then` once the client took what it was sent. */
9
+ readonly drained: (then: () => void) => void;
10
+ /** Ends the stream. */
11
+ readonly end: () => void;
12
+ };
13
+ /** Whether `header` (an `Accept-Encoding`) takes gzip. */
14
+ export declare function acceptsGzip(header: string | null): boolean;
15
+ /** A stream of events to `request`'s client; `closed` runs once it ends. */
16
+ export declare function eventsTo(request: Request, closed: () => void): Events;
17
+ //# sourceMappingURL=sse.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sse.d.ts","sourceRoot":"","sources":["../src/sse.ts"],"names":[],"mappings":"AAYA,gEAAgE;AAChE,MAAM,MAAM,MAAM,GAAG;IACpB,QAAQ,CAAC,QAAQ,EAAE,QAAQ,CAAA;IAC3B,yDAAyD;IACzD,QAAQ,CAAC,IAAI,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,OAAO,EAAE,EAAE,CAAC,EAAE,MAAM,KAAK,IAAI,CAAA;IAClE,0DAA0D;IAC1D,QAAQ,CAAC,IAAI,EAAE,MAAM,OAAO,CAAA;IAC5B,yDAAyD;IACzD,QAAQ,CAAC,OAAO,EAAE,CAAC,IAAI,EAAE,MAAM,IAAI,KAAK,IAAI,CAAA;IAC5C,uBAAuB;IACvB,QAAQ,CAAC,GAAG,EAAE,MAAM,IAAI,CAAA;CACxB,CAAA;AAED,0DAA0D;AAC1D,wBAAgB,WAAW,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,GAAG,OAAO,CAQ1D;AAED,4EAA4E;AAC5E,wBAAgB,QAAQ,CAAC,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,IAAI,GAAG,MAAM,CAyCrE"}
package/dist/sse.js ADDED
@@ -0,0 +1,63 @@
1
+ import { PassThrough, Readable } from 'node:stream';
2
+ import { constants, createGzip } from 'node:zlib';
3
+ /** A client's buffer past which its changes are dropped. */
4
+ const maxBuffer = 1024 * 1024;
5
+ /** Between two comments on a quiet stream. */
6
+ const keepAlive = 12_000;
7
+ /** Whether `header` (an `Accept-Encoding`) takes gzip. */
8
+ export function acceptsGzip(header) {
9
+ for (const part of (header ?? '').split(',')) {
10
+ const [name = '', ...params] = part.split(';').map((each) => each.trim());
11
+ if (name.toLowerCase() !== 'gzip')
12
+ continue;
13
+ const q = params.find((param) => param.startsWith('q='));
14
+ return q === undefined || Number(q.slice(2)) > 0;
15
+ }
16
+ return false;
17
+ }
18
+ /** A stream of events to `request`'s client; `closed` runs once it ends. */
19
+ export function eventsTo(request, closed) {
20
+ const gzip = acceptsGzip(request.headers.get('accept-encoding'));
21
+ const zipped = gzip ? createGzip() : undefined;
22
+ const stream = zipped ?? new PassThrough();
23
+ const write = (text) => {
24
+ if (stream.writableEnded || stream.destroyed)
25
+ return;
26
+ stream.write(text);
27
+ zipped?.flush(constants.Z_SYNC_FLUSH);
28
+ };
29
+ const beating = setInterval(() => write(':\n\n'), keepAlive);
30
+ beating.unref();
31
+ stream.once('close', () => {
32
+ clearInterval(beating);
33
+ closed();
34
+ });
35
+ const end = () => {
36
+ clearInterval(beating);
37
+ stream.end();
38
+ };
39
+ request.signal.addEventListener('abort', () => stream.destroy());
40
+ const headers = {
41
+ 'content-type': 'text/event-stream; charset=utf-8',
42
+ 'cache-control': 'no-store',
43
+ // nginx buffers a response unless told not to.
44
+ 'x-accel-buffering': 'no',
45
+ ...(gzip ? { 'content-encoding': 'gzip', vary: 'Accept-Encoding' } : {}),
46
+ };
47
+ const body = Readable.toWeb(stream);
48
+ return {
49
+ response: new Response(body, { headers }),
50
+ send: (event, data, id) => {
51
+ const line = id === undefined ? '' : `id: ${id}\n`;
52
+ write(`${line}event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
53
+ },
54
+ full: () => stream.writableLength + stream.readableLength > maxBuffer,
55
+ drained: (then) => {
56
+ if (stream.writableNeedDrain)
57
+ stream.once('drain', then);
58
+ else
59
+ then();
60
+ },
61
+ end,
62
+ };
63
+ }