Skip to main content
Version: 1.11.0

Operational Workflows

Quark ships a quark binary for the operational tasks around your app: migrations, schema inspection, model generation, seeding, and multi-tenant jobs.

go install github.com/jcsvwinston/quark/cmd/quark@latest
SubcommandWhat it does
initScaffold a project (config + layout) for a chosen dialect.
migratecreate / up / down / status / version for the versioned migrator.
model generateGenerate a model struct from a table (--from-table) or an inline spec (--fields).
inspectschema / table <name> / sql introspection.
syncCheck the connection and print how to wire client.Sync(...) — the diff/apply itself needs your compiled model types, so it runs in your app, not the CLI.
seedcreate / run / list seed scripts.
tenantprovision / migrate / list / migrate-all for multi-tenant databases.
validateValidate a model's tags against the live schema.
genGenerate typed accessors / binders (see Code Generation).

The CLI reads its connection from a .quark.yml config (created by quark init) or from QUARK_DATABASE_DEFAULT_DRIVER / QUARK_DATABASE_DEFAULT_DSN. When a key is missing the error names the exact keys, the env vars, and quark init, so you are never left guessing what to set.

Starting a project

quark init --dialect postgresql

init scaffolds the layout (models/, migrations/, seeders/), a .quark.yml, and the small runner your project needs (cmd/<app>/main.go, described below). If the directory is not already inside a Go module it also writes a go.mod so the runner's imports resolve — pass --module your/path to choose the import path, or edit the generated go.mod afterwards. When the directory is already inside a module, init leaves it untouched and derives the import path from it.

It finishes by printing the real next steps, in order:

Next steps:
1. Edit go.mod if you want a different module path.
2. go get github.com/jcsvwinston/quark@latest
3. quark migrate create initial_schema --from-models ./models --dialect postgresql
4. go run ./cmd/<app> migrate up

The runner is what runs migrations and seeders — the standalone quark binary cannot (see below).

Migrations from the CLI

quark migrate create add_users_table # scaffold a new migration file
quark migrate up # apply pending migrations
quark migrate status # show applied vs pending
quark migrate down # roll back the last migration

migrate create can also write the migration body for you. Point it at the package that holds your model structs and name the dialect, and it renders domain DDL through the same type mapping the runtime migrator uses:

quark migrate create domain_schema --from-models ./models --dialect postgresql

Tables come out in dependency order, parents before children. Each carries the NOT NULL, UNIQUE, and size constraints your tags declare, plus FOREIGN KEY clauses and indexes for every rel:"belongs_to" / join: pair, and the Down drops them in reverse order.

--dialect accepts postgresql|mysql|mariadb|sqlite|mssql|oracle and defaults to the configured database.default.driver. The result is a regular migration file — review and edit it like any other.

The standalone binary cannot see your migrations

What happens. migrate up / migrate down and seed run exit non-zero with an explanation when the registry is empty, instead of reporting a misleading "No pending migrations".

Why. Migration files register themselves in an init(), which only runs when their package is compiled into the running binary. A go installed quark never imports your project's migrations/ package, so it genuinely has nothing to apply.

What to do. Build the two-line runner shown in Embedding the same operations in your own binary. Your main imports the migrations package for its side effects and calls commands.Main(), which prints errors to stderr and exits non-zero on failure. Don't call a bare commands.Execute() from main: it returns the error instead of printing it, so every failure becomes a silent exit 0.

Generating models from an existing database

model generate is database-first or spec-first. From existing tables, it introspects the database and writes one struct per table:

quark model generate --from-table users,orders --out ./models --package models

Nullable columns become pointers, JSON/JSONB becomes json.RawMessage, and timestamp columns become time.Time. Treat the output as a starting point — review it before adding rich types, relations, soft-delete, or optimistic locking. Or define a model inline, no database needed:

quark model generate Product --fields "id:int64,name:string,price:float64" --out ./models

The --fields grammar reaches the rich half of the ORM, not just scalars:

  • A third segment adds a tag tokensku:string:unique, version:int64:version, title:string:not_null.
  • Generic specs produce container types and relationsbio:nullable<string>, tags:array<string>, attrs:json<Attrs>, author:belongs_to<User>. A belongs_to emits both the author_id column and the relation field pair.

The referenced types (Attrs, User above) stay yours to define.

Flags: --from-table, --fields, --out (default ./models), --package (default models), --dialect, --tags (default json). The --out directory is created if missing, and a malformed spec or unwritable output exits non-zero.

Inspecting and validating

quark inspect schema # list tables and columns
quark inspect table users # describe one table
quark validate users --models ./models # compare the User struct against the users table

validate compares one Go struct against the live table and reports the mapping in both directions:

  • A Go field whose column is missing in the database always exits non-zero. That is drift you have to fix.
  • A database column with no Go field exits non-zero only under --strict.

