summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore1
-rw-r--r--.gitmodules3
-rw-r--r--Makefile30
-rw-r--r--README.md99
-rw-r--r--assets/logo.pngbin0 -> 826324 bytes
m---------external/protobuf0
-rw-r--r--go.mod32
-rw-r--r--go.sum78
-rw-r--r--internal/app/action.go47
-rw-r--r--internal/app/command.go23
-rw-r--r--internal/app/flags.go127
-rw-r--r--internal/logging/context.go39
-rw-r--r--internal/logging/logging.go92
-rw-r--r--internal/logging/trace.go61
-rw-r--r--internal/manualgen/manualgen.go21
-rw-r--r--internal/server/model/.gitignore2
-rw-r--r--internal/server/server.go16
-rw-r--r--internal/server/setup.go34
-rw-r--r--internal/server/task.go33
-rw-r--r--internal/server/user.go34
-rw-r--r--internal/storage/db.go18
-rw-r--r--internal/storage/schema.go38
-rw-r--r--internal/storage/setup.go201
-rw-r--r--internal/tagging/tagging.go23
-rw-r--r--internal/util/context.go8
-rw-r--r--main.go19
-rwxr-xr-xresources/local_dev.sh19
-rw-r--r--resources/schema.proto79
-rw-r--r--runtime/.gitignore2
29 files changed, 1179 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..567609b
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+build/
diff --git a/.gitmodules b/.gitmodules
new file mode 100644
index 0000000..d58ae36
--- /dev/null
+++ b/.gitmodules
@@ -0,0 +1,3 @@
+[submodule "external/protobuf"]
+ path = external/protobuf
+ url = https://github.com/protocolbuffers/protobuf.git
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..7ef18f3
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,30 @@
+BUILD_DIR=./build
+APP=rafta
+
+all: setup codegen clean compile
+
+setup:
+ git submodule update --init
+
+codegen:
+ protoc \
+ --proto_path=resources \
+ --proto_path=external \
+ --go_out=internal/server/model \
+ --go_opt=paths=source_relative \
+ --go-grpc_out=internal/server/model \
+ --go-grpc_opt=paths=source_relative \
+ resources/schema.proto
+
+clean:
+ rm -rf $(BUILD_DIR) || exit 1
+
+compile:
+ mkdir -p $(BUILD_DIR) || exit 1
+ CGO_ENABLED=1 go run ./internal/manualgen > $(BUILD_DIR)/$(APP).1
+ CGO_ENABLED=1 go build -o $(BUILD_DIR)/$(APP) .
+
+.PHONY: run
+run:
+ ./resources/local_dev.sh
+
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..eef8ea6
--- /dev/null
+++ b/README.md
@@ -0,0 +1,99 @@
+<div align="center">
+
+ <img alt="A raft (boat) in a sea of tasks (checkmarks) logo" src="assets/logo.png" width="250px" />
+
+# Rafta
+
+**R**eally, **A**nother **F***cking **T**odo **A**pp?!
+
+</div>
+
+
+## Why another TODO app?
+
+Many open source implementations for ToDo apps go with a file-based approach
+(ex: task-warrior, todo-txt, orgmode, markdown). While this simplicity is
+elegant, it usually results in implementations that is lacking in the
+following scenarios:
+
+- Timely cross-device syncing
+- Notifications (for reminders and due tasks)
+- Good mobile interfaces
+
+This is one of the reason many people prefer a server based approach (ex:
+Todoist, Apple reminders, Evernote, TickTick). However, these tend to be much
+more complex and gravitate towards a proprietary, for-profit solution. This
+makes them generally harder to hack around when it comes to creating custom
+clients
+
+This project aims to get the best of both worlds: Cross platform reliability of
+a server-based approach with the hackability of scrappy text-based management.
+
+## Goals
+
+This project stems from my frustration trying to find a decent Task management
+infrastructure that fits my needs. So the project's primary goals are to
+fulfill the following needs:
+
+- Easy synchronisation between multiple clients (mobile desktop and such)
+- Neovim plugin as a desktop client (with a similar feel to [oil.nvim][1])
+- Pretty mobile interface (most txt-based apps are quite ugly IMO)
+- Notifications that are on-time on mobile
+- Easy extensibility through third-party clients (both daemons & user-facing)
+
+## Implementation
+
+So what's the plan to achieve this? This repo contains the implementation
+of a server as well as the communication specification to that server.
+Everyone is welcome to create their own frontend for that server as well as
+other tooling that can communicate with said server. Examples of that could be
+
+- mobile/desktop app
+- web app
+- text-editor plugin
+- conky/uebersicht desktop task viewer
+- Web Endpoint for an iCal calendar
+- webhook server
+
+As this project is in its infancy, specifications are subject to change but
+this is bound to diminish over time. Communications are done over a gRPC
+specification that can be found in the `resources/schema.proto` file.
+
+The end result would be something similar to this:
+
+```mermaid
+flowchart TD
+ Srv[Self-hosted Rafta Server]
+ A[Mobile App]
+ B[Neovim plugin]
+ C[TUI App]
+ D[Web App]
+ E[iCal generator]
+ Srv <--> |gRPC| A
+ Srv <--> |gRPC| B
+ Srv <--> |gRPC| C
+ Srv <--> |gRPC| D
+ Srv <--> |gRPC| E
+```
+
+Since the aim is for each user to self host his task (and at most the task of a
+few friends), a single container approach with an SQLite storage solution will
+be implemented.
+
+The centerpiece is the server itself so but as it's development matures, I will
+try and develop a client in Neovim as well as a mobile client.
+
+## Roadmap
+
+- [X] Protobuf schema definition
+- [X] Blank Database Initialization
+- [X] Request-ID tracking middleware
+- [X] Logging framework (request-ID, stack traces, optional Json)
+- [ ] **Actual Server Logic**
+- [ ] Task re-scheduling routine
+- [ ] User Authentication (probably jwt)
+- [ ] Neovim plugin client
+
+
+
+[1]: https://github.com/stevearc/oil.nvim
diff --git a/assets/logo.png b/assets/logo.png
new file mode 100644
index 0000000..ec96600
--- /dev/null
+++ b/assets/logo.png
Binary files differ
diff --git a/external/protobuf b/external/protobuf
new file mode 160000
+Subproject ae3015c78a0ea3baa76f3ad556c0ecd575f2e61
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..6e13b01
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,32 @@
+module github.com/ChausseBenjamin/rafta
+
+go 1.23.4
+
+require (
+ github.com/charmbracelet/log v0.4.0
+ github.com/hashicorp/go-uuid v1.0.3
+ github.com/mattn/go-sqlite3 v1.14.24
+ github.com/urfave/cli-docs/v3 v3.0.0-alpha6
+ github.com/urfave/cli/v3 v3.0.0-beta1
+ google.golang.org/grpc v1.70.0
+ google.golang.org/protobuf v1.36.4
+)
+
+require (
+ github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
+ github.com/charmbracelet/lipgloss v0.10.0 // indirect
+ github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect
+ github.com/go-logfmt/logfmt v0.6.0 // indirect
+ github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
+ github.com/mattn/go-isatty v0.0.18 // indirect
+ github.com/mattn/go-runewidth v0.0.15 // indirect
+ github.com/muesli/reflow v0.3.0 // indirect
+ github.com/muesli/termenv v0.15.2 // indirect
+ github.com/rivo/uniseg v0.4.7 // indirect
+ github.com/russross/blackfriday/v2 v2.1.0 // indirect
+ golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect
+ golang.org/x/net v0.32.0 // indirect
+ golang.org/x/sys v0.28.0 // indirect
+ golang.org/x/text v0.21.0 // indirect
+ google.golang.org/genproto/googleapis/rpc v0.0.0-20241202173237-19429a94021a // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..f47a5c4
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,78 @@
+github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
+github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
+github.com/charmbracelet/lipgloss v0.10.0 h1:KWeXFSexGcfahHX+54URiZGkBFazf70JNMtwg/AFW3s=
+github.com/charmbracelet/lipgloss v0.10.0/go.mod h1:Wig9DSfvANsxqkRsqj6x87irdy123SR4dOXlKa91ciE=
+github.com/charmbracelet/log v0.4.0 h1:G9bQAcx8rWA2T3pWvx7YtPTPwgqpk7D68BX21IRW8ZM=
+github.com/charmbracelet/log v0.4.0/go.mod h1:63bXt/djrizTec0l11H20t8FDSvA4CRZJ1KH22MdptM=
+github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w=
+github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
+github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
+github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
+github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
+github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
+github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
+github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
+github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
+github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
+github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
+github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
+github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
+github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
+github.com/mattn/go-isatty v0.0.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp98=
+github.com/mattn/go-isatty v0.0.18/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
+github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
+github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
+github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
+github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
+github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
+github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
+github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo=
+github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
+github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
+github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
+github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
+github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
+github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
+github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
+github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/urfave/cli-docs/v3 v3.0.0-alpha6 h1:w/l/N0xw1rO/aHRIGXJ0lDwwYFOzilup1qGvIytP3BI=
+github.com/urfave/cli-docs/v3 v3.0.0-alpha6/go.mod h1:p7Z4lg8FSTrPB9GTaNyTrK3ygffHZcK3w0cU2VE+mzU=
+github.com/urfave/cli/v3 v3.0.0-beta1 h1:6DTaaUarcM0wX7qj5Hcvs+5Dm3dyUTBbEwIWAjcw9Zg=
+github.com/urfave/cli/v3 v3.0.0-beta1/go.mod h1:FnIeEMYu+ko8zP1F9Ypr3xkZMIDqW3DR92yUtY39q1Y=
+go.opentelemetry.io/otel v1.32.0 h1:WnBN+Xjcteh0zdk01SVqV55d/m62NJLJdIyb4y/WO5U=
+go.opentelemetry.io/otel v1.32.0/go.mod h1:00DCVSB0RQcnzlwyTfqtxSm+DRr9hpYrHjNGiBHVQIg=
+go.opentelemetry.io/otel/metric v1.32.0 h1:xV2umtmNcThh2/a/aCP+h64Xx5wsj8qqnkYZktzNa0M=
+go.opentelemetry.io/otel/metric v1.32.0/go.mod h1:jH7CIbbK6SH2V2wE16W05BHCtIDzauciCRLoc/SyMv8=
+go.opentelemetry.io/otel/sdk v1.32.0 h1:RNxepc9vK59A8XsgZQouW8ue8Gkb4jpWtJm9ge5lEG4=
+go.opentelemetry.io/otel/sdk v1.32.0/go.mod h1:LqgegDBjKMmb2GC6/PrTnteJG39I8/vJCAP9LlJXEjU=
+go.opentelemetry.io/otel/sdk/metric v1.32.0 h1:rZvFnvmvawYb0alrYkjraqJq0Z4ZUJAiyYCU9snn1CU=
+go.opentelemetry.io/otel/sdk/metric v1.32.0/go.mod h1:PWeZlq0zt9YkYAp3gjKZ0eicRYvOh1Gd+X99x6GHpCQ=
+go.opentelemetry.io/otel/trace v1.32.0 h1:WIC9mYrXf8TmY/EXuULKc8hR17vE+Hjv2cssQDe03fM=
+go.opentelemetry.io/otel/trace v1.32.0/go.mod h1:+i4rkvCraA+tG6AzwloGaCtkx53Fa+L+V8e9a7YvhT8=
+golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
+golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
+golang.org/x/net v0.32.0 h1:ZqPmj8Kzc+Y6e0+skZsuACbx+wzMgo5MQsJh9Qd6aYI=
+golang.org/x/net v0.32.0/go.mod h1:CwU0IoeOlnQQWJ6ioyFrfRuomB8GKF6KbYXZVyeXNfs=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
+golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
+golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20241202173237-19429a94021a h1:hgh8P4EuoxpsuKMXX/To36nOFD7vixReXgn8lPGnt+o=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20241202173237-19429a94021a/go.mod h1:5uTbfoYQed2U9p3KIj2/Zzm02PYhndfdmML0qC3q3FU=
+google.golang.org/grpc v1.70.0 h1:pWFv03aZoHzlRKHWicjsZytKAiYCtNS0dHbXnIdq7jQ=
+google.golang.org/grpc v1.70.0/go.mod h1:ofIJqVKDXx/JiXrwr2IG4/zwdH9txy3IlF40RmcJSQw=
+google.golang.org/protobuf v1.36.4 h1:6A3ZDJHn/eNqc1i+IdefRzy/9PokBTPvcqMySR7NNIM=
+google.golang.org/protobuf v1.36.4/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/internal/app/action.go b/internal/app/action.go
new file mode 100644
index 0000000..b579253
--- /dev/null
+++ b/internal/app/action.go
@@ -0,0 +1,47 @@
+package app
+
+import (
+ "context"
+ "fmt"
+ "log/slog"
+
+ "github.com/ChausseBenjamin/rafta/internal/logging"
+ "github.com/ChausseBenjamin/rafta/internal/server"
+ "github.com/ChausseBenjamin/rafta/internal/storage"
+ "github.com/urfave/cli/v3"
+)
+
+func action(ctx context.Context, cmd *cli.Command) error {
+ err := logging.Setup(
+ cmd.String(FlagLogLevel),
+ cmd.String(FlagLogFormat),
+ cmd.String(FlagLogOutput),
+ )
+ if err != nil {
+ slog.Warn("Error(s) occured during logger initialization", logging.ErrKey, err)
+ }
+
+ slog.Info("Starting rafta server")
+
+ // TODO: Setup the db
+ store, err := storage.Setup(cmd.String(FlagDBPath))
+ if err != nil {
+ slog.Error("Unable to setup database", logging.ErrKey, err)
+ }
+
+ srv, lis, err := server.Setup(cmd.Int(FlagListenPort), store)
+ if err != nil {
+ slog.Error("Unable to setup server", logging.ErrKey, err)
+
+ return err
+ }
+
+ slog.Info(fmt.Sprintf("Listening on port %d", cmd.Int(FlagListenPort)))
+ if err := srv.Serve(lis); err != nil {
+ slog.Error("Server runtime error", logging.ErrKey, err)
+
+ return err
+ }
+
+ return nil
+}
diff --git a/internal/app/command.go b/internal/app/command.go
new file mode 100644
index 0000000..5c32ba2
--- /dev/null
+++ b/internal/app/command.go
@@ -0,0 +1,23 @@
+package app
+
+import (
+ "github.com/urfave/cli/v3"
+)
+
+const (
+ AppName = "rafta"
+ AppUsage = "Really, Another Freaking Todo App?!"
+)
+
+var version = "COMPILED"
+
+func Command() *cli.Command {
+ return &cli.Command{
+ Name: AppName,
+ Usage: AppUsage,
+ Authors: []any{"Benjamin Chausse <benjamin@chausse.xyz>"},
+ Version: version,
+ Flags: flags(),
+ Action: action,
+ }
+}
diff --git a/internal/app/flags.go b/internal/app/flags.go
new file mode 100644
index 0000000..e96b8ef
--- /dev/null
+++ b/internal/app/flags.go
@@ -0,0 +1,127 @@
+package app
+
+import (
+ "context"
+ "fmt"
+ "log/slog"
+ "os"
+ "strings"
+
+ "github.com/ChausseBenjamin/rafta/internal/logging"
+ "github.com/ChausseBenjamin/rafta/internal/server"
+ "github.com/urfave/cli/v3"
+)
+
+const (
+ FlagListenPort = "port"
+ FlagLogLevel = "log-level"
+ FlagLogFormat = "log-format"
+ FlagLogOutput = "log-output"
+ FlagDBPath = "database"
+)
+
+func flags() []cli.Flag {
+ return []cli.Flag{
+ // Logging {{{
+ &cli.StringFlag{
+ Name: FlagLogFormat,
+ Aliases: []string{"f"},
+ Value: "plain",
+ Usage: "plain, json",
+ Sources: cli.EnvVars("LOG_FORMAT"),
+ Action: validateLogFormat,
+ },
+ &cli.StringFlag{
+ Name: FlagLogOutput,
+ Aliases: []string{"o"},
+ Value: "stdout",
+ Usage: "stdout, stderr, file",
+ Sources: cli.EnvVars("LOG_OUTPUT"),
+ Action: validateLogOutput,
+ },
+ &cli.StringFlag{
+ Name: FlagLogLevel,
+ Aliases: []string{"l"},
+ Value: "info",
+ Usage: "debug, info, warn, error",
+ Sources: cli.EnvVars("LOG_LEVEL"),
+ Action: validateLogLevel,
+ }, // }}}
+ // gRPC server {{{
+ &cli.IntFlag{
+ Name: FlagListenPort,
+ Aliases: []string{"p"},
+ Value: 1234,
+ Sources: cli.EnvVars("LISTEN_PORT"),
+ Action: validateListenPort,
+ }, // }}}
+ // Database {{{
+ &cli.StringFlag{
+ Name: FlagDBPath,
+ Aliases: []string{"d"},
+ Value: "store.db",
+ Usage: "database file",
+ Sources: cli.EnvVars("DATABASE_PATH"),
+ Action: validateDBPath,
+ }, // }}}
+ }
+}
+
+func validateLogOutput(ctx context.Context, cmd *cli.Command, s string) error {
+ switch {
+ case s == "stdout" || s == "stderr":
+ return nil
+ default:
+ // assume file
+ f, err := os.OpenFile(s, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
+ if err != nil {
+ slog.ErrorContext(
+ ctx,
+ fmt.Sprintf("Error creating/accessing provided log file %s", s),
+ )
+ return err
+ }
+ defer f.Close()
+ return nil
+ }
+}
+
+func validateLogLevel(ctx context.Context, cmd *cli.Command, s string) error {
+ for _, lvl := range []string{"deb", "inf", "warn", "err"} {
+ if strings.Contains(strings.ToLower(s), lvl) {
+ return nil
+ }
+ }
+ slog.ErrorContext(
+ ctx,
+ fmt.Sprintf("Unknown log level provided: %s", s),
+ )
+ return logging.ErrInvalidLevel
+}
+
+func validateLogFormat(ctx context.Context, cmd *cli.Command, s string) error {
+ s = strings.ToLower(s)
+ if s == "json" || s == "plain" {
+ return nil
+ }
+ return nil
+}
+
+func validateListenPort(ctx context.Context, cmd *cli.Command, p int64) error {
+ if p < 1024 || p > 65535 {
+ slog.ErrorContext(
+ ctx,
+ fmt.Sprintf("Out-of-bound port provided: %d", p),
+ )
+ return server.ErrOutOfBoundsPort
+ }
+ return nil
+}
+
+func validateDBPath(ctx context.Context, cmd *cli.Command, s string) error {
+ // TODO: Ensure the db file is writable.
+ // TODO: Ensure the db file is a valid sqlite3 db.
+ // TODO: Call db.Reset() if either of the above fail.
+ // TODO: Log the error/crash if the db file is not writable.
+ return nil
+}
diff --git a/internal/logging/context.go b/internal/logging/context.go
new file mode 100644
index 0000000..29bad62
--- /dev/null
+++ b/internal/logging/context.go
@@ -0,0 +1,39 @@
+package logging
+
+import (
+ "context"
+ "log/slog"
+)
+
+type ctxTracker struct {
+ ctxKey interface{}
+ logKey string
+ next slog.Handler
+}
+
+func (h ctxTracker) Handle(ctx context.Context, r slog.Record) error {
+ if v := ctx.Value(h.ctxKey); v != nil {
+ r.AddAttrs(slog.Any(h.logKey, v))
+ }
+ return h.next.Handle(ctx, r)
+}
+
+func (h ctxTracker) Enabled(ctx context.Context, lvl slog.Level) bool {
+ return h.next.Enabled(ctx, lvl)
+}
+
+func (h ctxTracker) WithAttrs(attrs []slog.Attr) slog.Handler {
+ return h.next.WithAttrs(attrs)
+}
+
+func (h ctxTracker) WithGroup(name string) slog.Handler {
+ return h.next.WithGroup(name)
+}
+
+func withTrackedContext(current slog.Handler, ctxKey interface{}, logKey string) *ctxTracker {
+ return &ctxTracker{
+ ctxKey: ctxKey,
+ logKey: logKey,
+ next: current,
+ }
+}
diff --git a/internal/logging/logging.go b/internal/logging/logging.go
new file mode 100644
index 0000000..91a9734
--- /dev/null
+++ b/internal/logging/logging.go
@@ -0,0 +1,92 @@
+package logging
+
+import (
+ "errors"
+ "io"
+ "log/slog"
+ "os"
+ "strings"
+ "time"
+
+ "github.com/ChausseBenjamin/rafta/internal/util"
+ "github.com/charmbracelet/log"
+)
+
+const (
+ ErrKey = "error_message"
+)
+
+var (
+ ErrInvalidLevel = errors.New("invalid log level")
+ ErrInvalidFormat = errors.New("invalid log format")
+)
+
+func Setup(lvlStr, fmtStr, outStr string) error {
+ output, outputErr := setOutput(outStr)
+ format, formatErr := setFormat(fmtStr)
+ level, levelErr := setLevel(lvlStr)
+
+ prefixStr := ""
+ if format != log.JSONFormatter {
+ prefixStr = "Rafta 🚢"
+ }
+
+ var h slog.Handler = log.NewWithOptions(
+ output,
+ log.Options{
+ TimeFormat: time.DateTime,
+ Prefix: prefixStr,
+ Level: level,
+ ReportCaller: true,
+ Formatter: format,
+ },
+ )
+
+ h = withTrackedContext(h, util.ReqIDKey, "request_id")
+ h = withStackTrace(h)
+ slog.SetDefault(slog.New(h))
+ return errors.Join(outputErr, formatErr, levelErr)
+}
+
+func setLevel(target string) (log.Level, error) {
+ for _, l := range []struct {
+ prefix string
+ level log.Level
+ }{
+ {"deb", log.DebugLevel},
+ {"inf", log.InfoLevel},
+ {"warn", log.WarnLevel},
+ {"err", log.ErrorLevel},
+ } {
+ if strings.HasPrefix(strings.ToLower(target), l.prefix) {
+ return l.level, nil
+ }
+ }
+ return log.InfoLevel, ErrInvalidLevel
+}
+
+func setFormat(f string) (log.Formatter, error) {
+ switch f {
+ case "plain", "text":
+ return log.TextFormatter, nil
+ case "json", "structured":
+ return log.JSONFormatter, nil
+ }
+ return log.TextFormatter, ErrInvalidFormat
+}
+
+func setOutput(path string) (io.Writer, error) {
+ switch path {
+ case "stdout":
+ return os.Stdout, nil
+ case "stderr":
+ return os.Stderr, nil
+ default:
+ f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
+ if err != nil {
+ return os.Stdout, err
+ } else {
+ return f, nil
+ }
+ }
+}
diff --git a/internal/logging/trace.go b/internal/logging/trace.go
new file mode 100644
index 0000000..8900727
--- /dev/null
+++ b/internal/logging/trace.go
@@ -0,0 +1,61 @@
+package logging
+
+import (
+ "context"
+ "log/slog"
+ "runtime"
+ "strconv"
+ "strings"
+)
+
+const (
+ prgCount = 20
+ defSkip = 6
+)
+
+type stackTracer struct {
+ h slog.Handler
+ nSkip int
+}
+
+func (h stackTracer) Enabled(ctx context.Context, lvl slog.Level) bool {
+ return h.h.Enabled(ctx, lvl)
+}
+
+func (h stackTracer) WithAttrs(attrs []slog.Attr) slog.Handler {
+ return h.h.WithAttrs(attrs)
+}
+
+func (h stackTracer) WithGroup(name string) slog.Handler {
+ return h.h.WithGroup(name)
+}
+
+func (h stackTracer) Handle(ctx context.Context, r slog.Record) error {
+ if r.Level < slog.LevelError {
+ return h.h.Handle(ctx, r)
+ }
+
+ trace := h.GetTrace()
+ r.AddAttrs(slog.String("trace", trace))
+
+ return h.h.Handle(ctx, r)
+}
+
+func (h stackTracer) GetTrace() string {
+ var b strings.Builder
+ pc := make([]uintptr, prgCount)
+ n := runtime.Callers(h.nSkip, pc)
+ frames := runtime.CallersFrames(pc[:n])
+
+ for frame, more := frames.Next(); more; frame, more = frames.Next() {
+ b.WriteString(frame.Function + "\n " + frame.File + ":" + strconv.Itoa(frame.Line) + "\n")
+ }
+ return b.String()
+}
+
+func withStackTrace(h slog.Handler) slog.Handler {
+ return stackTracer{
+ h: h,
+ nSkip: defSkip,
+ }
+}
diff --git a/internal/manualgen/manualgen.go b/internal/manualgen/manualgen.go
new file mode 100644
index 0000000..88a72e2
--- /dev/null
+++ b/internal/manualgen/manualgen.go
@@ -0,0 +1,21 @@
+package main
+
+import (
+ "log/slog"
+ "os"
+
+ "github.com/ChausseBenjamin/rafta/internal/app"
+ "github.com/ChausseBenjamin/rafta/internal/logging"
+ docs "github.com/urfave/cli-docs/v3"
+)
+
+func main() {
+ a := app.Command()
+
+ man, err := docs.ToManWithSection(a, 1)
+ if err != nil {
+ slog.Error("failed to generate markdown", logging.ErrKey, err)
+ os.Exit(1)
+ }
+ os.Stdout.Write([]byte(man))
+}
diff --git a/internal/server/model/.gitignore b/internal/server/model/.gitignore
new file mode 100644
index 0000000..d6b7ef3
--- /dev/null
+++ b/internal/server/model/.gitignore
@@ -0,0 +1,2 @@
+*
+!.gitignore
diff --git a/internal/server/server.go b/internal/server/server.go
new file mode 100644
index 0000000..70e5861
--- /dev/null
+++ b/internal/server/server.go
@@ -0,0 +1,16 @@
+package server
+
+import (
+ "database/sql"
+ "errors"
+
+ m "github.com/ChausseBenjamin/rafta/internal/server/model"
+)
+
+var ErrOutOfBoundsPort = errors.New("port out of bounds")
+
+// Implements ComsServer interface
+type Service struct {
+ store *sql.DB
+ m.UnimplementedRaftaServer
+}
diff --git a/internal/server/setup.go b/internal/server/setup.go
new file mode 100644
index 0000000..551cd95
--- /dev/null
+++ b/internal/server/setup.go
@@ -0,0 +1,34 @@
+package server
+
+import (
+ "database/sql"
+ "fmt"
+ "log/slog"
+ "net"
+
+ "github.com/ChausseBenjamin/rafta/internal/logging"
+ m "github.com/ChausseBenjamin/rafta/internal/server/model"
+ "github.com/ChausseBenjamin/rafta/internal/tagging"
+ "google.golang.org/grpc"
+)
+
+func Setup(port int64, storage *sql.DB) (*grpc.Server, net.Listener, error) {
+ lis, err := net.Listen(
+ "tcp",
+ fmt.Sprintf(":%d", port),
+ )
+ if err != nil {
+ slog.Error("Unable to create listener", logging.ErrKey, err)
+ return nil, nil, err
+ }
+
+ grpcServer := grpc.NewServer(
+ grpc.ChainUnaryInterceptor(
+ tagging.UnaryInterceptor,
+ ),
+ )
+ raftaService := &Service{store: storage}
+ m.RegisterRaftaServer(grpcServer, raftaService)
+
+ return grpcServer, lis, nil
+}
diff --git a/internal/server/task.go b/internal/server/task.go
new file mode 100644
index 0000000..b8cf0b8
--- /dev/null
+++ b/internal/server/task.go
@@ -0,0 +1,33 @@
+package server
+
+import (
+ "context"
+ "log/slog"
+
+ m "github.com/ChausseBenjamin/rafta/internal/server/model"
+ "google.golang.org/protobuf/types/known/emptypb"
+)
+
+func (s Service) GetUserTasks(ctx context.Context, id *m.UserID) (*m.TaskList, error) {
+ slog.ErrorContext(ctx, "GetUserTasks not implemented yet")
+ return nil, nil
+}
+
+func (s Service) GetTask(ctx context.Context, id *m.TaskID) (*m.Task, error) {
+ return nil, nil
+}
+
+func (s Service) DeleteTask(ctx context.Context, id *m.TaskID) (*emptypb.Empty, error) {
+ slog.ErrorContext(ctx, "DeleteTask not implemented yet")
+ return nil, nil
+}
+
+func (s Service) UpdateTask(ctx context.Context, t *m.Task) (*m.Task, error) {
+ slog.ErrorContext(ctx, "UpdateTask not implemented yet")
+ return t, nil
+}
+
+func (s Service) CreateTask(ctx context.Context, data *m.TaskData) (*m.Task, error) {
+ slog.ErrorContext(ctx, "CreateTask not implemented yet")
+ return nil, nil
+}
diff --git a/internal/server/user.go b/internal/server/user.go
new file mode 100644
index 0000000..c4a97c4
--- /dev/null
+++ b/internal/server/user.go
@@ -0,0 +1,34 @@
+package server
+
+import (
+ "context"
+ "log/slog"
+
+ m "github.com/ChausseBenjamin/rafta/internal/server/model"
+ "google.golang.org/protobuf/types/known/emptypb"
+)
+
+func (s Service) GetAllUsers(ctx context.Context, empty *emptypb.Empty) (*m.UserList, error) {
+ slog.ErrorContext(ctx, "GetAllUsers not implemented yet")
+ return nil, nil
+}
+
+func (s Service) GetUser(ctx context.Context, id *m.UserID) (*m.User, error) {
+ slog.ErrorContext(ctx, "GetUser not implemented yet")
+ return nil, nil
+}
+
+func (s Service) DeleteUser(ctx context.Context, id *m.UserID) (*emptypb.Empty, error) {
+ slog.ErrorContext(ctx, "DeleteUser not implemented yet")
+ return nil, nil
+}
+
+func (s Service) UpdateUser(ctx context.Context, u *m.User) (*m.User, error) {
+ slog.ErrorContext(ctx, "UpdateUser not implemented yet")
+ return nil, nil
+}
+
+func (s Service) CreateUser(ctx context.Context, data *m.UserData) (*m.User, error) {
+ slog.ErrorContext(ctx, "CreateUser not implemented yet")
+ return nil, nil
+}
diff --git a/internal/storage/db.go b/internal/storage/db.go
new file mode 100644
index 0000000..06fe31c
--- /dev/null
+++ b/internal/storage/db.go
@@ -0,0 +1,18 @@
+package storage
+
+import (
+ "context"
+ "database/sql"
+ "log/slog"
+
+ "github.com/ChausseBenjamin/rafta/internal/util"
+)
+
+func GetDB(ctx context.Context) *sql.DB {
+ db, ok := ctx.Value(util.DBKey).(*sql.DB)
+ if !ok {
+ slog.Error("Unable to retrieve database from context")
+ return nil
+ }
+ return db
+}
diff --git a/internal/storage/schema.go b/internal/storage/schema.go
new file mode 100644
index 0000000..042c09b
--- /dev/null
+++ b/internal/storage/schema.go
@@ -0,0 +1,38 @@
+package storage
+
+func opts() string {
+ return "?_foreign_keys=on&_journal_mode=WAL"
+}
+
+func schema() string {
+ return `
+CREATE TABLE User (
+ UserID INTEGER PRIMARY KEY AUTOINCREMENT,
+ Name TEXT NOT NULL,
+ Email TEXT NOT NULL UNIQUE
+);
+
+CREATE TABLE Task (
+ TaskID INTEGER PRIMARY KEY AUTOINCREMENT,
+ Title TEXT NOT NULL,
+ Description TEXT NOT NULL,
+ Due DATE NOT NULL,
+ Do DATE NOT NULL,
+ Owner INTEGER NOT NULL,
+ FOREIGN KEY (Owner) REFERENCES User(UserID) ON DELETE CASCADE
+);
+
+CREATE TABLE Tag (
+ TagID INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
+ Name TEXT NOT NULL UNIQUE
+);
+
+CREATE TABLE TaskTag (
+ TaskUUID INTEGER NOT NULL,
+ TagID INTEGER NOT NULL,
+ PRIMARY KEY (TaskUUID, TagID),
+ FOREIGN KEY (TaskUUID) REFERENCES Task(UUID) ON DELETE CASCADE,
+ FOREIGN KEY (TagID) REFERENCES Tag(TagID) ON DELETE CASCADE
+);
+`
+}
diff --git a/internal/storage/setup.go b/internal/storage/setup.go
new file mode 100644
index 0000000..f103b15
--- /dev/null
+++ b/internal/storage/setup.go
@@ -0,0 +1,201 @@
+package storage
+
+import (
+ "database/sql"
+ "errors"
+ "fmt"
+ "log/slog"
+ "os"
+ "strings"
+
+ _ "github.com/mattn/go-sqlite3"
+)
+
+var (
+ ErrIntegrityCheckFailed = errors.New("integrity check failed")
+ ErrForeignKeysDisabled = errors.New("foreign_keys pragma is not enabled")
+ ErrJournalModeInvalid = errors.New("journal_mode is not wal")
+ ErrSchemaMismatch = errors.New("schema does not match expected definition")
+)
+
+// Setup opens the SQLite DB at path, verifies its integrity and schema,
+// and returns the valid DB handle. On any error, it backs up the old file
+// (if it exists) and calls genDB() to initialize a valid schema.
+func Setup(path string) (*sql.DB, error) {
+ _, statErr := os.Stat(path)
+ exists := statErr == nil
+
+ // If file doesn't exist, generate new DB.
+ if !exists {
+ return genDB(path)
+ }
+
+ db, err := sql.Open("sqlite3", path+opts())
+ if err != nil {
+ slog.Error("failed to open DB", "error", err)
+ backupFile(path)
+ return genDB(path)
+ }
+
+ // Integrity check.
+ var integrity string
+ if err = db.QueryRow("PRAGMA integrity_check;").Scan(&integrity); err != nil {
+ slog.Error("integrity check query failed", "error", err)
+ db.Close()
+ backupFile(path)
+ return genDB(path)
+ }
+ if integrity != "ok" {
+ slog.Error("integrity check failed", "error", ErrIntegrityCheckFailed)
+ db.Close()
+ backupFile(path)
+ return genDB(path)
+ }
+
+ // Validate schema and pragmas.
+ if err = validateSchema(db); err != nil {
+ slog.Error("schema validation failed", "error", err)
+ db.Close()
+ backupFile(path)
+ return genDB(path)
+ }
+
+ return db, nil
+}
+
+// validateSchema verifies that required pragmas and table definitions are set.
+func validateSchema(db *sql.DB) error {
+ // Check PRAGMA foreign_keys = on.
+ var fk int
+ if err := db.QueryRow("PRAGMA foreign_keys;").Scan(&fk); err != nil {
+ return err
+ }
+ if fk != 1 {
+ return ErrForeignKeysDisabled
+ }
+
+ // Check PRAGMA journal_mode = wal.
+ var jm string
+ if err := db.QueryRow("PRAGMA journal_mode;").Scan(&jm); err != nil {
+ return err
+ }
+ if strings.ToLower(jm) != "wal" {
+ return ErrJournalModeInvalid
+ }
+
+ // Define required table definitions (as substrings in lower-case).
+ type tableCheck struct {
+ name string
+ substrings []string
+ }
+
+ checks := []tableCheck{
+ {
+ name: "User",
+ substrings: []string{
+ "create table user",
+ "userid", "integer", "primary key", "autoincrement",
+ "name", "text", "not null",
+ "email", "text", "not null", "unique",
+ },
+ },
+ {
+ name: "Task",
+ substrings: []string{
+ "create table task",
+ "taskid", "integer", "primary key", "autoincrement",
+ "title", "not null",
+ "description", "not null",
+ "due", "date", "not null",
+ "do", "date", "not null",
+ "owner", "integer", "not null",
+ "foreign key", "references user",
+ },
+ },
+ {
+ name: "Tag",
+ substrings: []string{
+ "create table tag",
+ "tagid", "integer", "primary key", "autoincrement",
+ "name", "text", "not null", "unique",
+ },
+ },
+ {
+ name: "TaskTag",
+ substrings: []string{
+ "create table tasktag",
+ "taskuuid", "integer", "not null",
+ "tagid", "integer", "not null",
+ "primary key",
+ "foreign key", "references task",
+ "foreign key", "references tag",
+ },
+ },
+ }
+
+ for _, chk := range checks {
+ sqlDef, err := fetchTableSQL(db, chk.name)
+ if err != nil {
+ return fmt.Errorf("failed to fetch definition for table %s: %w", chk.name, err)
+ }
+ lc := strings.ToLower(sqlDef)
+ for _, substr := range chk.substrings {
+ if !strings.Contains(lc, substr) {
+ return fmt.Errorf("%w: table %s missing %q", ErrSchemaMismatch, chk.name, substr)
+ }
+ }
+ }
+
+ return nil
+}
+
+func fetchTableSQL(db *sql.DB, table string) (string, error) {
+ var sqlDef sql.NullString
+ err := db.QueryRow("SELECT sql FROM sqlite_master WHERE type='table' AND name=?", table).Scan(&sqlDef)
+ if err != nil {
+ return "", err
+ }
+ if !sqlDef.Valid {
+ return "", fmt.Errorf("no SQL definition found for table %s", table)
+ }
+ return sqlDef.String, nil
+}
+
+// backupFile renames the existing file by appending a ".bak" suffix.
+func backupFile(path string) {
+ backupPath := path + ".bak"
+ // If backupPath exists, append a timestamp.
+ if _, err := os.Stat(backupPath); err == nil {
+ backupPath = fmt.Sprintf("%s.%d.bak", path, os.Getpid())
+ }
+ if err := os.Rename(path, backupPath); err != nil {
+ slog.Error("failed to backup file", "error", err, "original", path, "backup", backupPath)
+ } else {
+ slog.Info("backed up corrupt DB", "original", path, "backup", backupPath)
+ }
+}
+
+// genDB creates a new database at path with the valid schema.
+func genDB(path string) (*sql.DB, error) {
+ db, err := sql.Open("sqlite3", path)
+ if err != nil {
+ slog.Error("failed to create DB", "error", err)
+ return nil, err
+ }
+
+ // Set pragmas.
+ if _, err := db.Exec("PRAGMA foreign_keys = on; PRAGMA journal_mode = wal;"); err != nil {
+ slog.Error("failed to set pragmas", "error", err)
+ db.Close()
+ return nil, err
+ }
+
+ if _, err := db.Exec(schema()); err != nil {
+ slog.Error("failed to initialize schema", "error", err)
+ db.Close()
+ return nil, err
+ }
+
+ slog.Info("created new blank DB with valid schema", "path", path)
+ return db, nil
+}
diff --git a/internal/tagging/tagging.go b/internal/tagging/tagging.go
new file mode 100644
index 0000000..8e8efe9
--- /dev/null
+++ b/internal/tagging/tagging.go
@@ -0,0 +1,23 @@
+package tagging
+
+import (
+ "context"
+ "log/slog"
+
+ "github.com/ChausseBenjamin/rafta/internal/logging"
+ "github.com/ChausseBenjamin/rafta/internal/util"
+ "github.com/hashicorp/go-uuid"
+ "google.golang.org/grpc"
+)
+
+// gRPC interceptor to tag requests with a unique identifier
+func UnaryInterceptor(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
+ id, err := uuid.GenerateUUID()
+ if err != nil {
+ slog.Error("Unable to generate UUID for request", logging.ErrKey, err)
+ }
+ ctx = context.WithValue(ctx, util.ReqIDKey, id)
+ slog.DebugContext(ctx, "Tagging request with UUID", "value", id)
+
+ return handler(ctx, req)
+}
diff --git a/internal/util/context.go b/internal/util/context.go
new file mode 100644
index 0000000..e7cbab2
--- /dev/null
+++ b/internal/util/context.go
@@ -0,0 +1,8 @@
+package util
+
+type ContextKey uint8
+
+const (
+ DBKey ContextKey = iota
+ ReqIDKey
+)
diff --git a/main.go b/main.go
new file mode 100644
index 0000000..a9032a4
--- /dev/null
+++ b/main.go
@@ -0,0 +1,19 @@
+package main
+
+import (
+ "context"
+ "log/slog"
+ "os"
+
+ "github.com/ChausseBenjamin/rafta/internal/app"
+ "github.com/ChausseBenjamin/rafta/internal/logging"
+)
+
+func main() {
+ cmd := app.Command()
+
+ if err := cmd.Run(context.Background(), os.Args); err != nil {
+ slog.Error("Program quit unexpectedly", logging.ErrKey, err)
+ os.Exit(1)
+ }
+}
diff --git a/resources/local_dev.sh b/resources/local_dev.sh
new file mode 100755
index 0000000..68bb46b
--- /dev/null
+++ b/resources/local_dev.sh
@@ -0,0 +1,19 @@
+#!/bin/sh
+
+env_vars=$(cat << EOF
+ LOG_LEVEL=debug
+ LOG_FORMAT=plain
+ LOG_OUTPUT=stdout
+ LISTEN_PORT=1234
+ DATABASE_PATH=runtime/store.db
+EOF
+)
+
+case "$1" in
+ --get-config)
+ echo "$env_vars"
+ ;;
+ *)
+ clear && env $env_vars go run . $@
+ ;;
+esac
diff --git a/resources/schema.proto b/resources/schema.proto
new file mode 100644
index 0000000..5b34332
--- /dev/null
+++ b/resources/schema.proto
@@ -0,0 +1,79 @@
+syntax = "proto3";
+
+import "protobuf/src/google/protobuf/empty.proto";
+import "protobuf/src/google/protobuf/timestamp.proto";
+
+option go_package = "github.com/ChausseBenjamin/rafta/internal/server/model";
+
+enum TaskState {
+ TASK_UNDEFINED = 0;
+ TASK_PENDING = 1;
+ TASK_IN_PROGRESS = 2;
+ TASK_DONE = 3;
+ TASK_BLOCKED = 4;
+}
+
+message UserID {
+ string uuid = 1;
+}
+
+message UserData {
+ string name = 1;
+ string email = 2;
+ google.protobuf.Timestamp created_on = 3;
+ google.protobuf.Timestamp last_login = 4;
+}
+
+message User {
+ UserID id = 1;
+ UserData data = 2;
+}
+
+message TaskID {
+ string uuid = 1;
+}
+
+message TaskData {
+ string title = 1;
+ Description desc = 2; // markdown
+ uint32 priority = 3;
+ TaskState state = 4;
+ google.protobuf.Timestamp created_on = 5;
+ google.protobuf.Timestamp last_updated = 6;
+ repeated string tags = 7;
+}
+
+message Description {
+ string data = 1;
+}
+
+message Task {
+ TaskID id = 1;
+ TaskData data = 2;
+}
+
+message TaskList {
+ repeated Task tasks = 1;
+}
+
+message UserList {
+ repeated User users = 1;
+}
+
+service Rafta {
+ // Retrieval
+ rpc GetUserTasks(UserID) returns (TaskList);
+ rpc GetAllUsers(google.protobuf.Empty) returns (UserList);
+ rpc GetUser(UserID) returns (User);
+ rpc GetTask(TaskID) returns (Task);
+
+ // Task Manipulation
+ rpc DeleteTask(TaskID) returns (google.protobuf.Empty);
+ rpc UpdateTask(Task) returns (Task);
+ rpc CreateTask(TaskData) returns (Task);
+
+ // User Manipulation
+ rpc DeleteUser(UserID) returns (google.protobuf.Empty);
+ rpc UpdateUser(User) returns (User);
+ rpc CreateUser(UserData) returns (User);
+}
diff --git a/runtime/.gitignore b/runtime/.gitignore
new file mode 100644
index 0000000..d6b7ef3
--- /dev/null
+++ b/runtime/.gitignore
@@ -0,0 +1,2 @@
+*
+!.gitignore