mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-12 18:06:11 +02:00
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work.
> - First-party **plugins** run as isolated workers spawned by the host
`plugin-loader`, reading company-scoped config through a governed
`ctx.config.get(companyId)` channel.
> - The host→worker `configChanged` RPC carries `{ config, companyId }`,
but the SDK dispatch dropped the scope — `onConfigChanged(newConfig)`
was companyId-blind by design — so a **proactive** worker kept a single
worker-global config.
> - #10092 added a startup replay that fans out **every** stored
company's config through `configChanged`. With no deterministic
ordering, a plugin configured for more than one distinct company ends up
running as whichever DB row was delivered last.
> - That is a latent cross-tenant identity/secret confusion bug: one
company's bot token could be applied to another company's traffic.
> - This pull request threads `companyId` through `onConfigChanged` and
adds a fail-closed cross-tenant guard at the SDK layer, so a
single-tenant worker can never silently collapse to a second company's
config.
> - The benefit is that the config-delivery class is fixed at the SDK
boundary — before any genuinely multi-company proactive plugin ships —
without changing today's single-tenant behavior.
## Linked Issues or Issue Description
No public GitHub issue — describing in-PR (hardening / latent security):
**Latent cross-tenant config collapse.** The worker-side `configChanged`
dispatch forwarded only `config` and dropped `companyId`, so a proactive
plugin kept a single worker-global config. #10092's startup replay
delivers every configured company's config sequentially with no `ORDER
BY`, so a plugin with configs for more than one distinct company would
apply a nondeterministic last-write-wins global config (one tenant's
credential applied to another's traffic).
- Builds on and must merge after #10092.
- Not exploitable today: the only proactive consumer (the chat gateway)
has single-tenant config rows, so last-write-wins is a no-op. This is a
hardening pre-condition before any multi-company proactive plugin ships.
## What Changed
- **Thread scope through:** `onConfigChanged(newConfig, context)` with a
new exported `PluginConfigChangeContext { companyId }`. Backward
compatible — the second arg is optional; existing single-arg
implementations are unaffected.
- **Fail-closed cross-tenant guard** (`worker-rpc-host.ts`): a
single-tenant plugin that receives `configChanged` for a second,
distinct company with a *different* config is rejected with the new
`PLUGIN_RPC_ERROR_CODES.CROSS_TENANT_CONFIG` instead of silently
overwriting the applied tenant's config. Idempotent replays of the
*same* config under a different scope row remain allowed.
- **Opt-in `multiCompanyConfig: true`** on the plugin definition for
plugins that genuinely serve multiple companies from one worker (keying
per-company state on `context.companyId`); the guard is bypassed for
those.
- **Deterministic `ORDER BY companyId`** on `registry.listConfigs`, so
the startup replay binds a single-tenant worker to a stable company
across restarts.
- **Loader visibility:** a `CROSS_TENANT_CONFIG` rejection is logged at
`warn` (was best-effort `debug`) so the misconfiguration is surfaced.
- **Regression test**
(`packages/plugins/sdk/tests/worker-rpc-host.test.ts`): two distinct
companies delivered via the startup-replay path fail closed and stay
bound to the first company; an idempotent same-config replay under a
different scope row is allowed; a `multiCompanyConfig` plugin receives
per-company config with the correct `context.companyId`.
## Verification
- SDK `tsc --noEmit`: clean.
- SDK vitest `worker-rpc-host.test.ts`: 7/7 pass (incl. 3 new). The
two-distinct-company case **fails against pre-fix code** and passes
after the fix.
- #10092 embedded-postgres `plugin-config-startup-delivery.test.ts`: 3/3
pass (unaffected by the new `ORDER BY`).
- Full server `tsc --noEmit` against this SDK: clean.
## Risks
- **Low functional risk.** The second `onConfigChanged` arg is optional
and existing implementations are unchanged. Today's single-tenant
gateway keeps working — idempotent same-config replays are explicitly
allowed, so the go-live is preserved.
- **Behavioral shift on misconfig:** a genuinely multi-company plugin
that has NOT opted into `multiCompanyConfig` now fails closed
(`CROSS_TENANT_CONFIG`) rather than silently collapsing to one tenant.
This is the intended safer default; opt in with `multiCompanyConfig:
true` to serve multiple companies from one worker.
- **Not in scope (residual).** Per-company workers/connections for a
genuinely multi-company gateway increase resource use and are tracked
separately (ties into the #10092 fan-out/timeout follow-up). This PR
fixes the class and fails closed; it does not build multi-tenant
connection management.
## Model Used
Claude — Anthropic `claude-opus-4-8` (Opus 4.8), extended thinking, with
tool use / code execution via Claude Code.
## Checklist
- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes:` / `Closes`
/ `Refs` OR (b) described the issue in-PR following the relevant issue
template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [ ] My branch name describes the change and contains no internal
Paperclip ticket id — branch predates this rule; not renaming an open PR
mid-review
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [ ] I have updated relevant documentation to reflect my changes — no
doc surface; internal SDK/host behavior only
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] I will address all Greptile and reviewer comments before
requesting merge
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: anicca <annica@Michaels-Mac-Studio.local>
Co-authored-by: Paperclip <noreply@paperclip.ing>
809 lines
26 KiB
TypeScript
809 lines
26 KiB
TypeScript
import { asc, eq, isNull, ne, sql, and } from "drizzle-orm";
|
||
import type { Db } from "@paperclipai/db";
|
||
import {
|
||
plugins,
|
||
pluginConfig,
|
||
pluginCompanySettings,
|
||
pluginEntities,
|
||
pluginJobs,
|
||
pluginJobRuns,
|
||
pluginWebhookDeliveries,
|
||
} from "@paperclipai/db";
|
||
import type {
|
||
PaperclipPluginManifestV1,
|
||
PluginStatus,
|
||
InstallPlugin,
|
||
UpdatePluginStatus,
|
||
UpsertPluginConfig,
|
||
PatchPluginConfig,
|
||
PluginCompanySettings,
|
||
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;
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// 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 company-scoped configuration. */
|
||
getConfig: (pluginId: string, companyId: string) =>
|
||
db
|
||
.select()
|
||
.from(pluginConfig)
|
||
.where(and(eq(pluginConfig.pluginId, pluginId), eq(pluginConfig.companyId, companyId)))
|
||
.then((rows) => rows[0] ?? null),
|
||
|
||
/**
|
||
* List every company-scoped configuration row for a plugin.
|
||
*
|
||
* Plugin config is company-scoped, but a worker is spawned once per plugin
|
||
* (not per company). Callers such as the plugin loader use this to replay
|
||
* each configured company's config to a freshly-started worker, so a
|
||
* proactive plugin that never runs inside a company-scoped invocation (and
|
||
* therefore cannot resolve `ctx.config.get(companyId)`) still receives its
|
||
* configuration at startup.
|
||
*
|
||
* Ordered deterministically by companyId: the startup replay delivers these
|
||
* rows to a single worker via `configChanged`, and a single-tenant worker
|
||
* binds to the first company it sees. Without a stable order the worker
|
||
* would bind to a nondeterministic (DB-dependent) company across restarts.
|
||
*/
|
||
listConfigs: (pluginId: string) =>
|
||
db
|
||
.select()
|
||
.from(pluginConfig)
|
||
.where(eq(pluginConfig.pluginId, pluginId))
|
||
.orderBy(asc(pluginConfig.companyId)),
|
||
|
||
/**
|
||
* Create or fully replace a plugin's company-scoped configuration.
|
||
* If a config row already exists for the plugin/company pair it is replaced;
|
||
* otherwise a new row is inserted.
|
||
*/
|
||
upsertConfig: async (pluginId: string, companyId: string, input: UpsertPluginConfig) => {
|
||
const plugin = await getById(pluginId);
|
||
if (!plugin) throw notFound("Plugin not found");
|
||
|
||
const existing = await db
|
||
.select()
|
||
.from(pluginConfig)
|
||
.where(and(eq(pluginConfig.pluginId, pluginId), eq(pluginConfig.companyId, companyId)))
|
||
.then((rows) => rows[0] ?? null);
|
||
|
||
if (existing) {
|
||
return db
|
||
.update(pluginConfig)
|
||
.set({
|
||
configJson: input.configJson,
|
||
lastError: null,
|
||
updatedAt: new Date(),
|
||
})
|
||
.where(and(eq(pluginConfig.pluginId, pluginId), eq(pluginConfig.companyId, companyId)))
|
||
.returning()
|
||
.then((rows) => rows[0]);
|
||
}
|
||
|
||
return db
|
||
.insert(pluginConfig)
|
||
.values({
|
||
pluginId,
|
||
companyId,
|
||
configJson: input.configJson,
|
||
})
|
||
.returning()
|
||
.then((rows) => rows[0]);
|
||
},
|
||
|
||
/**
|
||
* Partially update a plugin's company-scoped configuration via shallow merge.
|
||
* If no config row exists yet one is created with the supplied values.
|
||
*/
|
||
patchConfig: async (pluginId: string, companyId: string, input: PatchPluginConfig) => {
|
||
const plugin = await getById(pluginId);
|
||
if (!plugin) throw notFound("Plugin not found");
|
||
|
||
const existing = await db
|
||
.select()
|
||
.from(pluginConfig)
|
||
.where(and(eq(pluginConfig.pluginId, pluginId), eq(pluginConfig.companyId, companyId)))
|
||
.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(and(eq(pluginConfig.pluginId, pluginId), eq(pluginConfig.companyId, companyId)))
|
||
.returning()
|
||
.then((rows) => rows[0]);
|
||
}
|
||
|
||
return db
|
||
.insert(pluginConfig)
|
||
.values({
|
||
pluginId,
|
||
companyId,
|
||
configJson: input.configJson,
|
||
})
|
||
.returning()
|
||
.then((rows) => rows[0]);
|
||
},
|
||
|
||
/**
|
||
* Record an error against a plugin's config (e.g. validation failure
|
||
* against the plugin's instanceConfigSchema).
|
||
*/
|
||
setConfigError: async (pluginId: string, companyId: string, lastError: string | null) => {
|
||
const rows = await db
|
||
.update(pluginConfig)
|
||
.set({ lastError, updatedAt: new Date() })
|
||
.where(and(eq(pluginConfig.pluginId, pluginId), eq(pluginConfig.companyId, companyId)))
|
||
.returning();
|
||
|
||
if (rows.length === 0) throw notFound("Plugin config not found");
|
||
return rows[0];
|
||
},
|
||
|
||
/** Delete a plugin's config row. */
|
||
deleteConfig: async (pluginId: string, companyId: string) => {
|
||
const rows = await db
|
||
.delete(pluginConfig)
|
||
.where(and(eq(pluginConfig.pluginId, pluginId), eq(pluginConfig.companyId, companyId)))
|
||
.returning();
|
||
|
||
return rows[0] ?? null;
|
||
},
|
||
|
||
// ----- Company settings ----------------------------------------------
|
||
|
||
/** Retrieve company-scoped plugin settings. */
|
||
getCompanySettings: (pluginId: string, companyId: string): Promise<PluginCompanySettings | null> =>
|
||
db
|
||
.select()
|
||
.from(pluginCompanySettings)
|
||
.where(and(
|
||
eq(pluginCompanySettings.pluginId, pluginId),
|
||
eq(pluginCompanySettings.companyId, companyId),
|
||
))
|
||
.then((rows) => rows[0] ?? null) as Promise<PluginCompanySettings | null>,
|
||
|
||
/** Create or replace company-scoped plugin settings. */
|
||
upsertCompanySettings: async (
|
||
pluginId: string,
|
||
companyId: string,
|
||
input: { enabled?: boolean; settingsJson: Record<string, unknown>; lastError?: string | null },
|
||
): Promise<PluginCompanySettings> => {
|
||
const plugin = await getById(pluginId);
|
||
if (!plugin) throw notFound("Plugin not found");
|
||
|
||
const existing = await db
|
||
.select()
|
||
.from(pluginCompanySettings)
|
||
.where(and(
|
||
eq(pluginCompanySettings.pluginId, pluginId),
|
||
eq(pluginCompanySettings.companyId, companyId),
|
||
))
|
||
.then((rows) => rows[0] ?? null);
|
||
|
||
if (existing) {
|
||
return db
|
||
.update(pluginCompanySettings)
|
||
.set({
|
||
enabled: input.enabled ?? existing.enabled,
|
||
settingsJson: input.settingsJson,
|
||
lastError: input.lastError ?? null,
|
||
updatedAt: new Date(),
|
||
})
|
||
.where(eq(pluginCompanySettings.id, existing.id))
|
||
.returning()
|
||
.then((rows) => rows[0]) as Promise<PluginCompanySettings>;
|
||
}
|
||
|
||
return db
|
||
.insert(pluginCompanySettings)
|
||
.values({
|
||
pluginId,
|
||
companyId,
|
||
enabled: input.enabled ?? true,
|
||
settingsJson: input.settingsJson,
|
||
lastError: input.lastError ?? null,
|
||
})
|
||
.returning()
|
||
.then((rows) => rows[0]) as Promise<PluginCompanySettings>;
|
||
},
|
||
|
||
// ----- 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.
|
||
*
|
||
* Scope matches `plugin_entities_external_idx` (NULLS NOT DISTINCT):
|
||
* pass the owning `companyId` (or `null` for instance-scope) to retrieve
|
||
* the row that belongs to that tenant. Two companies can share the same
|
||
* `(pluginId, entityType, externalId)` tuple — omitting `companyId` would
|
||
* return the first matched row regardless of tenant, which is unsafe.
|
||
*
|
||
* @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.
|
||
* @param companyId - Tenant scope; `null` for instance-scope entities.
|
||
* @returns The matching `PluginEntityRecord` or null.
|
||
*/
|
||
getEntityByExternalId: (
|
||
pluginId: string,
|
||
entityType: string,
|
||
externalId: string,
|
||
companyId: string | null,
|
||
) => {
|
||
const companyIdPredicate =
|
||
companyId == null
|
||
? isNull(pluginEntities.companyId)
|
||
: eq(pluginEntities.companyId, companyId);
|
||
return db
|
||
.select()
|
||
.from(pluginEntities)
|
||
.where(
|
||
and(
|
||
companyIdPredicate,
|
||
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.
|
||
// Match the per-tenant uniqueness of `plugin_entities_external_idx`
|
||
// (companyId, pluginId, entityType, externalId) with NULLS NOT DISTINCT
|
||
// semantics: two companies (and instance-scope NULLs across each other)
|
||
// may share the same (pluginId, entityType, externalId) tuple, so the
|
||
// lookup MUST scope by companyId — `isNull` for instance-scope, `eq`
|
||
// otherwise — to avoid returning and overwriting another tenant's row.
|
||
const companyIdPredicate =
|
||
input.companyId == null
|
||
? isNull(pluginEntities.companyId)
|
||
: eq(pluginEntities.companyId, input.companyId);
|
||
const existing = await db
|
||
.select()
|
||
.from(pluginEntities)
|
||
.where(
|
||
and(
|
||
companyIdPredicate,
|
||
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.
|
||
*
|
||
* Pass the owning `companyId` so `plugin_job_runs.company_id` is populated
|
||
* and the row participates in the `ON DELETE CASCADE` from `companies`.
|
||
* `null` is the explicit instance-scope marker (cron jobs without a tenant);
|
||
* those rows survive company deletes but are still attributable.
|
||
*
|
||
* @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').
|
||
* @param companyId - Tenant scope; `null` for instance-scope runs.
|
||
* @returns The newly created `PluginJobRunRecord` in 'pending' status.
|
||
*/
|
||
createJobRun: async (
|
||
pluginId: string,
|
||
jobId: string,
|
||
trigger: PluginJobRunTrigger,
|
||
companyId: string | null,
|
||
) => {
|
||
return db
|
||
.insert(pluginJobRuns)
|
||
.values({
|
||
pluginId,
|
||
jobId,
|
||
companyId,
|
||
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.
|
||
*
|
||
* Pass the owning `companyId` so `plugin_webhook_deliveries.company_id` is
|
||
* populated and the row participates in the `ON DELETE CASCADE` from
|
||
* `companies`. `null` is the explicit instance-scope marker (public
|
||
* webhooks without a tenant); those rows survive company deletes but are
|
||
* still attributable.
|
||
*
|
||
* @param pluginId - The UUID of the receiving plugin.
|
||
* @param webhookKey - The endpoint key defined in the manifest.
|
||
* @param companyId - Tenant scope; `null` for instance-scope deliveries.
|
||
* @param input - The payload, headers, and optional external ID.
|
||
* @returns The newly created `PluginWebhookDeliveryRecord` in 'pending' status.
|
||
*/
|
||
createWebhookDelivery: async (
|
||
pluginId: string,
|
||
webhookKey: string,
|
||
companyId: string | null,
|
||
input: {
|
||
externalId?: string;
|
||
payload: Record<string, unknown>;
|
||
headers?: Record<string, string>;
|
||
},
|
||
) => {
|
||
return db
|
||
.insert(pluginWebhookDeliveries)
|
||
.values({
|
||
pluginId,
|
||
webhookKey,
|
||
companyId,
|
||
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);
|
||
},
|
||
};
|
||
}
|