@_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
@@ -10,7 +10,7 @@
10
10
  "type": "git",
11
11
  "url": "git+https://github.com/linked-fw/server.git"
12
12
  },
13
- "version": "2.24.11",
13
+ "version": "2.25.0",
14
14
  "packageManager": "npm@12.2.0",
15
15
  "linkedPackage": true,
16
16
  "type": "module",
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: