2026-08-25 07:51:38 +02:00
|
|
|
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<DeviceCapabilities>(),
|
|
|
|
|
createdAt: integer("created_at").notNull(),
|
|
|
|
|
lastConnectedAt: integer("last_connected_at"),
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-27 21:38:51 +02:00
|
|
|
export const sessions = sqliteTable("sessions", {
|
2026-08-25 07:51:38 +02:00
|
|
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
|
|
|
|
name: text("name"),
|
2026-08-27 21:38:51 +02:00
|
|
|
description: text("description"),
|
2026-08-25 07:51:38 +02:00
|
|
|
kind: text("kind", { enum: ["live", "replay"] }).notNull(),
|
2026-08-27 21:38:51 +02:00
|
|
|
// Self-referencing: which session's events this session replayed, if any.
|
|
|
|
|
replayedSessionId: integer("replayed_session_id").references((): AnySQLiteColumn => sessions.id, {
|
2026-08-25 07:51:38 +02:00
|
|
|
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"),
|
2026-08-27 21:38:51 +02:00
|
|
|
// How many times (and when) this session's events have been replayed -
|
|
|
|
|
// any completed session can be a replay source, there's no separate
|
|
|
|
|
// "saved recording" concept anymore.
|
|
|
|
|
playCount: integer("play_count").notNull().default(0),
|
|
|
|
|
lastPlayedAt: integer("last_played_at"),
|
2026-08-25 07:51:38 +02:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
export const sessionDevices = sqliteTable(
|
|
|
|
|
"session_devices",
|
|
|
|
|
{
|
|
|
|
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
2026-08-27 21:38:51 +02:00
|
|
|
sessionId: integer("session_id")
|
2026-08-25 07:51:38 +02:00
|
|
|
.notNull()
|
2026-08-27 21:38:51 +02:00
|
|
|
.references(() => sessions.id, { onDelete: "cascade" }),
|
2026-08-25 07:51:38 +02:00
|
|
|
deviceId: integer("device_id")
|
|
|
|
|
.notNull()
|
|
|
|
|
.references(() => devices.id, { onDelete: "restrict" }),
|
|
|
|
|
slotLabel: text("slot_label").notNull(),
|
|
|
|
|
connectedAt: integer("connected_at").notNull(),
|
|
|
|
|
disconnectedAt: integer("disconnected_at"),
|
|
|
|
|
},
|
2026-08-27 21:38:51 +02:00
|
|
|
(table) => [index("session_devices_session_id_idx").on(table.sessionId)],
|
2026-08-25 07:51:38 +02:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
export const sessionEvents = sqliteTable(
|
|
|
|
|
"session_events",
|
|
|
|
|
{
|
|
|
|
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
2026-08-27 21:38:51 +02:00
|
|
|
sessionId: integer("session_id")
|
2026-08-25 07:51:38 +02:00
|
|
|
.notNull()
|
2026-08-27 21:38:51 +02:00
|
|
|
.references(() => sessions.id, { onDelete: "cascade" }),
|
2026-08-25 07:51:38 +02:00
|
|
|
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) => [
|
2026-08-27 21:38:51 +02:00
|
|
|
index("session_events_session_ts_idx").on(table.sessionId, table.tsMs),
|
2026-08-25 07:51:38 +02:00
|
|
|
index("session_events_session_device_idx").on(table.sessionDeviceId),
|
|
|
|
|
],
|
|
|
|
|
);
|