@_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.
- package/CHANGELOG.md +21 -0
- package/lib/esm/backend.d.ts +8 -0
- package/lib/esm/backend.js +30 -20
- package/lib/esm/backend.js.map +1 -1
- package/lib/esm/index.d.ts +1 -1
- package/lib/esm/index.js +1 -1
- package/lib/esm/index.js.map +1 -1
- package/lib/esm/shapes/LincdAPI.js +5 -5
- package/lib/esm/shapes/LincdAPI.js.map +1 -1
- package/lib/esm/shapes/quadstores/RemoteDataset.d.ts +100 -0
- package/lib/esm/shapes/quadstores/RemoteDataset.js +188 -0
- package/lib/esm/shapes/quadstores/RemoteDataset.js.map +1 -0
- package/lib/esm/utils/queryPlane.d.ts +32 -6
- package/lib/esm/utils/queryPlane.js +53 -13
- package/lib/esm/utils/queryPlane.js.map +1 -1
- package/package.json +2 -2
- package/readme.md +90 -48
- package/lib/esm/shapes/quadstores/BackendAPIStore.d.ts +0 -58
- package/lib/esm/shapes/quadstores/BackendAPIStore.js +0 -71
- package/lib/esm/shapes/quadstores/BackendAPIStore.js.map +0 -1
- package/lib/esm/shapes/quadstores/GatewayStore.d.ts +0 -85
- package/lib/esm/shapes/quadstores/GatewayStore.js +0 -143
- package/lib/esm/shapes/quadstores/GatewayStore.js.map +0 -1
|
@@ -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
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
|
23
|
-
*
|
|
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:
|
|
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
|
|
52
|
-
*
|
|
53
|
-
*
|
|
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
|
|
64
|
-
*
|
|
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
|
-
|
|
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": "
|
|
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.
|
|
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
|
-
- [
|
|
24
|
-
- [
|
|
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
|
-
- **`
|
|
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
|
-
|
|
241
|
-
|
|
242
|
-
returns;
|
|
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
|
-
|
|
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
|
-
|
|
|
247
|
-
| Sends
|
|
248
|
-
| Authenticated by | the user's session |
|
|
249
|
-
|
|
|
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
|
-
|
|
266
|
+
`new RemoteDataset('main')` is the `name` form.
|
|
252
267
|
|
|
253
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
"
|
|
269
|
-
"store": "@_linked/server/shapes/quadstores/
|
|
270
|
-
"config": { "
|
|
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
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
- `
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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
|
-
}
|