├── natsrpc
├── example
│ ├── .gitignore
│ ├── natsrpc
│ ├── buf.yaml
│ ├── buf.gen.yaml
│ └── v1
│ │ └── example.proto
├── README.md
├── cmd
│ └── protoc-gen-natsrpc
│ │ └── main.go
├── protos
│ └── natsrpc
│ │ ├── ext.proto
│ │ └── ext.pb.go
├── Taskfile.yml
├── shared_go.qtpl
├── shared_go.qtpl.go
├── generator.go
├── services_kv_go.qtpl
├── services_server_go.qtpl
└── services_client_go.qtpl
├── .env
├── sqlc-gen-zombiezen
├── zombiezen
│ ├── examples
│ │ ├── .gitignore
│ │ ├── sqlc.yml
│ │ ├── migrations
│ │ │ └── 0001.sql
│ │ ├── setup_test.go
│ │ ├── queries
│ │ │ └── nullable.sql
│ │ └── setup.go
│ ├── crud.go
│ ├── gen.go
│ ├── queries.go
│ └── crud.qtpl
├── Taskfile.yml
├── main.go
└── README.md
├── .gitattributes
├── .envcrypt
├── wisshes
├── linode
│ ├── ctx.go
│ ├── regions.go
│ ├── domains.go
│ ├── types.go
│ └── client.go
├── README.md
├── steps.go
├── ctx.go
├── cmds.go
├── cond.go
├── apt.go
├── file.go
└── inventory.go
├── README.md
├── assets
├── gomps.png
├── toolbelt.png
└── wisshes.png
├── bytebufferpool
├── README.md
├── LICENSE
├── bytebuffer.go
└── pool.go
├── jtd
├── examples
│ ├── simple.json
│ ├── user.json
│ ├── notification.json
│ └── notification.go
├── README.md
├── cmd
│ └── jtd2go
│ │ └── main.go
├── jtd.go
└── jtd_test.go
├── network.go
├── slog.go
├── .gitignore
├── pool.go
├── embeddednats
├── cmd
│ └── examples
│ │ └── main.go
└── nats.go
├── Taskfile.yml
├── LICENSE
├── .vscode
└── launch.json
├── protobuf.go
├── egctx.go
├── easing_test.go
├── id.go
├── musical.go
├── math.go
├── eventbus.go
├── go.mod
├── strings.go
├── logic.go
├── datalog
└── datalog.go
├── envcrypt
└── main.go
├── sparse_set.go
├── easing.go
└── database.go
/natsrpc/example/.gitignore:
--------------------------------------------------------------------------------
1 | gen
--------------------------------------------------------------------------------
/natsrpc/example/natsrpc:
--------------------------------------------------------------------------------
1 | ../protos/natsrpc
--------------------------------------------------------------------------------
/.env:
--------------------------------------------------------------------------------
1 | ENVCRYPT_PASSWORD=testtest
2 | ENVCRYPT_SALT=test
--------------------------------------------------------------------------------
/sqlc-gen-zombiezen/zombiezen/examples/.gitignore:
--------------------------------------------------------------------------------
1 | zz
2 | *.log
--------------------------------------------------------------------------------
/.gitattributes:
--------------------------------------------------------------------------------
1 | *.png filter=lfs diff=lfs merge=lfs -text
2 |
3 | *.templ.go linguist-generated=true
--------------------------------------------------------------------------------
/natsrpc/example/buf.yaml:
--------------------------------------------------------------------------------
1 | version: v2
2 |
3 | breaking:
4 | use:
5 | - FILE
6 | lint:
7 | use:
8 | - DEFAULT
9 |
--------------------------------------------------------------------------------
/.envcrypt:
--------------------------------------------------------------------------------
1 | L4GJRXIYTRAKVENQTQTYXRV7ZSBNCQRKVPSZ2DYJNV2CK6QPVVC2ZAUGCF6FTYXI2OW27T32UDDMASQEAU2BQYFXZMZJEGCTPOEUZQ32KY3A74U2D5NIW===
--------------------------------------------------------------------------------
/wisshes/linode/ctx.go:
--------------------------------------------------------------------------------
1 | package linode
2 |
3 | import "github.com/delaneyj/toolbelt/wisshes"
4 |
5 | const ctxLinodeKeyPrefix wisshes.CtxKey = "linode-"
6 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # toolbelt
2 | A set of utilities used in every go project
3 |
4 |
5 |
--------------------------------------------------------------------------------
/assets/gomps.png:
--------------------------------------------------------------------------------
1 | version https://git-lfs.github.com/spec/v1
2 | oid sha256:cacf628b35afd555b34b62ea694fe8b018e708546dc813efdeacb5c4effd85ab
3 | size 1402455
4 |
--------------------------------------------------------------------------------
/assets/toolbelt.png:
--------------------------------------------------------------------------------
1 | version https://git-lfs.github.com/spec/v1
2 | oid sha256:1d466ee33348103676faa34c507b23e663e922631dcab12b851278d01b59d86b
3 | size 1173215
4 |
--------------------------------------------------------------------------------
/assets/wisshes.png:
--------------------------------------------------------------------------------
1 | version https://git-lfs.github.com/spec/v1
2 | oid sha256:c63bff2b1361a36ecf2f1709d47ce2ea5d191029a215e5e4bb3e0318ec3eafbf
3 | size 1472748
4 |
--------------------------------------------------------------------------------
/bytebufferpool/README.md:
--------------------------------------------------------------------------------
1 | # bytebufferpool
2 |
3 | Port of `github.com/valyala/bytebufferpool` with the backing slice kept unexported. Original source and MIT license remain at https://github.com/valyala/bytebufferpool.
4 |
--------------------------------------------------------------------------------
/wisshes/README.md:
--------------------------------------------------------------------------------
1 | # wisshes
2 |
3 | wisshes Is SSH + Extra Steps
4 |
5 |
6 |
7 |
8 | ## What
9 | Pure GO answer to Infrastructure as Code tools like Ansible
10 |
--------------------------------------------------------------------------------
/sqlc-gen-zombiezen/zombiezen/examples/sqlc.yml:
--------------------------------------------------------------------------------
1 | version: "2"
2 |
3 | plugins:
4 | - name: zz
5 | process:
6 | cmd: sqlc-gen-zombiezen
7 |
8 | sql:
9 | - engine: "sqlite"
10 | queries: "./queries"
11 | schema: "./migrations"
12 | codegen:
13 | - out: zz
14 | plugin: zz
15 |
--------------------------------------------------------------------------------
/jtd/examples/simple.json:
--------------------------------------------------------------------------------
1 | {
2 | "properties": {
3 | "name": { "type": "string" },
4 | "age": { "type": "int32" },
5 | "isActive": { "type": "boolean" }
6 | },
7 | "optionalProperties": {
8 | "tags": {
9 | "elements": { "type": "string" }
10 | },
11 | "metadata": {
12 | "values": { "type": "string" }
13 | }
14 | }
15 | }
--------------------------------------------------------------------------------
/wisshes/steps.go:
--------------------------------------------------------------------------------
1 | package wisshes
2 |
3 | import (
4 | "context"
5 | )
6 |
7 | type StepStatus string
8 |
9 | const (
10 | StepStatusUnchanged StepStatus = "unchanged"
11 | StepStatusChanged StepStatus = "changed"
12 | StepStatusFailed StepStatus = "failed"
13 | )
14 |
15 | type Step func(ctx context.Context) (revisedCtx context.Context, name string, status StepStatus, err error)
16 |
--------------------------------------------------------------------------------
/network.go:
--------------------------------------------------------------------------------
1 | package toolbelt
2 |
3 | import "net"
4 |
5 | // Returns a free port number that can be used to listen on.
6 | func FreePort() (port int, err error) {
7 | var a *net.TCPAddr
8 | if a, err = net.ResolveTCPAddr("tcp", "localhost:0"); err == nil {
9 | var l *net.TCPListener
10 | if l, err = net.ListenTCP("tcp", a); err == nil {
11 | defer l.Close()
12 | return l.Addr().(*net.TCPAddr).Port, nil
13 | }
14 | }
15 | return
16 | }
17 |
--------------------------------------------------------------------------------
/natsrpc/example/buf.gen.yaml:
--------------------------------------------------------------------------------
1 | version: v2
2 |
3 | managed:
4 | enabled: true
5 |
6 | plugins:
7 | - remote: buf.build/protocolbuffers/go
8 | out: ./gen
9 | opt:
10 | - paths=source_relative
11 |
12 | - remote: buf.build/community/planetscale-vtprotobuf:v0.5.0
13 | out: ./gen
14 | opt:
15 | - paths=source_relative
16 |
17 | - local: protoc-gen-natsrpc
18 | out: ./gen
19 | opt:
20 | - paths=source_relative
21 |
--------------------------------------------------------------------------------
/slog.go:
--------------------------------------------------------------------------------
1 | package toolbelt
2 |
3 | import (
4 | "context"
5 | "log/slog"
6 | )
7 |
8 | type CtxKey string
9 |
10 | const CtxSlogKey CtxKey = "slog"
11 |
12 | func CtxWithSlog(ctx context.Context, slog *slog.Logger) context.Context {
13 | return context.WithValue(ctx, CtxSlogKey, slog)
14 | }
15 |
16 | func CtxSlog(ctx context.Context) (logger *slog.Logger, ok bool) {
17 | logger, ok = ctx.Value(CtxSlogKey).(*slog.Logger)
18 | return logger, ok
19 | }
20 |
--------------------------------------------------------------------------------
/natsrpc/README.md:
--------------------------------------------------------------------------------
1 | Protobuf plugin to generate NATS equivalent to gRPC services
2 |
3 | ```shell
4 | go install github.com/delaneyj/toolbelt/natsrpc/cmd/protoc-gen-natsrpc@latest
5 | ```
6 |
7 | inside your `buf.gen.yaml` file, add the following:
8 |
9 | ```yaml
10 | version: v1
11 |
12 | plugins:
13 | - plugin: natsrpc
14 | out: ./gen
15 | opt:
16 | - paths=source_relative
17 | ```
18 |
19 | then run `buf generate` to generate the NATS files.
20 |
--------------------------------------------------------------------------------
/sqlc-gen-zombiezen/zombiezen/examples/migrations/0001.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE nullableTestTable (id integer PRIMARY KEY, myBool integer) strict;
2 |
3 | CREATE TABLE NAMES (
4 | id integer PRIMARY KEY,
5 | name text NOT NULL UNIQUE
6 | );
7 |
8 | CREATE TABLE authors (
9 | id integer PRIMARY KEY,
10 | first_name_id integer NOT NULL,
11 | last_name_id integer NOT NULL,
12 | FOREIGN KEY (first_name_id) REFERENCES NAMES(id),
13 | FOREIGN KEY (last_name_id) REFERENCES NAMES(id)
14 | );
--------------------------------------------------------------------------------
/natsrpc/cmd/protoc-gen-natsrpc/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "log"
5 |
6 | "github.com/delaneyj/toolbelt/natsrpc"
7 | "google.golang.org/protobuf/compiler/protogen"
8 | )
9 |
10 | func main() {
11 | log.SetFlags(log.LstdFlags | log.Lshortfile)
12 |
13 | opts := protogen.Options{
14 | ParamFunc: func(name, value string) error {
15 | log.Printf("param: %s=%s", name, value)
16 | return nil
17 | },
18 | }
19 | opts.Run(func(gen *protogen.Plugin) error {
20 | for _, file := range gen.Files {
21 | if !file.Generate {
22 | continue
23 | }
24 |
25 | natsrpc.Generate(gen, file)
26 | }
27 | return nil
28 | })
29 | }
30 |
--------------------------------------------------------------------------------
/natsrpc/protos/natsrpc/ext.proto:
--------------------------------------------------------------------------------
1 | syntax = "proto3";
2 |
3 | package natsrpc;
4 |
5 | option go_package = "github.com/delaneyj/toolbelt/natsrpc/protos/natsrpc";
6 |
7 | import "google/protobuf/descriptor.proto";
8 | import "google/protobuf/duration.proto";
9 |
10 | extend google.protobuf.ServiceOptions {
11 | optional bool is_not_singleton = 12337;
12 | }
13 |
14 | extend google.protobuf.MessageOptions {
15 | optional string kv_bucket = 13337;
16 | optional bool kv_client_readonly = 13338;
17 | optional google.protobuf.Duration kv_ttl = 13339;
18 | optional uint32 kv_history_count = 13340;
19 | }
20 |
21 | extend google.protobuf.FieldOptions { optional bool kv_id = 14337; }
--------------------------------------------------------------------------------
/sqlc-gen-zombiezen/zombiezen/examples/setup_test.go:
--------------------------------------------------------------------------------
1 | package examples
2 |
3 | import (
4 | "context"
5 | "os"
6 | "path/filepath"
7 | "testing"
8 | )
9 |
10 | func TestSetupDB(t *testing.T) {
11 | tempDataDir := t.TempDir()
12 |
13 | db, err := SetupDB(context.Background(), tempDataDir, true)
14 |
15 | if err != nil {
16 | t.Fatalf("SetupDB failed: %v", err)
17 | }
18 |
19 | if db != nil {
20 | defer db.Close()
21 | }
22 |
23 | expectedDBPath := filepath.Join(tempDataDir, "database", "examples.sqlite")
24 | if _, err := os.Stat(expectedDBPath); os.IsNotExist(err) {
25 | t.Errorf("database file was not created at the expected path: %s", expectedDBPath)
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # If you prefer the allow list template instead of the deny list, see community template:
2 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
3 | #
4 | # Binaries for programs and plugins
5 | *.exe
6 | *.exe~
7 | *.dll
8 | *.so
9 | *.dylib
10 |
11 | # Test binary, built with `go test -c`
12 | *.test
13 |
14 | # Output of the go coverage tool, specifically when used with LiteIDE
15 | *.out
16 |
17 | # Dependency directories (remove the comment below to include it)
18 | # vendor/
19 |
20 | # Go workspace file
21 | go.work
22 | .task
23 |
24 | # JTD generator binary
25 | jtd/cmd/jtd2go/jtd2go
26 | pixel2svg/pixel2svg
27 | .serena
28 | data
29 | tmp
--------------------------------------------------------------------------------
/pool.go:
--------------------------------------------------------------------------------
1 | package toolbelt
2 |
3 | import (
4 | "sync"
5 | )
6 |
7 | // A Pool is a generic wrapper around a sync.Pool.
8 | type Pool[T any] struct {
9 | pool sync.Pool
10 | }
11 |
12 | // New creates a new Pool with the provided new function.
13 | //
14 | // The equivalent sync.Pool construct is "sync.Pool{New: fn}"
15 | func New[T any](fn func() T) Pool[T] {
16 | return Pool[T]{
17 | pool: sync.Pool{New: func() interface{} { return fn() }},
18 | }
19 | }
20 |
21 | // Get is a generic wrapper around sync.Pool's Get method.
22 | func (p *Pool[T]) Get() T {
23 | return p.pool.Get().(T)
24 | }
25 |
26 | // Get is a generic wrapper around sync.Pool's Put method.
27 | func (p *Pool[T]) Put(x T) {
28 | p.pool.Put(x)
29 | }
30 |
--------------------------------------------------------------------------------
/embeddednats/cmd/examples/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "context"
5 | "os"
6 | "os/signal"
7 | "syscall"
8 |
9 | "github.com/delaneyj/toolbelt/embeddednats"
10 | )
11 |
12 | func main() {
13 | // create ze builder
14 | ctx := context.Background()
15 | ns, err := embeddednats.New(ctx,
16 | embeddednats.WithDirectory("/var/tmp/deleteme"),
17 | embeddednats.WithShouldClearData(true),
18 | )
19 | if err != nil {
20 | panic(err)
21 | }
22 |
23 | // behold ze server
24 | ns.WaitForServer()
25 | nc, err := ns.Client()
26 | if err != nil {
27 | panic(err)
28 | }
29 | nc.Publish("foo", []byte("hello world"))
30 |
31 | sig := make(chan os.Signal, 1)
32 | signal.Notify(sig, os.Interrupt, syscall.SIGTERM)
33 | <-sig
34 | }
35 |
--------------------------------------------------------------------------------
/sqlc-gen-zombiezen/zombiezen/examples/queries/nullable.sql:
--------------------------------------------------------------------------------
1 | -- name: GetNullableTestTableMyBool :many
2 | SELECT
3 | *
4 | FROM
5 | nullableTestTable
6 | WHERE
7 | myBool = @myBool;
8 |
9 | -- name: DistinctAuthorNames :many
10 | SELECT
11 | DISTINCT na.name AS first_name,
12 | nb.name AS last_name
13 | FROM
14 | authors a
15 | INNER JOIN NAMES na ON a.first_name_id = na.id
16 | INNER JOIN NAMES nb ON a.last_name_id = nb.id
17 | ORDER BY
18 | first_name,
19 | last_name;
20 |
21 | -- name: HasAuthors :one
22 | SELECT
23 | COUNT(*) > 0
24 | FROM
25 | authors;
26 |
27 | -- name: ListNullableTestTableByIDs :many
28 | SELECT
29 | id,
30 | myBool
31 | FROM
32 | nullableTestTable
33 | WHERE
34 | id IN (sqlc.slice('ids'));
35 |
--------------------------------------------------------------------------------
/Taskfile.yml:
--------------------------------------------------------------------------------
1 | # https://taskfile.dev
2 |
3 | version: "3"
4 |
5 | vars:
6 | VERSION: 0.7.6
7 |
8 | interval: 200ms
9 |
10 | tasks:
11 | libpub:
12 | cmds:
13 | - git push origin
14 | - git tag v{{.VERSION}}
15 | - git push --tags
16 | - GOPROXY=proxy.golang.org go list -m github.com/delaneyj/toolbelt@v{{.VERSION}}
17 |
18 | install-jtd:
19 | desc: Install the JTD to Go generator
20 | dir: jtd/cmd/jtd2go
21 | cmds:
22 | - go install .
23 |
24 | install-pixel2svg:
25 | desc: Install the pixel2svg tool
26 | dir: pixel2svg
27 | cmds:
28 | - go install .
29 |
30 | install-sqlc-gen-zombiezen:
31 | desc: Install the sqlc zombiezen plugin
32 | dir: sqlc-gen-zombiezen
33 | cmds:
34 | - go install .
35 |
--------------------------------------------------------------------------------
/sqlc-gen-zombiezen/Taskfile.yml:
--------------------------------------------------------------------------------
1 | # https://taskfile.dev
2 |
3 | version: "3"
4 |
5 | vars:
6 | GREETING: Hello, World!
7 |
8 | tasks:
9 | tools:
10 | cmds:
11 | - go get -u github.com/valyala/quicktemplate/qtc
12 | - go install github.com/valyala/quicktemplate/qtc@latest
13 | - go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest
14 |
15 | qtc:
16 | sources:
17 | - "**/*.qtpl"
18 | generates:
19 | - "**/*.qtpl.go"
20 | cmds:
21 | - qtc
22 |
23 | sqlc-examples:
24 | dir: zombiezen/examples
25 | cmds:
26 | - sqlc generate
27 | - goimports -w .
28 |
29 | sqlc:
30 | deps:
31 | - qtc
32 | sources:
33 | - "**/*.go"
34 | - exclude: "**.qtpl.go"
35 | cmds:
36 | - go install
37 | - task sqlc-examples
38 |
39 | default:
40 | cmds:
41 | - echo "{{.GREETING}}"
42 | silent: true
43 |
--------------------------------------------------------------------------------
/natsrpc/Taskfile.yml:
--------------------------------------------------------------------------------
1 | # https://taskfile.dev
2 |
3 | version: "3"
4 |
5 | vars:
6 | GREETING: Hello, World!
7 |
8 | tasks:
9 | tools:
10 | cmds:
11 | - go get -u github.com/valyala/quicktemplate/qtc
12 | - go install github.com/valyala/quicktemplate/qtc@latest
13 | - go install github.com/bufbuild/buf/cmd/buf@latest
14 |
15 | qtc:
16 | sources:
17 | - "**/*.qtpl"
18 | generates:
19 | - "**/*.qtpl.go"
20 | cmds:
21 | - qtc
22 |
23 | example:
24 | deps:
25 | - install
26 | dir: example
27 | sources:
28 | - "**/*.proto"
29 | - "**/*.yaml"
30 | generates:
31 | - "gen/**/*"
32 | cmds:
33 | - buf dep update
34 | - rm -rf gen
35 | - buf generate
36 |
37 | install:
38 | dir: cmd/protoc-gen-natsrpc
39 | deps:
40 | - qtc
41 | sources:
42 | - "../../**/*.go"
43 | - exclude: "../../**.qtpl.go"
44 | cmds:
45 | - go install
46 |
47 | default:
48 | cmds:
49 | - echo "{{.GREETING}}"
50 | silent: true
51 |
--------------------------------------------------------------------------------
/wisshes/ctx.go:
--------------------------------------------------------------------------------
1 | package wisshes
2 |
3 | import (
4 | "context"
5 |
6 | "github.com/melbahja/goph"
7 | )
8 |
9 | type CtxKey string
10 |
11 | const (
12 | ctxKeySSHClient CtxKey = "ssh-client"
13 | ctxKeyPreviousStep CtxKey = "previous-step"
14 | ctxKeyInventory CtxKey = "inventory"
15 | )
16 |
17 | func CtxSSHClient(ctx context.Context) *goph.Client {
18 | return ctx.Value(ctxKeySSHClient).(*goph.Client)
19 | }
20 |
21 | func CtxWithSSHClient(ctx context.Context, client *goph.Client) context.Context {
22 | return context.WithValue(ctx, ctxKeySSHClient, client)
23 | }
24 |
25 | func CtxPreviousStep(ctx context.Context) StepStatus {
26 | return ctx.Value(ctxKeyPreviousStep).(StepStatus)
27 | }
28 |
29 | func CtxWithPreviousStep(ctx context.Context, step StepStatus) context.Context {
30 | return context.WithValue(ctx, ctxKeyPreviousStep, step)
31 | }
32 |
33 | func CtxInventory(ctx context.Context) *Inventory {
34 | return ctx.Value(ctxKeyInventory).(*Inventory)
35 | }
36 |
37 | func CtxWithInventory(ctx context.Context, inv *Inventory) context.Context {
38 | return context.WithValue(ctx, ctxKeyInventory, inv)
39 | }
40 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023 Delaney
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/sqlc-gen-zombiezen/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "bufio"
5 | "context"
6 | "fmt"
7 | "io"
8 | "os"
9 |
10 | "github.com/delaneyj/toolbelt/sqlc-gen-zombiezen/zombiezen"
11 | "github.com/sqlc-dev/plugin-sdk-go/plugin"
12 | "google.golang.org/protobuf/proto"
13 | )
14 |
15 | func main() {
16 |
17 | if err := run(); err != nil {
18 | fmt.Fprintf(os.Stderr, "error generating JSON: %s", err)
19 | os.Exit(2)
20 | }
21 | }
22 |
23 | func run() error {
24 | reqBlob, err := io.ReadAll(os.Stdin)
25 | if err != nil {
26 | return fmt.Errorf("failed to read request: %w", err)
27 | }
28 | req := &plugin.GenerateRequest{}
29 | if err := proto.Unmarshal(reqBlob, req); err != nil {
30 | return fmt.Errorf("failed to unmarshal JSON: %w", err)
31 | }
32 |
33 | resp, err := zombiezen.Generate(context.Background(), req)
34 | if err != nil {
35 | return err
36 | }
37 |
38 | // if usesStdin {
39 | respBlob, err := proto.Marshal(resp)
40 | if err != nil {
41 | return err
42 | }
43 | w := bufio.NewWriter(os.Stdout)
44 | if _, err := w.Write(respBlob); err != nil {
45 | return err
46 | }
47 | if err := w.Flush(); err != nil {
48 | return err
49 | }
50 |
51 | return nil
52 | }
53 |
--------------------------------------------------------------------------------
/bytebufferpool/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2016 Aliaksandr Valialkin, VertaMedia
4 | Copyright (c) 2025 Delaney Gillilan
5 |
6 | Permission is hereby granted, free of charge, to any person obtaining a copy
7 | of this software and associated documentation files (the "Software"), to deal
8 | in the Software without restriction, including without limitation the rights
9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10 | copies of the Software, and to permit persons to whom the Software is
11 | furnished to do so, subject to the following conditions:
12 |
13 | The above copyright notice and this permission notice shall be included in all
14 | copies or substantial portions of the Software.
15 |
16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22 | SOFTWARE.
23 |
--------------------------------------------------------------------------------
/.vscode/launch.json:
--------------------------------------------------------------------------------
1 | {
2 | // Use IntelliSense to learn about possible attributes.
3 | // Hover to view descriptions of existing attributes.
4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
5 | "version": "0.2.0",
6 | "configurations": [
7 | {
8 | "name": "Attach to Process",
9 | "type": "go",
10 | "request": "attach",
11 | "mode": "local",
12 | "processId": 0
13 | },
14 | {
15 | "name": "SQLC Generate",
16 | "type": "go",
17 | "request": "launch",
18 | "mode": "auto",
19 | "program": "${workspaceFolder}/sqlc-gen-zombiezen/main.go",
20 | },
21 | {
22 | "name": "Env Encrypt",
23 | "type": "go",
24 | "request": "launch",
25 | "mode": "auto",
26 | "program": "${workspaceFolder}/envcrypt/main.go",
27 | "args": [
28 | "encrypt"
29 | ],
30 | "cwd": "${workspaceFolder}"
31 | },
32 | {
33 | "name": "Env Decrypt",
34 | "type": "go",
35 | "request": "launch",
36 | "mode": "auto",
37 | "program": "${workspaceFolder}/envcrypt/main.go",
38 | "args": [
39 | "decrypt"
40 | ],
41 | "cwd": "${workspaceFolder}"
42 | },
43 | ]
44 | }
--------------------------------------------------------------------------------
/protobuf.go:
--------------------------------------------------------------------------------
1 | package toolbelt
2 |
3 | import (
4 | "google.golang.org/protobuf/encoding/protojson"
5 | "google.golang.org/protobuf/proto"
6 | )
7 |
8 | func MustProtoMarshal(msg proto.Message) []byte {
9 | b, err := proto.Marshal(msg)
10 | if err != nil {
11 | panic(err)
12 | }
13 | return b
14 | }
15 |
16 | func MustProtoUnmarshal(b []byte, msg proto.Message) {
17 | if err := proto.Unmarshal(b, msg); err != nil {
18 | panic(err)
19 | }
20 | }
21 |
22 | func MustProtoJSONMarshal(msg proto.Message) []byte {
23 | b, err := protojson.Marshal(msg)
24 | if err != nil {
25 | panic(err)
26 | }
27 | return b
28 | }
29 |
30 | func MustProtoJSONUnmarshal(b []byte, msg proto.Message) {
31 | if err := protojson.Unmarshal(b, msg); err != nil {
32 | panic(err)
33 | }
34 | }
35 |
36 | type MustProtobufHandler struct {
37 | isJSON bool
38 | }
39 |
40 | func NewProtobufHandler(isJSON bool) *MustProtobufHandler {
41 | return &MustProtobufHandler{isJSON: isJSON}
42 | }
43 |
44 | func (h *MustProtobufHandler) Marshal(msg proto.Message) []byte {
45 | if h.isJSON {
46 | return MustProtoJSONMarshal(msg)
47 | }
48 | return MustProtoMarshal(msg)
49 | }
50 |
51 | func (h *MustProtobufHandler) Unmarshal(b []byte, msg proto.Message) {
52 | if h.isJSON {
53 | MustProtoJSONUnmarshal(b, msg)
54 | } else {
55 | MustProtoUnmarshal(b, msg)
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/egctx.go:
--------------------------------------------------------------------------------
1 | package toolbelt
2 |
3 | import (
4 | "context"
5 |
6 | "golang.org/x/sync/errgroup"
7 | )
8 |
9 | type ErrGroupSharedCtx struct {
10 | eg *errgroup.Group
11 | ctx context.Context
12 | }
13 |
14 | type CtxErrFunc func(ctx context.Context) error
15 |
16 | func NewErrGroupSharedCtx(ctx context.Context, funcs ...CtxErrFunc) *ErrGroupSharedCtx {
17 | eg, ctx := errgroup.WithContext(ctx)
18 |
19 | egCtx := &ErrGroupSharedCtx{
20 | eg: eg,
21 | ctx: ctx,
22 | }
23 |
24 | egCtx.Go(funcs...)
25 |
26 | return egCtx
27 | }
28 |
29 | func (egc *ErrGroupSharedCtx) Go(funcs ...CtxErrFunc) {
30 | for _, f := range funcs {
31 | fn := f
32 | egc.eg.Go(func() error {
33 | return fn(egc.ctx)
34 | })
35 | }
36 | }
37 |
38 | func (egc *ErrGroupSharedCtx) Wait() error {
39 | return egc.eg.Wait()
40 | }
41 |
42 | type ErrGroupSeparateCtx struct {
43 | eg *errgroup.Group
44 | }
45 |
46 | func NewErrGroupSeparateCtx() *ErrGroupSeparateCtx {
47 | eg := &errgroup.Group{}
48 |
49 | egCtx := &ErrGroupSeparateCtx{
50 | eg: eg,
51 | }
52 |
53 | return egCtx
54 | }
55 |
56 | func (egc *ErrGroupSeparateCtx) Go(ctx context.Context, funcs ...CtxErrFunc) {
57 | for _, f := range funcs {
58 | fn := f
59 | egc.eg.Go(func() error {
60 | return fn(ctx)
61 | })
62 | }
63 | }
64 |
65 | func (egc *ErrGroupSeparateCtx) Wait() error {
66 | return egc.eg.Wait()
67 | }
68 |
--------------------------------------------------------------------------------
/natsrpc/example/v1/example.proto:
--------------------------------------------------------------------------------
1 | syntax = "proto3";
2 |
3 | package example;
4 |
5 | option go_package = "github.com/delaneyj/toolbelt/natsrpc/example";
6 |
7 | import "google/protobuf/descriptor.proto";
8 | import "google/protobuf/timestamp.proto";
9 | import "natsrpc/ext.proto";
10 |
11 | // Test foo bar
12 | service Greeter {
13 | // Unary example
14 | rpc SayHello(SayHelloRequest) returns (SayHelloResponse);
15 |
16 | // Client streaming example
17 | rpc SayHelloSendN(stream SayHelloRequest) returns (SayHelloResponse);
18 |
19 | // Server streaming example
20 | rpc SayHelloNTimes(SayHelloNTimesRequest) returns (stream SayHelloResponse);
21 |
22 | // Bidirectional streaming example
23 | rpc SayHelloNN(stream SayHelloRequest)
24 | returns (stream SayHelloAdoptionResponse);
25 | }
26 |
27 | message SayHelloNTimesRequest {
28 | string name = 1;
29 | int32 count = 2;
30 | }
31 |
32 | message SayHelloRequest { string name = 1; }
33 | message SayHelloResponse { string message = 1; }
34 |
35 | message SayHelloAdoptionResponse {
36 | string name = 1;
37 | int64 adoption_id = 2;
38 | }
39 |
40 | message Test {
41 | option (natsrpc.kv_bucket) = "test";
42 | option (natsrpc.kv_client_readonly) = true;
43 | option (natsrpc.kv_ttl).seconds = 60;
44 | option (natsrpc.kv_history_count) = 5;
45 |
46 | google.protobuf.Timestamp timestamp = 1;
47 |
48 | string name = 2 [ (natsrpc.kv_id) = true ];
49 | repeated float values = 3;
50 | }
--------------------------------------------------------------------------------
/easing_test.go:
--------------------------------------------------------------------------------
1 | package toolbelt
2 |
3 | import (
4 | "math"
5 | "testing"
6 | )
7 |
8 | func TestFitEqualsLinearLerp64(t *testing.T) {
9 | // Compare Fit against direct linear interpolation for several values.
10 | oldMin, oldMax := 10.0, 30.0
11 | newMin, newMax := -5.0, 5.0
12 | for i := 0; i <= 20; i++ {
13 | x := oldMin + (float64(i)/20.0)*(oldMax-oldMin)
14 | got := Fit(x, oldMin, oldMax, newMin, newMax)
15 | want := newMin + ((x-oldMin)*(newMax-newMin))/(oldMax-oldMin)
16 | if math.Abs(got-want) > 1e-12 {
17 | t.Fatalf("Fit mismatch at i=%d: got=%v want=%v", i, got, want)
18 | }
19 | }
20 | }
21 |
22 | func TestFit01UsesLinear32(t *testing.T) {
23 | newMin, newMax := float32(100), float32(200)
24 | for i := 0; i <= 20; i++ {
25 | x := float32(i) / 20
26 | got := Fit01(x, newMin, newMax)
27 | want := newMin + x*(newMax-newMin)
28 | if diff := float64(got - want); math.Abs(diff) > 1e-5 {
29 | t.Fatalf("Fit01 mismatch at i=%d: got=%v want=%v", i, got, want)
30 | }
31 | }
32 | }
33 |
34 | func TestEaseLookup(t *testing.T) {
35 | e := Ease[float64]("easeInOutCubic")
36 | v := e(0.5)
37 | // For inOut cubic at t=0.5, value should be 0.5
38 | if math.Abs(v-0.5) > 1e-12 {
39 | t.Fatalf("unexpected value from Ease(inoutcubic) at t=0.5: %v", v)
40 | }
41 |
42 | // Unknown should fall back to linear
43 | el := Ease[float64]("unknown")
44 | if el(0.25) != 0.25 {
45 | t.Fatalf("unknown easing should be linear")
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/wisshes/cmds.go:
--------------------------------------------------------------------------------
1 | package wisshes
2 |
3 | import (
4 | "context"
5 | "errors"
6 | "fmt"
7 | "log"
8 | "strings"
9 |
10 | "github.com/melbahja/goph"
11 | "github.com/zeebo/xxh3"
12 | )
13 |
14 | func RunF(client *goph.Client, format string, args ...any) (string, error) {
15 | cmd := fmt.Sprintf(format, args...)
16 | // log.Printf("Running %s", cmd)
17 | out, err := client.Run(cmd)
18 | if err != nil {
19 | return "", fmt.Errorf("run %s: %w", cmd, err)
20 | }
21 | return string(out), nil
22 | }
23 |
24 | func Commands(cmds ...string) Step {
25 | return func(ctx context.Context) (context.Context, string, StepStatus, error) {
26 | client := CtxSSHClient(ctx)
27 | name := fmt.Sprintf("commands-%d", xxh3.HashString(strings.Join(cmds, "\n")))
28 |
29 | results := make([]StepStatus, len(cmds))
30 | errs := make([]error, len(cmds))
31 | for i, cmd := range cmds {
32 | out, err := RunF(client, "%s", cmd)
33 | if err != nil {
34 | log.Print(out)
35 | results[i] = StepStatusFailed
36 | errs[i] = fmt.Errorf("run: '%s' %w", cmd, err)
37 | break
38 | }
39 |
40 | results[i] = StepStatusChanged
41 | }
42 |
43 | if err := errors.Join(errs...); err != nil {
44 | return ctx, name, StepStatusFailed, err
45 | }
46 |
47 | for _, result := range results {
48 | if result == StepStatusChanged {
49 | return ctx, name, StepStatusChanged, nil
50 | }
51 | }
52 |
53 | return ctx, name, StepStatusUnchanged, nil
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/jtd/examples/user.json:
--------------------------------------------------------------------------------
1 | {
2 | "metadata": {
3 | "description": "User management schema"
4 | },
5 | "definitions": {
6 | "user": {
7 | "metadata": {
8 | "description": "represents a user in the system"
9 | },
10 | "properties": {
11 | "id": {
12 | "type": "string",
13 | "metadata": {
14 | "description": "unique identifier"
15 | }
16 | },
17 | "username": {
18 | "type": "string"
19 | },
20 | "email": {
21 | "type": "string"
22 | },
23 | "age": {
24 | "type": "int32",
25 | "nullable": true
26 | },
27 | "created": {
28 | "type": "timestamp"
29 | }
30 | },
31 | "optionalProperties": {
32 | "bio": {
33 | "type": "string",
34 | "metadata": {
35 | "description": "user biography"
36 | }
37 | },
38 | "avatar": {
39 | "type": "string",
40 | "metadata": {
41 | "description": "URL to avatar image"
42 | }
43 | }
44 | }
45 | },
46 | "userRole": {
47 | "metadata": {
48 | "description": "available user roles"
49 | },
50 | "enum": ["admin", "moderator", "user", "guest"]
51 | },
52 | "userList": {
53 | "elements": { "ref": "user" }
54 | },
55 | "userMap": {
56 | "values": { "ref": "user" }
57 | }
58 | }
59 | }
--------------------------------------------------------------------------------
/natsrpc/shared_go.qtpl:
--------------------------------------------------------------------------------
1 | {% func goSharedTypesTemplate(pkg *packageTmplData) %}
2 | // Code generated by protoc-gen-go-natsrpc. DO NOT EDIT.
3 |
4 | package {%s pkg.PackageName.Snake %}
5 |
6 | import (
7 | "fmt"
8 | "time"
9 |
10 | "github.com/nats-io/nats.go"
11 | "google.golang.org/protobuf/proto"
12 | )
13 |
14 | const NatsRpcErrorHeader = "error"
15 |
16 | type NatsRpcOptions struct {
17 | Timeout time.Duration
18 | }
19 | type NatsRpcOption func(*NatsRpcOptions)
20 |
21 | func WithTimeout(timeout time.Duration) NatsRpcOption {
22 | return func(opt *NatsRpcOptions) {
23 | opt.Timeout = timeout
24 | }
25 | }
26 |
27 | var DefaultNatsRpcOptions = func() *NatsRpcOptions {
28 | return &NatsRpcOptions{
29 | Timeout: 5 * time.Minute,
30 | }
31 | }
32 |
33 | func NewNatsRpcOptions(opts ...NatsRpcOption) *NatsRpcOptions {
34 | opt := DefaultNatsRpcOptions()
35 | for _, o := range opts {
36 | o(opt)
37 | }
38 | return opt
39 | }
40 |
41 | func sendError(msg *nats.Msg, err error) {
42 | msg.RespondMsg(&nats.Msg{
43 | Header: nats.Header{
44 | NatsRpcErrorHeader: []string{err.Error()},
45 | },
46 | })
47 | }
48 |
49 | func sendSuccess(msg *nats.Msg, res proto.Message) {
50 | resBytes, err := proto.Marshal(res)
51 | if err != nil {
52 | sendError(msg, fmt.Errorf("failed to marshal response: %w", err))
53 | return
54 | }
55 | msg.Respond(resBytes)
56 | }
57 |
58 | func sendEOF(msg *nats.Msg) {
59 | msg.Respond(nil)
60 | }
61 | {% endfunc %}
--------------------------------------------------------------------------------
/id.go:
--------------------------------------------------------------------------------
1 | package toolbelt
2 |
3 | import (
4 | "encoding/base32"
5 | "encoding/binary"
6 | "fmt"
7 | "time"
8 |
9 | "github.com/denisbrodbeck/machineid"
10 | "github.com/rzajac/zflake"
11 | "github.com/zeebo/xxh3"
12 | )
13 |
14 | var flake *zflake.Gen
15 |
16 | func NextID() int64 {
17 | if flake == nil {
18 | id, err := machineid.ID()
19 | if err != nil {
20 | id = time.Now().Format(time.RFC3339Nano)
21 | }
22 | h := xxh3.HashString(id) % (1 << zflake.BitLenGID)
23 | h16 := uint16(h)
24 |
25 | flake = zflake.NewGen(
26 | zflake.Epoch(time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC)),
27 | zflake.GID(h16),
28 | )
29 | }
30 |
31 | return flake.NextFID()
32 | }
33 |
34 | func NextEncodedID() string {
35 | buf := make([]byte, 8)
36 | binary.LittleEndian.PutUint64(buf, uint64(NextID()))
37 | return base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(buf)
38 | }
39 |
40 | func AliasHash(alias string) int64 {
41 | return int64(xxh3.HashString(alias) & 0x7fffffffffffffff)
42 | }
43 |
44 | func AliasHashf(format string, args ...interface{}) int64 {
45 | return AliasHash(fmt.Sprintf(format, args...))
46 | }
47 |
48 | func AliasHashEncoded(alias string) string {
49 | h := AliasHash(alias)
50 | buf := make([]byte, 8)
51 | binary.LittleEndian.PutUint64(buf, uint64(h))
52 |
53 | return base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(buf)
54 | }
55 |
56 | func AliasHashEncodedf(format string, args ...interface{}) string {
57 | return AliasHashEncoded(fmt.Sprintf(format, args...))
58 | }
59 |
--------------------------------------------------------------------------------
/musical.go:
--------------------------------------------------------------------------------
1 | package toolbelt
2 |
3 | type MusicalRatioName string
4 |
5 | const (
6 | MusicalRatioMinorSecond MusicalRatioName = "Minor Second"
7 | MusicalRatioMajorSecond MusicalRatioName = "Major Second"
8 | MusicalRatioMinorThird MusicalRatioName = "Minor Third"
9 | MusicalRatioMajorThird MusicalRatioName = "Major Third"
10 | MusicalRatioPerfectFourth MusicalRatioName = "Perfect Fourth"
11 | MusicalRatioAugmentedFourth MusicalRatioName = "Augmented Fourth"
12 | MusicalRatioPerfectFifth MusicalRatioName = "Perfect Fifth"
13 | MusicalRatioGoldenRatio MusicalRatioName = "Golden Ratio"
14 | MusicalRatioMinorSixth MusicalRatioName = "Minor Sixth"
15 | MusicalRatioMajorSixth MusicalRatioName = "Major Sixth"
16 | MusicalRatioMajorSeventh MusicalRatioName = "Major Seventh"
17 | MusicalRatioOctave MusicalRatioName = "Octave"
18 | )
19 |
20 | var musicalRatios = map[MusicalRatioName]float64{
21 | MusicalRatioMinorSecond: 1.067,
22 | MusicalRatioMajorSecond: 1.125,
23 | MusicalRatioMinorThird: 1.200,
24 | MusicalRatioMajorThird: 1.250,
25 | MusicalRatioPerfectFourth: 1.333,
26 | MusicalRatioAugmentedFourth: 1.414,
27 | MusicalRatioPerfectFifth: 1.500,
28 | MusicalRatioGoldenRatio: 1.618,
29 | MusicalRatioMinorSixth: 1.667,
30 | MusicalRatioMajorSixth: 1.778,
31 | MusicalRatioMajorSeventh: 1.875,
32 | MusicalRatioOctave: 2.000,
33 | }
34 |
35 | func MusicalRatio(name MusicalRatioName) float64 {
36 | return musicalRatios[name]
37 | }
38 |
--------------------------------------------------------------------------------
/wisshes/linode/regions.go:
--------------------------------------------------------------------------------
1 | package linode
2 |
3 | import (
4 | "bytes"
5 | "context"
6 | "fmt"
7 | "os"
8 | "path/filepath"
9 |
10 | "github.com/delaneyj/toolbelt/wisshes"
11 | "github.com/goccy/go-json"
12 | "github.com/linode/linodego"
13 | )
14 |
15 | const ctxLinodeKeyRegions = ctxLinodeKeyPrefix + "regions"
16 |
17 | func CtxLinodeRegion(ctx context.Context) []linodego.Region {
18 | return ctx.Value(ctxLinodeKeyRegions).([]linodego.Region)
19 | }
20 |
21 | func CtxWithLinodeRegion(ctx context.Context, regions []linodego.Region) context.Context {
22 | return context.WithValue(ctx, ctxLinodeKeyRegions, regions)
23 | }
24 |
25 | func Regions() wisshes.Step {
26 | return func(ctx context.Context) (context.Context, string, wisshes.StepStatus, error) {
27 | name := "regions"
28 |
29 | linodeClient := CtxLinodeClient(ctx)
30 |
31 | regions, err := linodeClient.ListRegions(ctx, nil)
32 | if err != nil {
33 | return ctx, name, wisshes.StepStatusFailed, err
34 | }
35 |
36 | b, err := json.MarshalIndent(regions, "", " ")
37 | if err != nil {
38 | return ctx, name, wisshes.StepStatusFailed, err
39 | }
40 |
41 | fp := filepath.Join(wisshes.ArtifactsDir(), name+".json")
42 | if previous, err := os.ReadFile(fp); err == nil {
43 | if bytes.Equal(previous, b) {
44 | ctx = CtxWithLinodeRegion(ctx, regions)
45 | return ctx, name, wisshes.StepStatusUnchanged, nil
46 | }
47 | }
48 | if err := os.WriteFile(fp, b, 0644); err != nil {
49 | return ctx, name, wisshes.StepStatusFailed, fmt.Errorf("write checksum: %w", err)
50 | }
51 |
52 | ctx = CtxWithLinodeRegion(ctx, regions)
53 | return ctx, name, wisshes.StepStatusUnchanged, nil
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/wisshes/linode/domains.go:
--------------------------------------------------------------------------------
1 | package linode
2 |
3 | import (
4 | "bytes"
5 | "context"
6 | "fmt"
7 | "os"
8 | "path/filepath"
9 |
10 | "github.com/delaneyj/toolbelt/wisshes"
11 | "github.com/goccy/go-json"
12 | "github.com/linode/linodego"
13 | )
14 |
15 | const ctxLinodeKeyDomains = ctxLinodeKeyPrefix + "domains"
16 |
17 | func CtxLinodeDomains(ctx context.Context) []linodego.Domain {
18 | return ctx.Value(ctxLinodeKeyDomains).([]linodego.Domain)
19 | }
20 |
21 | func CtxWithLinodeDomains(ctx context.Context, domains []linodego.Domain) context.Context {
22 | return context.WithValue(ctx, ctxLinodeKeyDomains, domains)
23 | }
24 |
25 | func Domains() wisshes.Step {
26 | return func(ctx context.Context) (context.Context, string, wisshes.StepStatus, error) {
27 | name := "domains"
28 |
29 | linodeClient := CtxLinodeClient(ctx)
30 |
31 | domains, err := linodeClient.ListDomains(ctx, nil)
32 | if err != nil {
33 | return ctx, name, wisshes.StepStatusFailed, err
34 | }
35 |
36 | b, err := json.MarshalIndent(domains, "", " ")
37 | if err != nil {
38 | return ctx, name, wisshes.StepStatusFailed, err
39 | }
40 |
41 | fp := filepath.Join(wisshes.ArtifactsDir(), name+".json")
42 | if previous, err := os.ReadFile(fp); err == nil {
43 | if bytes.Equal(previous, b) {
44 | ctx = CtxWithLinodeDomains(ctx, domains)
45 | return ctx, name, wisshes.StepStatusUnchanged, nil
46 | }
47 | }
48 | if err := os.WriteFile(fp, b, 0644); err != nil {
49 | return ctx, name, wisshes.StepStatusFailed, fmt.Errorf("write checksum: %w", err)
50 | }
51 |
52 | ctx = CtxWithLinodeDomains(ctx, domains)
53 | return ctx, name, wisshes.StepStatusUnchanged, nil
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/sqlc-gen-zombiezen/zombiezen/examples/setup.go:
--------------------------------------------------------------------------------
1 | package examples
2 |
3 | import (
4 | "context"
5 | "embed"
6 | "fmt"
7 | "io"
8 | "io/fs"
9 | "log"
10 | "os"
11 | "path/filepath"
12 | "slices"
13 | "strings"
14 |
15 | "github.com/delaneyj/toolbelt"
16 | )
17 |
18 | //go:embed migrations/*.sql
19 | var migrationsFS embed.FS
20 |
21 | func SetupDB(ctx context.Context, dataFolder string, shouldClear bool) (*toolbelt.Database, error) {
22 | migrationsDir := "migrations"
23 | migrationsFiles, err := migrationsFS.ReadDir(migrationsDir)
24 | if err != nil {
25 | return nil, fmt.Errorf("failed to read migrations directory: %w", err)
26 | }
27 | slices.SortFunc(migrationsFiles, func(a, b fs.DirEntry) int {
28 | return strings.Compare(a.Name(), b.Name())
29 | })
30 |
31 | migrations := make([]string, len(migrationsFiles))
32 | for i, file := range migrationsFiles {
33 | fn := filepath.Join(migrationsDir, file.Name())
34 | fnts := filepath.ToSlash(fn)
35 | f, err := migrationsFS.Open(fnts)
36 | if err != nil {
37 | return nil, fmt.Errorf("failed to open migration file: %w", err)
38 | }
39 | defer f.Close()
40 |
41 | content, err := io.ReadAll(f)
42 | if err != nil {
43 | return nil, fmt.Errorf("failed to read migration file: %w", err)
44 | }
45 |
46 | migrations[i] = string(content)
47 | }
48 |
49 | dbFolder := filepath.Join(dataFolder, "database")
50 | if shouldClear {
51 | log.Printf("Clearing database folder: %s", dbFolder)
52 | if err := os.RemoveAll(dbFolder); err != nil {
53 | return nil, fmt.Errorf("failed to remove database folder: %w", err)
54 | }
55 | }
56 | dbFilename := filepath.Join(dbFolder, "examples.sqlite")
57 | db, err := toolbelt.NewDatabase(
58 | ctx,
59 | toolbelt.DatabaseWithFilename(dbFilename),
60 | toolbelt.DatabaseWithMigrations(migrations),
61 | )
62 | if err != nil {
63 | return nil, fmt.Errorf("failed to create database: %w", err)
64 | }
65 |
66 | return db, nil
67 | }
68 |
--------------------------------------------------------------------------------
/wisshes/linode/types.go:
--------------------------------------------------------------------------------
1 | package linode
2 |
3 | import (
4 | "bytes"
5 | "context"
6 | "fmt"
7 | "os"
8 | "path/filepath"
9 |
10 | "github.com/delaneyj/toolbelt/wisshes"
11 | "github.com/goccy/go-json"
12 | "github.com/linode/linodego"
13 | )
14 |
15 | const ctxLinodeKeyInstanceTypes wisshes.CtxKey = "linode-instance-types"
16 |
17 | func CtxLinodeInstanceTypes(ctx context.Context) []linodego.LinodeType {
18 | return ctx.Value(ctxLinodeKeyInstanceTypes).([]linodego.LinodeType)
19 | }
20 |
21 | func CtxWithLinodeInstanceTypes(ctx context.Context, instanceTypes []linodego.LinodeType) context.Context {
22 | return context.WithValue(ctx, ctxLinodeKeyInstanceTypes, instanceTypes)
23 | }
24 |
25 | func InstanceTypes() wisshes.Step {
26 | return func(ctx context.Context) (context.Context, string, wisshes.StepStatus, error) {
27 | name := "linode_instance_types"
28 |
29 | linodeClient := CtxLinodeClient(ctx)
30 | if linodeClient == nil {
31 | return ctx, name, wisshes.StepStatusFailed, fmt.Errorf("linode client not found")
32 | }
33 |
34 | linodeTypes, err := linodeClient.ListTypes(ctx, nil)
35 | if err != nil {
36 | return ctx, name, wisshes.StepStatusFailed, fmt.Errorf("list types: %w", err)
37 | }
38 |
39 | b, err := json.MarshalIndent(linodeTypes, "", " ")
40 | if err != nil {
41 | return ctx, name, wisshes.StepStatusFailed, fmt.Errorf("marshal: %w", err)
42 | }
43 |
44 | fp := filepath.Join(wisshes.ArtifactsDir(), name+".json")
45 | if previous, err := os.ReadFile(fp); err == nil {
46 | if bytes.Equal(previous, b) {
47 | ctx = CtxWithLinodeInstanceTypes(ctx, linodeTypes)
48 | return ctx, name, wisshes.StepStatusUnchanged, nil
49 | }
50 | }
51 |
52 | if err := os.WriteFile(fp, b, 0644); err != nil {
53 | return ctx, name, wisshes.StepStatusFailed, fmt.Errorf("write checksum: %w", err)
54 | }
55 |
56 | ctx = CtxWithLinodeInstanceTypes(ctx, linodeTypes)
57 | return ctx, name, wisshes.StepStatusChanged, nil
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/sqlc-gen-zombiezen/README.md:
--------------------------------------------------------------------------------
1 | # SQLC Zombiezen driver plugin
2 |
3 | ## How to use
4 |
5 | 1. Install the plugin:
6 | ```shell
7 | go install github.com/delaneyj/toolbelt/sqlc-gen-zombiezen@latest
8 | ```
9 | 2. Make a `sqlc.yaml` similar to the following:
10 |
11 | ```yaml
12 | version: "2"
13 |
14 | plugins:
15 | - name: zz
16 | process:
17 | cmd: sqlc-gen-zombiezen
18 |
19 | sql:
20 | - engine: sqlite
21 | queries: "./queries"
22 | schema: "./migrations"
23 | codegen:
24 | - out: zz
25 | plugin: zz
26 | ```
27 |
28 | The generator understands `sqlc.slice('param')` macros and will emit query helpers that accept Go slices, rewrite the SQL placeholders, and bind each element for you.
29 |
30 | ### Options
31 |
32 | You can configure the plugin with the `options` block on a codegen target. For example,
33 | to skip generating CRUD helpers and automatic `time.Time` coercion:
34 |
35 | ```yaml
36 | sql:
37 | - engine: sqlite
38 | queries: ./queries
39 | schema: ./migrations
40 | codegen:
41 | - out: zz
42 | plugin: zz
43 | options:
44 | disable_crud: true
45 | disable_time_conversion: true
46 | ```
47 |
48 | - `disable_crud` (default `false`): Skip generating CRUD helpers.
49 | - `disable_time_conversion` (default `false`): Leave timestamp-like columns as their raw types instead of `time.Time`.
50 |
51 | The generated Go package name is automatically derived from the final segment of the configured `out` path, so pointing `out` at `internal/drivers` yields package `drivers`.
52 |
53 | The plugin removes the `out` directory before every run so stale files don’t linger.
54 |
55 | To generate code into a different folder (and package), just adjust `out`:
56 |
57 | ```yaml
58 | sql:
59 | - engine: sqlite
60 | queries: ./queries
61 | schema: ./migrations
62 | codegen:
63 | - out: internal/codegen
64 | plugin: zz
65 | ```
66 |
67 | 3. Run sqlc: `sqlc generate`
68 | 4. ???
69 | 5. Profit
70 |
--------------------------------------------------------------------------------
/wisshes/linode/client.go:
--------------------------------------------------------------------------------
1 | package linode
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | "log"
7 | "net/http"
8 |
9 | "github.com/delaneyj/toolbelt/wisshes"
10 | "github.com/linode/linodego"
11 | "golang.org/x/oauth2"
12 | )
13 |
14 | const (
15 | ctxLinodeKeyClient = ctxLinodeKeyPrefix + "client"
16 | ctxLinodeKeyAccount = ctxLinodeKeyPrefix + "account"
17 | )
18 |
19 | func CtxLinodeClient(ctx context.Context) *linodego.Client {
20 | return ctx.Value(ctxLinodeKeyClient).(*linodego.Client)
21 | }
22 |
23 | func CtxWithLinodeClient(ctx context.Context, client *linodego.Client) context.Context {
24 | return context.WithValue(ctx, ctxLinodeKeyClient, client)
25 | }
26 |
27 | func CtxLinodeAccount(ctx context.Context) *linodego.Account {
28 | return ctx.Value(ctxLinodeKeyAccount).(*linodego.Account)
29 | }
30 |
31 | func CtxWithLinodeAccount(ctx context.Context, account *linodego.Account) context.Context {
32 | return context.WithValue(ctx, ctxLinodeKeyAccount, account)
33 | }
34 |
35 | func ClientAndAccount(token string) wisshes.Step {
36 |
37 | return func(ctx context.Context) (context.Context, string, wisshes.StepStatus, error) {
38 | name := "linode client and account"
39 |
40 | linodeClient, acc, err := ClientFromToken(ctx, token)
41 | if err != nil {
42 | return ctx, name, wisshes.StepStatusFailed, fmt.Errorf("failed to get linode client and account: %w", err)
43 | }
44 |
45 | ctx = CtxWithLinodeClient(ctx, linodeClient)
46 | ctx = CtxWithLinodeAccount(ctx, acc)
47 | return ctx, name, wisshes.StepStatusUnchanged, nil
48 | }
49 | }
50 |
51 | func ClientFromToken(ctx context.Context, token string) (*linodego.Client, *linodego.Account, error) {
52 | tokenSource := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token})
53 | oauth2Client := &http.Client{
54 | Transport: &oauth2.Transport{
55 | Source: tokenSource,
56 | },
57 | }
58 |
59 | linodeClient := linodego.NewClient(oauth2Client)
60 | //linodeClient.SetDebug(true)
61 |
62 | acc, err := linodeClient.GetAccount(ctx)
63 | if err != nil {
64 | log.Printf("failed to get account: %v", err)
65 | return nil, nil, fmt.Errorf("failed to get account: %w", err)
66 | }
67 |
68 | return &linodeClient, acc, nil
69 |
70 | }
71 |
--------------------------------------------------------------------------------
/wisshes/cond.go:
--------------------------------------------------------------------------------
1 | package wisshes
2 |
3 | import (
4 | "context"
5 | "errors"
6 | "fmt"
7 | "log"
8 | "strings"
9 | )
10 |
11 | func RunAll(steps ...Step) Step {
12 | return func(ctx context.Context) (context.Context, string, StepStatus, error) {
13 | names := make([]string, len(steps))
14 | statuses := make([]StepStatus, len(steps))
15 | errs := make([]error, len(steps))
16 |
17 | for i, step := range steps {
18 | var (
19 | name string
20 | status StepStatus
21 | err error
22 | )
23 | ctx, name, status, err = step(ctx)
24 | if ctx == nil {
25 | panic("ctx is nil")
26 | }
27 |
28 | log.Printf("step %s: %s", name, status)
29 |
30 | names[i] = name
31 | statuses[i] = status
32 | errs[i] = err
33 |
34 | ctx = CtxWithPreviousStep(ctx, status)
35 |
36 | if err != nil {
37 | break
38 | }
39 | }
40 |
41 | name := fmt.Sprintf("run-all-%s", strings.Join(names, "-"))
42 |
43 | if err := errors.Join(errs...); err != nil {
44 | return ctx, name, StepStatusFailed, err
45 | }
46 |
47 | for _, status := range statuses {
48 | if status == StepStatusChanged {
49 | return ctx, name, StepStatusChanged, nil
50 | }
51 | }
52 |
53 | return ctx, name, StepStatusUnchanged, nil
54 | }
55 | }
56 |
57 | func IfPreviousChanged(steps ...Step) Step {
58 | return func(ctx context.Context) (context.Context, string, StepStatus, error) {
59 | prevStep := CtxPreviousStep(ctx)
60 | if prevStep != StepStatusChanged {
61 | return ctx, "if-prev-changed", StepStatusUnchanged, nil
62 | }
63 |
64 | ctx, n, s, err := RunAll(steps...)(ctx)
65 | name := fmt.Sprintf("if-prev-changed-%s", n)
66 | return ctx, name, s, err
67 | }
68 | }
69 |
70 | func IfCond(cond bool, steps ...Step) Step {
71 | return func(ctx context.Context) (context.Context, string, StepStatus, error) {
72 | if !cond {
73 | return nil, "if-cond", StepStatusUnchanged, nil
74 | }
75 |
76 | ctx, n, s, err := RunAll(steps...)(ctx)
77 | name := fmt.Sprintf("if-cond-%s", n)
78 | return ctx, name, s, err
79 | }
80 | }
81 |
82 | func Ternary(cond bool, ifTrue, ifFalse Step) Step {
83 | return func(ctx context.Context) (context.Context, string, StepStatus, error) {
84 | if cond {
85 | return ifTrue(ctx)
86 | }
87 | return ifFalse(ctx)
88 | }
89 | }
90 |
--------------------------------------------------------------------------------
/jtd/examples/notification.json:
--------------------------------------------------------------------------------
1 | {
2 | "metadata": {
3 | "description": "Notification system schema with discriminated unions"
4 | },
5 | "definitions": {
6 | "notification": {
7 | "metadata": {
8 | "description": "represents different types of notifications"
9 | },
10 | "discriminator": "type",
11 | "mapping": {
12 | "email": {
13 | "properties": {
14 | "to": { "type": "string" },
15 | "from": { "type": "string" },
16 | "subject": { "type": "string" },
17 | "body": { "type": "string" },
18 | "isHtml": { "type": "boolean" }
19 | },
20 | "optionalProperties": {
21 | "cc": { "elements": { "type": "string" } },
22 | "bcc": { "elements": { "type": "string" } },
23 | "attachments": {
24 | "elements": {
25 | "properties": {
26 | "filename": { "type": "string" },
27 | "contentType": { "type": "string" },
28 | "size": { "type": "int32" }
29 | }
30 | }
31 | }
32 | }
33 | },
34 | "sms": {
35 | "properties": {
36 | "to": { "type": "string" },
37 | "message": { "type": "string" }
38 | },
39 | "optionalProperties": {
40 | "provider": { "type": "string" }
41 | }
42 | },
43 | "push": {
44 | "properties": {
45 | "deviceToken": { "type": "string" },
46 | "title": { "type": "string" },
47 | "body": { "type": "string" }
48 | },
49 | "optionalProperties": {
50 | "badge": { "type": "int32" },
51 | "sound": { "type": "string" },
52 | "data": { "values": { "type": "string" } }
53 | }
54 | }
55 | }
56 | },
57 | "notificationStatus": {
58 | "enum": ["pending", "sent", "delivered", "failed"]
59 | },
60 | "notificationLog": {
61 | "properties": {
62 | "id": { "type": "string" },
63 | "notification": { "ref": "notification" },
64 | "status": { "ref": "notificationStatus" },
65 | "timestamp": { "type": "timestamp" },
66 | "attempts": { "type": "int32" }
67 | },
68 | "optionalProperties": {
69 | "error": { "type": "string", "nullable": true }
70 | }
71 | }
72 | }
73 | }
--------------------------------------------------------------------------------
/wisshes/apt.go:
--------------------------------------------------------------------------------
1 | package wisshes
2 |
3 | import (
4 | "context"
5 | "errors"
6 | "fmt"
7 | "log"
8 | "strings"
9 |
10 | "github.com/delaneyj/toolbelt"
11 | )
12 |
13 | type AptitudeStatus string
14 |
15 | const (
16 | AptitudeStatusUninstalled AptitudeStatus = "uninstalled"
17 | AptitudeStatusInstalled AptitudeStatus = "installed"
18 | )
19 |
20 | func Aptitude(desiredStatus AptitudeStatus, packageNames ...string) Step {
21 | return func(ctx context.Context) (context.Context, string, StepStatus, error) {
22 | client := CtxSSHClient(ctx)
23 |
24 | name := fmt.Sprintf("aptitude-%s-%s", desiredStatus, toolbelt.Kebab(strings.Join(packageNames, "-")))
25 |
26 | out, err := RunF(client, "apt-get update")
27 | if err != nil {
28 | return ctx, name, StepStatusFailed, fmt.Errorf("apt update: %w", err)
29 | }
30 | log.Print(out)
31 |
32 | results := make([]StepStatus, len(packageNames))
33 | errs := make([]error, len(packageNames))
34 | for i, packageName := range packageNames {
35 | query, err := RunF(client, "dpkg -l %s", packageName)
36 |
37 | isNotInstalled := strings.Contains(query, "no packages found matching")
38 | shouldInstall := err != nil || (desiredStatus == AptitudeStatusInstalled && isNotInstalled)
39 | shouldUninstall := desiredStatus == AptitudeStatusUninstalled && !isNotInstalled
40 |
41 | if !shouldInstall && !shouldUninstall {
42 | results[i] = StepStatusUnchanged
43 | continue
44 | }
45 |
46 | switch desiredStatus {
47 | case AptitudeStatusInstalled:
48 | log.Printf("installing %s", packageName)
49 | out, err := RunF(client, "apt-get install -y %s", packageName)
50 | if err != nil {
51 | log.Print(out)
52 | results[i] = StepStatusFailed
53 | errs[i] = fmt.Errorf("apt-get install: %w", err)
54 | continue
55 | }
56 | case AptitudeStatusUninstalled:
57 | log.Printf("removing %s", packageName)
58 | out, err := RunF(client, "apt remove -y %s", packageName)
59 | if err != nil {
60 | log.Print(out)
61 | results[i] = StepStatusFailed
62 | errs[i] = fmt.Errorf("apt remove: %w", err)
63 | continue
64 | }
65 | default:
66 | panic("unreachable")
67 | }
68 | }
69 |
70 | if err := errors.Join(errs...); err != nil {
71 | return ctx, name, StepStatusFailed, err
72 | }
73 |
74 | for _, result := range results {
75 | if result == StepStatusChanged {
76 | return ctx, name, result, nil
77 | }
78 | }
79 |
80 | return ctx, name, StepStatusUnchanged, nil
81 | }
82 | }
83 |
--------------------------------------------------------------------------------
/jtd/examples/notification.go:
--------------------------------------------------------------------------------
1 | package types
2 |
3 | import (
4 | "time"
5 | )
6 |
7 | // Notification represents different types of notifications
8 | // Notification is a discriminated union
9 | type Notification interface {
10 | isNotification()
11 | Type() string
12 | }
13 |
14 | // NotificationEmail is the "email" variant of Notification
15 | type NotificationEmail struct {
16 | Type_ string `json:"type"`
17 | Attachments []struct{} `json:"attachments,omitempty"`
18 | Bcc []string `json:"bcc,omitempty"`
19 | Body string `json:"body"`
20 | Cc []string `json:"cc,omitempty"`
21 | From string `json:"from"`
22 | IsHtml bool `json:"isHtml"`
23 | Subject string `json:"subject"`
24 | To string `json:"to"`
25 | }
26 |
27 | func (NotificationEmail) isNotification() {}
28 | func (v NotificationEmail) Type() string { return v.Type_ }
29 |
30 | // NotificationSms is the "sms" variant of Notification
31 | type NotificationSms struct {
32 | Type_ string `json:"type"`
33 | Message string `json:"message"`
34 | Provider *string `json:"provider,omitempty"`
35 | To string `json:"to"`
36 | }
37 |
38 | func (NotificationSms) isNotification() {}
39 | func (v NotificationSms) Type() string { return v.Type_ }
40 |
41 | // NotificationPush is the "push" variant of Notification
42 | type NotificationPush struct {
43 | Type_ string `json:"type"`
44 | Badge *int32 `json:"badge,omitempty"`
45 | Body string `json:"body"`
46 | Data map[string]string `json:"data,omitempty"`
47 | DeviceToken string `json:"deviceToken"`
48 | Sound *string `json:"sound,omitempty"`
49 | Title string `json:"title"`
50 | }
51 |
52 | func (NotificationPush) isNotification() {}
53 | func (v NotificationPush) Type() string { return v.Type_ }
54 |
55 | type NotificationStatus string
56 |
57 | const (
58 | NotificationStatusPending NotificationStatus = "pending"
59 | NotificationStatusSent NotificationStatus = "sent"
60 | NotificationStatusDelivered NotificationStatus = "delivered"
61 | NotificationStatusFailed NotificationStatus = "failed"
62 | )
63 |
64 | type NotificationLog struct {
65 | Attempts int32 `json:"attempts"`
66 | Error *string `json:"error,omitempty"`
67 | Id string `json:"id"`
68 | Notification Notification `json:"notification"`
69 | Status NotificationStatus `json:"status"`
70 | Timestamp time.Time `json:"timestamp"`
71 | }
72 |
--------------------------------------------------------------------------------
/bytebufferpool/bytebuffer.go:
--------------------------------------------------------------------------------
1 | package bytebufferpool
2 |
3 | import "io"
4 |
5 | // ByteBuffer provides a byte buffer that minimizes allocations.
6 | //
7 | // Use Get for obtaining an empty byte buffer.
8 | type ByteBuffer struct {
9 | buf []byte
10 | }
11 |
12 | // Len returns the size of the byte buffer.
13 | func (b *ByteBuffer) Len() int {
14 | return len(b.buf)
15 | }
16 |
17 | // ReadFrom implements io.ReaderFrom by appending all data read from r.
18 | func (b *ByteBuffer) ReadFrom(r io.Reader) (int64, error) {
19 | p := b.buf
20 | nStart := int64(len(p))
21 | nMax := int64(cap(p))
22 | n := nStart
23 | if nMax == 0 {
24 | nMax = 64
25 | p = make([]byte, nMax)
26 | } else {
27 | p = p[:nMax]
28 | }
29 | for {
30 | if n == nMax {
31 | nMax *= 2
32 | bNew := make([]byte, nMax)
33 | copy(bNew, p)
34 | p = bNew
35 | }
36 | nn, err := r.Read(p[n:])
37 | n += int64(nn)
38 | if err != nil {
39 | b.buf = p[:n]
40 | n -= nStart
41 | if err == io.EOF {
42 | return n, nil
43 | }
44 | return n, err
45 | }
46 | }
47 | }
48 |
49 | // WriteTo implements io.WriterTo.
50 | func (b *ByteBuffer) WriteTo(w io.Writer) (int64, error) {
51 | n, err := w.Write(b.buf)
52 | return int64(n), err
53 | }
54 |
55 | // Bytes returns the accumulated bytes in the buffer.
56 | //
57 | // The returned slice aliases the internal buffer.
58 | func (b *ByteBuffer) Bytes() []byte {
59 | return b.buf
60 | }
61 |
62 | // Write implements io.Writer by appending p to the buffer.
63 | func (b *ByteBuffer) Write(p []byte) (int, error) {
64 | b.buf = append(b.buf, p...)
65 | return len(p), nil
66 | }
67 |
68 | // WriteByte appends the byte c to the buffer.
69 | //
70 | // The function always returns nil.
71 | func (b *ByteBuffer) WriteByte(c byte) error {
72 | b.buf = append(b.buf, c)
73 | return nil
74 | }
75 |
76 | // WriteString appends s to the buffer.
77 | func (b *ByteBuffer) WriteString(s string) (int, error) {
78 | b.buf = append(b.buf, s...)
79 | return len(s), nil
80 | }
81 |
82 | // Set replaces the buffer contents with p.
83 | func (b *ByteBuffer) Set(p []byte) {
84 | b.buf = append(b.buf[:0], p...)
85 | }
86 |
87 | // SetString replaces the buffer contents with s.
88 | func (b *ByteBuffer) SetString(s string) {
89 | b.buf = append(b.buf[:0], s...)
90 | }
91 |
92 | // String returns the string representation of the buffer contents.
93 | func (b *ByteBuffer) String() string {
94 | return string(b.buf)
95 | }
96 |
97 | // Reset makes the buffer empty.
98 | func (b *ByteBuffer) Reset() {
99 | b.buf = b.buf[:0]
100 | }
101 |
--------------------------------------------------------------------------------
/math.go:
--------------------------------------------------------------------------------
1 | package toolbelt
2 |
3 | import (
4 | "math"
5 | "math/rand"
6 |
7 | "github.com/chewxy/math32"
8 | )
9 |
10 | type Float interface {
11 | ~float32 | ~float64
12 | }
13 |
14 | type Integer interface {
15 | ~int | ~uint8 | ~int8 | ~uint16 | ~int16 | ~uint32 | ~int32 | ~uint64 | ~int64
16 | }
17 |
18 | func Fit[T Float](
19 | x T,
20 | oldMin T,
21 | oldMax T,
22 | newMin T,
23 | newMax T,
24 | ) T {
25 | // Normalize x to [0,1], apply linear easing, then scale to new range.
26 | // This is equivalent to linear interpolation but keeps easing extensible.
27 | t := (x - oldMin) / (oldMax - oldMin)
28 | e := EaseLinear(t)
29 | return newMin + e*(newMax-newMin)
30 | }
31 |
32 | func Fit01[T Float](x T, newMin T, newMax T) T {
33 | // x is expected in [0,1]. Apply linear easing and scale to new range.
34 | e := EaseLinear(x)
35 | return newMin + e*(newMax-newMin)
36 | }
37 |
38 | func RoundFit01[T Float](x T, newMin T, newMax T) T {
39 | switch any(x).(type) {
40 | case float32:
41 | f := float32(x)
42 | nmin := float32(newMin)
43 | nmax := float32(newMax)
44 | return T(math32.Round(Fit01(f, nmin, nmax)))
45 | case float64:
46 | f := float64(x)
47 | nmin := float64(newMin)
48 | nmax := float64(newMax)
49 | return T(math.Round(Fit01(f, nmin, nmax)))
50 | default:
51 | panic("unsupported type")
52 | }
53 | }
54 |
55 | func FitMax[T Float](x T, newMax T) T {
56 | return Fit01(x, 0, newMax)
57 | }
58 |
59 | func Clamp[T Float](v T, minimum T, maximum T) T {
60 | realMin := minimum
61 | realMax := maximum
62 | if maximum < realMin {
63 | realMin = maximum
64 | realMax = minimum
65 | }
66 | return max(realMin, min(realMax, v))
67 | }
68 |
69 | func ClampFit[T Float](
70 | x T,
71 | oldMin T,
72 | oldMax T,
73 | newMin T,
74 | newMax T,
75 | ) T {
76 | f := Fit(x, oldMin, oldMax, newMin, newMax)
77 | return Clamp(f, newMin, newMax)
78 | }
79 |
80 | func ClampFit01[T Float](x T, newMin T, newMax T) T {
81 | f := Fit01(x, newMin, newMax)
82 | return Clamp(f, newMin, newMax)
83 | }
84 |
85 | func Clamp01[T Float](v T) T {
86 | return Clamp(v, 0, 1)
87 | }
88 |
89 | func RandNegOneToOneClamped[T Float](r *rand.Rand) T {
90 | switch any(*new(T)).(type) {
91 | case float32:
92 | return T(ClampFit(r.Float32(), 0, 1, -1, 1))
93 | case float64:
94 | return T(ClampFit(r.Float64(), 0, 1, -1, 1))
95 | default:
96 | panic("unsupported type")
97 | }
98 | }
99 |
100 | func RandIntRange[T Integer](r *rand.Rand, min, max T) T {
101 | return T(Fit(r.Float32(), 0, 1, float32(min), float32(max)))
102 | }
103 |
104 | func RandSliceItem[T any](r *rand.Rand, slice []T) T {
105 | return slice[r.Intn(len(slice))]
106 | }
107 |
--------------------------------------------------------------------------------
/eventbus.go:
--------------------------------------------------------------------------------
1 | package toolbelt
2 |
3 | import (
4 | "context"
5 | "errors"
6 | "sync"
7 | )
8 |
9 | type Subscriber[T any] func(msg T) error
10 |
11 | type EventBus[T any] interface {
12 | Subscribe(ctx context.Context, fn Subscriber[T]) context.CancelFunc
13 | Emit(ctx context.Context, msg T) error
14 | Count() int
15 | }
16 |
17 | type baseEventBus[T any] struct {
18 | mu sync.RWMutex
19 | subs []*Subscriber[T]
20 | }
21 |
22 | func newBaseEventBus[T any]() *baseEventBus[T] {
23 | return &baseEventBus[T]{
24 | subs: []*Subscriber[T]{},
25 | }
26 | }
27 |
28 | func (b *baseEventBus[T]) Subscribe(ctx context.Context, fn Subscriber[T]) context.CancelFunc {
29 | b.mu.Lock()
30 | defer b.mu.Unlock()
31 |
32 | _, cancelCtx := context.WithCancel(ctx)
33 |
34 | fnPtr := &fn
35 | b.subs = append(b.subs, fnPtr)
36 | cancel := func() {
37 | defer cancelCtx()
38 |
39 | b.mu.Lock()
40 | defer b.mu.Unlock()
41 |
42 | // Remove the subscriber from the list
43 | for i, sub := range b.subs {
44 | if sub == fnPtr {
45 | b.subs = append(b.subs[:i], b.subs[i+1:]...)
46 | break
47 | }
48 | }
49 | }
50 | return cancel
51 | }
52 |
53 | func (b *baseEventBus[T]) Count() int {
54 | b.mu.RLock()
55 | defer b.mu.RUnlock()
56 | return len(b.subs)
57 | }
58 |
59 | type EventBusSync[T any] struct {
60 | *baseEventBus[T]
61 | }
62 |
63 | func NewEventBusSync[T any]() *EventBusSync[T] {
64 | return &EventBusSync[T]{
65 | baseEventBus: newBaseEventBus[T](),
66 | }
67 | }
68 |
69 | func (b *EventBusSync[T]) Emit(ctx context.Context, msg T) error {
70 | b.mu.RLock()
71 | defer b.mu.RUnlock()
72 | for _, sub := range b.subs {
73 | if err := (*sub)(msg); err != nil {
74 | return err
75 | }
76 | }
77 | return nil
78 | }
79 |
80 | type EventBusAsync[T any] struct {
81 | *baseEventBus[T]
82 | errs []error
83 | mu sync.Mutex
84 | wg sync.WaitGroup
85 | }
86 |
87 | func NewEventBusAsync[T any]() *EventBusAsync[T] {
88 | return &EventBusAsync[T]{
89 | baseEventBus: newBaseEventBus[T](),
90 | }
91 | }
92 |
93 | func (b *EventBusAsync[T]) Emit(ctx context.Context, msg T) error {
94 | b.mu.Lock()
95 | defer b.mu.Unlock()
96 |
97 | // Subs might be modified while we are iterating over them,
98 | // so we need to copy them first.
99 | b.baseEventBus.mu.RLock()
100 | subs := make([]*Subscriber[T], len(b.subs))
101 | copy(subs, b.subs)
102 | b.baseEventBus.mu.RUnlock()
103 |
104 | clear(b.errs)
105 |
106 | b.wg.Add(len(subs))
107 | for _, sub := range subs {
108 | go func(sub Subscriber[T]) {
109 | defer b.wg.Done()
110 | if err := sub(msg); err != nil {
111 | b.errs = append(b.errs, err)
112 | }
113 | }(*sub)
114 | }
115 | b.wg.Wait()
116 | return errors.Join(b.errs...)
117 | }
118 |
--------------------------------------------------------------------------------
/sqlc-gen-zombiezen/zombiezen/crud.go:
--------------------------------------------------------------------------------
1 | package zombiezen
2 |
3 | import (
4 | "fmt"
5 | "strings"
6 |
7 | "github.com/delaneyj/toolbelt"
8 | pluralize "github.com/gertd/go-pluralize"
9 | "github.com/sqlc-dev/plugin-sdk-go/plugin"
10 | )
11 |
12 | func generateCRUD(req *plugin.GenerateRequest, opts *Options, packageName toolbelt.CasedString) (files []*plugin.File, err error) {
13 | pluralClient := pluralize.NewClient()
14 | for _, schema := range req.Catalog.Schemas {
15 | schemaName := toolbelt.ToCasedString(schema.Name)
16 |
17 | for _, table := range schema.Tables {
18 | tbl := &GenerateCRUDTable{
19 | PackageName: packageName,
20 | Schema: schemaName,
21 | Name: toolbelt.ToCasedString(table.Rel.Name),
22 | SingleName: toolbelt.ToCasedString(pluralClient.Singular(table.Rel.Name)),
23 | }
24 |
25 | if strings.HasSuffix(tbl.Name.Snake, "_fts") {
26 | continue
27 | }
28 | needsToolbelt := false
29 | for i, column := range table.Columns {
30 | if column.Name == "id" {
31 | tbl.HasID = true
32 | }
33 | columnName := toolbelt.ToCasedString(column.Name)
34 |
35 | goType, needsTime := toGoType(column, opts)
36 | if needsTime {
37 | tbl.NeedsTimePackage = true
38 | }
39 | f := GenerateField{
40 | Column: i + 1,
41 | Offset: i,
42 | Name: columnName,
43 | SQLType: toolbelt.ToCasedString(toSQLType(column)),
44 | GoType: toolbelt.ToCasedString(goType),
45 | BindGoType: toolbelt.ToCasedString(goType),
46 | OriginalName: column.Name,
47 | IsNullable: !column.NotNull,
48 | DurationFromText: isDurationFromText(column),
49 | }
50 | tbl.Fields = append(tbl.Fields, f)
51 |
52 | if usesToolbeltField(f) {
53 | needsToolbelt = true
54 | }
55 | if f.GoType.Original == "time.Duration" || f.GoType.Original == "time.Time" {
56 | tbl.NeedsTimePackage = true
57 | }
58 | }
59 |
60 | tbl.NeedsToolbelt = needsToolbelt
61 |
62 | contents := GenerateCRUD(tbl)
63 | filename := fmt.Sprintf("crud_%s_%s.go", schemaName.Snake, tbl.Name.Snake)
64 |
65 | files = append(files, &plugin.File{
66 | Name: filename,
67 | Contents: []byte(contents),
68 | })
69 | }
70 | }
71 | return files, nil
72 | }
73 |
74 | type GenerateCRUDTable struct {
75 | PackageName toolbelt.CasedString
76 | NeedsTimePackage bool
77 | NeedsToolbelt bool
78 | Schema toolbelt.CasedString
79 | Name toolbelt.CasedString
80 | SingleName toolbelt.CasedString
81 | Fields []GenerateField
82 | HasID bool
83 | }
84 |
85 | func usesToolbeltField(f GenerateField) bool {
86 | switch f.GoType.Original {
87 | case "time.Time":
88 | return true
89 | case "time.Duration":
90 | return !f.DurationFromText
91 | case "[]byte":
92 | return true
93 | default:
94 | return false
95 | }
96 | }
97 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/delaneyj/toolbelt
2 |
3 | go 1.24.1
4 |
5 | toolchain go1.24.2
6 |
7 | require (
8 | github.com/alecthomas/kong v1.13.0
9 | github.com/autosegment/ksuid v1.1.0
10 | github.com/cenkalti/backoff v2.2.1+incompatible
11 | github.com/chewxy/math32 v1.11.1
12 | github.com/denisbrodbeck/machineid v1.0.1
13 | github.com/dustin/go-humanize v1.0.1
14 | github.com/gertd/go-pluralize v0.2.1
15 | github.com/goccy/go-json v0.10.5
16 | github.com/joho/godotenv v1.5.1
17 | github.com/linode/linodego v1.61.0
18 | github.com/melbahja/goph v1.4.0
19 | github.com/nats-io/nats-server/v2 v2.12.2
20 | github.com/nats-io/nats.go v1.47.0
21 | github.com/rzajac/zflake v0.8.1
22 | github.com/samber/lo v1.52.0
23 | github.com/sqlc-dev/plugin-sdk-go v1.23.0
24 | github.com/stretchr/testify v1.11.1
25 | github.com/valyala/quicktemplate v1.8.0
26 | github.com/zeebo/xxh3 v1.0.2
27 | golang.org/x/crypto v0.45.0
28 | golang.org/x/oauth2 v0.33.0
29 | golang.org/x/sync v0.18.0
30 | golang.org/x/tools v0.39.0
31 | google.golang.org/protobuf v1.36.10
32 | k8s.io/apimachinery v0.34.2
33 | zombiezen.com/go/sqlite v1.4.2
34 | )
35 |
36 | require (
37 | github.com/antithesishq/antithesis-sdk-go v0.5.0 // indirect
38 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
39 | github.com/go-resty/resty/v2 v2.17.0 // indirect
40 | github.com/google/go-querystring v1.1.0 // indirect
41 | github.com/google/go-tpm v0.9.7 // indirect
42 | github.com/google/uuid v1.6.0 // indirect
43 | github.com/klauspost/compress v1.18.2 // indirect
44 | github.com/klauspost/cpuid/v2 v2.3.0 // indirect
45 | github.com/kr/fs v0.1.0 // indirect
46 | github.com/kr/pretty v0.3.1 // indirect
47 | github.com/mattn/go-isatty v0.0.20 // indirect
48 | github.com/minio/highwayhash v1.0.4-0.20251030100505-070ab1a87a76 // indirect
49 | github.com/nats-io/jwt/v2 v2.8.0 // indirect
50 | github.com/nats-io/nkeys v0.4.12 // indirect
51 | github.com/nats-io/nuid v1.0.1 // indirect
52 | github.com/ncruces/go-strftime v1.0.0 // indirect
53 | github.com/pkg/errors v0.9.1 // indirect
54 | github.com/pkg/sftp v1.13.10 // indirect
55 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
56 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
57 | github.com/valyala/bytebufferpool v1.0.0 // indirect
58 | golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39 // indirect
59 | golang.org/x/mod v0.30.0 // indirect
60 | golang.org/x/net v0.47.0 // indirect
61 | golang.org/x/sys v0.38.0 // indirect
62 | golang.org/x/text v0.31.0 // indirect
63 | golang.org/x/time v0.14.0 // indirect
64 | google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
65 | google.golang.org/grpc v1.77.0 // indirect
66 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
67 | gopkg.in/ini.v1 v1.67.0 // indirect
68 | gopkg.in/yaml.v3 v3.0.1 // indirect
69 | modernc.org/libc v1.67.1 // indirect
70 | modernc.org/mathutil v1.7.1 // indirect
71 | modernc.org/memory v1.11.0 // indirect
72 | modernc.org/sqlite v1.40.1 // indirect
73 | )
74 |
--------------------------------------------------------------------------------
/natsrpc/shared_go.qtpl.go:
--------------------------------------------------------------------------------
1 | // Code generated by qtc from "shared_go.qtpl". DO NOT EDIT.
2 | // See https://github.com/valyala/quicktemplate for details.
3 |
4 | //line shared_go.qtpl:1
5 | package natsrpc
6 |
7 | //line shared_go.qtpl:1
8 | import (
9 | qtio422016 "io"
10 |
11 | qt422016 "github.com/valyala/quicktemplate"
12 | )
13 |
14 | //line shared_go.qtpl:1
15 | var (
16 | _ = qtio422016.Copy
17 | _ = qt422016.AcquireByteBuffer
18 | )
19 |
20 | //line shared_go.qtpl:1
21 | func streamgoSharedTypesTemplate(qw422016 *qt422016.Writer, pkg *packageTmplData) {
22 | //line shared_go.qtpl:1
23 | qw422016.N().S(`
24 | // Code generated by protoc-gen-go-natsrpc. DO NOT EDIT.
25 |
26 | package `)
27 | //line shared_go.qtpl:4
28 | qw422016.E().S(pkg.PackageName.Snake)
29 | //line shared_go.qtpl:4
30 | qw422016.N().S(`
31 |
32 | import (
33 | "fmt"
34 | "time"
35 |
36 | "github.com/nats-io/nats.go"
37 | "google.golang.org/protobuf/proto"
38 | )
39 |
40 | const NatsRpcErrorHeader = "error"
41 |
42 | type NatsRpcOptions struct {
43 | Timeout time.Duration
44 | }
45 | type NatsRpcOption func(*NatsRpcOptions)
46 |
47 | func WithTimeout(timeout time.Duration) NatsRpcOption {
48 | return func(opt *NatsRpcOptions) {
49 | opt.Timeout = timeout
50 | }
51 | }
52 |
53 | var DefaultNatsRpcOptions = func() *NatsRpcOptions {
54 | return &NatsRpcOptions{
55 | Timeout: 5 * time.Minute,
56 | }
57 | }
58 |
59 | func NewNatsRpcOptions(opts ...NatsRpcOption) *NatsRpcOptions {
60 | opt := DefaultNatsRpcOptions()
61 | for _, o := range opts {
62 | o(opt)
63 | }
64 | return opt
65 | }
66 |
67 | func sendError(msg *nats.Msg, err error) {
68 | msg.RespondMsg(&nats.Msg{
69 | Header: nats.Header{
70 | NatsRpcErrorHeader: []string{err.Error()},
71 | },
72 | })
73 | }
74 |
75 | func sendSuccess(msg *nats.Msg, res proto.Message) {
76 | resBytes, err := proto.Marshal(res)
77 | if err != nil {
78 | sendError(msg, fmt.Errorf("failed to marshal response: %w", err))
79 | return
80 | }
81 | msg.Respond(resBytes)
82 | }
83 |
84 | func sendEOF(msg *nats.Msg) {
85 | msg.Respond(nil)
86 | }
87 | `)
88 | //line shared_go.qtpl:61
89 | }
90 |
91 | //line shared_go.qtpl:61
92 | func writegoSharedTypesTemplate(qq422016 qtio422016.Writer, pkg *packageTmplData) {
93 | //line shared_go.qtpl:61
94 | qw422016 := qt422016.AcquireWriter(qq422016)
95 | //line shared_go.qtpl:61
96 | streamgoSharedTypesTemplate(qw422016, pkg)
97 | //line shared_go.qtpl:61
98 | qt422016.ReleaseWriter(qw422016)
99 | //line shared_go.qtpl:61
100 | }
101 |
102 | //line shared_go.qtpl:61
103 | func goSharedTypesTemplate(pkg *packageTmplData) string {
104 | //line shared_go.qtpl:61
105 | qb422016 := qt422016.AcquireByteBuffer()
106 | //line shared_go.qtpl:61
107 | writegoSharedTypesTemplate(qb422016, pkg)
108 | //line shared_go.qtpl:61
109 | qs422016 := string(qb422016.B)
110 | //line shared_go.qtpl:61
111 | qt422016.ReleaseByteBuffer(qb422016)
112 | //line shared_go.qtpl:61
113 | return qs422016
114 | //line shared_go.qtpl:61
115 | }
116 |
--------------------------------------------------------------------------------
/embeddednats/nats.go:
--------------------------------------------------------------------------------
1 | package embeddednats
2 |
3 | import (
4 | "context"
5 | "log"
6 | "os"
7 |
8 | "github.com/cenkalti/backoff"
9 | "github.com/nats-io/nats-server/v2/server"
10 | "github.com/nats-io/nats.go"
11 | )
12 |
13 | type options struct {
14 | DataDirectory string
15 | ShouldClearData bool
16 | NATSServerOptions *server.Options
17 | Logging bool
18 | Debug bool
19 | Verbose bool
20 | }
21 |
22 | type Option func(*options)
23 |
24 | func WithDirectory(dir string) Option {
25 | return func(o *options) {
26 | o.DataDirectory = dir
27 | }
28 | }
29 |
30 | func WithShouldClearData(shouldClearData bool) Option {
31 | return func(o *options) {
32 | o.ShouldClearData = shouldClearData
33 | }
34 | }
35 |
36 | func WithNATSServerOptions(natsServerOptions *server.Options) Option {
37 | return func(o *options) {
38 | o.NATSServerOptions = natsServerOptions
39 | }
40 | }
41 |
42 | func WithLogging(trace bool, debug bool) Option {
43 | return func(o *options) {
44 | o.Logging = true
45 | o.Debug = debug
46 | o.Verbose = trace
47 | }
48 | }
49 |
50 | type Server struct {
51 | NatsServer *server.Server
52 | }
53 |
54 | // New creates a new embedded NATS server. Will automatically start the server
55 | // and clean up when the context is cancelled.
56 | func New(ctx context.Context, opts ...Option) (*Server, error) {
57 | options := &options{
58 | DataDirectory: "./data/nats",
59 | }
60 | for _, o := range opts {
61 | o(options)
62 | }
63 |
64 | if options.ShouldClearData {
65 | if err := os.RemoveAll(options.DataDirectory); err != nil {
66 | return nil, err
67 | }
68 | }
69 |
70 | if options.NATSServerOptions == nil {
71 | options.NATSServerOptions = &server.Options{
72 | JetStream: true,
73 | StoreDir: options.DataDirectory,
74 | }
75 | if options.Logging {
76 | options.NATSServerOptions.Debug = options.Debug
77 | options.NATSServerOptions.Trace = options.Verbose
78 | options.NATSServerOptions.TraceVerbose = options.Verbose
79 | }
80 | }
81 |
82 | // Initialize new server with options
83 | ns, err := server.NewServer(options.NATSServerOptions)
84 | if err != nil {
85 | panic(err)
86 | }
87 | if options.Logging {
88 | ns.ConfigureLogger()
89 | }
90 |
91 | go func() {
92 | <-ctx.Done()
93 | ns.Shutdown()
94 | }()
95 |
96 | // Start the server via goroutine
97 | ns.Start()
98 |
99 | return &Server{
100 | NatsServer: ns,
101 | }, nil
102 | }
103 |
104 | func (n *Server) Close() error {
105 | if n.NatsServer != nil && n.NatsServer.Running() {
106 | n.NatsServer.Shutdown()
107 | }
108 | return nil
109 | }
110 |
111 | func (n *Server) WaitForServer() {
112 | b := backoff.NewExponentialBackOff()
113 |
114 | for {
115 | d := b.NextBackOff()
116 | ready := n.NatsServer.ReadyForConnections(d)
117 | if ready {
118 | break
119 | }
120 |
121 | log.Printf("NATS server not ready, waited %s, retrying...", d)
122 | }
123 | }
124 |
125 | func (n *Server) Client() (*nats.Conn, error) {
126 | return nats.Connect(n.NatsServer.ClientURL())
127 | }
128 |
--------------------------------------------------------------------------------
/wisshes/file.go:
--------------------------------------------------------------------------------
1 | package wisshes
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | "log"
7 | "os"
8 | "path/filepath"
9 |
10 | "github.com/delaneyj/toolbelt"
11 | "github.com/zeebo/xxh3"
12 | )
13 |
14 | func FileRawToRemote(remotePath string, contents []byte) Step {
15 | name := fmt.Sprintf("file-remote-%s", toolbelt.Kebab(remotePath))
16 | return func(ctx context.Context) (context.Context, string, StepStatus, error) {
17 | client := CtxSSHClient(ctx)
18 | inv := CtxInventory(ctx)
19 |
20 | sftp, err := client.NewSftp()
21 | if err != nil {
22 | return ctx, name, StepStatusFailed, err
23 | }
24 | defer sftp.Close()
25 |
26 | localPath := inv.createTmpFilepath()
27 | log.Printf("Downloading %s to %s", remotePath, localPath)
28 | if err := client.Download(remotePath, localPath); err != nil {
29 | if !os.IsNotExist(err) {
30 | return ctx, name, StepStatusFailed, fmt.Errorf("download: %w", err)
31 | }
32 | }
33 | b, err := os.ReadFile(localPath)
34 | if err != nil {
35 | return ctx, name, StepStatusFailed, fmt.Errorf("read file: %w", err)
36 | }
37 | remoteHash := xxh3.Hash(b)
38 | localHash := xxh3.Hash(contents)
39 |
40 | if remoteHash == localHash {
41 | log.Printf("File %s unchanged", remotePath)
42 | return ctx, name, StepStatusUnchanged, err
43 | }
44 | log.Printf("File %s changed", remotePath)
45 |
46 | if err := os.WriteFile(localPath, contents, 0644); err != nil {
47 | return ctx, name, StepStatusFailed, fmt.Errorf("write file: %w", err)
48 | }
49 |
50 | remoteDir := filepath.Dir(remotePath)
51 | if _, err := RunF(client, "mkdir -p %s", remoteDir); err != nil {
52 | return ctx, name, StepStatusFailed, fmt.Errorf("mkdir: %w", err)
53 | }
54 |
55 | remoteFile, err := sftp.Create(remotePath)
56 | if err != nil {
57 | return ctx, name, StepStatusFailed, fmt.Errorf("create: %w", err)
58 | }
59 | defer remoteFile.Close()
60 |
61 | if _, err := remoteFile.Write(contents); err != nil {
62 | return ctx, name, StepStatusFailed, fmt.Errorf("copy: %w", err)
63 | }
64 |
65 | log.Printf("File %s updated", remotePath)
66 |
67 | return ctx, name, StepStatusChanged, nil
68 | }
69 | }
70 |
71 | func FilepathToRemote(remotePath string, localPath string) Step {
72 | name := fmt.Sprintf("file-remote-%s", toolbelt.Kebab(remotePath))
73 | return func(ctx context.Context) (context.Context, string, StepStatus, error) {
74 | client := CtxSSHClient(ctx)
75 |
76 | sftp, err := client.NewSftp()
77 | if err != nil {
78 | return ctx, name, StepStatusFailed, err
79 | }
80 | defer sftp.Close()
81 |
82 | remoteDir := filepath.Dir(remotePath)
83 | if _, err := RunF(client, "mkdir -p %s", remoteDir); err != nil {
84 | return ctx, name, StepStatusFailed, fmt.Errorf("mkdir: %w", err)
85 | }
86 |
87 | remoteFile, err := sftp.Create(remotePath)
88 | if err != nil {
89 | return ctx, name, StepStatusFailed, fmt.Errorf("create: %w", err)
90 | }
91 | defer remoteFile.Close()
92 |
93 | if err := client.Upload(localPath, remotePath); err != nil {
94 | return ctx, name, StepStatusFailed, fmt.Errorf("upload: %w", err)
95 | }
96 |
97 | log.Printf("File %s updated", remotePath)
98 |
99 | return ctx, name, StepStatusChanged, nil
100 | }
101 | }
102 |
--------------------------------------------------------------------------------
/strings.go:
--------------------------------------------------------------------------------
1 | package toolbelt
2 |
3 | import "strings"
4 |
5 | func Pascal(s string) string {
6 | return toCamel(s, true)
7 | }
8 |
9 | func Camel(s string) string {
10 | return toCamel(s, false)
11 | }
12 |
13 | func Snake(s string) string {
14 | return strings.ToLower(splitAndJoin(s, "_"))
15 | }
16 |
17 | func ScreamingSnake(s string) string {
18 | return strings.ToUpper(splitAndJoin(s, "_"))
19 | }
20 |
21 | func Kebab(s string) string {
22 | return strings.ToLower(splitAndJoin(s, "-"))
23 | }
24 |
25 | func Upper(s string) string {
26 | return strings.ToUpper(s)
27 | }
28 |
29 | func Lower(s string) string {
30 | return strings.ToLower(s)
31 | }
32 |
33 | type CasedFn func(string) string
34 |
35 | func Cased(s string, fn ...CasedFn) string {
36 | for _, f := range fn {
37 | s = f(s)
38 | }
39 | return s
40 | }
41 |
42 | type CasedString struct {
43 | Original string
44 | Pascal string
45 | Camel string
46 | Snake string
47 | ScreamingSnake string
48 | Kebab string
49 | Upper string
50 | Lower string
51 | }
52 |
53 | func ToCasedString(s string) CasedString {
54 | return CasedString{
55 | Original: s,
56 | Pascal: Pascal(s),
57 | Camel: Camel(s),
58 | Snake: Snake(s),
59 | ScreamingSnake: ScreamingSnake(s),
60 | Kebab: Kebab(s),
61 | Upper: Upper(s),
62 | Lower: Lower(s),
63 | }
64 | }
65 |
66 | // toCamel is adapted to avoid external deps; splits on non-alnum and case boundaries.
67 | func toCamel(s string, upperFirst bool) string {
68 | parts := splitWords(s)
69 | for i, p := range parts {
70 | if p == "" {
71 | continue
72 | }
73 | if i == 0 && !upperFirst {
74 | parts[i] = strings.ToLower(p[:1]) + strings.ToLower(p[1:])
75 | } else {
76 | parts[i] = strings.ToUpper(p[:1]) + strings.ToLower(p[1:])
77 | }
78 | }
79 | return strings.Join(parts, "")
80 | }
81 |
82 | func splitWords(s string) []string {
83 | var parts []string
84 | var cur []rune
85 | for _, r := range s {
86 | if !(isLetter(r) || isDigit(r)) {
87 | if len(cur) > 0 {
88 | parts = append(parts, string(cur))
89 | cur = cur[:0]
90 | }
91 | continue
92 | }
93 | if len(cur) > 0 && isBoundary(cur[len(cur)-1], r) {
94 | parts = append(parts, string(cur))
95 | cur = cur[:0]
96 | }
97 | cur = append(cur, r)
98 | }
99 | if len(cur) > 0 {
100 | parts = append(parts, string(cur))
101 | }
102 | return parts
103 | }
104 |
105 | func splitAndJoin(s, sep string) string {
106 | parts := splitWords(s)
107 | for i, p := range parts {
108 | parts[i] = strings.ToLower(p)
109 | }
110 | return strings.Join(parts, sep)
111 | }
112 |
113 | func isLetter(r rune) bool {
114 | return (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z')
115 | }
116 |
117 | func isDigit(r rune) bool {
118 | return r >= '0' && r <= '9'
119 | }
120 |
121 | func isUpper(r rune) bool { return r >= 'A' && r <= 'Z' }
122 | func isLower(r rune) bool { return r >= 'a' && r <= 'z' }
123 |
124 | func isBoundary(prev rune, curr rune) bool {
125 | // Boundary between lower->upper (camelCase) or letter->digit or digit->letter.
126 | if isLower(prev) && isUpper(curr) {
127 | return true
128 | }
129 | if (isLetter(prev) && isDigit(curr)) || (isDigit(prev) && isLetter(curr)) {
130 | return true
131 | }
132 | return false
133 | }
134 |
--------------------------------------------------------------------------------
/logic.go:
--------------------------------------------------------------------------------
1 | package toolbelt
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | "sync"
7 | "time"
8 | )
9 |
10 | // Throttle will only allow the function to be called once every d duration.
11 | func Throttle(d time.Duration, fn CtxErrFunc) CtxErrFunc {
12 | shouldWait := false
13 | mu := &sync.RWMutex{}
14 |
15 | checkShoulWait := func() bool {
16 | mu.RLock()
17 | defer mu.RUnlock()
18 | return shouldWait
19 | }
20 |
21 | return func(ctx context.Context) error {
22 | if checkShoulWait() {
23 | return nil
24 | }
25 |
26 | mu.Lock()
27 | defer mu.Unlock()
28 | shouldWait = true
29 |
30 | go func() {
31 | <-time.After(d)
32 | shouldWait = false
33 | }()
34 |
35 | if err := fn(ctx); err != nil {
36 | return fmt.Errorf("throttled function failed: %w", err)
37 | }
38 |
39 | return nil
40 | }
41 | }
42 |
43 | // Debounce will only call the function after d duration has passed since the last call.
44 | func Debounce(d time.Duration, fn CtxErrFunc) CtxErrFunc {
45 | var t *time.Timer
46 | mu := &sync.RWMutex{}
47 |
48 | return func(ctx context.Context) error {
49 | mu.Lock()
50 | defer mu.Unlock()
51 |
52 | if t != nil && !t.Stop() {
53 | <-t.C
54 | }
55 |
56 | t = time.AfterFunc(d, func() {
57 | if err := fn(ctx); err != nil {
58 | fmt.Printf("debounced function failed: %v\n", err)
59 | }
60 | })
61 |
62 | return nil
63 | }
64 | }
65 |
66 | func CallNTimesWithDelay(d time.Duration, n int, fn CtxErrFunc) CtxErrFunc {
67 | return func(ctx context.Context) error {
68 | called := 0
69 | for {
70 | shouldCall := false
71 | if n < 0 {
72 | shouldCall = true
73 | } else if called < n {
74 | shouldCall = true
75 | }
76 | if !shouldCall {
77 | break
78 | }
79 |
80 | if err := fn(ctx); err != nil {
81 | return fmt.Errorf("call n times with delay failed: %w", err)
82 | }
83 | called++
84 |
85 | <-time.After(d)
86 | }
87 |
88 | return nil
89 | }
90 | }
91 |
92 | // DebounceWithMaxWait creates a debounced function that waits for a quiet period
93 | // before executing, but guarantees execution within a maximum wait time.
94 | func DebounceWithMaxWait(waitTime time.Duration, maxWaitTime time.Duration, fn func(context.Context) error) func(context.Context) error {
95 | var (
96 | mu sync.Mutex
97 | timer *time.Timer
98 | maxTimer *time.Timer
99 | latestCtx context.Context
100 | firstCallAt time.Time
101 | )
102 |
103 | execute := func() {
104 | mu.Lock()
105 | ctx := latestCtx
106 | mu.Unlock()
107 |
108 | if ctx != nil {
109 | fn(ctx)
110 | }
111 |
112 | mu.Lock()
113 | timer = nil
114 | maxTimer = nil
115 | latestCtx = nil
116 | firstCallAt = time.Time{}
117 | mu.Unlock()
118 | }
119 |
120 | return func(ctx context.Context) error {
121 | mu.Lock()
122 | defer mu.Unlock()
123 |
124 | latestCtx = ctx
125 |
126 | // First call in this burst
127 | if firstCallAt.IsZero() {
128 | firstCallAt = time.Now()
129 |
130 | // Start max wait timer
131 | maxTimer = time.AfterFunc(maxWaitTime, execute)
132 | }
133 |
134 | // Reset debounce timer
135 | if timer != nil {
136 | timer.Stop()
137 | }
138 | timer = time.AfterFunc(waitTime, func() {
139 | mu.Lock()
140 | if maxTimer != nil {
141 | maxTimer.Stop()
142 | }
143 | mu.Unlock()
144 | execute()
145 | })
146 |
147 | return nil
148 | }
149 | }
150 |
--------------------------------------------------------------------------------
/wisshes/inventory.go:
--------------------------------------------------------------------------------
1 | package wisshes
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | "log"
7 | "os"
8 | "path/filepath"
9 | "strings"
10 | "time"
11 |
12 | "github.com/autosegment/ksuid"
13 | "github.com/melbahja/goph"
14 | )
15 |
16 | var WishDir = ".wisshes"
17 |
18 | type Inventory struct {
19 | Hosts []*goph.Client
20 | HostNames []string
21 | }
22 |
23 | func NewInventory(rootPassword string, namesAndIPs ...string) (inv *Inventory, err error) {
24 |
25 | if len(namesAndIPs) == 0 {
26 | return nil, fmt.Errorf("namesAndIPs is empty")
27 | }
28 | if len(namesAndIPs)%2 != 0 {
29 | return nil, fmt.Errorf("namesAndIPs must be even")
30 | }
31 |
32 | inv = &Inventory{}
33 |
34 | for i := 0; i < len(namesAndIPs); i += 2 {
35 | name := namesAndIPs[i]
36 | ip := namesAndIPs[i+1]
37 | inv.HostNames = append(inv.HostNames, name)
38 |
39 | host, err := goph.NewUnknown("root", ip, goph.Password(rootPassword))
40 | if err != nil {
41 | return nil, fmt.Errorf("new unknown: %w", err)
42 | }
43 | inv.Hosts = append(inv.Hosts, host)
44 | }
45 |
46 | if err := upsertWishDir(); err != nil {
47 | return nil, fmt.Errorf("upsert wish dir: %w", err)
48 | }
49 |
50 | return inv, nil
51 | }
52 |
53 | func (inv *Inventory) createTmpFilepath() string {
54 | return filepath.Join(TempDir(), ksuid.New().String())
55 | }
56 |
57 | func TempDir() string {
58 | return filepath.Join(WishDir, "tmp")
59 | }
60 |
61 | func ArtifactsDir() string {
62 | return filepath.Join(WishDir, "artifacts")
63 | }
64 |
65 | func upsertWishDir() error {
66 | if err := os.MkdirAll(ArtifactsDir(), 0755); err != nil {
67 | return fmt.Errorf("mkdir: %w", err)
68 | }
69 |
70 | if err := os.RemoveAll(TempDir()); err != nil {
71 | return fmt.Errorf("remove all: %w", err)
72 | }
73 | if err := os.MkdirAll(TempDir(), 0755); err != nil {
74 | return fmt.Errorf("mkdir: %w", err)
75 | }
76 | return nil
77 | }
78 |
79 | func (inv *Inventory) Close() {
80 | for _, host := range inv.Hosts {
81 | host.Close()
82 | }
83 | }
84 |
85 | func (inv *Inventory) Run(ctx context.Context, steps ...Step) (StepStatus, error) {
86 | lastStatus := StepStatusUnchanged
87 | ctx = CtxWithInventory(ctx, inv)
88 |
89 | if len(steps) == 0 {
90 | return lastStatus, nil
91 | }
92 |
93 | var (
94 | name string
95 | status StepStatus
96 | err error
97 | )
98 |
99 | for h, host := range inv.Hosts {
100 | hostName := inv.HostNames[h]
101 | if strings.Contains(hostName, "us-east") {
102 | log.Print("us-east")
103 | }
104 | ctx = CtxWithSSHClient(ctx, host)
105 | ctx = CtxWithPreviousStep(ctx, StepStatusUnchanged)
106 |
107 | for i, step := range steps {
108 | log.Printf("[%s:%s] step %d started", hostName, host.Config.Addr, i+1)
109 | start := time.Now()
110 |
111 | ctx, name, status, err = step(ctx)
112 | if err != nil {
113 | return status, fmt.Errorf("step %d: %w", i+1, err)
114 | }
115 |
116 | if status == StepStatusFailed {
117 | return status, fmt.Errorf("step %d: %w", i+1, err)
118 | }
119 |
120 | log.Printf("[%s:%s] step %d %s -> %s took %s", hostName, host.Config.Addr, i+1, name, status, time.Since(start))
121 | ctx = CtxWithPreviousStep(ctx, status)
122 | lastStatus = status
123 | }
124 | }
125 |
126 | return lastStatus, nil
127 | }
128 |
129 | func Run(ctx context.Context, steps ...Step) error {
130 | if err := upsertWishDir(); err != nil {
131 | return fmt.Errorf("upsert wish dir: %w", err)
132 | }
133 |
134 | if _, _, _, err := RunAll(steps...)(ctx); err != nil {
135 | return fmt.Errorf("run all: %w", err)
136 | }
137 | return nil
138 | }
139 |
--------------------------------------------------------------------------------
/jtd/README.md:
--------------------------------------------------------------------------------
1 | # JTD to Go Generator
2 |
3 | A JSON Type Definition (RFC 8927) to Go code generator.
4 |
5 | ## Installation
6 |
7 | ```bash
8 | go install github.com/delaneyj/toolbelt/jtd/cmd/jtd2go@latest
9 | ```
10 |
11 | ## Usage
12 |
13 | ```bash
14 | jtd2go -input schema.json -output types.go -package myapp
15 | ```
16 |
17 | ### Options
18 |
19 | - `-input`: Input JTD schema file (required)
20 | - `-output`: Output Go file (default: stdout)
21 | - `-package`: Go package name (default: "types")
22 | - `-comments`: Generate comments from descriptions (default: true)
23 | - `-validate`: Generate validation methods (default: false)
24 | - `-help`: Show help
25 |
26 | ## Features
27 |
28 | ### Supported JTD Forms
29 |
30 | - **Type forms**: All RFC 8927 primitive types
31 | - `boolean` → `bool`
32 | - `string` → `string`
33 | - `timestamp` → `time.Time`
34 | - `float32`, `float64` → `float32`, `float64`
35 | - `int8`, `int16`, `int32` → `int8`, `int16`, `int32`
36 | - `uint8`, `uint16`, `uint32` → `uint8`, `uint16`, `uint32`
37 |
38 | - **Enum form**: String enumerations → Go constants
39 | - **Elements form**: Arrays → Go slices
40 | - **Properties form**: Objects → Go structs
41 | - **Values form**: Maps → Go maps
42 | - **Discriminator form**: Tagged unions → Go interfaces
43 | - **Ref form**: Schema references
44 | - **Empty form**: Any type → `any`
45 |
46 | ### Additional Features
47 |
48 | - Nullable types using pointers
49 | - Optional struct fields with `omitempty` tags
50 | - Metadata descriptions as Go comments
51 | - Validation method generation
52 | - Proper handling of circular references
53 |
54 | ## Examples
55 |
56 | ### Simple Schema
57 |
58 | ```json
59 | {
60 | "properties": {
61 | "name": { "type": "string" },
62 | "age": { "type": "int32" }
63 | }
64 | }
65 | ```
66 |
67 | Generates:
68 |
69 | ```go
70 | type Root struct {
71 | Name string `json:"name"`
72 | Age int32 `json:"age"`
73 | }
74 | ```
75 |
76 | ### Enum Schema
77 |
78 | ```json
79 | {
80 | "definitions": {
81 | "status": {
82 | "enum": ["active", "inactive", "pending"]
83 | }
84 | }
85 | }
86 | ```
87 |
88 | Generates:
89 |
90 | ```go
91 | type Status string
92 |
93 | const (
94 | StatusActive Status = "active"
95 | StatusInactive Status = "inactive"
96 | StatusPending Status = "pending"
97 | )
98 | ```
99 |
100 | ### Discriminated Union
101 |
102 | ```json
103 | {
104 | "definitions": {
105 | "shape": {
106 | "discriminator": "type",
107 | "mapping": {
108 | "circle": {
109 | "properties": {
110 | "radius": { "type": "float64" }
111 | }
112 | },
113 | "rectangle": {
114 | "properties": {
115 | "width": { "type": "float64" },
116 | "height": { "type": "float64" }
117 | }
118 | }
119 | }
120 | }
121 | }
122 | }
123 | ```
124 |
125 | Generates:
126 |
127 | ```go
128 | type Shape interface {
129 | isShape()
130 | Type() string
131 | }
132 |
133 | type ShapeCircle struct {
134 | Type string `json:"type"`
135 | Radius float64 `json:"radius"`
136 | }
137 |
138 | func (ShapeCircle) isShape() {}
139 | func (v ShapeCircle) Type() string { return v.Type }
140 |
141 | type ShapeRectangle struct {
142 | Type string `json:"type"`
143 | Width float64 `json:"width"`
144 | Height float64 `json:"height"`
145 | }
146 |
147 | func (ShapeRectangle) isShape() {}
148 | func (v ShapeRectangle) Type() string { return v.Type }
149 | ```
150 |
151 | ## Testing
152 |
153 | ```bash
154 | go test ./jtd
155 | ```
156 |
157 | ## License
158 |
159 | Same as the parent toolbelt project.
--------------------------------------------------------------------------------
/bytebufferpool/pool.go:
--------------------------------------------------------------------------------
1 | package bytebufferpool
2 |
3 | import (
4 | "sort"
5 | "sync"
6 | "sync/atomic"
7 | )
8 |
9 | const (
10 | minBitSize = 6 // 2**6=64 is a CPU cache line size
11 | steps = 20
12 |
13 | minSize = 1 << minBitSize
14 |
15 | calibrateCallsThreshold = 42000
16 | maxPercentile = 0.95
17 | )
18 |
19 | // Distinct pools may be used for distinct types of byte buffers.
20 | // Properly determined byte buffer types with their own pools may help reducing
21 | // memory waste.
22 | type Pool struct {
23 | calls [steps]uint64
24 | calibrating uint64
25 |
26 | defaultSize uint64
27 | maxSize uint64
28 |
29 | pool sync.Pool
30 | }
31 |
32 | var defaultPool Pool
33 |
34 | // Get returns an empty byte buffer from the pool.
35 | //
36 | // Got byte buffer may be returned to the pool via Put call.
37 | // This reduces the number of memory allocations required for byte buffer
38 | // management.
39 | func Get() *ByteBuffer { return defaultPool.Get() }
40 |
41 | // Ensure returns a byte buffer with at least the requested capacity.
42 | func Ensure(size int) *ByteBuffer { return defaultPool.Ensure(size) }
43 |
44 | // Get returns new byte buffer with zero length.
45 | //
46 | // The byte buffer may be returned to the pool via Put after the use
47 | // in order to minimize GC overhead.
48 | func (p *Pool) Get() *ByteBuffer {
49 | v := p.pool.Get()
50 | if v != nil {
51 | return v.(*ByteBuffer)
52 | }
53 | return &ByteBuffer{
54 | buf: make([]byte, 0, atomic.LoadUint64(&p.defaultSize)),
55 | }
56 | }
57 |
58 | // Ensure returns a buffer with capacity at least size.
59 | func (p *Pool) Ensure(size int) *ByteBuffer {
60 | if size <= 0 {
61 | return p.Get()
62 | }
63 | b := p.Get()
64 | if cap(b.buf) < size {
65 | b.buf = make([]byte, 0, size)
66 | }
67 | return b
68 | }
69 |
70 | // Put returns byte buffer to the pool.
71 | //
72 | // The buffer mustn't be touched after returning it to the pool.
73 | // Otherwise data races will occur.
74 | func Put(b *ByteBuffer) { defaultPool.Put(b) }
75 |
76 | // Put releases byte buffer obtained via Get to the pool.
77 | //
78 | // The buffer mustn't be accessed after returning to the pool.
79 | func (p *Pool) Put(b *ByteBuffer) {
80 | idx := index(len(b.buf))
81 |
82 | if atomic.AddUint64(&p.calls[idx], 1) > calibrateCallsThreshold {
83 | p.calibrate()
84 | }
85 |
86 | maxSize := int(atomic.LoadUint64(&p.maxSize))
87 | if maxSize == 0 || cap(b.buf) <= maxSize {
88 | b.Reset()
89 | p.pool.Put(b)
90 | }
91 | }
92 |
93 | func (p *Pool) calibrate() {
94 | if !atomic.CompareAndSwapUint64(&p.calibrating, 0, 1) {
95 | return
96 | }
97 |
98 | a := make(callSizes, 0, steps)
99 | var callsSum uint64
100 | for i := uint64(0); i < steps; i++ {
101 | calls := atomic.SwapUint64(&p.calls[i], 0)
102 | callsSum += calls
103 | a = append(a, callSize{
104 | calls: calls,
105 | size: minSize << i,
106 | })
107 | }
108 | sort.Sort(a)
109 |
110 | defaultSize := a[0].size
111 | maxSize := defaultSize
112 |
113 | maxSum := uint64(float64(callsSum) * maxPercentile)
114 | callsSum = 0
115 | for i := 0; i < steps; i++ {
116 | if callsSum > maxSum {
117 | break
118 | }
119 | callsSum += a[i].calls
120 | size := a[i].size
121 | if size > maxSize {
122 | maxSize = size
123 | }
124 | }
125 |
126 | atomic.StoreUint64(&p.defaultSize, defaultSize)
127 | atomic.StoreUint64(&p.maxSize, maxSize)
128 | atomic.StoreUint64(&p.calibrating, 0)
129 | }
130 |
131 | type callSize struct {
132 | calls uint64
133 | size uint64
134 | }
135 |
136 | type callSizes []callSize
137 |
138 | func (ci callSizes) Len() int {
139 | return len(ci)
140 | }
141 |
142 | func (ci callSizes) Less(i, j int) bool {
143 | return ci[i].calls > ci[j].calls
144 | }
145 |
146 | func (ci callSizes) Swap(i, j int) {
147 | ci[i], ci[j] = ci[j], ci[i]
148 | }
149 |
150 | func index(n int) int {
151 | n--
152 | n >>= minBitSize
153 | idx := 0
154 | for n > 0 {
155 | n >>= 1
156 | idx++
157 | }
158 | if idx >= steps {
159 | idx = steps - 1
160 | }
161 | return idx
162 | }
163 |
--------------------------------------------------------------------------------
/datalog/datalog.go:
--------------------------------------------------------------------------------
1 | package datalog
2 |
3 | import (
4 | "iter"
5 | "strings"
6 | )
7 |
8 | type Triple = [3]string
9 | type Pattern = [3]string
10 |
11 | func NewTriple(subject, predicate, object string) Triple {
12 | return Triple{subject, predicate, object}
13 | }
14 |
15 | type State map[string]string
16 |
17 | func isVariable(s string) bool {
18 | return strings.HasPrefix(s, "?")
19 | }
20 |
21 | func deepCopyState(state State) State {
22 | newState := make(State, len(state)+1)
23 | for key, value := range state {
24 | newState[key] = value
25 | }
26 | return newState
27 | }
28 |
29 | func matchVariable(variable, triplePart string, state State) State {
30 | bound, ok := state[variable]
31 | if ok {
32 | return matchPart(bound, triplePart, state)
33 | }
34 | newState := deepCopyState(state)
35 | newState[variable] = triplePart
36 | return newState
37 | }
38 |
39 | func matchPart(patternPart, triplePart string, state State) State {
40 | if state == nil {
41 | return nil
42 | }
43 | if isVariable(patternPart) {
44 | return matchVariable(patternPart, triplePart, state)
45 | }
46 | if patternPart == triplePart {
47 | return state
48 | }
49 | return nil
50 | }
51 |
52 | func MatchPattern(pattern Pattern, triple Triple, state State) State {
53 | newState := deepCopyState(state)
54 |
55 | for idx, patternPart := range pattern {
56 | triplePart := triple[idx]
57 | newState = matchPart(patternPart, triplePart, newState)
58 | if newState == nil {
59 | return nil
60 | }
61 | }
62 |
63 | return newState
64 | }
65 |
66 | func (db *DB) QuerySingle(state State, pattern Pattern) (valid []State) {
67 | for triple := range relevantTriples(db, pattern) {
68 | newState := MatchPattern(pattern, triple, state)
69 | if newState != nil {
70 | valid = append(valid, newState)
71 | }
72 | }
73 | return valid
74 | }
75 |
76 | func (db *DB) QueryWhere(where ...Pattern) []State {
77 | states := []State{{}}
78 | for _, pattern := range where {
79 | revised := make([]State, 0, len(states))
80 | for _, state := range states {
81 | revised = append(revised, db.QuerySingle(state, pattern)...)
82 | }
83 | states = revised
84 | }
85 | return states
86 | }
87 |
88 | func (db *DB) Query(find []string, where ...Pattern) [][]string {
89 | states := db.QueryWhere(where...)
90 |
91 | results := make([][]string, len(states))
92 | for i, state := range states {
93 | results[i] = actualize(state, find...)
94 | }
95 | return results
96 | }
97 |
98 | func actualize(state State, find ...string) []string {
99 | results := make([]string, len(find))
100 | for i, findPart := range find {
101 | r := findPart
102 | if isVariable(findPart) {
103 | r = state[findPart]
104 | }
105 | results[i] = r
106 | }
107 | return results
108 | }
109 |
110 | type DB struct {
111 | triples []Triple
112 | entityIndex map[string][]Triple
113 | attrIndex map[string][]Triple
114 | valueIndex map[string][]Triple
115 | }
116 |
117 | func CreateDB(triples ...Triple) *DB {
118 | return &DB{
119 | triples: triples,
120 | entityIndex: indexBy(triples, 0),
121 | attrIndex: indexBy(triples, 1),
122 | valueIndex: indexBy(triples, 2),
123 | }
124 | }
125 |
126 | func indexBy(triples []Triple, idx int) map[string][]Triple {
127 | index := map[string][]Triple{}
128 | for _, triple := range triples {
129 | key := triple[idx]
130 | index[key] = append(index[key], triple)
131 | }
132 | return index
133 | }
134 |
135 | func relevantTriples(db *DB, pattern Pattern) iter.Seq[Triple] {
136 | return func(yield func(Triple) bool) {
137 | id, attr, value := pattern[0], pattern[1], pattern[2]
138 | if !isVariable(id) {
139 | for _, triple := range db.entityIndex[id] {
140 | if !yield(triple) {
141 | return
142 | }
143 | }
144 | return
145 | }
146 | if !isVariable(attr) {
147 | for _, triple := range db.attrIndex[attr] {
148 | if !yield(triple) {
149 | return
150 | }
151 | }
152 | return
153 | }
154 | if !isVariable(value) {
155 | for _, triple := range db.valueIndex[value] {
156 | if !yield(triple) {
157 | return
158 | }
159 | }
160 | return
161 | }
162 |
163 | for _, triple := range db.triples {
164 | if !yield(triple) {
165 | return
166 | }
167 | }
168 | }
169 | }
170 |
--------------------------------------------------------------------------------
/jtd/cmd/jtd2go/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "flag"
5 | "fmt"
6 | "log"
7 | "os"
8 | "path/filepath"
9 |
10 | "github.com/delaneyj/toolbelt/jtd"
11 | )
12 |
13 | func main() {
14 | var (
15 | input = flag.String("input", "", "Input JTD schema file (required)")
16 | output = flag.String("output", "", "Output Go file (default: stdout)")
17 | packageName = flag.String("package", "types", "Go package name")
18 | comments = flag.Bool("comments", true, "Generate comments from descriptions")
19 | validate = flag.Bool("validate", false, "Generate validation methods")
20 | help = flag.Bool("help", false, "Show help")
21 | )
22 |
23 | flag.Usage = func() {
24 | fmt.Fprintf(os.Stderr, "jtd2go - JSON Type Definition to Go code generator\n\n")
25 | fmt.Fprintf(os.Stderr, "Usage: %s [options]\n\n", os.Args[0])
26 | fmt.Fprintf(os.Stderr, "Options:\n")
27 | flag.PrintDefaults()
28 | fmt.Fprintf(os.Stderr, "\nExamples:\n")
29 | fmt.Fprintf(os.Stderr, " %s -input schema.json -output types.go\n", os.Args[0])
30 | fmt.Fprintf(os.Stderr, " %s -input schema.json -package myapp > types.go\n", os.Args[0])
31 | fmt.Fprintf(os.Stderr, " %s -input schema.json -validate -output types.go\n", os.Args[0])
32 | }
33 |
34 | flag.Parse()
35 |
36 | if *help {
37 | flag.Usage()
38 | os.Exit(0)
39 | }
40 |
41 | if *input == "" {
42 | fmt.Fprintf(os.Stderr, "Error: -input flag is required\n\n")
43 | flag.Usage()
44 | os.Exit(1)
45 | }
46 |
47 | // Read input file
48 | data, err := os.ReadFile(*input)
49 | if err != nil {
50 | log.Fatalf("Failed to read input file: %v", err)
51 | }
52 |
53 | // Parse the schema
54 | parser := jtd.NewParser()
55 | _, err = parser.Parse(data)
56 | if err != nil {
57 | log.Fatalf("Failed to parse schema: %v", err)
58 | }
59 |
60 | // Create generator
61 | opts := jtd.GeneratorOptions{
62 | PackageName: *packageName,
63 | GenerateComments: *comments,
64 | GenerateValidate: *validate,
65 | }
66 | generator := jtd.NewGenerator(parser, opts)
67 |
68 | // Generate code
69 | code, err := generator.Generate()
70 | if err != nil {
71 | log.Fatalf("Failed to generate code: %v", err)
72 | }
73 |
74 | // Generate validation methods if requested
75 | if *validate {
76 | validationCode, err := generator.GenerateValidation()
77 | if err != nil {
78 | log.Fatalf("Failed to generate validation code: %v", err)
79 | }
80 | code = append(code, validationCode...)
81 | }
82 |
83 | // Write output
84 | if *output != "" {
85 | // Ensure output directory exists
86 | dir := filepath.Dir(*output)
87 | if err := os.MkdirAll(dir, 0755); err != nil {
88 | log.Fatalf("Failed to create output directory: %v", err)
89 | }
90 |
91 | if err := os.WriteFile(*output, code, 0644); err != nil {
92 | log.Fatalf("Failed to write output file: %v", err)
93 | }
94 | fmt.Printf("Generated %s\n", *output)
95 | } else {
96 | // Write to stdout
97 | fmt.Print(string(code))
98 | }
99 | }
100 |
101 | // Example schema for testing:
102 | /*
103 | {
104 | "metadata": {
105 | "description": "User profile schema"
106 | },
107 | "definitions": {
108 | "user": {
109 | "properties": {
110 | "id": { "type": "string" },
111 | "name": { "type": "string" },
112 | "email": { "type": "string" },
113 | "age": { "type": "int32" },
114 | "created": { "type": "timestamp" }
115 | },
116 | "optionalProperties": {
117 | "bio": { "type": "string" },
118 | "avatar": { "type": "string" }
119 | }
120 | },
121 | "userRole": {
122 | "enum": ["admin", "user", "guest"]
123 | },
124 | "userList": {
125 | "elements": { "ref": "user" }
126 | },
127 | "userMap": {
128 | "values": { "ref": "user" }
129 | },
130 | "notification": {
131 | "discriminator": "type",
132 | "mapping": {
133 | "email": {
134 | "properties": {
135 | "to": { "type": "string" },
136 | "subject": { "type": "string" },
137 | "body": { "type": "string" }
138 | }
139 | },
140 | "sms": {
141 | "properties": {
142 | "to": { "type": "string" },
143 | "message": { "type": "string" }
144 | }
145 | }
146 | }
147 | }
148 | }
149 | }
150 | */
151 |
--------------------------------------------------------------------------------
/envcrypt/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "context"
5 | "crypto/cipher"
6 | "crypto/rand"
7 | "encoding/base32"
8 | "fmt"
9 | "io/fs"
10 | "log"
11 | "os"
12 | "path/filepath"
13 |
14 | "github.com/alecthomas/kong"
15 | "github.com/dustin/go-humanize"
16 | "github.com/joho/godotenv"
17 | "golang.org/x/crypto/argon2"
18 | "golang.org/x/crypto/chacha20poly1305"
19 | )
20 |
21 | func main() {
22 | log.SetFlags(log.Lshortfile | log.LstdFlags)
23 |
24 | ctx := context.Background()
25 | if err := run(ctx); err != nil {
26 | log.Fatal(err)
27 | }
28 | }
29 |
30 | var CLI struct {
31 | Encrypt EncryptCmd `cmd:"" help:"Encrypt environment variables locally"`
32 | Decrypt DecryptCmd `cmd:"" help:"Decrypt environment variables locally"`
33 | }
34 |
35 | func run(ctx context.Context) error {
36 | godotenv.Load()
37 | cliCtx := kong.Parse(&CLI, kong.Bind(ctx))
38 | if err := cliCtx.Run(ctx); err != nil {
39 | return fmt.Errorf("failed to run cli: %w", err)
40 | }
41 |
42 | return nil
43 | }
44 |
45 | func parse(password, salt, extension string) (aead cipher.AEAD, filepaths []string, err error) {
46 |
47 | key := argon2.Key([]byte(password), []byte(salt), 3, 64*1024, 4, 32)
48 | aead, err = chacha20poly1305.New(key)
49 | if err != nil {
50 | return nil, nil, fmt.Errorf("failed to create chacha20poly1305: %w", err)
51 | }
52 |
53 | if err := filepath.WalkDir(".", func(path string, d fs.DirEntry, err error) error {
54 | if err != nil {
55 | return err
56 | }
57 |
58 | if d.IsDir() {
59 | return nil
60 | }
61 |
62 | if filepath.Ext(path) != extension {
63 | return nil
64 | }
65 |
66 | filepaths = append(filepaths, path)
67 | return nil
68 | }); err != nil {
69 | return nil, nil, fmt.Errorf("failed to read env files: %w", err)
70 | }
71 |
72 | return
73 | }
74 |
75 | type EncryptCmd struct {
76 | Password string `short:"p" env:"ENVCRYPT_PASSWORD" help:"Secret to encrypt"`
77 | Salt string `short:"s" env:"ENVCRYPT_SALT" help:"Salt to use for encryption"`
78 | }
79 |
80 | func (cmd *EncryptCmd) Run() error {
81 | aead, envFilepaths, err := parse(cmd.Password, cmd.Salt, ".env")
82 | if err != nil {
83 | return fmt.Errorf("failed to parse: %w", err)
84 | }
85 |
86 | for _, envFilepath := range envFilepaths {
87 | msg, err := os.ReadFile(envFilepath)
88 | if err != nil {
89 | return fmt.Errorf("failed to read %s: %w", envFilepath, err)
90 | }
91 |
92 | nonce := make([]byte, aead.NonceSize(), aead.NonceSize()+len(msg)+aead.Overhead())
93 | if _, err := rand.Read(nonce); err != nil {
94 | panic(err)
95 | }
96 |
97 | // Encrypt the message and append the ciphertext to the nonce.
98 | encryptedMsg := aead.Seal(nonce, nonce, msg, nil)
99 | based := base32.StdEncoding.EncodeToString(encryptedMsg)
100 |
101 | encryptedFilename := fmt.Sprintf("%scrypt", envFilepath)
102 |
103 | fullpath := filepath.Join(filepath.Dir(envFilepath), encryptedFilename)
104 | if err := os.WriteFile(fullpath, []byte(based), 0644); err != nil {
105 | return fmt.Errorf("failed to write %s: %w", fullpath, err)
106 | }
107 |
108 | log.Printf("wrote %s to %s, size: %s", envFilepath, fullpath, humanize.Bytes(uint64(len(based))))
109 | }
110 |
111 | return nil
112 | }
113 |
114 | type DecryptCmd struct {
115 | Password string `short:"p" env:"ENVCRYPT_PASSWORD" help:"Secret to encrypt"`
116 | Salt string `short:"s" env:"ENVCRYPT_SALT" help:"Salt to use for encryption"`
117 | }
118 |
119 | func (cmd *DecryptCmd) Run() error {
120 | aead, envcryptFilepaths, err := parse(cmd.Password, cmd.Salt, ".envcrypt")
121 | if err != nil {
122 | return fmt.Errorf("failed to parse: %w", err)
123 | }
124 |
125 | for _, envcryptFilepath := range envcryptFilepaths {
126 |
127 | based, err := os.ReadFile(envcryptFilepath)
128 | if err != nil {
129 | return fmt.Errorf("failed to read %s: %w", envcryptFilepath, err)
130 | }
131 |
132 | encryptedMsg, err := base32.StdEncoding.DecodeString(string(based))
133 | if err != nil {
134 | return fmt.Errorf("failed to decode %s: %w", envcryptFilepath, err)
135 | }
136 |
137 | if len(encryptedMsg) < aead.NonceSize() {
138 | panic("ciphertext too short")
139 | }
140 |
141 | // Split nonce and ciphertext.
142 | nonce, ciphertext := encryptedMsg[:aead.NonceSize()], encryptedMsg[aead.NonceSize():]
143 |
144 | // Decrypt the message and check it wasn't tampered with.
145 | plaintext, err := aead.Open(nil, nonce, ciphertext, nil)
146 | if err != nil {
147 | panic(err)
148 | }
149 |
150 | envFilepath := envcryptFilepath[:len(envcryptFilepath)-len("crypt")]
151 | if err := os.WriteFile(envFilepath, plaintext, 0644); err != nil {
152 | return fmt.Errorf("failed to write %s: %w", envFilepath, err)
153 | }
154 |
155 | log.Printf("wrote %s to %s, size: %s", envcryptFilepath, envFilepath, humanize.Bytes(uint64(len(plaintext))))
156 | }
157 | return nil
158 | }
159 |
--------------------------------------------------------------------------------
/sqlc-gen-zombiezen/zombiezen/gen.go:
--------------------------------------------------------------------------------
1 | package zombiezen
2 |
3 | import (
4 | "bytes"
5 | "context"
6 | "encoding/json"
7 | "fmt"
8 | "go/format"
9 | "os"
10 | "path"
11 | "path/filepath"
12 | "strings"
13 |
14 | "github.com/delaneyj/toolbelt"
15 | "github.com/sqlc-dev/plugin-sdk-go/plugin"
16 | "golang.org/x/tools/imports"
17 | )
18 |
19 | type Options struct {
20 | DisableCRUD bool `json:"disable_crud"`
21 | DisableTimeConversion bool `json:"disable_time_conversion"`
22 | }
23 |
24 | type generationConfig struct {
25 | packageName toolbelt.CasedString
26 | }
27 |
28 | func Generate(ctx context.Context, req *plugin.GenerateRequest) (*plugin.GenerateResponse, error) {
29 | options, err := parseOptions(req)
30 | if err != nil {
31 | return nil, fmt.Errorf("parsing options: %w", err)
32 | }
33 |
34 | cfg, err := buildGenerationConfig(req, options)
35 | if err != nil {
36 | return nil, fmt.Errorf("configuring generation: %w", err)
37 | }
38 |
39 | if err := cleanupOutputDirectory(req); err != nil {
40 | return nil, fmt.Errorf("cleaning output directory: %w", err)
41 | }
42 |
43 | res := &plugin.GenerateResponse{}
44 |
45 | queryFiles, err := generateQueries(req, options, cfg.packageName)
46 | if err != nil {
47 | return nil, fmt.Errorf("generating queries: %w", err)
48 | }
49 | res.Files = append(res.Files, queryFiles...)
50 |
51 | if !options.DisableCRUD {
52 | crudFiles, err := generateCRUD(req, options, cfg.packageName)
53 | if err != nil {
54 | return nil, fmt.Errorf("generating crud: %w", err)
55 | }
56 | res.Files = append(res.Files, crudFiles...)
57 | }
58 |
59 | if err := formatGeneratedFiles(res.Files); err != nil {
60 | return nil, fmt.Errorf("formatting generated files: %w", err)
61 | }
62 |
63 | return res, nil
64 | }
65 |
66 | func buildGenerationConfig(req *plugin.GenerateRequest, opts *Options) (generationConfig, error) {
67 | settings := req.GetSettings()
68 | var outPath, pluginName string
69 | if settings != nil && settings.GetCodegen() != nil {
70 | outPath = strings.TrimSpace(settings.GetCodegen().GetOut())
71 | pluginName = strings.TrimSpace(settings.GetCodegen().GetPlugin())
72 | }
73 |
74 | packageCandidate := ""
75 | if outPath != "" {
76 | packageCandidate = lastPathComponent(outPath)
77 | }
78 | if packageCandidate == "" && pluginName != "" {
79 | packageCandidate = pluginName
80 | }
81 | if packageCandidate == "" {
82 | packageCandidate = "zz"
83 | }
84 |
85 | return generationConfig{
86 | packageName: toolbelt.ToCasedString(packageCandidate),
87 | }, nil
88 | }
89 |
90 | func cleanupOutputDirectory(req *plugin.GenerateRequest) error {
91 | settings := req.GetSettings()
92 | if settings == nil || settings.GetCodegen() == nil {
93 | return fmt.Errorf("missing codegen settings")
94 | }
95 |
96 | baseOut := strings.TrimSpace(settings.GetCodegen().GetOut())
97 | if baseOut == "" {
98 | return nil
99 | }
100 |
101 | target := filepath.Clean(baseOut)
102 | if strings.HasPrefix(target, "..") {
103 | return fmt.Errorf("refusing to clean output outside workspace: %q", target)
104 | }
105 |
106 | if target == "." || target == "" {
107 | return fmt.Errorf("refusing to remove unsafe output directory %q", target)
108 | }
109 |
110 | absTarget, err := filepath.Abs(target)
111 | if err != nil {
112 | return fmt.Errorf("determining absolute output path: %w", err)
113 | }
114 | if filepath.Dir(absTarget) == absTarget {
115 | return fmt.Errorf("refusing to remove root directory %q", absTarget)
116 | }
117 |
118 | if err := os.RemoveAll(absTarget); err != nil {
119 | return fmt.Errorf("removing output directory %s: %w", absTarget, err)
120 | }
121 | if err := os.MkdirAll(absTarget, 0o755); err != nil {
122 | return fmt.Errorf("creating output directory %s: %w", absTarget, err)
123 | }
124 | return nil
125 | }
126 |
127 | func lastPathComponent(p string) string {
128 | trimmed := strings.TrimSpace(p)
129 | if trimmed == "" {
130 | return ""
131 | }
132 | normalized := strings.ReplaceAll(trimmed, "\\", "/")
133 | base := path.Base(normalized)
134 | if base == "." || base == "/" {
135 | return ""
136 | }
137 | return base
138 | }
139 |
140 | func parseOptions(req *plugin.GenerateRequest) (*Options, error) {
141 | opts := &Options{}
142 | if len(req.PluginOptions) == 0 {
143 | return opts, nil
144 | }
145 |
146 | dec := json.NewDecoder(bytes.NewReader(req.PluginOptions))
147 | dec.DisallowUnknownFields()
148 | if err := dec.Decode(opts); err != nil {
149 | return nil, fmt.Errorf("unmarshalling options: %w", err)
150 | }
151 |
152 | return opts, nil
153 | }
154 |
155 | func formatGeneratedFiles(files []*plugin.File) error {
156 | opts := &imports.Options{
157 | Comments: true,
158 | TabIndent: true,
159 | TabWidth: 8,
160 | }
161 |
162 | for _, f := range files {
163 | if filepath.Ext(f.Name) != ".go" {
164 | continue
165 | }
166 |
167 | formatted, err := imports.Process(f.Name, f.Contents, opts)
168 | if err == nil {
169 | f.Contents = formatted
170 | continue
171 | }
172 |
173 | formatted, err = format.Source(f.Contents)
174 | if err != nil {
175 | return fmt.Errorf("formatting %s: %w", f.Name, err)
176 | }
177 | f.Contents = formatted
178 | }
179 |
180 | return nil
181 | }
182 |
--------------------------------------------------------------------------------
/natsrpc/generator.go:
--------------------------------------------------------------------------------
1 | package natsrpc
2 |
3 | import (
4 | "fmt"
5 | "log"
6 | "path/filepath"
7 | "time"
8 |
9 | "github.com/delaneyj/toolbelt"
10 | ext "github.com/delaneyj/toolbelt/natsrpc/protos/natsrpc"
11 | "google.golang.org/protobuf/compiler/protogen"
12 | "google.golang.org/protobuf/proto"
13 | "google.golang.org/protobuf/reflect/protoreflect"
14 | "google.golang.org/protobuf/types/known/durationpb"
15 | )
16 |
17 | var (
18 | isFirst = true
19 | serviceSeen = map[string]struct{}{}
20 | )
21 |
22 | func Generate(gen *protogen.Plugin, file *protogen.File) error {
23 |
24 | pkgData, err := optsToPackageData(file)
25 | if err != nil {
26 | return fmt.Errorf("failed to convert options to data: %w", err)
27 | }
28 |
29 | if pkgData == nil {
30 | return nil
31 | }
32 |
33 | if isFirst {
34 | isFirst = false
35 | sharedFilepath := filepath.Join(filepath.Dir(pkgData.FileBasepath), "natsrpc_shared.go")
36 | // log.Printf("Writing to file %s", sharedFilepath)
37 |
38 | sharedContent := goSharedTypesTemplate(pkgData)
39 | g := gen.NewGeneratedFile(sharedFilepath, pkgData.GoImportPath)
40 | if _, err := g.Write([]byte(sharedContent)); err != nil {
41 | return fmt.Errorf("failed to write to file: %w", err)
42 | }
43 | }
44 |
45 | if err := generateGoFile(gen, pkgData); err != nil {
46 | return fmt.Errorf("failed to generate file: %w", err)
47 | }
48 |
49 | return nil
50 | }
51 |
52 | type methodTmplData struct {
53 | ServiceName, Name toolbelt.CasedString
54 | IsClientStreaming, IsServerStreaming bool
55 | InputType, OutputType toolbelt.CasedString
56 | }
57 |
58 | type serviceTmplData struct {
59 | Name toolbelt.CasedString
60 | Subject string
61 | Methods []*methodTmplData
62 | }
63 |
64 | type kvTemplData struct {
65 | PackageName toolbelt.CasedString
66 | Name toolbelt.CasedString
67 | Bucket string
68 | IsClientReadonly bool
69 | TTL time.Duration
70 | ID toolbelt.CasedString
71 | IdIsString bool
72 | HistoryCount uint32
73 | }
74 |
75 | type packageTmplData struct {
76 | GoImportPath protogen.GoImportPath
77 | FileBasepath string
78 | PackageName toolbelt.CasedString
79 | Services []*serviceTmplData
80 | KeyValues []*kvTemplData
81 | }
82 |
83 | func optsToPackageData(file *protogen.File) (*packageTmplData, error) {
84 | // log.Printf("Generating package %+v", file)
85 | data := &packageTmplData{
86 | GoImportPath: file.GoImportPath,
87 | FileBasepath: file.GeneratedFilenamePrefix + "_natsrpc",
88 | PackageName: toolbelt.ToCasedString(string(file.GoPackageName)),
89 | Services: make([]*serviceTmplData, 0, len(file.Services)),
90 | }
91 |
92 | for _, s := range file.Services {
93 | if len(s.Methods) == 0 {
94 | continue
95 | }
96 |
97 | // log.Printf("Generating service %+v", s)
98 | sn := toolbelt.ToCasedString(s.GoName)
99 | svcData := &serviceTmplData{
100 | Name: sn,
101 | Subject: "natsrpc." + sn.Kebab,
102 | Methods: make([]*methodTmplData, len(s.Methods)),
103 | }
104 | for i, m := range s.Methods {
105 | mn := toolbelt.ToCasedString(string(m.Desc.Name()))
106 | methodData := &methodTmplData{
107 | Name: mn,
108 | ServiceName: sn,
109 | IsClientStreaming: m.Desc.IsStreamingClient(),
110 | IsServerStreaming: m.Desc.IsStreamingServer(),
111 | InputType: toolbelt.ToCasedString(m.Input.GoIdent.GoName),
112 | OutputType: toolbelt.ToCasedString(m.Output.GoIdent.GoName),
113 | }
114 | svcData.Methods[i] = methodData
115 | }
116 |
117 | data.Services = append(data.Services, svcData)
118 | }
119 |
120 | for _, msg := range file.Messages {
121 | kvBucket, ok := proto.GetExtension(msg.Desc.Options(), ext.E_KvBucket).(string)
122 | if !ok || kvBucket == "" {
123 | continue
124 | }
125 |
126 | log.Printf("Generating key-value %+v", msg)
127 |
128 | isReadonly := proto.GetExtension(msg.Desc.Options(), ext.E_KvClientReadonly).(bool)
129 | ttl := proto.GetExtension(msg.Desc.Options(), ext.E_KvTtl).(*durationpb.Duration)
130 | historyCount := proto.GetExtension(msg.Desc.Options(), ext.E_KvHistoryCount).(uint32)
131 |
132 | var idField *protogen.Field
133 | for _, f := range msg.Fields {
134 | isID := proto.GetExtension(f.Desc.Options(), ext.E_KvId).(bool)
135 | if isID {
136 | idField = f
137 | break
138 | }
139 | }
140 | if idField == nil {
141 | for _, f := range msg.Fields {
142 | if f.Desc.Name() == "id" {
143 | idField = f
144 | break
145 | }
146 | }
147 | }
148 |
149 | if idField == nil {
150 | return nil, fmt.Errorf("no id field found in message %s", msg.Desc.Name())
151 | }
152 |
153 | kvData := &kvTemplData{
154 | PackageName: data.PackageName,
155 | Name: toolbelt.ToCasedString(string(msg.Desc.Name())),
156 | Bucket: kvBucket,
157 | IsClientReadonly: isReadonly,
158 | TTL: ttl.AsDuration(),
159 | ID: toolbelt.ToCasedString(string(idField.Desc.Name())),
160 | IdIsString: idField.Desc.Kind() == protoreflect.StringKind,
161 | HistoryCount: historyCount,
162 | }
163 |
164 | data.KeyValues = append(data.KeyValues, kvData)
165 | }
166 |
167 | if len(data.Services) == 0 && len(data.KeyValues) == 0 {
168 | return nil, nil
169 | }
170 |
171 | return data, nil
172 | }
173 |
174 | func generateGoFile(gen *protogen.Plugin, data *packageTmplData) error {
175 | // log.Printf("Generating package %+v", data)
176 | log.Printf("Generating package '%s'", data.PackageName.Original)
177 |
178 | files := map[string]string{}
179 |
180 | if len(data.Services) > 0 {
181 | log.Printf("Generating services for package '%s', %d services", data.PackageName.Original, len(data.Services))
182 | files[data.FileBasepath+"_server.go"] = goServerTemplate(data)
183 | files[data.FileBasepath+"_client.go"] = goClientTemplate(data)
184 | }
185 |
186 | if len(data.KeyValues) > 0 {
187 | log.Printf("Generating key-values for package '%s'", data.PackageName.Original)
188 | files[data.FileBasepath+"_kv.go"] = goKVTemplate(data)
189 | }
190 |
191 | for filename, contents := range files {
192 | // log.Printf("Writing to file %s", filename)
193 |
194 | g := gen.NewGeneratedFile(filename, data.GoImportPath)
195 | if _, err := g.Write([]byte(contents)); err != nil {
196 | return fmt.Errorf("failed to write to file: %w", err)
197 | }
198 | }
199 |
200 | return nil
201 | }
202 |
--------------------------------------------------------------------------------
/natsrpc/services_kv_go.qtpl:
--------------------------------------------------------------------------------
1 |
2 | {% func goKVTemplate(pkg *packageTmplData) %}
3 | // Code generated by protoc-gen-go-natsrpc. DO NOT EDIT.
4 |
5 | package {%s pkg.PackageName.Snake %}
6 |
7 | import (
8 | "context"
9 | "fmt"
10 | "time"
11 | "errors"
12 | "github.com/nats-io/nats.go/jetstream"
13 | "google.golang.org/protobuf/proto"
14 | )
15 |
16 | {% for _, kv := range pkg.KeyValues %}
17 | type {%s kv.Name.Pascal %}KV struct {
18 | kv jetstream.KeyValue
19 | }
20 |
21 | func(tkv *{%s kv.Name.Pascal %}KV) new{%s kv.Name.Pascal %}() *{%s kv.Name.Pascal %}{
22 | return &{%s kv.Name.Pascal %}{}
23 | }
24 |
25 | func(tkv *{%s kv.Name.Pascal %}KV) id(msg *{%s kv.Name.Pascal %}) string {
26 | {%- if kv.IdIsString -%}
27 | return msg.{%s kv.ID.Pascal%}
28 | {%- else -%}
29 | return fmt.Sprint(msg.{%s kv.ID.Pascal %})
30 | {%- endif -%}
31 | }
32 |
33 | // should generate kv bucket for {%s= kv.Bucket %} {%s kv.Name.Pascal %}
34 | func Upsert{%s kv.Name.Pascal %}KV(ctx context.Context, js jetstream.JetStream) (*{%s kv.Name.Pascal %}KV, error) {
35 | ttl, err := time.ParseDuration("{%s kv.TTL.String() %}")
36 | if err != nil {
37 | return nil, fmt.Errorf("failed to parse duration: %w", err)
38 | }
39 |
40 | kvCfg := jetstream.KeyValueConfig{
41 | Bucket: "{%s= kv.Bucket %}",
42 | TTL: ttl,
43 | History: 1,
44 | }
45 | kv, err := js.CreateOrUpdateKeyValue(ctx, kvCfg)
46 | if err != nil {
47 | return nil, fmt.Errorf("failed to upsert kv: %w", err)
48 | }
49 |
50 | container := &{%s kv.Name.Pascal %}KV{
51 | kv: kv,
52 | }
53 |
54 | return container, nil
55 | }
56 |
57 | func (tkv *{%s kv.Name.Pascal %}KV) Keys(ctx context.Context, watchOpts ...jetstream.WatchOpt) ([]string, error) {
58 | keys, err := tkv.kv.Keys(ctx, watchOpts...)
59 | if err != nil && err != jetstream.ErrNoKeysFound {
60 | return nil, err
61 | }
62 | return keys, nil
63 | }
64 |
65 | func (tkv *{%s kv.Name.Pascal %}KV) Get(ctx context.Context, key string) (*{%s kv.Name.Pascal %}, uint64, error) {
66 | entry, err := tkv.kv.Get(ctx,key)
67 | if err != nil {
68 | if err == jetstream.ErrKeyNotFound {
69 | return nil, 0, nil
70 | }
71 | }
72 | out, err := tkv.unmarshal(entry)
73 | if err != nil {
74 | return out, 0, err
75 | }
76 | return out, entry.Revision(), nil
77 | }
78 |
79 | func (tkv *{%s kv.Name.Pascal %}KV) unmarshal(entry jetstream.KeyValueEntry) (*{%s kv.Name.Pascal %}, error) {
80 | if entry == nil {
81 | return nil, nil
82 | }
83 | b := entry.Value()
84 | if b == nil {
85 | return nil, nil
86 | }
87 | t := tkv.new{%s kv.Name.Pascal %}()
88 | if err := proto.Unmarshal(b, t); err != nil {
89 | return t, err
90 | }
91 | return t, nil
92 | }
93 |
94 | func (tkv *{%s kv.Name.Pascal %}KV) Load(ctx context.Context, keys ...string) ([]*{%s kv.Name.Pascal %}, error) {
95 | var errs []error
96 | loaded := make([]*{%s kv.Name.Pascal %}, len(keys))
97 | for i, key := range keys {
98 | t, _, err := tkv.Get(ctx, key)
99 | if err != nil {
100 | errs = append(errs, err)
101 | }
102 | loaded[i] = t
103 | }
104 | if len(errs) > 0 {
105 | return nil, errors.Join(errs...)
106 | }
107 | return loaded, nil
108 | }
109 |
110 | func (tkv *{%s kv.Name.Pascal %}KV) All(ctx context.Context) (out []*{%s kv.Name.Pascal %}, err error) {
111 | keys, err := tkv.kv.Keys(ctx)
112 | if err != nil {
113 | if err == jetstream.ErrNoKeysFound {
114 | return nil, nil
115 | }
116 | return nil, fmt.Errorf("failed to get all keys: %w", err)
117 | }
118 | return tkv.Load(ctx, keys...)
119 | }
120 |
121 | func (tkv *{%s kv.Name.Pascal %}KV) Set(ctx context.Context, value *{%s kv.Name.Pascal%}) (revision uint64, err error) {
122 | b, err := proto.Marshal(value)
123 | if err != nil {
124 | return 0, err
125 | }
126 | revision, err = tkv.kv.Put(ctx, tkv.id(value), b)
127 | return
128 | }
129 |
130 | func (tkv *{%s kv.Name.Pascal %}KV) Batch(ctx context.Context, values ... *{%s kv.Name.Pascal %}) (err error) {
131 | errs := make([]error, len(values))
132 | for i, value := range values {
133 | _, errs[i] = tkv.Set(ctx, value)
134 | }
135 | if err := errors.Join(errs...); err != nil {
136 | return fmt.Errorf("failed to batch set: %w", err)
137 | }
138 | return nil
139 | }
140 |
141 | func (tkv *{%s kv.Name.Pascal %}KV) Update(ctx context.Context, value *{%s kv.Name.Pascal %}, last uint64) (revision uint64, err error) {
142 | b, err := proto.Marshal(value)
143 | if err != nil {
144 | return 0, err
145 | }
146 | key := tkv.id(value)
147 | revision, err = tkv.kv.Update(ctx, key, b, last)
148 | return
149 | }
150 |
151 | func (tkv *{%s kv.Name.Pascal %}KV) DeleteKey(ctx context.Context, key string) (err error) {
152 | return tkv.kv.Delete(ctx, key)
153 | }
154 |
155 | func (tkv *{%s kv.Name.Pascal %}KV) Delete(ctx context.Context, value *{%s kv.Name.Pascal %}) (err error) {
156 | return tkv.kv.Delete(ctx, tkv.id(value))
157 | }
158 |
159 | type {%s kv.Name.Pascal %}Entry struct {
160 | Key string
161 | Op jetstream.KeyValueOp
162 | {%s kv.Name.Pascal %} *{%s kv.Name.Pascal %}
163 | }
164 |
165 | func (tkv *{%s kv.Name.Pascal %}KV) watch(ctx context.Context, w jetstream.KeyWatcher) (values <-chan *{%s kv.Name.Pascal %}Entry, stop func() error, err error) {
166 | ch := make(chan *{%s kv.Name.Pascal %}Entry)
167 | updates := w.Updates()
168 | go func(ctx context.Context, w jetstream.KeyWatcher) error {
169 | for {
170 | select {
171 | case <-ctx.Done():
172 | return nil
173 | case entry := <-updates:
174 | if entry == nil {
175 | continue
176 | }
177 |
178 | typeEntry := &{%s kv.Name.Pascal %}Entry{
179 | Key: entry.Key(),
180 | Op: entry.Operation(),
181 | {%s kv.Name.Pascal %}: nil,
182 | }
183 |
184 | if typeEntry.Op != jetstream.KeyValueDelete {
185 | t, err := tkv.unmarshal(entry)
186 | if err != nil {
187 | return err
188 | }
189 | typeEntry.{%s kv.Name.Pascal %} = t
190 | }
191 |
192 | ch <- typeEntry
193 | }
194 | }
195 | }(ctx, w)
196 | return ch, w.Stop, nil
197 | }
198 |
199 | func (tkv *{%s kv.Name.Pascal %}KV) Watch(ctx context.Context, key string, opts ...jetstream.WatchOpt) (values <-chan *{%s kv.Name.Pascal %}Entry, stop func() error, err error) {
200 | w, err := tkv.kv.Watch(ctx,key, opts...)
201 | if err != nil {
202 | return nil, nil, fmt.Errorf("failed to watch key %s: %w", key, err)
203 | }
204 | return tkv.watch(ctx, w)
205 | }
206 |
207 | func (tkv *{%s kv.Name.Pascal %}KV) WatchAll(ctx context.Context, opts ...jetstream.WatchOpt) (values <-chan *{%s kv.Name.Pascal %}Entry, stop func() error, err error) {
208 | w, err := tkv.kv.WatchAll(ctx,opts...)
209 | if err != nil {
210 | return nil, nil, fmt.Errorf("failed to watch all: %w", err)
211 | }
212 | return tkv.watch(ctx, w)
213 | }
214 |
215 | {% endfor %}
216 | {% endfunc %}
--------------------------------------------------------------------------------
/jtd/jtd.go:
--------------------------------------------------------------------------------
1 | package jtd
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 | )
7 |
8 | // Schema represents a JSON Type Definition schema according to RFC 8927
9 | type Schema struct {
10 | // Metadata
11 | Metadata map[string]any `json:"metadata,omitempty"`
12 |
13 | // Nullable indicates whether the value can be null
14 | Nullable bool `json:"nullable,omitempty"`
15 |
16 | // Type forms
17 | Type Type `json:"type,omitempty"`
18 |
19 | // Enum form
20 | Enum []string `json:"enum,omitempty"`
21 |
22 | // Elements form (for arrays)
23 | Elements *Schema `json:"elements,omitempty"`
24 |
25 | // Properties form (for objects)
26 | Properties map[string]*Schema `json:"properties,omitempty"`
27 | OptionalProperties map[string]*Schema `json:"optionalProperties,omitempty"`
28 | AdditionalProperties bool `json:"additionalProperties,omitempty"`
29 |
30 | // Values form (for maps)
31 | Values *Schema `json:"values,omitempty"`
32 |
33 | // Discriminator form (for tagged unions)
34 | Discriminator string `json:"discriminator,omitempty"`
35 | Mapping map[string]*Schema `json:"mapping,omitempty"`
36 |
37 | // Ref form
38 | Ref string `json:"ref,omitempty"`
39 |
40 | // Definitions (for root schema only)
41 | Definitions map[string]*Schema `json:"definitions,omitempty"`
42 | }
43 |
44 | // Type represents the primitive types in JTD
45 | type Type string
46 |
47 | const (
48 | TypeBoolean Type = "boolean"
49 | TypeString Type = "string"
50 | TypeTimestamp Type = "timestamp"
51 | TypeFloat32 Type = "float32"
52 | TypeFloat64 Type = "float64"
53 | TypeInt8 Type = "int8"
54 | TypeInt16 Type = "int16"
55 | TypeInt32 Type = "int32"
56 | TypeUint8 Type = "uint8"
57 | TypeUint16 Type = "uint16"
58 | TypeUint32 Type = "uint32"
59 | )
60 |
61 | // IsValid checks if the type is a valid JTD type
62 | func (t Type) IsValid() bool {
63 | switch t {
64 | case TypeBoolean, TypeString, TypeTimestamp,
65 | TypeFloat32, TypeFloat64,
66 | TypeInt8, TypeInt16, TypeInt32,
67 | TypeUint8, TypeUint16, TypeUint32:
68 | return true
69 | }
70 | return false
71 | }
72 |
73 | // ToGoType converts JTD type to Go type
74 | func (t Type) ToGoType() string {
75 | switch t {
76 | case TypeBoolean:
77 | return "bool"
78 | case TypeString:
79 | return "string"
80 | case TypeTimestamp:
81 | return "time.Time"
82 | case TypeFloat32:
83 | return "float32"
84 | case TypeFloat64:
85 | return "float64"
86 | case TypeInt8:
87 | return "int8"
88 | case TypeInt16:
89 | return "int16"
90 | case TypeInt32:
91 | return "int32"
92 | case TypeUint8:
93 | return "uint8"
94 | case TypeUint16:
95 | return "uint16"
96 | case TypeUint32:
97 | return "uint32"
98 | default:
99 | return "any"
100 | }
101 | }
102 |
103 | // Form returns the form of the schema
104 | func (s *Schema) Form() string {
105 | if s == nil {
106 | return "empty"
107 | }
108 |
109 | formCount := 0
110 | form := ""
111 |
112 | if s.Ref != "" {
113 | formCount++
114 | form = "ref"
115 | }
116 | if s.Type != "" {
117 | formCount++
118 | form = "type"
119 | }
120 | if s.Enum != nil {
121 | formCount++
122 | form = "enum"
123 | }
124 | if s.Elements != nil {
125 | formCount++
126 | form = "elements"
127 | }
128 | if len(s.Properties) > 0 || len(s.OptionalProperties) > 0 {
129 | formCount++
130 | form = "properties"
131 | }
132 | if s.Values != nil {
133 | formCount++
134 | form = "values"
135 | }
136 | if s.Discriminator != "" {
137 | formCount++
138 | form = "discriminator"
139 | }
140 |
141 | if formCount == 0 {
142 | return "empty"
143 | }
144 | if formCount > 1 {
145 | return "invalid"
146 | }
147 |
148 | return form
149 | }
150 |
151 | // Validate checks if the schema is valid according to RFC 8927
152 | func (s *Schema) Validate() error {
153 | form := s.Form()
154 | if form == "invalid" {
155 | return fmt.Errorf("schema has multiple forms")
156 | }
157 |
158 | switch form {
159 | case "type":
160 | if !s.Type.IsValid() {
161 | return fmt.Errorf("invalid type: %s", s.Type)
162 | }
163 | case "enum":
164 | if len(s.Enum) == 0 {
165 | return fmt.Errorf("enum cannot be empty")
166 | }
167 | // Check for duplicates
168 | seen := make(map[string]bool)
169 | for _, v := range s.Enum {
170 | if seen[v] {
171 | return fmt.Errorf("duplicate enum value: %s", v)
172 | }
173 | seen[v] = true
174 | }
175 | case "elements":
176 | if err := s.Elements.Validate(); err != nil {
177 | return fmt.Errorf("invalid elements schema: %w", err)
178 | }
179 | case "properties":
180 | // Check for overlapping property names
181 | for name := range s.Properties {
182 | if _, exists := s.OptionalProperties[name]; exists {
183 | return fmt.Errorf("property %s appears in both properties and optionalProperties", name)
184 | }
185 | }
186 | // Validate all property schemas
187 | for name, prop := range s.Properties {
188 | if err := prop.Validate(); err != nil {
189 | return fmt.Errorf("invalid property %s: %w", name, err)
190 | }
191 | }
192 | for name, prop := range s.OptionalProperties {
193 | if err := prop.Validate(); err != nil {
194 | return fmt.Errorf("invalid optional property %s: %w", name, err)
195 | }
196 | }
197 | case "values":
198 | if err := s.Values.Validate(); err != nil {
199 | return fmt.Errorf("invalid values schema: %w", err)
200 | }
201 | case "discriminator":
202 | if s.Discriminator == "" {
203 | return fmt.Errorf("discriminator cannot be empty")
204 | }
205 | if len(s.Mapping) == 0 {
206 | return fmt.Errorf("discriminator mapping cannot be empty")
207 | }
208 | // Validate all mapping schemas
209 | for tag, schema := range s.Mapping {
210 | if err := schema.Validate(); err != nil {
211 | return fmt.Errorf("invalid discriminator mapping %s: %w", tag, err)
212 | }
213 | // Ensure mapping schemas are properties form
214 | if schema.Form() != "properties" {
215 | return fmt.Errorf("discriminator mapping %s must be properties form", tag)
216 | }
217 | // Ensure discriminator property doesn't exist in mapping schemas
218 | if _, exists := schema.Properties[s.Discriminator]; exists {
219 | return fmt.Errorf("discriminator property %s cannot exist in mapping %s", s.Discriminator, tag)
220 | }
221 | if _, exists := schema.OptionalProperties[s.Discriminator]; exists {
222 | return fmt.Errorf("discriminator property %s cannot exist in mapping %s optional properties", s.Discriminator, tag)
223 | }
224 | }
225 | case "ref":
226 | if s.Ref == "" {
227 | return fmt.Errorf("ref cannot be empty")
228 | }
229 | }
230 |
231 | return nil
232 | }
233 |
234 | // ParseSchema parses a JSON Type Definition schema from JSON
235 | func ParseSchema(data []byte) (*Schema, error) {
236 | var schema Schema
237 | if err := json.Unmarshal(data, &schema); err != nil {
238 | return nil, fmt.Errorf("failed to parse schema: %w", err)
239 | }
240 |
241 | // Validate root schema
242 | if err := schema.Validate(); err != nil {
243 | return nil, err
244 | }
245 |
246 | // Validate definitions if present
247 | for name, def := range schema.Definitions {
248 | if err := def.Validate(); err != nil {
249 | return nil, fmt.Errorf("invalid definition %s: %w", name, err)
250 | }
251 | }
252 |
253 | return &schema, nil
254 | }
255 |
--------------------------------------------------------------------------------
/natsrpc/protos/natsrpc/ext.pb.go:
--------------------------------------------------------------------------------
1 | // Code generated by protoc-gen-go. DO NOT EDIT.
2 | // versions:
3 | // protoc-gen-go v1.35.1
4 | // protoc (unknown)
5 | // source: natsrpc/ext.proto
6 |
7 | package natsrpc
8 |
9 | import (
10 | protoreflect "google.golang.org/protobuf/reflect/protoreflect"
11 | protoimpl "google.golang.org/protobuf/runtime/protoimpl"
12 | descriptorpb "google.golang.org/protobuf/types/descriptorpb"
13 | durationpb "google.golang.org/protobuf/types/known/durationpb"
14 | reflect "reflect"
15 | )
16 |
17 | const (
18 | // Verify that this generated code is sufficiently up-to-date.
19 | _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
20 | // Verify that runtime/protoimpl is sufficiently up-to-date.
21 | _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
22 | )
23 |
24 | var file_natsrpc_ext_proto_extTypes = []protoimpl.ExtensionInfo{
25 | {
26 | ExtendedType: (*descriptorpb.MessageOptions)(nil),
27 | ExtensionType: (*string)(nil),
28 | Field: 13337,
29 | Name: "natsrpc.kv_bucket",
30 | Tag: "bytes,13337,opt,name=kv_bucket",
31 | Filename: "natsrpc/ext.proto",
32 | },
33 | {
34 | ExtendedType: (*descriptorpb.MessageOptions)(nil),
35 | ExtensionType: (*bool)(nil),
36 | Field: 13338,
37 | Name: "natsrpc.kv_client_readonly",
38 | Tag: "varint,13338,opt,name=kv_client_readonly",
39 | Filename: "natsrpc/ext.proto",
40 | },
41 | {
42 | ExtendedType: (*descriptorpb.MessageOptions)(nil),
43 | ExtensionType: (*durationpb.Duration)(nil),
44 | Field: 13339,
45 | Name: "natsrpc.kv_ttl",
46 | Tag: "bytes,13339,opt,name=kv_ttl",
47 | Filename: "natsrpc/ext.proto",
48 | },
49 | {
50 | ExtendedType: (*descriptorpb.MessageOptions)(nil),
51 | ExtensionType: (*uint32)(nil),
52 | Field: 13340,
53 | Name: "natsrpc.kv_history_count",
54 | Tag: "varint,13340,opt,name=kv_history_count",
55 | Filename: "natsrpc/ext.proto",
56 | },
57 | {
58 | ExtendedType: (*descriptorpb.FieldOptions)(nil),
59 | ExtensionType: (*bool)(nil),
60 | Field: 14337,
61 | Name: "natsrpc.kv_id",
62 | Tag: "varint,14337,opt,name=kv_id",
63 | Filename: "natsrpc/ext.proto",
64 | },
65 | }
66 |
67 | // Extension fields to descriptorpb.MessageOptions.
68 | var (
69 | // optional string kv_bucket = 13337;
70 | E_KvBucket = &file_natsrpc_ext_proto_extTypes[0]
71 | // optional bool kv_client_readonly = 13338;
72 | E_KvClientReadonly = &file_natsrpc_ext_proto_extTypes[1]
73 | // optional google.protobuf.Duration kv_ttl = 13339;
74 | E_KvTtl = &file_natsrpc_ext_proto_extTypes[2]
75 | // optional uint32 kv_history_count = 13340;
76 | E_KvHistoryCount = &file_natsrpc_ext_proto_extTypes[3]
77 | )
78 |
79 | // Extension fields to descriptorpb.FieldOptions.
80 | var (
81 | // optional bool kv_id = 14337;
82 | E_KvId = &file_natsrpc_ext_proto_extTypes[4]
83 | )
84 |
85 | var File_natsrpc_ext_proto protoreflect.FileDescriptor
86 |
87 | var file_natsrpc_ext_proto_rawDesc = []byte{
88 | 0x0a, 0x11, 0x6e, 0x61, 0x74, 0x73, 0x72, 0x70, 0x63, 0x2f, 0x65, 0x78, 0x74, 0x2e, 0x70, 0x72,
89 | 0x6f, 0x74, 0x6f, 0x12, 0x07, 0x6e, 0x61, 0x74, 0x73, 0x72, 0x70, 0x63, 0x1a, 0x20, 0x67, 0x6f,
90 | 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x64, 0x65,
91 | 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x6f, 0x72, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x1e,
92 | 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f,
93 | 0x64, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x3a, 0x40,
94 | 0x0a, 0x09, 0x6b, 0x76, 0x5f, 0x62, 0x75, 0x63, 0x6b, 0x65, 0x74, 0x12, 0x1f, 0x2e, 0x67, 0x6f,
95 | 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x4d, 0x65,
96 | 0x73, 0x73, 0x61, 0x67, 0x65, 0x4f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x99, 0x68, 0x20,
97 | 0x01, 0x28, 0x09, 0x52, 0x08, 0x6b, 0x76, 0x42, 0x75, 0x63, 0x6b, 0x65, 0x74, 0x88, 0x01, 0x01,
98 | 0x3a, 0x51, 0x0a, 0x12, 0x6b, 0x76, 0x5f, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x5f, 0x72, 0x65,
99 | 0x61, 0x64, 0x6f, 0x6e, 0x6c, 0x79, 0x12, 0x1f, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e,
100 | 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65,
101 | 0x4f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x9a, 0x68, 0x20, 0x01, 0x28, 0x08, 0x52, 0x10,
102 | 0x6b, 0x76, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x61, 0x64, 0x6f, 0x6e, 0x6c, 0x79,
103 | 0x88, 0x01, 0x01, 0x3a, 0x55, 0x0a, 0x06, 0x6b, 0x76, 0x5f, 0x74, 0x74, 0x6c, 0x12, 0x1f, 0x2e,
104 | 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e,
105 | 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x4f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x9b,
106 | 0x68, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70,
107 | 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x44, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e,
108 | 0x52, 0x05, 0x6b, 0x76, 0x54, 0x74, 0x6c, 0x88, 0x01, 0x01, 0x3a, 0x4d, 0x0a, 0x10, 0x6b, 0x76,
109 | 0x5f, 0x68, 0x69, 0x73, 0x74, 0x6f, 0x72, 0x79, 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x1f,
110 | 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66,
111 | 0x2e, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x4f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x18,
112 | 0x9c, 0x68, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x0e, 0x6b, 0x76, 0x48, 0x69, 0x73, 0x74, 0x6f, 0x72,
113 | 0x79, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x88, 0x01, 0x01, 0x3a, 0x36, 0x0a, 0x05, 0x6b, 0x76, 0x5f,
114 | 0x69, 0x64, 0x12, 0x1d, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74,
115 | 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x4f, 0x70, 0x74, 0x69, 0x6f, 0x6e,
116 | 0x73, 0x18, 0x81, 0x70, 0x20, 0x01, 0x28, 0x08, 0x52, 0x04, 0x6b, 0x76, 0x49, 0x64, 0x88, 0x01,
117 | 0x01, 0x42, 0x88, 0x01, 0x0a, 0x0b, 0x63, 0x6f, 0x6d, 0x2e, 0x6e, 0x61, 0x74, 0x73, 0x72, 0x70,
118 | 0x63, 0x42, 0x08, 0x45, 0x78, 0x74, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x50, 0x01, 0x5a, 0x33, 0x67,
119 | 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x64, 0x65, 0x6c, 0x61, 0x6e, 0x65,
120 | 0x79, 0x6a, 0x2f, 0x74, 0x6f, 0x6f, 0x6c, 0x62, 0x65, 0x6c, 0x74, 0x2f, 0x6e, 0x61, 0x74, 0x73,
121 | 0x72, 0x70, 0x63, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x73, 0x2f, 0x6e, 0x61, 0x74, 0x73, 0x72,
122 | 0x70, 0x63, 0xa2, 0x02, 0x03, 0x4e, 0x58, 0x58, 0xaa, 0x02, 0x07, 0x4e, 0x61, 0x74, 0x73, 0x72,
123 | 0x70, 0x63, 0xca, 0x02, 0x07, 0x4e, 0x61, 0x74, 0x73, 0x72, 0x70, 0x63, 0xe2, 0x02, 0x13, 0x4e,
124 | 0x61, 0x74, 0x73, 0x72, 0x70, 0x63, 0x5c, 0x47, 0x50, 0x42, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61,
125 | 0x74, 0x61, 0xea, 0x02, 0x07, 0x4e, 0x61, 0x74, 0x73, 0x72, 0x70, 0x63, 0x62, 0x06, 0x70, 0x72,
126 | 0x6f, 0x74, 0x6f, 0x33,
127 | }
128 |
129 | var file_natsrpc_ext_proto_goTypes = []any{
130 | (*descriptorpb.MessageOptions)(nil), // 0: google.protobuf.MessageOptions
131 | (*descriptorpb.FieldOptions)(nil), // 1: google.protobuf.FieldOptions
132 | (*durationpb.Duration)(nil), // 2: google.protobuf.Duration
133 | }
134 | var file_natsrpc_ext_proto_depIdxs = []int32{
135 | 0, // 0: natsrpc.kv_bucket:extendee -> google.protobuf.MessageOptions
136 | 0, // 1: natsrpc.kv_client_readonly:extendee -> google.protobuf.MessageOptions
137 | 0, // 2: natsrpc.kv_ttl:extendee -> google.protobuf.MessageOptions
138 | 0, // 3: natsrpc.kv_history_count:extendee -> google.protobuf.MessageOptions
139 | 1, // 4: natsrpc.kv_id:extendee -> google.protobuf.FieldOptions
140 | 2, // 5: natsrpc.kv_ttl:type_name -> google.protobuf.Duration
141 | 6, // [6:6] is the sub-list for method output_type
142 | 6, // [6:6] is the sub-list for method input_type
143 | 5, // [5:6] is the sub-list for extension type_name
144 | 0, // [0:5] is the sub-list for extension extendee
145 | 0, // [0:0] is the sub-list for field type_name
146 | }
147 |
148 | func init() { file_natsrpc_ext_proto_init() }
149 | func file_natsrpc_ext_proto_init() {
150 | if File_natsrpc_ext_proto != nil {
151 | return
152 | }
153 | type x struct{}
154 | out := protoimpl.TypeBuilder{
155 | File: protoimpl.DescBuilder{
156 | GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
157 | RawDescriptor: file_natsrpc_ext_proto_rawDesc,
158 | NumEnums: 0,
159 | NumMessages: 0,
160 | NumExtensions: 5,
161 | NumServices: 0,
162 | },
163 | GoTypes: file_natsrpc_ext_proto_goTypes,
164 | DependencyIndexes: file_natsrpc_ext_proto_depIdxs,
165 | ExtensionInfos: file_natsrpc_ext_proto_extTypes,
166 | }.Build()
167 | File_natsrpc_ext_proto = out.File
168 | file_natsrpc_ext_proto_rawDesc = nil
169 | file_natsrpc_ext_proto_goTypes = nil
170 | file_natsrpc_ext_proto_depIdxs = nil
171 | }
172 |
--------------------------------------------------------------------------------
/sparse_set.go:
--------------------------------------------------------------------------------
1 | package toolbelt
2 |
3 | // Handle is a stable reference to an element in a SparseSet.
4 | // Index and Generation are both uint32. A handle is valid if its
5 | // generation matches the current generation for its slot in the set
6 | // in which it was created.
7 | type Handle struct {
8 | Index uint32
9 | Generation uint32
10 | }
11 |
12 | // SparseSet is a growable, O(1) add/get/remove container that uses
13 | // stable, generational handles to reference elements without exposing
14 | // internal indices. Removals are constant time by swapping the last
15 | // element into the removed slot in the dense array.
16 | //
17 | // This is a Go port inspired by Karl Zylinski's odin-handle-map
18 | // (handle_map_growing), adapted to Go generics and idioms.
19 | //
20 | // The zero value is ready to use; use NewSparseSet to pre-reserve.
21 | type SparseSet[T any] struct {
22 | // dense holds the contiguous elements for iteration/cache friendliness.
23 | dense []T
24 | // denseSlots maps dense index -> slot id (sparse index) for reverse updates on swap-remove.
25 | denseSlots []uint32
26 |
27 | // sparse maps slot id -> dense index; -1 indicates free/unoccupied slot.
28 | sparse []int
29 | // generations is per-slot generation counter used to invalidate stale handles.
30 | generations []uint32
31 |
32 | // free is a stack of reusable slot ids.
33 | free []uint32
34 | }
35 |
36 | // NewSparseSet constructs a SparseSet with an optional initial slot capacity.
37 | // A capacity of 0 creates an empty set with no pre-allocated slots.
38 | func NewSparseSet[T any](initialCapacity int) *SparseSet[T] {
39 | s := &SparseSet[T]{}
40 | if initialCapacity > 0 {
41 | s.reserveSlots(initialCapacity)
42 | }
43 | return s
44 | }
45 |
46 | // Len returns the number of live elements.
47 | func (s *SparseSet[T]) Len() int { return len(s.dense) }
48 |
49 | // Cap returns the number of total slots (live + free) that can be used
50 | // without growing the sparse structures.
51 | func (s *SparseSet[T]) Cap() int { return len(s.sparse) }
52 |
53 | // Reserve ensures the set can accommodate at least n live elements
54 | // without reallocating dense storage. Note: this does not change the
55 | // number of available slots for handles. To increase the number of
56 | // potential handles without insertions, call ReserveSlots.
57 | func (s *SparseSet[T]) Reserve(n int) {
58 | if n > cap(s.dense) {
59 | // Grow dense slices' capacity
60 | newDense := make([]T, len(s.dense), n)
61 | copy(newDense, s.dense)
62 | s.dense = newDense
63 |
64 | newDenseSlots := make([]uint32, len(s.denseSlots), n)
65 | copy(newDenseSlots, s.denseSlots)
66 | s.denseSlots = newDenseSlots
67 | }
68 | }
69 |
70 | // maxSlotCount returns the maximum number of addressable slots for a uint32 index,
71 | // clamped to the platform's max int to avoid overflow on 32-bit architectures.
72 | func maxSlotCount() int {
73 | // If MaxInt < MaxUint32, clamp to MaxInt; otherwise return 2^32.
74 | if (uint64(^uint(0)) >> 1) < uint64(^uint32(0)) {
75 | return int(^uint(0) >> 1)
76 | }
77 | return int(^uint32(0)) + 1
78 | }
79 |
80 | // ReserveSlots ensures the set has at least n slots available to be
81 | // populated by future Insert calls before growing the sparse structures.
82 | func (s *SparseSet[T]) ReserveSlots(n int) { s.reserveSlots(n) }
83 |
84 | func (s *SparseSet[T]) reserveSlots(n int) {
85 | if n <= len(s.sparse) {
86 | return
87 | }
88 | if n > maxSlotCount() {
89 | panic("requested slots exceed index type capacity")
90 | }
91 | // Extend sparse arrays; new slots start as unoccupied with generation 1.
92 | add := n - len(s.sparse)
93 | s.sparse = append(s.sparse, make([]int, add)...)
94 | s.generations = append(s.generations, make([]uint32, add)...)
95 | // Mark new slots free and initialize generation to 1 so zero is always invalid.
96 | for i := len(s.sparse) - add; i < len(s.sparse); i++ {
97 | s.sparse[i] = -1
98 | s.generations[i] = uint32(1)
99 | s.free = append(s.free, uint32(i))
100 | }
101 | }
102 |
103 | // Insert adds v to the set and returns a stable handle to it.
104 | // Amortized O(1). May grow the internal storage.
105 | func (s *SparseSet[T]) Insert(v T) Handle {
106 | var slot uint32
107 | if n := len(s.free); n > 0 {
108 | slot = s.free[n-1]
109 | s.free = s.free[:n-1]
110 | } else {
111 | // Grow by at least one slot when needed.
112 | i := len(s.sparse)
113 | if i >= maxSlotCount() {
114 | panic("exceeded index type capacity")
115 | }
116 | s.sparse = append(s.sparse, -1)
117 | s.generations = append(s.generations, uint32(1)) // start at 1; 0 means invalid
118 | slot = uint32(i)
119 | }
120 |
121 | denseIndex := len(s.dense)
122 | s.dense = append(s.dense, v)
123 | s.denseSlots = append(s.denseSlots, slot)
124 | s.sparse[int(slot)] = denseIndex
125 |
126 | return Handle{Index: slot, Generation: s.generations[int(slot)]}
127 | }
128 |
129 | // Get returns the value for h if it is valid and present.
130 | func (s *SparseSet[T]) Get(h Handle) (T, bool) {
131 | var zero T
132 | idx, ok := s.indexOf(h)
133 | if !ok {
134 | return zero, false
135 | }
136 | return s.dense[idx], true
137 | }
138 |
139 | // GetRef returns a pointer to the value for h if valid.
140 | // Note: Removing other elements may move the value due to swap-remove,
141 | // invalidating previously taken pointers. Use only transiently.
142 | func (s *SparseSet[T]) GetRef(h Handle) (*T, bool) {
143 | idx, ok := s.indexOf(h)
144 | if !ok {
145 | return nil, false
146 | }
147 | return &s.dense[idx], true
148 | }
149 |
150 | // Contains reports whether h refers to a live element in s.
151 | func (s *SparseSet[T]) Contains(h Handle) bool {
152 | _, ok := s.indexOf(h)
153 | return ok
154 | }
155 |
156 | // Remove deletes the element referenced by h if present.
157 | // Returns true if the element existed and was removed.
158 | func (s *SparseSet[T]) Remove(h Handle) bool {
159 | idx, ok := s.indexOf(h)
160 | if !ok {
161 | return false
162 | }
163 | // Slot corresponding to the element to remove
164 | slot := s.denseSlots[idx]
165 |
166 | last := len(s.dense) - 1
167 | if idx != last {
168 | // Move last element into removed position
169 | s.dense[idx] = s.dense[last]
170 | s.denseSlots[idx] = s.denseSlots[last]
171 | movedSlot := s.denseSlots[idx]
172 | s.sparse[int(movedSlot)] = idx
173 | }
174 | // Truncate dense arrays
175 | s.dense = s.dense[:last]
176 | s.denseSlots = s.denseSlots[:last]
177 |
178 | // Invalidate handle and free slot
179 | s.sparse[int(slot)] = -1
180 | s.generations[int(slot)]++
181 | if s.generations[int(slot)] == 0 { // avoid zero being a valid generation
182 | s.generations[int(slot)] = uint32(1)
183 | }
184 | s.free = append(s.free, slot)
185 | return true
186 | }
187 |
188 | // Clear removes all elements. Existing handles become invalid.
189 | func (s *SparseSet[T]) Clear() {
190 | // Invalidate all occupied slots and add them to free list.
191 | for i := range s.denseSlots {
192 | slot := s.denseSlots[i]
193 | s.sparse[int(slot)] = -1
194 | s.generations[int(slot)]++
195 | if s.generations[int(slot)] == 0 {
196 | s.generations[int(slot)] = uint32(1)
197 | }
198 | s.free = append(s.free, slot)
199 | }
200 | s.dense = s.dense[:0]
201 | s.denseSlots = s.denseSlots[:0]
202 | }
203 |
204 | // Range iterates over all live elements. The callback receives a current
205 | // handle for each element and a pointer to its value. If f returns false,
206 | // iteration stops early.
207 | func (s *SparseSet[T]) Range(f func(h Handle, v *T) bool) {
208 | for i := 0; i < len(s.dense); i++ {
209 | slot := s.denseSlots[i]
210 | h := Handle{Index: slot, Generation: s.generations[int(slot)]}
211 | if !f(h, &s.dense[i]) {
212 | return
213 | }
214 | }
215 | }
216 |
217 | // Handles returns a snapshot of handles for all live elements.
218 | func (s *SparseSet[T]) Handles() []Handle {
219 | out := make([]Handle, len(s.dense))
220 | for i, slot := range s.denseSlots {
221 | out[i] = Handle{Index: slot, Generation: s.generations[int(slot)]}
222 | }
223 | return out
224 | }
225 |
226 | // Values returns a shallow copy of all live elements in dense order.
227 | func (s *SparseSet[T]) Values() []T {
228 | out := make([]T, len(s.dense))
229 | copy(out, s.dense)
230 | return out
231 | }
232 |
233 | // indexOf returns the dense index of handle h if valid.
234 | func (s *SparseSet[T]) indexOf(h Handle) (int, bool) {
235 | idxI := int(h.Index)
236 | if idxI < 0 || idxI >= len(s.sparse) {
237 | return -1, false
238 | }
239 | di := s.sparse[idxI]
240 | if di < 0 || di >= len(s.dense) {
241 | return -1, false
242 | }
243 | if s.generations[idxI] != h.Generation {
244 | return -1, false
245 | }
246 | // Sanity: verify reverse map points back to same slot (should always hold)
247 | if s.denseSlots[di] != h.Index {
248 | return -1, false
249 | }
250 | return di, true
251 | }
252 |
--------------------------------------------------------------------------------
/easing.go:
--------------------------------------------------------------------------------
1 | package toolbelt
2 |
3 | import (
4 | "math"
5 | )
6 |
7 | type EasingMode string
8 |
9 | const (
10 | EasingModeLinear EasingMode = "linear"
11 | EasingModeInQuad EasingMode = "inquad"
12 | EasingModeOutQuad EasingMode = "outquad"
13 | EasingModeInOutQuad EasingMode = "inoutquad"
14 | EasingModeInCubic EasingMode = "incubic"
15 | EasingModeOutCubic EasingMode = "outcubic"
16 | EasingModeInOutCubic EasingMode = "inoutcubic"
17 | EasingModeInQuart EasingMode = "inquart"
18 | EasingModeOutQuart EasingMode = "outquart"
19 | EasingModeInOutQuart EasingMode = "inoutquart"
20 | EasingModeInQuint EasingMode = "inquint"
21 | EasingModeOutQuint EasingMode = "outquint"
22 | EasingModeInOutQuint EasingMode = "inoutquint"
23 | EasingModeInSine EasingMode = "insine"
24 | EasingModeOutSine EasingMode = "outsine"
25 | EasingModeInOutSine EasingMode = "inoutsine"
26 | EasingModeInExpo EasingMode = "inexpo"
27 | EasingModeOutExpo EasingMode = "outexpo"
28 | EasingModeInOutExpo EasingMode = "inoutexpo"
29 | EasingModeInCirc EasingMode = "incirc"
30 | EasingModeOutCirc EasingMode = "outcirc"
31 | EasingModeInOutCirc EasingMode = "inoutcirc"
32 | EasingModeInElastic EasingMode = "inelastic"
33 | EasingModeOutElastic EasingMode = "outelastic"
34 | EasingModeInOutElastic EasingMode = "inoutelastic"
35 | EasingModeInBack EasingMode = "inback"
36 | EasingModeOutBack EasingMode = "outback"
37 | EasingModeInOutBack EasingMode = "inoutback"
38 | EasingModeInBounce EasingMode = "inbounce"
39 | EasingModeOutBounce EasingMode = "outbounce"
40 | EasingModeInOutBounce EasingMode = "inoutbounce"
41 | )
42 |
43 | // Easing functions based on Robert Penner's equations.
44 | // t is expected to be in the range [0, 1] and the return value is in [0, 1].
45 |
46 | // EasingFunc is a function that takes a normalized time t in [0,1]
47 | // and returns the eased value in [0,1].
48 | type EasingFunc[T Float] func(t T) T
49 |
50 | // Linear
51 | func EaseLinear[T Float](t T) T { return t }
52 |
53 | // Quad
54 | func EaseInQuad[T Float](t T) T { return T(float64(t) * float64(t)) }
55 | func EaseOutQuad[T Float](t T) T { return T(float64(t) * (2 - float64(t))) }
56 | func EaseInOutQuad[T Float](t T) T {
57 | ft := float64(t)
58 | if ft < 0.5 {
59 | return T(2 * ft * ft)
60 | }
61 | return T(-1 + (4-2*ft)*ft)
62 | }
63 |
64 | // Cubic
65 | func EaseInCubic[T Float](t T) T { ft := float64(t); return T(ft * ft * ft) }
66 | func EaseOutCubic[T Float](t T) T { ft := 1 - float64(t); return T(1 - ft*ft*ft) }
67 | func EaseInOutCubic[T Float](t T) T {
68 | ft := float64(t)
69 | if ft < 0.5 {
70 | return T(4 * ft * ft * ft)
71 | }
72 | f := -2*ft + 2
73 | return T(1 - (f*f*f)/2)
74 | }
75 |
76 | // Quart
77 | func EaseInQuart[T Float](t T) T { ft := float64(t); ft2 := ft * ft; return T(ft2 * ft2) }
78 | func EaseOutQuart[T Float](t T) T { ft := 1 - float64(t); ft2 := ft * ft; return T(1 - ft2*ft2) }
79 | func EaseInOutQuart[T Float](t T) T {
80 | ft := float64(t)
81 | if ft < 0.5 {
82 | return T(8 * ft * ft * ft * ft)
83 | }
84 | f := -2*ft + 2
85 | return T(1 - (f*f*f*f)/2)
86 | }
87 |
88 | // Quint
89 | func EaseInQuint[T Float](t T) T { ft := float64(t); ft2 := ft * ft; return T(ft2 * ft2 * ft) }
90 | func EaseOutQuint[T Float](t T) T { ft := 1 - float64(t); ft2 := ft * ft; return T(1 - ft2*ft2*ft) }
91 | func EaseInOutQuint[T Float](t T) T {
92 | ft := float64(t)
93 | if ft < 0.5 {
94 | return T(16 * ft * ft * ft * ft * ft)
95 | }
96 | f := -2*ft + 2
97 | return T(1 - (f*f*f*f*f)/2)
98 | }
99 |
100 | // Sine
101 | func EaseInSine[T Float](t T) T { return T(1 - math.Cos((float64(t)*math.Pi)/2)) }
102 | func EaseOutSine[T Float](t T) T { return T(math.Sin((float64(t) * math.Pi) / 2)) }
103 | func EaseInOutSine[T Float](t T) T {
104 | return T(-(math.Cos(math.Pi*float64(t)) - 1) / 2)
105 | }
106 |
107 | // Expo
108 | func EaseInExpo[T Float](t T) T {
109 | ft := float64(t)
110 | if ft == 0 {
111 | return 0
112 | }
113 | return T(math.Pow(2, 10*ft-10))
114 | }
115 | func EaseOutExpo[T Float](t T) T {
116 | ft := float64(t)
117 | if ft == 1 {
118 | return 1
119 | }
120 | return T(1 - math.Pow(2, -10*ft))
121 | }
122 | func EaseInOutExpo[T Float](t T) T {
123 | ft := float64(t)
124 | if ft == 0 {
125 | return 0
126 | }
127 | if ft == 1 {
128 | return 1
129 | }
130 | if ft < 0.5 {
131 | return T(math.Pow(2, 20*ft-10) / 2)
132 | }
133 | return T((2 - math.Pow(2, -20*ft+10)) / 2)
134 | }
135 |
136 | // Circ
137 | func EaseInCirc[T Float](t T) T { return T(1 - math.Sqrt(1-math.Pow(float64(t), 2))) }
138 | func EaseOutCirc[T Float](t T) T { ft := float64(t) - 1; return T(math.Sqrt(1 - ft*ft)) }
139 | func EaseInOutCirc[T Float](t T) T {
140 | ft := float64(t)
141 | if ft < 0.5 {
142 | return T((1 - math.Sqrt(1-math.Pow(2*ft, 2))) / 2)
143 | }
144 | return T((math.Sqrt(1-math.Pow(-2*ft+2, 2)) + 1) / 2)
145 | }
146 |
147 | // Back
148 | func EaseInBack[T Float](t T) T {
149 | c1 := 1.70158
150 | c3 := c1 + 1
151 | ft := float64(t)
152 | return T(c3*ft*ft*ft - c1*ft*ft)
153 | }
154 | func EaseOutBack[T Float](t T) T {
155 | c1 := 1.70158
156 | c3 := c1 + 1
157 | ft := float64(t) - 1
158 | return T(1 + c3*ft*ft*ft + c1*ft*ft)
159 | }
160 | func EaseInOutBack[T Float](t T) T {
161 | c1 := 1.70158
162 | c2 := c1 * 1.525
163 | ft := float64(t)
164 | if ft < 0.5 {
165 | f := 2 * ft
166 | return T((f * f * ((c2+1)*f - c2)) / 2)
167 | }
168 | f := 2*ft - 2
169 | return T((f*f*((c2+1)*f+c2) + 2) / 2)
170 | }
171 |
172 | // Elastic
173 | func EaseInElastic[T Float](t T) T {
174 | ft := float64(t)
175 | if ft == 0 {
176 | return 0
177 | }
178 | if ft == 1 {
179 | return 1
180 | }
181 | c4 := (2 * math.Pi) / 3
182 | return T(-math.Pow(2, 10*ft-10) * math.Sin((ft*10-10.75)*c4))
183 | }
184 | func EaseOutElastic[T Float](t T) T {
185 | ft := float64(t)
186 | if ft == 0 {
187 | return 0
188 | }
189 | if ft == 1 {
190 | return 1
191 | }
192 | c4 := (2 * math.Pi) / 3
193 | return T(math.Pow(2, -10*ft)*math.Sin((ft*10-0.75)*c4) + 1)
194 | }
195 | func EaseInOutElastic[T Float](t T) T {
196 | ft := float64(t)
197 | if ft == 0 {
198 | return 0
199 | }
200 | if ft == 1 {
201 | return 1
202 | }
203 | c5 := (2 * math.Pi) / 4.5
204 | if ft < 0.5 {
205 | return T(-(math.Pow(2, 20*ft-10) * math.Sin((20*ft-11.125)*c5)) / 2)
206 | }
207 | return T((math.Pow(2, -20*ft+10)*math.Sin((20*ft-11.125)*c5))/2 + 1)
208 | }
209 |
210 | // Bounce
211 | func EaseInBounce[T Float](t T) T { return T(1 - bounceOut(float64(1-t))) }
212 | func EaseOutBounce[T Float](t T) T { return T(bounceOut(float64(t))) }
213 | func EaseInOutBounce[T Float](t T) T {
214 | ft := float64(t)
215 | if ft < 0.5 {
216 | return T((1 - bounceOut(1-2*ft)) / 2)
217 | }
218 | return T((1 + bounceOut(2*ft-1)) / 2)
219 | }
220 |
221 | // bounceOut is a helper implementing the piecewise bounce easing.
222 | func bounceOut(t float64) float64 {
223 | n1 := 7.5625
224 | d1 := 2.75
225 | if t < 1/d1 {
226 | return n1 * t * t
227 | } else if t < 2/d1 {
228 | t -= 1.5 / d1
229 | return n1*t*t + 0.75
230 | } else if t < 2.5/d1 {
231 | t -= 2.25 / d1
232 | return n1*t*t + 0.9375
233 | } else {
234 | t -= 2.625 / d1
235 | return n1*t*t + 0.984375
236 | }
237 | }
238 |
239 | // Ease returns a named easing function. Unknown names fall back to linear.
240 | // Accepted names are case-insensitive and allow dashes/underscores/spaces, e.g.:
241 | //
242 | // "linear", "in-quad", "easeInCubic", "inout-sine", "out_bounce", etc.
243 | func Ease[T Float](mode EasingMode) EasingFunc[T] {
244 |
245 | switch mode {
246 |
247 | // Quad
248 | case EasingModeInQuad:
249 | return EaseInQuad[T]
250 | case EasingModeOutQuad:
251 | return EaseOutQuad[T]
252 | case EasingModeInOutQuad:
253 | return EaseInOutQuad[T]
254 |
255 | // Cubic
256 | case EasingModeInCubic:
257 | return EaseInCubic[T]
258 | case EasingModeOutCubic:
259 | return EaseOutCubic[T]
260 | case EasingModeInOutCubic:
261 | return EaseInOutCubic[T]
262 |
263 | // Quart
264 | case EasingModeInQuart:
265 | return EaseInQuart[T]
266 | case EasingModeOutQuart:
267 | return EaseOutQuart[T]
268 | case EasingModeInOutQuart:
269 | return EaseInOutQuart[T]
270 |
271 | // Quint
272 | case EasingModeInQuint:
273 | return EaseInQuint[T]
274 | case EasingModeOutQuint:
275 | return EaseOutQuint[T]
276 | case EasingModeInOutQuint:
277 | return EaseInOutQuint[T]
278 |
279 | // Sine
280 | case EasingModeInSine:
281 | return EaseInSine[T]
282 | case EasingModeOutSine:
283 | return EaseOutSine[T]
284 | case EasingModeInOutSine:
285 | return EaseInOutSine[T]
286 |
287 | // Expo
288 | case EasingModeInExpo:
289 | return EaseInExpo[T]
290 | case EasingModeOutExpo:
291 | return EaseOutExpo[T]
292 | case EasingModeInOutExpo:
293 | return EaseInOutExpo[T]
294 |
295 | // Circ
296 | case EasingModeInCirc:
297 | return EaseInCirc[T]
298 | case EasingModeOutCirc:
299 | return EaseOutCirc[T]
300 | case EasingModeInOutCirc:
301 | return EaseInOutCirc[T]
302 |
303 | // Back
304 | case EasingModeInBack:
305 | return EaseInBack[T]
306 | case EasingModeOutBack:
307 | return EaseOutBack[T]
308 | case EasingModeInOutBack:
309 | return EaseInOutBack[T]
310 |
311 | // Elastic
312 | case EasingModeInElastic:
313 | return EaseInElastic[T]
314 | case EasingModeOutElastic:
315 | return EaseOutElastic[T]
316 | case EasingModeInOutElastic:
317 | return EaseInOutElastic[T]
318 |
319 | // Bounce
320 | case EasingModeInBounce:
321 | return EaseInBounce[T]
322 | case EasingModeOutBounce:
323 | return EaseOutBounce[T]
324 | case EasingModeInOutBounce:
325 | return EaseInOutBounce[T]
326 |
327 | default:
328 | return EaseLinear[T]
329 | }
330 | }
331 |
--------------------------------------------------------------------------------
/natsrpc/services_server_go.qtpl:
--------------------------------------------------------------------------------
1 | {% func goServerTemplate(pkg *packageTmplData) %}
2 | // Code generated by protoc-gen-go-natsrpc. DO NOT EDIT.
3 |
4 | package {%s pkg.PackageName.Snake %}
5 |
6 | import (
7 | "context"
8 | "errors"
9 | "fmt"
10 | "log"
11 |
12 | "github.com/nats-io/nats.go"
13 | "google.golang.org/protobuf/proto"
14 | "gopkg.in/typ.v4/sync2"
15 | )
16 |
17 | {%- for _, service := range pkg.Services -%}
18 | {%- code
19 | nsp := service.Name.Pascal
20 | -%}
21 |
22 | type {%s nsp %}Service interface {
23 | OnClose() error
24 |
25 | //#region Methods!
26 | {%- for _, method := range service.Methods -%}
27 | {%- code
28 | cs := method.IsClientStreaming
29 | ss := method.IsServerStreaming
30 | -%}
31 | {%- switch -%}
32 | {%- case !cs && !ss -%}
33 | {%s method.Name.Pascal %}(ctx context.Context, req *{%s method.InputType.Original %}) (res *{%s method.OutputType.Original %}, err error) // Unary call for {%s method.Name.Pascal %}
34 | {%- case cs && !ss -%}
35 | {%s method.Name.Pascal %}(ctx context.Context, reqCh <-chan *{%s method.InputType.Original %}) (res *{%s method.OutputType.Original %}, err error) // Client streaming call for {%s method.Name.Pascal %}
36 | {%- case !cs && ss -%}
37 | {%s method.Name.Pascal %}(ctx context.Context, req *{%s method.InputType.Original %}, resCh chan<- *{%s method.OutputType.Original %}) (err error) // Server streaming call for {%s method.Name.Pascal %}
38 | {%- case cs && ss -%}
39 | {%s method.Name.Pascal %}(ctx context.Context, reqCh <-chan *{%s method.InputType.Original %}, resCh chan<- *{%s method.OutputType.Original %}, errCh chan<- error) error // Bidirectional streaming call for {%s method.Name.Pascal %}
40 | {%- endswitch -%}
41 | {%- endfor -%}
42 | //#endregion
43 | }
44 |
45 | const {%s nsp %}ServiceSubject = "{%s service.Subject %}"
46 |
47 | type {%s nsp %}ServiceRunner struct {
48 | baseSubject string
49 | service {%s nsp %}Service
50 | nc *nats.Conn
51 | subs []*nats.Subscription
52 | }
53 |
54 | func New{%s nsp %}ServiceRunnerSingleton(ctx context.Context, nc *nats.Conn, service {%s nsp %}Service) (*{%s nsp %}ServiceRunner, error) {
55 | return New{%s nsp %}ServiceRunner(ctx, nc, service, 0)
56 | }
57 |
58 | func New{%s nsp %}ServiceRunner(ctx context.Context, nc *nats.Conn, service {%s nsp %}Service, instanceID int64) (*{%s nsp %}ServiceRunner, error) {
59 | subjectSuffix := ""
60 | if instanceID > 0 {
61 | subjectSuffix = fmt.Sprintf(".%d", instanceID)
62 | }
63 |
64 | baseSubject := fmt.Sprintf("{%s service.Subject %}%s", subjectSuffix)
65 | {%- for _, method := range service.Methods -%}
66 | {%s method.Name.Camel %}Subject := baseSubject + ".{%s method.Name.Kebab %}"
67 | {%- endfor -%}
68 |
69 | runner := &{%s nsp %}ServiceRunner{
70 | service: service,
71 | nc: nc,
72 | }
73 |
74 | {% if len(service.Methods) > 0 %}
75 | var (
76 | sub *nats.Subscription
77 | err error
78 | )
79 | {%- for _, method := range service.Methods -%}
80 | {%- code
81 | subjectName := method.Name.Camel + "Subject"
82 | ss,cs := method.IsServerStreaming, method.IsClientStreaming
83 | -%}
84 | {%- switch -%}
85 | {%- case !cs && !ss -%}
86 | {%= goServerUnaryHandler(subjectName, method) %}
87 | {%- case cs && !ss -%}
88 | {%= goServerClientStreamHandler(subjectName, method) %}
89 | {%- case !cs && ss -%}
90 | {%= goServerServerStreamHandler(subjectName, method) %}
91 | {%- case cs && ss -%}
92 | {%= goServerBidiStreamHandler(subjectName, method) %}
93 | {%- endswitch -%}
94 | if err != nil {
95 | return nil, fmt.Errorf("failed to subscribe to {%s method.Name.Pascal %}: %w", err)
96 | }
97 | runner.subs = append(runner.subs, sub)
98 | {%- endfor -%}
99 | {% endif %}
100 |
101 | return runner,nil
102 | }
103 |
104 | func (runner *{%s nsp %}ServiceRunner) Close() error {
105 | var errs []error
106 |
107 | for _, sub := range runner.subs {
108 | if err := sub.Unsubscribe(); err != nil {
109 | errs = append(errs, err)
110 | }
111 | }
112 |
113 | if runner.service != nil {
114 | if err := runner.service.OnClose(); err != nil {
115 | errs = append(errs, err)
116 | }
117 | }
118 |
119 | if err := errors.Join(errs...); err != nil {
120 | return fmt.Errorf("failed to close runner: %w", err)
121 | }
122 |
123 | return nil
124 | }
125 |
126 | {%- endfor -%}
127 |
128 | {% endfunc %}
129 |
130 |
131 | {% func goServerUnaryHandler(subjectName string, method *methodTmplData) %}
132 | // Unary call for {%s method.Name.Pascal %}
133 | sub, err = nc.Subscribe({%s subjectName %}, func(msg *nats.Msg) {
134 | req := &{%s method.InputType.Original %}{}
135 | if err := proto.Unmarshal(msg.Data, req); err != nil {
136 | sendError(msg, fmt.Errorf("failed to unmarshal request: %w", err))
137 | return
138 | }
139 |
140 | res, err := runner.service.{%s method.Name.Pascal %}(context.Background(),req)
141 | if err != nil {
142 | sendError(msg, err)
143 | return
144 | }
145 | sendSuccess(msg, res)
146 | })
147 | {% endfunc %}
148 |
149 | {% func goServerClientStreamHandler(subjectName string, method *methodTmplData) %}
150 | {% code
151 | reqChName := method.Name.Camel + "ClientReqChs"
152 | inputName := method.InputType.Original
153 | %}
154 | // Client streaming call for {%s method.Name.Pascal %}
155 | {%s reqChName %} := sync2.Map[string, chan *{%s= inputName %}]{}
156 | sub, err = nc.Subscribe({%s subjectName %}, func(msg *nats.Msg) {
157 | // Check for end of stream
158 | if len(msg.Data) == 0 {
159 | log.Printf("Got EOF")
160 | reqCh, ok := {%s reqChName %}.Load(msg.Reply)
161 | if !ok {
162 | sendError(msg, errors.New("no request channel found"))
163 | return
164 | }
165 | close(reqCh)
166 | {%s reqChName %}.Delete(msg.Reply)
167 | return
168 | }
169 |
170 | // Check for request
171 | req := &{%s= inputName %}{}
172 | if err := proto.Unmarshal(msg.Data, req); err != nil {
173 | sendError(msg, fmt.Errorf("failed to unmarshal request: %w", err))
174 | return
175 | }
176 |
177 | log.Printf("Got request: %v", req)
178 |
179 | // Check for request channel
180 | reqCh, ok := {%s reqChName %}.Load(msg.Reply)
181 | if !ok {
182 | reqCh = make(chan *{%s= inputName %})
183 |
184 | {%s reqChName %}.Store(msg.Reply, reqCh)
185 |
186 | go func() {
187 | res, err := runner.service.{%s method.Name.Pascal %}(context.Background(),reqCh)
188 | if err != nil {
189 | sendError(msg, err)
190 | return
191 | }
192 | sendSuccess(msg, res)
193 | }()
194 | }
195 | reqCh <- req
196 | })
197 | {% endfunc %}
198 |
199 | {% func goServerServerStreamHandler(subjectName string, method *methodTmplData) %}
200 | // Server streaming call for {%s method.Name.Pascal %}
201 | sub, err = nc.Subscribe({%s subjectName %}, func(msg *nats.Msg) {
202 | req := &{%s method.InputType.Original %}{}
203 | if err := proto.Unmarshal(msg.Data, req); err != nil {
204 | sendError(msg, fmt.Errorf("failed to unmarshal request: %w", err))
205 | return
206 | }
207 |
208 | go func() {
209 | resCh := make(chan *{%s method.OutputType.Original %})
210 | defer close(resCh)
211 |
212 | // Send responses to client
213 | go func () {
214 | defer sendEOF(msg)
215 | for {
216 | select {
217 | case res, ok := <-resCh:
218 | if !ok {
219 | return
220 | }
221 | sendSuccess(msg, res)
222 | }
223 | }
224 | }()
225 |
226 | // User defined handler, this will block until the context is done
227 | if err := runner.service.{%s method.Name.Pascal%}(ctx, req, resCh); err != nil {
228 | sendError(msg, err)
229 | }
230 | }()
231 | })
232 | {% endfunc %}
233 |
234 | {% func goServerBidiStreamHandler(subjectName string, method *methodTmplData) %}
235 | {% code
236 | reqChName := method.Name.Camel + "BiReqChs"
237 | inputName := method.InputType.Original
238 | %}
239 | // Bidirectional streaming call for {%s method.Name.Pascal %}
240 | {%s reqChName %} := sync2.Map[string, chan *{%s= inputName %}]{}
241 | sub, err = nc.Subscribe({%s subjectName %}, func(msg *nats.Msg) {
242 | // Check for end of stream
243 | if len(msg.Data) == 0 {
244 | reqCh, ok := {%s reqChName %}.Load(msg.Reply)
245 | if !ok {
246 | sendError(msg, errors.New("no request channel found"))
247 | return
248 | }
249 | close(reqCh)
250 | {%s reqChName %}.Delete(msg.Reply)
251 | return
252 | }
253 |
254 | // Check for request
255 | req := &{%s= inputName %}{}
256 | if err := proto.Unmarshal(msg.Data, req); err != nil {
257 | sendError(msg, fmt.Errorf("failed to unmarshal request: %w", err))
258 | return
259 | }
260 |
261 | // Check for request channel
262 | reqCh, ok := {%s reqChName %}.Load(msg.Reply)
263 | if !ok {
264 | reqCh = make(chan *{%s= inputName %})
265 | {%s reqChName %}.Store(msg.Reply, reqCh)
266 |
267 | go func() {
268 | defer sendEOF(msg)
269 |
270 | resCh := make(chan *{%s method.OutputType.Original %})
271 | errCh := make(chan error)
272 |
273 | go func() {
274 | for {
275 | select {
276 | case res, ok := <-resCh:
277 | if !ok {
278 | return
279 | }
280 | sendSuccess(msg, res)
281 | case err := <-errCh:
282 | sendError(msg, err)
283 | return
284 | }
285 | }
286 | }()
287 | if err := runner.service.{%s method.Name.Pascal %}(context.Background(), reqCh, resCh, errCh); err != nil {
288 | sendError(msg, err)
289 | return
290 | }
291 | }()
292 | }
293 | reqCh <- req
294 | })
295 | {% endfunc %}
--------------------------------------------------------------------------------
/database.go:
--------------------------------------------------------------------------------
1 | package toolbelt
2 |
3 | import (
4 | "context"
5 | "embed"
6 | "errors"
7 | "fmt"
8 | "io"
9 | "io/fs"
10 | "os"
11 | "path/filepath"
12 | "runtime"
13 | "slices"
14 | "strings"
15 | "time"
16 |
17 | "google.golang.org/protobuf/types/known/timestamppb"
18 | "zombiezen.com/go/sqlite"
19 | "zombiezen.com/go/sqlite/sqlitemigration"
20 | "zombiezen.com/go/sqlite/sqlitex"
21 | )
22 |
23 | type Database struct {
24 | filename string
25 | migrations []string
26 | writePool *sqlitex.Pool
27 | readPool *sqlitex.Pool
28 | pragmas []string
29 | }
30 |
31 | type databaseOptions struct {
32 | filename string
33 | migrations []string
34 | pragmas []string
35 | shouldClear bool
36 | }
37 |
38 | type DatabaseOption func(*databaseOptions)
39 |
40 | const defaultDatabaseFilename = "database.sqlite"
41 |
42 | func DatabaseWithFilename(filename string) DatabaseOption {
43 | return func(o *databaseOptions) {
44 | o.filename = filename
45 | }
46 | }
47 |
48 | func DatabaseWithMigrations(migrations []string) DatabaseOption {
49 | cp := append([]string(nil), migrations...)
50 | return func(o *databaseOptions) {
51 | o.migrations = cp
52 | }
53 | }
54 |
55 | func DatabaseWithPragmas(pragmas ...string) DatabaseOption {
56 | cp := append([]string(nil), pragmas...)
57 | return func(o *databaseOptions) {
58 | o.pragmas = cp
59 | }
60 | }
61 |
62 | func DatabaseWithShouldClear(shouldClear bool) DatabaseOption {
63 | return func(o *databaseOptions) {
64 | o.shouldClear = shouldClear
65 | }
66 | }
67 |
68 | func normalizePragma(pragma string) string {
69 | s := strings.TrimSpace(pragma)
70 | if !strings.HasPrefix(strings.ToUpper(s), "PRAGMA ") {
71 | s = "PRAGMA " + s
72 | }
73 | if !strings.HasSuffix(s, ";") {
74 | s += ";"
75 | }
76 | return s
77 | }
78 |
79 | type TxFn func(tx *sqlite.Conn) error
80 |
81 | func NewDatabase(ctx context.Context, opts ...DatabaseOption) (*Database, error) {
82 | options := databaseOptions{}
83 | for _, opt := range opts {
84 | opt(&options)
85 | }
86 |
87 | if len(options.pragmas) == 0 {
88 | options.pragmas = []string{"foreign_keys = ON"}
89 | }
90 |
91 | if options.filename == "" {
92 | options.filename = defaultDatabaseFilename
93 | }
94 |
95 | db := &Database{
96 | filename: options.filename,
97 | migrations: options.migrations,
98 | pragmas: options.pragmas,
99 | }
100 |
101 | if err := db.Reset(ctx, options.shouldClear); err != nil {
102 | return nil, fmt.Errorf("failed to reset database: %w", err)
103 | }
104 |
105 | return db, nil
106 | }
107 |
108 | func (db *Database) Path() string {
109 | return db.filename
110 | }
111 |
112 | func (db *Database) WriteWithoutTx(ctx context.Context, fn TxFn) error {
113 | conn, err := db.writePool.Take(ctx)
114 | if err != nil {
115 | return fmt.Errorf("failed to take write connection: %w", err)
116 | }
117 | if conn == nil {
118 | return fmt.Errorf("could not get write connection from pool")
119 | }
120 | defer db.writePool.Put(conn)
121 |
122 | if err := fn(conn); err != nil {
123 | return fmt.Errorf("could not execute write transaction: %w", err)
124 | }
125 |
126 | return nil
127 | }
128 |
129 | func (db *Database) Reset(ctx context.Context, shouldClear bool) (err error) {
130 | if err := db.Close(); err != nil {
131 | return fmt.Errorf("could not close database: %w", err)
132 | }
133 |
134 | if shouldClear {
135 | dbFiles, err := filepath.Glob(db.filename + "*")
136 | if err != nil {
137 | return fmt.Errorf("could not glob database files: %w", err)
138 | }
139 | for _, file := range dbFiles {
140 | if err := os.Remove(file); err != nil {
141 | return fmt.Errorf("could not remove database file: %w", err)
142 | }
143 | }
144 | }
145 |
146 | if err := os.MkdirAll(filepath.Dir(db.filename), 0o755); err != nil {
147 | return fmt.Errorf("could not create database directory: %w", err)
148 | }
149 |
150 | uri := fmt.Sprintf("file:%s?_journal_mode=WAL&_synchronous=NORMAL", db.filename)
151 |
152 | db.writePool, err = sqlitex.NewPool(uri, sqlitex.PoolOptions{
153 | PoolSize: 1,
154 | PrepareConn: func(conn *sqlite.Conn) error {
155 | for _, pragma := range db.pragmas {
156 | stmt := normalizePragma(pragma)
157 | if err := sqlitex.ExecuteTransient(conn, stmt, nil); err != nil {
158 | return fmt.Errorf("apply pragma %q: %w", pragma, err)
159 | }
160 | }
161 | return nil
162 | },
163 | })
164 | if err != nil {
165 | return fmt.Errorf("could not open write pool: %w", err)
166 | }
167 |
168 | db.readPool, err = sqlitex.NewPool(uri, sqlitex.PoolOptions{
169 | PoolSize: runtime.NumCPU(),
170 | })
171 |
172 | schema := sqlitemigration.Schema{Migrations: db.migrations}
173 | conn, err := db.writePool.Take(ctx)
174 | if err != nil {
175 | return fmt.Errorf("failed to take write connection: %w", err)
176 | }
177 | defer func() {
178 | if conn != nil {
179 | db.writePool.Put(conn)
180 | }
181 | }()
182 |
183 | if err := sqlitemigration.Migrate(ctx, conn, schema); err != nil {
184 | return fmt.Errorf("failed to migrate database: %w", err)
185 | }
186 |
187 | db.writePool.Put(conn)
188 | conn = nil
189 |
190 | return nil
191 | }
192 |
193 | func (db *Database) Close() error {
194 | errs := []error{}
195 | if db.writePool != nil {
196 | errs = append(errs, db.writePool.Close())
197 | }
198 |
199 | if db.readPool != nil {
200 | errs = append(errs, db.readPool.Close())
201 | }
202 |
203 | return errors.Join(errs...)
204 | }
205 |
206 | func (db *Database) WriteTX(ctx context.Context, fn TxFn) (err error) {
207 | conn, err := db.writePool.Take(ctx)
208 | if err != nil {
209 | return fmt.Errorf("failed to take write connection: %w", err)
210 | }
211 | if conn == nil {
212 | return fmt.Errorf("could not get write connection from pool")
213 | }
214 | defer db.writePool.Put(conn)
215 |
216 | endFn, err := sqlitex.ImmediateTransaction(conn)
217 | if err != nil {
218 | return fmt.Errorf("could not start transaction: %w", err)
219 | }
220 | defer endFn(&err)
221 |
222 | if err := fn(conn); err != nil {
223 | return fmt.Errorf("could not execute write transaction: %w", err)
224 | }
225 |
226 | return nil
227 | }
228 |
229 | func (db *Database) ReadTX(ctx context.Context, fn TxFn) (err error) {
230 | conn, err := db.readPool.Take(ctx)
231 | if err != nil {
232 | return fmt.Errorf("failed to take read connection: %w", err)
233 | }
234 | if conn == nil {
235 | return fmt.Errorf("could not get read connection from pool")
236 | }
237 | defer db.readPool.Put(conn)
238 |
239 | endFn := sqlitex.Transaction(conn)
240 | defer endFn(&err)
241 |
242 | if err := fn(conn); err != nil {
243 | return fmt.Errorf("could not execute read transaction: %w", err)
244 | }
245 |
246 | return nil
247 | }
248 |
249 | const (
250 | secondsInADay = 86400
251 | UnixEpochJulianDay = 2440587.5
252 | )
253 |
254 | var (
255 | JulianZeroTime = JulianDayToTime(0)
256 | )
257 |
258 | // TimeToJulianDay converts a time.Time into a Julian day.
259 | func TimeToJulianDay(t time.Time) float64 {
260 | return float64(t.UTC().Unix())/secondsInADay + UnixEpochJulianDay
261 | }
262 |
263 | // JulianDayToTime converts a Julian day into a time.Time.
264 | func JulianDayToTime(d float64) time.Time {
265 | return time.Unix(int64((d-UnixEpochJulianDay)*secondsInADay), 0).UTC()
266 | }
267 |
268 | func JulianNow() float64 {
269 | return TimeToJulianDay(time.Now())
270 | }
271 |
272 | func TimestampJulian(ts *timestamppb.Timestamp) float64 {
273 | return TimeToJulianDay(ts.AsTime())
274 | }
275 |
276 | func JulianDayToTimestamp(f float64) *timestamppb.Timestamp {
277 | t := JulianDayToTime(f)
278 | return timestamppb.New(t)
279 | }
280 |
281 | func StmtJulianToTimestamp(stmt *sqlite.Stmt, colName string) *timestamppb.Timestamp {
282 | julianDays := stmt.GetFloat(colName)
283 | return JulianDayToTimestamp(julianDays)
284 | }
285 |
286 | func StmtJulianToTime(stmt *sqlite.Stmt, colName string) time.Time {
287 | julianDays := stmt.GetFloat(colName)
288 | return JulianDayToTime(julianDays)
289 | }
290 |
291 | func DurationToMilliseconds(d time.Duration) int64 {
292 | return int64(d / time.Millisecond)
293 | }
294 |
295 | func MillisecondsToDuration(ms int64) time.Duration {
296 | return time.Duration(ms) * time.Millisecond
297 | }
298 |
299 | func StmtBytes(stmt *sqlite.Stmt, colName string) []byte {
300 | bl := stmt.GetLen(colName)
301 | if bl == 0 {
302 | return nil
303 | }
304 |
305 | buf := make([]byte, bl)
306 | if writtent := stmt.GetBytes(colName, buf); writtent != bl {
307 | return nil
308 | }
309 |
310 | return buf
311 | }
312 |
313 | func StmtBytesByCol(stmt *sqlite.Stmt, col int) []byte {
314 | bl := stmt.ColumnLen(col)
315 | if bl == 0 {
316 | return nil
317 | }
318 |
319 | buf := make([]byte, bl)
320 | if writtent := stmt.ColumnBytes(col, buf); writtent != bl {
321 | return nil
322 | }
323 |
324 | return buf
325 | }
326 |
327 | func MigrationsFromFS(migrationsFS embed.FS, migrationsDir string) ([]string, error) {
328 | migrationsFiles, err := migrationsFS.ReadDir(migrationsDir)
329 | if err != nil {
330 | return nil, fmt.Errorf("failed to read migrations directory: %w", err)
331 | }
332 | slices.SortFunc(migrationsFiles, func(a, b fs.DirEntry) int {
333 | return strings.Compare(a.Name(), b.Name())
334 | })
335 |
336 | migrations := make([]string, len(migrationsFiles))
337 | for i, file := range migrationsFiles {
338 | fn := filepath.Join(migrationsDir, file.Name())
339 | fnts := filepath.ToSlash(fn)
340 | f, err := migrationsFS.Open(fnts)
341 | if err != nil {
342 | return nil, fmt.Errorf("failed to open migration file: %w", err)
343 | }
344 | defer f.Close()
345 |
346 | content, err := io.ReadAll(f)
347 | if err != nil {
348 | return nil, fmt.Errorf("failed to read migration file: %w", err)
349 | }
350 |
351 | migrations[i] = string(content)
352 | }
353 |
354 | return migrations, nil
355 | }
356 |
--------------------------------------------------------------------------------
/sqlc-gen-zombiezen/zombiezen/queries.go:
--------------------------------------------------------------------------------
1 | package zombiezen
2 |
3 | import (
4 | _ "embed"
5 | "fmt"
6 | "strings"
7 |
8 | "github.com/delaneyj/toolbelt"
9 | "github.com/delaneyj/toolbelt/bytebufferpool"
10 | pluralize "github.com/gertd/go-pluralize"
11 | "github.com/samber/lo"
12 | "github.com/sqlc-dev/plugin-sdk-go/plugin"
13 | )
14 |
15 | func generateQueries(req *plugin.GenerateRequest, opts *Options, packageName toolbelt.CasedString) (files []*plugin.File, err error) {
16 | pluralClient := pluralize.NewClient()
17 | queries := make([]*GenerateQueryContext, len(req.Queries))
18 | for i, q := range req.Queries {
19 | queryCtx := &GenerateQueryContext{
20 | PackageName: packageName,
21 | Name: toolbelt.ToCasedString(q.Name),
22 | SQL: strings.TrimSpace(q.Text),
23 | }
24 | if queryCtx.SQL == "" {
25 | return nil, fmt.Errorf("query %s has no SQL", q.Name)
26 | }
27 |
28 | queryCtx.Params = lo.Map(q.Params, func(p *plugin.Parameter, pi int) GenerateField {
29 | goType, _ := toGoType(p.Column, opts)
30 |
31 | isSlice := p.Column.GetIsSqlcSlice()
32 | fieldGoType := toolbelt.ToCasedString(goType)
33 | if isSlice {
34 | fieldGoType = toolbelt.ToCasedString("[]" + goType)
35 | }
36 |
37 | param := GenerateField{
38 | Column: int(p.Number),
39 | Offset: int(p.Number) - 1,
40 | Name: toolbelt.ToCasedString(toFieldName(p.Column)),
41 | SQLType: toolbelt.ToCasedString(toSQLType(p.Column)),
42 | GoType: fieldGoType,
43 | BindGoType: toolbelt.ToCasedString(goType),
44 | OriginalName: p.Column.Name,
45 | IsNullable: !p.Column.NotNull,
46 | IsSlice: isSlice,
47 | DurationFromText: isDurationFromText(p.Column),
48 | }
49 | if isSlice {
50 | queryCtx.HasSliceParams = true
51 | }
52 | if usesToolbeltParam(param) {
53 | queryCtx.NeedsToolbelt = true
54 | }
55 | return param
56 | })
57 | queryCtx.HasParams = len(q.Params) > 0
58 | queryCtx.ParamsIsSingularField = len(q.Params) == 1
59 |
60 | if len(q.Columns) > 0 {
61 | queryCtx.HasResponse = true
62 | queryCtx.ResponseFields = lo.Map(q.Columns, func(c *plugin.Column, ci int) GenerateField {
63 | goType, _ := toGoType(c, opts)
64 |
65 | col := GenerateField{
66 | Column: ci + 1,
67 | Offset: ci,
68 | Name: toolbelt.ToCasedString(toFieldName(c)),
69 | SQLType: toolbelt.ToCasedString(toSQLType(c)),
70 | GoType: toolbelt.ToCasedString(goType),
71 | BindGoType: toolbelt.ToCasedString(goType),
72 | OriginalName: c.Name,
73 | IsNullable: !c.NotNull,
74 | DurationFromText: isDurationFromText(c),
75 | }
76 | if usesToolbeltResponse(col) {
77 | queryCtx.NeedsToolbelt = true
78 | }
79 | return col
80 | })
81 | queryCtx.ResponseHasMultiple = q.Cmd == ":many"
82 | queryCtx.ResponseIsSingularField = len(q.Columns) == 1
83 |
84 | if modelName, ok := findModelReturn(pluralClient, req, q.Columns); ok {
85 | queryCtx.ResponseModelName = modelName
86 | queryCtx.ResponseHasModel = true
87 | }
88 | }
89 | queryCtx.NeedsTimePackage = queryNeedsTimeImport(queryCtx)
90 |
91 | queries[i] = queryCtx
92 | }
93 |
94 | for _, q := range queries {
95 | buf := bytebufferpool.Get()
96 | defer bytebufferpool.Put(buf)
97 | queryContents := GenerateQuery(q)
98 |
99 | f := &plugin.File{
100 | Name: fmt.Sprintf("%s.go", q.Name.Snake),
101 | Contents: []byte(queryContents),
102 | }
103 | files = append(files, f)
104 | }
105 |
106 | return files, nil
107 | }
108 |
109 | func toSQLType(c *plugin.Column) string {
110 | switch toolbelt.Lower(c.Type.Name) {
111 | case "text":
112 | return "text"
113 | case "integer", "int":
114 | return "int64"
115 | case "datetime", "real":
116 | return "float"
117 | case "boolean":
118 | return "bool"
119 | case "blob":
120 | return "bytes"
121 | case "bool":
122 | return "bool"
123 | default:
124 | panic(fmt.Sprintf("toSQLType unhandled type %s", c.Type.Name))
125 | }
126 |
127 | }
128 |
129 | func toFieldName(c *plugin.Column) string {
130 | n := c.Name
131 | if strings.HasSuffix(n, "_ms") {
132 | return n[:len(n)-3]
133 | }
134 | return n
135 | }
136 |
137 | func toGoType(c *plugin.Column, opts *Options) (val string, needsTime bool) {
138 | typ := toolbelt.Lower(c.Type.Name)
139 | disableTime := opts != nil && opts.DisableTimeConversion
140 |
141 | if strings.HasSuffix(c.Name, "ms") {
142 | return "time.Duration", true
143 | }
144 | if strings.HasSuffix(c.Name, "_interval") && typ == "text" {
145 | return "time.Duration", true
146 | }
147 |
148 | if !disableTime && (c.Name == "at" || strings.HasSuffix(c.Name, "_at") || typ == "datetime") {
149 | return "time.Time", true
150 | }
151 |
152 | switch typ {
153 | case "text":
154 | return "string", false
155 | case "integer", "int":
156 | return "int64", false
157 | case "real":
158 | return "float64", false
159 | case "datetime":
160 | return "string", false
161 | case "boolean", "bool":
162 | return "bool", false
163 | case "blob":
164 | return "[]byte", false
165 | default:
166 | panic(fmt.Sprintf("toGoType unhandled type '%s' for column '%s'", c.Type.Name, c.Name))
167 | }
168 | }
169 |
170 | type GenerateField struct {
171 | Column int // 1-indexed
172 | Offset int // 0-indexed
173 | Name toolbelt.CasedString
174 | SQLType toolbelt.CasedString
175 | GoType toolbelt.CasedString
176 | BindGoType toolbelt.CasedString
177 | OriginalName string
178 | IsNullable bool
179 | IsSlice bool
180 | DurationFromText bool
181 | }
182 |
183 | type GenerateQueryContext struct {
184 | PackageName toolbelt.CasedString
185 | Name toolbelt.CasedString
186 | HasParams, ParamsIsSingularField bool
187 | Params []GenerateField
188 | SQL string
189 | HasResponse bool
190 | ResponseIsSingularField bool
191 | ResponseFields []GenerateField
192 | ResponseHasMultiple bool
193 |
194 | ResponseHasModel bool
195 | ResponseModelName toolbelt.CasedString
196 |
197 | NeedsTimePackage bool
198 | NeedsToolbelt bool
199 | HasSliceParams bool
200 | }
201 |
202 | func queryNeedsTimeImport(q *GenerateQueryContext) bool {
203 | usesDurationParse := func(fields []GenerateField) bool {
204 | for _, f := range fields {
205 | if f.GoType.Original == "time.Duration" && f.DurationFromText {
206 | return true
207 | }
208 | }
209 | return false
210 | }
211 | usesTimeType := func(fields []GenerateField) bool {
212 | for _, f := range fields {
213 | if strings.Contains(f.GoType.Original, "time.") {
214 | return true
215 | }
216 | }
217 | return false
218 | }
219 |
220 | if usesDurationParse(q.Params) || usesDurationParse(q.ResponseFields) {
221 | return true
222 | }
223 |
224 | if q.HasParams {
225 | if q.ParamsIsSingularField {
226 | if strings.Contains(q.Params[0].GoType.Original, "time.") {
227 | return true
228 | }
229 | } else if usesTimeType(q.Params) {
230 | return true
231 | }
232 | }
233 |
234 | if q.HasResponse {
235 | if q.ResponseIsSingularField {
236 | if strings.Contains(q.ResponseFields[0].GoType.Original, "time.") {
237 | return true
238 | }
239 | } else if !q.ResponseHasModel && usesTimeType(q.ResponseFields) {
240 | return true
241 | }
242 | }
243 |
244 | return false
245 | }
246 |
247 | func findModelReturn(pluralClient *pluralize.Client, req *plugin.GenerateRequest, cols []*plugin.Column) (toolbelt.CasedString, bool) {
248 | if len(cols) == 0 {
249 | return toolbelt.CasedString{}, false
250 | }
251 |
252 | names := make([]string, len(cols))
253 | for i, c := range cols {
254 | names[i] = c.Name
255 | }
256 |
257 | var hint *plugin.Identifier
258 | for i, c := range cols {
259 | if c.Table == nil || c.Table.Name == "" {
260 | break
261 | }
262 | if i == 0 {
263 | hint = c.Table
264 | continue
265 | }
266 | if c.Table.GetName() != hint.GetName() || c.Table.GetSchema() != hint.GetSchema() {
267 | break
268 | }
269 | }
270 |
271 | candidates := make([]*plugin.Table, 0, 1)
272 | for _, schema := range req.Catalog.Schemas {
273 | if hint != nil && hint.GetSchema() != "" && schema.Name != hint.GetSchema() {
274 | continue
275 | }
276 | for _, t := range schema.Tables {
277 | if hint != nil && t.Rel != nil && t.Rel.GetName() != hint.GetName() {
278 | continue
279 | }
280 | if len(t.Columns) != len(names) {
281 | continue
282 | }
283 | match := true
284 | for i := range t.Columns {
285 | if t.Columns[i].Name != names[i] {
286 | match = false
287 | break
288 | }
289 | }
290 | if match {
291 | candidates = append(candidates, t)
292 | }
293 | }
294 | }
295 |
296 | if len(candidates) != 1 {
297 | return toolbelt.CasedString{}, false
298 | }
299 |
300 | return toolbelt.ToCasedString(pluralClient.Singular(candidates[0].Rel.GetName())), true
301 | }
302 |
303 | func isDurationFromText(c *plugin.Column) bool {
304 | typ := toolbelt.Lower(c.Type.Name)
305 | return strings.HasSuffix(c.Name, "_interval") && typ == "text"
306 | }
307 |
308 | func usesToolbeltResponse(f GenerateField) bool {
309 | switch f.GoType.Original {
310 | case "time.Time":
311 | return true
312 | case "time.Duration":
313 | return !f.DurationFromText
314 | case "[]byte":
315 | return true
316 | default:
317 | return false
318 | }
319 | }
320 |
321 | func usesToolbeltParam(f GenerateField) bool {
322 | switch f.BindGoType.Original {
323 | case "time.Time":
324 | return true
325 | case "time.Duration":
326 | return !f.DurationFromText
327 | default:
328 | return false
329 | }
330 | }
331 |
--------------------------------------------------------------------------------
/natsrpc/services_client_go.qtpl:
--------------------------------------------------------------------------------
1 |
2 | {% func goClientTemplate(pkg *packageTmplData) %}
3 | // Code generated by protoc-gen-go-natsrpc. DO NOT EDIT.
4 |
5 | package {%s pkg.PackageName.Snake %}
6 |
7 | import (
8 | "context"
9 | "fmt"
10 | "log"
11 | "time"
12 |
13 | "github.com/nats-io/nats.go"
14 | "google.golang.org/protobuf/proto"
15 | )
16 |
17 | {% for _,svc := range pkg.Services %}
18 | {% code clientName := svc.Name.Pascal + "NATSClient" %}
19 |
20 | type {%s clientName %} struct {
21 | nc *nats.Conn
22 | baseSubject string
23 | }
24 |
25 | func New{%s clientName %}(nc *nats.Conn, instanceID int64) (*{%s clientName %}, error) {
26 | subjectSuffix := ""
27 | if instanceID > 0 {
28 | subjectSuffix = fmt.Sprintf(".%d", instanceID)
29 | }
30 |
31 | client := &{%s clientName %}{
32 | baseSubject: "{%s svc.Subject %}" + subjectSuffix,
33 | nc: nc,
34 | }
35 | return client, nil
36 | }
37 |
38 | func New{%s clientName %}Singleton(nc *nats.Conn) (*{%s clientName %}, error) {
39 | return New{%s clientName %}(nc, 0)
40 | }
41 |
42 | func(client *{%s clientName %}) Close() error {
43 | return client.nc.Drain()
44 | }
45 |
46 | {% for _,method := range svc.Methods %}
47 | {% code cs,ss := method.IsClientStreaming, method.IsServerStreaming %}
48 | {% switch %}
49 | {% case !cs && !ss -%}
50 | {%= goClientUnaryHandler(method) -%}
51 | {% case cs && !ss -%}
52 | {%= goClientClientStreamHandler(method) -%}
53 | {% case !cs && ss -%}
54 | {%= goClientServerStreamHandler(method) -%}
55 | {% case cs && ss -%}
56 | {%= goClientBidiStreamHandler(method) -%}
57 | {% endswitch %}
58 | {% endfor %}
59 |
60 | {% endfor %}
61 | {% endfunc %}
62 |
63 | {% func goClientUnaryHandler(method *methodTmplData) %}
64 | {% code
65 | mn := method.Name.Pascal
66 | mnk := method.Name.Kebab
67 | in := method.InputType.Original
68 | out := method.OutputType.Original
69 | %}
70 | // Unary call for {%s mn %}
71 | func (c *{%s method.ServiceName.Pascal %}NATSClient) {%s mn %}(ctx context.Context, req *{%s in %}, opts ...NatsRpcOption) (*{%s out %}, error){
72 | reqBytes, err := proto.Marshal(req)
73 | if err != nil {
74 | return nil, fmt.Errorf("failed to marshal request: %w", err)
75 | }
76 |
77 | opt := NewNatsRpcOptions(opts...)
78 |
79 | msg, err := c.nc.Request(c.baseSubject + ".{%s mnk %}", reqBytes, opt.Timeout)
80 | if err != nil {
81 | return nil, fmt.Errorf("failed to send request: %w", err)
82 | }
83 |
84 | errHeader, ok := msg.Header[NatsRpcErrorHeader]
85 | if ok {
86 | return nil, fmt.Errorf("server error: %s", errHeader)
87 | }
88 |
89 | res := &{%s out %}{}
90 | if err := proto.Unmarshal(msg.Data, res); err != nil {
91 | return nil, fmt.Errorf("failed to unmarshal response: %w", err)
92 | }
93 |
94 | return res, nil
95 | }
96 | {% endfunc %}
97 |
98 | {% func goClientClientStreamHandler(method *methodTmplData) %}
99 | {% code
100 | mn := method.Name.Pascal
101 | mnk := method.Name.Kebab
102 | in := method.InputType.Original
103 | out := method.OutputType.Original
104 | %}
105 | // Client streaming call for {%s mn %}
106 | func( c *{%s method.ServiceName.Pascal %}NATSClient) {%s mn %}(ctx context.Context, reqGen func(reqCh chan<- *{%s in %}) error, opts ...NatsRpcOption) (res *{%s out %}, err error) {
107 | mailbox := nats.NewInbox()
108 |
109 | var (
110 | sub *nats.Subscription
111 | resCh = make(chan *{%s out %})
112 | opt = NewNatsRpcOptions(opts...)
113 | )
114 | sub, err = c.nc.Subscribe(mailbox, func(msg *nats.Msg) {
115 | log.Print("Got response from server")
116 | defer sub.Unsubscribe()
117 | defer close(resCh)
118 |
119 | t := time.NewTimer(opt.Timeout)
120 |
121 | select {
122 | case <-ctx.Done():
123 | err = ctx.Err()
124 | return
125 | case <-t.C:
126 | err = fmt.Errorf("timeout")
127 | return
128 | default:
129 | res = &{%s out %}{}
130 | if err = proto.Unmarshal(msg.Data, res); err != nil {
131 | res = nil
132 | err = fmt.Errorf("failed to unmarshal response: %w", err)
133 | return
134 | }
135 | resCh <- res
136 | }
137 | })
138 | if err != nil {
139 | return nil, fmt.Errorf("failed to subscribe to response: %w", err)
140 | }
141 |
142 | doneReqGen := make(chan struct{})
143 | reqCh := make(chan *{%s in %})
144 | go func() {
145 | defer func(){
146 | eofMsg := &nats.Msg{
147 | Subject: c.baseSubject + ".{%s mnk %}",
148 | Reply: mailbox,
149 | Data: nil,
150 | }
151 | c.nc.PublishMsg(eofMsg)
152 | }()
153 |
154 | if err = reqGen(reqCh); err != nil {
155 | err = fmt.Errorf("failed to generate requests: %w", err)
156 | return
157 | }
158 |
159 | <-doneReqGen
160 | }()
161 |
162 | for req := range reqCh {
163 | log.Printf("Sending request to server: %v", req)
164 | reqBytes, err := proto.Marshal(req)
165 | if err != nil {
166 | return nil, fmt.Errorf("failed to marshal request: %w", err)
167 | }
168 | msg := &nats.Msg{
169 | Subject: c.baseSubject + ".{%s mnk %}",
170 | Reply: mailbox,
171 | Data: reqBytes,
172 | }
173 | if err = c.nc.PublishMsg(msg); err != nil {
174 | return nil, fmt.Errorf("failed to send request: %w", err)
175 | }
176 | }
177 | doneReqGen <- struct{}{}
178 |
179 | res = <-resCh
180 | return
181 | }
182 | {% endfunc %}
183 |
184 | {% func goClientServerStreamHandler(method *methodTmplData) %}
185 | {% code
186 | mn := method.Name.Pascal
187 | mnk := method.Name.Kebab
188 | in := method.InputType.Original
189 | out := method.OutputType.Original
190 | %}
191 | // Server streaming call for {%s mn %}
192 | func( c *{%s method.ServiceName.Pascal %}NATSClient) {%s mn %}(ctx context.Context, req *{%s in %}, onRes func(res *{%s out %}) error, opt ...NatsRpcOption) ( error) {
193 | reqBytes, err := proto.Marshal(req)
194 | if err != nil {
195 | return fmt.Errorf("failed to marshal request: %w", err)
196 | }
197 |
198 | mailbox := nats.NewInbox()
199 |
200 | ch := make(chan *nats.Msg)
201 | defer close(ch)
202 |
203 | sub, err := c.nc.ChanSubscribe(mailbox, ch)
204 | if err != nil {
205 | return fmt.Errorf("failed to subscribe to response: %w", err)
206 | }
207 | defer sub.Unsubscribe()
208 |
209 | go func() error{
210 | msg := &nats.Msg{
211 | Subject: c.baseSubject + ".{%s mnk %}",
212 | Reply: mailbox,
213 | Data: reqBytes,
214 | }
215 | if err = c.nc.PublishMsg(msg); err != nil {
216 | return fmt.Errorf("failed to send request: %w", err)
217 | }
218 | return nil
219 | }()
220 |
221 | for {
222 | select {
223 | case <-ctx.Done():
224 | return ctx.Err()
225 | case msg := <-ch:
226 | if len(msg.Data) == 0 {
227 | return nil
228 | }
229 |
230 | res := &{%s out %}{}
231 | if err := proto.Unmarshal(msg.Data, res); err != nil {
232 | return fmt.Errorf("failed to unmarshal response: %w", err)
233 | }
234 | if err := onRes(res); err != nil {
235 | return fmt.Errorf("failed to handle response: %w", err)
236 | }
237 | }
238 | }
239 | }
240 | {% endfunc %}
241 |
242 | {% func goClientBidiStreamHandler( method *methodTmplData) %}
243 | {% code
244 | mn := method.Name.Pascal
245 | mnk := method.Name.Kebab
246 | in := method.InputType.Original
247 | out := method.OutputType.Original
248 | %}
249 | type Bidirectional{%s mn %}Func func(ctx context.Context, reqCh chan<- *{%s in %}, resCh <-chan *{%s out %}) error
250 | // Bidi streaming call for {%s mn %}
251 | func(c *{%s method.ServiceName.Pascal %}NATSClient) {%s mn %}(biDirectionalFunc Bidirectional{%s mn %}Func) error {
252 | var (
253 | mailbox = nats.NewInbox()
254 | serverResSub *nats.Subscription
255 | errCh = make(chan error)
256 | reqCh = make(chan *{%s in %})
257 | resCh = make(chan *{%s out %})
258 | doneCh = make(chan struct{})
259 | )
260 | defer close(resCh)
261 | defer close(doneCh)
262 | defer close(errCh)
263 |
264 | // Handle server responses
265 | serverResSub, err := c.nc.Subscribe(mailbox, func(msg *nats.Msg) {
266 | log.Print("Got response from server")
267 |
268 | if len(msg.Data) == 0 {
269 | doneCh <- struct{}{}
270 | return
271 | }
272 |
273 | res := &{%s out %}{}
274 | if err := proto.Unmarshal(msg.Data, res); err != nil {
275 | errCh <- fmt.Errorf("failed to unmarshal response: %w", err)
276 | return
277 | }
278 | resCh <- res
279 | })
280 | if err != nil {
281 | return fmt.Errorf("failed to subscribe to response: %w", err)
282 | }
283 | defer serverResSub.Unsubscribe()
284 |
285 | ctx := context.Background()
286 |
287 | // Start user defined bidirectional handler
288 | go func() {
289 | if err := biDirectionalFunc(ctx, reqCh, resCh); err != nil {
290 | errCh <- fmt.Errorf("failed to handle bidi stream: %w", err)
291 | }
292 | }()
293 |
294 | // Take requests from user defined handler and send them to server
295 | go func() {
296 | for req := range reqCh {
297 | log.Printf("Sending request to server: %v", req)
298 | reqBytes, err := proto.Marshal(req)
299 | if err != nil {
300 | errCh <- fmt.Errorf("failed to marshal request: %w", err)
301 | return
302 | }
303 | msg := &nats.Msg{
304 | Subject: c.baseSubject + ".{%s mnk %}",
305 | Reply: mailbox,
306 | Data: reqBytes,
307 | }
308 | if err = c.nc.PublishMsg(msg); err != nil {
309 | errCh <- fmt.Errorf("failed to send request: %w", err)
310 | return
311 | }
312 | }
313 | doneCh <- struct{}{}
314 | }()
315 |
316 | // Wait for context cancellation, error from user defined handler or EOF from server
317 | for {
318 | select {
319 | case <-ctx.Done():
320 | return ctx.Err()
321 | case err := <-errCh:
322 | if err != nil {
323 | return fmt.Errorf("failed to handle bidi stream: %w", err)
324 | }
325 | return nil
326 | case <-doneCh:
327 | return nil
328 | }
329 | }
330 | }
331 |
332 | {% endfunc %}
333 |
--------------------------------------------------------------------------------
/jtd/jtd_test.go:
--------------------------------------------------------------------------------
1 | package jtd
2 |
3 | import (
4 | "testing"
5 | )
6 |
7 | func TestSchemaValidation(t *testing.T) {
8 | tests := []struct {
9 | name string
10 | schema Schema
11 | wantErr bool
12 | }{
13 | {
14 | name: "valid type schema",
15 | schema: Schema{
16 | Type: TypeString,
17 | },
18 | wantErr: false,
19 | },
20 | {
21 | name: "valid enum schema",
22 | schema: Schema{
23 | Enum: []string{"foo", "bar", "baz"},
24 | },
25 | wantErr: false,
26 | },
27 | {
28 | name: "invalid empty enum",
29 | schema: Schema{
30 | Enum: []string{},
31 | },
32 | wantErr: true,
33 | },
34 | {
35 | name: "invalid duplicate enum",
36 | schema: Schema{
37 | Enum: []string{"foo", "bar", "foo"},
38 | },
39 | wantErr: true,
40 | },
41 | {
42 | name: "valid properties schema",
43 | schema: Schema{
44 | Properties: map[string]*Schema{
45 | "name": {Type: TypeString},
46 | "age": {Type: TypeInt32},
47 | },
48 | },
49 | wantErr: false,
50 | },
51 | {
52 | name: "invalid overlapping properties",
53 | schema: Schema{
54 | Properties: map[string]*Schema{
55 | "name": {Type: TypeString},
56 | },
57 | OptionalProperties: map[string]*Schema{
58 | "name": {Type: TypeString},
59 | },
60 | },
61 | wantErr: true,
62 | },
63 | {
64 | name: "valid discriminator schema",
65 | schema: Schema{
66 | Discriminator: "type",
67 | Mapping: map[string]*Schema{
68 | "user": {
69 | Properties: map[string]*Schema{
70 | "name": {Type: TypeString},
71 | },
72 | },
73 | "admin": {
74 | Properties: map[string]*Schema{
75 | "name": {Type: TypeString},
76 | "level": {Type: TypeInt32},
77 | },
78 | },
79 | },
80 | },
81 | wantErr: false,
82 | },
83 | {
84 | name: "invalid discriminator with non-properties mapping",
85 | schema: Schema{
86 | Discriminator: "type",
87 | Mapping: map[string]*Schema{
88 | "user": {Type: TypeString},
89 | },
90 | },
91 | wantErr: true,
92 | },
93 | {
94 | name: "invalid multiple forms",
95 | schema: Schema{
96 | Type: TypeString,
97 | Enum: []string{"foo", "bar"},
98 | },
99 | wantErr: true,
100 | },
101 | }
102 |
103 | for _, tt := range tests {
104 | t.Run(tt.name, func(t *testing.T) {
105 | err := tt.schema.Validate()
106 | if (err != nil) != tt.wantErr {
107 | t.Errorf("Schema.Validate() error = %v, wantErr %v", err, tt.wantErr)
108 | }
109 | })
110 | }
111 | }
112 |
113 | func TestParseSchema(t *testing.T) {
114 | tests := []struct {
115 | name string
116 | json string
117 | wantErr bool
118 | }{
119 | {
120 | name: "simple type schema",
121 | json: `{"type": "string"}`,
122 | },
123 | {
124 | name: "nullable type",
125 | json: `{"type": "string", "nullable": true}`,
126 | },
127 | {
128 | name: "enum schema",
129 | json: `{"enum": ["red", "green", "blue"]}`,
130 | },
131 | {
132 | name: "array schema",
133 | json: `{"elements": {"type": "string"}}`,
134 | },
135 | {
136 | name: "object schema",
137 | json: `{
138 | "properties": {
139 | "name": {"type": "string"},
140 | "age": {"type": "int32"}
141 | },
142 | "optionalProperties": {
143 | "bio": {"type": "string"}
144 | }
145 | }`,
146 | },
147 | {
148 | name: "map schema",
149 | json: `{"values": {"type": "float64"}}`,
150 | },
151 | {
152 | name: "discriminator schema",
153 | json: `{
154 | "discriminator": "type",
155 | "mapping": {
156 | "email": {
157 | "properties": {
158 | "to": {"type": "string"}
159 | }
160 | }
161 | }
162 | }`,
163 | },
164 | {
165 | name: "schema with definitions",
166 | json: `{
167 | "definitions": {
168 | "user": {
169 | "properties": {
170 | "id": {"type": "string"}
171 | }
172 | }
173 | },
174 | "ref": "user"
175 | }`,
176 | },
177 | {
178 | name: "invalid JSON",
179 | json: `{invalid}`,
180 | wantErr: true,
181 | },
182 | {
183 | name: "invalid schema",
184 | json: `{"type": "invalid"}`,
185 | wantErr: true,
186 | },
187 | }
188 |
189 | for _, tt := range tests {
190 | t.Run(tt.name, func(t *testing.T) {
191 | _, err := ParseSchema([]byte(tt.json))
192 | if (err != nil) != tt.wantErr {
193 | t.Errorf("ParseSchema() error = %v, wantErr %v", err, tt.wantErr)
194 | }
195 | })
196 | }
197 | }
198 |
199 | func TestTypeToGoType(t *testing.T) {
200 | tests := []struct {
201 | typ Type
202 | want string
203 | }{
204 | {TypeBoolean, "bool"},
205 | {TypeString, "string"},
206 | {TypeTimestamp, "time.Time"},
207 | {TypeFloat32, "float32"},
208 | {TypeFloat64, "float64"},
209 | {TypeInt8, "int8"},
210 | {TypeInt16, "int16"},
211 | {TypeInt32, "int32"},
212 | {TypeUint8, "uint8"},
213 | {TypeUint16, "uint16"},
214 | {TypeUint32, "uint32"},
215 | {Type("unknown"), "any"},
216 | }
217 |
218 | for _, tt := range tests {
219 | t.Run(string(tt.typ), func(t *testing.T) {
220 | if got := tt.typ.ToGoType(); got != tt.want {
221 | t.Errorf("Type.ToGoType() = %v, want %v", got, tt.want)
222 | }
223 | })
224 | }
225 | }
226 |
227 | func TestJSONValidation(t *testing.T) {
228 | parser := NewParser()
229 |
230 | schemaJSON := `{
231 | "properties": {
232 | "name": {"type": "string"},
233 | "age": {"type": "int32"}
234 | }
235 | }`
236 |
237 | schema, err := parser.Parse([]byte(schemaJSON))
238 | if err != nil {
239 | t.Fatalf("Failed to parse schema: %v", err)
240 | }
241 |
242 | tests := []struct {
243 | name string
244 | json string
245 | wantErr bool
246 | }{
247 | {
248 | name: "valid object",
249 | json: `{"name": "John", "age": 30}`,
250 | wantErr: false,
251 | },
252 | {
253 | name: "missing required property",
254 | json: `{"name": "John"}`,
255 | wantErr: true,
256 | },
257 | {
258 | name: "wrong type",
259 | json: `{"name": "John", "age": "thirty"}`,
260 | wantErr: true,
261 | },
262 | {
263 | name: "extra property",
264 | json: `{"name": "John", "age": 30, "extra": true}`,
265 | wantErr: true,
266 | },
267 | }
268 |
269 | for _, tt := range tests {
270 | t.Run(tt.name, func(t *testing.T) {
271 | err := parser.ValidateJSON([]byte(tt.json), schema)
272 | if (err != nil) != tt.wantErr {
273 | t.Errorf("Parser.ValidateJSON() error = %v, wantErr %v", err, tt.wantErr)
274 | }
275 | })
276 | }
277 | }
278 |
279 | func TestSchemaForm(t *testing.T) {
280 | tests := []struct {
281 | name string
282 | schema Schema
283 | want string
284 | }{
285 | {
286 | name: "empty schema",
287 | schema: Schema{},
288 | want: "empty",
289 | },
290 | {
291 | name: "type form",
292 | schema: Schema{Type: TypeString},
293 | want: "type",
294 | },
295 | {
296 | name: "enum form",
297 | schema: Schema{Enum: []string{"a", "b"}},
298 | want: "enum",
299 | },
300 | {
301 | name: "elements form",
302 | schema: Schema{Elements: &Schema{Type: TypeString}},
303 | want: "elements",
304 | },
305 | {
306 | name: "properties form",
307 | schema: Schema{Properties: map[string]*Schema{"x": {Type: TypeString}}},
308 | want: "properties",
309 | },
310 | {
311 | name: "values form",
312 | schema: Schema{Values: &Schema{Type: TypeString}},
313 | want: "values",
314 | },
315 | {
316 | name: "discriminator form",
317 | schema: Schema{Discriminator: "type", Mapping: map[string]*Schema{}},
318 | want: "discriminator",
319 | },
320 | {
321 | name: "ref form",
322 | schema: Schema{Ref: "foo"},
323 | want: "ref",
324 | },
325 | {
326 | name: "invalid multiple forms",
327 | schema: Schema{Type: TypeString, Ref: "foo"},
328 | want: "invalid",
329 | },
330 | }
331 |
332 | for _, tt := range tests {
333 | t.Run(tt.name, func(t *testing.T) {
334 | if got := tt.schema.Form(); got != tt.want {
335 | t.Errorf("Schema.Form() = %v, want %v", got, tt.want)
336 | }
337 | })
338 | }
339 | }
340 |
341 | func TestGeneratorIntegration(t *testing.T) {
342 | schemaJSON := `{
343 | "definitions": {
344 | "user": {
345 | "properties": {
346 | "id": {"type": "string"},
347 | "name": {"type": "string"},
348 | "age": {"type": "int32", "nullable": true}
349 | },
350 | "optionalProperties": {
351 | "bio": {"type": "string"}
352 | }
353 | },
354 | "role": {
355 | "enum": ["admin", "user", "guest"]
356 | }
357 | }
358 | }`
359 |
360 | parser := NewParser()
361 | _, err := parser.Parse([]byte(schemaJSON))
362 | if err != nil {
363 | t.Fatalf("Failed to parse schema: %v", err)
364 | }
365 |
366 | generator := NewGenerator(parser, GeneratorOptions{
367 | PackageName: "test",
368 | })
369 |
370 | code, err := generator.Generate()
371 | if err != nil {
372 | t.Fatalf("Failed to generate code: %v", err)
373 | }
374 |
375 | // Basic smoke test - check that code was generated
376 | if len(code) == 0 {
377 | t.Error("Generated code is empty")
378 | }
379 |
380 | // Check that it contains expected content
381 | codeStr := string(code)
382 | expectedContent := []string{
383 | "package test",
384 | "type User struct",
385 | "type Role string",
386 | "RoleAdmin",
387 | "RoleUser",
388 | "RoleGuest",
389 | }
390 |
391 | for _, expected := range expectedContent {
392 | if !contains(codeStr, expected) {
393 | t.Errorf("Generated code does not contain expected content: %s", expected)
394 | }
395 | }
396 | }
397 |
398 | func contains(s, substr string) bool {
399 | return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsHelper(s, substr))
400 | }
401 |
402 | func containsHelper(s, substr string) bool {
403 | for i := 0; i <= len(s)-len(substr); i++ {
404 | if s[i:i+len(substr)] == substr {
405 | return true
406 | }
407 | }
408 | return false
409 | }
410 |
--------------------------------------------------------------------------------
/sqlc-gen-zombiezen/zombiezen/crud.qtpl:
--------------------------------------------------------------------------------
1 | {% func GenerateCRUD(t *GenerateCRUDTable) %}
2 | // Code generated by "sqlc-gen-zombiezen". DO NOT EDIT.
3 |
4 | package {%s t.PackageName.Lower %}
5 |
6 | import (
7 | "fmt"
8 | {% if t.NeedsTimePackage %}"time"{% endif %}
9 | "zombiezen.com/go/sqlite"
10 | {% if t.NeedsToolbelt %}"github.com/delaneyj/toolbelt"{% endif %}
11 | )
12 |
13 | type {%s t.SingleName.Pascal %}Model struct {
14 | {%- for _,f := range t.Fields -%}
15 | {%s f.Name.Pascal %} {% if f.IsNullable %}*{% endif %}{%s f.GoType.Original %} `json:"{%s f.Name.Lower %}"`
16 | {%- endfor -%}
17 | }
18 |
19 |
20 | type Create{%s t.SingleName.Pascal %}Stmt struct {
21 | stmt *sqlite.Stmt
22 | }
23 |
24 | func Create{%s t.SingleName.Pascal %}(tx *sqlite.Conn) *Create{%s t.SingleName.Pascal %}Stmt {
25 | stmt := tx.Prep(`
26 | INSERT INTO {%s t.Name.Lower %} (
27 | {%- for i,f := range t.Fields -%}
28 | {%s f.Name.Lower %}{% if i < len(t.Fields) - 1 %},{% endif %}
29 | {%- endfor -%}
30 | ) VALUES (
31 | {%- for i := range t.Fields -%}
32 | ?{% if i < len(t.Fields) - 1 %},{% endif %}
33 | {%- endfor -%}
34 | )
35 | `)
36 | return &Create{%s t.SingleName.Pascal %}Stmt{stmt: stmt}
37 | }
38 |
39 | func (ps *Create{%s t.SingleName.Pascal %}Stmt) Run(m *{%s t.SingleName.Pascal %}Model) error {
40 | defer ps.stmt.Reset()
41 |
42 | // Bind parameters
43 | {%= bindFields(t) %}
44 |
45 | if _, err := ps.stmt.Step(); err != nil {
46 | return fmt.Errorf("failed to insert {%s t.Name.Lower %}: %w", err)
47 | }
48 |
49 | return nil
50 | }
51 |
52 | func OnceCreate{%s t.SingleName.Pascal %}(tx *sqlite.Conn, m *{%s t.SingleName.Pascal %}Model) error {
53 | ps := Create{%s t.SingleName.Pascal %}(tx)
54 | return ps.Run(m)
55 | }
56 |
57 | {%- if t.HasID -%}
58 | type Upsert{%s t.SingleName.Pascal %}Stmt struct {
59 | stmt *sqlite.Stmt
60 | }
61 |
62 | func Upsert{%s t.SingleName.Pascal %}(tx *sqlite.Conn) *Upsert{%s t.SingleName.Pascal %}Stmt {
63 | stmt := tx.Prep(`
64 | INSERT INTO {%s t.Name.Lower %} (
65 | {%- for i,f := range t.Fields -%}
66 | {%s f.Name.Lower %}{% if i < len(t.Fields) - 1 %},{% endif %}
67 | {%- endfor -%}
68 | ) VALUES (
69 | {%- for i := range t.Fields -%}
70 | ?{% if i < len(t.Fields) - 1 %},{% endif %}
71 | {%- endfor -%}
72 | )
73 | ON CONFLICT(id) DO UPDATE SET
74 | {%- for i,f := range t.Fields -%}
75 | {%- if i > 0 -%}
76 | {%s f.Name.Lower %} = excluded.{%s f.Name.Lower %}{% if i < len(t.Fields) - 1 %},{% endif %}
77 | {%- endif -%}
78 | {%- endfor -%}
79 | `)
80 | return &Upsert{%s t.SingleName.Pascal %}Stmt{stmt: stmt}
81 | }
82 |
83 | func (ps *Upsert{%s t.SingleName.Pascal %}Stmt) Run(m *{%s t.SingleName.Pascal %}Model) error {
84 | defer ps.stmt.Reset()
85 |
86 | // Bind parameters
87 | {%= bindFields(t) %}
88 |
89 | if _, err := ps.stmt.Step(); err != nil {
90 | return fmt.Errorf("failed to upsert {%s t.Name.Lower %}: %w", err)
91 | }
92 |
93 | return nil
94 | }
95 |
96 | func OnceUpsert{%s t.SingleName.Pascal %}(tx *sqlite.Conn, m *{%s t.SingleName.Pascal %}Model) error {
97 | ps := Upsert{%s t.SingleName.Pascal %}(tx)
98 | return ps.Run(m)
99 | }
100 | {%- endif -%}
101 |
102 | type ReadAll{%s t.Name.Pascal %}Stmt struct {
103 | stmt *sqlite.Stmt
104 | }
105 |
106 | func ReadAll{%s t.Name.Pascal %}(tx *sqlite.Conn) *ReadAll{%s t.Name.Pascal %}Stmt {
107 | stmt := tx.Prep(`
108 | SELECT
109 | {%- for i,f := range t.Fields -%}
110 | {%s f.Name.Lower %}{% if i < len(t.Fields) - 1 %},{% endif %}
111 | {%- endfor -%}
112 | FROM {%s t.Name.Lower %}
113 | `)
114 | return &ReadAll{%s t.Name.Pascal %}Stmt{stmt: stmt}
115 | }
116 |
117 | func (ps *ReadAll{%s t.Name.Pascal %}Stmt) Run() ([]*{%s t.SingleName.Pascal %}Model, error) {
118 | defer ps.stmt.Reset()
119 |
120 | var models []*{%s t.SingleName.Pascal %}Model
121 | for {
122 | hasRow, err := ps.stmt.Step()
123 | if err != nil {
124 | return nil, fmt.Errorf("failed to read {%s t.Name.Lower %}: %w", err)
125 | } else if !hasRow {
126 | break
127 | }
128 |
129 | m := &{%s t.SingleName.Pascal %}Model{}
130 | {%= fillResStruct(t) %}
131 |
132 | models = append(models, m)
133 | }
134 |
135 | return models, nil
136 | }
137 |
138 | func OnceReadAll{%s t.Name.Pascal %}(tx *sqlite.Conn) ([]*{%s t.SingleName.Pascal %}Model, error) {
139 | ps := ReadAll{%s t.Name.Pascal %}(tx)
140 | return ps.Run()
141 | }
142 |
143 | {%- if t.HasID -%}
144 | type ReadByID{%s t.SingleName.Pascal %}Stmt struct {
145 | stmt *sqlite.Stmt
146 | }
147 |
148 | func ReadByID{%s t.SingleName.Pascal %}(tx *sqlite.Conn) *ReadByID{%s t.SingleName.Pascal %}Stmt {
149 | stmt := tx.Prep(`
150 | SELECT
151 | {%- for i,f := range t.Fields -%}
152 | {%s f.Name.Lower %}{% if i < len(t.Fields) - 1 %},{% endif %}
153 | {%- endfor -%}
154 | FROM {%s t.Name.Lower %}
155 | WHERE id = ?
156 | `)
157 | return &ReadByID{%s t.SingleName.Pascal %}Stmt{stmt: stmt}
158 | }
159 |
160 | func (ps *ReadByID{%s t.SingleName.Pascal %}Stmt) Run(id int64) (*{%s t.SingleName.Pascal %}Model, error) {
161 | defer ps.stmt.Reset()
162 |
163 | ps.stmt.BindInt64(1, id)
164 |
165 | if hasRow, err := ps.stmt.Step(); err != nil {
166 | return nil, fmt.Errorf("failed to read {%s t.Name.Lower %}: %w", err)
167 | } else if !hasRow {
168 | return nil, nil
169 | }
170 |
171 | m := &{%s t.SingleName.Pascal %}Model{}
172 | {%= fillResStruct(t) %}
173 |
174 | return m, nil
175 | }
176 |
177 | func OnceReadByID{%s t.SingleName.Pascal %}(tx *sqlite.Conn, id int64) (*{%s t.SingleName.Pascal %}Model, error) {
178 | ps := ReadByID{%s t.SingleName.Pascal %}(tx)
179 | return ps.Run(id)
180 | }
181 | {%- endif -%}
182 |
183 | func Count{%s t.Name.Pascal %}(tx *sqlite.Conn) (int64, error) {
184 | stmt := tx.Prep(`
185 | SELECT COUNT(*)
186 | FROM {%s t.Name.Lower %}
187 | `)
188 | defer stmt.Reset()
189 |
190 | if hasRow, err := stmt.Step(); err != nil {
191 | return 0, fmt.Errorf("failed to count {%s t.Name.Lower %}: %w", err)
192 | } else if !hasRow {
193 | return 0, nil
194 | }
195 |
196 | return stmt.ColumnInt64(0), nil
197 | }
198 |
199 | func OnceCount{%s t.Name.Pascal %}(tx *sqlite.Conn) (int64, error) {
200 | return Count{%s t.Name.Pascal %}(tx)
201 | }
202 |
203 | type Update{%s t.SingleName.Pascal %}Stmt struct {
204 | stmt *sqlite.Stmt
205 | }
206 |
207 | func Update{%s t.SingleName.Pascal %}(tx *sqlite.Conn) *Update{%s t.SingleName.Pascal %}Stmt {
208 | stmt := tx.Prep(`
209 | UPDATE {%s t.Name.Lower %}
210 | SET
211 | {%- for i,f := range t.Fields -%}
212 | {%- if i > 0 -%}
213 | {%s f.Name.Lower %} = ?{%d i +1 %}{% if i < len(t.Fields) - 1 %},{% endif %}
214 | {%- endif -%}
215 | {%- endfor -%}
216 | WHERE id = ?1
217 | `)
218 | return &Update{%s t.SingleName.Pascal %}Stmt{stmt: stmt}
219 | }
220 |
221 | func (ps *Update{%s t.SingleName.Pascal %}Stmt) Run(m *{%s t.SingleName.Pascal %}Model) error {
222 | defer ps.stmt.Reset()
223 |
224 | // Bind parameters
225 | {%= bindFields(t) %}
226 |
227 | if _, err := ps.stmt.Step(); err != nil {
228 | return fmt.Errorf("failed to update {%s t.Name.Lower %}: %w", err)
229 | }
230 |
231 | return nil
232 | }
233 |
234 | func OnceUpdate{%s t.SingleName.Pascal %}(tx *sqlite.Conn, m *{%s t.SingleName.Pascal %}Model) error {
235 | ps := Update{%s t.SingleName.Pascal %}(tx)
236 | return ps.Run(m)
237 | }
238 |
239 | type Delete{%s t.SingleName.Pascal %}Stmt struct {
240 | stmt *sqlite.Stmt
241 | }
242 |
243 | func Delete{%s t.SingleName.Pascal %}(tx *sqlite.Conn) *Delete{%s t.SingleName.Pascal %}Stmt {
244 | stmt := tx.Prep(`
245 | DELETE FROM {%s t.Name.Lower %}
246 | WHERE id = ?
247 | `)
248 | return &Delete{%s t.SingleName.Pascal %}Stmt{stmt: stmt}
249 | }
250 |
251 | func (ps *Delete{%s t.SingleName.Pascal %}Stmt) Run(id int64) error {
252 | defer ps.stmt.Reset()
253 |
254 | ps.stmt.BindInt64(1, id)
255 |
256 | if _, err := ps.stmt.Step(); err != nil {
257 | return fmt.Errorf("failed to delete {%s t.Name.Lower %}: %w", err)
258 | }
259 |
260 | return nil
261 | }
262 |
263 | func OnceDelete{%s t.SingleName.Pascal %}(tx *sqlite.Conn, id int64) error {
264 | ps := Delete{%s t.SingleName.Pascal %}(tx)
265 | return ps.Run(id)
266 | }
267 |
268 | {% endfunc %}
269 |
270 | {%- func bindFields( tbl *GenerateCRUDTable) -%}
271 | {%- for _, f := range tbl.Fields -%}
272 | {%- if f.IsNullable -%}
273 | if m.{%s f.Name.Pascal %} == nil {
274 | ps.stmt.BindNull({%d f.Column %})
275 | } else {
276 | {%= bindField(f, true) -%}
277 | }
278 | {%- else -%}
279 | {%= bindField(f,false) %}
280 | {%- endif -%}
281 | {%- endfor -%}
282 | {%- endfunc -%}
283 |
284 | {%- func bindField(f GenerateField, isNullable bool) -%}
285 | ps.{%- switch f.GoType.Original -%}
286 | {%- case "time.Time" -%}
287 | stmt.Bind{%s f.SQLType.Pascal %}({%d f.Column %}, toolbelt.TimeToJulianDay({% if isNullable %}*{% endif %} m.{%s f.Name.Pascal %}))
288 | {%- case "time.Duration" -%}
289 | {%- if f.DurationFromText -%}
290 | stmt.BindText({%d f.Column %}, {% if isNullable %}(*{% endif %}m.{%s f.Name.Pascal %}{% if isNullable %}){% endif %}.String())
291 | {%- else -%}
292 | stmt.Bind{%s f.SQLType.Pascal %}({%d f.Column %}, toolbelt.DurationToMilliseconds({% if isNullable %}*{% endif %}m.{%s f.Name.Pascal %}))
293 | {%- endif -%}
294 | {%- default -%}
295 | stmt.Bind{%s f.SQLType.Pascal %}({%d f.Column %}, {% if isNullable %}*{% endif %}m.{%s f.Name.Pascal %})
296 | {%- endswitch -%}
297 | {%- endfunc -%}
298 |
299 | {%- func fillResStruct(t *GenerateCRUDTable) -%}
300 | {%- for i,f := range t.Fields -%}
301 | {%- if f.IsNullable %}
302 | if ps.stmt.ColumnIsNull({%d i %}) {
303 | m.{%s f.Name.Pascal %} = nil
304 | } else {
305 | {%- if f.GoType.Original == "time.Duration" && f.DurationFromText %}
306 | durStr := ps.stmt.ColumnText({%d i %})
307 | dur, err := time.ParseDuration(durStr)
308 | if err != nil {
309 | return nil, fmt.Errorf("parsing {%s t.Name.Lower %}.{%s f.Name.Lower %}: %w", err)
310 | }
311 | m.{%s f.Name.Pascal %} = &dur
312 | {%- else %}
313 | tmp := {%= fillResStructField(f, i) %}
314 | m.{%s f.Name.Pascal %} = &tmp
315 | {%- endif %}
316 | }
317 | {%- else %}
318 | {%- if f.GoType.Original == "time.Duration" && f.DurationFromText %}
319 | durStr := ps.stmt.ColumnText({%d i %})
320 | dur, err := time.ParseDuration(durStr)
321 | if err != nil {
322 | return nil, fmt.Errorf("parsing {%s t.Name.Lower %}.{%s f.Name.Lower %}: %w", err)
323 | }
324 | m.{%s f.Name.Pascal %} = dur
325 | {%- else %}
326 | m.{%s f.Name.Pascal %} = {%= fillResStructField(f,i) %}
327 | {%- endif %}
328 | {%- endif %}
329 | {%- endfor -%}
330 | {%- endfunc -%}
331 |
332 | {%- func fillResStructField(f GenerateField, i int) -%}
333 | {%- switch f.GoType.Original -%}
334 | {%- case "time.Time" -%}
335 | toolbelt.JulianDayToTime(ps.stmt.Column{%s f.SQLType.Pascal %}({%d i %}))
336 | {%- case "time.Duration" -%}
337 | toolbelt.MillisecondsToDuration(ps.stmt.Column{%s f.SQLType.Pascal %}({%d i %}))
338 | {%- case "[]byte" -%}
339 | toolbelt.StmtBytesByCol(ps.stmt, {%d i %})
340 | {%- default -%}
341 | ps.stmt.Column{%s f.SQLType.Pascal %}({%d i %})
342 | {%- endswitch -%}
343 | {%- endfunc -%}
344 |
--------------------------------------------------------------------------------