d25d482dc2
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
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
516 lines
20 KiB
TypeScript
516 lines
20 KiB
TypeScript
import { createHash, createPublicKey } from 'node:crypto'
|
|
import { createLogger } from '@sim/logger'
|
|
import { toError } from '@sim/utils/errors'
|
|
import { generateId } from '@sim/utils/id'
|
|
import { backoffWithJitter, parseRetryAfter } from '@sim/utils/retry'
|
|
import { importPKCS8, SignJWT } from 'jose'
|
|
import { z } from 'zod'
|
|
import { sleepUntilAborted } from '@/lib/data-drains/destinations/utils'
|
|
import type { DrainDestination } from '@/lib/data-drains/types'
|
|
|
|
const logger = createLogger('DataDrainSnowflakeDestination')
|
|
|
|
/**
|
|
* Snowflake account identifier formats (https://docs.snowflake.com/en/user-guide/admin-account-identifier):
|
|
* - Org-account: `<orgname>-<acctname>` — alphanumerics/underscore, hyphen-separated, no dots.
|
|
* - Legacy account locator: `<locator>` or `<locator>.<region>[.<cloud>]` — dots allowed.
|
|
*/
|
|
const ACCOUNT_ORG_RE = /^[A-Za-z0-9][A-Za-z0-9_]*(?:-[A-Za-z0-9_]+)+$/
|
|
/**
|
|
* First segment allows hyphens so org-account identifiers (`<orgname>-<acctname>`)
|
|
* carrying a legacy region/cloud suffix (e.g. `myorg-acct.us-east-1.aws`) match.
|
|
* `normalizeAccountForJwt` strips the dotted suffix for JWT `iss`/`sub`.
|
|
*/
|
|
const ACCOUNT_LOCATOR_RE = /^[A-Za-z0-9][A-Za-z0-9_-]*(?:\.[A-Za-z0-9][A-Za-z0-9_-]*){0,2}$/
|
|
function isValidAccount(v: string): boolean {
|
|
return ACCOUNT_ORG_RE.test(v) || ACCOUNT_LOCATOR_RE.test(v)
|
|
}
|
|
const IDENTIFIER_RE = /^[A-Za-z_][A-Za-z0-9_$]{0,254}$/
|
|
/** JWT lifetime; Snowflake caps server-side enforcement at 60 minutes regardless of `exp`. */
|
|
const JWT_LIFETIME_SECONDS = 55 * 60
|
|
/** Safety margin (in seconds) subtracted from the JWT exp when caching. */
|
|
const JWT_CACHE_SAFETY_MARGIN_SECONDS = 300
|
|
const PER_ATTEMPT_TIMEOUT_MS = 60_000
|
|
const POLL_INITIAL_INTERVAL_MS = 500
|
|
const POLL_MAX_INTERVAL_MS = 5_000
|
|
const POLL_DEADLINE_MS = 10 * 60_000
|
|
/**
|
|
* Cap on consecutive failed poll attempts (network errors or retryable HTTP
|
|
* statuses). Independent of the 10-minute wall-clock deadline so that
|
|
* persistent failures surface in seconds, not minutes — matches the
|
|
* MAX_ATTEMPTS shape used by `executeStatement`. Reset to 0 on a successful
|
|
* 202 (still-executing) response.
|
|
*/
|
|
const POLL_MAX_CONSECUTIVE_RETRIES = 8
|
|
/** Maximum number of attempts (including the initial attempt) for retryable POST failures. */
|
|
const EXECUTE_MAX_ATTEMPTS = 3
|
|
const EXECUTE_RETRY_BASE_DELAY_MS = 500
|
|
const EXECUTE_RETRY_MAX_DELAY_MS = 5_000
|
|
/** Conservative pre-2025_03 BCR VARIANT max size (16 MiB) so the same value works on every account. */
|
|
const VARIANT_MAX_BYTES = 16 * 1024 * 1024
|
|
/** Server-side statement execution timeout (seconds) sent in the SQL API request body. */
|
|
const SQL_API_TIMEOUT_SECONDS = 600
|
|
|
|
/**
|
|
* Snowflake JWT `iss`/`sub` require the bare account identifier without any
|
|
* region/cloud suffix. For account-locator format `xy12345.us-east-1.aws`,
|
|
* only `XY12345` is valid; for org-account format `myorg-acct.us-east-1`,
|
|
* only `MYORG-ACCT` is valid. Strip everything after the first dot.
|
|
*/
|
|
function normalizeAccountForJwt(account: string): string {
|
|
const dot = account.indexOf('.')
|
|
return (dot === -1 ? account : account.slice(0, dot)).toUpperCase()
|
|
}
|
|
|
|
const snowflakeConfigSchema = z.object({
|
|
/**
|
|
* Snowflake account identifier. Accepted formats:
|
|
* - Org-account (preferred): `<orgname>-<acctname>` (no dots), e.g. `myorg-acct`
|
|
* - Account locator: `<locator>` (no dots), e.g. `xy12345`
|
|
* - Legacy regional locator: `<locator>.<region>.<cloud>`, e.g. `xy12345.us-east-1.aws`
|
|
*
|
|
* Do not include the `.snowflakecomputing.com` suffix. Modern org-account
|
|
* identifiers must not contain dots; only legacy locator URLs use dots.
|
|
*/
|
|
account: z.string().min(3, 'account is required').refine(isValidAccount, {
|
|
message: 'account must be the Snowflake account identifier (e.g. orgname-accountname)',
|
|
}),
|
|
user: z
|
|
.string()
|
|
.min(1, 'user is required')
|
|
.refine((v) => IDENTIFIER_RE.test(v), {
|
|
message: 'user must be a valid Snowflake identifier',
|
|
}),
|
|
warehouse: z
|
|
.string()
|
|
.min(1)
|
|
.refine((v) => IDENTIFIER_RE.test(v), {
|
|
message: 'warehouse must be a valid Snowflake identifier',
|
|
}),
|
|
database: z
|
|
.string()
|
|
.min(1)
|
|
.refine((v) => IDENTIFIER_RE.test(v), {
|
|
message: 'database must be a valid Snowflake identifier',
|
|
}),
|
|
schema: z
|
|
.string()
|
|
.min(1)
|
|
.refine((v) => IDENTIFIER_RE.test(v), {
|
|
message: 'schema must be a valid Snowflake identifier',
|
|
}),
|
|
table: z
|
|
.string()
|
|
.min(1)
|
|
.refine((v) => IDENTIFIER_RE.test(v), {
|
|
message: 'table must be a valid Snowflake identifier',
|
|
}),
|
|
/** Target VARIANT column. Defaults to `DATA` (uppercase, matching Snowflake's unquoted identifier folding). */
|
|
column: z
|
|
.string()
|
|
.min(1)
|
|
.refine((v) => IDENTIFIER_RE.test(v), {
|
|
message: 'column must be a valid Snowflake identifier',
|
|
})
|
|
.optional(),
|
|
/** Optional Snowflake role to assume for the insert. */
|
|
role: z
|
|
.string()
|
|
.min(1)
|
|
.refine((v) => IDENTIFIER_RE.test(v), {
|
|
message: 'role must be a valid Snowflake identifier',
|
|
})
|
|
.optional(),
|
|
})
|
|
|
|
const snowflakeCredentialsSchema = z.object({
|
|
/** PKCS8-encoded RSA private key (PEM). The matching public key must be registered on the user. */
|
|
privateKey: z.string().min(1, 'privateKey is required'),
|
|
})
|
|
|
|
export type SnowflakeDestinationConfig = z.infer<typeof snowflakeConfigSchema>
|
|
export type SnowflakeDestinationCredentials = z.infer<typeof snowflakeCredentialsSchema>
|
|
|
|
/**
|
|
* Computes the SHA256:<base64> fingerprint of the public key derived from the
|
|
* given private key. Snowflake encodes this in the JWT issuer claim so the
|
|
* server can match the signature against the registered public key.
|
|
* Reference: https://docs.snowflake.com/en/developer-guide/sql-api/authenticating
|
|
*/
|
|
function computePublicKeyFingerprint(privateKeyPem: string): string {
|
|
const publicKey = createPublicKey({ key: privateKeyPem, format: 'pem' })
|
|
const spkiDer = publicKey.export({ type: 'spki', format: 'der' })
|
|
return `SHA256:${createHash('sha256').update(spkiDer).digest('base64')}`
|
|
}
|
|
|
|
interface JwtCacheEntry {
|
|
token: string
|
|
expiresAt: number
|
|
}
|
|
|
|
async function buildJwt(
|
|
account: string,
|
|
user: string,
|
|
privateKeyPem: string
|
|
): Promise<JwtCacheEntry> {
|
|
const fingerprint = computePublicKeyFingerprint(privateKeyPem)
|
|
const accountForJwt = normalizeAccountForJwt(account)
|
|
const userUpper = user.toUpperCase()
|
|
const issuer = `${accountForJwt}.${userUpper}.${fingerprint}`
|
|
const subject = `${accountForJwt}.${userUpper}`
|
|
const now = Math.floor(Date.now() / 1000)
|
|
const exp = now + JWT_LIFETIME_SECONDS
|
|
let privateKey: Awaited<ReturnType<typeof importPKCS8>>
|
|
try {
|
|
privateKey = await importPKCS8(privateKeyPem, 'RS256')
|
|
} catch (error) {
|
|
throw new Error(
|
|
`privateKey must be an unencrypted PKCS#8 PEM (-----BEGIN PRIVATE KEY-----). ` +
|
|
`Convert PKCS#1 with: openssl pkcs8 -topk8 -nocrypt -in rsa.pem -out pkcs8.pem. ` +
|
|
`Decrypt encrypted PEMs first. Underlying error: ${toError(error).message}`
|
|
)
|
|
}
|
|
const token = await new SignJWT({})
|
|
.setProtectedHeader({ alg: 'RS256', typ: 'JWT' })
|
|
.setIssuer(issuer)
|
|
.setSubject(subject)
|
|
.setIssuedAt(now)
|
|
.setExpirationTime(exp)
|
|
.sign(privateKey)
|
|
return { token, expiresAt: exp - JWT_CACHE_SAFETY_MARGIN_SECONDS }
|
|
}
|
|
|
|
/**
|
|
* Quotes a Snowflake identifier so that whatever case the user typed is
|
|
* preserved exactly. Without quoting, Snowflake folds unquoted identifiers
|
|
* to uppercase, which silently breaks any table whose canonical name was
|
|
* created with quoted mixed-case. Embedded `"` is escaped as `""`.
|
|
*/
|
|
function quoteIdentifier(name: string): string {
|
|
return `"${name.replace(/"/g, '""')}"`
|
|
}
|
|
|
|
function buildStatement(config: SnowflakeDestinationConfig, rowCount: number): string {
|
|
const column = quoteIdentifier(config.column ?? 'DATA')
|
|
const target = `${quoteIdentifier(config.database)}.${quoteIdentifier(config.schema)}.${quoteIdentifier(config.table)}`
|
|
const placeholders = Array.from({ length: rowCount }, () => '(PARSE_JSON(?))').join(', ')
|
|
return `INSERT INTO ${target} (${column}) VALUES ${placeholders}`
|
|
}
|
|
|
|
function isRetryableStatus(status: number): boolean {
|
|
return status === 408 || status === 429 || (status >= 500 && status <= 599)
|
|
}
|
|
|
|
interface ExecuteInput {
|
|
config: SnowflakeDestinationConfig
|
|
getJwt: () => Promise<string>
|
|
statement: string
|
|
bindings: string[]
|
|
signal: AbortSignal
|
|
}
|
|
|
|
async function executeStatement(input: ExecuteInput): Promise<void> {
|
|
for (const value of input.bindings) {
|
|
const bytes = Buffer.byteLength(value, 'utf8')
|
|
if (bytes > VARIANT_MAX_BYTES) {
|
|
throw new Error(
|
|
`Snowflake VARIANT value exceeds 16 MB limit (got ${bytes} bytes); split the row before delivery`
|
|
)
|
|
}
|
|
}
|
|
const baseUrl = `https://${input.config.account}.snowflakecomputing.com/api/v2/statements`
|
|
const bindings: Record<string, { type: 'TEXT'; value: string }> = {}
|
|
input.bindings.forEach((value, index) => {
|
|
bindings[(index + 1).toString()] = { type: 'TEXT', value }
|
|
})
|
|
const body = {
|
|
statement: input.statement,
|
|
timeout: SQL_API_TIMEOUT_SECONDS,
|
|
warehouse: input.config.warehouse,
|
|
role: input.config.role,
|
|
bindings,
|
|
}
|
|
const serializedBody = JSON.stringify(body)
|
|
/** Stable per-request UUID enables idempotent retries via `retry=true` on subsequent attempts. */
|
|
const requestId = generateId()
|
|
|
|
let lastError: unknown
|
|
for (let attempt = 1; attempt <= EXECUTE_MAX_ATTEMPTS; attempt++) {
|
|
if (input.signal.aborted) throw input.signal.reason ?? new Error('Aborted')
|
|
/** Acquire JWT before starting the per-attempt timer so token signing doesn't eat the network budget (mirrors pollStatement). */
|
|
const jwt = await input.getJwt()
|
|
const perAttempt = AbortSignal.any([input.signal, AbortSignal.timeout(PER_ATTEMPT_TIMEOUT_MS)])
|
|
const params = new URLSearchParams({ requestId })
|
|
if (attempt > 1) params.set('retry', 'true')
|
|
const url = `${baseUrl}?${params.toString()}`
|
|
let response: Response
|
|
try {
|
|
response = await fetch(url, {
|
|
method: 'POST',
|
|
headers: {
|
|
Authorization: `Bearer ${jwt}`,
|
|
'Content-Type': 'application/json',
|
|
Accept: 'application/json',
|
|
'X-Snowflake-Authorization-Token-Type': 'KEYPAIR_JWT',
|
|
'User-Agent': 'sim-data-drain/1.0',
|
|
},
|
|
body: serializedBody,
|
|
signal: perAttempt,
|
|
})
|
|
} catch (error) {
|
|
lastError = error
|
|
logger.warn('Snowflake request failed', {
|
|
attempt,
|
|
error: toError(error).message,
|
|
})
|
|
if (input.signal.aborted || attempt === EXECUTE_MAX_ATTEMPTS) throw error
|
|
await sleepUntilAborted(
|
|
backoffWithJitter(attempt, null, {
|
|
baseMs: EXECUTE_RETRY_BASE_DELAY_MS,
|
|
maxMs: EXECUTE_RETRY_MAX_DELAY_MS,
|
|
}),
|
|
input.signal
|
|
)
|
|
continue
|
|
}
|
|
if (response.status === 202) {
|
|
const json = (await response.json().catch(() => ({}))) as { statementHandle?: string }
|
|
if (!json.statementHandle) {
|
|
throw new Error('Snowflake returned 202 without a statementHandle')
|
|
}
|
|
await pollStatement({
|
|
account: input.config.account,
|
|
getJwt: input.getJwt,
|
|
handle: json.statementHandle,
|
|
signal: input.signal,
|
|
})
|
|
return
|
|
}
|
|
if (response.ok) {
|
|
/**
|
|
* Synchronous completions return 200 — same statement-level error envelope as
|
|
* the polled 200 path, so check `sqlState` here too instead of silently passing
|
|
* failures. Consuming the body also lets undici reuse the socket.
|
|
*/
|
|
const completion = (await response.json().catch(() => ({}))) as {
|
|
code?: string
|
|
sqlState?: string
|
|
message?: string
|
|
}
|
|
if (completion.sqlState && completion.sqlState !== '00000') {
|
|
throw new Error(
|
|
`Snowflake statement failed (sqlState ${completion.sqlState}${completion.code ? `, code ${completion.code}` : ''}): ${completion.message ?? ''}`
|
|
)
|
|
}
|
|
return
|
|
}
|
|
const text = await response.text().catch(() => '')
|
|
const error = new Error(`Snowflake responded with HTTP ${response.status}: ${text}`)
|
|
if (!isRetryableStatus(response.status) || attempt === EXECUTE_MAX_ATTEMPTS) throw error
|
|
lastError = error
|
|
const retryAfterMs = parseRetryAfter(response.headers.get('Retry-After'))
|
|
const delay = backoffWithJitter(attempt, retryAfterMs, {
|
|
baseMs: EXECUTE_RETRY_BASE_DELAY_MS,
|
|
maxMs: EXECUTE_RETRY_MAX_DELAY_MS,
|
|
})
|
|
logger.warn('Snowflake request retrying after retryable status', {
|
|
attempt,
|
|
status: response.status,
|
|
delayMs: delay,
|
|
})
|
|
await sleepUntilAborted(delay, input.signal)
|
|
}
|
|
throw lastError ?? new Error('Snowflake request failed after retries')
|
|
}
|
|
|
|
interface PollInput {
|
|
account: string
|
|
/** Thunk so long polls (past 55min) refresh the JWT instead of dying with 401. */
|
|
getJwt: () => Promise<string>
|
|
handle: string
|
|
signal: AbortSignal
|
|
}
|
|
|
|
/** Snowflake returns 202 while still executing and 200 on completion (async statement-handle semantics). */
|
|
async function pollStatement(input: PollInput): Promise<void> {
|
|
const url = `https://${input.account}.snowflakecomputing.com/api/v2/statements/${encodeURIComponent(input.handle)}`
|
|
const deadline = Date.now() + POLL_DEADLINE_MS
|
|
let interval = POLL_INITIAL_INTERVAL_MS
|
|
let skipIntervalSleep = true
|
|
let retryAttempt = 0
|
|
while (Date.now() < deadline) {
|
|
if (input.signal.aborted) throw input.signal.reason ?? new Error('Aborted')
|
|
if (!skipIntervalSleep) {
|
|
await sleepUntilAborted(interval, input.signal)
|
|
}
|
|
skipIntervalSleep = false
|
|
const jwt = await input.getJwt()
|
|
const perAttempt = AbortSignal.any([input.signal, AbortSignal.timeout(PER_ATTEMPT_TIMEOUT_MS)])
|
|
let response: Response
|
|
try {
|
|
response = await fetch(url, {
|
|
headers: {
|
|
Authorization: `Bearer ${jwt}`,
|
|
Accept: 'application/json',
|
|
'X-Snowflake-Authorization-Token-Type': 'KEYPAIR_JWT',
|
|
},
|
|
signal: perAttempt,
|
|
})
|
|
} catch (error) {
|
|
if (input.signal.aborted) throw error
|
|
retryAttempt++
|
|
if (retryAttempt > POLL_MAX_CONSECUTIVE_RETRIES) throw error
|
|
const delay = backoffWithJitter(retryAttempt, null, {
|
|
baseMs: EXECUTE_RETRY_BASE_DELAY_MS,
|
|
maxMs: EXECUTE_RETRY_MAX_DELAY_MS,
|
|
})
|
|
logger.warn('Snowflake poll request failed, retrying', {
|
|
attempt: retryAttempt,
|
|
delayMs: delay,
|
|
error: toError(error).message,
|
|
})
|
|
await sleepUntilAborted(delay, input.signal)
|
|
skipIntervalSleep = true
|
|
continue
|
|
}
|
|
if (response.status === 202) {
|
|
/** Drain the body so undici can return the socket to the keep-alive pool between polls. */
|
|
await response.text().catch(() => '')
|
|
retryAttempt = 0
|
|
interval = Math.min(interval * 2, POLL_MAX_INTERVAL_MS)
|
|
continue
|
|
}
|
|
if (isRetryableStatus(response.status)) {
|
|
retryAttempt++
|
|
if (retryAttempt > POLL_MAX_CONSECUTIVE_RETRIES) {
|
|
/** Drain the body so undici can return the socket to the keep-alive pool. */
|
|
const text = await response.text().catch(() => '')
|
|
throw new Error(
|
|
`Snowflake poll failed after ${POLL_MAX_CONSECUTIVE_RETRIES} consecutive retries (HTTP ${response.status}): ${text}`
|
|
)
|
|
}
|
|
const retryAfterMs = parseRetryAfter(response.headers.get('Retry-After'))
|
|
const delay = backoffWithJitter(retryAttempt, retryAfterMs, {
|
|
baseMs: EXECUTE_RETRY_BASE_DELAY_MS,
|
|
maxMs: EXECUTE_RETRY_MAX_DELAY_MS,
|
|
})
|
|
logger.warn('Snowflake poll retrying after retryable status', {
|
|
attempt: retryAttempt,
|
|
status: response.status,
|
|
delayMs: delay,
|
|
})
|
|
/** Drain the body so undici can return the socket to the keep-alive pool between retries. */
|
|
await response.text().catch(() => '')
|
|
await sleepUntilAborted(delay, input.signal)
|
|
skipIntervalSleep = true
|
|
continue
|
|
}
|
|
if (!response.ok) {
|
|
const text = await response.text().catch(() => '')
|
|
throw new Error(`Snowflake poll failed (HTTP ${response.status}): ${text}`)
|
|
}
|
|
/**
|
|
* Snowflake SQL API can return 200 with a statement-level error envelope
|
|
* (`code` / `sqlState` / `message`). Successful completions return
|
|
* `code === "090001"` ("statement executed successfully") or omit `code`,
|
|
* while statement errors come back with codes like `"002032"` and
|
|
* a populated `sqlState`. Treat anything with a `sqlState` as a failure.
|
|
*/
|
|
const completion = (await response.json().catch(() => ({}))) as {
|
|
code?: string
|
|
sqlState?: string
|
|
message?: string
|
|
}
|
|
if (completion.sqlState && completion.sqlState !== '00000') {
|
|
throw new Error(
|
|
`Snowflake statement failed (sqlState ${completion.sqlState}${completion.code ? `, code ${completion.code}` : ''}): ${completion.message ?? ''}`
|
|
)
|
|
}
|
|
return
|
|
}
|
|
throw new Error('Snowflake statement did not complete within the polling deadline')
|
|
}
|
|
|
|
export const snowflakeDestination: DrainDestination<
|
|
SnowflakeDestinationConfig,
|
|
SnowflakeDestinationCredentials
|
|
> = {
|
|
type: 'snowflake',
|
|
displayName: 'Snowflake',
|
|
configSchema: snowflakeConfigSchema,
|
|
credentialsSchema: snowflakeCredentialsSchema,
|
|
|
|
async test({ config, credentials, signal }) {
|
|
let cached: JwtCacheEntry | null = null
|
|
async function getJwt(): Promise<string> {
|
|
const now = Math.floor(Date.now() / 1000)
|
|
if (cached && cached.expiresAt > now) return cached.token
|
|
cached = await buildJwt(config.account, config.user, credentials.privateKey)
|
|
return cached.token
|
|
}
|
|
await executeStatement({
|
|
config,
|
|
getJwt,
|
|
statement: 'SELECT 1',
|
|
bindings: [],
|
|
signal,
|
|
})
|
|
},
|
|
|
|
openSession({ config, credentials }) {
|
|
let cached: JwtCacheEntry | null = null
|
|
async function getJwt(): Promise<string> {
|
|
const now = Math.floor(Date.now() / 1000)
|
|
if (cached && cached.expiresAt > now) return cached.token
|
|
cached = await buildJwt(config.account, config.user, credentials.privateKey)
|
|
return cached.token
|
|
}
|
|
return {
|
|
async deliver({ body, metadata, signal }) {
|
|
/**
|
|
* Bind the original line bytes — not `JSON.stringify(JSON.parse(line))` —
|
|
* so JSON numbers outside the JS safe-integer range (e.g. Snowflake
|
|
* NUMBER columns past 2^53-1) survive into VARIANT intact. We still
|
|
* parse each line so a malformed payload fails fast at the runner.
|
|
*/
|
|
const text = body.toString('utf8')
|
|
const rows: string[] = []
|
|
const lines = text.split(/\r?\n/)
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const line = lines[i]
|
|
if (line.length === 0) continue
|
|
try {
|
|
JSON.parse(line)
|
|
} catch (error) {
|
|
throw new Error(
|
|
`Snowflake NDJSON parse failed at line ${i + 1}: ${toError(error).message}`
|
|
)
|
|
}
|
|
rows.push(line)
|
|
}
|
|
if (rows.length === 0) {
|
|
return {
|
|
locator: `snowflake://${config.account}/${config.database}.${config.schema}.${config.table}#${metadata.runId}-${metadata.sequence}`,
|
|
}
|
|
}
|
|
await executeStatement({
|
|
config,
|
|
getJwt,
|
|
statement: buildStatement(config, rows.length),
|
|
bindings: rows,
|
|
signal,
|
|
})
|
|
logger.debug('Snowflake chunk delivered', {
|
|
account: config.account,
|
|
table: `${config.database}.${config.schema}.${config.table}`,
|
|
rows: rows.length,
|
|
})
|
|
return {
|
|
locator: `snowflake://${config.account}/${config.database}.${config.schema}.${config.table}#${metadata.runId}-${metadata.sequence}`,
|
|
}
|
|
},
|
|
async close() {},
|
|
}
|
|
},
|
|
}
|