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
568 lines
19 KiB
TypeScript
568 lines
19 KiB
TypeScript
import { z } from 'zod'
|
|
import { defineRouteContract } from '@/lib/api/contracts/types'
|
|
import { validateExternalUrl } from '@/lib/core/security/input-validation'
|
|
import { CADENCE_TYPES, DESTINATION_TYPES, SOURCE_TYPES } from '@/lib/data-drains/types'
|
|
|
|
/** AWS S3 bucket: 3-63 chars, lowercase alnum + . / -, see s3.ts for full rules. */
|
|
const S3_BUCKET_NAME_RE = /^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/
|
|
const S3_IPV4_LIKE_RE = /^(\d{1,3}\.){3}\d{1,3}$/
|
|
const AWS_REGION_RE = /^[a-z]{2,}(-[a-z]+)+-\d+$/
|
|
/** GCS bucket component: lowercase alnum + _ / -, start/end alnum. Mirrors gcs.ts. */
|
|
const GCS_BUCKET_COMPONENT_RE = /^[a-z0-9]([a-z0-9_-]*[a-z0-9])?$/
|
|
const GOOGLE_RESERVED_PREFIX_RE = /^(goog|google|g00gle)/i
|
|
const GOOGLE_CONTAINS_RE = /(google|g00gle)/i
|
|
function validateGcsBucketComponents(v: string): string | null {
|
|
if (v.length < 3 || v.length > 222) return 'bucket must be 3-222 characters'
|
|
const components = v.split('.')
|
|
for (const c of components) {
|
|
if (c.length < 1 || c.length > 63) {
|
|
return 'each dot-separated component must be 1-63 characters'
|
|
}
|
|
if (!GCS_BUCKET_COMPONENT_RE.test(c)) {
|
|
return 'each component must be lowercase, start/end alphanumeric, letters/digits/_/- only'
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
/** Azure storage account: 3-24 lowercase alnum. */
|
|
const AZURE_ACCOUNT_NAME_RE = /^[a-z0-9]{3,24}$/
|
|
/** Azure container: 3-63 chars, lowercase alnum + single hyphens. */
|
|
const AZURE_CONTAINER_NAME_RE = /^[a-z0-9]([a-z0-9]|-(?!-))+[a-z0-9]$/
|
|
/** Azure Blob Storage endpoint suffixes (Public, US Gov, China, Germany). */
|
|
const AZURE_ENDPOINT_SUFFIXES = [
|
|
'blob.core.windows.net',
|
|
'blob.core.usgovcloudapi.net',
|
|
'blob.core.chinacloudapi.cn',
|
|
'blob.core.cloudapi.de',
|
|
] as const
|
|
/** BigQuery project / dataset / table identifiers. */
|
|
const BQ_PROJECT_ID_RE = /^([a-z][a-z0-9.-]{0,61}[a-z0-9]:)?[a-z][a-z0-9-]{4,28}[a-z0-9]$/
|
|
const BQ_DATASET_RE = /^[A-Za-z0-9_]{1,1024}$/
|
|
const BQ_TABLE_RE = /^[\p{L}\p{M}\p{N}\p{Pc}\p{Pd} ]{1,1024}$/u
|
|
/** Snowflake account + identifier shapes — mirrored from snowflake.ts. */
|
|
const SNOWFLAKE_ACCOUNT_ORG_RE = /^[A-Za-z0-9][A-Za-z0-9_]*(?:-[A-Za-z0-9_]+)+$/
|
|
/** First segment allows hyphens so org-account identifiers carrying a region/cloud suffix match. Mirrors snowflake.ts. */
|
|
const SNOWFLAKE_ACCOUNT_LOCATOR_RE =
|
|
/^[A-Za-z0-9][A-Za-z0-9_-]*(?:\.[A-Za-z0-9][A-Za-z0-9_-]*){0,2}$/
|
|
const SNOWFLAKE_IDENTIFIER_RE = /^[A-Za-z_][A-Za-z0-9_$]{0,254}$/
|
|
/** Reserved Sim-namespaced header names that cannot be reused as the webhook signature header. */
|
|
const RESERVED_WEBHOOK_SIGNATURE_HEADER_NAMES = new Set([
|
|
'authorization',
|
|
'content-type',
|
|
'user-agent',
|
|
'idempotency-key',
|
|
'x-sim-timestamp',
|
|
'x-sim-signature-version',
|
|
'x-sim-drain-id',
|
|
'x-sim-run-id',
|
|
'x-sim-source',
|
|
'x-sim-sequence',
|
|
'x-sim-row-count',
|
|
'x-sim-probe',
|
|
'x-sim-signature',
|
|
])
|
|
|
|
export const dataDrainSourceSchema = z.enum(SOURCE_TYPES)
|
|
export const dataDrainDestinationTypeSchema = z.enum(DESTINATION_TYPES)
|
|
export const dataDrainCadenceSchema = z.enum(CADENCE_TYPES)
|
|
export const dataDrainRunStatusSchema = z.enum(['running', 'success', 'failed'])
|
|
export const dataDrainRunTriggerSchema = z.enum(['cron', 'manual'])
|
|
|
|
export const dataDrainOrgParamsSchema = z.object({
|
|
id: z.string().min(1, 'organization id is required'),
|
|
})
|
|
|
|
export const dataDrainParamsSchema = z.object({
|
|
id: z.string().min(1, 'organization id is required'),
|
|
drainId: z.string().min(1, 'drain id is required'),
|
|
})
|
|
|
|
const drainNameSchema = z.string().trim().min(1, 'name is required').max(120)
|
|
|
|
const s3ConfigBodySchema = z.object({
|
|
bucket: z
|
|
.string()
|
|
.min(3, 'bucket must be 3-63 characters')
|
|
.max(63, 'bucket must be 3-63 characters')
|
|
.refine((v) => S3_BUCKET_NAME_RE.test(v), {
|
|
message: 'bucket must be lowercase, 3-63 chars, start/end alphanumeric',
|
|
})
|
|
.refine((v) => !v.includes('..'), { message: 'bucket must not contain consecutive dots' })
|
|
.refine((v) => !v.includes('-.') && !v.includes('.-'), {
|
|
message: 'bucket must not contain a dash adjacent to a dot',
|
|
})
|
|
.refine((v) => !S3_IPV4_LIKE_RE.test(v), { message: 'bucket must not look like an IP address' })
|
|
.refine((v) => !v.startsWith('xn--'), { message: 'bucket must not start with "xn--"' })
|
|
.refine((v) => !v.startsWith('sthree-'), { message: 'bucket must not start with "sthree-"' })
|
|
.refine((v) => !v.startsWith('amzn-s3-demo-'), {
|
|
message: 'bucket must not start with "amzn-s3-demo-" (reserved by AWS)',
|
|
})
|
|
.refine(
|
|
(v) =>
|
|
!v.endsWith('-s3alias') &&
|
|
!v.endsWith('--ol-s3') &&
|
|
!v.endsWith('.mrap') &&
|
|
!v.endsWith('--x-s3') &&
|
|
!v.endsWith('--table-s3'),
|
|
{
|
|
message:
|
|
'bucket must not end with reserved suffix (-s3alias, --ol-s3, .mrap, --x-s3, --table-s3)',
|
|
}
|
|
),
|
|
region: z
|
|
.string()
|
|
.min(1, 'region is required')
|
|
.max(32, 'region is too long')
|
|
.refine((v) => AWS_REGION_RE.test(v), {
|
|
message: 'region must look like an AWS region code, e.g. us-east-1',
|
|
}),
|
|
prefix: z
|
|
.string()
|
|
.max(512)
|
|
.refine((v) => Buffer.byteLength(v, 'utf8') <= 512, {
|
|
message: 'prefix must be at most 512 bytes (UTF-8)',
|
|
})
|
|
.optional(),
|
|
endpoint: z
|
|
.string()
|
|
.url()
|
|
.refine((v) => v.startsWith('https://'), { message: 'endpoint must use https://' })
|
|
.refine((value) => validateExternalUrl(value, 'endpoint').isValid, {
|
|
message: 'endpoint must be HTTPS and not point at a private, loopback, or metadata address',
|
|
})
|
|
.optional(),
|
|
forcePathStyle: z.boolean().optional(),
|
|
})
|
|
|
|
const s3CredentialsBodySchema = z.object({
|
|
accessKeyId: z.string().min(1, 'accessKeyId is required'),
|
|
secretAccessKey: z.string().min(1, 'secretAccessKey is required'),
|
|
})
|
|
|
|
const gcsConfigBodySchema = z.object({
|
|
bucket: z
|
|
.string()
|
|
.min(3, 'bucket must be 3-222 characters')
|
|
.max(222, 'bucket must be 3-222 characters')
|
|
.superRefine((v, ctx) => {
|
|
const err = validateGcsBucketComponents(v)
|
|
if (err) ctx.addIssue({ code: z.ZodIssueCode.custom, message: err })
|
|
})
|
|
.refine((v) => !S3_IPV4_LIKE_RE.test(v), { message: 'bucket must not look like an IP address' })
|
|
.refine((v) => !v.includes('..'), { message: 'bucket must not contain consecutive dots' })
|
|
.refine((v) => !v.includes('-.') && !v.includes('.-'), {
|
|
message: 'bucket must not contain "-." or ".-"',
|
|
})
|
|
.refine((v) => !GOOGLE_RESERVED_PREFIX_RE.test(v) && !GOOGLE_CONTAINS_RE.test(v), {
|
|
message: 'bucket name cannot begin with "goog" or contain "google" / close misspellings',
|
|
}),
|
|
prefix: z
|
|
.string()
|
|
.max(512)
|
|
.refine((v) => Buffer.byteLength(v, 'utf8') <= 512, {
|
|
message: 'prefix must be at most 512 bytes (UTF-8)',
|
|
})
|
|
.refine((v) => !v.startsWith('.well-known/acme-challenge/'), {
|
|
message: 'prefix must not start with ".well-known/acme-challenge/" (reserved by GCS)',
|
|
})
|
|
.optional(),
|
|
})
|
|
|
|
const gcsCredentialsBodySchema = z.object({
|
|
serviceAccountJson: z.string().min(1, 'serviceAccountJson is required'),
|
|
})
|
|
|
|
const azureBlobConfigBodySchema = z.object({
|
|
accountName: z
|
|
.string()
|
|
.min(1, 'accountName is required')
|
|
.refine((v) => AZURE_ACCOUNT_NAME_RE.test(v), {
|
|
message: 'accountName must be 3-24 lowercase letters or digits',
|
|
}),
|
|
containerName: z
|
|
.string()
|
|
.min(3, 'containerName must be 3-63 characters')
|
|
.max(63)
|
|
.refine((v) => AZURE_CONTAINER_NAME_RE.test(v), {
|
|
message: 'containerName must use lowercase letters, digits, or single hyphens',
|
|
}),
|
|
prefix: z.string().max(512).optional(),
|
|
endpointSuffix: z
|
|
.string()
|
|
.refine((v) => (AZURE_ENDPOINT_SUFFIXES as readonly string[]).includes(v), {
|
|
message: `endpointSuffix must be one of: ${AZURE_ENDPOINT_SUFFIXES.join(', ')}`,
|
|
})
|
|
.optional(),
|
|
})
|
|
|
|
const azureBlobCredentialsBodySchema = z.object({
|
|
accountKey: z
|
|
.string()
|
|
.length(88, 'accountKey must be 88 base64 characters (64-byte Azure storage key)')
|
|
.regex(/^[A-Za-z0-9+/]+={0,2}$/, {
|
|
message: 'accountKey must be a base64-encoded Azure storage account key',
|
|
}),
|
|
})
|
|
|
|
const DATADOG_TAG_PAIR_RE = /^[A-Za-z][A-Za-z0-9_./-]*:[^,\s][^,]*$/
|
|
|
|
const datadogConfigBodySchema = z.object({
|
|
site: z.enum(['us1', 'us3', 'us5', 'eu1', 'ap1', 'ap2', 'gov']),
|
|
service: z.string().min(1).max(100).optional(),
|
|
tags: z
|
|
.string()
|
|
.min(1)
|
|
.max(1024)
|
|
.refine(
|
|
(v) =>
|
|
v
|
|
.split(',')
|
|
.map((t) => t.trim())
|
|
.filter((t) => t.length > 0)
|
|
.every((t) => DATADOG_TAG_PAIR_RE.test(t)),
|
|
{ message: 'tags must be comma-separated key:value pairs' }
|
|
)
|
|
.optional(),
|
|
})
|
|
|
|
const datadogCredentialsBodySchema = z.object({
|
|
apiKey: z.string().min(1, 'apiKey is required'),
|
|
})
|
|
|
|
const bigqueryConfigBodySchema = z.object({
|
|
projectId: z
|
|
.string()
|
|
.min(6, 'projectId is required')
|
|
.max(94)
|
|
.refine((v) => BQ_PROJECT_ID_RE.test(v), {
|
|
message: 'projectId must match Google Cloud project ID rules',
|
|
}),
|
|
datasetId: z
|
|
.string()
|
|
.min(1, 'datasetId is required')
|
|
.refine((v) => BQ_DATASET_RE.test(v), {
|
|
message: 'datasetId may only contain letters, digits, and underscores (max 1024)',
|
|
}),
|
|
tableId: z
|
|
.string()
|
|
.min(1, 'tableId is required')
|
|
.refine((v) => BQ_TABLE_RE.test(v), {
|
|
message:
|
|
'tableId may contain Unicode letters, marks, numbers, connectors, dashes, and spaces (max 1024)',
|
|
})
|
|
.refine((v) => Buffer.byteLength(v, 'utf8') <= 1024, {
|
|
message: 'tableId must be at most 1024 bytes (UTF-8)',
|
|
}),
|
|
})
|
|
|
|
const bigqueryCredentialsBodySchema = z.object({
|
|
serviceAccountJson: z.string().min(1, 'serviceAccountJson is required'),
|
|
})
|
|
|
|
const snowflakeConfigBodySchema = z.object({
|
|
account: z
|
|
.string()
|
|
.min(3, 'account is required')
|
|
.max(256)
|
|
.refine((v) => SNOWFLAKE_ACCOUNT_ORG_RE.test(v) || SNOWFLAKE_ACCOUNT_LOCATOR_RE.test(v), {
|
|
message:
|
|
'account must be a Snowflake org-account identifier (orgname-accountname) or legacy locator (locator[.region[.cloud]])',
|
|
}),
|
|
user: z.string().min(1, 'user is required').regex(SNOWFLAKE_IDENTIFIER_RE, {
|
|
message: 'user must be a valid Snowflake identifier',
|
|
}),
|
|
warehouse: z.string().min(1).regex(SNOWFLAKE_IDENTIFIER_RE, {
|
|
message: 'warehouse must be a valid Snowflake identifier',
|
|
}),
|
|
database: z.string().min(1).regex(SNOWFLAKE_IDENTIFIER_RE, {
|
|
message: 'database must be a valid Snowflake identifier',
|
|
}),
|
|
schema: z.string().min(1).regex(SNOWFLAKE_IDENTIFIER_RE, {
|
|
message: 'schema must be a valid Snowflake identifier',
|
|
}),
|
|
table: z.string().min(1).regex(SNOWFLAKE_IDENTIFIER_RE, {
|
|
message: 'table must be a valid Snowflake identifier',
|
|
}),
|
|
column: z
|
|
.string()
|
|
.min(1)
|
|
.regex(SNOWFLAKE_IDENTIFIER_RE, { message: 'column must be a valid Snowflake identifier' })
|
|
.optional(),
|
|
role: z
|
|
.string()
|
|
.min(1)
|
|
.regex(SNOWFLAKE_IDENTIFIER_RE, { message: 'role must be a valid Snowflake identifier' })
|
|
.optional(),
|
|
})
|
|
|
|
const snowflakeCredentialsBodySchema = z.object({
|
|
privateKey: z.string().min(1, 'privateKey is required'),
|
|
})
|
|
|
|
const webhookConfigBodySchema = z.object({
|
|
url: z
|
|
.string()
|
|
.url('url must be a valid URL')
|
|
.max(2048, 'url must be at most 2048 characters')
|
|
.refine((value) => validateExternalUrl(value, 'url').isValid, {
|
|
message: 'url must be HTTPS and not point at a private, loopback, or metadata address',
|
|
}),
|
|
signatureHeader: z
|
|
.string()
|
|
.min(1)
|
|
.max(128)
|
|
.refine((value) => !RESERVED_WEBHOOK_SIGNATURE_HEADER_NAMES.has(value.toLowerCase()), {
|
|
message: 'signatureHeader cannot reuse a reserved Sim header name',
|
|
})
|
|
.refine((value) => /^[A-Za-z0-9\-_]+$/.test(value) && !/[\r\n\0]/.test(value), {
|
|
message: 'signatureHeader must contain only letters, digits, hyphens, and underscores',
|
|
})
|
|
.optional(),
|
|
})
|
|
|
|
const webhookCredentialsBodySchema = z.object({
|
|
signingSecret: z
|
|
.string()
|
|
.min(32, 'signingSecret must be at least 32 characters')
|
|
.max(512, 'signingSecret must be at most 512 characters'),
|
|
bearerToken: z
|
|
.string()
|
|
.min(1)
|
|
.max(4096, 'bearerToken must be at most 4096 characters')
|
|
.refine((value) => !/[\r\n\0]/.test(value), {
|
|
message: 'bearerToken cannot contain CR, LF, or NUL characters',
|
|
})
|
|
.optional(),
|
|
})
|
|
|
|
/**
|
|
* Discriminated body shape used by both create and update. Each destination
|
|
* variant carries its own typed `destinationConfig` and optional
|
|
* `destinationCredentials`. On update, omitting `destinationCredentials`
|
|
* leaves the encrypted blob in place.
|
|
*/
|
|
export const dataDrainDestinationBodySchema = z.discriminatedUnion('destinationType', [
|
|
z.object({
|
|
destinationType: z.literal('s3'),
|
|
destinationConfig: s3ConfigBodySchema,
|
|
destinationCredentials: s3CredentialsBodySchema.optional(),
|
|
}),
|
|
z.object({
|
|
destinationType: z.literal('gcs'),
|
|
destinationConfig: gcsConfigBodySchema,
|
|
destinationCredentials: gcsCredentialsBodySchema.optional(),
|
|
}),
|
|
z.object({
|
|
destinationType: z.literal('azure_blob'),
|
|
destinationConfig: azureBlobConfigBodySchema,
|
|
destinationCredentials: azureBlobCredentialsBodySchema.optional(),
|
|
}),
|
|
z.object({
|
|
destinationType: z.literal('datadog'),
|
|
destinationConfig: datadogConfigBodySchema,
|
|
destinationCredentials: datadogCredentialsBodySchema.optional(),
|
|
}),
|
|
z.object({
|
|
destinationType: z.literal('bigquery'),
|
|
destinationConfig: bigqueryConfigBodySchema,
|
|
destinationCredentials: bigqueryCredentialsBodySchema.optional(),
|
|
}),
|
|
z.object({
|
|
destinationType: z.literal('snowflake'),
|
|
destinationConfig: snowflakeConfigBodySchema,
|
|
destinationCredentials: snowflakeCredentialsBodySchema.optional(),
|
|
}),
|
|
z.object({
|
|
destinationType: z.literal('webhook'),
|
|
destinationConfig: webhookConfigBodySchema,
|
|
destinationCredentials: webhookCredentialsBodySchema.optional(),
|
|
}),
|
|
])
|
|
|
|
const drainCommonBodyFieldsSchema = z.object({
|
|
name: drainNameSchema,
|
|
source: dataDrainSourceSchema,
|
|
scheduleCadence: dataDrainCadenceSchema,
|
|
enabled: z.boolean().optional(),
|
|
})
|
|
|
|
export const createDataDrainBodySchema = z.intersection(
|
|
drainCommonBodyFieldsSchema,
|
|
dataDrainDestinationBodySchema
|
|
)
|
|
|
|
/**
|
|
* Update bodies are partial — every field is optional. We deliberately don't
|
|
* use a discriminated union here: clients sending `{ enabled: false }` should
|
|
* not be forced to also send `destinationType`. The route validates the
|
|
* destination payloads against the typed `configSchema` / `credentialsSchema`
|
|
* for the existing drain's destination type before persisting, so the
|
|
* structural shape is still enforced — just at the route layer rather than at
|
|
* the contract boundary.
|
|
*/
|
|
export const updateDataDrainBodySchema = drainCommonBodyFieldsSchema.partial().extend({
|
|
destinationType: dataDrainDestinationTypeSchema.optional(),
|
|
destinationConfig: z.record(z.string(), z.unknown()).optional(),
|
|
destinationCredentials: z.record(z.string(), z.unknown()).optional(),
|
|
})
|
|
|
|
const drainDestinationResponseSchema = z.discriminatedUnion('destinationType', [
|
|
z.object({
|
|
destinationType: z.literal('s3'),
|
|
destinationConfig: s3ConfigBodySchema,
|
|
}),
|
|
z.object({
|
|
destinationType: z.literal('gcs'),
|
|
destinationConfig: gcsConfigBodySchema,
|
|
}),
|
|
z.object({
|
|
destinationType: z.literal('azure_blob'),
|
|
destinationConfig: azureBlobConfigBodySchema,
|
|
}),
|
|
z.object({
|
|
destinationType: z.literal('datadog'),
|
|
destinationConfig: datadogConfigBodySchema,
|
|
}),
|
|
z.object({
|
|
destinationType: z.literal('bigquery'),
|
|
destinationConfig: bigqueryConfigBodySchema,
|
|
}),
|
|
z.object({
|
|
destinationType: z.literal('snowflake'),
|
|
destinationConfig: snowflakeConfigBodySchema,
|
|
}),
|
|
z.object({
|
|
destinationType: z.literal('webhook'),
|
|
destinationConfig: webhookConfigBodySchema,
|
|
}),
|
|
])
|
|
|
|
const drainCommonResponseFieldsSchema = z.object({
|
|
id: z.string(),
|
|
organizationId: z.string(),
|
|
name: z.string(),
|
|
source: dataDrainSourceSchema,
|
|
scheduleCadence: dataDrainCadenceSchema,
|
|
enabled: z.boolean(),
|
|
cursor: z.string().nullable(),
|
|
lastRunAt: z.string().nullable(),
|
|
lastSuccessAt: z.string().nullable(),
|
|
createdBy: z.string(),
|
|
createdAt: z.string(),
|
|
updatedAt: z.string(),
|
|
})
|
|
|
|
export const dataDrainSchema = z.intersection(
|
|
drainCommonResponseFieldsSchema,
|
|
drainDestinationResponseSchema
|
|
)
|
|
|
|
export type DataDrain = z.output<typeof dataDrainSchema>
|
|
export type CreateDataDrainBody = z.input<typeof createDataDrainBodySchema>
|
|
export type UpdateDataDrainBody = z.input<typeof updateDataDrainBodySchema>
|
|
|
|
export const dataDrainListResponseSchema = z.object({
|
|
drains: z.array(dataDrainSchema),
|
|
})
|
|
|
|
export const dataDrainResponseSchema = z.object({
|
|
drain: dataDrainSchema,
|
|
})
|
|
|
|
export const dataDrainRunSchema = z.object({
|
|
id: z.string(),
|
|
drainId: z.string(),
|
|
status: dataDrainRunStatusSchema,
|
|
trigger: dataDrainRunTriggerSchema,
|
|
startedAt: z.string(),
|
|
finishedAt: z.string().nullable(),
|
|
rowsExported: z.number().int(),
|
|
bytesWritten: z.number().int(),
|
|
cursorBefore: z.string().nullable(),
|
|
cursorAfter: z.string().nullable(),
|
|
error: z.string().nullable(),
|
|
locators: z.array(z.string()),
|
|
})
|
|
|
|
export type DataDrainRun = z.output<typeof dataDrainRunSchema>
|
|
|
|
export const dataDrainRunListResponseSchema = z.object({
|
|
runs: z.array(dataDrainRunSchema),
|
|
})
|
|
|
|
export const runDataDrainResponseSchema = z.object({
|
|
jobId: z.string(),
|
|
})
|
|
|
|
export const testDataDrainResponseSchema = z.object({
|
|
ok: z.literal(true),
|
|
})
|
|
|
|
export const listDataDrainsContract = defineRouteContract({
|
|
method: 'GET',
|
|
path: '/api/organizations/[id]/data-drains',
|
|
params: dataDrainOrgParamsSchema,
|
|
response: { mode: 'json', schema: dataDrainListResponseSchema },
|
|
})
|
|
|
|
export const createDataDrainContract = defineRouteContract({
|
|
method: 'POST',
|
|
path: '/api/organizations/[id]/data-drains',
|
|
params: dataDrainOrgParamsSchema,
|
|
body: createDataDrainBodySchema,
|
|
response: { mode: 'json', schema: dataDrainResponseSchema },
|
|
})
|
|
|
|
export const getDataDrainContract = defineRouteContract({
|
|
method: 'GET',
|
|
path: '/api/organizations/[id]/data-drains/[drainId]',
|
|
params: dataDrainParamsSchema,
|
|
response: { mode: 'json', schema: dataDrainResponseSchema },
|
|
})
|
|
|
|
export const updateDataDrainContract = defineRouteContract({
|
|
method: 'PUT',
|
|
path: '/api/organizations/[id]/data-drains/[drainId]',
|
|
params: dataDrainParamsSchema,
|
|
body: updateDataDrainBodySchema,
|
|
response: { mode: 'json', schema: dataDrainResponseSchema },
|
|
})
|
|
|
|
export const deleteDataDrainContract = defineRouteContract({
|
|
method: 'DELETE',
|
|
path: '/api/organizations/[id]/data-drains/[drainId]',
|
|
params: dataDrainParamsSchema,
|
|
response: { mode: 'json', schema: z.object({ success: z.literal(true) }) },
|
|
})
|
|
|
|
export const runDataDrainContract = defineRouteContract({
|
|
method: 'POST',
|
|
path: '/api/organizations/[id]/data-drains/[drainId]/run',
|
|
params: dataDrainParamsSchema,
|
|
response: { mode: 'json', schema: runDataDrainResponseSchema },
|
|
})
|
|
|
|
export const testDataDrainContract = defineRouteContract({
|
|
method: 'POST',
|
|
path: '/api/organizations/[id]/data-drains/[drainId]/test',
|
|
params: dataDrainParamsSchema,
|
|
response: { mode: 'json', schema: testDataDrainResponseSchema },
|
|
})
|
|
|
|
export const listDataDrainRunsContract = defineRouteContract({
|
|
method: 'GET',
|
|
path: '/api/organizations/[id]/data-drains/[drainId]/runs',
|
|
params: dataDrainParamsSchema,
|
|
query: z
|
|
.object({
|
|
limit: z
|
|
.preprocess(
|
|
(v) => (typeof v === 'string' ? Number.parseInt(v, 10) : v),
|
|
z.number().int().min(1).max(200)
|
|
)
|
|
.optional(),
|
|
})
|
|
.optional(),
|
|
response: { mode: 'json', schema: dataDrainRunListResponseSchema },
|
|
})
|