fix(schema): normalize federation_peers.consecutive_failures to NOT NULL

The column was `integer DEFAULT 0` (nullable) since the initial schema.
Counters should not be nullable — the semantics are a count, not an
optional measurement. `consecutive_auth_failures` (added later) was
correctly declared NOT NULL; tightening `consecutive_failures` to match
removes the drift and eliminates the "|null" burden everywhere the value
is read.

SQLite does not support in-place ALTER … SET NOT NULL, so drizzle-kit
cannot auto-generate this. The manual migration uses the standard
SQLite recreate pattern (new table + INSERT SELECT + DROP + RENAME +
recreate index) under `PRAGMA defer_foreign_keys = ON` so the existing
federation_outbox → federation_peers FK survives the swap. The COPY
step coalesces any hypothetical NULL to 0 defensively; live probes on
both test instances (nova, orbit) showed zero NULL rows so no
actual backfill is required.

Verified by applying the full migration chain against a copy of the VM's
live DB: column ends as `notnull=1 dflt=0`, the peer row is preserved,
the unique index on origin is recreated, NULL inserts are rejected, and
`PRAGMA foreign_key_check` reports no violations.

Server `SanitizedPeer.consecutiveFailures` tightened to `number` to
match the new drizzle inference and the shared `FederationPeer` shape.

Follow-up #22 from S2S DM unification backlog.
This commit is contained in:
Jannis Braun
2026-04-21 22:21:06 +02:00
parent 0d74d1d112
commit 010aa7f7ca
6 changed files with 3264 additions and 3 deletions
+1 -1
View File
@@ -360,7 +360,7 @@ Migration flags (internal): `voice_bit_migrated`, `profile_attachments_cleaned`,
| status | text NOT NULL | `'active'` | active/pending/awaiting_approval/unreachable/revoked/rejected/needs_attention |
| lastSeenAt | integer | | |
| lastFailureAt | integer | | |
| consecutiveFailures | integer | 0 | >=10 → unreachable (network/5xx failures) |
| consecutiveFailures | integer NOT NULL | 0 | >=10 → unreachable (network/5xx failures). Counter — never null. |
| consecutiveAuthFailures | integer NOT NULL | 0 | >=5 → needs_attention. Tracked separately from `consecutiveFailures` (network) because auth (401/403) and network failures have different resolution paths. |
| lastSyncedAt | integer | 0 | |
| remoteMaxUploadSize | integer | | Bytes, from peer |
@@ -0,0 +1,29 @@
PRAGMA defer_foreign_keys=ON;
--> statement-breakpoint
CREATE TABLE `__new_federation_peers` (
`id` text PRIMARY KEY NOT NULL,
`origin` text NOT NULL,
`instance_name` text,
`hmac_secret` text NOT NULL,
`status` text DEFAULT 'active' NOT NULL,
`last_seen_at` integer,
`last_failure_at` integer,
`consecutive_failures` integer DEFAULT 0 NOT NULL,
`consecutive_auth_failures` integer DEFAULT 0 NOT NULL,
`last_synced_at` integer DEFAULT 0,
`remote_max_upload_size` integer,
`nonce_supported` integer DEFAULT 0 NOT NULL,
`pending_hmac_secret` text,
`secret_rotation_at` integer,
`secret_rotated_at` integer,
`auto_rotate_interval_days` integer DEFAULT 90 NOT NULL,
`created_at` integer NOT NULL
);
--> statement-breakpoint
INSERT INTO `__new_federation_peers`("id", "origin", "instance_name", "hmac_secret", "status", "last_seen_at", "last_failure_at", "consecutive_failures", "consecutive_auth_failures", "last_synced_at", "remote_max_upload_size", "nonce_supported", "pending_hmac_secret", "secret_rotation_at", "secret_rotated_at", "auto_rotate_interval_days", "created_at") SELECT "id", "origin", "instance_name", "hmac_secret", "status", "last_seen_at", "last_failure_at", COALESCE("consecutive_failures", 0), "consecutive_auth_failures", "last_synced_at", "remote_max_upload_size", "nonce_supported", "pending_hmac_secret", "secret_rotation_at", "secret_rotated_at", "auto_rotate_interval_days", "created_at" FROM `federation_peers`;
--> statement-breakpoint
DROP TABLE `federation_peers`;
--> statement-breakpoint
ALTER TABLE `__new_federation_peers` RENAME TO `federation_peers`;
--> statement-breakpoint
CREATE UNIQUE INDEX `federation_peers_origin_unique` ON `federation_peers` (`origin`);
File diff suppressed because it is too large Load Diff
@@ -29,6 +29,13 @@
"when": 1776789567488,
"tag": "0003_classy_loki",
"breakpoints": true
},
{
"idx": 4,
"version": "6",
"when": 1776802580650,
"tag": "0004_cooing_black_knight",
"breakpoints": true
}
]
}
+1 -1
View File
@@ -362,7 +362,7 @@ export const federationPeers = sqliteTable('federation_peers', {
status: text('status').notNull().default('active'),
lastSeenAt: integer('last_seen_at'),
lastFailureAt: integer('last_failure_at'),
consecutiveFailures: integer('consecutive_failures').default(0),
consecutiveFailures: integer('consecutive_failures').notNull().default(0),
consecutiveAuthFailures: integer('consecutive_auth_failures').notNull().default(0),
lastSyncedAt: integer('last_synced_at').default(0),
remoteMaxUploadSize: integer('remote_max_upload_size'),
+1 -1
View File
@@ -27,7 +27,7 @@ interface SanitizedPeer {
status: string;
lastSeenAt: number | null;
lastFailureAt: number | null;
consecutiveFailures: number | null;
consecutiveFailures: number;
lastSyncedAt: number | null;
createdAt: number;
rotationInProgress: boolean;