Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ac50c428a9 | |||
| 3ac865cd08 | |||
| f568fb5e8c | |||
| 20fff588cc | |||
| ba679223b2 |
+1
-20
@@ -1,24 +1,5 @@
|
||||
{
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "CMD=$(python3 -c \"import json,sys; d=json.load(sys.stdin); print(d.get('tool_input',d).get('command',''))\" 2>/dev/null || true); case \"$CMD\" in *grep*|*rg\\ *|*ripgrep*|*find\\ *|*fd\\ *|*ack\\ *|*ag\\ *) [ -f graphify-out/graph.json ] && echo '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"additionalContext\":\"MANDATORY: graphify-out/graph.json exists. You MUST run `graphify query \\\"<question>\\\"` before grepping raw files. Only grep after graphify has oriented you, or to modify/debug specific lines.\"}}' || true ;; esac"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Read|Glob",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "HIT=$(python3 -c \"import json,sys;d=json.load(sys.stdin);t=d.get('tool_input',d);exts=('.py','.js','.ts','.tsx','.jsx','.astro','.vue','.svelte','.go','.rs','.java','.rb','.c','.h','.cpp','.hpp','.cc','.cs','.kt','.swift','.php','.scala','.lua','.sh','.md','.rst','.txt','.mdx');vals=[str(t.get('file_path') or ''),str(t.get('pattern') or ''),str(t.get('path') or '')];j=' '.join(vals).lower().replace(chr(92),'/');tails=[('.'+x.rsplit('.',1)[-1]) for v in vals if v for x in [v.lower().replace(chr(92),'/').rsplit('/',1)[-1]] if '.' in x];sys.stdout.write('1' if 'graphify-out/' not in j and any(tl in exts for tl in tails) else '')\" 2>/dev/null || true); if [ \"$HIT\" = 1 ] && [ -f graphify-out/graph.json ]; then echo '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"additionalContext\":\"MANDATORY: graphify-out/graph.json exists. You MUST run graphify before reading source files. Use: `graphify query \\\"<question>\\\"` (scoped subgraph), `graphify explain \\\"<concept>\\\"`, or `graphify path \\\"<A>\\\" \\\"<B>\\\"`. Only read raw files after graphify has oriented you, or to modify/debug specific lines. This rule applies to subagents too \u2014 include it in every subagent prompt involving code exploration.\"}}'; fi || true"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
"PreToolUse": []
|
||||
}
|
||||
}
|
||||
@@ -11,8 +11,7 @@ to the tracker. You do not write the implementation — every line of ticket cod
|
||||
is written by a `ticket-implementer` subagent in its own git worktree. Reach for
|
||||
the editor yourself only for a merge conflict resolution.
|
||||
|
||||
Ticket source and `tea` usage: `docs/agents/issue-tracker.md`. Codebase
|
||||
questions: `graphify query "<question>"` before grepping.
|
||||
Ticket source and `tea` usage: `docs/agents/issue-tracker.md`.
|
||||
|
||||
## 1. Collect the tickets
|
||||
|
||||
|
||||
@@ -5,11 +5,6 @@
|
||||
backend/server
|
||||
backend/backend
|
||||
.playwright-mcp/
|
||||
# graphify map is committed; only regenerable/local parts are ignored
|
||||
graphify-out/cost.json
|
||||
graphify-out/cache/
|
||||
graphify-out/[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]/
|
||||
graphify-out/.rebuild.lock
|
||||
plans/
|
||||
.scratch/
|
||||
docs/superpowers/
|
||||
|
||||
@@ -163,13 +163,3 @@ Default five-role vocabulary, label strings unchanged (`needs-triage`, `needs-in
|
||||
### Domain docs
|
||||
|
||||
Single-context: one root `CONTEXT.md` plus `docs/adr/`, both created lazily. See `docs/agents/domain.md`.
|
||||
|
||||
## graphify
|
||||
|
||||
Project has knowledge graph at graphify-out/ with god nodes, community structure, cross-file relationships.
|
||||
|
||||
Rules:
|
||||
- For codebase questions and exploration, always first run `graphify query "<question>"` when graphify-out/graph.json exists. Use `graphify path "<A>" "<B>"` for relationships and `graphify explain "<concept>"` for focused concepts. Return scoped subgraph, usually much smaller than GRAPH_REPORT.md or raw grep output.
|
||||
- If graphify-out/wiki/index.md exists, use for broad navigation instead of raw source browsing.
|
||||
- Read graphify-out/GRAPH_REPORT.md only for broad architecture review or when query/path/explain don't surface enough context.
|
||||
- After modifying code, run `graphify update .` to keep graph current (AST-only, no API cost).
|
||||
|
||||
+41
-2
@@ -27,7 +27,8 @@ Guidance for OpenCode (and Claude Code) working under `backend/`. See root `AGEN
|
||||
`series` keyed `(site, series_id)`
|
||||
(`asura`|`demonic`|`comix`|`kagane`|`novelfull`|`lightnovelworld`) owns the
|
||||
shared facts — title, cover, canonical URL, `kind` (`manga`|`novel`),
|
||||
Latest Chapter, `latest_checked_at` — and `bookmarks` holds only what
|
||||
Latest Chapter, `latest_checked_at`, and the Sighting pair
|
||||
`latest_sighted_at`/`latest_raised_by` (issue #103) — and `bookmarks` holds only what
|
||||
differs between readers: progress, favourite, lifecycle bucket,
|
||||
`updated_at`. A bookmark is keyed `(reader_id, site, series_id)` — no
|
||||
surrogate id; the wire `key` is derived as `site:series_id` on read — and
|
||||
@@ -78,7 +79,9 @@ Guidance for OpenCode (and Claude Code) working under `backend/`. See root `AGEN
|
||||
each re-checking that Site's bookmarked series' newest published chapter from
|
||||
backend's own network access, so `latest_chapter` stays fresh when the user
|
||||
isn't browsing. Second, parallel signal — the userscript keeps its own
|
||||
`maybeCaptureLatestOnSeriesPage`/`backgroundRefreshLatest` logic unchanged.
|
||||
`maybeCaptureLatestOnSeriesPage`/`backgroundRefreshLatest` schedule, and its
|
||||
`reportLatestChapter` PUTs every read, unchanged numbers included, because an
|
||||
unchanged read is exactly the Sighting worth deferring a Poll on (#103).
|
||||
Two independent clocks: per-series rest (`series.latest_checked_at`,
|
||||
enforced by `Store.DueForLatestCheck`'s WHERE clause — `now - Rest`) and
|
||||
per-Lane gap (the Lane sleeping between fetches, `effectiveGap`). Both live
|
||||
@@ -91,6 +94,29 @@ Guidance for OpenCode (and Claude Code) working under `backend/`. See root `AGEN
|
||||
every tick; found chapter written straight to the series row via
|
||||
`Store.SetLatestChapter`, so a bookmark's `updated_at` — and the list
|
||||
order — is never touched.
|
||||
**Sightings** (issue #103, ADR-0011) let a Reader's own page read defer a
|
||||
Poll: `Store.RecordSighting` — called by the PUT handler *before* the Upsert,
|
||||
because the raise test needs the row as it stands — stamps
|
||||
`series.latest_sighted_at` and, when the report raises the stored number,
|
||||
names its Reader in `series.latest_raised_by`. The due query's HAVING clause
|
||||
is where deferral lives: a Series is skipped only while it has exactly one
|
||||
Bookmark, was sighted within one Rest, and is under the ceiling
|
||||
(`sightingCeilingRests`, six of that Site's rests) since its last Poll. So a
|
||||
shared Series is never deferred, and no Series goes six hours unpolled
|
||||
whatever arrives. `checkOne` judges the named Reader off the comparison it
|
||||
already makes: a lower number is a contradiction (logged with the Reader and
|
||||
both numbers), the same number an agreement, a higher number the Site
|
||||
publishing and neither — that last one clears the attribution instead, since
|
||||
the value the Poll then stores is its own and a later retraction is not the
|
||||
Reader's fault. Three contradictions
|
||||
(`store.SightingDisagreementLimit`) stop that Reader deferring — their
|
||||
reports still write the Latest Chapter — and twenty consecutive agreements
|
||||
(`store.SightingAgreementsToClear`) forgive them, as does the owner's
|
||||
clear-marks control. Deferral is recomputed from live facts every round, so
|
||||
nothing needs invalidating when a Series gains a second Bookmark; the one
|
||||
input read earlier is the Reader's marks, checked when the Sighting is
|
||||
recorded, so crossing the threshold or being cleared takes effect from that
|
||||
Reader's next Sighting and the standing already bought lasts out its rest.
|
||||
Refusals and browser loss are Lane-local: two `errChallengeHeld` in one pass
|
||||
stop that Site for `refuseBackoff` (15m) while other Lanes continue; an
|
||||
`errBrowserInterrupted` (remote Chrome restart) sets a shared Poller flag
|
||||
@@ -101,6 +127,13 @@ Guidance for OpenCode (and Claude Code) working under `backend/`. See root `AGEN
|
||||
and cover work (both healing a stored source URL and filling a blank from
|
||||
the series page) runs in the background so a slow CDN can't consume a
|
||||
Lane's gap.
|
||||
A refusal is only ever the challenge *page*: `isInterstitial` matches the
|
||||
orchestration path `/cdn-cgi/challenge-platform/h/`, never the bare prefix.
|
||||
Cloudflare injects `/cdn-cgi/challenge-platform/scripts/jsd/main.js` into
|
||||
ordinary 200 pages once a zone turns JS detections on, which demonic did on
|
||||
2026-08-16 — the prefix match then read every real demonic page as a refusal
|
||||
and parked that Lane in 15m backoff while plain TLS was returning the full
|
||||
series page.
|
||||
Fetches use `bogdanfinn/tls-client` with Chrome profile as defence in depth
|
||||
against fingerprint-based blocking; any failure log and skip. kagane, comix
|
||||
and novelfull sit behind Cloudflare JavaScript challenges the TLS client
|
||||
@@ -233,3 +266,9 @@ Guidance for OpenCode (and Claude Code) working under `backend/`. See root `AGEN
|
||||
than recording zeroes; a Lane that has never reached a pace renders no gap at
|
||||
all. `Checked` next to `Due` is what separates a stopped Lane from a quiet
|
||||
one, so neither figure may be dropped from the row.
|
||||
Due-without-Checked is *not* by itself a stall: a browser Lane under both
|
||||
wake thresholds sets `LaneState.Asleep` at the on-demand gate and renders
|
||||
"browser asleep" instead of "not checking", and never counts toward
|
||||
`Attention`. That is the commonest healthy state for kagane, comix and
|
||||
novelfull — one due Series, nothing checked — so spending the stall mark on
|
||||
it would train the owner to ignore the mark that matters.
|
||||
|
||||
@@ -621,6 +621,46 @@ func TestPutDoesNotClobberLatestCheckedAt(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Sighting deferral (issue #103) only reaches production through the PUT
|
||||
// handler: the store and poller can be right and the feature still dead if the
|
||||
// handler never records the report. Asserted where a client can see it - the
|
||||
// series stops being due the moment the PUT lands.
|
||||
func TestPutRecordsASighting(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
srv := newRouter(s, testConfig(), nil)
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
hour := time.Hour.Milliseconds()
|
||||
seedForCheck(t, s, "asura:x", "https://asurascans.com/comics/x", now-2*hour)
|
||||
due, err := s.DueForLatestCheck("asura", now-hour, now-6*hour)
|
||||
if err != nil {
|
||||
t.Fatalf("DueForLatestCheck: %v", err)
|
||||
}
|
||||
if len(due) != 1 {
|
||||
t.Fatalf("due before the PUT = %d series, want 1", len(due))
|
||||
}
|
||||
|
||||
body := `{"key":"asura:x","site":"asura","series_id":"x",
|
||||
"series_url":"https://asurascans.com/comics/x",
|
||||
"last_chapter":"Chapter 5","last_chapter_num":5,
|
||||
"latest_chapter":"Chapter 9","latest_chapter_num":9}`
|
||||
req := httptest.NewRequest(http.MethodPut, "/bookmarks/asura:x", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
srv.ServeHTTP(rec, auth(req))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("PUT status = %d, want 200 (body %s)", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
due, err = s.DueForLatestCheck("asura", now-hour, now-6*hour)
|
||||
if err != nil {
|
||||
t.Fatalf("DueForLatestCheck: %v", err)
|
||||
}
|
||||
if len(due) != 0 {
|
||||
t.Fatalf("due after the PUT = %d series, want 0: the handler recorded no Sighting", len(due))
|
||||
}
|
||||
}
|
||||
|
||||
// The userscript route is registered outside the web UI's Discord auth, so it
|
||||
// must keep working whatever the web config — see internal/userscript for the
|
||||
// handler's own behaviour. The credential in the path is the owner's derived
|
||||
|
||||
@@ -99,7 +99,18 @@ func (h *Handler) Put(w http.ResponseWriter, r *http.Request) {
|
||||
// reading progress actually moved. Any client value is ignored.
|
||||
b.UpdatedAt = time.Now().UnixMilli()
|
||||
|
||||
stored, err := h.Store.Upsert(httpmw.ReaderID(r), b)
|
||||
// A userscript PUT is a Sighting: the Reader's browser was on the Series
|
||||
// page and read its Latest Chapter (issue #103). Recorded before the
|
||||
// Upsert, which is what makes the raise comparison possible, and never
|
||||
// from the web UI's own read-modify-write — a Reader toggling a favourite
|
||||
// has not looked at the Site and must not postpone a Poll. A failure here
|
||||
// costs a deferral, not the write, so it is logged and dropped.
|
||||
readerID := httpmw.ReaderID(r)
|
||||
if err := h.Store.RecordSighting(readerID, b.Site, b.SeriesID, b.LatestChapterNum, b.UpdatedAt); err != nil {
|
||||
log.Printf("record sighting: %v", err)
|
||||
}
|
||||
|
||||
stored, err := h.Store.Upsert(readerID, b)
|
||||
if err != nil {
|
||||
log.Printf("upsert: %v", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
|
||||
@@ -250,11 +250,18 @@ func browserConnectionLost(ctx context.Context) bool {
|
||||
const challengePollInterval = 2 * time.Second
|
||||
|
||||
// isInterstitial reports whether html is Cloudflare's challenge page rather
|
||||
// than the site's own. Matched on the challenge runtime's script path, which is
|
||||
// stable across the interstitial's wording and locale — the visible "Just a
|
||||
// than the site's own. Matched on the challenge orchestration path
|
||||
// (/cdn-cgi/challenge-platform/h/<b|g|x>/orchestrate/...), which is stable
|
||||
// across the interstitial's wording and locale — the visible "Just a
|
||||
// moment..." title is neither.
|
||||
//
|
||||
// The bare "/cdn-cgi/challenge-platform/" prefix is NOT enough: Cloudflare
|
||||
// injects /cdn-cgi/challenge-platform/scripts/jsd/main.js into ordinary 200
|
||||
// pages when JS detections are on, so matching the prefix declared every real
|
||||
// demonic page a refusal and parked that Lane in 15m backoff (observed
|
||||
// 2026-08-16, demonic turned detections on).
|
||||
func isInterstitial(html string) bool {
|
||||
return strings.Contains(html, "/cdn-cgi/challenge-platform/")
|
||||
return strings.Contains(html, "/cdn-cgi/challenge-platform/h/")
|
||||
}
|
||||
|
||||
// run navigates to target and re-reads until done reports an answer, bounded by
|
||||
|
||||
@@ -117,6 +117,25 @@ func TestBrowserOnlyCoverURL(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The jsd script is injected into ordinary 200 pages when a zone turns JS
|
||||
// detections on; only the orchestration path means the page itself is the
|
||||
// challenge. Conflating the two parked the demonic Lane in refusal backoff
|
||||
// while every fetch was in fact the real series page (observed 2026-08-16).
|
||||
func TestIsInterstitial(t *testing.T) {
|
||||
if !isInterstitial(challengeFixture) {
|
||||
t.Fatal("challenge page not detected as interstitial")
|
||||
}
|
||||
const jsdInjected = `<html><head><title>The Possessed Grappler</title>
|
||||
<script src="/cdn-cgi/challenge-platform/scripts/jsd/main.js"></script></head>
|
||||
<body><a href="/chaptered.php?manga=13721&chapter=22">Chapter 22</a></body></html>`
|
||||
if isInterstitial(jsdInjected) {
|
||||
t.Fatal("real page carrying the injected jsd script misread as interstitial")
|
||||
}
|
||||
if got, ok := demonicLatestChapter("", jsdInjected); !ok || got.Label != "Chapter 22" {
|
||||
t.Fatalf("demonicLatestChapter = %+v, ok = %v, want Chapter 22", got, ok)
|
||||
}
|
||||
}
|
||||
func TestClassifyBrowserInterruption(t *testing.T) {
|
||||
if err := classifyBrowserError(context.Background(), true, context.Canceled); !errors.Is(err, errBrowserInterrupted) {
|
||||
t.Fatalf("classifyBrowserError(context.Canceled) = %v, want browser interruption", err)
|
||||
|
||||
@@ -47,6 +47,15 @@ func fetchCoverBytes(ctx context.Context, cover string, browser BrowserCoverFetc
|
||||
// inject it to exercise hostile DNS results without touching the live network.
|
||||
type CoverResolver func(context.Context, string) ([]netip.Addr, error)
|
||||
|
||||
// maxCoverBytes caps one cover, separately from the series-page maxBodyBytes:
|
||||
// a cover is a bounded binary asset, not a text page, and 4 MiB rejected 12%
|
||||
// of asurascans covers measured 2026-08-17 (p90 4.52 MB, max 8.57 MB — two of
|
||||
// the three over-cap files were JPEGs, not the animated GIF of issue #71).
|
||||
// 10 MiB is ~18% headroom over that worst case and matches the GitHub and
|
||||
// Discord image limits; see docs/research/gif-maximum-byte-size.md. GIF itself
|
||||
// has no maximum size, so this number is policy, not format.
|
||||
const maxCoverBytes = 10 << 20
|
||||
|
||||
// TLSCoverFetcher retrieves image bytes with the standard HTTPS client. Unlike
|
||||
// TLSFetcher, it does not need a browser fingerprint: cover hosts are public
|
||||
// CDNs and the response is accepted only after the destination gate passes.
|
||||
@@ -152,15 +161,15 @@ func (f *TLSCoverFetcher) Fetch(ctx context.Context, sourceURL string) ([]byte,
|
||||
if !ok {
|
||||
return nil, "", fmt.Errorf("fetch cover: unsupported content type %q", raw)
|
||||
}
|
||||
if resp.ContentLength > maxBodyBytes {
|
||||
return nil, "", fmt.Errorf("fetch cover: response exceeds %d bytes", maxBodyBytes)
|
||||
if resp.ContentLength > maxCoverBytes {
|
||||
return nil, "", fmt.Errorf("fetch cover: response exceeds %d bytes", maxCoverBytes)
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, maxBodyBytes+1))
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, maxCoverBytes+1))
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("read cover: %w", err)
|
||||
}
|
||||
if len(body) > maxBodyBytes {
|
||||
return nil, "", fmt.Errorf("fetch cover: response exceeds %d bytes", maxBodyBytes)
|
||||
if len(body) > maxCoverBytes {
|
||||
return nil, "", fmt.Errorf("fetch cover: response exceeds %d bytes", maxCoverBytes)
|
||||
}
|
||||
return body, contentType, nil
|
||||
}
|
||||
|
||||
@@ -171,7 +171,7 @@ func TestCoverFetcherRejectsOversizedBody(t *testing.T) {
|
||||
var calls int
|
||||
client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
calls++
|
||||
response := coverResponse(http.StatusOK, "image/webp", "", bytes.Repeat([]byte("x"), maxBodyBytes+1))
|
||||
response := coverResponse(http.StatusOK, "image/webp", "", bytes.Repeat([]byte("x"), maxCoverBytes+1))
|
||||
response.ContentLength = -1
|
||||
return response, nil
|
||||
})}
|
||||
@@ -187,6 +187,29 @@ func TestCoverFetcherRejectsOversizedBody(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Covers between the series-page cap and the cover cap must be accepted: the
|
||||
// 4 MiB page cap rejected 12% of asurascans covers (issue #71).
|
||||
func TestCoverFetcherAcceptsCoverOverPageCap(t *testing.T) {
|
||||
body := bytes.Repeat([]byte("x"), maxBodyBytes+1)
|
||||
client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
return coverResponse(http.StatusOK, "image/gif", "", body), nil
|
||||
})}
|
||||
fetcher := newCoverFetcher(client, func(context.Context, string) ([]netip.Addr, error) {
|
||||
return []netip.Addr{netip.MustParseAddr("198.51.100.10")}, nil
|
||||
})
|
||||
|
||||
got, contentType, err := fetcher.Fetch(context.Background(), "https://cdn.example/big.gif")
|
||||
if err != nil {
|
||||
t.Fatalf("Fetch rejected a %d-byte cover: %v", len(body), err)
|
||||
}
|
||||
if len(got) != len(body) {
|
||||
t.Fatalf("body = %d bytes, want %d", len(got), len(body))
|
||||
}
|
||||
if contentType != "image/gif" {
|
||||
t.Fatalf("content type = %q, want image/gif", contentType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoverFetcherRejectsNonImage(t *testing.T) {
|
||||
var calls int
|
||||
client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
|
||||
@@ -250,7 +250,8 @@ func (p *Poller) runLanePass(ctx context.Context, name string, paced bool) time.
|
||||
return defaultGap
|
||||
}
|
||||
|
||||
due, err := p.Store.DueForLatestCheck(name, now.Add(-s.Rest).UnixMilli())
|
||||
due, err := p.Store.DueForLatestCheck(name, now.Add(-s.Rest).UnixMilli(),
|
||||
now.Add(-sightingCeilingRests*s.Rest).UnixMilli())
|
||||
if err != nil {
|
||||
log.Printf("latest poll %s: due query: %v", name, err)
|
||||
st.Gap = defaultGap
|
||||
@@ -262,7 +263,7 @@ func (p *Poller) runLanePass(ctx context.Context, name string, paced bool) time.
|
||||
// browser): waking it for a single Poll would cost a challenge solve
|
||||
// per request. The Lane still paces at the default gap, which is what
|
||||
// the owner's page must show rather than a zero.
|
||||
st.Gap = defaultGap
|
||||
st.Gap, st.Asleep = defaultGap, true
|
||||
return defaultGap
|
||||
}
|
||||
if s.Browser != nil {
|
||||
@@ -461,6 +462,11 @@ func (p *Poller) checkOne(ctx context.Context, sr store.Series) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// The Poll is the oracle for whatever Sighting last raised this Series
|
||||
// (issue #103), and the judgement is free: the comparison below already
|
||||
// exists, and no extra request is made to reach it.
|
||||
p.judgeSighting(sr, facts.Latest.Num)
|
||||
|
||||
// Equality, not >, mirroring the userscript (L427): a site that retracts a
|
||||
// chapter should correct the stored number downward. The comparison is
|
||||
// against the due-query snapshot; a concurrent write in between only costs
|
||||
@@ -481,6 +487,43 @@ func (p *Poller) checkOne(ctx context.Context, sr store.Series) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// judgeSighting settles the Sighting the Series' stored Latest Chapter is owed
|
||||
// to, if any, against what the Site actually publishes. The asymmetry is the
|
||||
// whole of the detection rule and is what keeps it free of false alarms: a Poll
|
||||
// finding a *lower* number than stored means the Reader who raised it reported
|
||||
// a chapter that does not exist, while a Poll finding a higher one is only the
|
||||
// Site publishing since and means nothing about the report. Equality confirms
|
||||
// the report, which is how an honest Reader earns back a mark.
|
||||
//
|
||||
// A Series with no attribution — the stored value is a Poll's own, or a
|
||||
// previous Poll already judged the report — is nobody's to answer for.
|
||||
func (p *Poller) judgeSighting(sr store.Series, found float64) {
|
||||
if sr.LatestRaisedBy == nil || sr.LatestChapterNum == nil {
|
||||
return
|
||||
}
|
||||
stored := *sr.LatestChapterNum
|
||||
if found > stored {
|
||||
// The report is neither confirmed nor contradicted, but it is answered:
|
||||
// the value about to be stored is the Poll's own, so leaving the
|
||||
// attribution would credit this Reader with the next Poll's agreement
|
||||
// and blame them if the Site later retracts.
|
||||
if err := p.Store.ClearSightingAttribution(sr.Site, sr.SeriesID, *sr.LatestRaisedBy); err != nil {
|
||||
log.Printf("latest poll %q: clear sighting attribution: %v", sr.Key(), err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if found < stored {
|
||||
// Logged with both numbers and the Reader, because that is what tells a
|
||||
// broken Site adapter (which marks every Reader of that Site at once)
|
||||
// from one Reader deliberately lying.
|
||||
log.Printf("latest poll %q: sighting contradicted: reader %d raised it to %v, site publishes %v",
|
||||
sr.Key(), *sr.LatestRaisedBy, stored, found)
|
||||
}
|
||||
if err := p.Store.RecordSightingOutcome(sr.Site, sr.SeriesID, *sr.LatestRaisedBy, found == stored); err != nil {
|
||||
log.Printf("latest poll %q: record sighting outcome: %v", sr.Key(), err)
|
||||
}
|
||||
}
|
||||
|
||||
// healCover runs prefetchCover in the background. Cover bytes come from a
|
||||
// different host — often a CDN — and heal once in a Series's life, so they
|
||||
// must not consume a Lane's gap: a large import with many blanks would
|
||||
|
||||
@@ -1388,6 +1388,11 @@ func TestBrowserLaneWakeThresholds(t *testing.T) {
|
||||
if got := browser.callCount(); got != 0 {
|
||||
t.Fatalf("browser fetches with 3 freshly-due series = %d, want 0 (Chrome stays asleep)", got)
|
||||
}
|
||||
// The owner's page reads this state off the snapshot, and Due-without-
|
||||
// Checked has to be distinguishable there from a Lane that has stopped.
|
||||
if lane := laneByName(t, p, "kagane"); !lane.Asleep || lane.Due != 3 || lane.Checked != 0 {
|
||||
t.Fatalf("asleep kagane lane = %+v, want Asleep with 3 due and 0 checked", lane)
|
||||
}
|
||||
// 5 due crosses the count threshold.
|
||||
for i := 3; i < 5; i++ {
|
||||
seed(i)
|
||||
@@ -1396,6 +1401,9 @@ func TestBrowserLaneWakeThresholds(t *testing.T) {
|
||||
if got := browser.callCount(); got != 5 {
|
||||
t.Fatalf("browser fetches with 5 due series = %d, want 5", got)
|
||||
}
|
||||
if lane := laneByName(t, p, "kagane"); lane.Asleep {
|
||||
t.Fatalf("woken kagane lane still reports Asleep: %+v", lane)
|
||||
}
|
||||
// A single long-neglected series wakes the browser by age alone.
|
||||
seedForCheck(t, s, "kagane:ancient", "https://kagane.to/series/ancient", 0)
|
||||
p.runOnce(context.Background())
|
||||
@@ -1404,6 +1412,19 @@ func TestBrowserLaneWakeThresholds(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// laneByName pulls one Lane out of the poller's snapshot, failing rather than
|
||||
// returning a zero LaneState a caller would assert against by accident.
|
||||
func laneByName(t *testing.T, p *Poller, site string) LaneState {
|
||||
t.Helper()
|
||||
for _, lane := range p.LaneStatus().Lanes {
|
||||
if lane.Site == site {
|
||||
return lane
|
||||
}
|
||||
}
|
||||
t.Fatalf("no %q lane in the snapshot", site)
|
||||
return LaneState{}
|
||||
}
|
||||
|
||||
// When one browser Lane loses the sidecar, the round's remaining browser
|
||||
// Lanes are skipped: every fetch would fail anyway, and their Series must not
|
||||
// burn their stamps on a dead Chrome (issue #100).
|
||||
|
||||
@@ -0,0 +1,494 @@
|
||||
package latest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"bookmarkmanager/backend/internal/store"
|
||||
)
|
||||
|
||||
// Sightings (issue #103) are specified at the Poller seam, with the store as
|
||||
// the way in: a Sighting is seeded the way handlers.Put performs one, a round
|
||||
// is run against the injected fetcher and a frozen clock, and the assertions
|
||||
// are the two observable facts — whether the Series was fetched, and what the
|
||||
// stored Latest Chapter is afterwards. Nothing here asserts counter arithmetic
|
||||
// through an internal call or reads how a deferral is represented in a row.
|
||||
|
||||
const (
|
||||
sightingSlug = "chronicles-of-the-demon-faction-f886a8af"
|
||||
sightingKey = "asura:" + sightingSlug
|
||||
sightingURL = "https://asurascans.com/comics/" + sightingSlug
|
||||
)
|
||||
|
||||
// sightingFixtureLatest is the newest chapter asuraSeriesFixture publishes.
|
||||
const sightingFixtureLatest = 181.0
|
||||
|
||||
// sight performs one Sighting exactly as the JSON API does (handlers.Put):
|
||||
// RecordSighting against the row as stored, then the Upsert that stores the
|
||||
// reported value. The order is load-bearing — the raise comparison has nothing
|
||||
// to compare against once the Upsert has landed — and the bookmark's own fields
|
||||
// are carried over untouched, which is what a userscript PUT does when it
|
||||
// echoes back the row it cached.
|
||||
func sight(t *testing.T, s *store.Store, readerID int64, key string, num float64, at time.Time) {
|
||||
t.Helper()
|
||||
site, seriesID, ok := strings.Cut(key, ":")
|
||||
if !ok {
|
||||
t.Fatalf("key %q: no ':' separator", key)
|
||||
}
|
||||
b, found, err := s.Get(readerID, key)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("sight %q: get: %v found=%v", key, err, found)
|
||||
}
|
||||
if err := s.RecordSighting(readerID, site, seriesID, &num, at.UnixMilli()); err != nil {
|
||||
t.Fatalf("sight %q: %v", key, err)
|
||||
}
|
||||
b.LatestChapter = fmt.Sprintf("Chapter %v", num)
|
||||
b.LatestChapterNum = &num
|
||||
b.UpdatedAt = at.UnixMilli()
|
||||
if _, err := s.Upsert(readerID, b); err != nil {
|
||||
t.Fatalf("sight %q: upsert: %v", key, err)
|
||||
}
|
||||
}
|
||||
|
||||
// secondReader is another Reader on the same database. The owner seed is the
|
||||
// only reader-creation path in this package, so a second Open as a different
|
||||
// owner is how a test gets one (as TestRunOnceFetchesSharedSeriesOnce does).
|
||||
func secondReader(t *testing.T, dbURL string) *store.Store {
|
||||
t.Helper()
|
||||
other, err := store.Open(dbURL,
|
||||
store.Owner{DiscordID: "second-reader", TokenHash: sha256.Sum256([]byte("second-token-hash"))},
|
||||
t.TempDir(), testCoverBaseURL)
|
||||
if err != nil {
|
||||
t.Fatalf("Open second reader: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { other.Close() })
|
||||
return other
|
||||
}
|
||||
|
||||
func readLatestNum(t *testing.T, s *store.Store, readerID int64, key string) float64 {
|
||||
t.Helper()
|
||||
b, ok, err := s.Get(readerID, key)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("Get %q: %v ok=%v", key, err, ok)
|
||||
}
|
||||
if b.LatestChapterNum == nil {
|
||||
t.Fatalf("%q has no latest chapter", key)
|
||||
}
|
||||
return *b.LatestChapterNum
|
||||
}
|
||||
|
||||
// A Series only one Reader bookmarks is the case where being wrong can hurt
|
||||
// nobody but the Reader who reported it, so their Sighting stands in for the
|
||||
// Poll and the round leaves the Series alone.
|
||||
func TestSightingOnSolitarySeriesDefersPoll(t *testing.T) {
|
||||
s, _ := newTestStore(t)
|
||||
now := time.UnixMilli(20 * time.Hour.Milliseconds())
|
||||
seedForCheck(t, s, sightingKey, sightingURL, now.Add(-2*time.Hour).UnixMilli())
|
||||
sight(t, s, s.OwnerID(), sightingKey, sightingFixtureLatest, now.Add(-10*time.Minute))
|
||||
|
||||
f := &fakeFetcher{body: asuraSeriesFixture, status: 200}
|
||||
newTestPoller(t, s, f, now).runOnce(context.Background())
|
||||
|
||||
if got := f.callCount(); got != 0 {
|
||||
t.Fatalf("fetched %d times after a Sighting on a solitary Series, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
// On a shared Series the Sighting still writes the Latest Chapter for everyone,
|
||||
// but the Poll happens on schedule anyway — which is what corrects a wrong
|
||||
// value within the hour instead of letting it persist.
|
||||
func TestSightingOnSharedSeriesDoesNotDeferPoll(t *testing.T) {
|
||||
s, dbURL := newTestStore(t)
|
||||
now := time.UnixMilli(20 * time.Hour.Milliseconds())
|
||||
seedForCheck(t, s, sightingKey, sightingURL, now.Add(-2*time.Hour).UnixMilli())
|
||||
other := secondReader(t, dbURL)
|
||||
if _, err := s.Upsert(other.OwnerID(), store.Bookmark{
|
||||
Key: sightingKey, Site: "asura", SeriesID: sightingSlug, UpdatedAt: 2000,
|
||||
}); err != nil {
|
||||
t.Fatalf("seed second reader: %v", err)
|
||||
}
|
||||
sight(t, s, s.OwnerID(), sightingKey, 200, now.Add(-10*time.Minute))
|
||||
|
||||
// The Sighting updated the shared row immediately, before any Poll.
|
||||
if got := readLatestNum(t, s, s.OwnerID(), sightingKey); got != 200 {
|
||||
t.Fatalf("latest after the Sighting = %v, want 200", got)
|
||||
}
|
||||
|
||||
f := &fakeFetcher{body: asuraSeriesFixture, status: 200}
|
||||
newTestPoller(t, s, f, now).runOnce(context.Background())
|
||||
|
||||
if got := f.callCount(); got != 1 {
|
||||
t.Fatalf("fetched %d times after a Sighting on a shared Series, want 1", got)
|
||||
}
|
||||
if got := readLatestNum(t, s, s.OwnerID(), sightingKey); got != sightingFixtureLatest {
|
||||
t.Fatalf("latest after the Poll = %v, want the Site's own %v", got, sightingFixtureLatest)
|
||||
}
|
||||
}
|
||||
|
||||
// Reporting a chapter is not reading one: a Sighting may move the Latest
|
||||
// Chapter and nothing else. Both the solitary and the shared case, because the
|
||||
// deferral branch must not be where this guarantee lives.
|
||||
func TestSightingLeavesProgressAndOrderingUntouched(t *testing.T) {
|
||||
for _, shared := range []bool{false, true} {
|
||||
name := "solitary"
|
||||
if shared {
|
||||
name = "shared"
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
s, dbURL := newTestStore(t)
|
||||
read := 5.0
|
||||
if _, err := s.Upsert(s.OwnerID(), store.Bookmark{
|
||||
Key: sightingKey, Site: "asura", SeriesID: sightingSlug, SeriesURL: sightingURL,
|
||||
LastChapter: "Chapter 5", LastChapterNum: read,
|
||||
LastChapterURL: sightingURL + "/chapter/5", UpdatedAt: 1000,
|
||||
}); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
if shared {
|
||||
other := secondReader(t, dbURL)
|
||||
if _, err := s.Upsert(other.OwnerID(), store.Bookmark{
|
||||
Key: sightingKey, Site: "asura", SeriesID: sightingSlug, UpdatedAt: 2000,
|
||||
}); err != nil {
|
||||
t.Fatalf("seed second reader: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
sight(t, s, s.OwnerID(), sightingKey, 200, time.UnixMilli(9_000_000))
|
||||
|
||||
b, ok, err := s.Get(s.OwnerID(), sightingKey)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("Get: %v ok=%v", err, ok)
|
||||
}
|
||||
if b.LatestChapterNum == nil || *b.LatestChapterNum != 200 {
|
||||
t.Fatalf("LatestChapterNum = %v, want 200", b.LatestChapterNum)
|
||||
}
|
||||
if b.LastChapterNum != read {
|
||||
t.Fatalf("LastChapterNum = %v, want %v: a Sighting is not Progress", b.LastChapterNum, read)
|
||||
}
|
||||
if b.UpdatedAt != 1000 {
|
||||
t.Fatalf("updated_at moved to %d: a Sighting must not reorder the list", b.UpdatedAt)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The ceiling is what makes trusting a client report safe: however recently a
|
||||
// Series was sighted, one that has not been Polled in six hours is Polled.
|
||||
func TestSightingCeilingForcesPoll(t *testing.T) {
|
||||
s, _ := newTestStore(t)
|
||||
now := time.UnixMilli(20 * time.Hour.Milliseconds())
|
||||
seedForCheck(t, s, sightingKey, sightingURL, now.Add(-7*time.Hour).UnixMilli())
|
||||
sight(t, s, s.OwnerID(), sightingKey, sightingFixtureLatest, now.Add(-time.Minute))
|
||||
|
||||
f := &fakeFetcher{body: asuraSeriesFixture, status: 200}
|
||||
newTestPoller(t, s, f, now).runOnce(context.Background())
|
||||
|
||||
if got := f.callCount(); got != 1 {
|
||||
t.Fatalf("fetched %d times past the %s ceiling, want 1", got, sightingCeilingRests*defaultRest)
|
||||
}
|
||||
}
|
||||
|
||||
// Deferral is decided from live facts every round, so a Series that gains a
|
||||
// second Bookmark stops deferring at once — and one that loses it defers again.
|
||||
func TestDeferralFollowsTheBookmarkCount(t *testing.T) {
|
||||
s, dbURL := newTestStore(t)
|
||||
now := time.UnixMilli(20 * time.Hour.Milliseconds())
|
||||
seedForCheck(t, s, sightingKey, sightingURL, now.Add(-2*time.Hour).UnixMilli())
|
||||
sight(t, s, s.OwnerID(), sightingKey, sightingFixtureLatest, now.Add(-10*time.Minute))
|
||||
|
||||
f := &fakeFetcher{body: asuraSeriesFixture, status: 200}
|
||||
p := newTestPoller(t, s, f, now)
|
||||
p.runOnce(context.Background())
|
||||
if got := f.callCount(); got != 0 {
|
||||
t.Fatalf("solitary Series fetched %d times, want 0", got)
|
||||
}
|
||||
|
||||
other := secondReader(t, dbURL)
|
||||
if _, err := s.Upsert(other.OwnerID(), store.Bookmark{
|
||||
Key: sightingKey, Site: "asura", SeriesID: sightingSlug, UpdatedAt: 2000,
|
||||
}); err != nil {
|
||||
t.Fatalf("seed second reader: %v", err)
|
||||
}
|
||||
p.runOnce(context.Background())
|
||||
if got := f.callCount(); got != 1 {
|
||||
t.Fatalf("shared Series fetched %d times, want 1", got)
|
||||
}
|
||||
|
||||
// The Poll above consumed the rest, so move past it before asking again.
|
||||
if err := other.Delete(other.OwnerID(), sightingKey); err != nil {
|
||||
t.Fatalf("delete second bookmark: %v", err)
|
||||
}
|
||||
later := now.Add(2 * time.Hour)
|
||||
p.Now = func() time.Time { return later }
|
||||
sight(t, s, s.OwnerID(), sightingKey, sightingFixtureLatest, later.Add(-time.Minute))
|
||||
p.runOnce(context.Background())
|
||||
if got := f.callCount(); got != 1 {
|
||||
t.Fatalf("Series fetched %d times after returning to one Bookmark, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A Series nobody reports any more returns to the normal schedule on its own:
|
||||
// the Sighting's standing lasts one rest, not forever.
|
||||
func TestDeferralExpiresWithoutFurtherSightings(t *testing.T) {
|
||||
s, _ := newTestStore(t)
|
||||
now := time.UnixMilli(20 * time.Hour.Milliseconds())
|
||||
seedForCheck(t, s, sightingKey, sightingURL, now.Add(-2*time.Hour).UnixMilli())
|
||||
sight(t, s, s.OwnerID(), sightingKey, sightingFixtureLatest, now.Add(-10*time.Minute))
|
||||
|
||||
f := &fakeFetcher{body: asuraSeriesFixture, status: 200}
|
||||
p := newTestPoller(t, s, f, now)
|
||||
p.runOnce(context.Background())
|
||||
if got := f.callCount(); got != 0 {
|
||||
t.Fatalf("fetched %d times while the Sighting stood, want 0", got)
|
||||
}
|
||||
|
||||
p.Now = func() time.Time { return now.Add(90 * time.Minute) }
|
||||
p.runOnce(context.Background())
|
||||
if got := f.callCount(); got != 1 {
|
||||
t.Fatalf("fetched %d times once the Sighting aged out, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
// demonicFixture publishes one chapter in demonicscans' live page shape, so a
|
||||
// test can make a Site publish an arbitrary number rather than the one the
|
||||
// captured fixture froze.
|
||||
func demonicFixture(num float64) string {
|
||||
return fmt.Sprintf(
|
||||
`<a href="/chaptered.php?manga=11799&chapter=%v" class="chplinks" title="Catastrophic Necromancer %v">Chapter %v</a>`,
|
||||
num, num, num)
|
||||
}
|
||||
|
||||
const (
|
||||
demonicKey = "demonic:Catastrophic-Necromancer"
|
||||
demonicURL = "https://demonicscans.org/manga/Catastrophic-Necromancer"
|
||||
)
|
||||
|
||||
// contradictOnce reports a chapter that does not exist and then runs the round
|
||||
// that catches it, returning when that round ran so a caller can chain the
|
||||
// next one. The wait is one rest and a minute: a Sighting stands in for exactly
|
||||
// one rest, so that is the first moment this solitary Series is Polled again.
|
||||
func contradictOnce(t *testing.T, s *store.Store, p *Poller, sightAt time.Time, real float64) time.Time {
|
||||
t.Helper()
|
||||
sight(t, s, s.OwnerID(), demonicKey, real+500, sightAt)
|
||||
at := sightAt.Add(defaultRest + time.Minute)
|
||||
p.Now = func() time.Time { return at }
|
||||
p.runOnce(context.Background())
|
||||
if got := readLatestNum(t, s, s.OwnerID(), demonicKey); got != real {
|
||||
t.Fatalf("latest after the Poll = %v, want the Site's own %v", got, real)
|
||||
}
|
||||
return at
|
||||
}
|
||||
|
||||
func seedDemonic(t *testing.T, s *store.Store, checkedAt int64) {
|
||||
t.Helper()
|
||||
seedForCheck(t, s, demonicKey, demonicURL, checkedAt)
|
||||
}
|
||||
|
||||
// A Poll finding a lower number than stored means the Sighting that raised it
|
||||
// was false. The Reader is named — not the Series flagged — and both numbers are
|
||||
// logged, because that is what tells a broken adapter from a deliberate lie.
|
||||
func TestPollContradictingASightingNamesTheReaderAndBothNumbers(t *testing.T) {
|
||||
s, _ := newTestStore(t)
|
||||
now := time.UnixMilli(20 * time.Hour.Milliseconds())
|
||||
seedDemonic(t, s, now.Add(-2*time.Hour).UnixMilli())
|
||||
|
||||
var logs strings.Builder
|
||||
prev := log.Writer()
|
||||
log.SetOutput(&logs)
|
||||
t.Cleanup(func() { log.SetOutput(prev) })
|
||||
|
||||
f := &fakeFetcher{body: demonicFixture(296), status: 200}
|
||||
p := newTestPoller(t, s, f, now)
|
||||
contradictOnce(t, s, p, now, 296)
|
||||
|
||||
got := logs.String()
|
||||
for _, want := range []string{
|
||||
fmt.Sprintf("reader %d", s.OwnerID()), "796", "296", demonicKey,
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("contradiction log = %q, want it to name %q", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Three contradictions cost the Reader the right to defer. Nothing here writes
|
||||
// a counter: the marks are earned through Polls, which is the only way
|
||||
// production produces them.
|
||||
func TestThreeContradictionsStopDeferral(t *testing.T) {
|
||||
s, _ := newTestStore(t)
|
||||
start := time.UnixMilli(20 * time.Hour.Milliseconds())
|
||||
seedDemonic(t, s, start.Add(-2*time.Hour).UnixMilli())
|
||||
|
||||
f := &fakeFetcher{body: demonicFixture(296), status: 200}
|
||||
p := newTestPoller(t, s, f, start)
|
||||
at := start
|
||||
for range store.SightingDisagreementLimit {
|
||||
at = contradictOnce(t, s, p, at.Add(time.Minute), 296)
|
||||
}
|
||||
fetchesSoFar := f.callCount()
|
||||
|
||||
// The marked Reader sights the same solitary Series again. It still writes
|
||||
// the Latest Chapter — the penalty removes a privilege, it does not silence
|
||||
// anyone — but the Poll is no longer postponed: the round below runs while a
|
||||
// trusted Reader's Sighting would still be standing, and fetches anyway.
|
||||
sight(t, s, s.OwnerID(), demonicKey, 900, at.Add(31*time.Minute))
|
||||
if got := readLatestNum(t, s, s.OwnerID(), demonicKey); got != 900 {
|
||||
t.Fatalf("latest after a marked Reader's Sighting = %v, want 900", got)
|
||||
}
|
||||
p.Now = func() time.Time { return at.Add(defaultRest + time.Minute) }
|
||||
p.runOnce(context.Background())
|
||||
if got := f.callCount(); got != fetchesSoFar+1 {
|
||||
t.Fatalf("marked Reader's Sighting still deferred the Poll (fetches %d, want %d)",
|
||||
got, fetchesSoFar+1)
|
||||
}
|
||||
}
|
||||
|
||||
// The owner's remedy for a mark a broken Site adapter produced restores the
|
||||
// privilege without a wait and without SQL.
|
||||
func TestClearingMarksRestoresDeferral(t *testing.T) {
|
||||
s, _ := newTestStore(t)
|
||||
start := time.UnixMilli(20 * time.Hour.Milliseconds())
|
||||
seedDemonic(t, s, start.Add(-2*time.Hour).UnixMilli())
|
||||
|
||||
f := &fakeFetcher{body: demonicFixture(296), status: 200}
|
||||
p := newTestPoller(t, s, f, start)
|
||||
at := start
|
||||
for range store.SightingDisagreementLimit {
|
||||
at = contradictOnce(t, s, p, at.Add(time.Minute), 296)
|
||||
}
|
||||
if err := s.ClearReaderMarks(s.OwnerID()); err != nil {
|
||||
t.Fatalf("ClearReaderMarks: %v", err)
|
||||
}
|
||||
|
||||
fetchesSoFar := f.callCount()
|
||||
sight(t, s, s.OwnerID(), demonicKey, 900, at.Add(31*time.Minute))
|
||||
p.Now = func() time.Time { return at.Add(defaultRest + time.Minute) }
|
||||
p.runOnce(context.Background())
|
||||
if got := f.callCount(); got != fetchesSoFar {
|
||||
t.Fatalf("fetched %d times after the marks were cleared, want %d: deferral must resume",
|
||||
got, fetchesSoFar)
|
||||
}
|
||||
}
|
||||
|
||||
// Recovery is automatic but expensive: twenty Polls that each confirm a
|
||||
// Sighting of this Reader's clear the marks. Each round needs a new chapter,
|
||||
// because only a report that raises the stored number is attributed and so only
|
||||
// that one can be confirmed.
|
||||
func TestTwentyAgreementsClearTheMarks(t *testing.T) {
|
||||
s, _ := newTestStore(t)
|
||||
start := time.UnixMilli(20 * time.Hour.Milliseconds())
|
||||
seedDemonic(t, s, start.Add(-2*time.Hour).UnixMilli())
|
||||
|
||||
f := &fakeFetcher{body: demonicFixture(296), status: 200}
|
||||
p := newTestPoller(t, s, f, start)
|
||||
at := start
|
||||
for range store.SightingDisagreementLimit {
|
||||
at = contradictOnce(t, s, p, at.Add(time.Minute), 296)
|
||||
}
|
||||
|
||||
chapter := 296.0
|
||||
for range store.SightingAgreementsToClear {
|
||||
chapter++
|
||||
sight(t, s, s.OwnerID(), demonicKey, chapter, at.Add(time.Minute))
|
||||
f.body = demonicFixture(chapter) // the Site publishes what was reported
|
||||
at = at.Add(defaultRest + time.Minute)
|
||||
p.Now = func() time.Time { return at }
|
||||
p.runOnce(context.Background())
|
||||
}
|
||||
|
||||
fetchesSoFar := f.callCount()
|
||||
chapter++
|
||||
sight(t, s, s.OwnerID(), demonicKey, chapter, at.Add(31*time.Minute))
|
||||
p.Now = func() time.Time { return at.Add(defaultRest + time.Minute) }
|
||||
p.runOnce(context.Background())
|
||||
if got := f.callCount(); got != fetchesSoFar {
|
||||
t.Fatalf("fetched %d times after %d confirmations, want %d: the marks must be forgiven",
|
||||
got, store.SightingAgreementsToClear, fetchesSoFar)
|
||||
}
|
||||
}
|
||||
|
||||
// A Poll finding a higher number is the Site publishing since the Sighting and
|
||||
// means nothing about the Reader — no mark, and no credit either.
|
||||
func TestPollFindingHigherNumberIsNotAContradiction(t *testing.T) {
|
||||
s, _ := newTestStore(t)
|
||||
now := time.UnixMilli(20 * time.Hour.Milliseconds())
|
||||
seedDemonic(t, s, now.Add(-2*time.Hour).UnixMilli())
|
||||
// Reported truthfully, then the Site published one more.
|
||||
sight(t, s, s.OwnerID(), demonicKey, 295, now.Add(-10*time.Minute))
|
||||
|
||||
f := &fakeFetcher{body: demonicFixture(296), status: 200}
|
||||
p := newTestPoller(t, s, f, now)
|
||||
// One rest on, the Sighting has lapsed and the Poll happens.
|
||||
p.Now = func() time.Time { return now.Add(7 * time.Hour) }
|
||||
p.runOnce(context.Background())
|
||||
if got := f.callCount(); got != 1 {
|
||||
t.Fatalf("fetched %d times past the ceiling, want 1", got)
|
||||
}
|
||||
|
||||
// Unmarked, so a fresh Sighting still defers.
|
||||
at := now.Add(9 * time.Hour)
|
||||
sight(t, s, s.OwnerID(), demonicKey, 296, at.Add(-time.Minute))
|
||||
p.Now = func() time.Time { return at }
|
||||
p.runOnce(context.Background())
|
||||
if got := f.callCount(); got != 1 {
|
||||
t.Fatalf("a Reader whose report the Site overtook lost the right to defer (fetches %d, want 1)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A Poll that overtakes a Sighting takes ownership of the row: the value stored
|
||||
// afterwards is the Poll's own, so a later retraction is not the Reader's fault
|
||||
// and must not be charged to them.
|
||||
func TestAttributionDoesNotSurviveAPollThatOvertookIt(t *testing.T) {
|
||||
s, _ := newTestStore(t)
|
||||
now := time.UnixMilli(20 * time.Hour.Milliseconds())
|
||||
seedDemonic(t, s, now.Add(-2*time.Hour).UnixMilli())
|
||||
sight(t, s, s.OwnerID(), demonicKey, 295, now.Add(-10*time.Minute))
|
||||
|
||||
f := &fakeFetcher{body: demonicFixture(296), status: 200}
|
||||
p := newTestPoller(t, s, f, now)
|
||||
at := now.Add(defaultRest + time.Minute)
|
||||
p.Now = func() time.Time { return at }
|
||||
p.runOnce(context.Background())
|
||||
if got := readLatestNum(t, s, s.OwnerID(), demonicKey); got != 296 {
|
||||
t.Fatalf("latest after the Poll = %v, want the Site's own 296", got)
|
||||
}
|
||||
|
||||
var logs strings.Builder
|
||||
prev := log.Writer()
|
||||
log.SetOutput(&logs)
|
||||
t.Cleanup(func() { log.SetOutput(prev) })
|
||||
|
||||
f.body = demonicFixture(290) // the Site retracts what only the Poll wrote
|
||||
p.Now = func() time.Time { return at.Add(defaultRest + time.Minute) }
|
||||
p.runOnce(context.Background())
|
||||
if strings.Contains(logs.String(), "sighting contradicted") {
|
||||
t.Fatalf("a retraction of the Poll's own value was charged to a Reader: %s", logs.String())
|
||||
}
|
||||
}
|
||||
|
||||
// A PUT with no Latest Chapter in it — a favourite toggle, progress written
|
||||
// from a chapter page — is nobody looking at the Series page, so it buys no
|
||||
// deferral. Otherwise a client could suppress a Series' Polls while reporting
|
||||
// nothing, and with nothing reported there would be nothing to judge.
|
||||
func TestPutWithoutALatestChapterDoesNotDefer(t *testing.T) {
|
||||
s, _ := newTestStore(t)
|
||||
now := time.UnixMilli(20 * time.Hour.Milliseconds())
|
||||
seedDemonic(t, s, now.Add(-2*time.Hour).UnixMilli())
|
||||
// The handler's own call, with the field the client omitted.
|
||||
if err := s.RecordSighting(s.OwnerID(), "demonic", "Catastrophic-Necromancer",
|
||||
nil, now.Add(-time.Minute).UnixMilli()); err != nil {
|
||||
t.Fatalf("RecordSighting: %v", err)
|
||||
}
|
||||
|
||||
f := &fakeFetcher{body: demonicFixture(296), status: 200}
|
||||
p := newTestPoller(t, s, f, now)
|
||||
p.runOnce(context.Background())
|
||||
if got := f.callCount(); got != 1 {
|
||||
t.Fatalf("fetched %d times after a PUT carrying no chapter, want 1", got)
|
||||
}
|
||||
}
|
||||
@@ -409,6 +409,14 @@ const (
|
||||
// asleep (ADR-0005 on-demand browser).
|
||||
browserWakeCount = 5
|
||||
browserWakeAge = 15 * time.Minute
|
||||
// sightingCeilingRests caps Sighting deferral (issue #103): however many
|
||||
// Sightings arrive, a Series unpolled for this many of its Site's rests is
|
||||
// Polled. It is what makes a client report safe to trust — a wrong Latest
|
||||
// Chapter dies within the ceiling deterministically, rather than in
|
||||
// expectation the way a randomised audit would have it. Six, so a Series a
|
||||
// Reader visits constantly still gets one authoritative check per working
|
||||
// day-part.
|
||||
sightingCeilingRests = 6
|
||||
)
|
||||
|
||||
// effectiveGap is a Site's pace: the registry gap, or one rest divided by the
|
||||
|
||||
@@ -19,6 +19,11 @@ type LaneState struct {
|
||||
Clamped bool
|
||||
Refusing bool
|
||||
Browser bool
|
||||
// Asleep marks a browser Lane whose last pass declined to wake Chrome
|
||||
// because it was under both wake thresholds (ADR-0005). Due without
|
||||
// Checked then means "waiting for the group to gather", not "stopped", and
|
||||
// the page must not draw it as a stall.
|
||||
Asleep bool
|
||||
}
|
||||
|
||||
// Status is the owner's page snapshot of the whole poller (issue #102).
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
-- Sighting deferral (issue #103). latest_sighted_at is when a Reader's report
|
||||
-- last stood in for a Poll; it is separate from latest_checked_at because the
|
||||
-- six-hour ceiling has to know when the Series was last really fetched, and a
|
||||
-- Sighting writing the Poll's own column would erase that.
|
||||
-- latest_raised_by is attribution: whoever last raised this Series' Latest
|
||||
-- Chapter by Sighting, so a Poll that contradicts the value downwards names a
|
||||
-- Reader rather than flagging a row. Cleared by the Poll that judges it, NULL
|
||||
-- whenever the stored value is the Poll's own.
|
||||
ALTER TABLE series ADD COLUMN latest_sighted_at bigint NOT NULL DEFAULT 0;
|
||||
ALTER TABLE series ADD COLUMN latest_raised_by bigint REFERENCES readers(id) ON DELETE SET NULL;
|
||||
@@ -79,6 +79,11 @@ type Series struct {
|
||||
LatestChapter string
|
||||
LatestChapterNum *float64 // nil until first captured
|
||||
LatestCheckedAt int64 // unix ms; see MarkLatestChecked
|
||||
// LatestRaisedBy is the Reader whose Sighting last raised LatestChapter,
|
||||
// and nil when the stored value is a Poll's own finding. It is what lets a
|
||||
// Poll that contradicts the value downwards name a Reader instead of
|
||||
// merely flagging the row (issue #103); the Poll that judges it clears it.
|
||||
LatestRaisedBy *int64
|
||||
|
||||
// readerCount is the number of bookmarks referencing this series, filled
|
||||
// only by the due-queue query that orders on it.
|
||||
@@ -195,7 +200,7 @@ const bookmarkColumns = `b.site, b.series_id, s.title, s.series_url, s.cover_add
|
||||
// due query. latest_checked_at lives only on series — see MarkLatestChecked
|
||||
// for why it stays off every client-visible write.
|
||||
const seriesColumns = `s.site, s.series_id, s.title, s.series_url, s.cover, s.cover_address,
|
||||
s.kind, s.latest_chapter, s.latest_chapter_num, s.latest_checked_at`
|
||||
s.kind, s.latest_chapter, s.latest_chapter_num, s.latest_checked_at, s.latest_raised_by`
|
||||
|
||||
// Owner is the person running the service: the first Reader, seeded at startup
|
||||
// so a fresh deployment has a library before anyone logs in. The seed makes
|
||||
@@ -584,16 +589,17 @@ func (s *Store) scanBookmark(scan func(...any) error) (Bookmark, error) {
|
||||
}
|
||||
|
||||
// scanSeries reads one row in seriesColumns order, plus the due query's
|
||||
// reader_count column. latest_chapter_num is NULL until the first capture,
|
||||
// same as on the bookmark read path.
|
||||
// reader_count column. latest_chapter_num and latest_raised_by are both
|
||||
// nullable, same as latest_chapter_num on the bookmark read path.
|
||||
func scanSeries(scan func(...any) error) (Series, error) {
|
||||
var (
|
||||
sr Series
|
||||
latestChapterNum sql.NullFloat64
|
||||
latestRaisedBy sql.NullInt64
|
||||
)
|
||||
if err := scan(
|
||||
&sr.Site, &sr.SeriesID, &sr.Title, &sr.SeriesURL, &sr.Cover, &sr.CoverAddress,
|
||||
&sr.Kind, &sr.LatestChapter, &latestChapterNum, &sr.LatestCheckedAt,
|
||||
&sr.Kind, &sr.LatestChapter, &latestChapterNum, &sr.LatestCheckedAt, &latestRaisedBy,
|
||||
&sr.readerCount,
|
||||
); err != nil {
|
||||
return Series{}, err
|
||||
@@ -601,6 +607,9 @@ func scanSeries(scan func(...any) error) (Series, error) {
|
||||
if latestChapterNum.Valid {
|
||||
sr.LatestChapterNum = &latestChapterNum.Float64
|
||||
}
|
||||
if latestRaisedBy.Valid {
|
||||
sr.LatestRaisedBy = &latestRaisedBy.Int64
|
||||
}
|
||||
return sr, nil
|
||||
}
|
||||
|
||||
@@ -947,7 +956,20 @@ func (s *Store) Delete(readerID int64, key string) error {
|
||||
// burns requests. Archived bookmarks still count — knowing what a shelved
|
||||
// series is up to is the whole reason for archiving instead of deleting.
|
||||
// A series with no bookmarks at all never appears: the join excludes it.
|
||||
func (s *Store) DueForLatestCheck(site string, cutoffMs int64) ([]Series, error) {
|
||||
//
|
||||
// ceilingMs is the Sighting deferral ceiling (issue #103): a Series whose last
|
||||
// real Poll is older than it appears however recently it was sighted. That is
|
||||
// what bounds the whole mechanism — a wrong Latest Chapter dies within the
|
||||
// ceiling deterministically rather than in expectation. Deferral itself is
|
||||
// decided here, from two facts the query already computes, so a Lane gains no
|
||||
// query per round: a Sighting younger than cutoffMs holds the Series back, but
|
||||
// only while COUNT(*) is 1. A Series a second Reader bookmarks is Polled on
|
||||
// schedule, so a wrong value the whole guild can see is corrected by a check
|
||||
// that was never postponed; on a solitary Series the only person a wrong value
|
||||
// reaches is the Reader who reported it. Whether the reporting Reader is
|
||||
// allowed to defer at all was settled when the Sighting was recorded — see
|
||||
// RecordSighting.
|
||||
func (s *Store) DueForLatestCheck(site string, cutoffMs, ceilingMs int64) ([]Series, error) {
|
||||
rows, err := s.db.Query(`SELECT `+seriesColumns+`, COUNT(*) AS reader_count
|
||||
FROM series s
|
||||
JOIN bookmarks b ON b.site = s.site AND b.series_id = s.series_id
|
||||
@@ -957,7 +979,10 @@ func (s *Store) DueForLatestCheck(site string, cutoffMs int64) ([]Series, error)
|
||||
GROUP BY s.site, s.series_id, s.title, s.series_url, s.cover,
|
||||
s.kind, s.latest_chapter, s.latest_chapter_num, s.latest_checked_at
|
||||
HAVING COUNT(*) FILTER (WHERE b.status <> 'finished') > 0
|
||||
ORDER BY reader_count DESC, s.latest_checked_at ASC`, site, cutoffMs)
|
||||
AND (COUNT(*) > 1
|
||||
OR s.latest_sighted_at <= $2::bigint
|
||||
OR s.latest_checked_at <= $3::bigint)
|
||||
ORDER BY reader_count DESC, s.latest_checked_at ASC`, site, cutoffMs, ceilingMs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query due series: %w", err)
|
||||
}
|
||||
@@ -1044,3 +1069,111 @@ func (s *Store) SetLatestChapter(site, seriesID, label string, num float64) erro
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RecordSighting notes that a Reader's browser reported this Series' Latest
|
||||
// Chapter, which is the half of a Sighting the client body cannot express
|
||||
// (issue #103). It must be called *before* the Upsert that stores the reported
|
||||
// value: the raise test compares against what is still on the row, and after
|
||||
// the Upsert there is nothing left to compare with. A Series that does not
|
||||
// exist yet — the first Bookmark of it — is not a Sighting at all: nothing has
|
||||
// ever been Polled, so there is nothing to defer and nobody to attribute.
|
||||
//
|
||||
// Two independent effects, hence the two CASE arms. The deferral stamp is only
|
||||
// written for a Reader below the disagreement limit, so a marked Reader's
|
||||
// reports keep updating the Latest Chapter but stop postponing anything, and
|
||||
// clearing their marks restores the privilege on their next Sighting. The
|
||||
// attribution is written whenever the report raises the stored number,
|
||||
// including for a marked Reader — their Sightings are still judged, which is
|
||||
// how they earn the privilege back.
|
||||
//
|
||||
// num is the reported chapter number. A PUT that carries none — a favourite
|
||||
// toggle, or progress written from a chapter page — is no Sighting at all:
|
||||
// nobody read the Series page, so there is nothing to stand in for a Poll and
|
||||
// nothing that could later be judged.
|
||||
func (s *Store) RecordSighting(readerID int64, site, seriesID string, num *float64, ts int64) error {
|
||||
if num == nil {
|
||||
return nil
|
||||
}
|
||||
if _, err := s.db.Exec(`
|
||||
UPDATE series SET
|
||||
latest_sighted_at = CASE
|
||||
WHEN (SELECT sighting_disagreements FROM readers WHERE id = $3) < $6
|
||||
THEN $4::bigint ELSE latest_sighted_at END,
|
||||
latest_raised_by = CASE
|
||||
WHEN latest_chapter_num IS NULL OR $5::double precision > latest_chapter_num
|
||||
THEN $3::bigint ELSE latest_raised_by END
|
||||
WHERE site = $1 AND series_id = $2`,
|
||||
site, seriesID, readerID, ts, *num, SightingDisagreementLimit); err != nil {
|
||||
return fmt.Errorf("record sighting %s:%s: %w", site, seriesID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SightingAgreementsToClear is how many Polls must confirm a Reader's
|
||||
// Sightings in a row before their disagreements are forgiven. An agreement is
|
||||
// only recorded when a Poll later confirms a Sighting, so this is twenty Polls
|
||||
// of Series that Reader bookmarks — hours to days, not twenty page views. That
|
||||
// is the intended price: recovery is automatic but cannot be outwaited, and a
|
||||
// disagreement resets the run to zero, so credit cannot be banked in advance.
|
||||
const SightingAgreementsToClear = 20
|
||||
|
||||
// RecordSightingOutcome settles what a Poll decided about the Reader whose
|
||||
// Sighting last raised this Series' Latest Chapter, and clears the attribution
|
||||
// in the same transaction so one Sighting is judged exactly once. agreed is
|
||||
// the Poll confirming the stored value; its opposite is the Poll finding a
|
||||
// lower number, which means the raise was false.
|
||||
//
|
||||
// A Poll finding a *higher* number is neither — the Site published — and takes
|
||||
// ClearSightingAttribution instead.
|
||||
func (s *Store) RecordSightingOutcome(site, seriesID string, readerID int64, agreed bool) error {
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin sighting outcome %s:%s: %w", site, seriesID, err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// The run length is what "consecutive" means: a disagreement zeroes the
|
||||
// agreements, and completing a run zeroes both, so the next run starts
|
||||
// from nothing rather than forgiving every later disagreement instantly.
|
||||
q := `UPDATE readers SET sighting_disagreements = sighting_disagreements + 1,
|
||||
sighting_agreements = 0
|
||||
WHERE id = $1`
|
||||
args := []any{readerID}
|
||||
if agreed {
|
||||
q = `UPDATE readers SET
|
||||
sighting_agreements = CASE WHEN sighting_agreements + 1 >= $2 THEN 0
|
||||
ELSE sighting_agreements + 1 END,
|
||||
sighting_disagreements = CASE WHEN sighting_agreements + 1 >= $2 THEN 0
|
||||
ELSE sighting_disagreements END
|
||||
WHERE id = $1`
|
||||
args = append(args, SightingAgreementsToClear)
|
||||
}
|
||||
if _, err := tx.Exec(q, args...); err != nil {
|
||||
return fmt.Errorf("record sighting outcome for reader %d: %w", readerID, err)
|
||||
}
|
||||
if _, err := tx.Exec(clearAttributionSQL, site, seriesID, readerID); err != nil {
|
||||
return fmt.Errorf("clear sighting attribution %s:%s: %w", site, seriesID, err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit sighting outcome %s:%s: %w", site, seriesID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClearSightingAttribution answers a Sighting without judging it: the Poll
|
||||
// found a higher number, so the value about to be stored is its own and this
|
||||
// Reader is no longer answerable for the row. Without it the next Poll's
|
||||
// agreement would be credited to a Reader who did not earn it.
|
||||
func (s *Store) ClearSightingAttribution(site, seriesID string, readerID int64) error {
|
||||
if _, err := s.db.Exec(clearAttributionSQL, site, seriesID, readerID); err != nil {
|
||||
return fmt.Errorf("clear sighting attribution %s:%s: %w", site, seriesID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// clearAttributionSQL drops the attribution only while it still names the
|
||||
// Reader being judged: a Sighting landing between the due query's snapshot and
|
||||
// this write is a fresh, unjudged one and must not be erased by the previous
|
||||
// one's verdict.
|
||||
const clearAttributionSQL = `UPDATE series SET latest_raised_by = NULL
|
||||
WHERE site = $1 AND series_id = $2 AND latest_raised_by = $3`
|
||||
|
||||
@@ -277,6 +277,10 @@ func seedForCheck(t *testing.T, s *Store, key, seriesURL string, checkedAt int64
|
||||
}
|
||||
}
|
||||
|
||||
// noCeiling is a Sighting deferral ceiling no Series can reach, for the tests
|
||||
// that predate the ceiling and are about rest, ordering or buckets instead.
|
||||
const noCeiling = int64(-1)
|
||||
|
||||
func TestDueForLatestCheck(t *testing.T) {
|
||||
const hour = int64(3600_000)
|
||||
now := 10 * hour
|
||||
@@ -298,7 +302,7 @@ func TestDueForLatestCheck(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
seedForCheck(t, s, "asura:x", tt.seriesURL, tt.checkedAt)
|
||||
|
||||
due, err := s.DueForLatestCheck("asura", now-hour)
|
||||
due, err := s.DueForLatestCheck("asura", now-hour, noCeiling)
|
||||
if err != nil {
|
||||
t.Fatalf("DueForLatestCheck: %v", err)
|
||||
}
|
||||
@@ -319,7 +323,7 @@ func TestDueForLatestCheckOldestFirstAndScopedToSite(t *testing.T) {
|
||||
// asks for one Site, and no Lane may see another's queue.
|
||||
seedForCheck(t, s, "demonic:z", "https://demonicscans.org/manga/z", 0)
|
||||
|
||||
due, err := s.DueForLatestCheck("asura", 1000)
|
||||
due, err := s.DueForLatestCheck("asura", 1000, noCeiling)
|
||||
if err != nil {
|
||||
t.Fatalf("DueForLatestCheck: %v", err)
|
||||
}
|
||||
@@ -499,7 +503,7 @@ func TestDueForLatestCheckSkipsFinishedKeepsArchived(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
due, err := store.DueForLatestCheck("asura", time.Now().UnixMilli())
|
||||
due, err := store.DueForLatestCheck("asura", time.Now().UnixMilli(), noCeiling)
|
||||
if err != nil {
|
||||
t.Fatalf("DueForLatestCheck: %v", err)
|
||||
}
|
||||
@@ -1005,7 +1009,7 @@ func TestDueForLatestCheckOrdersByReaderCountThenAge(t *testing.T) {
|
||||
seedSecondReader(t, s, "asura:pop:2", "asura", "pop", 1001)
|
||||
seedForCheck(t, s, "asura:solo", "https://asurascans.com/comics/solo", 100)
|
||||
|
||||
due, err := s.DueForLatestCheck("asura", 1000)
|
||||
due, err := s.DueForLatestCheck("asura", 1000, noCeiling)
|
||||
if err != nil {
|
||||
t.Fatalf("DueForLatestCheck: %v", err)
|
||||
}
|
||||
@@ -1031,7 +1035,7 @@ func TestDueForLatestCheckExcludesOrphanSeries(t *testing.T) {
|
||||
t.Fatalf("seed orphan series: %v", err)
|
||||
}
|
||||
|
||||
due, err := s.DueForLatestCheck("asura", 1000)
|
||||
due, err := s.DueForLatestCheck("asura", 1000, noCeiling)
|
||||
if err != nil {
|
||||
t.Fatalf("DueForLatestCheck: %v", err)
|
||||
}
|
||||
@@ -1449,7 +1453,7 @@ func TestTwoReadersShareOneSeriesWithIndependentProgress(t *testing.T) {
|
||||
t.Fatalf("series rows = %d, want 1 shared row for two bookmarks", series)
|
||||
}
|
||||
|
||||
due, err := s.DueForLatestCheck("asura", time.Now().UnixMilli())
|
||||
due, err := s.DueForLatestCheck("asura", time.Now().UnixMilli(), noCeiling)
|
||||
if err != nil {
|
||||
t.Fatalf("DueForLatestCheck: %v", err)
|
||||
}
|
||||
@@ -1465,7 +1469,7 @@ func TestTwoReadersShareOneSeriesWithIndependentProgress(t *testing.T) {
|
||||
if b, ok, err := s.Get(s.OwnerID(), "asura:solo"); err != nil || !ok || b.LastChapterNum != 200 {
|
||||
t.Fatalf("owner's bookmark after the other's delete = %+v ok=%v err=%v, want it intact", b, ok, err)
|
||||
}
|
||||
due, err = s.DueForLatestCheck("asura", time.Now().UnixMilli())
|
||||
due, err = s.DueForLatestCheck("asura", time.Now().UnixMilli(), noCeiling)
|
||||
if err != nil {
|
||||
t.Fatalf("DueForLatestCheck after delete: %v", err)
|
||||
}
|
||||
|
||||
@@ -61,8 +61,11 @@ type laneRow struct {
|
||||
// is configured, which stops those Series just as completely.
|
||||
BrowserLost bool
|
||||
// Stalled marks a Lane with Series waiting that its last pass did not read
|
||||
// — the difference between a stopped Lane and a quiet one (story 13).
|
||||
// — the difference between a stopped Lane and a quiet one (story 13). A
|
||||
// browser Lane holding Chrome asleep under the wake thresholds is neither,
|
||||
// so it carries Asleep instead and never Stalled.
|
||||
Stalled bool
|
||||
Asleep bool
|
||||
// Attention is the one flag the template colours on, so an unhealthy Lane
|
||||
// is found at a glance rather than read for.
|
||||
Attention bool
|
||||
@@ -152,8 +155,9 @@ func (h *Handler) lanesView() lanesView {
|
||||
for _, l := range snap.Lanes {
|
||||
lost := l.Browser && !snap.BrowserReachable
|
||||
// Series waiting and none read is the shape of a Lane that has stopped
|
||||
// working, as distinct from one that is quiet for want of work.
|
||||
stalled := l.Due > 0 && l.Checked == 0
|
||||
// working, as distinct from one that is quiet for want of work — or one
|
||||
// deliberately leaving Chrome asleep until its group gathers.
|
||||
stalled := l.Due > 0 && l.Checked == 0 && !l.Asleep
|
||||
gap := ""
|
||||
if l.Gap > 0 {
|
||||
gap = l.Gap.Truncate(time.Second).String()
|
||||
@@ -168,6 +172,7 @@ func (h *Handler) lanesView() lanesView {
|
||||
Refusing: l.Refusing,
|
||||
BrowserLost: lost,
|
||||
Stalled: stalled,
|
||||
Asleep: l.Asleep,
|
||||
Attention: l.Clamped || l.Refusing || lost || stalled,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
{{if .Refusing}}<span class="lane-mark">refusing</span>{{end}}
|
||||
{{if .BrowserLost}}<span class="lane-mark">no browser</span>{{end}}
|
||||
{{if .Stalled}}<span class="lane-mark">not checking</span>{{end}}
|
||||
{{if .Asleep}}<span class="lane-mark">browser asleep</span>{{end}}
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
|
||||
@@ -776,6 +776,37 @@ func TestAdminPageShowsLaneStatus(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A browser Lane under both wake thresholds holds Chrome asleep (ADR-0005), so
|
||||
// Series due with none checked is the design working, not a stopped Lane. The
|
||||
// two must not render the same mark: "not checking" is the owner's cue to go
|
||||
// looking, and spending it on the commonest healthy browser-Lane state trains
|
||||
// them to ignore it.
|
||||
func TestAsleepBrowserLaneIsNotMarkedStalled(t *testing.T) {
|
||||
lanes := fakeLanes{latest.Status{
|
||||
Lanes: []latest.LaneState{
|
||||
{Site: "kagane", Due: 1, LastRun: time.Now(), Gap: 10 * time.Second, Browser: true, Asleep: true},
|
||||
},
|
||||
BrowserConfigured: true,
|
||||
BrowserReachable: true,
|
||||
}}
|
||||
router, st, _ := oauthWebTestServer(t, lanes)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/ui/admin/lanes", nil)
|
||||
req.AddCookie(sessionCookie(t, st))
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
body := rr.Body.String()
|
||||
if strings.Contains(body, "not checking") {
|
||||
t.Errorf("an asleep browser Lane is marked as stalled:\n%s", body)
|
||||
}
|
||||
if !strings.Contains(body, "browser asleep") {
|
||||
t.Errorf("an asleep browser Lane says nothing about why it read nothing:\n%s", body)
|
||||
}
|
||||
if strings.Contains(body, `class="attention"`) {
|
||||
t.Errorf("an asleep browser Lane is coloured as unhealthy:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// A Lane whose pass never reached a figure must not have that figure drawn as
|
||||
// a zero: a refusing Lane still reports the due count and gap its last real
|
||||
// pass saw, and a Lane that has never reached one omits it entirely.
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
# ADR-0011: Sightings — a Reader report defers a Poll where being wrong hurts only them
|
||||
|
||||
Date: 2026-08-16
|
||||
Status: accepted
|
||||
|
||||
## Decision
|
||||
|
||||
A **Sighting** is the Latest Chapter the Reader's own browser read off the
|
||||
Series page and PUT to the backend. It is now allowed to stand in for a Poll,
|
||||
under one restriction and one ceiling:
|
||||
|
||||
- **Solitary Series only.** A Sighting defers the Poll of a Series exactly one
|
||||
Bookmark points at. A Series two Readers share is Polled on schedule no matter
|
||||
how recently it was sighted.
|
||||
- **One rest of standing.** A Sighting postpones Polls for one Rest
|
||||
(`defaultRest`, an hour), not forever: a Series nobody visits again returns to
|
||||
the normal schedule by itself.
|
||||
- **Six-rest ceiling.** `sightingCeilingRests = 6`, counted in the Site's own
|
||||
Rest — six hours everywhere today. However many Sightings arrive, a Series
|
||||
unpolled that long is Polled.
|
||||
|
||||
Both live in the due query's HAVING clause (`store.DueForLatestCheck`), beside
|
||||
the Rest cutoff — the same place the schedule has always been decided, so no
|
||||
timer and no second code path can disagree with it.
|
||||
|
||||
Attribution and judgement:
|
||||
|
||||
- `Store.RecordSighting` runs *before* the Upsert that stores the reported
|
||||
value, because the raise test needs the row as it stands. A report that raises
|
||||
the stored Latest Chapter names its Reader in `series.latest_raised_by`.
|
||||
- The Poll is the oracle. `Poller.checkOne` already compares what the Site
|
||||
publishes against what is stored, so judgement costs no extra request: a lower
|
||||
number contradicts the Sighting (`sighting_disagreements + 1`, both numbers and
|
||||
the Reader logged), the same number confirms it (`sighting_agreements + 1`), a
|
||||
**higher** number is the Site publishing and means nothing either way — but it
|
||||
does clear the attribution (`Store.ClearSightingAttribution`), because the
|
||||
value stored afterwards is the Poll's own and nobody must answer for it.
|
||||
- At `SightingDisagreementLimit` (3) that Reader's Sightings stop deferring
|
||||
anything. They still write the Latest Chapter — the penalty removes a
|
||||
privilege, it does not silence anyone.
|
||||
- `SightingAgreementsToClear` (20) consecutive confirmations forgive the
|
||||
disagreements. A disagreement resets the run to zero.
|
||||
- The owner clears marks from the administration page (issue #102, shipped
|
||||
first precisely so a false mark has a remedy the day the mechanism lands).
|
||||
|
||||
One client change was required, and only one. Both userscripts stopped short of
|
||||
PUTting a read whose number had not moved (`applyLatestChapterIfChanged`), so
|
||||
the case this whole mechanism exists for — visiting a Series with nothing new —
|
||||
never reached the backend. `reportLatestChapter` now sends it, skipping only the
|
||||
local write and the re-render. A numberless PUT (favourite toggle, progress from
|
||||
a chapter page) is not a Sighting and defers nothing: nobody read the Series
|
||||
page, so there would be nothing to judge later.
|
||||
|
||||
## Why
|
||||
|
||||
Most of the backend's work was redundant. The userscript reads the Latest
|
||||
Chapter on every Series page visit; minutes later the Poll Lane fetches the same
|
||||
page for the same number. Deferring on a report converts a visit into a Poll
|
||||
saved, which is Lane capacity handed back to Series nobody is
|
||||
reading.
|
||||
|
||||
The restriction is the whole safety argument, and it is about **blast radius**,
|
||||
not about trust arithmetic:
|
||||
|
||||
- On a solitary Series, a wrong report can only mislead the Reader who made it.
|
||||
There is nobody else's ember to falsify.
|
||||
- On a shared Series it could mislead someone else, so a report never postpones
|
||||
anything there.
|
||||
|
||||
The ceiling bounds the damage in time: a false value dies within six hours
|
||||
whatever happens, because the Poll that finds it is guaranteed. That is also
|
||||
what makes lying pointless — the six-hour audit is certain, not sampled, so a
|
||||
determined attacker buys at most three ceilings' worth of a wrong number on
|
||||
their own Series and then loses deferral entirely.
|
||||
|
||||
The cost of recovery is deliberate. An agreement is only recorded when a later
|
||||
Poll confirms a Sighting, so twenty agreements are twenty Polls of Series that
|
||||
Reader bookmarks — hours to days of real time, not twenty page views. Waiting is
|
||||
therefore not a strategy, and credit cannot be banked in advance.
|
||||
|
||||
## Tradeoffs and rejections
|
||||
|
||||
- **Trusting a Sighting on a shared Series** rejected: it is the only case where
|
||||
one Reader's mistake reaches another Reader's list, and no amount of
|
||||
reputation makes that recoverable within the six-hour window.
|
||||
- **Cross-Reader agreement, voting, weighting, consensus scoring** rejected on
|
||||
evidence: every truth-discovery method estimates source reliability by
|
||||
comparing sources on the same object, and the standard survey states outright
|
||||
that an object provided by very few sources cannot have its confidence
|
||||
evaluated — Li, Gao, Meng, Li, Su, Zhao, Fan, Han, *A Survey on Truth
|
||||
Discovery*, SIGMOD Record 45(1), 2016 (arXiv:1505.02463), §"Challenges" on
|
||||
sparse sources. With the two Readers this backend actually has, a
|
||||
disagreement is a coin flip. The Poll is an authoritative oracle, so it is
|
||||
the only judge.
|
||||
- **A randomised audit** (Poll a fraction of deferred Series) rejected in favour
|
||||
of the fixed ceiling. Sampling an oracle against untrusted reports is the
|
||||
gold-question technique from crowdsourcing quality control — Le, Edmonds,
|
||||
Hester, Biewald, *Ensuring quality in crowdsourced search relevance
|
||||
evaluation: the effects of training question distribution*, SIGIR 2010
|
||||
Workshop on Crowdsourcing for Search Evaluation, which inserts known answers
|
||||
sporadically and adjusts each worker's trust from them. The ceiling is the
|
||||
same idea made deterministic: sampling prices an attack in expectation, a
|
||||
guaranteed six-hour audit prices it as a certainty, which is what makes the
|
||||
solitary-Series rule defensible in one sentence.
|
||||
- **A trust *ratio*** (agreements over judgements, as that same gold-question
|
||||
scheme uses) rejected for two thresholds: a ratio lets an attacker bank
|
||||
credit first and spend it on lies later, and it needs the owner watching a
|
||||
score to act. Three-and-twenty is a threshold both ways — a disagreement
|
||||
resets the run to zero, so credit cannot be pre-bought, and recovery happens
|
||||
without the owner in the loop.
|
||||
- **Blocking a marked Reader's writes** rejected: the Latest Chapter they report
|
||||
is still the best available value, and their Sightings must keep being judged
|
||||
or they could never earn the privilege back.
|
||||
- **Per-Series flagging** rejected in favour of per-Reader marks: a Series is
|
||||
not the thing that can be wrong. Naming the Reader and logging both numbers is
|
||||
also what distinguishes a broken Site adapter (every Reader of that Site
|
||||
contradicted at once) from one bad actor.
|
||||
- **Timers or a background reputation job** rejected: deferral is recomputed
|
||||
from live facts every round — Bookmark count and sighting timestamp — so a
|
||||
Series that gains a second Bookmark stops deferring at once, with nothing to
|
||||
invalidate. The Reader's marks are the one input read earlier, when the
|
||||
Sighting is recorded rather than when the round runs: a Reader who crosses
|
||||
the threshold, or has their marks cleared, changes behaviour from their next
|
||||
Sighting on, and the standing they already bought lasts out its rest. That is
|
||||
bounded by one rest and costs one subselect instead of joining `readers` into
|
||||
the due query on every round.
|
||||
|
||||
## Constraints preserved
|
||||
|
||||
- A Sighting is not Progress: it may move the Latest Chapter and nothing else.
|
||||
`updated_at` never moves, so a report cannot reorder the list (ADR-0004).
|
||||
- The Latest Chapter is a Series-level fact (ADR-0003): a Sighting writes the
|
||||
shared row, so every Reader of a shared Series sees it immediately — deferral
|
||||
is the only thing the solitary rule withholds.
|
||||
- Ember means new chapter only (`docs/design-system.md`): a marked Reader
|
||||
renders no differently in their own list, and nothing about the trust model
|
||||
reaches the Series list's colour.
|
||||
- The Poll remains authoritative. Where a Sighting and a Poll disagree, the
|
||||
Poll's value is what gets stored.
|
||||
@@ -0,0 +1,344 @@
|
||||
# GIF — maximum byte size of a file
|
||||
|
||||
Research note for Gitea issue #71 (backend `maxBodyBytes` = 4 MiB rejects the
|
||||
8,571,192-byte animated cover GIF at
|
||||
`https://cdn.asurascans.com/asura-images/covers/a-dragonslayers-peerless-regression.gif`).
|
||||
|
||||
All facts fetched live on **2026-08-17**: the GIF89a spec at
|
||||
`https://www.w3.org/Graphics/GIF/spec-gif89a.txt`, Go stdlib `image/gif`
|
||||
sources at `/usr/local/go/src/image/gif/reader.go` (Go 1.26.5), Chromium
|
||||
`blink/renderer/platform/image-decoders/` sources via
|
||||
`chromium.googlesource.com`, Firefox `image/decoders/nsGIFDecoder2.cpp` via
|
||||
`hg.mozilla.org`, and cover bytes probed with plain `curl` (desktop Chrome UA;
|
||||
`HEAD`/ranged `GET`). **No Cloudflare challenge was encountered on any CDN
|
||||
probe** — every request returned real headers, consistent with the AGENTS.md
|
||||
note of 2026-07-26 that plain `curl` works against both scan sites from the
|
||||
dev machine and the VPS.
|
||||
|
||||
Every claim carries the URL it came from, or a reproducible command.
|
||||
Interpretation rather than observation is marked `[INFERENCE]`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Summary answer table
|
||||
|
||||
| Question | Answer | Evidence |
|
||||
|---|---|---|
|
||||
| Does the GIF89a spec define a maximum file size? | **No.** There is no file-size field anywhere in the format; the only numeric ceilings are per-field (16-bit screen/image dimensions, 255-byte sub-blocks, 12-bit LZW codes). | §2 |
|
||||
| Maximum logical screen | 65535 × 65535 pixels (unsigned 16-bit width/height). | §2.1 |
|
||||
| Number of frames / image descriptors | Unbounded — "An unlimited number of images may be present per Data Stream." | §2.2 |
|
||||
| Formal max byte size of any single GIF | None. Single-frame worst case ≈ **6.44 GB** (12-bit LZW, max canvas); animated GIFs are **unbounded** because frames are unbounded. | §3 |
|
||||
| Does the backend's decoder (Go `image/gif`) bound size? | **No.** It reads 16-bit dimensions and allocates `width×height` bytes per frame; a 65535² frame forces a ~4 GiB allocation. No total-size or dimension guard. | §4.1 |
|
||||
| Do browsers bound on-disk GIF size? | Chromium and Firefox: no on-wire size cap in their GIF readers; Chromium caps *decoded* memory at min(4 B × pixels, platform budget). | §4.3, §4.4 |
|
||||
| Real cover sizes (asurascans, n=25) | min 190,410 B · median 1,275,082 B · p90 4,524,788 B · max 8,571,192 B · **3/25 > 4 MiB** (two JPEGs and the animated GIF) | §5 |
|
||||
| Real cover sizes (demonicscans/readermc, n=78) | min 13,298 B · median 63,061 B · max 801,200 B · 0/78 > 4 MiB | §5 |
|
||||
| Comparable service caps | GitHub: 10 MB for images/GIFs. Discord API: default 10 MiB per file. Wikimedia: 100 MiB upload / 5 GiB host. | §6 |
|
||||
| Recommended cover cap for #71 | **10 MiB** (separate from the 4 MiB series-page cap). Covers 100% of the 103 observed covers; matches GitHub/Discord calibration; ≤ 20 MiB worst-case transient per concurrent fetch+serve on a 1974 MiB swapless VPS. | §7 |
|
||||
|
||||
---
|
||||
|
||||
## 2. What the GIF89a specification actually bounds
|
||||
|
||||
Source: `https://www.w3.org/Graphics/GIF/spec-gif89a.txt` (fetched 2026-08-17).
|
||||
|
||||
### 2.1 Fixed-width fields — the only hard ceilings
|
||||
|
||||
The format is a stream of fixed-width blocks; the numeric fields that *do*
|
||||
have a ceiling are all 16-bit unsigned, little-endian ("multi-byte numeric
|
||||
fields are ordered Least Significant Byte first", §4 of the spec):
|
||||
|
||||
- **Logical Screen Width / Height** — "Unsigned" 2-byte fields (§18, Logical
|
||||
Screen Descriptor) → maximum **65535 × 65535** pixels.
|
||||
- **Image Left / Top Position, Image Width / Height** — "Unsigned" 2-byte
|
||||
fields (§20, Image Descriptor). Each image "must fit within the boundaries
|
||||
of the Logical Screen" (§20a), so an image cannot exceed the 65535² canvas
|
||||
even though its own fields would allow it.
|
||||
- **Data sub-blocks** — "A data sub-block may contain from 0 to 255 data
|
||||
bytes" (§15); each sub-block is preceded by a 1-byte size field and the
|
||||
stream is terminated by a 0x00 Block Terminator (§16). This bounds a
|
||||
*chunk*, not the stream.
|
||||
- **Global/Local Color Tables** — optional, "3 x 2^(Size of Global Color
|
||||
Table+1)" bytes with a 3-bit size field → at most 3 × 2⁸ = **768 bytes**
|
||||
each (§19, §21).
|
||||
- **LZW codes** — "The output codes are of variable length, starting at
|
||||
<code size>+1 bits per code, **up to 12 bits per code**. This defines a
|
||||
maximum code value of 4095 (0xFFF)" (Appendix F, COMPRESSION, rule 4).
|
||||
- **Trailer** — a single byte, fixed value 0x3B, "indicating the end of the
|
||||
GIF Data Stream" (§27).
|
||||
|
||||
### 2.2 What is unbounded
|
||||
|
||||
- **Number of images (frames).** §20a, verbatim: "This block is REQUIRED for
|
||||
an image. Exactly one Image Descriptor must be present per image in the
|
||||
Data Stream. **An unlimited number of images may be present per Data
|
||||
Stream.**"
|
||||
- **The Data Stream itself.** The grammar in Appendix B is
|
||||
`<GIF Data Stream> ::= Header <Logical Screen> <Data>* Trailer`, and the
|
||||
spec states "the entity Data … may be repeated any number of times,
|
||||
including 0 times." There is **no field anywhere that carries a file size,
|
||||
byte count, frame count, or total-length value**. §13 (Block Sizes) only
|
||||
defines sizes *within* blocks.
|
||||
|
||||
### 2.3 Verdict
|
||||
|
||||
**The GIF89a specification defines no maximum file size.** The only hard
|
||||
bounds are per-field: 65535×65535 pixels per screen/image, 255 bytes per
|
||||
sub-block, 12 bits per LZW code, and one trailer byte. A compliant decoder
|
||||
must process whatever stream the blocks describe. Any byte ceiling a
|
||||
particular GIF actually hits is therefore *implicit* — 16-bit dimensions,
|
||||
LZW code width, decoder memory, or an external policy — never something the
|
||||
format itself enforces. `[INFERENCE]` This is why real-world GIFs cap out at
|
||||
"a few GB at most" and every service that wants a bound has to impose one
|
||||
itself (see §6; Wikimedia explicitly documents that a 4 GiB host limit was a
|
||||
storage-representation artifact of 32-bit integers, `phab:T191805`, not a
|
||||
format limit).
|
||||
|
||||
---
|
||||
|
||||
## 3. Theoretical worst case
|
||||
|
||||
### 3.1 Single frame, maximal canvas, 8-bit pixels
|
||||
|
||||
| Quantity | Value | Derivation |
|
||||
|---|---|---|
|
||||
| Max pixels | 4,294,836,225 | 65535 × 65535 |
|
||||
| Raw 8-bit palette-index raster | 4,294,836,225 B ≈ **4.29 GB / 4.00 GiB** | 1 byte per pixel (Table Based Image Data, §22; Go's `image.Paletted` uses exactly 1 byte/pixel) |
|
||||
| LZW worst case | ≈ **6.44 GB / 6.00 GiB** | codes ≤ 12 bits each (Appendix F), at most ~1 code per pixel for incompressible data → ≤ 12 bits/px = 1.5 B/px → 4,294,836,225 × 1.5 B |
|
||||
| Sub-block overhead | ≈ +25.3 MB | every ≤255-byte chunk carries a 1-byte size field (§15): ⌈6,442,254,338 / 255⌉ ≈ 25,263,743 size bytes, + 1 block terminator |
|
||||
| Fixed overhead | ≈ +1.6 KB | header 6 B (§17) + logical screen descriptor 7 B (§18) + global color table ≤ 768 B (§19) + image descriptor 10 B (§20) + local color table ≤ 768 B (§21) + LZW minimum code size 1 B (§22) |
|
||||
|
||||
So a **single maximal-frame GIF cannot exceed ≈ 6.47 GB on the wire**
|
||||
(12-bit LZW bound), and LZW being lossless means the real byte count depends
|
||||
entirely on image content — the same canvas can be a few KB (flat color) or
|
||||
~6 GB (noise).
|
||||
|
||||
Two caveats, both marked `[INFERENCE]`:
|
||||
|
||||
- The "1.5 B/px" figure assumes ~one emitted code per pixel. An encoder is
|
||||
permitted to emit a Clear code at any point (Appendix F: "The Clear code
|
||||
can appear at any point in the image data stream"), so a
|
||||
pathological-but-compliant encoder emitting clear+pixel per pixel reaches
|
||||
~24 bits/px ≈ 12.9 GB for the max canvas. Real encoders do not do this;
|
||||
12-bit/px is the practical bound.
|
||||
- The spec's deferred-clear note (cover sheet) explicitly allows an encoder
|
||||
to keep using a full table at 12-bit codes without clearing, so the 12-bit
|
||||
cap holds for the whole stream, it cannot "grow" past 12 bits.
|
||||
|
||||
### 3.2 Animated GIFs: unbounded
|
||||
|
||||
Every frame is one Image Descriptor, each bounded by the 65535² canvas, but
|
||||
the *count* of frames is unbounded (§2.2). Total bytes = sum over frames —
|
||||
therefore **there is no finite maximum byte size for an animated GIF** in
|
||||
the format. The only thing that stops a real one is decoder memory, a
|
||||
service cap, or disk space. `[INFERENCE]` This is the category the issue #71
|
||||
cover falls into: it is an animated GIF (NETSCAPE2.0 loop extension found at
|
||||
offset 0x310 of the file, verified 2026-08-17 by a ranged GET), and its
|
||||
8,571,192 bytes are ~2.04× the current 4 MiB backend cap.
|
||||
|
||||
---
|
||||
|
||||
## 4. Decoder-side real limits
|
||||
|
||||
### 4.1 Go `image/gif` (the backend's decoder path, stdlib)
|
||||
|
||||
Source: `/usr/local/go/src/image/gif/reader.go`, Go 1.26.5.
|
||||
|
||||
- Dimensions are read as little-endian uint16 — `left/top/width/height :=
|
||||
int(d.tmp[N]) + int(d.tmp[N+1])<<8` (reader.go:490-493) — so the format
|
||||
ceiling 65535 applies, and nothing smaller is enforced.
|
||||
- The only geometric check is that each frame fits inside the logical
|
||||
screen: `if left+width > d.width || top+height > d.height` →
|
||||
`errors.New("gif: frame bounds larger than image bounds")` (reader.go:512-513).
|
||||
- **There is no file-size, byte-count, frame-count, or pixel-count guard.**
|
||||
Each frame allocates `image.NewPaletted(...)` (reader.go:515) — a
|
||||
`[]byte` of width×height — so decoding one legal 65535² frame attempts a
|
||||
**~4.29 GB allocation**. `DecodeAll` (reader.go:603-605) additionally
|
||||
retains every frame's `Pix` slice for the lifetime of the returned `*GIF`.
|
||||
- `[INFERENCE]` On the 1974 MiB swapless VPS (root AGENTS.md), decoding such
|
||||
a file would OOM rather than error cleanly; nothing in stdlib protects
|
||||
the process. This matters for §7: the backend stores cover bytes without
|
||||
decoding them (see §5.3), so the fetch path never triggers this — but any
|
||||
future "validate/re-encode server-side" scheme would.
|
||||
- Grep for `MaxInt|limit|too large|bounds` in reader.go: the only hits are
|
||||
the frame-bounds check above and the `tmp [1024]byte` scratch buffer
|
||||
(reader.go:109); no size caps exist.
|
||||
|
||||
### 4.2 giflib / libgif
|
||||
|
||||
**Not verified from source.** On 2026-08-17 the giflib sources were not
|
||||
reachable from this network: `github.com/giflib/giflib` returns 404 (repo
|
||||
gone/moved), `gitlab.com/giflib/giflib/-/raw/...` answers a Cloudflare
|
||||
"Just a moment…" challenge, and the SourceForge project download path
|
||||
404s. No limit claim about giflib is made here. `[INFERENCE]` giflib is
|
||||
widely known to be allocation-driven with no dimension cap, but that is not
|
||||
checked against source and is not needed for issue #71 (the backend uses Go
|
||||
stdlib, not giflib).
|
||||
|
||||
### 4.3 Chromium (browser behaviour, first-party source)
|
||||
|
||||
- `third_party/blink/renderer/platform/image-decoders/gif/gif_image_reader.cc`
|
||||
(via `chromium.googlesource.com/chromium/src/+/main/...`, fetched
|
||||
2026-08-17): **no GIF byte-size or dimension cap found** — grep for
|
||||
`max|limit|too large|dimension|65535|overflow` matches only license text.
|
||||
- The base `ImageDecoder` caps *decoded memory*, not transfer size:
|
||||
`CalculateMaxDecodedBytes` computes `min(4 * num_pixels, platform_max_decoded_bytes)`
|
||||
(8 bytes/pixel for high-bit-depth), and the header comment says "Ignoring
|
||||
this limit can cause excessive memory use or even crashes on low-memory
|
||||
devices"
|
||||
(`image_decoder.cc:94-117`, `image_decoder.h:545-549`). The GIF reader
|
||||
itself is untouched by this — it is a decoded-buffer budget.
|
||||
- Practical consequence `[INFERENCE]`: a browser will happily download and
|
||||
store a multi-GB GIF from its own cache perspective; Chromium only limits
|
||||
what it *decodes* into pixels.
|
||||
|
||||
### 4.4 Firefox
|
||||
|
||||
`image/decoders/nsGIFDecoder2.cpp` (via `hg.mozilla.org/mozilla-central/
|
||||
raw-file/tip/...`, fetched 2026-08-17): **no dimension or size limit**; the
|
||||
only guards are on LZW code width (`MAX_BITS` = 12, "maximum codeword size
|
||||
of 12 bits") and the decode stack. Nothing bounds the on-disk byte size.
|
||||
|
||||
### 4.5 Summary
|
||||
|
||||
No mainstream decoder enforces a byte-size ceiling; they stop at the 16-bit
|
||||
dimension ceiling (Go, by construction) or at decoded-memory budgets
|
||||
(Chromium) or nowhere (Firefox). A GIF's byte size is policed only by
|
||||
*storage* policies — which is what §6 calibrates and §7 sets.
|
||||
|
||||
---
|
||||
|
||||
## 5. Practical distribution — what real manga covers weigh
|
||||
|
||||
Probed **2026-08-17** with `curl -sI` (HEAD) and ranged GETs, desktop Chrome
|
||||
UA. No Cloudflare block on any request. Sample = covers *as the backend
|
||||
would fetch them* (the `og:image`/page-listed cover URL), not thumbnails we
|
||||
chose by hand.
|
||||
|
||||
### 5.1 Exact commands
|
||||
|
||||
```sh
|
||||
# asurascans.com — harvest cover URLs from the homepage, then HEAD each
|
||||
curl -s -A "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/126.0" https://asurascans.com/ -o home.html
|
||||
grep -oE 'https://cdn\.asurascans\.com/asura-images/covers/[^"&\\< ]+\.(webp|gif|jpg|jpeg|png)' home.html \
|
||||
| sort -u | grep -v '\-400\.' | head -25 > sample.txt # one full-res cover per series, no -400 thumbs
|
||||
while read -r u; do curl -s -A "…Chrome/126.0" -I "$u" | tr -d '\r' \
|
||||
| grep -iE '^content-length:'; done < sample.txt
|
||||
|
||||
# demonicscans.org — covers live on readermc.org (ADR-0007), URLs contain spaces/UTF-8
|
||||
curl -s -A "…Chrome/126.0" https://demonicscans.org/ -o demonic.html
|
||||
grep -oE 'src="https://readermc\.org/images/thumbnails/[^"]+"' demonic.html | tr -d 'src="' > demonic.txt
|
||||
# …plus og:image from 5 manga pages (Catastrophic-Necromancer, Magic-Emperor, …)
|
||||
# each URL percent-encoded per path segment (urllib.parse.quote, safe=':/') before HEAD
|
||||
```
|
||||
|
||||
### 5.2 asurascans — 25 full-res covers (mixed formats)
|
||||
|
||||
Homepage fetched 200 (664,700 B). All 25 returned `200` with a real
|
||||
`Content-Length`. Distribution:
|
||||
|
||||
| Statistic | Bytes |
|
||||
|---|---|
|
||||
| n | 25 |
|
||||
| min | 190,410 |
|
||||
| median | 1,275,082 |
|
||||
| p90 | 4,524,788 |
|
||||
| max | 8,571,192 |
|
||||
| mean | 1,943,651 |
|
||||
| **> 4 MiB (4,194,304)** | **3 (12%)** — `a-dragonslayers-peerless-regression.gif` 8,571,192 (the issue #71 cover, animated: NETSCAPE2.0 at 0x310, 550×733, 256 colors); `bad-born-blood.3008f6.webp` 4,524,788 `image/jpeg`; `ending-maker.cfbf53.webp` 4,619,303 `image/jpeg` |
|
||||
|
||||
Notes: the CDN serves `Content-Type` by stored bytes, not by URL extension
|
||||
(the `.webp` URLs return `image/png`, `image/jpeg`, or `image/webp` — the
|
||||
sample spans all four of `png/jpeg/webp/gif`). Two of the three over-cap
|
||||
files are **not GIFs**, so the current 4 MiB cap already silently drops 12%
|
||||
of asura covers of any format. p90 itself (4.52 MB) exceeds the cap.
|
||||
|
||||
### 5.3 demonicscans — 78 covers on readermc.org
|
||||
|
||||
78 unique cover URLs (73 from the homepage's `/images/thumbnails/` plus 5
|
||||
`og:image` values from manga pages — demonicscans publishes the thumbnail
|
||||
file as the full cover, so that is exactly what the backend would fetch).
|
||||
**78/78 returned 200 with a real Content-Length** (spaces and UTF-8 in the
|
||||
filenames were percent-encoded per path segment; the homepage's raw HTML
|
||||
carries `’`-style mojibake for curly quotes, which was repaired by
|
||||
latin-1→utf-8 re-encoding before probing).
|
||||
|
||||
| Statistic | Bytes |
|
||||
|---|---|
|
||||
| n | 78 |
|
||||
| min | 13,298 |
|
||||
| median | 63,061 |
|
||||
| p90 | 206,994 |
|
||||
| max | 801,200 |
|
||||
| mean | 110,038 |
|
||||
| > 4 MiB | 0 |
|
||||
|
||||
### 5.4 Reading
|
||||
|
||||
`[INFERENCE]` asurascans covers are the heavy tail (median 1.3 MB, top
|
||||
decile > 4 MiB, occasional ~5–9 MB), demonicscans covers are tiny (all
|
||||
< 0.8 MB). A cover cap must be chosen against the *asura* distribution —
|
||||
the 8.57 MB animated GIF is not a freak one-off outlier; the 90th
|
||||
percentile already crosses 4 MiB and two JPEGs sit between 4.5–4.7 MB.
|
||||
|
||||
---
|
||||
|
||||
## 6. Comparable documented byte caps (first-party docs only)
|
||||
|
||||
| Service | Cap | Source (fetched 2026-08-17) |
|
||||
|---|---|---|
|
||||
| GitHub (issues/PR comments) | **10 MB for images and gifs**; 25 MB other files; 10/100 MB video | `https://docs.github.com/en/get-started/writing-on-github/working-with-advanced-formatting/attaching-files` — "The maximum file size is: 10MB for images and gifs … 25MB for all other files" |
|
||||
| Discord (API uploads) | default **10 MiB per file**, higher with Nitro / boost tier | `https://discord.com/developers/docs/reference#uploading-files` — "The file upload size limit applies to each file in a request. The default limit is `10 MiB` for all users" (help-center article `support.discord.com/hc/en-us/articles/115002935588` exists but answered 403 from this network on the probe date, so its figures were not verified here) |
|
||||
| Wikimedia Commons | **100 MiB** upload limit; hosting up to **5 GiB**; GIF thumbnails limited to **100 megapixels**; prior 4 GiB host cap was a 32-bit storage artifact (phab:T191805) | `https://commons.wikimedia.org/wiki/Commons:Maximum_file_size` |
|
||||
| MDN | nothing — MDN documents no byte-size limit for images; browsers impose none (see §4.3–4.4) | `[INFERENCE]` from absence in the platform docs read in §4 |
|
||||
|
||||
Calibration takeaway: two major platforms independently land on **~10 MB**
|
||||
as the ceiling for an uploadable image/GIF (GitHub exactly 10 MB, Discord
|
||||
exactly 10 MiB), with Wikimedia the outlier at 100 MiB/5 GiB because it is a
|
||||
media *archive*. A 10 MiB cover cap is therefore squarely inside industry
|
||||
normal.
|
||||
|
||||
---
|
||||
|
||||
## 7. Recommendation for issue #71
|
||||
|
||||
**Raise the cover cap to 10 MiB (10,485,760 B) — as a separate constant, not
|
||||
by moving the shared one.**
|
||||
|
||||
Why:
|
||||
|
||||
- **Fits the measured reality.** The largest observed cover is 8,571,192 B
|
||||
(the issue's animated GIF) = 82% of 10 MiB; 10 MiB covers **100% of the
|
||||
103 sampled covers** and the *entire* asura distribution, including its
|
||||
heavy tail. 4 MiB rejects 12% of asura covers (two of them plain JPEGs).
|
||||
- **Matches industry calibration** (§6): GitHub 10 MB images/GIFs, Discord
|
||||
10 MiB default. A 10 MiB cap is a number every engineer recognizes, and
|
||||
it leaves ~18% headroom over the current worst observed file.
|
||||
- **Costs little on the target hardware.** The backend buffers cover bytes
|
||||
whole during fetch (`backend/internal/latest/cover.go`: `ContentLength >
|
||||
maxBodyBytes` rejection at :155, then `io.ReadAll(io.LimitReader(…,
|
||||
maxBodyBytes+1))` at :158) and loads the full body per `GET /covers/…`
|
||||
(`backend/internal/api/handlers.go`, `Cover` → `w.Write(body)`). Worst
|
||||
case per concurrent fetch **+** serve is therefore 2 × cap = 20 MiB; even
|
||||
ten of each concurrently is ~200 MiB of a 1974 MiB swapless VPS (~10%),
|
||||
and the browser unit (471 MiB, root AGENTS.md) is no longer on that box.
|
||||
The 4 MiB series-page cap is *not* the issue — measured pages run
|
||||
100 KB–1.2 MB (`backend/internal/latest/fetch.go` comment) — so keep it.
|
||||
- **The cap is a separate knob.** Today one `const maxBodyBytes = 4 << 20`
|
||||
(`backend/internal/latest/fetch.go:17`) gates *both* series pages and
|
||||
covers (`cover.go` references it). Raising it wholesale would loosen the
|
||||
page-side memory guard for no benefit; a cover-specific constant (e.g.
|
||||
`maxCoverBytes = 10 << 20`) keeps the two policies independent. The fetch
|
||||
already double-checks `ContentLength` and the post-`LimitReader` length,
|
||||
so a larger constant changes nothing else.
|
||||
|
||||
Alternatives and their costs:
|
||||
|
||||
| Option | Cost |
|
||||
|---|---|
|
||||
| Keep 4 MiB | 12% of asura covers (incl. non-GIF JPEGs) never stored — current bug, silent missing covers. |
|
||||
| 16 MiB cap | 2× headroom over the observed max for future GIFs; +60% worst-case transient memory vs 10 MiB; diverges from the GitHub/Discord 10 MB calibration. |
|
||||
| Server-side re-encode / downscale covers | Requires decoding → Go `image/gif` allocates width×height per frame with **no guard** (§4.1); a legal 65535² GIF forces a ~4.29 GB allocation on a 1974 MiB swapless box — OOM, not an error. Also mutates bytes, which the store treats as immutable/content-addressed (ADR-0007). Highest risk, no upside at this scale. |
|
||||
| No cap | Unbounded transient memory and disk; rejected outright. |
|
||||
|
||||
Decision is the user's; on the evidence, **10 MiB for covers, 4 MiB for
|
||||
pages** is the defensible middle.
|
||||
@@ -1,221 +0,0 @@
|
||||
{
|
||||
"0": "HTMX Library Internals",
|
||||
"1": "Cover Fetch Test Helpers",
|
||||
"2": "Manga Userscript Adapters",
|
||||
"3": "Novel Userscript Adapters",
|
||||
"4": "Series Acquisition Tests",
|
||||
"5": "Bookmarks API Tests",
|
||||
"6": "Storage Choice ADR",
|
||||
"7": "Cover & Acquire Internals",
|
||||
"8": "System Architecture Concepts",
|
||||
"9": "Session Middleware",
|
||||
"10": "Go Test Helpers",
|
||||
"11": "Store Tests",
|
||||
"12": "Bookmarks API Handler",
|
||||
"13": "Web UI Handlers",
|
||||
"14": "Go Error Handling",
|
||||
"15": "CDP Browser Client",
|
||||
"16": "Cloudflare bot scoring and poll cadence — what is actually documented",
|
||||
"17": "Go Code Style Guide",
|
||||
"18": "Agent Skills",
|
||||
"19": "Store",
|
||||
"20": "I/O Performance Patterns",
|
||||
"21": "CPU Optimization",
|
||||
"22": "Caching Patterns",
|
||||
"23": "Browser Entrypoint",
|
||||
"24": "Memory Allocation & GC",
|
||||
"25": "Cover Fetcher Tests",
|
||||
"26": "Open",
|
||||
"27": "Find Skills Guide",
|
||||
"28": "Allocation Patterns",
|
||||
"29": "Observability & Alerting",
|
||||
"30": "AGENTS.md",
|
||||
"31": "Store",
|
||||
"32": "Open",
|
||||
"33": "Go Testing Guide",
|
||||
"34": "Session Store",
|
||||
"35": "Web UI Filter Logic",
|
||||
"36": "Userscript Test Harness",
|
||||
"37": "Product & Security Context",
|
||||
"38": "novel-logic.test.js",
|
||||
"39": "UI Critique 2026-07-26A",
|
||||
"40": "UI Critique 2026-07-26B",
|
||||
"42": "pgtest.go",
|
||||
"43": "Issue Tracker & Triage",
|
||||
"44": "Ticket Workflow",
|
||||
"45": "Go Perf Alert Rules",
|
||||
"46": "Userscript Display Logic",
|
||||
"47": "Go Perf Skill Docs",
|
||||
"48": "Login Page Art",
|
||||
"49": "BookmarkManager Logo",
|
||||
"50": "Skills CLI",
|
||||
"51": "Skills Leaderboard",
|
||||
"52": "Complex Condition Extraction",
|
||||
"53": "Sentinel Errors",
|
||||
"54": "errors.As Patterns",
|
||||
"55": "errors.Is Patterns",
|
||||
"56": "errors.Join Patterns",
|
||||
"57": "Error Wrapping",
|
||||
"58": "Single Error Handling",
|
||||
"59": "SIMD Optimizations",
|
||||
"60": "GOGC Tuning",
|
||||
"61": "GOMEMLIMIT",
|
||||
"62": "Bottleneck Decision Tree",
|
||||
"63": "pprof Profiling",
|
||||
"64": "Test Timeout Helper",
|
||||
"65": "httptest Patterns",
|
||||
"66": "testify Suite Pattern",
|
||||
"67": "go:embed Fixtures",
|
||||
"68": "clockwork Time Mocking",
|
||||
"69": "testify Mocking",
|
||||
"70": "t.ArtifactDir Helper",
|
||||
"71": "Subtests Pitfall",
|
||||
"72": "golang-benchmark Skill",
|
||||
"73": "golang-concurrency Skill",
|
||||
"74": "golang-ci Skill",
|
||||
"75": "golang-database Skill",
|
||||
"76": "golang-lint Skill",
|
||||
"77": "testify Skill",
|
||||
"78": "Build Tag Integration Tests",
|
||||
"79": "Test Naming Convention",
|
||||
"80": "UI Critique A Finding",
|
||||
"81": "UI Critique B Finding",
|
||||
"82": "P0 Overflow Bug",
|
||||
"83": "P1 hx-indicator Gap",
|
||||
"84": "golang-benchmark Skill (ext)",
|
||||
"85": "golang-concurrency Skill (ext)",
|
||||
"86": "golang-ci Skill (ext)",
|
||||
"87": "golang-data-structures Skill (ext)",
|
||||
"88": "golang-database Skill (ext)",
|
||||
"89": "golang-design-patterns Skill (ext)",
|
||||
"90": "golang-documentation Skill (ext)",
|
||||
"91": "golang-gopls Skill (ext)",
|
||||
"92": "golang-lint Skill (ext)",
|
||||
"93": "golang-naming Skill (ext)",
|
||||
"94": "golang-observability Skill (ext)",
|
||||
"95": "golang-refactoring Skill (ext)",
|
||||
"96": "golang-safety Skill (ext)",
|
||||
"97": "golang-samber-oops Skill (ext)",
|
||||
"98": "golang-samber-slog Skill (ext)",
|
||||
"99": "golang-structs-interfaces Skill (ext)",
|
||||
"100": "golang-troubleshooting Skill (ext)",
|
||||
"101": "promql-cli Skill",
|
||||
"102": "Backend Module",
|
||||
"103": "bookmark-api Service",
|
||||
"104": "AGENTS.md",
|
||||
"105": "reviewer.md",
|
||||
"106": "Redeploy runbook",
|
||||
"107": "1. Backend",
|
||||
"108": "Deployment",
|
||||
"109": "Cinder — BookmarkManager design system",
|
||||
"110": "Implement tickets",
|
||||
"111": "SQLite → Postgres cutover runbook",
|
||||
"112": "Testing the userscript",
|
||||
"113": "ADR-0007: The backend hosts every Site's Cover bytes",
|
||||
"114": "Issue tracker: Gitea (`tea` CLI)",
|
||||
"115": "ADR-0006: The browser runs on the home machine, over the tailnet",
|
||||
"116": "ADR-0008: A Series identity is discovered from the Site's links, never derived from an address",
|
||||
"117": "Domain Docs",
|
||||
"118": "ticket-implementer.md",
|
||||
"119": "implementer.md",
|
||||
"120": "Series is a shared entity, and only the Poll may update it",
|
||||
"121": "Postgres replaces SQLite as the primary datastore",
|
||||
"122": "Identity comes from Discord OAuth; we store no passwords and send no email",
|
||||
"123": "The wire format stays flat and deliberately does not mirror the schema",
|
||||
"124": "ADR-0005: On-demand browser sidecar",
|
||||
"125": "sessions_test.go",
|
||||
"126": "Bookmark Manager",
|
||||
"127": "triage-labels.md",
|
||||
"128": "Cross-Ticket Contract",
|
||||
"129": "Implement Tickets Skill",
|
||||
"130": "Orchestrator Role",
|
||||
"131": "resolving-merge-conflicts Skill",
|
||||
"132": "tdd Skill",
|
||||
"133": "Ticket Wave Batching",
|
||||
"134": "Four-Object Browser Stub",
|
||||
"135": "Module Export Hook",
|
||||
"136": "logic.test.js Test Harness",
|
||||
"137": "manga-bookmark.user.js",
|
||||
"138": "stripBuildHash",
|
||||
"139": "Testing the Userscript Skill",
|
||||
"140": "cr-spec Agent",
|
||||
"141": "cr-standards Agent",
|
||||
"142": "Escalate Rather Than Guess",
|
||||
"143": "Status Contract",
|
||||
"144": "Ticket Implementer Agent",
|
||||
"145": "Worktree Isolation",
|
||||
"146": "Escalate Rather Than Guess (opencode)",
|
||||
"147": "Implementer Subagent (opencode)",
|
||||
"148": "Subagent-Driven Development",
|
||||
"149": "Code Quality Review",
|
||||
"150": "Reviewer Subagent (opencode)",
|
||||
"151": "Finding Severity Rubric",
|
||||
"152": "Spec Compliance Review",
|
||||
"156": "ResponseWriter",
|
||||
"161": "Why Use samber/oops",
|
||||
"162": "singleflight Cache Stampede Prevention",
|
||||
"163": "Struct Field Alignment",
|
||||
"164": "testing/synctest Deterministic Goroutine Testing",
|
||||
"177": "Backend CLAUDE.md Guidance",
|
||||
"178": "Graphify Knowledge Graph (graphify-out/)",
|
||||
"179": "CLAUDE.md (Symlink to AGENTS.md)",
|
||||
"192": "ADR-0001 (Drop modernc.org/sqlite)",
|
||||
"193": "ADR-0003 (Split Shared Series Facts)",
|
||||
"194": "SQLite-to-Postgres Cutover Runbook",
|
||||
"195": "Import SQL Generation Rules",
|
||||
"196": "Throwaway Import Generator",
|
||||
"206": "Real scaling limit is the poller outbound fetch budget",
|
||||
"207": "PostgreSQL (jackc/pgx/v5)",
|
||||
"208": "SQLite (modernc.org/sqlite)",
|
||||
"209": "Postgres chosen for future supportability, not concurrency",
|
||||
"210": "Per-Reader bearer token for userscripts",
|
||||
"211": "Discord OAuth2 (authorization code grant)",
|
||||
"212": "ADR-0002: Discord OAuth, no passwords, no email",
|
||||
"213": "Discord snowflake is the sole identity (lock-in)",
|
||||
"214": "Bookmark (per-Reader state: Progress, Favourite, Lifecycle)",
|
||||
"215": "Deduplicate polling per Series (reader_count DESC queue)",
|
||||
"216": "ADR-0003: Series is shared, only the Poll updates it",
|
||||
"217": "Only the Poll writes Series fields (security boundary)",
|
||||
"218": "Series (shared entity keyed site+series_id)",
|
||||
"219": "ADR-0004: Wire format stays flat, does not mirror schema",
|
||||
"220": "Flat wire shape is a contract, not an implementation detail",
|
||||
"221": "Installed userscripts must keep working (14-day grace window)",
|
||||
"222": "CDP (Chrome DevTools Protocol) endpoint",
|
||||
"223": "headless-shell service (socat-fronted CDP)",
|
||||
"224": "Start Chrome on first CDP connection, reap after 300s idle",
|
||||
"225": "BROWSER_WS_URL configuration seam",
|
||||
"226": "ADR-0006: Browser runs on the home machine over the tailnet",
|
||||
"227": "Browser moved home: VPS memory pressure, no requests served",
|
||||
"228": "Tailnet (Tailscale network)",
|
||||
"229": "Content-addressed filesystem storage (SHA-256 of source URL)",
|
||||
"230": "Cover (Series image bytes)",
|
||||
"231": "Deny-class destination control for outbound fetch",
|
||||
"232": "ADR-0007: Backend hosts every Site's Cover bytes",
|
||||
"233": "kagane CORP same-origin cover restriction",
|
||||
"234": "Backend acquires, stores, serves every Cover (uniformity)",
|
||||
"235": "a[aria-label='All Chapter'] anchor pointer",
|
||||
"236": "Series identity is discovered from the Site's links",
|
||||
"237": "ADR-0008: Series identity discovered, never derived",
|
||||
"238": "Chapter slug vs series slug divergence (~7% measured)",
|
||||
"239": "Scan truncated at first wpd-threads marker",
|
||||
"240": "Surface ADR conflicts explicitly rather than silently overriding",
|
||||
"241": "Domain docs: single-context layout guidance",
|
||||
"242": "/domain-modeling skill (lazy CONTEXT.md creation)",
|
||||
"243": "CONTEXT.md glossary (ubiquitous language)",
|
||||
"244": "Gitea (tea CLI, gitea.violetcrown.my.id)",
|
||||
"245": "wayfinder map/ticket mechanism",
|
||||
"246": "Triage labels: canonical roles to tracker labels",
|
||||
"247": "Canonical triage role labels (needs-triage ... wontfix)",
|
||||
"252": "a[aria-label='All Chapter'] priority pointer",
|
||||
"253": "Research: lightnovelworld chapter slug vs series slug",
|
||||
"254": "Gitea issue #77 (chapter vs series slug)",
|
||||
"255": "Slug divergence measurements (3/41 diverge, 1 split)",
|
||||
"256": "Unscoped chapter regex is SAFE, truncated at wpd-threads",
|
||||
"257": "BookmarkManager",
|
||||
"258": "Bromite (Primary Device)",
|
||||
"259": "Dark-First Design Constraint",
|
||||
"260": "Discord Guild Membership",
|
||||
"261": "Reader Isolation Invariant",
|
||||
"273": "AGENTS.md",
|
||||
"279": "Userscript CLAUDE guidance"
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
.
|
||||
@@ -1,566 +0,0 @@
|
||||
# Graph Report - mangaBookmark (2026-08-16)
|
||||
|
||||
## Corpus Check
|
||||
- 116 files · ~284,419 words
|
||||
- Verdict: corpus is large enough that graph structure adds value.
|
||||
|
||||
## Summary
|
||||
- 1701 nodes · 3463 edges · 219 communities (69 shown, 150 thin omitted)
|
||||
- Extraction: 91% EXTRACTED · 9% INFERRED · 0% AMBIGUOUS · INFERRED: 326 edges (avg confidence: 0.77)
|
||||
- Token cost: 0 input · 0 output
|
||||
|
||||
## Graph Freshness
|
||||
- Built from commit: `58014eb8`
|
||||
- Run `git rev-parse HEAD` and compare to check if the graph is stale.
|
||||
- Run `graphify update .` after code changes (no API cost).
|
||||
|
||||
## Community Hubs (Navigation)
|
||||
- [[_COMMUNITY_HTMX Library Internals|HTMX Library Internals]]
|
||||
- [[_COMMUNITY_Cover Fetch Test Helpers|Cover Fetch Test Helpers]]
|
||||
- [[_COMMUNITY_Manga Userscript Adapters|Manga Userscript Adapters]]
|
||||
- [[_COMMUNITY_Novel Userscript Adapters|Novel Userscript Adapters]]
|
||||
- [[_COMMUNITY_Series Acquisition Tests|Series Acquisition Tests]]
|
||||
- [[_COMMUNITY_Bookmarks API Tests|Bookmarks API Tests]]
|
||||
- [[_COMMUNITY_Storage Choice ADR|Storage Choice ADR]]
|
||||
- [[_COMMUNITY_Cover & Acquire Internals|Cover & Acquire Internals]]
|
||||
- [[_COMMUNITY_System Architecture Concepts|System Architecture Concepts]]
|
||||
- [[_COMMUNITY_Session Middleware|Session Middleware]]
|
||||
- [[_COMMUNITY_Go Test Helpers|Go Test Helpers]]
|
||||
- [[_COMMUNITY_Store Tests|Store Tests]]
|
||||
- [[_COMMUNITY_Bookmarks API Handler|Bookmarks API Handler]]
|
||||
- [[_COMMUNITY_Web UI Handlers|Web UI Handlers]]
|
||||
- [[_COMMUNITY_Go Error Handling|Go Error Handling]]
|
||||
- [[_COMMUNITY_CDP Browser Client|CDP Browser Client]]
|
||||
- [[_COMMUNITY_Cloudflare bot scoring and poll cadence — what is actually documented|Cloudflare bot scoring and poll cadence — what is actually documented]]
|
||||
- [[_COMMUNITY_Go Code Style Guide|Go Code Style Guide]]
|
||||
- [[_COMMUNITY_Agent Skills|Agent Skills]]
|
||||
- [[_COMMUNITY_Store|Store]]
|
||||
- [[_COMMUNITY_IO Performance Patterns|I/O Performance Patterns]]
|
||||
- [[_COMMUNITY_CPU Optimization|CPU Optimization]]
|
||||
- [[_COMMUNITY_Caching Patterns|Caching Patterns]]
|
||||
- [[_COMMUNITY_Browser Entrypoint|Browser Entrypoint]]
|
||||
- [[_COMMUNITY_Memory Allocation & GC|Memory Allocation & GC]]
|
||||
- [[_COMMUNITY_Cover Fetcher Tests|Cover Fetcher Tests]]
|
||||
- [[_COMMUNITY_Open|Open]]
|
||||
- [[_COMMUNITY_Find Skills Guide|Find Skills Guide]]
|
||||
- [[_COMMUNITY_Allocation Patterns|Allocation Patterns]]
|
||||
- [[_COMMUNITY_Observability & Alerting|Observability & Alerting]]
|
||||
- [[_COMMUNITY_AGENTS|AGENTS.md]]
|
||||
- [[_COMMUNITY_Store|Store]]
|
||||
- [[_COMMUNITY_Go Testing Guide|Go Testing Guide]]
|
||||
- [[_COMMUNITY_Session Store|Session Store]]
|
||||
- [[_COMMUNITY_Web UI Filter Logic|Web UI Filter Logic]]
|
||||
- [[_COMMUNITY_Userscript Test Harness|Userscript Test Harness]]
|
||||
- [[_COMMUNITY_Product & Security Context|Product & Security Context]]
|
||||
- [[_COMMUNITY_novel-logic.test.js|novel-logic.test.js]]
|
||||
- [[_COMMUNITY_UI Critique 2026-07-26A|UI Critique 2026-07-26A]]
|
||||
- [[_COMMUNITY_UI Critique 2026-07-26B|UI Critique 2026-07-26B]]
|
||||
- [[_COMMUNITY_pgtest.go|pgtest.go]]
|
||||
- [[_COMMUNITY_Issue Tracker & Triage|Issue Tracker & Triage]]
|
||||
- [[_COMMUNITY_Ticket Workflow|Ticket Workflow]]
|
||||
- [[_COMMUNITY_Go Perf Alert Rules|Go Perf Alert Rules]]
|
||||
- [[_COMMUNITY_Userscript Display Logic|Userscript Display Logic]]
|
||||
- [[_COMMUNITY_Go Perf Skill Docs|Go Perf Skill Docs]]
|
||||
- [[_COMMUNITY_Login Page Art|Login Page Art]]
|
||||
- [[_COMMUNITY_BookmarkManager Logo|BookmarkManager Logo]]
|
||||
- [[_COMMUNITY_Skills CLI|Skills CLI]]
|
||||
- [[_COMMUNITY_Skills Leaderboard|Skills Leaderboard]]
|
||||
- [[_COMMUNITY_Complex Condition Extraction|Complex Condition Extraction]]
|
||||
- [[_COMMUNITY_Sentinel Errors|Sentinel Errors]]
|
||||
- [[_COMMUNITY_errors.As Patterns|errors.As Patterns]]
|
||||
- [[_COMMUNITY_errors.Is Patterns|errors.Is Patterns]]
|
||||
- [[_COMMUNITY_errors.Join Patterns|errors.Join Patterns]]
|
||||
- [[_COMMUNITY_Error Wrapping|Error Wrapping]]
|
||||
- [[_COMMUNITY_Single Error Handling|Single Error Handling]]
|
||||
- [[_COMMUNITY_SIMD Optimizations|SIMD Optimizations]]
|
||||
- [[_COMMUNITY_GOGC Tuning|GOGC Tuning]]
|
||||
- [[_COMMUNITY_GOMEMLIMIT|GOMEMLIMIT]]
|
||||
- [[_COMMUNITY_Bottleneck Decision Tree|Bottleneck Decision Tree]]
|
||||
- [[_COMMUNITY_pprof Profiling|pprof Profiling]]
|
||||
- [[_COMMUNITY_Test Timeout Helper|Test Timeout Helper]]
|
||||
- [[_COMMUNITY_httptest Patterns|httptest Patterns]]
|
||||
- [[_COMMUNITY_testify Suite Pattern|testify Suite Pattern]]
|
||||
- [[_COMMUNITY_goembed Fixtures|go:embed Fixtures]]
|
||||
- [[_COMMUNITY_clockwork Time Mocking|clockwork Time Mocking]]
|
||||
- [[_COMMUNITY_testify Mocking|testify Mocking]]
|
||||
- [[_COMMUNITY_t.ArtifactDir Helper|t.ArtifactDir Helper]]
|
||||
- [[_COMMUNITY_Subtests Pitfall|Subtests Pitfall]]
|
||||
- [[_COMMUNITY_golang-benchmark Skill|golang-benchmark Skill]]
|
||||
- [[_COMMUNITY_golang-concurrency Skill|golang-concurrency Skill]]
|
||||
- [[_COMMUNITY_golang-ci Skill|golang-ci Skill]]
|
||||
- [[_COMMUNITY_golang-database Skill|golang-database Skill]]
|
||||
- [[_COMMUNITY_golang-lint Skill|golang-lint Skill]]
|
||||
- [[_COMMUNITY_testify Skill|testify Skill]]
|
||||
- [[_COMMUNITY_Build Tag Integration Tests|Build Tag Integration Tests]]
|
||||
- [[_COMMUNITY_Test Naming Convention|Test Naming Convention]]
|
||||
- [[_COMMUNITY_UI Critique A Finding|UI Critique A Finding]]
|
||||
- [[_COMMUNITY_UI Critique B Finding|UI Critique B Finding]]
|
||||
- [[_COMMUNITY_P0 Overflow Bug|P0 Overflow Bug]]
|
||||
- [[_COMMUNITY_P1 hx-indicator Gap|P1 hx-indicator Gap]]
|
||||
- [[_COMMUNITY_golang-benchmark Skill (ext)|golang-benchmark Skill (ext)]]
|
||||
- [[_COMMUNITY_golang-concurrency Skill (ext)|golang-concurrency Skill (ext)]]
|
||||
- [[_COMMUNITY_golang-ci Skill (ext)|golang-ci Skill (ext)]]
|
||||
- [[_COMMUNITY_golang-data-structures Skill (ext)|golang-data-structures Skill (ext)]]
|
||||
- [[_COMMUNITY_golang-database Skill (ext)|golang-database Skill (ext)]]
|
||||
- [[_COMMUNITY_golang-design-patterns Skill (ext)|golang-design-patterns Skill (ext)]]
|
||||
- [[_COMMUNITY_golang-documentation Skill (ext)|golang-documentation Skill (ext)]]
|
||||
- [[_COMMUNITY_golang-gopls Skill (ext)|golang-gopls Skill (ext)]]
|
||||
- [[_COMMUNITY_golang-lint Skill (ext)|golang-lint Skill (ext)]]
|
||||
- [[_COMMUNITY_golang-naming Skill (ext)|golang-naming Skill (ext)]]
|
||||
- [[_COMMUNITY_golang-observability Skill (ext)|golang-observability Skill (ext)]]
|
||||
- [[_COMMUNITY_golang-refactoring Skill (ext)|golang-refactoring Skill (ext)]]
|
||||
- [[_COMMUNITY_golang-safety Skill (ext)|golang-safety Skill (ext)]]
|
||||
- [[_COMMUNITY_golang-samber-oops Skill (ext)|golang-samber-oops Skill (ext)]]
|
||||
- [[_COMMUNITY_golang-samber-slog Skill (ext)|golang-samber-slog Skill (ext)]]
|
||||
- [[_COMMUNITY_golang-structs-interfaces Skill (ext)|golang-structs-interfaces Skill (ext)]]
|
||||
- [[_COMMUNITY_golang-troubleshooting Skill (ext)|golang-troubleshooting Skill (ext)]]
|
||||
- [[_COMMUNITY_promql-cli Skill|promql-cli Skill]]
|
||||
- [[_COMMUNITY_Backend Module|Backend Module]]
|
||||
- [[_COMMUNITY_bookmark-api Service|bookmark-api Service]]
|
||||
- [[_COMMUNITY_AGENTS|AGENTS.md]]
|
||||
- [[_COMMUNITY_reviewer|reviewer.md]]
|
||||
- [[_COMMUNITY_Redeploy runbook|Redeploy runbook]]
|
||||
- [[_COMMUNITY_1. Backend|1. Backend]]
|
||||
- [[_COMMUNITY_Deployment|Deployment]]
|
||||
- [[_COMMUNITY_Cinder — BookmarkManager design system|Cinder — BookmarkManager design system]]
|
||||
- [[_COMMUNITY_Implement tickets|Implement tickets]]
|
||||
- [[_COMMUNITY_SQLite → Postgres cutover runbook|SQLite → Postgres cutover runbook]]
|
||||
- [[_COMMUNITY_Testing the userscript|Testing the userscript]]
|
||||
- [[_COMMUNITY_ADR-0007 The backend hosts every Site's Cover bytes|ADR-0007: The backend hosts every Site's Cover bytes]]
|
||||
- [[_COMMUNITY_Issue tracker Gitea (`tea` CLI)|Issue tracker: Gitea (`tea` CLI)]]
|
||||
- [[_COMMUNITY_ADR-0006 The browser runs on the home machine, over the tailnet|ADR-0006: The browser runs on the home machine, over the tailnet]]
|
||||
- [[_COMMUNITY_ADR-0008 A Series identity is discovered from the Site's links, never derived from an address|ADR-0008: A Series identity is discovered from the Site's links, never derived from an address]]
|
||||
- [[_COMMUNITY_Domain Docs|Domain Docs]]
|
||||
- [[_COMMUNITY_ticket-implementer|ticket-implementer.md]]
|
||||
- [[_COMMUNITY_implementer|implementer.md]]
|
||||
- [[_COMMUNITY_Series is a shared entity, and only the Poll may update it|Series is a shared entity, and only the Poll may update it]]
|
||||
- [[_COMMUNITY_Postgres replaces SQLite as the primary datastore|Postgres replaces SQLite as the primary datastore]]
|
||||
- [[_COMMUNITY_Identity comes from Discord OAuth; we store no passwords and send no email|Identity comes from Discord OAuth; we store no passwords and send no email]]
|
||||
- [[_COMMUNITY_The wire format stays flat and deliberately does not mirror the schema|The wire format stays flat and deliberately does not mirror the schema]]
|
||||
- [[_COMMUNITY_ADR-0005 On-demand browser sidecar|ADR-0005: On-demand browser sidecar]]
|
||||
- [[_COMMUNITY_sessions_test.go|sessions_test.go]]
|
||||
- [[_COMMUNITY_Bookmark Manager|Bookmark Manager]]
|
||||
- [[_COMMUNITY_triage-labels|triage-labels.md]]
|
||||
- [[_COMMUNITY_Cross-Ticket Contract|Cross-Ticket Contract]]
|
||||
- [[_COMMUNITY_Implement Tickets Skill|Implement Tickets Skill]]
|
||||
- [[_COMMUNITY_Orchestrator Role|Orchestrator Role]]
|
||||
- [[_COMMUNITY_resolving-merge-conflicts Skill|resolving-merge-conflicts Skill]]
|
||||
- [[_COMMUNITY_tdd Skill|tdd Skill]]
|
||||
- [[_COMMUNITY_Ticket Wave Batching|Ticket Wave Batching]]
|
||||
- [[_COMMUNITY_Four-Object Browser Stub|Four-Object Browser Stub]]
|
||||
- [[_COMMUNITY_Module Export Hook|Module Export Hook]]
|
||||
- [[_COMMUNITY_logic.test.js Test Harness|logic.test.js Test Harness]]
|
||||
- [[_COMMUNITY_manga-bookmark.user.js|manga-bookmark.user.js]]
|
||||
- [[_COMMUNITY_stripBuildHash|stripBuildHash]]
|
||||
- [[_COMMUNITY_Testing the Userscript Skill|Testing the Userscript Skill]]
|
||||
- [[_COMMUNITY_cr-spec Agent|cr-spec Agent]]
|
||||
- [[_COMMUNITY_cr-standards Agent|cr-standards Agent]]
|
||||
- [[_COMMUNITY_Escalate Rather Than Guess|Escalate Rather Than Guess]]
|
||||
- [[_COMMUNITY_Status Contract|Status Contract]]
|
||||
- [[_COMMUNITY_Ticket Implementer Agent|Ticket Implementer Agent]]
|
||||
- [[_COMMUNITY_Worktree Isolation|Worktree Isolation]]
|
||||
- [[_COMMUNITY_Escalate Rather Than Guess (opencode)|Escalate Rather Than Guess (opencode)]]
|
||||
- [[_COMMUNITY_Implementer Subagent (opencode)|Implementer Subagent (opencode)]]
|
||||
- [[_COMMUNITY_Subagent-Driven Development|Subagent-Driven Development]]
|
||||
- [[_COMMUNITY_Code Quality Review|Code Quality Review]]
|
||||
- [[_COMMUNITY_Reviewer Subagent (opencode)|Reviewer Subagent (opencode)]]
|
||||
- [[_COMMUNITY_Finding Severity Rubric|Finding Severity Rubric]]
|
||||
- [[_COMMUNITY_Spec Compliance Review|Spec Compliance Review]]
|
||||
- [[_COMMUNITY_ResponseWriter|ResponseWriter]]
|
||||
- [[_COMMUNITY_Why Use samberoops|Why Use samber/oops]]
|
||||
- [[_COMMUNITY_singleflight Cache Stampede Prevention|singleflight Cache Stampede Prevention]]
|
||||
- [[_COMMUNITY_Struct Field Alignment|Struct Field Alignment]]
|
||||
- [[_COMMUNITY_testingsynctest Deterministic Goroutine Testing|testing/synctest Deterministic Goroutine Testing]]
|
||||
- [[_COMMUNITY_Backend CLAUDE.md Guidance|Backend CLAUDE.md Guidance]]
|
||||
- [[_COMMUNITY_Graphify Knowledge Graph (graphify-out)|Graphify Knowledge Graph (graphify-out/)]]
|
||||
- [[_COMMUNITY_CLAUDE.md (Symlink to AGENTS.md)|CLAUDE.md (Symlink to AGENTS.md)]]
|
||||
- [[_COMMUNITY_ADR-0001 (Drop modernc.orgsqlite)|ADR-0001 (Drop modernc.org/sqlite)]]
|
||||
- [[_COMMUNITY_ADR-0003 (Split Shared Series Facts)|ADR-0003 (Split Shared Series Facts)]]
|
||||
- [[_COMMUNITY_SQLite-to-Postgres Cutover Runbook|SQLite-to-Postgres Cutover Runbook]]
|
||||
- [[_COMMUNITY_Import SQL Generation Rules|Import SQL Generation Rules]]
|
||||
- [[_COMMUNITY_Throwaway Import Generator|Throwaway Import Generator]]
|
||||
- [[_COMMUNITY_Real scaling limit is the poller outbound fetch budget|Real scaling limit is the poller outbound fetch budget]]
|
||||
- [[_COMMUNITY_PostgreSQL (jackcpgxv5)|PostgreSQL (jackc/pgx/v5)]]
|
||||
- [[_COMMUNITY_SQLite (modernc.orgsqlite)|SQLite (modernc.org/sqlite)]]
|
||||
- [[_COMMUNITY_Postgres chosen for future supportability, not concurrency|Postgres chosen for future supportability, not concurrency]]
|
||||
- [[_COMMUNITY_Per-Reader bearer token for userscripts|Per-Reader bearer token for userscripts]]
|
||||
- [[_COMMUNITY_Discord OAuth2 (authorization code grant)|Discord OAuth2 (authorization code grant)]]
|
||||
- [[_COMMUNITY_ADR-0002 Discord OAuth, no passwords, no email|ADR-0002: Discord OAuth, no passwords, no email]]
|
||||
- [[_COMMUNITY_Discord snowflake is the sole identity (lock-in)|Discord snowflake is the sole identity (lock-in)]]
|
||||
- [[_COMMUNITY_Bookmark (per-Reader state Progress, Favourite, Lifecycle)|Bookmark (per-Reader state: Progress, Favourite, Lifecycle)]]
|
||||
- [[_COMMUNITY_Deduplicate polling per Series (reader_count DESC queue)|Deduplicate polling per Series (reader_count DESC queue)]]
|
||||
- [[_COMMUNITY_ADR-0003 Series is shared, only the Poll updates it|ADR-0003: Series is shared, only the Poll updates it]]
|
||||
- [[_COMMUNITY_Only the Poll writes Series fields (security boundary)|Only the Poll writes Series fields (security boundary)]]
|
||||
- [[_COMMUNITY_Series (shared entity keyed site+series_id)|Series (shared entity keyed site+series_id)]]
|
||||
- [[_COMMUNITY_ADR-0004 Wire format stays flat, does not mirror schema|ADR-0004: Wire format stays flat, does not mirror schema]]
|
||||
- [[_COMMUNITY_Flat wire shape is a contract, not an implementation detail|Flat wire shape is a contract, not an implementation detail]]
|
||||
- [[_COMMUNITY_Installed userscripts must keep working (14-day grace window)|Installed userscripts must keep working (14-day grace window)]]
|
||||
- [[_COMMUNITY_CDP (Chrome DevTools Protocol) endpoint|CDP (Chrome DevTools Protocol) endpoint]]
|
||||
- [[_COMMUNITY_headless-shell service (socat-fronted CDP)|headless-shell service (socat-fronted CDP)]]
|
||||
- [[_COMMUNITY_Start Chrome on first CDP connection, reap after 300s idle|Start Chrome on first CDP connection, reap after 300s idle]]
|
||||
- [[_COMMUNITY_BROWSER_WS_URL configuration seam|BROWSER_WS_URL configuration seam]]
|
||||
- [[_COMMUNITY_ADR-0006 Browser runs on the home machine over the tailnet|ADR-0006: Browser runs on the home machine over the tailnet]]
|
||||
- [[_COMMUNITY_Browser moved home VPS memory pressure, no requests served|Browser moved home: VPS memory pressure, no requests served]]
|
||||
- [[_COMMUNITY_Tailnet (Tailscale network)|Tailnet (Tailscale network)]]
|
||||
- [[_COMMUNITY_Content-addressed filesystem storage (SHA-256 of source URL)|Content-addressed filesystem storage (SHA-256 of source URL)]]
|
||||
- [[_COMMUNITY_Cover (Series image bytes)|Cover (Series image bytes)]]
|
||||
- [[_COMMUNITY_Deny-class destination control for outbound fetch|Deny-class destination control for outbound fetch]]
|
||||
- [[_COMMUNITY_ADR-0007 Backend hosts every Site's Cover bytes|ADR-0007: Backend hosts every Site's Cover bytes]]
|
||||
- [[_COMMUNITY_kagane CORP same-origin cover restriction|kagane CORP same-origin cover restriction]]
|
||||
- [[_COMMUNITY_Backend acquires, stores, serves every Cover (uniformity)|Backend acquires, stores, serves every Cover (uniformity)]]
|
||||
- [[_COMMUNITY_aaria-label='All Chapter' anchor pointer|a[aria-label='All Chapter'] anchor pointer]]
|
||||
- [[_COMMUNITY_Series identity is discovered from the Site's links|Series identity is discovered from the Site's links]]
|
||||
- [[_COMMUNITY_ADR-0008 Series identity discovered, never derived|ADR-0008: Series identity discovered, never derived]]
|
||||
- [[_COMMUNITY_Chapter slug vs series slug divergence (~7% measured)|Chapter slug vs series slug divergence (~7% measured)]]
|
||||
- [[_COMMUNITY_Scan truncated at first wpd-threads marker|Scan truncated at first wpd-threads marker]]
|
||||
- [[_COMMUNITY_Surface ADR conflicts explicitly rather than silently overriding|Surface ADR conflicts explicitly rather than silently overriding]]
|
||||
- [[_COMMUNITY_Domain docs single-context layout guidance|Domain docs: single-context layout guidance]]
|
||||
- [[_COMMUNITY_domain-modeling skill (lazy CONTEXT.md creation)|/domain-modeling skill (lazy CONTEXT.md creation)]]
|
||||
- [[_COMMUNITY_CONTEXT.md glossary (ubiquitous language)|CONTEXT.md glossary (ubiquitous language)]]
|
||||
- [[_COMMUNITY_Gitea (tea CLI, gitea.violetcrown.my.id)|Gitea (tea CLI, gitea.violetcrown.my.id)]]
|
||||
- [[_COMMUNITY_wayfinder mapticket mechanism|wayfinder map/ticket mechanism]]
|
||||
- [[_COMMUNITY_Triage labels canonical roles to tracker labels|Triage labels: canonical roles to tracker labels]]
|
||||
- [[_COMMUNITY_Canonical triage role labels (needs-triage ... wontfix)|Canonical triage role labels (needs-triage ... wontfix)]]
|
||||
- [[_COMMUNITY_aaria-label='All Chapter' priority pointer|a[aria-label='All Chapter'] priority pointer]]
|
||||
- [[_COMMUNITY_Research lightnovelworld chapter slug vs series slug|Research: lightnovelworld chapter slug vs series slug]]
|
||||
- [[_COMMUNITY_Gitea issue 77 (chapter vs series slug)|Gitea issue #77 (chapter vs series slug)]]
|
||||
- [[_COMMUNITY_Slug divergence measurements (341 diverge, 1 split)|Slug divergence measurements (3/41 diverge, 1 split)]]
|
||||
- [[_COMMUNITY_Unscoped chapter regex is SAFE, truncated at wpd-threads|Unscoped chapter regex is SAFE, truncated at wpd-threads]]
|
||||
- [[_COMMUNITY_BookmarkManager|BookmarkManager]]
|
||||
- [[_COMMUNITY_Bromite (Primary Device)|Bromite (Primary Device)]]
|
||||
- [[_COMMUNITY_Dark-First Design Constraint|Dark-First Design Constraint]]
|
||||
- [[_COMMUNITY_Discord Guild Membership|Discord Guild Membership]]
|
||||
- [[_COMMUNITY_Reader Isolation Invariant|Reader Isolation Invariant]]
|
||||
- [[_COMMUNITY_AGENTS|AGENTS.md]]
|
||||
- [[_COMMUNITY_Userscript CLAUDE guidance|Userscript CLAUDE guidance]]
|
||||
|
||||
## God Nodes (most connected - your core abstractions)
|
||||
1. `testConfig()` - 54 edges
|
||||
2. `newTestStore()` - 49 edges
|
||||
3. `newWebTestServer()` - 49 edges
|
||||
4. `newTestStore()` - 44 edges
|
||||
5. `e()` - 33 edges
|
||||
6. `Open()` - 30 edges
|
||||
7. `Handler` - 29 edges
|
||||
8. `Store` - 28 edges
|
||||
9. `ne()` - 28 edges
|
||||
10. `De()` - 28 edges
|
||||
|
||||
## Surprising Connections (you probably didn't know these)
|
||||
- `el()` --indirect_call--> `c()` [INFERRED]
|
||||
userscript/manga-bookmark.user.js → backend/internal/web/static/htmx.min.js
|
||||
- `el()` --indirect_call--> `c()` [INFERRED]
|
||||
userscript/novel-bookmark.user.js → backend/internal/web/static/htmx.min.js
|
||||
- `latestChapterFromAnchors()` --indirect_call--> `re()` [INFERRED]
|
||||
userscript/manga-bookmark.user.js → backend/internal/web/static/htmx.min.js
|
||||
- `el()` --indirect_call--> `k()` [INFERRED]
|
||||
userscript/manga-bookmark.user.js → backend/internal/web/static/htmx.min.js
|
||||
- `el()` --indirect_call--> `k()` [INFERRED]
|
||||
userscript/novel-bookmark.user.js → backend/internal/web/static/htmx.min.js
|
||||
|
||||
## Import Cycles
|
||||
- None detected.
|
||||
|
||||
## Hyperedges (group relationships)
|
||||
- **Batch Ticket Implementation Pipeline** — _claude_skills_implement_tickets_skill_implement_tickets, _omp_agents_ticket_implementer_ticket_implementer, _omp_agents_ticket_implementer_cr_spec, _omp_agents_ticket_implementer_cr_standards [INFERRED 0.85]
|
||||
- **Subagent-Driven Development Pipeline** — _opencode_agent_implementer_implementer, _opencode_agent_reviewer_reviewer, _opencode_agent_implementer_subagent_driven_development [INFERRED 0.85]
|
||||
- **Backend owns the truth (single-writer ownership of shared facts)** — docs_adr_0003_series_shared_and_poll_owned_poll_owned_writes, docs_adr_0004_wire_format_does_not_mirror_the_schema_flat_wire_contract, docs_adr_0007_backend_hosts_cover_bytes_server_side_covers [INFERRED 0.85]
|
||||
- **Headless browser infrastructure (sidecar, on-demand, home deployment)** — docs_adr_0005_on_demand_browser_headless_shell, docs_adr_0005_on_demand_browser_cdp, docs_adr_0005_on_demand_browser_on_demand_start, docs_adr_0006_browser_on_the_home_machine_home_machine_rationale [INFERRED 0.85]
|
||||
- **lightnovelworld series-identity investigation and fix** — docs_research_lightnovelworld_chapter_vs_series_slug_issue_77, docs_research_lightnovelworld_chapter_vs_series_slug_unscoped_regex, docs_adr_0008_series_identity_is_discovered_not_derived_discovered_identity [INFERRED 0.85]
|
||||
|
||||
## Communities (219 total, 150 thin omitted)
|
||||
|
||||
### Community 0 - "HTMX Library Internals"
|
||||
Cohesion: 0.08
|
||||
Nodes (101): A(), ae(), an(), at(), B(), be(), bn(), bt() (+93 more)
|
||||
|
||||
### Community 1 - "Cover Fetch Test Helpers"
|
||||
Cohesion: 0.09
|
||||
Nodes (89): floatPtr(), testConfig(), getCover(), Cookie, Handler, ResponseRecorder, T, TestListRendersAcquiredCover() (+81 more)
|
||||
|
||||
### Community 2 - "Manga Userscript Adapters"
|
||||
Cohesion: 0.06
|
||||
Nodes (76): adapterFor(), anchorsFromDocument(), anchorsFromHTML(), apiDelete(), apiGet(), apiPut(), applyFabPos(), applyLatestChapterIfChanged() (+68 more)
|
||||
|
||||
### Community 3 - "Novel Userscript Adapters"
|
||||
Cohesion: 0.06
|
||||
Nodes (79): adapterFor(), anchorsFromDocument(), anchorsFromHTML(), apiDelete(), apiGet(), apiPut(), applyFabPos(), applyLatestChapterIfChanged() (+71 more)
|
||||
|
||||
### Community 4 - "Series Acquisition Tests"
|
||||
Cohesion: 0.08
|
||||
Nodes (78): bookmarkNewKaganeSeries(), bookmarkNewNovelfullSeries(), bookmarkNewSeries(), Context, Store, T, newAcquirer(), readBookmark() (+70 more)
|
||||
|
||||
### Community 5 - "Bookmarks API Tests"
|
||||
Cohesion: 0.07
|
||||
Nodes (70): auth(), getBookmarks(), Handler, Request, Store, T, newTestServer(), newTestStore() (+62 more)
|
||||
|
||||
### Community 7 - "Cover & Acquire Internals"
|
||||
Cohesion: 0.07
|
||||
Nodes (43): Addr, fakeLanes, Context, Store, WaitGroup, defaultCoverResolver(), fetchCoverBytes(), Client (+35 more)
|
||||
|
||||
### Community 8 - "System Architecture Concepts"
|
||||
Cohesion: 0.15
|
||||
Nodes (15): Confirm Row (Archive/Finish/Remove), card.html — Series Card Template, htmx /ui/* Mutation Endpoints, chrome.html — Out-of-Band Regions, Action Key, Brand Mark SVG, Continue Reading Strip, icons.html — Icon Sprite Template (+7 more)
|
||||
|
||||
### Community 9 - "Session Middleware"
|
||||
Cohesion: 0.08
|
||||
Nodes (35): ClearCookie(), ClientIP(), Duration, Mutex, Request, ResponseWriter, Time, isHTTPS() (+27 more)
|
||||
|
||||
### Community 10 - "Go Test Helpers"
|
||||
Cohesion: 0.05
|
||||
Nodes (39): Test Helpers, Test Timeout, Basic Handler Test, HTTP Handler Testing, Query Parameters and Headers, Docker Compose Fixture, Integration Testing, SQL Schema Fixture (+31 more)
|
||||
|
||||
### Community 11 - "Store Tests"
|
||||
Cohesion: 0.14
|
||||
Nodes (45): Store, T, newTestStore(), readLatestCheckedAt(), readSeries(), secondReader(), seedForCheck(), seedSecondReader() (+37 more)
|
||||
|
||||
### Community 12 - "Bookmarks API Handler"
|
||||
Cohesion: 0.08
|
||||
Nodes (32): Handler, Request, ResponseWriter, Store, Healthz(), writeJSON(), Auth(), compressible() (+24 more)
|
||||
|
||||
### Community 13 - "Web UI Handlers"
|
||||
Cohesion: 0.14
|
||||
Nodes (18): currentLib(), currentTab(), filterBookmarks(), Client, HandlerFunc, Request, ResponseWriter, Store (+10 more)
|
||||
|
||||
### Community 14 - "Go Error Handling"
|
||||
Cohesion: 0.06
|
||||
Nodes (33): Creating Errors, Custom Error Types, Custom types that wrap other errors, Decision table: which error strategy to use, Error Creation, Error String Conventions, Errors as Values, `errors.New` — static error messages (+25 more)
|
||||
|
||||
### Community 15 - "CDP Browser Client"
|
||||
Cohesion: 0.20
|
||||
Nodes (17): applyMigration(), migrate(), Open(), refreshOwnerToken(), seedOwner(), TestCoverIsContentAddressedOnFilesystem(), TestCoverPersistsAcrossReopen(), TestMigration0002BackfillsExistingBookmarks() (+9 more)
|
||||
|
||||
### Community 16 - "Cloudflare bot scoring and poll cadence — what is actually documented"
|
||||
Cohesion: 0.06
|
||||
Nodes (33): 1.1 The score itself, 1.2 The detection engines (Enterprise Bot Management), 1.3 Rate limiting is a separate product, 1. What a bot score is and what feeds it, 2.1 What each plan gets, 2.2 Bot Fight Mode specifics (the Free-plan product), 2.3 Does the free tier "score" continuously?, 2. The free-plan reality (+25 more)
|
||||
|
||||
### Community 17 - "Go Code Style Guide"
|
||||
Cohesion: 0.08
|
||||
Nodes (23): Code Style Details, Extract Complex Conditions, Value vs Pointer Arguments, Code Organization Within Files, Complex Conditions & Init Scope, Composite Literals, Control Flow, Cross-References (+15 more)
|
||||
|
||||
### Community 19 - "Store"
|
||||
Cohesion: 0.33
|
||||
Nodes (5): ADR-0010: Poll Lanes — one independent Poll stream per Site, Constraints preserved, Decision, Tradeoffs and rejections, Why
|
||||
|
||||
### Community 20 - "I/O Performance Patterns"
|
||||
Cohesion: 0.11
|
||||
Nodes (18): Avoid io.ReadAll for large payloads, Batch Operations, Buffered I/O, Cgo Overhead, Channel: batch processing from a stream, Concurrent Multi-Stage Pipelines, Connection pooling, Database: batch inserts over row-by-row (+10 more)
|
||||
|
||||
### Community 21 - "CPU Optimization"
|
||||
Cohesion: 0.13
|
||||
Nodes (15): Cache Locality, Contiguous 2D allocation, CPU Optimization, False Sharing, Function Inlining, Handling CPU-specific instruction sets, Instruction-Level Parallelism, Monotonic Time (+7 more)
|
||||
|
||||
### Community 22 - "Caching Patterns"
|
||||
Cohesion: 0.13
|
||||
Nodes (14): Algorithmic Complexity, Avoid iterator chains, Caching Patterns, Compiled Pattern Caching, Early returns and short-circuit loops, LRU caches, Map lookups over slice scanning, Precomputed lookup tables (+6 more)
|
||||
|
||||
### Community 23 - "Browser Entrypoint"
|
||||
Cohesion: 0.35
|
||||
Nodes (14): browser_alive(), connection(), connection_signal(), finish_connection(), has_connections(), lock(), reaper(), entrypoint.sh script (+6 more)
|
||||
|
||||
### Community 24 - "Memory Allocation & GC"
|
||||
Cohesion: 0.13
|
||||
Nodes (15): Allocation Rate Reduction, Ballast pattern (pre-Go 1.19), Garbage Collector Tuning, GC pacing, GC Profiling and Diagnostics, GODEBUG=gctrace=1, GOGC (default: 100), GOMAXPROCS in Containers (+7 more)
|
||||
|
||||
### Community 25 - "Cover Fetcher Tests"
|
||||
Cohesion: 0.33
|
||||
Nodes (12): coverResponse(), Request, T, TestCoverFetcherCanonicalisesJpgAlias(), TestCoverFetcherFetchesPublicHTTPSImage(), TestCoverFetcherRefusesUnsafeDestinationsBeforeRequest(), TestCoverFetcherRejectsNonImage(), TestCoverFetcherRejectsOversizedBody() (+4 more)
|
||||
|
||||
### Community 26 - "Open"
|
||||
Cohesion: 0.05
|
||||
Nodes (62): awaitPromise(), browserConnectionLost(), classifyBrowserError(), comixRead(), comixSeriesPageURL(), Action, Context, Mutex (+54 more)
|
||||
|
||||
### Community 27 - "Find Skills Guide"
|
||||
Cohesion: 0.14
|
||||
Nodes (13): Common Skill Categories, Find Skills, How to Help Users Find Skills, Step 1: Understand What They Need, Step 2: Check the Leaderboard First, Step 3: Search for Skills, Step 4: Verify Quality Before Recommending, Step 5: Present Options to the User (+5 more)
|
||||
|
||||
### Community 28 - "Allocation Patterns"
|
||||
Cohesion: 0.14
|
||||
Nodes (14): Allocation Patterns, Backing Array Leaks, Direct indexing vs append, Eliminate redundant map lookups, Interface boxing, Map never shrinks, Map size hints, Memory Optimization (+6 more)
|
||||
|
||||
### Community 29 - "Observability & Alerting"
|
||||
Cohesion: 0.22
|
||||
Nodes (9): Alerting rules (examples), CPU saturation, GC pressure, Goroutine leaks, Grafana Dashboards, Memory leaks, Prometheus Metrics for Go, PromQL Queries for Performance Diagnosis (+1 more)
|
||||
|
||||
### Community 30 - "AGENTS.md"
|
||||
Cohesion: 0.40
|
||||
Nodes (5): Map of pointers for large, frequently updated structs, Memory Layout, Pointer receivers for large structs, Struct field alignment, Zero-size field at end of struct
|
||||
|
||||
### Community 31 - "Store"
|
||||
Cohesion: 0.08
|
||||
Nodes (8): coverRelativePath(), coverSourceAddress(), displayChapter(), Store, scanSeries(), TestDisplayChapter(), Bookmark, ReaderSummary
|
||||
|
||||
### Community 33 - "Go Testing Guide"
|
||||
Cohesion: 0.20
|
||||
Nodes (10): CI Regression Detection, Common Mistakes, Core Philosophy, Cross-References, Decision Tree: Where Is Time Spent?, Deep Dives, Go Performance Optimization, Iterative Optimization Methodology (+2 more)
|
||||
|
||||
### Community 34 - "Session Store"
|
||||
Cohesion: 0.28
|
||||
Nodes (4): Duration, Store, Time, Session
|
||||
|
||||
### Community 35 - "Web UI Filter Logic"
|
||||
Cohesion: 0.31
|
||||
Nodes (5): closeCardPanels(), setActiveTab(), toggleChapterForm(), toggleConfirmRow(), togglePanel()
|
||||
|
||||
### Community 37 - "Product & Security Context"
|
||||
Cohesion: 0.17
|
||||
Nodes (11): Accessibility & Inclusion, Brand Commitments, Capabilities and Constraints, Evidence on Hand, Operating Context, Platform, Positioning, Product (+3 more)
|
||||
|
||||
### Community 38 - "novel-logic.test.js"
|
||||
Cohesion: 0.33
|
||||
Nodes (5): ADR-0009: A Site answers fixed questions; an unusual Site owns its own fetch, Consequences, Considered options, Decision, Why
|
||||
|
||||
### Community 39 - "UI Critique 2026-07-26A"
|
||||
Cohesion: 0.29
|
||||
Nodes (6): Design Health Score, Design Specificity Verdict, Minor Observations, Persona Red Flags, Priority Issues, Questions to Consider
|
||||
|
||||
### Community 40 - "UI Critique 2026-07-26B"
|
||||
Cohesion: 0.29
|
||||
Nodes (6): Design Health Score, Design Specificity Verdict, Minor Observations, Persona Red Flags, Priority Issues, Questions to Consider
|
||||
|
||||
### Community 42 - "pgtest.go"
|
||||
Cohesion: 0.18
|
||||
Nodes (12): M, TestMain(), M, TestMain(), M, Main(), start(), URL() (+4 more)
|
||||
|
||||
### Community 45 - "Go Perf Alert Rules"
|
||||
Cohesion: 0.50
|
||||
Nodes (4): Prometheus Alerting Rules (Go Performance), GoroutineLeak Alert, HighGCPauseTime Alert, MemoryNearLimit Alert
|
||||
|
||||
### Community 46 - "Userscript Display Logic"
|
||||
Cohesion: 0.07
|
||||
Nodes (27): 1. Summary answer table, 2. The two reference pages, 3. Chapter page → series URL: every in-page pointer, in priority order, 4.1 Sample method, 4.2 Divergence results, 4.3 Is there a derivable rule? **No.**, 4.4 The split case — a slug can change *mid-series*, 4. The reverse direction, and how common divergence is (+19 more)
|
||||
|
||||
### Community 47 - "Go Perf Skill Docs"
|
||||
Cohesion: 0.22
|
||||
Nodes (5): Continuous Profiling, Production Observability for Performance, Pyroscope pull mode (via Grafana Alloy), Pyroscope push mode, Real-Time Visualization (Development)
|
||||
|
||||
### Community 48 - "Login Page Art"
|
||||
Cohesion: 0.67
|
||||
Nodes (3): Fantasy Sword, Fiery Volcanic Scene, Login Art: Sword in Volcanic Rock
|
||||
|
||||
### Community 49 - "BookmarkManager Logo"
|
||||
Cohesion: 1.00
|
||||
Nodes (3): Mirrored Double Bookmark Mark, Ember Flame Accent, BookmarkManager Logo
|
||||
|
||||
### Community 103 - "bookmark-api Service"
|
||||
Cohesion: 0.32
|
||||
Nodes (8): Browser Sidecar Service, CDP Endpoint (Tailnet-Bound :9222), Persistent Chrome Profile Volume, chrome/docker-compose.yml — Browser Deployable Unit, bookmark-api Prod Override, CDP Never on Shared Proxy Network, docker-compose.prod.yml — Production Override, Traefik Reverse Proxy Labels
|
||||
|
||||
### Community 104 - "AGENTS.md"
|
||||
Cohesion: 0.12
|
||||
Nodes (14): Agent skills, Architecture, Commands, Comments, Design system, Domain docs, Forge: Gitea, not GitHub, graphify (+6 more)
|
||||
|
||||
### Community 105 - "reviewer.md"
|
||||
Cohesion: 0.12
|
||||
Nodes (15): Assessment, Calibration, Critical (Must Fix), Do Not Trust the Report, Important (Should Fix), Inputs, Issues, Method (+7 more)
|
||||
|
||||
### Community 106 - "Redeploy runbook"
|
||||
Cohesion: 0.12
|
||||
Nodes (15): 0. Preflight, 1. Back up the database, 2. Pull the new code, 3. Rebuild and restart, 4. Verify the deploy, 5. Smoke-test the full loop, 6. Rollback, 7. The whole thing, as one block (+7 more)
|
||||
|
||||
### Community 107 - "1. Backend"
|
||||
Cohesion: 0.13
|
||||
Nodes (14): 1. Backend, 2. Userscript, Adapter reference (verified live 2026-07-24), Config (env), Deploy behind your reverse proxy, Desktop iteration (optional), Develop / test, Endpoints (+6 more)
|
||||
|
||||
### Community 108 - "Deployment"
|
||||
Cohesion: 0.14
|
||||
Nodes (13): 0. Prerequisites, 1. Configure `.env`, 1b. Web UI, 2. Build + start, 3. Verify over HTTPS, 4. Configure the userscript, 5. Install on Bromite, 6. Smoke-test the full loop (+5 more)
|
||||
|
||||
### Community 109 - "Cinder — BookmarkManager design system"
|
||||
Cohesion: 0.20
|
||||
Nodes (9): 1. The one idea, 2. Tokens, 3. Type, 4. Components (web UI), 5. Components (userscript panel), 6. Motion, 7. Accessibility floor (not negotiable), 8. Adding something new — checklist (+1 more)
|
||||
|
||||
### Community 110 - "Implement tickets"
|
||||
Cohesion: 0.22
|
||||
Nodes (8): 1. Collect the tickets, 2. Plan the batch, 3. Get the plan approved, 4. Run a wave, 5. Land the wave, 6. Close the batch, Implement tickets, Ticket #<n> — <title>
|
||||
|
||||
### Community 111 - "SQLite → Postgres cutover runbook"
|
||||
Cohesion: 0.22
|
||||
Nodes (8): 0. The generator is throwaway, 1. Stop the old API and take a fresh export, 2. Bring up Postgres with the schema and the owner Reader, 3. Generate the import SQL, 4. Review it by eye, 5. Apply it, 6. Afterwards, SQLite → Postgres cutover runbook
|
||||
|
||||
### Community 112 - "Testing the userscript"
|
||||
Cohesion: 0.29
|
||||
Nodes (6): Adding a test, Commands, Gotchas, How the harness works, Testing the userscript, What is NOT testable here
|
||||
|
||||
### Community 113 - "ADR-0007: The backend hosts every Site's Cover bytes"
|
||||
Cohesion: 0.29
|
||||
Nodes (6): ADR-0007: The backend hosts every Site's Cover bytes, Consequences, Considered options, Decision, Two deliberate relaxations, Why a future reader will find this surprising
|
||||
|
||||
### Community 114 - "Issue tracker: Gitea (`tea` CLI)"
|
||||
Cohesion: 0.29
|
||||
Nodes (6): Conventions, Issue tracker: Gitea (`tea` CLI), Pull requests as a triage surface, Wayfinding operations, When a skill says "fetch the relevant ticket", When a skill says "publish to the issue tracker"
|
||||
|
||||
### Community 115 - "ADR-0006: The browser runs on the home machine, over the tailnet"
|
||||
Cohesion: 0.33
|
||||
Nodes (5): ADR-0006: The browser runs on the home machine, over the tailnet, Consequences, Constraints, Decision, Why
|
||||
|
||||
### Community 116 - "ADR-0008: A Series identity is discovered from the Site's links, never derived from an address"
|
||||
Cohesion: 0.33
|
||||
Nodes (5): ADR-0008: A Series identity is discovered from the Site's links, never derived from an address, Consequences, Considered options, Decision, Why
|
||||
|
||||
### Community 117 - "Domain Docs"
|
||||
Cohesion: 0.33
|
||||
Nodes (5): Before exploring, read these, Domain Docs, File structure, Flag ADR conflicts, Use the glossary's vocabulary
|
||||
|
||||
### Community 118 - "ticket-implementer.md"
|
||||
Cohesion: 0.33
|
||||
Nodes (5): Escalate rather than guess, Order of work, Report, Review, The worktree is your whole world
|
||||
|
||||
### Community 119 - "implementer.md"
|
||||
Cohesion: 0.33
|
||||
Nodes (5): Before You Begin, Report Format, Self-Review Before Reporting, When You're in Over Your Head, Your Job
|
||||
|
||||
### Community 120 - "Series is a shared entity, and only the Poll may update it"
|
||||
Cohesion: 0.40
|
||||
Nodes (4): Consequences, Only the Poll writes Series fields, Series is a shared entity, and only the Poll may update it, Why
|
||||
|
||||
### Community 121 - "Postgres replaces SQLite as the primary datastore"
|
||||
Cohesion: 0.50
|
||||
Nodes (3): Consequences, Considered options, Postgres replaces SQLite as the primary datastore
|
||||
|
||||
### Community 122 - "Identity comes from Discord OAuth; we store no passwords and send no email"
|
||||
Cohesion: 0.50
|
||||
Nodes (3): Consequences, Considered options, Identity comes from Discord OAuth; we store no passwords and send no email
|
||||
|
||||
### Community 123 - "The wire format stays flat and deliberately does not mirror the schema"
|
||||
Cohesion: 0.50
|
||||
Nodes (3): Consequence, The wire format stays flat and deliberately does not mirror the schema, Why a future reader will find this surprising
|
||||
|
||||
### Community 124 - "ADR-0005: On-demand browser sidecar"
|
||||
Cohesion: 0.50
|
||||
Nodes (3): ADR-0005: On-demand browser sidecar, Constraints, Decision
|
||||
|
||||
### Community 125 - "sessions_test.go"
|
||||
Cohesion: 0.48
|
||||
Nodes (6): T, TestCreateAndGetSession(), TestDeleteSessionIsPerReader(), TestDeleteSessionRevokes(), TestExpiredSessionIsGone(), TestGetSessionUnknownID()
|
||||
|
||||
### Community 156 - "ResponseWriter"
|
||||
Cohesion: 0.18
|
||||
Nodes (13): AdminPatterns(), HandlerFunc, Request, ResponseWriter, Time, Handler, readerPathID(), since() (+5 more)
|
||||
|
||||
### Community 273 - "AGENTS.md"
|
||||
Cohesion: 0.50
|
||||
Nodes (3): Live URL shapes (verified 2026-07-26, may drift — re-check against live pages before trust), Second script: `novel-bookmark.user.js`, Userscript structure (single IIFE, `manga-bookmark.user.js`)
|
||||
|
||||
## Knowledge Gaps
|
||||
- **520 isolated node(s):** `bookmarkmanager/backend`, `ctxKey`, `loginView`, `ctxKey`, `test` (+515 more)
|
||||
These have ≤1 connection - possible missing edges or undocumented components.
|
||||
- **150 thin communities (<3 nodes) omitted from report** — run `graphify query` to explore isolated nodes.
|
||||
|
||||
## Suggested Questions
|
||||
_Questions this graph is uniquely positioned to answer:_
|
||||
|
||||
- **Why does `Open()` connect `CDP Browser Client` to `Cover Fetch Test Helpers`, `Series Acquisition Tests`, `Bookmarks API Tests`, `pgtest.go`, `Store Tests`, `Store`?**
|
||||
_High betweenness centrality (0.050) - this node is a cross-community bridge._
|
||||
- **Why does `New()` connect `Series Acquisition Tests` to `Bookmarks API Tests`, `Cover & Acquire Internals`, `Session Middleware`, `Web UI Handlers`, `CDP Browser Client`, `ResponseWriter`?**
|
||||
_High betweenness centrality (0.048) - this node is a cross-community bridge._
|
||||
- **Why does `newRouter()` connect `Bookmarks API Tests` to `Cover Fetch Test Helpers`, `Bookmarks API Handler`, `Series Acquisition Tests`, `ResponseWriter`?**
|
||||
_High betweenness centrality (0.031) - this node is a cross-community bridge._
|
||||
- **Are the 48 inferred relationships involving `testConfig()` (e.g. with `TestListRendersAcquiredCover()` and `TestPublicCoverNeverEchoesNonImage()`) actually correct?**
|
||||
_`testConfig()` has 48 INFERRED edges - model-reasoned connections that need verification._
|
||||
- **Are the 12 inferred relationships involving `newTestStore()` (e.g. with `TestAcquireDoesNotBlockTheWrite()` and `TestAcquireFailureLeavesTheBookmarkIntact()`) actually correct?**
|
||||
_`newTestStore()` has 12 INFERRED edges - model-reasoned connections that need verification._
|
||||
- **Are the 8 inferred relationships involving `newWebTestServer()` (e.g. with `TestListRendersAcquiredCover()` and `TestPublicCoverRejectsUnknownAddress()`) actually correct?**
|
||||
_`newWebTestServer()` has 8 INFERRED edges - model-reasoned connections that need verification._
|
||||
- **What connects `bookmarkmanager/backend`, `ctxKey`, `loginView` to the rest of the system?**
|
||||
_552 weakly-connected nodes found - possible documentation gaps or missing edges._
|
||||
File diff suppressed because one or more lines are too long
-54523
File diff suppressed because it is too large
Load Diff
@@ -1,662 +0,0 @@
|
||||
{
|
||||
".agents/skills/golang-code-style/evals/evals.json": {
|
||||
"mtime": 1784884678.6627614,
|
||||
"ast_hash": "bec0e12446e7af3cd05de9b6d42badd8",
|
||||
"semantic_hash": "bec0e12446e7af3cd05de9b6d42badd8"
|
||||
},
|
||||
".agents/skills/golang-error-handling/evals/evals.json": {
|
||||
"mtime": 1784884678.6655047,
|
||||
"ast_hash": "275d710b774fba1e1d0bc098d3646c6d",
|
||||
"semantic_hash": "275d710b774fba1e1d0bc098d3646c6d"
|
||||
},
|
||||
".agents/skills/golang-performance/evals/evals.json": {
|
||||
"mtime": 1784884678.6688662,
|
||||
"ast_hash": "4f06df87f90aa0e4f6deaa318b47689f",
|
||||
"semantic_hash": "4f06df87f90aa0e4f6deaa318b47689f"
|
||||
},
|
||||
".agents/skills/golang-testing/evals/evals.json": {
|
||||
"mtime": 1784884678.6721346,
|
||||
"ast_hash": "60a821bbfd20c6fe8bba996b8b540dd4",
|
||||
"semantic_hash": "60a821bbfd20c6fe8bba996b8b540dd4"
|
||||
},
|
||||
"backend/go.mod": {
|
||||
"mtime": 1786216141.668644,
|
||||
"ast_hash": "dac242903b0e98c3e4395159d609e08e",
|
||||
"semantic_hash": "dac242903b0e98c3e4395159d609e08e"
|
||||
},
|
||||
"backend/main.go": {
|
||||
"mtime": 1786863963.3450089,
|
||||
"ast_hash": "d5a50b03438d7c120079d40cc578462b",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"skills-lock.json": {
|
||||
"mtime": 1784884678.6842625,
|
||||
"ast_hash": "4a94ac85bad6bce330d085bcc0ae3ffd",
|
||||
"semantic_hash": "4a94ac85bad6bce330d085bcc0ae3ffd"
|
||||
},
|
||||
"userscript/manga-bookmark.user.js": {
|
||||
"mtime": 1786499529.779988,
|
||||
"ast_hash": "1f8bcddd3632d709f058a8401af8f127",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
".agents/skills/find-skills/SKILL.md": {
|
||||
"mtime": 1784884338.760326,
|
||||
"ast_hash": "62b297abdee9aea84c577ab2e04e1974",
|
||||
"semantic_hash": "62b297abdee9aea84c577ab2e04e1974"
|
||||
},
|
||||
".agents/skills/golang-code-style/SKILL.md": {
|
||||
"mtime": 1784884678.6623824,
|
||||
"ast_hash": "d6a01e6f64550a5c8d59dac2e948000e",
|
||||
"semantic_hash": "d6a01e6f64550a5c8d59dac2e948000e"
|
||||
},
|
||||
".agents/skills/golang-code-style/references/details.md": {
|
||||
"mtime": 1784884678.6627865,
|
||||
"ast_hash": "19891e396a986f1b24bf34b7a2854fcb",
|
||||
"semantic_hash": "19891e396a986f1b24bf34b7a2854fcb"
|
||||
},
|
||||
".agents/skills/golang-error-handling/SKILL.md": {
|
||||
"mtime": 1784884678.665052,
|
||||
"ast_hash": "8b7970f472adb240e5bc4bde863d43a6",
|
||||
"semantic_hash": "8b7970f472adb240e5bc4bde863d43a6"
|
||||
},
|
||||
".agents/skills/golang-error-handling/references/error-creation.md": {
|
||||
"mtime": 1784884678.665524,
|
||||
"ast_hash": "248dbf75492c68faef2334b8d83bd080",
|
||||
"semantic_hash": "248dbf75492c68faef2334b8d83bd080"
|
||||
},
|
||||
".agents/skills/golang-error-handling/references/error-handling.md": {
|
||||
"mtime": 1784884678.6655412,
|
||||
"ast_hash": "c2424ee3999b05963b199e0100727aa3",
|
||||
"semantic_hash": "c2424ee3999b05963b199e0100727aa3"
|
||||
},
|
||||
".agents/skills/golang-error-handling/references/error-wrapping.md": {
|
||||
"mtime": 1784884678.6655717,
|
||||
"ast_hash": "4a15d9266a1ea0c8bb1951e6b9c0f586",
|
||||
"semantic_hash": "4a15d9266a1ea0c8bb1951e6b9c0f586"
|
||||
},
|
||||
".agents/skills/golang-performance/SKILL.md": {
|
||||
"mtime": 1784884678.667833,
|
||||
"ast_hash": "35a15fd129c5bedaada3fd4df0b6ba8c",
|
||||
"semantic_hash": "35a15fd129c5bedaada3fd4df0b6ba8c"
|
||||
},
|
||||
".agents/skills/golang-performance/assets/prometheus-alerts.yml": {
|
||||
"mtime": 1784884678.668527,
|
||||
"ast_hash": "fa9357ffa87c4f894fc21afa9707c4db",
|
||||
"semantic_hash": "fa9357ffa87c4f894fc21afa9707c4db"
|
||||
},
|
||||
".agents/skills/golang-performance/references/caching.md": {
|
||||
"mtime": 1784884678.6690567,
|
||||
"ast_hash": "807c42a82994e5548dbf7eb6e30c71e0",
|
||||
"semantic_hash": "807c42a82994e5548dbf7eb6e30c71e0"
|
||||
},
|
||||
".agents/skills/golang-performance/references/cpu.md": {
|
||||
"mtime": 1784884678.669087,
|
||||
"ast_hash": "6d52e532ef51a35cc4134694c944517d",
|
||||
"semantic_hash": "6d52e532ef51a35cc4134694c944517d"
|
||||
},
|
||||
".agents/skills/golang-performance/references/io-networking.md": {
|
||||
"mtime": 1784884678.6691036,
|
||||
"ast_hash": "95c5dd51f728fd69c945a92ff021d766",
|
||||
"semantic_hash": "95c5dd51f728fd69c945a92ff021d766"
|
||||
},
|
||||
".agents/skills/golang-performance/references/memory.md": {
|
||||
"mtime": 1784884678.6691158,
|
||||
"ast_hash": "3b2108df06b4cfb3980fa80bbd9ebcff",
|
||||
"semantic_hash": "3b2108df06b4cfb3980fa80bbd9ebcff"
|
||||
},
|
||||
".agents/skills/golang-performance/references/observability.md": {
|
||||
"mtime": 1784884678.6691446,
|
||||
"ast_hash": "0aa8a498e8d55ccdd4990ad187bae828",
|
||||
"semantic_hash": "0aa8a498e8d55ccdd4990ad187bae828"
|
||||
},
|
||||
".agents/skills/golang-performance/references/runtime.md": {
|
||||
"mtime": 1784884678.669426,
|
||||
"ast_hash": "26386c33b3a3794aaef0556713bf8f3a",
|
||||
"semantic_hash": "26386c33b3a3794aaef0556713bf8f3a"
|
||||
},
|
||||
".agents/skills/golang-testing/SKILL.md": {
|
||||
"mtime": 1784884678.6716368,
|
||||
"ast_hash": "0a9b9793bba2a239db94e980272a393e",
|
||||
"semantic_hash": "0a9b9793bba2a239db94e980272a393e"
|
||||
},
|
||||
".agents/skills/golang-testing/references/helpers.md": {
|
||||
"mtime": 1784884678.6722167,
|
||||
"ast_hash": "0aecb7cfbeb9b374bf61c52e324d9d3f",
|
||||
"semantic_hash": "0aecb7cfbeb9b374bf61c52e324d9d3f"
|
||||
},
|
||||
".agents/skills/golang-testing/references/http-testing.md": {
|
||||
"mtime": 1784884678.67225,
|
||||
"ast_hash": "9111110c28a7fbbffc3537aad786b390",
|
||||
"semantic_hash": "9111110c28a7fbbffc3537aad786b390"
|
||||
},
|
||||
".agents/skills/golang-testing/references/integration-testing.md": {
|
||||
"mtime": 1784884678.67225,
|
||||
"ast_hash": "fcf9861bc36ae56e3fb7a1c18aa0e77f",
|
||||
"semantic_hash": "fcf9861bc36ae56e3fb7a1c18aa0e77f"
|
||||
},
|
||||
".agents/skills/golang-testing/references/mocking.md": {
|
||||
"mtime": 1784884678.6722653,
|
||||
"ast_hash": "3a08979e4603aae5c32a58d5b6c39765",
|
||||
"semantic_hash": "3a08979e4603aae5c32a58d5b6c39765"
|
||||
},
|
||||
"CLAUDE.md": {
|
||||
"mtime": 1786857336.6414917,
|
||||
"ast_hash": "c79e49f912d7852f9832565a5f9a1c39",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"DEPLOY.md": {
|
||||
"mtime": 1786861012.026504,
|
||||
"ast_hash": "b7c2f813aded562ee9291c9baed795ad",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"README.md": {
|
||||
"mtime": 1786861012.026504,
|
||||
"ast_hash": "81af12a0a2d43e791efd030b5f4d6cbc",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docker-compose.prod.yml": {
|
||||
"mtime": 1786292465.8305523,
|
||||
"ast_hash": "0751998a532297b8ac507a01ec48dc31",
|
||||
"semantic_hash": "0751998a532297b8ac507a01ec48dc31"
|
||||
},
|
||||
"docker-compose.yml": {
|
||||
"mtime": 1786861012.026504,
|
||||
"ast_hash": "d3b53a8f42a8e0acb4fc4306ea42c093",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
".claude/settings.json": {
|
||||
"mtime": 1784951973.1869545,
|
||||
"ast_hash": "e51077b6a7f1f67afc748f1a32a1557d",
|
||||
"semantic_hash": "e51077b6a7f1f67afc748f1a32a1557d"
|
||||
},
|
||||
"backend/web_test.go": {
|
||||
"mtime": 1786865248.7717106,
|
||||
"ast_hash": "7c298e39c63e4502cf6272d1e206e9ef",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"backend/main_test.go": {
|
||||
"mtime": 1786863966.7091317,
|
||||
"ast_hash": "0a5d4dbdc770b40c329ccccc899b59f4",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
".claude/settings.local.json": {
|
||||
"mtime": 1785697645.350201,
|
||||
"ast_hash": "9a1ac6369f968e8df4be9dcff0948f70",
|
||||
"semantic_hash": "9a1ac6369f968e8df4be9dcff0948f70"
|
||||
},
|
||||
"PRODUCT.md": {
|
||||
"mtime": 1786216141.660644,
|
||||
"ast_hash": "c52072d1978286060087fa0686f9c7f9",
|
||||
"semantic_hash": "c52072d1978286060087fa0686f9c7f9"
|
||||
},
|
||||
"backend/.impeccable/critique/2026-07-26T15-50-42Z__backend-templates-app-html.md": {
|
||||
"mtime": 1785128576.2412457,
|
||||
"ast_hash": "d08627c27f22d453125db6c1ab4b71ec",
|
||||
"semantic_hash": "d08627c27f22d453125db6c1ab4b71ec"
|
||||
},
|
||||
"backend/.impeccable/critique/2026-07-26T17-08-41Z__backend-templates-app-html.md": {
|
||||
"mtime": 1785128576.2452524,
|
||||
"ast_hash": "e69a8340a371579ca3ea689660f7d7bd",
|
||||
"semantic_hash": "e69a8340a371579ca3ea689660f7d7bd"
|
||||
},
|
||||
"AGENTS.md": {
|
||||
"mtime": 1786857336.6414917,
|
||||
"ast_hash": "c79e49f912d7852f9832565a5f9a1c39",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"userscript/test/logic.test.js": {
|
||||
"mtime": 1786499529.779988,
|
||||
"ast_hash": "80d512bfe6fa8b847f3ba6169c321a74",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
".claude/skills/testing-the-userscript/SKILL.md": {
|
||||
"mtime": 1786363889.5489495,
|
||||
"ast_hash": "8f3c0132eb4787a2c8736eb99f7689af",
|
||||
"semantic_hash": "8f3c0132eb4787a2c8736eb99f7689af"
|
||||
},
|
||||
"REDEPLOY.md": {
|
||||
"mtime": 1786857336.6414917,
|
||||
"ast_hash": "e5e910f0244a040e2ccdc669b17eb4db",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/design-system.md": {
|
||||
"mtime": 1786865139.9022946,
|
||||
"ast_hash": "3bd39932997c8e245508874194db4e49",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"backend/api_test.go": {
|
||||
"mtime": 1786863966.7056258,
|
||||
"ast_hash": "0d443b4fe3c04ab057505d73b8a88d07",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"backend/cover_test.go": {
|
||||
"mtime": 1786863966.7084978,
|
||||
"ast_hash": "fd6b3f6ef46bd17b0241d104b6e5bdf6",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"backend/internal/api/handlers.go": {
|
||||
"mtime": 1786363889.552731,
|
||||
"ast_hash": "59e6b8767ab19839bb8f82891a7e4616",
|
||||
"semantic_hash": "59e6b8767ab19839bb8f82891a7e4616"
|
||||
},
|
||||
"backend/internal/httpmw/middleware.go": {
|
||||
"mtime": 1786216141.672644,
|
||||
"ast_hash": "385b36f58488b7e6d93eb6d6034e9ee3",
|
||||
"semantic_hash": "385b36f58488b7e6d93eb6d6034e9ee3"
|
||||
},
|
||||
"backend/internal/latest/browser.go": {
|
||||
"mtime": 1786861012.026504,
|
||||
"ast_hash": "75f34a974568d122681939dd297944a2",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"backend/internal/latest/browser_test.go": {
|
||||
"mtime": 1786857336.6414917,
|
||||
"ast_hash": "800fa6aa471ada054a3c48943ad17ab7",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"backend/internal/latest/fetch.go": {
|
||||
"mtime": 1786499529.7688599,
|
||||
"ast_hash": "3e20ad86aa46783e9aa95c2b746551ee",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"backend/internal/latest/poller.go": {
|
||||
"mtime": 1786865046.096795,
|
||||
"ast_hash": "2bfb51177e3df474b80ea1b05523377e",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"backend/internal/latest/poller_test.go": {
|
||||
"mtime": 1786865291.3758123,
|
||||
"ast_hash": "1da669ac748a8d1a0289e557392fa3fd",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"backend/internal/latest/sites.go": {
|
||||
"mtime": 1786861012.026504,
|
||||
"ast_hash": "9b90f9b710ccbd0d0a0a6a0dd721ff3c",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"backend/internal/latest/sites_test.go": {
|
||||
"mtime": 1786857336.6414917,
|
||||
"ast_hash": "0db2028a6073f342fee61ad15c2e5f0f",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"backend/internal/latest/smoke_image_test.go": {
|
||||
"mtime": 1786363889.5602942,
|
||||
"ast_hash": "db068cb59575f8c82669acbaf84bcaed",
|
||||
"semantic_hash": "db068cb59575f8c82669acbaf84bcaed"
|
||||
},
|
||||
"backend/internal/pgtest/pgtest.go": {
|
||||
"mtime": 1786216141.6766438,
|
||||
"ast_hash": "f60372d41516e66f7aaeb272da227d6e",
|
||||
"semantic_hash": "f60372d41516e66f7aaeb272da227d6e"
|
||||
},
|
||||
"backend/internal/session/session.go": {
|
||||
"mtime": 1786216141.6766438,
|
||||
"ast_hash": "9952474ffb22c825d6f075b866ee26f4",
|
||||
"semantic_hash": "9952474ffb22c825d6f075b866ee26f4"
|
||||
},
|
||||
"backend/internal/session/session_test.go": {
|
||||
"mtime": 1786216141.680644,
|
||||
"ast_hash": "37ffd00964e7a67350c68ed50c6503c5",
|
||||
"semantic_hash": "37ffd00964e7a67350c68ed50c6503c5"
|
||||
},
|
||||
"backend/internal/store/migrations/0001_bookmarks.sql": {
|
||||
"mtime": 1786216141.680644,
|
||||
"ast_hash": "f87ccfb2c25c43f93021177ced0bfae4",
|
||||
"semantic_hash": "f87ccfb2c25c43f93021177ced0bfae4"
|
||||
},
|
||||
"backend/internal/store/migrations/0002_series.sql": {
|
||||
"mtime": 1786216141.6820722,
|
||||
"ast_hash": "5dc98771e0c0f6e8416b434c280b0efb",
|
||||
"semantic_hash": "5dc98771e0c0f6e8416b434c280b0efb"
|
||||
},
|
||||
"backend/internal/store/migrations/0003_reader.sql": {
|
||||
"mtime": 1786216141.6820722,
|
||||
"ast_hash": "444a97799f38f6222f87d4e9fb2d6258",
|
||||
"semantic_hash": "444a97799f38f6222f87d4e9fb2d6258"
|
||||
},
|
||||
"backend/internal/store/migrations/0004_owner_bookmarks.sql": {
|
||||
"mtime": 1786216141.684644,
|
||||
"ast_hash": "e4fa900cc223865d3ecd4c60c5707a65",
|
||||
"semantic_hash": "e4fa900cc223865d3ecd4c60c5707a65"
|
||||
},
|
||||
"backend/internal/store/migrations/0005_sessions.sql": {
|
||||
"mtime": 1786216141.684644,
|
||||
"ast_hash": "5158887ebc57cf8c16a7b821b61cd760",
|
||||
"semantic_hash": "5158887ebc57cf8c16a7b821b61cd760"
|
||||
},
|
||||
"backend/internal/store/migrations/0006_reader_token_epoch.sql": {
|
||||
"mtime": 1786216141.684644,
|
||||
"ast_hash": "3093cc1c3aae0cd9643d04105d045402",
|
||||
"semantic_hash": "3093cc1c3aae0cd9643d04105d045402"
|
||||
},
|
||||
"backend/internal/store/sessions.go": {
|
||||
"mtime": 1786216141.684644,
|
||||
"ast_hash": "eee3510cc6172ef4b1da820474c26b01",
|
||||
"semantic_hash": "eee3510cc6172ef4b1da820474c26b01"
|
||||
},
|
||||
"backend/internal/store/sessions_test.go": {
|
||||
"mtime": 1786216141.684644,
|
||||
"ast_hash": "0b6764a0ee20f5cb7748eecd31a1d220",
|
||||
"semantic_hash": "0b6764a0ee20f5cb7748eecd31a1d220"
|
||||
},
|
||||
"backend/internal/store/store.go": {
|
||||
"mtime": 1786863704.5112119,
|
||||
"ast_hash": "989629af18232e35b12f1eef2a8cb422",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"backend/internal/store/store_test.go": {
|
||||
"mtime": 1786863712.075631,
|
||||
"ast_hash": "e48f21e34238ba46874cc88cf96e022f",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"backend/internal/userscript/userscript.go": {
|
||||
"mtime": 1786216141.692644,
|
||||
"ast_hash": "aa13a71b1c9eefe4930fd31f27722328",
|
||||
"semantic_hash": "aa13a71b1c9eefe4930fd31f27722328"
|
||||
},
|
||||
"backend/internal/userscript/userscript_test.go": {
|
||||
"mtime": 1786216141.692644,
|
||||
"ast_hash": "6c050968d7b8b67956da1a3136d2c3c7",
|
||||
"semantic_hash": "6c050968d7b8b67956da1a3136d2c3c7"
|
||||
},
|
||||
"backend/internal/web/discord.go": {
|
||||
"mtime": 1786216141.692644,
|
||||
"ast_hash": "69e4959c65fa67d7491aedf6a71bb575",
|
||||
"semantic_hash": "69e4959c65fa67d7491aedf6a71bb575"
|
||||
},
|
||||
"backend/internal/web/oauth_test.go": {
|
||||
"mtime": 1786216141.692644,
|
||||
"ast_hash": "a3bddeb70dd8d7eb14139da808723ea8",
|
||||
"semantic_hash": "a3bddeb70dd8d7eb14139da808723ea8"
|
||||
},
|
||||
"backend/internal/web/static/filter.js": {
|
||||
"mtime": 1786022513.9473197,
|
||||
"ast_hash": "b4ee3306201bfd88b148b96801972617",
|
||||
"semantic_hash": "b4ee3306201bfd88b148b96801972617"
|
||||
},
|
||||
"backend/internal/web/static/htmx.min.js": {
|
||||
"mtime": 1785873769.5512016,
|
||||
"ast_hash": "19a573773be4ca22570ca2f8543120c5",
|
||||
"semantic_hash": "19a573773be4ca22570ca2f8543120c5"
|
||||
},
|
||||
"backend/internal/web/web.go": {
|
||||
"mtime": 1786863744.1453943,
|
||||
"ast_hash": "b051f2de214c4b253ee09fbbdb8ebb60",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"backend/reader_credential_test.go": {
|
||||
"mtime": 1786863966.7092986,
|
||||
"ast_hash": "0c93de387af595901c43b6bdb3bed8cc",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"chrome/entrypoint.sh": {
|
||||
"mtime": 1786363889.5678573,
|
||||
"ast_hash": "8008a187690764436540fab47ba0cfcc",
|
||||
"semantic_hash": "8008a187690764436540fab47ba0cfcc"
|
||||
},
|
||||
"userscript/novel-bookmark.user.js": {
|
||||
"mtime": 1786499529.779988,
|
||||
"ast_hash": "834effb0821f8d6c9f57f6554a5db462",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"userscript/test/novel-logic.test.js": {
|
||||
"mtime": 1786499529.779988,
|
||||
"ast_hash": "b25a7377af210dd0aff8284501fd0252",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
".opencode/agent/implementer.md": {
|
||||
"mtime": 1785873769.5402634,
|
||||
"ast_hash": "000de469c18d68352027e10c8ce8acfb",
|
||||
"semantic_hash": "000de469c18d68352027e10c8ce8acfb"
|
||||
},
|
||||
".opencode/agent/reviewer.md": {
|
||||
"mtime": 1785873769.5416775,
|
||||
"ast_hash": "e44a2f6f624db044e19508bc5ab05592",
|
||||
"semantic_hash": "e44a2f6f624db044e19508bc5ab05592"
|
||||
},
|
||||
"CONTEXT.md": {
|
||||
"mtime": 1786861012.022683,
|
||||
"ast_hash": "4aafbce0b046e6e34734fb414df818ce",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"CUTOVER.md": {
|
||||
"mtime": 1786216141.660644,
|
||||
"ast_hash": "6c6f3e4c4c2f57867894280bce728c50",
|
||||
"semantic_hash": "6c6f3e4c4c2f57867894280bce728c50"
|
||||
},
|
||||
"backend/AGENTS.md": {
|
||||
"mtime": 1786865319.314815,
|
||||
"ast_hash": "5bbabcd7dd425302cb0d989d8897baf6",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"backend/CLAUDE.md": {
|
||||
"mtime": 1786865319.314815,
|
||||
"ast_hash": "5bbabcd7dd425302cb0d989d8897baf6",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"backend/internal/web/templates/app.html": {
|
||||
"mtime": 1786864445.1584811,
|
||||
"ast_hash": "99d3e1139042d0eb61627155dddb3843",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"backend/internal/web/templates/card.html": {
|
||||
"mtime": 1786363889.5678573,
|
||||
"ast_hash": "ab83ae0dbb34fd40c146a7cc1263173e",
|
||||
"semantic_hash": "ab83ae0dbb34fd40c146a7cc1263173e"
|
||||
},
|
||||
"backend/internal/web/templates/chrome.html": {
|
||||
"mtime": 1786363889.5678573,
|
||||
"ast_hash": "d80b27cf3bd9d131075c485dd169dfcc",
|
||||
"semantic_hash": "d80b27cf3bd9d131075c485dd169dfcc"
|
||||
},
|
||||
"backend/internal/web/templates/icons.html": {
|
||||
"mtime": 1785873769.553139,
|
||||
"ast_hash": "8e10c507c32934a92463b4bca9e34fe6",
|
||||
"semantic_hash": "8e10c507c32934a92463b4bca9e34fe6"
|
||||
},
|
||||
"backend/internal/web/templates/list.html": {
|
||||
"mtime": 1786216141.696644,
|
||||
"ast_hash": "365548aace8c06559a1f66db0ae47256",
|
||||
"semantic_hash": "365548aace8c06559a1f66db0ae47256"
|
||||
},
|
||||
"backend/internal/web/templates/login.html": {
|
||||
"mtime": 1786216141.696644,
|
||||
"ast_hash": "bcc3101498a66cf8b79f9d97c6c9cd6b",
|
||||
"semantic_hash": "bcc3101498a66cf8b79f9d97c6c9cd6b"
|
||||
},
|
||||
"backend/internal/web/templates/readers.html": {
|
||||
"mtime": 1786864296.770521,
|
||||
"ast_hash": "2b1cbb24d6185e48561966db1af04ba4",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"backend/internal/web/templates/setup.html": {
|
||||
"mtime": 1786216141.696644,
|
||||
"ast_hash": "72e93c0b827414063596f7338987d879",
|
||||
"semantic_hash": "72e93c0b827414063596f7338987d879"
|
||||
},
|
||||
"docs/adr/0001-postgresql-over-sqlite.md": {
|
||||
"mtime": 1786216141.704644,
|
||||
"ast_hash": "abfb08754cee58be67311377904f8ca4",
|
||||
"semantic_hash": "abfb08754cee58be67311377904f8ca4"
|
||||
},
|
||||
"docs/adr/0002-discord-oauth-no-passwords-no-email.md": {
|
||||
"mtime": 1786216141.7071996,
|
||||
"ast_hash": "852a04d86659385085da6ffc8b489933",
|
||||
"semantic_hash": "852a04d86659385085da6ffc8b489933"
|
||||
},
|
||||
"docs/adr/0003-series-shared-and-poll-owned.md": {
|
||||
"mtime": 1786501942.7367291,
|
||||
"ast_hash": "6138b113340693e0cc667d1ecbb75f72",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/adr/0004-wire-format-does-not-mirror-the-schema.md": {
|
||||
"mtime": 1786216141.7071996,
|
||||
"ast_hash": "a6ea2770dec2156f78a65b35ba06902a",
|
||||
"semantic_hash": "a6ea2770dec2156f78a65b35ba06902a"
|
||||
},
|
||||
"docs/agents/domain.md": {
|
||||
"mtime": 1786216141.7071996,
|
||||
"ast_hash": "6f99318ac6cb9825b613bfde55d76091",
|
||||
"semantic_hash": "6f99318ac6cb9825b613bfde55d76091"
|
||||
},
|
||||
"docs/agents/issue-tracker.md": {
|
||||
"mtime": 1786216141.7087462,
|
||||
"ast_hash": "1342e66ccb84a84fd579fb6dc0b8243a",
|
||||
"semantic_hash": "1342e66ccb84a84fd579fb6dc0b8243a"
|
||||
},
|
||||
"docs/agents/triage-labels.md": {
|
||||
"mtime": 1786216141.7087462,
|
||||
"ast_hash": "69114d07ed792d6bb1d13758ba5435e1",
|
||||
"semantic_hash": "69114d07ed792d6bb1d13758ba5435e1"
|
||||
},
|
||||
"userscript/AGENTS.md": {
|
||||
"mtime": 1786499529.7762787,
|
||||
"ast_hash": "e276ffe9a6e7b55fd3235466a1995c22",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"userscript/CLAUDE.md": {
|
||||
"mtime": 1786499529.7762787,
|
||||
"ast_hash": "e276ffe9a6e7b55fd3235466a1995c22",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"backend/internal/web/static/login-art.png": {
|
||||
"mtime": 1786022513.9585779,
|
||||
"ast_hash": "05d7863cba344a946256719a0c9ef959",
|
||||
"semantic_hash": "05d7863cba344a946256719a0c9ef959"
|
||||
},
|
||||
"backend/internal/web/static/logo.svg": {
|
||||
"mtime": 1786022513.9585779,
|
||||
"ast_hash": "d0d34d0f08a25b53176cc55989b7babe",
|
||||
"semantic_hash": "d0d34d0f08a25b53176cc55989b7babe"
|
||||
},
|
||||
"backend/internal/store/migrations/0007_covers.sql": {
|
||||
"mtime": 1786262323.6964688,
|
||||
"ast_hash": "6033ce0701be1236ed175362363bd96c",
|
||||
"semantic_hash": "6033ce0701be1236ed175362363bd96c"
|
||||
},
|
||||
"docs/adr/0005-on-demand-browser.md": {
|
||||
"mtime": 1786292465.8305523,
|
||||
"ast_hash": "8cf2fb8c0a66c2c7d82ae433b99ca42b",
|
||||
"semantic_hash": "8cf2fb8c0a66c2c7d82ae433b99ca42b"
|
||||
},
|
||||
"chrome/docker-compose.yml": {
|
||||
"mtime": 1786292465.8305523,
|
||||
"ast_hash": "5605599395a3f085904e78a2bfec1e58",
|
||||
"semantic_hash": "5605599395a3f085904e78a2bfec1e58"
|
||||
},
|
||||
"docs/adr/0006-browser-on-the-home-machine.md": {
|
||||
"mtime": 1786501942.7367291,
|
||||
"ast_hash": "bfcaf39b6c7e96610a7caa00eeb36533",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/adr/0007-backend-hosts-cover-bytes.md": {
|
||||
"mtime": 1786292465.8305523,
|
||||
"ast_hash": "b19e38045b3dcda7dd59634ed9227a68",
|
||||
"semantic_hash": "b19e38045b3dcda7dd59634ed9227a68"
|
||||
},
|
||||
"backend/internal/store/migrations/0008_filesystem_covers.sql": {
|
||||
"mtime": 1786363889.5602942,
|
||||
"ast_hash": "46cf7822d4f667e3cab36b547abe5e97",
|
||||
"semantic_hash": "46cf7822d4f667e3cab36b547abe5e97"
|
||||
},
|
||||
"backend/internal/latest/cover.go": {
|
||||
"mtime": 1786857336.6414917,
|
||||
"ast_hash": "d5f2248c3d11de74bf5a3977651b17c2",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"backend/internal/latest/cover_fetch_test.go": {
|
||||
"mtime": 1786363889.5565126,
|
||||
"ast_hash": "60d9eb7c59a3751baf4f31c7655217e7",
|
||||
"semantic_hash": "60d9eb7c59a3751baf4f31c7655217e7"
|
||||
},
|
||||
"backend/internal/latest/acquire.go": {
|
||||
"mtime": 1786861012.026504,
|
||||
"ast_hash": "d605e3c94a62fc7787efbc139696b9d2",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"backend/internal/latest/acquire_test.go": {
|
||||
"mtime": 1786363889.5565126,
|
||||
"ast_hash": "7bd9f41814f6bf59d8998dfb81cf990a",
|
||||
"semantic_hash": "7bd9f41814f6bf59d8998dfb81cf990a"
|
||||
},
|
||||
"backend/internal/store/migrations/0009_series_cover_address.sql": {
|
||||
"mtime": 1786363889.5602942,
|
||||
"ast_hash": "4c1f6328b2e1a95828fad6d88d474c2d",
|
||||
"semantic_hash": "4c1f6328b2e1a95828fad6d88d474c2d"
|
||||
},
|
||||
".claude/skills/implement-tickets/SKILL.md": {
|
||||
"mtime": 1786417841.7117643,
|
||||
"ast_hash": "3060e32e19cc571d91871a98f18afe53",
|
||||
"semantic_hash": "3060e32e19cc571d91871a98f18afe53"
|
||||
},
|
||||
".omp/agents/ticket-implementer.md": {
|
||||
"mtime": 1786417841.7163916,
|
||||
"ast_hash": "0150a46c0d21c572b71b3d87d21ac925",
|
||||
"semantic_hash": "0150a46c0d21c572b71b3d87d21ac925"
|
||||
},
|
||||
"docs/adr/0008-series-identity-is-discovered-not-derived.md": {
|
||||
"mtime": 1786417841.7163916,
|
||||
"ast_hash": "3ce6d64ef6a8a39f27c257b39065389e",
|
||||
"semantic_hash": "3ce6d64ef6a8a39f27c257b39065389e"
|
||||
},
|
||||
"docs/research/lightnovelworld-chapter-vs-series-slug.md": {
|
||||
"mtime": 1786417841.7163916,
|
||||
"ast_hash": "74a4e538875f0a7e8ca3d5dc48c0bb53",
|
||||
"semantic_hash": "74a4e538875f0a7e8ca3d5dc48c0bb53"
|
||||
},
|
||||
"backend/internal/latest/smoke_lnw_test.go": {
|
||||
"mtime": 1786499529.7725692,
|
||||
"ast_hash": "2d65da8a081759172918fdf159760f45",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/adr/0009-a-site-answers-questions-its-own-way.md": {
|
||||
"mtime": 1786499529.7725692,
|
||||
"ast_hash": "8039012a5b6de2359ff1a47079f51b66",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"backend/internal/latest/read.go": {
|
||||
"mtime": 1786861012.026504,
|
||||
"ast_hash": "9f039cc3ad74f803d7621f7ef4157bf2",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/research/cloudflare-bot-scoring-and-poll-cadence.md": {
|
||||
"mtime": 1786501942.7367291,
|
||||
"ast_hash": "1aa17575ab20f2f36583602999a6a60f",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"backend/internal/latest/smoke_comix_test.go": {
|
||||
"mtime": 1786857336.6414917,
|
||||
"ast_hash": "111fdbb75fc68ac2ab1013bc916063cf",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/adr/0010-poll-lanes-per-site-pace.md": {
|
||||
"mtime": 1786861012.026504,
|
||||
"ast_hash": "dc19d75f034ca920d93b9c71e6ca28e6",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"backend/internal/latest/status.go": {
|
||||
"mtime": 1786865033.5648637,
|
||||
"ast_hash": "8141514efa5a7a66e77b9a62d4982d52",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"backend/internal/store/migrations/0010_reader_sightings.sql": {
|
||||
"mtime": 1786863695.6957178,
|
||||
"ast_hash": "a72c08c63fad530df3c793d16edfa385",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"backend/internal/web/admin.go": {
|
||||
"mtime": 1786865087.473256,
|
||||
"ast_hash": "b1c0f23a102a9bc7f84adf306d743f56",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"backend/internal/web/templates/admin.html": {
|
||||
"mtime": 1786865108.4095602,
|
||||
"ast_hash": "f04ea1cb01369d53053eac489e796faa",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"backend/internal/web/templates/lanes.html": {
|
||||
"mtime": 1786865113.5362902,
|
||||
"ast_hash": "8b9f9c7a56e0ec3bf950aa70acd95e92",
|
||||
"semantic_hash": ""
|
||||
}
|
||||
}
|
||||
@@ -905,27 +905,37 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Records the newest chapter a site has published. Silent: this fires from
|
||||
// Reports the newest chapter a site has published. Silent: this fires from
|
||||
// page visits and background checks the user did not ask for, and it never
|
||||
// reorders the list — updated_at is a candidate the server discards unless
|
||||
// reading progress moved.
|
||||
async function applyLatestChapterIfChanged(existing, latest) {
|
||||
//
|
||||
// An unchanged number is still sent. It is the read that lets the backend
|
||||
// skip its own poll of this series (a Sighting, issue #103), so the common
|
||||
// case — visiting a series with nothing new — is exactly the one worth
|
||||
// reporting. Only the local write and the re-render are skipped.
|
||||
async function reportLatestChapter(existing, latest) {
|
||||
if (!existing || !latest) return;
|
||||
if (existing.latest_chapter_num === latest.num) return;
|
||||
const bm = Object.assign({}, existing, {
|
||||
const changed = existing.latest_chapter_num !== latest.num;
|
||||
let bm = existing;
|
||||
if (changed) {
|
||||
bm = Object.assign({}, existing, {
|
||||
latest_chapter: latest.label,
|
||||
latest_chapter_num: latest.num,
|
||||
updated_at: Date.now(),
|
||||
});
|
||||
upsertLocal(bm);
|
||||
render();
|
||||
}
|
||||
// A queued write owns this row; the drain sends latest_chapter
|
||||
// with it, carrying the correct bucket.
|
||||
if (queueGet(bm.key)) return;
|
||||
try {
|
||||
const saved = await apiPut(bm.key, bm);
|
||||
if (changed) {
|
||||
upsertLocal(saved);
|
||||
render();
|
||||
}
|
||||
} catch (e) {
|
||||
/* offline — the local cache still shows it, retried on a later visit */
|
||||
}
|
||||
@@ -937,7 +947,7 @@
|
||||
if (p.type !== "series") return;
|
||||
const existing = state.byKey[keyOf(p)];
|
||||
if (!existing) return;
|
||||
applyLatestChapterIfChanged(
|
||||
reportLatestChapter(
|
||||
existing,
|
||||
computeLatestChapter(p.site, anchorsFromDocument(document), p.seriesId)
|
||||
);
|
||||
@@ -977,7 +987,7 @@
|
||||
const html = await res.text();
|
||||
latest = computeLatestChapter(bm.site, anchorsFromHTML(html), bm.series_id);
|
||||
}
|
||||
await applyLatestChapterIfChanged(state.byKey[bm.key] || bm, latest);
|
||||
await reportLatestChapter(state.byKey[bm.key] || bm, latest);
|
||||
} catch (e) {
|
||||
/* offline or blocked — try again after the throttle window */
|
||||
}
|
||||
|
||||
@@ -809,27 +809,37 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Records the newest chapter a site has published. Silent: this fires from
|
||||
// Reports the newest chapter a site has published. Silent: this fires from
|
||||
// page visits and background checks the user did not ask for, and it never
|
||||
// reorders the list — updated_at is a candidate the server discards unless
|
||||
// reading progress moved.
|
||||
async function applyLatestChapterIfChanged(existing, latest) {
|
||||
//
|
||||
// An unchanged number is still sent. It is the read that lets the backend
|
||||
// skip its own poll of this series (a Sighting, issue #103), so the common
|
||||
// case — visiting a series with nothing new — is exactly the one worth
|
||||
// reporting. Only the local write and the re-render are skipped.
|
||||
async function reportLatestChapter(existing, latest) {
|
||||
if (!existing || !latest) return;
|
||||
if (existing.latest_chapter_num === latest.num) return;
|
||||
const bm = Object.assign({}, existing, {
|
||||
const changed = existing.latest_chapter_num !== latest.num;
|
||||
let bm = existing;
|
||||
if (changed) {
|
||||
bm = Object.assign({}, existing, {
|
||||
latest_chapter: latest.label,
|
||||
latest_chapter_num: latest.num,
|
||||
updated_at: Date.now(),
|
||||
});
|
||||
upsertLocal(bm);
|
||||
render();
|
||||
}
|
||||
// A queued write owns this row; the drain sends latest_chapter
|
||||
// with it, carrying the correct bucket.
|
||||
if (queueGet(bm.key)) return;
|
||||
try {
|
||||
const saved = await apiPut(bm.key, bm);
|
||||
if (changed) {
|
||||
upsertLocal(saved);
|
||||
render();
|
||||
}
|
||||
} catch (e) {
|
||||
/* offline — the local cache still shows it, retried on a later visit */
|
||||
}
|
||||
@@ -841,7 +851,7 @@
|
||||
if (p.type !== "series") return;
|
||||
const existing = state.byKey[keyOf(p)];
|
||||
if (!existing) return;
|
||||
applyLatestChapterIfChanged(
|
||||
reportLatestChapter(
|
||||
existing,
|
||||
computeLatestChapter(p.site, anchorsFromDocument(document), p.seriesId)
|
||||
);
|
||||
@@ -883,7 +893,7 @@
|
||||
if (!res.ok) continue;
|
||||
const html = await res.text();
|
||||
const latest = computeLatestChapter(bm.site, anchorsFromHTML(html), bm.series_id);
|
||||
await applyLatestChapterIfChanged(state.byKey[bm.key] || bm, latest);
|
||||
await reportLatestChapter(state.byKey[bm.key] || bm, latest);
|
||||
} catch (e) {
|
||||
/* offline or blocked — try again after the throttle window */
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user