Initial implementation of mcq — document reading queue
Single-binary service: push raw markdown via REST/gRPC API, read rendered HTML through mobile-friendly web UI. MCIAS auth on all endpoints, SQLite storage, goldmark rendering with GFM and syntax highlighting. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
23
internal/db/db.go
Normal file
23
internal/db/db.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
mcdsldb "git.wntrmute.dev/mc/mcdsl/db"
|
||||
)
|
||||
|
||||
// DB wraps a SQLite database connection.
|
||||
type DB struct {
|
||||
*sql.DB
|
||||
}
|
||||
|
||||
// Open opens (or creates) a SQLite database at the given path with the
|
||||
// standard Metacircular pragmas: WAL mode, foreign keys, busy timeout.
|
||||
func Open(path string) (*DB, error) {
|
||||
sqlDB, err := mcdsldb.Open(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("db: %w", err)
|
||||
}
|
||||
return &DB{sqlDB}, nil
|
||||
}
|
||||
121
internal/db/documents.go
Normal file
121
internal/db/documents.go
Normal file
@@ -0,0 +1,121 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ErrNotFound is returned when a document does not exist.
|
||||
var ErrNotFound = errors.New("db: not found")
|
||||
|
||||
// Document represents a queued document.
|
||||
type Document struct {
|
||||
ID int64 `json:"id"`
|
||||
Slug string `json:"slug"`
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
PushedBy string `json:"pushed_by"`
|
||||
PushedAt string `json:"pushed_at"`
|
||||
Read bool `json:"read"`
|
||||
}
|
||||
|
||||
// ListDocuments returns all documents ordered by most recently pushed.
|
||||
func (d *DB) ListDocuments() ([]Document, error) {
|
||||
rows, err := d.Query(`SELECT id, slug, title, body, pushed_by, pushed_at, read FROM documents ORDER BY pushed_at DESC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var docs []Document
|
||||
for rows.Next() {
|
||||
var doc Document
|
||||
if err := rows.Scan(&doc.ID, &doc.Slug, &doc.Title, &doc.Body, &doc.PushedBy, &doc.PushedAt, &doc.Read); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
docs = append(docs, doc)
|
||||
}
|
||||
return docs, rows.Err()
|
||||
}
|
||||
|
||||
// GetDocument returns a single document by slug.
|
||||
func (d *DB) GetDocument(slug string) (*Document, error) {
|
||||
var doc Document
|
||||
err := d.QueryRow(
|
||||
`SELECT id, slug, title, body, pushed_by, pushed_at, read FROM documents WHERE slug = ?`,
|
||||
slug,
|
||||
).Scan(&doc.ID, &doc.Slug, &doc.Title, &doc.Body, &doc.PushedBy, &doc.PushedAt, &doc.Read)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &doc, nil
|
||||
}
|
||||
|
||||
// PutDocument creates or updates a document by slug (upsert).
|
||||
func (d *DB) PutDocument(slug, title, body, pushedBy string) (*Document, error) {
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
_, err := d.Exec(`
|
||||
INSERT INTO documents (slug, title, body, pushed_by, pushed_at, read)
|
||||
VALUES (?, ?, ?, ?, ?, 0)
|
||||
ON CONFLICT(slug) DO UPDATE SET
|
||||
title = excluded.title,
|
||||
body = excluded.body,
|
||||
pushed_by = excluded.pushed_by,
|
||||
pushed_at = excluded.pushed_at,
|
||||
read = 0`,
|
||||
slug, title, body, pushedBy, now,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return d.GetDocument(slug)
|
||||
}
|
||||
|
||||
// DeleteDocument removes a document by slug.
|
||||
func (d *DB) DeleteDocument(slug string) error {
|
||||
res, err := d.Exec(`DELETE FROM documents WHERE slug = ?`, slug)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarkRead sets the read flag on a document.
|
||||
func (d *DB) MarkRead(slug string) (*Document, error) {
|
||||
return d.setRead(slug, true)
|
||||
}
|
||||
|
||||
// MarkUnread clears the read flag on a document.
|
||||
func (d *DB) MarkUnread(slug string) (*Document, error) {
|
||||
return d.setRead(slug, false)
|
||||
}
|
||||
|
||||
func (d *DB) setRead(slug string, read bool) (*Document, error) {
|
||||
val := 0
|
||||
if read {
|
||||
val = 1
|
||||
}
|
||||
res, err := d.Exec(`UPDATE documents SET read = ? WHERE slug = ?`, val, slug)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if n == 0 {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return d.GetDocument(slug)
|
||||
}
|
||||
145
internal/db/documents_test.go
Normal file
145
internal/db/documents_test.go
Normal file
@@ -0,0 +1,145 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func openTestDB(t *testing.T) *DB {
|
||||
t.Helper()
|
||||
dbPath := filepath.Join(t.TempDir(), "test.db")
|
||||
database, err := Open(dbPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.Migrate(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { database.Close() })
|
||||
return database
|
||||
}
|
||||
|
||||
func TestPutAndGetDocument(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
|
||||
doc, err := db.PutDocument("test-slug", "Test Title", "# Hello", "kyle")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if doc.Slug != "test-slug" {
|
||||
t.Errorf("slug = %q, want %q", doc.Slug, "test-slug")
|
||||
}
|
||||
if doc.Title != "Test Title" {
|
||||
t.Errorf("title = %q, want %q", doc.Title, "Test Title")
|
||||
}
|
||||
if doc.Body != "# Hello" {
|
||||
t.Errorf("body = %q, want %q", doc.Body, "# Hello")
|
||||
}
|
||||
if doc.PushedBy != "kyle" {
|
||||
t.Errorf("pushed_by = %q, want %q", doc.PushedBy, "kyle")
|
||||
}
|
||||
if doc.Read {
|
||||
t.Error("new document should not be read")
|
||||
}
|
||||
|
||||
got, err := db.GetDocument("test-slug")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Title != "Test Title" {
|
||||
t.Errorf("got title = %q, want %q", got.Title, "Test Title")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPutDocumentUpsert(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
|
||||
_, err := db.PutDocument("slug", "V1", "body v1", "alice")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Mark as read.
|
||||
_, err = db.MarkRead("slug")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Upsert — should replace and reset read flag.
|
||||
doc, err := db.PutDocument("slug", "V2", "body v2", "bob")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if doc.Title != "V2" {
|
||||
t.Errorf("title = %q, want V2", doc.Title)
|
||||
}
|
||||
if doc.Body != "body v2" {
|
||||
t.Errorf("body = %q, want body v2", doc.Body)
|
||||
}
|
||||
if doc.PushedBy != "bob" {
|
||||
t.Errorf("pushed_by = %q, want bob", doc.PushedBy)
|
||||
}
|
||||
if doc.Read {
|
||||
t.Error("upsert should reset read flag")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListDocuments(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
|
||||
_, _ = db.PutDocument("a", "A", "body", "user")
|
||||
_, _ = db.PutDocument("b", "B", "body", "user")
|
||||
|
||||
docs, err := db.ListDocuments()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(docs) != 2 {
|
||||
t.Fatalf("got %d docs, want 2", len(docs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteDocument(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
|
||||
_, _ = db.PutDocument("del", "Del", "body", "user")
|
||||
if err := db.DeleteDocument("del"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err := db.GetDocument("del")
|
||||
if err != ErrNotFound {
|
||||
t.Errorf("got err = %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteDocumentNotFound(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
|
||||
err := db.DeleteDocument("nope")
|
||||
if err != ErrNotFound {
|
||||
t.Errorf("got err = %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkReadUnread(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
|
||||
_, _ = db.PutDocument("rw", "RW", "body", "user")
|
||||
|
||||
doc, err := db.MarkRead("rw")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !doc.Read {
|
||||
t.Error("expected read=true after MarkRead")
|
||||
}
|
||||
|
||||
doc, err = db.MarkUnread("rw")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if doc.Read {
|
||||
t.Error("expected read=false after MarkUnread")
|
||||
}
|
||||
}
|
||||
28
internal/db/migrate.go
Normal file
28
internal/db/migrate.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
mcdsldb "git.wntrmute.dev/mc/mcdsl/db"
|
||||
)
|
||||
|
||||
// Migrations is the ordered list of MCQ schema migrations.
|
||||
var Migrations = []mcdsldb.Migration{
|
||||
{
|
||||
Version: 1,
|
||||
Name: "documents",
|
||||
SQL: `
|
||||
CREATE TABLE IF NOT EXISTS documents (
|
||||
id INTEGER PRIMARY KEY,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
title TEXT NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
pushed_by TEXT NOT NULL,
|
||||
pushed_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||
read INTEGER NOT NULL DEFAULT 0
|
||||
);`,
|
||||
},
|
||||
}
|
||||
|
||||
// Migrate applies all pending migrations.
|
||||
func (d *DB) Migrate() error {
|
||||
return mcdsldb.Migrate(d.DB, Migrations)
|
||||
}
|
||||
Reference in New Issue
Block a user