blyg-core notes
How crates/blyg-core works, for whoever touches it next. The spec is
docs/SPEC.md; this file covers only the decisions the spec leaves open.
Layout
| Module | Role |
|---|---|
api/ | Blocking ureq 2 (rustls) client, one method per owner endpoint. Errors: transport → Offline, 401 → Unauthorized, 404 → NotFound, other non-2xx → Rejected{status, error, errors} (5xx too; api::is_transient treats ≥ 500 as retryable). Optional reads (/api/reading/imported on a studio before 0.40, …) return Ok(None) on 404. Debug redacts the token. |
store/ | One rusqlite connection behind a mutex, WAL, user_version migrations (schema.rs, append-only). Items + FTS5 trigram index, outbox, versions cache, reading list, subscriptions. |
sync/ | Engine (flush, pull, health/status, events) plus the worker thread. A 401 the credential can’t recover from stops the worker (no retry loop) and emits SignedOut once; queued ops stay. |
live.rs | LiveBackend: Backend. |
tk.rs | The Worker’s TK scope grammar, and provenance validation/remapping (see § Provenance). |
config/ | the Ghostty-style config file (keys.rs is the single key table; parse, edit preserves comments on write-back, paths XDG → Application Support, migrate from the old TOML files, show for +show-config); tokens.rs: TokenStore trait (KeychainTokenStore behind the default keychain feature, MemoryTokenStore for tests). |
Outbox
Ops are create, save, recreate (a pre-publish kind change of a draft that
already exists server-side) and delete_remote (retire the old draft after a
recreate). Edit ops carry no content: a push sends the working copy as it is
at push time. So coalescing is just “move the newest not-in-flight op’s
not_before”, and 50 keystrokes become one push. A save made while an op is
in flight queues a new op.
Ops run strictly in seq order per item. A later op for a LocalId waits for
its create, which maps the server id. Different items don’t block each other.
net (a mutex in Engine) serialises every item-touching network sequence:
worker flush, pull, publish and delete.
Pull cadence and change revisions (studio 0.32+)
Each pull first reads GET /api/changes ({epoch, domains}), before any data
(R3 of upstream’s polling design). Items depend on items+settings,
subscriptions on subscriptions, the reading list on reading,
subscriptions, signals and hoppers. A collection is fetched only when one
of its domains moved; after its fetch succeeds, the revisions captured before it
are stored in meta (changes_seen:<collection>, a JSON map; changes_epoch).
A failed, partial or deferred fetch records nothing, so it’s simply redone. A new
epoch forgets every stored revision. With read-state sync on (extension 5, a table
the counters don’t watch) the reading list is always read. Everything is read whole
every 10 min as a safety net. With /api/changes the worker pulls every
pull_interval / 4 (15 s); a 404 keeps the old full pull every pull_interval.
Work budgets (studio 0.28+)
A 429 maps to CoreError::RateLimited { retry_after } (Retry-After, default
60 s). It is transient: the op stays queued, the worker waits exactly that long,
and one Error event says “The blyg asked Burrow to slow down; retrying in Ns”.
Autosave save ops for one item run at least SAVE_GAP (3 s) apart unless
forced, so typing stays around 20 writes a minute at most.
Conflicts (deviation from the spec’s updated rule)
The spec says to flag a conflict when the server’s updated > base_updated
and the item has local edits. That doesn’t work against the real Worker:
saveWorkingCopy bumps updated on our own draft saves (false positives)
and never bumps it for published items (false negatives). Core compares
content instead. There’s a conflict when the item has unpushed edits and
the server’s content_md differs both from base_content (what we last synced
against) and from the local working copy. base_updated is still recorded.
This is checked in two places:
- On every pull (
merge_all). - Before every push of a
saveorrecreate: aGET /api/items/:idfirst, so an offline edit can’t silently overwrite a studio edit when the network comes back.sync_nowflushes before it pulls, so without this check it would clobber. The cost is one extra GET per debounced push.
Conflicted items are skipped by the flush. The resolutions:
KeepMine: base := theirs, and a push is queued.TakeServer: working copy := theirs, and local ops are dropped.KeepBoth: the same asTakeServer, plus a new local draft holding mine.
set_kind
The Worker only reads kind on POST /api/items (api.ts); PUT ignores it,
and there’s no other route. Before publish:
- a draft that is still local-only just changes its kind;
- a server-side draft gets
recreate: POST a new draft of the new kind with the current content, remap the server id, thendelete_remotethe old one.
Toggling back before the push is a no-op, because server_kind is tracked.
After publish, set_kind returns Rejected{409}.
Provenance (client-recorded TK disclosure)
The Worker keys a working copy’s TK provenance by scope position
(items.tk_provenance_json), so a plain PUT /api/items/:id that adds,
removes or reorders [TK]…[/TK] scopes would shift disclosure onto the wrong
span. Core therefore:
- tracks provenance per scope locally (schema v3:
tk_provJSON, keyed to the text intk_prov_md, andtk_prov_dirty).Backend::save_with_provenancesets it (validated like the server does: one entry per scope, only on generated scopes, only citing sources quoted in the scope); - carries it along on every plain
save(tk::remap: scopes are matched by instruction, in order; a scope whose output was rewritten entirely by hand loses it; malformed mid-typing text keeps the old keying until it parses); - pushes text and provenance together,
PATCH /api/items/:id {content_md, provenance}(studio 0.28+;POST /api/itemscarries it on create too), whenever the scope structure changed sincebase_contentor the tracked provenance changed, else a plain PATCH. Missing source versions are filled from the store (upstream requires them). Provenance the server holds but this client never saw (another client, the Worker’s own/generate) comes from the precheck’sItem.provenanceand is remapped first; - falls back to extension 4 (
PUT/GET …/tk-provenance) only when the server refuses theprovenancekey with a strict 400 (pre-0.28, learned per session); a 404 or 403 there means not recordable (plain PATCH, andLiveBackend::provenance_available()turns false until a provenance push lands); a 400 retries without sources, then clears it, saves the text and emits anErrorevent; - forgets tracked provenance when the server’s text replaces ours (pull, take-server, restore: the Worker clears its cache on restore).
Connecting
api::verify_connection(url, token) is the Connect sheet’s check:
GET /api/items with the token. It maps a transport failure to
ConnectError::Unreachable, 401 to WrongToken, 404 to MissingExtensions
(a server older than blygger-studio 0.9), a pre-0.9 answer to Outdated, and
anything else to Other. A studio 0.9 or later but older than 0.47 connects:
the sync engine’s studio-floor check (floor.rs, Api::studio_floor: the
manifest’s generator, else a /api/search probe for label) then holds
the outbox ops and publish of posts whose text has labelled links
(floor::has_labels; publish answers CoreError::ServerOutdated), and
everything else syncs.
Extension keys
The extension config (docs/SPEC.md § Extensions) is three repeatable
ValueKind::Text keys whose line grammar parse.rs checks, so a bad line is
an ordinary config error with a line number and the rest still loads.
extension = <name>. extension-allow = <name> <capability>, where the
capability is one of keys::EXTENSION_CAPABILITIES (exact case, so
hooks:itemPublished) or fs:<path>. extension-setting = <name> key=value.
Names and setting keys are lowercase kebab-case (parse::valid_extension_name).
Values are stored normalised (name cap, name key=value) so write-back
prints one form. The accessors are Config::extensions_enabled() (deduplicated,
in first-named order), extension_allows() (BTreeMap<name, BTreeSet<cap>>,
with fs: paths as written and ~ unexpanded) and extension_settings(name)
(a later line for the same key wins). Consent writes grants with
ConfigStore::set(&[("extension-allow", Change::List(..))]). Installed
extensions live in ConfigFiles::extensions_dir(): extensions/ beside the
first config file, or BLYGGER_EXTENSIONS_DIR. Nothing in blyg-core starts
an extension. The host is in the blyg-ext crate.
End-to-end tests
crates/blyg-core/tests/e2e.rs runs against a real Worker under
wrangler dev --local (all #[ignore]d, so CI doesn’t need one):
cd <worker dir> && npm install && npm run build # studio 0.10+ builds its SPA
BLYG_WORKER_DIR=<worker dir> scripts/e2e-local.sh
The Worker is a stock blygger-studio 0.47 or later (set BLYG_E2E_STOCK=1; the
few tests of the old fork’s extensions then skip), or a fork carrying them. Against
an older studio (checked at 0.46.1) the suite passes too, and
studio_floor_holds_labelled_posts_below_0_47 checks that only posts with
labelled links wait there. The script derives a local-only wrangler config (no routes, no
Workers AI binding, so nothing billed), random dev secrets including a
throwaway BLYG_OWNER_TOKEN, and scratch D1/R2 state. It starts two instances
(the second is a blyg to subscribe to) and stops only what it started. The
suite never calls POST /api/items/:id/generate. It covers bearer-token
connect, reads, autosave, publish, withdraw, pin and restore, kind changes,
uploads and their dedupe and removal, a 409 conflict from a direct API edit,
offline queueing, subscriptions and reading, read-state sync between two
clients, and AI provenance disclosure. Raw replies are checked against
openapi.json. Last green: 44/44 (plus the markdown-notes extension’s 2) against
stock studio 0.46.1 (2026-10-13).
Reading list
One row per (subscription_id, remote_id), upserted. The local read_version
(a number only) survives edits. The text of the version read is never kept
(spec §8.4); migration v2 dropped the old read_snapshot_md column. An RSS guid change on edit (same
subscription, same resolved page URL, and the old id is no longer listed) is
treated as the same row. reading() collapses duplicates on the absolute page
URL, falling back to (origin, remote_id), and prefers the blyg-kind
subscription. It hides tombstones unless they have a thumb or a hopper.
page is resolved against origin in the API layer. The next cursor is
opaque and passed back verbatim as before. Rows the server stopped returning
are pruned: all of them after a complete fetch, or only those inside the
fetched window after a partial one.
Other people’s versions & pins
api/public.rs::PublicClientis a separateureq::Agentwith no token. It is the only path to foreign origins, so the owner token can’t leak there (tests/versions.rs::the_owner_token_never_reaches_a_foreign_origin).- URLs:
model::{item_doc_url, pin_doc_url}joinitems/{id}.json/items/{id}/v{n}.jsononto the origin (a missing trailing slash is added, the id is percent-encoded as one segment, and only http(s) is accepted). This works for subdirectory mounts. remote_versionsreturns the full changelog, which the reading list uses for its notes.remote_shown_versions/model::shown_versionsreturn only current + pinned, which is what the version browser lists. RSS/L0 rows give only their current version and make no request.remote_pinnedreturns the version from theremote_pinscache if it’s there (pins are immutable, so they’re kept forever). Otherwise it refuses withRejected{404}and makes no request when the changelog doesn’t mark the version pinned. It then fetches, and a 404 also means not pinned (and drops the cached changelog). The changelog cache (remote_changelog) lastsCHANGELOG_TTL_MS= 2 min, or until the reading row’s version passes it. Offline, it falls back to the cache.content_hash="sha256:" + hex(SHA-256(content_md UTF-8))(digest §1.3, upstreamutil.ts::contentHash). A mismatch setshash_mismatchand the content is adopted anyway. SHA-256 is hand-rolled inutil.rs(FIPS known-answer tests) so there’s no new dependency orCargo.lockchurn. Swapping insha2is a one-liner.pinned_diff_basereturns the pinned version only whenread_versionis pinned.- Withdrawal (
store/reading.rs::retainable): when a tombstone arrives, its content is dropped unless a pin backs it. That’s the server’spinned_version_retained(its bytes are kept) or a pin inremote_pins(the newest cached one is used).ReadingItem.pinned_version_retained+pin_url()give the attribution. The DB runs withsecure_delete=ON, so purged bytes are overwritten on disk. - Own items:
pinrefuses an endcap locally with 409, refreshing versions first if the version is unknown.restoreonly loads the working copy.
Performance
5,000 items (store::tests::bench_search_5000, release build):
searchtakes about 0.1 ms, using a trigram phrase match.- A query shorter than 3 characters scans in Rust.
items()takes about 0.5 ms from the snapshot cache, which is keyed on SQLitetotal_changes(), or about 4–5 ms after any write.