Infrastructure build to support third-party app integrations

This commit is contained in:
KS Jannette
2026-08-01 07:35:01 -04:00
parent b4666c5439
commit 15af3465e2
53 changed files with 5864 additions and 659 deletions

View File

@@ -1,13 +0,0 @@
import pg from 'pg';
const pool = new pg.Pool({
connectionString: process.env.DATABASE_URL,
});
export const query = (text, params) => pool.query(text, params);
export const getPool = () => pool;
export const close = () => pool.end();
export default pool;

17
backend/db/index.ts Normal file
View File

@@ -0,0 +1,17 @@
import pg from 'pg';
import type { QueryResult, QueryResultRow } from 'pg';
const pool = new pg.Pool({
connectionString: process.env.DATABASE_URL,
});
export const query = <R extends QueryResultRow = QueryResultRow>(
text: string,
params?: unknown[]
): Promise<QueryResult<R>> => pool.query<R>(text, params);
export const getPool = (): pg.Pool => pool;
export const close = (): Promise<void> => pool.end();
export default pool;

View File

@@ -0,0 +1,64 @@
import { query } from './index.js';
import type { ProviderSlug } from '../types/integration.js';
/**
* Returns the new row id, or null when this delivery was already recorded.
* Providers deliver at least once, so the unique constraint firing is the
* expected path for a redelivery rather than an error.
*/
export const recordDelivery = async (input: {
provider: ProviderSlug;
externalId: string;
integrationId?: number;
}): Promise<number | null> => {
const { rows } = await query<{ id: number }>(
`INSERT INTO ingest_events (provider, external_id, integration_id)
VALUES ($1, $2, $3)
ON CONFLICT (provider, external_id) DO NOTHING
RETURNING id`,
[input.provider, input.externalId, input.integrationId ?? null]
);
return rows[0]?.id ?? null;
};
export const markProcessing = async (id: number): Promise<void> => {
await query(
`UPDATE ingest_events
SET status = 'processing', attempts = attempts + 1, updated_at = NOW()
WHERE id = $1`,
[id]
);
};
export const markDone = async (id: number): Promise<void> => {
await query(
`UPDATE ingest_events
SET status = 'done', last_error = NULL, updated_at = NOW()
WHERE id = $1`,
[id]
);
};
export const markFailed = async (id: number, error: string): Promise<void> => {
await query(
`UPDATE ingest_events
SET status = 'failed', last_error = $2, updated_at = NOW()
WHERE id = $1`,
[id, error.slice(0, 2000)]
);
};
/**
* The queue lives in process memory, so a restart strands anything that was
* mid-flight. Returns rows to 'pending' so they can be picked up again.
*/
export const resetStaleProcessing = async (olderThanMs: number): Promise<number> => {
const { rowCount } = await query(
`UPDATE ingest_events
SET status = 'pending', updated_at = NOW()
WHERE status = 'processing'
AND updated_at < NOW() - ($1 || ' milliseconds')::interval`,
[String(olderThanMs)]
);
return rowCount ?? 0;
};

View File

