{
  "attachments": [
    "./cover.png"
  ],
  "title": "Using SQLite as a document database for Mastodon exports",
  "tags": [
    "fediverse",
    "mastodon",
    "sqlite",
    "activitypub"
  ],
  "year": "2023",
  "month": "05",
  "day": "12",
  "isDir": true,
  "slug": "toots-in-sqlite",
  "type": "entry",
  "date": "2023-05-12T19:00:00.000Z",
  "postName": "2023-05-12-toots-in-sqlite",
  "html": "<p><strong>TL;DR</strong>: SQLite has JSON functions, generated columns, and full-text search - which all seems like a perfect mix for ingesting exports from Mastodon for search!</p>\n<!--more-->\n\n\n\n<figure>\n<img title=\"A mastodon living in a tiny database\" src=\"./cover.png\" class=\"wide\" width=\"\" height=\"\">\n<figcaption>A mastodon living in a tiny database, based on output generated by <a href=\"https://www.craiyon.com/\">Craiyon</a></figcaption>\n</figure>\n\n<h2 id=\"sqlite-is-more-capable-than-i-remembered\">SQlite is more capable than I remembered</h2>\n<p>I've been watching <a href=\"https://simonwillison.net/\">Simon Willison's</a> adventures in <a href=\"https://simonwillison.net/tags/sqlite/\">SQLite</a> and <a href=\"https://simonwillison.net/search/?tag=datasette\">Datasette</a> for a few years now. I keep meaning to tinker with this stuff myself, but never quite get around to it. Recently, though, a couple of interesting pieces sort of shuffled into place for me:</p>\n<ul>\n<li><p>I read this blog post by <a href=\"https://infosec.exchange/@dgl\">@dgl@infosec.exchange</a> on <a href=\"https://dgl.cx/2020/06/sqlite-json-support\">SQLite as a document database</a> - by combining <a href=\"https://www.sqlite.org/json1.html#jex\">json_extract()</a> and <a href=\"https://www.sqlite.org/gencol.html\">generated columns</a> in SQLite, you can build a JSON document database with indexes and all the fun of SQL queries. Just insert blobs of JSON and SQLite takes care of the rest.</p>\n</li>\n<li><p>I learned a bit about <a href=\"https://www.sqlite.org/fts5.html\">full-text search in SQLite</a>. With a few triggers, I can build an index table that supports full-text queries against content extracted from JSON in those generated columns.</p>\n</li>\n</ul>\n<p>Previously, I'd thought PostgreSQL was where I had to go for things like <a href=\"https://www.postgresql.org/docs/current/functions-json.html\">JSON functions</a> and <a href=\"https://www.postgresql.org/docs/current/textsearch.html\">full-text search</a>  - I didn't really expect to find that SQLite was this capable.</p>\n<p>While it's true you can do just about anything with a pile of docker containers, a small project fares much better with an embedded database like SQLite - especially if it's deployed to an environment like <a href=\"https://glitch.com/\">Glitch</a>. A SQLite easily database becomes de facto file format for personal tools.</p>\n<h2 id=\"mastodon-exports-are-full-of-json\">Mastodon exports are full of JSON</h2>\n<p>Okay, so that's some cool database stuff. What do I want to put in it?</p>\n<p>Well, I've accumulated a few thousand posts between Mastodon instances over the years. Support for search is rather uneven, depending on the Mastodon instance. So, I grabbed some JSON exports from my accounts and thought it might be interesting to make them searchable. </p>\n<p>I've tooted the most on three Mastodon instances: <a href=\"https://mastodon.social/@lmorchard\">mastodon.social</a>, <a href=\"https://toot.cafe/@lmorchard/\">toot.cafe</a>, and <a href=\"https://hackers.town/@lmorchard\">hackers.town</a>. I was active for a few months on my own self-hosted instance at <a href=\"https://toot.lmorchard.com/@lmorchard\">toot.lmorchard.com</a>, but I accidentally nuked the database at some point and lost all my content. (I guess I should have grabbed an export earlier!)</p>\n<p>An account export from Mastodon takes the form of a compressed tarball with media uploads and several collections of data in JSON. Of particular interest is a file named <code>outbox.json</code>: This contains all the account's posts and boosts, expressed in <a href=\"https://docs.joinmastodon.org/spec/activitypub/\">ActivityStreams 2.0 JSON format with Mastodon extensions</a>. (Notably, <em>not</em> in \"ActivityPub format\" <a href=\"https://tantek.com/2023/112/t2/account-migration-post-blog-archive-format\">as tantek recently pointed out</a>.)</p>\n<h2 id=\"tinkering-with-tables\">Tinkering with tables</h2>\n<p>Once I had the data, I tinkered with table schemas and came up with this mess:</p>\n<pre><code class=\"language-sql\">CREATE TABLE statuses (\n  json TEXT,\n  created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n  id TEXT GENERATED ALWAYS AS (json_extract(json, \"$.id\")) VIRTUAL UNIQUE,\n  type TEXT GENERATED ALWAYS AS (json_extract(json, \"$.type\")) VIRTUAL,\n  url TEXT GENERATED ALWAYS AS (json_extract(json, \"$.object.url\")) VIRTUAL,\n  summary TEXT GENERATED ALWAYS AS (json_extract(json, \"$.object.summary\")) VIRTUAL,\n  content TEXT GENERATED ALWAYS AS (json_extract(json, \"$.object.content\")) VIRTUAL,\n  displayName TEXT GENERATED ALWAYS AS (json_extract(json, \"$.actor.name\")) VIRTUAL,\n  publishedAt DATETIME GENERATED ALWAYS AS (json_extract(json, \"$.object.published\")) VIRTUAL,\n  accountUrl TEXT GENERATED ALWAYS AS (json_extract(json, \"$.actor.url\")) VIRTUAL,\n  accountAvatarUrl TEXT GENERATED ALWAYS AS (json_extract(json, \"$.actor.icon.url\")) VIRTUAL,\n  accountName TEXT GENERATED ALWAYS AS (json_extract(json, \"$.actor.name\")) VIRTUAL\n);\nCREATE INDEX `statuses_id_publishedAt` on `statuses` (`id`, `publishedAt`);\nCREATE INDEX `statuses_summary` on `statuses` (`summary`);\nCREATE INDEX `statuses_content` on `statuses` (`content`);\n</code></pre>\n<p>The only columns actually stored on disk for this table are <code>json</code> and <code>created_at</code> - the rest are all virtual generated columns based on properties extracted from the JSON data. Since these are virtual, <a href=\"https://www.sqlite.org/gencol.html#virtual_versus_stored_columns\">the value is computed on read</a>.</p>\n<p>But, two neat things about this: You can <a href=\"https://www.sqlite.org/gencol.html#capabilities\">create indexes on generated columns</a> (which <em>are</em> stored on disk) and you can <a href=\"https://www.sqlite.org/gencol.html#virtual_versus_stored_columns\">add new virtual generated columns whenever you want</a>. </p>\n<p>Dropping columns is a different story, but being able to add new things is at least a help for iteration without having to resort to <a href=\"https://www.sqlite.org/lang_altertable.html#making_other_kinds_of_table_schema_changes\">the dance of creating a new table and copying data</a> that SQLite often requires.</p>\n<p>For full text search, I cobbled this together:</p>\n<pre><code class=\"language-sql\">CREATE VIRTUAL TABLE statusesSearch\n  USING fts5(id UNINDEXED, summary, content);\n\nCREATE TRIGGER statuses_insert AFTER INSERT ON statuses BEGIN\n  INSERT INTO\n    statusesSearch (id, summary, content)\n  VALUES\n    (new.id, new.summary, new.content);\nEND;\n\nCREATE TRIGGER statuses_delete AFTER DELETE ON statuses BEGIN\n  DELETE FROM statusesSearch WHERE id = old.id;\nEND;\n\nCREATE TRIGGER statuses_update AFTER UPDATE ON statuses BEGIN\n  UPDATE statusesSearch\n  SET \n    summary = new.summary,\n    content = new.content\n  WHERE id = new.id;\nEND;\n</code></pre>\n<p>This sets up a <a href=\"https://www.sqlite.org/fts5.html\">Full Text Search</a> table, along with some triggers to keep it in sync with the source of content. Nothing too complicated going on here - although it did take me awhile to work out that this seems like the least-bad way to combine virtual generated columns and full-text search. I'm still learning about this stuff, though, so I may yet discover a better approach.</p>\n<h2 id=\"ingesting-the-json\">Ingesting the JSON</h2>\n<p>With all that in place, I was able to parse <code>outbox.json</code> from an export and iterate through the <code>orderedItems</code> array to just plonk the JSON into the table:</p>\n<pre><code class=\"language-javascript\">await connection.transaction(async (trx) =&gt; {\n    for (const item of outbox.orderedItems) {\n        await trx(\"statuses\")\n          .insert({ json: JSON.stringify(item) })\n          .onConflict(\"id\").merge();\n    }\n});\n</code></pre>\n<p>Of course, this snippet skips over some preamble of parsing JSON and using <a href=\"https://knexjs.org/\">Knex.js</a> to create a database connection. Also, it can help to chunk the items into smaller batches across several transactions.</p>\n<p>That said, the gist of the operation is to just upsert the JSON in the <code>json</code> column, opting to replace whenever a record with the same ID is found. This means the import is idempotent and can be re-run without duplicating data.</p>\n<p>Wrapping many inserts in a transaction is important here: Between transactions is when SQLite does the work to update indexes and run triggers. This occasionally leads to rebalancing trees in indexes and <a href=\"https://www.sqlite.org/fts5.html#the_automerge_configuration_option\">other general housekeeping in FTS5</a>. So, inserting each row individually would incur overhead that dramatically slows down a data import.</p>\n<h2 id=\"searching-the-toots\">Searching the toots</h2>\n<p>Finally, once all this data is shoehorned into the database, I can do full-text searches:</p>\n<pre><code class=\"language-sqlite\">sqlite&gt; .mode line\nsqlite&gt; select id from statusesSearch where content match \"hello world\" limit 10;\n     id = https://mastodon.social/users/lmorchard/statuses/55864/activity\n\n     id = https://hackers.town/users/lmorchard/statuses/107073175679835816/activity\n\n     id = https://hackers.town/users/lmorchard/statuses/109802318069508799/activity\n\n     id = https://hackers.town/users/lmorchard/statuses/109763470744160265/activity\n</code></pre>\n<p>It doesn't look like much, but then I can use this in a join or a subquery to relate back to the main table of imported toots:</p>\n<pre><code class=\"language-sqlite\">sqlite&gt; select\n  id, publishedAt, url, displayName, accountAvatarUrl,\n  json_extract(json, \"$.actor.summary\") as bio,\n  content \nfrom `statuses`\nwhere `statuses`.`id` in (\n    select `id` from `statusesSearch` where `statusesSearch` match \"hello world\"\n)\norder by `statuses`.`publishedAt` desc\nlimit 1;\n\n              id = https://hackers.town/users/lmorchard/statuses/109802318069508799/activity\n     publishedAt = 2023-02-03T18:53:30Z\n             url = https://hackers.town/@lmorchard/109802318069508799\n     displayName = Les Orchard\naccountAvatarUrl = https://hackers.town/system/accounts/avatars/000/136/533/original/1a8c651efe14fcd6.png\n             bio = &lt;p&gt;he / him; semi-hermit in PDX, USA; tinkerer; old adhd cat dad; serial enthusiast; editor-at-large for &lt;a href=\"http://lmorchard.com\" target=\"_blank\" rel=\"nofollow noopener noreferrer\"&gt;&lt;span class=\"invisible\"&gt;http://&lt;/span&gt;&lt;span class=\"\"&gt;lmorchard.com&lt;/span&gt;&lt;span class=\"invisible\"&gt;&lt;/span&gt;&lt;/a&gt;; astra mortemque superare gradatim; tootfinder&lt;/p&gt;\n         content = &lt;p&gt;&lt;span class=\"h-card\"&gt;&lt;a href=\"https://infosec.exchange/@Em0nM4stodon\" class=\"u-url mention\" rel=\"nofollow noopener noreferrer\" target=\"_blank\"&gt;@&lt;span&gt;Em0nM4stodon&lt;/span&gt;&lt;/a&gt;&lt;/span&gt; Personally, Amazon S3 or GitHub Pages.&lt;/p&gt;&lt;p&gt;The former, because I've been using it for 17 years since Amazon released it and it only costs me like $10 per year.&lt;/p&gt;&lt;p&gt;And the latter, because it's also easy (for me) to use git to push out content.&lt;/p&gt;&lt;p&gt;There are more varied options these days, but I've been too lazy / busy to explore much past a hello-world stage.&lt;/p&gt;\n</code></pre>\n<p>Since I'm able to use <code>json_extract()</code> in a select statement, that gives me access to everything in the imported JSON records - whether or not I thought ahead to define a column or index for any particular column. That feels pretty noSQL-ish to me!</p>\n<h2 id=\"next-steps\">Next steps</h2>\n<p>From here, I started tinkering with <a href=\"https://github.com/lmorchard/masto-recall\">a more complex node.js web app</a> to build a user interface for search queries and a bunch more. (Maybe too much more!) But, I'll save talking about that for future blog posts that I'll hopefully write.</p>\n<p>For this post, I just wanted to try jotting down the core ideas of shoehorning a pile of JSON data into a SQLite database. I've got a bunch more to learn about what I can do with this and where I might run into limits. But, in the meantime, this feels like a nicely lightweight way to play with a bunch of my data from the fediverse.</p>\n",
  "body": "**TL;DR**: SQLite has JSON functions, generated columns, and full-text search - which all seems like a perfect mix for ingesting exports from Mastodon for search!\n\n<!--more-->\n\n<figure>\n<img title=\"A mastodon living in a tiny database\" src=\"./cover.png\" class=\"wide\" />\n<figcaption>A mastodon living in a tiny database, based on output generated by <a href=\"https://www.craiyon.com/\">Craiyon</a></figcaption>\n</figure>\n\n## SQlite is more capable than I remembered\n\nI've been watching [Simon Willison's](https://simonwillison.net/) adventures in [SQLite](https://simonwillison.net/tags/sqlite/) and [Datasette](https://simonwillison.net/search/?tag=datasette) for a few years now. I keep meaning to tinker with this stuff myself, but never quite get around to it. Recently, though, a couple of interesting pieces sort of shuffled into place for me:\n\n- I read this blog post by [@dgl@infosec.exchange](https://infosec.exchange/@dgl) on [SQLite as a document database](https://dgl.cx/2020/06/sqlite-json-support) - by combining [json_extract()](https://www.sqlite.org/json1.html#jex) and [generated columns](https://www.sqlite.org/gencol.html) in SQLite, you can build a JSON document database with indexes and all the fun of SQL queries. Just insert blobs of JSON and SQLite takes care of the rest.\n\n- I learned a bit about [full-text search in SQLite](https://www.sqlite.org/fts5.html). With a few triggers, I can build an index table that supports full-text queries against content extracted from JSON in those generated columns.\n\nPreviously, I'd thought PostgreSQL was where I had to go for things like [JSON functions](https://www.postgresql.org/docs/current/functions-json.html) and [full-text search](https://www.postgresql.org/docs/current/textsearch.html)  - I didn't really expect to find that SQLite was this capable.\n\nWhile it's true you can do just about anything with a pile of docker containers, a small project fares much better with an embedded database like SQLite - especially if it's deployed to an environment like [Glitch](https://glitch.com/). A SQLite easily database becomes de facto file format for personal tools.\n\n## Mastodon exports are full of JSON\n\nOkay, so that's some cool database stuff. What do I want to put in it?\n\nWell, I've accumulated a few thousand posts between Mastodon instances over the years. Support for search is rather uneven, depending on the Mastodon instance. So, I grabbed some JSON exports from my accounts and thought it might be interesting to make them searchable. \n\nI've tooted the most on three Mastodon instances: [mastodon.social](https://mastodon.social/@lmorchard), [toot.cafe](https://toot.cafe/@lmorchard/), and [hackers.town](https://hackers.town/@lmorchard). I was active for a few months on my own self-hosted instance at [toot.lmorchard.com](https://toot.lmorchard.com/@lmorchard), but I accidentally nuked the database at some point and lost all my content. (I guess I should have grabbed an export earlier!)\n\nAn account export from Mastodon takes the form of a compressed tarball with media uploads and several collections of data in JSON. Of particular interest is a file named `outbox.json`: This contains all the account's posts and boosts, expressed in [ActivityStreams 2.0 JSON format with Mastodon extensions](https://docs.joinmastodon.org/spec/activitypub/). (Notably, *not* in \"ActivityPub format\" [as tantek recently pointed out](https://tantek.com/2023/112/t2/account-migration-post-blog-archive-format).)\n\n## Tinkering with tables\n\nOnce I had the data, I tinkered with table schemas and came up with this mess:\n\n```sql\nCREATE TABLE statuses (\n  json TEXT,\n  created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n  id TEXT GENERATED ALWAYS AS (json_extract(json, \"$.id\")) VIRTUAL UNIQUE,\n  type TEXT GENERATED ALWAYS AS (json_extract(json, \"$.type\")) VIRTUAL,\n  url TEXT GENERATED ALWAYS AS (json_extract(json, \"$.object.url\")) VIRTUAL,\n  summary TEXT GENERATED ALWAYS AS (json_extract(json, \"$.object.summary\")) VIRTUAL,\n  content TEXT GENERATED ALWAYS AS (json_extract(json, \"$.object.content\")) VIRTUAL,\n  displayName TEXT GENERATED ALWAYS AS (json_extract(json, \"$.actor.name\")) VIRTUAL,\n  publishedAt DATETIME GENERATED ALWAYS AS (json_extract(json, \"$.object.published\")) VIRTUAL,\n  accountUrl TEXT GENERATED ALWAYS AS (json_extract(json, \"$.actor.url\")) VIRTUAL,\n  accountAvatarUrl TEXT GENERATED ALWAYS AS (json_extract(json, \"$.actor.icon.url\")) VIRTUAL,\n  accountName TEXT GENERATED ALWAYS AS (json_extract(json, \"$.actor.name\")) VIRTUAL\n);\nCREATE INDEX `statuses_id_publishedAt` on `statuses` (`id`, `publishedAt`);\nCREATE INDEX `statuses_summary` on `statuses` (`summary`);\nCREATE INDEX `statuses_content` on `statuses` (`content`);\n```\n\nThe only columns actually stored on disk for this table are `json` and `created_at` - the rest are all virtual generated columns based on properties extracted from the JSON data. Since these are virtual, [the value is computed on read](https://www.sqlite.org/gencol.html#virtual_versus_stored_columns).\n\nBut, two neat things about this: You can [create indexes on generated columns](https://www.sqlite.org/gencol.html#capabilities) (which *are* stored on disk) and you can [add new virtual generated columns whenever you want](https://www.sqlite.org/gencol.html#virtual_versus_stored_columns). \n\nDropping columns is a different story, but being able to add new things is at least a help for iteration without having to resort to [the dance of creating a new table and copying data](https://www.sqlite.org/lang_altertable.html#making_other_kinds_of_table_schema_changes) that SQLite often requires.\n\nFor full text search, I cobbled this together:\n\n```sql\nCREATE VIRTUAL TABLE statusesSearch\n  USING fts5(id UNINDEXED, summary, content);\n\nCREATE TRIGGER statuses_insert AFTER INSERT ON statuses BEGIN\n  INSERT INTO\n    statusesSearch (id, summary, content)\n  VALUES\n    (new.id, new.summary, new.content);\nEND;\n\nCREATE TRIGGER statuses_delete AFTER DELETE ON statuses BEGIN\n  DELETE FROM statusesSearch WHERE id = old.id;\nEND;\n\nCREATE TRIGGER statuses_update AFTER UPDATE ON statuses BEGIN\n  UPDATE statusesSearch\n  SET \n    summary = new.summary,\n    content = new.content\n  WHERE id = new.id;\nEND;\n```\n\nThis sets up a [Full Text Search](https://www.sqlite.org/fts5.html) table, along with some triggers to keep it in sync with the source of content. Nothing too complicated going on here - although it did take me awhile to work out that this seems like the least-bad way to combine virtual generated columns and full-text search. I'm still learning about this stuff, though, so I may yet discover a better approach.\n\n## Ingesting the JSON\n\nWith all that in place, I was able to parse `outbox.json` from an export and iterate through the `orderedItems` array to just plonk the JSON into the table:\n\n```javascript\nawait connection.transaction(async (trx) => {\n\tfor (const item of outbox.orderedItems) {\n\t\tawait trx(\"statuses\")\n\t\t  .insert({ json: JSON.stringify(item) })\n\t\t  .onConflict(\"id\").merge();\n\t}\n});\n```\n\nOf course, this snippet skips over some preamble of parsing JSON and using [Knex.js](https://knexjs.org/) to create a database connection. Also, it can help to chunk the items into smaller batches across several transactions.\n\nThat said, the gist of the operation is to just upsert the JSON in the `json` column, opting to replace whenever a record with the same ID is found. This means the import is idempotent and can be re-run without duplicating data.\n\nWrapping many inserts in a transaction is important here: Between transactions is when SQLite does the work to update indexes and run triggers. This occasionally leads to rebalancing trees in indexes and [other general housekeeping in FTS5](https://www.sqlite.org/fts5.html#the_automerge_configuration_option). So, inserting each row individually would incur overhead that dramatically slows down a data import.\n\n## Searching the toots\n\nFinally, once all this data is shoehorned into the database, I can do full-text searches:\n\n```sqlite\nsqlite> .mode line\nsqlite> select id from statusesSearch where content match \"hello world\" limit 10;\n     id = https://mastodon.social/users/lmorchard/statuses/55864/activity\n\n     id = https://hackers.town/users/lmorchard/statuses/107073175679835816/activity\n\n     id = https://hackers.town/users/lmorchard/statuses/109802318069508799/activity\n\n     id = https://hackers.town/users/lmorchard/statuses/109763470744160265/activity\n```\n\nIt doesn't look like much, but then I can use this in a join or a subquery to relate back to the main table of imported toots:\n\n```sqlite\nsqlite> select\n  id, publishedAt, url, displayName, accountAvatarUrl,\n  json_extract(json, \"$.actor.summary\") as bio,\n  content \nfrom `statuses`\nwhere `statuses`.`id` in (\n    select `id` from `statusesSearch` where `statusesSearch` match \"hello world\"\n)\norder by `statuses`.`publishedAt` desc\nlimit 1;\n\n              id = https://hackers.town/users/lmorchard/statuses/109802318069508799/activity\n     publishedAt = 2023-02-03T18:53:30Z\n             url = https://hackers.town/@lmorchard/109802318069508799\n     displayName = Les Orchard\naccountAvatarUrl = https://hackers.town/system/accounts/avatars/000/136/533/original/1a8c651efe14fcd6.png\n             bio = <p>he / him; semi-hermit in PDX, USA; tinkerer; old adhd cat dad; serial enthusiast; editor-at-large for <a href=\"http://lmorchard.com\" target=\"_blank\" rel=\"nofollow noopener noreferrer\"><span class=\"invisible\">http://</span><span class=\"\">lmorchard.com</span><span class=\"invisible\"></span></a>; astra mortemque superare gradatim; tootfinder</p>\n         content = <p><span class=\"h-card\"><a href=\"https://infosec.exchange/@Em0nM4stodon\" class=\"u-url mention\" rel=\"nofollow noopener noreferrer\" target=\"_blank\">@<span>Em0nM4stodon</span></a></span> Personally, Amazon S3 or GitHub Pages.</p><p>The former, because I've been using it for 17 years since Amazon released it and it only costs me like $10 per year.</p><p>And the latter, because it's also easy (for me) to use git to push out content.</p><p>There are more varied options these days, but I've been too lazy / busy to explore much past a hello-world stage.</p>\n```\n\nSince I'm able to use `json_extract()` in a select statement, that gives me access to everything in the imported JSON records - whether or not I thought ahead to define a column or index for any particular column. That feels pretty noSQL-ish to me!\n\n## Next steps\n\nFrom here, I started tinkering with [a more complex node.js web app](https://github.com/lmorchard/masto-recall) to build a user interface for search queries and a bunch more. (Maybe too much more!) But, I'll save talking about that for future blog posts that I'll hopefully write.\n\nFor this post, I just wanted to try jotting down the core ideas of shoehorning a pile of JSON data into a SQLite database. I've got a bunch more to learn about what I can do with this and where I might run into limits. But, in the meantime, this feels like a nicely lightweight way to play with a bunch of my data from the fediverse.\n",
  "parentPath": "./content/posts/archives/2023/2023-05-12-toots-in-sqlite",
  "path": "2023/05/12/toots-in-sqlite",
  "thumbnail": "/2023/05/12/toots-in-sqlite/cover.png",
  "summary": "TL;DR: SQLite has JSON functions, generated columns, and full-text search - which all seems like a perfect mix for ingesting exports from Mastodon for search!",
  "needsBuild": true,
  "prevPostPath": "2023/02/06/the-machine-is-grieving",
  "prevPostTitle": "The Machine is Grieving",
  "nextPostPath": "2024/03/11/dance-for-the-bots",
  "nextPostTitle": "Dance like the bots aren't watching?"
}