397 lines · 16.4 KB · TypeScript
RawDownload
#!/usr/bin/env node // @effect-diagnostics nodeBuiltinImport:off - node:os resolves the shared T3 home guard.import * as NodeRuntime from "@effect/platform-node/NodeRuntime";import * as NodeServices from "@effect/platform-node/NodeServices";import * as NodeOS from "node:os";import * as Console from "effect/Console";import * as DateTime from "effect/DateTime";import * as Effect from "effect/Effect";import * as FileSystem from "effect/FileSystem";import * as Layer from "effect/Layer";import * as Logger from "effect/Logger";import * as Option from "effect/Option";import * as Path from "effect/Path";import * as Schema from "effect/Schema";import { Command, Flag } from "effect/unstable/cli"; import { resolveWorktreeT3Home } from "@t3tools/shared/devHome"; import { DevSeedError, MAX_SEED_THREAD_LIMIT, seedDevDatabase } from "./lib/dev-seed.ts";import { findRunningServerPid, findRunningServerPidSync } from "./lib/server-runtime-probe.ts"; const DEFAULT_THREAD_LIMIT = 25;const DEFAULT_ACTIVITY_LIMIT = 200; const resolveSharedHome = (path: Path.Path): string => {  const configured = process.env.T3CODE_HOME?.trim();  if (!configured) {    return path.join(NodeOS.homedir(), ".t3");  }  if (configured === "~") {    return NodeOS.homedir();  }  return path.resolve(    configured.startsWith("~/") || configured.startsWith("~\\")      ? path.join(NodeOS.homedir(), configured.slice(2))      : configured,  );}; const isPathWithin = (path: Path.Path, parent: string, candidate: string): boolean => {  const relative = path.relative(parent, candidate);  return (    relative === "" ||    (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative))  );}; class DevSeedTargetError extends Schema.TaggedErrorClass<DevSeedTargetError>()(  "DevSeedTargetError",  {    reason: Schema.Literals([      "shared-home",      "missing-target",      "not-a-worktree",      "server-running",      "same-source",    ]),    detail: Schema.String,  },) {  override get message(): string {    switch (this.reason) {      case "shared-home":        return [          `Refusing to seed ${this.detail}: that database lives in the shared T3 Code home.`,          "This command overwrites projection tables. Run it from a worktree, or pass --to <base-dir>.",        ].join("\n");      case "missing-target":        return [          `No database at ${this.detail}.`,          "Start the dev server once so migrations run (`bun run dev`), then seed.",        ].join("\n");      case "not-a-worktree":        return [          "Not inside a git worktree, so there is no worktree-local data directory to seed.",          "Pass --to <base-dir> to choose one explicitly.",        ].join("\n");      case "server-running":        return [          `A T3 Code server (pid ${this.detail}) is running against this data directory.`,          "Seeding replaces its projections and empties its event log while it holds them open,",          "which leaves the running server's in-memory read model describing rows that no longer exist.",          "Stop it, run the seed, then start it again.",        ].join("\n");      case "same-source":        return [          `Refusing to seed ${this.detail} from itself.`,          "The copy would restore what the delete removed, but the event log and the",          "projector cursors are cleared either way — so the directory loses its history",          "and keeps only whatever the thread and activity caps selected.",        ].join("\n");    }  }} /** * The state directory holding a base directory's database. * * `deriveServerPaths` picks `dev` for an implicit dev home and `userdata` * otherwise — a split that depends on how the server was started, which the * person seeding has no reason to know. Hard-coding `userdata` made a * main-checkout `bun run dev` (whose state lives in `<base>/dev`) unreachable: * the seed reported "no database, start the dev server so migrations run" about * a server that was already running. * * When both exist, neither is categorically right — a worktree run gets * `userdata` (the dev runner passes an explicit home), a main-checkout run gets * `dev` — so the tie goes to whichever was written most recently, i.e. the one * the last server to run actually opened. The chosen path is printed, so a * wrong guess is visible rather than silent. * * Recency is the newest of the database and its `-wal` sidecar. The server runs * SQLite in WAL mode, where writes land in the sidecar and the main file's * mtime can sit unchanged until a checkpoint — so reading the database alone * makes a busy directory look older than a dormant one. */const resolveStateDir = Effect.fn("devSeed.resolveStateDir")(function* (baseDir: string) {  const fileSystem = yield* FileSystem.FileSystem;  const path = yield* Path.Path;   const modifiedAtMs = (info: FileSystem.File.Info): number =>    Option.match(info.mtime, { onSome: (mtime) => mtime.getTime(), onNone: () => 0 });   const candidates: Array<{ readonly stateDir: string; readonly modifiedAtMs: number }> = [];  for (const stateDir of ["userdata", "dev"] as const) {    const dbPath = path.join(baseDir, stateDir, "state.sqlite");    const info = yield* fileSystem.stat(dbPath).pipe(Effect.option);    if (Option.isNone(info)) {      continue;    }    // The sidecar is absent outside WAL mode, and after a clean checkpoint.    const walInfo = yield* fileSystem.stat(`${dbPath}-wal`).pipe(Effect.option);    candidates.push({      stateDir,      modifiedAtMs: Math.max(        modifiedAtMs(info.value),        Option.match(walInfo, { onSome: modifiedAtMs, onNone: () => 0 }),      ),    });  }   // `userdata` first above, so an equal or missing mtime keeps the old default.  return (    candidates.reduce<{ readonly stateDir: string; readonly modifiedAtMs: number } | undefined>(      (best, candidate) =>        best === undefined || candidate.modifiedAtMs > best.modifiedAtMs ? candidate : best,      undefined,    )?.stateDir ?? "userdata"  );}); const stateDbPath = (path: Path.Path, baseDir: string, stateDir: string) =>  path.join(baseDir, stateDir, "state.sqlite"); /** * Copies the files behind the copied messages' attachment references. * * The rows carry only metadata; the bytes sit in a flat directory beside the * database as `<attachmentId><ext>` (apps/server/src/attachmentStore.ts). Copy * the rows alone and a seeded thread renders an image whose request 404s. * * Matched by directory listing rather than by reconstructing the extension: the * server infers it from mime type and file name at upload, and duplicating that * inference here would rot independently of it. */const copyAttachments = Effect.fn("devSeed.copyAttachments")(function* (input: {  readonly sourceDir: string;  readonly targetDir: string;  readonly attachmentIds: ReadonlyArray<string>;}) {  const fileSystem = yield* FileSystem.FileSystem;  const path = yield* Path.Path;   if (input.attachmentIds.length === 0) {    return 0;  }   const entries = yield* fileSystem    .readDirectory(input.sourceDir)    .pipe(Effect.orElseSucceed(() => [] as Array<string>));  const wanted = new Set(input.attachmentIds);  const matches = entries.filter((entry) => wanted.has(path.parse(entry).name));  if (matches.length === 0) {    return 0;  }   // Attachments are decoration on a dev fixture: one unreadable file should  // leave the rest of the seed intact rather than fail a completed database  // copy. The directory itself is subject to the same rule.  return yield* fileSystem.makeDirectory(input.targetDir, { recursive: true }).pipe(    Effect.andThen(      Effect.forEach(matches, (entry) =>        fileSystem          .copyFile(path.join(input.sourceDir, entry), path.join(input.targetDir, entry))          .pipe(            Effect.as(1),            Effect.orElseSucceed(() => 0),          ),      ),    ),    Effect.map((copied) => copied.reduce((total, one) => total + one, 0)),    Effect.orElseSucceed(() => 0),  );}); const devSeedCli = Command.make("dev-seed", {  from: Flag.string("from").pipe(    Flag.withDescription(      "Base directory to copy from (default: T3CODE_HOME, or ~/.t3). Read-only.",    ),    Flag.optional,    Flag.map(Option.getOrUndefined),  ),  to: Flag.string("to").pipe(    Flag.withDescription(      "Base directory to seed (default: this worktree's .t3). Its projection tables are replaced.",    ),    Flag.optional,    Flag.map(Option.getOrUndefined),  ),  // Bounded below: SQLite reads a negative LIMIT as "no limit", which would  // quietly turn a capped copy into a full-table one. The upper bound comes  // from the seeder itself, which binds one variable per selected thread —  // stating it separately here is what previously let the two drift apart.  threads: Flag.integer("threads").pipe(    Flag.withSchema(      Schema.Int.check(Schema.isGreaterThan(0), Schema.isLessThanOrEqualTo(MAX_SEED_THREAD_LIMIT)),    ),    Flag.withDescription(      `How many recent threads to copy (default ${String(DEFAULT_THREAD_LIMIT)}, max ${String(MAX_SEED_THREAD_LIMIT)}).`,    ),    Flag.withDefault(DEFAULT_THREAD_LIMIT),  ),  activities: Flag.integer("activities").pipe(    Flag.withSchema(Schema.Int.check(Schema.isGreaterThan(0))),    Flag.withDescription(      `Newest activities kept per thread (default ${String(DEFAULT_ACTIVITY_LIMIT)}).`,    ),    Flag.withDefault(DEFAULT_ACTIVITY_LIMIT),  ),}).pipe(  Command.withDescription(    "Copy recent projects and threads from a T3 Code data directory into an isolated dev one.",  ),  Command.withHandler((input) =>    Effect.gen(function* () {      const fileSystem = yield* FileSystem.FileSystem;      const path = yield* Path.Path;       const sharedHome = resolveSharedHome(path);      const sourceBaseDir = input.from ? path.resolve(input.from) : sharedHome;       const defaultTarget = yield* resolveWorktreeT3Home(process.cwd());      const targetBaseDir = input.to ? path.resolve(input.to) : defaultTarget;      if (targetBaseDir === undefined) {        return yield* new DevSeedTargetError({ reason: "not-a-worktree", detail: process.cwd() });      }       const [sourceStateDir, targetStateDir] = yield* Effect.all([        resolveStateDir(sourceBaseDir),        resolveStateDir(targetBaseDir),      ]);      const sourceDbPath = stateDbPath(path, sourceBaseDir, sourceStateDir);      const targetDbPath = stateDbPath(path, targetBaseDir, targetStateDir);       // The whole point is to keep dev data off the real home; overwriting it      // here would be the exact accident this command exists to avoid.      //      // Guarded on the canonical *database* paths, not the base directories: a      // base can canonicalize to itself while its `userdata` or `dev`      // subdirectory is a symlink into the shared home, and comparing bases      // then waves through a seed whose actual write target is the real      // database. Resolve the full path first — the file itself can be the      // symlink — and fall back to resolving the directory and re-joining the      // filename when the file does not exist yet: a nonexistent file cannot      // be a link to anything, but its directory still can be, and this is      // exactly the case where the missing-target advice ("start the dev      // server") would otherwise create the database in the shared home.      const canonicalDbPath = (databasePath: string) =>        fileSystem.realPath(databasePath).pipe(          Effect.catch(() =>            fileSystem              .realPath(path.dirname(databasePath))              .pipe(Effect.map((directory) => path.join(directory, path.basename(databasePath)))),          ),          Effect.orElseSucceed(() => databasePath),        );      const [canonicalTargetDb, canonicalSourceDb, canonicalShared] = yield* Effect.all([        canonicalDbPath(targetDbPath),        canonicalDbPath(sourceDbPath),        fileSystem.realPath(sharedHome).pipe(Effect.orElseSucceed(() => sharedHome)),      ]);      if (isPathWithin(path, canonicalShared, canonicalTargetDb)) {        return yield* new DevSeedTargetError({ reason: "shared-home", detail: canonicalTargetDb });      }      // After the shared-home refusal: that one must come first because the      // missing-target advice below — start a dev server, which creates the      // database — is exactly wrong for the shared home. Everywhere else, a      // missing database is the more actionable diagnosis, so it outranks      // same-source: aliased paths with nothing behind them are a setup      // problem, not a self-seed.      if (!(yield* fileSystem.exists(targetDbPath))) {        return yield* new DevSeedTargetError({ reason: "missing-target", detail: targetDbPath });      }       // Seeding a database from itself is not the data loss it looks like —      // the source reads a pre-transaction snapshot, so the copy puts the rows      // back. What does not come back is the event log and the cursors, and      // anything outside the caps. Nobody means to ask for that.      if (canonicalTargetDb === canonicalSourceDb) {        return yield* new DevSeedTargetError({ reason: "same-source", detail: canonicalTargetDb });      }       // SQLite locking is not enough on its own: the seed's transaction can take      // the write lock between a running server's own writes and commit cleanly.      // What it cannot do is tell that server the projections it is holding in      // memory, and the event log it is appending to, were both replaced under      // it. Its next write then lands on top of a read model it never built.      //      // Scoped to the state directory being seeded: a server using only the      // other directory's database is not touching this one, and blocking on it      // would refuse seeds that are perfectly safe.      const runningPid = yield* findRunningServerPid(targetBaseDir, targetStateDir);      if (Option.isSome(runningPid)) {        return yield* new DevSeedTargetError({          reason: "server-running",          detail: String(runningPid.value),        });      }       const seededAt = yield* Effect.map(DateTime.now, DateTime.formatIso);      const summary = yield* Effect.try({        try: () =>          seedDevDatabase({            sourceDbPath,            targetDbPath,            threadLimit: input.threads,            activityLimit: input.activities,            seededAt,            // The check above happens before the copy, which can take seconds.            // Re-checked just before the commit so a server that started in            // between aborts the seed instead of having its database swapped.            findRunningServerPid: () => findRunningServerPidSync(targetBaseDir, targetStateDir),          }),        catch: (cause) => cause as DevSeedError,      });       // Attachments sit beside the database, so they follow the same state dir.      const copiedAttachments = yield* copyAttachments({        sourceDir: path.join(sourceBaseDir, sourceStateDir, "attachments"),        targetDir: path.join(targetBaseDir, targetStateDir, "attachments"),        attachmentIds: summary.attachmentIds,      });       yield* Console.log(        [          `Seeded ${targetDbPath}`,          `  from     ${sourceDbPath}`,          `  projects ${String(summary.projects)}`,          `  threads  ${String(summary.threads)}`,          `  messages ${String(summary.messages)}`,          `  activity ${String(summary.activities)}`,          `  turns    ${String(summary.turns)}  sessions ${String(summary.sessions)}`,          ...(summary.attachmentIds.length > 0            ? [                `  files    ${String(copiedAttachments)}/${String(summary.attachmentIds.length)} attachment(s)`,              ]            : []),          ...(summary.skippedColumns.length > 0            ? [                `  note: skipped ${String(summary.skippedColumns.length)} column(s) absent from the target schema`,                `        (${summary.skippedColumns.join(", ")})`,              ]            : []),          "",          "Restart the dev server to pick it up.",        ].join("\n"),      );    }).pipe(      Effect.tapError((error) =>        Effect.logError(          error instanceof DevSeedError            ? `${error.message}${error.hint ? `\n${error.hint}` : ""}`            : error.message,        ),      ),    ),  ),); if (import.meta.main) {  Command.run(devSeedCli, { version: "0.0.0" }).pipe(    Effect.provide(Layer.mergeAll(Logger.layer([Logger.consolePretty()]), NodeServices.layer)),    NodeRuntime.runMain,  );}