Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .agents/skills/hyperindex/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -259,7 +259,7 @@ Use typed collection queries when the caller needs collection-specific filters,

## Record version history

For collections the deployment tracks, `recordHistory(uri: "at://…")` returns the observed versions of one record, oldest first, 100 per page by default (`first` up to 500; pass the last `id` as `after` for more). `value` holds the record body, or null for a delete. Use it to show how a record changed, for example who renamed an observation:
For collections the deployment tracks, `recordHistory(uri: "at://…")` returns the observed versions of one record, oldest first, 100 per page by default (`first` up to 500; pass the last `id` as `after` for more). `value` holds the record body. Deleted records (and records of deleted accounts) have no history. Use it to show how a record changed, for example who renamed an observation:

```graphql
{
Expand Down
8 changes: 4 additions & 4 deletions .agents/skills/hyperindex/references/schema-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -349,21 +349,21 @@ External label predicates bound by the containing filter field.

## Record version history

`recordHistory(uri: String!, first: Int = 100, after: String)` returns `[RecordVersion!]!`, oldest first. `first` must be 1 to 500; for the next page pass the last version's `id` as `after`. Versions are recorded for collections the deployment tracks (`RECORD_HISTORY_COLLECTIONS`; production tracks `app.gainforest.dwc.occurrence`); records that were never tracked return an empty list, and history recorded before a collection was removed stays queryable. A `baseline` version is what was indexed when history was switched on; earlier edits are not available.
`recordHistory(uri: String!, first: Int = 100, after: String)` returns `[RecordVersion!]!`, oldest first. `first` must be 1 to 500; for the next page pass the last version's `id` as `after`. Versions are recorded for collections the deployment tracks (`RECORD_HISTORY_COLLECTIONS`; production tracks `app.gainforest.dwc.occurrence`); records that were never tracked return an empty list, and history recorded before a collection was removed stays queryable. A `baseline` version is what was indexed when history was switched on; earlier edits are not available. Deleting a record, or deleting, deactivating or taking down its account, removes its history.

### `RecordVersion`

| Field | Type | Description |
| --- | --- | --- |
| `action` | `String!` | `baseline`, `create`, `update`, or `delete`. |
| `cid` | `String!` | CID of this version (for deletes, the version that was deleted). |
| `action` | `String!` | `baseline`, `create`, or `update`. |
| `cid` | `String!` | CID of this version. |
| `collection` | `String!` | Collection NSID. |
| `did` | `String!` | Repository DID. |
| `id` | `String!` | Monotonic version id; later versions have larger ids. |
| `live` | `Boolean` | True when seen on the live stream, false for a resync delivery, null for baseline rows. |
| `observedAt` | `String!` | When the indexer observed this version (RFC 3339). |
| `uri` | `String!` | Record AT-URI. |
| `value` | `JSON` | The record body at this version; null for deletes. |
| `value` | `JSON` | The record body at this version. |

## External label support

Expand Down
3 changes: 1 addition & 2 deletions .changes/unreleased/add-record-version-history.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
kind: added
body: Add opt-in record version history. Collections listed in RECORD_HISTORY_COLLECTIONS (exact NSIDs or prefix.* patterns, Tap mode) keep every observed version and delete in a new record_version table, seeded on startup with a baseline row per existing record, and the new root recordHistory(uri, first, after) GraphQL query pages through them oldest first. History is off by default; migration 015 adds the table.
time: 2026-09-23T03:10:00.000000+02:00
body: Add opt-in record version history. Collections listed in RECORD_HISTORY_COLLECTIONS (exact NSIDs or prefix.* patterns, Tap mode) keep every observed version in a new record_version table (removed again when the record or its account is deleted), seeded on startup with a baseline row per existing record, and the new root recordHistory(uri, first, after) GraphQL query pages through them oldest first. History is off by default; migrations 015 and 016 add the table and its indexes.
custom:
Affects: operator
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ Run verification based on what changed.
- `SECRET_KEY_BASE` must be at least 64 characters.
- `TAP_ENABLED=true` switches record ingestion to Tap mode. After Tap becomes healthy, Hyperindex runs one bounded background pass that fills missing `actor.handle` values from Tap's local `/info/:did` metadata without delaying API startup.
- `LABELER_SUBSCRIBE_ENABLED=true` with `LABELER_SUBSCRIBE_URLS` starts optional external `com.atproto.label.subscribeLabels` ingestion.
- `RECORD_HISTORY_COLLECTIONS` (Tap mode) keeps every version of matching records in `record_version`, exposed by the root `recordHistory(uri)` query. Startup seeds a `baseline` row for existing records that have no history yet; the version is written before the current-state upsert so Tap redelivery keeps both consistent.
- `RECORD_HISTORY_COLLECTIONS` (Tap mode) keeps every version of matching records in `record_version`, exposed by the root `recordHistory(uri)` query. Startup seeds a `baseline` row for existing records that have no history yet; the version is written before the current-state upsert so Tap redelivery keeps both consistent. Record deletes and account purges delete history too (always, even for collections no longer tracked).
- Migrations run automatically on startup.
- Backend container images bundle published helper Lexicons from `lexicons/`, pinned by `lexicons.json`, and set `LEXICON_DIR=/app/lexicons`. Database Lexicons override bundled documents with the same NSID.
- Be careful with `ALLOWED_ORIGINS`: current code allows all origins when unset, even if older prose suggests stricter defaults.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ TAP_SIGNAL_COLLECTION=app.bsky.feed.post docker compose -f docker-compose.tap.ym
| `TAP_COLLECTION_FILTERS` | Comma-separated collection NSIDs for Tap sidecar record filtering; set independently from legacy `JETSTREAM_COLLECTIONS` | *(empty)* |
| `RECORD_HISTORY_COLLECTIONS` | Comma-separated collection NSIDs or `prefix.*` patterns whose versions are kept in `record_version` and served, paged, by the `recordHistory` GraphQL query (Tap mode only) | *(empty: no history)* |

**Record version history.** Records in collections listed in `RECORD_HISTORY_COLLECTIONS` keep an append-only history: one row per distinct version (CID) the indexer observes, plus delete tombstones. On startup, every existing record in a newly listed collection gets a `baseline` row holding its current version, so history starts from what was indexed when it was switched on. Earlier versions are not recoverable because repositories only keep the latest one. Query it with `recordHistory(uri: "at://…", first: 100, after: "<id>")`, one page of up to 500 versions at a time, oldest first; pass the last version's `id` as `after` for the next page. Versions are told apart by CID (or a hash of the body when Tap sends none), so repeats are stored once. Removing a collection from the variable stops recording; history already stored stays queryable.
**Record version history.** Records in collections listed in `RECORD_HISTORY_COLLECTIONS` keep an append-only history: one row per distinct version (CID) the indexer observes. Deleting a record removes its history, and a deleted, deactivated or taken-down account loses all of its history, just as its current records are purged. On startup, every existing record in a newly listed collection gets a `baseline` row holding its current version, so history starts from what was indexed when it was switched on. Earlier versions are not recoverable because repositories only keep the latest one. Query it with `recordHistory(uri: "at://…", first: 100, after: "<id>")`, one page of up to 500 versions at a time, oldest first; pass the last version's `id` as `after` for the next page. Versions are told apart by CID (or a hash of the body when Tap sends none), so repeats are stored once. Removing a collection from the variable stops recording; history already stored stays queryable.

Tap Docker deployments also require `ADMIN_API_KEY` in `.env` because Hyperindex requires admin authentication at startup. `TAP_COLLECTION_FILTERS` is read by the Tap sidecar only; legacy `JETSTREAM_COLLECTIONS` remains part of Jetstream mode and is not used as a Tap filtering fallback.

Expand Down
79 changes: 74 additions & 5 deletions internal/database/migrations/migrations_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -298,7 +298,7 @@ func TestMigrations_RunPostgresRenamesIndexingActivity(t *testing.T) {
}
assertPostgresIndexNotExists(ctx, t, exec, schemaName, "idx_record_json_gin")

for _, version := range []string{"015", "014", "013", "012", "011"} {
for _, version := range []string{"016", "015", "014", "013", "012", "011"} {
if err := migrations.Rollback(ctx, exec); err != nil {
t.Fatalf("Rollback(%s) returned error: %v", version, err)
}
Expand Down Expand Up @@ -491,12 +491,14 @@ func TestMigrations_Rollback(t *testing.T) {
}
}

// rollbackRecordVersionMigration undoes 015 so tests written against 014 as the
// newest migration keep exercising 014 and 013.
// rollbackRecordVersionMigration undoes 016 and 015 so tests written against
// 014 as the newest migration keep exercising 014 and 013.
func rollbackRecordVersionMigration(ctx context.Context, t *testing.T, exec *sqlite.Executor) {
t.Helper()
if err := migrations.Rollback(ctx, exec); err != nil {
t.Fatalf("Rollback(015) error = %v", err)
for _, version := range []string{"016", "015"} {
if err := migrations.Rollback(ctx, exec); err != nil {
t.Fatalf("Rollback(%s) error = %v", version, err)
}
}
}

Expand All @@ -509,6 +511,9 @@ func TestMigrations_RecordVersionUpAndDown(t *testing.T) {
if !sqliteIndexExists(t, exec, "idx_record_version_uri_key") || !migrationVersionExists(t, exec, "015") {
t.Fatal("migration 015 did not create record_version")
}
if !sqliteIndexExists(t, exec, "idx_record_version_did") || !migrationVersionExists(t, exec, "016") {
t.Fatal("migration 016 did not create the record_version did index")
}
rollbackRecordVersionMigration(ctx, t, exec)
if sqliteIndexExists(t, exec, "idx_record_version_uri_key") || migrationVersionExists(t, exec, "015") {
t.Fatal("migration 015 schema/version remained after rollback")
Expand Down Expand Up @@ -898,3 +903,67 @@ func assertPostgresSequenceExists(ctx context.Context, t *testing.T, exec *postg
t.Errorf("postgres sequence %q count = %d, want 1", sequenceName, count)
}
}

func TestMigrations_016RemovesHistoryOfDeletedRecords(t *testing.T) {
exec := newTestExecutor(t)
ctx := context.Background()
if err := migrations.Run(ctx, exec); err != nil {
t.Fatalf("Run() error = %v", err)
}
if err := migrations.Rollback(ctx, exec); err != nil {
t.Fatalf("Rollback(016) error = %v", err)
}

// State left by the 015-era handler: a live record with history, a
// deleted record with a tombstone, a recreated record whose earlier life
// ended in a tombstone, and history of a purged account.
db := exec.DB()
mustExec := func(query string, args ...any) {
t.Helper()
if _, err := db.ExecContext(ctx, query, args...); err != nil {
t.Fatalf("exec %q: %v", query, err)
}
}
mustExec(`INSERT INTO record (uri, cid, did, collection, json) VALUES
('at://did:plc:a/c/live', 'l2', 'did:plc:a', 'c', '{}'),
('at://did:plc:a/c/recreated', 'r3', 'did:plc:a', 'c', '{}')`)
insertVersion := func(uri, cid, action string) {
t.Helper()
mustExec(`INSERT INTO record_version (uri, cid, version_key, did, collection, action, json) VALUES (?, ?, ?, 'did:plc:a', 'c', ?, '{}')`,
uri, cid, action+":"+cid, action)
}
insertVersion("at://did:plc:a/c/live", "l1", "create")
insertVersion("at://did:plc:a/c/live", "l2", "update")
insertVersion("at://did:plc:a/c/deleted", "d1", "create")
insertVersion("at://did:plc:a/c/deleted", "d1", "delete")
insertVersion("at://did:plc:a/c/recreated", "r1", "create")
insertVersion("at://did:plc:a/c/recreated", "r1", "delete")
insertVersion("at://did:plc:a/c/recreated", "r3", "create")
insertVersion("at://did:plc:purged/c/x", "p1", "create")

if err := migrations.Run(ctx, exec); err != nil {
t.Fatalf("Run() applying 016 error = %v", err)
}

rows, err := db.QueryContext(ctx, `SELECT uri || ' ' || cid || ' ' || action FROM record_version ORDER BY id`)
if err != nil {
t.Fatalf("query record_version: %v", err)
}
defer func() { _ = rows.Close() }()
var got []string
for rows.Next() {
var row string
if err := rows.Scan(&row); err != nil {
t.Fatalf("scan: %v", err)
}
got = append(got, row)
}
want := []string{
"at://did:plc:a/c/live l1 create",
"at://did:plc:a/c/live l2 update",
"at://did:plc:a/c/recreated r3 create",
}
if strings.Join(got, "\n") != strings.Join(want, "\n") {
t.Fatalf("record_version after 016 =\n%s\nwant\n%s", strings.Join(got, "\n"), strings.Join(want, "\n"))
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- The history removed on upgrade is not restored.
DROP INDEX IF EXISTS idx_record_version_did;
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
-- Deleting a record, or purging its account, now removes its version history.
-- Earlier builds kept delete tombstones and the versions before them, and
-- account purges left history behind; remove what they stored.
DELETE FROM record_version
WHERE EXISTS (
SELECT 1 FROM record_version tombstone
WHERE tombstone.uri = record_version.uri
AND tombstone.action = 'delete'
AND tombstone.id > record_version.id
);
DELETE FROM record_version
WHERE action = 'delete'
OR NOT EXISTS (SELECT 1 FROM record r WHERE r.uri = record_version.uri);

-- Account purges delete an account's record history by DID.
CREATE INDEX IF NOT EXISTS idx_record_version_did ON record_version(did);
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- The history removed on upgrade is not restored.
DROP INDEX IF EXISTS idx_record_version_did;
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
-- Deleting a record, or purging its account, now removes its version history.
-- Earlier builds kept delete tombstones and the versions before them, and
-- account purges left history behind; remove what they stored.
DELETE FROM record_version
WHERE EXISTS (
SELECT 1 FROM record_version tombstone
WHERE tombstone.uri = record_version.uri
AND tombstone.action = 'delete'
AND tombstone.id > record_version.id
);
DELETE FROM record_version
WHERE action = 'delete'
OR NOT EXISTS (SELECT 1 FROM record r WHERE r.uri = record_version.uri);

-- Account purges delete an account's record history by DID.
CREATE INDEX IF NOT EXISTS idx_record_version_did ON record_version(did);
19 changes: 8 additions & 11 deletions internal/database/repositories/record_versions.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,21 +19,21 @@ const (
RecordVersionBaseline = "baseline"
RecordVersionCreate = "create"
RecordVersionUpdate = "update"
RecordVersionDelete = "delete"

// MaxRecordHistoryPageSize bounds one recordHistory response.
MaxRecordHistoryPageSize = 500
)

// RecordVersion is one observed version of a record, or a delete tombstone.
// RecordVersion is one observed version of a record. Deleting a record (or
// purging its account) removes its history, see RecordsRepository.Delete.
type RecordVersion struct {
ID int64
URI string
CID string
DID string
Collection string
Action string
// JSON is the record body; nil for delete tombstones.
// JSON is the record body.
JSON *string
// Live reports whether Tap delivered the event from the live stream
// (false for resync/backfill deliveries); nil for baseline rows.
Expand Down Expand Up @@ -127,12 +127,9 @@ func NewRecordVersionsRepository(db database.Executor) *RecordVersionsRepository
return &RecordVersionsRepository{db: db}
}

// versionKey is the dedupe identity of a version: its CID; for Tap events
// without a CID, a hash of the body; for tombstones, the deleted CID.
// versionKey is the dedupe identity of a version: its CID, or for Tap events
// without a CID, a hash of the body.
func versionKey(v RecordVersionWrite) string {
if v.Action == RecordVersionDelete {
return "delete:" + v.CID
}
if v.CID != "" {
return v.CID
}
Expand All @@ -145,14 +142,14 @@ func versionKey(v RecordVersionWrite) string {
}

// Append records one version. A version already stored for the URI (same
// version key) is left untouched, so redelivered and resynced events,
// including repeated deletes, are no-ops.
// version key) is left untouched, so redelivered and resynced events are
// no-ops.
func (r *RecordVersionsRepository) Append(ctx context.Context, v RecordVersionWrite) error {
if v.URI == "" || v.DID == "" || v.Collection == "" {
return fmt.Errorf("record version requires uri, did and collection")
}
switch v.Action {
case RecordVersionCreate, RecordVersionUpdate, RecordVersionDelete:
case RecordVersionCreate, RecordVersionUpdate:
default:
return fmt.Errorf("unsupported record version action %q", v.Action)
}
Expand Down
45 changes: 38 additions & 7 deletions internal/database/repositories/record_versions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,17 +60,15 @@ func TestRecordVersions_AppendDedupesAndListsOldestFirst(t *testing.T) {
write(repositories.RecordVersionCreate, "cid1", strPtr(`{"scientificName":"Hirundo rustica","identifiedBy":"ai:gemini-3.8-flash"}`))
write(repositories.RecordVersionCreate, "cid1", strPtr(`{"scientificName":"Hirundo rustica","identifiedBy":"ai:gemini-3.8-flash"}`)) // redelivery
write(repositories.RecordVersionUpdate, "cid2", strPtr(`{"scientificName":"Hirundo tahitica","identifiedBy":"Maria"}`))
write(repositories.RecordVersionDelete, "cid2", nil)
write(repositories.RecordVersionDelete, "cid2", nil) // redelivered delete

versions, err := repo.ListByURI(ctx, uri, 0, 500)
if err != nil {
t.Fatalf("ListByURI: %v", err)
}
if len(versions) != 3 {
t.Fatalf("got %d versions, want 3 (redelivery deduped): %+v", len(versions), versions)
if len(versions) != 2 {
t.Fatalf("got %d versions, want 2 (redelivery deduped): %+v", len(versions), versions)
}
wantActions := []string{"create", "update", "delete"}
wantActions := []string{"create", "update"}
for i, v := range versions {
if v.Action != wantActions[i] {
t.Errorf("version %d action = %q, want %q", i, v.Action, wantActions[i])
Expand All @@ -82,8 +80,13 @@ func TestRecordVersions_AppendDedupesAndListsOldestFirst(t *testing.T) {
if versions[1].JSON == nil || versions[1].CID != "cid2" {
t.Errorf("update version = %+v", versions[1])
}
if versions[2].JSON != nil {
t.Errorf("delete tombstone should have no body, got %q", *versions[2].JSON)
// Deleting the record (even one missing from the record table)
// removes its history.
if err := db.Records.Delete(ctx, uri); err != nil {
t.Fatalf("Records.Delete: %v", err)
}
if remaining, err := repo.ListByURI(ctx, uri, 0, 500); err != nil || len(remaining) != 0 {
t.Fatalf("history after Records.Delete = %d versions (err %v), want none", len(remaining), err)
}
if versions[0].Live == nil || !*versions[0].Live {
t.Errorf("live flag not kept: %+v", versions[0].Live)
Expand Down Expand Up @@ -190,3 +193,31 @@ func TestRecordVersions_CIDLessVersionsStayDistinctAndPages(t *testing.T) {
})
}
}

func TestRecordVersions_AccountPurgeRemovesHistory(t *testing.T) {
for name, db := range recordVersionTestDBs(t) {
t.Run(name, func(t *testing.T) {
ctx := context.Background()
suffix := strconv.FormatInt(time.Now().UnixNano(), 36)
gone, kept := "did:plc:gone"+suffix, "did:plc:kept"+suffix
for _, did := range []string{gone, kept} {
body := `{"scientificName":"Quercus robur"}`
if err := db.RecordVersions.Append(ctx, repositories.RecordVersionWrite{
URI: "at://" + did + "/app.gainforest.dwc.occurrence/1", CID: "c1", DID: did,
Collection: "app.gainforest.dwc.occurrence", Action: repositories.RecordVersionCreate, JSON: &body,
}); err != nil {
t.Fatalf("Append: %v", err)
}
}
if err := db.Records.PurgeActorData(ctx, gone); err != nil {
t.Fatalf("PurgeActorData: %v", err)
}
if v, _ := db.RecordVersions.ListByURI(ctx, "at://"+gone+"/app.gainforest.dwc.occurrence/1", 0, 500); len(v) != 0 {
t.Fatalf("purged account still has %d versions", len(v))
}
if v, _ := db.RecordVersions.ListByURI(ctx, "at://"+kept+"/app.gainforest.dwc.occurrence/1", 0, 500); len(v) != 1 {
t.Fatalf("other account lost its history: %d versions", len(v))
}
})
}
}
Loading
Loading