@_linked/server 2.25.0 → 3.1.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.
@@ -1,3 +1,4 @@
1
+ import type { ShapeCatalog } from '@_linked/core/utils/ShapeCatalog';
1
2
  import { type QueryOperation } from '@_linked/server-utils/utils/QueryAccess';
2
3
  /**
3
4
  * Whether DSL-JSON carries a query-context reference (`{"@ctx": name}`) in any
@@ -7,9 +8,34 @@ import { type QueryOperation } from '@_linked/server-utils/utils/QueryAccess';
7
8
  */
8
9
  export declare function containsContextRef(json: unknown): boolean;
9
10
  /**
10
- * A rehydrated query builder from the DSL-JSON a client sent. The result is
11
- * what the store runs, so the access check and the store see the same query.
12
- * A body that is not a query answers 400.
11
+ * Supplies the shape catalog a generic query request is decoded in, from the
12
+ * DSL-JSON the client sent (the request context is available through the
13
+ * host's own means, e.g. `getCallContext`). `undefined` means the global
14
+ * shape registry.
15
+ */
16
+ export type QueryCatalogResolver = (query: unknown) => ShapeCatalog | undefined | Promise<ShapeCatalog | undefined>;
17
+ /**
18
+ * Set (or, with `undefined`, clear) the host's catalog resolver for the
19
+ * generic query endpoints. Without one, every query decodes in the global
20
+ * shape registry.
21
+ */
22
+ export declare function setQueryCatalogResolver(resolver: QueryCatalogResolver | undefined): void;
23
+ /**
24
+ * The catalog `query` is decoded in: the host resolver's answer, or
25
+ * `undefined` (the global registry) without a resolver. A live builder already
26
+ * carries its catalog, so the resolver is not asked for it.
27
+ *
28
+ * A `ServerCallError` the resolver throws is passed on as is (the host chose
29
+ * the status); any other failure is logged and answers 500.
30
+ */
31
+ export declare function resolveQueryCatalog(query: unknown): Promise<ShapeCatalog | undefined>;
32
+ /**
33
+ * A rehydrated query builder from the DSL-JSON a client sent, decoded in
34
+ * `catalog` (`undefined`: the global registry, whatever catalog is active;
35
+ * see `resolveQueryCatalog`). The builder keeps that catalog, so
36
+ * the access-check lowering and the store's lowering both resolve against it.
37
+ * The result is what the store runs, so the access check and the store see
38
+ * the same query. A body that is not a query answers 400.
13
39
  *
14
40
  * A query-context reference (`{"@ctx": "user"}`) also answers 400. Core
15
41
  * resolves one at lowering time against a process-wide context map, which on a
@@ -19,11 +45,11 @@ export declare function containsContextRef(json: unknown): boolean;
19
45
  * names its nodes by id.
20
46
  *
21
47
  * Without context references, lowering a rehydrated builder is a pure function
22
- * of the builder (closed and never mutated after `fromJSON`) and the shape
23
- * registry: the IR `authorizeGenericQuery` checks is the IR the store lowers
48
+ * of the builder (closed and never mutated after `fromJSON`) and its shape
49
+ * catalog: the IR `authorizeGenericQuery` checks is the IR the store lowers
24
50
  * from the same builder.
25
51
  */
26
- export declare function toQueryBuilder(query: any): any;
52
+ export declare function toQueryBuilder(query: unknown, catalog?: ShapeCatalog): any;
27
53
  /** The lowered IR of a query, or `undefined` when it cannot be lowered (refused). */
28
54
  export declare function lowerForAccessCheck(builder: any): unknown;
29
55
  /**
@@ -10,6 +10,10 @@
10
10
  * module turns the incoming query into the builder the store runs and the IR
11
11
  * the rules read.
12
12
  *
13
+ * The host can supply the shape catalog a request's query is decoded in
14
+ * (`setQueryCatalogResolver`), so one server can resolve the same shape IRI
15
+ * differently per request (for example per project branch).
16
+ *
13
17
  * INTERIM: that per-store access model needs to be revised and rethought from
14
18
  * the ground up (see QueryAccess).
15
19
  */
@@ -47,10 +51,51 @@ export function containsContextRef(json) {
47
51
  }
48
52
  return false;
49
53
  }