It loads your structs with go/packages. --models defaults to paths.models from .quark.yml, falling back to ./..., and the table is matched to a model by pluralized snake_case name unless you name it with --model User.

inspect table and model generate --from-table also exit non-zero when the table doesn't exist, rather than printing an empty report.

inspect schema and inspect table take --format table|json|yaml for machine-readable output. Schema sync (auto-migration) is a programmatic API — it needs your compiled model types, so it can't run from a standalone CLI. quark sync just checks the connection and prints the call to embed:

// DryRun previews the SQL without applying it.
err := client.Sync(ctx, quark.SyncOptions{DryRun: true}, &User{}, &Order{})

Embedding the same operations in your own binary

To reuse the CLI's own command tree (migrate, seed, tenant, …) with your project's migrations compiled in, you need a small runner in your project — and quark init already writes it for you (cmd/<app>/main.go, plus the migrations/seeders doc stubs it imports). Written out, the whole runner is:

// cmd/quark/main.go (in YOUR project)
package main

import (
_ "github.com/you/yourapp/migrations" // side-effect: registers migrations
_ "github.com/you/yourapp/seeders" // side-effect: registers seeders

"github.com/jcsvwinston/quark/cmd/quark/commands"
)

func main() { commands.Main() }

Both migrations and seeders register themselves from an init(), so blank-importing their packages is all the runner needs. quark migrate create and quark seed create write files that already do this — a scaffolded seeder is:

package seeders

import (
"context"

"github.com/jcsvwinston/quark"
"github.com/jcsvwinston/quark/seed"
)

func init() { seed.Register("demo_users", SeedDemoUsers) }

func SeedDemoUsers(ctx context.Context, client *quark.Client) error {
// ...
return nil
}

commands.Main executes the root command, prints any error to stderr and exits 1 — the same contract as the standalone binary, which is what CI gates need. commands.Execute remains available for mains that deliberately take over error handling; it returns the error and prints nothing, so never call it bare.

Two sibling runner libraries follow the same embed pattern for flows the standalone binary cannot know about (they need your registered models):

  • quarkmigrate — plan/verify/apply schema against the live database (exit 1 = drift, 2 = operational error).
  • quarktenantinstall-rls-policies renders and applies the native row-level-security DDL for every registered model, and verify-rls-policies is the boot/CI gate that fails (exit 1) when any table is left unenforced — including a policy that carries the right name while its predicate isolates nothing — see Native row-level security.

When you'd rather run these from your own service or CI binary, call the public APIs directly. A migration runner imports your migration package for its side effects and drives the versioned migrator:

// cmd/migrate/main.go
limits := quark.DefaultLimits()
limits.AllowRawQueries = true

client, err := quark.New("pgx", dsn, quark.WithLimits(limits))
if err != nil {
log.Fatal(err)
}
defer client.Close()

migrator := migrate.NewMigrator(client)
switch {
case dryRun:
err = migrator.UpDryRun(ctx, 0)
case down > 0:
err = migrator.Down(ctx, down)
default:
err = migrator.Up(ctx, 0)
}

For a dev/test schema bootstrap, call Migrate directly (it reflects the current model shape and creates missing tables — different from versioned migrations, which record exactly which DDL ran):

err := client.Migrate(ctx, &User{}, &Order{}, &Product{})

For database-per-tenant, quark tenant migrate <id> / migrate-all resolve each tenant's DSN from tenant.dsn_template in .quark.yml — the tenant id replaces the {tenant} placeholder:

tenant:
strategy: db_per_tenant
dsn_template: postgres://user:pass@localhost/{tenant}?sslmode=disable

Three behaviours are worth knowing before you wire this into a deploy job:

  • No dsn_template means the command fails, rather than guessing and migrating the default database under a tenant's name.
  • schema_per_tenant migrations can't be resolved from static config, so they exit with an explicit error. Run those from your own binary.
  • tenant provision under schema_per_tenant stops at the schema plus the registry row. It says so and exits 0 — that is the complete effect. It also refuses an id already registered in quark_tenants, so a retry never crashes on a duplicate CREATE SCHEMA.

In code the same loop stays explicit — resolve each tenant's DSN, build a client, run the same migrator:

for _, tenant := range tenants {
client, err := clientForTenant(tenant)
if err != nil {
return err
}
if err := migrate.NewMigrator(client).Up(ctx, 0); err != nil {
_ = client.Close()
return fmt.Errorf("tenant %s: %w", tenant.ID, err)
}
_ = client.Close()
}

(TenantRouter uses tenant IDs as schema names at query time but doesn't orchestrate schema provisioning — see Multi-Tenant.) Commit these commands with your app and run them from CI or deployment jobs.