Skip to content

(feat) Create backend-pgsql#51

Merged
Minterl merged 37 commits into
masterfrom
feat/backend-pgsql
Apr 22, 2026
Merged

(feat) Create backend-pgsql#51
Minterl merged 37 commits into
masterfrom
feat/backend-pgsql

Conversation

@Minterl

@Minterl Minterl commented Apr 20, 2026

Copy link
Copy Markdown
Contributor
  • scaffold out package
  • rename remote -> backend-remote
  • set up drizzle
  • gen migrations
  • set up tests

Summary by CodeRabbit

  • New Features

    • Added event-based state mode alongside snapshot mode; backends now support a commit-style event workflow.
    • Added a PostgreSQL backend with optional migrations and secret management.
  • Infrastructure

    • Added local Docker Compose for Postgres, DB schema (providers/endpoints/secrets), Drizzle config, and build/test tooling.
  • Tests

    • Added integration tests covering event commits, reads, error mapping, and secret management.
  • Documentation

    • Added README describing local Postgres integration test workflow.

@coderabbitai

coderabbitai Bot commented Apr 20, 2026

Copy link
Copy Markdown

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 7785b272-d1f3-4708-a3a2-87623d0fd69a

📥 Commits

Reviewing files that changed from the base of the PR and between 96888fd and 96fd512.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (12)
  • src/backend/backend/src/backend.ts
  • src/backend/backend/src/local.test.ts
  • src/backend/backend/src/local.ts
  • src/backend/pgsql/drizzle/20260422183522_heavy_colossus/migration.sql
  • src/backend/pgsql/drizzle/20260422183522_heavy_colossus/snapshot.json
  • src/backend/pgsql/drizzle/20260422184030_equal_odin/migration.sql
  • src/backend/pgsql/drizzle/20260422184030_equal_odin/snapshot.json
  • src/backend/pgsql/package.json
  • src/backend/pgsql/src/db/schema.ts
  • src/backend/pgsql/src/index.ts
  • src/backend/pgsql/test/index.test.ts
  • src/backend/remote/src/backend.ts

📝 Walkthrough

Walkthrough

Backend API gains a write-mode generic ('snapshot' | 'event') with conditional state shapes; describeBackend is mode-aware. A new event-mode PostgreSQL backend, schema, migrations, tests, and package/tooling are added; existing snapshot backends are annotated to use 'snapshot'.

Changes

Cohort / File(s) Summary
Core Backend Types
src/backend/backend/src/backend.ts, src/backend/backend/src/index.ts
Add BackendStateWriteMode and StateEvent. Make Backend and BackendDescriptor generic over writeMode with conditional state (snapshot: read/write/delete; event: read/commit). Overload describeBackend and re-export StateEvent.
Snapshot Backends & Tests
src/backend/backend/src/local.ts, src/backend/backend/src/local.test.ts, src/backend/remote/src/backend.ts
Call describeBackend<..., 'snapshot'>, set writeMode: 'snapshot' in descriptors; remove explicit Backend import in test and drop return type annotation on test helper.
Postgres Backend Implementation
src/backend/pgsql/src/index.ts
New event-mode PgSQL backend (createPgsqlBackend) with PgsqlBackendConfig. Implements state.read, transactional state.commit handling StateEvent (endpoint.created/updated/deleted), secret management, and DB→BackendError mapping.
Schema, Migrations & Snapshots
src/backend/pgsql/drizzle/.../migration.sql, src/backend/pgsql/drizzle/.../snapshot.json
Add migrations and snapshots to create providers, endpoints, secrets, identity PKs, FKs, and unique indexes (including secrets.endpoint_id unique).
PgSQL Package & Tooling
src/backend/pgsql/package.json, src/backend/pgsql/tsconfig.json, src/backend/pgsql/tsconfig.build.json, src/backend/pgsql/drizzle.config.ts, src/backend/pgsql/eslint.config.mjs, src/backend/pgsql/vitest.config.mjs, src/backend/pgsql/turbo.json
New package manifest, build/test scripts, TypeScript build configs, Drizzle Kit config, ESLint & Vitest configs, and turborepo task settings for the Postgres backend.
Docker Compose & README
src/backend/pgsql/compose.yaml, src/backend/pgsql/README.md
Add Docker Compose for local Postgres tests and README documenting local integration test workflow and connection string.
Integration Tests
src/backend/pgsql/test/index.test.ts
New integration tests (conditional on DATABASE_URL) covering migrations, commit/read flows, DB error mapping, and signing secret lifecycle; includes per-test DB cleanup and connection retry logic.
Package Rename
src/backend/remote/package.json
Rename package scope: @hookplane/remote@hookplane/backend-remote.

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant PgBackend
  participant DrizzleDB
  participant SecretsTable

  Client->>PgBackend: commit(events: StateEvent[])
  PgBackend->>DrizzleDB: BEGIN TRANSACTION
  loop per event
    PgBackend->>DrizzleDB: upsert provider (if needed)
    PgBackend->>DrizzleDB: insert/update/delete endpoint row
    PgBackend->>SecretsTable: insert/update/delete secret row (if applicable)
  end
  DrizzleDB-->>PgBackend: transaction result
  PgBackend-->>Client: ResultAsync<void, BackendError>
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Poem

