Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
59 commits
Select commit Hold shift + click to select a range
9a6ffa7
refactor(api): identify a rollout member by deployment and target
aparajon Sep 3, 2026
0327376
feat(storage): let one apply's members carry their own plans
aparajon Sep 3, 2026
3a54e8e
fix(api): name an unresolvable operation's plan error by deployment
aparajon Sep 3, 2026
8cad46b
feat(api): let one environment address several targets
aparajon Sep 3, 2026
ee4a5de
feat(api): gate multi-target on the engine, MySQL only
aparajon Sep 3, 2026
5a02275
fix(api): report a target beside an empty targets list as a conflict
aparajon Sep 3, 2026
2785ad8
feat(api): report how an environment's targets diverge on pull
aparajon Sep 3, 2026
e5afea2
feat(api): persist one plan per rollout member when members are plann…
aparajon Sep 3, 2026
98320c8
feat(api): fan an apply out to one operation per rollout member
aparajon Sep 3, 2026
595b855
feat(api): name every rollout member on a multi-target pull
aparajon Sep 10, 2026
b82c0b2
feat(api): plan multi-target environments independently instead of bl…
aparajon Sep 3, 2026
8256fde
feat(api): keep plan storage tolerant of an already-stored identifier
aparajon Sep 3, 2026
20de329
fix(api): create a single-member apply without database config
aparajon Sep 3, 2026
99970fd
docs(api): describe the pull payload's target member list
aparajon Sep 10, 2026
a4023dc
feat(github): name every rollout member unambiguously
aparajon Sep 3, 2026
d0bdeef
feat(observability): attribute per-table progress to its rollout member
aparajon Sep 3, 2026
550f1ca
feat(storage): name the operation key delimiter and cut a target prefix
aparajon Sep 14, 2026
e22cfcc
Merge branch 'armand/multi-target-op-plan' into armand/multi-target-c…
aparajon Sep 14, 2026
db19279
fix(api): refuse a listed target that contains the operation key deli…
aparajon Sep 14, 2026
d759483
Merge branch 'armand/multi-target-config' into armand/multi-target-plan
aparajon Sep 14, 2026
7412afa
refactor(api): resolve the planning contract through UsesTargetsList
aparajon Sep 14, 2026
b0349b9
Merge branch 'armand/multi-target-plan' into armand/multi-target-memb…
aparajon Sep 14, 2026
6f9ba19
fix(api): bind each member plan to the review round it was produced in
aparajon Sep 14, 2026
3f8d02a
Merge branch 'armand/multi-target-member-plans' into armand/multi-tar…
aparajon Sep 14, 2026
888c7a8
fix(api): name the target in an operation key only where it has to be…
aparajon Sep 14, 2026
95f4bd0
fix(tern): dispatch the plan the operation runs, not the apply's
aparajon Sep 14, 2026
e6c158b
Merge branch 'armand/multi-target-apply-fanout' into armand/multi-tar…
aparajon Sep 14, 2026
487fe30
Merge branch 'armand/multi-target-pull' into armand/multi-target-memb…
aparajon Sep 14, 2026
03ad6bf
Merge branch 'armand/multi-target-member-naming' into armand/multi-ta…
aparajon Sep 14, 2026
9370b76
fix(storage): accept a converged rollout member's task-less operation
aparajon Sep 14, 2026
9ab8592
Merge branch 'armand/multi-target-apply-fanout' into armand/multi-tar…
aparajon Sep 14, 2026
187943d
fix(cli): filter and name a multi-target pull's divergence report
aparajon Sep 14, 2026
af39df6
Merge branch 'armand/multi-target-pull' into armand/multi-target-memb…
aparajon Sep 14, 2026
bc0ffd4
fix(github): render every rollout member name as a code span
aparajon Sep 14, 2026
8eb813c
Merge branch 'armand/multi-target-member-naming' into armand/multi-ta…
aparajon Sep 14, 2026
9135242
fix(cli): attribute preview progress rows to their target
aparajon Sep 14, 2026
d15d638
fix(tern): drive every operation against the plan it names
aparajon Sep 14, 2026
38de446
Merge branch 'armand/multi-target-apply-fanout' into armand/multi-tar…
aparajon Sep 14, 2026
ea80679
Merge branch 'armand/multi-target-pull' into armand/multi-target-memb…
aparajon Sep 14, 2026
b540319
Merge branch 'armand/multi-target-member-naming' into armand/multi-ta…
aparajon Sep 14, 2026
ef652f0
fix(tern): keep the whole-apply drive entry point intact
aparajon Sep 14, 2026
b417c93
Merge remote-tracking branch 'origin/main' into armand/multi-target-m…
aparajon Sep 14, 2026
4f4afcb
Merge branch 'armand/multi-target-member-identity' into armand/multi-…
aparajon Sep 14, 2026
5246090
Merge branch 'armand/multi-target-op-plan' into armand/multi-target-c…
aparajon Sep 14, 2026
863feee
Merge branch 'armand/multi-target-config' into armand/multi-target-plan
aparajon Sep 14, 2026
bf39589
Merge branch 'armand/multi-target-plan' into armand/multi-target-memb…
aparajon Sep 14, 2026
7ed20bd
Merge branch 'armand/multi-target-member-plans' into armand/multi-tar…
aparajon Sep 14, 2026
5abe6bb
fix(tern): name the operation on every local drive call
aparajon Sep 14, 2026
502e028
Merge branch 'armand/multi-target-apply-fanout' into armand/multi-tar…
aparajon Sep 14, 2026
d708f1e
Merge branch 'armand/multi-target-pull' into armand/multi-target-memb…
aparajon Sep 14, 2026
2540e44
Merge branch 'armand/multi-target-member-naming' into armand/multi-ta…
aparajon Sep 14, 2026
d2dc605
fix(tern): fail closed when a task-less operation resolves to no plan
aparajon Sep 14, 2026
bc0f6c7
Merge branch 'armand/multi-target-apply-fanout' into armand/multi-tar…
aparajon Sep 14, 2026
9d7a825
Merge branch 'armand/multi-target-pull' into armand/multi-target-memb…
aparajon Sep 14, 2026
208ecaa
Merge branch 'armand/multi-target-member-naming' into armand/multi-ta…
aparajon Sep 14, 2026
6595a9c
feat(tern): key a change set by the work it would run
aparajon Sep 17, 2026
0157f0f
feat(api): carry each rollout member's plan and its grouping key
aparajon Sep 17, 2026
bf36a87
feat(github): say how much a rollout's targets agree this round
aparajon Sep 17, 2026
f2ca09b
feat(github): show every target's plan, not just the reviewed one
aparajon Sep 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 79 additions & 1 deletion TEMPLATES.md
Original file line number Diff line number Diff line change
Expand Up @@ -1289,7 +1289,7 @@ schemabot apply -e production

