ADR 018: Journal pages are stored by ISO date; the title format is a setting
Date: 2026-09-11. Status: accepted.
Context#
A journal page has three names at once today:
- its day,
journal_day = 20260907, which is the identity; - its stored name, whatever title format the graph was written with —
Mon, 07.09.2026in the imported Logseq graph,Sep 7th, 2026in a graph nooklet created itself; - its wire name, the ISO date, because
journalDayFromWireand rule 18 address journals by ISO everywhere in the API.
The second of those is the odd one out, and it has now caused three separate bugs:
- B-22 — a search result or block reference hands out the ISO date,
usePageByNamelooked the name up by key, found nothing, and said "This page doesn't exist yet" about a page with 40 blocks in it. Patched by falling back toparseJournalTitle→journal_day. - B-23 —
page.createguarded only against ISO names, so an agent calling it with"Sep 8th, 2026"minted an ordinary page that shadows the journal day forever. - A quieter one, never filed:
ref.dst_page_keyis the reference text folded to lower case, so[[Mon, 07.09.2026]],[[Sep 7th, 2026]]and[[2026-09-07]]are three different keys pointing at three different (mostly non-existent) pages. A journal's backlinks were whichever subset happened to use the same spelling as its stored name.
Every one of these is the same defect wearing a different hat: the format a date is displayed in had become part of its identity.
Decision#
A page with a journal day is stored under its ISO name. The displayed title is a preference. Every recognised way of writing a date resolves to the same key.
Three changes carry it:
-
packages/core'sapplyOpsderives the name.applyPageCreateandapplyPageRenameboth run the proposed name throughstoredPageName(name, journalDay), which returnsisoJournalName(day)for any page with a valid journal day and the proposed name otherwise. The op log keeps whatever name the op carried — that is history — while live state has exactly one answer. -
Reference keys canonicalise. The server's
normalizeKey(apply-ops.ts) is nownormalizePageName(canonicalRefName(name)), andcanonicalRefNamecollapses anythingparseJournalTitlerecognises to its ISO name.page_tag's tag keys go through the same function. Block text is not rewritten:[[Mon, 07.09.2026]]stays as the person typed it and simply resolves. -
Display is a setting.
apps/web/src/data/page-title.tsowns ajournalTitleFormatpreference and thedisplayPageName/displayRefNamehelpers every view now renders through. The importer no longer stores the source graph's:journal/page-title-format; it reports it as an import note instead, so the person can choose it in settings and see the dates they are used to.
An existing graph is migrated by packages/server/src/journal-names.ts, run from open() in the
CLI and guarded by a setting row.
Why the migration mints ops rather than updating rows#
A raw UPDATE page SET name = … is one line and would be wrong twice over. Live state is a
projection of the op log, so nooklet verify — which replays the log and compares — would flag
every renamed page. And a client bootstraps from a state snapshot and then follows the op stream,
so a rename that is not an op is a rename no other device ever hears about: the desktop app would
show Mon, 07.09.2026 forever while the server had moved on.
So the migration mints real page.rename ops with the server's device id. They apply locally,
replicate through sync, and apply identically on the other side because the coercion in (1) lives
in the shared reducer. That is also why applyPageRename coerces rather than refusing: a rename to
the ISO name has to be something that can actually happen.
Minting ops needs an HLC and a device id, which exist a layer above schema.ts, so this is a
startup task rather than a schema migration. It is idempotent and costs one setting read once the
graph is canonical.
The derived indexes are re-keyed in place rather than rebuilt from block content. The mapping (old
key → canonicalRefName) is a pure function of the key, so re-parsing 18k blocks would compute the
same answer about a hundred times more slowly.
Why not the alternatives#
Keep storing the display format; keep patching lookups. This is the status quo, and B-22's fix
is exactly it — usePageByName falls back to the day number when the name misses. It works, one
call site at a time, and every new consumer of page names starts out broken until someone notices.
The three bugs above are three such call sites; the reference index was a fourth nobody had looked
at yet.
Store the ISO name but leave references uncanonicalised. Half the change, and strictly worse
than doing nothing: renaming the pages without re-keying ref would empty every journal's
backlinks on the spot.
Canonicalise references but leave names alone. The other half. References would all agree on
2026-09-07 and resolve to nothing, because no page is stored under that key.
Make the display format a server setting rather than a per-device one. Tempting — it is a property of the graph, and it would follow you between machines. Left for later because it needs the settings API that does not exist yet, and because "what my dates look like on this screen" is genuinely defensible as a device preference. Moving it is a small change when there is somewhere to move it to.
Rewrite [[Mon, 07.09.2026]] in block text to [[2026-09-07]]. Consistent, and it would let
references resolve with no canonicalisation at all. Rejected: it edits 18k blocks of a person's
prose to make a storage decision visible, and it destroys the thing the format setting is for. The
words in a note belong to whoever wrote them.
Consequences#
- The page title is no longer editable on a journal page — it renders as a heading. There is
nothing for a rename to mean, and
applyPageRenamewould undo it anyway. - The page switcher and the all-pages filter match against the displayed title and the stored
name, so a journal is found by
2026-09and bySep 7thalike. Matching one or the other alone would have quietly lost half the ways people look for a day. LogseqConfig.journalPageTitleFormatno longer affects import. It is parsed, reported, and otherwise informational — likejournalFileNameFormatalready was.- The markdown mirror is unaffected:
pageFilePathalready derives a journal's file name from its day (journals/2026_09_10.md), so no file moves and notitle::changes. - A page named
11.12.2024with no journal day keeps its name and stays reachable by it — both in the op layer, which never guesses from a string, and inresolvePageRef, which checks for a real page before reading a name as a date. What you cannot do is create one throughpage.create: B-23's guard reads any parseable date as a journal, on the grounds that a date-shaped page name is far likelier to be a misdirected journal write than a deliberate title. An imported graph can still contain one, which is why the name-before-date order matters. - One thing to watch: a graph in which someone had already created an ordinary page named
2026-09-07blocks that day's journal from taking its name. The migration reports it rather than guessing; the journal keeps its old name and works as before.