292 lines · 12.1 KB · TypeScript
RawDownload
import * as NodeServices from "@effect/platform-node/NodeServices";import { assert, it } from "@effect/vitest";import * as Effect from "effect/Effect";import * as FileSystem from "effect/FileSystem";import * as Path from "effect/Path"; import { Launcher, readServiceState, writeServiceState } from "./serviceLauncher.ts";import {  compareExactServiceVersions,  decodeServiceState,  isExactServiceVersion,  SERVICE_LAUNCHER_PROTOCOL,  SERVICE_STOP_MARKER_FILE,} from "./cloud/serviceProtocol.ts"; it("accepts only exact semantic versions", () => {  for (const version of ["0.0.0", "1.2.3", "1.2.3-alpha.1", "1.2.3-0", "1.2.3+001"]) {    assert.isTrue(isExactServiceVersion(version), version);  }  for (const version of ["latest", "01.2.3", "1.2.3-01", "1.2.3-alpha..1", "1.2.3+."]) {    assert.isFalse(isExactServiceVersion(version), version);  }}); it("orders exact semantic versions without treating build metadata as precedence", () => {  assert.equal(compareExactServiceVersions("1.2.3", "1.2.3"), 0);  assert.equal(compareExactServiceVersions("1.2.4", "1.2.3"), 1);  assert.equal(compareExactServiceVersions("2.0.0-alpha.1", "2.0.0-alpha.2"), -1);  assert.equal(compareExactServiceVersions("2.0.0-alpha.2", "2.0.0-alpha.beta"), -1);  assert.equal(compareExactServiceVersions("2.0.0-alpha-beta", "2.0.0-alpha-alpha"), 1);  assert.equal(compareExactServiceVersions("2.0.0", "2.0.0-rc.1"), 1);  assert.equal(compareExactServiceVersions("2.0.0+one", "2.0.0+two"), 0);}); it("rejects contradictory service state", () => {  assert.isUndefined(    decodeServiceState({      protocol: SERVICE_LAUNCHER_PROTOCOL,      activeVersion: "0.0.31",      update: {        id: "update-1",        fromVersion: "0.0.30",        targetVersion: "0.0.32",        dbPath: "/tmp/state.sqlite",        status: "pending",      },    }),  );   assert.isUndefined(    decodeServiceState({      protocol: SERVICE_LAUNCHER_PROTOCOL,      activeVersion: "1.0.0",      update: {        id: "update-3",        fromVersion: "1.0.0",        targetVersion: "1.1.0",        status: "pending",      },    }),  );   assert.isUndefined(    decodeServiceState({      protocol: SERVICE_LAUNCHER_PROTOCOL,      activeVersion: "1.0.0",      update: {        id: "update-2",        fromVersion: "1.0.0",        targetVersion: "0.9.0",        dbPath: "/tmp/state.sqlite",        status: "pending",      },    }),  );}); it.layer(NodeServices.layer)("service state persistence", (it) => {  it.effect("durably replaces and strictly reads one state document", () =>    Effect.gen(function* () {      const fs = yield* FileSystem.FileSystem;      const path = yield* Path.Path;      const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-launcher-test-" });      const statePath = path.join(root, "runtime", "service-state.json");      const state = {        protocol: SERVICE_LAUNCHER_PROTOCOL,        activeVersion: "0.0.31",      } as const;       yield* Effect.promise(() => writeServiceState(statePath, state));      assert.deepEqual(yield* Effect.promise(() => readServiceState(statePath)), state);    }),  );   it.effect("serializes shutdown with launcher recovery", () =>    Effect.gen(function* () {      const fs = yield* FileSystem.FileSystem;      const path = yield* Path.Path;      const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-launcher-stop-" });      const statePath = path.join(root, "runtime", "service-state.json");      const versionDir = path.join(root, "runtime", "versions", "1.0.0");      const entryPath = path.join(versionDir, "node_modules", "t3", "dist", "bin.mjs");      yield* fs.makeDirectory(path.dirname(entryPath), { recursive: true });      yield* fs.writeFileString(entryPath, "setInterval(() => {}, 1_000);\n");      yield* fs.writeFileString(path.join(versionDir, ".install-complete"), "1.0.0\n");      yield* Effect.promise(() =>        writeServiceState(statePath, {          protocol: SERVICE_LAUNCHER_PROTOCOL,          activeVersion: "1.0.0",        }),      );       const launcher = new Launcher(root, yield* Effect.promise(() => readServiceState(statePath)));      const running = launcher.run();      const stopping = launcher.stop("SIGTERM");      // An explicit stop leaves the marker that tells a child shutting down      // mid-update that no replacement server is coming. It is present as      // soon as stop() returns its promise, before queued transitions run.      assert.isTrue(yield* fs.exists(path.join(root, "runtime", SERVICE_STOP_MARKER_FILE)));      yield* Effect.promise(() => stopping);      yield* Effect.promise(() => running);    }),  );   it.effect("commits only after the trial reports prepared", () =>    Effect.gen(function* () {      const fs = yield* FileSystem.FileSystem;      const path = yield* Path.Path;      const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-launcher-flow-" });      const statePath = path.join(root, "runtime", "service-state.json");      const databasePath = path.join(root, "userdata", "state.sqlite");      yield* fs.makeDirectory(path.dirname(databasePath), { recursive: true });      yield* fs.writeFileString(databasePath, "before trial");      // @effect-diagnostics-next-line preferSchemaOverJson:off - embeds a path in fake child source.      const encodedDatabasePath = JSON.stringify(databasePath);      const childSource = `const context = JSON.parse(process.env.T3_SERVICE_LAUNCHER_CONTEXT);if (context.update?.status === "pending") {  process.send({ type: "prepared", updateId: context.update.id });  process.on("message", (message) => {    if (message.type === "committed") process.exit(0);  });} else if (context.update === undefined) {  process.send({ type: "request-update", targetVersion: "1.1.0", dbPath: ${encodedDatabasePath} });  setInterval(() => {}, 1_000);} else {  process.exit(0);}`;      for (const version of ["1.0.0", "1.1.0"]) {        const versionDir = path.join(root, "runtime", "versions", version);        const entryPath = path.join(versionDir, "node_modules", "t3", "dist", "bin.mjs");        yield* fs.makeDirectory(path.dirname(entryPath), { recursive: true });        yield* fs.writeFileString(entryPath, childSource);        yield* fs.writeFileString(path.join(versionDir, ".install-complete"), `${version}\n`);      }      yield* Effect.promise(() =>        writeServiceState(statePath, {          protocol: SERVICE_LAUNCHER_PROTOCOL,          activeVersion: "1.0.0",        }),      );       const launcher = new Launcher(root, yield* Effect.promise(() => readServiceState(statePath)));      yield* Effect.promise(() =>        launcher.run().then(          () => Promise.reject(new Error("launcher unexpectedly completed")),          () => Promise.resolve(),        ),      );       const state = yield* Effect.promise(() => readServiceState(statePath));      assert.equal(state.activeVersion, "1.1.0");      assert.equal(state.update?.status, "committed");    }),  );   it.effect("rolls back a trial that reports the wrong update ID", () =>    Effect.gen(function* () {      const fs = yield* FileSystem.FileSystem;      const path = yield* Path.Path;      const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-launcher-rollback-" });      const statePath = path.join(root, "runtime", "service-state.json");      const databasePath = path.join(root, "userdata", "state.sqlite");      yield* fs.makeDirectory(path.dirname(databasePath), { recursive: true });      yield* fs.writeFileString(databasePath, "before trial");      // @effect-diagnostics-next-line preferSchemaOverJson:off - embeds a path in fake child source.      const encodedDatabasePath = JSON.stringify(databasePath);      const childSource = `const context = JSON.parse(process.env.T3_SERVICE_LAUNCHER_CONTEXT);if (context.update?.status === "pending") {  process.send({ type: "prepared", updateId: "wrong-update" });} else if (context.update === undefined) {  process.send({ type: "request-update", targetVersion: "1.1.0", dbPath: ${encodedDatabasePath} });  setInterval(() => {}, 1_000);} else {  process.exit(0);}`;      for (const version of ["1.0.0", "1.1.0"]) {        const versionDir = path.join(root, "runtime", "versions", version);        const entryPath = path.join(versionDir, "node_modules", "t3", "dist", "bin.mjs");        yield* fs.makeDirectory(path.dirname(entryPath), { recursive: true });        yield* fs.writeFileString(entryPath, childSource);        yield* fs.writeFileString(path.join(versionDir, ".install-complete"), `${version}\n`);      }      yield* Effect.promise(() =>        writeServiceState(statePath, {          protocol: SERVICE_LAUNCHER_PROTOCOL,          activeVersion: "1.0.0",        }),      );       const launcher = new Launcher(root, yield* Effect.promise(() => readServiceState(statePath)));      yield* Effect.promise(() =>        launcher.run().then(          () => Promise.reject(new Error("launcher unexpectedly completed")),          () => Promise.resolve(),        ),      );       const state = yield* Effect.promise(() => readServiceState(statePath));      assert.equal(state.activeVersion, "1.0.0");      assert.equal(state.update?.status, "rolled-back");      assert.equal(        state.update?.status === "rolled-back" ? state.update.reason : undefined,        "invalid-prepared",      );    }),  );   it.effect("restores the database when a migrating trial exits", () =>    Effect.gen(function* () {      const fs = yield* FileSystem.FileSystem;      const path = yield* Path.Path;      const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-launcher-db-" });      const statePath = path.join(root, "runtime", "service-state.json");      const databasePath = path.join(root, "userdata", "state.sqlite");      const original = "database before migration";      yield* fs.makeDirectory(path.dirname(databasePath), { recursive: true });      yield* fs.writeFileString(databasePath, original);      // @effect-diagnostics-next-line preferSchemaOverJson:off - embeds a path in fake child source.      const encodedDatabasePath = JSON.stringify(databasePath);      const childSource = `import { writeFileSync } from "node:fs";const context = JSON.parse(process.env.T3_SERVICE_LAUNCHER_CONTEXT);if (context.update?.status === "pending") {  writeFileSync(context.update.dbPath, "database after migration");  writeFileSync(context.update.dbPath + "-wal", "trial wal");  writeFileSync(context.update.dbPath + "-shm", "trial shm");  process.exit(1);} else if (context.update === undefined) {  process.send({ type: "request-update", targetVersion: "1.1.0", dbPath: ${encodedDatabasePath} });  setInterval(() => {}, 1_000);} else {  process.exit(0);}`;      for (const version of ["1.0.0", "1.1.0"]) {        const versionDir = path.join(root, "runtime", "versions", version);        const entryPath = path.join(versionDir, "node_modules", "t3", "dist", "bin.mjs");        yield* fs.makeDirectory(path.dirname(entryPath), { recursive: true });        yield* fs.writeFileString(entryPath, childSource);        yield* fs.writeFileString(path.join(versionDir, ".install-complete"), `${version}\n`);      }      yield* Effect.promise(() =>        writeServiceState(statePath, {          protocol: SERVICE_LAUNCHER_PROTOCOL,          activeVersion: "1.0.0",        }),      );       const launcher = new Launcher(root, yield* Effect.promise(() => readServiceState(statePath)));      yield* Effect.promise(() =>        launcher.run().then(          () => Promise.reject(new Error("launcher unexpectedly completed")),          () => Promise.resolve(),        ),      );       const state = yield* Effect.promise(() => readServiceState(statePath));      assert.equal(state.activeVersion, "1.0.0");      assert.equal(state.update?.status, "rolled-back");      assert.equal(yield* fs.readFileString(databasePath), original);      assert.isFalse(yield* fs.exists(`${databasePath}-wal`));      assert.isFalse(yield* fs.exists(`${databasePath}-shm`));      const updateId = state.update?.id;      assert.isDefined(updateId);      assert.isFalse(yield* fs.exists(path.join(root, "runtime", "db-backup", updateId)));    }),  );});