1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
//! §1e: commit-hook, version-history, and idempotency verification for
//! `#[repository(tenant_scoped, sharded)]`.
//!
//! These features run *inside the mutation transaction*, which the
//! self-routing extractor opens on the **shard primary**. This test proves
//! that for a sharded save:
//!
//! - the `_autumn_version_history` row lands on the shard DB,
//! - the `autumn_repository_commit_hooks` queue row lands on the shard DB,
//! - an HTTP idempotency replay does not double-write the shard.
//!
//! No production code change is expected — Section 5a already ships the
//! version-history and commit-hook migrations to shard targets; this is the
//! end-to-end confirmation on a real shard database.
//!
//! Run with:
//!
//! cargo test --test sharding_commit_hooks -- --include-ignored
#[cfg(all(feature = "db", feature = "test-support"))]
mod sharding_commit_hook_tests {
// The #[repository(commit_hooks = true)] expansion builds the idempotency
// discriminator with a `let mut x = None; if let Some(..) = .. { x = .. }`
// sequence. This is the first real (non-trybuild) test target to exercise
// that codegen under clippy, so allow the generated-code idiom here.
#![allow(clippy::useless_let_if_seq)]
use autumn_web::config::ShardConfig;
use autumn_web::prelude::*;
use autumn_web::test::{TestApp, TestDb};
use diesel::prelude::*;
use diesel_async::{RunQueryDsl, SimpleAsyncConnection};
// The real framework migration SQL applied to the shard, so the test
// exercises exactly the schema Section 5a ships to shard targets.
const VERSION_HISTORY_UP: &str = include_str!(
"../../version_history_migrations/20260526000000_create_version_history/up.sql"
);
const COMMIT_HOOK_UP: &str = include_str!(
"../../repository_commit_hook_migrations/20260515000000_create_repository_commit_hook_queue/up.sql"
);
// ── Schema ─────────────────────────────────────────────────
diesel::table! {
sharded_notes (id) {
id -> Int8,
title -> Text,
tenant_id -> Text,
}
}
#[autumn_web::model(table = "sharded_notes")]
pub struct Note {
#[id]
pub id: i64,
pub title: String,
// tenant_scoped: framework-managed, omitted from `NewNote` and stamped
// from the current tenant on insert.
#[default]
pub tenant_id: String,
}
// ── Hooks (commit_hooks = true requires a hooks type) ──────
#[derive(Clone, Default)]
pub struct NoteHooks;
impl autumn_web::hooks::MutationHooks for NoteHooks {
type Model = Note;
type NewModel = NewNote;
type UpdateModel = UpdateNote;
// Overriding an after-*-commit hook is what causes the generated save
// to durably stage a row into `autumn_repository_commit_hooks` inside
// the mutation transaction (i.e. on the shard primary).
async fn after_create_commit(
&self,
_ctx: &mut autumn_web::hooks::MutationContext,
_record: &Note,
) -> AutumnResult<()> {
Ok(())
}
}
// Self-routing, tenant-scoped, sharded, versioned, with commit hooks.
#[autumn_web::repository(
Note,
table = "sharded_notes",
tenant_scoped,
sharded,
versioned = true,
hooks = NoteHooks,
commit_hooks = true
)]
pub trait NoteRepository {}
// ── Handler ────────────────────────────────────────────────
#[derive(serde::Deserialize)]
struct NoteInput {
title: String,
}
/// Save through the self-routing repository extractor. No `ShardedDb` in
/// the signature — the generated `FromRequestParts` resolves tenant →
/// shard and opens the write transaction on the routed shard primary.
#[post("/notes")]
async fn create_note(
repo: PgNoteRepository,
Json(input): Json<NoteInput>,
) -> AutumnResult<(axum::http::StatusCode, Json<Note>)> {
// tenant_id is stamped from the current tenant by the tenant_scoped repo.
let note = repo.save(&NewNote { title: input.title }).await?;
Ok((axum::http::StatusCode::CREATED, Json(note)))
}
/// Establish the current tenant from an `X-Tenant` header for the whole
/// request. `with_tenant` sets the `CURRENT_TENANT` task-local, which both
/// the sharding extractor (shard routing key) and the `tenant_scoped`
/// repository (`tenant_id` stamping) read — no `[tenancy]` config needed.
async fn inject_tenant(
request: axum::extract::Request,
next: axum::middleware::Next,
) -> axum::response::Response {
match request
.headers()
.get("X-Tenant")
.and_then(|v| v.to_str().ok())
.map(str::to_owned)
{
Some(tenant) => autumn_web::tenancy::with_tenant(tenant, next.run(request)).await,
None => next.run(request).await,
}
}
// ── Setup ──────────────────────────────────────────────────
async fn setup_shard(db: &TestDb) {
let mut conn = db.pool().get().await.expect("shard connection");
conn.batch_execute(VERSION_HISTORY_UP)
.await
.expect("apply version-history migration to shard");
conn.batch_execute(COMMIT_HOOK_UP)
.await
.expect("apply commit-hook migration to shard");
conn.batch_execute(
"CREATE TABLE IF NOT EXISTS sharded_notes (
id BIGSERIAL PRIMARY KEY,
title TEXT NOT NULL,
tenant_id TEXT NOT NULL
)",
)
.await
.expect("create sharded_notes table");
}
async fn count(db: &TestDb, sql: &str) -> i64 {
let mut conn = db.pool().get().await.expect("count connection");
diesel::sql_query(sql)
.get_result::<CountRow>(&mut *conn)
.await
.expect("count query")
.n
}
#[derive(QueryableByName)]
struct CountRow {
#[diesel(sql_type = diesel::sql_types::BigInt)]
n: i64,
}
// ── Tests ──────────────────────────────────────────────────
/// A sharded save writes the version-history and commit-hook-queue rows on
/// the shard database, and an HTTP idempotency replay does not double-write.
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn sharded_save_records_history_and_hooks_on_shard() {
let db = TestDb::shared().await;
setup_shard(db).await;
let shard = ShardConfig {
name: "shard0".to_owned(),
primary_url: db.url().to_owned(),
..Default::default()
};
let client = TestApp::new()
.routes(routes![create_note])
.layer(axum::middleware::from_fn(inject_tenant))
.with_shards(vec![shard])
.idempotent()
.build();
// The idempotency store is a per-instance in-memory `MemoryIdempotencyStore`
// (see `TestApp::idempotent` / `src/test.rs`), NOT the shared Postgres that
// `TestDb::shared()` hands out — so keys need not be unique for cross-test
// isolation, and step 2 below deliberately reuses `key_k1` to exercise the
// replay path. Per-run UUID keys are kept only as harmless belt-and-suspenders.
let run_id = uuid::Uuid::new_v4();
let key_k1 = format!("note-k1-{run_id}");
let key_k2 = format!("note-k2-{run_id}");
// 1. First create with idempotency key k1.
client
.post("/notes")
.header("X-Tenant", "tenant-a")
.header("idempotency-key", key_k1.as_str())
.json(&serde_json::json!({"title": "first"}))
.send()
.await
.assert_status(201);
// The version-history row is on the shard DB.
assert_eq!(
count(
db,
"SELECT COUNT(*) AS n FROM _autumn_version_history \
WHERE table_name = 'sharded_notes' AND op = 'insert'",
)
.await,
1,
"one version-history insert row must land on the shard"
);
// The commit-hook queue row is on the shard DB (staged inside the same
// mutation transaction that wrote the note).
assert!(
count(
db,
"SELECT COUNT(*) AS n FROM autumn_repository_commit_hooks"
)
.await
>= 1,
"the after-commit hook must be staged on the shard's commit-hook queue"
);
// 2. Replay with the same idempotency key. The opaque tenant `from_fn`
// layer (installed above) forces fail-closed idempotency replay
// (`src/router.rs`), so the cached mutation is NOT re-served to a
// principal behind an opaque auth/tenant layer: the replay returns 409
// ("idempotency replay requires an inner replay stop for this route")
// and the inner handler is not re-executed. That is the intended secure
// behavior, and it still proves the no-double-write acceptance criterion
// via the count/history assertions immediately below.
client
.post("/notes")
.header("X-Tenant", "tenant-a")
.header("idempotency-key", key_k1.as_str())
.json(&serde_json::json!({"title": "first"}))
.send()
.await
.assert_status(409);
assert_eq!(
count(db, "SELECT COUNT(*) AS n FROM sharded_notes").await,
1,
"idempotent replay must not insert a second note on the shard"
);
assert_eq!(
count(
db,
"SELECT COUNT(*) AS n FROM _autumn_version_history \
WHERE table_name = 'sharded_notes'",
)
.await,
1,
"idempotent replay must not append a second version-history row"
);
// 3. A different idempotency key writes a second note and history row.
client
.post("/notes")
.header("X-Tenant", "tenant-a")
.header("idempotency-key", key_k2.as_str())
.json(&serde_json::json!({"title": "second"}))
.send()
.await
.assert_status(201);
assert_eq!(
count(db, "SELECT COUNT(*) AS n FROM sharded_notes").await,
2,
"a fresh idempotency key writes a second note on the shard"
);
}
}