├── 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 | wisshes mascot 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 | wisshes mascot 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 | --------------------------------------------------------------------------------