Files
soulseek/db/schema.ts
T
officer c11f45c033 soulseek, extracted from the platform as a plugin
Seventh plugin out, and the first that was already dead. Soulseek had been
switched off since 2026-08-13: hono.ts had the router import and its mount
commented out, so createSidecarProxy never ran, registerProxiedPrefix was never
called, the slskd:server announcement fired into a void, and officer-slskd was
not in the ecosystem at all. The permission entry had been reduced to api: [],
which is why assertPermissionTotality was satisfied and the server booted
clean. The UI rendered perfectly and every request 404'd.

So this is a resurrection as well as a move.

The mount is now /api/soulseek, derived via mountPrefix from the appName — all
47 frontend call sites repointed from /slskd/. The credential key in
service_connections stays 'slskd': it names the daemon, not the route. Those
two strings differ on purpose and conflating them reads the wrong row.

The connection stays in core service_connections, as transmission does, so
'slskd' remains in a platform union this plugin cannot extend. Recorded in
PLUGIN.md. Unlike transmission this plugin does own a schema: favourites and
the two halves of the cached browse tree, which slskd has no concept of. Those
three tables had been dropped from the drizzle barrel, so bun db:push would
have deleted them on any server holding data.

313 palette classes converted to the semantic tone set, including a shared
PHASE_TONE the three transfer views previously each copied.
2026-08-19 02:55:02 +00:00

115 lines
5.6 KiB
TypeScript

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'),
],
);