mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
* feat(skills): permissions layer * chore(db): drop skill_member migration 0261 for regeneration on latest staging Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(db): regenerate skill_member migration as 0262 on latest staging Same DDL as the dropped 0261 (skill_member table, enums, indexes, skill.workspace_shared) plus the hand-written write-user backfill, renumbered after staging's 0261. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(db): regenerate skill_member migration as 0263 after staging merge Staging claimed 0262 (strong_storm); same DDL plus the hand-written write-user backfill, renumbered on the merged snapshot chain. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(db): drop skill_member migration 0263 for regeneration on latest staging Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(db): regenerate skill_member migration as 0264 after staging merge Staging claimed 0263 (workflow_fork_sync_excluded); same DDL plus the hand-written write-user backfill, renumbered on the merged snapshot chain. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * make editing skills full page * fix disclaimer * edit access msg * fix lint * chore(db): drop skill_member migration 0264 for regeneration on latest staging Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(db): regenerate skill_member migration as 0265 after staging merge Staging claimed 0264 (fat_ikaris); same DDL plus the hand-written write-user backfill, renumbered on the merged snapshot chain. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix tests * simplify system * fix * fix lint * add mship skills docs * chore(db): drop skill_member migration 0265 for regeneration on latest staging Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(db): regenerate skill_member migration as 0266 after staging merge Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(deps): override zod to 4.3.6 to dedupe nested copies breaking type-check better-auth 1.6.23 and fumadocs-mdx resolve ^4.3.6 to a nested zod 4.4.3, which makes @sim/auth's inferred betterAuth types non-portable (TS2883) and split docs onto a second zod instance. Both ranges accept the repo-wide pinned 4.3.6, so a single hoisted copy satisfies everything. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix lint * feat(skills,tools): fullscreen skill create + shared custom tool editor Moves the rich-markdown and custom-tool editing surfaces out of modals and onto full-page surfaces, and collapses the duplicated chrome behind shared components. Skills - Add /skills/new, a full-page create surface mirroring the skill detail page (CredentialDetailLayout + DetailSection + unsaved-changes guard). "Add to Sim" navigates there instead of opening a modal. - Import moves to a header action (SkillImportButton) backed by a shared readSkillFile helper; the GitHub-URL import and its /api/skills/import route are removed. - Skill name validation is now one shared validateSkillName, replacing three copies of the kebab-case rule and its messages. - The skill editor roster renders through the shared MemberRow instead of re-deriving its identity block, with a locked role control and a lock-reason tooltip explaining inherited workspace-admin access. Custom tools - Extract the canvas modal's schema/code editors into a shared custom-tool-editor module (fields, wand generation, schema helpers), cutting custom-tool-modal.tsx by ~900 lines. - Settings > Custom tools gains a full-page detail sub-view (SettingsPanel + SettingsSection + saveDiscardActions), deep-linkable via ?custom-tool-id. Rows are clickable; delete now lives only in the detail view. - Replace legacy Button/Input/Badge/Label with the chip family, move chip-field chrome into CodeEditor behind an error prop, and delete its dead wand button. Rich markdown field - maxHeight is now opt-in: omit it on a page and the editor grows with its content so the page owns the only scrollbar. Modals pass explicit caps. - The field variant drops to font-weight 400 to match adjacent chip fields. * fix(skills): address review round on create navigation, 409 copy, and editor audit - Skill create navigated using the first element of the upsert response, but that endpoint returns the caller's whole skill list (built-ins prepended) — match the new skill by its workspace-unique name instead. - The suggested-skill 409 toast claimed the skill existed but was not shared and told the user to ask a skill admin. Every workspace member can already see and use every skill, so a 409 only means the name is taken. - Adding an editor emitted the skill_shared event and SKILL_MEMBER_ADDED audit even when onConflictDoNothing skipped the insert on a concurrent add. Gate both on the insert actually returning a row. * chore: format skills-resolver test import * fix(skills,tools): audit fixes — autocomplete boundary, resize clipping, error routing Two real regressions introduced while simplifying the extracted editor: - The schema-param autocomplete's trigger was rewritten to match a trailing identifier, but the completion still split on separators. The two disagreed, so typing `data.ci` opened the menu and selecting replaced `data.ci` whole — eating the member-access prefix. Both now share one SCHEMA_PARAM_WORD regex. - The uncapped markdown field measured its height only on value change while always setting overflow-hidden, so any width change that re-wrapped lines clipped the tail with no scrollbar to reach it. Now re-measures via ResizeObserver. Also from the audit: - Generation writes bypass the code field's change handler, so an open autocomplete stayed over a disabled streaming editor; close it on busy. - Delete failures rendered in the Schema section's error slot on both custom tool surfaces; route them to a toast instead. - Skill create navigated away while still dirty, stranding the unsaved-changes guard's history sentinel so Back landed on an empty create form. - The Description field on skill create never received its error border. - Drop a double-applied opacity-50 (the editor already dims when disabled), a dead try/catch around a non-throwing call that also shadowed the error prop, and a stale reference to a /tools page that does not exist. - Docs still described the removed GitHub-URL import and the old Add Skill dialog; rewrite for the create page and file/paste import. * feat(tools): read-only tool detail, create lands on the new tool, drop dead wand prompt API - Viewers without edit rights could not open a custom tool at all, while the equivalent skill and custom-block surfaces both offer a read-only view. The detail page now takes `readOnly`: editors inert, no Save/Discard/Delete, no Generate. Creating still requires edit rights. - Creating a tool bounced back to the list while creating a skill lands on the new skill. Tools now do the same. The upsert returns the workspace's whole tool list (newest first) rather than just the new row, so the id is matched by title instead of by index — the same trap that produced the skill-create navigation bug. - Remove `openPrompt`/`closePrompt` from useWand. `closePrompt`'s last callers went away with the custom-tool-modal extraction and `openPrompt` had none before it; nothing reads `isPromptVisible` any more either. * fix(tools): read-only editors, design-system wrench, skills-matching tool identity - readOnly never reached the editors: the prop gated actions and Generate but the schema and code fields were still typable for viewers without edit rights. Wire disabled through both fields into CodeEditor. - The row icon used lucide's Wrench (strokeWidth 2) where @sim/emcn/icons ships one drawn for this system (1.55, tuned viewBox), and it inherited body text colour instead of --text-icon. Swap it. - Give the tool detail page the same identity heading as skill detail: tile, name, and description at the top left, instead of only a header title. - Extract ResourceTile so the skills and tools tiles share one definition (SkillTile now composes it), and add an opt-in `iconFilled` to SettingsResourceRow so the tools list tile matches the skills gallery. Both default to today's behaviour for every existing consumer. * fix(mentions): use the product's own glyph for every @ mention kind The `@` menu and the inserted chip mapped kinds to arbitrary lucide icons — `Sparkles` for a skill, a generic `File` for every file — while the rest of the product has a settled glyph per resource. Mirror CHAT_CONTEXT_KIND_REGISTRY, which Chat's `@` menu already renders from: - skill now uses AgentSkillsIcon, the same glyph SkillTile shows everywhere - workflow / folder / table / knowledge use the @sim/emcn/icons set the sidebar and the chat registry use - file derives its icon from the filename extension, so a .pdf and a .csv are distinguishable, matching the file list and Chat's context chips - integration keeps the block's brand icon from the registry Also drop the generic placeholder. `kind` is untrusted — the node schema defaults it to `''` and a hand-written `sim:` link can carry anything — but an unrecognized kind now yields no icon instead of a meaningless box, which is what the chat registry does. The menu already guarded a missing icon; the chip now does too, so this cannot crash on a malformed link. * chore(db): drop skill_member migration 0266 for regeneration on latest staging * feat(db): regenerate skill_member migration as 0267 after staging merge --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Waleed Latif <walif6@gmail.com>
495 lines
16 KiB
TypeScript
495 lines
16 KiB
TypeScript
#!/usr/bin/env bun
|
|
/**
|
|
* Guards new Drizzle migrations against deploy-window downtime.
|
|
*
|
|
* During a deploy the previously-deployed app code keeps serving against the
|
|
* freshly-migrated schema (blue/green keeps both versions live). A migration
|
|
* that is backward-incompatible with that older code — drops a column it still
|
|
* reads, renames, adds a NOT NULL its inserts don't populate — throws until the
|
|
* new code takes over. The fix is the expand/contract discipline: additive now,
|
|
* destructive only after the dependent code is gone.
|
|
*
|
|
* This lint is the deterministic half of that guard (the `/db-migrate` skill is
|
|
* the judgment half). It classifies every statement in migrations added on this
|
|
* branch:
|
|
* - HARD ERROR: ops that are essentially never one-deploy-safe. Rewrite them.
|
|
* - ANNOTATE: legitimate contract-phase ops. Acknowledge each with a
|
|
* `-- migration-safe: <reason>` comment on the preceding line(s),
|
|
* only after confirming the dependent code already shipped out.
|
|
* - WARN: data backfills — surfaced for review, never block.
|
|
*
|
|
* Scope is new migration files only (git diff vs base); the existing corpus is
|
|
* grandfathered. Usage:
|
|
* bun run scripts/check-migrations-safety.ts [baseRef] # base defaults to origin/staging
|
|
* bun run scripts/check-migrations-safety.ts --all # whole corpus
|
|
* bun run scripts/check-migrations-safety.ts --dir <path> # a directory
|
|
*/
|
|
import { execFileSync } from 'node:child_process'
|
|
import { existsSync } from 'node:fs'
|
|
import { readdir, readFile } from 'node:fs/promises'
|
|
import path from 'node:path'
|
|
|
|
const ROOT = path.resolve(import.meta.dir, '..')
|
|
const MIGRATIONS_DIR = 'packages/db/migrations'
|
|
const ANNOTATION_PREFIX = '-- migration-safe:'
|
|
|
|
type Tier = 'error' | 'warn'
|
|
|
|
interface Finding {
|
|
line: number
|
|
statement: string
|
|
tier: Tier
|
|
rule: string
|
|
message: string
|
|
}
|
|
|
|
interface Statement {
|
|
sql: string
|
|
startLine: number
|
|
}
|
|
|
|
/** Strip quotes and any schema prefix so `"public"."user"` and `"user"` match. */
|
|
function bareName(raw: string): string {
|
|
const unquoted = raw.replace(/"/g, '')
|
|
const parts = unquoted.split('.')
|
|
return (parts[parts.length - 1] ?? unquoted).toLowerCase()
|
|
}
|
|
|
|
/**
|
|
* Split SQL into statements with their 1-based start line, respecting line
|
|
* comments (`--`), block comments, and single-quoted strings so a `;` inside
|
|
* any of them does not terminate a statement.
|
|
*/
|
|
function parseStatements(content: string): Statement[] {
|
|
const statements: Statement[] = []
|
|
let buf = ''
|
|
let startOffset = -1
|
|
let inLine = false
|
|
let inBlock = false
|
|
let inStr = false
|
|
let dollarTag: string | null = null
|
|
|
|
const lineAt = (offset: number): number => {
|
|
let line = 1
|
|
for (let i = 0; i < offset; i++) if (content[i] === '\n') line++
|
|
return line
|
|
}
|
|
const flush = () => {
|
|
const sql = buf.trim()
|
|
if (sql.length > 0 && startOffset >= 0) statements.push({ sql, startLine: lineAt(startOffset) })
|
|
buf = ''
|
|
startOffset = -1
|
|
}
|
|
|
|
for (let i = 0; i < content.length; i++) {
|
|
const c = content[i]
|
|
const next = content[i + 1]
|
|
|
|
if (inLine) {
|
|
if (c === '\n') inLine = false
|
|
continue
|
|
}
|
|
if (inBlock) {
|
|
if (c === '*' && next === '/') {
|
|
inBlock = false
|
|
i++
|
|
}
|
|
continue
|
|
}
|
|
if (inStr) {
|
|
buf += c
|
|
if (c === "'") {
|
|
if (next === "'") {
|
|
buf += "'"
|
|
i++
|
|
} else {
|
|
inStr = false
|
|
}
|
|
}
|
|
continue
|
|
}
|
|
if (dollarTag) {
|
|
if (c === '$' && content.startsWith(dollarTag, i)) {
|
|
buf += dollarTag
|
|
i += dollarTag.length - 1
|
|
dollarTag = null
|
|
} else {
|
|
buf += c
|
|
}
|
|
continue
|
|
}
|
|
if (c === '$') {
|
|
const tag = /^\$[A-Za-z_]*\$/.exec(content.slice(i))?.[0]
|
|
if (tag) {
|
|
if (startOffset < 0) startOffset = i
|
|
dollarTag = tag
|
|
buf += tag
|
|
i += tag.length - 1
|
|
continue
|
|
}
|
|
}
|
|
if (c === '-' && next === '-') {
|
|
inLine = true
|
|
i++
|
|
continue
|
|
}
|
|
if (c === '/' && next === '*') {
|
|
inBlock = true
|
|
i++
|
|
continue
|
|
}
|
|
if (c === "'") {
|
|
inStr = true
|
|
if (startOffset < 0) startOffset = i
|
|
buf += c
|
|
continue
|
|
}
|
|
if (c === ';') {
|
|
flush()
|
|
continue
|
|
}
|
|
if (startOffset < 0 && !/\s/.test(c)) startOffset = i
|
|
buf += c
|
|
}
|
|
flush()
|
|
return statements
|
|
}
|
|
|
|
/**
|
|
* Mirror of the api-validation annotation reader, for `--` SQL comments:
|
|
* scans up to three consecutive non-empty preceding lines for the prefix.
|
|
* `allowed` when a non-empty reason follows; `missingReason` flags a dangling
|
|
* annotation so it fails rather than silently passing.
|
|
*/
|
|
function readAnnotation(
|
|
lines: string[],
|
|
startLine: number
|
|
): { allowed: boolean; missingReason: boolean } {
|
|
let inspected = 0
|
|
for (let i = startLine - 2; i >= 0 && inspected < 3; i--) {
|
|
const trimmed = lines[i]?.trim() ?? ''
|
|
if (trimmed.length === 0) continue
|
|
inspected++
|
|
if (!trimmed.startsWith('--')) return { allowed: false, missingReason: false }
|
|
const idx = trimmed.indexOf(ANNOTATION_PREFIX)
|
|
if (idx === -1) continue
|
|
const reason = trimmed.slice(idx + ANNOTATION_PREFIX.length).trim()
|
|
if (reason.length === 0) return { allowed: false, missingReason: true }
|
|
return { allowed: true, missingReason: false }
|
|
}
|
|
return { allowed: false, missingReason: false }
|
|
}
|
|
|
|
interface RawMatch {
|
|
kind: 'error' | 'annotate' | 'warn'
|
|
rule: string
|
|
message: string
|
|
}
|
|
|
|
/**
|
|
* Classify one statement. `createdTables` holds tables created in the same
|
|
* migration — ops against a brand-new table have no old rows and no live
|
|
* traffic, so they are always safe and skipped. `sawCommit` tracks whether a
|
|
* `COMMIT;` breakpoint preceded a CONCURRENTLY index (see migrate.ts).
|
|
*/
|
|
function classify(sql: string, createdTables: Set<string>, sawCommit: boolean): RawMatch[] {
|
|
const s = sql.replace(/\s+/g, ' ').trim()
|
|
const matches: RawMatch[] = []
|
|
|
|
const alterTable = s.match(/\bALTER TABLE (?:IF EXISTS )?(?:ONLY )?("?[.\w]+"?)/i)
|
|
const targetTable = alterTable ? bareName(alterTable[1]) : null
|
|
const onNewTable = targetTable !== null && createdTables.has(targetTable)
|
|
|
|
if (/^CREATE (?:UNIQUE )?INDEX\b/i.test(s)) {
|
|
const on = s.match(/\bON ("?[.\w]+"?)/i)
|
|
const indexTable = on ? bareName(on[1]) : null
|
|
const concurrent = /\bCONCURRENTLY\b/i.test(s)
|
|
if (!(indexTable && createdTables.has(indexTable))) {
|
|
if (!concurrent) {
|
|
matches.push({
|
|
kind: 'error',
|
|
rule: 'index-not-concurrent',
|
|
message:
|
|
'CREATE INDEX on an existing table write-locks it for the whole build. Use CREATE INDEX CONCURRENTLY IF NOT EXISTS after a COMMIT; breakpoint (see packages/db/scripts/migrate.ts).',
|
|
})
|
|
} else if (!/\bIF NOT EXISTS\b/i.test(s)) {
|
|
matches.push({
|
|
kind: 'error',
|
|
rule: 'concurrent-index-not-idempotent',
|
|
message:
|
|
'CREATE INDEX CONCURRENTLY must be IF NOT EXISTS — a failed build replays from the top and a partial INVALID index would be skipped forever.',
|
|
})
|
|
} else if (!sawCommit) {
|
|
matches.push({
|
|
kind: 'error',
|
|
rule: 'concurrent-index-no-commit',
|
|
message:
|
|
'CREATE INDEX CONCURRENTLY cannot run inside the migration transaction. Precede it with a COMMIT; breakpoint and SET lock_timeout = 0 (see packages/db/scripts/migrate.ts).',
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
if (
|
|
!onNewTable &&
|
|
/\bADD COLUMN\b/i.test(s) &&
|
|
/\bNOT NULL\b/i.test(s) &&
|
|
!/\bDEFAULT\b/i.test(s)
|
|
) {
|
|
matches.push({
|
|
kind: 'error',
|
|
rule: 'add-not-null-no-default',
|
|
message:
|
|
'ADD COLUMN NOT NULL with no DEFAULT breaks old inserts (and fails on existing rows). Add it nullable or with a DEFAULT, backfill, then SET NOT NULL in a later migration once code populates it.',
|
|
})
|
|
}
|
|
|
|
if (/\bRENAME COLUMN\b/i.test(s) || /^ALTER TABLE\b[^;]*\bRENAME TO\b/i.test(s)) {
|
|
matches.push({
|
|
kind: 'error',
|
|
rule: 'rename',
|
|
message:
|
|
'RENAME of a column/table breaks old code reading the old name. Add the new column/table, dual-write in code, then drop the old one in a later deploy.',
|
|
})
|
|
}
|
|
|
|
if (
|
|
!onNewTable &&
|
|
/\bADD CONSTRAINT\b/i.test(s) &&
|
|
/\b(FOREIGN KEY|CHECK)\b/i.test(s) &&
|
|
!/\bNOT VALID\b/i.test(s)
|
|
) {
|
|
matches.push({
|
|
kind: 'error',
|
|
rule: 'constraint-not-valid',
|
|
message:
|
|
'ADD CONSTRAINT FOREIGN KEY/CHECK on an existing table locks it and rejects old writes that violate it. Add it NOT VALID, then VALIDATE CONSTRAINT in a separate step.',
|
|
})
|
|
}
|
|
|
|
if (!onNewTable) {
|
|
if (/^DROP TABLE\b/i.test(s)) {
|
|
matches.push({ kind: 'annotate', rule: 'drop-table', message: 'DROP TABLE' })
|
|
}
|
|
if (/\bDROP COLUMN\b/i.test(s)) {
|
|
matches.push({ kind: 'annotate', rule: 'drop-column', message: 'DROP COLUMN' })
|
|
}
|
|
if (/\bDROP CONSTRAINT\b/i.test(s)) {
|
|
matches.push({ kind: 'annotate', rule: 'drop-constraint', message: 'DROP CONSTRAINT' })
|
|
}
|
|
if (/\bDROP DEFAULT\b/i.test(s)) {
|
|
matches.push({ kind: 'annotate', rule: 'drop-default', message: 'DROP DEFAULT' })
|
|
}
|
|
if (/\bSET NOT NULL\b/i.test(s)) {
|
|
matches.push({ kind: 'annotate', rule: 'set-not-null', message: 'SET NOT NULL' })
|
|
}
|
|
if (/\bSET DATA TYPE\b/i.test(s) || /\bALTER COLUMN ("?[.\w]+"?) TYPE\b/i.test(s)) {
|
|
matches.push({ kind: 'annotate', rule: 'alter-type', message: 'column type change' })
|
|
}
|
|
}
|
|
if (/^DROP INDEX\b/i.test(s)) {
|
|
if (!/\bCONCURRENTLY\b/i.test(s)) {
|
|
matches.push({
|
|
kind: 'error',
|
|
rule: 'drop-index-not-concurrent',
|
|
message:
|
|
'Plain DROP INDEX takes an ACCESS EXCLUSIVE lock on the table for the whole drop. Use DROP INDEX CONCURRENTLY after a COMMIT; breakpoint (see packages/db/scripts/migrate.ts).',
|
|
})
|
|
} else if (!/\bIF EXISTS\b/i.test(s)) {
|
|
matches.push({
|
|
kind: 'error',
|
|
rule: 'concurrent-drop-index-not-idempotent',
|
|
message:
|
|
'DROP INDEX CONCURRENTLY must be IF EXISTS — a failed run replays from the top and would abort re-dropping an already-gone index.',
|
|
})
|
|
} else if (!sawCommit) {
|
|
matches.push({
|
|
kind: 'error',
|
|
rule: 'concurrent-drop-index-no-commit',
|
|
message:
|
|
'DROP INDEX CONCURRENTLY cannot run inside the migration transaction. Precede it with a COMMIT; breakpoint (see packages/db/scripts/migrate.ts).',
|
|
})
|
|
}
|
|
}
|
|
|
|
if (/^(UPDATE|DELETE)\b/i.test(s)) {
|
|
const noWhere = !/\bWHERE\b/i.test(s)
|
|
matches.push({
|
|
kind: 'warn',
|
|
rule: 'data-backfill',
|
|
message: noWhere
|
|
? 'data backfill with no WHERE rewrites/locks the whole table. Confirm it is batched, idempotent, and safe under concurrent writes.'
|
|
: 'data backfill. Confirm it is batched, idempotent, and safe under concurrent writes.',
|
|
})
|
|
}
|
|
|
|
return matches
|
|
}
|
|
|
|
const ANNOTATE_GUIDANCE =
|
|
'is a contract-phase op. Confirm the old code no longer reads/writes it (it must have shipped in an earlier deploy — not this same PR), then acknowledge with a `-- migration-safe: <reason>` comment on the line above.'
|
|
|
|
/** Lint a single migration's SQL. Returns only actionable findings. */
|
|
export function lintSql(content: string): Finding[] {
|
|
const lines = content.split('\n')
|
|
const statements = parseStatements(content)
|
|
const createdTables = new Set<string>()
|
|
for (const { sql } of statements) {
|
|
const m = sql.match(/^CREATE TABLE (?:IF NOT EXISTS )?("?[.\w]+"?)/i)
|
|
if (m) createdTables.add(bareName(m[1]))
|
|
}
|
|
|
|
const findings: Finding[] = []
|
|
let sawCommit = false
|
|
for (const { sql, startLine } of statements) {
|
|
for (const match of classify(sql, createdTables, sawCommit)) {
|
|
if (match.kind === 'error') {
|
|
findings.push({
|
|
line: startLine,
|
|
statement: sql,
|
|
tier: 'error',
|
|
rule: match.rule,
|
|
message: match.message,
|
|
})
|
|
} else if (match.kind === 'warn') {
|
|
findings.push({
|
|
line: startLine,
|
|
statement: sql,
|
|
tier: 'warn',
|
|
rule: match.rule,
|
|
message: match.message,
|
|
})
|
|
} else {
|
|
const ann = readAnnotation(lines, startLine)
|
|
if (ann.allowed) continue
|
|
findings.push({
|
|
line: startLine,
|
|
statement: sql,
|
|
tier: 'error',
|
|
rule: match.rule,
|
|
message: ann.missingReason
|
|
? `${match.message}: \`-- migration-safe:\` annotation has no reason. Give it a real justification.`
|
|
: `${match.message} ${ANNOTATE_GUIDANCE}`,
|
|
})
|
|
}
|
|
}
|
|
if (/^COMMIT\b/i.test(sql.trim())) sawCommit = true
|
|
}
|
|
return findings
|
|
}
|
|
|
|
function git(args: string[]): string | null {
|
|
try {
|
|
return execFileSync('git', args, {
|
|
cwd: ROOT,
|
|
encoding: 'utf8',
|
|
stdio: ['ignore', 'pipe', 'ignore'],
|
|
}).trim()
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
/** New migration files on this branch vs base, plus uncommitted ones locally. */
|
|
function changedMigrationFiles(baseRef: string): string[] {
|
|
const files = new Set<string>()
|
|
const inDir = (p: string) => p.startsWith(`${MIGRATIONS_DIR}/`) && p.endsWith('.sql')
|
|
|
|
const mergeBase = git(['merge-base', baseRef, 'HEAD']) ?? baseRef
|
|
const committed = git([
|
|
'diff',
|
|
'--name-only',
|
|
'--diff-filter=AM',
|
|
mergeBase,
|
|
'HEAD',
|
|
'--',
|
|
MIGRATIONS_DIR,
|
|
])
|
|
if (committed === null) return [] // git unavailable → fail open (handled by caller)
|
|
for (const f of committed.split('\n')) if (inDir(f)) files.add(f)
|
|
|
|
const status = git(['status', '--porcelain', '--', MIGRATIONS_DIR])
|
|
if (status) {
|
|
for (const raw of status.split('\n')) {
|
|
const p = raw.slice(3).trim()
|
|
if (inDir(p)) files.add(p)
|
|
}
|
|
}
|
|
// A migration deleted in the working tree (e.g. regenerated before commit) has
|
|
// no SQL left to lint — skip it rather than crash on the read.
|
|
return [...files].filter((f) => existsSync(path.join(ROOT, f)))
|
|
}
|
|
|
|
async function listSqlFiles(dir: string): Promise<string[]> {
|
|
const out: string[] = []
|
|
const entries = await readdir(dir, { withFileTypes: true })
|
|
for (const e of entries) {
|
|
const full = path.join(dir, e.name)
|
|
if (e.isDirectory()) out.push(...(await listSqlFiles(full)))
|
|
else if (e.name.endsWith('.sql')) out.push(full)
|
|
}
|
|
return out
|
|
}
|
|
|
|
async function resolveFiles(argv: string[]): Promise<string[] | null> {
|
|
if (argv.includes('--all')) {
|
|
return (await listSqlFiles(path.join(ROOT, MIGRATIONS_DIR))).map((f) => path.relative(ROOT, f))
|
|
}
|
|
const dirIdx = argv.indexOf('--dir')
|
|
if (dirIdx !== -1) {
|
|
const dir = argv[dirIdx + 1]
|
|
if (!dir) throw new Error('--dir requires a path')
|
|
return (await listSqlFiles(path.resolve(dir))).map((f) => path.relative(ROOT, f))
|
|
}
|
|
const baseRef = argv.find((a) => !a.startsWith('--')) ?? 'origin/staging'
|
|
const files = changedMigrationFiles(baseRef)
|
|
if (files.length === 0 && git(['rev-parse', 'HEAD']) === null) {
|
|
console.warn('⚠ git unavailable — skipping migration safety check.')
|
|
return null
|
|
}
|
|
return files
|
|
}
|
|
|
|
async function main() {
|
|
const files = await resolveFiles(process.argv.slice(2))
|
|
if (files === null) process.exit(0)
|
|
|
|
if (files.length === 0) {
|
|
console.log('✓ No new migrations to check.')
|
|
process.exit(0)
|
|
}
|
|
|
|
let errors = 0
|
|
let warnings = 0
|
|
for (const rel of files) {
|
|
const content = await readFile(path.join(ROOT, rel), 'utf8')
|
|
const findings = lintSql(content)
|
|
if (findings.length === 0) continue
|
|
|
|
console.error(`\n${rel}`)
|
|
for (const f of findings.sort((a, b) => a.line - b.line)) {
|
|
const icon = f.tier === 'error' ? '✗' : '⚠'
|
|
if (f.tier === 'error') errors++
|
|
else warnings++
|
|
console.error(` ${icon} ${rel}:${f.line} [${f.rule}]`)
|
|
console.error(` ${f.statement.replace(/\s+/g, ' ').slice(0, 120)}`)
|
|
console.error(` → ${f.message}`)
|
|
}
|
|
}
|
|
|
|
if (errors === 0) {
|
|
console.log(
|
|
warnings > 0
|
|
? `\n✓ No blocking migration issues (${warnings} warning(s) to review).`
|
|
: '\n✓ Migrations are backward-compatible.'
|
|
)
|
|
process.exit(0)
|
|
}
|
|
console.error(
|
|
`\nFound ${errors} blocking migration issue(s). Rewrite hard errors into expand/contract, or annotate contract ops once safe. See the /db-migrate skill.`
|
|
)
|
|
process.exit(1)
|
|
}
|
|
|
|
if (import.meta.main) main()
|