@_linked/server 3.13.0 → 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.
Files changed (41) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/lib/esm/backend.d.ts +23 -5
  3. package/lib/esm/backend.js +116 -25
  4. package/lib/esm/backend.js.map +1 -1
  5. package/lib/esm/shapes/LincdAPI.d.ts +1 -1
  6. package/lib/esm/shapes/LincdAPI.js +43 -7
  7. package/lib/esm/shapes/LincdAPI.js.map +1 -1
  8. package/lib/esm/shapes/LinkedServer.d.ts +50 -37
  9. package/lib/esm/shapes/LinkedServer.js +121 -76
  10. package/lib/esm/shapes/LinkedServer.js.map +1 -1
  11. package/lib/esm/shapes/quadstores/RemoteDataset.d.ts +35 -6
  12. package/lib/esm/shapes/quadstores/RemoteDataset.js +77 -12
  13. package/lib/esm/shapes/quadstores/RemoteDataset.js.map +1 -1
  14. package/lib/esm/utils/adminChannel.d.ts +22 -51
  15. package/lib/esm/utils/adminChannel.js +48 -36
  16. package/lib/esm/utils/adminChannel.js.map +1 -1
  17. package/lib/esm/utils/effectiveRoutes.d.ts +6 -0
  18. package/lib/esm/utils/effectiveRoutes.js +2 -1
  19. package/lib/esm/utils/effectiveRoutes.js.map +1 -1
  20. package/lib/esm/utils/forwardedTransactions.d.ts +46 -0
  21. package/lib/esm/utils/forwardedTransactions.js +155 -0
  22. package/lib/esm/utils/forwardedTransactions.js.map +1 -0
  23. package/lib/esm/utils/graphTargets.d.ts +37 -20
  24. package/lib/esm/utils/graphTargets.js +61 -82
  25. package/lib/esm/utils/graphTargets.js.map +1 -1
  26. package/lib/esm/utils/ontologySync.d.ts +40 -23
  27. package/lib/esm/utils/ontologySync.js +59 -37
  28. package/lib/esm/utils/ontologySync.js.map +1 -1
  29. package/lib/esm/utils/queryPlane.d.ts +13 -0
  30. package/lib/esm/utils/queryPlane.js +49 -2
  31. package/lib/esm/utils/queryPlane.js.map +1 -1
  32. package/lib/esm/utils/reloadSource.js +1 -1
  33. package/lib/esm/utils/reloadSource.js.map +1 -1
  34. package/lib/esm/utils/serverTransactions.d.ts +22 -0
  35. package/lib/esm/utils/serverTransactions.js +81 -0
  36. package/lib/esm/utils/serverTransactions.js.map +1 -0
  37. package/lib/esm/utils/syncShapes.d.ts +120 -8
  38. package/lib/esm/utils/syncShapes.js +151 -32
  39. package/lib/esm/utils/syncShapes.js.map +1 -1
  40. package/package.json +2 -2
  41. package/readme.md +134 -24
@@ -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 (the boot
9
- * shape sync has finished by then).
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
@@ -65,6 +66,32 @@ export function assertReloadablePackages(packages, allowed) {
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/sync-shapes', async (req, res) => {
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 = parseSyncShapesBody(req.body);
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.syncShapes(request));
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 type { ShapeCheckSummary } from './ontologySync.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 /**\n * The shape check's findings, counted by severity (each is logged), and the message of the\n * error the check threw, if it did (`failed`). Set by `POST /admin/sync-shapes`.\n */\n shapeCheck?: ShapeCheckSummary;\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\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;AAuCnD,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;KACpC,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 } 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\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 };\n}\n"]}
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"]}
@@ -11,40 +11,57 @@
11
11
  * `.from(graphs.allOntologies)` reaches the backend as the portable name, and the backend's
12
12
  * store expands it under this setting.
13
13
  *
