Changelog

All notable changes to tanGO are recorded here. The format follows Keep a Changelog, and what each version number promises is described in Versioning and compatibility.

Unreleased

0.6.0 - 2026-10-21

Added

  • Admin operational dashboard (opt-in). admin.WithDashboard(admin.DashboardConfig{Status, Activity}) turns /admin/ into a page with database and migration status and recent request and job activity; without it, or with neither source set, the landing page is unchanged. The status comes from a function you supply (build it from tango.Status), and the activity from any observability.Snapshotter. A failing or panicking source marks only its own card "unavailable" and logs the cause; the error text never reaches the page. New in observability: Snapshotter and Snapshot (a read side that is separate from Recorder), NewWindow (an opt-in in-memory collector: the last 60 minutes in one-minute buckets, HTTP requests by status class and latency, job runs by outcome, nothing per route or job, reset on restart, labelled "this instance only"), and Multi (feed several recorders at once). It is a starting point for "is this instance healthy right now", not a metrics store; keep an external monitoring system for history, alerts and several instances.

  • MySQL 8.4 support. mysql://user:pass@host:3306/db in TANGO_DB_DSN selects the new db.MySQL dialect, with github.com/go-sql-driver/mysql as the driver. Migrations, Store, the admin, tango newproject --dialect=mysql and testdb (a fresh database per test; TANGO_TEST_DSN=mysql://…, testdb.MySQLOnly, testdb.NotMySQL) all work on it, and CI runs the suite on MySQL 8.4. Tables are InnoDB with the utf8mb4_bin collation; timestamps are DATETIME(6) in UTC; connections use READ COMMITTED unless the DSN sets another isolation level, and the default pool recycles a connection after five minutes. MariaDB is not supported. Differences to know: an unbounded string that is unique or indexed is VARCHAR(255) (a longer value returns a *db.ValueTooLongError), LIKE is case-sensitive, and a migration that fails part-way cannot roll back (the error names every applied step and writes no tracking row). See the setup guide. A project with a shell/main.go from before this release must copy the new databaseLabel from the shell guide before using MySQL, or tango shell prints the DSN's password.

  • Admin dark mode. Every admin page now follows the browser's light or dark preference, and signed-in staff can pick Light, Dark or System from the sidebar footer; the choice is kept in a tango_admin_theme cookie scoped to /admin/. The light theme is nearly unchanged: input, checkbox and secondary-button borders are a shade darker, and primary and danger buttons darken on hover, so they meet WCAG contrast. Colours are now semantic tokens (bg-surface, text-ink, border-line, the status family, and so on) generated from one palette table, with contrast tests over its pairs in both themes. Widgets that hard-code light colours stay light, so switch them to the token classes in the admin guide; the Markdown preview stays a white canvas in both themes. There is nothing to configure.

Changed

  • AccessLogger is free when its logger discards Info events. It used to wrap the response and build the event's attributes for every request and only then let the logger drop it (3 allocations a request). It now asks the logger first and calls the next handler directly. A logger that logs Info behaves as before; the level is read on every request, so a slog.LevelVar still takes effect at once.

  • Dependencies. go.mod now requires chi 5.5.0 (was 5.3.2), golang.org/x/crypto 0.58.0, x/sys 0.49.0, x/term 0.47.0 and modernc.org/sqlite 1.60.1, and adds github.com/go-sql-driver/mysql 1.10.1 for the MySQL dialect. A project that depends on tanGO is raised to at least these versions the next time it updates; run go mod tidy.

Documentation

  • Choosing MaxOpen. The configuration guide now explains how the size of the database pool shapes tail latency, with measured figures, and suggests starting near the database's CPU count.

0.5.0 - 2026-10-17

