Skip to content

feat: PostgreSQL sink backend - #1687

Open
dcoric wants to merge 240 commits into
mainfrom
feat/postgres
Open

feat: PostgreSQL sink backend#1687
dcoric wants to merge 240 commits into
mainfrom
feat/postgres

Conversation

@dcoric

@dcoric dcoric commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Adds PostgreSQL as a supported sink backend, complete with schema migrations, data migration, and production connection/auth options.

This is an integration branch. The PostgreSQL work was split into small, reviewable PRs that build on each other, and merging them into main one at a time would leave a half-configured backend in place for days at a time. They target this branch instead, so each keeps its own review and CI, and main sees the finished backend in one piece.

Collected PRs

PR Scope Status
#1532 PostgreSQL sink backend (users, repos, pushes, session store) Merged
#1581 Versioned DDL schema migrations with advisory locking Merged
#1582 Data migration from mongo/fs into PostgreSQL Merged
#1583 Idle pool client error handling Merged
#1584 Configurable connection (discrete fields, TLS, pool tuning) Merged
#1590 Normalise repo permissions into a repo_users join table Merged
#1591 AWS RDS/Aurora IAM authentication Merged
#1692 Pushes hot-path indexes and slimmer list projections Merged
#1689 sink-parity agent skill and AGENTS.md steering for backend parity Merged

All nine pieces have merged and each was reviewed and CI-verified individually against this branch (full pipeline including the PostgreSQL integration lane). The branch carries no drift from main. This PR is the complete feature, ready for final review. Related issues: #1688, #1690, #1691.

What lands

  • postgres sink selectable via the existing sink config, alongside fs and mongo
  • Full Sink interface parity with the mongo and NeDB backends, including getRepoPushRollupsByCanonicalUrl, getPushesForUserProfile, updateRepo and the migration hooks
  • Versioned, advisory-locked DDL migrations applied on startup
  • migrate-to-postgres command for existing mongo/fs deployments
  • Connection via connection string, discrete fields or PG* env vars, with TLS and pool tuning
  • Optional RDS/Aurora IAM token auth
  • Design decisions recorded in the architecture doc, plus a sink-parity skill so future adaptor changes keep all backends aligned
  • Unit plus integration coverage, with a dedicated PostgreSQL CI lane

Note on migrations

main has since grown a cross-backend migration framework in src/db/migrations, which records logical migrations by string id through Sink hooks. That is a different concern from #1581, which versions the postgres DDL itself and serialises concurrent runs with an advisory lock. Both are kept: the postgres adapter implements the framework hooks in src/db/postgres/migrations.ts, and the DDL runner lives alongside it in src/db/postgres/schemaMigrations.ts.

Existing deployments are unaffected: the default sink remains the filesystem backend and postgres is opt-in.

dcoric added 30 commits May 11, 2026 16:13
Stub modules for the upcoming Postgres sink adapter. No behavior yet —
each adapter file is a placeholder so subsequent commits can land each
concern (helper, pushes, repos, users) in isolation.

Refs #1497
Adds a third `oneOf` entry for the `database` definition in the JSON
schema and regenerates the TypeScript config types. `connectionString`
is optional at the schema level so an env-var fallback can supply it at
runtime (added in a follow-up commit).

Refs #1497
Adds `GIT_PROXY_POSTGRES_CONNECTION_STRING` to `serverConfig` and wires
the postgres branch of `getDatabase()` to populate `connectionString`
from it when the user config omits one. Mirrors the existing pattern
used for `GIT_PROXY_MONGO_CONNECTION_STRING`.

Refs #1497
Documents the new sink type in the shipped default config. Disabled by
default so the `fs` backend continues to be selected unless an operator
explicitly enables postgres.

Refs #1497
Runtime deps for the new PostgreSQL sink adapter:

- `pg` — node-postgres client + Pool, used by the adapter modules.
- `connect-pg-simple` — express-session store backed by Postgres,
  used to persist UI sessions when the postgres sink is active.
- `@types/pg`, `@types/connect-pg-simple` — TypeScript definitions.

Refs #1497
Implements the foundation shared by the postgres adapter modules:

- `connect()` lazily constructs a `pg.Pool` from the configured
  connection string and runs an idempotent `CREATE TABLE IF NOT EXISTS`
  bootstrap exactly once per process. All adapter modules acquire the
  pool through this function, so the schema is in place before any
  query is executed against `users` / `repos` / `pushes`.
- `query()` is a thin convenience wrapper that awaits `connect()` and
  delegates to `pool.query`.
- `resetConnection()` tears down the pool and bootstrap latch — used by
  the integration test harness between suites.
- `getSessionStore()` returns a `connect-pg-simple` store bound to the
  same pool. Per issue #1497 it MUST NOT silently return undefined when
  postgres is the active sink (express-session would silently fall back
  to MemoryStore), so a missing connection string throws instead.

The schema covers the three application tables plus the indexes used by
`getPushes` (timestamp DESC) and `getRepo` (name lookup). The session
table is left to `connect-pg-simple` via `createTableIfMissing: true`.

Refs #1497
Implements the `Sink` push methods against the `pushes` table:

- `getPushes`: filters by the same keys the mongo backend supports
  (error/blocked/allowPush/authorised/canceled/rejected/type) via a
  small allow-list mapping, then sorts `ORDER BY timestamp DESC` to
  preserve current backend ordering (issue #1497 must-fix).
- `getPush` / `deletePush`: lookups by `id` PK.
- `writeAudit`: upsert on `id` with the full Action serialized into the
  `data` JSONB column and the projection columns kept in sync. Throws
  `Invalid id` to match mongo behaviour.
- `authorise` / `cancel` / `reject`: read-modify-write through
  `getPush` + `writeAudit`, identical to the mongo flow. `reject`
  assigns `action.rejection = rejection` so the persisted payload shape
  (reason / reviewer / timestamp) matches the existing backends.

The Action class is reconstructed from the `data` JSONB via the
existing `toClass` helper.

Refs #1497
Implements the `Sink` user methods against the `users` table:

- `findUser` / `findUserByEmail` / `findUserByOIDC`: lower-case the
  lookup keys to match the mongo and fs case-insensitivity behaviour.
- `getUsers`: optional username / email filters with the same
  lower-casing; SELECT projects `password` away (matching mongo's
  `.project({ password: 0 })`).
- `createUser`: insert with lower-cased username / email.
- `deleteUser`: delete by lower-cased username.
- `updateUser`: dynamic SET-builder that mirrors mongo's partial
  upsert. Identity is by `_id` when supplied, otherwise by `username`;
  if no matching row exists when keyed on username, a new row is
  inserted so callers can patch-or-create without two round trips.

`_id` is exposed as an opaque string (UUID rendered as text) so the
HTTP/UI contract is unchanged versus the mongo backend (which renders
ObjectId via `.toString()`).

Refs #1497
Implements the `Sink` repo methods against the `repos` table.

Permissions (`canPush` / `canAuthorise`) are stored as a single JSONB
column matching the existing mongo/fs shape, with a TODO marker
pointing at a future migration to a normalized `repo_users` join table
(open question called out in issue #1497).

Notable details:

- `addUser*` use `jsonb_set` + a DISTINCT subquery so re-adding an
  existing user is a no-op, matching the fs adapter's `includes` guard.
- `removeUser*` use `coalesce(..., '[]'::jsonb)` around the
  `array_agg` filter so that removing the last user leaves the array
  as `[]`, not `null` — issue #1497 explicitly requires this and the
  reader path additionally defaults `null` arrays to `[]` for
  belt-and-braces resilience against legacy rows.
- `getRepos` accepts the same query keys as the mongo backend
  (name / project / url) with the same lower-casing on `name`.
- `createRepo` returns the row with `_id` populated (UUID rendered as
  text), matching the mongo backend's contract.

Refs #1497
Adds the `postgres` branch to the runtime `start()` selector so a sink
config of `type: 'postgres'` resolves to the new adapter modules, and
re-exports the full `Sink` surface from `src/db/postgres/index.ts`.

The `getSessionStore` return type on the `Sink` interface and on the
top-level `src/db/index.ts` re-export is widened from
`MongoDBStore | undefined` to `MongoDBStore | Store | undefined`, where
`Store` is the express-session base class — `connect-pg-simple`
extends it. This keeps the existing mongo / fs callers type-compatible.

Refs #1497
Per issue #1497 must-fix: when the active sink is one that promises a
persistent session store (currently `mongo` or `postgres`),
`db.getSessionStore()` returning undefined must NOT silently fall
through to express-session's default `MemoryStore` — that store loses
sessions on every restart and is unsafe in any multi-process
deployment.

`createApp` now resolves the store before registering the session
middleware and throws if a persistent backend produced `undefined`.
The `fs` backend is unaffected: it has always returned `undefined`
deliberately, and falling back to MemoryStore there matches existing
single-node-only fs semantics.
Mocks the `query` export from the postgres helper so the suite runs
without a live database. Covers:

- `getPushes` ordering — asserts the generated SQL contains
  `ORDER BY timestamp DESC` (issue #1497 must-fix).
- `getPushes` column translation — `allowPush` filter maps to the
  `allow_push` snake_case column.
- `getPushes` unknown filter keys are ignored (no spurious WHERE).
- `getPush` returns null when the row is absent.
- `writeAudit` throws `Invalid id` for non-string ids (mongo parity).
- `writeAudit` upserts via `ON CONFLICT (id) DO UPDATE`.
- `reject` writes a serialized Action into the `data` JSONB column
  with the `rejection` field populated — confirming the payload shape
  matches the existing backends.
- `reject` throws when the push is missing.
Covers behaviour-critical paths:

- case insensitivity: findUser / findUserByEmail / createUser /
  deleteUser all lower-case their lookup or stored values (parity
  with the mongo and fs adapters).
- getUsers omits `password` from the projection (mirrors mongo's
  `.project({ password: 0 })`).
- updateUser dispatches on `_id` vs `username`, and when the
  username-keyed UPDATE matches nothing it falls back to INSERT —
  this is the upsert semantics issue #1497 calls for.
- updateUser throws when given neither `_id` nor `username`.
Targets the parity invariants called out in issue #1497:

- `getRepoById` defaults a NULL `users` JSONB to empty arrays — guards
  against legacy or partial rows and matches the fs/mongo contract.
- `getRepo` lower-cases the lookup name.
- `createRepo` serialises the default `{canPush:[],canAuthorise:[]}`
  into the JSONB column and stamps the generated `_id` back onto the
  returned object.
- `addUserCanPush` lower-cases the user value before storing.
- `removeUserCanPush` and `removeUserCanAuthorise` emit a SQL fragment
  that wraps the filtered array in `coalesce(..., '[]'::jsonb)`, so
  the array remains `[]` when the last user is removed — this is the
  explicit must-fix from the issue, and an end-to-end check sits in
  the integration suite.
Mocks the `pg` Pool and `connect-pg-simple` constructors so the suite
can exercise the helper without a real database. Covers:

- `connect()` is concurrency-safe: many parallel calls share one Pool
  and run the bootstrap SQL exactly once.
- the bootstrap SQL creates `users`, `repos`, and `pushes` (assertion
  via regex against the inlined statement).
- bootstrap failure does not permanently latch the helper: the next
  `connect()` retries instead of returning the rejected promise.
- `query()` surfaces the helpful error message when the configured
  connection string is missing.
- `getSessionStore()` throws (not returns undefined) when the
  connection string is missing — the explicit must-fix from issue
  #1497 to prevent a silent MemoryStore fallback.
- `getSessionStore()` constructs the `connect-pg-simple` store with
  `createTableIfMissing: true` and shares the helper's pool.

Full suite: 791 unit tests passing (+27 new), zero regressions.
Adds the scaffolding for postgres-backed integration tests:

- `vitest.config.integration.postgres.ts`: separate vitest config that
  scopes `include` to `test/db/postgres/**/*.integration.test.ts`, sets
  `RUN_POSTGRES_TESTS=true`, points `CONFIG_FILE` at the dedicated
  postgres test config, and uses a single-fork pool so the lazy
  pg.Pool is shared across the suite. Mirrors the shape of
  `vitest.config.integration.ts` for mongo.
- `test/setup-integration-postgres.ts`: connects a `pg.Client`,
  truncates the app tables (and the connect-pg-simple `session` table
  if it exists) between tests, drops them in `afterAll`, and calls
  `resetConnection()` + `invalidateCache()` so each test sees a fresh
  helper state.
- `test-integration.postgres.proxy.config.json`: minimal config with a
  single enabled postgres sink and local auth, so `getDatabase()`
  resolves to postgres without the default fs entry winning first.
- `package.json`: adds `npm run test:integration:postgres`.

No suites yet — added in the next commit.
Parity with the mongo pushes integration suite, gated on
RUN_POSTGRES_TESTS=true (set automatically by
vitest.config.integration.postgres.ts). The suite is skipped in normal
`npm test` runs and only executes against a real Postgres in the
dedicated `npm run test:integration:postgres` task.

The added test that goes beyond the mongo parity:

- `getPushes` returns results in descending timestamp order across
  three rows with deliberately distinct timestamps — exercising the
  must-fix ordering requirement end-to-end against a real database.

Otherwise the assertions mirror the existing mongo integration suite
verbatim so backend parity is verifiable side-by-side.
Parity with the mongo users integration suite, gated on
RUN_POSTGRES_TESTS=true. Mirrors the same case-insensitivity and
filtering assertions, plus one additional test exercising the
upsert-on-username path through `updateUser` end-to-end (the mongo
adapter has this via its `upsert: true`, our postgres adapter
implements it as an `UPDATE … WHERE username` fallback to `INSERT`).

`getUsers` asserts `password` is `null` in list responses rather than
`undefined`: the postgres SELECT projects `NULL::text AS password`,
which round-trips as `null` rather than being elided from the JSON
shape — semantically equivalent to mongo's omission for the API
consumers.
Parity with the mongo repo integration suite, gated on
RUN_POSTGRES_TESTS=true.

The permission-JSONB block is the centrepiece — it exercises the
explicit issue #1497 must-fix end-to-end against a real database:

- starts with empty arrays in the JSONB column.
- adding a user is deduplicated (re-adding does not double-insert).
- removing the *last* user leaves the array as `[]`, not `null`.
- the invariant applies symmetrically to `canAuthorise`.
- removing one user from a multi-user list keeps the rest intact.

Skipped without postgres available: full unit suite is 791 passing,
90 skipped (45 mongo + 18 postgres-pushes + 14 postgres-users + 13
postgres-repo), zero failures, zero regressions.
Adds a `postgres:16` service container to the `build-ubuntu` job and
a new `PostgreSQL Integration Tests` step that runs
`npm run test:integration:postgres` against it. The service uses the
default `postgres` superuser with database `git_proxy_test`, matching
the connection string our adapter and test harness default to.

Per the issue's "Open Questions" section, a single Postgres version
is sufficient for the initial lane; a broader matrix can follow once
the backend has soaked in.

Refs #1497
Documents the new `postgres` backend in the `sink` section of the
architecture reference:

- Lists `postgres` as a supported sink alongside `fs` and `mongo`.
- Shows the minimal config block.
- Documents the `GIT_PROXY_POSTGRES_CONNECTION_STRING` env-var
  fallback.
- Calls out the v1 limitations explicitly (no migration tooling, no
  AWS RDS IAM auth, JSONB permissions, no split PG env vars, fail-
  loudly on missing connection string).

Refs #1497
dcoric and others added 12 commits August 27, 2026 10:21
… module

The backfill integration test still imported MIGRATIONS from
src/db/postgres/migrations, which after the split holds the
cross-backend framework hooks and no longer exports it; the named
import resolved to undefined and the test crashed on first real lane
execution. The DDL list and pool-level runner live in
schemaMigrations.
feat: normalise PostgreSQL repo permissions into a repo_users join table
…-data-migration

# Conflicts:
#	website/docs/architecture/architecture.md
…-pushes-perf

# Conflicts:
#	src/db/postgres/schemaMigrations.ts
#	test/db/postgres/schemaMigrations.integration.test.ts
…-rds-iam-auth

# Conflicts:
#	test/db/postgres/helper.test.ts
feat: data migration from mongo/fs to the PostgreSQL sink
perf: index the PostgreSQL pushes hot paths and slim list projections
…-rds-iam-auth

# Conflicts:
#	website/docs/architecture/architecture.md
feat: AWS RDS/Aurora IAM authentication for the PostgreSQL sink
Comment thread .github/workflows/ci.yml Outdated
branches: [main, feat/postgres]
pull_request:
branches: [main]
branches: [main, feat/postgres]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't forget to revert these, we're getting duplicate jobs on the CI 😃

Will do a full review and AI scan later 👍🏼

@dcoric dcoric Aug 28, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, reverted - the triggers are back to main only. They existed so the stacked PRs (based on feat/postgres) got the full pipeline; with everything merged, this PR's own runs are covered by the original filter and the addition was just producing the duplicate runs you saw. The only ci.yml change left in the diff is the intended one: the PostgreSQL service container and integration test lane that ship with the feature.

@jescalada
jescalada requested review from a team, fabiovincenzi and jescalada August 28, 2026 14:09
The extra branch filters existed so the stacked PostgreSQL PRs (which
targeted feat/postgres instead of main) got the full pipeline. All of
those PRs have merged, and this umbrella PR is covered by the original
main filter, so the addition now only produces duplicate runs (the
push-triggered run plus this PR's synchronize run on every merge).

@jescalada jescalada left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@dcoric Thanks for this! My main question is the JSONB design choice: I'd like to know why you chose this beyond symmetry with Mongo/NeDB. What are the pros and cons of JSONB over normalization, and most importantly, which gives faster reads & writes?

@finos/git-proxy-maintainers I've reviewed all of the smaller component PRs previously, so this is a final pass + AI scan for the whole PR. I'd appreciate an extra look! 😃

Comment thread src/db/postgres/helper.ts Outdated
if (db.port !== undefined) config.port = db.port;
if (db.user !== undefined) config.user = db.user;
if (db.password !== undefined) config.password = db.password;
if (db.database !== undefined) config.database = db.database;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wondering if the buildPoolConfig function could be refactored? I noticed a lot of repeated code here, and it could be more readable 🤔

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Refactored in 435c232: the duplicated discrete-field copying is now applyDiscreteFields (with an includePassword switch for IAM mode, where a token provider replaces the password), and the SSL and pool-tuning blocks moved into applySsl / applyPoolTuning. buildPoolConfig itself is left as a readable three-way branch over the connection modes. Behaviour is identical — the existing unit tests over warnings and precedence pass unchanged.

Comment thread src/db/postgres/helper.ts
try {
await client.query('ROLLBACK');
} catch {
// the original error is the one worth surfacing

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it okay to leave this empty?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Intentional — that's the catch around ROLLBACK inside withTransaction. If rollback itself fails, the connection is already broken: the error worth surfacing is the original one from the transaction body, which is rethrown on the next line, and finally releases the client so the pool discards it. The comment inside the block says as much. Happy to add a debug log there if you'd prefer the rollback failure visible too.

Comment thread src/db/postgres/repo.ts Outdated
`INSERT INTO repos (project, name, url, date_created, last_modified)
VALUES ($1, $2, $3, $4, $5)
RETURNING _id`,
[repo.project ?? '', repo.name, repo.url, repo.dateCreated, repo.lastModified],

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought Repo was guaranteed to have the project attribute - any reason why we we're defaulting to '' here?

From /src/db/types:

export class Repo {
  project: string;
  name: string;
  url: string;
  users: { canPush: string[]; canAuthorise: string[] };
...

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Repo class declares project: string, but what actually reaches the sink at runtime are plain objects from the HTTP handlers and tests, not validated class instances — and the mongo/fs backends silently accept a missing project today. The column is TEXT NOT NULL DEFAULT '', so ?? '' makes postgres accept the same inputs the other backends do rather than throwing a not-null violation only on this sink. Tightening it to a thrown error would be a cross-backend behaviour change, which I'd rather not smuggle into this PR.

Comment thread src/db/postgres/users.ts Outdated
export const addPublicKey = async (username: string, publicKey: PublicKeyRecord): Promise<void> => {
const existingUser = await findUserBySSHKey(publicKey.key);
if (existingUser && existingUser.username.toLowerCase() !== username.toLowerCase()) {
throw new DuplicateSSHKeyError(existingUser.username);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this might be exploitable with two concurrent addPublicKey requests. If two users add the same public key simultaneously, they second call might be able to bypass the findUserBySSHKey check if the first call didn't complete the DB update.

Very unlikely to happen with real usage, but something that AI scans will probably complain about 😄

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — fixed in 61cb038. addPublicKey now runs in a transaction that first takes pg_advisory_xact_lock(hashtextextended(key, 0)): concurrent adds of the same key serialise on the lock and the loser then sees the winner's row in the duplicate check. (A unique constraint can't span elements of a JSONB array, hence the advisory lock rather than a schema change.) The target user row is also read FOR UPDATE for the fingerprint check. Added a deterministic integration test racing two users adding the same key — exactly one wins, the other gets DuplicateSSHKeyError.

Mongo carries the identical race; left out of scope here since it needs a different mechanism there.

Comment thread src/db/postgres/pushes.ts

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI scan comment on this one:

authorise / reject / cancel in pushes.ts: All three do getPush → mutate in memory → writeAudit, with no transaction and no row lock, and writeAudit overwrites data wholesale via ON CONFLICT DO UPDATE. Two reviewers acting on the same push at the same moment produce a straight lost update, and worse, if the proxy writes step results for that push id between the read and the write, those results vanish from the audit record. The fix is cheap since withTransaction already exists: wrap the three of them and read with SELECT data FROM pushes WHERE id = $1 FOR UPDATE. Mongo has the same shape, so it's not a regression, but Postgres is the backend where you can actually fix it.

I'm not sure if it correctly scanned other files as well: I don't think we're using withTransaction outside of updateRepo in /repo.ts...

Long story short, we should check if it's appropriate to wrap all our writeAudit-related calls in withTransaction 👍🏼

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed — fixed in 61cb038. authorise/reject/cancel now read the row with SELECT ... FOR UPDATE inside withTransaction and write through the same client, so two concurrent decisions (or a step-result write racing a reviewer) serialise instead of the later write discarding the earlier one. Also added an integration test running authorise and reject concurrently against the same push.

On the wider question: writeAudit itself stays un-wrapped — a single upsert is already atomic, and the proxy is the only writer while a push is in flight. It's the read-modify-write callers that needed the lock, and those three were the only ones in the postgres adapter.

Comment thread src/db/postgres/repo.ts
]);
};

export const createRepo = async (repo: Repo): Promise<Repo> => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI comment on this one:

createRepo isn't atomic. The INSERT INTO repos returns an id, then the function loops addUserToRole once per username, each of which is two more un-transacted round trips. A crash or connection drop partway leaves a repo row with partial or empty canPush / canAuthorise. Since those arrays are what gate pushing and approving, "created but missing its grants" is a state I'd rather not be able to reach. Same withTransaction treatment, and you can collapse the loop into a single insert over unnest($2::text[]).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 61cb038, exactly as the scan suggested: the repo insert and its permission grants run in one withTransaction, and the per-user loop is collapsed into a single INSERT ... SELECT ... FROM unnest($3::text[]). The same set-based insert is now shared with updateRepo's wholesale permission replacement, which had the same loop.

Comment thread src/db/postgres/repo.ts
* Permissions live in the `repo_users` join table rather than a column, so a
* supplied `users` object replaces that repo's rows wholesale.
*/
export const updateRepo = async (repo: Partial<Repo>): Promise<void> => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another interesting AI comment on this one:

A design question rather than a bug: repo_users.username is plain TEXT with no foreign key, and deleteUser doesn't touch the table. Delete alice, create a new alice six months later, and she quietly inherits every grant the old one had. updateUser renaming a username has the same problem in reverse, orphaning grants. Mongo carries the identical hole, so it's not new, but normalising into a join table is precisely the moment you get to fix it, and leaving it means the normalisation buys structure without buying integrity.

TL;DR: Deleting a user by username and adding it again (with the same username) makes the new one inherit the old user's permissions. Might be worth making an issue for this since it's apparently reproducible in Mongo too 🤷🏼

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed on filing an issue — opened #1705 covering all three backends. Fixing only postgres here would make the backends diverge behaviourally (mongo/fs carry the identical hole via the usernames embedded in repos.users), and keeping the sinks at parity is the whole point of this PR, so the cleanup belongs in a cross-backend change.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Last AI comment:

On migrations, one operational caveat worth documenting. connect() runs runMigrations lazily on the first query of any process, so the runtime DB role needs DDL rights permanently, which is awkward in the regulated shops that are the target audience here. And migration 5 drops repos.users, so during a rolling deploy any still-running older process breaks the moment a new one boots. I'd add an autoMigrate: false escape hatch plus a note in the architecture doc about deploy ordering. Related: connect-pg-simple runs with createTableIfMissing: true, which is a second DDL path living outside the versioned migration list entirely.

TL;DR: Make auto-migration optional since admin rights might no be enabled by default (especially in banks and the like). An organization would have to enable admin rights for the runtime DB, trigger the migration and then go back to regular rights.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Implemented in 435c232: new autoMigrate option on the postgres sink (default true, so behaviour is unchanged unless opted into). With autoMigrate: false startup performs no DDL at all — it only verifies the schema is current and refuses to start with the pending versions named. Migrations are applied out-of-band with DDL-capable credentials via the new npm run migrate:postgres:schema script (same advisory-locked runner, so it's safe against concurrent runs).

Also closed the second DDL path you flagged: the connect-pg-simple session table is now owned by migration 7 (IF NOT EXISTS adopts databases where the store already created it) and createTableIfMissing is switched off, so every piece of DDL flows through the versioned list. The architecture doc gained sections on autoMigrate and deploy ordering, including the migration-5 rolling-deploy caveat.


The adapter follows a few deliberate choices, made for parity with the existing backends rather than for idiomatic SQL:

- **Pushes stay documents.** A push is an audit record: written once, updated through a handful of state flips, and read back whole. The `pushes` table therefore keeps the entire action as a JSONB `data` column, with typed columns (`timestamp`, the status booleans) only for the fields that queries filter and sort on. This mirrors how the mongo and NeDB backends treat pushes and keeps the row shape stable as the `Action` type evolves.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm wondering about the implications of using JSONB for pushes, or the hybrid thing we have for users and repos. What are the downsides of this? Isn't it considerably harder to understand and maintain JSONB queries? Or is the idea to just be able to port over any Mongo behaviour easily (since we'd be operating with documents rather than rows)? What about efficiency of reading and writing to DB vs normalizing?

I'm worried about the maintainability of JSONB over normalized SQL tables. Although there's also the question of efficiency - if we normalized the DB, we would have to build the Action objects from different tables via queries 🤷🏼

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Expanded this section in 435c232 with a dedicated "JSONB over full normalisation is deliberate" bullet carrying the reasoning (and fixed the stale claim that repo permissions are still JSONB — they're normalised now). Fuller answer on the main review thread.

dcoric and others added 2 commits August 29, 2026 16:33
Address review feedback on #1687:

- authorise/reject/cancel now read the push row FOR UPDATE inside a
  transaction and upsert through the same client, so two concurrent
  decisions (or a step-result write racing a reviewer) serialise instead
  of the later write silently discarding the earlier one.
- createRepo wraps the repo insert and its permission grants in one
  transaction, and collapses the per-user loop into a single set-based
  insert over unnest(); a crash partway can no longer leave a repo
  behind without the canPush/canAuthorise grants that gate pushing and
  approving. updateRepo reuses the same set-based insert.
- addPublicKey serialises concurrent adds of the same key on a
  transaction-scoped advisory lock derived from the key text (a unique
  constraint cannot span elements of a JSONB array), closing the TOCTOU
  window in the duplicate-key check, and locks the target user row.

Unit tests updated for the transactional statement shapes; integration
tests add a deterministic duplicate-key race and a concurrent
authorise/reject decision test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KfBWbqYj8Yc9G1jBaqvdE7
…ol-config cleanup

Address review feedback on #1687:

- New `autoMigrate` sink option (default true). When false, startup no
  longer runs DDL: it only verifies the schema is current via
  assertMigrationsCurrent, refusing to start with the pending versions
  named, so the runtime role needs no DDL rights. Migrations are then
  applied out-of-band with elevated credentials via the new
  `npm run migrate:postgres:schema` script.
- Migration 7 owns the connect-pg-simple session table (IF NOT EXISTS
  adopts databases where the store already created it), and the store's
  createTableIfMissing second DDL path is switched off;
  ensureSessionStoreReady awaits the migration gate before probing.
- buildPoolConfig deduplicated into applyDiscreteFields / applySsl /
  applyPoolTuning with identical behaviour.
- Architecture doc: autoMigrate + deploy-ordering notes, and the JSONB
  vs normalisation rationale for the design-decisions section.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KfBWbqYj8Yc9G1jBaqvdE7
@dcoric

dcoric commented Aug 29, 2026

Copy link
Copy Markdown
Contributor Author

@jescalada Thanks for the thorough review! On the main question — JSONB vs normalization — this was discussed near the end of the Aug 26 community call, where we settled on storing documents as JSON in Postgres and avoiding full normalization, to keep maintenance simple and keep the sinks easy to hold at feature parity. The reasoning, now also written into the architecture doc's design-decisions section:

Access patterns. Pushes are written and read back whole, by id, and listed by timestamp. Nothing queries inside the document relationally — the handful of filtered fields (error, blocked, authorised, canceled, rejected, timestamp) are already promoted to real columns, and the two JSONB lookups the profile/activity pages make are covered by expression indexes (migration 6). Fully normalizing an Action would decompose a deeply nested document (steps, commit data, attestation) across many tables on every write and reassemble it with multi-way joins on every read, purely to serve queries the app never runs.

Reads and writes. A read is a single-row fetch by primary key either way, but JSONB skips the multi-join reassembly; a write is one upsert instead of a transactional multi-table write. For this workload — low write volume, document-shaped data — JSONB is at least as fast in both directions. Maintainability cuts the same way: the adapter stays a thin mapping, and a new Action field costs nothing here instead of a schema migration + insert/select/mapping change per table.

Where normalization pays, we did it. Repo permissions are queried relationally ("who can push where"), so they were normalized into repo_users (#1559 — this was one of #1497's open questions, resolved exactly this way). Users/repos are deliberately hybrid: real columns for everything queried or constrained (username, email, oidc_id, admin), JSONB only for the publicKeys blob. So the rule is: JSONB only where the data is genuinely a document.

Parity and portability. All three backends operating on the same document shapes is what makes the sink-parity contract tractable, and it's why migrate:postgres can move data between sinks without lossy transformation.


All the inline findings are now addressed on the branch:

  • 61cb038authorise/reject/cancel run in transactions with FOR UPDATE; createRepo is atomic with a set-based unnest() grant insert; addPublicKey closes its TOCTOU race with a transaction-scoped advisory lock. New unit + deterministic integration tests for the races.
  • 435c232autoMigrate: false escape hatch for DDL-restricted deployments (+ npm run migrate:postgres:schema for out-of-band runs), the session table folded into the versioned migration list (no more createTableIfMissing side channel), buildPoolConfig deduplicated, and the architecture doc updated with the JSONB rationale, autoMigrate, and deploy-ordering notes.
  • The username-reuse/grant-inheritance hole is filed as Deleting or renaming a user leaves their repo permission grants behind (all sink backends) #1705 — it's identical in mongo/fs, so it belongs in a cross-backend fix rather than this PR.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants