Revision history: change sets + revisions + revert — the undo substrate (#48) (#61)
Build image / build-and-push (push) Successful in 5s
Build image / build-and-push (push) Successful in 5s
Co-authored-by: Steve Dudenhoeffer <[email protected]>
This commit was merged in pull request #61.
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
-- Revision history: the undo substrate (#48).
|
||||
--
|
||||
-- The unit of undo is the OPERATION, not the row. "Empty the garlic bed and
|
||||
-- plant cucumbers" touches one object and many plantings; undoing half of that
|
||||
-- is worse than useless. So a change_set groups the row-level revisions it
|
||||
-- produced, and revert replays their inverses.
|
||||
--
|
||||
-- Two properties this schema is built around:
|
||||
-- * Revert is itself a change set (reverts_id points at its target). History is
|
||||
-- append-only and never rewritten, so an undo can be undone. git revert, not
|
||||
-- git reset.
|
||||
-- * Every revision carries full JSON row snapshots INCLUDING version, which is
|
||||
-- what the revert guard compares against so a later edit is reported as a
|
||||
-- conflict rather than silently clobbered.
|
||||
--
|
||||
-- JSON snapshots rather than a shadow table per entity: the rows are small,
|
||||
-- household scale makes storage a non-issue, and one code path covers all three
|
||||
-- entity types.
|
||||
--
|
||||
-- Deliberate gap: garden DELETION is not revertible. Dropping a garden cascades
|
||||
-- its objects and plantings without the service ever seeing them, and the
|
||||
-- ON DELETE CASCADE below would take the history with it. The history UI says so
|
||||
-- rather than pretending otherwise.
|
||||
CREATE TABLE change_sets (
|
||||
id INTEGER PRIMARY KEY,
|
||||
garden_id INTEGER NOT NULL REFERENCES gardens (id) ON DELETE CASCADE,
|
||||
actor_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||||
source TEXT NOT NULL CHECK (source IN ('ui', 'agent', 'api')),
|
||||
summary TEXT NOT NULL DEFAULT '',
|
||||
agent_run_id TEXT, -- executus run id when source='agent'
|
||||
reverts_id INTEGER REFERENCES change_sets (id), -- set when this change set reverts another
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||
);
|
||||
|
||||
-- The history panel's only query: one garden, newest first.
|
||||
CREATE INDEX idx_change_sets_garden ON change_sets (garden_id, created_at DESC);
|
||||
|
||||
-- "Has this change set been reverted?" — the list marks reverted entries, so the
|
||||
-- lookup by target must not be a scan.
|
||||
CREATE INDEX idx_change_sets_reverts ON change_sets (reverts_id) WHERE reverts_id IS NOT NULL;
|
||||
|
||||
CREATE TABLE revisions (
|
||||
id INTEGER PRIMARY KEY,
|
||||
change_set_id INTEGER NOT NULL REFERENCES change_sets (id) ON DELETE CASCADE,
|
||||
seq INTEGER NOT NULL, -- order within the change set
|
||||
entity_type TEXT NOT NULL CHECK (entity_type IN ('garden', 'object', 'planting')),
|
||||
entity_id INTEGER NOT NULL,
|
||||
op TEXT NOT NULL CHECK (op IN ('create', 'update', 'delete')),
|
||||
before TEXT, -- JSON row snapshot; NULL for create
|
||||
after TEXT -- JSON row snapshot; NULL for delete
|
||||
);
|
||||
|
||||
CREATE INDEX idx_revisions_change_set ON revisions (change_set_id, seq);
|
||||
@@ -61,6 +61,33 @@ func (d *DB) CreateObject(ctx context.Context, o *domain.GardenObject) (*domain.
|
||||
return created, nil
|
||||
}
|
||||
|
||||
// RestoreObject re-inserts a deleted object under its ORIGINAL id, preserving
|
||||
// version and timestamps — the inverse of a delete, for reverting a change set
|
||||
// (#48). SQLite allows an explicit INTEGER PRIMARY KEY insert once the row is
|
||||
// gone; if the id has been taken again the insert fails on the primary key, which
|
||||
// is the correct outcome (the service checks first and reports a conflict).
|
||||
//
|
||||
// Deliberately not routed through objectInsert: that omits id/version/timestamps
|
||||
// because a normal create must let the database assign them.
|
||||
func (d *DB) RestoreObject(ctx context.Context, o *domain.GardenObject) (*domain.GardenObject, error) {
|
||||
restored, err := scanObject(d.sql.QueryRowContext(ctx,
|
||||
`INSERT INTO garden_objects
|
||||
(id, garden_id, kind, name, shape, points, x_cm, y_cm, width_cm, height_cm,
|
||||
rotation_deg, z_index, plantable, color, props, grid_size_cm, snap_to_grid, notes,
|
||||
version, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
|
||||
strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||
RETURNING `+objectColumns,
|
||||
o.ID, o.GardenID, o.Kind, o.Name, o.Shape, o.Points, o.XCM, o.YCM, o.WidthCM, o.HeightCM,
|
||||
o.RotationDeg, o.ZIndex, boolToInt(o.Plantable), o.Color, o.Props,
|
||||
o.GridSizeCM, boolToInt(o.SnapToGrid), o.Notes, o.Version, o.CreatedAt,
|
||||
))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: restore object: %w", err)
|
||||
}
|
||||
return restored, nil
|
||||
}
|
||||
|
||||
// GetObject returns the object with the given id, or domain.ErrNotFound.
|
||||
func (d *DB) GetObject(ctx context.Context, id int64) (*domain.GardenObject, error) {
|
||||
o, err := scanObject(d.sql.QueryRowContext(ctx,
|
||||
|
||||
@@ -71,15 +71,59 @@ func (d *DB) ListActivePlantingsForObject(ctx context.Context, objectID int64) (
|
||||
objectID)
|
||||
}
|
||||
|
||||
// ClearObjectPlantings soft-removes every active plop in an object in one UPDATE
|
||||
// ListPlantingsForObject returns every plop in an object, removed ones included.
|
||||
// Used when an object is deleted: the FK cascades its plantings away without the
|
||||
// service seeing them, so they are snapshotted first or the delete would not be
|
||||
// revertible. Always a non-nil slice.
|
||||
func (d *DB) ListPlantingsForObject(ctx context.Context, objectID int64) ([]domain.Planting, error) {
|
||||
return queryPlantings(ctx, d.sql,
|
||||
`SELECT `+plantingColumns+` FROM plantings WHERE object_id = ? ORDER BY id`,
|
||||
objectID)
|
||||
}
|
||||
|
||||
// RestorePlanting re-inserts a deleted plop under its ORIGINAL id, preserving
|
||||
// version and timestamps — the plop counterpart of RestoreObject, and subject to
|
||||
// the same reasoning. Its parent object must exist again first, or the FK
|
||||
// rejects it; the revert orders object restores ahead of planting restores.
|
||||
func (d *DB) RestorePlanting(ctx context.Context, p *domain.Planting) (*domain.Planting, error) {
|
||||
restored, err := scanPlanting(d.sql.QueryRowContext(ctx,
|
||||
`INSERT INTO plantings
|
||||
(id, object_id, plant_id, x_cm, y_cm, radius_cm, count, label, planted_at, removed_at,
|
||||
version, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
|
||||
strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||
RETURNING `+plantingColumns,
|
||||
p.ID, p.ObjectID, p.PlantID, p.XCM, p.YCM, p.RadiusCM, p.Count, p.Label,
|
||||
p.PlantedAt, p.RemovedAt, p.Version, p.CreatedAt,
|
||||
))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: restore planting: %w", err)
|
||||
}
|
||||
return restored, nil
|
||||
}
|
||||
|
||||
// ClearObjectPlantings soft-removes the given plops of an object in one UPDATE
|
||||
// (sets removed_at=date, bumps version) and returns how many rows it affected.
|
||||
func (d *DB) ClearObjectPlantings(ctx context.Context, objectID int64, date string) (int, error) {
|
||||
//
|
||||
// It takes explicit ids rather than clearing "every active plop" so the caller
|
||||
// can snapshot exactly the rows it is about to change. Clearing by predicate
|
||||
// would let a plop created between the caller's read and this UPDATE be removed
|
||||
// without a revision — cleared, but with no way to undo it.
|
||||
func (d *DB) ClearObjectPlantings(ctx context.Context, objectID int64, date string, ids []int64) (int, error) {
|
||||
if len(ids) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
args := make([]any, 0, len(ids)+2)
|
||||
args = append(args, date, objectID)
|
||||
for _, id := range ids {
|
||||
args = append(args, id)
|
||||
}
|
||||
res, err := d.sql.ExecContext(ctx,
|
||||
`UPDATE plantings
|
||||
SET removed_at = ?, version = version + 1,
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
|
||||
WHERE object_id = ? AND removed_at IS NULL`,
|
||||
date, objectID,
|
||||
WHERE object_id = ? AND removed_at IS NULL AND id IN (`+placeholders(len(ids))+`)`,
|
||||
args...,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("store: clear object plantings: %w", err)
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
|
||||
)
|
||||
|
||||
// changeSetColumns lists change_sets columns in the order scanChangeSet expects.
|
||||
const changeSetColumns = `id, garden_id, actor_id, source, summary, agent_run_id, reverts_id, created_at`
|
||||
|
||||
func scanChangeSet(s scanner) (*domain.ChangeSet, error) {
|
||||
var cs domain.ChangeSet
|
||||
if err := s.Scan(
|
||||
&cs.ID, &cs.GardenID, &cs.ActorID, &cs.Source, &cs.Summary,
|
||||
&cs.AgentRunID, &cs.RevertsID, &cs.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &cs, nil
|
||||
}
|
||||
|
||||
// revisionColumns lists revisions columns in the order scanRevision expects.
|
||||
const revisionColumns = `id, change_set_id, seq, entity_type, entity_id, op, before, after`
|
||||
|
||||
func scanRevision(s scanner) (*domain.Revision, error) {
|
||||
var r domain.Revision
|
||||
if err := s.Scan(
|
||||
&r.ID, &r.ChangeSetID, &r.Seq, &r.EntityType, &r.EntityID, &r.Op, &r.Before, &r.After,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
// WriteChangeSet inserts a change set and all of its revisions in one
|
||||
// transaction, so history never records half an operation. The service buffers
|
||||
// revisions while the operation runs and calls this once it has succeeded —
|
||||
// which is also why an operation that fails partway leaves no change set behind.
|
||||
// seq is assigned from the slice order. Returns the stored change set.
|
||||
func (d *DB) WriteChangeSet(ctx context.Context, cs *domain.ChangeSet, revs []domain.Revision) (*domain.ChangeSet, error) {
|
||||
tx, err := d.sql.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: begin change set: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
created, err := scanChangeSet(tx.QueryRowContext(ctx,
|
||||
`INSERT INTO change_sets (garden_id, actor_id, source, summary, agent_run_id, reverts_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
RETURNING `+changeSetColumns,
|
||||
cs.GardenID, cs.ActorID, cs.Source, cs.Summary, cs.AgentRunID, cs.RevertsID))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: insert change set: %w", err)
|
||||
}
|
||||
|
||||
for i := range revs {
|
||||
r := &revs[i]
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO revisions (change_set_id, seq, entity_type, entity_id, op, before, after)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
created.ID, int64(i+1), r.EntityType, r.EntityID, r.Op, r.Before, r.After,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("store: insert revision: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, fmt.Errorf("store: commit change set: %w", err)
|
||||
}
|
||||
return created, nil
|
||||
}
|
||||
|
||||
// ListChangeSets returns a garden's change sets newest-first, one page at a time.
|
||||
// Each carries its actor's display name, the id of the change set that reverted
|
||||
// it (if any), and per-(entity,op) counts — everything the history list renders,
|
||||
// without a second round-trip per row. Always a non-nil slice.
|
||||
func (d *DB) ListChangeSets(ctx context.Context, gardenID int64, limit, offset int) ([]domain.ChangeSet, error) {
|
||||
// The "was this reverted?" lookup is a scalar subquery, NOT a LEFT JOIN: a
|
||||
// change set can be reverted more than once (undo, redo, undo again), and a
|
||||
// join would then emit one duplicate row per revert and silently corrupt the
|
||||
// page. MIN(id) names the first revert, which is the one worth showing.
|
||||
rows, err := d.sql.QueryContext(ctx,
|
||||
`SELECT `+qualifyColumns("cs", changeSetColumns)+`, u.display_name,
|
||||
(SELECT MIN(r.id) FROM change_sets r WHERE r.reverts_id = cs.id)
|
||||
FROM change_sets cs
|
||||
JOIN users u ON u.id = cs.actor_id
|
||||
WHERE cs.garden_id = ?
|
||||
ORDER BY cs.id DESC
|
||||
LIMIT ? OFFSET ?`,
|
||||
gardenID, limit, offset)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: list change sets: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
sets := []domain.ChangeSet{}
|
||||
ids := []any{}
|
||||
byID := map[int64]int{}
|
||||
for rows.Next() {
|
||||
var cs domain.ChangeSet
|
||||
if err := rows.Scan(
|
||||
&cs.ID, &cs.GardenID, &cs.ActorID, &cs.Source, &cs.Summary,
|
||||
&cs.AgentRunID, &cs.RevertsID, &cs.CreatedAt, &cs.ActorName, &cs.RevertedByID,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("store: scan change set: %w", err)
|
||||
}
|
||||
byID[cs.ID] = len(sets)
|
||||
ids = append(ids, cs.ID)
|
||||
sets = append(sets, cs)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("store: iterate change sets: %w", err)
|
||||
}
|
||||
if len(sets) == 0 {
|
||||
return sets, nil
|
||||
}
|
||||
|
||||
// One grouped query for the whole page rather than N per-row counts.
|
||||
countRows, err := d.sql.QueryContext(ctx,
|
||||
`SELECT change_set_id, entity_type, op, COUNT(*)
|
||||
FROM revisions
|
||||
WHERE change_set_id IN (`+placeholders(len(ids))+`)
|
||||
GROUP BY change_set_id, entity_type, op
|
||||
ORDER BY change_set_id, entity_type, op`,
|
||||
ids...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: count revisions: %w", err)
|
||||
}
|
||||
defer countRows.Close()
|
||||
for countRows.Next() {
|
||||
var csID int64
|
||||
var c domain.ChangeCount
|
||||
if err := countRows.Scan(&csID, &c.EntityType, &c.Op, &c.N); err != nil {
|
||||
return nil, fmt.Errorf("store: scan revision count: %w", err)
|
||||
}
|
||||
if i, ok := byID[csID]; ok {
|
||||
sets[i].Counts = append(sets[i].Counts, c)
|
||||
}
|
||||
}
|
||||
if err := countRows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("store: iterate revision counts: %w", err)
|
||||
}
|
||||
return sets, nil
|
||||
}
|
||||
|
||||
// GetChangeSet returns one change set with its revisions loaded in seq order, or
|
||||
// domain.ErrNotFound.
|
||||
func (d *DB) GetChangeSet(ctx context.Context, id int64) (*domain.ChangeSet, error) {
|
||||
cs, err := scanChangeSet(d.sql.QueryRowContext(ctx,
|
||||
`SELECT `+changeSetColumns+` FROM change_sets WHERE id = ?`, id))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, domain.ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: get change set: %w", err)
|
||||
}
|
||||
|
||||
rows, err := d.sql.QueryContext(ctx,
|
||||
`SELECT `+revisionColumns+` FROM revisions WHERE change_set_id = ? ORDER BY seq`, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: list revisions: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
cs.Revisions = []domain.Revision{}
|
||||
for rows.Next() {
|
||||
r, err := scanRevision(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: scan revision: %w", err)
|
||||
}
|
||||
cs.Revisions = append(cs.Revisions, *r)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("store: iterate revisions: %w", err)
|
||||
}
|
||||
return cs, nil
|
||||
}
|
||||
|
||||
// placeholders returns "?, ?, …" for an IN clause of n values.
|
||||
func placeholders(n int) string {
|
||||
if n <= 0 {
|
||||
return "NULL"
|
||||
}
|
||||
b := make([]byte, 0, n*3)
|
||||
for i := 0; i < n; i++ {
|
||||
if i > 0 {
|
||||
b = append(b, ',', ' ')
|
||||
}
|
||||
b = append(b, '?')
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
Reference in New Issue
Block a user