From d4e021386bb6d45dc757e8319ff7c98f538632bd Mon Sep 17 00:00:00 2001 From: xuu Date: Mon, 22 Jan 2024 16:00:58 -0700 Subject: [PATCH 1/4] chore: add mercury --- .gitignore | 3 + .vscode/launch.json | 23 ++ cmd/testsql/main.go | 45 +++ cron/cron.go | 6 +- env/env.go | 37 +- env/secret.go | 35 ++ go.mod | 89 +++-- go.sum | 262 +++++++++---- ident/ident.go | 115 ++++++ ident/null-user.go | 75 ++++ ident/routes.go | 360 ++++++++++++++++++ ident/source/mercury.go | 169 +++++++++ lg/tracer.go | 45 ++- mercury/app/app_test.go | 157 ++++++++ mercury/app/default-rules.go | 98 +++++ mercury/app/environ.go | 288 ++++++++++++++ mercury/http/notify.go | 63 +++ mercury/mercury.go | 695 ++++++++++++++++++++++++++++++++++ mercury/mqtt/notify.go | 27 ++ mercury/namespace.go | 125 ++++++ mercury/parse.go | 110 ++++++ mercury/public/favicon.ico | Bin 0 -> 1150 bytes mercury/public/index.html | 47 +++ mercury/public/style.css | 212 +++++++++++ mercury/registry.go | 400 +++++++++++++++++++ mercury/routes.go | 267 +++++++++++++ mercury/sql/init-pg.sql | 116 ++++++ mercury/sql/init-sql3.sql | 118 ++++++ mercury/sql/list-string.go | 130 +++++++ mercury/sql/notify.go | 55 +++ mercury/sql/otel.go | 40 ++ mercury/sql/rules.go | 99 +++++ mercury/sql/sql.go | 419 ++++++++++++++++++++ mux/httpmux.go | 15 + rsql/ast.go | 255 +++++++++++++ rsql/ast_test.go | 21 + rsql/dbcolumns.go | 90 +++++ rsql/lexer.go | 258 +++++++++++++ rsql/lexer_test.go | 105 +++++ rsql/parser.go | 285 ++++++++++++++ rsql/parser_test.go | 309 +++++++++++++++ rsql/squirrel/sqlizer.go | 300 +++++++++++++++ rsql/squirrel/sqlizer_test.go | 141 +++++++ rsql/token.go | 62 +++ service/service.go | 3 +- set/set.go | 4 + xdg/xdg.go | 30 +- 47 files changed, 6456 insertions(+), 152 deletions(-) create mode 100644 .gitignore create mode 100644 .vscode/launch.json create mode 100644 cmd/testsql/main.go create mode 100644 env/secret.go create mode 100644 ident/ident.go create mode 100644 ident/null-user.go create mode 100644 ident/routes.go create mode 100644 ident/source/mercury.go create mode 100644 mercury/app/app_test.go create mode 100644 mercury/app/default-rules.go create mode 100644 mercury/app/environ.go create mode 100644 mercury/http/notify.go create mode 100644 mercury/mercury.go create mode 100644 mercury/mqtt/notify.go create mode 100644 mercury/namespace.go create mode 100644 mercury/parse.go create mode 100644 mercury/public/favicon.ico create mode 100644 mercury/public/index.html create mode 100644 mercury/public/style.css create mode 100644 mercury/registry.go create mode 100644 mercury/routes.go create mode 100644 mercury/sql/init-pg.sql create mode 100644 mercury/sql/init-sql3.sql create mode 100644 mercury/sql/list-string.go create mode 100644 mercury/sql/notify.go create mode 100644 mercury/sql/otel.go create mode 100644 mercury/sql/rules.go create mode 100644 mercury/sql/sql.go create mode 100644 rsql/ast.go create mode 100644 rsql/ast_test.go create mode 100644 rsql/dbcolumns.go create mode 100644 rsql/lexer.go create mode 100644 rsql/lexer_test.go create mode 100644 rsql/parser.go create mode 100644 rsql/parser_test.go create mode 100644 rsql/squirrel/sqlizer.go create mode 100644 rsql/squirrel/sqlizer_test.go create mode 100644 rsql/token.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9784aeb --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +test.db +*.mercury +sour.is-mercury \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..1e1cc2b --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,23 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Launch Package", + "type": "go", + "request": "launch", + "mode": "auto", + "program": "${fileDirname}", + "cwd": "${workspaceFolder}" + }, + { + "name": "Attach to Process", + "type": "go", + "request": "attach", + "mode": "local", + "processId": 0 + } + ] +} \ No newline at end of file diff --git a/cmd/testsql/main.go b/cmd/testsql/main.go new file mode 100644 index 0000000..9b21798 --- /dev/null +++ b/cmd/testsql/main.go @@ -0,0 +1,45 @@ +package main + +import ( + "database/sql" + "log" + + _ "modernc.org/sqlite" +) + +func main() { + db, err := sql.Open("sqlite", "./test.db") + if err != nil { + log.Fatal(err) + } + defer db.Close() + + _, err = db.Exec(`drop table if exists foo`) + if err != nil { + log.Fatal(err) + } + + _, err = db.Exec(`create table foo (bar jsonb)`) + if err != nil { + log.Fatal(err) + } + + _, err = db.Exec(`insert into foo (bar) values ('["one"]')`) + if err != nil { + log.Fatal(err) + } + + rows, err := db.Query(`select j.value from foo, json_each(bar) j `) + if err != nil { + log.Fatal(err) + } + for rows.Next() { + var s string + err = rows.Scan(&s) + if err != nil { + log.Fatal(err) + } + + log.Println("GOT: ", s) + } +} \ No newline at end of file diff --git a/cron/cron.go b/cron/cron.go index e912fc8..43414c4 100644 --- a/cron/cron.go +++ b/cron/cron.go @@ -127,9 +127,6 @@ func (c *cron) run(ctx context.Context, now time.Time) { case <-timer.C: } - span.AddEvent("Cron Run: " + now.Format(time.RFC822)) - // fmt.Println("Cron Run: ", now.Format(time.RFC822)) - c.state.Use(ctx, func(ctx context.Context, state *state) error { run = append(run, state.queue...) state.queue = state.queue[:0] @@ -148,6 +145,9 @@ func (c *cron) run(ctx context.Context, now time.Time) { return } + span.AddEvent("Cron Run: " + now.Format(time.RFC822)) + // fmt.Println("Cron Run: ", now.Format(time.RFC822)) + wg, _ := errgroup.WithContext(ctx) for i := range run { diff --git a/env/env.go b/env/env.go index 0f52570..460dc26 100644 --- a/env/env.go +++ b/env/env.go @@ -9,35 +9,16 @@ import ( "strings" ) -func Default(name, defaultValue string) string { +func Default(name, defaultValue string) (s string) { name = strings.TrimSpace(name) - defaultValue = strings.TrimSpace(defaultValue) - if v := strings.TrimSpace(os.Getenv(name)); v != "" { - slog.Info("env", name, v) - return v - } - slog.Info("env", name, defaultValue+" (default)") - return defaultValue -} + s = strings.TrimSpace(defaultValue) -type secret string + if v, ok := os.LookupEnv(name); ok { + s = strings.TrimSpace(v) + slog.Info("env", slog.String(name, v)) + return + } -func (s secret) String() string { - if s == "" { - return "(nil)" - } - return "***" -} -func (s secret) Secret() string { - return string(s) -} -func Secret(name, defaultValue string) secret { - name = strings.TrimSpace(name) - defaultValue = strings.TrimSpace(defaultValue) - if v := strings.TrimSpace(os.Getenv(name)); v != "" { - slog.Info("env", name, secret(v)) - return secret(v) - } - slog.Info("env", name, secret(defaultValue).String()+" (default)") - return secret(defaultValue) + slog.Info("env", slog.String(name, s+" (default)")) + return } diff --git a/env/secret.go b/env/secret.go new file mode 100644 index 0000000..ac7bc98 --- /dev/null +++ b/env/secret.go @@ -0,0 +1,35 @@ +package env + +import ( + "os" + "log/slog" + "strings" +) + +type secret string + +func (s secret) String() string { + if s == "" { + return "(nil)" + } + return "***" +} + +func (s secret) Secret() string { + return string(s) +} + +func Secret(name, defaultValue string) (s secret) { + name = strings.TrimSpace(name) + s = secret(strings.TrimSpace(defaultValue)) + + if v, ok := os.LookupEnv(name); ok { + s = secret(strings.TrimSpace(v)) + slog.Info("env", slog.String(name, s.String())) + return + } + + slog.Info("env", slog.String(name, s.String()+" (default)")) + return +} + diff --git a/go.mod b/go.mod index 8de9153..d66e264 100644 --- a/go.mod +++ b/go.mod @@ -1,19 +1,21 @@ module go.sour.is/pkg -go 1.21 +go 1.22.0 require ( - github.com/99designs/gqlgen v0.17.34 + github.com/99designs/gqlgen v0.17.44 github.com/golang-jwt/jwt/v4 v4.5.0 - github.com/gorilla/websocket v1.5.0 + github.com/gorilla/websocket v1.5.1 github.com/matryer/is v1.4.1 - github.com/ravilushqa/otelgqlgen v0.13.1 - github.com/vektah/gqlparser/v2 v2.5.6 - go.opentelemetry.io/otel v1.18.0 - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.18.0 - go.opentelemetry.io/otel/sdk/metric v0.41.0 + github.com/ravilushqa/otelgqlgen v0.15.0 + github.com/tursodatabase/go-libsql v0.0.0-20240322134723-08771dcdd2f1 + github.com/vektah/gqlparser/v2 v2.5.11 + go.opentelemetry.io/otel v1.23.1 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.23.1 + go.opentelemetry.io/otel/sdk/metric v1.23.1 + go.sour.is/passwd v0.2.0 go.uber.org/multierr v1.11.0 - golang.org/x/sync v0.7.0 + golang.org/x/sync v0.6.0 ) require ( @@ -21,39 +23,58 @@ require ( github.com/antlr/antlr4/runtime/Go/antlr/v4 v4.0.0-20230512164433-5d1fd1a340c9 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.2.0 // indirect - github.com/felixge/httpsnoop v1.0.3 // indirect - github.com/hashicorp/golang-lru/v2 v2.0.3 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect + github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect + github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect github.com/libsql/sqlite-antlr4-parser v0.0.0-20230802215326-5cb5bb604475 // indirect - github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect - github.com/prometheus/client_model v0.4.1-0.20230718164431-9a2bf3000d16 // indirect - github.com/prometheus/common v0.44.0 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/prometheus/client_model v0.5.0 // indirect + github.com/prometheus/common v0.47.0 // indirect github.com/prometheus/procfs v0.12.0 // indirect - go.opentelemetry.io/contrib v1.16.1 // indirect - golang.org/x/exp v0.0.0-20220722155223-a9213eeb770e // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20230711160842-782d3b101e98 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20230711160842-782d3b101e98 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/sosodev/duration v1.2.0 // indirect + go.opentelemetry.io/contrib v1.23.0 // indirect + google.golang.org/genproto v0.0.0-20240213162025-012b6fc9bca9 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20240213162025-012b6fc9bca9 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20240213162025-012b6fc9bca9 // indirect + modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect + modernc.org/libc v1.41.0 // indirect + modernc.org/mathutil v1.6.0 // indirect + modernc.org/memory v1.7.2 // indirect + modernc.org/strutil v1.2.0 // indirect + modernc.org/token v1.1.0 // indirect ) require ( + github.com/BurntSushi/toml v1.3.2 + github.com/Masterminds/squirrel v1.5.4 github.com/cenkalti/backoff/v4 v4.2.1 // indirect - github.com/go-logr/logr v1.2.4 // indirect + github.com/go-logr/logr v1.4.1 // indirect github.com/go-logr/stdr v1.2.2 // indirect + github.com/golang/gddo v0.0.0-20210115222349-20d68f94ee1f github.com/golang/protobuf v1.5.3 // indirect - github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0 // indirect - github.com/prometheus/client_golang v1.17.0 - github.com/tursodatabase/go-libsql v0.0.0-20240322134723-08771dcdd2f1 - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.44.0 - go.opentelemetry.io/contrib/instrumentation/runtime v0.42.0 - go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.18.0 // indirect - go.opentelemetry.io/otel/exporters/prometheus v0.41.0 - go.opentelemetry.io/otel/metric v1.18.0 - go.opentelemetry.io/otel/sdk v1.18.0 - go.opentelemetry.io/otel/trace v1.18.0 - go.opentelemetry.io/proto/otlp v1.0.0 // indirect - golang.org/x/net v0.23.0 // indirect - golang.org/x/sys v0.18.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.1 // indirect + github.com/oklog/ulid/v2 v2.1.0 + github.com/prometheus/client_golang v1.18.0 + go.nhat.io/otelsql v0.12.0 + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.48.0 + go.opentelemetry.io/contrib/instrumentation/runtime v0.48.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.23.1 // indirect + go.opentelemetry.io/otel/exporters/prometheus v0.45.2 + go.opentelemetry.io/otel/metric v1.23.1 + go.opentelemetry.io/otel/sdk v1.23.1 + go.opentelemetry.io/otel/trace v1.23.1 + go.opentelemetry.io/proto/otlp v1.1.0 // indirect + golang.org/x/exp v0.0.0-20240213143201-ec583247a57a + golang.org/x/net v0.21.0 // indirect + golang.org/x/sys v0.17.0 // indirect golang.org/x/text v0.14.0 // indirect - google.golang.org/grpc v1.58.3 // indirect - google.golang.org/protobuf v1.33.0 // indirect + google.golang.org/grpc v1.61.1 // indirect + google.golang.org/protobuf v1.32.0 // indirect + modernc.org/sqlite v1.29.1 ) diff --git a/go.sum b/go.sum index 931d656..dedc078 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,13 @@ -github.com/99designs/gqlgen v0.17.34 h1:5cS5/OKFguQt+Ws56uj9FlG2xm1IlcJWNF2jrMIKYFQ= -github.com/99designs/gqlgen v0.17.34/go.mod h1:Axcd3jIFHBVcqzixujJQr1wGqE+lGTpz6u4iZBZg1G8= +cloud.google.com/go v0.16.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +github.com/99designs/gqlgen v0.17.44 h1:OS2wLk/67Y+vXM75XHbwRnNYJcbuJd4OBL76RX3NQQA= +github.com/99designs/gqlgen v0.17.44/go.mod h1:UTCu3xpK2mLI5qcMNw+HKDiEL77it/1XtAjisC4sLwM= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8= +github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= +github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60= +github.com/DATA-DOG/go-sqlmock v1.5.0/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM= +github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM= +github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10= github.com/agnivade/levenshtein v1.1.1 h1:QY8M92nrzkmr798gCo3kmMyqXFzdQVpxLlGPRBij0P8= github.com/agnivade/levenshtein v1.1.1/go.mod h1:veldBMzWxcCG2ZvUTKD2kJNRdCk5hVbJomOvKkmgYbo= github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ= @@ -10,6 +18,9 @@ github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bool64/shared v0.1.5 h1:fp3eUhBsrSjNCQPcSdQqZxxh9bBwrYiZ+zOKFkM0/2E= +github.com/bool64/shared v0.1.5/go.mod h1:081yz68YC9jeFB3+Bbmno2RFWvGKv1lPKkMP6MHJlPs= +github.com/bradfitz/gomemcache v0.0.0-20170208213004-1952afaa557d/go.mod h1:PmM6Mmwb0LSuEubjR8N7PtNe1KxZLtOUHtbeikc5h60= github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM= github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= @@ -19,125 +30,212 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgryski/trifles v0.0.0-20200323201526-dd97f9abfb48 h1:fRzb/w+pyskVMQ+UbP35JkH8yB7MYb4q/qhBarqZE6g= github.com/dgryski/trifles v0.0.0-20200323201526-dd97f9abfb48/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA= -github.com/felixge/httpsnoop v1.0.3 h1:s/nj+GCswXYzN5v2DpNMuMQYe+0DDwt5WVCU6CWBdXk= -github.com/felixge/httpsnoop v1.0.3/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/fsnotify/fsnotify v1.4.3-0.20170329110642-4da3e2cfbabc/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/garyburd/redigo v1.1.1-0.20170914051019-70e1b1943d4f/go.mod h1:NR3MbYisc3/PwhQ00EMzDiPmrwpPxAn5GI05/YaO1SY= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.2.4 h1:g01GSCwiDw2xSZfjJ2/T9M+S6pFdcNtFYsp+Y43HYDQ= -github.com/go-logr/logr v1.2.4/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ= +github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-stack/stack v1.6.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg= github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= -github.com/golang/glog v1.1.0 h1:/d3pCKDPWNnvIWe0vVUpNP32qc8U3PDVxySP/y360qE= -github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/gddo v0.0.0-20210115222349-20d68f94ee1f h1:16RtHeWGkJMc80Etb8RPCcKevXGldr57+LOyZt8zOlg= +github.com/golang/gddo v0.0.0-20210115222349-20d68f94ee1f/go.mod h1:ijRvpgDJDI262hYq/IQVYgf8hd8IHUs93Ol0kvMBAx4= +github.com/golang/lint v0.0.0-20170918230701-e5d664eb928e/go.mod h1:tluoj9z5200jBnyusfRPU2LqT6J+DAorxEvtC7LHB+E= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golang/snappy v0.0.0-20170215233205-553a64147049/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/go-cmp v0.1.1-0.20171103154506-982329095285/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= -github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= -github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0 h1:YBftPWNWd4WwGqtY2yeZL2ef8rHAxPBD8KFhJpmcqms= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0/go.mod h1:YN5jB8ie0yfIUg6VvR9Kz84aCaG7AsGZnLjhHbUqwPg= -github.com/hashicorp/golang-lru/v2 v2.0.3 h1:kmRrRLlInXvng0SmLxmQpQkpbYAvcXm7NPDrgxJa9mE= -github.com/hashicorp/golang-lru/v2 v2.0.3/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= -github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= +github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/gax-go v2.0.0+incompatible/go.mod h1:SFVmujtThgffbyetf+mdk2eWhX2bMyUtNHzFKcPA9HY= +github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY= +github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY= +github.com/gregjones/httpcache v0.0.0-20170920190843-316c5e0ff04e/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.1 h1:/c3QmbOGMGTOumP2iT/rCwB7b0QDGLKzqOmktBjT+Is= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.1/go.mod h1:5SN9VR2LTsRFsrEC6FHgRbTWrTHu6tqPeKxEQv15giM= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/hashicorp/hcl v0.0.0-20170914154624-68e816d1c783/go.mod h1:oZtUIOe8dh44I2q6ScRibXws4Ajl+d+nod3AaR9vL5w= +github.com/iancoleman/orderedmap v0.3.0 h1:5cbR2grmZR/DiVt+VJopEhtVs9YGInGIxAoMJn+Ichc= +github.com/iancoleman/orderedmap v0.3.0/go.mod h1:XuLcCUkdL5owUCQeF2Ue9uuw1EptkJDkXXS7VoV7XGE= +github.com/inconshreveable/log15 v0.0.0-20170622235902-74a0988b5f80/go.mod h1:cOaXtrgN4ScfRrD9Bre7U1thNq5RtJ8ZoP4iXVGRj6o= +github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 h1:SOEGU9fKiNWd/HOJuq6+3iTQz8KNCLtVX6idSoTLdUw= +github.com/lann/builder v0.0.0-20180802200727-47ae307949d0/go.mod h1:dXGbAdH5GtBTC4WfIxhKZfyBF/HBFgRZSWwZ9g/He9o= +github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhRWSsG5rVo6hYhAB/ADZrk= +github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6FmdpVm2joNMFikkuWg0EoCKLGUMNw= github.com/libsql/sqlite-antlr4-parser v0.0.0-20230802215326-5cb5bb604475 h1:6PfEMwfInASh9hkN83aR0j4W/eKaAZt/AURtXAXlas0= github.com/libsql/sqlite-antlr4-parser v0.0.0-20230802215326-5cb5bb604475/go.mod h1:20nXSmcf0nAscrzqsXeC2/tA3KkV2eCiJqYuyAgl+ss= +github.com/magiconair/properties v1.7.4-0.20170902060319-8d7837e64d3c/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ= github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= -github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo= -github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= +github.com/mattn/go-colorable v0.0.10-0.20170816031813-ad5389df28cd/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= +github.com/mattn/go-isatty v0.0.2/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y= +github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= +github.com/mitchellh/mapstructure v0.0.0-20170523030023-d0303fe80992/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/oklog/ulid/v2 v2.1.0 h1:+9lhoxAP56we25tyYETBBY1YLA2SaoLvUFgrP2miPJU= +github.com/oklog/ulid/v2 v2.1.0/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= +github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= +github.com/pelletier/go-toml v1.0.1-0.20170904195809-1d6b12b7cb29/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/prometheus/client_golang v1.17.0 h1:rl2sfwZMtSthVU752MqfjQozy7blglC+1SOtjMAMh+Q= -github.com/prometheus/client_golang v1.17.0/go.mod h1:VeL+gMmOAxkS2IqfCq0ZmHSL+LjWfWDUmp1mBz9JgUY= -github.com/prometheus/client_model v0.4.1-0.20230718164431-9a2bf3000d16 h1:v7DLqVdK4VrYkVD5diGdl4sxJurKJEMnODWRJlxV9oM= -github.com/prometheus/client_model v0.4.1-0.20230718164431-9a2bf3000d16/go.mod h1:oMQmHW1/JoDwqLtg57MGgP/Fb1CJEYF2imWWhWtMkYU= -github.com/prometheus/common v0.44.0 h1:+5BrQJwiBB9xsMygAB3TNvpQKOwlkc25LbISbrdOOfY= -github.com/prometheus/common v0.44.0/go.mod h1:ofAIvZbQ1e/nugmZGz4/qCb9Ap1VoSTIO7x0VV9VvuY= +github.com/prometheus/client_golang v1.18.0 h1:HzFfmkOzH5Q8L8G+kSJKUx5dtG87sewO+FoDDqP5Tbk= +github.com/prometheus/client_golang v1.18.0/go.mod h1:T+GXkCk5wSJyOqMIzVgvvjFDlkOQntgjkJWKrN5txjA= +github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw= +github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI= +github.com/prometheus/common v0.47.0 h1:p5Cz0FNHo7SnWOmWmoRozVcjEp0bIVU8cV7OShpjL1k= +github.com/prometheus/common v0.47.0/go.mod h1:0/KsvlIEfPQCQ5I2iNSAWKPZziNCvRs5EC6ILDTlAPc= github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo= github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo= -github.com/ravilushqa/otelgqlgen v0.13.1 h1:V+zFE75iDd2/CSzy5kKnb+Fi09SsE5535wv9U2nUEFE= -github.com/ravilushqa/otelgqlgen v0.13.1/go.mod h1:ZIyWykK2paCuNi9k8gk5edcNSwDJuxZaW90vZXpafxw= +github.com/ravilushqa/otelgqlgen v0.15.0 h1:U85nrlweMXTGaMChUViYM39/MXBZVeVVlpuHq+6eECQ= +github.com/ravilushqa/otelgqlgen v0.15.0/go.mod h1:o+1Eju0VySmgq2BP8Vupz2YrN21Bj7D7imBqu3m2uB8= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= +github.com/sosodev/duration v1.2.0 h1:pqK/FLSjsAADWY74SyWDCjOcd5l7H8GSnnOGEB9A1Us= +github.com/sosodev/duration v1.2.0/go.mod h1:RQIBBX0+fMLc/D9+Jb/fwvVmo0eZvDDEERAikUR6SDg= +github.com/spf13/afero v0.0.0-20170901052352-ee1bd8ee15a1/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= +github.com/spf13/cast v1.1.0/go.mod h1:r2rcYCSwa1IExKTDiTfzaxqT2FNHs8hODu4LnUfgKEg= +github.com/spf13/jwalterweatherman v0.0.0-20170901151539-12bd96e66386/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= +github.com/spf13/pflag v1.0.1-0.20170901120850-7aff26db30c1/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= +github.com/spf13/viper v1.0.0/go.mod h1:A8kyI5cUJhb8N+3pkfONlcEcZbueH6nhAm0Fq7SrnBM= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/swaggest/assertjson v1.9.0 h1:dKu0BfJkIxv/xe//mkCrK5yZbs79jL7OVf9Ija7o2xQ= +github.com/swaggest/assertjson v1.9.0/go.mod h1:b+ZKX2VRiUjxfUIal0HDN85W0nHPAYUbYH5WkkSsFsU= github.com/tursodatabase/go-libsql v0.0.0-20240322134723-08771dcdd2f1 h1:lQwP++jvwcQiPqqIXIvabCIvfh8bzibpjYvT4s0jWmA= github.com/tursodatabase/go-libsql v0.0.0-20240322134723-08771dcdd2f1/go.mod h1:sb520Yr+GHBsfL43FQgQ+rLFfuJkItgRWlTgbIQHVxA= -github.com/vektah/gqlparser/v2 v2.5.6 h1:Ou14T0N1s191eRMZ1gARVqohcbe1e8FrcONScsq8cRU= -github.com/vektah/gqlparser/v2 v2.5.6/go.mod h1:z8xXUff237NntSuH8mLFijZ+1tjV1swDbpDqjJmk6ME= -go.opentelemetry.io/contrib v1.16.1 h1:EpASvVyGx6/ZTlmXzxYfTMZxHROelCeXXa2uLiwltcs= -go.opentelemetry.io/contrib v1.16.1/go.mod h1:gIzjwWFoGazJmtCaDgViqOSJPde2mCWzv60o0bWPcZs= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.44.0 h1:KfYpVmrjI7JuToy5k8XV3nkapjWx48k4E4JOtVstzQI= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.44.0/go.mod h1:SeQhzAEccGVZVEy7aH87Nh0km+utSpo1pTv6eMMop48= -go.opentelemetry.io/contrib/instrumentation/runtime v0.42.0 h1:EbmAUG9hEAMXyfWEasIt2kmh/WmXUznUksChApTgBGc= -go.opentelemetry.io/contrib/instrumentation/runtime v0.42.0/go.mod h1:rD9feqRYP24P14t5kmhNMqsqm1jvKmpx2H2rKVw52V8= -go.opentelemetry.io/otel v1.18.0 h1:TgVozPGZ01nHyDZxK5WGPFB9QexeTMXEH7+tIClWfzs= -go.opentelemetry.io/otel v1.18.0/go.mod h1:9lWqYO0Db579XzVuCKFNPDl4s73Voa+zEck3wHaAYQI= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.18.0 h1:IAtl+7gua134xcV3NieDhJHjjOVeJhXAnYf/0hswjUY= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.18.0/go.mod h1:w+pXobnBzh95MNIkeIuAKcHe/Uu/CX2PKIvBP6ipKRA= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.18.0 h1:6pu8ttx76BxHf+xz/H77AUZkPF3cwWzXqAUsXhVKI18= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.18.0/go.mod h1:IOmXxPrxoxFMXdNy7lfDmE8MzE61YPcurbUm0SMjerI= -go.opentelemetry.io/otel/exporters/prometheus v0.41.0 h1:A3/bhjP5SmELy8dcpK+uttHeh9Qrh+YnS16/VzrztRQ= -go.opentelemetry.io/otel/exporters/prometheus v0.41.0/go.mod h1:mKuXEMi9suyyNJQ99SZCO0mpWGFe0MIALtjd3r6uo7Q= -go.opentelemetry.io/otel/metric v1.18.0 h1:JwVzw94UYmbx3ej++CwLUQZxEODDj/pOuTCvzhtRrSQ= -go.opentelemetry.io/otel/metric v1.18.0/go.mod h1:nNSpsVDjWGfb7chbRLUNW+PBNdcSTHD4Uu5pfFMOI0k= -go.opentelemetry.io/otel/sdk v1.18.0 h1:e3bAB0wB3MljH38sHzpV/qWrOTCFrdZF2ct9F8rBkcY= -go.opentelemetry.io/otel/sdk v1.18.0/go.mod h1:1RCygWV7plY2KmdskZEDDBs4tJeHG92MdHZIluiYs/M= -go.opentelemetry.io/otel/sdk/metric v0.41.0 h1:c3sAt9/pQ5fSIUfl0gPtClV3HhE18DCVzByD33R/zsk= -go.opentelemetry.io/otel/sdk/metric v0.41.0/go.mod h1:PmOmSt+iOklKtIg5O4Vz9H/ttcRFSNTgii+E1KGyn1w= -go.opentelemetry.io/otel/trace v1.18.0 h1:NY+czwbHbmndxojTEKiSMHkG2ClNH2PwmcHrdo0JY10= -go.opentelemetry.io/otel/trace v1.18.0/go.mod h1:T2+SGJGuYZY3bjj5rgh/hN7KIrlpWC5nS8Mjvzckz+0= -go.opentelemetry.io/proto/otlp v1.0.0 h1:T0TX0tmXU8a3CbNXzEKGeU5mIVOdf0oykP+u2lIVU/I= -go.opentelemetry.io/proto/otlp v1.0.0/go.mod h1:Sy6pihPLfYHkr3NkUbEhGHFhINUSI/v80hjKIs5JXpM= +github.com/vektah/gqlparser/v2 v2.5.11 h1:JJxLtXIoN7+3x6MBdtIP59TP1RANnY7pXOaDnADQSf8= +github.com/vektah/gqlparser/v2 v2.5.11/go.mod h1:1rCcfwB2ekJofmluGWXMSEnPMZgbxzwj6FaZ/4OT8Cc= +github.com/yudai/gojsondiff v1.0.0 h1:27cbfqXLVEJ1o8I6v3y9lg8Ydm53EKqHXAOMxEGlCOA= +github.com/yudai/gojsondiff v1.0.0/go.mod h1:AY32+k2cwILAkW1fbgxQ5mUmMiZFgLIV+FBNExI05xg= +github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 h1:BHyfKlQyqbsFN5p3IfnEUduWvb9is428/nNb5L3U01M= +github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDfVJdfcVVdX+jpBxNmX4rDAzaS45IcYoM= +go.nhat.io/otelsql v0.12.0 h1:/rBhWZiwHFLpCm5SGdafm+Owm0OmGmnF31XWxgecFtY= +go.nhat.io/otelsql v0.12.0/go.mod h1:39Hc9/JDfCl7NGrBi1uPP3QPofqwnC/i5SFd7gtDMWM= +go.opentelemetry.io/contrib v1.23.0 h1:5f6bvGoHE/7lcolc1jCA4Vzq2tnPs4tfqL1M/yfjbOA= +go.opentelemetry.io/contrib v1.23.0/go.mod h1:usW9bPlrjHiJFbK0a6yK/M5wNHs3nLmtrT3vzhoD3co= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.48.0 h1:doUP+ExOpH3spVTLS0FcWGLnQrPct/hD/bCPbDRUEAU= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.48.0/go.mod h1:rdENBZMT2OE6Ne/KLwpiXudnAsbdrdBaqBvTN8M8BgA= +go.opentelemetry.io/contrib/instrumentation/runtime v0.48.0 h1:dJlCKeq+zmO5Og4kgxqPvvJrzuD/mygs1g/NYM9dAsU= +go.opentelemetry.io/contrib/instrumentation/runtime v0.48.0/go.mod h1:p+hpBCpLHpuUrR0lHgnHbUnbCBll1IhrcMIlycC+xYs= +go.opentelemetry.io/otel v1.23.1 h1:Za4UzOqJYS+MUczKI320AtqZHZb7EqxO00jAHE0jmQY= +go.opentelemetry.io/otel v1.23.1/go.mod h1:Td0134eafDLcTS4y+zQ26GE8u3dEuRBiBCTUIRHaikA= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.23.1 h1:o8iWeVFa1BcLtVEV0LzrCxV2/55tB3xLxADr6Kyoey4= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.23.1/go.mod h1:SEVfdK4IoBnbT2FXNM/k8yC08MrfbhWk3U4ljM8B3HE= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.23.1 h1:cfuy3bXmLJS7M1RZmAL6SuhGtKUp2KEsrm00OlAXkq4= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.23.1/go.mod h1:22jr92C6KwlwItJmQzfixzQM3oyyuYLCfHiMY+rpsPU= +go.opentelemetry.io/otel/exporters/prometheus v0.45.2 h1:pe2Jqk1K18As0RCw7J08QhgXNqr+6npx0a5W4IgAFA8= +go.opentelemetry.io/otel/exporters/prometheus v0.45.2/go.mod h1:B38pscHKI6bhFS44FDw0eFU3iqG3ASNIvY+fZgR5sAc= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v0.40.0 h1:hf7JSONqAuXT1PDYYlVhKNMPLe4060d+4RFREcv7X2c= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v0.40.0/go.mod h1:IxD5qbw/XcnFB7i5k4d7J1aW5iBU2h4DgSxtk4YqR4c= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.17.0 h1:Ut6hgtYcASHwCzRHkXEtSsM251cXJPW+Z9DyLwEn6iI= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.17.0/go.mod h1:TYeE+8d5CjrgBa0ZuRaDeMpIC1xZ7atg4g+nInjuSjc= +go.opentelemetry.io/otel/metric v1.23.1 h1:PQJmqJ9u2QaJLBOELl1cxIdPcpbwzbkjfEyelTl2rlo= +go.opentelemetry.io/otel/metric v1.23.1/go.mod h1:mpG2QPlAfnK8yNhNJAxDZruU9Y1/HubbC+KyH8FaCWI= +go.opentelemetry.io/otel/sdk v1.23.1 h1:O7JmZw0h76if63LQdsBMKQDWNb5oEcOThG9IrxscV+E= +go.opentelemetry.io/otel/sdk v1.23.1/go.mod h1:LzdEVR5am1uKOOwfBWFef2DCi1nu3SA8XQxx2IerWFk= +go.opentelemetry.io/otel/sdk/metric v1.23.1 h1:T9/8WsYg+ZqIpMWwdISVVrlGb/N0Jr1OHjR/alpKwzg= +go.opentelemetry.io/otel/sdk/metric v1.23.1/go.mod h1:8WX6WnNtHCgUruJ4TJ+UssQjMtpxkpX0zveQC8JG/E0= +go.opentelemetry.io/otel/trace v1.23.1 h1:4LrmmEd8AU2rFvU1zegmvqW7+kWarxtNOPyeL6HmYY8= +go.opentelemetry.io/otel/trace v1.23.1/go.mod h1:4IpnpJFwr1mo/6HL8XIPJaE9y0+u1KcVmuW7dwFSVrI= +go.opentelemetry.io/proto/otlp v1.1.0 h1:2Di21piLrCqJ3U3eXGCTPHE9R8Nh+0uglSnOyxikMeI= +go.opentelemetry.io/proto/otlp v1.1.0/go.mod h1:GpBHCBWiqvVLDqmHZsoMM3C5ySeKTC7ej/RNTae6MdY= +go.sour.is/passwd v0.2.0 h1:eFLrvcayaS2e8ItjTU/tzBWtt1Am9xH97uBGqCCxdkk= +go.sour.is/passwd v0.2.0/go.mod h1:xDqWTLiztFhr1KvUh//lvmJfMg+9piWt7K+d1JX3n0s= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -golang.org/x/exp v0.0.0-20220722155223-a9213eeb770e h1:+WEEuIdZHnUeJJmEUjyYC2gfUMj69yZXw17EnHg/otA= -golang.org/x/exp v0.0.0-20220722155223-a9213eeb770e/go.mod h1:Kr81I6Kryrl9sr8s2FK3vxD90NdsKWRuOIl2O4CvYbA= -golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= -golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= -golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs= -golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= -golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E= -golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.19.0 h1:ENy+Az/9Y1vSrlrvBSyna3PITt4tiZLf7sgCjZBX7Wo= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/exp v0.0.0-20240213143201-ec583247a57a h1:HinSgX1tJRX3KsL//Gxynpw5CTOAIPhgL4W8PNiIpVE= +golang.org/x/exp v0.0.0-20240213143201-ec583247a57a/go.mod h1:CxmFvTBINI24O/j8iY7H1xHzx2i4OsyguNBmN/uPtqc= +golang.org/x/mod v0.15.0 h1:SernR4v+D55NyBH2QiEQrlBAnj1ECL6AGrA5+dPaMY8= +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/oauth2 v0.0.0-20170912212905-13449ad91cb2/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/sync v0.0.0-20170517211232-f52d1811a629/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= -golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= -golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= -golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= -golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/time v0.0.0-20170424234030-8be79e1e0910/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.18.0 h1:k8NLag8AGHnn+PHbl7g43CtqZAwG60vZkLqgyZgIHgQ= +golang.org/x/tools v0.18.0/go.mod h1:GL7B4CwcLLeo59yx/9UWWuNOW1n3VZ4f5axWfML7Lcg= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/genproto v0.0.0-20230711160842-782d3b101e98 h1:Z0hjGZePRE0ZBWotvtrwxFNrNE9CUAGtplaDK5NNI/g= -google.golang.org/genproto/googleapis/api v0.0.0-20230711160842-782d3b101e98 h1:FmF5cCW94Ij59cfpoLiwTgodWmm60eEV0CjlsVg2fuw= -google.golang.org/genproto/googleapis/api v0.0.0-20230711160842-782d3b101e98/go.mod h1:rsr7RhLuwsDKL7RmgDDCUc6yaGr1iqceVb5Wv6f6YvQ= -google.golang.org/genproto/googleapis/rpc v0.0.0-20230711160842-782d3b101e98 h1:bVf09lpb+OJbByTj913DRJioFFAjf/ZGxEz7MajTp2U= -google.golang.org/genproto/googleapis/rpc v0.0.0-20230711160842-782d3b101e98/go.mod h1:TUfxEVdsvPg18p6AslUXFoLdpED4oBnGwyqk3dV1XzM= -google.golang.org/grpc v1.58.0 h1:32JY8YpPMSR45K+c3o6b8VL73V+rR8k+DeMIr4vRH8o= -google.golang.org/grpc v1.58.0/go.mod h1:tgX3ZQDlNJGU96V6yHh1T/JeoBQ2TXdr43YbYSsCJk0= -google.golang.org/grpc v1.58.3 h1:BjnpXut1btbtgN/6sp+brB2Kbm2LjNXnidYujAVbSoQ= -google.golang.org/grpc v1.58.3/go.mod h1:tgX3ZQDlNJGU96V6yHh1T/JeoBQ2TXdr43YbYSsCJk0= +google.golang.org/api v0.0.0-20170921000349-586095a6e407/go.mod h1:4mhQ8q/RsB7i+udVvVy5NUi08OU8ZlA0gRVgrF7VFY0= +google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/genproto v0.0.0-20170918111702-1e559d0a00ee/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20240213162025-012b6fc9bca9 h1:9+tzLLstTlPTRyJTh+ah5wIMsBW5c4tQwGTN3thOW9Y= +google.golang.org/genproto v0.0.0-20240213162025-012b6fc9bca9/go.mod h1:mqHbVIp48Muh7Ywss/AD6I5kNVKZMmAa/QEW58Gxp2s= +google.golang.org/genproto/googleapis/api v0.0.0-20240213162025-012b6fc9bca9 h1:4++qSzdWBUy9/2x8L5KZgwZw+mjJZ2yDSCGMVM0YzRs= +google.golang.org/genproto/googleapis/api v0.0.0-20240213162025-012b6fc9bca9/go.mod h1:PVreiBMirk8ypES6aw9d4p6iiBNSIfZEBqr3UGoAi2E= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240213162025-012b6fc9bca9 h1:hZB7eLIaYlW9qXRfCq/qDaPdbeY3757uARz5Vvfv+cY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240213162025-012b6fc9bca9/go.mod h1:YUWgXUFRPfoYK1IHMuxH5K6nPEXSCzIMljnQ59lLRCk= +google.golang.org/grpc v1.2.1-0.20170921194603-d4b75ebd4f9f/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw= +google.golang.org/grpc v1.61.1 h1:kLAiWrZs7YeDM6MumDe7m3y4aM6wacLzM1Y/wiLP9XY= +google.golang.org/grpc v1.61.1/go.mod h1:VUbo7IFqmF1QtCAstipjG0GIoq49KvMe9+h1jFLBNJs= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= -google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= -google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= -google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +google.golang.org/protobuf v1.32.0 h1:pPC6BG5ex8PDFnkbrGU3EixyhKcQ2aDuBS36lqK/C7I= +google.golang.org/protobuf v1.32.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo= +gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw= +modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI= +modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4= +modernc.org/libc v1.41.0 h1:g9YAc6BkKlgORsUWj+JwqoB1wU3o4DE3bM3yvA3k+Gk= +modernc.org/libc v1.41.0/go.mod h1:w0eszPsiXoOnoMJgrXjglgLuDy/bt5RR4y3QzUUeodY= +modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= +modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= +modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E= +modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E= +modernc.org/sqlite v1.29.1 h1:19GY2qvWB4VPw0HppFlZCPAbmxFU41r+qjKZQdQ1ryA= +modernc.org/sqlite v1.29.1/go.mod h1:hG41jCYxOAOoO6BRK66AdRlmOcDzXf7qnwlwjUIOqa0= +modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= +modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/ident/ident.go b/ident/ident.go new file mode 100644 index 0000000..7c85a56 --- /dev/null +++ b/ident/ident.go @@ -0,0 +1,115 @@ +package ident + +import ( + "context" + "fmt" + "io" + "net/http" + "sort" + + "github.com/oklog/ulid/v2" + "go.sour.is/passwd" +) + +// Ident interface for a logged in user +type Ident interface { + Identity() string + Session() *SessionInfo +} + +type SessionInfo struct { + SessionID ulid.ULID + Active bool +} + +func (s *SessionInfo) Session() *SessionInfo { return s } + +// Handler handler function to read ident from HTTP request +type Handler interface { + ReadIdent(r *http.Request) (Ident, error) +} +type HandleGet interface { + GetIdent(context.Context /* identity */, string) (Ident, error) +} +type HandleRegister interface { + RegisterIdent(ctx context.Context, identity, displayName string, passwd []byte) (Ident, error) +} + +type source struct { + Handler + priority int +} + +var contextKey = struct{ key string }{"ident"} + +func FromContext(ctx context.Context) Ident { + if id, ok := ctx.Value(contextKey).(Ident); ok { + return id + } + return Anonymous +} + +type IDM struct { + rand io.Reader + sources []source + pwd *passwd.Passwd +} + +func NewIDM(pwd *passwd.Passwd, rand io.Reader) *IDM { + return &IDM{pwd: pwd, rand:rand} +} + +func (idm *IDM) Add(p int, h Handler) { + idm.sources = append(idm.sources, source{priority: p, Handler: h}) + sort.Slice(idm.sources, func(i, j int) bool { return idm.sources[i].priority < idm.sources[j].priority }) +} + +func (idm *IDM) Passwd(pass, hash []byte) ([]byte, error) { + return idm.pwd.Passwd(pass, hash) +} + +// ReadIdent read ident from a list of ident handlers +func (idm *IDM) ReadIdent(r *http.Request) (Ident, error) { + for _, source := range idm.sources { + u, err := source.ReadIdent(r) + if err != nil { + return Anonymous, err + } + + if u.Session().Active { + return u, err + } + } + + return Anonymous, nil +} + +func (idm *IDM) RegisterIdent(ctx context.Context, identity, displayName string, passwd []byte) (Ident, error) { + for _, source := range idm.sources { + if source, ok := source.Handler.(HandleRegister); ok { + return source.RegisterIdent(ctx, identity, displayName, passwd) + } + } + + return nil, fmt.Errorf("no HandleRegister source registered") +} + +func (idm *IDM) GetIdent(ctx context.Context, identity string) (Ident, error) { + for _, source := range idm.sources { + if source, ok := source.Handler.(HandleGet); ok { + return source.GetIdent(ctx, identity) + } + } + + return nil, fmt.Errorf("no HandleGet source registered") +} + +func (idm *IDM) NewSessionInfo() (session SessionInfo, err error) { + session.SessionID, err = ulid.New(ulid.Now(), idm.rand) + if err != nil { + return + } + session.Active = true + + return session, nil +} diff --git a/ident/null-user.go b/ident/null-user.go new file mode 100644 index 0000000..f67a121 --- /dev/null +++ b/ident/null-user.go @@ -0,0 +1,75 @@ +package ident + +import "net/http" + +// nullUser implements a null ident +type nullUser struct { + identity string + aspect string + displayName string + SessionInfo +} + +// Anonymous is a logged out user +var Anonymous = NewNullUser("anon", "none", "Guest User", false) + +// NewNullUser creates a null user ident +func NewNullUser(ident, aspect, name string, active bool) *nullUser { + return &nullUser{ident, aspect, name, SessionInfo{Active: active}} +} + +func (id nullUser) String() string { + return "id: " + id.identity + " dn: " + id.displayName +} + +// GetIdentity returns identity +func (m nullUser) Identity() string { + return m.identity +} + +// GetAspect returns aspect +func (m nullUser) Aspect() string { + return m.aspect +} + +// HasRole returns true if matches role +func (m nullUser) Role(r ...string) bool { + return m.Active +} + +// HasGroup returns true if matches group +func (m nullUser) Group(g ...string) bool { + return m.Active +} + +// GetGroups returns empty list +func (m nullUser) Groups() []string { + return []string{} +} + +// GetRoles returns empty list +func (m nullUser) Roles() []string { + return []string{} +} + +// GetMeta returns empty list +func (m nullUser) Meta() map[string]string { + return make(map[string]string) +} + +// IsActive returns true if active +func (m nullUser) IsActive() bool { + return m.Active +} + +// GetDisplay returns display name +func (m nullUser) Display() string { + return m.displayName +} + +// MakeHandlerFunc returns handler func +func (m nullUser) HandlerFunc() func(r *http.Request) Ident { + return func(r *http.Request) Ident { + return &m + } +} diff --git a/ident/routes.go b/ident/routes.go new file mode 100644 index 0000000..cb3702b --- /dev/null +++ b/ident/routes.go @@ -0,0 +1,360 @@ +package ident + +import ( + "context" + "errors" + "fmt" + "net/http" + "time" + + "github.com/oklog/ulid/v2" + + "go.sour.is/passwd" + "go.sour.is/pkg/lg" + "go.sour.is/pkg/locker" +) + +var ( + loginForm = func(nick string, valid bool) string { + indicator := "" + if !valid { + indicator = `class="invalid"` + } + if nick != "" { + nick = `value="` + nick + `"` + } + return ` +
+ + + + + +
` + } + logoutForm = func(display string) string { + return `` + } + registerForm = ` +
+ + + + + + +
` +) + +type sessions map[ulid.ULID]Ident + +type root struct { + idm *IDM + sessions *locker.Locked[sessions] +} + +func NewHTTP(idm *IDM) *root { + sessions := make(sessions) + return &root{ + idm: idm, + sessions: locker.New(sessions), + } +} + +func (s *root) RegisterHTTP(mux *http.ServeMux) { + mux.HandleFunc("/ident", s.get) + mux.HandleFunc("/ident/register", s.register) + mux.HandleFunc("/ident/login", s.login) + mux.HandleFunc("/ident/logout", s.logout) +} +func (s *root) RegisterAPIv1(mux *http.ServeMux) { + mux.HandleFunc("POST /ident", s.registerV1) + mux.HandleFunc("POST /ident/session", s.loginV1) + mux.HandleFunc("DELETE /ident/session", s.logoutV1) + mux.HandleFunc("GET /ident", s.getV1) +} +func (s *root) RegisterMiddleware(hdlr http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx, span := lg.Span(r.Context()) + defer span.End() + + cookie, err := r.Cookie("sour.is-ident") + span.RecordError(err) + if err != nil { + hdlr.ServeHTTP(w, r) + return + } + + sessionID, err := ulid.Parse(cookie.Value) + span.RecordError(err) + + var id Ident = Anonymous + if err == nil { + err = s.sessions.Use(ctx, func(ctx context.Context, sessions sessions) error { + if session, ok := sessions[sessionID]; ok { + id = session + } + return nil + }) + } + span.RecordError(err) + + r = r.WithContext(context.WithValue(r.Context(), contextKey, id)) + + hdlr.ServeHTTP(w, r) + }) +} +func (s *root) createSession(ctx context.Context, id Ident) error { + return s.sessions.Use(ctx, func(ctx context.Context, sessions sessions) error { + sessions[id.Session().SessionID] = id + return nil + }) +} +func (s *root) destroySession(ctx context.Context, id Ident) error { + session := id.Session() + session.Active = false + + return s.sessions.Use(ctx, func(ctx context.Context, sessions sessions) error { + delete(sessions, session.SessionID) + return nil + }) +} + +func (s *root) getV1(w http.ResponseWriter, r *http.Request) { + ctx, span := lg.Span(r.Context()) + defer span.End() + + var id Ident = FromContext(ctx) + if id == nil { + http.Error(w, "NO_AUTH", http.StatusUnauthorized) + return + } + fmt.Fprint(w, id) +} +func (s *root) loginV1(w http.ResponseWriter, r *http.Request) { + ctx, span := lg.Span(r.Context()) + defer span.End() + + id, err := s.idm.ReadIdent(r) + span.RecordError(err) + if err != nil { + http.Error(w, "ERR", http.StatusInternalServerError) + return + } + if !id.Session().Active { + http.Error(w, "NO_AUTH", http.StatusUnauthorized) + return + } + + err = s.createSession(ctx, id) + if err != nil { + span.RecordError(err) + http.Error(w, "ERR", http.StatusInternalServerError) + return + } + + http.SetCookie(w, &http.Cookie{ + Name: "sour.is-ident", + Value: id.Session().SessionID.String(), + Expires: time.Time{}, + Path: "/", + Secure: false, + HttpOnly: true, + }) + + fmt.Fprint(w, id) +} +func (s *root) logoutV1(w http.ResponseWriter, r *http.Request) { + ctx, span := lg.Span(r.Context()) + defer span.End() + + if r.Method != http.MethodPost { + http.Error(w, "ERR", http.StatusMethodNotAllowed) + return + } + + err := s.destroySession(ctx, FromContext(ctx)) + if err != nil { + span.RecordError(err) + http.Error(w, "NO_AUTH", http.StatusUnauthorized) + return + } + + http.SetCookie(w, &http.Cookie{Name: "sour.is-ident", MaxAge: -1}) + + http.Error(w, "GONE", http.StatusGone) +} +func (s *root) registerV1(w http.ResponseWriter, r *http.Request) { + ctx, span := lg.Span(r.Context()) + defer span.End() + + if r.Method != http.MethodPost { + http.Error(w, "ERR", http.StatusMethodNotAllowed) + return + } + r.ParseForm() + + identity := r.Form.Get("identity") + display := r.Form.Get("displayName") + passwd, err := s.idm.Passwd([]byte(r.Form.Get("passwd")), nil) + if err != nil { + http.Error(w, "ERR", http.StatusInternalServerError) + return + } + + _, err = s.idm.RegisterIdent(ctx, identity, display, passwd) + if err != nil { + http.Error(w, "ERR", http.StatusInternalServerError) + return + } + + http.Error(w, "OK "+identity, http.StatusCreated) +} + +func (s *root) get(w http.ResponseWriter, r *http.Request) { + ctx, span := lg.Span(r.Context()) + defer span.End() + + var id Ident = FromContext(ctx) + if id == nil { + http.Error(w, loginForm("", true), http.StatusOK) + return + } + + if !id.Session().Active { + http.Error(w, loginForm("", true), http.StatusOK) + return + } + + display := id.Identity() + if id, ok := id.(interface{ DisplayName() string }); ok { + display = id.DisplayName() + } + fmt.Fprint(w, logoutForm(display)) +} +func (s *root) login(w http.ResponseWriter, r *http.Request) { + ctx, span := lg.Span(r.Context()) + defer span.End() + + if r.Method == http.MethodGet { + fmt.Fprint(w, loginForm("", true)) + return + } + + id, err := s.idm.ReadIdent(r) + span.RecordError(err) + if err != nil { + if errors.Is(err, passwd.ErrNoMatch) { + http.Error(w, loginForm("", false), http.StatusOK) + return + } + + http.Error(w, "ERROR", http.StatusInternalServerError) + return + } + + if !id.Session().Active { + http.Error(w, loginForm("", false), http.StatusOK) + return + } + + err = s.createSession(ctx, id) + span.RecordError(err) + if err != nil { + http.Error(w, "ERROR", http.StatusInternalServerError) + return + } + + http.SetCookie(w, &http.Cookie{ + Name: "sour.is-ident", + Value: id.Session().SessionID.String(), + Expires: time.Time{}, + Path: "/", + Secure: false, + HttpOnly: true, + }) + + display := id.Identity() + if id, ok := id.(interface{ DisplayName() string }); ok { + display = id.DisplayName() + } + fmt.Fprint(w, logoutForm(display)) +} +func (s *root) logout(w http.ResponseWriter, r *http.Request) { + ctx, span := lg.Span(r.Context()) + defer span.End() + + if r.Method != http.MethodPost { + http.Error(w, "ERR", http.StatusMethodNotAllowed) + return + } + + http.SetCookie(w, &http.Cookie{Name: "sour.is-ident", MaxAge: -1}) + + err := s.destroySession(ctx, FromContext(ctx)) + span.RecordError(err) + if err != nil { + http.Error(w, loginForm("", true), http.StatusUnauthorized) + return + } + + fmt.Fprint(w, loginForm("", true)) +} +func (s *root) register(w http.ResponseWriter, r *http.Request) { + ctx, span := lg.Span(r.Context()) + defer span.End() + + if r.Method == http.MethodGet { + fmt.Fprint(w, registerForm) + return + } + + if r.Method != http.MethodPost { + http.Error(w, "ERR", http.StatusMethodNotAllowed) + return + } + + r.ParseForm() + identity := r.Form.Get("identity") + display := r.Form.Get("displayName") + + passwd, err := s.idm.Passwd([]byte(r.Form.Get("passwd")), nil) + if err != nil { + http.Error(w, "ERR", http.StatusInternalServerError) + return + } + + id, err := s.idm.RegisterIdent(ctx, identity, display, passwd) + if err != nil { + http.Error(w, "ERR", http.StatusInternalServerError) + return + } + + if !id.Session().Active { + http.Error(w, loginForm("", false), http.StatusUnauthorized) + return + } + + err = s.createSession(ctx, id) + span.RecordError(err) + if err != nil { + http.Error(w, "ERROR", http.StatusInternalServerError) + return + } + + http.SetCookie(w, &http.Cookie{ + Name: "sour.is-ident", + Value: id.Session().SessionID.String(), + Expires: time.Time{}, + Path: "/", + Secure: false, + HttpOnly: true, + }) + + display = id.Identity() + if id, ok := id.(interface{ DisplayName() string }); ok { + display = id.DisplayName() + } + + http.Error(w, logoutForm(display), http.StatusCreated) +} diff --git a/ident/source/mercury.go b/ident/source/mercury.go new file mode 100644 index 0000000..c54ed01 --- /dev/null +++ b/ident/source/mercury.go @@ -0,0 +1,169 @@ +package source + +import ( + "context" + "fmt" + "net/http" + "strings" + "time" + + "go.sour.is/pkg/lg" + "go.sour.is/pkg/mercury" + "go.sour.is/pkg/ident" +) + +type registry interface { + GetIndex(ctx context.Context, match, search string) (c mercury.Config, err error) + GetConfig(ctx context.Context, match, search, fields string) (mercury.Config, error) + WriteConfig(ctx context.Context, spaces mercury.Config) error +} + +type mercuryIdent struct { + identity string + display string + passwd []byte + ident.SessionInfo +} + +func (id *mercuryIdent) Identity() string { return id.identity } +func (id *mercuryIdent) DisplayName() string { return id.display } +func (id *mercuryIdent) Space() string { return "mercury.@" + id.identity } + +func (id *mercuryIdent) FromConfig(cfg mercury.Config) error { + if id == nil { + return fmt.Errorf("nil ident") + } + + for _, s := range cfg { + if !strings.HasPrefix(s.Space, "mercury.") { + continue + } + if id.identity == "" { + _, id.identity, _ = strings.Cut(s.Space, ".@") + id.identity, _, _ = strings.Cut(id.identity, ".") + } + + switch { + case strings.HasSuffix(s.Space, ".ident"): + id.passwd = []byte(s.FirstValue("passwd").First()) + default: + id.display = s.FirstValue("displayName").First() + } + } + return nil +} + +func (id *mercuryIdent) ToConfig() mercury.Config { + space := id.Space() + return mercury.Config{ + &mercury.Space{ + Space: space, + List: []mercury.Value{ + { + Space: space, + Seq: 1, + Name: "displayName", + Values: []string{id.display}, + }, + { + Space: space, + Seq: 2, + Name: "lastLogin", + Values: []string{time.UnixMilli(int64(id.Session().SessionID.Time())).Format(time.RFC3339)}, + }, + }, + }, + &mercury.Space{ + Space: space + ".ident", + List: []mercury.Value{ + { + Space: space + ".ident", + Seq: 1, + Name: "passwd", + Values: []string{string(id.passwd)}, + }, + }, + }, + } +} + +func (id *mercuryIdent) String() string { + return "id: " + id.identity + " sp: " + id.Space() + " dn: " + id.display // + " ps: " + string(id.passwd) +} + +func (id *mercuryIdent) HasRole(r ...string) bool { + return false +} + +type mercurySource struct { + r registry + idm *ident.IDM +} + +func NewMercury(r registry, pwd *ident.IDM) *mercurySource { + return &mercurySource{r, pwd} +} + +func (s *mercurySource) ReadIdent(r *http.Request) (ident.Ident, error) { + ctx, span := lg.Span(r.Context()) + defer span.End() + + if r.Method != http.MethodPost { + return nil, fmt.Errorf("method not allowed") + } + r.ParseForm() + id := &mercuryIdent{ + identity: r.Form.Get("identity"), + passwd: []byte(r.Form.Get("passwd")), + } + + space := id.Space() + c, err := s.r.GetConfig(ctx, "trace:"+space+".ident", "", "") + if err != nil { + span.RecordError(err) + return id, err + } + var current mercuryIdent + current.FromConfig(c) + if len(current.passwd) == 0 { + return nil, fmt.Errorf("not registered") + } + _, err = s.idm.Passwd(id.passwd, current.passwd) + if err != nil { + return id, err + } + current.SessionInfo, err = s.idm.NewSessionInfo() + if err != nil { + return id, err + } + + err = s.r.WriteConfig(ctx, current.ToConfig()) + if err != nil { + return ¤t, err + } + + return ¤t, nil +} +func (s *mercurySource) RegisterIdent(ctx context.Context, identity, display string, passwd []byte) (ident.Ident, error) { + ctx, span := lg.Span(ctx) + defer span.End() + + id := &mercuryIdent{identity: identity, display: display, passwd: passwd} + space := id.Space() + + _, err := s.r.GetIndex(ctx, space, "") + if err != nil { + return nil, err + } + + id.SessionInfo, err = s.idm.NewSessionInfo() + if err != nil { + return id, err + } + + err = s.r.WriteConfig(ctx, id.ToConfig()) + if err != nil { + return nil, err + } + return id, nil +} diff --git a/lg/tracer.go b/lg/tracer.go index 99b0190..5e98b69 100644 --- a/lg/tracer.go +++ b/lg/tracer.go @@ -58,17 +58,56 @@ type wrapSpan struct { trace.Span } +func LogQuery(q string, args []any, err error) (string, trace.EventOption) { + var attrs []attribute.KeyValue + for k, v := range args { + var attr attribute.KeyValue + switch v:=v.(type) { + case int64: + attr = attribute.Int64( + fmt.Sprintf("$%d", k), + v, + ) + case string: + attr = attribute.String( + fmt.Sprintf("$%d", k), + v, + ) + default: + attr = attribute.String( + fmt.Sprintf("$%d", k), + fmt.Sprint(v), + ) + } + + attrs = append(attrs, attr) + } + + return q, trace.WithAttributes(attrs...) +} + func (w wrapSpan) AddEvent(name string, options ...trace.EventOption) { w.Span.AddEvent(name, options...) cfg := trace.NewEventConfig(options...) attrs := cfg.Attributes() - args := make([]any, len(attrs)*2) + args := make([]any, len(attrs)) for i, a := range attrs { - args[2*i] = a.Key - args[2*i+1] = a.Value + switch a.Value.Type() { + case attribute.BOOL: + args[i] = slog.Bool(string(a.Key), a.Value.AsBool()) + case attribute.INT64: + args[i] = slog.Int64(string(a.Key), a.Value.AsInt64()) + case attribute.FLOAT64: + args[i] = slog.Float64(string(a.Key), a.Value.AsFloat64()) + case attribute.STRING: + args[i] = slog.String(string(a.Key), a.Value.AsString()) + default: + args[i] = slog.Any(string(a.Key), a.Value.AsInterface()) + } + } slog.Debug(name, args...) diff --git a/mercury/app/app_test.go b/mercury/app/app_test.go new file mode 100644 index 0000000..3824501 --- /dev/null +++ b/mercury/app/app_test.go @@ -0,0 +1,157 @@ +package app + +import ( + "context" + "reflect" + "testing" + + "go.sour.is/pkg/mercury" + "go.sour.is/pkg/ident" +) + +type mockUser struct { + roles map[string]struct{} + ident.SessionInfo +} + +func (m *mockUser) Identity() string { return "user" } +func (m *mockUser) HasRole(roles ...string) bool { + var found bool + for _, role := range roles { + if _, ok := m.roles[role]; ok { + found = true + } + } + + return found +} + +func Test_appConfig_GetRules(t *testing.T) { + type args struct { + u ident.Ident + } + tests := []struct { + name string + args args + wantLis mercury.Rules + }{ + {"normal", args{&mockUser{}}, nil}, + { + "admin", + args{ + &mockUser{ + SessionInfo: ident.SessionInfo{Active: true}, + roles: map[string]struct{}{"admin": {}}, + }, + }, + mercury.Rules{ + mercury.Rule{ + Role: "read", + Type: "NS", + Match: "mercury.source.*", + }, + mercury.Rule{ + Role: "read", + Type: "NS", + Match: "mercury.priority", + }, + mercury.Rule{ + Role: "read", + Type: "NS", + Match: "mercury.host", + }, + mercury.Rule{ + Role: "read", + Type: "NS", + Match: "mercury.environ", + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + a := mercuryEnviron{} + if gotLis, _ := a.GetRules(context.TODO(), tt.args.u); !reflect.DeepEqual(gotLis, tt.wantLis) { + t.Errorf("appConfig.GetRules() = %v, want %v", gotLis, tt.wantLis) + } + }) + } +} + +// func Test_appConfig_GetIndex(t *testing.T) { +// type args struct { +// search mercury.NamespaceSearch +// in1 *rsql.Program +// } +// tests := []struct { +// name string +// args args +// wantLis mercury.Config +// }{ +// {"nil", args{ +// nil, +// nil, +// }, nil}, + +// {"app.settings", args{ +// mercury.ParseNamespace("app.settings"), +// nil, +// }, mercury.Config{&mercury.Space{Space: "app.settings"}}}, +// } +// for _, tt := range tests { +// t.Run(tt.name, func(t *testing.T) { +// a := mercuryEnviron{} +// if gotLis, _ := a.GetIndex(tt.args.search, tt.args.in1); !reflect.DeepEqual(gotLis, tt.wantLis) { +// t.Errorf("appConfig.GetIndex() = %#v, want %#v", gotLis, tt.wantLis) +// } +// }) +// } +// } + +// func Test_appConfig_GetObjects(t *testing.T) { +// cfg, err := mercury.ParseText(strings.NewReader(` +// @mercury.source.mercury-settings.default +// match :0 * +// `)) + +// type args struct { +// search mercury.NamespaceSearch +// in1 *rsql.Program +// in2 []string +// } +// tests := []struct { +// name string +// args args +// wantLis mercury.Config +// }{ +// {"nil", args{ +// nil, +// nil, +// nil, +// }, nil}, + +// {"app.settings", args{ +// mercury.ParseNamespace("app.settings"), +// nil, +// nil, +// }, mercury.Config{ +// &mercury.Space{ +// Space: "app.settings", +// List: []mercury.Value{{ +// Space: "app.settings", +// Name: "app.setting", +// Values: []string{"TRUE"}}, +// }, +// }, +// }}, +// } +// for _, tt := range tests { +// cfg, err := +// t.Run(tt.name, func(t *testing.T) { +// a := appConfig{cfg: } +// if gotLis, _ := a.GetConfig(tt.args.search, tt.args.in1, tt.args.in2); !reflect.DeepEqual(gotLis, tt.wantLis) { +// t.Errorf("appConfig.GetIndex() = %#v, want %#v", gotLis, tt.wantLis) +// } +// }) +// } +// } diff --git a/mercury/app/default-rules.go b/mercury/app/default-rules.go new file mode 100644 index 0000000..eb7e841 --- /dev/null +++ b/mercury/app/default-rules.go @@ -0,0 +1,98 @@ +package app + +import ( + "context" + "strings" + + "go.sour.is/pkg/mercury" + "go.sour.is/pkg/ident" +) + +type mercuryDefault struct { + name string + cfg mercury.SpaceMap +} + +var ( + _ mercury.GetRules = (*mercuryDefault)(nil) + + _ mercury.GetIndex = (*mercuryEnviron)(nil) + _ mercury.GetConfig = (*mercuryEnviron)(nil) + _ mercury.GetRules = (*mercuryEnviron)(nil) +) + +// GetRules returns default rules for user role. +func (app *mercuryDefault) GetRules(ctx context.Context, id ident.Ident) (lis mercury.Rules, err error) { + identity := id.Identity() + + lis = append(lis, + mercury.Rule{ + Role: "write", + Type: "NS", + Match: "mercury.@" + identity, + }, + mercury.Rule{ + Role: "write", + Type: "NS", + Match: "mercury.@" + identity + ".*", + }, + ) + + groups := groups(identity, &app.cfg) + + if s, ok := app.cfg.Space("mercury.policy."+app.name); ok { + for _, p := range s.List { + if groups.Has(p.Name) { + for _, r := range p.Values { + fds := strings.Fields(r) + if len(fds) < 3 { + continue + } + lis = append(lis, mercury.Rule{ + Role: fds[0], + Type: fds[1], + Match: fds[2], + }) + } + } + } + } + + if u, ok := id.(hasRole); groups.Has("admin") || ok && u.HasRole("admin") { + lis = append(lis, + mercury.Rule{ + Role: "admin", + Type: "NS", + Match: "*", + }, + mercury.Rule{ + Role: "write", + Type: "NS", + Match: "*", + }, + mercury.Rule{ + Role: "admin", + Type: "GR", + Match: "*", + }, + ) + } else if u.HasRole("write") { + lis = append(lis, + mercury.Rule{ + Role: "write", + Type: "NS", + Match: "*", + }, + ) + } else if u.HasRole("read") { + lis = append(lis, + mercury.Rule{ + Role: "read", + Type: "NS", + Match: "*", + }, + ) + } + + return lis, nil +} diff --git a/mercury/app/environ.go b/mercury/app/environ.go new file mode 100644 index 0000000..9a8aa76 --- /dev/null +++ b/mercury/app/environ.go @@ -0,0 +1,288 @@ +package app + +import ( + "context" + "fmt" + "os" + "os/user" + "sort" + "strings" + + "go.sour.is/pkg/mercury" + "go.sour.is/pkg/ident" + "go.sour.is/pkg/rsql" + "go.sour.is/pkg/set" +) + +const ( + mercurySource = "mercury.source.*" + mercuryPriority = "mercury.priority" + mercuryHost = "mercury.host" + appDotEnviron = "mercury.environ" +) +var ( + mercuryPolicy = func(id string) string { return "mercury.@" + id + ".policy" } +) + +func Register(name string, cfg mercury.SpaceMap) { + for _, c := range cfg { + c.Tags = append(c.Tags, "RO") + } + mercury.Registry.Register("mercury-default", func(s *mercury.Space) any { return &mercuryDefault{name: name, cfg: cfg} }) + mercury.Registry.Register("mercury-environ", func(s *mercury.Space) any { return &mercuryEnviron{cfg: cfg, lookup: mercury.Registry.GetRules} }) +} + +type hasRole interface { + HasRole(r ...string) bool +} + +type mercuryEnviron struct { + cfg mercury.SpaceMap + lookup func(context.Context, ident.Ident) (mercury.Rules, error) +} + +// Index returns nil +func (app *mercuryEnviron) GetIndex(ctx context.Context, search mercury.NamespaceSearch, _ *rsql.Program) (lis mercury.Config, err error) { + + if search.Match(mercurySource) { + for _, s := range app.cfg.ToArray() { + if search.Match(s.Space) { + lis = append(lis, &mercury.Space{Space: s.Space, Tags: []string{"RO"}}) + } + } + } + + if search.Match(mercuryPriority) { + lis = append(lis, &mercury.Space{Space: mercuryPriority, Tags: []string{"RO"}}) + } + + if search.Match(mercuryHost) { + lis = append(lis, &mercury.Space{Space: mercuryHost, Tags: []string{"RO"}}) + } + + if search.Match(appDotEnviron) { + lis = append(lis, &mercury.Space{Space: appDotEnviron, Tags: []string{"RO"}}) + } + if id := ident.FromContext(ctx); id != nil { + identity := id.Identity() + match := mercuryPolicy(identity) + if search.Match(match) { + lis = append(lis, &mercury.Space{Space: match, Tags: []string{"RO"}}) + } + } + return +} + +// Objects returns nil +func (app *mercuryEnviron) GetConfig(ctx context.Context, search mercury.NamespaceSearch, _ *rsql.Program, _ []string) (lis mercury.Config, err error) { + if search.Match(mercurySource) { + for _, s := range app.cfg.ToArray() { + if search.Match(s.Space) { + lis = append(lis, s) + } + } + } + + if search.Match(mercuryPriority) { + space := mercury.Space{ + Space: mercuryPriority, + Tags: []string{"RO"}, + } + + // for i, key := range mercury.Registry { + // space.List = append(space.List, mercury.Value{ + // Space: appDotPriority, + // Seq: uint64(i), + // Name: key.Match, + // Values: []string{fmt.Sprint(key.Priority)}, + // }) + // } + + lis = append(lis, &space) + } + + if search.Match(mercuryHost) { + if usr, err := user.Current(); err == nil { + space := mercury.Space{ + Space: mercuryHost, + Tags: []string{"RO"}, + } + + hostname, _ := os.Hostname() + wd, _ := os.Getwd() + grp, _ := usr.GroupIds() + space.List = []mercury.Value{ + { + Space: mercuryHost, + Seq: 1, + Name: "hostname", + Values: []string{hostname}, + }, + { + Space: mercuryHost, + Seq: 2, + Name: "username", + Values: []string{usr.Username}, + }, + { + Space: mercuryHost, + Seq: 3, + Name: "uid", + Values: []string{usr.Uid}, + }, + { + Space: mercuryHost, + Seq: 4, + Name: "gid", + Values: []string{usr.Gid}, + }, + { + Space: mercuryHost, + Seq: 5, + Name: "display", + Values: []string{usr.Name}, + }, + { + Space: mercuryHost, + Seq: 6, + Name: "home", + Values: []string{usr.HomeDir}, + }, + { + Space: mercuryHost, + Seq: 7, + Name: "groups", + Values: grp, + }, + { + Space: mercuryHost, + Seq: 8, + Name: "pid", + Values: []string{fmt.Sprintf("%v", os.Getpid())}, + }, + { + Space: mercuryHost, + Seq: 9, + Name: "wd", + Values: []string{wd}, + }, + } + + lis = append(lis, &space) + } + } + + if search.Match(appDotEnviron) { + env := os.Environ() + space := mercury.Space{ + Space: appDotEnviron, + Tags: []string{"RO"}, + } + + sort.Strings(env) + for i, s := range env { + key, val, _ := strings.Cut(s, "=") + + vals := []string{val} + if strings.Contains(key, "PATH") || strings.Contains(key, "XDG") { + vals = strings.Split(val, ":") + } + + space.List = append(space.List, mercury.Value{ + Space: appDotEnviron, + Seq: uint64(i), + Name: key, + Values: vals, + }) + } + lis = append(lis, &space) + } + + if id := ident.FromContext(ctx); id != nil { + identity := id.Identity() + groups := groups(identity, &app.cfg) + match := mercuryPolicy(identity) + if search.Match(match) { + space := &mercury.Space{ + Space: match, + Tags: []string{"RO"}, + } + + lis = append(lis, space) + rules, err := app.lookup(ctx, id) + if err != nil { + space.AddNotes(err.Error()) + } else { + k := mercury.NewValue("groups") + k.AddValues(strings.Join(groups.Values(), " ")) + space.AddKeys(k) + + k = mercury.NewValue("rules") + for _, r := range rules { + k.AddValues(strings.Join([]string{r.Role, r.Type, r.Match}, " ")) + } + space.AddKeys(k) + } + } + } + + return +} + +// Rules returns nil +func (app *mercuryEnviron) GetRules(ctx context.Context, id ident.Ident) (lis mercury.Rules, err error) { + identity := id.Identity() + + lis = append(lis, + mercury.Rule{ + Role: "read", + Type: "NS", + Match: mercuryPolicy(identity), + }, + ) + + groups := groups(identity, &app.cfg) + + if u, ok := id.(hasRole); groups.Has("admin") || ok && u.HasRole("admin") { + lis = append(lis, + mercury.Rule{ + Role: "read", + Type: "NS", + Match: mercurySource, + }, + mercury.Rule{ + Role: "read", + Type: "NS", + Match: mercuryPriority, + }, + mercury.Rule{ + Role: "read", + Type: "NS", + Match: mercuryHost, + }, + mercury.Rule{ + Role: "read", + Type: "NS", + Match: appDotEnviron, + }, + ) + } + + return lis, nil +} + +func groups(identity string, cfg *mercury.SpaceMap) set.Set[string] { + groups := set.New[string]() + if s, ok := cfg.Space("mercury.groups"); ok { + for _, g := range s.List { + for _, v := range g.Values { + for _, u := range strings.Fields(v) { + if u == identity { + groups.Add(g.Name) + } + } + } + } + } + return groups +} diff --git a/mercury/http/notify.go b/mercury/http/notify.go new file mode 100644 index 0000000..44cf8e6 --- /dev/null +++ b/mercury/http/notify.go @@ -0,0 +1,63 @@ +package http + +import ( + "bytes" + "context" + "crypto/tls" + "crypto/x509" + "fmt" + "net/http" + + "go.sour.is/pkg/lg" + "go.sour.is/pkg/mercury" +) + +type httpNotify struct{} + +func (httpNotify) SendNotify(ctx context.Context, n mercury.Notify) error { + ctx, span := lg.Span(ctx) + defer span.End() + + cl := &http.Client{} + caCertPool, err := x509.SystemCertPool() + if err != nil { + caCertPool = x509.NewCertPool() + } + + // Setup HTTPS client + tlsConfig := &tls.Config{ + RootCAs: caCertPool, + } + + transport := &http.Transport{TLSClientConfig: tlsConfig} + + cl.Transport = transport + + var req *http.Request + req, err = http.NewRequestWithContext(ctx, n.Method, n.URL, bytes.NewBufferString("")) + if err != nil { + span.RecordError(err) + return err + } + req.Header.Set("content-type", "application/json") + + span.AddEvent(fmt.Sprint("URL: ", n.URL)) + res, err := cl.Do(req) + if err != nil { + span.RecordError(err) + return err + } + res.Body.Close() + span.AddEvent(fmt.Sprint(res.Status)) + if res.StatusCode != 200 { + span.RecordError(err) + err = fmt.Errorf("unable to read config") + return err + } + + return nil +} + +func Register() { + mercury.Registry.Register("http-notify", func(s *mercury.Space) any { return httpNotify{} }) +} diff --git a/mercury/mercury.go b/mercury/mercury.go new file mode 100644 index 0000000..29ce596 --- /dev/null +++ b/mercury/mercury.go @@ -0,0 +1,695 @@ +package mercury + +import ( + "fmt" + "path/filepath" + "strings" + + "golang.org/x/exp/maps" +) + +type Config []*Space + +func NewConfig(spaces ...*Space) Config { + return spaces +} +func (c *Config) AddSpace(spaces ...*Space) *Config { + *c = append(*c, spaces...) + return c +} + +// Len implements Len for sort.interface +func (lis Config) Len() int { + return len(lis) +} + +// Less implements Less for sort.interface +func (lis Config) Less(i, j int) bool { + return lis[i].Space < lis[j].Space +} + +// Swap implements Swap for sort.interface +func (lis Config) Swap(i, j int) { lis[i], lis[j] = lis[j], lis[i] } + +// StringList returns the space names as a list +func (lis Config) StringList() string { + var buf strings.Builder + for _, o := range lis { + if len(o.Notes) > 0 { + buf.WriteString("# ") + buf.WriteString(strings.Join(o.Notes, "\n# ")) + buf.WriteRune('\n') + } + buf.WriteRune('@') + buf.WriteString(o.Space) + if len(o.Tags) > 0 { + buf.WriteRune(' ') + buf.WriteString(strings.Join(o.Tags, " ")) + } + buf.WriteRune('\n') + } + return buf.String() +} + +// ToSpaceMap formats as SpaceMap +func (lis Config) ToSpaceMap() SpaceMap { + out := make(SpaceMap) + for _, c := range lis { + out[c.Space] = c + } + return out +} + +// String format config as string +func (lis Config) String() string { + attLen := 0 + tagLen := 0 + + for _, o := range lis { + for _, v := range o.List { + l := len(v.Name) + if attLen <= l { + attLen = l + } + + t := len(strings.Join(v.Tags, " ")) + if tagLen <= t { + tagLen = t + } + } + } + + var buf strings.Builder + for _, o := range lis { + if len(o.Notes) > 0 { + buf.WriteString("# ") + buf.WriteString(strings.Join(o.Notes, "\n# ")) + buf.WriteRune('\n') + } + + buf.WriteRune('@') + buf.WriteString(o.Space) + if len(o.Tags) > 0 { + buf.WriteRune(' ') + buf.WriteString(strings.Join(o.Tags, " ")) + } + buf.WriteRune('\n') + + for _, v := range o.List { + if len(v.Notes) > 0 { + buf.WriteString("# ") + buf.WriteString(strings.Join(v.Notes, "\n# ")) + buf.WriteString("\n") + } + + buf.WriteString(v.Name) + buf.WriteString(strings.Repeat(" ", attLen-len(v.Name)+1)) + + if len(v.Tags) > 0 { + t := strings.Join(v.Tags, " ") + buf.WriteString(t) + buf.WriteString(strings.Repeat(" ", tagLen-len(t)+1)) + } else { + buf.WriteString(strings.Repeat(" ", tagLen+1)) + } + + switch len(v.Values) { + case 0: + buf.WriteString("\n") + case 1: + buf.WriteString(" :") + buf.WriteString(v.Values[0]) + buf.WriteString("\n") + default: + buf.WriteString(" :") + buf.WriteString(v.Values[0]) + buf.WriteString("\n") + for _, s := range v.Values[1:] { + buf.WriteString(strings.Repeat(" ", attLen+tagLen+3)) + buf.WriteString(":") + buf.WriteString(s) + buf.WriteString("\n") + } + } + } + + buf.WriteRune('\n') + } + + return buf.String() +} + +// EnvString format config as environ +func (lis Config) EnvString() string { + var buf strings.Builder + for _, o := range lis { + for _, v := range o.List { + buf.WriteString(o.Space) + for _, t := range o.Tags { + buf.WriteRune(' ') + buf.WriteString(t) + } + buf.WriteRune(':') + buf.WriteString(v.Name) + for _, t := range v.Tags { + buf.WriteRune(' ') + buf.WriteString(t) + } + switch len(v.Values) { + case 0: + buf.WriteRune('=') + buf.WriteRune('\n') + case 1: + buf.WriteRune('=') + buf.WriteString(v.Values[0]) + buf.WriteRune('\n') + default: + buf.WriteRune('+') + buf.WriteRune('=') + buf.WriteString(v.Values[0]) + buf.WriteRune('\n') + for _, s := range v.Values[1:] { + buf.WriteString(o.Space) + buf.WriteRune(':') + buf.WriteString(v.Name) + buf.WriteRune('+') + buf.WriteRune('=') + buf.WriteString(s) + buf.WriteRune('\n') + } + } + } + } + + return buf.String() +} + +// INIString format config as ini +func (lis Config) INIString() string { + var buf strings.Builder + for _, o := range lis { + buf.WriteRune('[') + buf.WriteString(o.Space) + buf.WriteRune(']') + buf.WriteRune('\n') + for _, v := range o.List { + buf.WriteString(v.Name) + switch len(v.Values) { + case 0: + buf.WriteRune('=') + buf.WriteRune('\n') + case 1: + buf.WriteRune('=') + buf.WriteString(v.Values[0]) + buf.WriteRune('\n') + default: + buf.WriteRune('[') + buf.WriteRune('0') + buf.WriteRune(']') + + buf.WriteRune('=') + buf.WriteString(v.Values[0]) + buf.WriteRune('\n') + for i, s := range v.Values[1:] { + buf.WriteString(v.Name) + buf.WriteRune('[') + buf.WriteString(fmt.Sprintf("%d", i)) + buf.WriteRune(']') + buf.WriteRune('=') + buf.WriteString(s) + buf.WriteRune('\n') + } + } + } + } + + return buf.String() +} + +// String format config as string +func (lis Config) HTMLString() string { + attLen := 0 + tagLen := 0 + + for _, o := range lis { + for _, v := range o.List { + l := len(v.Name) + if attLen <= l { + attLen = l + } + + t := len(strings.Join(v.Tags, " ")) + if tagLen <= t { + tagLen = t + } + } + } + + var buf strings.Builder + for _, o := range lis { + if len(o.Notes) > 0 { + buf.WriteString("") + buf.WriteString("# ") + buf.WriteString(strings.Join(o.Notes, "\n# ")) + buf.WriteString("") + buf.WriteRune('\n') + } + + buf.WriteString("") + buf.WriteRune('@') + buf.WriteString(o.Space) + buf.WriteString("") + if len(o.Tags) > 0 { + buf.WriteRune(' ') + buf.WriteString("") + buf.WriteString(strings.Join(o.Tags, " ")) + buf.WriteString("") + } + buf.WriteRune('\n') + + for _, v := range o.List { + if len(v.Notes) > 0 { + buf.WriteString("") + buf.WriteString("# ") + buf.WriteString(strings.Join(v.Notes, "\n# ")) + buf.WriteString("") + buf.WriteString("\n") + } + + buf.WriteString("") + buf.WriteString(v.Name) + buf.WriteString("") + buf.WriteString(strings.Repeat(" ", attLen-len(v.Name)+1)) + + if len(v.Tags) > 0 { + t := strings.Join(v.Tags, " ") + buf.WriteString("") + buf.WriteString(t) + buf.WriteString(strings.Repeat(" ", tagLen-len(t)+1)) + buf.WriteString("") + } else { + buf.WriteString(strings.Repeat(" ", tagLen+1)) + } + + switch len(v.Values) { + case 0: + buf.WriteString("\n") + case 1: + buf.WriteString(" :") + buf.WriteString(v.Values[0]) + buf.WriteString("\n") + default: + buf.WriteString(" :") + buf.WriteString(v.Values[0]) + buf.WriteString("\n") + for _, s := range v.Values[1:] { + buf.WriteString(strings.Repeat(" ", attLen+tagLen+3)) + buf.WriteString(":") + buf.WriteString(s) + buf.WriteString("\n") + } + } + } + + buf.WriteRune('\n') + } + + return buf.String() +} + +// Space stores a registry of spaces +type Space struct { + Space string `json:"space"` + Tags []string `json:"tags,omitempty"` + Notes []string `json:"notes,omitempty"` + List []Value `json:"list,omitempty"` +} + +func NewSpace(space string) *Space { + return &Space{Space: space} +} + +// HasTag returns true if needle is found +// If the needle ends with a / it will be treated +// as a prefix for tag meta data. +func (s *Space) HasTag(needle string) bool { + isPrefix := strings.HasSuffix(needle, "/") + for i := range s.Tags { + switch isPrefix { + case true: + if strings.HasPrefix(s.Tags[i], needle) { + return true + } + case false: + if s.Tags[i] == needle { + return true + } + } + } + return false +} + +// GetTagMeta retuns the value after a '/' in a tag. +// Tags are in the format 'name' or 'name/value' +// This function returns the value. +func (s *Space) GetTagMeta(needle string, offset int) string { + if !strings.HasSuffix(needle, "/") { + needle += "/" + } + + for i := range s.Tags { + if strings.HasPrefix(s.Tags[i], needle) { + if offset > 0 { + offset-- + continue + } + + return strings.TrimPrefix(s.Tags[i], needle) + } + } + + return "" +} + +// FirstTagMeta returns the first meta tag value. +func (s *Space) FirstTagMeta(needle string) string { + return s.GetTagMeta(needle, 0) +} + +// GetValues that match name +func (s *Space) GetValues(name string) (lis []Value) { + for i := range s.List { + if s.List[i].Name == name { + lis = append(lis, s.List[i]) + } + } + return +} + +// FirstValue that matches name +func (s *Space) FirstValue(name string) Value { + for i := range s.List { + if s.List[i].Name == name { + return s.List[i] + } + } + return Value{} +} + +func (s *Space) SetTags(tags ...string) *Space { + s.Tags = tags + return s +} +func (s *Space) AddTags(tags ...string) *Space { + s.Tags = append(s.Tags, tags...) + return s +} +func (s *Space) SetNotes(notes ...string) *Space { + s.Notes = notes + return s +} +func (s *Space) AddNotes(notes ...string) *Space { + s.Notes = append(s.Notes, notes...) + return s +} +func (s *Space) SetKeys(keys ...*Value) *Space { + for i := range keys { + k := *keys[i] + k.Seq = uint64(i) + s.List = append(s.List, k) + } + + return s +} +func (s *Space) AddKeys(keys ...*Value) *Space { + l := uint64(len(s.List)) + for i := range keys { + k := *keys[i] + k.Seq = uint64(i) + l + s.List = append(s.List, k) + } + return s +} + +// SpaceMap generic map of space values +type SpaceMap map[string]*Space + +func (m SpaceMap) Space(name string) (*Space, bool) { + s, ok := m[name] + return s, ok +} + +// Rule is a type of rule +type Rule struct { + Role string + Type string + Match string +} + +// Rules is a list of rules +type Rules []Rule + +// GetNamespaceSearch returns a default search for users rules. +func (r Rules) GetNamespaceSearch() (lis NamespaceSearch) { + for _, o := range r { + if o.Type == "NS" && (o.Role == "read" || o.Role == "write") { + lis = append(lis, NamespaceStar(o.Match)) + } + } + return +} + +// Check if name matches rule +func (r Rule) Check(name string) bool { + ok, err := filepath.Match(r.Match, name) + if err != nil { + return false + } + return ok +} + +// CheckNamespace verifies user has access +func (r Rules) CheckNamespace(search NamespaceSearch) bool { + for _, ns := range search { + if !r.GetRoles("NS", ns.Value()).HasRole("read", "write") { + return false + } + } + + return true +} + +func (r Rules) Less(i, j int) bool { + si, sj := scoreRule(r[i]), scoreRule(r[j]) + if si != sj { + return si < sj + } + return len(r[i].Match) < len(r[j].Match) +} +func (r Rules) Swap(i, j int) { r[i], r[j] = r[j], r[i] } +func (r Rules) Len() int { return len(r) } + +func scoreRule(r Rule) int { + score := 0 + if r.Type == "GR" { + score += 1000 + } + switch r.Role { + case "admin": + score += 100 + case "write": + score += 50 + case "read": + score += 10 + } + return score +} + +// ReduceSearch verifies user has access +func (r Rules) ReduceSearch(search NamespaceSearch) (out NamespaceSearch) { + rules := r.GetNamespaceSearch() + skip := make(map[string]struct{}) + out = make(NamespaceSearch, 0, len(rules)) + + for _, rule := range rules { + if _, ok := skip[rule.Raw()]; ok { + continue + } + for _, ck := range search { + if _, ok := skip[ck.Raw()]; ok { + continue + } else if rule.Match(ck.Raw()) { + skip[ck.Raw()] = struct{}{} + out = append(out, ck) + } else if ck.Match(rule.Raw()) { + out = append(out, rule) + } + } + } + + return +} + +// Roles is a list of roles for a resource +type Roles map[string]struct{} + +// GetRoles returns a list of Roles +func (r Rules) GetRoles(typ, name string) (lis Roles) { + lis = make(Roles) + for _, o := range r { + if typ == o.Type && o.Check(name) { + lis[o.Role] = struct{}{} + } + } + return +} + +// HasRole is a valid role +func (r Roles) HasRole(roles ...string) bool { + for _, role := range roles { + if _, ok := r[role]; ok { + return true + } + } + return false +} + +// ToArray converts SpaceMap to ArraySpace +func (m SpaceMap) ToArray() Config { + a := make(Config, 0, len(m)) + for _, s := range m { + a = append(a, s) + } + return a +} +func (m *SpaceMap) MergeMap(s SpaceMap) { + m.Merge(maps.Values(s)...) +} +func (m *SpaceMap) Merge(lis ...*Space) { + for _, s := range lis { + // Only accept first version based on priority. + if _, ok := (*m)[s.Space]; ok { + continue + } + + (*m)[s.Space] = s + + // // Merge values together. + // c, ok := (*m)[s.Space] + // if ok { + // c = &Space{} + // } + // c.Notes = append(c.Notes, s.Notes...) + // c.Tags = append(c.Tags, s.Tags...) + // last := c.List[len(c.List)-1].Seq + // for i := range s.List { + // v := s.List[i] + // v.Seq += last + // c.List = append(c.List, v) + // } + // (*m)[s.Space] = c + } +} + +// Value stores the attributes for space values +type Value struct { + Space string `json:"-" db:"space"` + Seq uint64 `json:"-" db:"seq"` + Name string `json:"name"` + Values []string `json:"values"` + Notes []string `json:"notes"` + Tags []string `json:"tags"` +} + +// func (v *Value) ID() string { +// return gql.FmtID("MercurySpace:%v:%v", v.Space, v.Seq) +// } + +// HasTag returns true if needle is found +// If the needle ends with a / it will be treated +// as a prefix for tag meta data. +func (v Value) HasTag(needle string) bool { + isPrefix := strings.HasSuffix(needle, "/") + for i := range v.Tags { + switch isPrefix { + case true: + if strings.HasPrefix(v.Tags[i], needle) { + return true + } + case false: + if v.Tags[i] == needle { + return true + } + } + } + return false +} + +// GetTagMeta retuns the value after a '/' in a tag. +// Tags are in the format 'name' or 'name/value' +// This function returns the value. +func (v Value) GetTagMeta(needle string, offset int) string { + if !strings.HasSuffix(needle, "/") { + needle += "/" + } + + for i := range v.Tags { + if strings.HasPrefix(v.Tags[i], needle) { + if offset > 0 { + offset-- + continue + } + + return strings.TrimPrefix(v.Tags[i], needle) + } + } + + return "" +} + +// FirstTagMeta returns the first meta tag value. +func (v Value) FirstTagMeta(needle string) string { + return v.GetTagMeta(needle, 0) +} + +// First value in array. +func (v Value) First() string { + if len(v.Values) < 1 { + return "" + } + + return v.Values[0] +} + +// Join values with newlines. +func (v Value) Join() string { + return strings.Join(v.Values, "\n") +} + +func NewValue(name string) *Value { + return &Value{Name: name} +} +func (v *Value) SetTags(tags ...string) *Value { + v.Tags = tags + return v +} +func (v *Value) AddTags(tags ...string) *Value { + v.Tags = append(v.Tags, tags...) + return v +} +func (v *Value) SetNotes(notes ...string) *Value { + v.Notes = notes + return v +} +func (v *Value) AddNotes(notes ...string) *Value { + v.Notes = append(v.Notes, notes...) + return v +} +func (v *Value) SetValues(values ...string) *Value { + v.Values = values + return v +} +func (v *Value) AddValues(values ...string) *Value { + v.Values = append(v.Values, values...) + return v +} diff --git a/mercury/mqtt/notify.go b/mercury/mqtt/notify.go new file mode 100644 index 0000000..f67b057 --- /dev/null +++ b/mercury/mqtt/notify.go @@ -0,0 +1,27 @@ +package mqtt + +import ( + "context" + + "go.sour.is/pkg/lg" + "go.sour.is/pkg/mercury" +) + +type mqttNotify struct{} + +func (mqttNotify) SendNotify(ctx context.Context, n mercury.Notify) { + _, span := lg.Span(ctx) + defer span.End() + // var m mqtt.Message + // m, err = mqtt.NewMessage(n.URL, n) + // if err != nil { + // return + // } + // log.Debug(n) + // err = mqtt.Publish(m) + // return +} + +func Register() { + mercury.Registry.Register("mqtt-notify", func(s *mercury.Space) any { return &mqttNotify{} }) +} diff --git a/mercury/namespace.go b/mercury/namespace.go new file mode 100644 index 0000000..c783714 --- /dev/null +++ b/mercury/namespace.go @@ -0,0 +1,125 @@ +package mercury + +import ( + "path/filepath" + "strings" +) + +// NamespaceSpec implements a parsed namespace search +type NamespaceSpec interface { + Value() string + String() string + Raw() string + Match(string) bool +} + +// NamespaceSearch list of namespace specs +type NamespaceSearch []NamespaceSpec + +// ParseNamespace returns a list of parsed values +func ParseNamespace(ns string) (lis NamespaceSearch) { + for _, part := range strings.Split(ns, ";") { + if strings.HasPrefix(part, "trace:") { + for _, s := range strings.Split(part[6:], ",") { + lis = append(lis, NamespaceTrace(s)) + } + } else { + for _, s := range strings.Split(part, ",") { + if strings.Contains(s, "*") { + lis = append(lis, NamespaceStar(s)) + } else { + lis = append(lis, NamespaceNode(s)) + } + } + } + } + + return +} + +// String output string value +func (n NamespaceSearch) String() string { + lis := make([]string, 0, len(n)) + + for _, v := range n { + lis = append(lis, v.String()) + } + return strings.Join(lis, ",") +} + +// Match returns true if any match. +func (n NamespaceSearch) Match(s string) bool { + for _, m := range n { + ok, err := filepath.Match(m.Raw(), s) + if err != nil { + return false + } + if ok { + return true + } + } + + return false +} + +// NamespaceNode implements a node search value +type NamespaceNode string + +// String output string value +func (n NamespaceNode) String() string { return string(n) } + +// Quote return quoted value. +// func (n NamespaceNode) Quote() string { return `'` + n.Value() + `'` } + +// Value to return the value +func (n NamespaceNode) Value() string { return string(n) } + +// Raw return raw value. +func (n NamespaceNode) Raw() string { return string(n) } + +// Match returns true if any match. +func (n NamespaceNode) Match(s string) bool { return match(n, s) } + +// NamespaceTrace implements a trace search value +type NamespaceTrace string + +// String output string value +func (n NamespaceTrace) String() string { return "trace:" + string(n) } + +// Quote return quoted value. +// func (n NamespaceTrace) Quote() string { return `'` + n.Value() + `'` } + +// Value to return the value +func (n NamespaceTrace) Value() string { return strings.Replace(string(n), "*", "%", -1) } + +// Raw return raw value. +func (n NamespaceTrace) Raw() string { return string(n) } + +// Match returns true if any match. +func (n NamespaceTrace) Match(s string) bool { return match(n, s) } + +// NamespaceStar implements a trace search value +type NamespaceStar string + +// String output string value +func (n NamespaceStar) String() string { return string(n) } + +// Quote return quoted value. +// func (n NamespaceStar) Quote() string { return `'` + n.Value() + `'` } + +// Value to return the value +func (n NamespaceStar) Value() string { return strings.Replace(string(n), "*", "%", -1) } + +// Raw return raw value. +func (n NamespaceStar) Raw() string { return string(n) } + +// Match returns true if any match. +func (n NamespaceStar) Match(s string) bool { return match(n, s) } + +func match(n NamespaceSpec, s string) bool { + ok, err := filepath.Match(n.Raw(), s) + if err != nil { + return false + } + return ok +} diff --git a/mercury/parse.go b/mercury/parse.go new file mode 100644 index 0000000..6bcb33d --- /dev/null +++ b/mercury/parse.go @@ -0,0 +1,110 @@ +package mercury + +import ( + "bufio" + "io" + "strings" +) + +func ParseText(body io.Reader) (config SpaceMap, err error) { + config = make(SpaceMap) + + var space string + var name string + var tags []string + var notes []string + var seq uint64 + + scanner := bufio.NewScanner(body) + for scanner.Scan() { + line := scanner.Text() + + if len(line) == 0 { + continue + } + + if strings.HasPrefix(line, "#") { + notes = append(notes, strings.TrimPrefix(line, "# ")) + continue + } + + if strings.HasPrefix(line, "@") { + var c *Space + var ok bool + + sp := strings.Fields(strings.TrimPrefix(line, "@")) + space = sp[0] + + if c, ok = config[space]; !ok { + c = &Space{Space: space} + } + + c.Notes = append(make([]string, 0, len(notes)), notes...) + c.Tags = append(make([]string, 0, len(sp[1:])), sp[1:]...) + + config[space] = c + notes = notes[:0] + tags = tags[:0] + + continue + } + + if space == "" { + continue + } + + sp := strings.SplitN(line, ":", 2) + if len(sp) < 2 { + continue + } + + if strings.TrimSpace(sp[0]) == "" { + var c *Space + var ok bool + + if c, ok = config[space]; !ok { + c = &Space{Space: space} + } + + c.List[len(c.List)-1].Values = append(c.List[len(c.List)-1].Values, sp[1]) + config[space] = c + + continue + } + + fields := strings.Fields(sp[0]) + name = fields[0] + if len(fields) > 1 { + tags = fields[1:] + } + + var c *Space + var ok bool + + if c, ok = config[space]; !ok { + c = &Space{Space: space} + } + + seq++ + c.List = append( + c.List, + Value{ + Seq: seq, + Name: name, + Tags: append(make([]string, 0, len(tags)), tags...), + Notes: append(make([]string, 0, len(notes)), notes...), + Values: []string{sp[1]}, + }, + ) + config[space] = c + + notes = notes[:0] + tags = tags[:0] + } + + if err = scanner.Err(); err != nil { + return nil, err + } + + return +} diff --git a/mercury/public/favicon.ico b/mercury/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..30e536c6020e1560e5fc088204302e8c8ddf3bdc GIT binary patch literal 1150 zcmbtRu?@mN5Hk`zsc3tUw7uN;vDV@(EwuK_ch={kHFv2WIv* z4eYzdaSkj2Yz;;k + + + + ☿ Mercury ☿ + + + + + + + +
+ +

☿ Mercury ☿

+
+ +
+
+ +
+
+ +
+
+ +
+ +
+

+        
+
+ +
+ sour.is 🅭2024 + +
+ + + \ No newline at end of file diff --git a/mercury/public/style.css b/mercury/public/style.css new file mode 100644 index 0000000..d29091e --- /dev/null +++ b/mercury/public/style.css @@ -0,0 +1,212 @@ +* { + font-weight: lighter; + font-family: 'fira code', monospace; +} + +body { + margin: 0; + min-height: 100vh; +} + +header { + margin: 0 50px; + background: rgb(63, 94, 251); + background: radial-gradient(circle, rgba(63, 94, 251, 1) 0%, rgba(252, 70, 107, 1) 100%); + height: 100px; + user-select: none; +} + +h1 { + text-align: center; + color: white; + line-height: 100px; + margin: 0; +} + +input, +textarea { + font-size: small; + border: 2px solid cornflowerblue; +} + +input.invalid { + border-color: red; +} + +button { + border: 2px solid cornflowerblue; +} + +code, +pre { + font-size: small; + -webkit-user-select: text; + user-select: text; +} + +code strong { + font-weight: bold; +} + +code dfn { + color: green +} + +code i { + color: grey +} + +code em { + color: orange; +} + +code:focus { + animation: select 100ms step-end forwards; +} + +footer { + position: fixed; + font-size: x-small; + bottom: 0; + left: 0; + right: 0; + height: 1em; + padding: 4px; + color: white; + border-top: 1px solid black; + background: cornflowerblue; +} + +footer>span { + float: right; +} + +.container { + margin: 0 50px; + display: grid; + min-height: 100vh; +} + +.container .open { + grid-template-columns: 1fr 1fr; +} + +.container>div { + overflow: auto; + padding: 10px; + background: rgb(238, 174, 202); + border: 0px ; +} + +.container>div>code { + -webkit-user-select: all; + /* for Safari */ + user-select: all; +} + +.search { + display: flex +} + +.search>div { + background-color: lightgrey; + border-radius: 5px 0px 0px 5px; + border: 2px solid cornflowerblue; + border-right: 0; + user-select: none; + padding: 0 2px; +} + +.search>button { + border-left: 0; +} + +.search>div { + line-height: 30px; +} + +.search>input { + flex-grow: 5; +} + +.search>button { + flex-grow: 2; + border-radius: 0px 5px 5px 0px; +} + +.edit { + display: grid; + grid-template-columns: 1fr; +} + +.edit>button { + line-height: 28px; + border-bottom: 0; + border-radius: 5px 5px 0 0; +} + +.edit>textarea { + margin-top: 0; + border-radius: 0 0 5px 5px; +} + +@keyframes select { + to { + -webkit-user-select: text; + user-select: text; + } +} + +@media (prefers-color-scheme: dark) { + html { + color: white; + background: #111 + } + + header { + background: #111; + background: radial-gradient(circle, rgba(2, 0, 36, 1) 0%, rgba(7, 80, 29, 1) 35%, rgba(0, 0, 0, 1) 100%); + } + + h1 { + color: white; + } + + .container>div { + background: #111; + /* background: linear-gradient(304deg, rgba(2, 0, 36, 1) 0%, rgba(77, 77, 77, 1) 18%, rgba(0, 0, 0, 1) 100%); */ + border: 2px solid rgb(117, 117, 117); + } + + .search>div { + background-color: grey; + border-color: rgb(117, 117, 117); + } + + button { + color: white; + background: rgba(7, 80, 29, 1); + border: 2px solid rgb(117, 117, 117); + } + + button:hover { + background: rgb(11, 121, 44); + } + + button:active { + background: rgb(5, 59, 21); + } + + input, + textarea { + color: white; + background-color: #111; + border: 2px solid rgb(117, 117, 117); + } + footer { + color: white; + border-top: 1px solid white; + background: #222; + + } +} \ No newline at end of file diff --git a/mercury/registry.go b/mercury/registry.go new file mode 100644 index 0000000..9d53ff2 --- /dev/null +++ b/mercury/registry.go @@ -0,0 +1,400 @@ +package mercury + +import ( + "context" + "fmt" + "path/filepath" + "sort" + "strconv" + "strings" + + "go.sour.is/pkg/lg" + "go.sour.is/pkg/ident" + "go.sour.is/pkg/rsql" + "go.sour.is/pkg/set" + "golang.org/x/sync/errgroup" +) + +type GetIndex interface { + GetIndex(context.Context, NamespaceSearch, *rsql.Program) (Config, error) +} +type GetConfig interface { + GetConfig(context.Context, NamespaceSearch, *rsql.Program, []string) (Config, error) +} +type WriteConfig interface { + WriteConfig(context.Context, Config) error +} +type GetRules interface { + GetRules(context.Context, ident.Ident) (Rules, error) +} +type GetNotify interface { + GetNotify(context.Context, string) (ListNotify, error) +} +type SendNotify interface { + SendNotify(context.Context, Notify) error +} + +// type nobody struct{} + +// func (nobody) IsActive() bool { return true } +// func (nobody) Identity() string { return "xuu" } +// func (nobody) HasRole(r ...string) bool { return true } + +func (reg *registry) accessFilter(rules Rules, lis Config) (out Config, err error) { + accessList := make(map[string]struct{}) + for _, o := range lis { + if _, ok := accessList[o.Space]; ok { + out = append(out, o) + continue + } + + if role := rules.GetRoles("NS", o.Space); role.HasRole("read", "write") && !role.HasRole("deny") { + accessList[o.Space] = struct{}{} + out = append(out, o) + } + } + + return +} + +// HandlerItem a single handler matching +type matcher[T any] struct { + Name string + Match NamespaceSearch + Priority int + Handler T +} +type matchers struct { + getIndex []matcher[GetIndex] + getConfig []matcher[GetConfig] + writeConfig []matcher[WriteConfig] + getRules []matcher[GetRules] + getNotify []matcher[GetNotify] + sendNotify []matcher[SendNotify] +} + +// registry a list of handlers +type registry struct { + handlers map[string]func(*Space) any + matchers matchers +} + +func (m matcher[T]) String() string { + return fmt.Sprintf("%d: %s", m.Priority, m.Match) +} + +// Registry handler +var Registry *registry = ®istry{} + +func (r registry) String() string { + var buf strings.Builder + for h := range r.handlers { + buf.WriteString(h) + buf.WriteRune('\n') + } + + return buf.String() +} + +func (r *registry) resetMatchers() { + r.matchers.getIndex = r.matchers.getIndex[:0] + r.matchers.getConfig = r.matchers.getConfig[:0] + r.matchers.writeConfig = r.matchers.writeConfig[:0] + r.matchers.getRules = r.matchers.getRules[:0] + r.matchers.getNotify = r.matchers.getNotify[:0] + r.matchers.sendNotify = r.matchers.sendNotify[:0] +} +func (r *registry) sortMatchers() { + sort.Slice(r.matchers.getConfig, func(i, j int) bool { return r.matchers.getConfig[i].Priority < r.matchers.getConfig[j].Priority }) + sort.Slice(r.matchers.getIndex, func(i, j int) bool { return r.matchers.getIndex[i].Priority < r.matchers.getIndex[j].Priority }) + sort.Slice(r.matchers.writeConfig, func(i, j int) bool { return r.matchers.writeConfig[i].Priority < r.matchers.writeConfig[j].Priority }) + sort.Slice(r.matchers.getRules, func(i, j int) bool { return r.matchers.getRules[i].Priority < r.matchers.getRules[j].Priority }) + sort.Slice(r.matchers.getNotify, func(i, j int) bool { return r.matchers.getNotify[i].Priority < r.matchers.getNotify[j].Priority }) + sort.Slice(r.matchers.sendNotify, func(i, j int) bool { return r.matchers.sendNotify[i].Priority < r.matchers.sendNotify[j].Priority }) +} +func (r *registry) Register(name string, h func(*Space) any) { + if r.handlers == nil { + r.handlers = make(map[string]func(*Space) any) + } + r.handlers[name] = h +} + +func (r *registry) Configure(m SpaceMap) error { + r.resetMatchers() + for space, c := range m { + space = strings.TrimPrefix(space, "mercury.source.") + handler, name, _ := strings.Cut(space, ".") + matches := c.FirstValue("match") + for _, match := range matches.Values { + ps := strings.Fields(match) + priority, err := strconv.Atoi(ps[0]) + if err != nil { + return err + } + r.add(name, handler, ps[1], priority, c) + } + } + + r.sortMatchers() + return nil +} + +// Register add a handler to registry +func (r *registry) add(name, handler, match string, priority int, cfg *Space) error { + // log.Infos("mercury regster", "match", match, "pri", priority) + mkHandler, ok := r.handlers[handler] + if !ok { + return fmt.Errorf("handler not registered: %s", handler) + } + hdlr := mkHandler(cfg) + if err, ok := hdlr.(error); ok { + return fmt.Errorf("%w: failed to config %s as handler: %s", err, name, handler) + } + if hdlr == nil { + return fmt.Errorf("failed to config %s as handler: %s", name, handler) + } + + if hdlr, ok := hdlr.(GetIndex); ok { + r.matchers.getIndex = append( + r.matchers.getIndex, + matcher[GetIndex]{Name: name, Match: ParseNamespace(match), Priority: priority, Handler: hdlr}, + ) + } + if hdlr, ok := hdlr.(GetConfig); ok { + r.matchers.getConfig = append( + r.matchers.getConfig, + matcher[GetConfig]{Name: name, Match: ParseNamespace(match), Priority: priority, Handler: hdlr}, + ) + } + + if hdlr, ok := hdlr.(WriteConfig); ok { + + r.matchers.writeConfig = append( + r.matchers.writeConfig, + matcher[WriteConfig]{Name: name, Match: ParseNamespace(match), Priority: priority, Handler: hdlr}, + ) + } + if hdlr, ok := hdlr.(GetRules); ok { + r.matchers.getRules = append( + r.matchers.getRules, + matcher[GetRules]{Name: name, Match: ParseNamespace(match), Priority: priority, Handler: hdlr}, + ) + } + if hdlr, ok := hdlr.(GetNotify); ok { + r.matchers.getNotify = append( + r.matchers.getNotify, + matcher[GetNotify]{Name: name, Match: ParseNamespace(match), Priority: priority, Handler: hdlr}, + ) + } + if hdlr, ok := hdlr.(SendNotify); ok { + r.matchers.sendNotify = append( + r.matchers.sendNotify, + matcher[SendNotify]{Name: name, Match: ParseNamespace(match), Priority: priority, Handler: hdlr}, + ) + } + + return nil +} + +// GetIndex query each handler that match namespace. +func (r *registry) GetIndex(ctx context.Context, match, search string) (c Config, err error) { + ctx, span := lg.Span(ctx) + defer span.End() + + spec := ParseNamespace(match) + pgm := rsql.DefaultParse(search) + matches := make([]NamespaceSearch, len(r.matchers.getIndex)) + + for _, n := range spec { + for i, hdlr := range r.matchers.getIndex { + if hdlr.Match.Match(n.Raw()) { + matches[i] = append(matches[i], n) + } + } + } + + wg, ctx := errgroup.WithContext(ctx) + slots := make(chan Config, len(r.matchers.getConfig)) + wg.Go(func() error { + i := 0 + for lis := range slots { + c = append(c, lis...) + i++ + if i > len(slots) { + break + } + } + return nil + }) + + for i, hdlr := range r.matchers.getIndex { + i, hdlr := i, hdlr + + wg.Go(func() error { + span.AddEvent(fmt.Sprintf("INDEX %s %s", hdlr.Name, hdlr.Match)) + lis, err := hdlr.Handler.GetIndex(ctx, matches[i], pgm) + slots <- lis + return err + }) + } + + err = wg.Wait() + if err != nil { + return nil, err + } + + return +} + +// Search query each handler with a key=value search + +// GetConfig query each handler that match for fully qualified namespaces. +func (r *registry) GetConfig(ctx context.Context, match, search, fields string) (Config, error) { + ctx, span := lg.Span(ctx) + defer span.End() + + spec := ParseNamespace(match) + pgm := rsql.DefaultParse(search) + flds := strings.Split(fields, ",") + + matches := make([]NamespaceSearch, len(r.matchers.getConfig)) + + for _, n := range spec { + for i, hdlr := range r.matchers.getConfig { + if hdlr.Match.Match(n.Raw()) { + matches[i] = append(matches[i], n) + } + } + } + + m := make(SpaceMap) + for i, hdlr := range r.matchers.getConfig { + if len(matches[i]) == 0 { + continue + } + span.AddEvent(fmt.Sprintf("QUERY %s %s", hdlr.Name, hdlr.Match)) + lis, err := hdlr.Handler.GetConfig(ctx, matches[i], pgm, flds) + if err != nil { + return nil, err + } + m.Merge(lis...) + } + + return m.ToArray(), nil +} + +// WriteConfig write objects to backends +func (r *registry) WriteConfig(ctx context.Context, spaces Config) error { + ctx, span := lg.Span(ctx) + defer span.End() + + matches := make([]Config, len(r.matchers.writeConfig)) + + for _, s := range spaces { + for i, hdlr := range r.matchers.writeConfig { + if hdlr.Match.Match(s.Space) { + matches[i] = append(matches[i], s) + break + } + } + } + + for i, hdlr := range r.matchers.writeConfig { + if len(matches[i]) == 0 { + continue + } + span.AddEvent(fmt.Sprint("WRITE MATCH", hdlr.Name, hdlr.Match)) + err := hdlr.Handler.WriteConfig(ctx, matches[i]) + if err != nil { + return err + } + } + + return nil +} + +// GetRules query each of the handlers for rules. +func (r *registry) GetRules(ctx context.Context, user ident.Ident) (Rules, error) { + ctx, span := lg.Span(ctx) + defer span.End() + + s := set.New[Rule]() + for _, hdlr := range r.matchers.getRules { + span.AddEvent(fmt.Sprint("RULES", hdlr.Name, hdlr.Match)) + lis, err := hdlr.Handler.GetRules(ctx, user) + if err != nil { + return nil, err + } + s.Add(lis...) + } + var rules Rules = s.Values() + sort.Sort(rules) + return rules, nil +} + +// GetNotify query each of the handlers for rules. +func (r *registry) GetNotify(ctx context.Context, event string) (ListNotify, error) { + ctx, span := lg.Span(ctx) + defer span.End() + + s := set.New[Notify]() + for _, hdlr := range r.matchers.getNotify { + span.AddEvent(fmt.Sprint("GET NOTIFY", hdlr.Name, hdlr.Match)) + + lis, err := hdlr.Handler.GetNotify(ctx, event) + if err != nil { + return nil, err + } + s.Add(lis...) + } + + return s.Values(), nil +} + +func (r *registry) SendNotify(ctx context.Context, n Notify) (err error) { + ctx, span := lg.Span(ctx) + defer span.End() + + for _, hdlr := range r.matchers.sendNotify { + span.AddEvent(fmt.Sprint("SEND NOTIFY", hdlr.Name, hdlr.Match)) + + err := hdlr.Handler.SendNotify(ctx, n) + if err != nil { + return err + } + } + + return +} + +// Check if name matches notify +func (n Notify) Check(name string) bool { + ok, err := filepath.Match(n.Match, name) + if err != nil { + return false + } + return ok +} + +// Notify stores the attributes for a registry space +type Notify struct { + Name string + Match string + Event string + Method string + URL string +} + +// ListNotify array of notify +type ListNotify []Notify + +// Find returns list of notify that match name. +func (ln ListNotify) Find(name string) (lis ListNotify) { + lis = make(ListNotify, 0, len(ln)) + for _, o := range ln { + if o.Check(name) { + lis = append(lis, o) + } + } + return +} diff --git a/mercury/routes.go b/mercury/routes.go new file mode 100644 index 0000000..4041fdd --- /dev/null +++ b/mercury/routes.go @@ -0,0 +1,267 @@ +package mercury + +import ( + "encoding/json" + "fmt" + "log" + "net/http" + "sort" + "strings" + "time" + + "github.com/BurntSushi/toml" + "github.com/golang/gddo/httputil" + "go.sour.is/pkg/lg" + "go.sour.is/pkg/ident" +) + +type root struct{} + +func NewHTTP() *root { + return &root{} +} + +func (s *root) RegisterHTTP(mux *http.ServeMux) { + mux.Handle("/", http.FileServer(http.Dir("./mercury/public"))) +} +func (s *root) RegisterAPIv1(mux *http.ServeMux) { + mux.HandleFunc("GET /mercury", s.indexV1) + // mux.HandleFunc("/mercury/config", s.configV1) + mux.HandleFunc("GET /mercury/config", s.configV1) + mux.HandleFunc("POST /mercury/config", s.storeV1) +} + +func (s *root) configV1(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + s.storeV1(w, r) + return + } + + ctx, span := lg.Span(r.Context()) + defer span.End() + + var id ident.Ident = ident.FromContext(ctx) + + if !id.Session().Active { + span.RecordError(fmt.Errorf("NO_AUTH")) + http.Error(w, "NO_AUTH", http.StatusUnauthorized) + return + } + + rules, err := Registry.GetRules(ctx, id) + if err != nil { + span.RecordError(err) + http.Error(w, "ERR: "+err.Error(), http.StatusInternalServerError) + return + } + space := r.URL.Query().Get("space") + if space == "" { + space = "*" + } + + log.Print("SPC: ", space) + ns := ParseNamespace(space) + log.Print("PRE: ", ns) + ns = rules.ReduceSearch(ns) + log.Print("POST: ", ns) + + lis, err := Registry.GetConfig(ctx, ns.String(), "", "") + if err != nil { + http.Error(w, "ERR: "+err.Error(), http.StatusInternalServerError) + return + } + + lis, err = Registry.accessFilter(rules, lis) + if err != nil { + span.RecordError(err) + http.Error(w, "ERR: "+err.Error(), http.StatusInternalServerError) + return + } + + sort.Sort(lis) + var content string + + switch httputil.NegotiateContentType(r, []string{ + "text/plain", + "text/html", + "application/environ", + "application/ini", + "application/json", + "application/toml", + }, "text/plain") { + case "text/plain": + content = lis.String() + case "text/html": + content = lis.HTMLString() + case "application/environ": + content = lis.EnvString() + case "application/ini": + content = lis.INIString() + case "application/json": + json.NewEncoder(w).Encode(lis) + case "application/toml": + w.WriteHeader(200) + m := make(map[string]map[string][]string) + for _, o := range lis { + if _, ok := m[o.Space]; !ok { + m[o.Space] = make(map[string][]string) + } + for _, v := range o.List { + m[o.Space][v.Name] = append(m[o.Space][v.Name], v.Values...) + } + } + err := toml.NewEncoder(w).Encode(m) + if err != nil { + // log.Error(err) + http.Error(w, "ERR", http.StatusInternalServerError) + return + } + } + + fmt.Fprint(w, content) +} + +func (s *root) storeV1(w http.ResponseWriter, r *http.Request) { + ctx, span := lg.Span(r.Context()) + defer span.End() + + var id = ident.FromContext(ctx) + + if !id.Session().Active { + span.RecordError(fmt.Errorf("NO_AUTH")) + http.Error(w, "NO_AUTH", http.StatusUnauthorized) + return + } + + var config SpaceMap + var err error + contentType, _, _ := strings.Cut(r.Header.Get("Content-Type"), ";") + switch contentType { + case "text/plain": + config, err = ParseText(r.Body) + r.Body.Close() + case "application/x-www-form-urlencoded": + r.ParseForm() + config, err = ParseText(strings.NewReader(r.Form.Get("content"))) + case "multipart/form-data": + r.ParseMultipartForm(1 << 20) + config, err = ParseText(strings.NewReader(r.Form.Get("content"))) + default: + http.Error(w, "PARSE_ERR", http.StatusUnsupportedMediaType) + return + } + if err != nil { + span.RecordError(err) + http.Error(w, "PARSE_ERR", 400) + return + } + + { + rules, err := Registry.GetRules(ctx, id) + if err != nil { + span.RecordError(err) + http.Error(w, "ERR: "+err.Error(), http.StatusInternalServerError) + return + } + + notify, err := Registry.GetNotify(ctx, "updated") + if err != nil { + span.RecordError(err) + http.Error(w, "ERR", http.StatusInternalServerError) + return + } + _ = rules + var notifyActive = make(map[string]struct{}) + var filteredConfigs Config + for ns, c := range config { + if !rules.GetRoles("NS", ns).HasRole("write") { + span.AddEvent(fmt.Sprint("SKIP", ns)) + continue + } + + span.AddEvent(fmt.Sprint("SAVE", ns)) + for _, n := range notify.Find(ns) { + notifyActive[n.Name] = struct{}{} + } + filteredConfigs = append(filteredConfigs, c) + } + + err = Registry.WriteConfig(ctx, filteredConfigs) + if err != nil { + span.RecordError(err) + http.Error(w, "ERR", http.StatusInternalServerError) + return + } + + span.AddEvent(fmt.Sprint("SEND NOTIFYS ", notifyActive)) + for _, n := range notify { + if _, ok := notifyActive[n.Name]; ok { + err = Registry.SendNotify(ctx, n) + if err != nil { + span.RecordError(err) + http.Error(w, "ERR", http.StatusInternalServerError) + return + } + } + } + span.AddEvent("DONE!") + } + + w.WriteHeader(202) + fmt.Fprint(w, "OK") +} + +func (s *root) indexV1(w http.ResponseWriter, r *http.Request) { + ctx, span := lg.Span(r.Context()) + defer span.End() + + var id = ident.FromContext(ctx) + + timer := time.Now() + defer func() { fmt.Println(time.Since(timer)) }() + + if !id.Session().Active { + span.RecordError(fmt.Errorf("NO_AUTH")) + http.Error(w, "NO_AUTH", http.StatusUnauthorized) + return + } + + rules, err := Registry.GetRules(ctx, id) + if err != nil { + span.RecordError(err) + http.Error(w, "ERR: "+err.Error(), http.StatusInternalServerError) + return + } + + span.AddEvent(fmt.Sprint(rules)) + + space := r.URL.Query().Get("space") + if space == "" { + space = "*" + } + + ns := ParseNamespace(space) + ns = rules.ReduceSearch(ns) + span.AddEvent(ns.String()) + + lis, err := Registry.GetIndex(ctx, ns.String(), "") + if err != nil { + span.RecordError(err) + http.Error(w, "ERR: "+err.Error(), http.StatusInternalServerError) + return + } + + sort.Sort(lis) + + switch httputil.NegotiateContentType(r, []string{ + "text/plain", + "application/json", + }, "text/plain") { + case "text/plain": + _, err = fmt.Fprint(w, lis.StringList()) + span.RecordError(err) + case "application/json": + err = json.NewEncoder(w).Encode(lis) + span.RecordError(err) + } +} diff --git a/mercury/sql/init-pg.sql b/mercury/sql/init-pg.sql new file mode 100644 index 0000000..780ed09 --- /dev/null +++ b/mercury/sql/init-pg.sql @@ -0,0 +1,116 @@ +CREATE SEQUENCE IF NOT EXISTS mercury_spaces_id_seq; + +CREATE TABLE IF NOT EXISTS mercury_spaces +( + space character varying NOT NULL, + id integer NOT NULL DEFAULT nextval('mercury_spaces_id_seq'::regclass), + notes character varying[] NOT NULL DEFAULT '{}'::character varying[], + tags character varying[] NOT NULL DEFAULT '{}'::character varying[], + CONSTRAINT mercury_namespace_pk PRIMARY KEY (id) +); +CREATE UNIQUE INDEX IF NOT EXISTS mercury_namespace_space_uindex + ON mercury_spaces USING btree + (space ASC NULLS LAST); + +CREATE TABLE IF NOT EXISTS mercury_values +( + id integer NOT NULL, + seq integer NOT NULL, + name character varying NOT NULL, + "values" character varying[] NOT NULL DEFAULT '{}'::character varying[], + tags character varying[] NOT NULL DEFAULT '{}'::character varying[], + notes character varying[] NOT NULL DEFAULT '{}'::character varying[], + CONSTRAINT mercury_values_pk PRIMARY KEY (id, seq) +); +CREATE INDEX IF NOT EXISTS mercury_values_name_index + ON mercury_values USING btree + (name ASC NULLS LAST); + +CREATE OR REPLACE VIEW mercury_registry_vw + AS + SELECT + s.id, + v.seq, + s.space, + v.name, + v."values", + v.notes, + v.tags + FROM mercury_spaces s + JOIN mercury_values v ON s.id = v.id; + +CREATE OR REPLACE VIEW mercury_groups_vw + AS + SELECT DISTINCT + unnest(vw."values") AS user_id, + vw.name AS group_id + FROM mercury_registry_vw vw + WHERE vw.space::text = 'mercury.groups'::text; + +CREATE OR REPLACE VIEW mercury_group_rules_vw + AS + WITH + tt as ( + SELECT DISTINCT + vw.name AS group_id, + unnest(vw."values") AS rules + FROM mercury_registry_vw vw + WHERE vw.space::text = 'mercury.policy'::text + ) + SELECT tt.group_id, + split_part(tt.rules::text, ' '::text, 1) AS role, + split_part(tt.rules::text, ' '::text, 2) AS type, + split_part(tt.rules::text, ' '::text, 3) AS match + FROM tt; + +CREATE OR REPLACE VIEW mercury_user_rules_vw + AS + WITH + tt as ( + SELECT DISTINCT + vw.name AS group_id, + unnest(vw."values") AS rules + FROM mercury_registry_vw vw + WHERE vw.space::text = 'mercury.policy'::text + ) + SELECT + g.user_id, + split_part(tt.rules::text, ' '::text, 1) AS role, + split_part(tt.rules::text, ' '::text, 2) AS type, + split_part(tt.rules::text, ' '::text, 3) AS match + FROM mercury_groups_vw g + JOIN tt ON g.group_id::text = tt.group_id::text; + +CREATE OR REPLACE VIEW mercury_rules_vw + AS + SELECT + 'U-'::text || vw.user_id::text AS id, + vw.role, + vw.type, + vw.match + FROM mercury_user_rules_vw vw + UNION + SELECT + 'G-'::text || vw.group_id::text AS id, + vw.role, + vw.type, + vw.match + FROM mercury_group_rules_vw vw; + +CREATE OR REPLACE VIEW mercury_notify_vw + AS + WITH + tt as ( + SELECT DISTINCT + vw.name, + unnest(vw."values") AS rules + FROM mercury_registry_vw vw + WHERE vw.space::text = 'mercury.notify'::text + ) + SELECT + tt.name, + split_part(tt.rules::text, ' '::text, 1) AS match, + split_part(tt.rules::text, ' '::text, 2) AS event, + split_part(tt.rules::text, ' '::text, 3) AS method, + split_part(tt.rules::text, ' '::text, 4) AS url + FROM tt; diff --git a/mercury/sql/init-sql3.sql b/mercury/sql/init-sql3.sql new file mode 100644 index 0000000..0be20bf --- /dev/null +++ b/mercury/sql/init-sql3.sql @@ -0,0 +1,118 @@ +CREATE TABLE IF NOT EXISTS mercury_spaces +( + space character varying NOT NULL unique, + id integer NOT NULL CONSTRAINT mercury_namespace_pk PRIMARY KEY autoincrement, + notes json NOT NULL DEFAULT '[]', + tags json NOT NULL DEFAULT '[]' +); + +CREATE TABLE IF NOT EXISTS mercury_values +( + id integer NOT NULL, + seq integer NOT NULL, + name character varying NOT NULL, + "values" json NOT NULL DEFAULT '[]', + tags json NOT NULL DEFAULT '[]', + notes json NOT NULL DEFAULT '[]', + CONSTRAINT mercury_values_pk PRIMARY KEY (id, seq) +); + +drop view if exists mercury_registry_vw; +CREATE VIEW if not exists mercury_registry_vw + AS + SELECT + s.id, + v.seq, + s.space, + v.name, + v."values", + v.notes, + v.tags + FROM mercury_spaces s + JOIN mercury_values v ON s.id = v.id; + +drop view if exists mercury_groups_vw; +CREATE VIEW if not exists mercury_groups_vw + AS + SELECT DISTINCT + j.value AS user_id, + vw.name AS group_id + FROM mercury_registry_vw vw, json_each(vw."values") j + WHERE vw.space = 'mercury.groups'; + +drop view if exists mercury_group_rules_vw; +CREATE VIEW if not exists mercury_group_rules_vw + AS + WITH + tt as ( + SELECT DISTINCT + vw.name AS group_id, + j.value AS rules + FROM mercury_registry_vw vw, json_each(vw."values") j + WHERE vw.space = 'mercury.policy' + ) + SELECT tt.group_id, + tt.rules rule, + '' AS role, + '' AS type, + ''AS match + FROM tt; + +drop view if exists mercury_user_rules_vw; +CREATE VIEW if not exists mercury_user_rules_vw + AS + WITH + tt as ( + SELECT DISTINCT + vw.name AS group_id, + j.value AS rules + FROM mercury_registry_vw vw, json_each(vw."values") j + WHERE vw.space = 'mercury.policy' + ) + SELECT + g.user_id, + tt.rules rule, + '' AS role, + '' AS type, + '' AS match + FROM mercury_groups_vw g + JOIN tt ON g.group_id = tt.group_id; + +drop view if exists mercury_rules_vw; +CREATE VIEW if not exists mercury_rules_vw + AS + SELECT + 'U-' || vw.user_id AS id, + vw.rule, + vw.role, + vw.type, + vw.match + FROM mercury_user_rules_vw vw + UNION + SELECT + 'G-' || vw.group_id AS id, + vw.rule, + vw.role, + vw.type, + vw.match + FROM mercury_group_rules_vw vw; + +drop view if exists mercury_notify_vw; +CREATE VIEW if not exists mercury_notify_vw + AS + WITH + tt as ( + SELECT DISTINCT + vw.name, + j.value AS rules + FROM mercury_registry_vw vw, json_each(vw."values") j + WHERE vw.space = 'mercury.notify' + ) + SELECT + tt.name, + tt.rules rule, + substr(tt.rules, 1, instr(tt.rules, ' ')-1) AS match, + substr(tt.rules, instr(tt.rules, ' ')+1, instr(substr(tt.rules, instr(tt.rules, ' ')+1), ' ')-1) AS event, + '' AS method, + '' as url + FROM tt; diff --git a/mercury/sql/list-string.go b/mercury/sql/list-string.go new file mode 100644 index 0000000..6b6fa2f --- /dev/null +++ b/mercury/sql/list-string.go @@ -0,0 +1,130 @@ +package sql + +import ( + "database/sql/driver" + "fmt" + "strings" + "unicode" + "unicode/utf8" +) + +type valueFn func() (v driver.Value, err error) + +func (fn valueFn) Value() (v driver.Value, err error) { + return fn() +} + +type scanFn func(value any) error + +func (fn scanFn) Scan(v any) error { + return fn(v) +} + +func listScan(e *[]string, ends [2]rune) scanFn { + return func(value any) error { + var str string + switch v := value.(type) { + case string: + str = v + case []byte: + str = string(v) + case []rune: + str = string(v) + default: + return fmt.Errorf("array must be uint64, got: %T", value) + } + + if e == nil { + *e = []string{} + } + + str = trim(str, ends[0], ends[1]) + if len(str) == 0 { + return nil + } + + for _, s := range splitComma(string(str)) { + *e = append(*e, s) + } + + return nil + } +} + +func listValue(e []string, ends [2]rune) valueFn { + return func() (value driver.Value, err error) { + var b strings.Builder + + if len(e) == 0 { + return string(ends[:]), nil + } + + _, err = b.WriteRune(ends[0]) + if err != nil { + return + } + + var arr []string + for _, s := range e { + arr = append(arr, `"`+s+`"`) + } + _, err = b.WriteString(strings.Join(arr, ",")) + if err != nil { + return + } + + _, err = b.WriteRune(ends[1]) + if err != nil { + return + } + + return b.String(), nil + } +} + +func splitComma(s string) []string { + lastQuote := rune(0) + f := func(c rune) bool { + switch { + case c == lastQuote: + lastQuote = rune(0) + return false + case lastQuote != rune(0): + return false + case unicode.In(c, unicode.Quotation_Mark): + lastQuote = c + return false + default: + return c == ',' + } + } + lis := strings.FieldsFunc(s, f) + + var out []string + for _, s := range lis { + s = trim(s, '"', '"') + out = append(out, s) + } + + return out +} + +func trim(s string, start, end rune) string { + r0, size0 := utf8.DecodeRuneInString(s) + if size0 == 0 { + return s + } + if r0 != start { + return s + } + + r1, size1 := utf8.DecodeLastRuneInString(s) + if size1 == 0 { + return s + } + if r1 != end { + return s + } + + return s[size0 : len(s)-size1] +} diff --git a/mercury/sql/notify.go b/mercury/sql/notify.go new file mode 100644 index 0000000..3f8711b --- /dev/null +++ b/mercury/sql/notify.go @@ -0,0 +1,55 @@ +package sql + +import ( + "context" + "strings" + + "github.com/Masterminds/squirrel" + + "go.sour.is/pkg/lg" + "go.sour.is/pkg/mercury" +) + +// Notify stores the attributes for a registry space +type Notify struct { + Name string `json:"name" view:"mercury_notify_vw"` + Match string `json:"match"` + Event string `json:"event"` + Method string `json:"-" db:"method"` + URL string `json:"-" db:"url"` +} + +// GetNotify get list of rules +func (pgm *sqlHandler) GetNotify(ctx context.Context, event string) (lis mercury.ListNotify, err error) { + ctx, span := lg.Span(ctx) + defer span.End() + + rows, err := squirrel.Select(`"name"`, `"match"`, `"event"`, `"method"`, `"url"`, `"rule"`). + From("mercury_notify_vw"). + Where(squirrel.Eq{"event": event}). + PlaceholderFormat(squirrel.Dollar). + RunWith(pgm.db). + QueryContext(context.TODO()) + + if err != nil { + return nil, err + } + + defer rows.Close() + for rows.Next() { + var s mercury.Notify + var rule string + err = rows.Scan(&s.Name, &s.Match, &s.Event, &s.Method, &s.URL, &rule) + if err != nil { + return nil, err + } + if rule != "" { + s.Match, rule, _ = strings.Cut(rule, " ") + s.Event, rule, _ = strings.Cut(rule, " ") + s.Method, s.URL, _ = strings.Cut(rule, " ") + } + lis = append(lis, s) + } + + return lis, rows.Err() +} diff --git a/mercury/sql/otel.go b/mercury/sql/otel.go new file mode 100644 index 0000000..354067a --- /dev/null +++ b/mercury/sql/otel.go @@ -0,0 +1,40 @@ +package sql + +import ( + "database/sql" + + "go.nhat.io/otelsql" + semconv "go.opentelemetry.io/otel/semconv/v1.20.0" +) + +func openDB(driver, dsn string) (*sql.DB, error) { + system := semconv.DBSystemPostgreSQL + if driver == "sqlite" { + system = semconv.DBSystemSqlite + } + + // Register the otelsql wrapper for the provided postgres driver. + driverName, err := otelsql.Register(driver, + otelsql.AllowRoot(), + otelsql.TraceQueryWithoutArgs(), + otelsql.TraceRowsClose(), + otelsql.TraceRowsAffected(), + // otelsql.WithDatabaseName("my_database"), // Optional. + otelsql.WithSystem(system), // Optional. + ) + if err != nil { + return nil, err + } + + // Connect to a Postgres database using the postgres driver wrapper. + db, err := sql.Open(driverName, dsn) + if err != nil { + return nil, err + } + + if err := otelsql.RecordStats(db); err != nil { + return nil, err + } + + return db, nil +} diff --git a/mercury/sql/rules.go b/mercury/sql/rules.go new file mode 100644 index 0000000..90808be --- /dev/null +++ b/mercury/sql/rules.go @@ -0,0 +1,99 @@ +package sql + +import ( + "context" + "fmt" + "strings" + + "github.com/Masterminds/squirrel" + "go.sour.is/pkg/lg" + "go.sour.is/pkg/mercury" + "go.sour.is/pkg/ident" +) + +type grouper interface { + GetGroups() []string +} + +// GetRules get list of rules +func (p *sqlHandler) GetRules(ctx context.Context, user ident.Ident) (lis mercury.Rules, err error) { + ctx, span := lg.Span(ctx) + defer span.End() + + var ids []string + ids = append(ids, "U-"+user.Identity()) + switch u := user.(type) { + case grouper: + for _, g := range u.GetGroups() { + ids = append(ids, "G-"+g) + } + } + if groups, err := p.getGroups(ctx, user.Identity()); err != nil { + for _, g := range groups { + ids = append(ids, "G-"+g) + } + } + + query := squirrel.Select(`"role"`, `"type"`, `"match"`, `"rule"`). + From("mercury_rules_vw"). + Where(squirrel.Eq{"id": ids}). + PlaceholderFormat(squirrel.Dollar) + rows, err := query. + RunWith(p.db). + QueryContext(ctx) + + if err != nil { + span.RecordError(err) + return nil, err + } + + defer rows.Close() + for rows.Next() { + var s mercury.Rule + var rule string + err = rows.Scan(&s.Role, &s.Type, &s.Match, &rule) + if err != nil { + span.RecordError(err) + return nil, err + } + if rule != "" { + s.Role, rule, _ = strings.Cut(rule, " ") + s.Type, s.Match, _ = strings.Cut(rule, " ") + } + lis = append(lis, s) + } + err = rows.Err() + span.RecordError(err) + + span.AddEvent(fmt.Sprint("read rules ", len(lis))) + return lis, err +} + +// getGroups get list of groups +func (pgm *sqlHandler) getGroups(ctx context.Context, user string) (lis []string, err error) { + ctx, span := lg.Span(ctx) + defer span.End() + + rows, err := squirrel.Select("group_id"). + From("mercury_groups_vw"). + Where(squirrel.Eq{"user_id": user}). + PlaceholderFormat(squirrel.Dollar). + RunWith(pgm.db). + QueryContext(ctx) + + if err != nil { + return nil, err + } + + defer rows.Close() + for rows.Next() { + var s string + err = rows.Scan(&s) + if err != nil { + return nil, err + } + lis = append(lis, s) + } + + return lis, rows.Err() +} diff --git a/mercury/sql/sql.go b/mercury/sql/sql.go new file mode 100644 index 0000000..92193a1 --- /dev/null +++ b/mercury/sql/sql.go @@ -0,0 +1,419 @@ +package sql + +import ( + "context" + "database/sql" + "fmt" + "log" + "strings" + + sq "github.com/Masterminds/squirrel" + "go.sour.is/pkg/lg" + "go.sour.is/pkg/mercury" + "go.sour.is/pkg/rsql" + "golang.org/x/exp/maps" +) + +type sqlHandler struct { + db *sql.DB + paceholderFormat sq.PlaceholderFormat + listFormat [2]rune +} + +var ( + _ mercury.GetIndex = (*sqlHandler)(nil) + _ mercury.GetConfig = (*sqlHandler)(nil) + _ mercury.GetRules = (*sqlHandler)(nil) + _ mercury.WriteConfig = (*sqlHandler)(nil) +) + +func Register() { + mercury.Registry.Register("sql", func(s *mercury.Space) any { + var dsn string + var opts strings.Builder + var dbtype string + for _, c := range s.List { + if c.Name == "match" { + continue + } + if c.Name == "dbtype" { + dbtype = c.First() + continue + } + if c.Name == "dsn" { + dsn = c.First() + break + } + fmt.Fprintln(&opts, c.Name, "=", c.First()) + } + if dsn == "" { + dsn = opts.String() + } + + db, err := openDB(dbtype, dsn) + if err != nil { + return err + } + if err = db.Ping(); err != nil { + return err + } + switch dbtype { + case "sqlite": + return &sqlHandler{db, sq.Dollar, [2]rune{'[', ']'}} + case "postgres": + return &sqlHandler{db, sq.Dollar, [2]rune{'{', '}'}} + default: + return fmt.Errorf("unsupported dbtype: %s", dbtype) + } + }) +} + +type Space struct { + mercury.Space + ID uint64 +} +type Value struct { + mercury.Value + ID uint64 +} + +func (p *sqlHandler) GetIndex(ctx context.Context, search mercury.NamespaceSearch, pgm *rsql.Program) (mercury.Config, error) { + ctx, span := lg.Span(ctx) + defer span.End() + + cols := rsql.GetDbColumns(mercury.Space{}) + where, err := getWhere(search, cols) + if err != nil { + return nil, err + } + lis, err := p.listSpace(ctx, nil, where) + if err != nil { + log.Println(err) + return nil, err + } + + config := make(mercury.Config, len(lis)) + for i, s := range lis { + config[i] = &s.Space + } + + return config, nil +} + +func (p *sqlHandler) GetConfig(ctx context.Context, search mercury.NamespaceSearch, pgm *rsql.Program, fields []string) (mercury.Config, error) { + ctx, span := lg.Span(ctx) + defer span.End() + + idx, err := p.GetIndex(ctx, search, pgm) + if err != nil { + return nil, err + } + spaceMap := make(map[string]int, len(idx)) + for u, s := range idx { + spaceMap[s.Space] = u + } + + where, err := getWhere(search, rsql.GetDbColumns(mercury.Value{})) + if err != nil { + return nil, err + } + query := sq.Select(`"space"`, `"name"`, `"seq"`, `"notes"`, `"tags"`, `"values"`). + From("mercury_registry_vw"). + Where(where). + OrderBy("space asc", "name asc"). + PlaceholderFormat(p.paceholderFormat) + span.AddEvent(lg.LogQuery(query.ToSql())) + rows, err := query.RunWith(p.db). + QueryContext(ctx) + + if err != nil { + log.Println(err) + return nil, err + } + + defer rows.Close() + for rows.Next() { + var s mercury.Value + + err = rows.Scan( + &s.Space, + &s.Name, + &s.Seq, + listScan(&s.Notes, p.listFormat), + listScan(&s.Tags, p.listFormat), + listScan(&s.Values, p.listFormat), + ) + if err != nil { + return nil, err + } + if u, ok := spaceMap[s.Space]; ok { + idx[u].List = append(idx[u].List, s) + } + } + + err = rows.Err() + span.RecordError(err) + + span.AddEvent(fmt.Sprint("read index ", len(idx))) + return idx, err +} + +func (p *sqlHandler) listSpace(ctx context.Context, tx sq.BaseRunner, where sq.Sqlizer) ([]*Space, error) { + ctx, span := lg.Span(ctx) + defer span.End() + + if tx == nil { + tx = p.db + } + + query := sq.Select(`"id"`, `"space"`, `"tags"`, `"notes"`). + From("mercury_spaces"). + Where(where). + OrderBy("space asc"). + PlaceholderFormat(sq.Dollar) + span.AddEvent(lg.LogQuery(query.ToSql())) + rows, err := query.RunWith(tx). + QueryContext(ctx) + + if err != nil { + log.Println(err) + return nil, err + } + defer rows.Close() + + var lis []*Space + for rows.Next() { + var s Space + err = rows.Scan( + &s.ID, + &s.Space.Space, + listScan(&s.Space.Notes, p.listFormat), + listScan(&s.Space.Tags, p.listFormat), + ) + if err != nil { + return nil, err + } + lis = append(lis, &s) + } + + err = rows.Err() + span.RecordError(err) + + span.AddEvent(fmt.Sprint("read config ", len(lis))) + return lis, err +} + +// WriteConfig writes a config map to database +func (p *sqlHandler) WriteConfig(ctx context.Context, config mercury.Config) (err error) { + ctx, span := lg.Span(ctx) + defer span.End() + + // Delete spaces that are present in input but are empty. + deleteSpaces := make(map[string]struct{}) + + // get names of each space + var names = make(map[string]int) + for i, v := range config { + names[v.Space] = i + + if len(v.Tags) == 0 && len(v.Notes) == 0 && len(v.List) == 0 { + deleteSpaces[v.Space] = struct{}{} + } + } + + tx, err := p.db.Begin() + if err != nil { + return err + } + defer func() { + if err != nil && tx != nil { + tx.Rollback() + } + }() + + // get current spaces + lis, err := p.listSpace(ctx, tx, sq.Eq{"space": maps.Keys(names)}) + if err != nil { + return + } + + // determine which are being updated + var deleteIDs []uint64 + var updateIDs []uint64 + var currentNames = make(map[string]struct{}, len(lis)) + var updateSpaces []*mercury.Space + var insertSpaces []*mercury.Space + + for _, s := range lis { + spaceName := s.Space.Space + currentNames[spaceName] = struct{}{} + + if _, ok := deleteSpaces[spaceName]; ok { + deleteIDs = append(deleteIDs, s.ID) + continue + } + + updateSpaces = append(updateSpaces, config[names[spaceName]]) + updateIDs = append(updateIDs, s.ID) + } + for _, s := range config { + spaceName := s.Space + if _, ok := currentNames[spaceName]; !ok { + insertSpaces = append(insertSpaces, s) + } + } + + // delete spaces + if ids := deleteIDs; len(ids) > 0 { + _, err = sq.Delete("mercury_spaces").Where(sq.Eq{"id": ids}).RunWith(tx).PlaceholderFormat(sq.Dollar).ExecContext(ctx) + if err != nil { + return err + } + } + + // delete values + if ids := append(updateIDs, deleteIDs...); len(ids) > 0 { + _, err = sq.Delete("mercury_values").Where(sq.Eq{"id": ids}).RunWith(tx).PlaceholderFormat(sq.Dollar).ExecContext(ctx) + if err != nil { + return err + } + } + + var newValues []*Value + + // update spaces + for i, u := range updateSpaces { + query := sq.Update("mercury_spaces"). + Where(sq.Eq{"id": updateIDs[i]}). + Set("tags", listValue(u.Tags, p.listFormat)). + Set("notes", listValue(u.Notes, p.listFormat)). + PlaceholderFormat(sq.Dollar) + span.AddEvent(lg.LogQuery(query.ToSql())) + _, err := query.RunWith(tx).ExecContext(ctx) + + if err != nil { + return err + } + // log.Debugf("UPDATED %d SPACES", len(updateSpaces)) + for _, v := range u.List { + newValues = append(newValues, &Value{Value: v, ID: updateIDs[i]}) + } + } + + // insert spaces + for _, s := range insertSpaces { + var id uint64 + query := sq.Insert("mercury_spaces"). + PlaceholderFormat(sq.Dollar). + Columns("space", "tags", "notes"). + Values(s.Space, listValue(s.Tags, p.listFormat), listValue(s.Notes, p.listFormat)). + Suffix("RETURNING \"id\"") + span.AddEvent(lg.LogQuery(query.ToSql())) + + err := query. + RunWith(tx). + QueryRowContext(ctx). + Scan(&id) + if err != nil { + s, v, _ := query.ToSql() + log.Println(s, v, err) + return err + } + for _, v := range s.List { + newValues = append(newValues, &Value{Value: v, ID: id}) + } + } + + // write all values to db. + err = p.writeValues(ctx, tx, newValues) + // log.Debugf("WROTE %d ATTRS", len(attrs)) + + tx.Commit() + tx = nil + + return +} + +// writeValues writes the values to db +func (p *sqlHandler) writeValues(ctx context.Context, tx sq.BaseRunner, lis []*Value) (err error) { + ctx, span := lg.Span(ctx) + defer span.End() + + if len(lis) == 0 { + return nil + } + + newInsert := func() sq.InsertBuilder { + return sq.Insert("mercury_values"). + RunWith(tx). + PlaceholderFormat(sq.Dollar). + Columns( + `"id"`, + `"seq"`, + `"name"`, + `"values"`, + `"notes"`, + `"tags"`, + ) + } + chunk := int(65000 / 3) + insert := newInsert() + for i, s := range lis { + insert = insert.Values( + s.ID, + s.Seq, + s.Name, + listValue(s.Values, p.listFormat), + listValue(s.Notes, p.listFormat), + listValue(s.Tags, p.listFormat), + ) + // log.Debug(s.Name) + + if i > 0 && i%chunk == 0 { + // log.Debugf("inserting %v rows into %v", i%chunk, d.Table) + // log.Debug(insert.ToSql()) + span.AddEvent(lg.LogQuery(insert.ToSql())) + + _, err = insert.ExecContext(ctx) + if err != nil { + // log.Error(err) + return + } + + insert = newInsert() + } + } + if len(lis)%chunk > 0 { + // log.Debugf("inserting %v rows into %v", len(lis)%chunk, d.Table) + // log.Debug(insert.ToSql()) + span.AddEvent(lg.LogQuery(insert.ToSql())) + + _, err = insert.ExecContext(ctx) + if err != nil { + // log.Error(err) + return + } + } + + return +} + +func getWhere(search mercury.NamespaceSearch, d *rsql.DbColumns) (sq.Sqlizer, error) { + var where sq.Or + space, err := d.Col("space") + if err != nil { + return nil, err + } + for _, m := range search { + switch m.(type) { + case mercury.NamespaceNode: + where = append(where, sq.Eq{space: m.Value()}) + case mercury.NamespaceStar: + where = append(where, sq.Like{space: m.Value()}) + case mercury.NamespaceTrace: + e := sq.Expr(`? LIKE `+space+` || '%'`, m.Value()) + where = append(where, e) + } + } + return where, nil +} diff --git a/mux/httpmux.go b/mux/httpmux.go index add065f..18982be 100644 --- a/mux/httpmux.go +++ b/mux/httpmux.go @@ -8,6 +8,7 @@ type mux struct { *http.ServeMux api *http.ServeMux wellknown *http.ServeMux + handler http.Handler } func (mux *mux) Add(fns ...interface{ RegisterHTTP(*http.ServeMux) }) { @@ -24,16 +25,30 @@ func (mux *mux) Add(fns ...interface{ RegisterHTTP(*http.ServeMux) }) { // log.Printf("WellKnown: %T", fn) fn.RegisterWellKnown(mux.wellknown) } + + if fn, ok := fn.(interface{ RegisterMiddleware(http.Handler) http.Handler }); ok { + hdlr := mux.handler + // log.Printf("WellKnown: %T", fn) + mux.handler = fn.RegisterMiddleware(hdlr) + } } } + +func (mux *mux) ServeHTTP(w http.ResponseWriter, r *http.Request) { + mux.handler.ServeHTTP(w, r) +} + + func New() *mux { mux := &mux{ api: http.NewServeMux(), wellknown: http.NewServeMux(), ServeMux: http.NewServeMux(), } + mux.Handle("/v1/", http.StripPrefix("/v1", mux.api)) mux.Handle("/api/v1/", http.StripPrefix("/api/v1", mux.api)) mux.Handle("/.well-known/", http.StripPrefix("/.well-known", mux.wellknown)) + mux.handler = mux.ServeMux return mux } diff --git a/rsql/ast.go b/rsql/ast.go new file mode 100644 index 0000000..ca441cd --- /dev/null +++ b/rsql/ast.go @@ -0,0 +1,255 @@ +package rsql + +import ( + "bytes" + "strings" +) + +// Node is the smallest unit of ast +type Node interface { + TokenLiteral() string + String() string +} + +// Statement is a executable tree +type Statement interface { + Node + statementNode() +} + +// Expression is a portion of tree +type Expression interface { + Node + expressionNode() +} + +// Identifier is a variable name +type Identifier struct { + Token Token + Value string +} + +func (i *Identifier) expressionNode() {} + +// TokenLiteral returns the literal value of a token +func (i *Identifier) TokenLiteral() string { return i.Token.Literal } + +// String returns a string representation of value +func (i *Identifier) String() string { return i.Value } + +// Integer is a numeric value +type Integer struct { + Token Token + Value int64 +} + +func (i *Integer) expressionNode() {} + +// TokenLiteral returns the literal value of a token +func (i *Integer) TokenLiteral() string { return i.Token.Literal } + +// String returns a string representation of value +func (i *Integer) String() string { return i.Token.Literal } + +// Float is a floating point value +type Float struct { + Token Token + Value float64 +} + +func (i *Float) expressionNode() {} + +// TokenLiteral returns the literal value of a token +func (i *Float) TokenLiteral() string { return i.Token.Literal } + +// String returns a string representation of value +func (i *Float) String() string { return i.Token.Literal } + +// Bool is a boolean value +type Bool struct { + Token Token + Value bool +} + +func (i *Bool) expressionNode() {} + +// TokenLiteral returns the literal value of a token +func (i *Bool) TokenLiteral() string { return i.Token.Literal } + +// String returns a string representation of value +func (i *Bool) String() string { return i.Token.Literal } + +// Null is an empty value +type Null struct { + Token Token +} + +func (i *Null) expressionNode() {} + +// TokenLiteral returns the literal value of a token +func (i *Null) TokenLiteral() string { return i.Token.Literal } + +// String returns a string representation of value +func (i *Null) String() string { return i.Token.Literal } + +// String is an array of codepoints +type String struct { + Token Token + Value string +} + +func (i *String) expressionNode() {} + +// TokenLiteral returns the literal value of a token +func (i *String) TokenLiteral() string { return i.Token.Literal } + +// String returns a string representation of value +func (i *String) String() string { + var out bytes.Buffer + + out.WriteRune('"') + out.WriteString(i.Value) + out.WriteRune('"') + + return out.String() +} + +// Array is an array of tokens +type Array struct { + Token Token + Elements []Expression +} + +func (a *Array) expressionNode() {} + +// TokenLiteral returns the literal value of a token +func (a *Array) TokenLiteral() string { return a.Token.Literal } + +// String returns a string representation of value +func (a *Array) String() string { + var out bytes.Buffer + + var elements []string + for _, el := range a.Elements { + elements = append(elements, el.String()) + } + + out.WriteRune('(') + out.WriteString(strings.Join(elements, ",")) + out.WriteRune(')') + + return out.String() +} + +// Program is a collection of statements +type Program struct { + Statements []Statement + Args map[string]string +} + +func (p *Program) expressionNode() {} + +// TokenLiteral returns the literal value of a token +func (p *Program) TokenLiteral() string { + if len(p.Statements) > 0 { + return p.Statements[0].TokenLiteral() + } + return "" +} + +// String returns a string representation of value +func (p *Program) String() string { + var out bytes.Buffer + + for _, s := range p.Statements { + out.WriteString(s.String()) + } + if len(p.Args) > 0 { + out.WriteRune('|') + for a, b := range p.Args { + out.WriteRune(' ') + out.WriteString(a) + out.WriteRune(':') + out.WriteString(b) + } + } + return out.String() +} + +// ExpressionStatement is a collection of expressions +type ExpressionStatement struct { + Token Token + Expression Expression +} + +func (ExpressionStatement) statementNode() {} + +// TokenLiteral returns the literal value of a token +func (e ExpressionStatement) TokenLiteral() string { return e.Token.Literal } + +// String returns a string representation of value +func (e ExpressionStatement) String() string { + if e.Expression != nil { + return e.Expression.String() + } + + return "" +} + +// PrefixExpression is an expression with a preceeding operator +type PrefixExpression struct { + Token Token + Operator string + Right Expression +} + +func (p *PrefixExpression) expressionNode() {} + +// TokenLiteral returns the literal value of a token +func (p *PrefixExpression) TokenLiteral() string { return p.Token.Literal } + +// String returns a string representation of value +func (p *PrefixExpression) String() string { + var out bytes.Buffer + + out.WriteRune('(') + out.WriteString(p.Operator) + out.WriteString(p.Right.String()) + out.WriteRune(')') + + return out.String() +} + +// InfixExpression is two expressions with a infix operator +type InfixExpression struct { + Token Token + Left Expression + Operator string + Right Expression +} + +func (i *InfixExpression) expressionNode() {} + +// TokenLiteral returns the literal value of a token +func (i *InfixExpression) TokenLiteral() string { return i.Token.Literal } + +// String returns a string representation of value +func (i *InfixExpression) String() string { + var out bytes.Buffer + + out.WriteRune('(') + if i.Left != nil { + out.WriteString(i.Left.String()) + } else { + out.WriteString("nil") + } + out.WriteString(i.Operator) + if i.Right != nil { + out.WriteString(i.Right.String()) + } else { + out.WriteString("nil") + } + out.WriteRune(')') + + return out.String() +} diff --git a/rsql/ast_test.go b/rsql/ast_test.go new file mode 100644 index 0000000..0277301 --- /dev/null +++ b/rsql/ast_test.go @@ -0,0 +1,21 @@ +package rsql + +import "testing" + +func TestString(t *testing.T) { + program := &Program{ + Statements: []Statement { + ExpressionStatement{ + Token: Token{TokEQ, "=="}, + Expression: &InfixExpression{ + Token: Token{TokEQ, "=="}, + Left: &Identifier{Token{TokIdent,"foo"}, "foo"}, + Operator: "==", + Right: &Integer{Token{TokInteger, "5"}, 5}, + }, + }, + }, + } + + t.Log(program.String()) +} \ No newline at end of file diff --git a/rsql/dbcolumns.go b/rsql/dbcolumns.go new file mode 100644 index 0000000..e5fc263 --- /dev/null +++ b/rsql/dbcolumns.go @@ -0,0 +1,90 @@ +package rsql + +import ( + "fmt" + "reflect" + "strings" + "unicode" +) + +// DbColumns database model metadata +type DbColumns struct { + Cols []string + index map[string]int + Table string + View string +} + +// Col returns the mapped column names +func (d *DbColumns) Col(column string) (s string, err error) { + idx, ok := d.Index(column) + if !ok { + err = fmt.Errorf("column not found on table: %v", column) + return + } + return d.Cols[idx], err +} + +// Index returns the column number +func (d *DbColumns) Index(column string) (idx int, ok bool) { + idx, ok = d.index[column] + return +} + +// GetDbColumns builds a metadata struct +func GetDbColumns(o interface{}) *DbColumns { + d := DbColumns{} + t := reflect.TypeOf(o) + + d.index = make(map[string]int) + for i := 0; i < t.NumField(); i++ { + field := t.Field(i) + + sp := append(strings.Split(field.Tag.Get("db"), ","), "") + + tag := sp[0] + + json := field.Tag.Get("json") + if tag == "" { + tag = json + } + + graphql := field.Tag.Get("graphql") + if tag == "" { + tag = graphql + } + + if tag == "-" { + continue + } + + if tag == "" { + tag = field.Name + } + + d.index[field.Name] = len(d.Cols) + + if _, ok := d.index[tag]; !ok && tag != "" { + d.index[tag] = len(d.Cols) + } + if _, ok := d.index[json]; !ok && json != "" { + d.index[json] = len(d.Cols) + } + if _, ok := d.index[graphql]; !ok && graphql != "" { + d.index[graphql] = len(d.Cols) + } else if !ok && graphql == "" { + a := []rune(field.Name) + for i := 0; i < len(a); i++ { + if unicode.IsLower(a[i]) { + break + } + a[i] = unicode.ToLower(a[i]) + } + graphql = string(a) + d.index[graphql] = len(d.Cols) + } + + d.Cols = append(d.Cols, tag) + } + return &d +} diff --git a/rsql/lexer.go b/rsql/lexer.go new file mode 100644 index 0000000..80e91e3 --- /dev/null +++ b/rsql/lexer.go @@ -0,0 +1,258 @@ +package rsql + +import ( + "unicode" + "unicode/utf8" +) + +type Lexer struct { + input string + position int + readPosition int + rune rune +} + +// NewLexer returns a new lexing generator +func NewLexer(in string) *Lexer { + l := &Lexer{input: in} + l.readRune() + return l +} + +// NextToken returns the next token from lexer +func (l *Lexer) NextToken() Token { + var tok Token + + l.skipSpace() + + switch l.rune { + case '-': + l.readRune() + if isNumber(l.rune) { + var isFloat bool + tok.Literal, isFloat = l.readNumber() + if isFloat { + tok.Type = TokFloat + } else { + tok.Type = TokInteger + } + + } else if isLetter(l.rune) { + tok.Literal = l.readIdentifier() + tok.Type = lookupIdent(tok.Literal) + + } else { + tok = newToken(TokIllegal, l.rune) + return tok + } + + tok.Literal = "-" + tok.Literal + return tok + case '=': + r := l.peekRune() + if r == '=' { + r := l.rune + l.readRune() + tok.Type, tok.Literal = TokEQ, string(r)+string(l.rune) + } else if isLetter(r) { + tok = l.readFIQL() + + return tok + } else { + tok = newToken(TokIllegal, l.rune) + } + case ';': + tok = newToken(TokAND, l.rune) + case ',': + tok = newToken(TokOR, l.rune) + case ')': + tok = newToken(TokRParen, l.rune) + case '(': + tok = newToken(TokLParen, l.rune) + case ']': + tok = newToken(TokRBracket, l.rune) + case '[': + tok = newToken(TokLBracket, l.rune) + case '~': + tok = newToken(TokLIKE, l.rune) + case '!': + if l.peekRune() == '=' { + r := l.rune + l.readRune() + tok.Type, tok.Literal = TokNEQ, string(r)+string(l.rune) + } else if l.peekRune() == '~' { + r := l.rune + l.readRune() + tok.Type, tok.Literal = TokNLIKE, string(r)+string(l.rune) + } else { + tok = newToken(TokIllegal, l.rune) + return tok + } + case '<': + if l.peekRune() == '=' { + r := l.rune + l.readRune() + tok.Type, tok.Literal = TokLE, string(r)+string(l.rune) + } else { + tok = newToken(TokLT, l.rune) + } + case '>': + if l.peekRune() == '=' { + r := l.rune + l.readRune() + tok.Type, tok.Literal = TokGE, string(r)+string(l.rune) + } else { + tok = newToken(TokGT, l.rune) + } + case '"', '\'': + tok.Type = TokString + tok.Literal = l.readString(l.rune) + case 0: + tok.Type, tok.Literal = TokEOF, "" + default: + if isNumber(l.rune) { + var isFloat bool + tok.Literal, isFloat = l.readNumber() + if isFloat { + tok.Type = TokFloat + } else { + tok.Type = TokInteger + } + + } else if isLetter(l.rune) { + tok.Literal = l.readIdentifier() + tok.Type = lookupIdent(tok.Literal) + + } else { + tok = newToken(TokIllegal, l.rune) + return tok + } + + return tok + } + + l.readRune() + return tok +} + +func (l *Lexer) readRune() { + var size int + if l.readPosition >= len(l.input) { + l.rune = 0 + } else { + l.rune, size = utf8.DecodeRuneInString(l.input[l.readPosition:]) + } + + l.position = l.readPosition + l.readPosition += size +} +func (l *Lexer) peekRune() rune { + if l.readPosition >= len(l.input) { + return 0 + } + r, _ := utf8.DecodeRuneInString(l.input[l.readPosition:]) + return r +} + +func (l *Lexer) skipSpace() { + for unicode.IsSpace(l.rune) { + l.readRune() + } +} + +func (l *Lexer) readIdentifier() string { + position := l.position + if isLetter(l.rune) { + l.readRune() + } + + for isLetter(l.rune) || isNumber(l.rune) { + l.readRune() + } + + return l.input[position:l.position] +} + +func (l *Lexer) readNumber() (string, bool) { + isFloat := false + + position := l.position + for isNumber(l.rune) { + if l.rune == '.' { + isFloat = true + } + + l.readRune() + } + + return l.input[position:l.position], isFloat +} +func (l *Lexer) readString(st rune) string { + position := l.position + 1 + escape := false + for { + l.readRune() + + if l.rune == '\\' { + escape = true + + continue + } + if escape { + escape = false + continue + } + if l.rune == st || l.rune == 0 { + break + } + } + + return l.input[position:l.position] + +} +func (l *Lexer) readFIQL() Token { + l.readRune() + s := l.readIdentifier() + if l.rune != '=' { + return Token{TokIllegal, "=" + s} + } + l.readRune() + + switch s { + case "eq": + return Token{TokEQ, "=" + s + "="} + case "neq": + return Token{TokNEQ, "=" + s + "="} + case "gt": + return Token{TokGT, "=" + s + "="} + case "ge": + return Token{TokGE, "=" + s + "="} + case "lt": + return Token{TokLT, "=" + s + "="} + case "le": + return Token{TokLE, "=" + s + "="} + default: + return Token{TokExtend, "=" + s + "="} + } +} + +func isLetter(r rune) bool { + if unicode.IsSpace(r) { + return false + } + switch r { + case '"', '\'', '(', ')', ';', ',', '=', '!', '~', '<', '>', '[', ']': + return false + } + if '0' < r && r < '9' || r == '.' { + return false + } + + return unicode.IsPrint(r) +} +func isNumber(r rune) bool { + if '0' <= r && r <= '9' || r == '.' { + return true + } + return false +} diff --git a/rsql/lexer_test.go b/rsql/lexer_test.go new file mode 100644 index 0000000..02710b4 --- /dev/null +++ b/rsql/lexer_test.go @@ -0,0 +1,105 @@ +package rsql + +import ( + "testing" + + "github.com/matryer/is" +) + +func TestReservedToken(t *testing.T) { + input := `( ) ; , == != ~ < > <= >= [ ]` + tests := []struct { + expectedType TokenType + expectedLiteral string + }{ + {TokLParen, "("}, + {TokRParen, ")"}, + {TokAND, ";"}, + {TokOR, ","}, + {TokEQ, "=="}, + {TokNEQ, "!="}, + {TokLIKE, "~"}, + {TokLT, "<"}, + {TokGT, ">"}, + {TokLE, "<="}, + {TokGE, ">="}, + {TokLBracket, "["}, + {TokRBracket, "]"}, + } + + t.Run("Reserved Tokens", func(t *testing.T) { + is := is.New(t) + + l := NewLexer(input) + + for _, tt := range tests { + tok := l.NextToken() + is.Equal(tt.expectedType, tok.Type) + is.Equal(tt.expectedLiteral, tok.Literal) + } + }) +} +func TestNextToken(t *testing.T) { + input := `director=='name\'s';actor=eq="name's";Year=le=2000,Year>=2010;(one <= -1.0, two != true),three=in=(1,2,3);c4==5` + tests := []struct { + expectedType TokenType + expectedLiteral string + }{ + {TokIdent, `director`}, + {TokEQ, `==`}, + {TokString, `name\'s`}, + {TokAND, `;`}, + {TokIdent, `actor`}, + {TokEQ, `=eq=`}, + {TokString, `name's`}, + {TokAND, `;`}, + {TokIdent, "Year"}, + {TokLE, "=le="}, + {TokInteger, "2000"}, + {TokOR, ","}, + {TokIdent, "Year"}, + {TokGE, ">="}, + {TokInteger, "2010"}, + {TokAND, ";"}, + {TokLParen, "("}, + {TokIdent, "one"}, + {TokLE, "<="}, + {TokFloat, "-1.0"}, + {TokOR, ","}, + {TokIdent, "two"}, + {TokNEQ, "!="}, + {TokTRUE, "true"}, + {TokRParen, ")"}, + {TokOR, ","}, + {TokIdent, "three"}, + {TokExtend, "=in="}, + {TokLParen, "("}, + {TokInteger, "1"}, + {TokOR, ","}, + {TokInteger, "2"}, + {TokOR, ","}, + {TokInteger, "3"}, + {TokRParen, ")"}, + {TokAND, ";"}, + {TokIdent, "c4"}, + {TokEQ, "=="}, + {TokInteger, "5"}, + } + + t.Run("Next Token Parsing", func(t *testing.T) { + is := is.New(t) + + l := NewLexer(input) + + c := 0 + for _, tt := range tests { + c++ + tok := l.NextToken() + + is.Equal(tt.expectedType, tok.Type) + is.Equal(tt.expectedLiteral, tok.Literal) + + } + is.Equal(c, len(tests)) + }) +} diff --git a/rsql/parser.go b/rsql/parser.go new file mode 100644 index 0000000..0da0fc8 --- /dev/null +++ b/rsql/parser.go @@ -0,0 +1,285 @@ +package rsql + +import ( + "fmt" + "strconv" + "strings" +) + +// Precidence enumerations +const ( + _ = iota + PrecedenceLowest + PrecedenceAND + PrecedenceOR + PrecedenceCompare + PrecedenceHighest +) + +var precidences = map[TokenType]int{ + TokEQ: PrecedenceCompare, + TokNEQ: PrecedenceCompare, + TokLT: PrecedenceCompare, + TokLE: PrecedenceCompare, + TokGT: PrecedenceCompare, + TokGE: PrecedenceCompare, + TokLIKE: PrecedenceCompare, + TokOR: PrecedenceOR, + TokAND: PrecedenceAND, +} + +type ( + prefixParseFn func() Expression + infixParseFn func(expression Expression) Expression +) + +// Parser reads lexed values and builds an AST +type Parser struct { + l *Lexer + errors []string + + curToken Token + peekToken Token + + prefixParseFns map[TokenType]prefixParseFn + infixParseFns map[TokenType]infixParseFn +} + +// NewParser returns a parser for a given lexer +func NewParser(l *Lexer) *Parser { + p := &Parser{l: l} + + p.prefixParseFns = make(map[TokenType]prefixParseFn) + p.registerPrefix(TokIdent, p.parseIdentifier) + p.registerPrefix(TokInteger, p.parseInteger) + p.registerPrefix(TokFloat, p.parseFloat) + p.registerPrefix(TokTRUE, p.parseBool) + p.registerPrefix(TokFALSE, p.parseBool) + p.registerPrefix(TokNULL, p.parseNull) + p.registerPrefix(TokString, p.parseString) + p.registerPrefix(TokLParen, p.parseGroupedExpression) + p.registerPrefix(TokLBracket, p.parseArray) + + p.infixParseFns = make(map[TokenType]infixParseFn) + p.registerInfix(TokEQ, p.parseInfixExpression) + p.registerInfix(TokNEQ, p.parseInfixExpression) + p.registerInfix(TokLT, p.parseInfixExpression) + p.registerInfix(TokLE, p.parseInfixExpression) + p.registerInfix(TokGT, p.parseInfixExpression) + p.registerInfix(TokGE, p.parseInfixExpression) + p.registerInfix(TokLIKE, p.parseInfixExpression) + p.registerInfix(TokAND, p.parseInfixExpression) + p.registerInfix(TokOR, p.parseInfixExpression) + + p.nextToken() + p.nextToken() + + return p +} + +// DefaultParse sets up a default lex/parse and returns the program +func DefaultParse(in string) *Program { + args := make(map[string]string) + in, argstr, ok := strings.Cut(in, "|") + if ok { + for _, fd := range strings.Fields(argstr) { + a, b, _ := strings.Cut(fd, ":") + args[a] = b + } + } + + l := NewLexer(in) + p := NewParser(l) + return p.ParseProgram(args) +} + +func (p *Parser) registerPrefix(tokenType TokenType, fn prefixParseFn) { + p.prefixParseFns[tokenType] = fn +} +func (p *Parser) registerInfix(tokenType TokenType, fn infixParseFn) { + p.infixParseFns[tokenType] = fn +} + +// Errors returns a list of errors while parsing +func (p *Parser) Errors() []string { + return p.errors +} +func (p *Parser) peekError(t TokenType) { + msg := fmt.Sprintf("expected next token to be %s, got %s instad", + t, p.peekToken.Type) + p.errors = append(p.errors, msg) +} + +func (p *Parser) nextToken() { + p.curToken = p.peekToken + p.peekToken = p.l.NextToken() +} +func (p *Parser) curTokenIs(t TokenType) bool { + return p.curToken.Type == t +} +func (p *Parser) peekTokenIs(t TokenType) bool { + return p.peekToken.Type == t +} +func (p *Parser) expectPeek(t TokenType) bool { + if p.peekTokenIs(t) { + p.nextToken() + return true + } + p.peekError(t) + return false +} +func (p *Parser) peekPrecedence() int { + if p, ok := precidences[p.peekToken.Type]; ok { + return p + } + return PrecedenceLowest +} +func (p *Parser) curPrecedence() int { + if p, ok := precidences[p.curToken.Type]; ok { + return p + } + return PrecedenceLowest +} + +// ParseProgram builds a program AST from lexer +func (p *Parser) ParseProgram(args map[string]string) *Program { + program := &Program{Args: args} + program.Statements = []Statement{} + + for p.curToken.Type != TokEOF { + stmt := p.parseStatement() + if stmt != nil { + program.Statements = append(program.Statements, stmt) + } + p.nextToken() + } + + return program +} +func (p *Parser) parseStatement() Statement { + switch p.curToken.Type { + default: + return p.parseExpressionStatement() + } +} +func (p *Parser) parseExpressionStatement() *ExpressionStatement { + stmt := &ExpressionStatement{Token: p.curToken} + stmt.Expression = p.parseExpression(PrecedenceLowest) + + return stmt +} +func (p *Parser) parseExpression(precedence int) Expression { + prefix := p.prefixParseFns[p.curToken.Type] + if prefix == nil { + msg := fmt.Sprintf("no prefix parse function for %s found", p.curToken.Type) + p.errors = append(p.errors, msg) + return nil + } + leftExp := prefix() + + for !p.peekTokenIs(TokEOF) && precedence < p.peekPrecedence() { + infix := p.infixParseFns[p.peekToken.Type] + if infix == nil { + return leftExp + } + + p.nextToken() + + leftExp = infix(leftExp) + } + + return leftExp +} +func (p *Parser) parseIdentifier() Expression { + return &Identifier{Token: p.curToken, Value: p.curToken.Literal} +} +func (p *Parser) parseInteger() Expression { + lit := &Integer{Token: p.curToken} + + value, err := strconv.ParseInt(p.curToken.Literal, 0, 64) + if err != nil { + msg := fmt.Sprintf("could not parse %q as integer", p.curToken.Literal) + p.errors = append(p.errors, msg) + return nil + } + + lit.Value = value + return lit +} +func (p *Parser) parseFloat() Expression { + lit := &Float{Token: p.curToken} + + value, err := strconv.ParseFloat(p.curToken.Literal, 64) + if err != nil { + msg := fmt.Sprintf("could not parse %q as float", p.curToken.Literal) + p.errors = append(p.errors, msg) + return nil + } + + lit.Value = value + return lit +} +func (p *Parser) parseBool() Expression { + return &Bool{Token: p.curToken, Value: p.curTokenIs(TokTRUE)} +} +func (p *Parser) parseString() Expression { + s := p.curToken.Literal + s = strings.Replace(s, `\'`, `'`, -1) + s = strings.Replace(s, `\"`, `"`, -1) + + return &String{Token: p.curToken, Value: s} +} +func (p *Parser) parseNull() Expression { + return &Null{Token: p.curToken} +} +func (p *Parser) parseArray() Expression { + array := &Array{Token: p.curToken} + array.Elements = p.parseExpressionList(TokRBracket) + return array +} +func (p *Parser) parseExpressionList(end TokenType) []Expression { + var list []Expression + + if p.peekTokenIs(end) { + p.nextToken() + return list + } + + p.nextToken() + list = append(list, p.parseExpression(PrecedenceHighest)) + for p.peekTokenIs(TokOR) { + p.nextToken() + p.nextToken() + list = append(list, p.parseExpression(PrecedenceHighest)) + } + + if !p.expectPeek(end) { + return nil + } + + return list +} +func (p *Parser) parseInfixExpression(left Expression) Expression { + expression := &InfixExpression{ + Token: p.curToken, + Left: left, + Operator: p.curToken.Literal, + } + + precidence := p.curPrecedence() + p.nextToken() + expression.Right = p.parseExpression(precidence) + + return expression +} +func (p *Parser) parseGroupedExpression() Expression { + p.nextToken() + + exp := p.parseExpression(PrecedenceLowest) + + if !p.expectPeek(TokRParen) { + return nil + } + + return exp +} diff --git a/rsql/parser_test.go b/rsql/parser_test.go new file mode 100644 index 0000000..14b3153 --- /dev/null +++ b/rsql/parser_test.go @@ -0,0 +1,309 @@ +package rsql + +import ( + "fmt" + "strings" + "testing" + + "github.com/matryer/is" +) + +func typeOf(t any) string { return fmt.Sprintf("%T", t) } + +func TestIdentifierExpression(t *testing.T) { + input := `foobar` + + t.Run("Identifier Expressions", func(t *testing.T) { + is := is.New(t) + + l := NewLexer(input) + p := NewParser(l) + + program := p.ParseProgram(nil) + checkParserErrors(t, p) + + is.Equal(len(program.Statements), 1) + // if len(program.Statements) != 1 { + // t.Fatalf("program has not envough statements. got=%d", len(program.Statements)) + // } + }) +} + +func TestIntegerExpression(t *testing.T) { + input := `5` + + t.Run("IntegerExpression", func(t *testing.T) { + is := is.New(t) + + l := NewLexer(input) + p := NewParser(l) + + program := p.ParseProgram(nil) + checkParserErrors(t, p) + + is.Equal(len(program.Statements), 1) + // if len(program.Statements) != 1 { + // t.Fatalf("program has not enough statements. got=%d", len(program.Statements)) + // } + + stmt, ok := program.Statements[0].(*ExpressionStatement) + is.Equal(typeOf(program.Statements[0]), typeOf(&ExpressionStatement{})) + is.True(ok) + // if !ok { + // t.Fatalf("program.Statements[0] is not ExpressionStatement got=%T", + // program.Statements[0]) + // } + + literal, ok := stmt.Expression.(*Integer) + is.Equal(typeOf(literal), typeOf(&Integer{})) + is.True(ok) + // if !ok { + // t.Fatalf("stmt.Expression is not Integer got=%T", + // stmt.Expression) + // } + + is.Equal(literal.Value, int64(5)) + // if literal.Value != 5 { + // t.Errorf("literal.Value not %d. got=%d", 5, literal.Value) + // } + + is.Equal(literal.TokenLiteral(), "5") + // if literal.TokenLiteral() != "5" { + // t.Errorf("literal.TokenLiteral not %v. got=%v", "5", literal.TokenLiteral()) + // } + }) +} + +func TestInfixExpression(t *testing.T) { + tests := []struct { + input string + left string + operator string + right int64 + }{ + {"foo == 1", "foo", "==", 1}, + {"bar > 2", "bar", ">", 2}, + {"bin < 3", "bin", "<", 3}, + {"baz != 4", "baz", "!=", 4}, + {"buf >= 5", "buf", ">=", 5}, + {"goz <= 6", "goz", "<=", 6}, + } + t.Run("Infix Expressions", func(t *testing.T) { + is := is.New(t) + + for _, tt := range tests { + l := NewLexer(tt.input) + p := NewParser(l) + program := p.ParseProgram(nil) + checkParserErrors(t, p) + + is.Equal(len(program.Statements), 1) + // if len(program.Statements) != 1 { + // t.Fatalf("program has not envough statements. got=%d", len(program.Statements)) + // } + + stmt, ok := program.Statements[0].(*ExpressionStatement) + is.Equal(typeOf(stmt), typeOf(&ExpressionStatement{})) + is.True(ok) + // if !ok { + // t.Fatalf("program.Statements[0] is not ExpressionStatement got=%T", + // program.Statements[0]) + // } + + exp, ok := stmt.Expression.(*InfixExpression) + is.Equal(typeOf(exp), typeOf(&InfixExpression{})) + is.True(ok) + // if !ok { + // t.Fatalf("stmt.Expression is not InfixExpression got=%T", + // stmt.Expression) + // } + + if !testIdentifier(t, exp.Left, tt.left) { + return + } + + is.Equal(exp.Operator, tt.operator) + // if exp.Operator != tt.operator { + // t.Fatalf("exp.Operator is not '%v'. got '%v'", tt.operator, exp.Operator) + // } + + if testInteger(t, exp.Right, tt.right) { + return + } + } + }) +} + +func TestOperatorPrecedenceParsing(t *testing.T) { + tests := []struct { + input string + expect string + }{ + { + "foo == 1; bar == 2.0", + "((foo==1);(bar==2.0))", + }, + { + `director=='name\'s';actor=eq="name\'s";Year=le=2000,Year>=2010;one <= -1.0, two != true`, + `((((director=="name's");(actor=eq="name's"));((Year=le=2000),(Year>=2010)));((one<=-1.0),(two!=true)))`, + }, + } + t.Run("Operator Precidence Parsing", func(t *testing.T) { + is := is.New(t) + + for _, tt := range tests { + l := NewLexer(tt.input) + p := NewParser(l) + program := p.ParseProgram(nil) + checkParserErrors(t, p) + + actual := program.String() + is.Equal(actual, tt.expect) + // if actual != tt.expect { + // t.Errorf("expcected=%q, got=%q", tt.expect, actual) + // } + } + }) +} + +func TestParsingArray(t *testing.T) { + input := "[1, 2.1, true, null]" + + l := NewLexer(input) + p := NewParser(l) + program := p.ParseProgram(nil) + checkParserErrors(t, p) + + if len(program.Statements) != 1 { + t.Fatalf("program has not enough statements. got=%d", len(program.Statements)) + } + + stmt, ok := program.Statements[0].(*ExpressionStatement) + if !ok { + t.Fatalf("program.Statements[0] is not ExpressionStatement got=%T", + program.Statements[0]) + } + + array, ok := stmt.Expression.(*Array) + if !ok { + t.Fatalf("stmt.Expression is not Array got=%T", + stmt.Expression) + } + + if len(array.Elements) != 4 { + t.Fatalf("len(array.Elements) not 4. got=%v", len(array.Elements)) + } + + testInteger(t, array.Elements[0], 1) + testFloat(t, array.Elements[1], 2.1) + testBool(t, array.Elements[2], true) + testNull(t, array.Elements[3]) +} + +func checkParserErrors(t *testing.T, p *Parser) { + errors := p.Errors() + if len(errors) == 0 { + return + } + + t.Errorf("parser has %d errors", len(errors)) + for _, msg := range errors { + t.Errorf("parser error: %q", msg) + } + t.FailNow() +} + +func testInteger(t *testing.T, e Expression, value int64) bool { + literal, ok := e.(*Integer) + if !ok { + t.Errorf("stmt.Expression is not Integer got=%T", e) + return false + } + + if literal.Value != value { + t.Errorf("literal.Value not %d. got=%d", value, literal.Value) + return false + } + + if literal.TokenLiteral() != fmt.Sprintf("%v", value) { + t.Errorf("literal.TokenLiteral not %v. got=%v", value, literal.TokenLiteral()) + return false + } + + return true +} +func testFloat(t *testing.T, e Expression, value float64) bool { + literal, ok := e.(*Float) + if !ok { + t.Errorf("stmt.Expression is not Float got=%T", e) + return false + } + + if literal.Value != value { + t.Errorf("literal.Value not %f. got=%f", value, literal.Value) + return false + } + + if literal.TokenLiteral() != fmt.Sprintf("%v", value) { + t.Errorf("literal.TokenLiteral not %q. got=%q", fmt.Sprintf("%v", value), literal.TokenLiteral()) + return false + } + + return true +} +func testBool(t *testing.T, e Expression, value bool) bool { + literal, ok := e.(*Bool) + if !ok { + t.Errorf("stmt.Expression is not Float got=%T", e) + return false + } + + if literal.Value != value { + t.Errorf("literal.Value not %t. got=%t", value, literal.Value) + return false + } + + if literal.TokenLiteral() != fmt.Sprintf("%v", value) { + t.Errorf("literal.TokenLiteral not %v. got=%v", value, literal.TokenLiteral()) + return false + } + + return true +} +func testNull(t *testing.T, e Expression) bool { + literal, ok := e.(*Null) + if !ok { + t.Errorf("stmt.Expression is not Null got=%T", e) + return false + } + if literal.Token.Type != TokNULL { + t.Errorf("liternal.Token is not TokNULL got=%T", e) + return false + } + + return true +} +func testIdentifier(t *testing.T, e Expression, value string) bool { + literal, ok := e.(*Identifier) + if !ok { + t.Errorf("stmt.Expression is not Integer got=%T", e) + return false + } + + if literal.Value != value { + t.Errorf("literal.Value not %s. got=%s", value, literal.Value) + return false + } + + if literal.TokenLiteral() != value { + t.Errorf("literal.TokenLiteral not %v. got=%v", value, literal.TokenLiteral()) + return false + } + + return true +} + +func TestReplace(t *testing.T) { + is := is.New(t) + is.Equal(strings.Replace(`name\'s`, `\'`, `'`, -1), `name's`) +} diff --git a/rsql/squirrel/sqlizer.go b/rsql/squirrel/sqlizer.go new file mode 100644 index 0000000..49c0892 --- /dev/null +++ b/rsql/squirrel/sqlizer.go @@ -0,0 +1,300 @@ +package squirrel + +import ( + "fmt" + "strconv" + "strings" + + "github.com/Masterminds/squirrel" + "go.sour.is/pkg/rsql" +) + +type dbInfo interface { + Col(string) (string, error) +} + +type args map[string]string + +func (d *decoder) mkArgs(a args) args { + m := make(args, len(a)) + for k, v := range a { + if k == "limit" || k == "offset" { + m[k] = v + continue + } + + var err error + if k, err = d.dbInfo.Col(k); err == nil { + m[k] = v + + } + } + + return m +} + +func (a args) Limit() (uint64, bool) { + if a == nil { + return 0, false + } + if v, ok := a["limit"]; ok { + i, err := strconv.ParseUint(v, 10, 64) + return i, err == nil + } + + return 0, false +} +func (a args) Offset() (uint64, bool) { + if a == nil { + return 0, false + } + if v, ok := a["offset"]; ok { + i, err := strconv.ParseUint(v, 10, 64) + return i, err == nil + } + + return 0, false +} +func (a args) Order() []string { + var lis []string + + for k, v := range a { + if k == "limit" || k == "offset" { + continue + } + lis = append(lis, k+" "+v) + } + + return lis +} + +func Query(in string, db dbInfo) (squirrel.Sqlizer, args, error) { + d := decoder{dbInfo: db} + program := rsql.DefaultParse(in) + sql, err := d.decode(program) + return sql, d.mkArgs(program.Args), err +} + +type decoder struct { + dbInfo dbInfo +} + +func (db *decoder) decode(in *rsql.Program) (squirrel.Sqlizer, error) { + switch len(in.Statements) { + case 0: + return nil, nil + case 1: + return db.decodeStatement(in.Statements[0]) + default: + a := squirrel.And{} + for _, stmt := range in.Statements { + d, err := db.decodeStatement(stmt) + if d == nil { + return nil, err + } + + a = append(a, d) + } + return a, nil + } +} +func (db *decoder) decodeStatement(in rsql.Statement) (squirrel.Sqlizer, error) { + switch s := in.(type) { + case *rsql.ExpressionStatement: + return db.decodeExpression(s.Expression) + } + return nil, nil +} +func (db *decoder) decodeExpression(in rsql.Expression) (squirrel.Sqlizer, error) { + switch e := in.(type) { + case *rsql.InfixExpression: + return db.decodeInfix(e) + } + return nil, nil +} +func (db *decoder) decodeInfix(in *rsql.InfixExpression) (squirrel.Sqlizer, error) { + + switch in.Token.Type { + case rsql.TokAND: + a := squirrel.And{} + left, err := db.decodeExpression(in.Left) + if err != nil { + return nil, err + } + + switch v := left.(type) { + case squirrel.And: + for _, el := range v { + if el != nil { + a = append(a, el) + } + } + + default: + if v != nil { + a = append(a, v) + } + } + + right, err := db.decodeExpression(in.Right) + if err != nil { + return nil, err + } + + switch v := right.(type) { + case squirrel.And: + for _, el := range v { + if el != nil { + a = append(a, el) + } + } + + default: + if v != nil { + a = append(a, v) + } + } + + return a, nil + case rsql.TokOR: + left, err := db.decodeExpression(in.Left) + if err != nil { + return nil, err + } + right, err := db.decodeExpression(in.Right) + if err != nil { + return nil, err + } + + return squirrel.Or{left, right}, nil + case rsql.TokEQ: + col, err := db.dbInfo.Col(in.Left.String()) + if err != nil { + return nil, err + } + v, err := db.decodeValue(in.Right) + if err != nil { + return nil, err + } + + return squirrel.Eq{col: v}, nil + case rsql.TokLIKE: + col, err := db.dbInfo.Col(in.Left.String()) + if err != nil { + return nil, err + } + + v, err := db.decodeValue(in.Right) + if err != nil { + return nil, err + } + + switch value := v.(type) { + case string: + return Like{col, strings.Replace(value, "*", "%", -1)}, nil + default: + return nil, fmt.Errorf("LIKE requires a string value") + } + + case rsql.TokNEQ: + col, err := db.dbInfo.Col(in.Left.String()) + if err != nil { + return nil, err + } + v, err := db.decodeValue(in.Right) + if err != nil { + return nil, err + } + + return squirrel.NotEq{col: v}, nil + case rsql.TokGT: + col, err := db.dbInfo.Col(in.Left.String()) + if err != nil { + return nil, err + } + v, err := db.decodeValue(in.Right) + if err != nil { + return nil, err + } + + return squirrel.Gt{col: v}, nil + case rsql.TokGE: + col, err := db.dbInfo.Col(in.Left.String()) + if err != nil { + return nil, err + } + v, err := db.decodeValue(in.Right) + if err != nil { + return nil, err + } + + return squirrel.GtOrEq{col: v}, nil + case rsql.TokLT: + col, err := db.dbInfo.Col(in.Left.String()) + if err != nil { + return nil, err + } + v, err := db.decodeValue(in.Right) + if err != nil { + return nil, err + } + + return squirrel.Lt{col: v}, nil + case rsql.TokLE: + col, err := db.dbInfo.Col(in.Left.String()) + if err != nil { + return nil, err + } + v, err := db.decodeValue(in.Right) + if err != nil { + return nil, err + } + + return squirrel.LtOrEq{col: v}, nil + default: + return nil, nil + } +} +func (db *decoder) decodeValue(in rsql.Expression) (interface{}, error) { + switch v := in.(type) { + case *rsql.Array: + var values []interface{} + for _, el := range v.Elements { + v, err := db.decodeValue(el) + if err != nil { + return nil, err + } + + values = append(values, v) + } + + return values, nil + case *rsql.InfixExpression: + return db.decodeInfix(v) + case *rsql.Identifier: + return v.Value, nil + case *rsql.Integer: + return v.Value, nil + case *rsql.Float: + return v.Value, nil + case *rsql.String: + return v.Value, nil + case *rsql.Bool: + return v.Value, nil + case *rsql.Null: + return nil, nil + } + + return nil, nil +} + +type Like struct { + column string + value string +} + +func (l Like) ToSql() (sql string, args []interface{}, err error) { + sql = fmt.Sprintf("%s LIKE(?)", l.column) + args = append(args, l.value) + return +} diff --git a/rsql/squirrel/sqlizer_test.go b/rsql/squirrel/sqlizer_test.go new file mode 100644 index 0000000..f7bb292 --- /dev/null +++ b/rsql/squirrel/sqlizer_test.go @@ -0,0 +1,141 @@ +package squirrel + +import ( + "fmt" + "testing" + + "github.com/Masterminds/squirrel" + "github.com/matryer/is" + "go.sour.is/pkg/rsql" +) + +type testTable struct { + Foo string `json:"foo"` + Bar string `json:"bar"` + Baz string `json:"baz"` + Director string `json:"director"` + Actor string `json:"actor"` + Year string `json:"year"` + Genres string `json:"genres"` + One string `json:"one"` + Two string `json:"two"` + Family string `json:"family_name"` +} + +func TestQuery(t *testing.T) { + d := rsql.GetDbColumns(testTable{}) + is := is.New(t) + + tests := []struct { + input string + expect squirrel.Sqlizer + expectLimit *uint64 + expectOffset *uint64 + expectOrder []string + fail bool + }{ + {input: "foo==[1, 2, 3]", expect: squirrel.Eq{"foo": []interface{}{1, 2, 3}}}, + {input: "foo==1,(bar==2;baz==3)", expect: squirrel.Or{squirrel.Eq{"foo": 1}, squirrel.And{squirrel.Eq{"bar": 2}, squirrel.Eq{"baz": 3}}}}, + + {input: "foo==1", expect: squirrel.Eq{"foo": 1}}, + {input: "foo!=1.1", expect: squirrel.NotEq{"foo": 1.1}}, + {input: "foo==true", expect: squirrel.Eq{"foo": true}}, + {input: "foo==null", expect: squirrel.Eq{"foo": nil}}, + {input: "foo>2", expect: squirrel.Gt{"foo": 2}}, + {input: "foo>=2.1", expect: squirrel.GtOrEq{"foo": 2.1}}, + {input: "foo<3", expect: squirrel.Lt{"foo": 3}}, + {input: "foo<=3.1", expect: squirrel.LtOrEq{"foo": 3.1}}, + + {input: "foo=eq=1", expect: squirrel.Eq{"foo": 1}}, + {input: "foo=neq=1.1", expect: squirrel.NotEq{"foo": 1.1}}, + {input: "foo=gt=2", expect: squirrel.Gt{"foo": 2}}, + {input: "foo=ge=2.1", expect: squirrel.GtOrEq{"foo": 2.1}}, + {input: "foo=lt=3", expect: squirrel.Lt{"foo": 3}}, + {input: "foo=le=3.1", expect: squirrel.LtOrEq{"foo": 3.1}}, + + {input: "foo==1;bar==2", expect: squirrel.And{squirrel.Eq{"foo": 1}, squirrel.Eq{"bar": 2}}}, + {input: "foo==1,bar==2", expect: squirrel.Or{squirrel.Eq{"foo": 1}, squirrel.Eq{"bar": 2}}}, + {input: "foo==1,bar==2;baz=3", expect: nil}, + { + input: `director=='name\'s';actor=eq="name\'s";Year=le=2000,Year>=2010;one <= -1.0, two != true`, + expect: squirrel.And{ + squirrel.Eq{"director": "name's"}, + squirrel.Eq{"actor": "name's"}, + squirrel.Or{ + squirrel.LtOrEq{"year": 2000}, + squirrel.GtOrEq{"year": 2010}, + }, + squirrel.Or{ + squirrel.LtOrEq{"one": -1.0}, + squirrel.NotEq{"two": true}, + }, + }, + }, + { + input: `genres==[sci-fi,action] ; genres==[romance,animated,horror] , director~Que*Tarantino`, + expect: squirrel.And{ + squirrel.Eq{"genres": []interface{}{"sci-fi", "action"}}, + squirrel.Or{ + squirrel.Eq{"genres": []interface{}{"romance", "animated", "horror"}}, + Like{"director", "Que%Tarantino"}, + }, + }, + }, + {input: "", expect: nil}, + {input: "family_name==LUNDY", expect: squirrel.Eq{"family_name": "LUNDY"}}, + {input: "family_name==[1,2,null]", expect: squirrel.Eq{"family_name": []interface{}{1, 2, nil}}}, + {input: "family_name=LUNDY", expect: nil}, + {input: "family_name==LUNDY and family_name==SMITH", expect: squirrel.And{squirrel.Eq{"family_name": "LUNDY"}, squirrel.Eq{"family_name": "SMITH"}}}, + {input: "family_name==LUNDY or family_name==SMITH", expect: squirrel.Or{squirrel.Eq{"family_name": "LUNDY"}, squirrel.Eq{"family_name": "SMITH"}}}, + {input: "foo==1,family_name=LUNDY;baz==2", expect: nil}, + {input: "foo ~ bar*", expect: Like{"foo", "bar%"}}, + {input: "foo ~ [bar*,bin*]", expect: nil, fail: true}, + {input: "foo==1|limit:10", expect: squirrel.Eq{"foo": 1}, expectLimit: ptr(uint64(10))}, + {input: "foo==1|offset:2", expect: squirrel.Eq{"foo": 1}, expectOffset: ptr(uint64(2))}, + { + input: "foo>=1|limit:10 offset:2 foo:desc", + expect: squirrel.GtOrEq{"foo": 1}, + expectLimit: ptr(uint64(10)), + expectOffset: ptr(uint64(2)), + expectOrder: []string{"foo desc"}, + }, + } + + for i, tt := range tests { + q, a, err := Query(tt.input, d) + if !tt.fail && err != nil { + t.Error(err) + } + if q != nil { + t.Log(q.ToSql()) + } + + actual := fmt.Sprintf("%#v", q) + expect := fmt.Sprintf("%#v", tt.expect) + if expect != actual { + t.Errorf("test[%d]: %v\n\tinput and expected are not the same. wanted=%s got=%s", i, tt.input, expect, actual) + } + + if limit, ok := a.Limit(); tt.expectLimit != nil { + is.True(ok) + is.Equal(limit, *tt.expectLimit) + } else { + is.True(!ok) + } + + if offset, ok := a.Offset(); tt.expectOffset != nil { + is.True(ok) + is.Equal(offset, *tt.expectOffset) + } else { + is.True(!ok) + } + + if order := a.Order(); tt.expectOrder != nil { + is.Equal(order, tt.expectOrder) + } else { + is.Equal(len(order), 0) + } + } +} + +func ptr[T any](t T) *T { return &t } diff --git a/rsql/token.go b/rsql/token.go new file mode 100644 index 0000000..28de2a7 --- /dev/null +++ b/rsql/token.go @@ -0,0 +1,62 @@ +package rsql + +// Tokens for RSQL FIQL +const ( + TokIllegal = "TokIllegal" + TokEOF = "TokEOF" + + TokIdent = "TokIdent" + TokInteger = "TokInteger" + TokString = "TokString" + TokFloat = "TokFloat" + TokExtend = "TokExtend" + + TokLParen = "(" + TokRParen = ")" + + TokLBracket = "[" + TokRBracket = "]" + + TokLIKE = "~" + TokNLIKE= "!~" + TokNOT = "!" + TokLT = "<" + TokGT = ">" + TokLE = "<=" + TokGE = ">=" + TokEQ = "==" + TokNEQ = "!=" + TokAND = ";" + TokOR = "," + + TokTRUE = "true" + TokFALSE = "false" + TokNULL = "null" +) + +var keywords = map[string]TokenType { + "true": TokTRUE, + "false": TokFALSE, + "null": TokNULL, + "and": TokAND, + "or": TokOR, +} + +// TokenType is a token enumeration +type TokenType string +// Token is a type and literal pair +type Token struct { + Type TokenType + Literal string +} + +func newToken(tokenType TokenType, ch rune) Token { + return Token{Type: tokenType, Literal: string(ch)} +} + +func lookupIdent(ident string) TokenType { + if tok, ok := keywords[ident]; ok { + return tok + } + return TokIdent +} \ No newline at end of file diff --git a/service/service.go b/service/service.go index b891b82..fd085d6 100644 --- a/service/service.go +++ b/service/service.go @@ -35,10 +35,11 @@ func (s *Harness) Setup(ctx context.Context, apps ...application) error { ctx, span := lg.Span(ctx) defer span.End() + s.onRunning = make(chan struct{}) + // setup crontab c := cron.New(cron.DefaultGranularity) s.OnStart(c.Run) - s.onRunning = make(chan struct{}) s.crontab = c var err error diff --git a/set/set.go b/set/set.go index 4567d9d..21f358a 100644 --- a/set/set.go +++ b/set/set.go @@ -6,6 +6,7 @@ import ( "strings" "go.sour.is/pkg/math" + "golang.org/x/exp/maps" ) type Set[T comparable] map[T]struct{} @@ -33,6 +34,9 @@ func (s Set[T]) Delete(items ...T) Set[T] { } return s } +func (s Set[T]) Values() []T { + return maps.Keys(s) +} func (s Set[T]) Equal(e Set[T]) bool { for k := range s { diff --git a/xdg/xdg.go b/xdg/xdg.go index 70a27af..0c37931 100644 --- a/xdg/xdg.go +++ b/xdg/xdg.go @@ -4,6 +4,7 @@ package xdg import ( + "errors" "os" "path/filepath" "strings" @@ -36,6 +37,9 @@ func setENV(name, value string) string { return literal(name) } func Get(base, suffix string) string { + return strings.Join(paths(base, suffix), string(os.PathListSeparator)) +} +func paths(base, suffix string) []string { paths := strings.Split(os.ExpandEnv(base), string(os.PathListSeparator)) for i, path := range paths { if strings.HasPrefix(path, "~") { @@ -43,7 +47,17 @@ func Get(base, suffix string) string { } paths[i] = os.ExpandEnv(filepath.Join(path, suffix)) } - return strings.Join(paths, string(os.PathListSeparator)) + return paths +} +func Find(base, filename string) []string { + var files []string + for _, f := range paths(base, filename) { + if ok, _ := exists(f); !ok { + continue + } + files = append(files, f) + } + return files } func getHome() string { @@ -53,3 +67,17 @@ func getHome() string { } return home } + +func exists(name string) (bool, error) { + s, err := os.Stat(name) + if err == nil { + return true, nil + } + if errors.Is(err, os.ErrNotExist) { + return false, nil + } + if s.IsDir() { + return false, nil + } + return false, err +} -- 2.45.1 From 1f8b4ab24fad28b1eb07f47386aedf61f1b73d3e Mon Sep 17 00:00:00 2001 From: xuu Date: Fri, 5 Apr 2024 12:40:51 -0600 Subject: [PATCH 2/4] chore: mercury changes --- go.mod | 1 + go.work | 6 + go.work.sum | 334 ++++++++++++++++++++++++++++++++++++++ ident/ident.go | 16 +- ident/routes.go | 327 ++++++++++++------------------------- ident/source/mercury.go | 133 +++++++++++++-- ident/source/session.go | 83 ++++++++++ mercury/mercury.go | 70 +++++--- mercury/namespace.go | 16 +- mercury/namespace_test.go | 59 +++++++ mercury/parse.go | 33 +++- mercury/parse_test.go | 28 ++++ mercury/public/index.html | 2 +- mercury/public/style.css | 10 +- mercury/registry.go | 36 ++-- mercury/routes.go | 16 +- mercury/sql/init-pg.sql | 4 +- mercury/sql/init-sql3.sql | 6 +- mercury/sql/sql.go | 13 +- 19 files changed, 895 insertions(+), 298 deletions(-) create mode 100644 go.work create mode 100644 go.work.sum create mode 100644 ident/source/session.go create mode 100644 mercury/namespace_test.go create mode 100644 mercury/parse_test.go diff --git a/go.mod b/go.mod index d66e264..f8cf90d 100644 --- a/go.mod +++ b/go.mod @@ -61,6 +61,7 @@ require ( github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.1 // indirect github.com/oklog/ulid/v2 v2.1.0 github.com/prometheus/client_golang v1.18.0 + github.com/tursodatabase/go-libsql v0.0.0-20240322134723-08771dcdd2f1 go.nhat.io/otelsql v0.12.0 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.48.0 go.opentelemetry.io/contrib/instrumentation/runtime v0.48.0 diff --git a/go.work b/go.work new file mode 100644 index 0000000..538e4c9 --- /dev/null +++ b/go.work @@ -0,0 +1,6 @@ +go 1.22.0 + +use ( + . + ../go-tools +) diff --git a/go.work.sum b/go.work.sum new file mode 100644 index 0000000..370b28e --- /dev/null +++ b/go.work.sum @@ -0,0 +1,334 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.112.0/go.mod h1:3jEEVwZ/MHU4djK5t5RHuKOA/GbLddgTdVubX1qnPD4= +cloud.google.com/go/accessapproval v1.7.5/go.mod h1:g88i1ok5dvQ9XJsxpUInWWvUBrIZhyPDPbk4T01OoJ0= +cloud.google.com/go/accesscontextmanager v1.8.5/go.mod h1:TInEhcZ7V9jptGNqN3EzZ5XMhT6ijWxTGjzyETwmL0Q= +cloud.google.com/go/aiplatform v1.60.0/go.mod h1:eTlGuHOahHprZw3Hio5VKmtThIOak5/qy6pzdsqcQnM= +cloud.google.com/go/analytics v0.23.0/go.mod h1:YPd7Bvik3WS95KBok2gPXDqQPHy08TsCQG6CdUCb+u0= +cloud.google.com/go/apigateway v1.6.5/go.mod h1:6wCwvYRckRQogyDDltpANi3zsCDl6kWi0b4Je+w2UiI= +cloud.google.com/go/apigeeconnect v1.6.5/go.mod h1:MEKm3AiT7s11PqTfKE3KZluZA9O91FNysvd3E6SJ6Ow= +cloud.google.com/go/apigeeregistry v0.8.3/go.mod h1:aInOWnqF4yMQx8kTjDqHNXjZGh/mxeNlAf52YqtASUs= +cloud.google.com/go/appengine v1.8.5/go.mod h1:uHBgNoGLTS5di7BvU25NFDuKa82v0qQLjyMJLuPQrVo= +cloud.google.com/go/area120 v0.8.5/go.mod h1:BcoFCbDLZjsfe4EkCnEq1LKvHSK0Ew/zk5UFu6GMyA0= +cloud.google.com/go/artifactregistry v1.14.7/go.mod h1:0AUKhzWQzfmeTvT4SjfI4zjot72EMfrkvL9g9aRjnnM= +cloud.google.com/go/asset v1.17.2/go.mod h1:SVbzde67ehddSoKf5uebOD1sYw8Ab/jD/9EIeWg99q4= +cloud.google.com/go/assuredworkloads v1.11.5/go.mod h1:FKJ3g3ZvkL2D7qtqIGnDufFkHxwIpNM9vtmhvt+6wqk= +cloud.google.com/go/automl v1.13.5/go.mod h1:MDw3vLem3yh+SvmSgeYUmUKqyls6NzSumDm9OJ3xJ1Y= +cloud.google.com/go/baremetalsolution v1.2.4/go.mod h1:BHCmxgpevw9IEryE99HbYEfxXkAEA3hkMJbYYsHtIuY= +cloud.google.com/go/batch v1.8.0/go.mod h1:k8V7f6VE2Suc0zUM4WtoibNrA6D3dqBpB+++e3vSGYc= +cloud.google.com/go/beyondcorp v1.0.4/go.mod h1:Gx8/Rk2MxrvWfn4WIhHIG1NV7IBfg14pTKv1+EArVcc= +cloud.google.com/go/bigquery v1.59.1/go.mod h1:VP1UJYgevyTwsV7desjzNzDND5p6hZB+Z8gZJN1GQUc= +cloud.google.com/go/billing v1.18.2/go.mod h1:PPIwVsOOQ7xzbADCwNe8nvK776QpfrOAUkvKjCUcpSE= +cloud.google.com/go/binaryauthorization v1.8.1/go.mod h1:1HVRyBerREA/nhI7yLang4Zn7vfNVA3okoAR9qYQJAQ= +cloud.google.com/go/certificatemanager v1.7.5/go.mod h1:uX+v7kWqy0Y3NG/ZhNvffh0kuqkKZIXdvlZRO7z0VtM= +cloud.google.com/go/channel v1.17.5/go.mod h1:FlpaOSINDAXgEext0KMaBq/vwpLMkkPAw9b2mApQeHc= +cloud.google.com/go/cloudbuild v1.15.1/go.mod h1:gIofXZSu+XD2Uy+qkOrGKEx45zd7s28u/k8f99qKals= +cloud.google.com/go/clouddms v1.7.4/go.mod h1:RdrVqoFG9RWI5AvZ81SxJ/xvxPdtcRhFotwdE79DieY= +cloud.google.com/go/cloudtasks v1.12.6/go.mod h1:b7c7fe4+TJsFZfDyzO51F7cjq7HLUlRi/KZQLQjDsaY= +cloud.google.com/go/compute v1.20.1/go.mod h1:4tCnrn48xsqlwSAiLf1HXMQk8CONslYbdiEZc9FEIbM= +cloud.google.com/go/compute v1.23.3/go.mod h1:VCgBUoMnIVIR0CscqQiPJLAG25E3ZRZMzcFZeQ+h8CI= +cloud.google.com/go/compute v1.24.0/go.mod h1:kw1/T+h/+tK2LJK0wiPPx1intgdAM3j/g3hFDlscY40= +cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA= +cloud.google.com/go/contactcenterinsights v1.13.0/go.mod h1:ieq5d5EtHsu8vhe2y3amtZ+BE+AQwX5qAy7cpo0POsI= +cloud.google.com/go/container v1.31.0/go.mod h1:7yABn5s3Iv3lmw7oMmyGbeV6tQj86njcTijkkGuvdZA= +cloud.google.com/go/containeranalysis v0.11.4/go.mod h1:cVZT7rXYBS9NG1rhQbWL9pWbXCKHWJPYraE8/FTSYPE= +cloud.google.com/go/datacatalog v1.19.3/go.mod h1:ra8V3UAsciBpJKQ+z9Whkxzxv7jmQg1hfODr3N3YPJ4= +cloud.google.com/go/dataflow v0.9.5/go.mod h1:udl6oi8pfUHnL0z6UN9Lf9chGqzDMVqcYTcZ1aPnCZQ= +cloud.google.com/go/dataform v0.9.2/go.mod h1:S8cQUwPNWXo7m/g3DhWHsLBoufRNn9EgFrMgne2j7cI= +cloud.google.com/go/datafusion v1.7.5/go.mod h1:bYH53Oa5UiqahfbNK9YuYKteeD4RbQSNMx7JF7peGHc= +cloud.google.com/go/datalabeling v0.8.5/go.mod h1:IABB2lxQnkdUbMnQaOl2prCOfms20mcPxDBm36lps+s= +cloud.google.com/go/dataplex v1.14.2/go.mod h1:0oGOSFlEKef1cQeAHXy4GZPB/Ife0fz/PxBf+ZymA2U= +cloud.google.com/go/dataproc v1.12.0/go.mod h1:zrF3aX0uV3ikkMz6z4uBbIKyhRITnxvr4i3IjKsKrw4= +cloud.google.com/go/dataproc/v2 v2.4.0/go.mod h1:3B1Ht2aRB8VZIteGxQS/iNSJGzt9+CA0WGnDVMEm7Z4= +cloud.google.com/go/dataqna v0.8.5/go.mod h1:vgihg1mz6n7pb5q2YJF7KlXve6tCglInd6XO0JGOlWM= +cloud.google.com/go/datastore v1.15.0/go.mod h1:GAeStMBIt9bPS7jMJA85kgkpsMkvseWWXiaHya9Jes8= +cloud.google.com/go/datastream v1.10.4/go.mod h1:7kRxPdxZxhPg3MFeCSulmAJnil8NJGGvSNdn4p1sRZo= +cloud.google.com/go/deploy v1.17.1/go.mod h1:SXQyfsXrk0fBmgBHRzBjQbZhMfKZ3hMQBw5ym7MN/50= +cloud.google.com/go/dialogflow v1.49.0/go.mod h1:dhVrXKETtdPlpPhE7+2/k4Z8FRNUp6kMV3EW3oz/fe0= +cloud.google.com/go/dlp v1.11.2/go.mod h1:9Czi+8Y/FegpWzgSfkRlyz+jwW6Te9Rv26P3UfU/h/w= +cloud.google.com/go/documentai v1.25.0/go.mod h1:ftLnzw5VcXkLItp6pw1mFic91tMRyfv6hHEY5br4KzY= +cloud.google.com/go/domains v0.9.5/go.mod h1:dBzlxgepazdFhvG7u23XMhmMKBjrkoUNaw0A8AQB55Y= +cloud.google.com/go/edgecontainer v1.1.5/go.mod h1:rgcjrba3DEDEQAidT4yuzaKWTbkTI5zAMu3yy6ZWS0M= +cloud.google.com/go/errorreporting v0.3.0/go.mod h1:xsP2yaAp+OAW4OIm60An2bbLpqIhKXdWR/tawvl7QzU= +cloud.google.com/go/essentialcontacts v1.6.6/go.mod h1:XbqHJGaiH0v2UvtuucfOzFXN+rpL/aU5BCZLn4DYl1Q= +cloud.google.com/go/eventarc v1.13.4/go.mod h1:zV5sFVoAa9orc/52Q+OuYUG9xL2IIZTbbuTHC6JSY8s= +cloud.google.com/go/filestore v1.8.1/go.mod h1:MbN9KcaM47DRTIuLfQhJEsjaocVebNtNQhSLhKCF5GM= +cloud.google.com/go/firestore v1.14.0/go.mod h1:96MVaHLsEhbvkBEdZgfN+AS/GIkco1LRpH9Xp9YZfzQ= +cloud.google.com/go/functions v1.16.0/go.mod h1:nbNpfAG7SG7Duw/o1iZ6ohvL7mc6MapWQVpqtM29n8k= +cloud.google.com/go/gkebackup v1.3.5/go.mod h1:KJ77KkNN7Wm1LdMopOelV6OodM01pMuK2/5Zt1t4Tvc= +cloud.google.com/go/gkeconnect v0.8.5/go.mod h1:LC/rS7+CuJ5fgIbXv8tCD/mdfnlAadTaUufgOkmijuk= +cloud.google.com/go/gkehub v0.14.5/go.mod h1:6bzqxM+a+vEH/h8W8ec4OJl4r36laxTs3A/fMNHJ0wA= +cloud.google.com/go/gkemulticloud v1.1.1/go.mod h1:C+a4vcHlWeEIf45IB5FFR5XGjTeYhF83+AYIpTy4i2Q= +cloud.google.com/go/grafeas v0.3.4/go.mod h1:A5m316hcG+AulafjAbPKXBO/+I5itU4LOdKO2R/uDIc= +cloud.google.com/go/gsuiteaddons v1.6.5/go.mod h1:Lo4P2IvO8uZ9W+RaC6s1JVxo42vgy+TX5a6hfBZ0ubs= +cloud.google.com/go/iam v1.1.6/go.mod h1:O0zxdPeGBoFdWW3HWmBxJsk0pfvNM/p/qa82rWOGTwI= +cloud.google.com/go/iap v1.9.4/go.mod h1:vO4mSq0xNf/Pu6E5paORLASBwEmphXEjgCFg7aeNu1w= +cloud.google.com/go/ids v1.4.5/go.mod h1:p0ZnyzjMWxww6d2DvMGnFwCsSxDJM666Iir1bK1UuBo= +cloud.google.com/go/iot v1.7.5/go.mod h1:nq3/sqTz3HGaWJi1xNiX7F41ThOzpud67vwk0YsSsqs= +cloud.google.com/go/kms v1.15.7/go.mod h1:ub54lbsa6tDkUwnu4W7Yt1aAIFLnspgh0kPGToDukeI= +cloud.google.com/go/language v1.12.3/go.mod h1:evFX9wECX6mksEva8RbRnr/4wi/vKGYnAJrTRXU8+f8= +cloud.google.com/go/lifesciences v0.9.5/go.mod h1:OdBm0n7C0Osh5yZB7j9BXyrMnTRGBJIZonUMxo5CzPw= +cloud.google.com/go/logging v1.9.0/go.mod h1:1Io0vnZv4onoUnsVUQY3HZ3Igb1nBchky0A0y7BBBhE= +cloud.google.com/go/longrunning v0.5.5/go.mod h1:WV2LAxD8/rg5Z1cNW6FJ/ZpX4E4VnDnoTk0yawPBB7s= +cloud.google.com/go/managedidentities v1.6.5/go.mod h1:fkFI2PwwyRQbjLxlm5bQ8SjtObFMW3ChBGNqaMcgZjI= +cloud.google.com/go/maps v1.6.4/go.mod h1:rhjqRy8NWmDJ53saCfsXQ0LKwBHfi6OSh5wkq6BaMhI= +cloud.google.com/go/mediatranslation v0.8.5/go.mod h1:y7kTHYIPCIfgyLbKncgqouXJtLsU+26hZhHEEy80fSs= +cloud.google.com/go/memcache v1.10.5/go.mod h1:/FcblbNd0FdMsx4natdj+2GWzTq+cjZvMa1I+9QsuMA= +cloud.google.com/go/metastore v1.13.4/go.mod h1:FMv9bvPInEfX9Ac1cVcRXp8EBBQnBcqH6gz3KvJ9BAE= +cloud.google.com/go/monitoring v1.18.0/go.mod h1:c92vVBCeq/OB4Ioyo+NbN2U7tlg5ZH41PZcdvfc+Lcg= +cloud.google.com/go/networkconnectivity v1.14.4/go.mod h1:PU12q++/IMnDJAB+3r+tJtuCXCfwfN+C6Niyj6ji1Po= +cloud.google.com/go/networkmanagement v1.9.4/go.mod h1:daWJAl0KTFytFL7ar33I6R/oNBH8eEOX/rBNHrC/8TA= +cloud.google.com/go/networksecurity v0.9.5/go.mod h1:KNkjH/RsylSGyyZ8wXpue8xpCEK+bTtvof8SBfIhMG8= +cloud.google.com/go/notebooks v1.11.3/go.mod h1:0wQyI2dQC3AZyQqWnRsp+yA+kY4gC7ZIVP4Qg3AQcgo= +cloud.google.com/go/optimization v1.6.3/go.mod h1:8ve3svp3W6NFcAEFr4SfJxrldzhUl4VMUJmhrqVKtYA= +cloud.google.com/go/orchestration v1.8.5/go.mod h1:C1J7HesE96Ba8/hZ71ISTV2UAat0bwN+pi85ky38Yq8= +cloud.google.com/go/orgpolicy v1.12.1/go.mod h1:aibX78RDl5pcK3jA8ysDQCFkVxLj3aOQqrbBaUL2V5I= +cloud.google.com/go/osconfig v1.12.5/go.mod h1:D9QFdxzfjgw3h/+ZaAb5NypM8bhOMqBzgmbhzWViiW8= +cloud.google.com/go/oslogin v1.13.1/go.mod h1:vS8Sr/jR7QvPWpCjNqy6LYZr5Zs1e8ZGW/KPn9gmhws= +cloud.google.com/go/phishingprotection v0.8.5/go.mod h1:g1smd68F7mF1hgQPuYn3z8HDbNre8L6Z0b7XMYFmX7I= +cloud.google.com/go/policytroubleshooter v1.10.3/go.mod h1:+ZqG3agHT7WPb4EBIRqUv4OyIwRTZvsVDHZ8GlZaoxk= +cloud.google.com/go/privatecatalog v0.9.5/go.mod h1:fVWeBOVe7uj2n3kWRGlUQqR/pOd450J9yZoOECcQqJk= +cloud.google.com/go/pubsub v1.36.1/go.mod h1:iYjCa9EzWOoBiTdd4ps7QoMtMln5NwaZQpK1hbRfBDE= +cloud.google.com/go/pubsublite v1.8.1/go.mod h1:fOLdU4f5xldK4RGJrBMm+J7zMWNj/k4PxwEZXy39QS0= +cloud.google.com/go/recaptchaenterprise/v2 v2.9.2/go.mod h1:trwwGkfhCmp05Ll5MSJPXY7yvnO0p4v3orGANAFHAuU= +cloud.google.com/go/recommendationengine v0.8.5/go.mod h1:A38rIXHGFvoPvmy6pZLozr0g59NRNREz4cx7F58HAsQ= +cloud.google.com/go/recommender v1.12.1/go.mod h1:gf95SInWNND5aPas3yjwl0I572dtudMhMIG4ni8nr+0= +cloud.google.com/go/redis v1.14.2/go.mod h1:g0Lu7RRRz46ENdFKQ2EcQZBAJ2PtJHJLuiiRuEXwyQw= +cloud.google.com/go/resourcemanager v1.9.5/go.mod h1:hep6KjelHA+ToEjOfO3garMKi/CLYwTqeAw7YiEI9x8= +cloud.google.com/go/resourcesettings v1.6.5/go.mod h1:WBOIWZraXZOGAgoR4ukNj0o0HiSMO62H9RpFi9WjP9I= +cloud.google.com/go/retail v1.16.0/go.mod h1:LW7tllVveZo4ReWt68VnldZFWJRzsh9np+01J9dYWzE= +cloud.google.com/go/run v1.3.4/go.mod h1:FGieuZvQ3tj1e9GnzXqrMABSuir38AJg5xhiYq+SF3o= +cloud.google.com/go/scheduler v1.10.6/go.mod h1:pe2pNCtJ+R01E06XCDOJs1XvAMbv28ZsQEbqknxGOuE= +cloud.google.com/go/secretmanager v1.11.5/go.mod h1:eAGv+DaCHkeVyQi0BeXgAHOU0RdrMeZIASKc+S7VqH4= +cloud.google.com/go/security v1.15.5/go.mod h1:KS6X2eG3ynWjqcIX976fuToN5juVkF6Ra6c7MPnldtc= +cloud.google.com/go/securitycenter v1.24.4/go.mod h1:PSccin+o1EMYKcFQzz9HMMnZ2r9+7jbc+LvPjXhpwcU= +cloud.google.com/go/servicedirectory v1.11.4/go.mod h1:Bz2T9t+/Ehg6x+Y7Ycq5xiShYLD96NfEsWNHyitj1qM= +cloud.google.com/go/shell v1.7.5/go.mod h1:hL2++7F47/IfpfTO53KYf1EC+F56k3ThfNEXd4zcuiE= +cloud.google.com/go/spanner v1.56.0/go.mod h1:DndqtUKQAt3VLuV2Le+9Y3WTnq5cNKrnLb/Piqcj+h0= +cloud.google.com/go/speech v1.21.1/go.mod h1:E5GHZXYQlkqWQwY5xRSLHw2ci5NMQNG52FfMU1aZrIA= +cloud.google.com/go/storage v1.37.0/go.mod h1:i34TiT2IhiNDmcj65PqwCjcoUX7Z5pLzS8DEmoiFq1k= +cloud.google.com/go/storagetransfer v1.10.4/go.mod h1:vef30rZKu5HSEf/x1tK3WfWrL0XVoUQN/EPDRGPzjZs= +cloud.google.com/go/talent v1.6.6/go.mod h1:y/WQDKrhVz12WagoarpAIyKKMeKGKHWPoReZ0g8tseQ= +cloud.google.com/go/texttospeech v1.7.5/go.mod h1:tzpCuNWPwrNJnEa4Pu5taALuZL4QRRLcb+K9pbhXT6M= +cloud.google.com/go/tpu v1.6.5/go.mod h1:P9DFOEBIBhuEcZhXi+wPoVy/cji+0ICFi4TtTkMHSSs= +cloud.google.com/go/trace v1.10.5/go.mod h1:9hjCV1nGBCtXbAE4YK7OqJ8pmPYSxPA0I67JwRd5s3M= +cloud.google.com/go/translate v1.10.1/go.mod h1:adGZcQNom/3ogU65N9UXHOnnSvjPwA/jKQUMnsYXOyk= +cloud.google.com/go/video v1.20.4/go.mod h1:LyUVjyW+Bwj7dh3UJnUGZfyqjEto9DnrvTe1f/+QrW0= +cloud.google.com/go/videointelligence v1.11.5/go.mod h1:/PkeQjpRponmOerPeJxNPuxvi12HlW7Em0lJO14FC3I= +cloud.google.com/go/vision/v2 v2.8.0/go.mod h1:ocqDiA2j97pvgogdyhoxiQp2ZkDCyr0HWpicywGGRhU= +cloud.google.com/go/vmmigration v1.7.5/go.mod h1:pkvO6huVnVWzkFioxSghZxIGcsstDvYiVCxQ9ZH3eYI= +cloud.google.com/go/vmwareengine v1.1.1/go.mod h1:nMpdsIVkUrSaX8UvmnBhzVzG7PPvNYc5BszcvIVudYs= +cloud.google.com/go/vpcaccess v1.7.5/go.mod h1:slc5ZRvvjP78c2dnL7m4l4R9GwL3wDLcpIWz6P/ziig= +cloud.google.com/go/webrisk v1.9.5/go.mod h1:aako0Fzep1Q714cPEM5E+mtYX8/jsfegAuS8aivxy3U= +cloud.google.com/go/websecurityscanner v1.6.5/go.mod h1:QR+DWaxAz2pWooylsBF854/Ijvuoa3FCyS1zBa1rAVQ= +cloud.google.com/go/workflows v1.12.4/go.mod h1:yQ7HUqOkdJK4duVtMeBCAOPiN1ZF1E9pAMX51vpwB/w= +github.com/99designs/gqlgen v0.17.41/go.mod h1:GQ6SyMhwFbgHR0a8r2Wn8fYgEwPxxmndLFPhU63+cJE= +github.com/PuerkitoBio/goquery v1.8.1/go.mod h1:Q8ICL1kNUJ2sXGoAhPGUdYDJvgQgHzJsnnd3H7Ho5jQ= +github.com/alecthomas/kingpin/v2 v2.4.0/go.mod h1:0gyi0zQnjuFk8xrkNKamJoyUo382HRL7ATRpFZCw6tE= +github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137/go.mod h1:OMCwj8VM1Kc9e19TLln2VL61YJF0x1XFtfdL4JdbSyE= +github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA= +github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= +github.com/apache/arrow/go/v14 v14.0.2/go.mod h1:u3fgh3EdgN/YQ8cVQRguVW3R+seMybFg8QBQ5LU+eBY= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/census-instrumentation/opencensus-proto v0.4.1/go.mod h1:4T9NM4+4Vw91VeyqjLS6ao50K5bOcLKN6Q42XnYaRYw= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= +github.com/cncf/udpa/go v0.0.0-20220112060539-c52dc94e7fbe/go.mod h1:6pvJx4me5XPnfI9Z40ddWsdw2W/uZgQLFXToKeRcDiI= +github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +github.com/cncf/xds/go v0.0.0-20231109132714-523115ebc101/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= +github.com/envoyproxy/go-control-plane v0.11.1/go.mod h1:uhMcXKCQMEJHiAb0w+YGefQLaTEw+YhGluxZkrTmD0g= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/envoyproxy/protoc-gen-validate v1.0.2/go.mod h1:GpiZQP3dDbg4JouG/NNS7QWXpgx6x8QiMKdmN72jogE= +github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg= +github.com/gdamore/tcell/v2 v2.4.1-0.20210905002822-f057f0a857a1/go.mod h1:Az6Jt+M5idSED2YPGtwnfJV0kXohgdCBPmHGSYc1r04= +github.com/go-kit/log v0.2.1/go.mod h1:NwTd00d/i8cPZ3xOwwiv2PO5MOcx78fFErGNcVmBjv0= +github.com/go-logfmt/logfmt v0.5.1/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/glog v1.1.2/go.mod h1:zR+okUeTbrL6EL3xHUDxZuEtGv04p5shwip1+mL/rLQ= +github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= +github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/flatbuffers v23.5.26+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-pkcs11 v0.2.1-0.20230907215043-c6f79328ddf9/go.mod h1:6eQoGcuNJpa7jnd5pMGdkSaQpNDYvPlXWMcjXXThLlY= +github.com/google/martian/v3 v3.3.2/go.mod h1:oBOf6HBosgwRXnUGWUB05QECsc6uvmMiJ3+6W4l/CUk= +github.com/google/s2a-go v0.1.7/go.mod h1:50CgR4k1jNlWBu4UfS4AcfhVe1r6pdZPygJ3R8F0Qdw= +github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/enterprise-certificate-proxy v0.3.2/go.mod h1:VLSiSSBs/ksPL8kq3OBOQ6WRI2QnaFynd1DCjZ62+V0= +github.com/googleapis/gax-go/v2 v2.12.0/go.mod h1:y+aIqrI5eb1YGMVJfuV3185Ts/D7qKpsEkdD5+I6QGU= +github.com/gopherjs/gopherjs v0.0.0-20220221023154-0b2280d3ff96/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.0/go.mod h1:qmOFXW2epJhM0qSnUUYpldc7gVz2KMQwJ/QYCDIa7XU= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/iancoleman/strcase v0.2.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho= +github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= +github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= +github.com/kevinmbeaulieu/eq-go v1.0.0/go.mod h1:G3S8ajA56gKBZm4UB9AOyoOS37JO3roToPzKNM8dtdM= +github.com/klauspost/compress v1.16.7 h1:2mk3MPGNzKyxErAw8YaohYh69+pa4sIQSC0fPGCFR9I= +github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= +github.com/klauspost/cpuid/v2 v2.2.3/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY= +github.com/klauspost/cpuid/v2 v2.2.5/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= +github.com/logrusorgru/aurora/v3 v3.0.0/go.mod h1:vsR12bk5grlLvLXAYrBsb5Oc/N+LxAlxggSjiwMnCUc= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/lyft/protoc-gen-star/v2 v2.0.3/go.mod h1:amey7yeodaJhXSbf/TlLvWiqQfLOSpEk//mLlc+axEk= +github.com/magiconair/properties v1.8.6/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60= +github.com/matryer/moq v0.3.3/go.mod h1:RJ75ZZZD71hejp39j4crZLsEDszGk6iH4v4YsWFKH4s= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0/go.mod h1:QUyp042oQthUoa9bqDv0ER0wrtXnBruoNd7aNjkbP+k= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mmcloughlin/professor v0.0.0-20170922221822-6b97112ab8b3/go.mod h1:LQkXsHRSPIEklPCq8OMQAzYNS2NGtYStdNE/ej1oJU8= +github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= +github.com/pelletier/go-toml v1.9.4/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= +github.com/pierrec/lz4/v4 v4.1.18/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/common v0.45.0/go.mod h1:YJmSTw9BoKxJplESWWxlbyttQR4uaEcGyv9MZjVOJsY= +github.com/rivo/tview v0.0.0-20220307222120-9994674d60a8/go.mod h1:WIfMkQNY+oq/mWwtsjOYHIZBuwthioY2srOmljJkTnk= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/afero v1.8.2/go.mod h1:CtAatgMJh6bJEIs48Ay/FOnkljP3WeGUG0MC1RfAqwo= +github.com/spf13/cast v1.4.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= +github.com/spf13/cobra v1.4.0/go.mod h1:Wo4iy3BUC+X2Fybo0PDqwJIv3dNRiZLHQymsfxlB84g= +github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.10.1/go.mod h1:IGlFPqhNAPKRxohIzWpI5QEy4kuI7tcl5WvR+8qy1rU= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= +github.com/urfave/cli/v2 v2.27.1/go.mod h1:8qnjx1vcq5s2/wpsqoZFndg2CE5tNFyrTvS6SinrnYQ= +github.com/wblakecaldwell/profiler v0.0.0-20150908040756-6111ef1313a1/go.mod h1:3+0F8oLB1rQlbIcRAuqDgGdzNi9X69un/aPz4cUAFV4= +github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU= +github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8= +github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= +go.einride.tech/aip v0.66.0/go.mod h1:qAhMsfT7plxBX+Oy7Huol6YUvZ0ZzdUz26yZsQwfl1M= +go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= +go.opentelemetry.io/contrib v1.21.1/go.mod h1:usW9bPlrjHiJFbK0a6yK/M5wNHs3nLmtrT3vzhoD3co= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.47.0/go.mod h1:r9vWsPS/3AQItv3OSlEJ/E4mbrhUbbw18meOjArPtKQ= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.47.0/go.mod h1:SK2UL73Zy1quvRPonmOmRDiWk1KBV3LyIeeIxcEApWw= +go.opentelemetry.io/otel v1.21.0/go.mod h1:QZzNPQPm1zLX4gZK4cMi+71eaorMSGT3A4znnUvNNEo= +go.opentelemetry.io/otel v1.22.0/go.mod h1:eoV4iAi3Ea8LkAEI9+GFT44O6T/D0GWAVFyZVCC6pMI= +go.opentelemetry.io/otel v1.23.0/go.mod h1:YCycw9ZeKhcJFrb34iVSkyT0iczq/zYDtZYFufObyB0= +go.opentelemetry.io/otel/metric v1.21.0/go.mod h1:o1p3CA8nNHW8j5yuQLdc1eeqEaPfzug24uvsyIEJRWM= +go.opentelemetry.io/otel/metric v1.22.0/go.mod h1:evJGjVpZv0mQ5QBRJoBF64yMuOf4xCWdXjK8pzFvliY= +go.opentelemetry.io/otel/metric v1.23.0/go.mod h1:MqUW2X2a6Q8RN96E2/nqNoT+z9BSms20Jb7Bbp+HiTo= +go.opentelemetry.io/otel/sdk v1.21.0/go.mod h1:Nna6Yv7PWTdgJHVRD9hIYywQBRx7pbox6nwBnZIxl/E= +go.opentelemetry.io/otel/trace v1.21.0/go.mod h1:LGbsEB0f9LGjN+OZaQQ26sohbOmiMR+BaslueVtS/qQ= +go.opentelemetry.io/otel/trace v1.22.0/go.mod h1:RbbHXVqKES9QhzZq/fE5UnOSILqRt40a21sPw2He1xo= +go.opentelemetry.io/otel/trace v1.23.0/go.mod h1:GSGTbIClEsuZrGIzoEHqsVfxgn5UkggkflQwDScNUsk= +golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20210508222113-6edffad5e616/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= +golang.org/x/net v0.18.0/go.mod h1:/czyP5RqHAH4odGYxBJ1qz0+CE5WZ+2j1YgoEo8F2jQ= +golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U= +golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.12.0/go.mod h1:A74bZ3aGXgCY0qaIC9Ahg6Lglin4AMAco8cIv9baba4= +golang.org/x/oauth2 v0.14.0/go.mod h1:lAtNWgaWfL4cm7j2OV8TxGi9Qb7ECORx8DktCY74OwM= +golang.org/x/oauth2 v0.16.0/go.mod h1:hqZ+0LWXsiVoZpeld6jVt06P3adbS2Uu911W1SsJv2o= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/telemetry v0.0.0-20240208230135-b75ee8823808/go.mod h1:KG1lNk5ZFNssSZLrpVb4sMXKMpGwGXOxSG3rnu2gZQQ= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.17.0/go.mod h1:xsh6VxdV005rRVaS6SSAf9oiAqljS7UZUacMZ8Bnsps= +golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= +google.golang.org/api v0.162.0/go.mod h1:6SulDkfoBIg4NFmCuZ39XeeAgSHCPecfSUuDyYlAHs0= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= +google.golang.org/genproto v0.0.0-20231106174013-bbf56f31fb17/go.mod h1:J7XzRzVy1+IPwWHZUzoD0IccYZIrXILAQpc+Qy9CMhY= +google.golang.org/genproto v0.0.0-20240116215550-a9fa1716bcac/go.mod h1:+Rvu7ElI+aLzyDQhpHMFMMltsD6m7nqpuWDd2CwJw3k= +google.golang.org/genproto v0.0.0-20240125205218-1f4bbc51befe/go.mod h1:cc8bqMqtv9gMOr0zHg2Vzff5ULhhL2IXP4sbcn32Dro= +google.golang.org/genproto v0.0.0-20240205150955-31a09d347014/go.mod h1:xEgQu1e4stdSSsxPDK8Azkrk/ECl5HvdPf6nbZrTS5M= +google.golang.org/genproto/googleapis/api v0.0.0-20231106174013-bbf56f31fb17/go.mod h1:0xJLfVdJqpAPl8tDg1ujOCGzx6LFLttXT5NhllGOXY4= +google.golang.org/genproto/googleapis/api v0.0.0-20240102182953-50ed04b92917/go.mod h1:CmlNWB9lSezaYELKS5Ym1r44VrrbPUa7JTvw+6MbpJ0= +google.golang.org/genproto/googleapis/api v0.0.0-20240125205218-1f4bbc51befe/go.mod h1:4jWUdICTdgc3Ibxmr8nAJiiLHwQBY0UI0XZcEMaFKaA= +google.golang.org/genproto/googleapis/api v0.0.0-20240205150955-31a09d347014/go.mod h1:rbHMSEDyoYX62nRVLOCc4Qt1HbsdytAYoVwgjiOhF3I= +google.golang.org/genproto/googleapis/bytestream v0.0.0-20240125205218-1f4bbc51befe/go.mod h1:SCz6T5xjNXM4QFPRwxHcfChp7V+9DcXR3ay2TkHR8Tg= +google.golang.org/genproto/googleapis/rpc v0.0.0-20231106174013-bbf56f31fb17/go.mod h1:oQ5rr10WTTMvP4A36n8JpR1OrO1BEiV4f78CneXZxkA= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240102182953-50ed04b92917/go.mod h1:xtjpI3tXFPP051KaWnhvxkiubL/6dJ18vLVf7q2pTOU= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240125205218-1f4bbc51befe/go.mod h1:PAREbraiVEVGVdTZsVWjSbbTtSyGbAgIIvni8a8CD5s= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240205150955-31a09d347014/go.mod h1:SaPjaZGWb0lPqs6Ittu0spdfrOArqji4ZdeP5IC/9N4= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= +google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= +google.golang.org/grpc v1.60.1/go.mod h1:OlCHIeLYqSSsLi6i49B5QGdzaMZK9+M7LXN2FKz4eGM= +google.golang.org/grpc v1.61.0/go.mod h1:VUbo7IFqmF1QtCAstipjG0GIoq49KvMe9+h1jFLBNJs= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= +gopkg.in/ini.v1 v1.66.4/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +lukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk= +modernc.org/cc/v3 v3.41.0/go.mod h1:Ni4zjJYJ04CDOhG7dn640WGfwBzfE0ecX8TyMB0Fv0Y= +modernc.org/ccgo/v3 v3.16.15/go.mod h1:yT7B+/E2m43tmMOT51GMoM98/MtHIcQQSleGnddkUNI= +modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= diff --git a/ident/ident.go b/ident/ident.go index 7c85a56..c9f25c1 100644 --- a/ident/ident.go +++ b/ident/ident.go @@ -2,6 +2,7 @@ package ident import ( "context" + "errors" "fmt" "io" "net/http" @@ -52,11 +53,11 @@ func FromContext(ctx context.Context) Ident { type IDM struct { rand io.Reader sources []source - pwd *passwd.Passwd + pwd *passwd.Passwd } func NewIDM(pwd *passwd.Passwd, rand io.Reader) *IDM { - return &IDM{pwd: pwd, rand:rand} + return &IDM{pwd: pwd, rand: rand} } func (idm *IDM) Add(p int, h Handler) { @@ -70,18 +71,17 @@ func (idm *IDM) Passwd(pass, hash []byte) ([]byte, error) { // ReadIdent read ident from a list of ident handlers func (idm *IDM) ReadIdent(r *http.Request) (Ident, error) { + var errs error for _, source := range idm.sources { u, err := source.ReadIdent(r) - if err != nil { - return Anonymous, err - } + errs = errors.Join(errs, err) - if u.Session().Active { - return u, err + if u != nil && u.Session().Active { + return u, errs } } - return Anonymous, nil + return Anonymous, errs } func (idm *IDM) RegisterIdent(ctx context.Context, identity, displayName string, passwd []byte) (Ident, error) { diff --git a/ident/routes.go b/ident/routes.go index cb3702b..7077de0 100644 --- a/ident/routes.go +++ b/ident/routes.go @@ -2,16 +2,10 @@ package ident import ( "context" - "errors" "fmt" "net/http" - "time" - "github.com/oklog/ulid/v2" - - "go.sour.is/passwd" "go.sour.is/pkg/lg" - "go.sour.is/pkg/locker" ) var ( @@ -24,7 +18,7 @@ var ( nick = `value="` + nick + `"` } return ` -
+ @@ -32,8 +26,12 @@ var (
` } - logoutForm = func(display string) string { - return `` + logoutForm = func(id Ident) string { + display := id.Identity() + if id, ok := id.(interface{ DisplayName() string }); ok { + display = id.DisplayName() + } + return `` } registerForm = `
@@ -46,152 +44,104 @@ var (
` ) -type sessions map[ulid.ULID]Ident - -type root struct { - idm *IDM - sessions *locker.Locked[sessions] +type sessionIF interface { + ReadIdent(r *http.Request) (Ident, error) + CreateSession(context.Context, http.ResponseWriter, Ident) error + DestroySession(context.Context, http.ResponseWriter, Ident) error } -func NewHTTP(idm *IDM) *root { - sessions := make(sessions) +type root struct { + idm *IDM + session sessionIF +} + +func NewHTTP(idm *IDM, session sessionIF) *root { + idm.Add(0, session) return &root{ - idm: idm, - sessions: locker.New(sessions), + idm: idm, + session: session, } } func (s *root) RegisterHTTP(mux *http.ServeMux) { - mux.HandleFunc("/ident", s.get) - mux.HandleFunc("/ident/register", s.register) - mux.HandleFunc("/ident/login", s.login) - mux.HandleFunc("/ident/logout", s.logout) + mux.HandleFunc("/ident", s.sessionHTTP) + mux.HandleFunc("/ident/register", s.registerHTTP) + mux.HandleFunc("/ident/session", s.sessionHTTP) } func (s *root) RegisterAPIv1(mux *http.ServeMux) { + mux.HandleFunc("GET /ident", s.sessionV1) mux.HandleFunc("POST /ident", s.registerV1) - mux.HandleFunc("POST /ident/session", s.loginV1) - mux.HandleFunc("DELETE /ident/session", s.logoutV1) - mux.HandleFunc("GET /ident", s.getV1) + mux.HandleFunc("/ident/session", s.sessionV1) } func (s *root) RegisterMiddleware(hdlr http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ctx, span := lg.Span(r.Context()) defer span.End() + r = r.WithContext(ctx) - cookie, err := r.Cookie("sour.is-ident") + id, err := s.idm.ReadIdent(r) span.RecordError(err) - if err != nil { - hdlr.ServeHTTP(w, r) - return + if id == nil { + id = Anonymous } - sessionID, err := ulid.Parse(cookie.Value) - span.RecordError(err) - - var id Ident = Anonymous - if err == nil { - err = s.sessions.Use(ctx, func(ctx context.Context, sessions sessions) error { - if session, ok := sessions[sessionID]; ok { - id = session - } - return nil - }) - } - span.RecordError(err) - r = r.WithContext(context.WithValue(r.Context(), contextKey, id)) hdlr.ServeHTTP(w, r) }) } -func (s *root) createSession(ctx context.Context, id Ident) error { - return s.sessions.Use(ctx, func(ctx context.Context, sessions sessions) error { - sessions[id.Session().SessionID] = id - return nil - }) -} -func (s *root) destroySession(ctx context.Context, id Ident) error { - session := id.Session() - session.Active = false - return s.sessions.Use(ctx, func(ctx context.Context, sessions sessions) error { - delete(sessions, session.SessionID) - return nil - }) -} - -func (s *root) getV1(w http.ResponseWriter, r *http.Request) { +func (s *root) sessionV1(w http.ResponseWriter, r *http.Request) { ctx, span := lg.Span(r.Context()) defer span.End() var id Ident = FromContext(ctx) - if id == nil { - http.Error(w, "NO_AUTH", http.StatusUnauthorized) + switch r.Method { + case http.MethodGet: + if id == nil { + http.Error(w, "NO_AUTH", http.StatusUnauthorized) + return + } + fmt.Fprint(w, id) + case http.MethodPost: + if !id.Session().Active { + http.Error(w, "NO_AUTH", http.StatusUnauthorized) + return + } + + err := s.session.CreateSession(ctx, w, id) + if err != nil { + span.RecordError(err) + http.Error(w, "ERR", http.StatusInternalServerError) + return + } + + fmt.Fprint(w, id) + + case http.MethodDelete: + if !id.Session().Active { + http.Error(w, "NO_AUTH", http.StatusUnauthorized) + return + } + + err := s.session.DestroySession(ctx, w, FromContext(ctx)) + if err != nil { + span.RecordError(err) + http.Error(w, "ERR", http.StatusInternalServerError) + return + } + + http.Error(w, "GONE", http.StatusGone) + + default: + http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) return } - fmt.Fprint(w, id) -} -func (s *root) loginV1(w http.ResponseWriter, r *http.Request) { - ctx, span := lg.Span(r.Context()) - defer span.End() - - id, err := s.idm.ReadIdent(r) - span.RecordError(err) - if err != nil { - http.Error(w, "ERR", http.StatusInternalServerError) - return - } - if !id.Session().Active { - http.Error(w, "NO_AUTH", http.StatusUnauthorized) - return - } - - err = s.createSession(ctx, id) - if err != nil { - span.RecordError(err) - http.Error(w, "ERR", http.StatusInternalServerError) - return - } - - http.SetCookie(w, &http.Cookie{ - Name: "sour.is-ident", - Value: id.Session().SessionID.String(), - Expires: time.Time{}, - Path: "/", - Secure: false, - HttpOnly: true, - }) - - fmt.Fprint(w, id) -} -func (s *root) logoutV1(w http.ResponseWriter, r *http.Request) { - ctx, span := lg.Span(r.Context()) - defer span.End() - - if r.Method != http.MethodPost { - http.Error(w, "ERR", http.StatusMethodNotAllowed) - return - } - - err := s.destroySession(ctx, FromContext(ctx)) - if err != nil { - span.RecordError(err) - http.Error(w, "NO_AUTH", http.StatusUnauthorized) - return - } - - http.SetCookie(w, &http.Cookie{Name: "sour.is-ident", MaxAge: -1}) - - http.Error(w, "GONE", http.StatusGone) } func (s *root) registerV1(w http.ResponseWriter, r *http.Request) { ctx, span := lg.Span(r.Context()) defer span.End() - if r.Method != http.MethodPost { - http.Error(w, "ERR", http.StatusMethodNotAllowed) - return - } r.ParseForm() identity := r.Form.Get("identity") @@ -211,107 +161,60 @@ func (s *root) registerV1(w http.ResponseWriter, r *http.Request) { http.Error(w, "OK "+identity, http.StatusCreated) } -func (s *root) get(w http.ResponseWriter, r *http.Request) { +func (s *root) sessionHTTP(w http.ResponseWriter, r *http.Request) { ctx, span := lg.Span(r.Context()) defer span.End() - var id Ident = FromContext(ctx) - if id == nil { - http.Error(w, loginForm("", true), http.StatusOK) - return - } + id := FromContext(ctx) - if !id.Session().Active { - http.Error(w, loginForm("", true), http.StatusOK) - return - } - - display := id.Identity() - if id, ok := id.(interface{ DisplayName() string }); ok { - display = id.DisplayName() - } - fmt.Fprint(w, logoutForm(display)) -} -func (s *root) login(w http.ResponseWriter, r *http.Request) { - ctx, span := lg.Span(r.Context()) - defer span.End() - - if r.Method == http.MethodGet { + switch r.Method { + case http.MethodGet: + if id.Session().Active { + fmt.Fprint(w, logoutForm(id)) + return + } fmt.Fprint(w, loginForm("", true)) - return - } - - id, err := s.idm.ReadIdent(r) - span.RecordError(err) - if err != nil { - if errors.Is(err, passwd.ErrNoMatch) { + case http.MethodPost: + if !id.Session().Active { http.Error(w, loginForm("", false), http.StatusOK) return } - - http.Error(w, "ERROR", http.StatusInternalServerError) - return + + err := s.session.CreateSession(ctx, w, id) + span.RecordError(err) + if err != nil { + http.Error(w, "ERROR", http.StatusInternalServerError) + return + } + + fmt.Fprint(w, logoutForm(id)) + case http.MethodDelete: + err := s.session.DestroySession(ctx, w, FromContext(ctx)) + span.RecordError(err) + if err != nil { + http.Error(w, loginForm("", true), http.StatusUnauthorized) + return + } + + fmt.Fprint(w, loginForm("", true)) + default: + http.Error(w, "ERROR", http.StatusMethodNotAllowed) } - - if !id.Session().Active { - http.Error(w, loginForm("", false), http.StatusOK) - return - } - - err = s.createSession(ctx, id) - span.RecordError(err) - if err != nil { - http.Error(w, "ERROR", http.StatusInternalServerError) - return - } - - http.SetCookie(w, &http.Cookie{ - Name: "sour.is-ident", - Value: id.Session().SessionID.String(), - Expires: time.Time{}, - Path: "/", - Secure: false, - HttpOnly: true, - }) - - display := id.Identity() - if id, ok := id.(interface{ DisplayName() string }); ok { - display = id.DisplayName() - } - fmt.Fprint(w, logoutForm(display)) } -func (s *root) logout(w http.ResponseWriter, r *http.Request) { +func (s *root) registerHTTP(w http.ResponseWriter, r *http.Request) { ctx, span := lg.Span(r.Context()) defer span.End() - if r.Method != http.MethodPost { - http.Error(w, "ERR", http.StatusMethodNotAllowed) - return - } - - http.SetCookie(w, &http.Cookie{Name: "sour.is-ident", MaxAge: -1}) - - err := s.destroySession(ctx, FromContext(ctx)) - span.RecordError(err) - if err != nil { - http.Error(w, loginForm("", true), http.StatusUnauthorized) - return - } - - fmt.Fprint(w, loginForm("", true)) -} -func (s *root) register(w http.ResponseWriter, r *http.Request) { - ctx, span := lg.Span(r.Context()) - defer span.End() - - if r.Method == http.MethodGet { + switch r.Method { + case http.MethodGet: fmt.Fprint(w, registerForm) return - } - - if r.Method != http.MethodPost { + case http.MethodPost: + // break + default: http.Error(w, "ERR", http.StatusMethodNotAllowed) return + } r.ParseForm() @@ -335,26 +238,12 @@ func (s *root) register(w http.ResponseWriter, r *http.Request) { return } - err = s.createSession(ctx, id) + err = s.session.CreateSession(ctx, w, id) span.RecordError(err) if err != nil { http.Error(w, "ERROR", http.StatusInternalServerError) return } - http.SetCookie(w, &http.Cookie{ - Name: "sour.is-ident", - Value: id.Session().SessionID.String(), - Expires: time.Time{}, - Path: "/", - Secure: false, - HttpOnly: true, - }) - - display = id.Identity() - if id, ok := id.(interface{ DisplayName() string }); ok { - display = id.DisplayName() - } - - http.Error(w, logoutForm(display), http.StatusCreated) + http.Error(w, logoutForm(id), http.StatusCreated) } diff --git a/ident/source/mercury.go b/ident/source/mercury.go index c54ed01..c290cb5 100644 --- a/ident/source/mercury.go +++ b/ident/source/mercury.go @@ -12,6 +12,9 @@ import ( "go.sour.is/pkg/ident" ) +const identNS = "ident." +const identSFX = ".credentials" + type registry interface { GetIndex(ctx context.Context, match, search string) (c mercury.Config, err error) GetConfig(ctx context.Context, match, search, fields string) (mercury.Config, error) @@ -22,12 +25,13 @@ type mercuryIdent struct { identity string display string passwd []byte + ed25519 []byte ident.SessionInfo } func (id *mercuryIdent) Identity() string { return id.identity } func (id *mercuryIdent) DisplayName() string { return id.display } -func (id *mercuryIdent) Space() string { return "mercury.@" + id.identity } +func (id *mercuryIdent) Space() string { return identNS + "@" + id.identity } func (id *mercuryIdent) FromConfig(cfg mercury.Config) error { if id == nil { @@ -35,7 +39,7 @@ func (id *mercuryIdent) FromConfig(cfg mercury.Config) error { } for _, s := range cfg { - if !strings.HasPrefix(s.Space, "mercury.") { + if !strings.HasPrefix(s.Space, identNS) { continue } if id.identity == "" { @@ -44,7 +48,7 @@ func (id *mercuryIdent) FromConfig(cfg mercury.Config) error { } switch { - case strings.HasSuffix(s.Space, ".ident"): + case strings.HasSuffix(s.Space, ".credentials"): id.passwd = []byte(s.FirstValue("passwd").First()) default: id.display = s.FirstValue("displayName").First() @@ -74,10 +78,10 @@ func (id *mercuryIdent) ToConfig() mercury.Config { }, }, &mercury.Space{ - Space: space + ".ident", + Space: space + identSFX, List: []mercury.Value{ { - Space: space + ".ident", + Space: space + identSFX, Seq: 1, Name: "passwd", Values: []string{string(id.passwd)}, @@ -105,20 +109,38 @@ func NewMercury(r registry, pwd *ident.IDM) *mercurySource { } func (s *mercurySource) ReadIdent(r *http.Request) (ident.Ident, error) { + if id, err := s.readIdentBasic(r); id != nil { + return id, err + } + + if id, err := s.readIdentURL(r); id != nil { + return id, err + } + + if id, err := s.readIdentHTTP(r); id != nil { + return id, err + } + + return nil, fmt.Errorf("no auth") +} + +func (s *mercurySource) readIdentURL(r *http.Request) (ident.Ident, error) { ctx, span := lg.Span(r.Context()) defer span.End() - if r.Method != http.MethodPost { - return nil, fmt.Errorf("method not allowed") + pass, ok := r.URL.User.Password() + + if !ok { + return nil, nil } - r.ParseForm() + id := &mercuryIdent{ - identity: r.Form.Get("identity"), - passwd: []byte(r.Form.Get("passwd")), + identity: r.URL.User.Username(), + passwd: []byte(pass), } space := id.Space() - c, err := s.r.GetConfig(ctx, "trace:"+space+".ident", "", "") + c, err := s.r.GetConfig(ctx, "trace:"+space+identSFX, "", "") if err != nil { span.RecordError(err) return id, err @@ -144,6 +166,95 @@ func (s *mercurySource) ReadIdent(r *http.Request) (ident.Ident, error) { return ¤t, nil } + +func (s *mercurySource) readIdentBasic(r *http.Request) (ident.Ident, error) { + ctx, span := lg.Span(r.Context()) + defer span.End() + + user, pass, ok := r.BasicAuth() + + if !ok { + return nil, nil + } + + id := &mercuryIdent{ + identity: user, + passwd: []byte(pass), + } + + space := id.Space() + c, err := s.r.GetConfig(ctx, "trace:"+space+identSFX, "", "") + if err != nil { + span.RecordError(err) + return id, err + } + var current mercuryIdent + current.FromConfig(c) + if len(current.passwd) == 0 { + return nil, fmt.Errorf("not registered") + } + _, err = s.idm.Passwd(id.passwd, current.passwd) + if err != nil { + return id, err + } + current.SessionInfo, err = s.idm.NewSessionInfo() + if err != nil { + return id, err + } + + err = s.r.WriteConfig(ctx, current.ToConfig()) + if err != nil { + return ¤t, err + } + + return ¤t, nil +} + +func (s *mercurySource) readIdentHTTP(r *http.Request) (ident.Ident, error) { + ctx, span := lg.Span(r.Context()) + defer span.End() + + if r.Method != http.MethodPost { + return nil, fmt.Errorf("method not allowed") + } + r.ParseForm() + id := &mercuryIdent{ + identity: r.Form.Get("identity"), + passwd: []byte(r.Form.Get("passwd")), + } + + if id.identity == "" { + return nil, nil + } + + space := id.Space() + c, err := s.r.GetConfig(ctx, "trace:"+space+identSFX, "", "") + if err != nil { + span.RecordError(err) + return id, err + } + var current mercuryIdent + current.FromConfig(c) + if len(current.passwd) == 0 { + return nil, fmt.Errorf("not registered") + } + _, err = s.idm.Passwd(id.passwd, current.passwd) + if err != nil { + return id, err + } + current.SessionInfo, err = s.idm.NewSessionInfo() + if err != nil { + return id, err + } + + err = s.r.WriteConfig(ctx, current.ToConfig()) + if err != nil { + return ¤t, err + } + + return ¤t, nil +} + func (s *mercurySource) RegisterIdent(ctx context.Context, identity, display string, passwd []byte) (ident.Ident, error) { ctx, span := lg.Span(ctx) defer span.End() diff --git a/ident/source/session.go b/ident/source/session.go new file mode 100644 index 0000000..8c41ef3 --- /dev/null +++ b/ident/source/session.go @@ -0,0 +1,83 @@ +package source + +import ( + "context" + "net/http" + "time" + + "github.com/oklog/ulid/v2" + "go.sour.is/pkg/ident" + "go.sour.is/pkg/lg" + "go.sour.is/pkg/locker" +) + +const CookieName = "sour.is-ident" + +type sessions map[ulid.ULID]ident.Ident + +type session struct { + cookieName string + sessions *locker.Locked[sessions] +} + +func NewSession(cookieName string) *session { + return &session{ + cookieName: cookieName, + sessions: locker.New(make(sessions)), + } +} + +func (s *session) ReadIdent(r *http.Request) (ident.Ident, error) { + ctx, span := lg.Span(r.Context()) + defer span.End() + + cookie, err := r.Cookie(s.cookieName) + span.RecordError(err) + if err != nil { + return nil, nil + } + + sessionID, err := ulid.Parse(cookie.Value) + span.RecordError(err) + + var id ident.Ident = ident.Anonymous + if err == nil { + err = s.sessions.Use(ctx, func(ctx context.Context, sessions sessions) error { + if session, ok := sessions[sessionID]; ok { + id = session + } + return nil + }) + } + span.RecordError(err) + + return id, err +} + +func (s *session) CreateSession(ctx context.Context, w http.ResponseWriter, id ident.Ident) error { + http.SetCookie(w, &http.Cookie{ + Name: s.cookieName, + Value: id.Session().SessionID.String(), + Expires: time.Time{}, + Path: "/", + Secure: false, + HttpOnly: true, + }) + + return s.sessions.Use(ctx, func(ctx context.Context, sessions sessions) error { + sessions[id.Session().SessionID] = id + return nil + }) +} + +func (s *session) DestroySession(ctx context.Context, w http.ResponseWriter, id ident.Ident) error { + session := id.Session() + session.Active = false + + http.SetCookie(w, &http.Cookie{Name: s.cookieName, MaxAge: -1}) + + return s.sessions.Use(ctx, func(ctx context.Context, sessions sessions) error { + delete(sessions, session.SessionID) + return nil + }) +} diff --git a/mercury/mercury.go b/mercury/mercury.go index 29ce596..ceaa1d7 100644 --- a/mercury/mercury.go +++ b/mercury/mercury.go @@ -62,10 +62,16 @@ func (lis Config) ToSpaceMap() SpaceMap { // String format config as string func (lis Config) String() string { - attLen := 0 - tagLen := 0 - for _, o := range lis { + var buf strings.Builder + for i, o := range lis { + attLen := 0 + tagLen := 0 + + if i > 0 { + buf.WriteRune('\n') + } + for _, v := range o.List { l := len(v.Name) if attLen <= l { @@ -77,10 +83,7 @@ func (lis Config) String() string { tagLen = t } } - } - var buf strings.Builder - for _, o := range lis { if len(o.Notes) > 0 { buf.WriteString("# ") buf.WriteString(strings.Join(o.Notes, "\n# ")) @@ -133,7 +136,10 @@ func (lis Config) String() string { } } - buf.WriteRune('\n') + for _, line := range o.Trailer { + buf.WriteString(line) + buf.WriteRune('\n') + } } return buf.String() @@ -188,11 +194,21 @@ func (lis Config) EnvString() string { func (lis Config) INIString() string { var buf strings.Builder for _, o := range lis { + for _, note := range o.Notes { + buf.WriteString("; ") + buf.WriteString(note) + buf.WriteRune('\n') + } buf.WriteRune('[') buf.WriteString(o.Space) buf.WriteRune(']') buf.WriteRune('\n') for _, v := range o.List { + for _, note := range v.Notes { + buf.WriteString("; ") + buf.WriteString(note) + buf.WriteRune('\n') + } buf.WriteString(v.Name) switch len(v.Values) { case 0: @@ -221,6 +237,13 @@ func (lis Config) INIString() string { } } } + for _, line := range o.Trailer { + buf.WriteString("; ") + buf.WriteString(line) + buf.WriteRune('\n') + } + + buf.WriteRune('\n') } return buf.String() @@ -228,10 +251,16 @@ func (lis Config) INIString() string { // String format config as string func (lis Config) HTMLString() string { - attLen := 0 - tagLen := 0 - for _, o := range lis { + var buf strings.Builder + for i, o := range lis { + attLen := 0 + tagLen := 0 + + if i > 0 { + buf.WriteRune('\n') + } + for _, v := range o.List { l := len(v.Name) if attLen <= l { @@ -243,10 +272,7 @@ func (lis Config) HTMLString() string { tagLen = t } } - } - var buf strings.Builder - for _, o := range lis { if len(o.Notes) > 0 { buf.WriteString("") buf.WriteString("# ") @@ -311,7 +337,12 @@ func (lis Config) HTMLString() string { } } - buf.WriteRune('\n') + for _, line := range o.Trailer { + buf.WriteString("") + buf.WriteString(line) + buf.WriteString("") + buf.WriteRune('\n') + } } return buf.String() @@ -319,10 +350,11 @@ func (lis Config) HTMLString() string { // Space stores a registry of spaces type Space struct { - Space string `json:"space"` - Tags []string `json:"tags,omitempty"` - Notes []string `json:"notes,omitempty"` - List []Value `json:"list,omitempty"` + Space string `json:"space"` + Tags []string `json:"tags,omitempty"` + Notes []string `json:"notes,omitempty"` + List []Value `json:"list,omitempty"` + Trailer []string `json:"trailer,omitempty"` } func NewSpace(space string) *Space { @@ -437,7 +469,7 @@ type SpaceMap map[string]*Space func (m SpaceMap) Space(name string) (*Space, bool) { s, ok := m[name] return s, ok -} +} // Rule is a type of rule type Rule struct { diff --git a/mercury/namespace.go b/mercury/namespace.go index c783714..328bfbe 100644 --- a/mercury/namespace.go +++ b/mercury/namespace.go @@ -20,17 +20,11 @@ type NamespaceSearch []NamespaceSpec func ParseNamespace(ns string) (lis NamespaceSearch) { for _, part := range strings.Split(ns, ";") { if strings.HasPrefix(part, "trace:") { - for _, s := range strings.Split(part[6:], ",") { - lis = append(lis, NamespaceTrace(s)) - } + lis = append(lis, NamespaceTrace(part[6:])) + } else if strings.Contains(part, "*") { + lis = append(lis, NamespaceStar(part)) } else { - for _, s := range strings.Split(part, ",") { - if strings.Contains(s, "*") { - lis = append(lis, NamespaceStar(s)) - } else { - lis = append(lis, NamespaceNode(s)) - } - } + lis = append(lis, NamespaceNode(part)) } } @@ -44,7 +38,7 @@ func (n NamespaceSearch) String() string { for _, v := range n { lis = append(lis, v.String()) } - return strings.Join(lis, ",") + return strings.Join(lis, ";") } // Match returns true if any match. diff --git a/mercury/namespace_test.go b/mercury/namespace_test.go new file mode 100644 index 0000000..82e7951 --- /dev/null +++ b/mercury/namespace_test.go @@ -0,0 +1,59 @@ +package mercury_test + +import ( + "fmt" + "testing" + + "github.com/matryer/is" + "go.sour.is/pkg/mercury" + + sq "github.com/Masterminds/squirrel" +) + +func TestNamespaceParse(t *testing.T) { + var tests = []struct { + in string + out string + args []any + }{ + { + in: "d42.bgp.kapha.*;trace:d42.bgp.kapha", + out: "(column LIKE ? OR ? LIKE column || '%')", + args: []any{"d42.bgp.kapha.%", "d42.bgp.kapha"}, + }, + + { + in: "d42.bgp.kapha.*,d42.bgp.kapha", + out: "(column LIKE ? OR column = ?)", + args: []any{"d42.bgp.kapha.%", "d42.bgp.kapha"}, + }, + } + + for i, tt := range tests { + t.Run(fmt.Sprintf("test %d", i), func(t *testing.T) { + is := is.New(t) + out := mercury.ParseNamespace(tt.in) + sql, args, err := getWhere(out).ToSql() + is.NoErr(err) + is.Equal(sql, tt.out) + is.Equal(args, tt.args) + }) + } +} + +func getWhere(search mercury.NamespaceSearch) sq.Sqlizer { + var where sq.Or + space := "column" + for _, m := range search { + switch m.(type) { + case mercury.NamespaceNode: + where = append(where, sq.Eq{space: m.Value()}) + case mercury.NamespaceStar: + where = append(where, sq.Like{space: m.Value()}) + case mercury.NamespaceTrace: + e := sq.Expr(`? LIKE `+space+` || '%'`, m.Value()) + where = append(where, e) + } + } + return where +} diff --git a/mercury/parse.go b/mercury/parse.go index 6bcb33d..25c10d5 100644 --- a/mercury/parse.go +++ b/mercury/parse.go @@ -3,6 +3,7 @@ package mercury import ( "bufio" "io" + "log" "strings" ) @@ -49,6 +50,26 @@ func ParseText(body io.Reader) (config SpaceMap, err error) { continue } + if strings.HasPrefix(line, "----") && strings.HasSuffix(line, "----") { + var trailer []string + + trailer = append(trailer, line) + for scanner.Scan() { + line = scanner.Text() + trailer = append(trailer, line) + if strings.HasPrefix(line, "----") && strings.HasSuffix(line, "----") { + break + } + } + c, ok := config[space] + if !ok { + c = &Space{Space: space} + } + log.Println(trailer) + c.Trailer = append(c.Trailer, trailer...) + config[space] = c + continue + } if space == "" { continue } @@ -59,10 +80,8 @@ func ParseText(body io.Reader) (config SpaceMap, err error) { } if strings.TrimSpace(sp[0]) == "" { - var c *Space - var ok bool - - if c, ok = config[space]; !ok { + c, ok := config[space] + if !ok { c = &Space{Space: space} } @@ -78,10 +97,8 @@ func ParseText(body io.Reader) (config SpaceMap, err error) { tags = fields[1:] } - var c *Space - var ok bool - - if c, ok = config[space]; !ok { + c, ok := config[space] + if !ok { c = &Space{Space: space} } diff --git a/mercury/parse_test.go b/mercury/parse_test.go new file mode 100644 index 0000000..c2d6ca3 --- /dev/null +++ b/mercury/parse_test.go @@ -0,0 +1,28 @@ +package mercury_test + +import ( + "strings" + "testing" + + "github.com/matryer/is" + "go.sour.is/pkg/mercury" +) + +func TestParseText(t *testing.T) { + is := is.New(t) + sm, err := mercury.ParseText(strings.NewReader(` +@test.sign +key :value1 +-----BEGIN SSH SIGNATURE----- +U1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAgZ+OuJYdd3UiUbyBuO1RlsQR20a +Qm5mKneuMxRjGo3zkAAAAEZmlsZQAAAAAAAAAGc2hhNTEyAAAAUwAAAAtzc2gtZWQyNTUx +OQAAAED8T4C6WILXYZ1KxqDIlVhlrAEjr1Vc+tn8ypcVM3bN7iOexVvuUuvm90nr8eEwKU +acrdDxmq2S+oysQbK+pMUE +-----END SSH SIGNATURE----- +`)) + is.NoErr(err) + for _, c := range sm { + is.Equal(len(c.Trailer), 6) + } + +} diff --git a/mercury/public/index.html b/mercury/public/index.html index 9a020b1..c55cb39 100644 --- a/mercury/public/index.html +++ b/mercury/public/index.html @@ -32,7 +32,7 @@
+ style="overflow:auto; transition: height 0.2s ease-out;">

         
diff --git a/mercury/public/style.css b/mercury/public/style.css
index d29091e..0566cb3 100644
--- a/mercury/public/style.css
+++ b/mercury/public/style.css
@@ -6,6 +6,7 @@
 body {
     margin: 0;
     min-height: 100vh;
+    background: rgb(210, 221, 240);
 }
 
 header {
@@ -57,6 +58,11 @@ code i {
 }
 
 code em {
+    color: orangered;
+}
+
+code small {
+    font-size-adjust: 50%;
     color: orange;
 }
 
@@ -94,7 +100,7 @@ footer>span {
 .container>div {
     overflow: auto;
     padding: 10px;
-    background: rgb(238, 174, 202);
+    background-color: white;
     border: 0px ;
 }
 
@@ -158,7 +164,7 @@ footer>span {
 }
 
 @media (prefers-color-scheme: dark) {
-    html {
+    html, body {
         color: white;
         background: #111
     }
diff --git a/mercury/registry.go b/mercury/registry.go
index 9d53ff2..a48d371 100644
--- a/mercury/registry.go
+++ b/mercury/registry.go
@@ -8,8 +8,8 @@ import (
 	"strconv"
 	"strings"
 
-	"go.sour.is/pkg/lg"
 	"go.sour.is/pkg/ident"
+	"go.sour.is/pkg/lg"
 	"go.sour.is/pkg/rsql"
 	"go.sour.is/pkg/set"
 	"golang.org/x/sync/errgroup"
@@ -122,16 +122,32 @@ func (r *registry) Register(name string, h func(*Space) any) {
 func (r *registry) Configure(m SpaceMap) error {
 	r.resetMatchers()
 	for space, c := range m {
-		space = strings.TrimPrefix(space, "mercury.source.")
-		handler, name, _ := strings.Cut(space, ".")
-		matches := c.FirstValue("match")
-		for _, match := range matches.Values {
-			ps := strings.Fields(match)
-			priority, err := strconv.Atoi(ps[0])
-			if err != nil {
-				return err
+		if strings.HasPrefix(space, "mercury.source.") {
+			space = strings.TrimPrefix(space, "mercury.source.")
+			handler, name, _ := strings.Cut(space, ".")
+			matches := c.FirstValue("match")
+			for _, match := range matches.Values {
+				ps := strings.Fields(match)
+				priority, err := strconv.Atoi(ps[0])
+				if err != nil {
+					return err
+				}
+				r.add(name, handler, ps[1], priority, c)
+			}
+		}
+
+		if strings.HasPrefix(space, "mercury.output.") {
+			space = strings.TrimPrefix(space, "mercury.output.")
+			handler, name, _ := strings.Cut(space, ".")
+			matches := c.FirstValue("match")
+			for _, match := range matches.Values {
+				ps := strings.Fields(match)
+				priority, err := strconv.Atoi(ps[0])
+				if err != nil {
+					return err
+				}
+				r.add(name, handler, ps[1], priority, c)
 			}
-			r.add(name, handler, ps[1], priority, c)
 		}
 	}
 
diff --git a/mercury/routes.go b/mercury/routes.go
index 4041fdd..62b63dc 100644
--- a/mercury/routes.go
+++ b/mercury/routes.go
@@ -1,8 +1,10 @@
 package mercury
 
 import (
+	"embed"
 	"encoding/json"
 	"fmt"
+	"io/fs"
 	"log"
 	"net/http"
 	"sort"
@@ -11,8 +13,8 @@ import (
 
 	"github.com/BurntSushi/toml"
 	"github.com/golang/gddo/httputil"
-	"go.sour.is/pkg/lg"
 	"go.sour.is/pkg/ident"
+	"go.sour.is/pkg/lg"
 )
 
 type root struct{}
@@ -21,8 +23,13 @@ func NewHTTP() *root {
 	return &root{}
 }
 
+//go:embed public
+var public embed.FS
+
 func (s *root) RegisterHTTP(mux *http.ServeMux) {
-	mux.Handle("/", http.FileServer(http.Dir("./mercury/public")))
+	// mux.Handle("/", http.FileServer(http.Dir("./mercury/public")))
+	public, _ := fs.Sub(public, "public")
+	mux.Handle("/", http.FileServerFS(public))
 }
 func (s *root) RegisterAPIv1(mux *http.ServeMux) {
 	mux.HandleFunc("GET /mercury", s.indexV1)
@@ -30,6 +37,9 @@ func (s *root) RegisterAPIv1(mux *http.ServeMux) {
 	mux.HandleFunc("GET /mercury/config", s.configV1)
 	mux.HandleFunc("POST /mercury/config", s.storeV1)
 }
+func (s *root) RegisterWellKnown(mux *http.ServeMux) {
+	s.RegisterAPIv1(mux)
+}
 
 func (s *root) configV1(w http.ResponseWriter, r *http.Request) {
 	if r.Method == http.MethodPost {
@@ -62,7 +72,7 @@ func (s *root) configV1(w http.ResponseWriter, r *http.Request) {
 	log.Print("SPC:  ", space)
 	ns := ParseNamespace(space)
 	log.Print("PRE:  ", ns)
-	ns = rules.ReduceSearch(ns)
+	//ns = rules.ReduceSearch(ns)
 	log.Print("POST: ", ns)
 
 	lis, err := Registry.GetConfig(ctx, ns.String(), "", "")
diff --git a/mercury/sql/init-pg.sql b/mercury/sql/init-pg.sql
index 780ed09..75f64b6 100644
--- a/mercury/sql/init-pg.sql
+++ b/mercury/sql/init-pg.sql
@@ -6,6 +6,7 @@ CREATE TABLE IF NOT EXISTS mercury_spaces
     id integer NOT NULL DEFAULT nextval('mercury_spaces_id_seq'::regclass),
     notes character varying[] NOT NULL DEFAULT '{}'::character varying[],
     tags character varying[] NOT NULL DEFAULT '{}'::character varying[],
+    trailer character varying[] NOT NULL DEFAULT '{}'::character varying[],
     CONSTRAINT mercury_namespace_pk PRIMARY KEY (id)
 );
 CREATE UNIQUE INDEX IF NOT EXISTS mercury_namespace_space_uindex
@@ -35,7 +36,8 @@ CREATE OR REPLACE VIEW mercury_registry_vw
     v.name,
     v."values",
     v.notes,
-    v.tags
+    v.tags,
+    s.trailer
  FROM mercury_spaces s
  JOIN mercury_values v ON s.id = v.id;
 
diff --git a/mercury/sql/init-sql3.sql b/mercury/sql/init-sql3.sql
index 0be20bf..394bb67 100644
--- a/mercury/sql/init-sql3.sql
+++ b/mercury/sql/init-sql3.sql
@@ -3,7 +3,8 @@ CREATE TABLE IF NOT EXISTS mercury_spaces
     space character varying NOT NULL unique,
     id integer NOT NULL CONSTRAINT mercury_namespace_pk PRIMARY KEY autoincrement,
     notes json NOT NULL DEFAULT '[]',
-    tags json NOT NULL DEFAULT '[]'
+    tags json NOT NULL DEFAULT '[]',
+    trailer json NOT NULL DEFAULT '[]'
 );
 
 CREATE TABLE IF NOT EXISTS mercury_values
@@ -27,7 +28,8 @@ CREATE VIEW if not exists mercury_registry_vw
     v.name,
     v."values",
     v.notes,
-    v.tags
+    v.tags,
+    s.trailer
  FROM mercury_spaces s
  JOIN mercury_values v ON s.id = v.id;
 
diff --git a/mercury/sql/sql.go b/mercury/sql/sql.go
index 92193a1..9a606e9 100644
--- a/mercury/sql/sql.go
+++ b/mercury/sql/sql.go
@@ -166,7 +166,7 @@ func (p *sqlHandler) listSpace(ctx context.Context, tx sq.BaseRunner, where sq.S
 		tx = p.db
 	}
 
-	query := sq.Select(`"id"`, `"space"`, `"tags"`, `"notes"`).
+	query := sq.Select(`"id"`, `"space"`, `"notes"`, `"tags"`, `"trailer"`).
 		From("mercury_spaces").
 		Where(where).
 		OrderBy("space asc").
@@ -189,6 +189,7 @@ func (p *sqlHandler) listSpace(ctx context.Context, tx sq.BaseRunner, where sq.S
 			&s.Space.Space,
 			listScan(&s.Space.Notes, p.listFormat),
 			listScan(&s.Space.Tags, p.listFormat),
+			listScan(&s.Trailer, p.listFormat),
 		)
 		if err != nil {
 			return nil, err
@@ -287,6 +288,7 @@ func (p *sqlHandler) WriteConfig(ctx context.Context, config mercury.Config) (er
 			Where(sq.Eq{"id": updateIDs[i]}).
 			Set("tags", listValue(u.Tags, p.listFormat)).
 			Set("notes", listValue(u.Notes, p.listFormat)).
+			Set("trailer", listValue(u.Trailer, p.listFormat)).
 			PlaceholderFormat(sq.Dollar)
 		span.AddEvent(lg.LogQuery(query.ToSql()))
 		_, err := query.RunWith(tx).ExecContext(ctx)
@@ -305,8 +307,13 @@ func (p *sqlHandler) WriteConfig(ctx context.Context, config mercury.Config) (er
 		var id uint64
 		query := sq.Insert("mercury_spaces").
 			PlaceholderFormat(sq.Dollar).
-			Columns("space", "tags", "notes").
-			Values(s.Space, listValue(s.Tags, p.listFormat), listValue(s.Notes, p.listFormat)).
+			Columns("space", "tags", "notes", "trailer").
+			Values(
+				s.Space, 
+				listValue(s.Tags, p.listFormat), 
+				listValue(s.Notes, p.listFormat),
+				listValue(s.Trailer, p.listFormat),
+				).
 			Suffix("RETURNING \"id\"")
 		span.AddEvent(lg.LogQuery(query.ToSql()))
 
-- 
2.45.1


From b1bff4cbf0a013c0afd5bb6eab3e5a0c3e240f02 Mon Sep 17 00:00:00 2001
From: xuu 
Date: Fri, 19 Apr 2024 10:56:27 -0600
Subject: [PATCH 3/4] add libsql support

---
 go.mod                            |   7 +-
 go.sum                            |   5 +
 go.work.sum                       |   9 +-
 ident/source/mercury.go           |  55 +++---
 libsql_embed/open.go              |  68 ++++++--
 mercury/app/environ.go            |  17 +-
 mercury/namespace_test.go         |  59 -------
 mercury/registry.go               |  90 +++++-----
 mercury/routes.go                 |  10 +-
 mercury/{namespace.go => spec.go} |  89 +++++++++-
 mercury/spec_test.go              | 109 ++++++++++++
 mercury/sql/list-string.go        |   4 +-
 mercury/sql/notify.go             |   2 +-
 mercury/sql/otel.go               |  34 ++--
 mercury/sql/sql.go                | 280 ++++++++++++++++++++++++------
 rsql/dbcolumns.go                 |  18 +-
 service/service.go                |   1 -
 17 files changed, 609 insertions(+), 248 deletions(-)
 delete mode 100644 mercury/namespace_test.go
 rename mercury/{namespace.go => spec.go} (53%)
 create mode 100644 mercury/spec_test.go

diff --git a/go.mod b/go.mod
index f8cf90d..8e9e505 100644
--- a/go.mod
+++ b/go.mod
@@ -61,7 +61,6 @@ require (
 	github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.1 // indirect
 	github.com/oklog/ulid/v2 v2.1.0
 	github.com/prometheus/client_golang v1.18.0
-	github.com/tursodatabase/go-libsql v0.0.0-20240322134723-08771dcdd2f1
 	go.nhat.io/otelsql v0.12.0
 	go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.48.0
 	go.opentelemetry.io/contrib/instrumentation/runtime v0.48.0
@@ -72,10 +71,10 @@ require (
 	go.opentelemetry.io/otel/trace v1.23.1
 	go.opentelemetry.io/proto/otlp v1.1.0 // indirect
 	golang.org/x/exp v0.0.0-20240213143201-ec583247a57a
-	golang.org/x/net v0.21.0 // indirect
-	golang.org/x/sys v0.17.0 // indirect
+	golang.org/x/net v0.23.0 // indirect
+	golang.org/x/sys v0.18.0 // indirect
 	golang.org/x/text v0.14.0 // indirect
 	google.golang.org/grpc v1.61.1 // indirect
-	google.golang.org/protobuf v1.32.0 // indirect
+	google.golang.org/protobuf v1.33.0 // indirect
 	modernc.org/sqlite v1.29.1
 )
diff --git a/go.sum b/go.sum
index dedc078..aa7574f 100644
--- a/go.sum
+++ b/go.sum
@@ -183,6 +183,8 @@ golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
 golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
 golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4=
 golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
+golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs=
+golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
 golang.org/x/oauth2 v0.0.0-20170912212905-13449ad91cb2/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
 golang.org/x/sync v0.0.0-20170517211232-f52d1811a629/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ=
@@ -191,6 +193,7 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h
 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y=
 golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
 golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
@@ -216,6 +219,8 @@ google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp0
 google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
 google.golang.org/protobuf v1.32.0 h1:pPC6BG5ex8PDFnkbrGU3EixyhKcQ2aDuBS36lqK/C7I=
 google.golang.org/protobuf v1.32.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
+google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI=
+google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
diff --git a/go.work.sum b/go.work.sum
index 370b28e..a32bac6 100644
--- a/go.work.sum
+++ b/go.work.sum
@@ -160,7 +160,6 @@ github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:W
 github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
 github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
 github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
-github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
 github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
 github.com/google/flatbuffers v23.5.26+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
 github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
@@ -242,6 +241,8 @@ go.opentelemetry.io/otel/trace v1.21.0/go.mod h1:LGbsEB0f9LGjN+OZaQQ26sohbOmiMR+
 go.opentelemetry.io/otel/trace v1.22.0/go.mod h1:RbbHXVqKES9QhzZq/fE5UnOSILqRt40a21sPw2He1xo=
 go.opentelemetry.io/otel/trace v1.23.0/go.mod h1:GSGTbIClEsuZrGIzoEHqsVfxgn5UkggkflQwDScNUsk=
 golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
+golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA=
+golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
 golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
 golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
 golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
@@ -275,10 +276,10 @@ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBc
 golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4=
 golang.org/x/telemetry v0.0.0-20240208230135-b75ee8823808/go.mod h1:KG1lNk5ZFNssSZLrpVb4sMXKMpGwGXOxSG3rnu2gZQQ=
-golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
+golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58=
 golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
-golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
 golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
 golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
@@ -291,7 +292,6 @@ golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6f
 google.golang.org/api v0.162.0/go.mod h1:6SulDkfoBIg4NFmCuZ39XeeAgSHCPecfSUuDyYlAHs0=
 google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
 google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
-google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds=
 google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
 google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
 google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
@@ -325,7 +325,6 @@ google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2
 google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
 google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
 gopkg.in/ini.v1 v1.66.4/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
-gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw=
 honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
 honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
 lukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
diff --git a/ident/source/mercury.go b/ident/source/mercury.go
index c290cb5..142aa8f 100644
--- a/ident/source/mercury.go
+++ b/ident/source/mercury.go
@@ -7,17 +7,17 @@ import (
 	"strings"
 	"time"
 
+	"go.sour.is/pkg/ident"
 	"go.sour.is/pkg/lg"
 	"go.sour.is/pkg/mercury"
-	"go.sour.is/pkg/ident"
 )
 
 const identNS = "ident."
 const identSFX = ".credentials"
 
 type registry interface {
-	GetIndex(ctx context.Context, match, search string) (c mercury.Config, err error)
-	GetConfig(ctx context.Context, match, search, fields string) (mercury.Config, error)
+	GetIndex(ctx context.Context, search mercury.Search) (c mercury.Config, err error)
+	GetConfig(ctx context.Context, search mercury.Search) (mercury.Config, error)
 	WriteConfig(ctx context.Context, spaces mercury.Config) error
 }
 
@@ -50,6 +50,7 @@ func (id *mercuryIdent) FromConfig(cfg mercury.Config) error {
 		switch {
 		case strings.HasSuffix(s.Space, ".credentials"):
 			id.passwd = []byte(s.FirstValue("passwd").First())
+			id.ed25519 = []byte(s.FirstValue("ed25519").First())
 		default:
 			id.display = s.FirstValue("displayName").First()
 		}
@@ -59,34 +60,29 @@ func (id *mercuryIdent) FromConfig(cfg mercury.Config) error {
 
 func (id *mercuryIdent) ToConfig() mercury.Config {
 	space := id.Space()
+	list := func(values ...mercury.Value) []mercury.Value { return values }
+	value := func(space string, seq uint64, name string, values ...string) mercury.Value {
+		return mercury.Value{
+			Space:  space,
+			Seq:    seq,
+			Name:   name,
+			Values: values,
+		}
+	}
 	return mercury.Config{
 		&mercury.Space{
 			Space: space,
-			List: []mercury.Value{
-				{
-					Space:  space,
-					Seq:    1,
-					Name:   "displayName",
-					Values: []string{id.display},
-				},
-				{
-					Space:  space,
-					Seq:    2,
-					Name:   "lastLogin",
-					Values: []string{time.UnixMilli(int64(id.Session().SessionID.Time())).Format(time.RFC3339)},
-				},
-			},
+			List: list(
+				value(space, 1, "displayName", id.display),
+				value(space, 2, "lastLogin", time.UnixMilli(int64(id.Session().SessionID.Time())).Format(time.RFC3339)),
+			),
 		},
 		&mercury.Space{
 			Space: space + identSFX,
-			List: []mercury.Value{
-				{
-					Space:  space + identSFX,
-					Seq:    1,
-					Name:   "passwd",
-					Values: []string{string(id.passwd)},
-				},
-			},
+			List: list(
+				value(space+identSFX, 1, "passwd", string(id.passwd)),
+				value(space+identSFX, 1, "ed25519", string(id.ed25519)),
+			),
 		},
 	}
 }
@@ -140,7 +136,7 @@ func (s *mercurySource) readIdentURL(r *http.Request) (ident.Ident, error) {
 	}
 
 	space := id.Space()
-	c, err := s.r.GetConfig(ctx, "trace:"+space+identSFX, "", "")
+	c, err := s.r.GetConfig(ctx, mercury.ParseSearch("trace:"+space+identSFX))
 	if err != nil {
 		span.RecordError(err)
 		return id, err
@@ -183,7 +179,7 @@ func (s *mercurySource) readIdentBasic(r *http.Request) (ident.Ident, error) {
 	}
 
 	space := id.Space()
-	c, err := s.r.GetConfig(ctx, "trace:"+space+identSFX, "", "")
+	c, err := s.r.GetConfig(ctx, mercury.ParseSearch("trace:"+space+identSFX))
 	if err != nil {
 		span.RecordError(err)
 		return id, err
@@ -228,7 +224,7 @@ func (s *mercurySource) readIdentHTTP(r *http.Request) (ident.Ident, error) {
 	}
 
 	space := id.Space()
-	c, err := s.r.GetConfig(ctx, "trace:"+space+identSFX, "", "")
+	c, err := s.r.GetConfig(ctx, mercury.ParseSearch("trace:"+space+identSFX))
 	if err != nil {
 		span.RecordError(err)
 		return id, err
@@ -260,9 +256,8 @@ func (s *mercurySource) RegisterIdent(ctx context.Context, identity, display str
 	defer span.End()
 
 	id := &mercuryIdent{identity: identity, display: display, passwd: passwd}
-	space := id.Space()
 
-	_, err := s.r.GetIndex(ctx, space, "")
+	_, err := s.r.GetIndex(ctx, mercury.ParseSearch( id.Space()))
 	if err != nil {
 		return nil, err
 	}
diff --git a/libsql_embed/open.go b/libsql_embed/open.go
index 2da1a52..c8ce30d 100644
--- a/libsql_embed/open.go
+++ b/libsql_embed/open.go
@@ -4,7 +4,10 @@ import (
 	"context"
 	"database/sql"
 	"database/sql/driver"
+	"errors"
 	"fmt"
+	"io"
+	"log"
 	"net/url"
 	"os"
 	"path/filepath"
@@ -17,28 +20,37 @@ import (
 )
 
 func init() {
-	sql.Register("libsql+embed", &db{})
+	sql.Register("libsql+embed", &db{conns: make(map[string]*connector)})
 }
 
 type db struct {
-	conns map[string]connector
+	conns map[string]*connector
 	mu    sync.RWMutex
 }
 
 type connector struct {
 	*libsql.Connector
-	dsn    string
-	dir    string
-	driver *db
+	dsn       string
+	dir       string
+	driver    *db
+	removeDir bool
 }
 
+var _ io.Closer = (*connector)(nil)
+
 func (c *connector) Close() error {
+	log.Println("closing db connection", c.dir)
+	defer log.Println("closed db connection", c.dir)
+
 	c.driver.mu.Lock()
 	delete(c.driver.conns, c.dsn)
 	c.driver.mu.Unlock()
 
-	defer os.RemoveAll(c.dir)
+	if c.removeDir {
+		defer os.RemoveAll(c.dir)
+	}
 
+	log.Println("sync db")
 	if err := c.Connector.Sync(); err != nil {
 		return fmt.Errorf("syncing database: %w", err)
 	}
@@ -47,7 +59,12 @@ func (c *connector) Close() error {
 }
 
 func (db *db) OpenConnector(dsn string) (driver.Connector, error) {
-	if c, ok := func() (connector, bool) {
+	// log.Println("connector", dsn)
+	if dsn == "" {
+		return nil, fmt.Errorf("no dsn")
+	}
+
+	if c, ok := func() (*connector, bool) {
 		db.mu.RLock()
 		defer db.mu.RUnlock()
 		c, ok := db.conns[dsn]
@@ -79,20 +96,39 @@ func (db *db) OpenConnector(dsn string) (driver.Connector, error) {
 		libsql.WithAuthToken(authToken),
 	}
 
-	if refresh, err := strconv.ParseInt(u.Query().Get("refresh"),10,64); err == nil {
+	if refresh, err := strconv.ParseInt(u.Query().Get("refresh"), 10, 64); err == nil {
+		log.Println("refresh: ", refresh)
 		opts = append(opts, libsql.WithSyncInterval(time.Duration(refresh)*time.Minute))
 	}
 
 	if readWrite, err := strconv.ParseBool(u.Query().Get("readYourWrites")); err == nil {
+		log.Println("read your writes: ", readWrite)
 		opts = append(opts, libsql.WithReadYourWrites(readWrite))
 	}
 	if key := u.Query().Get("key"); key != "" {
 		opts = append(opts, libsql.WithEncryption(key))
 	}
-	
-	dir, err := os.MkdirTemp("", "libsql-*")
-	if err != nil {
-		return nil, fmt.Errorf("creating temporary directory: %w", err)
+
+	var dir string
+	var removeDir bool
+	if dir = u.Query().Get("store"); dir == "" {
+		removeDir = true
+		dir, err = os.MkdirTemp("", "libsql-*")
+		log.Println("creating temporary directory:", dir)
+		if err != nil {
+			return nil, fmt.Errorf("creating temporary directory: %w", err)
+		}
+	} else {
+		stat, err := os.Stat(dir)
+		if errors.Is(err, os.ErrNotExist) {
+			if err = os.MkdirAll(dir, 0700); err != nil {
+				return nil, err
+			}
+		} else {
+			if !stat.IsDir() {
+				return nil, fmt.Errorf("store not directory")
+			}
+		}
 	}
 
 	dbPath := filepath.Join(dir, dbname)
@@ -105,13 +141,19 @@ func (db *db) OpenConnector(dsn string) (driver.Connector, error) {
 		return nil, fmt.Errorf("creating connector: %w", err)
 	}
 
-	connector := connector{c, dsn, dir, db}
+	log.Println("sync db")
+	if err := c.Sync(); err != nil {
+		return nil, fmt.Errorf("syncing database: %w", err)
+	}
+	connector := &connector{c, dsn, dir, db, removeDir}
 	db.conns[dsn] = connector
 
 	return connector, nil
 }
 
 func (db *db) Open(dsn string) (driver.Conn, error) {
+	log.Println("open", dsn)
+
 	c, err := db.OpenConnector(dsn)
 	if err != nil {
 		return nil, err
diff --git a/mercury/app/environ.go b/mercury/app/environ.go
index 9a8aa76..dcc9ef3 100644
--- a/mercury/app/environ.go
+++ b/mercury/app/environ.go
@@ -8,9 +8,8 @@ import (
 	"sort"
 	"strings"
 
-	"go.sour.is/pkg/mercury"
 	"go.sour.is/pkg/ident"
-	"go.sour.is/pkg/rsql"
+	"go.sour.is/pkg/mercury"
 	"go.sour.is/pkg/set"
 )
 
@@ -20,8 +19,9 @@ const (
 	mercuryHost     = "mercury.host"
 	appDotEnviron   = "mercury.environ"
 )
+
 var (
-	mercuryPolicy   = func(id string) string { return "mercury.@" + id + ".policy" }
+	mercuryPolicy = func(id string) string { return "mercury.@" + id + ".policy" }
 )
 
 func Register(name string, cfg mercury.SpaceMap) {
@@ -41,8 +41,13 @@ type mercuryEnviron struct {
 	lookup func(context.Context, ident.Ident) (mercury.Rules, error)
 }
 
+func getSearch(spec mercury.Search) mercury.NamespaceSearch {
+	return spec.NamespaceSearch
+}
+
 // Index returns nil
-func (app *mercuryEnviron) GetIndex(ctx context.Context, search mercury.NamespaceSearch, _ *rsql.Program) (lis mercury.Config, err error) {
+func (app *mercuryEnviron) GetIndex(ctx context.Context, spec mercury.Search) (lis mercury.Config, err error) {
+	search := getSearch(spec)
 
 	if search.Match(mercurySource) {
 		for _, s := range app.cfg.ToArray() {
@@ -74,7 +79,9 @@ func (app *mercuryEnviron) GetIndex(ctx context.Context, search mercury.Namespac
 }
 
 // Objects returns nil
-func (app *mercuryEnviron) GetConfig(ctx context.Context, search mercury.NamespaceSearch, _ *rsql.Program, _ []string) (lis mercury.Config, err error) {
+func (app *mercuryEnviron) GetConfig(ctx context.Context, spec mercury.Search) (lis mercury.Config, err error) {
+	search := getSearch(spec)
+
 	if search.Match(mercurySource) {
 		for _, s := range app.cfg.ToArray() {
 			if search.Match(s.Space) {
diff --git a/mercury/namespace_test.go b/mercury/namespace_test.go
deleted file mode 100644
index 82e7951..0000000
--- a/mercury/namespace_test.go
+++ /dev/null
@@ -1,59 +0,0 @@
-package mercury_test
-
-import (
-	"fmt"
-	"testing"
-
-	"github.com/matryer/is"
-	"go.sour.is/pkg/mercury"
-
-	sq "github.com/Masterminds/squirrel"
-)
-
-func TestNamespaceParse(t *testing.T) {
-	var tests = []struct {
-		in  string
-		out string
-		args []any
-	}{
-		{
-			in: "d42.bgp.kapha.*;trace:d42.bgp.kapha",
-			out: "(column LIKE ? OR ? LIKE column || '%')",
-			args: []any{"d42.bgp.kapha.%", "d42.bgp.kapha"},
-		},
-
-		{
-			in: "d42.bgp.kapha.*,d42.bgp.kapha",
-			out: "(column LIKE ? OR column = ?)",
-			args: []any{"d42.bgp.kapha.%", "d42.bgp.kapha"},
-		},
-	}
-
-	for i, tt := range tests {
-		t.Run(fmt.Sprintf("test %d", i), func(t *testing.T) {
-			is := is.New(t)
-			out := mercury.ParseNamespace(tt.in)
-			sql, args, err := getWhere(out).ToSql()
-			is.NoErr(err)
-			is.Equal(sql, tt.out)
-			is.Equal(args, tt.args)
-		})
-	}
-}
-
-func getWhere(search mercury.NamespaceSearch) sq.Sqlizer {
-	var where sq.Or
-	space := "column"
-	for _, m := range search {
-		switch m.(type) {
-		case mercury.NamespaceNode:
-			where = append(where, sq.Eq{space: m.Value()})
-		case mercury.NamespaceStar:
-			where = append(where, sq.Like{space: m.Value()})
-		case mercury.NamespaceTrace:
-			e := sq.Expr(`? LIKE `+space+` || '%'`, m.Value())
-			where = append(where, e)
-		}
-	}
-	return where
-}
diff --git a/mercury/registry.go b/mercury/registry.go
index a48d371..ee98d26 100644
--- a/mercury/registry.go
+++ b/mercury/registry.go
@@ -3,6 +3,7 @@ package mercury
 import (
 	"context"
 	"fmt"
+	"log"
 	"path/filepath"
 	"sort"
 	"strconv"
@@ -10,16 +11,15 @@ import (
 
 	"go.sour.is/pkg/ident"
 	"go.sour.is/pkg/lg"
-	"go.sour.is/pkg/rsql"
 	"go.sour.is/pkg/set"
 	"golang.org/x/sync/errgroup"
 )
 
 type GetIndex interface {
-	GetIndex(context.Context, NamespaceSearch, *rsql.Program) (Config, error)
+	GetIndex(context.Context, Search) (Config, error)
 }
 type GetConfig interface {
-	GetConfig(context.Context, NamespaceSearch, *rsql.Program, []string) (Config, error)
+	GetConfig(context.Context, Search) (Config, error)
 }
 type WriteConfig interface {
 	WriteConfig(context.Context, Config) error
@@ -60,7 +60,7 @@ func (reg *registry) accessFilter(rules Rules, lis Config) (out Config, err erro
 // HandlerItem a single handler matching
 type matcher[T any] struct {
 	Name     string
-	Match    NamespaceSearch
+	Match    Search
 	Priority int
 	Handler  T
 }
@@ -122,17 +122,23 @@ func (r *registry) Register(name string, h func(*Space) any) {
 func (r *registry) Configure(m SpaceMap) error {
 	r.resetMatchers()
 	for space, c := range m {
+		log.Println("configure: ", space)
+
 		if strings.HasPrefix(space, "mercury.source.") {
 			space = strings.TrimPrefix(space, "mercury.source.")
 			handler, name, _ := strings.Cut(space, ".")
 			matches := c.FirstValue("match")
+			readonly := c.HasTag("readonly")
 			for _, match := range matches.Values {
 				ps := strings.Fields(match)
 				priority, err := strconv.Atoi(ps[0])
 				if err != nil {
 					return err
 				}
-				r.add(name, handler, ps[1], priority, c)
+				err = r.add(name, handler, strings.Join(ps[1:],"|"), priority, c, readonly)
+				if err != nil {
+					return err
+				}
 			}
 		}
 
@@ -146,7 +152,10 @@ func (r *registry) Configure(m SpaceMap) error {
 				if err != nil {
 					return err
 				}
-				r.add(name, handler, ps[1], priority, c)
+				err = r.add(name, handler, strings.Join(ps[1:],"|"), priority, c, false)
+				if err != nil {
+					return err
+				}
 			}
 		}
 	}
@@ -156,8 +165,8 @@ func (r *registry) Configure(m SpaceMap) error {
 }
 
 // Register add a handler to registry
-func (r *registry) add(name, handler, match string, priority int, cfg *Space) error {
-	// log.Infos("mercury regster", "match", match, "pri", priority)
+func (r *registry) add(name, handler, match string, priority int, cfg *Space, readonly bool) error {
+	log.Println("mercury regster", "match", match, "pri", priority)
 	mkHandler, ok := r.handlers[handler]
 	if !ok {
 		return fmt.Errorf("handler not registered: %s", handler)
@@ -173,61 +182,68 @@ func (r *registry) add(name, handler, match string, priority int, cfg *Space) er
 	if hdlr, ok := hdlr.(GetIndex); ok {
 		r.matchers.getIndex = append(
 			r.matchers.getIndex,
-			matcher[GetIndex]{Name: name, Match: ParseNamespace(match), Priority: priority, Handler: hdlr},
+			matcher[GetIndex]{Name: name, Match: ParseSearch(match), Priority: priority, Handler: hdlr},
 		)
 	}
 	if hdlr, ok := hdlr.(GetConfig); ok {
 		r.matchers.getConfig = append(
 			r.matchers.getConfig,
-			matcher[GetConfig]{Name: name, Match: ParseNamespace(match), Priority: priority, Handler: hdlr},
+			matcher[GetConfig]{Name: name, Match: ParseSearch(match), Priority: priority, Handler: hdlr},
 		)
 	}
 
-	if hdlr, ok := hdlr.(WriteConfig); ok {
+	if hdlr, ok := hdlr.(WriteConfig); !readonly && ok {
 
 		r.matchers.writeConfig = append(
 			r.matchers.writeConfig,
-			matcher[WriteConfig]{Name: name, Match: ParseNamespace(match), Priority: priority, Handler: hdlr},
+			matcher[WriteConfig]{Name: name, Match: ParseSearch(match), Priority: priority, Handler: hdlr},
 		)
 	}
 	if hdlr, ok := hdlr.(GetRules); ok {
 		r.matchers.getRules = append(
 			r.matchers.getRules,
-			matcher[GetRules]{Name: name, Match: ParseNamespace(match), Priority: priority, Handler: hdlr},
+			matcher[GetRules]{Name: name, Match: ParseSearch(match), Priority: priority, Handler: hdlr},
 		)
 	}
 	if hdlr, ok := hdlr.(GetNotify); ok {
 		r.matchers.getNotify = append(
 			r.matchers.getNotify,
-			matcher[GetNotify]{Name: name, Match: ParseNamespace(match), Priority: priority, Handler: hdlr},
+			matcher[GetNotify]{Name: name, Match: ParseSearch(match), Priority: priority, Handler: hdlr},
 		)
 	}
 	if hdlr, ok := hdlr.(SendNotify); ok {
 		r.matchers.sendNotify = append(
 			r.matchers.sendNotify,
-			matcher[SendNotify]{Name: name, Match: ParseNamespace(match), Priority: priority, Handler: hdlr},
+			matcher[SendNotify]{Name: name, Match: ParseSearch(match), Priority: priority, Handler: hdlr},
 		)
 	}
 
 	return nil
 }
 
-// GetIndex query each handler that match namespace.
-func (r *registry) GetIndex(ctx context.Context, match, search string) (c Config, err error) {
-	ctx, span := lg.Span(ctx)
-	defer span.End()
+func getMatches(search Search, matchers matchers) []Search {
+	matches := make([]Search, len(matchers.getIndex))
 
-	spec := ParseNamespace(match)
-	pgm := rsql.DefaultParse(search)
-	matches := make([]NamespaceSearch, len(r.matchers.getIndex))
-
-	for _, n := range spec {
-		for i, hdlr := range r.matchers.getIndex {
+	for _, n := range search.NamespaceSearch {
+		for i, hdlr := range matchers.getIndex {
 			if hdlr.Match.Match(n.Raw()) {
-				matches[i] = append(matches[i], n)
+				matches[i].NamespaceSearch = append(matches[i].NamespaceSearch, n)
+				matches[i].Count = search.Count
+				matches[i].Cursor = search.Cursor // need to decode cursor for the match
+				matches[i].Fields = search.Fields
+				matches[i].Find = search.Find
 			}
 		}
 	}
+	return matches
+}
+
+// GetIndex query each handler that match namespace.
+func (r *registry) GetIndex(ctx context.Context, search Search) (c Config, err error) {
+	ctx, span := lg.Span(ctx)
+	defer span.End()
+
+	matches := getMatches(search, r.matchers)
 
 	wg, ctx := errgroup.WithContext(ctx)
 	slots := make(chan Config, len(r.matchers.getConfig))
@@ -248,7 +264,7 @@ func (r *registry) GetIndex(ctx context.Context, match, search string) (c Config
 
 		wg.Go(func() error {
 			span.AddEvent(fmt.Sprintf("INDEX %s %s", hdlr.Name, hdlr.Match))
-			lis, err := hdlr.Handler.GetIndex(ctx, matches[i], pgm)
+			lis, err := hdlr.Handler.GetIndex(ctx, matches[i])
 			slots <- lis
 			return err
 		})
@@ -265,31 +281,19 @@ func (r *registry) GetIndex(ctx context.Context, match, search string) (c Config
 // Search query each handler with a key=value search
 
 // GetConfig query each handler that match for fully qualified namespaces.
-func (r *registry) GetConfig(ctx context.Context, match, search, fields string) (Config, error) {
+func (r *registry) GetConfig(ctx context.Context, search Search) (Config, error) {
 	ctx, span := lg.Span(ctx)
 	defer span.End()
 
-	spec := ParseNamespace(match)
-	pgm := rsql.DefaultParse(search)
-	flds := strings.Split(fields, ",")
-
-	matches := make([]NamespaceSearch, len(r.matchers.getConfig))
-
-	for _, n := range spec {
-		for i, hdlr := range r.matchers.getConfig {
-			if hdlr.Match.Match(n.Raw()) {
-				matches[i] = append(matches[i], n)
-			}
-		}
-	}
+	matches := getMatches(search, r.matchers)
 
 	m := make(SpaceMap)
 	for i, hdlr := range r.matchers.getConfig {
-		if len(matches[i]) == 0 {
+		if len(matches[i].NamespaceSearch) == 0 {
 			continue
 		}
 		span.AddEvent(fmt.Sprintf("QUERY %s %s", hdlr.Name, hdlr.Match))
-		lis, err := hdlr.Handler.GetConfig(ctx, matches[i], pgm, flds)
+		lis, err := hdlr.Handler.GetConfig(ctx, matches[i])
 		if err != nil {
 			return nil, err
 		}
diff --git a/mercury/routes.go b/mercury/routes.go
index 62b63dc..279a649 100644
--- a/mercury/routes.go
+++ b/mercury/routes.go
@@ -70,12 +70,12 @@ func (s *root) configV1(w http.ResponseWriter, r *http.Request) {
 	}
 
 	log.Print("SPC:  ", space)
-	ns := ParseNamespace(space)
+	ns := ParseSearch(space)
 	log.Print("PRE:  ", ns)
 	//ns = rules.ReduceSearch(ns)
 	log.Print("POST: ", ns)
 
-	lis, err := Registry.GetConfig(ctx, ns.String(), "", "")
+	lis, err := Registry.GetConfig(ctx, ns)
 	if err != nil {
 		http.Error(w, "ERR: "+err.Error(), http.StatusInternalServerError)
 		return
@@ -250,11 +250,11 @@ func (s *root) indexV1(w http.ResponseWriter, r *http.Request) {
 		space = "*"
 	}
 
-	ns := ParseNamespace(space)
-	ns = rules.ReduceSearch(ns)
+	ns := ParseSearch(space)
+	ns.NamespaceSearch = rules.ReduceSearch(ns.NamespaceSearch)
 	span.AddEvent(ns.String())
 
-	lis, err := Registry.GetIndex(ctx, ns.String(), "")
+	lis, err := Registry.GetIndex(ctx, ns)
 	if err != nil {
 		span.RecordError(err)
 		http.Error(w, "ERR: "+err.Error(), http.StatusInternalServerError)
diff --git a/mercury/namespace.go b/mercury/spec.go
similarity index 53%
rename from mercury/namespace.go
rename to mercury/spec.go
index 328bfbe..55c356f 100644
--- a/mercury/namespace.go
+++ b/mercury/spec.go
@@ -1,11 +1,33 @@
 package mercury
 
 import (
+	"log"
 	"path/filepath"
+	"strconv"
 	"strings"
 )
 
-// NamespaceSpec implements a parsed namespace search
+// Search implements a parsed namespace search
+// It parses the input and generates an AST to inform the driver how to select values.
+// *                         => all spaces
+// mercury.*                 => all prefixed with `mercury.`
+// mercury.config            => only space `mercury.config`
+// mercury.source.*#readonly => all prefixed with `mercury.source.` AND has tag `readonly`
+// test.*|mercury.*          => all prefixed with `test.` AND `mercury.`
+// test.* find bin=eq=bar    => all prefixed with `test.` AND has an attribute bin that equals bar
+// test.* fields foo,bin     => all prefixed with `test.` only show fields foo and bin
+//   - count 20                => start a cursor with 20 results
+//   - count 20 after  => continue after cursor for 20 results
+//     cursor encodes start points for each of the matched sources
+type Search struct {
+	NamespaceSearch
+	Find   []ops
+	Fields []string
+	Count  uint64
+	Offset uint64
+	Cursor string
+}
+
 type NamespaceSpec interface {
 	Value() string
 	String() string
@@ -17,8 +39,10 @@ type NamespaceSpec interface {
 type NamespaceSearch []NamespaceSpec
 
 // ParseNamespace returns a list of parsed values
-func ParseNamespace(ns string) (lis NamespaceSearch) {
-	for _, part := range strings.Split(ns, ";") {
+func ParseSearch(text string) (search Search) {
+	ns, text, _ := strings.Cut(text, " ")
+	var lis NamespaceSearch
+	for _, part := range strings.Split(ns, "|") {
 		if strings.HasPrefix(part, "trace:") {
 			lis = append(lis, NamespaceTrace(part[6:]))
 		} else if strings.Contains(part, "*") {
@@ -27,6 +51,40 @@ func ParseNamespace(ns string) (lis NamespaceSearch) {
 			lis = append(lis, NamespaceNode(part))
 		}
 	}
+	search.NamespaceSearch = lis
+
+	field, text, next := strings.Cut(text, " ")
+	text = strings.TrimSpace(text)
+	for next {
+		switch strings.ToLower(field) {
+		case "find":
+			field, text, _ = strings.Cut(text, " ")
+			text = strings.TrimSpace(text)
+			search.Find = simpleParse(field)
+
+		case "fields":
+			field, text, _ = strings.Cut(text, " ")
+			text = strings.TrimSpace(text)
+			search.Fields = strings.Split(field, ",")
+
+		case "count":
+			field, text, _ = strings.Cut(text, " ")
+			text = strings.TrimSpace(text)
+			search.Count, _ = strconv.ParseUint(field, 10, 64)
+
+		case "offset":
+			field, text, _ = strings.Cut(text, " ")
+			text = strings.TrimSpace(text)
+			search.Offset, _ = strconv.ParseUint(field, 10, 64)
+
+		case "after":
+			field, text, _ = strings.Cut(text, " ")
+			text = strings.TrimSpace(text)
+			search.Cursor = field
+		}
+		field, text, next = strings.Cut(text, " ")
+		text = strings.TrimSpace(text)
+	}
 
 	return
 }
@@ -117,3 +175,28 @@ func match(n NamespaceSpec, s string) bool {
 	}
 	return ok
 }
+
+type ops struct {
+	Left  string
+	Op    string
+	Right string
+}
+
+func simpleParse(in string) (out []ops) {
+	items := strings.Split(in, ",")
+	for _, i := range items {
+		log.Println(i)
+		eq := strings.Split(i, "=")
+		switch len(eq) {
+		case 2:
+			out = append(out, ops{eq[0], "eq", eq[1]})
+		case 3:
+			if eq[1] == "" {
+				eq[1] = "eq"
+			}
+			out = append(out, ops{eq[0], eq[1], eq[2]})
+		}
+	}
+
+	return
+}
diff --git a/mercury/spec_test.go b/mercury/spec_test.go
new file mode 100644
index 0000000..f8fbb36
--- /dev/null
+++ b/mercury/spec_test.go
@@ -0,0 +1,109 @@
+package mercury_test
+
+import (
+	"fmt"
+	"testing"
+
+	"github.com/matryer/is"
+	"go.sour.is/pkg/mercury"
+	"go.sour.is/pkg/mercury/sql"
+
+	sq "github.com/Masterminds/squirrel"
+)
+
+var MAX_FILTER int = 40
+
+func TestNamespaceParse(t *testing.T) {
+	var tests = []struct {
+		getWhere func(mercury.Search) sq.Sqlizer
+		in       string
+		out      string
+		args     []any
+	}{
+		{
+			getWhere: getWhere,
+			in:       "d42.bgp.kapha.*|trace:d42.bgp.kapha",
+			out:      "(column LIKE ? OR ? LIKE column || '%')",
+			args:     []any{"d42.bgp.kapha.%", "d42.bgp.kapha"},
+		},
+
+		{
+			getWhere: getWhere,
+			in:       "d42.bgp.kapha.*|d42.bgp.kapha",
+			out:      "(column LIKE ? OR column = ?)",
+			args:     []any{"d42.bgp.kapha.%", "d42.bgp.kapha"},
+		},
+
+		{
+			getWhere: mkWhere(t, sql.GetWhereSQ),
+			in:       "d42.bgp.kapha.* find active=eq=true",
+			out:      `SELECT * FROM spaces JOIN ( SELECT DISTINCT id FROM mercury_values mv, json_each(mv."values") vs WHERE (json_valid("values") AND name = ? AND vs.value = ?) ) r000 USING (id) WHERE (space LIKE ?)`,
+			args:     []any{"active", "true", "d42.bgp.kapha.%"},
+		},
+
+		{
+			getWhere: mkWhere(t, sql.GetWhereSQ),
+			in:       "d42.bgp.kapha.*   count 10 offset 5",
+			out:      `SELECT * FROM spaces WHERE (space LIKE ?) LIMIT 10 OFFSET 5`,
+			args:     []any{"d42.bgp.kapha.%"},
+		},
+
+		{
+			getWhere: mkWhere(t, sql.GetWhereSQ),
+			in:       "d42.bgp.kapha.* fields a,b,c",
+			out:      `SELECT * FROM spaces WHERE (space LIKE ?)`,
+			args:     []any{"d42.bgp.kapha.%"},
+		},
+
+		{
+			getWhere: mkWhere(t, sql.GetWhereSQ),
+			in:       "dn42.* find @type=in=[person,net]",
+			out:      `SELECT `,
+			args:     []any{"d42.bgp.kapha.%"},
+		},
+	}
+
+//SELECT * FROM spaces JOIN ( SELECT DISTINCT id FROM mercury_values mv, json_valid("values") vs WHERE (json_valid("values") AND name = ? AND vs.value = ?) ) r000 USING (id) WHERE (space LIKE ?) != 
+//SELECT * FROM spaces JOIN ( SELECT DISTINCT mv.id FROM mercury_values mv, json_each(mv."values") vs WHERE (json_valid("values") AND name = ? AND vs.value = ?) ) r000 USING (id) WHERE (space LIKE ?)
+
+	for i, tt := range tests {
+		t.Run(fmt.Sprintf("test %d", i), func(t *testing.T) {
+			is := is.New(t)
+			out := mercury.ParseSearch(tt.in)
+			sql, args, err := tt.getWhere(out).ToSql()
+			is.NoErr(err)
+			is.Equal(sql, tt.out)
+			is.Equal(args, tt.args)
+		})
+	}
+}
+
+func getWhere(search mercury.Search) sq.Sqlizer {
+	var where sq.Or
+	space := "column"
+	for _, m := range search.NamespaceSearch {
+		switch m.(type) {
+		case mercury.NamespaceNode:
+			where = append(where, sq.Eq{space: m.Value()})
+		case mercury.NamespaceStar:
+			where = append(where, sq.Like{space: m.Value()})
+		case mercury.NamespaceTrace:
+			e := sq.Expr(`? LIKE `+space+` || '%'`, m.Value())
+			where = append(where, e)
+		}
+	}
+	return where
+}
+
+func mkWhere(t *testing.T, where func(search mercury.Search) (func(sq.SelectBuilder) sq.SelectBuilder, error)) func(search mercury.Search) sq.Sqlizer {
+	t.Helper()
+
+	return func(search mercury.Search) sq.Sqlizer {
+		w, err := where(search)
+		if err != nil {
+			t.Log(err)
+			t.Fail()
+		}
+		return w(sq.Select("*").From("spaces"))
+	}
+}
diff --git a/mercury/sql/list-string.go b/mercury/sql/list-string.go
index 6b6fa2f..bbaff9b 100644
--- a/mercury/sql/list-string.go
+++ b/mercury/sql/list-string.go
@@ -43,9 +43,7 @@ func listScan(e *[]string, ends [2]rune) scanFn {
 			return nil
 		}
 
-		for _, s := range splitComma(string(str)) {
-			*e = append(*e, s)
-		}
+		*e = append(*e, splitComma(string(str))...)
 
 		return nil
 	}
diff --git a/mercury/sql/notify.go b/mercury/sql/notify.go
index 3f8711b..558c4b1 100644
--- a/mercury/sql/notify.go
+++ b/mercury/sql/notify.go
@@ -29,7 +29,7 @@ func (pgm *sqlHandler) GetNotify(ctx context.Context, event string) (lis mercury
 		Where(squirrel.Eq{"event": event}).
 		PlaceholderFormat(squirrel.Dollar).
 		RunWith(pgm.db).
-		QueryContext(context.TODO())
+		QueryContext(ctx)
 
 	if err != nil {
 		return nil, err
diff --git a/mercury/sql/otel.go b/mercury/sql/otel.go
index 354067a..b5bed4a 100644
--- a/mercury/sql/otel.go
+++ b/mercury/sql/otel.go
@@ -2,6 +2,7 @@ package sql
 
 import (
 	"database/sql"
+	"strings"
 
 	"go.nhat.io/otelsql"
 	semconv "go.opentelemetry.io/otel/semconv/v1.20.0"
@@ -9,25 +10,28 @@ import (
 
 func openDB(driver, dsn string) (*sql.DB, error) {
 	system := semconv.DBSystemPostgreSQL
-	if driver == "sqlite" {
+	if driver == "sqlite" || strings.HasPrefix(driver, "libsql") {
 		system = semconv.DBSystemSqlite
 	}
-	
-	// Register the otelsql wrapper for the provided postgres driver.
-	driverName, err := otelsql.Register(driver,
-		otelsql.AllowRoot(),
-		otelsql.TraceQueryWithoutArgs(),
-		otelsql.TraceRowsClose(),
-		otelsql.TraceRowsAffected(),
-		// otelsql.WithDatabaseName("my_database"),        // Optional.
-		otelsql.WithSystem(system), // Optional.
-	)
-	if err != nil {
-		return nil, err
+
+	if driver == "postgres" {
+		var err error
+		// Register the otelsql wrapper for the provided postgres driver.
+		driver, err = otelsql.Register(driver,
+			otelsql.AllowRoot(),
+			otelsql.TraceQueryWithoutArgs(),
+			otelsql.TraceRowsClose(),
+			otelsql.TraceRowsAffected(),
+			// otelsql.WithDatabaseName("my_database"),        // Optional.
+			otelsql.WithSystem(system), // Optional.
+		)
+		if err != nil {
+			return nil, err
+		}
 	}
-	
+
 	// Connect to a Postgres database using the postgres driver wrapper.
-	db, err := sql.Open(driverName, dsn)
+	db, err := sql.Open(driver, dsn)
 	if err != nil {
 		return nil, err
 	}
diff --git a/mercury/sql/sql.go b/mercury/sql/sql.go
index 9a606e9..a29cc24 100644
--- a/mercury/sql/sql.go
+++ b/mercury/sql/sql.go
@@ -3,21 +3,27 @@ package sql
 import (
 	"context"
 	"database/sql"
+	"errors"
 	"fmt"
 	"log"
+	"slices"
 	"strings"
 
 	sq "github.com/Masterminds/squirrel"
 	"go.sour.is/pkg/lg"
 	"go.sour.is/pkg/mercury"
-	"go.sour.is/pkg/rsql"
 	"golang.org/x/exp/maps"
 )
 
+var MAX_FILTER int = 40
+
 type sqlHandler struct {
+	name             string
 	db               *sql.DB
 	paceholderFormat sq.PlaceholderFormat
 	listFormat       [2]rune
+	readonly         bool
+	getWhere         func(search mercury.Search) (func(sq.SelectBuilder) sq.SelectBuilder, error)
 }
 
 var (
@@ -27,11 +33,13 @@ var (
 	_ mercury.WriteConfig = (*sqlHandler)(nil)
 )
 
-func Register() {
+func Register() func(context.Context) error {
+	var hdlrs []*sqlHandler
 	mercury.Registry.Register("sql", func(s *mercury.Space) any {
 		var dsn string
 		var opts strings.Builder
 		var dbtype string
+		var readonly bool = slices.Contains(s.Tags, "readonly")
 		for _, c := range s.List {
 			if c.Name == "match" {
 				continue
@@ -49,7 +57,6 @@ func Register() {
 		if dsn == "" {
 			dsn = opts.String()
 		}
-
 		db, err := openDB(dbtype, dsn)
 		if err != nil {
 			return err
@@ -58,31 +65,47 @@ func Register() {
 			return err
 		}
 		switch dbtype {
-		case "sqlite":
-			return &sqlHandler{db, sq.Dollar, [2]rune{'[', ']'}}
+		case "sqlite", "libsql", "libsql+embed":
+			h := &sqlHandler{s.Space, db, sq.Question, [2]rune{'[', ']'}, readonly, GetWhereSQ}
+			hdlrs = append(hdlrs, h)
+			return h
 		case "postgres":
-			return &sqlHandler{db, sq.Dollar, [2]rune{'{', '}'}}
+			h := &sqlHandler{s.Space, db, sq.Dollar, [2]rune{'{', '}'}, readonly, GetWherePG}
+			hdlrs = append(hdlrs, h)
+			return h
 		default:
 			return fmt.Errorf("unsupported dbtype: %s", dbtype)
 		}
 	})
+
+	return func(ctx context.Context) error {
+		var errs error
+
+		for _, h := range hdlrs {
+			// if err = ctx.Err(); err != nil {
+			// 	return  errors.Join(errs, err)
+			// }
+			errs = errors.Join(errs, h.db.Close())
+		}
+
+		return errs
+	}
 }
 
 type Space struct {
 	mercury.Space
-	ID uint64
+	id uint64
 }
 type Value struct {
 	mercury.Value
-	ID uint64
+	id uint64
 }
 
-func (p *sqlHandler) GetIndex(ctx context.Context, search mercury.NamespaceSearch, pgm *rsql.Program) (mercury.Config, error) {
+func (p *sqlHandler) GetIndex(ctx context.Context, search mercury.Search) (mercury.Config, error) {
 	ctx, span := lg.Span(ctx)
 	defer span.End()
 
-	cols := rsql.GetDbColumns(mercury.Space{})
-	where, err := getWhere(search, cols)
+	where, err := p.getWhere(search)
 	if err != nil {
 		return nil, err
 	}
@@ -100,28 +123,40 @@ func (p *sqlHandler) GetIndex(ctx context.Context, search mercury.NamespaceSearc
 	return config, nil
 }
 
-func (p *sqlHandler) GetConfig(ctx context.Context, search mercury.NamespaceSearch, pgm *rsql.Program, fields []string) (mercury.Config, error) {
+func (p *sqlHandler) GetConfig(ctx context.Context, search mercury.Search) (config mercury.Config, err error) {
 	ctx, span := lg.Span(ctx)
 	defer span.End()
 
-	idx, err := p.GetIndex(ctx, search, pgm)
+	where, err := p.getWhere(search)
 	if err != nil {
 		return nil, err
 	}
-	spaceMap := make(map[string]int, len(idx))
-	for u, s := range idx {
-		spaceMap[s.Space] = u
+	lis, err := p.listSpace(ctx, nil, where)
+	if err != nil {
+		log.Println(err)
+		return nil, err
 	}
 
-	where, err := getWhere(search, rsql.GetDbColumns(mercury.Value{}))
-	if err != nil {
-		return nil, err
+	if len(lis) == 0 {
+		return nil, nil
 	}
-	query := sq.Select(`"space"`, `"name"`, `"seq"`, `"notes"`, `"tags"`, `"values"`).
-		From("mercury_registry_vw").
-		Where(where).
-		OrderBy("space asc", "name asc").
+
+	spaceIDX := make([]uint64, len(lis))
+	spaceMap := make(map[uint64]int, len(lis))
+	config = make(mercury.Config, len(lis))
+	for i, s := range lis {
+		spaceIDX[i] = s.id
+		config[i] = &s.Space
+		spaceMap[s.id] = i
+	}
+
+	query := sq.Select(`"id"`, `"name"`, `"seq"`, `"notes"`, `"tags"`, `"values"`).
+		From("mercury_values").
+		Where(sq.Eq{"id": spaceIDX}).
+		OrderBy("id asc", "seq asc").
 		PlaceholderFormat(p.paceholderFormat)
+
+	span.AddEvent(p.name)
 	span.AddEvent(lg.LogQuery(query.ToSql()))
 	rows, err := query.RunWith(p.db).
 		QueryContext(ctx)
@@ -133,10 +168,10 @@ func (p *sqlHandler) GetConfig(ctx context.Context, search mercury.NamespaceSear
 
 	defer rows.Close()
 	for rows.Next() {
-		var s mercury.Value
+		var s Value
 
 		err = rows.Scan(
-			&s.Space,
+			&s.id,
 			&s.Name,
 			&s.Seq,
 			listScan(&s.Notes, p.listFormat),
@@ -146,19 +181,20 @@ func (p *sqlHandler) GetConfig(ctx context.Context, search mercury.NamespaceSear
 		if err != nil {
 			return nil, err
 		}
-		if u, ok := spaceMap[s.Space]; ok {
-			idx[u].List = append(idx[u].List, s)
+		if u, ok := spaceMap[s.id]; ok {
+			lis[u].List = append(lis[u].List, s.Value)
 		}
 	}
 
 	err = rows.Err()
 	span.RecordError(err)
 
-	span.AddEvent(fmt.Sprint("read index ", len(idx)))
-	return idx, err
+	span.AddEvent(fmt.Sprint("read index ", len(lis)))
+	// log.Println(config.String())
+	return config, err
 }
 
-func (p *sqlHandler) listSpace(ctx context.Context, tx sq.BaseRunner, where sq.Sqlizer) ([]*Space, error) {
+func (p *sqlHandler) listSpace(ctx context.Context, tx sq.BaseRunner, where func(sq.SelectBuilder) sq.SelectBuilder) ([]*Space, error) {
 	ctx, span := lg.Span(ctx)
 	defer span.End()
 
@@ -168,9 +204,11 @@ func (p *sqlHandler) listSpace(ctx context.Context, tx sq.BaseRunner, where sq.S
 
 	query := sq.Select(`"id"`, `"space"`, `"notes"`, `"tags"`, `"trailer"`).
 		From("mercury_spaces").
-		Where(where).
 		OrderBy("space asc").
-		PlaceholderFormat(sq.Dollar)
+		PlaceholderFormat(p.paceholderFormat)
+	query = where(query)
+
+	span.AddEvent(p.name)
 	span.AddEvent(lg.LogQuery(query.ToSql()))
 	rows, err := query.RunWith(tx).
 		QueryContext(ctx)
@@ -185,7 +223,7 @@ func (p *sqlHandler) listSpace(ctx context.Context, tx sq.BaseRunner, where sq.S
 	for rows.Next() {
 		var s Space
 		err = rows.Scan(
-			&s.ID,
+			&s.id,
 			&s.Space.Space,
 			listScan(&s.Space.Notes, p.listFormat),
 			listScan(&s.Space.Tags, p.listFormat),
@@ -209,6 +247,10 @@ func (p *sqlHandler) WriteConfig(ctx context.Context, config mercury.Config) (er
 	ctx, span := lg.Span(ctx)
 	defer span.End()
 
+	if p.readonly {
+		return fmt.Errorf("readonly database")
+	}
+
 	// Delete spaces that are present in input but are empty.
 	deleteSpaces := make(map[string]struct{})
 
@@ -233,7 +275,8 @@ func (p *sqlHandler) WriteConfig(ctx context.Context, config mercury.Config) (er
 	}()
 
 	// get current spaces
-	lis, err := p.listSpace(ctx, tx, sq.Eq{"space": maps.Keys(names)})
+	where := func(qry sq.SelectBuilder) sq.SelectBuilder { return qry.Where(sq.Eq{"space": maps.Keys(names)}) }
+	lis, err := p.listSpace(ctx, tx, where)
 	if err != nil {
 		return
 	}
@@ -250,12 +293,12 @@ func (p *sqlHandler) WriteConfig(ctx context.Context, config mercury.Config) (er
 		currentNames[spaceName] = struct{}{}
 
 		if _, ok := deleteSpaces[spaceName]; ok {
-			deleteIDs = append(deleteIDs, s.ID)
+			deleteIDs = append(deleteIDs, s.id)
 			continue
 		}
 
 		updateSpaces = append(updateSpaces, config[names[spaceName]])
-		updateIDs = append(updateIDs, s.ID)
+		updateIDs = append(updateIDs, s.id)
 	}
 	for _, s := range config {
 		spaceName := s.Space
@@ -266,7 +309,7 @@ func (p *sqlHandler) WriteConfig(ctx context.Context, config mercury.Config) (er
 
 	// delete spaces
 	if ids := deleteIDs; len(ids) > 0 {
-		_, err = sq.Delete("mercury_spaces").Where(sq.Eq{"id": ids}).RunWith(tx).PlaceholderFormat(sq.Dollar).ExecContext(ctx)
+		_, err = sq.Delete("mercury_spaces").Where(sq.Eq{"id": ids}).RunWith(tx).PlaceholderFormat(p.paceholderFormat).ExecContext(ctx)
 		if err != nil {
 			return err
 		}
@@ -274,7 +317,7 @@ func (p *sqlHandler) WriteConfig(ctx context.Context, config mercury.Config) (er
 
 	// delete values
 	if ids := append(updateIDs, deleteIDs...); len(ids) > 0 {
-		_, err = sq.Delete("mercury_values").Where(sq.Eq{"id": ids}).RunWith(tx).PlaceholderFormat(sq.Dollar).ExecContext(ctx)
+		_, err = sq.Delete("mercury_values").Where(sq.Eq{"id": ids}).RunWith(tx).PlaceholderFormat(p.paceholderFormat).ExecContext(ctx)
 		if err != nil {
 			return err
 		}
@@ -289,7 +332,8 @@ func (p *sqlHandler) WriteConfig(ctx context.Context, config mercury.Config) (er
 			Set("tags", listValue(u.Tags, p.listFormat)).
 			Set("notes", listValue(u.Notes, p.listFormat)).
 			Set("trailer", listValue(u.Trailer, p.listFormat)).
-			PlaceholderFormat(sq.Dollar)
+			PlaceholderFormat(p.paceholderFormat)
+		span.AddEvent(p.name)
 		span.AddEvent(lg.LogQuery(query.ToSql()))
 		_, err := query.RunWith(tx).ExecContext(ctx)
 
@@ -298,7 +342,7 @@ func (p *sqlHandler) WriteConfig(ctx context.Context, config mercury.Config) (er
 		}
 		// log.Debugf("UPDATED %d SPACES", len(updateSpaces))
 		for _, v := range u.List {
-			newValues = append(newValues, &Value{Value: v, ID: updateIDs[i]})
+			newValues = append(newValues, &Value{Value: v, id: updateIDs[i]})
 		}
 	}
 
@@ -306,15 +350,16 @@ func (p *sqlHandler) WriteConfig(ctx context.Context, config mercury.Config) (er
 	for _, s := range insertSpaces {
 		var id uint64
 		query := sq.Insert("mercury_spaces").
-			PlaceholderFormat(sq.Dollar).
+			PlaceholderFormat(p.paceholderFormat).
 			Columns("space", "tags", "notes", "trailer").
 			Values(
-				s.Space, 
-				listValue(s.Tags, p.listFormat), 
+				s.Space,
+				listValue(s.Tags, p.listFormat),
 				listValue(s.Notes, p.listFormat),
 				listValue(s.Trailer, p.listFormat),
-				).
+			).
 			Suffix("RETURNING \"id\"")
+		span.AddEvent(p.name)
 		span.AddEvent(lg.LogQuery(query.ToSql()))
 
 		err := query.
@@ -322,12 +367,13 @@ func (p *sqlHandler) WriteConfig(ctx context.Context, config mercury.Config) (er
 			QueryRowContext(ctx).
 			Scan(&id)
 		if err != nil {
+			span.AddEvent(p.name)
 			s, v, _ := query.ToSql()
 			log.Println(s, v, err)
 			return err
 		}
 		for _, v := range s.List {
-			newValues = append(newValues, &Value{Value: v, ID: id})
+			newValues = append(newValues, &Value{Value: v, id: id})
 		}
 	}
 
@@ -353,7 +399,7 @@ func (p *sqlHandler) writeValues(ctx context.Context, tx sq.BaseRunner, lis []*V
 	newInsert := func() sq.InsertBuilder {
 		return sq.Insert("mercury_values").
 			RunWith(tx).
-			PlaceholderFormat(sq.Dollar).
+			PlaceholderFormat(p.paceholderFormat).
 			Columns(
 				`"id"`,
 				`"seq"`,
@@ -367,7 +413,7 @@ func (p *sqlHandler) writeValues(ctx context.Context, tx sq.BaseRunner, lis []*V
 	insert := newInsert()
 	for i, s := range lis {
 		insert = insert.Values(
-			s.ID,
+			s.id,
 			s.Seq,
 			s.Name,
 			listValue(s.Values, p.listFormat),
@@ -378,7 +424,7 @@ func (p *sqlHandler) writeValues(ctx context.Context, tx sq.BaseRunner, lis []*V
 
 		if i > 0 && i%chunk == 0 {
 			// log.Debugf("inserting %v rows into %v", i%chunk, d.Table)
-			// log.Debug(insert.ToSql())
+			span.AddEvent(p.name)
 			span.AddEvent(lg.LogQuery(insert.ToSql()))
 
 			_, err = insert.ExecContext(ctx)
@@ -392,7 +438,7 @@ func (p *sqlHandler) writeValues(ctx context.Context, tx sq.BaseRunner, lis []*V
 	}
 	if len(lis)%chunk > 0 {
 		// log.Debugf("inserting %v rows into %v", len(lis)%chunk, d.Table)
-		// log.Debug(insert.ToSql())
+		span.AddEvent(p.name)
 		span.AddEvent(lg.LogQuery(insert.ToSql()))
 
 		_, err = insert.ExecContext(ctx)
@@ -405,13 +451,11 @@ func (p *sqlHandler) writeValues(ctx context.Context, tx sq.BaseRunner, lis []*V
 	return
 }
 
-func getWhere(search mercury.NamespaceSearch, d *rsql.DbColumns) (sq.Sqlizer, error) {
+func GetWherePG(search mercury.Search) (func(sq.SelectBuilder) sq.SelectBuilder, error) {
 	var where sq.Or
-	space, err := d.Col("space")
-	if err != nil {
-		return nil, err
-	}
-	for _, m := range search {
+	space := "space"
+
+	for _, m := range search.NamespaceSearch {
 		switch m.(type) {
 		case mercury.NamespaceNode:
 			where = append(where, sq.Eq{space: m.Value()})
@@ -422,5 +466,129 @@ func getWhere(search mercury.NamespaceSearch, d *rsql.DbColumns) (sq.Sqlizer, er
 			where = append(where, e)
 		}
 	}
-	return where, nil
+
+	var joins []sq.SelectBuilder
+	for i, o := range search.Find {
+		log.Println(o)
+		if i > MAX_FILTER {
+			err := fmt.Errorf("too many filters [%d]", MAX_FILTER)
+			return nil, err
+		}
+		q := sq.Select("DISTINCT id").From("mercury_values")
+
+		switch o.Op {
+		case "key":
+			q = q.Where(sq.Eq{"name": o.Left})
+		case "nkey":
+			q = q.Where(sq.NotEq{"name": o.Left})
+		case "eq":
+			q = q.Where("name = ? AND ? = any (values)", o.Left, o.Right)
+		case "neq":
+			q = q.Where("name = ? AND ? != any (values)", o.Left, o.Right)
+
+		case "gt":
+			q = q.Where("name = ? AND ? > any (values)", o.Left, o.Right)
+		case "lt":
+			q = q.Where("name = ? AND ? < any (values)", o.Left, o.Right)
+		case "ge":
+			q = q.Where("name = ? AND ? >= any (values)", o.Left, o.Right)
+		case "le":
+			q = q.Where("name = ? AND ? <= any (values)", o.Left, o.Right)
+
+			// case "like":
+			// 	q = q.Where("name = ? AND value LIKE ?", o.Left, o.Right)
+			// case "in":
+			// 	q = q.Where(sq.Eq{"name": o.Left, "value": strings.Split(o.Right, " ")})
+		}
+		joins = append(joins, q)
+	}
+
+	return func(s sq.SelectBuilder) sq.SelectBuilder {
+		for i, q := range joins {
+			s = s.JoinClause(q.Prefix("JOIN (").Suffix(fmt.Sprintf(`) r%03d USING (id)`, i)))
+		}
+
+		if search.Count > 0 {
+			s = s.Limit(search.Count)
+		}
+		return s.Where(where)
+	}, nil
+}
+
+func GetWhereSQ(search mercury.Search) (func(sq.SelectBuilder) sq.SelectBuilder, error) {
+	var where sq.Or
+
+	var errs error
+	id := "id"
+	space := "space"
+	name := "name"
+	values_each := `json_valid("values")`
+	values_valid := `json_valid("values")`
+
+	if errs != nil {
+		return nil, errs
+	}
+
+	for _, m := range search.NamespaceSearch {
+		switch m.(type) {
+		case mercury.NamespaceNode:
+			where = append(where, sq.Eq{space: m.Value()})
+		case mercury.NamespaceStar:
+			where = append(where, sq.Like{space: m.Value()})
+		case mercury.NamespaceTrace:
+			e := sq.Expr(`? LIKE `+space+` || '%'`, m.Value())
+			where = append(where, e)
+		}
+	}
+
+	var joins []sq.SelectBuilder
+	for i, o := range search.Find {
+		log.Println(o)
+		if i > MAX_FILTER {
+			err := fmt.Errorf("too many filters [%d]", MAX_FILTER)
+			return nil, err
+		}
+		q := sq.Select("DISTINCT " + id).From(`mercury_values mv, ` + values_each + ` vs`)
+
+		switch o.Op {
+		case "key":
+			q = q.Where(sq.Eq{name: o.Left})
+		case "nkey":
+			q = q.Where(sq.NotEq{name: o.Left})
+		case "eq":
+			q = q.Where(sq.And{sq.Expr(values_valid), sq.Eq{name: o.Left, `vs.value`: o.Right}})
+		case "neq":
+			q = q.Where(sq.And{sq.Expr(values_valid), sq.Eq{name: o.Left}, sq.NotEq{`vs.value`: o.Right}})
+
+		case "gt":
+			q = q.Where(sq.And{sq.Expr(values_valid), sq.Eq{name: o.Left}, sq.Gt{`vs.value`: o.Right}})
+		case "lt":
+			q = q.Where(sq.And{sq.Expr(values_valid), sq.Eq{name: o.Left}, sq.Lt{`vs.value`: o.Right}})
+		case "ge":
+			q = q.Where(sq.And{sq.Expr(values_valid), sq.Eq{name: o.Left}, sq.GtOrEq{`vs.value`: o.Right}})
+		case "le":
+			q = q.Where(sq.And{sq.Expr(values_valid), sq.Eq{name: o.Left}, sq.LtOrEq{`vs.value`: o.Right}})
+		case "like":
+			q = q.Where(sq.And{sq.Expr(values_valid), sq.Eq{name: o.Left}, sq.Like{`vs.value`: o.Right}})
+		case "in":
+			q = q.Where(sq.Eq{name: o.Left, "vs.value": strings.Split(o.Right, " ")})
+		}
+		joins = append(joins, q)
+	}
+
+	return func(s sq.SelectBuilder) sq.SelectBuilder {
+		for i, q := range joins {
+			s = s.JoinClause(q.Prefix("JOIN (").Suffix(fmt.Sprintf(`) r%03d USING (id)`, i)))
+		}
+
+		if search.Count > 0 {
+			s = s.Limit(search.Count)
+		}
+
+		if search.Offset > 0 {
+			s = s.Offset(search.Offset)
+		}
+
+		return s.Where(where)
+	}, nil
 }
diff --git a/rsql/dbcolumns.go b/rsql/dbcolumns.go
index e5fc263..bbb505f 100644
--- a/rsql/dbcolumns.go
+++ b/rsql/dbcolumns.go
@@ -11,8 +11,8 @@ import (
 type DbColumns struct {
 	Cols  []string
 	index map[string]int
-	Table     string
-	View      string
+	Table string
+	View  string
 }
 
 // Col returns the mapped column names
@@ -40,16 +40,16 @@ func GetDbColumns(o interface{}) *DbColumns {
 	for i := 0; i < t.NumField(); i++ {
 		field := t.Field(i)
 
-		sp := append(strings.Split(field.Tag.Get("db"), ","), "")
-
-		tag := sp[0]
+		tag, _, _ := strings.Cut(field.Tag.Get("db"), ",")
 
 		json := field.Tag.Get("json")
+		json, _, _ = strings.Cut(json, ",")
 		if tag == "" {
 			tag = json
 		}
 
 		graphql := field.Tag.Get("graphql")
+		graphql, _, _ = strings.Cut(graphql, ",")
 		if tag == "" {
 			tag = graphql
 		}
@@ -88,3 +88,11 @@ func GetDbColumns(o interface{}) *DbColumns {
 	}
 	return &d
 }
+
+func QuoteCols(cols []string) []string {
+	lis := make([]string, len(cols))
+	for i := range cols {
+		lis[i] = `"` + cols[i] + `"`
+	}
+	return lis
+}
diff --git a/service/service.go b/service/service.go
index fd085d6..9da3dc0 100644
--- a/service/service.go
+++ b/service/service.go
@@ -114,7 +114,6 @@ func (s *Harness) Run(ctx context.Context, appName, version string) error {
 	err := g.Wait()
 	if err != nil {
 		log.Printf("Shutdown due to error: %s", err)
-
 	}
 	return err
 }
-- 
2.45.1


From f5027d9bfd18edccfa2f058ce6f5ce464c7417ba Mon Sep 17 00:00:00 2001
From: xuu 
Date: Mon, 10 Jun 2024 21:12:11 -0600
Subject: [PATCH 4/4] chore: cleanup and add lsm

---
 .vscode/launch.json |  23 ---
 go.work             |   6 -
 go.work.sum         | 333 ---------------------------------------
 lsm/marshal.go      | 138 +++++++++++++++++
 lsm/marshal_test.go |  76 +++++++++
 lsm/sst.go          | 370 ++++++++++++++++++++++++++++++++++++++++++++
 lsm/sst_test.go     | 327 +++++++++++++++++++++++++++++++++++++++
 7 files changed, 911 insertions(+), 362 deletions(-)
 delete mode 100644 .vscode/launch.json
 delete mode 100644 go.work
 delete mode 100644 go.work.sum
 create mode 100644 lsm/marshal.go
 create mode 100644 lsm/marshal_test.go
 create mode 100644 lsm/sst.go
 create mode 100644 lsm/sst_test.go

diff --git a/.vscode/launch.json b/.vscode/launch.json
deleted file mode 100644
index 1e1cc2b..0000000
--- a/.vscode/launch.json
+++ /dev/null
@@ -1,23 +0,0 @@
-{
-    // Use IntelliSense to learn about possible attributes.
-    // Hover to view descriptions of existing attributes.
-    // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
-    "version": "0.2.0",
-    "configurations": [
-        {
-            "name": "Launch Package",
-            "type": "go",
-            "request": "launch",
-            "mode": "auto",
-            "program": "${fileDirname}",
-            "cwd": "${workspaceFolder}"
-        },
-        {
-            "name": "Attach to Process",
-            "type": "go",
-            "request": "attach",
-            "mode": "local",
-            "processId": 0
-        }
-    ]
-}
\ No newline at end of file
diff --git a/go.work b/go.work
deleted file mode 100644
index 538e4c9..0000000
--- a/go.work
+++ /dev/null
@@ -1,6 +0,0 @@
-go 1.22.0
-
-use (
-	.
-	../go-tools
-)
diff --git a/go.work.sum b/go.work.sum
deleted file mode 100644
index a32bac6..0000000
--- a/go.work.sum
+++ /dev/null
@@ -1,333 +0,0 @@
-cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
-cloud.google.com/go v0.112.0/go.mod h1:3jEEVwZ/MHU4djK5t5RHuKOA/GbLddgTdVubX1qnPD4=
-cloud.google.com/go/accessapproval v1.7.5/go.mod h1:g88i1ok5dvQ9XJsxpUInWWvUBrIZhyPDPbk4T01OoJ0=
-cloud.google.com/go/accesscontextmanager v1.8.5/go.mod h1:TInEhcZ7V9jptGNqN3EzZ5XMhT6ijWxTGjzyETwmL0Q=
-cloud.google.com/go/aiplatform v1.60.0/go.mod h1:eTlGuHOahHprZw3Hio5VKmtThIOak5/qy6pzdsqcQnM=
-cloud.google.com/go/analytics v0.23.0/go.mod h1:YPd7Bvik3WS95KBok2gPXDqQPHy08TsCQG6CdUCb+u0=
-cloud.google.com/go/apigateway v1.6.5/go.mod h1:6wCwvYRckRQogyDDltpANi3zsCDl6kWi0b4Je+w2UiI=
-cloud.google.com/go/apigeeconnect v1.6.5/go.mod h1:MEKm3AiT7s11PqTfKE3KZluZA9O91FNysvd3E6SJ6Ow=
-cloud.google.com/go/apigeeregistry v0.8.3/go.mod h1:aInOWnqF4yMQx8kTjDqHNXjZGh/mxeNlAf52YqtASUs=
-cloud.google.com/go/appengine v1.8.5/go.mod h1:uHBgNoGLTS5di7BvU25NFDuKa82v0qQLjyMJLuPQrVo=
-cloud.google.com/go/area120 v0.8.5/go.mod h1:BcoFCbDLZjsfe4EkCnEq1LKvHSK0Ew/zk5UFu6GMyA0=
-cloud.google.com/go/artifactregistry v1.14.7/go.mod h1:0AUKhzWQzfmeTvT4SjfI4zjot72EMfrkvL9g9aRjnnM=
-cloud.google.com/go/asset v1.17.2/go.mod h1:SVbzde67ehddSoKf5uebOD1sYw8Ab/jD/9EIeWg99q4=
-cloud.google.com/go/assuredworkloads v1.11.5/go.mod h1:FKJ3g3ZvkL2D7qtqIGnDufFkHxwIpNM9vtmhvt+6wqk=
-cloud.google.com/go/automl v1.13.5/go.mod h1:MDw3vLem3yh+SvmSgeYUmUKqyls6NzSumDm9OJ3xJ1Y=
-cloud.google.com/go/baremetalsolution v1.2.4/go.mod h1:BHCmxgpevw9IEryE99HbYEfxXkAEA3hkMJbYYsHtIuY=
-cloud.google.com/go/batch v1.8.0/go.mod h1:k8V7f6VE2Suc0zUM4WtoibNrA6D3dqBpB+++e3vSGYc=
-cloud.google.com/go/beyondcorp v1.0.4/go.mod h1:Gx8/Rk2MxrvWfn4WIhHIG1NV7IBfg14pTKv1+EArVcc=
-cloud.google.com/go/bigquery v1.59.1/go.mod h1:VP1UJYgevyTwsV7desjzNzDND5p6hZB+Z8gZJN1GQUc=
-cloud.google.com/go/billing v1.18.2/go.mod h1:PPIwVsOOQ7xzbADCwNe8nvK776QpfrOAUkvKjCUcpSE=
-cloud.google.com/go/binaryauthorization v1.8.1/go.mod h1:1HVRyBerREA/nhI7yLang4Zn7vfNVA3okoAR9qYQJAQ=
-cloud.google.com/go/certificatemanager v1.7.5/go.mod h1:uX+v7kWqy0Y3NG/ZhNvffh0kuqkKZIXdvlZRO7z0VtM=
-cloud.google.com/go/channel v1.17.5/go.mod h1:FlpaOSINDAXgEext0KMaBq/vwpLMkkPAw9b2mApQeHc=
-cloud.google.com/go/cloudbuild v1.15.1/go.mod h1:gIofXZSu+XD2Uy+qkOrGKEx45zd7s28u/k8f99qKals=
-cloud.google.com/go/clouddms v1.7.4/go.mod h1:RdrVqoFG9RWI5AvZ81SxJ/xvxPdtcRhFotwdE79DieY=
-cloud.google.com/go/cloudtasks v1.12.6/go.mod h1:b7c7fe4+TJsFZfDyzO51F7cjq7HLUlRi/KZQLQjDsaY=
-cloud.google.com/go/compute v1.20.1/go.mod h1:4tCnrn48xsqlwSAiLf1HXMQk8CONslYbdiEZc9FEIbM=
-cloud.google.com/go/compute v1.23.3/go.mod h1:VCgBUoMnIVIR0CscqQiPJLAG25E3ZRZMzcFZeQ+h8CI=
-cloud.google.com/go/compute v1.24.0/go.mod h1:kw1/T+h/+tK2LJK0wiPPx1intgdAM3j/g3hFDlscY40=
-cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA=
-cloud.google.com/go/contactcenterinsights v1.13.0/go.mod h1:ieq5d5EtHsu8vhe2y3amtZ+BE+AQwX5qAy7cpo0POsI=
-cloud.google.com/go/container v1.31.0/go.mod h1:7yABn5s3Iv3lmw7oMmyGbeV6tQj86njcTijkkGuvdZA=
-cloud.google.com/go/containeranalysis v0.11.4/go.mod h1:cVZT7rXYBS9NG1rhQbWL9pWbXCKHWJPYraE8/FTSYPE=
-cloud.google.com/go/datacatalog v1.19.3/go.mod h1:ra8V3UAsciBpJKQ+z9Whkxzxv7jmQg1hfODr3N3YPJ4=
-cloud.google.com/go/dataflow v0.9.5/go.mod h1:udl6oi8pfUHnL0z6UN9Lf9chGqzDMVqcYTcZ1aPnCZQ=
-cloud.google.com/go/dataform v0.9.2/go.mod h1:S8cQUwPNWXo7m/g3DhWHsLBoufRNn9EgFrMgne2j7cI=
-cloud.google.com/go/datafusion v1.7.5/go.mod h1:bYH53Oa5UiqahfbNK9YuYKteeD4RbQSNMx7JF7peGHc=
-cloud.google.com/go/datalabeling v0.8.5/go.mod h1:IABB2lxQnkdUbMnQaOl2prCOfms20mcPxDBm36lps+s=
-cloud.google.com/go/dataplex v1.14.2/go.mod h1:0oGOSFlEKef1cQeAHXy4GZPB/Ife0fz/PxBf+ZymA2U=
-cloud.google.com/go/dataproc v1.12.0/go.mod h1:zrF3aX0uV3ikkMz6z4uBbIKyhRITnxvr4i3IjKsKrw4=
-cloud.google.com/go/dataproc/v2 v2.4.0/go.mod h1:3B1Ht2aRB8VZIteGxQS/iNSJGzt9+CA0WGnDVMEm7Z4=
-cloud.google.com/go/dataqna v0.8.5/go.mod h1:vgihg1mz6n7pb5q2YJF7KlXve6tCglInd6XO0JGOlWM=
-cloud.google.com/go/datastore v1.15.0/go.mod h1:GAeStMBIt9bPS7jMJA85kgkpsMkvseWWXiaHya9Jes8=
-cloud.google.com/go/datastream v1.10.4/go.mod h1:7kRxPdxZxhPg3MFeCSulmAJnil8NJGGvSNdn4p1sRZo=
-cloud.google.com/go/deploy v1.17.1/go.mod h1:SXQyfsXrk0fBmgBHRzBjQbZhMfKZ3hMQBw5ym7MN/50=
-cloud.google.com/go/dialogflow v1.49.0/go.mod h1:dhVrXKETtdPlpPhE7+2/k4Z8FRNUp6kMV3EW3oz/fe0=
-cloud.google.com/go/dlp v1.11.2/go.mod h1:9Czi+8Y/FegpWzgSfkRlyz+jwW6Te9Rv26P3UfU/h/w=
-cloud.google.com/go/documentai v1.25.0/go.mod h1:ftLnzw5VcXkLItp6pw1mFic91tMRyfv6hHEY5br4KzY=
-cloud.google.com/go/domains v0.9.5/go.mod h1:dBzlxgepazdFhvG7u23XMhmMKBjrkoUNaw0A8AQB55Y=
-cloud.google.com/go/edgecontainer v1.1.5/go.mod h1:rgcjrba3DEDEQAidT4yuzaKWTbkTI5zAMu3yy6ZWS0M=
-cloud.google.com/go/errorreporting v0.3.0/go.mod h1:xsP2yaAp+OAW4OIm60An2bbLpqIhKXdWR/tawvl7QzU=
-cloud.google.com/go/essentialcontacts v1.6.6/go.mod h1:XbqHJGaiH0v2UvtuucfOzFXN+rpL/aU5BCZLn4DYl1Q=
-cloud.google.com/go/eventarc v1.13.4/go.mod h1:zV5sFVoAa9orc/52Q+OuYUG9xL2IIZTbbuTHC6JSY8s=
-cloud.google.com/go/filestore v1.8.1/go.mod h1:MbN9KcaM47DRTIuLfQhJEsjaocVebNtNQhSLhKCF5GM=
-cloud.google.com/go/firestore v1.14.0/go.mod h1:96MVaHLsEhbvkBEdZgfN+AS/GIkco1LRpH9Xp9YZfzQ=
-cloud.google.com/go/functions v1.16.0/go.mod h1:nbNpfAG7SG7Duw/o1iZ6ohvL7mc6MapWQVpqtM29n8k=
-cloud.google.com/go/gkebackup v1.3.5/go.mod h1:KJ77KkNN7Wm1LdMopOelV6OodM01pMuK2/5Zt1t4Tvc=
-cloud.google.com/go/gkeconnect v0.8.5/go.mod h1:LC/rS7+CuJ5fgIbXv8tCD/mdfnlAadTaUufgOkmijuk=
-cloud.google.com/go/gkehub v0.14.5/go.mod h1:6bzqxM+a+vEH/h8W8ec4OJl4r36laxTs3A/fMNHJ0wA=
-cloud.google.com/go/gkemulticloud v1.1.1/go.mod h1:C+a4vcHlWeEIf45IB5FFR5XGjTeYhF83+AYIpTy4i2Q=
-cloud.google.com/go/grafeas v0.3.4/go.mod h1:A5m316hcG+AulafjAbPKXBO/+I5itU4LOdKO2R/uDIc=
-cloud.google.com/go/gsuiteaddons v1.6.5/go.mod h1:Lo4P2IvO8uZ9W+RaC6s1JVxo42vgy+TX5a6hfBZ0ubs=
-cloud.google.com/go/iam v1.1.6/go.mod h1:O0zxdPeGBoFdWW3HWmBxJsk0pfvNM/p/qa82rWOGTwI=
-cloud.google.com/go/iap v1.9.4/go.mod h1:vO4mSq0xNf/Pu6E5paORLASBwEmphXEjgCFg7aeNu1w=
-cloud.google.com/go/ids v1.4.5/go.mod h1:p0ZnyzjMWxww6d2DvMGnFwCsSxDJM666Iir1bK1UuBo=
-cloud.google.com/go/iot v1.7.5/go.mod h1:nq3/sqTz3HGaWJi1xNiX7F41ThOzpud67vwk0YsSsqs=
-cloud.google.com/go/kms v1.15.7/go.mod h1:ub54lbsa6tDkUwnu4W7Yt1aAIFLnspgh0kPGToDukeI=
-cloud.google.com/go/language v1.12.3/go.mod h1:evFX9wECX6mksEva8RbRnr/4wi/vKGYnAJrTRXU8+f8=
-cloud.google.com/go/lifesciences v0.9.5/go.mod h1:OdBm0n7C0Osh5yZB7j9BXyrMnTRGBJIZonUMxo5CzPw=
-cloud.google.com/go/logging v1.9.0/go.mod h1:1Io0vnZv4onoUnsVUQY3HZ3Igb1nBchky0A0y7BBBhE=
-cloud.google.com/go/longrunning v0.5.5/go.mod h1:WV2LAxD8/rg5Z1cNW6FJ/ZpX4E4VnDnoTk0yawPBB7s=
-cloud.google.com/go/managedidentities v1.6.5/go.mod h1:fkFI2PwwyRQbjLxlm5bQ8SjtObFMW3ChBGNqaMcgZjI=
-cloud.google.com/go/maps v1.6.4/go.mod h1:rhjqRy8NWmDJ53saCfsXQ0LKwBHfi6OSh5wkq6BaMhI=
-cloud.google.com/go/mediatranslation v0.8.5/go.mod h1:y7kTHYIPCIfgyLbKncgqouXJtLsU+26hZhHEEy80fSs=
-cloud.google.com/go/memcache v1.10.5/go.mod h1:/FcblbNd0FdMsx4natdj+2GWzTq+cjZvMa1I+9QsuMA=
-cloud.google.com/go/metastore v1.13.4/go.mod h1:FMv9bvPInEfX9Ac1cVcRXp8EBBQnBcqH6gz3KvJ9BAE=
-cloud.google.com/go/monitoring v1.18.0/go.mod h1:c92vVBCeq/OB4Ioyo+NbN2U7tlg5ZH41PZcdvfc+Lcg=
-cloud.google.com/go/networkconnectivity v1.14.4/go.mod h1:PU12q++/IMnDJAB+3r+tJtuCXCfwfN+C6Niyj6ji1Po=
-cloud.google.com/go/networkmanagement v1.9.4/go.mod h1:daWJAl0KTFytFL7ar33I6R/oNBH8eEOX/rBNHrC/8TA=
-cloud.google.com/go/networksecurity v0.9.5/go.mod h1:KNkjH/RsylSGyyZ8wXpue8xpCEK+bTtvof8SBfIhMG8=
-cloud.google.com/go/notebooks v1.11.3/go.mod h1:0wQyI2dQC3AZyQqWnRsp+yA+kY4gC7ZIVP4Qg3AQcgo=
-cloud.google.com/go/optimization v1.6.3/go.mod h1:8ve3svp3W6NFcAEFr4SfJxrldzhUl4VMUJmhrqVKtYA=
-cloud.google.com/go/orchestration v1.8.5/go.mod h1:C1J7HesE96Ba8/hZ71ISTV2UAat0bwN+pi85ky38Yq8=
-cloud.google.com/go/orgpolicy v1.12.1/go.mod h1:aibX78RDl5pcK3jA8ysDQCFkVxLj3aOQqrbBaUL2V5I=
-cloud.google.com/go/osconfig v1.12.5/go.mod h1:D9QFdxzfjgw3h/+ZaAb5NypM8bhOMqBzgmbhzWViiW8=
-cloud.google.com/go/oslogin v1.13.1/go.mod h1:vS8Sr/jR7QvPWpCjNqy6LYZr5Zs1e8ZGW/KPn9gmhws=
-cloud.google.com/go/phishingprotection v0.8.5/go.mod h1:g1smd68F7mF1hgQPuYn3z8HDbNre8L6Z0b7XMYFmX7I=
-cloud.google.com/go/policytroubleshooter v1.10.3/go.mod h1:+ZqG3agHT7WPb4EBIRqUv4OyIwRTZvsVDHZ8GlZaoxk=
-cloud.google.com/go/privatecatalog v0.9.5/go.mod h1:fVWeBOVe7uj2n3kWRGlUQqR/pOd450J9yZoOECcQqJk=
-cloud.google.com/go/pubsub v1.36.1/go.mod h1:iYjCa9EzWOoBiTdd4ps7QoMtMln5NwaZQpK1hbRfBDE=
-cloud.google.com/go/pubsublite v1.8.1/go.mod h1:fOLdU4f5xldK4RGJrBMm+J7zMWNj/k4PxwEZXy39QS0=
-cloud.google.com/go/recaptchaenterprise/v2 v2.9.2/go.mod h1:trwwGkfhCmp05Ll5MSJPXY7yvnO0p4v3orGANAFHAuU=
-cloud.google.com/go/recommendationengine v0.8.5/go.mod h1:A38rIXHGFvoPvmy6pZLozr0g59NRNREz4cx7F58HAsQ=
-cloud.google.com/go/recommender v1.12.1/go.mod h1:gf95SInWNND5aPas3yjwl0I572dtudMhMIG4ni8nr+0=
-cloud.google.com/go/redis v1.14.2/go.mod h1:g0Lu7RRRz46ENdFKQ2EcQZBAJ2PtJHJLuiiRuEXwyQw=
-cloud.google.com/go/resourcemanager v1.9.5/go.mod h1:hep6KjelHA+ToEjOfO3garMKi/CLYwTqeAw7YiEI9x8=
-cloud.google.com/go/resourcesettings v1.6.5/go.mod h1:WBOIWZraXZOGAgoR4ukNj0o0HiSMO62H9RpFi9WjP9I=
-cloud.google.com/go/retail v1.16.0/go.mod h1:LW7tllVveZo4ReWt68VnldZFWJRzsh9np+01J9dYWzE=
-cloud.google.com/go/run v1.3.4/go.mod h1:FGieuZvQ3tj1e9GnzXqrMABSuir38AJg5xhiYq+SF3o=
-cloud.google.com/go/scheduler v1.10.6/go.mod h1:pe2pNCtJ+R01E06XCDOJs1XvAMbv28ZsQEbqknxGOuE=
-cloud.google.com/go/secretmanager v1.11.5/go.mod h1:eAGv+DaCHkeVyQi0BeXgAHOU0RdrMeZIASKc+S7VqH4=
-cloud.google.com/go/security v1.15.5/go.mod h1:KS6X2eG3ynWjqcIX976fuToN5juVkF6Ra6c7MPnldtc=
-cloud.google.com/go/securitycenter v1.24.4/go.mod h1:PSccin+o1EMYKcFQzz9HMMnZ2r9+7jbc+LvPjXhpwcU=
-cloud.google.com/go/servicedirectory v1.11.4/go.mod h1:Bz2T9t+/Ehg6x+Y7Ycq5xiShYLD96NfEsWNHyitj1qM=
-cloud.google.com/go/shell v1.7.5/go.mod h1:hL2++7F47/IfpfTO53KYf1EC+F56k3ThfNEXd4zcuiE=
-cloud.google.com/go/spanner v1.56.0/go.mod h1:DndqtUKQAt3VLuV2Le+9Y3WTnq5cNKrnLb/Piqcj+h0=
-cloud.google.com/go/speech v1.21.1/go.mod h1:E5GHZXYQlkqWQwY5xRSLHw2ci5NMQNG52FfMU1aZrIA=
-cloud.google.com/go/storage v1.37.0/go.mod h1:i34TiT2IhiNDmcj65PqwCjcoUX7Z5pLzS8DEmoiFq1k=
-cloud.google.com/go/storagetransfer v1.10.4/go.mod h1:vef30rZKu5HSEf/x1tK3WfWrL0XVoUQN/EPDRGPzjZs=
-cloud.google.com/go/talent v1.6.6/go.mod h1:y/WQDKrhVz12WagoarpAIyKKMeKGKHWPoReZ0g8tseQ=
-cloud.google.com/go/texttospeech v1.7.5/go.mod h1:tzpCuNWPwrNJnEa4Pu5taALuZL4QRRLcb+K9pbhXT6M=
-cloud.google.com/go/tpu v1.6.5/go.mod h1:P9DFOEBIBhuEcZhXi+wPoVy/cji+0ICFi4TtTkMHSSs=
-cloud.google.com/go/trace v1.10.5/go.mod h1:9hjCV1nGBCtXbAE4YK7OqJ8pmPYSxPA0I67JwRd5s3M=
-cloud.google.com/go/translate v1.10.1/go.mod h1:adGZcQNom/3ogU65N9UXHOnnSvjPwA/jKQUMnsYXOyk=
-cloud.google.com/go/video v1.20.4/go.mod h1:LyUVjyW+Bwj7dh3UJnUGZfyqjEto9DnrvTe1f/+QrW0=
-cloud.google.com/go/videointelligence v1.11.5/go.mod h1:/PkeQjpRponmOerPeJxNPuxvi12HlW7Em0lJO14FC3I=
-cloud.google.com/go/vision/v2 v2.8.0/go.mod h1:ocqDiA2j97pvgogdyhoxiQp2ZkDCyr0HWpicywGGRhU=
-cloud.google.com/go/vmmigration v1.7.5/go.mod h1:pkvO6huVnVWzkFioxSghZxIGcsstDvYiVCxQ9ZH3eYI=
-cloud.google.com/go/vmwareengine v1.1.1/go.mod h1:nMpdsIVkUrSaX8UvmnBhzVzG7PPvNYc5BszcvIVudYs=
-cloud.google.com/go/vpcaccess v1.7.5/go.mod h1:slc5ZRvvjP78c2dnL7m4l4R9GwL3wDLcpIWz6P/ziig=
-cloud.google.com/go/webrisk v1.9.5/go.mod h1:aako0Fzep1Q714cPEM5E+mtYX8/jsfegAuS8aivxy3U=
-cloud.google.com/go/websecurityscanner v1.6.5/go.mod h1:QR+DWaxAz2pWooylsBF854/Ijvuoa3FCyS1zBa1rAVQ=
-cloud.google.com/go/workflows v1.12.4/go.mod h1:yQ7HUqOkdJK4duVtMeBCAOPiN1ZF1E9pAMX51vpwB/w=
-github.com/99designs/gqlgen v0.17.41/go.mod h1:GQ6SyMhwFbgHR0a8r2Wn8fYgEwPxxmndLFPhU63+cJE=
-github.com/PuerkitoBio/goquery v1.8.1/go.mod h1:Q8ICL1kNUJ2sXGoAhPGUdYDJvgQgHzJsnnd3H7Ho5jQ=
-github.com/alecthomas/kingpin/v2 v2.4.0/go.mod h1:0gyi0zQnjuFk8xrkNKamJoyUo382HRL7ATRpFZCw6tE=
-github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137/go.mod h1:OMCwj8VM1Kc9e19TLln2VL61YJF0x1XFtfdL4JdbSyE=
-github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA=
-github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
-github.com/apache/arrow/go/v14 v14.0.2/go.mod h1:u3fgh3EdgN/YQ8cVQRguVW3R+seMybFg8QBQ5LU+eBY=
-github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
-github.com/census-instrumentation/opencensus-proto v0.4.1/go.mod h1:4T9NM4+4Vw91VeyqjLS6ao50K5bOcLKN6Q42XnYaRYw=
-github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
-github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
-github.com/cncf/udpa/go v0.0.0-20220112060539-c52dc94e7fbe/go.mod h1:6pvJx4me5XPnfI9Z40ddWsdw2W/uZgQLFXToKeRcDiI=
-github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
-github.com/cncf/xds/go v0.0.0-20231109132714-523115ebc101/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
-github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
-github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
-github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
-github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
-github.com/envoyproxy/go-control-plane v0.11.1/go.mod h1:uhMcXKCQMEJHiAb0w+YGefQLaTEw+YhGluxZkrTmD0g=
-github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
-github.com/envoyproxy/protoc-gen-validate v1.0.2/go.mod h1:GpiZQP3dDbg4JouG/NNS7QWXpgx6x8QiMKdmN72jogE=
-github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg=
-github.com/gdamore/tcell/v2 v2.4.1-0.20210905002822-f057f0a857a1/go.mod h1:Az6Jt+M5idSED2YPGtwnfJV0kXohgdCBPmHGSYc1r04=
-github.com/go-kit/log v0.2.1/go.mod h1:NwTd00d/i8cPZ3xOwwiv2PO5MOcx78fFErGNcVmBjv0=
-github.com/go-logfmt/logfmt v0.5.1/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
-github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
-github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
-github.com/golang/glog v1.1.2/go.mod h1:zR+okUeTbrL6EL3xHUDxZuEtGv04p5shwip1+mL/rLQ=
-github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
-github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
-github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
-github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
-github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
-github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
-github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
-github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
-github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
-github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
-github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
-github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
-github.com/google/flatbuffers v23.5.26+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
-github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
-github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
-github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
-github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/go-pkcs11 v0.2.1-0.20230907215043-c6f79328ddf9/go.mod h1:6eQoGcuNJpa7jnd5pMGdkSaQpNDYvPlXWMcjXXThLlY=
-github.com/google/martian/v3 v3.3.2/go.mod h1:oBOf6HBosgwRXnUGWUB05QECsc6uvmMiJ3+6W4l/CUk=
-github.com/google/s2a-go v0.1.7/go.mod h1:50CgR4k1jNlWBu4UfS4AcfhVe1r6pdZPygJ3R8F0Qdw=
-github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
-github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
-github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
-github.com/googleapis/enterprise-certificate-proxy v0.3.2/go.mod h1:VLSiSSBs/ksPL8kq3OBOQ6WRI2QnaFynd1DCjZ62+V0=
-github.com/googleapis/gax-go/v2 v2.12.0/go.mod h1:y+aIqrI5eb1YGMVJfuV3185Ts/D7qKpsEkdD5+I6QGU=
-github.com/gopherjs/gopherjs v0.0.0-20220221023154-0b2280d3ff96/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k=
-github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.0/go.mod h1:qmOFXW2epJhM0qSnUUYpldc7gVz2KMQwJ/QYCDIa7XU=
-github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
-github.com/iancoleman/strcase v0.2.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho=
-github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
-github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
-github.com/kevinmbeaulieu/eq-go v1.0.0/go.mod h1:G3S8ajA56gKBZm4UB9AOyoOS37JO3roToPzKNM8dtdM=
-github.com/klauspost/compress v1.16.7 h1:2mk3MPGNzKyxErAw8YaohYh69+pa4sIQSC0fPGCFR9I=
-github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
-github.com/klauspost/cpuid/v2 v2.2.3/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
-github.com/klauspost/cpuid/v2 v2.2.5/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
-github.com/logrusorgru/aurora/v3 v3.0.0/go.mod h1:vsR12bk5grlLvLXAYrBsb5Oc/N+LxAlxggSjiwMnCUc=
-github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
-github.com/lyft/protoc-gen-star/v2 v2.0.3/go.mod h1:amey7yeodaJhXSbf/TlLvWiqQfLOSpEk//mLlc+axEk=
-github.com/magiconair/properties v1.8.6/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60=
-github.com/matryer/moq v0.3.3/go.mod h1:RJ75ZZZD71hejp39j4crZLsEDszGk6iH4v4YsWFKH4s=
-github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
-github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
-github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
-github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0/go.mod h1:QUyp042oQthUoa9bqDv0ER0wrtXnBruoNd7aNjkbP+k=
-github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
-github.com/mmcloughlin/professor v0.0.0-20170922221822-6b97112ab8b3/go.mod h1:LQkXsHRSPIEklPCq8OMQAzYNS2NGtYStdNE/ej1oJU8=
-github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
-github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
-github.com/pelletier/go-toml v1.9.4/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
-github.com/pierrec/lz4/v4 v4.1.18/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
-github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
-github.com/prometheus/common v0.45.0/go.mod h1:YJmSTw9BoKxJplESWWxlbyttQR4uaEcGyv9MZjVOJsY=
-github.com/rivo/tview v0.0.0-20220307222120-9994674d60a8/go.mod h1:WIfMkQNY+oq/mWwtsjOYHIZBuwthioY2srOmljJkTnk=
-github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
-github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
-github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
-github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
-github.com/spf13/afero v1.8.2/go.mod h1:CtAatgMJh6bJEIs48Ay/FOnkljP3WeGUG0MC1RfAqwo=
-github.com/spf13/cast v1.4.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
-github.com/spf13/cobra v1.4.0/go.mod h1:Wo4iy3BUC+X2Fybo0PDqwJIv3dNRiZLHQymsfxlB84g=
-github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo=
-github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
-github.com/spf13/viper v1.10.1/go.mod h1:IGlFPqhNAPKRxohIzWpI5QEy4kuI7tcl5WvR+8qy1rU=
-github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
-github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
-github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
-github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
-github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
-github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw=
-github.com/urfave/cli/v2 v2.27.1/go.mod h1:8qnjx1vcq5s2/wpsqoZFndg2CE5tNFyrTvS6SinrnYQ=
-github.com/wblakecaldwell/profiler v0.0.0-20150908040756-6111ef1313a1/go.mod h1:3+0F8oLB1rQlbIcRAuqDgGdzNi9X69un/aPz4cUAFV4=
-github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU=
-github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8=
-github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
-go.einride.tech/aip v0.66.0/go.mod h1:qAhMsfT7plxBX+Oy7Huol6YUvZ0ZzdUz26yZsQwfl1M=
-go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
-go.opentelemetry.io/contrib v1.21.1/go.mod h1:usW9bPlrjHiJFbK0a6yK/M5wNHs3nLmtrT3vzhoD3co=
-go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.47.0/go.mod h1:r9vWsPS/3AQItv3OSlEJ/E4mbrhUbbw18meOjArPtKQ=
-go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.47.0/go.mod h1:SK2UL73Zy1quvRPonmOmRDiWk1KBV3LyIeeIxcEApWw=
-go.opentelemetry.io/otel v1.21.0/go.mod h1:QZzNPQPm1zLX4gZK4cMi+71eaorMSGT3A4znnUvNNEo=
-go.opentelemetry.io/otel v1.22.0/go.mod h1:eoV4iAi3Ea8LkAEI9+GFT44O6T/D0GWAVFyZVCC6pMI=
-go.opentelemetry.io/otel v1.23.0/go.mod h1:YCycw9ZeKhcJFrb34iVSkyT0iczq/zYDtZYFufObyB0=
-go.opentelemetry.io/otel/metric v1.21.0/go.mod h1:o1p3CA8nNHW8j5yuQLdc1eeqEaPfzug24uvsyIEJRWM=
-go.opentelemetry.io/otel/metric v1.22.0/go.mod h1:evJGjVpZv0mQ5QBRJoBF64yMuOf4xCWdXjK8pzFvliY=
-go.opentelemetry.io/otel/metric v1.23.0/go.mod h1:MqUW2X2a6Q8RN96E2/nqNoT+z9BSms20Jb7Bbp+HiTo=
-go.opentelemetry.io/otel/sdk v1.21.0/go.mod h1:Nna6Yv7PWTdgJHVRD9hIYywQBRx7pbox6nwBnZIxl/E=
-go.opentelemetry.io/otel/trace v1.21.0/go.mod h1:LGbsEB0f9LGjN+OZaQQ26sohbOmiMR+BaslueVtS/qQ=
-go.opentelemetry.io/otel/trace v1.22.0/go.mod h1:RbbHXVqKES9QhzZq/fE5UnOSILqRt40a21sPw2He1xo=
-go.opentelemetry.io/otel/trace v1.23.0/go.mod h1:GSGTbIClEsuZrGIzoEHqsVfxgn5UkggkflQwDScNUsk=
-golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
-golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA=
-golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
-golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
-golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
-golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
-golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
-golang.org/x/lint v0.0.0-20210508222113-6edffad5e616/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
-golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
-golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
-golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
-golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
-golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
-golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
-golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
-golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
-golang.org/x/net v0.18.0/go.mod h1:/czyP5RqHAH4odGYxBJ1qz0+CE5WZ+2j1YgoEo8F2jQ=
-golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
-golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
-golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
-golang.org/x/oauth2 v0.12.0/go.mod h1:A74bZ3aGXgCY0qaIC9Ahg6Lglin4AMAco8cIv9baba4=
-golang.org/x/oauth2 v0.14.0/go.mod h1:lAtNWgaWfL4cm7j2OV8TxGi9Qb7ECORx8DktCY74OwM=
-golang.org/x/oauth2 v0.16.0/go.mod h1:hqZ+0LWXsiVoZpeld6jVt06P3adbS2Uu911W1SsJv2o=
-golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
-golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
-golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
-golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
-golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4=
-golang.org/x/telemetry v0.0.0-20240208230135-b75ee8823808/go.mod h1:KG1lNk5ZFNssSZLrpVb4sMXKMpGwGXOxSG3rnu2gZQQ=
-golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58=
-golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
-golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
-golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
-golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
-golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
-golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
-golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
-golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
-golang.org/x/tools v0.17.0/go.mod h1:xsh6VxdV005rRVaS6SSAf9oiAqljS7UZUacMZ8Bnsps=
-golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
-google.golang.org/api v0.162.0/go.mod h1:6SulDkfoBIg4NFmCuZ39XeeAgSHCPecfSUuDyYlAHs0=
-google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
-google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
-google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
-google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
-google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
-google.golang.org/genproto v0.0.0-20231106174013-bbf56f31fb17/go.mod h1:J7XzRzVy1+IPwWHZUzoD0IccYZIrXILAQpc+Qy9CMhY=
-google.golang.org/genproto v0.0.0-20240116215550-a9fa1716bcac/go.mod h1:+Rvu7ElI+aLzyDQhpHMFMMltsD6m7nqpuWDd2CwJw3k=
-google.golang.org/genproto v0.0.0-20240125205218-1f4bbc51befe/go.mod h1:cc8bqMqtv9gMOr0zHg2Vzff5ULhhL2IXP4sbcn32Dro=
-google.golang.org/genproto v0.0.0-20240205150955-31a09d347014/go.mod h1:xEgQu1e4stdSSsxPDK8Azkrk/ECl5HvdPf6nbZrTS5M=
-google.golang.org/genproto/googleapis/api v0.0.0-20231106174013-bbf56f31fb17/go.mod h1:0xJLfVdJqpAPl8tDg1ujOCGzx6LFLttXT5NhllGOXY4=
-google.golang.org/genproto/googleapis/api v0.0.0-20240102182953-50ed04b92917/go.mod h1:CmlNWB9lSezaYELKS5Ym1r44VrrbPUa7JTvw+6MbpJ0=
-google.golang.org/genproto/googleapis/api v0.0.0-20240125205218-1f4bbc51befe/go.mod h1:4jWUdICTdgc3Ibxmr8nAJiiLHwQBY0UI0XZcEMaFKaA=
-google.golang.org/genproto/googleapis/api v0.0.0-20240205150955-31a09d347014/go.mod h1:rbHMSEDyoYX62nRVLOCc4Qt1HbsdytAYoVwgjiOhF3I=
-google.golang.org/genproto/googleapis/bytestream v0.0.0-20240125205218-1f4bbc51befe/go.mod h1:SCz6T5xjNXM4QFPRwxHcfChp7V+9DcXR3ay2TkHR8Tg=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20231106174013-bbf56f31fb17/go.mod h1:oQ5rr10WTTMvP4A36n8JpR1OrO1BEiV4f78CneXZxkA=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20240102182953-50ed04b92917/go.mod h1:xtjpI3tXFPP051KaWnhvxkiubL/6dJ18vLVf7q2pTOU=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20240125205218-1f4bbc51befe/go.mod h1:PAREbraiVEVGVdTZsVWjSbbTtSyGbAgIIvni8a8CD5s=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20240205150955-31a09d347014/go.mod h1:SaPjaZGWb0lPqs6Ittu0spdfrOArqji4ZdeP5IC/9N4=
-google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
-google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
-google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
-google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
-google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
-google.golang.org/grpc v1.60.1/go.mod h1:OlCHIeLYqSSsLi6i49B5QGdzaMZK9+M7LXN2FKz4eGM=
-google.golang.org/grpc v1.61.0/go.mod h1:VUbo7IFqmF1QtCAstipjG0GIoq49KvMe9+h1jFLBNJs=
-google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
-google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
-google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
-google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
-google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
-google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
-google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
-google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
-google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
-gopkg.in/ini.v1 v1.66.4/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
-honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
-honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
-lukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
-modernc.org/cc/v3 v3.41.0/go.mod h1:Ni4zjJYJ04CDOhG7dn640WGfwBzfE0ecX8TyMB0Fv0Y=
-modernc.org/ccgo/v3 v3.16.15/go.mod h1:yT7B+/E2m43tmMOT51GMoM98/MtHIcQQSleGnddkUNI=
-modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
diff --git a/lsm/marshal.go b/lsm/marshal.go
new file mode 100644
index 0000000..00034d2
--- /dev/null
+++ b/lsm/marshal.go
@@ -0,0 +1,138 @@
+package lsm
+
+import (
+	"bytes"
+	"encoding"
+	"encoding/binary"
+	"fmt"
+)
+
+type entry struct {
+	key   string
+	value uint64
+}
+
+// MarshalBinary implements encoding.BinaryMarshaler.
+func (e *entry) MarshalBinary() (data []byte, err error) {
+	data = make([]byte, len(e.key), len(e.key)+binary.MaxVarintLen16)
+	copy(data, e.key)
+
+	data = binary.AppendUvarint(data, e.value)
+	reverse(data[len(e.key):])
+	return data, err
+}
+
+// UnmarshalBinary implements encoding.BinaryUnmarshaler.
+func (e *entry) UnmarshalBinary(data []byte) error {
+	// fmt.Println("unmarshal", data, string(data))
+
+	if len(data) < binary.MaxVarintLen16 {
+		return fmt.Errorf("%w: bad data", ErrDecode)
+	}
+	head := make([]byte, binary.MaxVarintLen16)
+	copy(head, data[max(0, len(data)-cap(head)):])
+	reverse(head)
+
+	size := 0
+	e.value, size = binary.Uvarint(head)
+	if size == 0 {
+		return fmt.Errorf("%w: invalid data", ErrDecode)
+	}
+	e.key = string(data[:len(data)-size])
+
+	return nil
+}
+
+var _ encoding.BinaryMarshaler = (*entry)(nil)
+var _ encoding.BinaryUnmarshaler = (*entry)(nil)
+
+type entries []entry
+
+// MarshalBinary implements encoding.BinaryMarshaler.
+func (lis *entries) MarshalBinary() (data []byte, err error) {
+	var buf bytes.Buffer
+
+	for _, e := range *lis {
+		d, err := e.MarshalBinary()
+		if err != nil {
+			return nil, err
+		}
+
+		_, err = buf.Write(d)
+		if err != nil {
+			return nil, err
+		}
+
+		_, err = buf.Write(reverse(binary.AppendUvarint(make([]byte, 0, binary.MaxVarintLen32), uint64(len(d)))))
+		if err != nil {
+			return nil, err
+		}
+	}
+
+	return buf.Bytes(), err
+}
+
+// UnmarshalBinary implements encoding.BinaryUnmarshaler.
+func (lis *entries) UnmarshalBinary(data []byte) error {
+	head := make([]byte, binary.MaxVarintLen16)
+	pos := uint64(len(data))
+
+	for pos > 0 {
+		copy(head, data[max(0, pos-uint64(cap(head))):])
+		length, size := binary.Uvarint(reverse(head))
+
+		e := entry{}
+		if err := e.UnmarshalBinary(data[max(0, pos-(length+uint64(size))) : pos-uint64(size)]); err != nil {
+			return err
+		}
+		*lis = append(*lis, e)
+
+		pos -= length + uint64(size)
+	}
+	reverse(*lis)
+	return nil
+}
+
+var _ encoding.BinaryMarshaler = (*entries)(nil)
+var _ encoding.BinaryUnmarshaler = (*entries)(nil)
+
+type segment struct {
+	entries entries
+}
+
+// MarshalBinary implements encoding.BinaryMarshaler.
+func (s *segment) MarshalBinary() (data []byte, err error) {
+	head := header{
+		entries: uint64(len(s.entries)),
+	}
+
+	data, err = s.entries.MarshalBinary()
+	if err != nil {
+		return nil, err
+	}
+
+	head.datalen = uint64(len(data))
+
+	h := hash()
+	h.Write(data)
+	head.sig = h.Sum(nil)
+
+	return head.Append(data), err
+}
+
+// UnmarshalBinary implements encoding.BinaryUnmarshaler.
+func (s *segment) UnmarshalBinary(data []byte) error {
+	head, err := ReadHead(data)
+	if err != nil {
+		return err
+	}
+
+	h := hash()
+	h.Write(data[:head.datalen])
+	if !bytes.Equal(head.sig, h.Sum(nil)) {
+		return fmt.Errorf("%w: invalid checksum", ErrDecode)
+	}
+
+	s.entries = make(entries, 0, head.entries)
+	return s.entries.UnmarshalBinary(data[:head.datalen])
+}
diff --git a/lsm/marshal_test.go b/lsm/marshal_test.go
new file mode 100644
index 0000000..8bdcffa
--- /dev/null
+++ b/lsm/marshal_test.go
@@ -0,0 +1,76 @@
+package lsm
+
+import (
+	"io/fs"
+	"testing"
+
+	"github.com/matryer/is"
+)
+
+func TestEncoding(t *testing.T) {
+	is := is.New(t)
+
+	data := segment{entries: entries{
+		{"key-1", 1},
+		{"key-2", 2},
+		{"key-3", 3},
+		{"longerkey-4", 65535},
+	}}
+
+	b, err := data.MarshalBinary()
+	is.NoErr(err)
+
+	var got segment
+	err = got.UnmarshalBinary(b)
+	is.NoErr(err)
+
+	is.Equal(data, got)
+}
+
+func TestReverse(t *testing.T) {
+	is := is.New(t)
+
+	got := []byte("gnirts a si siht")
+	reverse(got)
+
+	is.Equal(got, []byte("this is a string"))
+
+	got = []byte("!gnirts a si siht")
+	reverse(got)
+
+	is.Equal(got, []byte("this is a string!"))
+}
+
+func TestFile(t *testing.T) {
+	is := is.New(t)
+
+	entries := entries {
+		{"key-1", 1},
+		{"key-2", 2},
+		{"key-3", 3},
+		{"longerkey-4", 65535},
+	}
+
+	f := basicFile(t, entries, entries, entries)
+
+	sf, err := ReadFile(f)
+	is.NoErr(err)
+
+	is.Equal(len(sf.segments), 3)
+}
+
+func basicFile(t *testing.T, lis ...entries) fs.File {
+	t.Helper()
+
+	segments := make([][]byte, len(lis))
+	var err error
+	for i, entries := range lis {
+		data := segment{entries: entries}
+		segments[i], err = data.MarshalBinary()
+		if err != nil {
+			t.Error(err)
+		}
+	}
+
+	return NewFile(segments...)
+}
diff --git a/lsm/sst.go b/lsm/sst.go
new file mode 100644
index 0000000..e95731c
--- /dev/null
+++ b/lsm/sst.go
@@ -0,0 +1,370 @@
+// SPDX-FileCopyrightText: 2023 Jon Lundy 
+// SPDX-License-Identifier: BSD-3-Clause
+
+// lsm -- Log Structured Merge-Tree
+//
+// This is a basic LSM tree using a SSTable optimized for append only writing. On disk data is organized into time ordered
+// files of segments, containing reverse sorted keys. Each segment ends with a magic value `Souris\x01`, a 4byte hash, count of
+// segment entries, and data length.
+
+package lsm
+
+import (
+	"bytes"
+	"encoding"
+	"encoding/binary"
+	"errors"
+	"fmt"
+	"hash/fnv"
+	"io"
+	"io/fs"
+	"sort"
+)
+
+var (
+	magic      = reverse(append([]byte("Souris"), '\x01'))
+	hash       = fnv.New32a
+	hashLength = hash().Size()
+	// segmentSize         = 2 ^ 16 // min 2^9 = 512b, max? 2^20 = 1M
+	segmentFooterLength = len(magic) + hashLength + binary.MaxVarintLen32 + binary.MaxVarintLen32
+)
+
+type header struct {
+	sig     []byte // 4Byte signature
+	entries uint64 // count of entries in segment
+	datalen uint64 // length of data
+	headlen uint64 // length of header
+	end     int64  // location of end of data/start of header (start of data is `end - datalen`)
+}
+
+// ReadHead parse header from a segment. reads from the end of slice of length segmentFooterLength
+func ReadHead(data []byte) (*header, error) {
+	if len(data) < len(magic)+6 {
+		return nil, fmt.Errorf("%w: invalid size", ErrDecode)
+	}
+
+	if !bytes.Equal(data[len(data)-len(magic):], magic) {
+		return nil, fmt.Errorf("%w: invalid header", ErrDecode)
+	}
+
+	head := make([]byte, 0, segmentFooterLength)
+	head = reverse(append(head, data[max(0, len(data)-cap(head)-1):]...))
+	size, s := binary.Uvarint(head[len(magic)+4:])
+	length, i := binary.Uvarint(head[len(magic)+4+s:])
+
+	return &header{
+		sig:     head[len(magic) : len(magic)+4],
+		entries: size,
+		datalen: length,
+		headlen: uint64(len(magic) + hashLength + s + i),
+		end:     int64(len(data)),
+	}, nil
+}
+func (h *header) Append(data []byte) []byte {
+
+	length := len(data)
+	data = append(data, h.sig...)
+	data = binary.AppendUvarint(data, h.entries)
+	data = binary.AppendUvarint(data, h.datalen)
+	reverse(data[length:])
+
+	return append(data, magic...)
+}
+
+var _ encoding.BinaryMarshaler = (*segment)(nil)
+var _ encoding.BinaryUnmarshaler = (*segment)(nil)
+
+var ErrDecode = errors.New("decode")
+
+func reverse[T any](b []T) []T {
+	l := len(b)
+	for i := 0; i < l/2; i++ {
+		b[i], b[l-i-1] = b[l-i-1], b[i]
+	}
+	return b
+}
+
+// func clone[T ~[]E, E any](e []E) []E {
+// 	return append(e[0:0:0], e...)
+// }
+
+type entryBytes []byte
+
+// KeyValue returns the parsed key and value from an entry
+func (e entryBytes) KeyValue() ([]byte, uint64) {
+	if len(e) < 2 {
+		return nil, 0
+	}
+	head := reverse(append(e[0:0:0], e[max(0, len(e)-binary.MaxVarintLen64):]...))
+	value, i := binary.Uvarint(head)
+	return append(e[0:0:0], e[:len(e)-i]...), value
+}
+
+// NewKeyValue packed into an entry
+func NewKeyValue(key []byte, val uint64) entryBytes {
+	length := len(key)
+	data := append(key[0:0:0], key...)
+	data = binary.AppendUvarint(data, val)
+	reverse(data[length:])
+
+	return data
+}
+
+type listEntries []entryBytes
+
+// WriteTo implements io.WriterTo.
+func (lis *listEntries) WriteTo(wr io.Writer) (int64, error) {
+	if lis == nil {
+		return 0, nil
+	}
+
+	head := header{
+		entries: uint64(len(*lis)),
+	}
+	h := hash()
+
+	wr = io.MultiWriter(wr, h)
+
+	var i int64
+	for _, b := range *lis {
+		j, err := wr.Write(b)
+		i += int64(j)
+		if err != nil {
+			return i, err
+		}
+
+		j, err = wr.Write(reverse(binary.AppendUvarint(make([]byte, 0, binary.MaxVarintLen32), uint64(len(b)))))
+		i += int64(j)
+		if err != nil {
+			return i, err
+		}
+	}
+	head.datalen = uint64(i)
+	head.sig = h.Sum(nil)
+
+	b := head.Append([]byte{})
+	j, err := wr.Write(b)
+	i += int64(j)
+
+	return i, err
+}
+
+var _ sort.Interface = listEntries{}
+
+// Len implements sort.Interface.
+func (lis listEntries) Len() int {
+	return len(lis)
+}
+
+// Less implements sort.Interface.
+func (lis listEntries) Less(i int, j int) bool {
+	iname, _ := lis[i].KeyValue()
+	jname, _ := lis[j].KeyValue()
+
+	return bytes.Compare(iname, jname) < 0
+}
+
+// Swap implements sort.Interface.
+func (lis listEntries) Swap(i int, j int) {
+	lis[i], lis[j] = lis[j], lis[i]
+}
+
+type segmentReader struct {
+	head *header
+	rd   io.ReaderAt
+}
+
+// FirstEntry parses the first segment entry from the end of the segment
+func (s *segmentReader) FirstEntry() (*entryBytes, error) {
+	e, _, err := s.readEntryAt(-1)
+	return e, err
+}
+
+func (s *segmentReader) VerifyHash() (bool, error) {
+	h := hash()
+	data := make([]byte, s.head.datalen)
+	_, err := s.rd.ReadAt(data, s.head.end-int64(s.head.datalen))
+	if err != nil {
+		return false, err
+	}
+	_, err = h.Write(data)
+	ok := bytes.Equal(h.Sum(nil), s.head.sig)
+
+	return ok, err
+}
+
+// Find locates needle within a segment. if it cant find it will return the nearest key before needle.
+func (s *segmentReader) Find(needle []byte, first bool) (*entryBytes, bool, error) {
+	if s == nil {
+		return nil, false, nil
+	}
+	e, pos, err := s.readEntryAt(-1)
+	if err != nil {
+		return nil, false, err
+	}
+
+	last := e
+	found := false
+	for pos > 0 {
+		key, _ := e.KeyValue()
+		switch bytes.Compare(key, needle) {
+		case 1: // key=ccc, needle=bbb
+			return last, found, nil
+		case 0: // equal
+			if first {
+				return e, true, nil
+			}
+			found = true
+			fallthrough
+		case -1: // key=aaa, needle=bbb
+			last = e
+			e, pos, err = s.readEntryAt(pos)
+			if err != nil {
+				return nil, found, err
+			}
+		}
+	}
+	return last, found, nil
+}
+func (s *segmentReader) readEntryAt(pos int64) (*entryBytes, int64, error) {
+	if pos < 0 {
+		pos = s.head.end
+	}
+	head := make([]byte, binary.MaxVarintLen16)
+	s.rd.ReadAt(head, pos-binary.MaxVarintLen16)
+	length, hsize := binary.Uvarint(reverse(head))
+
+	e := make(entryBytes, length)
+	_, err := s.rd.ReadAt(e, pos-int64(length)-int64(hsize))
+
+	return &e, pos - int64(length) - int64(hsize), err
+}
+
+type logFile struct {
+	rd interface {
+		io.ReaderAt
+		io.WriterTo
+	}
+	segments []segmentReader
+
+	fs.File
+}
+
+func ReadFile(fd fs.File) (*logFile, error) {
+	l := &logFile{File: fd}
+
+	stat, err := fd.Stat()
+	if err != nil {
+		return nil, err
+	}
+
+	eof := stat.Size()
+	if rd, ok := fd.(interface {
+		io.ReaderAt
+		io.WriterTo
+	}); ok {
+		l.rd = rd
+
+	} else {
+		rd, err := io.ReadAll(fd)
+		if err != nil {
+			return nil, err
+		}
+		l.rd = bytes.NewReader(rd)
+	}
+
+	head := make([]byte, segmentFooterLength)
+	for eof > 0 {
+		_, err = l.rd.ReadAt(head, eof-int64(segmentFooterLength))
+		if err != nil {
+			return nil, err
+		}
+
+		s := segmentReader{
+			rd: l.rd,
+		}
+		s.head, err = ReadHead(head)
+		s.head.end = eof - int64(s.head.headlen)
+		if err != nil {
+			return nil, err
+		}
+		eof -= int64(s.head.datalen) + int64(s.head.headlen)
+		l.segments = append(l.segments, s)
+	}
+
+	return l, nil
+}
+
+func (l *logFile) Count() int64 {
+	return int64(len(l.segments))
+}
+func (l *logFile) LoadSegment(pos int64) (*segmentBytes, error) {
+	if pos < 0 {
+		pos = int64(len(l.segments) - 1)
+	}
+	if pos > int64(len(l.segments)-1) {
+		return nil, ErrDecode
+	}
+	s := l.segments[pos]
+
+	b := make([]byte, s.head.datalen+s.head.headlen)
+	_, err := l.rd.ReadAt(b, s.head.end-int64(len(b)))
+	if err != nil {
+		return nil, err
+	}
+
+	return &segmentBytes{b, -1}, nil
+}
+func (l *logFile) Find(needle []byte, first bool) (*entryBytes, bool, error) {
+	var cur, last segmentReader
+
+	for _, s := range l.segments {
+		cur = s
+		e, err := cur.FirstEntry()
+		if err != nil {
+			return nil, false, err
+		}
+		k, _ := e.KeyValue()
+
+		if first && bytes.Equal(k, needle) {
+			break
+		}
+		if first && bytes.Compare(k, needle) > 0 {
+			e, ok, err := cur.Find(needle, first)
+			if ok || err != nil{
+				return e, ok, err
+			}
+			break
+		}
+		if !first && bytes.Compare(k, needle) > 0 {
+			break
+		}
+		last = s
+	}
+
+	e, ok, err := last.Find(needle, first)
+	if ok || err != nil{
+		return e, ok, err
+	}
+	// if by mistake it was not found in the last.. check the next segment.
+	return cur.Find(needle, first)
+}
+func (l *logFile) WriteTo(w io.Writer) (int64, error) {
+	return l.rd.WriteTo(w)
+}
+
+type segmentBytes struct {
+	b   []byte
+	pos int
+}
+
+type dataset struct {
+	rd    io.ReaderAt
+	files []logFile
+
+	fs.FS
+}
+
+func ReadDataset(fd fs.FS) (*dataset, error) {
+	panic("not implemented")
+}
diff --git a/lsm/sst_test.go b/lsm/sst_test.go
new file mode 100644
index 0000000..7e842ac
--- /dev/null
+++ b/lsm/sst_test.go
@@ -0,0 +1,327 @@
+// SPDX-FileCopyrightText: 2023 Jon Lundy 
+// SPDX-License-Identifier: BSD-3-Clause
+
+package lsm
+
+import (
+	"bytes"
+	crand "crypto/rand"
+	"encoding/base64"
+	"io"
+	"io/fs"
+	"math/rand"
+	"os"
+	"sort"
+	"sync"
+	"testing"
+	"time"
+
+	"github.com/matryer/is"
+)
+
+func TestLargeFile(t *testing.T) {
+	is := is.New(t)
+
+	segCount := 4098
+
+	f := randFile(t, 2_000_000, segCount)
+
+	sf, err := ReadFile(f)
+	is.NoErr(err)
+
+	is.True(len(sf.segments) <= segCount)
+	var needle []byte
+	for i, s := range sf.segments {
+		e, err := s.FirstEntry()
+		is.NoErr(err)
+		k, v := e.KeyValue()
+		needle = k
+		t.Logf("Segment-%d: %s = %d", i, k, v)
+	}
+	t.Log(f.Stat())
+
+	tt, ok, err := sf.Find(needle, true)
+	is.NoErr(err)
+	is.True(ok)
+	key, val := tt.KeyValue()
+	t.Log(string(key), val)
+
+	tt, ok, err = sf.Find([]byte("needle"), false)
+	is.NoErr(err)
+	is.True(!ok)
+	key, val = tt.KeyValue()
+	t.Log(string(key), val)
+
+	tt, ok, err = sf.Find([]byte{'\xff'}, false)
+	is.NoErr(err)
+	is.True(!ok)
+	key, val = tt.KeyValue()
+	t.Log(string(key), val)
+}
+
+func TestLargeFileDisk(t *testing.T) {
+	is := is.New(t)
+
+	segCount := 4098
+
+	t.Log("generate large file")
+	f := randFile(t, 2_000_000, segCount)
+
+	fd, err := os.CreateTemp("", "sst*")
+	is.NoErr(err)
+	defer func() { t.Log("cleanup:", fd.Name()); fd.Close(); os.Remove(fd.Name()) }()
+
+	t.Log("write file:", fd.Name())
+	_, err = io.Copy(fd, f)
+	is.NoErr(err)
+	fd.Seek(0, 0)
+
+	sf, err := ReadFile(fd)
+	is.NoErr(err)
+
+	is.True(len(sf.segments) <= segCount)
+	var needle []byte
+	for i, s := range sf.segments {
+		e, err := s.FirstEntry()
+		is.NoErr(err)
+		k, v := e.KeyValue()
+		needle = k
+
+		ok, err := s.VerifyHash()
+		is.NoErr(err)
+
+		t.Logf("Segment-%d: %s = %d %t", i, k, v, ok)
+		is.True(ok)
+	}
+	t.Log(f.Stat())
+
+	tt, ok, err := sf.Find(needle, false)
+	is.NoErr(err)
+	is.True(ok)
+	key, val := tt.KeyValue()
+	t.Log(string(key), val)
+
+	tt, ok, err = sf.Find([]byte("needle"), false)
+	is.NoErr(err)
+	is.True(!ok)
+	key, val = tt.KeyValue()
+	t.Log(string(key), val)
+
+	tt, ok, err = sf.Find([]byte{'\xff'}, false)
+	is.NoErr(err)
+	is.True(!ok)
+	key, val = tt.KeyValue()
+	t.Log(string(key), val)
+}
+
+func BenchmarkLargeFile(b *testing.B) {
+	segCount := 4098 / 4
+	f := randFile(b, 2_000_000, segCount)
+
+	sf, err := ReadFile(f)
+	if err != nil {
+		b.Error(err)
+	}
+	key := make([]byte, 5)
+	keys := make([][]byte, b.N)
+	for i := range keys {
+		_, err = crand.Read(key)
+		if err != nil {
+			b.Error(err)
+		}
+		keys[i] = []byte(base64.RawURLEncoding.EncodeToString(key))
+	}
+	b.Log("ready", b.N)
+	b.ResetTimer()
+	okays := 0
+	each := b.N / 10
+	for n := 0; n < b.N; n++ {
+		if each > 0 && n%each == 0 {
+			b.Log(n)
+		}
+		_, ok, err := sf.Find(keys[n], false)
+		if err != nil {
+			b.Error(err)
+		}
+		if ok {
+			okays++
+		}
+	}
+	b.Log("okays=", b.N, okays)
+}
+
+// TestFindRange is an initial range find for start and stop of a range of needles.
+// TODO: start the second query from where the first left off. Use an iterator?
+func TestFindRange(t *testing.T) {
+	is := is.New(t)
+
+	f := basicFile(t, 
+		entries{
+			{"AD", 5},
+			{"AC", 5},
+			{"AB", 4},
+			{"AB", 3},
+		},
+		entries{
+			{"AB", 2},
+			{"AA", 1},
+		},
+	)
+	sf, err := ReadFile(f)
+	is.NoErr(err)
+
+	var ok bool
+	var first, last  *entryBytes
+
+	first, ok, err = sf.Find([]byte("AB"), true)
+	is.NoErr(err)
+
+	key, val := first.KeyValue()
+	t.Log(string(key), val)
+
+	is.True(ok)
+	is.Equal(key, []byte("AB"))
+	is.Equal(val, uint64(2))
+
+	last, ok, err = sf.Find([]byte("AB"), false)
+	is.NoErr(err)
+
+	key, val = last.KeyValue()
+	t.Log(string(key), val)
+
+	is.True(ok)
+	is.Equal(key, []byte("AB"))
+	is.Equal(val, uint64(4))
+
+
+	last, ok, err = sf.Find([]byte("AC"), false)
+	is.NoErr(err)
+
+	key, val = last.KeyValue()
+	t.Log(string(key), val)
+
+	is.True(ok)
+	is.Equal(key, []byte("AC"))
+	is.Equal(val, uint64(5))
+}
+
+func randFile(t interface {
+	Helper()
+	Error(...any)
+}, size int, segments int) fs.File {
+	t.Helper()
+
+	lis := make(listEntries, size)
+	for i := range lis {
+		key := make([]byte, 5)
+		_, err := crand.Read(key)
+		if err != nil {
+			t.Error(err)
+		}
+		key = []byte(base64.RawURLEncoding.EncodeToString(key))
+		// key := []byte(fmt.Sprintf("key-%05d", i))
+
+		lis[i] = NewKeyValue(key, rand.Uint64()%16_777_216)
+	}
+
+	sort.Sort(sort.Reverse(&lis))
+	each := size / segments
+	if size%segments != 0 {
+		each++
+	}
+	split := make([]listEntries, segments)
+
+	for i := range split {
+		if (i+1)*each > len(lis) {
+			split[i] = lis[i*each : i*each+len(lis[i*each:])]
+			split = split[:i+1]
+			break
+		}
+		split[i] = lis[i*each : (i+1)*each]
+	}
+
+	var b bytes.Buffer
+	for _, s := range split {
+		s.WriteTo(&b)
+	}
+
+	return NewFile(b.Bytes())
+}
+
+type fakeStat struct {
+	size int64
+}
+
+// IsDir implements fs.FileInfo.
+func (*fakeStat) IsDir() bool {
+	panic("unimplemented")
+}
+
+// ModTime implements fs.FileInfo.
+func (*fakeStat) ModTime() time.Time {
+	panic("unimplemented")
+}
+
+// Mode implements fs.FileInfo.
+func (*fakeStat) Mode() fs.FileMode {
+	panic("unimplemented")
+}
+
+// Name implements fs.FileInfo.
+func (*fakeStat) Name() string {
+	panic("unimplemented")
+}
+
+// Size implements fs.FileInfo.
+func (s *fakeStat) Size() int64 {
+	return s.size
+}
+
+// Sys implements fs.FileInfo.
+func (*fakeStat) Sys() any {
+	panic("unimplemented")
+}
+
+var _ fs.FileInfo = (*fakeStat)(nil)
+
+type rd interface {
+	io.ReaderAt
+	io.Reader
+}
+type fakeFile struct {
+	stat func() fs.FileInfo
+
+	rd
+}
+
+func (fakeFile) Close() error                 { return nil }
+func (f fakeFile) Stat() (fs.FileInfo, error) { return f.stat(), nil }
+
+func NewFile(b ...[]byte) fs.File {
+	in := bytes.Join(b, nil)
+	rd := bytes.NewReader(in)
+	size := int64(len(in))
+	return &fakeFile{stat: func() fs.FileInfo { return &fakeStat{size: size} }, rd: rd}
+}
+func NewFileFromReader(rd *bytes.Reader) fs.File {
+	return &fakeFile{stat: func() fs.FileInfo { return &fakeStat{size: int64(rd.Len())} }, rd: rd}
+}
+
+type fakeFS struct {
+	files map[string]*fakeFile
+	mu    sync.RWMutex
+}
+
+// Open implements fs.FS.
+func (f *fakeFS) Open(name string) (fs.File, error) {
+	f.mu.RLock()
+	defer f.mu.RUnlock()
+
+	if file, ok := f.files[name]; ok {
+		return file, nil
+	}
+
+	return nil, fs.ErrNotExist
+}
+
+var _ fs.FS = (*fakeFS)(nil)
-- 
2.45.1