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 唯讀實查。
| 表 | 用途 | 主鍵 | 寫入者 | 現況筆數 |
|---|---|---|---|---|
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):
CHECK (json_valid(...)) 守住;讀取用 json_each。new Date().toISOString() 寫(帶毫秒與 Z)。INTEGER 0/1,以 CHECK (x IN (0, 1)) 守住。TEXT。不可用 SQL 字串比大小("1.10.0" < "1.9.0");排序與比較都在 Worker 用 src/versions.ts 做。表之間沒有宣告外鍵,都是邏輯關聯:
%%{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"
注意 agent_version_reports.current_version 是 Agent 自己的版號(例如 1.4.8),不是主系統版號。後台「Agent(在版/退回/失敗)」那欄拿它和 product_releases.version 比對(releaseAdoption,src/releases.ts:370-385),只有兩者版號剛好相同時才會計到數;這是現有統計的語意,看那欄時要知道它比對的是什麼。
用途:一版一列。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 --> [*]
| 寫入 | 函式 | 動到的欄位 |
|---|---|---|
| 建/覆蓋草稿 | 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。
用途:每台客戶主機一列(同一台換過授權會多一列),記「現在幾版、最後一次來問是什麼時候、最近一次升級結果」。刻意沒有客戶名稱欄位:不要順手加 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。
用途:主系統代 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,符合上述規則)。
用途:稽核「誰在什麼時候拿了哪一版的下載網址」。只記發出網址;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 三筆,其餘八版各一筆。
用途:被停止提供更新的授權(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 列。
用途:發布印樣(公鑰)的登記表,只給後台與 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)。
用途: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 自動登記草稿尚未啟用)。
| 檔 | 內容 | 動到的表 |
|---|---|---|
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 的寫法),讓既有列直接合法。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。
signed_manifest 的格式與驗法