Skip to content

Commit 4e196be

Browse files
committed
fix(share): add persistent exact updates
1 parent b560fd1 commit 4e196be

14 files changed

Lines changed: 144 additions & 19 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
### Fixes
66

7+
- Add persistent exact snapshot updates for dedicated Git-only readers so rows omitted by a newer filtered snapshot are removed without changing the merge-by-default behavior of richer local archives.
78
- Skip Discord members with a missing user object during pagination and record conversion so public sync no longer panics on a nil `User`. Thanks @SebTardif.
89

910
## v0.13.2 - 2026-08-14

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ discrawl publish --check
9595
discrawl publish
9696
```
9797

98-
The preflight is read-only and reports the export scope before any snapshot is written. Published snapshots exclude wiretap direct messages and local failure history. See [Git snapshots](docs/guides/git-snapshots.md) for repository layout, filters, media handling, and update behavior.
98+
The preflight is read-only and reports the export scope before any snapshot is written. Published snapshots exclude wiretap direct messages and local failure history. Dedicated readers of privacy-filtered snapshots can subscribe with `--exact` so later omissions remove previously shared rows; normal subscriptions preserve richer local rows. See [Git snapshots](docs/guides/git-snapshots.md) for repository layout, filters, media handling, and update behavior.
9999

100100
## Automation
101101

docs/commands/subscribe.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ discrawl subscribe --branch main https://github.com/example/discord-archive.git
1414
discrawl subscribe --stale-after 15m https://github.com/example/discord-archive.git
1515
discrawl subscribe --no-auto-update https://github.com/example/discord-archive.git
1616
discrawl subscribe --no-import https://github.com/example/discord-archive.git
17+
discrawl subscribe --exact https://github.com/example/public-archive.git
1718
discrawl subscribe --force https://github.com/example/discord-archive.git
1819
discrawl subscribe --with-embeddings https://github.com/example/discord-archive.git
1920
discrawl subscribe --no-media https://github.com/example/discord-archive.git
@@ -22,8 +23,9 @@ discrawl subscribe --no-media https://github.com/example/discord-archive.git
2223
## What it does
2324

2425
- writes a config with `discord.token_source = "none"` (so no bot token is required)
25-
- safely merges the latest snapshot into the local SQLite archive without deleting destination-only rows
26+
- safely merges the latest snapshot into the local SQLite archive without deleting destination-only rows by default
2627
- enables auto-refresh: read commands fetch and import when the local share import is older than `share.stale_after` (default `15m`)
28+
- with `--exact`, persists `share.update_mode = "exact"` and makes the initial import plus later manual and automatic updates mirror the current snapshot
2729

2830
## Flags
2931

@@ -32,14 +34,17 @@ discrawl subscribe --no-media https://github.com/example/discord-archive.git
3234
- `--stale-after <duration>` - how stale the local import can get before read commands auto-refresh
3335
- `--no-auto-update` - disable auto-refresh (use [`update`](update.html) manually)
3436
- `--no-import` - write config only; skip the initial pull/import
35-
- `--force` - replace public snapshot tables so the local database exactly matches the snapshot
37+
- `--exact` - persist exact replacement for this dedicated reader; rows omitted by later snapshots are removed
38+
- `--force` - replace public snapshot tables for the initial import only; later updates still use the configured update mode
3639
- `--with-embeddings` - import vectors that match your local `[search.embeddings]` identity
3740
- `--no-media` - skip restoring cached attachment media files into `cache_dir/media`
3841

3942
## Disabled in this mode
4043

4144
`sync` and `tail` are disabled when `discord.token_source = "none"` because they need live Discord access. Switch to a token-equipped config to re-enable them.
4245

46+
Use `--exact` for a cache that must expose exactly the publisher's current privacy-filtered view. Do not enable it on a richer local archive: exact updates intentionally remove non-DM rows that are absent from the snapshot. The default merge mode remains appropriate for archives that also ingest Discord or desktop-cache data.
47+
4348
## After subscribing
4449

4550
```bash

docs/commands/sync.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ By default, `sync` runs both live/local sources and does **not** import the Git
77
- Discord bot-token sync for bot-visible guild data
88
- local Discord Desktop cache import for classifiable cached messages and proven DMs
99

10-
Use [`update`](update.html) when you want to pull/import the shared Git snapshot. Routine imports upsert changed shards without deleting local cache rows. If you intentionally want a sync run to import the snapshot before live deltas, pass `--update=auto` for a stale safe merge or `--update=force` for an exact replacement. `--no-update` is accepted as an explicit no-op alias for the default.
10+
Use [`update`](update.html) when you want to pull/import the shared Git snapshot. Routine imports upsert changed shards without deleting local cache rows. If you intentionally want a sync run to import the snapshot before live deltas, pass `--update=auto` for the configured stale update mode (merge by default) or `--update=force` for an exact replacement. `--no-update` is accepted as an explicit no-op alias for the default.
1111

1212
Run one explicit `--full` pass when you want a complete historical guild archive. Use plain `sync` afterward for frequent latest-message and desktop-cache refreshes.
1313

@@ -44,15 +44,15 @@ discrawl sync --with-media
4444
| Command | Use when | Behavior |
4545
| --- | --- | --- |
4646
| `discrawl sync` | routine refresh | skips member refreshes, checks live top-level channels plus active threads, only fetches new messages for channels with a stored cursor |
47-
| `discrawl sync --update=auto` | hybrid Git/live refresh | safely merges a stale Git snapshot first, then runs the routine live refresh |
47+
| `discrawl sync --update=auto` | hybrid Git/live refresh | applies the configured stale snapshot update mode first, then runs the routine live refresh |
4848
| `discrawl sync --update=force` | intentional exact reconciliation | replaces public snapshot tables first, then runs the routine live refresh |
4949
| `discrawl sync --all-channels` | repair pass | broad incremental sweep across every stored channel/thread, including archived threads |
5050
| `discrawl sync --full` | historical backfill | crawls older history until channels are complete |
5151

5252
## Flags
5353

5454
- `--source <both|discord|wiretap>` - which archive sources to read
55-
- `--update <auto|force|none>` - safe-merge a stale snapshot, force an exact replacement, or skip snapshot import before live deltas
55+
- `--update <auto|force|none>` - apply the configured stale snapshot update mode, force an exact replacement, or skip snapshot import before live deltas
5656
- `--full` - historical backfill (slow on large guilds)
5757
- `--all-channels` - broader incremental sweep across every stored channel/thread
5858
- `--latest-only` - explicit latest-only run (also the default for untargeted `sync`)

docs/commands/update.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
Pulls a Git snapshot and safely merges changed rows into the local cache.
44

5-
Routine imports are delta-planned from crawlkit shard fingerprints, with a Git-object fallback for older manifests. Changed and new shards are upserted without deleting destination-only rows. Discrawl never falls back to an exact replacement unless you pass `--force`.
5+
Routine imports are delta-planned from crawlkit shard fingerprints, with a Git-object fallback for older manifests. Changed and new shards are upserted without deleting destination-only rows. Discrawl never falls back to an exact replacement unless you pass `--force` or explicitly configure `share.update_mode = "exact"`.
66

77
## Usage
88

@@ -33,13 +33,14 @@ discrawl update --force --ref backup-2026-06-19
3333
- you set `--no-auto-update` when subscribing and want to refresh on demand
3434
- a CI job already imported the latest snapshot but read commands still consider it stale
3535
- you need an exact reconciliation after Discrawl reports removed shards or an incompatible table change (`discrawl update --force`)
36+
- this is a dedicated filtered-snapshot reader configured with `share.update_mode = "exact"`, so every update must remove rows no longer present upstream
3637
- you need to restore a named tag or commit while leaving the checked-out share branch untouched (`discrawl update --force --ref <ref>`)
3738

38-
Normal updates preserve rows learned from live Discord or the desktop cache, even when those rows are absent from the Git snapshot. Generated event history and local sync cursors are not replayed during routine merges. If a safe merge is impossible, Discrawl keeps the current database, marks the snapshot as needing attention in `status --json`, and asks you to rerun with `--force`.
39+
Normal updates preserve rows learned from live Discord or the desktop cache, even when those rows are absent from the Git snapshot. Generated event history and local sync cursors are not replayed during routine merges. If a safe merge is impossible, Discrawl keeps the current database, marks the snapshot as needing attention in `status --json`, and asks you to rerun with `--force`. Exact mode instead replaces public snapshot tables on every update; use it only for a dedicated cache because destination-only non-DM rows are intentionally removed.
3940

4041
## How `sync` interacts
4142

