milestone 24: persist and surface the unsupported-line count

This commit is contained in:
2026-08-13 20:44:27 +02:00
parent 21571e448e
commit 1bce81eea0
18 changed files with 799 additions and 20 deletions
+2 -1
View File
@@ -98,7 +98,7 @@ Two SQLite files with opposite write profiles, isolated from each other:
Blocklist domains are **not** stored in SQLite — they are a cache of re-downloadable remote artifacts, not config or state: Blocklist domains are **not** stored in SQLite — they are a cache of re-downloadable remote artifacts, not config or state:
- Each source compiles to `/var/lib/nxdns/blocklists/<source_id>.list`: normalized, one domain per line, small header (source URL, fetch time, counts, checksum). Wildcard/regex/exception-flavored lines: wildcards go to `<source_id>.wild`, ABP exceptions (`@@||name^`) to `<source_id>.allow`; regex lines are counted + skipped (counts in metadata → UI). The checksum covers the three bodies in that order, so a source with no exceptions keeps the digest it had when only two existed and no upgrade forces a refetch. - Each source compiles to `/var/lib/nxdns/blocklists/<source_id>.list`: normalized, one domain per line, small header (source URL, fetch time, counts, checksum). Wildcard/regex/exception-flavored lines: wildcards go to `<source_id>.wild`, ABP exceptions (`@@||name^`) to `<source_id>.allow`; regex lines and browser-syntax lines nxdns cannot translate into a DNS decision are counted + skipped, both counts in metadata → UI. The checksum covers the three bodies in that order, so a source with no exceptions keeps the digest it had when only two existed and no upgrade forces a refetch.
- `config.db` keeps source **metadata only** (`blocklist_sources`). - `config.db` keeps source **metadata only** (`blocklist_sources`).
- Startup + post-update: parse files into the immutable in-memory matcher (RCU swap, §9.5). - Startup + post-update: parse files into the immutable in-memory matcher (RCU swap, §9.5).
- Corruption recovery is per-file: checksum mismatch → re-download one list. - Corruption recovery is per-file: checksum mismatch → re-download one list.
@@ -419,6 +419,7 @@ CREATE TABLE blocklist_sources (
wildcard_count INTEGER NOT NULL DEFAULT 0, wildcard_count INTEGER NOT NULL DEFAULT 0,
exception_count INTEGER NOT NULL DEFAULT 0, exception_count INTEGER NOT NULL DEFAULT 0,
skipped_regex_count INTEGER NOT NULL DEFAULT 0, skipped_regex_count INTEGER NOT NULL DEFAULT 0,
skipped_unsupported_count INTEGER NOT NULL DEFAULT 0,
checksum TEXT checksum TEXT
); );
+15
View File
@@ -287,6 +287,21 @@ first, so a downloaded list can reopen only a hole another downloaded list dug.
Each source reports how many it carried as `exceptions`; there is no way to write Each source reports how many it carried as `exceptions`; there is no way to write
one by hand, and no reason to want one — write an allow rule instead. one by hand, and no reason to want one — write an allow rule instead.
Two counters report what a compile skipped, and they are different facts.
`skipped_regex` counts regex lines: nxdns has a regex engine, but it takes
patterns only from the operator, so a regex line in a downloaded list is counted,
skipped and surfaced — adopt the ones you trust as `regex` rules.
`skipped_unsupported` counts lines nxdns cannot safely translate into a DNS
decision: cosmetic element hiding (`##`, `#@#`, `#?#`), rules carrying a `$`
modifier (except `$important` on an exception line, tolerated above), scheme
anchors, non-anchored `@@` forms — and, in a `domains`-format
list, a line holding more than one field before its inline comment, which usually
means the list is really a hosts file that was declared as `domains`. Neither is
an error, and the two are never one number. A large `skipped_unsupported` beside
a small `domain_count` usually means the list is written for browser extensions,
and its DNS or hosts variant will block more here. Both appear per source in the
blocklists UI and on `/api/blocklists`.
### group_sources ### group_sources
Which groups consult which blocklist sources. Which groups consult which blocklist sources.
+7 -4
View File
@@ -220,15 +220,18 @@ curl -s -X POST http://127.0.0.1:8080/api/blocklists/update
``` ```
```json ```json
{"sources":[{"id":1,"state":"ok","loaded":true,"last_attempt":1786629237,"last_success":1786629238,"url":"https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts","last_error":"","domains":97648,"wildcards":0,"exceptions":0,"skipped_regex":0}]} {"sources":[{"id":1,"state":"ok","loaded":true,"last_attempt":1786629237,"last_success":1786629238,"url":"https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts","last_error":"","domains":97648,"wildcards":0,"exceptions":0,"skipped_regex":0,"skipped_unsupported":0}]}
``` ```
The three zeros are the parts of this list that a hosts file cannot have. The four zeros describe this particular download, not the hosts format.
`wildcards` counts entries covering a name and its subdomains, `exceptions` `wildcards` counts entries covering a name and its subdomains, `exceptions`
counts the `@@` lines an Adblock Plus list uses to lift a name another list counts the `@@` lines an Adblock Plus list uses to lift a name another list
blocks, and `skipped_regex` counts the regex lines nxdns declines to take from a blocks, and `skipped_regex` counts the regex lines nxdns declines to take from a
downloaded list. Only the Adblock Plus syntax writes any of them; a hosts file is downloaded list. `skipped_unsupported` counts lines nxdns cannot translate into a
one name per line. DNS decision, and a large value next to a small `domains` means the list targets
browsers rather than DNS. Only `exceptions` is Adblock-Plus-only: a hosts list
can carry regex lines, `*.`-prefixed wildcards, and bare sink addresses that
count as unsupported. This one carries none of them.
The download is about 3 MB and takes a few seconds. Watch the first terminal The download is about 3 MB and takes a few seconds. Watch the first terminal
until this appears: until this appears:
+497
View File
@@ -0,0 +1,497 @@
# Milestone 24: `skipped_unsupported` is persisted, surfaced and explained
Goal: close the compile-pipeline silent drop that misleads the operator. `Counts.
skipped_unsupported` (`src/filter/compiler.zig:34`) is counted on every compile
(compiler.zig:92) and then discarded on the happy path — it reaches the
compiled-file header and the `NoValidEntries` error text, but no database
column, no API field and no UI cell. Its sibling `skipped_regex` reaches all
three. A blocklist made almost entirely of cosmetic browser filters therefore
compiles to almost nothing and looks, in the UI, exactly like a clean list.
AGENTS.md forbids exactly this ("no silent drops"). The milestone persists the
count, surfaces it everywhere `skipped_regex` is surfaced, and explains to the
operator why the two skip counters are different facts.
Design written 2026-08-13 against HEAD `21571e4`, revised after a Codex review
of the first draft.
## Implementation contract (read first)
- Read `AGENTS.md`, then this spec whole, before session work starts.
- **The v1 baseline is editable and there is no migration step.** As of commit
`21571e4`, `src/storage/migrations.zig` holds exactly one step and
`target_version` is 1 (migrations.zig:29-36). nxdns has zero installs; the
baseline freezes at v0.1 (`config_schema.zig:6-12`, PLAN §3.7, §11.2). The
new column is one line edited into `config_schema.ddl_v1` plus the identical
line in PLAN §11.2, which is kept byte-identical to it. A diff that adds a
`ddl_v2` or a second `Step` is wrong and must be reverted, not merged.
- After the API shape change, regenerate the contract samples
(`web/src/lib/contractSamples.gen.ts`; procedure in AGENTS.md — the
`zig build test -Dintegration -Dcontract-samples-out=...` command, never a
hand edit) and update `web/src/lib/types.ts` to match.
- No new `src/**.zig` files, so `src/tests.zig` and `build.zig` are untouched.
## Rulings (binding)
### 1. The column is `skipped_unsupported_count`, one edited line, no step
`skipped_regex_count` (config_schema.zig:63) sets the convention; the new
column follows it. In `config_schema.ddl_v1`, directly after the
`skipped_regex_count` line inside `CREATE TABLE blocklist_sources`:
```sql
skipped_unsupported_count INTEGER NOT NULL DEFAULT 0,
```
The identical line goes into PLAN §11.2 after PLAN.md:421 — that section is
kept byte-identical to `ddl_v1` and currently is (verified: the fenced SQL
matches the Zig string literal line for line).
Consequence to accept, not to fix: a development database stamped version 1
before this edit will fail the first `SELECT` naming the column with "no such
column", because the stamped version equals `target_version` and the step never
reruns. That is the documented pre-v0.1 contract (`config_schema.zig:6-12`) —
delete the scratch database. Do not add fallback SQL, `PRAGMA table_info`
probing, or a migration step to paper over it.
The baseline test "a fresh database reaches the baseline with every v1 column
and rule kind" (migrations.zig:266-283) gains
`try testing.expect(try columnExists(&database, "blocklist_sources", "skipped_unsupported_count"));`
beside the existing `exception_count` probe.
### 2. The repo persists it beside `skipped_regex_count`
`src/storage/repositories/sources_repo.zig`, mirroring `skipped_regex_count`
exactly (no default on either struct field, so every construction site fails
to compile until it names the count — that is the visibility this milestone is
about):
- `SourceRow` (sources_repo.zig:80-97) gains `skipped_unsupported_count: i64`
after `skipped_regex_count`.
- `SourceStats` (sources_repo.zig:99-110) gains the same field.
- `row_columns_sql` (sources_repo.zig:112-117) appends
`skipped_unsupported_count` to the SELECT list (index 11);
`readSourceRow` (sources_repo.zig:128-148) reads it with
`stmt.columnInt(11)`.
- `update_stats_sql` (sources_repo.zig:159-164) adds
`skipped_unsupported_count = ?8`; `updateSourceStats`
(sources_repo.zig:168-179) binds it.
- The module doc comment (sources_repo.zig:3-5) adds the column to its list of
server-produced facts, and so does the `src/config/model.zig:14` doc comment
that repeats that list.
**The sum at sources_repo.zig:318 includes the new column.** That sum lives in
the test "insertBlocklistSource leaves the runtime columns at their defaults";
it exists to prove an insert leaves every runtime counter at 0, and the new
column is a runtime counter. This is a deliberate decision, not an oversight:
no production query sums these columns into a "total entries" figure, and none
may start to — `skipped_regex_count` and `skipped_unsupported_count` count
lines *not* written, unlike `domain_count`, `wildcard_count` and
`exception_count`, so any future entries total must exclude both. The test sum
asserts defaults, which is the one context where adding them is correct.
Existing `SourceStats` / assertion sites in this file (the literals at
sources_repo.zig:386-393, 420-427, 483-493 and the zero-default loop at
sources_repo.zig:365-374) carry the new field with distinct non-zero values
where their siblings have them, and the round-trip assertions extend to it.
### 3. The manager writes it, and rehydration restores it
`src/filter/manager.zig`. The header already prints
`# skipped_unsupported {d}` (manager.zig:243) and `SourceStatus.counts` is a
full `compiler.Counts` (manager.zig:178), so within one process the count
already reaches the status table. What is missing is the database, which is
the only thing a restart reads — rehydration never reparses headers.
- The fresh-publish path (manager.zig:854-861) passes
`.skipped_unsupported_count = compiled.result.counts.skipped_unsupported` to
`updateSourceStats`.
- **The checksum-unchanged path (manager.zig:821-836) carries a bug this
milestone fixes.** It writes the *stored* `row.skipped_regex_count` back to
the database (manager.zig:830) while handing the *fresh*
`compiled.result.counts` to the in-memory status (manager.zig:833). The
checksum covers the `.list`, `.wild` and `.allow` bodies only, and a skipped
line lands in none of them — so a list that changes only its regex or
browser-syntax lines keeps its checksum, the running server shows the new
number, the database keeps the old one, and the next restart silently reverts
what the operator saw. That is milestone 21's column, wrong today.
Both skip counters take `compiled.result.counts` on this path:
`.skipped_regex_count = compiled.result.counts.skipped_regex` and
`.skipped_unsupported_count = compiled.result.counts.skipped_unsupported`.
`domain_count`, `wildcard_count` and `exception_count` keep reading from
`row` — they count written entries, so an unchanged checksum does mean an
unchanged value for them.
This needs a test the author has watched fail with the fix reverted: two
compiles of bodies whose written entries are identical but whose skipped
lines differ, asserting the same checksum, then asserting the database holds
the second compile's counts, then rehydrating through `applyLoadOutcomes` to
prove the restored status carries them. Report the observed failure output.
- `applyLoadOutcomes` rehydration (manager.zig:1545-1550) adds
`.skipped_unsupported = countOf(row.skipped_unsupported_count)` to the
`Counts` it rebuilds, so a restarted server reports what the last compile
skipped instead of 0.
- Test literals name the field: one `SourceStats` (manager.zig:1928) and two
`SourceRow` (manager.zig:2051, 2225). The rehydration test that feeds a row
through
`applyLoadOutcomes` asserts a non-zero `skipped_unsupported` lands in
`status.counts`. The header pin test (manager.zig:2030-2031) already covers
the `# skipped_unsupported` line and is extended only if its fixture counts
change.
- `rejectedWithoutEntries` (manager.zig:1640-1643) and `failNoValidEntries`
(manager.zig:1127-1128) already read the count and are unchanged.
`src/filter/filter_integration_test.zig` names `.skipped_regex_count` in six
`SourceStats` literals (:826, :858, :1075, :1145, :1257, :2158) — each gains
the sibling field. The stats round-trip assertion at :915 gains a sibling
assertion for the new column.
**Do not add an unsupported line to the existing fixture.** The fixture at
filter_integration_test.zig:112 is hosts-shaped, and `detectFormat`
(`src/filter/parsers.zig:118`) assigns one format to a whole source. A single
`##.ad-banner` or `$`-modifier line flips it to ABP, which re-parses every
existing line: the `*.wild` entry becomes unsupported and address fields
tokenize as domains (`src/filter/parser_abp.zig:68`). Every expected count in
that test would move, for a reason unrelated to this milestone.
Add a **separate ABP-format fixture and test** carrying `##.ad-banner` and
`||ads.example^$third-party` beside two blockable names, and assert
`skipped_unsupported_count = 2` through the compile-persist-read round trip
there.
`src/config/reconcile.zig`: the runtime-column preservation test seeds stats
at reconcile.zig:1096-1110 and asserts survival at reconcile.zig:1173. The
seed gains `.skipped_unsupported_count` with a distinct value and the
assertion block gains its expectation. No reconcile logic changes: the engine
updates declarative columns by name and never touches runtime columns, so the
new column survives with zero code change — the test is the proof.
### 4. The API speaks it in both shapes
Two shapes carry blocklist counters, and the new count joins both under the
names its siblings set:
- **`Blocklist`** (rows of `GET /api/blocklists`, serialized straight from
`SourceRow`): the field arrives automatically once `SourceRow` has it, as
`skipped_unsupported_count`. `src/web/openapi.yaml:1877-1895` adds it to
`required` and `properties`.
- **`SourceStatus`** (rows of `POST /api/blocklists/update`): `StatusView`
(`src/web/handlers/blocklists.zig:52-80`) gains
`skipped_unsupported: u32` after `skipped_regex`, mapped from
`status.counts.skipped_unsupported` in `from`.
`src/web/openapi.yaml:1920-1938` adds it to `required` and `properties`.
The handler test literals at blocklists.zig:323 (`SourceStats`) and :386
(`counts`) carry the field with non-zero values and the response assertions
extend to it.
Contract fallout, all in the same session (ruling 6 explains why):
- Regenerate `web/src/lib/contractSamples.gen.ts` with the AGENTS.md command.
- `web/src/lib/types.ts`: `Blocklist` gains
`skipped_unsupported_count: number` (types.ts:154-166); `SourceStatus`
gains `skipped_unsupported: number` (types.ts:183-195).
- The web test mocks are **not** typed against these interfaces, so `tsc` will
not force them. `BLOCKLISTS` in
`web/src/features/blocklists/BlocklistsPage.test.tsx` is an inferred object
literal handed to a `Record<string, unknown>` (BlocklistsPage.test.tsx:40),
and the same untyped-fetch pattern holds in
`web/src/features/groups/GroupsPage.test.tsx:16` and
`web/src/features/settings/authority.test.tsx:64` — both of which already
omit `exception_count` without failing. Updating a mock here is a semantic
fixture change, not a typecheck fix, and the implementer must not expect a
compiler error to point at them.
So: the BlocklistsPage mocks (:21, :34 blocklist rows; :104, :155, :168
status rows) gain the field because S2's rendering assertions read it. The
groups and settings mocks are left alone — they render no counter column,
and widening them buys nothing. S1 picks values no other cell in the same
table already shows (`3` and `7` are taken), e.g.
`skipped_unsupported_count: 21` and `skipped_unsupported: 17`.
### 5. The UI shows it always, in both tables, and says what it means
Both tables gain a `Skipped unsupported` column directly after
`Skipped regex`, rendered unconditionally:
- `web/src/features/blocklists/BlocklistsPage.tsx`: header after :157, cell
`{b.skipped_unsupported_count}` after :190, same
`shared.td, shared.tabularNums` props as its neighbours.
- `web/src/features/blocklists/SourceStatusSection.tsx`: header after :91,
cell `{source.skipped_unsupported}` after :114.
Always-shown is a decision, not a default: every other counter column here is
unconditional, including `Skipped regex` and `Exceptions`, which are 0 for
every plain hosts list, and the tutorial already explains those zeros. A
column that appears only when non-zero would make two lists' tables disagree
in shape, would hide the header that gives the number its meaning, and would
special-case exactly the counter this milestone exists to make visible. The
zero cell is not noise; it states that nothing in this list was classified as
unsupported — which is narrower than "clean", since `invalid` and `long_lines`
stay unsurfaced (ruling 7).
The two counters mean different things and the page must say so once. A muted
paragraph (the existing `styles.empty`-style muted text, matching
`SourceStatusSection`'s `note` treatment) rendered under the sources table in
`BlocklistsPage.tsx`, inside the same `else` branch as the table — the note
describes the two skip columns, so it appears exactly when they do. "Always
visible" above means unconditional on values, never hidden at 0; it does not
mean the empty state (`blocklists.length === 0`) carries a paragraph about
columns that are not on screen. The empty state stays one instruction. Exact
copy:
> Both “Skipped” columns count lines nxdns read and did not take. Skipped
> regex lines are patterns nxdns accepts only from you — adopt one you trust
> as a regex rule. Skipped unsupported lines are syntax nxdns cannot translate
> into a DNS decision: cosmetic element hiding, browser-only modifiers. A
> skipped unsupported count that dwarfs the domain count usually means the
> list is written for a browser extension, and its DNS or hosts variant will
> block more here.
`BlocklistsPage.test.tsx` asserts: both new headers render, the mock values
(`21`, and `17` after an update snapshot) render, and the note text is
present. No threshold logic, no badge, no coloring by magnitude (see
anti-requirements).
### 6. The docs explain both counters without contradicting what stands
- `docs/reference/configuration.md`, section `### blocklist_sources`, after
the exceptions paragraph (configuration.md:282-288), a new paragraph:
> Two counters report what a compile skipped, and they are different facts.
> `skipped_regex` counts regex lines: nxdns has a regex engine, but it takes
> patterns only from the operator, so a regex line in a downloaded list is
> counted, skipped and surfaced — adopt the ones you trust as `regex` rules.
> `skipped_unsupported` counts lines nxdns cannot safely translate into a DNS
> decision: cosmetic element hiding (`##`, `#@#`, `#?#`), rules carrying a
> `$` modifier (except `$important` on an exception line, tolerated above),
> scheme anchors, non-anchored `@@` forms — and, in a
> `domains`-format list, a line holding more than one field before its
> inline comment, which usually means the list is really a hosts file that
> was declared as `domains`. Neither is an error, and the two are never one
> number. A large `skipped_unsupported` beside a small `domain_count`
> usually means the list is written for browser extensions, and its DNS or
> hosts variant will block more here. Both appear per source in the
> blocklists UI and on `/api/blocklists`.
- `docs/tutorial/first-run.md`: the recorded JSON at first-run.md:223 gains
`"skipped_unsupported":0` after `"skipped_regex":0` (the endpoint now
returns it; the recorded values themselves stand). The paragraph at
first-run.md:226-231 becomes four zeros, keeps its `skipped_regex` sentence
as written, and **drops the "parts of this list that a hosts file cannot
have" framing** — a deliberate small widening ruled during implementation
review. The framing was false before this milestone touched it:
`parser_hosts.zig` recognizes regex lines (:16), reports a bare sink
address as unsupported (:21), and a `*.`-prefixed name compiles to a
wildcard in any format. Only `exceptions` is Adblock-Plus-only. The
paragraph now presents the zeros as facts about this particular download
and says so.
- The `$` modifier is named as skipped **with its one exception**: `$important`
on an anchored `@@` exception line is accepted and lands in
`exception_count` (`parser_abp.zig`, PLAN §2.2). The configuration.md
paragraph above, and the `SourceRow.skipped_unsupported_count` doc comment
in `sources_repo.zig` that mirrors it, both carry the qualifier — an
unqualified "rules carrying a `$` modifier" contradicts the parser.
- PLAN.md:101 (§3.8) currently reads "regex lines are counted + skipped
(counts in metadata → UI)". It becomes: regex lines *and* browser-syntax
lines are counted and skipped, both counts in metadata → UI. One sentence;
§2.2 (PLAN.md:36-37) already says unsupported forms "stay unsupported and
counted" and needs no edit.
- `docs/reference/files-and-directories.md` is untouched: the compiled-file
header already carried `# skipped_unsupported` before this milestone and
the file table there does not enumerate header lines.
### 7. The other three counters stay out, and the reasons differ per counter
The first draft claimed `invalid`, `long_lines` and `duplicates` were already
"header-and-error-path only". That is false:
- `invalid` reaches the compiled-file header (manager.zig:244) and the
`NoValidEntries` text (manager.zig:1127).
- `long_lines` reaches the `NoValidEntries` text only — not the header.
- `duplicates` reaches **nothing**: counted at compile, discarded on every
path.
They stay out of scope, and not for one shared reason — which is why the goal
above says "the silent drop that misleads the operator" rather than "the one
silent drop left":
- `duplicates` hides no failure. A deduplicated name still blocks; the count
measures input redundancy, not lost coverage. Discarding it discards a
curiosity, so "no silent drops" does not reach it — there is no drop.
- `invalid` and `long_lines` do measure dropped lines, and they remain only
partially surfaced. The catastrophic form — a download that is all rejects —
already fails loudly (`rejectedWithoutEntries`, state `no_valid_entries`);
the residual is a list that loses some lines and still loads, visible in
the header file and nowhere the UI reaches. That residual is real, it is
recorded here deliberately, and it is not this milestone: the two skip
counters name an action the operator can take (adopt the patterns; fetch
the DNS variant), while these two say only "the list is malformed", which
no column makes more actionable.
The decision is reversible; a later milestone can widen the table. What this
spec may not do is claim the three are visible when `duplicates` is not.
### 8. Export, import and reconcile change nothing
`nxdns export` / `import` carry configuration; the compile counters are facts
a running server produces. `model.BlocklistSource` holds only the four
configuration columns, the insert leaves runtime columns at their defaults
(sources_repo.zig:1-9, :48-61), and the dump helpers used by the import and
reconcile suites are `SELECT *` compared dump-to-dump
(`src/config/import.zig:167-186`, `src/config/reconcile.zig:988`), so no
golden text names columns and the new column appears in both sides of every
comparison. Ruling 3's reconcile-test extension is the only touch in
`src/config/`, plus the model.zig:14 comment from ruling 2.
## Sessions
Two sessions, strictly sequential: S1 then S2. No parallelism — deliberately.
The regenerated `contractSamples.gen.ts` typechecks only against a `types.ts`
that already carries the new fields, and the samples are produced by a Zig
integration run, so the generator and the interface it must satisfy sit on
opposite sides of the language boundary and cannot land independently. S2's
rendering assertions then read fields that only exist once S1 has landed both.
(The mocks are *not* part of this argument: they are untyped, so widening
`types.ts` does not break them. The first draft claimed otherwise.)
### Session S1: column, persistence, API, contract
Owns: `src/storage/config_schema.zig`, `src/storage/migrations.zig` (test
only), `src/storage/repositories/sources_repo.zig`, `src/filter/manager.zig`,
`src/filter/filter_integration_test.zig`, `src/config/reconcile.zig` (test
only), `src/config/model.zig` (comment only),
`src/web/handlers/blocklists.zig`, `src/web/openapi.yaml`,
`web/src/lib/types.ts`, `web/src/lib/contractSamples.gen.ts` (regenerated,
never hand-edited), `web/src/features/blocklists/BlocklistsPage.test.tsx`
(mock fields only — no rendering assertions),
`PLAN.md` (the §11.2 line and
the §3.8 sentence).
- S1.1 rulings 1 and 2: the DDL line in both copies, the repo columns, the
migrations and repo tests.
- S1.2 ruling 3: both `updateSourceStats` call sites, rehydration, test
literals, the reconcile preservation assertion.
- S1.3 ruling 4: `StatusView`, openapi.yaml, sample regeneration, `types.ts`,
mock fields.
Acceptance (S1):
- [ ] `zig build test` passes; the migrations baseline test proves
`blocklist_sources.skipped_unsupported_count` exists.
- [ ] A compile of the new **ABP-format** fixture carrying `##.ad-banner` and
`||ads.example^$third-party` persists `skipped_unsupported_count = 2`
through `updateSourceStats` and reads it back through `listSourceRows`.
The existing hosts-shaped fixture is unchanged, and every count it
already asserts still holds.
- [ ] A second compile whose written entries are byte-identical but whose
skipped lines differ produces the same checksum and still updates both
skip counters in the database; the test was watched failing with the
manager.zig:830 fix reverted, and the failure output is recorded.
- [ ] `applyLoadOutcomes` fed a row with `skipped_unsupported_count = 5` and
no live refresh yields `status.counts.skipped_unsupported == 5`.
- [ ] `zig build test -Dintegration` passes, and the regenerated
`contractSamples.gen.ts` carries `skipped_unsupported_count` in the
Blocklist sample and `skipped_unsupported` in the SourceStatus sample.
- [ ] `cd web && npm run typecheck && npm test` pass with the widened types.
### Session S2: UI and docs (needs S1)
Owns: `web/src/features/blocklists/BlocklistsPage.tsx`,
`web/src/features/blocklists/SourceStatusSection.tsx`,
`web/src/features/blocklists/BlocklistsPage.test.tsx` (rendering assertions),
`docs/reference/configuration.md`, `docs/tutorial/first-run.md`.
- S2.1 ruling 5: both columns, the note paragraph, the rendering assertions.
- S2.2 ruling 6: the two doc edits.
Acceptance (S2):
- [ ] `cd web && npm run typecheck && npm test && npm run lint` pass; the new
tests assert both `Skipped unsupported` headers, the mock values and the
note text.
- [ ] `npm run build` passes (`assert-css-layers.mjs` runs inside it).
- [ ] `docs/tutorial/first-run.md` no longer says "three zeros", its JSON
sample carries `skipped_unsupported`, and its `skipped_regex` sentence
is unchanged.
### Orchestrator
Verify S1 acceptance before starting S2. After S2, run the full gate set
(`zig build test`, `zig build test -Dintegration`, `test-aarch64` if qemu is
present, `cd web && npm test`, `npm run assert-bundled`), then a live smoke
against a scratch server with two real sources:
- `https://easylist.to/easylist/easylist.txt` — a browser-targeted list;
expect a `skipped_unsupported` several times its `domains` (do not assert an
exact number; assert the ratio and non-zero).
- `https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts` — expect
`skipped_unsupported` 0.
Verify the numbers appear in the `POST /api/blocklists/update` response, in
`GET /api/blocklists`, and in both UI tables. Then restart the server and
verify the counts survive into the status table without a refresh — that is
ruling 3's rehydration working against the real database. Record deviations
in `## Recorded (implementation)`.
## Module layout
New files: none. Deleted surface: none.
## File ownership
| File | Session |
| --- | --- |
| `src/storage/config_schema.zig` | S1 |
| `src/storage/migrations.zig` | S1 |
| `src/storage/repositories/sources_repo.zig` | S1 |
| `src/filter/manager.zig` | S1 |
| `src/filter/filter_integration_test.zig` | S1 |
| `src/config/reconcile.zig` (test), `src/config/model.zig` (comment) | S1 |
| `src/web/handlers/blocklists.zig`, `src/web/openapi.yaml` | S1 |
| `web/src/lib/types.ts`, `web/src/lib/contractSamples.gen.ts` | S1 |
| `PLAN.md` (§11.2 line, §3.8 sentence) | S1 |
| `web/src/features/blocklists/BlocklistsPage.test.tsx` | S1 (mock fields), then S2 (assertions) — sequential, never concurrent |
| `web/src/features/blocklists/BlocklistsPage.tsx`, `web/src/features/blocklists/SourceStatusSection.tsx` | S2 |
| `docs/reference/configuration.md`, `docs/tutorial/first-run.md` | S2 |
## Acceptance (milestone complete)
- [ ] All session acceptance boxes above.
- [ ] `config_schema.ddl_v1` and PLAN §11.2 are byte-identical, both carrying
the new line; `migrations.steps` still holds exactly one step and
`target_version` is still 1.
- [ ] The live smoke: EasyList shows a large `skipped_unsupported` and
StevenBlack shows 0, in the API and in both UI tables, and both values
survive a server restart.
- [ ] `nxdns export` output is byte-identical before and after a refresh that
wrote the new column (runtime columns stay out of exports).
- [ ] The regenerated contract samples typecheck against `web/src/lib/types.ts`
— that is what the sample mechanism proves, and it covers server-to-
TypeScript agreement only.
- [ ] `src/web/openapi.yaml` is reviewed **by hand** against the two changed
response shapes, and the reviewer says so in `## Recorded`. No automated
guard covers this: `web_integration_test.zig:2089` checks that routes and
methods exist and `:2575` counts operations, but nothing compares a
component schema to a real response. An openapi.yaml that omits the new
field will pass every gate in this repo.
## Anti-requirements
- No migration step, no `ddl_v2`, no runtime schema probing. The baseline is
edited in place per §3.7; a pre-edit scratch database is deleted, not
reconciled.
- No merging of the two skip counters into one number, anywhere — not in the
API, not in a UI total, not in prose. They are different facts.
- No "this list targets browsers" heuristic: no threshold, badge, warning
color or ratio computation in the UI. The number plus the note paragraph is
the surface; a cutoff would be an invented policy.
- No conditional rendering of the new column. It shows at 0 like every other
counter column.
- No change to `nxdns export` / `import` ZON: compile statistics are not
configuration.
- No per-line diagnostics, no sample of skipped lines in logs, API or UI —
counters and health surfaces, not log spam (AGENTS.md).
- No change to `rejectedWithoutEntries` or `failNoValidEntries` semantics.
- No hand edits to `contractSamples.gen.ts`.
- No new columns beyond the one. See ruling 7 for what that leaves open and
why — the reason is a scope decision, not a claim that the other counters
are already visible.
+2 -2
View File
@@ -11,8 +11,8 @@
//! Runtime columns are deliberately absent. `clients.first_seen`, //! Runtime columns are deliberately absent. `clients.first_seen`,
//! `clients.last_seen`, `rules.created_at` and //! `clients.last_seen`, `rules.created_at` and
//! `blocklist_sources.{last_updated, domain_count, wildcard_count, //! `blocklist_sources.{last_updated, domain_count, wildcard_count,
//! exception_count, skipped_regex_count, checksum}` are facts a running server //! exception_count, skipped_regex_count, skipped_unsupported_count, checksum}`
//! produces, not configuration. Including them would make two exports taken //! are facts a running server produces, not configuration. Including them would make two exports taken
//! minutes apart differ, which would make the byte-stable round trip untestable //! minutes apart differ, which would make the byte-stable round trip untestable
//! against a live server. //! against a live server.
//! //!
+3
View File
@@ -1102,6 +1102,7 @@ fn seedSourceStats(database: *db.Db, id: i64) !void {
.wildcard_count = 21, .wildcard_count = 21,
.exception_count = 9, .exception_count = 9,
.skipped_regex_count = 7, .skipped_regex_count = 7,
.skipped_unsupported_count = 33,
.checksum = "a" ** 64, .checksum = "a" ** 64,
}); });
} }
@@ -1171,6 +1172,8 @@ test "a source keeps its id, its checksum and its counters across a reconcile" {
try testing.expectEqual(@as(?i64, 1_700_000_000), row.last_updated); try testing.expectEqual(@as(?i64, 1_700_000_000), row.last_updated);
try testing.expectEqual(@as(i64, 4321), row.domain_count); try testing.expectEqual(@as(i64, 4321), row.domain_count);
try testing.expectEqual(@as(i64, 9), row.exception_count); try testing.expectEqual(@as(i64, 9), row.exception_count);
try testing.expectEqual(@as(i64, 7), row.skipped_regex_count);
try testing.expectEqual(@as(i64, 33), row.skipped_unsupported_count);
try testing.expectEqualStrings("a" ** 64, row.checksum.?); try testing.expectEqualStrings("a" ** 64, row.checksum.?);
} }
+172 -1
View File
@@ -121,6 +121,27 @@ const http_domains: i64 = 2;
const http_wildcards: i64 = 1; const http_wildcards: i64 = 1;
const http_exceptions: i64 = 0; const http_exceptions: i64 = 0;
const http_regex: i64 = 1; const http_regex: i64 = 1;
/// Zero, and asserted rather than assumed: a hosts list carries no line a DNS
/// sinkhole cannot translate, which is what makes the abp fixture below a
/// separate source instead of two more lines in this one.
const http_unsupported: i64 = 0;
/// An ABP-format list: one element-hiding rule and one `$`-modifier rule that
/// nxdns counts and skips, beside two names it blocks. `detectFormat` assigns
/// one format to a whole source, so these lines cannot join `http_body` — a
/// single `##` there would re-parse every hosts line as ABP.
const abp_body =
"! small abp list\n" ++
"##.ad-banner\n" ++
"||ads.example^$third-party\n" ++
"||blocked.example^\n" ++
"tracker.example\n";
/// `||blocked.example^` covers its own apex, so it writes one `.list` entry
/// beside its `.wild` one; `tracker.example` writes the second.
const abp_domains: i64 = 2;
const abp_wildcards: i64 = 1;
const abp_unsupported: i64 = 2;
/// Compiles `text` into `<base>.list`, `<base>.wild` and `<base>.allow` under /// Compiles `text` into `<base>.list`, `<base>.wild` and `<base>.allow` under
/// `dir`, exactly as the manager's compile stage does, and returns the /// `dir`, exactly as the manager's compile stage does, and returns the
@@ -393,7 +414,7 @@ const Env = struct {
// fixtures: the loopback http server // fixtures: the loopback http server
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const Route = enum(u8) { body, redirect, not_found, oversize, chunked, stall }; const Route = enum(u8) { body, redirect, not_found, oversize, chunked, stall, changed };
/// How long the `stall` route holds a reply open when nothing releases it. /// How long the `stall` route holds a reply open when nothing releases it.
/// ///
@@ -440,6 +461,11 @@ const oversize_length = "104857600";
const HttpFixture = struct { const HttpFixture = struct {
server: net.Server, server: net.Server,
body: []const u8, body: []const u8,
/// What the `changed` route serves: the same list after its author edited
/// it. A test sets this before the serving task starts and reaches it by
/// switching the route, so the two bodies are read through the atomic that
/// selects them and never written beside a request in flight.
changed_body: []const u8,
route: std.atomic.Value(u8), route: std.atomic.Value(u8),
/// Connections accepted, whatever came over them. A test that claims a pass /// Connections accepted, whatever came over them. A test that claims a pass
/// downloaded nothing reads this rather than the route counters: a refetch /// downloaded nothing reads this rather than the route counters: a refetch
@@ -466,6 +492,7 @@ const HttpFixture = struct {
return .{ return .{
.server = try local.listen(io, .{ .reuse_address = true }), .server = try local.listen(io, .{ .reuse_address = true }),
.body = body, .body = body,
.changed_body = "",
.route = .init(@intFromEnum(Route.body)), .route = .init(@intFromEnum(Route.body)),
.accepted = .init(0), .accepted = .init(0),
.flushed_parts = .init(0), .flushed_parts = .init(0),
@@ -514,6 +541,7 @@ const HttpFixture = struct {
fn respond(self: *HttpFixture, io: std.Io, request: *std.http.Server.Request) !void { fn respond(self: *HttpFixture, io: std.Io, request: *std.http.Server.Request) !void {
switch (@as(Route, @enumFromInt(self.route.load(.acquire)))) { switch (@as(Route, @enumFromInt(self.route.load(.acquire)))) {
.body => try request.respond(self.body, .{ .keep_alive = false }), .body => try request.respond(self.body, .{ .keep_alive = false }),
.changed => try request.respond(self.changed_body, .{ .keep_alive = false }),
.redirect => if (std.mem.eql(u8, request.head.target, redirect_path)) .redirect => if (std.mem.eql(u8, request.head.target, redirect_path))
try request.respond(self.body, .{ .keep_alive = false }) try request.respond(self.body, .{ .keep_alive = false })
else else
@@ -824,6 +852,7 @@ test "3: a damaged compiled file never replaces a serving snapshot with a worse
.wildcard_count = 1, .wildcard_count = 1,
.exception_count = 0, .exception_count = 0,
.skipped_regex_count = 0, .skipped_regex_count = 0,
.skipped_unsupported_count = 0,
.checksum = &bodyChecksum(good_list, good_wild, ""), .checksum = &bodyChecksum(good_list, good_wild, ""),
}); });
@@ -856,6 +885,7 @@ test "3: a damaged compiled file never replaces a serving snapshot with a worse
.wildcard_count = 1, .wildcard_count = 1,
.exception_count = 0, .exception_count = 0,
.skipped_regex_count = 0, .skipped_regex_count = 0,
.skipped_unsupported_count = 0,
.checksum = &bodyChecksum(unsorted_list, good_wild, ""), .checksum = &bodyChecksum(unsorted_list, good_wild, ""),
}); });
@@ -913,6 +943,7 @@ test "4: a 200 response is fetched, compiled, recorded and served" {
try testing.expectEqual(http_domains, row.domain_count); try testing.expectEqual(http_domains, row.domain_count);
try testing.expectEqual(http_wildcards, row.wildcard_count); try testing.expectEqual(http_wildcards, row.wildcard_count);
try testing.expectEqual(http_regex, row.skipped_regex_count); try testing.expectEqual(http_regex, row.skipped_regex_count);
try testing.expectEqual(http_unsupported, row.skipped_unsupported_count);
try testing.expectEqual(@as(usize, 64), (row.checksum orelse return error.TestNoChecksum).len); try testing.expectEqual(@as(usize, 64), (row.checksum orelse return error.TestNoChecksum).len);
try testing.expect(row.last_updated != null); try testing.expect(row.last_updated != null);
@@ -1073,6 +1104,7 @@ test "8: refetching identical content skips the rewrite and still moves last_upd
.wildcard_count = http_wildcards, .wildcard_count = http_wildcards,
.exception_count = http_exceptions, .exception_count = http_exceptions,
.skipped_regex_count = http_regex, .skipped_regex_count = http_regex,
.skipped_unsupported_count = http_unsupported,
.checksum = blk: { .checksum = blk: {
var rows = try listRows(&env.database); var rows = try listRows(&env.database);
defer rows.deinit(); defer rows.deinit();
@@ -1143,6 +1175,7 @@ fn publishFixtureFiles(env: *Env, id: i64, list_body: []const u8, wild_body: []c
.wildcard_count = 0, .wildcard_count = 0,
.exception_count = 0, .exception_count = 0,
.skipped_regex_count = 0, .skipped_regex_count = 0,
.skipped_unsupported_count = 0,
.checksum = &bodyChecksum(list_body, wild_body, ""), .checksum = &bodyChecksum(list_body, wild_body, ""),
}); });
} }
@@ -1255,6 +1288,7 @@ test "10b: the scheduler sweeps orphans on its own, with no operator call" {
.wildcard_count = 0, .wildcard_count = 0,
.exception_count = 0, .exception_count = 0,
.skipped_regex_count = 0, .skipped_regex_count = 0,
.skipped_unsupported_count = 0,
.checksum = &bodyChecksum(list_body, "", ""), .checksum = &bodyChecksum(list_body, "", ""),
}); });
@@ -2156,6 +2190,7 @@ test "20: a data directory written before exceptions existed loads with no check
.wildcard_count = 1, .wildcard_count = 1,
.exception_count = 0, .exception_count = 0,
.skipped_regex_count = 0, .skipped_regex_count = 0,
.skipped_unsupported_count = 0,
.checksum = &legacy_checksum, .checksum = &legacy_checksum,
}); });
@@ -2170,3 +2205,139 @@ test "20: a data directory written before exceptions existed loads with no check
try testing.expect(decision.blocked); try testing.expect(decision.blocked);
try testing.expect((try env.evaluate("x.ccc.example.com"))[0].blocked); try testing.expect((try env.evaluate("x.ccc.example.com"))[0].blocked);
} }
// ---------------------------------------------------------------------------
// 2122: the unsupported count, from the compile to the database and back
// ---------------------------------------------------------------------------
test "21: an abp list's unsupported lines are counted, persisted and read back" {
if (!build_options.integration) return error.SkipZigTest;
const gpa = testing.allocator;
const env = try Env.create(gpa);
defer env.destroy();
const io = env.io();
var fixture = try HttpFixture.init(io, abp_body);
defer fixture.deinit(io);
var group: std.Io.Group = .init;
defer group.cancel(io);
try group.concurrent(io, HttpFixture.serve, .{ &fixture, io });
var url_buf: [64]u8 = undefined;
const url = try fixture.url(&url_buf);
const id = try seedSource(&env.database, url);
try testing.expect(try refreshOnce(env, url));
try env.mgr.reload(io);
var rows = try listRows(&env.database);
defer rows.deinit();
const row = try rows.byUrl(url);
try testing.expectEqual(abp_domains, row.domain_count);
try testing.expectEqual(abp_wildcards, row.wildcard_count);
try testing.expectEqual(@as(i64, 0), row.skipped_regex_count);
try testing.expectEqual(abp_unsupported, row.skipped_unsupported_count);
const status = try env.status(id);
try testing.expectEqual(manager.State.ok, status.state);
try testing.expectEqual(@as(u32, @intCast(abp_unsupported)), status.counts.skipped_unsupported);
// What the number costs the operator: the `$`-modifier rule named a domain
// and blocked nothing, while the two lines nxdns could translate did block.
try testing.expect(!(try env.evaluate("ads.example"))[0].blocked);
try testing.expect((try env.evaluate("blocked.example"))[0].blocked);
try testing.expect((try env.evaluate("tracker.example"))[0].blocked);
}
/// The same list before and after its author edited only lines nxdns skips.
/// The written entries are identical in both, so the two compiles produce one
/// checksum and the refresh takes the unchanged-checksum path.
const churn_before =
"! churn fixture\n" ++
"##.ad-one\n" ++
"||blocked.example^\n" ++
"tracker.example\n";
const churn_after =
"! churn fixture\n" ++
"##.ad-one\n" ++
"##.ad-two\n" ++
"/ads[0-9]+/\n" ++
"||blocked.example^\n" ++
"tracker.example\n";
test "22: a list that changed only its skipped lines still updates both skip counters" {
if (!build_options.integration) return error.SkipZigTest;
const gpa = testing.allocator;
const env = try Env.create(gpa);
defer env.destroy();
const io = env.io();
var fixture = try HttpFixture.init(io, churn_before);
fixture.changed_body = churn_after;
defer fixture.deinit(io);
var group: std.Io.Group = .init;
defer group.cancel(io);
try group.concurrent(io, HttpFixture.serve, .{ &fixture, io });
var url_buf: [64]u8 = undefined;
const url = try fixture.url(&url_buf);
const id = try seedSource(&env.database, url);
try testing.expect(try refreshOnce(env, url));
var first_checksum: [64]u8 = undefined;
{
var rows = try listRows(&env.database);
defer rows.deinit();
const row = try rows.byUrl(url);
try testing.expectEqual(@as(i64, 0), row.skipped_regex_count);
try testing.expectEqual(@as(i64, 1), row.skipped_unsupported_count);
@memcpy(&first_checksum, row.checksum orelse return error.TestNoChecksum);
}
// The edited list. Two more skipped lines and not one written entry moved,
// so the refresh finds its stored checksum and rewrites nothing on disk.
fixture.setRoute(.changed);
try testing.expect(!try refreshOnce(env, url));
{
var rows = try listRows(&env.database);
defer rows.deinit();
const row = try rows.byUrl(url);
try testing.expectEqualStrings(&first_checksum, row.checksum orelse return error.TestNoChecksum);
// The three written counts are the ones an unchanged checksum vouches
// for; the two skip counts are the ones it says nothing about.
try testing.expectEqual(abp_domains, row.domain_count);
try testing.expectEqual(abp_wildcards, row.wildcard_count);
try testing.expectEqual(@as(i64, 1), row.skipped_regex_count);
try testing.expectEqual(@as(i64, 2), row.skipped_unsupported_count);
}
const live = try env.status(id);
try testing.expectEqual(@as(u32, 1), live.counts.skipped_regex);
try testing.expectEqual(@as(u32, 2), live.counts.skipped_unsupported);
// The restart. A new manager over the same database and the same files
// carries nothing across in memory, so the status it publishes is what
// rehydration read out of the row — which is the only reason writing the
// fresh counts above matters.
env.mgr.deinit(io);
env.mgr = try manager.Manager.init(
gpa,
&env.database,
.{ .dir = env.tmp.dir },
&env.f,
.{ .enabled = false },
budget,
);
try env.mgr.reload(io);
const restored = try env.status(id);
try testing.expectEqual(manager.State.ok, restored.state);
try testing.expect(restored.loaded);
try testing.expectEqual(@as(u32, 1), restored.counts.skipped_regex);
try testing.expectEqual(@as(u32, 2), restored.counts.skipped_unsupported);
}
+19 -2
View File
@@ -822,12 +822,21 @@ pub const Manager = struct {
if (std.mem.eql(u8, stored, &compiled.result.checksum) and if (std.mem.eql(u8, stored, &compiled.result.checksum) and
self.diskBodiesMatch(io, dir, row.id, stored)) self.diskBodiesMatch(io, dir, row.id, stored))
{ {
// The three written counts come from the row: the checksum
// covers the three bodies, so an unchanged checksum means an
// unchanged number of entries in each. The two skip counts do
// not: a skipped line lands in no body, so a list that changed
// only its regex or browser-syntax lines arrives here with a
// stale row and a fresh compile. Taking them from the row would
// show the operator one number and restore another after a
// restart.
try sources_repo.updateSourceStats(self.database, row.id, .{ try sources_repo.updateSourceStats(self.database, row.id, .{
.last_updated = now, .last_updated = now,
.domain_count = row.domain_count, .domain_count = row.domain_count,
.wildcard_count = row.wildcard_count, .wildcard_count = row.wildcard_count,
.exception_count = row.exception_count, .exception_count = row.exception_count,
.skipped_regex_count = row.skipped_regex_count, .skipped_regex_count = compiled.result.counts.skipped_regex,
.skipped_unsupported_count = compiled.result.counts.skipped_unsupported,
.checksum = stored, .checksum = stored,
}); });
status.succeed(now, compiled.result.counts); status.succeed(now, compiled.result.counts);
@@ -857,6 +866,7 @@ pub const Manager = struct {
.wildcard_count = compiled.result.counts.wildcards, .wildcard_count = compiled.result.counts.wildcards,
.exception_count = compiled.result.counts.exceptions, .exception_count = compiled.result.counts.exceptions,
.skipped_regex_count = compiled.result.counts.skipped_regex, .skipped_regex_count = compiled.result.counts.skipped_regex,
.skipped_unsupported_count = compiled.result.counts.skipped_unsupported,
.checksum = &compiled.result.checksum, .checksum = &compiled.result.checksum,
}); });
status.succeed(now, compiled.result.counts); status.succeed(now, compiled.result.counts);
@@ -1537,7 +1547,7 @@ fn applyLoadOutcomes(
entry.loaded = true; entry.loaded = true;
// Two states survive a successful load. `.ok`, because a // Two states survive a successful load. `.ok`, because a
// refresh in this process already filled the counters the // refresh in this process already filled the counters the
// compile produced and the three database columns are a subset // compile produced and the five database columns are a subset
// of them. And any refresh failure, because the files that just // of them. And any refresh failure, because the files that just
// loaded are exactly the ones the failed refresh could not // loaded are exactly the ones the failed refresh could not
// replace, so the operator must still see why. // replace, so the operator must still see why.
@@ -1547,6 +1557,7 @@ fn applyLoadOutcomes(
.wildcards = countOf(row.wildcard_count), .wildcards = countOf(row.wildcard_count),
.exceptions = countOf(row.exception_count), .exceptions = countOf(row.exception_count),
.skipped_regex = countOf(row.skipped_regex_count), .skipped_regex = countOf(row.skipped_regex_count),
.skipped_unsupported = countOf(row.skipped_unsupported_count),
}); });
}, },
.failed => |reason| { .failed => |reason| {
@@ -1926,6 +1937,7 @@ test "a canceled compiled-file read cancels the reload instead of recording it"
.domain_count = 1, .domain_count = 1,
.wildcard_count = 0, .wildcard_count = 0,
.skipped_regex_count = 0, .skipped_regex_count = 0,
.skipped_unsupported_count = 0,
.exception_count = 0, .exception_count = 0,
.checksum = &bodyChecksum(list_body, wild_body, allow_body), .checksum = &bodyChecksum(list_body, wild_body, allow_body),
}); });
@@ -2049,6 +2061,7 @@ test "the log label names a source without printing what its url carries" {
.domain_count = 0, .domain_count = 0,
.wildcard_count = 0, .wildcard_count = 0,
.skipped_regex_count = 0, .skipped_regex_count = 0,
.skipped_unsupported_count = 0,
.checksum = null, .checksum = null,
}; };
const printed = try std.fmt.bufPrint(&buf, "blocklist {f}: download failed: {s}", .{ const printed = try std.fmt.bufPrint(&buf, "blocklist {f}: download failed: {s}", .{
@@ -2223,6 +2236,7 @@ fn testRow(id: i64, enabled: bool) sources_repo.SourceRow {
.wildcard_count = 4, .wildcard_count = 4,
.exception_count = 2, .exception_count = 2,
.skipped_regex_count = 1, .skipped_regex_count = 1,
.skipped_unsupported_count = 5,
.checksum = "0" ** 64, .checksum = "0" ** 64,
}; };
} }
@@ -2333,6 +2347,9 @@ test "a load of a source this process never refreshed takes the row counters" {
try testing.expectEqual(@as(u32, 9), statuses[0].counts.domains); try testing.expectEqual(@as(u32, 9), statuses[0].counts.domains);
try testing.expectEqual(@as(u32, 4), statuses[0].counts.wildcards); try testing.expectEqual(@as(u32, 4), statuses[0].counts.wildcards);
try testing.expectEqual(@as(u32, 1), statuses[0].counts.skipped_regex); try testing.expectEqual(@as(u32, 1), statuses[0].counts.skipped_regex);
// Rehydration: a restart reads this from the row and nowhere else, because
// no path reparses a compiled file's header.
try testing.expectEqual(@as(u32, 5), statuses[0].counts.skipped_unsupported);
} }
test "a status borrows nothing, so a copy outlives the table it came from" { test "a status borrows nothing, so a copy outlives the table it came from" {
+1
View File
@@ -61,6 +61,7 @@ pub const ddl_v1: [:0]const u8 =
\\ wildcard_count INTEGER NOT NULL DEFAULT 0, \\ wildcard_count INTEGER NOT NULL DEFAULT 0,
\\ exception_count INTEGER NOT NULL DEFAULT 0, \\ exception_count INTEGER NOT NULL DEFAULT 0,
\\ skipped_regex_count INTEGER NOT NULL DEFAULT 0, \\ skipped_regex_count INTEGER NOT NULL DEFAULT 0,
\\ skipped_unsupported_count INTEGER NOT NULL DEFAULT 0,
\\ checksum TEXT \\ checksum TEXT
\\); \\);
\\ \\
+1
View File
@@ -271,6 +271,7 @@ test "a fresh database reaches the baseline with every v1 column and rule kind"
try testing.expectEqual(@as(u32, 1), target_version); try testing.expectEqual(@as(u32, 1), target_version);
try testing.expect(try columnExists(&database, "upstreams", "tls_name")); try testing.expect(try columnExists(&database, "upstreams", "tls_name"));
try testing.expect(try columnExists(&database, "blocklist_sources", "exception_count")); try testing.expect(try columnExists(&database, "blocklist_sources", "exception_count"));
try testing.expect(try columnExists(&database, "blocklist_sources", "skipped_unsupported_count"));
try database.exec( try database.exec(
\\INSERT INTO rules (group_id, pattern, kind, action, created_at) \\INSERT INTO rules (group_id, pattern, kind, action, created_at)
+26 -7
View File
@@ -1,9 +1,10 @@
//! `blocklist_sources`. //! `blocklist_sources`.
//! //!
//! Only the four configuration columns are read and written. `last_updated`, //! Only the four configuration columns are read and written. `last_updated`,
//! `domain_count`, `wildcard_count`, `exception_count`, `skipped_regex_count` //! `domain_count`, `wildcard_count`, `exception_count`, `skipped_regex_count`,
//! and `checksum` are facts a running server produces; an insert leaves them at //! `skipped_unsupported_count` and `checksum` are facts a running server
//! their column defaults so two exports taken minutes apart stay identical. //! produces; an insert leaves them at their column defaults so two exports taken
//! minutes apart stay identical.
//! //!
//! The import path is list / insert / deleteAll / count; the runtime columns and //! The import path is list / insert / deleteAll / count; the runtime columns and
//! the REST surface follow it, both keyed by row id. //! the REST surface follow it, both keyed by row id.
@@ -93,6 +94,12 @@ pub const SourceRow = struct {
/// builds `SourceRow` values from the refresh columns alone. /// builds `SourceRow` values from the refresh columns alone.
exception_count: i64 = 0, exception_count: i64 = 0,
skipped_regex_count: i64, skipped_regex_count: i64,
/// Lines the compiler read and could not translate into a DNS decision:
/// cosmetic element hiding, `$`-modifier rules (save the tolerated
/// `$important` exception suffix, which lands in `exception_count`), scheme
/// anchors. Counted and not written, like `skipped_regex_count` and unlike
/// the three counts above.
skipped_unsupported_count: i64,
checksum: ?[]const u8, checksum: ?[]const u8,
}; };
@@ -102,6 +109,7 @@ pub const SourceStats = struct {
wildcard_count: i64, wildcard_count: i64,
exception_count: i64, exception_count: i64,
skipped_regex_count: i64, skipped_regex_count: i64,
skipped_unsupported_count: i64,
/// Lowercase hex sha256 over the `.list` body, then the `.wild` body, then /// Lowercase hex sha256 over the `.list` body, then the `.wild` body, then
/// the `.allow` body. The allow body is hashed last so an empty one leaves /// the `.allow` body. The allow body is hashed last so an empty one leaves
/// the digest of a two-body compile unchanged, which is what keeps a /// the digest of a two-body compile unchanged, which is what keeps a
@@ -112,7 +120,7 @@ pub const SourceStats = struct {
const row_columns_sql = const row_columns_sql =
\\SELECT id, url, name, enabled, last_updated, \\SELECT id, url, name, enabled, last_updated,
\\ domain_count, wildcard_count, skipped_regex_count, checksum, \\ domain_count, wildcard_count, skipped_regex_count, checksum,
\\ is_suggested, exception_count \\ is_suggested, exception_count, skipped_unsupported_count
\\ FROM blocklist_sources \\ FROM blocklist_sources
; ;
@@ -143,6 +151,7 @@ fn readSourceRow(stmt: *db.Stmt, gpa: Allocator) db.Error!SourceRow {
.wildcard_count = stmt.columnInt(6), .wildcard_count = stmt.columnInt(6),
.exception_count = stmt.columnInt(10), .exception_count = stmt.columnInt(10),
.skipped_regex_count = stmt.columnInt(7), .skipped_regex_count = stmt.columnInt(7),
.skipped_unsupported_count = stmt.columnInt(11),
.checksum = checksum, .checksum = checksum,
}; };
} }
@@ -159,7 +168,8 @@ pub fn freeSourceRows(gpa: Allocator, items: []const SourceRow) void {
const update_stats_sql = const update_stats_sql =
\\UPDATE blocklist_sources \\UPDATE blocklist_sources
\\ SET last_updated = ?2, domain_count = ?3, wildcard_count = ?4, \\ SET last_updated = ?2, domain_count = ?3, wildcard_count = ?4,
\\ skipped_regex_count = ?5, checksum = ?6, exception_count = ?7 \\ skipped_regex_count = ?5, checksum = ?6, exception_count = ?7,
\\ skipped_unsupported_count = ?8
\\ WHERE id = ?1 \\ WHERE id = ?1
; ;
@@ -175,6 +185,7 @@ pub fn updateSourceStats(database: *db.Db, id: i64, stats: SourceStats) db.Error
try stmt.bindInt(5, stats.skipped_regex_count); try stmt.bindInt(5, stats.skipped_regex_count);
try stmt.bindText(6, stats.checksum); try stmt.bindText(6, stats.checksum);
try stmt.bindInt(7, stats.exception_count); try stmt.bindInt(7, stats.exception_count);
try stmt.bindInt(8, stats.skipped_unsupported_count);
try stmt.exec(); try stmt.exec();
} }
@@ -315,8 +326,11 @@ test "insertBlocklistSource leaves the runtime columns at their defaults" {
try testing.expectEqual( try testing.expectEqual(
@as(i64, 0), @as(i64, 0),
try database.queryInt( try database.queryInt(
"SELECT sum(domain_count + wildcard_count + exception_count + skipped_regex_count)" ++ // Every runtime counter, summed only because this asserts they are
" FROM blocklist_sources", // all 0. No production query may add the two skip counters to the
// three written ones: a skipped line was never written.
"SELECT sum(domain_count + wildcard_count + exception_count + skipped_regex_count" ++
" + skipped_unsupported_count) FROM blocklist_sources",
), ),
); );
} }
@@ -370,6 +384,7 @@ test "listSourceRows returns row ids and the runtime columns in url order" {
try testing.expectEqual(@as(i64, 0), row.wildcard_count); try testing.expectEqual(@as(i64, 0), row.wildcard_count);
try testing.expectEqual(@as(i64, 0), row.exception_count); try testing.expectEqual(@as(i64, 0), row.exception_count);
try testing.expectEqual(@as(i64, 0), row.skipped_regex_count); try testing.expectEqual(@as(i64, 0), row.skipped_regex_count);
try testing.expectEqual(@as(i64, 0), row.skipped_unsupported_count);
} }
} }
@@ -389,6 +404,7 @@ test "updateSourceStats writes the runtime columns of one source only" {
.wildcard_count = 21, .wildcard_count = 21,
.exception_count = 9, .exception_count = 9,
.skipped_regex_count = 7, .skipped_regex_count = 7,
.skipped_unsupported_count = 15,
.checksum = "a" ** 64, .checksum = "a" ** 64,
}); });
@@ -406,6 +422,7 @@ test "updateSourceStats writes the runtime columns of one source only" {
try testing.expectEqual(@as(i64, 21), updated.wildcard_count); try testing.expectEqual(@as(i64, 21), updated.wildcard_count);
try testing.expectEqual(@as(i64, 9), updated.exception_count); try testing.expectEqual(@as(i64, 9), updated.exception_count);
try testing.expectEqual(@as(i64, 7), updated.skipped_regex_count); try testing.expectEqual(@as(i64, 7), updated.skipped_regex_count);
try testing.expectEqual(@as(i64, 15), updated.skipped_unsupported_count);
try testing.expectEqualStrings("a" ** 64, updated.checksum.?); try testing.expectEqualStrings("a" ** 64, updated.checksum.?);
// The two untouched rows kept their defaults. // The two untouched rows kept their defaults.
@@ -423,6 +440,7 @@ fn listSourceRowsUnderFailure(gpa: Allocator) !void {
.wildcard_count = 3, .wildcard_count = 3,
.exception_count = 5, .exception_count = 5,
.skipped_regex_count = 4, .skipped_regex_count = 4,
.skipped_unsupported_count = 6,
.checksum = "b" ** 64, .checksum = "b" ** 64,
}); });
@@ -489,6 +507,7 @@ test "updateSource leaves the runtime columns where the refresh path left them"
.wildcard_count = 3, .wildcard_count = 3,
.exception_count = 2, .exception_count = 2,
.skipped_regex_count = 1, .skipped_regex_count = 1,
.skipped_unsupported_count = 4,
.checksum = "c" ** 64, .checksum = "c" ** 64,
}); });
+14 -1
View File
@@ -61,6 +61,7 @@ pub const StatusView = struct {
wildcards: u32, wildcards: u32,
exceptions: u32, exceptions: u32,
skipped_regex: u32, skipped_regex: u32,
skipped_unsupported: u32,
pub fn from(status: *const manager_mod.SourceStatus) StatusView { pub fn from(status: *const manager_mod.SourceStatus) StatusView {
return .{ return .{
@@ -75,6 +76,7 @@ pub const StatusView = struct {
.wildcards = status.counts.wildcards, .wildcards = status.counts.wildcards,
.exceptions = status.counts.exceptions, .exceptions = status.counts.exceptions,
.skipped_regex = status.counts.skipped_regex, .skipped_regex = status.counts.skipped_regex,
.skipped_unsupported = status.counts.skipped_unsupported,
}; };
} }
}; };
@@ -321,6 +323,7 @@ test "editing a blocklist keeps the counters the refresh wrote" {
.wildcard_count = 3, .wildcard_count = 3,
.exception_count = 2, .exception_count = 2,
.skipped_regex_count = 1, .skipped_regex_count = 1,
.skipped_unsupported_count = 8,
.checksum = "abc", .checksum = "abc",
}); });
@@ -335,6 +338,8 @@ test "editing a blocklist keeps the counters the refresh wrote" {
try testing.expectEqualStrings("renamed", row.name); try testing.expectEqualStrings("renamed", row.name);
try testing.expect(!row.enabled); try testing.expect(!row.enabled);
try testing.expectEqual(@as(i64, 42), row.domain_count); try testing.expectEqual(@as(i64, 42), row.domain_count);
try testing.expectEqual(@as(i64, 1), row.skipped_regex_count);
try testing.expectEqual(@as(i64, 8), row.skipped_unsupported_count);
try testing.expectEqual(@as(usize, 2), bench.reloads); try testing.expectEqual(@as(usize, 2), bench.reloads);
} }
@@ -383,7 +388,13 @@ test "a status becomes the flat shape the API answers with" {
const message = "connection refused"; const message = "connection refused";
@memcpy(status.last_error[0..message.len], message); @memcpy(status.last_error[0..message.len], message);
status.last_error_len = message.len; status.last_error_len = message.len;
status.counts = .{ .domains = 10, .wildcards = 2, .exceptions = 4, .skipped_regex = 1 }; status.counts = .{
.domains = 10,
.wildcards = 2,
.exceptions = 4,
.skipped_regex = 1,
.skipped_unsupported = 6,
};
const view: StatusView = .from(&status); const view: StatusView = .from(&status);
try testing.expectEqual(@as(i64, 7), view.id); try testing.expectEqual(@as(i64, 7), view.id);
@@ -393,4 +404,6 @@ test "a status becomes the flat shape the API answers with" {
try testing.expectEqualStrings(message, view.last_error); try testing.expectEqualStrings(message, view.last_error);
try testing.expectEqual(@as(u32, 10), view.domains); try testing.expectEqual(@as(u32, 10), view.domains);
try testing.expectEqual(@as(u32, 4), view.exceptions); try testing.expectEqual(@as(u32, 4), view.exceptions);
try testing.expectEqual(@as(u32, 1), view.skipped_regex);
try testing.expectEqual(@as(u32, 6), view.skipped_unsupported);
} }
+4 -2
View File
@@ -1876,7 +1876,7 @@ components:
Blocklist: Blocklist:
type: object type: object
required: [id, url, name, enabled, is_suggested, last_updated, domain_count, wildcard_count, exception_count, skipped_regex_count, checksum] required: [id, url, name, enabled, is_suggested, last_updated, domain_count, wildcard_count, exception_count, skipped_regex_count, skipped_unsupported_count, checksum]
properties: properties:
id: { type: integer } id: { type: integer }
url: { type: string } url: { type: string }
@@ -1890,6 +1890,7 @@ components:
wildcard_count: { type: integer } wildcard_count: { type: integer }
exception_count: { type: integer } exception_count: { type: integer }
skipped_regex_count: { type: integer } skipped_regex_count: { type: integer }
skipped_unsupported_count: { type: integer }
checksum: checksum:
type: string type: string
nullable: true nullable: true
@@ -1919,7 +1920,7 @@ components:
SourceStatus: SourceStatus:
type: object type: object
required: [id, state, loaded, last_attempt, last_success, url, last_error, domains, wildcards, exceptions, skipped_regex] required: [id, state, loaded, last_attempt, last_success, url, last_error, domains, wildcards, exceptions, skipped_regex, skipped_unsupported]
properties: properties:
id: { type: integer } id: { type: integer }
state: state:
@@ -1936,6 +1937,7 @@ components:
wildcards: { type: integer } wildcards: { type: integer }
exceptions: { type: integer } exceptions: { type: integer }
skipped_regex: { type: integer } skipped_regex: { type: integer }
skipped_unsupported: { type: integer }
Rule: Rule:
type: object type: object
@@ -19,6 +19,7 @@ const BLOCKLISTS = {
wildcard_count: 10, wildcard_count: 10,
exception_count: 7, exception_count: 7,
skipped_regex_count: 3, skipped_regex_count: 3,
skipped_unsupported_count: 21,
checksum: "abc", checksum: "abc",
}, },
{ {
@@ -32,6 +33,7 @@ const BLOCKLISTS = {
wildcard_count: 0, wildcard_count: 0,
exception_count: 0, exception_count: 0,
skipped_regex_count: 0, skipped_regex_count: 0,
skipped_unsupported_count: 0,
checksum: null, checksum: null,
}, },
], ],
@@ -102,6 +104,7 @@ const SNAPSHOT = {
wildcards: 12, wildcards: 12,
exceptions: 9, exceptions: 9,
skipped_regex: 4, skipped_regex: 4,
skipped_unsupported: 17,
}, },
], ],
}; };
@@ -117,7 +120,13 @@ test("renders the source table and the status empty state", async () => {
expect(screen.getByText("10")).toBeTruthy(); expect(screen.getByText("10")).toBeTruthy();
expect(screen.getByText("7")).toBeTruthy(); expect(screen.getByText("7")).toBeTruthy();
expect(screen.getByText("3")).toBeTruthy(); expect(screen.getByText("3")).toBeTruthy();
expect(screen.getByText("21")).toBeTruthy();
expect(screen.getByText("never")).toBeTruthy(); expect(screen.getByText("never")).toBeTruthy();
expect(screen.getByRole("columnheader", { name: "Skipped regex" })).toBeTruthy();
expect(screen.getByRole("columnheader", { name: "Skipped unsupported" })).toBeTruthy();
expect(
screen.getByText(/Skipped unsupported lines are syntax nxdns cannot translate into a DNS decision/),
).toBeTruthy();
const enabledToggle = screen.getByLabelText("StevenBlack enabled") as HTMLInputElement; const enabledToggle = screen.getByLabelText("StevenBlack enabled") as HTMLInputElement;
expect(enabledToggle.checked).toBe(true); expect(enabledToggle.checked).toBe(true);
@@ -153,6 +162,7 @@ test("update now disables the button, then replaces the status section from the
wildcards: 12, wildcards: 12,
exceptions: 9, exceptions: 9,
skipped_regex: 4, skipped_regex: 4,
skipped_unsupported: 17,
}, },
{ {
id: 2, id: 2,
@@ -166,6 +176,7 @@ test("update now disables the button, then replaces the status section from the
wildcards: 0, wildcards: 0,
exceptions: 0, exceptions: 0,
skipped_regex: 0, skipped_regex: 0,
skipped_unsupported: 0,
}, },
], ],
}; };
@@ -180,6 +191,8 @@ test("update now disables the button, then replaces the status section from the
expect(screen.getByText("12")).toBeTruthy(); expect(screen.getByText("12")).toBeTruthy();
expect(screen.getByText("9")).toBeTruthy(); expect(screen.getByText("9")).toBeTruthy();
expect(screen.getByText("4")).toBeTruthy(); expect(screen.getByText("4")).toBeTruthy();
expect(screen.getByText("17")).toBeTruthy();
expect(screen.getAllByRole("columnheader", { name: "Skipped unsupported" })).toHaveLength(2);
expect(screen.queryByText(/run .Update now. to fetch status/)).toBeNull(); expect(screen.queryByText(/run .Update now. to fetch status/)).toBeNull();
// The store notifies one flush before the mutation's success state lands. // The store notifies one flush before the mutation's success state lands.
await screen.findByText(/Update completed/); await screen.findByText(/Update completed/);
@@ -42,6 +42,10 @@ const styles = stylex.create({
marginTop: "1rem", marginTop: "1rem",
color: colors.textMuted, color: colors.textMuted,
}, },
note: {
marginTop: "0.5rem",
color: colors.textMuted,
},
table: { table: {
width: "100%", width: "100%",
minWidth: "max-content", minWidth: "max-content",
@@ -155,6 +159,7 @@ export default function BlocklistsPage() {
<th {...stylex.props(shared.th)}>Wildcards</th> <th {...stylex.props(shared.th)}>Wildcards</th>
<th {...stylex.props(shared.th)}>Exceptions</th> <th {...stylex.props(shared.th)}>Exceptions</th>
<th {...stylex.props(shared.th)}>Skipped regex</th> <th {...stylex.props(shared.th)}>Skipped regex</th>
<th {...stylex.props(shared.th)}>Skipped unsupported</th>
<th {...stylex.props(shared.th)}>Last updated</th> <th {...stylex.props(shared.th)}>Last updated</th>
<th {...stylex.props(shared.th)}> <th {...stylex.props(shared.th)}>
<span {...stylex.props(shared.srOnly)}>Actions</span> <span {...stylex.props(shared.srOnly)}>Actions</span>
@@ -188,6 +193,9 @@ export default function BlocklistsPage() {
<td {...stylex.props(shared.td, shared.tabularNums)}>{b.wildcard_count}</td> <td {...stylex.props(shared.td, shared.tabularNums)}>{b.wildcard_count}</td>
<td {...stylex.props(shared.td, shared.tabularNums)}>{b.exception_count}</td> <td {...stylex.props(shared.td, shared.tabularNums)}>{b.exception_count}</td>
<td {...stylex.props(shared.td, shared.tabularNums)}>{b.skipped_regex_count}</td> <td {...stylex.props(shared.td, shared.tabularNums)}>{b.skipped_regex_count}</td>
<td {...stylex.props(shared.td, shared.tabularNums)}>
{b.skipped_unsupported_count}
</td>
<td {...stylex.props(shared.td)}> <td {...stylex.props(shared.td)}>
{b.last_updated === null ? "never" : formatTime(b.last_updated)} {b.last_updated === null ? "never" : formatTime(b.last_updated)}
</td> </td>
@@ -221,6 +229,13 @@ export default function BlocklistsPage() {
))} ))}
</tbody> </tbody>
</table> </table>
<p {...stylex.props(styles.note)}>
Both Skipped columns count lines nxdns read and did not take. Skipped regex lines are patterns
nxdns accepts only from you adopt one you trust as a regex rule. Skipped unsupported lines are
syntax nxdns cannot translate into a DNS decision: cosmetic element hiding, browser-only
modifiers. A skipped unsupported count that dwarfs the domain count usually means the list is
written for a browser extension, and its DNS or hosts variant will block more here.
</p>
</div> </div>
)} )}
<InlineError error={tableError} /> <InlineError error={tableError} />
@@ -89,6 +89,7 @@ export default function SourceStatusSection({ sources, namesById }: SourceStatus
<th {...stylex.props(shared.th)}>Wildcards</th> <th {...stylex.props(shared.th)}>Wildcards</th>
<th {...stylex.props(shared.th)}>Exceptions</th> <th {...stylex.props(shared.th)}>Exceptions</th>
<th {...stylex.props(shared.th)}>Skipped regex</th> <th {...stylex.props(shared.th)}>Skipped regex</th>
<th {...stylex.props(shared.th)}>Skipped unsupported</th>
<th {...stylex.props(shared.th)}>Last error</th> <th {...stylex.props(shared.th)}>Last error</th>
</tr> </tr>
</thead> </thead>
@@ -112,6 +113,9 @@ export default function SourceStatusSection({ sources, namesById }: SourceStatus
<td {...stylex.props(shared.td, shared.tabularNums)}>{source.wildcards}</td> <td {...stylex.props(shared.td, shared.tabularNums)}>{source.wildcards}</td>
<td {...stylex.props(shared.td, shared.tabularNums)}>{source.exceptions}</td> <td {...stylex.props(shared.td, shared.tabularNums)}>{source.exceptions}</td>
<td {...stylex.props(shared.td, shared.tabularNums)}>{source.skipped_regex}</td> <td {...stylex.props(shared.td, shared.tabularNums)}>{source.skipped_regex}</td>
<td {...stylex.props(shared.td, shared.tabularNums)}>
{source.skipped_unsupported}
</td>
<td {...stylex.props(shared.td)}> <td {...stylex.props(shared.td)}>
{source.last_error === "" ? ( {source.last_error === "" ? (
<span {...stylex.props(styles.absent)}></span> <span {...stylex.props(styles.absent)}></span>
+2
View File
@@ -93,6 +93,7 @@ export const sample_list_blocklists: { blocklists: Blocklist[] } = {
last_updated: null, last_updated: null,
name: "ads", name: "ads",
skipped_regex_count: 0, skipped_regex_count: 0,
skipped_unsupported_count: 0,
url: "https://lists.example/ads.txt", url: "https://lists.example/ads.txt",
wildcard_count: 0, wildcard_count: 0,
}, },
@@ -118,6 +119,7 @@ export const sample_update_blocklists_now: { sources: SourceStatus[] } = {
last_success: 0, last_success: 0,
loaded: false, loaded: false,
skipped_regex: 0, skipped_regex: 0,
skipped_unsupported: 0,
state: "never_fetched", state: "never_fetched",
url: "https://lists.example/ads.txt", url: "https://lists.example/ads.txt",
wildcards: 0, wildcards: 0,
+2
View File
@@ -162,6 +162,7 @@ export interface Blocklist {
wildcard_count: number; wildcard_count: number;
exception_count: number; exception_count: number;
skipped_regex_count: number; skipped_regex_count: number;
skipped_unsupported_count: number;
checksum: string | null; checksum: string | null;
} }
@@ -192,6 +193,7 @@ export interface SourceStatus {
wildcards: number; wildcards: number;
exceptions: number; exceptions: number;
skipped_regex: number; skipped_regex: number;
skipped_unsupported: number;
} }
export type RuleKind = "exact" | "wildcard" | "regex"; export type RuleKind = "exact" | "wildcard" | "regex";