├── .gitignore ├── LICENSE.md ├── README.md ├── dbutil.go ├── go.mod ├── go.sum ├── handlers.go ├── ids.go ├── main.go ├── migrations.go ├── migrations ├── 001_history_triggers.down.sql ├── 001_history_triggers.up.sql ├── 002_todo_list.down.sql └── 002_todo_list.up.sql ├── setup-db.sh ├── todos.go └── todos_history.go /.gitignore: -------------------------------------------------------------------------------- 1 | /tmp/ 2 | /time-travelling-todo-lists-in-postgres 3 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # Creative Commons CC0 1.0 Universal 2 | 3 | CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED HEREUNDER. 4 | 5 | ### Statement of Purpose 6 | 7 | The laws of most jurisdictions throughout the world automatically confer exclusive Copyright and Related Rights (defined below) upon the creator and subsequent owner(s) (each and all, an "owner") of an original work of authorship and/or a database (each, a "Work"). 8 | 9 | Certain owners wish to permanently relinquish those rights to a Work for the purpose of contributing to a commons of creative, cultural and scientific works ("Commons") that the public can reliably and without fear of later claims of infringement build upon, modify, incorporate in other works, reuse and redistribute as freely as possible in any form whatsoever and for any purposes, including without limitation commercial purposes. These owners may contribute to the Commons to promote the ideal of a free culture and the further production of creative, cultural and scientific works, or to gain reputation or greater distribution for their Work in part through the use and efforts of others. 10 | 11 | For these and/or other purposes and motivations, and without any expectation of additional consideration or compensation, the person associating CC0 with a Work (the "Affirmer"), to the extent that he or she is an owner of Copyright and Related Rights in the Work, voluntarily elects to apply CC0 to the Work and publicly distribute the Work under its terms, with knowledge of his or her Copyright and Related Rights in the Work and the meaning and intended legal effect of CC0 on those rights. 12 | 13 | 1. __Copyright and Related Rights.__ A Work made available under CC0 may be protected by copyright and related or neighboring rights ("Copyright and Related Rights"). Copyright and Related Rights include, but are not limited to, the following: 14 | 15 | i. the right to reproduce, adapt, distribute, perform, display, communicate, and translate a Work; 16 | 17 | ii. moral rights retained by the original author(s) and/or performer(s); 18 | 19 | iii. publicity and privacy rights pertaining to a person's image or likeness depicted in a Work; 20 | 21 | iv. rights protecting against unfair competition in regards to a Work, subject to the limitations in paragraph 4(a), below; 22 | 23 | v. rights protecting the extraction, dissemination, use and reuse of data in a Work; 24 | 25 | vi. database rights (such as those arising under Directive 96/9/EC of the European Parliament and of the Council of 11 March 1996 on the legal protection of databases, and under any national implementation thereof, including any amended or successor version of such directive); and 26 | 27 | vii. other similar, equivalent or corresponding rights throughout the world based on applicable law or treaty, and any national implementations thereof. 28 | 29 | 2. __Waiver.__ To the greatest extent permitted by, but not in contravention of, applicable law, Affirmer hereby overtly, fully, permanently, irrevocably and unconditionally waives, abandons, and surrenders all of Affirmer's Copyright and Related Rights and associated claims and causes of action, whether now known or unknown (including existing as well as future claims and causes of action), in the Work (i) in all territories worldwide, (ii) for the maximum duration provided by applicable law or treaty (including future time extensions), (iii) in any current or future medium and for any number of copies, and (iv) for any purpose whatsoever, including without limitation commercial, advertising or promotional purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each member of the public at large and to the detriment of Affirmer's heirs and successors, fully intending that such Waiver shall not be subject to revocation, rescission, cancellation, termination, or any other legal or equitable action to disrupt the quiet enjoyment of the Work by the public as contemplated by Affirmer's express Statement of Purpose. 30 | 31 | 3. __Public License Fallback.__ Should any part of the Waiver for any reason be judged legally invalid or ineffective under applicable law, then the Waiver shall be preserved to the maximum extent permitted taking into account Affirmer's express Statement of Purpose. In addition, to the extent the Waiver is so judged Affirmer hereby grants to each affected person a royalty-free, non transferable, non sublicensable, non exclusive, irrevocable and unconditional license to exercise Affirmer's Copyright and Related Rights in the Work (i) in all territories worldwide, (ii) for the maximum duration provided by applicable law or treaty (including future time extensions), (iii) in any current or future medium and for any number of copies, and (iv) for any purpose whatsoever, including without limitation commercial, advertising or promotional purposes (the "License"). The License shall be deemed effective as of the date CC0 was applied by Affirmer to the Work. Should any part of the License for any reason be judged legally invalid or ineffective under applicable law, such partial invalidity or ineffectiveness shall not invalidate the remainder of the License, and in such case Affirmer hereby affirms that he or she will not (i) exercise any of his or her remaining Copyright and Related Rights in the Work or (ii) assert any associated claims and causes of action with respect to the Work, in either case contrary to Affirmer's express Statement of Purpose. 32 | 33 | 4. __Limitations and Disclaimers.__ 34 | 35 | a. No trademark or patent rights held by Affirmer are waived, abandoned, surrendered, licensed or otherwise affected by this document. 36 | 37 | b. Affirmer offers the Work as-is and makes no representations or warranties of any kind concerning the Work, express, implied, statutory or otherwise, including without limitation warranties of title, merchantability, fitness for a particular purpose, non infringement, or the absence of latent or other defects, accuracy, or the present or absence of errors, whether or not discoverable, all to the greatest extent permissible under applicable law. 38 | 39 | c. Affirmer disclaims responsibility for clearing rights of other persons that may apply to the Work or any use thereof, including without limitation any person's Copyright and Related Rights in the Work. Further, Affirmer disclaims responsibility for obtaining any necessary consents, permissions or other rights required for any use of the Work. 40 | 41 | d. Affirmer understands and acknowledges that Creative Commons is not a party to this document and has no duty or obligation with respect to this CC0 or use of the Work. 42 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Time Travelling Todo Lists in Postgres 2 | 3 | An implementation of system-versioned tables in Postgres using only triggers, 4 | along with a TODO list app with time travelling magic on top. 5 | 6 | ## How to Run 7 | 8 | This implementation uses Docker for spinning up a new Postgres database, and Go 9 | for the app itself. Here's a oneliner to (re)create the database and to start 10 | the app: 11 | 12 | 13 | ```shell 14 | $ ./setup-db.sh && go build && ./time-travelling-todo-lists-in-postgres 15 | ``` 16 | 17 | If you want to hack on the project, I recommend using 18 | [air](https://github.com/cosmtrek/air) to get fast feedback: 19 | 20 | ```shell 21 | $ ./setup-db.sh 22 | $ air 23 | ``` 24 | 25 | If you want to inspect the contents of the database itself, you can access it 26 | like so: 27 | 28 | ```sh 29 | $ PGPASSWORD=mySecretPassword psql -h localhost -p 10840 -U postgres postgres 30 | ``` 31 | 32 | ## Why System-Versioned/Temporal Tables 33 | 34 | I made the blog post ["Implementing System-Versioned Tables in 35 | Postgres"](https://hypirion.com/musings/implementing-system-versioned-tables-in-postgres). 36 | It also goes further into the details on how the. 37 | 38 | ## How to use it yourself 39 | 40 | I recommend reading the blog post to grok how the triggers work under the 41 | covers. Here I'll explain how to use it and the things you need to be aware of 42 | if you want to use this technique. 43 | 44 | First off, make a migration for the history triggers just like in 45 | `migrations/001_history_triggers.up.sql`. Then, take the tables you want 46 | system-versioned and make a copy named `xxx_history`. The history tables must 47 | follow this shape: 48 | 49 | ```sql 50 | CREATE TABLE mytable_history ( 51 | -- copy these columns, always keep them at the top 52 | history_id UUID PRIMARY KEY, 53 | systime TSTZRANGE NOT NULL CHECK (NOT ISEMPTY(systime)), 54 | 55 | -- table columns, in the exact same order as in mytable 56 | mytable_id UUID NOT NULL, 57 | more_columns TEXT NOT NULL 58 | ); 59 | ``` 60 | 61 | Be sure that the order of your columns are in the same order as in the original 62 | table, otherwise will end up with broken triggers that either break CUD 63 | operations on the original table, or even worse, silently insert corrupted data 64 | into the history table. 65 | 66 | If you are unsure of the ordering, you can use `psql` and issue the command `\d 67 | mytable` to see which order they are stored in. 68 | 69 | Future changes must always happen in pairs. For example: 70 | 71 | ```sql 72 | ALTER TABLE mytable 73 | ADD COLUMN more_columns TEXT NOT NULL DEFAULT 'default-value'; 74 | 75 | ALTER TABLE mytable_history 76 | ADD COLUMN more_columns TEXT NOT NULL DEFAULT 'default-value'; 77 | ``` 78 | 79 | The history tables won't be able to have any reasonable foreign keys, though as 80 | long as they contain the exact same shape as the snapshot table, that's not a 81 | problem. However, if you manipulate the history tables yourself, you may end up 82 | with dangling references (i.e: don't do that..). 83 | 84 | It is possible to include foreign keys in the history table to ensure you adhere 85 | to e.g. GDPR. I may show how to do that at some point in the future. 86 | 87 | --- 88 | 89 | Next you have to add the primary key and the triggers. The primary key is a GiST 90 | index, where the original primary key is compared with `=` (add them in sequence 91 | if the key is compound), and the system time is at the end, compared with `&&`. 92 | 93 | ```sql 94 | ALTER TABLE mytable_history 95 | ADD CONSTRAINT mytable_history_overlapping_excl 96 | EXCLUDE USING GIST (mytable_id WITH =, systime WITH &&); 97 | ``` 98 | 99 | Note that the GiST index will ensure that there's only one row matching the 100 | original primary key at any given time instant. **This means that concurrent 101 | transactions changing the same row is likely to cause one of them to fail.** If 102 | this is an issue for your use case, you have three options: 103 | 104 | 1. Don't use system-versioned tables, but rather an event table or something 105 | similar 106 | 2. Keep the GiST index, but modify/remove it as a constraint 107 | 3. Apply retry logic for transactions prone to the issue 108 | 109 | In my eyes, I'd use system-versioned tables for things users trigger, or things 110 | that doesn't change so fast that the GiST index causes a problem in practice. 111 | 112 | 113 | Creating the triggers is done as such: 114 | 115 | ```sql 116 | CREATE TRIGGER mytable_history_insert_delete_trigger 117 | AFTER INSERT OR DELETE ON mytable 118 | FOR EACH ROW 119 | EXECUTE PROCEDURE copy_inserts_and_deletes_into_history('mytable_history', 'mytable_id'); 120 | 121 | CREATE TRIGGER mytable_history_update_trigger 122 | AFTER UPDATE ON mytable 123 | FOR EACH ROW 124 | WHEN (OLD.* IS DISTINCT FROM NEW.*) -- to avoid updates on "noop calls" 125 | EXECUTE PROCEDURE copy_updates_into_history('mytable_history', 'mytable_id'); 126 | ``` 127 | 128 | If you want to, you can remove the `WHEN (OLD.* IS DISTINCT FROM NEW.*)` call, 129 | though it likely doesn't make much sense to do so. 130 | 131 | 132 | ## History Queries 133 | 134 | There are a couple of history queries over at `todos_history.go`, and they are 135 | for the most part straightforward. If you want rows that were valid at some 136 | point in time, use: 137 | 138 | ```sql 139 | mytable_history.systime @> CAST(:as_of AS timestamptz) 140 | ``` 141 | 142 | If you want to join over multiple history tables, be sure to include the systime 143 | clause for all of them, e.g.: 144 | 145 | ```sql 146 | SELECT m1.col1, m2.col2 147 | FROM mytable1_history m1 148 | JOIN mytable2_history m2 ON m1.some_id = m2.some_id 149 | WHERE m1.systime @> CAST(:as_of AS timestamptz) 150 | AND m2.systime @> CAST(:as_of AS timestamptz) 151 | AND m1.some_other_id = :myid; 152 | ``` 153 | 154 | If you don't, you'll probably get more rows than you wanted. 155 | 156 | It's generally a bad idea to join history tables with non-history tables. The 157 | exception is if the table is an append-only table or an event log of some kind. 158 | 159 | ## License 160 | 161 | I've waived my ownership to this by applying a CC0 license to this repo. Do 162 | whatever you want with the code that resides here, although I don't mind a 163 | referral back to this repository or to my original blog post. 164 | -------------------------------------------------------------------------------- /dbutil.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "fmt" 7 | "strings" 8 | 9 | "github.com/jmoiron/sqlx" 10 | "github.com/sirupsen/logrus" 11 | ) 12 | 13 | // this file contains helper functions to make the SQL queries easier to read. 14 | 15 | type Tx struct { 16 | tx *sqlx.Tx 17 | } 18 | 19 | type TableColumns []string 20 | 21 | func (tb TableColumns) Concat(other TableColumns) TableColumns { 22 | ret := make(TableColumns, len(tb)+len(other)) 23 | copy(ret, tb) 24 | copy(ret[len(tb):], other) 25 | return ret 26 | } 27 | 28 | func (tb TableColumns) OnAlias(alias string) TableColumns { 29 | ret := make(TableColumns, len(tb)) 30 | for i, val := range tb { 31 | ret[i] = alias + "." + val 32 | } 33 | return ret 34 | } 35 | 36 | func (tb TableColumns) String() string { 37 | return strings.Join(tb, ", ") 38 | } 39 | 40 | type QueryArgs map[string]any 41 | 42 | func (tx *Tx) Exec(query string, args QueryArgs) error { 43 | translatedQuery, sliceArgs, err := tx.tx.BindNamed(query, args) 44 | if err != nil { 45 | return err 46 | } 47 | _, err = tx.tx.Exec(translatedQuery, sliceArgs...) 48 | return err 49 | } 50 | 51 | func (tx *Tx) Get(dest any, query string, args QueryArgs) error { 52 | translatedQuery, sliceArgs, err := tx.tx.BindNamed(query, args) 53 | if err != nil { 54 | return err 55 | } 56 | return tx.tx.Get(dest, translatedQuery, sliceArgs...) 57 | } 58 | 59 | func (tx *Tx) Select(dest any, query string, args QueryArgs) error { 60 | translatedQuery, sliceArgs, err := tx.tx.BindNamed(query, args) 61 | if err != nil { 62 | return err 63 | } 64 | return tx.tx.Select(dest, translatedQuery, sliceArgs...) 65 | } 66 | 67 | func (tx *Tx) DeleteOne(query string, args QueryArgs) error { 68 | return tx.UpdateOne(query, args) 69 | } 70 | 71 | func (tx *Tx) UpdateOne(query string, args QueryArgs) error { 72 | translatedQuery, sliceArgs, err := tx.tx.BindNamed(query, args) 73 | if err != nil { 74 | return err 75 | } 76 | 77 | res, err := tx.tx.Exec(translatedQuery, sliceArgs...) 78 | if err != nil { 79 | return err 80 | } 81 | 82 | affected, err := res.RowsAffected() 83 | if err != nil { 84 | return err 85 | } 86 | 87 | // to allow to match on ErrNoRows. 88 | if affected == 0 { 89 | return fmt.Errorf("query affected 0 rows, should affect one: %w", sql.ErrNoRows) 90 | } 91 | 92 | if affected != 1 { 93 | return fmt.Errorf("query affected %d rows, but should only affect one", affected) 94 | } 95 | 96 | return nil 97 | } 98 | 99 | func RunInTx(ctx context.Context, db *sqlx.DB, f func(tx *Tx) error) error { 100 | tx, err := db.BeginTxx(ctx, nil) 101 | if err != nil { 102 | return err 103 | } 104 | didPanic := true 105 | defer func() { 106 | if didPanic { 107 | err := tx.Rollback() 108 | if err != nil { 109 | logrus.WithError(err).Info("failed to rollback transaction during panic") 110 | } 111 | } 112 | }() 113 | err = f(&Tx{tx: tx}) 114 | 115 | didPanic = false 116 | if err != nil { 117 | err2 := tx.Rollback() 118 | if err2 != nil { 119 | logrus.WithError(err).Info("failed to rollback transaction after error") 120 | } 121 | 122 | return err 123 | } 124 | 125 | err = tx.Commit() 126 | return err 127 | } 128 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/hypirion/time-travelling-todo-lists-in-postgres 2 | 3 | go 1.21.0 4 | 5 | require ( 6 | github.com/golang-migrate/migrate v3.5.4+incompatible 7 | github.com/golang-migrate/migrate/v4 v4.17.0 8 | github.com/lib/pq v1.10.9 9 | ) 10 | 11 | require ( 12 | github.com/bytedance/sonic v1.10.2 // indirect 13 | github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d // indirect 14 | github.com/chenzhuoyu/iasm v0.9.1 // indirect 15 | github.com/gabriel-vasile/mimetype v1.4.3 // indirect 16 | github.com/gin-contrib/sse v0.1.0 // indirect 17 | github.com/gin-gonic/gin v1.9.1 // indirect 18 | github.com/go-playground/locales v0.14.1 // indirect 19 | github.com/go-playground/universal-translator v0.18.1 // indirect 20 | github.com/go-playground/validator/v10 v10.17.0 // indirect 21 | github.com/gobuffalo/here v0.6.7 // indirect 22 | github.com/goccy/go-json v0.10.2 // indirect 23 | github.com/golang/protobuf v1.5.3 // indirect 24 | github.com/google/go-github/v39 v39.2.0 // indirect 25 | github.com/google/go-querystring v1.1.0 // indirect 26 | github.com/google/uuid v1.5.0 // indirect 27 | github.com/hashicorp/errwrap v1.1.0 // indirect 28 | github.com/hashicorp/go-multierror v1.1.1 // indirect 29 | github.com/jmoiron/sqlx v1.3.5 // indirect 30 | github.com/json-iterator/go v1.1.12 // indirect 31 | github.com/jxskiss/base62 v1.1.0 // indirect 32 | github.com/klauspost/cpuid/v2 v2.2.6 // indirect 33 | github.com/leodido/go-urn v1.2.4 // indirect 34 | github.com/maragudk/gomponents v0.20.1 // indirect 35 | github.com/markbates/pkger v0.17.1 // indirect 36 | github.com/mattn/go-isatty v0.0.20 // indirect 37 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 38 | github.com/modern-go/reflect2 v1.0.2 // indirect 39 | github.com/pelletier/go-toml/v2 v2.1.1 // indirect 40 | github.com/sirupsen/logrus v1.9.3 // indirect 41 | github.com/twitchyliquid64/golang-asm v0.15.1 // indirect 42 | github.com/ugorji/go/codec v1.2.12 // indirect 43 | go.uber.org/atomic v1.11.0 // indirect 44 | golang.org/x/arch v0.7.0 // indirect 45 | golang.org/x/crypto v0.18.0 // indirect 46 | golang.org/x/net v0.20.0 // indirect 47 | golang.org/x/oauth2 v0.16.0 // indirect 48 | golang.org/x/sys v0.16.0 // indirect 49 | golang.org/x/text v0.14.0 // indirect 50 | google.golang.org/appengine v1.6.8 // indirect 51 | google.golang.org/protobuf v1.32.0 // indirect 52 | gopkg.in/yaml.v3 v3.0.1 // indirect 53 | ) 54 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= 2 | github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= 3 | github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow= 4 | github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM= 5 | github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM= 6 | github.com/bytedance/sonic v1.10.0-rc/go.mod h1:ElCzW+ufi8qKqNW0FY314xriJhyJhuoJ3gFZdAHF7NM= 7 | github.com/bytedance/sonic v1.10.2 h1:GQebETVBxYB7JGWJtLBi07OVzWwt+8dWA00gEVW2ZFE= 8 | github.com/bytedance/sonic v1.10.2/go.mod h1:iZcSUejdk5aukTND/Eu/ivjQuEL0Cu9/rf50Hi0u/g4= 9 | github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY= 10 | github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk= 11 | github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d h1:77cEq6EriyTZ0g/qfRdp61a3Uu/AWrgIq2s0ClJV1g0= 12 | github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d/go.mod h1:8EPpVsBuRksnlj1mLy4AWzRNQYxauNi62uWcE3to6eA= 13 | github.com/chenzhuoyu/iasm v0.9.0/go.mod h1:Xjy2NpN3h7aUqeqM+woSuuvxmIe6+DDsiNLIrkAmYog= 14 | github.com/chenzhuoyu/iasm v0.9.1 h1:tUHQJXo3NhBqw6s33wkGn9SP3bvrWLdlVIJ3hQBL7P0= 15 | github.com/chenzhuoyu/iasm v0.9.1/go.mod h1:Xjy2NpN3h7aUqeqM+woSuuvxmIe6+DDsiNLIrkAmYog= 16 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 17 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 18 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 19 | github.com/dhui/dktest v0.4.0 h1:z05UmuXZHO/bgj/ds2bGMBu8FI4WA+Ag/m3ghL+om7M= 20 | github.com/dhui/dktest v0.4.0/go.mod h1:v/Dbz1LgCBOi2Uki2nUqLBGa83hWBGFMu5MrgMDCc78= 21 | github.com/docker/distribution v2.8.2+incompatible h1:T3de5rq0dB1j30rp0sA2rER+m322EBzniBPB6ZIzuh8= 22 | github.com/docker/distribution v2.8.2+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= 23 | github.com/docker/docker v24.0.7+incompatible h1:Wo6l37AuwP3JaMnZa226lzVXGA3F9Ig1seQen0cKYlM= 24 | github.com/docker/docker v24.0.7+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= 25 | github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKohAFqRJQ= 26 | github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec= 27 | github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= 28 | github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= 29 | github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= 30 | github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= 31 | github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= 32 | github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= 33 | github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg= 34 | github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU= 35 | github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= 36 | github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= 37 | github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= 38 | github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= 39 | github.com/go-playground/validator/v10 v10.17.0 h1:SmVVlfAOtlZncTxRuinDPomC2DkXJ4E5T9gDA0AIH74= 40 | github.com/go-playground/validator/v10 v10.17.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU= 41 | github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= 42 | github.com/gobuffalo/here v0.6.0/go.mod h1:wAG085dHOYqUpf+Ap+WOdrPTp5IYcDAs/x7PLa8Y5fM= 43 | github.com/gobuffalo/here v0.6.7 h1:hpfhh+kt2y9JLDfhYUxxCRxQol540jsVfKUZzjlbp8o= 44 | github.com/gobuffalo/here v0.6.7/go.mod h1:vuCfanjqckTuRlqAitJz6QC4ABNnS27wLb816UhsPcc= 45 | github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= 46 | github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= 47 | github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= 48 | github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= 49 | github.com/golang-migrate/migrate v3.5.4+incompatible h1:R7OzwvCJTCgwapPCiX6DyBiu2czIUMDCB118gFTKTUA= 50 | github.com/golang-migrate/migrate v3.5.4+incompatible/go.mod h1:IsVUlFN5puWOmXrqjgGUfIRIbU7mr8oNBE2tyERd9Wk= 51 | github.com/golang-migrate/migrate/v4 v4.17.0 h1:rd40H3QXU0AA4IoLllFcEAEo9dYKRHYND2gB4p7xcaU= 52 | github.com/golang-migrate/migrate/v4 v4.17.0/go.mod h1:+Cp2mtLP4/aXDTKb9wmXYitdrNx2HGs45rbWAo6OsKM= 53 | github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 54 | github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 55 | github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= 56 | github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= 57 | github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= 58 | github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= 59 | github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 60 | github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 61 | github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 62 | github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= 63 | github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 64 | github.com/google/go-github/v39 v39.2.0 h1:rNNM311XtPOz5rDdsJXAp2o8F67X9FnROXTvto3aSnQ= 65 | github.com/google/go-github/v39 v39.2.0/go.mod h1:C1s8C5aCC9L+JXIYpJM5GYytdX52vC1bLvHEF1IhBrE= 66 | github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= 67 | github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= 68 | github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 69 | github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU= 70 | github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 71 | github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= 72 | github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= 73 | github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= 74 | github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= 75 | github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= 76 | github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g= 77 | github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ= 78 | github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= 79 | github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= 80 | github.com/jxskiss/base62 v1.1.0 h1:A5zbF8v8WXx2xixnAKD2w+abC+sIzYJX+nxmhA6HWFw= 81 | github.com/jxskiss/base62 v1.1.0/go.mod h1:HhWAlUXvxKThfOlZbcuFzsqwtF5TcqS9ru3y5GfjWAc= 82 | github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= 83 | github.com/klauspost/cpuid/v2 v2.2.6 h1:ndNyv040zDGIDh8thGkXYjnFtiN02M1PVVF+JE/48xc= 84 | github.com/klauspost/cpuid/v2 v2.2.6/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= 85 | github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= 86 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 87 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 88 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 89 | github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q= 90 | github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4= 91 | github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= 92 | github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= 93 | github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= 94 | github.com/maragudk/gomponents v0.20.1 h1:TeJY1fXEcfUvzmvjeUgxol42dvkYMggK1c0V67crWWs= 95 | github.com/maragudk/gomponents v0.20.1/go.mod h1:nHkNnZL6ODgMBeJhrZjkMHVvNdoYsfmpKB2/hjdQ0Hg= 96 | github.com/markbates/pkger v0.17.1 h1:/MKEtWqtc0mZvu9OinB9UzVN9iYCwLWuyUv4Bw+PCno= 97 | github.com/markbates/pkger v0.17.1/go.mod h1:0JoVlrol20BSywW79rN3kdFFsE5xYM+rSCQDXbLhiuI= 98 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 99 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 100 | github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= 101 | github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= 102 | github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= 103 | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 104 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= 105 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 106 | github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= 107 | github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= 108 | github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= 109 | github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= 110 | github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= 111 | github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= 112 | github.com/opencontainers/image-spec v1.0.2 h1:9yCKha/T5XdGtO0q9Q9a6T5NUCsTn/DrBg0D7ufOcFM= 113 | github.com/opencontainers/image-spec v1.0.2/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0= 114 | github.com/pelletier/go-toml/v2 v2.1.1 h1:LWAJwfNvjQZCFIDKWYQaM62NcYeYViCmWIwmOStowAI= 115 | github.com/pelletier/go-toml/v2 v2.1.1/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= 116 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 117 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 118 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 119 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 120 | github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= 121 | github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= 122 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 123 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 124 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 125 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 126 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 127 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 128 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 129 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 130 | github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 131 | github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 132 | github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY= 133 | github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 134 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 135 | github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= 136 | github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= 137 | github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= 138 | github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= 139 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 140 | go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= 141 | go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= 142 | golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= 143 | golang.org/x/arch v0.7.0 h1:pskyeJh/3AmoQ8CPE95vxHLqp1G1GfGNXTmcl9NEKTc= 144 | golang.org/x/arch v0.7.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= 145 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 146 | golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 147 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 148 | golang.org/x/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc= 149 | golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= 150 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 151 | golang.org/x/mod v0.11.0 h1:bUO06HqtnRcc/7l71XBe4WcqTZ+3AH1J59zWDDwLKgU= 152 | golang.org/x/mod v0.11.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 153 | golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= 154 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 155 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 156 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 157 | golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo= 158 | golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= 159 | golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= 160 | golang.org/x/oauth2 v0.16.0 h1:aDkGMBSYxElaoP81NpoUoz2oo2R2wHdZpGToUxfyQrQ= 161 | golang.org/x/oauth2 v0.16.0/go.mod h1:hqZ+0LWXsiVoZpeld6jVt06P3adbS2Uu911W1SsJv2o= 162 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 163 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 164 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 165 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 166 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 167 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 168 | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 169 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 170 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 171 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 172 | golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= 173 | golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 174 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 175 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 176 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 177 | golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= 178 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 179 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 180 | golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= 181 | golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= 182 | golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 183 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 184 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 185 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 186 | golang.org/x/tools v0.10.0 h1:tvDr/iQoUqNdohiYm0LmmKcBk+q86lb9EprIUFhHHGg= 187 | golang.org/x/tools v0.10.0/go.mod h1:UJwyiVBsOA2uwvK/e5OY3GTpDUJriEd+/YlqAwLPmyM= 188 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 189 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 190 | google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= 191 | google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= 192 | google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= 193 | google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= 194 | google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= 195 | google.golang.org/protobuf v1.32.0 h1:pPC6BG5ex8PDFnkbrGU3EixyhKcQ2aDuBS36lqK/C7I= 196 | google.golang.org/protobuf v1.32.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= 197 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 198 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 199 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 200 | gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 201 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 202 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 203 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 204 | nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= 205 | rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= 206 | -------------------------------------------------------------------------------- /handlers.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "database/sql" 5 | "errors" 6 | "fmt" 7 | "net/http" 8 | "strconv" 9 | "strings" 10 | "time" 11 | 12 | g "github.com/maragudk/gomponents" 13 | c "github.com/maragudk/gomponents/components" 14 | . "github.com/maragudk/gomponents/html" 15 | ) 16 | 17 | func indexHandler(ctx *Context) (g.Node, error) { 18 | tls, err := GetAllTodoLists(ctx.Tx) 19 | if err != nil { 20 | return nil, err 21 | } 22 | 23 | return pageNode("Todo Lists", 24 | []g.Node{ 25 | H1(g.Text("Your Todo Lists")), 26 | todoListTable(tls), 27 | newTodoListForm(), 28 | }, 29 | ), nil 30 | } 31 | 32 | func newTodoListForm() g.Node { 33 | return FormEl(Method("post"), Action("/todo-lists"), 34 | H3(g.Text("Make a new list")), 35 | Label(For("name"), g.Text("Name of new list:")), 36 | Input(Type("text"), Name("name"), Required()), 37 | Button(g.Text("Create"))) 38 | } 39 | 40 | func todoListTable(tls []TodoListBase) g.Node { 41 | return table( 42 | []string{"Name", "Created", "Last Updated", ""}, 43 | g.Map(tls, todoListRow), 44 | ) 45 | } 46 | 47 | func todoListRow(tl TodoListBase) g.Node { 48 | return Tr( 49 | Td(A(Href(tl.ID.Href()), g.Text(tl.Name))), 50 | Td(g.Text(fmtTime(tl.CreatedAt))), 51 | Td(g.Text(fmtTime(tl.UpdatedAt))), 52 | Td(postButton(tl.ID.HrefTo("delete"), "delete")), 53 | ) 54 | } 55 | 56 | func postTodoListHandler(ctx *Context) error { 57 | name, ok := ctx.GetPostForm("name") 58 | name = strings.TrimSpace(name) 59 | if !ok || name == "" { 60 | return errors.New("must have a nonempty name") 61 | } 62 | 63 | tl, err := NewTodoList(ctx.Tx, name) 64 | if err != nil { 65 | return err 66 | } 67 | 68 | ctx.Redirect(http.StatusSeeOther, tl.ID.Href()) 69 | return nil 70 | } 71 | 72 | func getTodoListHandler(ctx *Context) (g.Node, error) { 73 | var tlid TodoListID 74 | err := tlid.Parse(ctx.Param("tlid")) 75 | if err != nil { 76 | return nil, err 77 | } 78 | 79 | tl, err := GetTodoListByID(ctx.Tx, tlid) 80 | if err != nil { 81 | return nil, err 82 | } 83 | 84 | completed := tl.Todos.FilterByCompleted(true) 85 | unfinished := tl.Todos.FilterByCompleted(false) 86 | 87 | return pageNode("Todo List - "+tl.Name, 88 | []g.Node{ 89 | H1(g.Text(tl.Name)), 90 | newTodosForm(tlid), 91 | g.If(len(unfinished) != 0, g.Group([]g.Node{ 92 | H3(g.Text("Todos")), 93 | Table(TBody(g.Map(unfinished, unfinishedTodoRow)...)), 94 | })), 95 | g.If(len(completed) != 0, g.Group([]g.Node{ 96 | H3(g.Text("Completed")), 97 | Table(TBody(g.Map(completed, completedTodoRow)...)), 98 | })), 99 | P(A(Href(tl.ID.HrefTo("revisions")), g.Text("Revisions"))), 100 | }, 101 | ), nil 102 | } 103 | 104 | func newTodosForm(tlid TodoListID) g.Node { 105 | return FormEl(Method("post"), Action(tlid.HrefTo("new-todos")), 106 | Label(For("new-todos"), g.Text("Make new todos (comma separated):")), 107 | Input(Type("text"), Name("new-todos"), Required()), 108 | Button(g.Text("Add"))) 109 | } 110 | 111 | func unfinishedTodoRow(todo Todo) g.Node { 112 | return Tr( 113 | Td(g.Text(todo.Description)), 114 | Td(postButton(todo.ID.HrefTo("complete"), "Complete"), 115 | postButton(todo.ID.HrefTo("delete"), "Delete")), 116 | ) 117 | } 118 | func completedTodoRow(todo Todo) g.Node { 119 | return Tr( 120 | Td(S(g.Text(todo.Description))), 121 | Td(postButton(todo.ID.HrefTo("reactivate"), "Reactivate"), 122 | postButton(todo.ID.HrefTo("delete"), "Delete")), 123 | ) 124 | } 125 | 126 | func deleteTodoListHandler(ctx *Context) error { 127 | var tlid TodoListID 128 | err := tlid.Parse(ctx.Param("tlid")) 129 | if err != nil { 130 | return err 131 | } 132 | 133 | err = DeleteTodoList(ctx.Tx, tlid) 134 | if err != nil { 135 | return err 136 | } 137 | ctx.Redirect(http.StatusSeeOther, "/") 138 | return nil 139 | } 140 | 141 | func newTodosHandler(ctx *Context) error { 142 | var tlid TodoListID 143 | err := tlid.Parse(ctx.Param("tlid")) 144 | if err != nil { 145 | return err 146 | } 147 | 148 | newTodosStr, ok := ctx.GetPostForm("new-todos") 149 | newTodosStr = strings.TrimSpace(newTodosStr) 150 | if !ok || newTodosStr == "" { 151 | return errors.New("must have some todos") 152 | } 153 | 154 | todos := strings.Split(newTodosStr, ",") 155 | for _, todo := range todos { 156 | todo = strings.TrimSpace(todo) 157 | if todo == "" { 158 | continue // yeah sure, may end up with no more todos this way 159 | } 160 | err = NewTodo(ctx.Tx, tlid, todo) 161 | if err != nil { 162 | return err 163 | } 164 | } 165 | 166 | ctx.Redirect(http.StatusSeeOther, tlid.Href()) 167 | 168 | return nil 169 | } 170 | 171 | func getTodoListRevisionsHandler(ctx *Context) (g.Node, error) { 172 | var tlid TodoListID 173 | err := tlid.Parse(ctx.Param("tlid")) 174 | if err != nil { 175 | return nil, err 176 | } 177 | 178 | revs, err := GetTodoListRevisions(ctx.Tx, tlid) 179 | if err != nil { 180 | return nil, err 181 | } 182 | 183 | if len(revs) == 0 { 184 | return nil, fmt.Errorf("no revisions for todo list with ID %s", tlid) 185 | } 186 | 187 | rows := make([]g.Node, len(revs)) 188 | for i, rev := range revs { 189 | revID := len(revs) - i 190 | rows[i] = todoListRevisionRow(rev, revID) 191 | } 192 | 193 | return pageNode("Todo List Revisions for "+tlid.String(), 194 | []g.Node{ 195 | H1(g.Text("Todo List Revisions for " + tlid.String())), 196 | table([]string{"Revision", "Valid from", "Valid to"}, 197 | rows), 198 | }, 199 | ), nil 200 | } 201 | 202 | func todoListRevisionRow(tlhb TodoListRevisionBase, versionID int) g.Node { 203 | sysUpper := "" 204 | if tlhb.SysUpper != nil { 205 | sysUpper = fmtTime(*tlhb.SysUpper) 206 | } 207 | 208 | return Tr( 209 | Td(A(Href(tlhb.HistoryID.Href()), g.Text("#"+strconv.Itoa(versionID)))), 210 | Td(g.Text(fmtTime(tlhb.SysLower))), 211 | Td(g.Text(sysUpper))) 212 | } 213 | 214 | func completeTodoHandler(ctx *Context) error { 215 | var tid TodoID 216 | err := tid.Parse(ctx.Param("tid")) 217 | if err != nil { 218 | return err 219 | } 220 | 221 | err = SetTodoCompleted(ctx.Tx, tid, true) 222 | if err != nil { 223 | return err 224 | } 225 | 226 | todo, err := GetTodoByID(ctx.Tx, tid) 227 | if err != nil { 228 | return err 229 | } 230 | 231 | ctx.Redirect(http.StatusSeeOther, todo.ListID.Href()) 232 | return nil 233 | } 234 | 235 | func reactivateTodoHandler(ctx *Context) error { 236 | var tid TodoID 237 | err := tid.Parse(ctx.Param("tid")) 238 | if err != nil { 239 | return err 240 | } 241 | 242 | err = SetTodoCompleted(ctx.Tx, tid, false) 243 | if err != nil { 244 | return err 245 | } 246 | 247 | todo, err := GetTodoByID(ctx.Tx, tid) 248 | if err != nil { 249 | return err 250 | } 251 | 252 | ctx.Redirect(http.StatusSeeOther, todo.ListID.Href()) 253 | return nil 254 | } 255 | 256 | func deleteTodoHandler(ctx *Context) error { 257 | var tid TodoID 258 | err := tid.Parse(ctx.Param("tid")) 259 | if err != nil { 260 | return err 261 | } 262 | 263 | todo, err := GetTodoByID(ctx.Tx, tid) 264 | if err != nil { 265 | return err 266 | } 267 | 268 | err = DeleteTodo(ctx.Tx, tid) 269 | if err != nil { 270 | return err 271 | } 272 | 273 | ctx.Redirect(http.StatusSeeOther, todo.ListID.Href()) 274 | return nil 275 | } 276 | 277 | func getTodoListRevisionHandler(ctx *Context) (g.Node, error) { 278 | var tlhid TodoListHistoryID 279 | err := tlhid.Parse(ctx.Param("tlhid")) 280 | if err != nil { 281 | return nil, err 282 | } 283 | 284 | todoListRev, err := GetTodoListRevisionByID(ctx.Tx, tlhid) 285 | if err != nil { 286 | return nil, err 287 | } 288 | 289 | current, err := GetTodoListByID(ctx.Tx, todoListRev.ID) 290 | if err != nil && !errors.Is(err, sql.ErrNoRows) { 291 | return nil, err 292 | } 293 | 294 | completed := todoListRev.Todos.FilterByCompleted(true) 295 | unfinished := todoListRev.Todos.FilterByCompleted(false) 296 | 297 | revRenderer := newTodoRevisionRenderer(current) 298 | 299 | return pageNode("Revision of "+todoListRev.Name, 300 | []g.Node{ 301 | H1(g.Text(todoListRev.Name)), 302 | P(A(Href(todoListRev.ID.Href()), g.Text("[current version]")), g.Text(" "), 303 | A(Href(todoListRev.ID.HrefTo("revisions")), g.Text("[list revisions]")), g.Text(" "), 304 | g.If(!revRenderer.equalTodos(todoListRev.Todos), 305 | postButton(todoListRev.HistoryID.HrefTo("restore"), "Restore list to this revision"))), 306 | g.If(len(unfinished) != 0, g.Group([]g.Node{ 307 | H3(g.Text("Todos")), 308 | Table(TBody(g.Map(unfinished, revRenderer.unfinishedRow)...)), 309 | })), 310 | g.If(len(completed) != 0, g.Group([]g.Node{ 311 | H3(g.Text("Completed")), 312 | Table(TBody(g.Map(completed, revRenderer.completedRow)...)), 313 | })), 314 | }, 315 | ), nil 316 | } 317 | 318 | func newTodoRevisionRenderer(current *TodoList) todoRevisionRenderer { 319 | trr := todoRevisionRenderer{ 320 | canRestoreTodos: current != nil, 321 | } 322 | if current != nil { 323 | trr.todoMap = map[TodoID]Todo{} 324 | for _, todo := range current.Todos { 325 | trr.todoMap[todo.ID] = todo 326 | } 327 | } 328 | return trr 329 | } 330 | 331 | type todoRevisionRenderer struct { 332 | canRestoreTodos bool 333 | todoMap map[TodoID]Todo 334 | } 335 | 336 | func (trr todoRevisionRenderer) equalTodos(todos TodoRevisions) bool { 337 | if len(trr.todoMap) != len(todos) { 338 | return false 339 | } 340 | for _, todo := range todos { 341 | if trr.revisionIsStale(todo) { 342 | return false 343 | } 344 | } 345 | return true 346 | } 347 | 348 | func (trr todoRevisionRenderer) revisionIsStale(todo TodoRevision) bool { 349 | // Here we match on contents and the identity, so e.g. making a new todo item 350 | // with the same description and complete status will mean you can end up with 351 | // duplicates. Anything not comparing on identity/primary key is gonna make 352 | // the restore procedure a bit more complicated. 353 | if todo.SysUpper == nil { 354 | return false 355 | } 356 | curTodo, ok := trr.todoMap[todo.ID] 357 | 358 | // check for presence and whether they are identical 359 | return !ok || curTodo != todo.Todo 360 | } 361 | 362 | func (trr todoRevisionRenderer) unfinishedRow(todo TodoRevision) g.Node { 363 | return Tr( 364 | Td(g.Text(todo.Description)), 365 | Td(g.If(trr.canRestoreTodos && trr.revisionIsStale(todo), 366 | postButton(todo.HistoryID.HrefTo("restore"), "Restore Todo to this state"))), 367 | ) 368 | } 369 | func (trr todoRevisionRenderer) completedRow(todo TodoRevision) g.Node { 370 | return Tr( 371 | Td(S(g.Text(todo.Description))), 372 | Td(g.If(trr.canRestoreTodos && trr.revisionIsStale(todo), 373 | postButton(todo.HistoryID.HrefTo("restore"), "Restore Todo to this state"))), 374 | ) 375 | } 376 | 377 | func restoreTodoListRevisionHandler(ctx *Context) error { 378 | var tlhid TodoListHistoryID 379 | err := tlhid.Parse(ctx.Param("tlhid")) 380 | if err != nil { 381 | return err 382 | } 383 | 384 | listID, err := RestoreTodoListToRevision(ctx.Tx, tlhid) 385 | if err != nil { 386 | return err 387 | } 388 | 389 | ctx.Redirect(http.StatusSeeOther, listID.Href()) 390 | return nil 391 | } 392 | 393 | func restoreTodoRevisionHandler(ctx *Context) error { 394 | var thid TodoHistoryID 395 | err := thid.Parse(ctx.Param("thid")) 396 | if err != nil { 397 | return err 398 | } 399 | 400 | _, err = RestoreTodoToRevision(ctx.Tx, thid) 401 | if err != nil { 402 | return err 403 | } 404 | 405 | // remain at the same location. It's not possible to deduce that without 406 | // passing in timestamp or the todo list history id, so we just use Referer. 407 | ctx.Redirect(http.StatusSeeOther, ctx.Request.Referer()) 408 | return nil 409 | } 410 | 411 | func errorNode(err error) g.Node { 412 | return pageNode("Error", 413 | []g.Node{ 414 | H1(g.Text("an error occurred")), 415 | P(g.Text(err.Error())), 416 | }, 417 | ) 418 | } 419 | 420 | func pageNode(title string, body []g.Node) g.Node { 421 | return c.HTML5(c.HTML5Props{ 422 | Title: title, 423 | Language: "en", 424 | Head: []g.Node{ 425 | Link(Rel("stylesheet"), Href("https://cdn.jsdelivr.net/npm/sakura.css/css/sakura.css"), Type("text/css")), 426 | StyleEl(g.Text(` 427 | .inline-form { display: inline; padding-right: 1em; } 428 | `)), 429 | }, 430 | Body: []g.Node{ 431 | Nav(A(Href("/"), g.Text("Home"))), 432 | g.Group(body), 433 | }, 434 | }) 435 | } 436 | 437 | func postButton(url, text string) g.Node { 438 | return FormEl(Class("inline-form"), 439 | Method("post"), Action(url), Button(g.Text(text))) 440 | } 441 | 442 | func fmtTime(t time.Time) string { 443 | return t.Local().Format(time.DateTime) 444 | } 445 | 446 | func table(headers []string, body []g.Node) g.Node { 447 | return Table( 448 | THead(Tr(g.Map(headers, func(s string) g.Node { return Th(g.Text(s)) })...)), 449 | TBody(body...), 450 | ) 451 | } 452 | -------------------------------------------------------------------------------- /ids.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "database/sql/driver" 5 | "fmt" 6 | "strings" 7 | 8 | "github.com/google/uuid" 9 | "github.com/jxskiss/base62" 10 | ) 11 | 12 | // trick to get Stripe-like identifiers. Uses base62 because - and _ can mess up 13 | // copypasting etc. 14 | type idUtil uuid.UUID 15 | 16 | func (id idUtil) str(prefix string) string { 17 | return prefix + "_" + base62.StdEncoding.EncodeToString(id[:]) 18 | } 19 | 20 | func (id *idUtil) fromStr(prefix string, data string) error { 21 | if !strings.HasPrefix(data, prefix+"_") { 22 | return fmt.Errorf("id %s did not start with %s", data, prefix) 23 | } 24 | idStr := data[len(prefix)+1:] 25 | bs, err := base62.StdEncoding.DecodeString(idStr) 26 | if err != nil { 27 | return err 28 | } 29 | if len(bs) != 16 { 30 | return fmt.Errorf("id %s has unexpected length", data) 31 | } 32 | copy(id[:], bs) 33 | return nil 34 | } 35 | 36 | type TodoListID uuid.UUID 37 | 38 | // Value implements the sql.Valuer interface 39 | func (id TodoListID) Value() (driver.Value, error) { 40 | return uuid.UUID(id).Value() 41 | } 42 | 43 | // Scan implements the sql.Scanner interface 44 | func (id *TodoListID) Scan(value interface{}) error { 45 | return (*uuid.UUID)(id).Scan(value) 46 | } 47 | 48 | // String implements the Stringer interface. 49 | func (id TodoListID) String() string { 50 | return idUtil(id).str("tl") 51 | } 52 | 53 | func (id *TodoListID) Parse(str string) error { 54 | return (*idUtil)(id).fromStr("tl", str) 55 | } 56 | 57 | func (id TodoListID) Href() string { 58 | return fmt.Sprintf("/todo-lists/%s", id) 59 | } 60 | 61 | func (id TodoListID) HrefTo(action string) string { 62 | return fmt.Sprintf("/todo-lists/%s/%s", id, action) 63 | } 64 | 65 | type TodoListHistoryID uuid.UUID 66 | 67 | // Value implements the sql.Valuer interface 68 | func (id TodoListHistoryID) Value() (driver.Value, error) { 69 | return uuid.UUID(id).Value() 70 | } 71 | 72 | // Scan implements the sql.Scanner interface 73 | func (id *TodoListHistoryID) Scan(value interface{}) error { 74 | return (*uuid.UUID)(id).Scan(value) 75 | } 76 | 77 | // String implements the Stringer interface. 78 | func (id TodoListHistoryID) String() string { 79 | return idUtil(id).str("tl_hist") 80 | } 81 | 82 | func (id *TodoListHistoryID) Parse(str string) error { 83 | return (*idUtil)(id).fromStr("tl_hist", str) 84 | } 85 | 86 | func (id TodoListHistoryID) Href() string { 87 | return fmt.Sprintf("/todo-lists-history/%s", id) 88 | } 89 | 90 | func (id TodoListHistoryID) HrefTo(action string) string { 91 | return fmt.Sprintf("/todo-lists-history/%s/%s", id, action) 92 | } 93 | 94 | type TodoID uuid.UUID 95 | 96 | // Value implements the sql.Valuer interface 97 | func (id TodoID) Value() (driver.Value, error) { 98 | return uuid.UUID(id).Value() 99 | } 100 | 101 | // Scan implements the sql.Scanner interface 102 | func (id *TodoID) Scan(value interface{}) error { 103 | return (*uuid.UUID)(id).Scan(value) 104 | } 105 | 106 | // String implements the Stringer interface. 107 | func (id TodoID) String() string { 108 | return idUtil(id).str("todo") 109 | } 110 | 111 | func (id *TodoID) Parse(str string) error { 112 | return (*idUtil)(id).fromStr("todo", str) 113 | } 114 | 115 | func (id TodoID) Href() string { 116 | return fmt.Sprintf("/todos/%s", id) 117 | } 118 | 119 | func (id TodoID) HrefTo(action string) string { 120 | return fmt.Sprintf("/todos/%s/%s", id, action) 121 | } 122 | 123 | type TodoHistoryID uuid.UUID 124 | 125 | // Value implements the sql.Valuer interface 126 | func (id TodoHistoryID) Value() (driver.Value, error) { 127 | return uuid.UUID(id).Value() 128 | } 129 | 130 | // Scan implements the sql.Scanner interface 131 | func (id *TodoHistoryID) Scan(value interface{}) error { 132 | return (*uuid.UUID)(id).Scan(value) 133 | } 134 | 135 | // String implements the Stringer interface. 136 | func (id TodoHistoryID) String() string { 137 | return idUtil(id).str("todo_hist") 138 | } 139 | 140 | func (id *TodoHistoryID) Parse(str string) error { 141 | return (*idUtil)(id).fromStr("todo_hist", str) 142 | } 143 | 144 | func (id TodoHistoryID) Href(action string) string { 145 | return fmt.Sprintf("/todos-history/%s", id) 146 | } 147 | 148 | func (id TodoHistoryID) HrefTo(action string) string { 149 | return fmt.Sprintf("/todos-history/%s/%s", id, action) 150 | } 151 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/gin-gonic/gin" 7 | "github.com/jmoiron/sqlx" 8 | _ "github.com/lib/pq" 9 | g "github.com/maragudk/gomponents" 10 | "github.com/sirupsen/logrus" 11 | ) 12 | 13 | func main() { 14 | // obviously, don't store passwords etc in your source code, this is just an 15 | // example 16 | db, err := sqlx.Open("postgres", "postgres://postgres:mySecretPassword@localhost:10840/postgres?sslmode=disable") 17 | if err != nil { 18 | panic(err) 19 | } 20 | 21 | runMigrations(db.DB) 22 | 23 | s := newServer(db) 24 | 25 | s.GETWithTx("/", indexHandler) 26 | s.POSTWithTx("/todo-lists", postTodoListHandler) 27 | s.GETWithTx("/todo-lists/:tlid", getTodoListHandler) 28 | s.POSTWithTx("/todo-lists/:tlid/delete", deleteTodoListHandler) 29 | s.POSTWithTx("/todo-lists/:tlid/new-todos", newTodosHandler) 30 | s.GETWithTx("/todo-lists/:tlid/revisions", getTodoListRevisionsHandler) 31 | 32 | s.POSTWithTx("/todos/:tid/complete", completeTodoHandler) 33 | s.POSTWithTx("/todos/:tid/reactivate", reactivateTodoHandler) 34 | s.POSTWithTx("/todos/:tid/delete", deleteTodoHandler) 35 | 36 | s.GETWithTx("/todo-lists-history/:tlhid", getTodoListRevisionHandler) 37 | s.POSTWithTx("/todo-lists-history/:tlhid/restore", restoreTodoListRevisionHandler) 38 | 39 | s.POSTWithTx("/todos-history/:thid/restore", restoreTodoRevisionHandler) 40 | 41 | s.Run() 42 | } 43 | 44 | type Context struct { 45 | *gin.Context 46 | Tx *Tx 47 | } 48 | 49 | type server struct { 50 | router *gin.Engine 51 | db *sqlx.DB 52 | } 53 | 54 | func newServer(db *sqlx.DB) *server { 55 | s := &server{ 56 | router: gin.New(), 57 | db: db, 58 | } 59 | s.router.Use(gin.Recovery()) 60 | return s 61 | } 62 | 63 | func (s *server) Run() { 64 | httpServer := &http.Server{ 65 | Addr: ":8080", 66 | Handler: s.router, 67 | } 68 | 69 | logrus.Info("Starting HTTP API on port ", httpServer.Addr) 70 | if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { 71 | logrus.WithError(err).Fatal() 72 | } 73 | } 74 | 75 | func (s *server) GETWithTx(path string, handler func(*Context) (g.Node, error)) { 76 | s.router.GET(path, s.wrapInTx(nodeHandler(handler))) 77 | } 78 | 79 | func (s *server) POSTWithTx(path string, handler func(*Context) error) { 80 | s.router.POST(path, s.wrapInTx(handler)) 81 | } 82 | 83 | func nodeHandler(handler func(*Context) (g.Node, error)) func(*Context) error { 84 | return func(c *Context) error { 85 | node, err := handler(c) 86 | if err != nil { 87 | return err 88 | } 89 | node.Render(c.Context.Writer) 90 | return nil 91 | } 92 | } 93 | 94 | func (s *server) wrapInTx(handler func(*Context) error) gin.HandlerFunc { 95 | return func(gc *gin.Context) { 96 | err := RunInTx(gc.Request.Context(), s.db, func(tx *Tx) error { 97 | return handler(&Context{ 98 | Context: gc, 99 | Tx: tx, 100 | }) 101 | }) 102 | if err != nil { 103 | gc.Status(500) 104 | errorNode(err).Render(gc.Writer) 105 | } 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /migrations.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "database/sql" 5 | "embed" 6 | "errors" 7 | 8 | "github.com/golang-migrate/migrate/v4" 9 | "github.com/golang-migrate/migrate/v4/database/postgres" 10 | _ "github.com/golang-migrate/migrate/v4/database/postgres" 11 | _ "github.com/golang-migrate/migrate/v4/source/github" 12 | 13 | "github.com/golang-migrate/migrate/v4/source/iofs" 14 | ) 15 | 16 | //go:embed migrations/*.sql 17 | var migrationsFS embed.FS 18 | 19 | func runMigrations(db *sql.DB) { 20 | inst, err := postgres.WithInstance(db, &postgres.Config{}) 21 | if err != nil { 22 | panic(err) 23 | } 24 | 25 | migrations, err := iofs.New(migrationsFS, "migrations") 26 | if err != nil { 27 | panic(err) 28 | } 29 | m, err := migrate.NewWithInstance("iofs", migrations, "postgres", inst) 30 | if err != nil { 31 | panic(err) 32 | } 33 | err = m.Up() 34 | if err != nil && !errors.Is(err, migrate.ErrNoChange) { 35 | panic(err) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /migrations/001_history_triggers.down.sql: -------------------------------------------------------------------------------- 1 | DROP FUNCTION copy_updates_into_history; 2 | DROP FUNCTION copy_inserts_and_deletes_into_history; 3 | DROP EXTENSION btree_gist; 4 | -------------------------------------------------------------------------------- /migrations/001_history_triggers.up.sql: -------------------------------------------------------------------------------- 1 | CREATE EXTENSION btree_gist; 2 | 3 | CREATE FUNCTION copy_inserts_and_deletes_into_history() RETURNS TRIGGER AS $$ 4 | DECLARE 5 | history_table TEXT := quote_ident(tg_argv[0]); 6 | id_field TEXT := quote_ident(tg_argv[1]); 7 | BEGIN 8 | IF (TG_OP = 'INSERT') THEN 9 | EXECUTE 'INSERT INTO ' || history_table || 10 | ' SELECT gen_random_uuid(), tstzrange(NOW(), null), $1.*' 11 | USING NEW; 12 | RETURN NEW; 13 | ELSIF (TG_OP = 'DELETE') THEN 14 | -- close current row 15 | -- note: updates and then deletes for same id 16 | -- in same tx will fail 17 | EXECUTE 'UPDATE ' || history_table || 18 | ' SET systime = tstzrange(lower(systime), NOW())' || 19 | ' WHERE ' || id_field || ' = $1.' || id_field || 20 | ' AND systime @> NOW()' USING OLD; 21 | RETURN OLD; 22 | END IF; 23 | RETURN NULL; 24 | END; 25 | $$ LANGUAGE plpgsql; 26 | 27 | CREATE FUNCTION copy_updates_into_history() RETURNS TRIGGER AS $$ 28 | DECLARE 29 | history_table TEXT := quote_ident(tg_argv[0]); 30 | id_field TEXT := quote_ident(tg_argv[1]); 31 | BEGIN 32 | -- ignore changes inside the same tx 33 | EXECUTE 'DELETE FROM ' || history_table || 34 | ' WHERE ' || id_field || ' = $1.' || id_field || 35 | ' AND lower(systime) = NOW()' || 36 | ' AND upper_inf(systime)' USING NEW; 37 | -- close current row 38 | -- (if any, may be deleted by previous line) 39 | EXECUTE 'UPDATE ' || history_table || 40 | ' SET systime = tstzrange(lower(systime), NOW())' 41 | ' WHERE ' || id_field || ' = $1.' || id_field || 42 | ' AND systime @> NOW()' USING NEW; 43 | -- insert new row 44 | EXECUTE 'INSERT INTO ' || history_table || 45 | ' SELECT gen_random_uuid(), tstzrange(NOW(), null), $1.*' 46 | USING NEW; 47 | RETURN NEW; 48 | END; 49 | $$ LANGUAGE plpgsql; 50 | -------------------------------------------------------------------------------- /migrations/002_todo_list.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE todos_history; 2 | DROP TABLE todos; 3 | DROP TABLE todo_lists_history; 4 | DROP TABLE todo_lists; 5 | 6 | -------------------------------------------------------------------------------- /migrations/002_todo_list.up.sql: -------------------------------------------------------------------------------- 1 | -- 2 | -- Todo lists 3 | -- 4 | 5 | CREATE TABLE todo_lists ( 6 | todo_list_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 7 | name TEXT NOT NULL, 8 | created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 9 | updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() 10 | ); 11 | 12 | CREATE TABLE todo_lists_history ( 13 | -- copy these fields, always keep them at the top 14 | history_id UUID PRIMARY KEY, 15 | systime TSTZRANGE NOT NULL CHECK (NOT ISEMPTY(systime)), 16 | 17 | -- table fields, in the exact same order as in todo_list 18 | todo_list_id UUID NOT NULL, 19 | name TEXT NOT NULL, 20 | created_at TIMESTAMPTZ NOT NULL, 21 | updated_at TIMESTAMPTZ NOT NULL 22 | ); 23 | 24 | 25 | ALTER TABLE todo_lists_history 26 | ADD CONSTRAINT todo_lists_history_overlapping_excl 27 | EXCLUDE USING GIST (todo_list_id WITH =, systime WITH &&); 28 | 29 | CREATE TRIGGER todo_lists_history_insert_delete_trigger 30 | AFTER INSERT OR DELETE ON todo_lists 31 | FOR EACH ROW 32 | EXECUTE PROCEDURE copy_inserts_and_deletes_into_history('todo_lists_history', 'todo_list_id'); 33 | 34 | CREATE TRIGGER todo_lists_history_update_trigger 35 | AFTER UPDATE ON todo_lists 36 | FOR EACH ROW 37 | WHEN (OLD.* IS DISTINCT FROM NEW.*) -- to avoid updates on "noop calls" 38 | EXECUTE PROCEDURE copy_updates_into_history('todo_lists_history', 'todo_list_id'); 39 | 40 | -- 41 | -- Todos 42 | -- 43 | 44 | CREATE TABLE todos ( 45 | todo_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 46 | todo_list_id UUID NOT NULL REFERENCES todo_lists(todo_list_id) ON DELETE CASCADE, 47 | description TEXT NOT NULL, 48 | created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 49 | completed BOOLEAN NOT NULL DEFAULT FALSE 50 | ); 51 | 52 | CREATE INDEX todos_todo_list_id_idx 53 | ON todos (todo_list_id, todo_id); 54 | 55 | CREATE TABLE todos_history ( 56 | -- copy these fields, always keep them at the top 57 | history_id UUID PRIMARY KEY, 58 | systime TSTZRANGE NOT NULL CHECK (NOT ISEMPTY(systime)), 59 | 60 | -- table fields, in the exact same order as in todos 61 | todo_id UUID NOT NULL, 62 | todo_list_id UUID NOT NULL, 63 | description TEXT NOT NULL, 64 | created_at TIMESTAMPTZ NOT NULL, 65 | completed BOOLEAN NOT NULL 66 | ); 67 | 68 | CREATE INDEX todos_history_todo_list_id 69 | ON todos_history USING GIST (todo_list_id, systime); 70 | 71 | ALTER TABLE todos_history 72 | ADD CONSTRAINT todos_history_overlapping_excl 73 | EXCLUDE USING GIST (todo_id WITH =, systime WITH &&); 74 | 75 | CREATE TRIGGER todos_history_insert_delete_trigger 76 | AFTER INSERT OR DELETE ON todos 77 | FOR EACH ROW 78 | EXECUTE PROCEDURE copy_inserts_and_deletes_into_history('todos_history', 'todo_id'); 79 | 80 | CREATE TRIGGER todos_history_update_trigger 81 | AFTER UPDATE ON todos 82 | FOR EACH ROW 83 | WHEN (OLD.* IS DISTINCT FROM NEW.*) -- to avoid updates on "noop calls" 84 | EXECUTE PROCEDURE copy_updates_into_history('todos_history', 'todo_id'); 85 | -------------------------------------------------------------------------------- /setup-db.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -euo pipefail 4 | 5 | pgpass=mySecretPassword 6 | pgport=${POSTGRES_PORT:-10840} 7 | 8 | DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" 9 | 10 | if docker ps -a --filter="name=temporal-table-test-db" --format "{{.Names}}" | \ 11 | grep -q 'temporal-table-test-db'; then 12 | echo "Stopping old container" 13 | docker stop temporal-table-test-db > /dev/null 14 | fi 15 | 16 | echo "Starting container" 17 | docker run --rm -d \ 18 | --name temporal-table-test-db \ 19 | --tmpfs=/var/lib/postgresql/data \ 20 | -e POSTGRES_PASSWORD="${pgpass}" \ 21 | -p "${pgport}:5432" \ 22 | -d postgres:16 \ 23 | -c fsync=off 24 | 25 | if command -v pg_isready &> /dev/null; then 26 | countdown=10 27 | while ! pg_isready -h localhost -p "${pgport}" >& /dev/null; do 28 | printf "\r%02d" ${countdown} 29 | sleep 1 30 | countdown=$((countdown-1)) 31 | if [ ${countdown} -le 0 ]; then 32 | echo -e "\rTook more than 10 seconds to wait for database to come up." 33 | echo "This seems suspiciously long, so I'll rather die than let you wait." 34 | exit 1 35 | fi 36 | done 37 | echo -e '\rTest database ready' 38 | else 39 | sleep 2 40 | echo 'pg_isready not found, but database should be ready now' 41 | fi 42 | -------------------------------------------------------------------------------- /todos.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "time" 4 | 5 | type TodoListBase struct { 6 | ID TodoListID `db:"todo_list_id"` 7 | Name string `db:"name"` 8 | CreatedAt time.Time `db:"created_at"` 9 | UpdatedAt time.Time `db:"updated_at"` 10 | } 11 | 12 | type TodoList struct { 13 | TodoListBase 14 | Todos Todos 15 | } 16 | 17 | var todoListCols = TableColumns{"todo_list_id", "name", "created_at", "updated_at"} 18 | 19 | func GetAllTodoLists(tx *Tx) ([]TodoListBase, error) { 20 | var tls []TodoListBase 21 | err := tx.Select(&tls, ` 22 | SELECT `+todoListCols.OnAlias("tl").String()+` 23 | FROM todo_lists tl 24 | ORDER BY name`, QueryArgs{}) 25 | if err != nil { 26 | return nil, err 27 | } 28 | return tls, nil 29 | } 30 | 31 | func GetTodoListByID(tx *Tx, tlid TodoListID) (*TodoList, error) { 32 | var tl TodoList 33 | err := tx.Get(&tl, ` 34 | SELECT `+todoListCols.OnAlias("tl").String()+` 35 | FROM todo_lists tl 36 | WHERE todo_list_id = :tlid`, 37 | QueryArgs{"tlid": tlid}) 38 | 39 | if err != nil { 40 | return nil, err 41 | } 42 | err = tl.attachTodos(tx) 43 | if err != nil { 44 | return nil, err 45 | } 46 | return &tl, err 47 | } 48 | 49 | func NewTodoList(tx *Tx, name string) (*TodoList, error) { 50 | var tlid TodoListID 51 | err := tx.Get(&tlid, ` 52 | INSERT INTO todo_lists (name) 53 | VALUES (:name) 54 | RETURNING todo_list_id`, QueryArgs{ 55 | "name": name, 56 | }) 57 | if err != nil { 58 | return nil, err 59 | } 60 | return GetTodoListByID(tx, tlid) 61 | } 62 | 63 | func UpdateTodoList(tx *Tx, tl TodoList) (*TodoList, error) { 64 | err := tx.UpdateOne(` 65 | UPDATE todo_lists 66 | SET name = :name 67 | , updated_at = NOW() 68 | WHERE todo_list_id = :id`, QueryArgs{ 69 | "id": tl.ID, 70 | "name": tl.Name, 71 | }) 72 | if err != nil { 73 | return nil, err 74 | } 75 | return GetTodoListByID(tx, tl.ID) 76 | } 77 | 78 | func DeleteTodoList(tx *Tx, tlid TodoListID) error { 79 | err := tx.DeleteOne(` 80 | DELETE FROM todo_lists 81 | WHERE todo_list_id = :id`, QueryArgs{ 82 | "id": tlid, 83 | }) 84 | return err 85 | } 86 | 87 | type Todo struct { 88 | ID TodoID `db:"todo_id"` 89 | ListID TodoListID `db:"todo_list_id"` 90 | Description string `db:"description"` 91 | CreatedAt time.Time `db:"created_at"` 92 | Completed bool `db:"completed"` 93 | } 94 | 95 | type Todos []Todo 96 | 97 | func (ts Todos) FilterByCompleted(completed bool) Todos { 98 | var res Todos 99 | for _, todo := range ts { 100 | if todo.Completed == completed { 101 | res = append(res, todo) 102 | } 103 | } 104 | return res 105 | } 106 | 107 | var todoCols = TableColumns{"todo_id", "todo_list_id", "description", "created_at", "completed"} 108 | 109 | func (tl *TodoList) attachTodos(tx *Tx) error { 110 | err := tx.Select(&tl.Todos, ` 111 | SELECT `+todoCols.OnAlias("t").String()+` 112 | FROM todos t 113 | WHERE t.todo_list_id = :tlid 114 | ORDER BY t.description ASC`, QueryArgs{ 115 | "tlid": tl.ID, 116 | }) 117 | return err 118 | } 119 | 120 | func GetTodoByID(tx *Tx, tid TodoID) (*Todo, error) { 121 | var todo Todo 122 | err := tx.Get(&todo, ` 123 | SELECT `+todoCols.OnAlias("t").String()+` 124 | FROM todos t 125 | WHERE todo_id = :tid`, QueryArgs{ 126 | "tid": tid, 127 | }) 128 | if err != nil { 129 | return nil, err 130 | } 131 | return &todo, nil 132 | } 133 | 134 | func NewTodo(tx *Tx, tlid TodoListID, description string) error { 135 | var tid TodoID 136 | err := tx.Get(&tid, ` 137 | INSERT INTO todos (todo_list_id, description) 138 | VALUES (:list_id, :description) 139 | RETURNING todo_id`, 140 | QueryArgs{ 141 | "list_id": tlid, 142 | "description": description, 143 | }) 144 | if err != nil { 145 | return err 146 | } 147 | return touchList(tx, tid) 148 | } 149 | 150 | func SetTodoCompleted(tx *Tx, tid TodoID, completed bool) error { 151 | err := tx.UpdateOne(` 152 | UPDATE todos 153 | SET completed = :completed 154 | WHERE todo_id = :id`, QueryArgs{ 155 | "id": tid, 156 | "completed": completed, 157 | }) 158 | if err != nil { 159 | return err 160 | } 161 | return touchList(tx, tid) 162 | } 163 | 164 | func DeleteTodo(tx *Tx, tid TodoID) error { 165 | err := touchList(tx, tid) 166 | if err != nil { 167 | return err 168 | } 169 | err = tx.DeleteOne(` 170 | DELETE FROM todos 171 | WHERE todo_id = :id`, QueryArgs{ 172 | "id": tid, 173 | }) 174 | return err 175 | } 176 | 177 | // touchList bumps the updated_at field on the list this todo is in, forcing a 178 | // new version of the todo list to be stored in the history table. 179 | func touchList(tx *Tx, tid TodoID) error { 180 | err := tx.UpdateOne(` 181 | UPDATE todo_lists 182 | SET updated_at = NOW() 183 | WHERE todo_list_id = (SELECT todo_list_id 184 | FROM todos 185 | WHERE todo_id = :tid)`, 186 | QueryArgs{ 187 | "tid": tid, 188 | }) 189 | return err 190 | } 191 | -------------------------------------------------------------------------------- /todos_history.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "database/sql" 5 | "errors" 6 | "time" 7 | ) 8 | 9 | type TodoListRevisionBase struct { 10 | HistoryID TodoListHistoryID `db:"history_id"` 11 | SysLower time.Time `db:"sys_lower"` 12 | SysUpper *time.Time `db:"sys_upper"` 13 | } 14 | 15 | type TodoListRevision struct { 16 | TodoListRevisionBase 17 | TodoList 18 | Todos TodoRevisions 19 | } 20 | 21 | // hmm, the OnAlias idea broke down here :( 22 | var todoListRevisionBaseCols = TableColumns{ 23 | "tlh.history_id", "LOWER(tlh.systime) AS sys_lower", "UPPER(tlh.systime) AS sys_upper", 24 | } 25 | 26 | func GetTodoListRevisions(tx *Tx, tlid TodoListID) ([]TodoListRevisionBase, error) { 27 | var revs []TodoListRevisionBase 28 | err := tx.Select(&revs, ` 29 | SELECT `+todoListRevisionBaseCols.String()+` 30 | FROM todo_lists_history tlh 31 | WHERE tlh.todo_list_id = :tlid 32 | ORDER BY systime DESC`, QueryArgs{ 33 | "tlid": tlid, 34 | }) 35 | if err != nil { 36 | return nil, err 37 | } 38 | return revs, nil 39 | } 40 | 41 | var todoListRevisionCols = todoListRevisionBaseCols.Concat(todoListCols.OnAlias("tlh")) 42 | 43 | func GetTodoListRevisionAsOf(tx *Tx, tlid TodoListID, asOf time.Time) (*TodoListRevision, error) { 44 | // not used anywhere in the current app, but implemented to show how it's 45 | // done. 46 | var tlr TodoListRevision 47 | err := tx.Get(&tlr, ` 48 | SELECT `+todoListRevisionCols.String()+` 49 | FROM todo_lists_history tlh 50 | WHERE tlh.todo_list_id = :tlid 51 | AND th.systime @> CAST(:as_of AS timestamptz)`, QueryArgs{ 52 | "tlid": tlid, 53 | "as_of": asOf, 54 | }) 55 | if err != nil { 56 | return nil, err 57 | } 58 | err = tlr.attachTodos(tx, asOf) 59 | if err != nil { 60 | return nil, err 61 | } 62 | return &tlr, nil 63 | } 64 | 65 | func GetTodoListRevisionByID(tx *Tx, tlhid TodoListHistoryID) (*TodoListRevision, error) { 66 | var tlr TodoListRevision 67 | err := tx.Get(&tlr, ` 68 | SELECT `+todoListRevisionCols.String()+` 69 | FROM todo_lists_history tlh 70 | WHERE tlh.history_id = :tlhid`, QueryArgs{ 71 | "tlhid": tlhid, 72 | }) 73 | if err != nil { 74 | return nil, err 75 | } 76 | err = tlr.attachTodos(tx, tlr.SysLower) 77 | if err != nil { 78 | return nil, err 79 | } 80 | return &tlr, nil 81 | } 82 | 83 | func RestoreTodoListToRevision(tx *Tx, tlhid TodoListHistoryID) (*TodoListID, error) { 84 | tlr, err := GetTodoListRevisionByID(tx, tlhid) 85 | if err != nil { 86 | return nil, err 87 | } 88 | 89 | err = DeleteTodoList(tx, tlr.ID) 90 | if err != nil && !errors.Is(err, sql.ErrNoRows) { 91 | // allow to restore deleted todo lists 92 | return nil, err 93 | } 94 | 95 | // insert the todo list from the history table 96 | err = tx.UpdateOne(` 97 | INSERT INTO todo_lists (`+todoListCols.String()+`) 98 | SELECT `+todoListCols.OnAlias("tlh").String()+` 99 | FROM todo_lists_history tlh 100 | WHERE tlh.history_id = :tlhid`, QueryArgs{ 101 | "tlhid": tlhid, 102 | }) 103 | if err != nil { 104 | return nil, err 105 | } 106 | 107 | // then insert the list elements 108 | err = tx.Exec(` 109 | INSERT INTO todos (`+todoCols.String()+`) 110 | SELECT `+todoCols.OnAlias("th").String()+` 111 | FROM todos_history th 112 | WHERE th.todo_list_id = :list_id 113 | AND th.systime @> CAST(:as_of AS timestamptz)`, QueryArgs{ 114 | "list_id": tlr.ID, 115 | "as_of": tlr.SysLower, 116 | }) 117 | 118 | if err != nil { 119 | return nil, err 120 | } 121 | return &tlr.ID, nil 122 | } 123 | 124 | type TodoRevision struct { 125 | HistoryID TodoHistoryID `db:"history_id"` 126 | SysLower time.Time `db:"sys_lower"` 127 | SysUpper *time.Time `db:"sys_upper"` 128 | Todo 129 | } 130 | 131 | type TodoRevisions []TodoRevision 132 | 133 | func (ts TodoRevisions) FilterByCompleted(completed bool) TodoRevisions { 134 | var res TodoRevisions 135 | for _, todo := range ts { 136 | if todo.Completed == completed { 137 | res = append(res, todo) 138 | } 139 | } 140 | return res 141 | } 142 | 143 | var todoRevisionBaseCols = TableColumns{ 144 | "th.history_id", "LOWER(th.systime) AS sys_lower", "UPPER(th.systime) AS sys_upper", 145 | } 146 | 147 | var todoRevisionCols = todoRevisionBaseCols.Concat(todoCols.OnAlias("th")) 148 | 149 | func (tlr *TodoListRevision) attachTodos(tx *Tx, asOf time.Time) error { 150 | // passing in asOf doesn't really do anthing valuable in this implementation: 151 | // Since we always update the todo list's updated_at field whenever we do 152 | // something with its todos, we may as well use the creation time of the 153 | // revision. However, if you don't need to maintain a list of revisions for 154 | // the list itself, but is rather interested in the state at a specific point 155 | // in time, you need the asOf as input. 156 | err := tx.Select(&tlr.Todos, ` 157 | SELECT `+todoRevisionCols.String()+` 158 | FROM todos_history th 159 | WHERE th.todo_list_id = :tlid 160 | AND th.systime @> CAST(:as_of AS timestamptz) 161 | ORDER BY th.description ASC`, QueryArgs{ 162 | "tlid": tlr.ID, 163 | "as_of": asOf, 164 | }) 165 | return err 166 | } 167 | 168 | func GetTodoRevisionByID(tx *Tx, thid TodoHistoryID) (*TodoRevision, error) { 169 | var tr TodoRevision 170 | err := tx.Get(&tr, ` 171 | SELECT `+todoRevisionCols.String()+` 172 | FROM todos_history th 173 | WHERE th.history_id = :thid`, QueryArgs{ 174 | "thid": thid, 175 | }) 176 | if err != nil { 177 | return nil, err 178 | } 179 | return &tr, nil 180 | } 181 | 182 | func GetTodoRevisionAsOf(tx *Tx, tid TodoID, asOf time.Time) (*TodoRevision, error) { 183 | // not used anywhere in the app as of right now, but present to show you how 184 | // it's implemented. 185 | var tr TodoRevision 186 | err := tx.Get(&tr, ` 187 | SELECT `+todoRevisionCols.String()+` 188 | FROM todos_history th 189 | WHERE th.todo_id = :tid 190 | AND th.systime @> CAST(:as_of AS timestamptz)`, QueryArgs{ 191 | "tid": tid, 192 | "as_of": asOf, 193 | }) 194 | if err != nil { 195 | return nil, err 196 | } 197 | return &tr, nil 198 | } 199 | 200 | func RestoreTodoToRevision(tx *Tx, thid TodoHistoryID) (*TodoListID, error) { 201 | tr, err := GetTodoRevisionByID(tx, thid) 202 | if err != nil { 203 | return nil, err 204 | } 205 | 206 | // delete it (if it still is in the list) 207 | err = DeleteTodo(tx, tr.ID) 208 | if err != nil && !errors.Is(err, sql.ErrNoRows) { 209 | return nil, err 210 | } 211 | 212 | // then insert it back into the list 213 | err = tx.Exec(` 214 | INSERT INTO todos (`+todoCols.String()+`) 215 | SELECT `+todoCols.OnAlias("th").String()+` 216 | FROM todos_history th 217 | WHERE th.history_id = :thid`, QueryArgs{ 218 | "thid": thid, 219 | }) 220 | 221 | if err != nil { 222 | return nil, err 223 | } 224 | return &tr.ListID, nil 225 | } 226 | --------------------------------------------------------------------------------