🐰 I hopped through types and tables bright,
Events or snapshots — both take flight.
I planted rows, ran migrations too,
Commits and reads now dance anew.
🥕✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 14.29% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title directly describes the main feature: creating a new PostgreSQL backend package, which aligns with the primary objective of scaffolding backend-pgsql and all related infrastructure.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/backend-pgsql

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@Minterl Minterl force-pushed the feat/backend-pgsql branch from e7624d2 to ac5225a Compare April 21, 2026 15:22
@Minterl Minterl force-pushed the feat/backend-pgsql branch from 71dee8f to 26c2d1c Compare April 21, 2026 16:14
@Minterl Minterl force-pushed the feat/backend-pgsql branch from 26c2d1c to 2daa037 Compare April 22, 2026 13:38
@Minterl Minterl marked this pull request as ready for review April 22, 2026 15:40

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 12

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/backend/pgsql/compose.yaml`:
- Around line 4-5: The published Postgres port in the docker-compose ports
mapping ('54329:5432') exposes the test DB on all interfaces; update the ports
entry in compose.yaml to bind to loopback only by replacing '54329:5432' with
'127.0.0.1:54329:5432' so Docker publishes Postgres on 127.0.0.1 and prevents
external access while keeping the same host and container ports.

In `@src/backend/pgsql/drizzle/20260420160720_tiresome_zaladane/migration.sql`:
- Around line 1-22: Make the foreign keys non-nullable and add the missing
uniqueness constraints: change "endpoints"."provider_id" and
"secrets"."endpoint_id" to NOT NULL (update the CREATE TABLE for endpoints and
secrets and the ALTER constraints endpoints_provider_id_providers_id_fkey and
secrets_endpoint_id_endpoints_id_fkey accordingly), add a UNIQUE constraint on
"providers"."name", and add a composite UNIQUE on ("endpoints"."provider_id",
"endpoints"."handle") so endpoint handles are unique per provider; update the
Drizzle schema definitions and regenerate the snapshot to reflect these column
changes and new unique constraints.

In `@src/backend/pgsql/package.json`:
- Line 41: Update package.json to replace the pre-release pins for drizzle
packages: change the dependency "drizzle-orm" (currently "1.0.0-beta.22") and
the devDependency "drizzle-kit" (currently "1.0.0-beta.22") to the latest stable
release (e.g., "0.45.2"). Ensure both entries are updated so package managers
install the stable 0.45.2 versions, then run your lockfile update
(npm/yarn/pnpm) to refresh the lockfile and verify builds/tests pass; look for
the keys "drizzle-orm" and "drizzle-kit" to locate and modify the entries.

In `@src/backend/pgsql/src/db/schema.ts`:
- Around line 24-27: The providers table currently allows duplicate names;
update the providers pgTable definition so providers.name is unique (e.g., add a
unique constraint or unique index for the name column) to enforce provider-name
uniqueness at the DB level; modify the exported providers constant (the
pgTable('providers', {...}) definition) to declare the uniqueness on the name
column (or add a migration that creates a unique index like
providers_name_unique) so concurrent creates cannot produce duplicate provider
rows.
- Around line 29-35: The secrets table allows multiple rows per endpoint and
writeSecret uses a check-then-act flow causing a race; update the schema so the
endpointId column (endpoint_id) is notNull and has a unique constraint/index to
enforce one secret per endpoint, and change the writeSecret implementation to an
atomic upsert (INSERT ... ON CONFLICT (endpoint_id) DO UPDATE SET secret =
EXCLUDED.secret, ... ) instead of doing a SELECT then INSERT; reference the
secrets table and endpointId column in schema changes and the writeSecret
function for the upsert conversion.
- Around line 13-15: The providerId and endpointId columns defined as
integer('provider_id').references(() => providers.id, ...) and
integer('endpoint_id').references(() => endpoints.id, ...) must be made
non-nullable to match the backend inserts and cascade semantics; update both
column definitions by adding .notNull() to providerId and endpointId in
schema.ts and then regenerate the SQL migrations so the DB enforces non-null
foreign keys and prevents orphaned records.

In `@src/backend/pgsql/src/index.ts`:
- Around line 187-206: The update path for endpoint.updated currently writes
event.handle and event.after.url without validation; change the handler to
validate using createEndpointHandle(event.handle) and
createEndpointUrl(event.after.url) before persisting, and on validation failure
return an err BackendError with while: 'write' (not 'read') and an appropriate
message; apply the same validation-and-write pattern to the other occurrence
noted (the second endpoint update block) so no invalid handle/url can be stored.
- Around line 62-70: The current commit function launches commitEvent for every
event concurrently via events.map, causing out-of-order writes and partial
commits; change commit to open a single DB transaction (begin), then iterate
events sequentially calling commitEvent(db, event) (await each ResultAsync and
stop on first Err), and if any event returns an Err rollback the transaction and
return that error, otherwise commit the transaction and return Ok; ensure you
use the same db/transaction handle passed into each commitEvent call and avoid
ResultAsync.combine so processing is strictly ordered and atomic.
- Around line 161-185: The current flow always creates a provider when missing
which allows non-create events (update/delete) to cause unintended writes and
cross-provider mutations; change logic so only endpoint.created events insert a
new provider: in the handler around provider resolution (the provider variable
and the db.insert(schema.providers) block), if event.type !== 'endpoint.created'
do not insert — instead return a NotFoundError when provider is missing;
additionally, for update/delete paths that call the endpoint update/delete
queries, add providerId (from the resolved provider.id) to the WHERE predicates
(the clauses that currently only match by handle) so updates/deletes include
providerId to scope mutations to the correct provider. Ensure WriteRejectedError
usage remains for failed inserts in the endpoint.created insert path.
- Around line 58-61: The current use of ResultAsync.fromSafePromise around async
DB helpers (readState, readSecret, writeSecret, deleteSecret) is unsafe because
those helpers can reject; replace each ResultAsync.fromSafePromise(...) call
with ResultAsync.fromPromise(helperCall, (e) => ({ kind: 'BackendError', name:
'UnknownError', message: `Database error: ${e}`, while: '<operation>' })) where
<operation> is 'read'|'readSecret'|'writeSecret'|'deleteSecret' respectively, or
alternatively add try/catch inside the helper functions
(readState/readSecret/writeSecret/deleteSecret) to convert thrown errors into
Result.ok/err before returning; update callers to use the new error-mapped
ResultAsync.fromPromise form to keep rejections inside the Result abstraction.

In `@src/backend/pgsql/test/index.test.ts`:
- Around line 66-80: The readiness-check loop opens a PostgreSQL Client but
never closes it on the success path, leaking a connection; inside the while loop
after successfully connecting and running the query (the success branch just
before the break) call await client.end() to close the client (using the
existing Client instance created with new Client({ connectionString: databaseUrl
})) before breaking, ensuring the connection is always closed regardless of the
success or error path while keeping the existing timeout/retry logic around
start, CONN_TIMEOUT and CONN_RETRY_DELAY.

In `@src/backend/pgsql/tsconfig.json`:
- Around line 5-7: The tsconfig path alias currently uses the mapping "~/*":
["src/*"]; update this mapping to "~/*": ["./src/*"] to match the core package
convention and ensure consistent module resolution in the composite TypeScript
project (change the right-hand side array entry from "src/*" to "./src/*").
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 5f474d0f-fffa-4b8f-bf32-7e90651c6eb7

📥 Commits

Reviewing files that changed from the base of the PR and between 404b794 and 257f295.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (22)
  • src/backend/backend/src/backend.ts
  • src/backend/backend/src/index.ts
  • src/backend/backend/src/local.test.ts
  • src/backend/backend/src/local.ts
  • src/backend/pgsql/README.md
  • src/backend/pgsql/compose.yaml
  • src/backend/pgsql/drizzle.config.ts
  • src/backend/pgsql/drizzle/20260420160720_tiresome_zaladane/migration.sql
  • src/backend/pgsql/drizzle/20260420160720_tiresome_zaladane/snapshot.json
  • src/backend/pgsql/drizzle/20260421151532_first_cammi/migration.sql
  • src/backend/pgsql/drizzle/20260421151532_first_cammi/snapshot.json
  • src/backend/pgsql/eslint.config.mjs
  • src/backend/pgsql/package.json
  • src/backend/pgsql/src/db/schema.ts
  • src/backend/pgsql/src/index.ts
  • src/backend/pgsql/test/index.test.ts
  • src/backend/pgsql/tsconfig.build.json
  • src/backend/pgsql/tsconfig.json
  • src/backend/pgsql/turbo.json
  • src/backend/pgsql/vitest.config.mjs
  • src/backend/remote/package.json
  • src/backend/remote/src/backend.ts

Comment thread src/backend/pgsql/compose.yaml
Comment thread src/backend/pgsql/package.json
Comment thread src/backend/pgsql/src/db/schema.ts Outdated
Comment thread src/backend/pgsql/src/db/schema.ts Outdated
Comment thread src/backend/pgsql/src/index.ts Outdated
Comment thread src/backend/pgsql/src/index.ts
Comment thread src/backend/pgsql/src/index.ts
Comment thread src/backend/pgsql/test/index.test.ts
Comment thread src/backend/pgsql/tsconfig.json
@Minterl Minterl force-pushed the feat/backend-pgsql branch 2 times, most recently from daf02bc to 96888fd Compare April 22, 2026 18:32

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 7

♻️ Duplicate comments (1)
src/backend/pgsql/src/index.ts (1)

208-282: ⚠️ Potential issue | 🟠 Major

while: 'read' in write-path validation errors.

Inside commitEvent (a write operation), the handle/url validation failures report while: 'read' at lines 216, 225, 246, and 255. These should be 'write', matching the rest of the write path (and matching the past-review guidance that update/create validation failures belong to the write operation).

🩹 Suggested fix
-                        while: 'read',
+                        while: 'write',

Apply to each of the four satisfies BackendError blocks under case 'endpoint.created' and case 'endpoint.updated'.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/backend/pgsql/src/index.ts` around lines 208 - 282, In commitEvent's
write-path cases ('endpoint.created' and 'endpoint.updated') the validation
error objects for createEndpointHandle and createEndpointUrl incorrectly set
while: 'read'; change those four occurrences to while: 'write' so the
BackendError objects in the create/update branches reflect the write operation
(update the error objects in the cases handling createEndpointHandle and
createEndpointUrl inside the 'endpoint.created' and 'endpoint.updated' switch
blocks).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/backend/backend/src/backend.ts`:
- Around line 197-210: The overloads on describeBackend (with TStateWriteMode
extends 'snapshot' and extends 'event') force callers to pass a literal mode and
can confuse consumers who try to parameterize over the union
BackendStateWriteMode; add a concise JSDoc above the describeBackend overloads
explaining that the overload set intentionally requires a literal 'snapshot' or
'event' mode (referencing describeBackend and BackendDescriptor) and that
callers should choose the concrete mode rather than a union type, so downstream
integrators understand why a union type will not match an overload.

In `@src/backend/pgsql/package.json`:
- Around line 32-37: The package.json exports entry is incorrect: change the
subpath key "./index" to the root "." so consumers can import
`@hookplane/backend-pgsql`, and within that export object reorder the conditional
map to place the "types" condition before "default" (i.e., ensure the export for
"." contains "types": "./dist/index.d.ts" followed by "default":
"./dist/index.js") so Node/TypeScript module resolution picks up the .d.ts types
correctly.

In `@src/backend/pgsql/src/index.ts`:
- Around line 119-130: The current null check against endpoints is dead because
db.select(...).where(...) always returns an array; update the logic in the block
that calls
db.select().from(schema.endpoints).where(eq(schema.endpoints.providerId,
provider.id)) to either (A) treat an empty array as "not found" by checking
endpoints.length === 0 and returning the NotFoundError (adjusting the
message/context as needed), or (B) remove the unreachable if branch entirely if
an empty endpoints array should be allowed; make the change where endpoints,
db.select, schema.endpoints, provider.id and providerName are referenced in
index.ts.
- Around line 284-311: The handler for case 'endpoint.deleted' currently
auto-deletes the provider by calling tx.delete(schema.providers) when no
endpoints remain, which causes surprising lifecycle coupling; remove that
auto-delete and instead either (a) delete the tx.delete(schema.providers) call
and leave a TODO/log so providers persist independently, or (b) gate the
deletion behind an explicit cleanup policy/config flag (e.g., a
cleanupProvidersOnLastEndpoint flag checked inside the 'endpoint.deleted' case)
so deletion only happens when configured; update the block handling
endpoint.deleted (the case 'endpoint.deleted' branch that queries
schema.endpoints and calls tx.delete(schema.providers)) accordingly and adjust
tests to expect providers to remain unless the cleanup policy is enabled.
- Around line 132-156: The error messages concatenate the Result objects (handle
and url) instead of their underlying errors; when createEndpointHandle or
createEndpointUrl returns an Err, include the actual error (e.g., handle.error
or url.error / the Err property provided by your Result type) in the
BackendError message and/or set it as the error's cause. Update the error
returns in the endpoint initialization block (where endpointIndex is built) and
the analogous checks in commitEvent (the checks around
createEndpointHandle/createEndpointUrl at the mentioned lines) to use the
Result's error property rather than stringifying the Result object, and ensure
you still satisfy the BackendError shape.
- Around line 70-82: The transaction callback passed to
ResultAsync.fromSafePromise in commit uses tx.rollback() which throws
TransactionRollbackError, so the subsequent return err(...) is unreachable and
thrown errors escape the wrapper; replace ResultAsync.fromSafePromise with
ResultAsync.fromPromise (or otherwise catch thrown DB errors) around
db.transaction so thrown TransactionRollbackError and other rejections are
converted into Err<BackendError>, and inside the transaction callback avoid
calling tx.rollback() directly — instead return an Err result (from commitEvent)
and let the transaction resolver handle committing/rolling back; update the
commit function (and similarly read, readSecret, writeSecret, deleteSecret) to
use db.transaction with a promise-returning callback and map thrown errors to
BackendError before returning a Result.

In `@src/backend/pgsql/test/index.test.ts`:
- Around line 30-36: Reorder the teardown deletes in the finally block to remove
child rows before parent rows: run await client.query('delete from endpoints')
and await client.query('delete from secrets') (the child tables) before await
client.query('delete from providers') (the parent), then await client.end();
update the delete sequence in the finally block in
src/backend/pgsql/test/index.test.ts accordingly.

---

Duplicate comments:
In `@src/backend/pgsql/src/index.ts`:
- Around line 208-282: In commitEvent's write-path cases ('endpoint.created' and
'endpoint.updated') the validation error objects for createEndpointHandle and
createEndpointUrl incorrectly set while: 'read'; change those four occurrences
to while: 'write' so the BackendError objects in the create/update branches
reflect the write operation (update the error objects in the cases handling
createEndpointHandle and createEndpointUrl inside the 'endpoint.created' and
'endpoint.updated' switch blocks).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 4d8692d7-700e-4ebc-a378-8ea02bfcd455

📥 Commits

Reviewing files that changed from the base of the PR and between 257f295 and 96888fd.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (7)
  • src/backend/backend/src/backend.ts
  • src/backend/backend/src/local.test.ts
  • src/backend/backend/src/local.ts
  • src/backend/pgsql/package.json
  • src/backend/pgsql/src/index.ts
  • src/backend/pgsql/test/index.test.ts
  • src/backend/remote/src/backend.ts

Comment thread src/backend/backend/src/backend.ts
Comment thread src/backend/pgsql/package.json
Comment thread src/backend/pgsql/src/index.ts
Comment on lines +119 to +130
const endpoints = await db
.select()
.from(schema.endpoints)
.where(eq(schema.endpoints.providerId, provider.id))
if (!endpoints) {
return err({
kind: 'BackendError',
name: 'NotFoundError',
message: `no endpoints found for provider name ${providerName}`,
while: 'read',
} satisfies BackendError)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Dead if (!endpoints) check.

db.select()...where(...) always resolves to an array — possibly empty, but never nullish. This branch is unreachable; if you want to distinguish "provider has no endpoints" from "provider not found", gate on endpoints.length === 0 (though the current read semantics arguably allow an empty provider state). Otherwise, remove the branch.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/backend/pgsql/src/index.ts` around lines 119 - 130, The current null
check against endpoints is dead because db.select(...).where(...) always returns
an array; update the logic in the block that calls
db.select().from(schema.endpoints).where(eq(schema.endpoints.providerId,
provider.id)) to either (A) treat an empty array as "not found" by checking
endpoints.length === 0 and returning the NotFoundError (adjusting the
message/context as needed), or (B) remove the unreachable if branch entirely if
an empty endpoints array should be allowed; make the change where endpoints,
db.select, schema.endpoints, provider.id and providerName are referenced in
index.ts.

