@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/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
+ }
@@ -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
+ }