@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.
- package/CHANGELOG.md +24 -0
- package/README.md +4 -2
- package/dist/entries.d.ts +38 -0
- package/dist/entries.d.ts.map +1 -0
- package/dist/entries.js +47 -0
- package/dist/fields.d.ts +77 -0
- package/dist/fields.d.ts.map +1 -0
- package/dist/fields.js +288 -0
- package/dist/functions.d.ts +28 -0
- package/dist/functions.d.ts.map +1 -0
- package/dist/functions.js +156 -0
- package/dist/index.d.ts +39 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +62 -0
- package/dist/json-schema.d.ts +8 -0
- package/dist/json-schema.d.ts.map +1 -0
- package/dist/json-schema.js +44 -0
- package/dist/limits.d.ts +6 -0
- package/dist/limits.d.ts.map +1 -0
- package/dist/limits.js +17 -0
- package/dist/live.d.ts +15 -0
- package/dist/live.d.ts.map +1 -0
- package/dist/live.js +168 -0
- package/dist/models.d.ts +8 -0
- package/dist/models.d.ts.map +1 -0
- package/dist/models.js +136 -0
- package/dist/openapi-functions.d.ts +11 -0
- package/dist/openapi-functions.d.ts.map +1 -0
- package/dist/openapi-functions.js +85 -0
- package/dist/openapi-inputs.d.ts +30 -0
- package/dist/openapi-inputs.d.ts.map +1 -0
- package/dist/openapi-inputs.js +316 -0
- package/dist/openapi-models.d.ts +14 -0
- package/dist/openapi-models.d.ts.map +1 -0
- package/dist/openapi-models.js +280 -0
- package/dist/openapi.d.ts +47 -0
- package/dist/openapi.d.ts.map +1 -0
- package/dist/openapi.js +150 -0
- package/dist/params.d.ts +11 -0
- package/dist/params.d.ts.map +1 -0
- package/dist/params.js +27 -0
- package/dist/sse.d.ts +17 -0
- package/dist/sse.d.ts.map +1 -0
- package/dist/sse.js +63 -0
- package/dist/streams.d.ts +73 -0
- package/dist/streams.d.ts.map +1 -0
- package/dist/streams.js +218 -0
- package/dist/values.d.ts +7 -0
- package/dist/values.d.ts.map +1 -0
- package/dist/values.js +123 -0
- package/package.json +47 -3
- package/src/entries.ts +82 -0
- package/src/fields.ts +391 -0
- package/src/functions.ts +205 -0
- package/src/index.ts +105 -0
- package/src/json-schema.ts +51 -0
- package/src/limits.ts +28 -0
- package/src/live.ts +223 -0
- package/src/models.ts +180 -0
- package/src/openapi-functions.ts +127 -0
- package/src/openapi-inputs.ts +399 -0
- package/src/openapi-models.ts +367 -0
- package/src/openapi.ts +204 -0
- package/src/params.ts +39 -0
- package/src/sse.ts +79 -0
- package/src/streams.ts +301 -0
- package/src/values.ts +163 -0
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { clientOf, validate } from '@ultimabase/http';
|
|
2
|
+
import { isNode, jsonSchemaOf, rowOf } from '@ultimabase/http/internal';
|
|
3
|
+
import { Refused } from 'ultimabase';
|
|
4
|
+
import { checkName, entryOf } from './entries.js';
|
|
5
|
+
import { defaultShape, defaultVirtuals, shaped } from './fields.js';
|
|
6
|
+
import { acceptsNull, isText, resolved } from './json-schema.js';
|
|
7
|
+
import { anyBody } from './models.js';
|
|
8
|
+
import { liveValue, wantsEvents } from './live.js';
|
|
9
|
+
import { paramsOf } from './params.js';
|
|
10
|
+
const none = { json: [], text: [] };
|
|
11
|
+
/**
|
|
12
|
+
* A GET's parameters: an object input's keys, as GraphQL's arguments, or
|
|
13
|
+
* one `input`; a string as is, any other value as JSON.
|
|
14
|
+
*/
|
|
15
|
+
export function getParams(root) {
|
|
16
|
+
if (root === undefined)
|
|
17
|
+
return { accepted: none, flat: false };
|
|
18
|
+
const object = resolved(root, root);
|
|
19
|
+
const { properties } = object;
|
|
20
|
+
if (object['type'] === 'object' && isNode(properties)) {
|
|
21
|
+
const keys = Object.entries(properties);
|
|
22
|
+
const text = (node) => isNode(node) && isText(node, root);
|
|
23
|
+
return {
|
|
24
|
+
accepted: {
|
|
25
|
+
json: keys.filter(([, node]) => !text(node)).map(([key]) => key),
|
|
26
|
+
text: keys.filter(([, node]) => text(node)).map(([key]) => key),
|
|
27
|
+
},
|
|
28
|
+
flat: true,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
const as = isText(root, root) ? 'text' : 'json';
|
|
32
|
+
return { accepted: { ...none, [as]: ['input'] }, flat: false };
|
|
33
|
+
}
|
|
34
|
+
const nonNull = (where) => new TypeError(`Cannot return null for non-nullable field ${where}.`);
|
|
35
|
+
/** A value as GraphQL returns a function's output, a model's row its scalars. */
|
|
36
|
+
function outputOf(api, output, value, where, pending) {
|
|
37
|
+
if (output.kind === 'schema')
|
|
38
|
+
return value ?? null;
|
|
39
|
+
if (value === null || value === undefined) {
|
|
40
|
+
if (output.form === 'nullable')
|
|
41
|
+
return null;
|
|
42
|
+
throw nonNull(where);
|
|
43
|
+
}
|
|
44
|
+
if (output.form === 'list') {
|
|
45
|
+
const items = Array.isArray(value) ? value : [];
|
|
46
|
+
const item = { ...output, form: 'one' };
|
|
47
|
+
return items.map((each) => outputOf(api, item, each, where, pending));
|
|
48
|
+
}
|
|
49
|
+
if (output.kind === 'model') {
|
|
50
|
+
const row = rowOf(value);
|
|
51
|
+
if (row === undefined) {
|
|
52
|
+
throw new TypeError(`A ${output.name} was returned without its id.`);
|
|
53
|
+
}
|
|
54
|
+
const entry = entryOf(api, output.name);
|
|
55
|
+
const copy = shaped(row, defaultShape(entry));
|
|
56
|
+
if (defaultVirtuals(entry).length > 0) {
|
|
57
|
+
pending.set(entry, [...(pending.get(entry) ?? []), { id: row.id, copy }]);
|
|
58
|
+
}
|
|
59
|
+
return copy;
|
|
60
|
+
}
|
|
61
|
+
const given = isNode(value) ? value : {};
|
|
62
|
+
const { type } = output;
|
|
63
|
+
return Object.fromEntries([...type.fields].map(([name, field]) => [
|
|
64
|
+
name,
|
|
65
|
+
outputOf(api, field, given[name], `${type.name}.${name}`, pending),
|
|
66
|
+
]));
|
|
67
|
+
}
|
|
68
|
+
/** The default virtuals of a function's rows, read by id as the caller (D-151). */
|
|
69
|
+
async function withVirtuals(pending, as) {
|
|
70
|
+
for (const [entry, rows] of pending) {
|
|
71
|
+
const names = defaultVirtuals(entry);
|
|
72
|
+
const ids = [...new Set(rows.map(({ id }) => id))];
|
|
73
|
+
const found = await entry.exposed.queries.findMany({
|
|
74
|
+
where: { id: { in: ids } },
|
|
75
|
+
take: ids.length,
|
|
76
|
+
select: Object.fromEntries([
|
|
77
|
+
['id', true],
|
|
78
|
+
...names.map((name) => [name, true]),
|
|
79
|
+
]),
|
|
80
|
+
}, { as });
|
|
81
|
+
const byId = new Map(found.map((row) => [row.id, row]));
|
|
82
|
+
for (const { id, copy } of rows) {
|
|
83
|
+
// A row the caller cannot read has none (D-109).
|
|
84
|
+
for (const name of names)
|
|
85
|
+
copy[name] = byId.get(id)?.[name] ?? null;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/** Each routed function under `${base}/functions` (D-109, D-113). */
|
|
90
|
+
export function functionRoutes(app, api, functions, base, live) {
|
|
91
|
+
const routed = [];
|
|
92
|
+
const owners = new Map();
|
|
93
|
+
for (const fn of functions) {
|
|
94
|
+
if (fn.with === false)
|
|
95
|
+
continue;
|
|
96
|
+
// A plugin's function is named with its plugin (D-113).
|
|
97
|
+
const owner = fn.plugin === undefined ? fn.name : `${fn.plugin}.${fn.name}`;
|
|
98
|
+
const name = checkName(fn.with?.name ?? fn.name, owner);
|
|
99
|
+
const other = owners.get(name);
|
|
100
|
+
if (other !== undefined) {
|
|
101
|
+
throw new Refused(`${owner}: the route functions/${name} is ${other}'s; set .with({ rest: { name } }).`);
|
|
102
|
+
}
|
|
103
|
+
owners.set(name, owner);
|
|
104
|
+
// GraphQL types both sides from their exports, and refuses without (D-45).
|
|
105
|
+
const { input, output } = fn;
|
|
106
|
+
const root = input === undefined
|
|
107
|
+
? undefined
|
|
108
|
+
: jsonSchemaOf(input, 'input', `${owner}: input`);
|
|
109
|
+
routed.push({ fn, owner, name, input: root });
|
|
110
|
+
const nullable = output?.kind === 'schema'
|
|
111
|
+
? acceptsNull(jsonSchemaOf(output.schema, 'output', `${owner}: output`))
|
|
112
|
+
: output?.form === 'nullable';
|
|
113
|
+
const answer = async (as, client, sent) => {
|
|
114
|
+
const value = await fn.run(sent, { as }, client);
|
|
115
|
+
if (output === undefined)
|
|
116
|
+
return true;
|
|
117
|
+
if (value === undefined && !nullable) {
|
|
118
|
+
throw new TypeError(`${fn.name} returned undefined; make its output .nullable() and return null.`);
|
|
119
|
+
}
|
|
120
|
+
const pending = new Map();
|
|
121
|
+
const answered = outputOf(api, output, value, fn.name, pending);
|
|
122
|
+
await withVirtuals(pending, as);
|
|
123
|
+
return answered;
|
|
124
|
+
};
|
|
125
|
+
const path = `${base}/functions/${name}`;
|
|
126
|
+
if (fn.query) {
|
|
127
|
+
const { accepted, flat } = getParams(root);
|
|
128
|
+
app.get(path, async (c) => {
|
|
129
|
+
const params = paramsOf(c.req.url, accepted);
|
|
130
|
+
const { client, sent } = clientOf(c);
|
|
131
|
+
const given = flat ? params : params['input'];
|
|
132
|
+
if (wantsEvents(c.req.raw)) {
|
|
133
|
+
const compute = async (as) => answer(as, client, given);
|
|
134
|
+
return sent(await liveValue(live, owner, c.req.raw, compute));
|
|
135
|
+
}
|
|
136
|
+
return sent(c.json(await answer(c.var.caller, client, given)));
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
else if (input === undefined) {
|
|
140
|
+
app.post(path, async (c) => {
|
|
141
|
+
paramsOf(c.req.url, none);
|
|
142
|
+
const { client, sent } = clientOf(c);
|
|
143
|
+
return sent(c.json(await answer(c.var.caller, client, undefined)));
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
else {
|
|
147
|
+
app.post(path, validate('json', anyBody), async (c) => {
|
|
148
|
+
paramsOf(c.req.url, none);
|
|
149
|
+
const { client, sent } = clientOf(c);
|
|
150
|
+
const body = c.req.valid('json');
|
|
151
|
+
return sent(c.json(await answer(c.var.caller, client, body)));
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return routed;
|
|
156
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { Http } from '@ultimabase/http';
|
|
2
|
+
import type { Realtime } from '@ultimabase/realtime';
|
|
3
|
+
import type { PluginOf, PluginTypes, UseOf } from 'ultimabase';
|
|
4
|
+
import type { RestWith } from './entries.ts';
|
|
5
|
+
import type { RestFunctionWith } from './functions.ts';
|
|
6
|
+
export type { RestWith } from './entries.ts';
|
|
7
|
+
export type { RestFunctionWith } from './functions.ts';
|
|
8
|
+
/** What `restPlugin` takes: `graphqlPlugin`'s options, by name (D-129). */
|
|
9
|
+
export interface RestOptions {
|
|
10
|
+
/** `/api` by default. */
|
|
11
|
+
readonly path?: string;
|
|
12
|
+
/** Relay connections, or `take` and `skip` lists. */
|
|
13
|
+
readonly pagination?: 'connection' | 'list';
|
|
14
|
+
/** The rows a list returns when none is asked for; 100 by default (D-55). */
|
|
15
|
+
readonly take?: number;
|
|
16
|
+
/** The rows a caller other than Master may ask for; 100 by default. */
|
|
17
|
+
readonly maxTake?: number;
|
|
18
|
+
/** The deepest a request of a caller other than Master nests; 10 by default. */
|
|
19
|
+
readonly maxDepth?: number;
|
|
20
|
+
/**
|
|
21
|
+
* The most rows a request of a caller other than Master may return, each
|
|
22
|
+
* list times the lists above it; 20 000 by default.
|
|
23
|
+
*/
|
|
24
|
+
readonly maxRows?: number;
|
|
25
|
+
}
|
|
26
|
+
/** The REST plugin's types (D-57). */
|
|
27
|
+
export interface Rest extends PluginTypes {
|
|
28
|
+
readonly name: 'rest';
|
|
29
|
+
readonly appOptions: RestOptions;
|
|
30
|
+
readonly requires: Http;
|
|
31
|
+
readonly optional: Realtime;
|
|
32
|
+
readonly with: RestWith;
|
|
33
|
+
readonly functionWith: RestFunctionWith;
|
|
34
|
+
}
|
|
35
|
+
/** The application's REST API, served by `httpPlugin` (D-129). */
|
|
36
|
+
export declare const restPlugin: PluginOf<Rest>;
|
|
37
|
+
/** A model's `.with({ rest })`, from its `use` (D-129). */
|
|
38
|
+
export declare const rest: UseOf<Rest>;
|
|
39
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,kBAAkB,CAAA;AAE5C,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,sBAAsB,CAAA;AAEpD,OAAO,KAAK,EAAE,QAAQ,EAAE,WAAW,EAAE,KAAK,EAAE,MAAM,YAAY,CAAA;AAG9D,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,cAAc,CAAA;AAE5C,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAA;AAKtD,YAAY,EAAE,QAAQ,EAAE,MAAM,cAAc,CAAA;AAC5C,YAAY,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAA;AAEtD,2EAA2E;AAC3E,MAAM,WAAW,WAAW;IAC3B,yBAAyB;IACzB,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAA;IACtB,qDAAqD;IACrD,QAAQ,CAAC,UAAU,CAAC,EAAE,YAAY,GAAG,MAAM,CAAA;IAC3C,6EAA6E;IAC7E,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAA;IACtB,uEAAuE;IACvE,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAA;IACzB,gFAAgF;IAChF,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;IAC1B;;;OAGG;IACH,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAA;CACzB;AAED,sCAAsC;AACtC,MAAM,WAAW,IAAK,SAAQ,WAAW;IACxC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,UAAU,EAAE,WAAW,CAAA;IAChC,QAAQ,CAAC,QAAQ,EAAE,IAAI,CAAA;IACvB,QAAQ,CAAC,QAAQ,EAAE,QAAQ,CAAA;IAC3B,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAA;IACvB,QAAQ,CAAC,YAAY,EAAE,gBAAgB,CAAA;CACvC;AAqDD,kEAAkE;AAClE,eAAO,MAAM,UAAU,EAAE,QAAQ,CAAC,IAAI,CAAiB,CAAA;AAEvD,2DAA2D;AAC3D,eAAO,MAAM,IAAI,EAAE,KAAK,CAAC,IAAI,CAAc,CAAA"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { httpPlugin, routes } from '@ultimabase/http';
|
|
2
|
+
import { realtimePlugin } from '@ultimabase/realtime';
|
|
3
|
+
import { definePlugin } from 'ultimabase';
|
|
4
|
+
import manifest from '../package.json' with { type: 'json' };
|
|
5
|
+
import { entriesOf } from './entries.js';
|
|
6
|
+
import { functionRoutes } from './functions.js';
|
|
7
|
+
import { modelRoutes } from './models.js';
|
|
8
|
+
import { openapiOf } from './openapi.js';
|
|
9
|
+
const baseOf = (options) => (options.path ?? '/api').replace(/\/+$/, '');
|
|
10
|
+
// What ends each running application's event streams, by its plugin's context.
|
|
11
|
+
const streams = new WeakMap();
|
|
12
|
+
const plugin = definePlugin({
|
|
13
|
+
name: 'rest',
|
|
14
|
+
manifest,
|
|
15
|
+
requires: { http: httpPlugin },
|
|
16
|
+
optional: { realtime: realtimePlugin },
|
|
17
|
+
api: (context) => {
|
|
18
|
+
const { plugins, models, functions, options, logger } = context;
|
|
19
|
+
const { realtime } = plugins;
|
|
20
|
+
const ends = new Set();
|
|
21
|
+
streams.set(context, ends);
|
|
22
|
+
const live = realtime && {
|
|
23
|
+
realtime,
|
|
24
|
+
streams: ends,
|
|
25
|
+
kept: new Map(),
|
|
26
|
+
open: new Map(),
|
|
27
|
+
logger,
|
|
28
|
+
};
|
|
29
|
+
const api = {
|
|
30
|
+
entries: entriesOf(models, options),
|
|
31
|
+
pagination: options.pagination ?? 'connection',
|
|
32
|
+
take: options.take ?? 100,
|
|
33
|
+
maxDepth: options.maxDepth ?? 10,
|
|
34
|
+
maxRows: options.maxRows ?? 20_000,
|
|
35
|
+
};
|
|
36
|
+
const base = baseOf(options);
|
|
37
|
+
const app = routes();
|
|
38
|
+
const routed = functionRoutes(app, api, functions, base, live);
|
|
39
|
+
for (const entry of api.entries.values()) {
|
|
40
|
+
if (entry.visible)
|
|
41
|
+
modelRoutes(app, api, entry, base, live);
|
|
42
|
+
}
|
|
43
|
+
// Built at startup, so a name clash refuses to start (D-144).
|
|
44
|
+
const document = openapiOf(api, routed, base, manifest.version);
|
|
45
|
+
app.get(`${base}/openapi.json`, (c) => c.json(document));
|
|
46
|
+
plugins.http.mount(app);
|
|
47
|
+
},
|
|
48
|
+
start: ({ plugins, options, logger }) => {
|
|
49
|
+
if (process.env['NODE_ENV'] === 'production')
|
|
50
|
+
return;
|
|
51
|
+
logger.info(`REST on ${plugins.http.url()}${baseOf(options)}.`);
|
|
52
|
+
},
|
|
53
|
+
// Before the HTTP plugin's stop, which waits for the streams to end.
|
|
54
|
+
stop: (context) => {
|
|
55
|
+
for (const end of streams.get(context) ?? [])
|
|
56
|
+
end();
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
/** The application's REST API, served by `httpPlugin` (D-129). */
|
|
60
|
+
export const restPlugin = plugin.plugin;
|
|
61
|
+
/** A model's `.with({ rest })`, from its `use` (D-129). */
|
|
62
|
+
export const rest = plugin.use;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { Node } from '@ultimabase/http/internal';
|
|
2
|
+
/** The node a `$ref` names among the root's `$defs`, or the node itself. */
|
|
3
|
+
export declare function resolved(node: Node, root: Node): Node;
|
|
4
|
+
/** A value only ever a string, which a query parameter holds as is. */
|
|
5
|
+
export declare function isText(node: Node, root: Node): boolean;
|
|
6
|
+
/** A node that accepts `null`, as GraphQL types a nullable output. */
|
|
7
|
+
export declare function acceptsNull(node: Node): boolean;
|
|
8
|
+
//# sourceMappingURL=json-schema.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"json-schema.d.ts","sourceRoot":"","sources":["../src/json-schema.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,2BAA2B,CAAA;AAGrD,4EAA4E;AAC5E,wBAAgB,QAAQ,CAAC,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,GAAG,IAAI,CAMrD;AAED,uEAAuE;AACvE,wBAAgB,MAAM,CAAC,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,GAAG,OAAO,CAwBtD;AAED,sEAAsE;AACtE,wBAAgB,WAAW,CAAC,IAAI,EAAE,IAAI,GAAG,OAAO,CAO/C"}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { isNode } from '@ultimabase/http/internal';
|
|
2
|
+
/** The node a `$ref` names among the root's `$defs`, or the node itself. */
|
|
3
|
+
export function resolved(node, root) {
|
|
4
|
+
const ref = node['$ref'];
|
|
5
|
+
if (typeof ref !== 'string' || !ref.startsWith('#/$defs/'))
|
|
6
|
+
return node;
|
|
7
|
+
const defs = isNode(root['$defs']) ? root['$defs'] : {};
|
|
8
|
+
const found = defs[ref.slice(8)];
|
|
9
|
+
return isNode(found) ? found : node;
|
|
10
|
+
}
|
|
11
|
+
/** A value only ever a string, which a query parameter holds as is. */
|
|
12
|
+
export function isText(node, root) {
|
|
13
|
+
const own = resolved(node, root);
|
|
14
|
+
const { type } = own;
|
|
15
|
+
if (type === 'string')
|
|
16
|
+
return true;
|
|
17
|
+
// `['string', 'null']`, a nullable string.
|
|
18
|
+
if (Array.isArray(type)) {
|
|
19
|
+
return (type.includes('string') &&
|
|
20
|
+
type.every((each) => each === 'string' || each === 'null'));
|
|
21
|
+
}
|
|
22
|
+
if (typeof own['const'] === 'string')
|
|
23
|
+
return true;
|
|
24
|
+
const values = own['enum'];
|
|
25
|
+
if (Array.isArray(values)) {
|
|
26
|
+
return (values.length > 0 && values.every((value) => typeof value === 'string'));
|
|
27
|
+
}
|
|
28
|
+
const variants = own['anyOf'] ?? own['oneOf'];
|
|
29
|
+
if (!Array.isArray(variants))
|
|
30
|
+
return false;
|
|
31
|
+
const kept = variants
|
|
32
|
+
.filter(isNode)
|
|
33
|
+
.filter((each) => resolved(each, root)['type'] !== 'null');
|
|
34
|
+
return kept.length > 0 && kept.every((each) => isText(each, root));
|
|
35
|
+
}
|
|
36
|
+
/** A node that accepts `null`, as GraphQL types a nullable output. */
|
|
37
|
+
export function acceptsNull(node) {
|
|
38
|
+
const variants = node['anyOf'] ?? node['oneOf'];
|
|
39
|
+
if (Array.isArray(variants)) {
|
|
40
|
+
return variants.some((each) => isNode(each) && each['type'] === 'null');
|
|
41
|
+
}
|
|
42
|
+
const { type } = node;
|
|
43
|
+
return Array.isArray(type) ? type.includes('null') : type === 'null';
|
|
44
|
+
}
|
package/dist/limits.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { Caller } from 'ultimabase';
|
|
2
|
+
import type { Api } from './entries.ts';
|
|
3
|
+
import type { Shape } from './fields.ts';
|
|
4
|
+
/** Refuses a request above the limits; `rows` absent checks its depth only. */
|
|
5
|
+
export declare function bounded(api: Api, caller: Caller, shape: Shape, rows?: number): void;
|
|
6
|
+
//# sourceMappingURL=limits.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"limits.d.ts","sourceRoot":"","sources":["../src/limits.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,YAAY,CAAA;AACxC,OAAO,KAAK,EAAE,GAAG,EAAE,MAAM,cAAc,CAAA;AACvC,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,aAAa,CAAA;AAGxC,+EAA+E;AAC/E,wBAAgB,OAAO,CACtB,GAAG,EAAE,GAAG,EACR,MAAM,EAAE,MAAM,EACd,KAAK,EAAE,KAAK,EACZ,IAAI,CAAC,EAAE,MAAM,GACX,IAAI,CAaN"}
|
package/dist/limits.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// The limits of a request of a caller other than Master (D-107): how deep it
|
|
2
|
+
// nests and, unless it is live (D-154), how many rows it may return.
|
|
3
|
+
import { badInput } from '@ultimabase/http/internal';
|
|
4
|
+
import { depthOf, rowsBelow } from './fields.js';
|
|
5
|
+
/** Refuses a request above the limits; `rows` absent checks its depth only. */
|
|
6
|
+
export function bounded(api, caller, shape, rows) {
|
|
7
|
+
if (caller.kind === 'master')
|
|
8
|
+
return;
|
|
9
|
+
const depth = depthOf(shape);
|
|
10
|
+
if (depth > api.maxDepth) {
|
|
11
|
+
throw badInput(`The request is nested ${depth} levels deep, its orderBy included; the most is ${api.maxDepth}.`);
|
|
12
|
+
}
|
|
13
|
+
if (rows === undefined || rows + rowsBelow(shape, rows) <= api.maxRows)
|
|
14
|
+
return;
|
|
15
|
+
const fewer = api.pagination === 'list' ? 'take' : 'first or last';
|
|
16
|
+
throw badInput(`The request asks for more than ${api.maxRows.toLocaleString('en-US')} rows. Ask for fewer with ${fewer}.`);
|
|
17
|
+
}
|
package/dist/live.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Caller } from 'ultimabase';
|
|
2
|
+
import type { Api, Entry } from './entries.ts';
|
|
3
|
+
import type { Live } from './streams.ts';
|
|
4
|
+
/** Whether `request` asks for a stream of events. */
|
|
5
|
+
export declare const wantsEvents: (request: Request) => boolean;
|
|
6
|
+
/** `GET /api/posts` with `Accept: text/event-stream`: a live window. */
|
|
7
|
+
export declare function liveList(api: Api, live: Live | undefined, entry: Entry, caller: Caller, request: Request): Promise<Response>;
|
|
8
|
+
/** `GET /api/posts/:id` with `Accept: text/event-stream`: a live row. */
|
|
9
|
+
export declare function liveRow(api: Api, live: Live | undefined, entry: Entry, caller: Caller, request: Request, id: string): Promise<Response>;
|
|
10
|
+
/**
|
|
11
|
+
* `GET /api/functions/:name` with `Accept: text/event-stream`: the result
|
|
12
|
+
* `compute` gives as the subscriber, again each time it changes.
|
|
13
|
+
*/
|
|
14
|
+
export declare function liveValue(live: Live | undefined, owner: string, request: Request, compute: (caller: Caller) => Promise<unknown>): Promise<Response>;
|
|
15
|
+
//# sourceMappingURL=live.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"live.d.ts","sourceRoot":"","sources":["../src/live.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,MAAM,EAAe,MAAM,YAAY,CAAA;AAErD,OAAO,KAAK,EAAE,GAAG,EAAQ,KAAK,EAAE,MAAM,cAAc,CAAA;AAKpD,OAAO,KAAK,EAAE,IAAI,EAAe,MAAM,cAAc,CAAA;AAGrD,qDAAqD;AACrD,eAAO,MAAM,WAAW,YAAa,OAAO,KAAG,OACqB,CAAA;AA0EpE,wEAAwE;AACxE,wBAAsB,QAAQ,CAC7B,GAAG,EAAE,GAAG,EACR,IAAI,EAAE,IAAI,GAAG,SAAS,EACtB,KAAK,EAAE,KAAK,EACZ,MAAM,EAAE,MAAM,EACd,OAAO,EAAE,OAAO,GACd,OAAO,CAAC,QAAQ,CAAC,CAyDnB;AAED,yEAAyE;AACzE,wBAAsB,OAAO,CAC5B,GAAG,EAAE,GAAG,EACR,IAAI,EAAE,IAAI,GAAG,SAAS,EACtB,KAAK,EAAE,KAAK,EACZ,MAAM,EAAE,MAAM,EACd,OAAO,EAAE,OAAO,EAChB,EAAE,EAAE,MAAM,GACR,OAAO,CAAC,QAAQ,CAAC,CAgBnB;AAiBD;;;GAGG;AACH,wBAAsB,SAAS,CAC9B,IAAI,EAAE,IAAI,GAAG,SAAS,EACtB,KAAK,EAAE,MAAM,EACb,OAAO,EAAE,OAAO,EAChB,OAAO,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,OAAO,CAAC,OAAO,CAAC,GAC3C,OAAO,CAAC,QAAQ,CAAC,CAanB"}
|
package/dist/live.js
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// A live model's list and row over Server-Sent Events (D-154): the read's
|
|
2
|
+
// JSON as `event: data`, then what changed as `event: live`, the JSON GraphQL
|
|
3
|
+
// sends under `extensions.live`; in page mode, `event: data` each time. A
|
|
4
|
+
// list first sends the token that resumes it, as `event: resume`. A live
|
|
5
|
+
// function sends its whole result as `event: data` each time it changes.
|
|
6
|
+
import { randomUUID } from 'node:crypto';
|
|
7
|
+
import { applied, badInput, connection, run } from '@ultimabase/http/internal';
|
|
8
|
+
import { PublicError } from 'ultimabase';
|
|
9
|
+
import { readerOf, shaped } from './fields.js';
|
|
10
|
+
import { bounded } from './limits.js';
|
|
11
|
+
import { paramsOf } from './params.js';
|
|
12
|
+
import { streamed } from './streams.js';
|
|
13
|
+
/** Whether `request` asks for a stream of events. */
|
|
14
|
+
export const wantsEvents = (request) => (request.headers.get('accept') ?? '').includes('text/event-stream');
|
|
15
|
+
/** The engine, unless `entry`'s model is not live. */
|
|
16
|
+
function liveOf(live, entry) {
|
|
17
|
+
const { name } = entry.model;
|
|
18
|
+
if (live !== undefined && live.realtime.live(name))
|
|
19
|
+
return live;
|
|
20
|
+
throw badInput(`${name} is not live: list realtime() in its use.`);
|
|
21
|
+
}
|
|
22
|
+
const fromTop = { size: undefined, backward: false };
|
|
23
|
+
const paging = new Set(['take', 'skip', 'cursor']);
|
|
24
|
+
/** What a live list's messages write, with what it sent so far. */
|
|
25
|
+
function listWriter(events, connected, page, counted, shape) {
|
|
26
|
+
const node = (row) => shaped(row, shape);
|
|
27
|
+
let window = [];
|
|
28
|
+
let hasMore = false;
|
|
29
|
+
let count;
|
|
30
|
+
let pageInfo = '';
|
|
31
|
+
const infoOf = () => {
|
|
32
|
+
const { pageInfo: info } = connection(window, fromTop, () => null);
|
|
33
|
+
return { ...info, hasNextPage: hasMore };
|
|
34
|
+
};
|
|
35
|
+
const data = (revision) => {
|
|
36
|
+
const info = infoOf();
|
|
37
|
+
pageInfo = JSON.stringify(info);
|
|
38
|
+
const { edges } = connection(window, fromTop, node);
|
|
39
|
+
const body = connected
|
|
40
|
+
? { edges, pageInfo: info, ...(counted && { totalCount: count }) }
|
|
41
|
+
: window.map(node);
|
|
42
|
+
events.send('data', body, revision);
|
|
43
|
+
};
|
|
44
|
+
return (message) => {
|
|
45
|
+
const { revision } = message;
|
|
46
|
+
hasMore = message.hasMore ?? hasMore;
|
|
47
|
+
count = message.totalCount ?? count;
|
|
48
|
+
if (message.kind === 'reset' || page) {
|
|
49
|
+
window =
|
|
50
|
+
message.kind === 'reset'
|
|
51
|
+
? message.rows
|
|
52
|
+
: applied(window, message.changes, (row) => row);
|
|
53
|
+
return data(revision);
|
|
54
|
+
}
|
|
55
|
+
const { changes } = message;
|
|
56
|
+
window = applied(window, changes, ({ id }) => ({ id }));
|
|
57
|
+
const live = {
|
|
58
|
+
revision,
|
|
59
|
+
changes: changes.map((change) => {
|
|
60
|
+
if ('remove' in change)
|
|
61
|
+
return change;
|
|
62
|
+
const [edge] = connection([change.upsert], fromTop, node).edges;
|
|
63
|
+
return {
|
|
64
|
+
upsert: connected ? edge : node(change.upsert),
|
|
65
|
+
after: change.after,
|
|
66
|
+
};
|
|
67
|
+
}),
|
|
68
|
+
};
|
|
69
|
+
if (counted && message.totalCount !== undefined) {
|
|
70
|
+
live['totalCount'] = message.totalCount;
|
|
71
|
+
}
|
|
72
|
+
const info = infoOf();
|
|
73
|
+
if (connected && JSON.stringify(info) !== pageInfo) {
|
|
74
|
+
pageInfo = JSON.stringify(info);
|
|
75
|
+
live['pageInfo'] = info;
|
|
76
|
+
}
|
|
77
|
+
events.send('live', live, revision);
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
/** `GET /api/posts` with `Accept: text/event-stream`: a live window. */
|
|
81
|
+
export async function liveList(api, live, entry, caller, request) {
|
|
82
|
+
const followed = liveOf(live, entry);
|
|
83
|
+
const connected = api.pagination === 'connection';
|
|
84
|
+
const arg = connected ? 'first' : 'take';
|
|
85
|
+
const params = paramsOf(request.url, {
|
|
86
|
+
json: [
|
|
87
|
+
'where',
|
|
88
|
+
'orderBy',
|
|
89
|
+
'select',
|
|
90
|
+
'include',
|
|
91
|
+
arg,
|
|
92
|
+
...(connected ? ['totalCount'] : []),
|
|
93
|
+
],
|
|
94
|
+
text: ['mode', 'resume'],
|
|
95
|
+
});
|
|
96
|
+
const { totalCount, mode, resume, ...args } = params;
|
|
97
|
+
if (totalCount !== undefined && typeof totalCount !== 'boolean') {
|
|
98
|
+
throw badInput('totalCount must be true or false.');
|
|
99
|
+
}
|
|
100
|
+
if (mode !== undefined && mode !== 'page') {
|
|
101
|
+
throw badInput('mode must be page.');
|
|
102
|
+
}
|
|
103
|
+
const { name } = entry.model;
|
|
104
|
+
const read = await run(async () => readerOf(api, caller.kind === 'master').list(entry, args, ''));
|
|
105
|
+
const asked = args[arg];
|
|
106
|
+
const rows = typeof asked === 'number' ? asked : entry.take;
|
|
107
|
+
const page = mode === 'page';
|
|
108
|
+
if (page && rows > entry.maxTake) {
|
|
109
|
+
throw badInput(`${arg} is at most ${entry.maxTake} on ${name} in page mode.`);
|
|
110
|
+
}
|
|
111
|
+
// A live list is bounded by maxLive, not by maxRows (D-154).
|
|
112
|
+
bounded(api, caller, read.shape);
|
|
113
|
+
// The window starts at the top of its order and holds `first` rows.
|
|
114
|
+
const listed = {
|
|
115
|
+
...Object.fromEntries(Object.entries(read.read).filter(([key]) => !paging.has(key))),
|
|
116
|
+
first: rows,
|
|
117
|
+
totalCount: totalCount === true,
|
|
118
|
+
};
|
|
119
|
+
return streamed(followed, request, read.shape, async (viewer, send) => viewer.list(name, listed, send), {
|
|
120
|
+
write: (events) => listWriter(events, connected, page, totalCount === true, read.shape),
|
|
121
|
+
signature: JSON.stringify([name, page, { ...listed, first: undefined }]),
|
|
122
|
+
first: rows,
|
|
123
|
+
resume: typeof resume === 'string' ? resume : undefined,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
/** `GET /api/posts/:id` with `Accept: text/event-stream`: a live row. */
|
|
127
|
+
export async function liveRow(api, live, entry, caller, request, id) {
|
|
128
|
+
const followed = liveOf(live, entry);
|
|
129
|
+
const params = paramsOf(request.url, {
|
|
130
|
+
json: ['select', 'include'],
|
|
131
|
+
// A live row is not resumed: it starts afresh.
|
|
132
|
+
text: ['resume'],
|
|
133
|
+
});
|
|
134
|
+
const { resume: _, ...fields } = params;
|
|
135
|
+
const read = await run(async () => readerOf(api, caller.kind === 'master').fields(entry, fields, ''));
|
|
136
|
+
bounded(api, caller, read.shape);
|
|
137
|
+
const args = { ...read.read, where: { id } };
|
|
138
|
+
return streamed(followed, request, read.shape, async (viewer, send) => viewer.row(entry.model.name, args, send));
|
|
139
|
+
}
|
|
140
|
+
/** A live function's error as its client gets it: public as is, else masked and logged. */
|
|
141
|
+
function failure(error, logger) {
|
|
142
|
+
if (error instanceof PublicError) {
|
|
143
|
+
const { code, message, details } = error;
|
|
144
|
+
return details === undefined
|
|
145
|
+
? { code, message }
|
|
146
|
+
: { code, message, details };
|
|
147
|
+
}
|
|
148
|
+
const correlationId = randomUUID();
|
|
149
|
+
logger.error({ correlationId, err: error }, 'A live function failed.');
|
|
150
|
+
return { code: 'INTERNAL', message: 'Internal error.', correlationId };
|
|
151
|
+
}
|
|
152
|
+
const noShape = { fields: [] };
|
|
153
|
+
/**
|
|
154
|
+
* `GET /api/functions/:name` with `Accept: text/event-stream`: the result
|
|
155
|
+
* `compute` gives as the subscriber, again each time it changes.
|
|
156
|
+
*/
|
|
157
|
+
export async function liveValue(live, owner, request, compute) {
|
|
158
|
+
if (live === undefined || !live.realtime.liveFunction(owner)) {
|
|
159
|
+
throw badInput(`${owner} is not live: list realtime() in its use.`);
|
|
160
|
+
}
|
|
161
|
+
const { logger } = live;
|
|
162
|
+
return streamed(live, request, noShape, async (viewer, send) => viewer.value(owner, compute, (message) => {
|
|
163
|
+
if (message.kind !== 'closed' || message.error === undefined) {
|
|
164
|
+
return send(message);
|
|
165
|
+
}
|
|
166
|
+
send({ kind: 'closed', error: failure(message.error, logger) });
|
|
167
|
+
}));
|
|
168
|
+
}
|
package/dist/models.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { Routes, StandardSchema } from '@ultimabase/http';
|
|
2
|
+
import type { Api, Entry } from './entries.ts';
|
|
3
|
+
import type { Live } from './streams.ts';
|
|
4
|
+
/** Any JSON body: the kernel checks the data (D-129). */
|
|
5
|
+
export declare const anyBody: StandardSchema;
|
|
6
|
+
/** Each route of a visible model, under `base`; its reads live with `live`. */
|
|
7
|
+
export declare function modelRoutes(app: Routes, api: Api, entry: Entry, base: string, live: Live | undefined): void;
|
|
8
|
+
//# sourceMappingURL=models.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"models.d.ts","sourceRoot":"","sources":["../src/models.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,MAAM,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAA;AAK9D,OAAO,KAAK,EAAE,GAAG,EAAQ,KAAK,EAAE,MAAM,cAAc,CAAA;AAIpD,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,cAAc,CAAA;AAMxC,yDAAyD;AACzD,eAAO,MAAM,OAAO,EAAE,cAErB,CAAA;AA4ED,+EAA+E;AAC/E,wBAAgB,WAAW,CAC1B,GAAG,EAAE,MAAM,EACX,GAAG,EAAE,GAAG,EACR,KAAK,EAAE,KAAK,EACZ,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,IAAI,GAAG,SAAS,GACpB,IAAI,CA2EN"}
|