@_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.
Files changed (44) hide show
  1. package/CHANGELOG.md +72 -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 +59 -39
  9. package/lib/esm/shapes/LinkedServer.js +158 -93
  10. package/lib/esm/shapes/LinkedServer.js.map +1 -1
  11. package/lib/esm/shapes/quadstores/RemoteDataset.d.ts +52 -20
  12. package/lib/esm/shapes/quadstores/RemoteDataset.js +134 -32
  13. package/lib/esm/shapes/quadstores/RemoteDataset.js.map +1 -1
  14. package/lib/esm/utils/adminChannel.d.ts +23 -51
  15. package/lib/esm/utils/adminChannel.js +50 -38
  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 +42 -17
  24. package/lib/esm/utils/graphTargets.js +62 -69
  25. package/lib/esm/utils/graphTargets.js.map +1 -1
  26. package/lib/esm/utils/linkedDependencies.d.ts +53 -7
  27. package/lib/esm/utils/linkedDependencies.js +130 -12
  28. package/lib/esm/utils/linkedDependencies.js.map +1 -1
  29. package/lib/esm/utils/ontologySync.d.ts +55 -31
  30. package/lib/esm/utils/ontologySync.js +94 -42
  31. package/lib/esm/utils/ontologySync.js.map +1 -1
  32. package/lib/esm/utils/queryPlane.d.ts +13 -0
  33. package/lib/esm/utils/queryPlane.js +49 -2
  34. package/lib/esm/utils/queryPlane.js.map +1 -1
  35. package/lib/esm/utils/reloadSource.js +1 -1
  36. package/lib/esm/utils/reloadSource.js.map +1 -1
  37. package/lib/esm/utils/serverTransactions.d.ts +22 -0
  38. package/lib/esm/utils/serverTransactions.js +81 -0
  39. package/lib/esm/utils/serverTransactions.js.map +1 -0
  40. package/lib/esm/utils/syncShapes.d.ts +123 -6
  41. package/lib/esm/utils/syncShapes.js +166 -35
  42. package/lib/esm/utils/syncShapes.js.map +1 -1
  43. package/package.json +4 -4
  44. package/readme.md +158 -38
package/readme.md CHANGED
@@ -25,6 +25,7 @@ If you want to further adjust the functionality of LincdServer yourself, either
25
25
  - [Without `url`](#without-url)
26
26
  - [With `url`](#with-url)
27
27
  - [`ping`](#ping)
28
+ - [Store transactions](#store-transactions)
28
29
  - [Shape catalog per request](#shape-catalog-per-request)
29
30
  - [API-only servers (`server.apiOnly`)](#api-only-servers-serverapionly)
30
31
  - [Host control channel (`/admin/*`)](#host-control-channel-admin)
@@ -314,7 +315,7 @@ Export path `@_linked/server/shapes/quadstores/RemoteDataset` (named export `Rem
314
315
  | Answered by | this app's own backend | a remote data server, e.g. a host's gateway |
315
316
  | Sends through | `Server.call`'s package form (`/call/@_linked/server/<method>`); on a backend, `Server`'s local shortcut | `globalThis.fetch` to `{url}/call/@_linked/server/<method>`, never `Server` |
316
317
  | Authenticated by | the user's session | `Authorization: Bearer <token>` |
317
- | `init()` | resolves at once | posts `ping`, throws a typed error when the server cannot be used |
318
+ | `init()` | asks the backend's `ping` for transactions only, resolves `undefined` (a failed ping leaves them off) | posts `ping`, throws a typed error when the server cannot be used |
318
319
  | Typical config | `linked.frontend.datasets.json` | `linked.backend.datasets.json` |
319
320
 
320
321
  ```typescript
@@ -323,7 +324,7 @@ export interface RemoteDatasetConfig {
323
324
  token?: string; // sent as Authorization: Bearer <token> when set and url is set
324
325
  name?: string; // id = ${DATA_ROOT}/backend-api-store/<name>; does not affect routing
325
326
  id?: string;
326
- forwardEndUser?: boolean | string; // url form: send the signed-in user's WebID (default true)
327
+ forwardUserToken?: boolean | string; // url form: send the signed-in user's access token (default true)
327
328
  }
328
329
  ```
329
330
 
@@ -349,26 +350,42 @@ semantics are under [Error semantics](#error-semantics). `token` is ignored.
349
350
  ```
350
351
 
351
352
  - Each method posts `{"args": [<query.toJSON()>]}` to `{url}/call/@_linked/server/<method>`
352
- (`selectQuery`, which also carries counts, `askQuery`, `updateQuery`, `createQuery`,
353
- `deleteQuery`) with `Content-Type` and `Accept` set to `application/json` and, when `token` is
354
- non-empty, `Authorization: Bearer <token>`. It resolves the parsed body; `null` is a valid
355
- result. Nothing is retried, so a write is never applied twice.
356
- - **The end user.** When the query is made while a signed-in user's HTTP request is being
357
- answered (the call context is `http` and its request has
358
- `linkedAuth.userAccount.accountOf.id`), the post also carries `x-linked-end-user: <WebID>`
359
- (exported as `END_USER_HEADER`). A system context (`runAsSystem`), an anonymous request and
360
- code outside any call send no such header. The token still decides which data the store
361
- reaches; the data server decides what to do with the end user. `forwardEndUser: false` (or the
362
- string `"false"`) turns it off. No other headers are sent.
363
-
364
- > **Warning: on by default.** Every query of a signed-in user's request sends that user's WebID
365
- > to the data server at `url`. Point `url` only at a server you would give your users'
366
- > identities to (for an app CN hosts, CN's gateway). To send none, set it off in the dataset's
367
- > config:
353
+ (`readQuery` for every read: a select, a count or an ask, told apart by the DSL-JSON `op`;
354
+ `updateQuery`, `createQuery`, `deleteQuery`; `graphQuery` for a graph query) with
355
+ `Content-Type` and `Accept` set to `application/json` and, when `token` is non-empty,
356
+ `Authorization: Bearer <token>`. It resolves the parsed body; `null` is a valid result. Nothing
357
+ is retried, so a write is never applied twice. The store declares `supports: ['ask']` (plus
358
+ `'transactions'`, below); a triples or terms read is refused without being sent, as the
359
+ server's `readQuery` answers neither.
360
+ - The server's `readQuery` checks a read under the access rule of its kind, read from the body
361
+ before it is decoded: a select and a count under `select`, an ask under `ask`. A triples or terms
362
+ read, a graph query and a write posted to it answer 400.
363
+ - **The user's token.** When the query is made while a signed-in user's HTTP request is being
364
+ answered (the call context is `http`), the post asks the hook on
365
+ `globalThis[Symbol.for('@_linked/auth:userTokenHeaders')]` (exported as
366
+ `USER_TOKEN_HEADERS_HOOK`; `@_linked/auth`'s backend installs it) with the request and
367
+ `{method: 'POST', url}` (the exact URL posted to). When it answers, the post also carries
368
+ `x-linked-user-token: <the user's Solid-OIDC access token>` (exported as `USER_TOKEN_HEADER`) and
369
+ `DPoP: <a proof for this one request>`, beside the bearer. auth answers for a session signed in
370
+ through an issuer that gave a DPoP-bound JWT access token (webid.email does), while the token has
371
+ more than 30 seconds left; other sessions, a system context (`runAsSystem`), an anonymous request
372
+ and code outside any call send neither. A hook that throws or answers something else sends
373
+ neither (one warning per process) and the query still goes. The token still decides which data
374
+ the store reaches; the data server verifies the user's token and proof (`@_linked/auth`'s
375
+ `verifySolidAccessToken`) and acts as that user. `forwardUserToken: false` (or the string
376
+ `"false"`) turns it off. No other headers are sent.
377
+
378
+ > **Warning: on by default.** Every query of a signed-in user's request sends that user's access
379
+ > token to the data server at `url`. Each proof is for one method and URL and the server cannot
380
+ > make new ones (the key stays with the app), but it learns who the user is. Point `url` only at
381
+ > a server you trust with your users' identities (for an app CN hosts, CN's gateway). To send
382
+ > none, set it off in the dataset's config:
368
383
  >
369
384
  > ```json
370
- > "config": { "url": "${DATA_SERVER_URL:-}", "token": "${DATA_SERVER_TOKEN:-}", "forwardEndUser": false }
385
+ > "config": { "url": "${DATA_SERVER_URL:-}", "token": "${DATA_SERVER_TOKEN:-}", "forwardUserToken": false }
371
386
  > ```
387
+ - A redirect is never followed (`redirect: 'manual'`): a 3xx answer is a `RemoteDatasetError`
388
+ with its status, so the bearer and the user's token go to `url` only.
372
389
  - Trailing slashes are trimmed from `url`. A `url` that is present but empty (an unset
373
390
  `${DATA_SERVER_URL:-}`) never falls back to the no-url form: `init()` and every method throw
374
391
  `RemoteDatasetUnreachableError` with `url: ''`.
@@ -377,9 +394,9 @@ semantics are under [Error semantics](#error-semantics). `token` is ignored.
377
394
  `init()`, so a boot that must fail loudly awaits it itself before registering the store.
378
395
  - `init()` resolves the ping body, which the store also keeps as the readonly `info`
379
396
  (`RemoteDatasetPingInfo`, frozen; `{}` when the body is not a JSON object; `undefined` before
380
- a successful ping and in the no-url form). The generic ping answers `{ok: true}`; a gateway may
381
- add fields naming the dataset it serves (CN's adds `projectId`, `branchName`, `dataset`,
382
- `forked`), so a boot can log where its data goes:
397
+ a successful ping and in the no-url form). The generic ping answers `{ok: true, supports}`; a
398
+ gateway may add fields naming the dataset it serves (CN's adds `projectId`, `branchName`,
399
+ `slot`, `dataset`, `forked`), so a boot can log where its data goes:
383
400
 
384
401
  ```ts
385
402
  const info = await store.init();
@@ -403,9 +420,43 @@ semantics are under [Error semantics](#error-semantics). `token` is ignored.
403
420
  ### `ping`
404
421
 
405
422
  `POST /call/@_linked/server/ping` (a `public` method of this package's generic provider) reads
406
- nothing, changes nothing and answers `{ "ok": true }`. A plain linked server has no token
407
- concept; a host that serves the wire with tokens checks the token itself and answers 401 for a
408
- refused one.
423
+ nothing, changes nothing and answers `{ "ok": true, "supports": [] }`, or `"supports":
424
+ ["transactions"]` when the server holds store transactions (below). A plain linked server has no
425
+ token concept; a host that serves the wire with tokens checks the token itself and answers 401
426
+ for a refused one.
427
+
428
+ ### Store transactions
429
+
430
+ Core's store transactions (`LinkedStorage.begin()`, `LinkedStorage.transaction(fn)`) go through a
431
+ `RemoteDataset` and this server to the stores behind it:
432
+
433
+ ```
434
+ POST /call/@_linked/server/beginTransaction {"args": []} → "<txId>"
435
+ POST /call/@_linked/server/commit {"args": ["<txId>"]} → null
436
+ POST /call/@_linked/server/rollback {"args": ["<txId>"]} → null
437
+ POST /call/@_linked/server/<x>Query {"args": [<query.toJSON(), with "txId">]}
438
+ ```
439
+
440
+ - **`RemoteDataset`** declares `'transactions'` in `supports` only after a `ping` that listed
441
+ them (both forms), so a server that does not hold them is never sent a txId; until then core
442
+ warns once per store and runs a transaction's queries directly. `beginTransaction()`,
443
+ `commit(txId)` and `rollback(txId)` post to the methods of the same names, never retried.
444
+ - **The server** holds transactions only when every dataset `LinkedStorage` routes to declares
445
+ `'transactions'` (`ping` says so). It answers a txId of its own and keeps a core `Transaction`
446
+ for it, which opens a transaction in each store a query of it routes to (a second store gets
447
+ its own, with core's warning). A query carrying the txId is checked against the store rules as
448
+ if it were sent alone; a refused or failed one rolls the transaction back in every store. An
449
+ idle transaction is rolled back after 30 seconds.
450
+ - **The caller.** A transaction needs a session (401 without one) and is bound to the session's
451
+ user account; a backend-to-backend call with no request behind it is `system`. Anyone else's
452
+ request with that txId is refused and leaves the transaction as it was.
453
+ - **Refusals** (`{error, code}`): 501 `transactions-unsupported`, 409 `transaction-unknown` (never
454
+ begun, ended, failed or expired; a `rollback` of one resolves), 403 `transaction-not-yours`. A
455
+ query with a txId sent to `/api/*`, and a graph query with one, are refused (400). Any refusal
456
+ of a request that names the caller's own transaction (a body that does not decode included)
457
+ rolls that transaction back. A store's own refusal is passed back as for any failed query.
458
+ - A forwarder of its own (a gateway) does the same: it lists `'transactions'` only when the store
459
+ it forwards to does, and never runs a query that carries a txId outside its transaction.
409
460
 
410
461
  ### Shape catalog per request
411
462
 
@@ -458,10 +509,8 @@ A host that starts the app process controls it over HTTP routes on the app's own
458
509
  | `GET /admin/health` | 200 `{status: 'ok'}` once the server listens (the boot ontology sync, shape check and shape sync have finished by then) |
459
510
  | `POST /admin/restart` | 202, then the process exits; the host starts it again |
460
511
  | `POST /admin/graph-registry-changed` | body `{dataset}`: the host changed that dataset's named graphs; the process drops its cached graph registry and answers 200 `{invalidated: dataset}`; 400 without a dataset name |
461
- | `GET /admin/routes` | 200 `{datasets: [{store, id?, default, routes, defaultGraphShapes}], graphTargets, graphTargetEnv}`: the effective graph routes and graph targets (see below) |
462
- | `POST /admin/sync-shapes` | 200 `{written, orphanScope, shapeGraphs, reloaded, shapes, ontologies: {written, skipped, graphs, droppedGraphs}, shapeCheck: {errors, warnings}}` (or `{written: 0, skipped, …}` when shape sync is off); 400 `{error}` for a malformed body or a package it may not reload; 500 `{error}` when the sync fails or times out |
463
-
464
- `POST /admin/sync-shapes` runs the ontology sync and the shape check (see [Ontology sync and the shape check](#ontology-sync-and-the-shape-check)), then re-syncs the registered shapes into the app's data exactly as the boot does (see [Shape graphs](#shape-graphs): only graphs whose stored content hash differs are written, with the boot's orphan policy and `syncShapesOnBoot` / `LINKED_SYNC_SHAPES_ON_BOOT` switches). `written` counts the shape graphs written, and `shapeGraphs` reports them. The optional body `{packages: [...]}` names packages whose source the host changed; each must be a valid npm package name and the app itself or one of its direct dependencies, never an `@_linked/*` package; the list only says what to reload, it never filters the syncs. In dev their modules are invalidated in Vite and their backends reloaded before the sync, so a shape file written a moment ago is registered; a built app reloads nothing. `reloaded` lists the packages actually reloaded and `shapes` the node shape IRIs registered afterwards, so the host can check that the shape it wrote is there. Syncs run one at a time, as the system; one that takes longer than `LINKED_SHAPE_SYNC_TIMEOUT_MS` (default 120000) fails and releases the queue. A source reload by itself (HMR) never syncs shapes. A failed ontology sync does not stop the shape sync; the route then answers 500 with the ontology error.
512
+ | `GET /admin/routes` | 200 `{datasets: [{store, id?, default, routes, defaultGraphShapes}], graphTargets, graphTargetEnv, graphWriter}`: the effective graph routes, graph targets and graph writer (see below) |
513
+ | `POST /admin/graph-content` | body `{packages?, reload?}`: 200 `{graphTargets, packages: [{name, shapes?, ontology?}], appGraph?, withoutTurtle, unreadable, reloaded}`, what the syncs would write, writing nothing, after reloading the packages named in `reload` (see [Graph writer and graph content](#graph-writer-and-graph-content)); 400 `{error}` for a malformed body or a package it may not reload; 409 `{error}` when a graph target is not `package`; 500 `{error}` when it fails or times out |
465
514
 
466
515
  `GET /admin/routes` reports the graph routes the process runs with, as core's routing holds them once the app's storage file (`linked.backend.storage`) has run. Routes are written only there (`LinkedStorage.setDatasetForShapes(dataset, shapes, {graph})`); the app never pushes them anywhere, and this route lets a host check what actually runs. One entry per dataset the router knows, the default dataset first, then in the order they were pinned:
467
516
 
@@ -470,7 +519,7 @@ A host that starts the app process controls it over HTTP routes on the app's own
470
519
  - `routes`: `{<shape IRI>: <graph IRI>}` as registered. A sub-shape without a route of its own is written to its nearest routed super-shape's graph and is not listed.
471
520
  - `defaultGraphShapes`: the registered shapes that resolve to this dataset and are written to its default graph (no route of their own, no routed super-shape), sorted.
472
521
 
473
- `graphTargets` is the process's one graph-target setting, `{ontologyGraph, shapeGraph}` (`package`, `default` or a shared graph IRI each; see [Ontology and shape graphs](#ontology-and-shape-graphs)): where the boot syncs write, for every dataset. `graphTargetEnv` holds `LINKED_ONTOLOGY_GRAPH` / `LINKED_SHAPE_GRAPH` as the process has them (unset ones left out), so a host can tell where the setting came from.
522
+ `graphWriter` is `LINKED_GRAPH_WRITER` (`app` or `host`; see [Graph writer and graph content](#graph-writer-and-graph-content)). `graphTargets` is the process's one graph-target setting, `{ontologyGraph, shapeGraph}` (`package`, `default` or a shared graph IRI each; see [Ontology and shape graphs](#ontology-and-shape-graphs)): where the boot syncs write, for every dataset. `graphTargetEnv` holds `LINKED_ONTOLOGY_GRAPH` / `LINKED_SHAPE_GRAPH` as the process has them (unset ones left out), so a host can tell where the setting came from.
474
523
 
475
524
  ```json
476
525
  {"datasets": [{"store": "FusekiStore", "default": true,
@@ -482,23 +531,67 @@ A host that starts the app process controls it over HTTP routes on the app's own
482
531
 
483
532
  Side effects to expect:
484
533
 
485
- - **A reload briefly disposes the package's providers.** Reloading a package (on HMR, or for `sync-shapes`) runs `dispose()` on its providers and constructs new ones; a request to that package's routes or methods that arrives in between can fail. Reloads run one at a time; one that takes longer than `LINKED_SOURCE_RELOAD_TIMEOUT_MS` (default 60000) rejects and releases the queue.
486
- - **A restart drops in-flight requests.** `POST /admin/restart` exits the process 50 ms after answering. A host that restarts an app whose server has no `sync-shapes` route (an older `@_linked/server`) to make its boot sync the shapes drops whatever that app was serving.
534
+ - **A reload briefly disposes the package's providers.** Reloading a package (on HMR, or for `graph-content`'s `reload`) runs `dispose()` on its providers and constructs new ones; a request to that package's routes or methods that arrives in between can fail. Reloads run one at a time; one that takes longer than `LINKED_SOURCE_RELOAD_TIMEOUT_MS` (default 60000) rejects and releases the queue.
535
+ - **A restart drops in-flight requests.** `POST /admin/restart` exits the process 50 ms after answering.
487
536
  - The channel listens on the app's public port, like every other route; only the secret protects it.
488
537
 
538
+ ### Graph writer and graph content
539
+
540
+ `LINKED_GRAPH_WRITER` says who writes the app's shape and ontology graphs: `app` (the default) or
541
+ `host`. Any other value stops the boot, naming the variable. Under `host` (a host such as Create
542
+ Now writes them) the app writes no shape or ontology graph: the boot registers the ontologies,
543
+ loads the class hierarchy and runs the shape check but syncs nothing (it logs one line saying the
544
+ host writes the graphs). Under `app` the boot syncs them (see [Ontology sync and the shape
545
+ check](#ontology-sync-and-the-shape-check) and [Shape graphs](#shape-graphs)) unless `linked.config`
546
+ sets `syncShapesOnBoot: false`.
547
+
548
+ The host reads the graphs from `POST /admin/graph-content`, body `{packages?: string[], reload?:
549
+ string[]}`. `reload` names packages whose source the host changed; each must be a valid npm
550
+ package name and the app itself or one of its direct dependencies (its `linked.unpublishedPackages`
551
+ among them), never an `@_linked/*` package. In dev their modules are invalidated in Vite and their
552
+ backends reloaded first, then the ontologies are registered again and the shape check runs, so a
553
+ shape file written a moment ago is in the answer; a built app reloads nothing. `reloaded` lists the
554
+ packages actually reloaded. `packages` limits the answer; without it every registered package with
555
+ shapes or ontology data is answered. It writes nothing. Requests run one at a time, as the system;
556
+ one that takes longer than `LINKED_SHAPE_SYNC_TIMEOUT_MS` (default 120000) fails with 500 and
557
+ releases the queue. It answers:
558
+
559
+ ```json
560
+ {"graphTargets": {"ontologyGraph": "package", "shapeGraph": "package"},
561
+ "packages": [{"name": "@acme/music",
562
+ "shapes": {"graph": "https://linked.cm/pkg/acme-music/graph/shapes", "mediaType": "text/turtle",
563
+ "text": "@prefix …", "hash": "…", "sources": ["data/shapes.ttl"]},
564
+ "ontology": {"graph": "https://linked.cm/pkg/acme-music/graph/ontology",
565
+ "mediaType": "application/n-triples", "text": "<…> <…> <…> .\n", "hash": "…",
566
+ "sources": ["data/music.ttl"]}}],
567
+ "appGraph": {"graph": "https://acme.example/data/graph/shapes", "mediaType": "text/turtle",
568
+ "text": "…", "hash": "…", "sources": []},
569
+ "withoutTurtle": [], "unreadable": [], "reloaded": []}
570
+ ```
571
+
572
+ Each `text` and `hash` is exactly what the `package`-mode sync writes and records as the graph's
573
+ `core:contentHash` (a shape graph: its Turtle and `shapeGraphHash`; an ontology graph: the
574
+ N-Triples of the package's data files and `ontologyGraphHash` of their raw texts, so the host must
575
+ not hash the text itself), and `sources` the record's `dcterms:source`. A package's shape Turtle
576
+ comes from where the shape sync takes it: an installed package without `shapes.ttl` has no `shapes`
577
+ here either and is listed in `withoutTurtle`. `unreadable` lists packages whose ontology data could
578
+ not be read. `appGraph` is `null` when the app has no single-owner shapes (drop the graph) and left
579
+ out without `DATA_ROOT`. The route answers 409 unless both `LINKED_ONTOLOGY_GRAPH` and
580
+ `LINKED_SHAPE_GRAPH` are `package`.
581
+
489
582
  ### Ontology sync and the shape check
490
583
 
491
- Before its shape sync, at boot and on `POST /admin/sync-shapes`, the server:
584
+ Before its shape sync at boot, the server:
492
585
 
493
586
  1. **Registers the installed packages' ontologies** (core `registerInstalledOntologies`): it imports `lib/esm/ontologies/*.register.js` of every linked package it discovered in the app's dependency tree, so a package the app only deep-imports shape files from (`@_linked/schema/shapes/Person`) is synced and checked too, and of every workspace package of the app (`linkedPackage: true` under its `workspaces`), which no package may import (a Create Now clone's `packages/<slug>-ont`). In dev, a package without a built `lib/` registers from `src/ontologies/*.register.ts` through Vite's `ssrLoadModule`; a built app skips it. A register module that fails is logged and the rest go on.
494
- 2. **Syncs the ontologies** (core `syncOntologies`): every registered package's ontology data is written into the app's default dataset, where `LINKED_ONTOLOGY_GRAPH` says (one graph per package by default; see [Ontology and shape graphs](#ontology-and-shape-graphs)), so the store's RDFS view infers the class hierarchy. The log line names a shared graph or the default graph when the sync wrote there. A package whose stored hash matches is skipped, so an unchanged sync writes nothing. `ontologies.written` and `ontologies.skipped` name the packages (a package whose data cannot be read is skipped with core's warning). Sync nodes of packages no longer registered are pruned only with `LINKED_SYNC_SHAPES_PRUNE_ORPHANS=true`, the shape sync's orphan policy.
495
- 3. **Checks the shapes** against that hierarchy (core `checkShapeHierarchy`, with every package's `dependencies` and `peerDependencies`). A running app is never stopped by it: each finding is logged, and `shapeCheck` counts them by severity.
587
+ 2. **Syncs the ontologies** (core `syncOntologiesRouted`): every registered package's ontology data is written into each dataset the storage routing needs it in (see [Which datasets the syncs write to](#which-datasets-the-syncs-write-to)), where `LINKED_ONTOLOGY_GRAPH` says (one graph per package by default; see [Ontology and shape graphs](#ontology-and-shape-graphs)), so the store's RDFS view infers the class hierarchy. The log line names a shared graph or the default graph when the sync wrote there. A package whose stored hash matches is skipped, so an unchanged sync writes nothing. The log line names the packages written and skipped (a package whose data cannot be read is skipped with core's warning). Sync nodes of packages no longer registered are pruned only with `LINKED_SYNC_SHAPES_PRUNE_ORPHANS=true`, the shape sync's orphan policy.
588
+ 3. **Checks the shapes** against that hierarchy (core `checkShapeHierarchy`, with every package's `dependencies` and `peerDependencies`). A running app is never stopped by it: each finding is logged, with a count by severity.
496
589
 
497
- The ontology sync follows the shape sync's switches (`syncShapesOnBoot`, `LINKED_SYNC_SHAPES_ON_BOOT`), at boot and on `POST /admin/sync-shapes` alike: with shape sync off, the route syncs neither ontologies nor shapes (it still registers the packages' ontologies and runs the shape check). `LINKED_SYNC_ONTOLOGIES_ON_BOOT=false` turns off only the boot's ontology sync. The in-memory class hierarchy (core `loadOntologyHierarchy`) is loaded at boot either way. A failed ontology sync at boot is logged and the boot goes on.
590
+ The ontology sync and the shape sync have one switch: both run when the app writes its own graphs (`LINKED_GRAPH_WRITER` unset or `app`; see [Graph writer and graph content](#graph-writer-and-graph-content)) and `linked.config` does not set `syncShapesOnBoot: false`. With the sync off the boot still registers the packages' ontologies and runs the shape check, and `POST /admin/graph-content` with `reload` registers and checks again. The in-memory class hierarchy (core `loadOntologyHierarchy`) is loaded at boot either way. A failed ontology sync at boot is logged and the boot goes on.
498
591
 
499
592
  ### Shape graphs
500
593
 
501
- After the ontology sync and the shape check, at boot and on `POST /admin/sync-shapes`, the server syncs the registered shapes into the app's default dataset with core's `syncShapeGraphs`. This section describes the default, `LINKED_SHAPE_GRAPH=package`; with a shared graph IRI every package's shapes and the app's generated ones go into that one graph (`graphs` lists it, `appGraph.graph` is it), and with `default` the shapes go into the default graph (`defaultGraph` counts the writes). See [Ontology and shape graphs](#ontology-and-shape-graphs).
594
+ After the ontology sync and the shape check, at boot, the server syncs the registered shapes with core's `syncShapeGraphsRouted`, into each dataset the storage routing needs them in (see [Which datasets the syncs write to](#which-datasets-the-syncs-write-to)). This section describes the default, `LINKED_SHAPE_GRAPH=package`; with a shared graph IRI every package's shapes and the app's generated ones go into that one graph (`graphs` lists it, `appGraph.graph` is it), and with `default` the shapes go into the default graph (`defaultGraph` counts the writes). See [Ontology and shape graphs](#ontology-and-shape-graphs).
502
595
 
503
596
  - **One graph per package.** Each package's shapes go in `{package IRI}/graph/shapes`, with a `ShapesGraph` record in the graph registry (never a hierarchy source). A graph whose stored content hash matches is not written, so an unchanged sync writes nothing; a changed one is replaced whole.
504
597
  - **Where the Turtle comes from.** An installed package's built `lib/esm/data/shapes.ttl`, which `linked build` writes. An installed package without that file gets no shape graph: nothing is generated for it (a package with no shapes has none). The app's own packages (the app and its workspace packages) are generated in process: in dev through Vite's `ssrLoadModule` (core's `packageShapesTurtle` on the core the source shapes registered on, the same bytes `linked build` writes); in a built app from their `lib/esm/data/shapes.ttl` when they have one.
@@ -512,7 +605,34 @@ The boot logs one line, for example:
512
605
  [LinkedServer] shape graph sync: written @acme/shop; skipped @_linked/schema; app graph written; graphs written https://linked.cm/pkg/acme-shop/graph/shapes, https://acme.example/data/graph/shapes; no shapes.ttl (no shape graph): @acme/legacy
513
606
  ```
514
607
 
515
- `POST /admin/sync-shapes` answers the same as `shapeGraphs`: `{written, skipped, pruned, graphs, appGraph: {graph, status}, withoutTurtle, cleaned, target}` (plus `defaultGraph`, the default-graph sync's writes, under `LINKED_SHAPE_GRAPH=default` or on a store without graph queries). `target` is the process's `shapeGraph` setting (see [Ontology and shape graphs](#ontology-and-shape-graphs)); with `fallback` set the store had no graph queries and the shapes went into the default graph instead. In a shared graph the rebuild covers the packages with Turtle: a package without it is left out (and logged), where the package mode keeps its old graph.
608
+ The sync runs under the switch of [Ontology sync and the shape check](#ontology-sync-and-the-shape-check): the app writes its own graphs and `syncShapesOnBoot` is not `false`. Its report, `{written, skipped, pruned, graphs, appGraph: {graph, status}, withoutTurtle, cleaned, target}` (plus `defaultGraph`, the default-graph sync's writes, under `LINKED_SHAPE_GRAPH=default` or on a store without graph queries), is what the log line shows. `target` is the process's `shapeGraph` setting (see [Ontology and shape graphs](#ontology-and-shape-graphs)); with `fallback` set the store had no graph queries and the shapes went into the default graph instead. In a shared graph the rebuild covers the packages with Turtle: a package without it is left out (and logged), where the package mode keeps its old graph.
609
+
610
+ ### Which datasets the syncs write to
611
+
612
+ The boot syncs follow the app's storage routing (`linked.backend.storage`), through core's
613
+ `@_linked/core/shapes/syncTargets` (its README's "Which datasets the syncs write to" has the rule):
614
+
615
+ - a package's **shape graph** goes to every dataset one of its shapes routes to, and to the
616
+ dataset(s) the `sh:NodeShape` / `sh:PropertyShape` shapes route to;
617
+ - a package's **ontology graph** goes to every dataset one of its shapes routes to, wherever a
618
+ shape targeting one of its classes routes, and to the dataset(s) of the ontology term shapes
619
+ (`rdfs:Class`, `owl:Class`, `rdf:Property`, …), so the hierarchy is where the instances are;
620
+ - the app's **own shape graph** goes to every dataset a shape it constrains routes to (the shapes
621
+ declaring `contains` properties and their sub-shapes), and those of the shape shapes.
622
+
623
+ An app without pins writes everything into its default dataset. With pins the boot logs
624
+ one line naming the datasets per graph kind, for example:
625
+
626
+ ```
627
+ [LinkedServer] The boot syncs follow the routing: shape graphs → FusekiStore "app" (every package), FusekiStore "music" (@acme/music); app shape graph → FusekiStore "app"; ontology graphs → FusekiStore "app" (every package), FusekiStore "music" (@acme/music, @_linked/schema).
628
+ ```
629
+
630
+ Each sync then logs one line per dataset (` in FusekiStore "music"`), and its report adds
631
+ `datasets` (each dataset's own report). When a pin moves, a package graph routing no longer sends
632
+ to the **default** dataset is dropped there (`prunedGraphs`, one log line each); one left in
633
+ another dataset stays and is reported (`leftovers`, one warning), since a store several apps share
634
+ may hold another app's copy. A dataset that cannot be read or written fails alone: the others are
635
+ synced, then the sync fails naming it (the boot logs a warning and goes on).
516
636
 
517
637
  ## Provider lifecycle and HMR
518
638