{"slug": "show-hn-preview-postgres-writes-from-ai-agents-with-xmin-checks-pg-dry-run", "title": "Show HN: Preview Postgres writes from AI agents with xmin checks (pg-dry-run)", "summary": "Pg-dry-run, a new open-source library, lets developers preview the effects of AI-generated Postgres writes before they are applied, converting INSERT, UPDATE, and DELETE statements into row-level JSON proposals that can be inspected and approved. The tool, installable via npm, reports affected rows, before-and-after values, and foreign key impacts, and rejects applies if underlying data changes, addressing the risk of AI agents executing unintended database modifications.", "body_md": "**Preview the effect of agent-generated Postgres writes before they run.**\n\nTurn `INSERT`\n\n, `UPDATE`\n\n, and `DELETE`\n\ninto\nrow-level proposals you can inspect, approve, and apply safely.\n\n[Why](#why-this-exists)\n· [Quick start](#quick-start)\n· [How it works](#how-it-works)\n· [Polycore](#how-polycore-uses-pg-dry-run)\n· [Safety](#safety-model)\n· [API](#api)\n\nAI agents increasingly inspect schemas, generate SQL, and operate real applications. Read access can be contained with a read-only role. Writes need a way to inspect the effect before production data changes.\n\nAn agent can generate a perfectly valid statement that does something nobody intended:\n\n```\nUPDATE profiles SET status = 'suspended' WHERE email LIKE '%@acme.com';\n```\n\nThe SQL looks reasonable, but production data decides whether it updates one account or fourteen. Inserts can pick up defaults that never appear in the statement, and deletes can reach other tables through foreign keys. Static checks and human review both see the query text, not its effect on the current database.\n\nThe useful safety question is not “Does this SQL look reasonable?” It is “Which rows will change, how will they change, and what else will be affected?”\n\n`pg-dry-run`\n\nevaluates a write as a read and returns a plain JSON proposal with\nthe affected rows and changes. It later applies only what was previewed; if an\nexisting row changed in the meantime, the entire apply is rejected.\n\n`agent-generated SQL → read-only preview → policy or approval → guarded apply`\n\nIt does not run an AI model or prescribe an approval UI. It provides the database mechanism an agent, CLI, admin tool, or approval system can build on.\n\n```\nnpm install pg-dry-run\n```\n\nCreate a runner and propose a write:\n\n``` js\nimport { createDryRunner } from \"pg-dry-run\";\n\nconst pg = createDryRunner({ url: process.env.DATABASE_URL });\n\nconst proposal = await pg.propose(\n  \"UPDATE profiles SET status = $1 WHERE email LIKE $2\",\n  [\"suspended\", \"%@acme.com\"],\n);\n\nproposal.rowCount; // 14, not the 1 you expected\n```\n\n`proposal`\n\nis plain JSON. A terminal, agent, or approval screen can render it\nlike this:\n\n```\n14 rows would change in profiles\n\n  a3f2…  alice@acme.com        status: active -> suspended\n  d579…  bob@acme.com          status: active -> suspended\n  9c46…  ci-bot@acme.com       status: active -> suspended\n  b700…  intern-2024@acme.com  status: active -> suspended\n  … 10 more\n\nwarning\n  BEFORE UPDATE trigger touch_updated_at may change the values actually written\n```\n\nPass the proposal through your own policy or approval flow. `apply()`\n\nwrites the\npreviewed rows in one transaction:\n\n``` js\nif (await yourApprovalFlow(proposal)) {\n  const receipt = await pg.apply(proposal);\n  receipt.rowsAffected; // 14\n}\n```\n\n| Statement | What `pg-dry-run` reports |\n|---|---|\n`UPDATE` |\nEvery matched row, its primary key, and the before and after values for each assigned column. |\n`DELETE` |\nEvery matched row, plus reachable foreign keys, cascade counts, and restrictions that would make the delete fail. |\n`INSERT` |\nThe complete row the table would create, including values supplied by column defaults. Sequence-generated keys are reported after apply because `nextval()` is itself a write. |\n| All writes | The target table, affected columns, warnings, timestamps, and the derived SQL used for the preview. |\n\nThe default limit is 1,000 rows. Larger writes are refused rather than reduced to a count that nobody can meaningfully review.\n\nThe library parses one `INSERT`\n\n, `UPDATE`\n\n, or `DELETE`\n\nwith PostgreSQL's parser.\nIt rejects statement shapes it cannot represent faithfully.\n\nFor an update or delete, it copies the original predicate as an untouched syntax\ntree into an equivalent `SELECT`\n\n:\n\n```\n-- input\nUPDATE profiles SET status = $1 WHERE email LIKE $2;\n\n-- derived preview\nSELECT\n  id,\n  xmin::text,\n  email::text,\n  status AS \"status.before\",\n  ($1::text)::text AS \"status.after\"\nFROM profiles\nWHERE email LIKE $2;\n```\n\nThe caller or model never supplies the derived query. Read\n`proposal.derivedSql`\n\nto inspect exactly what ran.\n\nThe preview runs inside `BEGIN TRANSACTION READ ONLY`\n\n. The library also reads\nPostgres catalog metadata for primary keys, column types, generated columns,\ntriggers, rewrite rules, unique columns, and foreign keys.\n\nInserts take a slightly different path. Their rows are already explicit, but\nthe finished row is not: the table may supply defaults for columns the\nstatement never mentions. `pg-dry-run`\n\nresolves those values during the preview\nand uses the resolved values during apply.\n\nA `Proposal`\n\ncontains the row-level diff, cascade reach, warnings, derived SQL,\nand the plan needed to rebuild the write. It has no methods and survives JSON\nserialization, so the preview and apply can happen in different processes.\n\nTreat a proposal as a capability, not as an inert report. If it crosses a queue\nor network boundary, authenticate it before passing it back to `apply()`\n\n.\n\nFor updates and deletes, each previewed row carries its primary key and `xmin`\n\n,\nthe Postgres transaction ID for that row version. The apply names those exact\nkeys and versions instead of running the original predicate again.\n\nThat gives the apply two useful properties:\n\n- A row that starts matching the predicate after the preview cannot join the approved set.\n- A previewed row that was modified or deleted causes the whole transaction to\nabort with\n`StateChangedError`\n\n. No partial write lands.\n\nAn insert is pinned to the values resolved during preview. For example, a\n`created_at DEFAULT now()`\n\nkeeps the previewed timestamp rather than evaluating\n`now()`\n\nagain after approval.\n\n`pg-dry-run`\n\nis the effect engine behind\n[Polycore's Postgres write path](https://docs.polycore.ai/pg-dry-run).\nA Polycore runner previews agent-generated SQL beside the database, feeds the\nresulting effect into policy and human approval, then applies the held proposal\nonce the write is cleared. Database credentials stay inside the customer's\ninfrastructure.\n\nThe library handles the Postgres-specific preview and guarded apply. Polycore provides the surrounding identity, policy, approval, environment routing, and audit trail.\n\nA wrong preview is more dangerous than no preview. `pg-dry-run`\n\nthrows\n`UnsupportedStatementError`\n\nwhen it cannot derive an equivalent read.\n\nIt currently refuses:\n\n- anything other than one\n`INSERT`\n\n,`UPDATE`\n\n, or`DELETE`\n\n, including DDL and data-modifying CTEs; - an update or delete without a\n`WHERE`\n\nclause; `UPDATE ... FROM`\n\n,`DELETE ... USING`\n\n, or a statement with a`WITH`\n\nclause;- assignment to an array element or object subfield;\n`INSERT ... SELECT`\n\n;- either form of\n`ON CONFLICT`\n\n; - writes to a generated column or a\n`GENERATED ALWAYS AS IDENTITY`\n\ncolumn; - an update or delete against a table without a primary key;\n- a proposal above\n`maxRows`\n\n, which defaults to 1,000.\n\nThe original predicate is copied, not interpreted. Any predicate Postgres can parse is supported as long as the surrounding statement shape is supported.\n\nOne connection string is enough:\n\n``` js\nconst pg = createDryRunner({\n  url: process.env.DATABASE_URL,\n});\n```\n\nThe library generates the preview query itself, refuses statement shapes it does not understand, and runs the preview in a read-only transaction.\n\nA separate `SELECT`\n\n-only role is still the stronger setup because Postgres\npermissions, rather than library correctness, enforce read-only access:\n\n``` js\nconst pg = createDryRunner({\n  url: process.env.DATABASE_URL, // apply\n  readUrl: process.env.DATABASE_URL_READONLY, // preview\n});\n```\n\nThe proposal reports hazards it can detect but cannot preview:\n\n**Triggers and rewrite rules.** A`BEFORE`\n\ntrigger can change the values being written, and any trigger or rule can write elsewhere. The proposal includes a warning when these exist.**Constraints.** A preview cannot guarantee that the apply will satisfy every unique or check constraint. Assignments to unique columns are flagged.**Volatile expressions.** Expressions such as`gen_random_uuid()`\n\nand`now()`\n\nare evaluated during preview. The resolved value is what apply writes. An insert that evaluates a column default reports a`default_evaluated`\n\nwarning.**Sequence defaults.**`nextval()`\n\ncannot run in a read-only preview. The affected columns are reported in a`deferred_default`\n\nwarning, and generated keys appear on the receipt.**Composite foreign keys.** Cascade discovery follows one column at a time. A composite key is reported as`composite_foreign_key_skipped`\n\nrather than presented as a complete count.**Cascade depth.** Traversal stops at`cascadeDepth`\n\n, which defaults to five. A truncated walk is reported as`cascade_depth_truncated`\n\n.Freezing can change`xmin`\n\nand`VACUUM FREEZE`\n\n.`xmin`\n\nwithout changing row data. That can reject a valid apply, but it cannot allow a stale one.**Postgres only.** The rewrite could be adapted to other transactional databases, but row-version pinning currently depends on Postgres.\n\n```\ncreateDryRunner(options: DryRunnerOptions): DryRunner\n\ninterface DryRunner {\n  propose(statement: string, params?: readonly unknown[]): Promise<Proposal>;\n  apply(proposal: Proposal): Promise<Receipt>;\n  close(): Promise<void>;\n}\n```\n\n| Option | Default | Purpose |\n|---|---|---|\n`url` |\n— | Connection string used for previews and applies. |\n`readUrl` |\n`url` |\nOptional `SELECT` -only connection used for previews. |\n`driver` / `readDriver` |\n— | Supply your own connection or pool implementation. |\n`maxRows` |\n`1000` |\nMaximum rows one proposal may name. |\n`ttlMs` |\n`5 minutes` |\nHow long a proposal remains applicable. |\n`statementTimeoutMs` |\n`10 seconds` |\nPer-statement timeout for previews and applies. |\n`labelColumns` |\ncommon human-readable columns | Preferred columns for row labels. |\n`cascadeDepth` |\n`5` |\nHow far to follow `ON DELETE CASCADE` . |\n\nA `Proposal`\n\ncarries `rowCount`\n\n, `changes`\n\n, `cascades`\n\n, `warnings`\n\n,\n`derivedSql`\n\n, `columns`\n\n, creation and expiry times, and the internal apply plan.\n\nA `Receipt`\n\ncarries `rowsAffected`\n\n, `appliedAt`\n\n, and the primary keys touched by\nthe apply. For an insert, this is where database-generated keys are reported.\n\nErrors extend `PgDryRunError`\n\n:\n\n`UnsupportedStatementError`\n\n`TooManyRowsError`\n\n`ProposalExpiredError`\n\n`StateChangedError`\n\nImplement `Driver`\n\nto bring your own pool or use a non-server Postgres. A driver\nonly needs to provide exclusive use of one session so every statement in a\ntransaction shares the same connection.\n\n```\npnpm install\npnpm verify\n```\n\nTests run against PostgreSQL semantics in-process through\n[PGlite](https://pglite.dev), so the normal development loop needs no server or\ncontainer. See [CONTRIBUTING.md](/polycore/pg-dry-run/blob/main/CONTRIBUTING.md) for the test matrix and\nproject conventions.\n\nSecurity issues should be reported privately. See [SECURITY.md](/polycore/pg-dry-run/blob/main/SECURITY.md).\n\nMIT. See [LICENSE](/polycore/pg-dry-run/blob/main/LICENSE).\n\nBuilt by [Polycore](https://polycore.ai) for governed production\naccess from AI agents and human operators.", "url": "https://wpnews.pro/news/show-hn-preview-postgres-writes-from-ai-agents-with-xmin-checks-pg-dry-run", "canonical_source": "https://github.com/polycore/pg-dry-run", "published_at": "2026-08-31 15:11:42+00:00", "updated_at": "2026-08-31 15:22:25.301039+00:00", "lang": "en", "topics": ["ai-tools", "ai-agents", "developer-tools"], "entities": ["pg-dry-run", "Postgres", "Polycore"], "alternates": {"html": "https://wpnews.pro/news/show-hn-preview-postgres-writes-from-ai-agents-with-xmin-checks-pg-dry-run", "markdown": "https://wpnews.pro/news/show-hn-preview-postgres-writes-from-ai-agents-with-xmin-checks-pg-dry-run.md", "text": "https://wpnews.pro/news/show-hn-preview-postgres-writes-from-ai-agents-with-xmin-checks-pg-dry-run.txt", "jsonld": "https://wpnews.pro/news/show-hn-preview-postgres-writes-from-ai-agents-with-xmin-checks-pg-dry-run.jsonld"}}