Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .release-notes/notice-response.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
## Add notice response message support

PostgreSQL sends NoticeResponse messages for non-fatal informational feedback — for example, "table does not exist, skipping" when you run `DROP TABLE IF EXISTS` on a nonexistent table, or `RAISE NOTICE` output from PL/pgSQL functions. Previously, the driver silently discarded these messages.

A new `pg_notice` callback on `SessionStatusNotify` delivers notices as `NoticeResponseMessage` values with the full set of PostgreSQL notice fields (severity, code, message, detail, hint, etc.):

```pony
actor MyNotify is SessionStatusNotify
be pg_notice(session: Session, notice: NoticeResponseMessage) =>
_env.out.print("[" + notice.severity + "] " + notice.code + ": "
+ notice.message)
```

The callback has a default no-op implementation, so existing code is unaffected.
23 changes: 16 additions & 7 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ Only one operation is in-flight at a time. The queue serializes execution. `quer
- `_ResponseParser` primitive: incremental parser consuming from a `Reader` buffer. Returns one parsed message per call, `None` if incomplete, errors on junk.
- `_ResponseMessageParser` primitive: routes parsed messages to the current session state's callbacks. Processes messages synchronously within a query cycle (looping until `ReadyForQuery` or buffer exhaustion), then yields via `s._process_again()` between cycles. This prevents behaviors like `close()` from interleaving between result delivery and query dequeuing. If a callback triggers shutdown during the loop, `on_shutdown` clears the read buffer, causing the next parse to return `None` and exit the loop.

**Supported message types:** AuthenticationOk, AuthenticationMD5Password, AuthenticationSASL, AuthenticationSASLContinue, AuthenticationSASLFinal, BackendKeyData, CommandComplete, CopyInResponse, DataRow, EmptyQueryResponse, ErrorResponse, NotificationResponse, ReadyForQuery, RowDescription, ParseComplete, BindComplete, NoData, CloseComplete, ParameterDescription, PortalSuspended. BackendKeyData is parsed and stored in `_SessionLoggedIn` (`backend_pid`, `backend_secret_key`) for future query cancellation. NotificationResponse is parsed into `_NotificationResponseMessage` and routed to `_SessionLoggedIn.on_notification()`, which delivers `pg_notification` to `SessionStatusNotify`. Extended query acknowledgment messages (ParseComplete, BindComplete, NoData, etc.) are parsed but silently consumed — they fall through the `_ResponseMessageParser` match without routing since the state machine tracks query lifecycle through data-carrying messages only. **Skipped async message types:** ParameterStatus (`'S'`), NoticeResponse (`'N'`) — explicitly matched in the parser and returned as `_SkippedMessage`, then ignored by `_ResponseMessageParser`. These are distinct from `_UnsupportedMessage`, which represents truly unknown message types.
**Supported message types:** AuthenticationOk, AuthenticationMD5Password, AuthenticationSASL, AuthenticationSASLContinue, AuthenticationSASLFinal, BackendKeyData, CommandComplete, CopyInResponse, DataRow, EmptyQueryResponse, ErrorResponse, NoticeResponse, NotificationResponse, ReadyForQuery, RowDescription, ParseComplete, BindComplete, NoData, CloseComplete, ParameterDescription, PortalSuspended. BackendKeyData is parsed and stored in `_SessionLoggedIn` (`backend_pid`, `backend_secret_key`) for future query cancellation. NotificationResponse is parsed into `_NotificationResponseMessage` and routed to `_SessionLoggedIn.on_notification()`, which delivers `pg_notification` to `SessionStatusNotify`. NoticeResponse is parsed into `NoticeResponseMessage` (using shared `_ResponseFieldBuilder` / `_parse_response_fields` with ErrorResponse) and routed via `on_notice()` to `SessionStatusNotify.pg_notice()`. Notices are delivered in all connected states (including during authentication) since PostgreSQL can send them at any time. Extended query acknowledgment messages (ParseComplete, BindComplete, NoData, etc.) are parsed but silently consumed — they fall through the `_ResponseMessageParser` match without routing since the state machine tracks query lifecycle through data-carrying messages only. **Skipped async message type:** ParameterStatus (`'S'`) — explicitly matched in the parser and returned as `_SkippedMessage`, then ignored by `_ResponseMessageParser`. Distinct from `_UnsupportedMessage`, which represents truly unknown message types.

### Public API Types

Expand All @@ -101,7 +101,8 @@ Only one operation is in-flight at a time. The queue serializes execution. `quer
- `FieldDataTypes` = `(Bool | F32 | F64 | I16 | I32 | I64 | None | String)`
- `TransactionStatus` — union type `(TransactionIdle | TransactionInBlock | TransactionFailed)`. Reported via `pg_transaction_status` callback on every `ReadyForQuery`.
- `Notification` — val class wrapping channel name, payload string, and notifying backend's process ID. Delivered via `pg_notification` callback.
- `SessionStatusNotify` interface (tag) — lifecycle callbacks (connected, connection_failed, authenticated, authentication_failed, transaction_status, notification, shutdown)
- `NoticeResponseMessage` — non-fatal PostgreSQL notice with all standard fields (same structure as `ErrorResponseMessage`). Delivered via `pg_notice` callback.
- `SessionStatusNotify` interface (tag) — lifecycle callbacks (connected, connection_failed, authenticated, authentication_failed, transaction_status, notification, notice, shutdown)
- `ResultReceiver` interface (tag) — `pg_query_result(Session, Result)`, `pg_query_failed(Session, Query, (ErrorResponseMessage | ClientQueryError))`
- `PrepareReceiver` interface (tag) — `pg_statement_prepared(Session, name)`, `pg_prepare_failed(Session, name, (ErrorResponseMessage | ClientQueryError))`
- `CopyInReceiver` interface (tag) — `pg_copy_ready(Session)`, `pg_copy_complete(Session, count)`, `pg_copy_failed(Session, (ErrorResponseMessage | ClientQueryError))`. Pull-based: session calls `pg_copy_ready` after `copy_in` and after each `send_copy_data`, letting the client control data flow
Expand Down Expand Up @@ -189,7 +190,12 @@ Tests live in the main `postgres/` package (private test classes), organized acr
- `_TestCopyInShutdownDrainsCopyQueue` — uses `_DoesntAnswerTestServer` that authenticates but never becomes ready; verifies pending `copy_in` calls receive `pg_copy_failed(SessionClosed)` on shutdown
- `_TestCopyInAfterSessionClosed` — integration test: connect, authenticate, close, call `copy_in`; verifies `pg_copy_failed(SessionClosed)`

