Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
name: CI

on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:

permissions:
contents: read

env:
GOWORK: "off"
CGO_ENABLED: "0"

jobs:
test:
name: Build + test + 100% coverage gate
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-go@v5
with:
go-version: "1.26.4"
check-latest: true
cache: true

- name: gofmt
run: |
# The generated protobuf sources under datapb/ are gofmt-clean as
# emitted; everything else must be too.
unformatted=$(gofmt -l .)
[ -z "$unformatted" ] || {
echo "::error::not gofmt-clean:"; echo "$unformatted"
gofmt -d $unformatted; exit 1; }

- name: go vet
run: go vet ./...

- name: Test with coverage
run: go test -coverprofile=coverage.out ./...

- name: Assert 100% statement coverage (excluding generated code)
run: |
# datapb/ is protoc-generated (DO NOT EDIT); its getters/reflection
# are not our statements to test, so drop them from the profile before
# gating. Every hand-written statement in data/ and grpcproxy/ must be
# 100% covered — no rounding.
grep -v 'github.com/go-widgets/data/datapb/' coverage.out > coverage.filtered
go tool cover -func=coverage.filtered
below=$(go tool cover -func=coverage.filtered | awk '$NF != "100.0%"')
if [ -n "$below" ]; then
echo "::error::coverage below 100% on:"
echo "$below"
exit 1
fi

- name: Race detector (CGO on, for -race only)
env:
CGO_ENABLED: "1"
run: go test -race ./...

- name: Cross-compile smoke (6 64-bit arches + wasm + darwin + windows)
run: |
for arch in amd64 arm64 riscv64 loong64 ppc64le s390x; do
echo "== linux/$arch =="
GOOS=linux GOARCH=$arch go build ./...
done
# The GRPCProxy client compiles to wasm (the server is native-only,
# gated //go:build !js) — this is what lets a browser run the same
# data pipeline.
GOOS=js GOARCH=wasm go build ./...
GOOS=darwin GOARCH=arm64 go build ./...
GOOS=windows GOARCH=amd64 go build ./...
72 changes: 71 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,73 @@
# go-widgets/data

