soulseek, extracted into plugins/soulseek — the platform half

Seventh extraction, and the first of something that was already dead. Soulseek
had been switched off since 2026-08-13: hono.ts carried the router import and
its PROTECTED_MOUNTS entry commented out, so createSidecarProxy never ran and
registerProxiedPrefix was never called; officer-slskd was not in the ecosystem
at all. Because the permission entry had been reduced to api: [], the totality
check was satisfied and the server booted clean — the UI rendered and every
request 404'd. Worth naming: the boot check proves every MOUNTED prefix has a
permission, and is silent about a feature that mounts nothing.

Also closes a live data hazard: schema.ts had the soulseek export commented
out, so the three tables were absent from the drizzle barrel and bun db:push
would have dropped soulseek_favorites, soulseek_browse_snapshots and
soulseek_browse_dirs on any server holding them. rebuild-soulseek-tree.ts
documented the workaround rather than the problem; deleted with the rest.

The connection stays in core service_connections under service 'slskd' while
the mount is now /api/soulseek. Those differ on purpose.

Test fixtures: slskd was canonical in three app-store tests, the third
extraction running to break them. installer.test.ts now builds a literal
fixture instead of reaching into the catalogue — it pins planSteps, which is
installer logic, and no catalogue entry is both provisioned and members:'none'
any more. catalogue.test.ts and members.test.ts re-anchored to vnc/notify/gitea.