Added

  • OpenAPI documents. openapi.New(openapi.Options{...}) in InstalledApps serves an OpenAPI 3.1 document of every included route at /openapi.json (opt-in: nothing is served otherwise). openapi.Dev is the same app served only when TANGO_OPENAPI=1, so a deployed process exposes nothing unless it sets the variable. Each route is listed with its method, path, path parameters, name and a tag from its prefix. tango.Doc(tango.RouteDoc{...}) adds a summary, a request body, query parameters, responses and a bearer requirement, with schemas reflected from Go types (encoding/json tags, nested and recursive types, varchar=n as maxLength); a type can name its schema with SchemaName(). tango check fails and names a route whose documented types cannot be described. The accounts JSON endpoints document themselves; the admin's pages and the cookie-flow pages are hidden from the document. RouteRegistry.Described and tango.DescribedRoute expose the route table to tools. tango newproject now leaves a comment in project/project.go saying where to add it. See OpenAPI documentation.
  • tango openapi [-o file] prints or writes the document (the new -tango-openapi app-side flag, handled by DispatchFlags; DumpOpenAPI, OpenAPIProvider and ErrNoOpenAPI). The output is the same bytes on every run, and a failed run leaves an existing file untouched.
  • Swagger UI. openapi.Options{UI: swaggerui.New()} adds a browsable page at /docs/ from Swagger UI 5.17.14 embedded in the binary (Apache-2.0; no network request). It is the separate package openapi/swaggerui and adds about 1.7 MB to a binary that imports it.

Changed

  • Database connections are reused under load. database/sql keeps only 2 idle connections, so a server handling more than 2 requests at once closed and reopened a database connection on almost every request (measured: about 10,900 PostgreSQL sessions opened in one 15-second run of single-pet reads). ServeContext now raises the idle limit to 32 unless your app bounded the pool with SetMaxOpenConns. tango.WithDBPool(tango.DBPool{...}) sets the limits explicitly; tango.WithDBPool(tango.DBPool{}) restores the previous behavior. The number of open connections is unchanged (unlimited).

  • Foreign keys are not indexed unless you say so (guidance). A fk= column is not indexed by itself, so filtering or joining on it scans the table (measured: list on a 10,000-row table ran at about 1,300 requests a second without the index and about 3,200 with it). Write tango:"fk=Category,index" on foreign keys you filter or join on. Indexing foreign keys by default is planned for a later minor release; this entry is the notice.

Fixed

  • The admin no longer comes up silently empty. The admin builds its pages when it is installed, so a model registered by an app listed after admin.New never appeared in it. tango check (and -check) now fails and names those models, the tango newproject scaffold says to add apps above admin.New, and tango newapp repeats it when the project installs the admin. Move admin.New after your apps.

0.4.0 - 2026-10-10

