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.
4 files changed,
+169
−0
internal/docs/migrations/0004_doc_snapshots.sql+27 −0internal/docs/models.go+23 −0internal/docs/snapshots.go+52 −0internal/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) |
| + } |
| +} |