Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
58c87c3
add support for outside-of-transaction migrations
muir Oct 16, 2025
0914b92
more tests
muir Sep 27, 2025
fcfe6c3
more tests
muir Sep 29, 2025
679e56f
shorten readme
muir Sep 29, 2025
fc8f426
adjustments, coverage, parallel
muir Sep 29, 2025
28559a4
coverage
muir Sep 29, 2025
7a82e48
more tests
muir Sep 29, 2025
89d9c43
less casting
muir Sep 29, 2025
a50657d
more coverage
muir Sep 30, 2025
2c3cb7a
more tests
muir Sep 30, 2025
ff1126e
lint
muir Oct 1, 2025
51b9e4e
test updates
muir Oct 1, 2025
387708b
all error generation -> memsql/errors
muir Oct 1, 2025
be1f33a
tweak readme to retrigger tests
muir Oct 1, 2025
4406956
refactor for common code and testability
muir Oct 3, 2025
251d748
lint
muir Oct 4, 2025
bd2f56c
more coverage
muir Oct 4, 2025
6f25476
more tests
muir Oct 4, 2025
2015e2a
stmtclass
muir Oct 6, 2025
5179895
simplify mysql
muir Oct 6, 2025
e941902
simplify mysql.go
muir Oct 7, 2025
1eab28a
cleanup
muir Oct 7, 2025
86fe132
copilot feedback
muir Oct 7, 2025
1583305
wip checkpoint
muir Oct 13, 2025
34a1fc5
redesign
muir Oct 15, 2025
df5f867
self review
muir Oct 15, 2025
a54e51a
read commited for forced downgrade w/generate
muir Oct 15, 2025
d8a6586
more self review
muir Oct 15, 2025
7673390
fake schema everywhere
muir Oct 15, 2025
b1b337b
test: consolidate postgres non-tx & classification, mysql generate & …
muir Oct 16, 2025
1d425d2
test(mysql): fold computed mismatch & failure into bad migrations sui…
muir Oct 16, 2025
753bf0e
chore(testing,postgres): simplify FakeSchema cleanup; switch server v…
muir Oct 16, 2025
f1bfd33
adjust comments for Script/Generate/Computed; remove GenerateE, a typo
muir Oct 16, 2025
91753e8
clean up / review feedback
muir Oct 24, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# GitHub Copilot Contribution Rules

One page. No ambiguity. Follow or reject the change.

## Good engineering
- always prefer to remove code than to add it
- always look for ways to simplify

## Core
- Remove things completely. No placeholders, stubs, TODO, commentary about removal. History lives only in git.
- No historical or refactor narration comments. Comments only explain current behavior, invariants, limits, dialect nuances, or (released) API deprecations.
- ASCII only. No Unicode of any kind.

## Public API
- Public = exported + existed in a tagged release + intentionally documented.
- Changing/removing: update doc comment only if callers must adapt.
- Shim only if break is not trivially fixable. Shim must be minimal, delegated, marked `// Deprecated:` with planned removal version, and tested.
- No shims for anything else.

## Testing
- Coverage target 95-100% for unit-testable code.
- No mocks.
- Use testify.
- Deterministic, table-driven tests. Assert meaningful outcomes only.
- Disallow: mocking frameworks, log-text assertions (unless log text is contract), sleeps for timing.
- Prefer errors.Is() over checking error text

## Style & Refactors
- Prefer elimination over abstraction. No commented-out code. No speculative feature flags.
- Inline vs extract based strictly on clarity or real reuse.
- Silent refactors do not add commentary. Behavioral changes only update directly affected docs.

## Errors
- Use memsql/errors only.
- Sentinel errors with errors.String type, augmented with .Errorf
- Wrap once at subsystem boundaries with context. Do not re-wrap in tight loops. Avoid repeating obvious parameter values.
- Don't capitalize the first letter

## Temporary changes for debugging
- Mark temporary code with "XXX"
- Add freely

## Checklist (all must be true)
- [ ] No history / refactor narration comments.
- [ ] No placeholders or commented-out code left behind.
- [ ] ASCII only.
- [ ] Public API changes reviewed; shims only when allowed & tested.
- [ ] Coverage for unit-testable code >=95%
- [ ] No mocks.
- [ ] Error wrapping matches boundary rule.
- [ ] Tests use testify and do not use t.Errorf() or t.FailNow()
- [ ] Sentinel errors use errors.String
- [ ] No temporary (XXX) code remaining for commits

## Enforcement
Non-compliant changes are rejected regardless of technical merit.
6 changes: 3 additions & 3 deletions .github/workflows/codeql-analysis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -64,10 +64,10 @@ jobs:
- name: Autobuild
uses: github/codeql-action/autobuild@f443b600d91635bebf5b0d9ebc620189c0d6fba5

# ℹ️ Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
# Info: Command-line programs to run using the OS shell.
# Docs: https://git.io/JvXDl

# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
# If the Autobuild fails above, remove it and uncomment the following three lines
# and modify them (or add more) to build your code if your project
# uses a compiled language

Expand Down
26 changes: 25 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,28 @@ complete. If the migration is revised, then the later parts can
be re-tried as long as the earlier parts are not modified. This
does not apply to `Compute()`ed migrations.

### PostgreSQL non-transactional (idempotent) DDL

Some PostgreSQL statements (e.g. `CREATE INDEX CONCURRENTLY`, `DROP
INDEX CONCURRENTLY`, `REFRESH MATERIALIZED VIEW CONCURRENTLY`)
cannot run inside an explicit transaction block. Libschema auto-detects
common patterns in `Script(...)` and will execute those outside a
transaction; you can also opt in explicitly by using a generic type
that is *not* a `*sql.Tx` (e.g. `Generate[*sql.DB]`). Non-transactional
migrations must be idempotent: safe to retry after partial failure.
A fuller rationale, supported patterns, and enum/value guidance
live in `lspostgres/NON_TRANSACTIONAL.md`.

### RepeatUntilNoOp (brief)

`RepeatUntilNoOp()` re-runs a single DML statement until
`RowsAffected()==0`. Use only for pure data updates where the driver
reports accurate counts. Avoid DDL, guarded `CREATE ... IF NOT
EXISTS`, concurrent index / materialized view commands, or
multi-statement scripts. For anything more complex, prefer a
`Computed` migration and loop explicitly. See the Go doc comment
on `RepeatUntilNoOp` for detailed guidance.

## Command line

The `OverrideOptions` can be added as command line flags that
Expand Down Expand Up @@ -291,9 +313,11 @@ will be clearly documented and will fail in a way that does not cause hidden pro
For example, switching from using "flag" to using OverrideOptions will trigger
an obvious breakage if you try to use a flag that no longer works.

Anticpated changes for the future:
Anticipated changes:

- API tweaks
- Support for additional databases
- Support for additional logging APIs
- Support for tracing spans (per migration)


136 changes: 119 additions & 17 deletions api.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,27 @@ import (
"context"
"database/sql"

"github.com/memsql/errors"

"github.com/muir/libschema/internal"
)

const (
// ErrDataAndDDL indicates a single migration mixes schema changes (DDL) and data
// manipulation (DML) where that combination is disallowed.
ErrDataAndDDL errors.String = "migration combines DDL (schema changes) and data manipulation"

// ErrNonIdempotentDDL indicates a non-transactional migration contains easily
// guardable DDL lacking an IF (NOT) EXISTS clause.
ErrNonIdempotentDDL errors.String = "unconditional migration has non-idempotent DDL"

"github.com/pkg/errors"
// ErrNonTxMultipleStatements indicates a non-transactional (idempotent) script migration
// attempted to execute multiple SQL statements when only one is allowed.
ErrNonTxMultipleStatements errors.String = "non-transactional migration has multiple statements"

// ErrNonIdempotentNonTx indicates a required-to-be-idempotent non-transactional migration
// failed idempotency validation heuristics.
ErrNonIdempotentNonTx errors.String = "non-idempotent non-transactional migration"
)

const DefaultTrackingTable = "libschema.migration_status"
Expand Down Expand Up @@ -46,14 +64,16 @@ type MigrationOption func(Migration)

// Migration defines a single database defintion update.
type MigrationBase struct {
Name MigrationName
async bool
rawAfter []MigrationName
order int // overall desired ordring across all libraries, ignores runAfter
status MigrationStatus
skipIf func() (bool, error)
skipRemainingIf func() (bool, error)
repeatUntilNoOp bool
Name MigrationName
async bool
rawAfter []MigrationName
order int // overall desired ordring across all libraries, ignores runAfter
status MigrationStatus
skipIf func() (bool, error)
skipRemainingIf func() (bool, error)
repeatUntilNoOp bool
nonTransactional bool // set automatically or by ForceNonTransactional / inference
forcedTx *bool // if not nil, explicitly chosen transactional mode (true=transactional, false=non-transactional)
}

