Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
claude/adoring-hopper-5x74bqclaude/affectionate-feynman-ykrf1hclaude/architecture-audit-design-wxprenclaude/build-status-update-3MXsfclaude/charming-meitner-mllb5rclaude/compare-gate-gluecron-s4mFQclaude/confident-faraday-tikcwbclaude/continue-work-XMTlIclaude/crontech-gluecron-deploy-7MIECclaude/crontech-platform-setup-SeKfwclaude/design-2026claude/ecstatic-ptolemy-jMdigclaude/enhance-github-integration-QNHdGclaude/fix-aa-loop-issue-PonMQclaude/fix-actions-and-processclaude/fix-desktop-errors-XqoW8claude/fix-red-workflowsclaude/fix-website-access-6FKJNclaude/gatetest-integration-hardeningclaude/github-audit-improvements-bDFr9claude/gluecron-launch-status-FoMRlclaude/hopeful-lamport-olfCTclaude/issue-to-pr-and-protectionsclaude/jolly-heisenberg-2sg1Qclaude/launch-preparation-QmTb6claude/new-session-xk1l7claude/plan-platform-architecture-kkN4yclaude/platform-analysis-roadmap-1nUGLclaude/platform-launch-assessment-8dWV8claude/polish-platform-release-AeDrUclaude/resume-previous-work-KzyLwclaude/review-crontech-handoff-qYEVqclaude/review-project-completeness-lHhS2claude/review-readme-docs-ulqPKclaude/serene-edison-rj87weclaude/setup-multi-repo-dev-BCwNQclaude/ship-fixes-and-tests-Jvz1cclaude/site-audit-competitive-pctlwgclaude/site-migration-vercel-XstpKclaude/standalone-product-repos-XHFTDcopilot/feat-smart-empty-states-keyboard-first-enhancementcopilot/feat-smart-morning-digest-review-context-restorecopilot/fix-and-process-workflowscopilot/update-ai-powered-code-reviewfeat/debt-mapfeat/push-policy-codeowners-hardeningfeat/smart-digest-contextfeat/stage-impactfeat/t1-secret-migrationfeat/u-polishfeat/w-self-hostfeat/w2-claude-configfix/agent-journey-orphan-sweepgatetest/auto-fix-1776586424172gatetest/auto-fix-1776586534814gatetest/auto-fix-1776590685143gatetest/auto-fix-1776590808199mainops/redeploy-retriggerstyle/dxt-cta-themeworktree-agent-a3377aad30d55da26worktree-agent-a7ef607b7ee1d6c74
migrate.ts4.7 KB · 150 lines
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
/**
 * Migration runner — executes every `drizzle/*.sql` file in order.
 * Tracks applied migrations in a dedicated table so repeat runs are idempotent.
 *
 * Dual-driver: uses Neon HTTP for *.neon.tech URLs, postgres.js TCP for
 * everything else (localhost, RDS, Supabase, plain Postgres).
 */

import { readdir, readFile } from "fs/promises";
import { join } from "path";
import { neon } from "@neondatabase/serverless";
import postgres from "postgres";
import { config } from "../lib/config";
import { isNeonUrl } from "./index";

type Exec = (query: string, params?: unknown[]) => Promise<unknown>;

/**
 * Split a migration file into individual SQL statements.
 *
 * Two formats are supported:
 *   1. Drizzle-generated migrations use `--> statement-breakpoint` markers
 *      between statements. Detect and split on those.
 *   2. Hand-written migrations (like `0000_init.sql`) just have multiple
 *      `CREATE TABLE` etc statements separated by `;`. Detect when the
 *      drizzle marker isn't present and fall back to a semicolon split
 *      that's safe for our schemas (no PL/pgSQL `$$...$$` blocks today).
 *
 * Comment-only lines (`--`) are stripped before splitting so a `;`
 * inside a comment doesn't trigger a false split. Empty fragments are
 * dropped. Trailing semicolons are removed.
 */
function splitMigrationStatements(content: string): string[] {
  if (/-->\s*statement-breakpoint/.test(content)) {
    return content
      .split(/-->\s*statement-breakpoint/)
      .map((s) =>
        s
          .split("\n")
          .filter((line) => !line.trim().startsWith("--"))
          .join("\n")
          .trim()
      )
      .filter((s) => s.length > 0);
  }
  // No breakpoint markers — split on `;` at end of (logical) line. Strip
  // pure-comment lines first.
  const stripped = content
    .split("\n")
    .filter((line) => !line.trim().startsWith("--"))
    .join("\n");
  return stripped
    .split(/;\s*\n/)
    .map((s) => s.trim().replace(/;\s*$/, "").trim())
    .filter((s) => s.length > 0);
}

async function runMigrations() {
  if (!config.databaseUrl) {
    throw new Error("DATABASE_URL is not set");
  }

  let exec: Exec;
  let cleanup: (() => Promise<void>) | null = null;

  if (isNeonUrl(config.databaseUrl)) {
    // @neondatabase/serverless 1.x: the tagged-template factory only accepts
    // a TemplateStringsArray. Use the .query(string, params) helper for the
    // dynamic-string flow this migration runner needs.
    const neonSql = neon(config.databaseUrl);
    exec = (q, p = []) => neonSql.query(q, p);
  } else {
    const client = postgres(config.databaseUrl, { max: 1, prepare: false });
    exec = (q, p) =>
      p && p.length > 0 ? client.unsafe(q, p as never[]) : client.unsafe(q);
    cleanup = async () => {
      await client.end({ timeout: 5 });
    };
  }

  try {
    // Ensure tracking table exists
    await exec(`
      CREATE TABLE IF NOT EXISTS "_migrations" (
        "name" text PRIMARY KEY,
        "applied_at" timestamp DEFAULT now() NOT NULL
      )
    `);

    const appliedRows = (await exec(`SELECT name FROM _migrations`)) as Array<{
      name: string;
    }>;
    const appliedSet = new Set(appliedRows.map((r) => r.name));

    const migrationsDir = join(process.cwd(), "drizzle");
    const files = (await readdir(migrationsDir))
      .filter((f) => f.endsWith(".sql"))
      .sort();

    if (files.length === 0) {
      console.log("No migration files found.");
      return;
    }

    for (const file of files) {
      if (appliedSet.has(file)) {
        console.log(`[migrate] ${file} — already applied, skipping`);
        continue;
      }

      console.log(`[migrate] applying ${file}...`);
      const content = await readFile(join(migrationsDir, file), "utf8");
      const statements = splitMigrationStatements(content);

      for (const stripped of statements) {
        try {
          await exec(stripped);
        } catch (err) {
          const msg = err instanceof Error ? err.message : String(err);
          if (
            msg.includes("already exists") ||
            msg.includes("duplicate_column") ||
            msg.includes("duplicate_object")
          ) {
            console.warn(
              `[migrate] ${file}: ${msg.slice(0, 120)} (treated as applied)`
            );
            continue;
          }
          console.error(
            `[migrate] ${file} failed on statement:\n${stripped.slice(0, 500)}\n\n${msg}`
          );
          throw err;
        }
      }

      await exec(`INSERT INTO _migrations (name) VALUES ($1)`, [file]);
      console.log(`[migrate] ${file} applied`);
    }

    console.log("[migrate] all migrations complete");
  } finally {
    if (cleanup) await cleanup();
  }
}

runMigrations().catch((err) => {
  console.error("Migration failed:", err);
  process.exit(1);
});