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
3 changes: 3 additions & 0 deletions packages/scrawn/proto/event/v1/event.proto
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ message SDKCall {
oneof debit {
float amount = 2;
string tag = 3;
string expr = 4; // Pricing expression (e.g., "add(mul(tag('PREMIUM'),3),250)")
}
}

Expand All @@ -60,11 +61,13 @@ message AITokenUsage {
oneof inputDebit {
float inputAmount = 4;
string inputTag = 5;
string inputExpr = 8; // Pricing expression for input tokens
}

oneof outputDebit {
float outputAmount = 6;
string outputTag = 7;
string outputExpr = 9; // Pricing expression for output tokens
}
}

Expand Down
19 changes: 13 additions & 6 deletions packages/scrawn/src/core/grpc/requestBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import type {
MethodInput,
MethodOutput,
RequestState,
UnaryMethodFn,
} from './types.js';
import type { GrpcCallContext } from './callContext.js';

Expand Down Expand Up @@ -152,14 +153,20 @@ export class RequestBuilder<
this.ctx.logCallStart();
this.ctx.log.debug(`Payload: ${JSON.stringify(this.payload)}`);

// The actual gRPC call - fully type-safe!
const response = await (this.ctx.client[this.ctx.methodName] as any)(
this.payload,
{ headers: this.ctx.getHeaders() }
);
// The actual gRPC call.
// Type assertion is required because TypeScript cannot track the relationship
// between a dynamic key access and the resulting function signature in mapped types.
// This is safe because:
// 1. methodName is constrained to ServiceMethodNames<S>
// 2. MethodInput/MethodOutput are derived from the same service definition
const method = this.ctx.client[this.ctx.methodName] as UnaryMethodFn<
MethodInput<S, M>,
MethodOutput<S, M>
>;
const response = await method(this.payload, { headers: this.ctx.getHeaders() });

