| author | |
| committer | |
| log | 7b38fb5c6b0fb46a7dadaf33e1739726be3dc104 |
| tree | 2e4529d1a70ec9314281d6e8c51a493291ebb013 |
| parent | 7537adc30f41059afcfeb8bd20368cb0acfce293 |
| signature | Signed by PGP key B5690EEEBB952194 |
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"; |
| 10 | 10 | import * as References from "effect/References"; |
| 11 | 11 | import * as Terminal from "effect/Terminal"; |
| 12 | 12 | |
| 13 | import * as BootService from "../cloud/bootService.ts"; | |
| 13 | 14 | import { |
| 14 | 15 | acquireRelayClientForLink, |
| 15 | 16 | formatHeadlessAuthorizationPrompt, |
| ... | ... | @@ -63,6 +64,15 @@ it.effect("treats cancelling optional background setup as a successful skip", () |
| 63 | 64 | }), |
| 64 | 65 | ); |
| 65 | 66 | |
| 67 | it.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 | ||
| 66 | 76 | it.effect("does not install the relay client when the user declines the managed download", () => |
| 67 | 77 | Effect.gen(function* () { |
| 68 | 78 | let installCalls = 0; |
apps/server/src/cli/service.ts+2| ... | ... | @@ -185,6 +185,8 @@ export const recoverServiceOnboardingOffer = <R>( |
| 185 | 185 | Console.warn(`Background setup did not finish: ${error.message}`).pipe(Effect.as(false)), |
| 186 | 186 | BootServiceInstallError: (error) => |
| 187 | 187 | 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)), | |
| 188 | 190 | }), |
| 189 | 191 | ); |
| 190 | 192 |
apps/server/src/cloud/bootService.test.ts+46-6| ... | ... | @@ -15,7 +15,11 @@ import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawne |
| 15 | 15 | import * as ProcessRunner from "../processRunner.ts"; |
| 16 | 16 | import * as BootService from "./bootService.ts"; |
| 17 | 17 | import { pinnedRuntimePaths } from "./pinnedRuntime.ts"; |
| 18 | import { parseServiceState } from "./serviceProtocol.ts"; | |
| 18 | import { | |
| 19 | parseServiceState, | |
| 20 | SERVICE_LAUNCHER_PROTOCOL, | |
| 21 | serviceStateHasPendingUpdate, | |
| 22 | } from "./serviceProtocol.ts"; | |
| 19 | 23 | |
| 20 | 24 | it("keeps systemd pinned to the stable launcher rather than a versioned server", () => { |
| 21 | 25 | const unit = BootService.renderBootServiceUnit({ |
| ... | ... | @@ -97,15 +101,24 @@ it.layer(NodeServices.layer)("boot service install", (it) => { |
| 97 | 101 | const plan = yield* service.install; |
| 98 | 102 | |
| 99 | 103 | expect(parseServiceState(yield* fs.readFileString(statePath))).toEqual({ |
| 100 | protocol: 1, | |
| 104 | protocol: SERVICE_LAUNCHER_PROTOCOL, | |
| 101 | 105 | activeVersion: "1.2.3", |
| 102 | 106 | }); |
| 103 | 107 | expect(yield* fs.readFileString(plan.launcherPath)).toBe("export {};\n"); |
| 104 | 108 | 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); | |
| 109 | 122 | expect((yield* service.status).current).toBe(false); |
| 110 | 123 | expect(yield* service.uninstall).toBe(true); |
| 111 | 124 | expect((yield* service.status).installed).toBe(false); |
| ... | ... | @@ -141,6 +154,33 @@ it.layer(NodeServices.layer)("boot service install", (it) => { |
| 141 | 154 | }), |
| 142 | 155 | ); |
| 143 | 156 | |
| 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 | ||
| 144 | 184 | it.effect("fails closed off Linux", () => |
| 145 | 185 | Effect.gen(function* () { |
| 146 | 186 | const { service } = yield* makeHarness("darwin"); |
apps/server/src/cloud/bootService.ts+21-1| ... | ... | @@ -21,6 +21,7 @@ import { |
| 21 | 21 | SERVICE_LAUNCHER_PROTOCOL, |
| 22 | 22 | SERVICE_STATE_FILE, |
| 23 | 23 | parseServiceState, |
| 24 | serviceStateHasPendingUpdate, | |
| 24 | 25 | type ServiceState, |
| 25 | 26 | } from "./serviceProtocol.ts"; |
| 26 | 27 | |
| ... | ... | @@ -110,10 +111,20 @@ export class BootServiceInstallError extends Schema.TaggedErrorClass<BootService |
| 110 | 111 | } |
| 111 | 112 | } |
| 112 | 113 | |
| 114 | export 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 | ||
| 113 | 123 | export type BootServiceError = |
| 114 | 124 | | BootServiceUnsupportedError |
| 115 | 125 | | BootServiceCommandError |
| 116 | | BootServiceInstallError; | |
| 126 | | BootServiceInstallError | |
| 127 | | BootServiceUpdatePendingError; | |
| 117 | 128 | |
| 118 | 129 | export interface BootServiceStatus { |
| 119 | 130 | readonly supported: boolean; |
| ... | ... | @@ -288,6 +299,15 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { |
| 288 | 299 | } |
| 289 | 300 | |
| 290 | 301 | 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 | } | |
| 291 | 311 | yield* fs |
| 292 | 312 | .makeDirectory(unitDir, { recursive: true }) |
| 293 | 313 | .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 |
| 11 | 11 | import * as ServerConfig from "../config.ts"; |
| 12 | 12 | import * as ProcessRunner from "../processRunner.ts"; |
| 13 | 13 | import * as ServiceLauncherClient from "./serviceLauncherClient.ts"; |
| 14 | import { SERVICE_LAUNCHER_PROTOCOL } from "./serviceProtocol.ts"; | |
| 14 | 15 | import * as ServerSelfUpdate from "./selfUpdate.ts"; |
| 15 | 16 | |
| 16 | 17 | interface HarnessOptions { |
| ... | ... | @@ -50,7 +51,11 @@ const makeHarness = Effect.fn("test.make_self_update_harness")(function* ( |
| 50 | 51 | const result = |
| 51 | 52 | options.preflight === "blocked" |
| 52 | 53 | ? { 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 | }; | |
| 54 | 59 | return { |
| 55 | 60 | // @effect-diagnostics-next-line preferSchemaOverJson:off - fake child-process stdout. |
| 56 | 61 | stdout: JSON.stringify(result), |
| ... | ... | @@ -64,7 +69,6 @@ const makeHarness = Effect.fn("test.make_self_update_harness")(function* ( |
| 64 | 69 | }); |
| 65 | 70 | const launcher = ServiceLauncherClient.ServiceLauncherClient.of({ |
| 66 | 71 | managed: options.managed ?? true, |
| 67 | trial: false, | |
| 68 | 72 | requestUpdate: |
| 69 | 73 | options.requestUpdate ?? |
| 70 | 74 | (() => |
apps/server/src/cloud/selfUpdate.ts+1-1| ... | ... | @@ -170,7 +170,7 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* () { |
| 170 | 170 | |
| 171 | 171 | yield* reportProgress("installing"); |
| 172 | 172 | const updateId = yield* launcher |
| 173 | .requestUpdate({ targetVersion }) | |
| 173 | .requestUpdate({ targetVersion, dbPath: serverConfig.dbPath }) | |
| 174 | 174 | .pipe( |
| 175 | 175 | Effect.mapError((error) => |
| 176 | 176 | failWith( |
apps/server/src/cloud/serviceLauncherClient.test.ts+18-10| ... | ... | @@ -5,6 +5,7 @@ import * as Fiber from "effect/Fiber"; |
| 5 | 5 | |
| 6 | 6 | import { |
| 7 | 7 | SERVICE_LAUNCHER_CONTEXT_ENV, |
| 8 | SERVICE_LAUNCHER_PROTOCOL, | |
| 8 | 9 | type ServiceLauncherChildMessage, |
| 9 | 10 | type ServiceLauncherParentMessage, |
| 10 | 11 | } from "./serviceProtocol.ts"; |
| ... | ... | @@ -53,10 +54,11 @@ it.effect("waits for the launcher to durably commit the trial update ID", () => |
| 53 | 54 | id: "update-1", |
| 54 | 55 | fromVersion: "1.0.0", |
| 55 | 56 | targetVersion: "1.1.0", |
| 57 | dbPath: "/tmp/state.sqlite", | |
| 56 | 58 | status: "pending" as const, |
| 57 | 59 | }; |
| 58 | 60 | const host = new FakeLauncherProcess({ |
| 59 | protocol: 1, | |
| 61 | protocol: SERVICE_LAUNCHER_PROTOCOL, | |
| 60 | 62 | childVersion: "1.1.0", |
| 61 | 63 | update: pending, |
| 62 | 64 | }); |
| ... | ... | @@ -79,13 +81,14 @@ it.effect("waits for the launcher to durably commit the trial update ID", () => |
| 79 | 81 | it.effect("returns the launcher-generated ID only after update acceptance", () => |
| 80 | 82 | Effect.gen(function* () { |
| 81 | 83 | const host = new FakeLauncherProcess({ |
| 82 | protocol: 1, | |
| 84 | protocol: SERVICE_LAUNCHER_PROTOCOL, | |
| 83 | 85 | childVersion: "1.0.0", |
| 84 | 86 | }); |
| 85 | 87 | 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 | ); | |
| 89 | 92 | yield* Effect.yieldNow; |
| 90 | 93 | host.emit({ |
| 91 | 94 | type: "update-accepted", |
| ... | ... | @@ -97,11 +100,15 @@ it.effect("returns the launcher-generated ID only after update acceptance", () = |
| 97 | 100 | |
| 98 | 101 | it.effect("preserves a launcher rejection as a distinct error", () => |
| 99 | 102 | 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", | |
| 104 | 106 | }); |
| 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 | ); | |
| 105 | 112 | yield* Effect.yieldNow; |
| 106 | 113 | host.emit({ type: "update-rejected", reason: "requires local update" }); |
| 107 | 114 | expect(yield* Fiber.join(requested).pipe(Effect.flip)).toMatchObject({ |
| ... | ... | @@ -115,12 +122,13 @@ it.effect("preserves a launcher rejection as a distinct error", () => |
| 115 | 122 | it.effect("rejects contradictory trial context instead of leaving activation closed", () => |
| 116 | 123 | Effect.gen(function* () { |
| 117 | 124 | const host = new FakeLauncherProcess({ |
| 118 | protocol: 1, | |
| 125 | protocol: SERVICE_LAUNCHER_PROTOCOL, | |
| 119 | 126 | childVersion: "1.1.0", |
| 120 | 127 | update: { |
| 121 | 128 | id: "update-1", |
| 122 | 129 | fromVersion: "1.0.0", |
| 123 | 130 | targetVersion: "1.2.0", |
| 131 | dbPath: "/tmp/state.sqlite", | |
| 124 | 132 | status: "pending", |
| 125 | 133 | }, |
| 126 | 134 | }); |
apps/server/src/cloud/serviceLauncherClient.ts+10-6| ... | ... | @@ -100,9 +100,9 @@ export class ServiceLauncherClient extends Context.Service< |
| 100 | 100 | ServiceLauncherClient, |
| 101 | 101 | { |
| 102 | 102 | readonly managed: boolean; |
| 103 | readonly trial: boolean; | |
| 104 | 103 | readonly requestUpdate: (input: { |
| 105 | 104 | readonly targetVersion: string; |
| 105 | readonly dbPath: string; | |
| 106 | 106 | }) => Effect.Effect<string, ServiceLauncherClientError | ServiceLauncherRejectedError>; |
| 107 | 107 | readonly prepareTrial: Effect.Effect< |
| 108 | 108 | ServerSelfUpdateOutcome | undefined, |
| ... | ... | @@ -137,8 +137,8 @@ const resolveStartup = Effect.fn("cloud.service_launcher_client.resolve_startup" |
| 137 | 137 | |
| 138 | 138 | export const resolveServiceLauncherMode = Effect.fn("cloud.service_launcher_client.resolve_mode")( |
| 139 | 139 | function* () { |
| 140 | const { context, managed } = yield* resolveStartup(); | |
| 141 | return { managed, trial: context?.update?.status === "pending" }; | |
| 140 | const { managed } = yield* resolveStartup(); | |
| 141 | return { managed }; | |
| 142 | 142 | }, |
| 143 | 143 | ); |
| 144 | 144 | |
| ... | ... | @@ -199,7 +199,7 @@ export const make = Effect.fn("cloud.service_launcher_client.make")(function* (o |
| 199 | 199 | }), |
| 200 | 200 | ); |
| 201 | 201 | |
| 202 | const requestUpdate = (input: { readonly targetVersion: string }) => | |
| 202 | const requestUpdate = (input: { readonly targetVersion: string; readonly dbPath: string }) => | |
| 203 | 203 | exchange( |
| 204 | 204 | { type: "request-update", ...input }, |
| 205 | 205 | (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 |
| 233 | 233 | if (reply.type !== "committed") { |
| 234 | 234 | return Effect.die("service launcher returned an impossible prepared response"); |
| 235 | 235 | } |
| 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 | }); | |
| 237 | 242 | }), |
| 238 | 243 | ) |
| 239 | 244 | : Effect.succeed(outcome); |
| 240 | 245 | |
| 241 | 246 | return ServiceLauncherClient.of({ |
| 242 | 247 | managed, |
| 243 | trial: pending !== undefined, | |
| 244 | 248 | requestUpdate, |
| 245 | 249 | prepareTrial, |
| 246 | 250 | }); |
apps/server/src/cloud/servicePreflight.test.ts+19-40| ... | ... | @@ -1,47 +1,26 @@ |
| 1 | // @effect-diagnostics nodeBuiltinImport:off | |
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 3 | 1 | import { expect, it } from "@effect/vitest"; |
| 4 | import * as Effect from "effect/Effect"; | |
| 5 | import * as FileSystem from "effect/FileSystem"; | |
| 6 | import * as Path from "effect/Path"; | |
| 7 | import * as NodeSqlite from "node:sqlite"; | |
| 8 | 2 | |
| 9 | import { migrationManifest } from "../persistence/Migrations.ts"; | |
| 10 | 3 | import { runServicePreflight } from "./servicePreflight.ts"; |
| 4 | import { SERVICE_LAUNCHER_PROTOCOL } from "./serviceProtocol.ts"; | |
| 11 | 5 | |
| 12 | it.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 | }); | |
| 6 | it("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" }); | |
| 32 | 14 | |
| 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", | |
| 45 | 20 | }), |
| 46 | ); | |
| 21 | ).toEqual({ | |
| 22 | status: "ready", | |
| 23 | version: "1.2.3", | |
| 24 | launcherProtocol: SERVICE_LAUNCHER_PROTOCOL, | |
| 25 | }); | |
| 47 | 26 | }); |
apps/server/src/cloud/servicePreflight.ts+1-38| ... | ... | @@ -1,7 +1,4 @@ |
| 1 | import * as NodeSqlite from "node:sqlite"; | |
| 2 | ||
| 3 | 1 | import packageJson from "../../package.json" with { type: "json" }; |
| 4 | import { migrationManifest } from "../persistence/Migrations.ts"; | |
| 5 | 2 | import { SERVICE_LAUNCHER_PROTOCOL } from "./serviceProtocol.ts"; |
| 6 | 3 | |
| 7 | 4 | export type ServicePreflightResult = |
| ... | ... | @@ -16,20 +13,8 @@ export type ServicePreflightResult = |
| 16 | 13 | readonly reason: string; |
| 17 | 14 | }; |
| 18 | 15 | |
| 19 | const 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 | ||
| 22 | const 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 | ||
| 32 | 16 | export function runServicePreflight(input: { |
| 17 | /** Older servers always pass this flag when invoking a staged preflight. */ | |
| 33 | 18 | readonly databasePath: string; |
| 34 | 19 | readonly launcherProtocol: number; |
| 35 | 20 | readonly version?: string; |
| ... | ... | @@ -44,28 +29,6 @@ export function runServicePreflight(input: { |
| 44 | 29 | }; |
| 45 | 30 | } |
| 46 | 31 | |
| 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 | ||
| 69 | 32 | return { status: "ready", version, launcherProtocol: SERVICE_LAUNCHER_PROTOCOL }; |
| 70 | 33 | } |
| 71 | 34 |
apps/server/src/cloud/serviceProtocol.ts+23-4| ... | ... | @@ -1,6 +1,7 @@ |
| 1 | 1 | import type { ServerSelfUpdateOutcome } from "@t3tools/contracts"; |
| 2 | 2 | |
| 3 | export const SERVICE_LAUNCHER_PROTOCOL = 1 as const; | |
| 3 | /** Protocol 2 snapshots SQLite before trials so migrations can be rolled back safely. */ | |
| 4 | export const SERVICE_LAUNCHER_PROTOCOL = 2 as const; | |
| 4 | 5 | export const SERVICE_LAUNCHER_CONTEXT_ENV = "T3_SERVICE_LAUNCHER_CONTEXT"; |
| 5 | 6 | export const SERVICE_LAUNCHER_FILE = "service-launcher.mjs"; |
| 6 | 7 | export const SERVICE_STATE_FILE = "service-state.json"; |
| ... | ... | @@ -9,6 +10,7 @@ export interface PendingServiceUpdate { |
| 9 | 10 | readonly id: string; |
| 10 | 11 | readonly fromVersion: string; |
| 11 | 12 | readonly targetVersion: string; |
| 13 | readonly dbPath: string; | |
| 12 | 14 | readonly status: "pending"; |
| 13 | 15 | } |
| 14 | 16 | |
| ... | ... | @@ -31,6 +33,7 @@ export type ServiceLauncherChildMessage = |
| 31 | 33 | | { |
| 32 | 34 | readonly type: "request-update"; |
| 33 | 35 | readonly targetVersion: string; |
| 36 | readonly dbPath: string; | |
| 34 | 37 | } |
| 35 | 38 | | { |
| 36 | 39 | readonly type: "prepared"; |
| ... | ... | @@ -78,7 +81,9 @@ export function decodeServiceUpdate(value: unknown): ServiceUpdateRecord | undef |
| 78 | 81 | return undefined; |
| 79 | 82 | } |
| 80 | 83 | 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; | |
| 82 | 87 | } |
| 83 | 88 | if ( |
| 84 | 89 | (status === "committed" || status === "rolled-back" || status === "failed") && |
| ... | ... | @@ -165,6 +170,16 @@ export function parseServiceState(value: string): ServiceState | undefined { |
| 165 | 170 | } |
| 166 | 171 | } |
| 167 | 172 | |
| 173 | /** Detects an in-flight update across launcher protocol versions before replacing its state. */ | |
| 174 | export 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 | ||
| 168 | 183 | export function decodeServiceLauncherContext(value: string): ServiceLauncherContext | undefined { |
| 169 | 184 | let parsed: unknown; |
| 170 | 185 | try { |
| ... | ... | @@ -202,8 +217,12 @@ export function decodeServiceLauncherChildMessage( |
| 202 | 217 | value: unknown, |
| 203 | 218 | ): ServiceLauncherChildMessage | undefined { |
| 204 | 219 | 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 }; | |
| 207 | 226 | } |
| 208 | 227 | return value.type === "prepared" && typeof value.updateId === "string" |
| 209 | 228 | ? { 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"; |
| 7 | 7 | |
| 8 | 8 | import { runMigrations } from "../Migrations.ts"; |
| 9 | 9 | import { ServerConfig } from "../../config.ts"; |
| 10 | import * as ServiceLauncherClient from "../../cloud/serviceLauncherClient.ts"; | |
| 11 | 10 | |
| 12 | 11 | type RuntimeSqliteLayerConfig = { |
| 13 | 12 | readonly filename: string; |
| ... | ... | @@ -31,28 +30,24 @@ const makeRuntimeSqliteLayer = Effect.fn("makeRuntimeSqliteLayer")(function* ( |
| 31 | 30 | return clientModule.layer(config); |
| 32 | 31 | }, Layer.unwrap); |
| 33 | 32 | |
| 34 | const 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 | ); | |
| 33 | const 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 | ); | |
| 45 | 41 | |
| 46 | 42 | export const makeSqlitePersistenceLive = Effect.fn("makeSqlitePersistenceLive")(function* ( |
| 47 | 43 | dbPath: string, |
| 48 | options?: { readonly trial?: boolean }, | |
| 49 | 44 | ) { |
| 50 | 45 | const fs = yield* FileSystem.FileSystem; |
| 51 | 46 | const path = yield* Path.Path; |
| 52 | 47 | yield* fs.makeDirectory(path.dirname(dbPath), { recursive: true }); |
| 53 | 48 | |
| 54 | 49 | return Layer.provideMerge( |
| 55 | setup(options?.trial === true), | |
| 50 | setup, | |
| 56 | 51 | makeRuntimeSqliteLayer({ |
| 57 | 52 | filename: dbPath, |
| 58 | 53 | spanAttributes: { |
| ... | ... | @@ -64,14 +59,13 @@ export const makeSqlitePersistenceLive = Effect.fn("makeSqlitePersistenceLive")( |
| 64 | 59 | }, Layer.unwrap); |
| 65 | 60 | |
| 66 | 61 | export const SqlitePersistenceMemory = Layer.provideMerge( |
| 67 | setup(false), | |
| 62 | setup, | |
| 68 | 63 | makeRuntimeSqliteLayer({ filename: ":memory:" }), |
| 69 | 64 | ); |
| 70 | 65 | |
| 71 | 66 | export const layerConfig = Layer.unwrap( |
| 72 | 67 | Effect.gen(function* () { |
| 73 | 68 | const { dbPath } = yield* ServerConfig; |
| 74 | const launcher = yield* ServiceLauncherClient.resolveServiceLauncherMode(); | |
| 75 | return makeSqlitePersistenceLive(dbPath, { trial: launcher.trial }); | |
| 69 | return makeSqlitePersistenceLive(dbPath); | |
| 76 | 70 | }), |
| 77 | 71 | ); |
apps/server/src/serviceLauncher.test.ts+95-8| ... | ... | @@ -9,6 +9,7 @@ import { |
| 9 | 9 | compareExactServiceVersions, |
| 10 | 10 | decodeServiceState, |
| 11 | 11 | isExactServiceVersion, |
| 12 | SERVICE_LAUNCHER_PROTOCOL, | |
| 12 | 13 | } from "./cloud/serviceProtocol.ts"; |
| 13 | 14 | |
| 14 | 15 | it("accepts only exact semantic versions", () => { |
| ... | ... | @@ -33,12 +34,13 @@ it("orders exact semantic versions without treating build metadata as precedence |
| 33 | 34 | it("rejects contradictory service state", () => { |
| 34 | 35 | assert.isUndefined( |
| 35 | 36 | decodeServiceState({ |
| 36 | protocol: 1, | |
| 37 | protocol: SERVICE_LAUNCHER_PROTOCOL, | |
| 37 | 38 | activeVersion: "0.0.31", |
| 38 | 39 | update: { |
| 39 | 40 | id: "update-1", |
| 40 | 41 | fromVersion: "0.0.30", |
| 41 | 42 | targetVersion: "0.0.32", |
| 43 | dbPath: "/tmp/state.sqlite", | |
| 42 | 44 | status: "pending", |
| 43 | 45 | }, |
| 44 | 46 | }), |
| ... | ... | @@ -46,12 +48,26 @@ it("rejects contradictory service state", () => { |
| 46 | 48 | |
| 47 | 49 | assert.isUndefined( |
| 48 | 50 | 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, | |
| 50 | 65 | activeVersion: "1.0.0", |
| 51 | 66 | update: { |
| 52 | 67 | id: "update-2", |
| 53 | 68 | fromVersion: "1.0.0", |
| 54 | 69 | targetVersion: "0.9.0", |
| 70 | dbPath: "/tmp/state.sqlite", | |
| 55 | 71 | status: "pending", |
| 56 | 72 | }, |
| 57 | 73 | }), |
| ... | ... | @@ -66,7 +82,7 @@ it.layer(NodeServices.layer)("service state persistence", (it) => { |
| 66 | 82 | const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-launcher-test-" }); |
| 67 | 83 | const statePath = path.join(root, "runtime", "service-state.json"); |
| 68 | 84 | const state = { |
| 69 | protocol: 1, | |
| 85 | protocol: SERVICE_LAUNCHER_PROTOCOL, | |
| 70 | 86 | activeVersion: "0.0.31", |
| 71 | 87 | } as const; |
| 72 | 88 | |
| ... | ... | @@ -88,7 +104,7 @@ it.layer(NodeServices.layer)("service state persistence", (it) => { |
| 88 | 104 | yield* fs.writeFileString(path.join(versionDir, ".install-complete"), "1.0.0\n"); |
| 89 | 105 | yield* Effect.promise(() => |
| 90 | 106 | writeServiceState(statePath, { |
| 91 | protocol: 1, | |
| 107 | protocol: SERVICE_LAUNCHER_PROTOCOL, | |
| 92 | 108 | activeVersion: "1.0.0", |
| 93 | 109 | }), |
| 94 | 110 | ); |
| ... | ... | @@ -106,6 +122,11 @@ it.layer(NodeServices.layer)("service state persistence", (it) => { |
| 106 | 122 | const path = yield* Path.Path; |
| 107 | 123 | const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-launcher-flow-" }); |
| 108 | 124 | 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); | |
| 109 | 130 | const childSource = ` |
| 110 | 131 | const context = JSON.parse(process.env.T3_SERVICE_LAUNCHER_CONTEXT); |
| 111 | 132 | if (context.update?.status === "pending") { |
| ... | ... | @@ -114,7 +135,7 @@ if (context.update?.status === "pending") { |
| 114 | 135 | if (message.type === "committed") process.exit(0); |
| 115 | 136 | }); |
| 116 | 137 | } 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} }); | |
| 118 | 139 | setInterval(() => {}, 1_000); |
| 119 | 140 | } else { |
| 120 | 141 | process.exit(0); |
| ... | ... | @@ -129,7 +150,7 @@ if (context.update?.status === "pending") { |
| 129 | 150 | } |
| 130 | 151 | yield* Effect.promise(() => |
| 131 | 152 | writeServiceState(statePath, { |
| 132 | protocol: 1, | |
| 153 | protocol: SERVICE_LAUNCHER_PROTOCOL, | |
| 133 | 154 | activeVersion: "1.0.0", |
| 134 | 155 | }), |
| 135 | 156 | ); |
| ... | ... | @@ -154,12 +175,17 @@ if (context.update?.status === "pending") { |
| 154 | 175 | const path = yield* Path.Path; |
| 155 | 176 | const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-launcher-rollback-" }); |
| 156 | 177 | 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); | |
| 157 | 183 | const childSource = ` |
| 158 | 184 | const context = JSON.parse(process.env.T3_SERVICE_LAUNCHER_CONTEXT); |
| 159 | 185 | if (context.update?.status === "pending") { |
| 160 | 186 | process.send({ type: "prepared", updateId: "wrong-update" }); |
| 161 | 187 | } 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} }); | |
| 163 | 189 | setInterval(() => {}, 1_000); |
| 164 | 190 | } else { |
| 165 | 191 | process.exit(0); |
| ... | ... | @@ -174,7 +200,7 @@ if (context.update?.status === "pending") { |
| 174 | 200 | } |
| 175 | 201 | yield* Effect.promise(() => |
| 176 | 202 | writeServiceState(statePath, { |
| 177 | protocol: 1, | |
| 203 | protocol: SERVICE_LAUNCHER_PROTOCOL, | |
| 178 | 204 | activeVersion: "1.0.0", |
| 179 | 205 | }), |
| 180 | 206 | ); |
| ... | ... | @@ -196,4 +222,65 @@ if (context.update?.status === "pending") { |
| 196 | 222 | ); |
| 197 | 223 | }), |
| 198 | 224 | ); |
| 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 = ` | |
| 239 | import { writeFileSync } from "node:fs"; | |
| 240 | const context = JSON.parse(process.env.T3_SERVICE_LAUNCHER_CONTEXT); | |
| 241 | if (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 | ); | |
| 199 | 286 | }); |
apps/server/src/serviceLauncher.ts+141-4| ... | ... | @@ -48,6 +48,121 @@ const runtimePaths = (baseDir: string, version: string) => { |
| 48 | 48 | }; |
| 49 | 49 | }; |
| 50 | 50 | |
| 51 | /** SQLite persists across the main file plus its WAL and shared-memory sidecars. */ | |
| 52 | const DB_FILE_SUFFIXES = ["", "-wal", "-shm"] as const; | |
| 53 | const RESTORE_MARKER = ".restore-pending"; | |
| 54 | ||
| 55 | const databaseBackupDir = (baseDir: string, updateId: string) => | |
| 56 | NodePath.join(baseDir, "runtime", "db-backup", updateId); | |
| 57 | ||
| 58 | const databaseBackupFile = (backupDir: string, suffix: (typeof DB_FILE_SUFFIXES)[number]) => | |
| 59 | NodePath.join(backupDir, suffix === "" ? "database" : `database${suffix}`); | |
| 60 | ||
| 61 | async 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 | ||
| 71 | async 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 | ||
| 80 | async 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 | */ | |
| 94 | async 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 | ||
| 117 | const restoreMarkerPath = (baseDir: string, updateId: string) => | |
| 118 | NodePath.join(databaseBackupDir(baseDir, updateId), RESTORE_MARKER); | |
| 119 | ||
| 120 | const 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. */ | |
| 124 | async 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. */ | |
| 138 | async 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 | ||
| 159 | async 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 | ||
| 51 | 166 | export async function readServiceState(filePath: string): Promise<ServiceState> { |
| 52 | 167 | const contents = await NodeFSP.readFile(filePath, "utf8"); |
| 53 | 168 | const state = parseServiceState(contents); |
| ... | ... | @@ -217,9 +332,16 @@ export class Launcher { |
| 217 | 332 | async #recover(): Promise<void> { |
| 218 | 333 | const update = this.#state.update; |
| 219 | 334 | if (update?.status !== "pending") { |
| 335 | if (update !== undefined) { | |
| 336 | await discardDatabaseBackup(this.#baseDir, update.id).catch(() => undefined); | |
| 337 | } | |
| 220 | 338 | await this.#startChild(this.#state.activeVersion, "active", update); |
| 221 | 339 | return; |
| 222 | 340 | } |
| 341 | if (await databaseRestorePending(this.#baseDir, update)) { | |
| 342 | await this.#returnToPrevious(update, "failed", "rollback-interrupted"); | |
| 343 | return; | |
| 344 | } | |
| 223 | 345 | if (!(await runtimeExists(this.#baseDir, update.targetVersion))) { |
| 224 | 346 | await this.#returnToPrevious(update, "failed", "target-runtime-missing"); |
| 225 | 347 | return; |
| ... | ... | @@ -228,6 +350,13 @@ export class Launcher { |
| 228 | 350 | } |
| 229 | 351 | |
| 230 | 352 | 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 | } | |
| 231 | 360 | try { |
| 232 | 361 | await this.#startChild(pending.targetVersion, "trial", pending); |
| 233 | 362 | } catch { |
| ... | ... | @@ -322,6 +451,10 @@ export class Launcher { |
| 322 | 451 | await reject("Remote updates must select a newer server version."); |
| 323 | 452 | return; |
| 324 | 453 | } |
| 454 | if (!NodePath.isAbsolute(message.dbPath)) { | |
| 455 | await reject("The requested database path is not absolute."); | |
| 456 | return; | |
| 457 | } | |
| 325 | 458 | if (!(await runtimeExists(this.#baseDir, message.targetVersion))) { |
| 326 | 459 | await reject("The requested target runtime is missing or incomplete."); |
| 327 | 460 | return; |
| ... | ... | @@ -331,6 +464,7 @@ export class Launcher { |
| 331 | 464 | id: NodeCrypto.randomUUID(), |
| 332 | 465 | fromVersion: child.version, |
| 333 | 466 | targetVersion: message.targetVersion, |
| 467 | dbPath: message.dbPath, | |
| 334 | 468 | status: "pending", |
| 335 | 469 | }; |
| 336 | 470 | const next: ServiceState = { ...this.#state, update: pending }; |
| ... | ... | @@ -375,6 +509,7 @@ export class Launcher { |
| 375 | 509 | await writeServiceState(this.#statePath, next); |
| 376 | 510 | this.#state = next; |
| 377 | 511 | child.role = "active"; |
| 512 | await discardDatabaseBackup(this.#baseDir, committed.id).catch(() => undefined); | |
| 378 | 513 | await sendMessage(child.process, { type: "committed", updateId: committed.id }); |
| 379 | 514 | } |
| 380 | 515 | |
| ... | ... | @@ -423,6 +558,11 @@ export class Launcher { |
| 423 | 558 | reason: string, |
| 424 | 559 | child?: ManagedChild, |
| 425 | 560 | ): Promise<void> { |
| 561 | if (child !== undefined) { | |
| 562 | this.#child = null; | |
| 563 | await terminateChild(child.process); | |
| 564 | } | |
| 565 | await restoreDatabaseBackup(this.#baseDir, pending); | |
| 426 | 566 | const outcome = terminalUpdate({ pending, status, reason }); |
| 427 | 567 | const next: ServiceState = { |
| 428 | 568 | ...this.#state, |
| ... | ... | @@ -431,10 +571,7 @@ export class Launcher { |
| 431 | 571 | }; |
| 432 | 572 | await writeServiceState(this.#statePath, next); |
| 433 | 573 | 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); | |
| 438 | 575 | await this.#startChild(next.activeVersion, "active", outcome); |
| 439 | 576 | } |
| 440 | 577 | } |
docs/internals/server-updates.md+25-15| ... | ... | @@ -29,39 +29,49 @@ Every write uses same-directory replacement plus file and directory fsync. |
| 29 | 29 | ## Remote Update |
| 30 | 30 | |
| 31 | 31 | 1. The active server installs `t3@<target>` into a unique staging directory. |
| 32 | 2. The target runs `__service-preflight` against the active database in read-only mode. | |
| 32 | 2. The target runs `__service-preflight` and verifies that the stable launcher supports its update | |
| 33 | protocol. | |
| 33 | 34 | 3. The staging directory is renamed to its immutable version path only after preflight succeeds. |
| 34 | 35 | 4. The active child sends `request-update`. The launcher validates the child and target, writes |
| 35 | 36 | pending state, generates the update ID, then replies `update-accepted`. |
| 36 | 37 | 5. After a short response-flush grace period, the launcher stops the active child. |
| 37 | 6. The launcher starts the target as a trial and gives it the pending update over IPC. | |
| 38 | 7. 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. | |
| 40 | 8. The trial sends `prepared`. The launcher durably commits B before replying `committed`. | |
| 41 | 9. The child opens the existing activation gate, accepts commands, and publishes lifecycle ready | |
| 42 | with the terminal update outcome. | |
| 38 | 6. With SQLite quiescent, the launcher snapshots the database, WAL, and shared-memory files. | |
| 39 | 7. The launcher starts the target as a trial and gives it the pending update over IPC. | |
| 40 | 8. 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. | |
| 42 | 9. The trial sends `prepared`. The launcher durably commits B, deletes the snapshot, then replies | |
| 43 | `committed`. | |
| 44 | 10. The child opens the existing activation gate, accepts commands, and publishes lifecycle ready | |
| 45 | with the terminal update outcome. | |
| 43 | 46 | |
| 44 | 47 | Post-commit startup does not call service `start`, `initialize`, `connect`, `load`, or `acquire` |
| 45 | 48 | operations. It only opens prepared gates and publishes prepared lifecycle state. |
| 46 | 49 | |
| 47 | 50 | The launcher serializes child exits, IPC messages, and timers. A trial must report prepared within |
| 48 | 120 seconds. If the trial exits or times out before prepared, the launcher records rollback and | |
| 49 | starts A. After commit, B is active and normal systemd restart policy applies. | |
| 51 | 120 seconds. If the trial exits or times out before prepared, the launcher stops it, restores the | |
| 52 | snapshot, records rollback, and starts A. A durable restore marker makes an interrupted restore | |
| 53 | resume before either version can boot. After commit, B is active and normal systemd restart policy | |
| 54 | applies. | |
| 50 | 55 | |
| 51 | ## Migration Boundary | |
| 56 | ## Database Rollback | |
| 52 | 57 | |
| 53 | Remote update preflight requires exact equality between the database's applied `(id, name)` rows | |
| 54 | and the target release's migration manifest. A target with a missing, additional, renamed, or | |
| 55 | unknown migration is blocked. Remote updates never migrate or downgrade a database. | |
| 58 | The launcher snapshots `state.sqlite`, `state.sqlite-wal`, and `state.sqlite-shm` after the old | |
| 59 | server stops and before the trial starts. This makes trial migrations and writes reversible without | |
| 60 | requiring down migrations. The snapshot is retained across launcher restarts and is removed only | |
| 61 | after commit or after both restore and the terminal rollback state are durable. | |
| 56 | 62 | |
| 57 | This deliberately means any release containing a migration requires a local service update: | |
| 63 | The protocol version is part of the safety boundary. A target that requires database snapshots is | |
| 64 | blocked when the installed launcher is too old. Upgrade the launcher once with: | |
| 58 | 65 | |
| 59 | 66 | ```sh |
| 60 | 67 | npx t3@<version> service update |
| 61 | 68 | ``` |
| 62 | 69 | |
| 63 | 70 | The local command stops the unit, selects the new launcher and exact runtime, then restarts the |
| 64 | service. Its normal server startup may run migrations. | |
| 71 | service. Later releases, including releases with migrations, can use the remote trial path. | |
| 72 | ||
| 73 | Snapshots briefly require enough free disk for another copy of the SQLite files. Attachments and | |
| 74 | other files under the state directory are outside this rollback boundary. | |
| 65 | 75 | |
| 66 | 76 | ## Client Correlation |
| 67 | 77 |
docs/user/background-service.md+5-2| ... | ... | @@ -30,10 +30,13 @@ npx t3@latest service uninstall |
| 30 | 30 | ``` |
| 31 | 31 | |
| 32 | 32 | Updating restarts T3 Code briefly. Let active agent work and terminal commands finish first. |
| 33 | If a remote update is already in progress, wait for it to finish before retrying a local update. | |
| 33 | 34 | |
| 34 | 35 | The systemd unit runs a small stable launcher. Exact T3 Code versions are installed separately, so |
| 35 | a failed remote candidate can return to the previous version without rewriting the unit. Releases | |
| 36 | that change the database must be installed with the local `service update` command above. | |
| 36 | a failed remote candidate can return to the previous version without rewriting the unit. The | |
| 37 | launcher snapshots the database before a remote candidate starts, so database updates roll back | |
| 38 | with the server version. An older launcher may require one local `service update` before this is | |
| 39 | available. | |
| 37 | 40 | |
| 38 | 41 | ## Using It with T3 Connect |
| 39 | 42 |
docs/user/updating.md+3-3| ... | ... | @@ -31,9 +31,9 @@ The update does not remove saved threads, settings, or project files. |
| 31 | 31 | The available action depends on how that server was started. T3 Code does not update connected |
| 32 | 32 | servers silently in the background. |
| 33 | 33 | |
| 34 | If the requested version includes a database update, remote installation stops before restart and | |
| 35 | asks you to run the exact `npx t3@<version> service update` command on the server machine. This is | |
| 36 | an intentional rollback-safety boundary. | |
| 34 | An 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 | |
| 36 | rollback support needed for later remote updates, including versions that change the database. | |
| 37 | 37 | |
| 38 | 38 | After selecting **Update server**, the warning becomes a three-step progress rail: |
| 39 | 39 | **Download**, **Install**, and **Resume**. The same progress appears in the conversation and in |