Skip to content
Open
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
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.0.3-dev.202611041359",
"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
10 changes: 6 additions & 4 deletions tests/integration/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,14 +37,16 @@ change is blocked on a turingdb release.

## How it works

- `global-setup.ts` — boots `turingdb` (via `uvx turingdb@latest`, daemonized)
on a throwaway temp dir on port `6699`, waits for readiness, then imports the
- `global-setup.ts` — boots `turingdb` (via `uvx turingdb@latest`, daemonized,
with `USE_TURING_PROTO=1` for the binary protocol that the app's `turingdb`
npm client speaks) on a throwaway temp dir on port `6699`, waits for
readiness, then imports the
fixture via `LOAD JSONL 'fixture.jsonl' AS vis_test`. Returns a teardown that
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
25 changes: 14 additions & 11 deletions tests/integration/global-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@
// TURINGDB_SPEC defaults to `turingdb@latest` (latest published, no lock).
// Either way the only host requirement is uv (or the local binary).
//
// The server runs with USE_TURING_PROTO=1, the binary protocol the app's
// `turingdb` npm client speaks.
//
// We run the server with `-demon` (daemonized): in the foreground it shuts
// down as soon as its stdin hits EOF, which happens immediately under a
// non-interactive spawn. The daemon is stopped on teardown with
Expand All @@ -19,6 +22,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 } from 'turingdb'
import { API_BASE, TEST_GRAPH, TEST_PORT } from './config'

const here = path.dirname(fileURLToPath(import.meta.url))
Expand All @@ -31,7 +35,11 @@ const TURINGDB_SPEC = process.env.TURINGDB_SPEC ?? 'turingdb@latest'
/** Run a child process to completion, rejecting on a non-zero exit. */
function run(cmd: string, args: string[]): Promise<void> {
return new Promise((resolve, reject) => {
const child = spawn(cmd, args, { cwd: here, stdio: ['ignore', 'pipe', 'pipe'] })
const child = spawn(cmd, args, {
cwd: here,
env: { ...process.env, USE_TURING_PROTO: '1' },
stdio: ['ignore', 'pipe', 'pipe'],
})
let out = ''
child.stdout?.on('data', (d) => (out += d))
child.stderr?.on('data', (d) => (out += d))
Expand All @@ -54,7 +62,7 @@ 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 fetch(`${API_BASE}/query?graph=`, { method: 'POST' })
return
} catch {
await new Promise((r) => setTimeout(r, 300))
Expand All @@ -69,15 +77,10 @@ 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 ?? ''}`)
}
// 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` })
await client.queryData(`LOAD JSONL '${FIXTURE}' AS ${TEST_GRAPH}`, { graph: '' })
}

export default async function setup() {
Expand Down
Loading
Loading