feat(federation): instance epoch schema + minting
This commit is contained in:
@@ -33,6 +33,8 @@ IDs: Snowflake text, permissions: bigint decimal strings
|
||||
| passwordChangedAt | integer | | Token revocation: tokens before this rejected |
|
||||
| showActivity | integer NOT NULL | 1 | Rich presence visibility |
|
||||
| federationRegistryUpdatedAt | integer | 0 | LWW timestamp for federation registry sync |
|
||||
| federationHealPending | integer | 0 | Instance-epoch self-healing: set when a replicated identity is flagged for re-heal after a peer reset |
|
||||
| federationHomeOrphaned | integer | 0 | Instance-epoch self-healing: set when this user's home instance was factory-reset and the account could not be re-linked |
|
||||
| createdAt | integer NOT NULL | | Epoch ms |
|
||||
|
||||
### spaces
|
||||
@@ -366,6 +368,7 @@ The user INSERT, `usedCount` increment, and redemption row INSERT all run in a s
|
||||
| id | integer PK | 1 | |
|
||||
| instanceName | text | `'Backspace'` | |
|
||||
| workerId | integer | | Snowflake worker ID |
|
||||
| instanceId | text | | Persistent instance epoch (incarnation UUID). Minted once per DB by `ensureDefaults` and guaranteed non-null after boot. Discriminator that lets peers detect this instance was factory-reset (new DB → new epoch on same origin). See [federation.md → Instance-Epoch Self-Healing]. |
|
||||
| discoveryEnabled | integer NOT NULL | 1 | |
|
||||
| maxBitrateKbps | integer NOT NULL | 20000 | |
|
||||
| minBitrateKbps | integer NOT NULL | 500 | |
|
||||
@@ -407,6 +410,22 @@ The user INSERT, `usedCount` increment, and redemption row INSERT all run in a s
|
||||
| remoteMaxUploadSize | integer | | Bytes, from peer |
|
||||
| createdAt | integer NOT NULL | | |
|
||||
| approvalToken | text | | Single-use 64-hex-char token stored when this row is in `awaiting_approval` (received from remote's 202 response). Verified against the inbound `/peer/accept` `approvalToken` field before promoting to `active`. Cleared (`NULL`) on promotion. See [federation.md → Approval Token Verification](federation.md#approval-token-verification). |
|
||||
| peerInstanceId | text | | Instance-epoch self-healing: the peer's persistent instance epoch (UUID) as last confirmed. `NULL` until first observed. Compared against `observedPeerInstanceId` to detect a factory-reset peer on the same origin. |
|
||||
| observedPeerInstanceId | text | | Instance-epoch self-healing: the instance epoch most recently reported by the peer. A mismatch with `peerInstanceId` signals the peer was reset. |
|
||||
| needsAttentionReason | text | | Instance-epoch self-healing: machine-readable reason a peer was moved to `needs_attention` (e.g. epoch reset detected), for admin surfacing. `NULL` when healthy. |
|
||||
|
||||
### federation_reset_events
|
||||
Instance-epoch self-healing ledger. One row per origin recording a detected federated-peer reset (same origin, new instance epoch). Upserted when a live epoch change is observed; `resolvedAt` is stamped once stale replicated identities from the dead epoch are healed.
|
||||
|
||||
| Column | Type | Default | Notes |
|
||||
|--------|------|---------|-------|
|
||||
| origin | text PK | | Peer origin URL whose epoch changed |
|
||||
| deadEpoch | text NOT NULL | | The instance epoch that was replaced (now stale) |
|
||||
| newEpoch | text | | The peer's new instance epoch, once known |
|
||||
| detectedAt | integer NOT NULL | | Epoch ms the reset was detected |
|
||||
| resolvedAt | integer | | Epoch ms healing completed; `NULL` while in progress |
|
||||
| stubCount | integer NOT NULL | 0 | Count of replicated identity stubs affected by the reset |
|
||||
| orphanedAccountCount | integer NOT NULL | 0 | Count of accounts that could not be re-linked to the new epoch |
|
||||
|
||||
### peer_approval_requests
|
||||
Queue of peering requests pending admin review when `autoAcceptPeering` is `false`. Holds **both directions**: inbound rows (remote asked to peer with us) and outbound rows (a local user-initiated `ensurePeered` call gated on this side; see [federation.md → Outbound Peering Gate](federation.md#outbound-peering-gate)). UNIQUE on `(origin, direction)` so the same origin may have at most one row per direction simultaneously. Rows expire after 30 days via janitor cleanup.
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
CREATE TABLE `federation_reset_events` (
|
||||
`origin` text PRIMARY KEY NOT NULL,
|
||||
`dead_epoch` text NOT NULL,
|
||||
`new_epoch` text,
|
||||
`detected_at` integer NOT NULL,
|
||||
`resolved_at` integer,
|
||||
`stub_count` integer DEFAULT 0 NOT NULL,
|
||||
`orphaned_account_count` integer DEFAULT 0 NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `federation_peers` ADD `peer_instance_id` text;--> statement-breakpoint
|
||||
ALTER TABLE `federation_peers` ADD `observed_peer_instance_id` text;--> statement-breakpoint
|
||||
ALTER TABLE `federation_peers` ADD `needs_attention_reason` text;--> statement-breakpoint
|
||||
ALTER TABLE `instance_settings` ADD `instance_id` text;--> statement-breakpoint
|
||||
ALTER TABLE `users` ADD `federation_heal_pending` integer DEFAULT 0;--> statement-breakpoint
|
||||
ALTER TABLE `users` ADD `federation_home_orphaned` integer DEFAULT 0;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -57,6 +57,13 @@
|
||||
"when": 1782832912087,
|
||||
"tag": "0007_nervous_orphan",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "6",
|
||||
"when": 1782932926154,
|
||||
"tag": "0008_cute_sebastian_shaw",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import Database from 'better-sqlite3';
|
||||
import { ensureDefaults } from './migrate.js';
|
||||
|
||||
function freshDb(): Database.Database {
|
||||
const db = new Database(':memory:');
|
||||
db.exec(`CREATE TABLE instance_settings (id integer PRIMARY KEY, worker_id integer, instance_id text, max_bitrate_kbps integer, min_bitrate_kbps integer, bitrate_step_kbps integer, allowed_resolutions text, allowed_framerates text, max_resolution integer, max_framerate integer, updated_at integer);
|
||||
CREATE TABLE users (id text PRIMARY KEY, is_admin integer DEFAULT 0, created_at integer);`);
|
||||
return db;
|
||||
}
|
||||
|
||||
describe('ensureDefaults instance epoch', () => {
|
||||
it('mints an instance_id when null and is idempotent', () => {
|
||||
const db = freshDb();
|
||||
ensureDefaults(db);
|
||||
const first = (db.prepare('SELECT instance_id FROM instance_settings WHERE id = 1').get() as { instance_id: string }).instance_id;
|
||||
expect(first).toMatch(/^[0-9a-f-]{36}$/);
|
||||
ensureDefaults(db);
|
||||
const second = (db.prepare('SELECT instance_id FROM instance_settings WHERE id = 1').get() as { instance_id: string }).instance_id;
|
||||
expect(second).toBe(first); // stable across boots
|
||||
});
|
||||
|
||||
it('mints a different id for a separate fresh DB', () => {
|
||||
const a = freshDb(); ensureDefaults(a);
|
||||
const b = freshDb(); ensureDefaults(b);
|
||||
const idA = (a.prepare('SELECT instance_id FROM instance_settings WHERE id = 1').get() as { instance_id: string }).instance_id;
|
||||
const idB = (b.prepare('SELECT instance_id FROM instance_settings WHERE id = 1').get() as { instance_id: string }).instance_id;
|
||||
expect(idA).not.toBe(idB);
|
||||
});
|
||||
});
|
||||
@@ -27,6 +27,18 @@ export function ensureDefaults(db: Database.Database): void {
|
||||
console.log(`[defaults] Generated Snowflake worker ID: ${workerId}`);
|
||||
}
|
||||
|
||||
// 2b. Ensure a persistent instance epoch (incarnation UUID) exists. A fresh
|
||||
// DB mints a new one — this is the discriminator for detecting resets.
|
||||
// The id=1 row is guaranteed by step 1's INSERT OR IGNORE above.
|
||||
const epochRow = db.prepare('SELECT instance_id FROM instance_settings WHERE id = 1').get() as
|
||||
{ instance_id: string | null } | undefined;
|
||||
if (!epochRow || epochRow.instance_id === null) {
|
||||
const instanceId = crypto.randomUUID();
|
||||
const res = db.prepare('UPDATE instance_settings SET instance_id = ? WHERE id = 1').run(instanceId);
|
||||
if (res.changes !== 1) throw new Error('ensureDefaults: instance_settings id=1 row missing — cannot mint epoch');
|
||||
console.log('[defaults] Generated instance epoch');
|
||||
}
|
||||
|
||||
// 3. Ensure at least one admin exists (promote earliest registered user)
|
||||
const anyAdmin = db.prepare('SELECT id FROM users WHERE is_admin = 1 LIMIT 1').get();
|
||||
if (!anyAdmin) {
|
||||
|
||||
@@ -23,6 +23,8 @@ export const users = sqliteTable('users', {
|
||||
passwordChangedAt: integer('password_changed_at'),
|
||||
showActivity: integer('show_activity').notNull().default(1),
|
||||
federationRegistryUpdatedAt: integer('federation_registry_updated_at').default(0),
|
||||
federationHealPending: integer('federation_heal_pending').default(0),
|
||||
federationHomeOrphaned: integer('federation_home_orphaned').default(0),
|
||||
createdAt: integer('created_at').notNull(),
|
||||
});
|
||||
|
||||
@@ -308,6 +310,7 @@ export const instanceSettings = sqliteTable('instance_settings', {
|
||||
id: integer('id').primaryKey().default(1),
|
||||
instanceName: text('instance_name').default('Backspace'),
|
||||
workerId: integer('worker_id'),
|
||||
instanceId: text('instance_id'),
|
||||
discoveryEnabled: integer('discovery_enabled').notNull().default(1),
|
||||
maxBitrateKbps: integer('max_bitrate_kbps').notNull().default(20000),
|
||||
minBitrateKbps: integer('min_bitrate_kbps').notNull().default(500),
|
||||
@@ -385,6 +388,22 @@ export const federationPeers = sqliteTable('federation_peers', {
|
||||
autoRotateIntervalDays: integer('auto_rotate_interval_days').notNull().default(90),
|
||||
createdAt: integer('created_at').notNull(),
|
||||
approvalToken: text('approval_token'),
|
||||
peerInstanceId: text('peer_instance_id'),
|
||||
observedPeerInstanceId: text('observed_peer_instance_id'),
|
||||
needsAttentionReason: text('needs_attention_reason'),
|
||||
});
|
||||
|
||||
// Records a detected federated-peer reset (same origin, new instance epoch).
|
||||
// One row per origin; upserted when a live epoch change is observed, resolved
|
||||
// once stale replicated identities are healed.
|
||||
export const federationResetEvents = sqliteTable('federation_reset_events', {
|
||||
origin: text('origin').primaryKey(),
|
||||
deadEpoch: text('dead_epoch').notNull(),
|
||||
newEpoch: text('new_epoch'),
|
||||
detectedAt: integer('detected_at').notNull(),
|
||||
resolvedAt: integer('resolved_at'),
|
||||
stubCount: integer('stub_count').notNull().default(0),
|
||||
orphanedAccountCount: integer('orphaned_account_count').notNull().default(0),
|
||||
});
|
||||
|
||||
// SQL-level CHECK constraint enforces (direction='inbound' → hmac_secret NOT NULL).
|
||||
|
||||
Reference in New Issue
Block a user