@_linked/server 3.12.1 → 3.14.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 +72 -0
- package/lib/esm/backend.d.ts +23 -5
- package/lib/esm/backend.js +116 -25
- package/lib/esm/backend.js.map +1 -1
- package/lib/esm/shapes/LincdAPI.d.ts +1 -1
- package/lib/esm/shapes/LincdAPI.js +43 -7
- package/lib/esm/shapes/LincdAPI.js.map +1 -1
- package/lib/esm/shapes/LinkedServer.d.ts +59 -39
- package/lib/esm/shapes/LinkedServer.js +158 -93
- package/lib/esm/shapes/LinkedServer.js.map +1 -1
- package/lib/esm/shapes/quadstores/RemoteDataset.d.ts +52 -20
- package/lib/esm/shapes/quadstores/RemoteDataset.js +134 -32
- package/lib/esm/shapes/quadstores/RemoteDataset.js.map +1 -1
- package/lib/esm/utils/adminChannel.d.ts +23 -51
- package/lib/esm/utils/adminChannel.js +50 -38
- package/lib/esm/utils/adminChannel.js.map +1 -1
- package/lib/esm/utils/effectiveRoutes.d.ts +6 -0
- package/lib/esm/utils/effectiveRoutes.js +2 -1
- package/lib/esm/utils/effectiveRoutes.js.map +1 -1
- package/lib/esm/utils/forwardedTransactions.d.ts +46 -0
- package/lib/esm/utils/forwardedTransactions.js +155 -0
- package/lib/esm/utils/forwardedTransactions.js.map +1 -0
- package/lib/esm/utils/graphTargets.d.ts +42 -17
- package/lib/esm/utils/graphTargets.js +62 -69
- package/lib/esm/utils/graphTargets.js.map +1 -1
- package/lib/esm/utils/linkedDependencies.d.ts +53 -7
- package/lib/esm/utils/linkedDependencies.js +130 -12
- package/lib/esm/utils/linkedDependencies.js.map +1 -1
- package/lib/esm/utils/ontologySync.d.ts +55 -31
- package/lib/esm/utils/ontologySync.js +94 -42
- package/lib/esm/utils/ontologySync.js.map +1 -1
- package/lib/esm/utils/queryPlane.d.ts +13 -0
- package/lib/esm/utils/queryPlane.js +49 -2
- package/lib/esm/utils/queryPlane.js.map +1 -1
- package/lib/esm/utils/reloadSource.js +1 -1
- package/lib/esm/utils/reloadSource.js.map +1 -1
- package/lib/esm/utils/serverTransactions.d.ts +22 -0
- package/lib/esm/utils/serverTransactions.js +81 -0
- package/lib/esm/utils/serverTransactions.js.map +1 -0
- package/lib/esm/utils/syncShapes.d.ts +123 -6
- package/lib/esm/utils/syncShapes.js +166 -35
- package/lib/esm/utils/syncShapes.js.map +1 -1
- package/package.json +4 -4
- package/readme.md +158 -38
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type express from 'express';
|
|
2
|
-
import type {
|
|
2
|
+
import type { GraphContents } from './syncShapes.js';
|
|
3
|
+
import type { GraphTargets } from '@_linked/core/utils/graphTargets';
|
|
3
4
|
import { type EffectiveRoutes } from './effectiveRoutes.js';
|
|
4
5
|
/** The environment variable that holds the control channel's secret. */
|
|
5
6
|
export declare const ADMIN_SECRET_ENV = "APP_ADMIN_SECRET";
|
|
@@ -7,65 +8,35 @@ export declare const ADMIN_SECRET_ENV = "APP_ADMIN_SECRET";
|
|
|
7
8
|
export declare const ADMIN_SECRET_HEADER = "x-app-admin-secret";
|
|
8
9
|
/** The control channel's secret: `APP_ADMIN_SECRET`, else `undefined` (no control channel). */
|
|
9
10
|
export declare function resolveAdminSecret(env?: Record<string, string | undefined>): string | undefined;
|
|
10
|
-
/** What one shape sync did. */
|
|
11
|
-
export interface ShapeSyncResult {
|
|
12
|
-
/**
|
|
13
|
-
* Shape graphs written (package graphs and the app's own graph) because their stored hash
|
|
14
|
-
* differed; on a store without graph queries, the default-graph sync's writes.
|
|
15
|
-
*/
|
|
16
|
-
written: number;
|
|
17
|
-
/** The orphan policy the sync ran with (see utils/syncShapes). */
|
|
18
|
-
orphanScope?: 'all' | 'none';
|
|
19
|
-
/** Set when no sync ran: shape sync is turned off, or there is no default dataset. */
|
|
20
|
-
skipped?: 'disabled' | 'no-default-dataset';
|
|
21
|
-
/**
|
|
22
|
-
* The shape graph sync: packages written and skipped, the graphs written, the app's own shape
|
|
23
|
-
* graph, the registered packages without `shapes.ttl` (so without a shape graph) and the
|
|
24
|
-
* default-graph shapes removed. Absent when no sync ran.
|
|
25
|
-
*/
|
|
26
|
-
shapeGraphs?: ShapeGraphsReport;
|
|
27
|
-
/**
|
|
28
|
-
* The requested packages whose source was reloaded before the sync (dev only: a built
|
|
29
|
-
* app reloads nothing). Set by `POST /admin/sync-shapes`.
|
|
30
|
-
*/
|
|
31
|
-
reloaded?: string[];
|
|
32
|
-
/** The node shape IRIs registered in the process after the reload. Set by `POST /admin/sync-shapes`. */
|
|
33
|
-
shapes?: string[];
|
|
34
|
-
/**
|
|
35
|
-
* The ontology sync: packages whose ontology graph (or, on a store without graph queries,
|
|
36
|
-
* class hierarchy) was written, packages left as they were (unchanged, or their data could not
|
|
37
|
-
* be read), and the graphs written and dropped. Set by `POST /admin/sync-shapes`.
|
|
38
|
-
*/
|
|
39
|
-
ontologies?: {
|
|
40
|
-
written: string[];
|
|
41
|
-
skipped: string[];
|
|
42
|
-
graphs?: string[];
|
|
43
|
-
droppedGraphs?: string[];
|
|
44
|
-
};
|
|
45
|
-
/** The shape check's findings, counted by severity (each is logged). Set by `POST /admin/sync-shapes`. */
|
|
46
|
-
shapeCheck?: {
|
|
47
|
-
errors: number;
|
|
48
|
-
warnings: number;
|
|
49
|
-
};
|
|
50
|
-
}
|
|
51
11
|
/** Whether `name` is a valid npm package name (and so can never be a path like `..`). */
|
|
52
12
|
export declare function isValidPackageName(name: string): boolean;
|
|
53
13
|
/**
|
|
54
14
|
* Refuse a package a host may not ask to reload: not a valid npm name, a framework
|
|
55
15
|
* (`@_linked/*`) package, or not in `allowed` (the app's own name and its direct
|
|
56
|
-
* dependencies). Throws {@link AdminRequestError}.
|
|
16
|
+
* dependencies, its `linked.unpublishedPackages` among them). Throws {@link AdminRequestError}.
|
|
57
17
|
*/
|
|
58
18
|
export declare function assertReloadablePackages(packages: string[], allowed: ReadonlySet<string>): void;
|
|
59
19
|
/** Thrown by a handler for a request the channel refuses with 400. */
|
|
60
20
|
export declare class AdminRequestError extends Error {
|
|
61
21
|
}
|
|
22
|
+
/** Thrown by a handler for a request that conflicts with how the process runs: answered 409. */
|
|
23
|
+
export declare class AdminConflictError extends Error {
|
|
24
|
+
}
|
|
25
|
+
/** The answer of `POST /admin/graph-content`. */
|
|
26
|
+
export interface GraphContentAnswer extends GraphContents {
|
|
27
|
+
/** The process's graph targets (both `'package'`, or the route answers 409). */
|
|
28
|
+
graphTargets: GraphTargets;
|
|
29
|
+
/** The requested packages whose source was reloaded first (dev only). */
|
|
30
|
+
reloaded: string[];
|
|
31
|
+
}
|
|
32
|
+
/** The body of a graph-content request: `packages` limits the answer, `reload` is reloaded first. */
|
|
33
|
+
export declare function parseGraphContentBody(body: unknown): {
|
|
34
|
+
packages?: string[];
|
|
35
|
+
reload: string[];
|
|
36
|
+
};
|
|
62
37
|
export interface AdminChannelHandlers {
|
|
63
38
|
/** Exit the process (the host restarts it). Called after the 202 is sent. */
|
|
64
39
|
exit: () => void;
|
|
65
|
-
/** Reload `packages` (when given) and sync the registered shapes into app data. */
|
|
66
|
-
syncShapes: (request: {
|
|
67
|
-
packages: string[];
|
|
68
|
-
}) => Promise<ShapeSyncResult>;
|
|
69
40
|
/**
|
|
70
41
|
* Drop the cached graph registry for `dataset` (see `POST /admin/graph-registry-changed`).
|
|
71
42
|
* Default: {@link dropGraphRegistryCache}.
|
|
@@ -73,6 +44,11 @@ export interface AdminChannelHandlers {
|
|
|
73
44
|
graphRegistryChanged?: (dataset: string) => void;
|
|
74
45
|
/** The effective graph routes (see `GET /admin/routes`). Default: {@link effectiveGraphRoutes}. */
|
|
75
46
|
routes?: () => EffectiveRoutes;
|
|
47
|
+
/** Reload `reload`, then answer the content of the app's graphs (see `POST /admin/graph-content`); without it the route answers 404. */
|
|
48
|
+
graphContent?: (request: {
|
|
49
|
+
packages?: string[];
|
|
50
|
+
reload: string[];
|
|
51
|
+
}) => Promise<GraphContentAnswer>;
|
|
76
52
|
}
|
|
77
53
|
/**
|
|
78
54
|
* Drop the process's cached graph registry after the host changed the graphs of `dataset`.
|
|
@@ -93,9 +69,5 @@ export declare function parseGraphRegistryChangedBody(body: unknown): {
|
|
|
93
69
|
* first, so the comparison does not reveal the secret's length either.
|
|
94
70
|
*/
|
|
95
71
|
export declare function adminSecretMatches(secret: string | undefined, provided: string | undefined): boolean;
|
|
96
|
-
/** The `packages` of a sync-shapes body: a list of package names, or none. */
|
|
97
|
-
export declare function parseSyncShapesBody(body: unknown): {
|
|
98
|
-
packages: string[];
|
|
99
|
-
};
|
|
100
72
|
/** Mount the control channel's routes on `app`. */
|
|
101
73
|
export declare function registerAdminRoutes(app: express.Express, secret: string | undefined, handlers: AdminChannelHandlers): void;
|
|
@@ -5,25 +5,26 @@
|
|
|
5
5
|
* 404, so a standalone app has no control channel. Every request carries the secret in
|
|
6
6
|
* the `x-app-admin-secret` header (401 otherwise).
|
|
7
7
|
*
|
|
8
|
-
* - `GET /admin/health` → 200 `{status: 'ok'}` once the server listens (
|
|
9
|
-
*
|
|
8
|
+
* - `GET /admin/health` → 200 `{status: 'ok'}` once the server listens (a boot sync, when
|
|
9
|
+
* the app writes its own graphs, has finished by then).
|
|
10
10
|
* - `POST /admin/restart` → 202, then the process exits; the host starts it again.
|
|
11
|
-
* - `POST /admin/sync-shapes` → re-syncs the app's ontologies (each registered package's
|
|
12
|
-
* ontology graph, skipped when unchanged), runs the shape check and
|
|
13
|
-
* re-syncs its shapes into its app data (each package's shape graph and the app's own,
|
|
14
|
-
* skipped when unchanged), as the boot does, and answers 200 with
|
|
15
|
-
* {@link ShapeSyncResult}, whose `shapeGraphs` reports the shape graphs. Body (optional):
|
|
16
|
-
* `{packages: string[]}`, packages whose source changed on disk: in dev they are
|
|
17
|
-
* reloaded first, so the sync sees shapes added or changed since the boot. Each
|
|
18
|
-
* name must be a valid npm package name and the app itself or one of its direct
|
|
19
|
-
* dependencies, never an `@_linked/*` framework package; anything else answers
|
|
20
|
-
* 400 `{error}`. The list never filters the syncs. A failed or timed-out sync
|
|
21
|
-
* answers 500 `{error}` (a failed ontology sync too, after the shape sync ran).
|
|
22
11
|
* - `POST /admin/graph-registry-changed` `{dataset}` → the host changed the named graphs of that
|
|
23
12
|
* dataset (a graph query through its gateway): the process drops its cached graph registry, so
|
|
24
13
|
* its next `.from(graphs.allOntologies)` read re-reads it, and answers 200
|
|
25
14
|
* `{invalidated: dataset}`. A body without a dataset name answers 400. Without the push the
|
|
26
15
|
* cache's 30-second expiry applies.
|
|
16
|
+
* - `POST /admin/graph-content` `{packages?, reload?}` → 200 {@link GraphContentAnswer}: what the
|
|
17
|
+
* app's `'package'`-mode syncs would write for each package (its shape graph's Turtle, its
|
|
18
|
+
* ontology graph's N-Triples, the hash each record gets, its sources) and for the app's own shape
|
|
19
|
+
* graph, writing nothing. A host that writes the app's graphs itself (`LINKED_GRAPH_WRITER=host`)
|
|
20
|
+
* reads them here. `reload` names packages whose source changed on disk: in dev they are
|
|
21
|
+
* reloaded first (their modules invalidated in Vite, their backends reloaded, their ontologies
|
|
22
|
+
* registered again and the shape check run), so the answer holds shapes added or changed since
|
|
23
|
+
* the boot; a built app reloads nothing. Each name must be a valid npm package name and the app
|
|
24
|
+
* itself or one of its direct dependencies, never an `@_linked/*` framework package. `packages`
|
|
25
|
+
* limits the answer. One request at a time, as the system; one that takes longer than
|
|
26
|
+
* `LINKED_SHAPE_SYNC_TIMEOUT_MS` fails with 500 and releases the queue. 400 for a malformed body
|
|
27
|
+
* or a package it may not reload; 409 when a graph target is not `'package'`.
|
|
27
28
|
* - `GET /admin/routes` → 200 {@link EffectiveRoutes}: the graph routes of every dataset the
|
|
28
29
|
* router knows (shape IRI → graph IRI) and the shapes each one writes to its default graph,
|
|
29
30
|
* as core's routing holds them once the app's storage file has run. The routes are written
|
|
@@ -49,7 +50,7 @@ export function isValidPackageName(name) {
|
|
|
49
50
|
/**
|
|
50
51
|
* Refuse a package a host may not ask to reload: not a valid npm name, a framework
|
|
51
52
|
* (`@_linked/*`) package, or not in `allowed` (the app's own name and its direct
|
|
52
|
-
* dependencies). Throws {@link AdminRequestError}.
|
|
53
|
+
* dependencies, its `linked.unpublishedPackages` among them). Throws {@link AdminRequestError}.
|
|
53
54
|
*/
|
|
54
55
|
export function assertReloadablePackages(packages, allowed) {
|
|
55
56
|
for (const pkg of packages) {
|
|
@@ -58,13 +59,39 @@ export function assertReloadablePackages(packages, allowed) {
|
|
|
58
59
|
if (pkg.startsWith('@_linked/'))
|
|
59
60
|
throw new AdminRequestError(`framework packages are not reloaded: ${pkg}`);
|
|
60
61
|
if (!allowed.has(pkg)) {
|
|
61
|
-
throw new AdminRequestError(`unknown package: ${pkg} is neither the app nor one of its direct dependencies`);
|
|
62
|
+
throw new AdminRequestError(`unknown package: ${pkg} is neither the app nor one of its direct dependencies (dependencies, devDependencies, linked.unpublishedPackages)`);
|
|
62
63
|
}
|
|
63
64
|
}
|
|
64
65
|
}
|
|
65
66
|
/** Thrown by a handler for a request the channel refuses with 400. */
|
|
66
67
|
export class AdminRequestError extends Error {
|
|
67
68
|
}
|
|
69
|
+
/** Thrown by a handler for a request that conflicts with how the process runs: answered 409. */
|
|
70
|
+
export class AdminConflictError extends Error {
|
|
71
|
+
}
|
|
72
|
+
/** The body of a graph-content request: `packages` limits the answer, `reload` is reloaded first. */
|
|
73
|
+
export function parseGraphContentBody(body) {
|
|
74
|
+
var _a;
|
|
75
|
+
if (body === undefined || body === null || body === '')
|
|
76
|
+
return { reload: [] };
|
|
77
|
+
if (typeof body !== 'object' || Array.isArray(body))
|
|
78
|
+
throw new AdminRequestError('the body must be a JSON object');
|
|
79
|
+
const names = (field) => {
|
|
80
|
+
const value = body[field];
|
|
81
|
+
if (value === undefined)
|
|
82
|
+
return undefined;
|
|
83
|
+
if (!Array.isArray(value) || value.some((p) => typeof p !== 'string' || !p)) {
|
|
84
|
+
throw new AdminRequestError(`${field} must be a list of package names`);
|
|
85
|
+
}
|
|
86
|
+
for (const p of value) {
|
|
87
|
+
if (!isValidPackageName(p))
|
|
88
|
+
throw new AdminRequestError(`not a package name: ${JSON.stringify(p)}`);
|
|
89
|
+
}
|
|
90
|
+
return [...new Set(value)];
|
|
91
|
+
};
|
|
92
|
+
const packages = names('packages');
|
|
93
|
+
return { ...(packages ? { packages } : {}), reload: (_a = names('reload')) !== null && _a !== void 0 ? _a : [] };
|
|
94
|
+
}
|
|
68
95
|
/**
|
|
69
96
|
* Drop the process's cached graph registry after the host changed the graphs of `dataset`.
|
|
70
97
|
*
|
|
@@ -95,25 +122,6 @@ export function adminSecretMatches(secret, provided) {
|
|
|
95
122
|
const digest = (value) => createHash('sha256').update(value).digest();
|
|
96
123
|
return timingSafeEqual(digest(provided), digest(secret));
|
|
97
124
|
}
|
|
98
|
-
/** The `packages` of a sync-shapes body: a list of package names, or none. */
|
|
99
|
-
export function parseSyncShapesBody(body) {
|
|
100
|
-
if (body === undefined || body === null || body === '')
|
|
101
|
-
return { packages: [] };
|
|
102
|
-
if (typeof body !== 'object') {
|
|
103
|
-
throw new AdminRequestError('the body must be a JSON object');
|
|
104
|
-
}
|
|
105
|
-
const packages = body.packages;
|
|
106
|
-
if (packages === undefined)
|
|
107
|
-
return { packages: [] };
|
|
108
|
-
if (!Array.isArray(packages) || packages.some((p) => typeof p !== 'string' || !p)) {
|
|
109
|
-
throw new AdminRequestError('packages must be a list of package names');
|
|
110
|
-
}
|
|
111
|
-
for (const p of packages) {
|
|
112
|
-
if (!isValidPackageName(p))
|
|
113
|
-
throw new AdminRequestError(`not a package name: ${JSON.stringify(p)}`);
|
|
114
|
-
}
|
|
115
|
-
return { packages: [...new Set(packages)] };
|
|
116
|
-
}
|
|
117
125
|
/** Mount the control channel's routes on `app`. */
|
|
118
126
|
export function registerAdminRoutes(app, secret, handlers) {
|
|
119
127
|
const requireAdmin = (req, res) => {
|
|
@@ -164,22 +172,26 @@ export function registerAdminRoutes(app, secret, handlers) {
|
|
|
164
172
|
((_a = handlers.graphRegistryChanged) !== null && _a !== void 0 ? _a : dropGraphRegistryCache)(dataset);
|
|
165
173
|
res.status(200).json({ invalidated: dataset });
|
|
166
174
|
});
|
|
167
|
-
app.post('/admin/
|
|
175
|
+
app.post('/admin/graph-content', async (req, res) => {
|
|
168
176
|
if (!requireAdmin(req, res))
|
|
169
177
|
return;
|
|
178
|
+
if (!handlers.graphContent) {
|
|
179
|
+
res.sendStatus(404);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
170
182
|
let request;
|
|
171
183
|
try {
|
|
172
|
-
request =
|
|
184
|
+
request = parseGraphContentBody(req.body);
|
|
173
185
|
}
|
|
174
186
|
catch (err) {
|
|
175
187
|
res.status(400).json({ error: err.message });
|
|
176
188
|
return;
|
|
177
189
|
}
|
|
178
190
|
try {
|
|
179
|
-
res.status(200).json(await handlers.
|
|
191
|
+
res.status(200).json(await handlers.graphContent(request));
|
|
180
192
|
}
|
|
181
193
|
catch (err) {
|
|
182
|
-
const status = err instanceof AdminRequestError ? 400 : 500;
|
|
194
|
+
const status = err instanceof AdminRequestError ? 400 : err instanceof AdminConflictError ? 409 : 500;
|
|
183
195
|
res.status(status).json({ error: err instanceof Error ? err.message : String(err) });
|
|
184
196
|
}
|
|
185
197
|
});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"adminChannel.js","sourceRoot":"","sources":["../../../src/utils/adminChannel.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The host→app control channel: `/admin/*` routes a host uses on an app process it\n * started. Present only when the process was started with a shared secret in\n * `APP_ADMIN_SECRET` (see {@link resolveAdminSecret}); without it every route answers\n * 404, so a standalone app has no control channel. Every request carries the secret in\n * the `x-app-admin-secret` header (401 otherwise).\n *\n * - `GET /admin/health` → 200 `{status: 'ok'}` once the server listens (the boot\n * shape sync has finished by then).\n * - `POST /admin/restart` → 202, then the process exits; the host starts it again.\n * - `POST /admin/sync-shapes` → re-syncs the app's ontologies (each registered package's\n * ontology graph, skipped when unchanged), runs the shape check and\n * re-syncs its shapes into its app data (each package's shape graph and the app's own,\n * skipped when unchanged), as the boot does, and answers 200 with\n * {@link ShapeSyncResult}, whose `shapeGraphs` reports the shape graphs. Body (optional):\n * `{packages: string[]}`, packages whose source changed on disk: in dev they are\n * reloaded first, so the sync sees shapes added or changed since the boot. Each\n * name must be a valid npm package name and the app itself or one of its direct\n * dependencies, never an `@_linked/*` framework package; anything else answers\n * 400 `{error}`. The list never filters the syncs. A failed or timed-out sync\n * answers 500 `{error}` (a failed ontology sync too, after the shape sync ran).\n * - `POST /admin/graph-registry-changed` `{dataset}` → the host changed the named graphs of that\n * dataset (a graph query through its gateway): the process drops its cached graph registry, so\n * its next `.from(graphs.allOntologies)` read re-reads it, and answers 200\n * `{invalidated: dataset}`. A body without a dataset name answers 400. Without the push the\n * cache's 30-second expiry applies.\n * - `GET /admin/routes` → 200 {@link EffectiveRoutes}: the graph routes of every dataset the\n * router knows (shape IRI → graph IRI) and the shapes each one writes to its default graph,\n * as core's routing holds them once the app's storage file has run. The routes are written\n * only in the app's storage file; this route lets a host check what actually runs.\n */\nimport { createHash, timingSafeEqual } from 'crypto';\nimport type express from 'express';\nimport { invalidateGraphRegistry } from '@_linked/core/utils/graphRegistryCache';\nimport type { ShapeGraphsReport } from './syncShapes.js';\nimport { effectiveGraphRoutes, type EffectiveRoutes } from './effectiveRoutes.js';\n\n/** The environment variable that holds the control channel's secret. */\nexport const ADMIN_SECRET_ENV = 'APP_ADMIN_SECRET';\n/** The request header that carries the secret. */\nexport const ADMIN_SECRET_HEADER = 'x-app-admin-secret';\n\n/** The control channel's secret: `APP_ADMIN_SECRET`, else `undefined` (no control channel). */\nexport function resolveAdminSecret(\n env: Record<string, string | undefined> = process.env\n): string | undefined {\n return env[ADMIN_SECRET_ENV] || undefined;\n}\n\n/** What one shape sync did. */\nexport interface ShapeSyncResult {\n /**\n * Shape graphs written (package graphs and the app's own graph) because their stored hash\n * differed; on a store without graph queries, the default-graph sync's writes.\n */\n written: number;\n /** The orphan policy the sync ran with (see utils/syncShapes). */\n orphanScope?: 'all' | 'none';\n /** Set when no sync ran: shape sync is turned off, or there is no default dataset. */\n skipped?: 'disabled' | 'no-default-dataset';\n /**\n * The shape graph sync: packages written and skipped, the graphs written, the app's own shape\n * graph, the registered packages without `shapes.ttl` (so without a shape graph) and the\n * default-graph shapes removed. Absent when no sync ran.\n */\n shapeGraphs?: ShapeGraphsReport;\n /**\n * The requested packages whose source was reloaded before the sync (dev only: a built\n * app reloads nothing). Set by `POST /admin/sync-shapes`.\n */\n reloaded?: string[];\n /** The node shape IRIs registered in the process after the reload. Set by `POST /admin/sync-shapes`. */\n shapes?: string[];\n /**\n * The ontology sync: packages whose ontology graph (or, on a store without graph queries,\n * class hierarchy) was written, packages left as they were (unchanged, or their data could not\n * be read), and the graphs written and dropped. Set by `POST /admin/sync-shapes`.\n */\n ontologies?: { written: string[]; skipped: string[]; graphs?: string[]; droppedGraphs?: string[] };\n /** The shape check's findings, counted by severity (each is logged). Set by `POST /admin/sync-shapes`. */\n shapeCheck?: { errors: number; warnings: number };\n}\n\n/** npm's package-name rule: optional `@scope/`, lower case, URL-safe, no leading `.` or `_` (a scope may start with `_`, as `@_linked` does). */\nconst NPM_NAME = /^(?:@[a-z0-9-~_][a-z0-9-._~]*\\/)?[a-z0-9-~][a-z0-9-._~]*$/;\n\n/** Whether `name` is a valid npm package name (and so can never be a path like `..`). */\nexport function isValidPackageName(name: string): boolean {\n return name.length <= 214 && NPM_NAME.test(name) && !name.split('/').some((part) => part === '.' || part === '..');\n}\n\n/**\n * Refuse a package a host may not ask to reload: not a valid npm name, a framework\n * (`@_linked/*`) package, or not in `allowed` (the app's own name and its direct\n * dependencies). Throws {@link AdminRequestError}.\n */\nexport function assertReloadablePackages(packages: string[], allowed: ReadonlySet<string>): void {\n for (const pkg of packages) {\n if (!isValidPackageName(pkg)) throw new AdminRequestError(`not a package name: ${JSON.stringify(pkg)}`);\n if (pkg.startsWith('@_linked/')) throw new AdminRequestError(`framework packages are not reloaded: ${pkg}`);\n if (!allowed.has(pkg)) {\n throw new AdminRequestError(`unknown package: ${pkg} is neither the app nor one of its direct dependencies`);\n }\n }\n}\n\n/** Thrown by a handler for a request the channel refuses with 400. */\nexport class AdminRequestError extends Error {}\n\nexport interface AdminChannelHandlers {\n /** Exit the process (the host restarts it). Called after the 202 is sent. */\n exit: () => void;\n /** Reload `packages` (when given) and sync the registered shapes into app data. */\n syncShapes: (request: { packages: string[] }) => Promise<ShapeSyncResult>;\n /**\n * Drop the cached graph registry for `dataset` (see `POST /admin/graph-registry-changed`).\n * Default: {@link dropGraphRegistryCache}.\n */\n graphRegistryChanged?: (dataset: string) => void;\n /** The effective graph routes (see `GET /admin/routes`). Default: {@link effectiveGraphRoutes}. */\n routes?: () => EffectiveRoutes;\n}\n\n/**\n * Drop the process's cached graph registry after the host changed the graphs of `dataset`.\n *\n * Every entry is dropped, not only that dataset's: an entry's key is each store's own\n * (`graphRegistryKey()`: a `FusekiStore` keys by its dataset URL, a `RemoteDataset` by the\n * dataset its server names), no store tells which key it uses, and the stores are built by the\n * app's storage config, whose aliases the server does not see. The registry is small, so the cost\n * is one registry read per dataset on its next expanding read.\n */\nexport function dropGraphRegistryCache(_dataset: string): void {\n invalidateGraphRegistry();\n}\n\n/** The `dataset` of a graph-registry-changed body: a non-empty name. */\nexport function parseGraphRegistryChangedBody(body: unknown): { dataset: string } {\n const dataset = body && typeof body === 'object' ? (body as { dataset?: unknown }).dataset : undefined;\n if (typeof dataset !== 'string' || !dataset.trim() || dataset.length > 1024) {\n throw new AdminRequestError('dataset must be the name of the dataset whose graphs changed');\n }\n return { dataset: dataset.trim() };\n}\n\n/**\n * Compare the provided secret with the configured one in constant time. Both sides are hashed\n * first, so the comparison does not reveal the secret's length either.\n */\nexport function adminSecretMatches(\n secret: string | undefined,\n provided: string | undefined\n): boolean {\n if (!secret || !provided) return false;\n const digest = (value: string) => createHash('sha256').update(value).digest();\n return timingSafeEqual(digest(provided), digest(secret));\n}\n\n/** The `packages` of a sync-shapes body: a list of package names, or none. */\nexport function parseSyncShapesBody(body: unknown): { packages: string[] } {\n if (body === undefined || body === null || body === '') return { packages: [] };\n if (typeof body !== 'object') {\n throw new AdminRequestError('the body must be a JSON object');\n }\n const packages = (body as { packages?: unknown }).packages;\n if (packages === undefined) return { packages: [] };\n if (!Array.isArray(packages) || packages.some((p) => typeof p !== 'string' || !p)) {\n throw new AdminRequestError('packages must be a list of package names');\n }\n for (const p of packages as string[]) {\n if (!isValidPackageName(p)) throw new AdminRequestError(`not a package name: ${JSON.stringify(p)}`);\n }\n return { packages: [...new Set(packages as string[])] };\n}\n\n/** Mount the control channel's routes on `app`. */\nexport function registerAdminRoutes(\n app: express.Express,\n secret: string | undefined,\n handlers: AdminChannelHandlers\n): void {\n const requireAdmin = (req: express.Request, res: express.Response): boolean => {\n if (!secret) {\n res.sendStatus(404); // no control channel in this mode\n return false;\n }\n if (!adminSecretMatches(secret, req.get(ADMIN_SECRET_HEADER))) {\n res.sendStatus(401);\n return false;\n }\n return true;\n };\n\n app.get('/admin/health', (req, res) => {\n if (!requireAdmin(req, res)) return;\n res.status(200).json({ status: 'ok' });\n });\n\n app.post('/admin/restart', (req, res) => {\n if (!requireAdmin(req, res)) return;\n res.sendStatus(202);\n setTimeout(handlers.exit, 50);\n });\n\n app.get('/admin/routes', (req, res) => {\n if (!requireAdmin(req, res)) return;\n try {\n res.status(200).json((handlers.routes ?? effectiveGraphRoutes)());\n } catch (err) {\n res.status(500).json({ error: err instanceof Error ? err.message : String(err) });\n }\n });\n\n app.post('/admin/graph-registry-changed', (req, res) => {\n if (!requireAdmin(req, res)) return;\n let dataset: string;\n try {\n ({ dataset } = parseGraphRegistryChangedBody(req.body));\n } catch (err) {\n res.status(400).json({ error: (err as Error).message });\n return;\n }\n (handlers.graphRegistryChanged ?? dropGraphRegistryCache)(dataset);\n res.status(200).json({ invalidated: dataset });\n });\n\n app.post('/admin/sync-shapes', async (req, res) => {\n if (!requireAdmin(req, res)) return;\n let request: { packages: string[] };\n try {\n request = parseSyncShapesBody(req.body);\n } catch (err) {\n res.status(400).json({ error: (err as Error).message });\n return;\n }\n try {\n res.status(200).json(await handlers.syncShapes(request));\n } catch (err) {\n const status = err instanceof AdminRequestError ? 400 : 500;\n res.status(status).json({ error: err instanceof Error ? err.message : String(err) });\n }\n });\n}\n"]}
|
|
1
|
+
{"version":3,"file":"adminChannel.js","sourceRoot":"","sources":["../../../src/utils/adminChannel.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The host→app control channel: `/admin/*` routes a host uses on an app process it\n * started. Present only when the process was started with a shared secret in\n * `APP_ADMIN_SECRET` (see {@link resolveAdminSecret}); without it every route answers\n * 404, so a standalone app has no control channel. Every request carries the secret in\n * the `x-app-admin-secret` header (401 otherwise).\n *\n * - `GET /admin/health` → 200 `{status: 'ok'}` once the server listens (a boot sync, when\n * the app writes its own graphs, has finished by then).\n * - `POST /admin/restart` → 202, then the process exits; the host starts it again.\n * - `POST /admin/graph-registry-changed` `{dataset}` → the host changed the named graphs of that\n * dataset (a graph query through its gateway): the process drops its cached graph registry, so\n * its next `.from(graphs.allOntologies)` read re-reads it, and answers 200\n * `{invalidated: dataset}`. A body without a dataset name answers 400. Without the push the\n * cache's 30-second expiry applies.\n * - `POST /admin/graph-content` `{packages?, reload?}` → 200 {@link GraphContentAnswer}: what the\n * app's `'package'`-mode syncs would write for each package (its shape graph's Turtle, its\n * ontology graph's N-Triples, the hash each record gets, its sources) and for the app's own shape\n * graph, writing nothing. A host that writes the app's graphs itself (`LINKED_GRAPH_WRITER=host`)\n * reads them here. `reload` names packages whose source changed on disk: in dev they are\n * reloaded first (their modules invalidated in Vite, their backends reloaded, their ontologies\n * registered again and the shape check run), so the answer holds shapes added or changed since\n * the boot; a built app reloads nothing. Each name must be a valid npm package name and the app\n * itself or one of its direct dependencies, never an `@_linked/*` framework package. `packages`\n * limits the answer. One request at a time, as the system; one that takes longer than\n * `LINKED_SHAPE_SYNC_TIMEOUT_MS` fails with 500 and releases the queue. 400 for a malformed body\n * or a package it may not reload; 409 when a graph target is not `'package'`.\n * - `GET /admin/routes` → 200 {@link EffectiveRoutes}: the graph routes of every dataset the\n * router knows (shape IRI → graph IRI) and the shapes each one writes to its default graph,\n * as core's routing holds them once the app's storage file has run. The routes are written\n * only in the app's storage file; this route lets a host check what actually runs.\n */\nimport { createHash, timingSafeEqual } from 'crypto';\nimport type express from 'express';\nimport { invalidateGraphRegistry } from '@_linked/core/utils/graphRegistryCache';\nimport type { GraphContents } from './syncShapes.js';\nimport type { GraphTargets } from '@_linked/core/utils/graphTargets';\nimport { effectiveGraphRoutes, type EffectiveRoutes } from './effectiveRoutes.js';\n\n/** The environment variable that holds the control channel's secret. */\nexport const ADMIN_SECRET_ENV = 'APP_ADMIN_SECRET';\n/** The request header that carries the secret. */\nexport const ADMIN_SECRET_HEADER = 'x-app-admin-secret';\n\n/** The control channel's secret: `APP_ADMIN_SECRET`, else `undefined` (no control channel). */\nexport function resolveAdminSecret(\n env: Record<string, string | undefined> = process.env\n): string | undefined {\n return env[ADMIN_SECRET_ENV] || undefined;\n}\n\n/** npm's package-name rule: optional `@scope/`, lower case, URL-safe, no leading `.` or `_` (a scope may start with `_`, as `@_linked` does). */\nconst NPM_NAME = /^(?:@[a-z0-9-~_][a-z0-9-._~]*\\/)?[a-z0-9-~][a-z0-9-._~]*$/;\n\n/** Whether `name` is a valid npm package name (and so can never be a path like `..`). */\nexport function isValidPackageName(name: string): boolean {\n return name.length <= 214 && NPM_NAME.test(name) && !name.split('/').some((part) => part === '.' || part === '..');\n}\n\n/**\n * Refuse a package a host may not ask to reload: not a valid npm name, a framework\n * (`@_linked/*`) package, or not in `allowed` (the app's own name and its direct\n * dependencies, its `linked.unpublishedPackages` among them). Throws {@link AdminRequestError}.\n */\nexport function assertReloadablePackages(packages: string[], allowed: ReadonlySet<string>): void {\n for (const pkg of packages) {\n if (!isValidPackageName(pkg)) throw new AdminRequestError(`not a package name: ${JSON.stringify(pkg)}`);\n if (pkg.startsWith('@_linked/')) throw new AdminRequestError(`framework packages are not reloaded: ${pkg}`);\n if (!allowed.has(pkg)) {\n throw new AdminRequestError(`unknown package: ${pkg} is neither the app nor one of its direct dependencies (dependencies, devDependencies, linked.unpublishedPackages)`);\n }\n }\n}\n\n/** Thrown by a handler for a request the channel refuses with 400. */\nexport class AdminRequestError extends Error {}\n\n/** Thrown by a handler for a request that conflicts with how the process runs: answered 409. */\nexport class AdminConflictError extends Error {}\n\n/** The answer of `POST /admin/graph-content`. */\nexport interface GraphContentAnswer extends GraphContents {\n /** The process's graph targets (both `'package'`, or the route answers 409). */\n graphTargets: GraphTargets;\n /** The requested packages whose source was reloaded first (dev only). */\n reloaded: string[];\n}\n\n/** The body of a graph-content request: `packages` limits the answer, `reload` is reloaded first. */\nexport function parseGraphContentBody(body: unknown): { packages?: string[]; reload: string[] } {\n if (body === undefined || body === null || body === '') return { reload: [] };\n if (typeof body !== 'object' || Array.isArray(body)) throw new AdminRequestError('the body must be a JSON object');\n const names = (field: 'packages' | 'reload'): string[] | undefined => {\n const value = (body as Record<string, unknown>)[field];\n if (value === undefined) return undefined;\n if (!Array.isArray(value) || value.some((p) => typeof p !== 'string' || !p)) {\n throw new AdminRequestError(`${field} must be a list of package names`);\n }\n for (const p of value as string[]) {\n if (!isValidPackageName(p)) throw new AdminRequestError(`not a package name: ${JSON.stringify(p)}`);\n }\n return [...new Set(value as string[])];\n };\n const packages = names('packages');\n return { ...(packages ? { packages } : {}), reload: names('reload') ?? [] };\n}\n\nexport interface AdminChannelHandlers {\n /** Exit the process (the host restarts it). Called after the 202 is sent. */\n exit: () => void;\n /**\n * Drop the cached graph registry for `dataset` (see `POST /admin/graph-registry-changed`).\n * Default: {@link dropGraphRegistryCache}.\n */\n graphRegistryChanged?: (dataset: string) => void;\n /** The effective graph routes (see `GET /admin/routes`). Default: {@link effectiveGraphRoutes}. */\n routes?: () => EffectiveRoutes;\n /** Reload `reload`, then answer the content of the app's graphs (see `POST /admin/graph-content`); without it the route answers 404. */\n graphContent?: (request: { packages?: string[]; reload: string[] }) => Promise<GraphContentAnswer>;\n}\n\n/**\n * Drop the process's cached graph registry after the host changed the graphs of `dataset`.\n *\n * Every entry is dropped, not only that dataset's: an entry's key is each store's own\n * (`graphRegistryKey()`: a `FusekiStore` keys by its dataset URL, a `RemoteDataset` by the\n * dataset its server names), no store tells which key it uses, and the stores are built by the\n * app's storage config, whose aliases the server does not see. The registry is small, so the cost\n * is one registry read per dataset on its next expanding read.\n */\nexport function dropGraphRegistryCache(_dataset: string): void {\n invalidateGraphRegistry();\n}\n\n/** The `dataset` of a graph-registry-changed body: a non-empty name. */\nexport function parseGraphRegistryChangedBody(body: unknown): { dataset: string } {\n const dataset = body && typeof body === 'object' ? (body as { dataset?: unknown }).dataset : undefined;\n if (typeof dataset !== 'string' || !dataset.trim() || dataset.length > 1024) {\n throw new AdminRequestError('dataset must be the name of the dataset whose graphs changed');\n }\n return { dataset: dataset.trim() };\n}\n\n/**\n * Compare the provided secret with the configured one in constant time. Both sides are hashed\n * first, so the comparison does not reveal the secret's length either.\n */\nexport function adminSecretMatches(\n secret: string | undefined,\n provided: string | undefined\n): boolean {\n if (!secret || !provided) return false;\n const digest = (value: string) => createHash('sha256').update(value).digest();\n return timingSafeEqual(digest(provided), digest(secret));\n}\n\n/** Mount the control channel's routes on `app`. */\nexport function registerAdminRoutes(\n app: express.Express,\n secret: string | undefined,\n handlers: AdminChannelHandlers\n): void {\n const requireAdmin = (req: express.Request, res: express.Response): boolean => {\n if (!secret) {\n res.sendStatus(404); // no control channel in this mode\n return false;\n }\n if (!adminSecretMatches(secret, req.get(ADMIN_SECRET_HEADER))) {\n res.sendStatus(401);\n return false;\n }\n return true;\n };\n\n app.get('/admin/health', (req, res) => {\n if (!requireAdmin(req, res)) return;\n res.status(200).json({ status: 'ok' });\n });\n\n app.post('/admin/restart', (req, res) => {\n if (!requireAdmin(req, res)) return;\n res.sendStatus(202);\n setTimeout(handlers.exit, 50);\n });\n\n app.get('/admin/routes', (req, res) => {\n if (!requireAdmin(req, res)) return;\n try {\n res.status(200).json((handlers.routes ?? effectiveGraphRoutes)());\n } catch (err) {\n res.status(500).json({ error: err instanceof Error ? err.message : String(err) });\n }\n });\n\n app.post('/admin/graph-registry-changed', (req, res) => {\n if (!requireAdmin(req, res)) return;\n let dataset: string;\n try {\n ({ dataset } = parseGraphRegistryChangedBody(req.body));\n } catch (err) {\n res.status(400).json({ error: (err as Error).message });\n return;\n }\n (handlers.graphRegistryChanged ?? dropGraphRegistryCache)(dataset);\n res.status(200).json({ invalidated: dataset });\n });\n\n app.post('/admin/graph-content', async (req, res) => {\n if (!requireAdmin(req, res)) return;\n if (!handlers.graphContent) {\n res.sendStatus(404);\n return;\n }\n let request: { packages?: string[]; reload: string[] };\n try {\n request = parseGraphContentBody(req.body);\n } catch (err) {\n res.status(400).json({ error: (err as Error).message });\n return;\n }\n try {\n res.status(200).json(await handlers.graphContent(request));\n } catch (err) {\n const status = err instanceof AdminRequestError ? 400 : err instanceof AdminConflictError ? 409 : 500;\n res.status(status).json({ error: err instanceof Error ? err.message : String(err) });\n }\n });\n}\n"]}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type GraphTargets } from '@_linked/core/utils/graphTargets';
|
|
2
|
+
import { type GraphWriter } from './graphTargets.js';
|
|
2
3
|
/** One dataset the router knows, and its graph routes. */
|
|
3
4
|
export interface DatasetRoutes {
|
|
4
5
|
/**
|
|
@@ -33,6 +34,11 @@ export interface EffectiveRoutes {
|
|
|
33
34
|
* so a host can tell where {@link graphTargets} came from.
|
|
34
35
|
*/
|
|
35
36
|
graphTargetEnv: Record<string, string>;
|
|
37
|
+
/**
|
|
38
|
+
* Who writes the app's shape and ontology graphs (`LINKED_GRAPH_WRITER`): `'app'`, or `'host'`
|
|
39
|
+
* when a host writes them and the process writes none.
|
|
40
|
+
*/
|
|
41
|
+
graphWriter: GraphWriter;
|
|
36
42
|
}
|
|
37
43
|
/** The routes of every dataset the router knows (see {@link EffectiveRoutes}). */
|
|
38
44
|
export declare function effectiveGraphRoutes(env?: Record<string, string | undefined>): EffectiveRoutes;
|
|
@@ -9,7 +9,7 @@ import { getShapeGraphs, graphRoutingFor } from '@_linked/core/utils/graphRoutin
|
|
|
9
9
|
import { getAllShapeClasses } from '@_linked/core/utils/ShapeClass';
|
|
10
10
|
import { graphs } from '@_linked/core/ontologies/graphs';
|
|
11
11
|
import { getGraphTargets } from '@_linked/core/utils/graphTargets';
|
|
12
|
-
import { graphTargetEnv } from './graphTargets.js';
|
|
12
|
+
import { graphTargetEnv, graphWriterFromEnv } from './graphTargets.js';
|
|
13
13
|
/** The routes of every dataset the router knows (see {@link EffectiveRoutes}). */
|
|
14
14
|
export function effectiveGraphRoutes(env = process.env) {
|
|
15
15
|
var _a, _b;
|
|
@@ -39,6 +39,7 @@ export function effectiveGraphRoutes(env = process.env) {
|
|
|
39
39
|
}),
|
|
40
40
|
graphTargets: getGraphTargets(),
|
|
41
41
|
graphTargetEnv: graphTargetEnv(env),
|
|
42
|
+
graphWriter: graphWriterFromEnv(env),
|
|
42
43
|
};
|
|
43
44
|
}
|
|
44
45
|
//# sourceMappingURL=effectiveRoutes.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"effectiveRoutes.js","sourceRoot":"","sources":["../../../src/utils/effectiveRoutes.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,OAAO,EAAE,aAAa,EAAE,MAAM,mCAAmC,CAAC;AAClE,OAAO,EAAE,cAAc,EAAE,eAAe,EAAE,MAAM,kCAAkC,CAAC;AACnF,OAAO,EAAE,kBAAkB,EAAE,MAAM,gCAAgC,CAAC;AACpE,OAAO,EAAE,MAAM,EAAE,MAAM,iCAAiC,CAAC;AAEzD,OAAO,EAAE,eAAe,EAAqB,MAAM,kCAAkC,CAAC;AACtF,OAAO,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;
|
|
1
|
+
{"version":3,"file":"effectiveRoutes.js","sourceRoot":"","sources":["../../../src/utils/effectiveRoutes.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,OAAO,EAAE,aAAa,EAAE,MAAM,mCAAmC,CAAC;AAClE,OAAO,EAAE,cAAc,EAAE,eAAe,EAAE,MAAM,kCAAkC,CAAC;AACnF,OAAO,EAAE,kBAAkB,EAAE,MAAM,gCAAgC,CAAC;AACpE,OAAO,EAAE,MAAM,EAAE,MAAM,iCAAiC,CAAC;AAEzD,OAAO,EAAE,eAAe,EAAqB,MAAM,kCAAkC,CAAC;AACtF,OAAO,EAAE,cAAc,EAAE,kBAAkB,EAAoB,MAAM,mBAAmB,CAAC;AA4CzF,kFAAkF;AAClF,MAAM,UAAU,oBAAoB,CAAC,MAA0C,OAAO,CAAC,GAAG;;IACxF,MAAM,QAAQ,GAAG,CAAC,GAAG,aAAa,CAAC,WAAW,EAAE,CAAC,CAAC;IAClD,MAAM,cAAc,GAAG,aAAa,CAAC,iBAAiB,EAAE,CAAC;IACzD,MAAM,kBAAkB,GAAG,IAAI,GAAG,CAAqB,QAAQ,CAAC,GAAG,CAAC,CAAC,EAAE,EAAE,EAAE,CAAC,CAAC,EAAE,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC;IACvF,KAAK,MAAM,CAAC,GAAG,EAAE,UAAU,CAAC,IAAI,kBAAkB,EAAE,EAAE,CAAC;QACrD,MAAM,OAAO,GAAG,aAAa,CAAC,uBAAuB,CAAC,UAAU,CAAC,CAAC;QAClE,MAAM,IAAI,GAAG,OAAO,IAAI,kBAAkB,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC;QACxD,IAAI,CAAC,IAAI;YAAE,SAAS;QACpB,MAAM,KAAK,GAAG,MAAA,MAAA,eAAe,CAAC,OAAO,CAAC,0CAAE,aAAa,CAAC,GAAG,CAAC,mCAAI,MAAM,CAAC,OAAO,CAAC;QAC7E,IAAI,KAAK,KAAK,MAAM,CAAC,OAAO;YAAE,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;IAC/C,CAAC;IACD,OAAO;QACL,QAAQ,EAAE,QAAQ,CAAC,GAAG,CAAC,CAAC,OAAO,EAAE,EAAE;;YACjC,MAAM,EAAE,GAAI,OAA4B,CAAC,EAAE,CAAC;YAC5C,OAAO;gBACL,KAAK,EAAE,CAAA,MAAA,OAAO,aAAP,OAAO,uBAAP,OAAO,CAAE,WAAW,0CAAE,IAAI,KAAI,SAAS;gBAC9C,GAAG,CAAC,OAAO,EAAE,KAAK,QAAQ,IAAI,EAAE,CAAC,CAAC,CAAC,EAAE,EAAE,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;gBAC/C,OAAO,EAAE,OAAO,KAAK,cAAc;gBACnC,MAAM,EAAE,cAAc,CAAC,OAAO,CAAC;gBAC/B,kBAAkB,EAAE,CAAC,MAAA,kBAAkB,CAAC,GAAG,CAAC,OAAO,CAAC,mCAAI,EAAE,CAAC,CAAC,IAAI,EAAE;aACnE,CAAC;QACJ,CAAC,CAAC;QACF,YAAY,EAAE,eAAe,EAAE;QAC/B,cAAc,EAAE,cAAc,CAAC,GAAG,CAAC;QACnC,WAAW,EAAE,kBAAkB,CAAC,GAAG,CAAC;KACrC,CAAC;AACJ,CAAC","sourcesContent":["/**\n * The graph routes the process runs with, as core's routing holds them once the app's storage\n * file has run (`LinkedStorage.setDatasetForShapes(dataset, shapes, {graph})`, or `loadStores`\n * for a datasets config that still carries routes). Answered by `GET /admin/routes`, so a host\n * can compare what it wrote into the storage file with what actually runs.\n */\nimport { LinkedStorage } from '@_linked/core/utils/LinkedStorage';\nimport { getShapeGraphs, graphRoutingFor } from '@_linked/core/utils/graphRouting';\nimport { getAllShapeClasses } from '@_linked/core/utils/ShapeClass';\nimport { graphs } from '@_linked/core/ontologies/graphs';\nimport type { IDataset } from '@_linked/core/interfaces/IDataset';\nimport { getGraphTargets, type GraphTargets } from '@_linked/core/utils/graphTargets';\nimport { graphTargetEnv, graphWriterFromEnv, type GraphWriter } from './graphTargets.js';\n\n/** One dataset the router knows, and its graph routes. */\nexport interface DatasetRoutes {\n /**\n * The store's class name (`FusekiStore`, `RemoteDataset`, …). The server never sees the\n * aliases of the storage config, so a dataset is told apart by this, `default` and `id`.\n */\n store: string;\n /** The store's `id`, when it has one (a named `RemoteDataset` does). */\n id?: string;\n /** Whether this is the default dataset (the one for every shape without a pin). */\n default: boolean;\n /** The dataset's routes as registered: shape IRI → graph IRI. */\n routes: Record<string, string>;\n /**\n * The registered shapes that resolve to this dataset and whose instances are written to its\n * default graph: no route of their own and no routed super-shape. Sorted.\n */\n defaultGraphShapes: string[];\n}\n\n/** The body of `GET /admin/routes`. */\nexport interface EffectiveRoutes {\n /** Every dataset the router knows, the default one first, then in the order they were pinned. */\n datasets: DatasetRoutes[];\n /**\n * The process's graph targets (core's `getGraphTargets()`: `'package'`, `'default'` or a shared\n * graph IRI each), one global setting for every dataset: where the boot syncs write and where\n * `graphs.allOntologies` / `graphs.allShapes` read.\n */\n graphTargets: GraphTargets;\n /**\n * `LINKED_ONTOLOGY_GRAPH` / `LINKED_SHAPE_GRAPH` as the process has them (unset ones left out),\n * so a host can tell where {@link graphTargets} came from.\n */\n graphTargetEnv: Record<string, string>;\n /**\n * Who writes the app's shape and ontology graphs (`LINKED_GRAPH_WRITER`): `'app'`, or `'host'`\n * when a host writes them and the process writes none.\n */\n graphWriter: GraphWriter;\n}\n\n/** The routes of every dataset the router knows (see {@link EffectiveRoutes}). */\nexport function effectiveGraphRoutes(env: Record<string, string | undefined> = process.env): EffectiveRoutes {\n const datasets = [...LinkedStorage.getDatasets()];\n const defaultDataset = LinkedStorage.getDefaultDataset();\n const defaultGraphShapes = new Map<IDataset, string[]>(datasets.map((ds) => [ds, []]));\n for (const [iri, shapeClass] of getAllShapeClasses()) {\n const dataset = LinkedStorage.getDatasetForShapeClass(shapeClass);\n const list = dataset && defaultGraphShapes.get(dataset);\n if (!list) continue;\n const graph = graphRoutingFor(dataset)?.graphForShape(iri) ?? graphs.default;\n if (graph === graphs.default) list.push(iri);\n }\n return {\n datasets: datasets.map((dataset) => {\n const id = (dataset as { id?: unknown }).id;\n return {\n store: dataset?.constructor?.name || 'unknown',\n ...(typeof id === 'string' && id ? { id } : {}),\n default: dataset === defaultDataset,\n routes: getShapeGraphs(dataset),\n defaultGraphShapes: (defaultGraphShapes.get(dataset) ?? []).sort(),\n };\n }),\n graphTargets: getGraphTargets(),\n graphTargetEnv: graphTargetEnv(env),\n graphWriter: graphWriterFromEnv(env),\n };\n}\n"]}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { Transaction } from '@_linked/core/utils/Transaction';
|
|
2
|
+
/** A transaction was asked of a forwarder whose stores do not all hold transactions. */
|
|
3
|
+
export declare class TransactionUnsupportedError extends Error {
|
|
4
|
+
readonly code = "transactions-unsupported";
|
|
5
|
+
constructor(message: string);
|
|
6
|
+
}
|
|
7
|
+
/** A txId this forwarder does not hold: never begun here, ended, failed, or expired. */
|
|
8
|
+
export declare class TransactionUnknownError extends Error {
|
|
9
|
+
readonly code = "transaction-unknown";
|
|
10
|
+
constructor(txId: string);
|
|
11
|
+
}
|
|
12
|
+
/** A txId presented by another caller than the one that began it. */
|
|
13
|
+
export declare class TransactionNotYoursError extends Error {
|
|
14
|
+
readonly code = "transaction-not-yours";
|
|
15
|
+
constructor(txId: string);
|
|
16
|
+
}
|
|
17
|
+
/** The query a transaction view stands for: core's `inTransaction` view has it as prototype. */
|
|
18
|
+
export declare function withoutTxId<Q extends object>(query: Q): Q;
|
|
19
|
+
export interface ForwardedTransactionsOptions {
|
|
20
|
+
/** Idle time after which a transaction is rolled back and forgotten, in ms (default 30 000). */
|
|
21
|
+
timeoutMs?: number;
|
|
22
|
+
}
|
|
23
|
+
export declare class ForwardedTransactions {
|
|
24
|
+
private readonly held;
|
|
25
|
+
private readonly timeoutMs;
|
|
26
|
+
constructor(options?: ForwardedTransactionsOptions);
|
|
27
|
+
/** How many transactions are open (for tests and diagnostics). */
|
|
28
|
+
get size(): number;
|
|
29
|
+
/** Hold `tx` for `caller`; answers its id. */
|
|
30
|
+
begin(caller: string, tx: Transaction): string;
|
|
31
|
+
/**
|
|
32
|
+
* Run `query` (which carries a txId) in its transaction: the caller must be the one that began
|
|
33
|
+
* it; then `check` (the rights of the query, as if it were sent alone) and `run` with the query
|
|
34
|
+
* outside its view. A refusal by `check` and a failure of `run` roll the transaction back. The
|
|
35
|
+
* transaction does not expire while the query runs.
|
|
36
|
+
*/
|
|
37
|
+
query<R>(query: object, caller: string, check: (tx: Transaction) => Promise<void> | void, run: (tx: Transaction, query: any) => Promise<R>): Promise<R>;
|
|
38
|
+
/** Commit: the caller's own transaction, else refused; unknown or expired rejects. */
|
|
39
|
+
commit(txId: string, caller: string): Promise<void>;
|
|
40
|
+
/** Roll back: the caller's own transaction, else refused; an unknown txId resolves (a no-op). */
|
|
41
|
+
rollback(txId: string, caller: string): Promise<void>;
|
|
42
|
+
private own;
|
|
43
|
+
private touch;
|
|
44
|
+
/** Forget `txId` (once), rolling its transaction back when asked to. */
|
|
45
|
+
private drop;
|
|
46
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The store transactions a forwarder holds for its callers (core report 103, "Forwarding contract").
|
|
3
|
+
*
|
|
4
|
+
* A forwarder (this server's query plane) answers `beginTransaction()` with an id of its own and
|
|
5
|
+
* keeps a core `Transaction` for it. Each later query that carries the id runs in that
|
|
6
|
+
* transaction, which opens a transaction in the store the query routes to at its first query
|
|
7
|
+
* there (and warns, as core does, when a second store is reached). The id is bound to the caller
|
|
8
|
+
* that began it: anyone else presenting it is refused, and the transaction is left as it was.
|
|
9
|
+
*
|
|
10
|
+
* - A query that fails, or that a check refuses, fails the transaction: it is rolled back in
|
|
11
|
+
* every store and forgotten, so a later `commit` rejects as unknown and `rollback` resolves
|
|
12
|
+
* (a select whose query context is not ready, core's `UnresolvedContextError`, did not run and
|
|
13
|
+
* fails nothing). A forwarder that refuses a query before it gets here calls `rollback` itself.
|
|
14
|
+
* - An idle transaction (no query running, and none, no commit or rollback for `timeoutMs`,
|
|
15
|
+
* default 30 s) is rolled back and forgotten.
|
|
16
|
+
* - A forwarder holds transactions only when every store it routes to does; it checks that
|
|
17
|
+
* itself (see `supportsTransactions` in core) before it begins one or runs a query in one.
|
|
18
|
+
*/
|
|
19
|
+
import { randomUUID } from 'node:crypto';
|
|
20
|
+
import { brandLinkedError, isLinkedError } from '@_linked/core/utils/linkedError';
|
|
21
|
+
import { UnresolvedContextError } from '@_linked/core/queries/QueryContext';
|
|
22
|
+
import { txIdOf } from '@_linked/core/queries/transactionQuery';
|
|
23
|
+
/** A transaction was asked of a forwarder whose stores do not all hold transactions. */
|
|
24
|
+
export class TransactionUnsupportedError extends Error {
|
|
25
|
+
constructor(message) {
|
|
26
|
+
super(message);
|
|
27
|
+
this.code = 'transactions-unsupported';
|
|
28
|
+
this.name = new.target.name;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/** A txId this forwarder does not hold: never begun here, ended, failed, or expired. */
|
|
32
|
+
export class TransactionUnknownError extends Error {
|
|
33
|
+
constructor(txId) {
|
|
34
|
+
super(`transaction ${txId} is unknown or expired`);
|
|
35
|
+
this.code = 'transaction-unknown';
|
|
36
|
+
this.name = new.target.name;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/** A txId presented by another caller than the one that began it. */
|
|
40
|
+
export class TransactionNotYoursError extends Error {
|
|
41
|
+
constructor(txId) {
|
|
42
|
+
super(`transaction ${txId} was begun by another caller`);
|
|
43
|
+
this.code = 'transaction-not-yours';
|
|
44
|
+
this.name = new.target.name;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
brandLinkedError(TransactionUnsupportedError, '@_linked/server', 'TransactionUnsupportedError');
|
|
48
|
+
brandLinkedError(TransactionUnknownError, '@_linked/server', 'TransactionUnknownError');
|
|
49
|
+
brandLinkedError(TransactionNotYoursError, '@_linked/server', 'TransactionNotYoursError');
|
|
50
|
+
/** The query a transaction view stands for: core's `inTransaction` view has it as prototype. */
|
|
51
|
+
export function withoutTxId(query) {
|
|
52
|
+
if (!Object.prototype.hasOwnProperty.call(query, 'txId'))
|
|
53
|
+
return query;
|
|
54
|
+
return Object.getPrototypeOf(query);
|
|
55
|
+
}
|
|
56
|
+
export class ForwardedTransactions {
|
|
57
|
+
constructor(options = {}) {
|
|
58
|
+
var _a;
|
|
59
|
+
this.held = new Map();
|
|
60
|
+
this.timeoutMs = (_a = options.timeoutMs) !== null && _a !== void 0 ? _a : 30000;
|
|
61
|
+
}
|
|
62
|
+
/** How many transactions are open (for tests and diagnostics). */
|
|
63
|
+
get size() {
|
|
64
|
+
return this.held.size;
|
|
65
|
+
}
|
|
66
|
+
/** Hold `tx` for `caller`; answers its id. */
|
|
67
|
+
begin(caller, tx) {
|
|
68
|
+
const txId = randomUUID();
|
|
69
|
+
const held = { tx, caller, running: 0 };
|
|
70
|
+
this.held.set(txId, held);
|
|
71
|
+
this.touch(txId, held);
|
|
72
|
+
return txId;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Run `query` (which carries a txId) in its transaction: the caller must be the one that began
|
|
76
|
+
* it; then `check` (the rights of the query, as if it were sent alone) and `run` with the query
|
|
77
|
+
* outside its view. A refusal by `check` and a failure of `run` roll the transaction back. The
|
|
78
|
+
* transaction does not expire while the query runs.
|
|
79
|
+
*/
|
|
80
|
+
async query(query, caller, check, run) {
|
|
81
|
+
const txId = txIdOf(query);
|
|
82
|
+
if (txId === undefined)
|
|
83
|
+
throw new Error('ForwardedTransactions.query: the query carries no txId');
|
|
84
|
+
const held = this.own(txId, caller);
|
|
85
|
+
if (held.timer)
|
|
86
|
+
clearTimeout(held.timer);
|
|
87
|
+
held.running++;
|
|
88
|
+
try {
|
|
89
|
+
await check(held.tx);
|
|
90
|
+
return await run(held.tx, withoutTxId(query));
|
|
91
|
+
}
|
|
92
|
+
catch (err) {
|
|
93
|
+
if (!isLinkedError(err, UnresolvedContextError))
|
|
94
|
+
await this.drop(txId, held, true);
|
|
95
|
+
throw err;
|
|
96
|
+
}
|
|
97
|
+
finally {
|
|
98
|
+
held.running--;
|
|
99
|
+
if (this.held.get(txId) === held && held.running === 0)
|
|
100
|
+
this.touch(txId, held);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
/** Commit: the caller's own transaction, else refused; unknown or expired rejects. */
|
|
104
|
+
async commit(txId, caller) {
|
|
105
|
+
const held = this.own(txId, caller);
|
|
106
|
+
await this.drop(txId, held, false);
|
|
107
|
+
await held.tx.commit();
|
|
108
|
+
}
|
|
109
|
+
/** Roll back: the caller's own transaction, else refused; an unknown txId resolves (a no-op). */
|
|
110
|
+
async rollback(txId, caller) {
|
|
111
|
+
const held = this.held.get(txId);
|
|
112
|
+
if (!held)
|
|
113
|
+
return;
|
|
114
|
+
if (held.caller !== caller)
|
|
115
|
+
throw new TransactionNotYoursError(txId);
|
|
116
|
+
await this.drop(txId, held, true);
|
|
117
|
+
}
|
|
118
|
+
own(txId, caller) {
|
|
119
|
+
const held = typeof txId === 'string' ? this.held.get(txId) : undefined;
|
|
120
|
+
if (!held)
|
|
121
|
+
throw new TransactionUnknownError(String(txId));
|
|
122
|
+
if (held.caller !== caller)
|
|
123
|
+
throw new TransactionNotYoursError(txId);
|
|
124
|
+
return held;
|
|
125
|
+
}
|
|
126
|
+
touch(txId, held) {
|
|
127
|
+
var _a, _b;
|
|
128
|
+
if (held.timer)
|
|
129
|
+
clearTimeout(held.timer);
|
|
130
|
+
held.timer = setTimeout(() => {
|
|
131
|
+
if (!held.running)
|
|
132
|
+
void this.drop(txId, held, true);
|
|
133
|
+
}, this.timeoutMs);
|
|
134
|
+
(_b = (_a = held.timer).unref) === null || _b === void 0 ? void 0 : _b.call(_a);
|
|
135
|
+
}
|
|
136
|
+
/** Forget `txId` (once), rolling its transaction back when asked to. */
|
|
137
|
+
async drop(txId, held, rollBack) {
|
|
138
|
+
if (held.timer)
|
|
139
|
+
clearTimeout(held.timer);
|
|
140
|
+
if (this.held.get(txId) !== held)
|
|
141
|
+
return;
|
|
142
|
+
this.held.delete(txId);
|
|
143
|
+
if (!rollBack)
|
|
144
|
+
return;
|
|
145
|
+
try {
|
|
146
|
+
await held.tx.rollback();
|
|
147
|
+
}
|
|
148
|
+
catch (err) {
|
|
149
|
+
// Committed or failed already: nothing to roll back.
|
|
150
|
+
if (held.tx.state === 'open')
|
|
151
|
+
console.error(`[linked] rolling back transaction ${txId} failed:`, err);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
//# sourceMappingURL=forwardedTransactions.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"forwardedTransactions.js","sourceRoot":"","sources":["../../../src/utils/forwardedTransactions.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The store transactions a forwarder holds for its callers (core report 103, \"Forwarding contract\").\n *\n * A forwarder (this server's query plane) answers `beginTransaction()` with an id of its own and\n * keeps a core `Transaction` for it. Each later query that carries the id runs in that\n * transaction, which opens a transaction in the store the query routes to at its first query\n * there (and warns, as core does, when a second store is reached). The id is bound to the caller\n * that began it: anyone else presenting it is refused, and the transaction is left as it was.\n *\n * - A query that fails, or that a check refuses, fails the transaction: it is rolled back in\n * every store and forgotten, so a later `commit` rejects as unknown and `rollback` resolves\n * (a select whose query context is not ready, core's `UnresolvedContextError`, did not run and\n * fails nothing). A forwarder that refuses a query before it gets here calls `rollback` itself.\n * - An idle transaction (no query running, and none, no commit or rollback for `timeoutMs`,\n * default 30 s) is rolled back and forgotten.\n * - A forwarder holds transactions only when every store it routes to does; it checks that\n * itself (see `supportsTransactions` in core) before it begins one or runs a query in one.\n */\nimport { randomUUID } from 'node:crypto';\nimport { brandLinkedError, isLinkedError } from '@_linked/core/utils/linkedError';\nimport { UnresolvedContextError } from '@_linked/core/queries/QueryContext';\nimport type { Transaction } from '@_linked/core/utils/Transaction';\nimport { txIdOf } from '@_linked/core/queries/transactionQuery';\n\n/** A transaction was asked of a forwarder whose stores do not all hold transactions. */\nexport class TransactionUnsupportedError extends Error {\n readonly code = 'transactions-unsupported';\n constructor(message: string) {\n super(message);\n this.name = new.target.name;\n }\n}\n\n/** A txId this forwarder does not hold: never begun here, ended, failed, or expired. */\nexport class TransactionUnknownError extends Error {\n readonly code = 'transaction-unknown';\n constructor(txId: string) {\n super(`transaction ${txId} is unknown or expired`);\n this.name = new.target.name;\n }\n}\n\n/** A txId presented by another caller than the one that began it. */\nexport class TransactionNotYoursError extends Error {\n readonly code = 'transaction-not-yours';\n constructor(txId: string) {\n super(`transaction ${txId} was begun by another caller`);\n this.name = new.target.name;\n }\n}\n\nbrandLinkedError(TransactionUnsupportedError, '@_linked/server', 'TransactionUnsupportedError');\nbrandLinkedError(TransactionUnknownError, '@_linked/server', 'TransactionUnknownError');\nbrandLinkedError(TransactionNotYoursError, '@_linked/server', 'TransactionNotYoursError');\n\n/** The query a transaction view stands for: core's `inTransaction` view has it as prototype. */\nexport function withoutTxId<Q extends object>(query: Q): Q {\n if (!Object.prototype.hasOwnProperty.call(query, 'txId')) return query;\n return Object.getPrototypeOf(query) as Q;\n}\n\ninterface Held {\n tx: Transaction;\n caller: string;\n timer?: ReturnType<typeof setTimeout>;\n /** Queries of it running now: it does not expire meanwhile. */\n running: number;\n}\n\nexport interface ForwardedTransactionsOptions {\n /** Idle time after which a transaction is rolled back and forgotten, in ms (default 30 000). */\n timeoutMs?: number;\n}\n\nexport class ForwardedTransactions {\n private readonly held = new Map<string, Held>();\n private readonly timeoutMs: number;\n\n constructor(options: ForwardedTransactionsOptions = {}) {\n this.timeoutMs = options.timeoutMs ?? 30_000;\n }\n\n /** How many transactions are open (for tests and diagnostics). */\n get size(): number {\n return this.held.size;\n }\n\n /** Hold `tx` for `caller`; answers its id. */\n begin(caller: string, tx: Transaction): string {\n const txId = randomUUID();\n const held: Held = { tx, caller, running: 0 };\n this.held.set(txId, held);\n this.touch(txId, held);\n return txId;\n }\n\n /**\n * Run `query` (which carries a txId) in its transaction: the caller must be the one that began\n * it; then `check` (the rights of the query, as if it were sent alone) and `run` with the query\n * outside its view. A refusal by `check` and a failure of `run` roll the transaction back. The\n * transaction does not expire while the query runs.\n */\n async query<R>(\n query: object,\n caller: string,\n check: (tx: Transaction) => Promise<void> | void,\n run: (tx: Transaction, query: any) => Promise<R>,\n ): Promise<R> {\n const txId = txIdOf(query);\n if (txId === undefined) throw new Error('ForwardedTransactions.query: the query carries no txId');\n const held = this.own(txId, caller);\n if (held.timer) clearTimeout(held.timer);\n held.running++;\n try {\n await check(held.tx);\n return await run(held.tx, withoutTxId(query));\n } catch (err) {\n if (!isLinkedError(err, UnresolvedContextError)) await this.drop(txId, held, true);\n throw err;\n } finally {\n held.running--;\n if (this.held.get(txId) === held && held.running === 0) this.touch(txId, held);\n }\n }\n\n /** Commit: the caller's own transaction, else refused; unknown or expired rejects. */\n async commit(txId: string, caller: string): Promise<void> {\n const held = this.own(txId, caller);\n await this.drop(txId, held, false);\n await held.tx.commit();\n }\n\n /** Roll back: the caller's own transaction, else refused; an unknown txId resolves (a no-op). */\n async rollback(txId: string, caller: string): Promise<void> {\n const held = this.held.get(txId);\n if (!held) return;\n if (held.caller !== caller) throw new TransactionNotYoursError(txId);\n await this.drop(txId, held, true);\n }\n\n private own(txId: string, caller: string): Held {\n const held = typeof txId === 'string' ? this.held.get(txId) : undefined;\n if (!held) throw new TransactionUnknownError(String(txId));\n if (held.caller !== caller) throw new TransactionNotYoursError(txId);\n return held;\n }\n\n private touch(txId: string, held: Held): void {\n if (held.timer) clearTimeout(held.timer);\n held.timer = setTimeout(() => {\n if (!held.running) void this.drop(txId, held, true);\n }, this.timeoutMs);\n held.timer.unref?.();\n }\n\n /** Forget `txId` (once), rolling its transaction back when asked to. */\n private async drop(txId: string, held: Held, rollBack: boolean): Promise<void> {\n if (held.timer) clearTimeout(held.timer);\n if (this.held.get(txId) !== held) return;\n this.held.delete(txId);\n if (!rollBack) return;\n try {\n await held.tx.rollback();\n } catch (err) {\n // Committed or failed already: nothing to roll back.\n if (held.tx.state === 'open') console.error(`[linked] rolling back transaction ${txId} failed:`, err);\n }\n }\n}\n"]}
|