Documentation
¶
Overview ¶
Package projection is v5's render-thread view of data owned by a worker (plan item P3.7).
A projection is a slice of domain state made resident on the render thread so the UI can read it without asking anyone. That is the whole point: the §1.2 filter keystroke must perform ZERO domain round-trips, because a round trip per keystroke reintroduces exactly the latency the off-thread architecture removed. Filtering, sorting, and windowing over a resident projection are local operations on local memory.
The worker keeps the authority and publishes deltas (delta); this package applies them and answers questions. It never queries.
Residency is bounded on purpose. Making the render thread hold an unbounded copy of a worker's data would trade a message-passing stall for a GC one — the risk M12 exists to bound — so Options.Resident caps it, and the cap is visible rather than silent.
Index ¶
- Constants
- Variables
- func DomainPanic(parseMessage string) error
- func EncodeReply(parseReply ReplyEnvelope) ([]byte, error)
- func EncodeRequest(parseRequestID uint64, parseCommand string, parsePayload []byte) ([]byte, error)
- func Optimistic[R any](parseCtx context.Context, parseApply func(), parseRollback func(), ...) (R, error)
- func Rejection(parseMessage string) error
- func WorkerDeath(parseMessage string) error
- type Bootstrap
- type Codec
- type Command
- type CommandClient
- type CommandError
- type Declared
- type Decoder
- type Entry
- type FailureKind
- type Key
- type Op
- type OpKind
- type Options
- type Policy
- type Poster
- type Projection
- func (parseProjection *Projection[T]) Apply(parseOps []Op) error
- func (parseProjection *Projection[T]) Complete() bool
- func (parseProjection *Projection[T]) DroppedByResidency() int
- func (parseProjection *Projection[T]) Filter(parsePredicate func(T) bool) []Entry[T]
- func (parseProjection *Projection[T]) Get(parseKey Key) (T, bool)
- func (parseProjection *Projection[T]) Hydrate(parseBootstrap Bootstrap) error
- func (parseProjection *Projection[T]) Len() int
- func (parseProjection *Projection[T]) MarkReady(parseComplete bool)
- func (parseProjection *Projection[T]) Ready() bool
- func (parseProjection *Projection[T]) Reset()
- func (parseProjection *Projection[T]) Rows() []Entry[T]
- func (parseProjection *Projection[T]) Window(parseOffset int, parseCount int) []Entry[T]
- type Registry
- type ReplyEnvelope
- type ReplyKind
- type RequestEnvelope
- type Resilient
- type WorkerClient
- func (parseClient *WorkerClient) Deliver(parseRequestID uint64, parsePayload []byte, parseWorkerErr error) bool
- func (parseClient *WorkerClient) DeliverEncodedReply(parseBytes []byte) (bool, error)
- func (parseClient *WorkerClient) DeliverPanic(parseRequestID uint64, parseMessage string) bool
- func (parseClient *WorkerClient) DeliverRejection(parseRequestID uint64, parseMessage string) bool
- func (parseClient *WorkerClient) InFlight() int
- func (parseClient *WorkerClient) Send(parseCtx context.Context, parseName string, parseRequest []byte) ([]byte, error)
- func (parseClient *WorkerClient) WorkerDied(parseReason string) int
- func (parseClient *WorkerClient) WorkerRestarted()
Constants ¶
const ( OpInsert = delta.OpInsert OpUpdate = delta.OpUpdate OpRemove = delta.OpRemove OpMove = delta.OpMove )
The op kinds, re-exported.
Aliases rather than new constants, so a kind produced by delta and a kind named through projection are the same value and switch statements over either stay exhaustive. Declaring separate constants would create two vocabularies for one concept and a conversion nobody would remember to write.
const DefaultResident = 20000
DefaultResident is the residency cap applied when Options.Resident is unset.
The number comes from M12: it is the row count at which render-thread projection memory and its GC cost stay inside the frame budget for a typical row. See memory_test.go, which both measures it and fails if this default drifts away from what was measured.
Variables ¶
var ErrDomainPanic = errors.New("projection: the domain handler panicked")
ErrDomainPanic is returned when a domain handler panicked.
It is a REJECTION rather than a death: P3.8a requires a domain panic to leave recoverable UI state, and that is only true if the worker contained the panic and stayed alive. A worker that dies on every panic gives the UI nothing to recover to.
var ErrRejected = errors.New("projection: the domain rejected the command")
ErrRejected is returned by a transport whose worker answered with a refusal.
It exists because a rejection is otherwise indistinguishable from a delivery failure, and the two want opposite handling: a refusal reached the domain and repeating it yields the same answer, while an undelivered message should be retried. Without this, a "retry on failure" policy spends its attempts re-asking a question that has already been answered no.
A transport reports it by wrapping this — the same mechanism as ErrWorkerDied — so classification never has to parse a message.
var ErrWorkerDied = errors.New("projection: the domain worker terminated")
ErrWorkerDied is returned by a transport whose worker has terminated.
A transport reports it by wrapping this, which is how death is distinguished from an ordinary delivery failure without parsing messages.
Functions ¶
func DomainPanic ¶
DomainPanic builds the error a transport returns when a domain handler panicked and the worker contained it.
func EncodeReply ¶
func EncodeReply(parseReply ReplyEnvelope) ([]byte, error)
EncodeReply renders a reply for postMessage.
func EncodeRequest ¶
EncodeRequest renders a request for postMessage.
func Optimistic ¶
func Optimistic[R any]( parseCtx context.Context, parseApply func(), parseRollback func(), parseInvoke func(context.Context) (R, error), ) (R, error)
Optimistic applies a local change immediately, runs a command, and undoes the local change if the command fails.
The rollback condition is the point, and it is not "any error". A failure that MAY have applied must not be rolled back: undoing a change the domain actually made would leave the UI showing state the domain does not have, and the next published delta would silently contradict it. Those cases keep the optimistic state and are reported so the caller can reconcile — usually by waiting for the authoritative projection.
The UI pattern this supports: apply, keep, and let the projection correct you. Roll back only when you know nothing happened.
func Rejection ¶
Rejection builds the error a transport returns when the domain refused a command, preserving the domain's own message.
Constructors rather than a documented convention, because "wrap ErrRejected" is exactly the kind of instruction that gets followed on three of four call sites — and the fourth silently becomes a retryable transport failure.
func WorkerDeath ¶
WorkerDeath builds the error a transport returns when its worker terminated.
Types ¶
type Bootstrap ¶
type Bootstrap struct {
// Keys are the row keys in published order.
Keys []Key `json:"k,omitempty"`
// Payloads are the encoded rows, parallel to Keys.
Payloads [][]byte `json:"p,omitempty"`
// Complete reports whether these rows are the whole projection.
//
// False means the server sent a prefix — the first screenful of a large
// table — and the client should expect more. A client that assumed
// completeness would stop paginating at the fold.
Complete bool `json:"c,omitempty"`
}
Bootstrap is a projection's initial contents, serialized for transport with server-rendered markup.
The field names are short because this is embedded in HTML and paid for on every page load. The row order is the published order; keys and payloads are parallel arrays rather than a slice of structs for the same reason — it avoids repeating two field names per row.
func BuildBootstrap ¶
func BuildBootstrap[T any](parseKeys []Key, parseRows []T, parseEncode func(T) ([]byte, error), parseComplete bool) (Bootstrap, error)
BuildBootstrap builds a bootstrap from rows the server already has.
Encoding is the caller's, matching the Decoder the client will hydrate with. A mismatch between them is the one failure this cannot catch, and it surfaces immediately at hydration rather than later.
type Codec ¶
type Codec interface {
Encode(parseValue any) ([]byte, error)
Decode(parseData []byte, parseTarget any) error
}
Codec encodes a command's arguments and decodes its result.
Separate from the command declaration so the same command can be carried by different encodings — JSON in development, a compact form in production — without touching the call sites.
type Command ¶
Command is a typed command declaration.
The type parameters are the contract: A is what callers pass, R is what they get back, and both are checked at every call site.
func Define ¶
Define declares a command with its argument and result types.
Call it once, at package scope, and use the returned value everywhere. The name is the only string in the arrangement.
type CommandClient ¶
type CommandClient interface {
// Send delivers a command and returns its encoded result.
Send(parseCtx context.Context, parseName string, parseRequest []byte) ([]byte, error)
}
CommandClient carries an encoded command to the domain worker.
An interface so commands are testable without a worker, and so the same declarations work over any transport.
type CommandError ¶
type CommandError struct {
Kind FailureKind
Command string
// Attempts is how many times the command was sent, so a caller can tell a
// first-try failure from an exhausted retry policy.
Attempts int
Err error
}
CommandError is a classified command failure.
func (*CommandError) Error ¶
func (parseErr *CommandError) Error() string
func (*CommandError) Unwrap ¶
func (parseErr *CommandError) Unwrap() error
type Declared ¶
type Declared interface {
Name() string
}
Declared is a command declaration erased to its name, so a heterogeneous set of commands can be verified together.
type Decoder ¶
Decoder turns a published payload into a value.
Decoding is the caller's because the payload format is the caller's; the engine that produced it treats payloads as opaque bytes.
type FailureKind ¶
type FailureKind uint8
FailureKind classifies why a command failed.
const ( // FailureRejected means the domain received the command and refused it. FailureRejected FailureKind = iota // FailureWorkerDied means the worker terminated. The command may or may not // have applied before it went. FailureWorkerDied // FailureTimedOut means no answer arrived in time. The work may still be // running. FailureTimedOut // FailureCancelled means the caller withdrew the command. FailureCancelled // FailureTransport means the message never reached the worker, so the // command definitely did not apply. FailureTransport )
func Classify ¶
func Classify(parseErr error) FailureKind
Classify exposes the classification for callers holding an error from a plain, unwrapped client.
func (FailureKind) MayHaveApplied ¶
func (parseKind FailureKind) MayHaveApplied() bool
MayHaveApplied reports whether the command might have taken effect.
This is the question a UI actually needs answered, and the reason the kinds exist. A failure that definitely did not apply can be retried freely and shown as "nothing happened"; one that may have applied must either be retried idempotently or reconciled against the domain before anything is shown.
func (FailureKind) Retryable ¶
func (parseKind FailureKind) Retryable() bool
Retryable reports whether retrying the same command could produce a different answer.
A rejection cannot: the domain will say the same thing. A cancellation cannot: the caller withdrew it.
func (FailureKind) String ¶
func (parseKind FailureKind) String() string
type Key ¶
Key identifies one row, re-exported from delta so a caller reading a projection does not have to import the publication engine as well.
type Options ¶
type Options struct {
// Resident caps how many rows are held on the render thread. Zero uses
// DefaultResident. A negative value means unbounded, which is supported but
// deliberately awkward to ask for.
Resident int
}
Options configure a projection.
type Policy ¶
type Policy struct {
// Timeout bounds one attempt. Zero means no timeout.
Timeout time.Duration
// MaxAttempts is the total number of attempts, including the first. Zero or
// one means no retry.
MaxAttempts int
// Backoff is the delay before the second attempt; it doubles thereafter.
// Zero retries immediately, which is rarely right against a restarting
// worker.
Backoff time.Duration
// RetryMayHaveApplied allows retrying failures that might already have taken
// effect.
//
// Off by default, and that default is the safe one: retrying a
// non-idempotent command that already applied duplicates it. Turn it on when
// commands carry stable ids and the domain runtime's replay guarantee (P3.4)
// makes a repeat a no-op.
RetryMayHaveApplied bool
// Sleep is the delay function, injectable so retry tests do not sleep.
Sleep func(parseCtx context.Context, parseDuration time.Duration) error
}
Policy configures timeout and retry behaviour.
type Poster ¶
Poster sends one encoded request toward the worker.
Returns as soon as the message is handed over. It does not and cannot return the reply; that arrives later through Deliver.
type Projection ¶
type Projection[T any] struct { // contains filtered or unexported fields }
Projection is a resident, ordered view of published rows.
Not safe for concurrent use. It belongs to the render thread, which is single-threaded by construction in wasm.
func New ¶
func New[T any](parseDecode Decoder[T], parseOptions Options) (*Projection[T], error)
New creates an empty projection.
func (*Projection[T]) Apply ¶
func (parseProjection *Projection[T]) Apply(parseOps []Op) error
Apply applies published ops in order.
Ops from one publish are a sequence, not a set: an insert may anchor on a key an earlier op in the same batch created. Applying them out of order, or filtering them, breaks that.
func (*Projection[T]) Complete ¶
func (parseProjection *Projection[T]) Complete() bool
Complete reports whether the projection holds the whole dataset rather than a server-sent prefix.
Distinct from Ready: a route can be ready to render — enough rows for the first screen — while more remain. A client that conflated them would stop paginating at the fold.
func (*Projection[T]) DroppedByResidency ¶
func (parseProjection *Projection[T]) DroppedByResidency() int
DroppedByResidency reports how many rows were refused because the residency cap was reached.
Non-zero means the projection is a prefix of what the worker published, and any local count or aggregate computed from it is a lower bound. Surfacing this is the difference between a bounded view and a wrong one.
func (*Projection[T]) Filter ¶
func (parseProjection *Projection[T]) Filter(parsePredicate func(T) bool) []Entry[T]
Filter returns rows matching a predicate, evaluated locally.
This is P3.7 criterion (c): the §1.2 filter keystroke performs zero domain round-trips. Nothing here sends a message, and that is asserted by message count in the tests rather than left as a claim.
func (*Projection[T]) Get ¶
func (parseProjection *Projection[T]) Get(parseKey Key) (T, bool)
Get returns one row by key.
func (*Projection[T]) Hydrate ¶
func (parseProjection *Projection[T]) Hydrate(parseBootstrap Bootstrap) error
Hydrate seeds a projection from a server bootstrap.
After it returns, Ready() is true — including for a bootstrap carrying zero rows, which is the case that makes Ready() worth having at all: a route with no results is loaded, not loading.
Hydrating a projection that already holds rows is refused. It would either duplicate keys or silently drop the bootstrap, and both are worse than an error at a point where the caller can still do something about it.
func (*Projection[T]) Len ¶
func (parseProjection *Projection[T]) Len() int
Len reports how many rows are resident.
func (*Projection[T]) MarkReady ¶
func (parseProjection *Projection[T]) MarkReady(parseComplete bool)
MarkReady declares a projection loaded without a bootstrap.
For a client-rendered route that has finished its first fetch. Without it, only server-rendered routes could ever be Ready, and a UI branching on Ready() would show a spinner forever on every other route.
func (*Projection[T]) Ready ¶
func (parseProjection *Projection[T]) Ready() bool
Ready reports whether the projection holds its initial contents.
The distinction this exists for: Ready() is NOT Len() > 0. A projection with zero rows may be loaded — a search with no matches, a new account with no transactions — and a UI that reads emptiness as loading shows a spinner forever on exactly those routes. It may equally be unloaded, and a UI that reads emptiness as loaded shows "nothing here" before anything was fetched.
func (*Projection[T]) Reset ¶
func (parseProjection *Projection[T]) Reset()
Reset drops every resident row, for a projection whose subject changed.
func (*Projection[T]) Rows ¶
func (parseProjection *Projection[T]) Rows() []Entry[T]
Rows returns the resident rows in published order.
The returned slice aliases the projection's own storage: it is read-only to the caller, and the next Apply may change it. Copying on every read would allocate a full projection per frame, which is the cost this package exists to avoid.
func (*Projection[T]) Window ¶
func (parseProjection *Projection[T]) Window(parseOffset int, parseCount int) []Entry[T]
Window returns a contiguous slice of rows, for virtualized rendering.
Out-of-range bounds are clamped rather than refused: a scroll position is naturally transient and can outrun a projection that just shrank, and panicking on a stale scroll offset would be a worse answer than an empty window.
type Registry ¶
type Registry struct {
// contains filtered or unexported fields
}
Registry is the worker-side set of command names that exist.
It closes the one gap the type system cannot: the compiler checks that a caller spelled the VARIABLE correctly, but not that the name string inside the declaration matches a handler the worker actually has. Verifying the whole set once at startup turns that into a boot-time failure with a list, rather than a runtime failure on the first click of a rarely-used feature.
func NewRegistry ¶
NewRegistry creates a registry from the names the worker handles.
type ReplyEnvelope ¶
type ReplyEnvelope struct {
RequestID uint64 `json:"rid"`
Kind ReplyKind `json:"k"`
Payload []byte `json:"p,omitempty"`
Message string `json:"m,omitempty"`
}
ReplyEnvelope is one command's result on the wire.
type ReplyKind ¶
type ReplyKind string
ReplyKind tells the client which of its constructors to use.
Carried explicitly rather than inferred from whether an error string is present, because "rejected", "panicked", and "the worker is dying" need different handling and only one of them is retryable. Inferring would collapse them into one, which is what FailureKind exists to prevent.
type RequestEnvelope ¶
type RequestEnvelope struct {
// RequestID is what the reply must echo. Correlation depends on it entirely,
// so a worker that drops it produces replies nobody can match.
RequestID uint64 `json:"rid"`
Command string `json:"cmd"`
// Payload is the encoded arguments, opaque to the envelope.
Payload []byte `json:"p,omitempty"`
}
RequestEnvelope is one command on the wire.
func DecodeRequest ¶
func DecodeRequest(parseBytes []byte) (RequestEnvelope, error)
DecodeRequest parses a request on the worker side.
type Resilient ¶
type Resilient struct {
// contains filtered or unexported fields
}
Resilient wraps a CommandClient with timeout, retry, and failure classification.
func NewResilient ¶
func NewResilient(parseInner CommandClient, parsePolicy Policy) (*Resilient, error)
NewResilient wraps a client.
type WorkerClient ¶
type WorkerClient struct {
// contains filtered or unexported fields
}
WorkerClient correlates postMessage replies back to their requests.
Safe for concurrent use: a render-thread caller issues requests while the message-event callback delivers replies, and in wasm those are different turns of the same thread rather than different threads — but the callback can still interleave between any two statements here.
func NewWorkerClient ¶
func NewWorkerClient(parsePoster Poster) (*WorkerClient, error)
NewWorkerClient wraps a poster.
func (*WorkerClient) Deliver ¶
func (parseClient *WorkerClient) Deliver(parseRequestID uint64, parsePayload []byte, parseWorkerErr error) bool
Deliver hands a worker reply back to whoever is waiting for it.
Called from the message-event callback. A reply for an unknown id is dropped and reported as such rather than treated as an error: it is the normal outcome for a request that timed out or was cancelled while in flight, and making it an error would fill a console with noise from working code.
func (*WorkerClient) DeliverEncodedReply ¶
func (parseClient *WorkerClient) DeliverEncodedReply(parseBytes []byte) (bool, error)
DeliverEncodedReply decodes a reply and routes it to its waiting request.
The single place that maps a wire kind to a failure kind, so the mapping exists once rather than in every transport. Reports whether anything was waiting, which is false for a request that timed out in flight — a normal outcome, not an error.
func (*WorkerClient) DeliverPanic ¶
func (parseClient *WorkerClient) DeliverPanic(parseRequestID uint64, parseMessage string) bool
DeliverPanic hands back a contained domain panic.
func (*WorkerClient) DeliverRejection ¶
func (parseClient *WorkerClient) DeliverRejection(parseRequestID uint64, parseMessage string) bool
DeliverRejection hands back a domain refusal, preserving its message.
Separate from Deliver so a transport does not have to remember to wrap with Rejection — the classification that decides whether a retry policy will spend attempts on it depends on that wrapping, and "remember to wrap" is the kind of instruction followed on three call sites out of four.
func (*WorkerClient) InFlight ¶
func (parseClient *WorkerClient) InFlight() int
InFlight reports how many requests are awaiting a reply.
Exists so a leak is observable. A correlation table that grows for the life of a session is the classic failure of this shape, and it is invisible without a count.
func (*WorkerClient) Send ¶
func (parseClient *WorkerClient) Send(parseCtx context.Context, parseName string, parseRequest []byte) ([]byte, error)
Send issues a command and waits for its reply.
func (*WorkerClient) WorkerDied ¶
func (parseClient *WorkerClient) WorkerDied(parseReason string) int
WorkerDied fails every in-flight request and refuses new ones.
Called when the worker's error or terminate event fires. Without it, every request outstanding at the moment of death waits for its own timeout — and a caller with no timeout waits forever, which presents as the app hanging rather than as the worker crashing.
Returns how many requests were failed, which is worth surfacing: a death that takes twenty in-flight commands with it is a different event from one that takes none.
func (*WorkerClient) WorkerRestarted ¶
func (parseClient *WorkerClient) WorkerRestarted()
WorkerRestarted clears the dead state after a replacement worker is ready.
Requests issued before the restart are NOT retried here. Whether a command that may have applied can safely be reissued is the caller's decision and depends on whether it is idempotent — exactly the judgement FailureKind exists to inform, and not one a transport should make on its own.