Repository navigation
Releases: angvp/tango
Release list
v0.7.0
Added
-
Sequenced realtime frames. A
Logicthat also implementsrealtime.Framerframes every payload admitted to each member of its room with a per-connection sequence: 1 for the join snapshot, then 2, 3 and so on, restarting at 1 on reconnect. The host owns the wire format.Joinfails withrealtime.ErrEmptyFrame(or the wrappedFrameerror) when the framed snapshot is empty or fails, and the failure does not close the existing connection or admit the new peer. Admitted sends fromSnapshotkeep their sequence and are not rolled back; a send whoseFrameerrors or overflows the queue closes the existing connection, which stays closed. ALogicthat does not implementFrameris unchanged. See the realtime guide. -
Google Cloud Storage adapter (experimental, separate module).
storage/gcsis astorage.Storeon Google Cloud Storage, in its own Go module so no cloud SDK reaches a project that does not import it.gcs.Newauthenticates with application-default credentials, workload identity or explicit credentials, andgcs.NewFromClienttakes a client the host built. Writes are create-only and atomic, and a retried write that already committed is confirmed by a per-write token, checksum and size rather than reported as a collision. Experimental: tested against an in-process emulator, not yet against a real bucket. The module is versioned and tagged separately asstorage/gcs/vA.B.C, after the rootv0.7.0tag it requires. See the uploads guide. -
Admin bulk delete.
admin.Options.BulkDeletelets a staff account delete the checked rows of one changelist page after a confirmation dialog. The delete is one all-or-nothing transaction that cascades like a single-row delete, an invalid selection (empty, over a page, a wrongcount, a repeated or non-canonical key) is rejected whole, and each committed delete logs oneadmin.EventBulkDelete(tango.admin.bulk_delete) event withmodel,selected_count(the rows selected, not the database rows a cascade deleted) andusername. It is off by default, needs JavaScript and never spans more than a page. See admin registration. -
Grouped filters.
Query.Filteris an optional boolean tree ANDed withWhereandAny, built withdb.Cond,db.And,db.Oranddb.Not(theExprandExprOptypes). AQuerywithout aFilterproduces exactly the SQL it did before. A filter holds at mostMaxFilterNodes(100) nodes andMaxFilterDepth(8) levels. -
IN,IS NULLandIS NOT NULL.OpInwithdb.In(field, slice)(a nil or empty list matches nothing on every dialect; at mostMaxInValues, 1,000, values per list andMaxQueryArgs, 10,000, bind values per query; a zerotime.Timeis rejected), andOpIsNull/OpIsNotNullwithdb.IsNull/db.IsNotNull, which take no value. All three work inWhere,AnyandFilter. A foreign key left at zero is stored asNULL, soOwnerID = 0still never matches (unchanged); useIsNullfor unset keys. -
Store.FirstandStore.Exists.Firstreads one row (primary-key order unlessOrderByis set,ErrNotFoundnaming the model when none, the destination untouched on any error);Existsreports whether any row matches. They validate and build SQL exactly asListandCountdo. -
Typed query errors.
ErrInvalidQuery,ErrQueryLimit,*QueryErrorand*QueryLimitError. Every malformed query is refused before any SQL runs. The existingWhere/Anyvalidation errors keep their text and now also matchErrInvalidQuery. -
Locale resolvers.
i18n.Resolver(a locale source that also names the request headers it depends on), the helpersFromQuery,FromCookieandFromHost,Chain(the host's order, thenAccept-Language),FuncResolverfor a plain function, andMiddlewareResolverto install one. Each helper returns only a locale with a registered catalog and none sets a cookie.MiddlewareandDefaultLocaleResolverare unchanged. -
i18n/i18ntest.Isolate. Test support:i18ntest.Isolate(t)gives a test an empty i18n state (catalogs, theOnMissinghook and its once-state, the observed keys) and restores the previous state int.Cleanup. A test that uses it must not be parallel. -
i18n.Keysandi18n.Validate.Trecords each (key, fallback) pair the first time it runs with a resolved locale (nothing is recorded, and nothing allocated, when no locale is set).Keys()lists them;Validate()checks every catalog against them and returns aReportwithProblems(ProblemMismatch,ProblemEmpty,ProblemConflictingFallbackwhen a key is seen with two fallbacks,ProblemRegistryFull) and the advisory, sortedUnobservedcatalog keys. It covers only what has run in this process, and the registry holds at most 4,096 keys, so fallbacks must be stable literals. -
i18n.OnMissing. A development hook that reports a missing translation (an absent key, an empty value or a value whose format verbs do not match the fallback's) with the resolved locale and key, at most once per pair. It isnilby default, runs outside the package's locks, andOnMissing(nil)clears it. -
The admin's remaining English is translatable.
<html lang>follows the selected catalog on every admin page and on the Markdown preview document (enwhen none is selected). The Markdown and JSON widgets' tab labels, buttons, character count,aria-labels, iframe title, script messages (Nothing to format.,Preview failed.and the rest, delivered asdata-msg-*attributes only when a locale was resolved), and the JSON validation errors now useadmin.*keys with the current English as fallback. An app without i18n renders the same output as before. A test keeps new literal text out of admin's templates. -
Translatable accounts pages. The register, login, password-reset and email-verification pages and their form errors (including the generic login error, which stays one message for every failure) go through
i18n.Tunderaccounts.*keys with the current English as fallback. A page declares<html lang>only when a catalog was selected, so an app without i18n renders exactly what it did before. The JSON API'serrortext stays English; clients branch oncode. -
Accounts emails in the request's language. The verification and reset emails are rendered in the locale of the request that queued them (
accounts.email.*keys, English fallback). The locale is captured when the email is queued, before any account lookup; the queued job keeps nothing else of the request. A translated body that lacks the%slink placeholder falls back to the English body, so the link is never dropped. -
The
tango newprojectscaffold points at the i18n guide. The generated middleware list carries a two-line comment saying how to opt in (i18n.Middleware(nil)and catalogs). The generated project is otherwise unchanged. -
examples/api-with-adminin Spanish. The example serves the admin in Spanish end to end: a completeescatalog, a smalles-MXoverride, a?lang=→ cookie →Accept-Languageorder and a language switcher, with a test that walks the pages in both locales and checksOnMissingandValidate. tanGO itself still ships no catalogs. -
examples/accounts-mailin Spanish. Registering, confirming an address and resetting a password run in Spanish, emails and links included, with a test that walks the flow and fails on any missing key.
Changed
-
The i18n middleware sets
VaryandContent-Language. Withi18n.Middleware(nil)every response getsVary: Accept-Language, so a cache in front of the app no longer serves one language to another visitor;MiddlewareResolver(Chain(...))sends the tokens its resolvers need (CookieforFromCookie).Content-Languagenames the selected catalog's locale, and only when a catalog was selected. ExistingVaryvalues, including ones a downstream handler adds, are merged without repeating a token (ignoring case). A resolver passed as a plain function adds noVary(useFuncResolverto declare one). Nothing changes for an app without the middleware. -
A bad catalog value falls back instead of printing garbage.
i18n.Tnow ignores a catalog value that is empty or whose format verbs differ from the fallback's (positional verbs such as%[2]scount, so reordering arguments is still fine), tries the base language, and then shows the fallback. Before,"Nuevo"for"New %s"renderedNuevo%!(EXTRA string=Pet)and an empty value rendered nothing. A literal%in a fallback must be%%once arguments are passed. -
Upgrade note: literal
%in i18n fallbacks. The fallback is a format string whenever arguments are passed, so a bare%is read as a format verb and must be escaped. Changei18n.T(ctx, "sale.label", "Save 10% on %s", item)toi18n.T(ctx, "sale.label", "Save 10%% on %s", item). Search the project'si18n.Tcalls and catalogs for%followed by a letter or space before relying on the new fallback rule.
Fixed
- Locale matching ignores case and the
-/_separator.Accept-Language: es-mx,ES-MXandes_MXnow select a catalog registered ases-MX(before,es-mxskipped the regional catalog andES-MXmatched nothing, not evenes). The locale the middleware stores is the catalog's own spelling, and catalogs registered under two spellings of one locale are merged.
v0.6.0
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 fromtango.Status), and the activity from anyobservability.Snapshotter. A failing or panicking source marks only its own card "unavailable" and logs the cause; the error text never reaches the page. New inobservability:SnapshotterandSnapshot(a read side that is separate fromRecorder),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"), andMulti(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/dbinTANGO_DB_DSNselects the newdb.MySQLdialect, withgithub.com/go-sql-driver/mysqlas the driver. Migrations,Store, the admin,tango newproject --dialect=mysqlandtestdb(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 theutf8mb4_bincollation; timestamps areDATETIME(6)in UTC; connections useREAD COMMITTEDunless the DSN sets another isolation level, and the default pool recycles a connection after five minutes. MariaDB is not supported. Differences to know: an unboundedstringthat isuniqueorindexed isVARCHAR(255)(a longer value returns a*db.ValueTooLongError),LIKEis 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 ashell/main.gofrom before this release must copy the newdatabaseLabelfrom the shell guide before using MySQL, ortango shellprints 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_themecookie 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
-
AccessLoggeris 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 aslog.LevelVarstill takes effect at once. -
Dependencies.
go.modnow requires chi 5.5.0 (was 5.3.2),golang.org/x/crypto0.58.0,x/sys0.49.0,x/term0.47.0 andmodernc.org/sqlite1.60.1, and addsgithub.com/go-sql-driver/mysql1.10.1 for the MySQL dialect. A project that depends on tanGO is raised to at least these versions the next time it updates; rungo 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.
v0.5.0
Added
- OpenAPI documents.
openapi.New(openapi.Options{...})inInstalledAppsserves an OpenAPI 3.1 document of every included route at/openapi.json(opt-in: nothing is served otherwise).openapi.Devis the same app served only whenTANGO_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/jsontags, nested and recursive types,varchar=nasmaxLength); a type can name its schema withSchemaName().tango checkfails and names a route whose documented types cannot be described. TheaccountsJSON endpoints document themselves; the admin's pages and the cookie-flow pages are hidden from the document.RouteRegistry.Describedandtango.DescribedRouteexpose the route table to tools.tango newprojectnow leaves a comment inproject/project.gosaying where to add it. See OpenAPI documentation. tango openapi [-o file]prints or writes the document (the new-tango-openapiapp-side flag, handled byDispatchFlags;DumpOpenAPI,OpenAPIProviderandErrNoOpenAPI). 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 packageopenapi/swaggeruiand adds about 1.7 MB to a binary that imports it.
Changed
-
Database connections are reused under load.
database/sqlkeeps 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).ServeContextnow raises the idle limit to 32 unless your app bounded the pool withSetMaxOpenConns.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:liston a 10,000-row table ran at about 1,300 requests a second without the index and about 3,200 with it). Writetango:"fk=Category,index"on foreign keys you filter or join on. Indexing foreign keys by default, as Django does, 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.Newnever appeared in it.tango check(and-check) now fails and names those models, thetango newprojectscaffold says to add apps aboveadmin.New, andtango newapprepeats it when the project installs the admin. Moveadmin.Newafter your apps.
v0.4.0
Added
-
JSON fields.
tango:"json"on astringfield declares it holds JSON text (model.FieldMeta.JSON).Store.CreateandStore.Updaterefuse text that is not valid JSON with a*db.InvalidJSONErrorthat matchesdb.ErrInvalidJSONand never contains the submitted text; the empty string is unset,nullis valid, and the stored text is exactly what was given. It is ordinaryTEXTon SQLite and PostgreSQL, so adding the tag to an existing field generates no migration and does not scan existing rows; reads are not checked.jsoncannot be combined withvarchar=n,text,pk,fk,uniqueorindex. See ADR 0059. -
JSON fields in the admin. A
tango:"json"field getsadmin.JSON()by default (Edit and Formatted tabs) with noOptions.Widgetsentry; 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 asadmin.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 passadmin.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.Filtersadds 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.Displaywithadmin.DisplayFuncandadmin.Displayrenders computed changelist cells, such as a file's size and an authorized download link, without admin knowing about storage;admin.ByteSizeandadmin.ShortHashhelp.admin.FieldErrorlets a widget report an error beside its own field. Forms now renderHelpTextand mark invalid fields witharia-invalidandaria-describedby; the changelist gets a labelled search box and filters, a caption and column scopes.Options.Labelsnow also names a field's changelist column when set. See admin registration and ADRs 0057 and 0058. -
Bounded strings.
tango:"varchar=n"declares astringfield of at mostncharacters (1 to 10,485,760), andtango:"text"states an unbounded one explicitly; a barestringstays unbounded text and generates no migration.model.FieldMeta.MaxLengthcarries the limit and a malformed tag failsRegisterwithmodel.ErrInvalidFieldTag.Store.CreateandStore.Updaterefuse 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 makemigrationswrites aVARCHAR(n)column (Column.Length, additive, with noCHECK), generates the widening changes (varchar(n)totextor a largervarchar) and the narrowing ones (texttovarchar(n)or a smallervarchar), 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 rendersmaxlengthand shows a translatable per-field message (admin.error.too_long) for a refused save, andadmin.FieldContextgainsMaxLength. Built-in models are not retagged. See models and tags and changing a field's type. -
Accounts for a single-page app.
accounts.WithJSONmounts 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 throughaccounts.JSONAuth(Issue,Authenticate) andAuthGrant, soaccountsimports no token package; JSON mode never reads or sets a cookie and has no logout.JSONConfigalso takes the emailed link templatesVerifyURLandResetURL({token}once; prefer a fragment), anOnRegisterhook that runs inside the account's transaction with aRegistration(aFieldErroror*db.ValueTooLongErroranswers422), andResolveIdentifierso login can take a username. Requests are strict (415,413,400 invalid_body), errors are{"error", "code", "fields"?}, and every response isno-store. Newdb.Store.InTxruns a callback in one transaction (commit, rollback on error, rollback and re-panic on panic; nested calls join). See the accounts guide, the newexamples/spa-accountsand ADRs 0050 and 0051. -
Accounts answers invalid JSON as a field error. In the JSON accounts API, a
*db.InvalidJSONErrorfrom a registration hook'stx.Create(alone or joined witherrors.Join) answers422 invalid_field, keyed by the Go field name with the messagemust be valid JSON, instead of a generic500; the document is never echoed. See the accounts guide. -
File uploads and storage. The new
storagepackage is an explicit object store for uploaded files, used from ordinary Views:Putstreams 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) andDeletecomplete theStore. There is noFileField, 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.Uploadreads one multipart file with bounded memory, andstorage.Servedelivers an authorized object with ranges, conditional requests, attachment by default andnosniff. 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 yourgo.mod), andstoragetestwith an in-memoryStoreand the conformance suite everyStorepasses. See the uploads guide, the newexamples/uploadsand ADRs 0052 to 0054. Native Google Cloud Storage is a later release. -
Caching. The new
cachepackage is a small, explicit cache for recomputable reads: a bytes-onlycache.Store(Get,Setwith a TTL of up to 30 days,Delete) where a miss is not an error, one portable key rule,cache.Prefixfor namespaces and bulk invalidation by version, typed JSON helpers (GetJSON,SetJSON,ErrCorrupt), and the fail-openFetchJSONread-through that reports cache failures to anOnErrorhook and never fails the request because the cache is down.cache/localis a bounded in-process store (entry limit, optional byte budget, least-recently-used eviction);cache/redis(Redis and Valkey) andcache/memcacheare their own Go modules, so no client library reaches yourgo.mod.cachetest.Runis 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 newexamples/cacheand 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 of0. 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 keyadmin.layout.signed_inis replaced byadmin.layout.signed_in_asandadmin.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.
v0.3.1
Fixed
db.Querywith anOffsetand noLimitfailed on SQLite with a SQL syntax error (SQLite refusesOFFSETwithoutLIMIT).Listnow skips the rows and returns the rest on both dialects. It also madetango shell'sList(model, map[string]any{"offset": n})fail the same way.
v0.3.0
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 ownshell/main.gothrough the newshellpackage, so the server never links it. HelpersModels,Describe,Get,List,Count,Create,UpdateandDeletework on any registered model addressed asapp.Model, with plain-map queries and rows;-c EXPRand piped input make it scriptable;--readonlyrefuses writes;Options.Helpersadds your own functions asproject.Name. At a terminal it has line editing, per-project history under your cache directory (TANGO_SHELL_HISTORY=offdisables 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 and ADR 0005. Addsgithub.com/traefik/yaegito the root module; only a binary that importsshelllinks it.admin.WithTrustedProxiesandaccounts.WithTrustedProxies: behind a reverse proxy, the failed-attempt limiters count the client address your proxy saw (the rightmostX-Forwarded-Forentry 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 fromX-Forwarded-Forand 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,realtimerooms, theaccountsmail 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 tuiguide, 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 newprojectwrites two more directories.project/project.goholdsfunc Config(store *db.Store) tango.Config: the installed apps, middleware and ordinary configuration thatmain.goused to build inline, so the server and the shell register the same apps from one list.main.goopens the database, builds the store and callsproject.Config(store).shell/main.gois the programtango shellruns.tango newappnow says to install a new app inproject/project.gowhen 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/boardproject list their apps inproject/project.go; the board's token service and live feed are built insideproject.Config, which stops the process with their error message when they cannot start. tango admin createandtango admin resetpassworduseTANGO_ADMIN_PASSWORDwhen 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 migratesays what it did:applied 2 migrations: admin/0001_auto_…, blog/0002_auto_…, orno pending migrations, instead of always printingmigrations applied. A project gets the new wording when it is built against this version; the rollback line is unchanged.tango newprojectends with the real first-run order (tango makemigrations,tango migrate,tango admin create <username>), and the generated project printsadmin: http://localhost:8000/admin/afterlistening 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 leftmostX-Forwarded-Forentry, 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 toX-Real-IP. It now readsX-Forwarded-Foronly 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-IPis ignored, and a valid address is returned in canonical form. Unusable data falls back to the peer's address. The failed-login limiters ofadminandaccounts(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 onX-Real-IP, make the proxy put the client address inX-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.
- Upgrade: nothing changes without trusted proxies. With them, check that your proxy adds the address it sees to
Fixed
tango admin createandresetpasswordno longer show the password as you type it on a terminal. Piped input is read as before. This addsgolang.org/x/termto the root module (ADR 0046).- Ctrl-C leaves
tango tuieven while a confirmation is showing; it used to be ignored until you answered. - Projects created by
tango newprojectnow shut down gracefully: the generatedmain.gocallstango.ServeContextundersignal.NotifyContextfor Ctrl-C andSIGTERM, so in-flight requests finish and registeredLifecyclecomponents stop. It calledtango.Serve, which never reacts to a signal. Existing applications are unchanged; swap inServeContextas the application lifecycle guide shows. tango tuino longer ends the session when an apply or rollback fails. It showsLast 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 tuiwith Ctrl-C orSIGTERMis a clean stop: tango waits for the server, printsserver stoppedand exits 0. It used to die with the signal. A server that fails, or exits with no signal, keeps its exit code. tango tuiexplains a status failure (runtango check; an oldermain.gomay needtango.DispatchFlags) before the raw error, which is kept.
v0.2.0
Changed
-
Projects created by
tango newprojectopt in to everything new in this release:LoadConfigFromEnv(tango.WithPortFromEnv()), which replaces the hardcoded:8000and honoursTANGO_ADDRandPORT;RequestID,RecovererandAccessLogger;- a global
MaxBodySize(1 << 20); MiddlewareScopeAll.
Existing applications keep their behaviour unless they opt in.
-
accountsmodels gainAccount.EmailVerifiedAtand a newAccountTokenmodel, for password reset and email verification. Every app that installsaccountsmust runtango makemigrationsand apply the result with-migrate, whether or not it enables mail: the change only adds a column and a table, so it needs no--renameor--allow-drop. Existing accounts read as unverified; nothing is gated on it unless an app usesaccounts.RequireVerified. -
The
routeattribute is now the route's registered pattern in every log and metric for a request, trailing slash included (/items/{id}/), under both middleware scopes. Thetango.http.access,tango.http.panicandtango.http.request_id_generation_failedlogs used to drop a trailing slash (/items/{id}), whilectx.Logger(),tango.http.view_errorand the HTTP metrics did not, so the same request reported two routes. Dashboards or alerts that match these three events on a trimmedrouteneed the registered spelling. The attribute key is unchanged.
Added
- A
mailpackage for outgoing email:mail.Sender,Messagewith in-memory attachments (sent asmultipart/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, andsmtp+insecure://is plaintext to a loopback host only, with an explicit port. Errors never contain credentials, recipients or messages.WriterSenderis for development, andmail/mailtestis for tests. See ADR 0044 and the mail guide. accounts.WithMailturns on password reset (/accounts/password-reset/) and email verification (/accounts/verify/), andaccounts.RequireVerifiedguards 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 configuredBaseURL, and GET never uses a link. Emails go through a bounded in-memory outbox, which logstango.accounts.mail_failedandtango.accounts.mail_dropped. WithoutWithMail,accountsbehaves as before. See ADR 0045.Config.NotFoundandConfig.MethodNotAllowedViews replace the router's404and405responses (tanGO setsAllowfor a405). TheAllowheader lists every method routed at that path, including a non-standard one the app registered. UnderMiddlewareScopeAllthey run inside global middleware and are observed; underMiddlewareScopeRoutesthey run outside it.Config.MiddlewareScope: withtango.MiddlewareScopeAll, global middleware wraps the whole router, so Unmatched requests (the router's404and405) 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 staysMiddlewareScopeRoutes, today's behaviour, and may becomeMiddlewareScopeAllonly after a minor release of notice; set the scope explicitly to keep yours. See ADR 0043.tango.LoadConfigFromEnv(tango.WithPortFromEnv())falls back to thePORTvariable hosting platforms set: the address isTANGO_ADDR, else":"+PORT, else:8000. Without the option,LoadConfigFromEnvis unchanged.tango.MaxBodySize(n)middleware caps request bodies: an oversized body gets413with{"error":"request body too large"}. When several limits apply, the most restrictive wins.
Security
ServeContext(andServe) 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, andWithReadHeaderTimeout(0)restores the old, unbounded behaviour.
Fixed
tango newappnow prints how to install the new app: the import path read fromgo.modand theInstalledAppsentry to add tomain.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,405and429from/accounts/*. It now promises only the JSONaccountssends, for a rejected CSRF token (403) and rate limiting (429, withRetry-After). Closed registration's403is an HTML page, and the router's405has no body. A test pins that contract.
v0.1.0
Changed
- Breaking: the database is chosen by
TANGO_DB_DSN's scheme alone, andTANGO_DB_DIALECTis retired: setting it is now an error.tango.LoadDBDSNFromEnvandtango.LoadDBDialectFromEnvare replaced bytango.LoadDBConfigFromEnv, which returns adb.DSNcarrying the dialect, driver name and connection string together.- Upgrade: put the scheme in the DSN (
TANGO_DB_DSN=postgres://…, orsqlite://app.db; unset still meanssqlite://app.db), dropTANGO_DB_DIALECT, and replace the two loader calls withdsn, err := tango.LoadDBConfigFromEnv()followed bysql.Open(dsn.Driver, dsn.Source)anddsn.Dialect. Projects generated bytango newprojectfrom this release on already do this.
- Upgrade: put the scheme in the DSN (
- Breaking:
tango makemigrationsrefuses to write a migration that drops a table or column unless each drop is authorised with--allow-drop app.Modelor--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
--renameor authorise the drop with--allow-drop, as the error message shows.
- Upgrade: if a run is refused, declare a rename with
- Breaking:
migration.Diffandmigration.DiffModelsreturn 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.Viewgained a variadic...ViewOptionparameter. Calls compile unchanged, but code that storesViewin a variable of its old function type no longer compiles.- Upgrade: add
...websocket.ViewOptionto that variable's function type, or callViewdirectly.
- Upgrade: add
Added
db.ParseDSN, which parses a scheme-qualified DSN into adb.DSN.- Renaming a field or a model with
tango makemigrations --rename, which keeps the column or table and its rows. - Widening type changes (
integertorealortext,realtotext,booleantointegerortext) through the newAlterColumnTypestep. - The public
testdbpackage: an app's own tests get a fresh database for the run's Test dialect, chosen byTANGO_TEST_DSN. examples/board, the tutorial's app, tested end to end on SQLite and PostgreSQL.realtime/websocket.Viewtakes options:WithOriginPatternsandWithInsecureSkipVerifyconfigure 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 NULLand the primary key. NULLreads as the zero value, and an unset foreign key is written asNULL.- Cookies are marked
Securebehind a TLS-terminating proxy that setsX-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 newprojectkeeps its standard-library imports in one sorted group.
v0.0.2
Overview
v0.0.2 introduces comprehensive observability, graceful shutdown, rate limiting, and real-time WebSocket enhancements to the Tango framework.
Features
Observability & Logging
- Structured HTTP Logging: Added request IDs and access/metrics middleware for comprehensive request tracking
- Observability Package: New
observabilitypackage with aRecorderfor centralized metrics and structured logging - Realtime Instrumentation: Hub operations now instrumented with the shared Recorder for visibility into WebSocket connections
Application Lifecycle & Graceful Shutdown
ServeContext: New ServeContext for lifecycle-aware graceful shutdown
Lifecycle Management: Lifecycle registration and stop-phase budget sharing across all Lifecycle.Stop calls
Realtime Integration: Realtime chat Hub now properly integrated into graceful shutdown process
Documentation: Comprehensive docs on application lifecycle and graceful shutdown patterns
Rate Limiting
Token Bucket Implementation: New ratelimit package with token-bucket core algorithm
Flexible Key Functions: KeyFunc and RemoteIPKey for flexible rate limit key extraction
Middleware Integration: Rate limit middleware ready to use in any HTTP handler
Safety Guarantees: Handles impossible costs, nil CIDRs, and overflow-safe bucket cleanup with opportunistic cleanup
WebSocket Enhancements
Configurable CORS: WebSocket View now allows configuration of upgrade's origin check for better CORS control
Bug Fixes
- Fixed handling of nil trusted CIDRs in RemoteIPKey
- Fixed impossible cost rejection in
ratelimit.Limiter.Take - Added opportunistic, overflow-safe bucket cleanup to Limiter
- Fixed stop-phase budget sharing to ensure consistent lifecycle behavior
Documentation
- Documented application lifecycle patterns and graceful shutdown process
- Documented cost-limit rejection and bucket cleanup behavior
- Unified version references to v0.0.1 in docs
- Documented rate limiting and integrated examples in notes-starter
Refactoring
- Delegated Serve logic to ServeContext for cleaner architecture
What's Next
The v0.0.2 release provides a solid foundation for production-grade applications with proper observability, graceful shutdown, and rate limiting. Future releases will expand on these capabilities.