diff --git a/package-lock.json b/package-lock.json index 2f89fe25..53c3e689 100644 --- a/package-lock.json +++ b/package-lock.json @@ -59,6 +59,7 @@ "eslint-plugin-storybook": "^0.11.2", "gh-pages": "^6.3.0", "globals": "^15.13.0", + "graphql": "^16.14.2", "happy-dom": "^20.8.3", "husky": "^9.1.7", "jsdom": "^28.1.0", @@ -8638,6 +8639,16 @@ "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", "dev": true }, + "node_modules/graphql": { + "version": "16.14.2", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.14.2.tgz", + "integrity": "sha512-Chq1s4CY7jmh8gO2qvLIJyfCDIN+EHLFW/9iShnp1z8FjBQMoodWP1kDC36VAMXXIvAjj4ARa7ntfAV2BrjsbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + } + }, "node_modules/grid-index": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/grid-index/-/grid-index-1.1.0.tgz", diff --git a/package.json b/package.json index 5a775b4f..c9ddb504 100644 --- a/package.json +++ b/package.json @@ -93,6 +93,7 @@ "eslint-plugin-storybook": "^0.11.2", "gh-pages": "^6.3.0", "globals": "^15.13.0", + "graphql": "^16.14.2", "happy-dom": "^20.8.3", "husky": "^9.1.7", "jsdom": "^28.1.0", diff --git a/src/api/tiled/README.md b/src/api/tiled/README.md index f890cb91..8fc4c52e 100644 --- a/src/api/tiled/README.md +++ b/src/api/tiled/README.md @@ -1,8 +1,8 @@ # Tiled API client (`tiled`) A complete client for [Tiled](https://github.com/bluesky/tiled): **all 48 API operations** from -`openapi.json`, the seven auth routes the spec omits, zarr URL builders, and a TanStack Query hook -for every one of them. +`openapi.json`, the seven auth routes the spec omits, zarr URL builders, the experimental +[graph of links](#graph-of-links) over GraphQL, and a TanStack Query hook for every one of them. Built on the client in [tiled-viewer-react](https://github.com/bluesky/tiled-viewer-react/tree/main/src/components/Tiled/api) @@ -244,6 +244,7 @@ endpoint types. | Webhooks | list, history | register, delete | | Server info | about, health, UI settings, metrics | — | | Auth | whoami | login, logout, API key create/revoke, session refresh/revoke | +| Graph of links | entity, entities, link, links, namespaces, raw `useTiledGraphQLQuery` | entity / link create, update, delete; namespace upsert, delete; raw `useTiledGraphQLMutation` | ### `useTiledDistinctQuery` is the one to know about @@ -322,7 +323,7 @@ header. ## Keys and invalidation -Keys are `['tiled', , , { baseUrl, initialPath }]`, with fourteen resources. +Keys are `['tiled', , , { baseUrl, initialPath }]`, with fifteen resources. The scope is last so prefixes like `['tiled','search']` still match. **The scope carries `initialPath`, not just `baseUrl`.** Tiled prepends the client's initial path to @@ -444,9 +445,121 @@ serve the server's own web UI, and the eight per-file zarr routes (`.zattrs`, `. `getZarrV3Url` return. Shipping typed fetchers for those would invite a consumer that should have used a zarr library. +## Graph of links + +Tiled's **experimental** entity/link graph (upstream #1387): *entities* — a project, a sample, a +dataset — optionally bound to a catalog node, and *links* `subject —predicate→ object` between them, +with CURIE namespaces for predicates. It is **GraphQL, not REST**, served at `{origin}/api/graphql` +(outside `/api/v1`, like `/healthz`), and only on a catalog-backed server. + +**Requires Tiled 0.2.19 or later** (0.2.19rc1+ on PyPI). The graph has been in Tiled since 0.2.15, +but 0.2.15–0.2.18 serve an older shape (`nodeId`, `catalogNodeId`, `accessBlob`) that this client +does not support; against one, entity and link calls reject with an "upgrade the server" error. Built +against bluesky/tiled@849c984a, whose graph code matches v0.2.19rc2 — see +[`graph/README.md`](./graph/README.md). + +```ts +import { + bindTiledGraphEntity, + createTiledGraphEntity, + createTiledGraphLink, + upsertTiledGraphNamespace, +} from '@/api/tiled'; + +await upsertTiledGraphNamespace('schema', 'https://schema.org/'); +await upsertTiledGraphNamespace('prov', 'http://www.w3.org/ns/prov#'); + +const project = await createTiledGraphEntity({ entityType: 'project', name: 'battery-cathodes-2026' }); +const sample = await createTiledGraphEntity({ entityType: 'sample', name: 'S001' }); +const run = await bindTiledGraphEntity('raw/3f2c9a1e', { entityType: 'dataset' }); // a Bluesky run + +await createTiledGraphLink({ subjectId: sample.id, predicate: 'schema:isPartOf', objectId: project.id }); +await createTiledGraphLink({ subjectId: run.id, predicate: 'prov:used', objectId: sample.id }); +``` + +### Entities bound to catalog nodes + +The server binds an entity to a node through `nodePathParts` but never says which node: entities +expose only `isNodeBound`. Upstream's convention, which its Python client's `bind_entity` follows, +is to also store the node's metadata URL in `uri`. Use `bindTiledGraphEntity` / +`useTiledBindGraphEntityMutation` rather than passing `nodePathParts` yourself. They set both +fields, and they resolve the path against the client's `initialPath`. That matters because +`nodePathParts` counts from the server root while every Finch path is relative to the prefix, so +with `initialPath: 'bl531'` the path `raw/x` is the node `bl531/raw/x`. + +```ts +// entity → catalog node +const located = tiledGraphEntityNodePath(run); // { path: 'bl531/raw/3f2c9a1e', sameServer: true } +if (located) await getTiledMetadata(located.path, { pathMode: 'absolute' }); + +// catalog node → entities +const bound = useTiledGraphNodeEntitiesQuery('raw/3f2c9a1e'); +``` + +`tiledGraphEntityNodePath` returns `null` for an entity with no Tiled metadata `uri`. Its path is +from the server root, hence `pathMode: 'absolute'`. `sameServer: false` means the URL names another +host, usually because the server moved; the path is still the one to try. To backfill an entity +bound without a `uri`, find it with `getTiledGraphNodeEntities(path)` and call +`updateTiledGraphEntity(id, { uri: getTiledGraphNodeUri(path) })`. + +The typed hooks select one level — an entity's fields, a link's fields with ids for its ends. For +anything nested, `useTiledGraphQLQuery` sends your own document through the same transport, so a +project's samples come back in one request: + +```tsx +const PROJECT_SAMPLES = `query ($id: ID!) { + entity(id: $id) { + name + incomingLinks(predicate: "schema:isPartOf") { subject { id name isNodeBound } } + } +}`; +const project = useTiledGraphQLQuery<{ + entity: { name: string; incomingLinks: { subject: { id: string; name: string } }[] } | null; +}>(PROJECT_SAMPLES, { id: projectId }); +``` + +Define the document at module scope — it is part of the cache key. The server caps nesting at depth +10: four `incomingLinks { subject { … } }` hops fit, five are rejected (checked against a live server). + +Five things the server does that a client has to know: + +- **Failures arrive as HTTP 200.** A resolver error comes back as `{ data, errors }`. Every graph call + rejects whenever `errors` is non-empty, with `error.graphqlErrors` and `error.graphqlCode` (e.g. + `NODE_NOT_FOUND` for a `nodePathParts` that names no node). A server without the graph answers 404, + and the error says so. +- **Reads never answer 401.** The graph filters what a caller may see rather than refusing. With an + access policy configured, an unauthenticated read comes back as empty lists; on a server with + **no** access policy (single-user `--api-key` mode, for one) it returns everything — even though + the same anonymous caller gets a 401 from REST `search`. So an empty graph usually means "check the + key", and a non-empty one proves nothing about authentication. Writes need `write:metadata`. +- **Updates are three-state.** In `updateGraphEntity` / `updateGraphLink`, an omitted field is + untouched and `null` clears it — `{ nodePathParts: null }` detaches an entity from its node. The + client sends exactly what you pass. `properties` cannot be updated at all. +- **Lists are pages without a count.** Every list defaults to `limit: 100`; a page shorter than + `limit` is the only end-of-list signal. Page with `offset`. +- **Nothing is unique yet.** On `main`, two entities can share a type and name, and a failing field in + an aliased multi-mutation nulls the whole `data` after earlier fields have committed. Upstream #1499 + adds a uniqueness constraint. + +**Keys and invalidation.** Every graph read — typed or raw — keys under one `graph` root, and every +graph write refreshes all of it, because writes ripple: deleting an entity deletes its links, and a +namespace change re-renders every predicate. Deleting a catalog node refreshes it too (the +`structure` bundle includes `graph`), because bound entities are deleted with their node. + +**Coverage** is checked against [`graph/schema.graphql`](./graph/schema.graphql), not `openapi.json`: +`TiledGraph.test.tsx` validates every document in [`graph/operations.ts`](./graph/operations.ts) +against the snapshot and fails when the schema has a root field the client does not. Upstream is still +renaming things (#1499: `entityType` → `kind`); `operations.ts` is the only file that spells a field +name, so such a rename is a small edit the test points at. The operations are described in +`TILED_GRAPH_ENDPOINTS`, separate from the spec-checked `TILED_ENDPOINTS`. + +Deliberately not here yet: traversal helpers, auto-pagination and batch writes. Each mostly works +around something upstream is likely to change (no counts, no bulk mutation, no uniqueness), so they +wait for the API to settle and for a Finch screen that needs them. + ## Not here yet -**Websockets and GraphQL.** Three seams are reserved so they land additively: +**Websockets.** Three seams are reserved so they land additively: - `apiKeyLocation: 'query'` is implemented already, because a browser websocket handshake cannot set headers and query-string auth is the only mode that will work. The queue server learned this the diff --git a/src/api/tiled/SKILLS.md b/src/api/tiled/SKILLS.md index 2f30c7f7..d8b425ac 100644 --- a/src/api/tiled/SKILLS.md +++ b/src/api/tiled/SKILLS.md @@ -28,6 +28,12 @@ client/ endpoints/ readEndpoints.ts registry descriptors: info, search, metadata GET, data reads, assets writeEndpoints.ts registry descriptors: writes, auth, zarr + graphEndpoints.ts graph-of-links descriptors (TILED_GRAPH_ENDPOINTS) — NOT spec routes + +graph/ + schema.graphql SDL snapshot of the GraphQL graph API — DO NOT hand-edit + operations.ts every GraphQL document; the ONLY file that spells a graph field name + README.md provenance, regeneration, known upstream churn types/ paths.ts TILED_PATHS (spec paths) + toClientPath + buildPath + completeness assert @@ -38,20 +44,21 @@ types/ requestOptions.ts TiledRequestOptions — the transport contract dataOptions.ts array/table/node option + return maps, and the endpoint/transport split searchFilters.ts all 15 filters; six values widened to take real values - errors.ts TiledApiError + toTiledApiError - registry.ts descriptor types + payloadAs/numberParam/tupleParam + errors.ts TiledApiError (+ graphqlErrors/graphqlCode) + toTiledApiError + graph.ts graph entity/link/namespace types, inputs, GraphQL envelope + registry.ts descriptor types (REST + graph) + payloadAs/numberParam/tupleParam common.ts the re-export hub + interceptor types index.ts barrel runtime/ - clientLike.ts TiledClientLike = Pick + TILED_CLIENT_LIKE_METHODS + clientLike.ts TiledClientLike = Pick + TILED_CLIENT_LIKE_METHODS TiledApiProvider.tsx client context + useTiledApiClient / useTiledApiClientOptional index.ts barrel hooks/ types.ts TiledHookError + re-export of the shared Finch option types errors.ts TiledEndpointUnavailableError - queryKeys.ts 14 roots + key builders; scope LAST, and carries initialPath + queryKeys.ts 15 roots + key builders; scope LAST, and carries initialPath invalidation.ts bundles + mutation->bundle map + useTiledInvalidate useTiledClient.ts resolver: provider -> Finch-configured singleton; useTiledQueryScope() internal/ @@ -67,6 +74,7 @@ hooks/ managementHooks.ts revisions, assets, webhooks, register, data source, stream — 5q + 6m infoHooks.ts 5 queries authHooks.ts 1 query + 6 mutations + graphHooks.ts graph of links — 7 queries + 10 mutations (incl. raw GraphQL, node binding) typeTests.ts compile-time assertions (tsconfig excludes src/testing, so they live here) index.ts barrel ``` @@ -96,6 +104,19 @@ The spec is the source of truth and the coverage test enforces it. Five steps, i Then the hook: see below. +### Adding a graph operation + +The graph is GraphQL, so `openapi.json` and `TILED_PATHS` are not involved. `graph/schema.graphql` is +the source of truth and `TiledGraph.test.tsx` enforces it: + +1. **`graph/operations.ts`** — add the named document and its `TILED_GRAPH_OPERATIONS` entry. +2. **`types/graph.ts`** — the result and input types. +3. **`client/TiledApiClient.ts`** — a method that calls `this.graphql(...)` and unwraps its field. +4. **`client/facade.ts`**, **`runtime/clientLike.ts`** (both lists), **`endpoints/graphEndpoints.ts`**. +5. The hook in `hooks/graphHooks.ts` on the `graph` root; mutations invalidate `['graph']`. +6. A playground entry in `components/TiledQueryPlayground/catalog/graph*.tsx` and a row in + `stories/TiledHooks.md`. + ## Adding a hook Hooks are written out explicitly, never generated by a factory — a factory would erase hover types, @@ -109,7 +130,7 @@ extra endpoint slot because the client's option types merge endpoint parameters the hooks do not — they `Omit` the transport keys and recombine in `fetch`. Mutation variables go through `mutate(variables)`, never the hook. -1. Add the resource to `tiledQueryRoots` + `tiledQueryKeys` if it is not one of the existing fourteen. +1. Add the resource to `tiledQueryRoots` + `tiledQueryKeys` if it is not one of the existing fifteen. 2. Write the hook in the matching `hooks/Hooks.ts`, with `@param` docs on every parameter. 3. If the hook takes an options object that mixes endpoint and transport fields, key it through a `keyParts` projection — never the object itself. @@ -147,12 +168,37 @@ without one fails CI. The two zarr URL builders are the only entries mapped to ` - `getServerInfo` resolving `null` is `data === null`, not `isError`. - the three corrected filter parameter names, and the JSON-array encodings. +`TiledGraph.test.tsx`: + +- `schema.graphql`'s `Query` / `Mutation` fields equal `TILED_GRAPH_OPERATIONS`, and every document + validates against the schema. +- graph descriptors: one per operation, real client methods, writes destructive except the two creates. +- transport: `POST {origin}/api/graphql`; a 200 with `errors` rejects with `graphqlCode`; 400 and 404 + are described; an explicit `null` survives an update while an omitted field is absent; 401 refreshes. +- hooks: id/document guards, one `graph` root, graph writes and `deleteNode` refresh it. + `TiledClient.test.ts`: path encoding, origin derivation, downsampling maths (including the non-array-structure guard), format resolution, JSON-seq parsing, search option mapping, error normalisation. ## Gotchas +- **The graph client needs Tiled ≥ 0.2.19.** 0.2.15–0.2.18 serve an older graph API (`nodeId`, + `accessBlob`) on which every entity and link document fails validation. `TILED_GRAPH_MIN_VERSION` + and the fields that first appeared in it live in `graph/operations.ts`; `graphql()` turns those + validation failures into an "upgrade the server" error. Bump both when the target version moves. +- **GraphQL fails with HTTP 200.** A resolver error arrives as `{ data, errors }` with status 200; + `graphql()` rejects whenever `errors` is non-empty. Never branch on status for a graph call. +- **The graph never answers 401.** Graph access control filters results instead of refusing: with an + access policy, an unauthenticated read returns empty lists; on a server *without* one (e.g. + single-user `--api-key`), it returns everything — verified against tiled@849c984a, where REST + `search` answered 401 for the same anonymous caller. Never treat a successful graph read as proof of + authentication, and treat an empty graph as "check the key" before "nothing there". +- **Bind entities to nodes with `bindGraphEntity`, not raw `nodePathParts`.** The server never says + which node an entity is bound to, so the path survives only in `uri`, which `bindGraphEntity` sets. + `nodePathParts` is from the server root; Finch paths are relative to `initialPath`. +- **Graph updates are three-state.** Omitted = untouched, `null` = clear. Pass inputs through untouched; + any helper that maps `undefined` to `null` (or drops `null`) breaks detach. - **Lists travel two different ways.** Repeated keys for `fields`/`sort`/`column`/`field`/`form_key`/ `metadata`; one JSON array for `keys_filter.keys`, `in.value`, `notin.value`, `specs.*`. Getting it wrong is a silent no-op or a 500. Both forms are in `client/searchParams.ts` with the evidence. diff --git a/src/api/tiled/client/TiledApiClient.ts b/src/api/tiled/client/TiledApiClient.ts index 22cc43d2..c64be0cc 100644 --- a/src/api/tiled/client/TiledApiClient.ts +++ b/src/api/tiled/client/TiledApiClient.ts @@ -13,7 +13,47 @@ import type { TiledRequestInterceptor, TiledResponseInterceptor, } from '../types/common'; -import { TiledApiError, toTiledApiError, type TiledHttpMethod } from '../types/errors'; +import { + TiledApiError, + formatGraphQLErrors, + toTiledApiError, + type TiledHttpMethod, +} from '../types/errors'; +import type { + TiledGraphBindEntityInput, + TiledGraphCreateEntityInput, + TiledGraphCreateLinkInput, + TiledGraphEntity, + TiledGraphEntityFilters, + TiledGraphEntityNodePath, + TiledGraphNodeEntityFilters, + TiledGraphLink, + TiledGraphLinkFilters, + TiledGraphNamespace, + TiledGraphQLErrorEntry, + TiledGraphQLResponse, + TiledGraphQLVariables, + TiledGraphUpdateEntityInput, + TiledGraphUpdateLinkInput, +} from '../types/graph'; +import { + GRAPH_CREATE_ENTITY, + GRAPH_CREATE_LINK, + GRAPH_DELETE_ENTITY, + GRAPH_DELETE_LINK, + GRAPH_DELETE_NAMESPACE, + GRAPH_ENTITIES, + GRAPH_ENTITY, + GRAPH_LINK, + GRAPH_LINKS, + GRAPH_NAMESPACES, + GRAPH_UPDATE_ENTITY, + GRAPH_UPDATE_LINK, + GRAPH_UPSERT_NAMESPACE, + TILED_GRAPH_MIN_VERSION, + fieldMissingFromOlderGraphApi, + graphOperationName, +} from '../graph/operations'; import type { GetDistinctResponse, DeliveryResponse, @@ -33,7 +73,7 @@ import { type TiledInfoResponse, } from '../types/info'; import type { TiledSearchItem, TiledSearchResult, TiledTableRow } from '../types/nodes'; -import { TILED_PATHS, buildPath } from '../types/paths'; +import { TILED_API_PREFIX, TILED_GRAPHQL_PATH, TILED_PATHS, buildPath } from '../types/paths'; import type { TiledApiKeyLocation, TiledApiKeyScheme, @@ -79,6 +119,7 @@ import { normalizeTiledBaseUrl, normalizeTiledPath, resolveEncodedTiledPath, + resolveTiledPath, tiledOriginFromBaseUrl, } from './urlUtils'; @@ -118,6 +159,23 @@ export interface TiledApiClientConfig { */ const TILED_PARAMS_SERIALIZER = { indexes: null } as const; +/** + * The message for a graph call rejected by a server whose graph API predates the one these + * documents target. Says what to do — upgrade the server — and keeps the server's own words, since + * the validation error alone reads like a bug in the client. + */ +function describeOlderGraphApi( + operation: string, + missingField: string, + errors: TiledGraphQLErrorEntry[], +): string { + return ( + `${operation} failed: this Tiled server's graph API is older than ${TILED_GRAPH_MIN_VERSION} ` + + `(it has no '${missingField}'). Finch's graph client needs Tiled ${TILED_GRAPH_MIN_VERSION} ` + + `or later; upgrade the server. Server said: ${formatGraphQLErrors(errors)}` + ); +} + /** * Flags the auth interceptor and the 401 handler read off a request. * @@ -2077,6 +2135,392 @@ export class TiledApiClient { // #endregion + // #region graph of links + // + // Tiled's experimental entity/link graph, served over GraphQL at `{origin}/api/graphql` — outside + // `/api/v1`, and only on a catalog-backed server. Not in `openapi.json`; the operations are + // checked against `../graph/schema.graphql` instead. Everything funnels through `graphql`, which + // goes through `request` like any other call, so auth, the 401 refresh, interceptors and + // per-call overrides apply unchanged. + + /** + * Send any GraphQL document to the graph endpoint and return its `data`. + * + * The escape hatch for selections the typed methods do not make — chiefly nested traversal, + * e.g. a project's samples in one request: + * + * ```ts + * const { entity } = await client.graphql<{ entity: { incomingLinks: { subject: { name: string } }[] } }>( + * `query ($id: ID!) { entity(id: $id) { incomingLinks(predicate: "schema:isPartOf") { subject { name } } } }`, + * { id: projectId }, + * ); + * ``` + * + * The caller owns the result type; nothing checks it. Pass parameters as `variables` rather than + * interpolating them into the document. The server limits nesting to depth 10. + * + * **Rejects whenever the response carries `errors`**, even alongside partial `data` — GraphQL + * reports resolver failures with HTTP 200, so the status alone says nothing. The + * {@link TiledApiError} carries `graphqlErrors`, `graphqlCode`, and the whole envelope as + * `responseBody`. A server with no graph router answers 404, reported as such. + * + * An unauthenticated read does **not** fail. The server filters rather than refuses: behind an + * access policy the caller sees empty lists, and on a server with no policy (single-user + * `--api-key`) it sees everything, although REST would answer the same caller 401. + */ + async graphql>( + document: string, + variables: TiledGraphQLVariables = {}, + options: TiledRequestOptions = {}, + ): Promise { + const operation = graphOperationName(document) ?? 'GraphQL operation'; + let body: TiledGraphQLResponse; + try { + body = await this.request>( + 'POST', + TILED_GRAPHQL_PATH, + { query: document, variables }, + options, + { origin: true }, + ); + } catch (error) { + if (error instanceof TiledApiError && error.status === 404) { + throw new TiledApiError({ + message: + `${operation} failed with 404: this server has no graph API. Tiled mounts ` + + '/api/graphql only when it serves a catalog-backed tree.', + method: 'POST', + path: TILED_GRAPHQL_PATH, + status: 404, + responseBody: error.responseBody, + cause: error, + }); + } + if (error instanceof TiledApiError && error.graphqlErrors) { + const missing = fieldMissingFromOlderGraphApi(error.graphqlErrors); + if (missing) { + throw new TiledApiError({ + message: describeOlderGraphApi(operation, missing, error.graphqlErrors), + method: 'POST', + path: TILED_GRAPHQL_PATH, + status: error.status, + responseBody: error.responseBody, + cause: error, + }); + } + } + throw error; + } + + if (body?.errors?.length) { + const missing = fieldMissingFromOlderGraphApi(body.errors); + throw new TiledApiError({ + message: missing + ? describeOlderGraphApi(operation, missing, body.errors) + : `${operation} failed: ${formatGraphQLErrors(body.errors)}`, + method: 'POST', + path: TILED_GRAPHQL_PATH, + status: 200, + responseBody: body, + }); + } + if (!body || typeof body !== 'object' || body.data === undefined || body.data === null) { + throw new TiledApiError({ + message: `${operation} failed: the response carried no data`, + method: 'POST', + path: TILED_GRAPHQL_PATH, + status: 200, + responseBody: body, + }); + } + return body.data; + } + + /** One entity by id, or `null` when it does not exist or the caller may not see it. */ + async getGraphEntity( + id: string, + options: TiledRequestOptions = {}, + ): Promise { + const data = await this.graphql<{ entity: TiledGraphEntity | null }>( + GRAPH_ENTITY, + { id }, + options, + ); + return data.entity; + } + + /** One page of entities, filtered by type and/or bound node. The server pages at 100. */ + async getGraphEntities( + filters: TiledGraphEntityFilters = {}, + options: TiledRequestOptions = {}, + ): Promise { + const data = await this.graphql<{ entities: TiledGraphEntity[] }>( + GRAPH_ENTITIES, + { ...filters }, + options, + ); + return data.entities; + } + + /** One link by id, or `null` when it does not exist or the caller may not see it. */ + async getGraphLink( + id: string, + options: TiledRequestOptions = {}, + ): Promise { + const data = await this.graphql<{ link: TiledGraphLink | null }>( + GRAPH_LINK, + { id }, + options, + ); + return data.link; + } + + /** + * One page of links, filtered by subject, predicate and/or object. + * + * `{ objectId }` is an entity's incoming links (a project's members); `{ subjectId }` its + * outgoing ones. + */ + async getGraphLinks( + filters: TiledGraphLinkFilters = {}, + options: TiledRequestOptions = {}, + ): Promise { + const data = await this.graphql<{ links: TiledGraphLink[] }>( + GRAPH_LINKS, + { ...filters }, + options, + ); + return data.links; + } + + /** The registered CURIE prefixes. */ + async getGraphNamespaces(options: TiledRequestOptions = {}): Promise { + const data = await this.graphql<{ namespaces: TiledGraphNamespace[] }>( + GRAPH_NAMESPACES, + {}, + options, + ); + return data.namespaces; + } + + /** Create an entity. Needs `write:metadata`. A `nodePathParts` naming no node is `NODE_NOT_FOUND`. */ + async createGraphEntity( + input: TiledGraphCreateEntityInput, + options: TiledRequestOptions = {}, + ): Promise { + const data = await this.graphql<{ createEntity: TiledGraphEntity }>( + GRAPH_CREATE_ENTITY, + { input }, + options, + ); + return data.createEntity; + } + + /** + * Change an entity. Omitted fields are left alone, `null` clears — see + * {@link TiledGraphUpdateEntityInput}. Resolves `null` when there is no such entity. + */ + async updateGraphEntity( + id: string, + input: TiledGraphUpdateEntityInput, + options: TiledRequestOptions = {}, + ): Promise { + const data = await this.graphql<{ updateEntity: TiledGraphEntity | null }>( + GRAPH_UPDATE_ENTITY, + { id, input }, + options, + ); + return data.updateEntity; + } + + /** Delete an entity **and every link attached to it**. Resolves whether it existed. */ + async deleteGraphEntity(id: string, options: TiledRequestOptions = {}): Promise { + const data = await this.graphql<{ deleteEntity: boolean }>( + GRAPH_DELETE_ENTITY, + { id }, + options, + ); + return data.deleteEntity; + } + + /** Create a link `subject —predicate→ object`. Needs `write:metadata`. */ + async createGraphLink( + input: TiledGraphCreateLinkInput, + options: TiledRequestOptions = {}, + ): Promise { + const data = await this.graphql<{ createLink: TiledGraphLink }>( + GRAPH_CREATE_LINK, + { input }, + options, + ); + return data.createLink; + } + + /** Change a link's predicate or access tags. Resolves `null` when there is no such link. */ + async updateGraphLink( + id: string, + input: TiledGraphUpdateLinkInput, + options: TiledRequestOptions = {}, + ): Promise { + const data = await this.graphql<{ updateLink: TiledGraphLink | null }>( + GRAPH_UPDATE_LINK, + { id, input }, + options, + ); + return data.updateLink; + } + + /** Delete one link. Resolves whether it existed. */ + async deleteGraphLink(id: string, options: TiledRequestOptions = {}): Promise { + const data = await this.graphql<{ deleteLink: boolean }>( + GRAPH_DELETE_LINK, + { id }, + options, + ); + return data.deleteLink; + } + + /** Register or replace a CURIE prefix. Changes how every predicate and property is rendered. */ + async upsertGraphNamespace( + prefix: string, + uri: string, + options: TiledRequestOptions = {}, + ): Promise { + const data = await this.graphql<{ upsertNamespace: TiledGraphNamespace }>( + GRAPH_UPSERT_NAMESPACE, + { prefix, uri }, + options, + ); + return data.upsertNamespace; + } + + /** Remove a CURIE prefix. Resolves whether it existed. */ + async deleteGraphNamespace( + prefix: string, + options: TiledRequestOptions = {}, + ): Promise { + const data = await this.graphql<{ deleteNamespace: boolean }>( + GRAPH_DELETE_NAMESPACE, + { prefix }, + options, + ); + return data.deleteNamespace; + } + + // Entities bound to catalog nodes. The server binds by `nodePathParts` but never says which node + // an entity is bound to (only `isNodeBound`), so upstream's convention — and its Python client's + // `bind_entity` — is to store the node's metadata URL in `uri` alongside the binding. These + // helpers do that, and resolve paths against `initialPath` the way every other method does: + // `nodePathParts` is from the server root, a Finch path is relative to the client's prefix. + + /** A node's path from the server root, as `nodePathParts` wants it: `['bl531', 'raw', 'x']`. */ + getGraphNodePathParts(path: string, options: TiledRequestOptions = {}): string[] { + const absolute = resolveTiledPath( + path, + options.initialPath ?? this.initialPath, + options.pathMode ?? ('relative' as TiledPathMode), + ); + return absolute ? absolute.split('/') : []; + } + + /** + * The node's canonical `uri`: its metadata URL on this server, + * `http://host:8000/api/v1/metadata/bl531/raw/x`. What `bindGraphEntity` stores, and what to + * pass to `updateGraphEntity(id, { uri })` to backfill an entity bound without one. + */ + getGraphNodeUri(path: string, options: TiledRequestOptions = {}): string { + return `${this.resolveBaseUrl(options)}/metadata/${this.encodePath(path, options)}`; + } + + /** + * Create an entity bound to the catalog node at `path`, with `nodePathParts` and `uri` set + * together so the node can be found again later (see {@link graphEntityNodePath}). `name` + * defaults to the node's key. Fails with `NODE_NOT_FOUND` when no node is at `path`. + */ + async bindGraphEntity( + path: string, + input: TiledGraphBindEntityInput, + options: TiledRequestOptions = {}, + ): Promise { + const nodePathParts = this.getGraphNodePathParts(path, options); + if (nodePathParts.length === 0) { + throw new TiledApiError({ + message: + 'bindGraphEntity needs the path of a catalog node; the root container cannot ' + + 'be bound.', + method: 'POST', + path: TILED_GRAPHQL_PATH, + }); + } + return this.createGraphEntity( + { + ...input, + name: input.name ?? nodePathParts[nodePathParts.length - 1], + nodePathParts, + uri: this.getGraphNodeUri(path, options), + }, + options, + ); + } + + /** One page of the entities bound to the catalog node at `path`. */ + getGraphNodeEntities( + path: string, + filters: TiledGraphNodeEntityFilters = {}, + options: TiledRequestOptions = {}, + ): Promise { + return this.getGraphEntities( + { ...filters, nodePathParts: this.getGraphNodePathParts(path, options) }, + options, + ); + } + + /** + * The catalog node an entity's `uri` points at, or `null` when it has no `uri` or it is not a + * Tiled metadata URL. The path is from the server root, so read it with `pathMode: 'absolute'`: + * + * ```ts + * const located = client.graphEntityNodePath(entity); + * if (located) await client.getMetadata(located.path, { pathMode: 'absolute' }); + * ``` + * + * `sameServer` compares the URL with this client's base URL. A mismatch usually means the + * server moved host since the entity was written; the path after `/api/v1/metadata/` is then + * still the right one to try. + */ + graphEntityNodePath( + entity: Pick, + options: TiledRequestOptions = {}, + ): TiledGraphEntityNodePath | null { + const uri = entity.uri; + if (!uri) return null; + const marker = `${TILED_API_PREFIX}/metadata`; + // The last occurrence, as `tiledOriginFromBaseUrl` does, so a server mounted under a + // sub-path that itself contains `/api/v1` still parses. + const at = uri.lastIndexOf(marker); + if (at < 0) return null; + const rest = uri.slice(at + marker.length).split(/[?#]/)[0]; + if (rest !== '' && !rest.startsWith('/')) return null; + let path: string; + try { + path = normalizeTiledPath( + rest + .split('/') + .map((segment) => decodeURIComponent(segment)) + .join('/'), + ); + } catch { + return null; // malformed percent-encoding: not a URL this client wrote + } + const base = uri.slice(0, at + TILED_API_PREFIX.length); + return { + path, + sameServer: normalizeTiledBaseUrl(base) === this.resolveBaseUrl(options), + }; + } + + // #endregion + // #region zarr // // URL builders only, deliberately. These routes exist to be consumed by zarr.js or xarray, diff --git a/src/api/tiled/client/facade.ts b/src/api/tiled/client/facade.ts index f477670d..49b66e81 100644 --- a/src/api/tiled/client/facade.ts +++ b/src/api/tiled/client/facade.ts @@ -15,6 +15,21 @@ import type { WebhookRegistrationRequest, WebhookResponse, } from '../types/generatedAliases'; +import type { + TiledGraphBindEntityInput, + TiledGraphCreateEntityInput, + TiledGraphCreateLinkInput, + TiledGraphEntity, + TiledGraphEntityFilters, + TiledGraphEntityNodePath, + TiledGraphNodeEntityFilters, + TiledGraphLink, + TiledGraphLinkFilters, + TiledGraphNamespace, + TiledGraphQLVariables, + TiledGraphUpdateEntityInput, + TiledGraphUpdateLinkInput, +} from '../types/graph'; import type { TiledAuthProvider, TiledInfoResponse } from '../types/info'; import type { TiledSearchItem, TiledSearchResult, TiledTableRow } from '../types/nodes'; import type { TiledLoginTokens, TiledRequestOptions } from '../types/requestOptions'; @@ -630,6 +645,140 @@ export function tiledLogout(options?: TiledRequestOptions): Promise { // #endregion +// #region graph of links + +/** Any GraphQL document against the graph endpoint; see `TiledApiClient.graphql`. */ +export function tiledGraphQL>( + document: string, + variables?: TiledGraphQLVariables, + options?: TiledRequestOptions, +): Promise { + return getDefaultTiledApiClient().graphql(document, variables, options); +} + +export function getTiledGraphEntity( + id: string, + options?: TiledRequestOptions, +): Promise { + return getDefaultTiledApiClient().getGraphEntity(id, options); +} + +export function getTiledGraphEntities( + filters?: TiledGraphEntityFilters, + options?: TiledRequestOptions, +): Promise { + return getDefaultTiledApiClient().getGraphEntities(filters, options); +} + +export function getTiledGraphLink( + id: string, + options?: TiledRequestOptions, +): Promise { + return getDefaultTiledApiClient().getGraphLink(id, options); +} + +export function getTiledGraphLinks( + filters?: TiledGraphLinkFilters, + options?: TiledRequestOptions, +): Promise { + return getDefaultTiledApiClient().getGraphLinks(filters, options); +} + +export function getTiledGraphNamespaces( + options?: TiledRequestOptions, +): Promise { + return getDefaultTiledApiClient().getGraphNamespaces(options); +} + +export function createTiledGraphEntity( + input: TiledGraphCreateEntityInput, + options?: TiledRequestOptions, +): Promise { + return getDefaultTiledApiClient().createGraphEntity(input, options); +} + +export function updateTiledGraphEntity( + id: string, + input: TiledGraphUpdateEntityInput, + options?: TiledRequestOptions, +): Promise { + return getDefaultTiledApiClient().updateGraphEntity(id, input, options); +} + +export function deleteTiledGraphEntity( + id: string, + options?: TiledRequestOptions, +): Promise { + return getDefaultTiledApiClient().deleteGraphEntity(id, options); +} + +export function createTiledGraphLink( + input: TiledGraphCreateLinkInput, + options?: TiledRequestOptions, +): Promise { + return getDefaultTiledApiClient().createGraphLink(input, options); +} + +export function updateTiledGraphLink( + id: string, + input: TiledGraphUpdateLinkInput, + options?: TiledRequestOptions, +): Promise { + return getDefaultTiledApiClient().updateGraphLink(id, input, options); +} + +export function deleteTiledGraphLink(id: string, options?: TiledRequestOptions): Promise { + return getDefaultTiledApiClient().deleteGraphLink(id, options); +} + +export function upsertTiledGraphNamespace( + prefix: string, + uri: string, + options?: TiledRequestOptions, +): Promise { + return getDefaultTiledApiClient().upsertGraphNamespace(prefix, uri, options); +} + +export function deleteTiledGraphNamespace( + prefix: string, + options?: TiledRequestOptions, +): Promise { + return getDefaultTiledApiClient().deleteGraphNamespace(prefix, options); +} + +/** Create an entity bound to the node at `path`, with `nodePathParts` and `uri` set together. */ +export function bindTiledGraphEntity( + path: string, + input: TiledGraphBindEntityInput, + options?: TiledRequestOptions, +): Promise { + return getDefaultTiledApiClient().bindGraphEntity(path, input, options); +} + +/** A page of the entities bound to the node at `path`. */ +export function getTiledGraphNodeEntities( + path: string, + filters?: TiledGraphNodeEntityFilters, + options?: TiledRequestOptions, +): Promise { + return getDefaultTiledApiClient().getGraphNodeEntities(path, filters, options); +} + +/** The catalog node an entity's `uri` points at, from the server root; `null` when it has none. */ +export function tiledGraphEntityNodePath( + entity: Pick, + options?: TiledRequestOptions, +): TiledGraphEntityNodePath | null { + return getDefaultTiledApiClient().graphEntityNodePath(entity, options); +} + +/** The canonical `uri` for the node at `path`, e.g. to backfill an entity bound without one. */ +export function getTiledGraphNodeUri(path: string, options?: TiledRequestOptions): string { + return getDefaultTiledApiClient().getGraphNodeUri(path, options); +} + +// #endregion + // #region zarr export function getTiledZarrV2Url(path: string, options?: TiledRequestOptions): string { diff --git a/src/api/tiled/endpointRegistry.ts b/src/api/tiled/endpointRegistry.ts index 710c8118..028eafa6 100644 --- a/src/api/tiled/endpointRegistry.ts +++ b/src/api/tiled/endpointRegistry.ts @@ -24,7 +24,12 @@ import { webhookWriteEndpointDescriptors, zarrEndpointDescriptors, } from './endpoints/writeEndpoints'; -import type { TiledEndpointDescriptor, TiledEndpointGroup } from './types/registry'; +import { graphEndpointDescriptors } from './endpoints/graphEndpoints'; +import type { + TiledEndpointDescriptor, + TiledEndpointGroup, + TiledGraphOperationDescriptor, +} from './types/registry'; /** * Every operation the client implements, described as data. @@ -75,6 +80,7 @@ export const TILED_ENDPOINT_GROUPS: readonly TiledEndpointGroup[] = [ 'webhooks', 'auth', 'zarr', + 'graph', ]; /** Human-readable group labels. */ @@ -93,8 +99,24 @@ export const TILED_GROUP_LABELS: Record = { webhooks: 'Webhooks', auth: 'Authentication', zarr: 'Zarr', + graph: 'Graph of links', }; +/** + * Every graph-of-links operation, described as data. + * + * Kept apart from {@link TILED_ENDPOINTS}, which is diffed against `openapi.json`: the graph is a + * GraphQL endpoint that spec does not contain. `TiledGraph.test.tsx` diffs this against + * `graph/schema.graphql` instead. Its group, `'graph'`, is listed in {@link TILED_ENDPOINT_GROUPS} + * so harnesses that group by it show the graph hooks too. + */ +export const TILED_GRAPH_ENDPOINTS: readonly TiledGraphOperationDescriptor[] = + graphEndpointDescriptors; + +export function getGraphEndpointById(id: string): TiledGraphOperationDescriptor | undefined { + return TILED_GRAPH_ENDPOINTS.find((endpoint) => endpoint.id === id); +} + export function getEndpointById(id: string): TiledEndpointDescriptor | undefined { return TILED_ENDPOINTS.find((endpoint) => endpoint.id === id); } diff --git a/src/api/tiled/endpoints/graphEndpoints.ts b/src/api/tiled/endpoints/graphEndpoints.ts new file mode 100644 index 00000000..032e47e0 --- /dev/null +++ b/src/api/tiled/endpoints/graphEndpoints.ts @@ -0,0 +1,165 @@ +import type { + TiledGraphCreateEntityInput, + TiledGraphCreateLinkInput, + TiledGraphEntityFilters, + TiledGraphLinkFilters, + TiledGraphUpdateEntityInput, + TiledGraphUpdateLinkInput, +} from '../types/graph'; +import type { TiledGraphOperationDescriptor } from '../types/registry'; + +/** + * The graph-of-links operations, one per root field of `../graph/schema.graphql`. + * + * `call` takes the operation's GraphQL variables as a plain object — the same names the server + * declares — so a harness can render one JSON editor for any of them. + * + * Destructive means "changes or removes something that already exists". Creating an entity or a + * link only adds; `upsertNamespace` can replace an existing prefix's URI and so re-render every + * predicate, which is why it is flagged. + */ +export const graphEndpointDescriptors: readonly TiledGraphOperationDescriptor[] = [ + { + id: 'graph.entity', + group: 'graph', + kind: 'query', + field: 'entity', + fn: 'getGraphEntity', + summary: 'One entity by id.', + sampleVariables: { id: '' }, + call: (client, vars) => client.getGraphEntity(String(vars.id ?? '')), + }, + { + id: 'graph.entities', + group: 'graph', + kind: 'query', + field: 'entities', + fn: 'getGraphEntities', + summary: 'A page of entities, by type and/or bound catalog node.', + sampleVariables: { entityType: 'project', limit: 100, offset: 0 }, + call: (client, vars) => client.getGraphEntities(vars as TiledGraphEntityFilters), + }, + { + id: 'graph.link', + group: 'graph', + kind: 'query', + field: 'link', + fn: 'getGraphLink', + summary: 'One link by id.', + sampleVariables: { id: '' }, + call: (client, vars) => client.getGraphLink(String(vars.id ?? '')), + }, + { + id: 'graph.links', + group: 'graph', + kind: 'query', + field: 'links', + fn: 'getGraphLinks', + summary: 'A page of links, by subject, predicate and/or object.', + sampleVariables: { predicate: 'schema:isPartOf', limit: 100, offset: 0 }, + call: (client, vars) => client.getGraphLinks(vars as TiledGraphLinkFilters), + }, + { + id: 'graph.namespaces', + group: 'graph', + kind: 'query', + field: 'namespaces', + fn: 'getGraphNamespaces', + summary: 'The registered CURIE prefixes.', + call: (client) => client.getGraphNamespaces(), + }, + { + id: 'graph.createEntity', + group: 'graph', + kind: 'mutation', + field: 'createEntity', + fn: 'createGraphEntity', + summary: 'Create an entity, optionally bound to a catalog node.', + sampleVariables: { input: { entityType: 'project', name: 'example-project' } }, + call: (client, vars) => client.createGraphEntity(vars.input as TiledGraphCreateEntityInput), + }, + { + id: 'graph.updateEntity', + group: 'graph', + kind: 'mutation', + field: 'updateEntity', + fn: 'updateGraphEntity', + summary: 'Change an entity. Omitted fields are untouched; null clears.', + destructive: true, + sampleVariables: { id: '', input: { name: 'renamed' } }, + call: (client, vars) => + client.updateGraphEntity( + String(vars.id ?? ''), + vars.input as TiledGraphUpdateEntityInput, + ), + }, + { + id: 'graph.deleteEntity', + group: 'graph', + kind: 'mutation', + field: 'deleteEntity', + fn: 'deleteGraphEntity', + summary: 'Delete an entity and every link attached to it.', + destructive: true, + sampleVariables: { id: '' }, + call: (client, vars) => client.deleteGraphEntity(String(vars.id ?? '')), + }, + { + id: 'graph.createLink', + group: 'graph', + kind: 'mutation', + field: 'createLink', + fn: 'createGraphLink', + summary: 'Link two entities: subject —predicate→ object.', + sampleVariables: { + input: { subjectId: '', predicate: 'schema:isPartOf', objectId: '' }, + }, + call: (client, vars) => client.createGraphLink(vars.input as TiledGraphCreateLinkInput), + }, + { + id: 'graph.updateLink', + group: 'graph', + kind: 'mutation', + field: 'updateLink', + fn: 'updateGraphLink', + summary: "Change a link's predicate or access tags.", + destructive: true, + sampleVariables: { id: '', input: { predicate: 'schema:isPartOf' } }, + call: (client, vars) => + client.updateGraphLink(String(vars.id ?? ''), vars.input as TiledGraphUpdateLinkInput), + }, + { + id: 'graph.deleteLink', + group: 'graph', + kind: 'mutation', + field: 'deleteLink', + fn: 'deleteGraphLink', + summary: 'Delete one link.', + destructive: true, + sampleVariables: { id: '' }, + call: (client, vars) => client.deleteGraphLink(String(vars.id ?? '')), + }, + { + id: 'graph.upsertNamespace', + group: 'graph', + kind: 'mutation', + field: 'upsertNamespace', + fn: 'upsertGraphNamespace', + summary: 'Register or replace a CURIE prefix.', + destructive: true, + sampleVariables: { prefix: 'schema', uri: 'https://schema.org/' }, + call: (client, vars) => + client.upsertGraphNamespace(String(vars.prefix ?? ''), String(vars.uri ?? '')), + }, + { + id: 'graph.deleteNamespace', + group: 'graph', + kind: 'mutation', + field: 'deleteNamespace', + fn: 'deleteGraphNamespace', + summary: 'Remove a CURIE prefix.', + destructive: true, + sampleVariables: { prefix: '' }, + call: (client, vars) => client.deleteGraphNamespace(String(vars.prefix ?? '')), + }, +]; diff --git a/src/api/tiled/graph/README.md b/src/api/tiled/graph/README.md new file mode 100644 index 00000000..1fa7962e --- /dev/null +++ b/src/api/tiled/graph/README.md @@ -0,0 +1,60 @@ +# Graph of links — schema snapshot + +`schema.graphql` is the GraphQL SDL of Tiled's entity/link graph, exported from upstream source at +**bluesky/tiled@849c984a**, whose `tiled/graph/` is byte-identical to the **v0.2.19rc2** release. +It is not in `../openapi.json`: the graph is served by a Strawberry GraphQL router at +`{origin}/api/graphql`, outside the `/api/v1` version segment, and only on catalog-backed servers. + +## Compatibility: Tiled 0.2.19 or later + +The graph has shipped since Tiled 0.2.15, but its API changed in 0.2.19, and this client targets +the newer shape (`TILED_GRAPH_MIN_VERSION` in `operations.ts`): + +| | 0.2.15 – 0.2.18 | 0.2.19rc1 and later | +| --- | --- | --- | +| Entity's node | `nodeId: Int` | `isNodeBound: Boolean` | +| Bind on create | `nodeId` | `nodePathParts: ['raw', 'S001']` | +| Node lookup | `catalogNodeId(path)` query | removed | +| Filter entities by node | — | `entities(nodePathParts: …)` | +| Access control | `accessBlob` | `accessTags` | + +Against a 0.2.18 server every entity and link operation fails GraphQL validation (verified against a +live 0.2.18); only the namespace operations work. The client recognises those validation messages +and rejects with "this Tiled server's graph API is older than 0.2.19 … upgrade the server", keeping +the server's own message after it. Supporting both shapes is deliberately out of scope: the older one +is gone upstream, and the API is still moving (see below). + +`operations.ts` holds every document the client sends, and is the **only** file that spells a +graph field name. `TiledGraph.test.tsx` validates each of those documents against this snapshot and +diffs the snapshot's `Query` / `Mutation` fields against `TILED_GRAPH_OPERATIONS`, so a schema +change surfaces as a failing test — the GraphQL counterpart of `TiledRegistry.test.ts`. + +## Regenerating + +From a Python environment with the target Tiled installed: + +```sh +python -c "from tiled.graph.schema import schema; print(schema.as_str())" > /tmp/schema.graphql +``` + +or against a running server, with any GraphQL introspection tool, e.g. +`npx get-graphql-schema http://localhost:8000/api/graphql`. + +Keep the one-line provenance comment at the top of `schema.graphql` and update the commit above. +Then run: + +```sh +npm run test:run -- src/testing/tests/TiledGraph.test.tsx +``` + +A renamed or removed field fails document validation; a new query or mutation fails the +completeness check, which is the signal to add a document, a client method, a hook, a registry +descriptor and a playground entry. + +## Known upstream changes + +- **#1499** renames `entityType` → `kind` and adds a unique `(node, kind, name)` constraint with an + `ENTITY_EXISTS` error. +- **#1500** moves entity/link ids to UUIDs (still `ID` on the wire) and adds a `name` filter to + `entities`. +- **#1457** JSON-LD import/export; **#1487** a graph web UI. diff --git a/src/api/tiled/graph/operations.ts b/src/api/tiled/graph/operations.ts new file mode 100644 index 00000000..e2c2981b --- /dev/null +++ b/src/api/tiled/graph/operations.ts @@ -0,0 +1,371 @@ +/** + * Every GraphQL document the client sends to Tiled's graph endpoint. + * + * **This is the only file that spells a graph field name.** The graph API is experimental and + * upstream is still renaming things (#1499 turns `entityType` into `kind`), so keeping every + * selection here means such a rename is an edit to this file and `../types/graph.ts`, and + * `TiledGraph.test.tsx` — which validates each document against `schema.graphql` — says exactly + * which documents broke. + * + * Selections are deliberately one level deep: an entity's fields, a link's fields with ids for its + * ends. Nested traversal (`incomingLinks { subject { … } }`) is what `TiledApiClient.graphql` is + * for; the server caps nesting depth at 10. + * + * `limit` / `offset` are declared `Int!` with the server's own defaults, so leaving them out of the + * variables gets the server default rather than an explicit `null` the server would reject. + */ + +const ENTITY_FIELDS = /* GraphQL */ ` + fragment TiledGraphEntityFields on Entity { + id + isNodeBound + entityType + name + uri + properties + createdAt + } +`; + +const LINK_FIELDS = /* GraphQL */ ` + fragment TiledGraphLinkFields on Link { + id + subjectId + predicate + objectId + properties + accessTags + createdAt + } +`; + +// #region queries + +export const GRAPH_ENTITY = /* GraphQL */ ` + query TiledGraphEntity($id: ID!) { + entity(id: $id) { + ...TiledGraphEntityFields + } + } + ${ENTITY_FIELDS} +`; + +export const GRAPH_ENTITIES = /* GraphQL */ ` + query TiledGraphEntities( + $entityType: String + $nodePathParts: [String!] + $limit: Int! = 100 + $offset: Int! = 0 + ) { + entities( + entityType: $entityType + nodePathParts: $nodePathParts + limit: $limit + offset: $offset + ) { + ...TiledGraphEntityFields + } + } + ${ENTITY_FIELDS} +`; + +export const GRAPH_LINK = /* GraphQL */ ` + query TiledGraphLink($id: ID!) { + link(id: $id) { + ...TiledGraphLinkFields + } + } + ${LINK_FIELDS} +`; + +export const GRAPH_LINKS = /* GraphQL */ ` + query TiledGraphLinks( + $subjectId: ID + $predicate: String + $objectId: ID + $limit: Int! = 100 + $offset: Int! = 0 + ) { + links( + subjectId: $subjectId + predicate: $predicate + objectId: $objectId + limit: $limit + offset: $offset + ) { + ...TiledGraphLinkFields + } + } + ${LINK_FIELDS} +`; + +export const GRAPH_NAMESPACES = /* GraphQL */ ` + query TiledGraphNamespaces { + namespaces { + prefix + uri + } + } +`; + +// #endregion + +// #region mutations + +export const GRAPH_CREATE_ENTITY = /* GraphQL */ ` + mutation TiledGraphCreateEntity($input: CreateEntityInput!) { + createEntity(input: $input) { + ...TiledGraphEntityFields + } + } + ${ENTITY_FIELDS} +`; + +export const GRAPH_UPDATE_ENTITY = /* GraphQL */ ` + mutation TiledGraphUpdateEntity($id: ID!, $input: UpdateEntityInput!) { + updateEntity(id: $id, input: $input) { + ...TiledGraphEntityFields + } + } + ${ENTITY_FIELDS} +`; + +export const GRAPH_DELETE_ENTITY = /* GraphQL */ ` + mutation TiledGraphDeleteEntity($id: ID!) { + deleteEntity(id: $id) + } +`; + +export const GRAPH_CREATE_LINK = /* GraphQL */ ` + mutation TiledGraphCreateLink($input: CreateLinkInput!) { + createLink(input: $input) { + ...TiledGraphLinkFields + } + } + ${LINK_FIELDS} +`; + +export const GRAPH_UPDATE_LINK = /* GraphQL */ ` + mutation TiledGraphUpdateLink($id: ID!, $input: UpdateLinkInput!) { + updateLink(id: $id, input: $input) { + ...TiledGraphLinkFields + } + } + ${LINK_FIELDS} +`; + +export const GRAPH_DELETE_LINK = /* GraphQL */ ` + mutation TiledGraphDeleteLink($id: ID!) { + deleteLink(id: $id) + } +`; + +export const GRAPH_UPSERT_NAMESPACE = /* GraphQL */ ` + mutation TiledGraphUpsertNamespace($prefix: String!, $uri: String!) { + upsertNamespace(prefix: $prefix, uri: $uri) { + prefix + uri + } + } +`; + +export const GRAPH_DELETE_NAMESPACE = /* GraphQL */ ` + mutation TiledGraphDeleteNamespace($prefix: String!) { + deleteNamespace(prefix: $prefix) + } +`; + +// #endregion + +/** One operation the client implements, described as data. */ +export interface TiledGraphOperation { + /** The GraphQL operation name, which also prefixes error messages. */ + name: string; + kind: 'query' | 'mutation'; + /** The root `Query` / `Mutation` field it selects — what the completeness test diffs. */ + field: string; + document: string; +} + +/** + * Every operation, one per root field of the schema. `TiledGraph.test.tsx` fails when the schema has + * a root field this list does not, or the reverse. + */ +export const TILED_GRAPH_OPERATIONS: readonly TiledGraphOperation[] = [ + { name: 'TiledGraphEntity', kind: 'query', field: 'entity', document: GRAPH_ENTITY }, + { name: 'TiledGraphEntities', kind: 'query', field: 'entities', document: GRAPH_ENTITIES }, + { name: 'TiledGraphLink', kind: 'query', field: 'link', document: GRAPH_LINK }, + { name: 'TiledGraphLinks', kind: 'query', field: 'links', document: GRAPH_LINKS }, + { + name: 'TiledGraphNamespaces', + kind: 'query', + field: 'namespaces', + document: GRAPH_NAMESPACES, + }, + { + name: 'TiledGraphCreateEntity', + kind: 'mutation', + field: 'createEntity', + document: GRAPH_CREATE_ENTITY, + }, + { + name: 'TiledGraphUpdateEntity', + kind: 'mutation', + field: 'updateEntity', + document: GRAPH_UPDATE_ENTITY, + }, + { + name: 'TiledGraphDeleteEntity', + kind: 'mutation', + field: 'deleteEntity', + document: GRAPH_DELETE_ENTITY, + }, + { + name: 'TiledGraphCreateLink', + kind: 'mutation', + field: 'createLink', + document: GRAPH_CREATE_LINK, + }, + { + name: 'TiledGraphUpdateLink', + kind: 'mutation', + field: 'updateLink', + document: GRAPH_UPDATE_LINK, + }, + { + name: 'TiledGraphDeleteLink', + kind: 'mutation', + field: 'deleteLink', + document: GRAPH_DELETE_LINK, + }, + { + name: 'TiledGraphUpsertNamespace', + kind: 'mutation', + field: 'upsertNamespace', + document: GRAPH_UPSERT_NAMESPACE, + }, + { + name: 'TiledGraphDeleteNamespace', + kind: 'mutation', + field: 'deleteNamespace', + document: GRAPH_DELETE_NAMESPACE, + }, +]; + +/** + * The oldest Tiled release whose graph API these documents match. + * + * The graph has shipped since 0.2.15, but 0.2.15–0.2.18 have an older shape — an entity exposes + * `nodeId` instead of `isNodeBound`, is bound by `nodeId` instead of `nodePathParts`, and access + * control is `accessBlob` instead of `accessTags` — so every entity and link document fails + * validation there. Only the namespace operations work on both. + */ +export const TILED_GRAPH_MIN_VERSION = '0.2.19'; + +/** Fields and arguments these documents use that first appeared in {@link TILED_GRAPH_MIN_VERSION}. */ +const FIELDS_ADDED_IN_MIN_VERSION = ['isNodeBound', 'nodePathParts', 'accessTags'] as const; + +/** GraphQL validation messages that mean "this schema does not have that name". */ +const UNKNOWN_NAME = /Cannot query field|Unknown argument|is not defined by type/; + +/** + * The first field newer than the server's graph API that `errors` complain about, or `undefined`. + * + * Matches the messages a Tiled 0.2.18 server actually returns for these documents, e.g. + * `Cannot query field 'isNodeBound' on type 'Entity'.` — so a caller gets "upgrade the server" + * rather than a validation error that reads like a bug in Finch. + */ +export function fieldMissingFromOlderGraphApi( + errors: readonly { message: string }[] | undefined, +): string | undefined { + for (const { message } of errors ?? []) { + if (!UNKNOWN_NAME.test(message)) continue; + const field = FIELDS_ADDED_IN_MIN_VERSION.find((name) => message.includes(`'${name}'`)); + if (field) return field; + } + return undefined; +} + +/** The kind of each operation a GraphQL document defines. */ +export type TiledGraphOperationKind = 'query' | 'mutation' | 'subscription'; + +/** + * The kind of every operation `document` defines, in order: `query` for a `query …` definition or + * the `{ … }` shorthand, `mutation`, `subscription`. Fragments are not operations and are skipped. + * + * A small scanner rather than the `graphql` package's parser, which is a devDependency only: it + * looks at top-level definitions and skips what could otherwise fool it — `#` comments, strings and + * block strings, and parentheses, whose variable defaults and directive arguments may hold `{ }`. + * It classifies; it does not validate. An unparseable document yields whatever it could see, and + * the server reports the rest. + * + * ```ts + * graphOperationKinds('{ entities { id } }'); // ['query'] + * graphOperationKinds('mutation ($i: CreateLinkInput!) { … }'); // ['mutation'] + * graphOperationKinds('fragment F on Entity { id } query { … }'); // ['query'] + * ``` + */ +export function graphOperationKinds(document: string): TiledGraphOperationKind[] { + const kinds: TiledGraphOperationKind[] = []; + let braces = 0; + let parens = 0; + // A definition keyword was read at the top level; its own `{` opens its body, not a shorthand. + let awaitingBody = false; + let i = 0; + const n = document.length; + + while (i < n) { + const c = document[i]; + if (c === '#') { + while (i < n && document[i] !== '\n' && document[i] !== '\r') i++; + } else if (document.startsWith('"""', i)) { + i += 3; + while (i < n && !document.startsWith('"""', i)) { + i += document.startsWith('\\"""', i) ? 4 : 1; + } + i += 3; + } else if (c === '"') { + i++; + while (i < n && document[i] !== '"' && document[i] !== '\n') { + i += document[i] === '\\' ? 2 : 1; + } + i++; + } else if (c === '(') { + parens++; + i++; + } else if (c === ')') { + parens = Math.max(0, parens - 1); + i++; + } else if (c === '{' && parens === 0) { + if (braces === 0) { + if (!awaitingBody) kinds.push('query'); + awaitingBody = false; + } + braces++; + i++; + } else if (c === '}' && parens === 0) { + braces = Math.max(0, braces - 1); + i++; + } else if (/[_A-Za-z]/.test(c)) { + const start = i; + while (i < n && /[_0-9A-Za-z]/.test(document[i])) i++; + if (braces === 0 && parens === 0 && !awaitingBody) { + const word = document.slice(start, i); + if (word === 'query' || word === 'mutation' || word === 'subscription') { + kinds.push(word); + awaitingBody = true; + } else if (word === 'fragment') { + awaitingBody = true; + } + } + } else { + i++; + } + } + return kinds; +} + +/** The operation name a document declares, for error messages. `undefined` for anonymous ones. */ +export function graphOperationName(document: string): string | undefined { + return /\b(?:query|mutation|subscription)\s+([_A-Za-z][_0-9A-Za-z]*)/.exec(document)?.[1]; +} diff --git a/src/api/tiled/graph/schema.graphql b/src/api/tiled/graph/schema.graphql new file mode 100644 index 00000000..d5cb69aa --- /dev/null +++ b/src/api/tiled/graph/schema.graphql @@ -0,0 +1,105 @@ +# Generated by `tiled.graph.schema:schema.as_str()` at bluesky/tiled@849c984a010352b9a272a7fcc7af4b341e0becb9 — do not hand-edit. See README.md. + +input CreateEntityInput { + entityType: String! + name: String! + nodePathParts: [String!] = null + uri: String = null + properties: JSON = null + accessTags: [String!] = null +} + +input CreateLinkInput { + subjectId: ID! + predicate: String! + objectId: ID! + properties: JSON = null + accessTags: [String!] = null +} + +type Entity { + id: ID! + isNodeBound: Boolean! + entityType: String! + name: String! + uri: String + properties: JSON + createdAt: String! + + """Links where this entity is the subject.""" + outgoingLinks(predicate: String = null, limit: Int! = 100, offset: Int! = 0): [Link!]! + + """Links where this entity is the object.""" + incomingLinks(predicate: String = null, limit: Int! = 100, offset: Int! = 0): [Link!]! +} + +""" +The `JSON` scalar type represents JSON values as specified by [ECMA-404](https://ecma-international.org/wp-content/uploads/ECMA-404_2nd_edition_december_2017.pdf). +""" +scalar JSON @specifiedBy(url: "https://ecma-international.org/wp-content/uploads/ECMA-404_2nd_edition_december_2017.pdf") + +type Link { + id: ID! + subjectId: ID! + predicate: String! + objectId: ID! + properties: JSON + accessTags: [String!]! + createdAt: String! + subject: Entity + object: Entity +} + +type Mutation { + createEntity(input: CreateEntityInput!): Entity! + createLink(input: CreateLinkInput!): Link! + + """Delete an entity and all its attached links. Returns true if found.""" + deleteEntity(id: ID!): Boolean! + + """Update an entity's name, uri, or entity_type.""" + updateEntity(id: ID!, input: UpdateEntityInput!): Entity + + """Delete a single link. Returns true if found.""" + deleteLink(id: ID!): Boolean! + + """Update a link's predicate.""" + updateLink(id: ID!, input: UpdateLinkInput!): Link + + """Register or update a CURIE prefix -> URI mapping.""" + upsertNamespace(prefix: String!, uri: String!): Namespace! + + """Delete a registered namespace. Returns true if found.""" + deleteNamespace(prefix: String!): Boolean! +} + +"""A CURIE prefix -> URI mapping used to expand/compact terms.""" +type Namespace { + prefix: String! + uri: String! +} + +type Query { + entity(id: ID!): Entity + entities(entityType: String = null, nodePathParts: [String!] = null, limit: Int! = 100, offset: Int! = 0): [Entity!]! + link(id: ID!): Link + + """Find links, optionally filtered by subject, predicate, and/or object.""" + links(subjectId: ID = null, predicate: String = null, objectId: ID = null, limit: Int! = 100, offset: Int! = 0): [Link!]! + + """List registered CURIE prefix -> URI mappings.""" + namespaces: [Namespace!]! +} + +input UpdateEntityInput { + name: String = null + nodePathParts: [String!] + uri: String + entityType: String = null + accessTags: [String!] +} + +input UpdateLinkInput { + predicate: String + accessTags: [String!] +} diff --git a/src/api/tiled/hooks/graphHooks.ts b/src/api/tiled/hooks/graphHooks.ts new file mode 100644 index 00000000..5dce779f --- /dev/null +++ b/src/api/tiled/hooks/graphHooks.ts @@ -0,0 +1,550 @@ +import type { UseMutationResult, UseQueryResult } from '@tanstack/react-query'; +import { requireArg } from '@/api/shared/errors'; +import { graphOperationKinds } from '../graph/operations'; +import type { TiledRequestOptions } from '../types/common'; +import { TiledApiError } from '../types/errors'; +import { TILED_GRAPHQL_PATH } from '../types/paths'; +import type { + TiledGraphBindEntityInput, + TiledGraphCreateEntityInput, + TiledGraphCreateLinkInput, + TiledGraphEntity, + TiledGraphEntityFilters, + TiledGraphLink, + TiledGraphLinkFilters, + TiledGraphNamespace, + TiledGraphNodeEntityFilters, + TiledGraphQLVariables, + TiledGraphUpdateEntityInput, + TiledGraphUpdateLinkInput, +} from '../types/graph'; +import { useTiledMutation } from './internal/useTiledMutation'; +import { useTiledQuery } from './internal/useTiledQuery'; +import { TILED_MUTATION_INVALIDATIONS } from './invalidation'; +import { tiledQueryKeys, type TiledQueryKeyFor } from './queryKeys'; +import type { FinchMutationOptions, FinchQueryOptions, TiledHookError } from './types'; +import { useTiledQueryScope } from './useTiledClient'; + +/** + * Tiled's experimental entity/link graph — `{origin}/api/graphql`. + * + * Every read keys under the one `graph` root and every write refreshes all of it; see + * `TiledGraphKeyArgs` for why. Deleting a catalog node refreshes it too, because the server deletes + * the entities bound to that node. + * + * Two things to know before using these: + * + * - **Reads never answer 401.** The server filters what the caller may see instead: behind an + * access policy an unauthenticated read is empty lists, and on a server with no policy it is + * everything. If everything is empty, check the API key first. + * - **Lists are single pages with no count.** The server pages at 100; a page shorter than `limit` + * is the only end-of-list signal. Page with `offset`. + */ + +type GraphKey = TiledQueryKeyFor<'graph'>; + +// #region raw GraphQL + +/** + * Any GraphQL document against the graph endpoint — for selections the typed hooks do not make, + * chiefly nested traversal: + * + * ```tsx + * const PROJECT_SAMPLES = `query ($id: ID!) { + * entity(id: $id) { + * name + * incomingLinks(predicate: "schema:isPartOf") { subject { id name } } + * } + * }`; + * const project = useTiledGraphQLQuery<{ entity: … }>(PROJECT_SAMPLES, { id: projectId }); + * ``` + * + * **The result type is yours to declare; nothing checks it.** Pass parameters as `variables`, not by + * interpolating them into the document. Idles while `document` is blank. + * + * **Queries only.** A document defining a `mutation` or `subscription` is rejected before it is + * sent: a write run as a query would execute, then repeat on every refetch. Use + * `useTiledGraphQLMutation` for writes. + * + * Define the document at module scope or memoise it: it is part of the cache key, so a string + * rebuilt every render with different whitespace is a different entry. + * + * @param document A GraphQL query. + * @param variables Its variables. + * @param queryOptions TanStack options. + * @param requestOptions Transport overrides. + */ +export function useTiledGraphQLQuery, TData = TResult>( + document: string, + variables?: TiledGraphQLVariables, + queryOptions?: FinchQueryOptions, + requestOptions?: TiledRequestOptions, +): UseQueryResult { + const scope = useTiledQueryScope(requestOptions); + const vars = variables ?? {}; + + return useTiledQuery({ + queryKey: tiledQueryKeys.graph(scope, { op: 'raw', document, variables: vars }), + fetch: (client, request) => { + const query = requireArg( + document.trim() || undefined, + 'useTiledGraphQLQuery', + 'document', + ); + // Refused before anything is sent. GraphQL writes travel over the same POST as reads, + // so a mutation here would execute — and TanStack re-runs a query on remount, refocus, + // polling and invalidation, repeating the write each time. + const writes = graphOperationKinds(query).filter((kind) => kind !== 'query'); + if (writes.length > 0) { + return Promise.reject( + new TiledApiError({ + message: + `useTiledGraphQLQuery only runs queries, but this document defines a ` + + `${writes[0]}. Use useTiledGraphQLMutation for writes.`, + method: 'POST', + path: TILED_GRAPHQL_PATH, + }), + ); + } + return client.graphql(query, vars, request); + }, + requestOptions, + queryOptions, + defaultEnabled: document.trim().length > 0, + }); +} + +/** What `useTiledGraphQLMutation().mutate` takes. */ +export interface TiledGraphQLMutationVariables { + /** A GraphQL mutation document. */ + document: string; + variables?: TiledGraphQLVariables; +} + +/** + * Any GraphQL mutation against the graph endpoint — e.g. several aliased `createLink`s in one + * request. + * + * Refreshes every graph read on success **and on failure**, before `mutateAsync` settles. + * + * **A failure can follow a partial write.** `createEntity` and `createLink` are declared non-null, + * so when one aliased field fails, GraphQL nulls the whole `data` — but the fields before it have + * already been committed, and the server does not roll them back. That is why a failure refreshes + * the cache too; the rejection still carries the server's error. Prefer one write per request + * unless you are prepared to reconcile. + */ +export function useTiledGraphQLMutation, TContext = unknown>( + mutationOptions?: FinchMutationOptions< + TResult, + TiledGraphQLMutationVariables, + TContext, + TiledHookError + >, + requestOptions?: TiledRequestOptions, +): UseMutationResult { + return useTiledMutation({ + perform: (client, { document, variables }, request) => + client.graphql(document, variables, request), + invalidates: TILED_MUTATION_INVALIDATIONS.useTiledGraphQLMutation, + invalidateOnError: true, + requestOptions, + mutationOptions, + }); +} + +// #endregion + +// #region entities + +/** + * One entity by id. `data` is `null` when it does not exist **or the caller may not see it** — the + * server does not distinguish the two. Idles while `id` is empty. + * + * @param id The entity id. + * @param queryOptions TanStack options. + * @param requestOptions Transport overrides. + */ +export function useTiledGraphEntityQuery( + id: string | undefined, + queryOptions?: FinchQueryOptions, + requestOptions?: TiledRequestOptions, +): UseQueryResult { + const scope = useTiledQueryScope(requestOptions); + + return useTiledQuery({ + queryKey: tiledQueryKeys.graph(scope, { op: 'entity', id: id ?? '' }), + fetch: (client, request) => + client.getGraphEntity( + requireArg(id || undefined, 'useTiledGraphEntityQuery', 'id'), + request, + ), + requestOptions, + queryOptions, + defaultEnabled: !!id, + }); +} + +/** + * One page of entities — by type (`{ entityType: 'project' }`), by bound catalog node + * (`{ nodePathParts: ['raw', 'S001'] }`), or all of them. + * + * @param filters Filters and paging. Omitted means the first 100 of everything. + * @param queryOptions TanStack options. + * @param requestOptions Transport overrides. + */ +export function useTiledGraphEntitiesQuery( + filters?: TiledGraphEntityFilters, + queryOptions?: FinchQueryOptions, + requestOptions?: TiledRequestOptions, +): UseQueryResult { + const scope = useTiledQueryScope(requestOptions); + const resolved = filters ?? {}; + + return useTiledQuery({ + queryKey: tiledQueryKeys.graph(scope, { op: 'entities', filters: resolved }), + fetch: (client, request) => client.getGraphEntities(resolved, request), + requestOptions, + queryOptions, + }); +} + +/** + * Create an entity. Bind it to a catalog node with `nodePathParts`; a path that names no node fails + * with `graphqlCode === 'NODE_NOT_FOUND'`. Needs `write:metadata`. + * + * On `main` nothing prevents two entities with the same type and name + */ +export function useTiledCreateGraphEntityMutation( + mutationOptions?: FinchMutationOptions< + TiledGraphEntity, + TiledGraphCreateEntityInput, + TContext, + TiledHookError + >, + requestOptions?: TiledRequestOptions, +): UseMutationResult { + return useTiledMutation({ + perform: (client, input, request) => client.createGraphEntity(input, request), + invalidates: TILED_MUTATION_INVALIDATIONS.useTiledCreateGraphEntityMutation, + invalidateOnError: true, + requestOptions, + mutationOptions, + }); +} + +/** What `useTiledUpdateGraphEntityMutation().mutate` takes. */ +export interface TiledUpdateGraphEntityVariables { + id: string; + /** Only the fields to change: omitted is untouched, `null` clears. */ + input: TiledGraphUpdateEntityInput; +} + +/** + * Change an entity. **Omitted fields are untouched; `null` clears them** — so + * `{ input: { nodePathParts: null } }` detaches the entity from its node and changes nothing else. + * Resolves `null` when there is no such entity. + */ +export function useTiledUpdateGraphEntityMutation( + mutationOptions?: FinchMutationOptions< + TiledGraphEntity | null, + TiledUpdateGraphEntityVariables, + TContext, + TiledHookError + >, + requestOptions?: TiledRequestOptions, +): UseMutationResult< + TiledGraphEntity | null, + TiledHookError, + TiledUpdateGraphEntityVariables, + TContext +> { + return useTiledMutation({ + perform: (client, { id, input }, request) => client.updateGraphEntity(id, input, request), + invalidates: TILED_MUTATION_INVALIDATIONS.useTiledUpdateGraphEntityMutation, + invalidateOnError: true, + requestOptions, + mutationOptions, + }); +} + +/** Delete an entity **and every link attached to it**. Resolves whether it existed. */ +export function useTiledDeleteGraphEntityMutation( + mutationOptions?: FinchMutationOptions, + requestOptions?: TiledRequestOptions, +): UseMutationResult { + return useTiledMutation({ + perform: (client, { id }, request) => client.deleteGraphEntity(id, request), + invalidates: TILED_MUTATION_INVALIDATIONS.useTiledDeleteGraphEntityMutation, + invalidateOnError: true, + requestOptions, + mutationOptions, + }); +} + +// #endregion + +// #region entities bound to catalog nodes + +/** + * The entities bound to one catalog node — "what does the graph say about this run?". + * + * `path` is a Finch path like every other hook's: relative to the client's `initialPath` unless + * `requestOptions.pathMode` is `'absolute'`. Idles while `path` is empty (the root cannot be bound). + * + * @param path The node's path. + * @param filters Type and paging. Omitted means the first 100. + * @param queryOptions TanStack options. + * @param requestOptions Transport overrides. + */ +export function useTiledGraphNodeEntitiesQuery( + path: string, + filters?: TiledGraphNodeEntityFilters, + queryOptions?: FinchQueryOptions, + requestOptions?: TiledRequestOptions, +): UseQueryResult { + const scope = useTiledQueryScope(requestOptions); + const resolved = filters ?? {}; + + return useTiledQuery({ + queryKey: tiledQueryKeys.graph(scope, { op: 'nodeEntities', path, filters: resolved }), + fetch: (client, request) => + client.getGraphNodeEntities( + requireArg(path || undefined, 'useTiledGraphNodeEntitiesQuery', 'path'), + resolved, + request, + ), + requestOptions, + queryOptions, + defaultEnabled: path.length > 0, + }); +} + +/** What `useTiledBindGraphEntityMutation().mutate` takes. */ +export interface TiledBindGraphEntityVariables { + /** The catalog node to bind, as a Finch path (relative to `initialPath`). */ + path: string; + input: TiledGraphBindEntityInput; +} + +/** + * Create an entity bound to a catalog node, recording the node in `uri` so it can be found again. + * + * The server never says which node an entity is bound to, so this sets `nodePathParts` (resolved + * against `initialPath`) and the node's metadata URL as `uri` together, as upstream's Python + * `bind_entity` does. Read the path back with `client.graphEntityNodePath(entity)` or + * `tiledGraphEntityNodePath(entity)`. `name` defaults to the node's key. + */ +export function useTiledBindGraphEntityMutation( + mutationOptions?: FinchMutationOptions< + TiledGraphEntity, + TiledBindGraphEntityVariables, + TContext, + TiledHookError + >, + requestOptions?: TiledRequestOptions, +): UseMutationResult { + return useTiledMutation({ + perform: (client, { path, input }, request) => client.bindGraphEntity(path, input, request), + invalidates: TILED_MUTATION_INVALIDATIONS.useTiledBindGraphEntityMutation, + invalidateOnError: true, + requestOptions, + mutationOptions, + }); +} + +// #endregion + +// #region links + +/** + * One link by id; `null` when it does not exist or the caller may not see it. Idles while `id` is + * empty. + * + * @param id The link id. + * @param queryOptions TanStack options. + * @param requestOptions Transport overrides. + */ +export function useTiledGraphLinkQuery( + id: string | undefined, + queryOptions?: FinchQueryOptions, + requestOptions?: TiledRequestOptions, +): UseQueryResult { + const scope = useTiledQueryScope(requestOptions); + + return useTiledQuery({ + queryKey: tiledQueryKeys.graph(scope, { op: 'link', id: id ?? '' }), + fetch: (client, request) => + client.getGraphLink( + requireArg(id || undefined, 'useTiledGraphLinkQuery', 'id'), + request, + ), + requestOptions, + queryOptions, + defaultEnabled: !!id, + }); +} + +/** + * One page of links. `{ objectId, predicate }` is what points *at* an entity — a project's samples, + * with `predicate: 'schema:isPartOf'`; `{ subjectId }` is what it points at. + * + * Returns ids for each end, not the entities; nest `subject { … }` through `useTiledGraphQLQuery` + * to get both in one request. + * + * @param filters Filters and paging. Omitted means the first 100 links. + * @param queryOptions TanStack options. + * @param requestOptions Transport overrides. + */ +export function useTiledGraphLinksQuery( + filters?: TiledGraphLinkFilters, + queryOptions?: FinchQueryOptions, + requestOptions?: TiledRequestOptions, +): UseQueryResult { + const scope = useTiledQueryScope(requestOptions); + const resolved = filters ?? {}; + + return useTiledQuery({ + queryKey: tiledQueryKeys.graph(scope, { op: 'links', filters: resolved }), + fetch: (client, request) => client.getGraphLinks(resolved, request), + requestOptions, + queryOptions, + }); +} + +/** Create a link `subject —predicate→ object`. Needs `write:metadata`. */ +export function useTiledCreateGraphLinkMutation( + mutationOptions?: FinchMutationOptions< + TiledGraphLink, + TiledGraphCreateLinkInput, + TContext, + TiledHookError + >, + requestOptions?: TiledRequestOptions, +): UseMutationResult { + return useTiledMutation({ + perform: (client, input, request) => client.createGraphLink(input, request), + invalidates: TILED_MUTATION_INVALIDATIONS.useTiledCreateGraphLinkMutation, + invalidateOnError: true, + requestOptions, + mutationOptions, + }); +} + +/** What `useTiledUpdateGraphLinkMutation().mutate` takes. */ +export interface TiledUpdateGraphLinkVariables { + id: string; + /** Only the fields to change: omitted is untouched, `null` clears. */ + input: TiledGraphUpdateLinkInput; +} + +/** Change a link's predicate or access tags. Resolves `null` when there is no such link. */ +export function useTiledUpdateGraphLinkMutation( + mutationOptions?: FinchMutationOptions< + TiledGraphLink | null, + TiledUpdateGraphLinkVariables, + TContext, + TiledHookError + >, + requestOptions?: TiledRequestOptions, +): UseMutationResult< + TiledGraphLink | null, + TiledHookError, + TiledUpdateGraphLinkVariables, + TContext +> { + return useTiledMutation({ + perform: (client, { id, input }, request) => client.updateGraphLink(id, input, request), + invalidates: TILED_MUTATION_INVALIDATIONS.useTiledUpdateGraphLinkMutation, + invalidateOnError: true, + requestOptions, + mutationOptions, + }); +} + +/** Delete one link. Resolves whether it existed. */ +export function useTiledDeleteGraphLinkMutation( + mutationOptions?: FinchMutationOptions, + requestOptions?: TiledRequestOptions, +): UseMutationResult { + return useTiledMutation({ + perform: (client, { id }, request) => client.deleteGraphLink(id, request), + invalidates: TILED_MUTATION_INVALIDATIONS.useTiledDeleteGraphLinkMutation, + invalidateOnError: true, + requestOptions, + mutationOptions, + }); +} + +// #endregion + +// #region namespaces + +/** + * The registered CURIE prefixes. + * + * @param queryOptions TanStack options. + * @param requestOptions Transport overrides. + */ +export function useTiledGraphNamespacesQuery( + queryOptions?: FinchQueryOptions, + requestOptions?: TiledRequestOptions, +): UseQueryResult { + const scope = useTiledQueryScope(requestOptions); + + return useTiledQuery({ + queryKey: tiledQueryKeys.graph(scope, { op: 'namespaces' }), + fetch: (client, request) => client.getGraphNamespaces(request), + requestOptions, + queryOptions, + }); +} + +/** What `useTiledUpsertGraphNamespaceMutation().mutate` takes. */ +export interface TiledUpsertGraphNamespaceVariables { + prefix: string; + uri: string; +} + +/** + * Register or replace a CURIE prefix. Changes how every predicate and property renders, which is + * why it refreshes every graph read. + */ +export function useTiledUpsertGraphNamespaceMutation( + mutationOptions?: FinchMutationOptions< + TiledGraphNamespace, + TiledUpsertGraphNamespaceVariables, + TContext, + TiledHookError + >, + requestOptions?: TiledRequestOptions, +): UseMutationResult< + TiledGraphNamespace, + TiledHookError, + TiledUpsertGraphNamespaceVariables, + TContext +> { + return useTiledMutation({ + perform: (client, { prefix, uri }, request) => + client.upsertGraphNamespace(prefix, uri, request), + invalidates: TILED_MUTATION_INVALIDATIONS.useTiledUpsertGraphNamespaceMutation, + invalidateOnError: true, + requestOptions, + mutationOptions, + }); +} + +/** Remove a CURIE prefix. Resolves whether it existed. */ +export function useTiledDeleteGraphNamespaceMutation( + mutationOptions?: FinchMutationOptions, + requestOptions?: TiledRequestOptions, +): UseMutationResult { + return useTiledMutation({ + perform: (client, { prefix }, request) => client.deleteGraphNamespace(prefix, request), + invalidates: TILED_MUTATION_INVALIDATIONS.useTiledDeleteGraphNamespaceMutation, + invalidateOnError: true, + requestOptions, + mutationOptions, + }); +} + +// #endregion diff --git a/src/api/tiled/hooks/index.ts b/src/api/tiled/hooks/index.ts index 8f01d4c4..ca584199 100644 --- a/src/api/tiled/hooks/index.ts +++ b/src/api/tiled/hooks/index.ts @@ -22,7 +22,7 @@ // Shared types and errors export type { FinchMutationOptions, FinchQueryOptions, TiledHookError } from './types'; export { TiledEndpointUnavailableError, isTiledEndpointUnavailableError } from './errors'; -export { TiledApiError, isTiledApiError } from '../types/errors'; +export { TiledApiError, isTiledApiError, isTiledGraphQLError } from '../types/errors'; // Shared across backends; no Tiled hook raises it today, but it is part of the common surface. export { FinchMissingArgumentError, isFinchMissingArgumentError } from '@/api/shared/errors'; @@ -42,6 +42,7 @@ export type { TiledArrayKeyArgs, TiledAssetKeyArgs, TiledDistinctKeyArgs, + TiledGraphKeyArgs, TiledNodeKeyArgs, TiledQueryKeyFor, TiledQueryRootName, @@ -191,4 +192,31 @@ export { } from './authHooks'; export type { TiledLoginVariables, TiledCreateApiKeyVariables } from './authHooks'; +export { + useTiledGraphQLQuery, + useTiledGraphQLMutation, + useTiledGraphEntityQuery, + useTiledGraphEntitiesQuery, + useTiledCreateGraphEntityMutation, + useTiledUpdateGraphEntityMutation, + useTiledDeleteGraphEntityMutation, + useTiledGraphNodeEntitiesQuery, + useTiledBindGraphEntityMutation, + useTiledGraphLinkQuery, + useTiledGraphLinksQuery, + useTiledCreateGraphLinkMutation, + useTiledUpdateGraphLinkMutation, + useTiledDeleteGraphLinkMutation, + useTiledGraphNamespacesQuery, + useTiledUpsertGraphNamespaceMutation, + useTiledDeleteGraphNamespaceMutation, +} from './graphHooks'; +export type { + TiledBindGraphEntityVariables, + TiledGraphQLMutationVariables, + TiledUpdateGraphEntityVariables, + TiledUpdateGraphLinkVariables, + TiledUpsertGraphNamespaceVariables, +} from './graphHooks'; + // #endregion diff --git a/src/api/tiled/hooks/internal/useTiledMutation.ts b/src/api/tiled/hooks/internal/useTiledMutation.ts index 8d4e6082..553b297b 100644 --- a/src/api/tiled/hooks/internal/useTiledMutation.ts +++ b/src/api/tiled/hooks/internal/useTiledMutation.ts @@ -19,6 +19,7 @@ export interface TiledMutationEngineArgs { ) => Promise; /** Caches to refresh on success; see `TILED_MUTATION_INVALIDATIONS`. */ invalidates: readonly TiledInvalidationBundleName[]; + invalidateOnError?: boolean; /** The hook's request-options parameter, merged under the resolver's defaults. */ requestOptions?: TiledRequestOptions; /** The hook's TanStack options parameter. */ @@ -34,6 +35,7 @@ export interface TiledMutationEngineArgs { export function useTiledMutation({ perform, invalidates, + invalidateOnError = false, requestOptions, mutationOptions, }: TiledMutationEngineArgs): UseMutationResult< @@ -56,5 +58,17 @@ export function useTiledMutation({ await invalidateTiledRoots(queryClient, resolveInvalidationRoots(invalidates)); await mutationOptions?.onSuccess?.(...args); }, + // Awaited, like `onSuccess`, so the refresh has happened by the time `mutateAsync` rejects. + onError: async (...args) => { + if (invalidateOnError) { + try { + await invalidateTiledRoots(queryClient, resolveInvalidationRoots(invalidates)); + } catch { + // A failed refresh must not replace the write's own error, which is the one + // the caller needs to see. + } + } + await mutationOptions?.onError?.(...args); + }, }); } diff --git a/src/api/tiled/hooks/invalidation.ts b/src/api/tiled/hooks/invalidation.ts index 7bf5335e..e4ab5069 100644 --- a/src/api/tiled/hooks/invalidation.ts +++ b/src/api/tiled/hooks/invalidation.ts @@ -29,11 +29,16 @@ export const TILED_INVALIDATION_BUNDLES = { * Wider than `metadata` on purpose: creating, deleting or registering a node changes what a * *search* returns and what a parent *container* read contains, not just that node's own entry. */ - structure: ['search', 'distinct', 'metadata', 'revisions', 'container', 'node'], + structure: ['search', 'distinct', 'metadata', 'revisions', 'container', 'node', 'graph'], webhooks: ['webhooks'], info: ['serverInfo'], /** Identity changed. */ auth: ['auth'], + /** + * Anything in the entity/link graph. One root for every graph read, raw documents included — + * see `TiledGraphKeyArgs` for why the writes cannot be scoped narrower. + */ + graph: ['graph'], /** Everything. Use after any change of identity. */ all: [ 'search', @@ -50,6 +55,7 @@ export const TILED_INVALIDATION_BUNDLES = { 'webhooks', 'serverInfo', 'auth', + 'graph', ], } as const satisfies Record; @@ -111,6 +117,17 @@ export const TILED_MUTATION_INVALIDATIONS = { useTiledRevokeApiKeyMutation: ['auth'], useTiledRefreshSessionMutation: ['all'], useTiledRevokeSessionMutation: ['all'], + // graph of links — every write refreshes every graph read; see `TiledGraphKeyArgs` + useTiledGraphQLMutation: ['graph'], + useTiledCreateGraphEntityMutation: ['graph'], + useTiledBindGraphEntityMutation: ['graph'], + useTiledUpdateGraphEntityMutation: ['graph'], + useTiledDeleteGraphEntityMutation: ['graph'], + useTiledCreateGraphLinkMutation: ['graph'], + useTiledUpdateGraphLinkMutation: ['graph'], + useTiledDeleteGraphLinkMutation: ['graph'], + useTiledUpsertGraphNamespaceMutation: ['graph'], + useTiledDeleteGraphNamespaceMutation: ['graph'], } as const satisfies Record; export type TiledMutationHookName = keyof typeof TILED_MUTATION_INVALIDATIONS; diff --git a/src/api/tiled/hooks/queryKeys.ts b/src/api/tiled/hooks/queryKeys.ts index c0930aa6..7b42286d 100644 --- a/src/api/tiled/hooks/queryKeys.ts +++ b/src/api/tiled/hooks/queryKeys.ts @@ -5,6 +5,12 @@ import type { TiledTableEndpoint, TiledTableReturnType, } from '../types/dataOptions'; +import type { + TiledGraphEntityFilters, + TiledGraphLinkFilters, + TiledGraphNodeEntityFilters, + TiledGraphQLVariables, +} from '../types/graph'; import type { TiledArrayKeyParts, TiledTableKeyParts } from './internal/keyParts'; /** @@ -46,8 +52,8 @@ export interface TiledQueryScope extends FinchQueryScope { /** * The resource prefix of every query hook. * - * Fourteen now, where there were five: the client covers the whole API rather than the read subset - * the package exposed. The grouping is by **resource**, not by endpoint — all seven search hooks + * Fifteen now, where there were five: the client covers the whole API rather than the read subset + * the package exposed, plus the graph-of-links GraphQL endpoint (`graph`). The grouping is by **resource**, not by endpoint — all seven search hooks * still share the `search` root, because they call one endpoint and the `TiledSearchConfig` in the * args fully determines the response, so two hooks that build the same filters correctly share one * entry and `['tiled','search']` invalidates every search however it was expressed. @@ -70,6 +76,7 @@ export const tiledQueryRoots = { webhooks: [TILED_QUERY_ROOT, 'webhooks'], serverInfo: [TILED_QUERY_ROOT, 'serverInfo'], auth: [TILED_QUERY_ROOT, 'auth'], + graph: [TILED_QUERY_ROOT, 'graph'], } as const satisfies Record; export type TiledQueryRootName = keyof typeof tiledQueryRoots; @@ -147,6 +154,35 @@ export type TiledWebhookKeyArgs = | { readonly kind: 'list'; readonly path: string } | { readonly kind: 'history'; readonly webhookId: number; readonly limit: number | null }; +/** + * What identifies one graph read. + * + * Every graph read shares the one `graph` root, distinguished by `op`, because graph writes ripple + * across all of them: deleting an entity deletes its links, and registering a namespace changes how + * every predicate and property is rendered. One root means every graph mutation can refresh + * everything graph-shaped, including raw documents whose contents the client cannot inspect. + * + * Filters are the caller's plain object — no signal or client rides along — and keys hash through + * JSON, so `{}` and `{ entityType: undefined }` are one entry. + */ +export type TiledGraphKeyArgs = + | { readonly op: 'entity'; readonly id: string } + | { readonly op: 'entities'; readonly filters: TiledGraphEntityFilters } + | { + readonly op: 'nodeEntities'; + /** As the caller wrote it; the scope's `initialPath` completes it. */ + readonly path: string; + readonly filters: TiledGraphNodeEntityFilters; + } + | { readonly op: 'link'; readonly id: string } + | { readonly op: 'links'; readonly filters: TiledGraphLinkFilters } + | { readonly op: 'namespaces' } + | { + readonly op: 'raw'; + readonly document: string; + readonly variables: TiledGraphQLVariables; + }; + /** * Key builders, one per resource. * @@ -197,6 +233,8 @@ export const tiledQueryKeys = { * inspector. That is exactly why a credential change invalidates rather than re-keys. */ auth: (scope: TiledQueryScope) => [...tiledQueryRoots.auth, null, scope] as const, + graph: (scope: TiledQueryScope, args: TiledGraphKeyArgs) => + [...tiledQueryRoots.graph, args, scope] as const, } as const; /** The key type a given resource produces, for parameterizing `FinchQueryOptions`. */ diff --git a/src/api/tiled/hooks/typeTests.ts b/src/api/tiled/hooks/typeTests.ts index d252123c..88630eb5 100644 --- a/src/api/tiled/hooks/typeTests.ts +++ b/src/api/tiled/hooks/typeTests.ts @@ -24,6 +24,13 @@ import { useTiledSearchQuery, } from './searchHooks'; import { useTiledTablePartitionAsJSONQuery } from './tableHooks'; +import type { TiledGraphEntity } from '../types/graph'; +import { + useTiledGraphEntitiesQuery, + useTiledGraphEntityQuery, + useTiledGraphQLQuery, + useTiledUpdateGraphEntityMutation, +} from './graphHooks'; /** * Compile-time assertions for the Tiled hook call shapes. @@ -201,3 +208,24 @@ function useInferenceChecks(): void { const info = useTiledSearchQuery(''); expectType(info.isError); } + +function useGraphChecks(): void { + // Typed graph reads resolve the entity shape; an id read can be `null`. + const entity = useTiledGraphEntityQuery('7'); + expectType(entity.data); + const entities = useTiledGraphEntitiesQuery({ entityType: 'project' }); + expectType(entities.data?.[0]?.entityType); + // @ts-expect-error `kind` is upstream's future name (#1499); `main` still says `entityType` + useTiledGraphEntitiesQuery({ kind: 'project' }); + + // The raw hook's result type is the caller's to declare. + const raw = useTiledGraphQLQuery<{ entity: { name: string } | null }>('query { … }'); + expectType(raw.data?.entity?.name); + + // Updates accept `null` to clear and omission to leave alone. + const update = useTiledUpdateGraphEntityMutation(); + update.mutate({ id: '7', input: { nodePathParts: null } }); + update.mutate({ id: '7', input: { name: 'renamed' } }); + // @ts-expect-error `properties` cannot be updated after creation + update.mutate({ id: '7', input: { properties: {} } }); +} diff --git a/src/api/tiled/index.ts b/src/api/tiled/index.ts index 5f8a3577..2d1846e8 100644 --- a/src/api/tiled/index.ts +++ b/src/api/tiled/index.ts @@ -150,11 +150,38 @@ export { } from './client/urlUtils'; /** Spec paths, for the registry and for callers building a URL themselves. */ -export { TILED_PATHS, TILED_API_PREFIX, buildPath, toClientPath, isApiPath } from './types/paths'; +export { + TILED_PATHS, + TILED_API_PREFIX, + TILED_GRAPHQL_PATH, + buildPath, + toClientPath, + isApiPath, +} from './types/paths'; export type { TiledPathAlias, TiledRegisteredPath, TiledPathScope } from './types/paths'; /** Errors. Every client rejection is one of these, or an `AbortError`. */ -export { TiledApiError, isTiledApiError, formatValidationErrors } from './types/errors'; +export { + TiledApiError, + isTiledApiError, + isTiledGraphQLError, + formatValidationErrors, + formatGraphQLErrors, +} from './types/errors'; + +/** + * The graph of links — Tiled's entity/link store, over GraphQL at + * `{origin}/api/graphql`. The client methods, free functions and hooks are exported with the rest; + * these are the types and the operation documents, for callers writing their own selections. + */ +export type * from './types/graph'; +export { + TILED_GRAPH_OPERATIONS, + TILED_GRAPH_MIN_VERSION, + graphOperationKinds, + graphOperationName, +} from './graph/operations'; +export type { TiledGraphOperation, TiledGraphOperationKind } from './graph/operations'; /** Structure-family narrowing for a `TiledSearchItem`. */ export { @@ -193,9 +220,12 @@ export { getEndpointsByGroup, getReadOnlyEndpoints, getWriteEndpoints, + TILED_GRAPH_ENDPOINTS, + getGraphEndpointById, } from './endpointRegistry'; export type { TiledEndpointDescriptor, + TiledGraphOperationDescriptor, TiledEndpointGroup, TiledEndpointInvocation, TiledEndpointParam, diff --git a/src/api/tiled/runtime/clientLike.ts b/src/api/tiled/runtime/clientLike.ts index 618e3f82..d5c2fa48 100644 --- a/src/api/tiled/runtime/clientLike.ts +++ b/src/api/tiled/runtime/clientLike.ts @@ -99,6 +99,26 @@ export type TiledClientLike = Pick< | 'refreshSession' | 'revokeSession' | 'logout' + // graph of links + | 'graphql' + | 'getGraphEntity' + | 'getGraphEntities' + | 'getGraphLink' + | 'getGraphLinks' + | 'getGraphNamespaces' + | 'createGraphEntity' + | 'updateGraphEntity' + | 'deleteGraphEntity' + | 'createGraphLink' + | 'updateGraphLink' + | 'deleteGraphLink' + | 'upsertGraphNamespace' + | 'deleteGraphNamespace' + | 'bindGraphEntity' + | 'getGraphNodeEntities' + | 'getGraphNodePathParts' + | 'getGraphNodeUri' + | 'graphEntityNodePath' // zarr | 'getZarrV2Url' | 'getZarrV3Url' @@ -180,6 +200,25 @@ export const TILED_CLIENT_LIKE_METHODS = [ 'refreshSession', 'revokeSession', 'logout', + 'graphql', + 'getGraphEntity', + 'getGraphEntities', + 'getGraphLink', + 'getGraphLinks', + 'getGraphNamespaces', + 'createGraphEntity', + 'updateGraphEntity', + 'deleteGraphEntity', + 'createGraphLink', + 'updateGraphLink', + 'deleteGraphLink', + 'upsertGraphNamespace', + 'deleteGraphNamespace', + 'bindGraphEntity', + 'getGraphNodeEntities', + 'getGraphNodePathParts', + 'getGraphNodeUri', + 'graphEntityNodePath', 'getZarrV2Url', 'getZarrV3Url', 'getBaseUrl', diff --git a/src/api/tiled/types/errors.ts b/src/api/tiled/types/errors.ts index a7f763ed..fb6975db 100644 --- a/src/api/tiled/types/errors.ts +++ b/src/api/tiled/types/errors.ts @@ -1,5 +1,6 @@ import axios from 'axios'; import type { HTTPValidationError, ValidationError } from './generatedAliases'; +import type { TiledGraphQLErrorEntry } from './graph'; /** HTTP verbs this client issues. */ export type TiledHttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; @@ -25,6 +26,14 @@ export class TiledApiError extends Error { /** True for FastAPI's 422; `validationErrors` is then populated. */ readonly isValidationError: boolean; readonly validationErrors?: ValidationError[]; + /** + * The `errors` array of a GraphQL response, for failures from the graph endpoint. + * + * Present whether the server answered 200 with errors (the usual case — a resolver failed) or + * 400 (the document did not parse or validate). `responseBody` keeps the whole envelope, + * including any partial `data`. + */ + readonly graphqlErrors?: TiledGraphQLErrorEntry[]; /** The original axios error, for callers that need `config`/`request`. */ readonly cause?: unknown; @@ -44,15 +53,51 @@ export class TiledApiError extends Error { this.cause = init.cause; this.validationErrors = extractValidationErrors(init.responseBody); this.isValidationError = init.status === 422 || this.validationErrors !== undefined; + this.graphqlErrors = extractGraphQLErrors(init.responseBody); // Restore the prototype chain — required for `instanceof` after TS downlevelling. Object.setPrototypeOf(this, TiledApiError.prototype); } + + /** The first machine-readable code in `graphqlErrors`, e.g. `'NODE_NOT_FOUND'`. */ + get graphqlCode(): string | undefined { + return this.graphqlErrors?.find((entry) => typeof entry.extensions?.code === 'string') + ?.extensions?.code; + } } export function isTiledApiError(error: unknown): error is TiledApiError { return error instanceof TiledApiError; } +/** Whether `error` is a failure reported by the graph endpoint's GraphQL `errors` array. */ +export function isTiledGraphQLError( + error: unknown, +): error is TiledApiError & { graphqlErrors: TiledGraphQLErrorEntry[] } { + return error instanceof TiledApiError && error.graphqlErrors !== undefined; +} + +/** The `errors` array of a GraphQL envelope, or `undefined` when `body` is not one. */ +export function extractGraphQLErrors(body: unknown): TiledGraphQLErrorEntry[] | undefined { + if (!body || typeof body !== 'object') return undefined; + const errors = (body as { errors?: unknown }).errors; + if (!Array.isArray(errors) || errors.length === 0) return undefined; + return errors.every( + (entry) => entry && typeof entry === 'object' && typeof entry.message === 'string', + ) + ? (errors as TiledGraphQLErrorEntry[]) + : undefined; +} + +/** `message [CODE]; message …` — every GraphQL error, each with its code when it has one. */ +export function formatGraphQLErrors(errors: TiledGraphQLErrorEntry[]): string { + return errors + .map((entry) => { + const code = entry.extensions?.code; + return typeof code === 'string' ? `${entry.message} [${code}]` : entry.message; + }) + .join('; '); +} + function extractValidationErrors(body: unknown): ValidationError[] | undefined { if (!body || typeof body !== 'object') return undefined; const detail = (body as HTTPValidationError).detail; @@ -71,9 +116,10 @@ export function formatValidationErrors(errors: ValidationError[] | undefined): s /** * Normalize an unknown rejection into a {@link TiledApiError}. * - * Tiled reports failures in two shapes — FastAPI's `{ detail }` for validation and framework - * errors, and its own `{ error: { code, message } }` envelope for some application errors — so the - * message is assembled from whichever is present before falling back to axios's own. + * Tiled reports failures in three shapes — FastAPI's `{ detail }` for validation and framework + * errors, its own `{ error: { code, message } }` envelope for some application errors, and the + * graph endpoint's GraphQL `{ errors: [...] }` — so the message is assembled from whichever is + * present before falling back to axios's own. * * An `AbortError` passes through untouched: a cancelled request is not an API failure, and TanStack * relies on being able to recognise it. @@ -132,6 +178,9 @@ function readServerMessage(body: unknown): string | undefined { const validation = extractValidationErrors(body); if (validation) return formatValidationErrors(validation); + const graphql = extractGraphQLErrors(body); + if (graphql) return formatGraphQLErrors(graphql); + const { detail, error } = body as { detail?: unknown; error?: unknown }; if (typeof detail === 'string') return detail; diff --git a/src/api/tiled/types/graph.ts b/src/api/tiled/types/graph.ts new file mode 100644 index 00000000..eac340b1 --- /dev/null +++ b/src/api/tiled/types/graph.ts @@ -0,0 +1,174 @@ +/** + * Types for Tiled's entity/link graph, served over GraphQL at `{origin}/api/graphql`. + * + * Hand-written, like `info.ts`, because the graph is not in `openapi.json`. They mirror + * `../graph/schema.graphql` — the committed SDL that `TiledGraph.test.tsx` validates every operation + * document against — so a field that drifts from the server fails a test rather than a request. + */ + +/** Arbitrary JSON, as carried by the `properties` fields (GraphQL `JSON` scalar). */ +export type TiledGraphJSON = unknown; + +/** + * A node in the graph: a project, a sample, a dataset — whatever `entityType` says. + * + * An entity is either **bound** to a node in this server's catalog (`isNodeBound`) or free-standing, + * optionally pointing elsewhere through `uri`. Bound entities are deleted with their catalog node. + */ +export interface TiledGraphEntity { + id: string; + /** Whether the entity is tied to a catalog node. Which node is not exposed; use `uri`. */ + isNodeBound: boolean; + /** The category, e.g. `'project'` or `'sample'`. Upstream will rename this `kind` (#1499). */ + entityType: string; + name: string; + uri: string | null; + /** + * Arbitrary JSON. Object **keys**, at any depth, are CURIE-expanded when written and compacted + * when read, against the namespaces registered at that moment: `schema:name` is stored as + * `https://schema.org/name` and read back as `schema:name`. Values are never rewritten. Register + * a prefix before writing keys that use it, or they are stored as literal text. + */ + properties: TiledGraphJSON | null; + /** ISO 8601 timestamp. */ + createdAt: string; +} + +/** A directed edge: `subject` —`predicate`→ `object`. */ +export interface TiledGraphLink { + id: string; + subjectId: string; + predicate: string; + objectId: string; + /** Same key expansion and compaction as {@link TiledGraphEntity.properties}. */ + properties: TiledGraphJSON | null; + accessTags: string[]; + /** ISO 8601 timestamp. */ + createdAt: string; +} + +/** A registered CURIE prefix, e.g. `{ prefix: 'schema', uri: 'https://schema.org/' }`. */ +export interface TiledGraphNamespace { + prefix: string; + uri: string; +} + +/** + * One page of a list. The server defaults to 100 and reports **no total count**: a page shorter + * than `limit` is the only sign there is nothing more. + */ +export interface TiledGraphPage { + limit?: number; + offset?: number; +} + +/** Filters for `entities`. Omitted filters match everything. */ +export interface TiledGraphEntityFilters extends TiledGraphPage { + entityType?: string; + /** Only entities bound to this catalog node, as key segments: `['raw', 'S001']`. */ + nodePathParts?: string[]; +} + +/** Filters for `links`. Predicates may be CURIEs; the server expands them before matching. */ +export interface TiledGraphLinkFilters extends TiledGraphPage { + subjectId?: string; + predicate?: string; + objectId?: string; +} + +export interface TiledGraphCreateEntityInput { + entityType: string; + name: string; + /** + * Bind to a catalog node by its key segments. The server resolves the path and rejects one that + * names no node with `NODE_NOT_FOUND`. + */ + nodePathParts?: string[] | null; + uri?: string | null; + properties?: TiledGraphJSON | null; + accessTags?: string[] | null; +} + +/** + * Fields to change on an entity. + * + * **Three states, not two.** An omitted field is left as it is; `null` clears it (for + * `nodePathParts`, detaches the entity from its node); a value sets it. The client sends exactly + * what is given — `undefined` keys are dropped on the wire, `null` survives — so build this object + * with only the fields you mean to touch. + * + * `properties` cannot be changed after creation; the server has no field for it. + */ +export interface TiledGraphUpdateEntityInput { + name?: string | null; + entityType?: string | null; + nodePathParts?: string[] | null; + uri?: string | null; + accessTags?: string[] | null; +} + +/** + * What `bindGraphEntity` takes: a create input whose node binding and `uri` are filled in from the + * node's path, and whose `name` defaults to the node's key. + */ +export interface TiledGraphBindEntityInput extends Omit< + TiledGraphCreateEntityInput, + 'nodePathParts' | 'uri' | 'name' +> { + /** Defaults to the node's key, the last segment of its path. */ + name?: string; +} + +/** Filters for the entities bound to one node; the node itself is the path argument. */ +export type TiledGraphNodeEntityFilters = Omit; + +/** + * Where an entity's `uri` says its catalog node is. + * + * Read from `uri` because the server does not say which node an entity is bound to. Only entities + * whose `uri` is a Tiled metadata URL have one — `bindGraphEntity` writes it that way. + */ +export interface TiledGraphEntityNodePath { + /** + * The node's path from the server root, decoded: `'bl531/raw/3f2c9a1e'`. Absolute, so read it + * with `pathMode: 'absolute'`; the client's own `initialPath` is not applied to it. + */ + path: string; + /** + * Whether the URL names the server this client is configured for. `false` after a server moves + * host, or for an entity written against another server; the path may still be right. + */ + sameServer: boolean; +} + +export interface TiledGraphCreateLinkInput { + subjectId: string; + predicate: string; + objectId: string; + properties?: TiledGraphJSON | null; + accessTags?: string[] | null; +} + +/** Fields to change on a link. Same three-state rule as {@link TiledGraphUpdateEntityInput}. */ +export interface TiledGraphUpdateLinkInput { + predicate?: string | null; + accessTags?: string[] | null; +} + +/** One entry of a GraphQL response's `errors` array. */ +export interface TiledGraphQLErrorEntry { + message: string; + path?: (string | number)[]; + locations?: { line: number; column: number }[]; + /** Tiled puts a machine-readable `code` here for some failures, e.g. `NODE_NOT_FOUND`. */ + extensions?: { code?: string; [key: string]: unknown }; +} + +/** The GraphQL response envelope. `data` may be partial, or `null`, when `errors` is present. */ +export interface TiledGraphQLResponse { + data?: T | null; + errors?: TiledGraphQLErrorEntry[]; +} + +/** Variables for a GraphQL document. */ +export type TiledGraphQLVariables = Record; diff --git a/src/api/tiled/types/paths.ts b/src/api/tiled/types/paths.ts index 955513f7..60c385c1 100644 --- a/src/api/tiled/types/paths.ts +++ b/src/api/tiled/types/paths.ts @@ -95,6 +95,15 @@ export const TILED_PATHS = { // #endregion } as const satisfies Record; +/** + * The graph-of-links GraphQL endpoint. Origin-scoped, like `/healthz`. + * + * Not in {@link TILED_PATHS}, because that table is the spec's path list and is checked against it: + * the graph router is not in `openapi.json` at all. Its coverage is checked against + * `../graph/schema.graphql` instead — see `TiledGraph.test.tsx`. + */ +export const TILED_GRAPHQL_PATH = '/api/graphql'; + /** Alias keys of {@link TILED_PATHS}, e.g. `'arrayFull'`. */ export type TiledPathAlias = keyof typeof TILED_PATHS; diff --git a/src/api/tiled/types/registry.ts b/src/api/tiled/types/registry.ts index a462f2a6..39903749 100644 --- a/src/api/tiled/types/registry.ts +++ b/src/api/tiled/types/registry.ts @@ -31,7 +31,8 @@ export type TiledEndpointGroup = | 'asset' | 'webhooks' | 'auth' - | 'zarr'; + | 'zarr' + | 'graph'; export interface TiledEndpointParam { name: string; @@ -123,3 +124,29 @@ export interface TiledEndpointDescriptor { /** Invoke this endpoint on a client. */ call: (client: TiledApiClient, input: TiledEndpointInvocation) => Promise; } + +/** + * Machine-readable description of one graph-of-links operation. + * + * A separate shape from {@link TiledEndpointDescriptor}, which is tied to `openapi.json` through its + * spec `path` and `operationId`: every graph operation is the same `POST /api/graphql`, and is + * identified by the root GraphQL field it selects instead. `TiledGraph.test.tsx` diffs these against + * `../graph/schema.graphql` the way `TiledRegistry.test.ts` diffs the REST registry against the spec. + */ +export interface TiledGraphOperationDescriptor { + /** Stable id, `graph.`, e.g. `'graph.createLink'`. */ + id: string; + group: 'graph'; + kind: 'query' | 'mutation'; + /** The root `Query` / `Mutation` field, as `schema.graphql` names it. */ + field: string; + /** Name of the corresponding `TiledApiClient` method. */ + fn: keyof TiledApiClient; + summary: string; + /** Changes or removes something that already exists — the harness should confirm first. */ + destructive?: boolean; + /** Prefilled variables for a manual harness. */ + sampleVariables?: Record; + /** Invoke this operation on a client, with the operation's own variables. */ + call: (client: TiledApiClient, variables: Record) => Promise; +} diff --git a/src/components/TiledQueryPlayground/QueryFieldEditor.tsx b/src/components/TiledQueryPlayground/QueryFieldEditor.tsx index a9f8d7a9..fe8e0ea6 100644 --- a/src/components/TiledQueryPlayground/QueryFieldEditor.tsx +++ b/src/components/TiledQueryPlayground/QueryFieldEditor.tsx @@ -70,6 +70,16 @@ function Control({ field, value, onChange }: QueryFieldEditorProps) { className={inputClass} /> ); + case 'multilineText': + return ( +