rows

package
v0.3.0 Latest Latest
Warning

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

Go to latest
Published: Sep 28, 2026 License: MIT Imports: 6 Imported by: 0

Documentation

Overview

Package rows is PayCLI's local editor for an array of objects: the rows of a Payload `blocks` field, or of a plain `array` field.

It exists because the one edit a content agent makes most often — "move the CTA above the media block", "drop the third block" — has no API of its own. Payload's REST surface can only replace the whole array, so the edit is a read-modify-write round trip that every caller re-invents with jq and a temp file, and gets wrong in the same two ways: the anchor index shifts once the moved row is lifted out, and a row is addressed by a position the caller read from a stale listing.

Nothing here performs I/O, reads the clock or consults a schema. A Selector is parsed from a string and matched against a []any; a Path (path.go) reaches an array at any depth of a decoded document — through a group's rows, a grid inside the group — using only the document's own shape; every mutation returns a NEW value and never aliases the input. That makes each rule below a table test, and it keeps the index arithmetic — the part that is actually hard — in one place instead of in six command files.

Index

Constants

View Source
const (
	// RowKindBlocks is an array whose rows carry a blockType.
	RowKindBlocks = "blocks"
	// RowKindArray is an array whose rows carry none: a plain array field.
	RowKindArray = "array"
)

Row kinds, as ArrayRowKind reports them.

View Source
const (
	KeyID        = "id"
	KeyBlockType = "blockType"
	KeyBlockName = "blockName"
)

Payload's own keys on a blocks row. They are protocol, not content: `blockType` decides which block the row IS, `id` is server-generated, and `blockName` is the admin-UI label. They are named here because the selector grammar addresses rows BY them.

View Source
const PathGrammar = `KEY ( .KEY | .N | [SELECTOR] )...   e.g. layout[2].blocks, layout[id:6ab8…].blocks`

PathGrammar is the one-line statement of the field-path grammar, shared by every help text that documents --field.

View Source
const SelectorGrammar = `N | -N | first | last | id:VALUE | name:VALUE | type:SLUG | type:SLUG[N]`

SelectorGrammar is the one-paragraph description of the grammar, shared by every command's help so the five forms are documented identically in all of them.

Variables

View Source
var SelectorForms = []string{
	"3           the row at index 3 (0-based)",
	"-1          the last row; -2 the second to last",
	"first,last  sugar for 0 and -1",
	"id:67f3a1   the row whose `id` is 67f3a1 — the only stable handle across edits",
	"name:Hero   the row whose `blockName` is exactly Hero (never a substring)",
	"type:cta    EVERY row whose `blockType` is cta",
	"type:cta[1] the second cta row (0-based), which is always exactly one row",
}

SelectorForms is the per-form explanation for help output.

Functions

func ArrayRowKind added in v0.3.0

func ArrayRowKind(list []any) string

ArrayRowKind reads what kind of field list is from its rows: RowKindBlocks when every row carries a blockType, RowKindArray when none does, "" when the list is empty, mixed, or holds only `{}` rows (an orphan left behind by a nested restructure says nothing either way).

func ClearPath added in v0.3.0

func ClearPath(obj map[string]any, key string) error

ClearPath clears a path inside obj, in place: the value becomes what makes Payload store "empty" (ClearedValue). It does NOT delete the key. Payload matches a row by its id and keeps the stored value of every key the row omits, so a deleted key is written back unchanged — `pay blocks set --unset blockName | pay apply` used to answer 2xx and change nothing.

A key that does not exist is not an error: `--unset` states a desired end state, and it is already true. A row selector that addresses no row IS an error, because that is a typo in the path rather than a state, and a row cannot be cleared — removing one is `pay blocks rm --field …`.

func ClearedValue added in v0.3.0

func ClearedValue(v any) any

ClearedValue is the value that clears a field holding v, verified against Payload 3.87 (Postgres) on localhost:

  • an array, blocks or hasMany field: [] (null answers 500);
  • a named group (a plain object): every sub-field cleared, because null answers 500 and {} keeps every stored sub-field;
  • anything else — a scalar, a rich-text editor state, a relationship or upload value (an id, a populated document, a {relationTo, value}): null.

