Add plugin framework and settings UI

This commit is contained in:
Dotta
2026-03-13 16:22:34 -05:00
parent 7e288d20fc
commit 80cdbdbd47
103 changed files with 31760 additions and 35 deletions

View 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);
},
};
}