Documentation
¶
Overview ¶
Package sequent produces gapless, formatted, per-scope document numbers — INV-2026-00042, then INV-2026-00043, never a gap, reset each year. It has zero dependencies: only the standard library.
Gapless sequential numbering is a legal requirement for invoices in many jurisdictions, yet it is reimplemented per product and mostly done wrong, because gaplessness is a durability property, not a formatting one. A number is only truly gapless if it is allocated in the same committed transaction as the document it numbers; allocate-then-fail leaves a hole. So sequent splits the problem honestly: it owns the deterministic parts — period keys, reset policies, the template render, per-scope isolation, zero-padding — and the atomic durable increment is a Store the caller supplies and binds to their own transaction.
f, _ := sequent.NewFormat("INV-{year}-{seq:05}", sequent.Yearly)
store := sequent.NewMemStore()
num, _ := f.Allocate(ctx, store, sequent.Scope{Tenant: "acme"}, time.Now())
// num == "INV-2026-00001", then "-00002", …, restarting at -00001 next year.
The model ¶
A Format is a compiled template plus a Reset policy and a timezone, constructed and validated once by NewFormat. Format.Allocate derives the period key from an instant, asks the Store for the next counter under that (scope, period), and renders the template — one call, one number. Format.Render formats an explicit counter without a Store, and Format.Key exposes the exact Store key so a caller can pre-lock the counter row.
Template tokens ¶
{year} (4-digit) {year2} (2-digit) {month} (2-digit) {day} (2-digit) {seq} (bare counter) {seq:N} (counter zero-padded to a minimum of N digits — never truncated, so 100000 under {seq:05} is "100000") {tenant} {series}, and {{ }} for literal braces. An unknown token or a malformed {seq:N} is a construction- time ErrBadTemplate, not a silent literal; a template with no {seq} token is ErrNoSeq. A validated Format therefore never fails on its template at render time.
Reset is a period-key change ¶
The Store key is (Tenant, Series, period), where period is "" for Never, "2026" for Yearly, "2026-02" for Monthly, "2026-02-14" for Daily — derived from the instant in the Format's timezone, because "which year is this invoice in" is a civil-date question that must not flip on a UTC boundary. When the period rolls over the key changes, so the Store starts a fresh counter at 1: a reset with no special-casing. The key is a length-prefixed encoding of the three parts, injective over arbitrary bytes, so no Tenant or Series string — however adversarial — can collide two distinct scopes onto one counter.
Gaplessness — the honest contract ¶
sequent cannot create a gap: Format.Allocate renders whatever consecutive counter the Store returns. Whether the overall run is gapless is decided by how the caller uses the Store:
- Gapless (correct): call the Store inside the same database transaction that inserts the document, and let both commit together. A rolled-back transaction never consumed a number. This is what a tax authority requires; the memory Store and the documented SQL pattern both support it.
- Not gapless: allocate the number, then create the document in a separate step that can fail — a crash between the two burns a number. sequent documents this and does not pretend the library alone prevents it.
MemStore is gapless within a process (mutex-guarded counters) and is the reference the conformance tests run against; it is not durable across a restart — that is the SQL Store's job, sketched in the README.
Concurrency ¶
A *Format is immutable and safe for concurrent use; Allocate takes no lock and holds none across the Store call. MemStore is safe for concurrent Next.
Errors ¶
NewFormat returns ErrBadTemplate, ErrNoSeq, ErrBadReset or ErrNilLocation; Format.Render and Format.Allocate return ErrBadSeq for a counter below 1, and Allocate returns ErrNilStore for a nil Store. All are matchable with errors.Is.
Not in scope ¶
The database (sequent defines the Store boundary and ships a memory one; the durable transactional Store is the caller's), distributed coordination, unordered id generation (ULID/snowflake), and formatting beyond the sequence tokens. See docs/DESIGN.md, including the "Phase 1 — as built" note recording the decisions and corrections this implementation made.
Example ¶
Example allocates three consecutive invoice numbers for one tenant, all in the same year, against the in-memory Store.
package main
import (
"context"
"fmt"
"time"
"github.com/zkrebbekx/sequent"
)
func main() {
f, _ := sequent.NewFormat("INV-{year}-{seq:05}", sequent.Yearly)
store := sequent.NewMemStore()
at := time.Date(2026, time.February, 14, 10, 0, 0, 0, time.UTC)
for i := 0; i < 3; i++ {
num, _ := f.Allocate(context.Background(), store, sequent.Scope{Tenant: "acme"}, at)
fmt.Println(num)
}
}
Output: INV-2026-00001 INV-2026-00002 INV-2026-00003
Example (Render) ¶
Example_render previews a template for an explicit counter without a Store, showing that {seq:05} is a minimum width, not a truncating one.
package main
import (
"fmt"
"time"
"github.com/zkrebbekx/sequent"
)
func main() {
f, _ := sequent.NewFormat("INV-{year}-{seq:05}", sequent.Yearly)
at := time.Date(2026, time.February, 14, 0, 0, 0, 0, time.UTC)
small, _ := f.Render(sequent.Scope{}, 42, at)
big, _ := f.Render(sequent.Scope{}, 100000, at)
fmt.Println(small)
fmt.Println(big)
}
Output: INV-2026-00042 INV-2026-100000
Example (Timezone) ¶
Example_timezone shows the civil period following the Format's timezone: an instant just after midnight in +09:00 is still the previous year in UTC.
package main
import (
"fmt"
"time"
"github.com/zkrebbekx/sequent"
)
func main() {
tokyo := time.FixedZone("JST", 9*60*60)
instant := time.Date(2027, time.January, 1, 0, 30, 0, 0, tokyo)
utc, _ := sequent.NewFormat("{year}-{seq}", sequent.Yearly)
local, _ := sequent.NewFormat("{year}-{seq}", sequent.Yearly, sequent.InLocation(tokyo))
u, _ := utc.Render(sequent.Scope{}, 1, instant)
l, _ := local.Render(sequent.Scope{}, 1, instant)
fmt.Println(u)
fmt.Println(l)
}
Output: 2026-1 2027-1
Example (YearlyReset) ¶
Example_yearlyReset shows the counter restarting at 1 when the civil year rolls over, because the period key changes from 2026 to 2027.
package main
import (
"context"
"fmt"
"time"
"github.com/zkrebbekx/sequent"
)
func main() {
f, _ := sequent.NewFormat("INV-{year}-{seq:05}", sequent.Yearly)
store := sequent.NewMemStore()
scope := sequent.Scope{Tenant: "acme"}
dec := time.Date(2026, time.December, 31, 23, 0, 0, 0, time.UTC)
jan := time.Date(2027, time.January, 1, 0, 30, 0, 0, time.UTC)
last2026, _ := f.Allocate(context.Background(), store, scope, dec)
first2027, _ := f.Allocate(context.Background(), store, scope, jan)
fmt.Println(last2026)
fmt.Println(first2027)
}
Output: INV-2026-00001 INV-2027-00001
Index ¶
- Variables
- type Format
- func (f *Format) Allocate(ctx context.Context, s Store, scope Scope, at time.Time) (string, error)
- func (f *Format) Key(scope Scope, at time.Time) string
- func (f *Format) Location() *time.Location
- func (f *Format) Render(scope Scope, seq int64, at time.Time) (string, error)
- func (f *Format) Reset() Reset
- func (f *Format) Template() string
- type MemStore
- type Option
- type Reset
- type Scope
- type Store
Examples ¶
Constants ¶
This section is empty.
Variables ¶
var ( // ErrBadTemplate is returned by [NewFormat] when a template is malformed: // empty, an unknown token such as {bogus}, a malformed {seq:N} width, an // empty {} token, or an unbalanced brace. Every construction-time template // error wraps this sentinel, so errors.Is(err, ErrBadTemplate) matches them // all; the message names the specific fault. ErrBadTemplate = errors.New("sequent: malformed template") // ErrNoSeq is returned by [NewFormat] when a template is otherwise well // formed but contains no {seq} (or {seq:N}) token. A sequence number needs a // place to render; a template without one is a constant string, which sequent // treats as a mistake rather than silently numbering nothing. ErrNoSeq = errors.New("sequent: template has no {seq} token") // ErrBadReset is returned by [NewFormat] when the reset policy is not one of // [Never], [Yearly], [Monthly] or [Daily]. ErrBadReset = errors.New("sequent: unknown reset policy") // ErrNilLocation is returned by [NewFormat] when [InLocation] is given a nil // *time.Location. Omit the option entirely for the default (UTC) instead. ErrNilLocation = errors.New("sequent: nil location") // ErrBadSeq is returned by [Format.Render] (and by [Format.Allocate] if a // Store hands back a non-positive counter) when a sequence value is less than // 1. A gapless counter is 1, 2, 3, …; zero or negative has no rendering. ErrBadSeq = errors.New("sequent: seq must be >= 1") // ErrNilStore is returned by [Format.Allocate] when the Store argument is nil. ErrNilStore = errors.New("sequent: nil Store") )
Sentinel errors. NewFormat reports template problems at construction time; Render and Allocate report a bad counter value at call time. Match them with errors.Is rather than by comparing error strings — the wrapped errors carry a descriptive suffix (the offending token, width, or template) that is not part of the stable contract.
Functions ¶
This section is empty.
Types ¶
type Format ¶
type Format struct {
// contains filtered or unexported fields
}
Format is a compiled, immutable numbering template plus its reset policy and timezone. Construct one with NewFormat, which validates the template once; thereafter Format.Render and Format.Allocate cannot fail on the template. A *Format is safe for concurrent use — it holds no mutable state and takes no lock (the Store owns counter atomicity).
func NewFormat ¶
NewFormat compiles template with reset policy reset, validating the template once. It returns ErrBadTemplate for a malformed template, ErrNoSeq for one with no {seq} token, ErrBadReset for an unknown policy, and ErrNilLocation for an explicit nil InLocation. On success the returned *Format renders without ever failing on the template.
f, err := sequent.NewFormat("INV-{year}-{seq:05}", sequent.Yearly)
f, err := sequent.NewFormat("INV-{year}-{seq:05}", sequent.Yearly, sequent.InLocation(loc))
func (*Format) Allocate ¶
Allocate produces the next formatted number for scope at instant at: it derives the period key from at and the reset policy, asks the Store for the next counter under (scope, period), and renders the template. One call, one number.
Allocate takes no lock and holds none across the call into s — the Store owns its own atomicity, and that is where gaplessness lives. A nil Store is ErrNilStore; an error from the Store propagates unwrapped so the caller's own errors.Is checks still work; a Store that violates its contract by returning a counter below 1 surfaces as ErrBadSeq.
func (*Format) Key ¶
Key is the exact Store key Format.Allocate would use for scope at instant at: the scope's Tenant and Series plus the reset period of at. It is exported so a caller can pre-lock or inspect the counter row (for the FOR UPDATE pattern) before allocating.
The key is a length-prefixed encoding of (Tenant, Series, period): each part is written as its byte length in decimal, a colon, then the bytes. This framing is injective — no two distinct triples can produce the same key — so adversarial Tenant/Series values (containing colons, digits, or any delimiter) can never collide two separate scopes onto one counter. See docs/DESIGN.md, "Correction — the period key must be collision-proof".
func (*Format) Location ¶
Location returns the timezone the Format computes civil periods and date tokens in (never nil; UTC by default).
func (*Format) Render ¶
Render formats an explicit seq for scope at instant at, without touching a Store — for previewing a template, or numbering when the caller already holds the counter. Date tokens and the period are computed in the Format's timezone. seq must be >= 1; a smaller value is ErrBadSeq. Render never fails on the template.
type MemStore ¶
type MemStore struct {
// contains filtered or unexported fields
}
MemStore is an in-memory Store: a mutex-guarded per-key counter. It is gapless within a single process — the reference the conformance tests run against — and safe for concurrent use. It is deliberately not durable: its counters are lost on restart, which is exactly the job a SQL Store exists to do (see the README's FOR UPDATE / UPDATE ... RETURNING sketch). Use MemStore for tests, previews, and single-process numbering that need not survive a crash.
func (*MemStore) Next ¶
Next returns the next consecutive counter for key, starting at 1. It honours context cancellation: a cancelled ctx returns its error and consumes no number, preserving gaplessness. The increment is atomic under an internal mutex, so concurrent callers for one key receive distinct consecutive values with no gap and no repeat.
type Option ¶
type Option func(*formatConfig)
Option configures a Format at construction. The only option today is InLocation; the type exists so the reset policy stays a required positional argument while the timezone (which defaults to UTC) is opt-in.
func InLocation ¶
InLocation sets the timezone in which civil periods and the {year}/{month}/ {day} tokens are computed. The default is UTC. Pass the business zone when "which year is this invoice in" must follow local midnight rather than a UTC boundary. A nil location is an ErrNilLocation error; omit the option for the default instead.
type Reset ¶
type Reset uint8
Reset selects when the per-scope counter restarts at 1. The restart is not special-cased logic: it falls out of the period key (see Format.Key). When the civil period named by the policy rolls over, the Store key changes, so the Store hands out a fresh counter beginning at 1.
const ( // Never keeps one ever-growing counter per scope; the period is always "". Never Reset = iota // Yearly restarts the counter each civil year (period "2026"). Yearly // Monthly restarts the counter each civil month (period "2026-02"). Monthly // Daily restarts the counter each civil day (period "2026-02-14"). Daily )
type Scope ¶
Scope isolates counters. Two allocations share a counter only when their Tenant, Series and reset period all match; differ in any one and the counters are independent. The zero Scope ({"", ""}) is the valid single-tenant, single-series default. Tenant and Series are opaque caller strings and may contain any bytes — the period key encodes them unambiguously (see Format.Key).
type Store ¶
Store hands out the durable, gapless counter for a key. The caller implements it over their storage and — for true gaplessness — binds Next to the same transaction that persists the document, so a rolled-back transaction consumes no number. Next must return strictly consecutive values (1, 2, 3, …) for a given key and must never repeat one; that is where gaplessness actually lives. sequent renders whatever Next returns and cannot itself create a gap.
A single key corresponds to one (Tenant, Series, period) triple; see Format.Key. Next may return an error (a cancelled context, a failed query), which Format.Allocate propagates without consuming a number.