Unrelated but mine: check:theme caught 5 palette classes I introduced in
CommandHQ earlier tonight against a baseline of 0. Converted to bg-success /
bg-warning. check:theme is not part of bun test, which is why the green suite
never showed it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 02:55:35 +00:00
co-authored by Claude Opus 5
parent c4b262dc51
commit b196c9cb55
52 changed files with 41 additions and 5807 deletions
-1
View File
@@ -49,7 +49,6 @@ export * from './dav';
export * from './invoiceshelf';
export * from './jellyfin';
export * from './notify';
export * from './soulseek';
export * from './wallet';
// `operations` is absent because it no longer exists: it held task_logs, and the Task Logs feature was
-1
View File
@@ -50,7 +50,6 @@ export * from './service-connections/schema'; // service_connections
// export * from './dav/schema'; // dav_app_passwords officer-caldav
// export * from './jellyfin/schema'; // jellyfin_servers officer-jellyfin
// export * from './invoiceshelf/schema'; // invoiceshelf_accounts officer-invoiceshelf
// export * from './soulseek/schema'; // soulseek_favorites, _browse_snapshots, _browse_dirs
// export * from './wallet/schema'; // wallet_wallets, _labels, _frozen_utxos, _chain_cache
// ── Plugin tables are NOT here ───────────────────────────────────────────────────────────────────
@@ -1,26 +0,0 @@
export { getSoulseekFavorites, addSoulseekFavorite, removeSoulseekFavorite } from './queries';
export {
getSoulseekBrowseSnapshots,
getSoulseekBrowseSnapshot,
startSoulseekBrowse,
finishSoulseekBrowse,
failSoulseekBrowse,
resetStaleSoulseekBrowses,
getSoulseekBrowseLevel,
searchSoulseekBrowseTree,
getSoulseekBrowseDirFiles,
getSoulseekBrowseDownload,
deleteSoulseekBrowse,
} from './queries';
export type {
BrowseDownloadFile,
BrowsedFile,
BrowseDirInput,
BrowseDirRow,
BrowseTreeNode,
BrowseLevel,
BrowseTreeSearch,
SoulseekBrowseSnapshot,
} from './queries';
@@ -1,398 +0,0 @@
import { eq, and, asc, isNull, inArray, sql } from 'drizzle-orm';
import { db } from '../db';
import { soulseekFavorites, soulseekBrowseSnapshots, soulseekBrowseDirs } from './schema';
/** A user's favourited Soulseek peers, alphabetical (the order the UI lists them in). */
export async function getSoulseekFavorites(userId: number): Promise<string[]> {
const rows = await db
.select({ username: soulseekFavorites.username })
.from(soulseekFavorites)
.where(eq(soulseekFavorites.userId, userId))
.orderBy(asc(soulseekFavorites.username));
return rows.map((r) => r.username);
}
/** Add a favourite (idempotent — a repeat add is a no-op via the unique constraint). */
export async function addSoulseekFavorite(userId: number, username: string): Promise<void> {
await db.insert(soulseekFavorites).values({ userId, username }).onConflictDoNothing();
}
/** Remove a favourite (no-op if it wasn't set). */
export async function removeSoulseekFavorite(userId: number, username: string): Promise<void> {
await db
.delete(soulseekFavorites)
.where(and(eq(soulseekFavorites.userId, userId), eq(soulseekFavorites.username, username)));
}
// ── Cached share trees ──
/** A file inside a cached folder, slimmed at ingest (see the schema note on dropped attributes). */
export type BrowsedFile = { name: string; size: number; extension: string };
/** One folder as slskd gives it: a full backslash-delimited path, plus its files. */
export type BrowseDirInput = { name: string; files: BrowsedFile[] };
/**
* One folder as it's stored — a tree node. slskd's flat paths are linked up into this shape by the
* sidecar's `buildTree` before they get here, which is why every count is already on the row.
*/
export type BrowseDirRow = {
name: string;
parentPath: string | null;
depth: number;
label: string;
childCount: number;
fileCount: number;
totalSize: number;
subtreeFileCount: number;
subtreeSize: number;
files: BrowsedFile[];
};
export type SoulseekBrowseSnapshot = {
username: string;
status: string;
error: string | null;
directoryCount: number;
fileCount: number;
totalSize: number;
startedAt: Date;
completedAt: Date | null;
};
const snapshotCols = {
username: soulseekBrowseSnapshots.username,
status: soulseekBrowseSnapshots.status,
error: soulseekBrowseSnapshots.error,
directoryCount: soulseekBrowseSnapshots.directoryCount,
fileCount: soulseekBrowseSnapshots.fileCount,
totalSize: soulseekBrowseSnapshots.totalSize,
startedAt: soulseekBrowseSnapshots.startedAt,
completedAt: soulseekBrowseSnapshots.completedAt,
};
/** Every cached share tree for a user, so the UI can badge each favourite in one request. */
export async function getSoulseekBrowseSnapshots(userId: number): Promise<SoulseekBrowseSnapshot[]> {
return db
.select(snapshotCols)
.from(soulseekBrowseSnapshots)
.where(eq(soulseekBrowseSnapshots.userId, userId))
.orderBy(asc(soulseekBrowseSnapshots.username));
}
/** One peer's snapshot, or null if never fetched. */
export async function getSoulseekBrowseSnapshot(
userId: number,
username: string,
): Promise<SoulseekBrowseSnapshot | null> {
const [row] = await db
.select(snapshotCols)
.from(soulseekBrowseSnapshots)
.where(and(eq(soulseekBrowseSnapshots.userId, userId), eq(soulseekBrowseSnapshots.username, username)))
.limit(1);
return row ?? null;
}
/**
* Mark a peer's tree as being fetched and return the snapshot id to ingest into. Deliberately leaves the
* existing folder rows and counts alone: they stay readable while the refresh runs, and survive it failing.
*/
export async function startSoulseekBrowse(userId: number, username: string): Promise<number> {
const [row] = await db
.insert(soulseekBrowseSnapshots)
.values({ userId, username, status: 'pending' })
.onConflictDoUpdate({
target: [soulseekBrowseSnapshots.userId, soulseekBrowseSnapshots.username],
set: { status: 'pending', error: null, startedAt: new Date(), completedAt: null },
})
.returning({ id: soulseekBrowseSnapshots.id });
if (!row) throw new Error('failed to create browse snapshot');
return row.id;
}
// Each row carries a whole folder's file list, so keep batches small enough that no single INSERT gets
// absurd. 18k folders is ~36 round trips at this size.
const INGEST_BATCH = 500;
/** Swap in a freshly built tree: replace the folder rows and mark the snapshot ready, atomically. */
export async function finishSoulseekBrowse(snapshotId: number, tree: BrowseDirRow[]): Promise<void> {
const rows = tree.map((r) => ({ ...r, snapshotId }));
await db.transaction(async (tx) => {
await tx.delete(soulseekBrowseDirs).where(eq(soulseekBrowseDirs.snapshotId, snapshotId));
for (let i = 0; i < rows.length; i += INGEST_BATCH) {
await tx.insert(soulseekBrowseDirs).values(rows.slice(i, i + INGEST_BATCH));
}
// Summed over the roots, not over every row: the subtree rollups already include descendants, so
// adding them all up would count each file once per level above it.
const roots = rows.filter((r) => r.parentPath === null);
await tx
.update(soulseekBrowseSnapshots)
.set({
status: 'ready',
error: null,
completedAt: new Date(),
directoryCount: rows.length,
fileCount: roots.reduce((n, r) => n + r.subtreeFileCount, 0),
totalSize: roots.reduce((n, r) => n + r.subtreeSize, 0),
})
.where(eq(soulseekBrowseSnapshots.id, snapshotId));
});
}
/** Record a failed fetch. Counts and folder rows are left as they were — a stale cache beats none. */
export async function failSoulseekBrowse(snapshotId: number, error: string): Promise<void> {
await db
.update(soulseekBrowseSnapshots)
.set({ status: 'failed', error: error.slice(0, 1000), completedAt: new Date() })
.where(eq(soulseekBrowseSnapshots.id, snapshotId));
}
/**
* Fail every snapshot still marked pending. Called on sidecar boot: an in-flight fetch dies with the
* process, so without this a crash mid-browse leaves a row spinning in the UI forever.
*/
export async function resetStaleSoulseekBrowses(): Promise<number> {
const rows = await db
.update(soulseekBrowseSnapshots)
.set({ status: 'failed', error: 'interrupted by a sidecar restart', completedAt: new Date() })
.where(eq(soulseekBrowseSnapshots.status, 'pending'))
.returning({ id: soulseekBrowseSnapshots.id });
return rows.length;
}
/** A tree row as the UI renders it. `files` stays out — those load per folder, on expand. */
export type BrowseTreeNode = {
id: number;
name: string;
parentPath: string | null;
label: string;
depth: number;
childCount: number;
fileCount: number;
totalSize: number;
subtreeFileCount: number;
subtreeSize: number;
};
const treeCols = {
id: soulseekBrowseDirs.id,
name: soulseekBrowseDirs.name,
parentPath: soulseekBrowseDirs.parentPath,
label: soulseekBrowseDirs.label,
depth: soulseekBrowseDirs.depth,
childCount: soulseekBrowseDirs.childCount,
fileCount: soulseekBrowseDirs.fileCount,
totalSize: soulseekBrowseDirs.totalSize,
subtreeFileCount: soulseekBrowseDirs.subtreeFileCount,
subtreeSize: soulseekBrowseDirs.subtreeSize,
};
/** The snapshot a request is scoped to, or null if this peer was never fetched. */
async function snapshotIdOf(userId: number, username: string): Promise<number | null> {
const [snap] = await db
.select({ id: soulseekBrowseSnapshots.id })
.from(soulseekBrowseSnapshots)
.where(and(eq(soulseekBrowseSnapshots.userId, userId), eq(soulseekBrowseSnapshots.username, username)))
.limit(1);
return snap?.id ?? null;
}
export type BrowseLevel = { nodes: BrowseTreeNode[]; total: number };
type BrowseLevelParams = { userId: number; username: string; parent?: string; limit: number; offset: number };
/**
* One level of the tree: the children of `parent`, or the roots when it's absent. Paged, because fan-out
* is not gentle — the widest folder measured on a real peer has 1,181 children.
*/
export async function getSoulseekBrowseLevel(params: BrowseLevelParams): Promise<BrowseLevel> {
const { userId, username, parent, limit, offset } = params;
const snapshotId = await snapshotIdOf(userId, username);
if (snapshotId === null) return { nodes: [], total: 0 };
const where = and(
eq(soulseekBrowseDirs.snapshotId, snapshotId),
parent ? eq(soulseekBrowseDirs.parentPath, parent) : isNull(soulseekBrowseDirs.parentPath),
);
const [nodes, [count]] = await Promise.all([
db
.select(treeCols)
.from(soulseekBrowseDirs)
.where(where)
.orderBy(asc(soulseekBrowseDirs.name))
.limit(limit)
.offset(offset),
db
.select({ total: sql<number>`count(*)::int` })
.from(soulseekBrowseDirs)
.where(where),
]);
return { nodes, total: count?.total ?? 0 };
}
// These names come off strangers' filesystems, so they're littered with typographic punctuation that no
// keyboard produces: curly apostrophes, en dashes, and U+2010 HYPHEN, which is pixel-identical to '-'.
// Typing "Hell's Ditch" or "1984-1985" found nothing at all, so both sides get folded to ASCII first.
// Same character set on both sides — extend one and you must extend the other.
const FOLD_FROM = '‘’ʼ'“”‐‑‒–—―− ';
const FOLD_TO = "''''\"\"------- ";
// Not in the translate() pair because it's one character standing in for three.
const ELLIPSIS = '…';
const foldPunctuation = (value: string): string => {
let out = '';
for (const ch of value) {
const at = FOLD_FROM.indexOf(ch);
out += at === -1 ? ch : FOLD_TO[at];
}
return out.split(ELLIPSIS).join('...');
};
// Postgres reads '%', '_' and '\' as LIKE metacharacters, so unescaped input meant a typed '_' matched
// every folder in the share while a pasted path — and every name here IS a backslash-delimited path —
// silently matched none. Backslash stays the escape character, which is why it's escaped first.
const likePattern = (term: string): string => `%${foldPunctuation(term).replace(/[\\%_]/g, (ch) => `\\${ch}`)}%`;
/** The column, folded the same way `likePattern` folds the term. */
const foldedName = sql`replace(translate(${soulseekBrowseDirs.name}, ${FOLD_FROM}, ${FOLD_TO}), ${ELLIPSIS}, '...')`;
export type BrowseTreeSearch = {
/** Matches AND every ancestor of a match, so the UI has a whole tree to render, not orphans. */
nodes: BrowseTreeNode[];
/** `name`s of the rows that actually matched — the rest of `nodes` are there to hold them up. */
matched: string[];
/** Matches in the snapshot, which exceeds `matched.length` when the cap kicked in. */
total: number;
truncated: boolean;
};
type BrowseTreeSearchParams = { userId: number; username: string; q: string; limit: number };
const SEP = '\\';
/** Every path above `name`, longest first: 'a\b\c' → ['a\b', 'a']. */
function ancestorsOf(name: string): string[] {
const out: string[] = [];
for (let cut = name.lastIndexOf(SEP); cut > 0; cut = name.lastIndexOf(SEP, cut - 1)) {
out.push(name.slice(0, cut));
}
return out;
}
/**
* Filtered view of the tree. Filtering never flattens it: matches come back with their full ancestor
* chains, which is what lets the UI open straight down to a hit and keep the shape visible around it.
*
* Ancestors are resolved by string prefix rather than a recursive CTE — the parent's path IS a prefix of
* the child's, so one indexed `name in (…)` lookup does what a walk would take `depth` round trips to do.
*/
export async function searchSoulseekBrowseTree(params: BrowseTreeSearchParams): Promise<BrowseTreeSearch> {
const { userId, username, q, limit } = params;
const snapshotId = await snapshotIdOf(userId, username);
const term = q.trim();
if (snapshotId === null || !term) return { nodes: [], matched: [], total: 0, truncated: false };
// Words are AND-ed, each matched anywhere in the path, so 'pogues hell' finds
// '…\The Pogues\… - Hell's Ditch …' — a folder you can name from two ends but never type in full.
// ILIKE with a leading wildcard can't use the btree index, so this was already a scan of the snapshot's
// ~18k rows — which is why folding the column, once per word, costs nothing on top.
const words = term.split(/\s+/).filter(Boolean);
const where = and(
eq(soulseekBrowseDirs.snapshotId, snapshotId),
...words.map((word) => sql`${foldedName} ilike ${likePattern(word)}`),
);
const [matches, [count]] = await Promise.all([
db.select(treeCols).from(soulseekBrowseDirs).where(where).orderBy(asc(soulseekBrowseDirs.name)).limit(limit),
db
.select({ total: sql<number>`count(*)::int` })
.from(soulseekBrowseDirs)
.where(where),
]);
const have = new Set(matches.map((m) => m.name));
const wanted = new Set<string>();
for (const m of matches) for (const a of ancestorsOf(m.name)) if (!have.has(a)) wanted.add(a);
const ancestors = wanted.size
? await db
.select(treeCols)
.from(soulseekBrowseDirs)
.where(and(eq(soulseekBrowseDirs.snapshotId, snapshotId), inArray(soulseekBrowseDirs.name, [...wanted])))
: [];
const nodes = [...matches, ...ancestors].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
const total = count?.total ?? 0;
return { nodes, matched: matches.map((m) => m.name), total, truncated: total > matches.length };
}
/** One cached folder's files. Scoped by user so a guessed dir id can't read another account's cache. */
type DirFilesParams = { userId: number; username: string; dirId: number };
// Joined and scoped by BOTH owner and peer: a dir id alone would happily return another peer's folder
// (ids are global), so the username in the request URL has to be part of the predicate, not decoration.
export async function getSoulseekBrowseDirFiles({
userId,
username,
dirId,
}: DirFilesParams): Promise<BrowsedFile[] | null> {
const [row] = await db
.select({ files: soulseekBrowseDirs.files })
.from(soulseekBrowseDirs)
.innerJoin(soulseekBrowseSnapshots, eq(soulseekBrowseDirs.snapshotId, soulseekBrowseSnapshots.id))
.where(
and(
eq(soulseekBrowseDirs.id, dirId),
eq(soulseekBrowseSnapshots.userId, userId),
eq(soulseekBrowseSnapshots.username, username),
),
)
.limit(1);
return row ? (row.files as BrowsedFile[]) : null;
}
/** A file as slskd wants it enqueued: the peer's own full path, plus the size it advertised. */
export type BrowseDownloadFile = { filename: string; size: number };
type BrowseDownloadParams = { userId: number; username: string; path: string; file?: string };
/**
* Resolve a download request to the exact files to enqueue — one folder's subtree, or a single file.
*
* Sizes and paths come from the cache rather than the client: slskd matches a queued download on
* filename AND size, so a wrong number is a transfer that never starts. The stored file name is a
* basename (browse, unlike search, doesn't repeat the folder), so the peer's real path is rejoined here.
*/
export async function getSoulseekBrowseDownload({
userId,
username,
path,
file,
}: BrowseDownloadParams): Promise<BrowseDownloadFile[]> {
const snapshotId = await snapshotIdOf(userId, username);
if (snapshotId === null) return [];
const rows = await db
.select({ name: soulseekBrowseDirs.name, files: soulseekBrowseDirs.files })
.from(soulseekBrowseDirs)
.where(
and(
eq(soulseekBrowseDirs.snapshotId, snapshotId),
// starts_with, not LIKE: these paths are full of backslashes, and one would be a LIKE escape.
file
? eq(soulseekBrowseDirs.name, path)
: sql`(${soulseekBrowseDirs.name} = ${path} or starts_with(${soulseekBrowseDirs.name}, ${path + SEP}))`,
),
)
.orderBy(asc(soulseekBrowseDirs.name));
return rows.flatMap((row) =>
(row.files as BrowsedFile[])
.filter((f) => (file ? f.name === file : true))
.map((f) => ({ filename: `${row.name}${SEP}${f.name}`, size: f.size })),
);
}
/** Drop a peer's cache entirely (snapshot + folders, via the FK cascade). */
export async function deleteSoulseekBrowse(userId: number, username: string): Promise<void> {
await db
.delete(soulseekBrowseSnapshots)
.where(and(eq(soulseekBrowseSnapshots.userId, userId), eq(soulseekBrowseSnapshots.username, username)));
}
@@ -1,114 +0,0 @@
import {
pgTable,
serial,
integer,
bigint,
text,
timestamp,
index,
jsonb,
foreignKey,
uniqueIndex,
} from 'drizzle-orm/pg-core';
import { users } from '../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'),
],
);