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
221 changes: 221 additions & 0 deletions docs/extensions/age.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
# Apache AGE Extension

[Apache AGE](https://age.apache.org/) (A Graph Extension) brings graph database capabilities to PostgreSQL, allowing you to use the Cypher query language alongside standard SQL.

## Installation

The AGE extension is included with PGlite. To use it:

```typescript
import { PGlite } from '@electric-sql/pglite'
import { age } from '@electric-sql/pglite/age'

const pg = new PGlite({
extensions: {
age,
},
})
```

## Quick Start

### Create a Graph

```typescript
// Create a new graph
await pg.exec("SELECT ag_catalog.create_graph('my_graph');")
```

### Create Nodes

```typescript
// Create a node with a label and properties
await pg.exec(`
SELECT * FROM ag_catalog.cypher('my_graph', $$
CREATE (n:Person {name: 'Alice', age: 30})
RETURN n
$$) as (v ag_catalog.agtype);
`)
```

### Create Relationships

```typescript
// Create nodes and a relationship between them
await pg.exec(`
SELECT * FROM ag_catalog.cypher('my_graph', $$
CREATE (a:Person {name: 'Alice'})-[:KNOWS {since: 2020}]->(b:Person {name: 'Bob'})
RETURN a, b
$$) as (a ag_catalog.agtype, b ag_catalog.agtype);
`)
```

### Query Data

```typescript
// Find all people Alice knows
const result = await pg.query(`
SELECT * FROM ag_catalog.cypher('my_graph', $$
MATCH (a:Person {name: 'Alice'})-[:KNOWS]->(friend:Person)
RETURN friend.name, friend.age
$$) as (name ag_catalog.agtype, age ag_catalog.agtype);
`)

console.log(result.rows)
// [{ name: '"Bob"', age: '25' }]
```

### Update Properties

```typescript
await pg.exec(`
SELECT * FROM ag_catalog.cypher('my_graph', $$
MATCH (n:Person {name: 'Alice'})
SET n.city = 'New York', n.age = 31
RETURN n
$$) as (v ag_catalog.agtype);
`)
```

### Delete Nodes

```typescript
await pg.exec(`
SELECT * FROM ag_catalog.cypher('my_graph', $$
MATCH (n:Person {name: 'Bob'})
DETACH DELETE n
$$) as (v ag_catalog.agtype);
`)
```

### Drop a Graph

```typescript
await pg.exec("SELECT ag_catalog.drop_graph('my_graph', true);")
```

## Complete Example: Social Network

```typescript
import { PGlite } from '@electric-sql/pglite'
import { age } from '@electric-sql/pglite/age'

async function main() {
const pg = new PGlite({ extensions: { age } })

// Create graph
await pg.exec("SELECT ag_catalog.create_graph('social');")

// Create users
await pg.exec(`
SELECT * FROM ag_catalog.cypher('social', $$
CREATE
(alice:User {name: 'Alice', email: 'alice@example.com'}),
(bob:User {name: 'Bob', email: 'bob@example.com'}),
(charlie:User {name: 'Charlie', email: 'charlie@example.com'})
$$) as (v ag_catalog.agtype);
`)

// Create friendships
await pg.exec(`
SELECT * FROM ag_catalog.cypher('social', $$
MATCH (a:User {name: 'Alice'}), (b:User {name: 'Bob'})
CREATE (a)-[:FRIENDS_WITH]->(b)
$$) as (v ag_catalog.agtype);
`)

await pg.exec(`
SELECT * FROM ag_catalog.cypher('social', $$
MATCH (b:User {name: 'Bob'}), (c:User {name: 'Charlie'})
CREATE (b)-[:FRIENDS_WITH]->(c)
$$) as (v ag_catalog.agtype);
`)

// Find friends of friends
const result = await pg.query(`
SELECT * FROM ag_catalog.cypher('social', $$
MATCH (alice:User {name: 'Alice'})-[:FRIENDS_WITH*1..2]->(person:User)
RETURN DISTINCT person.name
$$) as (name ag_catalog.agtype);
`)

console.log('Friends and friends-of-friends:', result.rows)
// [{ name: '"Bob"' }, { name: '"Charlie"' }]

await pg.close()
}

main()
```

## Cypher Query Syntax

AGE supports a subset of the Cypher query language. Key clauses include:

| Clause | Description | Example |
|--------|-------------|---------|
| `CREATE` | Create nodes and relationships | `CREATE (n:Label {prop: 'value'})` |
| `MATCH` | Find patterns in the graph | `MATCH (n:Label) RETURN n` |
| `WHERE` | Filter results | `WHERE n.age > 25` |
| `RETURN` | Specify what to return | `RETURN n.name, n.age` |
| `SET` | Update properties | `SET n.prop = 'new value'` |
| `DELETE` | Remove nodes/relationships | `DELETE n` or `DETACH DELETE n` |
| `ORDER BY` | Sort results | `ORDER BY n.name DESC` |
| `LIMIT` | Limit result count | `LIMIT 10` |

## Data Types

AGE returns data as `agtype`, a JSON-like format:

```typescript
// Vertex (node)
{id: 123, label: 'Person', properties: {name: 'Alice'}}::vertex

// Edge (relationship)
{id: 456, startid: 123, endid: 789, label: 'KNOWS', properties: {}}::edge

// Scalar values are JSON-encoded
'"Alice"' // string
'30' // number
'true' // boolean
```

## Important Notes

### Schema Qualification

All AGE functions are in the `ag_catalog` schema. The extension automatically sets `search_path` to include `ag_catalog`, but you can also use fully-qualified names:

```typescript
// Both work:
await pg.exec("SELECT create_graph('g');") // search_path includes ag_catalog
await pg.exec("SELECT ag_catalog.create_graph('g');") // explicit
```

### Column Definitions

Cypher queries require column definitions in the `as` clause:

```typescript
// Single column
SELECT * FROM ag_catalog.cypher('g', $$ RETURN 1 $$) as (v ag_catalog.agtype);

// Multiple columns
SELECT * FROM ag_catalog.cypher('g', $$
MATCH (n) RETURN n.name, n.age
$$) as (name ag_catalog.agtype, age ag_catalog.agtype);
```

## Limitations

- **File operations**: `load_labels_from_file()` is not available (no filesystem access in WASM)
- **Memory**: Large graphs may hit WebAssembly memory limits
- **Performance**: Graph operations are CPU-intensive; consider pagination for large result sets

## Resources

- [Apache AGE Documentation](https://age.apache.org/age-manual/master/index.html)
- [Cypher Query Language](https://neo4j.com/docs/cypher-manual/current/)
- [AGE GitHub Repository](https://github.com/apache/age)


10 changes: 10 additions & 0 deletions packages/pglite/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,16 @@
"default": "./dist/pg_uuidv7/index.cjs"
}
},
"./age": {
"import": {
"types": "./dist/age/index.d.ts",
"default": "./dist/age/index.js"
},
"require": {
"types": "./dist/age/index.d.cts",
"default": "./dist/age/index.cjs"
}
},
"./nodefs": {
"import": {
"types": "./dist/fs/nodefs.d.ts",
Expand Down
4 changes: 4 additions & 0 deletions packages/pglite/scripts/bundle-wasm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,10 @@ async function main() {
'.js',
'.cjs',
])
await findAndReplaceInDir('./dist/age', /\.\.\/release\//g, '', [
'.js',
'.cjs',
])
await findAndReplaceInDir(
'./dist',
`require("./postgres.js")`,
Expand Down
90 changes: 90 additions & 0 deletions packages/pglite/src/age/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import type {
Extension,
ExtensionSetupResult,
PGliteInterface,
} from '../interface'

export interface AgeOptions {
/**
* Whether to automatically set search_path to include ag_catalog.
* Default: false (use fully-qualified names for safety)
*/
setSearchPath?: boolean
}

const setup = async (
pg: PGliteInterface,
emscriptenOpts: any,
clientOnly?: boolean,
) => {
// The init function runs CREATE EXTENSION, LOAD, and hook verification.
// This must run in BOTH modes:
// - Main thread: pg is the actual PGlite instance
// - Worker client: pg is PGliteWorker which proxies commands to the worker
const init = async () => {
// Create the AGE extension
await pg.exec('CREATE EXTENSION IF NOT EXISTS age;')

// AGE requires explicit LOAD to activate parser hooks.
// This is different from extensions like pg_ivm which can lazy-load.
// AGE's post_parse_analyze_hook must be active BEFORE parsing any Cypher queries.
await pg.exec("LOAD 'age';")

// CRITICAL: AGE's internal C code (label_commands.c) creates indexes using
// operator class names WITHOUT schema qualification (e.g., "graphid_ops").
// PostgreSQL must be able to find these in search_path.
// We prepend ag_catalog to ensure AGE functions work correctly.
await pg.exec('SET search_path = ag_catalog, "$user", public;')

// Verify hooks are active by attempting a simple cypher parse.
// This validates that post_parse_analyze_hook is working.
try {
await pg.exec(`
SELECT * FROM ag_catalog.cypher('__age_init_test__', $$
RETURN 1
$$) as (v ag_catalog.agtype);
`)
} catch (e: unknown) {
const error = e as Error
const message = error.message || ''

// Expected error: graph doesn't exist (we haven't created it)
// This confirms the Cypher parser IS working (hooks active)
if (message.includes('does not exist')) {
// This is the expected case - hooks are working, graph just doesn't exist
return
}

// Syntax error means hooks failed to activate - Cypher wasn't parsed
if (message.includes('syntax error')) {
throw new Error(
'AGE hooks failed to initialize. LOAD may not have worked. ' +
'Cypher syntax was not recognized.',
)
}

// Any other error is unexpected and should be propagated
// Examples: permission denied, out of memory, connection errors
throw new Error(`AGE initialization failed unexpectedly: ${message}`)
}
}

// In client-only mode (worker client), skip bundlePath/emscriptenOpts
// but still provide init for hook activation
if (clientOnly) {
return {
init,
} satisfies ExtensionSetupResult
}

return {
emscriptenOpts,
bundlePath: new URL('../../release/age.tar.gz', import.meta.url),
init,
} satisfies ExtensionSetupResult
}

export const age = {
name: 'age',
setup,
} satisfies Extension
Loading
Loading