*Requested by @jackjackbits at 2026-01-01 00:00:00 UTC · planned from [`abcdef1`](https://github.com/block/schemabot/commit/abcdef1234567890abcdef1234567890abcdef12)*

✅ **Same plan on all 3 deployments** (eu, au, us).
✅ **Same plan on all 3 deployments** (`eu`, `au`, `us`).

```sql
CREATE TABLE `users` (
Expand Down Expand Up @@ -1421,6 +1421,84 @@ ALTER TABLE `products` ADD INDEX `idx_category_price`(`category`, `price`);
📋 **Plan**: **2** tables to create, **1** table to alter


---

▶️ **To apply** all schema changes from this PR, comment:
```
schemabot apply -e production
```

</details>

<details>
<summary><a name="targets-converging"></a><strong>Targets Converging</strong></summary>


## Schema Change Plan — Production

**Database**: `testapp` | **Type**: `MySQL` | **Schema Name**: `testapp`

*Requested by @jackjackbits at 2026-01-01 00:00:00 UTC · planned from [`abcdef1`](https://github.com/block/schemabot/commit/abcdef1234567890abcdef1234567890abcdef12)*

✅ **Planned separately for all 3 targets** (`primary/testapp_1`, `primary/testapp_2`, `primary/testapp_3`) — 2 need this change, 1 is already at this schema.

**`primary/testapp_1` (primary), `primary/testapp_3`** — 1 DDL statement

```sql
ALTER TABLE `users` ADD COLUMN `email` varchar(255);
```

**`primary/testapp_2`** — already at this schema, nothing to apply.

📋 **Plan**: 1 DDL statement on 2 of 3 targets


---

▶️ **To apply** all schema changes from this PR, comment:
```
schemabot apply -e production
```

</details>

<details>
<summary><a name="targets-diverging"></a><strong>Targets Diverging</strong></summary>


## Schema Change Plan — Production

**Database**: `testapp` | **Type**: `MySQL` | **Schema Name**: `testapp`

*Requested by @jackjackbits at 2026-01-01 00:00:00 UTC · planned from [`abcdef1`](https://github.com/block/schemabot/commit/abcdef1234567890abcdef1234567890abcdef12)*

✅ **Planned separately for all 3 targets** (`primary/testapp_1`, `primary/testapp_2`, `primary/testapp_3`) — 2 distinct plans. Each target applies its own.

<details open>
<summary><b>`primary/testapp_1` (primary), `primary/testapp_2` — 1 DDL statement</b></summary>

```sql
ALTER TABLE `users` ADD COLUMN `email` varchar(255);
```

</details>

<details>
<summary><b>`primary/testapp_3` — 2 DDL statements</b></summary>

```sql
ALTER TABLE `users` ADD COLUMN `email` varchar(255);

ALTER TABLE `users` ADD INDEX `idx_email`(`email`);
```

</details>

⚠️ Applying runs each target's own plan, including the ones collapsed above.

📋 **Plan**: 2 distinct plans on 3 targets


---

▶️ **To apply** all schema changes from this PR, comment:
Expand Down
46 changes: 46 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
- [Local Mode](#local-mode)
- [gRPC Mode](#grpc-mode)
- [Multi-Deployment Environments](#multi-deployment-environments)
- [Multi-Target Environment (preview)](#multi-target-environment-preview)
- [Environment Order](#environment-order)
- [Hybrid Mode](#hybrid-mode)
- [Drivers](#drivers)
Expand Down Expand Up @@ -266,6 +267,51 @@ A multi-deployment environment is gated on every deployment agreeing with one re
Every deployment name in `deployment_order` must be lowercase; the server
refuses to start otherwise.

## Multi-Target Environment (preview)

A database that lives on more than one target replaces the scalar `target` with a `targets` list. Each entry is one target the environment addresses, and rollout follows the listed order.

> **MySQL only.** `targets` is rejected at startup on any other database type. Addressing N targets has to be represented on every operator-facing surface — the plan comment, progress, the terminal summary — and that presentation is built per engine, so the feature is enabled per engine as the work lands.

> **Config surface only in this release.** `targets` resolves to one rollout member per target, but the plan and apply paths still treat every member of an environment the way they treat deployments.

```yaml
databases:
payments:
type: mysql
environments:
production:
deployment: payments-a
targets:
- payments-001
- payments-002
```

A `targets` list can also sit inside a `deployments` map entry, for a database whose targets are spread across several deployments:

```yaml
production:
deployments:
payments-a:
targets:
- payments-001
- payments-002
payments-b:
target: payments-003
```

Rules:

- `targets` requires `type: mysql`. Configuring it on a `vitess`, `strata`, or `postgres` database fails validation at startup.
- `targets` is mutually exclusive with `target` at the same level, and with a local `dsn` / `dsn_from`.
- An environment-level `targets` list is mutually exclusive with an environment-level `deployments` map, the same way an environment-level `target` is. A `targets` list inside a `deployments` entry is how the two combine.
- The list MUST contain at least one entry, and no entry may be empty.
- No entry may contain `/`. A deployment addressing several targets names each one in its members' operation keys, and `/` separates a key's components.
- One deployment may not list the same target twice. A rollout member is identified by its deployment and target together, so the same target under two different deployments is two distinct members and is allowed.
- Members resolve deployments outermost: every target of the first deployment, then every target of the next.

`targets` and `deployments` both fan an environment out across several members, but they mean different things. The deployments of one environment are expected to hold the same schema, so a difference between them is drift to surface. The targets of one environment are each planned on their own, so a difference between them is ordinary.

## Environment Order

Environment availability and promotion order are server-owned. Repository `schemabot.yaml` files identify the database and type; they do not list or opt into environments. SchemaBot resolves the environments for a database from server config:
Expand Down
129 changes: 128 additions & 1 deletion docs/schema-intelligence.md
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,80 @@ A clean audit returns `lint: []`; an omitted field means lint was not requested.
Pull runs schema-shape rules only. Rules about proposed changes, such as unsafe
drops, require a plan. See [lint and safety levels](lint-and-safety-levels.md#auditing-a-live-schema-pull---lint).

### Databases that span several targets

A database whose environment lists `targets` addresses several targets at once,
and each holds its own schema. There is no single live schema to return, so a
pull returns the primary target's — the one a caller materializes — plus a
`targets` array naming every target the environment addresses and how each of
the others differs from the primary.

```sh
schemabot pull -d shop -e production
```

```sql
-- Target `shop-001` — primary target, whose schema is below
-- Target `shop-002` — same schema as the primary target
-- Target `shop-003` — 2 tables differ from the primary target
-- shop.audit_log: differs
-- shop.order_events: missing
```

<details>
<summary>API equivalent</summary>

```http
POST /api/pull
Content-Type: application/json

{"database": "shop", "environment": "production"}
```

```json
{
"database": "shop",
"type": "mysql",
"environment": "production",
"table_count": 4,
"namespaces": {
"shop": {"tables": {"…": "CREATE TABLE …"}}
},
"targets": [
{"deployment": "commerce-a", "target": "shop-001", "table_count": 4, "primary": true},
{"deployment": "commerce-a", "target": "shop-002", "table_count": 4},
{"deployment": "commerce-a", "target": "shop-003", "table_count": 3, "diverged_tables": [
{"namespace": "shop", "table": "audit_log", "difference": "differs"},
{"namespace": "shop", "table": "order_events", "difference": "only_on_primary"}
]}
]
}
```

</details>

Read the array as the environment's whole member set. Exactly one entry carries
`"primary": true`, and it is the target whose schema is in `namespaces`; it
never carries `diverged_tables`, because it is the baseline the others are
compared against. A reconciling caller can take the member set straight from
this array rather than deriving it from target names.

`difference` is one of:

| Value | Meaning |
|---|---|
| `differs` | both targets hold the table, with different DDL |
| `only_on_primary` | only the primary target holds the table |
| `only_on_target` | only this target holds the table |

An empty `diverged_tables` means the two targets genuinely agree. It never means
the comparison was skipped: a target that cannot be pulled, or whose DDL cannot
be parsed, fails the whole pull rather than being reported as converged. Tables
are compared by their canonical parsed form, so formatting differences are not
divergence.

An environment that does not list `targets` carries no `targets` array at all.

### Engine support

The envelope differs by dialect:
Expand Down Expand Up @@ -497,7 +571,12 @@ from the server's build phase and its counters; it stays below 100 until the
apply completes and holds its last value between phases (see
[postgresql.md](postgresql.md)).
Sharded engines can add per-shard progress, and multi-deployment applies list
operations with their deployment, target, state, and cutover policy.
operations with their deployment, target, state, and cutover policy. A rollout's
table entries carry `deployment` and `target`, naming the member whose copy the
row reports. Attribute a table by the pair, never by `deployment` alone: one
deployment can address several targets, each running its own copy of the change,
so several rows for the same table share a deployment and differ only in their
target. Both fields are absent on an apply that runs against a single target.
The top-level `metadata` object carries engine-specific display fields when the
engine reports them: PostgreSQL applies report their position through `phase`,
`step`, `steps_total`, and `statement`; PlanetScale applies report deploy
Expand Down Expand Up @@ -538,6 +617,54 @@ Response excerpt (illustrative values):

</details>

<details>
<summary>Multi-target rollout response example</summary>

```http
GET /api/progress/apply/apply-example-75
```

Response excerpt (illustrative values):

```json
{
"apply_id": "apply-example-75",
"database": "shop",
"environment": "production",
"engine": "spirit",
"state": "running",
"operations": [
{"deployment": "commerce-a", "target": "shop-001", "state": "completed", "cutover_policy": "rolling"},
{"deployment": "commerce-a", "target": "shop-002", "state": "running", "cutover_policy": "rolling"}
],
"tables": [
{
"table_name": "orders",
"deployment": "commerce-a",
"target": "shop-001",
"ddl": "ALTER TABLE `orders` ADD INDEX `idx_status` (`status`)",
"status": "completed",
"percent_complete": 100
},
{
"table_name": "orders",
"deployment": "commerce-a",
"target": "shop-002",
"ddl": "ALTER TABLE `orders` ADD INDEX `idx_status` (`status`)",
"status": "running",
"rows_copied": 2000000,
"rows_total": 8000000,
"percent_complete": 25
}
]
}
```

Both rows report the same table under the same deployment, and only `target`
tells them apart.

</details>

<details>
<summary>PostgreSQL response example</summary>

Expand Down
Loading
Loading