**`_test_response_parser.pony`** — Parser unit tests (`_TestResponseParser*`) + test message builder classes (`_Incoming*TestMessage`) that construct raw protocol bytes for mock servers across all test files. `_TestResponseParserNotificationResponseMessage` verifies parsing into `_NotificationResponseMessage` with correct fields (including empty-payload edge case). `_TestResponseParserMultipleMessagesAsyncThenAuth` verifies buffer advancement across async message types (two skipped, one parsed notification) followed by AuthenticationOk.
**`_test_notice.pony`** — NoticeResponse tests (mock servers + integration):
- `_TestNoticeDelivery` — mock server authenticates, responds to query with CommandComplete + NoticeResponse + ReadyForQuery; verifies `pg_notice` fires with correct severity, code, and message fields
- `_TestNoticeDuringDataRows` — mock server sends RowDescription + DataRow + NoticeResponse + DataRow + CommandComplete + ReadyForQuery; verifies both query result (2 rows) and notice are delivered
- `_TestNoticeOnDropIfExists` — integration test: executes `DROP TABLE IF EXISTS` on a nonexistent table; verifies `pg_notice` fires with severity "NOTICE" and code "00000"

**`_test_response_parser.pony`** — Parser unit tests (`_TestResponseParser*`) + test message builder classes (`_Incoming*TestMessage`) that construct raw protocol bytes for mock servers across all test files. `_TestResponseParserNotificationResponseMessage` verifies parsing into `_NotificationResponseMessage` with correct fields (including empty-payload edge case). `_TestResponseParserNoticeResponseMessage` verifies parsing into `NoticeResponseMessage` with correct severity, code, and message fields. `_TestResponseParserMultipleMessagesAsyncThenAuth` verifies buffer advancement across async message types (one skipped ParameterStatus, one parsed NoticeResponse, one parsed NotificationResponse) followed by AuthenticationOk.

**`_test_frontend_message.pony`** — Frontend message unit tests (`_TestFrontendMessage*`).

Expand All @@ -199,7 +205,7 @@ Tests live in the main `postgres/` package (private test classes), organized acr

**`_test_mock_message_reader.pony`** — `_MockMessageReader` class that buffers TCP data and extracts complete PostgreSQL frontend messages. Two methods: `read_startup_message()` for startup-format messages (Int32 length + payload — StartupMessage, SSLRequest, CancelRequest) and `read_message()` for standard-format messages (Byte1 type + Int32 length + payload). Both return `(Array[U8] val | None)`. Used by all mock servers that inspect incoming data to ensure state transitions happen on message boundaries, not TCP segment boundaries.

