Merge public-gh/master into review/pr-162

This commit is contained in:
Dotta
2026-03-16 08:47:05 -05:00
536 changed files with 103660 additions and 9971 deletions
+290 -65
View File
@@ -1,6 +1,6 @@
import { existsSync, mkdirSync, readdirSync, statSync, unlinkSync } from "node:fs";
import { writeFile } from "node:fs/promises";
import { resolve } from "node:path";
import { readFile, writeFile } from "node:fs/promises";
import { basename, resolve } from "node:path";
import postgres from "postgres";
export type RunDatabaseBackupOptions = {
@@ -9,6 +9,9 @@ export type RunDatabaseBackupOptions = {
retentionDays: number;
filenamePrefix?: string;
connectTimeoutSeconds?: number;
includeMigrationJournal?: boolean;
excludeTables?: string[];
nullifyColumns?: Record<string, string[]>;
};
export type RunDatabaseBackupResult = {
@@ -17,6 +20,50 @@ export type RunDatabaseBackupResult = {
prunedCount: number;
};
export type RunDatabaseRestoreOptions = {
connectionString: string;
backupFile: string;
connectTimeoutSeconds?: number;
};
type SequenceDefinition = {
sequence_schema: string;
sequence_name: string;
data_type: string;
start_value: string;
minimum_value: string;
maximum_value: string;
increment: string;
cycle_option: "YES" | "NO";
owner_schema: string | null;
owner_table: string | null;
owner_column: string | null;
};
type TableDefinition = {
schema_name: string;
tablename: string;
};
const DRIZZLE_SCHEMA = "drizzle";
const DRIZZLE_MIGRATIONS_TABLE = "__drizzle_migrations";
const STATEMENT_BREAKPOINT = "-- paperclip statement breakpoint 69f6f3f1-42fd-46a6-bf17-d1d85f8f3900";
function sanitizeRestoreErrorMessage(error: unknown): string {
if (error && typeof error === "object") {
const record = error as Record<string, unknown>;
const firstLine = typeof record.message === "string"
? record.message.split(/\r?\n/, 1)[0]?.trim()
: "";
const detail = typeof record.detail === "string" ? record.detail.trim() : "";
const severity = typeof record.severity === "string" ? record.severity.trim() : "";
const message = firstLine || detail || (error instanceof Error ? error.message : String(error));
return severity ? `${severity}: ${message}` : message;
}
return error instanceof Error ? error.message : String(error);
}
function timestamp(date: Date = new Date()): string {
const pad = (n: number) => String(n).padStart(2, "0");
return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}-${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`;
@@ -47,10 +94,60 @@ function formatBackupSize(sizeBytes: number): string {
return `${(sizeBytes / (1024 * 1024)).toFixed(1)}M`;
}
function formatSqlLiteral(value: string): string {
const sanitized = value.replace(/\u0000/g, "");
let tag = "$paperclip$";
while (sanitized.includes(tag)) {
tag = `$paperclip_${Math.random().toString(36).slice(2, 8)}$`;
}
return `${tag}${sanitized}${tag}`;
}
function normalizeTableNameSet(values: string[] | undefined): Set<string> {
return new Set(
(values ?? [])
.map((value) => value.trim())
.filter((value) => value.length > 0),
);
}
function normalizeNullifyColumnMap(values: Record<string, string[]> | undefined): Map<string, Set<string>> {
const out = new Map<string, Set<string>>();
if (!values) return out;
for (const [tableName, columns] of Object.entries(values)) {
const normalizedTable = tableName.trim();
if (normalizedTable.length === 0) continue;
const normalizedColumns = new Set(
columns
.map((column) => column.trim())
.filter((column) => column.length > 0),
);
if (normalizedColumns.size > 0) {
out.set(normalizedTable, normalizedColumns);
}
}
return out;
}
function quoteIdentifier(value: string): string {
return `"${value.replaceAll("\"", "\"\"")}"`;
}
function quoteQualifiedName(schemaName: string, objectName: string): string {
return `${quoteIdentifier(schemaName)}.${quoteIdentifier(objectName)}`;
}
function tableKey(schemaName: string, tableName: string): string {
return `${schemaName}.${tableName}`;
}
export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise<RunDatabaseBackupResult> {
const filenamePrefix = opts.filenamePrefix ?? "paperclip";
const retentionDays = Math.max(1, Math.trunc(opts.retentionDays));
const connectTimeout = Math.max(1, Math.trunc(opts.connectTimeoutSeconds ?? 5));
const includeMigrationJournal = opts.includeMigrationJournal === true;
const excludedTableNames = normalizeTableNameSet(opts.excludeTables);
const nullifiedColumnsByTable = normalizeNullifyColumnMap(opts.nullifyColumns);
const sql = postgres(opts.connectionString, { max: 1, connect_timeout: connectTimeout });
try {
@@ -58,13 +155,35 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise
const lines: string[] = [];
const emit = (line: string) => lines.push(line);
const emitStatement = (statement: string) => {
emit(statement);
emit(STATEMENT_BREAKPOINT);
};
const emitStatementBoundary = () => {
emit(STATEMENT_BREAKPOINT);
};
emit("-- Paperclip database backup");
emit(`-- Created: ${new Date().toISOString()}`);
emit("");
emit("BEGIN;");
emitStatement("BEGIN;");
emitStatement("SET LOCAL session_replication_role = replica;");
emitStatement("SET LOCAL client_min_messages = warning;");
emit("");
const allTables = await sql<TableDefinition[]>`
SELECT table_schema AS schema_name, table_name AS tablename
FROM information_schema.tables
WHERE table_type = 'BASE TABLE'
AND (
table_schema = 'public'
OR (${includeMigrationJournal}::boolean AND table_schema = ${DRIZZLE_SCHEMA} AND table_name = ${DRIZZLE_MIGRATIONS_TABLE})
)
ORDER BY table_schema, table_name
`;
const tables = allTables;
const includedTableNames = new Set(tables.map(({ schema_name, tablename }) => tableKey(schema_name, tablename)));
// Get all enums
const enums = await sql<{ typname: string; labels: string[] }[]>`
SELECT t.typname, array_agg(e.enumlabel ORDER BY e.enumsortorder) AS labels
@@ -78,23 +197,65 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise
for (const e of enums) {
const labels = e.labels.map((l) => `'${l.replace(/'/g, "''")}'`).join(", ");
emit(`CREATE TYPE "public"."${e.typname}" AS ENUM (${labels});`);
emitStatement(`CREATE TYPE "public"."${e.typname}" AS ENUM (${labels});`);
}
if (enums.length > 0) emit("");
// Get tables in dependency order (referenced tables first)
const tables = await sql<{ tablename: string }[]>`
SELECT c.relname AS tablename
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = 'public'
AND c.relkind = 'r'
AND c.relname != '__drizzle_migrations'
ORDER BY c.relname
const allSequences = await sql<SequenceDefinition[]>`
SELECT
s.sequence_schema,
s.sequence_name,
s.data_type,
s.start_value,
s.minimum_value,
s.maximum_value,
s.increment,
s.cycle_option,
tblns.nspname AS owner_schema,
tbl.relname AS owner_table,
attr.attname AS owner_column
FROM information_schema.sequences s
JOIN pg_class seq ON seq.relname = s.sequence_name
JOIN pg_namespace n ON n.oid = seq.relnamespace AND n.nspname = s.sequence_schema
LEFT JOIN pg_depend dep ON dep.objid = seq.oid AND dep.deptype = 'a'
LEFT JOIN pg_class tbl ON tbl.oid = dep.refobjid
LEFT JOIN pg_namespace tblns ON tblns.oid = tbl.relnamespace
LEFT JOIN pg_attribute attr ON attr.attrelid = tbl.oid AND attr.attnum = dep.refobjsubid
WHERE s.sequence_schema = 'public'
OR (${includeMigrationJournal}::boolean AND s.sequence_schema = ${DRIZZLE_SCHEMA})
ORDER BY s.sequence_schema, s.sequence_name
`;
const sequences = allSequences.filter(
(seq) => !seq.owner_table || includedTableNames.has(tableKey(seq.owner_schema ?? "public", seq.owner_table)),
);
const schemas = new Set<string>();
for (const table of tables) schemas.add(table.schema_name);
for (const seq of sequences) schemas.add(seq.sequence_schema);
const extraSchemas = [...schemas].filter((schemaName) => schemaName !== "public");
if (extraSchemas.length > 0) {
emit("-- Schemas");
for (const schemaName of extraSchemas) {
emitStatement(`CREATE SCHEMA IF NOT EXISTS ${quoteIdentifier(schemaName)};`);
}
emit("");
}
if (sequences.length > 0) {
emit("-- Sequences");
for (const seq of sequences) {
const qualifiedSequenceName = quoteQualifiedName(seq.sequence_schema, seq.sequence_name);
emitStatement(`DROP SEQUENCE IF EXISTS ${qualifiedSequenceName} CASCADE;`);
emitStatement(
`CREATE SEQUENCE ${qualifiedSequenceName} AS ${seq.data_type} INCREMENT BY ${seq.increment} MINVALUE ${seq.minimum_value} MAXVALUE ${seq.maximum_value} START WITH ${seq.start_value}${seq.cycle_option === "YES" ? " CYCLE" : " NO CYCLE"};`,
);
}
emit("");
}
// Get full CREATE TABLE DDL via column info
for (const { tablename } of tables) {
for (const { schema_name, tablename } of tables) {
const qualifiedTableName = quoteQualifiedName(schema_name, tablename);
const columns = await sql<{
column_name: string;
data_type: string;
@@ -108,12 +269,12 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise
SELECT column_name, data_type, udt_name, is_nullable, column_default,
character_maximum_length, numeric_precision, numeric_scale
FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = ${tablename}
WHERE table_schema = ${schema_name} AND table_name = ${tablename}
ORDER BY ordinal_position
`;
emit(`-- Table: ${tablename}`);
emit(`DROP TABLE IF EXISTS "${tablename}" CASCADE;`);
emit(`-- Table: ${schema_name}.${tablename}`);
emitStatement(`DROP TABLE IF EXISTS ${qualifiedTableName} CASCADE;`);
const colDefs: string[] = [];
for (const col of columns) {
@@ -149,7 +310,7 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise
JOIN pg_class t ON t.oid = c.conrelid
JOIN pg_namespace n ON n.oid = t.relnamespace
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = ANY(c.conkey)
WHERE n.nspname = 'public' AND t.relname = ${tablename} AND c.contype = 'p'
WHERE n.nspname = ${schema_name} AND t.relname = ${tablename} AND c.contype = 'p'
GROUP BY c.conname
`;
for (const p of pk) {
@@ -157,17 +318,31 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise
colDefs.push(` CONSTRAINT "${p.constraint_name}" PRIMARY KEY (${cols})`);
}
emit(`CREATE TABLE "${tablename}" (`);
emit(`CREATE TABLE ${qualifiedTableName} (`);
emit(colDefs.join(",\n"));
emit(");");
emitStatementBoundary();
emit("");
}
const ownedSequences = sequences.filter((seq) => seq.owner_table && seq.owner_column);
if (ownedSequences.length > 0) {
emit("-- Sequence ownership");
for (const seq of ownedSequences) {
emitStatement(
`ALTER SEQUENCE ${quoteQualifiedName(seq.sequence_schema, seq.sequence_name)} OWNED BY ${quoteQualifiedName(seq.owner_schema ?? "public", seq.owner_table!)}.${quoteIdentifier(seq.owner_column!)};`,
);
}
emit("");
}
// Foreign keys (after all tables created)
const fks = await sql<{
const allForeignKeys = await sql<{
constraint_name: string;
source_schema: string;
source_table: string;
source_columns: string[];
target_schema: string;
target_table: string;
target_columns: string[];
update_rule: string;
@@ -175,137 +350,157 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise
}[]>`
SELECT
c.conname AS constraint_name,
srcn.nspname AS source_schema,
src.relname AS source_table,
array_agg(sa.attname ORDER BY array_position(c.conkey, sa.attnum)) AS source_columns,
tgtn.nspname AS target_schema,
tgt.relname AS target_table,
array_agg(ta.attname ORDER BY array_position(c.confkey, ta.attnum)) AS target_columns,
CASE c.confupdtype WHEN 'a' THEN 'NO ACTION' WHEN 'r' THEN 'RESTRICT' WHEN 'c' THEN 'CASCADE' WHEN 'n' THEN 'SET NULL' WHEN 'd' THEN 'SET DEFAULT' END AS update_rule,
CASE c.confdeltype WHEN 'a' THEN 'NO ACTION' WHEN 'r' THEN 'RESTRICT' WHEN 'c' THEN 'CASCADE' WHEN 'n' THEN 'SET NULL' WHEN 'd' THEN 'SET DEFAULT' END AS delete_rule
FROM pg_constraint c
JOIN pg_class src ON src.oid = c.conrelid
JOIN pg_namespace srcn ON srcn.oid = src.relnamespace
JOIN pg_class tgt ON tgt.oid = c.confrelid
JOIN pg_namespace n ON n.oid = src.relnamespace
JOIN pg_namespace tgtn ON tgtn.oid = tgt.relnamespace
JOIN pg_attribute sa ON sa.attrelid = src.oid AND sa.attnum = ANY(c.conkey)
JOIN pg_attribute ta ON ta.attrelid = tgt.oid AND ta.attnum = ANY(c.confkey)
WHERE c.contype = 'f' AND n.nspname = 'public'
GROUP BY c.conname, src.relname, tgt.relname, c.confupdtype, c.confdeltype
ORDER BY src.relname, c.conname
WHERE c.contype = 'f' AND (
srcn.nspname = 'public'
OR (${includeMigrationJournal}::boolean AND srcn.nspname = ${DRIZZLE_SCHEMA})
)
GROUP BY c.conname, srcn.nspname, src.relname, tgtn.nspname, tgt.relname, c.confupdtype, c.confdeltype
ORDER BY srcn.nspname, src.relname, c.conname
`;
const fks = allForeignKeys.filter(
(fk) => includedTableNames.has(tableKey(fk.source_schema, fk.source_table))
&& includedTableNames.has(tableKey(fk.target_schema, fk.target_table)),
);
if (fks.length > 0) {
emit("-- Foreign keys");
for (const fk of fks) {
const srcCols = fk.source_columns.map((c) => `"${c}"`).join(", ");
const tgtCols = fk.target_columns.map((c) => `"${c}"`).join(", ");
emit(
`ALTER TABLE "${fk.source_table}" ADD CONSTRAINT "${fk.constraint_name}" FOREIGN KEY (${srcCols}) REFERENCES "${fk.target_table}" (${tgtCols}) ON UPDATE ${fk.update_rule} ON DELETE ${fk.delete_rule};`,
emitStatement(
`ALTER TABLE ${quoteQualifiedName(fk.source_schema, fk.source_table)} ADD CONSTRAINT "${fk.constraint_name}" FOREIGN KEY (${srcCols}) REFERENCES ${quoteQualifiedName(fk.target_schema, fk.target_table)} (${tgtCols}) ON UPDATE ${fk.update_rule} ON DELETE ${fk.delete_rule};`,
);
}
emit("");
}
// Unique constraints
const uniques = await sql<{
const allUniqueConstraints = await sql<{
constraint_name: string;
schema_name: string;
tablename: string;
column_names: string[];
}[]>`
SELECT c.conname AS constraint_name,
n.nspname AS schema_name,
t.relname AS tablename,
array_agg(a.attname ORDER BY array_position(c.conkey, a.attnum)) AS column_names
FROM pg_constraint c
JOIN pg_class t ON t.oid = c.conrelid
JOIN pg_namespace n ON n.oid = t.relnamespace
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = ANY(c.conkey)
WHERE n.nspname = 'public' AND c.contype = 'u'
GROUP BY c.conname, t.relname
ORDER BY t.relname, c.conname
WHERE c.contype = 'u' AND (
n.nspname = 'public'
OR (${includeMigrationJournal}::boolean AND n.nspname = ${DRIZZLE_SCHEMA})
)
GROUP BY c.conname, n.nspname, t.relname
ORDER BY n.nspname, t.relname, c.conname
`;
const uniques = allUniqueConstraints.filter((entry) => includedTableNames.has(tableKey(entry.schema_name, entry.tablename)));
if (uniques.length > 0) {
emit("-- Unique constraints");
for (const u of uniques) {
const cols = u.column_names.map((c) => `"${c}"`).join(", ");
emit(`ALTER TABLE "${u.tablename}" ADD CONSTRAINT "${u.constraint_name}" UNIQUE (${cols});`);
emitStatement(`ALTER TABLE ${quoteQualifiedName(u.schema_name, u.tablename)} ADD CONSTRAINT "${u.constraint_name}" UNIQUE (${cols});`);
}
emit("");
}
// Indexes (non-primary, non-unique-constraint)
const indexes = await sql<{ indexdef: string }[]>`
SELECT indexdef
const allIndexes = await sql<{ schema_name: string; tablename: string; indexdef: string }[]>`
SELECT schemaname AS schema_name, tablename, indexdef
FROM pg_indexes
WHERE schemaname = 'public'
AND indexname NOT IN (
SELECT conname FROM pg_constraint
WHERE connamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'public')
WHERE (
schemaname = 'public'
OR (${includeMigrationJournal}::boolean AND schemaname = ${DRIZZLE_SCHEMA})
)
ORDER BY tablename, indexname
AND indexname NOT IN (
SELECT conname FROM pg_constraint c
JOIN pg_namespace n ON n.oid = c.connamespace
WHERE n.nspname = pg_indexes.schemaname
)
ORDER BY schemaname, tablename, indexname
`;
const indexes = allIndexes.filter((entry) => includedTableNames.has(tableKey(entry.schema_name, entry.tablename)));
if (indexes.length > 0) {
emit("-- Indexes");
for (const idx of indexes) {
emit(`${idx.indexdef};`);
emitStatement(`${idx.indexdef};`);
}
emit("");
}
// Dump data for each table
for (const { tablename } of tables) {
const count = await sql<{ n: number }[]>`
SELECT count(*)::int AS n FROM ${sql(tablename)}
`;
if ((count[0]?.n ?? 0) === 0) continue;
for (const { schema_name, tablename } of tables) {
const qualifiedTableName = quoteQualifiedName(schema_name, tablename);
const count = await sql.unsafe<{ n: number }[]>(`SELECT count(*)::int AS n FROM ${qualifiedTableName}`);
if (excludedTableNames.has(tablename) || (count[0]?.n ?? 0) === 0) continue;
// Get column info for this table
const cols = await sql<{ column_name: string; data_type: string }[]>`
SELECT column_name, data_type
FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = ${tablename}
WHERE table_schema = ${schema_name} AND table_name = ${tablename}
ORDER BY ordinal_position
`;
const colNames = cols.map((c) => `"${c.column_name}"`).join(", ");
emit(`-- Data for: ${tablename} (${count[0]!.n} rows)`);
emit(`-- Data for: ${schema_name}.${tablename} (${count[0]!.n} rows)`);
const rows = await sql`SELECT * FROM ${sql(tablename)}`.values();
const rows = await sql.unsafe(`SELECT * FROM ${qualifiedTableName}`).values();
const nullifiedColumns = nullifiedColumnsByTable.get(tablename) ?? new Set<string>();
for (const row of rows) {
const values = row.map((val: unknown) => {
const values = row.map((rawValue: unknown, index) => {
const columnName = cols[index]?.column_name;
const val = columnName && nullifiedColumns.has(columnName) ? null : rawValue;
if (val === null || val === undefined) return "NULL";
if (typeof val === "boolean") return val ? "true" : "false";
if (typeof val === "number") return String(val);
if (val instanceof Date) return `'${val.toISOString()}'`;
if (typeof val === "object") return `'${JSON.stringify(val).replace(/'/g, "''")}'`;
return `'${String(val).replace(/'/g, "''")}'`;
if (val instanceof Date) return formatSqlLiteral(val.toISOString());
if (typeof val === "object") return formatSqlLiteral(JSON.stringify(val));
return formatSqlLiteral(String(val));
});
emit(`INSERT INTO "${tablename}" (${colNames}) VALUES (${values.join(", ")});`);
emitStatement(`INSERT INTO ${qualifiedTableName} (${colNames}) VALUES (${values.join(", ")});`);
}
emit("");
}
// Sequence values
const sequences = await sql<{ sequence_name: string }[]>`
SELECT sequence_name
FROM information_schema.sequences
WHERE sequence_schema = 'public'
ORDER BY sequence_name
`;
if (sequences.length > 0) {
emit("-- Sequence values");
for (const seq of sequences) {
const val = await sql<{ last_value: string }[]>`
SELECT last_value::text FROM ${sql(seq.sequence_name)}
`;
if (val[0]) {
emit(`SELECT setval('"${seq.sequence_name}"', ${val[0].last_value});`);
const qualifiedSequenceName = quoteQualifiedName(seq.sequence_schema, seq.sequence_name);
const val = await sql.unsafe<{ last_value: string; is_called: boolean }[]>(
`SELECT last_value::text, is_called FROM ${qualifiedSequenceName}`,
);
const skipSequenceValue =
seq.owner_table !== null
&& excludedTableNames.has(seq.owner_table);
if (val[0] && !skipSequenceValue) {
emitStatement(`SELECT setval('${qualifiedSequenceName.replaceAll("'", "''")}', ${val[0].last_value}, ${val[0].is_called ? "true" : "false"});`);
}
}
emit("");
}
emit("COMMIT;");
emitStatement("COMMIT;");
emit("");
// Write the backup file
@@ -326,6 +521,36 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise
}
}
export async function runDatabaseRestore(opts: RunDatabaseRestoreOptions): Promise<void> {
const connectTimeout = Math.max(1, Math.trunc(opts.connectTimeoutSeconds ?? 5));
const sql = postgres(opts.connectionString, { max: 1, connect_timeout: connectTimeout });
try {
await sql`SELECT 1`;
const contents = await readFile(opts.backupFile, "utf8");
const statements = contents
.split(STATEMENT_BREAKPOINT)
.map((statement) => statement.trim())
.filter((statement) => statement.length > 0);
for (const statement of statements) {
await sql.unsafe(statement).execute();
}
} catch (error) {
const statementPreview = typeof error === "object" && error !== null && typeof (error as Record<string, unknown>).query === "string"
? String((error as Record<string, unknown>).query)
.split(/\r?\n/)
.map((line) => line.trim())
.find((line) => line.length > 0 && !line.startsWith("--"))
: null;
throw new Error(
`Failed to restore ${basename(opts.backupFile)}: ${sanitizeRestoreErrorMessage(error)}${statementPreview ? ` [statement: ${statementPreview.slice(0, 120)}]` : ""}`,
);
} finally {
await sql.end();
}
}
export function formatDatabaseBackupResult(result: RunDatabaseBackupResult): string {
const size = formatBackupSize(result.sizeBytes);
const pruned = result.prunedCount > 0 ? `; pruned ${result.prunedCount} old backup(s)` : "";
+15 -11
View File
@@ -2,12 +2,17 @@ import { createHash } from "node:crypto";
import { drizzle as drizzlePg } from "drizzle-orm/postgres-js";
import { migrate as migratePg } from "drizzle-orm/postgres-js/migrator";
import { readFile, readdir } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import postgres from "postgres";
import * as schema from "./schema/index.js";
const MIGRATIONS_FOLDER = new URL("./migrations", import.meta.url).pathname;
const MIGRATIONS_FOLDER = fileURLToPath(new URL("./migrations", import.meta.url));
const DRIZZLE_MIGRATIONS_TABLE = "__drizzle_migrations";
const MIGRATIONS_JOURNAL_JSON = new URL("./migrations/meta/_journal.json", import.meta.url).pathname;
const MIGRATIONS_JOURNAL_JSON = fileURLToPath(new URL("./migrations/meta/_journal.json", import.meta.url));
function createUtilitySql(url: string) {
return postgres(url, { max: 1, onnotice: () => {} });
}
function isSafeIdentifier(value: string): boolean {
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(value);
@@ -222,7 +227,7 @@ async function applyPendingMigrationsManually(
journalEntries.map((entry) => [entry.fileName, normalizeFolderMillis(entry.folderMillis)]),
);
const sql = postgres(url, { max: 1 });
const sql = createUtilitySql(url);
try {
const { migrationTableSchema, columnNames } = await ensureMigrationJournalTable(sql);
const qualifiedTable = `${quoteIdentifier(migrationTableSchema)}.${quoteIdentifier(DRIZZLE_MIGRATIONS_TABLE)}`;
@@ -471,7 +476,7 @@ export async function reconcilePendingMigrationHistory(
return { repairedMigrations: [], remainingMigrations: [] };
}
const sql = postgres(url, { max: 1 });
const sql = createUtilitySql(url);
const repairedMigrations: string[] = [];
try {
@@ -578,7 +583,7 @@ async function discoverMigrationTableSchema(sql: ReturnType<typeof postgres>): P
}
export async function inspectMigrations(url: string): Promise<MigrationState> {
const sql = postgres(url, { max: 1 });
const sql = createUtilitySql(url);
try {
const availableMigrations = await listMigrationFiles();
@@ -641,7 +646,7 @@ export async function applyPendingMigrations(url: string): Promise<void> {
const initialState = await inspectMigrations(url);
if (initialState.status === "upToDate") return;
const sql = postgres(url, { max: 1 });
const sql = createUtilitySql(url);
try {
const db = drizzlePg(sql);
@@ -679,7 +684,7 @@ export type MigrationBootstrapResult =
| { migrated: false; reason: "not-empty-no-migration-journal"; tableCount: number };
export async function migratePostgresIfEmpty(url: string): Promise<MigrationBootstrapResult> {
const sql = postgres(url, { max: 1 });
const sql = createUtilitySql(url);
try {
const migrationTableSchema = await discoverMigrationTableSchema(sql);
@@ -702,8 +707,7 @@ export async function migratePostgresIfEmpty(url: string): Promise<MigrationBoot
}
const db = drizzlePg(sql);
const migrationsFolder = new URL("./migrations", import.meta.url).pathname;
await migratePg(db, { migrationsFolder });
await migratePg(db, { migrationsFolder: MIGRATIONS_FOLDER });
return { migrated: true, reason: "migrated-empty-db", tableCount: 0 };
} finally {
@@ -719,14 +723,14 @@ export async function ensurePostgresDatabase(
throw new Error(`Unsafe database name: ${databaseName}`);
}
const sql = postgres(url, { max: 1 });
const sql = createUtilitySql(url);
try {
const existing = await sql<{ one: number }[]>`
select 1 as one from pg_database where datname = ${databaseName} limit 1
`;
if (existing.length > 0) return "exists";
await sql.unsafe(`create database "${databaseName}"`);
await sql.unsafe(`create database "${databaseName}" encoding 'UTF8' lc_collate 'C' lc_ctype 'C' template template0`);
return "created";
} finally {
await sql.end();
+2
View File
@@ -12,8 +12,10 @@ export {
} from "./client.js";
export {
runDatabaseBackup,
runDatabaseRestore,
formatDatabaseBackupResult,
type RunDatabaseBackupOptions,
type RunDatabaseBackupResult,
type RunDatabaseRestoreOptions,
} from "./backup-lib.js";
export * from "./schema/index.js";
+22 -14
View File
@@ -1,21 +1,29 @@
import { applyPendingMigrations, inspectMigrations } from "./client.js";
import { resolveMigrationConnection } from "./migration-runtime.js";
const url = process.env.DATABASE_URL;
async function main(): Promise<void> {
const resolved = await resolveMigrationConnection();
if (!url) {
throw new Error("DATABASE_URL is required for db:migrate");
}
console.log(`Migrating database via ${resolved.source}`);
const before = await inspectMigrations(url);
if (before.status === "upToDate") {
console.log("No pending migrations");
} else {
console.log(`Applying ${before.pendingMigrations.length} pending migration(s)...`);
await applyPendingMigrations(url);
try {
const before = await inspectMigrations(resolved.connectionString);
if (before.status === "upToDate") {
console.log("No pending migrations");
return;
}
const after = await inspectMigrations(url);
if (after.status !== "upToDate") {
throw new Error(`Migrations incomplete: ${after.pendingMigrations.join(", ")}`);
console.log(`Applying ${before.pendingMigrations.length} pending migration(s)...`);
await applyPendingMigrations(resolved.connectionString);
const after = await inspectMigrations(resolved.connectionString);
if (after.status !== "upToDate") {
throw new Error(`Migrations incomplete: ${after.pendingMigrations.join(", ")}`);
}
console.log("Migrations complete");
} finally {
await resolved.stop();
}
console.log("Migrations complete");
}
await main();
+136
View File
@@ -0,0 +1,136 @@
import { existsSync, readFileSync, rmSync } from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { ensurePostgresDatabase } from "./client.js";
import { resolveDatabaseTarget } from "./runtime-config.js";
type EmbeddedPostgresInstance = {
initialise(): Promise<void>;
start(): Promise<void>;
stop(): Promise<void>;
};
type EmbeddedPostgresCtor = new (opts: {
databaseDir: string;
user: string;
password: string;
port: number;
persistent: boolean;
initdbFlags?: string[];
onLog?: (message: unknown) => void;
onError?: (message: unknown) => void;
}) => EmbeddedPostgresInstance;
export type MigrationConnection = {
connectionString: string;
source: string;
stop: () => Promise<void>;
};
function readRunningPostmasterPid(postmasterPidFile: string): number | null {
if (!existsSync(postmasterPidFile)) return null;
try {
const pid = Number(readFileSync(postmasterPidFile, "utf8").split("\n")[0]?.trim());
if (!Number.isInteger(pid) || pid <= 0) return null;
process.kill(pid, 0);
return pid;
} catch {
return null;
}
}
function readPidFilePort(postmasterPidFile: string): number | null {
if (!existsSync(postmasterPidFile)) return null;
try {
const lines = readFileSync(postmasterPidFile, "utf8").split("\n");
const port = Number(lines[3]?.trim());
return Number.isInteger(port) && port > 0 ? port : null;
} catch {
return null;
}
}
async function loadEmbeddedPostgresCtor(): Promise<EmbeddedPostgresCtor> {
const require = createRequire(import.meta.url);
const resolveCandidates = [
path.resolve(fileURLToPath(new URL("../..", import.meta.url))),
path.resolve(fileURLToPath(new URL("../../server", import.meta.url))),
path.resolve(fileURLToPath(new URL("../../cli", import.meta.url))),
process.cwd(),
];
try {
const resolvedModulePath = require.resolve("embedded-postgres", { paths: resolveCandidates });
const mod = await import(pathToFileURL(resolvedModulePath).href);
return mod.default as EmbeddedPostgresCtor;
} catch {
throw new Error(
"Embedded PostgreSQL support requires dependency `embedded-postgres`. Reinstall dependencies and try again.",
);
}
}
async function ensureEmbeddedPostgresConnection(
dataDir: string,
preferredPort: number,
): Promise<MigrationConnection> {
const EmbeddedPostgres = await loadEmbeddedPostgresCtor();
const postmasterPidFile = path.resolve(dataDir, "postmaster.pid");
const runningPid = readRunningPostmasterPid(postmasterPidFile);
const runningPort = readPidFilePort(postmasterPidFile);
if (runningPid) {
const port = runningPort ?? preferredPort;
const adminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/postgres`;
await ensurePostgresDatabase(adminConnectionString, "paperclip");
return {
connectionString: `postgres://paperclip:paperclip@127.0.0.1:${port}/paperclip`,
source: `embedded-postgres@${port}`,
stop: async () => {},
};
}
const instance = new EmbeddedPostgres({
databaseDir: dataDir,
user: "paperclip",
password: "paperclip",
port: preferredPort,
persistent: true,
initdbFlags: ["--encoding=UTF8", "--locale=C"],
onLog: () => {},
onError: () => {},
});
if (!existsSync(path.resolve(dataDir, "PG_VERSION"))) {
await instance.initialise();
}
if (existsSync(postmasterPidFile)) {
rmSync(postmasterPidFile, { force: true });
}
await instance.start();
const adminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${preferredPort}/postgres`;
await ensurePostgresDatabase(adminConnectionString, "paperclip");
return {
connectionString: `postgres://paperclip:paperclip@127.0.0.1:${preferredPort}/paperclip`,
source: `embedded-postgres@${preferredPort}`,
stop: async () => {
await instance.stop();
},
};
}
export async function resolveMigrationConnection(): Promise<MigrationConnection> {
const target = resolveDatabaseTarget();
if (target.mode === "postgres") {
return {
connectionString: target.connectionString,
source: target.source,
stop: async () => {},
};
}
return ensureEmbeddedPostgresConnection(target.dataDir, target.port);
}
+45
View File
@@ -0,0 +1,45 @@
import { inspectMigrations } from "./client.js";
import { resolveMigrationConnection } from "./migration-runtime.js";
const jsonMode = process.argv.includes("--json");
async function main(): Promise<void> {
const connection = await resolveMigrationConnection();
try {
const state = await inspectMigrations(connection.connectionString);
const payload =
state.status === "upToDate"
? {
source: connection.source,
status: "upToDate" as const,
tableCount: state.tableCount,
pendingMigrations: [] as string[],
}
: {
source: connection.source,
status: "needsMigrations" as const,
tableCount: state.tableCount,
pendingMigrations: state.pendingMigrations,
reason: state.reason,
};
if (jsonMode) {
console.log(JSON.stringify(payload));
return;
}
if (payload.status === "upToDate") {
console.log(`Database is up to date via ${payload.source}`);
return;
}
console.log(
`Pending migrations via ${payload.source}: ${payload.pendingMigrations.join(", ")}`,
);
} finally {
await connection.stop();
}
}
await main();
@@ -1 +0,0 @@
ALTER TABLE "companies" ADD COLUMN IF NOT EXISTS "logo_url" text;
@@ -0,0 +1,39 @@
CREATE TABLE "workspace_runtime_services" (
"id" uuid PRIMARY KEY NOT NULL,
"company_id" uuid NOT NULL,
"project_id" uuid,
"project_workspace_id" uuid,
"issue_id" uuid,
"scope_type" text NOT NULL,
"scope_id" text,
"service_name" text NOT NULL,
"status" text NOT NULL,
"lifecycle" text NOT NULL,
"reuse_key" text,
"command" text,
"cwd" text,
"port" integer,
"url" text,
"provider" text NOT NULL,
"provider_ref" text,
"owner_agent_id" uuid,
"started_by_run_id" uuid,
"last_used_at" timestamp with time zone DEFAULT now() NOT NULL,
"started_at" timestamp with time zone DEFAULT now() NOT NULL,
"stopped_at" timestamp with time zone,
"stop_policy" jsonb,
"health_status" text DEFAULT 'unknown' NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "workspace_runtime_services" ADD CONSTRAINT "workspace_runtime_services_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "workspace_runtime_services" ADD CONSTRAINT "workspace_runtime_services_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "workspace_runtime_services" ADD CONSTRAINT "workspace_runtime_services_project_workspace_id_project_workspaces_id_fk" FOREIGN KEY ("project_workspace_id") REFERENCES "public"."project_workspaces"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "workspace_runtime_services" ADD CONSTRAINT "workspace_runtime_services_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "workspace_runtime_services" ADD CONSTRAINT "workspace_runtime_services_owner_agent_id_agents_id_fk" FOREIGN KEY ("owner_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "workspace_runtime_services" ADD CONSTRAINT "workspace_runtime_services_started_by_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("started_by_run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "workspace_runtime_services_company_workspace_status_idx" ON "workspace_runtime_services" USING btree ("company_id","project_workspace_id","status");--> statement-breakpoint
CREATE INDEX "workspace_runtime_services_company_project_status_idx" ON "workspace_runtime_services" USING btree ("company_id","project_id","status");--> statement-breakpoint
CREATE INDEX "workspace_runtime_services_run_idx" ON "workspace_runtime_services" USING btree ("started_by_run_id");--> statement-breakpoint
CREATE INDEX "workspace_runtime_services_company_updated_idx" ON "workspace_runtime_services" USING btree ("company_id","updated_at");
@@ -0,0 +1,2 @@
ALTER TABLE "issues" ADD COLUMN "execution_workspace_settings" jsonb;--> statement-breakpoint
ALTER TABLE "projects" ADD COLUMN "execution_workspace_policy" jsonb;
@@ -0,0 +1,54 @@
CREATE TABLE "document_revisions" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"document_id" uuid NOT NULL,
"revision_number" integer NOT NULL,
"body" text NOT NULL,
"change_summary" text,
"created_by_agent_id" uuid,
"created_by_user_id" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "documents" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"title" text,
"format" text DEFAULT 'markdown' NOT NULL,
"latest_body" text NOT NULL,
"latest_revision_id" uuid,
"latest_revision_number" integer DEFAULT 1 NOT NULL,
"created_by_agent_id" uuid,
"created_by_user_id" text,
"updated_by_agent_id" uuid,
"updated_by_user_id" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "issue_documents" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"issue_id" uuid NOT NULL,
"document_id" uuid NOT NULL,
"key" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "document_revisions" ADD CONSTRAINT "document_revisions_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "document_revisions" ADD CONSTRAINT "document_revisions_document_id_documents_id_fk" FOREIGN KEY ("document_id") REFERENCES "public"."documents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "document_revisions" ADD CONSTRAINT "document_revisions_created_by_agent_id_agents_id_fk" FOREIGN KEY ("created_by_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "documents" ADD CONSTRAINT "documents_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "documents" ADD CONSTRAINT "documents_created_by_agent_id_agents_id_fk" FOREIGN KEY ("created_by_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "documents" ADD CONSTRAINT "documents_updated_by_agent_id_agents_id_fk" FOREIGN KEY ("updated_by_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "issue_documents" ADD CONSTRAINT "issue_documents_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "issue_documents" ADD CONSTRAINT "issue_documents_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "issue_documents" ADD CONSTRAINT "issue_documents_document_id_documents_id_fk" FOREIGN KEY ("document_id") REFERENCES "public"."documents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "document_revisions_document_revision_uq" ON "document_revisions" USING btree ("document_id","revision_number");--> statement-breakpoint
CREATE INDEX "document_revisions_company_document_created_idx" ON "document_revisions" USING btree ("company_id","document_id","created_at");--> statement-breakpoint
CREATE INDEX "documents_company_updated_idx" ON "documents" USING btree ("company_id","updated_at");--> statement-breakpoint
CREATE INDEX "documents_company_created_idx" ON "documents" USING btree ("company_id","created_at");--> statement-breakpoint
CREATE UNIQUE INDEX "issue_documents_company_issue_key_uq" ON "issue_documents" USING btree ("company_id","issue_id","key");--> statement-breakpoint
CREATE UNIQUE INDEX "issue_documents_document_uq" ON "issue_documents" USING btree ("document_id");--> statement-breakpoint
CREATE INDEX "issue_documents_company_issue_updated_idx" ON "issue_documents" USING btree ("company_id","issue_id","updated_at");
@@ -0,0 +1,177 @@
-- Rollback:
-- DROP INDEX IF EXISTS "plugin_logs_level_idx";
-- DROP INDEX IF EXISTS "plugin_logs_plugin_time_idx";
-- DROP INDEX IF EXISTS "plugin_company_settings_company_plugin_uq";
-- DROP INDEX IF EXISTS "plugin_company_settings_plugin_idx";
-- DROP INDEX IF EXISTS "plugin_company_settings_company_idx";
-- DROP INDEX IF EXISTS "plugin_webhook_deliveries_key_idx";
-- DROP INDEX IF EXISTS "plugin_webhook_deliveries_status_idx";
-- DROP INDEX IF EXISTS "plugin_webhook_deliveries_plugin_idx";
-- DROP INDEX IF EXISTS "plugin_job_runs_status_idx";
-- DROP INDEX IF EXISTS "plugin_job_runs_plugin_idx";
-- DROP INDEX IF EXISTS "plugin_job_runs_job_idx";
-- DROP INDEX IF EXISTS "plugin_jobs_unique_idx";
-- DROP INDEX IF EXISTS "plugin_jobs_next_run_idx";
-- DROP INDEX IF EXISTS "plugin_jobs_plugin_idx";
-- DROP INDEX IF EXISTS "plugin_entities_external_idx";
-- DROP INDEX IF EXISTS "plugin_entities_scope_idx";
-- DROP INDEX IF EXISTS "plugin_entities_type_idx";
-- DROP INDEX IF EXISTS "plugin_entities_plugin_idx";
-- DROP INDEX IF EXISTS "plugin_state_plugin_scope_idx";
-- DROP INDEX IF EXISTS "plugin_config_plugin_id_idx";
-- DROP INDEX IF EXISTS "plugins_status_idx";
-- DROP INDEX IF EXISTS "plugins_plugin_key_idx";
-- DROP TABLE IF EXISTS "plugin_logs";
-- DROP TABLE IF EXISTS "plugin_company_settings";
-- DROP TABLE IF EXISTS "plugin_webhook_deliveries";
-- DROP TABLE IF EXISTS "plugin_job_runs";
-- DROP TABLE IF EXISTS "plugin_jobs";
-- DROP TABLE IF EXISTS "plugin_entities";
-- DROP TABLE IF EXISTS "plugin_state";
-- DROP TABLE IF EXISTS "plugin_config";
-- DROP TABLE IF EXISTS "plugins";
CREATE TABLE "plugins" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"plugin_key" text NOT NULL,
"package_name" text NOT NULL,
"package_path" text,
"version" text NOT NULL,
"api_version" integer DEFAULT 1 NOT NULL,
"categories" jsonb DEFAULT '[]'::jsonb NOT NULL,
"manifest_json" jsonb NOT NULL,
"status" text DEFAULT 'installed' NOT NULL,
"install_order" integer,
"last_error" text,
"installed_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "plugin_config" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"plugin_id" uuid NOT NULL,
"config_json" jsonb DEFAULT '{}'::jsonb NOT NULL,
"last_error" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "plugin_state" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"plugin_id" uuid NOT NULL,
"scope_kind" text NOT NULL,
"scope_id" text,
"namespace" text DEFAULT 'default' NOT NULL,
"state_key" text NOT NULL,
"value_json" jsonb NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "plugin_state_unique_entry_idx" UNIQUE NULLS NOT DISTINCT("plugin_id","scope_kind","scope_id","namespace","state_key")
);
--> statement-breakpoint
CREATE TABLE "plugin_entities" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"plugin_id" uuid NOT NULL,
"entity_type" text NOT NULL,
"scope_kind" text NOT NULL,
"scope_id" text,
"external_id" text,
"title" text,
"status" text,
"data" jsonb DEFAULT '{}'::jsonb NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "plugin_jobs" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"plugin_id" uuid NOT NULL,
"job_key" text NOT NULL,
"schedule" text NOT NULL,
"status" text DEFAULT 'active' NOT NULL,
"last_run_at" timestamp with time zone,
"next_run_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "plugin_job_runs" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"job_id" uuid NOT NULL,
"plugin_id" uuid NOT NULL,
"trigger" text NOT NULL,
"status" text DEFAULT 'pending' NOT NULL,
"duration_ms" integer,
"error" text,
"logs" jsonb DEFAULT '[]'::jsonb NOT NULL,
"started_at" timestamp with time zone,
"finished_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "plugin_webhook_deliveries" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"plugin_id" uuid NOT NULL,
"webhook_key" text NOT NULL,
"external_id" text,
"status" text DEFAULT 'pending' NOT NULL,
"duration_ms" integer,
"error" text,
"payload" jsonb NOT NULL,
"headers" jsonb DEFAULT '{}'::jsonb NOT NULL,
"started_at" timestamp with time zone,
"finished_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "plugin_company_settings" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"plugin_id" uuid NOT NULL,
"settings_json" jsonb DEFAULT '{}'::jsonb NOT NULL,
"last_error" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
"enabled" boolean DEFAULT true NOT NULL
);
--> statement-breakpoint
CREATE TABLE "plugin_logs" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"plugin_id" uuid NOT NULL,
"level" text NOT NULL DEFAULT 'info',
"message" text NOT NULL,
"meta" jsonb,
"created_at" timestamp with time zone NOT NULL DEFAULT now()
);
--> statement-breakpoint
ALTER TABLE "plugin_config" ADD CONSTRAINT "plugin_config_plugin_id_plugins_id_fk" FOREIGN KEY ("plugin_id") REFERENCES "public"."plugins"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "plugin_state" ADD CONSTRAINT "plugin_state_plugin_id_plugins_id_fk" FOREIGN KEY ("plugin_id") REFERENCES "public"."plugins"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "plugin_entities" ADD CONSTRAINT "plugin_entities_plugin_id_plugins_id_fk" FOREIGN KEY ("plugin_id") REFERENCES "public"."plugins"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "plugin_jobs" ADD CONSTRAINT "plugin_jobs_plugin_id_plugins_id_fk" FOREIGN KEY ("plugin_id") REFERENCES "public"."plugins"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "plugin_job_runs" ADD CONSTRAINT "plugin_job_runs_job_id_plugin_jobs_id_fk" FOREIGN KEY ("job_id") REFERENCES "public"."plugin_jobs"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "plugin_job_runs" ADD CONSTRAINT "plugin_job_runs_plugin_id_plugins_id_fk" FOREIGN KEY ("plugin_id") REFERENCES "public"."plugins"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "plugin_webhook_deliveries" ADD CONSTRAINT "plugin_webhook_deliveries_plugin_id_plugins_id_fk" FOREIGN KEY ("plugin_id") REFERENCES "public"."plugins"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "plugin_company_settings" ADD CONSTRAINT "plugin_company_settings_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "plugin_company_settings" ADD CONSTRAINT "plugin_company_settings_plugin_id_plugins_id_fk" FOREIGN KEY ("plugin_id") REFERENCES "public"."plugins"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "plugin_logs" ADD CONSTRAINT "plugin_logs_plugin_id_plugins_id_fk" FOREIGN KEY ("plugin_id") REFERENCES "public"."plugins"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "plugins_plugin_key_idx" ON "plugins" USING btree ("plugin_key");--> statement-breakpoint
CREATE INDEX "plugins_status_idx" ON "plugins" USING btree ("status");--> statement-breakpoint
CREATE UNIQUE INDEX "plugin_config_plugin_id_idx" ON "plugin_config" USING btree ("plugin_id");--> statement-breakpoint
CREATE INDEX "plugin_state_plugin_scope_idx" ON "plugin_state" USING btree ("plugin_id","scope_kind");--> statement-breakpoint
CREATE INDEX "plugin_entities_plugin_idx" ON "plugin_entities" USING btree ("plugin_id");--> statement-breakpoint
CREATE INDEX "plugin_entities_type_idx" ON "plugin_entities" USING btree ("entity_type");--> statement-breakpoint
CREATE INDEX "plugin_entities_scope_idx" ON "plugin_entities" USING btree ("scope_kind","scope_id");--> statement-breakpoint
CREATE UNIQUE INDEX "plugin_entities_external_idx" ON "plugin_entities" USING btree ("plugin_id","entity_type","external_id");--> statement-breakpoint
CREATE INDEX "plugin_jobs_plugin_idx" ON "plugin_jobs" USING btree ("plugin_id");--> statement-breakpoint
CREATE INDEX "plugin_jobs_next_run_idx" ON "plugin_jobs" USING btree ("next_run_at");--> statement-breakpoint
CREATE UNIQUE INDEX "plugin_jobs_unique_idx" ON "plugin_jobs" USING btree ("plugin_id","job_key");--> statement-breakpoint
CREATE INDEX "plugin_job_runs_job_idx" ON "plugin_job_runs" USING btree ("job_id");--> statement-breakpoint
CREATE INDEX "plugin_job_runs_plugin_idx" ON "plugin_job_runs" USING btree ("plugin_id");--> statement-breakpoint
CREATE INDEX "plugin_job_runs_status_idx" ON "plugin_job_runs" USING btree ("status");--> statement-breakpoint
CREATE INDEX "plugin_webhook_deliveries_plugin_idx" ON "plugin_webhook_deliveries" USING btree ("plugin_id");--> statement-breakpoint
CREATE INDEX "plugin_webhook_deliveries_status_idx" ON "plugin_webhook_deliveries" USING btree ("status");--> statement-breakpoint
CREATE INDEX "plugin_webhook_deliveries_key_idx" ON "plugin_webhook_deliveries" USING btree ("webhook_key");--> statement-breakpoint
CREATE INDEX "plugin_company_settings_company_idx" ON "plugin_company_settings" USING btree ("company_id");--> statement-breakpoint
CREATE INDEX "plugin_company_settings_plugin_idx" ON "plugin_company_settings" USING btree ("plugin_id");--> statement-breakpoint
CREATE UNIQUE INDEX "plugin_company_settings_company_plugin_uq" ON "plugin_company_settings" USING btree ("company_id","plugin_id");--> statement-breakpoint
CREATE INDEX "plugin_logs_plugin_time_idx" ON "plugin_logs" USING btree ("plugin_id","created_at");--> statement-breakpoint
CREATE INDEX "plugin_logs_level_idx" ON "plugin_logs" USING btree ("level");
@@ -0,0 +1 @@
ALTER TABLE "companies" ADD COLUMN "logo_url" text;
@@ -1,6 +1,6 @@
{
"id": "ada32d9a-1735-4149-91c7-83ae8f5dd482",
"prevId": "bd8d9b8d-3012-4c58-bcfd-b3215c164f82",
"id": "8186209d-f7ec-4048-bd4f-c96530f45304",
"prevId": "5f8dd541-9e28-4a42-890b-fc4a301604ac",
"version": "7",
"dialect": "postgresql",
"tables": {
@@ -2140,12 +2140,6 @@
"primaryKey": false,
"notNull": false
},
"logo_url": {
"name": "logo_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
@@ -4638,6 +4632,12 @@
"primaryKey": false,
"notNull": false
},
"execution_workspace_settings": {
"name": "execution_workspace_settings",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"started_at": {
"name": "started_at",
"type": "timestamp with time zone",
@@ -5755,6 +5755,12 @@
"primaryKey": false,
"notNull": false
},
"execution_workspace_policy": {
"name": "execution_workspace_policy",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"archived_at": {
"name": "archived_at",
"type": "timestamp with time zone",
@@ -5839,6 +5845,350 @@
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.workspace_runtime_services": {
"name": "workspace_runtime_services",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true
},
"company_id": {
"name": "company_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"project_id": {
"name": "project_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"project_workspace_id": {
"name": "project_workspace_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"issue_id": {
"name": "issue_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"scope_type": {
"name": "scope_type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"scope_id": {
"name": "scope_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"service_name": {
"name": "service_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true
},
"lifecycle": {
"name": "lifecycle",
"type": "text",
"primaryKey": false,
"notNull": true
},
"reuse_key": {
"name": "reuse_key",
"type": "text",
"primaryKey": false,
"notNull": false
},
"command": {
"name": "command",
"type": "text",
"primaryKey": false,
"notNull": false
},
"cwd": {
"name": "cwd",
"type": "text",
"primaryKey": false,
"notNull": false
},
"port": {
"name": "port",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"url": {
"name": "url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"provider": {
"name": "provider",
"type": "text",
"primaryKey": false,
"notNull": true
},
"provider_ref": {
"name": "provider_ref",
"type": "text",
"primaryKey": false,
"notNull": false
},
"owner_agent_id": {
"name": "owner_agent_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"started_by_run_id": {
"name": "started_by_run_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"last_used_at": {
"name": "last_used_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"started_at": {
"name": "started_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"stopped_at": {
"name": "stopped_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"stop_policy": {
"name": "stop_policy",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"health_status": {
"name": "health_status",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'unknown'"
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"workspace_runtime_services_company_workspace_status_idx": {
"name": "workspace_runtime_services_company_workspace_status_idx",
"columns": [
{
"expression": "company_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "project_workspace_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "status",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"workspace_runtime_services_company_project_status_idx": {
"name": "workspace_runtime_services_company_project_status_idx",
"columns": [
{
"expression": "company_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "project_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "status",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"workspace_runtime_services_run_idx": {
"name": "workspace_runtime_services_run_idx",
"columns": [
{
"expression": "started_by_run_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"workspace_runtime_services_company_updated_idx": {
"name": "workspace_runtime_services_company_updated_idx",
"columns": [
{
"expression": "company_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "updated_at",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"workspace_runtime_services_company_id_companies_id_fk": {
"name": "workspace_runtime_services_company_id_companies_id_fk",
"tableFrom": "workspace_runtime_services",
"tableTo": "companies",
"columnsFrom": [
"company_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"workspace_runtime_services_project_id_projects_id_fk": {
"name": "workspace_runtime_services_project_id_projects_id_fk",
"tableFrom": "workspace_runtime_services",
"tableTo": "projects",
"columnsFrom": [
"project_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
},
"workspace_runtime_services_project_workspace_id_project_workspaces_id_fk": {
"name": "workspace_runtime_services_project_workspace_id_project_workspaces_id_fk",
"tableFrom": "workspace_runtime_services",
"tableTo": "project_workspaces",
"columnsFrom": [
"project_workspace_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
},
"workspace_runtime_services_issue_id_issues_id_fk": {
"name": "workspace_runtime_services_issue_id_issues_id_fk",
"tableFrom": "workspace_runtime_services",
"tableTo": "issues",
"columnsFrom": [
"issue_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
},
"workspace_runtime_services_owner_agent_id_agents_id_fk": {
"name": "workspace_runtime_services_owner_agent_id_agents_id_fk",
"tableFrom": "workspace_runtime_services",
"tableTo": "agents",
"columnsFrom": [
"owner_agent_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
},
"workspace_runtime_services_started_by_run_id_heartbeat_runs_id_fk": {
"name": "workspace_runtime_services_started_by_run_id_heartbeat_runs_id_fk",
"tableFrom": "workspace_runtime_services",
"tableTo": "heartbeat_runs",
"columnsFrom": [
"started_by_run_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+30 -2
View File
@@ -187,8 +187,36 @@
{
"idx": 26,
"version": "7",
"when": 1772823634634,
"tag": "0026_high_anita_blake",
"when": 1773089625430,
"tag": "0026_lying_pete_wisdom",
"breakpoints": true
},
{
"idx": 27,
"version": "7",
"when": 1773150731736,
"tag": "0027_tranquil_tenebrous",
"breakpoints": true
},
{
"idx": 28,
"version": "7",
"when": 1773432085646,
"tag": "0028_harsh_goliath",
"breakpoints": true
},
{
"idx": 29,
"version": "7",
"when": 1773417600000,
"tag": "0029_plugin_tables",
"breakpoints": true
},
{
"idx": 30,
"version": "7",
"when": 1773668505562,
"tag": "0030_hot_slipstream",
"breakpoints": true
}
]
+108
View File
@@ -0,0 +1,108 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { resolveDatabaseTarget } from "./runtime-config.js";
const ORIGINAL_CWD = process.cwd();
const ORIGINAL_ENV = { ...process.env };
function writeJson(filePath: string, value: unknown) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, JSON.stringify(value, null, 2));
}
function writeText(filePath: string, value: string) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, value);
}
afterEach(() => {
process.chdir(ORIGINAL_CWD);
for (const key of Object.keys(process.env)) {
if (!(key in ORIGINAL_ENV)) delete process.env[key];
}
for (const [key, value] of Object.entries(ORIGINAL_ENV)) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
});
describe("resolveDatabaseTarget", () => {
it("uses DATABASE_URL from process env first", () => {
process.env.DATABASE_URL = "postgres://env-user:env-pass@db.example.com:5432/paperclip";
const target = resolveDatabaseTarget();
expect(target).toMatchObject({
mode: "postgres",
connectionString: "postgres://env-user:env-pass@db.example.com:5432/paperclip",
source: "DATABASE_URL",
});
});
it("uses DATABASE_URL from repo-local .paperclip/.env", () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-db-runtime-"));
const projectDir = path.join(tempDir, "repo");
fs.mkdirSync(projectDir, { recursive: true });
process.chdir(projectDir);
delete process.env.PAPERCLIP_CONFIG;
writeJson(path.join(projectDir, ".paperclip", "config.json"), {
database: { mode: "embedded-postgres", embeddedPostgresPort: 54329 },
});
writeText(
path.join(projectDir, ".paperclip", ".env"),
'DATABASE_URL="postgres://file-user:file-pass@db.example.com:6543/paperclip"\n',
);
const target = resolveDatabaseTarget();
expect(target).toMatchObject({
mode: "postgres",
connectionString: "postgres://file-user:file-pass@db.example.com:6543/paperclip",
source: "paperclip-env",
});
});
it("uses config postgres connection string when configured", () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-db-runtime-"));
const configPath = path.join(tempDir, "instance", "config.json");
process.env.PAPERCLIP_CONFIG = configPath;
writeJson(configPath, {
database: {
mode: "postgres",
connectionString: "postgres://cfg-user:cfg-pass@db.example.com:5432/paperclip",
},
});
const target = resolveDatabaseTarget();
expect(target).toMatchObject({
mode: "postgres",
connectionString: "postgres://cfg-user:cfg-pass@db.example.com:5432/paperclip",
source: "config.database.connectionString",
});
});
it("falls back to embedded postgres settings from config", () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-db-runtime-"));
const configPath = path.join(tempDir, "instance", "config.json");
process.env.PAPERCLIP_CONFIG = configPath;
writeJson(configPath, {
database: {
mode: "embedded-postgres",
embeddedPostgresDataDir: "~/paperclip-test-db",
embeddedPostgresPort: 55444,
},
});
const target = resolveDatabaseTarget();
expect(target).toMatchObject({
mode: "embedded-postgres",
dataDir: path.resolve(os.homedir(), "paperclip-test-db"),
port: 55444,
source: "embedded-postgres@55444",
});
});
});
+267
View File
@@ -0,0 +1,267 @@
import { existsSync, readFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
const DEFAULT_INSTANCE_ID = "default";
const CONFIG_BASENAME = "config.json";
const ENV_BASENAME = ".env";
const INSTANCE_ID_RE = /^[a-zA-Z0-9_-]+$/;
type PartialConfig = {
database?: {
mode?: "embedded-postgres" | "postgres";
connectionString?: string;
embeddedPostgresDataDir?: string;
embeddedPostgresPort?: number;
pgliteDataDir?: string;
pglitePort?: number;
};
};
export type ResolvedDatabaseTarget =
| {
mode: "postgres";
connectionString: string;
source: "DATABASE_URL" | "paperclip-env" | "config.database.connectionString";
configPath: string;
envPath: string;
}
| {
mode: "embedded-postgres";
dataDir: string;
port: number;
source: `embedded-postgres@${number}`;
configPath: string;
envPath: string;
};
function expandHomePrefix(value: string): string {
if (value === "~") return os.homedir();
if (value.startsWith("~/")) return path.resolve(os.homedir(), value.slice(2));
return value;
}
function resolvePaperclipHomeDir(): string {
const envHome = process.env.PAPERCLIP_HOME?.trim();
if (envHome) return path.resolve(expandHomePrefix(envHome));
return path.resolve(os.homedir(), ".paperclip");
}
function resolvePaperclipInstanceId(): string {
const raw = process.env.PAPERCLIP_INSTANCE_ID?.trim() || DEFAULT_INSTANCE_ID;
if (!INSTANCE_ID_RE.test(raw)) {
throw new Error(`Invalid PAPERCLIP_INSTANCE_ID '${raw}'.`);
}
return raw;
}
function resolveDefaultConfigPath(): string {
return path.resolve(
resolvePaperclipHomeDir(),
"instances",
resolvePaperclipInstanceId(),
CONFIG_BASENAME,
);
}
function resolveDefaultEmbeddedPostgresDir(): string {
return path.resolve(resolvePaperclipHomeDir(), "instances", resolvePaperclipInstanceId(), "db");
}
function resolveHomeAwarePath(value: string): string {
return path.resolve(expandHomePrefix(value));
}
function findConfigFileFromAncestors(startDir: string): string | null {
let currentDir = path.resolve(startDir);
while (true) {
const candidate = path.resolve(currentDir, ".paperclip", CONFIG_BASENAME);
if (existsSync(candidate)) return candidate;
const nextDir = path.resolve(currentDir, "..");
if (nextDir === currentDir) return null;
currentDir = nextDir;
}
}
function resolvePaperclipConfigPath(): string {
if (process.env.PAPERCLIP_CONFIG?.trim()) {
return path.resolve(process.env.PAPERCLIP_CONFIG.trim());
}
return findConfigFileFromAncestors(process.cwd()) ?? resolveDefaultConfigPath();
}
function resolvePaperclipEnvPath(configPath: string): string {
return path.resolve(path.dirname(configPath), ENV_BASENAME);
}
function parseEnvFile(contents: string): Record<string, string> {
const entries: Record<string, string> = {};
for (const rawLine of contents.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line || line.startsWith("#")) continue;
const match = rawLine.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\s*$/);
if (!match) continue;
const [, key, rawValue] = match;
const value = rawValue.trim();
if (!value) {
entries[key] = "";
continue;
}
if (
(value.startsWith("\"") && value.endsWith("\"")) ||
(value.startsWith("'") && value.endsWith("'"))
) {
entries[key] = value.slice(1, -1);
continue;
}
entries[key] = value.replace(/\s+#.*$/, "").trim();
}
return entries;
}
function readEnvEntries(envPath: string): Record<string, string> {
if (!existsSync(envPath)) return {};
return parseEnvFile(readFileSync(envPath, "utf8"));
}
function migrateLegacyConfig(raw: unknown): PartialConfig | null {
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
const config = { ...(raw as Record<string, unknown>) };
const databaseRaw = config.database;
if (typeof databaseRaw !== "object" || databaseRaw === null || Array.isArray(databaseRaw)) {
return config;
}
const database = { ...(databaseRaw as Record<string, unknown>) };
if (database.mode === "pglite") {
database.mode = "embedded-postgres";
if (
typeof database.embeddedPostgresDataDir !== "string" &&
typeof database.pgliteDataDir === "string"
) {
database.embeddedPostgresDataDir = database.pgliteDataDir;
}
if (
typeof database.embeddedPostgresPort !== "number" &&
typeof database.pglitePort === "number" &&
Number.isFinite(database.pglitePort)
) {
database.embeddedPostgresPort = database.pglitePort;
}
}
config.database = database;
return config as PartialConfig;
}
function asPositiveInt(value: unknown): number | null {
if (typeof value !== "number" || !Number.isFinite(value)) return null;
const rounded = Math.trunc(value);
return rounded > 0 ? rounded : null;
}
function readConfig(configPath: string): PartialConfig | null {
if (!existsSync(configPath)) return null;
let parsed: unknown;
try {
parsed = JSON.parse(readFileSync(configPath, "utf8"));
} catch (err) {
throw new Error(
`Failed to parse config at ${configPath}: ${err instanceof Error ? err.message : String(err)}`,
);
}
const migrated = migrateLegacyConfig(parsed);
if (migrated === null || typeof migrated !== "object" || Array.isArray(migrated)) {
throw new Error(`Invalid config at ${configPath}: expected a JSON object`);
}
const database =
typeof migrated.database === "object" &&
migrated.database !== null &&
!Array.isArray(migrated.database)
? migrated.database
: undefined;
return {
database: database
? {
mode: database.mode === "postgres" ? "postgres" : "embedded-postgres",
connectionString:
typeof database.connectionString === "string" ? database.connectionString : undefined,
embeddedPostgresDataDir:
typeof database.embeddedPostgresDataDir === "string"
? database.embeddedPostgresDataDir
: undefined,
embeddedPostgresPort: asPositiveInt(database.embeddedPostgresPort) ?? undefined,
pgliteDataDir: typeof database.pgliteDataDir === "string" ? database.pgliteDataDir : undefined,
pglitePort: asPositiveInt(database.pglitePort) ?? undefined,
}
: undefined,
};
}
export function resolveDatabaseTarget(): ResolvedDatabaseTarget {
const configPath = resolvePaperclipConfigPath();
const envPath = resolvePaperclipEnvPath(configPath);
const envEntries = readEnvEntries(envPath);
const envUrl = process.env.DATABASE_URL?.trim();
if (envUrl) {
return {
mode: "postgres",
connectionString: envUrl,
source: "DATABASE_URL",
configPath,
envPath,
};
}
const fileEnvUrl = envEntries.DATABASE_URL?.trim();
if (fileEnvUrl) {
return {
mode: "postgres",
connectionString: fileEnvUrl,
source: "paperclip-env",
configPath,
envPath,
};
}
const config = readConfig(configPath);
const connectionString = config?.database?.connectionString?.trim();
if (config?.database?.mode === "postgres" && connectionString) {
return {
mode: "postgres",
connectionString,
source: "config.database.connectionString",
configPath,
envPath,
};
}
const port = config?.database?.embeddedPostgresPort ?? 54329;
const dataDir = resolveHomeAwarePath(
config?.database?.embeddedPostgresDataDir ?? resolveDefaultEmbeddedPostgresDir(),
);
return {
mode: "embedded-postgres",
dataDir,
port,
source: `embedded-postgres@${port}`,
configPath,
envPath,
};
}
@@ -0,0 +1,30 @@
import { pgTable, uuid, text, integer, timestamp, index, uniqueIndex } from "drizzle-orm/pg-core";
import { companies } from "./companies.js";
import { agents } from "./agents.js";
import { documents } from "./documents.js";
export const documentRevisions = pgTable(
"document_revisions",
{
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id),
documentId: uuid("document_id").notNull().references(() => documents.id, { onDelete: "cascade" }),
revisionNumber: integer("revision_number").notNull(),
body: text("body").notNull(),
changeSummary: text("change_summary"),
createdByAgentId: uuid("created_by_agent_id").references(() => agents.id, { onDelete: "set null" }),
createdByUserId: text("created_by_user_id"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
documentRevisionUq: uniqueIndex("document_revisions_document_revision_uq").on(
table.documentId,
table.revisionNumber,
),
companyDocumentCreatedIdx: index("document_revisions_company_document_created_idx").on(
table.companyId,
table.documentId,
table.createdAt,
),
}),
);
+26
View File
@@ -0,0 +1,26 @@
import { pgTable, uuid, text, integer, timestamp, index } from "drizzle-orm/pg-core";
import { companies } from "./companies.js";
import { agents } from "./agents.js";
export const documents = pgTable(
"documents",
{
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id),
title: text("title"),
format: text("format").notNull().default("markdown"),
latestBody: text("latest_body").notNull(),
latestRevisionId: uuid("latest_revision_id"),
latestRevisionNumber: integer("latest_revision_number").notNull().default(1),
createdByAgentId: uuid("created_by_agent_id").references(() => agents.id, { onDelete: "set null" }),
createdByUserId: text("created_by_user_id"),
updatedByAgentId: uuid("updated_by_agent_id").references(() => agents.id, { onDelete: "set null" }),
updatedByUserId: text("updated_by_user_id"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
companyUpdatedIdx: index("documents_company_updated_idx").on(table.companyId, table.updatedAt),
companyCreatedIdx: index("documents_company_created_idx").on(table.companyId, table.createdAt),
}),
);
+12
View File
@@ -13,6 +13,7 @@ export { agentTaskSessions } from "./agent_task_sessions.js";
export { agentWakeupRequests } from "./agent_wakeup_requests.js";
export { projects } from "./projects.js";
export { projectWorkspaces } from "./project_workspaces.js";
export { workspaceRuntimeServices } from "./workspace_runtime_services.js";
export { projectGoals } from "./project_goals.js";
export { goals } from "./goals.js";
export { issues } from "./issues.js";
@@ -23,6 +24,9 @@ export { issueComments } from "./issue_comments.js";
export { issueReadStates } from "./issue_read_states.js";
export { assets } from "./assets.js";
export { issueAttachments } from "./issue_attachments.js";
export { documents } from "./documents.js";
export { documentRevisions } from "./document_revisions.js";
export { issueDocuments } from "./issue_documents.js";
export { heartbeatRuns } from "./heartbeat_runs.js";
export { heartbeatRunEvents } from "./heartbeat_run_events.js";
export { costEvents } from "./cost_events.js";
@@ -31,3 +35,11 @@ export { approvalComments } from "./approval_comments.js";
export { activityLog } from "./activity_log.js";
export { companySecrets } from "./company_secrets.js";
export { companySecretVersions } from "./company_secret_versions.js";
export { plugins } from "./plugins.js";
export { pluginConfig } from "./plugin_config.js";
export { pluginCompanySettings } from "./plugin_company_settings.js";
export { pluginState } from "./plugin_state.js";
export { pluginEntities } from "./plugin_entities.js";
export { pluginJobs, pluginJobRuns } from "./plugin_jobs.js";
export { pluginWebhookDeliveries } from "./plugin_webhooks.js";
export { pluginLogs } from "./plugin_logs.js";
+30
View File
@@ -0,0 +1,30 @@
import { pgTable, uuid, text, timestamp, index, uniqueIndex } from "drizzle-orm/pg-core";
import { companies } from "./companies.js";
import { issues } from "./issues.js";
import { documents } from "./documents.js";
export const issueDocuments = pgTable(
"issue_documents",
{
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id),
issueId: uuid("issue_id").notNull().references(() => issues.id, { onDelete: "cascade" }),
documentId: uuid("document_id").notNull().references(() => documents.id, { onDelete: "cascade" }),
key: text("key").notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
companyIssueKeyUq: uniqueIndex("issue_documents_company_issue_key_uq").on(
table.companyId,
table.issueId,
table.key,
),
documentUq: uniqueIndex("issue_documents_document_uq").on(table.documentId),
companyIssueUpdatedIdx: index("issue_documents_company_issue_updated_idx").on(
table.companyId,
table.issueId,
table.updatedAt,
),
}),
);
+1
View File
@@ -40,6 +40,7 @@ export const issues = pgTable(
requestDepth: integer("request_depth").notNull().default(0),
billingCode: text("billing_code"),
assigneeAdapterOverrides: jsonb("assignee_adapter_overrides").$type<Record<string, unknown>>(),
executionWorkspaceSettings: jsonb("execution_workspace_settings").$type<Record<string, unknown>>(),
startedAt: timestamp("started_at", { withTimezone: true }),
completedAt: timestamp("completed_at", { withTimezone: true }),
cancelledAt: timestamp("cancelled_at", { withTimezone: true }),
@@ -0,0 +1,41 @@
import { pgTable, uuid, text, timestamp, jsonb, index, uniqueIndex, boolean } from "drizzle-orm/pg-core";
import { companies } from "./companies.js";
import { plugins } from "./plugins.js";
/**
* `plugin_company_settings` table — stores operator-managed plugin settings
* scoped to a specific company.
*
* This is distinct from `plugin_config`, which stores instance-wide plugin
* configuration. Each company can have at most one settings row per plugin.
*
* Rows represent explicit overrides from the default company behavior:
* - no row => plugin is enabled for the company by default
* - row with `enabled = false` => plugin is disabled for that company
* - row with `enabled = true` => plugin remains enabled and stores company settings
*/
export const pluginCompanySettings = pgTable(
"plugin_company_settings",
{
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id")
.notNull()
.references(() => companies.id, { onDelete: "cascade" }),
pluginId: uuid("plugin_id")
.notNull()
.references(() => plugins.id, { onDelete: "cascade" }),
enabled: boolean("enabled").notNull().default(true),
settingsJson: jsonb("settings_json").$type<Record<string, unknown>>().notNull().default({}),
lastError: text("last_error"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
companyIdx: index("plugin_company_settings_company_idx").on(table.companyId),
pluginIdx: index("plugin_company_settings_plugin_idx").on(table.pluginId),
companyPluginUq: uniqueIndex("plugin_company_settings_company_plugin_uq").on(
table.companyId,
table.pluginId,
),
}),
);
+30
View File
@@ -0,0 +1,30 @@
import { pgTable, uuid, text, timestamp, jsonb, uniqueIndex } from "drizzle-orm/pg-core";
import { plugins } from "./plugins.js";
/**
* `plugin_config` table — stores operator-provided instance configuration
* for each plugin (one row per plugin, enforced by a unique index on
* `plugin_id`).
*
* The `config_json` column holds the values that the operator enters in the
* plugin settings UI. These values are validated at runtime against the
* plugin's `instanceConfigSchema` from the manifest.
*
* @see PLUGIN_SPEC.md §21.3
*/
export const pluginConfig = pgTable(
"plugin_config",
{
id: uuid("id").primaryKey().defaultRandom(),
pluginId: uuid("plugin_id")
.notNull()
.references(() => plugins.id, { onDelete: "cascade" }),
configJson: jsonb("config_json").$type<Record<string, unknown>>().notNull().default({}),
lastError: text("last_error"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
pluginIdIdx: uniqueIndex("plugin_config_plugin_id_idx").on(table.pluginId),
}),
);
+54
View File
@@ -0,0 +1,54 @@
import {
pgTable,
uuid,
text,
timestamp,
jsonb,
index,
uniqueIndex,
} from "drizzle-orm/pg-core";
import { plugins } from "./plugins.js";
import type { PluginStateScopeKind } from "@paperclipai/shared";
/**
* `plugin_entities` table — persistent high-level mapping between Paperclip
* objects and external plugin-defined entities.
*
* This table is used by plugins (e.g. `linear`, `github`) to store pointers
* to their respective external IDs for projects, issues, etc. and to store
* their custom data.
*
* Unlike `plugin_state`, which is for raw K-V persistence, `plugin_entities`
* is intended for structured object mappings that the host can understand
* and query for cross-plugin UI integration.
*
* @see PLUGIN_SPEC.md §21.3
*/
export const pluginEntities = pgTable(
"plugin_entities",
{
id: uuid("id").primaryKey().defaultRandom(),
pluginId: uuid("plugin_id")
.notNull()
.references(() => plugins.id, { onDelete: "cascade" }),
entityType: text("entity_type").notNull(),
scopeKind: text("scope_kind").$type<PluginStateScopeKind>().notNull(),
scopeId: text("scope_id"), // NULL for global scope (text to match plugin_state.scope_id)
externalId: text("external_id"), // ID in the external system
title: text("title"),
status: text("status"),
data: jsonb("data").$type<Record<string, unknown>>().notNull().default({}),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
pluginIdx: index("plugin_entities_plugin_idx").on(table.pluginId),
typeIdx: index("plugin_entities_type_idx").on(table.entityType),
scopeIdx: index("plugin_entities_scope_idx").on(table.scopeKind, table.scopeId),
externalIdx: uniqueIndex("plugin_entities_external_idx").on(
table.pluginId,
table.entityType,
table.externalId,
),
}),
);
+102
View File
@@ -0,0 +1,102 @@
import {
pgTable,
uuid,
text,
integer,
timestamp,
jsonb,
index,
uniqueIndex,
} from "drizzle-orm/pg-core";
import { plugins } from "./plugins.js";
import type { PluginJobStatus, PluginJobRunStatus, PluginJobRunTrigger } from "@paperclipai/shared";
/**
* `plugin_jobs` table — registration and runtime configuration for
* scheduled jobs declared by plugins in their manifests.
*
* Each row represents one scheduled job entry for a plugin. The
* `job_key` matches the key declared in the manifest's `jobs` array.
* The `schedule` column stores the cron expression or interval string
* used by the job scheduler to decide when to fire the job.
*
* Status values:
* - `active` — job is enabled and will run on schedule
* - `paused` — job is temporarily disabled by the operator
* - `error` — job has been disabled due to repeated failures
*
* @see PLUGIN_SPEC.md §21.3 — `plugin_jobs`
*/
export const pluginJobs = pgTable(
"plugin_jobs",
{
id: uuid("id").primaryKey().defaultRandom(),
/** FK to the owning plugin. Cascades on delete. */
pluginId: uuid("plugin_id")
.notNull()
.references(() => plugins.id, { onDelete: "cascade" }),
/** Identifier matching the key in the plugin manifest's `jobs` array. */
jobKey: text("job_key").notNull(),
/** Cron expression (e.g. `"0 * * * *"`) or interval string. */
schedule: text("schedule").notNull(),
/** Current scheduling state. */
status: text("status").$type<PluginJobStatus>().notNull().default("active"),
/** Timestamp of the most recent successful execution. */
lastRunAt: timestamp("last_run_at", { withTimezone: true }),
/** Pre-computed timestamp of the next scheduled execution. */
nextRunAt: timestamp("next_run_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
pluginIdx: index("plugin_jobs_plugin_idx").on(table.pluginId),
nextRunIdx: index("plugin_jobs_next_run_idx").on(table.nextRunAt),
uniqueJobIdx: uniqueIndex("plugin_jobs_unique_idx").on(table.pluginId, table.jobKey),
}),
);
/**
* `plugin_job_runs` table — immutable execution history for plugin-owned jobs.
*
* Each row is created when a job run begins and updated when it completes.
* Rows are never modified after `status` reaches a terminal value
* (`succeeded` | `failed` | `cancelled`).
*
* Trigger values:
* - `scheduled` — fired automatically by the cron/interval scheduler
* - `manual` — triggered by an operator via the admin UI or API
*
* @see PLUGIN_SPEC.md §21.3 — `plugin_job_runs`
*/
export const pluginJobRuns = pgTable(
"plugin_job_runs",
{
id: uuid("id").primaryKey().defaultRandom(),
/** FK to the parent job definition. Cascades on delete. */
jobId: uuid("job_id")
.notNull()
.references(() => pluginJobs.id, { onDelete: "cascade" }),
/** Denormalized FK to the owning plugin for efficient querying. Cascades on delete. */
pluginId: uuid("plugin_id")
.notNull()
.references(() => plugins.id, { onDelete: "cascade" }),
/** What caused this run to start (`"scheduled"` or `"manual"`). */
trigger: text("trigger").$type<PluginJobRunTrigger>().notNull(),
/** Current lifecycle state of this run. */
status: text("status").$type<PluginJobRunStatus>().notNull().default("pending"),
/** Wall-clock duration in milliseconds. Null until the run finishes. */
durationMs: integer("duration_ms"),
/** Error message if `status === "failed"`. */
error: text("error"),
/** Ordered list of log lines emitted during this run. */
logs: jsonb("logs").$type<string[]>().notNull().default([]),
startedAt: timestamp("started_at", { withTimezone: true }),
finishedAt: timestamp("finished_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
jobIdx: index("plugin_job_runs_job_idx").on(table.jobId),
pluginIdx: index("plugin_job_runs_plugin_idx").on(table.pluginId),
statusIdx: index("plugin_job_runs_status_idx").on(table.status),
}),
);
+43
View File
@@ -0,0 +1,43 @@
import {
pgTable,
uuid,
text,
timestamp,
jsonb,
index,
} from "drizzle-orm/pg-core";
import { plugins } from "./plugins.js";
/**
* `plugin_logs` table — structured log storage for plugin workers.
*
* Each row stores a single log entry emitted by a plugin worker via
* `ctx.logger.info(...)` etc. Logs are queryable by plugin, level, and
* time range to support the operator logs panel and debugging workflows.
*
* Rows are inserted by the host when handling `log` notifications from
* the worker process. A capped retention policy can be applied via
* periodic cleanup (e.g. delete rows older than 7 days).
*
* @see PLUGIN_SPEC.md §26 — Observability
*/
export const pluginLogs = pgTable(
"plugin_logs",
{
id: uuid("id").primaryKey().defaultRandom(),
pluginId: uuid("plugin_id")
.notNull()
.references(() => plugins.id, { onDelete: "cascade" }),
level: text("level").notNull().default("info"),
message: text("message").notNull(),
meta: jsonb("meta").$type<Record<string, unknown>>(),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
pluginTimeIdx: index("plugin_logs_plugin_time_idx").on(
table.pluginId,
table.createdAt,
),
levelIdx: index("plugin_logs_level_idx").on(table.level),
}),
);
+90
View File
@@ -0,0 +1,90 @@
import {
pgTable,
uuid,
text,
timestamp,
jsonb,
index,
unique,
} from "drizzle-orm/pg-core";
import type { PluginStateScopeKind } from "@paperclipai/shared";
import { plugins } from "./plugins.js";
/**
* `plugin_state` table — scoped key-value storage for plugin workers.
*
* Each row stores a single JSON value identified by
* `(plugin_id, scope_kind, scope_id, namespace, state_key)`. Plugins use
* this table through `ctx.state.get()`, `ctx.state.set()`, and
* `ctx.state.delete()` in the SDK.
*
* Scope kinds determine the granularity of isolation:
* - `instance` — one value shared across the whole Paperclip instance
* - `company` — one value per company
* - `project` — one value per project
* - `project_workspace` — one value per project workspace
* - `agent` — one value per agent
* - `issue` — one value per issue
* - `goal` — one value per goal
* - `run` — one value per agent run
*
* The `namespace` column defaults to `"default"` and can be used to
* logically group keys without polluting the root namespace.
*
* @see PLUGIN_SPEC.md §21.3 — `plugin_state`
*/
export const pluginState = pgTable(
"plugin_state",
{
id: uuid("id").primaryKey().defaultRandom(),
/** FK to the owning plugin. Cascades on delete. */
pluginId: uuid("plugin_id")
.notNull()
.references(() => plugins.id, { onDelete: "cascade" }),
/** Granularity of the scope (e.g. `"instance"`, `"project"`, `"issue"`). */
scopeKind: text("scope_kind").$type<PluginStateScopeKind>().notNull(),
/**
* UUID or text identifier for the scoped object.
* Null for `instance` scope (which has no associated entity).
*/
scopeId: text("scope_id"),
/**
* Sub-namespace to avoid key collisions within a scope.
* Defaults to `"default"` if the plugin does not specify one.
*/
namespace: text("namespace").notNull().default("default"),
/** The key identifying this state entry within the namespace. */
stateKey: text("state_key").notNull(),
/** JSON-serializable value stored by the plugin. */
valueJson: jsonb("value_json").notNull(),
/** Timestamp of the most recent write. */
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
/**
* Unique constraint enforces that there is at most one value per
* (plugin, scope kind, scope id, namespace, key) tuple.
*
* `nullsNotDistinct()` is required so that `scope_id IS NULL` entries
* (used by `instance` scope) are treated as equal by PostgreSQL rather
* than as distinct nulls — otherwise the upsert target in `set()` would
* fail to match existing rows and create duplicates.
*
* Requires PostgreSQL 15+.
*/
uniqueEntry: unique("plugin_state_unique_entry_idx")
.on(
table.pluginId,
table.scopeKind,
table.scopeId,
table.namespace,
table.stateKey,
)
.nullsNotDistinct(),
/** Speed up lookups by plugin + scope kind (most common access pattern). */
pluginScopeIdx: index("plugin_state_plugin_scope_idx").on(
table.pluginId,
table.scopeKind,
),
}),
);
+65
View File
@@ -0,0 +1,65 @@
import {
pgTable,
uuid,
text,
integer,
timestamp,
jsonb,
index,
} from "drizzle-orm/pg-core";
import { plugins } from "./plugins.js";
import type { PluginWebhookDeliveryStatus } from "@paperclipai/shared";
/**
* `plugin_webhook_deliveries` table — inbound webhook delivery history for plugins.
*
* When an external system sends an HTTP POST to a plugin's registered webhook
* endpoint (e.g. `/api/plugins/:pluginKey/webhooks/:webhookKey`), the server
* creates a row in this table before dispatching the payload to the plugin
* worker. This provides an auditable log of every delivery attempt.
*
* The `webhook_key` matches the key declared in the plugin manifest's
* `webhooks` array. `external_id` is an optional identifier supplied by the
* remote system (e.g. a GitHub delivery GUID) that can be used to detect
* and reject duplicate deliveries.
*
* Status values:
* - `pending` — received but not yet dispatched to the worker
* - `processing` — currently being handled by the plugin worker
* - `succeeded` — worker processed the payload successfully
* - `failed` — worker returned an error or timed out
*
* @see PLUGIN_SPEC.md §21.3 — `plugin_webhook_deliveries`
*/
export const pluginWebhookDeliveries = pgTable(
"plugin_webhook_deliveries",
{
id: uuid("id").primaryKey().defaultRandom(),
/** FK to the owning plugin. Cascades on delete. */
pluginId: uuid("plugin_id")
.notNull()
.references(() => plugins.id, { onDelete: "cascade" }),
/** Identifier matching the key in the plugin manifest's `webhooks` array. */
webhookKey: text("webhook_key").notNull(),
/** Optional de-duplication ID provided by the external system. */
externalId: text("external_id"),
/** Current delivery state. */
status: text("status").$type<PluginWebhookDeliveryStatus>().notNull().default("pending"),
/** Wall-clock processing duration in milliseconds. Null until delivery finishes. */
durationMs: integer("duration_ms"),
/** Error message if `status === "failed"`. */
error: text("error"),
/** Raw JSON body of the inbound HTTP request. */
payload: jsonb("payload").$type<Record<string, unknown>>().notNull(),
/** Relevant HTTP headers from the inbound request (e.g. signature headers). */
headers: jsonb("headers").$type<Record<string, string>>().notNull().default({}),
startedAt: timestamp("started_at", { withTimezone: true }),
finishedAt: timestamp("finished_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
pluginIdx: index("plugin_webhook_deliveries_plugin_idx").on(table.pluginId),
statusIdx: index("plugin_webhook_deliveries_status_idx").on(table.status),
keyIdx: index("plugin_webhook_deliveries_key_idx").on(table.webhookKey),
}),
);
+45
View File
@@ -0,0 +1,45 @@
import {
pgTable,
uuid,
text,
integer,
timestamp,
jsonb,
index,
uniqueIndex,
} from "drizzle-orm/pg-core";
import type { PluginCategory, PluginStatus, PaperclipPluginManifestV1 } from "@paperclipai/shared";
/**
* `plugins` table — stores one row per installed plugin.
*
* Each plugin is uniquely identified by `plugin_key` (derived from
* the manifest `id`). The full manifest is persisted as JSONB in
* `manifest_json` so the host can reconstruct capability and UI
* slot information without loading the plugin package.
*
* @see PLUGIN_SPEC.md §21.3
*/
export const plugins = pgTable(
"plugins",
{
id: uuid("id").primaryKey().defaultRandom(),
pluginKey: text("plugin_key").notNull(),
packageName: text("package_name").notNull(),
version: text("version").notNull(),
apiVersion: integer("api_version").notNull().default(1),
categories: jsonb("categories").$type<PluginCategory[]>().notNull().default([]),
manifestJson: jsonb("manifest_json").$type<PaperclipPluginManifestV1>().notNull(),
status: text("status").$type<PluginStatus>().notNull().default("installed"),
installOrder: integer("install_order"),
/** Resolved package path for local-path installs; used to find worker entrypoint. */
packagePath: text("package_path"),
lastError: text("last_error"),
installedAt: timestamp("installed_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
pluginKeyIdx: uniqueIndex("plugins_plugin_key_idx").on(table.pluginKey),
statusIdx: index("plugins_status_idx").on(table.status),
}),
);
+2 -1
View File
@@ -1,4 +1,4 @@
import { pgTable, uuid, text, timestamp, date, index } from "drizzle-orm/pg-core";
import { pgTable, uuid, text, timestamp, date, index, jsonb } from "drizzle-orm/pg-core";
import { companies } from "./companies.js";
import { goals } from "./goals.js";
import { agents } from "./agents.js";
@@ -15,6 +15,7 @@ export const projects = pgTable(
leadAgentId: uuid("lead_agent_id").references(() => agents.id),
targetDate: date("target_date"),
color: text("color"),
executionWorkspacePolicy: jsonb("execution_workspace_policy").$type<Record<string, unknown>>(),
archivedAt: timestamp("archived_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
@@ -0,0 +1,64 @@
import {
index,
integer,
jsonb,
pgTable,
text,
timestamp,
uuid,
} from "drizzle-orm/pg-core";
import { companies } from "./companies.js";
import { projects } from "./projects.js";
import { projectWorkspaces } from "./project_workspaces.js";
import { issues } from "./issues.js";
import { agents } from "./agents.js";
import { heartbeatRuns } from "./heartbeat_runs.js";
export const workspaceRuntimeServices = pgTable(
"workspace_runtime_services",
{
id: uuid("id").primaryKey(),
companyId: uuid("company_id").notNull().references(() => companies.id),
projectId: uuid("project_id").references(() => projects.id, { onDelete: "set null" }),
projectWorkspaceId: uuid("project_workspace_id").references(() => projectWorkspaces.id, { onDelete: "set null" }),
issueId: uuid("issue_id").references(() => issues.id, { onDelete: "set null" }),
scopeType: text("scope_type").notNull(),
scopeId: text("scope_id"),
serviceName: text("service_name").notNull(),
status: text("status").notNull(),
lifecycle: text("lifecycle").notNull(),
reuseKey: text("reuse_key"),
command: text("command"),
cwd: text("cwd"),
port: integer("port"),
url: text("url"),
provider: text("provider").notNull(),
providerRef: text("provider_ref"),
ownerAgentId: uuid("owner_agent_id").references(() => agents.id, { onDelete: "set null" }),
startedByRunId: uuid("started_by_run_id").references(() => heartbeatRuns.id, { onDelete: "set null" }),
lastUsedAt: timestamp("last_used_at", { withTimezone: true }).notNull().defaultNow(),
startedAt: timestamp("started_at", { withTimezone: true }).notNull().defaultNow(),
stoppedAt: timestamp("stopped_at", { withTimezone: true }),
stopPolicy: jsonb("stop_policy").$type<Record<string, unknown>>(),
healthStatus: text("health_status").notNull().default("unknown"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
companyWorkspaceStatusIdx: index("workspace_runtime_services_company_workspace_status_idx").on(
table.companyId,
table.projectWorkspaceId,
table.status,
),
companyProjectStatusIdx: index("workspace_runtime_services_company_project_status_idx").on(
table.companyId,
table.projectId,
table.status,
),
runIdx: index("workspace_runtime_services_run_idx").on(table.startedByRunId),
companyUpdatedIdx: index("workspace_runtime_services_company_updated_idx").on(
table.companyId,
table.updatedAt,
),
}),
);