Added

  • JSON fields. tango:"json" on a string field declares it holds JSON text (model.FieldMeta.JSON). Store.Create and Store.Update refuse text that is not valid JSON with a *db.InvalidJSONError that matches db.ErrInvalidJSON and never contains the submitted text; the empty string is unset, null is valid, and the stored text is exactly what was given. It is ordinary TEXT on SQLite and PostgreSQL, so adding the tag to an existing field generates no migration and does not scan existing rows; reads are not checked. json cannot be combined with varchar=n, text, pk, fk, unique or index. See ADR 0059.

  • JSON fields in the admin. A tango:"json" field gets admin.JSON() by default (Edit and Formatted tabs) with no Options.Widgets entry; an explicit entry still wins, and read-only and changelist rendering are unchanged. A save the store refuses for invalid JSON, including through a non-JSON widget such as admin.Textarea(), re-renders with a 422 and a translatable field error (admin.error.invalid_json, "Not valid JSON.") beside the field, keeping the text as typed. See admin registration.

  • Admin refinement. admin.JSON() edits a string field as JSON (syntax checked on save, the submitted text stored exactly, a browser-side Formatted tab). admin.Markdown(...) edits Markdown in a monospace textarea with a character count and, when you pass admin.WithMarkdownPreview, a sandboxed Preview tab from your own renderer: tanGO ships no parser or sanitizer, and you decide how HTML is sanitized (ADR 0058). Options.Filters adds boolean and foreign-key filters to the changelist (they combine with search and ordering; an over-large foreign-key filter still applies a primary key given in the URL). Options.Display with admin.DisplayFunc and admin.Display renders computed changelist cells, such as a file's size and an authorized download link, without admin knowing about storage; admin.ByteSize and admin.ShortHash help. admin.FieldError lets a widget report an error beside its own field. Forms now render HelpText and mark invalid fields with aria-invalid and aria-describedby; the changelist gets a labelled search box and filters, a caption and column scopes. Options.Labels now also names a field's changelist column when set. See admin registration and ADRs 0057 and 0058.

  • Bounded strings. tango:"varchar=n" declares a string field of at most n characters (1 to 10,485,760), and tango:"text" states an unbounded one explicitly; a bare string stays unbounded text and generates no migration. model.FieldMeta.MaxLength carries the limit and a malformed tag fails Register with model.ErrInvalidFieldTag. Store.Create and Store.Update refuse a longer value before any SQL with a *db.ValueTooLongError (db.ErrValueTooLong), counting runes the way PostgreSQL counts characters; SQLite does not enforce the length and raw SQL is not checked. tango makemigrations writes a VARCHAR(n) column (Column.Length, additive, with no CHECK), generates the widening changes (varchar(n) to text or a larger varchar) and the narrowing ones (text to varchar(n) or a smaller varchar), and a narrowing checks the existing data in Go first, failing with the number of rows and one offending key instead of truncating anything. A migration of narrowings is reversible; a widening makes its migration irreversible. The admin renders maxlength and shows a translatable per-field message (admin.error.too_long) for a refused save, and admin.FieldContext gains MaxLength. Built-in models are not retagged. See models and tags and changing a field's type.

  • Accounts for a single-page app. accounts.WithJSON mounts six bearer-only JSON endpoints under /accounts/api/ (register, login, password reset request and confirm, verify, resend verification) for a separate client, sharing the HTML flow's rules, limiters and outbox. The host supplies the token side through accounts.JSONAuth (Issue, Authenticate) and AuthGrant, so accounts imports no token package; JSON mode never reads or sets a cookie and has no logout. JSONConfig also takes the emailed link templates VerifyURL and ResetURL ({token} once; prefer a fragment), an OnRegister hook that runs inside the account's transaction with a Registration (a FieldError or *db.ValueTooLongError answers 422), and ResolveIdentifier so login can take a username. Requests are strict (415, 413, 400 invalid_body), errors are {"error", "code", "fields"?}, and every response is no-store. New db.Store.InTx runs a callback in one transaction (commit, rollback on error, rollback and re-panic on panic; nested calls join). See the accounts guide, the new examples/spa-accounts and ADRs 0050 and 0051.

  • Accounts answers invalid JSON as a field error. In the JSON accounts API, a *db.InvalidJSONError from a registration hook's tx.Create (alone or joined with errors.Join) answers 422 invalid_field, keyed by the Go field name with the message must be valid JSON, instead of a generic 500; the document is never echoed. See the accounts guide.

  • File uploads and storage. The new storage package is an explicit object store for uploaded files, used from ordinary Views: Put streams a file in, always sniffs its content type, enforces a required size cap and an optional allowed-types list, and returns a generated opaque key with the size and SHA-256; Stat, Open (a byte range) and Delete complete the Store. There is no FileField, no file model, no listing and no signed URLs: the host keeps the key and metadata in its own model and authorizes every access. storage.Upload reads one multipart file with bounded memory, and storage.Serve delivers an authorized object with ranges, conditional requests, attachment by default and nosniff. Backends: storage/local (atomic, os.Root-contained, for development and one host), storage/s3 (Amazon S3, Cloudflare R2 and other S3-compatible providers, in its own Go module so no cloud SDK reaches your go.mod), and storagetest with an in-memory Store and the conformance suite every Store passes. See the uploads guide, the new examples/uploads and ADRs 0052 to 0054. Native Google Cloud Storage is a later release.

  • Caching. The new cache package is a small, explicit cache for recomputable reads: a bytes-only cache.Store (Get, Set with a TTL of up to 30 days, Delete) where a miss is not an error, one portable key rule, cache.Prefix for namespaces and bulk invalidation by version, typed JSON helpers (GetJSON, SetJSON, ErrCorrupt), and the fail-open FetchJSON read-through that reports cache failures to an OnError hook and never fails the request because the cache is down. cache/local is a bounded in-process store (entry limit, optional byte budget, least-recently-used eviction); cache/redis (Redis and Valkey) and cache/memcache are their own Go modules, so no client library reaches your go.mod. cachetest.Run is the conformance suite every backend passes. There is no response, fragment or ORM caching, no automatic invalidation, and nothing logs. See the caching guide, the new examples/cache and ADRs 0055 and 0056.

