#include <cctype>
#include <cstring>
#include <napi.h>

#ifdef _WIN32
#include <direct.h>
#else
#include <unistd.h>
#endif

#include "macros.h"
#include "database.h"
#include "statement.h"
#include "function.h"
#include "session.h"
#include "blob.h"

using namespace node_sqlite3;

Napi::Object Database::Init(Napi::Env env, Napi::Object exports) {
    Napi::HandleScope scope(env);
    // declare napi_default_method here as it is only available in Node v14.12.0+
    auto napi_default_method = static_cast<napi_property_attributes>(napi_writable | napi_configurable);

    auto t = DefineClass(env, "Database", {
        InstanceMethod("close", &Database::Close, napi_default_method),
        InstanceMethod("exec", &Database::Exec, napi_default_method),
        InstanceMethod("wait", &Database::Wait, napi_default_method),
        InstanceMethod("loadExtension", &Database::LoadExtension, napi_default_method),
        InstanceMethod("serialize", &Database::Serialize, napi_default_method),
        InstanceMethod("parallelize", &Database::Parallelize, napi_default_method),
        InstanceMethod("configure", &Database::Configure, napi_default_method),
        InstanceMethod("interrupt", &Database::Interrupt, napi_default_method),
        InstanceMethod("_flushProfile", &Database::FlushProfile, napi_default_method),
        InstanceMethod("_queueBusy", &Database::QueueBusy, napi_default_method),
        // User-defined functions (Deliverable 06): internal entry points
        // wrapped by lib/sqlite3.js, which parses options and flushes the
        // statement cache.
        InstanceMethod("_registerFunction", &Database::RegisterUserFunction, napi_default_method),
        InstanceMethod("_registerAggregate", &Database::RegisterUserAggregate, napi_default_method),
        InstanceMethod("_registerCollation", &Database::RegisterUserCollation, napi_default_method),
        InstanceMethod("_removeFunction", &Database::RemoveUserFunction, napi_default_method),
        InstanceMethod("_removeCollation", &Database::RemoveUserCollation, napi_default_method),
        // Hooks, authorizer and progress (Deliverable 07): internal entry
        // points wrapped by lib/sqlite3.js, which parses options.
        InstanceMethod("_setAuthorizer", &Database::SetAuthorizer, napi_default_method),
        // Permission-model ATTACH gate (Deliverable 11): wrapped by
        // lib/sqlite3.js, which permission-checks allowlist entries.
        InstanceMethod("_setAttachGate", &Database::SetAttachGate, napi_default_method),
        InstanceMethod("_progressFlag", &Database::SetProgressFlag, napi_default_method),
        InstanceMethod("_progressCallback", &Database::SetProgressCallback, napi_default_method),
        InstanceMethod("_checkpoint", &Database::Checkpoint, napi_default_method),
        InstanceMethod("_tableInfo", &Database::TableInfo, napi_default_method),
        InstanceMethod("_dbConfig", &Database::DbConfig, napi_default_method),
        // Sessions, changesets and serialization (Deliverable 08):
        // internal entry points wrapped by lib/sqlite3.js.
        InstanceMethod("_applyChangeset", &Database::ApplyChangeset, napi_default_method),
        InstanceMethod("_serializeToBytes", &Database::SerializeToBytes, napi_default_method),
        InstanceMethod("_deserialize", &Database::Deserialize, napi_default_method),
        // JavaScript virtual tables (Phase 4): internal entry points
        // wrapped by lib/sqlite3.js, which validates the definition and
        // flushes the statement cache.
        InstanceMethod("_registerVtab", &Database::RegisterVtab, napi_default_method),
        InstanceMethod("_removeVtab", &Database::RemoveVtab, napi_default_method),
        // Phase 1/6 introspection and ops: transaction state, db status
        // counters, memory release, run-time limits, attached-file paths.
        InstanceAccessor("inTransaction", &Database::InTransactionGetter, nullptr),
        InstanceAccessor("txnState", &Database::TxnStateGetter, nullptr),
        InstanceMethod("_dbStatus", &Database::DbStatus, napi_default_method),
        InstanceMethod("_releaseMemory", &Database::ReleaseMemory, napi_default_method),
        InstanceMethod("_getLimit", &Database::GetLimit, napi_default_method),
        InstanceMethod("_dbLocation", &Database::DbLocation, napi_default_method),
        // True while the JS thread is inside a sqlite call on this
        // connection — i.e. while a user callback (function, aggregate,
        // virtual-table generator) invoked from a *Sync method is on the
        // stack. lib/sqlite3.js consults it to refuse the operations that
        // would finalize the statement sqlite is stepping (every
        // registration flushes the statement cache) BEFORE they touch any
        // state; the native entry points refuse too, but by then the
        // flush has already happened.
        InstanceAccessor("_inSyncCall", &Database::InSyncCallGetter, nullptr),
        InstanceAccessor("open", &Database::Open, nullptr),
        InstanceAccessor("integerMode", &Database::IntegerModeGetter, nullptr),
        InstanceAccessor("state", &Database::StateGetter, nullptr),
        InstanceAccessor("changes", &Database::ChangesGetter, nullptr),
        InstanceAccessor("totalChanges", &Database::TotalChangesGetter, nullptr),
        // Individual accessors for the statement cache's hot guard: the
        // state object is fine for diagnostics, but constructing it on
        // every cached call measured +46% on db.getSync cached (bench,
        // Deliverable 05).
        InstanceAccessor("closing", &Database::ClosingGetter, nullptr),
        InstanceAccessor("locked", &Database::LockedGetter, nullptr),
        InstanceAccessor("serialized", &Database::SerializedGetter, nullptr),
        InstanceAccessor("pending", &Database::PendingGetter, nullptr),
        InstanceAccessor("queued", &Database::QueuedGetter, nullptr)
    });

    // The constructors ride per-env instance data (NAPI >= 6 only: the
    // package declares napi_versions [10]). Allocated bare: node-addon-api
    // takes ownership of instance data and deletes it at env teardown.
    // Database::Init runs first in RegisterModule, so it allocates the
    // block the other classes then fill in.
    AddonData* data = new AddonData();
    data->database_ctor = Napi::Persistent(t);
    env.SetInstanceData<AddonData>(data);

    exports.Set("Database", t);
    return exports;
}

void Database::Process() {
    auto env = this->Env();
    Napi::HandleScope scope(env);

    // Virtual-table instance references queued by xDisconnect (a DROP or
    // a module replacement can disconnect on a worker) are napi work, so
    // this — the JS-thread point every completion and exclusive handler
    // funnels through — is where they are deleted. Until now they waited
    // for ~Database, leaking one strong generator reference per dropped
    // factory instance for the connection's lifetime.
    DrainVtabRefs();

    if (db_state == DbState::Closed && !queue.empty()) {
        // Work queued behind a *failed open* fails with the open's own
        // error (CANTOPEN etc.), not the generic closed message — it never
        // had a chance to run and the open failure is what explains that.
        EXCEPTION(
            open_failed ? open_error_message.c_str() : "Database handle is closed",
            open_failed ? open_error_status : SQLITE_MISUSE,
            exception);
        Napi::Value argv[] = { exception };
        bool called = false;

        // Call all callbacks with the error object. The IsEmpty() guard
        // first: Value() on a default-constructed (empty) reference is
        // undefined behaviour, and this drain now also fires for
        // callback-less internal batons (the permission-model ATTACH gate
        // install queued in the constructor) behind a failed open.
        while (!queue.empty()) {
            auto call = std::unique_ptr<Call>(queue.front());
            queue.pop();
            auto baton = std::unique_ptr<Baton>(call->baton);
            if (baton->callback.IsEmpty()) continue;
            Napi::Function cb = baton->callback.Value();
            if (IS_FUNCTION(cb)) {
                TRY_CATCH_CALL(this->Value(), cb, 1, argv);
                called = true;
            }
        }

        // When we couldn't call a callback function, emit an error on the
        // Database object — except after a failed open, whose error the
        // open path has already delivered through its callback or the
        // 'error' event; a second emit here would be an unhandled duplicate.
        if (!called && !open_failed) {
            Napi::Value info[] = { Napi::String::New(env, "error"), exception };
            EMIT_EVENT(Value(), 2, info);
        }
        return;
    }

    while (IsOpen() && (!exclusiveHeld || pending == 0) && !queue.empty()) {
        Call *c = queue.front();

        if (c->exclusive && pending > 0) {
            break;
        }

        queue.pop();
        std::unique_ptr<Call> call(c);
        exclusiveHeld = call->exclusive;
        call->callback(call->baton);

        if (exclusiveHeld) break;
    }
}

void Database::Schedule(Work_Callback callback, Baton* baton, bool exclusive) {
    auto env = this->Env();
    Napi::HandleScope scope(env);

    if (IsClosed()) {
        EXCEPTION("Database is closed", SQLITE_MISUSE, exception);
        Napi::Function cb = baton->callback.Value();
        // We don't call the actual callback, so we have to make sure that
        // the baton gets destroyed.
        delete baton;
        if (IS_FUNCTION(cb)) {
            Napi::Value argv[] = { exception };
            TRY_CATCH_CALL(Value(), cb, 1, argv);
        }
        else {
            Napi::Value argv[] = { Napi::String::New(env, "error"), exception };
            EMIT_EVENT(Value(), 2, argv);
        }
        return;
    }

    // !queue.empty() keeps the database queue FIFO. Without it, a
    // non-exclusive call dispatches immediately whenever locked is false,
    // even while an exclusive call (exec/close/wait/loadExtension) is
    // already waiting in the queue -- letting it overtake work it is
    // supposed to run after, and run concurrently with it. Parallel
    // throughput is unaffected: the queue is only non-empty once something
    // has had to wait.
    if (!IsOpen() || !queue.empty()
            || ((exclusiveHeld || exclusive || serialize) && pending > 0)) {
        queue.emplace(new Call(callback, baton, exclusive || serialize));
    }
    else {
        exclusiveHeld = exclusive;
        callback(baton);
    }
}

Database::Database(const Napi::CallbackInfo& info) : Napi::ObjectWrap<Database>(info) {
    auto env = info.Env();
    uv_mutex_init(&vtab_refs_mutex);

    if (info.Length() <= 0 || !info[0].IsString()) {
        Napi::TypeError::New(env, "String expected").ThrowAsJavaScriptException();
        return;
    }
    auto filename = info[0].As<Napi::String>().Utf8Value();

    unsigned int pos = 1;

    int mode;
    if (info.Length() >= pos && info[pos].IsNumber() && OtherIsInt(info[pos].As<Napi::Number>())) {
        mode = info[pos++].As<Napi::Number>().Int32Value();
    }
    else {
        mode = SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE | SQLITE_OPEN_FULLMUTEX;
    }

    Napi::Function callback;
    if (info.Length() >= pos && info[pos].IsFunction()) {
        callback = info[pos++].As<Napi::Function>();
    }

    info.This().As<Napi::Object>().DefineProperty(Napi::PropertyDescriptor::Value("filename", info[0].As<Napi::String>(), napi_default));
    info.This().As<Napi::Object>().DefineProperty(Napi::PropertyDescriptor::Value("mode", Napi::Number::New(env, mode), napi_default));

    // Start opening the database.
    auto* baton = new OpenBaton(this, callback, filename.c_str(), mode);
    if (info.Length() > pos && info[pos].IsBoolean()
            && info[pos].As<Napi::Boolean>().Value()) {
        // Synchronous open (the node:sqlite compat shim's DatabaseSync):
        // a fresh connection has nothing in flight or queued, so opening
        // inline on this thread is safe, and the connection is usable the
        // moment the constructor returns — node:sqlite's semantics. The
        // same Work_Open/Work_AfterOpen pair the async path runs.
        Work_Open(env, baton);
        Work_AfterOpen(env, napi_ok, baton);
        return;
    }
    Work_BeginOpen(baton);
}

void Database::Work_BeginOpen(Baton* baton) {
    auto env = baton->db->Env();
    CREATE_WORK("sqlite3.Database.Open", Work_Open, Work_AfterOpen);
}

