Skip to content
Open
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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ coverage/

# OS / IDE
**/.DS_Store
**/._*
.idea/
.vscode/
**/*.swp
Expand All @@ -37,6 +38,9 @@ coverage/
.claude/settings.local.json
localdocs/
execplan/
docs/plans/
docs/LOCAL_FORK_WORKFLOW.md
docs/GAIUS_THEME.md

# Generated npm bundle output (local)
cli/npm/main/
9 changes: 9 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

69 changes: 69 additions & 0 deletions hub/src/sse/sseManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,4 +118,73 @@ describe('SSEManager namespace filtering', () => {
expect(received).toHaveLength(1)
expect(received[0]?.id).toBe('visible')
})

it('broadcasts session sort preference updates to namespace even with session-scoped subscription', () => {
const manager = new SSEManager(0, new VisibilityTracker())
const received: SyncEvent[] = []

manager.subscribe({
id: 'alpha-session',
namespace: 'alpha',
userId: 1,
all: false,
sessionId: 'session-1',
send: (event) => {
received.push(event)
},
sendHeartbeat: () => {}
})

manager.broadcast({
type: 'session-sort-preference-updated',
namespace: 'alpha',
data: {
userId: 1,
version: 2
}
})

expect(received).toHaveLength(1)
expect(received[0]?.type).toBe('session-sort-preference-updated')
})

it('filters session sort preference updates by userId', () => {
const manager = new SSEManager(0, new VisibilityTracker())
const receivedUser1: SyncEvent[] = []
const receivedUser2: SyncEvent[] = []

manager.subscribe({
id: 'user1-conn',
namespace: 'alpha',
userId: 1,
all: true,
send: (event) => {
receivedUser1.push(event)
},
sendHeartbeat: () => {}
})

manager.subscribe({
id: 'user2-conn',
namespace: 'alpha',
userId: 2,
all: true,
send: (event) => {
receivedUser2.push(event)
},
sendHeartbeat: () => {}
})

manager.broadcast({
type: 'session-sort-preference-updated',
namespace: 'alpha',
data: {
userId: 1,
version: 3
}
})

expect(receivedUser1).toHaveLength(1)
expect(receivedUser2).toHaveLength(0)
})
})
8 changes: 8 additions & 0 deletions hub/src/sse/sseManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { VisibilityTracker } from '../visibility/visibilityTracker'
export type SSESubscription = {
id: string
namespace: string
userId: number | null
all: boolean
sessionId: string | null
machineId: string | null
Expand All @@ -29,6 +30,7 @@ export class SSEManager {
subscribe(options: {
id: string
namespace: string
userId?: number | null
all?: boolean
sessionId?: string | null
machineId?: string | null
Expand All @@ -39,6 +41,7 @@ export class SSEManager {
const subscription: SSEConnection = {
id: options.id,
namespace: options.namespace,
userId: options.userId ?? null,
all: Boolean(options.all),
sessionId: options.sessionId ?? null,
machineId: options.machineId ?? null,
Expand All @@ -56,6 +59,7 @@ export class SSEManager {
return {
id: subscription.id,
namespace: subscription.namespace,
userId: subscription.userId,
all: subscription.all,
sessionId: subscription.sessionId,
machineId: subscription.machineId
Expand Down Expand Up @@ -163,6 +167,10 @@ export class SSEManager {
return true
}

if (event.type === 'session-sort-preference-updated') {
return connection.userId === null || connection.userId === event.data.userId
}

if (connection.all) {
return true
}
Expand Down
69 changes: 65 additions & 4 deletions hub/src/store/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { dirname } from 'node:path'
import { MachineStore } from './machineStore'
import { MessageStore } from './messageStore'
import { PushStore } from './pushStore'
import { SessionSortPreferenceStore } from './sessionSortPreferenceStore'
import { SessionStore } from './sessionStore'
import { UserStore } from './userStore'

Expand All @@ -13,22 +14,26 @@ export type {
StoredMessage,
StoredPushSubscription,
StoredSession,
StoredSessionSortPreference,
StoredUser,
SessionSortPreferenceUpdateResult,
VersionedUpdateResult
} from './types'
export { MachineStore } from './machineStore'
export { MessageStore } from './messageStore'
export { PushStore } from './pushStore'
export { SessionSortPreferenceStore } from './sessionSortPreferenceStore'
export { SessionStore } from './sessionStore'
export { UserStore } from './userStore'

const SCHEMA_VERSION: number = 3
const SCHEMA_VERSION: number = 4
const REQUIRED_TABLES = [
'sessions',
'machines',
'messages',
'users',
'push_subscriptions'
'push_subscriptions',
'session_sort_preferences'
] as const

export class Store {
Expand All @@ -40,6 +45,7 @@ export class Store {
readonly messages: MessageStore
readonly users: UserStore
readonly push: PushStore
readonly sessionSortPreferences: SessionSortPreferenceStore

constructor(dbPath: string) {
this.dbPath = dbPath
Expand Down Expand Up @@ -81,6 +87,7 @@ export class Store {
this.messages = new MessageStore(this.db)
this.users = new UserStore(this.db)
this.push = new PushStore(this.db)
this.sessionSortPreferences = new SessionSortPreferenceStore(this.db)
}

private initSchema(): void {
Expand All @@ -98,14 +105,38 @@ export class Store {
return
}

if (currentVersion === 1 && SCHEMA_VERSION === 2) {
if (currentVersion === 1 && SCHEMA_VERSION >= 2) {
this.migrateFromV1ToV2()
if (SCHEMA_VERSION === 2) {
this.setUserVersion(SCHEMA_VERSION)
return
}

this.migrateFromV2ToV3()
if (SCHEMA_VERSION === 3) {
this.setUserVersion(SCHEMA_VERSION)
return
}

this.migrateFromV3ToV4()
this.setUserVersion(SCHEMA_VERSION)
return
}

if (currentVersion === 2 && SCHEMA_VERSION === 3) {
if (currentVersion === 2 && SCHEMA_VERSION >= 3) {
this.migrateFromV2ToV3()
if (SCHEMA_VERSION === 3) {
this.setUserVersion(SCHEMA_VERSION)
return
}

this.migrateFromV3ToV4()
this.setUserVersion(SCHEMA_VERSION)
return
}

if (currentVersion === 3 && SCHEMA_VERSION >= 4) {
this.migrateFromV3ToV4()
this.setUserVersion(SCHEMA_VERSION)
return
}
Expand Down Expand Up @@ -187,6 +218,19 @@ export class Store {
UNIQUE(namespace, endpoint)
);
CREATE INDEX IF NOT EXISTS idx_push_subscriptions_namespace ON push_subscriptions(namespace);

CREATE TABLE IF NOT EXISTS session_sort_preferences (
user_id INTEGER NOT NULL,
namespace TEXT NOT NULL,
sort_mode TEXT NOT NULL DEFAULT 'auto',
manual_order TEXT NOT NULL DEFAULT '{"groupOrder":[],"sessionOrder":{}}',
version INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY (user_id, namespace)
);
CREATE INDEX IF NOT EXISTS idx_session_sort_preferences_namespace
ON session_sort_preferences(namespace);
`)
}

Expand Down Expand Up @@ -280,6 +324,23 @@ export class Store {
return
}

private migrateFromV3ToV4(): void {
this.db.exec(`
CREATE TABLE IF NOT EXISTS session_sort_preferences (
user_id INTEGER NOT NULL,
namespace TEXT NOT NULL,
sort_mode TEXT NOT NULL DEFAULT 'auto',
manual_order TEXT NOT NULL DEFAULT '{"groupOrder":[],"sessionOrder":{}}',
version INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY (user_id, namespace)
);
CREATE INDEX IF NOT EXISTS idx_session_sort_preferences_namespace
ON session_sort_preferences(namespace);
`)
}

private getMachineColumnNames(): Set<string> {
const rows = this.db.prepare('PRAGMA table_info(machines)').all() as Array<{ name: string }>
return new Set(rows.map((row) => row.name))
Expand Down
Loading