Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -676,11 +676,13 @@ export const topEvents = defineEndpoint("top_events", {
output: {
event_name: t.string(),
event_count: t.uint64(),
debug_info: t.string().optional(), // May be absent from some responses
},
});

export type TopEventsParams = InferParams<typeof topEvents>;
export type TopEventsOutput = InferOutputRow<typeof topEvents>;
// { event_name: string; event_count: number; debug_info?: string }
```

### Internal Pipes (not exposed as API)
Expand Down Expand Up @@ -906,7 +908,8 @@ const schema = {
unique_users: t.aggregateFunction("uniq", t.string()),

// Modifiers
optional_field: t.string().nullable(),
nullable_field: t.string().nullable(),
optional_output_field: t.string().optional(), // For endpoint output schemas
category: t.string().lowCardinality(),
status: t.string().default("pending"),
event_id: t.uuid().defaultExpr("generateUUIDv4()"),
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@tinybirdco/sdk",
"version": "0.0.70",
"version": "0.0.71",
"description": "TypeScript SDK for Tinybird Forward - define datasources and pipes as TypeScript",
"type": "module",
"main": "./dist/index.js",
Expand Down
14 changes: 14 additions & 0 deletions src/cli/utils/schema-validation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,20 @@ describe("validateOutputSchema", () => {
});
});

it("allows missing optional output columns", () => {
const responseMeta: ColumnMeta[] = [{ name: "id", type: "UInt64" }];

const outputSchema = {
id: { _tinybirdType: "UInt64", _modifiers: {} },
name: { _tinybirdType: "String", _modifiers: { optional: true } },
};

const result = _validateOutputSchema(responseMeta, outputSchema as any);

expect(result.valid).toBe(true);
expect(result.missingColumns).toHaveLength(0);
});

it("detects extra columns", () => {
const responseMeta: ColumnMeta[] = [
{ name: "id", type: "UInt64" },
Expand Down
6 changes: 4 additions & 2 deletions src/cli/utils/schema-validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,8 +219,10 @@ function validateOutputSchema(

if (!actualType) {
// Column missing from response
result.missingColumns.push({ name, expectedType });
result.valid = false;
if (!validator._modifiers?.optional) {
result.missingColumns.push({ name, expectedType });
result.valid = false;
}
} else if (!typesAreCompatible(actualType, expectedType)) {
// Column exists but type doesn't match
result.typeMismatches.push({ name, expectedType, actualType });
Expand Down
35 changes: 33 additions & 2 deletions src/infer/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,37 @@ type OptionalParamKeys<T extends ParamsDefinition> = {
[K in keyof T]: T[K] extends ParamValidator<unknown, string, false> ? K : never;
}[keyof T];

/**
* Extract the required output keys from an output definition
*/
type RequiredOutputKeys<T extends OutputDefinition> = {
[K in keyof T]: T[K] extends TypeValidator<unknown, string, infer M>
? M extends { optional: true }
? never
: K
: K;
}[keyof T];

/**
* Extract the optional output keys from an output definition
*/
type OptionalOutputKeys<T extends OutputDefinition> = {
[K in keyof T]: T[K] extends TypeValidator<unknown, string, infer M>
? M extends { optional: true }
? K
: never
: never;
}[keyof T];

/**
* Extract a single output row type from an output definition
*/
type InferOutputObject<T extends OutputDefinition> = {
[K in RequiredOutputKeys<T>]: InferColumn<T[K]>;
} & {
[K in OptionalOutputKeys<T>]?: InferColumn<T[K]>;
};

/**
* Extract the params type from a pipe definition
*
Expand Down Expand Up @@ -131,14 +162,14 @@ export type InferParams<T> = T extends PipeDefinition<infer P, OutputDefinition>
* ```
*/
export type InferOutput<T> = T extends PipeDefinition<ParamsDefinition, infer O>
? { [K in keyof O]: InferColumn<O[K]> }[]
? InferOutputObject<O>[]
: never;

