Files
simstudioai--sim/apps/sim/lib/data-drains/service.ts
T
wehub-resource-sync d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

228 lines
6.1 KiB
TypeScript

import { db } from '@sim/db'
import { dataDrainRuns, dataDrains } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
import { eq } from 'drizzle-orm'
import { getDestination } from '@/lib/data-drains/destinations/registry'
import { decryptCredentials } from '@/lib/data-drains/encryption'
import { getSource } from '@/lib/data-drains/sources/registry'
import type { Cursor, RunTrigger } from '@/lib/data-drains/types'
const logger = createLogger('DataDrainsService')
const CHUNK_SIZE = 1000
export interface RunDrainResult {
drainId: string
runId: string
status: 'success' | 'failed' | 'skipped'
rowsExported: number
bytesWritten: number
cursorBefore: Cursor
cursorAfter: Cursor
locators: string[]
error?: string
}
/**
* Orchestrates one drain export. Source-/destination-agnostic — talks only to
* the registry interfaces. The drain's cursor is advanced only when the entire
* run completes successfully so consumers see at-least-once delivery and can
* dedupe on the per-row `id` field.
*/
export async function runDrain(
drainId: string,
trigger: RunTrigger,
options: { signal?: AbortSignal } = {}
): Promise<RunDrainResult> {
const signal = options.signal ?? new AbortController().signal
const [drain] = await db.select().from(dataDrains).where(eq(dataDrains.id, drainId)).limit(1)
if (!drain) {
throw new Error(`Data drain not found: ${drainId}`)
}
if (!drain.enabled) {
return {
drainId,
runId: '',
status: 'skipped',
rowsExported: 0,
bytesWritten: 0,
cursorBefore: drain.cursor,
cursorAfter: drain.cursor,
locators: [],
}
}
const source = getSource(drain.source)
const destination = getDestination(drain.destinationType)
const runId = generateId()
const startedAt = new Date()
await db.insert(dataDrainRuns).values({
id: runId,
drainId,
status: 'running',
trigger,
startedAt,
cursorBefore: drain.cursor,
})
const cursorBefore = drain.cursor
let cursor: Cursor = drain.cursor
let rowsExported = 0
let bytesWritten = 0
let sequence = 0
const locators: string[] = []
/**
* Schema-parse and decrypt happen *after* the run row is created so failures
* in either (e.g. encryption-key rotation, schema drift across versions)
* surface as a `failed` run row in the UI rather than vanishing into the
* background-job logs while `lastRunAt` quietly advances.
*/
let session: ReturnType<typeof destination.openSession> | null = null
try {
const config = destination.configSchema.parse(drain.destinationConfig)
const credentials = destination.credentialsSchema.parse(
await decryptCredentials(drain.destinationCredentials)
)
session = destination.openSession({ config, credentials })
for await (const chunk of source.pages({
organizationId: drain.organizationId,
cursor,
chunkSize: CHUNK_SIZE,
signal,
})) {
const ndjson = `${chunk.map((row) => JSON.stringify(source.serialize(row))).join('\n')}\n`
const body = Buffer.from(ndjson, 'utf8')
const result = await session.deliver({
body,
contentType: 'application/x-ndjson',
metadata: {
drainId,
runId,
source: drain.source,
sequence,
rowCount: chunk.length,
runStartedAt: startedAt,
},
signal,
})
locators.push(result.locator)
rowsExported += chunk.length
bytesWritten += body.byteLength
cursor = source.cursorAfter(chunk[chunk.length - 1])
sequence++
}
if (signal.aborted) {
throw new Error('Data drain run cancelled')
}
const finishedAt = new Date()
await db.transaction(async (tx) => {
await tx
.update(dataDrains)
.set({
cursor,
lastRunAt: finishedAt,
lastSuccessAt: finishedAt,
updatedAt: finishedAt,
})
.where(eq(dataDrains.id, drainId))
await tx
.update(dataDrainRuns)
.set({
status: 'success',
finishedAt,
rowsExported,
bytesWritten,
cursorAfter: cursor,
locators,
error: null,
})
.where(eq(dataDrainRuns.id, runId))
})
logger.info('Data drain run succeeded', {
drainId,
runId,
source: drain.source,
destinationType: drain.destinationType,
rowsExported,
bytesWritten,
chunks: sequence,
})
return {
drainId,
runId,
status: 'success',
rowsExported,
bytesWritten,
cursorBefore,
cursorAfter: cursor,
locators,
}
} catch (error) {
const finishedAt = new Date()
const message = toError(error).message
try {
await db.transaction(async (tx) => {
await tx
.update(dataDrains)
.set({ lastRunAt: finishedAt, updatedAt: finishedAt })
.where(eq(dataDrains.id, drainId))
await tx
.update(dataDrainRuns)
.set({
status: 'failed',
finishedAt,
rowsExported,
bytesWritten,
cursorAfter: cursorBefore,
locators,
error: message.slice(0, 4000),
})
.where(eq(dataDrainRuns.id, runId))
})
} catch (statusError) {
// Best-effort status write — the reaper repairs stuck rows. Log so DB
// outages don't hide behind the original delivery error.
logger.error('Failed to record data drain failure status', {
drainId,
runId,
deliveryError: message,
statusError: toError(statusError).message,
})
}
logger.error('Data drain run failed', {
drainId,
runId,
source: drain.source,
destinationType: drain.destinationType,
error: message,
})
throw error
} finally {
if (session) {
try {
await session.close()
} catch (closeError) {
logger.warn('Data drain session close failed', {
drainId,
runId,
error: toError(closeError).message,
})
}
}
}
}