func Copy

func Copy(list []any, src int, a Anchor) ([]any, int, error)

Copy duplicates the row at src into the destination a names, stripping every server-generated `id` on the way — at the top level and at every depth.

The strip is not optional and not a flag. Payload treats a row whose `id` matches an existing row as THAT row: a duplicate that kept its ids does not add a block, it silently rewrites the original and drops one of the two.

func HasBlockRow added in v0.3.0

func HasBlockRow(list []any) bool

HasBlockRow is hasBlockRow for callers outside the package.

func Insert

func Insert(list []any, row any, a Anchor) ([]any, int, error)

Insert puts row at the destination a names and returns the new array plus the index it landed at.

The destination space has len+1 slots (0 .. len), not len: appending to a 3-row array is index 3. A negative `--at` counts back through those slots, so `--at -1` is the last position — consistent with a negative selector meaning the last row.

func IsEditorState added in v0.3.0

func IsEditorState(obj map[string]any) bool

IsEditorState reports a Lexical rich-text value: an object whose `root` is a node of type "root". Its `children` are arrays of objects that look exactly like rows, so every walk over a document must stop at one.

func LooksLikeBlocks

func LooksLikeBlocks(list []any) bool

LooksLikeBlocks reports whether every object row carries a `blockType`, which is what distinguishes a blocks field from a plain `array` field in a document PayCLI has no schema for. An empty array is not blocks: there is nothing to read it from, and guessing would name a field the caller never asked for.

func Lookup added in v0.3.0

func Lookup(doc map[string]any, loc Location) (any, bool)

Lookup returns the value at a resolved location, false when the document no longer has it.

func Move

func Move(list []any, src int, a Anchor) ([]any, int, error)

Move relocates the row at src to the destination a names and returns the new array plus the index the row ended at.

The anchor is resolved against the array WITH the moved row still in it, and the destination is then recomputed against the array WITHOUT it. That is the whole reason this function exists: "move row 0 after row 3" naively becomes insert-at-4 on a 3-row remainder and lands the row past its anchor. Every hand-written version of this loop gets it wrong once.

func ObservedArrayField added in v0.3.0

func ObservedArrayField(doc any, blockType, rel string) bool

ObservedArrayField reports whether any row anywhere in doc whose blockType is blockType carries an array at rel (a dotted key path inside the row). It is the schema-free answer to "does a group have a `blocks` field?": on a site whose GraphQL introspection is off, the other groups on the page are the only evidence there is.

func ObservedRowKind added in v0.3.0

func ObservedRowKind(doc any, blockType, rel string) string

ObservedRowKind is ArrayRowKind over the arrays at rel of every row of blockType anywhere in doc, "" when they disagree or none has a row.

func Remove

func Remove(list []any, idx []int) []any

Remove deletes the rows at idx and returns the new array. Indices may arrive in any order and may repeat.

func Replace added in v0.3.0

func Replace(doc map[string]any, loc Location, value any) (map[string]any, error)

Replace returns a copy of doc with value stored at loc. Every object and array on the way is copied, never mutated: the decoded input is still what the error paths print. An object hop that is absent is created (a top-level layout that is null has to be fillable); a row hop must exist, because a location is only ever built by resolving it.

func ResolveMany

func ResolveMany(list []any, sel Selector, field string) ([]int, error)

ResolveMany matches sel and insists on at least one row. Several is fine.

func ResolveOne

func ResolveOne(list []any, sel Selector, field string) (int, error)

ResolveOne matches sel and insists on exactly one row.

func Set

func Set(list []any, idx int, patch map[string]any, unset []string) ([]any, error)

Set applies a field patch to the row at idx and returns the new array. Keys are paths into the row in the ParsePath grammar — `blockName`, `link.url`, `links.0.label`, `links[id:6ab8…].label`. A value in patch is written as given (nil writes null); every key in unset is cleared (ClearPath), never deleted.

