diff --git a/.github/workflows/build_and_test.yml b/.github/workflows/build_and_test.yml index 3a18acca4..9dd408125 100644 --- a/.github/workflows/build_and_test.yml +++ b/.github/workflows/build_and_test.yml @@ -64,10 +64,15 @@ jobs: - name: Typecheck pglite working-directory: ${{ github.workspace }}/packages/pglite run: pnpm typecheck + - name: Test pglite working-directory: ${{ github.workspace }}/packages/pglite run: pnpm test + - name: Test pglite-postgis + working-directory: ${{ github.workspace }}/packages/pglite-postgis + run: pnpm test + - name: Upload PGlite Interim to Github artifacts id: upload-pglite-interim-build-files uses: actions/upload-artifact@v4 @@ -84,6 +89,14 @@ jobs: path: ./packages/pglite-tools/release/** retention-days: 60 + - name: Upload pglite-postgis build artifacts to Github artifacts + id: upload-pglite-postgis-release-files + uses: actions/upload-artifact@v4 + with: + name: pglite-postgis-release-files-node-v20.x + path: ./packages/pglite-postgis/release/** + retention-days: 60 + build-and-test-pglite: name: Build and Test packages/pglite runs-on: blacksmith-32vcpu-ubuntu-2204 @@ -118,6 +131,12 @@ jobs: name: pglite-tools-release-files-node-v20.x path: ./packages/pglite-tools/release + - name: Download pglite-postgis build artifacts + uses: actions/download-artifact@v4 + with: + name: pglite-postgis-release-files-node-v20.x + path: ./packages/pglite-postgis/release + - name: Install dependencies run: | pnpm install --frozen-lockfile @@ -203,6 +222,12 @@ jobs: with: name: pglite-tools-release-files-node-v20.x path: ./packages/pglite-tools/release/ + + - name: Download pglite-postgis build artifacts + uses: actions/download-artifact@v4 + with: + name: pglite-postgis-release-files-node-v20.x + path: ./packages/pglite-postgis/release/ - name: Install dependencies run: pnpm install --frozen-lockfile @@ -224,7 +249,14 @@ jobs: uses: actions/upload-artifact@v4 with: name: pglite-tools-dist-node-v${{ matrix.node }} - path: ./packages/pglite-tools/dist/* + path: ./packages/pglite-tools/dist/* + + - name: Upload pglite-postgis distribution artifact + id: upload-pglite-postgis-dist + uses: actions/upload-artifact@v4 + with: + name: pglite-postgis-dist-node-v${{ matrix.node }} + path: ./packages/pglite-postgis/dist/* publish-website-with-demos: name: Publish website with demos @@ -253,12 +285,24 @@ jobs: name: pglite-tools-release-files-node-v20.x path: ./packages/pglite-tools/release + - name: Download pglite-postgis build artifacts + uses: actions/download-artifact@v4 + with: + name: pglite-postgis-release-files-node-v20.x + path: ./packages/pglite-postgis/release/ + - name: Download PGlite build artifacts uses: actions/download-artifact@v4 with: name: pglite-dist-node-v20.x path: ./packages/pglite/dist/ + - name: Download pglite-postgis dist artifacts + uses: actions/download-artifact@v4 + with: + name: pglite-postgis-dist-node-v20.x + path: ./packages/pglite-postgis/dist/ + - name: Install dependencies run: pnpm install --frozen-lockfile @@ -366,6 +410,12 @@ jobs: name: pglite-tools-release-files-node-v20.x path: ./packages/pglite-tools/release/ + - name: Download pglite-postgis build artifacts + uses: actions/download-artifact@v4 + with: + name: pglite-postgis-release-files-node-v20.x + path: ./packages/pglite-postgis/release/ + - run: pnpm install --frozen-lockfile - run: pnpm --filter "./packages/**" build - name: Create Release Pull Request or Publish diff --git a/docs/extensions/extensions.data.ts b/docs/extensions/extensions.data.ts index d66992983..6f5caf808 100644 --- a/docs/extensions/extensions.data.ts +++ b/docs/extensions/extensions.data.ts @@ -292,6 +292,20 @@ const baseExtensions: Extension[] = [ importName: 'pgtap', size: 239428, }, + { + name: 'postgis', + description: ` + PostGIS extends the capabilities of the PostgreSQL relational database by adding + support for storing, indexing, and querying geospatial data. + *No GDAL support atm. + `, + shortDescription: 'Storing, indexing, and querying geospatial data.', + docs: 'postgis.net', + tags: ['postgres extension'], + importPath: '@electric-sql/pglite-postgis', + importName: 'postgis', + size: 7901736, + }, { name: 'pg_uuidv7', description: ` diff --git a/docs/package.json b/docs/package.json index 03864e194..95ad3ef61 100644 --- a/docs/package.json +++ b/docs/package.json @@ -20,6 +20,7 @@ "dependencies": { "@electric-sql/pglite": "workspace:*", "@electric-sql/pglite-repl": "workspace:*", + "@electric-sql/pglite-postgis": "workspace:*", "@uiw/codemirror-theme-github": "^4.23.0", "dedent": "^1.5.3" } diff --git a/docs/repl/allExtensions.ts b/docs/repl/allExtensions.ts index 9c8c75f9f..21ba9290a 100644 --- a/docs/repl/allExtensions.ts +++ b/docs/repl/allExtensions.ts @@ -25,6 +25,7 @@ export { pg_visibility } from '@electric-sql/pglite/contrib/pg_visibility' export { pg_walinspect } from '@electric-sql/pglite/contrib/pg_walinspect' export { pgcrypto } from '@electric-sql/pglite/contrib/pgcrypto' export { pgtap } from '@electric-sql/pglite/pgtap' +export { postgis } from '@electric-sql/pglite-postgis' export { seg } from '@electric-sql/pglite/contrib/seg' export { tablefunc } from '@electric-sql/pglite/contrib/tablefunc' export { tcn } from '@electric-sql/pglite/contrib/tcn' diff --git a/package.json b/package.json index 600dd8143..d983ff93b 100644 --- a/package.json +++ b/package.json @@ -12,9 +12,10 @@ "ci:publish": "pnpm changeset publish", "ts:build": "pnpm -r --filter \"./packages/**\" build", "ts:build:debug": "DEBUG=true pnpm ts:build", + "wasm:copy-postgis": "mkdir -p ./packages/pglite-postgis/release && cp ./postgres-pglite/dist/extensions/postgis/postgis.tar.gz ./packages/pglite-postgis/release", "wasm:copy-pgdump": "mkdir -p ./packages/pglite-tools/release && cp ./postgres-pglite/dist/bin/pg_dump.* ./packages/pglite-tools/release", "wasm:copy-pglite": "mkdir -p ./packages/pglite/release/ && cp ./postgres-pglite/dist/bin/pglite.* ./packages/pglite/release/ && cp ./postgres-pglite/dist/extensions/*.tar.gz ./packages/pglite/release/", - "wasm:build": "cd postgres-pglite && ./build-with-docker.sh && cd .. && pnpm wasm:copy-pglite && pnpm wasm:copy-pgdump", + "wasm:build": "cd postgres-pglite && ./build-with-docker.sh && cd .. && pnpm wasm:copy-pglite && pnpm wasm:copy-pgdump && pnpm wasm:copy-postgis", "wasm:build:debug": "DEBUG=true pnpm wasm:build", "build:all": "pnpm wasm:build && pnpm ts:build", "build:all:debug": "DEBUG=true pnpm build:all" diff --git a/packages/pglite-postgis/.gitignore b/packages/pglite-postgis/.gitignore new file mode 100644 index 000000000..ef8abb0e7 --- /dev/null +++ b/packages/pglite-postgis/.gitignore @@ -0,0 +1,2 @@ +release/* +dist \ No newline at end of file diff --git a/packages/pglite-postgis/CHANGELOG.md b/packages/pglite-postgis/CHANGELOG.md new file mode 100644 index 000000000..41b251d32 --- /dev/null +++ b/packages/pglite-postgis/CHANGELOG.md @@ -0,0 +1,7 @@ +# @electric-sql/pglite-postgis + +## 0.0.1 + +- Initial release +- PostGIS extension extracted from `@electric-sql/pglite` + diff --git a/packages/pglite-postgis/README.md b/packages/pglite-postgis/README.md new file mode 100644 index 000000000..1e671b7b3 --- /dev/null +++ b/packages/pglite-postgis/README.md @@ -0,0 +1,50 @@ +# @electric-sql/pglite-postgis + +PostGIS extension for [PGlite](https://pglite.dev). + +## Installation + +```bash +npm install @electric-sql/pglite-postgis +``` + +## Usage + +```typescript +import { PGlite } from '@electric-sql/pglite' +import { postgis } from '@electric-sql/pglite-postgis' + +const pg = new PGlite({ + extensions: { + postgis, + }, +}) + +await pg.exec('CREATE EXTENSION IF NOT EXISTS postgis;') + +// Create a table with geometry columns +await pg.exec(` + CREATE TABLE cities ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL, + location GEOMETRY(Point, 4326) + ); +`) + +// Insert data +await pg.query(` + INSERT INTO cities (name, location) + VALUES ('New York', ST_GeomFromText('POINT(-74.0060 40.7128)', 4326)) +`) + +// Query with spatial functions +const result = await pg.query(` + SELECT name, ST_AsText(location) as location + FROM cities +`) +``` + +## License + +Apache-2.0 + diff --git a/packages/pglite-postgis/eslint.config.js b/packages/pglite-postgis/eslint.config.js new file mode 100644 index 000000000..e001f1b83 --- /dev/null +++ b/packages/pglite-postgis/eslint.config.js @@ -0,0 +1,22 @@ +import globals from 'globals' +import rootConfig from '../../eslint.config.js' + +export default [ + ...rootConfig, + { + ignores: ['release/**/*', 'dist/**/*'], + }, + { + languageOptions: { + globals: { + ...globals.browser, + ...globals.node, + }, + }, + rules: { + ...rootConfig.rules, + '@typescript-eslint/no-explicit-any': 'off', + }, + }, +] + diff --git a/packages/pglite-postgis/package.json b/packages/pglite-postgis/package.json new file mode 100644 index 000000000..d5a768298 --- /dev/null +++ b/packages/pglite-postgis/package.json @@ -0,0 +1,66 @@ +{ + "name": "@electric-sql/pglite-postgis", + "version": "0.0.1", + "description": "PostGIS extension for PGlite", + "author": "Electric DB Limited", + "homepage": "https://pglite.dev", + "license": "Apache-2.0", + "keywords": [ + "postgres", + "sql", + "database", + "wasm", + "pglite", + "postgis", + "gis", + "geospatial" + ], + "private": false, + "publishConfig": { + "access": "public" + }, + "type": "module", + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + } + }, + "files": [ + "dist" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/electric-sql/pglite.git", + "directory": "packages/pglite-postgis" + }, + "scripts": { + "build": "tsup", + "check:exports": "attw . --pack --profile node16", + "lint": "eslint ./src ./tests --report-unused-disable-directives --max-warnings 0", + "format": "prettier --write ./src ./tests", + "typecheck": "tsc", + "stylecheck": "pnpm lint && prettier --check ./src ./tests", + "test": "vitest", + "prepublishOnly": "pnpm check:exports" + }, + "devDependencies": { + "@arethetypeswrong/cli": "^0.18.1", + "@electric-sql/pglite": "workspace:*", + "@types/node": "^20.16.11", + "vitest": "^2.1.2" + }, + "peerDependencies": { + "@electric-sql/pglite": "workspace:0.3.14" + } +} + diff --git a/packages/pglite-postgis/src/index.ts b/packages/pglite-postgis/src/index.ts new file mode 100644 index 000000000..7638fbd62 --- /dev/null +++ b/packages/pglite-postgis/src/index.ts @@ -0,0 +1,17 @@ +import type { + Extension, + ExtensionSetupResult, + PGliteInterface, +} from '@electric-sql/pglite' + +const setup = async (_pg: PGliteInterface, emscriptenOpts: any) => { + return { + emscriptenOpts, + bundlePath: new URL('../release/postgis.tar.gz', import.meta.url), + } satisfies ExtensionSetupResult +} + +export const postgis = { + name: 'postgis', + setup, +} satisfies Extension diff --git a/packages/pglite-postgis/tests/postgis.test.ts b/packages/pglite-postgis/tests/postgis.test.ts new file mode 100644 index 000000000..c38ba765e --- /dev/null +++ b/packages/pglite-postgis/tests/postgis.test.ts @@ -0,0 +1,284 @@ +import { describe, it, expect } from 'vitest' +import { PGlite } from '@electric-sql/pglite' +import { postgis } from '../src/index.js' + +describe(`postgis`, () => { + it('basic', async () => { + const pg = new PGlite({ + extensions: { + postgis, + }, + }) + + await pg.exec('CREATE EXTENSION IF NOT EXISTS postgis;') + await pg.exec(` + CREATE TABLE vehicle_location ( + time TIMESTAMPTZ NOT NULL, + vehicle_id INT NOT NULL, + location GEOGRAPHY(POINT, 4326) +); + `) + const inserted = await pg.query(`INSERT INTO vehicle_location VALUES + ('2023-05-29 20:00:00', 1, 'POINT(15.3672 -87.7231)'), + ('2023-05-30 20:00:00', 1, 'POINT(15.3652 -80.7331)'), + ('2023-05-31 20:00:00', 1, 'POINT(15.2672 -85.7431)');`) + + expect(inserted.affectedRows).toEqual(3) + }), + it('cities', async () => { + const pg = new PGlite({ + extensions: { + postgis, + }, + }) + + await pg.exec('CREATE EXTENSION IF NOT EXISTS postgis;') + await pg.exec(` + CREATE TABLE cities ( + id SERIAL PRIMARY KEY, + name VARCHAR(100), + location GEOMETRY(Point, 4326) +); + `) + const inserted = await pg.query(`INSERT INTO cities (name, location) +VALUES + ('New York', ST_GeomFromText('POINT(-74.0060 40.7128)', 4326)), + ('Los Angeles', ST_GeomFromText('POINT(-118.2437 34.0522)', 4326)), + ('Chicago', ST_GeomFromText('POINT(-87.6298 41.8781)', 4326));`) + + expect(inserted.affectedRows).toEqual(3) + + const cities = await pg.query(`WITH state_boundary AS ( + SELECT ST_GeomFromText( + 'POLYGON((-91 36, -91 43, -87 43, -87 36, -91 36))', 4326 + ) AS geom +) +SELECT c.name +FROM cities c, state_boundary s +WHERE ST_Within(c.location, s.geom);`) + + expect(cities.affectedRows).toBe(0) + expect(cities.rows[0]).toEqual({ + name: 'Chicago', + }) + }) +}) + +it('areas', async () => { + const pg = new PGlite({ + extensions: { + postgis, + }, + }) + await pg.exec('CREATE EXTENSION IF NOT EXISTS postgis;') + + const area1 = await pg.exec(` + select ST_Area(geom) sqft, + ST_Area(geom) * 0.3048 ^ 2 sqm + from ( + select 'SRID=2249;POLYGON((743238 2967416,743238 2967450, + 743265 2967450,743265.625 2967416,743238 2967416))' :: geometry geom + ) subquery;`) + + expect(area1).toEqual([ + { + rows: [ + { + sqft: 928.625, + sqm: 86.27208552, + }, + ], + fields: [ + { + name: 'sqft', + dataTypeID: 701, + }, + { + name: 'sqm', + dataTypeID: 701, + }, + ], + affectedRows: 0, + }, + ]) + + const area2 = await pg.exec(` + select ST_Area(geom) sqft, + ST_Area(ST_Transform(geom, 26986)) As sqm + from ( + select + 'SRID=2249;POLYGON((743238 2967416,743238 2967450, + 743265 2967450,743265.625 2967416,743238 2967416))' :: geometry geom + ) subquery; + + -- Cleanup test schema + -- DROP SCHEMA postgis_test CASCADE; + `) + + expect(area2).toEqual([ + { + rows: [ + { + sqft: 928.625, + sqm: 86.27243061926092, + }, + ], + fields: [ + { + name: 'sqft', + dataTypeID: 701, + }, + { + name: 'sqm', + dataTypeID: 701, + }, + ], + affectedRows: 0, + }, + ]) + + const area3 = await pg.exec(` + select ST_Area(geog) / 0.3048 ^ 2 sqft_spheroid, + ST_Area(geog, false) / 0.3048 ^ 2 sqft_sphere, + ST_Area(geog) sqm_spheroid + from ( + select ST_Transform( + 'SRID=2249;POLYGON((743238 2967416,743238 2967450,743265 2967450,743265.625 2967416,743238 2967416))'::geometry, + 4326 + ) :: geography geog + ) as subquery; + `) + + expect(area3).toEqual([ + { + rows: [ + { + sqft_spheroid: 928.6844047556697, + sqft_sphere: 926.609762750544, + sqm_spheroid: 86.27760440239217, + }, + ], + fields: [ + { + name: 'sqft_spheroid', + dataTypeID: 701, + }, + { + name: 'sqft_sphere', + dataTypeID: 701, + }, + { + name: 'sqm_spheroid', + dataTypeID: 701, + }, + ], + affectedRows: 0, + }, + ]) +}) + +it('ST_Polygonize', async () => { + const pg = new PGlite({ + extensions: { + postgis, + }, + }) + await pg.exec('CREATE EXTENSION IF NOT EXISTS postgis;') + const res = await pg.exec(` + WITH data(geom) AS (VALUES + ('LINESTRING (180 40, 30 20, 20 90)'::geometry) + ,('LINESTRING (180 40, 160 160)'::geometry) + ,('LINESTRING (80 60, 120 130, 150 80)'::geometry) + ,('LINESTRING (80 60, 150 80)'::geometry) + ,('LINESTRING (20 90, 70 70, 80 130)'::geometry) + ,('LINESTRING (80 130, 160 160)'::geometry) + ,('LINESTRING (20 90, 20 160, 70 190)'::geometry) + ,('LINESTRING (70 190, 80 130)'::geometry) + ,('LINESTRING (70 190, 160 160)'::geometry) + ) + SELECT ST_AsText( ST_Polygonize( geom )) + FROM data; + `) + + expect(res).toEqual([ + { + rows: [ + { + st_astext: + 'GEOMETRYCOLLECTION(POLYGON((180 40,30 20,20 90,70 70,80 130,160 160,180 40),(150 80,120 130,80 60,150 80)),POLYGON((80 60,120 130,150 80,80 60)),POLYGON((80 130,70 70,20 90,20 160,70 190,80 130)),POLYGON((160 160,80 130,70 190,160 160)))', + }, + ], + fields: [ + { + name: 'st_astext', + dataTypeID: 25, + }, + ], + affectedRows: 0, + }, + ]) +}) + +it('complex1', async () => { + const pg = new PGlite({ + extensions: { + postgis, + }, + }) + await pg.exec('CREATE EXTENSION IF NOT EXISTS postgis;') + + await pg.exec(` + -- Create test schema + -- CREATE SCHEMA IF NOT EXISTS postgis_test; + -- SET search_path TO postgis_test; + + -- Create a table with geometry columns + CREATE TABLE cities ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL, + population INTEGER, + geom GEOMETRY(Point, 4326) + );`) + + await pg.exec(` + CREATE TABLE rivers ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL, + geom GEOMETRY(LineString, 4326) + ); + + -- Insert sample data + INSERT INTO cities (name, population, geom) VALUES + ('Paris', 2148000, ST_SetSRID(ST_MakePoint(2.3522, 48.8566), 4326)), + ('Berlin', 3769000, ST_SetSRID(ST_MakePoint(13.4050, 52.5200), 4326)), + ('London', 8982000, ST_SetSRID(ST_MakePoint(-0.1276, 51.5072), 4326)), + ('Amsterdam', 872757, ST_SetSRID(ST_MakePoint(4.9041, 52.3676), 4326)); + + INSERT INTO rivers (name, geom) VALUES + ('Seine', ST_SetSRID(ST_MakeLine(ARRAY[ + ST_MakePoint(2.1, 48.8), + ST_MakePoint(2.35, 48.85), + ST_MakePoint(2.45, 48.9) + ]), 4326)), + ('Spree', ST_SetSRID(ST_MakeLine(ARRAY[ + ST_MakePoint(13.1, 52.4), + ST_MakePoint(13.35, 52.5), + ST_MakePoint(13.45, 52.52) + ]), 4326)); + + -- Create spatial index + CREATE INDEX idx_cities_geom ON cities USING GIST (geom); + CREATE INDEX idx_rivers_geom ON rivers USING GIST (geom); + + -- Query: Find cities within 10 km of any river + SELECT + c.name AS city, + r.name AS river, + ST_Distance(c.geom::geography, r.geom::geography) AS distance_km + FROM cities c + JOIN rivers r + ON ST_DWithin(c.geom::geography, r.geom::geography, 10000) + ORDER BY distance_km; + + `) +}) diff --git a/packages/pglite-postgis/tsconfig.json b/packages/pglite-postgis/tsconfig.json new file mode 100644 index 000000000..6aab239b1 --- /dev/null +++ b/packages/pglite-postgis/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["src", "tsup.config.ts", "vitest.config.ts"] +} + diff --git a/packages/pglite-postgis/tsup.config.ts b/packages/pglite-postgis/tsup.config.ts new file mode 100644 index 000000000..0020b9fe5 --- /dev/null +++ b/packages/pglite-postgis/tsup.config.ts @@ -0,0 +1,26 @@ +import { cpSync } from 'fs' +import { resolve } from 'path' +import { defineConfig } from 'tsup' + +const entryPoints = ['src/index.ts'] + +const minify = process.env.DEBUG === 'true' ? false : true + +export default defineConfig([ + { + entry: entryPoints, + sourcemap: true, + dts: { + entry: entryPoints, + resolve: true, + }, + clean: true, + minify: minify, + shims: true, + format: ['esm', 'cjs'], + onSuccess: async () => { + cpSync(resolve('release/postgis.tar.gz'), resolve('dist/postgis.tar.gz')) + }, + }, +]) + diff --git a/packages/pglite-postgis/vitest.config.ts b/packages/pglite-postgis/vitest.config.ts new file mode 100644 index 000000000..3144cb036 --- /dev/null +++ b/packages/pglite-postgis/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + testTimeout: 30000, + }, +}) + diff --git a/packages/pglite-socket/package.json b/packages/pglite-socket/package.json index c5c6cf21e..eb9da0b88 100644 --- a/packages/pglite-socket/package.json +++ b/packages/pglite-socket/package.json @@ -52,6 +52,7 @@ "@arethetypeswrong/cli": "^0.18.1", "@electric-sql/pg-protocol": "workspace:*", "@electric-sql/pglite": "workspace:*", + "@electric-sql/pglite-postgis": "workspace:*", "@types/emscripten": "^1.41.1", "@types/node": "^20.16.11", "pg": "^8.14.0", @@ -60,6 +61,7 @@ "vitest": "^1.3.1" }, "peerDependencies": { - "@electric-sql/pglite": "workspace:*" + "@electric-sql/pglite": "workspace:*", + "@electric-sql/pglite-postgis": "workspace:*" } } diff --git a/packages/pglite-socket/src/scripts/server.ts b/packages/pglite-socket/src/scripts/server.ts index 552295625..be4465f86 100644 --- a/packages/pglite-socket/src/scripts/server.ts +++ b/packages/pglite-socket/src/scripts/server.ts @@ -1,6 +1,7 @@ #!/usr/bin/env node import { PGlite, DebugLevel } from '@electric-sql/pglite' +import type { Extension, Extensions } from '@electric-sql/pglite' import { PGLiteSocketServer } from '../index' import { parseArgs } from 'node:util' import { spawn, ChildProcess } from 'node:child_process' @@ -38,6 +39,12 @@ const args = parseArgs({ default: '0', help: 'Debug level (0-5)', }, + extensions: { + type: 'string', + short: 'e', + default: undefined, + help: 'Comma-separated list of extensions to load (e.g., vector,pgcrypto,postgis)', + }, run: { type: 'string', short: 'r', @@ -72,6 +79,7 @@ Options: -h, --host=HOST Host to bind to (default: 127.0.0.1) -u, --path=UNIX Unix socket to bind to (default: undefined). Takes precedence over host:port -v, --debug=LEVEL Debug level 0-5 (default: 0) + -e, --extensions=LIST Comma-separated list of extensions to load (e.g., vector,pgcrypto,postgis) -r, --run=COMMAND Command to run after server starts --include-database-url Include DATABASE_URL in subprocess environment --shutdown-timeout=MS Timeout for graceful subprocess shutdown in ms (default: 5000) @@ -83,6 +91,7 @@ interface ServerConfig { host: string path?: string debugLevel: DebugLevel + extensionNames?: string[] runCommand?: string includeDatabaseUrl: boolean shutdownTimeout: number @@ -99,12 +108,16 @@ class PGLiteServerRunner { } static parseConfig(): ServerConfig { + const extensionsArg = args.values.extensions as string | undefined return { dbPath: args.values.db as string, port: parseInt(args.values.port as string, 10), host: args.values.host as string, path: args.values.path as string, debugLevel: parseInt(args.values.debug as string, 10) as DebugLevel, + extensionNames: extensionsArg + ? extensionsArg.split(',').map((e) => e.trim()) + : undefined, runCommand: args.values.run as string, includeDatabaseUrl: args.values['include-database-url'] as boolean, shutdownTimeout: parseInt(args.values['shutdown-timeout'] as string, 10), @@ -126,11 +139,66 @@ class PGLiteServerRunner { } } + private async importExtensions(): Promise { + if (!this.config.extensionNames?.length) { + return undefined + } + + const extensions: Extensions = {} + + // Built-in extensions that are not in contrib + const builtInExtensions = [ + 'vector', + 'live', + 'pg_hashids', + 'pg_ivm', + 'pg_uuidv7', + 'pgtap', + ] + + for (const name of this.config.extensionNames) { + let ext: Extension | null = null + + try { + if (builtInExtensions.includes(name)) { + // Built-in extension (e.g., @electric-sql/pglite/vector) + const mod = await import(`@electric-sql/pglite/${name}`) + ext = mod[name] as Extension + } else { + // Try contrib first (e.g., @electric-sql/pglite/contrib/pgcrypto) + try { + const mod = await import(`@electric-sql/pglite/contrib/${name}`) + ext = mod[name] as Extension + } catch { + // Fall back to external package (e.g., @electric-sql/pglite-postgis) + const mod = await import(`@electric-sql/pglite-${name}`) + ext = mod[name] as Extension + } + } + + if (ext) { + extensions[name] = ext + console.log(`Imported extension: ${name}`) + } + } catch (error) { + console.error(`Failed to import extension '${name}':`, error) + throw new Error(`Failed to import extension '${name}'`) + } + } + + return Object.keys(extensions).length > 0 ? extensions : undefined + } + private async initializeDatabase(): Promise { console.log(`Initializing PGLite with database: ${this.config.dbPath}`) console.log(`Debug level: ${this.config.debugLevel}`) - this.db = new PGlite(this.config.dbPath, { debug: this.config.debugLevel }) + const extensions = await this.importExtensions() + + this.db = new PGlite(this.config.dbPath, { + debug: this.config.debugLevel, + extensions, + }) await this.db.waitReady console.log('PGlite database initialized') } diff --git a/packages/pglite/src/pglite.ts b/packages/pglite/src/pglite.ts index 2407bb199..1a273264f 100644 --- a/packages/pglite/src/pglite.ts +++ b/packages/pglite/src/pglite.ts @@ -669,6 +669,10 @@ export class PGlite // the previous call might have increased the size of the buffer so reset it to its default this.#inputData = new Uint8Array(PGlite.DEFAULT_RECV_BUF_SIZE) } + this.#readOffset = 0 + this.#outputData = message + + this.#writeOffset = 0 // execute the message mod._interactive_one(message.length, message[0]) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1693c8901..162c10daa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,6 +44,9 @@ importers: '@electric-sql/pglite': specifier: workspace:* version: link:../packages/pglite + '@electric-sql/pglite-postgis': + specifier: workspace:* + version: link:../packages/pglite-postgis '@electric-sql/pglite-repl': specifier: workspace:* version: link:../packages/pglite-repl @@ -202,6 +205,21 @@ importers: specifier: ^2.1.2 version: 2.1.2(@types/node@20.16.11)(jsdom@24.1.3)(terser@5.34.1) + packages/pglite-postgis: + devDependencies: + '@arethetypeswrong/cli': + specifier: ^0.18.1 + version: 0.18.1 + '@electric-sql/pglite': + specifier: workspace:* + version: link:../pglite + '@types/node': + specifier: ^20.16.11 + version: 20.16.11 + vitest: + specifier: ^2.1.2 + version: 2.1.2(@types/node@20.16.11)(jsdom@24.1.3)(terser@5.34.1) + packages/pglite-react: devDependencies: '@arethetypeswrong/cli': @@ -340,6 +358,9 @@ importers: '@electric-sql/pglite': specifier: workspace:* version: link:../pglite + '@electric-sql/pglite-postgis': + specifier: workspace:* + version: link:../pglite-postgis '@types/emscripten': specifier: ^1.41.1 version: 1.41.1 @@ -2077,7 +2098,6 @@ packages: bun@1.1.30: resolution: {integrity: sha512-ysRL1pq10Xba0jqVLPrKU3YIv0ohfp3cTajCPtpjCyppbn3lfiAVNpGoHfyaxS17OlPmWmR67UZRPw/EueQuug==} - cpu: [arm64, x64] os: [darwin, linux, win32] hasBin: true diff --git a/postgres-pglite b/postgres-pglite index bee4a36b7..fb2c2f16c 160000 --- a/postgres-pglite +++ b/postgres-pglite @@ -1 +1 @@ -Subproject commit bee4a36b76d2607f5c1d2ca61fd013958b17d0e9 +Subproject commit fb2c2f16c6a44c9d1c380e24f14d24ff76bac9d5