void Database::Work_Open(napi_env e, void* data) {
    auto* baton = static_cast<OpenBaton*>(data);
    auto* db = baton->db;

    // Kept for the ATTACH gate: whether URI filenames mean anything on
    // this connection is decided here, by this flag, and nowhere else.
    db->open_mode = baton->mode;

    baton->status = sqlite3_open_v2(
        baton->filename.c_str(),
        &db->_handle,
        baton->mode,
        NULL
    );

    if (baton->status != SQLITE_OK) {
        baton->message = std::string(sqlite3_errmsg(db->_handle));
        sqlite3_close(db->_handle);
        db->_handle = NULL;
    }
    else {
        // Set default database handle values.
        sqlite3_busy_timeout(db->_handle, 1000);
        // Extended result codes: step failures report e.g.
        // SQLITE_CONSTRAINT_UNIQUE instead of bare SQLITE_CONSTRAINT. The
        // JS error gains err.code (extended name), err.errno (extended
        // int) and err.primaryCode (primary name).
        sqlite3_extended_result_codes(db->_handle, 1);
        // Belt-and-braces: the C-API extension gate is explicitly off from
        // the start. Observed on the vendored 3.53.4 (probed, not cited):
        // SQLITE_DBCONFIG_ENABLE_LOAD_EXTENSION reads false on a fresh
        // open and maps to the C-API flag only — the SQL load_extension()
        // function is gated by a second flag (SQLITE_LoadExtFunc) that
        // only sqlite3_enable_load_extension() sets, so the SQL function
        // is unreachable here even after setting this DBCONFIG to 1.
        // Setting 0 anyway makes the C-API state deterministic for source
        // builds that compile with SQLITE_ENABLE_LOAD_EXTENSION, which
        // turns the C-API flag on by default. loadExtension() re-enables
        // the C API for the duration of its call and disables it after.
        sqlite3_db_config(db->_handle, SQLITE_DBCONFIG_ENABLE_LOAD_EXTENSION,
            0, NULL);
    }
}

void Database::Work_AfterOpen(napi_env e, napi_status status, void* data) {
    std::unique_ptr<OpenBaton> baton(static_cast<OpenBaton*>(data));
    AFTER_WORK_TEARDOWN_GUARD(baton);

    auto* db = baton->db;

    auto env = db->Env();
    Napi::HandleScope scope(env);

    // Drains the queue even when the completion callback below throws
    // (TRY_CATCH_CALL's early return). After a *failed* open the
    // connection is Closed by the failure branch below, so this same
    // drain fails everything queued behind the open with the open's own
    // error. The 'open' event still fires before the drain, as before.
    ProcessGuard process_on_exit(db);

    Napi::Value argv[1];
    if (baton->status != SQLITE_OK) {
        EXCEPTION(baton->message, baton->status, exception);
        argv[0] = exception;
        // A failed open is terminal: the connection will never become
        // usable, so it lands in Closed — and work already queued behind
        // the open (scheduled while it was still Opening) is failed by the
        // ProcessGuard drain below with this same error instead of
        // stranding in a queue Process() never dispatches from.
        db->open_failed = true;
        db->open_error_message = baton->message;
        db->open_error_status = baton->status;
        db->db_state = DbState::Closed;
    }
    else {
        db->db_state = DbState::Open;
        argv[0] = env.Null();
    }

    Napi::Function cb = baton->callback.Value();

    if (IS_FUNCTION(cb)) {
        TRY_CATCH_CALL(db->Value(), cb, 1, argv);
    }
    else if (db->db_state != DbState::Open) {
        Napi::Value info[] = { Napi::String::New(env, "error"), argv[0] };
        EMIT_EVENT(db->Value(), 2, info);
    }

    if (db->db_state == DbState::Open) {
        Napi::Value info[] = { Napi::String::New(env, "open") };
        EMIT_EVENT(db->Value(), 1, info);
    }
}

Napi::Value Database::Open(const Napi::CallbackInfo& info) {
    auto env = this->Env();
    auto* db = this;
    return Napi::Boolean::New(env, db->IsOpen());
}

Napi::Value Database::IntegerModeGetter(const Napi::CallbackInfo& info) {
    auto env = info.Env();
    switch (integer_mode) {
        case INTEGER_BIGINT: return Napi::String::New(env, "bigint");
        case INTEGER_MIXED:  return Napi::String::New(env, "mixed");
        default:             return Napi::String::New(env, "number");
    }
}

// True while this thread is inside sqlite on this connection: the *Sync
// methods' guard, observable from JavaScript because a callback they
// invoke re-entrantly must not perform the operations that finalize the
// stepping statement. See lib/sqlite3.js's assertNotInSyncCallback.
Napi::Value Database::InSyncCallGetter(const Napi::CallbackInfo& info) {
    return Napi::Boolean::New(info.Env(), sync_sqlite_depth > 0);
}

Napi::Value Database::StateGetter(const Napi::CallbackInfo& info) {
    auto env = info.Env();
    Napi::Object snapshot = Napi::Object::New(env);
    snapshot.Set("open", Napi::Boolean::New(env, IsOpen()));
    snapshot.Set("closing", Napi::Boolean::New(env, db_state == DbState::Closing));
    snapshot.Set("locked", Napi::Boolean::New(env, exclusiveHeld));
    snapshot.Set("serialized", Napi::Boolean::New(env, serialize));
    snapshot.Set("pending", Napi::Number::New(env, pending));
    snapshot.Set("queued", Napi::Number::New(env, static_cast<double>(queue.size())));
    snapshot.Freeze();
    return snapshot;
}

Napi::Value Database::ClosingGetter(const Napi::CallbackInfo& info) {
    return Napi::Boolean::New(info.Env(), db_state == DbState::Closing);
}

Napi::Value Database::LockedGetter(const Napi::CallbackInfo& info) {
    return Napi::Boolean::New(info.Env(), exclusiveHeld);
}

Napi::Value Database::SerializedGetter(const Napi::CallbackInfo& info) {
    return Napi::Boolean::New(info.Env(), serialize);
}

Napi::Value Database::PendingGetter(const Napi::CallbackInfo& info) {
    return Napi::Number::New(info.Env(), pending);
}

Napi::Value Database::QueuedGetter(const Napi::CallbackInfo& info) {
    return Napi::Number::New(info.Env(), static_cast<double>(queue.size()));
}

Napi::Value Database::QueueBusy(const Napi::CallbackInfo& info) {
    // A cached statement operation bypasses Database::Schedule entirely, so
    // it would otherwise run straight past anything waiting here.
    bool busy = db_state == DbState::Closing || !queue.empty()
        || (exclusiveHeld && pending > 0);
    return Napi::Boolean::New(info.Env(), busy);
}

Napi::Value Database::Close(const Napi::CallbackInfo& info) {
    auto env = info.Env();
    auto* db = this;
    OPTIONAL_ARGUMENT_FUNCTION(0, callback);

   auto* baton = new Baton(db, callback);
    db->Schedule(Work_BeginClose, baton, true);

    return info.This();
}

void Database::Work_BeginClose(Baton* baton) {
    assert(baton->db->exclusiveHeld);
    assert(baton->db->IsOpen());
    assert(baton->db->_handle);
    assert(baton->db->pending == 0);

    baton->db->pending++;
    baton->db->RemoveCallbacks();
    // Registered JS functions must not survive to sqlite3_close: they
    // capture this Database and fire from whatever thread touches the
    // handle. Main-thread here, with nothing in flight — the same
    // conditions RemoveCallbacks relies on for the hooks. The authorizer
    // and progress handler are unregistered for the same reason (both can
    // fire from the implicit work close performs). Sessions and blob
    // handles are torn down for the same lifetime reason: both hold
    // native objects sqlite3_close would leave dangling.
    baton->db->RemoveUserFunctions();
    baton->db->RemoveAuthorizer();
    baton->db->RemoveProgressHandler();
    baton->db->RemovePreupdateHook();
    baton->db->CloseLiveSessions();
    baton->db->CloseLiveBlobs();
    baton->db->ReleaseJsChannelIfIdle();
    baton->db->db_state = DbState::Closing;

    auto env = baton->db->Env();
    CREATE_WORK("sqlite3.Database.Close", Work_Close, Work_AfterClose);
}

void Database::Work_Close(napi_env e, void* data) {
    auto* baton = static_cast<Baton*>(data);
    auto* db = baton->db;

    baton->status = sqlite3_close(db->_handle);

    if (baton->status != SQLITE_OK) {
        baton->message = std::string(sqlite3_errmsg(db->_handle));
    }
    else {
        db->_handle = NULL;
    }
}

void Database::Work_AfterClose(napi_env e, napi_status status, void* data) {
    std::unique_ptr<Baton> baton(static_cast<Baton*>(data));
    AFTER_WORK_TEARDOWN_GUARD(baton);

    auto* db = baton->db;

    auto env = db->Env();
    Napi::HandleScope scope(env);

    db->pending--;
    // The exclusive close released the database either way.
    db->exclusiveHeld = false;

    // Drains the queue even when the completion callback below throws
    // (TRY_CATCH_CALL's early return), and on the failed-close path —
    // work queued behind the close used to sit undispatched forever
    // there too. The 'close' event still fires before the drain.
    ProcessGuard process_on_exit(db);

    Napi::Value argv[1];
    if (baton->status != SQLITE_OK) {
        // The close failed (e.g. SQLITE_BUSY from outstanding
        // statements): the connection is still open and usable.
        db->db_state = DbState::Open;
        EXCEPTION(baton->message, baton->status, exception);
        argv[0] = exception;
    }
    else {
        db->db_state = DbState::Closed;
        argv[0] = env.Null();
    }

    Napi::Function cb = baton->callback.Value();

    // Fire callbacks.
    if (IS_FUNCTION(cb)) {
        TRY_CATCH_CALL(db->Value(), cb, 1, argv);
    }
    else if (db->IsOpen()) {
        Napi::Value info[] = { Napi::String::New(env, "error"), argv[0] };
        EMIT_EVENT(db->Value(), 2, info);
    }

    if (!db->IsOpen()) {
        Napi::Value info[] = { Napi::String::New(env, "close") };
        EMIT_EVENT(db->Value(), 1, info);
    }
}

Napi::Value Database::Serialize(const Napi::CallbackInfo& info) {
    auto env = this->Env();
    auto* db = this;
    OPTIONAL_ARGUMENT_FUNCTION(0, callback);

    bool before = db->serialize;
    db->serialize = true;

    if (!callback.IsEmpty() && callback.IsFunction()) {
        TRY_CATCH_CALL(info.This(), callback, 0, NULL, info.This());
        db->serialize = before;
    }

    db->Process();

    return info.This();
}

Napi::Value Database::Parallelize(const Napi::CallbackInfo& info) {
    auto env = this->Env();
    auto* db = this;
    OPTIONAL_ARGUMENT_FUNCTION(0, callback);

    auto before = db->serialize;
    db->serialize = false;

    if (!callback.IsEmpty() && callback.IsFunction()) {
        TRY_CATCH_CALL(info.This(), callback, 0, NULL, info.This());
        db->serialize = before;
    }

    db->Process();

    return info.This();
}