A path that passes through an array addresses one of its rows and never replaces the array: before this rule `--set links.0.label=Buy` turned the `links` array into an object with a key "0", which Payload then dropped.

func SetPath added in v0.3.0

func SetPath(obj map[string]any, key string, value any) error

SetPath assigns a path inside obj, in place. obj must be the caller's own copy.

An object key that is absent or null is created as an object (a group the row has not filled yet). An intermediate scalar is replaced by an object, because the alternative is to fail on a path the caller can see is right in the schema. An ARRAY is never replaced: the next step must address one of its rows, and a selector that addresses none is a *NoMatchError — creating an array row out of thin air is `pay blocks add --field …`'s job.

func StripIDs

func StripIDs(v any) any

StripIDs removes the server-generated row ids from a row: the row's own `id` and the `id` of every row of every array nested inside it, at every depth. Exported because `pay blocks add` needs it for a row pasted out of another document, and `pay blocks new --from-example` for a real instance.

Only ROW ids are stripped — the ids Payload matches rows by, which a copy must not reuse. Two other kinds of `id` are references and are kept, exactly as the admin UI's "duplicate row" keeps them:

  • a relationship or upload value (an expanded document: `id` plus `createdAt`/`updatedAt`, or a polymorphic `{relationTo, value}`), whose `id` IS the reference — on klixpert stage a Lexical block's upload field reads back populated even at depth 0, and stripping its `id` left an object that names no upload at all;
  • anything inside a Lexical editor state: a block node's `fields.id`, a link or upload node's `id`. Payload's lexical hooks key their node map by those ids and skip a node that has none, and the upload validator reads `value.id`. They are scoped to their own rich-text value, so a copy that keeps them does not collide with the original.

A group object (a map under a key) has no id of its own; its arrays are walked like the row's.

func Types

func Types(list []any) []string

Types returns the distinct blockTypes present, sorted. Used to tell a caller what a failed `type:` selector could have matched.

Types

type AmbiguousError

type AmbiguousError struct {
	Sel     Selector
	Field   string
	Matched []int
	Have    []Summary
}

AmbiguousError is returned when a selector addressed several rows but the operation acts on exactly one.

func (*AmbiguousError) Error

func (e *AmbiguousError) Error() string

type Anchor

type Anchor struct {
	Mode  Mode
	Index int
	Sel   Selector
	// Label is how the caller SPELLED the destination, when that is clearer
	// than the mode it compiled to. `--last` compiles to index -1, and an op
	// log reading "(index -1)" makes a reader work out what the caller wrote;
	// "(last)" does not. Empty means "describe the mode".
	Label string
}

Anchor is a destination. Before/After are expressed relative to another ROW rather than to an index on purpose: an index read out of a listing is stale the moment anything else in the pipeline edits the array, while "after the media block" stays true.

func (Anchor) Describe

func (a Anchor) Describe() string

Describe renders the anchor for an error message or an op log. It returns "" when the destination is already stated by the sentence around it — an index the caller can read off the op's own text.

type ArrayInfo added in v0.3.0

type ArrayInfo struct {
	// Kind is "blocks" (rows carry a blockType) or "array" (a plain array
	// field: rows carry none, and `pay blocks add -` adds one).
	Kind string `json:"kind,omitempty"`
	// Accepts are the blockTypes the field takes, with where that list came
	// from; an observed list is not everything the field allows.
	Accepts              []string `json:"accepts,omitempty"`
	AcceptsSource        string   `json:"accepts_source,omitempty"`
	AcceptsAuthoritative *bool    `json:"accepts_authoritative,omitempty"`
}

ArrayInfo is what is known about one nested array of a listed row.

type Hop added in v0.3.0

type Hop struct {
	// Key is set for an object hop.
	Key string
	// Row reports an array hop; Index is then the row's position NOW.
	Row   bool
	Index int
	// Selector is the shortest selector that addresses this row among its
	// siblings — an `id:` whenever it has one — so a location can be printed in
	// a form that survives another stage renumbering the array.
	Selector string
	// BlockType is the row's blockType, "" when it has none.
	BlockType string
}

