Infrastructure build to support third-party app integrations
This commit is contained in:
@@ -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
17
backend/db/index.ts
Normal 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;
|
||||
64
backend/db/ingest_events.dao.ts
Normal file
64
backend/db/ingest_events.dao.ts
Normal 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;
|
||||
};
|
||||
132
backend/db/integrations.dao.ts
Normal file
132
backend/db/integrations.dao.ts
Normal 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');
|
||||
@@ -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
67
backend/db/migrate.ts
Normal 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();
|
||||
@@ -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
125
backend/db/notes.dao.ts
Normal 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;
|
||||
};
|
||||
@@ -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();
|
||||
Reference in New Issue
Block a user