feat(api): absorb the Hub control plane into the local instance model
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.
This commit is contained in:
@@ -62,21 +62,30 @@ describe('database migrations', () => {
|
||||
'app_settings',
|
||||
'audit_events',
|
||||
'capabilities',
|
||||
'connection_logs',
|
||||
'console_auth_config',
|
||||
'console_auth_sessions',
|
||||
'device_groups',
|
||||
'event_journal',
|
||||
'instance_tags',
|
||||
'instances',
|
||||
'job_attempts',
|
||||
'job_items',
|
||||
'jobs',
|
||||
'notification_channels',
|
||||
'notification_deliveries',
|
||||
'notification_queue',
|
||||
'notification_rules',
|
||||
'operation_preparations',
|
||||
'scheduled_runs',
|
||||
'scheduled_tasks',
|
||||
'schema_migrations',
|
||||
'secret_cleanup_tasks',
|
||||
'secret_references',
|
||||
'sms_messages',
|
||||
'sms_outbox',
|
||||
'status_snapshots',
|
||||
'tag_registry',
|
||||
]);
|
||||
|
||||
expect(database.pragma('foreign_keys', { simple: true })).toBe(1);
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { MIGRATIONS, migrateDatabase } from './migrations.js';
|
||||
|
||||
function seeded(): Database.Database {
|
||||
const db = new Database(':memory:');
|
||||
db.pragma('foreign_keys=ON');
|
||||
migrateDatabase(db, MIGRATIONS.slice(0, 10));
|
||||
db.prepare(
|
||||
`INSERT INTO instances (id,name,base_url,auth_mode,enabled,config_revision,created_at,updated_at)
|
||||
VALUES ('i-1','Node One','http://a','password',1,1,'2026-01-01T00:00:00.000Z','2026-01-01T00:00:00.000Z')`,
|
||||
).run();
|
||||
db.prepare(
|
||||
`INSERT INTO instance_tags (instance_id,tag,created_at) VALUES ('i-1','office','2026-01-01T00:00:00.000Z')`,
|
||||
).run();
|
||||
db.prepare(
|
||||
`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)
|
||||
VALUES ('t-1','Nightly','reboot-system',1,3,'0 3 * * *','Asia/Shanghai','{"mode":"fixed","instanceIds":["i-1"]}',
|
||||
NULL,NULL,NULL,NULL,'skip','skip','{"maxRetries":2,"intervalSeconds":60}',
|
||||
'2026-09-04T03:00:00.000Z',NULL,'sys','sys','2026-01-01T00:00:00.000Z','2026-01-01T00:00:00.000Z',NULL)`,
|
||||
).run();
|
||||
db.prepare(
|
||||
`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)
|
||||
VALUES ('r-1','t-1',3,'{}','2026-09-03T03:00:00.000Z','2026-09-03T03:00:00.000Z',
|
||||
'2026-09-03T03:00:01.000Z','2026-09-03T03:00:02.000Z','["i-1"]','succeeded',NULL,'[]','scheduled',1)`,
|
||||
).run();
|
||||
return db;
|
||||
}
|
||||
|
||||
describe('migration upgrade path', () => {
|
||||
it('preserves automation rows and registers existing tags when upgrading from migration 10', () => {
|
||||
const db = seeded();
|
||||
expect(() => migrateDatabase(db)).not.toThrow();
|
||||
const task = db.prepare('SELECT * FROM scheduled_tasks WHERE id=?').get('t-1') as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(task).toMatchObject({ name: 'Nightly', operation_type: 'reboot-system', version: 3 });
|
||||
expect(task.trigger_json).toBeNull();
|
||||
const run = db.prepare('SELECT * FROM scheduled_runs WHERE id=?').get('r-1') as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(run).toMatchObject({ scheduled_task_id: 't-1', outcome: 'succeeded' });
|
||||
expect(db.prepare('SELECT tag FROM tag_registry ORDER BY tag').all()).toEqual([
|
||||
{ tag: 'office' },
|
||||
]);
|
||||
expect(db.prepare('PRAGMA foreign_key_check').all()).toEqual([]);
|
||||
db.prepare(
|
||||
`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)
|
||||
VALUES ('t-2','Baseband','restart-baseband',1,1,'0 4 * * *','Asia/Shanghai','{"mode":"all"}',
|
||||
NULL,NULL,NULL,NULL,'skip','skip','{"maxRetries":0,"intervalSeconds":60}',NULL,NULL,'sys','sys',
|
||||
'2026-01-01T00:00:00.000Z','2026-01-01T00:00:00.000Z',NULL,'{"kind":"interval","value":6,"unit":"hours"}')`,
|
||||
).run();
|
||||
expect(
|
||||
(
|
||||
db.prepare('SELECT trigger_json FROM scheduled_tasks WHERE id=?').get('t-2') as {
|
||||
trigger_json: string;
|
||||
}
|
||||
).trigger_json,
|
||||
).toContain('interval');
|
||||
const names = (
|
||||
db
|
||||
.prepare(
|
||||
"SELECT name FROM sqlite_master WHERE type='index' AND name LIKE 'idx_scheduled%' ORDER BY name",
|
||||
)
|
||||
.all() as Array<{
|
||||
name: string;
|
||||
}>
|
||||
).map((row) => row.name);
|
||||
expect(names).toEqual([
|
||||
'idx_scheduled_runs_outcome_finished',
|
||||
'idx_scheduled_runs_task_due',
|
||||
'idx_scheduled_tasks_enabled_next_due',
|
||||
]);
|
||||
db.close();
|
||||
});
|
||||
|
||||
it('applies cleanly to an empty database', () => {
|
||||
const db = new Database(':memory:');
|
||||
db.pragma('foreign_keys=ON');
|
||||
migrateDatabase(db);
|
||||
expect(db.prepare('PRAGMA foreign_key_check').all()).toEqual([]);
|
||||
const applied = db.prepare('SELECT id,name FROM schema_migrations ORDER BY id').all() as Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
}>;
|
||||
// The ledger must match the declared migration list exactly, in order, so a new migration
|
||||
// can never be added without being applied here.
|
||||
expect(applied).toEqual(
|
||||
MIGRATIONS.map((migration) => ({ id: migration.id, name: migration.name })),
|
||||
);
|
||||
db.close();
|
||||
});
|
||||
});
|
||||
@@ -356,6 +356,288 @@ export const MIGRATIONS: readonly Migration[] = [
|
||||
'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 (
|
||||
|
||||
Reference in New Issue
Block a user