Hop is one resolved step: an object key, or a row of an array.

type Kind

type Kind string

Kind is the form a Selector took. The grammar is closed — five forms, no escapes, no wildcards — for the same reason `--path` is not jq: an open grammar guarantees a caller sends something plausible that this package does not implement, and gets an unspecified failure instead of a named one.

const (
	// KindIndex is a bare integer: `0`, `3`, `-1`. Negative counts from the
	// end, so `-1` is the last row.
	KindIndex Kind = "index"
	// KindID is `id:<value>` — the row's server-generated `id`.
	KindID Kind = "id"
	// KindName is `name:<value>` — an exact `blockName` match.
	KindName Kind = "name"
	// KindType is `type:<slug>` — every row with that `blockType`, or the
	// n-th one with `type:<slug>[n]`.
	KindType Kind = "type"
)

type Location added in v0.3.0

type Location struct {
	Hops []Hop
}

Location is a resolved path: the concrete hops from the document root.

func KeyLocation added in v0.3.0

func KeyLocation(dotted string) Location

KeyLocation is the location of a plain dotted object path, for a field that was chosen rather than parsed.

func (Location) Last added in v0.3.0

func (l Location) Last() Hop

Last returns the final hop, the zero Hop for an empty location.

func (Location) Nested added in v0.3.0

func (l Location) Nested() bool

Nested reports whether the location passes through an array row.

func (Location) Parent added in v0.3.0

func (l Location) Parent() (row Location, rel string, ok bool)

Parent returns the location of the innermost row l passes through (ending in that row's hop) and the key path from that row to the end of l ("blocks", "settings.items"). ok is false for a location through no row.

func (Location) Pattern added in v0.3.0

func (l Location) Pattern() string

Pattern is the location with every row erased: `layout[].blocks`. Every group on the page shares it, which is what a per-field schema is keyed by.

func (Location) Root added in v0.3.0

func (l Location) Root() string

Root is the unit Payload's REST API can write: the object-key prefix up to the first array row. `layout[2].blocks` is written as the whole of `layout`, because Payload has no per-row endpoint — a PATCH replaces the array.

func (Location) Stable added in v0.3.0

func (l Location) Stable() string

Stable is the location with each row's shortest selector: `layout[id:6ab8…].blocks`. It is what a later stage should be given, because an index is stale the moment another stage inserts or removes a row.

func (Location) String added in v0.3.0

func (l Location) String() string

String is the location with row positions: `layout[2].blocks`. It is exact for the document it was resolved against and stale after the next edit.

func (Location) With added in v0.3.0

func (l Location) With(h Hop) Location

With returns a copy of l with one more hop.

type Mode

type Mode string

Mode is how an Anchor names a destination.

const (
	// ModeAppend puts the row after every existing row. It is the default for
	// an insert, and the only mode that needs no argument.
	ModeAppend Mode = "append"
	// ModeIndex is `--at N` / `--to N`, a literal destination index.
	ModeIndex Mode = "index"
	// ModeBefore is `--before SEL`: end up immediately above that row.
	ModeBefore Mode = "before"
	// ModeAfter is `--after SEL`: end up immediately below that row.
	ModeAfter Mode = "after"
)

type NoMatchError

type NoMatchError struct {
	Sel   Selector
	Field string
	Have  []Summary
}

NoMatchError is returned when a selector that had to address a row addressed none. It carries the rows' summary so the caller can print what DOES exist instead of only what does not.

func (*NoMatchError) Error

func (e *NoMatchError) Error() string

type Node added in v0.3.0

type Node struct {
	// Depth is 0 for a row of the listed field, 1 for a row nested in one of
	// those, and so on.
	Depth int `json:"depth"`
	// Path is the row's own location with positions: `layout[2].blocks[1]`.
	Path string `json:"path"`
	// Field is the exact --field value that addresses the array this row is
	// in, written with stable selectors: `layout[id:6ab8…].blocks`. It is
	// empty (and omitted) for rows that arrived bare, with no document to
	// address them in.
	Field string `json:"field,omitempty"`
	Summary
	// Nested maps each array inside the row (a key path relative to it) to its
	// row count. An empty array is listed too: an empty nested blocks field is
	// exactly where a caller wants to `pay blocks add`.
	Nested map[string]int `json:"nested,omitempty"`
	// Arrays describes each array in Nested (same keys) as far as anything
	// knows: whether it is a blocks or a plain array field, and which
	// blockTypes it takes. Filled by the caller, which holds the schema and
	// the census; an array nothing knows about has no entry.
	Arrays map[string]ArrayInfo `json:"arrays,omitempty"`
	// Value is the row itself, for a caller that fills Summary.Row (--long).
	// It is never serialised on its own.
	Value any `json:"-"`
	// At is the row's own location, for a caller that resolves its nested
	// arrays.
	At Location `json:"-"`
}

Node is one row of a recursive listing.

func DocTree added in v0.3.0

func DocTree(doc map[string]any) []Node

DocTree lists every row of every row array in a whole document, at every depth, in document order (a row, then the rows nested in it) — what `pay outline` prints. Tree lists one array; DocTree finds the arrays itself: every top-level field (and every group field, through nested objects) that holds an array of objects, walked the way Tree walks a row's nested arrays (never into rich text or an expanded relationship).

Arrays whose rows carry a blockType (blocks fields: `layout`) come first, in key order, then the other row arrays (`breadcrumbs`, `hero.links`), so a page's outline starts with its content.

func FindRow added in v0.3.0

func FindRow(doc map[string]any, id string) (Node, bool)

FindRow looks for the row whose id is id anywhere in doc (every row array at every depth, never inside rich text or an expanded relationship) and returns the node describing it: its positional Path and the stable Field of the array it is in. ok is false when no row has that id.

func Tree added in v0.3.0

func Tree(list []any, at Location) []Node

Tree lists every row of list and, recursively, every row of every array nested inside those rows, in document order (a row, then its children). at is the location of list itself.

type Path added in v0.3.0

type Path struct {
	Raw   string
	Steps []Step
}

Path is a parsed field path.

func ParsePath added in v0.3.0

func ParsePath(raw string) (Path, error)

ParsePath parses a field path. It never consults a document.

type PathError added in v0.3.0

type PathError struct {
	Kind PathErrorKind
	// Path is the path as the caller wrote it.
	Path string
	// At is where the walk stopped. For PathAbsent it INCLUDES the absent key,
	// so At.String() names the field that is missing.
	At Location
	// Rest are the steps after At, not walked.
	Rest []Step
	// InRow reports that At passes through an array row: the absent key was
	// looked up on a block row, not on the document.
	InRow bool
	// Null reports a key that is present with a null value.
	Null bool
	// Container is the object the missing key was looked up on (PathAbsent) or
	// the value found (PathNotArray), for the caller to describe.
	Container any
	// Have lists what IS there: the object's keys for PathAbsent, a row's
	// array-valued keys for PathNotArray. Sorted.
	Have []string
	// Detail is a one-clause explanation for the message.
	Detail string
}

PathError is a path that did not resolve, with enough context to say what IS there instead.

func NotArrayError added in v0.3.0

func NotArrayError(p Path, r Resolved) *PathError

NotArrayError builds the error for a resolved value that is not an array, listing the arrays inside it when it is an object — `layout[2]` is a row, and the caller almost certainly meant one of its nested fields.

func (*PathError) Error added in v0.3.0

func (e *PathError) Error() string

func (*PathError) NextLooksLikeRow added in v0.3.0

func (e *PathError) NextLooksLikeRow() bool

NextLooksLikeRow reports whether the first unwalked step can only address a row — which turns "the key is absent" into "the parent row does not exist".

func (*PathError) RestAreKeys added in v0.3.0

func (e *PathError) RestAreKeys() bool

RestAreKeys reports whether every unwalked step is a plain key, so the whole remainder can be created as nested objects.

type PathErrorKind added in v0.3.0

type PathErrorKind string

PathErrorKind names why a path did not resolve.

const (
	// PathAbsent is a key that is not there (or is null) on the object the
	// walk reached.
	PathAbsent PathErrorKind = "absent"
	// PathNotContainer is a walk that reached a scalar and had steps left.
	PathNotContainer PathErrorKind = "not_container"
	// PathKeyOnArray is a bare step on an array that is not a row selector.
	PathKeyOnArray PathErrorKind = "key_on_array"
	// PathRowOnObject is a [selector] step on an object.
	PathRowOnObject PathErrorKind = "row_on_object"
	// PathNotArray is a path that resolved to something other than an array.
	PathNotArray PathErrorKind = "not_array"
)

type Resolved added in v0.3.0

type Resolved struct {
	At Location
	// Value is what the path addresses. It is nil for a key that is present
	// and null.
	Value any
}

Resolved is a path that reached its end.

func Resolve added in v0.3.0

func Resolve(doc map[string]any, p Path) (Resolved, error)

Resolve walks p through doc. It returns the location and the value there, or a typed error: *NoMatchError / *AmbiguousError when a row selector did not address exactly one row (their Field is the array's location), *PathError for everything else. It never requires the end value to be an array; the caller decides what a non-array means.

type Selector

type Selector struct {
	// Raw is the string the caller wrote, kept verbatim for error messages.
	Raw string
	// Kind is which of the five forms was used.
	Kind Kind
	// Index is set for KindIndex and may be negative.
	Index int
	// Value is the id, blockName or blockType being matched.
	Value string
	// Nth is the `[n]` subscript of `type:cta[1]`, nil when absent. It is
	// separate from Index so that "the second cta" and "row 2" cannot be
	// confused by a reader of this struct.
	Nth *int
}

Selector addresses one or more rows. The zero value is unusable; build one with ParseSelector.

func ParseSelector

func ParseSelector(s string) (Selector, error)

ParseSelector parses one selector. It never consults the rows, so a selector can be validated before a document has been read.

func (Selector) Match

func (s Selector) Match(list []any) []int

Match returns the indices sel addresses, ascending. An empty result is not an error here: the caller decides whether "no match" is fatal, because `rm` on an already-absent row and `mv` of a row that must exist want opposite answers.

type Step added in v0.3.0

type Step struct {
	// Text is the segment without its brackets.
	Text string
	// Bracket reports `[…]`. A bracketed step is always a row selector, and its
	// selector is parsed eagerly so a malformed one fails before any document
	// is read.
	Bracket bool
	// Sel is the pre-parsed selector of a bracketed step.
	Sel *Selector
}

Step is one segment of a parsed path, exactly as the caller wrote it. Whether it is an object key or a row selector is decided only while walking, by what the document holds at that point.

func (Step) String added in v0.3.0

func (s Step) String() string

type Summary

type Summary struct {
	Index     int    `json:"index"`
	ID        string `json:"id,omitempty"`
	BlockType string `json:"block_type,omitempty"`
	BlockName string `json:"block_name,omitempty"`
	// Selector is the shortest selector that addresses THIS row and no other.
	// It is emitted rather than left to the caller because the shortest one is
	// not the obvious one: an index is stale after the next edit in the pipe,
	// so an `id:` is preferred whenever the row has an id.
	Selector string `json:"selector"`
	// Fields is the row's own keys, minus Payload's plumbing, so a caller can
	// see what `pay blocks set` could address without printing the whole row.
	Fields []string `json:"fields,omitempty"`
	// Row is the complete row. It is only filled in for `--long`.
	Row any `json:"row,omitempty"`
}

Summary is one row as `pay blocks ls` reports it: enough to choose a row and to write a selector for it, and nothing else.

func Summarize

func Summarize(list []any) []Summary

Summarize renders every row. It never fails: a row that is not an object still gets an entry, because a listing that silently skips rows would make the printed indices disagree with the real ones.

Jump to

Keyboard shortcuts

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