<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>SQLite Hands-on 操作路線 on Tarragon</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/sqlite/hands-on/</link><description>Recent content in SQLite Hands-on 操作路線 on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Thu, 21 May 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/backend/01-database/vendors/sqlite/hands-on/index.xml" rel="self" type="application/rss+xml"/><item><title>SQLite Backup Restore Drill</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/sqlite/hands-on/backup-restore-drill/</link><pubDate>Thu, 21 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/sqlite/hands-on/backup-restore-drill/</guid><description>&lt;p>SQLite backup restore drill 的核心責任是證明單檔 database 可以被一致備份並還原。這篇承接 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/sqlite/file-lifecycle-backup-boundary/" data-link-title="SQLite file lifecycle 與 backup boundary" data-link-desc="把 SQLite 單檔案正式狀態拆成 WAL、backup API、restore drill、corruption recovery 與操作責任邊界">File lifecycle / backup boundary&lt;/a>，把備份從概念轉成 artifact、validation query 與 RPO / RTO note。&lt;/p>
&lt;p>本文的驗收標準是：你能從 live &lt;code>app.db&lt;/code> 建立 backup，將它還原到隔離路徑，通過 &lt;code>integrity_check&lt;/code> 與核心查詢，並記錄 restore duration。&lt;/p>
&lt;h2 id="prepare-source">Prepare Source&lt;/h2>
&lt;p>Prepare source 的核心責任是建立一個有 WAL 與資料變化的 live database。若你已跑過 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/sqlite/hands-on/local-file-quickstart/" data-link-title="SQLite Local File Quickstart" data-link-desc="SQLite local .db file、schema、seed data、PRAGMA baseline、query sample 與 cleanup 的操作說明">local file quickstart&lt;/a>，可以直接沿用 &lt;code>/tmp/sqlite-lab/app.db&lt;/code>。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">mkdir -p /tmp/sqlite-lab/backup /tmp/sqlite-lab/restore
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="nb">cd&lt;/span> /tmp/sqlite-lab
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">sqlite3 app.db &lt;span class="s2">&amp;#34;PRAGMA journal_mode = WAL;&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">sqlite3 app.db &lt;span class="s2">&amp;#34;INSERT INTO ledger_entries(account_id, amount_cents, idempotency_key, created_at) VALUES (2, 100, &amp;#39;backup-drill-1&amp;#39;, &amp;#39;2026-05-21T01:00:00Z&amp;#39;);&amp;#34;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這一步讓 source database 有新的資料。後續會用 backup snapshot 和 source 後續寫入做對照。&lt;/p>
&lt;h2 id="create-backup">Create Backup&lt;/h2>
&lt;p>Create backup 的核心責任是用 SQLite-aware 方法建立一致 snapshot。SQLite CLI &lt;code>.backup&lt;/code> 會透過 SQLite backup API 產出目標檔案。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">sqlite3 app.db &lt;span class="s2">&amp;#34;.backup &amp;#39;backup/app-backup.db&amp;#39;&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">sqlite3 backup/app-backup.db &lt;span class="s2">&amp;#34;PRAGMA integrity_check;&amp;#34;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>預期 &lt;code>integrity_check&lt;/code> 輸出 &lt;code>ok&lt;/code>。這是最小 backup evidence。&lt;/p>
&lt;p>&lt;code>VACUUM INTO&lt;/code> 也可以產出 compact copy，適合想順便整理檔案大小的情境。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">sqlite3 app.db &lt;span class="s2">&amp;#34;VACUUM INTO &amp;#39;backup/app-vacuum-copy.db&amp;#39;;&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">sqlite3 backup/app-vacuum-copy.db &lt;span class="s2">&amp;#34;PRAGMA integrity_check;&amp;#34;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>.backup&lt;/code> 與 &lt;code>VACUUM INTO&lt;/code> 都要在 runbook 中標明使用條件、耗時、目標路徑與失敗處理。正式環境還要記錄檔案大小、checksum 與 storage retention。&lt;/p>
&lt;h2 id="mutate-source-after-backup">Mutate Source After Backup&lt;/h2>
&lt;p>Mutate source 的核心責任是確認 backup 是時間點 snapshot。備份後對 source 寫入新資料，再用 restore 驗證 backup 保持原時間點。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">sqlite3 app.db &lt;span class="s2">&amp;#34;INSERT INTO ledger_entries(account_id, amount_cents, idempotency_key, created_at) VALUES (1, 777, &amp;#39;after-backup-write&amp;#39;, &amp;#39;2026-05-21T01:05:00Z&amp;#39;);&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">sqlite3 app.db &lt;span class="s2">&amp;#34;SELECT COUNT(*) FROM ledger_entries;&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">sqlite3 backup/app-backup.db &lt;span class="s2">&amp;#34;SELECT COUNT(*) FROM ledger_entries;&amp;#34;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Source count 應比 backup count 多一筆。這個差異讓 RPO 討論具體化：backup 只保護到它建立的時間點。&lt;/p>
&lt;h2 id="restore-isolated-copy">Restore Isolated Copy&lt;/h2>
&lt;p>Restore isolated copy 的核心責任是避免把演練和 source 混在一起。把 backup 複製到 restore path，所有 validation 都對 restore file 執行。&lt;/p></description><content:encoded><![CDATA[<p>SQLite backup restore drill 的核心責任是證明單檔 database 可以被一致備份並還原。這篇承接 <a href="/blog/backend/01-database/vendors/sqlite/file-lifecycle-backup-boundary/" data-link-title="SQLite file lifecycle 與 backup boundary" data-link-desc="把 SQLite 單檔案正式狀態拆成 WAL、backup API、restore drill、corruption recovery 與操作責任邊界">File lifecycle / backup boundary</a>，把備份從概念轉成 artifact、validation query 與 RPO / RTO note。</p>
<p>本文的驗收標準是：你能從 live <code>app.db</code> 建立 backup，將它還原到隔離路徑，通過 <code>integrity_check</code> 與核心查詢，並記錄 restore duration。</p>
<h2 id="prepare-source">Prepare Source</h2>
<p>Prepare source 的核心責任是建立一個有 WAL 與資料變化的 live database。若你已跑過 <a href="/blog/backend/01-database/vendors/sqlite/hands-on/local-file-quickstart/" data-link-title="SQLite Local File Quickstart" data-link-desc="SQLite local .db file、schema、seed data、PRAGMA baseline、query sample 與 cleanup 的操作說明">local file quickstart</a>，可以直接沿用 <code>/tmp/sqlite-lab/app.db</code>。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">mkdir -p /tmp/sqlite-lab/backup /tmp/sqlite-lab/restore
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nb">cd</span> /tmp/sqlite-lab
</span></span><span class="line"><span class="ln">3</span><span class="cl">sqlite3 app.db <span class="s2">&#34;PRAGMA journal_mode = WAL;&#34;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">sqlite3 app.db <span class="s2">&#34;INSERT INTO ledger_entries(account_id, amount_cents, idempotency_key, created_at) VALUES (2, 100, &#39;backup-drill-1&#39;, &#39;2026-05-21T01:00:00Z&#39;);&#34;</span></span></span></code></pre></div><p>這一步讓 source database 有新的資料。後續會用 backup snapshot 和 source 後續寫入做對照。</p>
<h2 id="create-backup">Create Backup</h2>
<p>Create backup 的核心責任是用 SQLite-aware 方法建立一致 snapshot。SQLite CLI <code>.backup</code> 會透過 SQLite backup API 產出目標檔案。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">sqlite3 app.db <span class="s2">&#34;.backup &#39;backup/app-backup.db&#39;&#34;</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">sqlite3 backup/app-backup.db <span class="s2">&#34;PRAGMA integrity_check;&#34;</span></span></span></code></pre></div><p>預期 <code>integrity_check</code> 輸出 <code>ok</code>。這是最小 backup evidence。</p>
<p><code>VACUUM INTO</code> 也可以產出 compact copy，適合想順便整理檔案大小的情境。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">sqlite3 app.db <span class="s2">&#34;VACUUM INTO &#39;backup/app-vacuum-copy.db&#39;;&#34;</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">sqlite3 backup/app-vacuum-copy.db <span class="s2">&#34;PRAGMA integrity_check;&#34;</span></span></span></code></pre></div><p><code>.backup</code> 與 <code>VACUUM INTO</code> 都要在 runbook 中標明使用條件、耗時、目標路徑與失敗處理。正式環境還要記錄檔案大小、checksum 與 storage retention。</p>
<h2 id="mutate-source-after-backup">Mutate Source After Backup</h2>
<p>Mutate source 的核心責任是確認 backup 是時間點 snapshot。備份後對 source 寫入新資料，再用 restore 驗證 backup 保持原時間點。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">sqlite3 app.db <span class="s2">&#34;INSERT INTO ledger_entries(account_id, amount_cents, idempotency_key, created_at) VALUES (1, 777, &#39;after-backup-write&#39;, &#39;2026-05-21T01:05:00Z&#39;);&#34;</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">sqlite3 app.db <span class="s2">&#34;SELECT COUNT(*) FROM ledger_entries;&#34;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">sqlite3 backup/app-backup.db <span class="s2">&#34;SELECT COUNT(*) FROM ledger_entries;&#34;</span></span></span></code></pre></div><p>Source count 應比 backup count 多一筆。這個差異讓 RPO 討論具體化：backup 只保護到它建立的時間點。</p>
<h2 id="restore-isolated-copy">Restore Isolated Copy</h2>
<p>Restore isolated copy 的核心責任是避免把演練和 source 混在一起。把 backup 複製到 restore path，所有 validation 都對 restore file 執行。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl">cp backup/app-backup.db restore/app-restored.db
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">sqlite3 restore/app-restored.db <span class="s2">&#34;PRAGMA integrity_check;&#34;</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">sqlite3 restore/app-restored.db <span class="s">&lt;&lt;&#39;SQL&#39;
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="s">.headers on
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="s">.mode column
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="s">SELECT account_id, SUM(amount_cents) AS balance_cents
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="s">FROM ledger_entries
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="s">GROUP BY account_id
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="s">ORDER BY account_id;
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="s">SQL</span></span></span></code></pre></div><p>正式 restore drill 還要啟動 application 指向 <code>restore/app-restored.db</code>，跑核心 read/write smoke test。若 application 需要 migration，也要確認 restore file 的 <code>PRAGMA user_version</code> 與 app version 相容。</p>
<h2 id="rpo--rto-note">RPO / RTO Note</h2>
<p>RPO / RTO note 的核心責任是把演練結果轉成服務承諾。RPO 是可接受資料遺失窗口，RTO 是可接受恢復時間。</p>
<table>
  <thead>
      <tr>
          <th>指標</th>
          <th>本 lab 記錄方式</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>RPO</td>
          <td>backup 建立時間到事故時間的資料差距</td>
      </tr>
      <tr>
          <td>RTO</td>
          <td>從取得 backup 到 app smoke test 成功耗時</td>
      </tr>
  </tbody>
</table>
<p>可以用 shell 的 <code>time</code> 記錄 restore duration。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="nb">time</span> sqlite3 restore/app-restored.db <span class="s2">&#34;PRAGMA integrity_check;&#34;</span></span></span></code></pre></div><p>正式服務要把 RPO / RTO 寫進 <a href="/blog/backend/01-database/vendors/sqlite/observability-runbook/" data-link-title="SQLite Observability and Runbook" data-link-desc="SQLite production runbook、backup evidence、WAL growth、busy errors、disk usage、restore drill 與 incident route">observability / runbook</a>。</p>
<h2 id="known-gap">Known Gap</h2>
<p>Known gap 的核心責任是讓 lab 結果誠實。這個 drill 驗證 SQLite-aware backup 與 restore path；它尚未覆蓋 object storage credential、remote retention、large database restore time、encrypted disk、user device support flow 與 legal retention。</p>
<p>完成本篇後，下一步可以進入 <a href="/blog/backend/01-database/vendors/sqlite/hands-on/wal-busy-reproduction/" data-link-title="SQLite WAL Busy Reproduction" data-link-desc="SQLite long transaction、SQLITE_BUSY、busy_timeout、checkpoint growth 與 writer queue 的操作說明">WAL busy reproduction</a> 觀察 writer boundary，或進入 <a href="/blog/backend/01-database/vendors/sqlite/hands-on/migration-fixture-lab/" data-link-title="SQLite Migration Fixture Lab" data-link-desc="SQLite user_version、table rebuild migration、fixture snapshot、rollback note 與 CI evidence 的操作說明">migration fixture lab</a> 建立 schema change evidence。</p>
]]></content:encoded></item><item><title>SQLite D1 / Turso Preview Lab</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/sqlite/hands-on/d1-turso-preview-lab/</link><pubDate>Thu, 21 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/sqlite/hands-on/d1-turso-preview-lab/</guid><description>&lt;p>SQLite D1 / Turso preview lab 的核心責任是把 local SQLite 轉向 edge SQLite product 前的 compatibility gap 找出來。這篇承接 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/sqlite/d1-turso-libsql-comparison/" data-link-title="SQLite D1 / Turso / libSQL Comparison" data-link-desc="Cloudflare D1、Turso、libSQL 與 local SQLite 在 edge、replication、consistency、migration 與 vendor boundary 的比較">D1 / Turso / libSQL Comparison&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/sqlite/migrate-to-d1-turso/" data-link-title="SQLite to D1 / Turso Migration" data-link-desc="SQLite 轉向 Cloudflare D1、Turso / libSQL 的 edge driver、compatibility audit、data movement 與 rollback">SQLite to D1 / Turso Migration&lt;/a>，把 edge migration 變成可回報的 query matrix。&lt;/p>
&lt;p>本文的驗收標準是：你能從 local SQLite 匯出 schema / seed，匯入 D1 或 Turso preview database，跑相同 query set，記錄 unsupported SQL、latency、error mapping 與 rollback route。&lt;/p>
&lt;h2 id="preview-scope">Preview Scope&lt;/h2>
&lt;p>Preview scope 的核心責任是把 lab 限制在 staging / preview。D1 與 Turso 都是平台產品，實際命令會依 CLI version、帳號、region 與專案設定改變；本文提供操作骨架與 evidence 格式，正式命令以官方文件為準。&lt;/p>
&lt;p>官方文件路由：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>產品&lt;/th>
 &lt;th>官方文件&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Cloudflare D1&lt;/td>
 &lt;td>&lt;a href="https://developers.cloudflare.com/d1/">Cloudflare D1 docs&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>D1 limits&lt;/td>
 &lt;td>&lt;a href="https://developers.cloudflare.com/d1/platform/limits/">Cloudflare D1 limits&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Turso&lt;/td>
 &lt;td>&lt;a href="https://docs.turso.tech/">Turso docs&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Turso embedded replicas&lt;/td>
 &lt;td>&lt;a href="https://docs.turso.tech/features/embedded-replicas/introduction">Embedded replicas&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Preview lab 要先確認資料不含 production PII。若 seed data 來自正式資料，先做 masking 或 synthetic data。&lt;/p>