func (m MigrationBase) Copy() MigrationBase {
Expand Down Expand Up @@ -190,16 +210,26 @@ func Asynchronous() MigrationOption {
}
}

// RepeatUntilNoOp marks a migration as potentially being needed to run multiple times.
// It will run over and over until the database reports that the migration
// modified no rows. This can useuflly be combined with Asychnronous.
// RepeatUntilNoOp marks a migration (Script or Generate) to be re-executed until
// the underlying driver reports zero rows affected. Use it for single, pure DML
// statements that progressively transform data (e.g. UPDATE batches that move a
// limited subset each run). It is NOT a generic looping primitive.
//
// Safety / correctness guidelines:
// - Single statement only - multi-statement scripts can give a meaningless final RowsAffected().
// - DML only - avoid DDL (CREATE/ALTER/DROP/REFRESH) or utility commands; most return 0 and will
// terminate immediately or loop uselessly.
// - Idempotent per batch - repeating the same statement after partial success must not corrupt data.
// - Do NOT mix with non-transactional Postgres DDL (concurrent index builds, REFRESH MATERIALIZED VIEW CONCURRENTLY).
// - If logic needs conditionals or multiple statements, write a Computed migration and loop explicitly.
//
// This marking only applies to Script() and Generated() migrations. The migration
// must be a single statement.
// Prefer a Computed migration when:
// - You need to run multiple statements per batch
// - You must inspect progress with custom queries
// - RowsAffected() is unreliable or driver-dependent
//
// For Computed() migrations, do not use RepeatUntilNoOp. Instead simply write the
// migration use Driver.DB() to get a database handle and use it to do many little
// transactions, each one modifying a few rows until there is no more work to do.
// Future note: libschema may emit debug warnings for obviously unreliable usages
// (e.g. DDL + RepeatUntilNoOp). Treat the above bullets as normative behavior now.
func RepeatUntilNoOp() MigrationOption {
return func(m Migration) {
m.Base().repeatUntilNoOp = true
Expand Down Expand Up @@ -247,6 +277,58 @@ func SkipRemainingIf(pred func() (bool, error)) MigrationOption {
}
}

// ForceNonTransactional forces a migration (Script, Generate, or Computed) to run without
// a wrapping transaction. By using this you assert the migration is idempotent (safe to retry).
// Overrides any automatic inference the driver would normally perform.
//
// Generate note: For drivers where generation always occurs inside a transaction context (e.g. Postgres
// Generate with *sql.Tx generator) forcing non-transactional only affects execution of the
// produced SQL, not the generator callback itself. Drivers will reject impossible combinations (e.g.
// attempting to force non-transactional on a generator that fundamentally requires *sql.Tx if they cannot
// safely downgrade).
func ForceNonTransactional() MigrationOption {
return func(m Migration) {
b := m.Base()
v := false // false means non-transactional
b.forcedTx = &v
Comment on lines +292 to +293
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should this use SetTransactional?

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, force applies later.

}
}

// ForceTransactional forces a migration to run inside a transaction even if automatic inference
// would choose non-transactional execution.
//
// Generate note: For generator-based migrations whose callback already receives *sql.Tx (Generate)
// this is effectively a no-op (they are already transactional). For generators that inherently execute
// outside a transaction (none exist in current public API) forcing transactional would be rejected.
//
// WARNING (foot-gun): On MySQL / SingleStore this DOES NOT make DDL atomic. Those engines
// autocommit each DDL statement regardless of any BEGIN you issue. By forcing transactional mode:
// - Earlier DML in the same migration may roll back while preceding DDL remains applied.
// - The bookkeeping UPDATE that records migration completion can fail while schema changes
// have already been partially or fully applied (leaving the migration marked incomplete and
// retried later against an already-changed schema).
// - Mixed DDL + DML ordering inside a forced transactional migration can produce inconsistent,
// misleading results during retries or partial failures.
//
// Use this only if you fully understand these semantics and prefer to retain a transactional wrapper
// for non-DDL statements. If you simply need safe DDL, prefer writing idempotent statements and let
// the driver downgrade automatically.
func ForceTransactional() MigrationOption {
return func(m Migration) {
b := m.Base()
v := true // true means transactional
b.forcedTx = &v
}
}

// ApplyForceOverride overrides transactionality for any prior force call (ForceTransactional
// or ForceNonTransactional)
func (m *MigrationBase) ApplyForceOverride() {
if m.forcedTx != nil { // forced override present
m.SetNonTransactional(!*m.forcedTx)
}
}

func (d *Database) DB() *sql.DB {
return d.db
}
Expand Down Expand Up @@ -290,6 +372,26 @@ func (m *MigrationBase) HasSkipIf() bool {
return m.skipIf != nil
}

// NonTransactional reports if the migration must not be wrapped in a transaction.
// This is automatically set for drivers (e.g. Postgres) that provide generic
// migration helpers which infer non-transactional status from the connection
// type used (e.g. *sql.DB vs *sql.Tx). A migration marked nonTransactional will
// be executed without an encompassing BEGIN/COMMIT; status recording still
// occurs within its own small transaction when supported.
func (m *MigrationBase) NonTransactional() bool {
return m.nonTransactional
}

func (m *MigrationBase) SetNonTransactional(v bool) {
m.nonTransactional = v
}

// ForcedTransactional reports if ForceTransactional() was explicitly called.
func (m *MigrationBase) ForcedTransactional() bool { return m.forcedTx != nil && *m.forcedTx }

// ForcedNonTransactional reports if ForceNonTransactional() was explicitly called.
func (m *MigrationBase) ForcedNonTransactional() bool { return m.forcedTx != nil && !*m.forcedTx }

func (n MigrationName) String() string {
return n.Library + ": " + n.Name
}
8 changes: 4 additions & 4 deletions apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,11 @@ import (
"log"
"os"

"github.com/muir/libschema/internal"

"github.com/hashicorp/go-multierror"
"github.com/memsql/errors"

"github.com/muir/libschema/dgorder"
"github.com/pkg/errors"
"github.com/muir/libschema/internal"
)

// Migrate runs pending migrations that have been registered as long as
Expand Down Expand Up @@ -75,7 +75,7 @@ func (s *Schema) Migrate(ctx context.Context) (err error) {
return err
}
}
return
return err
}

