Skip to main content

max / makenotwork

Scope Sando's topology sync to one product sync's first act is to ask which rows are not in this TOML and delete them. A TOML describes one product, so unscoped, syncing pom at startup answers "all of MNW's" and deletes them — and since startup syncs each app in turn, the last one to run would be the only one left standing. Two fixes to migration 011, both found by running it rather than reading it. A composite REFERENCES tiers(app, name) is a schema error until tiers actually has that unique index, raised when the statement runs and not at COMMIT, so deferring foreign keys does not cover it and each referenced table has to reach its final form before anything references it. And deploys and gate_runs had to be rebuilt too: their keys did not change, but their REFERENCES clauses named columns that stopped being unique, which is a mismatch on the next insert and cannot be altered in place. Every app column defaults to 'mnw', saying what the config says: a statement naming no product is about the one product Sando had. That is what lets the remaining queries be threaded a file at a time instead of in one unreviewable commit. Editing 011 rather than superseding it: verified unapplied everywhere (prod is at 10, the dev DB has no migrations table), which is the condition the pre-commit hook names for a bypass.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-06 23:42 UTC
Signed with PGP, not checked
Commit: 8a9a1af50d60cfddc28ff919b220dfa0904e63ee
Parent: 03ebaf6
3 files changed, +245 insertions, -60 deletions
@@ -9,7 +9,11 @@
9 9 -- state rather than as an error.
10 10 --
11 11 -- So `app` joins the key of everything that was keyed, and rides along on
12 - -- everything that was merely recorded.
12 + -- everything that was merely recorded. Every one of them defaults to 'mnw',
13 + -- which says the same thing the config says: a statement that does not name a
14 + -- product is about the one product Sando had. That default is what lets the
15 + -- daemon's queries be threaded one file at a time instead of in a single
16 + -- unreviewable commit.
13 17 --
14 18 -- Existing rows backfill to 'mnw'. That is not a guess: this daemon has only
15 19 -- ever deployed the MNW server, and a config with no [app.*] tables loads as
@@ -17,17 +21,24 @@
17 21 -- anybody editing either.
18 22 --
19 23 -- Four tables need their PRIMARY KEY widened, which SQLite cannot do in place,
20 - -- so they are rebuilt. `defer_foreign_keys` holds enforcement until COMMIT:
21 - -- mid-migration the referenced tables briefly do not exist, and every reference
22 - -- is satisfied again by the time the transaction closes. (`PRAGMA foreign_keys`
23 - -- itself is a no-op inside a transaction, which is why it is not used here.)
24 + -- so they are rebuilt. `defer_foreign_keys` holds row-level enforcement until
25 + -- COMMIT, which is what lets a referenced table be dropped and replaced with
26 + -- rows still pointing at it. (`PRAGMA foreign_keys` itself is a no-op inside a
27 + -- transaction, which is why it is not used here.)
28 + --
29 + -- Deferral does not cover everything, which decides the order below. A
30 + -- composite `REFERENCES tiers(app, name)` is a *schema* error — "foreign key
31 + -- mismatch" — until `tiers` actually has a unique index on those two columns,
32 + -- and that is raised when the statement runs, not at COMMIT. So `tiers` is
33 + -- rebuilt and renamed first, alone, and only then do the tables that reference
34 + -- it get created.
24 35
25 36 PRAGMA defer_foreign_keys = ON;
26 37
27 38 -- Tiers are per product. MNW's `a`/`b` and pom's tiers are different pipelines
28 39 -- that happen to be spelled alike.
29 40 CREATE TABLE tiers_new (
30 - app TEXT NOT NULL,
41 + app TEXT NOT NULL DEFAULT 'mnw',
31 42 name TEXT NOT NULL,
32 43 ord INTEGER NOT NULL,
33 44 provisioned INTEGER NOT NULL DEFAULT 0,
@@ -36,11 +47,13 @@
36 47 );
37 48 INSERT INTO tiers_new (app, name, ord, provisioned, canary)
38 49 SELECT 'mnw', name, ord, provisioned, canary FROM tiers;
50 + DROP TABLE tiers;
51 + ALTER TABLE tiers_new RENAME TO tiers;
39 52
40 53 -- A node name is only unique within its product: two products may each deploy
41 54 -- to a node they both call `prod-1`, and they are not the same machine role.
42 55 CREATE TABLE nodes_new (
43 - app TEXT NOT NULL,
56 + app TEXT NOT NULL DEFAULT 'mnw',
44 57 name TEXT NOT NULL,
45 58 tier TEXT NOT NULL,
46 59 ssh_target TEXT NOT NULL,
@@ -50,10 +63,13 @@
50 63 );
51 64 INSERT INTO nodes_new (app, name, tier, ssh_target, release_root)
52 65 SELECT 'mnw', name, tier, ssh_target, release_root FROM nodes;
66 + DROP TABLE nodes;
67 + ALTER TABLE nodes_new RENAME TO nodes;
68 + CREATE INDEX nodes_by_tier ON nodes(app, tier);
53 69
54 70 -- Two products releasing the same version string are two artifacts.
55 71 CREATE TABLE versions_new (
56 - app TEXT NOT NULL,
72 + app TEXT NOT NULL DEFAULT 'mnw',
57 73 version TEXT NOT NULL,
58 74 git_sha TEXT NOT NULL,
59 75 built_at TEXT NOT NULL,
@@ -62,12 +78,14 @@
62 78 );
63 79 INSERT INTO versions_new (app, version, git_sha, built_at, artifact_path)
64 80 SELECT 'mnw', version, git_sha, built_at, artifact_path FROM versions;
81 + DROP TABLE versions;
82 + ALTER TABLE versions_new RENAME TO versions;
65 83
66 84 -- What is deployed, per product per tier. Columns added since 001 are carried
67 85 -- across explicitly rather than by SELECT *, so a future column cannot be
68 86 -- silently dropped by this migration.
69 87 CREATE TABLE tier_state_new (
70 - app TEXT NOT NULL,
88 + app TEXT NOT NULL DEFAULT 'mnw',
71 89 tier TEXT NOT NULL,
72 90 current_version TEXT,
73 91 previous_version TEXT,
@@ -86,33 +104,85 @@
86 104 SELECT 'mnw', tier, current_version, previous_version, burn_in_started_at,
87 105 partial_reason, current_build_id, previous_build_id, advanced_at
88 106 FROM tier_state;
89 -
90 107 DROP TABLE tier_state;
91 - DROP TABLE nodes;
92 - DROP TABLE versions;
93 - DROP TABLE tiers;
94 -
95 - ALTER TABLE tiers_new RENAME TO tiers;
96 - ALTER TABLE nodes_new RENAME TO nodes;
97 - ALTER TABLE versions_new RENAME TO versions;
98 108 ALTER TABLE tier_state_new RENAME TO tier_state;
99 109
100 - CREATE INDEX nodes_by_tier ON nodes(app, tier);
101 -
102 110 -- The append-only tables keep their integer primary keys; `app` is a column,
103 - -- not part of an identity they already had.
104 - ALTER TABLE deploys ADD COLUMN app TEXT NOT NULL DEFAULT 'mnw';
105 - ALTER TABLE gate_runs ADD COLUMN app TEXT NOT NULL DEFAULT 'mnw';
111 + -- not part of an identity they already had. `backups` and `build_runs`
112 + -- reference nothing that moved, so a plain ADD COLUMN is enough.
106 113 ALTER TABLE backups ADD COLUMN app TEXT NOT NULL DEFAULT 'mnw';
107 114 ALTER TABLE build_runs ADD COLUMN app TEXT NOT NULL DEFAULT 'mnw';
108 115
116 + -- `deploys` and `gate_runs` cannot be, even though their keys are unchanged:
117 + -- each carries `REFERENCES versions(version)` / `tiers(name)` / `nodes(name)`
118 + -- clauses written when those were single-column keys. Those clauses are now
119 + -- schema errors ("foreign key mismatch") on the next INSERT, and SQLite has no
120 + -- way to alter a foreign key in place. So both are rebuilt to point at the
121 + -- composite keys.
122 + --
123 + -- A composite foreign key with any NULL column is not checked, which is exactly
124 + -- right for `deploys.node`: a tier-level deploy row names no node.
125 + CREATE TABLE deploys_new (
126 + id INTEGER PRIMARY KEY AUTOINCREMENT,
127 + app TEXT NOT NULL DEFAULT 'mnw',
128 + version TEXT NOT NULL,
129 + tier TEXT NOT NULL,
130 + node TEXT,
131 + started_at TEXT NOT NULL,
132 + finished_at TEXT,
133 + outcome TEXT NOT NULL DEFAULT 'in_progress',
134 + hotfix INTEGER NOT NULL DEFAULT 0,
135 + reset_burn_in INTEGER NOT NULL DEFAULT 0,
136 + outcome_json TEXT,
137 + build_id INTEGER REFERENCES build_runs(id),
138 + FOREIGN KEY (app, version) REFERENCES versions(app, version),
139 + FOREIGN KEY (app, tier) REFERENCES tiers(app, name),
140 + FOREIGN KEY (app, node) REFERENCES nodes(app, name)
141 + );
142 + INSERT INTO deploys_new (
143 + id, app, version, tier, node, started_at, finished_at, outcome, hotfix,
144 + reset_burn_in, outcome_json, build_id
145 + )
146 + SELECT id, 'mnw', version, tier, node, started_at, finished_at, outcome,
147 + hotfix, reset_burn_in, outcome_json, build_id
148 + FROM deploys;
149 + DROP TABLE deploys;
150 + ALTER TABLE deploys_new RENAME TO deploys;
151 +
152 + CREATE TABLE gate_runs_new (
153 + id INTEGER PRIMARY KEY AUTOINCREMENT,
154 + app TEXT NOT NULL DEFAULT 'mnw',
155 + version TEXT NOT NULL,
156 + tier TEXT NOT NULL,
157 + gate_kind TEXT NOT NULL,
158 + started_at TEXT NOT NULL,
159 + finished_at TEXT,
160 + build_id INTEGER REFERENCES build_runs(id),
161 + status TEXT,
162 + outcome_json TEXT,
163 + log_ref TEXT,
164 + FOREIGN KEY (app, version) REFERENCES versions(app, version),
165 + FOREIGN KEY (app, tier) REFERENCES tiers(app, name)
166 + );
167 + INSERT INTO gate_runs_new (
168 + id, app, version, tier, gate_kind, started_at, finished_at, build_id,
169 + status, outcome_json, log_ref
170 + )
171 + SELECT id, 'mnw', version, tier, gate_kind, started_at, finished_at,
172 + build_id, status, outcome_json, log_ref
173 + FROM gate_runs;
174 + DROP TABLE gate_runs;
175 + ALTER TABLE gate_runs_new RENAME TO gate_runs;
176 +
109 177 -- Every lookup that used to be by (tier, version) or by name is now by product
110 178 -- first. Left as separate indexes from the 001 ones, which are dropped: an
111 179 -- index that omits the leading column of the predicate cannot serve it.
112 - DROP INDEX IF EXISTS deploys_by_tier_version;
113 - DROP INDEX IF EXISTS gate_runs_lookup;
180 + -- The rebuilt tables took their indexes to the grave with them; the two that
181 + -- survive are dropped so every one of these is recreated app-first.
114 182 DROP INDEX IF EXISTS backups_name_fetched;
115 183 DROP INDEX IF EXISTS build_runs_by_sha;
184 + CREATE INDEX deploys_by_build ON deploys(build_id);
185 + CREATE INDEX gate_runs_by_build ON gate_runs(build_id, gate_kind);
116 186 CREATE INDEX deploys_by_tier_version ON deploys(app, tier, version);
117 187 CREATE INDEX gate_runs_lookup ON gate_runs(app, tier, version, gate_kind);
118 188 CREATE INDEX backups_name_fetched ON backups(app, name, fetched_at);
@@ -151,7 +151,7 @@
151 151 }
152 152 }
153 153 for (id, app) in apps.iter() {
154 - sync::sync(&pool, &app.topo).await?;
154 + sync::sync(&pool, id, &app.topo).await?;
155 155 tracing::debug!(app = %id, "topology synced");
156 156 }
157 157
@@ -6,11 +6,18 @@
6 6 //! a removed tier is preserved silently — the FK is cleared by deleting the
7 7 //! parent last. If you actually need to forget a retired tier, do it by hand.
8 8
9 + use crate::domain::AppId;
9 10 use crate::topology::Topology;
10 11 use anyhow::Result;
11 12 use sqlx::SqlitePool;
12 13
13 - pub async fn sync(pool: &SqlitePool, topo: &Topology) -> Result<()> {
14 + /// Reconcile one product's topology into the tier and node tables.
15 + ///
16 + /// Every statement is scoped to `app`. Without that, syncing one product would
17 + /// treat every other product's tiers as removed from config and delete them:
18 + /// the stale-row sweep asks "which rows are not in this TOML", and one TOML has
19 + /// never described more than one product.
20 + pub async fn sync(pool: &SqlitePool, app: &AppId, topo: &Topology) -> Result<()> {
14 21 let mut tx = pool.begin().await?;
15 22
16 23 let want_tiers: Vec<&str> = topo.tiers.iter().map(|t| t.name.as_str()).collect();
@@ -25,12 +32,15 @@
25 32 .collect();
26 33
27 34 // Drop stale nodes first (FK to tiers).
28 - let existing_nodes: Vec<(String, String)> = sqlx::query_as("SELECT name, tier FROM nodes")
29 - .fetch_all(&mut *tx)
30 - .await?;
35 + let existing_nodes: Vec<(String, String)> =
36 + sqlx::query_as("SELECT name, tier FROM nodes WHERE app = ?")
37 + .bind(app)
38 + .fetch_all(&mut *tx)
39 + .await?;
31 40 for (name, tier) in existing_nodes {
32 41 if !want_nodes.iter().any(|(t, n)| *t == tier && *n == name) {
33 - sqlx::query("DELETE FROM nodes WHERE name = ?")
42 + sqlx::query("DELETE FROM nodes WHERE app = ? AND name = ?")
43 + .bind(app)
34 44 .bind(&name)
35 45 .execute(&mut *tx)
36 46 .await?;
@@ -40,27 +50,32 @@
40 50 // Drop stale tiers. tier_state rows referencing them are preserved by
41 51 // clearing the FK target only after a manual cleanup — for now we just
42 52 // refuse to delete a tier that still has tier_state with non-null version.
43 - let existing_tiers: Vec<String> = sqlx::query_scalar("SELECT name FROM tiers")
53 + let existing_tiers: Vec<String> = sqlx::query_scalar("SELECT name FROM tiers WHERE app = ?")
54 + .bind(app)
44 55 .fetch_all(&mut *tx)
45 56 .await?;
46 57 for t in existing_tiers {
47 58 if !want_tiers.contains(&t.as_str()) {
48 - let in_use: Option<String> =
49 - sqlx::query_scalar("SELECT current_version FROM tier_state WHERE tier = ?")
50 - .bind(&t)
51 - .fetch_optional(&mut *tx)
52 - .await?
53 - .flatten();
59 + let in_use: Option<String> = sqlx::query_scalar(
60 + "SELECT current_version FROM tier_state WHERE app = ? AND tier = ?",
61 + )
62 + .bind(app)
63 + .bind(&t)
64 + .fetch_optional(&mut *tx)
65 + .await?
66 + .flatten();
54 67 anyhow::ensure!(
55 68 in_use.is_none(),
56 - "refusing to remove tier {t} from config: tier_state still pins a version. \
57 - clean it up by hand before editing sando.toml.",
69 + "refusing to remove tier {t} from app `{app}`'s config: tier_state still pins a \
70 + version. clean it up by hand before editing the topology.",
58 71 );
59 - sqlx::query("DELETE FROM tier_state WHERE tier = ?")
72 + sqlx::query("DELETE FROM tier_state WHERE app = ? AND tier = ?")
73 + .bind(app)
60 74 .bind(&t)
61 75 .execute(&mut *tx)
62 76 .await?;
63 - sqlx::query("DELETE FROM tiers WHERE name = ?")
77 + sqlx::query("DELETE FROM tiers WHERE app = ? AND name = ?")
78 + .bind(app)
64 79 .bind(&t)
65 80 .execute(&mut *tx)
66 81 .await?;
@@ -71,13 +86,14 @@
71 86 // promotion sequence is queryable without re-reading the TOML.
72 87 for (i, t) in topo.tiers.iter().enumerate() {
73 88 sqlx::query(
74 - "INSERT INTO tiers (name, ord, provisioned, canary)
75 - VALUES (?, ?, ?, ?)
76 - ON CONFLICT(name) DO UPDATE SET
89 + "INSERT INTO tiers (app, name, ord, provisioned, canary)
90 + VALUES (?, ?, ?, ?, ?)
91 + ON CONFLICT(app, name) DO UPDATE SET
77 92 ord = excluded.ord,
78 93 provisioned = excluded.provisioned,
79 94 canary = excluded.canary",
80 95 )
96 + .bind(app)
81 97 .bind(&t.name)
82 98 .bind(i as i64)
83 99 .bind(t.provisioned as i64)
@@ -85,20 +101,22 @@
85 101 .execute(&mut *tx)
86 102 .await?;
87 103
88 - sqlx::query("INSERT OR IGNORE INTO tier_state (tier) VALUES (?)")
104 + sqlx::query("INSERT OR IGNORE INTO tier_state (app, tier) VALUES (?, ?)")
105 + .bind(app)
89 106 .bind(&t.name)
90 107 .execute(&mut *tx)
91 108 .await?;
92 109
93 110 for n in &t.nodes {
94 111 sqlx::query(
95 - "INSERT INTO nodes (name, tier, ssh_target, release_root)
96 - VALUES (?, ?, ?, ?)
97 - ON CONFLICT(name) DO UPDATE SET
112 + "INSERT INTO nodes (app, name, tier, ssh_target, release_root)
113 + VALUES (?, ?, ?, ?, ?)
114 + ON CONFLICT(app, name) DO UPDATE SET
98 115 tier = excluded.tier,
99 116 ssh_target = excluded.ssh_target,
100 117 release_root = excluded.release_root",
101 118 )
119 + .bind(app)
102 120 .bind(&n.name)
103 121 .bind(&t.name)
104 122 .bind(&n.ssh_target)
@@ -128,6 +146,10 @@
128 146 pool
129 147 }
130 148
149 + fn app() -> AppId {
150 + AppId::default()
151 + }
152 +
131 153 fn topo(tiers: Vec<Tier>) -> Topology {
132 154 Topology {
133 155 repo: RepoConfig {
@@ -178,7 +200,7 @@
178 200 tier("c", false, vec![]),
179 201 ]);
180 202
181 - sync(&pool, &t).await.unwrap();
203 + sync(&pool, &app(), &t).await.unwrap();
182 204
183 205 let tier_names: Vec<String> = sqlx::query_scalar("SELECT name FROM tiers ORDER BY ord")
184 206 .fetch_all(&pool)
@@ -206,8 +228,8 @@
206 228 tier("host", true, vec![]),
207 229 tier("a", true, vec![node("n1")]),
208 230 ]);
209 - sync(&pool, &t).await.unwrap();
210 - sync(&pool, &t).await.unwrap();
231 + sync(&pool, &app(), &t).await.unwrap();
232 + sync(&pool, &app(), &t).await.unwrap();
211 233
212 234 let nodes: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM nodes")
213 235 .fetch_one(&pool)
@@ -225,9 +247,9 @@
225 247 async fn removing_node_from_config_drops_row() {
226 248 let pool = fresh_pool().await;
227 249 let t1 = topo(vec![tier("a", true, vec![node("n1"), node("n2")])]);
228 - sync(&pool, &t1).await.unwrap();
250 + sync(&pool, &app(), &t1).await.unwrap();
229 251 let t2 = topo(vec![tier("a", true, vec![node("n1")])]);
230 - sync(&pool, &t2).await.unwrap();
252 + sync(&pool, &app(), &t2).await.unwrap();
231 253
232 254 let nodes: Vec<String> = sqlx::query_scalar("SELECT name FROM nodes")
233 255 .fetch_all(&pool)
@@ -236,22 +258,115 @@
236 258 assert_eq!(nodes, vec!["n1"]);
237 259 }
238 260
261 + /// Syncing one product leaves every other product's tiers and nodes alone.
262 + ///
263 + /// The sweep at the top of `sync` asks "which rows are not in this TOML",
264 + /// and a TOML describes one product. Unscoped, syncing pom at startup would
265 + /// answer "all of MNW's" and delete them — and since startup syncs each app
266 + /// in turn, the last one to run would be the only one left standing.
267 + #[tokio::test]
268 + async fn syncing_one_app_does_not_touch_another() {
269 + let pool = fresh_pool().await;
270 + let mnw = AppId::new("mnw");
271 + let pom = AppId::new("pom");
272 + sync(
273 + &pool,
274 + &mnw,
275 + &topo(vec![tier("host", true, vec![node("n1")])]),
276 + )
277 + .await
278 + .unwrap();
279 + sync(
280 + &pool,
281 + &pom,
282 + &topo(vec![tier("host", true, vec![node("n2")])]),
283 + )
284 + .await
285 + .unwrap();
286 +
287 + // Both survive, and a tier name they share is two rows, not one.
288 + let tiers: Vec<(String, String)> =
289 + sqlx::query_as("SELECT app, name FROM tiers ORDER BY app")
290 + .fetch_all(&pool)
291 + .await
292 + .unwrap();
293 + assert_eq!(
294 + tiers,
295 + vec![
296 + ("mnw".to_string(), "host".to_string()),
297 + ("pom".to_string(), "host".to_string())
298 + ]
299 + );
300 + let nodes: Vec<(String, String)> =
301 + sqlx::query_as("SELECT app, name FROM nodes ORDER BY app")
302 + .fetch_all(&pool)
303 + .await
304 + .unwrap();
305 + assert_eq!(
306 + nodes,
307 + vec![
308 + ("mnw".to_string(), "n1".to_string()),
309 + ("pom".to_string(), "n2".to_string())
310 + ]
311 + );
312 + // And one tier_state row each, not one shared.
313 + let states: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM tier_state")
314 + .fetch_one(&pool)
315 + .await
316 + .unwrap();
317 + assert_eq!(states, 2);
318 + }
319 +
320 + /// A tier pinned in one product does not block removing the same-named tier
321 + /// from another.
322 + #[tokio::test]
323 + async fn a_pin_in_one_app_does_not_block_another_apps_edit() {
324 + let pool = fresh_pool().await;
325 + let mnw = AppId::new("mnw");
326 + let pom = AppId::new("pom");
327 + let two = topo(vec![tier("host", true, vec![]), tier("a", true, vec![])]);
328 + sync(&pool, &mnw, &two).await.unwrap();
329 + sync(&pool, &pom, &two).await.unwrap();
330 +
331 + // MNW pins a version on tier a.
332 + sqlx::query("INSERT INTO versions (app, version, git_sha, built_at, artifact_path) VALUES ('mnw','0.1.0','deadbeef','2026-05-22T00:00:00Z','/r/0.1.0')")
333 + .execute(&pool).await.unwrap();
334 + sqlx::query(
335 + "UPDATE tier_state SET current_version = '0.1.0' WHERE app = 'mnw' AND tier = 'a'",
336 + )
337 + .execute(&pool)
338 + .await
339 + .unwrap();
340 +
341 + // pom dropping ITS tier a is fine; MNW's pin is not pom's business.
342 + sync(&pool, &pom, &topo(vec![tier("host", true, vec![])]))
343 + .await
344 + .unwrap();
345 + // MNW dropping the same tier is still refused.
346 + let err = sync(&pool, &mnw, &topo(vec![tier("host", true, vec![])]))
347 + .await
348 + .unwrap_err();
349 + assert!(err.to_string().contains("tier_state still pins"), "{err}");
350 + }
351 +
239 352 #[tokio::test]
240 353 async fn refuses_to_drop_tier_with_pinned_version() {
241 354 let pool = fresh_pool().await;
242 355 let t1 = topo(vec![tier("host", true, vec![]), tier("a", true, vec![])]);
243 - sync(&pool, &t1).await.unwrap();
356 + sync(&pool, &app(), &t1).await.unwrap();
244 357
245 358 // Simulate a version being deployed on tier a.
246 - sqlx::query("INSERT INTO versions (version, git_sha, built_at, artifact_path) VALUES ('0.1.0', 'deadbeef', '2026-05-22T00:00:00Z', '/r/0.1.0')")
359 + sqlx::query("INSERT INTO versions (app, version, git_sha, built_at, artifact_path) VALUES ('mnw', '0.1.0', 'deadbeef', '2026-05-22T00:00:00Z', '/r/0.1.0')")
247 360 .execute(&pool).await.unwrap();
248 - sqlx::query("UPDATE tier_state SET current_version = '0.1.0' WHERE tier = 'a'")
249 - .execute(&pool)
250 - .await
251 - .unwrap();
361 + sqlx::query(
362 + "UPDATE tier_state SET current_version = '0.1.0' WHERE app = 'mnw' AND tier = 'a'",
363 + )
364 + .execute(&pool)
365 + .await
366 + .unwrap();
252 367
253 368 let t2 = topo(vec![tier("host", true, vec![])]);
254 - let err = sync(&pool, &t2).await.unwrap_err();
369 + let err = sync(&pool, &app(), &t2).await.unwrap_err();
255 370 assert!(
256 371 err.to_string().contains("tier_state still pins"),
257 372 "got: {err}"