Comment on lines +132 to +156
const endpointIndex: EndpointIndex<Provider> = new Map()
for (const endpoint of endpoints) {
const handle = createEndpointHandle(endpoint.handle)
if (handle.isErr()) {
return err({
kind: 'BackendError',
name: 'InternalError',
message: 'invalid endpoint handle: ' + handle,
while: 'read',
} satisfies BackendError)
}
const url = createEndpointUrl(endpoint.url)
if (url.isErr()) {
return err({
kind: 'BackendError',
name: 'InternalError',
message: 'invalid endpoint url: ' + url,
while: 'read',
} satisfies BackendError)
}
endpointIndex.set(handle.value, {
url: url.value,
events: endpoint.events,
config: endpoint.config,
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

String-concatenating a Result object into error messages.

handle and url here (and identically in commitEvent at lines 215, 224, 245, 254) are Result instances, not strings. 'invalid endpoint handle: ' + handle stringifies the Result object (typically [object Object] or Err(...)), making the error messages useless for diagnosis. Surface the underlying error instead.

🩹 Suggested fix
-                message: 'invalid endpoint handle: ' + handle,
+                message: `invalid endpoint handle: ${String(handle.error)}`,
...
-                message: 'invalid endpoint url: ' + url,
+                message: `invalid endpoint url: ${String(url.error)}`,

Consider also forwarding the underlying error as cause so it's not lost.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const endpointIndex: EndpointIndex<Provider> = new Map()
for (const endpoint of endpoints) {
const handle = createEndpointHandle(endpoint.handle)
if (handle.isErr()) {
return err({
kind: 'BackendError',
name: 'InternalError',
message: 'invalid endpoint handle: ' + handle,
while: 'read',
} satisfies BackendError)
}
const url = createEndpointUrl(endpoint.url)
if (url.isErr()) {
return err({
kind: 'BackendError',
name: 'InternalError',
message: 'invalid endpoint url: ' + url,
while: 'read',
} satisfies BackendError)
}
endpointIndex.set(handle.value, {
url: url.value,
events: endpoint.events,
config: endpoint.config,
})
const endpointIndex: EndpointIndex<Provider> = new Map()
for (const endpoint of endpoints) {
const handle = createEndpointHandle(endpoint.handle)
if (handle.isErr()) {
return err({
kind: 'BackendError',
name: 'InternalError',
message: `invalid endpoint handle: ${String(handle.error)}`,
while: 'read',
} satisfies BackendError)
}
const url = createEndpointUrl(endpoint.url)
if (url.isErr()) {
return err({
kind: 'BackendError',
name: 'InternalError',
message: `invalid endpoint url: ${String(url.error)}`,
while: 'read',
} satisfies BackendError)
}
endpointIndex.set(handle.value, {
url: url.value,
events: endpoint.events,
config: endpoint.config,
})
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/backend/pgsql/src/index.ts` around lines 132 - 156, The error messages
concatenate the Result objects (handle and url) instead of their underlying
errors; when createEndpointHandle or createEndpointUrl returns an Err, include
the actual error (e.g., handle.error or url.error / the Err property provided by
your Result type) in the BackendError message and/or set it as the error's
cause. Update the error returns in the endpoint initialization block (where
endpointIndex is built) and the analogous checks in commitEvent (the checks
around createEndpointHandle/createEndpointUrl at the mentioned lines) to use the
Result's error property rather than stringifying the Result object, and ensure
you still satisfy the BackendError shape.

Comment on lines +284 to +311
case 'endpoint.deleted': {
const result = await tx
.delete(schema.endpoints)
.where(
and(
eq(schema.endpoints.providerId, provider.id),
eq(schema.endpoints.handle, event.handle),
),
)
if (result.rowCount === 0) {
return err({
kind: 'BackendError',
name: 'NotFoundError',
message: `endpoint ${event.handle} not found`,
while: 'write',
} satisfies NotFoundError)
}
const providerEvents = await tx
.select()
.from(schema.endpoints)
.where(eq(schema.endpoints.providerId, provider.id))
if (providerEvents.length === 0) {
await tx
.delete(schema.providers)
.where(eq(schema.providers.id, provider.id))
}
break
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Auto-deleting the provider on last endpoint delete is a surprising side effect.

When the final endpoint is deleted, the provider row is also removed. This couples provider lifecycle to endpoints, which:

  • breaks referential intent if providers are meant to persist independently (e.g., a later endpoint.created for the same provider races and must re-insert);
  • is not symmetric with endpoint.created, which only auto-creates providers;
  • and can cause surprising ProviderNotFoundError from read after an unrelated delete, even when the caller still considers the provider configured.

The // TODO: we need a better solution for "stale" providers comment at line 97 already acknowledges this. Consider removing the auto-delete here and cleaning up stale providers out-of-band, or gating this on an explicit cleanup policy.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/backend/pgsql/src/index.ts` around lines 284 - 311, The handler for case
'endpoint.deleted' currently auto-deletes the provider by calling
tx.delete(schema.providers) when no endpoints remain, which causes surprising
lifecycle coupling; remove that auto-delete and instead either (a) delete the
tx.delete(schema.providers) call and leave a TODO/log so providers persist
independently, or (b) gate the deletion behind an explicit cleanup policy/config
flag (e.g., a cleanupProvidersOnLastEndpoint flag checked inside the
'endpoint.deleted' case) so deletion only happens when configured; update the
block handling endpoint.deleted (the case 'endpoint.deleted' branch that queries
schema.endpoints and calls tx.delete(schema.providers)) accordingly and adjust
tests to expect providers to remain unless the cleanup policy is enabled.

Comment on lines +30 to +36
} finally {
await client.query('delete from providers')
await client.query('delete from endpoints')
await client.query('delete from secrets')

await client.end()
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
fd -t f 'schema.ts' src/backend/pgsql/src/db | xargs -I{} cat {}

Repository: interlandi-io/hookplane

Length of output: 1038


🏁 Script executed:

#!/bin/bash
# Check the actual test file to see the cleanup block in context
sed -n '25,40p' src/backend/pgsql/test/index.test.ts

Repository: interlandi-io/hookplane

Length of output: 507


Delete child records before parent records for clarity and safety.

The foreign key constraints have ON DELETE CASCADE configured, so deletions will succeed in any order. However, it's clearer and safer to delete child records before parent records.

🧹 Suggested order
         } finally {
-            await client.query('delete from providers')
-            await client.query('delete from endpoints')
             await client.query('delete from secrets')
+            await client.query('delete from endpoints')
+            await client.query('delete from providers')

             await client.end()
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
} finally {
await client.query('delete from providers')
await client.query('delete from endpoints')
await client.query('delete from secrets')
await client.end()
}
} finally {
await client.query('delete from secrets')
await client.query('delete from endpoints')
await client.query('delete from providers')
await client.end()
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/backend/pgsql/test/index.test.ts` around lines 30 - 36, Reorder the
teardown deletes in the finally block to remove child rows before parent rows:
run await client.query('delete from endpoints') and await client.query('delete
from secrets') (the child tables) before await client.query('delete from
providers') (the parent), then await client.end(); update the delete sequence in
the finally block in src/backend/pgsql/test/index.test.ts accordingly.

@Minterl Minterl force-pushed the feat/backend-pgsql branch from 96888fd to 1d92098 Compare April 22, 2026 19:13
@Minterl Minterl force-pushed the feat/backend-pgsql branch from c5699d9 to 96fd512 Compare April 22, 2026 20:37
@Minterl Minterl merged commit 1013bd8 into master Apr 22, 2026
1 of 2 checks passed
Minterl added a commit that referenced this pull request Apr 22, 2026
* changeset

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant