@hames-ai/connectors 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,369 @@
1
+ /**
2
+ * Neo4j Query Ops (Non-Agentic Layer) — identity-free by construction.
3
+ *
4
+ * Server-side functions for direct Neo4j operations:
5
+ * - Schema fetching for agent initialization
6
+ * - Manual Cypher queries from a graph visualization
7
+ * - Connection management
8
+ *
9
+ * These operations bypass the agent layer for performance and simplicity.
10
+ *
11
+ * ## Identity is a gate the HOST applies, not a dependency here
12
+ * The host keeps thin `'use server'` RPC modules at the paths its clients
13
+ * import; every one of those runs its own per-module auth gate (duplicated,
14
+ * never imported — SD-13) and then delegates here. These ops therefore carry
15
+ * no identity at all, and every session is opened in READ access mode: this
16
+ * module never writes, and the driver enforces that rather than the query
17
+ * text being inspected for it. The intent-shaped writes live in
18
+ * `graph-edit.server.ts`.
19
+ *
20
+ * The `'use server'` directive this module carried in the host app is
21
+ * stripped by the package move (#225 PR-C2); `assertServerOnImport()` is the
22
+ * load-time guard that replaces it.
23
+ */
24
+
25
+ import { assertServerOnImport } from '@hames-ai/harness-patterns/assert.server'
26
+ import neo4j from 'neo4j-driver'
27
+ import { getNeo4jDriver, resetDriver, verifyConnection } from './client'
28
+ import { transformNeo4jToCytoscape, parseNeo4jResults } from './transform'
29
+ import { toPlainNeo4jValue } from './plain'
30
+
31
+ assertServerOnImport()
32
+
33
+ // ============================================================================
34
+ // Types
35
+ // ============================================================================
36
+
37
+ export interface SchemaResult {
38
+ success: boolean
39
+ schema?: string
40
+ error?: string
41
+ }
42
+
43
+ export interface CypherResult {
44
+ success: boolean
45
+ graphUpdate?: ReturnType<typeof transformNeo4jToCytoscape>
46
+ raw?: unknown[]
47
+ error?: string
48
+ }
49
+
50
+ export interface ConnectionResult {
51
+ success: boolean
52
+ error?: string
53
+ }
54
+
55
+ // ============================================================================
56
+ // Guards
57
+ // ============================================================================
58
+
59
+ /**
60
+ * A session the driver refuses to write through.
61
+ *
62
+ * Nothing in this module writes, so READ access mode costs nothing and makes
63
+ * the guarantee structural: the server rejects a write statement sent over a
64
+ * READ-mode transaction whatever the text says, which is the barrier a
65
+ * keyword blacklist can never be (comments, unicode escapes, `CALL { … }`
66
+ * subqueries, apoc procedures).
67
+ */
68
+ function readSession() {
69
+ return getNeo4jDriver().session({ defaultAccessMode: neo4j.session.READ })
70
+ }
71
+
72
+ // ============================================================================
73
+ // Schema Operations
74
+ // ============================================================================
75
+
76
+ /**
77
+ * Fetch the Neo4j database schema
78
+ * Used by agent for context about available node types and relationships
79
+ */
80
+ export async function getSchema(): Promise<SchemaResult> {
81
+ const session = readSession()
82
+ try {
83
+ const result = await session.run('CALL db.schema.visualization()')
84
+ return {
85
+ success: true,
86
+ schema: JSON.stringify(result.records, null, 2),
87
+ }
88
+ } catch (error) {
89
+ console.error('Failed to fetch schema:', error)
90
+ return {
91
+ success: false,
92
+ error: error instanceof Error ? error.message : String(error),
93
+ }
94
+ } finally {
95
+ await session.close()
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Get a formatted schema for the BAML agent
101
+ * Produces concise, LLM-friendly output with:
102
+ * - Node labels with their properties
103
+ * - Relationship patterns (start)-[TYPE]->(end)
104
+ */
105
+ export async function getSchemaForAgent(): Promise<SchemaResult> {
106
+ const session = readSession()
107
+ try {
108
+ // Get labels with their actual properties (sample 1 node per label)
109
+ const labelsQuery = `
110
+ CALL db.labels() YIELD label
111
+ CALL {
112
+ WITH label
113
+ MATCH (n) WHERE label IN labels(n)
114
+ RETURN keys(n) as props LIMIT 1
115
+ }
116
+ RETURN label, props
117
+ `
118
+ const labelsResult = await session.run(labelsQuery)
119
+
120
+ // Get relationship patterns by querying actual data
121
+ // (db.schema.visualization returns virtual nodes that don't support startNode/endNode)
122
+ const relsQuery = `
123
+ MATCH (a)-[r]->(b)
124
+ WITH
125
+ [lbl IN labels(a) WHERE lbl <> 'UNIQUE IMPORT LABEL'][0] as startLabel,
126
+ type(r) as relType,
127
+ [lbl IN labels(b) WHERE lbl <> 'UNIQUE IMPORT LABEL'][0] as endLabel
128
+ WHERE startLabel IS NOT NULL AND endLabel IS NOT NULL
129
+ RETURN DISTINCT startLabel, relType, endLabel
130
+ `
131
+ const relsResult = await session.run(relsQuery)
132
+
133
+ // Format as readable text
134
+ let schema = 'Node Labels:\n'
135
+ for (const record of labelsResult.records) {
136
+ const label = record.get('label')
137
+ if (label === 'UNIQUE IMPORT LABEL') continue // Skip APOC import label
138
+ const props = record.get('props') || []
139
+ schema += `- ${label} (properties: ${props.join(', ')})\n`
140
+ }
141
+
142
+ schema += '\nRelationships:\n'
143
+ for (const record of relsResult.records) {
144
+ const start = record.get('startLabel')
145
+ const rel = record.get('relType')
146
+ const end = record.get('endLabel')
147
+ if (start && rel && end) {
148
+ schema += `- (${start})-[${rel}]->(${end})\n`
149
+ }
150
+ }
151
+
152
+ return { success: true, schema }
153
+ } catch (error) {
154
+ console.error('Failed to fetch agent schema:', error)
155
+ // Fallback to simplified schema
156
+ return getSimplifiedSchema()
157
+ } finally {
158
+ await session.close()
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Get a simplified schema representation
164
+ * Useful for smaller context windows
165
+ */
166
+ export async function getSimplifiedSchema(): Promise<SchemaResult> {
167
+ const session = readSession()
168
+ try {
169
+ // Get node labels
170
+ const labelsResult = await session.run('CALL db.labels()')
171
+ const labels = labelsResult.records.map((r) => r.get(0))
172
+
173
+ // Get relationship types
174
+ const relTypesResult = await session.run('CALL db.relationshipTypes()')
175
+ const relTypes = relTypesResult.records.map((r) => r.get(0))
176
+
177
+ // Get property keys
178
+ const propsResult = await session.run('CALL db.propertyKeys()')
179
+ const propKeys = propsResult.records.map((r) => r.get(0))
180
+
181
+ const schema = {
182
+ nodeLabels: labels,
183
+ relationshipTypes: relTypes,
184
+ propertyKeys: propKeys,
185
+ }
186
+
187
+ return {
188
+ success: true,
189
+ schema: JSON.stringify(schema, null, 2),
190
+ }
191
+ } catch (error) {
192
+ console.error('Failed to fetch simplified schema:', error)
193
+ return {
194
+ success: false,
195
+ error: error instanceof Error ? error.message : String(error),
196
+ }
197
+ } finally {
198
+ await session.close()
199
+ }
200
+ }
201
+
202
+ // ============================================================================
203
+ // Node Property Operations
204
+ // ============================================================================
205
+
206
+ export interface NodePropertiesResult {
207
+ success: boolean
208
+ properties?: Record<string, unknown>
209
+ labels?: string[]
210
+ error?: string
211
+ }
212
+
213
+ /**
214
+ * Fetch properties for a specific node by element ID
215
+ * Used when clicking on a graph node that doesn't have properties loaded
216
+ *
217
+ * @param elementId - Neo4j 5.x element ID (e.g., "4:xxx:123")
218
+ */
219
+ export async function getNodeProperties(elementId: string): Promise<NodePropertiesResult> {
220
+ const session = readSession()
221
+ try {
222
+ const result = await session.run(
223
+ 'MATCH (n) WHERE elementId(n) = $elementId RETURN properties(n) as props, labels(n) as labels',
224
+ { elementId },
225
+ )
226
+
227
+ if (result.records.length === 0) {
228
+ return { success: false, error: 'Node not found' }
229
+ }
230
+
231
+ const record = result.records[0]
232
+ return {
233
+ success: true,
234
+ // Plain projection, not the driver's own values: an int property is an
235
+ // `Integer` instance, which the RPC serializer cannot encode (see
236
+ // `plain.ts`).
237
+ properties: toPlainNeo4jValue(record.get('props')) as Record<string, unknown>,
238
+ labels: record.get('labels') as string[],
239
+ }
240
+ } catch (error) {
241
+ console.error('Failed to fetch node properties:', error)
242
+ return {
243
+ success: false,
244
+ error: error instanceof Error ? error.message : String(error),
245
+ }
246
+ } finally {
247
+ await session.close()
248
+ }
249
+ }
250
+
251
+ // ============================================================================
252
+ // Manual Cypher Operations
253
+ // ============================================================================
254
+
255
+ // Defense in depth only (#230). The barrier is the READ-mode transaction
256
+ // below; this pre-check exists to answer an obvious write faster, and with a
257
+ // message that points at the host's chat interface. Word boundaries, not
258
+ // substrings (#190): `RETURN n.createdAt` and `MATCH (n:Dataset)` are reads.
259
+ const WRITE_CLAUSE = /\b(CREATE|MERGE|SET|DELETE|REMOVE|DETACH)\b/i
260
+
261
+ // How Neo4j phrases its own refusal of a write over a READ transaction.
262
+ const DRIVER_READ_ONLY_REFUSAL = /read[- ]?only|read access mode|ForbiddenDueToTransactionType/i
263
+
264
+ /**
265
+ * Execute a read-only Cypher query (for a graph visualization's manual input)
266
+ *
267
+ * The caller supplies the query text, so read-only is enforced by the driver
268
+ * (`executeRead` over a READ-mode session, #230), not by inspecting the text.
269
+ *
270
+ * @param cypher - The Cypher query to execute
271
+ */
272
+ export async function runManualCypher(cypher: string): Promise<CypherResult> {
273
+ const writeClause = WRITE_CLAUSE.exec(cypher)
274
+ if (writeClause) {
275
+ return {
276
+ success: false,
277
+ error: `Manual queries cannot use write operations (${writeClause[1].toUpperCase()}). Use the chat interface for modifications.`,
278
+ }
279
+ }
280
+
281
+ const session = readSession()
282
+ try {
283
+ // READ mode is pinned on the transaction as well as the session, so a
284
+ // write that slipped past WRITE_CLAUSE is refused by the server.
285
+ const result = await session.executeRead((tx) => tx.run(cypher))
286
+
287
+ // Plain-project the rows *before* anything else touches them: both what
288
+ // goes back over the RPC and what the Cytoscape projection embeds
289
+ // (`data.properties`, `data.neo4jId`) would otherwise carry driver class
290
+ // instances, which the serializer refuses mid-stream (see `plain.ts`).
291
+ const rows = result.records.map(
292
+ (r) => toPlainNeo4jValue(r.toObject()) as Record<string, unknown>,
293
+ )
294
+
295
+ // Parse and transform results for Cytoscape
296
+ const parsed = parseNeo4jResults({ records: rows })
297
+ const graphData = transformNeo4jToCytoscape(parsed.nodes || [], parsed.relationships || [])
298
+
299
+ return {
300
+ success: true,
301
+ graphUpdate: graphData,
302
+ raw: rows,
303
+ }
304
+ } catch (error) {
305
+ console.error('Manual Cypher query failed:', error)
306
+ const message = error instanceof Error ? error.message : String(error)
307
+ return {
308
+ success: false,
309
+ error: DRIVER_READ_ONLY_REFUSAL.test(message)
310
+ ? `Manual queries are read-only and the database refused this one. Use the chat interface for modifications. (${message})`
311
+ : message,
312
+ }
313
+ } finally {
314
+ await session.close()
315
+ }
316
+ }
317
+
318
+ // There is deliberately no write counterpart to `runManualCypher` (#228).
319
+ // `executeWriteCypher(cypher)` used to live where this module did in the host
320
+ // app: a `'use server'` RPC — so browser-reachable — that ran any string the
321
+ // caller sent, with no auth and no approval flow behind it despite what its
322
+ // comment claimed. It had no callers. Graph writes go through the
323
+ // intent-shaped ops in `graph-edit.server.ts`, which own their Cypher;
324
+ // nothing new belongs here that takes query text from a client.
325
+ //
326
+ // `runManualCypher` stays because a manual-query box in the host's graph
327
+ // visualization genuinely uses it, and it is safe on a different footing
328
+ // (#230): the caller must be authenticated (the host wrapper's gate), and
329
+ // the driver — not a blacklist — is what makes the query read-only. Any new
330
+ // export in this file must open its session through `readSession()`; that is
331
+ // what the source-scan pin in this package's queries tests holds.
332
+
333
+ // ============================================================================
334
+ // Connection Management
335
+ // ============================================================================
336
+
337
+ /**
338
+ * Reset the Neo4j connection
339
+ * Forces the driver singleton to reconnect on the next query
340
+ */
341
+ export async function resetNeo4jConnection(): Promise<ConnectionResult> {
342
+ try {
343
+ await resetDriver()
344
+ return { success: true }
345
+ } catch (error) {
346
+ return {
347
+ success: false,
348
+ error: error instanceof Error ? error.message : String(error),
349
+ }
350
+ }
351
+ }
352
+
353
+ /**
354
+ * Test the Neo4j connection
355
+ */
356
+ export async function testNeo4jConnection(): Promise<ConnectionResult> {
357
+ try {
358
+ const connected = await verifyConnection()
359
+ return {
360
+ success: connected,
361
+ error: connected ? undefined : 'Connection verification failed',
362
+ }
363
+ } catch (error) {
364
+ return {
365
+ success: false,
366
+ error: error instanceof Error ? error.message : String(error),
367
+ }
368
+ }
369
+ }