@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,73 @@
1
+ import type { LiveHandle, LiveMessage, RealtimeApi, Viewer } from '@ultimabase/realtime';
2
+ import type { Logger } from 'ultimabase';
3
+ import type { Shape } from './fields.ts';
4
+ import type { Events } from './sse.ts';
5
+ /**
6
+ * The realtime engine, what ends each open stream, the kept lists, and what
7
+ * keeps each open list as if its client left, by token.
8
+ */
9
+ export type Live = {
10
+ readonly realtime: RealtimeApi;
11
+ readonly streams: Set<() => void>;
12
+ readonly kept: Map<string, Kept>;
13
+ readonly open: Map<string, () => void>;
14
+ readonly logger: Logger;
15
+ };
16
+ /** Where a live read writes: an open stream of events, its response aside. */
17
+ export type Sink = Omit<Events, 'response'>;
18
+ type Changed = Exclude<LiveMessage, {
19
+ readonly kind: 'closed' | 'row' | 'value';
20
+ }>;
21
+ /** What a live list's messages write. */
22
+ export type Write = (message: Changed) => void;
23
+ /** A live read to open, given the viewer and what takes its messages. */
24
+ export type Open = (viewer: Viewer, send: (message: LiveMessage) => void) => Promise<LiveHandle>;
25
+ /** A live list: what writes it, what read it is, its rows, and its token. */
26
+ export type Listed = {
27
+ readonly write: (sink: Sink) => Write;
28
+ readonly signature: string;
29
+ readonly first: number;
30
+ readonly resume: string | undefined;
31
+ };
32
+ /** A sink that writes to its stream, or holds what it is sent while none. */
33
+ type Relay = Sink & {
34
+ readonly to: (sink: Sink) => void;
35
+ /** Holds what it is sent from now on; `ended` runs if it ends. */
36
+ readonly park: (ended: () => void) => void;
37
+ };
38
+ type Stream = {
39
+ readonly viewer: Viewer;
40
+ readonly relay: Relay;
41
+ readonly keep: Keep | undefined;
42
+ readonly signature: string;
43
+ handle?: LiveHandle;
44
+ /** Whether its client left before its first read. */
45
+ ended: boolean;
46
+ };
47
+ type Keep = {
48
+ readonly token: string;
49
+ first: number;
50
+ };
51
+ /** A list whose client left, until a resume takes it or its time is up. */
52
+ export type Kept = {
53
+ readonly stream: Stream;
54
+ readonly handle: LiveHandle;
55
+ readonly keep: Keep;
56
+ /** Takes it out, still open. */
57
+ readonly take: () => void;
58
+ readonly discard: () => void;
59
+ };
60
+ /**
61
+ * Follows the read `open` starts into `sink`; a list's changes go through
62
+ * `list`. Changes are dropped while the client's buffer is full, until the
63
+ * engine sends the window again. A closing error is sent as it comes: its
64
+ * read made it public.
65
+ */
66
+ export declare function follow(sink: Sink, viewer: Viewer, shape: Shape, open: Open, list?: (sink: Sink) => Write): Promise<LiveHandle>;
67
+ /**
68
+ * `request`'s stream of the read `open` starts, closed with the client; a
69
+ * list resumes the one its token keeps when it may.
70
+ */
71
+ export declare function streamed(live: Live, request: Request, shape: Shape, open: Open, list?: Listed): Promise<Response>;
72
+ export {};
73
+ //# sourceMappingURL=streams.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"streams.d.ts","sourceRoot":"","sources":["../src/streams.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,218 @@
1
+ // An open live read over Server-Sent Events (D-154), ended with its client,
2
+ // its caller or the application. A live list whose client left is kept 10 s,
3
+ // what it sends held, so that the same user's request with its token takes
4
+ // it back, grown, even before the server saw it leave. A live list's first
5
+ // event is its token.
6
+ import { randomUUID } from 'node:crypto';
7
+ import { run } from '@ultimabase/http/internal';
8
+ import { shaped } from './fields.js';
9
+ import { eventsTo } from './sse.js';
10
+ /** How long a list whose client left waits for a resume. */
11
+ const keptFor = 10_000;
12
+ const nothing = () => undefined;
13
+ function relayOf() {
14
+ let current;
15
+ let ended = nothing;
16
+ const held = [];
17
+ return {
18
+ send: (event, data, id) => {
19
+ if (current === undefined)
20
+ held.push([event, data, id]);
21
+ else
22
+ current.send(event, data, id);
23
+ },
24
+ full: () => current?.full() ?? false,
25
+ drained: (then) => {
26
+ if (current === undefined)
27
+ then();
28
+ else
29
+ current.drained(then);
30
+ },
31
+ end: () => {
32
+ if (current === undefined)
33
+ ended();
34
+ else
35
+ current.end();
36
+ },
37
+ to: (sink) => {
38
+ current = sink;
39
+ for (const [event, data, id] of held.splice(0))
40
+ sink.send(event, data, id);
41
+ },
42
+ park: (then) => {
43
+ current = undefined;
44
+ ended = then;
45
+ },
46
+ };
47
+ }
48
+ /**
49
+ * Follows the read `open` starts into `sink`; a list's changes go through
50
+ * `list`. Changes are dropped while the client's buffer is full, until the
51
+ * engine sends the window again. A closing error is sent as it comes: its
52
+ * read made it public.
53
+ */
54
+ export async function follow(sink, viewer, shape, open, list) {
55
+ const writes = list?.(sink);
56
+ let handle;
57
+ let dropping = false;
58
+ let owed = false;
59
+ const send = (message) => {
60
+ if (message.kind === 'closed') {
61
+ if (message.error !== undefined)
62
+ sink.send('error', message.error);
63
+ // A viewer closes when its caller changed: the client reconnects.
64
+ else if (!viewer.closed) {
65
+ sink.send('error', {
66
+ code: 'INTERNAL',
67
+ message: 'The live read closed.',
68
+ });
69
+ }
70
+ return sink.end();
71
+ }
72
+ if (dropping && message.kind === 'changes')
73
+ return;
74
+ if (!dropping && sink.full()) {
75
+ dropping = true;
76
+ sink.drained(() => {
77
+ if (handle === undefined)
78
+ owed = true;
79
+ else
80
+ void handle.resend();
81
+ });
82
+ return;
83
+ }
84
+ dropping = false;
85
+ if (message.kind === 'value') {
86
+ return sink.send('data', message.value, message.revision);
87
+ }
88
+ if (message.kind !== 'row')
89
+ return writes?.(message);
90
+ const { row, revision } = message;
91
+ sink.send('data', row && shaped(row, shape), revision);
92
+ };
93
+ handle = await open(viewer, send);
94
+ if (owed)
95
+ void handle.resend();
96
+ return handle;
97
+ }
98
+ /** Keeps `stream`'s list once its client left, until a resume takes it. */
99
+ function park(live, stream, handle, keep) {
100
+ const take = () => {
101
+ clearTimeout(timer);
102
+ live.kept.delete(keep.token);
103
+ live.streams.delete(discard);
104
+ };
105
+ const discard = () => {
106
+ take();
107
+ handle.close();
108
+ stream.viewer.close();
109
+ };
110
+ const timer = setTimeout(discard, keptFor);
111
+ timer.unref();
112
+ stream.relay.park(discard);
113
+ live.streams.add(discard);
114
+ live.kept.set(keep.token, { stream, handle, keep, take, discard });
115
+ }
116
+ /**
117
+ * `request`'s stream of events, which `stream` writes to from now on; the
118
+ * list is kept once the client leaves, unless the stream was ended.
119
+ */
120
+ function eventsFor(live, request, stream) {
121
+ let over = false;
122
+ let left = false;
123
+ const leave = () => {
124
+ if (left)
125
+ return;
126
+ left = true;
127
+ live.streams.delete(finish);
128
+ const { viewer, handle, keep } = stream;
129
+ if (keep !== undefined)
130
+ live.open.delete(keep.token);
131
+ if (over || handle === undefined || keep === undefined || viewer.closed) {
132
+ stream.ended = true;
133
+ handle?.close();
134
+ return viewer.close();
135
+ }
136
+ park(live, stream, handle, keep);
137
+ };
138
+ const events = eventsTo(request, leave);
139
+ const finish = () => {
140
+ over = true;
141
+ events.end();
142
+ };
143
+ live.streams.add(finish);
144
+ if (stream.keep !== undefined) {
145
+ // A resume may come before the server sees this client leave.
146
+ live.open.set(stream.keep.token, () => {
147
+ leave();
148
+ events.end();
149
+ });
150
+ // First, before what a kept list held.
151
+ events.send('resume', stream.keep.token);
152
+ }
153
+ stream.relay.to({ ...events, end: finish });
154
+ return { response: events.response, finish };
155
+ }
156
+ /** The list `list.resume` keeps, as the same read of the same user, grown. */
157
+ async function resumed(live, request, list) {
158
+ const token = list.resume ?? '';
159
+ live.open.get(token)?.();
160
+ const kept = live.kept.get(token);
161
+ if (kept === undefined)
162
+ return undefined;
163
+ const { stream, handle, keep } = kept;
164
+ if (stream.signature !== list.signature || list.first < keep.first) {
165
+ return undefined;
166
+ }
167
+ // Out first, so that neither its time nor another request takes it.
168
+ kept.take();
169
+ // The kept read follows this request's caller; another user ends it.
170
+ if (!(await stream.viewer.adopt(request))) {
171
+ kept.discard();
172
+ return undefined;
173
+ }
174
+ const { response, finish } = eventsFor(live, request, stream);
175
+ try {
176
+ if (list.first > keep.first) {
177
+ await run(async () => handle.grow(list.first - keep.first));
178
+ }
179
+ }
180
+ catch (error) {
181
+ finish();
182
+ stream.viewer.close();
183
+ throw error;
184
+ }
185
+ keep.first = list.first;
186
+ return response;
187
+ }
188
+ /**
189
+ * `request`'s stream of the read `open` starts, closed with the client; a
190
+ * list resumes the one its token keeps when it may.
191
+ */
192
+ export async function streamed(live, request, shape, open, list) {
193
+ if (list?.resume !== undefined) {
194
+ const taken = await resumed(live, request, list);
195
+ if (taken !== undefined)
196
+ return taken;
197
+ }
198
+ const viewer = await live.realtime.connect(request);
199
+ const stream = {
200
+ viewer,
201
+ relay: relayOf(),
202
+ keep: list && { token: randomUUID(), first: list.first },
203
+ signature: list?.signature ?? '',
204
+ ended: false,
205
+ };
206
+ const { response, finish } = eventsFor(live, request, stream);
207
+ try {
208
+ stream.handle = await run(async () => follow(stream.relay, viewer, shape, open, list?.write));
209
+ }
210
+ catch (error) {
211
+ finish();
212
+ viewer.close();
213
+ throw error;
214
+ }
215
+ if (stream.ended)
216
+ stream.handle.close();
217
+ return response;
218
+ }
@@ -0,0 +1,7 @@
1
+ import type { Api, Args } from './entries.ts';
2
+ export declare const isArgs: (value: unknown) => value is Args;
3
+ /** A filter with its dates read, through `AND`, `OR`, `NOT` and relations. */
4
+ export declare function whereOf(api: Api, model: string, where: Args, path: string): Args;
5
+ /** Data with its dates read, in the rows its relation writes reach too. */
6
+ export declare function dataOf(api: Api, model: string, data: Args, path: string): Args;
7
+ //# sourceMappingURL=values.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"values.d.ts","sourceRoot":"","sources":["../src/values.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,GAAG,EAAE,IAAI,EAAE,MAAM,cAAc,CAAA;AAM7C,eAAO,MAAM,MAAM,UAAW,OAAO,KAAG,KAAK,IAAI,IACoB,CAAA;AA4CrE,8EAA8E;AAC9E,wBAAgB,OAAO,CACtB,GAAG,EAAE,GAAG,EACR,KAAK,EAAE,MAAM,EACb,KAAK,EAAE,IAAI,EACX,IAAI,EAAE,MAAM,GACV,IAAI,CAuBN;AAsDD,2EAA2E;AAC3E,wBAAgB,MAAM,CACrB,GAAG,EAAE,GAAG,EACR,KAAK,EAAE,MAAM,EACb,IAAI,EAAE,IAAI,EACV,IAAI,EAAE,MAAM,GACV,IAAI,CAkBN"}
package/dist/values.js ADDED
@@ -0,0 +1,123 @@
1
+ import { InvalidQuery } from 'ultimabase';
2
+ import { entryOf } from './entries.js';
3
+ const iso = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:\d{2})$/;
4
+ export const isArgs = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
5
+ /** A string as a `Date`; any other value left for the kernel to check. */
6
+ function date(value, path) {
7
+ if (typeof value !== 'string')
8
+ return value;
9
+ const parsed = iso.test(value) ? new Date(value) : undefined;
10
+ if (parsed !== undefined && !Number.isNaN(parsed.getTime()))
11
+ return parsed;
12
+ throw new InvalidQuery(path, `${path} must be an ISO 8601 DateTime, such as 2026-01-31T12:00:00.000Z.`);
13
+ }
14
+ const isDate = (field, key) => key === 'createdAt' ||
15
+ key === 'updatedAt' ||
16
+ (field?.kind === 'scalar' && field.type === 'dateTime');
17
+ /** A date field's filter: a value, or operators whose values are dates. */
18
+ function dateFilter(value, path) {
19
+ if (!isArgs(value))
20
+ return date(value, path);
21
+ const filter = {};
22
+ for (const [operator, operand] of Object.entries(value)) {
23
+ const at = `${path}.${operator}`;
24
+ filter[operator] = Array.isArray(operand)
25
+ ? operand.map((item, index) => date(item, `${at}.${index}`))
26
+ : date(operand, at);
27
+ }
28
+ return filter;
29
+ }
30
+ const each = (value, path, map) => Array.isArray(value)
31
+ ? value.map((item, index) => isArgs(item) ? map(item, `${path}.${index}`) : item)
32
+ : isArgs(value)
33
+ ? map(value, path)
34
+ : value;
35
+ /** A filter with its dates read, through `AND`, `OR`, `NOT` and relations. */
36
+ export function whereOf(api, model, where, path) {
37
+ const fields = entryOf(api, model).model.fields;
38
+ const read = {};
39
+ for (const [key, value] of Object.entries(where)) {
40
+ const at = `${path}.${key}`;
41
+ const field = fields.find((candidate) => candidate.name === key);
42
+ if (key === 'AND' || key === 'OR' || key === 'NOT') {
43
+ read[key] = each(value, at, (inner, to) => whereOf(api, model, inner, to));
44
+ }
45
+ else if (isDate(field, key))
46
+ read[key] = dateFilter(value, at);
47
+ else if (field !== undefined && field.kind !== 'scalar' && isArgs(value)) {
48
+ // `is`, `some` and the others each take the target's filter.
49
+ const { target } = field;
50
+ read[key] = Object.fromEntries(Object.entries(value).map(([operator, inner]) => [
51
+ operator,
52
+ isArgs(inner)
53
+ ? whereOf(api, target, inner, `${at}.${operator}`)
54
+ : inner,
55
+ ]));
56
+ }
57
+ else
58
+ read[key] = value;
59
+ }
60
+ return read;
61
+ }
62
+ /** `item` with its `key` read, when it is an object. */
63
+ function within(item, key, path, read) {
64
+ const value = item[key];
65
+ return isArgs(value)
66
+ ? { ...item, [key]: read(value, `${path}.${key}`) }
67
+ : item;
68
+ }
69
+ // The operations that create, update or delete a target's row (D-138).
70
+ const reaching = new Set(['connectOrCreate', 'create', 'delete', 'update']);
71
+ /**
72
+ * A relation write with the dates of its rows and unique targets read
73
+ * (D-128, D-138). A hidden model is connected, disconnected or set, never
74
+ * created, updated or deleted, as over GraphQL.
75
+ */
76
+ function relationOf(api, model, field, value, path) {
77
+ const { target } = field;
78
+ const hidden = !entryOf(api, target).visible;
79
+ const row = (item, at) => dataOf(api, target, item, at);
80
+ const unique = (item, at) => whereOf(api, target, item, at);
81
+ const read = { ...value };
82
+ for (const [key, operand] of Object.entries(value)) {
83
+ const at = `${path}.${key}`;
84
+ if (hidden && reaching.has(key)) {
85
+ throw new InvalidQuery(at, `Unknown key '${at}' on ${model}.`);
86
+ }
87
+ if (key === 'create')
88
+ read[key] = each(operand, at, row);
89
+ else if (key === 'connectOrCreate') {
90
+ read[key] = each(operand, at, (item, to) => within(within(item, 'where', to, unique), 'create', to, row));
91
+ }
92
+ else if (key === 'update' && field.kind === 'many') {
93
+ read[key] = each(operand, at, (item, to) => within(within(item, 'where', to, unique), 'data', to, row));
94
+ }
95
+ else if (key === 'update')
96
+ read[key] = each(operand, at, row);
97
+ else if (key === 'delete' && field.kind === 'many') {
98
+ read[key] = each(operand, at, unique);
99
+ }
100
+ }
101
+ return read;
102
+ }
103
+ /** Data with its dates read, in the rows its relation writes reach too. */
104
+ export function dataOf(api, model, data, path) {
105
+ const read = { ...data };
106
+ for (const field of entryOf(api, model).model.fields) {
107
+ const value = data[field.name];
108
+ const at = `${path}.${field.name}`;
109
+ if (field.kind === 'scalar') {
110
+ if (field.type === 'dateTime' && value !== undefined) {
111
+ read[field.name] = date(value, at);
112
+ }
113
+ // Number updates are the server API's only (D-132).
114
+ if (field.type === 'number' && isArgs(value)) {
115
+ throw new InvalidQuery(at, `${at} must be a number.`);
116
+ }
117
+ }
118
+ else if (isArgs(value)) {
119
+ read[field.name] = relationOf(api, model, field, value, at);
120
+ }
121
+ }
122
+ return read;
123
+ }
package/package.json CHANGED
@@ -1,6 +1,50 @@
1
1
  {
2
2
  "name": "@ultimabase/rest",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.1",
4
+ "description": "REST for UltimaBase: GraphQL's arguments and responses over resource routes, served by the HTTP plugin.",
5
+ "type": "module",
6
+ "license": "Apache-2.0",
7
+ "homepage": "https://ultimabase.dev/docs/rest",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/MrEkinox/ultimabase.git",
11
+ "directory": "packages/rest"
12
+ },
13
+ "engines": {
14
+ "node": ">=24.7.0"
15
+ },
16
+ "exports": {
17
+ ".": "./dist/index.js"
18
+ },
19
+ "files": [
20
+ "dist",
21
+ "src",
22
+ "!src/**/*.test.ts",
23
+ "!src/test"
24
+ ],
25
+ "scripts": {
26
+ "prepack": "rm -rf dist && yarn run -T tsc --project tsconfig.build.json"
27
+ },
28
+ "publishConfig": {
29
+ "exports": {
30
+ ".": "./dist/index.js"
31
+ }
32
+ },
33
+ "peerDependencies": {
34
+ "@ultimabase/http": "^0.1.1",
35
+ "@ultimabase/realtime": "^0.1.1",
36
+ "ultimabase": "^0.1.1"
37
+ },
38
+ "devDependencies": {
39
+ "@seriousme/openapi-schema-validator": "2.11.0",
40
+ "@types/pg": "8.23.1",
41
+ "@ultimabase/http": "0.1.1",
42
+ "@ultimabase/postgres": "0.1.1",
43
+ "@ultimabase/realtime": "0.1.1",
44
+ "ajv": "8.20.0",
45
+ "pg": "8.23.1",
46
+ "ultimabase": "0.1.1",
47
+ "vite": "8.3.4",
48
+ "vitest": "5.0.5"
49
+ }
6
50
  }