42-
`discrawl sync` does **not** auto-import the share unless `--update=auto` (safe merge when stale) or `--update=force` (exact replacement before live deltas). Routine live refreshes stay fast; explicit imports happen via `update`.
43+
`discrawl sync` does **not** auto-import the share unless `--update=auto` (configured update mode when stale) or `--update=force` (exact replacement before live deltas). Routine live refreshes stay fast; explicit imports happen via `update`.
4344

4445
## See also
4546

docs/configuration.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ remote = ""
9393
repo_path = "~/.local/share/discrawl/share" # macOS: "~/Library/Application Support/discrawl/share"
9494
branch = "main"
9595
auto_update = true
96+
update_mode = "merge" # "exact" for a dedicated snapshot-only cache
9697
stale_after = "15m"
9798
media = true
9899

@@ -145,6 +146,7 @@ Set `discord.token_source = "keyring"` if you want to require keyring lookup and
145146
- changing `db_path` does not migrate existing data; copy the file yourself if you want to keep history
146147
- `sync.attachment_media = true` makes `sync` behave like `sync --with-media`; media bytes are cached under `cache_dir/media`, and CDN `404`/other fetch failures are recorded on attachment rows
147148
- `share.media = false` makes publish/update/auto-update omit or skip restoring cached media; `subscribe --no-media` writes this for Git-only readers. With the default `share.media = true`, publish/update include cached non-DM media as gzip-compressed snapshot files, but publish does not fetch missing Discord files by itself.
149+
- `share.update_mode = "merge"` preserves destination-only rows during update and auto-update. Set it to `"exact"` only for a dedicated snapshot reader whose public tables must mirror the current snapshot; exact updates remove non-DM rows that the publisher omits. `subscribe --exact` writes this setting.
148150
- `[share.filter]` narrows only `publish` output; sync can still keep a richer local archive
149151
- `share.filter.public_only` exports only channels visible to the guild
150152
`@everyone` role after category/channel permission overwrites; private

docs/guides/git-snapshots.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -61,13 +61,14 @@ Once `share.remote` is configured, read commands auto-fetch and import when the
6161
```bash
6262
discrawl subscribe --stale-after 15m https://github.com/example/discord-archive.git
6363
discrawl subscribe --no-auto-update https://github.com/example/discord-archive.git
64+
discrawl subscribe --exact https://github.com/example/public-archive.git
6465
```
6566

66-
`discrawl update` runs the same safe pull/merge step manually. `discrawl update --force --ref <tag-or-commit>` reads historical Git objects directly and leaves the share checkout unchanged. Snapshot imports are delta-planned from crawlkit shard fingerprints. Older manifests without those fields fall back to Git blob identity, so the common publish shape only imports changed canonical shards. Routine merges preserve destination-only rows and do not replay generated event history or remote sync cursors.
67+
`discrawl update` runs the configured pull/import step manually. The default `share.update_mode = "merge"` is delta-planned from crawlkit shard fingerprints. Older manifests without those fields fall back to Git blob identity, so the common publish shape only imports changed canonical shards. Routine merges preserve destination-only rows and do not replay generated event history or remote sync cursors. `subscribe --exact` persists exact replacement for a dedicated snapshot-only cache, so rows omitted from newer privacy-filtered snapshots are removed during both manual and automatic updates.
6768

68-
Discrawl does not silently fall back to a full import. Removed shards and incompatible table changes leave the current database intact and require `discrawl update --force`. Forced updates replace public snapshot tables and rebuild search indexes; local DM rows remain untouched.
69+
Discrawl does not silently fall back from merge mode to a full import. Removed shards and incompatible table changes leave the current database intact and require `discrawl update --force`. Forced and configured exact updates replace public snapshot tables and rebuild search indexes; local DM rows remain untouched. Exact mode intentionally removes other destination-only rows, so do not use it on a richer archive that also ingests live Discord or desktop-cache data. `--force` is a one-shot override; `--exact` is the persistent subscription contract.
6970

70-
`discrawl sync` does **not** auto-import the share unless `--update=auto` or `--update=force` is provided. Auto mode uses the safe merge; force mode performs exact replacement before live deltas.
71+
`discrawl sync` does **not** auto-import the share unless `--update=auto` or `--update=force` is provided. Auto mode uses the configured update mode (merge by default); force mode performs exact replacement before live deltas.
7172

7273
## Hybrid mode
7374

internal/cli/cli.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -820,15 +820,16 @@ func (r *runtime) autoUpdateShare(mode shareUpdateMode) error {
820820
return err
821821
}
822822
r.setSyncLockPhase("share import")
823-
if mode == shareUpdateForce {
823+
exact := mode == shareUpdateForce || r.cfg.ShareUpdatesExact()
824+
if exact {
824825
_, _, err = share.Replace(r.ctx, r.store, opts)
825826
} else {
826827
_, _, err = share.MergeIfChanged(r.ctx, r.store, opts)
827828
}
828829
if errors.Is(err, share.ErrNoManifest) {
829830
return nil
830831
}
831-
if errors.Is(err, share.ErrReplacementRequired) && mode != shareUpdateForce {
832+
if errors.Is(err, share.ErrReplacementRequired) && !exact {
832833
r.logger.Warn("share update requires forced exact reconciliation", "error", err)
833834
return nil
834835
}

internal/cli/cli_test.go

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1279,17 +1279,19 @@ func TestReadCommandsCanDisableAutoImportWithEnv(t *testing.T) {
12791279
require.Empty(t, lastImport)
12801280
}
12811281

1282-
func TestSubscribeNoMediaPersistsShareMediaOptOut(t *testing.T) {
1282+
func TestSubscribePersistsExactModeAndMediaOptOut(t *testing.T) {
12831283
ctx := context.Background()
12841284
dir := t.TempDir()
12851285
cfgPath := filepath.Join(dir, "config.toml")
12861286

12871287
var out bytes.Buffer
1288-
require.NoError(t, Run(ctx, []string{"--config", cfgPath, "subscribe", "--no-import", "--no-media", "https://github.com/example/archive.git"}, &out, &bytes.Buffer{}))
1288+
require.NoError(t, Run(ctx, []string{"--config", cfgPath, "subscribe", "--no-import", "--exact", "--no-media", "https://github.com/example/archive.git"}, &out, &bytes.Buffer{}))
12891289

12901290
cfg, err := config.Load(cfgPath)
12911291
require.NoError(t, err)
12921292
require.False(t, cfg.ShareMediaEnabled())
1293+
require.Equal(t, config.ShareUpdateModeExact, cfg.Share.UpdateMode)
1294+
require.True(t, cfg.ShareUpdatesExact())
12931295
}
12941296

12951297
func TestSubscribeCloudDoesNotCreateLocalDB(t *testing.T) {
@@ -2333,6 +2335,18 @@ func TestShareUpdateImportsNewRemoteSnapshot(t *testing.T) {
23332335
require.NoError(t, Run(ctx, []string{"--config", readerCfgPath, "search", "newer snapshot"}, &out, &bytes.Buffer{}))
23342336
require.Contains(t, out.String(), "newer git snapshot arrived")
23352337

2338+
readerCfg.Share.UpdateMode = config.ShareUpdateModeExact
2339+
readerCfg.Share.StaleAfter = "1ns"
2340+
require.NoError(t, config.Write(readerCfgPath, readerCfg))
2341+
out.Reset()
2342+
require.NoError(t, Run(ctx, []string{"--config", readerCfgPath, "search", "newer snapshot"}, &out, &bytes.Buffer{}))
2343+
reader, err = store.Open(ctx, readerCfg.DBPath)
2344+
require.NoError(t, err)
2345+
_, rows, err = reader.ReadOnlyQuery(ctx, `select count(*) from messages where id = 'local-only'`)
2346+
require.NoError(t, err)
2347+
require.Equal(t, "0", rows[0][0], "configured exact auto-update must remove destination-only rows")
2348+
require.NoError(t, reader.Close())
2349+
23362350
out.Reset()
23372351
require.NoError(t, Run(ctx, []string{"--config", readerCfgPath, "update", "--force"}, &out, &bytes.Buffer{}))
23382352
require.Contains(t, out.String(), "forced=true")

internal/cli/output.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -207,7 +207,7 @@ Generate the archive activity report.
207207
208208
Check configuration, storage, credentials, and optional services.
209209
`,
210-
"subscribe": `Usage: discrawl subscribe [--repo PATH] [--branch NAME] [--stale-after DURATION] [--no-auto-update] [--no-import] [--force] [--with-embeddings] [--no-media] REMOTE
210+
"subscribe": `Usage: discrawl subscribe [--repo PATH] [--branch NAME] [--stale-after DURATION] [--no-auto-update] [--no-import] [--exact] [--force] [--with-embeddings] [--no-media] REMOTE
211211
212212
Configure and optionally import a read-only snapshot subscription.
213213
`,

0 commit comments

Comments
 (0)