feat: add plugin worker protocol, bootstrap and plugin_storage table

Worker-side ctx proxy plus the message envelope it talks over, and the
per-plugin key/value table behind ctx.storage with migrations for all
three dialects. Rows cascade with the plugin so uninstalling leaves
nothing behind.
This commit is contained in:
LukeGus committed 2026-09-21 07:19:01 -05:00
1 parent a1805cb7ae
commit 248698f62d
19 files changed
+30035

No files matched your search

+10
View File
@@ -728,6 +728,16 @@ async function initializeCompleteDatabase(): Promise<void> {
UNIQUE (plugin_id, registry_id)
);
CREATE TABLE IF NOT EXISTS plugin_storage (
id INTEGER PRIMARY KEY AUTOINCREMENT,
plugin_id TEXT NOT NULL,
key TEXT NOT NULL,
value TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (plugin_id, key),
FOREIGN KEY (plugin_id) REFERENCES plugins (id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS api_keys (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
+23
View File
@@ -2279,4 +2279,27 @@ export const pluginInstallCounts = mysqlTable(
],
);
/**
* Per-plugin key/value state, written through ctx.storage. Rows are always
* scoped to the calling plugin by the broker, so a plugin cannot name another
* plugin's scope. Cascades with the plugin so uninstalling leaves nothing.
*/
export const pluginStorage = mysqlTable(
"plugin_storage",
{
id: int("id").autoincrement().primaryKey(),
pluginId: varchar("plugin_id", { length: 255 })
.notNull()
.references(() => plugins.id, { onDelete: "cascade" }),
key: varchar("key", { length: 255 }).notNull(),
value: text("value").notNull(),
updatedAt: varchar("updated_at", { length: 255 })
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
},
(table) => [
uniqueIndex("idx_plugin_storage_plugin_key").on(table.pluginId, table.key),
],
);
// --- plugins end ---
+23
View File
@@ -2280,4 +2280,27 @@ export const pluginInstallCounts = pgTable(
],
);
/**
* Per-plugin key/value state, written through ctx.storage. Rows are always
* scoped to the calling plugin by the broker, so a plugin cannot name another
* plugin's scope. Cascades with the plugin so uninstalling leaves nothing.
*/
export const pluginStorage = pgTable(
"plugin_storage",
{
id: serial("id").primaryKey(),
pluginId: varchar("plugin_id", { length: 255 })
.notNull()
.references(() => plugins.id, { onDelete: "cascade" }),
key: varchar("key", { length: 255 }).notNull(),
value: text("value").notNull(),
updatedAt: varchar("updated_at", { length: 255 })
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
(table) => [
uniqueIndex("idx_plugin_storage_plugin_key").on(table.pluginId, table.key),
],
);
// --- plugins end ---
+23
View File
@@ -2278,4 +2278,27 @@ export const pluginInstallCounts = sqliteTable(
],
);
/**
* Per-plugin key/value state, written through ctx.storage. Rows are always
* scoped to the calling plugin by the broker, so a plugin cannot name another
* plugin's scope. Cascades with the plugin so uninstalling leaves nothing.
*/
export const pluginStorage = sqliteTable(
"plugin_storage",
{
id: integer("id").primaryKey({ autoIncrement: true }),
pluginId: text("plugin_id")
.notNull()
.references(() => plugins.id, { onDelete: "cascade" }),
key: text("key").notNull(),
value: text("value").notNull(),
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
(table) => [
uniqueIndex("idx_plugin_storage_plugin_key").on(table.pluginId, table.key),
],
);
// --- plugins end ---
@@ -33,6 +33,7 @@ import { NetworkTopologyRepository } from "./network-topology-repository.js";
import { OpenTabRepository } from "./open-tab-repository.js";
import { OpksshTokenRepository } from "./opkssh-token-repository.js";
import { PluginRepository } from "./plugin-repository.js";
import { PluginStorageRepository } from "./plugin-storage-repository.js";
import { PluginPermissionGrantRepository } from "./plugin-permission-grant-repository.js";
import { PluginRegistryRepository } from "./plugin-registry-repository.js";
import { PluginInstallCountRepository } from "./plugin-install-count-repository.js";
@@ -385,6 +386,13 @@ export function createCurrentPluginPermissionGrantRepository(): PluginPermission
);
}
export function createCurrentPluginStorageRepository(): PluginStorageRepository {
return new PluginStorageRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("plugin_storage_repository_write"),
);
}
export function createCurrentPluginRegistryRepository(): PluginRegistryRepository {
return new PluginRegistryRepository(
createCurrentRepositoryContext(),
@@ -0,0 +1,93 @@
import { and, eq } from "drizzle-orm";
import { pluginStorage } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
import { insertReturning, updateReturning } from "./returning.js";
export type PluginStorageRecord = typeof pluginStorage.$inferSelect;
export class PluginStorageRepository {
constructor(
private readonly context: DatabaseContext,
private readonly onWrite?: () => void | Promise<void>,
) {}
async get(pluginId: string, key: string): Promise<string | null> {
const rows = await this.context.drizzle
.select()
.from(pluginStorage)
.where(
and(eq(pluginStorage.pluginId, pluginId), eq(pluginStorage.key, key)),
)
.limit(1);
return rows[0]?.value ?? null;
}
async listKeys(pluginId: string): Promise<string[]> {
const rows = await this.context.drizzle
.select()
.from(pluginStorage)
.where(eq(pluginStorage.pluginId, pluginId));
return rows.map((row) => row.key);
}
async set(
pluginId: string,
key: string,
value: string,
now = new Date().toISOString(),
): Promise<void> {
const existing = await this.context.drizzle
.select()
.from(pluginStorage)
.where(
and(eq(pluginStorage.pluginId, pluginId), eq(pluginStorage.key, key)),
)
.limit(1);
if (existing[0]) {
await updateReturning(
this.context,
pluginStorage,
{ value, updatedAt: now },
and(eq(pluginStorage.pluginId, pluginId), eq(pluginStorage.key, key))!,
);
} else {
await insertReturning(this.context, pluginStorage, {
pluginId,
key,
value,
updatedAt: now,
});
}
await this.afterWrite();
}
async delete(pluginId: string, key: string): Promise<boolean> {
const result = await this.context.drizzle
.delete(pluginStorage)
.where(
and(eq(pluginStorage.pluginId, pluginId), eq(pluginStorage.key, key)),
);
const affected = rowsAffected(result) > 0;
if (affected) await this.afterWrite();
return affected;
}
async deleteByPlugin(pluginId: string): Promise<number> {
const result = await this.context.drizzle
.delete(pluginStorage)
.where(eq(pluginStorage.pluginId, pluginId));
if (rowsAffected(result) > 0) await this.afterWrite();
return rowsAffected(result);
}
private async afterWrite(): Promise<void> {
await this.onWrite?.();
}
}