@_linked/server 2.24.11 → 2.25.0
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
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# @\_linked/server
|
|
2
2
|
|
|
3
|
+
## 2.25.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- [#177](https://github.com/linked-fw/server/pull/177) [`781e5d0`](https://github.com/linked-fw/server/commit/781e5d0f41a8fa7a2e0984f9740003968511c759) Thanks [@flyon](https://github.com/flyon)! - GatewayStore: an IDataset that forwards Linked Queries to a gateway. Exported at `@_linked/server/shapes/quadstores/GatewayStore` so an app can select it from `linked.backend.datasets.json`; it posts each query as DSL-JSON with a bearer credential and throws `GatewayUnreachableError` from `init()` when the gateway does not answer.
|
|
8
|
+
|
|
3
9
|
## 2.24.11
|
|
4
10
|
|
|
5
11
|
### Patch Changes
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { IDataset } from '@_linked/core/interfaces/IDataset';
|
|
2
|
+
import type { SelectQuery } from '@_linked/core/queries/SelectQuery';
|
|
3
|
+
import type { CountQuery } from '@_linked/core/queries/CountQuery';
|
|
4
|
+
import type { AskQuery } from '@_linked/core/queries/AskQuery';
|
|
5
|
+
import type { UpdateQuery } from '@_linked/core/queries/UpdateQuery';
|
|
6
|
+
import type { CreateQuery } from '@_linked/core/queries/CreateQuery';
|
|
7
|
+
import type { DeleteQuery, DeleteResponse } from '@_linked/core/queries/DeleteQuery';
|
|
8
|
+
import type { SelectResult, UpdateResult, CreateResult } from '@_linked/core/queries/IntermediateRepresentation';
|
|
9
|
+
/**
|
|
10
|
+
* Constructor argument for `new GatewayStore(config)` — the `config` object of a
|
|
11
|
+
* `linked.backend.datasets.json` entry, passed verbatim by `loadStores`.
|
|
12
|
+
*/
|
|
13
|
+
export interface GatewayStoreConfig {
|
|
14
|
+
/** Base URL of the gateway, e.g. `http://localhost:4040/gateway`. Routes are appended to it. */
|
|
15
|
+
endpoint: string;
|
|
16
|
+
/** The bearer credential the gateway issued to this process. */
|
|
17
|
+
credential: string;
|
|
18
|
+
/** Transport override; defaults to `globalThis.fetch`. */
|
|
19
|
+
fetch?: typeof fetch;
|
|
20
|
+
}
|
|
21
|
+
/** The op names on the wire; each is the `IDataset` method the store was called on. */
|
|
22
|
+
export type GatewayOp = 'select' | 'ask' | 'update' | 'create' | 'delete';
|
|
23
|
+
/** The wire version this store speaks. The gateway refuses any other with `code: "wire-version"`. */
|
|
24
|
+
export declare const GATEWAY_WIRE_VERSION = 1;
|
|
25
|
+
/**
|
|
26
|
+
* A non-2xx answer from the gateway. `code` is the body's `code` when the gateway sent one
|
|
27
|
+
* (`bad-request`, `wire-version`, `unknown-shape`, `refused`, `fork-failed`, `query-failed`),
|
|
28
|
+
* else a generic one; `body` is the parsed response when it was JSON.
|
|
29
|
+
*/
|
|
30
|
+
export declare class GatewayStoreError extends Error {
|
|
31
|
+
readonly status: number;
|
|
32
|
+
readonly code: string;
|
|
33
|
+
readonly body?: unknown;
|
|
34
|
+
constructor(message: string, status: number, code: string, body?: unknown);
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* The gateway could not be used at all: the request never got an HTTP answer (network error,
|
|
38
|
+
* `status` 0), or the gateway refused the credential (401). Thrown by `init()` and by every
|
|
39
|
+
* query method; a process that gets this at boot should stop rather than carry on without data.
|
|
40
|
+
*/
|
|
41
|
+
export declare class GatewayUnreachableError extends GatewayStoreError {
|
|
42
|
+
}
|
|
43
|
+
/** What `GET {endpoint}/health` answers with a valid credential. */
|
|
44
|
+
export interface GatewayHealth {
|
|
45
|
+
projectId: string;
|
|
46
|
+
branchName: string;
|
|
47
|
+
dataset: string;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* An {@link IDataset} that forwards every Linked Query to a gateway over HTTP and returns
|
|
51
|
+
* the gateway's result as is.
|
|
52
|
+
*
|
|
53
|
+
* Each query method posts `{v, op, query: query.toJSON()}` to `POST {endpoint}/query` with
|
|
54
|
+
* `Authorization: Bearer <credential>` and resolves with the body's `result` — the same plain
|
|
55
|
+
* JSON rows `BackendAPIStore` returns. The gateway decides which dataset the query runs on
|
|
56
|
+
* from the credential; the store carries no project, branch or actor.
|
|
57
|
+
*
|
|
58
|
+
* Unlike `BackendAPIStore` it does not go through `Server.call` and has no dependency on
|
|
59
|
+
* the process's own HTTP server: it is meant for a backend whose data lives behind another
|
|
60
|
+
* service, selected from `linked.backend.datasets.json` by the host that started it.
|
|
61
|
+
*
|
|
62
|
+
* The store offers no raw SPARQL (`rawQuery`), no dataset administration and no change feed.
|
|
63
|
+
*/
|
|
64
|
+
export declare class GatewayStore implements IDataset {
|
|
65
|
+
readonly endpoint: string;
|
|
66
|
+
private readonly credential;
|
|
67
|
+
private readonly fetchImpl;
|
|
68
|
+
constructor(config: GatewayStoreConfig);
|
|
69
|
+
/**
|
|
70
|
+
* Checks the gateway answers for this credential: `GET {endpoint}/health`. Resolves with the
|
|
71
|
+
* gateway's answer; throws {@link GatewayUnreachableError} when the gateway cannot be reached
|
|
72
|
+
* or refuses the credential. Safe to call more than once; it changes nothing on either side.
|
|
73
|
+
*
|
|
74
|
+
* `LinkedStorage.setDefaultDataset` calls `init()` without awaiting it, so a boot that must
|
|
75
|
+
* fail loudly awaits `init()` itself before registering the store.
|
|
76
|
+
*/
|
|
77
|
+
init(): Promise<GatewayHealth>;
|
|
78
|
+
selectQuery(query: SelectQuery | CountQuery): Promise<SelectResult | number>;
|
|
79
|
+
askQuery(query: AskQuery): Promise<boolean>;
|
|
80
|
+
updateQuery(query: UpdateQuery): Promise<UpdateResult>;
|
|
81
|
+
createQuery(query: CreateQuery): Promise<CreateResult>;
|
|
82
|
+
deleteQuery(query: DeleteQuery): Promise<DeleteResponse>;
|
|
83
|
+
private send;
|
|
84
|
+
private request;
|
|
85
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/** The wire version this store speaks. The gateway refuses any other with `code: "wire-version"`. */
|
|
2
|
+
export const GATEWAY_WIRE_VERSION = 1;
|
|
3
|
+
/**
|
|
4
|
+
* A non-2xx answer from the gateway. `code` is the body's `code` when the gateway sent one
|
|
5
|
+
* (`bad-request`, `wire-version`, `unknown-shape`, `refused`, `fork-failed`, `query-failed`),
|
|
6
|
+
* else a generic one; `body` is the parsed response when it was JSON.
|
|
7
|
+
*/
|
|
8
|
+
export class GatewayStoreError extends Error {
|
|
9
|
+
constructor(message, status, code, body) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.name = new.target.name;
|
|
12
|
+
this.status = status;
|
|
13
|
+
this.code = code;
|
|
14
|
+
this.body = body;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* The gateway could not be used at all: the request never got an HTTP answer (network error,
|
|
19
|
+
* `status` 0), or the gateway refused the credential (401). Thrown by `init()` and by every
|
|
20
|
+
* query method; a process that gets this at boot should stop rather than carry on without data.
|
|
21
|
+
*/
|
|
22
|
+
export class GatewayUnreachableError extends GatewayStoreError {
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* An {@link IDataset} that forwards every Linked Query to a gateway over HTTP and returns
|
|
26
|
+
* the gateway's result as is.
|
|
27
|
+
*
|
|
28
|
+
* Each query method posts `{v, op, query: query.toJSON()}` to `POST {endpoint}/query` with
|
|
29
|
+
* `Authorization: Bearer <credential>` and resolves with the body's `result` — the same plain
|
|
30
|
+
* JSON rows `BackendAPIStore` returns. The gateway decides which dataset the query runs on
|
|
31
|
+
* from the credential; the store carries no project, branch or actor.
|
|
32
|
+
*
|
|
33
|
+
* Unlike `BackendAPIStore` it does not go through `Server.call` and has no dependency on
|
|
34
|
+
* the process's own HTTP server: it is meant for a backend whose data lives behind another
|
|
35
|
+
* service, selected from `linked.backend.datasets.json` by the host that started it.
|
|
36
|
+
*
|
|
37
|
+
* The store offers no raw SPARQL (`rawQuery`), no dataset administration and no change feed.
|
|
38
|
+
*/
|
|
39
|
+
export class GatewayStore {
|
|
40
|
+
constructor(config) {
|
|
41
|
+
var _a;
|
|
42
|
+
const endpoint = typeof (config === null || config === void 0 ? void 0 : config.endpoint) === 'string' ? config.endpoint.trim() : '';
|
|
43
|
+
const credential = typeof (config === null || config === void 0 ? void 0 : config.credential) === 'string' ? config.credential.trim() : '';
|
|
44
|
+
if (!endpoint) {
|
|
45
|
+
throw new Error('GatewayStore: `endpoint` is empty. A gateway store needs the URL of the gateway ' +
|
|
46
|
+
'(the host that started this process sets it, e.g. through CN_EG_GATEWAY).');
|
|
47
|
+
}
|
|
48
|
+
if (!credential) {
|
|
49
|
+
throw new Error('GatewayStore: `credential` is empty. A gateway store needs the bearer credential ' +
|
|
50
|
+
'the gateway issued to this process (e.g. CN_APP_SERVICE_SECRET).');
|
|
51
|
+
}
|
|
52
|
+
const fetchImpl = (_a = config.fetch) !== null && _a !== void 0 ? _a : globalThis.fetch;
|
|
53
|
+
if (typeof fetchImpl !== 'function') {
|
|
54
|
+
throw new Error('GatewayStore: no `fetch` available; pass one in the config.');
|
|
55
|
+
}
|
|
56
|
+
this.endpoint = endpoint.replace(/\/+$/, '');
|
|
57
|
+
this.credential = credential;
|
|
58
|
+
this.fetchImpl = fetchImpl;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Checks the gateway answers for this credential: `GET {endpoint}/health`. Resolves with the
|
|
62
|
+
* gateway's answer; throws {@link GatewayUnreachableError} when the gateway cannot be reached
|
|
63
|
+
* or refuses the credential. Safe to call more than once; it changes nothing on either side.
|
|
64
|
+
*
|
|
65
|
+
* `LinkedStorage.setDefaultDataset` calls `init()` without awaiting it, so a boot that must
|
|
66
|
+
* fail loudly awaits `init()` itself before registering the store.
|
|
67
|
+
*/
|
|
68
|
+
async init() {
|
|
69
|
+
return (await this.request('GET', '/health'));
|
|
70
|
+
}
|
|
71
|
+
selectQuery(query) {
|
|
72
|
+
return this.send('select', query.toJSON());
|
|
73
|
+
}
|
|
74
|
+
askQuery(query) {
|
|
75
|
+
return this.send('ask', query.toJSON());
|
|
76
|
+
}
|
|
77
|
+
updateQuery(query) {
|
|
78
|
+
return this.send('update', query.toJSON());
|
|
79
|
+
}
|
|
80
|
+
createQuery(query) {
|
|
81
|
+
return this.send('create', query.toJSON());
|
|
82
|
+
}
|
|
83
|
+
deleteQuery(query) {
|
|
84
|
+
return this.send('delete', query.toJSON());
|
|
85
|
+
}
|
|
86
|
+
async send(op, query) {
|
|
87
|
+
const body = (await this.request('POST', '/query', {
|
|
88
|
+
v: GATEWAY_WIRE_VERSION,
|
|
89
|
+
op,
|
|
90
|
+
query,
|
|
91
|
+
}));
|
|
92
|
+
if (!body || typeof body !== 'object' || !('result' in body)) {
|
|
93
|
+
throw new GatewayStoreError(`GatewayStore: the gateway answered ${op} without a result`, 200, 'bad-response', body);
|
|
94
|
+
}
|
|
95
|
+
return body.result;
|
|
96
|
+
}
|
|
97
|
+
async request(method, route, json) {
|
|
98
|
+
const url = this.endpoint + route;
|
|
99
|
+
const headers = {
|
|
100
|
+
Authorization: `Bearer ${this.credential}`,
|
|
101
|
+
Accept: 'application/json',
|
|
102
|
+
};
|
|
103
|
+
const init = { method, headers };
|
|
104
|
+
if (json !== undefined) {
|
|
105
|
+
headers['Content-Type'] = 'application/json';
|
|
106
|
+
init.body = JSON.stringify(json);
|
|
107
|
+
}
|
|
108
|
+
let response;
|
|
109
|
+
try {
|
|
110
|
+
response = await this.fetchImpl(url, init);
|
|
111
|
+
}
|
|
112
|
+
catch (err) {
|
|
113
|
+
const cause = err instanceof Error ? err.message : String(err);
|
|
114
|
+
throw new GatewayUnreachableError(`GatewayStore: cannot reach the gateway at ${url}: ${cause}`, 0, 'unreachable');
|
|
115
|
+
}
|
|
116
|
+
const text = await response.text();
|
|
117
|
+
let body = undefined;
|
|
118
|
+
if (text) {
|
|
119
|
+
try {
|
|
120
|
+
body = JSON.parse(text);
|
|
121
|
+
}
|
|
122
|
+
catch (_a) {
|
|
123
|
+
body = text;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (response.ok)
|
|
127
|
+
return body;
|
|
128
|
+
const code = body && typeof body === 'object' && typeof body.code === 'string'
|
|
129
|
+
? body.code
|
|
130
|
+
: `http-${response.status}`;
|
|
131
|
+
const detail = body && typeof body === 'object' && typeof body.error === 'string'
|
|
132
|
+
? body.error
|
|
133
|
+
: typeof body === 'string' && body
|
|
134
|
+
? body
|
|
135
|
+
: response.statusText;
|
|
136
|
+
const message = `GatewayStore: ${method} ${url} answered ${response.status} (${code})${detail ? `: ${detail}` : ''}`;
|
|
137
|
+
if (response.status === 401) {
|
|
138
|
+
throw new GatewayUnreachableError(message, 401, code, body);
|
|
139
|
+
}
|
|
140
|
+
throw new GatewayStoreError(message, response.status, code, body);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
//# sourceMappingURL=GatewayStore.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"GatewayStore.js","sourceRoot":"","sources":["../../../../src/shapes/quadstores/GatewayStore.ts"],"names":[],"mappings":"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","sourcesContent":["import type { IDataset } from '@_linked/core/interfaces/IDataset';\nimport type { SelectQuery } from '@_linked/core/queries/SelectQuery';\nimport type { CountQuery } from '@_linked/core/queries/CountQuery';\nimport type { AskQuery } from '@_linked/core/queries/AskQuery';\nimport type { UpdateQuery } from '@_linked/core/queries/UpdateQuery';\nimport type { CreateQuery } from '@_linked/core/queries/CreateQuery';\nimport type {\n DeleteQuery,\n DeleteResponse,\n} from '@_linked/core/queries/DeleteQuery';\nimport type {\n SelectResult,\n UpdateResult,\n CreateResult,\n} from '@_linked/core/queries/IntermediateRepresentation';\n\n/**\n * Constructor argument for `new GatewayStore(config)` — the `config` object of a\n * `linked.backend.datasets.json` entry, passed verbatim by `loadStores`.\n */\nexport interface GatewayStoreConfig {\n /** Base URL of the gateway, e.g. `http://localhost:4040/gateway`. Routes are appended to it. */\n endpoint: string;\n /** The bearer credential the gateway issued to this process. */\n credential: string;\n /** Transport override; defaults to `globalThis.fetch`. */\n fetch?: typeof fetch;\n}\n\n/** The op names on the wire; each is the `IDataset` method the store was called on. */\nexport type GatewayOp = 'select' | 'ask' | 'update' | 'create' | 'delete';\n\n/** The wire version this store speaks. The gateway refuses any other with `code: \"wire-version\"`. */\nexport const GATEWAY_WIRE_VERSION = 1;\n\n/**\n * A non-2xx answer from the gateway. `code` is the body's `code` when the gateway sent one\n * (`bad-request`, `wire-version`, `unknown-shape`, `refused`, `fork-failed`, `query-failed`),\n * else a generic one; `body` is the parsed response when it was JSON.\n */\nexport class GatewayStoreError extends Error {\n readonly status: number;\n readonly code: string;\n readonly body?: unknown;\n\n constructor(message: string, status: number, code: string, body?: unknown) {\n super(message);\n this.name = new.target.name;\n this.status = status;\n this.code = code;\n this.body = body;\n }\n}\n\n/**\n * The gateway could not be used at all: the request never got an HTTP answer (network error,\n * `status` 0), or the gateway refused the credential (401). Thrown by `init()` and by every\n * query method; a process that gets this at boot should stop rather than carry on without data.\n */\nexport class GatewayUnreachableError extends GatewayStoreError {}\n\n/** What `GET {endpoint}/health` answers with a valid credential. */\nexport interface GatewayHealth {\n projectId: string;\n branchName: string;\n dataset: string;\n}\n\n/**\n * An {@link IDataset} that forwards every Linked Query to a gateway over HTTP and returns\n * the gateway's result as is.\n *\n * Each query method posts `{v, op, query: query.toJSON()}` to `POST {endpoint}/query` with\n * `Authorization: Bearer <credential>` and resolves with the body's `result` — the same plain\n * JSON rows `BackendAPIStore` returns. The gateway decides which dataset the query runs on\n * from the credential; the store carries no project, branch or actor.\n *\n * Unlike `BackendAPIStore` it does not go through `Server.call` and has no dependency on\n * the process's own HTTP server: it is meant for a backend whose data lives behind another\n * service, selected from `linked.backend.datasets.json` by the host that started it.\n *\n * The store offers no raw SPARQL (`rawQuery`), no dataset administration and no change feed.\n */\nexport class GatewayStore implements IDataset {\n readonly endpoint: string;\n private readonly credential: string;\n private readonly fetchImpl: typeof fetch;\n\n constructor(config: GatewayStoreConfig) {\n const endpoint = typeof config?.endpoint === 'string' ? config.endpoint.trim() : '';\n const credential = typeof config?.credential === 'string' ? config.credential.trim() : '';\n if (!endpoint) {\n throw new Error(\n 'GatewayStore: `endpoint` is empty. A gateway store needs the URL of the gateway ' +\n '(the host that started this process sets it, e.g. through CN_EG_GATEWAY).',\n );\n }\n if (!credential) {\n throw new Error(\n 'GatewayStore: `credential` is empty. A gateway store needs the bearer credential ' +\n 'the gateway issued to this process (e.g. CN_APP_SERVICE_SECRET).',\n );\n }\n const fetchImpl = config.fetch ?? globalThis.fetch;\n if (typeof fetchImpl !== 'function') {\n throw new Error('GatewayStore: no `fetch` available; pass one in the config.');\n }\n this.endpoint = endpoint.replace(/\\/+$/, '');\n this.credential = credential;\n this.fetchImpl = fetchImpl;\n }\n\n /**\n * Checks the gateway answers for this credential: `GET {endpoint}/health`. Resolves with the\n * gateway's answer; throws {@link GatewayUnreachableError} when the gateway cannot be reached\n * or refuses the credential. Safe to call more than once; it changes nothing on either side.\n *\n * `LinkedStorage.setDefaultDataset` calls `init()` without awaiting it, so a boot that must\n * fail loudly awaits `init()` itself before registering the store.\n */\n async init(): Promise<GatewayHealth> {\n return (await this.request('GET', '/health')) as GatewayHealth;\n }\n\n selectQuery(query: SelectQuery | CountQuery): Promise<SelectResult | number> {\n return this.send('select', query.toJSON());\n }\n\n askQuery(query: AskQuery): Promise<boolean> {\n return this.send('ask', query.toJSON());\n }\n\n updateQuery(query: UpdateQuery): Promise<UpdateResult> {\n return this.send('update', query.toJSON());\n }\n\n createQuery(query: CreateQuery): Promise<CreateResult> {\n return this.send('create', query.toJSON());\n }\n\n deleteQuery(query: DeleteQuery): Promise<DeleteResponse> {\n return this.send('delete', query.toJSON());\n }\n\n private async send<T>(op: GatewayOp, query: unknown): Promise<T> {\n const body = (await this.request('POST', '/query', {\n v: GATEWAY_WIRE_VERSION,\n op,\n query,\n })) as { result?: T } | null;\n if (!body || typeof body !== 'object' || !('result' in body)) {\n throw new GatewayStoreError(\n `GatewayStore: the gateway answered ${op} without a result`,\n 200,\n 'bad-response',\n body,\n );\n }\n return body.result as T;\n }\n\n private async request(method: 'GET' | 'POST', route: string, json?: unknown): Promise<unknown> {\n const url = this.endpoint + route;\n const headers: Record<string, string> = {\n Authorization: `Bearer ${this.credential}`,\n Accept: 'application/json',\n };\n const init: RequestInit = { method, headers };\n if (json !== undefined) {\n headers['Content-Type'] = 'application/json';\n init.body = JSON.stringify(json);\n }\n\n let response: Response;\n try {\n response = await this.fetchImpl(url, init);\n } catch (err) {\n const cause = err instanceof Error ? err.message : String(err);\n throw new GatewayUnreachableError(\n `GatewayStore: cannot reach the gateway at ${url}: ${cause}`,\n 0,\n 'unreachable',\n );\n }\n\n const text = await response.text();\n let body: unknown = undefined;\n if (text) {\n try {\n body = JSON.parse(text);\n } catch {\n body = text;\n }\n }\n\n if (response.ok) return body;\n\n const code =\n body && typeof body === 'object' && typeof (body as any).code === 'string'\n ? ((body as any).code as string)\n : `http-${response.status}`;\n const detail =\n body && typeof body === 'object' && typeof (body as any).error === 'string'\n ? (body as any).error\n : typeof body === 'string' && body\n ? body\n : response.statusText;\n const message = `GatewayStore: ${method} ${url} answered ${response.status} (${code})${\n detail ? `: ${detail}` : ''\n }`;\n\n if (response.status === 401) {\n throw new GatewayUnreachableError(message, 401, code, body);\n }\n throw new GatewayStoreError(message, response.status, code, body);\n }\n}\n"]}
|
package/package.json
CHANGED
package/readme.md
CHANGED
|
@@ -19,6 +19,9 @@ If you want to further adjust the functionality of LincdServer yourself, either
|
|
|
19
19
|
- [Generic backend providers](#generic-backend-providers)
|
|
20
20
|
- [Gotchas](#gotchas)
|
|
21
21
|
- [Error semantics](#error-semantics)
|
|
22
|
+
- [Query stores](#query-stores)
|
|
23
|
+
- [BackendAPIStore](#backendapistore)
|
|
24
|
+
- [GatewayStore](#gatewaystore)
|
|
22
25
|
- [API-only servers (`server.apiOnly`)](#api-only-servers-serverapionly)
|
|
23
26
|
- [Provider lifecycle and HMR](#provider-lifecycle-and-hmr)
|
|
24
27
|
- [When a provider throws, and refusing to start](#when-a-provider-throws-and-refusing-to-start)
|
|
@@ -232,6 +235,72 @@ What the caller sees depends on how it calls:
|
|
|
232
235
|
- An unmatched call resolves `undefined` by default and rejects with a 501 `ServerCallError` when `rejectOnError` is set.
|
|
233
236
|
- **`BackendAPIStore`.** It always uses `rejectOnError`, so a failed query rejects with the status and message. A successful call resolves with whatever the provider returned, `undefined` included.
|
|
234
237
|
|
|
238
|
+
## Query stores
|
|
239
|
+
|
|
240
|
+
Two `IDataset` implementations in this package forward Linked Queries over HTTP instead of
|
|
241
|
+
answering them. Both ship `query.toJSON()` and resolve with the plain JSON rows the other side
|
|
242
|
+
returns; neither offers raw SPARQL (`rawQuery`), dataset administration or a change feed.
|
|
243
|
+
|
|
244
|
+
| | `BackendAPIStore` | `GatewayStore` |
|
|
245
|
+
| --- | --- | --- |
|
|
246
|
+
| Runs in | the browser (or any client of this package's `/call` routes) | a backend process |
|
|
247
|
+
| Sends to | this app's own backend, through `Server.call` (`/call/<package>/<method>`) | a gateway of another service: `POST {endpoint}/query` |
|
|
248
|
+
| Authenticated by | the user's session | a bearer credential the gateway issued to this process |
|
|
249
|
+
| Who picks the dataset | the backend's `LinkedStorage` | the gateway, from the credential |
|
|
250
|
+
|
|
251
|
+
### BackendAPIStore
|
|
252
|
+
|
|
253
|
+
Export path `@_linked/server/shapes/quadstores/BackendAPIStore`. Selected in
|
|
254
|
+
`linked.frontend.datasets.json`; `config` may carry a `name` or `id`, neither of which affects
|
|
255
|
+
routing. Its error semantics are under [Error semantics](#error-semantics).
|
|
256
|
+
|
|
257
|
+
### GatewayStore
|
|
258
|
+
|
|
259
|
+
Export path `@_linked/server/shapes/quadstores/GatewayStore` (named export `GatewayStore`, so
|
|
260
|
+
`loadStores` resolves it by the last path segment). It is for a backend whose data lives behind
|
|
261
|
+
a gateway run by the host that started it — for example an app process started by Create Now,
|
|
262
|
+
whose queries go to CN's gateway so that branch forks, authorization and audit apply to app
|
|
263
|
+
traffic.
|
|
264
|
+
|
|
265
|
+
```json
|
|
266
|
+
{
|
|
267
|
+
"datasets": {
|
|
268
|
+
"gateway": {
|
|
269
|
+
"store": "@_linked/server/shapes/quadstores/GatewayStore",
|
|
270
|
+
"config": { "endpoint": "${CN_EG_GATEWAY:-}", "credential": "${CN_APP_SERVICE_SECRET:-}" }
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
```typescript
|
|
277
|
+
export interface GatewayStoreConfig {
|
|
278
|
+
endpoint: string; // base URL of the gateway, routes are appended: `${endpoint}/query`, `${endpoint}/health`
|
|
279
|
+
credential: string; // sent as `Authorization: Bearer <credential>`
|
|
280
|
+
fetch?: typeof fetch; // transport override, default `globalThis.fetch`
|
|
281
|
+
}
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
- The constructor throws at once when `endpoint` or `credential` is empty. The `:-` defaults in
|
|
285
|
+
the JSON keep `parseDatasetsConfig` from throwing when the variables are unset; a storage file
|
|
286
|
+
instantiates the alias only when the host set them (the presence of `CN_EG_GATEWAY` is the
|
|
287
|
+
mode switch; `CN_APP_SERVICE_SECRET` is injected into the process environment, never written
|
|
288
|
+
to `.env`).
|
|
289
|
+
- `init()` is `GET {endpoint}/health` and resolves with the gateway's answer
|
|
290
|
+
(`{projectId, branchName, dataset}`). It throws `GatewayUnreachableError` when the gateway
|
|
291
|
+
cannot be reached or refuses the credential. `LinkedStorage.setDefaultDataset` does not await
|
|
292
|
+
`init()`, so a boot that must fail loudly awaits `init()` itself before registering the store.
|
|
293
|
+
Calling it again is harmless.
|
|
294
|
+
- Every query method posts `{ "v": 1, "op": "select" | "ask" | "update" | "create" | "delete", "query": <query.toJSON()> }`
|
|
295
|
+
and resolves with the body's `result`. A count arrives through `selectQuery` with `op: "select"`
|
|
296
|
+
(the gateway dispatches on the query's own `op`). Nothing about the project, branch or actor is
|
|
297
|
+
in the body: the credential decides all three.
|
|
298
|
+
- Errors: a network failure and a `401` reject with `GatewayUnreachableError` (`status` 0 or 401);
|
|
299
|
+
any other non-2xx answer rejects with `GatewayStoreError`, whose `code` is the body's `code`
|
|
300
|
+
(`bad-request`, `wire-version`, `unknown-shape`, `refused`, `fork-failed`, `query-failed`),
|
|
301
|
+
else `http-<status>`, with the parsed body on `body`. `GatewayUnreachableError extends
|
|
302
|
+
GatewayStoreError`.
|
|
303
|
+
|
|
235
304
|
## API-only servers (`server.apiOnly`)
|
|
236
305
|
|
|
237
306
|
A backend that serves no frontend app (e.g. started with `linked start --api-only`) can set `apiOnly` in its server config:
|