authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-08-04 18:41:56-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-04 18:41:56-07:00
log7b38fb5c6b0fb46a7dadaf33e1739726be3dc104
tree2e4529d1a70ec9314281d6e8c51a493291ebb013
parent7537adc30f41059afcfeb8bd20368cb0acfce293
signature Signed by PGP key B5690EEEBB952194

fix(server): allow remote updates with database migrations (#5374)


17 files changed, 437 insertions(+), 157 deletions(-)

apps/server/src/cli/connect.test.ts+10
......@@ -10,6 +10,7 @@ import * as Option from "effect/Option";
1010import * as References from "effect/References";
1111import * as Terminal from "effect/Terminal";
1212
13import * as BootService from "../cloud/bootService.ts";
1314import {
1415 acquireRelayClientForLink,
1516 formatHeadlessAuthorizationPrompt,
......@@ -63,6 +64,15 @@ it.effect("treats cancelling optional background setup as a successful skip", ()
6364 }),
6465);
6566
67it.effect("keeps a successful connection when a remote service update is pending", () =>
68 Effect.gen(function* () {
69 const result = yield* recoverServiceOnboardingOffer(
70 Effect.fail(new BootService.BootServiceUpdatePendingError()),
71 );
72 assert.isFalse(result);
73 }),
74);
75
6676it.effect("does not install the relay client when the user declines the managed download", () =>
6777 Effect.gen(function* () {
6878 let installCalls = 0;
apps/server/src/cli/service.ts+2
......@@ -185,6 +185,8 @@ export const recoverServiceOnboardingOffer = <R>(
185185 Console.warn(`Background setup did not finish: ${error.message}`).pipe(Effect.as(false)),
186186 BootServiceInstallError: (error) =>
187187 Console.warn(`Background setup did not finish: ${error.message}`).pipe(Effect.as(false)),
188 BootServiceUpdatePendingError: (error) =>
189 Console.warn(`Background setup did not finish: ${error.message}`).pipe(Effect.as(false)),
188190 }),
189191 );
190192
apps/server/src/cloud/bootService.test.ts+46-6
......@@ -15,7 +15,11 @@ import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawne
1515import * as ProcessRunner from "../processRunner.ts";
1616import * as BootService from "./bootService.ts";
1717import { pinnedRuntimePaths } from "./pinnedRuntime.ts";
18import { parseServiceState } from "./serviceProtocol.ts";
18import {
19 parseServiceState,
20 SERVICE_LAUNCHER_PROTOCOL,
21 serviceStateHasPendingUpdate,
22} from "./serviceProtocol.ts";
1923
2024it("keeps systemd pinned to the stable launcher rather than a versioned server", () => {
2125 const unit = BootService.renderBootServiceUnit({
......@@ -97,15 +101,24 @@ it.layer(NodeServices.layer)("boot service install", (it) => {
97101 const plan = yield* service.install;
98102
99103 expect(parseServiceState(yield* fs.readFileString(statePath))).toEqual({
100 protocol: 1,
104 protocol: SERVICE_LAUNCHER_PROTOCOL,
101105 activeVersion: "1.2.3",
102106 });
103107 expect(yield* fs.readFileString(plan.launcherPath)).toBe("export {};\n");
104108 expect((yield* service.status).current).toBe(true);
105 yield* fs.writeFileString(
106 statePath,
107 '{"protocol":1,"activeVersion":"1.2.3","update":{"id":"u","fromVersion":"1.2.3","targetVersion":"1.2.4","status":"pending"}}',
108 );
109 // @effect-diagnostics-next-line preferSchemaOverJson:off - fixed launcher-owned test document.
110 const pendingState = JSON.stringify({
111 protocol: SERVICE_LAUNCHER_PROTOCOL,
112 activeVersion: "1.2.3",
113 update: {
114 id: "u",
115 fromVersion: "1.2.3",
116 targetVersion: "1.2.4",
117 dbPath: "/tmp/state.sqlite",
118 status: "pending",
119 },
120 });
121 yield* fs.writeFileString(statePath, pendingState);
109122 expect((yield* service.status).current).toBe(false);
110123 expect(yield* service.uninstall).toBe(true);
111124 expect((yield* service.status).installed).toBe(false);
......@@ -141,6 +154,33 @@ it.layer(NodeServices.layer)("boot service install", (it) => {
141154 }),
142155 );
143156
157 it.effect("restarts without overwriting a pending remote update", () =>
158 Effect.gen(function* () {
159 const { service, fs, statePath, commands } = yield* makeHarness();
160 yield* service.install;
161 // @effect-diagnostics-next-line preferSchemaOverJson:off - fixed launcher-owned test document.
162 const pendingState = JSON.stringify({
163 protocol: SERVICE_LAUNCHER_PROTOCOL - 1,
164 activeVersion: "1.2.3",
165 update: {
166 id: "remote-update",
167 fromVersion: "1.2.3",
168 targetVersion: "1.2.4",
169 status: "pending",
170 },
171 });
172 yield* fs.writeFileString(statePath, pendingState);
173 commands.length = 0;
174
175 expect((yield* service.install.pipe(Effect.flip))._tag).toBe("BootServiceUpdatePendingError");
176 expect(serviceStateHasPendingUpdate(yield* fs.readFileString(statePath))).toBe(true);
177 expect(commands.filter((command) => command.startsWith("systemctl "))).toEqual([
178 "systemctl --user stop t3code.service",
179 "systemctl --user restart t3code.service",
180 ]);
181 }),
182 );
183
144184 it.effect("fails closed off Linux", () =>
145185 Effect.gen(function* () {
146186 const { service } = yield* makeHarness("darwin");
apps/server/src/cloud/bootService.ts+21-1
......@@ -21,6 +21,7 @@ import {
2121 SERVICE_LAUNCHER_PROTOCOL,
2222 SERVICE_STATE_FILE,
2323 parseServiceState,
24 serviceStateHasPendingUpdate,
2425 type ServiceState,
2526} from "./serviceProtocol.ts";
2627
......@@ -110,10 +111,20 @@ export class BootServiceInstallError extends Schema.TaggedErrorClass<BootService
110111 }
111112}
112113
114export class BootServiceUpdatePendingError extends Schema.TaggedErrorClass<BootServiceUpdatePendingError>()(
115 "BootServiceUpdatePendingError",
116 {},
117) {
118 override get message(): string {
119 return "A remote server update is still pending. Wait for it to finish, then retry.";
120 }
121}
122
113123export type BootServiceError =
114124 | BootServiceUnsupportedError
115125 | BootServiceCommandError
116 | BootServiceInstallError;
126 | BootServiceInstallError
127 | BootServiceUpdatePendingError;
117128
118129export interface BootServiceStatus {
119130 readonly supported: boolean;
......@@ -288,6 +299,15 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {
288299 }
289300
290301 yield* Effect.gen(function* () {
302 if (installed) {
303 const previousStateText = yield* fs.readFileString(statePath).pipe(Effect.option);
304 if (
305 Option.isSome(previousStateText) &&
306 serviceStateHasPendingUpdate(previousStateText.value)
307 ) {
308 return yield* new BootServiceUpdatePendingError();
309 }
310 }
291311 yield* fs
292312 .makeDirectory(unitDir, { recursive: true })
293313 .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause })));
apps/server/src/cloud/selfUpdate.test.ts+6-2
......@@ -11,6 +11,7 @@ import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawne
1111import * as ServerConfig from "../config.ts";
1212import * as ProcessRunner from "../processRunner.ts";
1313import * as ServiceLauncherClient from "./serviceLauncherClient.ts";
14import { SERVICE_LAUNCHER_PROTOCOL } from "./serviceProtocol.ts";
1415import * as ServerSelfUpdate from "./selfUpdate.ts";
1516
1617interface HarnessOptions {
......@@ -50,7 +51,11 @@ const makeHarness = Effect.fn("test.make_self_update_harness")(function* (
5051 const result =
5152 options.preflight === "blocked"
5253 ? { status: "blocked", version: "1.1.0", reason: "local update required" }
53 : { status: "ready", version: "1.1.0", launcherProtocol: 1 };
54 : {
55 status: "ready",
56 version: "1.1.0",
57 launcherProtocol: SERVICE_LAUNCHER_PROTOCOL,
58 };
5459 return {
5560 // @effect-diagnostics-next-line preferSchemaOverJson:off - fake child-process stdout.
5661 stdout: JSON.stringify(result),
......@@ -64,7 +69,6 @@ const makeHarness = Effect.fn("test.make_self_update_harness")(function* (
6469 });
6570 const launcher = ServiceLauncherClient.ServiceLauncherClient.of({
6671 managed: options.managed ?? true,
67 trial: false,
6872 requestUpdate:
6973 options.requestUpdate ??
7074 (() =>
apps/server/src/cloud/selfUpdate.ts+1-1
......@@ -170,7 +170,7 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* () {
170170
171171 yield* reportProgress("installing");
172172 const updateId = yield* launcher
173 .requestUpdate({ targetVersion })
173 .requestUpdate({ targetVersion, dbPath: serverConfig.dbPath })
174174 .pipe(
175175 Effect.mapError((error) =>
176176 failWith(
apps/server/src/cloud/serviceLauncherClient.test.ts+18-10
......@@ -5,6 +5,7 @@ import * as Fiber from "effect/Fiber";
55
66import {
77 SERVICE_LAUNCHER_CONTEXT_ENV,
8 SERVICE_LAUNCHER_PROTOCOL,
89 type ServiceLauncherChildMessage,
910 type ServiceLauncherParentMessage,
1011} from "./serviceProtocol.ts";
......@@ -53,10 +54,11 @@ it.effect("waits for the launcher to durably commit the trial update ID", () =>
5354 id: "update-1",
5455 fromVersion: "1.0.0",
5556 targetVersion: "1.1.0",
57 dbPath: "/tmp/state.sqlite",
5658 status: "pending" as const,
5759 };
5860 const host = new FakeLauncherProcess({
59 protocol: 1,
61 protocol: SERVICE_LAUNCHER_PROTOCOL,
6062 childVersion: "1.1.0",
6163 update: pending,
6264 });
......@@ -79,13 +81,14 @@ it.effect("waits for the launcher to durably commit the trial update ID", () =>
7981it.effect("returns the launcher-generated ID only after update acceptance", () =>
8082 Effect.gen(function* () {
8183 const host = new FakeLauncherProcess({
82 protocol: 1,
84 protocol: SERVICE_LAUNCHER_PROTOCOL,
8385 childVersion: "1.0.0",
8486 });
8587 const client = yield* makeClient(host, "1.0.0");
86 const requested = yield* Effect.forkChild(client.requestUpdate({ targetVersion: "1.1.0" }), {
87 startImmediately: true,
88 });
88 const requested = yield* Effect.forkChild(
89 client.requestUpdate({ targetVersion: "1.1.0", dbPath: "/tmp/state.sqlite" }),
90 { startImmediately: true },
91 );
8992 yield* Effect.yieldNow;
9093 host.emit({
9194 type: "update-accepted",
......@@ -97,11 +100,15 @@ it.effect("returns the launcher-generated ID only after update acceptance", () =
97100
98101it.effect("preserves a launcher rejection as a distinct error", () =>
99102 Effect.gen(function* () {
100 const host = new FakeLauncherProcess({ protocol: 1, childVersion: "1.0.0" });
101 const client = yield* makeClient(host, "1.0.0");
102 const requested = yield* Effect.forkChild(client.requestUpdate({ targetVersion: "1.1.0" }), {
103 startImmediately: true,
103 const host = new FakeLauncherProcess({
104 protocol: SERVICE_LAUNCHER_PROTOCOL,
105 childVersion: "1.0.0",
104106 });
107 const client = yield* makeClient(host, "1.0.0");
108 const requested = yield* Effect.forkChild(
109 client.requestUpdate({ targetVersion: "1.1.0", dbPath: "/tmp/state.sqlite" }),
110 { startImmediately: true },
111 );
105112 yield* Effect.yieldNow;
106113 host.emit({ type: "update-rejected", reason: "requires local update" });
107114 expect(yield* Fiber.join(requested).pipe(Effect.flip)).toMatchObject({
......@@ -115,12 +122,13 @@ it.effect("preserves a launcher rejection as a distinct error", () =>
115122it.effect("rejects contradictory trial context instead of leaving activation closed", () =>
116123 Effect.gen(function* () {
117124 const host = new FakeLauncherProcess({
118 protocol: 1,
125 protocol: SERVICE_LAUNCHER_PROTOCOL,
119126 childVersion: "1.1.0",
120127 update: {
121128 id: "update-1",
122129 fromVersion: "1.0.0",
123130 targetVersion: "1.2.0",
131 dbPath: "/tmp/state.sqlite",
124132 status: "pending",
125133 },
126134 });
apps/server/src/cloud/serviceLauncherClient.ts+10-6
......@@ -100,9 +100,9 @@ export class ServiceLauncherClient extends Context.Service<
100100 ServiceLauncherClient,
101101 {
102102 readonly managed: boolean;
103 readonly trial: boolean;
104103 readonly requestUpdate: (input: {
105104 readonly targetVersion: string;
105 readonly dbPath: string;
106106 }) => Effect.Effect<string, ServiceLauncherClientError | ServiceLauncherRejectedError>;
107107 readonly prepareTrial: Effect.Effect<
108108 ServerSelfUpdateOutcome | undefined,
......@@ -137,8 +137,8 @@ const resolveStartup = Effect.fn("cloud.service_launcher_client.resolve_startup"
137137
138138export const resolveServiceLauncherMode = Effect.fn("cloud.service_launcher_client.resolve_mode")(
139139 function* () {
140 const { context, managed } = yield* resolveStartup();
141 return { managed, trial: context?.update?.status === "pending" };
140 const { managed } = yield* resolveStartup();
141 return { managed };
142142 },
143143);
144144
......@@ -199,7 +199,7 @@ export const make = Effect.fn("cloud.service_launcher_client.make")(function* (o
199199 }),
200200 );
201201
202 const requestUpdate = (input: { readonly targetVersion: string }) =>
202 const requestUpdate = (input: { readonly targetVersion: string; readonly dbPath: string }) =>
203203 exchange(
204204 { type: "request-update", ...input },
205205 (reply) => reply.type === "update-accepted" || reply.type === "update-rejected",
......@@ -233,14 +233,18 @@ export const make = Effect.fn("cloud.service_launcher_client.make")(function* (o
233233 if (reply.type !== "committed") {
234234 return Effect.die("service launcher returned an impossible prepared response");
235235 }
236 return Effect.succeed({ ...pending, status: "committed" as const });
236 return Effect.succeed({
237 id: pending.id,
238 fromVersion: pending.fromVersion,
239 targetVersion: pending.targetVersion,
240 status: "committed" as const,
241 });
237242 }),
238243 )
239244 : Effect.succeed(outcome);
240245
241246 return ServiceLauncherClient.of({
242247 managed,
243 trial: pending !== undefined,
244248 requestUpdate,
245249 prepareTrial,
246250 });
apps/server/src/cloud/servicePreflight.test.ts+19-40
......@@ -1,47 +1,26 @@
1// @effect-diagnostics nodeBuiltinImport:off
2import * as NodeServices from "@effect/platform-node/NodeServices";
31import { expect, it } from "@effect/vitest";
4import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";
6import * as Path from "effect/Path";
7import * as NodeSqlite from "node:sqlite";
82
9import { migrationManifest } from "../persistence/Migrations.ts";
103import { runServicePreflight } from "./servicePreflight.ts";
4import { SERVICE_LAUNCHER_PROTOCOL } from "./serviceProtocol.ts";
115
12it.layer(NodeServices.layer)("service update preflight", (it) => {
13 it.effect("requires exact migration-manifest equality without mutating the database", () =>
14 Effect.gen(function* () {
15 const fs = yield* FileSystem.FileSystem;
16 const path = yield* Path.Path;
17 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-preflight-test-" });
18 const databasePath = path.join(root, "state.sqlite");
19 const database = new NodeSqlite.DatabaseSync(databasePath);
20 database.exec("CREATE TABLE effect_sql_migrations (migration_id INTEGER, name TEXT)");
21 const insert = database.prepare(
22 "INSERT INTO effect_sql_migrations (migration_id, name) VALUES (?, ?)",
23 );
24 for (const [id, name] of migrationManifest) insert.run(id, name);
25 database.close();
26
27 expect(runServicePreflight({ databasePath, launcherProtocol: 1, version: "1.2.3" })).toEqual({
28 status: "ready",
29 version: "1.2.3",
30 launcherProtocol: 1,
31 });
6it("requires the database-snapshot launcher protocol", () => {
7 expect(
8 runServicePreflight({
9 databasePath: "/missing/state.sqlite",
10 launcherProtocol: SERVICE_LAUNCHER_PROTOCOL - 1,
11 version: "1.2.3",
12 }),
13 ).toMatchObject({ status: "blocked", version: "1.2.3" });
3214
33 const changed = new NodeSqlite.DatabaseSync(databasePath);
34 changed.exec("DELETE FROM effect_sql_migrations WHERE migration_id = 35");
35 changed.close();
36 const blocked = runServicePreflight({
37 databasePath,
38 launcherProtocol: 1,
39 version: "1.2.3",
40 });
41 expect(blocked.status).toBe("blocked");
42 if (blocked.status === "blocked") {
43 expect(blocked.reason).toContain("npx t3@1.2.3 service update");
44 }
15 expect(
16 runServicePreflight({
17 databasePath: "/missing/state.sqlite",
18 launcherProtocol: SERVICE_LAUNCHER_PROTOCOL,
19 version: "1.2.3",
4520 }),
46 );
21 ).toEqual({
22 status: "ready",
23 version: "1.2.3",
24 launcherProtocol: SERVICE_LAUNCHER_PROTOCOL,
25 });
4726});
apps/server/src/cloud/servicePreflight.ts+1-38
......@@ -1,7 +1,4 @@
1import * as NodeSqlite from "node:sqlite";
2
31import packageJson from "../../package.json" with { type: "json" };
4import { migrationManifest } from "../persistence/Migrations.ts";
52import { SERVICE_LAUNCHER_PROTOCOL } from "./serviceProtocol.ts";
63
74export type ServicePreflightResult =
......@@ -16,20 +13,8 @@ export type ServicePreflightResult =
1613 readonly reason: string;
1714 };
1815
19const localUpdateReason = (version: string) =>
20 `This version includes a database update and cannot be installed remotely. Run \`npx t3@${version} service update\` on the server machine.`;
21
22const isMigrationRow = (
23 value: unknown,
24): value is { readonly migration_id: number; readonly name: string } =>
25 typeof value === "object" &&
26 value !== null &&
27 "migration_id" in value &&
28 typeof value.migration_id === "number" &&
29 "name" in value &&
30 typeof value.name === "string";
31
3216export function runServicePreflight(input: {
17 /** Older servers always pass this flag when invoking a staged preflight. */
3318 readonly databasePath: string;
3419 readonly launcherProtocol: number;
3520 readonly version?: string;
......@@ -44,28 +29,6 @@ export function runServicePreflight(input: {
4429 };
4530 }
4631
47 try {
48 const database = new NodeSqlite.DatabaseSync(input.databasePath, { readOnly: true });
49 try {
50 const rows: ReadonlyArray<unknown> = database
51 .prepare("SELECT migration_id, name FROM effect_sql_migrations ORDER BY migration_id")
52 .all();
53 const exact =
54 rows.length === migrationManifest.length &&
55 rows.every((row, index) => {
56 const expected = migrationManifest[index];
57 return (
58 isMigrationRow(row) && row.migration_id === expected?.[0] && row.name === expected?.[1]
59 );
60 });
61 if (!exact) return { status: "blocked", version, reason: localUpdateReason(version) };
62 } finally {
63 database.close();
64 }
65 } catch {
66 return { status: "blocked", version, reason: localUpdateReason(version) };
67 }
68
6932 return { status: "ready", version, launcherProtocol: SERVICE_LAUNCHER_PROTOCOL };
7033}
7134
apps/server/src/cloud/serviceProtocol.ts+23-4
......@@ -1,6 +1,7 @@
11import type { ServerSelfUpdateOutcome } from "@t3tools/contracts";
22
3export const SERVICE_LAUNCHER_PROTOCOL = 1 as const;
3/** Protocol 2 snapshots SQLite before trials so migrations can be rolled back safely. */
4export const SERVICE_LAUNCHER_PROTOCOL = 2 as const;
45export const SERVICE_LAUNCHER_CONTEXT_ENV = "T3_SERVICE_LAUNCHER_CONTEXT";
56export const SERVICE_LAUNCHER_FILE = "service-launcher.mjs";
67export const SERVICE_STATE_FILE = "service-state.json";
......@@ -9,6 +10,7 @@ export interface PendingServiceUpdate {
910 readonly id: string;
1011 readonly fromVersion: string;
1112 readonly targetVersion: string;
13 readonly dbPath: string;
1214 readonly status: "pending";
1315}
1416
......@@ -31,6 +33,7 @@ export type ServiceLauncherChildMessage =
3133 | {
3234 readonly type: "request-update";
3335 readonly targetVersion: string;
36 readonly dbPath: string;
3437 }
3538 | {
3639 readonly type: "prepared";
......@@ -78,7 +81,9 @@ export function decodeServiceUpdate(value: unknown): ServiceUpdateRecord | undef
7881 return undefined;
7982 }
8083 if (status === "pending") {
81 return { id, fromVersion, targetVersion, status };
84 return typeof value.dbPath === "string" && value.dbPath.trim() !== ""
85 ? { id, fromVersion, targetVersion, dbPath: value.dbPath, status }
86 : undefined;
8287 }
8388 if (
8489 (status === "committed" || status === "rolled-back" || status === "failed") &&
......@@ -165,6 +170,16 @@ export function parseServiceState(value: string): ServiceState | undefined {
165170 }
166171}
167172
173/** Detects an in-flight update across launcher protocol versions before replacing its state. */
174export function serviceStateHasPendingUpdate(value: string): boolean {
175 try {
176 const parsed: unknown = JSON.parse(value);
177 return isRecord(parsed) && isRecord(parsed.update) && parsed.update.status === "pending";
178 } catch {
179 return false;
180 }
181}
182
168183export function decodeServiceLauncherContext(value: string): ServiceLauncherContext | undefined {
169184 let parsed: unknown;
170185 try {
......@@ -202,8 +217,12 @@ export function decodeServiceLauncherChildMessage(
202217 value: unknown,
203218): ServiceLauncherChildMessage | undefined {
204219 if (!isRecord(value)) return undefined;
205 if (value.type === "request-update" && typeof value.targetVersion === "string") {
206 return { type: value.type, targetVersion: value.targetVersion };
220 if (
221 value.type === "request-update" &&
222 typeof value.targetVersion === "string" &&
223 typeof value.dbPath === "string"
224 ) {
225 return { type: value.type, targetVersion: value.targetVersion, dbPath: value.dbPath };
207226 }
208227 return value.type === "prepared" && typeof value.updateId === "string"
209228 ? { type: value.type, updateId: value.updateId }
apps/server/src/persistence/Layers/Sqlite.ts+11-17
......@@ -7,7 +7,6 @@ import type { SqlError } from "effect/unstable/sql/SqlError";
77
88import { runMigrations } from "../Migrations.ts";
99import { ServerConfig } from "../../config.ts";
10import * as ServiceLauncherClient from "../../cloud/serviceLauncherClient.ts";
1110
1211type RuntimeSqliteLayerConfig = {
1312 readonly filename: string;
......@@ -31,28 +30,24 @@ const makeRuntimeSqliteLayer = Effect.fn("makeRuntimeSqliteLayer")(function* (
3130 return clientModule.layer(config);
3231}, Layer.unwrap);
3332
34const setup = (trial: boolean) =>
35 Layer.effectDiscard(
36 Effect.gen(function* () {
37 const sql = yield* SqlClient.SqlClient;
38 yield* sql`PRAGMA foreign_keys = ON;`;
39 if (!trial) {
40 yield* sql`PRAGMA journal_mode = WAL;`;
41 yield* runMigrations();
42 }
43 }),
44 );
33const setup = Layer.effectDiscard(
34 Effect.gen(function* () {
35 const sql = yield* SqlClient.SqlClient;
36 yield* sql`PRAGMA foreign_keys = ON;`;
37 yield* sql`PRAGMA journal_mode = WAL;`;
38 yield* runMigrations();
39 }),
40);
4541
4642export const makeSqlitePersistenceLive = Effect.fn("makeSqlitePersistenceLive")(function* (
4743 dbPath: string,
48 options?: { readonly trial?: boolean },
4944) {
5045 const fs = yield* FileSystem.FileSystem;
5146 const path = yield* Path.Path;
5247 yield* fs.makeDirectory(path.dirname(dbPath), { recursive: true });
5348
5449 return Layer.provideMerge(
55 setup(options?.trial === true),
50 setup,
5651 makeRuntimeSqliteLayer({
5752 filename: dbPath,
5853 spanAttributes: {
......@@ -64,14 +59,13 @@ export const makeSqlitePersistenceLive = Effect.fn("makeSqlitePersistenceLive")(
6459}, Layer.unwrap);
6560
6661export const SqlitePersistenceMemory = Layer.provideMerge(
67 setup(false),
62 setup,
6863 makeRuntimeSqliteLayer({ filename: ":memory:" }),
6964);
7065
7166export const layerConfig = Layer.unwrap(
7267 Effect.gen(function* () {
7368 const { dbPath } = yield* ServerConfig;
74 const launcher = yield* ServiceLauncherClient.resolveServiceLauncherMode();
75 return makeSqlitePersistenceLive(dbPath, { trial: launcher.trial });
69 return makeSqlitePersistenceLive(dbPath);
7670 }),
7771);
apps/server/src/serviceLauncher.test.ts+95-8
......@@ -9,6 +9,7 @@ import {
99 compareExactServiceVersions,
1010 decodeServiceState,
1111 isExactServiceVersion,
12 SERVICE_LAUNCHER_PROTOCOL,
1213} from "./cloud/serviceProtocol.ts";
1314
1415it("accepts only exact semantic versions", () => {
......@@ -33,12 +34,13 @@ it("orders exact semantic versions without treating build metadata as precedence
3334it("rejects contradictory service state", () => {
3435 assert.isUndefined(
3536 decodeServiceState({
36 protocol: 1,
37 protocol: SERVICE_LAUNCHER_PROTOCOL,
3738 activeVersion: "0.0.31",
3839 update: {
3940 id: "update-1",
4041 fromVersion: "0.0.30",
4142 targetVersion: "0.0.32",
43 dbPath: "/tmp/state.sqlite",
4244 status: "pending",
4345 },
4446 }),
......@@ -46,12 +48,26 @@ it("rejects contradictory service state", () => {
4648
4749 assert.isUndefined(
4850 decodeServiceState({
49 protocol: 1,
51 protocol: SERVICE_LAUNCHER_PROTOCOL,
52 activeVersion: "1.0.0",
53 update: {
54 id: "update-3",
55 fromVersion: "1.0.0",
56 targetVersion: "1.1.0",
57 status: "pending",
58 },
59 }),
60 );
61
62 assert.isUndefined(
63 decodeServiceState({
64 protocol: SERVICE_LAUNCHER_PROTOCOL,
5065 activeVersion: "1.0.0",
5166 update: {
5267 id: "update-2",
5368 fromVersion: "1.0.0",
5469 targetVersion: "0.9.0",
70 dbPath: "/tmp/state.sqlite",
5571 status: "pending",
5672 },
5773 }),
......@@ -66,7 +82,7 @@ it.layer(NodeServices.layer)("service state persistence", (it) => {
6682 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-launcher-test-" });
6783 const statePath = path.join(root, "runtime", "service-state.json");
6884 const state = {
69 protocol: 1,
85 protocol: SERVICE_LAUNCHER_PROTOCOL,
7086 activeVersion: "0.0.31",
7187 } as const;
7288
......@@ -88,7 +104,7 @@ it.layer(NodeServices.layer)("service state persistence", (it) => {
88104 yield* fs.writeFileString(path.join(versionDir, ".install-complete"), "1.0.0\n");
89105 yield* Effect.promise(() =>
90106 writeServiceState(statePath, {
91 protocol: 1,
107 protocol: SERVICE_LAUNCHER_PROTOCOL,
92108 activeVersion: "1.0.0",
93109 }),
94110 );
......@@ -106,6 +122,11 @@ it.layer(NodeServices.layer)("service state persistence", (it) => {
106122 const path = yield* Path.Path;
107123 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-launcher-flow-" });
108124 const statePath = path.join(root, "runtime", "service-state.json");
125 const databasePath = path.join(root, "userdata", "state.sqlite");
126 yield* fs.makeDirectory(path.dirname(databasePath), { recursive: true });
127 yield* fs.writeFileString(databasePath, "before trial");
128 // @effect-diagnostics-next-line preferSchemaOverJson:off - embeds a path in fake child source.
129 const encodedDatabasePath = JSON.stringify(databasePath);
109130 const childSource = `
110131const context = JSON.parse(process.env.T3_SERVICE_LAUNCHER_CONTEXT);
111132if (context.update?.status === "pending") {
......@@ -114,7 +135,7 @@ if (context.update?.status === "pending") {
114135 if (message.type === "committed") process.exit(0);
115136 });
116137} else if (context.update === undefined) {
117 process.send({ type: "request-update", targetVersion: "1.1.0" });
138 process.send({ type: "request-update", targetVersion: "1.1.0", dbPath: ${encodedDatabasePath} });
118139 setInterval(() => {}, 1_000);
119140} else {
120141 process.exit(0);
......@@ -129,7 +150,7 @@ if (context.update?.status === "pending") {
129150 }
130151 yield* Effect.promise(() =>
131152 writeServiceState(statePath, {
132 protocol: 1,
153 protocol: SERVICE_LAUNCHER_PROTOCOL,
133154 activeVersion: "1.0.0",
134155 }),
135156 );
......@@ -154,12 +175,17 @@ if (context.update?.status === "pending") {
154175 const path = yield* Path.Path;
155176 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-launcher-rollback-" });
156177 const statePath = path.join(root, "runtime", "service-state.json");
178 const databasePath = path.join(root, "userdata", "state.sqlite");
179 yield* fs.makeDirectory(path.dirname(databasePath), { recursive: true });
180 yield* fs.writeFileString(databasePath, "before trial");
181 // @effect-diagnostics-next-line preferSchemaOverJson:off - embeds a path in fake child source.
182 const encodedDatabasePath = JSON.stringify(databasePath);
157183 const childSource = `
158184const context = JSON.parse(process.env.T3_SERVICE_LAUNCHER_CONTEXT);
159185if (context.update?.status === "pending") {
160186 process.send({ type: "prepared", updateId: "wrong-update" });
161187} else if (context.update === undefined) {
162 process.send({ type: "request-update", targetVersion: "1.1.0" });
188 process.send({ type: "request-update", targetVersion: "1.1.0", dbPath: ${encodedDatabasePath} });
163189 setInterval(() => {}, 1_000);
164190} else {
165191 process.exit(0);
......@@ -174,7 +200,7 @@ if (context.update?.status === "pending") {
174200 }
175201 yield* Effect.promise(() =>
176202 writeServiceState(statePath, {
177 protocol: 1,
203 protocol: SERVICE_LAUNCHER_PROTOCOL,
178204 activeVersion: "1.0.0",
179205 }),
180206 );
......@@ -196,4 +222,65 @@ if (context.update?.status === "pending") {
196222 );
197223 }),
198224 );
225
226 it.effect("restores the database when a migrating trial exits", () =>
227 Effect.gen(function* () {
228 const fs = yield* FileSystem.FileSystem;
229 const path = yield* Path.Path;
230 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-launcher-db-" });
231 const statePath = path.join(root, "runtime", "service-state.json");
232 const databasePath = path.join(root, "userdata", "state.sqlite");
233 const original = "database before migration";
234 yield* fs.makeDirectory(path.dirname(databasePath), { recursive: true });
235 yield* fs.writeFileString(databasePath, original);
236 // @effect-diagnostics-next-line preferSchemaOverJson:off - embeds a path in fake child source.
237 const encodedDatabasePath = JSON.stringify(databasePath);
238 const childSource = `
239import { writeFileSync } from "node:fs";
240const context = JSON.parse(process.env.T3_SERVICE_LAUNCHER_CONTEXT);
241if (context.update?.status === "pending") {
242 writeFileSync(context.update.dbPath, "database after migration");
243 writeFileSync(context.update.dbPath + "-wal", "trial wal");
244 writeFileSync(context.update.dbPath + "-shm", "trial shm");
245 process.exit(1);
246} else if (context.update === undefined) {
247 process.send({ type: "request-update", targetVersion: "1.1.0", dbPath: ${encodedDatabasePath} });
248 setInterval(() => {}, 1_000);
249} else {
250 process.exit(0);
251}
252`;
253 for (const version of ["1.0.0", "1.1.0"]) {
254 const versionDir = path.join(root, "runtime", "versions", version);
255 const entryPath = path.join(versionDir, "node_modules", "t3", "dist", "bin.mjs");
256 yield* fs.makeDirectory(path.dirname(entryPath), { recursive: true });
257 yield* fs.writeFileString(entryPath, childSource);
258 yield* fs.writeFileString(path.join(versionDir, ".install-complete"), `${version}\n`);
259 }
260 yield* Effect.promise(() =>
261 writeServiceState(statePath, {
262 protocol: SERVICE_LAUNCHER_PROTOCOL,
263 activeVersion: "1.0.0",
264 }),
265 );
266
267 const launcher = new Launcher(root, yield* Effect.promise(() => readServiceState(statePath)));
268 yield* Effect.promise(() =>
269 launcher.run().then(
270 () => Promise.reject(new Error("launcher unexpectedly completed")),
271 () => Promise.resolve(),
272 ),
273 );
274
275 const state = yield* Effect.promise(() => readServiceState(statePath));
276 assert.equal(state.activeVersion, "1.0.0");
277 assert.equal(state.update?.status, "rolled-back");
278 assert.equal(yield* fs.readFileString(databasePath), original);
279 assert.isFalse(yield* fs.exists(`${databasePath}-wal`));
280 assert.isFalse(yield* fs.exists(`${databasePath}-shm`));
281 const updateId = state.update?.id;
282 assert.isDefined(updateId);
283 assert.isFalse(yield* fs.exists(path.join(root, "runtime", "db-backup", updateId)));
284 }),
285 );
199286});
apps/server/src/serviceLauncher.ts+141-4
......@@ -48,6 +48,121 @@ const runtimePaths = (baseDir: string, version: string) => {
4848 };
4949};
5050
51/** SQLite persists across the main file plus its WAL and shared-memory sidecars. */
52const DB_FILE_SUFFIXES = ["", "-wal", "-shm"] as const;
53const RESTORE_MARKER = ".restore-pending";
54
55const databaseBackupDir = (baseDir: string, updateId: string) =>
56 NodePath.join(baseDir, "runtime", "db-backup", updateId);
57
58const databaseBackupFile = (backupDir: string, suffix: (typeof DB_FILE_SUFFIXES)[number]) =>
59 NodePath.join(backupDir, suffix === "" ? "database" : `database${suffix}`);
60
61async function pathExists(target: string): Promise<boolean> {
62 try {
63 await NodeFSP.access(target);
64 return true;
65 } catch (cause) {
66 if (cause instanceof Error && "code" in cause && cause.code === "ENOENT") return false;
67 throw cause;
68 }
69}
70
71async function syncFile(filePath: string): Promise<void> {
72 const handle = await NodeFSP.open(filePath, "r");
73 try {
74 await handle.sync();
75 } finally {
76 await handle.close();
77 }
78}
79
80async function syncDirectory(directory: string): Promise<void> {
81 const handle = await NodeFSP.open(directory, "r");
82 try {
83 await handle.sync();
84 } finally {
85 await handle.close();
86 }
87}
88
89/**
90 * Snapshots the database once per update before the first trial. A completed
91 * backup is never overwritten because a restarted launcher may be looking at
92 * database writes from an earlier attempt by the same trial.
93 */
94async function backupDatabaseOnce(baseDir: string, pending: PendingServiceUpdate): Promise<void> {
95 const backupDir = databaseBackupDir(baseDir, pending.id);
96 if (await pathExists(backupDir)) return;
97
98 const stagingDir = `${backupDir}.staging`;
99 await NodeFSP.rm(stagingDir, { recursive: true, force: true });
100 await NodeFSP.mkdir(stagingDir, { recursive: true, mode: 0o700 });
101 try {
102 for (const suffix of DB_FILE_SUFFIXES) {
103 const source = `${pending.dbPath}${suffix}`;
104 if (suffix !== "" && !(await pathExists(source))) continue;
105 const destination = databaseBackupFile(stagingDir, suffix);
106 await NodeFSP.copyFile(source, destination);
107 await syncFile(destination);
108 }
109 await NodeFSP.rename(stagingDir, backupDir);
110 await syncDirectory(NodePath.dirname(backupDir));
111 } catch (cause) {
112 await NodeFSP.rm(stagingDir, { recursive: true, force: true }).catch(() => undefined);
113 throw cause;
114 }
115}
116
117const restoreMarkerPath = (baseDir: string, updateId: string) =>
118 NodePath.join(databaseBackupDir(baseDir, updateId), RESTORE_MARKER);
119
120const databaseRestorePending = (baseDir: string, pending: PendingServiceUpdate) =>
121 pathExists(restoreMarkerPath(baseDir, pending.id));
122
123/** Mark rollback before changing live files so launcher recovery cannot boot a partial restore. */
124async function markDatabaseRestorePending(backupDir: string): Promise<void> {
125 const markerPath = NodePath.join(backupDir, RESTORE_MARKER);
126 if (!(await pathExists(markerPath))) {
127 const handle = await NodeFSP.open(markerPath, "wx", 0o600);
128 try {
129 await handle.sync();
130 } finally {
131 await handle.close();
132 }
133 await syncDirectory(backupDir);
134 }
135}
136
137/** Restore is retryable after any process crash while the backup directory remains. */
138async function restoreDatabaseBackup(
139 baseDir: string,
140 pending: PendingServiceUpdate,
141): Promise<void> {
142 const backupDir = databaseBackupDir(baseDir, pending.id);
143 if (!(await pathExists(backupDir))) return;
144
145 await markDatabaseRestorePending(backupDir);
146 for (const suffix of DB_FILE_SUFFIXES) {
147 const target = `${pending.dbPath}${suffix}`;
148 const source = databaseBackupFile(backupDir, suffix);
149 if (await pathExists(source)) {
150 await NodeFSP.copyFile(source, target);
151 await syncFile(target);
152 } else {
153 await NodeFSP.rm(target, { force: true });
154 }
155 }
156 await syncDirectory(NodePath.dirname(pending.dbPath));
157}
158
159async function discardDatabaseBackup(baseDir: string, updateId: string): Promise<void> {
160 const backupDir = databaseBackupDir(baseDir, updateId);
161 if (!(await pathExists(backupDir))) return;
162 await NodeFSP.rm(backupDir, { recursive: true, force: true });
163 await syncDirectory(NodePath.dirname(backupDir));
164}
165
51166export async function readServiceState(filePath: string): Promise<ServiceState> {
52167 const contents = await NodeFSP.readFile(filePath, "utf8");
53168 const state = parseServiceState(contents);
......@@ -217,9 +332,16 @@ export class Launcher {
217332 async #recover(): Promise<void> {
218333 const update = this.#state.update;
219334 if (update?.status !== "pending") {
335 if (update !== undefined) {
336 await discardDatabaseBackup(this.#baseDir, update.id).catch(() => undefined);
337 }
220338 await this.#startChild(this.#state.activeVersion, "active", update);
221339 return;
222340 }
341 if (await databaseRestorePending(this.#baseDir, update)) {
342 await this.#returnToPrevious(update, "failed", "rollback-interrupted");
343 return;
344 }
223345 if (!(await runtimeExists(this.#baseDir, update.targetVersion))) {
224346 await this.#returnToPrevious(update, "failed", "target-runtime-missing");
225347 return;
......@@ -228,6 +350,13 @@ export class Launcher {
228350 }
229351
230352 async #startTrial(pending: PendingServiceUpdate): Promise<void> {
353 // The previous child is dead here, so all three SQLite files are quiescent.
354 try {
355 await backupDatabaseOnce(this.#baseDir, pending);
356 } catch {
357 await this.#returnToPrevious(pending, "failed", "db-backup-failed");
358 return;
359 }
231360 try {
232361 await this.#startChild(pending.targetVersion, "trial", pending);
233362 } catch {
......@@ -322,6 +451,10 @@ export class Launcher {
322451 await reject("Remote updates must select a newer server version.");
323452 return;
324453 }
454 if (!NodePath.isAbsolute(message.dbPath)) {
455 await reject("The requested database path is not absolute.");
456 return;
457 }
325458 if (!(await runtimeExists(this.#baseDir, message.targetVersion))) {
326459 await reject("The requested target runtime is missing or incomplete.");
327460 return;
......@@ -331,6 +464,7 @@ export class Launcher {
331464 id: NodeCrypto.randomUUID(),
332465 fromVersion: child.version,
333466 targetVersion: message.targetVersion,
467 dbPath: message.dbPath,
334468 status: "pending",
335469 };
336470 const next: ServiceState = { ...this.#state, update: pending };
......@@ -375,6 +509,7 @@ export class Launcher {
375509 await writeServiceState(this.#statePath, next);
376510 this.#state = next;
377511 child.role = "active";
512 await discardDatabaseBackup(this.#baseDir, committed.id).catch(() => undefined);
378513 await sendMessage(child.process, { type: "committed", updateId: committed.id });
379514 }
380515
......@@ -423,6 +558,11 @@ export class Launcher {
423558 reason: string,
424559 child?: ManagedChild,
425560 ): Promise<void> {
561 if (child !== undefined) {
562 this.#child = null;
563 await terminateChild(child.process);
564 }
565 await restoreDatabaseBackup(this.#baseDir, pending);
426566 const outcome = terminalUpdate({ pending, status, reason });
427567 const next: ServiceState = {
428568 ...this.#state,
......@@ -431,10 +571,7 @@ export class Launcher {
431571 };
432572 await writeServiceState(this.#statePath, next);
433573 this.#state = next;
434 if (child !== undefined) {
435 this.#child = null;
436 await terminateChild(child.process);
437 }
574 await discardDatabaseBackup(this.#baseDir, pending.id).catch(() => undefined);
438575 await this.#startChild(next.activeVersion, "active", outcome);
439576 }
440577}
docs/internals/server-updates.md+25-15
......@@ -29,39 +29,49 @@ Every write uses same-directory replacement plus file and directory fsync.
2929## Remote Update
3030
31311. The active server installs `t3@<target>` into a unique staging directory.
322. The target runs `__service-preflight` against the active database in read-only mode.
322. The target runs `__service-preflight` and verifies that the stable launcher supports its update
33 protocol.
33343. The staging directory is renamed to its immutable version path only after preflight succeeds.
34354. The active child sends `request-update`. The launcher validates the child and target, writes
3536 pending state, generates the update ID, then replies `update-accepted`.
36375. After a short response-flush grace period, the launcher stops the active child.
376. The launcher starts the target as a trial and gives it the pending update over IPC.
387. The trial acquires dependencies, binds HTTP, starts every long-running root fiber, and verifies
39 that each root is parked at the activation gate. It does not run migrations.
408. The trial sends `prepared`. The launcher durably commits B before replying `committed`.
419. The child opens the existing activation gate, accepts commands, and publishes lifecycle ready
42 with the terminal update outcome.
386. With SQLite quiescent, the launcher snapshots the database, WAL, and shared-memory files.
397. The launcher starts the target as a trial and gives it the pending update over IPC.
408. The trial runs migrations, acquires dependencies, binds HTTP, starts every long-running root
41 fiber, and verifies that each root is parked at the activation gate.
429. The trial sends `prepared`. The launcher durably commits B, deletes the snapshot, then replies
43 `committed`.
4410. The child opens the existing activation gate, accepts commands, and publishes lifecycle ready
45 with the terminal update outcome.
4346
4447Post-commit startup does not call service `start`, `initialize`, `connect`, `load`, or `acquire`
4548operations. It only opens prepared gates and publishes prepared lifecycle state.
4649
4750The launcher serializes child exits, IPC messages, and timers. A trial must report prepared within
48120 seconds. If the trial exits or times out before prepared, the launcher records rollback and
49starts A. After commit, B is active and normal systemd restart policy applies.
51120 seconds. If the trial exits or times out before prepared, the launcher stops it, restores the
52snapshot, records rollback, and starts A. A durable restore marker makes an interrupted restore
53resume before either version can boot. After commit, B is active and normal systemd restart policy
54applies.
5055
51## Migration Boundary
56## Database Rollback
5257
53Remote update preflight requires exact equality between the database's applied `(id, name)` rows
54and the target release's migration manifest. A target with a missing, additional, renamed, or
55unknown migration is blocked. Remote updates never migrate or downgrade a database.
58The launcher snapshots `state.sqlite`, `state.sqlite-wal`, and `state.sqlite-shm` after the old
59server stops and before the trial starts. This makes trial migrations and writes reversible without
60requiring down migrations. The snapshot is retained across launcher restarts and is removed only
61after commit or after both restore and the terminal rollback state are durable.
5662
57This deliberately means any release containing a migration requires a local service update:
63The protocol version is part of the safety boundary. A target that requires database snapshots is
64blocked when the installed launcher is too old. Upgrade the launcher once with:
5865
5966```sh
6067npx t3@<version> service update
6168```
6269
6370The local command stops the unit, selects the new launcher and exact runtime, then restarts the
64service. Its normal server startup may run migrations.
71service. Later releases, including releases with migrations, can use the remote trial path.
72
73Snapshots briefly require enough free disk for another copy of the SQLite files. Attachments and
74other files under the state directory are outside this rollback boundary.
6575
6676## Client Correlation
6777
docs/user/background-service.md+5-2
......@@ -30,10 +30,13 @@ npx t3@latest service uninstall
3030```
3131
3232Updating restarts T3 Code briefly. Let active agent work and terminal commands finish first.
33If a remote update is already in progress, wait for it to finish before retrying a local update.
3334
3435The systemd unit runs a small stable launcher. Exact T3 Code versions are installed separately, so
35a failed remote candidate can return to the previous version without rewriting the unit. Releases
36that change the database must be installed with the local `service update` command above.
36a failed remote candidate can return to the previous version without rewriting the unit. The
37launcher snapshots the database before a remote candidate starts, so database updates roll back
38with the server version. An older launcher may require one local `service update` before this is
39available.
3740
3841## Using It with T3 Connect
3942
docs/user/updating.md+3-3
......@@ -31,9 +31,9 @@ The update does not remove saved threads, settings, or project files.
3131The available action depends on how that server was started. T3 Code does not update connected
3232servers silently in the background.
3333
34If the requested version includes a database update, remote installation stops before restart and
35asks you to run the exact `npx t3@<version> service update` command on the server machine. This is
36an intentional rollback-safety boundary.
34An older background-service launcher may ask you to run the exact
35`npx t3@<version> service update` command on the server machine. That one local update installs the
36rollback support needed for later remote updates, including versions that change the database.
3737
3838After selecting **Update server**, the warning becomes a three-step progress rail:
3939**Download**, **Install**, and **Resume**. The same progress appears in the conversation and in