&lt;h2 id="export-local-sqlite">Export Local SQLite&lt;/h2>
&lt;p>Export local SQLite 的核心責任是建立 target platform 的 seed input。沿用 &lt;code>/tmp/sqlite-lab/app.db&lt;/code> 或 migration fixture。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">mkdir -p /tmp/sqlite-edge-lab
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="nb">cd&lt;/span> /tmp/sqlite-edge-lab
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">cp /tmp/sqlite-lab/app.db ./app.db
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">sqlite3 app.db &lt;span class="s2">&amp;#34;.schema&amp;#34;&lt;/span> &amp;gt; schema.sql
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">sqlite3 app.db &lt;span class="s2">&amp;#34;.dump&amp;#34;&lt;/span> &amp;gt; seed.sql
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">sqlite3 app.db &lt;span class="s2">&amp;#34;SELECT COUNT(*) FROM accounts;&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">sqlite3 app.db &lt;span class="s2">&amp;#34;SELECT COUNT(*) FROM ledger_entries;&amp;#34;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>schema.sql&lt;/code> 用來審查 DDL，&lt;code>seed.sql&lt;/code> 用來匯入 preview database。正式 migration 可能要拆 schema / data / index，並處理 target platform limits。&lt;/p>
&lt;h2 id="build-query-matrix">Build Query Matrix&lt;/h2>
&lt;p>Build query matrix 的核心責任是定義 preview 要驗證什麼。query set 要代表產品行為，而非只跑一個 &lt;code>SELECT 1&lt;/code>。&lt;/p></description><content:encoded><![CDATA[<p>SQLite D1 / Turso preview lab 的核心責任是把 local SQLite 轉向 edge SQLite product 前的 compatibility gap 找出來。這篇承接 <a href="/blog/backend/01-database/vendors/sqlite/d1-turso-libsql-comparison/" data-link-title="SQLite D1 / Turso / libSQL Comparison" data-link-desc="Cloudflare D1、Turso、libSQL 與 local SQLite 在 edge、replication、consistency、migration 與 vendor boundary 的比較">D1 / Turso / libSQL Comparison</a> 與 <a href="/blog/backend/01-database/vendors/sqlite/migrate-to-d1-turso/" data-link-title="SQLite to D1 / Turso Migration" data-link-desc="SQLite 轉向 Cloudflare D1、Turso / libSQL 的 edge driver、compatibility audit、data movement 與 rollback">SQLite to D1 / Turso Migration</a>，把 edge migration 變成可回報的 query matrix。</p>
<p>本文的驗收標準是：你能從 local SQLite 匯出 schema / seed，匯入 D1 或 Turso preview database，跑相同 query set，記錄 unsupported SQL、latency、error mapping 與 rollback route。</p>
<h2 id="preview-scope">Preview Scope</h2>
<p>Preview scope 的核心責任是把 lab 限制在 staging / preview。D1 與 Turso 都是平台產品，實際命令會依 CLI version、帳號、region 與專案設定改變；本文提供操作骨架與 evidence 格式，正式命令以官方文件為準。</p>
<p>官方文件路由：</p>
<table>
  <thead>
      <tr>
          <th>產品</th>
          <th>官方文件</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Cloudflare D1</td>
          <td><a href="https://developers.cloudflare.com/d1/">Cloudflare D1 docs</a></td>
      </tr>
      <tr>
          <td>D1 limits</td>
          <td><a href="https://developers.cloudflare.com/d1/platform/limits/">Cloudflare D1 limits</a></td>
      </tr>
      <tr>
          <td>Turso</td>
          <td><a href="https://docs.turso.tech/">Turso docs</a></td>
      </tr>
      <tr>
          <td>Turso embedded replicas</td>
          <td><a href="https://docs.turso.tech/features/embedded-replicas/introduction">Embedded replicas</a></td>
      </tr>
  </tbody>
</table>
<p>Preview lab 要先確認資料不含 production PII。若 seed data 來自正式資料，先做 masking 或 synthetic data。</p>
<h2 id="export-local-sqlite">Export Local SQLite</h2>
<p>Export local SQLite 的核心責任是建立 target platform 的 seed input。沿用 <code>/tmp/sqlite-lab/app.db</code> 或 migration fixture。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">mkdir -p /tmp/sqlite-edge-lab
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nb">cd</span> /tmp/sqlite-edge-lab
</span></span><span class="line"><span class="ln">3</span><span class="cl">cp /tmp/sqlite-lab/app.db ./app.db
</span></span><span class="line"><span class="ln">4</span><span class="cl">sqlite3 app.db <span class="s2">&#34;.schema&#34;</span> &gt; schema.sql
</span></span><span class="line"><span class="ln">5</span><span class="cl">sqlite3 app.db <span class="s2">&#34;.dump&#34;</span> &gt; seed.sql
</span></span><span class="line"><span class="ln">6</span><span class="cl">sqlite3 app.db <span class="s2">&#34;SELECT COUNT(*) FROM accounts;&#34;</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">sqlite3 app.db <span class="s2">&#34;SELECT COUNT(*) FROM ledger_entries;&#34;</span></span></span></code></pre></div><p><code>schema.sql</code> 用來審查 DDL，<code>seed.sql</code> 用來匯入 preview database。正式 migration 可能要拆 schema / data / index，並處理 target platform limits。</p>
<h2 id="build-query-matrix">Build Query Matrix</h2>
<p>Build query matrix 的核心責任是定義 preview 要驗證什麼。query set 要代表產品行為，而非只跑一個 <code>SELECT 1</code>。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">Q1 list account balances
</span></span><span class="line"><span class="ln">2</span><span class="cl">Q2 insert ledger entry with unique idempotency key
</span></span><span class="line"><span class="ln">3</span><span class="cl">Q3 insert duplicate idempotency key and capture error
</span></span><span class="line"><span class="ln">4</span><span class="cl">Q4 foreign key violation
</span></span><span class="line"><span class="ln">5</span><span class="cl">Q5 transaction rollback
</span></span><span class="line"><span class="ln">6</span><span class="cl">Q6 pagination by created_at
</span></span><span class="line"><span class="ln">7</span><span class="cl">Q7 explain / performance sample if platform supports it</span></span></code></pre></div><p>這份 matrix 要保存 expected result。Local SQLite 先跑一次，把 row count、error category、latency baseline 記下來。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">sqlite3 app.db <span class="s">&lt;&lt;&#39;SQL&#39;
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="s">.timer on
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="s">SELECT a.id, a.owner_name, SUM(l.amount_cents) AS balance_cents
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="s">FROM accounts a
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="s">JOIN ledger_entries l ON l.account_id = a.id
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="s">GROUP BY a.id, a.owner_name
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="s">ORDER BY a.id;
</span></span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="s">SQL</span></span></span></code></pre></div><h2 id="import-to-d1-preview">Import to D1 Preview</h2>
<p>Import to D1 preview 的核心責任是驗證 Cloudflare D1 workflow。以下是操作骨架，正式命令與 flags 以 Cloudflare D1 docs 和 Wrangler 版本為準。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># Example shape only. Use your project naming and official Wrangler docs.</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">wrangler d1 create sqlite_edge_preview
</span></span><span class="line"><span class="ln">3</span><span class="cl">wrangler d1 execute sqlite_edge_preview --file<span class="o">=</span>seed.sql
</span></span><span class="line"><span class="ln">4</span><span class="cl">wrangler d1 execute sqlite_edge_preview --command<span class="o">=</span><span class="s2">&#34;SELECT COUNT(*) FROM accounts;&#34;</span></span></span></code></pre></div><p>D1 preview evidence 要記錄：</p>
<table>
  <thead>
      <tr>
          <th>Evidence</th>
          <th>內容</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>CLI version</td>
          <td>Wrangler version、account / project</td>
      </tr>
      <tr>
          <td>Import log</td>
          <td>duration、file size、error</td>
      </tr>
      <tr>
          <td>Query result</td>
          <td>每個 query 的 row count / error</td>
      </tr>
      <tr>
          <td>Limit hit</td>
          <td>D1 limits 是否影響 seed 或 query</td>
      </tr>
      <tr>
          <td>Rollback</td>
          <td>刪除 preview DB 或重建 seed</td>
      </tr>
  </tbody>
</table>
<p>若 seed file 太大或某些 SQL 需要改寫，就把 gap 寫進 compatibility matrix，先保留 production migration 的審查邊界。</p>
<h2 id="import-to-turso-preview">Import to Turso Preview</h2>
<p>Import to Turso preview 的核心責任是驗證 remote database、client SDK 與 embedded replica 行為。以下是操作骨架，正式命令以 Turso docs 與 CLI version 為準。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># Example shape only. Use your org, group, region and official Turso docs.</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">turso db create sqlite-edge-preview
</span></span><span class="line"><span class="ln">3</span><span class="cl">turso db shell sqlite-edge-preview &lt; seed.sql
</span></span><span class="line"><span class="ln">4</span><span class="cl">turso db shell sqlite-edge-preview <span class="s2">&#34;SELECT COUNT(*) FROM accounts;&#34;</span></span></span></code></pre></div><p>Turso preview evidence 要多記 replica freshness。若使用 embedded replica，測試流程要包含 bootstrap、sync、read query、write delegation 與 sync 後 read。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">embedded replica evidence:
</span></span><span class="line"><span class="ln">2</span><span class="cl">  bootstrap duration
</span></span><span class="line"><span class="ln">3</span><span class="cl">  first read latency
</span></span><span class="line"><span class="ln">4</span><span class="cl">  write path
</span></span><span class="line"><span class="ln">5</span><span class="cl">  sync command / interval
</span></span><span class="line"><span class="ln">6</span><span class="cl">  read freshness after write</span></span></code></pre></div><p>Freshness 是 product decision。若 query matrix 只測 remote primary，仍需要追加 embedded replica 的使用者體驗驗證。</p>
<h2 id="compatibility-matrix">Compatibility Matrix</h2>
<p>Compatibility matrix 的核心責任是把 local SQLite 與 edge target 的差異留下來。建議表格欄位如下：</p>
<table>
  <thead>
      <tr>
          <th>Query / operation</th>
          <th>Local SQLite</th>
          <th>D1 preview</th>
          <th>Turso preview</th>
          <th>Decision</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Balance list</td>
          <td>pass</td>
          <td>pass / diff</td>
          <td>pass / diff</td>
          <td>keep / rewrite</td>
      </tr>
      <tr>
          <td>Unique violation</td>
          <td>error class</td>
          <td>error class</td>
          <td>error class</td>
          <td>map error</td>
      </tr>
      <tr>
          <td>FK violation</td>
          <td>error class</td>
          <td>error class</td>
          <td>error class</td>
          <td>enable / validate</td>
      </tr>
      <tr>
          <td>Transaction rollback</td>
          <td>pass</td>
          <td>pass / diff</td>
          <td>pass / diff</td>
          <td>rewrite workflow</td>
      </tr>
      <tr>
          <td>Import seed</td>
          <td>pass</td>
          <td>duration / limit</td>
          <td>duration / limit</td>
          <td>split batch</td>
      </tr>
  </tbody>
</table>
<p>Decision 欄要寫具體下一步。<code>rewrite workflow</code> 代表 application adapter 要改；<code>split batch</code> 代表 migration runbook 要改；<code>map error</code> 代表 repository error classification 要改。</p>
<h2 id="latency-and-cost-sample">Latency and Cost Sample</h2>
<p>Latency and cost sample 的核心責任是避免只看功能相容。Edge SQLite migration 的收益常來自 region latency 或 managed operation，因此 preview 要量測主要使用者區域的 read / write latency。</p>
<p>最小量測：</p>
<ol>
<li>Local baseline latency。</li>
<li>Preview target read latency。</li>
<li>Preview target write latency。</li>
<li>Error rate / retry count。</li>
<li>Estimated request / storage / egress cost。</li>
</ol>
<p>Latency sample 要搭配 freshness。快速讀到舊資料和稍慢讀到最新資料是不同產品體驗；query matrix 要標註哪個 workflow 可以接受 stale read。</p>
<h2 id="rollback-route">Rollback Route</h2>
<p>Rollback route 的核心責任是保留 local SQLite 退路。Preview lab 完成後，要能刪除 preview database、保留 local seed、重跑 local app。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">sqlite3 app.db <span class="s2">&#34;PRAGMA integrity_check;&#34;</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">sqlite3 app.db <span class="s2">&#34;SELECT COUNT(*) FROM accounts;&#34;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">sqlite3 app.db <span class="s2">&#34;SELECT COUNT(*) FROM ledger_entries;&#34;</span></span></span></code></pre></div><p>正式 cutover 的 rollback 還要處理 target-only writes。Preview 階段應避免讓真實使用者寫入 target；若需要 shadow traffic，先用 read-only 或 synthetic write。</p>
<h2 id="completion-note">Completion Note</h2>
<p>Completion note 的核心責任是決定是否進入正式 migration。Lab 完成後應輸出四個 artifact：<code>seed.sql</code>、import log、compatibility matrix、rollback note。</p>
<p>進入正式 migration 的條件：</p>
<ol>
<li>Query matrix 主要 workflow 通過或已有 rewrite plan。</li>
<li>Platform limits 對資料量與 migration time 可接受。</li>
<li>Error mapping 已接到 repository adapter。</li>
<li>Freshness / latency 符合產品需求。</li>
<li>Export / rollback route 已演練。</li>
</ol>
<p>完成本篇後，回到 <a href="/blog/backend/01-database/vendors/sqlite/migrate-to-d1-turso/" data-link-title="SQLite to D1 / Turso Migration" data-link-desc="SQLite 轉向 Cloudflare D1、Turso / libSQL 的 edge driver、compatibility audit、data movement 與 rollback">SQLite to D1 / Turso Migration</a> 補正式 phase plan。</p>
]]></content:encoded></item><item><title>SQLite Local File Quickstart</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/sqlite/hands-on/local-file-quickstart/</link><pubDate>Thu, 21 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/sqlite/hands-on/local-file-quickstart/</guid><description>&lt;p>SQLite local file quickstart 的核心責任是建立後續 backup、WAL、migration 與 fixture lab 共用的 database file。這個 lab 把 SQLite 從抽象服務選型轉成可觀察的檔案、schema、PRAGMA、transaction 與 sidecar artifact。&lt;/p>
&lt;p>本文的驗收標準是：你能建立一個可重建的 &lt;code>app.db&lt;/code>，知道它的 schema version、journal mode、foreign key 設定、seed data 與 cleanup 路徑。&lt;/p>
&lt;h2 id="lab-directory">Lab Directory&lt;/h2>
&lt;p>Lab directory 的核心責任是把 SQLite artifact 放在隔離資料夾，避免和正式檔案混淆。以下命令建立一個可刪除的本地工作區。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">mkdir -p /tmp/sqlite-lab
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="nb">cd&lt;/span> /tmp/sqlite-lab
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">rm -f app.db app.db-wal app.db-shm&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>驗收 artifact 是 &lt;code>/tmp/sqlite-lab/app.db&lt;/code>。後續 lab 可以沿用這個路徑，也可以每次從頭建立。&lt;/p>
&lt;h2 id="baseline-schema">Baseline Schema&lt;/h2>
&lt;p>Baseline schema 的核心責任是建立一組能測 transaction、constraint、index 與 query 的小型資料模型。這裡使用 &lt;code>accounts&lt;/code> 與 &lt;code>ledger_entries&lt;/code>，因為它們能清楚展示 foreign key 與金額 invariant。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">sqlite3 app.db &lt;span class="s">&amp;lt;&amp;lt;&amp;#39;SQL&amp;#39;
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">&lt;span class="s">PRAGMA journal_mode = WAL;
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">&lt;span class="s">PRAGMA foreign_keys = ON;
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">&lt;span class="s">PRAGMA user_version = 1;
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">&lt;span class="s">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">&lt;span class="s">CREATE TABLE accounts (
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="s"> id INTEGER PRIMARY KEY,
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">&lt;span class="s"> owner_name TEXT NOT NULL,
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">&lt;span class="s"> status TEXT NOT NULL CHECK (status IN (&amp;#39;active&amp;#39;, &amp;#39;closed&amp;#39;)),
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="s"> created_at TEXT NOT NULL
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">&lt;span class="s">) STRICT;
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">&lt;span class="s">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl">&lt;span class="s">CREATE TABLE ledger_entries (
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl">&lt;span class="s"> id INTEGER PRIMARY KEY,
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl">&lt;span class="s"> account_id INTEGER NOT NULL REFERENCES accounts(id),
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl">&lt;span class="s"> amount_cents INTEGER NOT NULL CHECK (amount_cents != 0),
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl">&lt;span class="s"> idempotency_key TEXT NOT NULL UNIQUE,
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl">&lt;span class="s"> created_at TEXT NOT NULL
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">19&lt;/span>&lt;span class="cl">&lt;span class="s">) STRICT;
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">20&lt;/span>&lt;span class="cl">&lt;span class="s">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">21&lt;/span>&lt;span class="cl">&lt;span class="s">CREATE INDEX idx_ledger_entries_account_created
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">22&lt;/span>&lt;span class="cl">&lt;span class="s">ON ledger_entries(account_id, created_at);
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">23&lt;/span>&lt;span class="cl">&lt;span class="s">SQL&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這段 schema 的重點是明確資料合約。&lt;code>STRICT&lt;/code>、&lt;code>CHECK&lt;/code>、&lt;code>FOREIGN KEY&lt;/code> 與 &lt;code>UNIQUE&lt;/code> 讓 fixture 更接近正式資料責任，也讓後續 migration lab 有可驗證的 invariant。&lt;/p>
&lt;h2 id="seed-data">Seed Data&lt;/h2>
&lt;p>Seed data 的核心責任是建立可重跑的測試資料。每筆 ledger entry 都有 idempotency key，讓後續 edge / retry 設計可以沿用。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">sqlite3 app.db &lt;span class="s">&amp;lt;&amp;lt;&amp;#39;SQL&amp;#39;
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">&lt;span class="s">PRAGMA foreign_keys = ON;
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">&lt;span class="s">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">&lt;span class="s">BEGIN;
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">&lt;span class="s">INSERT INTO accounts(id, owner_name, status, created_at)
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">&lt;span class="s">VALUES
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="s"> (1, &amp;#39;Ada&amp;#39;, &amp;#39;active&amp;#39;, &amp;#39;2026-05-21T00:00:00Z&amp;#39;),
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">&lt;span class="s"> (2, &amp;#39;Lin&amp;#39;, &amp;#39;active&amp;#39;, &amp;#39;2026-05-21T00:05:00Z&amp;#39;);
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">&lt;span class="s">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="s">INSERT INTO ledger_entries(account_id, amount_cents, idempotency_key, created_at)
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">&lt;span class="s">VALUES
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">&lt;span class="s"> (1, 1200, &amp;#39;seed-ada-credit-1&amp;#39;, &amp;#39;2026-05-21T00:10:00Z&amp;#39;),
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl">&lt;span class="s"> (1, -200, &amp;#39;seed-ada-debit-1&amp;#39;, &amp;#39;2026-05-21T00:12:00Z&amp;#39;),
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl">&lt;span class="s"> (2, 900, &amp;#39;seed-lin-credit-1&amp;#39;, &amp;#39;2026-05-21T00:15:00Z&amp;#39;);
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl">&lt;span class="s">COMMIT;
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl">&lt;span class="s">SQL&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Seed 完成後先跑基本查詢。這一步確認 schema、constraint 與 index 入口都可用。&lt;/p></description><content:encoded><![CDATA[<p>SQLite local file quickstart 的核心責任是建立後續 backup、WAL、migration 與 fixture lab 共用的 database file。這個 lab 把 SQLite 從抽象服務選型轉成可觀察的檔案、schema、PRAGMA、transaction 與 sidecar artifact。</p>
<p>本文的驗收標準是：你能建立一個可重建的 <code>app.db</code>，知道它的 schema version、journal mode、foreign key 設定、seed data 與 cleanup 路徑。</p>
<h2 id="lab-directory">Lab Directory</h2>
<p>Lab directory 的核心責任是把 SQLite artifact 放在隔離資料夾，避免和正式檔案混淆。以下命令建立一個可刪除的本地工作區。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">mkdir -p /tmp/sqlite-lab
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nb">cd</span> /tmp/sqlite-lab
</span></span><span class="line"><span class="ln">3</span><span class="cl">rm -f app.db app.db-wal app.db-shm</span></span></code></pre></div><p>驗收 artifact 是 <code>/tmp/sqlite-lab/app.db</code>。後續 lab 可以沿用這個路徑，也可以每次從頭建立。</p>
<h2 id="baseline-schema">Baseline Schema</h2>
<p>Baseline schema 的核心責任是建立一組能測 transaction、constraint、index 與 query 的小型資料模型。這裡使用 <code>accounts</code> 與 <code>ledger_entries</code>，因為它們能清楚展示 foreign key 與金額 invariant。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl">sqlite3 app.db <span class="s">&lt;&lt;&#39;SQL&#39;
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="s">PRAGMA journal_mode = WAL;
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="s">PRAGMA foreign_keys = ON;
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="s">PRAGMA user_version = 1;
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="s">
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="s">CREATE TABLE accounts (
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="s">  id INTEGER PRIMARY KEY,
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="s">  owner_name TEXT NOT NULL,
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="s">  status TEXT NOT NULL CHECK (status IN (&#39;active&#39;, &#39;closed&#39;)),
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="s">  created_at TEXT NOT NULL
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="s">) STRICT;
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="s">
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="s">CREATE TABLE ledger_entries (
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="s">  id INTEGER PRIMARY KEY,
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="s">  account_id INTEGER NOT NULL REFERENCES accounts(id),
</span></span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="s">  amount_cents INTEGER NOT NULL CHECK (amount_cents != 0),
</span></span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="s">  idempotency_key TEXT NOT NULL UNIQUE,
</span></span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="s">  created_at TEXT NOT NULL
</span></span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="s">) STRICT;
</span></span></span><span class="line"><span class="ln">20</span><span class="cl"><span class="s">
</span></span></span><span class="line"><span class="ln">21</span><span class="cl"><span class="s">CREATE INDEX idx_ledger_entries_account_created
</span></span></span><span class="line"><span class="ln">22</span><span class="cl"><span class="s">ON ledger_entries(account_id, created_at);
</span></span></span><span class="line"><span class="ln">23</span><span class="cl"><span class="s">SQL</span></span></span></code></pre></div><p>這段 schema 的重點是明確資料合約。<code>STRICT</code>、<code>CHECK</code>、<code>FOREIGN KEY</code> 與 <code>UNIQUE</code> 讓 fixture 更接近正式資料責任，也讓後續 migration lab 有可驗證的 invariant。</p>
<h2 id="seed-data">Seed Data</h2>
<p>Seed data 的核心責任是建立可重跑的測試資料。每筆 ledger entry 都有 idempotency key，讓後續 edge / retry 設計可以沿用。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl">sqlite3 app.db <span class="s">&lt;&lt;&#39;SQL&#39;
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="s">PRAGMA foreign_keys = ON;
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="s">
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="s">BEGIN;
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="s">INSERT INTO accounts(id, owner_name, status, created_at)
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="s">VALUES
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="s">  (1, &#39;Ada&#39;, &#39;active&#39;, &#39;2026-05-21T00:00:00Z&#39;),
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="s">  (2, &#39;Lin&#39;, &#39;active&#39;, &#39;2026-05-21T00:05:00Z&#39;);
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="s">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="s">INSERT INTO ledger_entries(account_id, amount_cents, idempotency_key, created_at)
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="s">VALUES
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="s">  (1, 1200, &#39;seed-ada-credit-1&#39;, &#39;2026-05-21T00:10:00Z&#39;),
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="s">  (1, -200, &#39;seed-ada-debit-1&#39;, &#39;2026-05-21T00:12:00Z&#39;),
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="s">  (2, 900, &#39;seed-lin-credit-1&#39;, &#39;2026-05-21T00:15:00Z&#39;);
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="s">COMMIT;
</span></span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="s">SQL</span></span></span></code></pre></div><p>Seed 完成後先跑基本查詢。這一步確認 schema、constraint 與 index 入口都可用。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">sqlite3 app.db <span class="s">&lt;&lt;&#39;SQL&#39;
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="s">.headers on
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="s">.mode column
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="s">SELECT a.id, a.owner_name, SUM(l.amount_cents) AS balance_cents
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="s">FROM accounts a
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="s">JOIN ledger_entries l ON l.account_id = a.id
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="s">GROUP BY a.id, a.owner_name
</span></span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="s">ORDER BY a.id;
</span></span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="s">SQL</span></span></span></code></pre></div><p>預期輸出應顯示 Ada 餘額 <code>1000</code>，Lin 餘額 <code>900</code>。</p>
<h2 id="pragma-snapshot">PRAGMA Snapshot</h2>
<p>PRAGMA snapshot 的核心責任是把連線設定變成 evidence。SQLite 的部分設定與 connection 有關，因此 lab 要明確查出當前狀態。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">sqlite3 app.db <span class="s">&lt;&lt;&#39;SQL&#39;
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="s">.headers on
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="s">.mode column
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="s">PRAGMA journal_mode;
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="s">PRAGMA foreign_keys;
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="s">PRAGMA user_version;
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="s">PRAGMA integrity_check;
</span></span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="s">SQL</span></span></span></code></pre></div><p>驗收重點如下：</p>
<table>
  <thead>
      <tr>
          <th>欄位</th>
          <th>期望結果</th>
          <th>意義</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>journal_mode</code></td>
          <td><code>wal</code></td>
          <td>後續可觀察 <code>-wal</code> sidecar</td>
      </tr>
      <tr>
          <td><code>foreign_keys</code></td>
          <td><code>1</code></td>
          <td>constraint 在連線上已啟用</td>
      </tr>
      <tr>
          <td><code>user_version</code></td>
          <td><code>1</code></td>
          <td>migration 起點清楚</td>
      </tr>
      <tr>
          <td>integrity</td>
          <td><code>ok</code></td>
          <td>database file 基本健康</td>
      </tr>
  </tbody>
</table>
<h2 id="transaction-sample">Transaction Sample</h2>
<p>Transaction sample 的核心責任是建立後續 busy / migration lab 的共同語言。SQLite transaction 成功時要同時更新資料與保護 invariant。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">sqlite3 app.db <span class="s">&lt;&lt;&#39;SQL&#39;
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="s">PRAGMA foreign_keys = ON;
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="s">BEGIN IMMEDIATE;
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="s">INSERT INTO ledger_entries(account_id, amount_cents, idempotency_key, created_at)
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="s">VALUES (1, 300, &#39;manual-ada-credit-1&#39;, &#39;2026-05-21T00:20:00Z&#39;);
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="s">COMMIT;
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="s">SQL</span></span></span></code></pre></div><p><code>BEGIN IMMEDIATE</code> 會提早取得 write lock。這讓後續 <a href="/blog/backend/01-database/vendors/sqlite/hands-on/wal-busy-reproduction/" data-link-title="SQLite WAL Busy Reproduction" data-link-desc="SQLite long transaction、SQLITE_BUSY、busy_timeout、checkpoint growth 與 writer queue 的操作說明">WAL busy reproduction</a> 可以直接展示 single writer boundary。</p>
<h2 id="file-artifact-check">File Artifact Check</h2>
<p>File artifact check 的核心責任是讓讀者看到 SQLite 由 <code>.db</code> 與可能存在的 sidecar 共同構成。WAL mode 可能建立 <code>-wal</code> 與 <code>-shm</code> sidecar，backup / copy / restore runbook 要理解這些檔案。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">ls -lh app.db app.db-wal app.db-shm</span></span></code></pre></div><p>若 sidecar 暫時未出現，可以再寫入一筆資料或保持連線開啟。Sidecar 是否存在取決於 WAL 狀態、checkpoint 與 connection lifecycle。</p>
<h2 id="cleanup">Cleanup</h2>
<p>Cleanup 的核心責任是讓 lab 可以重跑。若要重新開始，刪除 database 與 sidecar。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">rm -f /tmp/sqlite-lab/app.db /tmp/sqlite-lab/app.db-wal /tmp/sqlite-lab/app.db-shm</span></span></code></pre></div><p>完成本篇後，下一步可以進入 <a href="/blog/backend/01-database/vendors/sqlite/hands-on/backup-restore-drill/" data-link-title="SQLite Backup Restore Drill" data-link-desc="SQLite .backup、VACUUM INTO、restore validation、sidecar file handling 與 RPO / RTO note 的操作說明">backup restore drill</a> 或 <a href="/blog/backend/01-database/vendors/sqlite/hands-on/wal-busy-reproduction/" data-link-title="SQLite WAL Busy Reproduction" data-link-desc="SQLite long transaction、SQLITE_BUSY、busy_timeout、checkpoint growth 與 writer queue 的操作說明">WAL busy reproduction</a>。</p>
]]></content:encoded></item><item><title>SQLite Migration Fixture Lab</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/sqlite/hands-on/migration-fixture-lab/</link><pubDate>Thu, 21 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/sqlite/hands-on/migration-fixture-lab/</guid><description>&lt;p>SQLite migration fixture lab 的核心責任是把 schema migration 與 test fixture 放進同一個可重建流程。這篇承接 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/sqlite/schema-migration-versioning/" data-link-title="SQLite Schema Migration and Versioning" data-link-desc="SQLite schema migration、user_version、table rebuild、ALTER TABLE 限制、app release compatibility 與 migration evidence">Schema Migration / Versioning&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/sqlite/test-fixture-best-practice/" data-link-title="SQLite Test Fixture Best Practice" data-link-desc="SQLite 作為 test fixture、repository contract test、production dialect gap、seed data、fixture snapshot 與 CI evidence 的操作判準">Test Fixture Best Practice&lt;/a>，讓 migration 有版本、snapshot、validation 與 rollback note。&lt;/p>
&lt;p>本文的驗收標準是：你能建立 v1 fixture、套用 v2 migration、產生 v2 snapshot，並用 validation query 證明資料合約仍成立。&lt;/p>
&lt;h2 id="create-fixture">Create Fixture&lt;/h2>
&lt;p>Create fixture 的核心責任是建立乾淨、可重建的 source fixture。沿用 quickstart schema，或重新建立一份 fixture DB。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">mkdir -p /tmp/sqlite-fixture-lab
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">&lt;span class="nb">cd&lt;/span> /tmp/sqlite-fixture-lab
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">rm -f fixture-v1.db fixture-v2.db
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">sqlite3 fixture-v1.db &lt;span class="s">&amp;lt;&amp;lt;&amp;#39;SQL&amp;#39;
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">&lt;span class="s">PRAGMA foreign_keys = ON;
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">&lt;span class="s">PRAGMA user_version = 1;
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="s">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">&lt;span class="s">CREATE TABLE accounts (
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">&lt;span class="s"> id INTEGER PRIMARY KEY,
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="s"> owner_name TEXT NOT NULL,
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">&lt;span class="s"> status TEXT NOT NULL CHECK (status IN (&amp;#39;active&amp;#39;, &amp;#39;closed&amp;#39;)),
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">&lt;span class="s"> created_at TEXT NOT NULL
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl">&lt;span class="s">) STRICT;
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl">&lt;span class="s">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl">&lt;span class="s">CREATE TABLE ledger_entries (
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl">&lt;span class="s"> id INTEGER PRIMARY KEY,
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl">&lt;span class="s"> account_id INTEGER NOT NULL REFERENCES accounts(id),
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl">&lt;span class="s"> amount_cents INTEGER NOT NULL CHECK (amount_cents != 0),
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">19&lt;/span>&lt;span class="cl">&lt;span class="s"> idempotency_key TEXT NOT NULL UNIQUE,
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">20&lt;/span>&lt;span class="cl">&lt;span class="s"> created_at TEXT NOT NULL
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">21&lt;/span>&lt;span class="cl">&lt;span class="s">) STRICT;
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">22&lt;/span>&lt;span class="cl">&lt;span class="s">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">23&lt;/span>&lt;span class="cl">&lt;span class="s">INSERT INTO accounts VALUES (1, &amp;#39;Ada&amp;#39;, &amp;#39;active&amp;#39;, &amp;#39;2026-05-21T00:00:00Z&amp;#39;);
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">24&lt;/span>&lt;span class="cl">&lt;span class="s">INSERT INTO ledger_entries(account_id, amount_cents, idempotency_key, created_at)
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">25&lt;/span>&lt;span class="cl">&lt;span class="s">VALUES (1, 1000, &amp;#39;fixture-v1-ada&amp;#39;, &amp;#39;2026-05-21T00:10:00Z&amp;#39;);
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">26&lt;/span>&lt;span class="cl">&lt;span class="s">SQL&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這個 fixture 是 v1 source of truth。CI 可以每次從 SQL 重建，也可以保存 &lt;code>fixture-v1.db&lt;/code> 作為 binary fixture；兩者都要有版本與 checksum。&lt;/p></description><content:encoded><![CDATA[<p>SQLite migration fixture lab 的核心責任是把 schema migration 與 test fixture 放進同一個可重建流程。這篇承接 <a href="/blog/backend/01-database/vendors/sqlite/schema-migration-versioning/" data-link-title="SQLite Schema Migration and Versioning" data-link-desc="SQLite schema migration、user_version、table rebuild、ALTER TABLE 限制、app release compatibility 與 migration evidence">Schema Migration / Versioning</a> 與 <a href="/blog/backend/01-database/vendors/sqlite/test-fixture-best-practice/" data-link-title="SQLite Test Fixture Best Practice" data-link-desc="SQLite 作為 test fixture、repository contract test、production dialect gap、seed data、fixture snapshot 與 CI evidence 的操作判準">Test Fixture Best Practice</a>，讓 migration 有版本、snapshot、validation 與 rollback note。</p>
<p>本文的驗收標準是：你能建立 v1 fixture、套用 v2 migration、產生 v2 snapshot，並用 validation query 證明資料合約仍成立。</p>
<h2 id="create-fixture">Create Fixture</h2>
<p>Create fixture 的核心責任是建立乾淨、可重建的 source fixture。沿用 quickstart schema，或重新建立一份 fixture DB。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl">mkdir -p /tmp/sqlite-fixture-lab
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="nb">cd</span> /tmp/sqlite-fixture-lab
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">rm -f fixture-v1.db fixture-v2.db
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">sqlite3 fixture-v1.db <span class="s">&lt;&lt;&#39;SQL&#39;
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="s">PRAGMA foreign_keys = ON;
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="s">PRAGMA user_version = 1;
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="s">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="s">CREATE TABLE accounts (
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="s">  id INTEGER PRIMARY KEY,
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="s">  owner_name TEXT NOT NULL,
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="s">  status TEXT NOT NULL CHECK (status IN (&#39;active&#39;, &#39;closed&#39;)),
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="s">  created_at TEXT NOT NULL
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="s">) STRICT;
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="s">
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="s">CREATE TABLE ledger_entries (
</span></span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="s">  id INTEGER PRIMARY KEY,
</span></span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="s">  account_id INTEGER NOT NULL REFERENCES accounts(id),
</span></span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="s">  amount_cents INTEGER NOT NULL CHECK (amount_cents != 0),
</span></span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="s">  idempotency_key TEXT NOT NULL UNIQUE,
</span></span></span><span class="line"><span class="ln">20</span><span class="cl"><span class="s">  created_at TEXT NOT NULL
</span></span></span><span class="line"><span class="ln">21</span><span class="cl"><span class="s">) STRICT;
</span></span></span><span class="line"><span class="ln">22</span><span class="cl"><span class="s">
</span></span></span><span class="line"><span class="ln">23</span><span class="cl"><span class="s">INSERT INTO accounts VALUES (1, &#39;Ada&#39;, &#39;active&#39;, &#39;2026-05-21T00:00:00Z&#39;);
</span></span></span><span class="line"><span class="ln">24</span><span class="cl"><span class="s">INSERT INTO ledger_entries(account_id, amount_cents, idempotency_key, created_at)
</span></span></span><span class="line"><span class="ln">25</span><span class="cl"><span class="s">VALUES (1, 1000, &#39;fixture-v1-ada&#39;, &#39;2026-05-21T00:10:00Z&#39;);
</span></span></span><span class="line"><span class="ln">26</span><span class="cl"><span class="s">SQL</span></span></span></code></pre></div><p>這個 fixture 是 v1 source of truth。CI 可以每次從 SQL 重建，也可以保存 <code>fixture-v1.db</code> 作為 binary fixture；兩者都要有版本與 checksum。</p>
<h2 id="pre-migration-snapshot">Pre-Migration Snapshot</h2>
<p>Pre-migration snapshot 的核心責任是建立 rollback 起點。正式 migration 前應先保存 source DB。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">sqlite3 fixture-v1.db <span class="s2">&#34;.backup &#39;fixture-v1-before-migration.db&#39;&#34;</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">sqlite3 fixture-v1-before-migration.db <span class="s2">&#34;PRAGMA integrity_check;&#34;</span></span></span></code></pre></div><p>這份 snapshot 代表 migration 失敗時的回退點。CI log 要保留 snapshot path、schema version 與 migration id。</p>
<h2 id="apply-add-column-migration">Apply Add Column Migration</h2>
<p>Apply add column migration 的核心責任是展示低風險 schema change。先複製 v1，再套用 v2。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">cp fixture-v1.db fixture-v2.db
</span></span><span class="line"><span class="ln">2</span><span class="cl">sqlite3 fixture-v2.db <span class="s">&lt;&lt;&#39;SQL&#39;
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="s">PRAGMA foreign_keys = ON;
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="s">BEGIN;
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="s">ALTER TABLE accounts ADD COLUMN email TEXT;
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="s">PRAGMA user_version = 2;
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="s">COMMIT;
</span></span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="s">SQL</span></span></span></code></pre></div><p>驗證 schema version 與新欄位：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">sqlite3 fixture-v2.db <span class="s">&lt;&lt;&#39;SQL&#39;
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="s">PRAGMA user_version;
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="s">PRAGMA table_info(accounts);
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="s">SQL</span></span></span></code></pre></div><p>Add column 是較簡單的 migration。涉及 drop column、rename、constraint 重建或資料 reshape 時，應改用 table rebuild 策略。</p>
<h2 id="table-rebuild-example">Table Rebuild Example</h2>
<p>Table rebuild 的核心責任是展示 SQLite schema migration 的高風險路徑。以下範例把 <code>accounts.status</code> 的 allowed value 加入 <code>suspended</code>，透過新表重建 constraint。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl">sqlite3 fixture-v2.db <span class="s">&lt;&lt;&#39;SQL&#39;
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="s">PRAGMA foreign_keys = OFF;
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="s">BEGIN;
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="s">
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="s">CREATE TABLE accounts_new (
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="s">  id INTEGER PRIMARY KEY,
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="s">  owner_name TEXT NOT NULL,
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="s">  status TEXT NOT NULL CHECK (status IN (&#39;active&#39;, &#39;closed&#39;, &#39;suspended&#39;)),
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="s">  created_at TEXT NOT NULL,
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="s">  email TEXT
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="s">) STRICT;
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="s">
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="s">INSERT INTO accounts_new(id, owner_name, status, created_at, email)
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="s">SELECT id, owner_name, status, created_at, email
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="s">FROM accounts;
</span></span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="s">
</span></span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="s">DROP TABLE accounts;
</span></span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="s">ALTER TABLE accounts_new RENAME TO accounts;
</span></span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="s">
</span></span></span><span class="line"><span class="ln">20</span><span class="cl"><span class="s">PRAGMA user_version = 3;
</span></span></span><span class="line"><span class="ln">21</span><span class="cl"><span class="s">COMMIT;
</span></span></span><span class="line"><span class="ln">22</span><span class="cl"><span class="s">PRAGMA foreign_keys = ON;
</span></span></span><span class="line"><span class="ln">23</span><span class="cl"><span class="s">SQL</span></span></span></code></pre></div><p>Table rebuild 要保存 index、trigger、view 與 FK reference。這個 lab 只有小型 schema；正式 migration 要先列出所有 dependent object。</p>
<h2 id="validation-query">Validation Query</h2>
<p>Validation query 的核心責任是證明 migration 後資料仍符合 domain invariant。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">sqlite3 fixture-v2.db <span class="s">&lt;&lt;&#39;SQL&#39;
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="s">PRAGMA integrity_check;
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="s">PRAGMA foreign_key_check;
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="s">SELECT COUNT(*) AS account_count FROM accounts;
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="s">SELECT COUNT(*) AS ledger_count FROM ledger_entries;
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="s">SELECT SUM(amount_cents) AS total_balance FROM ledger_entries;
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="s">PRAGMA user_version;
</span></span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="s">SQL</span></span></span></code></pre></div><p>驗收結果應包含 integrity <code>ok</code>、foreign key check 空結果、account count <code>1</code>、ledger count <code>1</code>、total balance <code>1000</code>、user version <code>3</code>。</p>
<h2 id="contract-test-hook">Contract Test Hook</h2>
<p>Contract test hook 的核心責任是讓 fixture 進入 CI。語言與 framework 可以不同，但測試要固定做三件事：開啟 FK、確認 schema version、跑 repository contract。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">test setup:
</span></span><span class="line"><span class="ln">2</span><span class="cl">  copy fixture-v2.db to temp path
</span></span><span class="line"><span class="ln">3</span><span class="cl">  open SQLite connection
</span></span><span class="line"><span class="ln">4</span><span class="cl">  execute PRAGMA foreign_keys = ON
</span></span><span class="line"><span class="ln">5</span><span class="cl">  assert PRAGMA user_version = 3
</span></span><span class="line"><span class="ln">6</span><span class="cl">  run repository contract tests</span></span></code></pre></div><p>每個 test 使用 temp copy 可以避免資料污染。需要測 concurrency 時，改用 <a href="/blog/backend/01-database/vendors/sqlite/hands-on/wal-busy-reproduction/" data-link-title="SQLite WAL Busy Reproduction" data-link-desc="SQLite long transaction、SQLITE_BUSY、busy_timeout、checkpoint growth 與 writer queue 的操作說明">WAL busy reproduction</a>。</p>
<h2 id="rollback-note">Rollback Note</h2>
<p>Rollback note 的核心責任是把 migration 失敗時的處理寫清楚。這個 lab 的 rollback 是保留 <code>fixture-v1-before-migration.db</code>，在 migration validation 失敗時停止 release 並保存 failed DB。</p>
<p>正式 runbook 要記錄：</p>
<ol>
<li>Migration id 與 source / target <code>user_version</code>。</li>
<li>Pre-migration backup path。</li>
<li>Validation query 與結果。</li>
<li>Failed DB 保存路徑。</li>
<li>Release block / rollback 條件。</li>
</ol>
<p>完成本篇後，下一步可以讀 <a href="/blog/backend/01-database/vendors/sqlite/migrate-to-postgresql/" data-link-title="SQLite to PostgreSQL Migration" data-link-desc="SQLite 升級到 PostgreSQL 的 driver、schema diff、data copy、dual run、cutover、rollback 與 cleanup">SQLite to PostgreSQL migration</a> 或 <a href="/blog/backend/01-database/vendors/sqlite/migrate-to-d1-turso/" data-link-title="SQLite to D1 / Turso Migration" data-link-desc="SQLite 轉向 Cloudflare D1、Turso / libSQL 的 edge driver、compatibility audit、data movement 與 rollback">SQLite to D1 / Turso migration</a>。</p>
]]></content:encoded></item><item><title>SQLite WAL Busy Reproduction</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/sqlite/hands-on/wal-busy-reproduction/</link><pubDate>Thu, 21 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/sqlite/hands-on/wal-busy-reproduction/</guid><description>&lt;p>SQLite WAL busy reproduction 的核心責任是讓讀者親眼看到 single writer boundary。這篇承接 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/sqlite/wal-concurrency-locking/" data-link-title="SQLite WAL Concurrency and Locking" data-link-desc="SQLite WAL mode 如何降低 reader / writer 衝突、保留 single writer boundary，並用 SQLITE_BUSY、WAL growth、checkpoint 訊號判斷 production 上限">WAL concurrency / locking&lt;/a>，把 &lt;code>SQLITE_BUSY&lt;/code> 從文字警告轉成可重現 timeline。&lt;/p>
&lt;p>本文的驗收標準是：你能用兩個 sqlite3 session 重現 writer contention，觀察 busy timeout 行為，並用 WAL size 與 checkpoint result 連回 production runbook。&lt;/p>
&lt;h2 id="prepare-database">Prepare Database&lt;/h2>
&lt;p>Prepare database 的核心責任是建立可重現的 WAL mode database。若已跑過 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/sqlite/hands-on/local-file-quickstart/" data-link-title="SQLite Local File Quickstart" data-link-desc="SQLite local .db file、schema、seed data、PRAGMA baseline、query sample 與 cleanup 的操作說明">local file quickstart&lt;/a>，可以沿用 &lt;code>/tmp/sqlite-lab/app.db&lt;/code>。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="nb">cd&lt;/span> /tmp/sqlite-lab
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">sqlite3 app.db &lt;span class="s2">&amp;#34;PRAGMA journal_mode = WAL;&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">sqlite3 app.db &lt;span class="s2">&amp;#34;PRAGMA busy_timeout = 1000;&amp;#34;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>確認 WAL mode：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">sqlite3 app.db &lt;span class="s2">&amp;#34;PRAGMA journal_mode;&amp;#34;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>預期輸出是 &lt;code>wal&lt;/code>。&lt;/p>
&lt;h2 id="session-a-hold-writer-lock">Session A: Hold Writer Lock&lt;/h2>
&lt;p>Session A 的核心責任是刻意持有 write transaction。開第一個 terminal，執行：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">sqlite3 app.db&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>在 sqlite prompt 內輸入：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="n">PRAGMA&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">foreign_keys&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">ON&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">BEGIN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">IMMEDIATE&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">INSERT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">INTO&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">ledger_entries&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">account_id&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">amount_cents&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">idempotency_key&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">created_at&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">VALUES&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">11&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;busy-session-a&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;2026-05-21T02:00:00Z&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>先保持 transaction 開啟，暫時延後 &lt;code>COMMIT&lt;/code>。&lt;code>BEGIN IMMEDIATE&lt;/code> 會取得 writer lock，讓第二個 writer 需要等待或失敗。&lt;/p>
&lt;h2 id="session-b-observe-busy">Session B: Observe Busy&lt;/h2>
&lt;p>Session B 的核心責任是用第二個 connection 觀察 single writer boundary。開第二個 terminal，執行：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="nb">cd&lt;/span> /tmp/sqlite-lab
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">sqlite3 app.db &lt;span class="s2">&amp;#34;PRAGMA busy_timeout = 1000; INSERT INTO ledger_entries(account_id, amount_cents, idempotency_key, created_at) VALUES (1, 22, &amp;#39;busy-session-b&amp;#39;, &amp;#39;2026-05-21T02:01:00Z&amp;#39;);&amp;#34;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>預期結果是等待約 1 秒後出現 busy / locked 類錯誤。不同 sqlite3 版本的錯誤文字可能略有差異，核心訊號是第二個 writer 在 Session A commit 前拿不到 write lock。&lt;/p>
&lt;h2 id="release-lock">Release Lock&lt;/h2>
&lt;p>Release lock 的核心責任是確認 contention 來自 writer transaction。回到 Session A，輸入：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="k">COMMIT&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">quit&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>再次執行 Session B 的 insert，這次應成功。&lt;/p></description><content:encoded><![CDATA[<p>SQLite WAL busy reproduction 的核心責任是讓讀者親眼看到 single writer boundary。這篇承接 <a href="/blog/backend/01-database/vendors/sqlite/wal-concurrency-locking/" data-link-title="SQLite WAL Concurrency and Locking" data-link-desc="SQLite WAL mode 如何降低 reader / writer 衝突、保留 single writer boundary，並用 SQLITE_BUSY、WAL growth、checkpoint 訊號判斷 production 上限">WAL concurrency / locking</a>，把 <code>SQLITE_BUSY</code> 從文字警告轉成可重現 timeline。</p>
<p>本文的驗收標準是：你能用兩個 sqlite3 session 重現 writer contention，觀察 busy timeout 行為，並用 WAL size 與 checkpoint result 連回 production runbook。</p>
<h2 id="prepare-database">Prepare Database</h2>
<p>Prepare database 的核心責任是建立可重現的 WAL mode database。若已跑過 <a href="/blog/backend/01-database/vendors/sqlite/hands-on/local-file-quickstart/" data-link-title="SQLite Local File Quickstart" data-link-desc="SQLite local .db file、schema、seed data、PRAGMA baseline、query sample 與 cleanup 的操作說明">local file quickstart</a>，可以沿用 <code>/tmp/sqlite-lab/app.db</code>。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="nb">cd</span> /tmp/sqlite-lab
</span></span><span class="line"><span class="ln">2</span><span class="cl">sqlite3 app.db <span class="s2">&#34;PRAGMA journal_mode = WAL;&#34;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">sqlite3 app.db <span class="s2">&#34;PRAGMA busy_timeout = 1000;&#34;</span></span></span></code></pre></div><p>確認 WAL mode：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">sqlite3 app.db <span class="s2">&#34;PRAGMA journal_mode;&#34;</span></span></span></code></pre></div><p>預期輸出是 <code>wal</code>。</p>
<h2 id="session-a-hold-writer-lock">Session A: Hold Writer Lock</h2>
<p>Session A 的核心責任是刻意持有 write transaction。開第一個 terminal，執行：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">sqlite3 app.db</span></span></code></pre></div><p>在 sqlite prompt 內輸入：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="n">PRAGMA</span><span class="w"> </span><span class="n">foreign_keys</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="k">ON</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="k">BEGIN</span><span class="w"> </span><span class="k">IMMEDIATE</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="k">INSERT</span><span class="w"> </span><span class="k">INTO</span><span class="w"> </span><span class="n">ledger_entries</span><span class="p">(</span><span class="n">account_id</span><span class="p">,</span><span class="w"> </span><span class="n">amount_cents</span><span class="p">,</span><span class="w"> </span><span class="n">idempotency_key</span><span class="p">,</span><span class="w"> </span><span class="n">created_at</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w"></span><span class="k">VALUES</span><span class="w"> </span><span class="p">(</span><span class="mi">1</span><span class="p">,</span><span class="w"> </span><span class="mi">11</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;busy-session-a&#39;</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;2026-05-21T02:00:00Z&#39;</span><span class="p">);</span></span></span></code></pre></div><p>先保持 transaction 開啟，暫時延後 <code>COMMIT</code>。<code>BEGIN IMMEDIATE</code> 會取得 writer lock，讓第二個 writer 需要等待或失敗。</p>
<h2 id="session-b-observe-busy">Session B: Observe Busy</h2>
<p>Session B 的核心責任是用第二個 connection 觀察 single writer boundary。開第二個 terminal，執行：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="nb">cd</span> /tmp/sqlite-lab
</span></span><span class="line"><span class="ln">2</span><span class="cl">sqlite3 app.db <span class="s2">&#34;PRAGMA busy_timeout = 1000; INSERT INTO ledger_entries(account_id, amount_cents, idempotency_key, created_at) VALUES (1, 22, &#39;busy-session-b&#39;, &#39;2026-05-21T02:01:00Z&#39;);&#34;</span></span></span></code></pre></div><p>預期結果是等待約 1 秒後出現 busy / locked 類錯誤。不同 sqlite3 版本的錯誤文字可能略有差異，核心訊號是第二個 writer 在 Session A commit 前拿不到 write lock。</p>
<h2 id="release-lock">Release Lock</h2>
<p>Release lock 的核心責任是確認 contention 來自 writer transaction。回到 Session A，輸入：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">COMMIT</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="p">.</span><span class="n">quit</span></span></span></code></pre></div><p>再次執行 Session B 的 insert，這次應成功。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">sqlite3 app.db <span class="s2">&#34;PRAGMA foreign_keys = ON; INSERT INTO ledger_entries(account_id, amount_cents, idempotency_key, created_at) VALUES (1, 22, &#39;busy-session-b&#39;, &#39;2026-05-21T02:01:00Z&#39;);&#34;</span></span></span></code></pre></div><p>若 idempotency key 已在前一次嘗試中寫入，改成新的 key。這個細節也提醒 production write 要有 idempotency 設計。</p>
<h2 id="busy-timeout-comparison">Busy Timeout Comparison</h2>
<p>Busy timeout comparison 的核心責任是區分「等一下」和「解決 writer contention」。Timeout 可以讓短暫鎖等待更平滑，但長交易仍會造成延遲或失敗。</p>
<p>重開 Session A 並持有 transaction：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">BEGIN</span><span class="w"> </span><span class="k">IMMEDIATE</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="k">INSERT</span><span class="w"> </span><span class="k">INTO</span><span class="w"> </span><span class="n">ledger_entries</span><span class="p">(</span><span class="n">account_id</span><span class="p">,</span><span class="w"> </span><span class="n">amount_cents</span><span class="p">,</span><span class="w"> </span><span class="n">idempotency_key</span><span class="p">,</span><span class="w"> </span><span class="n">created_at</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="k">VALUES</span><span class="w"> </span><span class="p">(</span><span class="mi">1</span><span class="p">,</span><span class="w"> </span><span class="mi">33</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;busy-session-a-long&#39;</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;2026-05-21T02:10:00Z&#39;</span><span class="p">);</span></span></span></code></pre></div><p>在 Session B 測不同 timeout：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="nb">time</span> sqlite3 app.db <span class="s2">&#34;PRAGMA busy_timeout = 5000; INSERT INTO ledger_entries(account_id, amount_cents, idempotency_key, created_at) VALUES (1, 44, &#39;busy-session-b-long&#39;, &#39;2026-05-21T02:11:00Z&#39;);&#34;</span></span></span></code></pre></div><p>若 Session A 在 5 秒內 commit，Session B 可能成功；若持續持有 transaction，Session B 會在 timeout 後失敗。這就是 production 裡 busy timeout 的邊界：它緩衝短鎖，長 transaction 仍要被設計移除。</p>
<h2 id="wal-and-checkpoint">WAL and Checkpoint</h2>
<p>WAL and checkpoint 的核心責任是把 writer activity 和 file artifact 連起來。多做幾次寫入後觀察 sidecar。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">ls -lh app.db app.db-wal app.db-shm
</span></span><span class="line"><span class="ln">2</span><span class="cl">sqlite3 app.db <span class="s2">&#34;PRAGMA wal_checkpoint(PASSIVE);&#34;</span></span></span></code></pre></div><p><code>wal_checkpoint</code> 會回傳 checkpoint 狀態。正式 runbook 要記錄 WAL size、checkpoint duration、reader age 與 checkpoint failure。</p>
<p>可以手動觸發 truncate checkpoint：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">sqlite3 app.db <span class="s2">&#34;PRAGMA wal_checkpoint(TRUNCATE);&#34;</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">ls -lh app.db app.db-wal app.db-shm</span></span></code></pre></div><p>TRUNCATE 適合 lab 觀察。Production 使用時要評估 reader、latency 與維護窗口。</p>
<h2 id="mitigation-note">Mitigation Note</h2>
<p>Mitigation note 的核心責任是把 lab 結果轉成設計策略。看到 <code>SQLITE_BUSY</code> 後，優先檢查 long transaction、未關閉 cursor、背景 job、write burst、parallel test 共用 DB 與 checkpoint pressure。</p>
<p>常見策略包含：</p>
<ol>
<li>縮短 transaction，將外部 API call 移到 transaction 外。</li>
<li>設定合理 busy timeout 與 retry backoff。</li>
<li>把 write queue 序列化，讓高風險 workflow 先排隊。</li>
<li>將 heavy read 移到 snapshot 或 replica。</li>
<li>當 concurrent writer 成為常態，評估 PostgreSQL / MySQL。</li>
</ol>
<p>完成本篇後，下一步讀 <a href="/blog/backend/01-database/vendors/sqlite/observability-runbook/" data-link-title="SQLite Observability and Runbook" data-link-desc="SQLite production runbook、backup evidence、WAL growth、busy errors、disk usage、restore drill 與 incident route">observability / runbook</a> 把 busy、WAL 與 checkpoint 變成正式監控訊號。</p>
]]></content:encoded></item></channel></rss>