notebook

package
v0.2.32-rc2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 1, 2026 License: MPL-2.0 Imports: 18 Imported by: 0

Documentation

Overview

Package notebook is the append-only record a stack writes and reads through txco://notebook/*: per (tenant, namespace, name) a head row that allocates sequence numbers, and entries {seq, at, type, data, object_key} in that order. Task is state; a notebook is history — "how did we get here?" for a task, a conversation, a workspace session, an import.

Storage is dialect-aware (registry.Dialect, the same seam imap, auth and the scheduled store use); the bundled backend is a SQLite file of its own. It is deliberately NOT a set of runtime tables: the dbcache watcher reloads the whole runtime mirror on any runtime-DB write, and a notebook is a high-write table by definition.

Invariants: seq is allocated from the head row's next_seq — stored, never MAX(seq)+1 — so a pruned tail never recycles a sequence number; `at` is assigned here at commit and never by the caller; every read returns entries ascending by seq; a duplicate object_key returns the original row unchanged; and a public cursor is opaque — it carries the head's generation, so a cursor from a deleted-and-recreated notebook fails loudly (StaleCursorError) instead of silently re-reading new entries as old ones.

A notebook write is not part of the transaction of the thing it describes: it is an application record, not an event-sourcing substrate.

Index

Constants

View Source
const (
	// MaxNameBytes bounds a whole name.
	MaxNameBytes = 250
	// MaxSegmentBytes bounds one '/'-separated segment.
	MaxSegmentBytes = 128
	// MaxTypeBytes bounds an entry type.
	MaxTypeBytes = 128
	// MaxObjectKeyBytes bounds an object_key: it sits in a btree index, and
	// a Postgres index tuple caps well under this (same guard as the
	// scheduled store's idempotency key).
	MaxObjectKeyBytes = 512
)

Name grammar — the blob grammar, adopted so hierarchical names (task/42, conversation/alice, workspace/session-abc) work and a prefix listing is meaningful. KV's own grammar forbids '/' and would reject every one of those.

View Source
const AtLayout = "2006-01-02T15:04:05.000000000Z"

AtLayout is the canonical, FIXED-WIDTH UTC timestamp stored in `at`, `expires_at`, `created_at` and `updated_at`: 30 bytes always (the fraction keeps its zeros, the zone is a literal Z), so a TEXT comparison on either engine is a chronological comparison. time.RFC3339Nano trims trailing zeros and would sort "…00Z" after "…00.5Z".

Variables

This section is empty.

Functions

func ErrorCode

func ErrorCode(err error) string

ErrorCode returns the code carried anywhere in err's chain, or "".

func FormatAt

func FormatAt(t time.Time) string

FormatAt renders t in AtLayout, in UTC.

func ParseAt

func ParseAt(s string) (time.Time, error)

ParseAt reads an AtLayout timestamp, accepting any RFC 3339 precision as a fallback so caller-supplied `since`/`until` normalise through it.

func Register

func Register(name string, c Constructor)

Register adds a backend constructor. Called from a backend package's init().

func ValidName

func ValidName(name string) error

ValidName reports why name is not a well-formed notebook name (nil = valid): '/'-separated segments matching [A-Za-z0-9._-]{1,128}; no empty, "." or ".." segments (so no leading/trailing '/'); no leading-'_' segment (reserved); at most MaxNameBytes in total.

func ValidType

func ValidType(t string) error

ValidType reports why t is not a well-formed entry type (nil = valid): [A-Za-z0-9._:-]{1,128} — task.created, command.finished, recipient.resolved.

Types

type AppendReq

type AppendReq struct {
	Ref       Ref
	Type      string
	Data      json.RawMessage
	ObjectKey string
	TTL       time.Duration
}

AppendReq is one append. TTL > 0 overrides the head's ttl_secs for this entry only; 0 inherits it.

type AppendResult

type AppendResult struct {
	Seq     int64
	At      time.Time
	Existed bool
}

AppendResult reports the entry's position. Existed means the object_key was already recorded: Seq and At are the ORIGINAL row's and nothing was written, so a retry is observationally identical to the first success.

type CodedError

type CodedError interface {
	error
	Code() string
}

CodedError carries a stable txco_notebook_* code. The op layer surfaces it as `<into>.error.{code,message}` with a nil Go error, so an author branches with `WHEN ._notebook.error.code != ""` and the run continues — a record that silently fails is worse than no record.

type Config

type Config struct {
	// DBPath is the bundled SQLite backend's file path (--notebook-db-path).
	DBPath string
}

Config carries backend-selecting options resolved from chassis config. The bundled "sqlite" backend uses DBPath. Adding a field here doesn't affect existing backends (same posture as imap.Config / scheduled.Config).

type Constructor

type Constructor func(Config) (*Store, error)

Constructor builds a Store from resolved config. It is expected to open its backing DB and call Store.EnsureSchema before returning.

type Cursor

type Cursor struct {
	Generation int64
	Seq        int64
}

Cursor is a position in a notebook. On the wire it is an opaque string (Encode); callers never build one from a seq. It carries the head's generation so a cursor outlives neither a delete nor a recreate.

func ParseCursor

func ParseCursor(s string) (Cursor, error)

ParseCursor reads an Encode'd cursor; "" is the zero cursor. Anything else that does not decode — including a bare sequence number — is an InvalidArgError, so the format can evolve behind the version character.

func (Cursor) Encode

func (c Cursor) Encode() string

Encode renders the cursor as an opaque, URL- and header-safe string: a version character followed by base64url (no padding) of "<generation>:<seq>". The zero cursor encodes as "".

func (Cursor) IsZero

func (c Cursor) IsZero() bool

IsZero reports the start-of-notebook cursor (the empty string).

type Entry

type Entry struct {
	Seq       int64
	At        time.Time
	Type      string
	Data      json.RawMessage
	ObjectKey string
	ExpiresAt time.Time
}

Entry is one notebook row. Data is always a valid JSON document ("{}" when the append omitted it). ExpiresAt is zero for an unbounded entry.

type Head struct {
	Name       string
	Generation int64
	HighSeq    int64
	TTL        time.Duration
	CreatedAt  time.Time
	UpdatedAt  time.Time
}

Head is a notebook's head row. HighSeq is the last allocated sequence number (0 = never appended); TTL is the default retention for new entries (0 = unbounded).

type InvalidArgError

type InvalidArgError struct{ Reason string }

InvalidArgError flags a malformed request: a bad tenant or namespace, an empty or ill-formed type, data that is not JSON or not UTF-8, a negative window, since >= until, or a cursor that does not decode.

func (*InvalidArgError) Code

func (e *InvalidArgError) Code() string

func (*InvalidArgError) Error

func (e *InvalidArgError) Error() string

type InvalidNameError

type InvalidNameError struct{ Reason string }

InvalidNameError flags a notebook name outside the grammar (see ValidName).

func (*InvalidNameError) Code

func (e *InvalidNameError) Code() string

func (*InvalidNameError) Error

func (e *InvalidNameError) Error() string

type Limits

type Limits struct {
	// MaxDataBytes caps one entry's `data` (the KV --kv-max-value-bytes
	// posture); 0 = unlimited.
	MaxDataBytes int
	// DefaultReadLimit is the page size when a read names none.
	DefaultReadLimit int
	// MaxReadLimit is the page ceiling; larger requests are clamped.
	MaxReadLimit int
	// MaxTTL clamps a requested TTL; 0 = unlimited.
	MaxTTL time.Duration
}

Limits are the node-configured caps. Zero fields take DefaultLimits.

func DefaultLimits

func DefaultLimits() Limits

DefaultLimits are the bundled defaults.

type ListResult

type ListResult struct {
	Notebooks []Head
	Next      string
	Count     int
}

ListResult is one page of heads, ascending by name. Next is the last name when more remain, "" otherwise.

type ReadReq

type ReadReq struct {
	Ref   Ref
	After Cursor
	UpTo  int64
	Since time.Time
	Until time.Time
	Tail  int
	Type  string
	Limit int
}

ReadReq selects entries. Selection varies; the order never does — every result is ascending by seq.

  • After: exclusive cursor (the head walk); zero = from the start.
  • Since/Until: `at` in [Since, Until); zero = unbounded on that side.
  • Tail: the newest N of the selection, still returned ascending.
  • Type: only entries of that type.
  • Limit: page cap; 0 = DefaultReadLimit, clamped to MaxReadLimit.
  • UpTo: an inclusive seq ceiling ForEach uses to snapshot a walk. Not on the op surface.

type ReadResult

type ReadResult struct {
	Entries   []Entry
	Next      Cursor
	Cursor    Cursor
	Count     int
	Truncated bool
}

ReadResult is one page. Next is the cursor for the following page and is non-zero only when the page was full (the LOOP … UNTIL next == "" drain idiom); in tail mode it is always zero, because rows clipped there are older than the window and not reachable by a forward cursor. Cursor is the position after the last returned entry whenever Count > 0 — what a poller feeds back as After — and equals Next when Next is non-zero.

type Ref

type Ref struct {
	Tenant    string
	Namespace string
	Name      string
}

Ref identifies a notebook: unique within (tenant, namespace), never addressable across tenants. Tenant and namespace are single segments (the KV grammar: no '/', no control characters); the name follows the blob grammar so hierarchical names like task/42 work and prefixes are enumerable.

func (Ref) ID

func (r Ref) ID() string

ID is the composed row key, stored whole (not hashed) so the table stays legible to an operator and List stays a bounded scan.

func (Ref) Validate

func (r Ref) Validate() error

Validate reports why r is not a well-formed reference (nil = valid).

type StaleCursorError

type StaleCursorError struct{}

StaleCursorError is returned when a cursor's generation is not the notebook's current one: the notebook was deleted and recreated since the cursor was issued, so continuing would silently re-read new entries as old. Restart from the beginning.

func (*StaleCursorError) Code

func (e *StaleCursorError) Code() string

func (*StaleCursorError) Error

func (e *StaleCursorError) Error() string

