feat: move snippets backend and tables into a plugin (B2)

This commit is contained in:
LukeGus committed 2026-09-23 08:58:53 -05:00
1 parent fdedca39fd
commit e07221eaf3
51 files changed
+28426 -3553

No files matched your search

+18 -4
View File
@@ -81,10 +81,11 @@ export interface PluginKeyValue {
* to query through `db`. Both require db:own.
*
* Scoped by name: every table carries the p_<id>_ prefix, and `refs` exposes
* users and ssh_data read-only so a plugin can join against them without being
* able to write them. In-process code could reach around all of this - see
* "What this protects, and what it does not" in ARCHITECTURE.md. The
* capability, the prefix, lint and review are the contract, not a sandbox.
* users, ssh_data, roles and user_roles read-only so a plugin can join against
* them without being able to write them. In-process code could reach around
* all of this - see "What this protects, and what it does not" in
* ARCHITECTURE.md. The capability, the prefix, lint and review are the
* contract, not a sandbox.
*/
export interface PluginDatabase {
/** Registers a definition and returns its queryable table object. */
@@ -158,6 +159,17 @@ export interface SyncEntityRegistration {
/** Adds an entity to remote sync between a desktop backend and a server. */
export interface PluginSync {
registerEntity: (entity: SyncEntityRegistration) => void;
/**
* Records that a row was deleted, so a pull on the other side of remote
* sync removes it too. Call it with the row's syncId right after deleting
* it, for a registered entity whose rows can be deleted (a no-op silently
* loses deletes across devices otherwise). A falsy syncId is ignored.
*/
recordTombstone: (
userId: string,
entityType: string,
syncId: string | null | undefined,
) => Promise<void>;
}
export interface PluginRegistry {
@@ -481,6 +493,8 @@ export interface PluginSshConnection<Client = unknown> {
/** An ssh2 Client, already "ready". */
client: Client;
jumpClient: Client | null;
/** The host as core resolved it: ip, port, username, name and the rest. */
host: PluginSshHost;
/** Ends the connection and its jump chain. Also runs on deactivate. */
dispose: () => void;
}
+9 -3
View File
@@ -92,8 +92,11 @@ export function buildTable(
}
/**
* A minimal stand-in for a core table, for tests that join against users or
* ssh_data without core's schema. Only what createTestDb's stubs declare.
* A minimal stand-in for a core table, for tests that join against users,
* ssh_data, roles or user_roles without core's schema. Only what
* createTestDb's stubs declare. The property name is camelCased and mapped
* to its snake_case column the same way buildTable does, so e.g. a stub's
* "displayName" property reads the real "display_name" SQL column.
*/
export function buildRefTable(
name: string,
@@ -101,8 +104,11 @@ export function buildRefTable(
): unknown {
const built: Record<string, unknown> = {};
for (const [property, type] of Object.entries(columns)) {
const columnSqlName = columnName(property);
built[property] =
type === "integer" ? sqliteInteger(property) : sqliteText(property);
type === "integer"
? sqliteInteger(columnSqlName)
: sqliteText(columnSqlName);
}
return sqliteTable(name, built as never);
}
+37 -4
View File
@@ -70,6 +70,8 @@ export interface FakePluginContext {
tables: PluginTableDefinition[];
/** Sync entities registered through ctx.sync.registerEntity, in order. */
syncEntities: SyncEntityRegistration[];
/** Tombstones recorded through ctx.sync.recordTombstone, in order. */
tombstones: Array<{ userId: string; entityType: string; syncId: string }>;
/** WebSocket routes registered through ctx.ws, in order. */
wsRoutes: Array<{ path: string; raw: boolean }>;
/** Router options passed to ctx.http.router, in order. */
@@ -137,6 +139,11 @@ export function createFakeContext(
const kv = new Map<string, unknown>();
const tables: PluginTableDefinition[] = [];
const syncEntities: SyncEntityRegistration[] = [];
const tombstones: Array<{
userId: string;
entityType: string;
syncId: string;
}> = [];
const wsRoutes: Array<{ path: string; raw: boolean }> = [];
const httpRouters: Array<PluginRouterOptions | undefined> = [];
const listeners = new Map<string, Set<(payload: unknown) => void>>();
@@ -263,6 +270,10 @@ export function createFakeContext(
registerEntity: (entity) => {
syncEntities.push(entity);
},
recordTombstone: async (userId, entityType, syncId) => {
if (!syncId) return;
tombstones.push({ userId, entityType, syncId });
},
},
registry: {
@@ -380,6 +391,9 @@ export function createFakeContext(
return {
client: sshClient as never,
jumpClient: null,
host: (typeof host === "number"
? { id: host, ip: "", port: 22, username: "" }
: host) as never,
dispose: () => {},
};
},
@@ -390,6 +404,7 @@ export function createFakeContext(
jumpChain: async () => ({
client: sshClient as never,
jumpClient: null,
host: { id: 0, ip: "", port: 22, username: "" } as never,
dispose: () => {},
}),
poolKey: (pool, host) =>
@@ -452,6 +467,7 @@ export function createFakeContext(
kv,
tables,
syncEntities,
tombstones,
wsRoutes,
httpRouters,
settings,
@@ -722,10 +738,11 @@ export interface TestDb {
/**
* An in-memory SQLite database with a plugin's own migrations applied.
*
* Core's users and ssh_data exist as minimal stubs with foreign keys on, so a
* refUser or refHost column cascades exactly as it does on the server. The
* migrations are read from <pluginDir>/migrations/sqlite and split with the
* same splitter the server's runner uses.
* Core's users, ssh_data, roles and user_roles exist as minimal stubs with
* foreign keys on, so a refUser or refHost column cascades exactly as it
* does on the server, and ctx.db.refs() has something real to join against.
* The migrations are read from <pluginDir>/migrations/sqlite and split with
* the same splitter the server's runner uses.
*/
export async function createTestDb(
pluginDir: string,
@@ -747,6 +764,12 @@ export async function createTestDb(
"CREATE TABLE users (id TEXT PRIMARY KEY, username TEXT NOT NULL DEFAULT '')",
);
sqlite.exec("CREATE TABLE ssh_data (id INTEGER PRIMARY KEY AUTOINCREMENT)");
sqlite.exec(
"CREATE TABLE roles (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, display_name TEXT NOT NULL DEFAULT '')",
);
sqlite.exec(
"CREATE TABLE user_roles (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, role_id INTEGER NOT NULL REFERENCES roles(id) ON DELETE CASCADE)",
);
const handle = drizzle(sqlite);
const pluginId = path.basename(pluginDir);
@@ -781,6 +804,16 @@ export async function createTestDb(
const refs = {
users: builder.buildRefTable("users", { id: "text", username: "text" }),
hosts: builder.buildRefTable("ssh_data", { id: "integer" }),
roles: builder.buildRefTable("roles", {
id: "integer",
name: "text",
displayName: "text",
}),
userRoles: builder.buildRefTable("user_roles", {
id: "integer",
userId: "text",
roleId: "integer",
}),
};
const database: PluginDatabase = {