From 5dd31e0742f5f766cbc1a3c198465f48309ee755 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Sun, 31 May 2026 23:12:30 +0000 Subject: [PATCH 1/4] feat: add 6 tooling skills to .agents/skills/ Move tooling skills from constructive-skills and absorb relevant reference docs from constructive-db: - pgpm: PostgreSQL Package Manager + starter-kits, template-authoring, nextjs-app, pgpm-tables, pgpm-export references - constructive-pnpm: PNPM workspace management (copy as-is) - constructive-setup: Monorepo setup + local-dev-setup, local-env, full-pipeline references - constructive-testing: Test frameworks + test-authoring, ci-test-optimization, integration-testing references - constructive-cli: NEW skill for generated CLI commands + codegen reference (extracted from constructive-sdk-graphql) - graphile-search: Unified search plugin (copy as-is) Updated AGENTS.md with Tooling Skills table. --- .agents/skills/constructive-cli.zip | Bin 0 -> 3498 bytes .agents/skills/constructive-cli/SKILL.md | 124 ++++ .../references/codegen-cli-reference.md | 111 ++++ .agents/skills/constructive-pnpm.zip | Bin 0 -> 11003 bytes .agents/skills/constructive-pnpm/SKILL.md | 62 ++ .../references/pnpm-monorepo-management.md | 401 +++++++++++++ .../references/pnpm-publishing.md | 416 +++++++++++++ .../references/pnpm-workspace.md | 275 +++++++++ .agents/skills/constructive-setup.zip | Bin 0 -> 8316 bytes .agents/skills/constructive-setup/SKILL.md | 128 ++++ .../references/full-pipeline.md | 111 ++++ .../references/local-dev-setup.md | 48 ++ .../references/local-email-services.md | 159 +++++ .../references/local-env.md | 108 ++++ .agents/skills/constructive-testing.zip | Bin 0 -> 43742 bytes .agents/skills/constructive-testing/SKILL.md | 118 ++++ .../references/ci-test-optimization.md | 97 +++ .../references/drizzle-orm-test.md | 409 +++++++++++++ .../references/drizzle-orm.md | 490 +++++++++++++++ .../references/integration-testing.md | 107 ++++ .../references/pgsql-parser-testing.md | 223 +++++++ .../references/pgsql-test-exceptions.md | 222 +++++++ .../references/pgsql-test-helpers.md | 345 +++++++++++ .../references/pgsql-test-jwt-context.md | 211 +++++++ .../references/pgsql-test-rls.md | 339 +++++++++++ .../references/pgsql-test-scenario-setup.md | 289 +++++++++ .../references/pgsql-test-seeding.md | 286 +++++++++ .../references/pgsql-test-snapshot.md | 357 +++++++++++ .../references/pgsql-test-transactions.md | 110 ++++ .../references/pgsql-test.md | 214 +++++++ .../references/supabase-test.md | 383 ++++++++++++ .../references/test-authoring.md | 105 ++++ .agents/skills/graphile-search.zip | Bin 0 -> 16931 bytes .agents/skills/graphile-search/SKILL.md | 255 ++++++++ .../references/bm25-adapter.md | 81 +++ .../references/codegen-sdk-queries.md | 563 ++++++++++++++++++ .../references/pgvector-adapter.md | 209 +++++++ .../references/search-adapter-interface.md | 134 +++++ .../references/trgm-adapter.md | 134 +++++ .../references/tsvector-adapter.md | 77 +++ .agents/skills/pgpm.zip | Bin 0 -> 49883 bytes .agents/skills/pgpm/SKILL.md | 327 ++++++++++ .agents/skills/pgpm/references/changes.md | 258 ++++++++ .agents/skills/pgpm/references/ci-cd.md | 441 ++++++++++++++ .agents/skills/pgpm/references/cli.md | 389 ++++++++++++ .../skills/pgpm/references/dependencies.md | 209 +++++++ .../pgpm/references/deploy-lifecycle.md | 248 ++++++++ .agents/skills/pgpm/references/docker.md | 142 +++++ .agents/skills/pgpm/references/env.md | 205 +++++++ .../references/environment-configuration.md | 359 +++++++++++ .agents/skills/pgpm/references/extensions.md | 197 ++++++ .../skills/pgpm/references/module-naming.md | 175 ++++++ .agents/skills/pgpm/references/nextjs-app.md | 100 ++++ .agents/skills/pgpm/references/pgpm-export.md | 56 ++ .agents/skills/pgpm/references/pgpm-tables.md | 59 ++ .agents/skills/pgpm/references/plan-format.md | 154 +++++ .agents/skills/pgpm/references/publishing.md | 345 +++++++++++ .../skills/pgpm/references/sql-conventions.md | 309 ++++++++++ .../skills/pgpm/references/starter-kits.md | 79 +++ .../pgpm/references/template-authoring.md | 111 ++++ .agents/skills/pgpm/references/testing.md | 292 +++++++++ .../skills/pgpm/references/troubleshooting.md | 313 ++++++++++ .agents/skills/pgpm/references/workspace.md | 188 ++++++ AGENTS.md | 13 + 64 files changed, 12670 insertions(+) create mode 100644 .agents/skills/constructive-cli.zip create mode 100644 .agents/skills/constructive-cli/SKILL.md create mode 100644 .agents/skills/constructive-cli/references/codegen-cli-reference.md create mode 100644 .agents/skills/constructive-pnpm.zip create mode 100644 .agents/skills/constructive-pnpm/SKILL.md create mode 100644 .agents/skills/constructive-pnpm/references/pnpm-monorepo-management.md create mode 100644 .agents/skills/constructive-pnpm/references/pnpm-publishing.md create mode 100644 .agents/skills/constructive-pnpm/references/pnpm-workspace.md create mode 100644 .agents/skills/constructive-setup.zip create mode 100644 .agents/skills/constructive-setup/SKILL.md create mode 100644 .agents/skills/constructive-setup/references/full-pipeline.md create mode 100644 .agents/skills/constructive-setup/references/local-dev-setup.md create mode 100644 .agents/skills/constructive-setup/references/local-email-services.md create mode 100644 .agents/skills/constructive-setup/references/local-env.md create mode 100644 .agents/skills/constructive-testing.zip create mode 100644 .agents/skills/constructive-testing/SKILL.md create mode 100644 .agents/skills/constructive-testing/references/ci-test-optimization.md create mode 100644 .agents/skills/constructive-testing/references/drizzle-orm-test.md create mode 100644 .agents/skills/constructive-testing/references/drizzle-orm.md create mode 100644 .agents/skills/constructive-testing/references/integration-testing.md create mode 100644 .agents/skills/constructive-testing/references/pgsql-parser-testing.md create mode 100644 .agents/skills/constructive-testing/references/pgsql-test-exceptions.md create mode 100644 .agents/skills/constructive-testing/references/pgsql-test-helpers.md create mode 100644 .agents/skills/constructive-testing/references/pgsql-test-jwt-context.md create mode 100644 .agents/skills/constructive-testing/references/pgsql-test-rls.md create mode 100644 .agents/skills/constructive-testing/references/pgsql-test-scenario-setup.md create mode 100644 .agents/skills/constructive-testing/references/pgsql-test-seeding.md create mode 100644 .agents/skills/constructive-testing/references/pgsql-test-snapshot.md create mode 100644 .agents/skills/constructive-testing/references/pgsql-test-transactions.md create mode 100644 .agents/skills/constructive-testing/references/pgsql-test.md create mode 100644 .agents/skills/constructive-testing/references/supabase-test.md create mode 100644 .agents/skills/constructive-testing/references/test-authoring.md create mode 100644 .agents/skills/graphile-search.zip create mode 100644 .agents/skills/graphile-search/SKILL.md create mode 100644 .agents/skills/graphile-search/references/bm25-adapter.md create mode 100644 .agents/skills/graphile-search/references/codegen-sdk-queries.md create mode 100644 .agents/skills/graphile-search/references/pgvector-adapter.md create mode 100644 .agents/skills/graphile-search/references/search-adapter-interface.md create mode 100644 .agents/skills/graphile-search/references/trgm-adapter.md create mode 100644 .agents/skills/graphile-search/references/tsvector-adapter.md create mode 100644 .agents/skills/pgpm.zip create mode 100644 .agents/skills/pgpm/SKILL.md create mode 100644 .agents/skills/pgpm/references/changes.md create mode 100644 .agents/skills/pgpm/references/ci-cd.md create mode 100644 .agents/skills/pgpm/references/cli.md create mode 100644 .agents/skills/pgpm/references/dependencies.md create mode 100644 .agents/skills/pgpm/references/deploy-lifecycle.md create mode 100644 .agents/skills/pgpm/references/docker.md create mode 100644 .agents/skills/pgpm/references/env.md create mode 100644 .agents/skills/pgpm/references/environment-configuration.md create mode 100644 .agents/skills/pgpm/references/extensions.md create mode 100644 .agents/skills/pgpm/references/module-naming.md create mode 100644 .agents/skills/pgpm/references/nextjs-app.md create mode 100644 .agents/skills/pgpm/references/pgpm-export.md create mode 100644 .agents/skills/pgpm/references/pgpm-tables.md create mode 100644 .agents/skills/pgpm/references/plan-format.md create mode 100644 .agents/skills/pgpm/references/publishing.md create mode 100644 .agents/skills/pgpm/references/sql-conventions.md create mode 100644 .agents/skills/pgpm/references/starter-kits.md create mode 100644 .agents/skills/pgpm/references/template-authoring.md create mode 100644 .agents/skills/pgpm/references/testing.md create mode 100644 .agents/skills/pgpm/references/troubleshooting.md create mode 100644 .agents/skills/pgpm/references/workspace.md diff --git a/.agents/skills/constructive-cli.zip b/.agents/skills/constructive-cli.zip new file mode 100644 index 0000000000000000000000000000000000000000..fe4a1b93b5c22184b60a2689db8c69b6586c4113 GIT binary patch literal 3498 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 literal 0 HcmV?d00001 diff --git a/.agents/skills/constructive-cli/SKILL.md b/.agents/skills/constructive-cli/SKILL.md new file mode 100644 index 0000000000..c894b8c3c7 --- /dev/null +++ b/.agents/skills/constructive-cli/SKILL.md @@ -0,0 +1,124 @@ +--- +name: constructive-cli +description: "Generated CLI commands and scaffolding — how the CLI is generated from GraphQL schemas, how to use it, codegen options, multi-target unified CLI, and the CLI reference. Use when asked to 'generate a CLI', 'create CLI commands', 'build a command-line client', 'run generated CLI', or when working with @constructive-io/graphql-codegen CLI output." +metadata: + author: constructive-io + version: "1.0.0" +--- + +# Constructive CLI (Generated) + +The Constructive codegen pipeline generates interactive command-line interfaces from GraphQL schemas using inquirerer. The generated CLI provides CRUD commands for each table and custom operations, plus built-in infrastructure commands for authentication and context management. + +## When to Apply + +Use this skill when: +- Generating a CLI tool from a GraphQL schema +- Running or customizing the generated CLI +- Understanding the CLI codegen pipeline +- Building internal tooling or admin scripts from GraphQL APIs +- Configuring multi-target unified CLI output + +## How the CLI Is Generated + +The CLI is generated by `@constructive-io/graphql-codegen` alongside the ORM client: + +```typescript +import { generate } from '@constructive-io/graphql-codegen'; + +await generate({ + schemaFile: './schemas/public.graphql', + output: './generated', + cli: true, // ORM is auto-generated alongside CLI +}); +``` + +### CLI Configuration Options + +```typescript +cli: { + toolName: 'myapp', // Config stored at ~/.myapp/ via appstash + entryPoint: true, // Generate runnable index.ts + builtinNames: { + auth: 'credentials', // Rename infrastructure commands + context: 'env', + }, +} +``` + +## Output Structure + +``` +{output}/cli/ +├── index.ts # Entry point (only if entryPoint: true) +├── executor.ts # CLI executor with command routing +├── command-map.ts # Map of all commands +├── context.ts # Infrastructure: context management +├── auth.ts # Infrastructure: auth/credentials +├── utils.ts # Shared CLI utilities +└── commands/ + ├── users.ts # Generated CRUD commands per table + └── ... +``` + +## Running the Generated CLI + +```bash +# With entry point +npx ts-node generated/cli/index.ts + +# Or compile and run +npx tsc && node dist/generated/cli/index.js +``` + +## Built-in Infrastructure Commands + +### context — Manage API endpoints + +```bash +myapp context create production --endpoint https://api.example.com/graphql +myapp context list +myapp context use production +myapp context current +``` + +### auth — Manage API credentials + +```bash +myapp auth set-token +myapp auth status +myapp auth logout +``` + +### Builtin Name Collision Handling + +If a table name collides with `auth` or `context`, the infrastructure command is automatically renamed (`auth` → `credentials`, `context` → `env`). Override with `builtinNames`. + +## Multi-Target CLI (Unified) + +Combine all targets into a single CLI with namespaced commands: + +```typescript +import { generateMulti } from '@constructive-io/graphql-codegen'; + +await generateMulti({ + configs: { + public: { schemaFile: './schemas/public.graphql', output: './generated/public', cli: true }, + admin: { schemaFile: './schemas/admin.graphql', output: './generated/admin', cli: true }, + }, + unifiedCli: { toolName: 'myapp', entryPoint: true }, +}); +// Commands: myapp public users list, myapp admin users list +``` + +## Reference Guide + +| Reference | Topic | Consult When | +|-----------|-------|--------------| +| [references/codegen-cli-reference.md](references/codegen-cli-reference.md) | Full CLI codegen reference | Looking up CLI flags, source options, environment variables | + +## Cross-References + +- `constructive-sdk-graphql` skill (in constructive-skills) — Full codegen pipeline (hooks, ORM, CLI generation) +- `pgpm` skill — Database migrations (deploy before generating CLI) +- `constructive-setup` skill — Monorepo setup and local development diff --git a/.agents/skills/constructive-cli/references/codegen-cli-reference.md b/.agents/skills/constructive-cli/references/codegen-cli-reference.md new file mode 100644 index 0000000000..fc13628fd5 --- /dev/null +++ b/.agents/skills/constructive-cli/references/codegen-cli-reference.md @@ -0,0 +1,111 @@ +# CLI Codegen Reference + +Complete reference for `@constructive-io/graphql-codegen` CLI commands. + +## @constructive-io/graphql-codegen generate + +Generate type-safe React Query hooks and/or ORM client from GraphQL schema. + +```bash +npx @constructive-io/graphql-codegen generate [options] +``` + +### Source Options (choose one) + +| Option | Alias | Description | Default | +|--------|-------|-------------|---------| +| `--endpoint ` | `-e` | GraphQL endpoint URL | - | +| `--schema-file ` | `-s` | Path to GraphQL schema file (.graphql) | - | +| `--schemas ` | - | PostgreSQL schemas (comma-separated) | - | +| `--api-names ` | - | API names for auto schema discovery | - | +| `--config ` | `-c` | Path to config file | `graphql-codegen.config.ts` | + +### Generator Options + +| Option | Description | Default | +|--------|-------------|---------| +| `--react-query` | Generate React Query hooks | `false` | +| `--orm` | Generate ORM client | `false` | + +### Output Options + +| Option | Alias | Description | Default | +|--------|-------|-------------|---------| +| `--output ` | `-o` | Output directory | `./generated/graphql` | +| `--target ` | `-t` | Target name (for multi-target configs) | - | + +### Schema Export Options + +| Option | Description | Default | +|--------|-------------|---------| +| `--schema-enabled` | Export GraphQL SDL schema file | `false` | +| `--schema-output ` | Output directory for exported schema | Same as `--output` | +| `--schema-filename ` | Filename for exported schema | `schema.graphql` | + +### Other Options + +| Option | Alias | Description | Default | +|--------|-------|-------------|---------| +| `--authorization ` | `-a` | Authorization header value | - | +| `--verbose` | `-v` | Show detailed output | `false` | +| `--dry-run` | - | Preview without writing files | `false` | + +## Examples + +### From GraphQL Endpoint + +```bash +npx @constructive-io/graphql-codegen generate --react-query --endpoint https://api.example.com/graphql +npx @constructive-io/graphql-codegen generate --orm --endpoint https://api.example.com/graphql +npx @constructive-io/graphql-codegen generate --react-query --orm --endpoint https://api.example.com/graphql +``` + +### From Schema File + +```bash +npx @constructive-io/graphql-codegen generate --react-query --schema-file ./schema.graphql +``` + +### From Database + +```bash +npx @constructive-io/graphql-codegen generate --react-query --schemas public,app_public +npx @constructive-io/graphql-codegen generate --orm --api-names my_api +``` + +### Using Config File + +```bash +npx @constructive-io/graphql-codegen generate +npx @constructive-io/graphql-codegen generate --config ./config/codegen.config.ts +npx @constructive-io/graphql-codegen generate --target production # Multi-target +``` + +## Environment Variables + +| Variable | Description | +|----------|-------------| +| `PGHOST` | PostgreSQL host (for database introspection) | +| `PGPORT` | PostgreSQL port | +| `PGDATABASE` | PostgreSQL database name | +| `PGUSER` | PostgreSQL user | +| `PGPASSWORD` | PostgreSQL password | + +## Exit Codes + +| Code | Meaning | +|------|---------| +| `0` | Success | +| `1` | General error | +| `2` | Configuration error | +| `3` | Network/schema error | + +## Troubleshooting + +| Issue | Solution | +|-------|----------| +| No code generated | Add `--react-query` or `--orm` flag | +| "Cannot use both endpoint and schemas" | Choose one schema source | +| No CLI generated | Add `cli: true` to generate options | +| Auth errors | Run `{toolName} auth set-token ` | +| Wrong endpoint | Run `{toolName} context use ` | diff --git a/.agents/skills/constructive-pnpm.zip b/.agents/skills/constructive-pnpm.zip new file mode 100644 index 0000000000000000000000000000000000000000..927ee03cb36b10cb69e63cd8ab2ff861754fd60a GIT binary patch literal 11003 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 run ` | Run command in specific package | +| `pnpm --filter ... run ` | Run in package and dependencies | +| `pnpm add --filter ` | Add dependency to package | +| `pnpm add -w` | Add dependency to root | +| `pnpm up -r -i -L` | Interactive dependency update | +| `pnpm lerna version` | Version packages | +| `pnpm lerna publish` | Publish packages | + +## Hybrid Workspaces + +Some repos (like Constructive) have both PNPM packages and PGPM modules: + +```yaml +# pnpm-workspace.yaml +packages: + - 'packages/*' # TypeScript packages + - 'pgpm/*' # PGPM CLI and tools + - 'postgres/*' # PostgreSQL utilities +``` + +The root may also have a `pgpm.json` for SQL module configuration. + +## Troubleshooting + +### Dependency Resolution Issues + +```bash +# Clear cache and reinstall +pnpm store prune +rm -rf node_modules +pnpm install +``` + +### Workspace Link Issues + +```bash +# Check workspace links +pnpm why +``` + +### Build Order Issues + +```bash +# Verify dependency graph +pnpm list -r --depth=0 +``` + +## Best Practices + +1. **Keep root private**: Never publish the root package +2. **Use workspace protocol**: Always `workspace:*` for internal deps +3. **Consistent structure**: Same directory layout across packages +4. **Shared config**: Extend root tsconfig.json, eslint.config.js +5. **Filter in CI**: Only build/test changed packages +6. **Lock file**: Always commit pnpm-lock.yaml +7. **Dedupe regularly**: Run `pnpm dedupe` periodically +8. **Document dependencies**: Clear README for each package + +## References + +- Related skill: `pnpm-workspace` for workspace setup +- Related skill: `pnpm-publishing` for publishing workflow +- Related skill: `pgpm` (`references/workspace.md`) for SQL module workspaces diff --git a/.agents/skills/constructive-pnpm/references/pnpm-publishing.md b/.agents/skills/constructive-pnpm/references/pnpm-publishing.md new file mode 100644 index 0000000000..cff610e342 --- /dev/null +++ b/.agents/skills/constructive-pnpm/references/pnpm-publishing.md @@ -0,0 +1,416 @@ +--- +name: pnpm-publishing +description: Publish TypeScript packages using makage and lerna following Constructive standards. Use when asked to "publish a package", "release to npm", "build for publishing", or when preparing packages for npm distribution. +compatibility: pnpm, makage, lerna, Node.js 18+ +metadata: + author: constructive-io + version: "1.0.0" +--- + +# Publishing TypeScript Packages (Constructive Standard) + +Publish TypeScript packages to npm using makage for builds and lerna for versioning. This covers the dist-folder publishing pattern that prevents tree-shaking into weird import paths. + +## When to Apply + +Use this skill when: +- Building TypeScript packages for npm publishing +- Configuring makage for package builds +- Running lerna version and publish workflows +- Setting up the dist-folder publishing pattern + +## Why Dist-Folder Publishing? + +Constructive publishes from the `dist/` folder to: +- Prevent consumers from importing internal paths (`my-pkg/src/internal`) +- Ensure clean package structure on npm +- Keep source files out of published package +- Maintain consistent import paths + +## Anti-Pattern: ESM-Only with Exports Map + +**NEVER use the `exports` map pattern:** + +```json +{ + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + }, + "./api": { + "import": "./dist/api/index.js", + "types": "./dist/api/index.d.ts" + } + } +} +``` + +**Problems with this approach:** +- Breaks CommonJS consumers +- Exposes `dist/` in import paths +- Incompatible with the dist-folder publishing pattern +- Creates inconsistent import paths between development and published package + +**Instead, use the Constructive standard pattern shown below.** + +## Deep Nested Imports (Recommended for Tree-Shaking) + +Deep nested imports via file path are **fully supported and recommended** for tree-shaking. With dist-folder publishing, the `dist/` folder becomes the package root, so consumers can import directly from subdirectories: + +```typescript +// These imports work correctly with dist-folder publishing: +import { OrmClient } from '@my-org/sdk/api'; +import { AdminClient } from '@my-org/sdk/admin'; +import { AuthClient } from '@my-org/sdk/auth'; +``` + +This works because the published package structure looks like: + +```text +@my-org/sdk (on npm) +├── index.js # Main entry point +├── api/ +│ └── index.js # API-specific code +├── admin/ +│ └── index.js # Admin-specific code +└── auth/ + └── index.js # Auth-specific code +``` + +**Benefits of this approach:** +- Full tree-shaking support (only import what you need) +- Works with both CommonJS and ESM +- No `exports` map needed +- Clean import paths without `dist/` + +**Source structure for nested imports:** + +```text +my-package/ +├── src/ +│ ├── index.ts # Re-exports or shared code +│ ├── api/ +│ │ └── index.ts # API module +│ ├── admin/ +│ │ └── index.ts # Admin module +│ └── auth/ +│ └── index.ts # Auth module +└── package.json +``` + +After `makage build`, the `dist/` folder mirrors this structure and becomes the published package root. + +## Anti-Pattern: Manual Build Scripts Without Makage + +**NEVER use manual build scripts like this:** + +```json +{ + "scripts": { + "clean": "rimraf dist/**", + "copy": "copyfiles -f ../../LICENSE package.json dist", + "build": "npm run clean; tsc -p tsconfig.json; tsc -p tsconfig.esm.json; npm run copy" + }, + "devDependencies": { + "copyfiles": "^2.4.1", + "rimraf": "^6.0.1" + } +} +``` + +**Problems with this approach:** +- Reinvents what makage already does +- Requires multiple devDependencies (copyfiles, rimraf) instead of one (makage) +- Manual tsconfig management for CJS/ESM builds +- Inconsistent build behavior across packages +- Missing features like automatic source map handling + +**Instead, use makage which handles all of this automatically.** + +## Makage Overview + +[makage](https://www.npmjs.com/package/makage) is a tiny build helper that replaces cpy, rimraf, and other build tools: + +| Command | Description | +|---------|-------------| +| `makage build` | Clean, compile TypeScript, copy assets | +| `makage build --dev` | Build with source maps | +| `makage clean` | Remove dist folder | +| `makage assets` | Copy LICENSE, README, package.json to dist | + +## Package Configuration + +### package.json + +```json +{ + "name": "my-package", + "version": "0.1.0", + "description": "Package description", + "author": "Constructive ", + "main": "index.js", + "module": "esm/index.js", + "types": "index.d.ts", + "homepage": "https://github.com/org/my-workspace", + "license": "MIT", + "publishConfig": { + "access": "public", + "directory": "dist" + }, + "repository": { + "type": "git", + "url": "https://github.com/org/my-workspace" + }, + "scripts": { + "copy": "makage assets", + "clean": "makage clean", + "prepublishOnly": "npm run build", + "build": "makage build", + "lint": "eslint . --fix", + "test": "jest", + "test:watch": "jest --watch" + }, + "devDependencies": { + "makage": "0.1.10" + } +} +``` + +**Critical fields:** +- `publishConfig.directory: "dist"` — Publish from dist folder +- `main: "index.js"` — Points to CJS build (in dist) +- `module: "esm/index.js"` — Points to ESM build (in dist) +- `types: "index.d.ts"` — Points to type declarations (in dist) + +## Build Output Structure + +After `makage build`: + +```text +my-package/ +├── src/ +│ └── index.ts +├── dist/ +│ ├── index.js # CJS build +│ ├── index.d.ts # Type declarations +│ ├── esm/ +│ │ └── index.js # ESM build +│ ├── package.json # Copied from root +│ ├── README.md # Copied from root +│ └── LICENSE # Copied from root +└── package.json +``` + +The `dist/` folder is what gets published to npm. + +## Build Workflow + +### Development Build + +```bash +# Build with source maps for debugging +makage build --dev +``` + +### Production Build + +```bash +# Full build: clean, compile, copy assets +makage build +``` + +### Clean + +```bash +# Remove dist folder +makage clean +``` + +## Publishing Workflow + +### 1. Prepare + +```bash +# Install dependencies +pnpm install + +# Build all packages +pnpm -r run build + +# Run tests +pnpm -r run test + +# Run linting +pnpm -r run lint +``` + +### 2. Version + +```bash +# Interactive versioning (independent mode) +pnpm lerna version + +# Or with conventional commits +pnpm lerna version --conventional-commits +``` + +### 3. Publish + +```bash +# Publish to npm +pnpm lerna publish from-package +``` + +**Note:** Use `from-package` to publish packages that have been versioned but not yet published. + +### One-Liner + +```bash +pnpm install && pnpm -r run build && pnpm -r run test && pnpm lerna version && pnpm lerna publish from-package +``` + +## Dry Run Commands + +Test without making changes: + +```bash +# Test versioning (no git operations) +pnpm lerna version --no-git-tag-version --no-push + +# Test publishing +pnpm lerna publish from-package --dry-run +``` + +## Lerna Configuration + +### lerna.json + +```json +{ + "$schema": "node_modules/lerna/schemas/lerna-schema.json", + "version": "independent", + "npmClient": "pnpm", + "registry": "https://registry.npmjs.org", + "command": { + "create": { + "homepage": "https://github.com/org/my-workspace", + "license": "MIT", + "access": "restricted" + }, + "publish": { + "allowBranch": "main", + "message": "chore(release): publish", + "conventionalCommits": true + } + } +} +``` + +## Access Control + +### Public Packages + +```json +{ + "publishConfig": { + "access": "public", + "directory": "dist" + } +} +``` + +### Private/Scoped Packages + +```json +{ + "publishConfig": { + "access": "restricted", + "directory": "dist" + } +} +``` + +## TypeScript Configuration + +### tsconfig.json (package level) + +```json +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "declaration": true, + "declarationMap": true + }, + "include": ["src/**/*"] +} +``` + +### ESM Build + +makage handles dual CJS/ESM builds automatically. The ESM output goes to `dist/esm/`. + +## Workspace Dependencies + +When publishing, `workspace:*` references are converted to actual versions: + +**Before publish (package.json):** +```json +{ + "dependencies": { + "my-other-package": "workspace:*" + } +} +``` + +**After publish (in dist/package.json):** +```json +{ + "dependencies": { + "my-other-package": "^0.5.0" + } +} +``` + +## Common Issues + +### Package Not Found After Publish + +Ensure `publishConfig.directory` is set to `"dist"`. + +### Types Not Found + +Ensure `types` field points to declaration file in dist: +```json +{ + "types": "index.d.ts" +} +``` + +### ESM Import Errors + +Ensure `module` field points to ESM build: +```json +{ + "module": "esm/index.js" +} +``` + +## Best Practices + +1. **Always build before publish**: Use `prepublishOnly` script +2. **Test the build**: Run tests against built output +3. **Use dry-run first**: Test versioning and publishing before committing +4. **Keep dist clean**: Run `makage clean` before builds +5. **Conventional commits**: Enable for automatic changelogs + +## References + +- Related skill: `pnpm-workspace` for workspace setup +- Related skill: `pgpm` (`references/publishing.md`) for SQL module publishing +- [makage on npm](https://www.npmjs.com/package/makage) diff --git a/.agents/skills/constructive-pnpm/references/pnpm-workspace.md b/.agents/skills/constructive-pnpm/references/pnpm-workspace.md new file mode 100644 index 0000000000..8aa131bcdf --- /dev/null +++ b/.agents/skills/constructive-pnpm/references/pnpm-workspace.md @@ -0,0 +1,275 @@ +--- +name: pnpm-workspace +description: Create and manage PNPM workspaces following Constructive standards. Use when asked to "create a monorepo", "set up a workspace", "configure pnpm", or when starting a new TypeScript/JavaScript project with multiple packages. +compatibility: pnpm, Node.js 18+, lerna +metadata: + author: constructive-io + version: "1.0.0" +--- + +# PNPM Workspaces (Constructive Standard) + +Create and manage PNPM monorepo workspaces following Constructive conventions. This covers pure TypeScript/JavaScript workspaces (not pgpm workspaces for SQL modules). + +## When to Apply + +Use this skill when: +- Creating a new TypeScript/JavaScript monorepo +- Setting up a pnpm workspace structure +- Configuring lerna for versioning and publishing +- Managing internal package dependencies + +## Workspace Structure + +A Constructive-standard pnpm workspace: + +```text +my-workspace/ +├── .eslintrc.json +├── .gitignore +├── .prettierrc.json +├── lerna.json +├── package.json +├── packages/ +│ ├── package-a/ +│ │ ├── package.json +│ │ ├── src/ +│ │ └── tsconfig.json +│ └── package-b/ +│ ├── package.json +│ ├── src/ +│ └── tsconfig.json +├── pnpm-lock.yaml +├── pnpm-workspace.yaml +└── tsconfig.json +``` + +## Core Configuration Files + +### pnpm-workspace.yaml + +Defines which directories contain packages: + +```yaml +packages: + - 'packages/*' +``` + +For larger projects with multiple package directories: + +```yaml +packages: + - 'packages/*' + - 'apps/*' + - 'libs/*' +``` + +### Root package.json + +```json +{ + "name": "my-workspace", + "version": "0.0.1", + "private": true, + "repository": { + "type": "git", + "url": "https://github.com/org/my-workspace" + }, + "license": "MIT", + "publishConfig": { + "access": "restricted" + }, + "scripts": { + "build": "pnpm -r run build", + "clean": "pnpm -r run clean", + "test": "pnpm -r run test", + "lint": "pnpm -r run lint", + "deps": "pnpm up -r -i -L" + }, + "devDependencies": { + "@types/jest": "^30.0.0", + "@types/node": "^22.10.2", + "@typescript-eslint/eslint-plugin": "^8.53.1", + "@typescript-eslint/parser": "^8.53.1", + "eslint": "^9.39.2", + "eslint-config-prettier": "^10.1.8", + "jest": "^30.2.0", + "lerna": "^8.2.4", + "prettier": "^3.8.0", + "ts-jest": "^29.4.6", + "ts-node": "^10.9.2", + "typescript": "^5.6.3" + } +} +``` + +**Key points:** +- Root package is `private: true` (never published) +- Scripts use `pnpm -r` to run recursively across packages +- `deps` script for interactive dependency updates + +### lerna.json + +```json +{ + "$schema": "node_modules/lerna/schemas/lerna-schema.json", + "version": "independent", + "npmClient": "pnpm", + "registry": "https://registry.npmjs.org", + "command": { + "create": { + "homepage": "https://github.com/org/my-workspace", + "license": "MIT", + "access": "restricted" + }, + "publish": { + "allowBranch": "main", + "message": "chore(release): publish", + "conventionalCommits": true + } + } +} +``` + +**Versioning modes:** +- `"version": "independent"` — Each package versioned separately (recommended for utility libraries) +- `"version": "0.0.1"` — Fixed versioning, all packages share same version (recommended for tightly coupled packages) + +## Package Configuration + +### Individual package.json + +```json +{ + "name": "my-package", + "version": "0.1.0", + "description": "Package description", + "author": "Constructive ", + "main": "index.js", + "module": "esm/index.js", + "types": "index.d.ts", + "homepage": "https://github.com/org/my-workspace", + "license": "MIT", + "publishConfig": { + "access": "public", + "directory": "dist" + }, + "repository": { + "type": "git", + "url": "https://github.com/org/my-workspace" + }, + "scripts": { + "clean": "makage clean", + "build": "makage build", + "lint": "eslint . --fix", + "test": "jest" + }, + "devDependencies": { + "makage": "0.1.10" + } +} +``` + +**Key patterns:** +- `publishConfig.directory: "dist"` — Publish from dist folder (prevents tree-shaking into weird paths) +- `main` and `module` point to built files (not src) +- Uses makage for build tooling + +## Internal Dependencies + +Reference workspace packages using `workspace:*`: + +```json +{ + "dependencies": { + "my-other-package": "workspace:*" + } +} +``` + +When published, `workspace:*` is replaced with the actual version number. + +## Common Commands + +| Command | Description | +|---------|-------------| +| `pnpm install` | Install all dependencies | +| `pnpm -r run build` | Build all packages | +| `pnpm -r run test` | Test all packages | +| `pnpm --filter run build` | Build specific package | +| `pnpm up -r -i -L` | Interactive dependency update | +| `pnpm lerna version` | Version packages | +| `pnpm lerna publish` | Publish packages | + +## Creating a New Package + +```bash +# Create package directory +mkdir -p packages/my-new-package/src + +# Initialize package.json +cd packages/my-new-package +pnpm init +``` + +Then configure package.json following the pattern above. + +## TypeScript Configuration + +### Root tsconfig.json + +```json +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "lib": ["ES2020"], + "declaration": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true + } +} +``` + +### Package tsconfig.json + +```json +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"] +} +``` + +## PNPM vs PGPM Workspaces + +| Aspect | PNPM Workspace | PGPM Workspace | +|--------|----------------|----------------| +| Purpose | TypeScript/JS packages | SQL database modules | +| Config | pnpm-workspace.yaml | pnpm-workspace.yaml + pgpm.json | +| Build | makage build | pgpm package | +| Output | dist/ folder | SQL bundles | +| Registry | npm | npm (for @pgpm/* packages) | + +Some repos (like constructive) are **hybrid** — they have both pnpm packages and pgpm modules. + +## Best Practices + +1. **Keep root private**: Never publish the root package +2. **Use workspace protocol**: Always use `workspace:*` for internal deps +3. **Consistent structure**: All packages follow same directory layout +4. **Shared config**: Extend root tsconfig.json in packages +5. **Independent versioning**: Use for utility libraries with different release cycles +6. **Fixed versioning**: Use for tightly coupled packages that should release together + +## References + +- Related skill: `pnpm-publishing` for publishing workflow with makage +- Related skill: `pgpm` (`references/workspace.md`) for SQL module workspaces +- Related skill: `pgpm` (`references/publishing.md`) for publishing pgpm modules diff --git a/.agents/skills/constructive-setup.zip b/.agents/skills/constructive-setup.zip new file mode 100644 index 0000000000000000000000000000000000000000..17e5e2491b32a3b676bdc5240f33de62740ea3dc GIT binary patch literal 8316 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 literal 0 HcmV?d00001 diff --git a/.agents/skills/constructive-setup/SKILL.md b/.agents/skills/constructive-setup/SKILL.md new file mode 100644 index 0000000000..8a150517d2 --- /dev/null +++ b/.agents/skills/constructive-setup/SKILL.md @@ -0,0 +1,128 @@ +--- +name: constructive-setup +description: "Set up the Constructive monorepo for development — install dependencies, start PostgreSQL via pgpm Docker, bootstrap users, build, run tests, and start local email services. Use when asked to 'set up constructive', 'get constructive running', 'set up dev environment', 'bootstrap database', 'start email services', 'test emails locally', or when starting work in the constructive-io/constructive repo." +metadata: + author: constructive-io + version: "1.0.0" + triggers: "user, model" +--- + +# Constructive Monorepo Setup + +Lightweight setup guide for getting the `constructive-io/constructive` monorepo running locally. References detailed skills for each subsystem instead of duplicating their content. + +## When to Apply + +Use this skill when: +- Setting up the Constructive monorepo for the first time +- Starting a new development session that needs a running database +- Troubleshooting a broken local environment + +## Quick Setup + +```bash +# 1. Install dependencies +pnpm install + +# 2. Start PostgreSQL via pgpm Docker +pgpm docker start --image docker.io/constructiveio/postgres-plus:18 --recreate +eval "$(pgpm env)" + +# 3. Bootstrap database users +pgpm admin-users bootstrap --yes +pgpm admin-users add --test --yes + +# 4. Build the monorepo +pnpm build + +# 5. Run tests (from a specific package) +cd packages/yourmodule +pnpm test +``` + +## Step-by-Step Details + +### 1. Install Dependencies + +The monorepo uses pnpm workspaces: + +```bash +pnpm install +``` + +### 2. Start PostgreSQL + +Use pgpm's Docker integration to start a local PostgreSQL container. The `postgres-plus:18` image includes all required extensions (PostGIS, pgvector, uuid-ossp, etc.). + +```bash +pgpm docker start --image docker.io/constructiveio/postgres-plus:18 --recreate +eval "$(pgpm env)" +``` + +> **Important:** `eval "$(pgpm env)"` must be run as a separate command (not chained with `&&`) because the env vars aren't available until the command completes. + +For full Docker options (custom ports, names, passwords), see the **pgpm** skill: [references/docker.md](../pgpm/references/docker.md) + +For environment variable details, see the **pgpm** skill: [references/env.md](../pgpm/references/env.md) + +### 3. Bootstrap Database Users + +Create the required PostgreSQL roles for Constructive's security model: + +```bash +pgpm admin-users bootstrap --yes +pgpm admin-users add --test --yes +``` + +### 4. Build + +```bash +pnpm build +``` + +This builds all packages in the monorepo. Required before running tests or starting servers. + +### 5. Run Tests + +Tests are run per-package: + +```bash +cd packages/yourmodule # or graphile/yourplugin, pgpm/core, etc. +pnpm test # single run +pnpm test:watch # watch mode +``` + +For testing patterns and frameworks, see the **constructive-testing** skill. + +## Local Email Services + +Start Mailpit, Admin GraphQL, send-email-link, and job-service for local email testing. + +See [local-email-services.md](./references/local-email-services.md) for Docker Compose setup, port reference, and troubleshooting. + +## Monorepo Layout + +| Directory | Contents | +|-----------|----------| +| `packages/*` | Constructive CLI, ORM, query-builder, server-utils | +| `pgpm/*` | PGPM engine, CLI, shared types/logger/env | +| `graphql/*` | GraphQL server, explorer, codegen, types, query/react utilities | +| `graphile/*` | Graphile/PostGraphile plugins (postgis, search, etc.) | +| `postgres/*` | PostgreSQL tooling (pg-ast, pg-codegen, introspectron, pgsql-test) | +| `extensions/*` | PGPM extension modules | + +For full navigation, see the repo's `AGENTS.md`. + +### Local Development (from constructive-db) + +| Reference | Topic | Consult When | +|-----------|-------|--------------| +| [references/local-dev-setup.md](references/local-dev-setup.md) | Quick-start local dev | Docker Postgres + pgpm deploy + GraphQL server startup | +| [references/local-env.md](references/local-env.md) | Full local environment | CLI testing, e2e tests, subdomain routing, troubleshooting | +| [references/full-pipeline.md](references/full-pipeline.md) | End-to-end pipeline | Docker → deploy → provision → codegen → verify | + +## Cross-References + +- **pgpm** skill — Database migrations, Docker, environment, CLI commands +- **constructive-testing** skill — Test frameworks (pgsql-test, drizzle-orm-test, supabase-test) +- **constructive-cli** skill — Generated CLI commands and scaffolding diff --git a/.agents/skills/constructive-setup/references/full-pipeline.md b/.agents/skills/constructive-setup/references/full-pipeline.md new file mode 100644 index 0000000000..eff1449420 --- /dev/null +++ b/.agents/skills/constructive-setup/references/full-pipeline.md @@ -0,0 +1,111 @@ +# Constructive Full Pipeline + +Autonomous end-to-end workflow: Docker → deploy platform DB → provision user DB → generate SDK → verify. + +## Prerequisites + +- Docker running +- Node.js v22+ +- `pgpm` installed globally: `npm install -g pgpm` +- `pnpm` installed +- Both repos cloned: + - `constructive-io/constructive-db` (database + codegen) + - `constructive-io/constructive` (monorepo with GraphQL server + codegen CLI) + +## Phase 1: Start PostgreSQL + +```bash +pgpm docker start --image docker.io/constructiveio/postgres-plus:18 --recreate +eval "$(pgpm env)" +pgpm admin-users bootstrap --yes +pgpm admin-users add --test --yes +``` + +**Verify:** `psql -c "SELECT 1"` returns successfully. + +## Phase 2: Deploy Platform Database + +```bash +cd +pnpm install +dropdb --if-exists constructive +pgpm deploy --database constructive --createdb --yes --package constructive-services +pgpm deploy --database constructive --yes --package constructive-local +``` + +**Verify:** `psql -d constructive -c "SELECT count(*) FROM metaschema_public.database"` returns without error. + +## Phase 3: Start GraphQL Server + +```bash +cd /graphql/server +PGDATABASE=constructive pnpm dev +``` + +**Verify:** `curl -s -o /dev/null -w "%{http_code}" http://api.localhost:3000/graphql` returns `405`. + +### Endpoint Reference + +| Endpoint | Purpose | +|---|---| +| `http://auth.localhost:3000/graphql` | Main auth | +| `http://api.localhost:3000/graphql` | Main public API | +| `http://auth-.localhost:3000/graphql` | Per-DB auth | +| `http://app-public-.localhost:3000/graphql` | Per-DB app API | +| `http://admin-.localhost:3000/graphql` | Per-DB admin | + +## Phase 4: Provision a User Database (via SDK) + +See `constructive-sdk` skill for the full auth + provisioning flow. + +```typescript +import { createClient as createAuthClient } from '@constructive-db/sdk/auth'; +import { createClient as createPublicClient } from '@constructive-db/sdk/public'; + +// 1. Sign up + sign in +const authDb = createAuthClient({ endpoint: 'http://auth.localhost:3000/graphql' }); +await authDb.mutation.signUp({ input: { email, password } }, { select: { ok: true } }).execute(); +const result = await authDb.mutation.signIn( + { input: { email, password } }, + { select: { result: { select: { accessToken: true, userId: true } } } } +).execute(); +const { accessToken, userId } = result.signIn.result; + +// 2. Provision database with all modules +const publicDb = createPublicClient({ + endpoint: 'http://api.localhost:3000/graphql', + headers: { Authorization: `Bearer ${accessToken}` }, +}); +await publicDb.databaseProvisionModule.create({ + data: { + databaseName: dbName, ownerId: userId, subdomain: dbName, domain: 'localhost', + modules: ['all'], bootstrapUser: true, + }, + select: { id: true, databaseId: true, status: true }, +}).execute(); +``` + +## Phase 5: Regenerate SDK (Codegen) + +```bash +cd +pnpm run generate:all +``` + +| Command | What it runs | +|---|---| +| `generate:constructive-all` | `generate:constructive` + `generate:schemas` | +| `generate:schemas-all` | `generate:schemas` + `generate:sdk` + `generate:sdk-new` + `generate:cli` | +| `generate:all` | Everything in correct order | + +## Phase 6: Verify End-to-End + +1. **Tests pass:** `cd && pnpm test` +2. **SDK types compile:** `cd sdk/constructive-sdk && pnpm tsc --noEmit` +3. **GraphQL server responds:** `curl -s http://api.localhost:3000/graphql -H "Content-Type: application/json" -d '{"query":"{ __typename }"}' | jq .` + +## Related Skills + +- `constructive-sdk` — Auth flow, database provisioning, secure tables +- `constructive-sdk-database` — Database CRUD, modules, lifecycle +- `pgpm` — Docker, migrations, testing diff --git a/.agents/skills/constructive-setup/references/local-dev-setup.md b/.agents/skills/constructive-setup/references/local-dev-setup.md new file mode 100644 index 0000000000..8398460980 --- /dev/null +++ b/.agents/skills/constructive-setup/references/local-dev-setup.md @@ -0,0 +1,48 @@ +# Local Dev Setup + +Quick-start for running the Constructive GraphQL server locally with Docker Postgres and pgpm. + +## Prerequisites + +- Docker (for Postgres) +- Node.js v22+ +- pgpm: `npm install -g pgpm` + +## Start + +```bash +eval "$(pgpm env)" +pgpm docker start +``` + +## Deploy Platform DB + +```bash +cd path/to/constructive-db +dropdb --if-exists constructive +pgpm deploy --database constructive --createdb --yes --package constructive-services +pgpm deploy --database constructive --yes --package constructive-local +``` + +## Start GraphQL Server + +```bash +cd path/to/constructive/graphql/server +PGDATABASE=constructive pnpm dev +``` + +Health check: `curl -s -o /dev/null -w "%{http_code}" http://api.localhost:3000/graphql` → 405 + +## Endpoint Reference + +| Endpoint | Purpose | +|---|---| +| `http://auth.localhost:3000/graphql` | Main auth | +| `http://api.localhost:3000/graphql` | Main public API | +| `http://auth-.localhost:3000/graphql` | Per-DB auth | +| `http://app-public-.localhost:3000/graphql` | Per-DB app API | +| `http://admin-.localhost:3000/graphql` | Per-DB admin | + +## Related + +- `constructive-sdk` skill — provision a database after setup diff --git a/.agents/skills/constructive-setup/references/local-email-services.md b/.agents/skills/constructive-setup/references/local-email-services.md new file mode 100644 index 0000000000..aede300f7a --- /dev/null +++ b/.agents/skills/constructive-setup/references/local-email-services.md @@ -0,0 +1,159 @@ +# Local Email Services + +Start Mailpit, Admin GraphQL Server, send-email-link, and job-service for local email testing using Docker Compose. + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Local Development │ +│ │ +│ Next.js (3011) ──► Public GraphQL (3000) │ +│ │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ Email Services (Docker) │ │ +│ │ │ │ +│ │ Admin GraphQL (3002) │ │ +│ │ │ │ │ +│ │ ▼ │ │ +│ │ send-email-link (8082) │ │ +│ │ │ │ │ +│ │ ▼ │ │ +│ │ Mailpit (1025/8025) ◄── job-service (8080) │ │ +│ │ │ │ +│ └─────────────────────────────────────────────────────┘ │ +│ │ +│ PostgreSQL (5432) ◄── job-service polls app_jobs │ +└─────────────────────────────────────────────────────────────┘ +``` + +## Prerequisites + +1. Docker Desktop running +2. PostgreSQL running with constructive database +3. Database provisioned with `app_jobs` schema +4. `constructive` repo on branch `feat/add-local-email-service-docker-compose` (or main after merged) +5. `constructive:dev` Docker image built + +> ⚠️ **分支要求:** `docker-compose.local-email.yml` 在 `feat/add-local-email-service-docker-compose` 分支,合并到 main 之前需先切换: +> ```bash +> cd /path/to/constructive +> git checkout feat/add-local-email-service-docker-compose +> ``` + +## Quick Start + +### Step 1: Build the Docker Image (first time only) + +```bash +cd /path/to/constructive + +# Switch to required branch (until merged to main) +git checkout feat/add-local-email-service-docker-compose + +# Create network +docker network create constructive-net 2>/dev/null || true + +# Build image (takes ~3-5 minutes) +docker build -t constructive:dev . +``` + +### Step 2: Start Email Services + +```bash +cd /path/to/constructive + +docker-compose -f docker-compose.local-email.yml up -d +``` + +### Step 3: Verify + +```bash +# Check status +docker-compose -f docker-compose.local-email.yml ps + +# Health check +echo "=== Health Check ===" +curl -s http://localhost:8025 >/dev/null && echo "✅ Mailpit (8025)" +curl -s http://localhost:3002/graphql -H "X-Meta-Schema: true" -H "Content-Type: application/json" \ + -d '{"query":"{ __typename }"}' | grep -q __typename && echo "✅ Admin GraphQL (3002)" +lsof -i:8082 >/dev/null && echo "✅ send-email-link (8082)" +lsof -i:8080 >/dev/null && echo "✅ job-service (8080)" +``` + +**Mailpit UI:** http://localhost:8025 + +## Managing Services + +```bash +cd /path/to/constructive + +# Start +docker-compose -f docker-compose.local-email.yml up -d + +# Stop +docker-compose -f docker-compose.local-email.yml down + +# View logs (all services) +docker-compose -f docker-compose.local-email.yml logs -f + +# View logs (specific service) +docker logs -f mailpit +docker logs -f constructive-admin-server +docker logs -f send-email-link +docker logs -f knative-job-service + +# Restart +docker-compose -f docker-compose.local-email.yml restart +``` + +## Port Reference + +| Service | Port | URL | +|---------|------|-----| +| Mailpit SMTP | 1025 | - | +| Mailpit Web UI | 8025 | http://localhost:8025 | +| Admin GraphQL | 3002 | http://localhost:3002/graphql | +| send-email-link | 8082 | http://localhost:8082 | +| job-service | 8080 | http://localhost:8080 | + +## Test Email Flow + +1. Open Mailpit: http://localhost:8025 +2. Trigger an invite/signup from your app (http://localhost:3011) +3. Check Mailpit for the email + +## Troubleshooting + +### Port already in use + +```bash +# Find and kill process on port +lsof -ti:8082 | xargs kill -9 +``` + +### Container name conflict + +```bash +# Remove old containers +docker rm -f mailpit constructive-admin-server send-email-link knative-job-service + +# Restart +docker-compose -f docker-compose.local-email.yml up -d +``` + +### Cannot connect to PostgreSQL + +Check that `PGHOST` in docker-compose uses `host.docker.internal` (not `localhost`): + +```yaml +PGHOST: host.docker.internal +``` + +### View service logs for debugging + +```bash +docker logs constructive-admin-server 2>&1 | tail -30 +docker logs send-email-link 2>&1 | tail -30 +docker logs knative-job-service 2>&1 | tail -30 +``` diff --git a/.agents/skills/constructive-setup/references/local-env.md b/.agents/skills/constructive-setup/references/local-env.md new file mode 100644 index 0000000000..d7519f8fa2 --- /dev/null +++ b/.agents/skills/constructive-setup/references/local-env.md @@ -0,0 +1,108 @@ +# Constructive Local Environment Setup + +Set up a fully working local Constructive environment with PostgreSQL, the constructive-local database package, and the GraphQL server. Enables testing the generated CLI, running e2e tests, and developing against a real GraphQL API. + +## When to Apply + +Use this reference when: +- Setting up a local development environment for Constructive +- Testing the generated CLI (`constructive-cli`) +- Running the e2e test script (`test-cli-e2e.sh`) +- Needing a running GraphQL server for development +- Deploying `constructive-local` to a local PostgreSQL instance + +## Prerequisites + +- Docker installed and running +- Node.js 22+ +- pnpm installed +- Access to `constructive-io/constructive-db` and `constructive-io/constructive` repos + +## Step-by-Step Setup + +### 1. Install pgpm globally + +```bash +npm install -g pgpm +``` + +### 2. Start PostgreSQL via pgpm Docker + +```bash +pgpm docker start +``` + +Uses `docker.io/constructiveio/postgres-plus:18` container (includes PostGIS, pgvector, and other required extensions). PostgreSQL 17+ is required because `constructive-local` uses `security_invoker` on views. + +### 3. Set environment variables + +```bash +eval "$(pgpm env)" +``` + +Sets `PGHOST`, `PGPORT`, `PGUSER`, `PGPASSWORD`, and `PGDATABASE` for the local PostgreSQL instance. + +**Important**: Run this as a separate command (not chained with `&&`) so the env vars are available for subsequent commands. + +### 4. Deploy constructive-local + +```bash +cd /path/to/constructive-db/packages/constructive-local +pgpm deploy +``` + +Deploys the full Constructive database schema (tables, functions, triggers, RLS policies). Takes about 30-60 seconds. + +### 5. Install dependencies and start server + +```bash +cd /path/to/constructive +pnpm install +pnpm dev +``` + +The server starts at `http://localhost:5555` with subdomain-based routing: + +| Target | Endpoint | +|--------|----------| +| Public | `http://api.localhost:5555/graphql` | +| Auth | `http://auth.localhost:5555/graphql` | +| Objects | `http://objects.localhost:5555/graphql` | +| Admin | `http://admin.localhost:5555/graphql` | + +### 6. Test the CLI + +```bash +cd /path/to/constructive-db/sdk/constructive-cli +pnpm install +npx tsx cli/index.ts --help +``` + +#### Quick smoke test + +```bash +CLI="npx tsx cli/index.ts" +$CLI context create local --endpoint http://api.localhost:5555/graphql +$CLI context use local +$CLI public:sign-up --input '{"email":"test@example.com","password":"testpass123"}' +$CLI credentials set-token "" +$CLI public:database list +``` + +## Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `BASE_HOST` | `localhost` | Server hostname | +| `BASE_PORT` | `5555` | Server port | +| `CLI_EMAIL` | auto-generated | Test user email | +| `CLI_PASSWORD` | `testpass123` | Test user password | + +## Troubleshooting + +| Issue | Fix | +|-------|-----| +| "security_invoker" error | Need PostgreSQL 17+. Use `pgpm docker start --image docker.io/constructiveio/postgres-plus:18` | +| "OrmClientConfig requires endpoint" | Set CLI context: `context create local --endpoint ...` | +| "permission denied for schema" | Token not set or expired. Re-authenticate and `credentials set-token` | +| Server not responding | Verify with `curl -s http://api.localhost:5555/graphql -H 'Content-Type: application/json' -d '{"query":"{ __typename }"}'` | diff --git a/.agents/skills/constructive-testing.zip b/.agents/skills/constructive-testing.zip new file mode 100644 index 0000000000000000000000000000000000000000..d8a34d06591825048b554daf920df3147f884a2f GIT binary patch literal 43742 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

J6FHz`$UuMlLjx%wM^k6+F=APQf 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 literal 0 HcmV?d00001 diff --git a/.agents/skills/constructive-testing/SKILL.md b/.agents/skills/constructive-testing/SKILL.md new file mode 100644 index 0000000000..07cc045990 --- /dev/null +++ b/.agents/skills/constructive-testing/SKILL.md @@ -0,0 +1,118 @@ +--- +name: constructive-testing +description: "All PostgreSQL and database testing frameworks — pgsql-test (RLS, seeding, snapshots, JWT context, scenario setup), drizzle-orm-test (type-safe Drizzle testing), supabase-test (Supabase RLS testing), drizzle-orm (schema patterns), and pgsql-parser testing. Use when writing database tests, testing RLS policies, seeding test data, or testing with any Constructive test framework." +compatibility: pgsql-test, drizzle-orm-test, supabase-test, Jest/Vitest, PostgreSQL +metadata: + author: constructive-io + version: "2.0.0" +--- + +# Constructive Testing + +All database testing frameworks for Constructive. Each framework builds on `pgsql-test` underneath — they all create isolated test databases with proper teardown. + +## When to Apply + +Use this skill when: +- Writing PostgreSQL integration tests +- Testing RLS policies, permissions, multi-tenant security +- Seeding test data (fixtures, JSON, SQL, CSV) +- Testing with Drizzle ORM or Supabase +- Working in the pgsql-parser repository +- Choosing which test framework to use + +## Which Framework to Use + +| Scenario | Framework | Reference | +|----------|-----------|-----------| +| Raw SQL, RLS policies, database functions | `pgsql-test` | [pgsql-test.md](./references/pgsql-test.md) | +| PostGraphile schema, basic GraphQL queries | `graphile-test` | (part of constructive monorepo) | +| GraphQL with Constructive plugins (search, pgvector, etc.) | `@constructive-io/graphql-test` | (part of constructive monorepo) | +| Dynamic codegen + ORM in tests | `@constructive-io/graphql-test` (`runCodegenAndLoad`) | (part of constructive monorepo) | +| HTTP endpoints, auth headers, middleware | `@constructive-io/graphql-server-test` | (part of constructive monorepo) | +| Type-safe Drizzle ORM tests | `drizzle-orm-test` | [drizzle-orm-test.md](./references/drizzle-orm-test.md) | +| Supabase applications, auth.users | `supabase-test` | [supabase-test.md](./references/supabase-test.md) | +| pgsql-parser repo specifically | pgsql-parser workflow | [pgsql-parser-testing.md](./references/pgsql-parser-testing.md) | + +## Quick Start (pgsql-test) + +```typescript +import { getConnections } from 'pgsql-test'; + +let db, teardown; +beforeAll(async () => ({ db, teardown } = await getConnections())); +afterAll(() => teardown()); +beforeEach(() => db.beforeEach()); +afterEach(() => db.afterEach()); + +test('example', async () => { + db.setContext({ role: 'authenticated', 'jwt.claims.user_id': '123' }); + const result = await db.query('SELECT current_user_id()'); + expect(result.rows[0].current_user_id).toBe('123'); +}); +``` + +## The Testing Framework Hierarchy + +Choose the **highest-level framework** that fits your test scenario: + +``` +┌─────────────────────────────────────────────────────┐ +│ @constructive-io/graphql-server-test │ HTTP-level +│ SuperTest against real Express + PostGraphile │ (full stack) +├─────────────────────────────────────────────────────┤ +│ @constructive-io/graphql-test │ GraphQL + Constructive +│ GraphQL queries with all Constructive plugins │ plugins loaded +├─────────────────────────────────────────────────────┤ +│ graphile-test │ GraphQL schema-level +│ GraphQL queries against PostGraphile schema │ (no HTTP) +├─────────────────────────────────────────────────────┤ +│ pgsql-test │ SQL-level +│ Raw SQL queries, RLS, seeding, snapshots │ (database only) +└─────────────────────────────────────────────────────┘ +``` + +## Critical Rules + +1. **Always include `beforeEach`/`afterEach` hooks** — savepoint-based isolation prevents test state leakage +2. **Never create `new pg.Pool()` or `new pg.Client()` in tests** — use `getConnections()` +3. **Never manually create/drop databases** — the framework handles this +4. **Never skip hooks** — tests will leak state + +## Reference Guide + +### Core Test Framework (pgsql-test) + +| Reference | Topic | Consult When | +|-----------|-------|--------------| +| [pgsql-test.md](./references/pgsql-test.md) | pgsql-test overview and setup | Getting started with PostgreSQL testing | +| [pgsql-test-rls.md](./references/pgsql-test-rls.md) | RLS policy testing | Testing row-level security, user isolation | +| [pgsql-test-seeding.md](./references/pgsql-test-seeding.md) | Test data seeding | loadJson, loadSql, loadCsv fixtures | +| [pgsql-test-exceptions.md](./references/pgsql-test-exceptions.md) | Exception handling | Testing operations that should fail | +| [pgsql-test-snapshot.md](./references/pgsql-test-snapshot.md) | Snapshot testing | pruneIds, pruneDates, deterministic assertions | +| [pgsql-test-helpers.md](./references/pgsql-test-helpers.md) | Helper utilities | Common test helper functions | +| [pgsql-test-jwt-context.md](./references/pgsql-test-jwt-context.md) | JWT context testing | Setting JWT claims, testing authenticated queries | +| [pgsql-test-transactions.md](./references/pgsql-test-transactions.md) | Transaction-local context & roles | `beforeAll` context gotcha, three roles (superuser/administrator/authenticated), `pg` vs `db` | +| [pgsql-test-scenario-setup.md](./references/pgsql-test-scenario-setup.md) | Complex scenario setup | Multi-client scenarios, complex test arrangements | + +### Additional Frameworks + +| Reference | Topic | Consult When | +|-----------|-------|--------------| +| [drizzle-orm-test.md](./references/drizzle-orm-test.md) | Drizzle ORM testing | Type-safe database tests with Drizzle | +| [drizzle-orm.md](./references/drizzle-orm.md) | Drizzle ORM schema patterns | Schema design, query building with Drizzle | +| [supabase-test.md](./references/supabase-test.md) | Supabase testing | Testing Supabase apps, auth.users, anon/authenticated roles | +| [pgsql-parser-testing.md](./references/pgsql-parser-testing.md) | pgsql-parser repo testing | SQL parser/deparser tests, round-trip validation | + +### Test Authoring & CI (from constructive-db) + +| Reference | Topic | Consult When | +|-----------|-------|--------------| +| [references/test-authoring.md](references/test-authoring.md) | Writing lean, readable tests | Choosing presets, test file structure, utility usage | +| [references/ci-test-optimization.md](references/ci-test-optimization.md) | CI/CD speed optimization | Shard balancing, test compression, runner sizing | +| [references/integration-testing.md](references/integration-testing.md) | SQL-first integration tests | pg/db client pattern, RLS testing, multi-actor design | + +## Cross-References + +- `pgpm` — Database migrations (deploy before testing) +- `constructive-setup` — Monorepo setup and local development environment diff --git a/.agents/skills/constructive-testing/references/ci-test-optimization.md b/.agents/skills/constructive-testing/references/ci-test-optimization.md new file mode 100644 index 0000000000..0d0b44dbd4 --- /dev/null +++ b/.agents/skills/constructive-testing/references/ci-test-optimization.md @@ -0,0 +1,97 @@ +# CI/CD & Test Optimization + +## Philosophy + +Test optimization has two complementary axes: +1. **Test compression** — reduce boilerplate so redundancy becomes visible, then eliminate redundancy +2. **CI/CD speed** — minimize wall-clock time through sharding, runner sizing, and provisioning efficiency + +## Phase 1: Pattern Discovery & Utility Abstraction + +### Finding Repeated Patterns + +1. **Grep for raw SQL in test files** — any `SELECT`, `INSERT`, `UPDATE` outside of `test-helpers.ts` is a candidate +2. **Cluster by shape** — group queries by what they do, not by exact SQL +3. **Count occurrences** — patterns appearing 3+ times are high-value abstractions +4. **Check if utility already exists** — scan `test-helpers.ts` first + +### What Makes a Good Test Utility + +Worth creating when: +- Pattern appears in **3+ files** (or 5+ times in one file) +- Involves **multiple steps** that always occur together +- Raw code **obscures test intent** +- Replaces **copy-paste-prone code** + +NOT worth creating when: +- Would **hide `setContext` calls** — these must remain visible +- It's a **one-liner** that's already clear +- Would require **many parameters** that vary across call sites + +### Choosing `db` vs `_dangerouslyBypassRLS` + +- **Use `this.db`** when: the operation is something an application user would do +- **Use `this._dangerouslyBypassRLS`** when: the operation is test setup that bypasses security +- **Rule of thumb:** if the original test code used `pg` (superuser), use `_dangerouslyBypassRLS` + +## Phase 2: Test Compression + +1. Read the file top-to-bottom, noting existing utilities and raw SQL patterns +2. Extract file-level constants for repeated values +3. Replace multi-step sequences with utility calls +4. Keep `setContext` visible +5. Verify semantic equivalence + +## Phase 3: Test Merging & Deletion + +### Merge Candidates + +1. **Same preset** — files can only merge if they use the same `provisionTestDatabase` preset +2. **Same directory** — merge within the same shard regex pattern +3. **Small files** — prioritize files with <5 test cases or <100 lines + +### Merge Benefit + +Each merged file saves ~50-60s of `provisionTestDatabase` time + ~5-10s overhead. + +## Phase 4: CI/CD Speed Optimization + +### Shard Balancing + +**Target:** All shards within 20% of each other. Bottleneck shard < 10 minutes. + +**Split a shard when:** +- Wall-clock exceeds 10 minutes +- Shard has >12 test files +- A single test file takes >120s + +**Merge shards when:** +- A shard has <3 test files +- Total test time < 60s (overhead dominates) + +### Runner Sizing + +Tests are I/O-bound (waiting on PostgreSQL). 8vCPU recommended — diminishing returns past that. + +### Shard Configuration + +Edit `.github/workflows/run-tests.yaml` matrix. The `test_pattern` is a regex matched against test file paths: + +```yaml +- package: packages/metaschema + test_pattern: 'auth/(identity-sign-in|sessions|api-keys)' + shard_name: 'metaschema-auth-1' +``` + +## Database Provisioning Rules + +- Only ONE `provisionTestDatabase` call in `beforeAll` per file +- `beforeEach`/`afterEach` must only do savepoint/rollback +- Choose the **lightest preset** that covers the test's needs + +## Monitoring Checklist + +- [ ] Bottleneck shard < 10 minutes +- [ ] All shards within 30% of each other +- [ ] No `provisionTestDatabase` calls outside `beforeAll` +- [ ] No single-test files that could merge into a neighbor diff --git a/.agents/skills/constructive-testing/references/drizzle-orm-test.md b/.agents/skills/constructive-testing/references/drizzle-orm-test.md new file mode 100644 index 0000000000..b118e674d3 --- /dev/null +++ b/.agents/skills/constructive-testing/references/drizzle-orm-test.md @@ -0,0 +1,409 @@ +--- +name: drizzle-orm-test +description: Test PostgreSQL databases with Drizzle ORM using drizzle-orm-test. Use when asked to "test with Drizzle", "test Drizzle ORM", "write type-safe database tests", or when testing applications using Drizzle ORM. +compatibility: drizzle-orm-test, drizzle-orm, Jest/Vitest, PostgreSQL +metadata: + author: constructive-io + version: "1.0.0" +--- + +# Testing with Drizzle ORM + +Test PostgreSQL databases with Drizzle ORM using drizzle-orm-test. Get type-safe queries, automatic context management, and RLS testing. + +## When to Apply + +Use this skill when: +- Testing applications using Drizzle ORM +- Writing type-safe database tests +- Testing RLS policies with Drizzle +- Migrating from pgsql-test to Drizzle + +## Why drizzle-orm-test? + +drizzle-orm-test is a drop-in replacement for pgsql-test that adds: +- Type-safe queries with Drizzle ORM +- Automatic context management +- Same test isolation patterns +- Compatible with existing pgsql-test workflows + +## Setup + +### Install Dependencies + +```bash +pnpm add -D drizzle-orm-test drizzle-orm +``` + +### Define Drizzle Schema + +Create `src/schema.ts`: + +```typescript +import { pgTable, uuid, text, timestamp } from 'drizzle-orm/pg-core'; + +export const users = pgTable('users', { + id: uuid('id').primaryKey().defaultRandom(), + email: text('email').notNull().unique(), + name: text('name'), + createdAt: timestamp('created_at').defaultNow() +}); + +export const posts = pgTable('posts', { + id: uuid('id').primaryKey().defaultRandom(), + title: text('title').notNull(), + content: text('content'), + ownerId: uuid('owner_id').references(() => users.id), + createdAt: timestamp('created_at').defaultNow() +}); +``` + +## Core Concepts + +### Three Database Clients + +| Client | Purpose | +|--------|---------| +| `pg` | Superuser pgsql-test client (bypasses RLS) | +| `db` | User pgsql-test client (for RLS context) | +| `drizzleDb` | Drizzle ORM client (type-safe queries) | + +### Test Isolation + +Same as pgsql-test: +- `beforeEach()` starts transaction/savepoint +- `afterEach()` rolls back +- Tests are completely isolated + +## Basic Test Structure + +```typescript +import { getConnections, PgTestClient } from 'drizzle-orm-test'; +import { drizzle } from 'drizzle-orm/node-postgres'; +import { users, posts } from '../src/schema'; + +let pg: PgTestClient; +let db: PgTestClient; +let teardown: () => Promise; +let drizzleDb: ReturnType; + +beforeAll(async () => { + ({ pg, db, teardown } = await getConnections()); + + // Create Drizzle client from pg connection + drizzleDb = drizzle(pg.client); +}); + +afterAll(async () => { + await teardown(); +}); + +beforeEach(async () => { + await pg.beforeEach(); + await db.beforeEach(); +}); + +afterEach(async () => { + await db.afterEach(); + await pg.afterEach(); +}); +``` + +## Type-Safe Queries + +### Insert + +```typescript +it('inserts a user with Drizzle', async () => { + const [user] = await drizzleDb + .insert(users) + .values({ + email: 'alice@example.com', + name: 'Alice' + }) + .returning(); + + expect(user.email).toBe('alice@example.com'); + expect(user.name).toBe('Alice'); + expect(user.id).toBeDefined(); +}); +``` + +### Select + +```typescript +it('queries users with Drizzle', async () => { + // Insert test data + await drizzleDb.insert(users).values([ + { email: 'alice@example.com', name: 'Alice' }, + { email: 'bob@example.com', name: 'Bob' } + ]); + + // Query with type safety + const result = await drizzleDb + .select() + .from(users) + .where(eq(users.name, 'Alice')); + + expect(result).toHaveLength(1); + expect(result[0].email).toBe('alice@example.com'); +}); +``` + +### Update + +```typescript +import { eq } from 'drizzle-orm'; + +it('updates a user', async () => { + const [user] = await drizzleDb + .insert(users) + .values({ email: 'alice@example.com', name: 'Alice' }) + .returning(); + + const [updated] = await drizzleDb + .update(users) + .set({ name: 'Alice Smith' }) + .where(eq(users.id, user.id)) + .returning(); + + expect(updated.name).toBe('Alice Smith'); +}); +``` + +### Delete + +```typescript +it('deletes a user', async () => { + const [user] = await drizzleDb + .insert(users) + .values({ email: 'alice@example.com' }) + .returning(); + + await drizzleDb + .delete(users) + .where(eq(users.id, user.id)); + + const result = await drizzleDb + .select() + .from(users) + .where(eq(users.id, user.id)); + + expect(result).toHaveLength(0); +}); +``` + +## Testing RLS with Drizzle + +For RLS testing, use `db.setContext()` with the pgsql-test client, then query with Drizzle: + +```typescript +import { getConnections, PgTestClient } from 'drizzle-orm-test'; +import { drizzle } from 'drizzle-orm/node-postgres'; +import { eq } from 'drizzle-orm'; +import { posts } from '../src/schema'; + +let pg: PgTestClient; +let db: PgTestClient; +let teardown: () => Promise; +let drizzleDb: ReturnType; + +const alice = '550e8400-e29b-41d4-a716-446655440001'; +const bob = '550e8400-e29b-41d4-a716-446655440002'; + +beforeAll(async () => { + ({ pg, db, teardown } = await getConnections()); + + // Create Drizzle client from db connection (respects RLS) + drizzleDb = drizzle(db.client); +}); + +afterAll(async () => { + await teardown(); +}); + +beforeEach(async () => { + await pg.beforeEach(); + await db.beforeEach(); +}); + +afterEach(async () => { + await db.afterEach(); + await pg.afterEach(); +}); + +it('user only sees own posts', async () => { + // Seed as superuser + await pg.loadJson({ + 'posts': [ + { title: 'Alice Post', owner_id: alice }, + { title: 'Bob Post', owner_id: bob } + ] + }); + + // Set context to Alice + db.setContext({ + role: 'authenticated', + 'request.jwt.claim.sub': alice + }); + + // Query with Drizzle - RLS filters results + const result = await drizzleDb + .select() + .from(posts); + + expect(result).toHaveLength(1); + expect(result[0].title).toBe('Alice Post'); +}); +``` + +## Testing INSERT Policies + +```typescript +it('user can insert own post', async () => { + db.setContext({ + role: 'authenticated', + 'request.jwt.claim.sub': alice + }); + + const [post] = await drizzleDb + .insert(posts) + .values({ + title: 'My Post', + ownerId: alice + }) + .returning(); + + expect(post.title).toBe('My Post'); + expect(post.ownerId).toBe(alice); +}); + +it('user cannot insert for another user', async () => { + db.setContext({ + role: 'authenticated', + 'request.jwt.claim.sub': alice + }); + + const point = 'insert_other'; + await db.savepoint(point); + + await expect( + drizzleDb + .insert(posts) + .values({ + title: 'Hacked Post', + ownerId: bob + }) + ).rejects.toThrow(/permission denied|violates row-level security/); + + await db.rollback(point); +}); +``` + +## Testing UPDATE Policies + +```typescript +it('user can update own post', async () => { + // Seed + await pg.loadJson({ + 'posts': [{ id: 'post-1', title: 'Original', owner_id: alice }] + }); + + db.setContext({ + role: 'authenticated', + 'request.jwt.claim.sub': alice + }); + + const [updated] = await drizzleDb + .update(posts) + .set({ title: 'Updated' }) + .where(eq(posts.id, 'post-1')) + .returning(); + + expect(updated.title).toBe('Updated'); +}); + +it('user cannot update other user post', async () => { + await pg.loadJson({ + 'posts': [{ id: 'post-1', title: 'Bob Post', owner_id: bob }] + }); + + db.setContext({ + role: 'authenticated', + 'request.jwt.claim.sub': alice + }); + + // RLS filters - update affects 0 rows + const result = await drizzleDb + .update(posts) + .set({ title: 'Hacked' }) + .where(eq(posts.id, 'post-1')) + .returning(); + + expect(result).toHaveLength(0); +}); +``` + +## Testing DELETE Policies + +```typescript +it('user can delete own post', async () => { + await pg.loadJson({ + 'posts': [{ id: 'post-1', title: 'My Post', owner_id: alice }] + }); + + db.setContext({ + role: 'authenticated', + 'request.jwt.claim.sub': alice + }); + + await drizzleDb + .delete(posts) + .where(eq(posts.id, 'post-1')); + + // Verify as superuser + const result = await pg.query('SELECT * FROM posts WHERE id = $1', ['post-1']); + expect(result.rows).toHaveLength(0); +}); +``` + +## Handling Expected Failures + +Use savepoint pattern with Drizzle: + +```typescript +it('anonymous cannot insert', async () => { + db.setContext({ role: 'anonymous' }); + + const point = 'anon_insert'; + await db.savepoint(point); + + await expect( + drizzleDb + .insert(posts) + .values({ title: 'Hacked' }) + ).rejects.toThrow(/permission denied/); + + await db.rollback(point); +}); +``` + +## Watch Mode + +```bash +pnpm test:watch +``` + +## Best Practices + +1. **Use `pg` for setup**: Bypass RLS when seeding +2. **Use `db` for context**: Set role/user context +3. **Use Drizzle for queries**: Type-safe assertions +4. **Savepoint for failures**: Handle expected errors +5. **Schema in sync**: Keep Drizzle schema matching database + +## References + +- Related skill: `pgsql-test-rls` for RLS testing patterns +- Related skill: `pgsql-test-exceptions` for handling aborted transactions +- Related skill: `pgsql-test-seeding` for seeding strategies diff --git a/.agents/skills/constructive-testing/references/drizzle-orm.md b/.agents/skills/constructive-testing/references/drizzle-orm.md new file mode 100644 index 0000000000..3e87a3940d --- /dev/null +++ b/.agents/skills/constructive-testing/references/drizzle-orm.md @@ -0,0 +1,490 @@ +--- +name: drizzle-orm +description: Drizzle ORM patterns for PostgreSQL schema design and queries. Use when asked to "design Drizzle schema", "write Drizzle queries", "set up Drizzle ORM", or when building type-safe database layers. +compatibility: drizzle-orm, drizzle-kit, PostgreSQL, TypeScript +metadata: + author: constructive-io + version: "1.0.0" +--- + +# Drizzle ORM Patterns + +Design PostgreSQL schemas and write type-safe queries with Drizzle ORM. This skill covers schema design patterns, query building, and integration with the Constructive ecosystem. + +## When to Apply + +Use this skill when: +- Designing database schemas with Drizzle +- Writing type-safe database queries +- Setting up Drizzle ORM in a project +- Integrating Drizzle with pgsql-test or drizzle-orm-test + +## Installation + +```bash +pnpm add drizzle-orm +pnpm add -D drizzle-kit +``` + +## Schema Design + +### Basic Table Definition + +```typescript +import { pgTable, uuid, text, timestamp, boolean, integer } from 'drizzle-orm/pg-core'; + +export const users = pgTable('users', { + id: uuid('id').primaryKey().defaultRandom(), + email: text('email').notNull().unique(), + name: text('name'), + isActive: boolean('is_active').default(true), + createdAt: timestamp('created_at').defaultNow(), + updatedAt: timestamp('updated_at').defaultNow() +}); +``` + +### Foreign Key Relations + +```typescript +import { pgTable, uuid, text, timestamp } from 'drizzle-orm/pg-core'; + +export const users = pgTable('users', { + id: uuid('id').primaryKey().defaultRandom(), + email: text('email').notNull().unique() +}); + +export const posts = pgTable('posts', { + id: uuid('id').primaryKey().defaultRandom(), + title: text('title').notNull(), + content: text('content'), + authorId: uuid('author_id').references(() => users.id).notNull(), + createdAt: timestamp('created_at').defaultNow() +}); + +export const comments = pgTable('comments', { + id: uuid('id').primaryKey().defaultRandom(), + content: text('content').notNull(), + postId: uuid('post_id').references(() => posts.id).notNull(), + authorId: uuid('author_id').references(() => users.id).notNull() +}); +``` + +### Indexes + +```typescript +import { pgTable, uuid, text, index, uniqueIndex } from 'drizzle-orm/pg-core'; + +export const users = pgTable('users', { + id: uuid('id').primaryKey().defaultRandom(), + email: text('email').notNull(), + organizationId: uuid('organization_id').notNull() +}, (table) => [ + uniqueIndex('users_email_idx').on(table.email), + index('users_org_idx').on(table.organizationId) +]); +``` + +### Composite Primary Keys + +```typescript +import { pgTable, uuid, primaryKey } from 'drizzle-orm/pg-core'; + +export const userRoles = pgTable('user_roles', { + userId: uuid('user_id').references(() => users.id).notNull(), + roleId: uuid('role_id').references(() => roles.id).notNull() +}, (table) => [ + primaryKey({ columns: [table.userId, table.roleId] }) +]); +``` + +### Enums + +```typescript +import { pgTable, uuid, pgEnum } from 'drizzle-orm/pg-core'; + +export const statusEnum = pgEnum('status', ['pending', 'active', 'archived']); + +export const projects = pgTable('projects', { + id: uuid('id').primaryKey().defaultRandom(), + name: text('name').notNull(), + status: statusEnum('status').default('pending') +}); +``` + +### JSON Columns + +```typescript +import { pgTable, uuid, jsonb } from 'drizzle-orm/pg-core'; + +export const settings = pgTable('settings', { + id: uuid('id').primaryKey().defaultRandom(), + userId: uuid('user_id').references(() => users.id).notNull(), + preferences: jsonb('preferences').$type<{ + theme: 'light' | 'dark'; + notifications: boolean; + }>() +}); +``` + +## Query Patterns + +### Setup Client + +```typescript +import { drizzle } from 'drizzle-orm/node-postgres'; +import { Pool } from 'pg'; +import * as schema from './schema'; + +const pool = new Pool({ + connectionString: process.env.DATABASE_URL +}); + +export const db = drizzle(pool, { schema }); +``` + +### Select Queries + +```typescript +import { eq, and, or, like, gt, lt, isNull, inArray } from 'drizzle-orm'; +import { users, posts } from './schema'; + +// Select all +const allUsers = await db.select().from(users); + +// Select with where +const activeUsers = await db + .select() + .from(users) + .where(eq(users.isActive, true)); + +// Select specific columns +const userEmails = await db + .select({ email: users.email, name: users.name }) + .from(users); + +// Multiple conditions +const filteredUsers = await db + .select() + .from(users) + .where(and( + eq(users.isActive, true), + like(users.email, '%@example.com') + )); + +// OR conditions +const result = await db + .select() + .from(users) + .where(or( + eq(users.name, 'Alice'), + eq(users.name, 'Bob') + )); + +// IN clause +const specificUsers = await db + .select() + .from(users) + .where(inArray(users.id, ['id1', 'id2', 'id3'])); + +// NULL checks +const usersWithoutName = await db + .select() + .from(users) + .where(isNull(users.name)); +``` + +### Insert Queries + +```typescript +// Single insert +const [newUser] = await db + .insert(users) + .values({ + email: 'alice@example.com', + name: 'Alice' + }) + .returning(); + +// Multiple insert +const newUsers = await db + .insert(users) + .values([ + { email: 'alice@example.com', name: 'Alice' }, + { email: 'bob@example.com', name: 'Bob' } + ]) + .returning(); + +// Insert with conflict handling +await db + .insert(users) + .values({ email: 'alice@example.com', name: 'Alice' }) + .onConflictDoNothing(); + +// Upsert +await db + .insert(users) + .values({ email: 'alice@example.com', name: 'Alice' }) + .onConflictDoUpdate({ + target: users.email, + set: { name: 'Alice Updated' } + }); +``` + +### Update Queries + +```typescript +// Update with where +const [updated] = await db + .update(users) + .set({ name: 'Alice Smith' }) + .where(eq(users.id, userId)) + .returning(); + +// Update multiple fields +await db + .update(users) + .set({ + name: 'Alice Smith', + updatedAt: new Date() + }) + .where(eq(users.id, userId)); +``` + +### Delete Queries + +```typescript +// Delete with where +await db + .delete(users) + .where(eq(users.id, userId)); + +// Delete with returning +const [deleted] = await db + .delete(users) + .where(eq(users.id, userId)) + .returning(); +``` + +### Joins + +```typescript +// Inner join +const postsWithAuthors = await db + .select({ + postTitle: posts.title, + authorName: users.name + }) + .from(posts) + .innerJoin(users, eq(posts.authorId, users.id)); + +// Left join +const usersWithPosts = await db + .select({ + userName: users.name, + postTitle: posts.title + }) + .from(users) + .leftJoin(posts, eq(users.id, posts.authorId)); +``` + +### Relational Queries + +With schema relations defined: + +```typescript +import { relations } from 'drizzle-orm'; + +export const usersRelations = relations(users, ({ many }) => ({ + posts: many(posts) +})); + +export const postsRelations = relations(posts, ({ one, many }) => ({ + author: one(users, { + fields: [posts.authorId], + references: [users.id] + }), + comments: many(comments) +})); +``` + +Query with relations: + +```typescript +// Fetch users with their posts +const usersWithPosts = await db.query.users.findMany({ + with: { + posts: true + } +}); + +// Nested relations +const usersWithPostsAndComments = await db.query.users.findMany({ + with: { + posts: { + with: { + comments: true + } + } + } +}); + +// Selective columns with relations +const result = await db.query.users.findMany({ + columns: { + id: true, + name: true + }, + with: { + posts: { + columns: { + title: true + } + } + } +}); +``` + +### Aggregations + +```typescript +import { count, sum, avg, max, min } from 'drizzle-orm'; + +// Count +const [{ total }] = await db + .select({ total: count() }) + .from(users); + +// Count with condition +const [{ activeCount }] = await db + .select({ activeCount: count() }) + .from(users) + .where(eq(users.isActive, true)); + +// Group by +const postCounts = await db + .select({ + authorId: posts.authorId, + postCount: count() + }) + .from(posts) + .groupBy(posts.authorId); +``` + +### Ordering and Pagination + +```typescript +import { desc, asc } from 'drizzle-orm'; + +// Order by +const sortedUsers = await db + .select() + .from(users) + .orderBy(desc(users.createdAt)); + +// Multiple order columns +const sorted = await db + .select() + .from(users) + .orderBy(asc(users.name), desc(users.createdAt)); + +// Pagination +const page = await db + .select() + .from(users) + .limit(10) + .offset(20); +``` + +### Transactions + +```typescript +await db.transaction(async (tx) => { + const [user] = await tx + .insert(users) + .values({ email: 'alice@example.com' }) + .returning(); + + await tx + .insert(posts) + .values({ + title: 'First Post', + authorId: user.id + }); +}); +``` + +## Integration with pgsql-test + +```typescript +import { getConnections, PgTestClient } from 'drizzle-orm-test'; +import { drizzle } from 'drizzle-orm/node-postgres'; +import * as schema from './schema'; + +let pg: PgTestClient; +let db: ReturnType; +let teardown: () => Promise; + +beforeAll(async () => { + ({ pg, teardown } = await getConnections()); + db = drizzle(pg.client, { schema }); +}); + +afterAll(async () => { + await teardown(); +}); + +beforeEach(async () => { + await pg.beforeEach(); +}); + +afterEach(async () => { + await pg.afterEach(); +}); + +it('creates a user', async () => { + const [user] = await db + .insert(schema.users) + .values({ email: 'test@example.com' }) + .returning(); + + expect(user.email).toBe('test@example.com'); +}); +``` + +## Schema Organization + +For larger projects, organize schemas by domain: + +``` +src/ + db/ + schema/ + index.ts # Re-exports all schemas + users.ts # User-related tables + posts.ts # Post-related tables + relations.ts # All relations + client.ts # Drizzle client setup +``` + +```typescript +// src/db/schema/index.ts +export * from './users'; +export * from './posts'; +export * from './relations'; +``` + +## Best Practices + +1. **Use UUID primary keys**: `uuid('id').primaryKey().defaultRandom()` +2. **Add timestamps**: Include `createdAt` and `updatedAt` on most tables +3. **Define relations**: Enable relational queries with `relations()` +4. **Type JSON columns**: Use `.$type()` for type-safe JSON +5. **Index foreign keys**: Add indexes on frequently queried foreign keys +6. **Use transactions**: Wrap related operations in transactions +7. **Return inserted/updated rows**: Use `.returning()` to get results + +## References + +- Related skill: `drizzle-orm-test` for testing with Drizzle +- Related skill: `pgsql-test-snapshot` for snapshot testing +- Related skill: `pgsql-test-rls` for RLS testing with Drizzle diff --git a/.agents/skills/constructive-testing/references/integration-testing.md b/.agents/skills/constructive-testing/references/integration-testing.md new file mode 100644 index 0000000000..68658a9302 --- /dev/null +++ b/.agents/skills/constructive-testing/references/integration-testing.md @@ -0,0 +1,107 @@ +# Integration Testing in constructive-db + +## The Two Clients: `pg` vs `db` + +Every integration test gets two database clients from `pgsql-test`: + +```typescript +import { getConnections, PgTestClient } from 'pgsql-test'; + +let db: PgTestClient; // RLS-enforced (authenticated role) +let pg: PgTestClient; // Superuser (bypasses RLS) +let teardown: () => Promise; + +beforeAll(async () => { + ({ db, pg, teardown } = await getConnections()); +}); +afterAll(() => teardown()); +``` + +### The Cardinal Rule + +> **`pg` is ONLY used in `beforeAll` for bootstrap/DDL/catalog queries. `db` is used for ALL data operations and test queries.** + +- `pg` (superuser) bypasses RLS entirely. Use it **only** for: creating test users, provisioning databases, DDL operations, and read-only catalog queries. +- `db` (authenticated or administrator role) enforces triggers and FK constraints. Use it for all data operations. +- Never use `pg` inside `it()` blocks for queries you're testing. + +### The Three Roles + +| Role | Client | Bypasses RLS? | When to use | +|------|--------|---------------|-------------| +| **superuser** | `pg` | Yes | Bootstrap only: `createTestUser`, `provisionDatabase`, DDL | +| **administrator** | `db` with `setContext({ role: 'administrator' })` | Effectively yes | Elevated data operations: adding members, creating buckets | +| **authenticated** | `db` with `setContext({ role: 'authenticated', ... })` | No (full RLS) | All test queries — this is what real users get | + +### Cross-Connection Visibility + +> **Data written by `db` inside a per-test savepoint is invisible to `pg` (separate connection).** + +```typescript +// WRONG — pg can't see db's savepoint data +const row = await pg.one(`SELECT ... WHERE actor_id = $1`, [user.user_id]); + +// CORRECT — use db with administrator role +db.setContext({ role: 'administrator' }); +const row = await db.one(`SELECT ... WHERE actor_id = $1`, [user.user_id]); +``` + +### Cross-Connection Deadlock: NEVER mix `pg` and `db` for the same rows + +If a test body needs to seed data AND act on it, ALL operations must go through `db`: + +```typescript +// WRONG — deadlocks: pg holds lock, db blocks waiting +it('test', async () => { + await limits.insert({ ... }); // pg + db.setContext({ role: 'authenticated', ... }); + const ok = await limits_as_user.increment(...); // db → DEADLOCK +}); + +// CORRECT — single connection +it('test', async () => { + db.setContext({ role: 'administrator' }); + await limits_as_user.insert({ ... }); // db + db.setContext({ role: 'authenticated', ... }); + const ok = await limits_as_user.increment(...); // db → works +}); +``` + +**When is `pg` safe?** Only in `beforeAll`, where there are no savepoints yet. + +## Test File Structure + +```typescript +jest.setTimeout(300000); +process.env.LOG_SCOPE = 'pgsql-test'; + +import { getConnections, PgTestClient } from 'pgsql-test'; + +const ALICE_ID = '00000000-0000-0000-0000-00000000a001'; +const BOB_ID = '00000000-0000-0000-0000-00000000b002'; + +let db: PgTestClient; +let pg: PgTestClient; +let teardown: () => Promise; + +beforeAll(async () => { + ({ db, pg, teardown } = await getConnections()); + // 1. Bootstrap — pg (superuser, auto-commits) + // 2. Set membership defaults — pg + // 3. Resolve schema/table names — pg (read-only) + // 4. Add members — db as administrator (triggers populate SPRT) +}); + +afterAll(() => teardown()); +beforeEach(async () => { await db.beforeEach(); }); +afterEach(async () => { await db.afterEach(); }); +``` + +## Setting Actor Context + +```typescript +db.setContext({ role: 'authenticated', 'jwt.claims.user_id': ALICE_ID }); +const rows = await db.any(`SELECT * FROM "${schema}"."${table}" WHERE owner_id = $1`, [ALICE_ID]); +``` + +Some tests also use `db.auth({ userId: ALICE_ID })` as a shorthand. diff --git a/.agents/skills/constructive-testing/references/pgsql-parser-testing.md b/.agents/skills/constructive-testing/references/pgsql-parser-testing.md new file mode 100644 index 0000000000..d973f61050 --- /dev/null +++ b/.agents/skills/constructive-testing/references/pgsql-parser-testing.md @@ -0,0 +1,223 @@ +--- +name: pgsql-parser-testing +description: Test the pgsql-parser repository (SQL parser/deparser). Use when working in the pgsql-parser repo, fixing deparser issues, running parser tests, or validating SQL round-trips. Scoped specifically to the constructive-io/pgsql-parser repository. +compatibility: Node.js 18+, pnpm, pgsql-parser repository +metadata: + author: constructive-io + version: "1.0.0" + scope: constructive-io/pgsql-parser +--- + +# PGSQL Parser Testing + +Testing workflow for the pgsql-parser repository. This skill is scoped specifically to the `constructive-io/pgsql-parser` monorepo. + +## When to Apply + +Use this skill when: +- Working in the pgsql-parser repository +- Fixing deparser or parser issues +- Running parser/deparser tests +- Validating SQL round-trip correctness +- Adding new SQL syntax support + +## Repository Structure + +``` +pgsql-parser/ + packages/ + parser/ # SQL parser (libpg_query bindings) + deparser/ # SQL deparser (AST to SQL) + plpgsql-parser/ # PL/pgSQL parser + plpgsql-deparser/ # PL/pgSQL deparser + types/ # TypeScript type definitions + utils/ # Utility functions + traverse/ # AST traversal utilities + transform/ # AST transformation utilities +``` + +## Testing Strategy + +The pgsql-parser uses AST-level equality for correctness, not string equality: + +``` +parse(sql1) → ast1 → deparse(ast1) → sql2 → parse(sql2) → ast2 +``` + +While `sql2 !== sql1` textually, a correct round-trip means `ast1 === ast2`. + +### Key Principle + +Exact SQL string equality is not required. The focus is on comparing resulting ASTs. Use `expectAstMatch` (deparser) or `expectPGParse` (ast package) to validate correctness. + +## Development Workflow + +### Initial Setup + +```bash +pnpm install +pnpm build +``` + +### Running Tests + +Run all tests: +```bash +pnpm test +``` + +Run tests for a specific package: +```bash +cd packages/deparser +pnpm test +``` + +Watch mode for rapid iteration: +```bash +cd packages/deparser +pnpm test:watch +``` + +Run a specific test: +```bash +pnpm test --testNamePattern="specific-test-name" +``` + +## Fixing Deparser Issues + +### Systematic Approach + +1. **One test at a time**: Focus on individual failing tests + ```bash + pnpm test --testNamePattern="specific-test" + ``` + +2. **Always check for regressions**: After each fix, run full test suite + ```bash + pnpm test + ``` + +3. **Build before testing**: Always rebuild after code changes + ```bash + pnpm build && pnpm test + ``` + +4. **Clean commits**: Stage files explicitly + ```bash + git add packages/deparser/src/specific-file.ts + ``` + +### Workflow Loop + +``` +Make changes → pnpm build → pnpm test --testNamePattern="target" → pnpm test (full) → commit +``` + +## Test Utilities + +### Deparser Tests + +Location: `packages/deparser/test-utils/index.ts` + +```typescript +import { expectAstMatch } from '../test-utils'; + +it('deparses SELECT correctly', () => { + expectAstMatch('SELECT * FROM users'); +}); +``` + +### AST Package Tests + +Location: `packages/ast/test/utils/index.ts` + +Uses database deparser for validation: +```typescript +import { expectPGParse } from '../test/utils'; + +it('round-trips through database deparser', async () => { + await expectPGParse('SELECT * FROM users WHERE id = 1'); +}); +``` + +Note: AST tests require the database to have `deparser.expressions_array` function available. + +## Common Commands + +| Command | Description | +|---------|-------------| +| `pnpm build` | Build all packages | +| `pnpm test` | Run all tests | +| `pnpm test:watch` | Run tests in watch mode | +| `pnpm lint` | Run linter | +| `pnpm clean` | Clean build artifacts | + +## Package-Specific Testing + +### Parser Package + +Tests libpg_query bindings and SQL parsing: +```bash +cd packages/parser +pnpm test +``` + +### Deparser Package + +Tests AST-to-SQL conversion: +```bash +cd packages/deparser +pnpm test +``` + +### PL/pgSQL Packages + +Tests PL/pgSQL parsing and deparsing: +```bash +cd packages/plpgsql-parser +pnpm test + +cd packages/plpgsql-deparser +pnpm test +``` + +## Debugging Tips + +1. **Use isolated debug scripts** for complex issues (don't commit them) + +2. **Check the AST structure** when tests fail: + ```typescript + import { parse } from 'pgsql-parser'; + console.log(JSON.stringify(parse('SELECT 1'), null, 2)); + ``` + +3. **Compare ASTs visually** to understand differences: + ```typescript + const ast1 = parse(sql1); + const ast2 = parse(deparse(ast1)); + console.log('Original:', JSON.stringify(ast1, null, 2)); + console.log('Round-trip:', JSON.stringify(ast2, null, 2)); + ``` + +## Troubleshooting + +| Issue | Solution | +|-------|----------| +| Tests fail after changes | Run `pnpm build` before `pnpm test` | +| Type errors | Check `packages/types` for type definitions | +| Shared code changes | Rebuild dependent packages | +| Snapshot mismatches | Review changes, update with `pnpm test -u` if correct | + +## Important Notes + +- Changes to `types` or `utils` packages may require rebuilding dependent packages +- Each package can be developed and tested independently +- The project uses Lerna for monorepo management +- Always verify no regressions before committing + +## References + +- Deparser testing docs: `packages/deparser/TESTING.md` +- Quoting rules: `packages/deparser/QUOTING-RULES.md` +- Deparser usage: `packages/deparser/DEPARSER_USAGE.md` +- PL/pgSQL deparser: `packages/plpgsql-deparser/AGENTS.md` diff --git a/.agents/skills/constructive-testing/references/pgsql-test-exceptions.md b/.agents/skills/constructive-testing/references/pgsql-test-exceptions.md new file mode 100644 index 0000000000..c8a3977836 --- /dev/null +++ b/.agents/skills/constructive-testing/references/pgsql-test-exceptions.md @@ -0,0 +1,222 @@ +--- +name: pgsql-test-exceptions +description: Handle PostgreSQL aborted transactions when testing operations that should fail. Use when testing RLS policy violations, constraint errors, permission denied errors, or any expected database exceptions. Essential for security testing. +compatibility: Node.js 18+, pgsql-test package, PostgreSQL +metadata: + author: constructive-io + version: "1.0.0" +--- + +# Testing Exceptions and Aborted Transactions + +Handle PostgreSQL's transaction abort behavior when testing operations that should fail. This is essential for security testing where you verify that unauthorized operations are rejected. + +## When to Apply + +Use this skill when: +- Testing RLS policy violations (user can't access other users' data) +- Testing constraint violations (unique, foreign key, check) +- Testing permission denied errors +- Testing any operation that should throw an error +- Verifying database state after a failed operation + +## The Problem + +When PostgreSQL encounters an error inside a transaction, it aborts the entire transaction. The connection rejects all further commands until you explicitly end the transaction: + +``` +current transaction is aborted, commands ignored until end of transaction block +``` + +This breaks naive exception testing: + +```typescript +// THIS WILL FAIL +it('users cannot insert pets for other users', async () => { + db.setContext({ + role: 'authenticated', + 'jwt.claims.user_id': bob + }); + + await expect( + db.query(`INSERT INTO pets (name, owner_id) VALUES ('Fake', $1)`, [alice]) + ).rejects.toThrow(/violates row-level security/); + + // ERROR: transaction is aborted, this query fails! + const count = await db.query(`SELECT COUNT(*) FROM pets WHERE owner_id = $1`, [alice]); +}); +``` + +## The Solution: Savepoints + +Create a savepoint before the failing operation, then roll back to it: + +```typescript +it('users cannot insert pets for other users', async () => { + db.setContext({ + role: 'authenticated', + 'jwt.claims.user_id': bob + }); + + // 1. Create savepoint before expected failure + await db.savepoint('insert_attempt'); + + // 2. Test the operation that should fail + await expect( + db.query(`INSERT INTO pets (name, owner_id) VALUES ('Fake', $1)`, [alice]) + ).rejects.toThrow(/violates row-level security/); + + // 3. Roll back to clear the error state + await db.rollback('insert_attempt'); + + // 4. Continue with verification queries + const count = await db.query(`SELECT COUNT(*) FROM pets WHERE owner_id = $1`, [alice]); + expect(parseInt(count.rows[0].count)).toBe(2); +}); +``` + +## Pattern Template + +```typescript +// 1. Create savepoint +await db.savepoint('my_savepoint_name'); + +// 2. Execute operation that should fail +await expect( + db.query(`...`) +).rejects.toThrow(/expected error pattern/); + +// 3. Roll back to savepoint +await db.rollback('my_savepoint_name'); + +// 4. Continue with additional queries +const result = await db.query(`...`); +``` + +## Common Scenarios + +### RLS Policy Violations + +```typescript +it('users cannot modify other users data', async () => { + db.setContext({ role: 'authenticated', 'jwt.claims.user_id': bob }); + + await db.savepoint('update_attempt'); + await expect( + db.query(`UPDATE items SET name = 'stolen' WHERE owner_id = $1`, [alice]) + ).rejects.toThrow(/violates row-level security/); + await db.rollback('update_attempt'); + + // Verify data unchanged + db.setContext({ role: 'authenticated', 'jwt.claims.user_id': alice }); + const item = await db.query(`SELECT name FROM items WHERE owner_id = $1`, [alice]); + expect(item.rows[0].name).toBe('original'); +}); +``` + +### Permission Denied + +```typescript +it('anonymous users cannot modify data', async () => { + db.setContext({ role: 'anonymous' }); + + await db.savepoint('anon_insert'); + await expect( + db.query(`INSERT INTO users (name) VALUES ('hacker')`) + ).rejects.toThrow(/permission denied/); + await db.rollback('anon_insert'); + + await db.savepoint('anon_update'); + await expect( + db.query(`UPDATE users SET name = 'hacked'`) + ).rejects.toThrow(/permission denied/); + await db.rollback('anon_update'); + + await db.savepoint('anon_delete'); + await expect( + db.query(`DELETE FROM users`) + ).rejects.toThrow(/permission denied/); + await db.rollback('anon_delete'); +}); +``` + +### Constraint Violations + +```typescript +it('rejects duplicate emails', async () => { + await db.query(`INSERT INTO users (email) VALUES ('test@example.com')`); + + await db.savepoint('duplicate_email'); + await expect( + db.query(`INSERT INTO users (email) VALUES ('test@example.com')`) + ).rejects.toThrow(/duplicate key value violates unique constraint/); + await db.rollback('duplicate_email'); + + // Verify only one user exists + const count = await db.query(`SELECT COUNT(*) FROM users`); + expect(parseInt(count.rows[0].count)).toBe(1); +}); +``` + +### PLPGSQL Function Validation + +```typescript +describe('plpgsql_expr', () => { + it('rejects NULL query', async () => { + await db.savepoint('null_query'); + await expect( + db.any(`SELECT my_function(NULL)`) + ).rejects.toThrow('query cannot be NULL'); + await db.rollback('null_query'); + }); + + it('rejects invalid input', async () => { + await db.savepoint('invalid_input'); + await expect( + db.any(`SELECT my_function('{"invalid": true}'::jsonb)`) + ).rejects.toThrow('invalid input format'); + await db.rollback('invalid_input'); + }); +}); +``` + +### Multiple Failure Tests in Sequence + +```typescript +it('validates all input constraints', async () => { + // Test 1: null name + await db.savepoint('null_name'); + await expect( + db.query(`INSERT INTO products (name, price) VALUES (NULL, 10)`) + ).rejects.toThrow(/null value in column "name"/); + await db.rollback('null_name'); + + // Test 2: negative price + await db.savepoint('negative_price'); + await expect( + db.query(`INSERT INTO products (name, price) VALUES ('item', -5)`) + ).rejects.toThrow(/violates check constraint/); + await db.rollback('negative_price'); + + // Test 3: valid insert works + await db.query(`INSERT INTO products (name, price) VALUES ('item', 10)`); + const result = await db.query(`SELECT * FROM products`); + expect(result.rows).toHaveLength(1); +}); +``` + +## Key Rules + +1. **Always use unique savepoint names** - Avoid conflicts between tests +2. **Roll back immediately after the expected failure** - Before any other queries +3. **Use descriptive savepoint names** - Makes debugging easier +4. **Each failure needs its own savepoint** - Can't reuse savepoints after rollback + +## Why This Matters + +Without savepoints, you cannot verify database state after a failed operation. That verification is often the most important part of a security test - confirming that the malicious operation had no effect. + +## References + +- Related skill: `pgpm` (`references/testing.md`) for general test setup +- Related skill: `pgpm` (`references/env.md`) for environment configuration diff --git a/.agents/skills/constructive-testing/references/pgsql-test-helpers.md b/.agents/skills/constructive-testing/references/pgsql-test-helpers.md new file mode 100644 index 0000000000..26083d5415 --- /dev/null +++ b/.agents/skills/constructive-testing/references/pgsql-test-helpers.md @@ -0,0 +1,345 @@ +--- +name: pgsql-test-helpers +description: Creating reusable test helper functions and constants for consistent, maintainable database tests. Use when extracting common test patterns into helpers, defining shared constants, or reducing duplication in pgsql-test suites. +--- + +Creating reusable test helper functions and constants for consistent, maintainable database tests. + +## Overview + +As test suites grow, common patterns emerge: creating users, setting up contexts, querying specific tables. Extracting these into helper functions improves readability, reduces duplication, and makes tests more maintainable. + +## Predefined Constants + +### Test User IDs + +Use predefined UUIDs for test users to ensure consistency across tests: + +```typescript +export const TEST_USER_IDS = { + USER_1: '00000000-0000-0000-0000-000000000001', + USER_2: '00000000-0000-0000-0000-000000000002', + USER_3: '00000000-0000-0000-0000-000000000003', + ADMIN: '00000000-0000-0000-0000-000000000099', +} as const; +``` + +Benefits: +- Easy to identify in database queries and logs +- Consistent across all test files +- Type-safe with `as const` + +### Scope/Role Constants + +```typescript +export const ROLES = { + ANONYMOUS: 'anonymous', + AUTHENTICATED: 'authenticated', + SERVICE: 'service_role', +} as const; + +export const SCOPE = { + APP: 1, + ORG: 2, + GROUP: 3, +} as const; +``` + +## TypeScript Interfaces for Options + +Define interfaces for helper function parameters: + +```typescript +export interface CreateUserOptions { + id: string; + username?: string; + display_name?: string; + email?: string; + is_admin?: boolean; +} + +export interface CreateOrganizationOptions { + name: string; + owner_id: string; +} + +export interface AddMemberOptions { + user_id: string; + org_id: string; + role?: 'member' | 'admin' | 'owner'; +} +``` + +Benefits: +- Self-documenting function signatures +- IDE autocomplete support +- Compile-time validation + +## Helper Function Patterns + +### Creating Test Users + +```typescript +import { PgTestClient } from 'pgsql-test'; + +export async function createTestUser( + pg: PgTestClient, + options: CreateUserOptions +): Promise { + const { + id, + username = `user_${id.slice(0, 8)}`, + display_name = 'Test User', + email, + is_admin = false, + } = options; + + const columns = ['id', 'username', 'display_name', 'is_admin']; + const values: unknown[] = [id, username, display_name, is_admin]; + const placeholders = ['$1', '$2', '$3', '$4']; + + if (email !== undefined) { + columns.push('email'); + values.push(email); + placeholders.push(`$${values.length}`); + } + + await pg.query( + `INSERT INTO users (${columns.join(', ')}) + VALUES (${placeholders.join(', ')}) + ON CONFLICT (id) DO NOTHING`, + values + ); +} +``` + +### Creating Organizations + +```typescript +export async function createOrganization( + client: PgTestClient, + options: CreateOrganizationOptions +): Promise { + const { name, owner_id } = options; + + const result = await client.one<{ id: string }>( + `INSERT INTO organizations (name, owner_id) + VALUES ($1, $2) + RETURNING id`, + [name, owner_id] + ); + + return result.id; +} +``` + +### Querying with Type Safety + +```typescript +export interface UserRecord { + id: string; + username: string; + display_name: string; + is_admin: boolean; + created_at: Date; +} + +export async function getUserById( + client: PgTestClient, + userId: string +): Promise { + return client.oneOrNone( + `SELECT id, username, display_name, is_admin, created_at + FROM users WHERE id = $1`, + [userId] + ); +} + +export async function getUsersByOrg( + client: PgTestClient, + orgId: string +): Promise { + return client.any( + `SELECT u.id, u.username, u.display_name, u.is_admin, u.created_at + FROM users u + JOIN memberships m ON m.user_id = u.id + WHERE m.org_id = $1`, + [orgId] + ); +} +``` + +## Unique Name Generation + +For avoiding collisions in parallel tests: + +```typescript +export function uniqueName(prefix: string): string { + return `${prefix}-${Date.now()}`; +} + +export function uniqueEmail(prefix: string = 'test'): string { + return `${prefix}-${Date.now()}@example.com`; +} +``` + +Usage: + +```typescript +const orgName = uniqueName('test-org'); // 'test-org-1706123456789' +const email = uniqueEmail('alice'); // 'alice-1706123456789@example.com' +``` + +## Context Helper Functions + +### Setting Up Authenticated Context + +```typescript +export function setAuthContext( + db: PgTestClient, + userId: string, + additionalClaims?: Record +): void { + db.setContext({ + role: 'authenticated', + 'jwt.claims.user_id': userId, + ...additionalClaims, + }); +} + +export function setOrgContext( + db: PgTestClient, + userId: string, + orgId: string +): void { + db.setContext({ + role: 'authenticated', + 'jwt.claims.user_id': userId, + 'jwt.claims.org_id': orgId, + }); +} +``` + +Usage: + +```typescript +it('user can access their data', async () => { + setAuthContext(db, TEST_USER_IDS.USER_1); + const data = await db.any('SELECT * FROM my_table'); + expect(data.length).toBeGreaterThan(0); +}); +``` + +## Organizing Test Utils + +### File Structure + +``` +__tests__/ + test-utils/ + index.ts # Re-exports everything + constants.ts # TEST_USER_IDS, ROLES, etc. + interfaces.ts # TypeScript interfaces + user-helpers.ts # User-related helpers + org-helpers.ts # Organization helpers + context-helpers.ts # Context/auth helpers +``` + +### index.ts + +```typescript +export * from './constants'; +export * from './interfaces'; +export * from './user-helpers'; +export * from './org-helpers'; +export * from './context-helpers'; +``` + +### Usage in Tests + +```typescript +import { + TEST_USER_IDS, + createTestUser, + createOrganization, + setAuthContext, +} from '../test-utils'; + +describe('Organization tests', () => { + beforeAll(async () => { + await createTestUser(pg, { id: TEST_USER_IDS.USER_1 }); + }); + + it('creates organization', async () => { + setAuthContext(db, TEST_USER_IDS.USER_1); + const orgId = await createOrganization(db, { + name: 'Test Org', + owner_id: TEST_USER_IDS.USER_1, + }); + expect(orgId).toBeDefined(); + }); +}); +``` + +## Assertion Helpers + +### Expecting Specific Counts + +```typescript +export async function expectRowCount( + client: PgTestClient, + table: string, + expectedCount: number, + where?: string, + values?: unknown[] +): Promise { + const whereClause = where ? ` WHERE ${where}` : ''; + const result = await client.one<{ count: string }>( + `SELECT COUNT(*) FROM ${table}${whereClause}`, + values + ); + expect(parseInt(result.count)).toBe(expectedCount); +} +``` + +Usage: + +```typescript +await expectRowCount(db, 'users', 2); +await expectRowCount(db, 'memberships', 1, 'org_id = $1', [orgId]); +``` + +### Expecting Access Denied + +```typescript +export async function expectAccessDenied( + client: PgTestClient, + query: string, + values?: unknown[] +): Promise { + const result = await client.any(query, values); + expect(result.length).toBe(0); +} + +export async function expectQueryError( + client: PgTestClient, + query: string, + values?: unknown[], + errorPattern?: RegExp +): Promise { + await expect(client.query(query, values)).rejects.toThrow(errorPattern); +} +``` + +## Best Practices + +1. Keep helpers focused and single-purpose +2. Use TypeScript interfaces for all option objects +3. Provide sensible defaults for optional parameters +4. Use `ON CONFLICT DO NOTHING` for idempotent user creation +5. Return IDs from creation helpers for use in subsequent operations +6. Group related helpers in separate files +7. Re-export everything from a central index.ts +8. Use predefined constants instead of magic strings/UUIDs +9. Document complex helpers with JSDoc comments +10. Keep helpers in a dedicated test-utils directory diff --git a/.agents/skills/constructive-testing/references/pgsql-test-jwt-context.md b/.agents/skills/constructive-testing/references/pgsql-test-jwt-context.md new file mode 100644 index 0000000000..d08050e1ff --- /dev/null +++ b/.agents/skills/constructive-testing/references/pgsql-test-jwt-context.md @@ -0,0 +1,211 @@ +--- +name: pgsql-test-jwt-context +description: Setting up JWT claims and role-based context for RLS testing with pgsql-test. Use when testing Row-Level Security policies, simulating authenticated users with JWT claims, or configuring PostgreSQL session variables for RLS. +--- + +Setting up JWT claims and role-based context for RLS testing with pgsql-test. + +## Overview + +When testing Row-Level Security (RLS) policies, you need to simulate authenticated users with JWT claims. The `pgsql-test` library provides the `setContext()` method to configure PostgreSQL session variables that RLS policies can read. + +## The setContext API + +Use `setContext()` to simulate different user roles and JWT claims: + +```typescript +db.setContext({ + role: 'authenticated', + 'jwt.claims.user_id': '00000000-0000-0000-0000-000000000001', + 'jwt.claims.org_id': 'acme-corp' +}); +``` + +This applies settings using `SET LOCAL` statements, ensuring they persist only for the current transaction and maintain proper isolation between tests. + +## How It Works Internally + +The `setContext()` method generates SQL statements: + +```sql +-- For the 'role' key, uses SET LOCAL ROLE +SET LOCAL ROLE "authenticated"; + +-- For other keys, uses set_config() with transaction-local scope +SELECT set_config('jwt.claims.user_id', '00000000-0000-0000-0000-000000000001', true); +SELECT set_config('jwt.claims.org_id', 'acme-corp', true); +``` + +The third parameter `true` in `set_config()` makes the setting transaction-local, which is essential for test isolation. + +## The auth() Helper Method + +For common authentication patterns, use the `auth()` helper: + +```typescript +// Simple authenticated user +db.auth({ + role: 'authenticated', + userId: '00000000-0000-0000-0000-000000000001' +}); + +// Custom user ID key +db.auth({ + role: 'authenticated', + userId: '123', + userIdKey: 'request.jwt.claims.sub' +}); +``` + +## Common JWT Claim Patterns + +### User Authentication + +```typescript +db.setContext({ + role: 'authenticated', + 'jwt.claims.user_id': userId +}); +``` + +### Organization Context + +```typescript +db.setContext({ + role: 'authenticated', + 'jwt.claims.user_id': userId, + 'jwt.claims.org_id': orgId +}); +``` + +### Database Context + +```typescript +db.setContext({ + role: 'authenticated', + 'jwt.claims.user_id': userId, + 'jwt.claims.database_id': databaseId +}); +``` + +### Additional Claims + +```typescript +db.setContext({ + role: 'authenticated', + 'jwt.claims.user_id': userId, + 'jwt.claims.user_agent': 'Mozilla/5.0...', + 'jwt.claims.ip_address': '127.0.0.1' +}); +``` + +## Reading Claims in SQL + +Your RLS policies can read these claims using `current_setting()`: + +```sql +-- In an RLS policy +CREATE POLICY user_isolation ON my_table + FOR ALL + USING (owner_id = current_setting('jwt.claims.user_id', true)::uuid); +``` + +You can also create helper functions: + +```sql +CREATE FUNCTION current_user_id() RETURNS uuid AS $$ + SELECT current_setting('jwt.claims.user_id', true)::uuid; +$$ LANGUAGE sql STABLE; +``` + +## Clearing Context + +To reset context between scenarios: + +```typescript +db.clearContext(); +``` + +This nulls all previously set context variables and resets to the default anonymous role. + +## Testing Different Access Levels + +```typescript +describe('RLS policies', () => { + const USER_1 = '00000000-0000-0000-0000-000000000001'; + const USER_2 = '00000000-0000-0000-0000-000000000002'; + + beforeEach(() => db.beforeEach()); + afterEach(() => db.afterEach()); + + it('user can see their own data', async () => { + db.setContext({ + role: 'authenticated', + 'jwt.claims.user_id': USER_1 + }); + + const rows = await db.any('SELECT * FROM my_table WHERE owner_id = $1', [USER_1]); + expect(rows.length).toBeGreaterThan(0); + }); + + it('user cannot see other users data', async () => { + db.setContext({ + role: 'authenticated', + 'jwt.claims.user_id': USER_2 + }); + + const rows = await db.any('SELECT * FROM my_table WHERE owner_id = $1', [USER_1]); + expect(rows.length).toBe(0); + }); + + it('anonymous users have no access', async () => { + db.setContext({ role: 'anonymous' }); + + const rows = await db.any('SELECT * FROM my_table'); + expect(rows.length).toBe(0); + }); +}); +``` + +## Context Timing + +Call `setContext()` before `beforeEach()` to apply context at the start of each test: + +```typescript +describe('authenticated role', () => { + beforeEach(async () => { + db.setContext({ role: 'authenticated', 'jwt.claims.user_id': USER_ID }); + await db.beforeEach(); + }); + + afterEach(() => db.afterEach()); + + it('runs as authenticated', async () => { + const res = await db.query(`SELECT current_setting('role', true) AS role`); + expect(res.rows[0].role).toBe('authenticated'); + }); +}); +``` + +Or set context within individual tests for scenario-specific testing: + +```typescript +it('switches between users', async () => { + db.setContext({ role: 'authenticated', 'jwt.claims.user_id': USER_1 }); + const user1Data = await db.any('SELECT * FROM my_table'); + + db.setContext({ role: 'authenticated', 'jwt.claims.user_id': USER_2 }); + const user2Data = await db.any('SELECT * FROM my_table'); + + expect(user1Data).not.toEqual(user2Data); +}); +``` + +## Best Practices + +1. Use predefined UUID constants for test user IDs to ensure consistency +2. Set context before `beforeEach()` for describe-level defaults +3. Use `clearContext()` when switching between unrelated scenarios +4. Test both positive cases (user can access) and negative cases (user cannot access) +5. Test anonymous/unauthenticated access explicitly +6. **In `beforeAll()`, wrap context-dependent `db` operations in explicit `db.begin()`/`db.commit()`** — `set_config(..., true)` is transaction-local, so without an active transaction the context is lost between queries. See [pgsql-test-transactions.md](./pgsql-test-transactions.md) for details. diff --git a/.agents/skills/constructive-testing/references/pgsql-test-rls.md b/.agents/skills/constructive-testing/references/pgsql-test-rls.md new file mode 100644 index 0000000000..9861d265c9 --- /dev/null +++ b/.agents/skills/constructive-testing/references/pgsql-test-rls.md @@ -0,0 +1,339 @@ +--- +name: pgsql-test-rls +description: Test Row-Level Security (RLS) policies with pgsql-test. Use when asked to "test RLS", "test permissions", "test user access", "verify security policies", or when writing tests for multi-tenant applications. +compatibility: pgsql-test, Jest/Vitest, PostgreSQL +metadata: + author: constructive-io + version: "1.0.0" +--- + +# Testing RLS Policies with pgsql-test + +Test Row-Level Security policies by simulating different users and roles. Verify your security policies work correctly with isolated, transactional tests. + +## When to Apply + +Use this skill when: +- Testing RLS policies for multi-tenant applications +- Verifying user isolation (users only see their own data) +- Testing role-based access (anonymous, authenticated, admin) +- Validating INSERT/UPDATE/DELETE policies + +## Setup + +Install pgsql-test: +```bash +pnpm add -D pgsql-test +``` + +Configure Jest/Vitest with the test database. + +## Core Concepts + +### Two Database Clients + +pgsql-test provides two clients: + +| Client | Purpose | +|--------|---------| +| `pg` | Superuser client for setup/teardown (bypasses RLS) | +| `db` | User client for testing with RLS enforcement | + +### Test Isolation + +Each test runs in a transaction with savepoints: +- `beforeEach()` starts a savepoint +- `afterEach()` rolls back to savepoint +- Tests are completely isolated + +## Basic RLS Test Structure + +```typescript +import { getConnections, PgTestClient } from 'pgsql-test'; + +let pg: PgTestClient; +let db: PgTestClient; +let teardown: () => Promise; + +beforeAll(async () => { + ({ pg, db, teardown } = await getConnections()); +}); + +afterAll(async () => { + await teardown(); +}); + +beforeEach(async () => { + await pg.beforeEach(); + await db.beforeEach(); +}); + +afterEach(async () => { + await db.afterEach(); + await pg.afterEach(); +}); +``` + +## Setting User Context + +Use `setContext()` to simulate different users: + +```typescript +// Simulate authenticated user +db.setContext({ + role: 'authenticated', + 'request.jwt.claim.sub': userId +}); + +// Simulate anonymous user +db.setContext({ role: 'anonymous' }); + +// Simulate admin +db.setContext({ + role: 'administrator', + 'request.jwt.claim.sub': adminId +}); +``` + +## Testing SELECT Policies + +Verify users only see their own data: + +```typescript +it('users only see their own records', async () => { + // Setup: Insert data as superuser + await pg.query(` + INSERT INTO app.posts (id, title, owner_id) VALUES + ('post-1', 'User 1 Post', $1), + ('post-2', 'User 2 Post', $2) + `, [user1Id, user2Id]); + + // Test: User 1 queries + db.setContext({ + role: 'authenticated', + 'request.jwt.claim.sub': user1Id + }); + + const result = await db.query('SELECT * FROM app.posts'); + + expect(result.rows).toHaveLength(1); + expect(result.rows[0].title).toBe('User 1 Post'); +}); +``` + +## Testing INSERT Policies + +Verify users can only insert their own data: + +```typescript +it('user can insert own record', async () => { + db.setContext({ + role: 'authenticated', + 'request.jwt.claim.sub': userId + }); + + const result = await db.one(` + INSERT INTO app.posts (title, owner_id) + VALUES ('My Post', $1) + RETURNING id, title, owner_id + `, [userId]); + + expect(result.title).toBe('My Post'); + expect(result.owner_id).toBe(userId); +}); + +it('user cannot insert for another user', async () => { + db.setContext({ + role: 'authenticated', + 'request.jwt.claim.sub': user1Id + }); + + // Use savepoint pattern for expected failures + const point = 'insert_other_user'; + await db.savepoint(point); + + await expect( + db.query(` + INSERT INTO app.posts (title, owner_id) + VALUES ('Hacked Post', $1) + `, [user2Id]) + ).rejects.toThrow(/permission denied|violates row-level security/); + + await db.rollback(point); +}); +``` + +## Testing UPDATE Policies + +```typescript +it('user can update own record', async () => { + // Setup + await pg.query(` + INSERT INTO app.posts (id, title, owner_id) + VALUES ('post-1', 'Original', $1) + `, [userId]); + + // Test + db.setContext({ + role: 'authenticated', + 'request.jwt.claim.sub': userId + }); + + const result = await db.one(` + UPDATE app.posts SET title = 'Updated' + WHERE id = 'post-1' + RETURNING title + `); + + expect(result.title).toBe('Updated'); +}); + +it('user cannot update another user record', async () => { + await pg.query(` + INSERT INTO app.posts (id, title, owner_id) + VALUES ('post-1', 'Original', $1) + `, [user2Id]); + + db.setContext({ + role: 'authenticated', + 'request.jwt.claim.sub': user1Id + }); + + // Update returns no rows (RLS filters it out) + const result = await db.query(` + UPDATE app.posts SET title = 'Hacked' + WHERE id = 'post-1' + RETURNING id + `); + + expect(result.rows).toHaveLength(0); +}); +``` + +## Testing DELETE Policies + +```typescript +it('user can delete own record', async () => { + await pg.query(` + INSERT INTO app.posts (id, title, owner_id) + VALUES ('post-1', 'To Delete', $1) + `, [userId]); + + db.setContext({ + role: 'authenticated', + 'request.jwt.claim.sub': userId + }); + + await db.query(`DELETE FROM app.posts WHERE id = 'post-1'`); + + // Verify as superuser + const result = await pg.query(` + SELECT * FROM app.posts WHERE id = 'post-1' + `); + expect(result.rows).toHaveLength(0); +}); +``` + +## Testing Anonymous Access + +```typescript +it('anonymous users have read-only access', async () => { + await pg.query(` + INSERT INTO app.public_posts (id, title) + VALUES ('post-1', 'Public Post') + `); + + db.setContext({ role: 'anonymous' }); + + // Can read public data + const result = await db.query('SELECT * FROM app.public_posts'); + expect(result.rows).toHaveLength(1); + + // Cannot modify + const point = 'anon_insert'; + await db.savepoint(point); + await expect( + db.query(`INSERT INTO app.public_posts (title) VALUES ('Hacked')`) + ).rejects.toThrow(/permission denied/); + await db.rollback(point); +}); +``` + +## Multi-User Scenarios + +Test interactions between multiple users: + +```typescript +describe('multi-user isolation', () => { + const alice = '550e8400-e29b-41d4-a716-446655440001'; + const bob = '550e8400-e29b-41d4-a716-446655440002'; + + beforeEach(async () => { + // Seed data for both users + await pg.query(` + INSERT INTO app.posts (title, owner_id) VALUES + ('Alice Post 1', $1), + ('Alice Post 2', $1), + ('Bob Post 1', $2) + `, [alice, bob]); + }); + + it('alice sees only her posts', async () => { + db.setContext({ + role: 'authenticated', + 'request.jwt.claim.sub': alice + }); + + const result = await db.query('SELECT title FROM app.posts ORDER BY title'); + expect(result.rows).toHaveLength(2); + expect(result.rows.map(r => r.title)).toEqual(['Alice Post 1', 'Alice Post 2']); + }); + + it('bob sees only his posts', async () => { + db.setContext({ + role: 'authenticated', + 'request.jwt.claim.sub': bob + }); + + const result = await db.query('SELECT title FROM app.posts'); + expect(result.rows).toHaveLength(1); + expect(result.rows[0].title).toBe('Bob Post 1'); + }); +}); +``` + +## Handling Expected Failures + +When testing operations that should fail, use the savepoint pattern to avoid "current transaction is aborted" errors: + +```typescript +it('rejects unauthorized access', async () => { + db.setContext({ role: 'anonymous' }); + + const point = 'unauthorized_access'; + await db.savepoint(point); + + await expect( + db.query('INSERT INTO app.private_data (secret) VALUES ($1)', ['hack']) + ).rejects.toThrow(/permission denied/); + + await db.rollback(point); + + // Can continue using db connection + const result = await db.query('SELECT 1 as ok'); + expect(result.rows[0].ok).toBe(1); +}); +``` + +## Watch Mode + +Run tests in watch mode for rapid feedback: +```bash +pnpm test:watch +``` + +## References + +- Related skill: `pgsql-test-exceptions` for handling aborted transactions +- Related skill: `pgsql-test-seeding` for seeding test data +- Related skill: `pgpm` (`references/testing.md`) for general test setup diff --git a/.agents/skills/constructive-testing/references/pgsql-test-scenario-setup.md b/.agents/skills/constructive-testing/references/pgsql-test-scenario-setup.md new file mode 100644 index 0000000000..e193401443 --- /dev/null +++ b/.agents/skills/constructive-testing/references/pgsql-test-scenario-setup.md @@ -0,0 +1,289 @@ +--- +name: pgsql-test-scenario-setup +description: Structuring complex test scenarios with proper isolation, transaction management, and multi-client patterns. Use when building complex RLS test scenarios, managing test isolation, or implementing multi-client database test patterns. +--- + +Structuring complex test scenarios with proper isolation, transaction management, and multi-client patterns. + +## Overview + +Complex RLS and database tests often require careful setup: creating users, seeding data, and testing access patterns. This skill covers the patterns for structuring these scenarios with proper test isolation. + +## The Two-Client Pattern + +The `getConnections()` function returns multiple clients with different privilege levels: + +```typescript +import { getConnections, PgTestClient } from 'pgsql-test'; + +let db: PgTestClient; // App-level client (RLS-enforced) +let pg: PgTestClient; // Superuser client (bypasses RLS) +let teardown: () => Promise; + +beforeAll(async () => { + ({ db, pg, teardown } = await getConnections()); +}); + +afterAll(() => teardown()); +``` + +### When to Use Each Client + +**Use `pg` (superuser) for:** +- Test setup that needs to bypass RLS +- Creating test users and seed data +- Administrative operations +- Verifying data exists regardless of RLS + +**Use `db` (app-level) for:** +- Testing actual RLS behavior +- Simulating real application queries +- Verifying access control works correctly + +### Example: RLS Test Setup + +```typescript +const TEST_USER_1 = '00000000-0000-0000-0000-000000000001'; +const TEST_USER_2 = '00000000-0000-0000-0000-000000000002'; + +beforeAll(async () => { + ({ db, pg, teardown } = await getConnections()); + + // Use pg (superuser) to create test users - bypasses RLS + await pg.query( + `INSERT INTO users (id, username) VALUES ($1, 'user1') ON CONFLICT DO NOTHING`, + [TEST_USER_1] + ); + await pg.query( + `INSERT INTO users (id, username) VALUES ($1, 'user2') ON CONFLICT DO NOTHING`, + [TEST_USER_2] + ); + + // Create test data owned by user 1 + await pg.query( + `INSERT INTO documents (owner_id, title) VALUES ($1, 'User 1 Doc')`, + [TEST_USER_1] + ); +}); + +// Now test RLS with db client +it('user 1 can see their documents', async () => { + db.setContext({ role: 'authenticated', 'jwt.claims.user_id': TEST_USER_1 }); + const docs = await db.any('SELECT * FROM documents'); + expect(docs.length).toBe(1); +}); + +it('user 2 cannot see user 1 documents', async () => { + db.setContext({ role: 'authenticated', 'jwt.claims.user_id': TEST_USER_2 }); + const docs = await db.any('SELECT * FROM documents'); + expect(docs.length).toBe(0); +}); +``` + +## Transaction Management + +### Test Isolation with beforeEach/afterEach + +Each test runs in its own transaction that rolls back after the test: + +```typescript +beforeEach(async () => { + await db.beforeEach(); // BEGIN + SAVEPOINT +}); + +afterEach(async () => { + await db.afterEach(); // ROLLBACK TO SAVEPOINT + COMMIT +}); +``` + +This ensures tests don't affect each other - any data created during a test is rolled back. + +### What beforeEach/afterEach Do + +```typescript +// db.beforeEach() executes: +await this.begin(); // BEGIN transaction +await this.savepoint(); // SAVEPOINT "lqlsavepoint" + +// db.afterEach() executes: +await this.rollback(); // ROLLBACK TO SAVEPOINT "lqlsavepoint" +await this.commit(); // COMMIT (the outer transaction) +``` + +## The publish() Method + +When you need data created in one client to be visible to another client (or to persist beyond the current transaction), use `publish()`: + +```typescript +it('cross-connection visibility', async () => { + // Create data with db client + db.setContext({ role: 'authenticated', 'jwt.claims.user_id': USER_ID }); + await db.query(`INSERT INTO items (name) VALUES ('test item')`); + + // Data is not yet visible to pg client (different connection) + let pgItems = await pg.any('SELECT * FROM items WHERE name = $1', ['test item']); + expect(pgItems.length).toBe(0); + + // Publish makes data visible to other connections + await db.publish(); + + // Now pg can see it + pgItems = await pg.any('SELECT * FROM items WHERE name = $1', ['test item']); + expect(pgItems.length).toBe(1); +}); +``` + +### What publish() Does + +```typescript +// db.publish() executes: +await this.commit(); // Make data visible to other sessions +await this.begin(); // Start fresh transaction +await this.savepoint(); // Maintain rollback harness +await this.ctxQuery(); // Reapply setContext() settings +``` + +## Setup Patterns + +### Pattern 1: Simple Setup in beforeAll + +For tests that share the same seed data: + +```typescript +beforeAll(async () => { + ({ db, pg, teardown } = await getConnections()); + + // Seed data once + await pg.query(`INSERT INTO users (id, name) VALUES ($1, 'Alice')`, [USER_ID]); +}); + +afterAll(() => teardown()); + +beforeEach(() => db.beforeEach()); +afterEach(() => db.afterEach()); +``` + +### Pattern 2: Complex Setup with Transactions + +For setup that requires multiple steps with intermediate commits: + +```typescript +beforeAll(async () => { + ({ db, pg, teardown } = await getConnections()); + + // Start transaction on pg for setup + await pg.begin(); + await pg.savepoint(); + + // Create users + await pg.query(`INSERT INTO users (id, name) VALUES ($1, 'Alice')`, [USER_1]); + await pg.query(`INSERT INTO users (id, name) VALUES ($1, 'Bob')`, [USER_2]); + + // Commit pg's work so db can see it + await pg.commit(); + await pg.begin(); + await pg.savepoint(); + + // Now db can work with the users + await db.begin(); + await db.savepoint(); + + db.setContext({ role: 'authenticated', 'jwt.claims.user_id': USER_1 }); + // ... additional setup with RLS context + + await db.commit(); + await db.begin(); + await db.savepoint(); +}); +``` + +### Pattern 3: Per-Describe Setup + +For describe blocks that need their own isolated setup: + +```typescript +describe('Admin scenarios', () => { + let adminId: string; + + beforeAll(async () => { + // Create admin user for this describe block + const result = await pg.one<{ id: string }>( + `INSERT INTO users (name, is_admin) VALUES ('Admin', true) RETURNING id` + ); + adminId = result.id; + }); + + beforeEach(() => db.beforeEach()); + afterEach(() => db.afterEach()); + + it('admin can see all data', async () => { + db.setContext({ role: 'authenticated', 'jwt.claims.user_id': adminId }); + // ... + }); +}); +``` + +## Scenario Testing Pattern + +For testing complex workflows with multiple actors: + +```typescript +describe('Organization membership scenarios', () => { + const OWNER_ID = '00000000-0000-0000-0000-000000000001'; + const MEMBER_ID = '00000000-0000-0000-0000-000000000002'; + let orgId: string; + + beforeAll(async () => { + ({ db, pg, teardown } = await getConnections()); + + // Create test users + await pg.query(`INSERT INTO users (id, name) VALUES ($1, 'Owner')`, [OWNER_ID]); + await pg.query(`INSERT INTO users (id, name) VALUES ($1, 'Member')`, [MEMBER_ID]); + }); + + afterAll(() => teardown()); + beforeEach(() => db.beforeEach()); + afterEach(() => db.afterEach()); + + it('owner creates organization', async () => { + db.setContext({ role: 'authenticated', 'jwt.claims.user_id': OWNER_ID }); + + const result = await db.one<{ id: string }>( + `INSERT INTO organizations (name) VALUES ('Acme') RETURNING id` + ); + orgId = result.id; + + expect(orgId).toBeDefined(); + }); + + it('owner can add members', async () => { + db.setContext({ role: 'authenticated', 'jwt.claims.user_id': OWNER_ID }); + + // First recreate the org (previous test rolled back) + const org = await db.one<{ id: string }>( + `INSERT INTO organizations (name) VALUES ('Acme') RETURNING id` + ); + + await db.query( + `INSERT INTO memberships (org_id, user_id) VALUES ($1, $2)`, + [org.id, MEMBER_ID] + ); + + const members = await db.any( + `SELECT * FROM memberships WHERE org_id = $1`, + [org.id] + ); + expect(members.length).toBe(1); + }); +}); +``` + +## Best Practices + +1. Use `pg` for setup, `db` for testing RLS behavior +2. Always call `beforeEach()`/`afterEach()` for test isolation +3. Use `publish()` when data needs to be visible across connections +4. Keep test user IDs as constants for consistency +5. Structure complex scenarios with clear beforeAll setup +6. Remember that each test's changes are rolled back - don't depend on previous test state +7. Use descriptive test names that explain the scenario being tested diff --git a/.agents/skills/constructive-testing/references/pgsql-test-seeding.md b/.agents/skills/constructive-testing/references/pgsql-test-seeding.md new file mode 100644 index 0000000000..fc8b25674b --- /dev/null +++ b/.agents/skills/constructive-testing/references/pgsql-test-seeding.md @@ -0,0 +1,286 @@ +--- +name: pgsql-test-seeding +description: Seed test databases with pgsql-test using loadJson, loadSql, and loadCsv. Use when asked to "seed test data", "load fixtures", "populate test database", or when setting up test data for database tests. +compatibility: pgsql-test, Jest/Vitest, PostgreSQL +metadata: + author: constructive-io + version: "1.0.0" +--- + +# Seeding Test Databases with pgsql-test + +Load test data efficiently using loadJson, loadSql, and loadCsv methods. Create maintainable, realistic test fixtures. + +## When to Apply + +Use this skill when: +- Setting up test data for database tests +- Loading fixtures from JSON, SQL, or CSV files +- Seeding data that respects or bypasses RLS +- Creating per-test or shared test data + +## Seeding Methods Overview + +| Method | Best For | RLS Behavior | +|--------|----------|--------------| +| `loadJson()` | Inline data, small datasets | Respects RLS (use `pg` to bypass) | +| `loadSql()` | Complex data, version-controlled fixtures | Respects RLS (use `pg` to bypass) | +| `loadCsv()` | Large datasets, spreadsheet exports | Bypasses RLS (uses COPY) | + +## Seeding with loadJson() + +Best for inline test data. Clean, readable, and type-safe. + +```typescript +import { getConnections, PgTestClient } from 'pgsql-test'; + +let pg: PgTestClient; +let db: PgTestClient; +let teardown: () => Promise; + +beforeAll(async () => { + ({ pg, db, teardown } = await getConnections()); + + // Seed using superuser to bypass RLS + await pg.loadJson({ + 'app.users': [ + { + id: '550e8400-e29b-41d4-a716-446655440001', + email: 'alice@example.com', + name: 'Alice' + }, + { + id: '550e8400-e29b-41d4-a716-446655440002', + email: 'bob@example.com', + name: 'Bob' + } + ], + 'app.posts': [ + { + id: 'post-1', + title: 'First Post', + owner_id: '550e8400-e29b-41d4-a716-446655440001' + }, + { + id: 'post-2', + title: 'Second Post', + owner_id: '550e8400-e29b-41d4-a716-446655440002' + } + ] + }); +}); + +afterAll(async () => { + await teardown(); +}); +``` + +**Key features:** +- Schema-qualified table names: `'app.users'` +- Explicit UUIDs for referential integrity +- Multiple tables in one call +- Order matters for foreign keys + +## Seeding with loadSql() + +Best for complex data or version-controlled fixtures. + +Create `__tests__/fixtures/seed.sql`: +```sql +-- Insert users +INSERT INTO app.users (id, email, name) VALUES + ('550e8400-e29b-41d4-a716-446655440001', 'alice@example.com', 'Alice'), + ('550e8400-e29b-41d4-a716-446655440002', 'bob@example.com', 'Bob'), + ('550e8400-e29b-41d4-a716-446655440003', 'charlie@example.com', 'Charlie'); + +-- Insert posts with foreign key references +INSERT INTO app.posts (id, title, owner_id) VALUES + ('post-1', 'Alice Post 1', '550e8400-e29b-41d4-a716-446655440001'), + ('post-2', 'Alice Post 2', '550e8400-e29b-41d4-a716-446655440001'), + ('post-3', 'Bob Post', '550e8400-e29b-41d4-a716-446655440002'); +``` + +Load in tests: +```typescript +import path from 'path'; + +beforeAll(async () => { + ({ pg, db, teardown } = await getConnections()); + + await pg.loadSql([ + path.join(__dirname, 'fixtures/seed.sql') + ]); +}); +``` + +**Multiple SQL files:** +```typescript +await pg.loadSql([ + path.join(__dirname, 'fixtures/users.sql'), + path.join(__dirname, 'fixtures/posts.sql'), + path.join(__dirname, 'fixtures/comments.sql') +]); +``` + +Files execute in order, so put parent tables first. + +## Seeding with loadCsv() + +Best for large datasets or spreadsheet exports. + +Create `__tests__/fixtures/users.csv`: +```csv +id,email,name +550e8400-e29b-41d4-a716-446655440001,alice@example.com,Alice +550e8400-e29b-41d4-a716-446655440002,bob@example.com,Bob +550e8400-e29b-41d4-a716-446655440003,charlie@example.com,Charlie +``` + +Load in tests: +```typescript +import path from 'path'; + +beforeAll(async () => { + ({ pg, db, teardown } = await getConnections()); + + await pg.loadCsv({ + 'app.users': path.join(__dirname, 'fixtures/users.csv'), + 'app.posts': path.join(__dirname, 'fixtures/posts.csv') + }); +}); +``` + +**Important:** `loadCsv()` uses PostgreSQL's COPY command, which bypasses RLS. Always use `pg` (superuser) client for CSV loading. + +## Combining Seeding Strategies + +Mix methods based on data characteristics: + +```typescript +beforeAll(async () => { + ({ pg, db, teardown } = await getConnections()); + + // 1. Load large reference data from CSV + await pg.loadCsv({ + 'app.categories': path.join(__dirname, 'fixtures/categories.csv') + }); + + // 2. Load complex relationships from SQL + await pg.loadSql([ + path.join(__dirname, 'fixtures/users-with-roles.sql') + ]); + + // 3. Add test-specific data inline + await pg.loadJson({ + 'app.posts': [ + { title: 'Test Post', owner_id: testUserId, category_id: 1 } + ] + }); +}); +``` + +## Per-Test Seeding + +When different tests need different data, seed in `beforeEach()`: + +```typescript +beforeAll(async () => { + ({ pg, db, teardown } = await getConnections()); +}); + +afterAll(async () => { + await teardown(); +}); + +beforeEach(async () => { + await pg.beforeEach(); + await db.beforeEach(); +}); + +afterEach(async () => { + await db.afterEach(); + await pg.afterEach(); +}); + +describe('empty state tests', () => { + it('handles no data gracefully', async () => { + const result = await db.query('SELECT COUNT(*) FROM app.posts'); + expect(result.rows[0].count).toBe('0'); + }); +}); + +describe('populated state tests', () => { + beforeEach(async () => { + await pg.loadJson({ + 'app.posts': [ + { title: 'Test Post', owner_id: userId } + ] + }); + }); + + it('finds existing posts', async () => { + const result = await db.query('SELECT COUNT(*) FROM app.posts'); + expect(result.rows[0].count).toBe('1'); + }); +}); +``` + +## RLS-Aware Seeding + +When testing RLS, seed with the appropriate client: + +```typescript +// Bypass RLS for setup (use pg) +await pg.loadJson({ + 'app.posts': [{ title: 'Admin Post', owner_id: adminId }] +}); + +// Respect RLS for user operations (use db with context) +db.setContext({ + role: 'authenticated', + 'request.jwt.claim.sub': userId +}); + +await db.loadJson({ + 'app.posts': [{ title: 'User Post', owner_id: userId }] +}); +``` + +## Fixture Organization + +Recommended structure: +```text +__tests__/ +├── fixtures/ +│ ├── users.csv +│ ├── posts.csv +│ ├── seed.sql +│ └── complex-scenario.sql +├── users.test.ts +├── posts.test.ts +└── rls.test.ts +``` + +## Best Practices + +1. **Use explicit IDs**: Makes referential integrity predictable +2. **Order by dependencies**: Parent tables before child tables +3. **Keep fixtures minimal**: Only seed what tests need +4. **Use `pg` for setup**: Bypass RLS during seeding +5. **Use `db` for testing**: Enforce RLS during assertions +6. **Version control fixtures**: SQL/CSV files in repo + +## Troubleshooting + +| Issue | Solution | +|-------|----------| +| Foreign key violation | Load parent tables first | +| RLS blocking inserts | Use `pg` client instead of `db` | +| CSV format errors | Ensure headers match column names | +| Data persists between tests | Check beforeEach/afterEach hooks | + +## References + +- Related skill: `pgsql-test-rls` for RLS testing patterns +- Related skill: `pgsql-test-exceptions` for handling errors +- Related skill: `pgpm` (`references/testing.md`) for general test setup diff --git a/.agents/skills/constructive-testing/references/pgsql-test-snapshot.md b/.agents/skills/constructive-testing/references/pgsql-test-snapshot.md new file mode 100644 index 0000000000..d658ad2812 --- /dev/null +++ b/.agents/skills/constructive-testing/references/pgsql-test-snapshot.md @@ -0,0 +1,357 @@ +--- +name: pgsql-test-snapshot +description: Snapshot testing utilities for PostgreSQL tests. Use when asked to "snapshot test", "prune IDs from snapshots", "deterministic test output", or when writing tests that need stable, reproducible assertions. +compatibility: pgsql-test, drizzle-orm-test, Jest/Vitest, PostgreSQL +metadata: + author: constructive-io + version: "1.0.0" +--- + +# Snapshot Testing with pgsql-test + +Use snapshot utilities from `pgsql-test/utils` to create deterministic, reproducible test assertions. These helpers replace dynamic values (IDs, UUIDs, dates, hashes) with stable placeholders. + +## When to Apply + +Use this skill when: +- Writing snapshot tests for database queries +- Need deterministic output from queries with UUIDs or timestamps +- Testing API responses that include database-generated values +- Comparing query results across test runs + +## Core Utilities + +Import from `pgsql-test/utils` or `drizzle-orm-test/utils`: + +```typescript +import { + snapshot, + prune, + pruneIds, + pruneDates, + pruneUUIDs, + pruneHashes, + pruneTokens, + composePruners, + createSnapshot +} from 'pgsql-test/utils'; +``` + +## Basic Usage + +### snapshot() + +The main utility that applies all default pruners recursively: + +```typescript +import { snapshot } from 'pgsql-test/utils'; + +const result = await db.query('SELECT * FROM users'); +expect(snapshot(result.rows)).toMatchSnapshot(); +``` + +Output transforms dynamic values to stable placeholders: + +```typescript +// Before snapshot() +{ + id: '550e8400-e29b-41d4-a716-446655440000', + name: 'Alice', + created_at: '2024-01-15T10:30:00.000Z', + password_hash: '$2b$10$...' +} + +// After snapshot() +{ + id: '[ID]', + name: 'Alice', + created_at: '[DATE]', + password_hash: '[hash]' +} +``` + +### With Drizzle ORM + +```typescript +import { drizzle } from 'drizzle-orm/node-postgres'; +import { snapshot } from 'drizzle-orm-test/utils'; +import { users } from './schema'; + +const drizzleDb = drizzle(db.client); +const result = await drizzleDb.select().from(users); +expect(snapshot(result)).toMatchSnapshot(); +``` + +## Individual Pruners + +### pruneIds() + +Replaces `id` and `*_id` fields with `[ID]`: + +```typescript +import { pruneIds } from 'pgsql-test/utils'; + +pruneIds({ id: 123, user_id: 'abc-123', name: 'Alice' }); +// { id: '[ID]', user_id: '[ID]', name: 'Alice' } +``` + +### pruneDates() + +Replaces Date objects and ISO date strings in `*_at` or `*At` fields: + +```typescript +import { pruneDates } from 'pgsql-test/utils'; + +pruneDates({ + created_at: '2024-01-15T10:30:00.000Z', + updatedAt: new Date(), + name: 'Alice' +}); +// { created_at: '[DATE]', updatedAt: '[DATE]', name: 'Alice' } +``` + +### pruneUUIDs() + +Replaces UUID values in `uuid` and `queue_name` fields: + +```typescript +import { pruneUUIDs } from 'pgsql-test/utils'; + +pruneUUIDs({ uuid: '550e8400-e29b-41d4-a716-446655440000' }); +// { uuid: '[UUID]' } +``` + +### pruneHashes() + +Replaces `*_hash` fields starting with `$`: + +```typescript +import { pruneHashes } from 'pgsql-test/utils'; + +pruneHashes({ password_hash: '$2b$10$xyz...' }); +// { password_hash: '[hash]' } +``` + +### pruneTokens() + +Replaces `token` and `*_token` fields: + +```typescript +import { pruneTokens } from 'pgsql-test/utils'; + +pruneTokens({ access_token: 'eyJhbGciOiJIUzI1NiIs...' }); +// { access_token: '[token]' } +``` + +### pruneIdArrays() + +Replaces `*_ids` array fields with count placeholder: + +```typescript +import { pruneIdArrays } from 'pgsql-test/utils'; + +pruneIdArrays({ member_ids: ['id1', 'id2', 'id3'] }); +// { member_ids: '[UUIDs-3]' } +``` + +### prunePeoplestamps() + +Replaces `*_by` fields (audit columns): + +```typescript +import { prunePeoplestamps } from 'pgsql-test/utils'; + +prunePeoplestamps({ created_by: 'user-123', updated_by: 'user-456' }); +// { created_by: '[peoplestamp]', updated_by: '[peoplestamp]' } +``` + +### pruneSchemas() + +Replaces schema names starting with `zz-`: + +```typescript +import { pruneSchemas } from 'pgsql-test/utils'; + +pruneSchemas({ schema: 'zz-abc123' }); +// { schema: '[schemahash]' } +``` + +## ID Hash Tracking + +Track ID relationships across snapshots using `IdHash`: + +```typescript +import { snapshot, pruneIds, IdHash } from 'pgsql-test/utils'; + +const idHash: IdHash = { + '550e8400-e29b-41d4-a716-446655440001': 'alice', + '550e8400-e29b-41d4-a716-446655440002': 'bob' +}; + +const result = [ + { id: '550e8400-e29b-41d4-a716-446655440001', name: 'Alice' }, + { id: '550e8400-e29b-41d4-a716-446655440002', name: 'Bob' } +]; + +expect(snapshot(result, idHash)).toMatchSnapshot(); +// [ +// { id: '[ID-alice]', name: 'Alice' }, +// { id: '[ID-bob]', name: 'Bob' } +// ] +``` + +Numeric ID tracking: + +```typescript +const idHash: IdHash = {}; +let counter = 1; + +// Assign IDs as you encounter them +for (const row of result) { + if (!idHash[row.id]) { + idHash[row.id] = counter++; + } +} + +expect(snapshot(result, idHash)).toMatchSnapshot(); +// [ +// { id: '[ID-1]', name: 'Alice' }, +// { id: '[ID-2]', name: 'Bob' } +// ] +``` + +## Custom Pruners + +### composePruners() + +Combine multiple pruners into one: + +```typescript +import { composePruners, pruneDates, pruneIds } from 'pgsql-test/utils'; + +const myPruner = composePruners(pruneDates, pruneIds); +const result = myPruner({ id: 123, created_at: new Date() }); +// { id: '[ID]', created_at: '[DATE]' } +``` + +### createSnapshot() + +Create a custom snapshot function with specific pruners: + +```typescript +import { createSnapshot, pruneDates, pruneIds, pruneHashes } from 'pgsql-test/utils'; + +const mySnapshot = createSnapshot([pruneDates, pruneIds, pruneHashes]); + +const result = await db.query('SELECT * FROM users'); +expect(mySnapshot(result.rows)).toMatchSnapshot(); +``` + +## Default Pruners + +The `snapshot()` function applies these pruners by default: + +1. `pruneTokens` — `token`, `*_token` +2. `prunePeoplestamps` — `*_by` +3. `pruneDates` — `*_at`, `*At`, Date objects +4. `pruneIdArrays` — `*_ids` arrays +5. `pruneUUIDs` — `uuid`, `queue_name` +6. `pruneHashes` — `*_hash` +7. `pruneIds` — `id`, `*_id` + +## Error Code Extraction + +Extract error codes from enhanced error messages: + +```typescript +import { getErrorCode } from 'pgsql-test/utils'; + +try { + await db.query('SELECT * FROM nonexistent'); +} catch (err) { + const code = getErrorCode(err.message); + // Returns first line only, stripping debug context + expect(code).toBe('UNDEFINED_TABLE'); +} +``` + +## PostgreSQL Error Formatting + +Format PostgreSQL errors for readable output: + +```typescript +import { + extractPgErrorFields, + formatPgError, + formatPgErrorFields +} from 'pgsql-test/utils'; + +try { + await db.query('invalid sql'); +} catch (err) { + const fields = extractPgErrorFields(err); + console.log(formatPgError(err)); + // Formatted error with context +} +``` + +## Complete Test Example + +```typescript +import { getConnections, PgTestClient } from 'pgsql-test'; +import { snapshot, IdHash } from 'pgsql-test/utils'; + +let db: PgTestClient; +let teardown: () => Promise; + +beforeAll(async () => { + ({ db, teardown } = await getConnections()); +}); + +afterAll(async () => { + await teardown(); +}); + +beforeEach(async () => { + await db.beforeEach(); +}); + +afterEach(async () => { + await db.afterEach(); +}); + +describe('User queries', () => { + it('returns users with stable snapshot', async () => { + // Seed test data + await db.query(` + INSERT INTO users (email, name) VALUES + ('alice@example.com', 'Alice'), + ('bob@example.com', 'Bob') + `); + + const result = await db.query('SELECT * FROM users ORDER BY email'); + + // Snapshot with ID tracking + const idHash: IdHash = {}; + result.rows.forEach((row, i) => { + idHash[row.id] = i + 1; + }); + + expect(snapshot(result.rows, idHash)).toMatchSnapshot(); + }); +}); +``` + +## Best Practices + +1. **Use snapshot() by default**: Covers most common dynamic fields +2. **Track IDs with IdHash**: When relationships between records matter +3. **Custom pruners for special fields**: Create domain-specific pruners +4. **Order results**: Use ORDER BY for deterministic row order +5. **Prune before comparing**: Apply pruners before any assertions + +## References + +- Related skill: `pgsql-test-seeding` for seeding test data +- Related skill: `pgsql-test-rls` for RLS testing +- Related skill: `drizzle-orm-test` for Drizzle ORM integration diff --git a/.agents/skills/constructive-testing/references/pgsql-test-transactions.md b/.agents/skills/constructive-testing/references/pgsql-test-transactions.md new file mode 100644 index 0000000000..600b873cc8 --- /dev/null +++ b/.agents/skills/constructive-testing/references/pgsql-test-transactions.md @@ -0,0 +1,110 @@ +--- +name: pgsql-test-transactions +description: Transaction-local context, the beforeAll gotcha, and PostgreSQL role patterns for RLS testing. Use when debugging "context lost" errors, getting RLS failures in beforeAll, or choosing between pg and db for test operations. +--- + +# Transaction-Local Context & Role Patterns + +## The `set_config(..., true)` Contract + +`pgsql-test`'s `setContext()` uses `set_config('key', 'value', true)` internally. The third parameter `true` makes the setting **transaction-local** — it only persists within the current transaction. + +This works transparently inside test bodies (`it()` blocks) because `beforeEach()` calls `db.begin()` + `db.savepoint()`, creating an active transaction. But in `beforeAll()`, there is no active transaction by default. + +## The `beforeAll` Gotcha + +**Symptom:** You call `setContext()` and then a query, but the query runs without any JWT context — leading to RLS violations or `current_setting()` returning NULL. + +**Root cause:** Without an active transaction, each `db.query()` runs in auto-commit mode. The `ctxQuery()` call (which executes `set_config`) runs in one implicit transaction that immediately commits, and the actual query runs in a separate implicit transaction where the context no longer exists. + +**Fix — wrap `beforeAll` operations in explicit transactions:** + +```typescript +beforeAll(async () => { + ({ db, pg, teardown } = await getConnections()); + + // pg operations auto-commit — no transaction needed + await pg.query(`CREATE TABLE ...`); + await pg.query(`INSERT INTO users ...`); + + // db operations NEED an explicit transaction for context to persist + await db.begin(); + db.setContext({ role: 'authenticated', 'jwt.claims.user_id': ADMIN_ID }); + await db.query(`INSERT INTO app.teams (...) VALUES (...)`); // context persists! + await db.query(`INSERT INTO app.members (...) VALUES (...)`); // still works! + await db.commit(); +}); +``` + +**Without `db.begin()`:** +```typescript +// WRONG — context lost between queries in auto-commit mode +db.setContext({ role: 'authenticated', 'jwt.claims.user_id': ADMIN_ID }); +await db.query(`INSERT INTO app.teams (...) VALUES (...)`); // ERROR: no context +``` + +## Role Patterns for RLS Testing + +PostgreSQL supports multiple roles with different privilege levels. `pgsql-test` gives you `pg` (superuser) and `db` (app-level) to test against them: + +| Role | Client | Bypasses RLS? | Fires Triggers? | When to use | +|------|--------|---------------|-----------------|-------------| +| **superuser** | `pg` | Yes | Yes | Schema setup, DDL, seed data in `beforeAll` | +| **administrator** | `db` + `setContext({ role: 'administrator' })` | Depends on grants | Yes | Elevated data ops that should still exercise triggers | +| **authenticated** | `db` + `setContext({ role: 'authenticated', ... })` | No (full RLS) | Yes | All test queries — this is what real users experience | + +### Why Use `db` with an Elevated Role Instead of `pg` for Data Operations + +The superuser (`pg`) bypasses RLS entirely, but it also means your tests skip the real code path. If your application has triggers that fire on INSERT (e.g., populating audit logs, updating membership tables, or maintaining denormalized data), using `pg` bypasses none of those — triggers still fire for superusers. However, `pg` **does** bypass all RLS policies, which can mask broken policies. + +Using `db` with an elevated role like `administrator`: +- Triggers fire normally +- FK constraints are validated +- The test exercises a more realistic code path +- You can verify that your grant/privilege setup actually works + +```typescript +// Superuser — bypasses RLS, may hide policy bugs +await pg.query(`INSERT INTO app.posts (owner_id, title) VALUES ($1, 'test')`, [ALICE_ID]); + +// Elevated role via db — triggers fire, grants are tested +db.setContext({ role: 'administrator' }); +await db.query(`INSERT INTO app.posts (owner_id, title) VALUES ($1, 'test')`, [ALICE_ID]); +``` + +### Switching Roles Mid-Test + +```typescript +it('admin seeds, user reads', async () => { + // Elevated operation + db.setContext({ role: 'administrator' }); + await db.query(`INSERT INTO app.posts (owner_id, title) VALUES ($1, 'Admin Post')`, [ALICE_ID]); + + // Switch to user — RLS now enforced + db.setContext({ role: 'authenticated', 'jwt.claims.user_id': ALICE_ID }); + const rows = await db.any('SELECT * FROM app.posts'); + expect(rows.length).toBeGreaterThan(0); +}); +``` + +## `pg` vs `db` Quick Reference + +| Operation | Use `pg`? | Use `db`? | Notes | +|-----------|-----------|-----------|-------| +| CREATE TABLE, DDL | ✓ | | Superuser needed for schema changes | +| Seed reference/lookup data | ✓ | | Bootstrap data in `beforeAll` | +| Catalog/information_schema queries | ✓ | | Read-only, no RLS concern | +| Insert data that triggers should process | | ✓ (elevated role) | Ensures triggers fire and side effects happen | +| Grant permissions | | ✓ (elevated role) | Validates grant setup works | +| Test queries (what users see) | | ✓ (authenticated) | RLS must be enforced | + +## Common Pitfalls + +### 1. Forgetting `db.begin()` in `beforeAll` +Context is transaction-local. Without `begin()`, `setContext()` has no effect on subsequent queries. This is the #1 cause of "my context disappeared" bugs. + +### 2. Cross-connection deadlock +Never mix `pg` and `db` in the same test body when both are inside savepoints. Both have separate transactions — data locked by one blocks the other indefinitely. Use `db` with an elevated role for in-test seeding instead. + +### 3. Assuming `pg` tests real behavior +`pg` bypasses RLS, so tests using `pg` for data operations won't catch broken policies. Use `pg` only for setup/DDL, and `db` for everything you want to actually test. diff --git a/.agents/skills/constructive-testing/references/pgsql-test.md b/.agents/skills/constructive-testing/references/pgsql-test.md new file mode 100644 index 0000000000..7bf027051e --- /dev/null +++ b/.agents/skills/constructive-testing/references/pgsql-test.md @@ -0,0 +1,214 @@ +--- +name: pgsql-test +description: PostgreSQL integration testing with pgsql-test — RLS policies, seeding, exceptions, snapshots, helpers, JWT context, and complex scenario setup. Use when asked to "test RLS", "test permissions", "seed test data", "snapshot test", "test database", "write integration tests", "test user access", "handle aborted transactions", or when writing any PostgreSQL test with pgsql-test. +compatibility: pgsql-test, Jest/Vitest, PostgreSQL, Node.js 18+ +metadata: + author: constructive-io + version: "2.0.0" +--- + +# pgsql-test (PostgreSQL Integration Testing) + +pgsql-test provides a complete testing toolkit for PostgreSQL — from RLS policy verification and test seeding to snapshot utilities and complex multi-client scenario management. All tests run in transactions with savepoint-based isolation. + +## When to Apply + +Use this skill when: +- **Testing RLS policies:** Verifying user isolation, role-based access, multi-tenant security +- **Seeding test data:** Loading fixtures with loadJson, loadSql, loadCsv +- **Testing exceptions:** Handling aborted transactions when operations should fail +- **Snapshot testing:** Deterministic assertions with pruneIds, pruneDates, etc. +- **Building helpers:** Reusable test functions, constants, assertion utilities +- **JWT context:** Simulating authenticated users with claims for RLS +- **Complex scenarios:** Multi-client patterns, transaction management, cross-connection visibility + +## Quick Start + +```bash +pnpm add -D pgsql-test +``` + +```typescript +import { getConnections, PgTestClient } from 'pgsql-test'; + +let pg: PgTestClient; // Superuser (bypasses RLS) +let db: PgTestClient; // App-level (enforces RLS) +let teardown: () => Promise; + +beforeAll(async () => { + ({ pg, db, teardown } = await getConnections()); +}); + +afterAll(async () => { + await teardown(); +}); + +beforeEach(async () => { + await pg.beforeEach(); + await db.beforeEach(); +}); + +afterEach(async () => { + await db.afterEach(); + await pg.afterEach(); +}); +``` + +## Core Concepts + +### Two Database Clients + +| Client | Purpose | +|--------|---------| +| `pg` | Superuser — setup/teardown, bypasses RLS | +| `db` | App-level — testing with RLS enforcement | + +### Test Isolation + +Each test runs in a transaction with savepoints: +- `beforeEach()` starts a savepoint +- `afterEach()` rolls back to savepoint +- Tests are completely isolated + +## Testing RLS Policies + +```typescript +// Set user context +db.setContext({ + role: 'authenticated', + 'request.jwt.claim.sub': userId +}); + +// Users only see their own records +const result = await db.query('SELECT * FROM app.posts'); +expect(result.rows).toHaveLength(1); +``` + +## Handling Expected Failures (Savepoint Pattern) + +When testing operations that should fail, use savepoints to avoid "current transaction is aborted" errors: + +```typescript +it('rejects unauthorized access', async () => { + db.setContext({ role: 'anonymous' }); + + await db.savepoint('unauthorized_access'); + + await expect( + db.query('INSERT INTO app.private_data (secret) VALUES ($1)', ['hack']) + ).rejects.toThrow(/permission denied/); + + await db.rollback('unauthorized_access'); + + // Connection still works + const result = await db.query('SELECT 1 as ok'); + expect(result.rows[0].ok).toBe(1); +}); +``` + +## Seeding Test Data + +```typescript +// Inline JSON (best for small datasets) +await pg.loadJson({ + 'app.users': [ + { id: 'user-1', email: 'alice@example.com', name: 'Alice' } + ] +}); + +// SQL files (best for complex data) +await pg.loadSql([path.join(__dirname, 'fixtures/seed.sql')]); + +// CSV files (best for large datasets, uses COPY) +await pg.loadCsv({ + 'app.categories': path.join(__dirname, 'fixtures/categories.csv') +}); +``` + +## Snapshot Testing + +```typescript +import { snapshot, IdHash } from 'pgsql-test/utils'; + +const result = await db.query('SELECT * FROM users ORDER BY email'); +expect(snapshot(result.rows)).toMatchSnapshot(); + +// With ID tracking +const idHash: IdHash = {}; +result.rows.forEach((row, i) => { idHash[row.id] = i + 1; }); +expect(snapshot(result.rows, idHash)).toMatchSnapshot(); +``` + +Default pruners: `pruneTokens`, `prunePeoplestamps`, `pruneDates`, `pruneIdArrays`, `pruneUUIDs`, `pruneHashes`, `pruneIds`. + +## JWT Context for RLS + +```typescript +// Authenticated user +db.setContext({ + role: 'authenticated', + 'jwt.claims.user_id': userId +}); + +// Organization context +db.setContext({ + role: 'authenticated', + 'jwt.claims.user_id': userId, + 'jwt.claims.org_id': orgId +}); + +// Anonymous +db.setContext({ role: 'anonymous' }); + +// Clear context +db.clearContext(); +``` + +## Reusable Test Helpers + +```typescript +export const TEST_USER_IDS = { + USER_1: '00000000-0000-0000-0000-000000000001', + USER_2: '00000000-0000-0000-0000-000000000002', + ADMIN: '00000000-0000-0000-0000-000000000099', +} as const; + +export function setAuthContext(db: PgTestClient, userId: string): void { + db.setContext({ + role: 'authenticated', + 'jwt.claims.user_id': userId, + }); +} +``` + +## Troubleshooting Quick Reference + +| Issue | Quick Fix | +|-------|-----------| +| "current transaction is aborted" | Use savepoint pattern before expected failures | +| Data persists between tests | Ensure `beforeEach`/`afterEach` hooks are set up | +| RLS blocking test inserts | Use `pg` (superuser) for seeding, `db` for testing | +| Foreign key violations in seeding | Load parent tables before child tables | +| Tests interfere with each other | Every test file needs `beforeEach`/`afterEach` hooks | + +## Reference Guide + +Consult these reference files for detailed documentation on specific topics: + +| Reference | Topic | Consult When | +|-----------|-------|--------------| +| [references/rls.md](references/rls.md) | Testing RLS policies | SELECT/INSERT/UPDATE/DELETE policies, multi-user isolation, anonymous access | +| [references/seeding.md](references/seeding.md) | Seeding test databases | loadJson, loadSql, loadCsv, RLS-aware seeding, fixture organization | +| [references/exceptions.md](references/exceptions.md) | Handling aborted transactions | Savepoint pattern for expected failures, constraint violations, permission errors | +| [references/snapshot.md](references/snapshot.md) | Snapshot testing utilities | pruneIds, pruneDates, IdHash tracking, custom pruners, error formatting | +| [references/helpers.md](references/helpers.md) | Reusable test helpers | Constants, typed helpers, assertion utilities, test-utils organization | +| [references/jwt-context.md](references/jwt-context.md) | JWT claims and role context | setContext API, auth() helper, reading claims in SQL, context timing | +| [references/scenario-setup.md](references/scenario-setup.md) | Complex test scenarios | Two-client pattern, transaction management, publish(), per-describe setup | + +## Cross-References + +Related skills (separate from this skill): +- **`constructive-testing`** — Framework selection guide: which testing framework to use (pgsql-test vs graphile-test vs graphql-test vs server-test) and anti-patterns to avoid +- `pgpm` (`references/testing.md`) — General pgpm test setup and seed adapters +- `drizzle-orm-test` — Testing with Drizzle ORM (uses pgsql-test utilities) +- `constructive-safegres` — Safegres authorization policies that RLS tests validate diff --git a/.agents/skills/constructive-testing/references/supabase-test.md b/.agents/skills/constructive-testing/references/supabase-test.md new file mode 100644 index 0000000000..f1e2bb8c69 --- /dev/null +++ b/.agents/skills/constructive-testing/references/supabase-test.md @@ -0,0 +1,383 @@ +--- +name: supabase-test +description: Test Supabase applications with supabase-test. Use when asked to "test Supabase", "test RLS with Supabase", "write Supabase tests", or when testing applications built on Supabase. +compatibility: supabase-test, Jest/Vitest, Supabase, PostgreSQL +metadata: + author: constructive-io + version: "1.0.0" +--- + +# Testing Supabase Applications with supabase-test + +TypeScript-native testing for Supabase with ephemeral databases, RLS testing, and multi-user simulation. + +## When to Apply + +Use this skill when: +- Testing Supabase applications +- Testing RLS policies with Supabase roles (anon, authenticated) +- Simulating authenticated users in tests +- Testing with Supabase's auth.users table + +## Why supabase-test? + +Traditional Supabase testing uses pgTap (SQL-based). supabase-test provides: +- Pure TypeScript tests (Jest/Vitest) +- Multi-user RLS simulation +- Direct Postgres access +- Instant test isolation +- CI-ready ephemeral databases + +## Setup + +### Install Dependencies + +```bash +pnpm add -D supabase-test +``` + +### Initialize Supabase + +```bash +npx supabase init +npx supabase start +``` + +### Configure pgpm (Optional) + +If using pgpm for schema management: +```bash +pgpm init workspace +cd packages/myapp +pgpm init +pgpm install @pgpm/supabase +``` + +## Core Concepts + +### Two Database Clients + +| Client | Purpose | +|--------|---------| +| `pg` | Superuser client for setup/teardown (bypasses RLS) | +| `db` | User client for testing with Supabase roles | + +### Test Isolation + +Each test runs in a transaction: +- `beforeEach()` starts transaction/savepoint +- `afterEach()` rolls back +- Tests are completely isolated + +## Basic Test Structure + +```typescript +import { getConnections, PgTestClient } from 'supabase-test'; + +let pg: PgTestClient; +let db: PgTestClient; +let teardown: () => Promise; + +beforeAll(async () => { + ({ pg, db, teardown } = await getConnections()); +}); + +afterAll(async () => { + await teardown(); +}); + +beforeEach(async () => { + await db.beforeEach(); +}); + +afterEach(async () => { + await db.afterEach(); +}); + +it('queries the database', async () => { + const result = await db.query('SELECT 1 + 1 AS sum'); + expect(result.rows[0].sum).toBe(2); +}); +``` + +## Creating Test Users + +Use `insertUser()` to create users in `auth.users`: + +```typescript +import { getConnections, PgTestClient, insertUser } from 'supabase-test'; + +let pg: PgTestClient; +let db: PgTestClient; +let teardown: () => Promise; + +let alice: any; +let bob: any; + +beforeAll(async () => { + ({ pg, db, teardown } = await getConnections()); + + // Create users in auth.users (requires superuser) + alice = await insertUser(pg, 'alice@example.com', '550e8400-e29b-41d4-a716-446655440001'); + bob = await insertUser(pg, 'bob@example.com', '550e8400-e29b-41d4-a716-446655440002'); +}); +``` + +**Parameters:** +- `pg` - Superuser client (required for auth.users) +- `email` - User's email +- `id` - Optional UUID (auto-generated if omitted) + +## Setting User Context + +Simulate Supabase roles with `setContext()`: + +```typescript +// Authenticated user +db.setContext({ + role: 'authenticated', + 'request.jwt.claim.sub': alice.id +}); + +// Anonymous user +db.setContext({ role: 'anon' }); + +// Service role (admin) +db.setContext({ role: 'service_role' }); +``` + +## Testing RLS Policies + +### User Can Access Own Data + +```typescript +it('user can insert own record', async () => { + db.setContext({ + role: 'authenticated', + 'request.jwt.claim.sub': alice.id + }); + + const result = await db.one(` + INSERT INTO app.posts (title, owner_id) + VALUES ($1, $2) + RETURNING id, title, owner_id + `, ['My Post', alice.id]); + + expect(result.title).toBe('My Post'); + expect(result.owner_id).toBe(alice.id); +}); +``` + +### User Cannot Access Others' Data + +```typescript +it('user cannot see other users data', async () => { + // Bob creates a post + db.setContext({ + role: 'authenticated', + 'request.jwt.claim.sub': bob.id + }); + + await db.one(` + INSERT INTO app.posts (title, owner_id) + VALUES ($1, $2) + RETURNING id + `, ['Bob Post', bob.id]); + + // Alice queries - should not see Bob's post + db.setContext({ + role: 'authenticated', + 'request.jwt.claim.sub': alice.id + }); + + const result = await db.query('SELECT * FROM app.posts'); + expect(result.rows).toHaveLength(0); +}); +``` + +### Testing Permission Denied + +Use savepoint pattern for expected failures: + +```typescript +it('anonymous cannot insert', async () => { + db.setContext({ role: 'anon' }); + + const point = 'anon_insert'; + await db.savepoint(point); + + await expect( + db.query(`INSERT INTO app.posts (title) VALUES ('Hacked')`) + ).rejects.toThrow(/permission denied/); + + await db.rollback(point); +}); +``` + +## Seeding Test Data + +### With insertUser() + +```typescript +beforeAll(async () => { + ({ pg, db, teardown } = await getConnections()); + + alice = await insertUser(pg, 'alice@example.com'); + bob = await insertUser(pg, 'bob@example.com'); +}); +``` + +### With loadJson() + +```typescript +beforeAll(async () => { + ({ pg, db, teardown } = await getConnections()); + + alice = await insertUser(pg, 'alice@example.com', '550e8400-e29b-41d4-a716-446655440001'); + + await pg.loadJson({ + 'app.posts': [ + { title: 'Post 1', owner_id: alice.id }, + { title: 'Post 2', owner_id: alice.id } + ] + }); +}); +``` + +### With loadSql() + +```typescript +import path from 'path'; + +beforeAll(async () => { + ({ pg, db, teardown } = await getConnections()); + + await pg.loadSql([ + path.join(__dirname, 'fixtures/seed.sql') + ]); +}); +``` + +### With loadCsv() + +```typescript +import path from 'path'; + +beforeAll(async () => { + ({ pg, db, teardown } = await getConnections()); + + await pg.loadCsv({ + 'app.posts': path.join(__dirname, 'fixtures/posts.csv') + }); +}); +``` + +**Note:** `loadCsv()` bypasses RLS (uses COPY). Always use `pg` client. + +## Query Methods + +| Method | Returns | Use Case | +|--------|---------|----------| +| `db.query(sql, params)` | `{ rows: [...] }` | Multiple rows | +| `db.one(sql, params)` | Single row object | Exactly one row | +| `db.many(sql, params)` | Array of rows | Multiple rows (array) | + +```typescript +// Multiple rows +const result = await db.query('SELECT * FROM app.posts'); +console.log(result.rows); + +// Single row (throws if not exactly one) +const post = await db.one('SELECT * FROM app.posts WHERE id = $1', [postId]); +console.log(post.title); + +// Array of rows +const posts = await db.many('SELECT * FROM app.posts'); +console.log(posts.length); +``` + +## Complete Example + +```typescript +import { getConnections, PgTestClient, insertUser } from 'supabase-test'; + +let pg: PgTestClient; +let db: PgTestClient; +let teardown: () => Promise; + +let alice: any; +let bob: any; + +beforeAll(async () => { + ({ pg, db, teardown } = await getConnections()); + + alice = await insertUser(pg, 'alice@example.com'); + bob = await insertUser(pg, 'bob@example.com'); +}); + +afterAll(async () => { + await teardown(); +}); + +beforeEach(async () => { + await db.beforeEach(); +}); + +afterEach(async () => { + await db.afterEach(); +}); + +describe('RLS policies', () => { + it('users only see their own posts', async () => { + // Alice creates a post + db.setContext({ + role: 'authenticated', + 'request.jwt.claim.sub': alice.id + }); + + await db.one(` + INSERT INTO app.posts (title, owner_id) + VALUES ('Alice Post', $1) + RETURNING id + `, [alice.id]); + + // Bob creates a post + db.setContext({ + role: 'authenticated', + 'request.jwt.claim.sub': bob.id + }); + + await db.one(` + INSERT INTO app.posts (title, owner_id) + VALUES ('Bob Post', $1) + RETURNING id + `, [bob.id]); + + // Alice queries - only sees her post + db.setContext({ + role: 'authenticated', + 'request.jwt.claim.sub': alice.id + }); + + const result = await db.many('SELECT title FROM app.posts'); + expect(result).toHaveLength(1); + expect(result[0].title).toBe('Alice Post'); + }); +}); +``` + +## Running Tests + +```bash +# Run all tests +pnpm test + +# Watch mode +pnpm test:watch +``` + +## References + +- Related skill: `pgsql-test-rls` for general RLS testing patterns +- Related skill: `pgsql-test-seeding` for seeding strategies +- Related skill: `pgsql-test-exceptions` for handling aborted transactions diff --git a/.agents/skills/constructive-testing/references/test-authoring.md b/.agents/skills/constructive-testing/references/test-authoring.md new file mode 100644 index 0000000000..522c908929 --- /dev/null +++ b/.agents/skills/constructive-testing/references/test-authoring.md @@ -0,0 +1,105 @@ +# Test Authoring: Lean, Readable, Fast + +How to structure tests — which preset to pick, which utilities to call, and how to keep test files short and expressive. For CI shard balancing see `ci-test-optimization.md`. For RLS/transaction mechanics see `integration-testing.md`. + +## Rule #1: Provision the Minimum + +Every module added to a preset costs provisioning time (~3–8s per module). The single biggest lever for fast tests is provisioning only what the test exercises. + +### Preset Selection Decision Tree + +``` +What does your test exercise? +│ +├─ Table/field/trigger generators (no RLS, no auth) +│ → preset: 'minimal' (~5s) +│ +├─ App-level memberships or permissions (no orgs) +│ → preset: 'permissions_app' (~10s) +│ +├─ Memberships with data manipulation (no RLS enforcement) +│ → preset: 'memberships' (~12s) +│ +├─ Memberships with RLS (org creation via createOrg) +│ → preset: 'memberships_rls' (~14s) +│ +├─ Auth flows (login, register, sessions, tokens) +│ → preset: 'accounts' (~20s) +│ +├─ Invite workflows +│ → preset: 'invites' (~22s) +│ +├─ Org chart / hierarchy +│ → preset: 'hierarchy' (~18s) +│ +├─ Feature needs specific modules not in a preset +│ → Custom array: preset: ['users_module', 'agent_chat_module'] +│ +└─ Blueprint construction / "needs everything" + → preset: 'full' (~50-60s) — use sparingly +``` + +### Anti-Pattern: Over-Provisioning + +```typescript +// ❌ 60s provisioning to test a feature that only needs users_module +const { testHelper } = await provisionTestDatabase(db, pg, { preset: 'full' }); + +// ✅ 5s provisioning, tests exactly what it needs +const { testHelper } = await provisionTestDatabase(db, pg, { preset: 'minimal' }); +``` + +## Infrastructure Belongs in CI, Not in Tests + +Roles are bootstrapped by CI before tests run. Tests must assume roles already exist. Never call `CREATE EXTENSION` in test code — declare in `.control` file. + +## Test File Skeleton + +```typescript +jest.setTimeout(120000); +process.env.LOG_SCOPE = 'pgsql-test'; + +import { getConnections, PgTestClient, snapshot } from 'pgsql-test'; +import { TestHelper, provisionTestDatabase } from '../../test-utils/test-helpers'; + +let db: PgTestClient; +let pg: PgTestClient; +let teardown: () => Promise; +let t: TestHelper; + +beforeAll(async () => { + ({ db, pg, teardown } = await getConnections()); + const { testHelper } = await provisionTestDatabase(db, pg, { preset: 'minimal' }); + t = testHelper; +}); + +afterAll(() => teardown()); +beforeEach(async () => { await db.beforeEach(); }); +afterEach(async () => { await db.afterEach(); }); +``` + +### DbMetaTest (Higher-Level API) + +For tests needing org charts, labeled snapshots, or `asUser()` context isolation: + +```typescript +let ctx: DbMetaTest; +beforeAll(async () => { ctx = await DbMetaTest.setup({ preset: 'hierarchy' }); }); +afterAll(() => ctx.teardown()); +ctx.installHooks(); +``` + +## Using Test Utilities for Readable Tests + +Tests should read like scenarios, not SQL: + +```typescript +// ❌ Hard to read +const limitsTable = await pg.one(`SELECT s.schema_name, lm.limits_table::text FROM ...`); +await pg.any(`INSERT INTO "${limitsTable.schema_name}"."${limitsTable.limits_table}" ...`); + +// ✅ Readable +const limitsTable = await t.getLimitsTable('app'); +await pg.any(`INSERT INTO ${limitsTable} (name, num, max, actor_id) VALUES ('seats', 0, 3, $1)`, [user.id]); +const result = await t.callLimitIncrement('seats', 'app'); +``` diff --git a/.agents/skills/graphile-search.zip b/.agents/skills/graphile-search.zip new file mode 100644 index 0000000000000000000000000000000000000000..63385959a98c03a7104202240993a3db061d94df GIT binary patch literal 16931 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 { + console.log(`${a.title} (score: ${a.searchScore})`); + }); +} +``` + +pgvector is excluded from `unifiedSearch` because it requires a vector array input, not text. + +### searchScore — Composite Relevance + +A normalized 0..1 relevance score that combines all active search signals. Returns `null` when no search filters are active. + +```typescript +const result = await db.article.findMany({ + where: { + fullTextSearchTsv: 'postgres tutorial', + }, + orderBy: 'SEARCH_TSV_RANK_DESC', + select: { + id: true, + title: true, + searchTsvRank: true, // per-adapter score + searchScore: true, // composite: normalized 0..1 + }, +}).execute(); +``` + +The composite score normalizes each adapter's raw score to 0..1 (bounded ranges use linear normalization, unbounded use sigmoid) and averages them. Custom weights can be configured in the preset. + +--- + +## TSVector Queries + +### Basic Full-Text Search + +```typescript +const result = await db.article.findMany({ + where: { + fullTextSearchTsv: 'postgres full text', + }, + orderBy: 'SEARCH_TSV_RANK_DESC', + select: { + id: true, + title: true, + searchTsvRank: true, + }, +}).execute(); + +if (result.ok) { + result.data.articles.nodes.forEach(a => { + console.log(`${a.title} (rank: ${a.searchTsvRank})`); + }); +} +``` + +### Search with Pagination + +```typescript +const result = await db.article.findMany({ + where: { + fullTextSearchTsv: 'database indexing', + }, + orderBy: 'SEARCH_TSV_RANK_DESC', + first: 10, + after: cursor, + select: { + id: true, + title: true, + searchTsvRank: true, + }, +}).execute(); +``` + +### Combining Search with Other Filters + +```typescript +const result = await db.article.findMany({ + where: { + fullTextSearchTsv: 'postgres', + isPublished: { equalTo: true }, + category: { equalTo: 'tech' }, + }, + orderBy: 'SEARCH_TSV_RANK_DESC', + first: 20, + select: { + id: true, + title: true, + category: true, + searchTsvRank: true, + }, +}).execute(); +``` + +### Field Naming Convention + +| DB Column | Filter Field | Score Field | OrderBy | +|-----------|-------------|-------------|---------| +| `search_tsv` | `fullTextSearchTsv` | `searchTsvRank` | `SEARCH_TSV_RANK_ASC/DESC` | +| `body_tsv` | `fullTextBodyTsv` | `bodyTsvRank` | `BODY_TSV_RANK_ASC/DESC` | + +**Pattern:** +- Filter: `fullText` + camelCase(column) +- Score: camelCase(column) + `Rank` (Float, higher = better, null when no filter active) +- OrderBy: SCREAMING_SNAKE(column) + `_RANK_ASC/DESC` + +--- + +## BM25 Queries + +### Basic BM25 Search + +```typescript +const result = await db.document.findMany({ + where: { + bm25Content: { query: 'postgres full text search' }, + }, + orderBy: 'BM25_CONTENT_SCORE_ASC', + select: { + id: true, + title: true, + bm25ContentScore: true, + }, +}).execute(); + +if (result.ok) { + result.data.documents.nodes.forEach(d => { + console.log(`${d.title} (score: ${d.bm25ContentScore})`); + }); +} +``` + +**Important:** BM25 scores are negative — more negative means more relevant. Sort ascending (`_ASC`) to get the best matches first. + +### Search with Pagination + +```typescript +const result = await db.document.findMany({ + where: { + bm25Content: { query: 'machine learning' }, + }, + orderBy: 'BM25_CONTENT_SCORE_ASC', + first: 10, + after: cursor, + select: { + id: true, + title: true, + bm25ContentScore: true, + }, +}).execute(); +``` + +### Combining BM25 with Other Filters + +```typescript +const result = await db.document.findMany({ + where: { + bm25Content: { query: 'kubernetes deployment' }, + isPublished: { equalTo: true }, + category: { equalTo: 'devops' }, + }, + orderBy: 'BM25_CONTENT_SCORE_ASC', + first: 20, + select: { + id: true, + title: true, + category: true, + bm25ContentScore: true, + }, +}).execute(); +``` + +### Field Naming Convention + +| DB Column | Filter Field | Score Field | OrderBy | +|-----------|-------------|-------------|---------| +| `content` | `bm25Content` | `bm25ContentScore` | `BM25_CONTENT_SCORE_ASC/DESC` | +| `body` | `bm25Body` | `bm25BodyScore` | `BM25_BODY_SCORE_ASC/DESC` | + +**Pattern:** +- Filter: `bm25` + CamelCase(column) — accepts `{ query: String }` input +- Score: `bm25` + CamelCase(column) + `Score` (Float, negative, more negative = better, null when no filter active) +- OrderBy: `BM25_` + SCREAMING_SNAKE(column) + `_SCORE_ASC/DESC` + +--- + +## Trigram Queries + +### Similarity Search (via StringTrgmFilter) + +On qualifying tables (those with intentional search infrastructure), string columns get `similarTo` and `wordSimilarTo` operators: + +```typescript +// similarTo: overall trigram similarity +const result = await db.article.findMany({ + where: { + title: { similarTo: { value: 'postgre', threshold: 0.2 } }, + }, + first: 20, + select: { + id: true, + title: true, + titleTrgmSimilarity: true, // 0..1, higher = more similar + }, +}).execute(); + +if (result.ok) { + result.data.articles.nodes.forEach(a => { + console.log(`${a.title} (similarity: ${a.titleTrgmSimilarity})`); + }); +} +``` + +```typescript +// wordSimilarTo: best substring similarity (better for search-as-you-type) +const result = await db.article.findMany({ + where: { + title: { wordSimilarTo: { value: 'postgres', threshold: 0.3 } }, + }, + orderBy: 'TITLE_TRGM_SIMILARITY_DESC', + first: 10, + select: { + id: true, + title: true, + titleTrgmSimilarity: true, + }, +}).execute(); +``` + +### Adapter-Level Filter + +```typescript +const result = await db.article.findMany({ + where: { + trgmTitle: { value: 'postgre' }, + }, + orderBy: 'TITLE_TRGM_SIMILARITY_DESC', + select: { + id: true, + title: true, + titleTrgmSimilarity: true, + }, +}).execute(); +``` + +### ILIKE Search (GIN-Accelerated) + +The GIN trigram index still accelerates `ILIKE` queries via standard filter operators: + +```typescript +const result = await db.article.findMany({ + where: { + title: { likeInsensitive: '%postgres%' }, + }, + first: 20, + select: { + id: true, + title: true, + }, +}).execute(); +``` + +### Field Naming Convention + +| DB Column | Adapter Filter | Score Field | OrderBy | +|-----------|---------------|-------------|---------| +| `title` | `trgmTitle` | `titleTrgmSimilarity` | `TITLE_TRGM_SIMILARITY_ASC/DESC` | +| `name` | `trgmName` | `nameTrgmSimilarity` | `NAME_TRGM_SIMILARITY_ASC/DESC` | + +**Pattern:** +- Adapter filter: `trgm` + CamelCase(column) — accepts `{ value: String, threshold?: Float }` +- Connection filter: column gets `StringTrgmFilter` with `similarTo`/`wordSimilarTo` operators +- Score: camelCase(column) + `TrgmSimilarity` (Float, 0..1, higher = better) +- OrderBy: SCREAMING_SNAKE(column) + `_TRGM_SIMILARITY_ASC/DESC` + +--- + +## pgvector Queries + +### Basic Nearest Neighbor Search + +```typescript +const result = await db.document.findMany({ + where: { + vectorEmbedding: { + vector: queryVector, + metric: 'COSINE', + }, + }, + orderBy: 'EMBEDDING_DISTANCE_ASC', + first: 10, + select: { + id: true, + title: true, + embeddingDistance: true, + }, +}).execute(); + +if (result.ok) { + result.data.documents.nodes.forEach(d => { + console.log(`${d.title} (distance: ${d.embeddingDistance})`); + }); +} +``` + +### Search with Distance Threshold + +```typescript +const result = await db.document.findMany({ + where: { + vectorEmbedding: { + vector: queryVector, + metric: 'COSINE', + distance: 0.5, // filter out results beyond threshold + }, + }, + orderBy: 'EMBEDDING_DISTANCE_ASC', + select: { + id: true, + title: true, + embeddingDistance: true, + }, +}).execute(); +``` + +### Combining Vector Search with Other Filters + +```typescript +const result = await db.document.findMany({ + where: { + vectorEmbedding: { + vector: queryVector, + metric: 'COSINE', + }, + isPublished: { equalTo: true }, + category: { equalTo: 'tech' }, + }, + orderBy: 'EMBEDDING_DISTANCE_ASC', + first: 10, + select: { + id: true, + title: true, + category: true, + embeddingDistance: true, + }, +}).execute(); +``` + +### Distance Metrics + +| Metric | Range | Meaning | Sort Direction | +|--------|-------|---------|----------------| +| `COSINE` | 0 to 2 | 0 = identical, 2 = opposite | ASC = most similar | +| `L2` | 0 to infinity | 0 = identical | ASC = most similar | +| `IP` | -infinity to 0 | More negative = more similar | ASC = most similar | + +### Field Naming Convention + +| DB Column | Filter Field | Distance Field | OrderBy | +|-----------|-------------|----------------|---------| +| `embedding` | `vectorEmbedding` | `embeddingDistance` | `EMBEDDING_DISTANCE_ASC/DESC` | +| `content_vec` | `vectorContentVec` | `contentVecDistance` | `CONTENT_VEC_DISTANCE_ASC/DESC` | + +**Pattern:** +- Filter: `vector` + CamelCase(column) — accepts `{ vector: [Float!]!, metric?: String, distance?: Float, includeChunks?: Boolean }` +- Distance: camelCase(column) + `Distance` (Float, lower = closer, null when no filter active) +- OrderBy: SCREAMING_SNAKE(column) + `_DISTANCE_ASC/DESC` + +### Chunk-Aware Vector Search + +Tables with the `@hasChunks` smart tag automatically get chunk-aware search. The distance returned is `LEAST(parent_distance, MIN(chunk_distance))` — the best match across the document embedding and all chunk embeddings. + +Chunk search is **on by default** for `@hasChunks` tables. No extra code is needed: + +```typescript +// Chunk-aware search — ON by default for @hasChunks tables +// Distance = LEAST(parent embedding distance, closest chunk distance) +const result = await db.document.findMany({ + where: { + vectorEmbedding: { + vector: queryVector, + metric: 'COSINE', + // includeChunks defaults to true when @hasChunks is present + }, + }, + orderBy: 'EMBEDDING_DISTANCE_ASC', + first: 10, + select: { + id: true, + title: true, + embeddingDistance: true, // best distance across parent + all chunks + }, +}).execute(); + +if (result.ok) { + result.data.documents.nodes.forEach(d => { + console.log(`${d.title} (distance: ${d.embeddingDistance})`); + }); +} +``` + +### Opt Out of Chunk Search + +Set `includeChunks: false` to only search the parent embedding: + +```typescript +const result = await db.document.findMany({ + where: { + vectorEmbedding: { + vector: queryVector, + metric: 'COSINE', + includeChunks: false, // only use parent embedding + }, + }, + orderBy: 'EMBEDDING_DISTANCE_ASC', + first: 10, + select: { + id: true, + title: true, + embeddingDistance: true, // parent distance only + }, +}).execute(); +``` + +### Chunk-Aware Search with Distance Threshold + +The distance threshold applies to the combined (chunk-aware) distance: + +```typescript +const result = await db.document.findMany({ + where: { + vectorEmbedding: { + vector: queryVector, + metric: 'COSINE', + distance: 0.3, // threshold applies to LEAST(parent, chunk) + }, + isPublished: { equalTo: true }, + }, + orderBy: 'EMBEDDING_DISTANCE_ASC', + first: 20, + select: { + id: true, + title: true, + embeddingDistance: true, + searchScore: true, // composite 0..1 relevance + }, +}).execute(); +``` + +> **Note:** `includeChunks` only appears on `VectorNearbyInput` when at least one table in the schema has the `@hasChunks` smart tag. For tables without chunks, the field is absent and vector search behaves as standard parent-only search. + +--- + +## Multi-Strategy Patterns + +### Fuzzy Fallback (BM25 + Trigram) + +```typescript +// Primary: BM25 search +const bm25Result = await db.document.findMany({ + where: { bm25Content: { query: userQuery } }, + orderBy: 'BM25_CONTENT_SCORE_ASC', + first: 10, + select: { id: true, title: true, bm25ContentScore: true }, +}).execute(); + +const bm25Docs = bm25Result.ok ? bm25Result.data.documents.nodes : []; + +// Fallback: trigram if BM25 returned few results +if (bm25Docs.length < 3) { + const fuzzyResult = await db.document.findMany({ + where: { + title: { similarTo: { value: userQuery, threshold: 0.15 } }, + }, + orderBy: 'TITLE_TRGM_SIMILARITY_DESC', + first: 10, + select: { id: true, title: true, titleTrgmSimilarity: true }, + }).execute(); +} +``` + +### Autocomplete Pipeline (Trigram + TSVector) + +```typescript +// Stage 1: Autocomplete (on every keystroke) +const autocomplete = await db.article.findMany({ + where: { + title: { similarTo: { value: partialInput, threshold: 0.15 } }, + }, + orderBy: 'TITLE_TRGM_SIMILARITY_DESC', + first: 5, + select: { id: true, title: true, titleTrgmSimilarity: true }, +}).execute(); + +// Stage 2: Full search (on form submit) +const search = await db.article.findMany({ + where: { fullTextSearchTsv: fullQuery }, + orderBy: 'SEARCH_TSV_RANK_DESC', + first: 20, + select: { id: true, title: true, searchTsvRank: true }, +}).execute(); +``` + +### Semantic + Keyword Hybrid (pgvector + TSVector) + +```typescript +// Semantic search +const semanticResult = await db.document.findMany({ + where: { vectorEmbedding: { vector: queryEmbedding, metric: 'COSINE' } }, + orderBy: 'EMBEDDING_DISTANCE_ASC', + first: 20, + select: { id: true, title: true, embeddingDistance: true }, +}).execute(); + +// Keyword search +const keywordResult = await db.document.findMany({ + where: { fullTextSearchTsv: userQuery }, + orderBy: 'SEARCH_TSV_RANK_DESC', + first: 20, + select: { id: true, title: true, searchTsvRank: true }, +}).execute(); + +// Merge by reciprocal rank fusion or custom weighting +``` diff --git a/.agents/skills/graphile-search/references/pgvector-adapter.md b/.agents/skills/graphile-search/references/pgvector-adapter.md new file mode 100644 index 0000000000..bc9e40bcf0 --- /dev/null +++ b/.agents/skills/graphile-search/references/pgvector-adapter.md @@ -0,0 +1,209 @@ +# pgvector Adapter + +Semantic similarity search using the `pgvector` extension. Supports cosine, L2, and inner product distance metrics for embedding-based search and RAG. + +## How It Works + +The pgvector adapter: +1. **Detects** `vector(N)` columns on tables (via `VectorCodecPlugin` codec registration) +2. **Registers** `VectorSearchInput` type (`{ query: [Float!]!, metric: VectorMetric }`) +3. **Generates** distance score as a computed field and orderBy enum + +## Adapter Configuration + +```typescript +import { createPgvectorAdapter } from 'graphile-search'; + +createPgvectorAdapter({ + filterPrefix: 'vector', // default: 'vector' + defaultMetric: 'COSINE', // default: 'COSINE' (COSINE | L2 | IP) +}) +``` + +## Generated GraphQL + +Given a table with an `embedding vector(768)` column: + +### Filter + +```graphql +query { + allDocuments(where: { + vectorEmbedding: { query: [0.1, 0.2, ...], metric: COSINE } + }) { + nodes { + title + embeddingVectorDistance + } + } +} +``` + +### Score Field + +```graphql +type Document { + embeddingVectorDistance: Float # distance score, lower = more similar +} +``` + +### OrderBy + +```graphql +enum DocumentsOrderBy { + EMBEDDING_VECTOR_DISTANCE_ASC # most similar first + EMBEDDING_VECTOR_DISTANCE_DESC # least similar first +} +``` + +## Distance Metrics + +| Metric | Operator | Range | Notes | +|--------|----------|-------|-------| +| `COSINE` | `<=>` | 0-2 | 0 = identical; best for normalized embeddings | +| `L2` | `<->` | 0-inf | Euclidean distance | +| `IP` | `<#>` | -inf to 0 | Negative inner product; less negative = more similar | + +## Score Semantics + +| Property | Value | +|----------|-------| +| Metric | `distance` | +| Lower is better | Yes (closer = more similar) | +| Range | Unbounded (uses sigmoid normalization in searchScore) | + +## Adapter Flags + +| Flag | Value | +|------|-------| +| `isSupplementary` | `false` (primary adapter) | +| `isIntentionalSearch` | `false` (embeddings do NOT trigger trgm) | +| `supportsTextSearch` | `false` (not included in unifiedSearch composite) | + +pgvector is the only primary adapter that sets `isIntentionalSearch: false`. This is because vector embeddings operate on a different domain than text search — a table with only pgvector columns shouldn't get trgm similarity fields on its text columns. + +## Chunk-Aware Search (`@hasChunks`) + +Tables with long-form content (documents, articles, etc.) often split text into **chunks**, each with its own embedding. The pgvector adapter transparently queries across parent and chunk embeddings when the `@hasChunks` smart tag is present on the table's codec. + +### How It Works + +1. The parent table has a `vector(N)` column (the document-level embedding) +2. A separate chunks table stores per-chunk embeddings with a foreign key back to the parent +3. The `@hasChunks` smart tag tells the adapter where to find the chunks table +4. At query time, the adapter computes `LEAST(parent_distance, MIN(chunk_distance))` — the best match across all embeddings + +### Smart Tag Configuration + +Set `@hasChunks` on the parent table codec as a JSON object: + +```json +{ + "chunksTable": "documents_chunks", + "parentFk": "parent_id", + "parentPk": "id", + "embeddingField": "embedding" +} +``` + +| Field | Default | Description | +|-------|---------|-------------| +| `chunksTable` | *(required)* | Name of the chunks table | +| `chunksSchema` | parent table's schema | Schema of the chunks table | +| `parentFk` | `parent_id` | Column in chunks table that references the parent | +| `parentPk` | `id` | Primary key column on the parent table | +| `embeddingField` | `embedding` | Vector column in the chunks table | + +In Constructive, the `SearchVector` node type with chunks enabled automatically creates the chunks table and wires up the relationship. The smart tag is applied via a Graphile plugin or smart tags file. + +### `includeChunks` Filter Field + +When `@hasChunks` is detected, `VectorNearbyInput` gains an `includeChunks` boolean field: + +```graphql +input VectorNearbyInput { + vector: [Float!]! + metric: VectorMetric + distance: Float + includeChunks: Boolean # only present when @hasChunks is on the table +} +``` + +- **`true` (default for `@hasChunks` tables):** Distance = `LEAST(parent_distance, MIN(chunk_distance))` +- **`false`:** Distance = parent embedding distance only + +### Generated SQL + +When `includeChunks` is active, the adapter generates: + +```sql +LEAST( + COALESCE(parent.embedding <=> $query, 'Infinity'::float), + COALESCE( + (SELECT MIN(c.embedding <=> $query) + FROM documents_chunks AS c + WHERE c.parent_id = parent.id), + 'Infinity'::float + ) +) +``` + +`COALESCE` handles cases where the parent or chunks may not have embeddings. + +### GraphQL Query Examples + +```graphql +# Chunk-aware (default) — returns best distance across parent + all chunks +query { + allDocuments(where: { + vectorEmbedding: { vector: [0.1, 0.2, ...], metric: COSINE } + }) { + nodes { + title + embeddingVectorDistance # LEAST(parent, closest chunk) + } + } +} + +# Parent-only — opt out of chunk search +query { + allDocuments(where: { + vectorEmbedding: { vector: [0.1, 0.2, ...], metric: COSINE, includeChunks: false } + }) { + nodes { + title + embeddingVectorDistance # parent distance only + } + } +} +``` + +## VectorCodecPlugin + +The `VectorCodecPlugin` (included in `UnifiedSearchPreset`) teaches PostGraphile about the `vector` PostgreSQL type: + +- **Wire format:** PostgreSQL sends vectors as text `[0.1,0.2,...,0.768]` +- **JavaScript:** `number[]` +- **GraphQL scalar:** `Vector` (serialized as `[Float]`) + +Without this plugin, PostGraphile silently ignores `vector(N)` columns. + +## Codegen — Vector Scalar Mapping + +When generating typed SDK code, the `Vector` scalar maps to `number[]`: + +```typescript +// graphql-codegen.config.ts +scalars: { + Vector: 'number[]', +} +``` + +With `@constructive-io/graphql-codegen >= 4.6.0`, this mapping is built-in. + +## Prerequisites + +- `pgvector` extension enabled (pre-enabled in Constructive stack) +- A `vector(N)` column on the table +- An HNSW or IVFFlat index for performance (`CREATE INDEX ... USING hnsw (embedding vector_cosine_ops)`) +- `VectorCodecPlugin` loaded (included automatically by `UnifiedSearchPreset`) diff --git a/.agents/skills/graphile-search/references/search-adapter-interface.md b/.agents/skills/graphile-search/references/search-adapter-interface.md new file mode 100644 index 0000000000..0085d40783 --- /dev/null +++ b/.agents/skills/graphile-search/references/search-adapter-interface.md @@ -0,0 +1,134 @@ +# SearchAdapter Interface + +The `SearchAdapter` interface is the contract each search algorithm implements to plug into the unified search plugin. + +## Interface Definition + +```typescript +interface SearchAdapter { + /** Unique identifier (e.g. 'tsv', 'bm25', 'trgm', 'vector') */ + name: string; + + /** Score semantics for normalization */ + scoreSemantics: ScoreSemantics; + + /** + * When true, only activates on tables that already have at least one + * column detected by an adapter whose isIntentionalSearch is true. + * Prevents trgm from adding fields to every table with text columns. + * @default false + */ + isSupplementary?: boolean; + + /** + * When true, this adapter's presence triggers supplementary adapters. + * tsvector and BM25 set this to true. pgvector sets this to false + * (embeddings are not text search). + * @default true + */ + isIntentionalSearch?: boolean; + + /** Filter prefix for connection filter field names (e.g. 'bm25' -> bm25Body) */ + filterPrefix: string; + + /** + * Whether this adapter supports plain text queries. + * If true, columns are included in the unifiedSearch composite filter. + * pgvector sets this to false (requires vector input, not text). + * @default false + */ + supportsTextSearch?: boolean; + + /** Detect searchable columns on a given codec/table */ + detectColumns(codec: PgCodecWithAttributes, build: any): SearchableColumn[]; + + /** Register any custom GraphQL types during the init hook */ + registerTypes(build: any): void; + + /** Apply a filter and return the SQL expression + score select index */ + applyFilter(args: FilterApplyArgs): FilterApplyResult | null; + + /** Build a text search input value from a plain text query (for unifiedSearch) */ + buildTextSearchInput?(text: string): unknown; +} +``` + +## ScoreSemantics + +```typescript +interface ScoreSemantics { + /** Metric name for field naming (e.g. 'rank', 'score', 'similarity', 'distance') */ + metric: string; + + /** If true, lower values are better (BM25, pgvector distance) */ + lowerIsBetter: boolean; + + /** + * Known range bounds for normalization, or null if unbounded. + * - trgm: [0, 1] + * - tsvector: [0, 1] + * - BM25: null (unbounded negative) + * - pgvector: null (0 to infinity) + */ + range: [number, number] | null; +} +``` + +## SearchableColumn + +```typescript +interface SearchableColumn { + /** The raw PostgreSQL column name (e.g. 'body', 'tsv', 'embedding') */ + attributeName: string; + + /** Optional extra data the adapter needs during SQL generation */ + adapterData?: unknown; +} +``` + +## Creating a Custom Adapter + +```typescript +import type { SearchAdapter } from 'graphile-search'; + +function createMyAdapter(): SearchAdapter { + return { + name: 'myalgo', + scoreSemantics: { + metric: 'relevance', + lowerIsBetter: false, + range: [0, 1], + }, + filterPrefix: 'myalgo', + supportsTextSearch: true, + + detectColumns(codec, build) { + // Return columns that have your search infrastructure + return []; + }, + + registerTypes(build) { + // Register any custom GraphQL input types + }, + + applyFilter(args) { + // Generate SQL WHERE clause and score expression + return null; + }, + + buildTextSearchInput(text) { + return { query: text }; + }, + }; +} +``` + +Then register it: + +```typescript +import { createUnifiedSearchPlugin } from 'graphile-search'; + +const plugin = createUnifiedSearchPlugin({ + adapters: [createMyAdapter()], +}); +``` diff --git a/.agents/skills/graphile-search/references/trgm-adapter.md b/.agents/skills/graphile-search/references/trgm-adapter.md new file mode 100644 index 0000000000..4cd275fd0b --- /dev/null +++ b/.agents/skills/graphile-search/references/trgm-adapter.md @@ -0,0 +1,134 @@ +# Trgm Adapter + +Fuzzy text matching using the `pg_trgm` extension. Provides typo tolerance, "did you mean?" suggestions, and trigram similarity scoring. + +## How It Works + +The trgm adapter: +1. **Detects** text/varchar columns on tables (any `text` or `varchar` column is a candidate) +2. **Registers** `TrgmSearchInput` type (`{ value: String!, threshold: Float }`) +3. **Adds** `similarTo` and `wordSimilarTo` operators on `StringTrgmFilter` +4. **Generates** similarity score as a computed field and orderBy enum + +## Supplementary Adapter Pattern + +Trgm is a **supplementary adapter** — it only activates on tables where at least one "intentional search" adapter (tsvector or BM25) has detected columns. This prevents trgm similarity fields from appearing on every table with text columns. + +pgvector alone does NOT trigger trgm activation because it sets `isIntentionalSearch: false`. + +### Override with @trgmSearch + +Force trgm on tables without intentional search: + +```sql +-- Table-level +COMMENT ON TABLE app_public.contacts IS E'@trgmSearch'; + +-- Column-level +COMMENT ON COLUMN app_public.contacts.name IS E'@trgmSearch'; +``` + +## Adapter Configuration + +```typescript +import { createTrgmAdapter } from 'graphile-search'; + +createTrgmAdapter({ + filterPrefix: 'trgm', // default: 'trgm' + defaultThreshold: 0.3, // default: 0.3 + requireIntentionalSearch: true, // default: true (makes it supplementary) +}) +``` + +Setting `requireIntentionalSearch: false` makes trgm activate on ALL tables with text columns (not recommended for large schemas). + +## Generated GraphQL + +Given a table with intentional search (e.g. tsvector) and a `title text` column: + +### Filter (Connection Filter) + +```graphql +query { + allArticles(where: { + title: { similarTo: { value: "postgre", threshold: 0.2 } } + }) { + nodes { title } + } +} +``` + +```graphql +query { + allArticles(where: { + title: { wordSimilarTo: { value: "postgres", threshold: 0.3 } } + }) { + nodes { title } + } +} +``` + +### Adapter-Level Filter + +```graphql +query { + allArticles(where: { + trgmTitle: { value: "postgre", threshold: 0.2 } + }) { + nodes { + title + titleTrgmSimilarity + } + } +} +``` + +### Score Field + +```graphql +type Article { + titleTrgmSimilarity: Float # pg_trgm similarity() score, 0..1 +} +``` + +### OrderBy + +```graphql +enum ArticlesOrderBy { + TITLE_TRGM_SIMILARITY_ASC + TITLE_TRGM_SIMILARITY_DESC +} +``` + +## StringTrgmFilter vs StringFilter + +On tables that qualify for trgm, string columns use `StringTrgmFilter` instead of the standard `StringFilter`. This type inherits all standard string operators and adds: + +| Operator | SQL | Description | +|----------|-----|-------------| +| `similarTo` | `similarity(col, value) > threshold` | Overall trigram similarity | +| `wordSimilarTo` | `word_similarity(value, col) > threshold` | Best substring similarity | + +Both accept `TrgmSearchInput { value: String!, threshold: Float }`. Default threshold is 0.3. + +## Score Semantics + +| Property | Value | +|----------|-------| +| Metric | `similarity` | +| Lower is better | No (higher = more similar) | +| Range | [0, 1] | + +## Adapter Flags + +| Flag | Value | +|------|-------| +| `isSupplementary` | `true` (when `requireIntentionalSearch` is true) | +| `isIntentionalSearch` | N/A (supplementary adapters don't set this) | +| `supportsTextSearch` | `true` (included in unifiedSearch composite filter) | + +## Prerequisites + +- `pg_trgm` extension enabled (pre-enabled in Constructive stack) +- At least one "intentional search" column on the same table (tsvector or BM25), OR `@trgmSearch` smart tag +- For best performance: a GIN trigram index (`gin_trgm_ops`) on text columns you query diff --git a/.agents/skills/graphile-search/references/tsvector-adapter.md b/.agents/skills/graphile-search/references/tsvector-adapter.md new file mode 100644 index 0000000000..d26b6c9e59 --- /dev/null +++ b/.agents/skills/graphile-search/references/tsvector-adapter.md @@ -0,0 +1,77 @@ +# TSVector Adapter + +Full-text search using PostgreSQL's built-in `tsvector` type. Provides keyword search with stemming, weighted ranking, and phrase matching. + +## How It Works + +The tsvector adapter: +1. **Detects** columns with `tsvector` type on each table +2. **Registers** a `FullText` scalar (via `TsvectorCodecPlugin`) so tsvector columns appear in GraphQL +3. **Adds** a `matches` filter operator on the `FullText` type (via `createMatchesOperatorFactory`) +4. **Generates** `ts_rank()` score as a computed field and orderBy enum + +## Adapter Configuration + +```typescript +import { createTsvectorAdapter } from 'graphile-search'; + +createTsvectorAdapter({ + filterPrefix: 'fullText', // default: 'fullText' + tsConfig: 'english', // default: 'english' +}) +``` + +## Generated GraphQL + +Given a table with a `search_tsv tsvector` column: + +### Filter + +```graphql +query { + allArticles(where: { fullTextSearchTsv: { matches: "postgres tutorial" } }) { + nodes { ... } + } +} +``` + +The `matches` operator uses `websearch_to_tsquery()` internally — supports natural language queries with AND/OR/NOT. + +### Score Field + +```graphql +type Article { + searchTsvRank: Float # ts_rank() score, 0..1, higher = better +} +``` + +### OrderBy + +```graphql +enum ArticlesOrderBy { + SEARCH_TSV_RANK_ASC + SEARCH_TSV_RANK_DESC +} +``` + +## Score Semantics + +| Property | Value | +|----------|-------| +| Metric | `rank` | +| Lower is better | No (higher = more relevant) | +| Range | [0, 1] | + +## Adapter Flags + +| Flag | Value | +|------|-------| +| `isSupplementary` | `false` (primary adapter) | +| `isIntentionalSearch` | `true` (triggers supplementary adapters like trgm) | +| `supportsTextSearch` | `true` (included in unifiedSearch composite filter) | + +## Prerequisites + +- A `tsvector` column on the table (typically populated via metaschema `full_text_search` triggers) +- A GIN index on the tsvector column (for performance) +- `TsvectorCodecPlugin` loaded (included automatically by `UnifiedSearchPreset`) diff --git a/.agents/skills/pgpm.zip b/.agents/skills/pgpm.zip new file mode 100644 index 0000000000000000000000000000000000000000..230a42096297d51644836e10085b35e2813446f6 GIT binary patch literal 49883 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.sql` | Creates the object | `-- Deploy to pg` | +| `revert/.sql` | Removes the object | `-- Revert from pg` | +| `verify/.sql` | Confirms deployment | `-- Verify on pg` | + +### pgpm.plan + +The plan file controls deployment order. Each line: +``` +change_name [dep1 dep2] 2026-01-25T00:00:00Z author +``` + +Dependencies `[...]` must come immediately after the change name, before the timestamp. + +### .control File + +Each module has a `.control` file declaring its name and PostgreSQL extension dependencies: +``` +comment = 'My database module' +default_version = '0.0.1' +requires = 'uuid-ossp,plpgsql' +``` + +The `requires` field uses **control file names** (e.g., `pgpm-base32`), NOT npm names (e.g., `@pgpm/base32`). See [references/module-naming.md](references/module-naming.md) for details. + +### Workspace vs Module + +- **Workspace** = pnpm monorepo containing one or more modules (`pgpm init workspace`) +- **Module** = individual database package with its own .control, pgpm.plan, and deploy/revert/verify directories (`pgpm init`) + +## Critical Rules + +### 1. NEVER Use CREATE OR REPLACE + +pgpm is deterministic. Each change deploys exactly once. To modify an existing object, create a new change that drops and recreates it. + +```sql +-- CORRECT +CREATE FUNCTION app.my_function() ... + +-- WRONG +CREATE OR REPLACE FUNCTION app.my_function() ... +``` + +### 2. NO Transaction Wrapping + +Do NOT add `BEGIN`/`COMMIT` to SQL files. pgpm handles transactions automatically. + +```sql +-- CORRECT — just the raw SQL +CREATE TABLE app.users ( ... ); + +-- WRONG +BEGIN; +CREATE TABLE app.users ( ... ); +COMMIT; +``` + +### 3. NEVER Run CREATE EXTENSION Directly + +pgpm handles extension creation during deploy. Declare extensions in your `.control` file's `requires` field instead. + +## Key Commands Quick Reference + +| Command | Purpose | +|---------|---------| +| `pgpm init workspace` | Create a new pnpm monorepo workspace | +| `pgpm init` | Create a new module in a workspace | +| `pgpm add --requires ` | Add a new change (creates deploy/revert/verify files) | +| `pgpm deploy` | Deploy changes to database | +| `pgpm deploy --createdb --database ` | Create database and deploy | +| `pgpm verify` | Run verification scripts | +| `pgpm revert --to ` | Revert changes back to a specific point | +| `pgpm tag ` | Tag current state for targeted deploys | +| `pgpm install ` | Install a pgpm module dependency | +| `pgpm extension` | Interactive dependency selector | +| `pgpm test-packages` | Test all packages | +| `pgpm test-packages --full-cycle` | Test deploy → verify → revert → redeploy | +| `pgpm docker start` | Start PostgreSQL container | +| `pgpm docker stop` | Stop PostgreSQL container | +| `pgpm env` | Print environment variable exports | +| `pgpm migrate status` | Show deployed vs pending changes | +| `pgpm plan` | Generate plan from SQL `-- requires:` comments | +| `pgpm package` | Bundle module for publishing | + +## Essential Development Workflow + +```bash +# Start PostgreSQL and load environment +pgpm docker start +eval "$(pgpm env)" + +# Bootstrap admin users (first time) +pgpm admin-users bootstrap --yes +pgpm admin-users add --test --yes + +# Add a new change +pgpm add schemas/app/tables/orders --requires schemas/app + +# Edit deploy/revert/verify SQL files + +# Deploy and verify +pgpm deploy --createdb --database mydb +pgpm verify + +# Run tests +pnpm test + +# Tag a release +pgpm tag v1.0.0 +``` + +## Common Workflows + +### Adding a Table + +```bash +# Add the change +pgpm add schemas/app/tables/users --requires schemas/app +``` + +```sql +-- deploy/schemas/app/tables/users.sql +-- Deploy schemas/app/tables/users to pg +-- requires: schemas/app + +CREATE TABLE app.users ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + email text NOT NULL UNIQUE, + name text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now() +); +``` + +```sql +-- revert/schemas/app/tables/users.sql +-- Revert schemas/app/tables/users from pg + +DROP TABLE IF EXISTS app.users; +``` + +```sql +-- verify/schemas/app/tables/users.sql +-- Verify schemas/app/tables/users on pg + +DO $$ +BEGIN + ASSERT (SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_schema = 'app' AND table_name = 'users' + )), 'Table app.users does not exist'; +END $$; +``` + +### Writing Tests (with pgsql-test) + +```typescript +import { getConnections } from 'pgsql-test'; +import * as seed from 'pgsql-test/seed'; + +let db, teardown; + +beforeAll(async () => { + ({ db, teardown } = await getConnections({}, [ + seed.pgpm({ database: 'mydb' }) + ])); +}); +afterAll(() => teardown()); +beforeEach(() => db.beforeEach()); +afterEach(() => db.afterEach()); + +it('creates a user', async () => { + const result = await db.query(` + INSERT INTO app.users (email, name) + VALUES ('test@example.com', 'Test User') + RETURNING * + `); + expect(result.rows[0].email).toBe('test@example.com'); +}); +``` + +### CI/CD Full-Cycle Validation + +```bash +pgpm test-packages --full-cycle +``` + +This proves: deploy → verify → revert → redeploy works for every module. + +### Fixing a Broken Deploy + +```bash +# Check status +pgpm migrate status + +# Revert the bad change +pgpm revert --to + +# Fix the SQL, then redeploy +pgpm deploy +``` + +## Troubleshooting Quick Reference + +| Issue | Quick Fix | +|-------|-----------| +| Can't connect to database | `pgpm docker start && eval "$(pgpm env)"` | +| `PGHOST` not set | `eval "$(pgpm env)"` — must use `eval`, not run in subshell | +| Transaction aborted in tests | Use `db.beforeEach()` / `db.afterEach()` savepoint pattern | +| Tests interfere with each other | Ensure every test file has `beforeEach`/`afterEach` hooks | +| Module not found during deploy | Verify `.control` file exists and workspace structure is correct | +| Dependency not found | Check `.control` `requires` uses control names, not npm names | +| Port 5432 already in use | `lsof -i :5432` then stop conflicting process | +| `Invalid line format` in pgpm.plan | Dependencies `[...]` must come right after change name, before timestamp | +| `CREATE OR REPLACE` error | Remove `OR REPLACE` — pgpm is deterministic | +| Container won't start | `pgpm docker start --recreate` for a fresh container | + +See [references/troubleshooting.md](references/troubleshooting.md) for detailed solutions. + +## Reference Guide + +Consult these reference files for detailed documentation on specific topics: + +| Reference | Topic | Consult When | +|-----------|-------|--------------| +| [references/cli.md](references/cli.md) | Complete CLI command reference | Looking up command flags, options, or less common commands | +| [references/workspace.md](references/workspace.md) | Creating and managing workspaces | Setting up a new project, understanding workspace structure | +| [references/changes.md](references/changes.md) | Authoring database changes | Writing deploy/revert/verify scripts, using `pgpm add` | +| [references/sql-conventions.md](references/sql-conventions.md) | SQL file format and conventions | Writing SQL files, naming conventions, header format | +| [references/dependencies.md](references/dependencies.md) | Managing module dependencies | Within-module or cross-module dependency references | +| [references/deploy-lifecycle.md](references/deploy-lifecycle.md) | Deploy/verify/revert lifecycle | Understanding deployment process, tagging, status checking | +| [references/docker.md](references/docker.md) | Docker container management | Starting/stopping PostgreSQL, custom container options | +| [references/env.md](references/env.md) | Environment variable management | Loading env vars, profiles, Supabase local development | +| [references/environment-configuration.md](references/environment-configuration.md) | @pgpmjs/env library API | Programmatic configuration, config hierarchy, utility functions | +| [references/extensions.md](references/extensions.md) | PostgreSQL extensions & pgpm modules | Adding extensions, installing @pgpm/* modules, .control requires | +| [references/module-naming.md](references/module-naming.md) | npm names vs control file names | Confused about which identifier to use where | +| [references/plan-format.md](references/plan-format.md) | pgpm.plan file format | Fixing `Invalid line format` errors, editing plan files manually | +| [references/publishing.md](references/publishing.md) | Publishing modules to npm | Bundling, versioning with lerna, publishing @pgpm/* packages | +| [references/testing.md](references/testing.md) | PostgreSQL integration tests | Setting up pgsql-test, seed adapters, test patterns | +| [references/troubleshooting.md](references/troubleshooting.md) | Common issues and solutions | Debugging connection, deployment, testing, or Docker problems | +| [references/ci-cd.md](references/ci-cd.md) | GitHub Actions CI/CD workflows | Setting up CI for pgpm projects, PostgreSQL service containers, test sharding | + +### Project Scaffolding + +| Reference | Topic | Consult When | +|-----------|-------|--------------| +| [references/starter-kits.md](references/starter-kits.md) | `pgpm init` templates and scaffolding | Creating new workspaces, modules, or Next.js apps | +| [references/template-authoring.md](references/template-authoring.md) | Custom boilerplate authoring | Creating `.boilerplate.json`, placeholder system, question config | +| [references/nextjs-app.md](references/nextjs-app.md) | Constructive Next.js app boilerplate | Setting up frontend app, project structure, auth flows, SDK generation | + +### Database Operations (from constructive-db) + +| Reference | Topic | Consult When | +|-----------|-------|--------------| +| [references/pgpm-tables.md](references/pgpm-tables.md) | Table creation rules | Creating tables in metaschema, deterministic ID triggers | +| [references/pgpm-export.md](references/pgpm-export.md) | DB export to pgpm packages | Exporting a live database to pgpm for deterministic migrations | + +## Cross-References + +Related skills: +- `constructive-testing` — PostgreSQL testing patterns (RLS, seeding, snapshots, JWT context) +- `constructive-setup` — Monorepo setup and local development environment +- `constructive-cli` — Generated CLI commands and scaffolding diff --git a/.agents/skills/pgpm/references/changes.md b/.agents/skills/pgpm/references/changes.md new file mode 100644 index 0000000000..1aa948dd34 --- /dev/null +++ b/.agents/skills/pgpm/references/changes.md @@ -0,0 +1,258 @@ + +# Authoring Database Changes with PGPM + +Create safe, reversible database changes using pgpm's three-file pattern. Every change has deploy, revert, and verify scripts. + +## When to Apply + +Use this skill when: +- Adding tables, functions, triggers, or indexes +- Creating database migrations +- Modifying existing schema +- Organizing database changes in a pgpm module + +## The Three-File Pattern + +Every database change consists of three files: + +| File | Purpose | +|------|---------| +| `deploy/.sql` | Creates the object | +| `revert/.sql` | Removes the object | +| `verify/.sql` | Confirms deployment | + +## Adding a Change + +```bash +pgpm add schemas/pets/tables/pets --requires schemas/pets +``` + +This creates: +```text +deploy/schemas/pets/tables/pets.sql +revert/schemas/pets/tables/pets.sql +verify/schemas/pets/tables/pets.sql +``` + +And updates `pgpm.plan`: +```sh +schemas/pets/tables/pets [schemas/pets] 2025-11-14T00:00:00Z Author +``` + +## Writing Deploy Scripts + +Deploy scripts create database objects. Use `CREATE`, not `CREATE OR REPLACE` (pgpm is deterministic). + +**deploy/schemas/pets/tables/pets.sql:** +```sql +-- Deploy: schemas/pets/tables/pets +-- requires: schemas/pets + +CREATE TABLE pets.pets ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + name TEXT NOT NULL, + breed TEXT, + owner_id UUID, + created_at TIMESTAMPTZ DEFAULT NOW() +); +``` + +**Important:** Never use `CREATE OR REPLACE` unless absolutely necessary. pgpm tracks what's deployed and ensures idempotency through its migration system. + +## Writing Revert Scripts + +Revert scripts undo the deploy. Must leave database in pre-deploy state. + +**revert/schemas/pets/tables/pets.sql:** +```sql +-- Revert: schemas/pets/tables/pets + +DROP TABLE IF EXISTS pets.pets; +``` + +## Writing Verify Scripts + +Verify scripts confirm deployment succeeded. Use `DO` blocks that raise exceptions on failure. + +**verify/schemas/pets/tables/pets.sql:** +```sql +-- Verify: schemas/pets/tables/pets + +DO $$ +BEGIN + PERFORM 1 FROM pg_tables + WHERE schemaname = 'pets' AND tablename = 'pets'; + IF NOT FOUND THEN + RAISE EXCEPTION 'Table pets.pets does not exist'; + END IF; +END $$; +``` + +## Nested Paths + +Organize changes hierarchically using nested paths: + +```text +schemas/ +└── app/ + ├── schema.sql + ├── tables/ + │ └── users/ + │ ├── table.sql + │ └── indexes/ + │ └── email.sql + ├── functions/ + │ └── create_user.sql + └── triggers/ + └── updated_at.sql +``` + +Add changes with full paths: +```bash +pgpm add schemas/app/schema +pgpm add schemas/app/tables/users/table --requires schemas/app/schema +pgpm add schemas/app/tables/users/indexes/email --requires schemas/app/tables/users/table +pgpm add schemas/app/functions/create_user --requires schemas/app/tables/users/table +``` + +**Key insight:** Deployment order follows the plan file, not directory structure. Nested paths are for organization only. + +## Plan File Format + +The `pgpm.plan` file tracks all changes: + +```sh +%syntax-version=1.0.0 +%project=pets +%uri=pets + +schemas/pets 2025-11-14T00:00:00Z Author +schemas/pets/tables/pets [schemas/pets] 2025-11-14T00:00:00Z Author +schemas/pets/tables/pets/indexes/name [schemas/pets/tables/pets] 2025-11-14T00:00:00Z Author +``` + +Format: `change_name [dependencies] timestamp author # optional note` + +## Two Workflows + +### Incremental (Development) + +Add changes one at a time: +```bash +pgpm add schemas/pets --requires uuid-ossp +pgpm add schemas/pets/tables/pets --requires schemas/pets +``` + +Plan file updates automatically with each `pgpm add`. + +### Pre-Production (Batch) + +Write all SQL files first, then generate plan: +```bash +# Write deploy/revert/verify files manually +# Then generate plan from requires comments: +pgpm plan +``` + +`pgpm plan` reads `-- requires:` comments from deploy files and generates the plan. + +## Common Change Types + +### Schema +```bash +pgpm add schemas/app +``` + +```sql +-- deploy/schemas/app.sql +CREATE SCHEMA app; + +-- revert/schemas/app.sql +DROP SCHEMA IF EXISTS app CASCADE; + +-- verify/schemas/app.sql +DO $$ BEGIN + PERFORM 1 FROM information_schema.schemata WHERE schema_name = 'app'; + IF NOT FOUND THEN RAISE EXCEPTION 'Schema app does not exist'; END IF; +END $$; +``` + +### Table +```bash +pgpm add schemas/app/tables/users --requires schemas/app +``` + +```sql +-- deploy/schemas/app/tables/users.sql +CREATE TABLE app.users ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + email TEXT UNIQUE NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +-- revert/schemas/app/tables/users.sql +DROP TABLE IF EXISTS app.users; + +-- verify/schemas/app/tables/users.sql +DO $$ BEGIN + PERFORM 1 FROM pg_tables WHERE schemaname = 'app' AND tablename = 'users'; + IF NOT FOUND THEN RAISE EXCEPTION 'Table app.users does not exist'; END IF; +END $$; +``` + +### Function +```bash +pgpm add schemas/app/functions/get_user --requires schemas/app/tables/users +``` + +```sql +-- deploy/schemas/app/functions/get_user.sql +CREATE FUNCTION app.get_user(user_id UUID) +RETURNS app.users AS $$ + SELECT * FROM app.users WHERE id = user_id; +$$ LANGUAGE sql STABLE; + +-- revert/schemas/app/functions/get_user.sql +DROP FUNCTION IF EXISTS app.get_user(UUID); + +-- verify/schemas/app/functions/get_user.sql +DO $$ BEGIN + PERFORM 1 FROM pg_proc WHERE proname = 'get_user'; + IF NOT FOUND THEN RAISE EXCEPTION 'Function get_user does not exist'; END IF; +END $$; +``` + +### Index +```bash +pgpm add schemas/app/tables/users/indexes/email --requires schemas/app/tables/users +``` + +```sql +-- deploy/schemas/app/tables/users/indexes/email.sql +CREATE INDEX idx_users_email ON app.users(email); + +-- revert/schemas/app/tables/users/indexes/email.sql +DROP INDEX IF EXISTS app.idx_users_email; + +-- verify/schemas/app/tables/users/indexes/email.sql +DO $$ BEGIN + PERFORM 1 FROM pg_indexes WHERE indexname = 'idx_users_email'; + IF NOT FOUND THEN RAISE EXCEPTION 'Index idx_users_email does not exist'; END IF; +END $$; +``` + +## Deploy and Verify + +```bash +# Deploy to database +pgpm deploy --database myapp_dev --createdb --yes + +# Verify deployment +pgpm verify --database myapp_dev +``` + +## References + +- Related reference: `references/workspace.md` for workspace setup +- Related reference: `references/dependencies.md` for cross-module dependencies +- Related reference: `references/testing.md` for testing database changes diff --git a/.agents/skills/pgpm/references/ci-cd.md b/.agents/skills/pgpm/references/ci-cd.md new file mode 100644 index 0000000000..b33bfbb7c2 --- /dev/null +++ b/.agents/skills/pgpm/references/ci-cd.md @@ -0,0 +1,441 @@ +--- +name: github-workflows-pgpm +description: Configure GitHub Actions workflows for PostgreSQL database testing, PGPM migrations, and CI/CD pipelines in Constructive projects. Use when setting up CI/CD for a PGPM-based project, configuring PostgreSQL service containers in GitHub Actions, or running database tests in CI. +--- + +Configure GitHub Actions workflows for PostgreSQL database testing, PGPM migrations, and CI/CD pipelines in Constructive projects. + +## When to Apply + +Use this skill when: +- Setting up CI/CD for a PGPM-based project +- Configuring PostgreSQL service containers in GitHub Actions +- Running database tests with pgsql-test in CI +- Generating SDKs or types from database schemas in CI +- Building and publishing Docker images for PostgreSQL + +## Core Workflow Pattern + +Every Constructive CI workflow follows this pattern: + +1. **Spin up PostgreSQL service container** with health checks +2. **Install pnpm and Node.js** with caching +3. **Cache and install pgpm CLI** globally +4. **Build the workspace** with `pnpm -r build` +5. **Bootstrap database users** with `pgpm admin-users` +6. **Run tests** per package + +## PostgreSQL Service Container + +Use the Constructive PostgreSQL image with extensions pre-installed: + +```yaml +services: + pg_db: + image: ghcr.io/constructive-io/docker/postgres-plus:17 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: password + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 +``` + +For simpler setups without custom extensions: + +```yaml +services: + pg_db: + image: docker.io/constructiveio/postgres-plus:18 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: password + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 +``` + +## Environment Variables + +Standard PostgreSQL environment variables for tests: + +```yaml +env: + PGHOST: localhost + PGPORT: 5432 + PGUSER: postgres + PGPASSWORD: password +``` + +For MinIO/S3 testing (uploads, storage): + +```yaml +env: + MINIO_ENDPOINT: http://localhost:9000 + AWS_ACCESS_KEY: minioadmin + AWS_SECRET_KEY: minioadmin + AWS_REGION: us-east-1 + BUCKET_NAME: test-bucket +``` + +## PGPM CLI Caching + +Cache the pgpm CLI to speed up workflows: + +```yaml +env: + PGPM_VERSION: '2.7.9' + +steps: + - name: Cache pgpm CLI + uses: actions/cache@v4 + with: + path: ~/.npm + key: pgpm-${{ runner.os }}-${{ env.PGPM_VERSION }} + + - name: Install pgpm CLI globally + run: npm install -g pgpm@${{ env.PGPM_VERSION }} +``` + +## Database User Bootstrap + +Before running tests, bootstrap the database users: + +```yaml +- name: Seed pg and app_user + run: | + pgpm admin-users bootstrap --yes + pgpm admin-users add --test --yes +``` + +This creates: +- The `app_user` role for RLS testing +- Test-specific roles and permissions + +## Complete Test Workflow + +Full workflow for running tests across multiple packages: + +```yaml +name: CI tests +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-tests + cancel-in-progress: true + +env: + PGPM_VERSION: '2.7.9' + +jobs: + test: + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + package: + - packages/my-package + - packages/another-package + + env: + PGHOST: localhost + PGPORT: 5432 + PGUSER: postgres + PGPASSWORD: password + + services: + pg_db: + image: ghcr.io/constructive-io/docker/postgres-plus:17 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: password + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + + steps: + - name: Configure Git + run: | + git config --global user.name "CI Test User" + git config --global user.email "ci@example.com" + + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v2 + with: + version: 10 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install + + - name: Cache pgpm CLI + uses: actions/cache@v4 + with: + path: ~/.npm + key: pgpm-${{ runner.os }}-${{ env.PGPM_VERSION }} + + - name: Install pgpm CLI globally + run: npm install -g pgpm@${{ env.PGPM_VERSION }} + + - name: Build + run: pnpm -r build + + - name: Seed pg and app_user + run: | + pgpm admin-users bootstrap --yes + pgpm admin-users add --test --yes + + - name: Test ${{ matrix.package }} + run: cd ./${{ matrix.package }} && pnpm test +``` + +## Integration Test Workflow + +For running pgpm's built-in integration tests: + +```yaml +- name: Run Integration Tests + run: pgpm test-packages +``` + +This runs all package tests defined in the pgpm workspace. + +## SDK Generation Workflow + +Generate typed SDKs from database schemas: + +```yaml +name: generate-sdk +on: + workflow_dispatch: + inputs: + commit_changes: + description: 'Commit and push generated SDK changes' + required: false + default: 'false' + type: boolean + +jobs: + generate-sdk: + runs-on: ubuntu-latest + + # ... services and setup steps ... + + steps: + # ... checkout, pnpm, node, pgpm setup ... + + - name: Build + run: pnpm -r build + + - name: Seed pg and app_user + run: | + pgpm admin-users bootstrap --yes + pgpm admin-users add --test --yes + + - name: Generate SDK + run: | + cd sdk/my-sdk + pnpm run generate + + - name: Check for changes + id: check_changes + run: | + if git diff --quiet sdk/my-sdk/src/generated; then + echo "has_changes=false" >> $GITHUB_OUTPUT + else + echo "has_changes=true" >> $GITHUB_OUTPUT + fi + + - name: Commit and push changes + if: ${{ inputs.commit_changes == 'true' && steps.check_changes.outputs.has_changes == 'true' }} + run: | + git add sdk/my-sdk/src/generated + git commit -m "chore: regenerate SDK types" + git push + + - name: Upload generated SDK as artifact + if: ${{ steps.check_changes.outputs.has_changes == 'true' }} + uses: actions/upload-artifact@v4 + with: + name: generated-sdk + path: sdk/my-sdk/src/generated + retention-days: 7 +``` + +## Test Sharding + +For large test suites, split tests across parallel jobs: + +```yaml +strategy: + fail-fast: false + matrix: + package: [packages/core] + test_pattern: [''] + include: + - package: packages/large-package + test_pattern: 'auth|rls' + shard_name: 'large-package-auth-rls' + - package: packages/large-package + test_pattern: 'permissions|orgs' + shard_name: 'large-package-permissions-orgs' + +steps: + - name: Test ${{ matrix.package }}${{ matrix.shard_name && format(' ({0})', matrix.shard_name) || '' }} + shell: bash + run: | + cd ./${{ matrix.package }} + if [ -n "${{ matrix.test_pattern }}" ]; then + pnpm test -- "${{ matrix.test_pattern }}" + else + pnpm test + fi +``` + +## MinIO Service Container + +For testing uploads and S3-compatible storage: + +```yaml +services: + minio_cdn: + image: minio/minio:edge-cicd + env: + MINIO_ROOT_USER: minioadmin + MINIO_ROOT_PASSWORD: minioadmin + ports: + - 9000:9000 + - 9001:9001 + options: >- + --health-cmd "curl -f http://localhost:9000/minio/health/live || exit 1" + --health-interval 10s + --health-timeout 5s + --health-retries 5 +``` + +## Concurrency Control + +Prevent duplicate workflow runs: + +```yaml +concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-tests + cancel-in-progress: true +``` + +## Docker Build Workflow + +Build and push PostgreSQL images: + +```yaml +name: Docker +on: + workflow_dispatch: + inputs: + process: + description: 'Process to build' + type: choice + options: [pgvector, postgis, pgvector-postgis] + +jobs: + build-push: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + env: + REPO: ghcr.io/${{ github.repository_owner }} + PLATFORMS: linux/amd64,linux/arm64 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push + run: | + make \ + PROCESS=${{ inputs.process }} \ + REPO_NAME=$REPO \ + PLATFORMS="$PLATFORMS" \ + build-push-process +``` + +## Per-Package Environment Variables + +Pass package-specific environment variables: + +```yaml +strategy: + matrix: + include: + - package: packages/client + env: + TEST_DATABASE_URL: postgres://postgres:password@localhost:5432/postgres + - package: uploads/s3-streamer + env: + BUCKET_NAME: test-bucket + +steps: + - name: Test ${{ matrix.package }} + run: cd ./${{ matrix.package }} && pnpm test + env: ${{ matrix.env }} +``` + +## Best Practices + +1. **Always use health checks** — Ensure PostgreSQL is ready before tests run +2. **Cache pgpm CLI** — Speeds up workflow execution significantly +3. **Use concurrency control** — Prevent duplicate runs on rapid pushes +4. **Configure Git** — Required for tests that use git operations +5. **Use matrix strategy** — Run tests in parallel across packages +6. **Bootstrap users before tests** — `pgpm admin-users` creates required roles +7. **Use fail-fast: false** — Let all tests complete even if some fail +8. **Pin pgpm version** — Ensure consistent behavior across runs + +## References + +- Related skill: `pgsql-test` for database testing framework +- Related skill: `pgpm` (`references/workspace.md`) for PGPM project setup +- Related skill: `pnpm-workspace` for PNPM monorepo configuration +- [GitHub Actions documentation](https://docs.github.com/en/actions) +- [pnpm/action-setup](https://github.com/pnpm/action-setup) diff --git a/.agents/skills/pgpm/references/cli.md b/.agents/skills/pgpm/references/cli.md new file mode 100644 index 0000000000..57d175d463 --- /dev/null +++ b/.agents/skills/pgpm/references/cli.md @@ -0,0 +1,389 @@ + +# pgpm CLI Reference + +Complete reference for the pgpm (PostgreSQL Package Manager) command-line interface. pgpm provides deterministic, plan-driven database migrations with dependency management. + +## When to Apply + +Use this skill when: +- Deploying database changes +- Managing database migrations +- Installing or upgrading pgpm modules +- Testing pgpm packages in CI/CD +- Setting up local PostgreSQL development + +## Quick Start + +```bash +# Install pgpm globally +npm install -g pgpm + +# Ensure PostgreSQL is running and env vars are loaded +# See references/docker.md and references/env.md for setup + +# Create workspace and module +pgpm init workspace +cd my-app +pgpm init +cd packages/your-module + +# Deploy to database +pgpm deploy --createdb --database mydb +``` + +## Core Commands + +### Database Operations + +**pgpm deploy** — Deploy database changes and migrations + +```bash +# Deploy to current database (from PGDATABASE) +pgpm deploy + +# Create database if missing +pgpm deploy --createdb + +# Deploy to specific database +pgpm deploy --database mydb + +# Deploy specific package to a tag +pgpm deploy --package mypackage --to @v1.0.0 +``` + +> **⚠️ WARNING: `--fast` flag (use with extreme caution)** +> +> ```bash +> pgpm deploy --fast --no-tx +> ``` +> +> `--fast` is **NOT idempotent** — it is meant to be run **once only** for +> quick testing on a fresh database. It skips dependency-tracking checks, so +> if your package shares dependencies with anything already deployed (e.g. +> `pgpm-verify`), it will blindly attempt to re-deploy them, causing errors. +> +> **Only use `--fast` on a throwaway database that has nothing else deployed.** +> For all other cases, use the standard `pgpm deploy` command. + +**pgpm verify** — Verify database state matches expected migrations + +```bash +pgpm verify +pgpm verify --package mypackage +``` + +**pgpm revert** — Safely revert database changes + +```bash +pgpm revert +pgpm revert --to @v1.0.0 +``` + +### Migration Management + +**pgpm migrate** — Comprehensive migration management + +```bash +# Initialize migration tracking +pgpm migrate init + +# Check migration status +pgpm migrate status + +# List all changes +pgpm migrate list + +# Show change dependencies +pgpm migrate deps +``` + +### Module Management + +**pgpm install** — Install pgpm modules as dependencies + +```bash +# Install single package +pgpm install @pgpm/faker + +# Install multiple packages +pgpm install @pgpm/base32 @pgpm/faker +``` + +**pgpm upgrade-modules** — Upgrade installed modules to latest versions + +```bash +# Interactive selection +pgpm upgrade-modules + +# Upgrade all without prompting +pgpm upgrade-modules --all + +# Preview without changes +pgpm upgrade-modules --dry-run + +# Upgrade specific modules +pgpm upgrade-modules --modules @pgpm/base32,@pgpm/faker + +# Upgrade across entire workspace +pgpm upgrade-modules --workspace --all +``` + +**pgpm extension** — Interactively manage module dependencies + +```bash +pgpm extension +``` + +### Workspace Initialization + +**pgpm init** — Initialize new module or workspace + +```bash +# Create new workspace +pgpm init workspace + +# Create new module (inside workspace) +pgpm init + +# Use full template path (recommended) +pgpm init --template pnpm/module +pgpm init -t pgpm/workspace + +# Create workspace + module in one command +pgpm init -w +pgpm init --template pnpm/module -w + +# Use custom template repository +pgpm init --repo https://github.com/org/templates.git --template my-template +``` + +### Change Management + +**pgpm add** — Add a new database change + +```bash +pgpm add my_change +``` + +This creates three files in `sql/`: +- `deploy/my_change.sql` — Deploy script +- `revert/my_change.sql` — Revert script +- `verify/my_change.sql` — Verify script + +**pgpm remove** — Remove a database change + +```bash +pgpm remove my_change +``` + +**pgpm rename** — Rename a database change + +```bash +pgpm rename old_name new_name +``` + +### Tagging and Versioning + +**pgpm tag** — Version your changes with tags + +```bash +# Tag latest change +pgpm tag v1.0.0 + +# Tag with comment +pgpm tag v1.0.0 --comment "Initial release" + +# Tag specific change +pgpm tag v1.1.0 --package mypackage --changeName my-change +``` + +### Packaging and Distribution + +**pgpm plan** — Generate deployment plans + +```bash +pgpm plan +``` + +**pgpm package** — Package module for distribution + +```bash +pgpm package +pgpm package --no-plan +``` + +### Testing + +**pgpm test-packages** — Run integration tests on all modules in workspace + +```bash +# Deploy only +pgpm test-packages + +# Full deploy/verify/revert/deploy cycle +pgpm test-packages --full-cycle + +# Continue after failures +pgpm test-packages --continue-on-fail + +# Exclude specific modules +pgpm test-packages --exclude legacy-module + +# Combine options +pgpm test-packages --full-cycle --continue-on-fail --exclude broken-module +``` + +### Docker and Environment + +**pgpm docker** — Manage local PostgreSQL container + +```bash +pgpm docker start +pgpm docker stop +``` + +**pgpm env** — Print PostgreSQL environment variables + +```bash +# Standard PostgreSQL +eval "$(pgpm env)" + +# Supabase local development +eval "$(pgpm env --supabase)" +``` + +### Admin Users + +**pgpm admin-users** — Manage database admin users + +```bash +# Bootstrap admin users from pgpm.json roles config +pgpm admin-users bootstrap + +# Add specific user +pgpm admin-users add myuser + +# Remove user +pgpm admin-users remove myuser +``` + +### Utilities + +**pgpm dump** — Dump database to SQL file + +```bash +# Dump to timestamped file +pgpm dump --database mydb + +# Dump to specific file +pgpm dump --database mydb --out ./backup.sql + +# Dump with pruning (for test fixtures) +pgpm dump --database mydb --database-id +``` + +**pgpm kill** — Clean up database connections + +```bash +# Kill connections and drop databases +pgpm kill + +# Only kill connections +pgpm kill --no-drop +``` + +**pgpm clear** — Clear database state + +```bash +pgpm clear +``` + +**pgpm export** — Export migrations from existing databases + +```bash +pgpm export +``` + +**pgpm analyze** — Analyze database structure + +```bash +pgpm analyze +``` + +### Cache and Updates + +**pgpm cache clean** — Clear cached template repos + +```bash +pgpm cache clean +``` + +**pgpm update** — Install latest pgpm version + +```bash +pgpm update +``` + +## Environment Variables + +pgpm uses standard PostgreSQL environment variables: + +| Variable | Description | +|----------|-------------| +| `PGHOST` | Database host | +| `PGPORT` | Database port | +| `PGDATABASE` | Database name | +| `PGUSER` | Database user | +| `PGPASSWORD` | Database password | + +Quick setup with `eval "$(pgpm env)"` or manual export. + +## Global Options + +Most commands support: + +| Option | Description | +|--------|-------------| +| `--help, -h` | Show help | +| `--version, -v` | Show version | +| `--cwd

` | Set working directory | + +## Common Workflows + +### Starting a New Project + +```bash +pgpm init workspace +cd my-app +pgpm init +cd packages/new-module +pgpm add some_change +# Edit sql/deploy/some_change.sql +pgpm deploy --createdb +``` + +### Installing and Using a Module + +```bash +cd packages/your-module +pgpm install @pgpm/faker +pgpm deploy --createdb --database mydb +psql -d mydb -c "SELECT faker.city('MI');" +``` + +### CI/CD Testing + +```bash +# Bootstrap admin users +pgpm admin-users bootstrap + +# Test all packages +pgpm test-packages --full-cycle --continue-on-fail +``` + +## References + +- Related reference: `references/workspace.md` for workspace structure +- Related reference: `references/changes.md` for authoring changes +- Related reference: `references/dependencies.md` for module dependencies +- Related skill: `github-workflows-pgpm` for CI/CD workflows diff --git a/.agents/skills/pgpm/references/dependencies.md b/.agents/skills/pgpm/references/dependencies.md new file mode 100644 index 0000000000..38c9f3069e --- /dev/null +++ b/.agents/skills/pgpm/references/dependencies.md @@ -0,0 +1,209 @@ + +# Managing PGPM Dependencies + +Handle dependencies between database changes and across modules in pgpm workspaces. + +## When to Apply + +Use this skill when: +- Adding dependencies between database changes +- Referencing objects from other modules +- Managing cross-module dependencies +- Resolving dependency order issues + +## Dependency Types + +### Within-Module Dependencies + +Changes within the same module reference each other by path: + +```sql +-- deploy/schemas/pets/tables/pets.sql +-- requires: schemas/pets + +CREATE TABLE pets.pets ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + name TEXT NOT NULL +); +``` + +Add with `--requires`: +```bash +pgpm add schemas/pets/tables/pets --requires schemas/pets +``` + +### Cross-Module Dependencies + +Reference changes from other modules using `module:path` syntax: + +```sql +-- deploy/schemas/app/tables/user_pets.sql +-- requires: schemas/app/tables/users +-- requires: pets:schemas/pets/tables/pets + +CREATE TABLE app.user_pets ( + user_id UUID REFERENCES app.users(id), + pet_id UUID REFERENCES pets.pets(id), + PRIMARY KEY (user_id, pet_id) +); +``` + +The `pets:schemas/pets/tables/pets` syntax means: +- `pets` = module name (from .control file) +- `schemas/pets/tables/pets` = change path within that module + +## The .control File + +Module metadata and extension dependencies live in the `.control` file: + +```sh +# pets.control +comment = 'Pet management module' +default_version = '0.0.1' +requires = 'uuid-ossp,plpgsql' +``` + +| Field | Purpose | +|-------|---------| +| `comment` | Module description | +| `default_version` | Semantic version | +| `requires` | PostgreSQL extensions needed | + +## Adding Extension Dependencies + +When your module needs PostgreSQL extensions: + +```bash +# Interactive mode +pgpm extension + +# Or edit .control directly +requires = 'uuid-ossp,plpgsql,pgcrypto' +``` + +## Dependency Resolution + +pgpm resolves dependencies recursively: + +1. Reads `pgpm.plan` for change order +2. Parses `-- requires:` comments +3. Resolves cross-module references +4. Deploys in correct topological order + +Example deployment order: +```text +1. uuid-ossp (extension) +2. plpgsql (extension) +3. schemas/pets (schema) +4. schemas/pets/tables/pets (table) +5. schemas/app (schema) +6. schemas/app/tables/users (table) +7. schemas/app/tables/user_pets (references both) +``` + +## Common Patterns + +### Schema Before Tables + +```bash +pgpm add schemas/app +pgpm add schemas/app/tables/users --requires schemas/app +pgpm add schemas/app/tables/posts --requires schemas/app/tables/users +``` + +### Functions After Tables + +```bash +pgpm add schemas/app/functions/create_user --requires schemas/app/tables/users +``` + +### Triggers After Functions + +```bash +pgpm add schemas/app/triggers/user_updated --requires schemas/app/functions/update_timestamp +``` + +### Cross-Module Reference + +Module A (users): +```bash +pgpm add schemas/users/tables/users +``` + +Module B (posts): +```bash +pgpm add schemas/posts/tables/posts --requires users:schemas/users/tables/users +``` + +## Viewing Dependencies + +Check what a change depends on: +```bash +# View plan file +cat pgpm.plan +``` + +Plan shows dependencies in brackets: +```sh +schemas/app/tables/user_pets [schemas/app/tables/users pets:schemas/pets/tables/pets] 2025-11-14T00:00:00Z Author +``` + +## Circular Dependencies + +pgpm prevents circular dependencies. If you see: +```text +Error: Circular dependency detected +``` + +Refactor to break the cycle: +1. Extract shared objects to a base module +2. Have both modules depend on the base +3. Remove direct cross-references + +**Before (circular):** +```text +module-a depends on module-b +module-b depends on module-a +``` + +**After (resolved):** +```text +module-base (shared objects) +module-a depends on module-base +module-b depends on module-base +``` + +## Deploying with Dependencies + +Deploy resolves all dependencies automatically: + +```bash +# Deploy single module (pulls in dependencies) +pgpm deploy --database myapp_dev --createdb --yes + +# Deploy specific module in workspace +cd packages/posts +pgpm deploy --database myapp_dev --yes +``` + +## Troubleshooting + +| Issue | Solution | +|-------|----------| +| "Module not found" | Ensure module is in workspace `packages/` | +| "Change not found" | Check path matches exactly in plan file | +| "Circular dependency" | Refactor to use base module pattern | +| Wrong deploy order | Check `-- requires:` comments in deploy files | + +## Best Practices + +1. **Explicit dependencies**: Always declare what you need +2. **Minimal dependencies**: Only require what's directly used +3. **Consistent naming**: Use same paths in requires and plan +4. **Test deployments**: Verify order with fresh database + +## References + +- Related reference: `references/workspace.md` for workspace setup +- Related reference: `references/changes.md` for authoring changes +- Related reference: `references/testing.md` for testing modules diff --git a/.agents/skills/pgpm/references/deploy-lifecycle.md b/.agents/skills/pgpm/references/deploy-lifecycle.md new file mode 100644 index 0000000000..fe190e7fd0 --- /dev/null +++ b/.agents/skills/pgpm/references/deploy-lifecycle.md @@ -0,0 +1,248 @@ + +# pgpm Deploy Lifecycle + +The complete deploy → verify → revert lifecycle for pgpm database modules. + +## When to Apply + +Use this skill when: +- Deploying database changes with `pgpm deploy` +- Reverting deployments with `pgpm revert` +- Verifying deployed state with `pgpm verify` +- Tagging deployment points with `pgpm tag` +- Checking deployment status with `pgpm migrate status` +- Running full-cycle tests with `pgpm test-packages` + +## Core Concept + +pgpm deployments are **deterministic and plan-driven**. Every change is tracked in `pgpm.plan`, and each change has exactly three scripts: +- **deploy/** — applies the change +- **verify/** — confirms it was applied correctly +- **revert/** — undoes the change + +pgpm handles transactions automatically — you just write the SQL. + +## Deploy + +### Basic Deploy + +```bash +# Deploy all pending changes for the current module +pgpm deploy + +# Deploy with database creation (if database doesn't exist) +pgpm deploy --createdb + +# Deploy a specific module by name +pgpm deploy my-module + +# Deploy all modules in the workspace +pgpm deploy --workspace --all +``` + +### What Happens During Deploy + +1. **Dependency resolution** — reads `.control` file, resolves all required extensions and pgpm modules +2. **Extension creation** — native Postgres extensions get `CREATE EXTENSION IF NOT EXISTS` +3. **Module dependency deploy** — pgpm modules from `extensions/` are deployed first (topological order) +4. **Plan execution** — each change in `pgpm.plan` is executed in order: + - Checks if already deployed (via tracking schema) + - Runs the deploy script + - Records the change in the tracking schema +5. **Automatic verification** — after deploy, verify scripts run to confirm state + +### Deploy to a Tag + +```bash +# Deploy only up to a specific tag +pgpm deploy --to @v1.0.0 +``` + +### Deploy Options + +| Option | Description | +|--------|-------------| +| `--createdb` | Create the target database if it doesn't exist | +| `--workspace` | Operate at workspace level | +| `--all` | Deploy all modules (with `--workspace`) | +| `--to @tag` | Deploy up to a specific tag | +| `--yes` | Skip confirmation prompts | + +## Verify + +Verify checks that deployed changes are actually in the expected state. + +```bash +# Verify all deployed changes +pgpm verify + +# Verify a specific module +pgpm verify my-module +``` + +### What Happens During Verify + +For each deployed change, pgpm runs the corresponding `verify/` script. Verify scripts typically use `SELECT` statements that will fail if the expected objects don't exist: + +```sql +-- verify/schemas/app/tables/users.sql +SELECT id, email, name, created_at +FROM app.users +WHERE FALSE; +``` + +If any verify script fails, pgpm reports which changes are in a bad state. + +## Revert + +Revert undoes deployed changes in reverse order. + +```bash +# Revert the last deployed change +pgpm revert + +# Revert to a specific tag +pgpm revert --to @v1.0.0 + +# Revert all changes +pgpm revert --all + +# Revert with confirmation skip +pgpm revert --yes +``` + +### What Happens During Revert + +1. Changes are reverted in **reverse plan order** (last deployed = first reverted) +2. Each revert script runs (e.g., `DROP TABLE`, `DROP FUNCTION`) +3. The change is removed from the tracking schema +4. Verify scripts run to confirm the revert + +### Revert Options + +| Option | Description | +|--------|-------------| +| `--to @tag` | Revert back to a specific tag (exclusive — the tag itself stays) | +| `--all` | Revert all deployed changes | +| `--yes` | Skip confirmation prompts | + +## Tagging + +Tags mark specific points in the deployment plan for targeted deploy/revert. + +```bash +# Tag the current state +pgpm tag v1.0.0 + +# Tag with a description +pgpm tag v1.0.0 -m "Initial release" +``` + +Tags appear in `pgpm.plan` as: + +``` +@v1.0.0 2024-01-15T10:00:00Z user # Initial release +``` + +### Using Tags + +```bash +# Deploy up to a tag +pgpm deploy --to @v1.0.0 + +# Revert to a tag (keeps the tag, reverts everything after it) +pgpm revert --to @v1.0.0 +``` + +## Status + +Check what's deployed and what's pending. + +```bash +# Show deployment status +pgpm migrate status +``` + +This shows: +- Which changes are deployed +- Which changes are pending (in plan but not yet deployed) +- The current tag (if any) + +## Full-Cycle Testing + +`pgpm test-packages` runs a full deploy → verify → revert → deploy cycle to validate that all scripts work correctly in both directions. + +```bash +# Full cycle test for current module +pgpm test-packages --full-cycle + +# Full cycle test for all workspace modules +pgpm test-packages --full-cycle --workspace --all +``` + +This is the gold standard for validating migrations — it proves: +1. Deploy scripts apply correctly +2. Verify scripts confirm the deployed state +3. Revert scripts cleanly undo everything +4. Re-deploy works (proving revert was complete) + +## Common Workflows + +### First-time workspace deploy + +> **Prerequisite:** Ensure PostgreSQL is running and environment is loaded. See `references/docker.md` and `references/env.md` for setup. + +```bash +pgpm admin-users bootstrap --yes +pgpm deploy --createdb --workspace --all --yes +``` + +### Deploy after adding new changes + +```bash +pgpm deploy +pgpm verify +``` + +### Revert a bad deploy + +```bash +pgpm revert --yes +# Fix the issue, then redeploy +pgpm deploy +``` + +### Tag a release and deploy to that point + +```bash +pgpm tag v1.0.0 +pgpm deploy --to @v1.0.0 +``` + +### Validate all migrations (CI) + +> **Note:** In CI, start Postgres and load env vars first. See `references/docker.md` and `references/env.md`, or `github-workflows-pgpm` for CI-specific patterns. + +```bash +pgpm admin-users bootstrap --yes +pgpm test-packages --full-cycle --workspace --all +``` + +## Tracking Schema + +pgpm tracks deployments in a PostgreSQL schema (typically `pgpm_migrate`). This contains: +- `changes` table — records each deployed change with timestamp and deployer +- `tags` table — records tagged points + +This is how pgpm knows what's already deployed and what's pending. + +## Troubleshooting + +| Issue | Cause | Fix | +|-------|-------|-----| +| `role "authenticated" does not exist` | Missing bootstrap | Run `pgpm admin-users bootstrap --yes` | +| `database "mydb" does not exist` | Database not created | Use `pgpm deploy --createdb` | +| Deploy fails mid-way | SQL error in a deploy script | Fix the script, `pgpm revert` the failed change, redeploy | +| Verify fails after deploy | Deploy script didn't create expected objects | Check deploy script matches verify expectations | +| Revert fails | Revert script references objects that don't exist | Check for dependencies between changes | +| `Already deployed` | Change was previously deployed | Check `pgpm migrate status` — may need `pgpm revert` first | diff --git a/.agents/skills/pgpm/references/docker.md b/.agents/skills/pgpm/references/docker.md new file mode 100644 index 0000000000..c5400c75ff --- /dev/null +++ b/.agents/skills/pgpm/references/docker.md @@ -0,0 +1,142 @@ + +# PGPM Docker + +Manage PostgreSQL Docker containers for local development using the `pgpm docker` command. + +## When to Apply + +Use this skill when: +- Setting up a local PostgreSQL database for development +- Starting or stopping PostgreSQL containers +- Recreating a fresh database container +- User asks to run tests that need a database +- Troubleshooting database connection issues + +## Quick Start + +### Start PostgreSQL Container + +```bash +pgpm docker start +``` + +This starts a PostgreSQL 17 container with default settings: +- Container name: `postgres` +- Port: `5432` +- User: `postgres` +- Password: `password` + +### Start with Custom Options + +```bash +pgpm docker start --port 5433 --name my-postgres +``` + +### Recreate Container (Fresh Database) + +```bash +pgpm docker start --recreate +``` + +### Stop Container + +```bash +pgpm docker stop +``` + +## Command Reference + +### pgpm docker start + +Start a PostgreSQL Docker container. + +| Option | Description | Default | +|--------|-------------|---------| +| `--name ` | Container name | `postgres` | +| `--image ` | Docker image | `docker.io/constructiveio/postgres-plus:18` | +| `--port ` | Host port mapping | `5432` | +| `--user ` | PostgreSQL user | `postgres` | +| `--password ` | PostgreSQL password | `password` | +| `--recreate` | Remove and recreate container | `false` | + +### pgpm docker stop + +Stop a running PostgreSQL container. + +| Option | Description | Default | +|--------|-------------|---------| +| `--name ` | Container name to stop | `postgres` | + +## Common Workflows + +### Development Setup + +```bash +# Start fresh database +pgpm docker start --recreate + +# Load environment variables +eval "$(pgpm env)" + +# Deploy your PGPM modules +pgpm deploy +``` + +### Running Tests + +```bash +# Ensure database is running +pgpm docker start + +# Run tests with environment +pgpm env pnpm test +``` + +### Multiple Databases + +```bash +# Start main database on default port +pgpm docker start --name main-db + +# Start test database on different port +pgpm docker start --name test-db --port 5433 +``` + +## PostgreSQL Version + +The default image `docker.io/constructiveio/postgres-plus:18` includes PostgreSQL 17 which is required for: +- `security_invoker` views +- Latest PostgreSQL features used by Constructive + +If you see errors like "unrecognized parameter security_invoker", ensure you're using PostgreSQL 17+. + +## Troubleshooting + +| Issue | Solution | +|-------|----------| +| "Docker is not installed" | Install Docker Desktop or Docker Engine | +| "Port already in use" | Use `--port` to specify a different port, or stop the conflicting container | +| Container won't start | Check `docker logs postgres` for errors | +| "Container already exists" | Use `--recreate` to remove and recreate | +| Permission denied | Ensure Docker daemon is running and user has permissions | + +## Environment Variables + +After starting the container, use `pgpm env` to set up environment variables: + +```bash +eval "$(pgpm env)" +``` + +This sets: +- `PGHOST=localhost` +- `PGPORT=5432` +- `PGUSER=postgres` +- `PGPASSWORD=password` +- `PGDATABASE=postgres` + +## References + +For related references: +- Environment management: See `references/env.md` +- Running tests: See `references/testing.md` diff --git a/.agents/skills/pgpm/references/env.md b/.agents/skills/pgpm/references/env.md new file mode 100644 index 0000000000..eaaff791d6 --- /dev/null +++ b/.agents/skills/pgpm/references/env.md @@ -0,0 +1,205 @@ +# PGPM Env + +Manage PostgreSQL environment variables with profile support using the `pgpm env` command. + +## When to Apply + +Use this skill when: +- Setting up environment variables for database connections +- Running commands that need PostgreSQL connection info +- Switching between local Postgres and Supabase profiles +- Deploying PGPM modules with correct database settings +- Running tests or scripts that need database access + +## Quick Start + +### Load Environment Variables + +```bash +eval "$(pgpm env)" +``` + +This sets the following environment variables: +- `PGHOST=localhost` +- `PGPORT=5432` +- `PGUSER=postgres` +- `PGPASSWORD=password` +- `PGDATABASE=postgres` + +### Run Command with Environment + +```bash +pgpm env pgpm deploy --database mydb +``` + +This runs `pgpm deploy --database mydb` with the PostgreSQL environment variables automatically set. + +## Profiles + +### Default Profile (Local Postgres) + +```bash +eval "$(pgpm env)" +``` + +| Variable | Value | +|----------|-------| +| `PGHOST` | `localhost` | +| `PGPORT` | `5432` | +| `PGUSER` | `postgres` | +| `PGPASSWORD` | `password` | +| `PGDATABASE` | `postgres` | + +### Supabase Profile + +```bash +eval "$(pgpm env --supabase)" +``` + +| Variable | Value | +|----------|-------| +| `PGHOST` | `localhost` | +| `PGPORT` | `54322` | +| `PGUSER` | `supabase_admin` | +| `PGPASSWORD` | `postgres` | +| `PGDATABASE` | `postgres` | + +## Command Reference + +### Print Environment Exports + +```bash +pgpm env # Default Postgres profile +pgpm env --supabase # Supabase profile +``` + +Output (for shell evaluation): +```bash +export PGHOST="localhost" +export PGPORT="5432" +export PGUSER="postgres" +export PGPASSWORD="password" +export PGDATABASE="postgres" +``` + +### Execute Command with Environment + +```bash +pgpm env [args...] +pgpm env --supabase [args...] +``` + +Examples: +```bash +pgpm env createdb mydb +pgpm env pgpm deploy --database mydb +pgpm env psql -c "SELECT 1" +pgpm env --supabase pgpm deploy --database mydb +``` + +## Common Workflows + +### Development Setup + +```bash +# Start database container +pgpm docker start + +# Load environment into current shell +eval "$(pgpm env)" + +# Now all commands have database access +createdb myapp +pgpm deploy --database myapp +``` + +### Running Tests + +```bash +# Run tests with database environment +pgpm env pnpm test + +# Or load into shell first +eval "$(pgpm env)" +pnpm test +``` + +### PGPM Deployment + +```bash +# Deploy to a specific database +pgpm env pgpm deploy --database constructive + +# Verify deployment +pgpm env pgpm verify --database constructive +``` + +### Supabase Local Development + +```bash +# Start Supabase locally (using supabase CLI) +supabase start + +# Load Supabase environment +eval "$(pgpm env --supabase)" + +# Deploy modules to Supabase +pgpm deploy --database postgres +``` + +## Shell Integration + +### Bash/Zsh + +Add to your shell profile for automatic loading: + +```bash +# ~/.bashrc or ~/.zshrc +alias pgenv='eval "$(pgpm env)"' +alias pgenv-supa='eval "$(pgpm env --supabase)"' +``` + +Then use: +```bash +pgenv # Load default Postgres env +pgenv-supa # Load Supabase env +``` + +### One-liner Commands + +```bash +# Create database and deploy in one command +pgpm env bash -c "createdb mydb && pgpm deploy --database mydb" +``` + +## Environment Variables Reference + +The `pgpm env` command sets standard PostgreSQL environment variables that are recognized by: +- `psql` and other PostgreSQL CLI tools +- Node.js `pg` library +- PGPM CLI commands +- Any tool using libpq + +| Variable | Description | +|----------|-------------| +| `PGHOST` | Database server hostname | +| `PGPORT` | Database server port | +| `PGUSER` | Database username | +| `PGPASSWORD` | Database password | +| `PGDATABASE` | Default database name | + +## Troubleshooting + +| Issue | Solution | +|-------|----------| +| "Connection refused" | Ensure database container is running with `pgpm docker start` | +| Wrong database | Check `PGDATABASE` or specify `--database` flag | +| Auth failed | Verify password matches container settings | +| Supabase not connecting | Ensure Supabase is running on port 54322 | +| Env vars not persisting | Use `eval "$(pgpm env)"` to load into current shell | + +## References + +For related skills: +- Docker container management: See `references/docker.md` +- Running tests: See `references/testing.md` diff --git a/.agents/skills/pgpm/references/environment-configuration.md b/.agents/skills/pgpm/references/environment-configuration.md new file mode 100644 index 0000000000..dcf11b734c --- /dev/null +++ b/.agents/skills/pgpm/references/environment-configuration.md @@ -0,0 +1,359 @@ +# Environment Configuration with @pgpmjs/env + +Unified environment configuration for PGPM and Constructive projects. Provides config file discovery, environment variable parsing, and hierarchical option merging. + +## When to Apply + +Use this skill when: +- Configuring PostgreSQL connections programmatically +- Setting up PGPM environment options +- Managing database configuration across environments +- Writing code that needs consistent environment handling + +## Installation + +```bash +pnpm add @pgpmjs/env +``` + +## Core Concepts + +### Configuration Hierarchy + +Options are merged in this order (later overrides earlier): + +1. **PGPM defaults** — Built-in sensible defaults +2. **Config file** — `pgpm.json` discovered via walkUp +3. **Environment variables** — `PGHOST`, `PGPORT`, etc. +4. **Runtime overrides** — Passed programmatically + +## Basic Usage + +### getEnvOptions() + +Get merged PGPM options: + +```typescript +import { getEnvOptions } from '@pgpmjs/env'; + +const options = getEnvOptions(); +// Returns merged options from defaults + config + env vars + +// With runtime overrides +const options = getEnvOptions({ + pg: { database: 'mydb' } +}); + +// With custom working directory +const options = getEnvOptions({}, '/path/to/project'); +``` + +### getConnEnvOptions() + +Get database connection options specifically: + +```typescript +import { getConnEnvOptions } from '@pgpmjs/env'; + +const connOptions = getConnEnvOptions(); +// Returns db-specific options with roles and connections resolved +``` + +### getDeploymentEnvOptions() + +Get deployment-specific options: + +```typescript +import { getDeploymentEnvOptions } from '@pgpmjs/env'; + +const deployOptions = getDeploymentEnvOptions(); +// Returns deployment options (useTx, fast, usePlan, etc.) +``` + +## Environment Variables + +### PostgreSQL Connection + +| Variable | Description | Default | +|----------|-------------|---------| +| `PGHOST` | Database host | `localhost` | +| `PGPORT` | Database port | `5432` | +| `PGDATABASE` | Database name | — | +| `PGUSER` | Database user | `postgres` | +| `PGPASSWORD` | Database password | — | + +### Database Configuration + +| Variable | Description | +|----------|-------------| +| `PGROOTDATABASE` | Root database for admin operations | +| `PGTEMPLATE` | Template database for createdb | +| `DB_PREFIX` | Prefix for database names | +| `DB_EXTENSIONS` | Comma-separated list of extensions | +| `DB_CWD` | Working directory for database operations | + +### Connection Credentials + +| Variable | Description | +|----------|-------------| +| `DB_CONNECTION_USER` | App connection user | +| `DB_CONNECTION_PASSWORD` | App connection password | +| `DB_CONNECTION_ROLE` | App connection role | +| `DB_CONNECTIONS_APP_USER` | App-level user | +| `DB_CONNECTIONS_APP_PASSWORD` | App-level password | +| `DB_CONNECTIONS_ADMIN_USER` | Admin-level user | +| `DB_CONNECTIONS_ADMIN_PASSWORD` | Admin-level password | + +### Deployment Options + +| Variable | Description | +|----------|-------------| +| `DEPLOYMENT_USE_TX` | Use transactions for deployment | +| `DEPLOYMENT_FAST` | Fast deployment mode | +| `DEPLOYMENT_USE_PLAN` | Use deployment plan | +| `DEPLOYMENT_CACHE` | Enable deployment caching | +| `DEPLOYMENT_TO_CHANGE` | Deploy to specific change | + +### Server Configuration + +| Variable | Description | +|----------|-------------| +| `PORT` | Server port | +| `SERVER_HOST` | Server host | +| `SERVER_TRUST_PROXY` | Trust proxy headers | +| `SERVER_ORIGIN` | Server origin URL | +| `SERVER_STRICT_AUTH` | Strict authentication mode | + +### CDN/Storage + +| Variable | Description | +|----------|-------------| +| `BUCKET_PROVIDER` | Storage provider (s3, minio) | +| `BUCKET_NAME` | Bucket name | +| `AWS_REGION` | AWS region | +| `AWS_ACCESS_KEY_ID` | AWS access key | +| `AWS_SECRET_ACCESS_KEY` | AWS secret key | +| `MINIO_ENDPOINT` | MinIO endpoint URL | + +### Jobs Configuration + +| Variable | Description | +|----------|-------------| +| `JOBS_SCHEMA` | Schema for job tables | +| `JOBS_SUPPORT_ANY` | Support any job type | +| `JOBS_SUPPORTED` | Comma-separated supported job types | +| `INTERNAL_GATEWAY_URL` | Internal gateway URL | +| `INTERNAL_JOBS_CALLBACK_URL` | Jobs callback URL | +| `INTERNAL_JOBS_CALLBACK_PORT` | Jobs callback port | + +### Error Output + +| Variable | Description | +|----------|-------------| +| `PGPM_ERROR_QUERY_HISTORY_LIMIT` | Query history limit in errors | +| `PGPM_ERROR_MAX_LENGTH` | Max error message length | +| `PGPM_ERROR_VERBOSE` | Verbose error output | + +## Config File Discovery + +### loadConfigSync() + +Load `pgpm.json` by walking up directory tree: + +```typescript +import { loadConfigSync } from '@pgpmjs/env'; + +const config = loadConfigSync('/path/to/project'); +// Finds nearest pgpm.json walking up from given path +``` + +### loadConfigSyncFromDir() + +Load config from specific directory: + +```typescript +import { loadConfigSyncFromDir } from '@pgpmjs/env'; + +const config = loadConfigSyncFromDir('/path/to/project'); +``` + +### resolvePgpmPath() + +Find the pgpm.json file path: + +```typescript +import { resolvePgpmPath } from '@pgpmjs/env'; + +const pgpmPath = resolvePgpmPath('/path/to/project'); +// Returns full path to pgpm.json or undefined +``` + +## Workspace Resolution + +### resolvePnpmWorkspace() + +Find pnpm-workspace.yaml: + +```typescript +import { resolvePnpmWorkspace } from '@pgpmjs/env'; + +const workspacePath = resolvePnpmWorkspace('/path/to/project'); +``` + +### resolveLernaWorkspace() + +Find lerna.json: + +```typescript +import { resolveLernaWorkspace } from '@pgpmjs/env'; + +const lernaPath = resolveLernaWorkspace('/path/to/project'); +``` + +### resolveWorkspaceByType() + +Find workspace config by type: + +```typescript +import { resolveWorkspaceByType, WorkspaceType } from '@pgpmjs/env'; + +const path = resolveWorkspaceByType('/path/to/project', 'pnpm'); +// WorkspaceType: 'pnpm' | 'lerna' | 'npm' +``` + +## Utility Functions + +### walkUp() + +Walk up directory tree to find a file: + +```typescript +import { walkUp } from '@pgpmjs/env'; + +const found = walkUp('/start/path', 'pgpm.json'); +// Returns path to file or undefined +``` + +### getEnvVars() + +Parse environment variables into PgpmOptions: + +```typescript +import { getEnvVars } from '@pgpmjs/env'; + +const envOptions = getEnvVars(); +// Or with custom env object +const envOptions = getEnvVars(process.env); +``` + +### getNodeEnv() + +Get normalized NODE_ENV: + +```typescript +import { getNodeEnv } from '@pgpmjs/env'; + +const env = getNodeEnv(); +// Returns 'development' | 'production' | 'test' +``` + +### parseEnvBoolean() + +Parse boolean environment variable: + +```typescript +import { parseEnvBoolean } from '@pgpmjs/env'; + +parseEnvBoolean('true'); // true +parseEnvBoolean('1'); // true +parseEnvBoolean('yes'); // true +parseEnvBoolean('false'); // false +parseEnvBoolean(undefined); // undefined +``` + +### parseEnvNumber() + +Parse numeric environment variable: + +```typescript +import { parseEnvNumber } from '@pgpmjs/env'; + +parseEnvNumber('5432'); // 5432 +parseEnvNumber('invalid'); // undefined +parseEnvNumber(undefined); // undefined +``` + +## pgpm.json Configuration + +Example `pgpm.json` with environment options: + +```json +{ + "name": "my-module", + "version": "1.0.0", + "db": { + "rootDb": "postgres", + "template": "template1", + "prefix": "myapp_", + "extensions": ["uuid-ossp", "pgcrypto"], + "roles": { + "admin": "admin_role", + "app": "app_role", + "anonymous": "anon_role", + "authenticated": "auth_role" + }, + "connections": { + "app": { + "user": "app_user", + "password": "app_password" + }, + "admin": { + "user": "admin_user", + "password": "admin_password" + } + } + }, + "deployment": { + "useTx": true, + "fast": false, + "usePlan": true + } +} +``` + +## Integration with pgsql-test + +```typescript +import { getConnEnvOptions } from '@pgpmjs/env'; +import { getConnections } from 'pgsql-test'; + +const connOptions = getConnEnvOptions(); +const { db, teardown } = await getConnections(connOptions); +``` + +## Integration with pgpm CLI + +The pgpm CLI uses @pgpmjs/env internally. Quick setup: + +```bash +# Export standard PostgreSQL env vars +eval "$(pgpm env)" + +# Now all pgpm commands use these vars +pgpm deploy --createdb +``` + +## Best Practices + +1. **Use getEnvOptions()**: Let the library handle merging +2. **Config file for defaults**: Put project defaults in pgpm.json +3. **Env vars for secrets**: Never commit passwords to pgpm.json +4. **Override at runtime**: Pass overrides for test-specific config +5. **Consistent cwd**: Pass explicit cwd when running from different directories + +## References + +- Related skill: `references/cli.md` for CLI commands +- Related skill: `references/workspace.md` for workspace configuration +- Related skill: `github-workflows-pgpm` for CI/CD environment setup +- Related skill: `constructive-env` — Covers the full two-layer architecture (`@pgpmjs/env` + `@constructive-io/graphql-env`), GraphQL-specific env vars, SMTP config, and the "which package to import" decision guide diff --git a/.agents/skills/pgpm/references/extensions.md b/.agents/skills/pgpm/references/extensions.md new file mode 100644 index 0000000000..41b4d0df2f --- /dev/null +++ b/.agents/skills/pgpm/references/extensions.md @@ -0,0 +1,197 @@ +# pgpm Extensions + +How extensions and modules work in pgpm — adding dependencies, installing packages, and understanding the .control file. + +## When to Apply + +Use this skill when: +- Adding a PostgreSQL extension (uuid-ossp, pgcrypto, plpgsql, etc.) to a module +- Installing a pgpm-published module (@pgpm/faker, @pgpm/base32, etc.) +- Editing a `.control` file's `requires` list +- Running `pgpm extension` or `pgpm install` +- Debugging missing extension errors during deploy + +## Critical Rule + +**NEVER run `CREATE EXTENSION` directly in SQL migration files.** pgpm is deterministic — it reads the `.control` file and handles extension creation automatically during `pgpm deploy`. Writing `CREATE EXTENSION` in a deploy script will cause errors or duplicate operations. + +## Two Kinds of Extensions + +### 1. Native PostgreSQL Extensions + +Built into Postgres or installed via OS packages. Examples: + +| Extension | Purpose | +|-----------|---------| +| `uuid-ossp` | UUID generation (`uuid_generate_v4()`) | +| `pgcrypto` | Cryptographic functions (`gen_random_bytes()`) | +| `plpgsql` | PL/pgSQL procedural language | +| `pg_trgm` | Trigram text similarity | +| `citext` | Case-insensitive text | +| `hstore` | Key-value store | + +These are resolved by Postgres itself during deploy. pgpm issues `CREATE EXTENSION IF NOT EXISTS` for them automatically. + +### 2. pgpm Modules + +Published to npm under scoped names (e.g., `@pgpm/faker`, `@pgpm/base32`, `@pgpm/uuid`). These contain their own deploy/revert/verify scripts and are installed into the workspace's `extensions/` directory. + +| npm Name | Control Name | Purpose | +|----------|-------------|---------| +| `@pgpm/base32` | `pgpm-base32` | Base32 encoding | +| `@pgpm/types` | `pgpm-types` | Common types | +| `@pgpm/verify` | `pgpm-verify` | Verification helpers | +| `@pgpm/uuid` | `pgpm-uuid` | UUID utilities | +| `@pgpm/faker` | `pgpm-faker` | Test data generation | + +During deploy, pgpm resolves these from the `extensions/` directory and deploys them before your module (topological dependency order). + +## The .control File + +Every pgpm module has a `.control` file at its root. This declares metadata and dependencies. + +### Anatomy + +``` +# my-module extension +comment = 'My module description' +default_version = '0.0.1' +requires = 'plpgsql, uuid-ossp, pgpm-base32, pgpm-types' +``` + +**Key fields:** +- `comment` — Human-readable description +- `default_version` — Version string (typically `0.0.1`) +- `requires` — Comma-separated list of dependency **control names** (not npm names) + +### Control Names vs npm Names + +The `requires` field uses **control file names**, not npm package names: + +| npm Name (for install) | Control Name (for requires) | +|------------------------|-----------------------------| +| `@pgpm/base32` | `pgpm-base32` | +| `@pgpm/types` | `pgpm-types` | +| `uuid-ossp` | `uuid-ossp` | +| `pgcrypto` | `pgcrypto` | + +See `references/module-naming.md` for the full naming convention. + +## Adding Dependencies + +### Interactive: `pgpm extension` + +Run inside a module directory to interactively select dependencies: + +```bash +cd packages/my-module +pgpm extension +``` + +This shows a checkbox picker of all available modules in the workspace. Selected items are written to the `.control` file's `requires` list. You can also type custom extension names for native Postgres extensions. + +### Installing npm-published pgpm modules: `pgpm install` + +To add an npm-published pgpm module to your workspace: + +```bash +# Install a single module +pgpm install @pgpm/base32 + +# Install multiple modules +pgpm install @pgpm/base32 @pgpm/types @pgpm/uuid + +# Install all missing modules declared in .control requires +pgpm install +``` + +`pgpm install` downloads the module from npm and places it in the workspace's `extensions/` directory (e.g., `extensions/@pgpm/base32/`). + +After installing, use `pgpm extension` to add the installed module to your `.control` file's `requires`. + +### Manual Editing + +You can also edit the `.control` file directly: + +``` +requires = 'plpgsql, uuid-ossp, pgpm-base32' +``` + +Then run `pgpm install` (no arguments) to install any missing modules. + +## The extensions/ Directory + +When you run `pgpm install @pgpm/foo`, it creates: + +``` +extensions/ + @pgpm/ + foo/ + pgpm-foo.control # Module's control file + pgpm.plan # Module's deployment plan + deploy/ # Deploy scripts + revert/ # Revert scripts + verify/ # Verify scripts + package.json # npm metadata +``` + +This directory is typically committed to version control so that `pgpm deploy` can resolve all dependencies without needing npm access. + +## Upgrading Modules + +```bash +# Upgrade a specific module +pgpm upgrade-modules @pgpm/base32 + +# Upgrade all modules in the workspace +pgpm upgrade-modules --workspace --all + +# Preview what would be upgraded +pgpm upgrade-modules --workspace --all --dry-run +``` + +## Dependency Resolution During Deploy + +When you run `pgpm deploy`, pgpm: + +1. Reads the target module's `.control` file for `requires` +2. Resolves native Postgres extensions → queues `CREATE EXTENSION IF NOT EXISTS` +3. Resolves pgpm modules from `extensions/` → deploys them first (recursively resolving their dependencies) +4. Deploys your module's changes in plan order + +This is fully automatic — you never need to manually order extension creation. + +## Common Workflows + +### Add a native Postgres extension to your module + +1. Edit `.control`: + ``` + requires = 'plpgsql, uuid-ossp, pgcrypto' + ``` +2. Deploy — pgpm creates the extensions automatically + +### Add a pgpm module dependency + +1. Install: `pgpm install @pgpm/base32` +2. Add to requires: `pgpm extension` (interactive) or edit `.control` +3. Deploy — pgpm deploys `@pgpm/base32` before your module + +### Check what's installed + +```bash +# List installed modules in the workspace extensions/ dir +ls extensions/ + +# Check a module's dependencies +cat packages/my-module/my-module.control +``` + +## Troubleshooting + +| Issue | Cause | Fix | +|-------|-------|-----| +| `extension "pgpm-foo" is not available` | Module not installed in `extensions/` | Run `pgpm install @pgpm/foo` | +| `extension "uuid-ossp" is not available` | Postgres image missing the extension | Use `docker.io/constructiveio/postgres-plus:18` or `postgres-plus:17` image | +| Deploy creates extension twice | You wrote `CREATE EXTENSION` in a deploy script | Remove it — pgpm handles this automatically | +| Wrong name in requires | Used npm name instead of control name | Use control name (e.g., `pgpm-base32` not `@pgpm/base32`) | diff --git a/.agents/skills/pgpm/references/module-naming.md b/.agents/skills/pgpm/references/module-naming.md new file mode 100644 index 0000000000..dbac427474 --- /dev/null +++ b/.agents/skills/pgpm/references/module-naming.md @@ -0,0 +1,175 @@ +# PGPM Module Naming: npm Names vs Control File Names + +pgpm modules have two different identifiers that serve different purposes. Understanding when to use each is critical for correct dependency management. + +## When to Apply + +Use this skill when: +- Creating or editing `.control` files +- Writing `-- requires:` statements in SQL deploy files +- Running `pgpm install` commands +- Referencing dependencies between modules +- Publishing modules to npm + +## The Two Identifiers + +Every pgpm module has two names: + +### 1. npm Package Name (for distribution) + +Defined in `package.json` as the `name` field. Used for npm distribution and the `pgpm install` command. + +**Format:** `@scope/package-name` (scoped) or `package-name` (unscoped) + +**Examples:** +- `@sf-bot/rag-core` +- `@san-francisco/sf-docs-embeddings` +- `@pgpm/base32` + +### 2. Control File Name / Extension Name (for PostgreSQL) + +Defined by the `.control` filename and `%project=` in `pgpm.plan`. Used in PostgreSQL extension system and SQL dependency declarations. + +**Format:** `module-name` (no scope, no @ symbol) + +**Examples:** +- `rag-core` +- `sf-docs-embeddings` +- `pgpm-base32` + +## When to Use Each + +### Use npm Package Name (`@scope/name`) + +**1. pgpm install command:** +```bash +pgpm install @sf-bot/rag-core @sf-bot/rag-functions @sf-bot/rag-indexes +``` + +**2. package.json dependencies:** +```json +{ + "dependencies": { + "@sf-bot/rag-core": "^0.0.3" + } +} +``` + +### Use Control File Name (`name`) + +**1. .control file requires line:** +```sh +# sf-docs-embeddings.control +requires = 'rag-core' +``` + +**2. SQL deploy file requires comments:** +```sql +-- Deploy data/seed_collection to pg +-- requires: rag-core +``` + +**3. pgpm.plan %project declaration:** +```sh +%project=sf-docs-embeddings +``` + +**4. Cross-package references in pgpm.plan:** +```sh +data/seed [rag-core:schemas/rag/schema] 2026-01-25T00:00:00Z Author +``` + +## Real-World Example + +Consider the `sf-docs-embeddings` module: + +**package.json** (npm name for distribution): +```json +{ + "name": "@san-francisco/sf-docs-embeddings", + "version": "0.0.3" +} +``` + +**sf-docs-embeddings.control** (control name for PostgreSQL): +```sh +# sf-docs-embeddings extension +comment = 'San Francisco documentation embeddings' +default_version = '0.0.1' +requires = 'rag-core' +``` + +**pgpm.plan** (control name for project): +```sh +%project=sf-docs-embeddings +``` + +**deploy/data/seed_collection.sql** (control name in requires): +```sql +-- Deploy data/seed_collection to pg +-- requires: rag-core +``` + +## The Mapping + +pgpm maintains an internal mapping between control names and npm names. When you run `pgpm install`, it: + +1. Reads the `.control` file's `requires` list (control names) +2. Maps those to npm package names +3. Installs the npm packages + +For example, if your `.control` has `requires = 'pgpm-base32'`, pgpm knows to install `@pgpm/base32` from npm. + +## Common Mistakes + +### Wrong: Using npm name in .control file +```sh +# WRONG +requires = '@sf-bot/rag-core' + +# CORRECT +requires = 'rag-core' +``` + +### Wrong: Using control name in pgpm install +```bash +# WRONG +pgpm install rag-core + +# CORRECT +pgpm install @sf-bot/rag-core +``` + +### Wrong: Using npm name in SQL requires +```sql +-- WRONG +-- requires: @sf-bot/rag-core + +-- CORRECT +-- requires: rag-core +``` + +## Quick Reference Table + +| Context | Use | Example | +|---------|-----|---------| +| `pgpm install` | npm name | `@sf-bot/rag-core` | +| `package.json` name | npm name | `@sf-bot/rag-core` | +| `package.json` dependencies | npm name | `@sf-bot/rag-core` | +| `.control` requires | control name | `rag-core` | +| SQL `-- requires:` | control name | `rag-core` | +| `pgpm.plan` %project | control name | `rag-core` | +| Cross-package deps | control name | `rag-core:schemas/rag` | + +## Summary + +- **npm names** (`@scope/name`): Used for distribution and installation via npm/pgpm install +- **Control names** (`name`): Used for PostgreSQL extension system, .control files, and SQL dependency declarations + +Think of it this way: npm names are for the JavaScript/npm ecosystem, control names are for the PostgreSQL ecosystem. + +## References + +- Related skill: `references/cli.md` for CLI commands +- Related skill: `references/workspace.md` for workspace structure +- Related skill: `references/changes.md` for authoring database changes diff --git a/.agents/skills/pgpm/references/nextjs-app.md b/.agents/skills/pgpm/references/nextjs-app.md new file mode 100644 index 0000000000..8e7520abab --- /dev/null +++ b/.agents/skills/pgpm/references/nextjs-app.md @@ -0,0 +1,100 @@ +# Constructive Next.js App Boilerplate + +A frontend-only Next.js application that connects to a Constructive backend. Provides production-ready authentication flows, organization management, invite handling, member management, and account settings — all powered by a generated GraphQL SDK. + +## Setup + +### 1. Scaffold from Template + +```bash +pgpm init -w \ + --repo constructive-io/sandbox-templates \ + --template nextjs/constructive-app \ + --name \ + --fullName "" \ + --email "" \ + --repoName \ + --username \ + --license MIT \ + --moduleName +``` + +### 2. Install and Configure + +```bash +cd /packages/ +pnpm install +``` + +Create `.env.local`: + +```bash +NEXT_PUBLIC_SCHEMA_BUILDER_GRAPHQL_ENDPOINT=http://api.localhost:3000/graphql +``` + +### 3. Generate SDK and Start + +```bash +pnpm codegen # Generate GraphQL SDK against running backend +pnpm dev # Opens at http://localhost:3001 +``` + +## Backend Requirements + +Requires a running Constructive backend (typically via Constructive Hub): + +| Service | Port | Purpose | +|---------|------|---------| +| PostgreSQL | 5432 | Database with Constructive schema | +| GraphQL Server (Public) | 3000 | API endpoint for app operations | +| GraphQL Server (Private) | 3002 | Admin operations | +| Job Service | 8080 | Background job processing | +| Email Function | 8082 | Email sending via SMTP | +| Mailpit SMTP | 1025 | Email server (development) | +| Mailpit UI | 8025 | View sent emails | + +## Project Structure + +``` +src/ +├── app/ # Next.js App Router pages +│ ├── login/ register/ # Auth flows +│ ├── account/ settings/ # User management +│ └── orgs/[orgId]/ # Org-scoped pages (activity, invites, members, settings) +├── components/ +│ ├── ui/ # shadcn/ui components (43 components) +│ ├── auth/ # Auth forms +│ ├── organizations/ # Org CRUD +│ ├── invites/ members/ # Org management +│ └── app-shell/ # Sidebar, navigation, layout +├── graphql/ +│ └── schema-builder-sdk/ # Generated SDK (via codegen) +└── lib/ + ├── auth/ # Auth utilities and context + ├── gql/ # GraphQL hooks and query factories + └── permissions/ # Permission checking +``` + +## Customization + +### Branding + +Edit `src/config/branding.ts` — app name, tagline, logo paths, legal links. + +### Adding UI Components + +```bash +npx shadcn@latest add @constructive/ +``` + +Registry URL configured in `components.json`. Components use Base UI primitives, Tailwind CSS 4, and cva for variants. + +## Features + +- **Authentication** — Login, register, logout, password reset, email verification +- **Organizations** — Create and manage organizations +- **Invites** — Send and accept organization invites +- **Members** — Manage organization members and roles +- **Account Management** — Profile, email verification, account deletion +- **App Shell** — Sidebar navigation, theme switching, responsive layout +- **Permissions** — Role-based access control for org features diff --git a/.agents/skills/pgpm/references/pgpm-export.md b/.agents/skills/pgpm/references/pgpm-export.md new file mode 100644 index 0000000000..33e1045881 --- /dev/null +++ b/.agents/skills/pgpm/references/pgpm-export.md @@ -0,0 +1,56 @@ +# pgpm Export + +Export a provisioned Constructive DB back to pgpm packages — two outputs: + +| Package | Contains | +|---|---| +| Extension (`extensionName`) | Raw SQL migrations — tables, RLS, functions, indexes | +| Service (`metaExtensionName`) | Metaschema records — database/table/field/policy rows as INSERTs | + +## CLI Usage (Partial Automation) + +```bash +cd path/to/your-app # your project workspace +eval "$(pgpm env)" + +pgpm export \ + --author "name " \ + --extensionName myapp \ + --metaExtensionName myapp-svc +``` + +Three prompts still require TTY: select database, select database_id, select schemas. + +## Programmatic API + +Key steps: +1. Look up `database_id` from `metaschema_public.database` +2. Look up `schema_names` from `metaschema_public.schema` +3. Call `exportMigrations()` from `@pgpmjs/core` + +```typescript +await exportMigrations({ + project, options, + dbInfo: { dbname: HOST_DB, databaseName: DB_NAME, database_ids: [databaseId] }, + author: AUTHOR, schema_names, + extensionName: EXT_NAME, metaExtensionName: SVC_NAME, + outdir: resolve(WORKSPACE, 'packages'), +}); +``` + +## How It Works + +1. Queries `db_migrate.sql_actions` for raw migration history +2. Applies schema name replacer (internal names → portable `extensionName` prefix) +3. Writes extension package with `pgpm.plan`, `deploy/`, `revert/`, `verify/` +4. Reads metaschema records via `export-meta` +5. Writes service package + +## Re-Running + +- Interactive: prompts to confirm overwrite +- Programmatic: silently overwrites SQL files, preserves `pgpm.json`/`package.json` + +## Related + +- `constructive-sdk` skill — provision before exporting diff --git a/.agents/skills/pgpm/references/pgpm-tables.md b/.agents/skills/pgpm/references/pgpm-tables.md new file mode 100644 index 0000000000..a3b662fadd --- /dev/null +++ b/.agents/skills/pgpm/references/pgpm-tables.md @@ -0,0 +1,59 @@ +# pgpm Table Creation Rules + +When creating a new table in `metaschema_modules_public`, `metaschema_public`, or `services_public` schemas in the constructive-db repository, follow these steps. + +## Required: Deterministic ID Trigger + +Every table with a `uuid` primary key MUST have a `zzz_set_deterministic_id` trigger. This is critical for deterministic test runs and reproducible deployments. + +### Pattern + +For a table named `my_table` in schema `metaschema_modules_public`: + +1. **Deploy file** at `packages/metaschema/deploy/schemas/metaschema_modules_public/tables/my_table/triggers/set_deterministic_id.sql`: + +```sql +-- Deploy schemas/metaschema_modules_public/tables/my_table/triggers/set_deterministic_id to pg + +-- requires: schemas/metaschema_modules_private/schema +-- requires: schemas/metaschema_modules_public/tables/my_table/table +-- requires: schemas/metaschema_private/procedures/deterministic_id + +BEGIN; + +CREATE FUNCTION metaschema_modules_private.tg_set_my_table_deterministic_id() +RETURNS TRIGGER AS $$ +BEGIN + IF current_setting('metaschema.deterministic_ids', true) = 'true' THEN + NEW.id := metaschema_private.deterministic_id(NEW.table_id, NEW.node_type); + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER zzz_set_deterministic_id +BEFORE INSERT ON metaschema_modules_public.my_table +FOR EACH ROW +EXECUTE FUNCTION metaschema_modules_private.tg_set_my_table_deterministic_id(); + +ALTER TABLE metaschema_modules_public.my_table +ENABLE ALWAYS TRIGGER zzz_set_deterministic_id; + +COMMIT; +``` + +Note: The `deterministic_id()` arguments vary by table. Check existing tables for the correct arguments. + +2. **Verify file** — use `verify_function` and `verify_trigger` +3. **Revert file** — `DROP TRIGGER IF EXISTS` + `DROP FUNCTION IF EXISTS` +4. **pgpm.plan entry** — add with proper dependencies + +## Checklist for New Tables + +- [ ] Table DDL (deploy/verify/revert + pgpm.plan + extension SQL) +- [ ] Insert trigger (deploy/verify/revert + pgpm.plan) +- [ ] **Deterministic ID trigger** (deploy/verify/revert + pgpm.plan + extension SQL) +- [ ] COMMENT ON COLUMN for every column +- [ ] COMMENT ON TABLE +- [ ] Foreign key constraints with `@omit manyToMany` comments +- [ ] Indexes on foreign key columns diff --git a/.agents/skills/pgpm/references/plan-format.md b/.agents/skills/pgpm/references/plan-format.md new file mode 100644 index 0000000000..08e81c379b --- /dev/null +++ b/.agents/skills/pgpm/references/plan-format.md @@ -0,0 +1,154 @@ +# PGPM Plan File Format + +Guide to the correct format for pgpm.plan files and common format errors. + +## When to Apply + +Use this skill when: +- Encountering "Invalid line format" errors from pgpm +- Creating new pgpm.plan files +- Adding changes with dependencies to a plan file +- Debugging plan file parse errors + +## Plan File Format + +### Basic Structure + +A pgpm.plan file has the following structure: + +``` +%syntax-version=1.0.0 +%project=module-name +%uri=module-name + +change_name [dependencies] timestamp planner # comment +``` + +### Change Line Format + +The correct format for a change line is: + +``` +change_name [dep1 dep2] 2026-01-25T00:00:00Z planner-name # optional comment +``` + +**Order matters!** The components must appear in this exact order: +1. `change_name` - The name/path of the change (e.g., `schemas/public/tables/users`) +2. `[dependencies]` - Optional, space-separated list of dependencies in square brackets +3. `timestamp` - ISO 8601 format: `YYYY-MM-DDTHH:MM:SSZ` +4. `planner` - Name of the person/entity who planned the change +5. `` - Email in angle brackets +6. `# comment` - Optional comment starting with `#` + +### Common Mistake: Dependencies After Email + +**Wrong:** +``` +data/seed_chunks 2026-01-25T00:00:00Z city-of-san-francisco [data/create_collection] # comment +``` + +**Correct:** +``` +data/seed_chunks [data/create_collection] 2026-01-25T00:00:00Z city-of-san-francisco # comment +``` + +The parser expects dependencies immediately after the change name, not after the email. + +## Error Messages + +### "Line N: Invalid line format" + +**Symptom:** +```text +PgpmError: Failed to parse plan file /path/to/pgpm.plan: Line 6: Invalid line format +``` + +**Cause:** The line doesn't match the expected format. Most commonly: +- Dependencies placed in wrong position +- Missing or malformed timestamp +- Missing angle brackets around email +- Invalid characters in change name + +**Solution:** Check the line format matches: +``` +change_name [deps] timestamp planner # comment +``` + +### Parser Regex + +The pgpm parser uses this regex pattern for change lines: +```javascript +/^(\S+)(?:\s+\[([^\]]*)\])?(?:\s+(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z)(?:\s+([^<]+?))?(?:\s+<([^>]+)>)?(?:\s+#\s+(.*))?)?$/ +``` + +This breaks down as: +- `(\S+)` - change name (required) +- `(?:\s+\[([^\]]*)\])?` - dependencies in brackets (optional) +- `(?:\s+(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z)` - ISO timestamp +- `(?:\s+([^<]+?))?` - planner name +- `(?:\s+<([^>]+)>)?` - email in angle brackets +- `(?:\s+#\s+(.*))?` - comment + +## Examples + +### Change Without Dependencies + +``` +schemas/public/tables/users 2026-01-25T00:00:00Z dan # create users table +``` + +### Change With Single Dependency + +``` +schemas/public/tables/posts [schemas/public/tables/users] 2026-01-25T00:00:00Z dan # posts table +``` + +### Change With Multiple Dependencies + +``` +schemas/public/views/user_posts [schemas/public/tables/users schemas/public/tables/posts] 2026-01-25T00:00:00Z dan +``` + +### Cross-Module Dependency + +``` +data/seed [other-module:schemas/setup] 2026-01-25T00:00:00Z dan # depends on other module +``` + +## SQL File Dependencies + +Dependencies can also be declared in SQL deploy files using the `-- requires:` comment: + +```sql +-- Deploy module-name:schemas/public/tables/posts to pg +-- requires: schemas/public/tables/users + +CREATE TABLE posts ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid REFERENCES users(id), + content text +); +``` + +**Note:** Do not wrap SQL in `BEGIN`/`COMMIT` transactions - pgpm handles transactions automatically. + +These are used by pgpm for dependency resolution but the plan file format is what gets parsed first. + +## Quick Reference + +| Component | Required | Position | Format | +|-----------|----------|----------|--------| +| change_name | Yes | 1st | No spaces, use `/` for paths | +| [dependencies] | No | 2nd | Space-separated in brackets | +| timestamp | Yes* | 3rd | `YYYY-MM-DDTHH:MM:SSZ` | +| planner | Yes* | 4th | Any text without `<` | +| email | Yes* | 5th | In angle brackets `<...>` | +| comment | No | 6th | After `# ` | + +*Required if any metadata is present + +## References + +- Related skill: `references/troubleshooting.md` for general pgpm issues +- Related skill: `references/dependencies.md` for dependency management +- Related skill: `references/changes.md` for adding changes to modules diff --git a/.agents/skills/pgpm/references/publishing.md b/.agents/skills/pgpm/references/publishing.md new file mode 100644 index 0000000000..305cb3bf11 --- /dev/null +++ b/.agents/skills/pgpm/references/publishing.md @@ -0,0 +1,345 @@ + +# Publishing PGPM Modules (Constructive Standard) + +Publish pgpm SQL modules to npm using pgpm package bundling and lerna for versioning. This covers the workflow for @pgpm/* scoped packages. + +## When to Apply + +Use this skill when: +- Publishing SQL database modules to npm +- Bundling pgpm packages for distribution +- Managing @pgpm/* scoped packages +- Working with pgpm-modules or similar repositories + +## PGPM vs PNPM Workspaces + +| Aspect | PGPM Workspace | PNPM Workspace | +|--------|----------------|----------------| +| Purpose | SQL database modules | TypeScript/JS packages | +| Config | pnpm-workspace.yaml + pgpm.json | pnpm-workspace.yaml only | +| Build | `pgpm package` | `makage build` | +| Output | SQL bundles | dist/ folder | +| Versioning | Fixed (recommended) | Independent | + +## Workspace Structure + +```text +pgpm-modules/ +├── .gitignore +├── lerna.json +├── package.json +├── packages/ +│ ├── faker/ +│ │ ├── deploy/ +│ │ ├── revert/ +│ │ ├── verify/ +│ │ ├── package.json +│ │ ├── pgpm-faker.control +│ │ └── pgpm.plan +│ └── utils/ +│ ├── deploy/ +│ ├── revert/ +│ ├── verify/ +│ ├── package.json +│ ├── pgpm-utils.control +│ └── pgpm.plan +├── pgpm.json +├── pnpm-lock.yaml +└── pnpm-workspace.yaml +``` + +## Configuration Files + +### pgpm.json + +Points to packages containing SQL modules: + +```json +{ + "packages": [ + "packages/*" + ] +} +``` + +### pnpm-workspace.yaml + +Same packages directory: + +```yaml +packages: + - packages/* +``` + +### lerna.json (Fixed Versioning) + +For pgpm modules, use **fixed versioning** so all modules release together: + +```json +{ + "$schema": "node_modules/lerna/schemas/lerna-schema.json", + "version": "0.16.6", + "npmClient": "pnpm" +} +``` + +**Note:** Unlike TypeScript packages which often use independent versioning, pgpm modules typically use fixed versioning because they're tightly coupled. + +### Root package.json + +```json +{ + "name": "pgpm-modules", + "version": "0.0.1", + "private": true, + "repository": { + "type": "git", + "url": "https://github.com/constructive-io/pgpm-modules" + }, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "scripts": { + "bundle": "pnpm -r bundle", + "lint": "pnpm -r lint", + "test": "pnpm -r test", + "deps": "pnpm up -r -i -L" + }, + "devDependencies": { + "@types/jest": "^30.0.0", + "jest": "^30.2.0", + "lerna": "^8.2.3", + "pgsql-test": "^2.18.6", + "ts-jest": "^29.4.5", + "typescript": "^5.9.3" + } +} +``` + +## Module Configuration + +### Module package.json + +```json +{ + "name": "@pgpm/faker", + "version": "0.16.0", + "description": "Fake data generation utilities for testing", + "author": "Dan Lynch ", + "keywords": ["postgresql", "pgpm", "faker", "testing"], + "publishConfig": { + "access": "public" + }, + "scripts": { + "bundle": "pgpm package", + "test": "jest", + "test:watch": "jest --watch" + }, + "dependencies": { + "@pgpm/types": "workspace:*", + "@pgpm/verify": "workspace:*" + }, + "devDependencies": { + "pgpm": "^1.3.0" + }, + "repository": { + "type": "git", + "url": "https://github.com/constructive-io/pgpm-modules" + } +} +``` + +**Key differences from TypeScript packages:** +- No `publishConfig.directory` — publishes from package root +- Uses `pgpm package` for bundling instead of makage +- Dependencies on other @pgpm/* modules use `workspace:*` + +### Module .control File + +```sh +# pgpm-faker.control +comment = 'Fake data generation utilities' +default_version = '0.16.0' +requires = 'plpgsql,uuid-ossp' +``` + +### Module pgpm.plan + +```sh +%syntax-version=1.0.0 +%project=pgpm-faker +%uri=pgpm-faker + +schemas/faker 2025-01-01T00:00:00Z Author +schemas/faker/functions/random_name [schemas/faker] 2025-01-01T00:00:00Z Author +``` + +## Build Workflow + +### Bundle a Module + +```bash +cd packages/faker +pgpm package +``` + +Or bundle all modules: + +```bash +pnpm -r bundle +``` + +### Run Tests + +```bash +# All modules +pnpm -r test + +# Specific module +pnpm --filter @pgpm/faker test +``` + +## Publishing Workflow + +### 1. Prepare + +```bash +pnpm install +pnpm -r bundle +pnpm -r test +``` + +### 2. Version + +```bash +# Fixed versioning (all packages get same version) +pnpm lerna version + +# Or with conventional commits +pnpm lerna version --conventional-commits +``` + +### 3. Publish + +```bash +# Use from-package to publish versioned packages +pnpm lerna publish from-package +``` + +### One-Liner + +```bash +pnpm install && pnpm -r bundle && pnpm -r test && pnpm lerna version && pnpm lerna publish from-package +``` + +## Dry Run Commands + +```bash +# Test versioning (no git operations) +pnpm lerna version --no-git-tag-version --no-push + +# Test publishing +pnpm lerna publish from-package --dry-run +``` + +## Module Dependencies + +### Internal Dependencies + +Use `workspace:*` for dependencies on other pgpm modules: + +```json +{ + "dependencies": { + "@pgpm/types": "workspace:*", + "@pgpm/verify": "workspace:*" + } +} +``` + +### SQL Dependencies + +Declare SQL-level dependencies in the .control file: + +```sh +requires = 'plpgsql,uuid-ossp,@pgpm/types' +``` + +And in deploy scripts: + +```sql +-- Deploy: schemas/faker/functions/random_name +-- requires: schemas/faker +-- requires: @pgpm/types:schemas/types + +CREATE FUNCTION faker.random_name() +RETURNS TEXT AS $$ + -- implementation +$$ LANGUAGE plpgsql; +``` + +## Three-File Pattern + +Every SQL change has three files: + +| File | Purpose | +|------|---------| +| `deploy/.sql` | Creates the object | +| `revert/.sql` | Removes the object | +| `verify/.sql` | Confirms deployment | + +**Example:** + +`deploy/schemas/faker/functions/random_name.sql`: +```sql +-- Deploy: schemas/faker/functions/random_name +-- requires: schemas/faker + +BEGIN; +CREATE FUNCTION faker.random_name() +RETURNS TEXT AS $$ +BEGIN + RETURN 'John Doe'; +END; +$$ LANGUAGE plpgsql; +COMMIT; +``` + +`revert/schemas/faker/functions/random_name.sql`: +```sql +-- Revert: schemas/faker/functions/random_name + +BEGIN; +DROP FUNCTION IF EXISTS faker.random_name(); +COMMIT; +``` + +`verify/schemas/faker/functions/random_name.sql`: +```sql +-- Verify: schemas/faker/functions/random_name + +SELECT verify_function('faker.random_name'); +``` + +## Naming Conventions + +- Package name: `@pgpm/` +- Control file: `pgpm-.control` +- SQL uses snake_case for identifiers +- Never use `CREATE OR REPLACE` — pgpm is deterministic + +## Best Practices + +1. **Fixed versioning**: Use for tightly coupled SQL modules +2. **Test before publish**: Run `pnpm -r test` to verify all modules +3. **Bundle before publish**: Run `pnpm -r bundle` to create packages +4. **Use verify helpers**: Leverage @pgpm/verify for consistent verification +5. **Document dependencies**: Keep .control file and SQL requires in sync + +## References + +- Related reference: `references/workspace.md` for workspace setup +- Related reference: `references/changes.md` for authoring SQL changes +- Related reference: `references/dependencies.md` for managing dependencies +- Related skill: `pnpm-publishing` for TypeScript package publishing diff --git a/.agents/skills/pgpm/references/sql-conventions.md b/.agents/skills/pgpm/references/sql-conventions.md new file mode 100644 index 0000000000..7586047204 --- /dev/null +++ b/.agents/skills/pgpm/references/sql-conventions.md @@ -0,0 +1,309 @@ + +# pgpm SQL Conventions + +Rules and format for writing SQL migration files in pgpm modules. + +## When to Apply + +Use this skill when: +- Writing new deploy/revert/verify SQL files +- Adding database changes to a pgpm module +- Reviewing SQL migration code for correctness +- Debugging deployment failures related to SQL format + +## Critical Rules + +### 1. NEVER Use CREATE OR REPLACE + +pgpm is **deterministic** — each change is deployed exactly once and reverted exactly once. Use `CREATE`, not `CREATE OR REPLACE`: + +```sql +-- CORRECT +CREATE FUNCTION app.my_function() ... + +-- WRONG — never do this in pgpm +CREATE OR REPLACE FUNCTION app.my_function() ... +``` + +If you need to modify an existing function, create a **new change** that drops and recreates it, or use the revert/redeploy cycle. + +### 2. NO Transaction Wrapping + +**Do NOT add `BEGIN`/`COMMIT` or `BEGIN`/`ROLLBACK` to your SQL files.** pgpm handles transactions automatically. Just write the raw SQL: + +```sql +-- CORRECT — just the SQL +-- Deploy schemas/app/tables/users to pg + +CREATE TABLE app.users ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + email text NOT NULL UNIQUE, + name text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now() +); + +-- WRONG — do not wrap in transactions +BEGIN; +CREATE TABLE app.users ( ... ); +COMMIT; +``` + +### 3. Use snake_case for All Identifiers + +All SQL identifiers must use `snake_case`: + +```sql +-- CORRECT +CREATE TABLE app.user_profiles ( + user_id uuid NOT NULL, + display_name text, + created_at timestamptz NOT NULL DEFAULT now() +); + +-- WRONG +CREATE TABLE app.userProfiles ( + userId uuid NOT NULL, + displayName text, + createdAt timestamptz NOT NULL DEFAULT now() +); +``` + +## File Header Format + +Every SQL file starts with a header comment declaring its purpose and path. + +### Deploy Files + +```sql +-- Deploy schemas/app/tables/users to pg + +-- requires: schemas/app/schema + +CREATE TABLE app.users ( + ... +); +``` + +### Revert Files + +```sql +-- Revert schemas/app/tables/users from pg + +DROP TABLE IF EXISTS app.users; +``` + +### Verify Files + +```sql +-- Verify schemas/app/tables/users on pg + +SELECT id, email, name, created_at +FROM app.users +WHERE FALSE; +``` + +**Header pattern:** +- Deploy: `-- Deploy to pg` +- Revert: `-- Revert from pg` +- Verify: `-- Verify on pg` + +Always check existing files in the same directory for the exact format used in that module. + +## Dependency Declarations + +Use `-- requires:` comments after the header to declare dependencies: + +```sql +-- Deploy schemas/app/tables/user_profiles to pg + +-- requires: schemas/app/schema +-- requires: schemas/app/tables/users + +CREATE TABLE app.user_profiles ( + user_id uuid NOT NULL REFERENCES app.users(id), + bio text, + avatar_url text +); +``` + +### Cross-Module Dependencies + +When depending on a change from another module, prefix with the module name: + +```sql +-- Deploy schemas/app/procedures/get_user to pg + +-- requires: schemas/app/schema +-- requires: other-module:schemas/shared/tables/users + +CREATE FUNCTION app.get_user(user_id uuid) ... +``` + +The format is `module_name:change_path`. + +## Common Change Types + +### Schema + +```sql +-- Deploy schemas/app/schema to pg + +CREATE SCHEMA app; +``` + +Revert: `DROP SCHEMA IF EXISTS app;` +Verify: `SELECT 1/count(*) FROM information_schema.schemata WHERE schema_name = 'app';` + +### Table + +```sql +-- Deploy schemas/app/tables/users to pg + +-- requires: schemas/app/schema + +CREATE TABLE app.users ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + email text NOT NULL UNIQUE, + name text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now() +); +``` + +Revert: `DROP TABLE IF EXISTS app.users;` +Verify: `SELECT id, email, name, created_at FROM app.users WHERE FALSE;` + +### Function / Procedure + +```sql +-- Deploy schemas/app/procedures/authenticate to pg + +-- requires: schemas/app/schema +-- requires: schemas/app/tables/users + +CREATE FUNCTION app.authenticate(email text, password text) +RETURNS app.users AS $$ +DECLARE + result app.users; +BEGIN + SELECT * INTO result + FROM app.users u + WHERE u.email = authenticate.email; + + IF result IS NULL THEN + RAISE EXCEPTION 'Invalid credentials'; + END IF; + + RETURN result; +END; +$$ LANGUAGE plpgsql STRICT SECURITY DEFINER; +``` + +Revert: `DROP FUNCTION IF EXISTS app.authenticate(text, text);` +Verify: `SELECT has_function_privilege('app.authenticate(text, text)', 'execute');` + +### Index + +```sql +-- Deploy schemas/app/tables/users/indexes/users_email_idx to pg + +-- requires: schemas/app/tables/users + +CREATE INDEX users_email_idx ON app.users (email); +``` + +Revert: `DROP INDEX IF EXISTS app.users_email_idx;` + +### Grant / RLS Policy + +```sql +-- Deploy schemas/app/tables/users/policies/users_select_policy to pg + +-- requires: schemas/app/tables/users + +ALTER TABLE app.users ENABLE ROW LEVEL SECURITY; + +CREATE POLICY users_select_policy ON app.users + FOR SELECT + TO authenticated + USING (id = current_setting('auth.user_id')::uuid); +``` + +Revert: `DROP POLICY IF EXISTS users_select_policy ON app.users;` + +### View (PostgreSQL 17+) + +```sql +-- Deploy schemas/app/views/active_users to pg + +-- requires: schemas/app/tables/users + +CREATE VIEW app.active_users + WITH (security_invoker = true) +AS + SELECT id, email, name + FROM app.users + WHERE active = true; +``` + +Note: `security_invoker` requires PostgreSQL 17+. + +### Trigger + +```sql +-- Deploy schemas/app/tables/users/triggers/update_timestamp to pg + +-- requires: schemas/app/tables/users + +CREATE FUNCTION app.tg_update_timestamp() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at := now(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER update_timestamp + BEFORE UPDATE ON app.users + FOR EACH ROW + EXECUTE FUNCTION app.tg_update_timestamp(); +``` + +## Nested Path Organization + +Changes are organized in nested directory paths that mirror the database structure: + +``` +deploy/ + schemas/ + app/ + schema.sql + tables/ + users.sql + posts.sql + posts/ + indexes/ + posts_author_idx.sql + policies/ + posts_select_policy.sql + procedures/ + authenticate.sql + views/ + active_users.sql +``` + +The path in the plan file matches the directory path: +``` +schemas/app/schema [deps] timestamp author # comment +schemas/app/tables/users [schemas/app/schema] timestamp author # comment +``` + +## Checklist for New Changes + +1. Create all three files: `deploy/`, `revert/`, `verify/` +2. Add the correct header to each file (`-- Deploy`, `-- Revert`, `-- Verify`) +3. Add `-- requires:` declarations in the deploy file +4. Add the change to `pgpm.plan` with dependencies +5. Use `CREATE` not `CREATE OR REPLACE` +6. Do NOT wrap in `BEGIN`/`COMMIT` — pgpm handles transactions +7. Use `snake_case` for all identifiers +8. Check existing files in the module for format conventions diff --git a/.agents/skills/pgpm/references/starter-kits.md b/.agents/skills/pgpm/references/starter-kits.md new file mode 100644 index 0000000000..51ba7e1f13 --- /dev/null +++ b/.agents/skills/pgpm/references/starter-kits.md @@ -0,0 +1,79 @@ +# Project Scaffolding with pgpm init + +Scaffold new Constructive projects using `pgpm init` — workspace/module templates (PGPM and PNPM variants), Next.js app boilerplate, custom template repositories, and boilerplate authoring. + +## When to Apply + +Use this reference when: +- Scaffolding a new workspace or module with `pgpm init` +- Setting up a Constructive Next.js frontend application +- Using custom template repositories +- Authoring new boilerplate templates +- Setting up non-interactive `pgpm init` for CI/CD + +## Quick Start + +```bash +# Create a PGPM workspace + module +pgpm init -w + +# Create a Next.js app from template +pgpm init -w --repo constructive-io/sandbox-templates --template nextjs/constructive-app + +# Create a pure TypeScript workspace +pgpm init workspace --dir pnpm +``` + +## Available Templates + +| Template | Command | Description | +|----------|---------|-------------| +| PGPM workspace | `pgpm init workspace` | Monorepo with pgpm.json, migrations support | +| PGPM module | `pgpm init` | Database module with pgpm.plan, .control file | +| PNPM workspace | `pgpm init workspace --dir pnpm` | Pure PNPM workspace (no pgpm files) | +| PNPM module | `pgpm init --dir pnpm` | Pure TypeScript package | +| Next.js App | `pgpm init -w --repo constructive-io/sandbox-templates -t nextjs/constructive-app` | Full-stack Constructive frontend | + +## CLI Options + +| Option | Description | +|--------|-------------| +| `--repo ` | Template repository (default: constructive-io/pgpm-boilerplates) | +| `--from-branch ` | Branch/tag to use when cloning repo | +| `--dir ` | Template variant directory (e.g., pnpm, supabase) | +| `--template, -t ` | Full template path (e.g., pnpm/module) — combines dir and type | +| `--boilerplate` | Prompt to select from available boilerplates | +| `--create-workspace, -w` | Create a workspace first, then create the module inside it | +| `--no-tty` | Run in non-interactive mode | + +## Non-Interactive Mode + +For CI/CD pipelines and automation, use `--no-tty` or set `CI=true`: + +```bash +pgpm init workspace --no-tty \ + --name my-workspace \ + --fullName "Your Name" \ + --email "you@example.com" \ + --username your-github-username \ + --license MIT +``` + +### Required Parameters for Non-Interactive Module + +| Parameter | Description | +|-----------|-------------| +| `--moduleName` | Module name | +| `--moduleDesc` | Module description | +| `--fullName` | Author's full name | +| `--email` | Author's email | +| `--username` | GitHub username | +| `--repoName` | Repository name | +| `--license` | License | +| `--access` | npm access level (public/restricted) | +| `--extensions` | PostgreSQL extensions (comma-separated) | + +## Detailed References + +- [template-authoring.md](template-authoring.md) — Creating custom boilerplate templates +- [nextjs-app.md](nextjs-app.md) — Constructive Next.js app boilerplate diff --git a/.agents/skills/pgpm/references/template-authoring.md b/.agents/skills/pgpm/references/template-authoring.md new file mode 100644 index 0000000000..273a6b464d --- /dev/null +++ b/.agents/skills/pgpm/references/template-authoring.md @@ -0,0 +1,111 @@ +# Custom Boilerplate Authoring + +Create and customize boilerplate templates for `pgpm init`. + +## Template Repository Structure + +``` +my-boilerplates/ + .boilerplates.json # Root config (points to default directory) + pgpm/ # Default template variant (PGPM) + module/ + .boilerplate.json # Module template config + package.json # Template files with placeholders + pgpm.plan + workspace/ + .boilerplate.json # Workspace template config + pnpm/ # Alternative variant (pure PNPM) + module/ + .boilerplate.json + workspace/ + .boilerplate.json +``` + +## Root Configuration + +`.boilerplates.json` at the repository root specifies the default template directory: + +```json +{ + "dir": "pgpm" +} +``` + +## Template Configuration + +Each template has a `.boilerplate.json` file defining its type, workspace requirements, and questions. + +### Template Types + +| Type | Description | +|------|-------------| +| `workspace` | Creates a new monorepo workspace | +| `module` | Creates a package within a workspace | +| `generic` | Standalone template (no workspace context) | + +### Workspace Requirements + +```json +{ + "type": "module", + "requiresWorkspace": "pgpm" +} +``` + +| Value | Description | +|-------|-------------| +| `"pgpm"` | Requires PGPM workspace (pgpm.json) | +| `"pnpm"` | Requires PNPM workspace (pnpm-workspace.yaml) | +| `"lerna"` | Requires Lerna workspace (lerna.json) | +| `"npm"` | Requires npm workspace (package.json with workspaces) | +| `false` | No workspace required | + +## Placeholder System + +Templates use the `____placeholder____` pattern (4 underscores on each side) for variable substitution: + +```json +{ + "name": "@____username____/____moduleName____", + "version": "0.0.1", + "description": "____moduleDesc____", + "author": "____fullName____ <____email____>" +} +``` + +## Question Configuration + +| Field | Type | Description | +|-------|------|-------------| +| `name` | string | Placeholder name (e.g., `____fullName____`) | +| `message` | string | Prompt shown to user | +| `required` | boolean | Whether the field is required | +| `type` | string | Input type: `text`, `list`, `checkbox` | +| `options` | string[] | Static options for list/checkbox | +| `default` | any | Static default value | +| `defaultFrom` | string | Resolver for dynamic default | +| `setFrom` | string | Auto-set value (skips prompt) | +| `optionsFrom` | string | Resolver for dynamic options | + +### Resolvers + +**defaultFrom:** `git.user.name`, `git.user.email`, `npm.whoami`, `workspace.dirname` + +**setFrom:** `workspace.name`, `workspace.author.name`, `workspace.author.email`, `workspace.license`, `workspace.organization.name` + +**optionsFrom:** `licenses` (SPDX license identifiers) + +## Creating a Custom Repository + +1. Create a new repository with the structure above +2. Add `.boilerplates.json` pointing to your default directory +3. Create template directories with `.boilerplate.json` configs +4. Add template files with `____placeholder____` patterns +5. Use with `pgpm init --repo owner/your-boilerplates` + +## Best Practices + +1. Use `setFrom` for values that inherit from workspace context +2. Use `defaultFrom` for sensible defaults that users can override +3. Keep placeholder names descriptive and consistent +4. Test templates with `--no-tty` to ensure all required fields are defined diff --git a/.agents/skills/pgpm/references/testing.md b/.agents/skills/pgpm/references/testing.md new file mode 100644 index 0000000000..a80d977ff9 --- /dev/null +++ b/.agents/skills/pgpm/references/testing.md @@ -0,0 +1,292 @@ + +# PGPM Testing + +Run PostgreSQL integration tests with isolated databases using the `pgsql-test` package. + +## Testing Framework Standard + +**IMPORTANT**: Constructive projects use **Jest** as the standard testing framework. Do NOT use vitest, mocha, or other test runners unless explicitly approved. Jest provides: +- Consistent testing experience across all packages +- Built-in mocking and assertion libraries +- Snapshot testing support +- Parallel test execution + +## When to Apply + +Use this skill when: +- Writing integration tests that need a database +- Testing PGPM modules or migrations +- Setting up isolated test databases +- Seeding test data from SQL files or PGPM modules +- Running PostGraphile/GraphQL integration tests + +## Quick Start + +### Installation + +```bash +pnpm add -D pgsql-test +``` + +### Basic Test Setup + +```typescript +import { getConnections } from 'pgsql-test'; + +let db: any; +let teardown: () => Promise; + +beforeAll(async () => { + ({ db, teardown } = await getConnections()); +}); + +afterAll(() => teardown()); +beforeEach(() => db.beforeEach()); +afterEach(() => db.afterEach()); + +test('database query works', async () => { + const result = await db.query('SELECT 1 as num'); + expect(result.rows[0].num).toBe(1); +}); +``` + +## Core API + +### getConnections() + +Creates an isolated test database and returns clients plus cleanup function. + +```typescript +import { getConnections } from 'pgsql-test'; + +const { db, teardown } = await getConnections( + connectionOptions?, // Optional: custom connection settings + seedAdapters? // Optional: array of seed adapters +); +``` + +Returns: +- `db` - PgTestClient with query methods and transaction helpers +- `teardown` - Cleanup function to drop the test database + +### PgTestClient Methods + +| Method | Description | +|--------|-------------| +| `db.query(sql, params?)` | Execute SQL query | +| `db.beforeEach()` | Start savepoint (call in beforeEach) | +| `db.afterEach()` | Rollback to savepoint (call in afterEach) | +| `db.setContext(key, value)` | Set session context variable | +| `db.getPool()` | Get underlying pg Pool | + +## Seeding Data + +### SQL File Seeding + +```typescript +import { getConnections, seed } from 'pgsql-test'; + +const { db, teardown } = await getConnections({}, [ + seed.sqlfile(['./fixtures/schema.sql', './fixtures/data.sql']) +]); +``` + +### Function Seeding + +```typescript +import { getConnections, seed } from 'pgsql-test'; + +const { db, teardown } = await getConnections({}, [ + seed.fn(async (client) => { + await client.query(` + CREATE TABLE users ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL + ) + `); + await client.query(` + INSERT INTO users (name) VALUES ('Alice'), ('Bob') + `); + }) +]); +``` + +### PGPM Module Seeding + +Deploy a PGPM module into the test database: + +```typescript +import { getConnections, seed } from 'pgsql-test'; + +const { db, teardown } = await getConnections({}, [ + seed.pgpm(process.cwd()) // Deploy module from current directory +]); +``` + +### CSV Seeding + +```typescript +import { getConnections, seed } from 'pgsql-test'; + +const { db, teardown } = await getConnections({}, [ + seed.sqlfile(['./fixtures/schema.sql']), + seed.csv('users', './fixtures/users.csv') +]); +``` + +## Test Patterns + +### Transaction Isolation + +Each test runs in a savepoint that gets rolled back: + +```typescript +beforeEach(() => db.beforeEach()); // Creates savepoint +afterEach(() => db.afterEach()); // Rolls back to savepoint + +test('insert is isolated', async () => { + await db.query("INSERT INTO users (name) VALUES ('Test')"); + // This insert is rolled back after the test +}); + +test('previous insert not visible', async () => { + const result = await db.query("SELECT * FROM users WHERE name = 'Test'"); + expect(result.rows).toHaveLength(0); // Rolled back! +}); +``` + +### Setting User Context + +For RLS (Row Level Security) testing: + +```typescript +test('user can only see own data', async () => { + await db.setContext('user_id', 'user-123'); + + const result = await db.query('SELECT * FROM user_data'); + // Only returns rows where user_id = 'user-123' +}); +``` + +### Multiple Connections + +```typescript +const { db: adminDb, teardown: teardownAdmin } = await getConnections({ + user: 'postgres' +}); + +const { db: appDb, teardown: teardownApp } = await getConnections({ + user: 'app_user' +}); +``` + +## Running Tests + +### Prerequisites + +1. Start PostgreSQL: +```bash +pgpm docker start +``` + +2. Load environment: +```bash +eval "$(pgpm env)" +``` + +3. Run tests: +```bash +pnpm test +``` + +### One-liner + +```bash +pgpm env pnpm test +``` + +### Watch Mode + +```bash +pgpm env pnpm test --watch +``` + +## Common Workflows + +### Testing PGPM Module + +```typescript +import { getConnections, seed } from 'pgsql-test'; + +describe('my-module', () => { + let db: any, teardown: () => Promise; + + beforeAll(async () => { + ({ db, teardown } = await getConnections({}, [ + seed.pgpm(__dirname + '/..') // Deploy parent module + ])); + }); + + afterAll(() => teardown()); + beforeEach(() => db.beforeEach()); + afterEach(() => db.afterEach()); + + test('function works correctly', async () => { + const result = await db.query('SELECT my_function($1)', ['input']); + expect(result.rows[0].my_function).toBe('expected'); + }); +}); +``` + +### Testing with Fixtures + +```typescript +import { getConnections, seed } from 'pgsql-test'; +import path from 'path'; + +const fixtures = path.join(__dirname, '__fixtures__'); + +beforeAll(async () => { + ({ db, teardown } = await getConnections({}, [ + seed.sqlfile([ + path.join(fixtures, 'schema.sql'), + path.join(fixtures, 'seed-data.sql') + ]) + ])); +}); +``` + +## Troubleshooting + +| Issue | Solution | +|-------|----------| +| "Connection refused" | Run `pgpm docker start` first | +| "Database does not exist" | Check PGDATABASE env var or use `pgpm env` | +| Tests hang | Ensure `teardown()` is called in afterAll | +| Data leaking between tests | Add `beforeEach/afterEach` savepoint calls | +| Permission denied | Check database user has CREATE DATABASE permission | +| Slow tests | Use savepoints instead of recreating database per test | + +## File Structure + +Recommended test file organization: + +``` +my-module/ + __tests__/ + __fixtures__/ + schema.sql + seed-data.sql + my-feature.test.ts + deploy/ + revert/ + verify/ + pgpm.plan +``` + +## References + +For related skills: +- Docker container management: See `references/docker.md` +- Environment variables: See `references/env.md` +- GraphQL codegen: See `constructive-graphql-codegen` skill diff --git a/.agents/skills/pgpm/references/troubleshooting.md b/.agents/skills/pgpm/references/troubleshooting.md new file mode 100644 index 0000000000..ccc8534d11 --- /dev/null +++ b/.agents/skills/pgpm/references/troubleshooting.md @@ -0,0 +1,313 @@ + +# PGPM Troubleshooting + +Quick fixes for common pgpm, PostgreSQL, and testing issues. + +## When to Apply + +Use this skill when encountering: +- Connection errors to PostgreSQL +- Docker-related issues +- Environment variable problems +- Transaction aborted errors in tests +- Deployment failures + +## PostgreSQL Connection Issues + +### Docker Not Running + +**Symptom:** +```text +Cannot connect to the Docker daemon +``` + +**Solution:** +1. Start Docker Desktop +2. Wait for it to fully initialize +3. Then run pgpm commands + +### PostgreSQL Not Accepting Connections + +**Symptom:** +```text +psql: error: connection to server at "localhost" (127.0.0.1), port 5432 failed +``` + +**Solution:** +```bash +# Start PostgreSQL container +pgpm docker start + +# Load environment variables +eval "$(pgpm env)" + +# Verify connection +psql -c "SELECT version();" +``` + +### Wrong Port or Host + +**Symptom:** +```text +connection refused +``` + +**Solution:** +```bash +# Check current environment +echo $PGHOST $PGPORT + +# Reload environment +eval "$(pgpm env)" + +# Verify settings +pgpm env +``` + +## Environment Variable Issues + +### PGHOST Not Set + +**Symptom:** +```text +PGHOST not set +``` +or +```text +could not connect to server: No such file or directory +``` + +**Solution:** +```bash +# Load pgpm environment +eval "$(pgpm env)" +``` + +**Permanent fix** - add to shell config: +```bash +# Add to ~/.bashrc or ~/.zshrc +eval "$(pgpm env)" +``` + +### Environment Not Persisting + +**Symptom:** Environment variables reset after each command + +**Cause:** Running `eval $(pgpm env)` in a subshell or script + +**Solution:** Run in current shell: +```bash +# Correct - runs in current shell +eval "$(pgpm env)" + +# Wrong - runs in subshell +bash -c 'eval "$(pgpm env)"' +``` + +## Testing Issues + +### Tests Fail to Connect + +**Symptom:** Tests time out or fail with connection errors + +**Solution:** +```bash +# 1. Start PostgreSQL +pgpm docker start + +# 2. Load environment +eval "$(pgpm env)" + +# 3. Bootstrap users (run once) +pgpm admin-users bootstrap --yes + +# 4. Run tests +pnpm test +``` + +### Current Transaction Is Aborted + +**Symptom:** +```text +current transaction is aborted, commands ignored until end of transaction block +``` + +**Cause:** An error occurred in the transaction, and PostgreSQL marks the entire transaction as aborted. All subsequent queries fail until the transaction ends. + +**Solution:** Use savepoints when testing operations that should fail: + +```typescript +// Before the expected failure +const point = 'my_savepoint'; +await db.savepoint(point); + +// Operation that should fail +await expect( + db.query('INSERT INTO restricted_table ...') +).rejects.toThrow(/permission denied/); + +// After the failure - rollback to savepoint +await db.rollback(point); + +// Now you can continue using the connection +const result = await db.query('SELECT 1'); +``` + +**Pattern for multiple failures:** +```typescript +it('tests multiple failure scenarios', async () => { + // First failure + const point1 = 'first_failure'; + await db.savepoint(point1); + await expect(db.query('...')).rejects.toThrow(); + await db.rollback(point1); + + // Second failure + const point2 = 'second_failure'; + await db.savepoint(point2); + await expect(db.query('...')).rejects.toThrow(); + await db.rollback(point2); + + // Continue with passing assertions + const result = await db.query('SELECT 1'); + expect(result.rows[0]).toBeDefined(); +}); +``` + +### Tests Interfering with Each Other + +**Symptom:** Tests pass individually but fail when run together + +**Cause:** Missing or incorrect beforeEach/afterEach hooks + +**Solution:** +```typescript +beforeEach(async () => { + await pg.beforeEach(); + await db.beforeEach(); +}); + +afterEach(async () => { + await db.afterEach(); + await pg.afterEach(); +}); +``` + +## Deployment Issues + +### Module Not Found + +**Symptom:** +```text +Error: Module 'mymodule' not found +``` + +**Solution:** +1. Ensure you're in a pgpm workspace (has `pgpm.json`) +2. Check module is in `packages/` directory +3. Verify module has `.control` file + +### Dependency Not Found + +**Symptom:** +```text +Error: Change 'module:path/to/change' not found +``` + +**Solution:** +1. Check the referenced module exists +2. Verify the change path matches exactly +3. Check `-- requires:` comment syntax: + ```sql + -- requires: other_module:schemas/other/tables/table + ``` + +### Deploy Order Wrong + +**Symptom:** Foreign key or reference errors during deployment + +**Solution:** +1. Check `pgpm.plan` for correct order +2. Verify `-- requires:` comments in deploy files +3. Regenerate plan if needed: + ```bash + pgpm plan + ``` + +## Docker Issues + +### Container Won't Start + +**Symptom:** +```text +Error starting container +``` + +**Solution:** +```bash +# Stop any existing containers +pgpm docker stop + +# Remove old containers +docker rm -f pgpm-postgres + +# Start fresh +pgpm docker start +``` + +### Port Already in Use + +**Symptom:** +```text +port 5432 is already in use +``` + +**Solution:** +```bash +# Find what's using the port +lsof -i :5432 + +# Either stop that process or use a different port +# Edit docker-compose.yml to use different port +``` + +### Volume Permission Issues + +**Symptom:** +```text +Permission denied on volume mount +``` + +**Solution:** +```bash +# Remove old volumes +docker volume rm pgpm_data + +# Restart +pgpm docker start +``` + +## Quick Reference + +| Issue | Quick Fix | +|-------|-----------| +| Can't connect | `pgpm docker start && eval "$(pgpm env)"` | +| PGHOST not set | `eval "$(pgpm env)"` | +| Transaction aborted | Use savepoint pattern | +| Tests interfere | Check beforeEach/afterEach hooks | +| Module not found | Verify workspace structure | +| Port in use | `lsof -i :5432` then stop conflicting process | + +## Getting Help + +If issues persist: +1. Check pgpm version: `pgpm --version` +2. Check Docker status: `docker ps` +3. Check PostgreSQL logs: `docker logs pgpm-postgres` +4. Verify environment: `pgpm env` + +## References + +- Related reference: `references/docker.md` for Docker management +- Related reference: `references/env.md` for environment configuration +- Related skill: `pgsql-test-exceptions` for transaction handling diff --git a/.agents/skills/pgpm/references/workspace.md b/.agents/skills/pgpm/references/workspace.md new file mode 100644 index 0000000000..9b59de2749 --- /dev/null +++ b/.agents/skills/pgpm/references/workspace.md @@ -0,0 +1,188 @@ + +# PGPM Workspaces + +Create and manage pgpm workspaces for modular PostgreSQL development. Workspaces bring npm-style modularity to database development. + +## When to Apply + +Use this skill when: +- Starting a new modular database project +- Creating a pgpm workspace structure +- Initializing database modules +- Setting up a pnpm monorepo for database packages + +## Quick Start + +### Create a Workspace + +```bash +pgpm init workspace +``` + +Enter workspace name when prompted: +```sh +? Enter workspace name: my-database-project +``` + +This creates a complete pnpm monorepo: +```text +my-database-project/ +├── docker-compose.yml +├── pgpm.json +├── lerna.json +├── LICENSE +├── Makefile +├── package.json +├── packages/ +├── pnpm-workspace.yaml +├── README.md +└── tsconfig.json +``` + +### Install Dependencies + +```bash +cd my-database-project +pnpm install +``` + +### Create a Module + +Inside the workspace: +```bash +pgpm init +``` + +Enter module details: +```sh +? Enter module name: pets +? Select extensions: uuid-ossp, plpgsql +``` + +This creates: +```text +packages/pets/ +├── pets.control +├── pgpm.plan +├── deploy/ +├── revert/ +└── verify/ +``` + +## Workspace vs Module + +**Workspace**: Top-level directory containing your entire project. Has `pgpm.json` and `packages/` directory. Like an npm project root. + +**Module**: Self-contained database package inside the workspace. Has its own `pgpm.plan`, `.control` file, and migration directories. Like an individual npm package. + +## Key Files + +### pgpm.json (Workspace Config) + +```json +{ + "packages": ["packages/*"] +} +``` + +Points pgpm to your modules directory. + +### module.control (Module Metadata) + +```sh +# pets.control +comment = 'Pet adoption module' +default_version = '0.0.1' +requires = 'uuid-ossp,plpgsql' +``` + +Declares module name, description, version, and dependencies. + +### pgpm.plan (Migration Plan) + +```sh +%syntax-version=1.0.0 +%project=pets +%uri=pets + +schemas/pets 2025-11-14T00:00:00Z Author +schemas/pets/tables/pets [schemas/pets] 2025-11-14T00:00:00Z Author +``` + +Tracks all changes in deployment order. + +## Common Commands + +| Command | Description | +|---------|-------------| +| `pgpm init workspace` | Create new workspace | +| `pgpm init` | Create new module in workspace | +| `pgpm add ` | Add a database change | +| `pgpm deploy` | Deploy module to database | +| `pgpm verify` | Verify deployment | +| `pgpm revert` | Rollback changes | + +## Environment Setup + +Before deploying, ensure PostgreSQL is running and connection variables are loaded. + +> See `references/docker.md` for starting PostgreSQL and `references/env.md` for loading environment variables. + +```bash +# Verify connection +psql -c "SELECT version();" + +# Bootstrap database users (run once) +pgpm admin-users bootstrap --yes +``` + +## Deploy a Module + +```bash +cd packages/pets +pgpm deploy --database pets_dev --createdb --yes +``` + +pgpm: +1. Creates the database if needed +2. Resolves dependencies +3. Deploys changes in order +4. Tracks deployment in `pgpm_migrate` schema + +## Module Structure Best Practices + +Organize changes hierarchically: +```text +deploy/ +└── schemas/ + └── app/ + ├── schema.sql + ├── tables/ + │ └── users.sql + ├── functions/ + │ └── create_user.sql + └── triggers/ + └── updated_at.sql +``` + +Use nested paths: +```bash +pgpm add schemas/app/schema +pgpm add schemas/app/tables/users --requires schemas/app/schema +pgpm add schemas/app/functions/create_user --requires schemas/app/tables/users +``` + +## Troubleshooting + +| Issue | Solution | +|-------|----------| +| "Cannot connect to Docker" | Start Docker Desktop first | +| "PGHOST not set" | Load PG env vars (see `references/env.md`) | +| "Connection refused" | Ensure PostgreSQL is running (see `references/docker.md`) | +| Module not found | Ensure you're inside a workspace with `pgpm.json` | + +## References + +- Related reference: `references/docker.md` for Docker management +- Related reference: `references/env.md` for environment configuration +- Related reference: `references/changes.md` for authoring database changes diff --git a/AGENTS.md b/AGENTS.md index 81283511fa..77a2d30f2f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -137,6 +137,19 @@ afterEach(async () => { - **Never** skip `beforeEach`/`afterEach` hooks — tests will leak state to each other - **Never** construct connection strings manually — use the env configuration system +## Tooling Skills + +The `.agents/skills/` directory contains tooling-focused skills for this monorepo: + +| Skill | Description | When to Use | +|-------|-------------|-------------| +| `pgpm` | PostgreSQL Package Manager — migrations, CLI, Docker, CI/CD, project scaffolding, table creation rules, DB export | Database migrations, workspace/module creation, `pgpm init`, deploy/revert | +| `constructive-pnpm` | PNPM workspace management — monorepo config, dist-folder publishing with makage/lerna, dependency management | Configuring pnpm workspaces, publishing packages, managing monorepo dependencies | +| `constructive-setup` | Monorepo setup — install dependencies, start PostgreSQL, bootstrap users, build, run tests, local email services | Setting up the development environment, local dev, full pipeline | +| `constructive-testing` | All PostgreSQL testing frameworks — pgsql-test, drizzle-orm-test, supabase-test, test authoring, CI optimization | Writing database tests, testing RLS policies, choosing test presets, CI shard balancing | +| `constructive-cli` | Generated CLI commands — how the CLI is generated from GraphQL schemas, codegen options, multi-target CLI | Generating CLI tools, running generated CLI, understanding codegen pipeline | +| `graphile-search` | Unified PostGraphile v5 search plugin — tsvector, BM25, pg_trgm, pgvector adapters, composite searchScore | Adding search to GraphQL, configuring search adapters, querying search via SDK | + ## Tips 1. Start with `pgpm/core/AGENTS.md` to understand the migration and plan model. From b9a7947b4607034e25915f19d0ad9f50aca5702c Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Sun, 31 May 2026 23:33:22 +0000 Subject: [PATCH 2/4] fix: remove wrong-scope content from tooling skills - Remove constructive-db-specific refs from testing: ci-test-optimization, integration-testing, test-authoring (belong in constructive-db repo) - Remove generic drizzle-orm.md (490 lines of Drizzle docs, not Constructive-specific) - Remove pgsql-parser-testing.md (pgsql-parser repo has its own skills) - Remove closed-source refs from setup: full-pipeline.md, local-dev-setup.md, local-env.md (reference constructive-services/constructive-local packages) - Update SKILL.md files and AGENTS.md to remove dead references --- .agents/skills/constructive-setup.zip | Bin 8316 -> 3954 bytes .agents/skills/constructive-setup/SKILL.md | 8 - .../references/full-pipeline.md | 111 ---- .../references/local-dev-setup.md | 48 -- .../references/local-env.md | 108 ---- .agents/skills/constructive-testing.zip | Bin 43742 -> 32353 bytes .agents/skills/constructive-testing/SKILL.md | 16 +- .../references/ci-test-optimization.md | 97 ---- .../references/drizzle-orm.md | 490 ------------------ .../references/integration-testing.md | 107 ---- .../references/pgsql-parser-testing.md | 223 -------- .../references/test-authoring.md | 105 ---- AGENTS.md | 2 +- 13 files changed, 3 insertions(+), 1312 deletions(-) delete mode 100644 .agents/skills/constructive-setup/references/full-pipeline.md delete mode 100644 .agents/skills/constructive-setup/references/local-dev-setup.md delete mode 100644 .agents/skills/constructive-setup/references/local-env.md delete mode 100644 .agents/skills/constructive-testing/references/ci-test-optimization.md delete mode 100644 .agents/skills/constructive-testing/references/drizzle-orm.md delete mode 100644 .agents/skills/constructive-testing/references/integration-testing.md delete mode 100644 .agents/skills/constructive-testing/references/pgsql-parser-testing.md delete mode 100644 .agents/skills/constructive-testing/references/test-authoring.md diff --git a/.agents/skills/constructive-setup.zip b/.agents/skills/constructive-setup.zip index 17e5e2491b32a3b676bdc5240f33de62740ea3dc..a840704e9f1a85286c88179e13f35bbe75470e3d 100644 GIT binary patch delta 1908 zcmZvddpr}28pk(tnKYx>RCArOToP|Ar(8lJ*NKvA8ZmRsr6Hu5DdM$Zt}PO|RrU&J zVWe`25*xiFwW%cc-k3W%(|P|n=X0Lt^ZY)a=lT48&%fWzlM5=lvGySGU4WgMCS2}>bV@9AL%xJPnCCMHecAQtK~vtA`VSUF+RNZ?!U9|YizMm#l${1 zOPUvB-GJ-^y=1+ylfCgIw1HK~<3VhuJ;`Yy73n;W2R>g+p3w68Tzil#Z?7w`seZw> zS~|7jK`lShHPZEhEbPT?x^Un|D!ngEQb)%OzKqLkUo4T9ec@hS}fk%gT&TG zk8^IKQc~fAIjtqH;1wuVMh&>~7<){=e3&wI{SBlr{N+Jw8?<_pe)Lj+C0a+9OdmdF z@6XJgU2-2&jHrLiH~P$+opsc8G?an4ov)kZB0Ln!1|w~k&wJ@=;0UGsq|?nYKV>Zj z52*%pG%s4g)q~*R&<22x3`~y-Hdi5!P$yT8Fvo==wXNOf2@> zvuJfniQP2l(M??UUl}h@9QH$UR7=pBbDDx@6gxlyfUBmZf)?qq`T|fTbVV*-YpcV4 z86YDiZiWi%zSvj~$DxnEQwZ7h5%o{2aZiJ;iUUvX*M;bK6+9Q4Ik;C|n)#yIf87HI zQ8Nh{B`_~1Wj3%3dsfZO2c5g$)_$7!$n4@v|0s!tnK9GI+hK%&tfg=yMt zY-nr-0HcVlGYEK!5jXe1;T8^c#I$>5uN&Prirt7@`0Z}L`sECvY0GY zc3^YOaAY&fvsc*qcI7F5;log1=hkPT1@6a)X0UqLL%rVO>lw&mnSRa4E@Webum=X0 ziE*NLWFDyy&PgrhkO}7d{9e7sH0k}`t=|7+vm5A|_iiBD9$oUu9xo@$djtt~0J;#f zj$Q=Dfu3_2`xnjc#ZKA+PbZ!Bo;D?r!!_C0w5{-2CPa}( zAszqg^t>e=-SN1f*vY1E>wE2BYJyczQ2)%(8veHNi4$dODQJsx$|ViluEc0m$=HzE zRm*{>!$p&4$X~dphW1ov&xd*sZVozst88>DaEiTYy*KptG1z!?5|8CLjPSo0K#+1;zugIJoe|vVXeC8_d&&^W<*+rUd(S(jXqRNlEV;ZFHO?E(3_$83EC7W9%EMougq{C9!;FGUh=( z#p&jdxqf293qD7pz33x;;7ZQo2G61b^3KQ+BXo<_d0FD@%p~3QT#o%CLV@zqBO{e& z4XGlR`jpAfAf`huGjIL2D~)?aTVM(rptkw%(tmt*X~VTX9>(jjsT@ce*X%qahemg{ z4Rv&@)aEZ-h)eW26S2FQ8e7(OEK%PPFl-kln7SH0NDuPJTV0Sqcp=e3hdKxM&p#f9 zWC?PbXvEmdOpamf&uK)gxj?J0>nQ$@ef!B_e(#YVrzK{)Tx${ymbWL~R@;4-*%rG$ zh7~_fa%`9nMx#dH&~GW@OTV82p@0ryIi_?YXNtZwuN!WzjiA;Ss3Fo!9QTFU$)2BxAhv(44Cc5u>*%_kg@d9 z^B@%HLAYge75JK0IVT}9ZY@#3VJD?u)d3X4L3gyvkAW!|3CY-kOB(C5wxxb?N6tQN za9&c)DUoIUIe~){yA)@hURp9+%BWlk>9^uF+0~{8W;5l=aSKJ1$!Gi?5+w!Udeuvd z5MRB){|9Zj*0$QLGdF>X@9iS98Y;iPw%h}qV48E*=Tk#0e$_cyp4<0{C`=~O+U=a#)fc<#@wOAbzbo@^IJ-l+NfVXgV1w9mte z`)UbQrB$<(Rh_PPNf;NEg1qa-h`=V&IqCGeHUQQhD5e7X8*M-igQ9FK*51KTRZ#^0 z=eWvYyjQjo8{fGpi-|B+psK|1R5`@pLtzpU0MIY9vf6*c3;;j^_Wr^pP^9-Ms!|LC aHI&x>K^xXyLWDmN07YFWGPgr^wtoX1vV6Dz delta 6036 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 K?oRvnCI1HyJ}Gkm diff --git a/.agents/skills/constructive-setup/SKILL.md b/.agents/skills/constructive-setup/SKILL.md index 8a150517d2..f57d822e45 100644 --- a/.agents/skills/constructive-setup/SKILL.md +++ b/.agents/skills/constructive-setup/SKILL.md @@ -113,14 +113,6 @@ See [local-email-services.md](./references/local-email-services.md) for Docker C For full navigation, see the repo's `AGENTS.md`. -### Local Development (from constructive-db) - -| Reference | Topic | Consult When | -|-----------|-------|--------------| -| [references/local-dev-setup.md](references/local-dev-setup.md) | Quick-start local dev | Docker Postgres + pgpm deploy + GraphQL server startup | -| [references/local-env.md](references/local-env.md) | Full local environment | CLI testing, e2e tests, subdomain routing, troubleshooting | -| [references/full-pipeline.md](references/full-pipeline.md) | End-to-end pipeline | Docker → deploy → provision → codegen → verify | - ## Cross-References - **pgpm** skill — Database migrations, Docker, environment, CLI commands diff --git a/.agents/skills/constructive-setup/references/full-pipeline.md b/.agents/skills/constructive-setup/references/full-pipeline.md deleted file mode 100644 index eff1449420..0000000000 --- a/.agents/skills/constructive-setup/references/full-pipeline.md +++ /dev/null @@ -1,111 +0,0 @@ -# Constructive Full Pipeline - -Autonomous end-to-end workflow: Docker → deploy platform DB → provision user DB → generate SDK → verify. - -## Prerequisites - -- Docker running -- Node.js v22+ -- `pgpm` installed globally: `npm install -g pgpm` -- `pnpm` installed -- Both repos cloned: - - `constructive-io/constructive-db` (database + codegen) - - `constructive-io/constructive` (monorepo with GraphQL server + codegen CLI) - -## Phase 1: Start PostgreSQL - -```bash -pgpm docker start --image docker.io/constructiveio/postgres-plus:18 --recreate -eval "$(pgpm env)" -pgpm admin-users bootstrap --yes -pgpm admin-users add --test --yes -``` - -**Verify:** `psql -c "SELECT 1"` returns successfully. - -## Phase 2: Deploy Platform Database - -```bash -cd -pnpm install -dropdb --if-exists constructive -pgpm deploy --database constructive --createdb --yes --package constructive-services -pgpm deploy --database constructive --yes --package constructive-local -``` - -**Verify:** `psql -d constructive -c "SELECT count(*) FROM metaschema_public.database"` returns without error. - -## Phase 3: Start GraphQL Server - -```bash -cd /graphql/server -PGDATABASE=constructive pnpm dev -``` - -**Verify:** `curl -s -o /dev/null -w "%{http_code}" http://api.localhost:3000/graphql` returns `405`. - -### Endpoint Reference - -| Endpoint | Purpose | -|---|---| -| `http://auth.localhost:3000/graphql` | Main auth | -| `http://api.localhost:3000/graphql` | Main public API | -| `http://auth-.localhost:3000/graphql` | Per-DB auth | -| `http://app-public-.localhost:3000/graphql` | Per-DB app API | -| `http://admin-.localhost:3000/graphql` | Per-DB admin | - -## Phase 4: Provision a User Database (via SDK) - -See `constructive-sdk` skill for the full auth + provisioning flow. - -```typescript -import { createClient as createAuthClient } from '@constructive-db/sdk/auth'; -import { createClient as createPublicClient } from '@constructive-db/sdk/public'; - -// 1. Sign up + sign in -const authDb = createAuthClient({ endpoint: 'http://auth.localhost:3000/graphql' }); -await authDb.mutation.signUp({ input: { email, password } }, { select: { ok: true } }).execute(); -const result = await authDb.mutation.signIn( - { input: { email, password } }, - { select: { result: { select: { accessToken: true, userId: true } } } } -).execute(); -const { accessToken, userId } = result.signIn.result; - -// 2. Provision database with all modules -const publicDb = createPublicClient({ - endpoint: 'http://api.localhost:3000/graphql', - headers: { Authorization: `Bearer ${accessToken}` }, -}); -await publicDb.databaseProvisionModule.create({ - data: { - databaseName: dbName, ownerId: userId, subdomain: dbName, domain: 'localhost', - modules: ['all'], bootstrapUser: true, - }, - select: { id: true, databaseId: true, status: true }, -}).execute(); -``` - -## Phase 5: Regenerate SDK (Codegen) - -```bash -cd -pnpm run generate:all -``` - -| Command | What it runs | -|---|---| -| `generate:constructive-all` | `generate:constructive` + `generate:schemas` | -| `generate:schemas-all` | `generate:schemas` + `generate:sdk` + `generate:sdk-new` + `generate:cli` | -| `generate:all` | Everything in correct order | - -## Phase 6: Verify End-to-End - -1. **Tests pass:** `cd && pnpm test` -2. **SDK types compile:** `cd sdk/constructive-sdk && pnpm tsc --noEmit` -3. **GraphQL server responds:** `curl -s http://api.localhost:3000/graphql -H "Content-Type: application/json" -d '{"query":"{ __typename }"}' | jq .` - -## Related Skills - -- `constructive-sdk` — Auth flow, database provisioning, secure tables -- `constructive-sdk-database` — Database CRUD, modules, lifecycle -- `pgpm` — Docker, migrations, testing diff --git a/.agents/skills/constructive-setup/references/local-dev-setup.md b/.agents/skills/constructive-setup/references/local-dev-setup.md deleted file mode 100644 index 8398460980..0000000000 --- a/.agents/skills/constructive-setup/references/local-dev-setup.md +++ /dev/null @@ -1,48 +0,0 @@ -# Local Dev Setup - -Quick-start for running the Constructive GraphQL server locally with Docker Postgres and pgpm. - -## Prerequisites - -- Docker (for Postgres) -- Node.js v22+ -- pgpm: `npm install -g pgpm` - -## Start - -```bash -eval "$(pgpm env)" -pgpm docker start -``` - -## Deploy Platform DB - -```bash -cd path/to/constructive-db -dropdb --if-exists constructive -pgpm deploy --database constructive --createdb --yes --package constructive-services -pgpm deploy --database constructive --yes --package constructive-local -``` - -## Start GraphQL Server - -```bash -cd path/to/constructive/graphql/server -PGDATABASE=constructive pnpm dev -``` - -Health check: `curl -s -o /dev/null -w "%{http_code}" http://api.localhost:3000/graphql` → 405 - -## Endpoint Reference - -| Endpoint | Purpose | -|---|---| -| `http://auth.localhost:3000/graphql` | Main auth | -| `http://api.localhost:3000/graphql` | Main public API | -| `http://auth-.localhost:3000/graphql` | Per-DB auth | -| `http://app-public-.localhost:3000/graphql` | Per-DB app API | -| `http://admin-.localhost:3000/graphql` | Per-DB admin | - -## Related - -- `constructive-sdk` skill — provision a database after setup diff --git a/.agents/skills/constructive-setup/references/local-env.md b/.agents/skills/constructive-setup/references/local-env.md deleted file mode 100644 index d7519f8fa2..0000000000 --- a/.agents/skills/constructive-setup/references/local-env.md +++ /dev/null @@ -1,108 +0,0 @@ -# Constructive Local Environment Setup - -Set up a fully working local Constructive environment with PostgreSQL, the constructive-local database package, and the GraphQL server. Enables testing the generated CLI, running e2e tests, and developing against a real GraphQL API. - -## When to Apply - -Use this reference when: -- Setting up a local development environment for Constructive -- Testing the generated CLI (`constructive-cli`) -- Running the e2e test script (`test-cli-e2e.sh`) -- Needing a running GraphQL server for development -- Deploying `constructive-local` to a local PostgreSQL instance - -## Prerequisites - -- Docker installed and running -- Node.js 22+ -- pnpm installed -- Access to `constructive-io/constructive-db` and `constructive-io/constructive` repos - -## Step-by-Step Setup - -### 1. Install pgpm globally - -```bash -npm install -g pgpm -``` - -### 2. Start PostgreSQL via pgpm Docker - -```bash -pgpm docker start -``` - -Uses `docker.io/constructiveio/postgres-plus:18` container (includes PostGIS, pgvector, and other required extensions). PostgreSQL 17+ is required because `constructive-local` uses `security_invoker` on views. - -### 3. Set environment variables - -```bash -eval "$(pgpm env)" -``` - -Sets `PGHOST`, `PGPORT`, `PGUSER`, `PGPASSWORD`, and `PGDATABASE` for the local PostgreSQL instance. - -**Important**: Run this as a separate command (not chained with `&&`) so the env vars are available for subsequent commands. - -### 4. Deploy constructive-local - -```bash -cd /path/to/constructive-db/packages/constructive-local -pgpm deploy -``` - -Deploys the full Constructive database schema (tables, functions, triggers, RLS policies). Takes about 30-60 seconds. - -### 5. Install dependencies and start server - -```bash -cd /path/to/constructive -pnpm install -pnpm dev -``` - -The server starts at `http://localhost:5555` with subdomain-based routing: - -| Target | Endpoint | -|--------|----------| -| Public | `http://api.localhost:5555/graphql` | -| Auth | `http://auth.localhost:5555/graphql` | -| Objects | `http://objects.localhost:5555/graphql` | -| Admin | `http://admin.localhost:5555/graphql` | - -### 6. Test the CLI - -```bash -cd /path/to/constructive-db/sdk/constructive-cli -pnpm install -npx tsx cli/index.ts --help -``` - -#### Quick smoke test - -```bash -CLI="npx tsx cli/index.ts" -$CLI context create local --endpoint http://api.localhost:5555/graphql -$CLI context use local -$CLI public:sign-up --input '{"email":"test@example.com","password":"testpass123"}' -$CLI credentials set-token "" -$CLI public:database list -``` - -## Environment Variables - -| Variable | Default | Description | -|----------|---------|-------------| -| `BASE_HOST` | `localhost` | Server hostname | -| `BASE_PORT` | `5555` | Server port | -| `CLI_EMAIL` | auto-generated | Test user email | -| `CLI_PASSWORD` | `testpass123` | Test user password | - -## Troubleshooting - -| Issue | Fix | -|-------|-----| -| "security_invoker" error | Need PostgreSQL 17+. Use `pgpm docker start --image docker.io/constructiveio/postgres-plus:18` | -| "OrmClientConfig requires endpoint" | Set CLI context: `context create local --endpoint ...` | -| "permission denied for schema" | Token not set or expired. Re-authenticate and `credentials set-token` | -| Server not responding | Verify with `curl -s http://api.localhost:5555/graphql -H 'Content-Type: application/json' -d '{"query":"{ __typename }"}'` | diff --git a/.agents/skills/constructive-testing.zip b/.agents/skills/constructive-testing.zip index d8a34d06591825048b554daf920df3147f884a2f..9003413835a1007a5a13a6530dad84e0eaf46b46 100644 GIT binary patch delta 2848 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 diff --git a/.agents/skills/constructive-testing/SKILL.md b/.agents/skills/constructive-testing/SKILL.md index 07cc045990..68f7cabbe0 100644 --- a/.agents/skills/constructive-testing/SKILL.md +++ b/.agents/skills/constructive-testing/SKILL.md @@ -1,7 +1,8 @@ --- name: constructive-testing -description: "All PostgreSQL and database testing frameworks — pgsql-test (RLS, seeding, snapshots, JWT context, scenario setup), drizzle-orm-test (type-safe Drizzle testing), supabase-test (Supabase RLS testing), drizzle-orm (schema patterns), and pgsql-parser testing. Use when writing database tests, testing RLS policies, seeding test data, or testing with any Constructive test framework." +description: "PostgreSQL testing frameworks — pgsql-test (RLS, seeding, snapshots, JWT context, scenario setup), drizzle-orm-test (type-safe Drizzle testing), supabase-test (Supabase RLS testing). Use when writing database tests, testing RLS policies, seeding test data, or testing with any Constructive test framework." compatibility: pgsql-test, drizzle-orm-test, supabase-test, Jest/Vitest, PostgreSQL + triggers: "user, model" metadata: author: constructive-io version: "2.0.0" @@ -18,8 +19,6 @@ Use this skill when: - Testing RLS policies, permissions, multi-tenant security - Seeding test data (fixtures, JSON, SQL, CSV) - Testing with Drizzle ORM or Supabase -- Working in the pgsql-parser repository -- Choosing which test framework to use ## Which Framework to Use @@ -32,7 +31,6 @@ Use this skill when: | HTTP endpoints, auth headers, middleware | `@constructive-io/graphql-server-test` | (part of constructive monorepo) | | Type-safe Drizzle ORM tests | `drizzle-orm-test` | [drizzle-orm-test.md](./references/drizzle-orm-test.md) | | Supabase applications, auth.users | `supabase-test` | [supabase-test.md](./references/supabase-test.md) | -| pgsql-parser repo specifically | pgsql-parser workflow | [pgsql-parser-testing.md](./references/pgsql-parser-testing.md) | ## Quick Start (pgsql-test) @@ -100,17 +98,7 @@ Choose the **highest-level framework** that fits your test scenario: | Reference | Topic | Consult When | |-----------|-------|--------------| | [drizzle-orm-test.md](./references/drizzle-orm-test.md) | Drizzle ORM testing | Type-safe database tests with Drizzle | -| [drizzle-orm.md](./references/drizzle-orm.md) | Drizzle ORM schema patterns | Schema design, query building with Drizzle | | [supabase-test.md](./references/supabase-test.md) | Supabase testing | Testing Supabase apps, auth.users, anon/authenticated roles | -| [pgsql-parser-testing.md](./references/pgsql-parser-testing.md) | pgsql-parser repo testing | SQL parser/deparser tests, round-trip validation | - -### Test Authoring & CI (from constructive-db) - -| Reference | Topic | Consult When | -|-----------|-------|--------------| -| [references/test-authoring.md](references/test-authoring.md) | Writing lean, readable tests | Choosing presets, test file structure, utility usage | -| [references/ci-test-optimization.md](references/ci-test-optimization.md) | CI/CD speed optimization | Shard balancing, test compression, runner sizing | -| [references/integration-testing.md](references/integration-testing.md) | SQL-first integration tests | pg/db client pattern, RLS testing, multi-actor design | ## Cross-References diff --git a/.agents/skills/constructive-testing/references/ci-test-optimization.md b/.agents/skills/constructive-testing/references/ci-test-optimization.md deleted file mode 100644 index 0d0b44dbd4..0000000000 --- a/.agents/skills/constructive-testing/references/ci-test-optimization.md +++ /dev/null @@ -1,97 +0,0 @@ -# CI/CD & Test Optimization - -## Philosophy - -Test optimization has two complementary axes: -1. **Test compression** — reduce boilerplate so redundancy becomes visible, then eliminate redundancy -2. **CI/CD speed** — minimize wall-clock time through sharding, runner sizing, and provisioning efficiency - -## Phase 1: Pattern Discovery & Utility Abstraction - -### Finding Repeated Patterns - -1. **Grep for raw SQL in test files** — any `SELECT`, `INSERT`, `UPDATE` outside of `test-helpers.ts` is a candidate -2. **Cluster by shape** — group queries by what they do, not by exact SQL -3. **Count occurrences** — patterns appearing 3+ times are high-value abstractions -4. **Check if utility already exists** — scan `test-helpers.ts` first - -### What Makes a Good Test Utility - -Worth creating when: -- Pattern appears in **3+ files** (or 5+ times in one file) -- Involves **multiple steps** that always occur together -- Raw code **obscures test intent** -- Replaces **copy-paste-prone code** - -NOT worth creating when: -- Would **hide `setContext` calls** — these must remain visible -- It's a **one-liner** that's already clear -- Would require **many parameters** that vary across call sites - -### Choosing `db` vs `_dangerouslyBypassRLS` - -- **Use `this.db`** when: the operation is something an application user would do -- **Use `this._dangerouslyBypassRLS`** when: the operation is test setup that bypasses security -- **Rule of thumb:** if the original test code used `pg` (superuser), use `_dangerouslyBypassRLS` - -## Phase 2: Test Compression - -1. Read the file top-to-bottom, noting existing utilities and raw SQL patterns -2. Extract file-level constants for repeated values -3. Replace multi-step sequences with utility calls -4. Keep `setContext` visible -5. Verify semantic equivalence - -## Phase 3: Test Merging & Deletion - -### Merge Candidates - -1. **Same preset** — files can only merge if they use the same `provisionTestDatabase` preset -2. **Same directory** — merge within the same shard regex pattern -3. **Small files** — prioritize files with <5 test cases or <100 lines - -### Merge Benefit - -Each merged file saves ~50-60s of `provisionTestDatabase` time + ~5-10s overhead. - -## Phase 4: CI/CD Speed Optimization - -### Shard Balancing - -**Target:** All shards within 20% of each other. Bottleneck shard < 10 minutes. - -**Split a shard when:** -- Wall-clock exceeds 10 minutes -- Shard has >12 test files -- A single test file takes >120s - -**Merge shards when:** -- A shard has <3 test files -- Total test time < 60s (overhead dominates) - -### Runner Sizing - -Tests are I/O-bound (waiting on PostgreSQL). 8vCPU recommended — diminishing returns past that. - -### Shard Configuration - -Edit `.github/workflows/run-tests.yaml` matrix. The `test_pattern` is a regex matched against test file paths: - -```yaml -- package: packages/metaschema - test_pattern: 'auth/(identity-sign-in|sessions|api-keys)' - shard_name: 'metaschema-auth-1' -``` - -## Database Provisioning Rules - -- Only ONE `provisionTestDatabase` call in `beforeAll` per file -- `beforeEach`/`afterEach` must only do savepoint/rollback -- Choose the **lightest preset** that covers the test's needs - -## Monitoring Checklist - -- [ ] Bottleneck shard < 10 minutes -- [ ] All shards within 30% of each other -- [ ] No `provisionTestDatabase` calls outside `beforeAll` -- [ ] No single-test files that could merge into a neighbor diff --git a/.agents/skills/constructive-testing/references/drizzle-orm.md b/.agents/skills/constructive-testing/references/drizzle-orm.md deleted file mode 100644 index 3e87a3940d..0000000000 --- a/.agents/skills/constructive-testing/references/drizzle-orm.md +++ /dev/null @@ -1,490 +0,0 @@ ---- -name: drizzle-orm -description: Drizzle ORM patterns for PostgreSQL schema design and queries. Use when asked to "design Drizzle schema", "write Drizzle queries", "set up Drizzle ORM", or when building type-safe database layers. -compatibility: drizzle-orm, drizzle-kit, PostgreSQL, TypeScript -metadata: - author: constructive-io - version: "1.0.0" ---- - -# Drizzle ORM Patterns - -Design PostgreSQL schemas and write type-safe queries with Drizzle ORM. This skill covers schema design patterns, query building, and integration with the Constructive ecosystem. - -## When to Apply - -Use this skill when: -- Designing database schemas with Drizzle -- Writing type-safe database queries -- Setting up Drizzle ORM in a project -- Integrating Drizzle with pgsql-test or drizzle-orm-test - -## Installation - -```bash -pnpm add drizzle-orm -pnpm add -D drizzle-kit -``` - -## Schema Design - -### Basic Table Definition - -```typescript -import { pgTable, uuid, text, timestamp, boolean, integer } from 'drizzle-orm/pg-core'; - -export const users = pgTable('users', { - id: uuid('id').primaryKey().defaultRandom(), - email: text('email').notNull().unique(), - name: text('name'), - isActive: boolean('is_active').default(true), - createdAt: timestamp('created_at').defaultNow(), - updatedAt: timestamp('updated_at').defaultNow() -}); -``` - -### Foreign Key Relations - -```typescript -import { pgTable, uuid, text, timestamp } from 'drizzle-orm/pg-core'; - -export const users = pgTable('users', { - id: uuid('id').primaryKey().defaultRandom(), - email: text('email').notNull().unique() -}); - -export const posts = pgTable('posts', { - id: uuid('id').primaryKey().defaultRandom(), - title: text('title').notNull(), - content: text('content'), - authorId: uuid('author_id').references(() => users.id).notNull(), - createdAt: timestamp('created_at').defaultNow() -}); - -export const comments = pgTable('comments', { - id: uuid('id').primaryKey().defaultRandom(), - content: text('content').notNull(), - postId: uuid('post_id').references(() => posts.id).notNull(), - authorId: uuid('author_id').references(() => users.id).notNull() -}); -``` - -### Indexes - -```typescript -import { pgTable, uuid, text, index, uniqueIndex } from 'drizzle-orm/pg-core'; - -export const users = pgTable('users', { - id: uuid('id').primaryKey().defaultRandom(), - email: text('email').notNull(), - organizationId: uuid('organization_id').notNull() -}, (table) => [ - uniqueIndex('users_email_idx').on(table.email), - index('users_org_idx').on(table.organizationId) -]); -``` - -### Composite Primary Keys - -```typescript -import { pgTable, uuid, primaryKey } from 'drizzle-orm/pg-core'; - -export const userRoles = pgTable('user_roles', { - userId: uuid('user_id').references(() => users.id).notNull(), - roleId: uuid('role_id').references(() => roles.id).notNull() -}, (table) => [ - primaryKey({ columns: [table.userId, table.roleId] }) -]); -``` - -### Enums - -```typescript -import { pgTable, uuid, pgEnum } from 'drizzle-orm/pg-core'; - -export const statusEnum = pgEnum('status', ['pending', 'active', 'archived']); - -export const projects = pgTable('projects', { - id: uuid('id').primaryKey().defaultRandom(), - name: text('name').notNull(), - status: statusEnum('status').default('pending') -}); -``` - -### JSON Columns - -```typescript -import { pgTable, uuid, jsonb } from 'drizzle-orm/pg-core'; - -export const settings = pgTable('settings', { - id: uuid('id').primaryKey().defaultRandom(), - userId: uuid('user_id').references(() => users.id).notNull(), - preferences: jsonb('preferences').$type<{ - theme: 'light' | 'dark'; - notifications: boolean; - }>() -}); -``` - -## Query Patterns - -### Setup Client - -```typescript -import { drizzle } from 'drizzle-orm/node-postgres'; -import { Pool } from 'pg'; -import * as schema from './schema'; - -const pool = new Pool({ - connectionString: process.env.DATABASE_URL -}); - -export const db = drizzle(pool, { schema }); -``` - -### Select Queries - -```typescript -import { eq, and, or, like, gt, lt, isNull, inArray } from 'drizzle-orm'; -import { users, posts } from './schema'; - -// Select all -const allUsers = await db.select().from(users); - -// Select with where -const activeUsers = await db - .select() - .from(users) - .where(eq(users.isActive, true)); - -// Select specific columns -const userEmails = await db - .select({ email: users.email, name: users.name }) - .from(users); - -// Multiple conditions -const filteredUsers = await db - .select() - .from(users) - .where(and( - eq(users.isActive, true), - like(users.email, '%@example.com') - )); - -// OR conditions -const result = await db - .select() - .from(users) - .where(or( - eq(users.name, 'Alice'), - eq(users.name, 'Bob') - )); - -// IN clause -const specificUsers = await db - .select() - .from(users) - .where(inArray(users.id, ['id1', 'id2', 'id3'])); - -// NULL checks -const usersWithoutName = await db - .select() - .from(users) - .where(isNull(users.name)); -``` - -### Insert Queries - -```typescript -// Single insert -const [newUser] = await db - .insert(users) - .values({ - email: 'alice@example.com', - name: 'Alice' - }) - .returning(); - -// Multiple insert -const newUsers = await db - .insert(users) - .values([ - { email: 'alice@example.com', name: 'Alice' }, - { email: 'bob@example.com', name: 'Bob' } - ]) - .returning(); - -// Insert with conflict handling -await db - .insert(users) - .values({ email: 'alice@example.com', name: 'Alice' }) - .onConflictDoNothing(); - -// Upsert -await db - .insert(users) - .values({ email: 'alice@example.com', name: 'Alice' }) - .onConflictDoUpdate({ - target: users.email, - set: { name: 'Alice Updated' } - }); -``` - -### Update Queries - -```typescript -// Update with where -const [updated] = await db - .update(users) - .set({ name: 'Alice Smith' }) - .where(eq(users.id, userId)) - .returning(); - -// Update multiple fields -await db - .update(users) - .set({ - name: 'Alice Smith', - updatedAt: new Date() - }) - .where(eq(users.id, userId)); -``` - -### Delete Queries - -```typescript -// Delete with where -await db - .delete(users) - .where(eq(users.id, userId)); - -// Delete with returning -const [deleted] = await db - .delete(users) - .where(eq(users.id, userId)) - .returning(); -``` - -### Joins - -```typescript -// Inner join -const postsWithAuthors = await db - .select({ - postTitle: posts.title, - authorName: users.name - }) - .from(posts) - .innerJoin(users, eq(posts.authorId, users.id)); - -// Left join -const usersWithPosts = await db - .select({ - userName: users.name, - postTitle: posts.title - }) - .from(users) - .leftJoin(posts, eq(users.id, posts.authorId)); -``` - -### Relational Queries - -With schema relations defined: - -```typescript -import { relations } from 'drizzle-orm'; - -export const usersRelations = relations(users, ({ many }) => ({ - posts: many(posts) -})); - -export const postsRelations = relations(posts, ({ one, many }) => ({ - author: one(users, { - fields: [posts.authorId], - references: [users.id] - }), - comments: many(comments) -})); -``` - -Query with relations: - -```typescript -// Fetch users with their posts -const usersWithPosts = await db.query.users.findMany({ - with: { - posts: true - } -}); - -// Nested relations -const usersWithPostsAndComments = await db.query.users.findMany({ - with: { - posts: { - with: { - comments: true - } - } - } -}); - -// Selective columns with relations -const result = await db.query.users.findMany({ - columns: { - id: true, - name: true - }, - with: { - posts: { - columns: { - title: true - } - } - } -}); -``` - -### Aggregations - -```typescript -import { count, sum, avg, max, min } from 'drizzle-orm'; - -// Count -const [{ total }] = await db - .select({ total: count() }) - .from(users); - -// Count with condition -const [{ activeCount }] = await db - .select({ activeCount: count() }) - .from(users) - .where(eq(users.isActive, true)); - -// Group by -const postCounts = await db - .select({ - authorId: posts.authorId, - postCount: count() - }) - .from(posts) - .groupBy(posts.authorId); -``` - -### Ordering and Pagination - -```typescript -import { desc, asc } from 'drizzle-orm'; - -// Order by -const sortedUsers = await db - .select() - .from(users) - .orderBy(desc(users.createdAt)); - -// Multiple order columns -const sorted = await db - .select() - .from(users) - .orderBy(asc(users.name), desc(users.createdAt)); - -// Pagination -const page = await db - .select() - .from(users) - .limit(10) - .offset(20); -``` - -### Transactions - -```typescript -await db.transaction(async (tx) => { - const [user] = await tx - .insert(users) - .values({ email: 'alice@example.com' }) - .returning(); - - await tx - .insert(posts) - .values({ - title: 'First Post', - authorId: user.id - }); -}); -``` - -## Integration with pgsql-test - -```typescript -import { getConnections, PgTestClient } from 'drizzle-orm-test'; -import { drizzle } from 'drizzle-orm/node-postgres'; -import * as schema from './schema'; - -let pg: PgTestClient; -let db: ReturnType; -let teardown: () => Promise; - -beforeAll(async () => { - ({ pg, teardown } = await getConnections()); - db = drizzle(pg.client, { schema }); -}); - -afterAll(async () => { - await teardown(); -}); - -beforeEach(async () => { - await pg.beforeEach(); -}); - -afterEach(async () => { - await pg.afterEach(); -}); - -it('creates a user', async () => { - const [user] = await db - .insert(schema.users) - .values({ email: 'test@example.com' }) - .returning(); - - expect(user.email).toBe('test@example.com'); -}); -``` - -## Schema Organization - -For larger projects, organize schemas by domain: - -``` -src/ - db/ - schema/ - index.ts # Re-exports all schemas - users.ts # User-related tables - posts.ts # Post-related tables - relations.ts # All relations - client.ts # Drizzle client setup -``` - -```typescript -// src/db/schema/index.ts -export * from './users'; -export * from './posts'; -export * from './relations'; -``` - -## Best Practices - -1. **Use UUID primary keys**: `uuid('id').primaryKey().defaultRandom()` -2. **Add timestamps**: Include `createdAt` and `updatedAt` on most tables -3. **Define relations**: Enable relational queries with `relations()` -4. **Type JSON columns**: Use `.$type()` for type-safe JSON -5. **Index foreign keys**: Add indexes on frequently queried foreign keys -6. **Use transactions**: Wrap related operations in transactions -7. **Return inserted/updated rows**: Use `.returning()` to get results - -## References - -- Related skill: `drizzle-orm-test` for testing with Drizzle -- Related skill: `pgsql-test-snapshot` for snapshot testing -- Related skill: `pgsql-test-rls` for RLS testing with Drizzle diff --git a/.agents/skills/constructive-testing/references/integration-testing.md b/.agents/skills/constructive-testing/references/integration-testing.md deleted file mode 100644 index 68658a9302..0000000000 --- a/.agents/skills/constructive-testing/references/integration-testing.md +++ /dev/null @@ -1,107 +0,0 @@ -# Integration Testing in constructive-db - -## The Two Clients: `pg` vs `db` - -Every integration test gets two database clients from `pgsql-test`: - -```typescript -import { getConnections, PgTestClient } from 'pgsql-test'; - -let db: PgTestClient; // RLS-enforced (authenticated role) -let pg: PgTestClient; // Superuser (bypasses RLS) -let teardown: () => Promise; - -beforeAll(async () => { - ({ db, pg, teardown } = await getConnections()); -}); -afterAll(() => teardown()); -``` - -### The Cardinal Rule - -> **`pg` is ONLY used in `beforeAll` for bootstrap/DDL/catalog queries. `db` is used for ALL data operations and test queries.** - -- `pg` (superuser) bypasses RLS entirely. Use it **only** for: creating test users, provisioning databases, DDL operations, and read-only catalog queries. -- `db` (authenticated or administrator role) enforces triggers and FK constraints. Use it for all data operations. -- Never use `pg` inside `it()` blocks for queries you're testing. - -### The Three Roles - -| Role | Client | Bypasses RLS? | When to use | -|------|--------|---------------|-------------| -| **superuser** | `pg` | Yes | Bootstrap only: `createTestUser`, `provisionDatabase`, DDL | -| **administrator** | `db` with `setContext({ role: 'administrator' })` | Effectively yes | Elevated data operations: adding members, creating buckets | -| **authenticated** | `db` with `setContext({ role: 'authenticated', ... })` | No (full RLS) | All test queries — this is what real users get | - -### Cross-Connection Visibility - -> **Data written by `db` inside a per-test savepoint is invisible to `pg` (separate connection).** - -```typescript -// WRONG — pg can't see db's savepoint data -const row = await pg.one(`SELECT ... WHERE actor_id = $1`, [user.user_id]); - -// CORRECT — use db with administrator role -db.setContext({ role: 'administrator' }); -const row = await db.one(`SELECT ... WHERE actor_id = $1`, [user.user_id]); -``` - -### Cross-Connection Deadlock: NEVER mix `pg` and `db` for the same rows - -If a test body needs to seed data AND act on it, ALL operations must go through `db`: - -```typescript -// WRONG — deadlocks: pg holds lock, db blocks waiting -it('test', async () => { - await limits.insert({ ... }); // pg - db.setContext({ role: 'authenticated', ... }); - const ok = await limits_as_user.increment(...); // db → DEADLOCK -}); - -// CORRECT — single connection -it('test', async () => { - db.setContext({ role: 'administrator' }); - await limits_as_user.insert({ ... }); // db - db.setContext({ role: 'authenticated', ... }); - const ok = await limits_as_user.increment(...); // db → works -}); -``` - -**When is `pg` safe?** Only in `beforeAll`, where there are no savepoints yet. - -## Test File Structure - -```typescript -jest.setTimeout(300000); -process.env.LOG_SCOPE = 'pgsql-test'; - -import { getConnections, PgTestClient } from 'pgsql-test'; - -const ALICE_ID = '00000000-0000-0000-0000-00000000a001'; -const BOB_ID = '00000000-0000-0000-0000-00000000b002'; - -let db: PgTestClient; -let pg: PgTestClient; -let teardown: () => Promise; - -beforeAll(async () => { - ({ db, pg, teardown } = await getConnections()); - // 1. Bootstrap — pg (superuser, auto-commits) - // 2. Set membership defaults — pg - // 3. Resolve schema/table names — pg (read-only) - // 4. Add members — db as administrator (triggers populate SPRT) -}); - -afterAll(() => teardown()); -beforeEach(async () => { await db.beforeEach(); }); -afterEach(async () => { await db.afterEach(); }); -``` - -## Setting Actor Context - -```typescript -db.setContext({ role: 'authenticated', 'jwt.claims.user_id': ALICE_ID }); -const rows = await db.any(`SELECT * FROM "${schema}"."${table}" WHERE owner_id = $1`, [ALICE_ID]); -``` - -Some tests also use `db.auth({ userId: ALICE_ID })` as a shorthand. diff --git a/.agents/skills/constructive-testing/references/pgsql-parser-testing.md b/.agents/skills/constructive-testing/references/pgsql-parser-testing.md deleted file mode 100644 index d973f61050..0000000000 --- a/.agents/skills/constructive-testing/references/pgsql-parser-testing.md +++ /dev/null @@ -1,223 +0,0 @@ ---- -name: pgsql-parser-testing -description: Test the pgsql-parser repository (SQL parser/deparser). Use when working in the pgsql-parser repo, fixing deparser issues, running parser tests, or validating SQL round-trips. Scoped specifically to the constructive-io/pgsql-parser repository. -compatibility: Node.js 18+, pnpm, pgsql-parser repository -metadata: - author: constructive-io - version: "1.0.0" - scope: constructive-io/pgsql-parser ---- - -# PGSQL Parser Testing - -Testing workflow for the pgsql-parser repository. This skill is scoped specifically to the `constructive-io/pgsql-parser` monorepo. - -## When to Apply - -Use this skill when: -- Working in the pgsql-parser repository -- Fixing deparser or parser issues -- Running parser/deparser tests -- Validating SQL round-trip correctness -- Adding new SQL syntax support - -## Repository Structure - -``` -pgsql-parser/ - packages/ - parser/ # SQL parser (libpg_query bindings) - deparser/ # SQL deparser (AST to SQL) - plpgsql-parser/ # PL/pgSQL parser - plpgsql-deparser/ # PL/pgSQL deparser - types/ # TypeScript type definitions - utils/ # Utility functions - traverse/ # AST traversal utilities - transform/ # AST transformation utilities -``` - -## Testing Strategy - -The pgsql-parser uses AST-level equality for correctness, not string equality: - -``` -parse(sql1) → ast1 → deparse(ast1) → sql2 → parse(sql2) → ast2 -``` - -While `sql2 !== sql1` textually, a correct round-trip means `ast1 === ast2`. - -### Key Principle - -Exact SQL string equality is not required. The focus is on comparing resulting ASTs. Use `expectAstMatch` (deparser) or `expectPGParse` (ast package) to validate correctness. - -## Development Workflow - -### Initial Setup - -```bash -pnpm install -pnpm build -``` - -### Running Tests - -Run all tests: -```bash -pnpm test -``` - -Run tests for a specific package: -```bash -cd packages/deparser -pnpm test -``` - -Watch mode for rapid iteration: -```bash -cd packages/deparser -pnpm test:watch -``` - -Run a specific test: -```bash -pnpm test --testNamePattern="specific-test-name" -``` - -## Fixing Deparser Issues - -### Systematic Approach - -1. **One test at a time**: Focus on individual failing tests - ```bash - pnpm test --testNamePattern="specific-test" - ``` - -2. **Always check for regressions**: After each fix, run full test suite - ```bash - pnpm test - ``` - -3. **Build before testing**: Always rebuild after code changes - ```bash - pnpm build && pnpm test - ``` - -4. **Clean commits**: Stage files explicitly - ```bash - git add packages/deparser/src/specific-file.ts - ``` - -### Workflow Loop - -``` -Make changes → pnpm build → pnpm test --testNamePattern="target" → pnpm test (full) → commit -``` - -## Test Utilities - -### Deparser Tests - -Location: `packages/deparser/test-utils/index.ts` - -```typescript -import { expectAstMatch } from '../test-utils'; - -it('deparses SELECT correctly', () => { - expectAstMatch('SELECT * FROM users'); -}); -``` - -### AST Package Tests - -Location: `packages/ast/test/utils/index.ts` - -Uses database deparser for validation: -```typescript -import { expectPGParse } from '../test/utils'; - -it('round-trips through database deparser', async () => { - await expectPGParse('SELECT * FROM users WHERE id = 1'); -}); -``` - -Note: AST tests require the database to have `deparser.expressions_array` function available. - -## Common Commands - -| Command | Description | -|---------|-------------| -| `pnpm build` | Build all packages | -| `pnpm test` | Run all tests | -| `pnpm test:watch` | Run tests in watch mode | -| `pnpm lint` | Run linter | -| `pnpm clean` | Clean build artifacts | - -## Package-Specific Testing - -### Parser Package - -Tests libpg_query bindings and SQL parsing: -```bash -cd packages/parser -pnpm test -``` - -### Deparser Package - -Tests AST-to-SQL conversion: -```bash -cd packages/deparser -pnpm test -``` - -### PL/pgSQL Packages - -Tests PL/pgSQL parsing and deparsing: -```bash -cd packages/plpgsql-parser -pnpm test - -cd packages/plpgsql-deparser -pnpm test -``` - -## Debugging Tips - -1. **Use isolated debug scripts** for complex issues (don't commit them) - -2. **Check the AST structure** when tests fail: - ```typescript - import { parse } from 'pgsql-parser'; - console.log(JSON.stringify(parse('SELECT 1'), null, 2)); - ``` - -3. **Compare ASTs visually** to understand differences: - ```typescript - const ast1 = parse(sql1); - const ast2 = parse(deparse(ast1)); - console.log('Original:', JSON.stringify(ast1, null, 2)); - console.log('Round-trip:', JSON.stringify(ast2, null, 2)); - ``` - -## Troubleshooting - -| Issue | Solution | -|-------|----------| -| Tests fail after changes | Run `pnpm build` before `pnpm test` | -| Type errors | Check `packages/types` for type definitions | -| Shared code changes | Rebuild dependent packages | -| Snapshot mismatches | Review changes, update with `pnpm test -u` if correct | - -## Important Notes - -- Changes to `types` or `utils` packages may require rebuilding dependent packages -- Each package can be developed and tested independently -- The project uses Lerna for monorepo management -- Always verify no regressions before committing - -## References - -- Deparser testing docs: `packages/deparser/TESTING.md` -- Quoting rules: `packages/deparser/QUOTING-RULES.md` -- Deparser usage: `packages/deparser/DEPARSER_USAGE.md` -- PL/pgSQL deparser: `packages/plpgsql-deparser/AGENTS.md` diff --git a/.agents/skills/constructive-testing/references/test-authoring.md b/.agents/skills/constructive-testing/references/test-authoring.md deleted file mode 100644 index 522c908929..0000000000 --- a/.agents/skills/constructive-testing/references/test-authoring.md +++ /dev/null @@ -1,105 +0,0 @@ -# Test Authoring: Lean, Readable, Fast - -How to structure tests — which preset to pick, which utilities to call, and how to keep test files short and expressive. For CI shard balancing see `ci-test-optimization.md`. For RLS/transaction mechanics see `integration-testing.md`. - -## Rule #1: Provision the Minimum - -Every module added to a preset costs provisioning time (~3–8s per module). The single biggest lever for fast tests is provisioning only what the test exercises. - -### Preset Selection Decision Tree - -``` -What does your test exercise? -│ -├─ Table/field/trigger generators (no RLS, no auth) -│ → preset: 'minimal' (~5s) -│ -├─ App-level memberships or permissions (no orgs) -│ → preset: 'permissions_app' (~10s) -│ -├─ Memberships with data manipulation (no RLS enforcement) -│ → preset: 'memberships' (~12s) -│ -├─ Memberships with RLS (org creation via createOrg) -│ → preset: 'memberships_rls' (~14s) -│ -├─ Auth flows (login, register, sessions, tokens) -│ → preset: 'accounts' (~20s) -│ -├─ Invite workflows -│ → preset: 'invites' (~22s) -│ -├─ Org chart / hierarchy -│ → preset: 'hierarchy' (~18s) -│ -├─ Feature needs specific modules not in a preset -│ → Custom array: preset: ['users_module', 'agent_chat_module'] -│ -└─ Blueprint construction / "needs everything" - → preset: 'full' (~50-60s) — use sparingly -``` - -### Anti-Pattern: Over-Provisioning - -```typescript -// ❌ 60s provisioning to test a feature that only needs users_module -const { testHelper } = await provisionTestDatabase(db, pg, { preset: 'full' }); - -// ✅ 5s provisioning, tests exactly what it needs -const { testHelper } = await provisionTestDatabase(db, pg, { preset: 'minimal' }); -``` - -## Infrastructure Belongs in CI, Not in Tests - -Roles are bootstrapped by CI before tests run. Tests must assume roles already exist. Never call `CREATE EXTENSION` in test code — declare in `.control` file. - -## Test File Skeleton - -```typescript -jest.setTimeout(120000); -process.env.LOG_SCOPE = 'pgsql-test'; - -import { getConnections, PgTestClient, snapshot } from 'pgsql-test'; -import { TestHelper, provisionTestDatabase } from '../../test-utils/test-helpers'; - -let db: PgTestClient; -let pg: PgTestClient; -let teardown: () => Promise; -let t: TestHelper; - -beforeAll(async () => { - ({ db, pg, teardown } = await getConnections()); - const { testHelper } = await provisionTestDatabase(db, pg, { preset: 'minimal' }); - t = testHelper; -}); - -afterAll(() => teardown()); -beforeEach(async () => { await db.beforeEach(); }); -afterEach(async () => { await db.afterEach(); }); -``` - -### DbMetaTest (Higher-Level API) - -For tests needing org charts, labeled snapshots, or `asUser()` context isolation: - -```typescript -let ctx: DbMetaTest; -beforeAll(async () => { ctx = await DbMetaTest.setup({ preset: 'hierarchy' }); }); -afterAll(() => ctx.teardown()); -ctx.installHooks(); -``` - -## Using Test Utilities for Readable Tests - -Tests should read like scenarios, not SQL: - -```typescript -// ❌ Hard to read -const limitsTable = await pg.one(`SELECT s.schema_name, lm.limits_table::text FROM ...`); -await pg.any(`INSERT INTO "${limitsTable.schema_name}"."${limitsTable.limits_table}" ...`); - -// ✅ Readable -const limitsTable = await t.getLimitsTable('app'); -await pg.any(`INSERT INTO ${limitsTable} (name, num, max, actor_id) VALUES ('seats', 0, 3, $1)`, [user.id]); -const result = await t.callLimitIncrement('seats', 'app'); -``` diff --git a/AGENTS.md b/AGENTS.md index 77a2d30f2f..a647400b21 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -146,7 +146,7 @@ The `.agents/skills/` directory contains tooling-focused skills for this monorep | `pgpm` | PostgreSQL Package Manager — migrations, CLI, Docker, CI/CD, project scaffolding, table creation rules, DB export | Database migrations, workspace/module creation, `pgpm init`, deploy/revert | | `constructive-pnpm` | PNPM workspace management — monorepo config, dist-folder publishing with makage/lerna, dependency management | Configuring pnpm workspaces, publishing packages, managing monorepo dependencies | | `constructive-setup` | Monorepo setup — install dependencies, start PostgreSQL, bootstrap users, build, run tests, local email services | Setting up the development environment, local dev, full pipeline | -| `constructive-testing` | All PostgreSQL testing frameworks — pgsql-test, drizzle-orm-test, supabase-test, test authoring, CI optimization | Writing database tests, testing RLS policies, choosing test presets, CI shard balancing | +| `constructive-testing` | PostgreSQL testing frameworks — pgsql-test, drizzle-orm-test, supabase-test | Writing database tests, testing RLS policies, seeding test data | | `constructive-cli` | Generated CLI commands — how the CLI is generated from GraphQL schemas, codegen options, multi-target CLI | Generating CLI tools, running generated CLI, understanding codegen pipeline | | `graphile-search` | Unified PostGraphile v5 search plugin — tsvector, BM25, pg_trgm, pgvector adapters, composite searchScore | Adding search to GraphQL, configuring search adapters, querying search via SDK | From 33e26630df13e9483e2e99dc894eae775f97c3c3 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Sun, 31 May 2026 23:37:51 +0000 Subject: [PATCH 3/4] fix: restore local dev setup without constructive-db references - Restore local-dev-setup.md and local-env.md with constructive-db package references (constructive-services, constructive-local, dropdb) replaced by pointers to constructive-db-local-env skill - Keep full-pipeline.md removed (was entirely constructive-db oriented) - Add reference table back to setup SKILL.md --- .agents/skills/constructive-setup.zip | Bin 3954 -> 6452 bytes .agents/skills/constructive-setup/SKILL.md | 9 ++ .../references/local-dev-setup.md | 48 ++++++++++ .../references/local-env.md | 90 ++++++++++++++++++ 4 files changed, 147 insertions(+) create mode 100644 .agents/skills/constructive-setup/references/local-dev-setup.md create mode 100644 .agents/skills/constructive-setup/references/local-env.md diff --git a/.agents/skills/constructive-setup.zip b/.agents/skills/constructive-setup.zip index a840704e9f1a85286c88179e13f35bbe75470e3d..2c4d1af45428d8a50617635dae59441e7a47be28 100644 GIT binary patch delta 4138 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 delta 1814 zcmV+x2kH2@AEk+e0*WpSB?$ncHABD=P$e|5ZP`A>=zT6MN$1ps2Hj)(*Ek9jcP z1E&x75SJ^YYN>WY(wN)F~+7Ox1_RbD_fYs0_!Ch z5eb{Zsbl(CVhtcXYujp@WrC2nwjs&ZOw6)eyi@`I2zJ?BT93p=TeI98(rBpR`-5T# ze;gzQxMLzVxePcoI6eRfB5u9w78Qpz(3K=Dyt{+hJ;?VMRF7VDwzwPeGM~>OWsUSb zoznA1DlcoBwjOFy(){@}+5C}O%=Ep~_rBip+}38r{o?dNV)5A~X(4Z`F3g^OjT@c| z&n9xl8@O@w$rBX|cTSJQkT0h6?FaRge`-G&V9MGyxh{;zAQ0a0#|GhYFE1HsUfrFRfZq2etm$Dkf`b&0u?_M}V%z*-Zs(N1V4I@q&@o7S^8B{59#zZlP<;Y? z#8ZS`J-?a2kPR2nfpj|XM(#prC&aOso=!*3{qL~Th&|)%--#f8pQhgM`0R zmbOk4jt?l|a-f9QN(tG8W>t0Vur>m%cc4(^=rMZ({DF(k+qg~bZuS6f|BYmJ6*y4M zgGx;_qo;1A>*#l?mw`@();}G75^&b1L6G1JZ-PV+t^_QuX^&vwIWK z$w#Nsg5)N}Nf0L|#lP0X$R4nl@>qI+T}g2ax`TP6zc+v|O#s(QZ{O*7cGHh%S(fd1afsGe{W#g8{O&!?8Z~A zfHC&}=G3W5?+gC|q~K5lI&@96?rA3?*%c4xhvfBcfV(zGA0~(QbO}%i2XE=U)QHST zf!=5D^ZnoG9UEZ&0Uc-abZI{pU0gq((2qa8n9$pf{We!<%T$>_nRij<-XujZqY_?T zy?g;a0g_<5N(gHse^8IxRs$jfs&GQOs0e&prMrD{dij9gyS9Rhcq;&&6$6|qr8fwH zHU$QGm|-LO>`0DEX9+-7ZZ~p83qmn00aP;0RRBQ4FCWD E0MkEX`2YX_ diff --git a/.agents/skills/constructive-setup/SKILL.md b/.agents/skills/constructive-setup/SKILL.md index f57d822e45..25f0e4d1fd 100644 --- a/.agents/skills/constructive-setup/SKILL.md +++ b/.agents/skills/constructive-setup/SKILL.md @@ -113,6 +113,15 @@ See [local-email-services.md](./references/local-email-services.md) for Docker C For full navigation, see the repo's `AGENTS.md`. +### Local Development Environment + +| Reference | Topic | Consult When | +|-----------|-------|--------------| +| [local-dev-setup.md](references/local-dev-setup.md) | Quick-start local dev | Docker Postgres + GraphQL server startup | +| [local-env.md](references/local-env.md) | Full local environment | Detailed setup, endpoint reference, troubleshooting | + +> **Note:** Platform database deployment requires `pgpm deploy` from the `constructive-db` repo — see the `constructive-db-local-env` skill there for those steps. + ## Cross-References - **pgpm** skill — Database migrations, Docker, environment, CLI commands diff --git a/.agents/skills/constructive-setup/references/local-dev-setup.md b/.agents/skills/constructive-setup/references/local-dev-setup.md new file mode 100644 index 0000000000..943973b121 --- /dev/null +++ b/.agents/skills/constructive-setup/references/local-dev-setup.md @@ -0,0 +1,48 @@ +# Local Dev Setup + +Quick-start for running the Constructive GraphQL server locally with Docker Postgres and pgpm. + +## Prerequisites + +- Docker (for Postgres) +- Node.js v22+ +- pgpm: `npm install -g pgpm` + +## Start PostgreSQL + +```bash +pgpm docker start --image docker.io/constructiveio/postgres-plus:18 --recreate +eval "$(pgpm env)" +pgpm admin-users bootstrap --yes +pgpm admin-users add --test --yes +``` + +> **Important:** `eval "$(pgpm env)"` must be run as a separate command (not chained with `&&`) so the env vars are available for subsequent commands. + +## Deploy Platform Database + +> Database deployment uses `pgpm deploy` from the `constructive-db` repo. See the `constructive-db-local-env` skill in `constructive-io/constructive-db` for step-by-step instructions. + +## Start GraphQL Server + +```bash +cd graphql/server +PGDATABASE=constructive pnpm dev +``` + +Health check: `curl -s -o /dev/null -w "%{http_code}" http://api.localhost:3000/graphql` → 405 + +## Endpoint Reference + +| Endpoint | Purpose | +|---|---| +| `http://auth.localhost:3000/graphql` | Main auth | +| `http://api.localhost:3000/graphql` | Main public API | +| `http://auth-.localhost:3000/graphql` | Per-DB auth | +| `http://app-public-.localhost:3000/graphql` | Per-DB app API | +| `http://admin-.localhost:3000/graphql` | Per-DB admin | + +## Related + +- `constructive-db-local-env` skill (in constructive-db repo) — platform database deployment +- `constructive-sdk` skill — provision a user database after setup diff --git a/.agents/skills/constructive-setup/references/local-env.md b/.agents/skills/constructive-setup/references/local-env.md new file mode 100644 index 0000000000..3dc2ca3ace --- /dev/null +++ b/.agents/skills/constructive-setup/references/local-env.md @@ -0,0 +1,90 @@ +# Constructive Local Environment Setup + +Set up a fully working local Constructive environment with PostgreSQL and the GraphQL server. Enables developing against a real GraphQL API and running tests. + +## When to Apply + +Use this reference when: +- Setting up a local development environment for Constructive +- Needing a running GraphQL server for development +- Troubleshooting local environment issues + +## Prerequisites + +- Docker installed and running +- Node.js 22+ +- pnpm installed + +## Step-by-Step Setup + +### 1. Install pgpm globally + +```bash +npm install -g pgpm +``` + +### 2. Start PostgreSQL via pgpm Docker + +```bash +pgpm docker start --image docker.io/constructiveio/postgres-plus:18 --recreate +``` + +Uses the `postgres-plus:18` container (includes PostGIS, pgvector, and other required extensions). PostgreSQL 17+ is required due to `security_invoker` views. + +### 3. Set environment variables + +```bash +eval "$(pgpm env)" +``` + +Sets `PGHOST`, `PGPORT`, `PGUSER`, `PGPASSWORD`, and `PGDATABASE` for the local PostgreSQL instance. + +**Important**: Run this as a separate command (not chained with `&&`) so the env vars are available for subsequent commands. + +### 4. Bootstrap database users + +```bash +pgpm admin-users bootstrap --yes +pgpm admin-users add --test --yes +``` + +### 5. Deploy the platform database + +> This step uses `pgpm deploy` from the `constructive-db` repo. See the `constructive-db-local-env` skill in `constructive-io/constructive-db` for deployment instructions. + +### 6. Install dependencies and start server + +```bash +cd /path/to/constructive +pnpm install +pnpm build +cd graphql/server +PGDATABASE=constructive pnpm dev +``` + +The server starts with subdomain-based routing: + +| Target | Endpoint | +|--------|----------| +| Public | `http://api.localhost:3000/graphql` | +| Auth | `http://auth.localhost:3000/graphql` | +| Objects | `http://objects.localhost:3000/graphql` | +| Admin | `http://admin.localhost:3000/graphql` | + +## Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `PGHOST` | `localhost` | PostgreSQL host | +| `PGPORT` | `5432` | PostgreSQL port | +| `PGUSER` | `postgres` | PostgreSQL user | +| `PGPASSWORD` | `password` | PostgreSQL password | +| `PGDATABASE` | `constructive` | Target database | + +## Troubleshooting + +| Issue | Fix | +|-------|-----| +| "security_invoker" error | Need PostgreSQL 17+. Use `pgpm docker start --image docker.io/constructiveio/postgres-plus:18` | +| "permission denied for schema" | Token not set or expired. Re-authenticate | +| Server not responding | Verify with `curl -s http://api.localhost:3000/graphql -H 'Content-Type: application/json' -d '{"query":"{ __typename }"}'` | From 0942d080a280c9c6cdf46c389aea290857ba3497 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Sun, 31 May 2026 23:46:30 +0000 Subject: [PATCH 4/4] fix: replace closed-source deploy with generic pgpm deploy workflow Local dev setup now shows the generic workflow: cd /path/to/your-database pgpm deploy --database myapp --createdb --yes Instead of referencing constructive-db internal packages. --- .agents/skills/constructive-setup.zip | Bin 6452 -> 6685 bytes .agents/skills/constructive-setup/SKILL.md | 2 -- .../references/local-dev-setup.md | 23 +++++++++++++++--- .../references/local-env.md | 21 +++++++++++++--- 4 files changed, 37 insertions(+), 9 deletions(-) diff --git a/.agents/skills/constructive-setup.zip b/.agents/skills/constructive-setup.zip index 2c4d1af45428d8a50617635dae59441e7a47be28..e6f238a15bf0eff0ede2a6fa70cca541aa02f76f 100644 GIT binary patch delta 4207 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

G_)6*ul1?-kUa=uTCW@H-2oM6$d2VYaFtE zi9=jPr&SP7qV8W-Vy@}eE=usHVl%-wmuO~*RE@+}DW6sMbOgJ9;I0ro-)I8osqlmA zhZ=qb&reaRqy#e zT@`DpMb`VeR*Wlc}c_Hjdj$^uG%P&OOGx zBDCd+>JLQTObyrz0%{s{;_pm}WdbwOzi6oA4Ceb|=q{@x{)*1n==Ns* zLrH(|Ao2%1_XX?d{{#HoXKV(yG)Hp#*?9Q=2P^>We}Mx4Ao^R?F(D+mf7oRHGDfZp tyYj8yl(_-y4*dV~JO9thQ+hxygpQY#6hLhDuLM&r9|tX2GTXmL{{y(n54ZpT delta 4016 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 diff --git a/.agents/skills/constructive-setup/SKILL.md b/.agents/skills/constructive-setup/SKILL.md index 25f0e4d1fd..65fb614420 100644 --- a/.agents/skills/constructive-setup/SKILL.md +++ b/.agents/skills/constructive-setup/SKILL.md @@ -120,8 +120,6 @@ For full navigation, see the repo's `AGENTS.md`. | [local-dev-setup.md](references/local-dev-setup.md) | Quick-start local dev | Docker Postgres + GraphQL server startup | | [local-env.md](references/local-env.md) | Full local environment | Detailed setup, endpoint reference, troubleshooting | -> **Note:** Platform database deployment requires `pgpm deploy` from the `constructive-db` repo — see the `constructive-db-local-env` skill there for those steps. - ## Cross-References - **pgpm** skill — Database migrations, Docker, environment, CLI commands diff --git a/.agents/skills/constructive-setup/references/local-dev-setup.md b/.agents/skills/constructive-setup/references/local-dev-setup.md index 943973b121..93513c28b2 100644 --- a/.agents/skills/constructive-setup/references/local-dev-setup.md +++ b/.agents/skills/constructive-setup/references/local-dev-setup.md @@ -19,17 +19,32 @@ pgpm admin-users add --test --yes > **Important:** `eval "$(pgpm env)"` must be run as a separate command (not chained with `&&`) so the env vars are available for subsequent commands. -## Deploy Platform Database +## Deploy Your Database -> Database deployment uses `pgpm deploy` from the `constructive-db` repo. See the `constructive-db-local-env` skill in `constructive-io/constructive-db` for step-by-step instructions. +Navigate to your pgpm database workspace and deploy: + +```bash +cd /path/to/your-database +pgpm deploy +``` + +This runs all migrations in your `pgpm.plan` and provisions the schema. If your module hasn't been deployed before, add `--createdb` to create the database: + +```bash +pgpm deploy --database myapp --createdb --yes +``` + +For full deploy options, see the **pgpm** skill: [references/deploy.md](../pgpm/references/deploy.md) ## Start GraphQL Server ```bash cd graphql/server -PGDATABASE=constructive pnpm dev +PGDATABASE=myapp pnpm dev ``` +Set `PGDATABASE` to match the database name you deployed to. + Health check: `curl -s -o /dev/null -w "%{http_code}" http://api.localhost:3000/graphql` → 405 ## Endpoint Reference @@ -44,5 +59,5 @@ Health check: `curl -s -o /dev/null -w "%{http_code}" http://api.localhost:3000/ ## Related -- `constructive-db-local-env` skill (in constructive-db repo) — platform database deployment +- **pgpm** skill — full deploy/revert/verify reference, Docker options, environment variables - `constructive-sdk` skill — provision a user database after setup diff --git a/.agents/skills/constructive-setup/references/local-env.md b/.agents/skills/constructive-setup/references/local-env.md index 3dc2ca3ace..ec3eeea55b 100644 --- a/.agents/skills/constructive-setup/references/local-env.md +++ b/.agents/skills/constructive-setup/references/local-env.md @@ -48,9 +48,22 @@ pgpm admin-users bootstrap --yes pgpm admin-users add --test --yes ``` -### 5. Deploy the platform database +### 5. Deploy your database -> This step uses `pgpm deploy` from the `constructive-db` repo. See the `constructive-db-local-env` skill in `constructive-io/constructive-db` for deployment instructions. +Navigate to your pgpm database workspace and deploy: + +```bash +cd /path/to/your-database +pgpm deploy --database myapp --createdb --yes +``` + +This runs all migrations in your `pgpm.plan` and provisions the full schema (tables, functions, triggers, RLS policies). For an existing database, omit `--createdb`: + +```bash +pgpm deploy +``` + +For full deploy options, see the **pgpm** skill. ### 6. Install dependencies and start server @@ -59,9 +72,11 @@ cd /path/to/constructive pnpm install pnpm build cd graphql/server -PGDATABASE=constructive pnpm dev +PGDATABASE=myapp pnpm dev ``` +Set `PGDATABASE` to match the database name you deployed to. + The server starts with subdomain-based routing: | Target | Endpoint |