FR-167 · 資料表規格 · 2026-11-01

更新站資料表

update-center D1(SQLite)七張表:欄位、約束、索引、誰寫誰讀、現況筆數,以及 migration 對照

更新站的資料庫是 Cloudflare D1(SQLite),庫名 guidant-update-center、綁定名 UPDATE_DB。七張表裡沒有任何客戶名稱欄位:回報只記授權編號、機器指紋、版號、結果、時間(D9)。本頁欄位以 update-center/migrations/0001~0004.sql 為準,寫入與讀取對回 src/ 的函式,現況筆數為 2026-11-01 唯讀實查。

§1

總覽

表 用途 主鍵 寫入者 現況筆數
product_releases 發行版:一版一列,含簽好的清單原文 version CI register-draft、CLI publish、後台撤回/說明/內部先裝 10
instance_version_reports 每台客戶主機現在幾版、最近一次升級結果 (license_id, fingerprint) /v1/check、/v1/report(host) 3
agent_version_reports 每台 Agent 現在幾版、最近一次升級結果(主系統代報) (license_id, agent_fingerprint) /v1/report(agent) 2
release_download_log 誰在什麼時候拿了哪一版的整包下載網址 id 自增 /v1/download-url(整包) 15
license_denylist 拒絕提供更新的授權 license_id 後台 0
signing_keys 發布印樣(公鑰)登記表,只給後台顯示 kid CLI sync-keys、後台 0
ci_tokens CI 用的 scoped token(只存雜湊) id 自增 後台 0

另有 wrangler 管理的 d1_migrations(已套 0001_init.sql~0004_agent_version_reports.sql)。

D1 的型別慣例(0001_init.sql:1-2):

  • SQLite 沒有陣列型別,陣列欄位存 JSON 文字,以 CHECK (json_valid(...)) 守住;讀取用 json_each。
  • 時間一律 ISO 8601 UTC 字串,Worker 以 new Date().toISOString() 寫(帶毫秒與 Z)。
  • 布林存 INTEGER 0/1,以 CHECK (x IN (0, 1)) 守住。
  • 版號存 TEXT。不可用 SQL 字串比大小("1.10.0" < "1.9.0");排序與比較都在 Worker 用 src/versions.ts 做。
§2

關聯圖

表之間沒有宣告外鍵,都是邏輯關聯:

%%{init: {'theme':'base','themeVariables':{'primaryColor':'#E2F0F1','primaryTextColor':'#14201F','primaryBorderColor':'#0E7C86','secondaryColor':'#EEF2F3','secondaryTextColor':'#14201F','tertiaryColor':'#FBFCFC','tertiaryTextColor':'#14201F','lineColor':'#4A5A5C','textColor':'#14201F','mainBkg':'#E2F0F1','nodeBorder':'#0E7C86','nodeTextColor':'#14201F','edgeLabelBackground':'#FBFCFC','titleColor':'#14201F','clusterBkg':'#FBFCFC','clusterBorder':'#E4EAEB','actorBkg':'#E2F0F1','actorTextColor':'#14201F','actorBorder':'#0E7C86','actorLineColor':'#C7D1D2','signalColor':'#4A5A5C','signalTextColor':'#14201F','labelBoxBkgColor':'#EEF2F3','labelBoxBorderColor':'#C7D1D2','labelTextColor':'#14201F','loopTextColor':'#14201F','noteBkgColor':'#F6EBD5','noteTextColor':'#14201F','noteBorderColor':'#9C6B12','activationBkgColor':'#EEF2F3','activationBorderColor':'#C7D1D2','sequenceNumberColor':'#FFFFFF'}}}%%
erDiagram
  product_releases {
    TEXT version PK
    INTEGER major
    TEXT min_base_version
    TEXT r2_key UK
    TEXT status
    TEXT internal_fingerprints "JSON 陣列"
    TEXT signed_manifest
    TEXT kid
    TEXT min_agent_version
  }
  instance_version_reports {
    TEXT license_id PK
    TEXT fingerprint PK
    TEXT current_version
    TEXT last_upgrade_to
  }
  agent_version_reports {
    TEXT license_id PK
    TEXT agent_fingerprint PK
    TEXT host_fingerprint
    TEXT current_version
    TEXT last_upgrade_to
  }
  release_download_log {
    INTEGER id PK
    TEXT license_id
    TEXT fingerprint
    TEXT version
  }
  license_denylist {
    TEXT license_id PK
    TEXT reason
  }
  signing_keys {
    TEXT kid PK
    TEXT status
  }
  ci_tokens {
    INTEGER id PK
    TEXT name UK
    TEXT token_hash UK
  }
  product_releases ||..o{ release_download_log : "version"
  product_releases ||..o{ instance_version_reports : "version = current_version/last_upgrade_to"
  product_releases ||..o{ agent_version_reports : "Agent 版號,非主系統版號"
  product_releases }o..o{ instance_version_reports : "internal_fingerprints 含 fingerprint"
  signing_keys |o..o{ product_releases : "kid"
  license_denylist |o..o{ instance_version_reports : "license_id"
  instance_version_reports ||..o{ agent_version_reports : "license_id+host_fingerprint"
圖 1 — 更新站七張表的邏輯關聯(虛線=無外鍵約束)

注意 agent_version_reports.current_version 是 Agent 自己的版號(例如 1.4.8),不是主系統版號。後台「Agent(在版/退回/失敗)」那欄拿它和 product_releases.version 比對(releaseAdoption,src/releases.ts:370-385),只有兩者版號剛好相同時才會計到數;這是現有統計的語意,看那欄時要知道它比對的是什麼。

§3

product_releases

用途:一版一列。CI 登記成草稿,決策者蓋章後變已發布,撤回是終點。同一版號只會有一列:重登記草稿是覆蓋不是新增。

欄位 型別 NOT NULL 預設 CHECK/約束 說明 來源
version TEXT 是(PK) — PRIMARY KEY 版號 X.Y.Z[-後綴] 0001:6
major INTEGER 是 — major >= 1 由 version 第一段推,不收 CI 自報 0001:7
min_base_version TEXT 是 — — 可直升的最低起始版;planPath 由它遞推 0001:9
sha256 TEXT 是 — — 整包雜湊(Worker 端驗 64 hex) 0001:10
size INTEGER 是 — size > 0 整包位元組數 0001:11
r2_key TEXT 是 — UNIQUE 整包的 R2 key,只由 r2Key() 產生 0001:13
status TEXT 是 'draft' IN ('draft','published','withdrawn') 0001:14
internal_fingerprints TEXT 是 '[]' json_valid 草稿先開給哪幾台(JSON 陣列,機器指紋 64 hex,≤20) 0001:16
has_security_fix INTEGER 是 0 IN (0, 1) 0001:17
signed_manifest TEXT 否 — — 決策者簽好的 v2 信封原文,更新站不解、不改 0001:19
kid TEXT 否 — — 主簽章 kid 0001:20
retain_until TEXT 否 — — 登記時寫死 = 現在 + 10 年(D29) 0001:22
created_at TEXT 是 — — 登記時間;重登記草稿會更新 0001:23
published_at TEXT 否 — — 最近一次蓋章送回的時間(同版重簽會覆蓋) 0001:24
withdrawn_at TEXT 否 — — 0001:25
release_notes TEXT 是 '[]' json_valid 「這一版更新了什麼」字串陣列(≤20 條、每條 ≤500 字) 0002:4
impact TEXT 是 '' — 影響範圍(≤2000 字) 0002:5
action TEXT 是 '' — 建議動作(≤2000 字) 0002:6
min_agent_version TEXT 否 — — 最低相容 Agent 版;CI 登記時帶,CLI 蓋進簽章 0003:3

表層 CHECK:status <> 'published' OR (signed_manifest IS NOT NULL AND kid IS NOT NULL)(0001:26)——已發布的版一定有簽好的清單與 kid,連直接寫 D1 也繞不過。

索引:idx_product_releases_major_status (major, status)(0001:28),給 visibleReleases 的 WHERE major = ? AND status … 用;另有 PK 與 r2_key UNIQUE 的自動索引。

狀態轉換:

%%{init: {'theme':'base','themeVariables':{'primaryColor':'#E2F0F1','primaryTextColor':'#14201F','primaryBorderColor':'#0E7C86','secondaryColor':'#EEF2F3','secondaryTextColor':'#14201F','tertiaryColor':'#FBFCFC','tertiaryTextColor':'#14201F','lineColor':'#4A5A5C','textColor':'#14201F','mainBkg':'#E2F0F1','nodeBorder':'#0E7C86','nodeTextColor':'#14201F','edgeLabelBackground':'#FBFCFC','titleColor':'#14201F','clusterBkg':'#FBFCFC','clusterBorder':'#E4EAEB','actorBkg':'#E2F0F1','actorTextColor':'#14201F','actorBorder':'#0E7C86','actorLineColor':'#C7D1D2','signalColor':'#4A5A5C','signalTextColor':'#14201F','labelBoxBkgColor':'#EEF2F3','labelBoxBorderColor':'#C7D1D2','labelTextColor':'#14201F','loopTextColor':'#14201F','noteBkgColor':'#F6EBD5','noteTextColor':'#14201F','noteBorderColor':'#9C6B12','activationBkgColor':'#EEF2F3','activationBorderColor':'#C7D1D2','sequenceNumberColor':'#FFFFFF'}}}%%
stateDiagram-v2
  [*] --> draft: register-draft(CI)
  draft --> draft: register-draft 同版號(覆蓋;包換了清內部先裝結果)
  draft --> published: publish(內部先裝全成功)
  published --> published: publish 同版重簽
  draft --> withdrawn: withdraw
  published --> withdrawn: withdraw
  withdrawn --> [*]
圖 2 — product_releases.status 狀態機
寫入 函式 動到的欄位
建/覆蓋草稿 registerDraft(src/releases.ts:226-250) 新增全部;覆蓋時只改 min_base_version、sha256、size、has_security_fix、created_at、min_agent_version,WHERE status = 'draft'
發布/重簽 publishRelease(:331-347) status、signed_manifest、kid、published_at
撤回 withdrawRelease(:350-356) status、withdrawn_at
內部先裝 setInternalTargets(:310-318) internal_fingerprints,只有 draft
版本說明 setReleaseNotes(:411-421) release_notes、impact、action(COALESCE 部分更新),draft/published
讀取 函式 用途
visibleReleases(:62-75) 客戶 check/download-url 同主版號 published + draft 且 json_each(internal_fingerprints) 含這台
getRelease、listReleases(:270-272、:423-426) 後台、CLI SELECT *;列表在 Worker 依 semver 新到舊排
releaseAdoption(:370-385) 後台列表 每版關聯回報表算台數

現況(2026-11-01):10 列(1.27.1~1.27.10),全部 published、kid 皆為測試章 b91819f86eb4b4f8、min_base_version 皆 1.0.0、min_agent_version 皆 1.0.0、has_security_fix 皆 0、internal_fingerprints 皆 []、最早 retain_until 為 2036-10-30。

§4

instance_version_reports

用途:每台客戶主機一列(同一台換過授權會多一列),記「現在幾版、最後一次來問是什麼時候、最近一次升級結果」。刻意沒有客戶名稱欄位:不要順手加 customer_code/issued_to,那會讓更新站變成客戶名單(0001:30-31)。

欄位 型別 NOT NULL CHECK 說明 來源
license_id TEXT 是(PK) — 授權編號 0001:33
fingerprint TEXT 是(PK) — 主機機器指紋(64 hex) 0001:34
current_version TEXT 是 — 主系統現行版號 0001:35
last_seen TEXT 是 — 最近一次 check/report 時間 0001:36
last_upgrade_result TEXT 否 IN ('succeeded','rolled_back','failed','skipped') 0001:37
last_upgrade_from TEXT 否 — 0001:38
last_upgrade_to TEXT 否 — 0001:39
last_upgrade_at TEXT 否 — 0001:40

主鍵:(license_id, fingerprint)(0001:41)。沒有另建索引;internalTargetStatus 依 fingerprint IN (…) 查,筆數小,未建索引。

寫入 函式 動到的欄位
/v1/check touchInstance(src/instances.ts:8-17) upsert current_version、last_seen;不碰 last_upgrade_*
/v1/report(host) recordUpgrade(:21-41) upsert 全部欄位;current_version 取 body 的 current_version,last_upgrade_at = last_seen = 現在
重登記草稿且包換了 clearInternalResults(src/releases.ts:252-261) 那幾台內部機器對這版的 last_upgrade_result 設 NULL

讀取:internalTargetStatus(發布前核內部先裝)、releaseAdoption(on_version=current_version 等於該版;rolled_back/failed=last_upgrade_to 等於該版且結果相符;major_total=current_version LIKE '<major>.%')。

現況:3 列——兩台 current_version = 1.27.8、一台 1.27.10,last_upgrade_result 皆 succeeded。

§5

agent_version_reports

用途:主系統代 Agent 回報升級結果,一台 Agent 一列。Agent 沒有自己的授權,身分是主機的授權。同樣不存客戶名稱與 Agent 主機名稱(0004:2-3)。

欄位 型別 NOT NULL CHECK 說明 來源
license_id TEXT 是(PK) — 主機的授權編號 0004:6
host_fingerprint TEXT 是 — 代報的那台主機的指紋 0004:7
agent_fingerprint TEXT 是(PK) — Agent 指紋(64 hex) 0004:8
current_version TEXT 是 — Agent 現行版號:succeeded 時 = to,其他結果 = from 0004:9
last_upgrade_result TEXT 否 IN ('succeeded','rolled_back','failed','skipped') 0004:10
last_upgrade_from TEXT 否 — 0004:11
last_upgrade_to TEXT 否 — 0004:12
last_upgrade_at TEXT 否 — 0004:13
last_seen TEXT 是 — 最近一次回報時間 0004:14

主鍵:(license_id, agent_fingerprint)(0004:15),不含 host_fingerprint:Agent 換掛到同授權另一台主機時覆寫同一列,不是多一台。

寫入:只有 /v1/report target=agent 的 recordAgentUpgrade(src/instances.ts:47-68),upsert 全部欄位。current_version 只在成功時寫 to:退回/失敗/略過時 Agent 還停在 from,寫 to 會讓後台把沒升上去的 Agent 算進「在這版」。

讀取:releaseAdoption 的 agent_on_version、agent_rolled_back、agent_failed。

BE 送出端欄位對應:agent_rollout_service._unreported(app/product_update/service/agent_rollout_service.py:428-438)送 agent_fingerprint、result(rollout 列 status)、from_version、to_version(rollout 列 target_version);送達才寫 reported_at。

現況:2 列——一台 succeeded、current_version = 1.4.8;一台 failed、current_version = 1.4.7(失敗時停在 from,符合上述規則)。

§6

release_download_log

用途:稽核「誰在什麼時候拿了哪一版的下載網址」。只記發出網址;R2 實際有沒有下載完不在這裡(0001:44)。

欄位 型別 NOT NULL 說明 來源
id INTEGER 是(PK) AUTOINCREMENT 0001:46
license_id TEXT 是 0001:47
fingerprint TEXT 是 0001:48
version TEXT 是 0001:49
at TEXT 是 0001:50

索引:idx_release_download_log_version (version, at)(0001:52)。

寫入:logDownload(src/instances.ts:70-75),只在 /v1/download-url 回整包網址時;artifact=ops 小檔不記。目前沒有任何端點讀這張表(後台未顯示),供直接查 D1 稽核。

現況:15 列;依版號 1.27.8 四筆、1.27.1 三筆,其餘八版各一筆。

§7

license_denylist

用途:被停止提供更新的授權(D12:由決策者在後台手動加)。在名單上的授權打三個客戶端端點一律 403 license_denied,連有沒有新版都不回。

欄位 型別 NOT NULL 說明 來源
license_id TEXT 是(PK) ^[A-Za-z0-9_-]{1,64}$(Worker 驗) 0001:56
reason TEXT 是 ≤500 字 0001:57
added_by TEXT 是 Access 信箱,或 ci:<名稱> 0001:58
at TEXT 是 0001:59
動作 函式(src/denylist.ts)
判定(客戶端三端點) isDenied:SELECT 1 … WHERE license_id = ?
列表 listDenied:ORDER BY at DESC
加入 addDenied:ON CONFLICT (license_id) DO UPDATE 理由、操作者、時間(留最新一次)
移除 removeDenied:實刪

現況:0 列。

§8

signing_keys

用途:發布印樣(公鑰)的登記表,只給後台與 GET /admin/keys 顯示。更新站不用它驗任何東西:客戶端只信自己機器上 /etc/guidant/release-keys/*.pub,這張表改錯也騙不了客戶端(src/keys.ts:1-3)。私鑰永遠不進這裡。

欄位 型別 NOT NULL CHECK 說明 來源
kid TEXT 是(PK) — 公鑰 raw 32 bytes 的 sha256 前 16 hex 0001:65
pubkey TEXT 是 — base64 的 32 bytes Ed25519 公鑰 0001:66
status TEXT 是 IN ('primary','backup','revoked') 0001:67
created_at TEXT 是 — 第一次登記時間(之後 upsert 不改) 0001:68
revoked_at TEXT 否 — 0001:69

寫入:upsertKey(src/keys.ts:41-52)。登記前 kidMatches 核 kid 與公鑰對得上;作廢單向:已 revoked 的 kid 再送任何狀態都回 409 kid_revoked。CLI sync-keys 把本機角色對應成站上狀態:主力 → primary、備用 → backup、換鑰中新章 → backup、作廢 → revoked。

讀取:listKeys(依 primary、backup、revoked 排);publish 端點查「這個 kid 是不是 revoked」,是就 409(只擋作廢、不要求必須登記過)。

現況:0 列(尚未跑過 sync-keys)。

§9

ci_tokens

用途:GitLab CI 打 register-draft 的 scoped token。只存 sha256 雜湊,明文只在建立當下回一次(0001:72-73)。

欄位 型別 NOT NULL 預設 CHECK/約束 說明 來源
id INTEGER 是(PK) AUTOINCREMENT 0001:75
name TEXT 是 — UNIQUE ^[A-Za-z0-9._-]{1,64}$ 0001:76
token_hash TEXT 是 — UNIQUE guc_<64 hex> 明文的 sha256 hex 0001:77
scope TEXT 是 'register-draft' scope = 'register-draft' 只能是這個值 0001:78
is_active INTEGER 是 1 IN (0, 1) 撤銷=0,不刪列 0001:79
created_at TEXT 是 — 0001:80
last_used_at TEXT 否 — 每次驗過都更新 0001:81

scope 的 CHECK 讓「開一支能打別的端點的 token」在資料庫層就做不到;Worker 驗 token 時也再帶 AND scope = 'register-draft'(src/access.ts:134)。

動作 函式(src/access.ts)
驗 token ciOperator:長度 ≤128 → sha256 → 查 is_active = 1 AND scope = 'register-draft' → 更新 last_used_at
建立 createCiToken:ON CONFLICT (name) DO NOTHING RETURNING id,重名回 null(409 name_taken)
撤銷 revokeCiToken:is_active 1 → 0
列表 listCiTokens:不含 token_hash

現況:0 列(CI 自動登記草稿尚未啟用)。

§10

migration 對照

檔 內容 動到的表
0001_init.sql 建六張表與兩個索引 product_releases、instance_version_reports、release_download_log、license_denylist、signing_keys、ci_tokens
0002_release_notes.sql 加版本說明三欄 release_notes('[]'、json_valid)、impact、action('')。刻意不進簽章:清單一簽就改不了,這三段發布後仍可修字 product_releases
0003_min_agent_version.sql 加 min_agent_version(可 NULL:舊草稿與不設下限的版本沒有) product_releases
0004_agent_version_reports.sql 建 agent_version_reports 新表

套用:

cd ~/Projects/Billows/Audit-Manager/update-center
npm run db:migrate:local                                        # 本機 env.dev 模擬庫
npx wrangler d1 migrations apply guidant-update-center --remote # 正式庫(首腦做)

wrangler 在 d1_migrations 記已套過的檔名,重跑只套新的。測試由 test/apply-migrations.ts 每次對測試庫套全部 migration。

改 schema 的規則:

  • 只加不改:新欄位用 ALTER TABLE … ADD COLUMN,NOT NULL 欄位要給預設值(0002 的寫法),讓既有列直接合法。
  • 先套 migration、再部署 Worker。
  • 不加客戶名稱類欄位:client.spec.ts「D1 schema 守衛」會擋 instance_version_reports 出現這類欄位,並核六張主表都在。

唯讀查詢範例:

npx wrangler d1 execute guidant-update-center --remote --json \
  --command "SELECT version, status, kid, published_at FROM product_releases"

D1 對 compound SELECT 的項數有上限,多張表計數請用子查詢寫在同一個 SELECT 裡,不要用一長串 UNION ALL。

§11

相關文件