type Store

type Store struct {
	// contains filtered or unexported fields
}

Store is the façade over the two tables. It carries the dialect (for `?`→`$n` rebinding), the node limits, and a clock seam for tests, mirroring imap.Store.

func NewStore

func NewStore(db *sql.DB, d registry.Dialect) *Store

NewStore builds a Store over the opened DB and its dialect. A nil dialect defaults to SQLite (the in-tree default).

func Open

func Open(name string, cfg Config) (*Store, error)

Open constructs the named backend. Unknown name is a startup error listing what is available (so a misconfigured --notebook-store fails loudly).

func (*Store) Append

func (s *Store) Append(ctx context.Context, req AppendReq) (AppendResult, error)

Append records one entry and returns its position. Validation happens before any DB work; every DB failure comes back as a StoreError.

Appends to one notebook are serialized: the transaction first locks the head row (LockClause — FOR UPDATE on Postgres; on SQLite the _txlock=immediate connection already holds the write lock), so the object_key check and the next_seq allocation can never interleave across nodes. A unique-index violation (an out-of-band writer that won the same key) is retried once, in a fresh transaction — Postgres aborts the current one on a violation.

On Postgres the common case is tried first as one statement (appendFast); anything it does not cover falls through to this transaction untouched.

func (*Store) Close

func (s *Store) Close() error

Close releases the underlying DB handle.

func (*Store) DB

func (s *Store) DB() *sql.DB

DB exposes the handle for tests and backends. Not for request paths.

func (*Store) Delete

func (s *Store) Delete(ctx context.Context, tenant, namespace, name string) (bool, error)

Delete removes the notebook: its entries, then its head, in one transaction under the head lock, so a concurrent Append re-evaluates to the auto-create path with no orphan rows to collide with — and the recreated head carries a new generation, which is what invalidates every cursor issued before the delete. Returns false when there was nothing.

func (*Store) EnsureSchema

func (s *Store) EnsureSchema(ctx context.Context) error

EnsureSchema creates the tables + indexes if absent. Portable DDL (the imap store's rules): TEXT ids, TEXT fixed-width timestamps, JSON as TEXT, BIGINT counters, native partial indexes — one statement set serves SQLite and Postgres. There is no migration runner for these stores; changes must be additive.

func (*Store) ForEach

func (s *Store) ForEach(ctx context.Context, req ReadReq, fn func(Entry) error) error

ForEach streams the selection oldest→newest without materializing more than one page: it snapshots the head's HighSeq as the ceiling (so a hot notebook cannot make an export run forever), then walks pages of MaxReadLimit. fn's error aborts the walk and is returned as is. A tail request is a single page.

func (*Store) Limits

func (s *Store) Limits() Limits

Limits returns the caps in force.

func (*Store) List

func (s *Store) List(ctx context.Context, tenant, namespace, prefix, after string, limit int) (ListResult, error)

List returns one page of the (tenant, namespace) heads whose name starts with prefix, ascending by name. after is the last name of the previous page. Prefix matching is substr equality, not LIKE ('_' is legal in a name) and not a byte-range on the id (wrong under a non-C Postgres collation).

func (*Store) Read

func (s *Store) Read(ctx context.Context, req ReadReq) (ReadResult, error)

Read returns one page of the selection, ascending by seq. A notebook that does not exist reads as empty; a cursor from an earlier generation is a StaleCursorError.

func (*Store) SetLimits

func (s *Store) SetLimits(l Limits)

SetLimits installs the node's caps. A zero page size or ceiling keeps the default; MaxDataBytes and MaxTTL are taken as given (0 = unlimited).

func (*Store) SetTTL

func (s *Store) SetTTL(ctx context.Context, ref Ref, ttl time.Duration) error

SetTTL sets the head's default retention for new entries (0 = unbounded), creating the head if needed. Existing entries keep their expiry.

func (*Store) Stat

func (s *Store) Stat(ctx context.Context, ref Ref) (Head, bool, error)

Stat returns the head row. ok is false when the notebook has never been appended to (or was deleted).

func (*Store) Sweep

func (s *Store) Sweep(ctx context.Context, now time.Time, batch int) (int64, error)

Sweep deletes up to batch expired entries (expires_at <= now, the complement of the read predicate) and reports how many. It never touches the head row, so next_seq is never renumbered and an all-expired notebook keeps its head. Callers loop while the count equals batch.

type StoreError

type StoreError struct {
	Op  string
	Err error
}

StoreError wraps a database or driver failure.

func (*StoreError) Code

func (e *StoreError) Code() string

func (*StoreError) Error

func (e *StoreError) Error() string

func (*StoreError) Unwrap

func (e *StoreError) Unwrap() error

type TooLargeError

type TooLargeError struct {
	Field string
	Max   int
	Got   int
}

TooLargeError flags a field over its byte cap. Its own code so an author can branch "trim the payload and retry" apart from "malformed".

func (*TooLargeError) Code

func (e *TooLargeError) Code() string

func (*TooLargeError) Error

func (e *TooLargeError) Error() string

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL