import { sqliteTable, text, integer, real, index, type AnySQLiteColumn } from "drizzle-orm/sqlite-core"; export interface DeviceCapabilities { /** Buttplug OutputType strings (Vibrate/Rotate/Position/...) present on any feature. */ outputs: string[]; featureCount: number; } // No `users` table - the app is gated by a single ACCESS_PASSWORD env var // (see lib/auth/session.ts), not a persisted account. export const devices = sqliteTable("devices", { id: integer("id").primaryKey({ autoIncrement: true }), displayName: text("display_name"), // Web Bluetooth exposes no stable hardware id, only the advertised name - // this is the best identity key available across browser sessions. bleName: text("ble_name").notNull(), deviceClass: text("device_class"), capabilities: text("capabilities", { mode: "json" }).$type(), createdAt: integer("created_at").notNull(), lastConnectedAt: integer("last_connected_at"), }); export const playSessions = sqliteTable("play_sessions", { id: integer("id").primaryKey({ autoIncrement: true }), name: text("name"), kind: text("kind", { enum: ["live", "replay"] }).notNull(), replayedRecordingId: integer("replayed_recording_id").references((): AnySQLiteColumn => recordings.id, { onDelete: "set null", }), status: text("status", { enum: ["active", "completed", "aborted"] }).notNull().default("active"), startedAt: integer("started_at").notNull(), endedAt: integer("ended_at"), durationMs: integer("duration_ms"), notes: text("notes"), }); export const sessionDevices = sqliteTable( "session_devices", { id: integer("id").primaryKey({ autoIncrement: true }), playSessionId: integer("play_session_id") .notNull() .references(() => playSessions.id, { onDelete: "cascade" }), deviceId: integer("device_id") .notNull() .references(() => devices.id, { onDelete: "restrict" }), slotLabel: text("slot_label").notNull(), connectedAt: integer("connected_at").notNull(), disconnectedAt: integer("disconnected_at"), }, (table) => [index("session_devices_play_session_id_idx").on(table.playSessionId)], ); export const sessionEvents = sqliteTable( "session_events", { id: integer("id").primaryKey({ autoIncrement: true }), playSessionId: integer("play_session_id") .notNull() .references(() => playSessions.id, { onDelete: "cascade" }), sessionDeviceId: integer("session_device_id") .notNull() .references(() => sessionDevices.id, { onDelete: "cascade" }), // Relative to the session's startedAt, not wall-clock - keeps timeline // math and replay scheduling drift-free and reproducible. tsMs: integer("ts_ms").notNull(), commandType: text("command_type", { enum: ["vibrate", "rotate", "linear", "stop"] }).notNull(), featureIndex: integer("feature_index").notNull(), value: real("value").notNull(), durationMs: integer("duration_ms"), rawPayload: text("raw_payload", { mode: "json" }), }, (table) => [ index("session_events_session_ts_idx").on(table.playSessionId, table.tsMs), index("session_events_session_device_idx").on(table.sessionDeviceId), ], ); export interface RecordingDeviceSlot { slotLabel: string; recordedBleName: string; deviceClass: string | null; capabilities: DeviceCapabilities | null; /** The original session_devices.id this slot was captured from - lets the * replay UI map a chosen live device back to this slot's session_events. */ sourceSessionDeviceId: number; } export const recordings = sqliteTable("recordings", { id: integer("id").primaryKey({ autoIncrement: true }), // A recording is a thin pointer over an already-captured play_session, not // a duplicated event pipeline - see lib/db/queries/recordings.ts for why. sourcePlaySessionId: integer("source_play_session_id") .notNull() .references(() => playSessions.id, { onDelete: "restrict" }), name: text("name").notNull(), description: text("description"), createdAt: integer("created_at").notNull(), durationMs: integer("duration_ms").notNull(), deviceSlots: text("device_slots", { mode: "json" }).$type().notNull(), playCount: integer("play_count").notNull().default(0), lastPlayedAt: integer("last_played_at"), });