Napi::Value Database::Configure(const Napi::CallbackInfo& info) {
    auto env = this->Env();
    auto* db = this;

    REQUIRE_ARGUMENTS(2);

    Napi::Function handle;
    // The JS layer calls configure(type, true) for every addListener and
    // (type, false) when the last listener is gone, so the register
    // handlers SET the hook state rather than toggling it (toggling
    // uninstalled the hook on the second listener — a pre-v9 latent bug
    // the multi-listener tests pin).
    auto hook_enable = [&]() -> bool {
        if (info[1].IsBoolean()) return info[1].As<Napi::Boolean>().Value();
        return true;
    };
    if (info[0].StrictEquals( Napi::String::New(env, "trace"))) {
        auto* baton = new HookBaton(db, handle, hook_enable());
        db->Schedule(RegisterTraceCallback, baton);
    }
    else if (info[0].StrictEquals( Napi::String::New(env, "profile"))) {
        auto* baton = new HookBaton(db, handle, hook_enable());
        db->Schedule(RegisterProfileCallback, baton);
    }
    else if (info[0].StrictEquals( Napi::String::New(env, "busyTimeout"))) {
        if (!info[1].IsNumber()) {
            Napi::TypeError::New(env, "Value must be an integer").ThrowAsJavaScriptException();
            return env.Null();
        }
       auto* baton = new Baton(db, handle);
        baton->timeout = info[1].As<Napi::Number>().Int32Value();
        db->Schedule(SetBusyTimeout, baton);
    }
    else if (info[0].StrictEquals( Napi::String::New(env, "limit"))) {
        REQUIRE_ARGUMENTS(3);
        if (!info[1].IsNumber()) {
            Napi::TypeError::New(env, "limit id must be an integer").ThrowAsJavaScriptException();
            return env.Null();
        }
        if (!info[2].IsNumber()) {
            Napi::TypeError::New(env, "limit value must be an integer").ThrowAsJavaScriptException();
            return env.Null();
        }
        int id = info[1].As<Napi::Number>().Int32Value();
        int value = info[2].As<Napi::Number>().Int32Value();
        Baton* baton = new LimitBaton(db, handle, id, value);
        db->Schedule(SetLimit, baton);
    }
    else if (info[0].StrictEquals(
            Napi::String::New(env, "walAutocheckpoint"))) {
        if (!info[1].IsNumber()) {
            Napi::TypeError::New(env,
                "walAutocheckpoint value must be an integer"
            ).ThrowAsJavaScriptException();
            return env.Null();
        }
        auto* baton = new Baton(db, handle);
        baton->timeout = info[1].As<Napi::Number>().Int32Value();
        db->Schedule(SetWalAutocheckpoint, baton);
    }
    else if (info[0].StrictEquals(Napi::String::New(env, "integerMode"))) {
        // Pure JS-side marshalling state: applied immediately, no sqlite
        // handle access, so unlike the other options it needs no baton.
        if (!info[1].IsString()) {
            Napi::TypeError::New(env,
                "integerMode must be one of 'number', 'bigint', 'mixed'"
            ).ThrowAsJavaScriptException();
            return env.Null();
        }
        std::string mode = info[1].As<Napi::String>().Utf8Value();
        if (mode == "number") {
            integer_mode = INTEGER_NUMBER;
        }
        else if (mode == "bigint") {
            integer_mode = INTEGER_BIGINT;
        }
        else if (mode == "mixed") {
            integer_mode = INTEGER_MIXED;
        }
        else {
            Napi::TypeError::New(env,
                "integerMode must be one of 'number', 'bigint', 'mixed'"
            ).ThrowAsJavaScriptException();
            return env.Null();
        }
    }
    else if (info[0].StrictEquals(Napi::String::New(env, "change"))) {
        auto* baton = new HookBaton(db, handle, hook_enable());
        db->Schedule(RegisterUpdateCallback, baton);
    }
    else if (info[0].StrictEquals(Napi::String::New(env, "commit"))) {
        auto* baton = new HookBaton(db, handle, hook_enable());
        db->Schedule(RegisterCommitCallback, baton);
    }
    else if (info[0].StrictEquals(Napi::String::New(env, "rollback"))) {
        auto* baton = new HookBaton(db, handle, hook_enable());
        db->Schedule(RegisterRollbackCallback, baton);
    }
    else if (info[0].StrictEquals(Napi::String::New(env, "wal"))) {
        auto* baton = new HookBaton(db, handle, hook_enable());
        db->Schedule(RegisterWalCallback, baton);
    }
    else if (info[0].StrictEquals(Napi::String::New(env, "preupdate"))) {
        auto* baton = new HookBaton(db, handle, hook_enable());
        db->Schedule(RegisterPreupdateCallback, baton);
    }
    else {
        Napi::TypeError::New(env,
            info[0].As<Napi::String>().Utf8Value() +
            " is not a valid configuration option"
        ).ThrowAsJavaScriptException();
        return env.Null();
    }

    db->Process();

    return info.This();
}

Napi::Value Database::Interrupt(const Napi::CallbackInfo& info) {
    auto env = this->Env();
    auto* db = this;

    if (!db->IsOpen()) {
        Napi::Error::New(env, "Database is not open").ThrowAsJavaScriptException();
        return env.Null();
    }

    if (db->db_state == DbState::Closing) {
        Napi::Error::New(env, "Database is closing").ThrowAsJavaScriptException();
        return env.Null();
    }

    sqlite3_interrupt(db->_handle);
    return info.This();
}

void Database::SetBusyTimeout(Baton* b) {
    auto baton = std::unique_ptr<Baton>(b);

    if (baton->db->MayBlockOnWorkerRoundTrip()) {
        baton->db->Schedule(SetBusyTimeout, baton.release(), true);
        return;
    }

    assert(baton->db->IsOpen());
    assert(baton->db->_handle);
    // Nothing in flight: the deferral above guarantees it, and the sqlite
    // call below would otherwise race a worker for the connection mutex.
    assert(!baton->db->MayBlockOnWorkerRoundTrip());

    sqlite3_busy_timeout(baton->db->_handle, baton->timeout);

    // Exclusive when deferred: hand the database back.
    baton->db->exclusiveHeld = false;
    baton->db->Process();
}

void Database::SetLimit(Baton* b) {
    std::unique_ptr<LimitBaton> baton(static_cast<LimitBaton*>(b));

    if (baton->db->MayBlockOnWorkerRoundTrip()) {
        baton->db->Schedule(SetLimit, baton.release(), true);
        return;
    }

    assert(baton->db->IsOpen());
    assert(baton->db->_handle);
    assert(!baton->db->MayBlockOnWorkerRoundTrip());

    sqlite3_limit(baton->db->_handle, baton->id, baton->value);

    baton->db->exclusiveHeld = false;
    baton->db->Process();
}

// configure('walAutocheckpoint', n): sqlite3_wal_autocheckpoint. The value
// rides the same baton slot busyTimeout uses (an int with no sqlite-side
// interpretation until the worker applies it).
void Database::SetWalAutocheckpoint(Baton* b) {
    auto baton = std::unique_ptr<Baton>(b);

    if (baton->db->MayBlockOnWorkerRoundTrip()) {
        baton->db->Schedule(SetWalAutocheckpoint, baton.release(), true);
        return;
    }

    assert(baton->db->IsOpen());
    assert(baton->db->_handle);
    assert(!baton->db->MayBlockOnWorkerRoundTrip());

    sqlite3_wal_autocheckpoint(baton->db->_handle, baton->timeout);

    baton->db->exclusiveHeld = false;
    baton->db->Process();
}

// Recompute the sqlite3_trace_v2 mask from the registered JS hooks and
// (un)install the single native callback accordingly.
void Database::UpdateTraceMask(Database* db, sqlite3* handle) {
    unsigned int mask = 0;
    if (db->debug_trace != NULL)   mask |= SQLITE_TRACE_STMT;
    if (db->debug_profile != NULL) mask |= SQLITE_TRACE_PROFILE;
    if (mask != 0) {
        sqlite3_trace_v2(handle, mask, Database::TraceV2Callback, db);
    }
    else {
        sqlite3_trace_v2(handle, 0, NULL, NULL);
    }
}

void Database::RegisterTraceCallback(Baton* b) {
    auto baton = std::unique_ptr<HookBaton>(static_cast<HookBaton*>(b));
    if (baton->db->MayBlockOnWorkerRoundTrip()) {
        baton->db->Schedule(RegisterTraceCallback, baton.release(), true);
        return;
    }
    assert(baton->db->IsOpen());
    assert(baton->db->_handle);
    // Nothing in flight: the deferral above guarantees it, and the sqlite
    // call below would otherwise race a worker for the connection mutex.
    assert(!baton->db->MayBlockOnWorkerRoundTrip());
    auto* db = baton->db;

    if (baton->enable && db->debug_trace == NULL) {
        db->debug_trace = new AsyncTrace(db, TraceCallback);
    }
    else if (!baton->enable && db->debug_trace != NULL) {
        db->debug_trace->finish();
        db->debug_trace = NULL;
    }
    UpdateTraceMask(db, db->_handle);

    // Only now: Process() dispatches queued statement work, and the moment
    // it does a worker may be inside a round trip holding the connection
    // mutex that sqlite3_trace_v2 above needs. Releasing first would put
    // this thread back in exactly the deadlock the deferral above exists
    // to avoid.
    db->exclusiveHeld = false;
    db->Process();
}

// Note: This function is called in the sqlite worker thread.
int Database::TraceV2Callback(unsigned int type, void* ctx, void* p, void* x) {
    auto* db = static_cast<Database*>(ctx);

    if (type == SQLITE_TRACE_STMT && db->debug_trace != NULL) {
        char* sql = sqlite3_expanded_sql(static_cast<sqlite3_stmt*>(p));
        if (sql != NULL) {
            db->debug_trace->send(new std::string(sql));
            sqlite3_free(sql);
        }
    }
    else if (type == SQLITE_TRACE_PROFILE && db->debug_profile != NULL) {
        auto* info = new ProfileInfo();
        char* sql = sqlite3_expanded_sql(static_cast<sqlite3_stmt*>(p));
        info->sql = sql != NULL ? std::string(sql) : std::string();
        if (sql != NULL) sqlite3_free(sql);
        info->nsecs = *static_cast<sqlite3_int64*>(x);
        db->debug_profile->send(info);
    }

    return SQLITE_OK;
}

void Database::TraceCallback(Database* db, std::string* s) {
    std::unique_ptr<std::string> sql(s);
    // Note: This function is called in the main V8 thread.
    auto env = db->Env();
    Napi::HandleScope scope(env);

    Napi::Value argv[] = {
        Napi::String::New(env, "trace"),
        Napi::String::New(env, sql->c_str())
    };
    EMIT_EVENT(db->Value(), 2, argv);
}

void Database::RegisterProfileCallback(Baton* b) {
    auto baton = std::unique_ptr<HookBaton>(static_cast<HookBaton*>(b));
    if (baton->db->MayBlockOnWorkerRoundTrip()) {
        baton->db->Schedule(RegisterProfileCallback, baton.release(), true);
        return;
    }
    assert(baton->db->IsOpen());
    assert(baton->db->_handle);
    // Nothing in flight: the deferral above guarantees it, and the sqlite
    // call below would otherwise race a worker for the connection mutex.
    assert(!baton->db->MayBlockOnWorkerRoundTrip());
    auto* db = baton->db;

    if (baton->enable && db->debug_profile == NULL) {
        db->debug_profile = new AsyncProfile(db, ProfileCallback);
    }
    else if (!baton->enable && db->debug_profile != NULL) {
        db->debug_profile->finish();
        db->debug_profile = NULL;
    }
    UpdateTraceMask(db, db->_handle);

    // Release only after the sqlite call; see RegisterTraceCallback.
    db->exclusiveHeld = false;
    db->Process();
}

// _flushProfile(): delivers the profile batons already queued for this
// connection right now, instead of on whichever loop turn the uv_async
// callback runs. SQLITE_TRACE_PROFILE fires on the worker thread as a
// statement finishes, before that statement's own completion reaches JS,
// so a caller that has awaited its query has a span waiting in the queue
// — this is what lets lib/sqlite3.js drain it before dropping a
// subscribeQueries() subscriber rather than losing it.
//
// Safe to call at any time: the queue is swapped out under its mutex, so
// a listener that calls back in sees an empty queue rather than
// re-entering the same items, and a connection with tracing off has no
// queue at all.
Napi::Value Database::FlushProfile(const Napi::CallbackInfo& info) {
    if (debug_profile != NULL) {
        debug_profile->flush();
    }
    return info.Env().Undefined();
}

void Database::ProfileCallback(Database *db, ProfileInfo* i) {
    auto info = std::unique_ptr<ProfileInfo>(i);
    auto env = db->Env();
    Napi::HandleScope scope(env);

    Napi::Value argv[] = {
        Napi::String::New(env, "profile"),
        Napi::String::New(env, info->sql.c_str()),
        Napi::Number::New(env, (double)info->nsecs / 1000000.0)
    };
    EMIT_EVENT(db->Value(), 3, argv);
}

void Database::EnsureTxnChannel(Database* db) {
    if (db->txn_event == NULL) {
        db->txn_event = new AsyncUpdate(db, TxnCallback);
    }
}

void Database::MaybeDropTxnChannel(Database* db) {
    if (db->txn_event != NULL && !db->hook_change && !db->hook_commit
            && !db->hook_rollback) {
        db->txn_event->finish();
        db->txn_event = NULL;
    }
}

void Database::RegisterUpdateCallback(Baton* b) {
    auto baton = std::unique_ptr<HookBaton>(static_cast<HookBaton*>(b));
    if (baton->db->MayBlockOnWorkerRoundTrip()) {
        baton->db->Schedule(RegisterUpdateCallback, baton.release(), true);
        return;
    }
    assert(baton->db->IsOpen());
    assert(baton->db->_handle);
    // Nothing in flight: the deferral above guarantees it, and the sqlite
    // call below would otherwise race a worker for the connection mutex.
    assert(!baton->db->MayBlockOnWorkerRoundTrip());
    auto* db = baton->db;

    if (baton->enable != db->hook_change) {
        db->hook_change = baton->enable;
        if (baton->enable) {
            EnsureTxnChannel(db);
            sqlite3_update_hook(db->_handle, UpdateCallback, db);
        }
        else {
            sqlite3_update_hook(db->_handle, NULL, NULL);
            MaybeDropTxnChannel(db);
        }
    }

    // Release only after the sqlite call; see RegisterTraceCallback.
    db->exclusiveHeld = false;
    db->Process();
}

void Database::RegisterCommitCallback(Baton* b) {
    auto baton = std::unique_ptr<HookBaton>(static_cast<HookBaton*>(b));
    if (baton->db->MayBlockOnWorkerRoundTrip()) {
        baton->db->Schedule(RegisterCommitCallback, baton.release(), true);
        return;
    }
    assert(baton->db->IsOpen());
    assert(baton->db->_handle);
    assert(!baton->db->MayBlockOnWorkerRoundTrip());
    auto* db = baton->db;

    if (baton->enable != db->hook_commit) {
        db->hook_commit = baton->enable;
        if (baton->enable) {
            EnsureTxnChannel(db);
            sqlite3_commit_hook(db->_handle, CommitHook, db);
        }
        else {
            sqlite3_commit_hook(db->_handle, NULL, NULL);
            MaybeDropTxnChannel(db);
        }
    }

    db->exclusiveHeld = false;
    db->Process();
}

void Database::RegisterRollbackCallback(Baton* b) {
    auto baton = std::unique_ptr<HookBaton>(static_cast<HookBaton*>(b));
    if (baton->db->MayBlockOnWorkerRoundTrip()) {
        baton->db->Schedule(RegisterRollbackCallback, baton.release(), true);
        return;
    }
    assert(baton->db->IsOpen());
    assert(baton->db->_handle);
    assert(!baton->db->MayBlockOnWorkerRoundTrip());
    auto* db = baton->db;

    if (baton->enable != db->hook_rollback) {
        db->hook_rollback = baton->enable;
        if (baton->enable) {
            EnsureTxnChannel(db);
            sqlite3_rollback_hook(db->_handle, RollbackHook, db);
        }
        else {
            sqlite3_rollback_hook(db->_handle, NULL, NULL);
            MaybeDropTxnChannel(db);
        }
    }

    db->exclusiveHeld = false;
    db->Process();
}

void Database::RegisterWalCallback(Baton* b) {
    auto baton = std::unique_ptr<HookBaton>(static_cast<HookBaton*>(b));
    if (baton->db->MayBlockOnWorkerRoundTrip()) {
        baton->db->Schedule(RegisterWalCallback, baton.release(), true);
        return;
    }
    assert(baton->db->IsOpen());
    assert(baton->db->_handle);
    assert(!baton->db->MayBlockOnWorkerRoundTrip());
    auto* db = baton->db;

    if (baton->enable && db->wal_event == NULL) {
        db->wal_event = new AsyncWal(db, WalCallback);
        sqlite3_wal_hook(db->_handle, WalHook, db);
    }
    else if (!baton->enable && db->wal_event != NULL) {
        sqlite3_wal_hook(db->_handle, NULL, NULL);
        db->wal_event->finish();
        db->wal_event = NULL;
    }

    db->exclusiveHeld = false;
    db->Process();
}

void Database::UpdateCallback(void* db, int type, const char* database,
        const char* table, sqlite3_int64 rowid) {
    // Note: This function is called in the thread pool.
    // Note: Some queries, such as "EXPLAIN" queries, are not sent through this.
    auto* handle = static_cast<Database*>(db);
    if (handle->txn_event == NULL) return; // removed / closing
    auto* info = new UpdateInfo();
    info->kind = UpdateInfo::kChange;
    info->type = type;
    info->database = std::string(database);
    info->table = std::string(table);
    info->rowid = rowid;
    handle->txn_event->send(info);
}

// Runs on the thread executing the COMMIT. The commit itself has already
// happened when sqlite calls this; the return value is advisory-only by
// design (see database.h) — always "proceed".
int Database::CommitHook(void* ctx) {
    auto* db = static_cast<Database*>(ctx);
    if (db->txn_event == NULL) return SQLITE_OK;
    auto* info = new UpdateInfo();
    info->kind = UpdateInfo::kCommit;
    db->txn_event->send(info);
    return SQLITE_OK;
}

// Runs on the thread executing the ROLLBACK (including the implicit
// rollback of a close, and of a rolled-back savepoint release).
void Database::RollbackHook(void* ctx) {
    auto* db = static_cast<Database*>(ctx);
    if (db->txn_event == NULL) return;
    auto* info = new UpdateInfo();
    info->kind = UpdateInfo::kRollback;
    db->txn_event->send(info);
}

// Runs on the thread committing a write into the WAL. Returning nonzero
// would prevent the automatic checkpoint; the event is observational, so
// the checkpoint always proceeds.
int Database::WalHook(void* ctx, sqlite3* /*handle*/, const char* database,
        int pages) {
    auto* db = static_cast<Database*>(ctx);
    if (db->wal_event == NULL) return SQLITE_OK;
    auto* info = new WalInfo();
    info->database = std::string(database != NULL ? database : "");
    info->pages = pages;
    db->wal_event->send(info);
    return SQLITE_OK;
}

void Database::TxnCallback(Database* db, UpdateInfo* i) {
    auto info = std::unique_ptr<UpdateInfo>(i);
    // Note: This function is called in the main V8 thread.
    auto env = db->Env();
    Napi::HandleScope scope(env);

    if (info->kind == UpdateInfo::kCommit) {
        Napi::Value argv[] = { Napi::String::New(env, "commit") };
        EMIT_EVENT(db->Value(), 1, argv);
        return;
    }
    if (info->kind == UpdateInfo::kRollback) {
        Napi::Value argv[] = { Napi::String::New(env, "rollback") };
        EMIT_EVENT(db->Value(), 1, argv);
        return;
    }
    Napi::Value argv[] = {
        Napi::String::New(env, "change"),
        Napi::String::New(env, sqlite_authorizer_string(info->type)),
        Napi::String::New(env, info->database.c_str()),
        Napi::String::New(env, info->table.c_str()),
        Napi::Number::New(env, info->rowid),
    };
    EMIT_EVENT(db->Value(), 5, argv);
}

void Database::WalCallback(Database* db, WalInfo* i) {
    auto info = std::unique_ptr<WalInfo>(i);
    auto env = db->Env();
    Napi::HandleScope scope(env);

    Napi::Value argv[] = {
        Napi::String::New(env, "wal"),
        Napi::String::New(env, info->database.c_str()),
        Napi::Number::New(env, info->pages)
    };
    EMIT_EVENT(db->Value(), 3, argv);
}

// --- Preupdate event (Deliverable 08) ----------------------------------------

void Database::RegisterPreupdateCallback(Baton* b) {
    auto baton = std::unique_ptr<HookBaton>(static_cast<HookBaton*>(b));
    if (baton->db->MayBlockOnWorkerRoundTrip()) {
        baton->db->Schedule(RegisterPreupdateCallback, baton.release(), true);
        return;
    }
    assert(baton->db->IsOpen());
    assert(baton->db->_handle);
    // Nothing in flight: the deferral above guarantees it, and the sqlite
    // call below would otherwise race a worker for the connection mutex.
    assert(!baton->db->MayBlockOnWorkerRoundTrip());
    auto* db = baton->db;

    if (baton->enable != db->hook_preupdate) {
        if (baton->enable) {
            // The preupdate hook slot is shared with the session
            // extension (see src/session.h): refuse loudly while a
            // session is tracked rather than letting sqlite3session_*
            // silently displace our hook — or ours displace theirs.
            if (!db->live_sessions.empty()) {
                Napi::Env env = db->Env();
                Napi::HandleScope scope(env);
                EXCEPTION("cannot register a 'preupdate' listener while a "
                    "session is open on this connection: both use "
                    "SQLite's single preupdate hook, and one would "
                    "silently stop the other. Close the session first",
                    SQLITE_MISUSE, exception);
                Napi::Value info[] = {
                    Napi::String::New(env, "error"), exception
                };
                EMIT_EVENT(db->Value(), 2, info);
            }
            else {
                db->preupdate_event = new AsyncPreupdate(db,
                    PreupdateCallback);
                sqlite3_preupdate_hook(db->_handle, PreupdateTrampoline, db);
                db->hook_preupdate = true;
            }
        }
        else {
            // Unregister before finishing the channel so no event can be
            // in flight for it.
            sqlite3_preupdate_hook(db->_handle, NULL, NULL);
            db->preupdate_event->finish();
            db->preupdate_event = NULL;
            db->hook_preupdate = false;
        }
    }

    // Release only after the sqlite call; see RegisterTraceCallback.
    db->exclusiveHeld = false;
    db->Process();
}

// Note: called in the thread performing the write — the libuv worker for
// asynchronous statements, or the JS thread inside the *Sync methods.
void Database::PreupdateTrampoline(void* ctx, sqlite3* handle, int op,
        const char* database, const char* table, sqlite3_int64 key1,
        sqlite3_int64 key2) {
    auto* db = static_cast<Database*>(ctx);
    if (db->preupdate_event == NULL) return; // removed / closing

    // sqlite3_preupdate_old/new are only valid inside this callback, so
    // the row values are materialised eagerly here — the Async bridge
    // defers the JS event to the loop thread, and a half-filled container
    // must never be what JS observes. Both row vectors are fully sized
    // before any element is converted.
    auto* info = new PreupdateInfo();
    info->op = op;
    info->database = database != NULL ? database : "";
    info->table = table != NULL ? table : "";
    info->key1 = key1;
    info->key2 = key2;

    int n_col = sqlite3_preupdate_count(handle);
    if (op != SQLITE_INSERT) {
        info->old_row.resize(n_col);
        for (int i = 0; i < n_col; i++) {
            sqlite3_value* v = NULL;
            if (sqlite3_preupdate_old(handle, i, &v) == SQLITE_OK
                    && v != NULL) {
                ValueToCell(&info->old_row[i], v);
            }
        }
    }
    if (op != SQLITE_DELETE) {
        info->new_row.resize(n_col);
        for (int i = 0; i < n_col; i++) {
            sqlite3_value* v = NULL;
            if (sqlite3_preupdate_new(handle, i, &v) == SQLITE_OK
                    && v != NULL) {
                ValueToCell(&info->new_row[i], v);
            }
        }
    }

    db->preupdate_event->send(info);
}

void Database::PreupdateCallback(Database* db, PreupdateInfo* i) {
    auto info = std::unique_ptr<PreupdateInfo>(i);
    // Note: called in the main V8 thread.
    auto env = db->Env();
    Napi::HandleScope scope(env);

    Napi::Object payload = Napi::Object::New(env);
    payload.Set("op",
        Napi::String::New(env, sqlite_authorizer_string(info->op)));
    payload.Set("database",
        Napi::String::New(env, info->database.c_str()));
    payload.Set("table", Napi::String::New(env, info->table.c_str()));
    // key1 is the rowid being inserted/deleted/updated; key2 carries the
    // new rowid only for a rowid-changing UPDATE. INSERT therefore has no
    // old rowid to report.
    payload.Set("oldRowid", info->op == SQLITE_INSERT
        ? env.Null()
        : ConvertInt64ToJS(env, info->key1, db->integer_mode,
            "the preupdate oldRowid"));
    payload.Set("rowid", ConvertInt64ToJS(env,
        info->op == SQLITE_UPDATE ? info->key2 : info->key1,
        db->integer_mode, "the preupdate rowid"));

    // A blob write fires the hook as SQLITE_DELETE (the new values are
    // not yet available); old/new stay op-gated so JS sees the same
    // INSERT=new-only / DELETE=old-only / UPDATE=both shape regardless.
    const int mode = db->integer_mode;
    auto row_to_array = [&](Row& cells, const char* what) -> Napi::Value {
        Napi::Array out = Napi::Array::New(env, cells.size());
        for (size_t idx = 0; idx < cells.size(); idx++) {
            out.Set(idx, CellToJS(env, cells[idx], mode, what));
            if (env.IsExceptionPending()) return env.Null();
        }
        return out;
    };
    if (info->op != SQLITE_INSERT) {
        payload.Set("oldRow",
            row_to_array(info->old_row, "a preupdate old row value"));
    }
    else {
        payload.Set("oldRow", env.Null());
    }
    if (info->op != SQLITE_DELETE) {
        payload.Set("newRow",
            row_to_array(info->new_row, "a preupdate new row value"));
    }
    else {
        payload.Set("newRow", env.Null());
    }

    Napi::Value argv[] = {
        Napi::String::New(env, "preupdate"),
        payload
    };
    EMIT_EVENT(db->Value(), 2, argv);
}

void Database::RemovePreupdateHook() {
    // Main-thread, nothing in flight (Work_BeginClose / ~Database). The
    // sqlite hook is uninstalled before the channel is finished; sqlite
    // itself may still fire the hook during the implicit rollback of
    // sqlite3_close, by which point the channel is gone and the
    // trampoline drops the event — the update hook's contract.
    if (_handle != NULL && hook_preupdate) {
        sqlite3_preupdate_hook(_handle, NULL, NULL);
    }
    hook_preupdate = false;
    if (preupdate_event != NULL) {
        preupdate_event->finish();
        preupdate_event = NULL;
    }
}


// --- Authorizer ------------------------------------------------------------

namespace {

bool ValidAuthDecision(int v) {
    return v == SQLITE_OK || v == SQLITE_DENY || v == SQLITE_IGNORE;
}

// Reads one optional string field of a rule row: null/undefined/omitted
// become the match-anything flag; a string (including the empty string)
// is matched exactly.
bool ReadRuleString(const Napi::Value& value, std::string* out, bool* any) {
    if (value.IsNull() || value.IsUndefined()) {
        out->clear();
        *any = true;
        return true;
    }
    if (!value.IsString()) return false;
    *out = value.As<Napi::String>().Utf8Value();
    *any = false;
    return true;
}

// Element access with an explicit index type: a literal 0 in Get(0) is
// also a null-pointer constant and makes the const char* overload
// ambiguous.
inline Napi::Value At(const Napi::Array& a, uint32_t i) {
    return a.Get(i);
}

} // namespace

// _setAuthorizer(defaultDecision, rules) installs a policy;
// _setAuthorizer() / _setAuthorizer(null) removes it. rules is an array
// of [action, verdict, arg1, arg2, database, trigger] rows (action -1
// and null fields are wildcards; an explicitly-passed empty string
// matches only an empty argument); the JS layer orders deny rules
// before allow rules so a deny can never be overridden.
Napi::Value Database::SetAuthorizer(const Napi::CallbackInfo& info) {
    auto env = info.Env();
    auto* db = this;

    auto* baton = new AuthBaton(db, Napi::Function());
    if (info.Length() == 0 || info[0].IsNull() || info[0].IsUndefined()) {
        baton->remove = true;
    }
    else {
        if (info.Length() < 2 || !info[0].IsNumber() || !info[1].IsArray()) {
            delete baton;
            Napi::TypeError::New(env,
                "authorizer policy must be a decision constant and a rule array"
            ).ThrowAsJavaScriptException();
            return env.Null();
        }
        auto* policy = new AuthPolicy();
        policy->default_decision = info[0].As<Napi::Number>().Int32Value();
        if (!ValidAuthDecision(policy->default_decision)) {
            delete baton;
            delete policy;
            Napi::TypeError::New(env,
                "default decision must be sqlite3.OK, sqlite3.DENY or sqlite3.IGNORE"
            ).ThrowAsJavaScriptException();
            return env.Null();
        }
        Napi::Array rules = info[1].As<Napi::Array>();
        uint32_t count = rules.Length();
        policy->rules.reserve(count);
        for (uint32_t i = 0; i < count; i++) {
            Napi::Value row = rules.Get(i);
            if (!row.IsArray() || row.As<Napi::Array>().Length() != 6) {
                delete baton;
                delete policy;
                Napi::TypeError::New(env,
                    "authorizer rule " + std::to_string(i) +
                    " must be [action, verdict, arg1, arg2, database, trigger]"
                ).ThrowAsJavaScriptException();
                return env.Null();
            }
            Napi::Array parts = row.As<Napi::Array>();
            if (!At(parts, 0).IsNumber() || !At(parts, 1).IsNumber()) {
                delete baton;
                delete policy;
                Napi::TypeError::New(env,
                    "authorizer rule " + std::to_string(i) +
                    ": action and verdict must be numbers"
                ).ThrowAsJavaScriptException();
                return env.Null();
            }
            AuthRule rule;
            rule.action = At(parts, 0).As<Napi::Number>().Int32Value();
            rule.verdict = At(parts, 1).As<Napi::Number>().Int32Value();
            bool ok = ValidAuthDecision(rule.verdict)
                && ReadRuleString(At(parts, 2), &rule.arg1, &rule.arg1_any)
                && ReadRuleString(At(parts, 3), &rule.arg2, &rule.arg2_any)
                && ReadRuleString(At(parts, 4), &rule.database,
                    &rule.database_any)
                && ReadRuleString(At(parts, 5), &rule.trigger,
                    &rule.trigger_any);
            if (!ok) {
                delete baton;
                delete policy;
                Napi::TypeError::New(env,
                    "authorizer rule " + std::to_string(i) +
                    " has an invalid verdict (OK/DENY/IGNORE) or a non-string match field"
                ).ThrowAsJavaScriptException();
                return env.Null();
            }
            policy->rules.push_back(std::move(rule));
        }
        baton->policy = policy;
    }

    db->Schedule(Work_SetAuthorizer, baton, true);
    db->Process();

    return info.This();
}

void Database::Work_SetAuthorizer(Baton* b) {
    auto baton = std::unique_ptr<AuthBaton>(static_cast<AuthBaton*>(b));
    if (baton->db->MayBlockOnWorkerRoundTrip()) {
        baton->db->Schedule(Work_SetAuthorizer, baton.release(), true);
        return;
    }
    assert(baton->db->IsOpen());
    assert(baton->db->_handle);
    // Nothing in flight: the deferral above guarantees it, and the sqlite
    // call below would otherwise race a worker for the connection mutex.
    assert(!baton->db->MayBlockOnWorkerRoundTrip());
    auto* db = baton->db;

    // The swap happens at pending == 0, so no prepare (the only context
    // the authorizer fires in) can be reading the old policy.
    AuthPolicy* old = db->auth_policy;
    if (baton->remove) {
        db->auth_policy = NULL;
        // The ATTACH gate shares this single sqlite authorizer slot: the
        // callback stays installed while the gate is armed, so removing a
        // declarative policy does not silently re-open ATTACH.
        if (!db->attach_gate) {
            sqlite3_set_authorizer(db->_handle, NULL, NULL);
        }
    }
    else {
        db->auth_policy = baton->policy;
        baton->policy = NULL; // ownership moved to the database
        sqlite3_set_authorizer(db->_handle, AuthorizerCallback, db);
    }
    delete old;

    db->exclusiveHeld = false;
    db->Process();
}

int Database::AuthorizerCallback(void* ctx, int action, const char* arg1,
        const char* arg2, const char* database, const char* trigger) {
    // Runs on whatever thread is inside sqlite3_prepare (or a step's
    // transparent re-prepare). Pure C matching — no JS, no round trip:
    // the policy is evaluated here precisely because a JS callback could
    // not return a value synchronously from this context.
    auto* db = static_cast<Database*>(ctx);

    // ATTACH gate pre-filter: SQLITE_ATTACH is also what VACUUM INTO
    // fires for its output file, so this one check closes both SQL-level
    // paths to the filesystem. Falls through to the declarative policy
    // when the target is allowed (a user policy may still deny it).
    if (db->attach_gate && action == SQLITE_ATTACH
            && !AttachTargetAllowed(db, arg1)) {
        return SQLITE_DENY;
    }

    const AuthPolicy* policy = db->auth_policy;
    if (policy == NULL) return SQLITE_OK;

    for (const auto& rule : policy->rules) {
        if (rule.action >= 0 && rule.action != action) continue;
        if (!rule.arg1_any && (arg1 == NULL || rule.arg1 != arg1)) continue;
        if (!rule.arg2_any && (arg2 == NULL || rule.arg2 != arg2)) continue;
        if (!rule.database_any
                && (database == NULL || rule.database != database)) continue;
        if (!rule.trigger_any
                && (trigger == NULL || rule.trigger != trigger)) continue;
        return rule.verdict;
    }
    return policy->default_decision;
}

void Database::RemoveAuthorizer() {
    // Main-thread, nothing in flight (Work_BeginClose / ~Database).
    if (_handle != NULL && (auth_policy != NULL || attach_gate)) {
        sqlite3_set_authorizer(_handle, NULL, NULL);
    }
    delete auth_policy;
    auth_policy = NULL;
    attach_gate = false;
    attach_allow.clear();
}

// --- ATTACH gate -------------------------------------------------------------

namespace {

// Lexical path comparison helpers for the gate: no syscalls, no symlink
// resolution — a differently-spelled target does not match (fail-closed).
bool PathEquals(const std::string& allowed, const char* arg1) {
    if (allowed == arg1) return true;
#ifndef _WIN32
    // POSIX only: '\' is an ordinary filename character, so normalising it
    // would *widen* the allowlist — an entry for "dir/x.db" would admit an
    // ATTACH of the distinct, never-permission-checked file "dir\x.db"
    // (verified: the backslash-spelled file was created and attached).
    // Exact match is the whole rule here.
    return false;
#else
    // Windows: a target may arrive with either separator while the
    // allowlist entry (a JS string) uses the other.
    if (allowed.find('\\') == std::string::npos
            && strchr(arg1, '\\') == NULL) {
        return false;
    }
    size_t n = allowed.size();
    if (strlen(arg1) != n) return false;
    for (size_t i = 0; i < n; i++) {
        char a = allowed[i];
        char b = arg1[i];
        if (a == '\\') a = '/';
        if (b == '\\') b = '/';
        if (a != b) return false;
    }
    return true;
#endif
}

// The process cwd, for making a relative ATTACH target comparable against
// absolute allowlist entries. Empty when unavailable, in which case
// relative targets simply do not match (fail-closed).
std::string CurrentWorkingDir() {
    char buf[4096];
#ifdef _WIN32
    const char* got = _getcwd(buf, sizeof(buf));
#else
    const char* got = getcwd(buf, sizeof(buf));
#endif
    return got != NULL ? std::string(got) : std::string();
}

} // namespace

// _setAttachGate(enabled, allowPaths) arms/disarms the gate. The JS layer
// has already permission-checked every allowlist entry against Node's
// permission model; here the entries are only matched. Exclusive, with the
// same MayBlockOnWorkerRoundTrip deferral as the authorizer: it takes the
// connection mutex to install/remove the sqlite authorizer.
Napi::Value Database::SetAttachGate(const Napi::CallbackInfo& info) {
    auto env = info.Env();
    auto* db = this;

    auto* baton = new AttachGateBaton(db, Napi::Function());
    if (info.Length() >= 1 && (info[0].IsBoolean() || info[0].IsNumber())) {
        bool enable = info[0].IsBoolean()
            ? info[0].As<Napi::Boolean>().Value()
            : info[0].As<Napi::Number>().Int32Value() != 0;
        baton->enable = enable;
        if (enable) {
            if (info.Length() < 2 || !info[1].IsArray()) {
                delete baton;
                Napi::TypeError::New(env,
                    "attach gate requires an array of allowed paths"
                ).ThrowAsJavaScriptException();
                return env.Null();
            }
            Napi::Array allow = info[1].As<Napi::Array>();
            uint32_t count = allow.Length();
            baton->allow.reserve(count);
            for (uint32_t i = 0; i < count; i++) {
                Napi::Value entry = allow.Get(i);
                if (!entry.IsString()) {
                    delete baton;
                    Napi::TypeError::New(env,
                        "allowed attach path " + std::to_string(i) +
                        " must be a string"
                    ).ThrowAsJavaScriptException();
                    return env.Null();
                }
                baton->allow.push_back(entry.As<Napi::String>().Utf8Value());
            }
        }
    }
    else {
        delete baton;
        Napi::TypeError::New(env,
            "attach gate expects (enabled, allowedPaths)"
        ).ThrowAsJavaScriptException();
        return env.Null();
    }

    db->Schedule(Work_SetAttachGate, baton, true);
    db->Process();

    return info.This();
}

void Database::Work_SetAttachGate(Baton* b) {
    auto baton = std::unique_ptr<AttachGateBaton>(
        static_cast<AttachGateBaton*>(b));
    if (baton->db->MayBlockOnWorkerRoundTrip()) {
        baton->db->Schedule(Work_SetAttachGate, baton.release(), true);
        return;
    }
    assert(baton->db->IsOpen());
    assert(baton->db->_handle);
    // Nothing in flight: the deferral above guarantees it, and the sqlite
    // call below would otherwise race a worker for the connection mutex.
    assert(!baton->db->MayBlockOnWorkerRoundTrip());
    auto* db = baton->db;

    db->attach_gate = baton->enable;
    db->attach_allow = std::move(baton->allow);

    // One sqlite authorizer slot: install the shared callback when the
    // gate arms (unless a declarative policy already installed it), and
    // remove it when the gate disarms and no policy remains.
    if (db->attach_gate && db->auth_policy == NULL) {
        sqlite3_set_authorizer(db->_handle, AuthorizerCallback, db);
    }
    else if (!db->attach_gate && db->auth_policy == NULL) {
        sqlite3_set_authorizer(db->_handle, NULL, NULL);
    }

    db->exclusiveHeld = false;
    db->Process();
}

// Matches an ATTACH target (SQLITE_ATTACH arg1, the filename as written in
// the SQL or bound to it) against the allowlist: exact string, separator-
// normalised, or lexically joined with the process cwd for relative
// targets. No filesystem access, no realpath: matching is lexical on
// purpose, so the caller must ATTACH using a spelling the allowlist
// recognises.
bool Database::AttachTargetAllowed(const Database* db, const char* arg1) {
    if (arg1 == NULL) return false;
    // ':memory:' is the only spelling that touches no filesystem and so
    // cannot be an fs-permission bypass. ('' is NOT one: SQLite creates a
    // private temporary database backed by real files under the temp
    // directory, so it stays denied — fail-closed.)
    //
    if (strcmp(arg1, ":memory:") == 0) {
        return true;
    }
    // The URI memory forms are in-memory only on a connection opened with
    // SQLITE_OPEN_URI, which is opt-in per open (sqlite3.OPEN_URI) and is
    // not the default. Without it SQLite treats 'file:…' as an ordinary
    // filename, so accepting these unconditionally opened a hole instead
    // of closing one: verified — ATTACH 'file::memory:' on a default
    // connection created a real file of that literal name in the process
    // cwd, outside the allowlist, while the gate reported it as
    // in-memory. On a non-URI connection such a target falls through to
    // the allowlist match below, like any other filename.
    if ((db->open_mode & SQLITE_OPEN_URI) != 0
            && (sqlite3_strnicmp(arg1, "file::memory:", 13) == 0
                || (sqlite3_strnicmp(arg1, "file:", 5) == 0
                    && strstr(arg1, "mode=memory") != NULL))) {
        return true;
    }
    if (db->attach_allow.empty()) return false;
    for (const auto& allowed : db->attach_allow) {
        if (PathEquals(allowed, arg1)) return true;
    }
    // A relative target is compared against the cwd-joined spelling of
    // each absolute allowlist entry.
    if (arg1[0] == '/' || arg1[0] == '\\'
            || (isalpha(static_cast<unsigned char>(arg1[0])) && arg1[1] == ':')) {
        return false;
    }
    std::string cwd = CurrentWorkingDir();
    if (cwd.empty()) return false;
    std::string joined = cwd;
    if (joined.back() != '/' && joined.back() != '\\') joined += '/';
    joined += arg1;
    for (const auto& allowed : db->attach_allow) {
        if (PathEquals(allowed, joined.c_str())) return true;
    }
    return false;
}

// --- Progress handler / cancellation token ----------------------------------

static_assert(std::atomic<int32_t>::is_always_lock_free,
    "the cancellation token relies on lock-free cross-thread flag reads");

// _progressFlag(int32Array, period) installs the token form; the array is
// an Int32Array view over a SharedArrayBuffer (the stable napi surface
// has no SharedArrayBuffer accessors; the typed-array info call returns
// the shared backing pointer, and rooting the view roots the buffer).
// _progressFlag() removes whatever progress handler is installed.
Napi::Value Database::SetProgressFlag(const Napi::CallbackInfo& info) {
    auto env = info.Env();
    auto* db = this;

    if (info.Length() == 0 || info[0].IsNull() || info[0].IsUndefined()) {
        auto* baton = new ProgressFlagBaton(db, Napi::Function(), 0);
        db->Schedule(Work_SetProgressFlag, baton, true);
        db->Process();
        return info.This();
    }

    void* data = NULL;
    if (!info[0].IsTypedArray()) {
        Napi::TypeError::New(env,
            "cancellation token must be an Int32Array over a SharedArrayBuffer"
        ).ThrowAsJavaScriptException();
        return env.Null();
    }
    napi_typedarray_type type;
    size_t length = 0, byte_offset = 0;
    napi_value backing = NULL;
    napi_status st = napi_get_typedarray_info(env, info[0],
        &type, &length, &data, &backing, &byte_offset);
    if (st != napi_ok || type != napi_int32_array || length < 1
            || data == NULL || backing == NULL) {
        Napi::TypeError::New(env,
            "cancellation token must be an Int32Array with at least one element"
        ).ThrowAsJavaScriptException();
        return env.Null();
    }
    // The JS layer requires a SharedArrayBuffer backing store (it can
    // check; the stable napi surface cannot): that is what makes the flag
    // visible when it is set from a worker thread.
    if (info.Length() < 2 || !info[1].IsNumber()
            || info[1].As<Napi::Number>().Int32Value() < 1) {
        Napi::TypeError::New(env,
            "progress period must be a positive integer (VM instructions)"
        ).ThrowAsJavaScriptException();
        return env.Null();
    }

    auto* baton = new ProgressFlagBaton(db, Napi::Function(),
        info[1].As<Napi::Number>().Int32Value());
    baton->buffer = Napi::Persistent(info[0]);
    baton->flag = static_cast<std::atomic<int32_t>*>(data);
    db->Schedule(Work_SetProgressFlag, baton, true);
    db->Process();

    return info.This();
}

// _progressCallback(period, fn) installs the JS-callback form;
// _progressCallback() removes whatever progress handler is installed.
Napi::Value Database::SetProgressCallback(const Napi::CallbackInfo& info) {
    auto env = info.Env();
    auto* db = this;

    if (info.Length() == 0 || info[0].IsNull() || info[0].IsUndefined()
            || info[1].IsNull() || info[1].IsUndefined()) {
        auto* baton = new FunctionBaton(db, Napi::Function(), "", 0, 0);
        baton->nArg = -1; // sentinel: remove
        db->Schedule(Work_SetProgressCallback, baton, true);
        db->Process();
        return info.This();
    }

    if (!info[0].IsNumber() || info[0].As<Napi::Number>().Int32Value() < 1) {
        Napi::TypeError::New(env,
            "progress period must be a positive integer (VM instructions)"
        ).ThrowAsJavaScriptException();
        return env.Null();
    }
    if (!info[1].IsFunction()) {
        Napi::TypeError::New(env,
            "progress callback must be a function"
        ).ThrowAsJavaScriptException();
        return env.Null();
    }

    auto* baton = new FunctionBaton(db, Napi::Function(), "progress",
        info[0].As<Napi::Number>().Int32Value(), 0);
    baton->fn.Reset(info[1].As<Napi::Function>(), 1);
    db->Schedule(Work_SetProgressCallback, baton, true);
    db->Process();

    return info.This();
}

// Installs/updates the sqlite hook from the current state. Exclusive
// context: the handler's flag pointer and buffer reference are published
// only while nothing can be inside the handler.
void Database::ApplyProgressHandler(Database* db) {
    if (db->progress_mode == Database::ProgressMode::None) {
        sqlite3_progress_handler(db->_handle, 0, NULL, NULL);
    }
    else {
        sqlite3_progress_handler(db->_handle, db->progress_period,
            Database::ProgressHandler, db);
    }
}

void Database::Work_SetProgressFlag(Baton* b) {
    auto baton = std::unique_ptr<ProgressFlagBaton>(
        static_cast<ProgressFlagBaton*>(b));
    if (baton->db->MayBlockOnWorkerRoundTrip()) {
        baton->db->Schedule(Work_SetProgressFlag, baton.release(), true);
        return;
    }
    assert(baton->db->IsOpen());
    assert(baton->db->_handle);
    assert(!baton->db->MayBlockOnWorkerRoundTrip());
    auto* db = baton->db;

    if (baton->buffer.IsEmpty()) {
        // Remove: unregister first, then drop the published state.
        db->progress_mode = ProgressMode::None;
        db->progress_flag = NULL;
        db->progress_buffer.Reset();
        ApplyProgressHandler(db);
    }
    else {
        // The flag form replaces the callback form (one sqlite slot).
        if (db->js_progress != NULL) {
            db->DropJsProgressHolder();
            db->ReleaseJsChannelIfIdle();
        }
        db->progress_flag = baton->flag;
        db->progress_buffer = std::move(baton->buffer);
        db->progress_mode = ProgressMode::Flag;
        db->progress_period = baton->period;
        ApplyProgressHandler(db);
    }

    db->exclusiveHeld = false;
    db->Process();
}

void Database::Work_SetProgressCallback(Baton* b) {
    auto baton = std::unique_ptr<FunctionBaton>(static_cast<FunctionBaton*>(b));
    if (baton->db->MayBlockOnWorkerRoundTrip()) {
        baton->db->Schedule(Work_SetProgressCallback, baton.release(), true);
        return;
    }
    assert(baton->db->IsOpen());
    assert(baton->db->_handle);
    assert(!baton->db->MayBlockOnWorkerRoundTrip());
    auto* db = baton->db;

    if (baton->nArg == -1) {
        db->progress_mode = ProgressMode::None;
        db->progress_flag = NULL;
        db->progress_buffer.Reset();
        db->DropJsProgressHolder();
        ApplyProgressHandler(db);
        db->ReleaseJsChannelIfIdle();
    }
    else {
        if (!db->EnsureJsChannel()) {
            db->ReportRegistrationFailure(SQLITE_NOMEM);
        }
        else {
            JsFunc* old = db->js_progress;
            db->js_progress = new JsFunc(db, "progress", 0);
            db->js_progress->fn.Reset(baton->fn.Value(), 1);
            delete old;
            // The callback form replaces the flag form (one sqlite slot).
            db->progress_flag = NULL;
            db->progress_buffer.Reset();
            db->progress_mode = ProgressMode::Callback;
            db->progress_period = baton->nArg;
            ApplyProgressHandler(db);
        }
    }

    db->exclusiveHeld = false;
    db->Process();
}

void Database::DropJsProgressHolder() {
    if (js_progress != NULL) {
        JsFunc* fn = js_progress;
        js_progress = NULL;
        if (!fn->dead) {
            fn->dead = true;
            fn->fn.Reset();
            fn->db->Unref();
        }
        delete fn;
    }
}

void Database::RemoveProgressHandler() {
    // Main-thread, nothing in flight (Work_BeginClose / ~Database).
    if (_handle != NULL && progress_mode != ProgressMode::None) {
        sqlite3_progress_handler(_handle, 0, NULL, NULL);
    }
    progress_mode = ProgressMode::None;
    progress_period = 0;
    progress_flag = NULL;
    progress_buffer.Reset();
    DropJsProgressHolder();
}

// --- WAL checkpoints --------------------------------------------------------

// _checkpoint(dbName, mode, callback). Non-exclusive: concurrent statement
// work is serialized by the connection mutex inside sqlite, and a
// checkpoint racing readers is exactly what the busy flag reports.
Napi::Value Database::Checkpoint(const Napi::CallbackInfo& info) {
    auto env = info.Env();
    auto* db = this;

    REQUIRE_ARGUMENT_STRING(0, database);
    REQUIRE_ARGUMENT_INTEGER(1, mode);
    OPTIONAL_ARGUMENT_FUNCTION(2, callback);

    auto* baton = new CheckpointBaton(db, callback, database.c_str(), mode);
    db->Schedule(Work_BeginCheckpoint, baton);

    return info.This();
}

void Database::Work_BeginCheckpoint(Baton* baton) {
    assert(baton->db->IsOpen());
    assert(baton->db->_handle);
    baton->db->pending++;

    auto env = baton->db->Env();
    CREATE_WORK("sqlite3.Database.Checkpoint", Work_Checkpoint, Work_AfterCheckpoint);
}

void Database::Work_Checkpoint(napi_env e, void* data) {
    auto* baton = static_cast<CheckpointBaton*>(data);

    baton->status = sqlite3_wal_checkpoint_v2(
        baton->db->_handle,
        baton->database.c_str(),
        baton->mode,
        &baton->log_frames,
        &baton->ckpt_frames
    );

    if (baton->status != SQLITE_OK && baton->status != SQLITE_BUSY) {
        baton->message = std::string(sqlite3_errmsg(baton->db->_handle));
    }
}

void Database::Work_AfterCheckpoint(napi_env e, napi_status status, void* data) {
    std::unique_ptr<CheckpointBaton> baton(static_cast<CheckpointBaton*>(data));
    AFTER_WORK_TEARDOWN_GUARD(baton);
    auto* db = baton->db;

    auto env = db->Env();
    Napi::HandleScope scope(env);

    db->pending--;
    db->Process();

    // Calling Value() on a default-constructed (empty) FunctionReference
    // is undefined behaviour and fatals in practice. The dual-mode JS
    // wrappers always append a callback, so the raw no-callback form of
    // these internal entry points was never exercised until the untrusted
    // open path queued _dbConfig without one. IsEmpty() is a plain member
    // check — the same guard shape Statement::CleanQueue uses.
    if (baton->callback.IsEmpty()) return;
    Napi::Function cb = baton->callback.Value();
    if (!IS_FUNCTION(cb)) return;

    if (baton->status != SQLITE_OK && baton->status != SQLITE_BUSY) {
        EXCEPTION(baton->message, baton->status, exception);
        Napi::Value argv[] = { exception };
        TRY_CATCH_CALL(db->Value(), cb, 1, argv);
        return;
    }
    Napi::Object result = Napi::Object::New(env);
    result.Set("busy", Napi::Boolean::New(env, baton->status == SQLITE_BUSY));
    result.Set("logFrames", Napi::Number::New(env, baton->log_frames));
    result.Set("checkpointedFrames",
        Napi::Number::New(env, baton->ckpt_frames));
    Napi::Value argv[] = { env.Null(), result };
    TRY_CATCH_CALL(db->Value(), cb, 2, argv);
}

// --- Table metadata ---------------------------------------------------------

// _tableInfo(dbName, table, callback): PRAGMA <db>.table_info(<table>) for
// the column order, cid and defaults, enriched per column with
// sqlite3_table_column_metadata (declared type, collation, autoincrement).
// The PRAGMA is subject to the installed authorizer — a deny-by-default
// policy must allow SQLITE_PRAGMA (and the SELECT family is not involved).
Napi::Value Database::TableInfo(const Napi::CallbackInfo& info) {
    auto env = info.Env();
    auto* db = this;

    REQUIRE_ARGUMENT_STRING(0, database);
    REQUIRE_ARGUMENT_STRING(1, table);
    OPTIONAL_ARGUMENT_FUNCTION(2, callback);

    auto* baton = new TableInfoBaton(db, callback, database.c_str(), table.c_str());
    db->Schedule(Work_BeginTableInfo, baton);

    return info.This();
}

void Database::Work_BeginTableInfo(Baton* baton) {
    assert(baton->db->IsOpen());
    assert(baton->db->_handle);
    baton->db->pending++;

    auto env = baton->db->Env();
    CREATE_WORK("sqlite3.Database.TableInfo", Work_TableInfo, Work_AfterTableInfo);
}

void Database::Work_TableInfo(napi_env e, void* data) {
    auto* baton = static_cast<TableInfoBaton*>(data);
    auto* db = baton->db;

    // %Q quotes and escapes the identifier path; the schema name is
    // validated by the JS layer to the plain identifiers sqlite accepts.
    char* sql = sqlite3_mprintf("PRAGMA %s.table_info(%Q)",
        baton->database.c_str(), baton->table.c_str());
    if (sql == NULL) {
        baton->status = SQLITE_NOMEM;
        baton->message = "out of memory";
        return;
    }

    sqlite3_stmt* stmt = NULL;
    int rc = sqlite3_prepare_v2(db->_handle, sql, -1, &stmt, NULL);
    sqlite3_free(sql);
    if (rc != SQLITE_OK) {
        baton->status = rc;
        baton->message = std::string(sqlite3_errmsg(db->_handle));
        return;
    }

    baton->status = SQLITE_OK;
    while ((rc = sqlite3_step(stmt)) == SQLITE_ROW) {
        TableInfoBaton::Column col{};
        col.cid = sqlite3_column_int(stmt, 0);
        const char* name = (const char*)sqlite3_column_text(stmt, 1);
        col.name = name != NULL ? name : "";
        const char* type = (const char*)sqlite3_column_text(stmt, 2);
        col.type = type != NULL ? type : "";
        col.not_null = sqlite3_column_int(stmt, 3) != 0;
        const char* dflt = (const char*)sqlite3_column_text(stmt, 4);
        col.dflt = dflt != NULL ? dflt : "";
        col.pk = sqlite3_column_int(stmt, 5);

        // Enrich with the metadata API; failure here keeps the PRAGMA row
        // (e.g. a view, where there is no table metadata to read).
        const char* decl = NULL;
        const char* collate = NULL;
        int not_null = 0, pk = 0, autoinc = 0;
        int mrc = sqlite3_table_column_metadata(db->_handle,
            baton->database.c_str(), baton->table.c_str(), col.name.c_str(),
            &decl, &collate, &not_null, &pk, &autoinc);
        if (mrc == SQLITE_OK) {
            if (decl != NULL) col.type = decl;
            if (collate != NULL) col.collate = collate;
            col.not_null = not_null != 0;
            col.pk = pk;
            col.autoinc = autoinc != 0;
        }
        baton->columns.push_back(std::move(col));
    }
    if (rc != SQLITE_DONE) {
        baton->status = rc;
        baton->message = std::string(sqlite3_errmsg(db->_handle));
    }
    sqlite3_finalize(stmt);
}

void Database::Work_AfterTableInfo(napi_env e, napi_status status, void* data) {
    std::unique_ptr<TableInfoBaton> baton(static_cast<TableInfoBaton*>(data));
    AFTER_WORK_TEARDOWN_GUARD(baton);
    auto* db = baton->db;

    auto env = db->Env();
    Napi::HandleScope scope(env);

    db->pending--;
    db->Process();

    // See Work_AfterCheckpoint: never call Value() on an empty reference.
    if (baton->callback.IsEmpty()) return;
    Napi::Function cb = baton->callback.Value();
    if (!IS_FUNCTION(cb)) return;

    if (baton->status != SQLITE_OK) {
        EXCEPTION(baton->message, baton->status, exception);
        Napi::Value argv[] = { exception };
        TRY_CATCH_CALL(db->Value(), cb, 1, argv);
        return;
    }

    Napi::Array result = Napi::Array::New(env, baton->columns.size());
    size_t i = 0;
    for (const auto& col : baton->columns) {
        Napi::Object row = Napi::Object::New(env);
        row.Set("cid", Napi::Number::New(env, col.cid));
        row.Set("name", Napi::String::New(env, col.name.c_str()));
        row.Set("type", Napi::String::New(env, col.type.c_str()));
        row.Set("notNull", Napi::Boolean::New(env, col.not_null));
        // The PRAGMA reports the default's literal SQL text or nothing;
        // an absent field (not a null one) says "no DEFAULT clause".
        if (!col.dflt.empty()) {
            row.Set("defaultValue", Napi::String::New(env, col.dflt.c_str()));
        }
        row.Set("primaryKey", Napi::Number::New(env, col.pk));
        row.Set("collate", Napi::String::New(env, col.collate.c_str()));
        row.Set("autoIncrement", Napi::Boolean::New(env, col.autoinc));
        result.Set(i++, row);
    }
    Napi::Value argv[] = { env.Null(), result };
    TRY_CATCH_CALL(db->Value(), cb, 2, argv);
}

// --- db_config subset ---------------------------------------------------------

// _dbConfig(op, value, callback). value 0/1 sets; -1 queries without
// changing. The callback receives the previous value. Exclusive: unlike
// a read, a set followed by a query must not be reordered by the
// parallel worker pool.
Napi::Value Database::DbConfig(const Napi::CallbackInfo& info) {
    auto env = info.Env();
    auto* db = this;

    REQUIRE_ARGUMENT_INTEGER(0, op);
    REQUIRE_ARGUMENT_INTEGER(1, value);
    OPTIONAL_ARGUMENT_FUNCTION(2, callback);

    auto* baton = new DbConfigBaton(db, callback, op, value);
    db->Schedule(Work_BeginDbConfig, baton, true);

    return info.This();
}

void Database::Work_BeginDbConfig(Baton* baton) {
    assert(baton->db->IsOpen());
    assert(baton->db->_handle);
    baton->db->pending++;

    auto env = baton->db->Env();
    CREATE_WORK("sqlite3.Database.DbConfig", Work_DbConfig, Work_AfterDbConfig);
}

void Database::Work_DbConfig(napi_env e, void* data) {
    auto* baton = static_cast<DbConfigBaton*>(data);

    // sqlite3_db_config's out pointer receives the value AFTER the new
    // one is applied (see the aFlagOp handling in the amalgamation), not
    // the prior value its docs describe. Query first so the callback
    // really sees the previous state, then apply.
    int applied = 0;
    baton->status = sqlite3_db_config(baton->db->_handle, baton->op,
        -1, &baton->previous);
    if (baton->status == SQLITE_OK && baton->value >= 0) {
        baton->status = sqlite3_db_config(baton->db->_handle, baton->op,
            baton->value, &applied);
    }
    if (baton->status != SQLITE_OK) {
        baton->message = std::string(sqlite3_errmsg(baton->db->_handle));
    }
}

void Database::Work_AfterDbConfig(napi_env e, napi_status status, void* data) {
    std::unique_ptr<DbConfigBaton> baton(static_cast<DbConfigBaton*>(data));
    AFTER_WORK_TEARDOWN_GUARD(baton);
    auto* db = baton->db;

    auto env = db->Env();
    Napi::HandleScope scope(env);

    db->pending--;
    db->Process();

    // See Work_AfterCheckpoint: never call Value() on an empty reference.
    if (baton->callback.IsEmpty()) return;
    Napi::Function cb = baton->callback.Value();
    if (!IS_FUNCTION(cb)) return;

    if (baton->status != SQLITE_OK) {
        EXCEPTION(baton->message, baton->status, exception);
        Napi::Value argv[] = { exception };
        TRY_CATCH_CALL(db->Value(), cb, 1, argv);
        return;
    }
    Napi::Value argv[] = {
        env.Null(), Napi::Boolean::New(env, baton->previous != 0)
    };
    TRY_CATCH_CALL(db->Value(), cb, 2, argv);
}

// --- Change counters ----------------------------------------------------------

Napi::Value Database::ChangesGetter(const Napi::CallbackInfo& info) {
    auto env = info.Env();
    if (!IsOpen() || _handle == NULL) {
        Napi::Error::New(env, "Database is not open").ThrowAsJavaScriptException();
        return env.Null();
    }
    // A worker blocked mid-round-trip holds the connection mutex while
    // waiting for this very thread; reading the counter now would
    // deadlock. Everything else merely serializes on the mutex inside
    // sqlite, which is the ordinary main-thread sqlite cost.
    if (MayBlockOnWorkerRoundTrip()) {
        Napi::Error::New(env,
            "db.changes cannot be read while a JavaScript function, "
            "collation or progress callback is mid-call on this "
            "connection; read it from a callback or after the query"
        ).ThrowAsJavaScriptException();
        return env.Null();
    }
    return ConvertInt64ToJS(env, sqlite3_changes64(_handle),
        integer_mode, "db.changes");
}

Napi::Value Database::TotalChangesGetter(const Napi::CallbackInfo& info) {
    auto env = info.Env();
    if (!IsOpen() || _handle == NULL) {
        Napi::Error::New(env, "Database is not open").ThrowAsJavaScriptException();
        return env.Null();
    }
    if (MayBlockOnWorkerRoundTrip()) {
        Napi::Error::New(env,
            "db.totalChanges cannot be read while a JavaScript function, "
            "collation or progress callback is mid-call on this "
            "connection; read it from a callback or after the query"
        ).ThrowAsJavaScriptException();
        return env.Null();
    }
    return ConvertInt64ToJS(env, sqlite3_total_changes64(_handle),
        integer_mode, "db.totalChanges");
}

// --- Transaction state, db status, memory and limit introspection ------
//
// All of these read live sqlite state, so they follow the db.changes
// pattern: refuse while a worker could be blocked mid-round-trip holding
// the connection mutex; everything else merely serializes on the
// (recursive) mutex inside sqlite, the ordinary main-thread sqlite cost.

// Shared open/refusal prelude for the live sqlite readers. Returns true
// when the caller may touch _handle.
bool Database::LiveReadGate(Napi::Env env, const char* who) {
    if (!IsOpen() || _handle == NULL) {
        Napi::Error::New(env, "Database is not open")
            .ThrowAsJavaScriptException();
        return false;
    }
    if (MayBlockOnWorkerRoundTrip()) {
        std::string what = std::string(who) +
            " cannot be read while a JavaScript function, collation, "
            "progress callback or virtual-table generator is mid-call on "
            "this connection; read it from a callback or after the query";
        Napi::Error::New(env, what).ThrowAsJavaScriptException();
        return false;
    }
    return true;
}

Napi::Value Database::InTransactionGetter(const Napi::CallbackInfo& info) {
    auto env = info.Env();
    if (!LiveReadGate(env, "db.inTransaction")) return env.Null();
    // sqlite3_get_autocommit: 0 inside an explicit transaction.
    return Napi::Boolean::New(env,
        sqlite3_get_autocommit(_handle) == 0);
}

Napi::Value Database::TxnStateGetter(const Napi::CallbackInfo& info) {
    auto env = info.Env();
    if (!LiveReadGate(env, "db.txnState")) return env.Null();
    switch (sqlite3_txn_state(_handle, "main")) {
        case SQLITE_TXN_NONE: return Napi::String::New(env, "none");
        case SQLITE_TXN_READ:  return Napi::String::New(env, "read");
        case SQLITE_TXN_WRITE: return Napi::String::New(env, "write");
        default: return env.Undefined();
    }
}

// _dbStatus(op, reset?) -> { current, highwater }: sqlite3_db_status
// counters (cache hits/misses, schema usage, statement memory...). Note
// that SQLITE_DEFAULT_MEMSTATUS=0 in this build zeroes the process-wide
// memory counters; the per-database cache and schema counters work.
Napi::Value Database::DbStatus(const Napi::CallbackInfo& info) {
    auto env = info.Env();
    REQUIRE_ARGUMENT_INTEGER(0, op);
    bool reset = false;
    if (info.Length() > 1 && !info[1].IsUndefined()) {
        if (!info[1].IsBoolean()) {
            Napi::TypeError::New(env, "reset flag must be a boolean")
                .ThrowAsJavaScriptException();
            return env.Null();
        }
        reset = info[1].As<Napi::Boolean>().Value();
    }
    if (!LiveReadGate(env, "db.status()")) return env.Null();
    int current = 0;
    int highwater = 0;
    int rc = sqlite3_db_status(_handle, op, &current, &highwater,
        reset ? 1 : 0);
    if (rc != SQLITE_OK) {
        EXCEPTION(sqlite3_errmsg(_handle), rc, exception);
        exception.As<Napi::Error>().ThrowAsJavaScriptException();
        return env.Null();
    }
    Napi::Object result = Napi::Object::New(env);
    result.Set("current", Napi::Number::New(env, current));
    result.Set("highwater", Napi::Number::New(env, highwater));
    return result;
}

// _releaseMemory() -> bytes freed: sqlite3_db_release_memory, the pool
// pressure lever (it releases non-essential page cache).
Napi::Value Database::ReleaseMemory(const Napi::CallbackInfo& info) {
    auto env = info.Env();
    if (!LiveReadGate(env, "db.releaseMemory()")) return env.Null();
    int freed = sqlite3_db_release_memory(_handle);
    return Napi::Number::New(env, freed);
}

// _getLimit(id) -> current value: sqlite3_limit(id, -1) reads without
// setting. The JS layer maps names onto ids (db.limits).
Napi::Value Database::GetLimit(const Napi::CallbackInfo& info) {
    auto env = info.Env();
    REQUIRE_ARGUMENT_INTEGER(0, id);
    if (!LiveReadGate(env, "db.limits")) return env.Null();
    return Napi::Number::New(env, sqlite3_limit(_handle, id, -1));
}

// _dbLocation(dbName) -> filesystem path of an attached database ("" for
// in-memory or temp schemas): sqlite3_db_filename.
Napi::Value Database::DbLocation(const Napi::CallbackInfo& info) {
    auto env = info.Env();
    REQUIRE_ARGUMENT_STRING(0, name);
    if (!LiveReadGate(env, "db.location()")) return env.Null();
    const char* file = sqlite3_db_filename(_handle, name.c_str());
    return Napi::String::New(env, file != NULL ? file : "");
}

Napi::Value Database::Exec(const Napi::CallbackInfo& info) {
    auto env = this->Env();
    auto* db = this;

    REQUIRE_ARGUMENT_STRING(0, sql);
    OPTIONAL_ARGUMENT_FUNCTION(1, callback);

    Baton* baton = new ExecBaton(db, callback, sql.c_str());
    db->Schedule(Work_BeginExec, baton, true);

    return info.This();
}

void Database::Work_BeginExec(Baton* baton) {
    assert(baton->db->exclusiveHeld);
    assert(baton->db->IsOpen());
    assert(baton->db->_handle);
    assert(baton->db->pending == 0);
    baton->db->pending++;

    auto env = baton->db->Env();
    CREATE_WORK("sqlite3.Database.Exec", Work_Exec, Work_AfterExec);
}

void Database::Work_Exec(napi_env e, void* data) {
    auto* baton = static_cast<ExecBaton*>(data);

    char* message = NULL;
    baton->status = sqlite3_exec(
        baton->db->_handle,
        baton->sql.c_str(),
        NULL,
        NULL,
        &message
    );

    if (baton->status != SQLITE_OK && message != NULL) {
        baton->message = std::string(message);
        sqlite3_free(message);
    }
}

void Database::Work_AfterExec(napi_env e, napi_status status, void* data) {
    std::unique_ptr<ExecBaton> baton(static_cast<ExecBaton*>(data));
    AFTER_WORK_TEARDOWN_GUARD(baton);

    auto* db = baton->db;
    db->pending--;
    // The exclusive exec released the database.
    db->exclusiveHeld = false;

    auto env = db->Env();
    Napi::HandleScope scope(env);

    // Drains the queue even when the completion callback below throws
    // (TRY_CATCH_CALL's early return).
    ProcessGuard process_on_exit(db);

    Napi::Function cb = baton->callback.Value();

    if (baton->status != SQLITE_OK) {
        EXCEPTION(baton->message, baton->status, exception);
        db->AttachPendingJsError(exception_obj);

        if (IS_FUNCTION(cb)) {
            Napi::Value argv[] = { exception };
            TRY_CATCH_CALL(db->Value(), cb, 1, argv);
        }
        else {
            Napi::Value info[] = { Napi::String::New(env, "error"), exception };
            EMIT_EVENT(db->Value(), 2, info);
        }
    }
    else if (IS_FUNCTION(cb)) {
        Napi::Value argv[] = { env.Null() };
        TRY_CATCH_CALL(db->Value(), cb, 1, argv);
    }
}

Napi::Value Database::Wait(const Napi::CallbackInfo& info) {
    auto env = info.Env();
    auto* db = this;

    OPTIONAL_ARGUMENT_FUNCTION(0, callback);

   auto* baton = new Baton(db, callback);
    db->Schedule(Work_Wait, baton, true);

    return info.This();
}

void Database::Work_Wait(Baton* b) {
    auto baton = std::unique_ptr<Baton>(b);

    auto env = baton->db->Env();
    Napi::HandleScope scope(env);

    assert(baton->db->exclusiveHeld);
    assert(baton->db->IsOpen());
    assert(baton->db->_handle);
    assert(baton->db->pending == 0);

    // The exclusive wait releases the database; Process sets it again
    // when it dispatches the next exclusive call, if any.
    baton->db->exclusiveHeld = false;

    Napi::Function cb = baton->callback.Value();
    if (IS_FUNCTION(cb)) {
        Napi::Value argv[] = { env.Null() };
        TRY_CATCH_CALL(baton->db->Value(), cb, 1, argv);
    }

    baton->db->Process();
}

Napi::Value Database::LoadExtension(const Napi::CallbackInfo& info) {
    auto env = this->Env();
    auto* db = this;

    REQUIRE_ARGUMENT_STRING(0, filename);
    OPTIONAL_ARGUMENT_FUNCTION(1, callback);

    Baton* baton = new LoadExtensionBaton(db, callback, filename.c_str());
    db->Schedule(Work_BeginLoadExtension, baton, true);

    return info.This();
}

void Database::Work_BeginLoadExtension(Baton* baton) {
    assert(baton->db->exclusiveHeld);
    assert(baton->db->IsOpen());
    assert(baton->db->_handle);
    assert(baton->db->pending == 0);
    baton->db->pending++;

    auto env = baton->db->Env();
    CREATE_WORK("sqlite3.Database.LoadExtension", Work_LoadExtension, Work_AfterLoadExtension);
}

void Database::Work_LoadExtension(napi_env e, void* data) {
    auto* baton = static_cast<LoadExtensionBaton*>(data);

    sqlite3_enable_load_extension(baton->db->_handle, 1);

    char* message = NULL;
    baton->status = sqlite3_load_extension(
        baton->db->_handle,
        baton->filename.c_str(),
        0,
        &message
    );

    sqlite3_enable_load_extension(baton->db->_handle, 0);

    if (baton->status != SQLITE_OK && message != NULL) {
        baton->message = std::string(message);
        sqlite3_free(message);
    }
}

void Database::Work_AfterLoadExtension(napi_env e, napi_status status, void* data) {
    std::unique_ptr<LoadExtensionBaton> baton(static_cast<LoadExtensionBaton*>(data));
    AFTER_WORK_TEARDOWN_GUARD(baton);

    auto* db = baton->db;
    db->pending--;
    // The exclusive loadExtension released the database.
    db->exclusiveHeld = false;

    auto env = db->Env();
    Napi::HandleScope scope(env);

    // Drains the queue even when the completion callback below throws
    // (TRY_CATCH_CALL's early return).
    ProcessGuard process_on_exit(db);

    Napi::Function cb = baton->callback.Value();

    if (baton->status != SQLITE_OK) {
        EXCEPTION(baton->message, baton->status, exception);

        if (IS_FUNCTION(cb)) {
            Napi::Value argv[] = { exception };
            TRY_CATCH_CALL(db->Value(), cb, 1, argv);
        }
        else {
            Napi::Value info[] = { Napi::String::New(env, "error"), exception };
            EMIT_EVENT(db->Value(), 2, info);
        }
    }
    else if (IS_FUNCTION(cb)) {
        Napi::Value argv[] = { env.Null() };
        TRY_CATCH_CALL(db->Value(), cb, 1, argv);
    }
}

void Database::RemoveCallbacks() {
    if (debug_trace) {
        debug_trace->finish();
        debug_trace = NULL;
    }
    if (debug_profile) {
        debug_profile->finish();
        debug_profile = NULL;
    }
    if (txn_event) {
        txn_event->finish();
        txn_event = NULL;
    }
    hook_change = hook_commit = hook_rollback = false;
    if (wal_event) {
        wal_event->finish();
        wal_event = NULL;
    }
}
