multica-ai/multica · error

invalid schema migrations table %q: %w

Error message

invalid schema migrations table %q: %w

What it means

quoteQualifiedIdentifier rejected the schema_migrations table name before any SQL ran. The table identifier is interpolated into CREATE TABLE / EXISTS / INSERT / DELETE statements, so a name that cannot be safely quoted (empty components, NUL bytes, or an invalid qualified form) is refused rather than risk malformed or injectable SQL.

Source

Thrown at server/cmd/migrate/main.go:349

// processes against the same database with the same options: every
// caller blocks on pg_advisory_lock, and once it is their turn the
// already-applied EXISTS check turns each finished migration into a
// no-op skip. See GitHub multica-ai/multica#3647 / MUL-2923.
func runMigrations(ctx context.Context, pool *pgxpool.Pool, opts runOptions) error {
	switch opts.Direction {
	case "up", "down":
		// ok
	default:
		return fmt.Errorf("invalid direction %q (want \"up\" or \"down\")", opts.Direction)
	}

	table := opts.SchemaMigrationsTable
	if table == "" {
		table = defaultSchemaMigrationsTable
	}
	tableIdent, err := quoteQualifiedIdentifier(table)
	if err != nil {
		return fmt.Errorf("invalid schema migrations table %q: %w", table, err)
	}
	lockKey := opts.AdvisoryLockKey
	if lockKey == 0 {
		lockKey = migrationAdvisoryLockKey
	}

	// pg_advisory_lock is scoped to a single session, so we must pin one
	// *pgxpool.Conn for the whole run — calling pool.Exec would attach the
	// lock to a random connection that pgxpool could hand back out before
	// the loop finishes, making the lock effectively a no-op. We use the
	// blocking pg_advisory_lock (not pg_try_*) so a late-arriving runner
	// queues behind the current one instead of crash-looping; once it
	// acquires the lock the EXISTS checks below turn finished migrations
	// into no-op skips.
	//
	// We deliberately do NOT wrap the loop in a single transaction: the
	// repo already ships migrations using CREATE INDEX CONCURRENTLY,
	// which Postgres rejects inside a transaction block.

View on GitHub (pinned to 2c0912b6ec)

Solutions

  1. Use a valid identifier: single name (schema_migrations) or schema-qualified (myschema.schema_migrations)
  2. Check the variable/flag expansion for empties or trailing dots
  3. Avoid NUL/control characters in the configured name

Example fix

# before
migrate -schema-migrations-table="tenant."

# after
migrate -schema-migrations-table="tenant.schema_migrations"
Defensive patterns

Strategy: validation

Validate before calling

if strings.Contains(name, "\x00") || strings.HasPrefix(name, ".") || strings.HasSuffix(name, ".") || name == "" {
    return fmt.Errorf("bad table name %q", name)
}

Try / catch

tableIdent, err := quoteQualifiedIdentifier(table)
if err != nil {
    return fmt.Errorf("invalid schema migrations table %q: %w", table, err)
}

Prevention

When it happens

Trigger: Passing -schema-migrations-table="" explicitly (the empty default is replaced, but an explicitly empty qualified form like "public." is not), a name with embedded NUL, or a malformed multi-part identifier.

Common situations: Custom tracking-table names in multi-tenant setups; script variables that expand to a partially-set value.

Related errors


AI-assisted analysis of multica-ai/multica@2c0912b6ec (2026-08-15). Data as JSON: /api/errors/72fbe80cbe5a3b93. Report an issue: GitHub.