Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs/QUERY_PANEL_IMPLEMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -684,6 +684,11 @@ export class CypherQueryError extends Error {

### `src/api/api.ts` (Modified)

> **Superseded.** The `fetch` + JSON transport below is what this branch shipped. The app no
> longer calls the server directly: `executeCypherQuery()` now goes through the `turingdb` npm
> client (`TuringClient.queryData`), which speaks TuringDB's binary protocol, and maps its
> `TuringQueryError` to `CypherQueryError`. `CypherQueryErrorResponse` was removed with it.

Added the `executeCypherQuery()` function:

```typescript
Expand Down
11 changes: 11 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
"three": "^0.170.0",
"three-instanced-uniforms-mesh": "^0.52.0",
"troika-three-text": "^0.52.2",
"turingdb": "3.1.0",
"typescript": "~5.6.2",
"vite": "^6.1.6",
"vite-plugin-glsl": "^1.3.1",
Expand Down
31 changes: 16 additions & 15 deletions src/api/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,16 @@ import {
type ListPropertyTypesResponse,
} from './responses'

import { TuringClient, TuringQueryError } from 'turingdb'

import type { NodeEntries } from './models/nodeEntries.model'
import type { NodeEntry } from './models/nodeEntry.model'
import type { EdgeEntry } from './models/edgeEntry.model'

// One client for the whole app: it holds no per-query state, since every call
// passes its own graph. The first query compiles the wasm decoder.
const client = new TuringClient({ url: '/api/query' })

// Build an `OR` chain in place of the `IN` operator (which the Cypher dialect
// does not yet support).
function inClause(field: string, values: number[]): string {
Expand Down Expand Up @@ -446,20 +452,15 @@ export async function getEdges(args: GetEdgesArgs): Promise<GetEdgesResponse> {
}

export async function executeCypherQuery(args: CypherQueryArgs): Promise<CypherQueryResponse> {
return await fetch(`/api/query?graph=${args.graph}`, {
method: 'POST',
signal: args.signal ?? args.controller?.signal,
headers: {
'Content-Type': 'text/plain',
},
body: args.query,
}).then((res) =>
res.text().then((text) => {
const json = JSON.parse(text)
if (json.error) {
throw new CypherQueryError(json.error, json.error_details || '')
}
return json.data
try {
return await client.queryData(args.query, {
graph: args.graph,
signal: args.signal ?? args.controller?.signal,
})
)
} catch (error) {
if (error instanceof TuringQueryError) {
throw new CypherQueryError(error.status, error.message)
}
throw error
}
}
6 changes: 0 additions & 6 deletions src/api/responses/cypherQuery.response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,6 @@
// e.g. MATCH (n) RETURN n returns [[0, 1, 2, ...]] - one chunk with one column of node IDs
export type CypherQueryResponse = unknown[][]

// Error response from the server
export type CypherQueryErrorResponse = {
error: string
error_details: string
}

// Custom error class to hold both error type and details
export class CypherQueryError extends Error {
errorType: string
Expand Down
19 changes: 7 additions & 12 deletions tests/cypher-query.spec.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import { test, expect } from '@playwright/test'
import { setupLoadedGraph } from './helpers'
import { fulfillError, fulfillRows, queryOf, setupLoadedGraph } from './helpers'

test.describe('Cypher query execution', () => {
test('Ctrl+Enter executes the query', async ({ page }) => {
let querySent = ''

await setupLoadedGraph(page, {
cypherHandler: async (route) => {
querySent = await route.request().postData() ?? ''
await route.fulfill({ json: { data: [[[1, 2]]] } })
querySent = queryOf(route)
await fulfillRows(route, [[1, 2]])
},
})

Expand All @@ -25,7 +25,7 @@ test.describe('Cypher query execution', () => {
await setupLoadedGraph(page, {
cypherHandler: async (route) => {
queryCalled = true
await route.fulfill({ json: { data: [[[1]]] } })
await fulfillRows(route, [[1]])
},
})

Expand All @@ -40,12 +40,7 @@ test.describe('Cypher query execution', () => {
test('query error is displayed and dismissible', async ({ page }) => {
await setupLoadedGraph(page, {
cypherHandler: async (route) => {
await route.fulfill({
json: {
error: 'SyntaxError',
error_details: 'Unexpected token at position 5',
},
})
await fulfillError(route, 'PARSE_ERROR', 'Unexpected token at position 5')
},
})

Expand All @@ -56,7 +51,7 @@ test.describe('Cypher query execution', () => {
await input.press('Control+Enter')

// Error card should appear
await expect(page.getByText('SyntaxError')).toBeVisible()
await expect(page.getByText('PARSE_ERROR')).toBeVisible()
await expect(page.getByText('Unexpected token at position 5')).toBeVisible()

// Dismiss the error
Expand All @@ -69,7 +64,7 @@ test.describe('Cypher query execution', () => {
cypherHandler: async (route) => {
// Delay response to keep pending state visible
await new Promise((r) => setTimeout(r, 3000))
await route.fulfill({ json: { data: [[[1]]] } })
await fulfillRows(route, [[1]])
},
})

Expand Down
41 changes: 29 additions & 12 deletions tests/helpers.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Page, Route } from '@playwright/test'
import { type Cell, type QueryStatus, decodeRequestQuery, protoError, protoRows } from './proto'

// The whole app now talks to a single backend endpoint, `/api/query`: catalog
// and data access go through Cypher statements (`LIST AVAILABLE GRAPHS`,
Expand All @@ -7,10 +8,10 @@ import type { Page, Route } from '@playwright/test'
// serves the app's own statements as columnar fixture data and hands genuine
// user queries to an optional `cypherHandler`.
//
// TuringDB's columnar response shape is `{ data: [chunk, ...] }` where each
// chunk is `[column, ...]` and each column is a flat array of that column's
// cell values — i.e. `{ data: [[col0, col1, ...]] }` for a single chunk. The
// api.ts reshapers read the columns positionally, matching each `YIELD` clause.
// The app's `turingdb` client speaks TuringDB's binary protocol, so the mock
// answers with protocol packets (see proto.ts). A response is one chunk of
// columns, each a flat array of that column's cell values. The api.ts
// reshapers read the columns positionally, matching each `YIELD` clause.

// Fixture: 3 nodes (Alice/Bob/Acme) and 2 edges (Alice-KNOWS->Bob,
// Bob-WORKS_AT->Acme), mirroring tests/integration/fixture.jsonl in spirit.
Expand All @@ -34,16 +35,14 @@ const IN_EDGES_IDS = [[], [[100, 1]], [[101, 2]]]
const OUT_EDGE_COUNTS = ['{"0":1}', '{"1":1}', '{}']
const IN_EDGE_COUNTS = ['{}', '{"0":1}', '{"1":1}']

/** One-chunk response from its column arrays. */
function chunk(columns: unknown[][]) {
return { data: [columns] }
}
/** The columns of the one chunk that a mocked response carries. */
const chunk = (columns: Cell[][]) => columns

/**
* Map one of the app's own statements to its columnar response, or return null
* if `body` isn't a statement the app itself issues (i.e. it's a user query).
*/
function internalQueryResponse(body: string, graphs: string[], loaded: boolean) {
function internalQueryResponse(body: string, graphs: string[], loaded: boolean): Cell[][] | null {
// Catalog statements. Check the more specific "AVAILABLE" form first.
if (body.includes('LIST AVAILABLE GRAPHS')) {
return chunk([graphs, graphs.map(() => loaded), graphs.map(() => false)])
Expand Down Expand Up @@ -103,11 +102,11 @@ export async function mockApi(page: Page, opts: MockApiOpts = {}) {
const loaded = opts.loaded ?? false

await page.route('**/api/query*', async (route) => {
const body = route.request().postData() ?? ''
const body = queryOf(route)

const internal = internalQueryResponse(body, graphs, loaded)
if (internal !== null) {
await route.fulfill({ json: internal })
await fulfillRows(route, internal)
return
}

Expand All @@ -116,7 +115,25 @@ export async function mockApi(page: Page, opts: MockApiOpts = {}) {
await opts.cypherHandler(route)
return
}
await route.fulfill({ json: { data: [] } })
await fulfillRows(route, [[]])
})
}

/** The Cypher text the app sent in this request. */
export function queryOf(route: Route): string {
return decodeRequestQuery(route.request().postDataBuffer())
}

/** Answer with one chunk of columns, as a server does for a successful query. */
export async function fulfillRows(route: Route, columns: Cell[][]) {
await route.fulfill({ contentType: 'application/turing-proto', body: protoRows(columns) })
}

/** Answer with a failed query: the app sees this status and message. */
export async function fulfillError(route: Route, status: QueryStatus, message: string) {
await route.fulfill({
contentType: 'application/turing-proto',
body: protoError(status, message),
})
}

Expand Down
4 changes: 2 additions & 2 deletions tests/integration/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,8 @@ change is blocked on a turingdb release.
stops the server and removes the temp dir.
- `fixture.jsonl` — the deterministic fixture graph (`vis_test`: 4 nodes,
3 edges) in Neo4j APOC JSONL format. Edit this file to change the seed data.
- `setup-fetch.ts` — shims `fetch` so the relative `/api/*` URLs in `api.ts`
resolve to the test server, reproducing the Vite/Express dev proxy's
- `setup-fetch.ts` — shims `fetch` so the relative `/api/query` URL of the
client in `api.ts` resolves to the test server, reproducing the Vite/Express dev proxy's
`/api`-strip.
- `config.ts` — shared port / base URL / graph name.

Expand Down
23 changes: 11 additions & 12 deletions tests/integration/global-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { copyFileSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { TuringClient, TuringQueryError } from 'turingdb'
import { API_BASE, TEST_GRAPH, TEST_PORT } from './config'

const here = path.dirname(fileURLToPath(import.meta.url))
Expand Down Expand Up @@ -49,14 +50,20 @@ function turingdb(args: string[]): Promise<void> {
return TURINGDB_BIN ? run(TURINGDB_BIN, args) : run('uvx', [TURINGDB_SPEC, ...args])
}

/** Resolve once the server answers an HTTP request (any response = up). */
// Global setup runs outside the workers, so setup-fetch.ts's `/api` rewrite
// does not apply here: give the client the server's own URL.
const client = new TuringClient({ url: `${API_BASE}/query` })

/** Resolve once the server answers a query; retry only while it can't be reached. */
async function waitForServer(timeoutMs = 30_000): Promise<void> {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
try {
await fetch(`${API_BASE}/query?graph=`, { method: 'POST', body: 'LIST GRAPH' })
await client.queryData('LIST GRAPH', { graph: '' })
return
} catch {
} catch (err) {
// The server answered, so it is up but failing: report that, not a timeout.
if (err instanceof TuringQueryError) throw err
await new Promise((r) => setTimeout(r, 300))
}
}
Expand All @@ -69,15 +76,7 @@ async function waitForServer(timeoutMs = 30_000): Promise<void> {
* shot — no change/commit workflow needed.
*/
async function loadFixture(): Promise<void> {
const res = await fetch(`${API_BASE}/query?graph=`, {
method: 'POST',
headers: { 'Content-Type': 'text/plain' },
body: `LOAD JSONL '${FIXTURE}' AS ${TEST_GRAPH}`,
})
const json = (await res.json()) as { error?: string; error_details?: string }
if (json.error) {
throw new Error(`LOAD JSONL failed: ${json.error} ${json.error_details ?? ''}`)
}
await client.queryData(`LOAD JSONL '${FIXTURE}' AS ${TEST_GRAPH}`, { graph: '' })
}

export default async function setup() {
Expand Down
4 changes: 2 additions & 2 deletions tests/integration/setup-fetch.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// Per-worker setup: the api.ts functions call relative URLs like
// `/api/get_graph_status`, relying on the Vite/Express dev proxy to strip the
// Per-worker setup: the `turingdb` client in api.ts posts every query to the
// relative URL `/api/query`, relying on the Vite/Express dev proxy to strip the
// `/api` prefix and forward to the TuringDB server. There is no such proxy in
// Node, so we shim global fetch to reproduce it: `/api/X` -> `${API_BASE}/X`.
//
Expand Down
Loading
Loading