import { pgTable, serial, integer, bigint, text, timestamp, index, jsonb, foreignKey, uniqueIndex, } from 'drizzle-orm/pg-core'; import { users } from 'officerdb/auth/schema'; // Soulseek state that Officer owns because slskd has none. slskd exposes no favourites/buddy-list API // (verified against 0.26.0's UsersController: only endpoint/browse/directory/info/status), so the peers // the owner marks live here instead of in a forked daemon. // // Every table here is `soulseek_`-prefixed, and this file deliberately holds nothing else: when sidecars // start owning their own schema, it moves wholesale into src/servers/sidecar/slskd/ with no untangling. // Only the officer-slskd sidecar reads or writes these tables. export const soulseekFavorites = pgTable( 'soulseek_favorites', { id: serial('id').primaryKey(), userId: integer('user_id') .notNull() .references(() => users.id, { onDelete: 'cascade' }), username: text('username').notNull(), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), }, // Also the lookup index — (user_id, username) covers "all of a user's favourites" by leftmost prefix. (t) => [uniqueIndex('uq_soulseek_favorites_user_username').on(t.userId, t.username)], ); // ── Cached share trees ── // // A peer's share tree comes from slskd as ONE blocking response with every file of every folder inlined: // measured at 59 MB / 18k folders / 284k files for a single real peer. Sending that to a browser is // hopeless, and the browse is lost the moment the tab navigates away. So the sidecar fetches it in the // background and caches it here, and the UI reads paginated slices instead. // // One snapshot row per (user, peer) — refreshes replace it rather than accumulating history. export const soulseekBrowseSnapshots = pgTable( 'soulseek_browse_snapshots', { id: serial('id').primaryKey(), userId: integer('user_id') .notNull() .references(() => users.id, { onDelete: 'cascade' }), username: text('username').notNull(), // 'pending' while the sidecar is fetching, then 'ready' or 'failed'. A failed refresh keeps the // previous rows, so a peer going offline never destroys a good cache. status: text('status').notNull().default('pending'), error: text('error'), directoryCount: integer('directory_count').notNull().default(0), fileCount: integer('file_count').notNull().default(0), // Shares run to terabytes, well past int4. totalSize: bigint('total_size', { mode: 'number' }).notNull().default(0), startedAt: timestamp('started_at', { withTimezone: true }).notNull().defaultNow(), completedAt: timestamp('completed_at', { withTimezone: true }), }, (t) => [uniqueIndex('uq_soulseek_browse_snapshots_user_username').on(t.userId, t.username)], ); // One row per FOLDER, with that folder's files inlined as jsonb — not a row per file, which would be // 284k rows per peer. Folders are what you page and filter through; files are only ever read for the // folder you opened, so they ride along in the row. // // slskd hands back a flat list of backslash-delimited paths, so the tree is derived once at ingest and // stored here rather than recomputed per request: `parentPath` links a row to its parent and the subtree // rollups are precomputed, which is what lets the UI open one level at a time. Measured on real peers, // the tree is already complete in slskd's response — exactly one intermediate folder was missing across // 30k rows — but ingest synthesizes any that are absent so a level can never dead-end. export const soulseekBrowseDirs = pgTable( 'soulseek_browse_dirs', { id: serial('id').primaryKey(), snapshotId: integer('snapshot_id').notNull(), name: text('name').notNull(), // null for a root. Its parent's `name`, i.e. `name` minus the last backslash segment. parentPath: text('parent_path'), // Segment count, 1-based, so the UI can indent without walking back up the chain. depth: integer('depth').notNull().default(1), // Last path segment — what a tree row actually displays. label: text('label').notNull().default(''), childCount: integer('child_count').notNull().default(0), fileCount: integer('file_count').notNull().default(0), totalSize: bigint('total_size', { mode: 'number' }).notNull().default(0), // This folder AND everything under it. A parent's own file count is usually cover art; the number // worth showing on a collapsed row is the whole subtree's. subtreeFileCount: integer('subtree_file_count').notNull().default(0), subtreeSize: bigint('subtree_size', { mode: 'number' }).notNull().default(0), // BrowsedFile[] — slimmed at ingest to {name,size,extension}: slskd's browse response carries // per-file `attributes` (bitrate/duration) that real peers leave empty, and dropping them plus the // wire noise took the sample from 59 MB to 19 MB. files: jsonb('files').notNull().default([]), }, (t) => [ // Filtered search and the name lookups behind ancestor expansion. index('idx_soulseek_browse_dirs_snapshot_name').on(t.snapshotId, t.name), // The tree's hot path: one level, ordered by name. Covers "the roots" too, via a null parent_path. index('idx_soulseek_browse_dirs_snapshot_parent').on(t.snapshotId, t.parentPath, t.name), // Named explicitly: drizzle's derived name for this pair would be 64 chars, one over Postgres's // 63-char identifier cap, so it would be silently truncated and never match the schema again. foreignKey({ name: 'soulseek_browse_dirs_snapshot_id_fk', columns: [t.snapshotId], foreignColumns: [soulseekBrowseSnapshots.id], }).onDelete('cascade'), ], );