14
- * The syncs write into the default dataset only. {@link syncPinWarning} names the shapes whose
15
- * routing sends their reads elsewhere, when the boot runs those syncs.
14
+ * The syncs follow the storage routing (core's `syncTargets`); {@link syncTargetsLine} names the
15
+ * datasets they write into when that is more than the default dataset.
16
16
  */
17
17
  import { type GraphTargets } from '@_linked/core/utils/graphTargets';
18
+ import type { GraphSyncTargets } from '@_linked/core/shapes/syncTargets';
19
+ import type { IDataset } from '@_linked/core/interfaces/IDataset';
18
20
  /**
19
21
  * Read `LINKED_ONTOLOGY_GRAPH` / `LINKED_SHAPE_GRAPH` from `env` (checked: an invalid value throws,
20
22
  * naming the variable) and set them as the process's graph targets (core's `setGraphTargets`;
21
23
  * unset ones are `'package'`). Returns the settings the env gave.
22
24
  */
23
25
  export declare function applyGraphTargetsFromEnv(env: Record<string, string | undefined>): Partial<GraphTargets>;
24
- /** The env variables as set (unset and empty ones left out), for `GET /admin/routes`. */
25
- export declare function graphTargetEnv(env: Record<string, string | undefined>): Record<string, string>;
26
- /** The classes of the shape shapes: what the shape sync writes. */
27
- export declare const SHAPE_SHAPE_CLASSES: readonly string[];
26
+ /** The variable that says who writes the app's shape and ontology graphs. */
27
+ export declare const GRAPH_WRITER_ENV = "LINKED_GRAPH_WRITER";
28
+ /**
29
+ * Who writes the app's shape and ontology graphs: `'app'` (the default) the app itself, at boot;
30
+ * `'host'` a host that runs it (Create Now), which reads the
31
+ * content through `POST /admin/graph-content` and writes it itself, so the app writes none.
32
+ */
33
+ export type GraphWriter = 'app' | 'host';
28
34
  /**
29
- * The classes of the ontology term shapes (the class and property shapes core, `@_linked/rdfs`
30
- * and `@_linked/owl` register): what the ontology sync writes and the hierarchy reads.
35
+ * `LINKED_GRAPH_WRITER` from `env`: unset or empty is `'app'`; any value other than `app` or
36
+ * `host` throws, naming the variable (the boot stops on it).
31
37
  */
32
- export declare const ONTOLOGY_TERM_CLASSES: readonly string[];
33
- /** Which boot syncs run: only the shapes a running sync writes are worth a warning. */
38
+ export declare function graphWriterFromEnv(env: Record<string, string | undefined>): GraphWriter;
39
+ /**
40
+ * Whether this process writes its shape and ontology graphs: the one check every write path of the
41
+ * syncs goes through (the boot's ontology and shape syncs). False
42
+ * under `LINKED_GRAPH_WRITER=host`.
43
+ */
44
+ export declare function appWritesGraphs(env?: Record<string, string | undefined>): boolean;
45
+ /** The env variables as set (unset and empty ones left out), for `GET /admin/routes`. */
46
+ export declare function graphTargetEnv(env: Record<string, string | undefined>): Record<string, string>;
47
+ /** Which boot syncs run: only the graphs a running sync writes are named. */
34
48
  export interface BootSyncs {
35
- /** The shape sync runs (`shapeSyncOn`): it writes the shape shapes. */
49
+ /** The shape sync runs (`shapeSyncOn`): it writes the shape graphs and the app's shape graph. */
36
50
  shapeSync: boolean;
37
- /** The ontology sync runs (`bootSyncsOntologies`): it writes the ontology term shapes. */
51
+ /** The ontology sync runs (the same switch as the shape sync): it writes the ontology graphs. */
38
52
  ontologySync: boolean;
39
53
  }
40
54
  /**
41
- * One warning when routing pins a shape a boot sync that runs writes (the shape shapes
42
- * `NodeShape` / `PropertyShape` for the shape sync, an ontology term shape such as `rdfs:Class` /
43
- * `owl:ObjectProperty` for the ontology sync) to a dataset other than the default one: the syncs
44
- * write into the default dataset only, so reads of those shapes on the pinned dataset miss what
45
- * they wrote. Names each such shape and its dataset; a sub-shape counts under the target class it
46
- * declares, or its parent's when it declares none.
47
- * Undefined when there is nothing to warn about (no sync runs, or no default dataset). Reads the
55
+ * One log line naming the datasets the boot syncs that run write into, per graph kind, when the
56
+ * routing sends any graph beyond the default dataset (core's `graphSyncTargets`: a package's
57
+ * shape graph goes wherever its shapes or the shape shapes route, its ontology graph also
58
+ * wherever shapes using its classes or the term shapes route, the app's graph wherever shapes
59
+ * declaring `contains`, or their sub-shapes, route). Undefined when every graph goes to the default dataset only (the
60
+ * syncs' own lines say enough), when no sync runs or without a default dataset. `core` is
61
+ * `@_linked/core/shapes/syncTargets` (server only, imported when the boot runs). Reads the
48
62
  * registered shapes outside any shape catalog (run it at boot, never inside `withShapeCatalog`).
49
63
  */
50
- export declare function syncPinWarning(syncs: BootSyncs): string | undefined;
64
+ export declare function syncTargetsLine(syncs: BootSyncs, core: {
65
+ graphSyncTargets: () => GraphSyncTargets;
66
+ datasetLabel: (dataset: IDataset) => string;
67
+ }): string | undefined;