@@ -0,0 +1,132 @@
import { query } from './index.js';
import { decryptSecret, encryptSecret } from '../lib/crypto.js';
import type { IntegrationRow, ProviderSlug } from '../types/integration.js';
type IntegrationRecord = {
id: number;
provider: ProviderSlug;
external_workspace_id: string | null;
display_name: string | null;
api_key_hash: string | null;
access_token_ciphertext: string | null;
refresh_token_ciphertext: string | null;
signing_secret_ciphertext: string | null;
token_expires_at: Date | null;
scopes: string[] | null;
};
const PUBLIC_COLUMNS = `
id, provider, external_workspace_id, display_name, token_expires_at, scopes
`;
/**
* Ciphertext columns are dropped here rather than in the query so that every
* caller path converges on a value that structurally cannot carry a secret.
*/
const toPublicRow = (record: IntegrationRecord): IntegrationRow => ({
id: record.id,
provider: record.provider,
externalWorkspaceId: record.external_workspace_id,
displayName: record.display_name,
scopes: record.scopes ?? [],
tokenExpiresAt: record.token_expires_at,
});
export const findByApiKeyHash = async (
hash: string
): Promise<IntegrationRow | null> => {
const { rows } = await query<IntegrationRecord>(
`SELECT ${PUBLIC_COLUMNS} FROM integrations WHERE api_key_hash = $1`,
[hash]
);
return rows[0] ? toPublicRow(rows[0]) : null;
};
export const findByProviderWorkspace = async (
provider: ProviderSlug,
externalWorkspaceId: string
): Promise<IntegrationRow | null> => {
const { rows } = await query<IntegrationRecord>(
`SELECT ${PUBLIC_COLUMNS} FROM integrations
WHERE provider = $1 AND external_workspace_id = $2`,
[provider, externalWorkspaceId]
);
return rows[0] ? toPublicRow(rows[0]) : null;
};
export const upsertInstall = async (input: {
provider: ProviderSlug;
externalWorkspaceId: string;
displayName?: string;
apiKeyHash?: string;
signingSecret?: string;
scopes?: string[];
}): Promise<IntegrationRow> => {
const { rows } = await query<IntegrationRecord>(
`INSERT INTO integrations
(provider, external_workspace_id, display_name, api_key_hash,
signing_secret_ciphertext, scopes)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (provider, external_workspace_id) DO UPDATE SET
display_name = EXCLUDED.display_name,
api_key_hash = COALESCE(EXCLUDED.api_key_hash, integrations.api_key_hash),
signing_secret_ciphertext = COALESCE(
EXCLUDED.signing_secret_ciphertext, integrations.signing_secret_ciphertext
),
scopes = EXCLUDED.scopes,
updated_at = NOW()
RETURNING ${PUBLIC_COLUMNS}`,
[
input.provider,
input.externalWorkspaceId,
input.displayName ?? null,
input.apiKeyHash ?? null,
input.signingSecret ? encryptSecret(input.signingSecret) : null,
input.scopes ?? [],
]
);
return toPublicRow(rows[0]);
};
export const updateTokens = async (input: {
id: number;
accessToken: string;
refreshToken?: string;
expiresAt?: Date;
}): Promise<void> => {
await query(
`UPDATE integrations SET
access_token_ciphertext = $2,
refresh_token_ciphertext = COALESCE($3, refresh_token_ciphertext),
token_expires_at = $4,
updated_at = NOW()
WHERE id = $1`,
[
input.id,
encryptSecret(input.accessToken),
input.refreshToken ? encryptSecret(input.refreshToken) : null,
input.expiresAt ?? null,
]
);
};
const readSecret = async (
id: number,
column: 'access_token_ciphertext' | 'refresh_token_ciphertext' | 'signing_secret_ciphertext'
): Promise<string | null> => {
const { rows } = await query<Record<string, string | null>>(
`SELECT ${column} AS value FROM integrations WHERE id = $1`,
[id]
);
const value = rows[0]?.value;
return value ? decryptSecret(value) : null;
};
export const getAccessToken = (id: number): Promise<string | null> =>
readSecret(id, 'access_token_ciphertext');
export const getRefreshToken = (id: number): Promise<string | null> =>
readSecret(id, 'refresh_token_ciphertext');
export const getSigningSecret = (id: number): Promise<string | null> =>
readSecret(id, 'signing_secret_ciphertext');

View File

@@ -1,30 +0,0 @@
import 'dotenv/config';
import { query, close } from './index.js';
const up = `
CREATE TABLE IF NOT EXISTS notes (
id VARCHAR(64) PRIMARY KEY,
text TEXT NOT NULL,
x INTEGER DEFAULT 0,
y INTEGER DEFAULT 0,
author VARCHAR(128),
color VARCHAR(32) DEFAULT 'yellow',
source_meta JSONB DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
`;
const run = async () => {
try {
await query(up);
console.log('Migration complete — notes table ready.');
} catch (err) {
console.error('Migration failed:', err.message);
process.exit(1);
} finally {
await close();
}
};
run();

67
backend/db/migrate.ts Normal file
View File

@@ -0,0 +1,67 @@
import 'dotenv/config';
import { query, close } from './index.js';
const up = `
CREATE TABLE IF NOT EXISTS notes (
id VARCHAR(64) PRIMARY KEY,
text TEXT NOT NULL,
x INTEGER DEFAULT 0,
y INTEGER DEFAULT 0,
author VARCHAR(128),
color VARCHAR(32) DEFAULT 'yellow',
source_meta JSONB DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS integrations (
id SERIAL PRIMARY KEY,
provider VARCHAR(32) NOT NULL,
external_workspace_id VARCHAR(128),
display_name VARCHAR(255),
api_key_hash CHAR(64),
access_token_ciphertext TEXT,
refresh_token_ciphertext TEXT,
signing_secret_ciphertext TEXT,
token_expires_at TIMESTAMPTZ,
scopes TEXT[] DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE (provider, external_workspace_id)
);
CREATE INDEX IF NOT EXISTS integrations_api_key_hash_idx
ON integrations (api_key_hash);
-- The unique constraint is the deduplication mechanism: providers deliver
-- at least once, so a redelivery must collide rather than insert.
CREATE TABLE IF NOT EXISTS ingest_events (
id SERIAL PRIMARY KEY,
provider VARCHAR(32) NOT NULL,
external_id VARCHAR(255) NOT NULL,
integration_id INTEGER REFERENCES integrations(id) ON DELETE SET NULL,
status VARCHAR(16) NOT NULL DEFAULT 'pending',
attempts INTEGER NOT NULL DEFAULT 0,
last_error TEXT,
received_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE (provider, external_id)
);
CREATE INDEX IF NOT EXISTS ingest_events_status_idx
ON ingest_events (status, updated_at);
`;
const run = async (): Promise<void> => {
try {
await query(up);
console.log('Migration complete — notes, integrations, ingest_events ready.');
} catch (err) {
console.error('Migration failed:', err instanceof Error ? err.message : err);
process.exit(1);
} finally {
await close();
}
};
run();

View File

@@ -1,110 +0,0 @@
import QueryStream from 'pg-query-stream';
import { pipeline } from 'node:stream/promises';
import { Readable } from 'node:stream';
import { query, getPool } from './index.js';
import { batch } from '../lib/streams.js';
const SELECT_NOTES = 'SELECT id, text, x, y, author, color FROM notes ORDER BY id';
// Postgres caps a statement at 65535 bind parameters; six columns per note
// leaves 10922 as the hard ceiling.
const INSERT_BATCH_SIZE = 1000;
export const getAllNotes = async () => {
const { rows } = await query(SELECT_NOTES);
return rows;
};
/**
* Streams every note as an object-mode Readable. The pooled client is released on end, error, or
* destruction by consumer.
*
* @returns {Promise<import('node:stream').Readable>}
*/
export const streamAllNotes = async () => {
const client = await getPool().connect();
let released = false;
const release = () => {
if (released) return;
released = true;
client.release();
};
try {
const rows = client.query(new QueryStream(SELECT_NOTES));
rows.once('end', release);
rows.once('error', release);
rows.once('close', release);
return rows;
} catch (err) {
release();
throw err;
}
};
export const getNoteById = async (id) => {
const { rows } = await query(
'SELECT id, text, x, y, author, color FROM notes WHERE id = $1',
[id]
);
return rows[0] || null;
};
export const createNote = async (note) => {
const { rows } = await query(
`INSERT INTO notes (id, text, x, y, author, color)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, text, x, y, author, color`,
[note.id, note.text, note.x ?? 0, note.y ?? 0, note.author, note.color ?? 'yellow']
);
return rows[0];
};
const insertNoteBatch = async (notes) => {
const values = [];
const placeholders = [];
notes.forEach((note, i) => {
const offset = i * 6;
placeholders.push(
`($${offset + 1}, $${offset + 2}, $${offset + 3}, $${offset + 4}, $${offset + 5}, $${offset + 6})`
);
values.push(
note.id,
note.text,
note.x ?? 0,
note.y ?? 0,
note.author,
note.color ?? 'yellow'
);
});
const { rows } = await query(
`INSERT INTO notes (id, text, x, y, author, color)
VALUES ${placeholders.join(', ')}
RETURNING id, text, x, y, author, color`,
values
);
return rows;
};
export const createNotes = async (notes) => {
if (!notes || notes.length === 0) {
return [];
}
const inserted = [];
await pipeline(
Readable.from(notes, { objectMode: true }),
batch(INSERT_BATCH_SIZE),
async (batches) => {
for await (const chunk of batches) {
inserted.push(...await insertNoteBatch(chunk));
}
}
);
return inserted;
};

125
backend/db/notes.dao.ts Normal file
View File

