Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 62 additions & 31 deletions server/src/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,13 @@ db.exec(`
device_id TEXT NOT NULL,
device_name TEXT NOT NULL DEFAULT 'Unknown Device',
cursor INTEGER NOT NULL DEFAULT 0,
-- 1 once this device holds the chain's notes (it founded the chain or
-- finished an initial transfer); 0 while it still needs to be bootstrapped.
-- This is the AUTHORITATIVE answer to "does this device need a transfer?" —
-- the client used to decide it from a localStorage flag / note count, which
-- raced with note-loading and got wiped, causing already-synced devices to
-- ask each other to re-sync.
initialized INTEGER NOT NULL DEFAULT 1,
last_seen_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000),
created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000),
PRIMARY KEY (room_id, device_id)
Expand Down Expand Up @@ -73,6 +80,16 @@ db.exec(`
CREATE INDEX IF NOT EXISTS idx_transfers_room ON transfers(room_id, status);
`)

// Migration for databases created before the `initialized` column existed.
// Existing rows are devices that were ALREADY paired and syncing, so they are
// established members — default them to initialized = 1 so they are never asked
// to re-bootstrap. (Idempotent: the duplicate-column error is ignored.)
try {
db.exec(`ALTER TABLE devices ADD COLUMN initialized INTEGER NOT NULL DEFAULT 1`)
} catch {
/* column already exists */
}

// ── Sync code operations ─────────────────────────────────────────────────────

const stmtRegisterSyncCode = db.prepare(`
Expand All @@ -97,25 +114,33 @@ export interface DeviceRecord {
deviceId: string
deviceName: string
cursor: number
initialized: boolean
lastSeenAt: number
createdAt: number
}

// On first registration the `initialized` flag is set from the caller-supplied
// value. On reconnect (ON CONFLICT) it is intentionally NOT touched — a device's
// initialized state, once earned, is permanent for the life of its row.
const stmtRegisterDevice = db.prepare(`
INSERT INTO devices (room_id, device_id, device_name, cursor, last_seen_at)
VALUES (?, ?, ?, 0, ?)
INSERT INTO devices (room_id, device_id, device_name, cursor, initialized, last_seen_at)
VALUES (?, ?, ?, 0, ?, ?)
ON CONFLICT(room_id, device_id) DO UPDATE SET
device_name = excluded.device_name,
last_seen_at = excluded.last_seen_at
`)

const stmtSetInitialized = db.prepare(`
UPDATE devices SET initialized = 1 WHERE room_id = ? AND device_id = ?
`)

const stmtGetDevice = db.prepare(`
SELECT device_id, device_name, cursor, last_seen_at, created_at
SELECT device_id, device_name, cursor, initialized, last_seen_at, created_at
FROM devices WHERE room_id = ? AND device_id = ?
`)

const stmtGetDevices = db.prepare(`
SELECT device_id, device_name, cursor, last_seen_at, created_at
SELECT device_id, device_name, cursor, initialized, last_seen_at, created_at
FROM devices WHERE room_id = ?
`)

Expand All @@ -139,46 +164,52 @@ const stmtGetMinCursor = db.prepare(`
SELECT MIN(cursor) as min_cursor FROM devices WHERE room_id = ?
`)

export function registerDevice(roomId: string, deviceId: string, deviceName: string): DeviceRecord {
const now = Date.now()
stmtRegisterDevice.run(roomId, deviceId, deviceName, now)
const row = stmtGetDevice.get(roomId, deviceId) as {
device_id: string; device_name: string; cursor: number; last_seen_at: number; created_at: number
}
type DeviceRow = {
device_id: string; device_name: string; cursor: number
initialized: number; last_seen_at: number; created_at: number
}

function toDeviceRecord(row: DeviceRow): DeviceRecord {
return {
deviceId: row.device_id,
deviceName: row.device_name,
cursor: row.cursor,
initialized: !!row.initialized,
lastSeenAt: row.last_seen_at,
createdAt: row.created_at,
}
}

/**
* Register (or touch) a device. `initializedIfNew` sets the initialized flag
* ONLY when the row is first created — the founder of a chain (no other devices
* yet) is initialized; a device that joins an existing chain is not, until it
* finishes a transfer. Reconnects never change an existing row's flag.
*/
export function registerDevice(
roomId: string,
deviceId: string,
deviceName: string,
initializedIfNew: boolean,
): DeviceRecord {
const now = Date.now()
stmtRegisterDevice.run(roomId, deviceId, deviceName, initializedIfNew ? 1 : 0, now)
return toDeviceRecord(stmtGetDevice.get(roomId, deviceId) as DeviceRow)
}

/** Mark a device as having completed its initial bootstrap (received a transfer). */
export function markDeviceInitialized(roomId: string, deviceId: string): void {
stmtSetInitialized.run(roomId, deviceId)
}

export function getDevice(roomId: string, deviceId: string): DeviceRecord | null {
const row = stmtGetDevice.get(roomId, deviceId) as {
device_id: string; device_name: string; cursor: number; last_seen_at: number; created_at: number
} | undefined
if (!row) return null
return {
deviceId: row.device_id,
deviceName: row.device_name,
cursor: row.cursor,
lastSeenAt: row.last_seen_at,
createdAt: row.created_at,
}
const row = stmtGetDevice.get(roomId, deviceId) as DeviceRow | undefined
return row ? toDeviceRecord(row) : null
}

export function getDevices(roomId: string): DeviceRecord[] {
const rows = stmtGetDevices.all(roomId) as {
device_id: string; device_name: string; cursor: number; last_seen_at: number; created_at: number
}[]
return rows.map(r => ({
deviceId: r.device_id,
deviceName: r.device_name,
cursor: r.cursor,
lastSeenAt: r.last_seen_at,
createdAt: r.created_at,
}))
const rows = stmtGetDevices.all(roomId) as DeviceRow[]
return rows.map(toDeviceRecord)
}

export function updateDeviceCursor(roomId: string, deviceId: string, cursor: number): void {
Expand Down
29 changes: 17 additions & 12 deletions server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
*
* WebSocket protocol (JSON messages):
* Client → Server:
* { type: 'join', roomId, token, deviceId, deviceName, needsTransfer }
* { type: 'join', roomId, token, deviceId, deviceName }
* { type: 'push-op', payload (base64) }
* { type: 'ack', cursor }
* { type: 'request-transfer' }
Expand Down Expand Up @@ -50,6 +50,7 @@ import {
registerSyncCode,
syncCodeExists,
registerDevice,
markDeviceInitialized,
getDevice,
getDevices,
updateDeviceCursor,
Expand Down Expand Up @@ -632,26 +633,28 @@ function handleMessage(client: Client, msg: Record<string, unknown>, ip: string)
// Leave previous room if any
removeClient(client)

// Determine the device's role from AUTHORITATIVE server state, not a client
// hint. A brand-new registration is a "founder" (initialized) only when no
// other device exists yet; a device joining an existing chain starts
// un-initialized and must receive a transfer before it's a full member.
// An existing row keeps whatever initialized state it already earned.
const othersBefore = getDevices(roomId).filter(d => d.deviceId !== deviceId)
const initializedIfNew = othersBefore.length === 0

// Register/update device in DB
const device = registerDevice(roomId, deviceId, deviceName)
const device = registerDevice(roomId, deviceId, deviceName, initializedIfNew)

client.roomId = roomId
client.deviceId = deviceId
client.deviceName = deviceName
const room = getRoom(roomId)
room.add(client)

// Whether this device needs an initial transfer is the CLIENT's own
// declaration (`needsTransfer`), driven by persisted intent: it generated
// the code (origin → false) or entered an existing code and hasn't yet
// pulled the notes (→ true). The server can't infer this — the data is
// E2E-encrypted, note count is unreliable (fresh devices can have a seed
// note), and `cursor === 0` is true for the origin too. We only honor the
// request when there's actually another device to copy from.
const clientNeedsTransfer = msg.needsTransfer === true
const allDevices = getDevices(roomId)
const otherDevices = allDevices.filter(d => d.deviceId !== deviceId)
const isNewDevice = clientNeedsTransfer && otherDevices.length > 0
// A device "needs a transfer" iff the server has it as un-initialized AND
// there is another device to copy from.
const isNewDevice = !device.initialized && otherDevices.length > 0
const maxSeq = getMaxSeq(roomId)
const hasPendingOps = device.cursor < maxSeq

Expand Down Expand Up @@ -1028,9 +1031,11 @@ function handleMessage(client: Client, msg: Record<string, unknown>, ip: string)
updateTransferStatus(transferId, 'completed')

// Update the new device's cursor to current max so it doesn't replay ops
// that were part of the transfer
// that were part of the transfer, and mark it initialized so it is never
// asked to bootstrap again (and rejoins as a full member).
const maxSeq = getMaxSeq(roomId)
updateDeviceCursor(roomId, transfer.requesterId, maxSeq)
markDeviceInitialized(roomId, transfer.requesterId)

// Notify both parties
sendToDevice(roomId, transfer.requesterId, { type: 'transfer-complete', transferId })
Expand Down
7 changes: 2 additions & 5 deletions src/components/SettingsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1108,8 +1108,7 @@ function SyncPage({ syncState, onTriggerSync, onSyncEnabled, onSyncDisabled }: S
try {
const result = await generateSyncCode()
setGeneratedCode(result.syncCode)
// We created this chain — we are the source of truth, no transfer needed.
enableSync(result.syncCode, result.roomId, result.token, { initialized: true })
enableSync(result.syncCode, result.roomId, result.token)
setEnabled(true)
onSyncEnabled()
} catch (e) {
Expand All @@ -1125,9 +1124,7 @@ function SyncPage({ syncState, onTriggerSync, onSyncEnabled, onSyncDisabled }: S
setError(null)
try {
const result = await validateSyncCode(syncCodeInput)
// We're joining an existing chain — we must pull the notes from an
// existing device before we're a full member.
enableSync(syncCodeInput, result.roomId, result.token, { initialized: false })
enableSync(syncCodeInput, result.roomId, result.token)
setEnabled(true)
setMode(null)
setSyncCodeInput('')
Expand Down
Loading
Loading