Change Request: API Versioning for pgmi Session Interface#
Status: IMPLEMENTED This design document describes changes that have been completed. The “Current State” section reflects the pre-implementation state for historical context. The “Target State” is now the actual implementation. See Session API for current API documentation.
Overview#
Introduce versioned API contracts for pgmi’s session-scoped interface (temp tables, views, functions). This enables:
- Stable deploy.sql scripts that don’t break on pgmi upgrades
- DevOps pipelines pinned to specific API versions
- Internal refactoring freedom for pgmi maintainers
Motivation#
Currently, deploy.sql scripts directly reference internal tables like pg_temp.pgmi_source. If pgmi changes the table structure, all user deploy.sql scripts break. By introducing versioned views as the public API, pgmi can evolve internally while maintaining backward compatibility.
Use case: A CI/CD pipeline uses pgmi deploy --compat=1. Even when pgmi 2.0 ships with breaking internal changes, the pipeline continues working because pgmi provides the v1 API contract.
Design Decisions (Already Made)#
| Decision | Choice | Rationale |
|---|---|---|
| CLI flag name | --compat | Industry standard (Kubernetes, REST APIs) |
| View naming | pgmi_*_view suffix | Explicit, consistent with existing pgmi_plan_view |
| Internal table naming | _pgmi_* underscore prefix | Convention: underscore = internal |
| Default version | Latest stable | New users get best experience |
| Deprecation policy | 100% backward compatible | No version removal, old versions always work |
| File naming | api-v1.sql, api-v2.sql | Major versions only, no semver in filenames |
pgmi_plan_view location | Moves to api-v1.sql | It’s part of the public API, references internal tables |
| Macro code generation | SQL function returns replacement code | Go calls pgmi_test_generate(), replaces macro with result |
Pre-Implementation State (Historical)#
Internal Tables (in internal/params/schema.sql)#
| Table | Purpose | Used By |
|---|---|---|
pgmi_source | Non-test source files | deploy.sql queries, pgmi_plan_view |
pgmi_source_metadata | Parsed <pgmi-meta> XML | pgmi_plan_view JOIN |
pgmi_parameter | CLI parameters | current_setting('pgmi.key', true) |
pgmi_test_directory | Test directory hierarchy | pgmi_test_plan() |
pgmi_test_source | Test file content | pgmi_test_plan(), pgmi_test() macro |
Existing Views#
| View | Purpose |
|---|---|
pgmi_plan_view | Execution order with sort_key expansion (already follows _view convention) |
Existing Functions (Public API)#
| Function | Purpose |
|---|---|
pgmi_test_plan(pattern) | Return test execution plan |
pgmi_is_sql_file(filename) | Check SQL file extension |
pgmi_register_file(...) | Internal: Go calls to insert files |
pgmi_validate_pattern(pattern) | Internal: Regex validation |
pgmi_has_tests(dir, pattern) | Internal: Recursive test discovery |
pgmi_persist_test_plan(schema, pattern) | Export test plan to permanent table |
Note: Parameter access is via current_setting('pgmi.key', true). Templates handle their own declaration, validation, and defaults.
Implemented State#
File Structure#
internal/params/
schema.sql # Internal tables (_pgmi_*), always executes first
api-v1.sql # V1 views and public functions
api-v2.sql # Future: V2 contract (when needed)Renamed Internal Tables (in schema.sql)#
| Current Name | New Name |
|---|---|
pgmi_source | _pgmi_source |
pgmi_source_metadata | _pgmi_source_metadata |
pgmi_parameter | _pgmi_parameter |
pgmi_test_directory | _pgmi_test_directory |
pgmi_test_source | _pgmi_test_source |
V1 API Views (in api-v1.sql)#
| View | Definition | Notes |
|---|---|---|
pgmi_source_view | SELECT * FROM _pgmi_source | Primary file access |
pgmi_parameter_view | SELECT * FROM _pgmi_parameter | Parameter inspection |
pgmi_plan_view | Complex view with UNNEST/JOIN over _pgmi_source, _pgmi_source_metadata | Execution ordering (moved from schema.sql) |
pgmi_test_source_view | SELECT * FROM _pgmi_test_source | Test file access |
pgmi_test_directory_view | SELECT * FROM _pgmi_test_directory | Test hierarchy |
pgmi_source_metadata_view | SELECT * FROM _pgmi_source_metadata | Metadata inspection |
Note: pgmi_plan_view is moved from schema.sql to api-v1.sql. It references internal tables (_pgmi_source, _pgmi_source_metadata) but exposes a stable interface. The view contains execution ordering logic (UNNEST sort_keys, ROW_NUMBER, etc.).
V1 API Functions (in api-v1.sql)#
Functions that are part of the v1 contract:
pgmi_test_plan(pattern)pgmi_is_sql_file(filename)pgmi_persist_test_plan(schema, pattern)pgmi_test_callback(event)- default callbackpgmi_test_generate(pattern, callback)- generates macro replacement code
Note: Parameter functions (pgmi_declare_param, pgmi_get_param) were removed. Parameters are accessed via current_setting('pgmi.key', true). Templates handle declaration, validation, and defaults.
Internal functions (not part of public API, stay in schema.sql):
pgmi_register_file(...)- called by Go onlypgmi_validate_pattern(...)- internal helperpgmi_has_tests(...)- internal helper
Macro Code Generation Architecture#
Current State (Go generates SQL)#
The Go preprocessor in internal/preprocessor/ finds pgmi_test() or CALL pgmi_test() calls and replaces them with a large DO block containing loops, savepoints, and EXECUTE statements. This means:
- Go needs to know internal table names
- Different API versions would require different Go code
- SQL generation logic is split between Go and SQL
Target State (SQL generates SQL)#
Move code generation to a SQL function that returns the replacement code as TEXT:
┌─────────────────┐ ┌────────────────────────────┐ ┌──────────────────┐
│ Go preprocessor │ ──► │ SELECT pgmi_test_generate( │ ──► │ Go replaces │
│ finds macro │ │ 'pattern', 'callback') │ │ macro with │
│ pgmi_test(...) │ │ Returns: DO $$ ... END $$ │ │ returned SQL │
└─────────────────┘ └────────────────────────────┘ └──────────────────┘The Generator Function (in api-v1.sql)#
CREATE OR REPLACE FUNCTION pg_temp.pgmi_test_generate(
p_pattern TEXT DEFAULT NULL,
p_callback TEXT DEFAULT 'pg_temp.pgmi_test_callback'
) RETURNS TEXT
LANGUAGE plpgsql AS $$
DECLARE
v_callback_safe TEXT;
v_pattern_literal TEXT;
BEGIN
-- Escape/validate inputs
v_callback_safe := COALESCE(p_callback, 'pg_temp.pgmi_test_callback');
v_pattern_literal := CASE
WHEN p_pattern IS NULL THEN 'NULL'
ELSE format('%L', p_pattern)
END;
-- Return the complete DO block as TEXT
-- This references internal tables (_pgmi_test_source, etc.)
RETURN format($code$
DO $pgmi_test$
DECLARE
v_step RECORD;
v_event pg_temp.pgmi_test_event;
v_savepoint_name TEXT;
v_ordinal INT := 0;
BEGIN
-- Suite start
v_event := ROW('suite_start', NULL, NULL, 0, 0, NULL)::pg_temp.pgmi_test_event;
PERFORM %s(v_event);
FOR v_step IN (
SELECT * FROM pg_temp.pgmi_test_plan(%s)
)
LOOP
v_ordinal := v_ordinal + 1;
v_savepoint_name := 'pgmi_test_' || v_ordinal;
CASE v_step.step_type
WHEN 'fixture' THEN
v_event := ROW('fixture_start', v_step.script_path, v_step.directory, v_step.depth, v_ordinal, NULL);
PERFORM %s(v_event);
EXECUTE (SELECT content FROM pg_temp._pgmi_test_source WHERE path = v_step.script_path);
WHEN 'test' THEN
SAVEPOINT _test_sp;
v_event := ROW('test_start', v_step.script_path, v_step.directory, v_step.depth, v_ordinal, NULL);
PERFORM %s(v_event);
BEGIN
EXECUTE (SELECT content FROM pg_temp._pgmi_test_source WHERE path = v_step.script_path);
v_event := ROW('test_end', v_step.script_path, v_step.directory, v_step.depth, v_ordinal, NULL);
PERFORM %s(v_event);
EXCEPTION WHEN OTHERS THEN
RAISE;
END;
ROLLBACK TO SAVEPOINT _test_sp;
WHEN 'teardown' THEN
v_event := ROW('teardown_start', NULL, v_step.directory, v_step.depth, v_ordinal, NULL);
PERFORM %s(v_event);
END CASE;
END LOOP;
-- Suite end
v_event := ROW('suite_end', NULL, NULL, 0, v_ordinal, NULL)::pg_temp.pgmi_test_event;
PERFORM %s(v_event);
END $pgmi_test$;
$code$,
v_callback_safe, -- suite_start callback
v_pattern_literal, -- pattern for pgmi_test_plan
v_callback_safe, -- fixture callback
v_callback_safe, -- test_start callback
v_callback_safe, -- test_end callback
v_callback_safe, -- teardown callback
v_callback_safe -- suite_end callback
);
END;
$$;
COMMENT ON FUNCTION pg_temp.pgmi_test_generate IS
'Generates the SQL code for pgmi_test() macro expansion.
Called by Go preprocessor, returns a DO block as TEXT.
Part of the versioned API contract - v1 generates code referencing v1 internal tables.';Implementation Note: The actual implementation in
api-v1.sqldiffers from this proposal. Instead of generating a single DO block containing SAVEPOINT commands (which would fail because PL/pgSQL doesn’t support savepoints), the actual implementation generates a series of top-level SQL statements where SAVEPOINT/ROLLBACK are at the SQL level and test content is wrapped in separate DO blocks using EXECUTE. Seeinternal/contract/api-v1.sqlfor the working implementation.
Updated Go Preprocessor Flow#
// internal/preprocessor/preprocessor.go
func (p *Preprocessor) ExpandMacros(ctx context.Context, conn *pgxpool.Conn, sql string) (string, error) {
// Find all pgmi_test(...) or CALL pgmi_test(...) calls
matches := pgmiTestRegex.FindAllStringSubmatchIndex(sql, -1)
for _, match := range matches {
pattern, callback := extractArguments(sql, match)
// Call the SQL generator function (part of versioned API)
var generatedSQL string
err := conn.QueryRow(ctx,
"SELECT pg_temp.pgmi_test_generate($1, $2)",
pattern, callback,
).Scan(&generatedSQL)
if err != nil {
return "", fmt.Errorf("macro expansion failed: %w", err)
}
// Replace macro with generated SQL
sql = replaceMacro(sql, match, generatedSQL)
}
return sql, nil
}Benefits of This Approach#
- Version isolation: v1 generator knows v1 internals, v2 generator knows v2 internals
- Go becomes version-agnostic: Just calls
pgmi_test_generate(), doesn’t know table names - SQL generation in SQL: More natural, easier to maintain and test
- Testable: Unit test the generator function independently
- Consistent behavior: Generated code is deterministic and versioned
Go Implementation#
New Package: internal/contract#
// internal/contract/contract.go
package contract
import "embed"
//go:embed api-v1.sql
//go:embed api-v2.sql
var embeddedAPIs embed.FS
type Version string
const (
V1 Version = "1"
Latest Version = V1 // Update when new version released
)
var supportedVersions = map[Version]string{
V1: "api-v1.sql",
}
type Contract struct {
Version Version
SQL string
}
// Load returns the API contract for the specified version.
// Returns error if version is not supported.
func Load(version string) (*Contract, error) {
v := Version(version)
if v == "" {
v = Latest
}
filename, ok := supportedVersions[v]
if !ok {
return nil, fmt.Errorf("unsupported API version %q; supported: %v",
version, SupportedVersions())
}
content, err := embeddedAPIs.ReadFile(filename)
if err != nil {
return nil, fmt.Errorf("failed to load API v%s: %w", version, err)
}
return &Contract{Version: v, SQL: string(content)}, nil
}
// SupportedVersions returns list of supported API versions.
func SupportedVersions() []Version {
versions := make([]Version, 0, len(supportedVersions))
for v := range supportedVersions {
versions = append(versions, v)
}
sort.Slice(versions, func(i, j int) bool {
return versions[i] < versions[j]
})
return versions
}CLI Flag Addition#
In internal/cli/deploy.go:
var compat string
func init() {
deployCmd.Flags().StringVar(&compat, "compat", "",
"Compatibility level (default: latest)")
}Deployer Integration#
In internal/services/deployer.go, after executing schema.sql:
// Load and execute API contract
contract, err := contract.Load(cfg.Compat)
if err != nil {
return fmt.Errorf("API contract error: %w", err)
}
if _, err := conn.Exec(ctx, contract.SQL); err != nil {
return fmt.Errorf("failed to initialize API v%s: %w", contract.Version, err)
}Execution Order#
1. schema.sql → Creates internal tables (_pgmi_*), internal functions
2. Go inserts files → _pgmi_source via pgmi_register_file()
3. Go inserts params → _pgmi_parameter
4. api-v{N}.sql → Creates versioned views, public functions, pgmi_test_generate()
5. Go preprocessor → Calls pgmi_test_generate() to expand macros in deploy.sql
6. deploy.sql executes → Uses views (pgmi_source_view, pgmi_plan_view, etc.)Critical: Step 4 (api-v{N}.sql) must execute BEFORE step 5 (preprocessing) because the preprocessor calls pgmi_test_generate() which is defined in the API contract.
Loader Code Updates#
internal/files/loader/loader.go directly references table names for batch inserts:
| Current | New |
|---|---|
pg_temp.pgmi_test_source | pg_temp._pgmi_test_source |
pg_temp.pgmi_test_directory | pg_temp._pgmi_test_directory |
The pgmi_register_file() function (stays in schema.sql) handles _pgmi_source internally, so loader code calling it doesn’t need changes for that table.
Template Updates#
Advanced Template (deploy.sql)#
Change all references:
-- Before
FROM pg_temp.pgmi_source WHERE ...
-- After
FROM pg_temp.pgmi_source_view WHERE ...Affected locations in advanced template:
- Parameter loading (reads
pgmi_source_viewforsession.xml) - Deploy function (joins
pgmi_plan_viewwithpgmi_source_view)
Basic Template (deploy.sql)#
Similar changes - update pgmi_source to pgmi_source_view.
Documentation Updates#
User-Facing Documentation#
Only document the public API:
- Views:
pgmi_source_view,pgmi_parameter_view,pgmi_plan_view,pgmi_test_source_view - Functions:
pgmi_test_plan(),pgmi_test_generate() - Parameter access:
current_setting('pgmi.key', true)
Do NOT document:
- Internal tables (
_pgmi_*) - Internal functions (
pgmi_register_file,pgmi_validate_pattern,pgmi_has_tests)
CLAUDE.md Updates#
Update the “Session-Centric Deployment Model” section to reference views instead of tables.
pgmi ai Output#
Update embedded documentation to reference the public API only.
Testing Strategy#
Unit Tests (internal/contract/contract_test.go)#
func TestLoad_ValidVersion(t *testing.T) {
c, err := contract.Load("1")
require.NoError(t, err)
assert.Equal(t, contract.V1, c.Version)
assert.Contains(t, c.SQL, "pgmi_source_view")
}
func TestLoad_EmptyVersionUsesLatest(t *testing.T) {
c, err := contract.Load("")
require.NoError(t, err)
assert.Equal(t, contract.Latest, c.Version)
}
func TestLoad_UnsupportedVersion(t *testing.T) {
_, err := contract.Load("99")
require.Error(t, err)
assert.Contains(t, err.Error(), "unsupported API version")
}
func TestSupportedVersions(t *testing.T) {
versions := contract.SupportedVersions()
assert.Contains(t, versions, contract.V1)
}Integration Tests#
Template deployment with default version:
- Run existing
TestTemplateDeployment- should pass unchanged
- Run existing
Template deployment with explicit version:
func TestTemplateDeployment_ExplicitCompat(t *testing.T) { // Deploy with --compat=1 // Verify views exist // Verify deploy.sql can query pgmi_source_view }Unsupported version error:
func TestDeploy_UnsupportedCompat(t *testing.T) { // Deploy with --compat=99 // Expect clear error message listing supported versions }View functionality:
func TestAPIViews_MatchTableContent(t *testing.T) { // Insert test data into _pgmi_source // Query pgmi_source_view // Verify identical results }
Regression Tests#
Run full test suite to ensure nothing breaks:
make test-all
make test-integrationImplementation History#
Phase 1: SQL Refactoring#
Rename tables in
schema.sql:pgmi_source→_pgmi_sourcepgmi_source_metadata→_pgmi_source_metadatapgmi_parameter→_pgmi_parameterpgmi_test_directory→_pgmi_test_directorypgmi_test_source→_pgmi_test_source- Update
pgmi_register_file()to reference_pgmi_source - Update DROP TABLE statements at top
Update internal function references in
schema.sql:pgmi_has_tests()→ reference_pgmi_test_directory,_pgmi_test_sourcepgmi_test_plan()→ reference_pgmi_test_directory,_pgmi_test_source(Removed - templates handle parameters)pgmi_declare_param()→ reference_pgmi_parameter
Create
api-v1.sql:- Move
pgmi_plan_viewfromschema.sql(update to reference_pgmi_*) - Add simple views:
pgmi_source_view,pgmi_parameter_view, etc. - Move public functions:
pgmi_test_plan(), etc. (Note:pgmi_get_param()andpgmi_declare_param()were removed) - Add
pgmi_test_generate()function (migrate logic from Go preprocessor) - Add
pgmi_test_callback()default callback
- Move
Update loader code (
internal/files/loader/loader.go):pgmi_test_source→_pgmi_test_sourcepgmi_test_directory→_pgmi_test_directory
Phase 2: Go Infrastructure#
Create
internal/contractpackage:- Embed
api-v1.sql Load(version)functionSupportedVersions()function- Unit tests
- Embed
Update deployer (
internal/services/deployer.go):- Execute
schema.sqlfirst - Execute
api-v{N}.sqlsecond - Pass API version from config
- Execute
Update preprocessor (
internal/preprocessor/):- Remove inline SQL generation
- Call
pgmi_test_generate()to get replacement code - Simplify to: find macro → call function → replace
Add
--compatCLI flag:- Add to
internal/cli/deploy.go - Wire through config to deployer
- Default to latest
- Add to
Phase 3: Templates and Testing#
Update templates:
- Advanced:
pgmi_source→pgmi_source_view - Basic:
pgmi_source→pgmi_source_view - Verify
CALL pgmi_test()still works (now via generator)
- Advanced:
Run full test suite:
make test- unit testsmake test-integration- template deployment- Fix any regressions
Phase 4: Documentation#
Update documentation:
- CLAUDE.md - reference views, not tables
internal/ai/content/- update embedded docs- Template README files
Add version-specific tests:
- Test
--compat=1explicitly - Test unsupported version error
- Test default version behavior
- Test
Acceptance Criteria (Completed)#
CLI & Versioning#
-
--compat=1flag accepted bypgmi deploy - Omitting
--compatuses latest stable version -
--compat=99returns clear error with supported versions list
Schema Structure#
- Internal tables prefixed with
_(_pgmi_source,_pgmi_parameter, etc.) - Public views created (
pgmi_source_view,pgmi_parameter_view, etc.) -
pgmi_plan_viewinapi-v1.sql, references internal tables -
pgmi_test_generate()function exists and returns valid SQL
Macro Expansion#
-
CALL pgmi_test()expands viapgmi_test_generate()function call - Go preprocessor no longer contains inline SQL generation
- Generated code references internal tables (
_pgmi_test_source)
Templates#
- Advanced template uses
pgmi_source_view(notpgmi_source) - Basic template uses
pgmi_source_view(notpgmi_source) -
TestTemplateDeploymentpasses for both templates
Documentation#
-
pgmi aioutput only references public API (views, public functions) - CLAUDE.md references views, not tables
- No references to
_pgmi_*tables in user-facing documentation
Risks and Mitigations#
| Risk | Mitigation |
|---|---|
| Breaking existing deploy.sql scripts | Templates updated in same release; clear migration guide |
| Performance overhead from views | Views are simple SELECT * - optimizer eliminates overhead |
| Complexity of maintaining multiple versions | Start with v1 only; add v2 when actually needed |
| Forgetting to update templates | Integration tests catch missing view references |
pgmi_test_generate() SQL correctness | Port existing Go tests; verify generated SQL matches current output |
| Preprocessor now requires DB connection | Already true (files loaded before preprocessing) |
Future Considerations#
- V2 API: Only create when there’s a breaking change that can’t be backward-compatible
- Version deprecation warnings:
RAISE WARNING 'API v1 deprecated, migrate to v2' - Version discovery:
pgmi --supported-api-versionscommand - Per-project version pinning:
.pgmi.yamlwithapi-version: 1
Estimated Effort#
| Phase | Effort |
|---|---|
| Phase 1: SQL Refactoring | 3-4 hours |
| Phase 2: Go Infrastructure | 3-4 hours |
| Phase 3: Templates and Testing | 2-3 hours |
| Phase 4: Documentation | 1-2 hours |
| Total | 9-13 hours |
Note: The pgmi_test_generate() function is the most complex piece - it must produce identical output to the current Go preprocessor. Recommend implementing incrementally with comparison tests.
References#
Files to Modify#
| File | Changes |
|---|---|
internal/params/schema.sql | Rename tables to _pgmi_*, update internal function references |
internal/params/api-v1.sql | NEW: Views, public functions, pgmi_test_generate() |
internal/files/loader/loader.go | Update table references to _pgmi_* |
internal/preprocessor/preprocessor.go | Replace inline SQL with pgmi_test_generate() call |
internal/services/deployer.go | Execute api-v{N}.sql after schema, before preprocessing |
internal/cli/deploy.go | Add --compat flag |
internal/contract/contract.go | NEW: API version management |
internal/scaffold/templates/advanced/deploy.sql | pgmi_source → pgmi_source_view |
internal/scaffold/templates/basic/deploy.sql | pgmi_source → pgmi_source_view |
CLAUDE.md | Update session model documentation |
internal/ai/content/ | Update embedded AI documentation |
Key Test Files#
| File | Purpose |
|---|---|
internal/contract/contract_test.go | NEW: Version loading tests |
internal/preprocessor/preprocessor_test.go | Update for new expansion approach |
internal/scaffold/integration_test.go | Template deployment verification |
internal/params/schema_test.go | Schema object tests |
Grep Commands for Discovery#
# Find all references to pgmi_source (not _pgmi_source)
grep -r "pgmi_source" --include="*.go" --include="*.sql" | grep -v "_pgmi_source"
# Find all references to pgmi_parameter
grep -r "pgmi_parameter" --include="*.go" --include="*.sql" | grep -v "_pgmi_parameter"
# Find preprocessor SQL generation
grep -r "pgmi_test" internal/preprocessor/