@@ -0,0 +1,125 @@
import QueryStream from 'pg-query-stream';
import { pipeline } from 'node:stream/promises';
import { Readable } from 'node:stream';
import { query, getPool } from './index.js';
import { batch } from '../lib/streams.js';
import type { Note, NoteInput } from '../types/domain.js';
const SELECT_NOTES = 'SELECT id, text, x, y, author, color FROM notes ORDER BY id';
// Postgres caps a statement at 65535 bind parameters; seven columns per note
// leaves 9362 as the hard ceiling.
const INSERT_BATCH_SIZE = 1000;
const INSERT_COLUMNS = 7;
export const getAllNotes = async (): Promise<Note[]> => {
const { rows } = await query<Note>(SELECT_NOTES);
return rows;
};
/**
* Streams every note as an object-mode Readable. The pooled client is released on end, error, or
* destruction by consumer.
*/
export const streamAllNotes = async (): Promise<Readable> => {
const client = await getPool().connect();
let released = false;
const release = () => {
if (released) return;
released = true;
client.release();
};
try {
const rows = client.query(new QueryStream(SELECT_NOTES)) as unknown as Readable;
rows.once('end', release);
rows.once('error', release);
rows.once('close', release);
return rows;
} catch (err) {
release();
throw err;
}
};
export const getNoteById = async (id: string): Promise<Note | null> => {
const { rows } = await query<Note>(
'SELECT id, text, x, y, author, color FROM notes WHERE id = $1',
[id]
);
return rows[0] || null;
};
export const createNote = async (note: NoteInput): Promise<Note> => {
const { rows } = await query<Note>(
`INSERT INTO notes (id, text, x, y, author, color, source_meta)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, text, x, y, author, color`,
[
note.id,
note.text,
note.x ?? 0,
note.y ?? 0,
note.author,
note.color ?? 'yellow',
JSON.stringify(note.sourceMeta ?? {}),
]
);
return rows[0];
};
const insertNoteBatch = async (notes: NoteInput[]): Promise<Note[]> => {
const values: unknown[] = [];
const placeholders: string[] = [];
notes.forEach((note, i) => {
const offset = i * INSERT_COLUMNS;
const slots = Array.from(
{ length: INSERT_COLUMNS },
(_, col) => `$${offset + col + 1}`
);
placeholders.push(`(${slots.join(', ')})`);
values.push(
note.id,
note.text,
note.x ?? 0,
note.y ?? 0,
note.author,
note.color ?? 'yellow',
JSON.stringify(note.sourceMeta ?? {})
);
});
// Redelivered webhooks can carry a note that already landed; skipping the
// conflict keeps ingestion idempotent at the row level as well.
const { rows } = await query<Note>(
`INSERT INTO notes (id, text, x, y, author, color, source_meta)
VALUES ${placeholders.join(', ')}
ON CONFLICT (id) DO NOTHING
RETURNING id, text, x, y, author, color`,
values
);
return rows;
};
export const createNotes = async (notes: NoteInput[]): Promise<Note[]> => {
if (!notes || notes.length === 0) {
return [];
}
const inserted: Note[] = [];
await pipeline(
Readable.from(notes, { objectMode: true }),
batch<NoteInput>(INSERT_BATCH_SIZE),
async (batches: AsyncIterable<NoteInput[]>) => {
for await (const chunk of batches) {
inserted.push(...await insertNoteBatch(chunk));
}
}
);
return inserted;
};

View File

@@ -2,33 +2,35 @@ import 'dotenv/config';
import split2 from 'split2';
import { from as copyFrom } from 'pg-copy-streams';
import { createReadStream } from 'node:fs';
import { Transform } from 'node:stream';
import { Transform, type TransformCallback } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import { fileURLToPath } from 'node:url';
import { getPool, close } from './index.js';
const FIXTURE = fileURLToPath(new URL('./fixtures/notes.jsonl', import.meta.url));
const COLUMNS = ['id', 'text', 'x', 'y', 'author', 'color'];
const COLUMNS = ['id', 'text', 'x', 'y', 'author', 'color'] as const;
const DEFAULTS = { x: 0, y: 0, color: 'yellow' };
type Column = typeof COLUMNS[number];
const csvField = (value) => {
const DEFAULTS: Partial<Record<Column, unknown>> = { x: 0, y: 0, color: 'yellow' };
const csvField = (value: unknown): string => {
if (value === null || value === undefined) return '';
return `"${String(value).replaceAll('"', '""')}"`;
};
const toCsvRows = () => new Transform({
const toCsvRows = (): Transform => new Transform({
writableObjectMode: true,
transform(line, _encoding, callback) {
transform(line: string, _encoding: BufferEncoding, callback: TransformCallback) {
if (line.trim().length === 0) {
callback();
return;
}
let note;
let note: Record<string, unknown>;
try {
note = JSON.parse(line);
note = JSON.parse(line) as Record<string, unknown>;
} catch {
callback(new Error(`Fixture contains a malformed JSON line: ${line.slice(0, 80)}`));
return;
@@ -39,7 +41,7 @@ const toCsvRows = () => new Transform({
},
});
const run = async () => {
const run = async (): Promise<void> => {
const client = await getPool().connect();
try {
@@ -66,10 +68,10 @@ const run = async () => {
await client.query('COMMIT');
console.log(`Seed complete — ${inserted} notes inserted (${staged - inserted} already existed).`);
console.log(`Seed complete — ${inserted} notes inserted (${(staged ?? 0) - (inserted ?? 0)} already existed).`);
} catch (err) {
await client.query('ROLLBACK').catch(() => {});
console.error('Seed failed:', err.message);
console.error('Seed failed:', err instanceof Error ? err.message : err);
process.exitCode = 1;
} finally {
client.release();