54
+ let queryCatalogResolver;
55
+ /**
56
+ * Set (or, with `undefined`, clear) the host's catalog resolver for the
57
+ * generic query endpoints. Without one, every query decodes in the global
58
+ * shape registry.
59
+ */
60
+ export function setQueryCatalogResolver(resolver) {
61
+ queryCatalogResolver = resolver;
62
+ }
63
+ /** A live builder passed by backend code (never a plain object from JSON). */
64
+ function isLiveBuilder(query) {
65
+ return (!!query &&
66
+ typeof query === 'object' &&
67
+ Object.getPrototypeOf(query) !== Object.prototype &&
68
+ typeof query.__queryKind === 'string');
69
+ }
70
+ /**
71
+ * The catalog `query` is decoded in: the host resolver's answer, or
72
+ * `undefined` (the global registry) without a resolver. A live builder already
73
+ * carries its catalog, so the resolver is not asked for it.
74
+ *
75
+ * A `ServerCallError` the resolver throws is passed on as is (the host chose
76
+ * the status); any other failure is logged and answers 500.
77
+ */
78
+ export async function resolveQueryCatalog(query) {
79
+ const resolver = queryCatalogResolver;
80
+ if (!resolver || isLiveBuilder(query))
81
+ return undefined;
82
+ try {
83
+ return await resolver(query);
84
+ }
85
+ catch (error) {
86
+ if (ServerCallError.is(error))
87
+ throw error;
88
+ console.error('[queryPlane] the shape catalog resolver failed:', error);
89
+ throw new ServerCallError(500, 'Shape catalog unavailable');
90
+ }
91
+ }
50
92
  /**
51
- * A rehydrated query builder from the DSL-JSON a client sent. The result is
52
- * what the store runs, so the access check and the store see the same query.
53
- * A body that is not a query answers 400.
93
+ * A rehydrated query builder from the DSL-JSON a client sent, decoded in
94
+ * `catalog` (`undefined`: the global registry, whatever catalog is active;
95
+ * see `resolveQueryCatalog`). The builder keeps that catalog, so
96
+ * the access-check lowering and the store's lowering both resolve against it.
97
+ * The result is what the store runs, so the access check and the store see
98
+ * the same query. A body that is not a query answers 400.
54
99
  *
55
100
  * A query-context reference (`{"@ctx": "user"}`) also answers 400. Core
56
101
  * resolves one at lowering time against a process-wide context map, which on a
@@ -60,23 +105,18 @@ export function containsContextRef(json) {
60
105
  * names its nodes by id.
61
106
  *
62
107
  * Without context references, lowering a rehydrated builder is a pure function
63
- * of the builder (closed and never mutated after `fromJSON`) and the shape
64
- * registry: the IR `authorizeGenericQuery` checks is the IR the store lowers
108
+ * of the builder (closed and never mutated after `fromJSON`) and its shape
109
+ * catalog: the IR `authorizeGenericQuery` checks is the IR the store lowers
65
110
  * from the same builder.
66
111
  */
