Add central notification channels, rules, queue and delivery logs, fleet organization groups and tags, device discovery, the device action catalog, instance module reads, the log centre, connection settings and system maintenance as native /api/v1 routes backed by the existing secret store, audit trail and pinned upstream transport.
714 lines
31 KiB
TypeScript
714 lines
31 KiB
TypeScript
import type { SqliteDatabase } from './database.js';
|
|
import { createHash } from 'node:crypto';
|
|
|
|
export interface Migration {
|
|
readonly id: number;
|
|
readonly name: string;
|
|
readonly statements: readonly string[];
|
|
}
|
|
|
|
export const MIGRATIONS: readonly Migration[] = [
|
|
{
|
|
id: 1,
|
|
name: 'initial-control-plane-schema',
|
|
statements: [
|
|
`CREATE TABLE instances (
|
|
id TEXT PRIMARY KEY,
|
|
name TEXT NOT NULL,
|
|
base_url TEXT NOT NULL UNIQUE,
|
|
auth_mode TEXT NOT NULL DEFAULT 'password',
|
|
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
|
|
config_revision INTEGER NOT NULL DEFAULT 1 CHECK (config_revision > 0),
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL
|
|
)`,
|
|
`CREATE TABLE instance_tags (
|
|
instance_id TEXT NOT NULL REFERENCES instances(id) ON DELETE CASCADE,
|
|
tag TEXT NOT NULL,
|
|
created_at TEXT NOT NULL,
|
|
PRIMARY KEY (instance_id, tag)
|
|
)`,
|
|
`CREATE TABLE capabilities (
|
|
instance_id TEXT NOT NULL REFERENCES instances(id) ON DELETE CASCADE,
|
|
operation_id TEXT NOT NULL,
|
|
state TEXT NOT NULL CHECK (state IN ('supported', 'unsupported', 'auth-required', 'degraded', 'unknown')),
|
|
upstream_version TEXT,
|
|
detail_code TEXT,
|
|
observed_at TEXT NOT NULL,
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL,
|
|
PRIMARY KEY (instance_id, operation_id)
|
|
)`,
|
|
`CREATE TABLE status_snapshots (
|
|
id TEXT PRIMARY KEY,
|
|
instance_id TEXT NOT NULL REFERENCES instances(id) ON DELETE CASCADE,
|
|
category TEXT NOT NULL,
|
|
state TEXT NOT NULL CHECK (state IN ('fresh', 'stale', 'expired', 'unknown')),
|
|
payload_json TEXT NOT NULL,
|
|
observed_at TEXT NOT NULL,
|
|
expires_at TEXT,
|
|
created_at TEXT NOT NULL
|
|
)`,
|
|
`CREATE TABLE jobs (
|
|
id TEXT PRIMARY KEY,
|
|
parent_job_id TEXT REFERENCES jobs(id) ON DELETE RESTRICT,
|
|
operation_id TEXT NOT NULL,
|
|
risk_level TEXT NOT NULL CHECK (risk_level IN ('R0', 'R1', 'R2', 'R3')),
|
|
status TEXT NOT NULL,
|
|
requested_by TEXT NOT NULL,
|
|
request_id TEXT NOT NULL,
|
|
parameters_digest TEXT NOT NULL,
|
|
created_at TEXT NOT NULL,
|
|
started_at TEXT,
|
|
finished_at TEXT,
|
|
updated_at TEXT NOT NULL
|
|
)`,
|
|
`CREATE TABLE job_items (
|
|
id TEXT PRIMARY KEY,
|
|
job_id TEXT NOT NULL REFERENCES jobs(id) ON DELETE CASCADE,
|
|
instance_id TEXT NOT NULL REFERENCES instances(id) ON DELETE RESTRICT,
|
|
attempt_number INTEGER NOT NULL DEFAULT 1 CHECK (attempt_number > 0),
|
|
status TEXT NOT NULL,
|
|
result_code TEXT,
|
|
created_at TEXT NOT NULL,
|
|
started_at TEXT,
|
|
finished_at TEXT,
|
|
updated_at TEXT NOT NULL,
|
|
UNIQUE (job_id, instance_id, attempt_number)
|
|
)`,
|
|
`CREATE TABLE audit_events (
|
|
id TEXT PRIMARY KEY,
|
|
instance_id TEXT REFERENCES instances(id) ON DELETE SET NULL,
|
|
job_id TEXT REFERENCES jobs(id) ON DELETE SET NULL,
|
|
actor TEXT NOT NULL,
|
|
operation_id TEXT NOT NULL,
|
|
risk_level TEXT NOT NULL CHECK (risk_level IN ('R0', 'R1', 'R2', 'R3')),
|
|
request_id TEXT NOT NULL,
|
|
parameters_summary_json TEXT NOT NULL,
|
|
body_digest TEXT,
|
|
result_code TEXT NOT NULL,
|
|
duration_ms INTEGER NOT NULL CHECK (duration_ms >= 0),
|
|
created_at TEXT NOT NULL
|
|
)`,
|
|
`CREATE TABLE app_settings (
|
|
key TEXT PRIMARY KEY,
|
|
value_json TEXT NOT NULL,
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL
|
|
)`,
|
|
`CREATE TABLE secret_references (
|
|
id TEXT PRIMARY KEY,
|
|
instance_id TEXT REFERENCES instances(id) ON DELETE CASCADE,
|
|
purpose TEXT NOT NULL,
|
|
provider TEXT NOT NULL,
|
|
external_reference TEXT NOT NULL,
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL,
|
|
UNIQUE (provider, external_reference)
|
|
)`,
|
|
'CREATE INDEX idx_status_snapshots_instance_observed_at ON status_snapshots(instance_id, observed_at DESC)',
|
|
'CREATE INDEX idx_jobs_status_created_at ON jobs(status, created_at DESC)',
|
|
'CREATE INDEX idx_audit_events_created_at ON audit_events(created_at DESC)',
|
|
'CREATE INDEX idx_capabilities_state ON capabilities(state)',
|
|
'CREATE INDEX idx_job_items_job_status ON job_items(job_id, status)',
|
|
],
|
|
},
|
|
{
|
|
id: 2,
|
|
name: 'secret-cleanup-task-outbox',
|
|
statements: [
|
|
`CREATE TABLE secret_cleanup_tasks (
|
|
reference TEXT PRIMARY KEY,
|
|
instance_id TEXT NOT NULL,
|
|
purpose TEXT NOT NULL,
|
|
provider TEXT NOT NULL,
|
|
generation INTEGER NOT NULL DEFAULT 1 CHECK (generation > 0),
|
|
queued_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL
|
|
)`,
|
|
`INSERT INTO secret_cleanup_tasks (reference,instance_id,purpose,provider,generation,queued_at,updated_at)
|
|
SELECT external_reference,instance_id,purpose,provider,1,updated_at,updated_at
|
|
FROM (
|
|
SELECT external_reference,instance_id,purpose,provider,updated_at,
|
|
ROW_NUMBER() OVER (
|
|
PARTITION BY instance_id,purpose
|
|
ORDER BY updated_at DESC,created_at DESC,id DESC
|
|
) AS row_number
|
|
FROM secret_references
|
|
WHERE instance_id IS NOT NULL
|
|
) ranked
|
|
WHERE row_number > 1`,
|
|
`DELETE FROM secret_references
|
|
WHERE id IN (
|
|
SELECT id FROM (
|
|
SELECT id, ROW_NUMBER() OVER (
|
|
PARTITION BY instance_id,purpose
|
|
ORDER BY updated_at DESC,created_at DESC,id DESC
|
|
) AS row_number
|
|
FROM secret_references
|
|
WHERE instance_id IS NOT NULL
|
|
) ranked
|
|
WHERE row_number > 1
|
|
)`,
|
|
'CREATE UNIQUE INDEX uq_secret_references_instance_purpose ON secret_references(instance_id, purpose) WHERE instance_id IS NOT NULL',
|
|
'CREATE INDEX idx_secret_cleanup_tasks_queued_at ON secret_cleanup_tasks(queued_at)',
|
|
],
|
|
},
|
|
{
|
|
id: 3,
|
|
name: 'current-status-snapshot-lifecycle',
|
|
statements: [
|
|
`DELETE FROM status_snapshots
|
|
WHERE id IN (
|
|
SELECT id FROM (
|
|
SELECT id, ROW_NUMBER() OVER (
|
|
PARTITION BY instance_id, category
|
|
ORDER BY observed_at DESC, created_at DESC, id DESC
|
|
) AS row_number
|
|
FROM status_snapshots
|
|
) ranked
|
|
WHERE row_number > 1
|
|
)`,
|
|
'CREATE UNIQUE INDEX uq_status_snapshots_instance_category ON status_snapshots(instance_id, category)',
|
|
],
|
|
},
|
|
{
|
|
id: 4,
|
|
name: 'r3-preparations-and-durable-job-lineage',
|
|
statements: [
|
|
'ALTER TABLE jobs ADD COLUMN root_job_id TEXT REFERENCES jobs(id) ON DELETE RESTRICT',
|
|
'ALTER TABLE jobs ADD COLUMN retry_of_job_id TEXT REFERENCES jobs(id) ON DELETE RESTRICT',
|
|
'UPDATE jobs SET root_job_id=id WHERE root_job_id IS NULL',
|
|
`CREATE TABLE job_items_v4 (
|
|
id TEXT PRIMARY KEY,
|
|
job_id TEXT NOT NULL REFERENCES jobs(id) ON DELETE CASCADE,
|
|
instance_id TEXT NOT NULL,
|
|
attempt_number INTEGER NOT NULL DEFAULT 1 CHECK (attempt_number > 0),
|
|
status TEXT NOT NULL,
|
|
result_code TEXT,
|
|
error_json TEXT,
|
|
source_job_item_id TEXT REFERENCES job_items_v4(id) ON DELETE RESTRICT,
|
|
created_at TEXT NOT NULL,
|
|
started_at TEXT,
|
|
finished_at TEXT,
|
|
updated_at TEXT NOT NULL,
|
|
UNIQUE (job_id, instance_id, attempt_number)
|
|
)`,
|
|
`INSERT INTO job_items_v4 (id,job_id,instance_id,attempt_number,status,result_code,created_at,started_at,finished_at,updated_at)
|
|
SELECT id,job_id,instance_id,attempt_number,status,result_code,created_at,started_at,finished_at,updated_at FROM job_items`,
|
|
'DROP TABLE job_items',
|
|
'ALTER TABLE job_items_v4 RENAME TO job_items',
|
|
'CREATE INDEX idx_job_items_job_status ON job_items(job_id, status)',
|
|
`CREATE TABLE job_attempts (
|
|
id TEXT PRIMARY KEY,
|
|
job_id TEXT NOT NULL REFERENCES jobs(id) ON DELETE CASCADE,
|
|
status TEXT NOT NULL CHECK (status IN ('running','succeeded','failed','cancelled','unknown-result')),
|
|
started_at TEXT NOT NULL,
|
|
finished_at TEXT,
|
|
created_at TEXT NOT NULL
|
|
)`,
|
|
'CREATE INDEX idx_job_attempts_job_started_at ON job_attempts(job_id, started_at)',
|
|
`CREATE TABLE operation_preparations (
|
|
id TEXT PRIMARY KEY,
|
|
operation_id TEXT NOT NULL CHECK (operation_id = 'deleteInstance'),
|
|
risk_level TEXT NOT NULL CHECK (risk_level = 'R3'),
|
|
status TEXT NOT NULL CHECK (status IN ('prepared','consumed','expired','invalidated')),
|
|
target_instance_id TEXT NOT NULL,
|
|
target_revision INTEGER NOT NULL CHECK (target_revision > 0),
|
|
parameter_schema_id TEXT NOT NULL,
|
|
parameters_digest TEXT NOT NULL,
|
|
token_digest TEXT NOT NULL CHECK (length(token_digest) = 64),
|
|
requested_by TEXT NOT NULL,
|
|
request_id TEXT NOT NULL,
|
|
expires_at TEXT NOT NULL,
|
|
consumed_at TEXT,
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL
|
|
)`,
|
|
'CREATE INDEX idx_operation_preparations_status_expires_at ON operation_preparations(status, expires_at)',
|
|
],
|
|
},
|
|
{
|
|
id: 5,
|
|
name: 'generic-secure-operation-preparation-binding',
|
|
statements: [
|
|
`CREATE TABLE operation_preparations_v5 (
|
|
id TEXT PRIMARY KEY,
|
|
operation_id TEXT NOT NULL,
|
|
risk_level TEXT NOT NULL CHECK (risk_level IN ('R2','R3')),
|
|
status TEXT NOT NULL CHECK (status IN ('prepared','consumed','expired','invalidated')),
|
|
target_instance_id TEXT NOT NULL,
|
|
target_revision INTEGER NOT NULL CHECK (target_revision > 0),
|
|
target_origin TEXT NOT NULL,
|
|
method TEXT NOT NULL,
|
|
path TEXT NOT NULL,
|
|
canonical_query TEXT NOT NULL,
|
|
body_digest TEXT NOT NULL CHECK (length(body_digest) = 64),
|
|
content_type TEXT NOT NULL,
|
|
parameter_schema_id TEXT NOT NULL,
|
|
parameters_digest TEXT NOT NULL CHECK (length(parameters_digest) = 64),
|
|
nonce TEXT NOT NULL UNIQUE,
|
|
token_digest TEXT NOT NULL CHECK (length(token_digest) = 64),
|
|
requested_by TEXT NOT NULL,
|
|
request_id TEXT NOT NULL,
|
|
expires_at TEXT NOT NULL,
|
|
consumed_at TEXT,
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL
|
|
)`,
|
|
`INSERT INTO operation_preparations_v5
|
|
(id,operation_id,risk_level,status,target_instance_id,target_revision,target_origin,method,path,
|
|
canonical_query,body_digest,content_type,parameter_schema_id,parameters_digest,nonce,token_digest,
|
|
requested_by,request_id,expires_at,consumed_at,created_at,updated_at)
|
|
SELECT p.id,p.operation_id,p.risk_level,p.status,p.target_instance_id,p.target_revision,
|
|
COALESCE(i.base_url,''),'DELETE','/api/v1/instances/' || p.target_instance_id,'',
|
|
'${createHash('sha256').update('').digest('hex')}','',p.parameter_schema_id,p.parameters_digest,
|
|
'legacy-' || p.id,p.token_digest,p.requested_by,p.request_id,p.expires_at,p.consumed_at,p.created_at,p.updated_at
|
|
FROM operation_preparations p LEFT JOIN instances i ON i.id=p.target_instance_id`,
|
|
'DROP TABLE operation_preparations',
|
|
'ALTER TABLE operation_preparations_v5 RENAME TO operation_preparations',
|
|
'CREATE INDEX idx_operation_preparations_status_expires_at ON operation_preparations(status, expires_at)',
|
|
],
|
|
},
|
|
{
|
|
id: 6,
|
|
name: 'durable-event-journal',
|
|
statements: [
|
|
`CREATE TABLE event_journal (
|
|
sequence INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
public_id TEXT NOT NULL UNIQUE,
|
|
envelope_json TEXT NOT NULL
|
|
)`,
|
|
'CREATE INDEX idx_event_journal_sequence ON event_journal(sequence)',
|
|
],
|
|
},
|
|
{
|
|
id: 7,
|
|
name: 'aggregate-console-password-protection',
|
|
statements: [
|
|
`CREATE TABLE console_auth_config (
|
|
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
|
|
protection_enabled INTEGER NOT NULL DEFAULT 0 CHECK (protection_enabled IN (0, 1)),
|
|
password_salt TEXT,
|
|
password_hash TEXT,
|
|
password_revision INTEGER NOT NULL DEFAULT 0 CHECK (password_revision >= 0),
|
|
updated_at TEXT NOT NULL,
|
|
CHECK ((password_salt IS NULL) = (password_hash IS NULL))
|
|
)`,
|
|
`CREATE TABLE console_auth_sessions (
|
|
session_hash TEXT PRIMARY KEY CHECK (length(session_hash) = 64),
|
|
password_revision INTEGER NOT NULL CHECK (password_revision > 0),
|
|
created_at TEXT NOT NULL,
|
|
expires_at TEXT NOT NULL
|
|
)`,
|
|
'CREATE INDEX idx_console_auth_sessions_expires_at ON console_auth_sessions(expires_at)',
|
|
],
|
|
},
|
|
{
|
|
id: 8,
|
|
name: 'scheduled-automation',
|
|
statements: [
|
|
`CREATE TABLE scheduled_tasks (
|
|
id TEXT PRIMARY KEY,
|
|
name TEXT NOT NULL,
|
|
operation_type TEXT NOT NULL CHECK (operation_type IN ('restart-service','reboot-system','send-sms')),
|
|
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
|
|
version INTEGER NOT NULL DEFAULT 1 CHECK (version > 0),
|
|
cron_expression TEXT NOT NULL,
|
|
timezone TEXT NOT NULL CHECK (timezone = 'Asia/Shanghai'),
|
|
target_selector_json TEXT NOT NULL,
|
|
sms_secret_reference TEXT,
|
|
sms_recipient_count INTEGER CHECK (sms_recipient_count IS NULL OR sms_recipient_count > 0),
|
|
effective_start_at TEXT,
|
|
effective_end_at TEXT,
|
|
misfire_policy TEXT NOT NULL CHECK (misfire_policy IN ('skip','catch-up-once')),
|
|
overlap_policy TEXT NOT NULL CHECK (overlap_policy IN ('skip','queue-once')),
|
|
retry_policy_json TEXT NOT NULL,
|
|
next_due_at TEXT,
|
|
last_evaluated_at TEXT,
|
|
created_by TEXT NOT NULL,
|
|
updated_by TEXT NOT NULL,
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL,
|
|
deleted_at TEXT,
|
|
CHECK ((operation_type = 'send-sms') = (sms_secret_reference IS NOT NULL)),
|
|
CHECK ((sms_secret_reference IS NULL) = (sms_recipient_count IS NULL))
|
|
)`,
|
|
`CREATE TABLE scheduled_runs (
|
|
id TEXT PRIMARY KEY,
|
|
scheduled_task_id TEXT NOT NULL REFERENCES scheduled_tasks(id) ON DELETE RESTRICT,
|
|
schedule_version INTEGER NOT NULL CHECK (schedule_version > 0),
|
|
task_snapshot_json TEXT NOT NULL,
|
|
due_at TEXT NOT NULL,
|
|
claimed_at TEXT NOT NULL,
|
|
started_at TEXT,
|
|
finished_at TEXT,
|
|
target_snapshot_json TEXT NOT NULL,
|
|
outcome TEXT CHECK (outcome IS NULL OR outcome IN ('succeeded','partially-succeeded','failed','skipped','no-targets','needs-attention')),
|
|
reason TEXT,
|
|
job_ids_json TEXT NOT NULL DEFAULT '[]',
|
|
trigger_source TEXT NOT NULL CHECK (trigger_source IN ('scheduled','manual')),
|
|
attempt INTEGER NOT NULL DEFAULT 1 CHECK (attempt > 0),
|
|
UNIQUE (scheduled_task_id, schedule_version, due_at, trigger_source)
|
|
)`,
|
|
'CREATE INDEX idx_scheduled_tasks_enabled_next_due ON scheduled_tasks(enabled, next_due_at) WHERE deleted_at IS NULL',
|
|
'CREATE INDEX idx_scheduled_runs_task_due ON scheduled_runs(scheduled_task_id, due_at DESC)',
|
|
'CREATE INDEX idx_scheduled_runs_outcome_finished ON scheduled_runs(outcome, finished_at DESC)',
|
|
],
|
|
},
|
|
{
|
|
id: 9,
|
|
name: 'central-notifications',
|
|
statements: [
|
|
`CREATE TABLE notification_channels (
|
|
id TEXT PRIMARY KEY,
|
|
name TEXT NOT NULL,
|
|
type TEXT NOT NULL CHECK (type IN (
|
|
'webhook','bark','pushplus','wecom_app','wecom_robot','dingtalk_robot','dingtalk_app',
|
|
'feishu_robot','telegram','email','serverchan'
|
|
)),
|
|
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
|
|
config_json TEXT NOT NULL,
|
|
secret_reference TEXT,
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL
|
|
)`,
|
|
`CREATE TABLE notification_rules (
|
|
id TEXT PRIMARY KEY,
|
|
name TEXT NOT NULL,
|
|
event_type TEXT NOT NULL CHECK (event_type IN (
|
|
'sms','ddns','version','system','device','automation'
|
|
)),
|
|
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
|
|
condition_json TEXT NOT NULL,
|
|
scope_json TEXT NOT NULL,
|
|
channel_ids_json TEXT NOT NULL,
|
|
templates_json TEXT NOT NULL,
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL
|
|
)`,
|
|
`CREATE TABLE notification_deliveries (
|
|
id TEXT PRIMARY KEY,
|
|
rule_id TEXT REFERENCES notification_rules(id) ON DELETE SET NULL,
|
|
channel_id TEXT NOT NULL REFERENCES notification_channels(id) ON DELETE CASCADE,
|
|
instance_id TEXT,
|
|
event_type TEXT NOT NULL,
|
|
status TEXT NOT NULL CHECK (status IN (
|
|
'success','failed','pending','sending','retrying','unmatched','no_available_channel','quiet_hours'
|
|
)),
|
|
detail TEXT,
|
|
created_at TEXT NOT NULL,
|
|
sent_at TEXT
|
|
)`,
|
|
'CREATE INDEX idx_notification_deliveries_created_at ON notification_deliveries(created_at DESC)',
|
|
'CREATE INDEX idx_notification_deliveries_status_created_at ON notification_deliveries(status, created_at DESC)',
|
|
],
|
|
},
|
|
{
|
|
id: 10,
|
|
name: 'hub-message-and-notification-queue-persistence',
|
|
statements: [
|
|
`CREATE TABLE sms_messages (
|
|
id TEXT PRIMARY KEY,
|
|
instance_id TEXT NOT NULL,
|
|
upstream_id TEXT NOT NULL,
|
|
direction TEXT NOT NULL CHECK (direction IN ('incoming','outgoing','received','sent','unknown')),
|
|
phone_number TEXT NOT NULL,
|
|
content TEXT NOT NULL,
|
|
timestamp TEXT NOT NULL,
|
|
status TEXT NOT NULL,
|
|
transport TEXT NOT NULL,
|
|
synced_at TEXT NOT NULL,
|
|
UNIQUE (instance_id, upstream_id)
|
|
)`,
|
|
'CREATE INDEX idx_sms_messages_instance_timestamp ON sms_messages(instance_id, timestamp DESC)',
|
|
'CREATE INDEX idx_sms_messages_timestamp ON sms_messages(timestamp DESC)',
|
|
'CREATE INDEX idx_sms_messages_phone_timestamp ON sms_messages(phone_number, timestamp DESC)',
|
|
`CREATE TABLE notification_queue (
|
|
id TEXT PRIMARY KEY,
|
|
instance_id TEXT,
|
|
event_type TEXT NOT NULL,
|
|
rule_id TEXT REFERENCES notification_rules(id) ON DELETE SET NULL,
|
|
channel_id TEXT REFERENCES notification_channels(id) ON DELETE CASCADE,
|
|
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN (
|
|
'pending','sending','succeeded','failed','cancelled'
|
|
)),
|
|
attempts INTEGER NOT NULL DEFAULT 0 CHECK (attempts >= 0),
|
|
max_attempts INTEGER NOT NULL DEFAULT 3 CHECK (max_attempts > 0),
|
|
payload_json TEXT NOT NULL,
|
|
last_error TEXT,
|
|
available_at TEXT NOT NULL,
|
|
delivered_at TEXT,
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL
|
|
)`,
|
|
'CREATE INDEX idx_notification_queue_status_available_at ON notification_queue(status, available_at)',
|
|
'CREATE INDEX idx_notification_queue_created_at ON notification_queue(created_at DESC)',
|
|
],
|
|
},
|
|
{
|
|
id: 11,
|
|
name: 'hub-groups-tags-and-automation-triggers',
|
|
statements: [
|
|
`CREATE TABLE device_groups (
|
|
id TEXT PRIMARY KEY,
|
|
name TEXT NOT NULL UNIQUE,
|
|
description TEXT NOT NULL DEFAULT '',
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL
|
|
)`,
|
|
'ALTER TABLE instances ADD COLUMN group_id TEXT REFERENCES device_groups(id) ON DELETE SET NULL',
|
|
'CREATE INDEX idx_instances_group_id ON instances(group_id)',
|
|
`CREATE TABLE tag_registry (
|
|
tag TEXT PRIMARY KEY,
|
|
color TEXT NOT NULL DEFAULT '',
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL
|
|
)`,
|
|
`INSERT INTO tag_registry (tag, created_at, updated_at)
|
|
SELECT tag, MIN(created_at), MAX(created_at) FROM instance_tags GROUP BY tag`,
|
|
// scheduled_tasks carries an operation_type CHECK that has to widen, so both the
|
|
// task table and its only child are rebuilt; renaming the child first keeps the
|
|
// RESTRICT edge from firing while the parent is swapped out.
|
|
'ALTER TABLE scheduled_runs RENAME TO scheduled_runs_old',
|
|
'ALTER TABLE scheduled_tasks RENAME TO scheduled_tasks_old',
|
|
`CREATE TABLE scheduled_tasks (
|
|
id TEXT PRIMARY KEY,
|
|
name TEXT NOT NULL,
|
|
operation_type TEXT NOT NULL CHECK (operation_type IN (
|
|
'restart-service','reboot-system','send-sms','restart-baseband','backup-data'
|
|
)),
|
|
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
|
|
version INTEGER NOT NULL DEFAULT 1 CHECK (version > 0),
|
|
cron_expression TEXT NOT NULL,
|
|
timezone TEXT NOT NULL CHECK (timezone = 'Asia/Shanghai'),
|
|
target_selector_json TEXT NOT NULL,
|
|
sms_secret_reference TEXT,
|
|
sms_recipient_count INTEGER CHECK (sms_recipient_count IS NULL OR sms_recipient_count > 0),
|
|
effective_start_at TEXT,
|
|
effective_end_at TEXT,
|
|
misfire_policy TEXT NOT NULL CHECK (misfire_policy IN ('skip','catch-up-once')),
|
|
overlap_policy TEXT NOT NULL CHECK (overlap_policy IN ('skip','queue-once')),
|
|
retry_policy_json TEXT NOT NULL,
|
|
next_due_at TEXT,
|
|
last_evaluated_at TEXT,
|
|
created_by TEXT NOT NULL,
|
|
updated_by TEXT NOT NULL,
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL,
|
|
deleted_at TEXT,
|
|
trigger_json TEXT,
|
|
CHECK ((operation_type = 'send-sms') = (sms_secret_reference IS NOT NULL)),
|
|
CHECK ((sms_secret_reference IS NULL) = (sms_recipient_count IS NULL))
|
|
)`,
|
|
`CREATE TABLE scheduled_runs (
|
|
id TEXT PRIMARY KEY,
|
|
scheduled_task_id TEXT NOT NULL REFERENCES scheduled_tasks(id) ON DELETE RESTRICT,
|
|
schedule_version INTEGER NOT NULL CHECK (schedule_version > 0),
|
|
task_snapshot_json TEXT NOT NULL,
|
|
due_at TEXT NOT NULL,
|
|
claimed_at TEXT NOT NULL,
|
|
started_at TEXT,
|
|
finished_at TEXT,
|
|
target_snapshot_json TEXT NOT NULL,
|
|
outcome TEXT CHECK (outcome IS NULL OR outcome IN ('succeeded','partially-succeeded','failed','skipped','no-targets','needs-attention')),
|
|
reason TEXT,
|
|
job_ids_json TEXT NOT NULL DEFAULT '[]',
|
|
trigger_source TEXT NOT NULL CHECK (trigger_source IN ('scheduled','manual')),
|
|
attempt INTEGER NOT NULL DEFAULT 1 CHECK (attempt > 0),
|
|
UNIQUE (scheduled_task_id, schedule_version, due_at, trigger_source)
|
|
)`,
|
|
`INSERT INTO scheduled_tasks (id,name,operation_type,enabled,version,cron_expression,timezone,
|
|
target_selector_json,sms_secret_reference,sms_recipient_count,effective_start_at,
|
|
effective_end_at,misfire_policy,overlap_policy,retry_policy_json,next_due_at,
|
|
last_evaluated_at,created_by,updated_by,created_at,updated_at,deleted_at,trigger_json)
|
|
SELECT id,name,operation_type,enabled,version,cron_expression,timezone,target_selector_json,
|
|
sms_secret_reference,sms_recipient_count,effective_start_at,effective_end_at,misfire_policy,
|
|
overlap_policy,retry_policy_json,next_due_at,last_evaluated_at,created_by,updated_by,
|
|
created_at,updated_at,deleted_at,NULL FROM scheduled_tasks_old`,
|
|
`INSERT INTO scheduled_runs (id,scheduled_task_id,schedule_version,task_snapshot_json,due_at,
|
|
claimed_at,started_at,finished_at,target_snapshot_json,outcome,reason,job_ids_json,
|
|
trigger_source,attempt)
|
|
SELECT id,scheduled_task_id,schedule_version,task_snapshot_json,due_at,claimed_at,started_at,
|
|
finished_at,target_snapshot_json,outcome,reason,job_ids_json,trigger_source,attempt
|
|
FROM scheduled_runs_old`,
|
|
'DROP TABLE scheduled_runs_old',
|
|
'DROP TABLE scheduled_tasks_old',
|
|
'CREATE INDEX idx_scheduled_tasks_enabled_next_due ON scheduled_tasks(enabled, next_due_at) WHERE deleted_at IS NULL',
|
|
'CREATE INDEX idx_scheduled_runs_task_due ON scheduled_runs(scheduled_task_id, due_at DESC)',
|
|
'CREATE INDEX idx_scheduled_runs_outcome_finished ON scheduled_runs(outcome, finished_at DESC)',
|
|
],
|
|
},
|
|
{
|
|
id: 12,
|
|
name: 'schedule-action-delay',
|
|
statements: [
|
|
`ALTER TABLE scheduled_tasks ADD COLUMN delay_seconds INTEGER
|
|
CHECK (delay_seconds IS NULL OR (delay_seconds >= 0 AND delay_seconds <= 3600))`,
|
|
],
|
|
},
|
|
{
|
|
id: 13,
|
|
name: 'notification-rule-rate-limit-and-quiet-hours',
|
|
statements: [
|
|
`ALTER TABLE notification_rules ADD COLUMN rate_limit_json TEXT NOT NULL DEFAULT
|
|
'{"enabled":false,"maxMessages":20,"windowSeconds":60}'`,
|
|
`ALTER TABLE notification_rules ADD COLUMN quiet_hours_json TEXT NOT NULL DEFAULT '[]'`,
|
|
// Suppressions (quiet hours, rate limit) are attributed to a rule rather than a channel,
|
|
// so channel_id becomes optional. The status CHECK also has to widen, which means the
|
|
// table is rebuilt; nothing references notification_deliveries, so the swap is contained.
|
|
`CREATE TABLE notification_deliveries_new (
|
|
id TEXT PRIMARY KEY,
|
|
rule_id TEXT REFERENCES notification_rules(id) ON DELETE SET NULL,
|
|
channel_id TEXT REFERENCES notification_channels(id) ON DELETE CASCADE,
|
|
instance_id TEXT,
|
|
event_type TEXT NOT NULL,
|
|
status TEXT NOT NULL CHECK (status IN (
|
|
'success','failed','pending','sending','retrying','unmatched','no_available_channel',
|
|
'quiet_hours','rate_limited'
|
|
)),
|
|
detail TEXT,
|
|
created_at TEXT NOT NULL,
|
|
sent_at TEXT
|
|
)`,
|
|
`INSERT INTO notification_deliveries_new
|
|
(id,rule_id,channel_id,instance_id,event_type,status,detail,created_at,sent_at)
|
|
SELECT id,rule_id,channel_id,instance_id,event_type,status,detail,created_at,sent_at
|
|
FROM notification_deliveries`,
|
|
'DROP TABLE notification_deliveries',
|
|
'ALTER TABLE notification_deliveries_new RENAME TO notification_deliveries',
|
|
'CREATE INDEX idx_notification_deliveries_created_at ON notification_deliveries(created_at DESC)',
|
|
'CREATE INDEX idx_notification_deliveries_status_created_at ON notification_deliveries(status, created_at DESC)',
|
|
],
|
|
},
|
|
{
|
|
id: 14,
|
|
name: 'connection-log-history',
|
|
statements: [
|
|
// The Hub keeps a rolling connection history per device; the snapshot table only holds
|
|
// the latest probe, so outcomes need their own bounded journal.
|
|
`CREATE TABLE connection_logs (
|
|
id TEXT PRIMARY KEY,
|
|
instance_id TEXT NOT NULL REFERENCES instances(id) ON DELETE CASCADE,
|
|
outcome TEXT NOT NULL CHECK (outcome IN ('success','stale','failed','unsupported')),
|
|
state TEXT NOT NULL CHECK (state IN ('fresh','stale','expired','unknown')),
|
|
error_code TEXT,
|
|
http_status INTEGER,
|
|
duration_ms INTEGER NOT NULL CHECK (duration_ms >= 0),
|
|
observed_at TEXT NOT NULL
|
|
)`,
|
|
'CREATE INDEX idx_connection_logs_observed_at ON connection_logs(observed_at DESC)',
|
|
'CREATE INDEX idx_connection_logs_instance_observed_at ON connection_logs(instance_id, observed_at DESC)',
|
|
'CREATE INDEX idx_connection_logs_outcome_observed_at ON connection_logs(outcome, observed_at DESC)',
|
|
],
|
|
},
|
|
{
|
|
id: 15,
|
|
name: 'sms-offline-outbox',
|
|
statements: [
|
|
// The Hub keeps sending while a device is offline and delivers when it returns; the
|
|
// central archive alone cannot hold a write, so outbound SMS need their own queue.
|
|
`CREATE TABLE sms_outbox (
|
|
id TEXT PRIMARY KEY,
|
|
instance_id TEXT NOT NULL REFERENCES instances(id) ON DELETE CASCADE,
|
|
phone_number TEXT NOT NULL,
|
|
content TEXT NOT NULL,
|
|
status TEXT NOT NULL DEFAULT 'queued' CHECK (status IN (
|
|
'queued','sending','sent','failed','cancelled'
|
|
)),
|
|
attempts INTEGER NOT NULL DEFAULT 0 CHECK (attempts >= 0),
|
|
max_attempts INTEGER NOT NULL DEFAULT 24 CHECK (max_attempts > 0),
|
|
last_error TEXT,
|
|
available_at TEXT NOT NULL,
|
|
sent_at TEXT,
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL
|
|
)`,
|
|
'CREATE INDEX idx_sms_outbox_status_available_at ON sms_outbox(status, available_at)',
|
|
'CREATE INDEX idx_sms_outbox_instance_status ON sms_outbox(instance_id, status)',
|
|
'CREATE INDEX idx_sms_outbox_created_at ON sms_outbox(created_at DESC)',
|
|
],
|
|
},
|
|
{
|
|
id: 16,
|
|
name: 'notification-channel-secret-fields',
|
|
statements: [
|
|
// Hub channels keep several credentials (DingTalk needs an access token *and* a signing
|
|
// key), so the secret store holds a JSON map and the row records which keys it covers.
|
|
"ALTER TABLE notification_channels ADD COLUMN secret_fields TEXT NOT NULL DEFAULT ''",
|
|
],
|
|
},
|
|
];
|
|
|
|
const createMigrationsTable = `CREATE TABLE schema_migrations (
|
|
id INTEGER PRIMARY KEY,
|
|
name TEXT NOT NULL UNIQUE,
|
|
checksum TEXT NOT NULL,
|
|
applied_at TEXT NOT NULL
|
|
)`;
|
|
|
|
function checksum(migration: Migration): string {
|
|
return createHash('sha256').update(JSON.stringify(migration.statements)).digest('hex');
|
|
}
|
|
|
|
function validateCatalog(migrations: readonly Migration[]): void {
|
|
const ids = new Set<number>();
|
|
const names = new Set<string>();
|
|
let previous = -Infinity;
|
|
for (const migration of migrations) {
|
|
if (!Number.isSafeInteger(migration.id) || migration.id < 0)
|
|
throw new Error(`Migration id ${migration.id} is invalid`);
|
|
if (ids.has(migration.id)) throw new Error(`Duplicate migration id ${migration.id}`);
|
|
if (names.has(migration.name)) throw new Error(`Duplicate migration name ${migration.name}`);
|
|
if (migration.id <= previous) throw new Error('Migration ids must be strictly increasing');
|
|
ids.add(migration.id);
|
|
names.add(migration.name);
|
|
previous = migration.id;
|
|
}
|
|
}
|
|
|
|
export function migrateDatabase(
|
|
database: SqliteDatabase,
|
|
migrations: readonly Migration[] = MIGRATIONS,
|
|
): void {
|
|
validateCatalog(migrations);
|
|
database.transaction(() => {
|
|
database.exec(
|
|
'CREATE TABLE IF NOT EXISTS schema_migrations (id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE, checksum TEXT NOT NULL, applied_at TEXT NOT NULL)',
|
|
);
|
|
const maximum = database.prepare('SELECT MAX(id) AS id FROM schema_migrations').get() as {
|
|
id: number | null;
|
|
};
|
|
const applied = database.prepare('SELECT name, checksum FROM schema_migrations WHERE id = ?');
|
|
const appliedByName = database.prepare('SELECT id FROM schema_migrations WHERE name = ?');
|
|
const record = database.prepare(
|
|
'INSERT INTO schema_migrations (id, name, checksum, applied_at) VALUES (?, ?, ?, ?)',
|
|
);
|
|
for (const migration of migrations) {
|
|
const digest = checksum(migration);
|
|
const existing = applied.get(migration.id) as { name: string; checksum: string } | undefined;
|
|
if (existing) {
|
|
if (existing.name !== migration.name)
|
|
throw new Error(`Migration ${migration.id} name mismatch`);
|
|
if (existing.checksum !== digest)
|
|
throw new Error(`Migration ${migration.id} checksum drift detected`);
|
|
continue;
|
|
}
|
|
const existingName = appliedByName.get(migration.name) as { id: number } | undefined;
|
|
if (existingName)
|
|
throw new Error(
|
|
`Migration ${migration.name} drift detected: applied as id ${existingName.id}`,
|
|
);
|
|
if (maximum.id !== null && migration.id < maximum.id)
|
|
throw new Error(
|
|
`Migration ${migration.id} is an unapplied backfill below applied id ${maximum.id}`,
|
|
);
|
|
for (const statement of migration.statements) database.exec(statement);
|
|
record.run(migration.id, migration.name, digest, new Date().toISOString());
|
|
}
|
|
})();
|
|
}
|
|
|
|
// Kept separate so a failed first migration transaction removes metadata too.
|
|
export const migrationMetadataSql = createMigrationsTable;
|