Test helpers: `_ConnectionTestConfiguration` reads env vars with defaults. Several test message builder classes (`_Incoming*TestMessage`) construct raw protocol bytes for unit tests. Mock server tests use ports in the 7669–7691 range and 9667–9668. **Port 7680 is reserved by Windows** (Update Delivery Optimization) and will fail to bind on WSL2 — do not use it.
Test helpers: `_ConnectionTestConfiguration` reads env vars with defaults. Several test message builder classes (`_Incoming*TestMessage`) construct raw protocol bytes for unit tests. Mock server tests use ports in the 7669–7693 range and 9667–9668. **Port 7680 is reserved by Windows** (Update Delivery Optimization) and will fail to bind on WSL2 — do not use it.

## Known Issues and TODOs in Code

Expand All @@ -215,7 +221,7 @@ Test helpers: `_ConnectionTestConfiguration` reads env vars with defaults. Sever

**Authentication:** MD5 password and SCRAM-SHA-256. No SCRAM-SHA-256-PLUS (channel binding), Kerberos, GSS, or certificate auth. Design: [discussion #83](https://github.com/ponylang/postgres/discussions/83).

**Protocol:** Simple query protocol and extended query protocol (parameterized queries via unnamed and named prepared statements). Parameters are text-format only; type OIDs are inferred by the server. LISTEN/NOTIFY notifications are delivered via `pg_notification` callback. COPY FROM STDIN (bulk data loading) via `Session.copy_in()` with pull-based `CopyInReceiver` flow. No COPY TO STDOUT or function calls. Design: [discussion #104](https://github.com/ponylang/postgres/discussions/104).
**Protocol:** Simple query protocol and extended query protocol (parameterized queries via unnamed and named prepared statements). Parameters are text-format only; type OIDs are inferred by the server. LISTEN/NOTIFY notifications are delivered via `pg_notification` callback. NoticeResponse messages (non-fatal informational feedback) are delivered via `pg_notice` callback. COPY FROM STDIN (bulk data loading) via `Session.copy_in()` with pull-based `CopyInReceiver` flow. No COPY TO STDOUT or function calls. Design: [discussion #104](https://github.com/ponylang/postgres/discussions/104).

## PostgreSQL Wire Protocol Reference

Expand Down Expand Up @@ -350,7 +356,7 @@ Can arrive between any other messages (must always handle):
## File Layout

```
postgres/ # Main package (47 files)
postgres/ # Main package (49 files)
postgres.pony # Package-level docstring (user-facing API overview)
notification.pony # Notification val class (channel, payload, pid)
session.pony # Session actor + state machine traits + query sub-state machine
Expand All @@ -368,7 +374,8 @@ postgres/ # Main package (47 files)
session_status_notify.pony # SessionStatusNotify interface
transaction_status.pony # TransactionStatus union type (TransactionIdle, TransactionInBlock, TransactionFailed)
query_error.pony # ClientQueryError types
error_response_message.pony # ErrorResponseMessage + builder
error_response_message.pony # ErrorResponseMessage + _ResponseFieldBuilder (shared with NoticeResponseMessage)
notice_response_message.pony # NoticeResponseMessage (non-fatal server notices)
field.pony # Field class
field_data_types.pony # FieldDataTypes union
row.pony # Row class
Expand Down Expand Up @@ -397,6 +404,7 @@ postgres/ # Main package (47 files)
_test_equality.pony # Equality tests for Field/Row/Rows (example + PonyCheck property)
_test_scram.pony # SCRAM-SHA-256 computation tests
_test_notification.pony # LISTEN/NOTIFY tests (mock servers)
_test_notice.pony # NoticeResponse tests (mock servers + integration)
_test_transaction_status.pony # Transaction status tests (mock servers + integration)
_test_copy_in.pony # COPY IN tests (mock servers + integration)
assets/test-cert.pem # Self-signed test certificate for SSL unit tests
Expand All @@ -409,6 +417,7 @@ examples/named-prepared-query/named-prepared-query-example.pony # Named prepared
examples/crud/crud-example.pony # Multi-query CRUD workflow
examples/cancel/cancel-example.pony # Query cancellation with pg_sleep
examples/copy-in/copy-in-example.pony # Bulk data loading with COPY FROM STDIN
examples/notice/notice-example.pony # NoticeResponse handling with DROP IF EXISTS
examples/transaction-status/transaction-status-example.pony # Transaction status tracking with BEGIN/COMMIT
.ci-dockerfiles/pg-ssl/ # Dockerfile + init scripts for SSL-enabled PostgreSQL CI container (SCRAM-SHA-256 default + MD5 user)
```
4 changes: 4 additions & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ Asynchronous notifications using PostgreSQL's LISTEN/NOTIFY mechanism. Subscribe

Bulk data loading using `COPY ... FROM STDIN`. Creates a table, loads three rows of tab-delimited text data via `Session.copy_in()`, verifies the data with a SELECT, then drops the table. Demonstrates the pull-based `CopyInReceiver` interface: `pg_copy_ready` fires after each `send_copy_data`, and `finish_copy` completes the operation.

## notice

Server notice handling using `pg_notice`. Executes `DROP TABLE IF EXISTS` on a nonexistent table, which triggers a PostgreSQL `NoticeResponse`, and prints the notice fields (severity, code, message). Shows how `SessionStatusNotify.pg_notice` delivers non-fatal informational messages from the server.

## transaction-status

Transaction status tracking using `pg_transaction_status`. Sends `BEGIN` and `COMMIT` and prints the `TransactionStatus` reported at each step. Shows how `SessionStatusNotify.pg_transaction_status` fires on every `ReadyForQuery` with `TransactionIdle`, `TransactionInBlock`, or `TransactionFailed`.
80 changes: 80 additions & 0 deletions examples/notice/notice-example.pony
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
use "cli"
use "collections"
use lori = "lori"
// in your code this `use` statement would be:
// use "postgres"
use "../../postgres"

actor Main
new create(env: Env) =>
let server_info = ServerInfo(env.vars)
let auth = lori.TCPConnectAuth(env.root)

let client = Client(auth, server_info, env.out)

// This example demonstrates NoticeResponse handling. It executes
// DROP TABLE IF EXISTS on a nonexistent table, which triggers a
// server notice, then prints the notice fields.
actor Client is (SessionStatusNotify & ResultReceiver)
let _session: Session
let _out: OutStream

new create(auth: lori.TCPConnectAuth, info: ServerInfo, out: OutStream) =>
_out = out
_session = Session(
ServerConnectInfo(auth, info.host, info.port),
DatabaseConnectInfo(info.username, info.password, info.database),
this)

be close() =>
_session.close()

be pg_session_authenticated(session: Session) =>
_out.print("Authenticated.")
_out.print("Dropping nonexistent table to trigger a notice...")
session.execute(
SimpleQuery("DROP TABLE IF EXISTS nonexistent_example_table"), this)

be pg_session_authentication_failed(
s: Session,
reason: AuthenticationFailureReason)
=>
_out.print("Failed to authenticate.")

be pg_notice(session: Session, notice: NoticeResponseMessage) =>
_out.print("Notice received:")
_out.print(" severity: " + notice.severity)
_out.print(" code: " + notice.code)
_out.print(" message: " + notice.message)

be pg_query_result(session: Session, result: Result) =>
_out.print("Query completed.")
_out.print("Done.")
close()

be pg_query_failed(session: Session, query: Query,
failure: (ErrorResponseMessage | ClientQueryError))
=>
match failure
| let e: ErrorResponseMessage =>
_out.print("Query failed: [" + e.severity + "] " + e.code + ": "
+ e.message)
| let e: ClientQueryError =>
_out.print("Query failed: client error")
end
close()

class val ServerInfo
let host: String
let port: String
let username: String
let password: String
let database: String

new val create(vars: (Array[String] val | None)) =>
let e = EnvVars(vars)
host = try e("POSTGRES_HOST")? else "127.0.0.1" end
port = try e("POSTGRES_PORT")? else "5432" end
username = try e("POSTGRES_USERNAME")? else "postgres" end
password = try e("POSTGRES_PASSWORD")? else "postgres" end
database = try e("POSTGRES_DATABASE")? else "postgres" end
5 changes: 3 additions & 2 deletions postgres/_response_message_parser.pony
Original file line number Diff line number Diff line change
Expand Up @@ -55,11 +55,12 @@ primitive _ResponseMessageParser
s.state.on_backend_key_data(s, msg)
| let msg: _NotificationResponseMessage =>
s.state.on_notification(s, msg)
| let msg: NoticeResponseMessage =>
s.state.on_notice(s, msg)
| let msg: _CopyInResponseMessage =>
s.state.on_copy_in_response(s, msg)
| _SkippedMessage =>
// Known async message (ParameterStatus, NoticeResponse) —
// intentionally not routed.
// Known async message (ParameterStatus) — intentionally not routed.
None
| let msg: _EmptyQueryResponseMessage =>
s.state.on_empty_query_response(s)
Expand Down
Loading