Reference for documentation.db, the SQLite database backing all in-app help: Tier 1/2 tooltips, plus the Tier 3 web content they link to, served by WebServer. Read this before touching anything under localWebServer/, idetooltips/, or plugin-manager/.../documentation/, or before writing/editing SQL against this database.
This is a read-only, prebuilt database — CoGo never creates or migrates its schema at runtime (see ADR 0001, exception 1). The schema is owned by the separate OfflineDocumentationTools project (the docdb-studio tool); never change it from this repo.
- Installed path:
context.getDatabasePath("documentation.db")(Environment.DOC_DBincommon/.../utils/Environment.java), i.e. the app's privatedatabases/dir. - Bundled as an asset and extracted on install/update by
BundledAssetsInstaller/SplitAssetsInstaller. - Debug override: if
/sdcard/Download/documentation.dbexists and is newer than the installed copy,WebServerandToolTipManagerswap to it at request time (timestamp-compared per request, not just at startup) — a fast way to test a new database on-device without reinstalling. The comparison is on modification time, andadb pushpreserves the source file's mtime -- so pushing a database saved earlier than the one already on the device silently does not swap, and the app keeps serving the old one with no error anywhere. Follow a push withadb shell touch /sdcard/Download/documentation.db(this cost real debugging time on ADFA-5153).WebServer's debug logging and experiment flags are also file-flag-gated under/sdcard/Download/(CodeOnTheGo.webserver.debug,CodeOnTheGo.exp,CodeOnTheGo.webserver.cs0). - Don't trust a local copy's on-disk schema or row content as ground truth without checking freshness first. Any manually downloaded or debug-override copy is independent of git history — a stale one can have a different schema (e.g. missing
UNIQUE(path)ortemplateId) or be missing rows that already exist in the current, maintained database. A stale copy caused a real near-miss in ADFA-5088: a SQL script validated against it would have silently overwritten curated production tooltip content for several tags. Diff or re-download before authoring SQL against a local copy's state, not just before shipping it.
A star schema: a large fact table at the center, small dimension tables around it. There are two fact tables — Content (Tier 3 web content) and Tooltips (Tier 1/2 tooltips) — because they serve different lookup patterns.
CREATE TABLE Content (
id INTEGER PRIMARY KEY AUTOINCREMENT,
path TEXT NOT NULL,
languageID INTEGER NOT NULL,
content BLOB NOT NULL,
contentTypeID INTEGER NOT NULL,
templateId INTEGER,
FOREIGN KEY (languageID) REFERENCES Languages(id),
FOREIGN KEY (contentTypeID) REFERENCES ContentTypes(id),
UNIQUE(path)
);One row per file the web server can serve (HTML, CSS, JS, image, video, PDF, ...) — 30,000+ rows. Key points:
pathis the lookup key (indexed via theUNIQUEconstraint) and is whatWebServermatches the HTTP request path against. Paths carry a short source prefix to avoid collisions between doc sets, e.g.k/index.html(Kotlin) vsj/index.html(Java).contentis compressed — Brotli for text-like formats, format-specific compression otherwise (images/video/fonts).ContentTypes.compressionsays which. Every migratedContentrow withContentTypes.compression = 'brotli'is Brotli-compressed against the single shared dictionary inCompressionDictionary(see below), converted in one pass by ADFA-5153 — but plugin-contributed Tier 3 rows (PluginDocumentationManager/BrotliCompressor, see below) are plain, dictionary-free Brotli, and there is no per-row flag distinguishing the two, because a dictionary-compressed stream and a plain one are not distinguishable at decode time by inspection. They are distinguishable by attempting the decode: attaching the wrong dictionary decodes without error to different bytes than were compressed (its backward distances resolve into real, just incorrect, bytes) — but attaching no dictionary to a stream that needs one reliably throws (IOException, "corrupted input"), since distances into the dictionary region are then out of bounds for any spec-compliant decoder.WebServerrelies on exactly this: it tries the dictionary first and falls back to a plain decode onIOException, which correctly handles both dictionary-compressed and plain rows — but never rely on decode success/failure to detect a wrong dictionary, since that case is silent. Content over 1 MB is split across multiple rows: the first row's path is the base path, continuation rows arepath-1,path-2, ... (languageId = 1), reassembled byWebServerbefore returning.templateId:0(or unset) meanscontentis legacy HTML with presentation baked in (the pre-CMS Release 0/1 format). A positive value meanscontentis JSON facts only, rendered through the matching row inTemplates(a Pebble template) — the ongoing move to a proper CMS that de-duplicates presentation across near-identical pages (e.g.sin/cosdocs).- The
UNIQUE(path)constraint rejects any duplicatepath, regardless oflanguageID— a second language for an existing path isn't supported yet (onlyEN-uscurrently exists). Getting there needs an upstream schema change to composite uniqueness on(path, languageID)(see Known rough edges below).
Dimensions: Languages(id, value) (4-letter codes, e.g. EN-us); ContentTypes(id, value, compression) (MIME type + compression scheme, ~30 rows).
CREATE TABLE Tooltips (
id INTEGER PRIMARY KEY AUTOINCREMENT,
categoryId INTEGER NOT NULL,
tag TEXT NOT NULL,
summary TEXT NOT NULL,
detail TEXT NOT NULL,
UNIQUE(categoryId, tag),
FOREIGN KEY(categoryId) REFERENCES TooltipCategories(id)
);summaryis Tier 1 (the initial popup),detailis Tier 2 (after "See more"); both may contain HTML.- Looked up by
(categoryId, tag), which the IDE stamps on the UI widget that owns the tooltip. TheUNIQUEconstraint gives this lookup an index, so it's fast. TooltipCategories(id, category)is a tiny dimension table (four categories at the time of writing).TooltipButtons(tooltipId, buttonNumberId, description, uri)holds the Tier 3 links shown at the bottom of a Tier 2 tooltip —tooltipId->Tooltips.id,buttonNumberId->TooltipButtonNumbers.id.urishould resolve to aContent.path(after stripping?query/#fragment).TooltipButtonNumbers(id)exists only to pin a fixed, manually-assigned display order when a tooltip has multiple Tier 3 links. (Flagged in the source design doc as something worth redoing without a whole extra table.)
DocumentationDatabaseVersion(major, minor, patch, who, comment, changeTime)— the database's own semver (ADFA-5220), replacing the heuristics that used to infer the format from which tables happened to exist. Append-only: each change is anotherINSERT, so the row inserted last is the current version, not the highest one ever recorded — a rebuild from an older content set is a downgrade and has to read as one (DatabaseVersionResolver.resolveMajorVersion, which returns null for a database predating the table).MAJOR >= 2is what tells the app its brotliContentrows are dictionary-compressed; below that,WebServerneither reads nor attachesCompressionDictionary. Gating on the declared version rather than on the table's presence matters in both directions: a database can carry the dictionary table while its content is still plain brotli (every row would then pay a failed dictionary decode before its plain one, on every request), and a migrated database that lost the table fails loudly instead of quietly.CompressionDictionary(id, data)— single-row table (id INTEGER PRIMARY KEY CHECK (id = 1)) holding the raw Brotli dictionary every ADFA-5153-migratedcompression = 'brotli'Contentrow is compressed against. Trained once, from a representative sample across the wholeContenttable, byOfflineDocumentationTools'migrate_content_to_dictionary_brotli.py/populate_db.py(never retrained after that — a dictionary-compressed row is only decodable against the exact dictionary it was compressed with, so replacing it would silently orphan every already-migrated row). Shipping the dictionary insidedocumentation.dbitself, rather than as a separate bundled asset, keeps it version-locked to the content compressed against it.WebServerloads it lazily -- not merely from starting the server or swapping databases, but on the first content fetch that needs it afterdatabasechanges, and only whenDocumentationDatabaseVersiondeclaresMAJOR >= 2(see above) -- and caches it from then on, reloading again only on the next database change (a swap can bring in a database with a different dictionary or none, so it can't stay cached across one). Per row, it tries decoding with the dictionary attached first via brotli4j'sattachDictionary, falling back to a plain decode on failure — needed both for a database predating this migration (noCompressionDictionarytable at all) and for plugin-contributed rows within an otherwise-migrated database (seePluginDocumentationManagerbelow).Templates(id, name, content)— Pebble template source, keyed by id (and bynamefor well-known templates likebookshelf). Referenced byContent.templateId.Bookshelf(contentID, bookCategoryID, title, description)/BookCategories(id, category, description)— the Dynamic Bookshelf: one row per "book" (PDF or similar), linked to its Tier 3 page viacontentID->Content.id. Two DB triggers keepBookshelfin sync when a PDF row is inserted/deleted fromContent;title/descriptiondon't come from those triggers and must be set by hand. Non-PDF books need a separate ingestion path (plugin-provided, e.g. viaPluginDocumentationManager).LastChange(documentationSet, changeTime, who)— audit trail for edits made throughdocdb-studio; not shown to end users.DatabaseVersionResolverreads thedocumentationSet = 'wholedb'row to report the DB's build/edit stamp in debug logging, falling back to the most recent row of any set if'wholedb'is missing.- Misc
ide_tooltip_tableandPUCCtables are historical/example artifacts — not part of the live lookup paths above.
All three sites below open the file with SQLiteDatabase.openDatabase(..., OPEN_READONLY) — no writes, ever, from this app (see ADR 0001 for why raw SQLite is justified here instead of Room).
-
app/.../localWebServer/WebServer.kt— serves Tier 3. On eachGET, runs:SELECT C.content, CT.value, CT.compression, C.templateId FROM Content C, ContentTypes CT WHERE C.contentTypeID = CT.id AND C.path = ?
then reassembles chunked blobs, always decompresses Brotli content (attaching
CompressionDictionary's bytes first, if loaded — see above) since this server never negotiatesContent-Encodingwith the client, and instantiates the template iftemplateId > 0. Also serves a Dynamic Bookshelf JSON payload (joiningContent/Bookshelf/BookCategories, rendered through thebookshelftemplate) and debug-only HTML dumps at/pr/db(LastChange, last 20 rows) and/pr/pr(recent projects, from a different database). -
idetooltips/.../ToolTipManager.kt— serves Tier 1/2. Looks upTooltipsjoined toTooltipCategoriesby(category, tag), thenTooltipButtonsfor the Tier 3 links shown at the bottom. -
plugin-manager/.../documentation/PluginDocumentationManager.kt(withTier3AssetWalker.kt, and theDocumentationExtensioncontract inplugin-api) — lets plugins contribute their own help content into the same lookup paths.
Schema changes and data edits happen outside this repo, in OfflineDocumentationTools/docdb-studio (a Flet GUI over this same documentation.db). Conventions enforced there that matter if you're reasoning about data correctness here:
- The schema is locked —
docdb-studio's ownAGENTS.mdsays never change it. If a new column/table is genuinely needed, it's a cross-repo change coordinated with that project, not something to route around in CoGo. - Tooltip uniqueness is
(categoryId, tag);TooltipButtons.urivalues are validated there againstContent.path(post?query/#fragmentstripping) before being allowed into the database. - Every edit made through the tool updates
LastChangefor the affected documentation set, which is howDatabaseVersionResolver's debug logging can say what build of the docs is loaded.
Some tickets (e.g. ADFA-5088) ship a one-off .sql script under docs/docdb/ for a docdb-studio maintainer to run against the real database, rather than editing it directly through the tool. Gotchas found writing those scripts:
- The system SQLite has no JSON1 on some devices, and your desktop does.
JSON_OBJECT,JSON_GROUP_ARRAYand friends compile fine under thesqlite3CLI (3.44 ships JSON1 built in) and then fail at runtime withno such function: JSON_OBJECTon real hardware — reproduced on a Galaxy Note 20 Ultra, where the Dynamic Bookshelf was an HTTP 500 until the query was rewritten (ADFA-5179). Nothing on the desktop side of the fence will warn you. Write plain relational SQL and assemble the JSON in Kotlin, and treat any device-only 500 from a new query as this until proven otherwise. Applies to every reader of this database, not justWebServer: adocdb-studioscript, a tooltip query inToolTipManager, andPluginDocumentationManagerare all equally exposed. - Keep each
.systemline simple. The sqlite3 CLI's.systemdot-command can hit a content-dependent shell-parsing failure when a line chains multiple operators (;,&&,||, parentheses) — it reproduces for some input strings and not others, so it won't necessarily show up in a quick test. Stick to one plaincommand | pipe > fileper.systemline. .bail onis required forBEGIN/COMMITto actually mean atomic. Without it, a mid-script SQL error prints to stderr but the script keeps going — including reaching the finalCOMMIT, which then persists whatever succeeded before the error (verified empirically, not just documented behavior)..bailalso can't see.systemshell failures directly, so a failed or empty Brotli payload (which leaves its target file missing or zero-length) needs its own check: insert itsREADFILE()into a throwawayCREATE TEMP TABLEguarded byNOT NULL CHECK (length(content) > 0)immediately before the realContentinsert, turning that failure into a real SQL error.bailwill catch. Seedocs/docdb/ADFA-5088-preference-tooltips.sqlfor the working pattern.- Don't write Brotli payloads to bare
/tmp/*.brfilenames. A fixed, guessable name directly under world-writable/tmplets another local user pre-plant a symlink or race the write/read pair between the.system echo | brotliwrite and theREADFILE()read (CWE-377). Create an owner-only working directory instead —rm -rfit, thenmkdir -m 700it (the mode is set atomically at creation, with no window where it's briefly world-accessible) — write every payload under that directory, and remove it again beforeCOMMIT. See the same script for the working pattern.
- A Tier 3 link that points off-device is a bug in the content, not the code — the web server and webview will happily follow it. If you see one while working in this area, it's a data problem to report upstream, not a
WebServerbug to fix here. Content'sUNIQUE(path)constraint (rather thanUNIQUE(path, languageID)) means a second language for an existing path can't currently be added without a schema change upstream — multi-language content isn't fully wired yet even though theLanguagesdimension anticipates it.TooltipButtonNumbersis a whole table whose only job is pinning a manual sort order; a lighter-weight mechanism (e.g. an ordering column directly onTooltipButtons) would remove a table.