Skip to content

fix(adapters): transaction outcome, silent driver errors, await, and CI running the tests - #3

Merged
JustGodWork merged 7 commits into
developfrom
pr/adapters-promises-and-ci
Jul 22, 2026
Merged

fix(adapters): transaction outcome, silent driver errors, await, and CI running the tests#3
JustGodWork merged 7 commits into
developfrom
pr/adapters-promises-and-ci

Conversation

@JustGodWork

Copy link
Copy Markdown
Owner

Seven changes covering the async error path, the built-in promise and the CI. Based on #2, review #1 and #2 first.

Every successful transaction was reported as a rollback. finish(committed and nil or "..."): true and nil is nil, which falls through to the error string, so the callback always received it. The COMMIT had happened, the caller saw a failure, and a naive retry ran the whole transaction a second time. oxmysql is the only adapter advertising transaction support, so the feature was unusable. The self-test covers transactions with a mock that short-circuits that exact line.

No driver error ever reached the caller. nanos passed nil as the error unconditionally on the async path, so a failed query read as an empty result set: find() on a missing table resolved nil, meaning "not found", instead of rejecting. The synchronous path handled it correctly, which is what gave the omission away. oxmysql now catches a raising export, and inside a transaction treats a nil return from query() as the failure oxmysql documents it to be, rather than an empty result the transaction body keeps working from.

Failed statements are now logged through the configured logger. A driver error previously only reached the rejected promise, and the most common call style, no :catch and no :await, discards it. A failed write produced no output at all on a live server.

await() could return before the promise settled. One yield, no loop, so any host scheduler resuming the coroutine made it return a nil value. FiveM and nanos both run their own scheduler, and the built-in provider is the default. The waiting coroutine was also stored in a single slot, so a second waiter on the same promise replaced the first, which was then never resumed at all.

queue_until_ready leaked its callbacks. _flush_ready only ran on the success path, so a failing sync() left every queued operation with its callback never invoked: no resolution, no rejection, no timeout, and any awaiting coroutine suspended for good. That is exactly the scenario the option exists for, a database not reachable at boot.

PostgreSQL was mapped to the mysql dialect as "close enough for our SQL". It is not: that dialect quotes with backticks and appends ENGINE=InnoDB, both rejected at the first token, while supports_returning still advertised the engine as supported. It now raises with a message pointing at the alternatives.

CI ran build.lua and pushed dist/ to master without ever running the test suites, so nothing stood between a source regression and the published bundle. Both suites now run against the freshly built dist/ and a failure stops the job before the commit step.

Known limit, not addressed here: a callback the driver never invokes at all still hangs. That needs a timeout the adapters cannot provide on their own.

Suite: 341 passing, 260 before this series.

@JustGodWork
JustGodWork force-pushed the pr/model-and-relations branch from 7772186 to 21dda5a Compare July 22, 2026 00:45
@JustGodWork
JustGodWork force-pushed the pr/adapters-promises-and-ci branch from 4d3b974 to ddc08dd Compare July 22, 2026 00:45
@JustGodWork
JustGodWork force-pushed the pr/model-and-relations branch from 21dda5a to fc22761 Compare July 22, 2026 01:00
@JustGodWork
JustGodWork force-pushed the pr/adapters-promises-and-ci branch from ddc08dd to bb86c42 Compare July 22, 2026 01:00
@JustGodWork
JustGodWork force-pushed the pr/model-and-relations branch from fc22761 to 89ae994 Compare July 22, 2026 01:03
transaction() ended with `finish(committed and nil or "...")`. Since
`true and nil` is nil, the expression always fell through to the error
string, so every successful COMMIT was reported to the caller as a
rollback. The data was written, the caller saw a failure, and a retry
duplicated the whole transaction.

Replaced with an explicit branch and added regression tests exercising
the real adapter for both outcomes.
Neither adapter could report a failed query, so the three outcomes of a
broken statement were an empty result set, total silence, or a coroutine
suspended for good.

nanos passed nil as the error unconditionally on the async path, so a
callback carrying a driver error read as zero rows: find() on a missing
table resolved nil ("not found") instead of rejecting. The sync path
already handled it, which is what gave the omission away.

oxmysql now catches a raising export, and inside a transaction treats a
nil return from query() as the failure oxmysql documents it to be rather
than an empty result the body would keep working from.

A callback the driver never invokes at all still hangs; that needs a
timeout the adapters cannot provide on their own.
await() yielded exactly once and stored the waiting coroutine in a single
slot. Two consequences on hosts that run their own scheduler, which is
the case for both FiveM and nanos:

  - any foreign resume made await() return while the promise was still
    pending, handing back a nil value;
  - a second coroutine awaiting the same promise overwrote the first,
    which was then never resumed at all.

await() now yields until the promise actually settles, and _settle wakes
every registered waiter that is still suspended.
A driver error only ever reached the rejected promise, and the most
common way to call Norm discards it: no :catch, no :await means a failed
create() or delete() produced not one line of output. Combined with the
adapters, which could not report an error at all, a broken statement was
invisible on a live server.

Every data operation now reports the error and the statement through the
orm's logger before handing it to the callback. Rejection handling is
unchanged, so an application that does catch it sees no difference.
_flush_ready only ran on the success path, so a failing sync() left every
operation held by queue_until_ready with its callback never invoked. The
promises stayed pending forever: no resolution, no rejection, no timeout,
and any coroutine awaiting one hung for good.

That is precisely the scenario the option exists for, a database not yet
reachable at boot. Queued operations now receive the schema error and the
ORM stays not ready, so a later successful sync() still works.
engine_to_dialect mapped PostgreSQL to the mysql dialect as "close enough
for our SQL". It is not: that dialect quotes identifiers with backticks
and appends ENGINE=InnoDB DEFAULT CHARSET=utf8mb4, so the very first
CREATE TABLE was rejected at the opening backtick, while supports_returning
still advertised PostgreSQL as supported.

The engine now raises with a message pointing at the alternatives. An
explicit `dialect` option still bypasses the mapping.
The workflow rebuilt dist/ and pushed it straight to master without ever
running tests/run.py or tests/run_nanos.py, so nothing stood between a
source regression and the published bundle. Both suites now run against
the freshly built dist/ and a failure stops the job before the commit
step.
@JustGodWork
JustGodWork force-pushed the pr/adapters-promises-and-ci branch from bb86c42 to 9907d4d Compare July 22, 2026 01:04
@JustGodWork
JustGodWork changed the base branch from pr/model-and-relations to develop July 22, 2026 01:04
@JustGodWork
JustGodWork merged commit f946812 into develop Jul 22, 2026
1 of 2 checks passed
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