Skip to content

drivers(sql): an upsert whose conflictKeys have no backing unique index throws a raw SqliteError — the local twin of #8413's consequence 2 #8445

Description

@os-zhuang

Found while implementing #8413 (PR pending), out of that card's scope and not fixed there. Measured on origin/main @ 2bacd1aa.

What was measured

#8413 fixed the REMOTE Turso face so that a conflictKeys upsert whose target has no backing unique index refuses with an ADR-0112 envelope (VALIDATION_ERROR / 400) instead of a raw SqliteError. While confirming that the local face was the correct reference for that card, the same probe was run against the LOCAL face — which inherits SqlDriver.upsert:

LOCAL upsert('plain', { email: 'a@b.com', title: 'x' }, ['email'])
-> THREW name=SqliteError code=SQLITE_ERROR status=undefined
msg=insert into `plain` (`created_at`, `email`, `id`, `title`, `updated_at`) values (...)

code: 'SQLITE_ERROR', status: undefined — the identical un-enveloped shape #8413 was filed to remove, on the other face.

Why it is a separate card

It is not the same defect and does not belong in #8413:

Why it is worth fixing

mapDataError reads error.code / error.status to build the response envelope; with neither set this falls through to the default branch and ships { "error": "insert into ... values (...)" } — a raw SQL string, with no code, to the caller. That is both unbranchable and an information leak of the statement text, and it is exactly the argument invalidFilterError's docstring already records for the filter refusals (#1116 / #1077's envelope pass).

Adjacent but distinct: #8441 covers a raw driver code reaching client-facing payloads through the batch verbs' code limb. This one is the throw site itself on the upsert path.

Suggested disposition (not proposing the verdict)

The natural shape is the twin of what #8413 landed remotely — recognise the "conflict target is not a key" condition and re-raise it enveloped, first sentence for first sentence with the remote refusal (#5240, one condition one wording). Whether the dialect-spanning recognition is worth a shared predicate in @objectstack/types (beside isUniqueViolationError, which answers the opposite condition) is the part that needs a decision — SQLite answers on the message, and Postgres/MySQL wording for this condition is unmeasured.

Related: #8413 (the remote twin), #8441, #6543 (the shared unique-violation predicate), #1116 / #1077 (the envelope pass this continues).


Generated by Claude Code

Metadata

Metadata

Assignees

Type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions