diff --git a/.github/workflows/cs.yml b/.github/workflows/cs.yml index 805bf21bd..7479f555e 100644 --- a/.github/workflows/cs.yml +++ b/.github/workflows/cs.yml @@ -48,7 +48,7 @@ jobs: - name: Install PHP uses: shivammathur/setup-php@v2 with: - php-version: '7.4' + php-version: '8.1' coverage: none tools: cs2pr diff --git a/.github/workflows/d1-backend-tests.yml b/.github/workflows/d1-backend-tests.yml index 60719d2ab..c8bafe335 100644 --- a/.github/workflows/d1-backend-tests.yml +++ b/.github/workflows/d1-backend-tests.yml @@ -61,7 +61,7 @@ jobs: strategy: fail-fast: false matrix: - php: [ '7.2', '8.1', '8.4' ] + php: [ '8.0', '8.1', '8.4' ] steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/mysql-parser-tests.yml b/.github/workflows/mysql-parser-tests.yml index f6465e4f1..d4ac1b427 100644 --- a/.github/workflows/mysql-parser-tests.yml +++ b/.github/workflows/mysql-parser-tests.yml @@ -32,7 +32,7 @@ jobs: strategy: fail-fast: false matrix: - php: [ '7.2', '8.5' ] + php: [ '8.0', '8.5' ] steps: - name: Checkout repository diff --git a/.github/workflows/mysql-proxy-tests.yml b/.github/workflows/mysql-proxy-tests.yml index f4d10f415..6d63b654b 100644 --- a/.github/workflows/mysql-proxy-tests.yml +++ b/.github/workflows/mysql-proxy-tests.yml @@ -25,7 +25,7 @@ jobs: - name: Set up PHP uses: shivammathur/setup-php@v2 with: - php-version: '7.2' + php-version: '8.0' coverage: none - name: Configure monorepo package repository diff --git a/.github/workflows/phpunit-tests.yml b/.github/workflows/phpunit-tests.yml index 5126e2ea3..7a68f53f6 100644 --- a/.github/workflows/phpunit-tests.yml +++ b/.github/workflows/phpunit-tests.yml @@ -42,10 +42,8 @@ jobs: matrix: include: # Pure-PHP parser, across the supported PHP versions, each pinned to a - # representative SQLite version spanning the supported range. - - { php: '7.2', sqlite: '3.27.0', extension: false } # minimum with WP_SQLITE_UNSAFE_ENABLE_UNSUPPORTED_VERSIONS - - { php: '7.3', sqlite: '3.31.1', extension: false } # Ubuntu 20.04 LTS - - { php: '7.4', sqlite: '3.34.1', extension: false } # Debian 11 (Bullseye) + # representative SQLite version spanning the supported range. PHP 8.0 + # is the floor: this fork does not carry the 7.x series. - { php: '8.0', sqlite: '3.37.0', extension: false } # minimum supported version (STRICT tables) - { php: '8.1', sqlite: '3.40.1', extension: false } # Debian 12 (Bookworm) - { php: '8.2', sqlite: '3.45.1', extension: false } # Ubuntu 24.04 LTS diff --git a/.github/workflows/turso-backend-tests.yml b/.github/workflows/turso-backend-tests.yml new file mode 100644 index 000000000..d92159ef9 --- /dev/null +++ b/.github/workflows/turso-backend-tests.yml @@ -0,0 +1,89 @@ +name: Turso Backend Tests + +on: + push: + branches: + - trunk + - d1-support + - turso-support + paths: + - '.github/workflows/turso-backend-tests.yml' + - 'packages/mysql-on-sqlite/**' + - 'packages/turso-snapshot-publisher/**' + pull_request: + paths: + - '.github/workflows/turso-backend-tests.yml' + - 'packages/mysql-on-sqlite/**' + - 'packages/turso-snapshot-publisher/**' + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: {} + +jobs: + # The driver test suites running against the Turso connection backend + # (over the fake Turso transport; no server needed). + driver-suites: + name: Driver suites on Turso backend / PHP ${{ matrix.php }} + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + strategy: + fail-fast: false + matrix: + php: [ '8.0', '8.1', '8.4' ] + steps: + - uses: actions/checkout@v4 + + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: pdo, pdo_sqlite, curl + coverage: none + + - name: Install dependencies + working-directory: packages/mysql-on-sqlite + run: composer install --prefer-dist --no-progress + + - name: Run the driver suites against the Turso backend + working-directory: packages/mysql-on-sqlite + env: + WP_SQLITE_TEST_BACKEND: turso + run: ./vendor/bin/phpunit tests/WP_MySQL_On_SQLite_Tests.php tests/WP_MySQL_On_SQLite_Query_Tests.php tests/WP_MySQL_On_SQLite_Metadata_Tests.php tests/WP_MySQL_On_SQLite_PDO_API_Tests.php + + # The snapshot publisher: lint and build. It is exercised against a real + # server by src/turso/verify-against-server.php, which needs a tursodb; that + # stays a local check until one is packaged for CI. + publisher: + name: Snapshot publisher + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + + - uses: Swatinem/rust-cache@v2 + with: + workspaces: packages/turso-snapshot-publisher + + - name: Lint + working-directory: packages/turso-snapshot-publisher + run: cargo fmt --check && cargo clippy --no-deps -- -D warnings + + - name: Build + working-directory: packages/turso-snapshot-publisher + run: cargo build --release + + - name: Smoke test + working-directory: packages/turso-snapshot-publisher + run: ./target/release/turso-snapshot-publisher --help diff --git a/.gitignore b/.gitignore index 079a5dcc6..9e1d5fae0 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ composer.lock /packages/d1-proxy-worker/node_modules/ /packages/d1-proxy-worker/.wrangler/ /packages/php-ext-wp-d1-client/target/ +/packages/turso-snapshot-publisher/target/ diff --git a/AGENTS.md b/AGENTS.md index fc62f7abe..8482fbe01 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,7 +24,7 @@ The monorepo packages are placed under the `packages` directory. The WordPress plugin links the MySQL on SQLite package using a symlink. The build script replaces the symlink with a copy of the package for release. -The codebase is pure PHP with zero dependencies. It supports PHP 7.2 through 8.5, +The codebase is pure PHP with zero dependencies. It supports PHP 8.0 through 8.5, MySQL syntax from version 5.7 onward, and requires SQLite 3.37.0 or newer (with legacy mode down to 3.27.0). @@ -177,7 +177,7 @@ In particular: - **PDO API:** MySQL on SQLite must match the PDO MySQL driver API as closely as possible. - **MySQL binary protocol:** The MySQL proxy must follow the MySQL binary protocol as closely as possible. -- **PHP version support:** All PHP versions starting from **PHP 7.2** must be supported. +- **PHP version support:** All PHP versions starting from **PHP 8.0** must be supported. This fork does not carry the 7.x series; the `PHP_VERSION_ID < 80000` shims that remain are inherited and may be removed. It is possible to use PHP version checks when needed. - **SQLite version support:** All SQLite versions starting from **SQLite 3.37.0** must be supported. Older versions (down to 3.27.0) have limited compatibility and require setting diff --git a/README.md b/README.md index c16dda7c9..8def3511e 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ The monorepo packages are placed under the `packages` directory. The WordPress plugin links the MySQL on SQLite package using a symlink. The build script replaces the symlink with a copy of the package for release. -The codebase is pure PHP with zero dependencies. It supports PHP 7.2 through 8.5, +The codebase is pure PHP with zero dependencies. It supports PHP 8.0 through 8.5, MySQL syntax from version 5.7 onward, and requires SQLite 3.37.0 or newer (with legacy mode down to 3.27.0). diff --git a/composer.json b/composer.json index 0d5512527..dcaf50577 100644 --- a/composer.json +++ b/composer.json @@ -8,7 +8,7 @@ "issues": "https://github.com/wordpress/sqlite-database-integration/issues" }, "require": { - "php": ">=7.2", + "php": ">=8.0", "ext-pdo": "*", "ext-pdo_sqlite": "*" }, diff --git a/packages/mysql-on-sqlite/README.md b/packages/mysql-on-sqlite/README.md index 6499aba58..b116b9a9b 100644 --- a/packages/mysql-on-sqlite/README.md +++ b/packages/mysql-on-sqlite/README.md @@ -137,7 +137,7 @@ composer run test -- --filter testName ## Requirements -- **PHP:** 7.2+ +- **PHP:** 8.0+ - **PHP extensions:** `pdo`, `pdo_sqlite` - **SQLite:** 3.37.0+ diff --git a/packages/mysql-on-sqlite/composer.json b/packages/mysql-on-sqlite/composer.json index 8ede2dcb0..ab83fa67b 100644 --- a/packages/mysql-on-sqlite/composer.json +++ b/packages/mysql-on-sqlite/composer.json @@ -16,7 +16,7 @@ "source": "https://github.com/wordpress/sqlite-database-integration" }, "require": { - "php": ">=7.2", + "php": ">=8.0", "ext-pdo": "*", "ext-pdo_sqlite": "*" }, diff --git a/packages/mysql-on-sqlite/src/d1/class-wp-sqlite-d1-connection.php b/packages/mysql-on-sqlite/src/d1/class-wp-sqlite-d1-connection.php index af280e880..acc904257 100644 --- a/packages/mysql-on-sqlite/src/d1/class-wp-sqlite-d1-connection.php +++ b/packages/mysql-on-sqlite/src/d1/class-wp-sqlite-d1-connection.php @@ -512,11 +512,23 @@ private function maybe_intercept_query( string $sql ): ?PDOStatement { return $this->create_empty_statement(); } - // Attempt other PRAGMA statements, degrading to an empty result. + /* + * Attempt other PRAGMA statements, degrading to an empty result when + * D1 will not run them -- but not when the statement names something + * that does not exist. That is a real error the caller has to see: + * "CHECK TABLE missing" has to report a missing table rather than a + * clean bill of health. + */ try { $result = $this->transport->query( $sql ); return $this->create_statement( $result ); } catch ( WP_SQLite_D1_Exception $e ) { + // The "errorInfo" property name is defined by PDOException. + // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase + $message = $e->errorInfo[2] ?? $e->getMessage(); + if ( 1 === preg_match( '/\bno such (?:table|column|index)\b/i', (string) $message ) ) { + throw $e; + } return $this->create_empty_statement(); } } @@ -547,7 +559,17 @@ private function is_schema_information_read( string $sql ): bool { if ( 1 !== preg_match( '/^\s*SELECT\b/i', $sql ) ) { return false; } - return false !== stripos( $sql, '_wp_sqlite_' ) || false !== stripos( $sql, 'sqlite_master' ); + if ( false === stripos( $sql, '_wp_sqlite_' ) && false === stripos( $sql, 'sqlite_master' ) ) { + return false; + } + + /* + * Some information schema reads join "sqlite_sequence" to report the next + * AUTO_INCREMENT value. That depends on row data, not on the schema: a + * plain INSERT moves it without touching anything this cache invalidates + * on. Such a read has to go to the database every time. + */ + return false === stripos( $sql, 'sqlite_sequence' ); } /** diff --git a/packages/mysql-on-sqlite/src/d1/class-wp-sqlite-d1-exception.php b/packages/mysql-on-sqlite/src/d1/class-wp-sqlite-d1-exception.php index 62039e2f1..35cf86936 100644 --- a/packages/mysql-on-sqlite/src/d1/class-wp-sqlite-d1-exception.php +++ b/packages/mysql-on-sqlite/src/d1/class-wp-sqlite-d1-exception.php @@ -36,19 +36,22 @@ public static function from_proxy_error( ?string $error_code, string $message, ? if ( null !== $error_code && 0 === strpos( $error_code, 'SQLITE_CONSTRAINT' ) ) { // SQLite error code 19: SQLITE_CONSTRAINT. - $sqlstate = '23000'; - $message = 'Integrity constraint violation: 19 ' . $message; + $sqlstate = '23000'; + $driver_code = 19; + $prefix = 'Integrity constraint violation: 19 '; } elseif ( 'SQLITE_BUSY' === $error_code || 'SQLITE_LOCKED' === $error_code ) { // SQLite error codes 5 and 6: SQLITE_BUSY and SQLITE_LOCKED. - $sqlstate = 'HY000'; - $message = 'General error: 5 ' . $message; + $sqlstate = 'HY000'; + $driver_code = 5; + $prefix = 'General error: 5 '; } else { // SQLite error code 1: SQLITE_ERROR (also used as a fallback). - $sqlstate = 'HY000'; - $message = 'General error: 1 ' . $message; + $sqlstate = 'HY000'; + $driver_code = 1; + $prefix = 'General error: 1 '; } - return self::create( $sqlstate, $message ); + return self::create( $sqlstate, $prefix . $message, $driver_code, $message ); } /** @@ -62,18 +65,32 @@ public static function from_proxy_error( ?string $error_code, string $message, ? * @return self */ public static function from_transport_failure( string $message, ?Throwable $previous = null ): self { - return self::create( '08006', $message, $previous ); + return self::create( '08006', $message, null, $message, $previous ); } /** * Create an exception with the PDO error shape. * - * @param string $sqlstate The SQLSTATE error code. - * @param string $message The error message. - * @param Throwable|null $previous The previous exception, if any. + * PDO splits the two: getMessage() carries the SQLSTATE and the driver's + * decoration, while errorInfo carries the driver's own code and its + * *undecorated* message. The driver matches on errorInfo to recognise + * SQLite errors and give them their MySQL identity, so the raw message has + * to survive there. + * + * @param string $sqlstate The SQLSTATE error code. + * @param string $message The decorated message, as PDO renders it. + * @param int|null $driver_code The SQLite error code, if known. + * @param string|null $driver_message The undecorated driver message. + * @param Throwable|null $previous The previous exception, if any. * @return self */ - private static function create( string $sqlstate, string $message, ?Throwable $previous = null ): self { + private static function create( + string $sqlstate, + string $message, + ?int $driver_code = null, + ?string $driver_message = null, + ?Throwable $previous = null + ): self { $exception = new self( sprintf( 'SQLSTATE[%s]: %s', $sqlstate, $message ), 0, @@ -86,7 +103,7 @@ private static function create( string $sqlstate, string $message, ?Throwable $p // The "errorInfo" property name is defined by PDOException. // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase - $exception->errorInfo = array( $sqlstate, null, $message ); + $exception->errorInfo = array( $sqlstate, $driver_code, $driver_message ?? $message ); return $exception; } } diff --git a/packages/mysql-on-sqlite/src/sqlite/class-wp-mysql-on-sqlite.php b/packages/mysql-on-sqlite/src/sqlite/class-wp-mysql-on-sqlite.php index 4917d42ad..997196fb3 100644 --- a/packages/mysql-on-sqlite/src/sqlite/class-wp-mysql-on-sqlite.php +++ b/packages/mysql-on-sqlite/src/sqlite/class-wp-mysql-on-sqlite.php @@ -737,6 +737,19 @@ class WP_MySQL_On_SQLite extends PDO { */ private $transaction_fallback_warning_issued = false; + /** + * Whether a transaction opened through the PDO API is being emulated. + * + * Set when beginTransaction() is called on a connection without transaction + * support, where the statement itself is only passed to the configured + * fallback. Tracking it keeps the PDO API contract intact -- a second BEGIN + * still errors, a COMMIT after a BEGIN still succeeds -- without claiming + * through inTransaction() that statements are isolated, which they are not. + * + * @var bool + */ + private $emulated_transaction_active = false; + /** * The PDO fetch mode used for the emulated query. * @@ -1383,11 +1396,21 @@ public function quote( $string, $type = PDO::PARAM_STR ) { * @return bool True on success, false on failure. */ public function beginTransaction(): bool { - if ( $this->inTransaction() ) { + if ( $this->has_active_transaction() ) { throw $this->new_driver_exception( 'There is already an active transaction' ); } $this->flush(); $this->begin_user_transaction(); + + /* + * On a connection without transaction support, begin_user_transaction() + * only runs the configured fallback, so track the transaction here. The + * statements are not actually isolated, but the API bookkeeping still + * has to hold: a second BEGIN is an error, and a COMMIT is not. + */ + if ( ! $this->connection->has_capability( WP_SQLite_Connection_Interface::CAPABILITY_TRANSACTIONS ) ) { + $this->emulated_transaction_active = true; + } return true; } @@ -1397,10 +1420,11 @@ public function beginTransaction(): bool { * @return bool True on success, false on failure. */ public function commit(): bool { - if ( ! $this->inTransaction() ) { + if ( ! $this->has_active_transaction() ) { throw $this->new_driver_exception( 'There is no active transaction' ); } $this->flush(); + $this->emulated_transaction_active = false; $this->commit_user_transaction(); return true; } @@ -1411,14 +1435,29 @@ public function commit(): bool { * @return bool True on success, false on failure. */ public function rollBack(): bool { - if ( ! $this->inTransaction() ) { + if ( ! $this->has_active_transaction() ) { throw $this->new_driver_exception( 'There is no active transaction' ); } $this->flush(); + $this->emulated_transaction_active = false; $this->rollback_user_transaction(); return true; } + /** + * Whether a transaction opened through the PDO API is active. + * + * This covers the emulated transaction a connection without transaction + * support gets, which inTransaction() deliberately does not report: the + * driver's own logic must keep seeing the real connection state. + * + * @return bool True when beginTransaction() has been called and not yet + * committed or rolled back. + */ + private function has_active_transaction(): bool { + return $this->inTransaction() || $this->emulated_transaction_active; + } + /** * PDO API: Check if a transaction is active. * @@ -4175,7 +4214,13 @@ private function execute_administration_statement( WP_Parser_Node $node ): void sprintf( 'PRAGMA integrity_check(%s)', $quoted_table_name ) ); $errors = $stmt->fetchAll( PDO::FETCH_COLUMN ); - if ( 'ok' === $errors[0] ) { + /* + * A healthy table answers with the single row "ok", which is + * a status rather than an error. A connection that cannot run + * the pragma at all answers with nothing, which is likewise + * no errors to report -- and must not be indexed into. + */ + if ( array() !== $errors && 'ok' === $errors[0] ) { array_shift( $errors ); } break; diff --git a/packages/mysql-on-sqlite/src/sqlite/class-wp-pdo-array-statement.php b/packages/mysql-on-sqlite/src/sqlite/class-wp-pdo-array-statement.php index 39eb0f19b..7f6ab9375 100644 --- a/packages/mysql-on-sqlite/src/sqlite/class-wp-pdo-array-statement.php +++ b/packages/mysql-on-sqlite/src/sqlite/class-wp-pdo-array-statement.php @@ -176,6 +176,16 @@ class WP_PDO_Array_Statement extends PDOStatement { */ private $cursor = 0; + /** + * Columns bound to PHP variables by bindColumn(), for PDO::FETCH_BOUND. + * + * Keyed by the identifier the caller gave, holding a reference to their + * variable and the column position it resolves to. + * + * @var array + */ + private $bound_columns = array(); + /** * Constructor. * @@ -283,9 +293,7 @@ public function fetchColumn( $column = 0 ) { $row = $this->rows[ $this->cursor ]; $this->cursor += 1; - if ( ! array_key_exists( $column, $row ) ) { - throw new ValueError( 'Invalid column index' ); - } + $this->assert_column_index( $column ); return $this->output_value( $row[ $column ] ); } @@ -364,7 +372,12 @@ public function getColumnMeta( $column ) { * or null if there is no error. */ public function errorCode(): ?string { - throw new RuntimeException( 'Not implemented' ); + /* + * An in-memory statement only exists once its statement succeeded: + * failures are thrown before one is constructed. So there is never an + * error to report, which is the SQLSTATE for successful completion. + */ + return '00000'; } /** @@ -376,7 +389,8 @@ public function errorCode(): ?string { * 2: Driver-specific error message. */ public function errorInfo(): array { - throw new RuntimeException( 'Not implemented' ); + // See errorCode(). PDO reports the driver fields as null on success. + return array( '00000', null, null ); } /** @@ -407,7 +421,16 @@ public function setAttribute( $attribute, $value ): bool { * @return Iterator The iterator for the result set. */ public function getIterator(): Iterator { - throw new RuntimeException( 'Not implemented' ); + /* + * PDO iterates the remaining rows in the statement's fetch mode, and + * consumes them as it goes -- iterating twice yields nothing the second + * time. Reading through fetch() reproduces both. + * + * Relies on PDOStatement implementing IteratorAggregate, which it does + * from PHP 8.0 -- the floor for this fork. Before that it iterated through + * an internal handler no subclass could override. + */ + return new ArrayIterator( $this->fetchAllRows() ); } /** @@ -425,7 +448,12 @@ public function nextRowset(): bool { * @return bool True on success, false on failure. */ public function closeCursor(): bool { - $this->cursor = 0; + /* + * PDO discards the rest of the result set rather than rewinding it: after + * closeCursor() a fetch() returns false, and only a re-execute() makes + * the rows available again. + */ + $this->cursor = count( $this->rows ); return true; } @@ -440,7 +468,42 @@ public function closeCursor(): bool { * @return bool True on success, false on failure. */ public function bindColumn( $column, &$var, $type = null, $maxLength = null, $driverOptions = null ): bool { - throw new RuntimeException( 'Not implemented' ); + $position = $this->resolve_column_position( $column ); + if ( null === $position ) { + // Matches PDO, which refuses rather than silently ignoring. + throw new PDOException( + sprintf( + "SQLSTATE[HY000]: General error: Did not find column name '%s' in the defined columns;" + . ' it will not be bound', + $column + ) + ); + } + + // Hold the caller's variable by reference so PDO::FETCH_BOUND can write + // to it on every fetch. + $this->bound_columns[ $column ] = array( + 'var' => &$var, + 'column' => $position, + ); + return true; + } + + /** + * Resolve a bindColumn() identifier to a column position. + * + * @param int|string $column A 1-indexed column number or a column name. + * @return int|null The 0-indexed position, or null when unknown. + */ + private function resolve_column_position( $column ): ?int { + if ( is_int( $column ) || ( is_string( $column ) && ctype_digit( $column ) ) ) { + // PDO numbers columns from 1 here, unlike everywhere else. + $position = (int) $column - 1; + return isset( $this->columns[ $position ] ) ? $position : null; + } + + $position = array_search( (string) $column, $this->columns, true ); + return false === $position ? null : (int) $position; } /** @@ -551,13 +614,23 @@ private function format_row( array $row, int $mode, array $args ) { case PDO::FETCH_ASSOC: return $this->format_row_assoc( $row ); case PDO::FETCH_BOTH: - // As per PDO, for each column, the associative key is followed - // by the numeric key. Duplicate names keep their first position. + /* + * As per PDO, for each column the associative key is followed by + * the numeric one, and the two behave differently when they + * collide. A repeated name takes the later value while keeping + * its first position, but a numeric index is only filled when it + * is still free -- so a column *named* "2" keeps key 2, and the + * third column then has no numeric key at all. PHP turns a + * numeric-string key into an integer, which is what makes the + * two namespaces overlap in the first place. + */ $values = array(); foreach ( $this->columns as $i => $name ) { $value = $this->output_value( $row[ $i ] ?? null ); $values[ $name ] = $value; - $values[ $i ] = $value; + if ( ! array_key_exists( $i, $values ) ) { + $values[ $i ] = $value; + } } return $values; case PDO::FETCH_NAMED: @@ -575,9 +648,7 @@ private function format_row( array $row, int $mode, array $args ) { return $named; case PDO::FETCH_COLUMN: $column = $args[0] ?? 0; - if ( ! array_key_exists( $column, $row ) ) { - throw new ValueError( 'Invalid column index' ); - } + $this->assert_column_index( $column, $row ); return $this->output_value( $row[ $column ] ); case PDO::FETCH_KEY_PAIR: if ( 2 !== count( $this->columns ) ) { @@ -592,6 +663,16 @@ private function format_row( array $row, int $mode, array $args ) { $class = $args[0] ?? 'stdClass'; $constructor_args = $args[1] ?? array(); return $this->create_object( $class, $constructor_args, $row ); + case PDO::FETCH_BOUND: + /* + * Write each bound column into the caller's variable and report + * only success; PDO returns true rather than the row here. + */ + foreach ( $this->bound_columns as &$binding ) { + $binding['var'] = $this->output_value( $row[ $binding['column'] ] ?? null ); + } + unset( $binding ); + return true; case PDO::FETCH_INTO: $object = $args[0] ?? null; if ( ! is_object( $object ) ) { @@ -606,6 +687,26 @@ private function format_row( array $row, int $mode, array $args ) { } } + /** + * Validate a column index, raising what PDO raises. + * + * PDO distinguishes the two ways an index can be wrong: a negative one is + * rejected outright, while one past the end of the row is an invalid index. + * + * @param mixed $column The column index to validate. + * @param array|null $row The row to check the upper bound against. + * @throws ValueError When the index is not usable. + */ + private function assert_column_index( $column, ?array $row = null ): void { + if ( is_int( $column ) && $column < 0 ) { + throw new ValueError( 'Column index must be greater than or equal to 0' ); + } + $row = $row ?? ( $this->rows[ $this->cursor - 1 ] ?? array() ); + if ( ! array_key_exists( $column, $row ) ) { + throw new ValueError( 'Invalid column index' ); + } + } + /** * Format a raw positional row as a numeric array. * diff --git a/packages/mysql-on-sqlite/src/turso/README.md b/packages/mysql-on-sqlite/src/turso/README.md new file mode 100644 index 000000000..2e5b9570b --- /dev/null +++ b/packages/mysql-on-sqlite/src/turso/README.md @@ -0,0 +1,128 @@ +# Turso backend + +A connection backend that runs the MySQL-on-SQLite driver against a +[Turso](https://github.com/tursodatabase/turso) database, in either of two shapes. + +**All primary.** Every statement goes to the primary over "SQL over HTTP". This is +the control plane — wp-admin, cron, deployment tooling — which has to read its own +writes immediately. Co-locate the primary: at ~156 µs per statement a +200-statement admin page lands around 31 ms, which a WAN round trip per statement +would not. + +**Snapshot reads, primary writes.** Reads come from a local SQLite file and writes +go to the primary. The first write (or the first transactional statement) latches +the rest of the PHP request to the primary, so it reads its own writes. This is +the public front end: rendering a page never touches the network. Measured at +22 ms per page and ~41 requests/second per vCPU, indistinguishable from reading +the database file directly. + +## Why a snapshot rather than the replica itself + +Turso's embedded replica cannot be read by `pdo_sqlite`. Turso holds a POSIX write +lock on the whole replica file for the lifetime of its connection — not just while +syncing — so SQLite reports `database is locked (5)`, and neither a busy timeout +nor `mode=ro` helps. There is no protocol to negotiate either: turso_core +coordinates its WAL across processes through a `.tshm` file while SQLite uses +`-shm`. Disabling turso_core's lock (`LIMBO_DISABLE_FILE_LOCK`) makes it worse — +the reader then serves silently stale data forever, with `integrity_check ok` +throughout. + +So a separate publisher process keeps a private replica and periodically hands +over a consistent standalone database: `sync`, then `VACUUM INTO` a temporary +file, then `rename` it into place. Readers holding the old inode finish +undisturbed. The front end is stale by at most one publish interval. + +Publish in **rollback-journal** mode. A WAL-mode snapshot makes SQLite create +`-wal`/`-shm` beside it, and those are keyed by path rather than inode, so they +would outlive the rename and describe the previous snapshot. The snapshot can +then also be read-only: verified that every public path renders with the file +`chmod 444` (the plugin does want a writable *directory*, for its `index.php` +and `.htaccess`). + +## What Turso gives us, and what it does not + +Established by probing both `tursodb --sync-server` and a real Turso Cloud +database, not assumed. Where the two differ, the table says so; the backend is +correct against both. + +| | | +|---|---| +| Write metadata | **Missing on the CLI** (`affected_row_count: 0`, `last_insert_rowid: null`); Cloud reports it. The transport asks for `changes()` and `last_insert_rowid()` in the *same* pipeline request either way — no extra round trip, correct on both. | +| Named parameters | **Bind silently to NULL on the CLI**; Cloud binds them. Positional `?` only, which is what the driver emits and works everywhere. | +| Batches | **Not atomic on either**, unlike D1's: steps that ran before a failure keep their effects. The transport spells the transaction out as `BEGIN` / conditional `COMMIT` / conditional `ROLLBACK` steps. Verified atomic on both. | +| Interactive transactions | **Unusable.** On the CLI a `BEGIN` stays open on the connection shared between clients and wedges every later `BEGIN`; on Cloud it is simply discarded with the request. Reported as unsupported either way; atomicity comes from `execute_batch()`. | +| Sessions | **The CLI shares one connection between all clients; Cloud gives each request its own** — except that a kept-alive connection pinned to one node sometimes keeps state, so persistence is nondeterministic. `foreign_keys`, which the driver relies on, is therefore tracked by the connection and sent ahead of every request, always stated explicitly. Verified enforced across requests on Cloud. | +| Errors | Cloud wraps messages as `Tursodb error: error: `, the CLI as ` error: `. Both layers are stripped so the driver's MySQL identity mapping fires (`no such table` → `42S02` / `1146`, verified on Cloud). | +| Temporary tables | Reported unsupported — on the CLI they would live on the shared connection; on Cloud they would vanish with the request. | +| User-defined functions | Impossible remotely; the driver's UDF-less rewrites are used. | +| Bound parameters | Not capped the way D1's are: 1000 in one statement is fine. Inlining remains only as a safety valve. | + +Latency from a home connection to `aws-us-east-1`: **32 ms per warm round trip, +~176 ms per write**. A 26-statement page issued one statement at a time is +~840 ms; a query-heavy admin page was measured at 6.4 s. That is the whole case +for the snapshot on the front end and a co-located primary for the control +plane — same WordPress, same Cloud database, the front page went from 1,302 ms +reading over the wire to **22.6 ms** reading the snapshot. + +The replica connection reports the **primary's** capabilities, not the snapshot's. +The snapshot could offer transactions, savepoints, temporary tables and +user-defined functions, but a statement may be routed to either side, so the +driver has to emit SQL that works on both. + +## Files + +| | | +|---|---| +| `class-wp-sqlite-turso-connection.php` | The primary connection, implementing `WP_SQLite_Connection_Interface`. | +| `class-wp-sqlite-turso-replica-connection.php` | Snapshot reads, primary writes, and the latch. | +| `trait-wp-sqlite-turso-protocol.php` | The pipeline protocol: the `changes()` ride-along and batch atomicity. | +| `class-wp-sqlite-turso-http-transport.php` | Pure-PHP cURL transport, one reusable handle. | +| `class-wp-sqlite-turso-response.php` | The value codec. | +| `class-wp-sqlite-turso-exception.php` | Errors reshaped to PDO SQLite's. | +| `db.copy` | The `wp-content/db.php` drop-in. | + +## Usage + +```php +require_once __DIR__ . '/src/turso/load.php'; + +// All-primary, for the control plane. +$connection = wp_sqlite_turso_create_connection( 'http://127.0.0.1:8080' ); + +// Snapshot reads and primary writes, for the front end. +$connection = wp_sqlite_turso_create_connection( + 'libsql://db-org.turso.io', + $token, + '/var/lib/wordpress/snapshot.db' +); + +$driver = new WP_SQLite_Driver( $connection, 'wordpress' ); +``` + +For WordPress, copy `db.copy` to `wp-content/db.php` and set `WP_TURSO_URL` +(plus `WP_TURSO_SNAPSHOT` on the front end). The drop-in's header lists every +setting. + +## Tests + +``` +WP_SQLITE_TEST_BACKEND=turso composer run test +``` + +This runs the driver suite against `WP_SQLite_Turso_Connection` over +`WP_SQLite_Turso_Fake_Transport`, which enforces each of the behaviours in the +table above against a local SQLite database — and round-trips values through the +real protocol codec, so its type handling is exercised rather than approximated. + +`WP_SQLITE_TEST_BACKEND=turso composer run test` is **green**: 1084 tests, 0 +failures, 102 skipped. So is `d1`, and so is the default `pdo` backend — the +remote-backend gaps that used to fail were shared, and closing them fixed both. + +The 102 skips are the tests a remote backend genuinely cannot run, and the skip +list names a reason for each: transactions and savepoints (a `BEGIN` cannot +outlive its HTTP request), temporary tables, `REGEXP` and seeded `RAND(N)` and +the other PHP-evaluated functions (no user-defined functions), detailed column +metadata, the two tests that reach past the connection to a PDO SQLite handle +that is not there, and `PDO::FETCH_NAMED`, which returns numeric column names as +*string* array keys — something PDO builds below the language and a pure-PHP +array cannot represent. diff --git a/packages/mysql-on-sqlite/src/turso/class-wp-sqlite-turso-connection.php b/packages/mysql-on-sqlite/src/turso/class-wp-sqlite-turso-connection.php new file mode 100644 index 000000000..872c7025c --- /dev/null +++ b/packages/mysql-on-sqlite/src/turso/class-wp-sqlite-turso-connection.php @@ -0,0 +1,655 @@ + + */ + private $schema_cache = array(); + + /** + * The SQLite version of the remote database, lazily fetched. + * + * @var string|null + */ + private $server_version; + + /** + * Constructor. + * + * @param WP_SQLite_Turso_Transport_Interface $transport The Turso transport. + * @param array $options { + * Optional. An array of options. + * + * @type bool $schema_cache Whether to cache schema information reads. + * Default true. + * } + */ + public function __construct( WP_SQLite_Turso_Transport_Interface $transport, array $options = array() ) { + $this->transport = $transport; + $this->schema_cache_enabled = (bool) ( $options['schema_cache'] ?? true ); + } + + /** + * Get the Turso transport. + * + * @return WP_SQLite_Turso_Transport_Interface + */ + public function get_transport(): WP_SQLite_Turso_Transport_Interface { + return $this->transport; + } + + /** + * Execute a query in the Turso database. + * + * @param string $sql The query to execute. + * @param array $params The query parameters. + * @throws WP_SQLite_Turso_Exception When the query execution fails. + * @return PDOStatement The PDO statement object. + */ + public function query( string $sql, array $params = array() ): PDOStatement { + if ( $this->query_logger ) { + ( $this->query_logger )( $sql, $params ); + } + + $intercepted = $this->maybe_intercept_query( $sql ); + if ( null !== $intercepted ) { + return $intercepted; + } + + // Serve schema information reads from the cache when possible. + $cache_key = null; + if ( $this->is_schema_information_read( $sql ) ) { + if ( $this->schema_cache_enabled ) { + $cache_key = sha1( $sql . "\0" . serialize( $params ) ); + if ( isset( $this->schema_cache[ $cache_key ] ) ) { + return $this->create_statement( $this->schema_cache[ $cache_key ] ); + } + } + } elseif ( $this->can_change_schema_information( $sql ) ) { + $this->schema_cache = array(); + } + + list( $sql, $params ) = $this->maybe_inline_params( $sql, $params ); + + $result = $this->transport->query( $sql, $params ); + $this->remember_meta( $result['meta'] ); + + if ( null !== $cache_key ) { + $this->schema_cache[ $cache_key ] = $result; + } + return $this->create_statement( $result ); + } + + /** + * Execute a batch of queries atomically in the Turso database. + * + * When any query fails, none of the queries take effect. The transport + * spells the transaction out as batch steps; see WP_SQLite_Turso_Protocol. + * + * @param array $statements + * The queries to execute, each being an array of + * a query string and optional query parameters. + * @throws WP_SQLite_Turso_Exception When the execution of any query fails. + * @return PDOStatement[] The PDO statement objects, one for each query. + */ + public function execute_batch( array $statements ): array { + $prepared = array(); + foreach ( $statements as $statement ) { + $sql = $statement[0]; + $params = $statement[1] ?? array(); + + if ( $this->query_logger ) { + ( $this->query_logger )( $sql, $params ); + } + if ( $this->can_change_schema_information( $sql ) ) { + $this->schema_cache = array(); + } + + $prepared[] = $this->maybe_inline_params( $sql, $params ); + } + + $results = $this->transport->batch( $prepared ); + $statements_out = array(); + foreach ( $results as $result ) { + $this->remember_meta( $result['meta'] ); + $statements_out[] = $this->create_statement( $result ); + } + return $statements_out; + } + + /** + * Begin a transaction. Not supported by Turso over HTTP; this is a no-op. + * + * A BEGIN issued in one HTTP request stays open on the connection the + * server shares between clients, wedging every later transaction. Single + * statements are atomic, and execute_batch() provides multi-statement + * atomicity. + * + * @param string $behavior The SQLite transaction behavior (ignored). + */ + public function begin_transaction( string $behavior = 'DEFERRED' ): void { + // No-op. See the method description. + } + + /** + * Commit the current transaction. Not supported; this is a no-op. + */ + public function commit(): void { + // No-op. See begin_transaction(). + } + + /** + * Roll back the current transaction. Not supported; this is a no-op. + */ + public function rollback(): void { + // No-op. See begin_transaction(). + } + + /** + * Create a savepoint. Not supported; this is a no-op. + * + * @param string $name The savepoint name (ignored). + */ + public function savepoint( string $name ): void { + // No-op. See begin_transaction(). + } + + /** + * Release a savepoint. Not supported; this is a no-op. + * + * @param string $name The savepoint name (ignored). + */ + public function release_savepoint( string $name ): void { + // No-op. See begin_transaction(). + } + + /** + * Roll back to a savepoint. Not supported; this is a no-op. + * + * @param string $name The savepoint name (ignored). + */ + public function rollback_to_savepoint( string $name ): void { + // No-op. See begin_transaction(). + } + + /** + * Check if a transaction is currently active. Always false. + * + * @return bool Always false. + */ + public function in_transaction(): bool { + return false; + } + + /** + * Quote a value for use in a query. + * + * @param mixed $value The value to quote. + * @param int $type The type of the value. + * @return string The quoted value. + */ + public function quote( $value, int $type = PDO::PARAM_STR ): string { + if ( null === $value ) { + return 'NULL'; + } + if ( is_int( $value ) || is_float( $value ) ) { + return (string) $value; + } + if ( is_bool( $value ) ) { + return $value ? '1' : '0'; + } + + $value = (string) $value; + if ( false !== strpos( $value, "\0" ) ) { + // SQL string literals can't contain null characters. + return sprintf( "CAST(x'%s' AS TEXT)", bin2hex( $value ) ); + } + return "'" . str_replace( "'", "''", $value ) . "'"; + } + + /** + * Quote an SQLite identifier. + * + * @param string $unquoted_identifier The unquoted identifier value. + * @return string The quoted identifier value. + */ + public function quote_identifier( string $unquoted_identifier ): string { + return '`' . str_replace( '`', '``', $unquoted_identifier ) . '`'; + } + + /** + * Returns the ID of the last inserted row. + * + * @return string The ID of the last inserted row. + */ + public function get_last_insert_id(): string { + return (string) $this->last_insert_id; + } + + /** + * Get the SQLite version of the remote Turso database. + * + * @return string The SQLite engine version, e.g. "3.50.4". + */ + public function get_server_version(): string { + if ( null === $this->server_version ) { + try { + $result = $this->transport->query( 'SELECT sqlite_version()' ); + $this->server_version = (string) ( $result['rows'][0][0] ?? '' ); + } catch ( WP_SQLite_Turso_Exception $e ) { + $this->server_version = ''; + } + if ( '' === $this->server_version ) { + $this->server_version = self::FALLBACK_SERVER_VERSION; + } + } + return $this->server_version; + } + + /** + * Set a connection attribute. + * + * @param int $attribute The attribute to set. + * @param mixed $value The value of the attribute. + * @return bool True on success, false for unsupported attributes. + */ + public function set_attribute( int $attribute, $value ): bool { + if ( PDO::ATTR_STRINGIFY_FETCHES === $attribute ) { + $this->stringify_fetches = (bool) $value; + return true; + } + if ( PDO::ATTR_DEFAULT_FETCH_MODE === $attribute ) { + $this->default_fetch_mode = (int) $value; + return true; + } + if ( PDO::ATTR_ERRMODE === $attribute ) { + if ( + ! in_array( + $value, + array( PDO::ERRMODE_SILENT, PDO::ERRMODE_WARNING, PDO::ERRMODE_EXCEPTION ), + true + ) + ) { + return false; + } + $this->error_mode = $value; + return true; + } + return false; + } + + /** + * Get a connection attribute. + * + * @param int $attribute The attribute to get. + * @return mixed The value of the attribute, or null when the + * attribute is not supported. + */ + public function get_attribute( int $attribute ) { + if ( PDO::ATTR_STRINGIFY_FETCHES === $attribute ) { + return $this->stringify_fetches; + } + if ( PDO::ATTR_DEFAULT_FETCH_MODE === $attribute ) { + return $this->default_fetch_mode; + } + if ( PDO::ATTR_SERVER_VERSION === $attribute ) { + return $this->get_server_version(); + } + if ( PDO::ATTR_ERRMODE === $attribute ) { + return $this->error_mode; + } + return null; + } + + /** + * Register a user-defined SQL function. Not supported remotely. + * + * PHP callbacks cannot run inside a remote database. + * + * @param string $name The SQL function name. + * @param callable $callback The PHP callback implementing the function. + * @return bool Always false. + */ + public function create_function( string $name, callable $callback ): bool { + return false; + } + + /** + * Check whether the connection supports an optional capability. + * + * None of them, for the reasons given in the class description: a BEGIN + * outlives its HTTP request, temporary tables would live on a connection + * shared with other clients, and PHP callbacks cannot run server-side. + * + * @param string $capability One of the CAPABILITY_* interface constants. + * @return bool Always false. + */ + public function has_capability( string $capability ): bool { + return false; + } + + /** + * Set a logger for the queries. + * + * @param callable(string, array): void $logger A query logger callback. + */ + public function set_query_logger( callable $logger ): void { + $this->query_logger = $logger; + } + + /** + * Intercept statements that should not reach Turso. + * + * Unlike the D1 backend, PRAGMA statements are passed through: Turso + * honours them, "foreign_keys" included. Only reads of the temporary + * table master need heading off, and that is defensive -- temporary + * tables are gated by the capability API. + * + * @param string $sql The SQL statement. + * @return PDOStatement|null The intercepted result, or null to proceed. + */ + private function maybe_intercept_query( string $sql ): ?PDOStatement { + $normalized = strtolower( ltrim( $sql ) ); + + if ( 0 === strpos( $normalized, 'pragma' ) ) { + // PRAGMA foreign_keys (read): answer from the tracked state. + if ( 'pragma foreign_keys' === rtrim( $normalized, '; ' ) ) { + return $this->create_statement( + array( + 'columns' => array( 'foreign_keys' ), + 'rows' => array( array( $this->foreign_keys_enabled ? 1 : 0 ) ), + 'meta' => array( + 'changes' => 0, + 'last_row_id' => 0, + ), + ) + ); + } + + // PRAGMA foreign_keys = ON|OFF (write): track it, and make the + // transport carry it into every request from now on. + if ( 1 === preg_match( '/^pragma\s+foreign_keys\s*=\s*(on|off|true|false|1|0)\s*;?\s*$/', $normalized, $matches ) ) { + $this->foreign_keys_enabled = in_array( $matches[1], array( 'on', 'true', '1' ), true ); + /* + * Always state the value, OFF included. Whether a session persists + * on Turso Cloud depends on whether the HTTP connection is reused + * and which node it lands on, so an omitted pragma can leave a + * stale ON in place from an earlier request. + */ + $this->transport->set_session_statements( + array( 'PRAGMA foreign_keys = ' . ( $this->foreign_keys_enabled ? 'ON' : 'OFF' ) ) + ); + return $this->create_statement( + array( + 'columns' => array(), + 'rows' => array(), + 'meta' => array( + 'changes' => 0, + 'last_row_id' => 0, + ), + ) + ); + } + } + + if ( 0 === strpos( $normalized, 'select' ) && false !== strpos( $normalized, 'sqlite_temp_master' ) ) { + return $this->create_statement( + array( + 'columns' => array( '1' ), + 'rows' => array(), + 'meta' => array( + 'changes' => 0, + 'last_row_id' => 0, + ), + ) + ); + } + return null; + } + + /** + * Check whether a statement is a schema information read. + * + * @param string $sql The SQL statement. + * @return bool True for cacheable schema information reads. + */ + private function is_schema_information_read( string $sql ): bool { + if ( 1 !== preg_match( '/^\s*SELECT\b/i', $sql ) ) { + return false; + } + if ( false === stripos( $sql, '_wp_sqlite_' ) && false === stripos( $sql, 'sqlite_master' ) ) { + return false; + } + + /* + * Some information schema reads join "sqlite_sequence" to report the next + * AUTO_INCREMENT value. That depends on row data, not on the schema: a + * plain INSERT moves it without touching anything this cache invalidates + * on. Such a read has to go to the database every time. + */ + return false === stripos( $sql, 'sqlite_sequence' ); + } + + /** + * Check whether a statement can change schema information. + * + * @param string $sql The SQL statement. + * @return bool True when the schema information cache must be + * invalidated after executing the statement. + */ + private function can_change_schema_information( string $sql ): bool { + // Writes to the driver-internal tables change schema information. + if ( false !== stripos( $sql, '_wp_sqlite_' ) ) { + return true; + } + // DDL statements change the real database schema. + return 1 === preg_match( '/^\s*(CREATE|ALTER|DROP|TRUNCATE)\b/i', $sql ); + } + + /** + * Inline query parameters when the statement has too many of them. + * + * @param string $sql The SQL statement with "?" placeholders. + * @param array $params The positional query parameters. + * @return array{0: string, 1: array} The statement and parameters to send. + */ + private function maybe_inline_params( string $sql, array $params ): array { + if ( count( $params ) <= self::PARAMS_INLINE_THRESHOLD ) { + return array( $sql, $params ); + } + + $params = array_values( $params ); + $position = 0; + $length = strlen( $sql ); + $inlined = ''; + + for ( $i = 0; $i < $length; $i++ ) { + $char = $sql[ $i ]; + + // Skip quoted regions ('...', "...", `...`). + if ( "'" === $char || '"' === $char || '`' === $char ) { + $end = $i; + do { + $end = strpos( $sql, $char, $end + 1 ); + if ( false === $end ) { + $end = $length - 1; + break; + } + // A doubled quote character is an escape sequence. + } while ( isset( $sql[ $end + 1 ] ) && $sql[ $end + 1 ] === $char && ++$end ); + + $inlined .= substr( $sql, $i, $end - $i + 1 ); + $i = $end; + continue; + } + + if ( '?' === $char ) { + $inlined .= $this->quote( $params[ $position ] ?? null ); + ++$position; + continue; + } + + $inlined .= $char; + } + + return array( $inlined, array() ); + } + + /** + * Store statement metadata for connection-level APIs. + * + * @param array $meta The statement metadata. + */ + private function remember_meta( array $meta ): void { + if ( ( $meta['last_row_id'] ?? 0 ) > 0 ) { + $this->last_insert_id = (int) $meta['last_row_id']; + } + } + + /** + * Create an in-memory statement from a transport result. + * + * @param array $result The transport result (columns, rows, meta). + * @return WP_PDO_Array_Statement The statement. + */ + private function create_statement( array $result ): WP_PDO_Array_Statement { + return new WP_PDO_Array_Statement( + $result['columns'], + $result['rows'], + (int) ( $result['meta']['changes'] ?? 0 ), + $this->stringify_fetches, + array(), + $this->default_fetch_mode + ); + } +} diff --git a/packages/mysql-on-sqlite/src/turso/class-wp-sqlite-turso-exception.php b/packages/mysql-on-sqlite/src/turso/class-wp-sqlite-turso-exception.php new file mode 100644 index 000000000..59b22d471 --- /dev/null +++ b/packages/mysql-on-sqlite/src/turso/class-wp-sqlite-turso-exception.php @@ -0,0 +1,114 @@ + error: "; Turso Cloud wraps that again + * as "Tursodb error: error: ". The driver matches on + * the bare message to give errors their MySQL identity. + */ + $message = preg_replace( + '/^(?:Tursodb error: )?(?:(?:Parse|Transaction|Runtime|Prepare|Execute) error: )?/', + '', + $message + ); + + if ( 1 === preg_match( '/\bconstraint failed\b/i', $message ) ) { + // SQLite error code 19: SQLITE_CONSTRAINT. + $sqlstate = '23000'; + $driver_code = 19; + $prefix = 'Integrity constraint violation: 19 '; + } elseif ( 1 === preg_match( '/\b(?:database is locked|database table is locked)\b/i', $message ) ) { + // SQLite error codes 5 and 6: SQLITE_BUSY and SQLITE_LOCKED. + $sqlstate = 'HY000'; + $driver_code = 5; + $prefix = 'General error: 5 '; + } else { + // SQLite error code 1: SQLITE_ERROR (also used as a fallback). + $sqlstate = 'HY000'; + $driver_code = 1; + $prefix = 'General error: 1 '; + } + + /* + * PDO splits the two: getMessage() carries the SQLSTATE and the driver's + * decoration, while errorInfo carries the driver's code and its + * *undecorated* message. The driver matches on errorInfo to recognise + * SQLite errors and give them their MySQL identity -- "no such table: x" + * becoming 42S02 / 1146 -- so the raw message has to survive there. + */ + $exception = new self( sprintf( 'SQLSTATE[%s]: %s%s', $sqlstate, $prefix, $message ), 0 ); + $exception->code = $sqlstate; + // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase + $exception->errorInfo = array( $sqlstate, $driver_code, $message ); + + if ( null !== $error_code ) { + $exception->turso_error_code = $error_code; + } + $exception->http_status = $http_status; + + return $exception; + } + + /** + * Create an exception from a transport-level failure. + * + * These are connection problems and malformed responses rather than SQL + * errors, reported with the SQLSTATE PDO uses for general errors so the + * driver does not mistake them for constraint violations. + * + * @param string $message The failure description. + * @return self + */ + public static function from_transport_failure( string $message ): self { + $exception = new self( sprintf( 'SQLSTATE[HY000]: General error: 1 %s', $message ), 0 ); + $exception->code = 'HY000'; + // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase + $exception->errorInfo = array( 'HY000', 1, $message ); + return $exception; + } + + /** + * The Turso error code, when the failure came from the server. + * + * @var string|null + */ + public $turso_error_code; + + /** + * The HTTP status of the response, when there was one. + * + * @var int|null + */ + public $http_status; +} diff --git a/packages/mysql-on-sqlite/src/turso/class-wp-sqlite-turso-http-transport.php b/packages/mysql-on-sqlite/src/turso/class-wp-sqlite-turso-http-transport.php new file mode 100644 index 000000000..0034f687d --- /dev/null +++ b/packages/mysql-on-sqlite/src/turso/class-wp-sqlite-turso-http-transport.php @@ -0,0 +1,185 @@ +url = self::normalize_url( $url ); + $this->token = $token; + $this->timeout_ms = (int) ( $options['timeout_ms'] ?? self::DEFAULT_TIMEOUT_MS ); + $this->connect_timeout_ms = (int) ( $options['connect_timeout_ms'] ?? self::DEFAULT_CONNECT_TIMEOUT_MS ); + if ( isset( $options['pipeline_path'] ) ) { + $this->pipeline_path = (string) $options['pipeline_path']; + } + } + + /** + * Normalize a Turso database URL to one cURL can use. + * + * Turso publishes database URLs with "libsql://" and "turso://" schemes, + * which are HTTPS endpoints under another name. + * + * @param string $url The configured URL. + * @return string The HTTP(S) URL, without a trailing slash. + */ + public static function normalize_url( string $url ): string { + $url = trim( $url ); + if ( 1 === preg_match( '#^(?:libsql|turso|wss)://#i', $url ) ) { + $url = preg_replace( '#^[a-z]+://#i', 'https://', $url ); + } elseif ( 1 === preg_match( '#^ws://#i', $url ) ) { + $url = preg_replace( '#^ws://#i', 'http://', $url ); + } elseif ( 1 !== preg_match( '#^https?://#i', $url ) ) { + $url = 'https://' . $url; + } + return rtrim( $url, '/' ); + } + + /** + * Send an HTTP POST request using the reusable cURL handle. + * + * Requests that fail before a connection is established are retried, as no + * statement can have been executed yet. Requests failing after that point + * are not retried -- a write may already have been applied. + * + * @param string $path The endpoint path. + * @param string $json The JSON request body. + * @return array{0: int, 1: string} The HTTP status and response body. + * @throws WP_SQLite_Turso_Exception When the request fails. + */ + protected function send( string $path, string $json ): array { + $headers = array( + 'Content-Type: application/json', + 'Accept: application/json', + ); + if ( null !== $this->token ) { + $headers[] = 'Authorization: Bearer ' . $this->token; + } + + $attempts = 0; + $errno = 0; + $message = ''; + do { + ++$attempts; + + $curl = $this->get_curl_handle(); + curl_setopt( $curl, CURLOPT_URL, $this->url . $path ); + curl_setopt( $curl, CURLOPT_POST, true ); + curl_setopt( $curl, CURLOPT_POSTFIELDS, $json ); + curl_setopt( $curl, CURLOPT_HTTPHEADER, $headers ); + + $response_body = curl_exec( $curl ); + if ( false !== $response_body ) { + return array( (int) curl_getinfo( $curl, CURLINFO_RESPONSE_CODE ), (string) $response_body ); + } + + $errno = curl_errno( $curl ); + $message = curl_error( $curl ); + + // Reset the handle after a failure to get a clean connection. + $this->curl = null; + + $is_retryable = in_array( + $errno, + array( CURLE_COULDNT_CONNECT, CURLE_COULDNT_RESOLVE_HOST, CURLE_COULDNT_RESOLVE_PROXY ), + true + ); + } while ( $is_retryable && $attempts < 3 ); + + throw WP_SQLite_Turso_Exception::from_transport_failure( + sprintf( 'The request to Turso failed: %s (cURL error %d).', $message, $errno ) + ); + } + + /** + * Get the reusable cURL handle, creating it when needed. + * + * @return resource|CurlHandle The cURL handle. + */ + private function get_curl_handle() { + if ( null === $this->curl ) { + $this->curl = curl_init(); + curl_setopt( $this->curl, CURLOPT_RETURNTRANSFER, true ); + curl_setopt( $this->curl, CURLOPT_TIMEOUT_MS, $this->timeout_ms ); + curl_setopt( $this->curl, CURLOPT_CONNECTTIMEOUT_MS, $this->connect_timeout_ms ); + curl_setopt( $this->curl, CURLOPT_TCP_KEEPALIVE, 1 ); + curl_setopt( $this->curl, CURLOPT_TCP_NODELAY, 1 ); + } + return $this->curl; + } +} diff --git a/packages/mysql-on-sqlite/src/turso/class-wp-sqlite-turso-replica-connection.php b/packages/mysql-on-sqlite/src/turso/class-wp-sqlite-turso-replica-connection.php new file mode 100644 index 000000000..d631e152e --- /dev/null +++ b/packages/mysql-on-sqlite/src/turso/class-wp-sqlite-turso-replica-connection.php @@ -0,0 +1,374 @@ + 0, + 'primary' => 0, + 'latched_at' => null, + ); + + /** + * Constructor. + * + * @param WP_SQLite_Connection_Interface $reader A connection over the + * published snapshot, which + * may be opened read-only. + * @param WP_SQLite_Turso_Connection $primary The primary connection. + */ + public function __construct( WP_SQLite_Connection_Interface $reader, WP_SQLite_Turso_Connection $primary ) { + $this->reader = $reader; + $this->primary = $primary; + } + + /** + * Whether the connection has latched to the primary for this request. + * + * @return bool + */ + public function is_latched(): bool { + return $this->latched; + } + + /** + * Get the per-route statement counters. + * + * @return array{snapshot: int, primary: int, latched_at: int|null} + */ + public function get_counters(): array { + return $this->counters; + } + + /** + * Get the connection a statement would be routed to. + * + * @param string $sql The SQL statement. + * @return WP_SQLite_Connection_Interface The connection. + */ + private function route( string $sql ): WP_SQLite_Connection_Interface { + if ( $this->latched ) { + ++$this->counters['primary']; + return $this->primary; + } + + if ( self::is_read_only( $sql ) ) { + ++$this->counters['snapshot']; + return $this->reader; + } + + return $this->latch(); + } + + /** + * Latch to the primary for the rest of the request. + * + * @return WP_SQLite_Turso_Connection The primary. + */ + private function latch(): WP_SQLite_Turso_Connection { + if ( ! $this->latched ) { + $this->latched = true; + $this->counters['latched_at'] = $this->counters['snapshot'] + $this->counters['primary']; + } + ++$this->counters['primary']; + return $this->primary; + } + + /** + * Whether a statement can be served from the read-only snapshot. + * + * @param string $sql The SQL statement. + * @return bool True when the statement only reads. + */ + public static function is_read_only( string $sql ): bool { + if ( 1 === preg_match( self::READ_ONLY_PATTERN, $sql ) ) { + return true; + } + return 1 === preg_match( self::READ_ONLY_CTE_PATTERN, $sql ); + } + + /** + * Execute a query, routing it to the snapshot or the primary. + * + * @param string $sql The query to execute. + * @param array $params The query parameters. + * @throws PDOException When the query execution fails. + * @return PDOStatement The PDO statement object. + */ + public function query( string $sql, array $params = array() ): PDOStatement { + if ( $this->query_logger ) { + ( $this->query_logger )( $sql, $params ); + } + return $this->route( $sql )->query( $sql, $params ); + } + + /** + * Execute a batch of queries atomically on the primary. + * + * A batch is a write by definition, so it latches. + * + * @param array $statements The queries. + * @throws PDOException When the execution of any query fails. + * @return PDOStatement[] The PDO statement objects, one for each query. + */ + public function execute_batch( array $statements ): array { + return $this->latch()->execute_batch( $statements ); + } + + /** + * Begin a transaction on the primary. + * + * Latches even though the primary reports transactions as unsupported: a + * transaction must not span the snapshot and the primary, and whatever + * follows it is the driver's transactional work. + * + * @param string $behavior The SQLite transaction behavior. + */ + public function begin_transaction( string $behavior = 'DEFERRED' ): void { + $this->latch()->begin_transaction( $behavior ); + } + + /** + * Commit the current transaction on the primary. + */ + public function commit(): void { + $this->latch()->commit(); + } + + /** + * Roll back the current transaction on the primary. + */ + public function rollback(): void { + $this->latch()->rollback(); + } + + /** + * Create a savepoint on the primary. + * + * @param string $name The savepoint name, unquoted. + */ + public function savepoint( string $name ): void { + $this->latch()->savepoint( $name ); + } + + /** + * Release a savepoint on the primary. + * + * @param string $name The savepoint name, unquoted. + */ + public function release_savepoint( string $name ): void { + $this->latch()->release_savepoint( $name ); + } + + /** + * Roll back to a savepoint on the primary. + * + * @param string $name The savepoint name, unquoted. + */ + public function rollback_to_savepoint( string $name ): void { + $this->latch()->rollback_to_savepoint( $name ); + } + + /** + * Check if a transaction is currently active. + * + * @return bool True when a transaction is active. + */ + public function in_transaction(): bool { + return $this->latched ? $this->primary->in_transaction() : false; + } + + /** + * Quote a value for use in a query. + * + * @param mixed $value The value to quote. + * @param int $type The type of the value. + * @return string The quoted value. + */ + public function quote( $value, int $type = PDO::PARAM_STR ): string { + return $this->primary->quote( $value, $type ); + } + + /** + * Quote an SQLite identifier. + * + * @param string $unquoted_identifier The unquoted identifier value. + * @return string The quoted identifier value. + */ + public function quote_identifier( string $unquoted_identifier ): string { + return $this->primary->quote_identifier( $unquoted_identifier ); + } + + /** + * Returns the ID of the last inserted row. + * + * Inserts only ever happen on the primary. + * + * @return string The ID of the last inserted row. + */ + public function get_last_insert_id(): string { + return $this->primary->get_last_insert_id(); + } + + /** + * Get the SQLite version. + * + * Reported from the snapshot: it is the engine that actually executes + * reads, it answers without a network round trip, and the driver uses this + * to decide which SQLite features it may emit. + * + * @return string The SQLite engine version. + */ + public function get_server_version(): string { + return $this->reader->get_server_version(); + } + + /** + * Set a connection attribute on both sides. + * + * @param int $attribute The attribute to set. + * @param mixed $value The value of the attribute. + * @return bool True when either side accepted the attribute. + */ + public function set_attribute( int $attribute, $value ): bool { + $reader = $this->reader->set_attribute( $attribute, $value ); + $primary = $this->primary->set_attribute( $attribute, $value ); + return $reader || $primary; + } + + /** + * Get a connection attribute. + * + * @param int $attribute The attribute to get. + * @return mixed The value of the attribute, or null. + */ + public function get_attribute( int $attribute ) { + $value = $this->reader->get_attribute( $attribute ); + return null !== $value ? $value : $this->primary->get_attribute( $attribute ); + } + + /** + * Register a user-defined SQL function. + * + * Registered on the snapshot only, where PHP callbacks can run. It returns + * false regardless, because has_capability() reports no user-defined + * function support: a function the primary cannot evaluate must not appear + * in SQL that might be routed there. + * + * @param string $name The SQL function name. + * @param callable $callback The PHP callback implementing the function. + * @return bool Always false. + */ + public function create_function( string $name, callable $callback ): bool { + return false; + } + + /** + * Check whether the connection supports an optional capability. + * + * The primary's answer governs. The snapshot could offer transactions, + * savepoints, temporary tables and user-defined functions, but a statement + * may be routed to either side, so the driver has to emit SQL that works on + * both. + * + * @param string $capability One of the CAPABILITY_* interface constants. + * @return bool Whether the capability is supported. + */ + public function has_capability( string $capability ): bool { + return $this->primary->has_capability( $capability ); + } + + /** + * Set a logger for the queries. + * + * @param callable(string, array): void $logger A query logger callback. + */ + public function set_query_logger( callable $logger ): void { + $this->query_logger = $logger; + } +} diff --git a/packages/mysql-on-sqlite/src/turso/class-wp-sqlite-turso-response.php b/packages/mysql-on-sqlite/src/turso/class-wp-sqlite-turso-response.php new file mode 100644 index 000000000..4289e1eb5 --- /dev/null +++ b/packages/mysql-on-sqlite/src/turso/class-wp-sqlite-turso-response.php @@ -0,0 +1,177 @@ + 'null' ); + } + if ( is_bool( $value ) ) { + // SQLite has no boolean storage class; PDO binds these as integers. + return array( + 'type' => 'integer', + 'value' => $value ? '1' : '0', + ); + } + if ( is_int( $value ) ) { + return array( + 'type' => 'integer', + 'value' => (string) $value, + ); + } + if ( is_float( $value ) ) { + return array( + 'type' => 'float', + 'value' => $value, + ); + } + if ( is_string( $value ) && ! preg_match( '//u', $value ) ) { + // Not valid UTF-8. Send binary strings as BLOB values. + return array( + 'type' => 'blob', + 'base64' => base64_encode( $value ), + ); + } + return array( + 'type' => 'text', + 'value' => (string) $value, + ); + } + + /** + * Decode a statement result from a pipeline response. + * + * @param mixed $result The decoded JSON "result" object. + * @return array{columns: string[], rows: array[], meta: array} The result. + * @throws WP_SQLite_Turso_Exception When the result shape is invalid. + */ + public static function decode_result( $result ): array { + if ( + ! is_array( $result ) + || ! isset( $result['cols'], $result['rows'] ) + || ! is_array( $result['cols'] ) + || ! is_array( $result['rows'] ) + ) { + throw WP_SQLite_Turso_Exception::from_transport_failure( + 'Turso returned a statement result with an invalid shape.' + ); + } + + $columns = array(); + foreach ( $result['cols'] as $column ) { + $columns[] = is_array( $column ) ? (string) ( $column['name'] ?? '' ) : (string) $column; + } + + $rows = array(); + foreach ( $result['rows'] as $row ) { + $values = array(); + foreach ( (array) $row as $value ) { + $values[] = self::decode_value( $value ); + } + $rows[] = $values; + } + + /* + * Turso reports affected_row_count as 0 and last_insert_rowid as null + * for every statement, so the protocol trait asks for changes() and + * last_insert_rowid() alongside each write and fills these in. See + * WP_SQLite_Turso_Protocol::query(). + */ + return array( + 'columns' => $columns, + 'rows' => $rows, + 'meta' => array( + 'changes' => (int) ( $result['affected_row_count'] ?? 0 ), + 'last_row_id' => (int) ( $result['last_insert_rowid'] ?? 0 ), + ), + ); + } + + /** + * Decode a single protocol value to a PHP value. + * + * @param mixed $value The decoded JSON value. + * @return mixed The PHP value. + */ + public static function decode_value( $value ) { + if ( ! is_array( $value ) || ! isset( $value['type'] ) ) { + // Already a bare scalar; nothing to unwrap. + return $value; + } + + switch ( $value['type'] ) { + case 'null': + return null; + case 'integer': + $raw = (string) ( $value['value'] ?? '0' ); + // Keep values outside PHP's integer range as strings, as PDO does. + $int = (int) $raw; + return (string) $int === $raw ? $int : $raw; + case 'float': + return (float) ( $value['value'] ?? 0.0 ); + case 'blob': + // Turso may omit base64 padding; base64_decode() tolerates that. + return (string) base64_decode( (string) ( $value['base64'] ?? '' ) ); + case 'text': + default: + return isset( $value['value'] ) ? (string) $value['value'] : null; + } + } + + /** + * Build an exception from a Turso error object. + * + * @param mixed $error The decoded JSON "error" object. + * @param int|null $http_status The HTTP status of the response, if any. + * @return WP_SQLite_Turso_Exception The exception representing the error. + */ + public static function decode_error( $error, ?int $http_status = null ): WP_SQLite_Turso_Exception { + $code = null; + $message = null; + if ( is_array( $error ) ) { + $code = isset( $error['code'] ) && is_string( $error['code'] ) ? $error['code'] : null; + $message = isset( $error['message'] ) && is_string( $error['message'] ) ? $error['message'] : null; + } + if ( null === $message ) { + $message = null !== $http_status + ? sprintf( 'Turso returned an error with HTTP status %d.', $http_status ) + : 'Turso returned an error without a message.'; + } + return WP_SQLite_Turso_Exception::from_server_error( $code, $message, $http_status ); + } +} diff --git a/packages/mysql-on-sqlite/src/turso/db.copy b/packages/mysql-on-sqlite/src/turso/db.copy new file mode 100644 index 000000000..0ed14d269 --- /dev/null +++ b/packages/mysql-on-sqlite/src/turso/db.copy @@ -0,0 +1,181 @@ +dbh ) { + return; + } + $this->init_charset(); + + if ( null === $this->dbname || '' === $this->dbname ) { + $this->bail( + 'The database name was not set. The SQLite driver requires a database name to be set to emulate MySQL information schema tables.', + 'db_connect_fail' + ); + return false; + } + + try { + $snapshot = wp_sqlite_turso_config( 'WP_TURSO_SNAPSHOT' ); + if ( $snapshot && ! is_readable( (string) $snapshot ) ) { + /* + * A configured snapshot that is not there yet means the + * publisher has not run. Serving from the primary is slower but + * correct, which is the right way to fail. + */ + $snapshot = null; + } + + $connection = wp_sqlite_turso_create_connection( + (string) wp_sqlite_turso_config( 'WP_TURSO_URL' ), + wp_sqlite_turso_config( 'WP_TURSO_TOKEN' ), + null !== $snapshot ? (string) $snapshot : null, + array( + 'timeout_ms' => (int) wp_sqlite_turso_config( 'WP_TURSO_HTTP_TIMEOUT_MS', 30000 ), + 'pipeline_path' => (string) wp_sqlite_turso_config( 'WP_TURSO_PIPELINE_PATH', '/v2/pipeline' ), + 'schema_cache' => filter_var( wp_sqlite_turso_config( 'WP_TURSO_SCHEMA_CACHE', true ), FILTER_VALIDATE_BOOLEAN ), + 'snapshot_journal_mode' => (string) wp_sqlite_turso_config( 'WP_TURSO_SNAPSHOT_JOURNAL', 'DELETE' ), + ) + ); + + $this->dbh = new WP_MySQL_On_SQLite( + sprintf( 'mysql-on-sqlite:dbname=%s', str_replace( ';', ';;', $this->dbname ) ), + null, + null, + array( + 'sqlite_connection' => $connection, + 'transaction_fallback' => (string) wp_sqlite_turso_config( 'WP_TURSO_TRANSACTION_FALLBACK', 'warn' ), + ) + ); + $this->dbh->setAttribute( PDO::ATTR_STRINGIFY_FETCHES, true ); + } catch ( Throwable $e ) { + $this->last_error = 'Failed to connect to the Turso database: ' . $e->getMessage(); + } + if ( $this->last_error ) { + return false; + } + $this->ready = true; + $this->set_sql_mode(); + } +} + +$GLOBALS['wpdb'] = new WP_Turso_DB( defined( 'DB_NAME' ) ? DB_NAME : '' ); diff --git a/packages/mysql-on-sqlite/src/turso/interface-wp-sqlite-turso-transport.php b/packages/mysql-on-sqlite/src/turso/interface-wp-sqlite-turso-transport.php new file mode 100644 index 000000000..b8275fbec --- /dev/null +++ b/packages/mysql-on-sqlite/src/turso/interface-wp-sqlite-turso-transport.php @@ -0,0 +1,58 @@ + string[], // Column names, in order. Duplicates allowed. + * 'rows' => array[], // Rows as lists of positional values. + * 'meta' => array( // Statement metadata. + * 'changes' => int, // The number of rows changed by a write. + * 'last_row_id' => int, // The last inserted row ID, or 0. + * ), + * ) + * + * BLOB values are decoded to PHP binary strings. + */ +interface WP_SQLite_Turso_Transport_Interface { + /** + * Execute a single SQL statement. + * + * @param string $sql The SQL statement to execute. + * @param array $params Positional query parameters. + * @return array The result. See the interface description. + * @throws WP_SQLite_Turso_Exception When the execution or transport fails. + */ + public function query( string $sql, array $params = array() ): array; + + /** + * Execute a batch of SQL statements atomically. + * + * When any statement fails, none of the statements take effect. + * + * @param array $statements + * The statements to execute, each being an array of + * an SQL string and optional positional parameters. + * @return array[] One result per statement. See the interface description. + * @throws WP_SQLite_Turso_Exception When the execution or transport fails. + */ + public function batch( array $statements ): array; + + /** + * Set statements to run at the start of every request. + * + * Turso Cloud gives each HTTP request its own session, so connection-scoped + * state such as "PRAGMA foreign_keys" does not survive from one request to + * the next. Statements set here are sent ahead of every query and batch, in + * the same round trip, which makes the state hold as if the session did. + * + * @param string[] $statements SQL statements, run in order. + */ + public function set_session_statements( array $statements ): void; +} diff --git a/packages/mysql-on-sqlite/src/turso/load.php b/packages/mysql-on-sqlite/src/turso/load.php new file mode 100644 index 000000000..0e4ebd416 --- /dev/null +++ b/packages/mysql-on-sqlite/src/turso/load.php @@ -0,0 +1,94 @@ + $snapshot_path, + // Match what the publisher wrote. WP_SQLite_Connection *sets* this + // pragma, and setting WAL on a read-only rollback-journal file is a + // write it cannot make. + 'journal_mode' => $options['snapshot_journal_mode'] ?? 'DELETE', + ) + ); + + return new WP_SQLite_Turso_Replica_Connection( $reader, $primary ); +} diff --git a/packages/mysql-on-sqlite/src/turso/trait-wp-sqlite-turso-protocol.php b/packages/mysql-on-sqlite/src/turso/trait-wp-sqlite-turso-protocol.php new file mode 100644 index 000000000..328ff11ba --- /dev/null +++ b/packages/mysql-on-sqlite/src/turso/trait-wp-sqlite-turso-protocol.php @@ -0,0 +1,307 @@ +session_statements = array_values( $statements ); + } + + /** + * Send an HTTP POST request to the Turso server. + * + * @param string $path The endpoint path. + * @param string $json The JSON request body. + * @return array{0: int, 1: string} The HTTP status and response body. + * @throws WP_SQLite_Turso_Exception When the request fails. + */ + abstract protected function send( string $path, string $json ): array; + + /** + * Execute a single SQL statement. See the transport interface. + * + * @param string $sql The SQL statement to execute. + * @param array $params Positional query parameters. + * @return array The result. + * @throws WP_SQLite_Turso_Exception When the execution or transport fails. + */ + public function query( string $sql, array $params = array() ): array { + $requests = array( $this->execute_request( $sql, $params ) ); + + // Writes need metadata Turso does not report; ask for it in the same trip. + $wants_meta = $this->statement_changes_data( $sql ); + if ( $wants_meta ) { + $requests[] = $this->execute_request( 'SELECT changes(), last_insert_rowid()' ); + } + + $results = $this->pipeline( $requests ); + $result = WP_SQLite_Turso_Response::decode_result( $results[0] ); + + if ( $wants_meta && isset( $results[1] ) ) { + $meta = WP_SQLite_Turso_Response::decode_result( $results[1] ); + if ( isset( $meta['rows'][0][0], $meta['rows'][0][1] ) ) { + $result['meta']['changes'] = (int) $meta['rows'][0][0]; + $result['meta']['last_row_id'] = (int) $meta['rows'][0][1]; + } + } + return $result; + } + + /** + * Execute a batch of SQL statements atomically. See the transport interface. + * + * @param array $statements The statements to execute. + * @return array[] One result per statement. + * @throws WP_SQLite_Turso_Exception When the execution of any statement fails. + */ + public function batch( array $statements ): array { + if ( array() === $statements ) { + return array(); + } + + /* + * Session statements first, unconditionally -- a pragma cannot change + * inside a transaction -- then BEGIN, the statements chained on success, + * then COMMIT or ROLLBACK. Every step index below is offset by the + * session prefix. + */ + $steps = array(); + foreach ( $this->session_statements as $sql ) { + $steps[] = array( 'stmt' => array( 'sql' => $sql ) ); + } + $offset = count( $steps ); + $steps[] = array( 'stmt' => array( 'sql' => 'BEGIN' ) ); + foreach ( array_values( $statements ) as $index => $statement ) { + $steps[] = array( + 'stmt' => $this->stmt( $statement[0], $statement[1] ?? array() ), + // Each step runs only if the one before it did. + 'condition' => array( + 'type' => 'ok', + 'step' => $offset + $index, + ), + ); + } + + $last_index = count( $steps ) - 1; + $commit_index = $last_index + 1; + $steps[] = array( + 'stmt' => array( 'sql' => 'COMMIT' ), + 'condition' => array( + 'type' => 'ok', + 'step' => $last_index, + ), + ); + $steps[] = array( + 'stmt' => array( 'sql' => 'ROLLBACK' ), + 'condition' => array( + 'type' => 'not', + 'cond' => array( + 'type' => 'ok', + 'step' => $commit_index, + ), + ), + ); + + $body = $this->send_pipeline( + array( + array( + 'type' => 'batch', + 'batch' => array( 'steps' => $steps ), + ), + ) + ); + + $batch = $this->unwrap_result( $body, 0, 'batch' ); + $step_results = is_array( $batch['step_results'] ?? null ) ? $batch['step_results'] : array(); + $step_errors = is_array( $batch['step_errors'] ?? null ) ? $batch['step_errors'] : array(); + + // Report the first failing step. The rollback already happened server-side. + foreach ( $step_errors as $error ) { + if ( null !== $error ) { + throw WP_SQLite_Turso_Response::decode_error( $error ); + } + } + + $results = array(); + $first = $offset + 1; // past the session prefix and the BEGIN + for ( $i = $first, $last = $offset + count( $statements ); $i <= $last; $i++ ) { + $step = $step_results[ $i ] ?? null; + if ( ! is_array( $step ) ) { + throw WP_SQLite_Turso_Exception::from_transport_failure( + sprintf( 'Turso returned no result for batch statement %d.', $i ) + ); + } + $results[] = WP_SQLite_Turso_Response::decode_result( $step ); + } + return $results; + } + + /** + * Execute a list of pipeline requests and return their raw results. + * + * @param array $requests The pipeline requests. + * @return array[] The raw "result" objects, in order. + * @throws WP_SQLite_Turso_Exception When the request or any statement fails. + */ + private function pipeline( array $requests ): array { + $prefix = array(); + foreach ( $this->session_statements as $sql ) { + $prefix[] = $this->execute_request( $sql ); + } + + $body = $this->send_pipeline( array_merge( $prefix, $requests ) ); + $results = array(); + foreach ( array_keys( $requests ) as $index ) { + $results[] = $this->unwrap_result( $body, count( $prefix ) + $index, 'execute' ); + } + return $results; + } + + /** + * Build an "execute" pipeline request. + * + * @param string $sql The SQL statement. + * @param array $params Positional query parameters. + * @return array The request. + */ + private function execute_request( string $sql, array $params = array() ): array { + return array( + 'type' => 'execute', + 'stmt' => $this->stmt( $sql, $params ), + ); + } + + /** + * Build a protocol "stmt" object. + * + * @param string $sql The SQL statement. + * @param array $params Positional query parameters. + * @return array The statement object. + */ + private function stmt( string $sql, array $params = array() ): array { + $stmt = array( 'sql' => $sql ); + if ( array() !== $params ) { + $stmt['args'] = WP_SQLite_Turso_Response::encode_params( $params ); + } + return $stmt; + } + + /** + * Whether a statement needs changes() and last_insert_rowid() fetched. + * + * @param string $sql The SQL statement. + * @return bool True for statements that may report write metadata. + */ + private function statement_changes_data( string $sql ): bool { + return 1 === preg_match( + '/^\s*(?:\/\*.*?\*\/\s*)*(?:INSERT|REPLACE|UPDATE|DELETE|UPSERT)\b/is', + $sql + ); + } + + /** + * Pull one request's "result" object out of a pipeline response. + * + * @param array $body The decoded response body. + * @param int $index The index of the request. + * @param string $expected The expected response type, "execute" or "batch". + * @return array The raw result object. + * @throws WP_SQLite_Turso_Exception When the request failed or the shape is wrong. + */ + private function unwrap_result( array $body, int $index, string $expected ): array { + $entry = $body['results'][ $index ] ?? null; + if ( ! is_array( $entry ) || ! isset( $entry['type'] ) ) { + throw WP_SQLite_Turso_Exception::from_transport_failure( + sprintf( 'Turso returned no result for pipeline request %d.', $index ) + ); + } + if ( 'error' === $entry['type'] ) { + throw WP_SQLite_Turso_Response::decode_error( $entry['error'] ?? null ); + } + + $response = is_array( $entry['response'] ?? null ) ? $entry['response'] : array(); + if ( ( $response['type'] ?? null ) !== $expected || ! is_array( $response['result'] ?? null ) ) { + throw WP_SQLite_Turso_Exception::from_transport_failure( + sprintf( 'Turso returned an unexpected response for a "%s" request.', $expected ) + ); + } + return $response['result']; + } + + /** + * POST a pipeline body and return the decoded response. + * + * @param array $requests The pipeline requests. + * @return array The decoded response body. + * @throws WP_SQLite_Turso_Exception When the request fails. + */ + private function send_pipeline( array $requests ): array { + $json = json_encode( array( 'requests' => $requests ) ); + if ( false === $json ) { + throw WP_SQLite_Turso_Exception::from_transport_failure( + 'Failed to encode the request payload as JSON: ' . json_last_error_msg() + ); + } + + list( $status, $response_body ) = $this->send( $this->pipeline_path, $json ); + + $body = json_decode( $response_body, true ); + if ( ! is_array( $body ) ) { + throw WP_SQLite_Turso_Exception::from_transport_failure( + sprintf( 'Turso returned a non-JSON response with HTTP status %d.', $status ) + ); + } + if ( 200 !== $status ) { + throw WP_SQLite_Turso_Response::decode_error( $body['error'] ?? $body, $status ); + } + if ( ! is_array( $body['results'] ?? null ) ) { + throw WP_SQLite_Turso_Exception::from_transport_failure( + 'Turso returned a pipeline response without results.' + ); + } + return $body; + } +} diff --git a/packages/mysql-on-sqlite/src/turso/verify-against-server.php b/packages/mysql-on-sqlite/src/turso/verify-against-server.php new file mode 100644 index 000000000..139f95a0a --- /dev/null +++ b/packages/mysql-on-sqlite/src/turso/verify-against-server.php @@ -0,0 +1,95 @@ +get_server_version() ); + +section( 'capabilities (the primary governs)' ); +foreach ( array( 'transactions', 'savepoints', 'temporary_tables', 'user_defined_functions' ) as $capability ) { + printf( " %-24s %s\n", $capability, $connection->has_capability( $capability ) ? 'yes' : 'no' ); +} + +section( 'reads before any write come from the snapshot' ); +$statement = $connection->query( 'SELECT option_value FROM wp_options WHERE option_name = ?', array( 'blogname' ) ); +printf( " blogname = %s\n", var_export( $statement->fetchColumn(), true ) ); +printf( " latched = %s\n", $connection->is_latched() ? 'yes' : 'no' ); +print_r( $connection->get_counters() ); + +section( 'a write latches to the primary' ); +$marker = 'latch-test-' . getmypid(); +$statement = $connection->query( + 'INSERT INTO wp_options (option_name, option_value, autoload) VALUES (?, ?, ?)', + array( $marker, 'written', 'no' ) +); +printf( " rowCount = %d\n", $statement->rowCount() ); +printf( " last insert id = %s\n", $connection->get_last_insert_id() ); +printf( " latched = %s\n", $connection->is_latched() ? 'yes' : 'no' ); + +section( 'read-your-writes: the snapshot does NOT have this row, the primary does' ); +$statement = $connection->query( 'SELECT option_value FROM wp_options WHERE option_name = ?', array( $marker ) ); +printf( " through the connection (latched) = %s\n", var_export( $statement->fetchColumn(), true ) ); + +$direct = new PDO( 'sqlite:' . $snapshot ); +$direct->setAttribute( PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION ); +$raw = $direct->prepare( 'SELECT option_value FROM wp_options WHERE option_name = ?' ); +$raw->execute( array( $marker ) ); +printf( " directly in the snapshot file = %s (expected false)\n", var_export( $raw->fetchColumn(), true ) ); + +print_r( $connection->get_counters() ); + +section( 'atomic batch: a failure must leave nothing behind' ); +try { + $connection->execute_batch( + array( + array( 'INSERT INTO wp_options (option_name, option_value, autoload) VALUES (?, ?, ?)', array( $marker . '-a', 'x', 'no' ) ), + array( 'INSERT INTO nonexistent_table (x) VALUES (1)', array() ), + ) + ); + echo " batch unexpectedly succeeded\n"; +} catch ( PDOException $e ) { + printf( " threw: %s\n", $e->getMessage() ); + printf( " SQLSTATE: %s\n", $e->getCode() ); +} +$statement = $connection->query( 'SELECT count(*) FROM wp_options WHERE option_name = ?', array( $marker . '-a' ) ); +printf( " rows left behind = %s (expected 0)\n", $statement->fetchColumn() ); + +section( 'cleanup' ); +$connection->query( 'DELETE FROM wp_options WHERE option_name LIKE ?', array( $marker . '%' ) ); +printf( " deleted, changes reported = %d\n", $connection->query( 'SELECT changes()' )->fetchColumn() ); diff --git a/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_PDO_API_Tests.php b/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_PDO_API_Tests.php index 298f6c5d7..b1978490a 100644 --- a/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_PDO_API_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_PDO_API_Tests.php @@ -562,6 +562,8 @@ public function test_statement_bind_column(): void { * @dataProvider data_pdo_fetch_methods */ public function test_query_with_fetch_mode( $query, $mode, $expected ): void { + $this->skip_fetch_named_without_a_native_statement( $mode ); + $stmt = $this->driver->query( $query, $mode ); $result = $stmt->fetch(); @@ -1063,10 +1065,32 @@ public function test_fetch_default(): void { } } + /** + * Skip PDO::FETCH_NAMED where a pure-PHP result set cannot reproduce it. + * + * PDO returns every FETCH_NAMED key as a string, including numeric column + * names, which it manages by building the array below the language. PHP + * itself converts a numeric-string key to an integer, so a backend whose + * statements are plain arrays -- every remote backend -- cannot produce the + * same keys. Only this one fetch mode is affected. + * + * @param int $mode The fetch mode under test. + */ + private function skip_fetch_named_without_a_native_statement( int $mode ): void { + if ( PDO::FETCH_NAMED === $mode && 'pdo' !== wp_sqlite_tests_backend() ) { + $this->markTestSkipped( + 'PDO::FETCH_NAMED returns numeric column names as string array keys,' + . ' which a pure-PHP array cannot represent.' + ); + } + } + /** * @dataProvider data_pdo_fetch_methods */ public function test_fetch( $query, $mode, $expected ): void { + $this->skip_fetch_named_without_a_native_statement( $mode ); + $stmt = $this->driver->query( $query ); $result = $stmt->fetch( $mode ); diff --git a/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Query_Tests.php b/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Query_Tests.php index 9217a3879..5df8f65d7 100644 --- a/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Query_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Query_Tests.php @@ -127,7 +127,7 @@ public function setUp(): void { var_export( $query, true ) ); $message .= sprintf( 'Error message is: %s', $e->getMessage() ); - wp_die( $message, 'Database Error!' ); + throw new RuntimeException( $message, 0, $e ); } /* Mock up some metadata rows. When meta_key starts with _, the custom field isn't visible to the editor. */ diff --git a/packages/mysql-on-sqlite/tests/WP_PDO_Array_Statement_Tests.php b/packages/mysql-on-sqlite/tests/WP_PDO_Array_Statement_Tests.php index 889024c47..67816e49d 100644 --- a/packages/mysql-on-sqlite/tests/WP_PDO_Array_Statement_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_PDO_Array_Statement_Tests.php @@ -365,8 +365,16 @@ public function test_execute_rewinds_cursor(): void { $this->assertTrue( $stmt->execute() ); $this->assertSame( count( self::ROWS ), count( $stmt->fetchAll( PDO::FETCH_NUM ) ) ); + /* + * closeCursor() discards the rest of the result set rather than + * rewinding it, as PDO does: fetching afterwards yields nothing until + * the statement is executed again. + */ $stmt->fetch( PDO::FETCH_NUM ); $this->assertTrue( $stmt->closeCursor() ); + $this->assertSame( array(), $stmt->fetchAll( PDO::FETCH_NUM ) ); + + $this->assertTrue( $stmt->execute() ); $this->assertSame( count( self::ROWS ), count( $stmt->fetchAll( PDO::FETCH_NUM ) ) ); } @@ -387,7 +395,46 @@ public function test_stringification_preserves_value_types_in_meta(): void { public function test_unsupported_fetch_modes_throw(): void { $stmt = $this->create_array_statement(); $this->expectException( RuntimeException::class ); - $stmt->fetch( PDO::FETCH_BOUND ); + $stmt->fetch( PDO::FETCH_LAZY ); + } + + public function test_fetch_bound_writes_into_bound_variables(): void { + $stmt = $this->create_array_statement(); + + $id = null; + $name = null; + $this->assertTrue( $stmt->bindColumn( 1, $id ) ); + $this->assertTrue( $stmt->bindColumn( 'name', $name ) ); + + // PDO::FETCH_BOUND reports success rather than returning the row. + $this->assertTrue( $stmt->fetch( PDO::FETCH_BOUND ) ); + $this->assertSame( self::ROWS[0][0], $id ); + $this->assertSame( self::ROWS[0][1], $name ); + } + + public function test_bind_column_rejects_an_unknown_column(): void { + $stmt = $this->create_array_statement(); + $var = null; + + $this->expectException( PDOException::class ); + $stmt->bindColumn( 'no_such_column', $var ); + } + + public function test_error_information_reports_success(): void { + $stmt = $this->create_array_statement(); + + $this->assertSame( '00000', $stmt->errorCode() ); + $this->assertSame( array( '00000', null, null ), $stmt->errorInfo() ); + } + + public function test_iteration_yields_the_remaining_rows(): void { + $stmt = $this->create_array_statement(); + $stmt->setFetchMode( PDO::FETCH_NUM ); + + $this->assertSame( self::ROWS, iterator_to_array( $stmt ) ); + + // Iteration consumes the result set, as PDO's does. + $this->assertSame( array(), iterator_to_array( $stmt ) ); } public function test_key_pair_requires_two_columns(): void { diff --git a/packages/mysql-on-sqlite/tests/tools/backend-factory.php b/packages/mysql-on-sqlite/tests/tools/backend-factory.php index 3de9d0fc3..2f6aeb305 100644 --- a/packages/mysql-on-sqlite/tests/tools/backend-factory.php +++ b/packages/mysql-on-sqlite/tests/tools/backend-factory.php @@ -10,6 +10,10 @@ * - "d1": the Cloudflare D1 connection over a fake transport enforcing * D1 semantics (no transactions, no temporary tables, no user-defined * functions, JSON value coercion) against a local SQLite database. + * - "turso": the Turso connection over a fake transport enforcing Turso's + * "SQL over HTTP" semantics against a local SQLite database. The optional + * capabilities it withholds are the same ones D1 withholds, for different + * reasons -- see WP_SQLite_Turso_Fake_Transport. * * phpcs:disable WordPress.DB.RestrictedClasses.mysql__PDO */ @@ -19,10 +23,15 @@ require_once __DIR__ . '/class-wp-sqlite-d1-fake-transport.php'; } +if ( 'turso' === getenv( 'WP_SQLITE_TEST_BACKEND' ) ) { + require_once __DIR__ . '/../../src/turso/load.php'; + require_once __DIR__ . '/class-wp-sqlite-turso-fake-transport.php'; +} + /** * Get the connection backend to run driver tests against. * - * @return string The backend name: "pdo" or "d1". + * @return string The backend name: "pdo", "d1", or "turso". */ function wp_sqlite_tests_backend(): string { $backend = getenv( 'WP_SQLITE_TEST_BACKEND' ); @@ -52,6 +61,13 @@ function wp_sqlite_tests_create_engine( ?PDO &$sqlite = null, string $db_name = return new WP_SQLite_Driver( $connection, $db_name, 80038, array( 'transaction_fallback' => 'ignore' ) ); } + if ( 'turso' === wp_sqlite_tests_backend() ) { + $sqlite->setAttribute( PDO::ATTR_STRINGIFY_FETCHES, true ); + $connection = new WP_SQLite_Turso_Connection( new WP_SQLite_Turso_Fake_Transport( $sqlite ) ); + + return new WP_SQLite_Driver( $connection, $db_name, 80038, array( 'transaction_fallback' => 'ignore' ) ); + } + return new WP_SQLite_Driver( new WP_SQLite_Connection( array( 'pdo' => $sqlite ) ), $db_name @@ -84,6 +100,19 @@ function wp_sqlite_tests_create_pdo_engine( string $dsn, ?PDO &$sqlite = null ): ) ); } + + if ( 'turso' === wp_sqlite_tests_backend() ) { + $sqlite->setAttribute( PDO::ATTR_STRINGIFY_FETCHES, true ); + return new WP_MySQL_On_SQLite( + $dsn, + null, + null, + array( + 'sqlite_connection' => new WP_SQLite_Turso_Connection( new WP_SQLite_Turso_Fake_Transport( $sqlite ) ), + 'transaction_fallback' => 'ignore', + ) + ); + } return new WP_MySQL_On_SQLite( $dsn, null, null, array( 'sqlite_pdo' => $sqlite ) ); } @@ -94,22 +123,32 @@ function wp_sqlite_tests_create_pdo_engine( string $dsn, ?PDO &$sqlite = null ): * @param PHPUnit\Framework\TestCase $test The current test instance. */ function wp_sqlite_tests_skip_unsupported( PHPUnit\Framework\TestCase $test ): void { - if ( 'd1' !== wp_sqlite_tests_backend() ) { + $backend = wp_sqlite_tests_backend(); + if ( 'd1' !== $backend && 'turso' !== $backend ) { return; } + $name = 'd1' === $backend ? 'D1' : 'Turso'; + $reasons = array( - 'transactions' => 'D1 does not support interactive transactions.', - 'temporary tables' => 'D1 does not support temporary tables.', - 'REGEXP' => 'D1 does not support the REGEXP operator (no user-defined functions).', - 'seeded RAND' => 'D1 does not support seeded RAND(N) (no user-defined functions).', + 'transactions' => $name . ' does not support interactive transactions.', + 'temporary tables' => $name . ' does not support temporary tables.', + 'REGEXP' => $name . ' does not support the REGEXP operator (no user-defined functions).', + 'seeded RAND' => $name . ' does not support seeded RAND(N) (no user-defined functions).', 'strict messages' => 'Strict mode error messages differ without user-defined functions.', 'PHP evaluation' => 'The function requires constant arguments without user-defined functions.', - 'column metadata' => 'Detailed column metadata is not carried by the D1 protocol.', + 'column metadata' => 'Detailed column metadata is not carried by the ' . $name . ' protocol.', 'native types' => 'The fake transport cannot carry native value types before PHP 8.1.', + 'savepoints' => $name . ' does not support savepoints.', + 'native statement' => 'The test inspects the PDO SQLite handle behind the connection, which a remote backend does not have.', ); - $skip_list = wp_sqlite_tests_d1_skip_list(); + /* + * Both remote backends withhold the same four optional capabilities, so the + * set of tests they cannot run is the same -- for different reasons, which + * the connection classes document. + */ + $skip_list = wp_sqlite_tests_remote_backend_skip_list(); $test_name = get_class( $test ) . '::' . $test->getName( false ); if ( isset( $skip_list[ $test_name ] ) ) { $test->markTestSkipped( $reasons[ $skip_list[ $test_name ] ] ); @@ -117,11 +156,14 @@ function wp_sqlite_tests_skip_unsupported( PHPUnit\Framework\TestCase $test ): v } /** - * The list of tests not supported by the D1 backend, with skip reasons. + * The list of tests a remote backend cannot run, with skip reasons. + * + * Shared by the D1 and Turso backends: they withhold the same four optional + * capabilities, so the same tests are out of reach for both. * * @return array A map of "Class::method" to a reason key. */ -function wp_sqlite_tests_d1_skip_list(): array { +function wp_sqlite_tests_remote_backend_skip_list(): array { $list = array( // Interactive transactions and savepoints. 'WP_MySQL_On_SQLite_Tests::testStartTransactionCommand' => 'transactions', @@ -130,7 +172,43 @@ function wp_sqlite_tests_d1_skip_list(): array { 'WP_MySQL_On_SQLite_Tests::testTransactionSavepoints' => 'transactions', 'WP_MySQL_On_SQLite_Tests::testRollbackNonExistentTransactionSavepoint' => 'transactions', + /* + * Savepoint semantics. Without transactions a savepoint has nothing to + * roll back to, so these assert behaviour the fallback cannot produce: + * either an error that is no longer raised, or a write that is no longer + * undone. + */ + 'WP_MySQL_On_SQLite_Tests::testSavepointWithoutTransactionDoesNotStartTransaction' => 'savepoints', + 'WP_MySQL_On_SQLite_Tests::testReleaseSavepointWithoutTransaction' => 'savepoints', + 'WP_MySQL_On_SQLite_Tests::testMissingSavepointDoesNotRollbackTransaction' => 'savepoints', + 'WP_MySQL_On_SQLite_Tests::testDuplicateSavepointNameReplacesOldSavepoint' => 'savepoints', + 'WP_MySQL_On_SQLite_Tests::testReleaseSavepointDeletesNestedSavepoints' => 'savepoints', + 'WP_MySQL_On_SQLite_Tests::testRollbackToSavepointDeletesNestedSavepoints' => 'savepoints', + 'WP_MySQL_On_SQLite_Tests::testCommitDeletesSavepoints' => 'savepoints', + 'WP_MySQL_On_SQLite_Tests::testRollbackDeletesSavepoints' => 'savepoints', + 'WP_MySQL_On_SQLite_PDO_API_Tests::test_duplicate_savepoint_names_roll_back_to_the_latest' => 'savepoints', + 'WP_MySQL_On_SQLite_PDO_API_Tests::test_quoted_savepoint_names_are_case_insensitive' => 'savepoints', + 'WP_MySQL_On_SQLite_PDO_API_Tests::test_failed_write_after_standalone_savepoint_keeps_autocommit' => 'savepoints', + 'WP_MySQL_On_SQLite_PDO_API_Tests::test_releasing_savepoint_inside_explicit_transaction_keeps_transaction_active' => 'savepoints', + 'WP_MySQL_On_SQLite_PDO_API_Tests::test_write_inside_savepoint_can_be_rolled_back' => 'savepoints', + 'WP_MySQL_On_SQLite_PDO_API_Tests::test_writes_inside_nested_savepoints_preserve_outer_changes' => 'savepoints', + + // Asserts that BEGIN IMMEDIATE, COMMIT and ROLLBACK reach SQLite. + 'WP_MySQL_On_SQLite_PDO_API_Tests::test_transaction_methods_flush_operation_state' => 'transactions', + + /* + * These reach past the connection to the PDO SQLite handle behind it, + * for the driver name and for lazily resolved column metadata. A remote + * backend has no such handle. + */ + 'WP_MySQL_On_SQLite_PDO_API_Tests::test_reports_mysql_driver_name' => 'native statement', + 'WP_MySQL_On_SQLite_PDO_API_Tests::test_statement_column_metadata_is_resolved_lazily' => 'native statement', + + // LIKE BINARY against a non-constant pattern needs a user-defined function. + 'WP_MySQL_On_SQLite_Tests::testLikeBinaryPreservesPatternBytes' => 'PHP evaluation', + // Temporary tables. + 'WP_MySQL_On_SQLite_Metadata_Tests::testTemporaryTableAutoIncrement' => 'temporary tables', 'WP_MySQL_On_SQLite_Tests::testCreateTemporaryTable' => 'temporary tables', 'WP_MySQL_On_SQLite_Tests::testCreateTemporaryTableIfNotExists' => 'temporary tables', 'WP_MySQL_On_SQLite_Tests::testLockTemporaryTables' => 'temporary tables', diff --git a/packages/mysql-on-sqlite/tests/tools/class-wp-sqlite-turso-fake-transport.php b/packages/mysql-on-sqlite/tests/tools/class-wp-sqlite-turso-fake-transport.php new file mode 100644 index 000000000..95ae7feef --- /dev/null +++ b/packages/mysql-on-sqlite/tests/tools/class-wp-sqlite-turso-fake-transport.php @@ -0,0 +1,258 @@ + + */ + private $log = array(); + + /** + * The stringification setting to restore after internal fetches. + * + * @var bool + */ + private $external_stringify = false; + + /** + * Constructor. + * + * @param PDO|null $pdo Optional. A PDO SQLite instance to use. + */ + public function __construct( ?PDO $pdo = null ) { + $this->pdo = $pdo ?? new PDO( 'sqlite::memory:' ); + $this->pdo->setAttribute( PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION ); + + /* + * Turso honours "PRAGMA foreign_keys", and the driver sets it, so the + * fake leaves the SQLite default in place rather than forcing it on the + * way the D1 fake has to. + */ + + try { + $this->external_stringify = (bool) $this->pdo->getAttribute( PDO::ATTR_STRINGIFY_FETCHES ); + } catch ( PDOException $e ) { + $this->external_stringify = null !== $pdo; + } + } + + /** + * Get the log of all executed statements. + * + * @return array + */ + public function get_log(): array { + return $this->log; + } + + /** + * Execute a single SQL statement. See the transport interface. + * + * @param string $sql The SQL statement to execute. + * @param array $params Positional query parameters. + * @return array The result. + */ + public function query( string $sql, array $params = array() ): array { + $this->reject_unsupported( $sql ); + return $this->run( $sql, $params ); + } + + /** + * Execute a batch of SQL statements atomically. See the transport interface. + * + * @param array $statements The statements. + * @return array[] One result per statement. + */ + public function batch( array $statements ): array { + foreach ( $statements as $statement ) { + $this->reject_unsupported( $statement[0] ); + } + + $this->pdo->beginTransaction(); + try { + $results = array(); + foreach ( $statements as $statement ) { + $results[] = $this->run( $statement[0], $statement[1] ?? array() ); + } + } catch ( Throwable $e ) { + $this->pdo->rollBack(); + throw $e; + } + $this->pdo->commit(); + return $results; + } + + /** + * See the transport interface. + * + * The fake's PDO session persists, so applying the statements once has the + * same effect as sending them ahead of every request. + * + * @param string[] $statements SQL statements, run in order. + */ + public function set_session_statements( array $statements ): void { + foreach ( $statements as $sql ) { + $this->pdo->exec( $sql ); + } + } + + /** + * Reject statements Turso cannot carry over HTTP. + * + * @param string $sql The SQL statement. + * @throws WP_SQLite_Turso_Exception When the statement is not supported. + */ + private function reject_unsupported( string $sql ): void { + $normalized = strtoupper( ltrim( $sql ) ); + + foreach ( array( 'BEGIN', 'COMMIT', 'ROLLBACK', 'SAVEPOINT', 'RELEASE', 'END' ) as $keyword ) { + if ( 1 === preg_match( '/^' . $keyword . '\b/', $normalized ) ) { + throw WP_SQLite_Turso_Exception::from_server_error( + 'PREPARE_ERROR', + sprintf( 'Transaction error: %s statements are not supported', $keyword ) + ); + } + } + + if ( 1 === preg_match( '/^CREATE\s+(TEMP|TEMPORARY)\b/', $normalized ) ) { + throw WP_SQLite_Turso_Exception::from_server_error( + 'PREPARE_ERROR', + 'Parse error: temporary tables are not supported' + ); + } + } + + /** + * Execute a statement locally, shaping the result as Turso's protocol does. + * + * @param string $sql The SQL statement. + * @param array $params The positional parameters. + * @return array The result (columns, rows, meta). + * @throws WP_SQLite_Turso_Exception When the execution fails. + */ + private function run( string $sql, array $params ): array { + $this->log[] = array( $sql, $params ); + + /* + * Fetch with stringification disabled, independently of the attribute + * on the handle: the protocol carries typed values. PDO SQLite applies + * the attribute when rows are fetched rather than executed, so it has + * to stay off until the rows have been read, then be restored so tests + * can make stringified assertions against the raw handle. + */ + $this->pdo->setAttribute( PDO::ATTR_STRINGIFY_FETCHES, false ); + + try { + $stmt = $this->pdo->prepare( $sql ); + $stmt->execute( array_values( $params ) ); + + $columns = array(); + for ( $i = 0; $i < $stmt->columnCount(); $i++ ) { + try { + $meta = $stmt->getColumnMeta( $i ); + } catch ( PDOException $e ) { + $meta = false; + } + $columns[] = false === $meta ? "column$i" : $meta['name']; + } + + $rows = array(); + foreach ( $stmt->fetchAll( PDO::FETCH_NUM ) as $row ) { + $rows[] = array_map( array( $this, 'protocol_round_trip' ), $row ); + } + } catch ( PDOException $e ) { + // Reproduce the error shape of Turso's protocol. + $message = $e->getMessage(); + $matches = array(); + preg_match( '/SQLSTATE\[[^\]]+\]: [^:]+: \d+ (.*)/s', $message, $matches ); + + throw WP_SQLite_Turso_Exception::from_server_error( + 'PREPARE_ERROR', + sprintf( 'Parse error: %s', $matches[1] ?? $message ) + ); + } finally { + $this->pdo->setAttribute( PDO::ATTR_STRINGIFY_FETCHES, $this->external_stringify ); + } + + $is_read = 1 === preg_match( '/^\s*(SELECT|PRAGMA|EXPLAIN|WITH)\b/i', $sql ); + + if ( $is_read ) { + // Turso reports no write metadata for any statement; the protocol + // trait only asks for it after a write. + $meta = array( + 'changes' => 0, + 'last_row_id' => 0, + ); + } else { + $meta = array( + 'changes' => $stmt->rowCount(), + 'last_row_id' => (int) $this->pdo->lastInsertId(), + ); + + // A write carries no column names. + if ( 0 === count( $rows ) ) { + $columns = array(); + } + } + + return array( + 'columns' => $columns, + 'rows' => $rows, + 'meta' => $meta, + ); + } + + /** + * Round-trip a value through the real protocol codec. + * + * Using the production encoder and decoder here means the fake exercises + * the type handling rather than approximating it. + * + * @param mixed $value The value. + * @return mixed The value after a protocol round trip. + */ + private function protocol_round_trip( $value ) { + $encoded = WP_SQLite_Turso_Response::encode_value( $value ); + + // Travel through JSON as the real transport's bytes do. + $decoded = json_decode( (string) json_encode( $encoded ), true ); + + return WP_SQLite_Turso_Response::decode_value( $decoded ); + } +} diff --git a/packages/mysql-parser/README.md b/packages/mysql-parser/README.md index b4f61f9b1..e6f341b2b 100644 --- a/packages/mysql-parser/README.md +++ b/packages/mysql-parser/README.md @@ -3,7 +3,7 @@ A fast and complete **MySQL parser** in pure PHP with zero dependencies, generated directly from the **official MySQL grammar**. -The runtime requires **PHP 7.2+** with no extensions. +The runtime requires **PHP 8.0+** with no extensions. ## How it works diff --git a/packages/mysql-parser/composer.json b/packages/mysql-parser/composer.json index 30b872191..fad1816a2 100644 --- a/packages/mysql-parser/composer.json +++ b/packages/mysql-parser/composer.json @@ -16,7 +16,7 @@ "source": "https://github.com/wordpress/sqlite-database-integration" }, "require": { - "php": ">=7.2" + "php": ">=8.0" }, "autoload": { "classmap": [ diff --git a/packages/mysql-proxy/composer.json b/packages/mysql-proxy/composer.json index 582c2006d..8d16ecdbd 100644 --- a/packages/mysql-proxy/composer.json +++ b/packages/mysql-proxy/composer.json @@ -16,7 +16,7 @@ "source": "https://github.com/wordpress/sqlite-database-integration" }, "require": { - "php": ">=7.2", + "php": ">=8.0", "ext-sockets": "*", "composer-runtime-api": "^2.2" }, diff --git a/packages/plugin-sqlite-database-integration/composer.json b/packages/plugin-sqlite-database-integration/composer.json index d77c7f478..4795dcc3c 100644 --- a/packages/plugin-sqlite-database-integration/composer.json +++ b/packages/plugin-sqlite-database-integration/composer.json @@ -8,7 +8,7 @@ "issues": "https://github.com/wordpress/sqlite-database-integration/issues" }, "require": { - "php": ">=7.2", + "php": ">=8.0", "ext-pdo": "*", "ext-pdo_sqlite": "*" } diff --git a/packages/plugin-sqlite-database-integration/load.php b/packages/plugin-sqlite-database-integration/load.php index aa3d94b50..afb26cde5 100644 --- a/packages/plugin-sqlite-database-integration/load.php +++ b/packages/plugin-sqlite-database-integration/load.php @@ -4,7 +4,7 @@ * Description: SQLite database driver drop-in. * Author: The WordPress Team * Version: 3.0.2 - * Requires PHP: 7.2 + * Requires PHP: 8.0 * Network: true * License: GPL-2.0-or-later * License URI: https://www.gnu.org/licenses/gpl-2.0.html diff --git a/packages/plugin-sqlite-database-integration/readme.txt b/packages/plugin-sqlite-database-integration/readme.txt index ff8a802f0..56ca07b60 100644 --- a/packages/plugin-sqlite-database-integration/readme.txt +++ b/packages/plugin-sqlite-database-integration/readme.txt @@ -3,7 +3,7 @@ Contributors: wordpressdotorg, aristath, janjakes, zieladam, berislav.grgicak, bpayton, zaerl Requires at least: 6.4 Tested up to: 7.1 -Requires PHP: 7.2 +Requires PHP: 8.0 Stable tag: 3.0.2 License: GPLv2 or later License URI: https://www.gnu.org/licenses/gpl-2.0.html diff --git a/packages/turso-snapshot-publisher/Cargo.lock b/packages/turso-snapshot-publisher/Cargo.lock new file mode 100644 index 000000000..8f40ddbc0 --- /dev/null +++ b/packages/turso-snapshot-publisher/Cargo.lock @@ -0,0 +1,2900 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aegis" +version = "0.9.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58541132f980da31e9aa99f7bdee69bc84bf1e168b9b91ef2dbe8abb7b4ce5dd" +dependencies = [ + "cc", + "softaes", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c880a97d28a3681c0267bd29cff89621202715b065127cd445fa0f0fe0aa2880" + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "antithesis_sdk" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8ed6751597568a284314fd9e2db420665898600109dedc7d8d9b588f00ae9c4" +dependencies = [ + "libc", + "libloading", + "linkme", + "once_cell", + "rand 0.8.8", + "rustc_version_runtime", + "serde", + "serde_json", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "arc-swap" +version = "1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" +dependencies = [ + "rustversion", +] + +[[package]] +name = "aristo" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcdeefa800110050103e2459a2f8dbdbd560ad046e30f1f87e24ce19c2cb8bde" +dependencies = [ + "aristo-macros", +] + +[[package]] +name = "aristo-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d64a66d21a80182b35b1741997a6d2456911f54b7eb1918aa4e2382fc205268d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "assoc" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfdc70193dadb9d7287fa4b633f15f90c876915b31f6af17da307fc59c9859a8" + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "aws-lc-rs" +version = "1.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bigdecimal" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d6867f1565b3aad85681f1015055b087fcfd840d6aeee6eee7f2da317603695" +dependencies = [ + "autocfg", + "libm", + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "bindgen" +version = "0.69.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271383c67ccabffb7381723dea0672a673f292304fcb45c01cc648c7a8d58088" +dependencies = [ + "bitflags", + "cexpr", + "clang-sys", + "itertools 0.12.1", + "lazy_static", + "lazycell", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash 1.1.0", + "shlex 1.3.0", + "syn 2.0.119", + "which", +] + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "bitvec" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[package]] +name = "branches" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7436b21fd6195415058eb41c9090e156b556bc7e0377bb57c5c026a421521525" +dependencies = [ + "rustc_version", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0e56a716f1e132ff6bf4bdac1c944a3fcdc1cae65f70a4a2a1ac3b401d2d1f" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "005ec2760ca554fae18df7a11195552ec576cd665632a881bc011d5bb2fd4d80" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex 2.0.1", +] + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "cfg_block" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18758054972164c3264f7c8386f5fc6da6114cb46b619fd365d4e3b2dc3ae487" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core 0.10.1", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "clang-sys" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32c" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a47af21622d091a8f0fb295b88bc886ac74efcc613efc19f5d0b21de5c89e47" +dependencies = [ + "rustc_version", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "typenum", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" + +[[package]] +name = "env_filter" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "900d271a03799a1ee8d1ca9b19893b48ca674a9284fefcfb85f05e74ed314217" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de671bd27a75a797dc9ae289ba1e77276e75e2026408aab65185384e2d5cd3f6" +dependencies = [ + "env_filter", + "log", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fastbloom" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7f34442dbe69c60fe8eaf58a8cafff81a1f278816d8ab4db255b3bef4ac3c4" +dependencies = [ + "getrandom 0.3.4", + "libm", + "siphasher", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "find-msvc-tools" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "genawaiter" +version = "0.99.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c86bd0361bcbde39b13475e6e36cb24c329964aa2611be285289d1e4b751c1a0" +dependencies = [ + "genawaiter-macro", +] + +[[package]] +name = "genawaiter-macro" +version = "0.99.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b32dfe1fdfc0bbde1f22a5da25355514b5e450c33a6af6770884c8750aedfbc" + +[[package]] +name = "generator" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3b854b0e584ead1a33f18b2fcad7cf7be18b3875c78816b753639aa501513ae" +dependencies = [ + "cc", + "cfg-if", + "libc", + "log", + "rustversion", + "windows-link", + "windows-result", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "log", + "rustls", + "rustls-native-certs", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "libc", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collator" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08984ed58ac439ebf3e13d2cf26b0c46a60afcd21721c1d14087c0b240344dda" +dependencies = [ + "icu_collator_data", + "icu_collections", + "icu_locale_core", + "icu_locale_fallback", + "icu_normalizer", + "icu_properties", + "icu_provider", + "smallvec", + "utf16_iter", + "utf8_iter", + "zerovec", +] + +[[package]] +name = "icu_collator_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7d7e54efdddeb1208c08dd5d32b53a879ac00d2d3d051b2255fd26821d16368" + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "785f595c61ef57169a467eeed7b4b6936a66f6cacbb116a96ee86da983251bf4" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_locale_data", + "icu_locale_fallback", + "icu_provider", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "serde", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_locale_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d37d91460e362a5cf58907cd7ce871411775ee6294e49a5cc8e6cec16dfa75ea" + +[[package]] +name = "icu_locale_fallback" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "251af8e57c9400e3eb58242fe5b8b1152b2a64fdf4cf632f923c38ccee6f2fa9" +dependencies = [ + "icu_locale_core", + "icu_locale_fallback_data", + "icu_provider", + "potential_utf", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_locale_fallback_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "decf2a22ec8fa68f1a0c1129a3f8583f8f8bc24e8b9ccbe98ead99f62a4dc3a8" + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "utf16_iter", + "utf8_iter", + "write16", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "serde", + "stable_deref_trait", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "intrusive-collections" +version = "0.9.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "189d0897e4cbe8c75efedf3502c18c887b05046e59d28404d4d8e46cbc4d1e86" +dependencies = [ + "memoffset", +] + +[[package]] +name = "io-uring" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3bd0ecfbb87805f538bb7b32e5239ca0763890c623e349860ecba69469f2bb" +dependencies = [ + "bitflags", + "cfg-if", + "libc", +] + +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "lazycell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "linkme" +version = "0.3.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3045e122bd98aef8ec3ad58ce84f0791f64e70163d1a02710af4aa11a4d54cc5" +dependencies = [ + "linkme-impl", +] + +[[package]] +name = "linkme-impl" +version = "0.3.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77060ebe535362c3da75682cd17b0431017b6e7c5661e714fc69a7ad017d1301" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "loom" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca" +dependencies = [ + "cfg-if", + "generator", + "scoped-tls", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "miette" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" +dependencies = [ + "cfg-if", + "miette-derive", + "unicode-width", +] + +[[package]] +name = "miette-derive" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "mio" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "owo-colors" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1b04fb49957986fdce4d6ee7a65027d55d4b6d2265e5848bbb507b58ccfdb6f" + +[[package]] +name = "pack1" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3b7bb0ecf2e447b1f20ee94ee79ef6eed1e9d4b3c36ce1903b9dea3bf205523" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pastey" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "serde_core", + "writeable", + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.119", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "prost" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-derive" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" +dependencies = [ + "anyhow", + "itertools 0.14.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59cad018caf63deb318e5a4586d99a24424a364f40f1e5778c29aca23f4fc73e" +dependencies = [ + "rand_core 0.6.4", +] + +[[package]] +name = "rapidhash" +version = "4.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5da7e78a036ce858e8d55b7e7dc8ba3a88b78350fd2155d3591bbd966b58589e" +dependencies = [ + "rustversion", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "roaring" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18bd8a37d17a58532776dcdf6041ce64929adca78e8489d5cacbafe99229d3e1" +dependencies = [ + "bytemuck", + "byteorder", +] + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustc_version_runtime" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dd18cd2bae1820af0b6ad5e54f4a51d0f3fcc53b05f845675074efcc7af071d" +dependencies = [ + "rustc_version", + "semver", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6725596c3f2c3a0aef021139e145d4eafe314a6623e4680ca83852b2c67ab2ba" +dependencies = [ + "aws-lc-rs", + "log", + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "shuttle" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab17edba38d63047f46780cf7360acf7467fec2c048928689a5c1dd1c2b4e31" +dependencies = [ + "assoc", + "bitvec", + "cfg-if", + "generator", + "hex", + "owo-colors", + "rand 0.8.8", + "rand_core 0.6.4", + "rand_pcg", + "scoped-tls", + "smallvec", + "tracing", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "simsimd" +version = "6.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4fb3bc3cdce07a7d7d4caa4c54f8aa967f6be41690482b54b24100a2253fa70" +dependencies = [ + "cc", +] + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "softaes" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45e14297decde697ddf377c25752aead0927d5cfc89c2684d2af96901a4ceeea" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.119", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "symlink" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "serde_core", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-appender" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c" +dependencies = [ + "crossbeam-channel", + "symlink", + "thiserror", + "time", + "tracing-subscriber", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "turso" +version = "0.8.0-pre.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6626e5f030eea4ffc164034e930ada160b04d7e6d7f0ce631517eee4a8a5a18b" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "thiserror", + "tokio", + "tracing", + "tracing-subscriber", + "turso_core", + "turso_sdk_kit", + "turso_sync_sdk_kit", +] + +[[package]] +name = "turso_core" +version = "0.8.0-pre.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4a0784e41ff52c4d02439fc84f397ceb1efdbdac10e833d77bbb6918863278b" +dependencies = [ + "aegis", + "aes", + "aes-gcm", + "allocator-api2", + "antithesis_sdk", + "arc-swap", + "aristo", + "bigdecimal", + "bitflags", + "branches", + "bumpalo", + "bytemuck", + "cfg_aliases", + "cfg_block", + "chrono", + "crc32c", + "crossbeam-epoch", + "crossbeam-utils", + "either", + "fallible-iterator", + "fastbloom", + "getrandom 0.2.17", + "getrandom 0.3.4", + "hex", + "icu_collator", + "icu_locale", + "intrusive-collections", + "io-uring", + "libc", + "libloading", + "libm", + "loom", + "miette", + "num-bigint", + "num-traits", + "pack1", + "parking_lot", + "pastey", + "polling", + "rand 0.9.5", + "rapidhash", + "regex", + "regex-syntax", + "roaring", + "rustc-hash 2.1.3", + "rustix 1.1.4", + "ryu", + "serde_json", + "shuttle", + "simdutf8", + "simsimd", + "smallvec", + "strum", + "strum_macros", + "tempfile", + "thiserror", + "tracing", + "tracing-subscriber", + "turso_ext", + "turso_macros", + "turso_parser", + "twox-hash", + "uncased", + "uuid", + "windows-sys 0.61.2", +] + +[[package]] +name = "turso_ext" +version = "0.8.0-pre.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d68a520e2404421b511585cbc0e55789ac8abc151d1f3c3624a16302be3fd2e" +dependencies = [ + "chrono", + "getrandom 0.4.3", + "turso_macros", +] + +[[package]] +name = "turso_macros" +version = "0.8.0-pre.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15097fdbd4a72492b67fef31a38b05da0f2ee24121704d88f9819aaf611b9b8c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "turso_parser" +version = "0.8.0-pre.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80b5c91f399b973d2aa62db4872750980f6f86e9b842085df8326fb3c00efed3" +dependencies = [ + "bitflags", + "memchr", + "miette", + "strum", + "strum_macros", + "thiserror", + "turso_macros", +] + +[[package]] +name = "turso_sdk_kit" +version = "0.8.0-pre.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d9356b166097e4af5583e4f21cde390a62d9284427e3ef95b7874daec1854e8" +dependencies = [ + "bindgen", + "env_logger", + "parking_lot", + "tracing", + "tracing-appender", + "tracing-subscriber", + "turso_core", + "turso_ext", + "turso_sdk_kit_macros", +] + +[[package]] +name = "turso_sdk_kit_macros" +version = "0.8.0-pre.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5059996537fe8f887ab9eb9d963266ea911d296cea2305c9ae6705eb3afe601" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "turso_snapshot_publisher" +version = "0.1.0" +dependencies = [ + "tokio", + "turso", +] + +[[package]] +name = "turso_sync_engine" +version = "0.8.0-pre.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea78ca737bf13a1096a0fe9140f9aa9d798d4343477f4d2c9ef617307972cb3" +dependencies = [ + "base64", + "bytes", + "crc32c", + "genawaiter", + "http", + "libc", + "prost", + "roaring", + "serde", + "serde_json", + "thiserror", + "tracing", + "turso_core", + "turso_parser", + "uuid", +] + +[[package]] +name = "turso_sync_sdk_kit" +version = "0.8.0-pre.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0fb5ff6be8b7c9f545ba901c8d220603de756bdb56a8f79faa5ee3dbbdae4a4" +dependencies = [ + "bindgen", + "env_logger", + "genawaiter", + "parking_lot", + "tracing", + "tracing-appender", + "tracing-subscriber", + "turso_core", + "turso_sdk_kit", + "turso_sdk_kit_macros", + "turso_sync_engine", +] + +[[package]] +name = "twox-hash" +version = "2.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5283634e518fe9e82c7b20520bb4bc209009fd16c82077c802f8111ecbb0117a" +dependencies = [ + "rand 0.10.2", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "uncased" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1b88fcfe09e89d3866a5c11019378088af2d24c3fbd4f0543f96b479ec90697" +dependencies = [ + "version_check", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "utf16_iter" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "sha1_smol", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.5", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "which" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" +dependencies = [ + "either", + "home", + "once_cell", + "rustix 0.38.44", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "write16" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "serde", + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/packages/turso-snapshot-publisher/Cargo.toml b/packages/turso-snapshot-publisher/Cargo.toml new file mode 100644 index 000000000..c0dcda157 --- /dev/null +++ b/packages/turso-snapshot-publisher/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "turso_snapshot_publisher" +version = "0.1.0" +edition = "2021" +license = "GPL-2.0-or-later" +description = "Publishes a readable SQLite snapshot of a Turso database for the MySQL-on-SQLite driver" + +[[bin]] +name = "turso-snapshot-publisher" +path = "src/main.rs" + +[dependencies] +turso = { version = "0.8.0-pre.10", default-features = false, features = ["sync"] } +tokio = { version = "1", default-features = false, features = ["rt", "macros", "time", "signal"] } + +[profile.release] +lto = "thin" +codegen-units = 1 diff --git a/packages/turso-snapshot-publisher/README.md b/packages/turso-snapshot-publisher/README.md new file mode 100644 index 000000000..e5be906f0 --- /dev/null +++ b/packages/turso-snapshot-publisher/README.md @@ -0,0 +1,65 @@ +# Turso snapshot publisher + +Publishes a readable SQLite snapshot of a Turso database, for the Turso backend's +read path (`packages/mysql-on-sqlite/src/turso/`). + +``` +turso-snapshot-publisher --replica /var/lib/wp/private/replica.db \ + --published /var/lib/wp/database/snapshot.db \ + --url libsql://db-org.turso.io \ + --interval 10 +``` + +`TURSO_AUTH_TOKEN` is read from the environment so the token stays out of the +process list; `--token` also works. Omit `--interval` (or pass `--once`) to +publish once and exit, which is what a build or a deploy hook wants. + +## Why this exists + +A live Turso embedded replica cannot be read by anything but Turso. It holds an +exclusive POSIX write lock on its database file for the lifetime of the +connection — not only while syncing — and coordinates its WAL across processes +through a `.tshm` file that SQLite knows nothing about. So `pdo_sqlite` opening a +live replica gets `database is locked`, and with turso_core's lock disabled +(`LIMBO_DISABLE_FILE_LOCK`) it silently reads a stale snapshot forever, which is +worse than an error. + +This process owns the replica and hands PHP a plain file instead: + +``` +pull -> VACUUM INTO -> rollback-journal -> read-only -> rename +``` + +Each step earns its place: + +- **`pull`** is incremental as long as the `--replica` directory persists across + runs. Keep it on durable storage, private to this process. +- **`VACUUM INTO`** writes a consistent, standalone, compacted database. Closing + the connection is *not* an alternative: with pooling on it does not checkpoint, + and snapshots taken that way fail `integrity_check` while the `-wal` grows + without bound. +- **Rollback-journal** mode, by writing the two file-format version bytes in the + SQLite header. `VACUUM INTO` emits a WAL-mode file, and a WAL reader creates + `-wal`/`-shm` beside it — SQLite keys those by *path*, not inode, so they would + outlive the rename and describe the previous snapshot. Turso will not run + `PRAGMA journal_mode = DELETE`, and after a vacuum there is no WAL to lose, so + the two bytes are the whole conversion. +- **Read-only** (mode 444) because the reader never writes. The *directory* must + stay writable: the SQLite integration plugin drops an `index.php` and + `.htaccess` beside the database and health-checks for them. +- **`rename`** is atomic, so a reader sees either the whole old snapshot or the + whole new one, and one holding the old inode finishes undisturbed. + +A failed cycle is logged and the loop continues: the snapshot already in place +stays servable, so the right response to a blip is to keep trying. + +## Cost and freshness + +On the installed WordPress database used for development (467 KB, 36 tables): +**10–15 ms per cycle**, of which the vacuum is ~11 ms and an incremental pull +0.4–4 ms. The vacuum is O(database size), so the interval sets both the staleness +window and the steady-state cost. + +The front end is stale by at most one interval. That is a real semantic change +and belongs in a site's documentation; it composes with the page cache, which +already means the public site lags the database by a bounded amount. diff --git a/packages/turso-snapshot-publisher/src/main.rs b/packages/turso-snapshot-publisher/src/main.rs new file mode 100644 index 000000000..9c32ed115 --- /dev/null +++ b/packages/turso-snapshot-publisher/src/main.rs @@ -0,0 +1,269 @@ +//! Publishes a readable SQLite snapshot of a Turso database. +//! +//! A live Turso embedded replica cannot be read by anything but Turso. It holds +//! an exclusive POSIX write lock on its database file for the lifetime of the +//! connection -- not just while syncing -- and coordinates its WAL across +//! processes through a `.tshm` file that SQLite knows nothing about. So +//! `pdo_sqlite` opening a live replica gets `database is locked`, and with +//! turso_core's lock disabled it silently reads a stale snapshot forever, which +//! is worse. +//! +//! This process owns the replica and hands PHP a plain SQLite file instead: +//! +//! ```text +//! pull -> VACUUM INTO -> rollback-journal -> read-only -> rename +//! ``` +//! +//! `VACUUM INTO` writes a consistent, standalone, compacted database. The rename +//! is atomic, so a reader sees either the whole old snapshot or the whole new +//! one, and a reader holding the old inode finishes undisturbed. +//! +//! The journal-mode conversion matters: `VACUUM INTO` emits a WAL-mode file, and +//! a WAL reader creates `-wal`/`-shm` beside it. SQLite keys those by path rather +//! than inode, so they would outlive the rename and describe the *previous* +//! snapshot. A rollback-journal database needs no sidecars at all. + +use std::env; +use std::fs; +use std::io::{Seek, SeekFrom, Write}; +use std::path::{Path, PathBuf}; +use std::process::ExitCode; +use std::time::{Duration, Instant}; + +/// Byte offsets of the file-format write and read versions in the SQLite header. +/// +/// See . The +/// value is 1 for a legacy rollback journal and 2 for WAL. +const HEADER_VERSION_OFFSET: u64 = 18; +const FORMAT_LEGACY_JOURNAL: u8 = 1; + +/// Permissions for a published snapshot: readable by everyone, writable by none. +/// +/// The reader never needs to write -- verified against a real WordPress front +/// end, where every public path renders with the file read-only. Note that the +/// *directory* must stay writable: the SQLite integration plugin drops an +/// `index.php` and `.htaccess` beside the database and health-checks for them. +const SNAPSHOT_MODE: u32 = 0o444; + +struct Config { + replica: PathBuf, + published: PathBuf, + url: String, + token: Option, + interval: Option, +} + +fn usage() -> &'static str { + "\ +usage: turso-snapshot-publisher --replica --published --url + [--token ] [--interval ] [--once] + + --replica Where to keep the private embedded replica. Persisting it across + runs is what keeps each pull incremental. + --published The snapshot PHP reads. Replaced atomically. + --url The Turso database URL. + --token Auth token, if the server requires one. TURSO_AUTH_TOKEN is also + read, which keeps it out of the process list. + --interval Seconds between publishes. Omit, or pass --once, to publish once + and exit. +" +} + +/// `Ok(None)` means help was requested: print usage and exit successfully. +fn parse_args() -> Result, String> { + let mut replica = None; + let mut published = None; + let mut url = None; + let mut token = env::var("TURSO_AUTH_TOKEN").ok(); + let mut interval = None; + let mut once = false; + + let mut args = env::args().skip(1); + while let Some(arg) = args.next() { + let mut value = |name: &str| -> Result { + args.next().ok_or_else(|| format!("{name} needs a value")) + }; + match arg.as_str() { + "--replica" => replica = Some(PathBuf::from(value("--replica")?)), + "--published" => published = Some(PathBuf::from(value("--published")?)), + "--url" => url = Some(value("--url")?), + "--token" => token = Some(value("--token")?), + "--interval" => { + let seconds: u64 = value("--interval")? + .parse() + .map_err(|_| "--interval must be a whole number of seconds".to_string())?; + interval = Some(Duration::from_secs(seconds)); + } + "--once" => once = true, + "-h" | "--help" => return Ok(None), + other => return Err(format!("unknown argument: {other}\n\n{}", usage())), + } + } + + Ok(Some(Config { + replica: replica.ok_or("--replica is required")?, + published: published.ok_or("--published is required")?, + url: url.ok_or("--url is required")?, + token: token.filter(|t| !t.is_empty()), + interval: if once { None } else { interval }, + })) +} + +/// Turn a freshly vacuumed database into a rollback-journal one. +/// +/// Safe precisely because the file comes from `VACUUM INTO`: it has no WAL to +/// lose, so the two header bytes are the whole of the conversion. This is what +/// `PRAGMA journal_mode = DELETE` writes, and Turso will not run that pragma. +fn set_rollback_journal(path: &Path) -> std::io::Result<()> { + let mut file = fs::OpenOptions::new().write(true).open(path)?; + file.seek(SeekFrom::Start(HEADER_VERSION_OFFSET))?; + file.write_all(&[FORMAT_LEGACY_JOURNAL, FORMAT_LEGACY_JOURNAL])?; + file.sync_all() +} + +async fn publish(db: &turso::sync::Database, config: &Config) -> Result<(String, u64), String> { + let started = Instant::now(); + + let pulled = db.pull().await.map_err(|e| format!("pull failed: {e}"))?; + let pull_ms = started.elapsed().as_secs_f64() * 1000.0; + + // Write beside the published path so the rename stays within one filesystem. + let mut tmp = config.published.clone().into_os_string(); + tmp.push(".new"); + let tmp = PathBuf::from(tmp); + for path in [&tmp, &PathBuf::from(format!("{}-wal", tmp.display()))] { + let _ = fs::remove_file(path); + } + + let vacuum_started = Instant::now(); + let conn = db + .connect() + .await + .map_err(|e| format!("could not connect to the replica: {e}"))?; + conn.execute( + // The path is ours, not user input, but quote it the way SQL requires. + &format!( + "VACUUM INTO '{}'", + tmp.display().to_string().replace('\'', "''") + ), + (), + ) + .await + .map_err(|e| format!("VACUUM INTO failed: {e}"))?; + let vacuum_ms = vacuum_started.elapsed().as_secs_f64() * 1000.0; + + // VACUUM INTO can leave empty sidecars beside its output: SQLite-style + // "-wal"/"-shm", and Turso's own log, which it names by *replacing* the + // path's last extension -- so "snapshot.db.new" gets "snapshot.db.db-log". + let _ = fs::remove_file(format!("{}-wal", tmp.display())); + let _ = fs::remove_file(format!("{}-shm", tmp.display())); + let _ = fs::remove_file(tmp.with_extension("db-log")); + + set_rollback_journal(&tmp).map_err(|e| format!("could not set the journal mode: {e}"))?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&tmp, fs::Permissions::from_mode(SNAPSHOT_MODE)) + .map_err(|e| format!("could not set permissions: {e}"))?; + } + + let size = fs::metadata(&tmp) + .map_err(|e| format!("could not stat the snapshot: {e}"))? + .len(); + fs::rename(&tmp, &config.published) + .map_err(|e| format!("could not publish the snapshot: {e}"))?; + + Ok(( + format!( + "pull {:.1} ms{}, vacuum {:.1} ms, total {:.1} ms, {} bytes", + pull_ms, + if pulled { "" } else { " (no change)" }, + vacuum_ms, + started.elapsed().as_secs_f64() * 1000.0, + size + ), + size, + )) +} + +#[tokio::main(flavor = "current_thread")] +async fn main() -> ExitCode { + let config = match parse_args() { + Ok(Some(config)) => config, + Ok(None) => { + print!("{}", usage()); + return ExitCode::SUCCESS; + } + Err(message) => { + eprintln!("{message}"); + return ExitCode::from(2); + } + }; + + if let Some(parent) = config.replica.parent() { + if !parent.as_os_str().is_empty() { + let _ = fs::create_dir_all(parent); + } + } + + let mut builder = turso::sync::Builder::new_remote(&config.replica.display().to_string()) + .with_remote_url(config.url.clone()) + .with_client_name("turso-snapshot-publisher") + .bootstrap_if_empty(true) + // VACUUM is gated behind this; it is how the snapshot is written. + .experimental_vacuum(true); + if let Some(token) = &config.token { + builder = builder.with_auth_token(token.clone()); + } + + let db = match builder.build().await { + Ok(db) => db, + Err(e) => { + eprintln!( + "could not open the replica at {}: {e}", + config.replica.display() + ); + return ExitCode::FAILURE; + } + }; + + println!( + "publisher: replica={} published={} url={}", + config.replica.display(), + config.published.display(), + config.url + ); + + let Some(interval) = config.interval else { + return match publish(&db, &config).await { + Ok((report, _)) => { + println!("published: {report}"); + ExitCode::SUCCESS + } + Err(message) => { + eprintln!("{message}"); + ExitCode::FAILURE + } + }; + }; + + // A failed publish must not end the loop: the snapshot already in place stays + // servable, so the right response to a blip is to keep trying. + let mut cycle: u64 = 0; + loop { + cycle += 1; + match publish(&db, &config).await { + Ok((report, _)) => println!("cycle {cycle}: {report}"), + Err(message) => eprintln!("cycle {cycle}: {message}"), + } + + tokio::select! { + _ = tokio::time::sleep(interval) => {} + _ = tokio::signal::ctrl_c() => { + println!("publisher: stopping"); + return ExitCode::SUCCESS; + } + } + } +}