Neo4j Infrastructure
Neo4j is the primary database for the FSA Platform, using @neo4j/graphql for schema-driven development.
Schema Location
All schemas: packages/core/src/infrastructure/neo4j/schemas/
Schema Template
type Entity @node {
id: ID! @id
name: String!
description: String
status: String! @default(value: "draft")
# Uniqueness enforced via DB constraints (see migrations/)
# Relationships
parent: Parent! @relationship(type: "BELONGS_TO", direction: OUT)
children: [Child!]! @relationship(type: "HAS", direction: OUT)
# Computed (needs custom resolver)
computed: Boolean @customResolver
# Timestamps
createdAt: DateTime! @timestamp(operations: [CREATE])
updatedAt: DateTime! @timestamp(operations: [CREATE, UPDATE])
}Directives Reference
| Directive | Purpose | Example |
|---|---|---|
@node | Marks as Neo4j node | type User @node |
@id | Auto-generate unique ID | id: ID! @id |
@default | Default value | status: String! @default(value: "draft") |
@timestamp | Auto-manage dates | createdAt: DateTime! @timestamp(operations: [CREATE]) |
@relationship | Define graph edge | See below |
@customResolver | Needs custom resolver | computed: Boolean @customResolver |
Note:
@uniquewas removed in@neo4j/graphqlv7. Uniqueness constraints are managed via Cypher migrations inpackages/core/src/infrastructure/neo4j/migrations/.
Relationship Patterns
Simple Relationship
organisation: Organisation! @relationship(type: "FOR", direction: OUT)With Edge Properties
type UserAccountEdge @relationshipProperties {
role: String!
joinedAt: DateTime!
}
type Account @node {
users: [User!]! @relationship(type: "MEMBER_OF", direction: IN, properties: "UserAccountEdge")
}Many-to-Many
# Both sides reference the same relationship type
frameworks: [Framework!]! @relationship(type: "USES", direction: OUT)
engagements: [Engagement!]! @relationship(type: "USES", direction: IN)Custom Resolvers
Location: packages/core/src/infrastructure/neo4j/resolvers/
import { getDriver } from '../client';
export const organisationResolvers = {
Organisation: {
provisional: async (parent: { id: string }) => {
const driver = getDriver();
const session = driver.session();
try {
const result = await session.run(
`MATCH (o:Organisation {id: $id}) RETURN o:Provisional as provisional`,
{ id: parent.id }
);
return result.records[0]?.get('provisional') ?? false;
} finally {
await session.close();
}
},
},
};Cypher Executor
For custom Cypher queries: packages/core/src/infrastructure/neo4j/cypher-executor.ts
import { executeCypher } from '../neo4j/cypher-executor';
// Read query
const result = await executeCypher(
`MATCH (o:Organisation)-[:HAS]->(l:Location) WHERE o.id = $id RETURN l`,
{ id: organisationId },
{ accessMode: 'READ' }
);
// Write query
await executeCypher(
`MATCH (o:Organisation {id: $id}) SET o.name = $name RETURN o`,
{ id, name },
{ accessMode: 'WRITE' }
);Naming Conventions
| Type | Convention | Example |
|---|---|---|
| Node Labels | PascalCase | User, Organisation |
| Relationship Types | SCREAMING_SNAKE_CASE | MEMBER_OF, BELONGS_TO |
| Properties | camelCase | createdAt, firstName |
After Schema Changes
pnpm codegen # Regenerate typesBest Practices
- Use directives - @timestamp, @id, @default over manual handling
- Define relationships explicitly - clear direction and type
- Edge properties for metadata - role, timestamp on relationships
- Custom resolvers sparingly - only when directives don't suffice
- Index important fields - for query performance
Connection & Pool Tuning
The driver's pool size and connection timeouts follow the env-var convention (ADR-0012, docs/adr/0012-local-dev-env-var-fallbacks.md): the in-code defaults are tuned for local dev, and preview/production override them on Vercel for Fluid Compute against Aura.
| Driver setting | Env var | Local default | Preview / production |
|---|---|---|---|
maxConnectionPoolSize | NEO4J_MAX_POOL_SIZE | 100 | set on Vercel (currently 40) |
connectionAcquisitionTimeout | NEO4J_CONNECTION_ACQUISITION_TIMEOUT_MS | 60000 | optional override |
connectionLivenessCheckTimeout | NEO4J_CONNECTION_LIVENESS_CHECK_TIMEOUT_MS | 30000 | optional override |
maxConnectionLifetime | NEO4J_MAX_CONNECTION_LIFETIME_MS | 480000 | optional override |
Set
NEO4J_MAX_POOL_SIZEon Vercel before deploying — its default changed (was a hardcoded10), so set it (Production + Preview) or a deploy inherits the local-best100and many instances can exhaust the Aura connection ceiling. The timeout vars are safe to leave unset —connectionAcquisitionTimeoutis now intentionally 60s in every environment (was 10s); override only to tune.
All four resolve through resolveDriverConfig() in client.ts via the shared readPositiveInt reader (packages/core/src/infrastructure/config/env.ts) — each a positive integer (milliseconds for the timeouts), warning and falling back on an invalid value.
Related Files
| Type | Location |
|---|---|
| Schemas | packages/core/src/infrastructure/neo4j/schemas/ |
| Client | packages/core/src/infrastructure/neo4j/client.ts |
| Env config reader | packages/core/src/infrastructure/config/env.ts |
| Resolvers | packages/core/src/infrastructure/neo4j/resolvers/ |
| Cypher Executor | packages/core/src/infrastructure/neo4j/cypher-executor.ts |