The headless data spine of the go-widgets UI ecosystem. See the initial pull request for the full implementation.
The headless **data spine** of the [go-widgets](https://github.com/go-widgets)
UI ecosystem: a typed record model with validation, a bindable collection with
sort / filter / group / pagination / aggregation, and a **pluggable proxy** so
the *very same* query pipeline runs in-process or against a remote service —
natively and in a browser/wasm build alike.

Pure Go, `CGO=0`, BSD-3-Clause. The core package imports only the standard
library and [`go-widgets/mvvm`](https://github.com/go-widgets/mvvm); nothing here
depends on a GUI.

## Layers

| Piece | What it is |
|-------|-----------|
| `Value` / `Kind` | a comparable typed scalar (string / int / float / bool) |
| `Record` / `Schema` / `Field` / `Rule` | a typed row and its validation |
| `Query` → `Apply` → `View` | the pure query engine: filter, sort, group, page, aggregate |
| `Proxy` | the backend seam: `List` / `Query` / `Mutate` |
| `MemoryProxy` | the in-process reference backend |
| `grpcproxy.Server` / `grpcproxy.Client` | the same contract over gRPC, carried by [`grpc-transports/websocket`](https://github.com/grpc-transports/websocket) |
| `Store[R]` | a typed, bindable collection wired through `mvvm.ObservableList` |

## Why a proxy seam

`Store` talks to a `Proxy`; it never knows whether the data is a `MemoryProxy` in
the same process or a `grpcproxy.Client` reaching a server across a websocket.
Because the query engine (`Apply`) is a pure function of `(records, Query)`, the
client and the server run *the same code* on the same rows — so a `MemoryProxy`
and a `grpcproxy.Client` return **byte-identical** Views.

That equality is asserted directly: `grpcproxy`'s conformance test runs a battery
of sort→filter→group→page→aggregate queries through both proxies and through the
engine, canonicalises every resulting `View`, and requires all three to be
identical byte for byte. The WebSocket transport compiles to `js/wasm`, so the
`grpcproxy.Client` is exactly what a go-widgets wasm app uses to speak this same
service from the browser — no second data path.

## Example

```go
schema := data.Schema{Fields: []data.Field{
{Name: "id", Kind: data.KindInt},
{Name: "name", Kind: data.KindString, Rules: []data.Rule{data.Required("name required")}},
{Name: "salary", Kind: data.KindFloat},
}}
mem, _ := data.NewMemoryProxy(schema, "id",
data.Record{"id": data.Int(1), "name": data.String("ann"), "salary": data.Float(30)},
)

type person struct{ ID int64; Name string; Salary float64 }
store := data.NewStore(mem, data.Codec[person]{
Encode: func(p person) data.Record {
return data.Record{"id": data.Int(p.ID), "name": data.String(p.Name), "salary": data.Float(p.Salary)}
},
Decode: func(r data.Record) person {
return person{ID: r["id"].Int, Name: r["name"].Str, Salary: r["salary"].Float}
},
})
store.SetQuery(data.Query{Sorts: []data.Sort{{Field: "salary", Desc: true}}, Limit: 20})
store.Load(ctx) // runs the query, fills the ObservableList
_ = store.Items() // bind this to a view — it re-renders on change
```

Swap `mem` for a `grpcproxy.Client` and nothing else changes.

## Status

Core `data` and `grpcproxy` are at 100% statement coverage (the generated
`datapb/` protobuf code excepted); CI builds all six 64-bit Go targets plus
`js/wasm`, macOS and Windows, and runs the race detector. Org-conformance
landing/logo/docs are a follow-up.
88 changes: 88 additions & 0 deletions canonical.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
// Copyright (c) 2026 the go-widgets/data authors. All rights reserved.
// Use of this source code is governed by a BSD-3-Clause license that can be
// found in the LICENSE file at the root of this repository.

package data

import (
"sort"
"strconv"
"strings"
)

// Canonical renders a View as a deterministic byte string: the same View value
// always yields the same bytes, and — crucially — a View computed by MemoryProxy
// and the identical View reconstructed from a grpcproxy round-trip encode to the
// SAME bytes. That is the equality the proxy-conformance test asserts.
//
// Determinism is achieved by never depending on Go map iteration order: record
// fields and aggregate labels are always emitted in sorted order, and rows and
// groups keep their query order. The format is unambiguous (length-tagged
// enough for the fields we carry), but it is meant for equality, not parsing.
func Canonical(v View) []byte {
var b strings.Builder
b.WriteString("total=")
b.WriteString(strconv.Itoa(v.Total))
b.WriteByte('\n')

b.WriteString("aggs=")
writeAggs(&b, v.Aggregates)
b.WriteByte('\n')

if v.Groups != nil {
b.WriteString("groups=")
b.WriteString(strconv.Itoa(len(v.Groups)))
b.WriteByte('\n')
for _, g := range v.Groups {
b.WriteString("group key=")
b.WriteString(g.Key.canonical())
b.WriteString(" aggs=")
writeAggs(&b, g.Aggregates)
b.WriteByte('\n')
writeRows(&b, g.Rows)
}
return []byte(b.String())
}

b.WriteString("rows=")
b.WriteString(strconv.Itoa(len(v.Rows)))
b.WriteByte('\n')
writeRows(&b, v.Rows)
return []byte(b.String())
}

// writeRows appends every row in order, each as its sorted-field canonical form.
func writeRows(b *strings.Builder, rows []Record) {
for _, r := range rows {
b.WriteString(" row")
for _, name := range r.sortedNames() {
b.WriteByte(' ')
b.WriteString(name)
b.WriteByte('=')
b.WriteString(r[name].canonical())
}
b.WriteByte('\n')
}
}

// writeAggs appends the aggregates in sorted-label order (or "-" when none), so
// the aggregate map never leaks its iteration order into the bytes.
func writeAggs(b *strings.Builder, aggs map[string]Value) {
if len(aggs) == 0 {
b.WriteByte('-')
return
}
labels := make([]string, 0, len(aggs))
for k := range aggs {
labels = append(labels, k)
}
sort.Strings(labels)
for i, k := range labels {
if i > 0 {
b.WriteByte(',')
}
b.WriteString(k)
b.WriteByte('=')
b.WriteString(aggs[k].canonical())
}
}
59 changes: 59 additions & 0 deletions canonical_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// Copyright (c) 2026 the go-widgets/data authors. All rights reserved.
// Use of this source code is governed by a BSD-3-Clause license that can be
// found in the LICENSE file at the root of this repository.

package data

import (
"bytes"
"testing"
)

func TestCanonicalIsOrderIndependent(t *testing.T) {
// Two records with the same content built in different insertion orders must
// canonicalise identically (fields are emitted sorted).
a := Record{"b": Int(2), "a": String("x"), "c": Bool(true)}
b := Record{"c": Bool(true), "a": String("x"), "b": Int(2)}
va := Canonical(View{Rows: []Record{a}, Total: 1})
vb := Canonical(View{Rows: []Record{b}, Total: 1})
if !bytes.Equal(va, vb) {
t.Fatalf("field order leaked:\n%s\n---\n%s", va, vb)
}
}

func TestCanonicalUngroupedShape(t *testing.T) {
v := View{
Rows: []Record{{"id": Int(1), "name": String("ann")}},
Total: 1,
Aggregates: map[string]Value{"count": Int(1), "sum(x)": Float(2.5)},
}
got := string(Canonical(v))
want := "total=1\n" +
"aggs=count=i:1,sum(x)=f:2.5\n" +
"rows=1\n" +
" row id=i:1 name=s:ann\n"
if got != want {
t.Fatalf("canonical =\n%q\nwant\n%q", got, want)
}
}

func TestCanonicalGroupedAndNoAggs(t *testing.T) {
v := View{
Total: 2,
Groups: []Group{
{Key: String("red"), Rows: []Record{{"id": Int(1)}}, Aggregates: map[string]Value{"count": Int(1)}},
{Key: String("blue"), Rows: []Record{{"id": Int(2)}}, Aggregates: nil},
},
}
got := string(Canonical(v))
want := "total=2\n" +
"aggs=-\n" +
"groups=2\n" +
"group key=s:red aggs=count=i:1\n" +
" row id=i:1\n" +
"group key=s:blue aggs=-\n" +
" row id=i:2\n"
if got != want {
t.Fatalf("grouped canonical =\n%q\nwant\n%q", got, want)
}
}
Loading
Loading