Merge pull request #7 from KristianJBorgwarth/fix/uow
fix(uow): added filestore for file/db atomicity
This commit is contained in:
commit
81ba03bb3d
9 changed files with 247 additions and 122 deletions
|
|
@ -1,2 +0,0 @@
|
||||||
// Package files provides utilities for working with files and directories.
|
|
||||||
package files
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
package files
|
|
||||||
|
|
||||||
import "os"
|
|
||||||
|
|
||||||
func WriteToFile(path string, data []byte) (filePath string, err error) {
|
|
||||||
if checkIfFileExists(path) {
|
|
||||||
return path, nil
|
|
||||||
}
|
|
||||||
err = os.WriteFile(path, data, 0o644)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return path, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func checkIfFileExists(path string) bool {
|
|
||||||
_, err := os.Stat(path)
|
|
||||||
return err == nil
|
|
||||||
}
|
|
||||||
|
|
@ -2,11 +2,10 @@ package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"os"
|
|
||||||
|
|
||||||
"github.com/KristianJBorgwarth/dendrite.daemon/core/frontmatter"
|
"github.com/KristianJBorgwarth/dendrite.daemon/core/frontmatter"
|
||||||
|
"github.com/KristianJBorgwarth/dendrite.daemon/core/template"
|
||||||
"github.com/KristianJBorgwarth/dendrite.daemon/persistence/repositories"
|
"github.com/KristianJBorgwarth/dendrite.daemon/persistence/repositories"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -25,51 +24,48 @@ func NewCreateNoteHandler(uow *repositories.UnitOfWork) *CreateNoteHandler {
|
||||||
return &CreateNoteHandler{uow: uow}
|
return &CreateNoteHandler{uow: uow}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h CreateNoteHandler) Handle(ctx context.Context, raw json.RawMessage) (any, error) {
|
func (h *CreateNoteHandler) Handle(ctx context.Context, raw json.RawMessage) (any, error) {
|
||||||
var cmd createNoteCommand
|
var cmd createNoteCommand
|
||||||
|
|
||||||
if err := json.Unmarshal(raw, &cmd); err != nil {
|
if err := json.Unmarshal(raw, &cmd); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
data, err := h.getTemplate(cmd.TemplatePath)
|
data, err := template.GenerateTemplate(cmd.TemplatePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if data == nil {
|
||||||
|
data = []byte("---\ntitle: " + cmd.Title + "\ntags: []\n---\n")
|
||||||
|
}
|
||||||
|
|
||||||
tags, err := frontmatter.ParseTags(data)
|
tags, err := frontmatter.ParseTags(data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
err = h.uow.Execute(ctx, func(tx *sql.Tx) error {
|
tx, err := h.uow.Begin()
|
||||||
tagRepo := repositories.NewTagRepository(tx)
|
|
||||||
noteRepo := repositories.NewNoteRepository(tx)
|
|
||||||
|
|
||||||
if err = tagRepo.Upsert(ctx, tags); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if err = noteRepo.Upsert(ctx, cmd.Title, cmd.Path, frontmatter.Slugify(cmd.Title)); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
defer h.uow.Rollback()
|
||||||
|
|
||||||
|
tagRepo := repositories.NewTagRepository(tx)
|
||||||
|
noteRepo := repositories.NewNoteRepository(tx)
|
||||||
|
|
||||||
|
if err = tagRepo.Upsert(ctx, tags); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = noteRepo.Upsert(ctx, cmd.Title, cmd.Path, frontmatter.Slugify(cmd.Title)); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
h.uow.FileStore.Stage(cmd.Path, data)
|
||||||
|
|
||||||
|
if err = h.uow.Commit(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
return cmd.Path, nil
|
return cmd.Path, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *CreateNoteHandler) getTemplate(templatePath string) ([]byte, error) {
|
|
||||||
if templatePath == "" {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
data, err := os.ReadFile(templatePath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return data, nil
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package repositories
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"database/sql"
|
||||||
)
|
)
|
||||||
|
|
||||||
type NoteRepository interface {
|
type NoteRepository interface {
|
||||||
|
|
@ -9,21 +10,21 @@ type NoteRepository interface {
|
||||||
}
|
}
|
||||||
|
|
||||||
type noteRepository struct {
|
type noteRepository struct {
|
||||||
dbCtx DBContext
|
Transaction *sql.Tx
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewNoteRepository(dbContext DBContext) NoteRepository {
|
func NewNoteRepository(tx *sql.Tx) NoteRepository {
|
||||||
return ¬eRepository{dbCtx: dbContext}
|
return ¬eRepository{Transaction: tx}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *noteRepository) Upsert(ctx context.Context, title string, path string, slug string) error {
|
func (r *noteRepository) Upsert(ctx context.Context, title string, path string, slug string) error {
|
||||||
query := `
|
query := `
|
||||||
INSERT INTO notes (title, path, slug)
|
INSERT INTO notes (title, path, slug, created_at, updated_at)
|
||||||
VALUES ($1, $2, $3)
|
VALUES (?, ?, ?, datetime('now'), datetime('now'))
|
||||||
ON CONFLICT (slug) DO UPDATE
|
ON CONFLICT (slug) DO UPDATE
|
||||||
SET title = EXCLUDED.title,
|
SET title = EXCLUDED.title,
|
||||||
path = EXCLUDED.path;
|
path = EXCLUDED.path;
|
||||||
`
|
`
|
||||||
_, err := r.dbCtx.ExecContext(ctx, query, title, path, slug)
|
_, err := r.Transaction.ExecContext(ctx, query, title, path, slug)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package repositories
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"database/sql"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -10,11 +11,11 @@ type TagRepository interface {
|
||||||
}
|
}
|
||||||
|
|
||||||
type tagRepository struct {
|
type tagRepository struct {
|
||||||
dbContext DBContext
|
Transaction *sql.Tx
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewTagRepository(dbContext DBContext) TagRepository {
|
func NewTagRepository(tx *sql.Tx) TagRepository {
|
||||||
return &tagRepository{dbContext: dbContext}
|
return &tagRepository{Transaction: tx}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *tagRepository) Upsert(ctx context.Context, names []string) error {
|
func (r *tagRepository) Upsert(ctx context.Context, names []string) error {
|
||||||
|
|
@ -30,18 +31,10 @@ func (r *tagRepository) Upsert(ctx context.Context, names []string) error {
|
||||||
args = append(args, name)
|
args = append(args, name)
|
||||||
}
|
}
|
||||||
|
|
||||||
query := "WITH input(name) AS (VALUES " +
|
query := "INSERT OR IGNORE INTO tags(name) VALUES " + strings.Join(placeholders, ",")
|
||||||
strings.Join(placeholders, ",") +
|
|
||||||
") INSERT INTO tags(name) " +
|
|
||||||
"SELECT name FROM input " +
|
|
||||||
"ON CONFLICT(name) DO NOTHING;"
|
|
||||||
|
|
||||||
_, err := r.dbContext.ExecContext(ctx, query, args)
|
_, err := r.Transaction.ExecContext(ctx, query, args...)
|
||||||
if err != nil {
|
return err
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *tagRepository) UpsertNoteTags(noteID int64, tagIDs []int64) error {
|
func (r *tagRepository) UpsertNoteTags(noteID int64, tagIDs []int64) error {
|
||||||
|
|
@ -64,7 +57,7 @@ func (r *tagRepository) UpsertNoteTags(noteID int64, tagIDs []int64) error {
|
||||||
"ON CONFLICT(note_id, tag_id) DO NOTHING" +
|
"ON CONFLICT(note_id, tag_id) DO NOTHING" +
|
||||||
"SELECT note_id, tag_id FROM input;"
|
"SELECT note_id, tag_id FROM input;"
|
||||||
|
|
||||||
_, err := r.dbContext.ExecContext(context.Background(), query, args)
|
_, err := r.Transaction.ExecContext(context.Background(), query, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,40 +1,49 @@
|
||||||
package repositories
|
package repositories
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
|
||||||
|
"github.com/KristianJBorgwarth/dendrite.daemon/persistence/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
type DBContext interface {
|
|
||||||
ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
|
|
||||||
QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
|
|
||||||
QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
|
|
||||||
}
|
|
||||||
|
|
||||||
type UnitOfWork struct {
|
type UnitOfWork struct {
|
||||||
db *sql.DB
|
db *sql.DB
|
||||||
|
Transaction *sql.Tx
|
||||||
|
FileStore *store.FileStore
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewUnitOfWork(db *sql.DB) *UnitOfWork {
|
func NewUnitOfWork(db *sql.DB) *UnitOfWork {
|
||||||
return &UnitOfWork{db: db}
|
return &UnitOfWork{db: db, FileStore: store.NewFileStore()}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *UnitOfWork) Execute(ctx context.Context, fn func(tx *sql.Tx) error) (err error) {
|
func (u *UnitOfWork) Begin() (tx *sql.Tx, err error) {
|
||||||
transcation, err := u.db.BeginTx(ctx, nil)
|
tx, err = u.db.Begin()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
u.Transaction = tx
|
||||||
defer func() {
|
return tx, nil
|
||||||
if err != nil {
|
}
|
||||||
_ = transcation.Rollback()
|
|
||||||
}
|
func (u *UnitOfWork) Commit() error {
|
||||||
}()
|
if err := u.FileStore.Flush(); err != nil {
|
||||||
|
u.Transaction.Rollback()
|
||||||
if err = fn(transcation); err != nil {
|
u.Transaction = nil
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := u.Transaction.Commit(); err != nil {
|
||||||
err = transcation.Commit()
|
u.FileStore.Rollback()
|
||||||
return err
|
u.Transaction = nil
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
u.Transaction = nil
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *UnitOfWork) Rollback() {
|
||||||
|
if u.Transaction == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
u.Transaction.Rollback()
|
||||||
|
u.FileStore.Rollback()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
2
persistence/store/doc.go
Normal file
2
persistence/store/doc.go
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
// Package store provides access to any in memory stores related to files and index
|
||||||
|
package store
|
||||||
50
persistence/store/file_store.go
Normal file
50
persistence/store/file_store.go
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
package store
|
||||||
|
|
||||||
|
import "os"
|
||||||
|
|
||||||
|
type FileStore struct {
|
||||||
|
staged []stagedFile
|
||||||
|
committed []string
|
||||||
|
}
|
||||||
|
|
||||||
|
type stagedFile struct {
|
||||||
|
path string
|
||||||
|
data []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewFileStore() *FileStore {
|
||||||
|
return &FileStore{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fs *FileStore) Stage(path string, data []byte) {
|
||||||
|
fs.staged = append(fs.staged, stagedFile{path: path, data: data})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fs *FileStore) Flush() error {
|
||||||
|
for _, file := range fs.staged {
|
||||||
|
if fs.fileExists(file.path) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(file.path, file.data, 0o644); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fs.committed = append(fs.committed, file.path)
|
||||||
|
}
|
||||||
|
fs.staged = nil
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fs *FileStore) Rollback() error {
|
||||||
|
for _, path := range fs.committed {
|
||||||
|
if err := os.Remove(path); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fs.committed = nil
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fs *FileStore) fileExists(path string) bool {
|
||||||
|
_, err := os.Stat(path)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
@ -2,42 +2,137 @@ package integration_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/KristianJBorgwarth/dendrite.daemon/core/handlers"
|
"github.com/KristianJBorgwarth/dendrite.daemon/core/handlers"
|
||||||
"github.com/KristianJBorgwarth/dendrite.daemon/persistence/repositories"
|
"github.com/KristianJBorgwarth/dendrite.daemon/persistence/repositories"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestCreateNoteHandlerOnSucess(t *testing.T) {
|
func newCreateNoteHandler() *handlers.CreateNoteHandler {
|
||||||
// Arrange
|
|
||||||
uow := repositories.NewUnitOfWork(Fixture.DB)
|
uow := repositories.NewUnitOfWork(Fixture.DB)
|
||||||
|
return handlers.NewCreateNoteHandler(uow)
|
||||||
|
}
|
||||||
|
|
||||||
handler := handlers.NewCreateNoteHandler(uow)
|
func TestCreateNoteHandler_NoTemplate_CreatesNoteFileAndReturnsPath(t *testing.T) {
|
||||||
|
// Arrange
|
||||||
requestParams := struct {
|
handler := newCreateNoteHandler()
|
||||||
Title string `json:"title"`
|
notePath := filepath.Join(t.TempDir(), "my-note.md")
|
||||||
Content string `json:"content"`
|
params, _ := json.Marshal(map[string]any{
|
||||||
}{
|
"title": "My Note",
|
||||||
Title: "Test Note",
|
"path": notePath,
|
||||||
Content: "This is a test note.",
|
})
|
||||||
}
|
|
||||||
|
|
||||||
requestParamsBytes, err := json.Marshal(requestParams)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to marshal request params: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
request := CreateTestRequest("createNote", 1, requestParamsBytes)
|
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
response, err := handler.Handle(Fixture.TestContext, request.Params)
|
result, err := handler.Handle(Fixture.TestContext, params)
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
if err != nil {
|
require.NoError(t, err)
|
||||||
t.Fatalf("handler returned an error: %v", err)
|
assert.Equal(t, notePath, result)
|
||||||
}
|
|
||||||
|
|
||||||
if response != nil {
|
var count int
|
||||||
t.Fatalf("expected response to be nil, got: %v", response)
|
require.NoError(t, Fixture.DB.QueryRow(`SELECT COUNT(*) FROM notes WHERE slug = ?`, "my-note").Scan(&count))
|
||||||
}
|
assert.Equal(t, 1, count)
|
||||||
|
|
||||||
|
_, statErr := os.Stat(notePath)
|
||||||
|
assert.NoError(t, statErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateNoteHandler_WithTemplate_CreatesNoteFileWithTagsAndReturnsPath(t *testing.T) {
|
||||||
|
// Arrange
|
||||||
|
handler := newCreateNoteHandler()
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
templatePath := filepath.Join(dir, "template.md")
|
||||||
|
require.NoError(t, os.WriteFile(templatePath, []byte("---\ntitle: Template\ntags: [go, testing]\n---\n"), 0o644))
|
||||||
|
|
||||||
|
notePath := filepath.Join(dir, "templated-note.md")
|
||||||
|
params, _ := json.Marshal(map[string]any{
|
||||||
|
"title": "Templated Note",
|
||||||
|
"path": notePath,
|
||||||
|
"templatePath": templatePath,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Act
|
||||||
|
result, err := handler.Handle(Fixture.TestContext, params)
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, notePath, result)
|
||||||
|
|
||||||
|
var noteCount int
|
||||||
|
require.NoError(t, Fixture.DB.QueryRow(`SELECT COUNT(*) FROM notes WHERE slug = ?`, "templated-note").Scan(¬eCount))
|
||||||
|
assert.Equal(t, 1, noteCount)
|
||||||
|
|
||||||
|
rows, err := Fixture.DB.Query(`SELECT name FROM tags WHERE name IN ('go', 'testing')`)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var tags []string
|
||||||
|
for rows.Next() {
|
||||||
|
var name string
|
||||||
|
require.NoError(t, rows.Scan(&name))
|
||||||
|
tags = append(tags, name)
|
||||||
|
}
|
||||||
|
assert.Len(t, tags, 2)
|
||||||
|
|
||||||
|
_, statErr := os.Stat(notePath)
|
||||||
|
assert.NoError(t, statErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateNoteHandler_DuplicateSlug_Upserts(t *testing.T) {
|
||||||
|
// Arrange
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
path1 := filepath.Join(dir, "dup-note.md")
|
||||||
|
params1, _ := json.Marshal(map[string]any{"title": "Dup Note", "path": path1})
|
||||||
|
_, err := newCreateNoteHandler().Handle(Fixture.TestContext, params1)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
path2 := filepath.Join(dir, "dup-note-moved.md")
|
||||||
|
params2, _ := json.Marshal(map[string]any{"title": "Dup Note", "path": path2})
|
||||||
|
|
||||||
|
// Act
|
||||||
|
_, err = newCreateNoteHandler().Handle(Fixture.TestContext, params2)
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var count int
|
||||||
|
require.NoError(t, Fixture.DB.QueryRow(`SELECT COUNT(*) FROM notes WHERE slug = ?`, "dup-note").Scan(&count))
|
||||||
|
assert.Equal(t, 1, count)
|
||||||
|
|
||||||
|
var path string
|
||||||
|
require.NoError(t, Fixture.DB.QueryRow(`SELECT path FROM notes WHERE slug = ?`, "dup-note").Scan(&path))
|
||||||
|
assert.Equal(t, path2, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateNoteHandler_InvalidJSON_ReturnsError(t *testing.T) {
|
||||||
|
// Arrange
|
||||||
|
handler := newCreateNoteHandler()
|
||||||
|
|
||||||
|
// Act
|
||||||
|
_, err := handler.Handle(Fixture.TestContext, json.RawMessage(`{invalid json}`))
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateNoteHandler_NonExistentTemplatePath_ReturnsError(t *testing.T) {
|
||||||
|
// Arrange
|
||||||
|
handler := newCreateNoteHandler()
|
||||||
|
params, _ := json.Marshal(map[string]any{
|
||||||
|
"title": "Ghost Note",
|
||||||
|
"path": filepath.Join(t.TempDir(), "ghost.md"),
|
||||||
|
"templatePath": "/non/existent/template.md",
|
||||||
|
})
|
||||||
|
|
||||||
|
// Act
|
||||||
|
_, err := handler.Handle(Fixture.TestContext, params)
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
assert.Error(t, err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue