From f756486538d4690b05a793e89bb0839699426ca5 Mon Sep 17 00:00:00 2001 From: abbuehlj Date: Tue, 23 Dec 2025 09:55:47 +0100 Subject: [PATCH 01/11] feat(extensions): Add Apache AGE graph database extension Add support for Apache AGE (A Graph Extension) which brings graph database capabilities and the Cypher query language to PGlite. Features: - Full Cypher query language support - Create/query/update/delete graph nodes and relationships - Variable-length path queries - Integration with standard SQL New files: - packages/pglite/src/age/index.ts - Extension wrapper - packages/pglite/tests/age.test.ts - Test suite (43 tests) - docs/extensions/age.md - User documentation Modified files: - package.json - Add ./age export - tsup.config.ts - Add entry point - bundle-wasm.ts - Add path replacement Usage: import { PGlite } from '@electric-sql/pglite' import { age } from '@electric-sql/pglite/age' const pg = new PGlite({ extensions: { age } }) await pg.exec("SELECT ag_catalog.create_graph('my_graph');") Requires: electric-sql/postgres-pglite PR for build system changes Depends on: apache/age 32-bit compatibility (jpabbuehl/age fork) --- docs/extensions/age.md | 220 ++++++++++ packages/pglite/package.json | 10 + packages/pglite/scripts/bundle-wasm.ts | 4 + packages/pglite/src/age/index.ts | 91 ++++ packages/pglite/tests/age.test.ts | 581 +++++++++++++++++++++++++ packages/pglite/tsup.config.ts | 1 + postgres-pglite | 2 +- 7 files changed, 908 insertions(+), 1 deletion(-) create mode 100644 docs/extensions/age.md create mode 100644 packages/pglite/src/age/index.ts create mode 100644 packages/pglite/tests/age.test.ts diff --git a/docs/extensions/age.md b/docs/extensions/age.md new file mode 100644 index 000000000..dbeaa2508 --- /dev/null +++ b/docs/extensions/age.md @@ -0,0 +1,220 @@ +# 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) + diff --git a/packages/pglite/package.json b/packages/pglite/package.json index 8d7b326bd..04ee4d764 100644 --- a/packages/pglite/package.json +++ b/packages/pglite/package.json @@ -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", diff --git a/packages/pglite/scripts/bundle-wasm.ts b/packages/pglite/scripts/bundle-wasm.ts index 7d45f9965..cd10544ce 100644 --- a/packages/pglite/scripts/bundle-wasm.ts +++ b/packages/pglite/scripts/bundle-wasm.ts @@ -73,6 +73,10 @@ async function main() { '.js', '.cjs', ]) + await findAndReplaceInDir('./dist/age', /\.\.\/release\//g, '', [ + '.js', + '.cjs', + ]) await findAndReplaceInDir( './dist', `require("./postgres.js")`, diff --git a/packages/pglite/src/age/index.ts b/packages/pglite/src/age/index.ts new file mode 100644 index 000000000..d72a0ddc7 --- /dev/null +++ b/packages/pglite/src/age/index.ts @@ -0,0 +1,91 @@ +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 + diff --git a/packages/pglite/tests/age.test.ts b/packages/pglite/tests/age.test.ts new file mode 100644 index 000000000..76ef79f90 --- /dev/null +++ b/packages/pglite/tests/age.test.ts @@ -0,0 +1,581 @@ +/** + * AGE Extension Tests for PGlite + * + * Apache AGE (A Graph Extension) brings graph database functionality to PostgreSQL. + * This test suite demonstrates common graph operations using Cypher query language. + * + * @see https://age.apache.org/ - Apache AGE documentation + * @see https://pglite.dev/ - PGlite documentation + * + * Usage: + * ```typescript + * import { PGlite } from '@electric-sql/pglite' + * import { age } from '@electric-sql/pglite/age' + * + * const pg = new PGlite({ extensions: { age } }) + * ``` + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest' +import { testEsmCjsAndDTC } from './test-utils.ts' + +await testEsmCjsAndDTC(async (importType) => { + const { PGlite } = + importType === 'esm' + ? await import('../dist/index.js') + : ((await import( + '../dist/index.cjs' + )) as unknown as typeof import('../dist/index.js')) + + const { age } = + importType === 'esm' + ? await import('../dist/age/index.js') + : ((await import( + '../dist/age/index.cjs' + )) as unknown as typeof import('../dist/age/index.js')) + + describe(`age (${importType})`, () => { + // ========================================================================= + // BASIC EXTENSION LOADING + // ========================================================================= + + it('can load extension', async () => { + const pg = new PGlite({ + extensions: { + age, + }, + }) + + const res = await pg.query<{ extname: string }>(` + SELECT extname FROM pg_extension WHERE extname = 'age' + `) + + expect(res.rows).toHaveLength(1) + expect(res.rows[0].extname).toBe('age') + await pg.close() + }) + + // ========================================================================= + // GRAPH LIFECYCLE - CREATE AND DROP GRAPHS + // ========================================================================= + + it('can create a graph', async () => { + const pg = new PGlite({ + extensions: { + age, + }, + }) + + // Create a new graph using ag_catalog.create_graph() + // This creates the graph metadata and necessary internal tables + await pg.exec("SELECT ag_catalog.create_graph('test_graph');") + + // Verify graph exists in ag_catalog.ag_graph system table + const res = await pg.query<{ name: string }>(` + SELECT name FROM ag_catalog.ag_graph WHERE name = 'test_graph' + `) + + expect(res.rows).toHaveLength(1) + expect(res.rows[0].name).toBe('test_graph') + await pg.close() + }) + + it('can drop graph', async () => { + const pg = new PGlite({ + extensions: { + age, + }, + }) + + // Create and then drop a graph + await pg.exec("SELECT ag_catalog.create_graph('temp_graph');") + await pg.exec("SELECT ag_catalog.drop_graph('temp_graph', true);") + + // Verify graph no longer exists + const res = await pg.query<{ name: string }>(` + SELECT name FROM ag_catalog.ag_graph WHERE name = 'temp_graph' + `) + + expect(res.rows).toHaveLength(0) + await pg.close() + }) + + // ========================================================================= + // CREATING NODES (VERTICES) + // ========================================================================= + + it('can execute cypher CREATE and MATCH', async () => { + const pg = new PGlite({ + extensions: { + age, + }, + }) + + await pg.exec("SELECT ag_catalog.create_graph('cypher_test');") + + // CREATE a node with a label and properties + // Labels are like types/categories for nodes (e.g., Person, Movie) + // Properties are key-value pairs stored on the node + await pg.exec(` + SELECT * FROM ag_catalog.cypher('cypher_test', $$ + CREATE (n:Person {name: 'Alice', age: 30}) + RETURN n + $$) as (v ag_catalog.agtype); + `) + + // MATCH finds nodes that match the pattern + // Properties in the pattern act as filters + const res = await pg.query<{ v: string }>(` + SELECT * FROM ag_catalog.cypher('cypher_test', $$ + MATCH (n:Person {name: 'Alice'}) + RETURN n + $$) as (v ag_catalog.agtype); + `) + + expect(res.rows).toHaveLength(1) + // AGE returns data as agtype (JSON-like format) + expect(res.rows[0].v).toContain('Alice') + await pg.close() + }) + + // ========================================================================= + // CREATING RELATIONSHIPS (EDGES) + // ========================================================================= + + it('can create edges between nodes', async () => { + const pg = new PGlite({ + extensions: { + age, + }, + }) + + await pg.exec("SELECT ag_catalog.create_graph('edge_test');") + + // Create a full path: two nodes connected by an edge + // Pattern: (node1)-[:EDGE_TYPE]->(node2) + // Edges are directed (arrow shows direction) + await pg.exec(` + SELECT * FROM ag_catalog.cypher('edge_test', $$ + 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 the relationship + // MATCH pattern includes the edge with its type + const res = await pg.query<{ name: string; friend: string }>(` + SELECT * FROM ag_catalog.cypher('edge_test', $$ + MATCH (a:Person)-[:KNOWS]->(b:Person) + RETURN a.name, b.name + $$) as (name ag_catalog.agtype, friend ag_catalog.agtype); + `) + + expect(res.rows).toHaveLength(1) + expect(res.rows[0].name).toBe('"Alice"') + expect(res.rows[0].friend).toBe('"Bob"') + await pg.close() + }) + + // ========================================================================= + // CYPHER PARSER HOOKS - VERIFYING AGE INTEGRATION + // ========================================================================= + + it('hooks are active - cypher syntax parses correctly', async () => { + const pg = new PGlite({ + extensions: { + age, + }, + }) + + await pg.exec("SELECT ag_catalog.create_graph('hook_test');") + + // This query uses Cypher-specific syntax that PostgreSQL + // doesn't understand natively. It only works because AGE's + // post_parse_analyze_hook intercepts and transforms the query. + const res = await pg.query<{ result: string }>(` + SELECT * FROM ag_catalog.cypher('hook_test', $$ + RETURN 1 + 2 + $$) as (result ag_catalog.agtype); + `) + + expect(res.rows).toHaveLength(1) + expect(res.rows[0].result).toBe('3') + await pg.close() + }) + + // ========================================================================= + // FILTERING WITH WHERE CLAUSE + // ========================================================================= + + it('can use WHERE clause in MATCH', async () => { + const pg = new PGlite({ + extensions: { + age, + }, + }) + + await pg.exec("SELECT ag_catalog.create_graph('where_test');") + + // Create multiple nodes + await pg.exec(` + SELECT * FROM ag_catalog.cypher('where_test', $$ + CREATE (:Person {name: 'Alice', age: 30}), + (:Person {name: 'Bob', age: 25}), + (:Person {name: 'Charlie', age: 35}) + $$) as (v ag_catalog.agtype); + `) + + // Use WHERE to filter results + // WHERE clause supports comparison operators and boolean logic + const res = await pg.query<{ name: string }>(` + SELECT * FROM ag_catalog.cypher('where_test', $$ + MATCH (p:Person) + WHERE p.age > 28 + RETURN p.name + $$) as (name ag_catalog.agtype); + `) + + expect(res.rows).toHaveLength(2) + const names = res.rows.map((r) => r.name) + expect(names).toContain('"Alice"') + expect(names).toContain('"Charlie"') + await pg.close() + }) + + // ========================================================================= + // QUERY ANALYSIS WITH EXPLAIN + // ========================================================================= + + it('EXPLAIN works on cypher queries', async () => { + const pg = new PGlite({ + extensions: { + age, + }, + }) + + await pg.exec("SELECT ag_catalog.create_graph('explain_test');") + + // EXPLAIN shows the query execution plan + // Useful for performance tuning + const res = await pg.query<{ 'QUERY PLAN': string }>(` + EXPLAIN SELECT * FROM ag_catalog.cypher('explain_test', $$ + MATCH (n) + RETURN n + $$) as (v ag_catalog.agtype); + `) + + expect(res.rows.length).toBeGreaterThan(0) + await pg.close() + }) + + // ========================================================================= + // UNICODE AND INTERNATIONAL TEXT SUPPORT + // ========================================================================= + + it('handles unicode in properties', async () => { + const pg = new PGlite({ + extensions: { + age, + }, + }) + + await pg.exec("SELECT ag_catalog.create_graph('unicode_test');") + + // Create node with unicode properties + // AGE supports full UTF-8 text in property values + await pg.exec(` + SELECT * FROM ag_catalog.cypher('unicode_test', $$ + CREATE (n:Message { + text: '你好世界', + emoji: '🎉', + mixed: 'Hello 世界! 🌍' + }) + $$) as (v ag_catalog.agtype); + `) + + const res = await pg.query<{ text: string }>(` + SELECT * FROM ag_catalog.cypher('unicode_test', $$ + MATCH (n:Message) + RETURN n.text + $$) as (text ag_catalog.agtype); + `) + + expect(res.rows).toHaveLength(1) + expect(res.rows[0].text).toContain('你好世界') + await pg.close() + }) + + // ========================================================================= + // ERROR HANDLING + // ========================================================================= + + it('handles invalid cypher syntax gracefully', async () => { + const pg = new PGlite({ + extensions: { + age, + }, + }) + + await pg.exec("SELECT ag_catalog.create_graph('error_test');") + + // Invalid Cypher syntax should throw an error + await expect( + pg.exec(` + SELECT * FROM ag_catalog.cypher('error_test', $$ + MATCH (n INVALID SYNTAX + $$) as (v ag_catalog.agtype); + `), + ).rejects.toThrow() + + await pg.close() + }) + + // ========================================================================= + // UPDATING NODE PROPERTIES + // ========================================================================= + + it('can update node properties', async () => { + const pg = new PGlite({ + extensions: { + age, + }, + }) + + await pg.exec("SELECT ag_catalog.create_graph('update_test');") + + // Create a node + await pg.exec(` + SELECT * FROM ag_catalog.cypher('update_test', $$ + CREATE (n:Person {name: 'Alice', age: 30}) + $$) as (v ag_catalog.agtype); + `) + + // Update the node using SET clause + await pg.exec(` + SELECT * FROM ag_catalog.cypher('update_test', $$ + MATCH (n:Person {name: 'Alice'}) + SET n.age = 31, n.city = 'New York' + RETURN n + $$) as (v ag_catalog.agtype); + `) + + // Verify the update + const res = await pg.query<{ age: string; city: string }>(` + SELECT * FROM ag_catalog.cypher('update_test', $$ + MATCH (n:Person {name: 'Alice'}) + RETURN n.age, n.city + $$) as (age ag_catalog.agtype, city ag_catalog.agtype); + `) + + expect(res.rows).toHaveLength(1) + expect(res.rows[0].age).toBe('31') + expect(res.rows[0].city).toBe('"New York"') + await pg.close() + }) + + // ========================================================================= + // DELETING NODES + // ========================================================================= + + it('can delete nodes', async () => { + const pg = new PGlite({ + extensions: { + age, + }, + }) + + await pg.exec("SELECT ag_catalog.create_graph('delete_test');") + + // Create nodes + await pg.exec(` + SELECT * FROM ag_catalog.cypher('delete_test', $$ + CREATE (:Person {name: 'ToDelete'}), + (:Person {name: 'ToKeep'}) + $$) as (v ag_catalog.agtype); + `) + + // Delete specific node using DELETE clause + // DETACH DELETE removes the node and all its relationships + await pg.exec(` + SELECT * FROM ag_catalog.cypher('delete_test', $$ + MATCH (n:Person {name: 'ToDelete'}) + DELETE n + $$) as (v ag_catalog.agtype); + `) + + // Verify only one node remains + const res = await pg.query<{ count: string }>(` + SELECT * FROM ag_catalog.cypher('delete_test', $$ + MATCH (n:Person) + RETURN count(n) + $$) as (count ag_catalog.agtype); + `) + + expect(res.rows[0].count).toBe('1') + await pg.close() + }) + + // ========================================================================= + // ORDERING AND LIMITING RESULTS + // ========================================================================= + + it('can use ORDER BY and LIMIT', async () => { + const pg = new PGlite({ + extensions: { + age, + }, + }) + + await pg.exec("SELECT ag_catalog.create_graph('order_test');") + + // Create multiple nodes with different ages + await pg.exec(` + SELECT * FROM ag_catalog.cypher('order_test', $$ + CREATE (:Person {name: 'Alice', age: 30}), + (:Person {name: 'Bob', age: 25}), + (:Person {name: 'Charlie', age: 35}), + (:Person {name: 'Diana', age: 28}) + $$) as (v ag_catalog.agtype); + `) + + // Query with ORDER BY and LIMIT + // ORDER BY sorts results, LIMIT restricts count + const res = await pg.query<{ name: string }>(` + SELECT * FROM ag_catalog.cypher('order_test', $$ + MATCH (p:Person) + RETURN p.name + ORDER BY p.age DESC + LIMIT 2 + $$) as (name ag_catalog.agtype); + `) + + expect(res.rows).toHaveLength(2) + expect(res.rows[0].name).toBe('"Charlie"') // age 35 + expect(res.rows[1].name).toBe('"Alice"') // age 30 + await pg.close() + }) + + // ========================================================================= + // REAL-WORLD EXAMPLE: SOCIAL NETWORK + // ========================================================================= + + describe('real-world example: social network', () => { + let pg: InstanceType + + beforeAll(async () => { + pg = new PGlite({ + extensions: { age }, + }) + + // Create a social network 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', joined: '2023-01-15'}), + (bob:User {name: 'Bob', email: 'bob@example.com', joined: '2023-02-20'}), + (charlie:User {name: 'Charlie', email: 'charlie@example.com', joined: '2023-03-10'}), + (diana:User {name: 'Diana', email: 'diana@example.com', joined: '2023-04-05'}) + $$) as (v ag_catalog.agtype); + `) + + // Create friendship relationships + await pg.exec(` + SELECT * FROM ag_catalog.cypher('social', $$ + MATCH (alice:User {name: 'Alice'}), (bob:User {name: 'Bob'}) + CREATE (alice)-[:FRIENDS_WITH {since: '2023-03-01'}]->(bob) + $$) as (v ag_catalog.agtype); + `) + + await pg.exec(` + SELECT * FROM ag_catalog.cypher('social', $$ + MATCH (alice:User {name: 'Alice'}), (charlie:User {name: 'Charlie'}) + CREATE (alice)-[:FRIENDS_WITH {since: '2023-04-15'}]->(charlie) + $$) as (v ag_catalog.agtype); + `) + + await pg.exec(` + SELECT * FROM ag_catalog.cypher('social', $$ + MATCH (bob:User {name: 'Bob'}), (diana:User {name: 'Diana'}) + CREATE (bob)-[:FRIENDS_WITH {since: '2023-05-01'}]->(diana) + $$) as (v ag_catalog.agtype); + `) + + // Create posts + await pg.exec(` + SELECT * FROM ag_catalog.cypher('social', $$ + MATCH (alice:User {name: 'Alice'}) + CREATE (alice)-[:POSTED]->(p:Post { + content: 'Hello from PGlite with AGE!', + timestamp: '2023-06-01T10:00:00Z', + likes: 42 + }) + $$) as (v ag_catalog.agtype); + `) + }) + + afterAll(async () => { + await pg.close() + }) + + it('can find direct friends', async () => { + const res = await pg.query<{ friend: string }>(` + SELECT * FROM ag_catalog.cypher('social', $$ + MATCH (alice:User {name: 'Alice'})-[:FRIENDS_WITH]->(friend:User) + RETURN friend.name + $$) as (friend ag_catalog.agtype); + `) + + expect(res.rows).toHaveLength(2) + const friends = res.rows.map((r) => r.friend) + expect(friends).toContain('"Bob"') + expect(friends).toContain('"Charlie"') + }) + + it('can find friends of friends', async () => { + // Variable length path: find friends up to 2 hops away + const res = await pg.query<{ person: string }>(` + SELECT * FROM ag_catalog.cypher('social', $$ + MATCH (alice:User {name: 'Alice'})-[:FRIENDS_WITH*1..2]->(person:User) + WHERE person.name <> 'Alice' + RETURN DISTINCT person.name + $$) as (person ag_catalog.agtype); + `) + + // Should find Bob, Charlie (direct) and Diana (through Bob) + expect(res.rows.length).toBeGreaterThanOrEqual(2) + const people = res.rows.map((r) => r.person) + expect(people).toContain('"Diana"') // friend of friend + }) + + it('can find posts by user', async () => { + const res = await pg.query<{ content: string; likes: string }>(` + SELECT * FROM ag_catalog.cypher('social', $$ + MATCH (u:User {name: 'Alice'})-[:POSTED]->(post:Post) + RETURN post.content, post.likes + $$) as (content ag_catalog.agtype, likes ag_catalog.agtype); + `) + + expect(res.rows).toHaveLength(1) + expect(res.rows[0].content).toContain('PGlite with AGE') + expect(res.rows[0].likes).toBe('42') + }) + + it('can count relationships', async () => { + const res = await pg.query<{ name: string; friend_count: string }>(` + SELECT * FROM ag_catalog.cypher('social', $$ + MATCH (u:User)-[:FRIENDS_WITH]->(friend:User) + RETURN u.name, count(friend) as friend_count + ORDER BY friend_count DESC + $$) as (name ag_catalog.agtype, friend_count ag_catalog.agtype); + `) + + // Alice has 2 friends (most) + expect(res.rows[0].name).toBe('"Alice"') + expect(res.rows[0].friend_count).toBe('2') + }) + }) + }) +}) diff --git a/packages/pglite/tsup.config.ts b/packages/pglite/tsup.config.ts index e4cadffa8..4faab1359 100644 --- a/packages/pglite/tsup.config.ts +++ b/packages/pglite/tsup.config.ts @@ -27,6 +27,7 @@ const entryPoints = [ 'src/pg_ivm/index.ts', 'src/pgtap/index.ts', 'src/pg_uuidv7/index.ts', + 'src/age/index.ts', 'src/worker/index.ts', ] diff --git a/postgres-pglite b/postgres-pglite index 1195d5388..2fecd224d 160000 --- a/postgres-pglite +++ b/postgres-pglite @@ -1 +1 @@ -Subproject commit 1195d5388bd5529e0013c45fa816cfcd953d84e0 +Subproject commit 2fecd224d7ba69bfed5374edd10b699c9c02def9 From 9e82c60cc985a905a3f0ec51de66c270271c7964 Mon Sep 17 00:00:00 2001 From: abbuehlj Date: Sun, 11 Jan 2026 17:54:09 +0100 Subject: [PATCH 02/11] chore: Update postgres-pglite submodule with 32-bit AGE support - Update submodule to include SIZEOF_DATUM=4 build flag for AGE - Minor code style fixes (quote consistency) --- docs/extensions/age.md | 1 + packages/pglite/src/age/index.ts | 3 +-- postgres-pglite | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/extensions/age.md b/docs/extensions/age.md index dbeaa2508..f40ba5a78 100644 --- a/docs/extensions/age.md +++ b/docs/extensions/age.md @@ -218,3 +218,4 @@ $$) as (name ag_catalog.agtype, age ag_catalog.agtype); - [Cypher Query Language](https://neo4j.com/docs/cypher-manual/current/) - [AGE GitHub Repository](https://github.com/apache/age) + diff --git a/packages/pglite/src/age/index.ts b/packages/pglite/src/age/index.ts index d72a0ddc7..633187b5b 100644 --- a/packages/pglite/src/age/index.ts +++ b/packages/pglite/src/age/index.ts @@ -34,7 +34,7 @@ const setup = async ( // 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;") + 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. @@ -88,4 +88,3 @@ export const age = { name: 'age', setup, } satisfies Extension - diff --git a/postgres-pglite b/postgres-pglite index 2fecd224d..962b3bb14 160000 --- a/postgres-pglite +++ b/postgres-pglite @@ -1 +1 @@ -Subproject commit 2fecd224d7ba69bfed5374edd10b699c9c02def9 +Subproject commit 962b3bb146c5650342d5745cdc06e2f5d7798cb1 From c5ddd75c4ab2ee5d065b613d473bd2cc7848cca5 Mon Sep 17 00:00:00 2001 From: abbuehlj Date: Tue, 20 Jan 2026 08:23:38 +0100 Subject: [PATCH 03/11] chore: update postgres-pglite submodule to point to upstream AGE --- postgres-pglite | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/postgres-pglite b/postgres-pglite index 962b3bb14..17c7e46b0 160000 --- a/postgres-pglite +++ b/postgres-pglite @@ -1 +1 @@ -Subproject commit 962b3bb146c5650342d5745cdc06e2f5d7798cb1 +Subproject commit 17c7e46b0d3f571b8f09e1772ac77dfcd1f030f0 From 23768cfe3f48b765799125b6222303445bceb283 Mon Sep 17 00:00:00 2001 From: abbuehlj Date: Sun, 25 Jan 2026 09:02:17 +0100 Subject: [PATCH 04/11] upd: update postgres-pglite submodule to include age-extension fixes --- postgres-pglite | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/postgres-pglite b/postgres-pglite index 17c7e46b0..fd6042e7a 160000 --- a/postgres-pglite +++ b/postgres-pglite @@ -1 +1 @@ -Subproject commit 17c7e46b0d3f571b8f09e1772ac77dfcd1f030f0 +Subproject commit fd6042e7ac9a4a6b06c8d17584f2eb4060f26c1d From bfa10bc1cf589d5724718eb5559c68f9671a6046 Mon Sep 17 00:00:00 2001 From: abbuehlj Date: Mon, 16 Feb 2026 10:12:11 +0100 Subject: [PATCH 05/11] chore: update postgres-pglite submodule for AGE PG17/v1.7.0-rc0 - Points to official upstream Apache AGE PG17/v1.7.0-rc0 release - No longer depends on custom fork branch for 32-bit support - 32-bit WASM compatibility included in official release --- postgres-pglite | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/postgres-pglite b/postgres-pglite index fd6042e7a..c1932a14d 160000 --- a/postgres-pglite +++ b/postgres-pglite @@ -1 +1 @@ -Subproject commit fd6042e7ac9a4a6b06c8d17584f2eb4060f26c1d +Subproject commit c1932a14d98aa4ee0bdb3f2c70a89a08c8f9b7a9 From 1f4ce6e6e7b0fde6366db2811377c395c379b476 Mon Sep 17 00:00:00 2001 From: Lucas Mohallem Ferraz Date: Sat, 21 Feb 2026 07:43:28 -0800 Subject: [PATCH 06/11] fix(pglite): update PR for AGE, format and submodule --- .gitmodules | 2 +- docs/extensions/age.md | 32 +++++++++++++++----------------- postgres-pglite | 2 +- 3 files changed, 17 insertions(+), 19 deletions(-) diff --git a/.gitmodules b/.gitmodules index e11c6ba01..de9bd816e 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,3 @@ [submodule "postgres-pglite"] path = postgres-pglite - url = ../postgres-pglite.git + url = https://github.com/jpabbuehl/postgres-pglite.git diff --git a/docs/extensions/age.md b/docs/extensions/age.md index f40ba5a78..ebe9578ac 100644 --- a/docs/extensions/age.md +++ b/docs/extensions/age.md @@ -152,16 +152,16 @@ main() 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` | +| 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 @@ -171,7 +171,7 @@ AGE returns data as `agtype`, a JSON-like format: // Vertex (node) {id: 123, label: 'Person', properties: {name: 'Alice'}}::vertex -// Edge (relationship) +// Edge (relationship) {id: 456, startid: 123, endid: 789, label: 'KNOWS', properties: {}}::edge // Scalar values are JSON-encoded @@ -188,8 +188,8 @@ All AGE functions are in the `ag_catalog` schema. The extension automatically se ```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 +await pg.exec("SELECT create_graph('g');") // search_path includes ag_catalog +await pg.exec("SELECT ag_catalog.create_graph('g');") // explicit ``` ### Column Definitions @@ -201,8 +201,8 @@ Cypher queries require column definitions in the `as` clause: 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 +SELECT * FROM ag_catalog.cypher('g', $$ + MATCH (n) RETURN n.name, n.age $$) as (name ag_catalog.agtype, age ag_catalog.agtype); ``` @@ -217,5 +217,3 @@ $$) as (name ag_catalog.agtype, age ag_catalog.agtype); - [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) - - diff --git a/postgres-pglite b/postgres-pglite index c1932a14d..bdc5180f5 160000 --- a/postgres-pglite +++ b/postgres-pglite @@ -1 +1 @@ -Subproject commit c1932a14d98aa4ee0bdb3f2c70a89a08c8f9b7a9 +Subproject commit bdc5180f50cde6dddb734ddcca45cb9c4139d236 From 80dc8d0fefaba2e6d65515306ad20dd5c3989eb6 Mon Sep 17 00:00:00 2001 From: Lucas Mohallem Ferraz Date: Sun, 8 Mar 2026 15:34:56 -0700 Subject: [PATCH 07/11] fix(pglite): manually init AGE, prune docs, format modules, provide changeset --- .changeset/silver-age-support.md | 5 + docs/extensions/age.md | 176 ++---------------------------- packages/pglite/src/age/index.ts | 75 +------------ packages/pglite/tests/age.test.ts | 70 ++++++++++++ 4 files changed, 84 insertions(+), 242 deletions(-) create mode 100644 .changeset/silver-age-support.md diff --git a/.changeset/silver-age-support.md b/.changeset/silver-age-support.md new file mode 100644 index 000000000..c58cae640 --- /dev/null +++ b/.changeset/silver-age-support.md @@ -0,0 +1,5 @@ +--- +"@electric-sql/pglite": patch +--- + +Add Apache AGE graph database extension support diff --git a/docs/extensions/age.md b/docs/extensions/age.md index ebe9578ac..83e63cefd 100644 --- a/docs/extensions/age.md +++ b/docs/extensions/age.md @@ -17,184 +17,24 @@ const pg = new PGlite({ }) ``` -## 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: +All AGE functions are in the `ag_catalog` schema. The extension does not implicitly update the search path for safety. You must either manually set the `search_path` to include `ag_catalog` for your connection, or 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 +// Explicit qualification: +await pg.exec("SELECT ag_catalog.create_graph('g');") + +// Setting the search path for the session: +await pg.exec('SET search_path = ag_catalog, "$user", public;') +await pg.exec("SELECT create_graph('g');") ``` ### Column Definitions -Cypher queries require column definitions in the `as` clause: +Cypher queries require column definitions in the `as` clause to map the dynamic graph types back to standard PostgreSQL relations: ```typescript // Single column diff --git a/packages/pglite/src/age/index.ts b/packages/pglite/src/age/index.ts index 633187b5b..6b537e8c9 100644 --- a/packages/pglite/src/age/index.ts +++ b/packages/pglite/src/age/index.ts @@ -4,83 +4,10 @@ import type { 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 - } - +const setup = async (_pg: PGliteInterface, emscriptenOpts: any) => { return { emscriptenOpts, bundlePath: new URL('../../release/age.tar.gz', import.meta.url), - init, } satisfies ExtensionSetupResult } diff --git a/packages/pglite/tests/age.test.ts b/packages/pglite/tests/age.test.ts index 76ef79f90..fff679c72 100644 --- a/packages/pglite/tests/age.test.ts +++ b/packages/pglite/tests/age.test.ts @@ -45,6 +45,11 @@ await testEsmCjsAndDTC(async (importType) => { age, }, }) + await pg.exec(` + CREATE EXTENSION IF NOT EXISTS age; + LOAD 'age'; + SET search_path = ag_catalog, "$user", public; + `) const res = await pg.query<{ extname: string }>(` SELECT extname FROM pg_extension WHERE extname = 'age' @@ -65,6 +70,11 @@ await testEsmCjsAndDTC(async (importType) => { age, }, }) + await pg.exec(` + CREATE EXTENSION IF NOT EXISTS age; + LOAD 'age'; + SET search_path = ag_catalog, "$user", public; + `) // Create a new graph using ag_catalog.create_graph() // This creates the graph metadata and necessary internal tables @@ -86,6 +96,11 @@ await testEsmCjsAndDTC(async (importType) => { age, }, }) + await pg.exec(` + CREATE EXTENSION IF NOT EXISTS age; + LOAD 'age'; + SET search_path = ag_catalog, "$user", public; + `) // Create and then drop a graph await pg.exec("SELECT ag_catalog.create_graph('temp_graph');") @@ -110,6 +125,11 @@ await testEsmCjsAndDTC(async (importType) => { age, }, }) + await pg.exec(` + CREATE EXTENSION IF NOT EXISTS age; + LOAD 'age'; + SET search_path = ag_catalog, "$user", public; + `) await pg.exec("SELECT ag_catalog.create_graph('cypher_test');") @@ -148,6 +168,11 @@ await testEsmCjsAndDTC(async (importType) => { age, }, }) + await pg.exec(` + CREATE EXTENSION IF NOT EXISTS age; + LOAD 'age'; + SET search_path = ag_catalog, "$user", public; + `) await pg.exec("SELECT ag_catalog.create_graph('edge_test');") @@ -186,6 +211,11 @@ await testEsmCjsAndDTC(async (importType) => { age, }, }) + await pg.exec(` + CREATE EXTENSION IF NOT EXISTS age; + LOAD 'age'; + SET search_path = ag_catalog, "$user", public; + `) await pg.exec("SELECT ag_catalog.create_graph('hook_test');") @@ -213,6 +243,11 @@ await testEsmCjsAndDTC(async (importType) => { age, }, }) + await pg.exec(` + CREATE EXTENSION IF NOT EXISTS age; + LOAD 'age'; + SET search_path = ag_catalog, "$user", public; + `) await pg.exec("SELECT ag_catalog.create_graph('where_test');") @@ -252,6 +287,11 @@ await testEsmCjsAndDTC(async (importType) => { age, }, }) + await pg.exec(` + CREATE EXTENSION IF NOT EXISTS age; + LOAD 'age'; + SET search_path = ag_catalog, "$user", public; + `) await pg.exec("SELECT ag_catalog.create_graph('explain_test');") @@ -278,6 +318,11 @@ await testEsmCjsAndDTC(async (importType) => { age, }, }) + await pg.exec(` + CREATE EXTENSION IF NOT EXISTS age; + LOAD 'age'; + SET search_path = ag_catalog, "$user", public; + `) await pg.exec("SELECT ag_catalog.create_graph('unicode_test');") @@ -315,6 +360,11 @@ await testEsmCjsAndDTC(async (importType) => { age, }, }) + await pg.exec(` + CREATE EXTENSION IF NOT EXISTS age; + LOAD 'age'; + SET search_path = ag_catalog, "$user", public; + `) await pg.exec("SELECT ag_catalog.create_graph('error_test');") @@ -340,6 +390,11 @@ await testEsmCjsAndDTC(async (importType) => { age, }, }) + await pg.exec(` + CREATE EXTENSION IF NOT EXISTS age; + LOAD 'age'; + SET search_path = ag_catalog, "$user", public; + `) await pg.exec("SELECT ag_catalog.create_graph('update_test');") @@ -383,6 +438,11 @@ await testEsmCjsAndDTC(async (importType) => { age, }, }) + await pg.exec(` + CREATE EXTENSION IF NOT EXISTS age; + LOAD 'age'; + SET search_path = ag_catalog, "$user", public; + `) await pg.exec("SELECT ag_catalog.create_graph('delete_test');") @@ -425,6 +485,11 @@ await testEsmCjsAndDTC(async (importType) => { age, }, }) + await pg.exec(` + CREATE EXTENSION IF NOT EXISTS age; + LOAD 'age'; + SET search_path = ag_catalog, "$user", public; + `) await pg.exec("SELECT ag_catalog.create_graph('order_test');") @@ -466,6 +531,11 @@ await testEsmCjsAndDTC(async (importType) => { pg = new PGlite({ extensions: { age }, }) + await pg.exec(` + CREATE EXTENSION IF NOT EXISTS age; + LOAD 'age'; + SET search_path = ag_catalog, "$user", public; + `) // Create a social network graph await pg.exec("SELECT ag_catalog.create_graph('social');") From d99b334d1c1656c3920751cdb2c6280e5c7a0d53 Mon Sep 17 00:00:00 2001 From: tudor Date: Tue, 10 Mar 2026 08:30:08 +0100 Subject: [PATCH 08/11] update submodule --- .gitmodules | 2 +- postgres-pglite | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitmodules b/.gitmodules index de9bd816e..f3bced548 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,3 @@ [submodule "postgres-pglite"] path = postgres-pglite - url = https://github.com/jpabbuehl/postgres-pglite.git + url = git@github.com:electric-sql/postgres-pglite.git diff --git a/postgres-pglite b/postgres-pglite index bdc5180f5..51e222cc5 160000 --- a/postgres-pglite +++ b/postgres-pglite @@ -1 +1 @@ -Subproject commit bdc5180f50cde6dddb734ddcca45cb9c4139d236 +Subproject commit 51e222cc5f799675b8dd098f5cb7bf46cbad75a2 From 4895144dc398f0e3c7e79ed052ddde8b6bf85b8f Mon Sep 17 00:00:00 2001 From: tudor Date: Tue, 10 Mar 2026 08:32:23 +0100 Subject: [PATCH 09/11] submodule --- .gitmodules | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitmodules b/.gitmodules index f3bced548..e11c6ba01 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,3 @@ [submodule "postgres-pglite"] path = postgres-pglite - url = git@github.com:electric-sql/postgres-pglite.git + url = ../postgres-pglite.git From 99f588aee6be4dbfb40cc78cf6bf86a2d3988b89 Mon Sep 17 00:00:00 2001 From: tudor Date: Tue, 10 Mar 2026 09:24:54 +0100 Subject: [PATCH 10/11] missing docs for Apache AGE extension --- docs/extensions/extensions.data.ts | 20 ++++++++++++++++++++ docs/repl/allExtensions.ts | 1 + packages/pglite-socket/src/scripts/server.ts | 1 + 3 files changed, 22 insertions(+) diff --git a/docs/extensions/extensions.data.ts b/docs/extensions/extensions.data.ts index d66992983..413a0de31 100644 --- a/docs/extensions/extensions.data.ts +++ b/docs/extensions/extensions.data.ts @@ -575,6 +575,26 @@ const baseExtensions: Extension[] = [ importName: 'pg_hashids', size: 4212, }, + { + name: 'Apache AGE', + description: ` + An extension for PostgreSQL that enables users to leverage a graph database on top of + the existing relational databases. AGE is an acronym for A Graph Extension and is + inspired by Bitnine's AgensGraph, a multi-model database fork of PostgreSQL. The basic + principle of the project is to create a single storage that handles both the relational + and graph data model so that the users can use the standard ANSI SQL along with openCypher, + one of the most popular graph query languages today. There is a strong need for cohesive, + easy-to-implement multi-model databases. As an extension of PostgreSQL, AGE supports all + the functionalities and features of PostgreSQL while also offering a graph model to boot. + `, + shortDescription: + 'Leverage a graph database on top of the existing relational databases.', + docs: 'https://github.com/apache/age', + tags: ['postgres extension'], + importPath: '@electric-sql/pglite/age', + importName: 'age', + size: 141551, + }, ] const tags = [ diff --git a/docs/repl/allExtensions.ts b/docs/repl/allExtensions.ts index 9c8c75f9f..82a977395 100644 --- a/docs/repl/allExtensions.ts +++ b/docs/repl/allExtensions.ts @@ -34,3 +34,4 @@ export { unaccent } from '@electric-sql/pglite/contrib/unaccent' export { uuid_ossp } from '@electric-sql/pglite/contrib/uuid_ossp' export { vector } from '@electric-sql/pglite/vector' export { pg_hashids } from '@electric-sql/pglite/pg_hashids' +export { age } from '@electric-sql/pglite/age' diff --git a/packages/pglite-socket/src/scripts/server.ts b/packages/pglite-socket/src/scripts/server.ts index ba765bb38..428dd6d8a 100644 --- a/packages/pglite-socket/src/scripts/server.ts +++ b/packages/pglite-socket/src/scripts/server.ts @@ -156,6 +156,7 @@ class PGLiteServerRunner { 'pg_ivm', 'pg_uuidv7', 'pgtap', + 'age', ] for (const name of this.config.extensionNames) { From ba775576df755a8b588ceffb748bcaf895499409 Mon Sep 17 00:00:00 2001 From: tudor Date: Tue, 10 Mar 2026 09:31:01 +0100 Subject: [PATCH 11/11] no changeset --- .changeset/silver-age-support.md | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 .changeset/silver-age-support.md diff --git a/.changeset/silver-age-support.md b/.changeset/silver-age-support.md deleted file mode 100644 index c58cae640..000000000 --- a/.changeset/silver-age-support.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@electric-sql/pglite": patch ---- - -Add Apache AGE graph database extension support