relay-dsh-plugin-events 0.2.2 → 0.2.3-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,15 +1,15 @@
1
1
  # Relay Events for DeepSeek Harness
2
2
 
3
- > **Now supports DSH `0.1.2-rc.1` while retaining `0.1.2-alpha.3` compatibility.** Plugin `0.2.2` is verified on both releases. [Install it from npm](https://www.npmjs.com/package/relay-dsh-plugin-events) · [Compatibility evidence](https://github.com/yangbobo2021/Relay/tree/codex/relay-foundation/dsh-lab/dsh-0.1.2-rc.1-20260903).
3
+ > **Now supports DSH `0.1.2-rc.1` while retaining `0.1.2-alpha.3` compatibility.** Plugin `0.2.3` is verified on both releases and safely upgrades existing Relay databases. [Install it from npm](https://www.npmjs.com/package/relay-dsh-plugin-events) · [Compatibility evidence](https://github.com/yangbobo2021/Relay/tree/codex/relay-foundation/dsh-lab/dsh-0.1.2-rc.1-20260903).
4
4
 
5
- > **Release channels:** `latest` → `0.2.2`; `next` → `0.2.1-rc.1`.
5
+ > **Release channels:** `latest` → `0.2.3`; `next` → `0.2.3-rc.2`.
6
6
 
7
7
  ```bash
8
- npx @deepseek-ai/dsh@0.1.2-rc.1 plugin --profile web add relay-dsh-plugin-events@0.2.2
8
+ npx @deepseek-ai/dsh@0.1.2-rc.1 plugin --profile web add relay-dsh-plugin-events@0.2.3
9
9
  npx @deepseek-ai/dsh@0.1.2-rc.1 web
10
10
  ```
11
11
 
12
- [![DSH compatibility](https://img.shields.io/badge/DSH-0.1.1--rc.2%20%7C%200.1.2--alpha.2%20%7C%200.1.2--alpha.3-2f7d68)](https://github.com/deepseek-ai/deepseek-harness)
12
+ [![DSH compatibility](https://img.shields.io/badge/DSH-0.1.1--rc.2%20%7C%200.1.2--alpha.2%20%7C%200.1.2--alpha.3%20%7C%200.1.2--rc.1-2f7d68)](https://github.com/deepseek-ai/deepseek-harness)
13
13
 
14
14
  English | [中文](README.zh.md)
15
15
 
@@ -19,8 +19,8 @@ delivery to the owning existing Session, recovery, and a Waiting Events settings
19
19
  surface without adding an execution backend.
20
20
 
21
21
  The older `internal` npm channel remains available for integration testing and
22
- does not carry this compatibility guarantee. Use the exact `0.2.1`
23
- versions in the latest-DSH command above; do not substitute `@internal`.
22
+ does not carry this compatibility guarantee. Use the exact `0.2.3`
23
+ version in the latest-DSH command above; do not substitute `@internal`.
24
24
 
25
25
  ```bash
26
26
  dsh plugin --profile web add --save-exact relay-dsh-plugin-events@internal
@@ -31,6 +31,18 @@ Semantic routing and Monitor execution are separate plugins. Without a Router,
31
31
  Events uses exact event-type matching. Without Monitors, Wait-only registration
32
32
  and external ingress remain fully usable.
33
33
 
34
+ ## Database lifecycle
35
+
36
+ The Relay database is persistent and is not removed when the plugin is
37
+ uninstalled. On startup, the plugin initializes a missing or empty database at
38
+ the current schema. A recognized older schema is integrity-checked, backed up to
39
+ `<database>.backup-v<old>-to-v<current>.sqlite`, and upgraded in one transaction
40
+ before dependent indexes are created. The backup is verified and reused on a
41
+ retry. Migration failure rolls back all schema changes and reports the backup
42
+ path. An unknown non-empty schema, a corrupt database, foreign-key violations,
43
+ or a schema newer than the plugin supports is rejected rather than guessed or
44
+ silently modified.
45
+
34
46
  See [SPEC.md](SPEC.md) and
35
47
  [delivery scenarios](docs/acceptance-scenarios.md).
36
48
 
package/README.zh.md CHANGED
@@ -1,15 +1,15 @@
1
1
  # 面向 DeepSeek Harness 的 Relay Events
2
2
 
3
- > **现已支持 DSH `0.1.2-rc.1`,并保留对 `0.1.2-alpha.3` 的兼容。** 插件 `0.2.2` 已在两个版本上完成验证。[从 npm 安装](https://www.npmjs.com/package/relay-dsh-plugin-events) · [兼容性证据](https://github.com/yangbobo2021/Relay/tree/codex/relay-foundation/dsh-lab/dsh-0.1.2-rc.1-20260903)。
3
+ > **现已支持 DSH `0.1.2-rc.1`,并保留对 `0.1.2-alpha.3` 的兼容。** 插件 `0.2.3` 已在两个版本上完成验证,并可安全升级已有 Relay 数据库。[从 npm 安装](https://www.npmjs.com/package/relay-dsh-plugin-events) · [兼容性证据](https://github.com/yangbobo2021/Relay/tree/codex/relay-foundation/dsh-lab/dsh-0.1.2-rc.1-20260903)。
4
4
 
5
- > **发布通道:** `latest` → `0.2.2`;`next` → `0.2.1-rc.1`。
5
+ > **发布通道:** `latest` → `0.2.3`;`next` → `0.2.3-rc.2`。
6
6
 
7
7
  ```bash
8
- npx @deepseek-ai/dsh@0.1.2-rc.1 plugin --profile web add relay-dsh-plugin-events@0.2.2
8
+ npx @deepseek-ai/dsh@0.1.2-rc.1 plugin --profile web add relay-dsh-plugin-events@0.2.3
9
9
  npx @deepseek-ai/dsh@0.1.2-rc.1 web
10
10
  ```
11
11
 
12
- [![DSH 兼容版本](https://img.shields.io/badge/DSH-0.1.1--rc.2%20%7C%200.1.2--alpha.2%20%7C%200.1.2--alpha.3-2f7d68)](https://github.com/deepseek-ai/deepseek-harness)
12
+ [![DSH 兼容版本](https://img.shields.io/badge/DSH-0.1.1--rc.2%20%7C%200.1.2--alpha.2%20%7C%200.1.2--alpha.3%20%7C%200.1.2--rc.1-2f7d68)](https://github.com/deepseek-ai/deepseek-harness)
13
13
 
14
14
  [English](README.md) | 中文
15
15
 
@@ -18,7 +18,7 @@ Wait/Event/Delivery 核心。它提供 Agent 工具、通用 JSON 事件入口
18
18
  Session 投递、故障恢复以及 Waiting Events 设置界面,但不增加执行后端。
19
19
 
20
20
  旧的 `internal` npm 通道继续用于集成测试,不包含此次兼容保证。请使用上方最新版
21
- DSH 命令中精确的 `0.2.1` 版本,不要替换为 `@internal`。
21
+ DSH 命令中精确的 `0.2.3` 版本,不要替换为 `@internal`。
22
22
 
23
23
  ```bash
24
24
  dsh plugin --profile web add --save-exact relay-dsh-plugin-events@internal
@@ -28,6 +28,15 @@ dsh web
28
28
  语义路由与 Monitor 执行由独立插件提供。未安装 Router 时,Events 使用精确事件类型
29
29
  匹配;未安装 Monitors 时,Wait 注册与外部事件入口仍然可以完整使用。
30
30
 
31
+ ## 数据库生命周期
32
+
33
+ Relay 数据库是持久化数据,卸载插件时不会被删除。启动时,插件会将不存在或空数据库
34
+ 直接初始化为当前 schema。对于可识别的旧 schema,会先校验完整性,再备份到
35
+ `<database>.backup-v<old>-to-v<current>.sqlite`,然后在同一个事务内执行升级,
36
+ 最后创建依赖新字段的索引。备份会被校验,重试时会复用。迁移失败会回滚所有 schema 修改,
37
+ 并在错误中给出备份路径。未知的非空 schema、损坏的数据库、外键违规,或高于插件所支持版本的
38
+ schema 都会被拒绝,不会猜测结构或静默修改数据。
39
+
31
40
  详见 [SPEC.md](SPEC.md) 与[投递场景](docs/acceptance-scenarios.md)。
32
41
 
33
42
  将 `DSH_ROOT` 指向准备好的官方 DSH 只读检出,然后执行
@@ -29,7 +29,7 @@ Official DSH references: `b150a551b8d465e31e418e1b2eaf5e79bbb7d28e`,
29
29
  | EVT-021 | Non-exclusive fan-out | Exact non-exclusive matches create one atomic Delivery per Session with complete matched cards. | SQLite integration |
30
30
  | EVT-022 | Trusted bound source | Only a registered source capability can submit a binding; public payload ownership fields have no authority. | service + protocol security |
31
31
  | EVT-023 | Stale/cross-Session binding | Invalid owner, Wait, version, or Session/Wait pair escalates with no claim or Delivery. | SQLite integration |
32
- | EVT-024 | Schema upgrade | Existing schema v4/v5/v9 storage upgrades through schema v10, preserving Delivery snapshots and adding notification receipt/attempt evidence. | real file migration |
32
+ | EVT-024 | Safe schema initialization and upgrade | Missing, empty, and metadata-only storage initializes directly at schema v10; every historical schema v1-v9 is integrity-checked, verified-backup protected, transactionally upgraded, structurally validated, and idempotent while preserving data. Failed migration rolls back; unknown non-empty and future schemas fail closed without mutation. | real file migration matrix + official DSH startup |
33
33
  | EVT-025 | Router failure terminal state | Repeated Router failure reaches the configured budget, commits one inspectable escalation, creates no Delivery, and omits raw provider errors/secrets. | unit + SQLite |
34
34
  | EVT-026 | Incomplete Event recovery | The background scan resumes `received`/`routing` Events and reaches a single terminal decision without redelivery or duplicate attempts. | fake-clock integration + restart |
35
35
  | EVT-027 | Conflicting provider identity | Reuse of one source delivery identity with different content fails closed, preserves the original terminal Event, and creates no second record. | SQLite security integration |
package/lib/client.js CHANGED
@@ -18,58 +18,58 @@ window.__ModuleLoader__.load({
18
18
  document.head.appendChild(tag);
19
19
  }
20
20
  var WaitingEventsSection_module_css_default = {
21
- "error": "bDRFYa_error",
22
- "bundlePermissions": "bDRFYa_bundlePermissions",
23
- "details": "bDRFYa_details",
24
- "eventHeader": "bDRFYa_eventHeader",
25
- "pagination": "bDRFYa_pagination",
26
- "cadenceAction": "bDRFYa_cadenceAction",
27
- "connectorNote": "bDRFYa_connectorNote",
28
- "counts": "bDRFYa_counts",
29
- "registration": "bDRFYa_registration",
30
- "statusBadge": "bDRFYa_statusBadge",
31
- "filters": "bDRFYa_filters",
21
+ "historyHeader": "bDRFYa_historyHeader",
22
+ "failure": "bDRFYa_failure",
32
23
  "emptyHistory": "bDRFYa_emptyHistory",
33
- "monitors": "bDRFYa_monitors",
34
- "connectors": "bDRFYa_connectors",
35
24
  "iconButton": "bDRFYa_iconButton",
36
- "timerSummary": "bDRFYa_timerSummary",
37
- "waitContent": "bDRFYa_waitContent",
38
- "emptyCatalog": "bDRFYa_emptyCatalog",
25
+ "details": "bDRFYa_details",
26
+ "waitText": "bDRFYa_waitText",
39
27
  "bundleRemediation": "bDRFYa_bundleRemediation",
40
- "actions": "bDRFYa_actions",
41
- "monitorMain": "bDRFYa_monitorMain",
42
- "historyHeader": "bDRFYa_historyHeader",
43
- "rowState": "bDRFYa_rowState",
44
- "activation": "bDRFYa_activation",
28
+ "empty": "bDRFYa_empty",
29
+ "connectors": "bDRFYa_connectors",
45
30
  "connector": "bDRFYa_connector",
46
- "confirmation": "bDRFYa_confirmation",
47
- "summary": "bDRFYa_summary",
48
- "registrationHeader": "bDRFYa_registrationHeader",
49
- "registrations": "bDRFYa_registrations",
50
- "section": "bDRFYa_section",
51
- "secretAction": "bDRFYa_secretAction",
52
- "waitText": "bDRFYa_waitText",
53
- "sessionButton": "bDRFYa_sessionButton",
31
+ "bundlePermissions": "bDRFYa_bundlePermissions",
32
+ "error": "bDRFYa_error",
33
+ "counts": "bDRFYa_counts",
34
+ "monitors": "bDRFYa_monitors",
54
35
  "connectorHeader": "bDRFYa_connectorHeader",
55
- "total": "bDRFYa_total",
36
+ "accounts": "bDRFYa_accounts",
56
37
  "monitorTimes": "bDRFYa_monitorTimes",
38
+ "message": "bDRFYa_message",
57
39
  "catalogHeader": "bDRFYa_catalogHeader",
58
- "events": "bDRFYa_events",
59
- "bundleTypes": "bDRFYa_bundleTypes",
40
+ "registrations": "bDRFYa_registrations",
41
+ "registration": "bDRFYa_registration",
42
+ "registrationHeader": "bDRFYa_registrationHeader",
60
43
  "bundleHeader": "bDRFYa_bundleHeader",
61
- "overdue": "bDRFYa_overdue",
62
- "message": "bDRFYa_message",
63
- "title": "bDRFYa_title",
44
+ "rowState": "bDRFYa_rowState",
64
45
  "bundleCatalog": "bDRFYa_bundleCatalog",
65
- "stateDot": "bDRFYa_stateDot",
46
+ "sessionButton": "bDRFYa_sessionButton",
47
+ "summary": "bDRFYa_summary",
48
+ "activation": "bDRFYa_activation",
49
+ "connectorNote": "bDRFYa_connectorNote",
50
+ "bundleTypes": "bDRFYa_bundleTypes",
51
+ "eventHeader": "bDRFYa_eventHeader",
52
+ "filters": "bDRFYa_filters",
53
+ "statusBadge": "bDRFYa_statusBadge",
54
+ "actions": "bDRFYa_actions",
55
+ "waitContent": "bDRFYa_waitContent",
56
+ "monitorMain": "bDRFYa_monitorMain",
57
+ "section": "bDRFYa_section",
58
+ "emptyCatalog": "bDRFYa_emptyCatalog",
59
+ "pagination": "bDRFYa_pagination",
60
+ "timerSummary": "bDRFYa_timerSummary",
61
+ "cadenceAction": "bDRFYa_cadenceAction",
66
62
  "toolbar": "bDRFYa_toolbar",
67
- "waits": "bDRFYa_waits",
68
- "history": "bDRFYa_history",
69
- "accounts": "bDRFYa_accounts",
70
- "failure": "bDRFYa_failure",
63
+ "total": "bDRFYa_total",
71
64
  "eventDetails": "bDRFYa_eventDetails",
72
- "empty": "bDRFYa_empty"
65
+ "confirmation": "bDRFYa_confirmation",
66
+ "history": "bDRFYa_history",
67
+ "waits": "bDRFYa_waits",
68
+ "events": "bDRFYa_events",
69
+ "secretAction": "bDRFYa_secretAction",
70
+ "overdue": "bDRFYa_overdue",
71
+ "title": "bDRFYa_title",
72
+ "stateDot": "bDRFYa_stateDot"
73
73
  };
74
74
  //#endregion
75
75
  //#region src/client/WaitingEventsSection.tsx
@@ -1,5 +1,5 @@
1
1
  import assert from "node:assert/strict";
2
- import { mkdirSync } from "node:fs";
2
+ import { existsSync, mkdirSync, renameSync, rmSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
4
  import { dirname, join, resolve } from "node:path";
5
5
  import { Service } from "@deepseek-ai/cordis";
@@ -443,7 +443,7 @@ function boundRoutingDecision(sessions, binding, providerId) {
443
443
  }
444
444
  //#endregion
445
445
  //#region src/runtime/schema.mjs
446
- const SCHEMA_SQL = `
446
+ const SCHEMA_TABLES_SQL = `
447
447
  CREATE TABLE IF NOT EXISTS relay_schema (
448
448
  version INTEGER PRIMARY KEY,
449
449
  applied_at TEXT NOT NULL
@@ -489,10 +489,6 @@ const SCHEMA_SQL = `
489
489
  updated_at TEXT NOT NULL
490
490
  ) STRICT;
491
491
 
492
- CREATE UNIQUE INDEX IF NOT EXISTS activations_one_active_per_session
493
- ON activations(session_id)
494
- WHERE state = 'active';
495
-
496
492
  CREATE TABLE IF NOT EXISTS runs (
497
493
  id TEXT PRIMARY KEY,
498
494
  session_id TEXT NOT NULL REFERENCES sessions(id),
@@ -521,14 +517,6 @@ const SCHEMA_SQL = `
521
517
  updated_at TEXT NOT NULL
522
518
  ) STRICT;
523
519
 
524
- CREATE UNIQUE INDEX IF NOT EXISTS waits_live_exclusive_owner
525
- ON waits(exclusive_owner_key)
526
- WHERE exclusive_owner_key IS NOT NULL
527
- AND status IN ('active', 'claimed');
528
-
529
- CREATE INDEX IF NOT EXISTS waits_session_status
530
- ON waits(session_id, status);
531
-
532
520
  CREATE TABLE IF NOT EXISTS events (
533
521
  id TEXT PRIMARY KEY,
534
522
  source TEXT NOT NULL,
@@ -544,17 +532,6 @@ const SCHEMA_SQL = `
544
532
  updated_at TEXT NOT NULL
545
533
  ) STRICT;
546
534
 
547
- CREATE UNIQUE INDEX IF NOT EXISTS events_source_identity
548
- ON events(source, source_event_id)
549
- WHERE source_event_id IS NOT NULL;
550
-
551
- CREATE UNIQUE INDEX IF NOT EXISTS events_source_fingerprint
552
- ON events(source, fingerprint);
553
-
554
- CREATE UNIQUE INDEX IF NOT EXISTS events_trusted_correlation
555
- ON events(correlation_key)
556
- WHERE correlation_key IS NOT NULL;
557
-
558
535
  CREATE TABLE IF NOT EXISTS routing_attempts (
559
536
  id TEXT PRIMARY KEY,
560
537
  event_id TEXT NOT NULL REFERENCES events(id),
@@ -599,12 +576,6 @@ const SCHEMA_SQL = `
599
576
  PRIMARY KEY (delivery_id, wait_id)
600
577
  ) STRICT;
601
578
 
602
- CREATE INDEX IF NOT EXISTS deliveries_session_state
603
- ON deliveries(session_id, state, created_at);
604
-
605
- CREATE INDEX IF NOT EXISTS deliveries_event_state
606
- ON deliveries(event_id, state);
607
-
608
579
  CREATE TABLE IF NOT EXISTS monitors (
609
580
  id TEXT PRIMARY KEY,
610
581
  session_id TEXT NOT NULL REFERENCES sessions(id),
@@ -677,15 +648,6 @@ const SCHEMA_SQL = `
677
648
  UNIQUE(monitor_id, trigger_key)
678
649
  ) STRICT;
679
650
 
680
- CREATE INDEX IF NOT EXISTS monitors_due
681
- ON monitors(state, next_check_at);
682
-
683
- CREATE INDEX IF NOT EXISTS monitors_session_state
684
- ON monitors(session_id, state);
685
-
686
- CREATE INDEX IF NOT EXISTS monitor_checks_monitor_time
687
- ON monitor_checks(monitor_id, started_at);
688
-
689
651
  CREATE TABLE IF NOT EXISTS notification_outcomes (
690
652
  event_id TEXT PRIMARY KEY REFERENCES events(id),
691
653
  provider TEXT,
@@ -697,31 +659,119 @@ const SCHEMA_SQL = `
697
659
  updated_at TEXT NOT NULL
698
660
  ) STRICT;
699
661
  `;
662
+ const SCHEMA_INDEXES_SQL = `
663
+ CREATE UNIQUE INDEX IF NOT EXISTS activations_one_active_per_session
664
+ ON activations(session_id)
665
+ WHERE state = 'active';
666
+
667
+ CREATE UNIQUE INDEX IF NOT EXISTS waits_live_exclusive_owner
668
+ ON waits(exclusive_owner_key)
669
+ WHERE exclusive_owner_key IS NOT NULL
670
+ AND status IN ('active', 'claimed');
671
+
672
+ CREATE INDEX IF NOT EXISTS waits_session_status
673
+ ON waits(session_id, status);
674
+
675
+ CREATE UNIQUE INDEX IF NOT EXISTS events_source_identity
676
+ ON events(source, source_event_id)
677
+ WHERE source_event_id IS NOT NULL;
678
+
679
+ CREATE UNIQUE INDEX IF NOT EXISTS events_source_fingerprint
680
+ ON events(source, fingerprint);
681
+
682
+ CREATE UNIQUE INDEX IF NOT EXISTS events_trusted_correlation
683
+ ON events(correlation_key)
684
+ WHERE correlation_key IS NOT NULL;
685
+
686
+ CREATE INDEX IF NOT EXISTS deliveries_session_state
687
+ ON deliveries(session_id, state, created_at);
688
+
689
+ CREATE INDEX IF NOT EXISTS deliveries_event_state
690
+ ON deliveries(event_id, state);
691
+
692
+ CREATE INDEX IF NOT EXISTS monitors_due
693
+ ON monitors(state, next_check_at);
694
+
695
+ CREATE INDEX IF NOT EXISTS monitors_session_state
696
+ ON monitors(session_id, state);
697
+
698
+ CREATE INDEX IF NOT EXISTS monitor_checks_monitor_time
699
+ ON monitor_checks(monitor_id, started_at);
700
+ `;
701
+ const SCHEMA_SQL = `${SCHEMA_TABLES_SQL}\n${SCHEMA_INDEXES_SQL}`;
700
702
  //#endregion
701
703
  //#region src/runtime/store.mjs
702
704
  var RelayStore = class {
703
705
  constructor(path = ":memory:", { clock = () => /* @__PURE__ */ new Date(), idFactory = randomUUID } = {}) {
704
706
  this.clock = clock;
705
707
  this.idFactory = idFactory;
708
+ this.migrationBackupPath = null;
706
709
  this.database = new DatabaseSync(path);
707
- this.database.exec("PRAGMA foreign_keys = ON");
708
- this.database.exec("PRAGMA busy_timeout = 5000");
709
- this.database.exec("PRAGMA journal_mode = WAL");
710
- this.database.exec(SCHEMA_SQL);
711
- const schemaVersion = this.database.prepare("SELECT MAX(version) AS version FROM relay_schema").get().version;
712
- if (schemaVersion == null) this.database.prepare("INSERT INTO relay_schema (version, applied_at) VALUES (?, ?)").run(10, this.now());
713
- else {
714
- assert.ok(schemaVersion <= 10, `unsupported schema version ${schemaVersion}`);
715
- if (schemaVersion < 10) {
716
- this.migrateSchema(schemaVersion);
710
+ try {
711
+ this.database.exec("PRAGMA foreign_keys = ON");
712
+ this.database.exec("PRAGMA busy_timeout = 5000");
713
+ this.initializeSchema(path);
714
+ this.database.prepare("INSERT OR IGNORE INTO runtime_counters (name, value) VALUES ('routing_epoch', 0)").run();
715
+ } catch (error) {
716
+ this.database.close();
717
+ const backup = this.migrationBackupPath ? ` Backup: ${this.migrationBackupPath}.` : "";
718
+ throw new Error(`Relay database initialization failed: ${error.message}.${backup}`, { cause: error });
719
+ }
720
+ }
721
+ initializeSchema(path) {
722
+ const tables = this.database.prepare("SELECT name FROM sqlite_schema WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name").all().map((row) => row.name);
723
+ if (tables.length === 0 || tables.length === 1 && tables[0] === "relay_schema" && this.database.prepare("SELECT COUNT(*) AS count FROM relay_schema").get().count === 0) {
724
+ this.database.exec("PRAGMA journal_mode = WAL");
725
+ this.transaction(() => {
726
+ this.database.exec(SCHEMA_SQL);
717
727
  this.database.prepare("INSERT INTO relay_schema (version, applied_at) VALUES (?, ?)").run(10, this.now());
718
- }
728
+ this.assertCurrentSchema();
729
+ });
730
+ return;
731
+ }
732
+ assert.ok(tables.includes("relay_schema"), "non-empty Relay database has no relay_schema metadata; refusing an unsafe implicit migration");
733
+ const schemaVersion = this.database.prepare("SELECT MAX(version) AS version FROM relay_schema").get().version;
734
+ assert.ok(Number.isSafeInteger(schemaVersion) && schemaVersion > 0, "non-empty Relay database has no valid schema version");
735
+ assert.ok(schemaVersion <= 10, `database schema version ${schemaVersion} is newer than supported version 10`);
736
+ this.database.exec("PRAGMA journal_mode = WAL");
737
+ this.assertDatabaseIntegrity("before schema initialization");
738
+ if (schemaVersion < 10) this.migrationBackupPath = this.createMigrationBackup(path, schemaVersion);
739
+ this.transaction(() => {
740
+ this.database.exec(SCHEMA_TABLES_SQL);
741
+ if (schemaVersion < 10) this.migrateSchema(schemaVersion);
742
+ this.assertRequiredTablesAndColumns();
743
+ this.database.exec(SCHEMA_INDEXES_SQL);
744
+ this.assertCurrentSchema();
745
+ });
746
+ }
747
+ createMigrationBackup(path, fromVersion) {
748
+ if (path === ":memory:" || typeof path !== "string") return null;
749
+ const backupPath = `${path}.backup-v${fromVersion}-to-v10.sqlite`;
750
+ if (existsSync(backupPath)) {
751
+ assertDatabaseFileIsHealthy(backupPath, "existing migration backup");
752
+ return backupPath;
753
+ }
754
+ const temporaryPath = `${backupPath}.tmp-${process.pid}-${randomUUID()}`;
755
+ try {
756
+ this.database.exec(`VACUUM INTO ${sqlString(temporaryPath)}`);
757
+ assertDatabaseFileIsHealthy(temporaryPath, "new migration backup");
758
+ renameSync(temporaryPath, backupPath);
759
+ return backupPath;
760
+ } catch (error) {
761
+ if (existsSync(temporaryPath)) rmSync(temporaryPath);
762
+ throw new Error(`could not create verified migration backup ${backupPath}: ${error.message}`, { cause: error });
719
763
  }
720
- this.database.prepare("INSERT OR IGNORE INTO runtime_counters (name, value) VALUES ('routing_epoch', 0)").run();
721
764
  }
722
765
  migrateSchema(fromVersion) {
723
- if (fromVersion < 3) {
724
- if (!this.database.prepare("PRAGMA table_info(runs)").all().some((column) => column.name === "activation_id")) this.transaction(() => {
766
+ for (let targetVersion = fromVersion + 1; targetVersion <= 10; targetVersion += 1) {
767
+ this.applyMigration(targetVersion);
768
+ this.database.prepare("INSERT INTO relay_schema (version, applied_at) VALUES (?, ?)").run(targetVersion, this.now());
769
+ }
770
+ }
771
+ applyMigration(targetVersion) {
772
+ if (targetVersion === 2) return;
773
+ if (targetVersion === 3) {
774
+ if (!this.database.prepare("PRAGMA table_info(runs)").all().some((column) => column.name === "activation_id")) {
725
775
  this.database.exec("ALTER TABLE runs ADD COLUMN activation_id TEXT");
726
776
  const runs = this.database.prepare("SELECT * FROM runs ORDER BY started_at, id").all();
727
777
  for (const run of runs) {
@@ -729,17 +779,18 @@ var RelayStore = class {
729
779
  const state = run.state === "running" ? "active" : "committed";
730
780
  const timestamp = run.finished_at ?? run.started_at;
731
781
  this.database.prepare(`
732
- INSERT INTO activations (
733
- id, session_id, trigger_type, state, delivery_ids_json,
734
- provisional_outcome_json, provisional_at, committed_at,
735
- created_at, updated_at
736
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
737
- `).run(activationId, run.session_id, run.trigger_type, state, run.delivery_ids_json, run.outcome_json, run.outcome_json == null ? null : timestamp, state === "committed" ? timestamp : null, run.started_at, timestamp);
782
+ INSERT INTO activations (
783
+ id, session_id, trigger_type, state, delivery_ids_json,
784
+ provisional_outcome_json, provisional_at, committed_at,
785
+ created_at, updated_at
786
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
787
+ `).run(activationId, run.session_id, run.trigger_type, state, run.delivery_ids_json, run.outcome_json, run.outcome_json == null ? null : timestamp, state === "committed" ? timestamp : null, run.started_at, timestamp);
738
788
  this.database.prepare("UPDATE runs SET activation_id = ? WHERE id = ?").run(activationId, run.id);
739
789
  }
740
- });
790
+ }
791
+ return;
741
792
  }
742
- if (fromVersion < 4) {
793
+ if (targetVersion === 4) {
743
794
  const columns = this.database.prepare("PRAGMA table_info(activations)").all();
744
795
  const names = new Set(columns.map((column) => column.name));
745
796
  for (const [name, type] of [
@@ -748,8 +799,8 @@ var RelayStore = class {
748
799
  ["accepted_at", "TEXT"],
749
800
  ["last_error", "TEXT"]
750
801
  ]) if (!names.has(name)) this.database.exec(`ALTER TABLE activations ADD COLUMN ${name} ${type}`);
751
- this.transaction(() => {
752
- this.database.exec(`
802
+ const routingSql = this.database.prepare("SELECT sql FROM sqlite_schema WHERE type = 'table' AND name = 'routing_decisions'").get()?.sql ?? "";
803
+ if (/\bspawn\b/u.test(routingSql)) this.database.exec(`
753
804
  ALTER TABLE routing_decisions RENAME TO routing_decisions_v3;
754
805
  CREATE TABLE routing_decisions (
755
806
  id TEXT PRIMARY KEY,
@@ -771,15 +822,16 @@ var RelayStore = class {
771
822
  FROM routing_decisions_v3;
772
823
  DROP TABLE routing_decisions_v3;
773
824
  `);
774
- });
825
+ return;
775
826
  }
776
- if (fromVersion < 5) {
827
+ if (targetVersion === 5) {
777
828
  const columns = this.database.prepare("PRAGMA table_info(delivery_waits)").all();
778
829
  const names = new Set(columns.map((column) => column.name));
779
830
  if (!names.has("ordinal")) this.database.exec("ALTER TABLE delivery_waits ADD COLUMN ordinal INTEGER NOT NULL DEFAULT 0");
780
831
  if (!names.has("wait_snapshot_json")) this.database.exec("ALTER TABLE delivery_waits ADD COLUMN wait_snapshot_json TEXT");
832
+ return;
781
833
  }
782
- if (fromVersion < 6) {
834
+ if (targetVersion === 6) {
783
835
  const columns = this.database.prepare("PRAGMA table_info(monitors)").all();
784
836
  const names = new Set(columns.map((column) => column.name));
785
837
  for (const [name, type] of [
@@ -789,8 +841,10 @@ var RelayStore = class {
789
841
  ["terminal_actor", "TEXT"],
790
842
  ["terminal_at", "TEXT"]
791
843
  ]) if (!names.has(name)) this.database.exec(`ALTER TABLE monitors ADD COLUMN ${name} ${type}`);
844
+ return;
792
845
  }
793
- if (fromVersion < 7) this.database.exec(`
846
+ if (targetVersion === 7) {
847
+ this.database.exec(`
794
848
  CREATE TABLE IF NOT EXISTS notification_outcomes (
795
849
  event_id TEXT PRIMARY KEY REFERENCES events(id),
796
850
  provider TEXT,
@@ -800,11 +854,13 @@ var RelayStore = class {
800
854
  updated_at TEXT NOT NULL
801
855
  ) STRICT;
802
856
  `);
803
- if (fromVersion < 8) {
857
+ return;
858
+ }
859
+ if (targetVersion === 8) {
804
860
  if (!this.database.prepare("PRAGMA table_info(events)").all().some((column) => column.name === "correlation_key")) this.database.exec("ALTER TABLE events ADD COLUMN correlation_key TEXT");
805
- this.database.exec("CREATE UNIQUE INDEX IF NOT EXISTS events_trusted_correlation ON events(correlation_key) WHERE correlation_key IS NOT NULL");
861
+ return;
806
862
  }
807
- if (fromVersion < 9) {
863
+ if (targetVersion === 9) {
808
864
  const columns = new Set(this.database.prepare("PRAGMA table_info(activations)").all().map((column) => column.name));
809
865
  for (const [name, type] of [
810
866
  ["attempt_count", "INTEGER NOT NULL DEFAULT 0"],
@@ -812,12 +868,36 @@ var RelayStore = class {
812
868
  ["terminal_reason_code", "TEXT"],
813
869
  ["terminal_at", "TEXT"]
814
870
  ]) if (!columns.has(name)) this.database.exec(`ALTER TABLE activations ADD COLUMN ${name} ${type}`);
871
+ return;
815
872
  }
816
- if (fromVersion < 10) {
873
+ if (targetVersion === 10) {
817
874
  const columns = new Set(this.database.prepare("PRAGMA table_info(notification_outcomes)").all().map((column) => column.name));
818
875
  if (!columns.has("receipt_id")) this.database.exec("ALTER TABLE notification_outcomes ADD COLUMN receipt_id TEXT");
819
876
  if (!columns.has("attempt_count")) this.database.exec("ALTER TABLE notification_outcomes ADD COLUMN attempt_count INTEGER NOT NULL DEFAULT 0");
877
+ return;
820
878
  }
879
+ throw new Error(`no Relay database migration is defined for schema version ${targetVersion}`);
880
+ }
881
+ assertRequiredTablesAndColumns() {
882
+ const expected = expectedSchemaShape();
883
+ for (const [table, expectedColumns] of expected.tables) {
884
+ const actualColumns = new Set(this.database.prepare(`PRAGMA table_info(${quoteIdentifier(table)})`).all().map((column) => column.name));
885
+ assert.ok(actualColumns.size > 0, `required table ${table} is missing`);
886
+ for (const column of expectedColumns) assert.ok(actualColumns.has(column), `required column ${table}.${column} is missing`);
887
+ }
888
+ }
889
+ assertCurrentSchema() {
890
+ this.assertRequiredTablesAndColumns();
891
+ const expected = expectedSchemaShape();
892
+ const actualIndexes = new Map(this.database.prepare("SELECT name, sql FROM sqlite_schema WHERE type = 'index' AND sql IS NOT NULL").all().map((row) => [row.name, normalizeSql(row.sql)]));
893
+ for (const [name, sql] of expected.indexes) assert.equal(actualIndexes.get(name), sql, `required index ${name} does not match the current schema`);
894
+ this.assertDatabaseIntegrity("after schema initialization");
895
+ const foreignKeyFailures = this.database.prepare("PRAGMA foreign_key_check").all();
896
+ assert.deepEqual(foreignKeyFailures, [], "Relay database contains foreign-key violations");
897
+ }
898
+ assertDatabaseIntegrity(stage) {
899
+ const results = this.database.prepare("PRAGMA quick_check").all();
900
+ assert.deepEqual(results.map((row) => row.quick_check), ["ok"], `Relay database failed integrity validation ${stage}`);
821
901
  }
822
902
  close() {
823
903
  this.database.close();
@@ -2114,6 +2194,40 @@ var RelayStore = class {
2114
2194
  this.database.prepare("UPDATE runtime_counters SET value = value + 1 WHERE name = 'routing_epoch'").run();
2115
2195
  }
2116
2196
  };
2197
+ let cachedExpectedSchemaShape;
2198
+ function expectedSchemaShape() {
2199
+ if (cachedExpectedSchemaShape) return cachedExpectedSchemaShape;
2200
+ const reference = new DatabaseSync(":memory:");
2201
+ try {
2202
+ reference.exec(SCHEMA_SQL);
2203
+ const tableNames = reference.prepare("SELECT name FROM sqlite_schema WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name").all().map((row) => row.name);
2204
+ cachedExpectedSchemaShape = {
2205
+ tables: new Map(tableNames.map((table) => [table, reference.prepare(`PRAGMA table_info(${quoteIdentifier(table)})`).all().map((column) => column.name)])),
2206
+ indexes: new Map(reference.prepare("SELECT name, sql FROM sqlite_schema WHERE type = 'index' AND sql IS NOT NULL ORDER BY name").all().map((row) => [row.name, normalizeSql(row.sql)]))
2207
+ };
2208
+ return cachedExpectedSchemaShape;
2209
+ } finally {
2210
+ reference.close();
2211
+ }
2212
+ }
2213
+ function assertDatabaseFileIsHealthy(path, label) {
2214
+ const database = new DatabaseSync(path, { readOnly: true });
2215
+ try {
2216
+ const results = database.prepare("PRAGMA quick_check").all().map((row) => row.quick_check);
2217
+ assert.deepEqual(results, ["ok"], `${label} failed SQLite integrity validation: ${path}`);
2218
+ } finally {
2219
+ database.close();
2220
+ }
2221
+ }
2222
+ function normalizeSql(value) {
2223
+ return value.replace(/\s+/gu, " ").trim().replace(/;$/u, "");
2224
+ }
2225
+ function quoteIdentifier(value) {
2226
+ return `"${value.replaceAll("\"", "\"\"")}"`;
2227
+ }
2228
+ function sqlString(value) {
2229
+ return `'${value.replaceAll("'", "''")}'`;
2230
+ }
2117
2231
  function hydrateWaitRegistration(row, waits) {
2118
2232
  return {
2119
2233
  session_id: row.id,