this.ctx.logCallSuccess();
return response as MethodOutput<S, M>;
return response;
} catch (error) {
this.ctx.logCallError(error);
throw error;
Expand Down
18 changes: 14 additions & 4 deletions packages/scrawn/src/core/grpc/streamRequestBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import type {
ServiceMethodNames,
MethodInput,
MethodOutput,
ClientStreamingMethodFn,
} from './types.js';
import type { GrpcCallContext } from './callContext.js';

Expand Down Expand Up @@ -101,14 +102,23 @@ export class StreamRequestBuilder<
try {
this.ctx.logCallStart();

// The actual client-streaming gRPC call
const response = await (this.ctx.client[this.ctx.methodName] as any)(
iterable,
// The actual client-streaming gRPC call.
// Type assertion is required because TypeScript cannot track the relationship
// between a dynamic key access and the resulting function signature in mapped types.
// This is safe because:
// 1. methodName is constrained to ServiceMethodNames<S>
// 2. MethodInput/MethodOutput are derived from the same service definition
const method = this.ctx.client[this.ctx.methodName] as ClientStreamingMethodFn<
MethodInput<S, M>,
MethodOutput<S, M>
>;
const response = await method(
iterable as AsyncIterable<Partial<MethodInput<S, M>>>,
{ headers: this.ctx.getHeaders() }
);

this.ctx.logCallSuccess();
return response as MethodOutput<S, M>;
return response;
} catch (error) {
this.ctx.logCallError(error);
throw error;
Expand Down
58 changes: 58 additions & 0 deletions packages/scrawn/src/core/grpc/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,3 +115,61 @@ export type ServerStreamingMethodNames<S extends ServiceType> = MethodsOfKind<S,
* Bidi-streaming methods accept a stream of requests and return a stream of responses.
*/
export type BidiStreamingMethodNames<S extends ServiceType> = MethodsOfKind<S, 'bidi_streaming'>;

/**
* Options passed to gRPC method calls.
* Matches CallOptions from @connectrpc/connect.
*/
export interface GrpcCallOptions {
headers?: Headers;
signal?: AbortSignal;
timeoutMs?: number;
onHeader?: (headers: globalThis.Headers) => void;
onTrailer?: (trailers: globalThis.Headers) => void;
}

/**
* Function signature for a unary gRPC method.
* Takes a partial message and options, returns a promise of the response.
*
* @template I - Input message type
* @template O - Output message type
*/
export type UnaryMethodFn<I, O> = (
request: Partial<I>,
options?: GrpcCallOptions
) => Promise<O>;

/**
* Function signature for a client-streaming gRPC method.
* Takes an async iterable of partial messages and options, returns a promise of the response.
*
* @template I - Input message type
* @template O - Output message type
*/
export type ClientStreamingMethodFn<I, O> = (
request: AsyncIterable<Partial<I>>,
options?: GrpcCallOptions
) => Promise<O>;

/**
* Get the method function type from a Client for a specific method.
* This properly extracts the callable type for dynamic method access.
*
* Note: Due to TypeScript limitations with mapped types and dynamic keys,
* this type is used with type assertions when accessing client methods
* via computed property names. The assertion is safe because:
* 1. The method name M is constrained to ServiceMethodNames<S>
* 2. The input/output types are derived from the same service definition
*
* @template S - The gRPC service type
* @template M - The method name on the service
*/
export type ClientMethod<
S extends ServiceType,
M extends ServiceMethodNames<S>
> = S['methods'][M] extends { kind: 'unary' }
? UnaryMethodFn<MethodInput<S, M>, MethodOutput<S, M>>
: S['methods'][M] extends { kind: 'client_streaming' }
? ClientStreamingMethodFn<MethodInput<S, M>, MethodOutput<S, M>>
: never;
167 changes: 167 additions & 0 deletions packages/scrawn/src/core/pricing/builders.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
/**
* Pricing DSL Builder Functions
*
* This module provides the fluent builder functions for constructing
* type-safe pricing expressions. These functions perform light validation
* and build the AST that gets serialized to a string for the backend.
*
* @example
* ```typescript
* import { add, mul, tag } from '@scrawn/core';
*
* // (PREMIUM_CALL * 3) + EXTRA_FEE + 250 cents
* const expr = add(mul(tag('PREMIUM_CALL'), 3), tag('EXTRA_FEE'), 250);
* ```
*/

import type { AmountExpr, TagExpr, OpExpr, PriceExpr, ExprInput } from './types.js';
import { validateExpr } from './validate.js';

/**
* Convert an ExprInput (PriceExpr or number) to a PriceExpr.
* Numbers are wrapped as AmountExpr (cents).
*/
function toExpr(input: ExprInput): PriceExpr {
if (typeof input === 'number') {
return { kind: 'amount', value: input } as const;
}
return input;
}

/**
* Create a tag reference expression.
* Tags are resolved to their cent values by the backend.
*
* @param name - The name of the price tag (must be non-empty)
* @returns A TagExpr referencing the named tag
* @throws Error if name is empty or whitespace-only
*
* @example
* ```typescript
* const premiumTag = tag('PREMIUM_CALL');
* ```
*/
export function tag(name: string): TagExpr {
const expr: TagExpr = { kind: 'tag', name } as const;
validateExpr(expr); // Will throw if invalid
return expr;
}

/**
* Create an addition expression.
* Adds all arguments together: arg1 + arg2 + arg3 + ...
*
* @param args - Two or more expressions or numbers (cents) to add
* @returns An OpExpr representing the sum
* @throws Error if fewer than 2 arguments provided
*
* @example
* ```typescript
* // 100 + 200 + tag('BONUS')
* const sum = add(100, 200, tag('BONUS'));
* ```
*/
export function add(...args: ExprInput[]): OpExpr {
const expr: OpExpr = {
kind: 'op',
op: 'ADD',
args: args.map(toExpr),
} as const;
validateExpr(expr);
return expr;
}

/**
* Create a subtraction expression.
* Subtracts subsequent arguments from the first: arg1 - arg2 - arg3 - ...
*
* @param args - Two or more expressions or numbers (cents) to subtract
* @returns An OpExpr representing the difference
* @throws Error if fewer than 2 arguments provided
*
* @example
* ```typescript
* // tag('TOTAL') - 50
* const diff = sub(tag('TOTAL'), 50);
* ```
*/
export function sub(...args: ExprInput[]): OpExpr {
const expr: OpExpr = {
kind: 'op',
op: 'SUB',
args: args.map(toExpr),
} as const;
validateExpr(expr);
return expr;
}

/**
* Create a multiplication expression.
* Multiplies all arguments together: arg1 * arg2 * arg3 * ...
*
* @param args - Two or more expressions or numbers (cents) to multiply
* @returns An OpExpr representing the product
* @throws Error if fewer than 2 arguments provided
*
* @example
* ```typescript
* // tag('PER_TOKEN') * 100
* const product = mul(tag('PER_TOKEN'), 100);
* ```
*/
export function mul(...args: ExprInput[]): OpExpr {
const expr: OpExpr = {
kind: 'op',
op: 'MUL',
args: args.map(toExpr),
} as const;
validateExpr(expr);
return expr;
}

/**
* Create a division expression.
* Divides the first argument by subsequent arguments: arg1 / arg2 / arg3 / ...
*
* Note: The backend performs integer division. Results are truncated, not rounded.
*
* @param args - Two or more expressions or numbers (cents) to divide
* @returns An OpExpr representing the quotient
* @throws Error if fewer than 2 arguments provided
* @throws Error if any literal divisor is 0 (detected at build time)
*
* @example
* ```typescript
* // tag('TOTAL') / 2
* const half = div(tag('TOTAL'), 2);
* ```
*/
export function div(...args: ExprInput[]): OpExpr {
const expr: OpExpr = {
kind: 'op',
op: 'DIV',
args: args.map(toExpr),
} as const;
validateExpr(expr);
return expr;
}

/**
* Create an amount expression from a number of cents.
* This is useful when you need to explicitly create an AmountExpr
* rather than relying on auto-conversion.
*
* @param cents - The amount in cents (must be an integer)
* @returns An AmountExpr representing the amount
* @throws Error if cents is not a finite integer
*
* @example
* ```typescript
* const fee = amount(250); // 250 cents = $2.50
* ```
*/
export function amount(cents: number): AmountExpr {
const expr: AmountExpr = { kind: 'amount', value: cents } as const;
validateExpr(expr);
return expr;
}
42 changes: 42 additions & 0 deletions packages/scrawn/src/core/pricing/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/**
* Pricing DSL Module
*
* Provides a type-safe DSL for building pricing expressions that combine
* literal amounts, named price tags, and arithmetic operations.
*
* @example
* ```typescript
* import { add, mul, tag, serializeExpr } from '@scrawn/core';
*
* // Build expression: (PREMIUM_CALL * 3) + EXTRA_FEE + 250 cents
* const expr = add(mul(tag('PREMIUM_CALL'), 3), tag('EXTRA_FEE'), 250);
*
* // Serialize for backend: "add(mul(tag('PREMIUM_CALL'),3),tag('EXTRA_FEE'),250)"
* const exprString = serializeExpr(expr);
*
* // Use in event payload
* await scrawn.sdkCallEventConsumer({
* userId: 'u123',
* debitExpr: expr
* });
* ```
*/

// Export types
export type {
OpType,
AmountExpr,
TagExpr,
OpExpr,
PriceExpr,
ExprInput,
} from './types.js';

// Export builder functions
export { tag, add, sub, mul, div, amount } from './builders.js';

// Export serialization
export { serializeExpr, prettyPrintExpr } from './serialize.js';

// Export validation
export { validateExpr, isValidExpr, PricingExpressionError } from './validate.js';
Loading
Loading