pgcommon 0.1.0__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. {pgcommon-0.1.0 → pgcommon-0.2.0}/.github/workflows/ci.yml +13 -0
  2. {pgcommon-0.1.0 → pgcommon-0.2.0}/ARCHITECTURE.md +3 -3
  3. pgcommon-0.2.0/CHANGELOG.md +61 -0
  4. {pgcommon-0.1.0 → pgcommon-0.2.0}/PKG-INFO +5 -3
  5. {pgcommon-0.1.0 → pgcommon-0.2.0}/README.md +4 -2
  6. {pgcommon-0.1.0 → pgcommon-0.2.0}/docs/architecture/mermaid/runintx-lifecycle.mmd +1 -1
  7. {pgcommon-0.1.0 → pgcommon-0.2.0}/pyproject.toml +1 -1
  8. {pgcommon-0.1.0 → pgcommon-0.2.0}/src/pgcommon/config.py +47 -19
  9. {pgcommon-0.1.0 → pgcommon-0.2.0}/src/pgcommon/errors.py +9 -0
  10. {pgcommon-0.1.0 → pgcommon-0.2.0}/src/pgcommon/migrate/runner.py +31 -9
  11. {pgcommon-0.1.0 → pgcommon-0.2.0}/src/pgcommon/retry.py +4 -1
  12. {pgcommon-0.1.0 → pgcommon-0.2.0}/tests/fixtures/fake_migrate_conn.py +35 -5
  13. {pgcommon-0.1.0 → pgcommon-0.2.0}/tests/integration/test_migrate_runner_integration.py +5 -3
  14. {pgcommon-0.1.0 → pgcommon-0.2.0}/tests/unit/test_config.py +69 -3
  15. {pgcommon-0.1.0 → pgcommon-0.2.0}/tests/unit/test_migrate_runner.py +60 -1
  16. {pgcommon-0.1.0 → pgcommon-0.2.0}/tests/unit/test_retry.py +11 -0
  17. {pgcommon-0.1.0 → pgcommon-0.2.0}/uv.lock +1 -1
  18. pgcommon-0.1.0/CHANGELOG.md +0 -29
  19. {pgcommon-0.1.0 → pgcommon-0.2.0}/.env-example +0 -0
  20. {pgcommon-0.1.0 → pgcommon-0.2.0}/.github/CODEOWNERS +0 -0
  21. {pgcommon-0.1.0 → pgcommon-0.2.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  22. {pgcommon-0.1.0 → pgcommon-0.2.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  23. {pgcommon-0.1.0 → pgcommon-0.2.0}/.github/SECURITY.md +0 -0
  24. {pgcommon-0.1.0 → pgcommon-0.2.0}/.github/dependabot.yml +0 -0
  25. {pgcommon-0.1.0 → pgcommon-0.2.0}/.github/pull_request_template.md +0 -0
  26. {pgcommon-0.1.0 → pgcommon-0.2.0}/.github/workflows/quality.yml +0 -0
  27. {pgcommon-0.1.0 → pgcommon-0.2.0}/.github/workflows/release.yml +0 -0
  28. {pgcommon-0.1.0 → pgcommon-0.2.0}/.github/workflows/test.yml +0 -0
  29. {pgcommon-0.1.0 → pgcommon-0.2.0}/.github/workflows/validate.yml +0 -0
  30. {pgcommon-0.1.0 → pgcommon-0.2.0}/.gitignore +0 -0
  31. {pgcommon-0.1.0 → pgcommon-0.2.0}/CONTRIBUTING.md +0 -0
  32. {pgcommon-0.1.0 → pgcommon-0.2.0}/Dockerfile +0 -0
  33. {pgcommon-0.1.0 → pgcommon-0.2.0}/LICENSE +0 -0
  34. {pgcommon-0.1.0 → pgcommon-0.2.0}/Makefile +0 -0
  35. {pgcommon-0.1.0 → pgcommon-0.2.0}/SECURITY.md +0 -0
  36. {pgcommon-0.1.0 → pgcommon-0.2.0}/VERSIONING.md +0 -0
  37. {pgcommon-0.1.0 → pgcommon-0.2.0}/docker-compose.yml +0 -0
  38. {pgcommon-0.1.0 → pgcommon-0.2.0}/docs/Architecture.md +0 -0
  39. {pgcommon-0.1.0 → pgcommon-0.2.0}/docs/MigrationGuide.md +0 -0
  40. {pgcommon-0.1.0 → pgcommon-0.2.0}/docs/adr/ADR-001-contextvars-guc-propagation.md +0 -0
  41. {pgcommon-0.1.0 → pgcommon-0.2.0}/docs/adr/ADR-002-cancellation-and-rollback-safety.md +0 -0
  42. {pgcommon-0.1.0 → pgcommon-0.2.0}/docs/adr/ADR-003-guc-injection-timing.md +0 -0
  43. {pgcommon-0.1.0 → pgcommon-0.2.0}/docs/adr/ADR-004-migration-runner-design.md +0 -0
  44. {pgcommon-0.1.0 → pgcommon-0.2.0}/docs/architecture/README.md +0 -0
  45. {pgcommon-0.1.0 → pgcommon-0.2.0}/docs/architecture/mermaid/consuming-service-wiring.mmd +0 -0
  46. {pgcommon-0.1.0 → pgcommon-0.2.0}/docs/architecture/mermaid/documentation-assets.mmd +0 -0
  47. {pgcommon-0.1.0 → pgcommon-0.2.0}/docs/architecture/mermaid/layer-model.mmd +0 -0
  48. {pgcommon-0.1.0 → pgcommon-0.2.0}/docs/architecture/mermaid/migration-runner.mmd +0 -0
  49. {pgcommon-0.1.0 → pgcommon-0.2.0}/docs/architecture/mermaid/observability-stack.mmd +0 -0
  50. {pgcommon-0.1.0 → pgcommon-0.2.0}/docs/architecture/mermaid/pool-create-hook-composition.mmd +0 -0
  51. {pgcommon-0.1.0 → pgcommon-0.2.0}/docs/architecture/mermaid/runinsavepoint-lifecycle.mmd +0 -0
  52. {pgcommon-0.1.0 → pgcommon-0.2.0}/docs/architecture/mermaid/withconn-pgbouncer-mode.mmd +0 -0
  53. {pgcommon-0.1.0 → pgcommon-0.2.0}/docs/architecture/mermaid/withconn-standard-mode.mmd +0 -0
  54. {pgcommon-0.1.0 → pgcommon-0.2.0}/migrations/0001_create_example_table.down.sql +0 -0
  55. {pgcommon-0.1.0 → pgcommon-0.2.0}/migrations/0001_create_example_table.up.sql +0 -0
  56. {pgcommon-0.1.0 → pgcommon-0.2.0}/src/pgcommon/__init__.py +0 -0
  57. {pgcommon-0.1.0 → pgcommon-0.2.0}/src/pgcommon/_internal/__init__.py +0 -0
  58. {pgcommon-0.1.0 → pgcommon-0.2.0}/src/pgcommon/_internal/dsn.py +0 -0
  59. {pgcommon-0.1.0 → pgcommon-0.2.0}/src/pgcommon/_internal/duration.py +0 -0
  60. {pgcommon-0.1.0 → pgcommon-0.2.0}/src/pgcommon/_internal/query_event.py +0 -0
  61. {pgcommon-0.1.0 → pgcommon-0.2.0}/src/pgcommon/_internal/sql_verb.py +0 -0
  62. {pgcommon-0.1.0 → pgcommon-0.2.0}/src/pgcommon/_internal/sqlstate.py +0 -0
  63. {pgcommon-0.1.0 → pgcommon-0.2.0}/src/pgcommon/guc.py +0 -0
  64. {pgcommon-0.1.0 → pgcommon-0.2.0}/src/pgcommon/integrations/__init__.py +0 -0
  65. {pgcommon-0.1.0 → pgcommon-0.2.0}/src/pgcommon/integrations/fastapi.py +0 -0
  66. {pgcommon-0.1.0 → pgcommon-0.2.0}/src/pgcommon/logger.py +0 -0
  67. {pgcommon-0.1.0 → pgcommon-0.2.0}/src/pgcommon/metrics.py +0 -0
  68. {pgcommon-0.1.0 → pgcommon-0.2.0}/src/pgcommon/migrate/__init__.py +0 -0
  69. {pgcommon-0.1.0 → pgcommon-0.2.0}/src/pgcommon/migrate/__main__.py +0 -0
  70. {pgcommon-0.1.0 → pgcommon-0.2.0}/src/pgcommon/migrate/cli.py +0 -0
  71. {pgcommon-0.1.0 → pgcommon-0.2.0}/src/pgcommon/migrate/errors.py +0 -0
  72. {pgcommon-0.1.0 → pgcommon-0.2.0}/src/pgcommon/pool.py +0 -0
  73. {pgcommon-0.1.0 → pgcommon-0.2.0}/src/pgcommon/py.typed +0 -0
  74. {pgcommon-0.1.0 → pgcommon-0.2.0}/src/pgcommon/tracer.py +0 -0
  75. {pgcommon-0.1.0 → pgcommon-0.2.0}/src/pgcommon/tx.py +0 -0
  76. {pgcommon-0.1.0 → pgcommon-0.2.0}/tests/__init__.py +0 -0
  77. {pgcommon-0.1.0 → pgcommon-0.2.0}/tests/e2e/__init__.py +0 -0
  78. {pgcommon-0.1.0 → pgcommon-0.2.0}/tests/e2e/conftest.py +0 -0
  79. {pgcommon-0.1.0 → pgcommon-0.2.0}/tests/e2e/test_rls_pgbouncer_e2e.py +0 -0
  80. {pgcommon-0.1.0 → pgcommon-0.2.0}/tests/fixtures/__init__.py +0 -0
  81. {pgcommon-0.1.0 → pgcommon-0.2.0}/tests/fixtures/fake_asyncpg.py +0 -0
  82. {pgcommon-0.1.0 → pgcommon-0.2.0}/tests/fixtures/fakes.py +0 -0
  83. {pgcommon-0.1.0 → pgcommon-0.2.0}/tests/integration/__init__.py +0 -0
  84. {pgcommon-0.1.0 → pgcommon-0.2.0}/tests/integration/conftest.py +0 -0
  85. {pgcommon-0.1.0 → pgcommon-0.2.0}/tests/integration/test_concurrency_stress.py +0 -0
  86. {pgcommon-0.1.0 → pgcommon-0.2.0}/tests/integration/test_error_helpers_real_violations.py +0 -0
  87. {pgcommon-0.1.0 → pgcommon-0.2.0}/tests/integration/test_pool_lifecycle.py +0 -0
  88. {pgcommon-0.1.0 → pgcommon-0.2.0}/tests/integration/test_retry_real_serialization_failure.py +0 -0
  89. {pgcommon-0.1.0 → pgcommon-0.2.0}/tests/integration/test_rls_guc_injection.py +0 -0
  90. {pgcommon-0.1.0 → pgcommon-0.2.0}/tests/integration/test_tx_and_savepoint.py +0 -0
  91. {pgcommon-0.1.0 → pgcommon-0.2.0}/tests/unit/__init__.py +0 -0
  92. {pgcommon-0.1.0 → pgcommon-0.2.0}/tests/unit/test_errors.py +0 -0
  93. {pgcommon-0.1.0 → pgcommon-0.2.0}/tests/unit/test_guc.py +0 -0
  94. {pgcommon-0.1.0 → pgcommon-0.2.0}/tests/unit/test_integrations_fastapi.py +0 -0
  95. {pgcommon-0.1.0 → pgcommon-0.2.0}/tests/unit/test_internal.py +0 -0
  96. {pgcommon-0.1.0 → pgcommon-0.2.0}/tests/unit/test_logger.py +0 -0
  97. {pgcommon-0.1.0 → pgcommon-0.2.0}/tests/unit/test_metrics.py +0 -0
  98. {pgcommon-0.1.0 → pgcommon-0.2.0}/tests/unit/test_migrate_cli.py +0 -0
  99. {pgcommon-0.1.0 → pgcommon-0.2.0}/tests/unit/test_pool.py +0 -0
  100. {pgcommon-0.1.0 → pgcommon-0.2.0}/tests/unit/test_tracer.py +0 -0
  101. {pgcommon-0.1.0 → pgcommon-0.2.0}/tests/unit/test_tx.py +0 -0
@@ -78,6 +78,19 @@ jobs:
78
78
  path: dist/
79
79
  if-no-files-found: error
80
80
 
81
+ interop:
82
+ name: Cross-language compatibility
83
+ needs: build
84
+ uses: BCBP-SOLUTIONS-FZC-LLC/platform-interop-tests/.github/workflows/interop.yml@main
85
+ with:
86
+ # This repo's own PR/branch ref; the other five default to their main branch (see
87
+ # platform-interop-tests/README.md for why).
88
+ platform_pgcommon_py_ref: ${{ github.head_ref || github.ref_name }}
89
+ secrets:
90
+ # Reuses the org's existing GO_PRIVATE_TOKEN (Contents:Read across the six sibling
91
+ # repos) rather than provisioning a separate GH_PRIVATE_TOKEN for this repo alone.
92
+ gh_private_token: ${{ secrets.GO_PRIVATE_TOKEN }}
93
+
81
94
  lint-dockerfile:
82
95
  name: Lint Dockerfile
83
96
  runs-on: ubuntu-latest
@@ -277,7 +277,7 @@ sequenceDiagram
277
277
  run_in_tx ->> pool: pool._acquire() — shared acquire primitive (also used by with_conn)
278
278
 
279
279
  opt pgbouncer_mode AND guc_provider set and GUCSet non-zero
280
- run_in_tx ->> run_in_tx: guc_provider() -> GUCSet; guc_set.validate() [before BEGIN]
280
+ run_in_tx ->> run_in_tx: guc_provider() -> GUCSet, guc_set.validate() [before BEGIN]
281
281
  end
282
282
 
283
283
  run_in_tx ->>+ DB: conn.transaction(isolation=..., readonly=...) — BEGIN
@@ -359,7 +359,7 @@ flowchart TD
359
359
  T -- "up/down/steps" --> B["_load_migrations(source)\nparse NNNN_description.{up,down}.sql\nraise NoMigrationsFoundError if none match"]
360
360
  B --> C1["connect() -> SET lock_timeout = '30s'"]
361
361
  C1 --> D1["SELECT pg_advisory_lock(key)"]
362
- D1 --> E1["CREATE TABLE IF NOT EXISTS schema_migrations\n(only ever run while the lock is held —\nconcurrent CREATE TABLE IF NOT EXISTS is not race-safe in Postgres)"]
362
+ D1 --> E1["CREATE TABLE IF NOT EXISTS pgcommon_migrations\n(only ever run while the lock is held —\nconcurrent CREATE TABLE IF NOT EXISTS is not race-safe in Postgres)"]
363
363
  E1 --> F{"dirty flag set\nfrom a previous run?"}
364
364
  F -- yes --> G([raise MigrationDirtyError])
365
365
  F -- no --> H["apply/roll back the requested migrations,\none at a time, each in its own transaction"]
@@ -372,7 +372,7 @@ flowchart TD
372
372
 
373
373
  T -- "version" --> C2["connect() -> SET lock_timeout = '30s'\n(never calls _load_migrations — cannot raise NoMigrationsFoundError)"]
374
374
  C2 --> D2["SELECT pg_advisory_lock(key)"]
375
- D2 --> E2["CREATE TABLE IF NOT EXISTS schema_migrations\n(same race-avoidance as the up/down/steps path)"]
375
+ D2 --> E2["CREATE TABLE IF NOT EXISTS pgcommon_migrations\n(same race-avoidance as the up/down/steps path)"]
376
376
  E2 --> M["read (version, dirty) — returned as-is,\nno dirty check, never raises MigrationDirtyError"]
377
377
  M --> K2["SELECT pg_advisory_unlock(key) [always, in a finally block]"]
378
378
  K2 --> L
@@ -0,0 +1,61 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this project follows [semantic versioning](VERSIONING.md).
4
+
5
+ ## [Unreleased]
6
+
7
+ ## [0.2.0] - 2026-07-27
8
+
9
+ ### Added
10
+
11
+ - `migrate.Runner` accepts an optional `table` constructor parameter (default
12
+ `"pgcommon_migrations"`) naming the version-tracking table, validated against
13
+ `^[A-Za-z_][A-Za-z0-9_]*$`. Lets a library that ships its own migrations (e.g.
14
+ `eventcommon`'s outbox schema) track its version state in an isolated table, independent of
15
+ a consuming service's own `schema_migrations`/dirty-state. New `InvalidMigrationsTableError`
16
+ sentinel raised for an invalid name.
17
+
18
+ ### Fixed — cross-language compatibility with the Go `platform-pgcommon` library
19
+
20
+ - `migrate.Runner`'s default tracking table is `"pgcommon_migrations"`, not `"schema_migrations"`
21
+ — matching the Go library's own default, which is deliberately not `schema_migrations` so this
22
+ runner never collides with a consuming service's own `golang-migrate`-tracked table. Pass
23
+ `table="schema_migrations"` explicitly to adopt an existing `golang-migrate` deployment.
24
+ - `config_from_env()` now warns (`key="PG_USER/PG_DBNAME"`) and leaves `Config.dsn` empty when
25
+ `PG_USER`/`PG_DBNAME` are missing and `DATABASE_URL` is unset, instead of silently building an
26
+ unusable DSN — matching Go's `internal/config.Load()`.
27
+ - `PG_MAX_CONNS`/`PG_MIN_CONNS` now reject `0` (not just negative values), matching Go's
28
+ `parsePositiveInt32`.
29
+ - The insecure-`sslmode` warning is now keyed `"DATABASE_URL"` (and sourced only from the DSN's
30
+ own `sslmode` query parameter) when `DATABASE_URL` is set, matching Go's mutually-exclusive
31
+ branching — previously it was always keyed `"PG_SSLMODE"` and could miss a warning Go would
32
+ have emitted, or emit one Go wouldn't.
33
+ - `PG_BOUNCER_MODE` now matches only the exact string `"true"` (case-sensitive, no trimming),
34
+ matching Go's `os.Getenv("PG_BOUNCER_MODE") == "true"` — previously accepted a wider set of
35
+ truthy spellings (`"1"`, `"yes"`, `"on"`, ...) that Go's own config loader does not.
36
+ - `RetryOptions.multiplier < 1` is now clamped up to `1` (constant wait) instead of producing a
37
+ shrinking backoff, matching Go's `backoffWait`.
38
+
39
+ ## [0.1.0] - 2026-07-26
40
+
41
+ ### Changed
42
+
43
+ - Licensed under Apache-2.0 (was `Proprietary`) and published to public PyPI as `pgcommon`
44
+ via Trusted Publishing — the source repository itself stays private. See
45
+ [VERSIONING.md § Publishing to PyPI](VERSIONING.md#publishing-to-pypi).
46
+
47
+ ### Added
48
+
49
+ - Initial Python port of `platform-pgcommon` (Go) for `asyncio`/FastAPI services, built on `asyncpg`.
50
+ - `Config`, `config_from_env`, `ConfigWarning` (`pgcommon.config`).
51
+ - Sentinel errors and SQLSTATE helpers (`pgcommon.errors`).
52
+ - `GUCSet`, contextvars-based propagation, `inject_gucs` (`pgcommon.guc`).
53
+ - `Pool` — fail-fast construction, `with_conn`, `health`, `stat`, `drain_and_close` (`pgcommon.pool`).
54
+ - `run_in_tx`, `run_in_savepoint` (`pgcommon.tx`).
55
+ - `RetryOptions`, `run_in_tx_with_retry`, `run_in_tx_with_retry_opts` (`pgcommon.retry`).
56
+ - `SlowQueryTracer` (`pgcommon.tracer`).
57
+ - Prometheus metrics + `OTelQueryRecorder` (`pgcommon.metrics`).
58
+ - Dependency-light `.sql` migration `Runner` + `pgcommon-migrate` console script (`pgcommon.migrate`).
59
+ - Optional FastAPI integration: lifespan pool wiring, `GUCAuthMiddleware`, `/healthz` helper (`pgcommon.integrations.fastapi`, `pgcommon[fastapi]` extra).
60
+ - Optional `structlog` `Logger` adapter (`pgcommon[structlog]` extra).
61
+ - Unit, integration (testcontainers Postgres), and e2e (testcontainers Postgres + PgBouncer) test suites.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pgcommon
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Opinionated async PostgreSQL access layer for platform services — RLS tenancy, retries, and observability by default.
5
5
  Project-URL: Homepage, https://github.com/BCBP-SOLUTIONS-FZC-LLC/platform-pgcommon-py
6
6
  Project-URL: Repository, https://github.com/BCBP-SOLUTIONS-FZC-LLC/platform-pgcommon-py
@@ -835,7 +835,7 @@ try:
835
835
  await runner.up() # apply all pending migrations
836
836
  except MigrationDirtyError:
837
837
  # A previous migration failed mid-run. Fix the migration and manually
838
- # clear the schema_migrations.dirty flag before retrying.
838
+ # clear the pgcommon_migrations.dirty flag before retrying.
839
839
  raise
840
840
 
841
841
  version, dirty = await runner.version() # report current schema version
@@ -843,10 +843,12 @@ await runner.down(1) # roll back one step
843
843
  await runner.steps(2) # apply exactly two steps forward (or -2 to roll back two)
844
844
  ```
845
845
 
846
- Migration files follow the `NNNN_description.up.sql` / `NNNN_description.down.sql` naming convention (see [`migrations/`](migrations/) for the shipped example pair). Applied versions are tracked in a `schema_migrations(version bigint, dirty boolean)` table, created automatically if absent.
846
+ Migration files follow the `NNNN_description.up.sql` / `NNNN_description.down.sql` naming convention (see [`migrations/`](migrations/) for the shipped example pair). Applied versions are tracked in a `pgcommon_migrations(version bigint, dirty boolean)` table, created automatically if absent. The default is deliberately *not* `schema_migrations` — matching the Go `pgcommon` library's own default — so this runner never collides with a consuming service's own `golang-migrate`-tracked `schema_migrations` table.
847
847
 
848
848
  > **Custom migration source:** pass any directory path as `Runner`'s second argument — it is read fresh on every `up`/`down`/`steps` call, so no code changes are needed to add migration files.
849
849
 
850
+ > **Isolated tracking table:** pass `table="my_table"` to track versions in a table other than the default — e.g. a library that ships its own migrations (bundled with the package, applied via its own `Runner` instance) uses a dedicated table so its version/dirty state never collides with the consuming service's own domain migrations run against the same database. Pass `table="schema_migrations"` explicitly to adopt an existing `golang-migrate`-based deployment's tracking table instead.
851
+
850
852
  ---
851
853
 
852
854
  ## Observability — Prometheus and OTel
@@ -809,7 +809,7 @@ try:
809
809
  await runner.up() # apply all pending migrations
810
810
  except MigrationDirtyError:
811
811
  # A previous migration failed mid-run. Fix the migration and manually
812
- # clear the schema_migrations.dirty flag before retrying.
812
+ # clear the pgcommon_migrations.dirty flag before retrying.
813
813
  raise
814
814
 
815
815
  version, dirty = await runner.version() # report current schema version
@@ -817,10 +817,12 @@ await runner.down(1) # roll back one step
817
817
  await runner.steps(2) # apply exactly two steps forward (or -2 to roll back two)
818
818
  ```
819
819
 
820
- Migration files follow the `NNNN_description.up.sql` / `NNNN_description.down.sql` naming convention (see [`migrations/`](migrations/) for the shipped example pair). Applied versions are tracked in a `schema_migrations(version bigint, dirty boolean)` table, created automatically if absent.
820
+ Migration files follow the `NNNN_description.up.sql` / `NNNN_description.down.sql` naming convention (see [`migrations/`](migrations/) for the shipped example pair). Applied versions are tracked in a `pgcommon_migrations(version bigint, dirty boolean)` table, created automatically if absent. The default is deliberately *not* `schema_migrations` — matching the Go `pgcommon` library's own default — so this runner never collides with a consuming service's own `golang-migrate`-tracked `schema_migrations` table.
821
821
 
822
822
  > **Custom migration source:** pass any directory path as `Runner`'s second argument — it is read fresh on every `up`/`down`/`steps` call, so no code changes are needed to add migration files.
823
823
 
824
+ > **Isolated tracking table:** pass `table="my_table"` to track versions in a table other than the default — e.g. a library that ships its own migrations (bundled with the package, applied via its own `Runner` instance) uses a dedicated table so its version/dirty state never collides with the consuming service's own domain migrations run against the same database. Pass `table="schema_migrations"` explicitly to adopt an existing `golang-migrate`-based deployment's tracking table instead.
825
+
824
826
  ---
825
827
 
826
828
  ## Observability — Prometheus and OTel
@@ -7,7 +7,7 @@ sequenceDiagram
7
7
  run_in_tx ->> pool: pool._acquire() — shared acquire primitive (also used by with_conn)
8
8
 
9
9
  opt pgbouncer_mode AND guc_provider set and GUCSet non-zero
10
- run_in_tx ->> run_in_tx: guc_provider() -> GUCSet; guc_set.validate() [before BEGIN]
10
+ run_in_tx ->> run_in_tx: guc_provider() -> GUCSet, guc_set.validate() [before BEGIN]
11
11
  end
12
12
 
13
13
  run_in_tx ->>+ DB: conn.transaction(isolation=..., readonly=...) — BEGIN
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pgcommon"
3
- version = "0.1.0"
3
+ version = "0.2.0"
4
4
  description = "Opinionated async PostgreSQL access layer for platform services — RLS tenancy, retries, and observability by default."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -98,9 +98,9 @@ def _parse_positive_int(env_var: str, default: int) -> tuple[int, ConfigWarning
98
98
  return default, ConfigWarning(
99
99
  key=env_var, reason=f"invalid integer {raw!r}, using default {default}"
100
100
  )
101
- if parsed < 0:
101
+ if parsed <= 0:
102
102
  return default, ConfigWarning(
103
- key=env_var, reason=f"value {parsed} must be >= 0, using default {default}"
103
+ key=env_var, reason=f"value {parsed} must be positive, using default {default}"
104
104
  )
105
105
  return parsed, None
106
106
 
@@ -119,17 +119,32 @@ def _parse_duration_with_warning(
119
119
  )
120
120
 
121
121
 
122
- def _parse_bool(env_var: str, default: bool) -> bool:
123
- raw = os.environ.get(env_var)
124
- if raw is None:
125
- return default
126
- return raw.strip().lower() in ("1", "true", "t", "yes", "y", "on")
122
+ def _is_pgbouncer_mode() -> bool:
123
+ # Exact-string match, no trimming/case-folding — mirrors Go's `os.Getenv(...) == "true"`
124
+ # precisely, since a shared .env file/Helm value must behave identically for either language.
125
+ return os.environ.get("PG_BOUNCER_MODE") == "true"
126
+
127
127
 
128
+ @dataclass(frozen=True, slots=True)
129
+ class _DSNResult:
130
+ dsn: str
131
+ warning: ConfigWarning | None
132
+ # sslmode + the warning key to use if it's insecure — kept alongside the DSN because Go's
133
+ # two DSN-construction branches (DATABASE_URL vs PG_* parts) each have their own,
134
+ # mutually-exclusive sslmode source and warning key; there's no single global sslmode check.
135
+ sslmode: str | None
136
+ sslmode_warning_key: str
128
137
 
129
- def _build_dsn() -> tuple[str, ConfigWarning | None]:
138
+
139
+ def _build_dsn() -> _DSNResult:
130
140
  database_url = os.environ.get("DATABASE_URL")
131
141
  if database_url:
132
- return database_url, None
142
+ return _DSNResult(
143
+ dsn=database_url,
144
+ warning=None,
145
+ sslmode=_sslmode_of(database_url),
146
+ sslmode_warning_key="DATABASE_URL",
147
+ )
133
148
 
134
149
  host = os.environ.get("PG_HOST", "localhost")
135
150
  port = os.environ.get("PG_PORT", "5432")
@@ -138,9 +153,21 @@ def _build_dsn() -> tuple[str, ConfigWarning | None]:
138
153
  dbname = os.environ.get("PG_DBNAME", "")
139
154
  sslmode = os.environ.get("PG_SSLMODE", "require")
140
155
 
141
- userinfo = f"{user}:{password}@" if user or password else ""
156
+ if not user or not dbname:
157
+ return _DSNResult(
158
+ dsn="",
159
+ warning=ConfigWarning(
160
+ key="PG_USER/PG_DBNAME",
161
+ reason="both PG_USER and PG_DBNAME are required to build a DSN; database "
162
+ "connections will fail",
163
+ ),
164
+ sslmode=sslmode,
165
+ sslmode_warning_key="PG_SSLMODE",
166
+ )
167
+
168
+ userinfo = f"{user}:{password}@"
142
169
  dsn = f"postgres://{userinfo}{host}:{port}/{dbname}?sslmode={sslmode}"
143
- return dsn, None
170
+ return _DSNResult(dsn=dsn, warning=None, sslmode=sslmode, sslmode_warning_key="PG_SSLMODE")
144
171
 
145
172
 
146
173
  def _sslmode_of(dsn: str) -> str | None:
@@ -162,9 +189,10 @@ def config_from_env() -> tuple[Config, list[ConfigWarning]]:
162
189
  """
163
190
  warnings: list[ConfigWarning] = []
164
191
 
165
- dsn, dsn_warning = _build_dsn()
166
- if dsn_warning:
167
- warnings.append(dsn_warning)
192
+ dsn_result = _build_dsn()
193
+ dsn = dsn_result.dsn
194
+ if dsn_result.warning:
195
+ warnings.append(dsn_result.warning)
168
196
 
169
197
  max_conns, w = _parse_positive_int("PG_MAX_CONNS", _DEFAULT_MAX_CONNS)
170
198
  if w:
@@ -197,14 +225,14 @@ def config_from_env() -> tuple[Config, list[ConfigWarning]]:
197
225
  )
198
226
  if w:
199
227
  warnings.append(w)
200
- pgbouncer_mode = _parse_bool("PG_BOUNCER_MODE", False)
228
+ pgbouncer_mode = _is_pgbouncer_mode()
201
229
 
202
- sslmode = os.environ.get("PG_SSLMODE") or _sslmode_of(dsn)
203
- if sslmode and sslmode.lower() in _INSECURE_SSLMODES:
230
+ if dsn_result.sslmode and dsn_result.sslmode.lower() in _INSECURE_SSLMODES:
204
231
  warnings.append(
205
232
  ConfigWarning(
206
- key="PG_SSLMODE",
207
- reason=f"sslmode={sslmode!r} does not enforce TLS — use 'require' or stronger in production",
233
+ key=dsn_result.sslmode_warning_key,
234
+ reason=f"sslmode={dsn_result.sslmode!r} does not enforce TLS — use 'require' or "
235
+ "stronger in production",
208
236
  )
209
237
  )
210
238
 
@@ -22,6 +22,7 @@ __all__ = [
22
22
  "EmptyTenantRoleError",
23
23
  "InconsistentGUCSetError",
24
24
  "InvalidMaxConnsError",
25
+ "InvalidMigrationsTableError",
25
26
  "InvalidMinConnsError",
26
27
  "InvalidSavepointNameError",
27
28
  "InvalidTenantRoleError",
@@ -84,6 +85,14 @@ class MigrationDirtyError(PgCommonError):
84
85
  """Raised when the schema is left dirty by a previously failed migration run."""
85
86
 
86
87
 
88
+ class InvalidMigrationsTableError(PgCommonError):
89
+ """Raised by `migrate.Runner` when `table` fails `^[A-Za-z_][A-Za-z0-9_]*$`.
90
+
91
+ The table name is interpolated directly into SQL (identifiers cannot be bound as query
92
+ parameters), so it is validated up front rather than escaped.
93
+ """
94
+
95
+
87
96
  class NoMigrationsFoundError(PgCommonError):
88
97
  """Raised when the migration source directory has no matching `*.sql` files."""
89
98
 
@@ -3,9 +3,9 @@
3
3
  Deliberately not built on Alembic — Alembic assumes SQLAlchemy models, which this library does
4
4
  not have. This is a from-scratch, `golang-migrate`-equivalent runner: it reads
5
5
  `NNNN_description.up.sql` / `NNNN_description.down.sql` pairs from a directory, tracks the
6
- applied version in a `schema_migrations` table, and takes a Postgres advisory lock for the
7
- duration of the run so two runners started concurrently (e.g. two replicas booting at once)
8
- don't race each other.
6
+ applied version in a tracking table (default `pgcommon_migrations`, see `Runner.__init__`'s
7
+ `table` param), and takes a Postgres advisory lock for the duration of the run so two runners
8
+ started concurrently (e.g. two replicas booting at once) don't race each other.
9
9
 
10
10
  ⚠️ **Never run migrations from every replica's boot path simultaneously.** The advisory lock
11
11
  here only serializes the runs — it does not make it safe for the application to start against
@@ -26,7 +26,7 @@ from typing import TypeVar
26
26
 
27
27
  import asyncpg
28
28
 
29
- from pgcommon.errors import MigrationDirtyError, NoMigrationsFoundError
29
+ from pgcommon.errors import InvalidMigrationsTableError, MigrationDirtyError, NoMigrationsFoundError
30
30
  from pgcommon.logger import Logger
31
31
 
32
32
  __all__ = ["Migration", "Runner"]
@@ -34,6 +34,7 @@ __all__ = ["Migration", "Runner"]
34
34
  T = TypeVar("T")
35
35
 
36
36
  _FILENAME_RE = re.compile(r"^(?P<version>\d+)_(?P<description>.+)\.(?P<direction>up|down)\.sql$")
37
+ _TABLE_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
37
38
  _ADVISORY_LOCK_KEY = zlib.crc32(b"pgcommon-migrate")
38
39
  _LOCK_TIMEOUT_SECONDS = 30
39
40
 
@@ -90,12 +91,33 @@ class Runner:
90
91
 
91
92
  `source` defaults are the caller's responsibility — pass an absolute or relative path to a
92
93
  directory containing `NNNN_description.up.sql` / `NNNN_description.down.sql` pairs.
94
+
95
+ `table` names the version-tracking table (default `"pgcommon_migrations"` — deliberately not
96
+ `"schema_migrations"`, so this runner never collides with a consuming service's own
97
+ `golang-migrate`/`schema_migrations`-based migrations, matching the Go `pgcommon` library's
98
+ default). Pass a distinct name to run an isolated set of migrations against the same database
99
+ without colliding with another `Runner`'s tracking table or dirty-state — e.g. a library that
100
+ ships its own migrations (like `eventcommon`'s outbox schema) alongside a consuming service's
101
+ own domain migrations, or to explicitly opt into reading/writing an existing
102
+ `"schema_migrations"` table from a prior `golang-migrate` deployment. Must match
103
+ `^[A-Za-z_][A-Za-z0-9_]*$`; it is interpolated directly into SQL since identifiers cannot be
104
+ bound as query parameters.
93
105
  """
94
106
 
95
- def __init__(self, dsn: str, source: str | Path, *, logger: Logger | None = None) -> None:
107
+ def __init__(
108
+ self,
109
+ dsn: str,
110
+ source: str | Path,
111
+ *,
112
+ logger: Logger | None = None,
113
+ table: str = "pgcommon_migrations",
114
+ ) -> None:
115
+ if not _TABLE_NAME_RE.match(table):
116
+ raise InvalidMigrationsTableError(f"invalid migrations table name: {table!r}")
96
117
  self._dsn = dsn
97
118
  self._source = Path(source)
98
119
  self._logger = logger
120
+ self._table = table
99
121
 
100
122
  async def up(self) -> None:
101
123
  """Apply all pending migrations, in ascending version order."""
@@ -131,21 +153,21 @@ class Runner:
131
153
 
132
154
  async def _ensure_schema_migrations_table(self, conn: asyncpg.Connection) -> None:
133
155
  await conn.execute(
134
- "CREATE TABLE IF NOT EXISTS schema_migrations ("
156
+ f"CREATE TABLE IF NOT EXISTS {self._table} ("
135
157
  "version bigint PRIMARY KEY, dirty boolean NOT NULL DEFAULT false)"
136
158
  )
137
159
 
138
160
  async def _current_version(self, conn: asyncpg.Connection) -> tuple[int, bool]:
139
- row = await conn.fetchrow("SELECT version, dirty FROM schema_migrations LIMIT 1")
161
+ row = await conn.fetchrow(f"SELECT version, dirty FROM {self._table} LIMIT 1")
140
162
  if row is None:
141
163
  return 0, False
142
164
  return row["version"], row["dirty"]
143
165
 
144
166
  async def _set_version(self, conn: asyncpg.Connection, version: int, *, dirty: bool) -> None:
145
167
  async with conn.transaction():
146
- await conn.execute("DELETE FROM schema_migrations")
168
+ await conn.execute(f"DELETE FROM {self._table}")
147
169
  await conn.execute(
148
- "INSERT INTO schema_migrations (version, dirty) VALUES ($1, $2)", version, dirty
170
+ f"INSERT INTO {self._table} (version, dirty) VALUES ($1, $2)", version, dirty
149
171
  )
150
172
 
151
173
  async def _with_advisory_lock(
@@ -53,7 +53,10 @@ def _retry_reason(exc: BaseException) -> str | None:
53
53
 
54
54
 
55
55
  def _compute_delay_seconds(opts: RetryOptions, attempt: int) -> float:
56
- delay = opts.initial_wait.total_seconds() * (opts.multiplier ** (attempt - 1))
56
+ # A multiplier < 1 would shrink the delay on every retry instead of growing it — Go's
57
+ # backoffWait clamps this up to 1 (constant wait) rather than allowing it.
58
+ multiplier = max(opts.multiplier, 1.0)
59
+ delay = opts.initial_wait.total_seconds() * (multiplier ** (attempt - 1))
57
60
  max_wait_seconds = opts.max_wait.total_seconds()
58
61
  if max_wait_seconds > 0:
59
62
  delay = min(delay, max_wait_seconds)
@@ -8,8 +8,15 @@ letting a specific migration's SQL fail on demand) without a database.
8
8
 
9
9
  from __future__ import annotations
10
10
 
11
+ import re
11
12
  from dataclasses import dataclass, field
12
13
 
14
+ _DEFAULT_TABLE = "pgcommon_migrations"
15
+ _CREATE_TABLE_RE = re.compile(r"CREATE TABLE IF NOT EXISTS (\w+)")
16
+ _SELECT_VERSION_RE = re.compile(r"SELECT version, dirty FROM (\w+)")
17
+ _DELETE_RE = re.compile(r"DELETE FROM (\w+)")
18
+ _INSERT_RE = re.compile(r"INSERT INTO (\w+)")
19
+
13
20
 
14
21
  class _NoOpTransaction:
15
22
  async def __aenter__(self) -> _NoOpTransaction:
@@ -21,7 +28,15 @@ class _NoOpTransaction:
21
28
 
22
29
  @dataclass
23
30
  class FakeMigrationConnection:
31
+ """Tracks per-table schema state so tests can drive two `Runner`s (different `table=`)
32
+ against the same fake connection and assert their version bookkeeping stays independent.
33
+
34
+ `schema_migrations` is kept as a plain attribute (not nested under `tables`) for backward
35
+ compatibility with existing tests that only ever use the default table name.
36
+ """
37
+
24
38
  schema_migrations: dict[str, object] | None = None
39
+ tables: dict[str, dict[str, object] | None] = field(default_factory=dict)
25
40
  executed_migration_sql: list[str] = field(default_factory=list)
26
41
  lock_calls: list[int] = field(default_factory=list)
27
42
  unlock_calls: list[int] = field(default_factory=list)
@@ -29,12 +44,24 @@ class FakeMigrationConnection:
29
44
  closed: bool = False
30
45
  call_order: list[str] = field(default_factory=list)
31
46
 
47
+ def _get_table_state(self, table: str) -> dict[str, object] | None:
48
+ if table == _DEFAULT_TABLE:
49
+ return self.schema_migrations
50
+ return self.tables.get(table)
51
+
52
+ def _set_table_state(self, table: str, state: dict[str, object] | None) -> None:
53
+ if table == _DEFAULT_TABLE:
54
+ self.schema_migrations = state
55
+ else:
56
+ self.tables[table] = state
57
+
32
58
  async def execute(self, sql: str, *args: object) -> str:
33
59
  stripped = sql.strip()
34
60
  if stripped.startswith("SET lock_timeout"):
35
61
  return "SET"
36
- if stripped.startswith("CREATE TABLE IF NOT EXISTS schema_migrations"):
62
+ if m := _CREATE_TABLE_RE.match(stripped):
37
63
  self.call_order.append("create_table")
64
+ self.tables.setdefault(m.group(1), self.tables.get(m.group(1)))
38
65
  return "CREATE TABLE"
39
66
  if stripped.startswith("SELECT pg_advisory_lock"):
40
67
  self.call_order.append("lock")
@@ -43,12 +70,12 @@ class FakeMigrationConnection:
43
70
  if stripped.startswith("SELECT pg_advisory_unlock"):
44
71
  self.unlock_calls.append(args[0]) # type: ignore[arg-type]
45
72
  return "SELECT"
46
- if stripped.startswith("DELETE FROM schema_migrations"):
47
- self.schema_migrations = None
73
+ if m := _DELETE_RE.match(stripped):
74
+ self._set_table_state(m.group(1), None)
48
75
  return "DELETE"
49
- if stripped.startswith("INSERT INTO schema_migrations"):
76
+ if m := _INSERT_RE.match(stripped):
50
77
  version, dirty = args
51
- self.schema_migrations = {"version": version, "dirty": dirty}
78
+ self._set_table_state(m.group(1), {"version": version, "dirty": dirty})
52
79
  return "INSERT"
53
80
 
54
81
  self.executed_migration_sql.append(stripped)
@@ -57,6 +84,9 @@ class FakeMigrationConnection:
57
84
  return "SELECT"
58
85
 
59
86
  async def fetchrow(self, sql: str, *args: object) -> dict[str, object] | None:
87
+ stripped = sql.strip()
88
+ if m := _SELECT_VERSION_RE.match(stripped):
89
+ return self._get_table_state(m.group(1))
60
90
  return self.schema_migrations
61
91
 
62
92
  def transaction(self, **_kwargs: object) -> _NoOpTransaction:
@@ -35,9 +35,11 @@ def migrations_dir(tmp_path: Path, table: str) -> Path:
35
35
 
36
36
  @pytest.fixture(autouse=True)
37
37
  async def _drop_schema_migrations(postgres_dsn: str, pg_conn):
38
- # Each integration test file shares one Postgres container; keep schema_migrations state
39
- # isolated per test by resetting it before every test in this module.
40
- await pg_conn.execute("DROP TABLE IF EXISTS schema_migrations")
38
+ # Each integration test file shares one Postgres container; keep the tracking table's state
39
+ # isolated per test by resetting it before every test in this module. "pgcommon_migrations"
40
+ # is the Runner's default (see runner.py) — deliberately not "schema_migrations", so this
41
+ # library never collides with a consuming service's own golang-migrate-tracked table.
42
+ await pg_conn.execute("DROP TABLE IF EXISTS pgcommon_migrations")
41
43
  yield
42
44
 
43
45
 
@@ -97,20 +97,46 @@ def test_config_from_env_builds_dsn_from_pg_vars(monkeypatch: pytest.MonkeyPatch
97
97
 
98
98
 
99
99
  def test_config_from_env_defaults_host_and_sslmode(monkeypatch: pytest.MonkeyPatch) -> None:
100
- cfg, _ = config_from_env()
101
- assert cfg.dsn == "postgres://localhost:5432/?sslmode=require"
100
+ # PG_USER/PG_DBNAME are required to build a DSN from parts (see the dedicated warning test
101
+ # below) — set them here so this test isolates the host/port/sslmode defaults.
102
+ monkeypatch.setenv("PG_USER", "svc")
103
+ monkeypatch.setenv("PG_DBNAME", "orders")
104
+ cfg, warnings = config_from_env()
105
+ assert cfg.dsn == "postgres://svc:@localhost:5432/orders?sslmode=require"
106
+ assert warnings == []
107
+
108
+
109
+ def test_config_from_env_warns_when_user_or_dbname_missing(
110
+ monkeypatch: pytest.MonkeyPatch,
111
+ ) -> None:
112
+ # Matches Go's buildDSN: DSN is left empty (not a syntactically-valid-but-unusable string)
113
+ # when either PG_USER or PG_DBNAME is missing, so Pool.create fails fast with a clear error.
114
+ cfg, warnings = config_from_env()
115
+ assert cfg.dsn == ""
116
+ assert any(w.key == "PG_USER/PG_DBNAME" for w in warnings)
117
+
118
+
119
+ def test_config_from_env_warns_when_only_dbname_missing(monkeypatch: pytest.MonkeyPatch) -> None:
120
+ monkeypatch.setenv("PG_USER", "svc")
121
+ cfg, warnings = config_from_env()
122
+ assert cfg.dsn == ""
123
+ assert any(w.key == "PG_USER/PG_DBNAME" for w in warnings)
102
124
 
103
125
 
104
126
  @pytest.mark.parametrize("sslmode", ["disable", "allow", "prefer"])
105
127
  def test_config_from_env_warns_on_insecure_sslmode(
106
128
  monkeypatch: pytest.MonkeyPatch, sslmode: str
107
129
  ) -> None:
130
+ monkeypatch.setenv("PG_USER", "svc")
131
+ monkeypatch.setenv("PG_DBNAME", "orders")
108
132
  monkeypatch.setenv("PG_SSLMODE", sslmode)
109
133
  _, warnings = config_from_env()
110
134
  assert any(w.key == "PG_SSLMODE" for w in warnings)
111
135
 
112
136
 
113
137
  def test_config_from_env_no_warning_for_require(monkeypatch: pytest.MonkeyPatch) -> None:
138
+ monkeypatch.setenv("PG_USER", "svc")
139
+ monkeypatch.setenv("PG_DBNAME", "orders")
114
140
  monkeypatch.setenv("PG_SSLMODE", "require")
115
141
  _, warnings = config_from_env()
116
142
  assert warnings == []
@@ -121,10 +147,26 @@ def test_config_from_env_warns_on_insecure_sslmode_in_database_url(
121
147
  ) -> None:
122
148
  monkeypatch.setenv("DATABASE_URL", "postgres://user:pass@host:5432/db?sslmode=disable")
123
149
  _, warnings = config_from_env()
124
- assert any(w.key == "PG_SSLMODE" for w in warnings)
150
+ # Once DATABASE_URL is set, Go inspects only the DSN's own sslmode query param (never
151
+ # PG_SSLMODE) and keys the warning "DATABASE_URL", not "PG_SSLMODE".
152
+ assert any(w.key == "DATABASE_URL" for w in warnings)
153
+
154
+
155
+ def test_config_from_env_ignores_pg_sslmode_env_var_once_database_url_is_set(
156
+ monkeypatch: pytest.MonkeyPatch,
157
+ ) -> None:
158
+ # A secure PG_SSLMODE env var must not mask an insecure sslmode inside DATABASE_URL itself —
159
+ # and, symmetrically, a "secure-looking" DATABASE_URL must not be second-guessed by a
160
+ # leftover insecure PG_SSLMODE env var either. DATABASE_URL's own query param always wins.
161
+ monkeypatch.setenv("PG_SSLMODE", "require")
162
+ monkeypatch.setenv("DATABASE_URL", "postgres://user:pass@host:5432/db?sslmode=disable")
163
+ _, warnings = config_from_env()
164
+ assert any(w.key == "DATABASE_URL" for w in warnings)
125
165
 
126
166
 
127
167
  def test_config_from_env_parses_durations(monkeypatch: pytest.MonkeyPatch) -> None:
168
+ monkeypatch.setenv("PG_USER", "svc")
169
+ monkeypatch.setenv("PG_DBNAME", "orders")
128
170
  monkeypatch.setenv("PG_MAX_CONN_LIFETIME", "2h")
129
171
  monkeypatch.setenv("PG_SLOW_QUERY_THRESHOLD", "500ms")
130
172
  cfg, warnings = config_from_env()
@@ -160,12 +202,34 @@ def test_config_from_env_warns_and_defaults_on_negative_int(
160
202
  assert any(w.key == "PG_MIN_CONNS" for w in warnings)
161
203
 
162
204
 
205
+ def test_config_from_env_warns_and_defaults_on_zero_int(
206
+ monkeypatch: pytest.MonkeyPatch,
207
+ ) -> None:
208
+ # Zero must be rejected too, not just negative values — matches Go's parsePositiveInt32,
209
+ # which checks `v <= 0`, not `v < 0`.
210
+ monkeypatch.setenv("PG_MAX_CONNS", "0")
211
+ cfg, warnings = config_from_env()
212
+ assert cfg.max_conns == 10
213
+ assert any(w.key == "PG_MAX_CONNS" for w in warnings)
214
+
215
+
163
216
  def test_config_from_env_parses_bouncer_mode(monkeypatch: pytest.MonkeyPatch) -> None:
164
217
  monkeypatch.setenv("PG_BOUNCER_MODE", "true")
165
218
  cfg, _ = config_from_env()
166
219
  assert cfg.pgbouncer_mode is True
167
220
 
168
221
 
222
+ @pytest.mark.parametrize("value", ["1", "yes", "True", "TRUE", "on", " true", "true "])
223
+ def test_config_from_env_bouncer_mode_requires_exact_lowercase_true(
224
+ monkeypatch: pytest.MonkeyPatch, value: str
225
+ ) -> None:
226
+ # Go matches only the exact string "true" (os.Getenv(...) == "true") — no case-folding, no
227
+ # trimming, no other truthy spellings. A shared .env file must behave identically either way.
228
+ monkeypatch.setenv("PG_BOUNCER_MODE", value)
229
+ cfg, _ = config_from_env()
230
+ assert cfg.pgbouncer_mode is False
231
+
232
+
169
233
  @pytest.mark.parametrize(
170
234
  "env_var",
171
235
  [
@@ -191,6 +255,8 @@ def test_config_from_env_no_warning_when_database_url_has_no_sslmode(
191
255
 
192
256
 
193
257
  def test_config_from_env_min_conns_from_env(monkeypatch: pytest.MonkeyPatch) -> None:
258
+ monkeypatch.setenv("PG_USER", "svc")
259
+ monkeypatch.setenv("PG_DBNAME", "orders")
194
260
  monkeypatch.setenv("PG_MIN_CONNS", "5")
195
261
  cfg, warnings = config_from_env()
196
262
  assert cfg.min_conns == 5
@@ -5,7 +5,7 @@ from pathlib import Path
5
5
  import pytest
6
6
 
7
7
  import pgcommon.migrate.runner as runner_module
8
- from pgcommon.errors import MigrationDirtyError, NoMigrationsFoundError
8
+ from pgcommon.errors import InvalidMigrationsTableError, MigrationDirtyError, NoMigrationsFoundError
9
9
  from pgcommon.migrate.runner import Runner, _load_migrations
10
10
  from tests.fixtures.fake_migrate_conn import FakeMigrationConnection
11
11
  from tests.fixtures.fakes import FakeLogger
@@ -363,3 +363,62 @@ def test_load_migrations_skips_subdirectories(tmp_path: Path) -> None:
363
363
  (d / "0002_not_a_file.up.sql").mkdir() # a directory that happens to match the pattern
364
364
  migrations = _load_migrations(d)
365
365
  assert set(migrations) == {1}
366
+
367
+
368
+ # --- table= (isolated tracking table name) ---
369
+
370
+
371
+ def test_default_table_name_is_pgcommon_migrations(migrations_dir: Path) -> None:
372
+ runner = Runner("postgres://localhost/db", migrations_dir)
373
+ assert runner._table == "pgcommon_migrations"
374
+
375
+
376
+ async def test_can_override_to_schema_migrations_for_golang_migrate_compat(
377
+ migrations_dir: Path, patch_connect: FakeMigrationConnection
378
+ ) -> None:
379
+ # golang-migrate (and any pre-existing golang-migrate-based deployment) tracks versions in
380
+ # "schema_migrations" — a caller adopting that table explicitly opts in via table=.
381
+ runner = Runner("postgres://localhost/db", migrations_dir, table="schema_migrations")
382
+ await runner.up()
383
+ assert patch_connect.tables["schema_migrations"] == {"version": 3, "dirty": False}
384
+
385
+
386
+ async def test_custom_table_name_is_used_for_tracking(
387
+ migrations_dir: Path, patch_connect: FakeMigrationConnection
388
+ ) -> None:
389
+ runner = Runner("postgres://localhost/db", migrations_dir, table="outbox_migrations")
390
+ await runner.up()
391
+ assert patch_connect.schema_migrations is None
392
+ assert patch_connect.tables["outbox_migrations"] == {"version": 3, "dirty": False}
393
+
394
+
395
+ async def test_two_runners_with_different_tables_track_versions_independently(
396
+ migrations_dir: Path, patch_connect: FakeMigrationConnection
397
+ ) -> None:
398
+ domain_runner = Runner("postgres://localhost/db", migrations_dir)
399
+ outbox_runner = Runner("postgres://localhost/db", migrations_dir, table="outbox_migrations")
400
+
401
+ await domain_runner.up()
402
+ assert patch_connect.schema_migrations == {"version": 3, "dirty": False}
403
+ assert patch_connect.tables.get("outbox_migrations") is None
404
+
405
+ patch_connect.executed_migration_sql.clear()
406
+ await outbox_runner.up()
407
+ assert patch_connect.tables["outbox_migrations"] == {"version": 3, "dirty": False}
408
+ # Domain runner's version is untouched by the outbox runner's run.
409
+ assert patch_connect.schema_migrations == {"version": 3, "dirty": False}
410
+
411
+ domain_version, domain_dirty = await domain_runner.version()
412
+ outbox_version, outbox_dirty = await outbox_runner.version()
413
+ assert (domain_version, domain_dirty) == (3, False)
414
+ assert (outbox_version, outbox_dirty) == (3, False)
415
+
416
+
417
+ @pytest.mark.parametrize("bad_table", ["", "1abc", "has space", "has-dash", "drop table;--"])
418
+ def test_invalid_table_name_raises(bad_table: str, tmp_path: Path) -> None:
419
+ with pytest.raises(InvalidMigrationsTableError):
420
+ Runner("postgres://localhost/db", tmp_path, table=bad_table)
421
+
422
+
423
+ def test_valid_table_name_with_underscore_and_digits_is_accepted(tmp_path: Path) -> None:
424
+ Runner("postgres://localhost/db", tmp_path, table="outbox_migrations_v2")
@@ -204,3 +204,14 @@ async def test_jitter_produces_varying_delays_within_bounds(patch_create_pool) -
204
204
  assert len(delays) > 1
205
205
  for d in delays:
206
206
  assert 0.05 <= d <= 0.15 + 1e-9
207
+
208
+
209
+ def test_multiplier_below_one_is_clamped_to_constant_wait() -> None:
210
+ # A multiplier < 1 would shrink the delay on every retry — Go's backoffWait clamps this up
211
+ # to 1 (a constant wait) rather than allowing a shrinking backoff.
212
+ from pgcommon.retry import _compute_delay_seconds
213
+
214
+ opts = RetryOptions(initial_wait=timedelta(milliseconds=50), multiplier=0.5)
215
+ assert _compute_delay_seconds(opts, 1) == pytest.approx(0.05)
216
+ assert _compute_delay_seconds(opts, 2) == pytest.approx(0.05)
217
+ assert _compute_delay_seconds(opts, 3) == pytest.approx(0.05)
@@ -681,7 +681,7 @@ wheels = [
681
681
 
682
682
  [[package]]
683
683
  name = "pgcommon"
684
- version = "0.1.0"
684
+ version = "0.2.0"
685
685
  source = { editable = "." }
686
686
  dependencies = [
687
687
  { name = "asyncpg" },
@@ -1,29 +0,0 @@
1
- # Changelog
2
-
3
- All notable changes to this project are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this project follows [semantic versioning](VERSIONING.md).
4
-
5
- ## [Unreleased]
6
-
7
- ## [0.1.0] - 2026-07-26
8
-
9
- ### Changed
10
-
11
- - Licensed under Apache-2.0 (was `Proprietary`) and published to public PyPI as `pgcommon`
12
- via Trusted Publishing — the source repository itself stays private. See
13
- [VERSIONING.md § Publishing to PyPI](VERSIONING.md#publishing-to-pypi).
14
-
15
- ### Added
16
-
17
- - Initial Python port of `platform-pgcommon` (Go) for `asyncio`/FastAPI services, built on `asyncpg`.
18
- - `Config`, `config_from_env`, `ConfigWarning` (`pgcommon.config`).
19
- - Sentinel errors and SQLSTATE helpers (`pgcommon.errors`).
20
- - `GUCSet`, contextvars-based propagation, `inject_gucs` (`pgcommon.guc`).
21
- - `Pool` — fail-fast construction, `with_conn`, `health`, `stat`, `drain_and_close` (`pgcommon.pool`).
22
- - `run_in_tx`, `run_in_savepoint` (`pgcommon.tx`).
23
- - `RetryOptions`, `run_in_tx_with_retry`, `run_in_tx_with_retry_opts` (`pgcommon.retry`).
24
- - `SlowQueryTracer` (`pgcommon.tracer`).
25
- - Prometheus metrics + `OTelQueryRecorder` (`pgcommon.metrics`).
26
- - Dependency-light `.sql` migration `Runner` + `pgcommon-migrate` console script (`pgcommon.migrate`).
27
- - Optional FastAPI integration: lifespan pool wiring, `GUCAuthMiddleware`, `/healthz` helper (`pgcommon.integrations.fastapi`, `pgcommon[fastapi]` extra).
28
- - Optional `structlog` `Logger` adapter (`pgcommon[structlog]` extra).
29
- - Unit, integration (testcontainers Postgres), and e2e (testcontainers Postgres + PgBouncer) test suites.
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes