diff --git a/src/lib/markdown.ts b/src/lib/markdown.ts index 4ea0349..f8f6850 100644 --- a/src/lib/markdown.ts +++ b/src/lib/markdown.ts @@ -356,10 +356,13 @@ export function findLiveRequests( return [...out.values()]; } -/** Every `img/…` asset key referenced by the document (for dims + GC). */ +/** Every `img/…` asset key referenced by the document (for dims + GC). The base + * name matches the `[\w.-]` the image renderer accepts, not just the hex of a + * freshly minted key, so a manually-written key is never GC'd out from under a + * document that still draws it. */ export function imageKeysIn(src: string | null | undefined): string[] { const keys = new Set(); - const re = /img\/[A-Za-z0-9]+\.(?:webp|png|jpe?g|avif)/g; + const re = /img\/[\w-]+\.(?:webp|png|jpe?g|avif)/g; let m: RegExpExecArray | null; while ((m = re.exec(src ?? ''))) keys.add(m[0]); return [...keys]; diff --git a/src/lib/server/hits.test.ts b/src/lib/server/hits.test.ts index e8f5fbc..24db10b 100644 --- a/src/lib/server/hits.test.ts +++ b/src/lib/server/hits.test.ts @@ -1,7 +1,7 @@ import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { beforeEach, describe, expect, it } from 'vitest'; -import { DEVICE_UPSERT, HITS_UPSERT, MIGRATE_VISITOR, VISITORS_INSERT } from './track'; +import { DEVICE_UPSERT, HITS_UPSERT, MIGRATE_VISITOR, PURGE_VISITOR, VISITORS_INSERT } from './track'; /** * §13, test 3 — the `hits` upsert increments rather than duplicating. @@ -101,6 +101,23 @@ withSqlite('hits', () => { ]); }); + it('folds without aborting when the fingerprint row already exists', () => { + // The page's own deferred write can land after the beacon, leaving a row + // under the fingerprint identity on a slug the cookie-less identity also + // hit. A plain UPDATE onto that primary key would throw and abort the + // batch; `OR IGNORE` skips it and the purge clears the leftover, so the + // slug ends up counted exactly once. + db.prepare(VISITORS_INSERT).run('2026-01-01', 'gh', 'old'); + db.prepare(VISITORS_INSERT).run('2026-01-01', 'gh', 'new'); // already present + + expect(() => db.prepare(MIGRATE_VISITOR).run('2026-01-01', 'new', 'old')).not.toThrow(); + db.prepare(PURGE_VISITOR).run('2026-01-01', 'old'); + + expect(rows<{ vh: string; slug: string }>(`SELECT vh, slug FROM visitors`)).toEqual([ + { vh: 'new', slug: 'gh' } + ]); + }); + it('leaves the next day untouched when it migrates an identity', () => { db.prepare(VISITORS_INSERT).run('2026-01-01', 'gh', 'old'); db.prepare(VISITORS_INSERT).run('2026-01-02', 'gh', 'old'); diff --git a/src/lib/server/track.ts b/src/lib/server/track.ts index e87ff31..d825283 100644 --- a/src/lib/server/track.ts +++ b/src/lib/server/track.ts @@ -115,8 +115,19 @@ export const DEVICE_UPSERT = `INSERT INTO hits_device (day, slug, kind, os, brow export const VISITORS_INSERT = `INSERT OR IGNORE INTO visitors (day, slug, vh) VALUES (?, ?, ?)`; -/** Fold a cookie-less identity into the fingerprint identity for the whole day. */ -export const MIGRATE_VISITOR = `UPDATE visitors SET vh = ?2 WHERE day = ?1 AND vh = ?3`; +/** + * Fold a cookie-less identity into the fingerprint identity for the whole day. + * `OR IGNORE` because a row for the fingerprint identity may already exist on + * the same (day, slug) — the page's own deferred `track()` can land after the + * beacon — and a plain UPDATE onto that existing primary key would abort the + * whole batch. Rows that cannot move are cleared by `PURGE_VISITOR` below, so + * the old identity never lingers as a duplicate. + */ +export const MIGRATE_VISITOR = `UPDATE OR IGNORE visitors SET vh = ?2 WHERE day = ?1 AND vh = ?3`; + +/** Remove any cookie-less rows the migration could not move (their fingerprint + * row already existed), so the two identities never double count. */ +export const PURGE_VISITOR = `DELETE FROM visitors WHERE day = ?1 AND vh = ?2`; /** First-party fingerprint cookie. HttpOnly: the page never needs to read it. */ export const FP_COOKIE = 'f'; diff --git a/src/lib/ui/Button.svelte b/src/lib/ui/Button.svelte index 5dd3353..cccf32a 100644 --- a/src/lib/ui/Button.svelte +++ b/src/lib/ui/Button.svelte @@ -71,7 +71,17 @@ {/snippet} {#if href} - + + {@const inert = disabled || busy} + {@render label()} {:else} diff --git a/src/routes/admin/+layout.svelte b/src/routes/admin/+layout.svelte index ef398f7..487f3d2 100644 --- a/src/routes/admin/+layout.svelte +++ b/src/routes/admin/+layout.svelte @@ -35,8 +35,9 @@