feat(web): SQL metadata backend (SQLite + Postgres/Supabase) + conformance (phase 2)

Add a database/sql MetaStore (db_sql.go) targeting SQLite locally and
Postgres/Supabase in production through one portable schema: real tables
(accounts, tokens, auth_policy, projects, orgs, org_members, invites,
shares, devices), idempotent CREATE TABLE IF NOT EXISTS migrations at Open,
?→$N placeholder rebinding for Postgres, times as RFC3339 text, and
transactional multi-row writes (an org and its members). Pure-Go drivers
only (modernc.org/sqlite, jackc/pgx v5) so CGO-free builds keep working.

One shared conformance suite (db_conformance_test.go) runs the same
service-level operations — accounts+tokens, policy, pending/approve,
projects create-or-join/rename/delete, org roles, invite create/redeem/
uses/validity, share create/revoke/expiry, devices — then reopens the store
and asserts everything persisted. Runs against file AND sqlite always, and
postgres when BDRIVE_TEST_POSTGRES is set. Verified green on all three
against a real local Postgres.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R7Q9ZKSZRTdvrSJkYLUmYs
This commit is contained in:
Snow Lee
2026-07-10 00:07:38 -07:00
co-authored by Claude Opus 4.8
parent f14601a983
commit 0fa83e4532
4 changed files with 776 additions and 3 deletions
+13 -2
View File
@@ -4,14 +4,18 @@ go 1.25.8
require (
cloud.google.com/go/storage v1.62.3
github.com/AlecAivazis/survey/v2 v2.3.7
github.com/aws/aws-sdk-go-v2 v1.42.0
github.com/aws/aws-sdk-go-v2/config v1.32.25
github.com/aws/aws-sdk-go-v2/service/s3 v1.103.3
github.com/aws/smithy-go v1.27.2
github.com/jackc/pgx/v5 v5.10.0
github.com/mattn/go-isatty v0.0.20
github.com/spf13/cobra v1.10.2
github.com/yuin/goldmark v1.8.2
golang.org/x/crypto v0.51.0
google.golang.org/api v0.284.0
modernc.org/sqlite v1.53.0
)
require (
@@ -22,7 +26,6 @@ require (
cloud.google.com/go/compute/metadata v0.9.0 // indirect
cloud.google.com/go/iam v1.7.0 // indirect
cloud.google.com/go/monitoring v1.24.3 // indirect
github.com/AlecAivazis/survey/v2 v2.3.7 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 // indirect
@@ -42,6 +45,7 @@ require (
github.com/aws/aws-sdk-go-v2/service/sts v1.43.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/envoyproxy/go-control-plane/envoy v1.37.0 // indirect
github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
@@ -53,11 +57,15 @@ require (
github.com/googleapis/enterprise-certificate-proxy v0.3.16 // indirect
github.com/googleapis/gax-go/v2 v2.22.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/mattn/go-colorable v0.1.2 // indirect
github.com/mattn/go-isatty v0.0.8 // indirect
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/spf13/pflag v1.0.9 // indirect
github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
@@ -81,4 +89,7 @@ require (
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
google.golang.org/grpc v1.81.1 // indirect
google.golang.org/protobuf v1.36.11 // indirect
modernc.org/libc v1.73.4 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)
+58 -1
View File
@@ -30,6 +30,7 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.55.0/go.mod h1:vB2GH9GAYYJTO3mEn8oYwzEdhlayZIdQz6zdzgUIRvA=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 h1:0s6TxfCu2KHkkZPnBfsQ2y5qia0jl3MMrmBhu3nCOYk=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc=
github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63nhn5WAunQHLTznkw5W8b1Xc0dNjp83s=
github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w=
github.com/aws/aws-sdk-go-v2 v1.42.0 h1:XvXMJTkFQtpBKIWZnmr9ZEOc2InWM2yldjXEJ/bymhA=
github.com/aws/aws-sdk-go-v2 v1.42.0/go.mod h1:27+ACypSLljLAEKsCYOmrjKh83vuTRkuAe9Uv/3A4bg=
@@ -72,11 +73,14 @@ github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XL
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik=
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.17 h1:QeVUsEDNrLBW4tMgZHvxy18sKtr6VI492kBhUfhDJNI=
github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
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/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA=
github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU=
github.com/envoyproxy/go-control-plane/envoy v1.37.0 h1:u3riX6BoYRfF4Dr7dwSOroNfdSbEPe9Yyl09/B6wBrQ=
@@ -100,6 +104,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc=
github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -108,22 +114,38 @@ github.com/googleapis/enterprise-certificate-proxy v0.3.16 h1:F/VPrx0YPBdksZJQdC
github.com/googleapis/enterprise-certificate-proxy v0.3.16/go.mod h1:9Yb0eAkH/Xqhvv3zbeKf/+wMJqCeocWc6KIhDvEAuYE=
github.com/googleapis/gax-go/v2 v2.22.0 h1:PjIWBpgGIVKGoCXuiCoP64altEJCj3/Ei+kSU5vlZD4=
github.com/googleapis/gax-go/v2 v2.22.0/go.mod h1:irWBbALSr0Sk3qlqb9SyJ1h68WjgeFuiOzI4Rqw5+aY=
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/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog=
github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
github.com/mattn/go-colorable v0.1.2 h1:/bC9yWikZXAL9uJdulbSfyVNIR3n3trXl+v8+1sx8mU=
github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
github.com/mattn/go-isatty v0.0.8 h1:HLtExJ+uU2HOZ+wI0Tt5DtUDrx8yhUqDcp7fYERX4CE=
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
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/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4=
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo=
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
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/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
@@ -132,7 +154,9 @@ github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An
github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo=
github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
@@ -164,6 +188,8 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
@@ -181,6 +207,7 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
@@ -198,6 +225,8 @@ golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
@@ -217,3 +246,31 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.28.4 h1:Hd/4Es+MBj+/7hSdZaisNyu6bv3V0Dp2MdllyfqaH+c=
modernc.org/cc/v4 v4.28.4/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.4 h1:OVnSOWQjVKOYkFxoHYB+qQmSHK5gqMqARM+K9DpR/Ws=
modernc.org/ccgo/v4 v4.34.4/go.mod h1:qdKqE8FNIYyysougB1RX9MxCzp5oJOcQXSobANJ4TuE=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.3 h1:6QAplYyVO+KdPW3pGnqmJDUxtkec8ooEWvks/hhU3lc=
modernc.org/gc/v3 v3.1.3/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.73.4 h1:+ra4Ui8ngyt8HDcO1FTDPWlkAh6yOdaO2yAoh8MddQA=
modernc.org/libc v1.73.4/go.mod h1:DXZ3eO8qMCNn2SnmTNCiC71nJ9Rcq3PsnpU6Vc4rWK8=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.53.0 h1:20WG8N9q4ji/dEqGk4uiI0c6OPjSeLTNYGFCc3+7c1M=
modernc.org/sqlite v1.53.0/go.mod h1:xoEpOIpGrgT48H5iiyt/YXPCZPEzlfmfFwtk8Lklw8s=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+241
View File
@@ -0,0 +1,241 @@
package webapp
import (
"database/sql"
"os"
"path/filepath"
"testing"
"time"
)
// A metaBackend is one MetaStore implementation under test. reset clears any
// shared durable state before the run; open returns a fresh store over the
// SAME underlying storage, so the suite can write, reopen, and prove the data
// persisted.
type metaBackend struct {
name string
reset func(t *testing.T)
open func(t *testing.T) MetaStore
}
func metaBackends(t *testing.T) []metaBackend {
dir := t.TempDir()
sqlitePath := filepath.Join(t.TempDir(), "meta.db")
backends := []metaBackend{
{
name: "file",
reset: func(t *testing.T) {},
open: func(t *testing.T) MetaStore {
s, err := OpenFileStore(dir)
if err != nil {
t.Fatal(err)
}
return s
},
},
{
name: "sqlite",
reset: func(t *testing.T) {},
open: func(t *testing.T) MetaStore {
s, err := OpenSQLStore("sqlite", sqlitePath)
if err != nil {
t.Fatal(err)
}
return s
},
},
}
// Postgres/Supabase is exercised only when a DSN is reachable.
if dsn := os.Getenv("BDRIVE_TEST_POSTGRES"); dsn != "" {
backends = append(backends, metaBackend{
name: "postgres",
reset: func(t *testing.T) {
db, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatalf("postgres reset: %v", err)
}
defer db.Close()
db.Exec(`DROP TABLE IF EXISTS accounts, tokens, auth_policy, projects, orgs, org_members, invites, shares, devices`)
},
open: func(t *testing.T) MetaStore {
s, err := OpenSQLStore("pgx", dsn)
if err != nil {
t.Fatal(err)
}
return s
},
})
} else {
t.Log("BDRIVE_TEST_POSTGRES not set — postgres backend UNTESTED in this run")
}
return backends
}
// TestMetaStoreConformance runs the same service-level operations against every
// backend, then reopens the store and asserts the data survived — covering
// accounts+tokens, the signup policy, pending/approve, projects (create-or-join,
// rename, delete), orgs (roles), invites (create/redeem/uses/validity), shares
// (create/revoke/expiry), and devices.
func TestMetaStoreConformance(t *testing.T) {
for _, be := range metaBackends(t) {
t.Run(be.name, func(t *testing.T) {
be.reset(t)
// ---- write everything through the services ----
st := be.open(t)
auth, err := NewBuiltinAuth(st.Accounts(), true, nil)
if err != nil {
t.Fatal(err)
}
// account + token
u, err := auth.signup("dev@x.io", "Dev", "password1")
if err != nil {
t.Fatal(err)
}
tok, err := auth.issueToken(u.ID, "cli")
if err != nil {
t.Fatal(err)
}
if _, ok := auth.userForToken(tok); !ok {
t.Fatal("token should authenticate its account")
}
// pending + policy
if err := auth.SetPolicy(false, true); err != nil { // require approval
t.Fatal(err)
}
pend, err := auth.signup("pending@x.io", "Pend", "password1")
if err != nil {
t.Fatal(err)
}
if pend.Status != statusPending {
t.Fatalf("new account status = %q, want pending", pend.Status)
}
projects, err := NewProjectDB(st.Projects())
if err != nil {
t.Fatal(err)
}
p1, created, err := projects.GetOrCreate("wiki", "o-1")
if err != nil || !created {
t.Fatalf("create wiki: created=%v err=%v", created, err)
}
if _, again, _ := projects.GetOrCreate("wiki", "o-1"); again {
t.Fatal("same name+org must join, not create")
}
if _, other, _ := projects.GetOrCreate("wiki", "o-2"); !other {
t.Fatal("same name in a different org must create")
}
if err := projects.Rename(p1.ID, "handbook"); err != nil {
t.Fatal(err)
}
p2, _, _ := projects.GetOrCreate("scratch", "o-1")
if err := projects.Delete(p2.ID); err != nil {
t.Fatal(err)
}
orgs, err := NewOrgDB(st.Orgs())
if err != nil {
t.Fatal(err)
}
org, err := orgs.Create("Acme", "boss@x.io")
if err != nil {
t.Fatal(err)
}
if err := orgs.AddMember(org.ID, "worker@x.io", RoleMember); err != nil {
t.Fatal(err)
}
if err := orgs.SetRole(org.ID, "worker@x.io", RoleOwner); err != nil {
t.Fatal(err)
}
inv, err := orgs.CreateInvite(org.ID, "boss@x.io", time.Hour)
if err != nil {
t.Fatal(err)
}
orgs.RecordInviteUse(inv.Token)
if !orgs.ValidInvite(inv.Token) {
t.Fatal("fresh invite should be valid")
}
shares, err := NewShareDB(st.Shares())
if err != nil {
t.Fatal(err)
}
live, err := shares.Create(p1.ID, "handbook.md", "boss@x.io", 0)
if err != nil {
t.Fatal(err)
}
gone, _ := shares.Create(p1.ID, "temp.md", "boss@x.io", time.Hour)
if !shares.Revoke(gone.Token) {
t.Fatal("revoke should succeed")
}
devices, err := NewDeviceRegistry(st.Devices())
if err != nil {
t.Fatal(err)
}
devices.Observe(DeviceInfo{ID: "d1", Name: "laptop", OS: "mac", User: "dev@x.io", IP: "1.2.3.4"})
if err := st.Close(); err != nil {
t.Fatal(err)
}
// ---- reopen and verify everything persisted ----
st2 := be.open(t)
defer st2.Close()
auth2, _ := NewBuiltinAuth(st2.Accounts(), true, nil)
if _, ok := auth2.userForToken(tok); !ok {
t.Fatal("token lost across reload")
}
if !auth2.RequireApproval || auth2.RequireVerification {
t.Fatal("policy lost across reload")
}
if got := auth2.PendingUsers(); len(got) != 1 || got[0].Email != "pending@x.io" {
t.Fatalf("pending users after reload = %+v", got)
}
if err := auth2.Approve(pend.ID); err != nil {
t.Fatal(err)
}
if len(auth2.PendingUsers()) != 0 {
t.Fatal("approve did not clear pending")
}
projects2, _ := NewProjectDB(st2.Projects())
list := projects2.List()
if len(list) != 2 { // handbook (o-1), wiki (o-2); scratch was deleted
t.Fatalf("projects after reload = %+v", list)
}
hb, ok := projects2.Get(p1.ID)
if !ok || hb.Name != "handbook" {
t.Fatalf("rename lost across reload: %+v", hb)
}
orgs2, _ := NewOrgDB(st2.Orgs())
ro, ok := orgs2.Get(org.ID)
if !ok || ro.Members["boss@x.io"] != RoleOwner || ro.Members["worker@x.io"] != RoleOwner {
t.Fatalf("org roles lost across reload: %+v", ro)
}
if !orgs2.ValidInvite(inv.Token) {
t.Fatal("invite lost across reload")
}
if got := orgs2.ListInvites(org.ID); len(got) != 1 || got[0].Uses != 1 {
t.Fatalf("invite uses after reload = %+v", got)
}
shares2, _ := NewShareDB(st2.Shares())
if _, ok := shares2.Get(live.Token); !ok {
t.Fatal("live share lost across reload")
}
if _, ok := shares2.Get(gone.Token); ok {
t.Fatal("revoked share came back after reload")
}
devices2, _ := NewDeviceRegistry(st2.Devices())
d, ok := devices2.Get("d1")
if !ok || d.Name != "laptop" || d.IP != "1.2.3.4" {
t.Fatalf("device lost across reload: %+v", d)
}
})
}
}
+464
View File
@@ -0,0 +1,464 @@
package webapp
import (
"database/sql"
"fmt"
"strings"
"time"
// Pure-Go drivers only, so static (CGO-free) builds keep working.
_ "github.com/jackc/pgx/v5/stdlib" // "pgx" — Postgres / Supabase
_ "modernc.org/sqlite" // "sqlite" — embedded local DB
)
// The SQL backend: one *sql.DB shared by the repos, targeting SQLite (local)
// or Postgres/Supabase (production) through the same portable schema. Each
// record is a real row; multi-step writes (an org and its members) run in a
// transaction. Timestamps are stored as RFC3339 text and booleans as 0/1 so
// the same statements work on both engines.
type dialect int
const (
dialectSQLite dialect = iota
dialectPostgres
)
type sqlMetaStore struct {
db *sql.DB
d dialect
accounts *sqlAccountRepo
projects *sqlProjectRepo
orgs *sqlOrgRepo
shares *sqlShareRepo
devices *sqlDeviceRepo
}
// OpenSQLStore opens (and migrates) a SQL metadata store. driver is "sqlite"
// or "pgx" (Postgres/Supabase); dsn is the connection string / file path.
func OpenSQLStore(driver, dsn string) (MetaStore, error) {
db, err := sql.Open(driver, dsn)
if err != nil {
return nil, fmt.Errorf("open %s: %w", driver, err)
}
d := dialectSQLite
switch driver {
case "pgx", "postgres", "pgx/v5":
d = dialectPostgres
case "sqlite", "sqlite3":
d = dialectSQLite
default:
db.Close()
return nil, fmt.Errorf("unsupported database driver %q (use sqlite or pgx)", driver)
}
if err := db.Ping(); err != nil {
db.Close()
return nil, fmt.Errorf("connect %s: %w", driver, err)
}
s := &sqlMetaStore{db: db, d: d}
if err := s.migrate(); err != nil {
db.Close()
return nil, err
}
s.accounts = &sqlAccountRepo{s}
s.projects = &sqlProjectRepo{s}
s.orgs = &sqlOrgRepo{s}
s.shares = &sqlShareRepo{s}
s.devices = &sqlDeviceRepo{s}
return s, nil
}
func (s *sqlMetaStore) Accounts() AccountRepo { return s.accounts }
func (s *sqlMetaStore) Projects() ProjectRepo { return s.projects }
func (s *sqlMetaStore) Orgs() OrgRepo { return s.orgs }
func (s *sqlMetaStore) Shares() ShareRepo { return s.shares }
func (s *sqlMetaStore) Devices() DeviceRepo { return s.devices }
func (s *sqlMetaStore) Close() error { return s.db.Close() }
// q rebinds ?-placeholders to $1,$2,… for Postgres; SQLite keeps ?.
func (s *sqlMetaStore) q(query string) string {
if s.d != dialectPostgres {
return query
}
var b strings.Builder
n := 0
for _, r := range query {
if r == '?' {
n++
fmt.Fprintf(&b, "$%d", n)
} else {
b.WriteRune(r)
}
}
return b.String()
}
func (s *sqlMetaStore) exec(query string, args ...any) error {
_, err := s.db.Exec(s.q(query), args...)
return err
}
// tenc / tdec store times as RFC3339 text (empty string for the zero time),
// avoiding per-driver timestamp scanning differences.
func tenc(t time.Time) string {
if t.IsZero() {
return ""
}
return t.UTC().Format(time.RFC3339Nano)
}
func tdec(s string) time.Time {
if s == "" {
return time.Time{}
}
t, err := time.Parse(time.RFC3339Nano, s)
if err != nil {
return time.Time{}
}
return t.UTC()
}
func b2i(b bool) int {
if b {
return 1
}
return 0
}
func (s *sqlMetaStore) migrate() error {
stmts := []string{
`CREATE TABLE IF NOT EXISTS accounts (
id TEXT PRIMARY KEY, email TEXT NOT NULL, name TEXT NOT NULL,
pass TEXT NOT NULL, status TEXT NOT NULL DEFAULT '', created TEXT NOT NULL DEFAULT '')`,
`CREATE TABLE IF NOT EXISTS tokens (
hash TEXT PRIMARY KEY, user_id TEXT NOT NULL, device TEXT NOT NULL DEFAULT '',
created TEXT NOT NULL DEFAULT '')`,
`CREATE TABLE IF NOT EXISTS auth_policy (
id INTEGER PRIMARY KEY, require_verification INTEGER NOT NULL DEFAULT 0,
require_approval INTEGER NOT NULL DEFAULT 0)`,
`CREATE TABLE IF NOT EXISTS projects (
id TEXT PRIMARY KEY, name TEXT NOT NULL, org TEXT NOT NULL DEFAULT '',
created TEXT NOT NULL DEFAULT '')`,
`CREATE TABLE IF NOT EXISTS orgs (
id TEXT PRIMARY KEY, name TEXT NOT NULL, created TEXT NOT NULL DEFAULT '')`,
`CREATE TABLE IF NOT EXISTS org_members (
org TEXT NOT NULL, email TEXT NOT NULL, role TEXT NOT NULL, PRIMARY KEY (org, email))`,
`CREATE TABLE IF NOT EXISTS invites (
token TEXT PRIMARY KEY, org TEXT NOT NULL, creator TEXT NOT NULL DEFAULT '',
created TEXT NOT NULL DEFAULT '', expires TEXT NOT NULL DEFAULT '', uses INTEGER NOT NULL DEFAULT 0)`,
`CREATE TABLE IF NOT EXISTS shares (
token TEXT PRIMARY KEY, project TEXT NOT NULL, path TEXT NOT NULL,
creator TEXT NOT NULL DEFAULT '', created TEXT NOT NULL DEFAULT '', expires TEXT NOT NULL DEFAULT '')`,
`CREATE TABLE IF NOT EXISTS devices (
id TEXT PRIMARY KEY, name TEXT NOT NULL DEFAULT '', os TEXT NOT NULL DEFAULT '',
user_email TEXT NOT NULL DEFAULT '', ip TEXT NOT NULL DEFAULT '', last_seen TEXT NOT NULL DEFAULT '')`,
}
for _, st := range stmts {
if _, err := s.db.Exec(st); err != nil {
return fmt.Errorf("migrate: %w", err)
}
}
return nil
}
// ---- accounts ----
type sqlAccountRepo struct{ s *sqlMetaStore }
func (r *sqlAccountRepo) Load() ([]*authUser, []authToken, *authPolicy, error) {
var users []*authUser
rows, err := r.s.db.Query(`SELECT id, email, name, pass, status, created FROM accounts`)
if err != nil {
return nil, nil, nil, err
}
for rows.Next() {
var u authUser
var created string
if err := rows.Scan(&u.ID, &u.Email, &u.Name, &u.Pass, &u.Status, &created); err != nil {
rows.Close()
return nil, nil, nil, err
}
u.Created = tdec(created)
users = append(users, &u)
}
rows.Close()
if err := rows.Err(); err != nil {
return nil, nil, nil, err
}
var tokens []authToken
rows, err = r.s.db.Query(`SELECT hash, user_id, device, created FROM tokens`)
if err != nil {
return nil, nil, nil, err
}
for rows.Next() {
var t authToken
var created string
if err := rows.Scan(&t.Hash, &t.User, &t.Device, &created); err != nil {
rows.Close()
return nil, nil, nil, err
}
t.Created = tdec(created)
tokens = append(tokens, t)
}
rows.Close()
if err := rows.Err(); err != nil {
return nil, nil, nil, err
}
var policy *authPolicy
var rv, ra int
err = r.s.db.QueryRow(`SELECT require_verification, require_approval FROM auth_policy WHERE id = 1`).Scan(&rv, &ra)
if err == nil {
policy = &authPolicy{RequireVerification: rv != 0, RequireApproval: ra != 0}
} else if err != sql.ErrNoRows {
return nil, nil, nil, err
}
return users, tokens, policy, nil
}
func (r *sqlAccountRepo) PutAccount(u *authUser) error {
return r.s.exec(`INSERT INTO accounts (id,email,name,pass,status,created) VALUES (?,?,?,?,?,?)
ON CONFLICT(id) DO UPDATE SET email=excluded.email, name=excluded.name, pass=excluded.pass,
status=excluded.status, created=excluded.created`,
u.ID, u.Email, u.Name, u.Pass, u.Status, tenc(u.Created))
}
func (r *sqlAccountRepo) DeleteAccount(id string) error {
return r.s.exec(`DELETE FROM accounts WHERE id = ?`, id)
}
func (r *sqlAccountRepo) PutToken(t authToken) error {
return r.s.exec(`INSERT INTO tokens (hash,user_id,device,created) VALUES (?,?,?,?)
ON CONFLICT(hash) DO UPDATE SET user_id=excluded.user_id, device=excluded.device, created=excluded.created`,
t.Hash, t.User, t.Device, tenc(t.Created))
}
func (r *sqlAccountRepo) DeleteToken(hash string) error {
return r.s.exec(`DELETE FROM tokens WHERE hash = ?`, hash)
}
func (r *sqlAccountRepo) PutPolicy(p authPolicy) error {
return r.s.exec(`INSERT INTO auth_policy (id,require_verification,require_approval) VALUES (1,?,?)
ON CONFLICT(id) DO UPDATE SET require_verification=excluded.require_verification,
require_approval=excluded.require_approval`,
b2i(p.RequireVerification), b2i(p.RequireApproval))
}
// ---- projects ----
type sqlProjectRepo struct{ s *sqlMetaStore }
func (r *sqlProjectRepo) Load() ([]Project, error) {
rows, err := r.s.db.Query(`SELECT id, name, org, created FROM projects`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Project
for rows.Next() {
var p Project
var created string
if err := rows.Scan(&p.ID, &p.Name, &p.Org, &created); err != nil {
return nil, err
}
p.Created = tdec(created)
out = append(out, p)
}
return out, rows.Err()
}
func (r *sqlProjectRepo) Put(p Project) error {
return r.s.exec(`INSERT INTO projects (id,name,org,created) VALUES (?,?,?,?)
ON CONFLICT(id) DO UPDATE SET name=excluded.name, org=excluded.org, created=excluded.created`,
p.ID, p.Name, p.Org, tenc(p.Created))
}
func (r *sqlProjectRepo) Delete(id string) error {
return r.s.exec(`DELETE FROM projects WHERE id = ?`, id)
}
// ---- orgs (+ members, + invites) ----
type sqlOrgRepo struct{ s *sqlMetaStore }
func (r *sqlOrgRepo) Load() ([]Org, []OrgInvite, error) {
orgs := map[string]*Org{}
var order []string
rows, err := r.s.db.Query(`SELECT id, name, created FROM orgs`)
if err != nil {
return nil, nil, err
}
for rows.Next() {
var o Org
var created string
if err := rows.Scan(&o.ID, &o.Name, &created); err != nil {
rows.Close()
return nil, nil, err
}
o.Created = tdec(created)
o.Members = map[string]string{}
orgs[o.ID] = &o
order = append(order, o.ID)
}
rows.Close()
if err := rows.Err(); err != nil {
return nil, nil, err
}
rows, err = r.s.db.Query(`SELECT org, email, role FROM org_members`)
if err != nil {
return nil, nil, err
}
for rows.Next() {
var org, email, role string
if err := rows.Scan(&org, &email, &role); err != nil {
rows.Close()
return nil, nil, err
}
if o := orgs[org]; o != nil {
o.Members[email] = role
}
}
rows.Close()
if err := rows.Err(); err != nil {
return nil, nil, err
}
outOrgs := make([]Org, 0, len(order))
for _, id := range order {
outOrgs = append(outOrgs, *orgs[id])
}
var invites []OrgInvite
rows, err = r.s.db.Query(`SELECT token, org, creator, created, expires, uses FROM invites`)
if err != nil {
return nil, nil, err
}
for rows.Next() {
var i OrgInvite
var created, expires string
if err := rows.Scan(&i.Token, &i.Org, &i.Creator, &created, &expires, &i.Uses); err != nil {
rows.Close()
return nil, nil, err
}
i.Created, i.Expires = tdec(created), tdec(expires)
invites = append(invites, i)
}
rows.Close()
return outOrgs, invites, rows.Err()
}
func (r *sqlOrgRepo) PutOrg(o Org) error {
tx, err := r.s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.Exec(r.s.q(`INSERT INTO orgs (id,name,created) VALUES (?,?,?)
ON CONFLICT(id) DO UPDATE SET name=excluded.name, created=excluded.created`),
o.ID, o.Name, tenc(o.Created)); err != nil {
return err
}
if _, err := tx.Exec(r.s.q(`DELETE FROM org_members WHERE org = ?`), o.ID); err != nil {
return err
}
for email, role := range o.Members {
if _, err := tx.Exec(r.s.q(`INSERT INTO org_members (org,email,role) VALUES (?,?,?)`),
o.ID, email, role); err != nil {
return err
}
}
return tx.Commit()
}
func (r *sqlOrgRepo) DeleteOrg(id string) error {
tx, err := r.s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.Exec(r.s.q(`DELETE FROM org_members WHERE org = ?`), id); err != nil {
return err
}
if _, err := tx.Exec(r.s.q(`DELETE FROM orgs WHERE id = ?`), id); err != nil {
return err
}
return tx.Commit()
}
func (r *sqlOrgRepo) PutInvite(i OrgInvite) error {
return r.s.exec(`INSERT INTO invites (token,org,creator,created,expires,uses) VALUES (?,?,?,?,?,?)
ON CONFLICT(token) DO UPDATE SET org=excluded.org, creator=excluded.creator,
created=excluded.created, expires=excluded.expires, uses=excluded.uses`,
i.Token, i.Org, i.Creator, tenc(i.Created), tenc(i.Expires), i.Uses)
}
func (r *sqlOrgRepo) DeleteInvite(token string) error {
return r.s.exec(`DELETE FROM invites WHERE token = ?`, token)
}
// ---- shares ----
type sqlShareRepo struct{ s *sqlMetaStore }
func (r *sqlShareRepo) Load() ([]Share, error) {
rows, err := r.s.db.Query(`SELECT token, project, path, creator, created, expires FROM shares`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Share
for rows.Next() {
var s Share
var created, expires string
if err := rows.Scan(&s.Token, &s.Project, &s.Path, &s.Creator, &created, &expires); err != nil {
return nil, err
}
s.Created, s.Expires = tdec(created), tdec(expires)
out = append(out, s)
}
return out, rows.Err()
}
func (r *sqlShareRepo) Put(s Share) error {
return r.s.exec(`INSERT INTO shares (token,project,path,creator,created,expires) VALUES (?,?,?,?,?,?)
ON CONFLICT(token) DO UPDATE SET project=excluded.project, path=excluded.path,
creator=excluded.creator, created=excluded.created, expires=excluded.expires`,
s.Token, s.Project, s.Path, s.Creator, tenc(s.Created), tenc(s.Expires))
}
func (r *sqlShareRepo) Delete(token string) error {
return r.s.exec(`DELETE FROM shares WHERE token = ?`, token)
}
// ---- devices ----
type sqlDeviceRepo struct{ s *sqlMetaStore }
func (r *sqlDeviceRepo) Load() ([]DeviceInfo, error) {
rows, err := r.s.db.Query(`SELECT id, name, os, user_email, ip, last_seen FROM devices`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []DeviceInfo
for rows.Next() {
var d DeviceInfo
var lastSeen string
if err := rows.Scan(&d.ID, &d.Name, &d.OS, &d.User, &d.IP, &lastSeen); err != nil {
return nil, err
}
d.LastSeen = tdec(lastSeen)
out = append(out, d)
}
return out, rows.Err()
}
func (r *sqlDeviceRepo) Put(d DeviceInfo) error {
return r.s.exec(`INSERT INTO devices (id,name,os,user_email,ip,last_seen) VALUES (?,?,?,?,?,?)
ON CONFLICT(id) DO UPDATE SET name=excluded.name, os=excluded.os, user_email=excluded.user_email,
ip=excluded.ip, last_seen=excluded.last_seen`,
d.ID, d.Name, d.OS, d.User, d.IP, tenc(d.LastSeen))
}