Skip to content

Commit 8b8d1e0

Browse files
authored
fix(beeper): store plain text, resume backfilled history, classify link previews (#571)
Beeper's API returns HTML in a message's `text` field for some messages and plain text for others, with no field distinguishing them. msgvault stored it verbatim, so message bodies, snippets and the search index carried raw markup — on a large archive roughly 10% of messages held `<a href>` tags, and `target="_blank"` swamped searches for the ordinary word "target". Text is now converted when it is HTML and left untouched when it is not, so messages containing `<` or `&` survive intact. A chat whose backfill had completed only ever synced forward. Beeper Desktop keeps filling in a network's older history for hours or weeks after it is linked, and that history arrives with old timestamps — it neither advances the chat's last activity nor falls in the reconcile window, so it was never archived. Completed chats now re-check their oldest end once a day and resume the backfill when Beeper has added more behind them. Syncs report how many chats they reopened. Media that arrives as a forwarded link preview — an Instagram reel, an x.com post — now records the URL it previews in `attachments.attachment_metadata`. That distinguishes media a sender composed from a public post they forwarded, which matters because forwarded previews can dominate an archive's bytes while remaining recoverable from their URL. Download behaviour is unchanged; everything is still archived. Existing archives repair themselves: each source re-derives its stored text and attachment metadata once, on its next sync, and reports what it fixed. To repair on demand instead of waiting, or to finish an interrupted pass: ```bash msgvault repair-derived --source-type beeper msgvault repair-derived --source-type beeper --identifier instagramgo ``` It reads the verbatim payload archived with every message, so it needs no provider connection and repairs messages Beeper no longer holds. Only derived columns are rewritten — raw payloads, downloaded media and sync cursors are untouched — so it is idempotent. The pass is registered per source type, so other importers that archive raw payloads can opt in without a new command. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Matthew Sweeney <sweenzor@users.noreply.github.com>
1 parent 7a19e77 commit 8b8d1e0

39 files changed

Lines changed: 2465 additions & 69 deletions

cmd/msgvault/cmd/build_cache.go

Lines changed: 37 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -613,7 +613,8 @@ func derivedDriftOnly(staleness cacheStaleness) bool {
613613
staleness.HasConversationTypeDrift || staleness.HasParticipantIdentifierDrift ||
614614
staleness.HasParticipantDisplayNameDrift) &&
615615
!staleness.HasNew && !staleness.HasDeleted &&
616-
!staleness.HasUpdated && !staleness.HasAccountIdentityDrift
616+
!staleness.HasUpdated && !staleness.HasAccountIdentityDrift &&
617+
!staleness.HasDerivedDataDrift
617618
}
618619