package/src/entries.ts ADDED
@@ -0,0 +1,82 @@
1
+ // The models REST serves (D-129): each model's plural and limits, named and
2
+ // bounded as GraphQL names and bounds them (D-107).
3
+ import { plural } from '@ultimabase/http/internal'
4
+ import type { ExposedModel, SchemaModel } from 'ultimabase'
5
+ import { Refused } from 'ultimabase'
6
+
7
+ /** A model's key of `.with` (D-129). */
8
+ export type RestWith =
9
+ { readonly plural?: string; readonly maxTake?: number } | false
10
+
11
+ export type Args = { readonly [key: string]: unknown }
12
+
13
+ export type Entry = {
14
+ readonly exposed: ExposedModel<RestWith>
15
+ readonly model: SchemaModel
16
+ /** Routed; a hidden model is read and connected through relations only. */
17
+ readonly visible: boolean
18
+ readonly plural: string
19
+ /** The rows a list returns without `first`, `last` or `take`. */
20
+ readonly take: number
21
+ readonly maxTake: number
22
+ }
23
+
24
+ /** What every route reads: the models and the plugin's settings. */
25
+ export type Api = {
26
+ readonly entries: ReadonlyMap<string, Entry>
27
+ readonly pagination: 'connection' | 'list'
28
+ readonly take: number
29
+ readonly maxDepth: number
30
+ readonly maxRows: number
31
+ }
32
+
33
+ export function entryOf(api: Api, model: string): Entry {
34
+ const entry = api.entries.get(model)
35
+ if (entry === undefined) throw new Error(`Unknown model ${model}.`)
36
+ return entry
37
+ }
38
+
39
+ /** Stops startup on a route name GraphQL would refuse as a field name. */
40
+ export function checkName(name: string, where: string): string {
41
+ if (/^[_A-Za-z][_0-9A-Za-z]*$/.test(name) && !name.startsWith('__')) {
42
+ return name
43
+ }
44
+ throw new Refused(`${where}: ${name} is not a route name.`)
45
+ }
46
+
47
+ /** `id`, `createdAt` and `updatedAt`, which every model has (D-22). */
48
+ export const system: readonly string[] = ['id', 'createdAt', 'updatedAt']
49
+
50
+ /** Each model's entry; two routed models of one plural stop startup. */
51
+ export function entriesOf(
52
+ models: readonly ExposedModel<RestWith>[],
53
+ options: { readonly take?: number; readonly maxTake?: number },
54
+ ): ReadonlyMap<string, Entry> {
55
+ const take = options.take ?? 100
56
+ const entries = new Map<string, Entry>()
57
+ const owners = new Map<string, string>()
58
+ for (const exposed of models) {
59
+ const name = exposed.schema.name
60
+ const own = exposed.with === false ? {} : (exposed.with ?? {})
61
+ const given =
62
+ own.plural ?? plural(name.charAt(0).toLowerCase() + name.slice(1))
63
+ const entry = {
64
+ exposed,
65
+ model: exposed.schema,
66
+ visible: exposed.with !== false,
67
+ plural: checkName(given, name),
68
+ take,
69
+ maxTake: own.maxTake ?? options.maxTake ?? 100,
70
+ }
71
+ entries.set(name, entry)
72
+ if (!entry.visible) continue
73
+ const other = given === 'functions' ? 'the functions' : owners.get(given)
74
+ if (other !== undefined) {
75
+ throw new Refused(
76
+ `${name}: the route ${given} is ${other}'s; set .with({ rest: { plural } }).`,
77
+ )
78
+ }
79
+ owners.set(given, name)
80
+ }
81
+ return entries
82
+ }