/**
* Extract a single output row type (without array wrapper)
*/
export type InferOutputRow<T> = T extends PipeDefinition<ParamsDefinition, infer O>
? { [K in keyof O]: InferColumn<O[K]> }
? InferOutputObject<O>
: never;

/**
Expand Down
27 changes: 26 additions & 1 deletion src/schema/pipe.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, it, expect } from "vitest";
import { describe, it, expect, expectTypeOf } from "vitest";
import {
definePipe,
defineSinkPipe,
Expand All @@ -19,6 +19,7 @@ import { defineKafkaConnection, defineS3Connection } from "./connection.js";
import { t } from "./types.js";
import { p } from "./params.js";
import { engine } from "./engines.js";
import type { InferOutputRow } from "../infer/index.js";

describe("Pipe Schema", () => {
describe("node", () => {
Expand Down Expand Up @@ -82,6 +83,30 @@ describe("Pipe Schema", () => {
expect(pipe.options.description).toBe("A test pipe");
});

it("infers optional output fields as optional object properties", () => {
const pipe = definePipe("my_pipe", {
nodes: [node({ name: "endpoint", sql: "SELECT 1" })],
output: {
id: t.uint64(),
subtitle: t.string().optional(),
metadata: t.string().nullable().optional(),
},
endpoint: true,
});

type Row = InferOutputRow<typeof pipe>;

const requiredOnly: Row = { id: 1 };
const withOptionals: Row = { id: 1, subtitle: "hello", metadata: null };

expectTypeOf<Row["id"]>().toEqualTypeOf<number>();
expectTypeOf<Row["subtitle"]>().toEqualTypeOf<string | undefined>();
expectTypeOf<Row["metadata"]>().toEqualTypeOf<string | null | undefined>();
expect(requiredOnly).toEqual({ id: 1 });
expect(withOptionals).toEqual({ id: 1, subtitle: "hello", metadata: null });
expect(pipe._output?.subtitle?._modifiers.optional).toBe(true);
});

it("throws error for invalid pipe name", () => {
expect(() =>
definePipe("123invalid", {
Expand Down
15 changes: 15 additions & 0 deletions src/schema/types.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,21 @@ describe("Type Validators (t.*)", () => {
});
});

describe("Optional modifier", () => {
it("marks field as optional without changing the Tinybird type", () => {
const type = t.string().optional();
expect(type._tinybirdType).toBe("String");
expect(type._modifiers.optional).toBe(true);
});

it("can be combined with nullable fields", () => {
const type = t.string().nullable().optional();
expect(type._tinybirdType).toBe("Nullable(String)");
expect(type._modifiers.nullable).toBe(true);
expect(type._modifiers.optional).toBe(true);
});
});

describe("LowCardinality modifier", () => {
it("wraps type in LowCardinality", () => {
const type = t.string().lowCardinality();
Expand Down
18 changes: 18 additions & 0 deletions src/schema/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ export interface TypeValidator<
/** Metadata about modifiers applied */
readonly _modifiers: TModifiers;

/** Mark this output field as optional (it may be absent from endpoint responses) */
optional(): TypeValidator<
TType,
TTinybirdType,
TModifiers & { optional: true }
>;
/** Make this column nullable */
nullable(): TypeValidator<
TType | null,
Expand Down Expand Up @@ -63,6 +69,7 @@ export interface TypeValidator<
}

export interface TypeModifiers {
optional?: boolean;
nullable?: boolean;
lowCardinality?: boolean;
hasDefault?: boolean;
Expand Down Expand Up @@ -94,6 +101,17 @@ function createValidator<TType, TTinybirdType extends string>(
tinybirdType,
modifiers,

optional() {
return createValidator<TType, TTinybirdType>(tinybirdType, {
...modifiers,
optional: true,
}) as TypeValidator<
TType,
TTinybirdType,
TypeModifiers & { optional: true }
>;
},

nullable() {
// If already has LowCardinality, we need to move Nullable inside
// ClickHouse requires: LowCardinality(Nullable(X)), not Nullable(LowCardinality(X))
Expand Down
Loading