oficina / docs Public

Add doc_snapshots table and the newest-usable-snapshot reader

Task 4: migration 0004_doc_snapshots.sql, the DocSnapshot model, and
Store.newestSnapshot, which returns the highest-seq/highest-id row
for a document whose abi_version matches the running core, or nil
when none is usable. (doc_id, seq) is deliberately not unique — two
concurrent writes at the cadence boundary can produce equivalent
snapshots at the same seq. Nothing writes a snapshot yet; that is
Task 5.
Paul Campbell pushed by paul@keymail.dev 10c6c47aeed59f77355dc78fae795c21fbf1bd24 parent d5e6f13
4 files changed, +169 −0
  • internal/docs/migrations/0004_doc_snapshots.sql +27 −0
  • internal/docs/models.go +23 −0
  • internal/docs/snapshots.go +52 −0
  • internal/docs/snapshots_test.go +67 −0
diff --git a/internal/docs/migrations/0004_doc_snapshots.sql b/internal/docs/migrations/0004_doc_snapshots.sql
new file mode 100644
index 0000000..3f04494
--- /dev/null
+++ b/internal/docs/migrations/0004_doc_snapshots.sql
@@ -0,0 +1,27 @@
+-- doc_snapshots holds one Yjs update per row encoding a document's
+-- entire state as of `seq` (the snapshots design, D2). It is an
+-- accelerator, never the source of truth: doc_updates remains complete
+-- and authoritative, and a row here that cannot be read is discarded in
+-- favour of replaying the log.
+--
+-- (doc_id, seq) is deliberately NOT unique. Two concurrent POSTs at the
+-- cadence boundary can both snapshot the same replayed prefix, and those
+-- rows are equivalent by construction, so a collision is not an error to
+-- raise. "Newest usable" resolves as highest seq, then highest id.
+--
+-- abi_version records what produced the row, which is what makes
+-- discarding a snapshot from an older core implementable rather than
+-- merely promised.
+--
+-- Additive: creates a table, touches no existing row.
+CREATE TABLE IF NOT EXISTS doc_snapshots (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ doc_id INTEGER NOT NULL,
+ seq INTEGER NOT NULL,
+ state BLOB NOT NULL,
+ abi_version INTEGER NOT NULL,
+ created_at DATETIME NOT NULL
+);
+
+CREATE INDEX IF NOT EXISTS idx_doc_snapshots_doc_seq
+ ON doc_snapshots (doc_id, seq DESC, id DESC);
diff --git a/internal/docs/models.go b/internal/docs/models.go
index ada43ba..c95c8d6 100644
--- a/internal/docs/models.go
+++ b/internal/docs/models.go
@@ -93,3 +93,26 @@ type DocUpdate struct {
CreatedAt time.Time `gorm:"column:created_at;not null"`
}
+
+// DocSnapshot is one document's entire state encoded as a single Yjs
+// update, as of Seq. It is an accelerator for replay and for the update
+// endpoint, never the source of truth — doc_updates keeps every row, and
+// a snapshot that cannot be read is discarded in favour of replaying
+// them (the snapshots design, D2).
+//
+// State is plaintext in this table, exactly as DocUpdate.UpdateBin is.
+// Worth one caveat the sealing work should carry: a doc_updates row is a
+// fragment of an edit, while this is the entire document in one blob.
+//
+// ABIVersion records the core that produced State. Without it, "discard
+// a snapshot from an older ABI" is unenforceable prose.
+type DocSnapshot struct {
+ ID int64 `gorm:"primaryKey"`
+ DocID int64 `gorm:"column:doc_id;not null;index"`
+ Seq int64 `gorm:"column:seq;not null"`
+
+ State []byte `gorm:"column:state;not null"`
+ ABIVersion uint32 `gorm:"column:abi_version;not null"`
+
+ CreatedAt time.Time `gorm:"column:created_at;not null"`
+}
diff --git a/internal/docs/snapshots.go b/internal/docs/snapshots.go
new file mode 100644
index 0000000..1544651
--- /dev/null
+++ b/internal/docs/snapshots.go
@@ -0,0 +1,52 @@
+package docs
+
+import (
+ "context"
+ "errors"
+ "fmt"
+
+ "gorm.io/gorm"
+
+ "github.com/carlosframework/docs/internal/core"
+)
+
+// coreABIVersion is core.ABIVersion, aliased here so this package's own
+// snapshot code and tests read as docs code rather than reaching into
+// internal/core at every call site. It is the same constant, not a
+// second number that could drift from it.
+const coreABIVersion = core.ABIVersion
+
+// snapshotCadence is how many updates may accumulate past a snapshot
+// before the next one is taken. 256 keeps a replay to a snapshot plus a
+// short tail, which is what holds the retained-update count in a
+// core.Handle clear of maxRetainedUpdates (4096) — about 16x of headroom.
+//
+// That headroom is conditional, and the condition is worth knowing:
+// it holds while snapshot writes keep succeeding. Roughly fifteen
+// consecutive failures — each logged at Error, so noisy rather than
+// silent — walk the tail back to the cap.
+const snapshotCadence = 256
+
+// newestSnapshot returns the most recent usable snapshot for docID, or
+// nil when there is none.
+//
+// "Usable" excludes rows written by a different core ABI: the state
+// encoding is that core's business, and a row from another one is
+// discarded rather than trusted. Ordering is seq DESC, id DESC — two
+// concurrent POSTs at the cadence boundary may legitimately write
+// equivalent snapshots at the same seq, so the tie resolves to the last
+// row written rather than being treated as an error.
+func (s *Store) newestSnapshot(ctx context.Context, docID int64) (*DocSnapshot, error) {
+ var row DocSnapshot
+ err := s.db.WithContext(ctx).
+ Where("doc_id = ? AND abi_version = ?", docID, coreABIVersion).
+ Order("seq DESC, id DESC").
+ First(&row).Error
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return nil, nil
+ }
+ if err != nil {
+ return nil, fmt.Errorf("docs: newest snapshot for document %d: %w", docID, err)
+ }
+ return &row, nil
+}
diff --git a/internal/docs/snapshots_test.go b/internal/docs/snapshots_test.go
new file mode 100644
index 0000000..a985606
--- /dev/null
+++ b/internal/docs/snapshots_test.go
@@ -0,0 +1,67 @@
+package docs
+
+import (
+ "context"
+ "testing"
+ "time"
+)
+
+func TestNewestSnapshotPrefersHighestSeqThenHighestID(t *testing.T) {
+ app, err := NewForTest(t)
+ if err != nil {
+ t.Fatalf("NewForTest: %v", err)
+ }
+ ctx := context.Background()
+ owner := app.TestPerson(t, "owner@example.com")
+ doc, _ := app.Store().Create(ctx, owner, "Snapshots")
+
+ write := func(seq int64, state string, abi uint32) {
+ row := &DocSnapshot{DocID: doc.ID, Seq: seq, State: []byte(state), ABIVersion: abi, CreatedAt: time.Now().UTC()}
+ if err := app.Store().db.WithContext(ctx).Create(row).Error; err != nil {
+ t.Fatalf("insert snapshot: %v", err)
+ }
+ }
+ write(10, "older", coreABIVersion)
+ write(20, "first-at-20", coreABIVersion)
+ write(20, "second-at-20", coreABIVersion)
+
+ got, err := app.Store().newestSnapshot(ctx, doc.ID)
+ if err != nil {
+ t.Fatalf("newestSnapshot: %v", err)
+ }
+ if got == nil || string(got.State) != "second-at-20" {
+ t.Fatalf("newestSnapshot returned %+v, want the second row at seq 20", got)
+ }
+}
+
+func TestNewestSnapshotIgnoresAnotherABIsRows(t *testing.T) {
+ app, _ := NewForTest(t)
+ ctx := context.Background()
+ owner := app.TestPerson(t, "owner@example.com")
+ doc, _ := app.Store().Create(ctx, owner, "Old ABI")
+
+ row := &DocSnapshot{DocID: doc.ID, Seq: 5, State: []byte("from an older core"), ABIVersion: coreABIVersion - 1, CreatedAt: time.Now().UTC()}
+ if err := app.Store().db.WithContext(ctx).Create(row).Error; err != nil {
+ t.Fatalf("insert: %v", err)
+ }
+
+ got, err := app.Store().newestSnapshot(ctx, doc.ID)
+ if err != nil {
+ t.Fatalf("newestSnapshot: %v", err)
+ }
+ if got != nil {
+ t.Error("a snapshot from another ABI was treated as usable")
+ }
+}
+
+func TestNewestSnapshotIsNilWhenThereAreNone(t *testing.T) {
+ app, _ := NewForTest(t)
+ ctx := context.Background()
+ owner := app.TestPerson(t, "owner@example.com")
+ doc, _ := app.Store().Create(ctx, owner, "None")
+
+ got, err := app.Store().newestSnapshot(ctx, doc.ID)
+ if err != nil || got != nil {
+ t.Errorf("newestSnapshot = (%v, %v), want (nil, nil)", got, err)
+ }
+}