func (d *Database) prepare(ctx context.Context) error {
Expand Down
1 change: 0 additions & 1 deletion config.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,5 +51,4 @@ type OverrideOptions struct {
// import "flag"
// import "github.com/muir/nfigure"
// nfigure.MustExportToFlagSet(flag.CommandLine, "flag", &libschema.DefautOverrides)
//
var DefaultOverrides OverrideOptions
3 changes: 2 additions & 1 deletion dgorder/dependencies.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ package dgorder
import (
"container/heap"

"github.com/pkg/errors"
"github.com/memsql/errors"
)

type Node struct {
Expand Down Expand Up @@ -74,6 +74,7 @@ func (h *IntHeap) Push(x interface{}) {
// not just its contents.
*h = append(*h, x.(int))
}

func (h *IntHeap) Pop() interface{} {
old := *h
n := len(old)
Expand Down
7 changes: 4 additions & 3 deletions driver.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@ package libschema

import (
"database/sql"
"fmt"
"strings"

"github.com/memsql/errors"
)

// maps from DSN to driver name
Expand All @@ -22,7 +23,7 @@ func OpenAnyDB(dsn string) (*sql.DB, error) {
return sql.Open(driver, dsn)
}
}
return nil, fmt.Errorf("could not find database driver matching %s", wanted)
return nil, errors.Errorf("could not find database driver matching %s", wanted)
}
return nil, fmt.Errorf("could not find appropriate database driver for DSN")
return nil, errors.Errorf("could not find appropriate database driver for DSN")
}
6 changes: 3 additions & 3 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,18 @@ require (
github.com/go-sql-driver/mysql v1.9.3
github.com/hashicorp/go-multierror v1.1.1
github.com/lib/pq v1.10.9
github.com/memsql/errors v0.2.0
github.com/muir/sqltoken v0.1.0
github.com/muir/testinglogur v0.0.1
github.com/pkg/errors v0.9.1
github.com/stretchr/testify v1.11.1
)

require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/hashicorp/errwrap v1.0.0 // indirect
github.com/kr/pretty v0.1.0 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
Loading
Loading