Skip to content

fix(ai): serialize ai_messages content before insert on Postgres - #592

Open
diogenxs wants to merge 1 commit into
CoreBunch:mainfrom
lazybirds-cloud:lazybirds/pg
Open

diogenxs wants to merge 1 commit into
CoreBunch:mainfrom
lazybirds-cloud:lazybirds/pg

Conversation

@diogenxs

@diogenxs diogenxs commented Oct 1, 2026 •

Copy link
Copy Markdown

Fixes #593.

Problem

On Postgres, ai_messages.content_json is a text column. Bun SQL binds a plain array to a text parameter via String(), so every AI message is stored as the literal '[object Object]'. On replay the row fails schema validation, history comes back with empty content, and the provider call fails (Anthropic: 400 user messages must have non-empty content).

Fix

JSON.stringify(input.content) at the single insert in server/ai/conversations/store.ts.

  • Postgres: the text column now holds real JSON; normalizePostgresRow already JSON.parses string-valued *_json columns on read.
  • SQLite: toBindable passes strings through unchanged, so what gets stored is byte-for-byte the same as before.

Why narrow (revised)

The first version of this PR serialized every object param in the Postgres adapter. As @qu4ntom pointed out, that breaks jsonb columns: Bun binds objects to jsonb correctly today, and a pre-stringified value would be stored as a JSON string scalar, so jsonField() queries (data-row filters, plugins) would stop matching new rows. content_json is the only text JSON column in migrations-pg.ts, so the fix belongs at that call site.

Not included

  • Rows already stored as '[object Object]' aren't recoverable. They read back as empty blocks, which the read path already handles. Migrating the column to jsonb could be a follow-up if you'd prefer that over the call-site fix.

Verification

  • bun test src/__tests__/ai/ (conversation / tool-result / image round-trip tests): green
  • typecheck and eslint clean

🤖 Generated with Claude Code

@diogenxs
diogenxs force-pushed the lazybirds/pg branch 3 times, most recently from 2719644 to e149278 Compare October 1, 2026 19:12
@diogenxs

diogenxs commented Oct 1, 2026

Copy link
Copy Markdown
Author

Filed the underlying report as #593. Rebased onto current main (the adapter gained ownsPool/close() — conflict resolved, serializer untouched) and the full test suite runs green locally; the only failures I see are the canvas/DOM tests that also fail on pristine main in my environment, nothing related to this change.

@qu4ntom

qu4ntom commented Oct 13, 2026

Copy link
Copy Markdown

Root cause confirmed. We hit this on 0.0.21 (Postgres, Bun 1.4.2): AI chat messages are stored as [object Object], and Anthropic answers with 400 user messages must have non-empty content.

One caveat with this approach: stringifying all object params breaks jsonb columns. Bun binds objects to jsonb correctly today, but a pre-stringified value is stored as a JSON string scalar:

${{ theme: 'dark' }}                 // jsonb_typeof = object, ->>'theme' = 'dark'
${JSON.stringify({ theme: 'dark' })} // jsonb_typeof = string, ->>'theme' = NULL

Reads still work (normalizePostgresRow re-parses strings), but jsonField() queries, e.g. data-row filters and plugins, stop matching for newly written rows.

ai_messages.content_json is the only text JSON column on Postgres, so a narrower fix seems safer, either:

  • JSON.stringify(input.content) in server/ai/conversations/store.ts (verified), or
  • migrating that column to jsonb (after cleaning up existing [object Object] rows).

On Postgres `ai_messages.content_json` is `text`, and Bun SQL binds a plain
array to a text parameter via String(), storing '[object Object]'. Replaying
that history yields empty user content and provider calls fail (Anthropic:
400 "user messages must have non-empty content").

JSON.stringify the content at the insert. SQLite passes strings through
unchanged, and reads on both dialects JSON.parse `_json` strings, so
behavior there is identical. Kept narrow on purpose: stringifying every
object param in the PG adapter would turn values bound to jsonb columns
into JSON string scalars and break jsonField() queries.

Fixes CoreBunch#593.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@diogenxs diogenxs changed the title fix(db): serialize non-binary JS values for Postgres — mirror SQLite toBindable fix(ai): serialize ai_messages content before insert on Postgres Oct 18, 2026
@diogenxs

Copy link
Copy Markdown
Author

Thanks @qu4ntom, good catch on jsonb. Pre-stringifying in the adapter would have turned objects bound to jsonb columns into string scalars and broken jsonField() matching. I've reworked the PR to the narrow fix you suggested: JSON.stringify(input.content) at the ai_messages insert in server/ai/conversations/store.ts, rebased on current main, and the adapter is left untouched. SQLite stores exactly the same thing as before (strings pass through toBindable). Existing [object Object] rows are left alone, since they read back as empty blocks. Happy to follow up with a jsonb migration for that column if the maintainers prefer it.

This branch has not been deployed

No deployments
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.

[Bug]: Postgres — *_json columns store '[object Object]'; AI chat history corrupts and provider calls fail

2 participants