From d37227d69fc61e9368c5889a237dd7110c3b74d6 Mon Sep 17 00:00:00 2001 From: Alison Cheng Date: Wed, 7 Oct 2026 09:23:33 -0700 Subject: [PATCH 1/9] install graphql --- package-lock.json | 11 +++++++++++ package.json | 1 + 2 files changed, 12 insertions(+) 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", From d841f5cdcc8d1b85a364b384c4909eb7489d4977 Mon Sep 17 00:00:00 2001 From: Alison Cheng Date: Wed, 7 Oct 2026 12:24:40 -0700 Subject: [PATCH 2/9] Create graph schema, types, and ops document --- src/api/tiled/graph/README.md | 40 +++++ src/api/tiled/graph/operations.ts | 258 +++++++++++++++++++++++++++++ src/api/tiled/graph/schema.graphql | 105 ++++++++++++ src/api/tiled/types/errors.ts | 55 +++++- src/api/tiled/types/graph.ts | 140 ++++++++++++++++ src/api/tiled/types/paths.ts | 9 + 6 files changed, 604 insertions(+), 3 deletions(-) create mode 100644 src/api/tiled/graph/README.md create mode 100644 src/api/tiled/graph/operations.ts create mode 100644 src/api/tiled/graph/schema.graphql create mode 100644 src/api/tiled/types/graph.ts diff --git a/src/api/tiled/graph/README.md b/src/api/tiled/graph/README.md new file mode 100644 index 00000000..115be320 --- /dev/null +++ b/src/api/tiled/graph/README.md @@ -0,0 +1,40 @@ +# Graph of links — schema snapshot + +`schema.graphql` is the GraphQL SDL of Tiled's entity/link graph, exported from bluesky tiled v0.2.18 +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. + +`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..4cf7d82e --- /dev/null +++ b/src/api/tiled/graph/operations.ts @@ -0,0 +1,258 @@ +/** + * 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 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/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..295baf64 --- /dev/null +++ b/src/api/tiled/types/graph.ts @@ -0,0 +1,140 @@ +/** + * 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; +} + +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; From e96483bfc8cea17ca89b4c51c70be4919d2c3543 Mon Sep 17 00:00:00 2001 From: Alison Cheng Date: Wed, 7 Oct 2026 13:03:58 -0700 Subject: [PATCH 3/9] Graph client methods and registry --- src/api/tiled/client/TiledApiClient.ts | 296 +++++++++++++++++++++- src/api/tiled/client/facade.ts | 115 +++++++++ src/api/tiled/endpointRegistry.ts | 24 +- src/api/tiled/endpoints/graphEndpoints.ts | 165 ++++++++++++ src/api/tiled/index.ts | 29 ++- src/api/tiled/types/registry.ts | 29 ++- src/testing/tests/TiledRegistry.test.ts | 9 +- 7 files changed, 660 insertions(+), 7 deletions(-) create mode 100644 src/api/tiled/endpoints/graphEndpoints.ts diff --git a/src/api/tiled/client/TiledApiClient.ts b/src/api/tiled/client/TiledApiClient.ts index 22cc43d2..d90b23f3 100644 --- a/src/api/tiled/client/TiledApiClient.ts +++ b/src/api/tiled/client/TiledApiClient.ts @@ -13,7 +13,41 @@ 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 { + TiledGraphCreateEntityInput, + TiledGraphCreateLinkInput, + TiledGraphEntity, + TiledGraphEntityFilters, + TiledGraphLink, + TiledGraphLinkFilters, + TiledGraphNamespace, + 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, + graphOperationName, +} from '../graph/operations'; import type { GetDistinctResponse, DeliveryResponse, @@ -33,7 +67,7 @@ import { type TiledInfoResponse, } from '../types/info'; import type { TiledSearchItem, TiledSearchResult, TiledTableRow } from '../types/nodes'; -import { TILED_PATHS, buildPath } from '../types/paths'; +import { TILED_GRAPHQL_PATH, TILED_PATHS, buildPath } from '../types/paths'; import type { TiledApiKeyLocation, TiledApiKeyScheme, @@ -2077,6 +2111,264 @@ 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, + }); + } + throw error; + } + + if (body?.errors?.length) { + throw new TiledApiError({ + message: `${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; + } + + // #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..dfdbf5c6 100644 --- a/src/api/tiled/client/facade.ts +++ b/src/api/tiled/client/facade.ts @@ -15,6 +15,18 @@ import type { WebhookRegistrationRequest, WebhookResponse, } from '../types/generatedAliases'; +import type { + TiledGraphCreateEntityInput, + TiledGraphCreateLinkInput, + TiledGraphEntity, + TiledGraphEntityFilters, + 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 +642,109 @@ 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); +} + +// #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/index.ts b/src/api/tiled/index.ts index 5f8a3577..fb114a1e 100644 --- a/src/api/tiled/index.ts +++ b/src/api/tiled/index.ts @@ -150,11 +150,33 @@ 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, graphOperationName } from './graph/operations'; +export type { TiledGraphOperation } from './graph/operations'; /** Structure-family narrowing for a `TiledSearchItem`. */ export { @@ -193,9 +215,12 @@ export { getEndpointsByGroup, getReadOnlyEndpoints, getWriteEndpoints, + TILED_GRAPH_ENDPOINTS, + getGraphEndpointById, } from './endpointRegistry'; export type { TiledEndpointDescriptor, + TiledGraphOperationDescriptor, TiledEndpointGroup, TiledEndpointInvocation, TiledEndpointParam, 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/testing/tests/TiledRegistry.test.ts b/src/testing/tests/TiledRegistry.test.ts index 945feea6..4d824b4c 100644 --- a/src/testing/tests/TiledRegistry.test.ts +++ b/src/testing/tests/TiledRegistry.test.ts @@ -3,6 +3,7 @@ import openapi from '../../api/tiled/openapi.json'; import { TILED_ENDPOINTS, TILED_ENDPOINT_GROUPS, + TILED_GRAPH_ENDPOINTS, TILED_GROUP_LABELS, getEndpointById, getEndpointsByGroup, @@ -163,7 +164,13 @@ describe('registry shape', () => { expect(TILED_ENDPOINT_GROUPS, endpoint.id).toContain(endpoint.group); } for (const group of TILED_ENDPOINT_GROUPS) { - expect(getEndpointsByGroup(group).length, group).toBeGreaterThan(0); + // `graph` is the one group whose operations live in their own registry, because they + // are GraphQL rather than spec routes — see `TiledGraph.test.tsx`. + const count = + group === 'graph' + ? TILED_GRAPH_ENDPOINTS.length + : getEndpointsByGroup(group).length; + expect(count, group).toBeGreaterThan(0); expect(TILED_GROUP_LABELS[group], group).toBeTruthy(); } }); From 3efcac6e9b0972e6612842bc08660e7322f92d1e Mon Sep 17 00:00:00 2001 From: Alison Cheng Date: Wed, 7 Oct 2026 13:13:11 -0700 Subject: [PATCH 4/9] Add graph hooks, playground entries, and unit tests --- src/api/tiled/hooks/graphHooks.ts | 443 +++++++++++++++++ src/api/tiled/hooks/index.ts | 27 +- src/api/tiled/hooks/invalidation.ts | 18 +- src/api/tiled/hooks/queryKeys.ts | 35 +- src/api/tiled/hooks/typeTests.ts | 28 ++ src/api/tiled/runtime/clientLike.ts | 29 ++ .../TiledQueryPlayground/QueryFieldEditor.tsx | 10 + .../catalog/graphMutations.tsx | 342 +++++++++++++ .../catalog/graphQueries.tsx | 216 ++++++++ .../TiledQueryPlayground/catalog/index.ts | 6 +- src/components/TiledQueryPlayground/types.ts | 4 +- src/stories/TiledHooks.md | 35 +- src/testing/tests/TiledGraph.test.tsx | 462 ++++++++++++++++++ src/testing/tests/TiledHooks.test.tsx | 15 + src/testing/tests/TiledQueryCatalog.test.ts | 16 +- 15 files changed, 1670 insertions(+), 16 deletions(-) create mode 100644 src/api/tiled/hooks/graphHooks.ts create mode 100644 src/components/TiledQueryPlayground/catalog/graphMutations.tsx create mode 100644 src/components/TiledQueryPlayground/catalog/graphQueries.tsx create mode 100644 src/testing/tests/TiledGraph.test.tsx diff --git a/src/api/tiled/hooks/graphHooks.ts b/src/api/tiled/hooks/graphHooks.ts new file mode 100644 index 00000000..a4cd7848 --- /dev/null +++ b/src/api/tiled/hooks/graphHooks.ts @@ -0,0 +1,443 @@ +import type { UseMutationResult, UseQueryResult } from '@tanstack/react-query'; +import { requireArg } from '@/api/shared/errors'; +import type { TiledRequestOptions } from '../types/common'; +import type { + TiledGraphCreateEntityInput, + TiledGraphCreateLinkInput, + TiledGraphEntity, + TiledGraphEntityFilters, + TiledGraphLink, + TiledGraphLinkFilters, + TiledGraphNamespace, + 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. + * + * 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) => + client.graphql( + requireArg(document.trim() || undefined, 'useTiledGraphQLQuery', 'document'), + 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. + * + * **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. 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, + 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, + 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, + 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, + 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, + 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, + 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, + 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, + 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, + requestOptions, + mutationOptions, + }); +} + +// #endregion diff --git a/src/api/tiled/hooks/index.ts b/src/api/tiled/hooks/index.ts index 8f01d4c4..cceebb09 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,28 @@ export { } from './authHooks'; export type { TiledLoginVariables, TiledCreateApiKeyVariables } from './authHooks'; +export { + useTiledGraphQLQuery, + useTiledGraphQLMutation, + useTiledGraphEntityQuery, + useTiledGraphEntitiesQuery, + useTiledCreateGraphEntityMutation, + useTiledUpdateGraphEntityMutation, + useTiledDeleteGraphEntityMutation, + useTiledGraphLinkQuery, + useTiledGraphLinksQuery, + useTiledCreateGraphLinkMutation, + useTiledUpdateGraphLinkMutation, + useTiledDeleteGraphLinkMutation, + useTiledGraphNamespacesQuery, + useTiledUpsertGraphNamespaceMutation, + useTiledDeleteGraphNamespaceMutation, +} from './graphHooks'; +export type { + TiledGraphQLMutationVariables, + TiledUpdateGraphEntityVariables, + TiledUpdateGraphLinkVariables, + TiledUpsertGraphNamespaceVariables, +} from './graphHooks'; + // #endregion diff --git a/src/api/tiled/hooks/invalidation.ts b/src/api/tiled/hooks/invalidation.ts index 7bf5335e..96ae411a 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,16 @@ 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'], + 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..ebe21de8 100644 --- a/src/api/tiled/hooks/queryKeys.ts +++ b/src/api/tiled/hooks/queryKeys.ts @@ -5,6 +5,11 @@ import type { TiledTableEndpoint, TiledTableReturnType, } from '../types/dataOptions'; +import type { + TiledGraphEntityFilters, + TiledGraphLinkFilters, + TiledGraphQLVariables, +} from '../types/graph'; import type { TiledArrayKeyParts, TiledTableKeyParts } from './internal/keyParts'; /** @@ -46,8 +51,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 +75,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 +153,29 @@ 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: '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 +226,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/runtime/clientLike.ts b/src/api/tiled/runtime/clientLike.ts index 618e3f82..2a695228 100644 --- a/src/api/tiled/runtime/clientLike.ts +++ b/src/api/tiled/runtime/clientLike.ts @@ -99,6 +99,21 @@ export type TiledClientLike = Pick< | 'refreshSession' | 'revokeSession' | 'logout' + // graph of links + | 'graphql' + | 'getGraphEntity' + | 'getGraphEntities' + | 'getGraphLink' + | 'getGraphLinks' + | 'getGraphNamespaces' + | 'createGraphEntity' + | 'updateGraphEntity' + | 'deleteGraphEntity' + | 'createGraphLink' + | 'updateGraphLink' + | 'deleteGraphLink' + | 'upsertGraphNamespace' + | 'deleteGraphNamespace' // zarr | 'getZarrV2Url' | 'getZarrV3Url' @@ -180,6 +195,20 @@ export const TILED_CLIENT_LIKE_METHODS = [ 'refreshSession', 'revokeSession', 'logout', + 'graphql', + 'getGraphEntity', + 'getGraphEntities', + 'getGraphLink', + 'getGraphLinks', + 'getGraphNamespaces', + 'createGraphEntity', + 'updateGraphEntity', + 'deleteGraphEntity', + 'createGraphLink', + 'updateGraphLink', + 'deleteGraphLink', + 'upsertGraphNamespace', + 'deleteGraphNamespace', 'getZarrV2Url', 'getZarrV3Url', 'getBaseUrl', 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 ( +