67
- export function toQueryBuilder(query) {
68
- // A live builder passed by backend code (never a plain object from JSON).
69
- if (query &&
70
- typeof query === 'object' &&
71
- Object.getPrototypeOf(query) !== Object.prototype &&
72
- typeof query.__queryKind === 'string') {
112
+ export function toQueryBuilder(query, catalog) {
113
+ if (isLiveBuilder(query))
73
114
  return query;
74
- }
75
115
  if (containsContextRef(query)) {
76
116
  throw new ServerCallError(400, 'Query context references (@ctx) are not accepted by the generic query endpoints');
77
117
  }
78
118
  try {
79
- const builder = fromJSON(query);
119
+ const builder = fromJSON(query, { catalog });
80
120
  if (!builder || typeof builder !== 'object')
81
121
  throw new Error('not a query');
82
122
  return builder;
@@ -1 +1 @@
1
- {"version":3,"file":"queryPlane.js","sourceRoot":"","sources":["../../../src/utils/queryPlane.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Authorization for the generic query plane: the endpoints that run a query the\n * client built (`LincdServerBackendProvider.*Query` behind\n * `/call/@_linked/server/*Query`, and `LincdAPI` `post_*` behind `/api/*`).\n *\n * The rules are declared per store in the app's storage config\n * (`linked.backend.storage.js`) with `withAccess` from\n * `@_linked/server-utils/utils/QueryAccess`; see `checkQueryAccess` there for\n * how a query is mapped to stores and the order the checks apply in. This\n * module turns the incoming query into the builder the store runs and the IR\n * the rules read.\n *\n * INTERIM: that per-store access model needs to be revised and rethought from\n * the ground up (see QueryAccess).\n */\nimport { fromJSON } from '@_linked/core';\nimport { lower } from '@_linked/core/queries/lower';\nimport {\n checkQueryAccess,\n type QueryOperation,\n} from '@_linked/server-utils/utils/QueryAccess';\nimport { ServerCallError } from '@_linked/server-utils/utils/ServerCallError';\nimport { getRawQueriesMode } from './rawQueries.js';\nimport { getRpcExposureMode } from './rpcExposure.js';\n\n/** The DSL-JSON tag of a query-context reference (`CONTEXT_REF_KEY` in core). */\nconst CONTEXT_REF_KEY = '@ctx';\n\n/**\n * Whether DSL-JSON carries a query-context reference (`{\"@ctx\": name}`) in any\n * position: a select or count subject, an update target, a delete id, a\n * where-clause operand or a mutation field value. Every DSL-JSON decoder in\n * core reads a context reference from this one key.\n */\nexport function containsContextRef(json: unknown): boolean {\n const seen = new Set<object>();\n const stack: unknown[] = [json];\n while (stack.length) {\n const value = stack.pop();\n if (!value || typeof value !== 'object' || seen.has(value)) continue;\n seen.add(value);\n if (Array.isArray(value)) {\n for (const item of value) stack.push(item);\n continue;\n }\n if (Object.prototype.hasOwnProperty.call(value, CONTEXT_REF_KEY)) return true;\n for (const key of Object.keys(value)) stack.push((value as any)[key]);\n }\n return false;\n}\n\n/**\n * A rehydrated query builder from the DSL-JSON a client sent. The result is\n * what the store runs, so the access check and the store see the same query.\n * A body that is not a query answers 400.\n *\n * A query-context reference (`{\"@ctx\": \"user\"}`) also answers 400. Core\n * resolves one at lowering time against a process-wide context map, which on a\n * server holds whatever some code last set there, not the caller's identity,\n * and lowering runs twice (once for the access check, once in the store), so\n * the two could even resolve it differently. A query built from client JSON\n * names its nodes by id.\n *\n * Without context references, lowering a rehydrated builder is a pure function\n * of the builder (closed and never mutated after `fromJSON`) and the shape\n * registry: the IR `authorizeGenericQuery` checks is the IR the store lowers\n * from the same builder.\n */\nexport function toQueryBuilder(query: any): any {\n // A live builder passed by backend code (never a plain object from JSON).\n if (\n query &&\n typeof query === 'object' &&\n Object.getPrototypeOf(query) !== Object.prototype &&\n typeof query.__queryKind === 'string'\n ) {\n return query;\n }\n if (containsContextRef(query)) {\n throw new ServerCallError(\n 400,\n 'Query context references (@ctx) are not accepted by the generic query endpoints'\n );\n }\n try {\n const builder = fromJSON(query);\n if (!builder || typeof builder !== 'object') throw new Error('not a query');\n return builder;\n } catch {\n throw new ServerCallError(400, 'Invalid query');\n }\n}\n\n/** The lowered IR of a query, or `undefined` when it cannot be lowered (refused). */\nexport function lowerForAccessCheck(builder: any): unknown {\n try {\n return lower(builder);\n } catch {\n return undefined;\n }\n}\n\n/**\n * Check `builder` (rehydrated from `query` with `toQueryBuilder`) against the\n * store access rules. Throws a `ServerCallError` (400/401/403) when the current\n * call may not run it; the caller then hands the same builder to the store.\n *\n * Deliberately returns nothing: a query builder is a thenable, so resolving an\n * async function with one would run it.\n */\nexport async function authorizeGenericQuery(\n operation: QueryOperation,\n query: unknown,\n builder: any,\n endpoint: string\n): Promise<void> {\n await checkQueryAccess({\n operation,\n query,\n ir: lowerForAccessCheck(builder),\n mode: getRpcExposureMode(),\n endpoint,\n });\n}\n\n/** Check a raw SPARQL query against the `rawQueries` setting (see utils/rawQueries). */\nexport async function authorizeRawQuery(query: unknown, endpoint: string): Promise<void> {\n if (typeof query !== 'string') {\n throw new ServerCallError(400, 'Invalid query');\n }\n await checkQueryAccess({\n operation: 'select',\n query,\n ir: undefined,\n raw: true,\n rawQueries: getRawQueriesMode(),\n mode: getRpcExposureMode(),\n endpoint,\n });\n}\n"]}
1
+ {"version":3,"file":"queryPlane.js","sourceRoot":"","sources":["../../../src/utils/queryPlane.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Authorization for the generic query plane: the endpoints that run a query the\n * client built (`LincdServerBackendProvider.*Query` behind\n * `/call/@_linked/server/*Query`, and `LincdAPI` `post_*` behind `/api/*`).\n *\n * The rules are declared per store in the app's storage config\n * (`linked.backend.storage.js`) with `withAccess` from\n * `@_linked/server-utils/utils/QueryAccess`; see `checkQueryAccess` there for\n * how a query is mapped to stores and the order the checks apply in. This\n * module turns the incoming query into the builder the store runs and the IR\n * the rules read.\n *\n * The host can supply the shape catalog a request's query is decoded in\n * (`setQueryCatalogResolver`), so one server can resolve the same shape IRI\n * differently per request (for example per project branch).\n *\n * INTERIM: that per-store access model needs to be revised and rethought from\n * the ground up (see QueryAccess).\n */\nimport { fromJSON } from '@_linked/core';\nimport { lower } from '@_linked/core/queries/lower';\nimport type { ShapeCatalog } from '@_linked/core/utils/ShapeCatalog';\nimport {\n checkQueryAccess,\n type QueryOperation,\n} from '@_linked/server-utils/utils/QueryAccess';\nimport { ServerCallError } from '@_linked/server-utils/utils/ServerCallError';\nimport { getRawQueriesMode } from './rawQueries.js';\nimport { getRpcExposureMode } from './rpcExposure.js';\n\n/** The DSL-JSON tag of a query-context reference (`CONTEXT_REF_KEY` in core). */\nconst CONTEXT_REF_KEY = '@ctx';\n\n/**\n * Whether DSL-JSON carries a query-context reference (`{\"@ctx\": name}`) in any\n * position: a select or count subject, an update target, a delete id, a\n * where-clause operand or a mutation field value. Every DSL-JSON decoder in\n * core reads a context reference from this one key.\n */\nexport function containsContextRef(json: unknown): boolean {\n const seen = new Set<object>();\n const stack: unknown[] = [json];\n while (stack.length) {\n const value = stack.pop();\n if (!value || typeof value !== 'object' || seen.has(value)) continue;\n seen.add(value);\n if (Array.isArray(value)) {\n for (const item of value) stack.push(item);\n continue;\n }\n if (Object.prototype.hasOwnProperty.call(value, CONTEXT_REF_KEY)) return true;\n for (const key of Object.keys(value)) stack.push((value as any)[key]);\n }\n return false;\n}\n\n/**\n * Supplies the shape catalog a generic query request is decoded in, from the\n * DSL-JSON the client sent (the request context is available through the\n * host's own means, e.g. `getCallContext`). `undefined` means the global\n * shape registry.\n */\nexport type QueryCatalogResolver = (\n query: unknown\n) => ShapeCatalog | undefined | Promise<ShapeCatalog | undefined>;\n\nlet queryCatalogResolver: QueryCatalogResolver | undefined;\n\n/**\n * Set (or, with `undefined`, clear) the host's catalog resolver for the\n * generic query endpoints. Without one, every query decodes in the global\n * shape registry.\n */\nexport function setQueryCatalogResolver(resolver: QueryCatalogResolver | undefined): void {\n queryCatalogResolver = resolver;\n}\n\n/** A live builder passed by backend code (never a plain object from JSON). */\nfunction isLiveBuilder(query: any): boolean {\n return (\n !!query &&\n typeof query === 'object' &&\n Object.getPrototypeOf(query) !== Object.prototype &&\n typeof query.__queryKind === 'string'\n );\n}\n\n/**\n * The catalog `query` is decoded in: the host resolver's answer, or\n * `undefined` (the global registry) without a resolver. A live builder already\n * carries its catalog, so the resolver is not asked for it.\n *\n * A `ServerCallError` the resolver throws is passed on as is (the host chose\n * the status); any other failure is logged and answers 500.\n */\nexport async function resolveQueryCatalog(query: unknown): Promise<ShapeCatalog | undefined> {\n const resolver = queryCatalogResolver;\n if (!resolver || isLiveBuilder(query)) return undefined;\n try {\n return await resolver(query);\n } catch (error) {\n if (ServerCallError.is(error)) throw error;\n console.error('[queryPlane] the shape catalog resolver failed:', error);\n throw new ServerCallError(500, 'Shape catalog unavailable');\n }\n}\n\n/**\n * A rehydrated query builder from the DSL-JSON a client sent, decoded in\n * `catalog` (`undefined`: the global registry, whatever catalog is active;\n * see `resolveQueryCatalog`). The builder keeps that catalog, so\n * the access-check lowering and the store's lowering both resolve against it.\n * The result is what the store runs, so the access check and the store see\n * the same query. A body that is not a query answers 400.\n *\n * A query-context reference (`{\"@ctx\": \"user\"}`) also answers 400. Core\n * resolves one at lowering time against a process-wide context map, which on a\n * server holds whatever some code last set there, not the caller's identity,\n * and lowering runs twice (once for the access check, once in the store), so\n * the two could even resolve it differently. A query built from client JSON\n * names its nodes by id.\n *\n * Without context references, lowering a rehydrated builder is a pure function\n * of the builder (closed and never mutated after `fromJSON`) and its shape\n * catalog: the IR `authorizeGenericQuery` checks is the IR the store lowers\n * from the same builder.\n */\nexport function toQueryBuilder(query: unknown, catalog?: ShapeCatalog): any {\n if (isLiveBuilder(query)) return query;\n if (containsContextRef(query)) {\n throw new ServerCallError(\n 400,\n 'Query context references (@ctx) are not accepted by the generic query endpoints'\n );\n }\n try {\n const builder = fromJSON(query as any, { catalog });\n if (!builder || typeof builder !== 'object') throw new Error('not a query');\n return builder;\n } catch {\n throw new ServerCallError(400, 'Invalid query');\n }\n}\n\n/** The lowered IR of a query, or `undefined` when it cannot be lowered (refused). */\nexport function lowerForAccessCheck(builder: any): unknown {\n try {\n return lower(builder);\n } catch {\n return undefined;\n }\n}\n\n/**\n * Check `builder` (rehydrated from `query` with `toQueryBuilder`) against the\n * store access rules. Throws a `ServerCallError` (400/401/403) when the current\n * call may not run it; the caller then hands the same builder to the store.\n *\n * Deliberately returns nothing: a query builder is a thenable, so resolving an\n * async function with one would run it.\n */\nexport async function authorizeGenericQuery(\n operation: QueryOperation,\n query: unknown,\n builder: any,\n endpoint: string\n): Promise<void> {\n await checkQueryAccess({\n operation,\n query,\n ir: lowerForAccessCheck(builder),\n mode: getRpcExposureMode(),\n endpoint,\n });\n}\n\n/** Check a raw SPARQL query against the `rawQueries` setting (see utils/rawQueries). */\nexport async function authorizeRawQuery(query: unknown, endpoint: string): Promise<void> {\n if (typeof query !== 'string') {\n throw new ServerCallError(400, 'Invalid query');\n }\n await checkQueryAccess({\n operation: 'select',\n query,\n ir: undefined,\n raw: true,\n rawQueries: getRawQueriesMode(),\n mode: getRpcExposureMode(),\n endpoint,\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.25.0",
13
+ "version": "3.1.0",
14
14
  "packageManager": "npm@12.2.0",
15
15
  "linkedPackage": true,
16
16
  "type": "module",
@@ -39,7 +39,7 @@
39
39
  ],
40
40
  "dependencies": {
41
41
  "@_linked/cli": "^1.45.5",
42
- "@_linked/core": "^2.28.0",
42
+ "@_linked/core": "^2.35.0",
43
43
  "@_linked/server-utils": "^1.12.0",
44
44
  "autoprefixer": "^10.4.18",
45
45
  "compression": "^1.7.4",
package/readme.md CHANGED
@@ -20,8 +20,10 @@ If you want to further adjust the functionality of LincdServer yourself, either
20
20
  - [Gotchas](#gotchas)
21
21
  - [Error semantics](#error-semantics)
22
22
  - [Query stores](#query-stores)
23
- - [BackendAPIStore](#backendapistore)
24
- - [GatewayStore](#gatewaystore)
23
+ - [Without `url`](#without-url)
24
+ - [With `url`](#with-url)
25
+ - [`ping`](#ping)
26
+ - [Shape catalog per request](#shape-catalog-per-request)
25
27
  - [API-only servers (`server.apiOnly`)](#api-only-servers-serverapionly)
26
28
  - [Provider lifecycle and HMR](#provider-lifecycle-and-hmr)
27
29
  - [When a provider throws, and refusing to start](#when-a-provider-throws-and-refusing-to-start)
@@ -233,73 +235,113 @@ What the caller sees depends on how it calls:
233
235
  - **`Server.call` on the backend.** Here it calls the `LinkedServer` directly.
234
236
  - A provider method that throws always rejects.
235
237
  - An unmatched call resolves `undefined` by default and rejects with a 501 `ServerCallError` when `rejectOnError` is set.
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.
238
+ - **`RemoteDataset` without `url`.** 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.
237
239
 
238
240
  ## Query stores
239
241
 
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.
242
+ `RemoteDataset` is an `IDataset` whose Linked Queries are answered by a linked server instead of
243
+ by the store itself. It ships `query.toJSON()` and resolves with the plain JSON the server
244
+ returns; it offers no raw SPARQL (`rawQuery`), dataset administration or change feed.
243
245
 
244
- | | `BackendAPIStore` | `GatewayStore` |
246
+ Export path `@_linked/server/shapes/quadstores/RemoteDataset` (named export `RemoteDataset`, so
247
+ `loadStores` resolves it by the last path segment). One class, two forms:
248
+
249
+ | | no `url` | with `url` |
245
250
  | --- | --- | --- |
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 |
251
+ | Answered by | this app's own backend | a remote data server, e.g. a host's gateway |
252
+ | Sends through | `Server.call`'s package form (`/call/@_linked/server/<method>`); on a backend, `Server`'s local shortcut | `globalThis.fetch` to `{url}/call/@_linked/server/<method>`, never `Server` |
253
+ | Authenticated by | the user's session | `Authorization: Bearer <token>` |
254
+ | `init()` | resolves at once | posts `ping`, throws a typed error when the server cannot be used |
255
+ | Typical config | `linked.frontend.datasets.json` | `linked.backend.datasets.json` |
256
+
257
+ ```typescript
258
+ export interface RemoteDatasetConfig {
259
+ url?: string; // data server base, e.g. http://localhost:4040/gateway; routes are appended
260
+ token?: string; // sent as Authorization: Bearer <token> when set and url is set
261
+ name?: string; // id = ${DATA_ROOT}/backend-api-store/<name>; does not affect routing
262
+ id?: string;
263
+ }
264
+ ```
250
265
 
251
- ### BackendAPIStore
266
+ `new RemoteDataset('main')` is the `name` form.
252
267
 
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).
268
+ ### Without `url`
256
269
 
257
- ### GatewayStore
270
+ Every query is `Server.call(packageName, { method, rejectOnError: true }, query.toJSON())`, and
271
+ this package's generic provider answers it from the backend's `LinkedStorage`. Its error
272
+ semantics are under [Error semantics](#error-semantics). `token` is ignored.
258
273
 
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.
274
+ ### With `url`
264
275
 
265
276
  ```json
266
277
  {
267
278
  "datasets": {
268
- "gateway": {
269
- "store": "@_linked/server/shapes/quadstores/GatewayStore",
270
- "config": { "endpoint": "${CN_EG_GATEWAY:-}", "credential": "${CN_APP_SERVICE_SECRET:-}" }
279
+ "appData": {
280
+ "store": "@_linked/server/shapes/quadstores/RemoteDataset",
281
+ "config": { "url": "${DATA_SERVER_URL:-}", "token": "${DATA_SERVER_TOKEN:-}" }
271
282
  }
272
283
  }
273
284
  }
274
285
  ```
275
286
 
287
+ - Each method posts `{"args": [<query.toJSON()>]}` to `{url}/call/@_linked/server/<method>`
288
+ (`selectQuery`, which also carries counts, `askQuery`, `updateQuery`, `createQuery`,
289
+ `deleteQuery`) with `Content-Type` and `Accept` set to `application/json` and, when `token` is
290
+ non-empty, `Authorization: Bearer <token>`. It resolves the parsed body; `null` is a valid
291
+ result. No other headers are sent and nothing is retried, so a write is never applied twice.
292
+ - Trailing slashes are trimmed from `url`. A `url` that is present but empty (an unset
293
+ `${DATA_SERVER_URL:-}`) never falls back to the no-url form: `init()` and every method throw
294
+ `RemoteDatasetUnreachableError` with `url: ''`.
295
+ - `init()` posts `{"args": []}` to `{url}/call/@_linked/server/ping`. A success is memoized; after
296
+ a failure the next `init()` asks again. `LinkedStorage.setDefaultDataset` does not await
297
+ `init()`, so a boot that must fail loudly awaits it itself before registering the store.
298
+ - Errors, each carrying `url` (the base), `status` (0 when no HTTP answer came back) and `code`
299
+ (the error body's `code`, when the server sent one). Messages name the url and the method,
300
+ never the token.
301
+
302
+ | Answer | Error |
303
+ | --- | --- |
304
+ | no HTTP answer | `RemoteDatasetUnreachableError` (status 0) |
305
+ | 401 or 403 | `RemoteDatasetUnauthorizedError` |
306
+ | on `ping` only: 404, 501, 502, 503, 504, or a body that is not JSON | `RemoteDatasetUnreachableError` |
307
+ | any other non-2xx | `RemoteDatasetError` |
308
+
309
+ `RemoteDatasetUnreachableError` and `RemoteDatasetUnauthorizedError` extend
310
+ `RemoteDatasetError`, which extends `Error`; match them with `instanceof`.
311
+
312
+ ### `ping`
313
+
314
+ `POST /call/@_linked/server/ping` (a `public` method of this package's generic provider) reads
315
+ nothing, changes nothing and answers `{ "ok": true }`. A plain linked server has no token
316
+ concept; a host that serves the wire with tokens checks the token itself and answers 401 for a
317
+ refused one.
318
+
319
+ ### Shape catalog per request
320
+
321
+ The generic query endpoints (`/call/@_linked/server/*Query` and `/api/*`) rebuild the client's
322
+ DSL-JSON with core's `fromJSON`. By default it decodes against the global shape registry. A
323
+ host that serves several shape sets at once (for example one per project branch) supplies a
324
+ `ShapeCatalog` (from `@_linked/core`) per request:
325
+
276
326
  ```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
- }
327
+ import { setQueryCatalogResolver } from '@_linked/server/utils/queryPlane';
328
+
329
+ // query: the DSL-JSON the client sent. Read the request itself through getCallContext().
330
+ setQueryCatalogResolver(async (query) => catalogForThisRequest(query));
282
331
  ```
283
332
 
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`.
333
+ - The resolver is called once per request, before the query is decoded. `undefined` means the
334
+ global registry. With no resolver set, behaviour is unchanged.
335
+ - The builder keeps the catalog it was decoded in, so the access check (`withAccess` rules) and
336
+ the store lower the query against the same shapes. A shape the catalog does not hold answers
337
+ `400 Invalid query`.
338
+ - A resolver that throws a `ServerCallError` answers with its status and message. Any other
339
+ throw is logged and answers `500 {"error": "Shape catalog unavailable"}`.
340
+ - A live builder passed in by backend code already carries its catalog; the resolver is not
341
+ called for it.
342
+ - `setQueryCatalogResolver(undefined)` removes the resolver. `resolveQueryCatalog(query)` and
343
+ `toQueryBuilder(query, catalog)` are exported for hosts that serve the same wire on their own
344
+ routes.
303
345
 
304
346
  ## API-only servers (`server.apiOnly`)
305
347
 
@@ -1,58 +0,0 @@
1
- import type { IDataset } from '@_linked/core/interfaces/IDataset';
2
- import type { SelectQuery } from '@_linked/core/queries/SelectQuery';
3
- import type { AskQuery } from '@_linked/core/queries/AskQuery';
4
- import type { UpdateQuery } from '@_linked/core/queries/UpdateQuery';
5
- import type { CreateQuery } from '@_linked/core/queries/CreateQuery';
6
- import type { DeleteQuery, DeleteResponse } from '@_linked/core/queries/DeleteQuery';
7
- import type { SelectResult, UpdateResult, CreateResult } from '@_linked/core/queries/IntermediateRepresentation';
8
- /**
9
- * Constructor argument for `new BackendAPIStore(config)`. Per the
10
- * docs/backlog/016-ejection-export-flow.md spec — a single JSON object
11
- * passed verbatim from linked.{frontend,backend}.datasets.json's `config`.
12
- */
13
- export interface BackendAPIStoreConfig {
14
- /** Stable name; becomes the suffix of the store URI: ${DATA_ROOT}/backend-api-store/<name>. */
15
- name?: string;
16
- /** Or pass a fully-qualified URI directly. */
17
- id?: string;
18
- }
19
- /**
20
- * Frontend-side store that routes all queries to the backend via `Server.call()`.
21
- * The backend's generic provider for this package executes them against the real store.
22
- *
23
- * A store is not a Shape. This class used to `extends Shape` and carry `@linkedShape`
24
- * with a `targetClass`, purely so that `Server.call(this, …)` could address it: the
25
- * transport derived the target from `shape instanceof Shape && shape.id` (the instance)
26
- * and `shapeClass.shape.id` (the class). Neither carried any meaning — the backend
27
- * provider never read the store it was handed, and query routing is decided by the
28
- * QUERY's shape, not the store's.
29
- *
30
- * It now uses `Server.call`'s package form instead, which addresses the backend by
31
- * package name and sends no shape fields at all. That removes the last reason for this
32
- * class to be a Shape, and with it the `new (providerShapeClass)({id})` the backend had
33
- * to perform to rebuild a store instance nobody used.
34
- */
35
- export declare class BackendAPIStore implements IDataset {
36
- /**
37
- * Kept so the config form is still accepted and a store can be named, but it is no
38
- * longer an addressing mechanism — nothing transmits or resolves it. Retained because
39
- * `linked.{frontend,backend}.datasets.json` may still pass `name`/`id`.
40
- */
41
- readonly id?: string;
42
- constructor(config?: BackendAPIStoreConfig | string | {
43
- id?: string;
44
- });
45
- init(): Promise<void>;
46
- selectQuery(query: SelectQuery): Promise<SelectResult>;
47
- askQuery(query: AskQuery): Promise<boolean>;
48
- updateQuery(query: UpdateQuery): Promise<UpdateResult>;
49
- createQuery(query: CreateQuery): Promise<CreateResult>;
50
- deleteQuery(query: DeleteQuery): Promise<DeleteResponse>;
51
- /**
52
- * Opts in to `rejectOnError`, so a failed call (HTTP error status, or no
53
- * provider on the backend) rejects with a `ServerCallError` carrying the
54
- * status and the server's message instead of reading as an empty result.
55
- * Whatever a successful call returns, `undefined` included, resolves as is.
56
- */
57
- private callBackend;
58
- }
@@ -1,71 +0,0 @@
1
- import { packageName } from '../../package.js';
2
- import { Server } from '@_linked/server-utils/utils/Server';
3
- /**
4
- * Frontend-side store that routes all queries to the backend via `Server.call()`.
5
- * The backend's generic provider for this package executes them against the real store.
6
- *
7
- * A store is not a Shape. This class used to `extends Shape` and carry `@linkedShape`
8
- * with a `targetClass`, purely so that `Server.call(this, …)` could address it: the
9
- * transport derived the target from `shape instanceof Shape && shape.id` (the instance)
10
- * and `shapeClass.shape.id` (the class). Neither carried any meaning — the backend
11
- * provider never read the store it was handed, and query routing is decided by the
12
- * QUERY's shape, not the store's.
13
- *
14
- * It now uses `Server.call`'s package form instead, which addresses the backend by
15
- * package name and sends no shape fields at all. That removes the last reason for this
16
- * class to be a Shape, and with it the `new (providerShapeClass)({id})` the backend had
17
- * to perform to rebuild a store instance nobody used.
18
- */
19
- export class BackendAPIStore {
20
- constructor(config) {
21
- if (!config)
22
- return;
23
- if (typeof config === 'string') {
24
- this.id = `${process.env.DATA_ROOT}/backend-api-store/${config}`;
25
- return;
26
- }
27
- if (config.id) {
28
- this.id = config.id;
29
- return;
30
- }
31
- if (config.name) {
32
- const name = config.name;
33
- this.id = `${process.env.DATA_ROOT}/backend-api-store/${name}`;
34
- }
35
- }
36
- async init() {
37
- // No initialization needed — queries are routed to the backend
38
- }
39
- // Queries serialize to DSL-JSON for the wire (core 2.10.0 contract flip): the
40
- // live (closed) query can't cross Server.call as-is, so we ship `toJSON()` and
41
- // the BackendAPIStoreProvider rehydrates with `fromJSON()` on the backend.
42
- selectQuery(query) {
43
- return this.callBackend('selectQuery', query.toJSON());
44
- }
45
- askQuery(query) {
46
- return this.callBackend('askQuery', query.toJSON());
47
- }
48
- updateQuery(query) {
49
- return this.callBackend('updateQuery', query.toJSON());
50
- }
51
- createQuery(query) {
52
- return this.callBackend('createQuery', query.toJSON());
53
- }
54
- deleteQuery(query) {
55
- return this.callBackend('deleteQuery', query.toJSON());
56
- }
57
- /**
58
- * Opts in to `rejectOnError`, so a failed call (HTTP error status, or no
59
- * provider on the backend) rejects with a `ServerCallError` carrying the
60
- * status and the server's message instead of reading as an empty result.
61
- * Whatever a successful call returns, `undefined` included, resolves as is.
62
- */
63
- callBackend(method, json) {
64
- // The PACKAGE form, not the shape form. It posts to
65
- // `/call/<packageName>/<method>` with a body of `{args}` only — no `shapeURI`,
66
- // no `instanceNode` — and the backend answers from this package's generic
67
- // provider without resolving or instantiating any Shape.
68
- return Server.call(packageName, { method, rejectOnError: true }, json);
69
- }
70
- }
71
- //# sourceMappingURL=BackendAPIStore.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"BackendAPIStore.js","sourceRoot":"","sources":["../../../../src/shapes/quadstores/BackendAPIStore.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAC/C,OAAO,EAAE,MAAM,EAAE,MAAM,oCAAoC,CAAC;AA4B5D;;;;;;;;;;;;;;;GAeG;AACH,MAAM,OAAO,eAAe;IAQ1B,YAAY,MAAyD;QACnE,IAAI,CAAC,MAAM;YAAE,OAAO;QACpB,IAAI,OAAO,MAAM,KAAK,QAAQ,EAAE,CAAC;YAC/B,IAAI,CAAC,EAAE,GAAG,GAAG,OAAO,CAAC,GAAG,CAAC,SAAS,sBAAsB,MAAM,EAAE,CAAC;YACjE,OAAO;QACT,CAAC;QACD,IAAK,MAAgC,CAAC,EAAE,EAAE,CAAC;YACzC,IAAI,CAAC,EAAE,GAAI,MAAgC,CAAC,EAAG,CAAC;YAChD,OAAO;QACT,CAAC;QACD,IAAK,MAAgC,CAAC,IAAI,EAAE,CAAC;YAC3C,MAAM,IAAI,GAAI,MAAgC,CAAC,IAAK,CAAC;YACrD,IAAI,CAAC,EAAE,GAAG,GAAG,OAAO,CAAC,GAAG,CAAC,SAAS,sBAAsB,IAAI,EAAE,CAAC;QACjE,CAAC;IACH,CAAC;IAED,KAAK,CAAC,IAAI;QACR,+DAA+D;IACjE,CAAC;IAED,8EAA8E;IAC9E,+EAA+E;IAC/E,2EAA2E;IAC3E,WAAW,CAAC,KAAkB;QAC5B,OAAO,IAAI,CAAC,WAAW,CAAC,aAAa,EAAE,KAAK,CAAC,MAAM,EAAE,CAAC,CAAC;IACzD,CAAC;IAED,QAAQ,CAAC,KAAe;QACtB,OAAO,IAAI,CAAC,WAAW,CAAC,UAAU,EAAE,KAAK,CAAC,MAAM,EAAE,CAAC,CAAC;IACtD,CAAC;IAED,WAAW,CAAC,KAAkB;QAC5B,OAAO,IAAI,CAAC,WAAW,CAAC,aAAa,EAAE,KAAK,CAAC,MAAM,EAAE,CAAC,CAAC;IACzD,CAAC;IAED,WAAW,CAAC,KAAkB;QAC5B,OAAO,IAAI,CAAC,WAAW,CAAC,aAAa,EAAE,KAAK,CAAC,MAAM,EAAE,CAAC,CAAC;IACzD,CAAC;IAED,WAAW,CAAC,KAAkB;QAC5B,OAAO,IAAI,CAAC,WAAW,CAAC,aAAa,EAAE,KAAK,CAAC,MAAM,EAAE,CAAC,CAAC;IACzD,CAAC;IAED;;;;;OAKG;IACK,WAAW,CAAI,MAAc,EAAE,IAAa;QAClD,oDAAoD;QACpD,+EAA+E;QAC/E,0EAA0E;QAC1E,yDAAyD;QACzD,OAAO,MAAM,CAAC,IAAI,CAAC,WAAW,EAAE,EAAE,MAAM,EAAE,aAAa,EAAE,IAAI,EAAE,EAAE,IAAI,CAAC,CAAC;IACzE,CAAC;CACF","sourcesContent":["import { packageName } from '../../package.js';\nimport { Server } from '@_linked/server-utils/utils/Server';\nimport type { IDataset } from '@_linked/core/interfaces/IDataset';\nimport type { SelectQuery } from '@_linked/core/queries/SelectQuery';\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 BackendAPIStore(config)`. Per the\n * docs/backlog/016-ejection-export-flow.md spec — a single JSON object\n * passed verbatim from linked.{frontend,backend}.datasets.json's `config`.\n */\nexport interface BackendAPIStoreConfig {\n /** Stable name; becomes the suffix of the store URI: ${DATA_ROOT}/backend-api-store/<name>. */\n name?: string;\n /** Or pass a fully-qualified URI directly. */\n id?: string;\n}\n\n/**\n * Frontend-side store that routes all queries to the backend via `Server.call()`.\n * The backend's generic provider for this package executes them against the real store.\n *\n * A store is not a Shape. This class used to `extends Shape` and carry `@linkedShape`\n * with a `targetClass`, purely so that `Server.call(this, …)` could address it: the\n * transport derived the target from `shape instanceof Shape && shape.id` (the instance)\n * and `shapeClass.shape.id` (the class). Neither carried any meaning — the backend\n * provider never read the store it was handed, and query routing is decided by the\n * QUERY's shape, not the store's.\n *\n * It now uses `Server.call`'s package form instead, which addresses the backend by\n * package name and sends no shape fields at all. That removes the last reason for this\n * class to be a Shape, and with it the `new (providerShapeClass)({id})` the backend had\n * to perform to rebuild a store instance nobody used.\n */\nexport class BackendAPIStore implements IDataset {\n /**\n * Kept so the config form is still accepted and a store can be named, but it is no\n * longer an addressing mechanism — nothing transmits or resolves it. Retained because\n * `linked.{frontend,backend}.datasets.json` may still pass `name`/`id`.\n */\n readonly id?: string;\n\n constructor(config?: BackendAPIStoreConfig | string | { id?: string }) {\n if (!config) return;\n if (typeof config === 'string') {\n this.id = `${process.env.DATA_ROOT}/backend-api-store/${config}`;\n return;\n }\n if ((config as BackendAPIStoreConfig).id) {\n this.id = (config as BackendAPIStoreConfig).id!;\n return;\n }\n if ((config as BackendAPIStoreConfig).name) {\n const name = (config as BackendAPIStoreConfig).name!;\n this.id = `${process.env.DATA_ROOT}/backend-api-store/${name}`;\n }\n }\n\n async init(): Promise<void> {\n // No initialization needed — queries are routed to the backend\n }\n\n // Queries serialize to DSL-JSON for the wire (core 2.10.0 contract flip): the\n // live (closed) query can't cross Server.call as-is, so we ship `toJSON()` and\n // the BackendAPIStoreProvider rehydrates with `fromJSON()` on the backend.\n selectQuery(query: SelectQuery): Promise<SelectResult> {\n return this.callBackend('selectQuery', query.toJSON());\n }\n\n askQuery(query: AskQuery): Promise<boolean> {\n return this.callBackend('askQuery', query.toJSON());\n }\n\n updateQuery(query: UpdateQuery): Promise<UpdateResult> {\n return this.callBackend('updateQuery', query.toJSON());\n }\n\n createQuery(query: CreateQuery): Promise<CreateResult> {\n return this.callBackend('createQuery', query.toJSON());\n }\n\n deleteQuery(query: DeleteQuery): Promise<DeleteResponse> {\n return this.callBackend('deleteQuery', query.toJSON());\n }\n\n /**\n * Opts in to `rejectOnError`, so a failed call (HTTP error status, or no\n * provider on the backend) rejects with a `ServerCallError` carrying the\n * status and the server's message instead of reading as an empty result.\n * Whatever a successful call returns, `undefined` included, resolves as is.\n */\n private callBackend<T>(method: string, json: unknown): Promise<T> {\n // The PACKAGE form, not the shape form. It posts to\n // `/call/<packageName>/<method>` with a body of `{args}` only — no `shapeURI`,\n // no `instanceNode` — and the backend answers from this package's generic\n // provider without resolving or instantiating any Shape.\n return Server.call(packageName, { method, rejectOnError: true }, json);\n }\n}\n"]}
@@ -1,85 +0,0 @@
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
- }