Changed

  • An admin changelist ?page= beyond the last page now shows the last page, so the rows and the pager always describe the same page (before, it showed an empty list under a pager that named the last page).

  • The admin foreign key select now starts with a "— None —" choice (translation key admin.fk.none), and an unset foreign key shows an em dash in the changelist instead of 0. Saving "None" stores the same unset value (NULL) as before.

  • POST /admin/logout/ now requires the session's CSRF token when the request carries a session cookie, so a cross-site form cannot sign an admin out. A missing or wrong token gets 403 and leaves the session in place. A request without a session cookie still redirects to the login page.

  • The admin sidebar footer no longer says "Signed in over Basic Auth" (admin has used session logins since 0.3). It now shows "Signed in as " with a Log out button that posts to /admin/logout/ with the session's CSRF token. The translation key admin.layout.signed_in is replaced by admin.layout.signed_in_as and admin.layout.logout.

  • /accounts/login/ now always spends one password comparison, even for an unknown or inactive account, so its timing no longer says whether an account exists. Nothing else about the HTML flow changes.

0.3.1 - 2026-10-09

Fixed

  • db.Query with an Offset and no Limit failed on SQLite with a SQL syntax error (SQLite refuses OFFSET without LIMIT). List now skips the rows and returns the rest on both dialects. It also made tango shell's List(model, map[string]any{"offset": n}) fail the same way.

0.3.0 - 2026-10-09

Added

  • tango shell: a Go prompt on your project's models and database, with nothing served. It is a Yaegi interpreter (pinned at v0.16.1) run by the project's own shell/main.go through the new shell package, so the server never links it. Helpers Models, Describe, Get, List, Count, Create, Update and Delete work on any registered model addressed as app.Model, with plain-map queries and rows; -c EXPR and piped input make it scriptable; --readonly refuses writes; Options.Helpers adds your own functions as project.Name. At a terminal it has line editing, per-project history under your cache directory (TANGO_SHELL_HISTORY=off disables it), and Ctrl-C that clears the line, or ends a running evaluation with status 130. It says what Go the interpreter cannot run (min, max, clear, some generics, ranging over a function or an integer) and keeps the session through errors and panics. It runs your code with your project's credentials; it is not a sandbox. See the shell guide, ADR 0048. Adds github.com/traefik/yaegi to the root module; only a binary that imports shell links it.
  • admin.WithTrustedProxies and accounts.WithTrustedProxies: behind a reverse proxy, the failed-attempt limiters count the client address your proxy saw (the rightmost X-Forwarded-For entry outside the networks you name) instead of the proxy's own, so users no longer share one bucket. Opt-in: without it nothing is read from X-Forwarded-For and behaviour is unchanged, and only requests from the named networks may supply a forwarded address. The limiter also no longer keeps an entry for an address that has no recent failure. See behind a reverse proxy.
  • Running more than one instance: what is shared across instances (sessions, accounts, tokens), what is per process (the login limiter, ratelimit, realtime rooms, the accounts mail outbox and cooldown) and the workarounds, with ADR 0047 recording that shared state is not built yet and what would reopen it. No API changes.
  • A tango tui guide, with screens checked against the code by a test, and the promise about the dashboard in versioning and compatibility: the command, its terminal requirement, confirmation before database changes and delegation to the supported operations are covered; layout, wording and key bindings are not.

Changed

  • tango newproject writes two more directories. project/project.go holds func Config(store *db.Store) tango.Config: the installed apps, middleware and ordinary configuration that main.go used to build inline, so the server and the shell register the same apps from one list. main.go opens the database, builds the store and calls project.Config(store). shell/main.go is the program tango shell runs. tango newapp now says to install a new app in project/project.go when that file exists. Existing projects keep working as servers; to get a shell they follow the documented upgrade, a diff tested against the scaffold as it was before (ADR 0048).
  • The tutorial and the examples/board project list their apps in project/project.go; the board's token service and live feed are built inside project.Config, which stops the process with their error message when they cannot start.
  • tango admin create and tango admin resetpassword use TANGO_ADMIN_PASSWORD when it is set and non-empty, with no prompt. It was documented but never read. The value is never printed; the command names the variable instead.
  • tango migrate says what it did: applied 2 migrations: admin/0001_auto_…, blog/0002_auto_…, or no pending migrations, instead of always printing migrations applied. A project gets the new wording when it is built against this version; the rollback line is unchanged.
  • tango newproject ends with the real first-run order (tango makemigrations, tango migrate, tango admin create <username>), and the generated project prints admin: http://localhost:8000/admin/ after listening on (not with --no-admin). Existing projects are unchanged.
  • tango tui:
    • the greyed-out "Shell" item is gone;
    • "Apply pending migrations" and "Roll back the latest migration" grey out with a reason when status shows they cannot run (nothing to apply, nothing to roll back, or project status incomplete);
    • the confirmation prompts say what they will do.

