@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.
- package/LICENSE +21 -0
- package/README.md +77 -0
- package/app-tools/registry.ts +185 -0
- package/graph/graph-auth.ts +29 -0
- package/graph/graph-tools.server.ts +1921 -0
- package/index.ts +40 -0
- package/mcp-catalog.ts +149 -0
- package/neo4j/client.ts +112 -0
- package/neo4j/graph-edit.server.ts +127 -0
- package/neo4j/index.ts +34 -0
- package/neo4j/plain.ts +153 -0
- package/neo4j/queries.ts +369 -0
- package/neo4j/transform.ts +437 -0
- package/package.json +66 -0
package/index.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @hames-ai/connectors — the connectors companion (#225 PR-3).
|
|
3
|
+
*
|
|
4
|
+
* Microsoft Graph app-side tools, the Neo4j non-agentic layer, and the
|
|
5
|
+
* MCP-gateway namespace catalog — moved out of the host app behind injected
|
|
6
|
+
* seams so a consumer supplies identity, tokens, content classification and
|
|
7
|
+
* storage while this package owns the protocols and the query shapes.
|
|
8
|
+
*
|
|
9
|
+
* ## What the root barrel carries (and what it deliberately does not)
|
|
10
|
+
*
|
|
11
|
+
* The root is the CLIENT-SAFE surface: the namespace catalog (pure data) and
|
|
12
|
+
* the Neo4j→Cytoscape transform (pure functions, type-only cytoscape import).
|
|
13
|
+
* Everything else is server-only and reached through its subpath, resolved by
|
|
14
|
+
* the `./*` wildcard export:
|
|
15
|
+
*
|
|
16
|
+
* - `@hames-ai/connectors/neo4j` and `@hames-ai/connectors/neo4j/client` — the
|
|
17
|
+
* explicit-config driver factory (`configureNeo4j`, no env fallback);
|
|
18
|
+
* - `@hames-ai/connectors/neo4j/queries` / `neo4j/graph-edit.server` — the
|
|
19
|
+
* identity-free ops the host's `'use server'` wrappers gate and delegate
|
|
20
|
+
* to;
|
|
21
|
+
* - `@hames-ai/connectors/app-tools/registry` — the generic in-process tool
|
|
22
|
+
* registry (`createAppToolRegistry`);
|
|
23
|
+
* - `@hames-ai/connectors/graph/graph-tools.server` —
|
|
24
|
+
* `registerGraphConnectorTools(deps)` and its REQUIRED supplier bag.
|
|
25
|
+
*
|
|
26
|
+
* The app that hosted these modules composes them in its
|
|
27
|
+
* `app-tools/index.server.ts` composition root (which stays host-side): it
|
|
28
|
+
* builds the registry with its own identity resolver, hands the Graph tools
|
|
29
|
+
* their `graphFetch` / content / stash suppliers, and registers the transport
|
|
30
|
+
* on core's seam.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
export { mcpNamespace, MCP_TOOL_CATALOG } from './mcp-catalog'
|
|
34
|
+
export {
|
|
35
|
+
transformNeo4jToCytoscape,
|
|
36
|
+
parseNeo4jResults,
|
|
37
|
+
type Neo4jNode,
|
|
38
|
+
type Neo4jRelationship,
|
|
39
|
+
type Neo4jQueryResult,
|
|
40
|
+
} from './neo4j/transform'
|
package/mcp-catalog.ts
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The MCP gateway tool→namespace catalog — THIS DEPLOYMENT'S data.
|
|
3
|
+
*
|
|
4
|
+
* Moved here from core (`harness-patterns/tools.server.ts`, where it lived as
|
|
5
|
+
* `KNOWN_TOOL_SERVERS`) by Lane B2 (#225 L5): which tool names exist behind the
|
|
6
|
+
* gateway is a property of this deployment's `configs/mcp-config.yaml`, not of
|
|
7
|
+
* the library, so the catalog lives in `app-tools/` beside the rest of the
|
|
8
|
+
* app's own tooling (docs/plan/harness-npm-lib.md §"stays in app/"). Core keeps
|
|
9
|
+
* only the consultation ORDER and the deployment-independent heuristic.
|
|
10
|
+
*
|
|
11
|
+
* It is registered ONCE, at the same boot point as the app-tool transport:
|
|
12
|
+
* `app-tools/index.server.ts` (imported by `src/middleware.ts`) calls
|
|
13
|
+
* `registerToolNamespaces(mcpNamespace)`, so `inferServer` — and therefore both
|
|
14
|
+
* `Tools()`'s grouping and `withInjectionGuard`'s `isUntrusted` — see this map
|
|
15
|
+
* without any call site passing it. The `Tools({ namespaces })` argument
|
|
16
|
+
* (REQUIRED, owner ruling B-iii) is the same map passed explicitly.
|
|
17
|
+
*
|
|
18
|
+
* For a package consumer, the registration is not advisory: since #242 item 4
|
|
19
|
+
* the guard REFUSES a declared namespace it cannot verify, and the refusal
|
|
20
|
+
* names this registration. Skipping it is a build-time error, not a silent
|
|
21
|
+
* pass-through.
|
|
22
|
+
*
|
|
23
|
+
* 86 distinct names across 6 namespaces. The app-side per-user tools are NOT
|
|
24
|
+
* here — they declare their own namespaces (`registry.server.ts`), which ride
|
|
25
|
+
* `inferServer` through the app transport's `namespaceFor`.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/** Explicit mapping of tool names to server groups. Covers tools whose names
|
|
29
|
+
* don't encode the server identity (memory, context7, redis, filesystem, web)
|
|
30
|
+
* and pins names the heuristic already gets right (neo4j) so a future edit to
|
|
31
|
+
* the heuristic's verb list can't silently regroup them. */
|
|
32
|
+
const MCP_TOOL_CATALOG: Record<string, string> = {}
|
|
33
|
+
|
|
34
|
+
// Memory Knowledge Graph server
|
|
35
|
+
for (const t of [
|
|
36
|
+
'create_entities',
|
|
37
|
+
'create_relations',
|
|
38
|
+
'add_observations',
|
|
39
|
+
'delete_entities',
|
|
40
|
+
'delete_relations',
|
|
41
|
+
'delete_observations',
|
|
42
|
+
'open_nodes',
|
|
43
|
+
'search_nodes',
|
|
44
|
+
'read_graph',
|
|
45
|
+
])
|
|
46
|
+
MCP_TOOL_CATALOG[t] = 'memory'
|
|
47
|
+
|
|
48
|
+
// Neo4j Cypher server. These already resolve to 'neo4j' via the verb-strip
|
|
49
|
+
// heuristic in core (read_/write_/get_ → parts[1]), but pin them explicitly so
|
|
50
|
+
// a future edit to the `verbs` list can't silently regroup them.
|
|
51
|
+
for (const t of ['read_neo4j_cypher', 'write_neo4j_cypher', 'get_neo4j_schema'])
|
|
52
|
+
MCP_TOOL_CATALOG[t] = 'neo4j'
|
|
53
|
+
|
|
54
|
+
// Context7 documentation server
|
|
55
|
+
for (const t of ['resolve-library-id', 'get-library-docs']) MCP_TOOL_CATALOG[t] = 'context7'
|
|
56
|
+
|
|
57
|
+
// Web search / fetch server
|
|
58
|
+
for (const t of ['search', 'fetch', 'fetch_content']) MCP_TOOL_CATALOG[t] = 'web'
|
|
59
|
+
|
|
60
|
+
// Redis server
|
|
61
|
+
for (const t of [
|
|
62
|
+
'get',
|
|
63
|
+
'set',
|
|
64
|
+
'delete',
|
|
65
|
+
'expire',
|
|
66
|
+
'rename',
|
|
67
|
+
'type',
|
|
68
|
+
'dbsize',
|
|
69
|
+
'info',
|
|
70
|
+
'hget',
|
|
71
|
+
'hset',
|
|
72
|
+
'hdel',
|
|
73
|
+
'hexists',
|
|
74
|
+
'hgetall',
|
|
75
|
+
'lpush',
|
|
76
|
+
'rpush',
|
|
77
|
+
'lpop',
|
|
78
|
+
'rpop',
|
|
79
|
+
'lrange',
|
|
80
|
+
'llen',
|
|
81
|
+
'sadd',
|
|
82
|
+
'srem',
|
|
83
|
+
'smembers',
|
|
84
|
+
'zadd',
|
|
85
|
+
'zrange',
|
|
86
|
+
'zrem',
|
|
87
|
+
'json_get',
|
|
88
|
+
'json_set',
|
|
89
|
+
'json_del',
|
|
90
|
+
'xadd',
|
|
91
|
+
'xdel',
|
|
92
|
+
'xrange',
|
|
93
|
+
'publish',
|
|
94
|
+
'subscribe',
|
|
95
|
+
'unsubscribe',
|
|
96
|
+
'scan_keys',
|
|
97
|
+
'scan_all_keys',
|
|
98
|
+
'search_redis_documents',
|
|
99
|
+
'create_vector_index_hash',
|
|
100
|
+
'set_vector_in_hash',
|
|
101
|
+
'get_vector_from_hash',
|
|
102
|
+
'vector_search_hash',
|
|
103
|
+
'get_indexed_keys_number',
|
|
104
|
+
'get_indexes',
|
|
105
|
+
'get_index_info',
|
|
106
|
+
])
|
|
107
|
+
MCP_TOOL_CATALOG[t] = 'redis'
|
|
108
|
+
|
|
109
|
+
// Filesystem server
|
|
110
|
+
for (const t of [
|
|
111
|
+
'read_file',
|
|
112
|
+
'write_file',
|
|
113
|
+
'edit_file',
|
|
114
|
+
'create_directory',
|
|
115
|
+
'list_directory',
|
|
116
|
+
'list_directory_with_sizes',
|
|
117
|
+
'directory_tree',
|
|
118
|
+
'move_file',
|
|
119
|
+
'search_files',
|
|
120
|
+
'search_files_content',
|
|
121
|
+
'get_file_info',
|
|
122
|
+
'read_file_lines',
|
|
123
|
+
'head_file',
|
|
124
|
+
'tail_file',
|
|
125
|
+
'read_text_file',
|
|
126
|
+
'read_multiple_text_files',
|
|
127
|
+
'read_media_file',
|
|
128
|
+
'read_multiple_media_files',
|
|
129
|
+
'find_duplicate_files',
|
|
130
|
+
'find_empty_directories',
|
|
131
|
+
'calculate_directory_size',
|
|
132
|
+
'list_allowed_directories',
|
|
133
|
+
'zip_directory',
|
|
134
|
+
'zip_files',
|
|
135
|
+
'unzip_file',
|
|
136
|
+
])
|
|
137
|
+
MCP_TOOL_CATALOG[t] = 'filesystem'
|
|
138
|
+
|
|
139
|
+
/** The catalog, for introspection (the app-side namespace test reads it). */
|
|
140
|
+
export { MCP_TOOL_CATALOG }
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* The catalog as a `NamespaceResolver` — the value this module hands to
|
|
144
|
+
* `registerToolNamespaces()` and to every `Tools({ namespaces })` call site.
|
|
145
|
+
* Undefined for names it does not know, so the caller's chain falls through.
|
|
146
|
+
*/
|
|
147
|
+
export function mcpNamespace(toolName: string): string | undefined {
|
|
148
|
+
return MCP_TOOL_CATALOG[toolName]
|
|
149
|
+
}
|
package/neo4j/client.ts
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Neo4j Driver Client (Non-Agentic Layer)
|
|
3
|
+
*
|
|
4
|
+
* Direct neo4j-driver connection for operations that don't require BAML/UTCP:
|
|
5
|
+
* - Schema fetching
|
|
6
|
+
* - Manual Cypher queries from a graph visualization
|
|
7
|
+
*
|
|
8
|
+
* ## Configuration is explicit-only (design S5, #225 PR-3)
|
|
9
|
+
* `configureNeo4j({ url, user, password })` is called by the HOST — at app
|
|
10
|
+
* boot in `middleware.ts`, or explicitly by a standalone script — before the
|
|
11
|
+
* first `getNeo4jDriver()`. There is NO env fallback in the package, on
|
|
12
|
+
* purpose: a client that silently guessed a connection would send a
|
|
13
|
+
* deployment's data at whatever the ambient environment happened to name.
|
|
14
|
+
* Unset config is a NAMED error at first use (`Neo4jNotConfiguredError`),
|
|
15
|
+
* never a default — the C1-deferred work the peel disclosed.
|
|
16
|
+
*
|
|
17
|
+
* Note: This module runs server-side only; the ops that consume it carry the
|
|
18
|
+
* `assertServerOnImport()` guard.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import neo4j, { Driver } from 'neo4j-driver'
|
|
22
|
+
|
|
23
|
+
// ============================================================================
|
|
24
|
+
// Driver Management
|
|
25
|
+
// ============================================================================
|
|
26
|
+
|
|
27
|
+
/** Explicit connection config, set by the host (design S5). */
|
|
28
|
+
export interface Neo4jConfig {
|
|
29
|
+
/** Bolt URI, e.g. `bolt://localhost:7687`. */
|
|
30
|
+
url: string
|
|
31
|
+
user: string
|
|
32
|
+
password: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The driver was used before `configureNeo4j()` ran — a NAMED error at first
|
|
37
|
+
* use rather than a default, so a misconfigured deployment fails loudly at
|
|
38
|
+
* the first query instead of connecting to a guessed endpoint.
|
|
39
|
+
*/
|
|
40
|
+
export class Neo4jNotConfiguredError extends Error {
|
|
41
|
+
constructor() {
|
|
42
|
+
super(
|
|
43
|
+
'Neo4j is not configured: call configureNeo4j({ url, user, password }) ' +
|
|
44
|
+
'before the first getNeo4jDriver(). The package deliberately has no env ' +
|
|
45
|
+
'fallback — the host owns the connection details.',
|
|
46
|
+
)
|
|
47
|
+
this.name = 'Neo4jNotConfiguredError'
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
let config: Neo4jConfig | null = null
|
|
52
|
+
let driver: Driver | null = null
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Configure the Neo4j connection explicitly (design S5).
|
|
56
|
+
*
|
|
57
|
+
* Called once by the host — app boot (`middleware.ts`) or a standalone
|
|
58
|
+
* script's entry — with the bolt URI and credentials the host resolved.
|
|
59
|
+
* Later calls win: the live driver is dropped so the next
|
|
60
|
+
* `getNeo4jDriver()` reconnects with the new config (and `resetDriver` keeps
|
|
61
|
+
* its "next call reconnects" contract).
|
|
62
|
+
*/
|
|
63
|
+
export function configureNeo4j(next: Neo4jConfig): void {
|
|
64
|
+
if (!next || typeof next.url !== 'string' || !next.url.trim()) {
|
|
65
|
+
throw new Error('configureNeo4j: a non-empty bolt url is required')
|
|
66
|
+
}
|
|
67
|
+
config = next
|
|
68
|
+
driver = null
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Get or create the Neo4j driver singleton. Throws
|
|
73
|
+
* {@link Neo4jNotConfiguredError} when no explicit config was handed over.
|
|
74
|
+
*/
|
|
75
|
+
export function getNeo4jDriver(): Driver {
|
|
76
|
+
if (!driver) {
|
|
77
|
+
if (!config) throw new Neo4jNotConfiguredError()
|
|
78
|
+
driver = neo4j.driver(config.url, neo4j.auth.basic(config.user, config.password))
|
|
79
|
+
|
|
80
|
+
console.log('✅ Neo4j driver initialized')
|
|
81
|
+
console.log(` - URI: ${config.url}`)
|
|
82
|
+
console.log(` - User: ${config.user}`)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return driver
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Reset the driver connection
|
|
90
|
+
* Closes the singleton so the next call reconnects
|
|
91
|
+
*/
|
|
92
|
+
export async function resetDriver(): Promise<void> {
|
|
93
|
+
if (driver) {
|
|
94
|
+
await driver.close()
|
|
95
|
+
driver = null
|
|
96
|
+
console.log('✅ Neo4j driver reset')
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Verify driver connectivity
|
|
102
|
+
*/
|
|
103
|
+
export async function verifyConnection(): Promise<boolean> {
|
|
104
|
+
try {
|
|
105
|
+
const drv = getNeo4jDriver()
|
|
106
|
+
await drv.verifyConnectivity()
|
|
107
|
+
return true
|
|
108
|
+
} catch (error) {
|
|
109
|
+
console.error('Neo4j connection verification failed:', error)
|
|
110
|
+
return false
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Neo4j Graph Edit Ops (#226 C2) — identity-free by construction.
|
|
3
|
+
*
|
|
4
|
+
* Intent-shaped operations for a graph visualization UI's edit affordances
|
|
5
|
+
* (create node, link nodes, edit a property). Replaces the deleted
|
|
6
|
+
* `write-action.ts`, whose `executeCypherWrite(cypher, params)` was a
|
|
7
|
+
* browser-reachable arbitrary-Cypher endpoint with no auth.
|
|
8
|
+
*
|
|
9
|
+
* Every operation:
|
|
10
|
+
* - is gated by the HOST's thin `'use server'` wrapper (per-module
|
|
11
|
+
* duplicated gate, SD-13) before it reaches this module — the ops
|
|
12
|
+
* themselves carry no identity;
|
|
13
|
+
* - owns its Cypher — the client sends intent, never query text;
|
|
14
|
+
* - passes all values as Cypher parameters;
|
|
15
|
+
* - validates identifiers (label / relationship type / property key), which
|
|
16
|
+
* cannot be parameters, against a strict charset allowlist before
|
|
17
|
+
* interpolating them backtick-quoted.
|
|
18
|
+
*
|
|
19
|
+
* The `'use server'` directive this module carried in the host app is
|
|
20
|
+
* stripped by the package move (#225 PR-C2); `assertServerOnImport()` is the
|
|
21
|
+
* load-time guard that replaces it.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { assertServerOnImport } from '@hames-ai/harness-patterns/assert.server'
|
|
25
|
+
import { getNeo4jDriver } from './client'
|
|
26
|
+
|
|
27
|
+
assertServerOnImport()
|
|
28
|
+
|
|
29
|
+
// Labels, relationship types and property keys cannot be Cypher parameters,
|
|
30
|
+
// so they are interpolated — restricted to a charset that cannot terminate
|
|
31
|
+
// the backtick quoting or smuggle query syntax. The UI legitimately mints NEW
|
|
32
|
+
// labels/relationship types (free-text inputs, defaults `Concept` /
|
|
33
|
+
// `RELATES_TO`), so validation is by shape, not by membership in
|
|
34
|
+
// db.labels()/db.relationshipTypes() — a catalog check would reject the first
|
|
35
|
+
// node of every new label.
|
|
36
|
+
const SAFE_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/
|
|
37
|
+
|
|
38
|
+
function assertSafeIdentifier(kind: string, value: string): string {
|
|
39
|
+
if (!SAFE_IDENTIFIER.test(value)) {
|
|
40
|
+
throw new Error(`Invalid ${kind} ${JSON.stringify(value)}: must match ${SAFE_IDENTIFIER}`)
|
|
41
|
+
}
|
|
42
|
+
return value
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function run(cypher: string, params: Record<string, unknown>) {
|
|
46
|
+
const session = getNeo4jDriver().session()
|
|
47
|
+
try {
|
|
48
|
+
return await session.run(cypher, params)
|
|
49
|
+
} finally {
|
|
50
|
+
await session.close()
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The single summary record every op's final RETURN produces. A zero-match
|
|
55
|
+
* MATCH turns the write into a no-op that resolves exactly like a success
|
|
56
|
+
* (#314), so callers read this rather than assuming a row came back. */
|
|
57
|
+
function summaryRecord(result: { records: { get: (key: string) => unknown }[] }) {
|
|
58
|
+
const record = result.records[0]
|
|
59
|
+
if (!record) throw new Error('Graph edit query returned no summary record')
|
|
60
|
+
return record
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The write's final `RETURN count(*)` — how many nodes the MATCH bound. */
|
|
64
|
+
function matchedCount(
|
|
65
|
+
result: { records: { get: (key: string) => unknown }[] },
|
|
66
|
+
key: string,
|
|
67
|
+
): number {
|
|
68
|
+
return Number(summaryRecord(result).get(key))
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Create a node with the given label, name and optional description.
|
|
72
|
+
* Resolves with the created node's Neo4j elementId: the canvas keys a fresh
|
|
73
|
+
* node by its user-typed name (#323 B1), so later edits and relations in the
|
|
74
|
+
* same session need the real id to target it with. */
|
|
75
|
+
export async function createGraphNode(
|
|
76
|
+
label: string,
|
|
77
|
+
name: string,
|
|
78
|
+
description?: string,
|
|
79
|
+
): Promise<string> {
|
|
80
|
+
const safeLabel = assertSafeIdentifier('label', label)
|
|
81
|
+
if (description) {
|
|
82
|
+
const result = await run(
|
|
83
|
+
`CREATE (n:\`${safeLabel}\` {name: $name, description: $description}) RETURN elementId(n) AS elementId`,
|
|
84
|
+
{ name, description },
|
|
85
|
+
)
|
|
86
|
+
return String(summaryRecord(result).get('elementId'))
|
|
87
|
+
}
|
|
88
|
+
const result = await run(
|
|
89
|
+
`CREATE (n:\`${safeLabel}\` {name: $name}) RETURN elementId(n) AS elementId`,
|
|
90
|
+
{ name },
|
|
91
|
+
)
|
|
92
|
+
return String(summaryRecord(result).get('elementId'))
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Create a relationship of the given type between two nodes, matched by
|
|
96
|
+
* elementId. MERGE keeps a second click on the same pair idempotent instead of
|
|
97
|
+
* stacking duplicate edges. */
|
|
98
|
+
export async function linkGraphNodes(
|
|
99
|
+
sourceId: string,
|
|
100
|
+
targetId: string,
|
|
101
|
+
relType: string,
|
|
102
|
+
): Promise<void> {
|
|
103
|
+
const safeType = assertSafeIdentifier('relationship type', relType)
|
|
104
|
+
const result = await run(
|
|
105
|
+
`MATCH (a), (b) WHERE elementId(a) = $sourceId AND elementId(b) = $targetId MERGE (a)-[:\`${safeType}\`]->(b) RETURN count(*) AS linked`,
|
|
106
|
+
{ sourceId, targetId },
|
|
107
|
+
)
|
|
108
|
+
if (matchedCount(result, 'linked') === 0) {
|
|
109
|
+
throw new Error('Graph edit matched no graph node for that relation — nothing was created')
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Set one property on the node with the given elementId. */
|
|
114
|
+
export async function setGraphNodeProperty(
|
|
115
|
+
nodeId: string,
|
|
116
|
+
key: string,
|
|
117
|
+
value: string,
|
|
118
|
+
): Promise<void> {
|
|
119
|
+
const safeKey = assertSafeIdentifier('property key', key)
|
|
120
|
+
const result = await run(
|
|
121
|
+
`MATCH (n) WHERE elementId(n) = $nodeId SET n.\`${safeKey}\` = $value RETURN count(n) AS matched`,
|
|
122
|
+
{ nodeId, value },
|
|
123
|
+
)
|
|
124
|
+
if (matchedCount(result, 'matched') === 0) {
|
|
125
|
+
throw new Error('Graph edit matched no graph node with that id — nothing was written')
|
|
126
|
+
}
|
|
127
|
+
}
|
package/neo4j/index.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Neo4j Module (Non-Agentic Layer)
|
|
3
|
+
*
|
|
4
|
+
* Exports direct Neo4j driver functionality for operations that don't require BAML/UTCP:
|
|
5
|
+
* - Schema fetching
|
|
6
|
+
* - Manual Cypher queries from graph visualizations
|
|
7
|
+
* - Connection management
|
|
8
|
+
*
|
|
9
|
+
* The host's `'use server'` wrappers (which gate every export on an
|
|
10
|
+
* authenticated caller before delegating here) live in the host app at the
|
|
11
|
+
* paths its clients already import.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
// Client
|
|
15
|
+
export {
|
|
16
|
+
getNeo4jDriver,
|
|
17
|
+
resetDriver,
|
|
18
|
+
verifyConnection,
|
|
19
|
+
configureNeo4j,
|
|
20
|
+
Neo4jNotConfiguredError,
|
|
21
|
+
type Neo4jConfig,
|
|
22
|
+
} from './client'
|
|
23
|
+
|
|
24
|
+
// Query ops (identity-free — the host gates them)
|
|
25
|
+
export {
|
|
26
|
+
getSchema,
|
|
27
|
+
getSimplifiedSchema,
|
|
28
|
+
runManualCypher,
|
|
29
|
+
resetNeo4jConnection,
|
|
30
|
+
testNeo4jConnection,
|
|
31
|
+
type SchemaResult,
|
|
32
|
+
type CypherResult,
|
|
33
|
+
type ConnectionResult,
|
|
34
|
+
} from './queries'
|
package/neo4j/plain.ts
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plain projections of neo4j-driver values.
|
|
3
|
+
*
|
|
4
|
+
* Everything the driver hands back is a class instance — `Node`, `Relationship`,
|
|
5
|
+
* `Path`, `Integer`, the temporal types — and SolidStart serialises a
|
|
6
|
+
* `'use server'` return value with seroval, which refuses any object whose
|
|
7
|
+
* prototype it does not know:
|
|
8
|
+
*
|
|
9
|
+
* SerovalUnsupportedTypeError: The value [object Object] of type "object"
|
|
10
|
+
* cannot be parsed/serialized.
|
|
11
|
+
*
|
|
12
|
+
* That throw lands *after* the response headers are on the wire, so the browser
|
|
13
|
+
* never sees an error envelope — it sees a truncated stream and reports
|
|
14
|
+
* `Malformed server function stream header`. Which is why a query returning
|
|
15
|
+
* scalars worked while `MATCH (n) RETURN n LIMIT 5` did not (#237 follow-up).
|
|
16
|
+
*
|
|
17
|
+
* `toPlainNeo4jValue` walks a driver value into arrays, plain objects and
|
|
18
|
+
* primitives. The unknown-object fallback is deliberate: a driver type nobody
|
|
19
|
+
* anticipated degrades to its own enumerable properties instead of killing the
|
|
20
|
+
* response.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import neo4j, { type Integer } from 'neo4j-driver'
|
|
24
|
+
|
|
25
|
+
/** Stands in for a reference that points back into its own ancestry. */
|
|
26
|
+
export const CIRCULAR_PLACEHOLDER = '[Circular]'
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Project a neo4j-driver value into something seroval (and `JSON.stringify`)
|
|
30
|
+
* can encode.
|
|
31
|
+
*
|
|
32
|
+
* - `Integer`/`bigint` → a `number` when it fits exactly, otherwise a decimal
|
|
33
|
+
* string. Silently rounding an int64 past 2^53 would be worse than a string.
|
|
34
|
+
* - `Node` → `{ elementId, identity, labels, properties }`, `Relationship` →
|
|
35
|
+
* the same plus `type` and its endpoints, `Path` → `{ start, end, segments }`.
|
|
36
|
+
* These keep the field names `neo4j/transform.ts` duck-types on, so the
|
|
37
|
+
* Cytoscape projection is built from the plain form too.
|
|
38
|
+
* - temporal types and points → their `toString()` / component form.
|
|
39
|
+
* - anything else object-shaped → its own enumerable keys, recursively.
|
|
40
|
+
*/
|
|
41
|
+
export function toPlainNeo4jValue(value: unknown): unknown {
|
|
42
|
+
return project(value, new Set())
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function project(value: unknown, ancestors: Set<object>): unknown {
|
|
46
|
+
if (value === null || value === undefined) return value
|
|
47
|
+
if (typeof value === 'bigint') return fromBigInt(value)
|
|
48
|
+
if (typeof value !== 'object') return value
|
|
49
|
+
|
|
50
|
+
if (neo4j.isInt(value)) return fromInteger(value)
|
|
51
|
+
|
|
52
|
+
if (
|
|
53
|
+
neo4j.isDate(value) ||
|
|
54
|
+
neo4j.isDateTime(value) ||
|
|
55
|
+
neo4j.isLocalDateTime(value) ||
|
|
56
|
+
neo4j.isTime(value) ||
|
|
57
|
+
neo4j.isLocalTime(value) ||
|
|
58
|
+
neo4j.isDuration(value)
|
|
59
|
+
) {
|
|
60
|
+
return String(value)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// A reference already open further up the walk: emitting it again would
|
|
64
|
+
// recurse forever. Siblings that merely share a reference are unaffected —
|
|
65
|
+
// `ancestors` only holds the current path (see the `delete` below).
|
|
66
|
+
if (ancestors.has(value)) return CIRCULAR_PLACEHOLDER
|
|
67
|
+
ancestors.add(value)
|
|
68
|
+
try {
|
|
69
|
+
if (Array.isArray(value)) return value.map((item) => project(item, ancestors))
|
|
70
|
+
|
|
71
|
+
if (neo4j.isPoint(value)) {
|
|
72
|
+
return {
|
|
73
|
+
srid: project(value.srid, ancestors),
|
|
74
|
+
x: value.x,
|
|
75
|
+
y: value.y,
|
|
76
|
+
z: value.z,
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (neo4j.isNode(value)) {
|
|
81
|
+
return {
|
|
82
|
+
elementId: value.elementId,
|
|
83
|
+
identity: project(value.identity, ancestors),
|
|
84
|
+
labels: [...value.labels],
|
|
85
|
+
properties: projectProperties(value.properties, ancestors),
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (neo4j.isRelationship(value)) {
|
|
90
|
+
return {
|
|
91
|
+
elementId: value.elementId,
|
|
92
|
+
identity: project(value.identity, ancestors),
|
|
93
|
+
type: value.type,
|
|
94
|
+
start: project(value.start, ancestors),
|
|
95
|
+
end: project(value.end, ancestors),
|
|
96
|
+
startNodeElementId: value.startNodeElementId,
|
|
97
|
+
endNodeElementId: value.endNodeElementId,
|
|
98
|
+
properties: projectProperties(value.properties, ancestors),
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (neo4j.isUnboundRelationship(value)) {
|
|
103
|
+
return {
|
|
104
|
+
elementId: value.elementId,
|
|
105
|
+
identity: project(value.identity, ancestors),
|
|
106
|
+
type: value.type,
|
|
107
|
+
properties: projectProperties(value.properties, ancestors),
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (neo4j.isPathSegment(value)) {
|
|
112
|
+
return {
|
|
113
|
+
start: project(value.start, ancestors),
|
|
114
|
+
relationship: project(value.relationship, ancestors),
|
|
115
|
+
end: project(value.end, ancestors),
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (neo4j.isPath(value)) {
|
|
120
|
+
return {
|
|
121
|
+
start: project(value.start, ancestors),
|
|
122
|
+
end: project(value.end, ancestors),
|
|
123
|
+
length: value.length,
|
|
124
|
+
segments: value.segments.map((segment) => project(segment, ancestors)),
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return projectProperties(value as Record<string, unknown>, ancestors)
|
|
129
|
+
} finally {
|
|
130
|
+
ancestors.delete(value)
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function projectProperties(
|
|
135
|
+
properties: Record<string, unknown> | undefined,
|
|
136
|
+
ancestors: Set<object>,
|
|
137
|
+
): Record<string, unknown> {
|
|
138
|
+
const out: Record<string, unknown> = {}
|
|
139
|
+
for (const [key, entry] of Object.entries(properties ?? {})) {
|
|
140
|
+
out[key] = project(entry, ancestors)
|
|
141
|
+
}
|
|
142
|
+
return out
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function fromInteger(value: Integer): number | string {
|
|
146
|
+
return value.inSafeRange() ? value.toNumber() : value.toString()
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function fromBigInt(value: bigint): number | string {
|
|
150
|
+
return value >= BigInt(Number.MIN_SAFE_INTEGER) && value <= BigInt(Number.MAX_SAFE_INTEGER)
|
|
151
|
+
? Number(value)
|
|
152
|
+
: value.toString()
|
|
153
|
+
}
|