Files
zeekayandHanzo Dev 1a3cc15421 namespace: a value that names one entity database
The fleet stores OLTP data as one SQLite file per entity, and the name of
that file is a path -- <dir>/<ns>.db locally, a key prefix in object
storage. orm/db canonicalised it with path.Clean plus "must begin with a
letter", which leaves a namespace an opaque string that every consumer
re-derives structure from and re-validates: orm/replicated re-split and
re-checked each segment, commerce/db has splitTenant.

So make it a value. Namespace wraps an unexported string, the only routes
to one are the constructors and Parse, and the shape is kind[/id][/group].
Segments are [a-z0-9][a-z0-9_-]*, which leaves no spelling of a separator,
a dot-segment or a hidden name to reject later, and ids fold to lowercase
because on a case-insensitive filesystem org/Acme and org/acme are two
namespaces over one file.

This package is what a namespace IS; orm/db.Namespaces resolves one to a
handle.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-28 09:39:30 -07:00

297 lines
12 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package namespace names the one database an entity's data lives in.
//
// The fleet stores OLTP data as one SQLite file per entity — an org, a user, a
// repository — rather than one per tenant, so that a file stays small and
// several can be opened at once. A namespace is the name of one of those files.
//
// It is a value with constructors and not a string with a validator, because
// the string form IS a path: it becomes <dir>/<namespace>.db locally and a key
// prefix in object storage. A name that a validator lets through late is a name
// that has already been written down somewhere by then. Here there is no route
// to a Namespace that does not go through a constructor, so the illegal ones do
// not exist to be caught.
//
// This package is what a namespace IS. Resolving one to an open handle —
// materialise, open, cache, evict — is hanzoai/orm/db.Namespaces, and it is the
// only other thing allowed an opinion about namespaces.
package namespace
import (
"errors"
"fmt"
"strings"
)
// Kind is the sort of entity whose data a namespace holds.
//
// The set is closed and adding to it is a patch to this file, on purpose: which
// entities own their own data is one fleet-wide fact, and a fact with two homes
// is a fact that disagrees with itself. It is also the reason Kind cannot be
// injected — no exported function takes one — so a Kind literal that names
// nothing can never reach a Namespace.
type Kind string
const (
KindOrg Kind = "org"
KindUser Kind = "user"
KindRepo Kind = "repo"
KindSystem Kind = "system"
)
// maxSegment bounds an id or a group. A filesystem component stops at 255 bytes
// and an S3 key at 1024; 64 clears both with room for the kind, the group and
// the ".db", and still holds a UUID (36) or a ULID (26) with most of it spare.
const maxSegment = 64
// Namespace names one database: an entity kind, that entity's id, and
// optionally a group naming which of its related entity types this file holds.
//
// It holds its own canonical string rather than the three parts, so String is
// free and the map key hashes as one string — Namespaces keys every open handle
// by this value and looks one up on every request. The parts are derived on the
// rare path instead, which is the right way round.
//
// The zero Namespace is not a namespace. Go gives every struct a zero value and
// no encapsulation takes that away, so it is defined rather than left to be
// discovered: IsZero reports it, String returns "", MarshalText refuses it, and
// whatever turns a namespace into a path must refuse it too. A zero value that
// resolved to a file would be one file quietly shared by every caller who
// forgot to set one — the worst failure this package exists to prevent, so it
// is the one Go forces us to state explicitly.
type Namespace struct{ s string }
// Group names a set of related entity types kept in a file of their own, so
// that an entity's data is several small databases and not one that grows
// without bound: a repository's issues need not be opened to read its settings.
//
// It is a type rather than a string because a group is CODE where an id is
// DATA. A package declares the groups it owns once —
//
// var Issues = namespace.MustGroup("issues")
//
// — while an id arrives with a request. That is the whole reason the two are
// built differently: MustGroup fails at init or never, so there is no error to
// handle at a call site that cannot fail.
//
// The zero Group means no group, i.e. the entity's own data. That is a
// definition, not a hole: ns.WithGroup(Group{}) is ns.
type Group struct{ s string }
// Org, User and Repo name one entity's database.
//
// The id is the entity's STABLE identity, never a human-facing name. A
// namespace is a path in object storage and a replication prefix; deriving it
// from something a person can rename — a slug, an email, an owner/name pair —
// makes a rename into a file move and a broken history. Transferring a
// repository between orgs is likewise a row somewhere, not a namespace change,
// which is why a repo is repo/<id> and never org/<org>/repo/<name>.
func Org(id string) (Namespace, error) { return of(KindOrg, id) }
func User(id string) (Namespace, error) { return of(KindUser, id) }
func Repo(id string) (Namespace, error) { return of(KindRepo, id) }
// System is the instance's own namespace: what belongs to the deployment rather
// than to any org, user or repo — app state, login sources, topics, notices.
//
// It takes no id because there is one instance. Groups keep its contents in
// separate files (System().WithGroup(Notices)), so "instance-level" costs a
// namespace and not an exception to the rule that every table lives in one.
func System() Namespace { return Namespace{string(KindSystem)} }
// MustOrg, MustUser and MustRepo are for ids fixed in the source — tests,
// seeds, a constant in a migration. They panic, which is correct for a value
// that is wrong before the program runs and wrong for anything from a request.
func MustOrg(id string) Namespace { return must(Org(id)) }
func MustUser(id string) Namespace { return must(User(id)) }
func MustRepo(id string) Namespace { return must(Repo(id)) }
// NewGroup returns the group of that name.
func NewGroup(name string) (Group, error) {
c, err := ident(name)
if err != nil {
return Group{}, fmt.Errorf("namespace: group: %w", err)
}
return Group{c}, nil
}
// MustGroup is NewGroup for a group named in the source, which is all of them.
func MustGroup(name string) Group {
g, err := NewGroup(name)
if err != nil {
panic(err)
}
return g
}
// of is the one place a Namespace is built from parts, so the rule about which
// kinds exist and which of them name an entity is stated once.
func of(k Kind, id string) (Namespace, error) {
switch k {
case KindOrg, KindUser, KindRepo:
case KindSystem:
if id != "" {
return Namespace{}, fmt.Errorf("namespace: %s names the instance and takes no id, got %q", k, id)
}
return System(), nil
default:
return Namespace{}, fmt.Errorf("namespace: %q is not an entity kind", string(k))
}
c, err := ident(id)
if err != nil {
return Namespace{}, fmt.Errorf("namespace: %s id: %w", k, err)
}
return join(k, c, ""), nil
}
func join(k Kind, id, group string) Namespace {
s := string(k)
if id != "" {
s += "/" + id
}
if group != "" {
s += "/" + group
}
return Namespace{s}
}
// parts splits the canonical form back into what built it.
func (ns Namespace) parts() (k Kind, id, group string) {
kind, rest, _ := strings.Cut(ns.s, "/")
a, b, _ := strings.Cut(rest, "/")
if Kind(kind) == KindSystem {
return KindSystem, "", a // the instance names no entity, so its one segment is a group
}
return Kind(kind), a, b
}
// WithGroup returns this entity's namespace for one group of its related types.
//
// It REPLACES any group already set instead of nesting, so a namespace is
// always kind[/id][/group] and there is never a second shape to parse. The zero
// Namespace absorbs, because there is no entity to name a group of.
func (ns Namespace) WithGroup(g Group) Namespace {
k, id, _ := ns.parts()
if k == "" {
return ns
}
return join(k, id, g.s)
}
func (ns Namespace) Kind() Kind { k, _, _ := ns.parts(); return k }
func (ns Namespace) ID() string { _, id, _ := ns.parts(); return id }
func (ns Namespace) Group() Group { _, _, g := ns.parts(); return Group{g} }
func (ns Namespace) IsZero() bool { return ns.s == "" }
func (ns Namespace) String() string { return ns.s }
func (g Group) String() string { return g.s }
// Parse reads a namespace back from its string form, for the strings that
// arrive from outside a Go program: a request, a config file, a queue message.
//
// Parse(ns.String()) == ns for every ns a constructor returns, so a namespace
// survives a round trip instead of degrading into a string with conventions
// attached to it.
//
// It folds case exactly as the constructors do and repairs nothing else.
// "Org/Acme" is org/acme because a namespace is a value and "Acme" and "acme"
// are one org — one value, one spelling, which is canonicalisation. "org//acme"
// and "org/acme/" are errors, because String never emits them and accepting
// them would make one namespace have several encodings — which is the aliasing
// this package removes, arriving through the back door.
func Parse(s string) (Namespace, error) {
kind, rest, hasRest := strings.Cut(s, "/")
k, err := ident(kind)
if err != nil {
return Namespace{}, fmt.Errorf("namespace: %q: kind: %w", s, err)
}
id, group, hasGroup := strings.Cut(rest, "/")
if Kind(k) == KindSystem {
id, group, hasGroup = "", rest, hasRest // no entity, so all of rest is the group
}
if strings.Contains(group, "/") {
return Namespace{}, fmt.Errorf("namespace: %q has too many segments, want kind[/id][/group]", s)
}
ns, err := of(Kind(k), id)
if err != nil {
return Namespace{}, err
}
if !hasGroup {
return ns, nil
}
g, err := NewGroup(group)
if err != nil {
return Namespace{}, fmt.Errorf("namespace: %q: %w", s, err)
}
return ns.WithGroup(g), nil
}
// MarshalText and UnmarshalText put every encoder that speaks text — JSON, YAML,
// a flag, a database column — through Parse and String rather than around them.
// Without MarshalText, json.Marshal of a Namespace emits {}: the fields that
// make illegal values unrepresentable also make them invisible to reflection,
// so this is not a convenience, it is the difference between a namespace and
// silent data loss.
//
// Marshalling the zero Namespace is an error rather than "": the mistake is at
// the write, and "" would only fail later at whoever reads it back.
func (ns Namespace) MarshalText() ([]byte, error) {
if ns.IsZero() {
return nil, errors.New("namespace: the zero Namespace names no database")
}
return []byte(ns.s), nil
}
func (ns *Namespace) UnmarshalText(b []byte) error {
p, err := Parse(string(b))
if err != nil {
return err
}
*ns = p
return nil
}
// ident canonicalises one segment — an id, a group or a kind — and is the whole
// of what a namespace may be made of.
//
// Lowercase, because a namespace is a FILE and half the fleet develops on a
// case-insensitive filesystem. There "org/Acme" and "org/acme" are two
// namespaces over one file: two open handles, two replication streams, one
// history, and it works on the laptop. Folding at construction removes the
// difference rather than hoping every caller normalises first. ASCII-only, so
// the fold is the same everywhere — Unicode lowercases Turkish "I" to a dotless
// "ı", which would name a different file on a Turkish laptop than on a server.
//
// [a-z0-9][a-z0-9_-]* then settles what a segment cannot be by construction
// instead of by denylist: no separator, so no path; no dot, so no ".." and no
// hidden name; no trailing dot or space, which Windows and some object stores
// drop; no empty segment; no percent- or unicode-encoded form of any of those.
func ident(s string) (string, error) {
if s == "" {
return "", errors.New("must not be empty")
}
if len(s) > maxSegment {
return "", fmt.Errorf("%q is %d bytes, over the %d-byte limit", s, len(s), maxSegment)
}
folded := false
for i := 0; i < len(s); i++ {
switch c := s[i]; {
case c >= 'a' && c <= 'z', c >= '0' && c <= '9':
case c >= 'A' && c <= 'Z':
folded = true
case (c == '-' || c == '_') && i > 0:
default:
return "", fmt.Errorf("%q: %q is not allowed, want [a-z0-9][a-z0-9_-]*", s, string(rune(c)))
}
}
if !folded {
return s, nil
}
return strings.ToLower(s), nil // provably ASCII by the loop above, so this is the ASCII fold
}
func must(ns Namespace, err error) Namespace {
if err != nil {
panic(err)
}
return ns
}