Security

  • ratelimit.RemoteIPKey(trustedProxies...) could be made to give a client the key it chose. It took the leftmost X-Forwarded-For entry, which the client writes when the proxy appends to the header (nginx's default), and it did not check that the value was an IP address, so a client behind a trusted proxy could rotate its key to evade a limit or flood the limiter with new keys. It also fell back to X-Real-IP. It now reads X-Forwarded-For only from a trusted peer, parses every entry as an IP address, walks the list from the right skipping trusted proxies, and takes the first other address, which is the one your nearest trusted proxy saw. X-Real-IP is ignored, and a valid address is returned in canonical form. Unusable data falls back to the peer's address. The failed-login limiters of admin and accounts (admin.WithTrustedProxies, accounts.WithTrustedProxies) use the same resolver. This changes behaviour under the compatibility promise's security exception.
    • Upgrade: nothing changes without trusted proxies. With them, check that your proxy adds the address it sees to X-Forwarded-For (appending and replacing both work; passing the header through untouched does not). If you relied on X-Real-IP, make the proxy put the client address in X-Forwarded-For. Keys for IPv6 clients are now lower-case and compressed, and IPv4-mapped IPv6 addresses are keyed as IPv4, so a limit that was counted under the old spelling restarts once.

Fixed

  • tango admin create and resetpassword no longer show the password as you type it on a terminal. Piped input is read as before. This adds golang.org/x/term to the root module (ADR 0046).
  • Ctrl-C leaves tango tui even while a confirmation is showing; it used to be ignored until you answered.
  • Projects created by tango newproject now shut down gracefully: the generated main.go calls tango.ServeContext under signal.NotifyContext for Ctrl-C and SIGTERM, so in-flight requests finish and registered Lifecycle components stop. It called tango.Serve, which never reacts to a signal. Existing applications are unchanged; swap in ServeContext as the application lifecycle guide shows.
  • tango tui no longer ends the session when an apply or rollback fails. It shows Last action failed: … with the exit code and the command's last error line, refreshes the status, and returns to the menu; if the refresh also fails, both errors show.
  • Stopping the server started from tango tui with Ctrl-C or SIGTERM is a clean stop: tango waits for the server, prints server stopped and exits 0. It used to die with the signal. A server that fails, or exits with no signal, keeps its exit code.
  • tango tui explains a status failure (run tango check; an older main.go may need tango.DispatchFlags) before the raw error, which is kept.

0.2.0 - 2026-10-08

Changed

  • Projects created by tango newproject opt in to everything new in this release:

    • LoadConfigFromEnv(tango.WithPortFromEnv()), which replaces the hardcoded :8000 and honours TANGO_ADDR and PORT;
    • RequestID, Recoverer and AccessLogger;
    • a global MaxBodySize(1 << 20);
    • MiddlewareScopeAll.

    Existing applications keep their behaviour unless they opt in.

  • accounts models gain Account.EmailVerifiedAt and a new AccountToken model, for password reset and email verification. Every app that installs accounts must run tango makemigrations and apply the result with -migrate, whether or not it enables mail: the change only adds a column and a table, so it needs no --rename or --allow-drop. Existing accounts read as unverified; nothing is gated on it unless an app uses accounts.RequireVerified.

  • The route attribute is now the route's registered pattern in every log and metric for a request, trailing slash included (/items/{id}/), under both middleware scopes. The tango.http.access, tango.http.panic and tango.http.request_id_generation_failed logs used to drop a trailing slash (/items/{id}), while ctx.Logger(), tango.http.view_error and the HTTP metrics did not, so the same request reported two routes. Dashboards or alerts that match these three events on a trimmed route need the registered spelling. The attribute key is unchanged.

Added

  • A mail package for outgoing email: mail.Sender, Message with in-memory attachments (sent as multipart/mixed, 10 MiB of raw attachments by default), and three senders. NewSMTPSender/SMTPSenderFromEnv (TANGO_SMTP_URL) always encrypt: smtp:// requires STARTTLS, smtps:// is TLS from the start, and smtp+insecure:// is plaintext to a loopback host only, with an explicit port. Errors never contain credentials, recipients or messages. WriterSender is for development, and mail/mailtest is for tests. See ADR 0044 and the mail guide.
  • accounts.WithMail turns on password reset (/accounts/password-reset/) and email verification (/accounts/verify/), and accounts.RequireVerified guards a View on a verified email. Reset never reveals whether an address has an account, links are single-use, hashed, tied to the address they were sent to and built on a configured BaseURL, and GET never uses a link. Emails go through a bounded in-memory outbox, which logs tango.accounts.mail_failed and tango.accounts.mail_dropped. Without WithMail, accounts behaves as before. See ADR 0045.
  • Config.NotFound and Config.MethodNotAllowed Views replace the router's 404 and 405 responses (tanGO sets Allow for a 405). The Allow header lists every method routed at that path, including a non-standard one the app registered. Under MiddlewareScopeAll they run inside global middleware and are observed; under MiddlewareScopeRoutes they run outside it.
  • Config.MiddlewareScope: with tango.MiddlewareScopeAll, global middleware wraps the whole router, so Unmatched requests (the router's 404 and 405) are logged, counted, recovered and body-limited, reported with route "(unmatched)". Global middleware then runs before routing, so it can't read route parameters or rewrite which route answers. The default stays MiddlewareScopeRoutes, today's behaviour, and may become MiddlewareScopeAll only after a minor release of notice; set the scope explicitly to keep yours. See ADR 0043.
  • tango.LoadConfigFromEnv(tango.WithPortFromEnv()) falls back to the PORT variable hosting platforms set: the address is TANGO_ADDR, else ":"+PORT, else :8000. Without the option, LoadConfigFromEnv is unchanged.
  • tango.MaxBodySize(n) middleware caps request bodies: an oversized body gets 413 with {"error":"request body too large"}. When several limits apply, the most restrictive wins.

Security

  • ServeContext (and Serve) now drop a client that takes more than 10 seconds to send a request's headers, so a slow-header (Slowloris-style) client can't hold connections open indefinitely. This changes default behaviour under the compatibility promise's security exception. tango.WithReadHeaderTimeout(d) sets another bound, and WithReadHeaderTimeout(0) restores the old, unbounded behaviour.

Fixed

  • tango newapp now prints how to install the new app: the import path read from go.mod and the InstalledApps entry to add to main.go. A new app used to be created silently and answer 404 until wired in.
  • The compatibility page promised a JSON body for every 403, 405 and 429 from /accounts/*. It now promises only the JSON accounts sends, for a rejected CSRF token (403) and rate limiting (429, with Retry-After). Closed registration's 403 is an HTML page, and the router's 405 has no body. A test pins that contract.

0.1.0 - 2026-10-08

Changed

  • Breaking: the database is chosen by TANGO_DB_DSN's scheme alone, and TANGO_DB_DIALECT is retired: setting it is now an error. tango.LoadDBDSNFromEnv and tango.LoadDBDialectFromEnv are replaced by tango.LoadDBConfigFromEnv, which returns a db.DSN carrying the dialect, driver name and connection string together.
    • Upgrade: put the scheme in the DSN (TANGO_DB_DSN=postgres://…, or sqlite://app.db; unset still means sqlite://app.db), drop TANGO_DB_DIALECT, and replace the two loader calls with dsn, err := tango.LoadDBConfigFromEnv() followed by sql.Open(dsn.Driver, dsn.Source) and dsn.Dialect. Projects generated by tango newproject from this release on already do this.
  • Breaking: tango makemigrations refuses to write a migration that drops a table or column unless each drop is authorised with --allow-drop app.Model or --allow-drop app.Model.Field, and refuses model changes no migration can express (a narrowing type change, a change of primary key or of a foreign key's target). A refused run writes nothing and prints the commands to choose between.
    • Upgrade: if a run is refused, declare a rename with --rename or authorise the drop with --allow-drop, as the error message shows.
  • Breaking: migration.Diff and migration.DiffModels return an error alongside the migrations. These are part of the CLI-internal migration surface, which applications don't normally call.
    • Upgrade: handle the second return value.
  • SQLite writers wait up to five seconds for a held lock instead of failing at once with database is locked.
  • Breaking: realtime/websocket.View gained a variadic ...ViewOption parameter. Calls compile unchanged, but code that stores View in a variable of its old function type no longer compiles.
    • Upgrade: add ...websocket.ViewOption to that variable's function type, or call View directly.

Added

  • db.ParseDSN, which parses a scheme-qualified DSN into a db.DSN.
  • Renaming a field or a model with tango makemigrations --rename, which keeps the column or table and its rows.
  • Widening type changes (integer to real or text, real to text, boolean to integer or text) through the new AlterColumnType step.
  • The public testdb package: an app's own tests get a fresh database for the run's Test dialect, chosen by TANGO_TEST_DSN.
  • examples/board, the tutorial's app, tested end to end on SQLite and PostgreSQL.
  • realtime/websocket.View takes options: WithOriginPatterns and WithInsecureSkipVerify configure the upgrade's origin check.

Fixed

  • Dropping a column on SQLite keeps everything else about the table: rows, indexes, unique constraints, foreign keys in both directions, defaults, NOT NULL and the primary key.
  • NULL reads as the zero value, and an unset foreign key is written as NULL.
  • Cookies are marked Secure behind a TLS-terminating proxy that sets X-Forwarded-Proto.
  • Application sessions work on PostgreSQL.
  • Times read back in UTC on every dialect.
  • Generated migrations order their steps, and migrations across apps, by foreign key, so they apply on PostgreSQL.
  • Migration files generated by v0.0.1 and v0.0.2 apply and roll back on PostgreSQL even when a table references one whose name sorts after it: the runner now creates referenced tables first and drops referencing tables first within a migration.
  • Cascade delete handles circular foreign key references.
  • Every SQL identifier tanGO generates is quoted, so a model named after a reserved word (User, Order) works on both dialects.
  • Creating a row with an explicit ID on PostgreSQL advances the ID sequence.
  • tango newproject keeps its standard-library imports in one sorted group.

0.0.2 - 2026-09-23

The v0.0.2 tag on GitHub was later moved to a commit adding realtime/websocket.View options; the Go module proxy, and so every go get, serves the original commit (4fd6667), which this section and its comparison link describe. Those options are listed under 0.1.0.

Added

  • The observability package, with a Recorder interface for metrics and stable event and metric names.
  • Structured HTTP logging: the RequestID, Recoverer and AccessLogger middleware, and HTTP duration metrics.
  • Background jobs: registration, scheduling and structured failure reporting.
  • ServeContext, with lifecycle-aware graceful shutdown, and Lifecycle registration on Registry.
  • The ratelimit package: a token-bucket Limiter, KeyFunc, RemoteIPKey and Middleware.
  • realtime Hub operations report to the shared Recorder.

Changed

  • Serve delegates to ServeContext.

Fixed

  • Every Lifecycle.Stop call shares one stop-phase budget.
  • RemoteIPKey handles nil trusted CIDRs.
  • ratelimit.Limiter.Take rejects costs a bucket can never hold.
  • Limiter cleans up idle buckets without overflowing.

0.0.1 - 2026-09-21

Added

  • The first public release: the tango package (apps, models, routing with reverse lookup, Views, middleware, checks), db.Store on SQLite and PostgreSQL, schema migrations generated by tango makemigrations, and the tango CLI (newproject, newapp, run, check, migrate, tui, admin).
  • The admin app: a default theme, session-cookie login with CSRF protection and rate-limited attempts, staff and superuser tiers, foreign key selects, and best-effort widgets and branding.
  • Many-to-one relationships, cascade delete, and bounded Where/Any/Count query filtering.
  • Reusable apps that ship their own migrations and static assets.
  • The auth package for application sessions, the accounts app for registration, login and logout, and auth/jwt for HS256 bearer tokens.
  • The i18n package.
  • The realtime package, with single-owner rooms and generation-safe timers, and its realtime/websocket adapter.

View this page's source on GitHub