619620
// refreshIdentityDatasetsOnly rebuilds every identity-derived dataset while
@@ -691,11 +692,11 @@ func buildCacheLocked(
691692
// concurrent identity mutation therefore makes the stamped revision LAG
692693
// the store, which HasIdentityDrift detects on the next staleness check —
693694
// the cache self-heals. Never move this read after the export. The same
694-
// invariant applies to the account-identity, participant-identifier, and
695-
// participant display-name revisions read alongside it: this full build
696-
// derives all of those datasets fresh from the current store state, so
697-
// stamping a lagging revision here is likewise self-healing — the matching
698-
// staleness check catches it on the next pass.
695+
// invariant applies to the derived-data, account-identity,
696+
// participant-identifier, and participant display-name revisions read
697+
// alongside it: this full build exports those facts from the current store
698+
// state, so stamping a lagging revision here is likewise self-healing — the
699+
// matching staleness check catches it on the next pass.
699700
identityStore, err := store.Open(dbPath)
700701
if err != nil {
701702
return nil, fmt.Errorf("open store for identity export: %w", err)
@@ -705,6 +706,11 @@ func buildCacheLocked(
705706
_ = identityStore.Close()
706707
return nil, fmt.Errorf("read identity revision: %w", err)
707708
}
709+
derivedDataRevision, err := identityStore.DerivedDataRevision()
710+
if err != nil {
711+
_ = identityStore.Close()
712+
return nil, fmt.Errorf("read derived-data revision: %w", err)
713+
}
708714
accountIdentityRevision, err := identityStore.AccountIdentityRevision()
709715
if err != nil {
710716
_ = identityStore.Close()
@@ -837,6 +843,13 @@ func buildCacheLocked(
837843
return nil, fmt.Errorf("inspect attachment MIME schema: %w", err)
838844
}
839845
sourceSnapshot.hasAttachmentMIME = attachmentMIMEColumnCount > 0
846+
var attachmentMetadataColumnCount int
847+
if err := sourceSnapshot.QueryRow(`
848+
SELECT COUNT(*) FROM pragma_table_info('attachments') WHERE name = 'attachment_metadata'
849+
`).Scan(&attachmentMetadataColumnCount); err != nil {
850+
return nil, fmt.Errorf("inspect attachment metadata schema: %w", err)
851+
}
852+
sourceSnapshot.hasAttachmentMetadata = attachmentMetadataColumnCount > 0
840853
var messageSourceAttributionColumnCount int
841854
if err := sourceSnapshot.QueryRow(`
842855
SELECT COUNT(*) FROM pragma_table_info('messages')
@@ -981,20 +994,26 @@ func buildCacheLocked(
981994
if sourceSnapshot.hasAttachmentMIME {
982995
attachmentMIMEExpression = "COALESCE(TRY_CAST(mime_type AS VARCHAR), '') AS mime_type"
983996
}
997+
attachmentMetadataExpression := "NULL::VARCHAR AS attachment_metadata"
998+
if sourceSnapshot.hasAttachmentMetadata {
999+
attachmentMetadataExpression = "TRY_CAST(attachment_metadata AS VARCHAR) AS attachment_metadata"
1000+
}
9841001
if err := runExport(tableAttachments, fmt.Sprintf(`
9851002
COPY (
9861003
SELECT
9871004
id AS attachment_id,
9881005
message_id,
9891006
size,
9901007
COALESCE(TRY_CAST(filename AS VARCHAR), '') as filename,
1008+
%s,
9911009
%s
9921010
FROM sqlite_db.attachments%s
9931011
) TO '%s/%s' (
9941012
FORMAT PARQUET,
9951013
COMPRESSION 'zstd'
9961014
)
997-
`, attachmentMIMEExpression, attachmentsFilter, escapedAttachmentsDir, junctionFile)); err != nil {
1015+
`, attachmentMIMEExpression, attachmentMetadataExpression,
1016+
attachmentsFilter, escapedAttachmentsDir, junctionFile)); err != nil {
9981017
return nil, fmt.Errorf("export attachments: %w", err)
9991018
}
10001019

@@ -1375,6 +1394,7 @@ func buildCacheLocked(
13751394
LastFailedSyncRunCount: syncCounters.failedRunCount,
13761395
LastFailedSyncRunIDSum: syncCounters.failedRunIDSum,
13771396
IdentityRevision: identityRevision,
1397+
DerivedDataRevision: derivedDataRevision,
13781398
AccountIdentityRevision: accountIdentityRevision,
13791399
ParticipantIdentifierRevision: participantIdentifierRevision,
13801400
ParticipantDisplayNameRevision: participantDisplayNameRevision,
@@ -1599,6 +1619,7 @@ type cacheSourceSnapshot struct {
15991619
sqliteTx *sql.Tx
16001620
tmpDir string
16011621
hasAttachmentMIME bool
1622+
hasAttachmentMetadata bool
16021623
hasMessageSourceAttribution bool
16031624
hasRecipientEnvelope bool
16041625
}
@@ -1717,10 +1738,16 @@ func (s *cacheSourceSnapshot) PrepareDatasets(names ...string) error {
17171738
}
17181739

17191740
func (s *cacheSourceSnapshot) tables() []cacheSnapshotTable {
1720-
attachmentQuery := "SELECT id, message_id, size, filename, '' AS mime_type FROM attachments"
1741+
attachmentMIMEColumn := "'' AS mime_type"
17211742
if s.hasAttachmentMIME {
1722-
attachmentQuery = "SELECT id, message_id, size, filename, mime_type FROM attachments"
1743+
attachmentMIMEColumn = "mime_type"
1744+
}
1745+
attachmentMetadataColumn := "NULL AS attachment_metadata"
1746+
if s.hasAttachmentMetadata {
1747+
attachmentMetadataColumn = "attachment_metadata"
17231748
}
1749+
attachmentQuery := "SELECT id, message_id, size, filename, " + attachmentMIMEColumn +
1750+
", " + attachmentMetadataColumn + " FROM attachments"
17241751
recipientEnvelopeColumn := "'' AS email_address"
17251752
if s.hasRecipientEnvelope {
17261753
recipientEnvelopeColumn = "email_address"
@@ -1748,7 +1775,7 @@ func (s *cacheSourceSnapshot) tables() []cacheSnapshotTable {
17481775
{"message_labels", "SELECT message_id, label_id FROM message_labels",
17491776
"types={'message_id': 'BIGINT', 'label_id': 'BIGINT'}"},
17501777
{tableAttachments, attachmentQuery,
1751-
"types={'id': 'BIGINT', 'message_id': 'BIGINT', 'size': 'BIGINT', 'filename': 'VARCHAR', 'mime_type': 'VARCHAR'}"},
1778+
"types={'id': 'BIGINT', 'message_id': 'BIGINT', 'size': 'BIGINT', 'filename': 'VARCHAR', 'mime_type': 'VARCHAR', 'attachment_metadata': 'VARCHAR'}"},
17521779
{tableParticipants, "SELECT id, email_address, domain, display_name, phone_number FROM participants",
17531780
"types={'id': 'BIGINT', 'email_address': 'VARCHAR', 'domain': 'VARCHAR', 'display_name': 'VARCHAR', 'phone_number': 'VARCHAR'}"},
17541781
{"account_identities", "SELECT source_id, address FROM account_identities",

cmd/msgvault/cmd/build_cache_test.go

Lines changed: 59 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2111,6 +2111,63 @@ func TestBuildCache_UTF8Handling(t *testing.T) {
21112111
assert.Equal("Test émoji 🎉 and unicode", subject, "unicode should be preserved")
21122112
}
21132113

2114+
func TestBuildCacheExportsAttachmentMetadataForRawQuery(t *testing.T) {
2115+
for _, tc := range []struct {
2116+
name string
2117+
forceCSV bool
2118+
}{
2119+
{name: "sqlite scanner"},
2120+
{name: "CSV fallback", forceCSV: true},
2121+
} {
2122+
t.Run(tc.name, func(t *testing.T) {
2123+
require := require.New(t)
2124+
assert := assert.New(t)
2125+
if tc.forceCSV {
2126+
t.Setenv("MSGVAULT_FORCE_CSV_SNAPSHOT", "1")
2127+
} else {
2128+
t.Setenv("MSGVAULT_FORCE_CSV_SNAPSHOT", "")
2129+
}
2130+
2131+
tmpDir := setupTestSQLite(t)
2132+
dbPath := filepath.Join(tmpDir, "test.db")
2133+
analyticsDir := filepath.Join(tmpDir, "analytics")
2134+
db, err := sql.Open("sqlite3", dbPath)
2135+
require.NoError(err, "open SQLite fixture")
2136+
_, err = db.Exec(`ALTER TABLE attachments ADD COLUMN attachment_metadata JSON`)
2137+
require.NoError(err, "add attachment metadata column")
2138+
_, err = db.Exec(`UPDATE attachments SET attachment_metadata = '{"shared_url":"https://example.com/post"}' WHERE id = 1`)
2139+
require.NoError(err, "set link-preview metadata")
2140+
_, err = db.Exec(`UPDATE messages SET message_type = 'beeper' WHERE id = 2`)
2141+
require.NoError(err, "mark fixture message as Beeper")
2142+
require.NoError(db.Close(), "close SQLite fixture")
2143+
2144+
_, err = buildCache(dbPath, analyticsDir, true)
2145+
require.NoError(err, "build analytics cache")
2146+
engine, err := query.NewDuckDBEngine(analyticsDir, "", nil)
2147+
require.NoError(err, "open analytics query engine")
2148+
defer func() { _ = engine.Close() }()
2149+
2150+
result, err := engine.QuerySQL(context.Background(), `
2151+
SELECT COALESCE(a.attachment_metadata IS NOT NULL, 0) AS is_share,
2152+
COUNT(*), SUM(a.size)
2153+
FROM attachments a
2154+
JOIN messages m ON m.id = a.message_id
2155+
WHERE m.message_type = 'beeper'
2156+
GROUP BY is_share
2157+
ORDER BY is_share`)
2158+
require.NoError(err, "run documented link-preview query")
2159+
assert.Equal([]string{"is_share", "count_star()", "sum(a.size)"}, result.Columns)
2160+
require.Len(result.Rows, 2)
2161+
assert.Equal("0", fmt.Sprint(result.Rows[0][0]))
2162+
assert.Equal("1", fmt.Sprint(result.Rows[0][1]))
2163+
assert.Equal("5000", fmt.Sprint(result.Rows[0][2]))
2164+
assert.Equal("1", fmt.Sprint(result.Rows[1][0]))
2165+
assert.Equal("1", fmt.Sprint(result.Rows[1][1]))
2166+
assert.Equal("10000", fmt.Sprint(result.Rows[1][2]))
2167+
})
2168+
}
2169+
}
2170+
21142171
// TestBuildCache_EmptyDatabase tests handling of empty database.
21152172
func TestBuildCache_EmptyDatabase(t *testing.T) {
21162173
require := require.New(t)
@@ -3302,8 +3359,7 @@ func TestCacheNeedsBuild_IgnoresAlreadyProcessedUpdatedSyncRun(t *testing.T) {
33023359
// schema version other than the current one now forces a full rebuild.
33033360
func TestCacheNeedsBuild_SchemaVersionMismatch(t *testing.T) {
33043361
require := require.New(t)
3305-
require.Equal(18, cacheSchemaVersion,
3306-
"participant directory revisions require a one-time cache rebuild at v18")
3362+
require.Equal(19, cacheSchemaVersion, "attachment metadata requires cache v19")
33073363
tmpDir := setupTestSQLiteEmpty(t)
33083364

33093365
dbPath := filepath.Join(tmpDir, "test.db")
@@ -3338,7 +3394,7 @@ func TestCacheNeedsBuild_SchemaVersionMismatch(t *testing.T) {
33383394
require.False(result.Skipped, "schema mismatch must execute a full rebuild")
33393395
upgraded, err := query.ReadCacheSyncState(analyticsDir)
33403396
require.NoError(err, "read upgraded cache state")
3341-
require.Equal(18, upgraded.SchemaVersion)
3397+
require.Equal(19, upgraded.SchemaVersion)
33423398
require.NoFileExists(filepath.Join(analyticsDir, tableParticipantIdentifiers, "data.parquet"),
33433399
"full rebuild must replace rather than extend the v11 identifier dataset")
33443400
identifierParquet := filepath.Join(analyticsDir, tableParticipantIdentifiers, "participant_identifiers.parquet")

cmd/msgvault/cmd/cache_derived.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,16 @@ func refreshDerivedDatasetsOnly(
6969
_ = st.Close()
7070
return nil, fmt.Errorf("read identity revision: %w", err)
7171
}
72+
derivedDataRevision, err := st.DerivedDataRevision()
73+
if err != nil {
74+
_ = st.Close()
75+
return nil, fmt.Errorf("read derived-data revision: %w", err)
76+
}
77+
if derivedDataRevision != state.DerivedDataRevision {
78+
_ = st.Close()
79+
return nil, fmt.Errorf("%w: derived-data revision changed",
80+
ErrDerivedRefreshRequiresFullBuild)
81+
}
7282
accountIdentityRevision, err := st.AccountIdentityRevision()
7383
if err != nil {
7484
_ = st.Close()

cmd/msgvault/cmd/cache_refresh_test.go

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@ package cmd
22

33
import (
44
"context"
5+
"database/sql"
56
"encoding/json"
67
"errors"
8+
"fmt"
79
"io/fs"
810
"os"
911
"path/filepath"
@@ -19,6 +21,7 @@ import (
1921
"go.kenn.io/msgvault/internal/identityindex"
2022
"go.kenn.io/msgvault/internal/oauth"
2123
"go.kenn.io/msgvault/internal/query"
24+
"go.kenn.io/msgvault/internal/rederive"
2225
"go.kenn.io/msgvault/internal/store"
2326
)
2427

@@ -332,6 +335,119 @@ func TestRebuildCacheAfterWriteReturnsError(t *testing.T) {
332335
require.ErrorContains(err, "refresh analytics cache")
333336
}
334337

338+
func TestRebuildCacheAfterDerivedRepairRefreshesCurrentCache(t *testing.T) {
339+
require := require.New(t)
340+
assert := assert.New(t)
341+
tmpDir := t.TempDir()
342+
dbPath := filepath.Join(tmpDir, "msgvault.db")
343+
344+
savedCfg := cfg
345+
t.Cleanup(func() { cfg = savedCfg })
346+
cfg = &config.Config{HomeDir: tmpDir, Data: config.DataConfig{DataDir: tmpDir}}
347+
analyticsDir := cfg.AnalyticsDir()
348+
349+
st, err := store.Open(dbPath)
350+
require.NoError(err)
351+
require.NoError(st.InitSchema())
352+
source, err := st.GetOrCreateSource("beeper", "signal")
353+
require.NoError(err)
354+
conversationID, err := st.EnsureConversationWithType(
355+
source.ID, "!cache-repair:example.org", "direct_chat", "Cache repair",
356+
)
357+
require.NoError(err)
358+
sentAt := time.Date(2026, 8, 9, 12, 0, 0, 0, time.UTC)
359+
messageID, err := st.UpsertMessage(&store.Message{
360+
ConversationID: conversationID,
361+
SourceID: source.ID,
362+
SourceMessageID: "repair-cache-1",
363+
MessageType: "beeper",
364+
SentAt: sql.NullTime{Time: sentAt, Valid: true},
365+
ReceivedAt: sql.NullTime{Time: sentAt, Valid: true},
366+
Snippet: sql.NullString{String: "stale snippet", Valid: true},
367+
HasAttachments: true,
368+
AttachmentCount: 1,
369+
})
370+
require.NoError(err)
371+
require.NoError(st.UpsertMessageBody(
372+
messageID,
373+
sql.NullString{String: "stale body", Valid: true},
374+
sql.NullString{},
375+
))
376+
raw, err := json.Marshal(map[string]any{
377+
"id": "repair-cache-1",
378+
"chatID": "!cache-repair:example.org",
379+
"accountID": "signal",
380+
"senderID": "@user-a:example.org",
381+
"senderName": "User A",
382+
"timestamp": sentAt,
383+
"type": "IMAGE",
384+
"text": "https://example.com/post",
385+
"attachments": []map[string]any{{
386+
"id": "mxc://example.org/share", "type": "img", "mimeType": "image/jpeg",
387+
}},
388+
})
389+
require.NoError(err)
390+
require.NoError(st.UpsertMessageRawWithFormat(messageID, raw, "beeper_json"))
391+
require.NoError(st.ReplaceMessageBeeperAttachments(messageID, []store.AttachmentRef{{
392+
MimeType: "image/jpeg",
393+
StoragePath: "mxc://example.org/share",
394+
SourceAttachmentID: "beeper:mxc://example.org/share",
395+
MediaType: "image",
396+
}}))
397+
require.NoError(st.Close())
398+
399+
_, err = buildCache(dbPath, analyticsDir, true)
400+
require.NoError(err, "build current-schema cache before repair")
401+
initialState, err := query.ReadCacheSyncState(analyticsDir)
402+
require.NoError(err)
403+
assert.Equal(query.CacheSchemaVersion, initialState.SchemaVersion)
404+
assert.Zero(initialState.DerivedDataRevision)
405+
406+
readCached := func() (string, any) {
407+
t.Helper()
408+
engine, openErr := query.NewDuckDBEngine(analyticsDir, "", nil)
409+
require.NoError(openErr)
410+
result, queryErr := engine.QuerySQL(context.Background(), `
411+
SELECT m.snippet, a.attachment_metadata
412+
FROM messages m
413+
JOIN attachments a ON a.message_id = m.id
414+
WHERE m.source_message_id = 'repair-cache-1'`)
415+
closeErr := engine.Close()
416+
require.NoError(queryErr)
417+
require.NoError(closeErr)
418+
require.Len(result.Rows, 1)
419+
return fmt.Sprint(result.Rows[0][0]), result.Rows[0][1]
420+
}
421+
422+
beforeSnippet, beforeMetadata := readCached()
423+
assert.Equal("stale snippet", beforeSnippet)
424+
assert.Nil(beforeMetadata)
425+
426+
st, err = store.Open(dbPath)
427+
require.NoError(err)
428+
sum, err := rederive.Run(
429+
context.Background(), st, "beeper", source.Identifier, source.ID, nil,
430+
)
431+
require.NoError(err)
432+
require.Zero(sum.Errors)
433+
require.NoError(st.Close())
434+
435+
staleness := cacheNeedsBuild(dbPath, analyticsDir)
436+
require.True(staleness.NeedsBuild)
437+
assert.True(staleness.HasDerivedDataDrift)
438+
assert.True(staleness.FullRebuild,
439+
"an incremental append cannot replace already-cached repaired rows")
440+
441+
require.NoError(rebuildCacheAfterWrite(dbPath))
442+
repairedState, err := query.ReadCacheSyncState(analyticsDir)
443+
require.NoError(err)
444+
assert.Equal(int64(1), repairedState.DerivedDataRevision)
445+
afterSnippet, afterMetadata := readCached()
446+
assert.Equal("https://example.com/post", afterSnippet)
447+
require.NotNil(afterMetadata)
448+
assert.JSONEq(`{"shared_url":"https://example.com/post"}`, fmt.Sprint(afterMetadata))
449+
}
450+
335451
func TestScheduledCacheRefreshSkipsWhenAutoBuildCacheDisabled(t *testing.T) {
336452
require := require.New(t)
337453
assert := assert.New(t)

cmd/msgvault/cmd/cache_staleness.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,11 @@ type cacheStaleness struct {
2323
// index-only refresh applies must check HasAccountIdentityDrift too —
2424
// see derivedDriftOnly in build_cache.go.
2525
HasIdentityDrift bool
26+
// HasDerivedDataDrift signals an offline repair rewrote existing message
27+
// or attachment facts already inside the committed cache watermark. These
28+
// facts require a full rebuild; neither incremental append nor the
29+
// identity-only refresh can replace them.
30+
HasDerivedDataDrift bool
2631
// HasConversationParticipantDrift signals conversation membership changed
2732
// for a conversation already represented by the committed message
2833
// watermark. The index-only refresh can rebuild relationship_activity and
@@ -294,6 +299,19 @@ func cacheNeedsBuildLocked(dbPath, analyticsDir string) cacheStaleness {
294299
}
295300
}
296301

302+
derivedDataRevision, err := db.DerivedDataRevision()
303+
if err != nil {
304+
return cacheStaleness{
305+
NeedsBuild: true, FullRebuild: true,
306+
Reason: "cannot verify derived-data revision",
307+
}
308+
}
309+
if derivedDataRevision != state.DerivedDataRevision {
310+
result.HasDerivedDataDrift = true
311+
result.FullRebuild = true
312+
reasons = append(reasons, "derived message data changed")
313+
}
314+
297315
// Account-identity drift covers identity mutations that invalidate baked
298316
// message data: confirming or removing a confirmed "me" address via
299317
// AddAccountIdentity/RemoveAccountIdentity, and participant merges via

0 commit comments

Comments
 (0)