1 parent
95c0587c1e
commit
5b22add1f3
21 files changed
+293
-89
No files matched your search
@@ -1,5 +1,25 @@
|
|||||||
# RustBox changelog
|
# RustBox changelog
|
||||||
|
|
||||||
|
### 0.2.2
|
||||||
|
- Fixed the SQLite isolation regression test to initialize `AppConfig` with a struct literal plus `..Default::default()`, satisfying `cargo clippy --all-targets -- -D warnings` without suppressing `field_reassign_with_default`.
|
||||||
|
- Fixed Rust test isolation for SQLite: test-only `db::pool::configure()` still initializes the requested database schema but no longer pins the first temporary SQLite path in the process-wide `OnceLock`, preventing later tests from reusing a deleted `TempDir` and collapsing into HTTP 500/database-open failures.
|
||||||
|
- Added a regression test that configures SQLite in one temporary directory, removes it, then opens an account database in a second temporary directory.
|
||||||
|
- Strengthened the offline release contract to assert that `scripts/build-release.sh` requires the full locked test suite, warning-free Clippy, and a locked release build before a release is accepted.
|
||||||
|
- Fixed the PostgreSQL startup schema compile failure (`implementation of sqlx::Executor is not general enough`) by executing the multi-statement DDL batch through `sqlx::Executor::execute()` on the transaction connection instead of `sqlx::raw_sql(...).execute(&mut PgConnection)`.
|
||||||
|
- Preserved transactional startup schema initialization and the local `client_min_messages=WARNING` scope used to suppress expected idempotent PostgreSQL NOTICE output.
|
||||||
|
- Added a static regression gate that forbids the lifetime-problematic `raw_sql(&schema).execute(&mut *tx)` PostgreSQL startup path.
|
||||||
|
- No schema migration is required; runtime schema remains V047 and V048 remains the next migration number.
|
||||||
|
- Fixed PostgreSQL header-sync invalidation used by message mutations (including marking/moving messages as spam): the UPSERT now qualifies the existing tenant `user_meta.value`, avoiding `column reference "value" is ambiguous` and HTTP 500 responses.
|
||||||
|
- Added a regression check for tenant SQL rewriting of the qualified `user_meta.value` reference and a static audit covering all PostgreSQL `ON CONFLICT ... DO UPDATE` target-column reads.
|
||||||
|
- Confirmed the equivalent SQLite UPSERT is not ambiguous and MySQL uses `ON DUPLICATE KEY UPDATE`, so no matching backend fix is required.
|
||||||
|
- No schema migration is required; the runtime schema remains V047 and V048 remains the next migration number.
|
||||||
|
- Centralized SQL schema initialization in `db::pool::configure()` so the database session store no longer executes the global schema a second time.
|
||||||
|
- Fixed the duplicate startup DDL for all SQL backends: PostgreSQL no longer repeats `relation ... already exists, skipping` notices, while MySQL and SQLite also avoid the redundant second schema pass.
|
||||||
|
- Scoped PostgreSQL startup schema initialization to a transaction with `client_min_messages=WARNING`, suppressing expected idempotent `CREATE ... IF NOT EXISTS` notices without hiding application/database warnings globally.
|
||||||
|
- Removed the Docker-specific SQLx PostgreSQL notice filter workaround; normal `RUST_LOG=info` is sufficient.
|
||||||
|
- No schema migration is required; the runtime schema remains V047 and V048 remains the next migration number.
|
||||||
|
- Release number kept at 0.2.2 for this cumulative patch set.
|
||||||
|
|
||||||
### 0.2.1
|
### 0.2.1
|
||||||
- Fixed Docker development authentication by documenting and defaulting Compose to `ENVIRONMENT=development`, so session cookies work over direct HTTP while production keeps `Secure` cookies behind HTTPS.
|
- Fixed Docker development authentication by documenting and defaulting Compose to `ENVIRONMENT=development`, so session cookies work over direct HTTP while production keeps `Secure` cookies behind HTTPS.
|
||||||
- Exposed `pgp_enabled` through the auth policy and hid OpenPGP settings, identity controls, composer controls and reader UI when OpenPGP is disabled by administrator policy.
|
- Exposed `pgp_enabled` through the auth policy and hid OpenPGP settings, identity controls, composer controls and reader UI when OpenPGP is disabled by administrator policy.
|
||||||
|
|||||||
Generated
+1
-1
@@ -3508,7 +3508,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustbox"
|
name = "rustbox"
|
||||||
version = "0.2.1"
|
version = "0.2.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
"async-imap",
|
"async-imap",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "rustbox"
|
name = "rustbox"
|
||||||
version = "0.2.1"
|
version = "0.2.2"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
rust-version = "1.88"
|
rust-version = "1.88"
|
||||||
default-run = "rustbox"
|
default-run = "rustbox"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# RustBox attribution / notice
|
# RustBox attribution / notice
|
||||||
|
|
||||||
RustBox 0.2.1 is derived from the Oxi webmail project:
|
RustBox 0.2.2 is derived from the Oxi webmail project:
|
||||||
|
|
||||||
- Upstream: `c0h1b4/oxi`
|
- Upstream: `c0h1b4/oxi`
|
||||||
- Base snapshot: `7f1507c486b78cf33e54beb701dd460d3bd2bd5d`
|
- Base snapshot: `7f1507c486b78cf33e54beb701dd460d3bd2bd5d`
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# RustBox 0.2.1
|
# RustBox 0.2.2
|
||||||
|
|
||||||
RustBox is a webmail derived from `c0h1b4/oxi`. The consumed Oxi revision is recorded in `UPSTREAM_BASE`.
|
RustBox is a webmail derived from `c0h1b4/oxi`. The consumed Oxi revision is recorded in `UPSTREAM_BASE`.
|
||||||
|
|
||||||
@@ -34,7 +34,7 @@ rustbox/
|
|||||||
|
|
||||||
`build.rs` combines the CSS modules from `src/web/css/manifest.txt` and JavaScript modules from `src/web/js/manifest.txt`. Cargo embeds the generated CSS/JS bundles, HTML and SVG sprite in the RustBox executable. There is no standalone source `src/web/app.js`; `src/web/` is the single source of truth and Cargo generates the embedded bundles in `OUT_DIR`.
|
`build.rs` combines the CSS modules from `src/web/css/manifest.txt` and JavaScript modules from `src/web/js/manifest.txt`. Cargo embeds the generated CSS/JS bundles, HTML and SVG sprite in the RustBox executable. There is no standalone source `src/web/app.js`; `src/web/` is the single source of truth and Cargo generates the embedded bundles in `OUT_DIR`.
|
||||||
|
|
||||||
## Storage and search in 0.2.1
|
## Storage and search in 0.2.2
|
||||||
|
|
||||||
RustBox separates three concerns: SQL metadata, blob storage and search. The SQL engine does **not** decide whether `data_dir` may be used. A single-node PostgreSQL/MySQL deployment may still use filesystem blobs and local Tantivy. In cluster mode, local node state is rejected unless the configured backend is explicitly shared.
|
RustBox separates three concerns: SQL metadata, blob storage and search. The SQL engine does **not** decide whether `data_dir` may be used. A single-node PostgreSQL/MySQL deployment may still use filesystem blobs and local Tantivy. In cluster mode, local node state is rejected unless the configured backend is explicitly shared.
|
||||||
|
|
||||||
@@ -217,7 +217,7 @@ Initialize Git after extracting the release archive:
|
|||||||
```bash
|
```bash
|
||||||
git init
|
git init
|
||||||
git add .
|
git add .
|
||||||
git commit -m "RustBox 0.2.1 release"
|
git commit -m "RustBox 0.2.2 release"
|
||||||
./scripts/setup-upstream.sh
|
./scripts/setup-upstream.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+28
-3
@@ -1,4 +1,4 @@
|
|||||||
RustBox 0.2.1 release manifest
|
RustBox 0.2.2 release manifest
|
||||||
|
|
||||||
Architecture:
|
Architecture:
|
||||||
- database_backend = sqlite | mysql | pgsql selects authoritative SQL only
|
- database_backend = sqlite | mysql | pgsql selects authoritative SQL only
|
||||||
@@ -27,19 +27,44 @@ Database/schema:
|
|||||||
- runtime migration V047 adds attachment checksum metadata and search_outbox
|
- runtime migration V047 adds attachment checksum metadata and search_outbox
|
||||||
- next migration number: V048
|
- next migration number: V048
|
||||||
|
|
||||||
|
PostgreSQL message mutation fix (0.2.2):
|
||||||
|
- header_sync_generation UPSERT qualifies the current tenant user_meta.value on the UPDATE RHS
|
||||||
|
- this prevents PostgreSQL from treating `value` as ambiguous during ON CONFLICT DO UPDATE
|
||||||
|
- SQLite/MySQL equivalents were reviewed and require no matching SQL change
|
||||||
|
- no schema migration is required
|
||||||
|
|
||||||
|
PostgreSQL startup compile fix (0.2.2):
|
||||||
|
- multi-statement global schema DDL uses `sqlx::Executor::execute()` on the transaction connection
|
||||||
|
- avoids SQLx raw_sql mutable-connection lifetime inference failures (`Executor is not general enough`)
|
||||||
|
- retains the startup transaction and `SET LOCAL client_min_messages TO WARNING`
|
||||||
|
- no schema migration is required
|
||||||
|
|
||||||
|
Database/session startup (0.2.2):
|
||||||
|
- db::pool::configure() is the single owner of global schema initialization
|
||||||
|
- database-backed session persistence does not execute schema.sql a second time
|
||||||
|
- the fix applies uniformly to SQLite, MySQL and PostgreSQL
|
||||||
|
- PostgreSQL expected IF NOT EXISTS notices are suppressed only inside the startup DDL transaction
|
||||||
|
- no SQL migration is required; runtime schema remains V047
|
||||||
|
|
||||||
|
SQLite test isolation (0.2.2):
|
||||||
|
- test builds initialize each requested SQLite database but do not publish a TempDir-backed path into the process-wide database OnceLock
|
||||||
|
- account/database tests therefore remain isolated when Rust runs them in parallel
|
||||||
|
- a regression test covers deleting the first TempDir before opening a second test database
|
||||||
|
|
||||||
Docker examples:
|
Docker examples:
|
||||||
- docker/compose-single-sqlite.yml
|
- docker/compose-single-sqlite.yml
|
||||||
- docker/compose-postgres-s3-opensearch.yml (PostgreSQL + SILO/S3 + OpenSearch)
|
- docker/compose-postgres-s3-opensearch.yml (PostgreSQL + SILO/S3 + OpenSearch)
|
||||||
- docker/compose-mysql-nfs-opensearch.yml (MySQL + real NFS volume + OpenSearch)
|
- docker/compose-mysql-nfs-opensearch.yml (MySQL + real NFS volume + OpenSearch)
|
||||||
|
|
||||||
Operations/docs:
|
Operations/docs:
|
||||||
- README.md documents 0.2.1 storage/search configuration
|
- README.md documents 0.2.2 storage/search configuration
|
||||||
- docker/README.md documents Compose variants
|
- docker/README.md documents Compose variants
|
||||||
- docs/STORAGE_SEARCH_OPERATIONS.md documents failure modes, shared-storage requirements and migration behavior
|
- docs/STORAGE_SEARCH_OPERATIONS.md documents failure modes, shared-storage requirements and migration behavior
|
||||||
- TODO.md leaves live SILO/MinIO/AWS/Ceph and real multi-node acceptance validation explicitly pending
|
- TODO.md leaves live SILO/MinIO/AWS/Ceph and real multi-node acceptance validation explicitly pending
|
||||||
|
|
||||||
Build/release metadata:
|
Build/release metadata:
|
||||||
- Cargo.toml version = 0.2.1
|
- `scripts/package-release.sh` runs the full Rust release gate before creating ZIP/SHA256 artifacts
|
||||||
|
- Cargo.toml version = 0.2.2
|
||||||
- rust-toolchain.toml pins Rust 1.88.0
|
- rust-toolchain.toml pins Rust 1.88.0
|
||||||
- Cargo.lock is intentionally not shipped because this environment could not regenerate it after dependency changes
|
- Cargo.lock is intentionally not shipped because this environment could not regenerate it after dependency changes
|
||||||
- CI and scripts/build-release.sh generate Cargo.lock, then run locked tests/Clippy/release build
|
- CI and scripts/build-release.sh generate Cargo.lock, then run locked tests/Clippy/release build
|
||||||
+17
-17
@@ -1,30 +1,30 @@
|
|||||||
RustBox 0.2.1 - final source validation
|
RustBox 0.2.2 - final source validation
|
||||||
|
|
||||||
Completed in this environment:
|
Completed in this environment:
|
||||||
- offline release validator: scripts/validate-release-static.py (144/144 checks passed)
|
- offline release validator: scripts/validate-release-static.py (164/164 checks passed)
|
||||||
- all maintained TOML examples parse successfully
|
- all maintained TOML examples parse successfully
|
||||||
- Docker Compose topology/config references are validated by the offline release validator
|
- Docker Compose topology/config references are validated by the offline release validator
|
||||||
- JSON files parse successfully
|
- JSON files parse successfully
|
||||||
- browser JavaScript assembled in src/web/js/manifest.txt order passes node --check
|
- browser JavaScript assembled in src/web/js/manifest.txt order passes node --check
|
||||||
- shell scripts pass bash -n
|
- shell scripts pass bash -n
|
||||||
- Python helper scripts compile successfully
|
- Python helper scripts compile successfully
|
||||||
- Rust source delimiter sanity check passes after removing partial-patch leftovers
|
- targeted source checks validate the PostgreSQL header-sync UPSERT and startup schema-initialization control flow
|
||||||
- git diff --check passes on the final source tree
|
- the PostgreSQL startup DDL path now uses `Executor::execute()` instead of the lifetime-problematic `raw_sql(...).execute(&mut PgConnection)` path; compiler validation remains part of the Rust-host release gate
|
||||||
|
- whitespace/patch-artifact checks pass on the final source tree (this packaged worktree has no `.git` metadata)
|
||||||
- no Cargo.lock, *.orig, *.rej, temporary FIX/APPLY scripts, or patch leftovers are shipped
|
- no Cargo.lock, *.orig, *.rej, temporary FIX/APPLY scripts, or patch leftovers are shipped
|
||||||
- regression source checks retain the 0.2.0 release gate and add 0.2.1 policy/backpressure checks
|
- regression source checks retain the PostgreSQL header-sync qualification audit and now reject the SQLx raw_sql mutable-connection lifetime regression in startup DDL
|
||||||
|
- SQLite unit-test configuration no longer leaks a temporary database path through the process-wide OnceLock; a dedicated regression test covers deleted TempDir reuse
|
||||||
|
- the static release contract now verifies that build-release.sh requires cargo test, Clippy with -D warnings, and a locked release build
|
||||||
|
- scripts/package-release.sh runs the full build gate before creating the source ZIP and SHA256, so packaging cannot succeed after a failed Rust test/Clippy/build gate
|
||||||
|
|
||||||
0.2.1 fixes included:
|
0.2.2 fixes included:
|
||||||
- OpenPGP policy visibility follows pgp_enabled and disabled OpenPGP is hidden from the UI
|
- PostgreSQL startup schema initialization no longer triggers SQLx `Executor is not general enough`; raw multi-statement DDL is executed directly through `Executor::execute()` inside the existing transaction
|
||||||
- OpenSearch 429/flood-stage failures trigger worker backoff instead of per-event warning storms
|
- PostgreSQL header-sync invalidation qualifies the existing tenant user_meta.value in ON CONFLICT DO UPDATE, eliminating the ambiguous-column 500 seen during message mutations such as marking spam
|
||||||
- Docker development defaults session cookies for direct HTTP and documents the production HTTPS requirement
|
- tenant SQL rewriting has a regression test proving the qualified user_meta reference is rewritten to the tenant table
|
||||||
- cluster_mode controls the implicit data_dir default; PostgreSQL/MySQL remain valid with local data_dir backends in single-node deployments
|
- SQLite and MySQL equivalents were reviewed and do not have the PostgreSQL ambiguity
|
||||||
- storage/search configuration comes only from [storage] and [search]
|
- 0.2.2 single-schema-owner and PostgreSQL NOTICE-noise fixes remain in place
|
||||||
- removed pre-0.2 top-level storage/search switches and the tantivy backend alias
|
- no database migration is required; V047 remains current and V048 remains next
|
||||||
- removed runtime legacy attachment read-through; attachment access uses logical BlobStorage keys
|
- 0.2.1 OpenPGP visibility, OpenSearch backpressure and HTTP/dev cookie fixes remain in place
|
||||||
- fixed search_outbox tenant/import table parity
|
|
||||||
- fixed nested SQLite transactions in message move paths
|
|
||||||
- fixed the Clippy -D warnings failures reported for examples, config, storage, search, routes, realtime and formatting
|
|
||||||
- scripts/build-release.sh removes stale Cargo.lock, runs the static gate, generates a fresh lockfile for locked Cargo commands, and cleans it on exit
|
|
||||||
|
|
||||||
Not executed in this packaging environment:
|
Not executed in this packaging environment:
|
||||||
- cargo fmt/test/clippy/build with Rust 1.88, because Rust/Cargo is not installed here and outbound DNS is unavailable
|
- cargo fmt/test/clippy/build with Rust 1.88, because Rust/Cargo is not installed here and outbound DNS is unavailable
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
# RustBox 0.2.1
|
# RustBox 0.2.2
|
||||||
host = "127.0.0.1"
|
host = "127.0.0.1"
|
||||||
port = 3001
|
port = 3001
|
||||||
environment = "production"
|
environment = "production"
|
||||||
|
|||||||
+1
-1
@@ -164,4 +164,4 @@ docker compose -p rustbox -f compose-postgres-s3-opensearch.yml exec opensearch
|
|||||||
-d '{"index.blocks.read_only_allow_delete":null}'
|
-d '{"index.blocks.read_only_allow_delete":null}'
|
||||||
```
|
```
|
||||||
|
|
||||||
RustBox 0.2.1 backs off its OpenSearch outbox worker after these 429/flood-stage responses, so a full disk no longer produces one warning per queued event.
|
RustBox 0.2.2 backs off its OpenSearch outbox worker after these 429/flood-stage responses, so a full disk no longer produces one warning per queued event.
|
||||||
@@ -76,7 +76,7 @@ services:
|
|||||||
SMTP_HOST: ${SMTP_HOST:-mail.example.com}
|
SMTP_HOST: ${SMTP_HOST:-mail.example.com}
|
||||||
SMTP_PORT: ${SMTP_PORT:-587}
|
SMTP_PORT: ${SMTP_PORT:-587}
|
||||||
TLS_ENABLED: ${TLS_ENABLED:-true}
|
TLS_ENABLED: ${TLS_ENABLED:-true}
|
||||||
RUST_LOG: ${RUST_LOG:-info,sqlx::postgres::notice=warn}
|
RUST_LOG: ${RUST_LOG:-info}
|
||||||
DATABASE_URL: postgres://rustbox:${POSTGRES_PASSWORD:-rustbox-dev-password}@postgres:5432/rustbox
|
DATABASE_URL: postgres://rustbox:${POSTGRES_PASSWORD:-rustbox-dev-password}@postgres:5432/rustbox
|
||||||
SESSION_ENCRYPTION_KEY: ${SESSION_ENCRYPTION_KEY:-MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=}
|
SESSION_ENCRYPTION_KEY: ${SESSION_ENCRYPTION_KEY:-MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=}
|
||||||
AWS_ACCESS_KEY_ID: ${S3_ACCESS_KEY:-rustbox}
|
AWS_ACCESS_KEY_ID: ${S3_ACCESS_KEY:-rustbox}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# RustBox 0.2.1 - implementation status
|
# RustBox 0.2.2 - implementation status
|
||||||
|
|
||||||
This file tracks the `0.2.1` source snapshot. Storage/search scope and outstanding acceptance work are tracked in `TODO.md`. A feature is only listed as implemented when working code exists in this source tree; partial items are called out explicitly.
|
This file tracks the `0.2.2` source snapshot. Storage/search scope and outstanding acceptance work are tracked in `TODO.md`. A feature is only listed as implemented when working code exists in this source tree; partial items are called out explicitly.
|
||||||
|
|
||||||
## 0.2.1 storage/search architecture
|
## 0.2.2 storage/search architecture
|
||||||
|
|
||||||
- SQL backend selection (`sqlite`/`mysql`/`pgsql`) is independent from blob storage and search backend selection.
|
- SQL backend selection (`sqlite`/`mysql`/`pgsql`) is independent from blob storage and search backend selection.
|
||||||
- Cluster mode clears an implicit/default `data_dir`; PostgreSQL/MySQL single-node deployments may still intentionally use `data_dir`-backed filesystem storage and local Tantivy.
|
- Cluster mode clears an implicit/default `data_dir`; PostgreSQL/MySQL single-node deployments may still intentionally use `data_dir`-backed filesystem storage and local Tantivy.
|
||||||
@@ -11,6 +11,7 @@ This file tracks the `0.2.1` source snapshot. Storage/search scope and outstandi
|
|||||||
- OpenSearch is available as the cluster-capable indexer. Local Tantivy remains single-node only; search may also be disabled.
|
- OpenSearch is available as the cluster-capable indexer. Local Tantivy remains single-node only; search may also be disabled.
|
||||||
- Search mutations use a durable SQL outbox with claim/lease, retry/backoff and dead-letter state; the index remains rebuildable derived state.
|
- Search mutations use a durable SQL outbox with claim/lease, retry/backoff and dead-letter state; the index remains rebuildable derived state.
|
||||||
- V047 adds attachment checksum metadata and the search outbox across SQLite, MySQL and PostgreSQL.
|
- V047 adds attachment checksum metadata and the search outbox across SQLite, MySQL and PostgreSQL.
|
||||||
|
- SQL global-schema initialization is centralized in `db::pool::configure()`; database-backed sessions do not repeat startup DDL for SQLite, MySQL or PostgreSQL.
|
||||||
- Docker Compose examples cover standalone SQLite, PostgreSQL + SILO (S3-compatible) + OpenSearch, and MySQL + NFS + OpenSearch.
|
- Docker Compose examples cover standalone SQLite, PostgreSQL + SILO (S3-compatible) + OpenSearch, and MySQL + NFS + OpenSearch.
|
||||||
- Live SILO/MinIO/AWS S3/Ceph RGW, live NFS multi-host, live OpenSearch and full multi-node failover acceptance remain release-environment validation items and are not represented as completed here.
|
- Live SILO/MinIO/AWS S3/Ceph RGW, live NFS multi-host, live OpenSearch and full multi-node failover acceptance remain release-environment validation items and are not represented as completed here.
|
||||||
|
|
||||||
|
|||||||
+5
-6
@@ -3,15 +3,14 @@
|
|||||||
1. Update the version in `Cargo.toml` and the RustBox package entry in `Cargo.lock`.
|
1. Update the version in `Cargo.toml` and the RustBox package entry in `Cargo.lock`.
|
||||||
2. Confirm `UPSTREAM_BASE` is the Oxi revision actually consumed by the release.
|
2. Confirm `UPSTREAM_BASE` is the Oxi revision actually consumed by the release.
|
||||||
3. Ensure no TypeScript sources or separate frontend build dependencies are present.
|
3. Ensure no TypeScript sources or separate frontend build dependencies are present.
|
||||||
4. Run:
|
4. Run the release gate and package only after it passes:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo fmt --all -- --check
|
./scripts/package-release.sh
|
||||||
cargo test --locked
|
|
||||||
cargo clippy --locked --all-targets -- -D warnings
|
|
||||||
./scripts/build-release.sh
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`package-release.sh` runs `scripts/build-release.sh` first. That gate regenerates the lockfile, runs the static validator, `cargo fmt --check`, the full `cargo test --locked` suite, Clippy with `-D warnings`, and the locked release build. The ZIP and SHA256 are created only after all of those steps succeed.
|
||||||
|
|
||||||
5. Verify:
|
5. Verify:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -27,7 +26,7 @@ RUSTBOX_CONFIG=./config.dev.toml ./target/release/rustbox --check-config
|
|||||||
|
|
||||||
Browser JavaScript source lives in functional modules under `src/web/js/`, ordered by `src/web/js/manifest.txt`. `build.rs` concatenates those modules into the embedded `rustbox-ui.js` asset during every Cargo build. The repository has no duplicate `ui/` snapshot; `src/web/` is the only browser UI source.
|
Browser JavaScript source lives in functional modules under `src/web/js/`, ordered by `src/web/js/manifest.txt`. `build.rs` concatenates those modules into the embedded `rustbox-ui.js` asset during every Cargo build. The repository has no duplicate `ui/` snapshot; `src/web/` is the only browser UI source.
|
||||||
|
|
||||||
## Updating an existing checkout to 0.2.1
|
## Updating an existing checkout to 0.2.2
|
||||||
|
|
||||||
Do not overlay the release with `cp *` or another command that skips dotfiles. That can retain stale `.github`, `.env.example`, or `Cargo.lock` files.
|
Do not overlay the release with `cp *` or another command that skips dotfiles. That can retain stale `.github`, `.env.example`, or `Cargo.lock` files.
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# RustBox 0.2.1 storage/search operations
|
# RustBox 0.2.2 storage/search operations
|
||||||
|
|
||||||
RustBox 0.2.1 treats SQL, blob storage and search as independent capabilities. The database engine does not decide whether `data_dir` is allowed.
|
RustBox 0.2.2 treats SQL, blob storage and search as independent capabilities. The database engine does not decide whether `data_dir` is allowed.
|
||||||
|
|
||||||
## Deployment matrix
|
## Deployment matrix
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Database schemas and migrations
|
# Database schemas and migrations
|
||||||
|
|
||||||
RustBox 0.2.1 keeps SQL, blob storage and search as independent concerns:
|
RustBox 0.2.2 keeps SQL, blob storage and search as independent concerns:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
database_backend = sqlite | mysql | pgsql
|
database_backend = sqlite | mysql | pgsql
|
||||||
@@ -34,7 +34,7 @@ Account data is isolated inside the selected physical database with a determinis
|
|||||||
|
|
||||||
Every schema-changing release must add the same logical migration for all supported SQL engines and register it in `src/db/pool.rs`. Do not update only `schema.sql`: fresh installs use the baseline while existing installations use the numbered migration path.
|
Every schema-changing release must add the same logical migration for all supported SQL engines and register it in `src/db/pool.rs`. Do not update only `schema.sql`: fresh installs use the baseline while existing installations use the numbered migration path.
|
||||||
|
|
||||||
The next migration number after RustBox 0.2.1 is **V048**.
|
The next migration number after RustBox 0.2.2 is **V048**.
|
||||||
|
|
||||||
Redis and Redis Sentinel do not use SQL migrations.
|
Redis and Redis Sentinel do not use SQL migrations.
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# RustBox 0.2.1 - packaged production configuration (DEB/RPM)
|
# RustBox 0.2.2 - packaged production configuration (DEB/RPM)
|
||||||
#
|
#
|
||||||
# This file contains all supported RustBox configuration options.
|
# This file contains all supported RustBox configuration options.
|
||||||
# Commented options are optional and do not change the packaged defaults.
|
# Commented options are optional and do not change the packaged defaults.
|
||||||
|
|||||||
Executable
+30
@@ -0,0 +1,30 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "$ROOT"
|
||||||
|
|
||||||
|
./scripts/build-release.sh
|
||||||
|
|
||||||
|
VERSION="$(python3 - <<'PY'
|
||||||
|
import tomllib
|
||||||
|
from pathlib import Path
|
||||||
|
with Path('Cargo.toml').open('rb') as fh:
|
||||||
|
print(tomllib.load(fh)['package']['version'])
|
||||||
|
PY
|
||||||
|
)"
|
||||||
|
|
||||||
|
OUT_DIR="${1:-$ROOT/dist}"
|
||||||
|
mkdir -p "$OUT_DIR"
|
||||||
|
ARCHIVE="$OUT_DIR/RustBox-$VERSION.zip"
|
||||||
|
CHECKSUM="$ARCHIVE.sha256"
|
||||||
|
|
||||||
|
python3 make_zip.py "$ARCHIVE"
|
||||||
|
(
|
||||||
|
cd "$(dirname "$ARCHIVE")"
|
||||||
|
sha256sum "$(basename "$ARCHIVE")" > "$(basename "$CHECKSUM")"
|
||||||
|
)
|
||||||
|
|
||||||
|
printf 'Release archive: %s\n' "$ARCHIVE"
|
||||||
|
printf 'SHA256: '
|
||||||
|
cat "$CHECKSUM"
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Static/offline release checks for RustBox 0.2.1.
|
"""Static/offline release checks for RustBox 0.2.2.
|
||||||
|
|
||||||
These checks intentionally avoid pretending to replace `cargo test`: they validate
|
These checks intentionally avoid pretending to replace `cargo test`: they validate
|
||||||
configuration, SQL migrations, Docker examples, query-catalog parity and release
|
configuration, SQL migrations, Docker examples, query-catalog parity and release
|
||||||
@@ -49,19 +49,26 @@ def extract_section(text: str, begin: str, end: str) -> str:
|
|||||||
|
|
||||||
# Release/toolchain metadata.
|
# Release/toolchain metadata.
|
||||||
cargo = tomllib.loads(read("Cargo.toml"))
|
cargo = tomllib.loads(read("Cargo.toml"))
|
||||||
require(cargo["package"]["version"] == "0.2.1", "Cargo package version is 0.2.1")
|
require(cargo["package"]["version"] == "0.2.2", "Cargo package version is 0.2.2")
|
||||||
require(cargo["package"]["rust-version"] == "1.88", "Cargo rust-version is 1.88")
|
require(cargo["package"]["rust-version"] == "1.88", "Cargo rust-version is 1.88")
|
||||||
toolchain = tomllib.loads(read("rust-toolchain.toml"))
|
toolchain = tomllib.loads(read("rust-toolchain.toml"))
|
||||||
require(toolchain["toolchain"]["channel"] == "1.88.0", "rust-toolchain pins Rust 1.88.0")
|
require(toolchain["toolchain"]["channel"] == "1.88.0", "rust-toolchain pins Rust 1.88.0")
|
||||||
require(not (ROOT / "Cargo.lock").exists(), "stale Cargo.lock is not shipped")
|
require(not (ROOT / "Cargo.lock").exists(), "stale Cargo.lock is not shipped")
|
||||||
require("cargo generate-lockfile" in read("scripts/build-release.sh"), "release script regenerates Cargo.lock")
|
require("cargo generate-lockfile" in read("scripts/build-release.sh"), "release script regenerates Cargo.lock")
|
||||||
|
release_script = read("scripts/build-release.sh")
|
||||||
|
require("cargo test --locked" in release_script, "release script requires the full Rust test suite")
|
||||||
|
require("cargo clippy --locked --all-targets -- -D warnings" in release_script, "release script requires warning-free Clippy")
|
||||||
|
require("cargo build --release --locked" in release_script, "release script requires a locked release build")
|
||||||
|
package_script = read("scripts/package-release.sh")
|
||||||
|
require("./scripts/build-release.sh" in package_script, "release packaging runs the full build gate before creating artifacts")
|
||||||
|
require("make_zip.py" in package_script and "sha256sum" in package_script, "release packaging creates ZIP and SHA256 only after the build gate")
|
||||||
require("cargo generate-lockfile" in read(".github/workflows/ci.yml"), "CI regenerates Cargo.lock before --locked checks")
|
require("cargo generate-lockfile" in read(".github/workflows/ci.yml"), "CI regenerates Cargo.lock before --locked checks")
|
||||||
require("validate-release-static.py" in read("scripts/build-release.sh"), "release script runs offline/static release gate")
|
require("validate-release-static.py" in read("scripts/build-release.sh"), "release script runs offline/static release gate")
|
||||||
require("validate-release-static.py" in read(".github/workflows/ci.yml"), "CI runs offline/static release gate")
|
require("validate-release-static.py" in read(".github/workflows/ci.yml"), "CI runs offline/static release gate")
|
||||||
require(read("README.md").startswith("# RustBox 0.2.1"), "README identifies RustBox 0.2.1")
|
require(read("README.md").startswith("# RustBox 0.2.2"), "README identifies RustBox 0.2.2")
|
||||||
require(read("RELEASE-MANIFEST.txt").startswith("RustBox 0.2.1"), "release manifest identifies RustBox 0.2.1")
|
require(read("RELEASE-MANIFEST.txt").startswith("RustBox 0.2.2"), "release manifest identifies RustBox 0.2.2")
|
||||||
require(read("RELEASE-VALIDATION.txt").startswith("RustBox 0.2.1"), "release validation identifies RustBox 0.2.1")
|
require(read("RELEASE-VALIDATION.txt").startswith("RustBox 0.2.2"), "release validation identifies RustBox 0.2.2")
|
||||||
require(read("docs/IMPLEMENTATION_STATUS.md").startswith("# RustBox 0.2.1"), "implementation status identifies RustBox 0.2.1")
|
require(read("docs/IMPLEMENTATION_STATUS.md").startswith("# RustBox 0.2.2"), "implementation status identifies RustBox 0.2.2")
|
||||||
require((ROOT / "docs/STORAGE_SEARCH_OPERATIONS.md").is_file(), "storage/search operations guide exists")
|
require((ROOT / "docs/STORAGE_SEARCH_OPERATIONS.md").is_file(), "storage/search operations guide exists")
|
||||||
require('imap_host = ""' not in read("config.dev.toml"), "development config does not override IMAP host with an invalid empty value")
|
require('imap_host = ""' not in read("config.dev.toml"), "development config does not override IMAP host with an invalid empty value")
|
||||||
require(not re.search(r"(?m)^IMAP_HOST=\s*$", read(".env.example")), "environment example does not set an invalid empty IMAP host")
|
require(not re.search(r"(?m)^IMAP_HOST=\s*$", read(".env.example")), "environment example does not set an invalid empty IMAP host")
|
||||||
@@ -70,6 +77,78 @@ require("setOpenPgpAvailability" in read("src/web/js/72-openpgp-identities.js"),
|
|||||||
require("is_opensearch_backpressure_error" in read("src/search/engine.rs"), "OpenSearch worker classifies backpressure failures")
|
require("is_opensearch_backpressure_error" in read("src/search/engine.rs"), "OpenSearch worker classifies backpressure failures")
|
||||||
require("worker pass failed; backing off" in read("src/search/engine.rs"), "OpenSearch worker backs off after failures")
|
require("worker pass failed; backing off" in read("src/search/engine.rs"), "OpenSearch worker backs off after failures")
|
||||||
require("ENVIRONMENT: ${ENVIRONMENT:-development}" in read("docker/compose-postgres-s3-opensearch.yml"), "PostgreSQL Compose defaults direct HTTP to development cookie mode")
|
require("ENVIRONMENT: ${ENVIRONMENT:-development}" in read("docker/compose-postgres-s3-opensearch.yml"), "PostgreSQL Compose defaults direct HTTP to development cookie mode")
|
||||||
|
session_db = read("src/session_store/database.rs")
|
||||||
|
db_pool = read("src/db/pool.rs")
|
||||||
|
require("crate::db::pool::configure(config).await?" in session_db, "database session store delegates schema initialization to db::pool::configure")
|
||||||
|
require("global_schema_for_backend" not in session_db, "database session store does not run a second backend schema pass")
|
||||||
|
require("sqlx::raw_sql(&schema)" not in session_db and "execute_batch(&schema)" not in session_db, "session store avoids duplicate schema DDL for SQLite/MySQL/PostgreSQL")
|
||||||
|
require("SET LOCAL client_min_messages TO WARNING" in db_pool, "PostgreSQL idempotent startup DDL suppresses expected NOTICE noise locally")
|
||||||
|
require("Executor as _" in db_pool, "PostgreSQL startup imports SQLx Executor for direct raw DDL execution")
|
||||||
|
require(".execute(schema.as_str())" in db_pool, "PostgreSQL startup executes schema DDL through Executor instead of raw_sql")
|
||||||
|
require(not re.search(r"raw_sql\(&schema\)\s*\.execute\(&mut \*tx\)", db_pool), "PostgreSQL startup avoids raw_sql mutable-connection lifetime regression")
|
||||||
|
require("sqlite_test_configuration_does_not_pin_deleted_tempdir" in db_pool, "SQLite test isolation has a regression test for deleted TempDir reuse")
|
||||||
|
require("let config = AppConfig {" in db_pool and "let mut config = AppConfig::default();" not in db_pool, "SQLite isolation regression test is Clippy-clean with struct initialization")
|
||||||
|
require("each test's own data_dir" in db_pool and "#[cfg(test)]" in db_pool, "SQLite test configuration does not publish a temporary database path globally")
|
||||||
|
require("RUST_LOG: ${RUST_LOG:-info,sqlx::postgres::notice=warn}" not in read("docker/compose-postgres-s3-opensearch.yml"), "PostgreSQL Compose no longer depends on a notice-filter workaround")
|
||||||
|
pgsql_queries = read("src/queries/pgsql/queries.rs")
|
||||||
|
require("ON CONFLICT(key) DO UPDATE SET value = CAST(user_meta.value AS INTEGER) + 1" in pgsql_queries, "PostgreSQL header-sync UPSERT qualifies the existing target value")
|
||||||
|
require("tenant_rewriter_prefixes_header_sync_target_qualifier" in db_pool, "tenant SQL rewriter has a regression test for qualified header-sync value")
|
||||||
|
mysql_queries = read("src/queries/mysql/queries.rs")
|
||||||
|
require("ON DUPLICATE KEY UPDATE value = CAST(value AS SIGNED) + 1" in mysql_queries, "MySQL header-sync uses its non-EXCLUDED duplicate-key form")
|
||||||
|
|
||||||
|
|
||||||
|
# PostgreSQL UPSERT audit: any read of an INSERT target column inside
|
||||||
|
# ON CONFLICT ... DO UPDATE must be qualified as either excluded.<column>
|
||||||
|
# or <target_table>.<column>. PostgreSQL otherwise treats names such as
|
||||||
|
# `value` as ambiguous between the existing target row and EXCLUDED.
|
||||||
|
def postgres_upsert_ambiguities(source: str) -> list[str]:
|
||||||
|
const_re = re.compile(
|
||||||
|
r'pub const\s+(\w+)\s*:\s*&str\s*=\s*(r#".*?"#|"(?:\\.|[^"\\])*")\s*;',
|
||||||
|
re.S,
|
||||||
|
)
|
||||||
|
problems: list[str] = []
|
||||||
|
for const_name, literal in const_re.findall(source):
|
||||||
|
if literal.startswith('r#"'):
|
||||||
|
sql = literal[3:-2]
|
||||||
|
else:
|
||||||
|
sql = bytes(literal[1:-1], 'utf-8').decode('unicode_escape')
|
||||||
|
if not re.search(r'ON\s+CONFLICT.*DO\s+UPDATE\s+SET', sql, re.I | re.S):
|
||||||
|
continue
|
||||||
|
insert = re.search(
|
||||||
|
r'INSERT\s+INTO\s+([A-Za-z_][\w]*)\s*\((.*?)\)\s*VALUES',
|
||||||
|
sql,
|
||||||
|
re.I | re.S,
|
||||||
|
)
|
||||||
|
if insert is None:
|
||||||
|
problems.append(f'{const_name}: unable to parse INSERT target')
|
||||||
|
continue
|
||||||
|
table = insert.group(1)
|
||||||
|
columns = [column.strip().strip('"') for column in insert.group(2).split(',')]
|
||||||
|
update = re.split(r'DO\s+UPDATE\s+SET', sql, maxsplit=1, flags=re.I)[1]
|
||||||
|
scan = re.sub(r"'(?:''|[^'])*'", '', update)
|
||||||
|
for column in columns:
|
||||||
|
candidate = re.sub(
|
||||||
|
rf'\b(?:excluded|{re.escape(table)})\s*\.\s*{re.escape(column)}\b',
|
||||||
|
'',
|
||||||
|
scan,
|
||||||
|
flags=re.I,
|
||||||
|
)
|
||||||
|
candidate = re.sub(
|
||||||
|
rf'(?:(?<=^)|(?<=,))\s*{re.escape(column)}\s*=',
|
||||||
|
'=',
|
||||||
|
candidate,
|
||||||
|
flags=re.I,
|
||||||
|
)
|
||||||
|
if re.search(rf'(?<!\.)\b{re.escape(column)}\b', candidate, re.I):
|
||||||
|
problems.append(f'{const_name}: unqualified target read `{column}` in {table} UPSERT')
|
||||||
|
return problems
|
||||||
|
|
||||||
|
pgsql_upsert_ambiguities = postgres_upsert_ambiguities(pgsql_queries)
|
||||||
|
require(
|
||||||
|
not pgsql_upsert_ambiguities,
|
||||||
|
'PostgreSQL ON CONFLICT target-column reads are fully qualified'
|
||||||
|
+ (f" ({'; '.join(pgsql_upsert_ambiguities)})" if pgsql_upsert_ambiguities else ''),
|
||||||
|
)
|
||||||
|
|
||||||
# Parse all maintained TOML examples.
|
# Parse all maintained TOML examples.
|
||||||
toml_files = [
|
toml_files = [
|
||||||
@@ -152,6 +231,11 @@ try:
|
|||||||
require(outbox is not None, "fresh SQLite schema has search_outbox")
|
require(outbox is not None, "fresh SQLite schema has search_outbox")
|
||||||
outbox_columns = {row[1] for row in conn.execute("PRAGMA table_info(u_test__search_outbox)")}
|
outbox_columns = {row[1] for row in conn.execute("PRAGMA table_info(u_test__search_outbox)")}
|
||||||
require("claimed_until" in outbox_columns, "fresh SQLite search_outbox separates worker lease from retry availability")
|
require("claimed_until" in outbox_columns, "fresh SQLite search_outbox separates worker lease from retry availability")
|
||||||
|
sqlite_header_sync = "INSERT INTO u_test__user_meta (key, value) VALUES ('header_sync_generation', '1') ON CONFLICT(key) DO UPDATE SET value = CAST(value AS INTEGER) + 1"
|
||||||
|
conn.execute(sqlite_header_sync)
|
||||||
|
conn.execute(sqlite_header_sync)
|
||||||
|
sqlite_generation = conn.execute("SELECT value FROM u_test__user_meta WHERE key='header_sync_generation'").fetchone()
|
||||||
|
require(sqlite_generation == ("2",), "SQLite header-sync UPSERT remains unambiguous and increments generation")
|
||||||
account_default = conn.execute("PRAGMA table_info(rustbox_accounts)").fetchall()
|
account_default = conn.execute("PRAGMA table_info(rustbox_accounts)").fetchall()
|
||||||
version_default = next((row[4] for row in account_default if row[1] == "schema_version"), None)
|
version_default = next((row[4] for row in account_default if row[1] == "schema_version"), None)
|
||||||
require(str(version_default).strip("'\"") == "46", "fresh global compatibility baseline remains V046 before tenant registration")
|
require(str(version_default).strip("'\"") == "46", "fresh global compatibility baseline remains V046 before tenant registration")
|
||||||
|
|||||||
+75
-12
@@ -9,7 +9,7 @@ use rusqlite::OptionalExtension as _;
|
|||||||
use rusqlite::types::{ToSql, ToSqlOutput, Value};
|
use rusqlite::types::{ToSql, ToSqlOutput, Value};
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
use sqlx::pool::PoolConnection;
|
use sqlx::pool::PoolConnection;
|
||||||
use sqlx::{MySql, MySqlPool, PgPool, Postgres, Row as SqlxRow};
|
use sqlx::{Executor as _, MySql, MySqlPool, PgPool, Postgres, Row as SqlxRow};
|
||||||
use tokio::runtime::{Builder, Handle};
|
use tokio::runtime::{Builder, Handle};
|
||||||
use tokio::sync::Mutex as AsyncMutex;
|
use tokio::sync::Mutex as AsyncMutex;
|
||||||
|
|
||||||
@@ -180,14 +180,6 @@ fn global_schema(schema: &str) -> Result<String, String> {
|
|||||||
schema_section(schema, "-- RUSTBOX:GLOBAL-BEGIN", "-- RUSTBOX:GLOBAL-END")
|
schema_section(schema, "-- RUSTBOX:GLOBAL-BEGIN", "-- RUSTBOX:GLOBAL-END")
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn global_schema_for_backend(backend: DatabaseBackend) -> Result<String, String> {
|
|
||||||
match backend {
|
|
||||||
DatabaseBackend::Sqlite => global_schema(SQLITE_SCHEMA),
|
|
||||||
DatabaseBackend::Mysql => global_schema(MYSQL_SCHEMA),
|
|
||||||
DatabaseBackend::Pgsql => global_schema(PGSQL_SCHEMA),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn tenant_schema(schema: &str, prefix: &str) -> Result<String, String> {
|
fn tenant_schema(schema: &str, prefix: &str) -> Result<String, String> {
|
||||||
Ok(
|
Ok(
|
||||||
schema_section(schema, "-- RUSTBOX:TENANT-BEGIN", "-- RUSTBOX:TENANT-END")?
|
schema_section(schema, "-- RUSTBOX:TENANT-BEGIN", "-- RUSTBOX:TENANT-END")?
|
||||||
@@ -236,7 +228,22 @@ pub async fn configure(config: &AppConfig) -> Result<(), String> {
|
|||||||
.map_err(|e| format!("failed to configure SQLite database: {e}"))?;
|
.map_err(|e| format!("failed to configure SQLite database: {e}"))?;
|
||||||
conn.execute_batch(&global_schema(SQLITE_SCHEMA)?)
|
conn.execute_batch(&global_schema(SQLITE_SCHEMA)?)
|
||||||
.map_err(|e| format!("SQLite global schema initialization failed: {e}"))?;
|
.map_err(|e| format!("SQLite global schema initialization failed: {e}"))?;
|
||||||
ConfiguredDatabase::Sqlite { path }
|
|
||||||
|
// Unit tests create many independent temporary data directories in one
|
||||||
|
// process. Pinning the first SQLite path in the process-wide OnceLock
|
||||||
|
// makes later tests reuse a TempDir that may already have been deleted,
|
||||||
|
// causing a cascade of "unable to open database file" failures.
|
||||||
|
// Initialize the requested test database, but let open_user_db() resolve
|
||||||
|
// each test's own data_dir instead of publishing this path globally.
|
||||||
|
#[cfg(test)]
|
||||||
|
{
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(test))]
|
||||||
|
{
|
||||||
|
ConfiguredDatabase::Sqlite { path }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
DatabaseBackend::Mysql => {
|
DatabaseBackend::Mysql => {
|
||||||
let url = config
|
let url = config
|
||||||
@@ -280,10 +287,29 @@ pub async fn configure(config: &AppConfig) -> Result<(), String> {
|
|||||||
let schema = global_schema(PGSQL_SCHEMA)?;
|
let schema = global_schema(PGSQL_SCHEMA)?;
|
||||||
let init_pool = pool.clone();
|
let init_pool = pool.clone();
|
||||||
runtime_wait(&runtime, async move {
|
runtime_wait(&runtime, async move {
|
||||||
sqlx::raw_sql(&schema)
|
// PostgreSQL emits NOTICE for every CREATE ... IF NOT EXISTS that
|
||||||
.execute(&init_pool)
|
// already exists. Keep expected idempotent startup DDL quiet without
|
||||||
|
// changing application-wide logging or server settings.
|
||||||
|
let mut tx = init_pool
|
||||||
|
.begin()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("PostgreSQL global schema transaction failed: {e}"))?;
|
||||||
|
sqlx::query("SET LOCAL client_min_messages TO WARNING")
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("failed to quiet PostgreSQL schema notices: {e}"))?;
|
||||||
|
// `raw_sql(...).execute(&mut PgConnection)` can trigger SQLx's
|
||||||
|
// "Executor is not general enough" lifetime error when this
|
||||||
|
// future is spawned onto the dedicated database runtime. Execute
|
||||||
|
// the unprepared multi-statement DDL directly through `Executor`
|
||||||
|
// instead; this is SQLx's supported path for raw DDL batches.
|
||||||
|
(&mut *tx)
|
||||||
|
.execute(schema.as_str())
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("PostgreSQL global schema initialization failed: {e}"))?;
|
.map_err(|e| format!("PostgreSQL global schema initialization failed: {e}"))?;
|
||||||
|
tx.commit()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("PostgreSQL global schema commit failed: {e}"))?;
|
||||||
Ok(())
|
Ok(())
|
||||||
})?;
|
})?;
|
||||||
ConfiguredDatabase::Pgsql { pool, runtime }
|
ConfiguredDatabase::Pgsql { pool, runtime }
|
||||||
@@ -2287,6 +2313,31 @@ pub fn open_test_db() -> Connection {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn sqlite_test_configuration_does_not_pin_deleted_tempdir() {
|
||||||
|
let first = tempfile::tempdir().expect("failed to create first temp dir");
|
||||||
|
let config = AppConfig {
|
||||||
|
database_backend: "sqlite".to_string(),
|
||||||
|
database_url: None,
|
||||||
|
data_dir: first.path().to_string_lossy().into_owned(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
configure(&config)
|
||||||
|
.await
|
||||||
|
.expect("failed to initialize first SQLite test database");
|
||||||
|
assert!(first.path().join("rustbox.sqlite").is_file());
|
||||||
|
drop(first);
|
||||||
|
|
||||||
|
let second = tempfile::tempdir().expect("failed to create second temp dir");
|
||||||
|
let second_dir = second.path().to_string_lossy().into_owned();
|
||||||
|
let connection = open_user_db(&second_dir, "second-test-account")
|
||||||
|
.expect("second test must not reuse the deleted first SQLite path");
|
||||||
|
|
||||||
|
assert_eq!(connection.backend(), DatabaseBackend::Sqlite);
|
||||||
|
assert!(second.path().join("rustbox.sqlite").is_file());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn table_prefix_is_short_and_deterministic() {
|
fn table_prefix_is_short_and_deterministic() {
|
||||||
let hash = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
let hash = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||||
@@ -2322,6 +2373,18 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tenant_rewriter_prefixes_header_sync_target_qualifier() {
|
||||||
|
let sql = prefix_tenant_tables(
|
||||||
|
"INSERT INTO user_meta (key, value) VALUES ('header_sync_generation', '1') ON CONFLICT(key) DO UPDATE SET value = CAST(user_meta.value AS INTEGER) + 1",
|
||||||
|
"u_test__",
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
sql,
|
||||||
|
"INSERT INTO u_test__user_meta (key, value) VALUES ('header_sync_generation', '1') ON CONFLICT(key) DO UPDATE SET value = CAST(u_test__user_meta.value AS INTEGER) + 1"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_schema_contains_sessions_and_user_tables() {
|
fn test_schema_contains_sessions_and_user_tables() {
|
||||||
let conn = open_test_db();
|
let conn = open_test_db();
|
||||||
|
|||||||
@@ -243,7 +243,7 @@ pub mod db_header_sync {
|
|||||||
pub const Q_001: &str = "SELECT value FROM user_meta WHERE key = 'header_sync_generation'";
|
pub const Q_001: &str = "SELECT value FROM user_meta WHERE key = 'header_sync_generation'";
|
||||||
pub const Q_002: &str =
|
pub const Q_002: &str =
|
||||||
"INSERT INTO user_meta (key, value) VALUES ('header_sync_generation', '1')
|
"INSERT INTO user_meta (key, value) VALUES ('header_sync_generation', '1')
|
||||||
ON CONFLICT(key) DO UPDATE SET value = CAST(value AS INTEGER) + 1";
|
ON CONFLICT(key) DO UPDATE SET value = CAST(user_meta.value AS INTEGER) + 1";
|
||||||
}
|
}
|
||||||
|
|
||||||
pub mod db_identities {
|
pub mod db_identities {
|
||||||
|
|||||||
@@ -1114,10 +1114,7 @@ async fn process_search_outbox(
|
|||||||
Err(error) => {
|
Err(error) => {
|
||||||
if is_opensearch_backpressure_error(&error) {
|
if is_opensearch_backpressure_error(&error) {
|
||||||
crate::db::search_outbox::defer_transient_failure(
|
crate::db::search_outbox::defer_transient_failure(
|
||||||
&conn,
|
&conn, &event, &error, 30,
|
||||||
&event,
|
|
||||||
&error,
|
|
||||||
30,
|
|
||||||
)?;
|
)?;
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"OpenSearch temporarily rejected indexing for tenant {user_hash}: {error}"
|
"OpenSearch temporarily rejected indexing for tenant {user_hash}: {error}"
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ use sqlx::{FromRow, MySqlPool, PgPool};
|
|||||||
|
|
||||||
use super::{PersistedSession, SessionPersistence};
|
use super::{PersistedSession, SessionPersistence};
|
||||||
use crate::config::AppConfig;
|
use crate::config::AppConfig;
|
||||||
use crate::db::pool::{DatabaseBackend, global_schema_for_backend};
|
|
||||||
use crate::queries;
|
use crate::queries;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -149,20 +148,15 @@ pub struct DatabaseSessionStore {
|
|||||||
|
|
||||||
impl DatabaseSessionStore {
|
impl DatabaseSessionStore {
|
||||||
pub async fn connect(config: &AppConfig) -> Result<Self, String> {
|
pub async fn connect(config: &AppConfig) -> Result<Self, String> {
|
||||||
|
// Schema ownership is centralized in db::pool::configure(). Calling it here
|
||||||
|
// keeps direct/test construction safe, while the normal application startup
|
||||||
|
// hits the OnceLock fast path. Do not run schema.sql again from this store:
|
||||||
|
// that used to duplicate DDL for SQLite/MySQL/PostgreSQL and produced repeated
|
||||||
|
// PostgreSQL "relation already exists, skipping" NOTICE messages.
|
||||||
|
crate::db::pool::configure(config).await?;
|
||||||
|
|
||||||
let pool = match config.database_backend.as_str() {
|
let pool = match config.database_backend.as_str() {
|
||||||
"sqlite" => {
|
"sqlite" => Pool::Sqlite(sqlite_path(config)?),
|
||||||
let path = sqlite_path(config)?;
|
|
||||||
let migration_path = path.clone();
|
|
||||||
let schema = global_schema_for_backend(DatabaseBackend::Sqlite)?;
|
|
||||||
sqlite_task(migration_path, move |conn| {
|
|
||||||
conn.execute_batch(&schema).map_err(|e| {
|
|
||||||
format!("SQLite database schema initialization failed: {e}")
|
|
||||||
})?;
|
|
||||||
Ok(())
|
|
||||||
})
|
|
||||||
.await?;
|
|
||||||
Pool::Sqlite(path)
|
|
||||||
}
|
|
||||||
"mysql" => {
|
"mysql" => {
|
||||||
let url = config
|
let url = config
|
||||||
.database_url
|
.database_url
|
||||||
@@ -171,11 +165,6 @@ impl DatabaseSessionStore {
|
|||||||
let pool = MySqlPool::connect(url)
|
let pool = MySqlPool::connect(url)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("failed to connect MySQL database: {e}"))?;
|
.map_err(|e| format!("failed to connect MySQL database: {e}"))?;
|
||||||
let schema = global_schema_for_backend(DatabaseBackend::Mysql)?;
|
|
||||||
sqlx::raw_sql(&schema)
|
|
||||||
.execute(&pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("MySQL database schema initialization failed: {e}"))?;
|
|
||||||
Pool::Mysql(pool)
|
Pool::Mysql(pool)
|
||||||
}
|
}
|
||||||
"pgsql" => {
|
"pgsql" => {
|
||||||
@@ -186,10 +175,6 @@ impl DatabaseSessionStore {
|
|||||||
let pool = PgPool::connect(url)
|
let pool = PgPool::connect(url)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("failed to connect PostgreSQL database: {e}"))?;
|
.map_err(|e| format!("failed to connect PostgreSQL database: {e}"))?;
|
||||||
let schema = global_schema_for_backend(DatabaseBackend::Pgsql)?;
|
|
||||||
sqlx::raw_sql(&schema).execute(&pool).await.map_err(|e| {
|
|
||||||
format!("PostgreSQL database schema initialization failed: {e}")
|
|
||||||
})?;
|
|
||||||
Pool::Pgsql(pool)
|
Pool::Pgsql(pool)
|
||||||
}
|
}
|
||||||
other => return Err(format!("unsupported database backend: {other}")),
|
other => return Err(format!("unsupported database backend: {other}")),
|
||||||
|
|||||||
Reference in new issue
Block a user