Skip to content

[Bug]: t3 auth, t3 pair, t3 thread and other CLI commands apply the CLI's own migrations to a running server's database #26696

Open

Description

@ianrowe12

Before submitting

  • I searched existing issues and did not find a duplicate.
  • I included enough detail to reproduce or investigate the problem.

Area

apps/server

Steps to reproduce

This is the follow-up named in the #10516 triage ("treat 'mint without this checkout's runMigrations()' as the follow-up so auth pairing create cannot do the same thing with an explicit --base-dir"). #22855 closed #10516 by fixing worktree discovery in t3 pair, and its description says it does not cover this part. #16102 also left out "CLI auth locks" to keep its scope small.

Deterministic, from source on main @ 9826dbb990, with a scratch T3 home:

  1. node apps/server/src/bin.ts serve --base-dir "$H" --host 127.0.0.1 --port 39771 --no-browser. The ledger ends at 72 WaitingLinkRequestIndex.
  2. Stand in for a newer CLI: in the same checkout, add 073_CliOnlyProbe.ts (CREATE TABLE IF NOT EXISTS repro_cli_only_probe (...)) and its migrationEntries row. The running server already loaded its migration list at startup and is not affected by the edit.
  3. node apps/server/src/bin.ts auth session issue --base-dir "$H" --token-only
  4. SELECT migration_id, name FROM effect_sql_migrations ORDER BY migration_id DESC LIMIT 1 on $H/userdata/statev2.sqlite.
  5. Revert the edit, then restart the server (an unmodified main build).
  6. Stand in for a later build that ships a different migration 73: add 073_ReleasedFeature.ts, then start the server.

The repro script is attached below, and its output is under Logs. The command table further down is from repeating steps 1–4 once per command; that output isn't attached.

Expected behavior

A CLI command does not apply its own pending migrations to the database of a running server that owns the T3 home (server-owner.sqlite held). This is the migration-writing follow-up named in #10516, not the older-CLI case from #21736. How the CLI should behave instead (use the schema as it is, delegate to the server, or refuse) is for triage.

Actual behavior

  • Step 3 exits 0 and prints a token. The CLI logs nothing about migrations. The ledger now ends at 73 CliOnlyProbe. The server is still running and still holds the lock: a second serve on the same home exits 78 with "A T3 Code server already owns …".
  • Step 5: the server starts and logs Database migration history diverges from this build; recorded migration ids are skipped, not reconciled by name.
  • Step 6: the server exits 1 with This database's migration history would skip required schema migrations: 73_ReleasedFeature (recorded: CliOnlyProbe) … (persistence/Migrations.ts:197). This is the same outcome as [Bug]: Worktree pairing can migrate the live desktop database #10516, which now ends in a clear refusal instead of a missing-column crash.

I repeated steps 1–4 on a fresh home for each command. Every command that opens persistence applied the extra migration while the server ran:

Command (server running, --base-dir set) Exit Server alive Ledger max
t3 auth pairing list 0 yes 73
t3 auth session list 0 yes 73
t3 pair --ttl 5m 0 yes 73
t3 project add <dir> (live mode) 0 yes 73
t3 environment list 0 yes 73
t3 thread list 1 (no project in cwd) yes 73
t3 connect status 0 yes 73

Even read-only list commands migrate. A command that later fails also migrates first, because the layer is built before the command's handler runs.

Code path on main @ 9826dbb990:

  • EnvironmentAuth.layerRuntime provides layerStorage = Layer.mergeAll(ServerSecretStore.layer, SqlitePersistence.layerConfig) (auth/EnvironmentAuth.ts:1395-1400).
  • SqlitePersistence.layerConfig runs initializeV2Database(dbPath) (persistence/Sqlite.ts:72), then layerSetup, which runs runMigrations() (Sqlite.ts:33) and resets sqlite_stat1 (Sqlite.ts:36-40). None of these steps checks server ownership. The server takes ownership "Before anything opens the database" (server.ts:782-783). The CLI never does.
  • These commands provide that layer: cli/auth.ts:41, cli/pair.ts:440, cli/connect.ts:541, cli/thread.ts:239 and cli/environment.ts:40,47. cli/project.ts resolves EnvironmentAuth (:407) before it chooses live or offline mode, so the ownership check that fix(server): enforce one database owner and guard update rollback #16102 added for offline writes (:430-433) comes after the migrations have already run.

Two things are by design, so I'm not reporting them: CLI commands writing rows while the server runs (Sqlite.ts:17), and PRAGMA journal_mode = WAL, which does nothing on a database that is already in WAL mode.

Impact

Minor bug or occasional failure

Who hits it: someone running a CLI that has pending migrations against the same T3 home as a running server. For example, a stable 0.1.3 server stays running while npx t3@nightly auth session issue uses its home (the nightly CLI's migration list runs to 072). A source checkout run without --base-dir is another route, since its default home is T3CODE_HOME or ~/.t3.

Version or commit

main @ 9826dbb (2026-10-28)

Environment

macOS 27.0 (arm64), Node 26.0.0, server and CLI run from source (node apps/server/src/bin.ts). No provider sessions involved.

Logs or stack traces

== upstream/main 9826dbb990, T3 home /tmp/t3-i3-home.DYNs
== 1. start the server (unmodified main)
server pid 26852 listening on 39771
ledger:
72 WaitingLinkRequestIndex
71 PeerLinkFence
== 2. 'newer CLI': same checkout plus one migration (073_CliOnlyProbe)
t3 auth session issue exit=0 (token printed: yes)
ledger:
73 CliOnlyProbe
72 WaitingLinkRequestIndex
server still running: yes
second 'serve' on the same home exit=78 -> A T3 Code server already owns /tmp/t3-i3-home
== 3. restart the (unmodified) server on the migrated database
started; warning logged:
Database migration history diverges from this build; recorded migration ids are skipped, not reconciled by name.
== 4. a later build that ships a different migration 073 (ReleasedFeature)
start ok? 1 (pid alive: no)
This database's migration history would skip required schema migrations: 73_ReleasedFeature (recorded: CliOnlyProbe). The migrator skips every id up to the highest recorded id (73), regardless of name. It was probably written by a different build. Start the build that wrote it, ...

Screenshots, recordings, or supporting files

repro script (run from a scratch worktree of main; edits only that worktree and reverts them)
#!/bin/bash
set -u
WT=/path/to/scratch-worktree; SRV=$WT/apps/server
H=$(mktemp -d /tmp/t3-i3-home.XXXX); PORT=39771; DB=$H/userdata/statev2.sqlite
unset T3CODE_HOME; cd "$SRV"
ledger() { sqlite3 "$DB" "SELECT migration_id || ' ' || name FROM effect_sql_migrations ORDER BY migration_id DESC LIMIT 2;"; }
add_migration() { # $1 = name
  cat > src/persistence/Migrations/073_$1.ts <<TS
import * as Effect from "effect/Effect";
import * as SqlClient from "effect/sql/SqlClient";
export default Effect.gen(function* () {
  const sql = yield* SqlClient.SqlClient;
  yield* sql\`CREATE TABLE IF NOT EXISTS repro_$1 (id INTEGER PRIMARY KEY)\`;
});
TS
  perl -0pi -e "s|(import Migration0072 from \"./Migrations/072_WaitingLinkRequestIndex.ts\";)|\$1\nimport Migration0073 from \"./Migrations/073_$1.ts\";|; s|(  \[72, \"WaitingLinkRequestIndex\", Migration0072\],)|\$1\n  [73, \"$1\", Migration0073],|" src/persistence/Migrations.ts
}
revert_migration() { git -C "$WT" checkout -q -- apps/server/src/persistence/Migrations.ts; rm -f src/persistence/Migrations/073_*.ts; }
start_server() { node src/bin.ts serve --base-dir "$H" --host 127.0.0.1 --port $PORT --no-browser >"$1" 2>&1 & SERVER_PID=$!
  for _ in $(seq 1 120); do grep -q "Listening on" "$1" && return 0; kill -0 $SERVER_PID 2>/dev/null || return 1; sleep 0.5; done; return 1; }
stop_server() { kill $SERVER_PID; wait $SERVER_PID 2>/dev/null; }

start_server "$H/server1.log"; ledger
add_migration CliOnlyProbe
node src/bin.ts auth session issue --base-dir "$H" --token-only >/dev/null; echo "exit=$?"; ledger
node src/bin.ts serve --base-dir "$H" --host 127.0.0.1 --port $((PORT+1)) --no-browser >/dev/null 2>&1; echo "second serve exit=$?"
revert_migration; stop_server
start_server "$H/server2.log"; grep -o "Database migration history diverges[^\"]*" "$H/server2.log"; stop_server
add_migration ReleasedFeature; start_server "$H/server3.log"; grep -o "would skip required schema migrations[^\"]*" "$H/server3.log"; stop_server; revert_migration

Workaround

Run the CLI that matches the running server: ~/.t3/bin/t3, or npx t3@<server version>. Never run a source checkout's CLI without --base-dir pointing at that checkout's own T3 home.

Related: #10516 (closed by #22855, discovery only), #21736 (an older CLI holding the write lock, closed as not planned; this report is about a newer CLI changing the schema), #21936 (the same startup refusal after an intentional channel switch, closed as unsupported reuse), #21106 (skipped migrations from an unshipped build, closed as unsupported reuse), #15017 and #23599 (the V1 snapshot trap), #16102.

Investigated by Opus 5.5 with Claude Code. Reviewed by GPT 6.1 Sol.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions