From f34fb580fe2e8c87d6e78aba41982f11eebe59d1 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Sat, 23 May 2026 08:11:48 +0000 Subject: [PATCH 1/4] refactor: deduplicate remaining seed fixtures across server-test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract shared base/setup.sql (roles, uuid-ossp, stamps) from the repeated setup.sql files in simple-seed/, schema-snapshot/, and search-seed/. Changes: - Add __fixtures__/seed/base/setup.sql — minimal base layer - Delete simple-seed/ entirely — tests now compose from base/setup.sql + root app-schemas/simple-pets/ (schema + test-data) - Delete schema-snapshot/setup.sql — tests now use base/setup.sql - Replace search-seed/setup.sql with search-seed/extensions.sql containing only pg_trgm + optional pgvector (unique content) Updated test files: - server.integration.test.ts — simple-seed scenarios use shared base - cli-e2e.test.ts — both simple-seed and search-seed use shared base - search.integration.test.ts — uses shared base + local extensions - schema-snapshot.test.ts — uses shared base Remaining local fixtures (unique content, not shareable): - search-seed/ — extensions.sql, schema.sql, test-data.sql - schema-snapshot/ — schema.sql, test-data.sql - simple-seed-storage/ — storage module setup, schema, test-data --- __fixtures__/seed/README.md | 59 +++++++++++++------ __fixtures__/seed/base/setup.sql | 53 +++++++++++++++++ .../seed/schema-snapshot/setup.sql | 35 ----------- .../seed/search-seed/extensions.sql | 14 +++++ .../__fixtures__/seed/search-seed/setup.sql | 45 -------------- .../__fixtures__/seed/simple-seed/schema.sql | 54 ----------------- .../__fixtures__/seed/simple-seed/setup.sql | 35 ----------- .../seed/simple-seed/test-data.sql | 11 ---- graphql/server-test/__tests__/cli-e2e.test.ts | 19 +++--- .../__tests__/schema-snapshot.test.ts | 9 ++- .../__tests__/search.integration.test.ts | 10 +++- .../__tests__/server.integration.test.ts | 10 ++-- 12 files changed, 137 insertions(+), 217 deletions(-) create mode 100644 __fixtures__/seed/base/setup.sql delete mode 100644 graphql/server-test/__fixtures__/seed/schema-snapshot/setup.sql create mode 100644 graphql/server-test/__fixtures__/seed/search-seed/extensions.sql delete mode 100644 graphql/server-test/__fixtures__/seed/search-seed/setup.sql delete mode 100644 graphql/server-test/__fixtures__/seed/simple-seed/schema.sql delete mode 100644 graphql/server-test/__fixtures__/seed/simple-seed/setup.sql delete mode 100644 graphql/server-test/__fixtures__/seed/simple-seed/test-data.sql diff --git a/__fixtures__/seed/README.md b/__fixtures__/seed/README.md index 3f1a359a2b..7ffb94c523 100644 --- a/__fixtures__/seed/README.md +++ b/__fixtures__/seed/README.md @@ -6,46 +6,69 @@ Composable SQL seed layers for integration testing. Each layer builds on the pre | Layer | Files | What it provides | |-------|-------|-----------------| -| **services** | `services/setup.sql` | Roles, extensions, stamps, metaschema tables, services tables, settings tables, modules tables, grants | +| **base** | `base/setup.sql` | Roles (`administrator`, `authenticated`, `anonymous`), `uuid-ossp` extension, `stamps` schema + `timestamps()` trigger | +| **services** | `services/setup.sql` | Everything in base + `citext`, metaschema tables, services tables, settings tables, modules tables, grants (self-contained) | | **services data** | `services/test-data.sql` | Example database (`simple-pets`), 3 schemas, 5 APIs, 2 domains, API→schema linkage, animals metaschema entries | | **app-schemas** | `app-schemas/simple-pets/schema.sql` | `simple-pets-*` schemas, animals table with constraints/indexes/triggers | | **app data** | `app-schemas/simple-pets/test-data.sql` | 5 test animals (Buddy, Max, Whiskers, Mittens, Tweety) | ## Usage with pgsql-test -```typescript -import { getConnections } from 'pgsql-test'; -import path from 'path'; +### Non-services tests (no metaschema) +```typescript const SEED = path.resolve(__dirname, '../../../__fixtures__/seed'); -const { db, teardown } = await getConnections({ - seed: seed.sqlfile([ - `${SEED}/services/setup.sql`, - `${SEED}/services/test-data.sql`, - `${SEED}/app-schemas/simple-pets/schema.sql`, - `${SEED}/app-schemas/simple-pets/test-data.sql`, - ]) -}); +seed.sqlfile([ + `${SEED}/base/setup.sql`, // roles, extensions, stamps + // ... your app-specific schema + data +]) +``` + +### Services-enabled tests (metaschema + domain resolution) + +```typescript +seed.sqlfile([ + `${SEED}/services/setup.sql`, // metaschema + services DDL + `${SEED}/app-schemas/simple-pets/schema.sql`, // app tables + `${SEED}/services/test-data.sql`, // API + domain rows + `${SEED}/app-schemas/simple-pets/test-data.sql`, // test animals +]) ``` ## Composition Pick only the layers you need: +- **Base only** (roles + stamps, no metaschema): `base/setup.sql` + your own schema/data - **Metaschema + services only** (no app tables): `services/setup.sql` + `services/test-data.sql` -- **Full stack with app data**: all four files in order +- **Full stack with app data**: `services/setup.sql` + `app-schemas/*` + `services/test-data.sql` + `app-schemas/*/test-data.sql` - **Custom app schema**: `services/setup.sql` + `services/test-data.sql` + your own schema/data SQL +> **Note:** `services/setup.sql` is self-contained — it includes everything from `base/setup.sql` plus metaschema and services DDL. You do NOT need to load both `base/setup.sql` and `services/setup.sql`. + ## Consumers -These test files use the shared fixtures (no local duplicates): +These test files use the shared fixtures: -| Test file | Seed files used | -|-----------|----------------| -| `graphql/server-test/__tests__/server.integration.test.ts` | `services/*` + `app-schemas/simple-pets/*` (services scenarios) | -| `graphql/server-test/__tests__/express-context.integration.test.ts` | `services/*` + `app-schemas/simple-pets/*` | +| Test file | Shared fixtures used | +|-----------|---------------------| +| `graphql/server-test/__tests__/server.integration.test.ts` | `base/*` (simple-seed), `services/*` + `app-schemas/*` (services scenarios) | +| `graphql/server-test/__tests__/express-context.integration.test.ts` | `services/*` + `app-schemas/*` | | `graphql/server-test/__tests__/upload.integration.test.ts` | `services/setup.sql` (DDL only, storage data is local) | +| `graphql/server-test/__tests__/cli-e2e.test.ts` | `base/*` + `app-schemas/*` (animals), `base/*` (search) | +| `graphql/server-test/__tests__/search.integration.test.ts` | `base/*` | +| `graphql/server-test/__tests__/schema-snapshot.test.ts` | `base/*` | + +## Local Fixtures (not shared) + +Some test scenarios have unique content that stays in `graphql/server-test/__fixtures__/seed/`: + +| Directory | What's unique | +|-----------|--------------| +| `search-seed/` | `extensions.sql` (pg_trgm + pgvector), `schema.sql` (articles table with tsvector/trigram/vector columns), `test-data.sql` | +| `schema-snapshot/` | `schema.sql` (5-table blog schema: users, posts, tags, post_tags, comments), `test-data.sql` | +| `simple-seed-storage/` | `setup.sql` (storage module, JWT functions), `schema.sql` (3-tenant storage schemas), `test-data.sql` | ## Well-Known IDs diff --git a/__fixtures__/seed/base/setup.sql b/__fixtures__/seed/base/setup.sql new file mode 100644 index 0000000000..95c6691d81 --- /dev/null +++ b/__fixtures__/seed/base/setup.sql @@ -0,0 +1,53 @@ +-- Shared fixture: minimal base setup +-- +-- Creates extensions, roles, and the stamps schema needed by all test +-- scenarios. This is the smallest common denominator — tests that also +-- need metaschema / services should load services/setup.sql instead +-- (which is self-contained and includes everything here). +-- +-- Usage: +-- seed.sqlfile([ +-- shared('base', 'setup.sql'), +-- // ... then your app-specific schema + data +-- ]) + +-- ===================================================== +-- EXTENSIONS +-- ===================================================== + +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +-- ===================================================== +-- ROLES +-- ===================================================== + +DO $$ +BEGIN + IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'administrator') THEN + CREATE ROLE administrator; + END IF; + IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'authenticated') THEN + CREATE ROLE authenticated; + END IF; + IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'anonymous') THEN + CREATE ROLE anonymous; + END IF; +END +$$; + +-- ===================================================== +-- STAMPS (timestamp triggers) +-- ===================================================== + +CREATE SCHEMA IF NOT EXISTS stamps; + +CREATE OR REPLACE FUNCTION stamps.timestamps() +RETURNS TRIGGER AS $$ +BEGIN + IF TG_OP = 'INSERT' THEN + NEW.created_at = COALESCE(NEW.created_at, now()); + END IF; + NEW.updated_at = now(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; diff --git a/graphql/server-test/__fixtures__/seed/schema-snapshot/setup.sql b/graphql/server-test/__fixtures__/seed/schema-snapshot/setup.sql deleted file mode 100644 index 8fe1d5b34a..0000000000 --- a/graphql/server-test/__fixtures__/seed/schema-snapshot/setup.sql +++ /dev/null @@ -1,35 +0,0 @@ --- Setup for schema-snapshot test scenario --- Creates the required extensions and roles - --- Ensure uuid-ossp extension is available -CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; - --- Create required roles if they don't exist -DO $$ -BEGIN - IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'administrator') THEN - CREATE ROLE administrator; - END IF; - IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'authenticated') THEN - CREATE ROLE authenticated; - END IF; - IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'anonymous') THEN - CREATE ROLE anonymous; - END IF; -END -$$; - --- Create stamps schema for timestamp trigger if not exists -CREATE SCHEMA IF NOT EXISTS stamps; - --- Create timestamps trigger function -CREATE OR REPLACE FUNCTION stamps.timestamps() -RETURNS TRIGGER AS $$ -BEGIN - IF TG_OP = 'INSERT' THEN - NEW.created_at = COALESCE(NEW.created_at, now()); - END IF; - NEW.updated_at = now(); - RETURN NEW; -END; -$$ LANGUAGE plpgsql; diff --git a/graphql/server-test/__fixtures__/seed/search-seed/extensions.sql b/graphql/server-test/__fixtures__/seed/search-seed/extensions.sql new file mode 100644 index 0000000000..605127dba4 --- /dev/null +++ b/graphql/server-test/__fixtures__/seed/search-seed/extensions.sql @@ -0,0 +1,14 @@ +-- Search-specific extensions (loaded after base/setup.sql) +-- +-- Adds pg_trgm for fuzzy matching and optionally pgvector for similarity search. + +CREATE EXTENSION IF NOT EXISTS "pg_trgm"; + +-- pgvector may not be available in all environments +DO $$ +BEGIN + CREATE EXTENSION IF NOT EXISTS "vector"; +EXCEPTION WHEN OTHERS THEN + RAISE NOTICE 'pgvector extension not available, skipping'; +END +$$; diff --git a/graphql/server-test/__fixtures__/seed/search-seed/setup.sql b/graphql/server-test/__fixtures__/seed/search-seed/setup.sql deleted file mode 100644 index 169f5ca8f8..0000000000 --- a/graphql/server-test/__fixtures__/seed/search-seed/setup.sql +++ /dev/null @@ -1,45 +0,0 @@ --- Setup for search-seed test scenario --- Creates the required extensions and roles for search testing - --- Core extensions -CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; -CREATE EXTENSION IF NOT EXISTS "pg_trgm"; - --- Try to create vector extension (may not be available in all environments) -DO $$ -BEGIN - CREATE EXTENSION IF NOT EXISTS "vector"; -EXCEPTION WHEN OTHERS THEN - RAISE NOTICE 'pgvector extension not available, skipping'; -END -$$; - --- Create required roles if they don't exist -DO $$ -BEGIN - IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'administrator') THEN - CREATE ROLE administrator; - END IF; - IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'authenticated') THEN - CREATE ROLE authenticated; - END IF; - IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'anonymous') THEN - CREATE ROLE anonymous; - END IF; -END -$$; - --- Create stamps schema for timestamp trigger if not exists -CREATE SCHEMA IF NOT EXISTS stamps; - --- Create timestamps trigger function -CREATE OR REPLACE FUNCTION stamps.timestamps() -RETURNS TRIGGER AS $$ -BEGIN - IF TG_OP = 'INSERT' THEN - NEW.created_at = COALESCE(NEW.created_at, now()); - END IF; - NEW.updated_at = now(); - RETURN NEW; -END; -$$ LANGUAGE plpgsql; diff --git a/graphql/server-test/__fixtures__/seed/simple-seed/schema.sql b/graphql/server-test/__fixtures__/seed/simple-seed/schema.sql deleted file mode 100644 index c9fe18d088..0000000000 --- a/graphql/server-test/__fixtures__/seed/simple-seed/schema.sql +++ /dev/null @@ -1,54 +0,0 @@ --- Schema creation for simple-seed test scenario --- Creates the simple-pets schemas and animals table - --- Create schemas -CREATE SCHEMA IF NOT EXISTS "simple-pets-public"; -CREATE SCHEMA IF NOT EXISTS "simple-pets-pets-public"; - --- Grant schema usage -GRANT USAGE ON SCHEMA "simple-pets-public" TO administrator, authenticated, anonymous; -GRANT USAGE ON SCHEMA "simple-pets-pets-public" TO administrator, authenticated, anonymous; - --- Set default privileges -ALTER DEFAULT PRIVILEGES IN SCHEMA "simple-pets-public" - GRANT ALL ON TABLES TO administrator; -ALTER DEFAULT PRIVILEGES IN SCHEMA "simple-pets-public" - GRANT USAGE ON SEQUENCES TO administrator, authenticated; -ALTER DEFAULT PRIVILEGES IN SCHEMA "simple-pets-public" - GRANT ALL ON FUNCTIONS TO administrator, authenticated, anonymous; - -ALTER DEFAULT PRIVILEGES IN SCHEMA "simple-pets-pets-public" - GRANT ALL ON TABLES TO administrator; -ALTER DEFAULT PRIVILEGES IN SCHEMA "simple-pets-pets-public" - GRANT USAGE ON SEQUENCES TO administrator, authenticated; -ALTER DEFAULT PRIVILEGES IN SCHEMA "simple-pets-pets-public" - GRANT ALL ON FUNCTIONS TO administrator, authenticated, anonymous; - --- Create animals table -CREATE TABLE IF NOT EXISTS "simple-pets-pets-public".animals ( - id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), - name text NOT NULL, - species text NOT NULL, - owner_id uuid, - created_at timestamptz DEFAULT now(), - updated_at timestamptz DEFAULT now(), - CONSTRAINT animals_name_chk CHECK (character_length(name) <= 256), - CONSTRAINT animals_species_chk CHECK (character_length(species) <= 100) -); - --- Create timestamp trigger -DROP TRIGGER IF EXISTS timestamps_tg ON "simple-pets-pets-public".animals; -CREATE TRIGGER timestamps_tg - BEFORE INSERT OR UPDATE - ON "simple-pets-pets-public".animals - FOR EACH ROW - EXECUTE PROCEDURE stamps.timestamps(); - --- Create indexes -CREATE INDEX IF NOT EXISTS animals_created_at_idx ON "simple-pets-pets-public".animals (created_at); -CREATE INDEX IF NOT EXISTS animals_updated_at_idx ON "simple-pets-pets-public".animals (updated_at); - --- Grant table permissions (allow anonymous to do CRUD for tests) -GRANT SELECT, INSERT, UPDATE, DELETE ON "simple-pets-pets-public".animals TO administrator; -GRANT SELECT, INSERT, UPDATE, DELETE ON "simple-pets-pets-public".animals TO authenticated; -GRANT SELECT, INSERT, UPDATE, DELETE ON "simple-pets-pets-public".animals TO anonymous; diff --git a/graphql/server-test/__fixtures__/seed/simple-seed/setup.sql b/graphql/server-test/__fixtures__/seed/simple-seed/setup.sql deleted file mode 100644 index 1b44bc1e5b..0000000000 --- a/graphql/server-test/__fixtures__/seed/simple-seed/setup.sql +++ /dev/null @@ -1,35 +0,0 @@ --- Setup for simple-seed test scenario --- Creates the required schemas and extensions - --- Ensure uuid-ossp extension is available -CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; - --- Create required roles if they don't exist -DO $$ -BEGIN - IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'administrator') THEN - CREATE ROLE administrator; - END IF; - IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'authenticated') THEN - CREATE ROLE authenticated; - END IF; - IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'anonymous') THEN - CREATE ROLE anonymous; - END IF; -END -$$; - --- Create stamps schema for timestamp trigger if not exists -CREATE SCHEMA IF NOT EXISTS stamps; - --- Create timestamps trigger function -CREATE OR REPLACE FUNCTION stamps.timestamps() -RETURNS TRIGGER AS $$ -BEGIN - IF TG_OP = 'INSERT' THEN - NEW.created_at = COALESCE(NEW.created_at, now()); - END IF; - NEW.updated_at = now(); - RETURN NEW; -END; -$$ LANGUAGE plpgsql; diff --git a/graphql/server-test/__fixtures__/seed/simple-seed/test-data.sql b/graphql/server-test/__fixtures__/seed/simple-seed/test-data.sql deleted file mode 100644 index 9d8c296f3f..0000000000 --- a/graphql/server-test/__fixtures__/seed/simple-seed/test-data.sql +++ /dev/null @@ -1,11 +0,0 @@ --- Test data for simple-seed scenario --- Inserts 5 animals: 2 Dogs, 2 Cats, 1 Bird - -INSERT INTO "simple-pets-pets-public".animals (id, name, species, owner_id, created_at, updated_at) -VALUES - ('a0000001-0000-0000-0000-000000000001', 'Buddy', 'Dog', NULL, now(), now()), - ('a0000001-0000-0000-0000-000000000002', 'Max', 'Dog', NULL, now(), now()), - ('a0000001-0000-0000-0000-000000000003', 'Whiskers', 'Cat', NULL, now(), now()), - ('a0000001-0000-0000-0000-000000000004', 'Mittens', 'Cat', NULL, now(), now()), - ('a0000001-0000-0000-0000-000000000005', 'Tweety', 'Bird', NULL, now(), now()) -ON CONFLICT (id) DO NOTHING; diff --git a/graphql/server-test/__tests__/cli-e2e.test.ts b/graphql/server-test/__tests__/cli-e2e.test.ts index 9a26611ad0..96de755f80 100644 --- a/graphql/server-test/__tests__/cli-e2e.test.ts +++ b/graphql/server-test/__tests__/cli-e2e.test.ts @@ -79,9 +79,12 @@ function resolveNodePaths(): string[] { return [...dirs]; } -const seedRoot = path.join(__dirname, '..', '__fixtures__', 'seed'); +const localSeedRoot = path.join(__dirname, '..', '__fixtures__', 'seed'); +const sharedSeedRoot = path.join(__dirname, '..', '..', '..', '__fixtures__', 'seed'); const sql = (seedDir: string, file: string) => - path.join(seedRoot, seedDir, file); + path.join(localSeedRoot, seedDir, file); +const shared = (...segments: string[]) => + path.join(sharedSeedRoot, ...segments); const TOOL_NAME = 'cli-e2e-test'; @@ -395,9 +398,9 @@ describe('CLI E2E — generated CLI against real DB', () => { }, [ seed.sqlfile([ - sql('simple-seed', 'setup.sql'), - sql('simple-seed', 'schema.sql'), - sql('simple-seed', 'test-data.sql'), + shared('base', 'setup.sql'), + shared('app-schemas', 'simple-pets', 'schema.sql'), + shared('app-schemas', 'simple-pets', 'test-data.sql'), ]), ], ); @@ -783,7 +786,8 @@ describe('CLI E2E — search commands against real DB', () => { }, [ seed.sqlfile([ - sql('search-seed', 'setup.sql'), + shared('base', 'setup.sql'), + sql('search-seed', 'extensions.sql'), sql('search-seed', 'schema.sql'), sql('search-seed', 'test-data.sql'), ]), @@ -1126,7 +1130,8 @@ describe('CLI E2E — embedder / --auto-embed', () => { }, [ seed.sqlfile([ - sql('search-seed', 'setup.sql'), + shared('base', 'setup.sql'), + sql('search-seed', 'extensions.sql'), sql('search-seed', 'schema.sql'), sql('search-seed', 'test-data.sql'), ]), diff --git a/graphql/server-test/__tests__/schema-snapshot.test.ts b/graphql/server-test/__tests__/schema-snapshot.test.ts index a2b9b2c22b..3caac09d18 100644 --- a/graphql/server-test/__tests__/schema-snapshot.test.ts +++ b/graphql/server-test/__tests__/schema-snapshot.test.ts @@ -28,9 +28,12 @@ import { getConnections, seed } from '../src'; jest.setTimeout(60000); -const seedRoot = path.join(__dirname, '..', '__fixtures__', 'seed'); +const localSeedRoot = path.join(__dirname, '..', '__fixtures__', 'seed'); +const sharedSeedRoot = path.join(__dirname, '..', '..', '..', '__fixtures__', 'seed'); const sql = (seedDir: string, file: string) => - path.join(seedRoot, seedDir, file); + path.join(localSeedRoot, seedDir, file); +const shared = (...segments: string[]) => + path.join(sharedSeedRoot, ...segments); const schemas = ['snapshot_public']; @@ -49,7 +52,7 @@ describe('Schema Snapshot', () => { }, [ seed.sqlfile([ - sql('schema-snapshot', 'setup.sql'), + shared('base', 'setup.sql'), sql('schema-snapshot', 'schema.sql'), sql('schema-snapshot', 'test-data.sql'), ]), diff --git a/graphql/server-test/__tests__/search.integration.test.ts b/graphql/server-test/__tests__/search.integration.test.ts index f18bbf96eb..57efe12390 100644 --- a/graphql/server-test/__tests__/search.integration.test.ts +++ b/graphql/server-test/__tests__/search.integration.test.ts @@ -20,9 +20,12 @@ import type supertest from 'supertest'; jest.setTimeout(60000); -const seedRoot = path.join(__dirname, '..', '__fixtures__', 'seed'); +const localSeedRoot = path.join(__dirname, '..', '__fixtures__', 'seed'); +const sharedSeedRoot = path.join(__dirname, '..', '..', '..', '__fixtures__', 'seed'); const sql = (seedDir: string, file: string) => - path.join(seedRoot, seedDir, file); + path.join(localSeedRoot, seedDir, file); +const shared = (...segments: string[]) => + path.join(sharedSeedRoot, ...segments); const schemas = ['search_public']; @@ -48,7 +51,8 @@ describe('Unified Search — server integration', () => { }, [ seed.sqlfile([ - sql('search-seed', 'setup.sql'), + shared('base', 'setup.sql'), + sql('search-seed', 'extensions.sql'), sql('search-seed', 'schema.sql'), sql('search-seed', 'test-data.sql'), ]), diff --git a/graphql/server-test/__tests__/server.integration.test.ts b/graphql/server-test/__tests__/server.integration.test.ts index f8fef844b2..875a441327 100644 --- a/graphql/server-test/__tests__/server.integration.test.ts +++ b/graphql/server-test/__tests__/server.integration.test.ts @@ -12,10 +12,7 @@ import type supertest from 'supertest'; jest.setTimeout(30000); -const localSeedRoot = path.join(__dirname, '..', '__fixtures__', 'seed'); const sharedSeedRoot = path.join(__dirname, '..', '..', '..', '__fixtures__', 'seed'); -const sql = (seedDir: string, file: string) => - path.join(localSeedRoot, seedDir, file); const shared = (...segments: string[]) => path.join(sharedSeedRoot, ...segments); const schemas = ['simple-pets-public', 'simple-pets-pets-public']; @@ -110,10 +107,11 @@ const seedFilesFor = (seedDir: Scenario['seedDir']) => { shared('app-schemas', 'simple-pets', 'test-data.sql'), ]; } + // simple-seed: base setup + shared app-schemas return [ - sql(seedDir, 'setup.sql'), - sql(seedDir, 'schema.sql'), - sql(seedDir, 'test-data.sql'), + shared('base', 'setup.sql'), + shared('app-schemas', 'simple-pets', 'schema.sql'), + shared('app-schemas', 'simple-pets', 'test-data.sql'), ]; }; From fc3d8784e02a64435075de56ccd0444a6a75941a Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Sat, 23 May 2026 19:25:50 +0000 Subject: [PATCH 2/4] refactor: move role creation upstream to pgsql-test createBaseRoles() Remove all inline CREATE ROLE blocks from seed SQL fixtures. Roles (administrator, authenticated, anonymous) are now created by pgsql-test's getConnections() via DbAdmin.createBaseRoles(), which delegates to generateCreateBaseRolesSQL() from @pgpmjs/core. This eliminates the DO $$ ... IF NOT EXISTS ... CREATE ROLE pattern that can cause lock issues, and aligns with the pgpm role management workflow. --- __fixtures__/seed/README.md | 8 +++++--- __fixtures__/seed/base/setup.sql | 27 ++++++--------------------- __fixtures__/seed/services/setup.sql | 22 ++-------------------- postgres/pgsql-client/src/admin.ts | 12 ++++++++++++ postgres/pgsql-test/src/connect.ts | 1 + 5 files changed, 26 insertions(+), 44 deletions(-) diff --git a/__fixtures__/seed/README.md b/__fixtures__/seed/README.md index 7ffb94c523..02fbc5b87b 100644 --- a/__fixtures__/seed/README.md +++ b/__fixtures__/seed/README.md @@ -6,12 +6,14 @@ Composable SQL seed layers for integration testing. Each layer builds on the pre | Layer | Files | What it provides | |-------|-------|-----------------| -| **base** | `base/setup.sql` | Roles (`administrator`, `authenticated`, `anonymous`), `uuid-ossp` extension, `stamps` schema + `timestamps()` trigger | +| **base** | `base/setup.sql` | `uuid-ossp` extension, `stamps` schema + `timestamps()` trigger | | **services** | `services/setup.sql` | Everything in base + `citext`, metaschema tables, services tables, settings tables, modules tables, grants (self-contained) | | **services data** | `services/test-data.sql` | Example database (`simple-pets`), 3 schemas, 5 APIs, 2 domains, API→schema linkage, animals metaschema entries | | **app-schemas** | `app-schemas/simple-pets/schema.sql` | `simple-pets-*` schemas, animals table with constraints/indexes/triggers | | **app data** | `app-schemas/simple-pets/test-data.sql` | 5 test animals (Buddy, Max, Whiskers, Mittens, Tweety) | +> **Note:** Roles (`administrator`, `authenticated`, `anonymous`) are created upstream by `pgsql-test`'s `createBaseRoles()` — seed SQL should never create roles. + ## Usage with pgsql-test ### Non-services tests (no metaschema) @@ -20,7 +22,7 @@ Composable SQL seed layers for integration testing. Each layer builds on the pre const SEED = path.resolve(__dirname, '../../../__fixtures__/seed'); seed.sqlfile([ - `${SEED}/base/setup.sql`, // roles, extensions, stamps + `${SEED}/base/setup.sql`, // extensions, stamps // ... your app-specific schema + data ]) ``` @@ -40,7 +42,7 @@ seed.sqlfile([ Pick only the layers you need: -- **Base only** (roles + stamps, no metaschema): `base/setup.sql` + your own schema/data +- **Base only** (extensions + stamps, no metaschema): `base/setup.sql` + your own schema/data - **Metaschema + services only** (no app tables): `services/setup.sql` + `services/test-data.sql` - **Full stack with app data**: `services/setup.sql` + `app-schemas/*` + `services/test-data.sql` + `app-schemas/*/test-data.sql` - **Custom app schema**: `services/setup.sql` + `services/test-data.sql` + your own schema/data SQL diff --git a/__fixtures__/seed/base/setup.sql b/__fixtures__/seed/base/setup.sql index 95c6691d81..1ef1848579 100644 --- a/__fixtures__/seed/base/setup.sql +++ b/__fixtures__/seed/base/setup.sql @@ -1,8 +1,11 @@ -- Shared fixture: minimal base setup -- --- Creates extensions, roles, and the stamps schema needed by all test --- scenarios. This is the smallest common denominator — tests that also --- need metaschema / services should load services/setup.sql instead +-- Creates extensions and the stamps schema needed by all test scenarios. +-- Roles (administrator, authenticated, anonymous) are created upstream by +-- pgsql-test's createUserRole() — do NOT recreate them here. +-- +-- This is the smallest common denominator — tests that also need +-- metaschema / services should load services/setup.sql instead -- (which is self-contained and includes everything here). -- -- Usage: @@ -17,24 +20,6 @@ CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; --- ===================================================== --- ROLES --- ===================================================== - -DO $$ -BEGIN - IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'administrator') THEN - CREATE ROLE administrator; - END IF; - IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'authenticated') THEN - CREATE ROLE authenticated; - END IF; - IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'anonymous') THEN - CREATE ROLE anonymous; - END IF; -END -$$; - -- ===================================================== -- STAMPS (timestamp triggers) -- ===================================================== diff --git a/__fixtures__/seed/services/setup.sql b/__fixtures__/seed/services/setup.sql index 5bd3727104..5ef63eebf1 100644 --- a/__fixtures__/seed/services/setup.sql +++ b/__fixtures__/seed/services/setup.sql @@ -2,6 +2,8 @@ -- -- Creates the minimum tables needed to emulate a production Constructive -- database with API resolution, domain routing, and RLS module support. +-- Roles (administrator, authenticated, anonymous) are created upstream by +-- pgsql-test's createUserRole() — do NOT recreate them here. -- -- Usage (via pgsql-test seed adapter): -- seed.sqlfile([ @@ -17,26 +19,6 @@ CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; CREATE EXTENSION IF NOT EXISTS "citext"; -DO $$ -BEGIN - IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'administrator') THEN - CREATE ROLE administrator; - END IF; - IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'authenticated') THEN - CREATE ROLE authenticated; - END IF; - IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'anonymous') THEN - CREATE ROLE anonymous; - END IF; - IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'app_user') THEN - CREATE ROLE app_user; - END IF; - IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'app_admin') THEN - CREATE ROLE app_admin; - END IF; -END -$$; - CREATE SCHEMA IF NOT EXISTS stamps; CREATE OR REPLACE FUNCTION stamps.timestamps() diff --git a/postgres/pgsql-client/src/admin.ts b/postgres/pgsql-client/src/admin.ts index 419afebb29..c994c10467 100644 --- a/postgres/pgsql-client/src/admin.ts +++ b/postgres/pgsql-client/src/admin.ts @@ -1,4 +1,5 @@ import { + generateCreateBaseRolesSQL, generateCreateUserWithGrantsSQL, generateGrantRoleSQL } from '@pgpmjs/core'; @@ -125,6 +126,17 @@ export class DbAdmin { this.safeDropDb(template); } + async createBaseRoles(dbName?: string): Promise { + const db = dbName ?? this.config.database; + const roles = { + anonymous: getRoleName('anonymous', this.roleConfig), + authenticated: getRoleName('authenticated', this.roleConfig), + administrator: getRoleName('administrator', this.roleConfig) + }; + const sql = generateCreateBaseRolesSQL(roles); + await this.streamSql(sql, db); + } + async grantRole(role: string, user: string, dbName?: string): Promise { const db = dbName ?? this.config.database; const sql = generateGrantRoleSQL(role, user); diff --git a/postgres/pgsql-test/src/connect.ts b/postgres/pgsql-test/src/connect.ts index 06aa359093..8829d5bb71 100644 --- a/postgres/pgsql-test/src/connect.ts +++ b/postgres/pgsql-test/src/connect.ts @@ -72,6 +72,7 @@ export const getConnections = async ( const connOpts: PgTestConnectionOptions = cn.db; const root = getPgRootAdmin(config, connOpts); + await root.createBaseRoles(connOpts.rootDb!); await root.createUserRole( connOpts.connections!.app!.user!, connOpts.connections!.app!.password!, From 87b452ba9468b333552718bd2e26eca1f63a56f2 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Sat, 23 May 2026 19:30:02 +0000 Subject: [PATCH 3/4] fix: retry createBaseRoles on 'tuple concurrently updated' When parallel CI test suites call createBaseRoles simultaneously, the ALTER ROLE in generateCreateBaseRolesSQL can race and fail with 'tuple concurrently updated'. Add retry loop (3 attempts) to handle this transient concurrency error. --- postgres/pgsql-client/src/admin.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/postgres/pgsql-client/src/admin.ts b/postgres/pgsql-client/src/admin.ts index c994c10467..7670304a16 100644 --- a/postgres/pgsql-client/src/admin.ts +++ b/postgres/pgsql-client/src/admin.ts @@ -134,7 +134,20 @@ export class DbAdmin { administrator: getRoleName('administrator', this.roleConfig) }; const sql = generateCreateBaseRolesSQL(roles); - await this.streamSql(sql, db); + const maxRetries = 3; + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + await this.streamSql(sql, db); + return; + } catch (err: any) { + const msg = err?.message ?? ''; + if (msg.includes('tuple concurrently updated') && attempt < maxRetries) { + log.warn(`createBaseRoles: concurrent update (attempt ${attempt}/${maxRetries}), retrying...`); + continue; + } + throw err; + } + } } async grantRole(role: string, user: string, dbName?: string): Promise { From b524766508bf23cb048c0dfb6245cb31f99f2aeb Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Sat, 23 May 2026 19:39:13 +0000 Subject: [PATCH 4/4] =?UTF-8?q?revert:=20remove=20createBaseRoles=20?= =?UTF-8?q?=E2=80=94=20roles=20created=20by=20pgpm=20admin-users=20bootstr?= =?UTF-8?q?ap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CI workflow already runs 'pgpm admin-users bootstrap' before tests, which creates the base roles (administrator, authenticated, anonymous). No code changes needed in pgsql-client or pgsql-test — the seed SQL just needed to stop duplicating what the workflow already does. --- postgres/pgsql-client/src/admin.ts | 25 ------------------------- postgres/pgsql-test/src/connect.ts | 1 - 2 files changed, 26 deletions(-) diff --git a/postgres/pgsql-client/src/admin.ts b/postgres/pgsql-client/src/admin.ts index 7670304a16..419afebb29 100644 --- a/postgres/pgsql-client/src/admin.ts +++ b/postgres/pgsql-client/src/admin.ts @@ -1,5 +1,4 @@ import { - generateCreateBaseRolesSQL, generateCreateUserWithGrantsSQL, generateGrantRoleSQL } from '@pgpmjs/core'; @@ -126,30 +125,6 @@ export class DbAdmin { this.safeDropDb(template); } - async createBaseRoles(dbName?: string): Promise { - const db = dbName ?? this.config.database; - const roles = { - anonymous: getRoleName('anonymous', this.roleConfig), - authenticated: getRoleName('authenticated', this.roleConfig), - administrator: getRoleName('administrator', this.roleConfig) - }; - const sql = generateCreateBaseRolesSQL(roles); - const maxRetries = 3; - for (let attempt = 1; attempt <= maxRetries; attempt++) { - try { - await this.streamSql(sql, db); - return; - } catch (err: any) { - const msg = err?.message ?? ''; - if (msg.includes('tuple concurrently updated') && attempt < maxRetries) { - log.warn(`createBaseRoles: concurrent update (attempt ${attempt}/${maxRetries}), retrying...`); - continue; - } - throw err; - } - } - } - async grantRole(role: string, user: string, dbName?: string): Promise { const db = dbName ?? this.config.database; const sql = generateGrantRoleSQL(role, user); diff --git a/postgres/pgsql-test/src/connect.ts b/postgres/pgsql-test/src/connect.ts index 8829d5bb71..06aa359093 100644 --- a/postgres/pgsql-test/src/connect.ts +++ b/postgres/pgsql-test/src/connect.ts @@ -72,7 +72,6 @@ export const getConnections = async ( const connOpts: PgTestConnectionOptions = cn.db; const root = getPgRootAdmin(config, connOpts); - await root.createBaseRoles(connOpts.rootDb!); await root.createUserRole( connOpts.connections!.app!.user!, connOpts.connections!.app!.password!,