Add plugin framework and settings UI
This commit is contained in:
963
server/src/services/plugin-registry.ts
Normal file
963
server/src/services/plugin-registry.ts
Normal file
@@ -0,0 +1,963 @@
|
||||
import { asc, eq, ne, sql, and, inArray } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
plugins,
|
||||
companies,
|
||||
pluginConfig,
|
||||
pluginCompanySettings,
|
||||
pluginEntities,
|
||||
pluginJobs,
|
||||
pluginJobRuns,
|
||||
pluginWebhookDeliveries,
|
||||
} from "@paperclipai/db";
|
||||
import type {
|
||||
PaperclipPluginManifestV1,
|
||||
PluginStatus,
|
||||
InstallPlugin,
|
||||
UpdatePluginStatus,
|
||||
UpsertPluginConfig,
|
||||
PatchPluginConfig,
|
||||
PluginCompanySettings,
|
||||
CompanyPluginAvailability,
|
||||
UpsertPluginCompanySettings,
|
||||
UpdateCompanyPluginAvailability,
|
||||
PluginEntityRecord,
|
||||
PluginEntityQuery,
|
||||
PluginJobRecord,
|
||||
PluginJobRunRecord,
|
||||
PluginWebhookDeliveryRecord,
|
||||
PluginJobStatus,
|
||||
PluginJobRunStatus,
|
||||
PluginJobRunTrigger,
|
||||
PluginWebhookDeliveryStatus,
|
||||
} from "@paperclipai/shared";
|
||||
import { conflict, notFound } from "../errors.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Detect if a Postgres error is a unique-constraint violation on the
|
||||
* `plugins_plugin_key_idx` unique index.
|
||||
*/
|
||||
function isPluginKeyConflict(error: unknown): boolean {
|
||||
if (typeof error !== "object" || error === null) return false;
|
||||
const err = error as { code?: string; constraint?: string; constraint_name?: string };
|
||||
const constraint = err.constraint ?? err.constraint_name;
|
||||
return err.code === "23505" && constraint === "plugins_plugin_key_idx";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Service
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* PluginRegistry – CRUD operations for the `plugins` and `plugin_config`
|
||||
* tables. Follows the same factory-function pattern used by the rest of
|
||||
* the Paperclip service layer.
|
||||
*
|
||||
* This is the lowest-level persistence layer for plugins. Higher-level
|
||||
* concerns such as lifecycle state-machine enforcement and capability
|
||||
* gating are handled by {@link pluginLifecycleManager} and
|
||||
* {@link pluginCapabilityValidator} respectively.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §21.3 — Required Tables
|
||||
*/
|
||||
export function pluginRegistryService(db: Db) {
|
||||
// -----------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
async function getById(id: string) {
|
||||
return db
|
||||
.select()
|
||||
.from(plugins)
|
||||
.where(eq(plugins.id, id))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
}
|
||||
|
||||
async function getByKey(pluginKey: string) {
|
||||
return db
|
||||
.select()
|
||||
.from(plugins)
|
||||
.where(eq(plugins.pluginKey, pluginKey))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
}
|
||||
|
||||
async function nextInstallOrder(): Promise<number> {
|
||||
const result = await db
|
||||
.select({ maxOrder: sql<number>`coalesce(max(${plugins.installOrder}), 0)` })
|
||||
.from(plugins);
|
||||
return (result[0]?.maxOrder ?? 0) + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the persisted company override row for a plugin, if one exists.
|
||||
*
|
||||
* Missing rows are meaningful: the company inherits the default-enabled
|
||||
* behavior and the caller should treat the plugin as available.
|
||||
*/
|
||||
async function getCompanySettingsRow(companyId: string, pluginId: string) {
|
||||
return db
|
||||
.select()
|
||||
.from(pluginCompanySettings)
|
||||
.where(and(
|
||||
eq(pluginCompanySettings.companyId, companyId),
|
||||
eq(pluginCompanySettings.pluginId, pluginId),
|
||||
))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize registry records into the API response returned by company
|
||||
* plugin availability routes.
|
||||
*
|
||||
* The key business rule is captured here: plugins are enabled for a company
|
||||
* unless an explicit `plugin_company_settings.enabled = false` override says
|
||||
* otherwise.
|
||||
*/
|
||||
function toCompanyAvailability(
|
||||
companyId: string,
|
||||
plugin: Awaited<ReturnType<typeof getById>>,
|
||||
settings: PluginCompanySettings | null,
|
||||
): CompanyPluginAvailability {
|
||||
if (!plugin) {
|
||||
throw notFound("Plugin not found");
|
||||
}
|
||||
|
||||
return {
|
||||
companyId,
|
||||
pluginId: plugin.id,
|
||||
pluginKey: plugin.pluginKey,
|
||||
pluginDisplayName: plugin.manifestJson.displayName,
|
||||
pluginStatus: plugin.status,
|
||||
available: settings?.enabled ?? true,
|
||||
settingsJson: settings?.settingsJson ?? {},
|
||||
lastError: settings?.lastError ?? null,
|
||||
createdAt: settings?.createdAt ?? null,
|
||||
updatedAt: settings?.updatedAt ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Public API
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
return {
|
||||
// ----- Read -----------------------------------------------------------
|
||||
|
||||
/** List all registered plugins ordered by install order. */
|
||||
list: () =>
|
||||
db
|
||||
.select()
|
||||
.from(plugins)
|
||||
.orderBy(asc(plugins.installOrder)),
|
||||
|
||||
/**
|
||||
* List installed plugins (excludes soft-deleted/uninstalled).
|
||||
* Use for Plugin Manager and default API list so uninstalled plugins do not appear.
|
||||
*/
|
||||
listInstalled: () =>
|
||||
db
|
||||
.select()
|
||||
.from(plugins)
|
||||
.where(ne(plugins.status, "uninstalled"))
|
||||
.orderBy(asc(plugins.installOrder)),
|
||||
|
||||
/** List plugins filtered by status. */
|
||||
listByStatus: (status: PluginStatus) =>
|
||||
db
|
||||
.select()
|
||||
.from(plugins)
|
||||
.where(eq(plugins.status, status))
|
||||
.orderBy(asc(plugins.installOrder)),
|
||||
|
||||
/** Get a single plugin by primary key. */
|
||||
getById,
|
||||
|
||||
/** Get a single plugin by its unique `pluginKey`. */
|
||||
getByKey,
|
||||
|
||||
// ----- Install / Register --------------------------------------------
|
||||
|
||||
/**
|
||||
* Register (install) a new plugin.
|
||||
*
|
||||
* The caller is expected to have already resolved and validated the
|
||||
* manifest from the package. This method persists the plugin row and
|
||||
* assigns the next install order.
|
||||
*/
|
||||
install: async (input: InstallPlugin, manifest: PaperclipPluginManifestV1) => {
|
||||
const existing = await getByKey(manifest.id);
|
||||
if (existing) {
|
||||
if (existing.status !== "uninstalled") {
|
||||
throw conflict(`Plugin already installed: ${manifest.id}`);
|
||||
}
|
||||
|
||||
// Reinstall after soft-delete: reactivate the existing row so plugin-scoped
|
||||
// data and references remain stable across uninstall/reinstall cycles.
|
||||
return db
|
||||
.update(plugins)
|
||||
.set({
|
||||
packageName: input.packageName,
|
||||
packagePath: input.packagePath ?? null,
|
||||
version: manifest.version,
|
||||
apiVersion: manifest.apiVersion,
|
||||
categories: manifest.categories,
|
||||
manifestJson: manifest,
|
||||
status: "installed" as PluginStatus,
|
||||
lastError: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(plugins.id, existing.id))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
}
|
||||
|
||||
const installOrder = await nextInstallOrder();
|
||||
|
||||
try {
|
||||
const rows = await db
|
||||
.insert(plugins)
|
||||
.values({
|
||||
pluginKey: manifest.id,
|
||||
packageName: input.packageName,
|
||||
version: manifest.version,
|
||||
apiVersion: manifest.apiVersion,
|
||||
categories: manifest.categories,
|
||||
manifestJson: manifest,
|
||||
status: "installed" as PluginStatus,
|
||||
installOrder,
|
||||
packagePath: input.packagePath ?? null,
|
||||
})
|
||||
.returning();
|
||||
return rows[0];
|
||||
} catch (error) {
|
||||
if (isPluginKeyConflict(error)) {
|
||||
throw conflict(`Plugin already installed: ${manifest.id}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// ----- Update ---------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Update a plugin's manifest and version (e.g. on upgrade).
|
||||
* The plugin must already exist.
|
||||
*/
|
||||
update: async (
|
||||
id: string,
|
||||
data: {
|
||||
packageName?: string;
|
||||
version?: string;
|
||||
manifest?: PaperclipPluginManifestV1;
|
||||
},
|
||||
) => {
|
||||
const plugin = await getById(id);
|
||||
if (!plugin) throw notFound("Plugin not found");
|
||||
|
||||
const setClause: Partial<typeof plugins.$inferInsert> & { updatedAt: Date } = {
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
if (data.packageName !== undefined) setClause.packageName = data.packageName;
|
||||
if (data.version !== undefined) setClause.version = data.version;
|
||||
if (data.manifest !== undefined) {
|
||||
setClause.manifestJson = data.manifest;
|
||||
setClause.apiVersion = data.manifest.apiVersion;
|
||||
setClause.categories = data.manifest.categories;
|
||||
}
|
||||
|
||||
return db
|
||||
.update(plugins)
|
||||
.set(setClause)
|
||||
.where(eq(plugins.id, id))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
},
|
||||
|
||||
// ----- Status ---------------------------------------------------------
|
||||
|
||||
/** Update a plugin's lifecycle status and optional error message. */
|
||||
updateStatus: async (id: string, input: UpdatePluginStatus) => {
|
||||
const plugin = await getById(id);
|
||||
if (!plugin) throw notFound("Plugin not found");
|
||||
|
||||
return db
|
||||
.update(plugins)
|
||||
.set({
|
||||
status: input.status,
|
||||
lastError: input.lastError ?? null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(plugins.id, id))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
},
|
||||
|
||||
// ----- Uninstall / Remove --------------------------------------------
|
||||
|
||||
/**
|
||||
* Uninstall a plugin.
|
||||
*
|
||||
* When `removeData` is true the plugin row (and cascaded config) is
|
||||
* hard-deleted. Otherwise the status is set to `"uninstalled"` for
|
||||
* a soft-delete that preserves the record.
|
||||
*/
|
||||
uninstall: async (id: string, removeData = false) => {
|
||||
const plugin = await getById(id);
|
||||
if (!plugin) throw notFound("Plugin not found");
|
||||
|
||||
if (removeData) {
|
||||
// Hard delete – plugin_config cascades via FK onDelete
|
||||
return db
|
||||
.delete(plugins)
|
||||
.where(eq(plugins.id, id))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
}
|
||||
|
||||
// Soft delete – mark as uninstalled
|
||||
return db
|
||||
.update(plugins)
|
||||
.set({
|
||||
status: "uninstalled" as PluginStatus,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(plugins.id, id))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
},
|
||||
|
||||
// ----- Config ---------------------------------------------------------
|
||||
|
||||
/** Retrieve a plugin's instance configuration. */
|
||||
getConfig: (pluginId: string) =>
|
||||
db
|
||||
.select()
|
||||
.from(pluginConfig)
|
||||
.where(eq(pluginConfig.pluginId, pluginId))
|
||||
.then((rows) => rows[0] ?? null),
|
||||
|
||||
/**
|
||||
* Create or fully replace a plugin's instance configuration.
|
||||
* If a config row already exists for the plugin it is replaced;
|
||||
* otherwise a new row is inserted.
|
||||
*/
|
||||
upsertConfig: async (pluginId: string, input: UpsertPluginConfig) => {
|
||||
const plugin = await getById(pluginId);
|
||||
if (!plugin) throw notFound("Plugin not found");
|
||||
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(pluginConfig)
|
||||
.where(eq(pluginConfig.pluginId, pluginId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
if (existing) {
|
||||
return db
|
||||
.update(pluginConfig)
|
||||
.set({
|
||||
configJson: input.configJson,
|
||||
lastError: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(pluginConfig.pluginId, pluginId))
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
}
|
||||
|
||||
return db
|
||||
.insert(pluginConfig)
|
||||
.values({
|
||||
pluginId,
|
||||
configJson: input.configJson,
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
},
|
||||
|
||||
/**
|
||||
* Partially update a plugin's instance configuration via shallow merge.
|
||||
* If no config row exists yet one is created with the supplied values.
|
||||
*/
|
||||
patchConfig: async (pluginId: string, input: PatchPluginConfig) => {
|
||||
const plugin = await getById(pluginId);
|
||||
if (!plugin) throw notFound("Plugin not found");
|
||||
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(pluginConfig)
|
||||
.where(eq(pluginConfig.pluginId, pluginId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
if (existing) {
|
||||
const merged = { ...existing.configJson, ...input.configJson };
|
||||
return db
|
||||
.update(pluginConfig)
|
||||
.set({
|
||||
configJson: merged,
|
||||
lastError: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(pluginConfig.pluginId, pluginId))
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
}
|
||||
|
||||
return db
|
||||
.insert(pluginConfig)
|
||||
.values({
|
||||
pluginId,
|
||||
configJson: input.configJson,
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
},
|
||||
|
||||
// ----- Company-scoped settings ----------------------------------------
|
||||
|
||||
/** Retrieve a plugin's company-scoped settings row, if any. */
|
||||
getCompanySettings: (companyId: string, pluginId: string) =>
|
||||
getCompanySettingsRow(companyId, pluginId),
|
||||
|
||||
/** Create or replace the company-scoped settings row for a plugin. */
|
||||
upsertCompanySettings: async (
|
||||
companyId: string,
|
||||
pluginId: string,
|
||||
input: UpsertPluginCompanySettings,
|
||||
) => {
|
||||
const plugin = await getById(pluginId);
|
||||
if (!plugin) throw notFound("Plugin not found");
|
||||
|
||||
const existing = await getCompanySettingsRow(companyId, pluginId);
|
||||
if (existing) {
|
||||
return db
|
||||
.update(pluginCompanySettings)
|
||||
.set({
|
||||
enabled: true,
|
||||
settingsJson: input.settingsJson ?? {},
|
||||
lastError: input.lastError ?? null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(pluginCompanySettings.id, existing.id))
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
}
|
||||
|
||||
return db
|
||||
.insert(pluginCompanySettings)
|
||||
.values({
|
||||
companyId,
|
||||
pluginId,
|
||||
enabled: true,
|
||||
settingsJson: input.settingsJson ?? {},
|
||||
lastError: input.lastError ?? null,
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
},
|
||||
|
||||
/** Delete the company-scoped settings row for a plugin if it exists. */
|
||||
deleteCompanySettings: async (companyId: string, pluginId: string) => {
|
||||
const plugin = await getById(pluginId);
|
||||
if (!plugin) throw notFound("Plugin not found");
|
||||
|
||||
const existing = await getCompanySettingsRow(companyId, pluginId);
|
||||
if (!existing) return null;
|
||||
|
||||
return db
|
||||
.delete(pluginCompanySettings)
|
||||
.where(eq(pluginCompanySettings.id, existing.id))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
},
|
||||
|
||||
/** List normalized company-plugin availability records across installed plugins. */
|
||||
listCompanyAvailability: async (
|
||||
companyId: string,
|
||||
filter?: { available?: boolean },
|
||||
) => {
|
||||
const installed = await db
|
||||
.select()
|
||||
.from(plugins)
|
||||
.where(ne(plugins.status, "uninstalled"))
|
||||
.orderBy(asc(plugins.installOrder));
|
||||
|
||||
const settingsRows = await db
|
||||
.select()
|
||||
.from(pluginCompanySettings)
|
||||
.where(eq(pluginCompanySettings.companyId, companyId));
|
||||
|
||||
const settingsByPluginId = new Map(settingsRows.map((row) => [row.pluginId, row]));
|
||||
const availability = installed.map((plugin) => {
|
||||
const row = settingsByPluginId.get(plugin.id) ?? null;
|
||||
return {
|
||||
...toCompanyAvailability(companyId, plugin, row),
|
||||
};
|
||||
});
|
||||
|
||||
if (filter?.available === undefined) return availability;
|
||||
return availability.filter((item) => item.available === filter.available);
|
||||
},
|
||||
|
||||
/**
|
||||
* Batch-check which companies have this plugin explicitly disabled.
|
||||
* Returns a Set of companyIds where `enabled = false`. Companies with
|
||||
* no settings row default to enabled, so they are NOT in the result set.
|
||||
*/
|
||||
getDisabledCompanyIds: async (companyIds: string[], pluginId: string): Promise<Set<string>> => {
|
||||
if (companyIds.length === 0) return new Set();
|
||||
const rows = await db
|
||||
.select({
|
||||
companyId: pluginCompanySettings.companyId,
|
||||
enabled: pluginCompanySettings.enabled,
|
||||
})
|
||||
.from(pluginCompanySettings)
|
||||
.where(and(
|
||||
inArray(pluginCompanySettings.companyId, companyIds),
|
||||
eq(pluginCompanySettings.pluginId, pluginId),
|
||||
));
|
||||
const disabled = new Set<string>();
|
||||
for (const row of rows) {
|
||||
if (!row.enabled) disabled.add(row.companyId);
|
||||
}
|
||||
return disabled;
|
||||
},
|
||||
|
||||
/** Get the normalized availability record for a single company/plugin pair. */
|
||||
getCompanyAvailability: async (companyId: string, pluginId: string) => {
|
||||
const plugin = await getById(pluginId);
|
||||
if (!plugin || plugin.status === "uninstalled") return null;
|
||||
|
||||
const settings = await getCompanySettingsRow(companyId, pluginId);
|
||||
return toCompanyAvailability(companyId, plugin, settings);
|
||||
},
|
||||
|
||||
/** Update normalized company availability, persisting or deleting settings as needed. */
|
||||
updateCompanyAvailability: async (
|
||||
companyId: string,
|
||||
pluginId: string,
|
||||
input: UpdateCompanyPluginAvailability,
|
||||
) => {
|
||||
const plugin = await getById(pluginId);
|
||||
if (!plugin || plugin.status === "uninstalled") throw notFound("Plugin not found");
|
||||
|
||||
const existing = await getCompanySettingsRow(companyId, pluginId);
|
||||
|
||||
if (!input.available) {
|
||||
const row = await (existing
|
||||
? db
|
||||
.update(pluginCompanySettings)
|
||||
.set({
|
||||
enabled: false,
|
||||
settingsJson: input.settingsJson ?? existing.settingsJson,
|
||||
lastError: input.lastError ?? existing.lastError ?? null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(pluginCompanySettings.id, existing.id))
|
||||
.returning()
|
||||
.then((rows) => rows[0])
|
||||
: db
|
||||
.insert(pluginCompanySettings)
|
||||
.values({
|
||||
companyId,
|
||||
pluginId,
|
||||
enabled: false,
|
||||
settingsJson: input.settingsJson ?? {},
|
||||
lastError: input.lastError ?? null,
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0]));
|
||||
|
||||
return {
|
||||
...toCompanyAvailability(companyId, plugin, row),
|
||||
};
|
||||
}
|
||||
|
||||
const row = await (existing
|
||||
? db
|
||||
.update(pluginCompanySettings)
|
||||
.set({
|
||||
enabled: true,
|
||||
settingsJson: input.settingsJson ?? existing.settingsJson,
|
||||
lastError: input.lastError ?? existing.lastError ?? null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(pluginCompanySettings.id, existing.id))
|
||||
.returning()
|
||||
.then((rows) => rows[0])
|
||||
: db
|
||||
.insert(pluginCompanySettings)
|
||||
.values({
|
||||
companyId,
|
||||
pluginId,
|
||||
enabled: true,
|
||||
settingsJson: input.settingsJson ?? {},
|
||||
lastError: input.lastError ?? null,
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0]));
|
||||
|
||||
return {
|
||||
...toCompanyAvailability(companyId, plugin, row),
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Ensure all companies have an explicit enabled row for this plugin.
|
||||
*
|
||||
* Company availability defaults to enabled when no row exists, but this
|
||||
* helper persists explicit `enabled=true` rows so newly-installed plugins
|
||||
* appear as enabled immediately and consistently in company-scoped views.
|
||||
*/
|
||||
seedEnabledForAllCompanies: async (pluginId: string) => {
|
||||
const plugin = await getById(pluginId);
|
||||
if (!plugin || plugin.status === "uninstalled") throw notFound("Plugin not found");
|
||||
|
||||
const companyRows = await db
|
||||
.select({ id: companies.id })
|
||||
.from(companies);
|
||||
|
||||
if (companyRows.length === 0) return 0;
|
||||
|
||||
const now = new Date();
|
||||
await db
|
||||
.insert(pluginCompanySettings)
|
||||
.values(
|
||||
companyRows.map((company) => ({
|
||||
companyId: company.id,
|
||||
pluginId,
|
||||
enabled: true,
|
||||
settingsJson: {},
|
||||
lastError: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})),
|
||||
)
|
||||
.onConflictDoNothing({
|
||||
target: [pluginCompanySettings.companyId, pluginCompanySettings.pluginId],
|
||||
});
|
||||
|
||||
return companyRows.length;
|
||||
},
|
||||
|
||||
/**
|
||||
* Record an error against a plugin's config (e.g. validation failure
|
||||
* against the plugin's instanceConfigSchema).
|
||||
*/
|
||||
setConfigError: async (pluginId: string, lastError: string | null) => {
|
||||
const rows = await db
|
||||
.update(pluginConfig)
|
||||
.set({ lastError, updatedAt: new Date() })
|
||||
.where(eq(pluginConfig.pluginId, pluginId))
|
||||
.returning();
|
||||
|
||||
if (rows.length === 0) throw notFound("Plugin config not found");
|
||||
return rows[0];
|
||||
},
|
||||
|
||||
/** Delete a plugin's config row. */
|
||||
deleteConfig: async (pluginId: string) => {
|
||||
const rows = await db
|
||||
.delete(pluginConfig)
|
||||
.where(eq(pluginConfig.pluginId, pluginId))
|
||||
.returning();
|
||||
|
||||
return rows[0] ?? null;
|
||||
},
|
||||
|
||||
// ----- Entities -------------------------------------------------------
|
||||
|
||||
/**
|
||||
* List persistent entity mappings owned by a specific plugin, with filtering and pagination.
|
||||
*
|
||||
* @param pluginId - The UUID of the plugin.
|
||||
* @param query - Optional filters (type, externalId) and pagination (limit, offset).
|
||||
* @returns A list of matching `PluginEntityRecord` objects.
|
||||
*/
|
||||
listEntities: (pluginId: string, query?: PluginEntityQuery) => {
|
||||
const conditions = [eq(pluginEntities.pluginId, pluginId)];
|
||||
if (query?.entityType) conditions.push(eq(pluginEntities.entityType, query.entityType));
|
||||
if (query?.externalId) conditions.push(eq(pluginEntities.externalId, query.externalId));
|
||||
|
||||
return db
|
||||
.select()
|
||||
.from(pluginEntities)
|
||||
.where(and(...conditions))
|
||||
.orderBy(asc(pluginEntities.createdAt))
|
||||
.limit(query?.limit ?? 100)
|
||||
.offset(query?.offset ?? 0);
|
||||
},
|
||||
|
||||
/**
|
||||
* Look up a plugin-owned entity mapping by its external identifier.
|
||||
*
|
||||
* @param pluginId - The UUID of the plugin.
|
||||
* @param entityType - The type of entity (e.g., 'project', 'issue').
|
||||
* @param externalId - The identifier in the external system.
|
||||
* @returns The matching `PluginEntityRecord` or null.
|
||||
*/
|
||||
getEntityByExternalId: (
|
||||
pluginId: string,
|
||||
entityType: string,
|
||||
externalId: string,
|
||||
) =>
|
||||
db
|
||||
.select()
|
||||
.from(pluginEntities)
|
||||
.where(
|
||||
and(
|
||||
eq(pluginEntities.pluginId, pluginId),
|
||||
eq(pluginEntities.entityType, entityType),
|
||||
eq(pluginEntities.externalId, externalId),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0] ?? null),
|
||||
|
||||
/**
|
||||
* Create or update a persistent mapping between a Paperclip object and an
|
||||
* external entity.
|
||||
*
|
||||
* @param pluginId - The UUID of the plugin.
|
||||
* @param input - The entity data to persist.
|
||||
* @returns The newly created or updated `PluginEntityRecord`.
|
||||
*/
|
||||
upsertEntity: async (
|
||||
pluginId: string,
|
||||
input: Omit<typeof pluginEntities.$inferInsert, "id" | "pluginId" | "createdAt" | "updatedAt">,
|
||||
) => {
|
||||
// Drizzle doesn't support pg-specific onConflictDoUpdate easily in the insert() call
|
||||
// with complex where clauses, so we do it manually.
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(pluginEntities)
|
||||
.where(
|
||||
and(
|
||||
eq(pluginEntities.pluginId, pluginId),
|
||||
eq(pluginEntities.entityType, input.entityType),
|
||||
eq(pluginEntities.externalId, input.externalId ?? ""),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
if (existing) {
|
||||
return db
|
||||
.update(pluginEntities)
|
||||
.set({
|
||||
...input,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(pluginEntities.id, existing.id))
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
}
|
||||
|
||||
return db
|
||||
.insert(pluginEntities)
|
||||
.values({
|
||||
...input,
|
||||
pluginId,
|
||||
} as any)
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete a specific plugin-owned entity mapping by its internal UUID.
|
||||
*
|
||||
* @param id - The UUID of the entity record.
|
||||
* @returns The deleted record, or null if not found.
|
||||
*/
|
||||
deleteEntity: async (id: string) => {
|
||||
const rows = await db
|
||||
.delete(pluginEntities)
|
||||
.where(eq(pluginEntities.id, id))
|
||||
.returning();
|
||||
return rows[0] ?? null;
|
||||
},
|
||||
|
||||
// ----- Jobs -----------------------------------------------------------
|
||||
|
||||
/**
|
||||
* List all scheduled jobs registered for a specific plugin.
|
||||
*
|
||||
* @param pluginId - The UUID of the plugin.
|
||||
* @returns A list of `PluginJobRecord` objects.
|
||||
*/
|
||||
listJobs: (pluginId: string) =>
|
||||
db
|
||||
.select()
|
||||
.from(pluginJobs)
|
||||
.where(eq(pluginJobs.pluginId, pluginId))
|
||||
.orderBy(asc(pluginJobs.jobKey)),
|
||||
|
||||
/**
|
||||
* Look up a plugin job by its unique job key.
|
||||
*
|
||||
* @param pluginId - The UUID of the plugin.
|
||||
* @param jobKey - The key defined in the plugin manifest.
|
||||
* @returns The matching `PluginJobRecord` or null.
|
||||
*/
|
||||
getJobByKey: (pluginId: string, jobKey: string) =>
|
||||
db
|
||||
.select()
|
||||
.from(pluginJobs)
|
||||
.where(and(eq(pluginJobs.pluginId, pluginId), eq(pluginJobs.jobKey, jobKey)))
|
||||
.then((rows) => rows[0] ?? null),
|
||||
|
||||
/**
|
||||
* Register or update a scheduled job for a plugin.
|
||||
*
|
||||
* @param pluginId - The UUID of the plugin.
|
||||
* @param jobKey - The unique key for the job.
|
||||
* @param input - The schedule (cron) and optional status.
|
||||
* @returns The updated or created `PluginJobRecord`.
|
||||
*/
|
||||
upsertJob: async (
|
||||
pluginId: string,
|
||||
jobKey: string,
|
||||
input: { schedule: string; status?: PluginJobStatus },
|
||||
) => {
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(pluginJobs)
|
||||
.where(and(eq(pluginJobs.pluginId, pluginId), eq(pluginJobs.jobKey, jobKey)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
if (existing) {
|
||||
return db
|
||||
.update(pluginJobs)
|
||||
.set({
|
||||
schedule: input.schedule,
|
||||
status: input.status ?? existing.status,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(pluginJobs.id, existing.id))
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
}
|
||||
|
||||
return db
|
||||
.insert(pluginJobs)
|
||||
.values({
|
||||
pluginId,
|
||||
jobKey,
|
||||
schedule: input.schedule,
|
||||
status: input.status ?? "active",
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
},
|
||||
|
||||
/**
|
||||
* Record the start of a specific job execution.
|
||||
*
|
||||
* @param pluginId - The UUID of the plugin.
|
||||
* @param jobId - The UUID of the parent job record.
|
||||
* @param trigger - What triggered this run (e.g., 'schedule', 'manual').
|
||||
* @returns The newly created `PluginJobRunRecord` in 'pending' status.
|
||||
*/
|
||||
createJobRun: async (
|
||||
pluginId: string,
|
||||
jobId: string,
|
||||
trigger: PluginJobRunTrigger,
|
||||
) => {
|
||||
return db
|
||||
.insert(pluginJobRuns)
|
||||
.values({
|
||||
pluginId,
|
||||
jobId,
|
||||
trigger,
|
||||
status: "pending",
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
},
|
||||
|
||||
/**
|
||||
* Update the status, duration, and logs of a job execution record.
|
||||
*
|
||||
* @param runId - The UUID of the job run.
|
||||
* @param input - The update fields (status, error, duration, etc.).
|
||||
* @returns The updated `PluginJobRunRecord`.
|
||||
*/
|
||||
updateJobRun: async (
|
||||
runId: string,
|
||||
input: {
|
||||
status: PluginJobRunStatus;
|
||||
durationMs?: number;
|
||||
error?: string;
|
||||
logs?: string[];
|
||||
startedAt?: Date;
|
||||
finishedAt?: Date;
|
||||
},
|
||||
) => {
|
||||
return db
|
||||
.update(pluginJobRuns)
|
||||
.set(input)
|
||||
.where(eq(pluginJobRuns.id, runId))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
},
|
||||
|
||||
// ----- Webhooks -------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Create a record for an incoming webhook delivery.
|
||||
*
|
||||
* @param pluginId - The UUID of the receiving plugin.
|
||||
* @param webhookKey - The endpoint key defined in the manifest.
|
||||
* @param input - The payload, headers, and optional external ID.
|
||||
* @returns The newly created `PluginWebhookDeliveryRecord` in 'pending' status.
|
||||
*/
|
||||
createWebhookDelivery: async (
|
||||
pluginId: string,
|
||||
webhookKey: string,
|
||||
input: {
|
||||
externalId?: string;
|
||||
payload: Record<string, unknown>;
|
||||
headers?: Record<string, string>;
|
||||
},
|
||||
) => {
|
||||
return db
|
||||
.insert(pluginWebhookDeliveries)
|
||||
.values({
|
||||
pluginId,
|
||||
webhookKey,
|
||||
externalId: input.externalId,
|
||||
payload: input.payload,
|
||||
headers: input.headers ?? {},
|
||||
status: "pending",
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
},
|
||||
|
||||
/**
|
||||
* Update the status and processing metrics of a webhook delivery.
|
||||
*
|
||||
* @param deliveryId - The UUID of the delivery record.
|
||||
* @param input - The update fields (status, error, duration, etc.).
|
||||
* @returns The updated `PluginWebhookDeliveryRecord`.
|
||||
*/
|
||||
updateWebhookDelivery: async (
|
||||
deliveryId: string,
|
||||
input: {
|
||||
status: PluginWebhookDeliveryStatus;
|
||||
durationMs?: number;
|
||||
error?: string;
|
||||
startedAt?: Date;
|
||||
finishedAt?: Date;
|
||||
},
|
||||
) => {
|
||||
return db
|
||||
.update(pluginWebhookDeliveries)
|
||||
.set(input)
|
||||
.where(eq(pluginWebhookDeliveries.id, deliveryId))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user