@segmentstream-cloud/app 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,8 +1,9 @@
1
1
  # @segmentstream-cloud/app
2
2
 
3
- Sign-in and sessions for SegmentStream workspace apps built with Next.js (App Router,
4
- version 16 or later). Every visitor signs in with their SegmentStream account, and only
5
- members of the app's workspace get in.
3
+ Sign-in, sessions, and warehouse data access for SegmentStream workspace apps built with
4
+ Next.js (App Router, version 16 or later). Every visitor signs in with their SegmentStream
5
+ account, only members of the app's workspace get in, and the app reads workspace data as
6
+ that user without holding any warehouse credentials.
6
7
 
7
8
  ## Install
8
9
 
@@ -77,6 +78,77 @@ To sign out, post a form to the SDK route:
77
78
  </form>
78
79
  ```
79
80
 
81
+ ## Query the warehouse
82
+
83
+ ```tsx
84
+ import { sql } from "@segmentstream-cloud/app";
85
+ import { segmentstream } from "@/segmentstream";
86
+
87
+ type Revenue = { order_date: string; total_revenue: number };
88
+
89
+ export default async function Page() {
90
+ const { rows } = await segmentstream.warehouse.query<Revenue>(
91
+ `SELECT order_date, total_revenue
92
+ FROM gold.revenue_summary
93
+ WHERE order_date >= @from
94
+ ORDER BY order_date`,
95
+ { params: { from: sql.date("2026-09-01") } },
96
+ );
97
+
98
+ return <pre>{JSON.stringify(rows, null, 2)}</pre>;
99
+ }
100
+ ```
101
+
102
+ - Queries are read-only GoogleSQL over the workspace warehouse. `dataset.table` resolves
103
+ to the workspace's project. Anything other than a single `SELECT` is rejected.
104
+ - Pass values as named `@parameters` in `params`; never build SQL from user input.
105
+ Strings, booleans, `bigint`, safe integers (`INT64`), other numbers (`FLOAT64`), `Date`
106
+ (`TIMESTAMP`) and arrays of these are typed automatically. Use `sql.date()`,
107
+ `sql.datetime()`, `sql.timestamp()`, `sql.numeric()`, `sql.bignumeric()`, `sql.int64()`
108
+ and `sql.array(type, values)` for anything else.
109
+ - `query()` waits for running queries (`timeoutMs`, default 60 s) and follows result
110
+ pages up to `maxRows` (default 10,000); `truncated` reports whether more rows exist.
111
+ `maximumBytesBilled` lowers the per-query byte ceiling; `warehouse.dryRun()` returns the
112
+ estimated bytes and result columns without running the query.
113
+ - Long queries keep running in the warehouse for up to an hour after `query()` stops
114
+ waiting. It then throws `query_timeout` with a `queryId`. Resume the query from a later
115
+ request, for example a Route Handler your page polls, with
116
+ `warehouse.result(queryId)`. Each request carries a fresh session token, so a query can
117
+ outlive the 5-minute token. Don't hold one request open for minutes.
118
+ - Values arrive as JSON: safe integers as numbers, larger integers and decimals as strings,
119
+ timestamps as ISO strings, records and arrays as JSON strings.
120
+
121
+ Semantic models give governed metrics without SQL:
122
+
123
+ ```ts
124
+ const { rows } = await segmentstream.semantic.query("advertising", {
125
+ dimensions: ["platform"],
126
+ measures: ["spend", "clicks"],
127
+ dateRange: { from: "2026-09-01", to: "2026-09-30" },
128
+ filters: [{ dimension: "currency", operator: "equals", value: "USD" }],
129
+ });
130
+ ```
131
+
132
+ `semantic.list()` and `semantic.get(name)` describe the deployed models.
133
+
134
+ ## Call the SegmentStream API
135
+
136
+ The session token works on the whole SegmentStream API, with the signed-in member's
137
+ organization role and permissions, exactly like their own login. `fetch` adds it to any
138
+ API path:
139
+
140
+ ```ts
141
+ const response = await segmentstream.fetch(
142
+ `/api/v1/workspaces/${user.workspaceId}/warehouse/datasets`,
143
+ );
144
+ ```
145
+
146
+ `warehouse` and `semantic` are typed helpers over the same API, for the app's own
147
+ workspace. Every call is server-only: use it from Server Components, Route Handlers, and
148
+ Server Functions behind the proxy, and pass results to Client Components as props or
149
+ through your own Route Handlers. Typed helpers throw `SegmentStreamApiError` with the API
150
+ error `code`, `status`, `detail` and, for timeouts, the `queryId`.
151
+
80
152
  ## How requests are handled
81
153
 
82
154
  - Page navigations without a session are redirected to SegmentStream to sign in, then
@@ -102,11 +174,17 @@ and the SDK drops the `__Host-` prefix and `Secure` attribute only for `http://l
102
174
  - The app never sees SegmentStream's identity-provider tokens. It receives a short-lived
103
175
  (5-minute) ES256 session token scoped to this workspace app and a rotating refresh
104
176
  token, both in `HttpOnly`, `SameSite=Lax` cookies (`__Host-` and `Secure` in production).
105
- - Session tokens are verified locally against SegmentStream's published keys: issuer,
106
- expiry, workspace, organization, and an `app:` audience.
177
+ - Session tokens are verified against SegmentStream's published keys: issuer, expiry,
178
+ workspace, organization, and an `app:` audience.
107
179
  - The proxy refreshes expired sessions silently. When access is revoked, the next refresh
108
180
  fails and the user is signed out.
109
181
  - Sign-in uses the authorization-code flow with PKCE (S256) and a single-use `state`.
182
+ - API calls carry the session token, which SegmentStream accepts as the member: their
183
+ WorkOS role and permissions, read at sign-in and every refresh, decide what the call may
184
+ do. App code therefore acts as whoever is visiting, across their organization, so treat
185
+ deploying app code like any other code your organization runs with member access.
186
+ Warehouse queries run with the workspace's own identity; the app never holds warehouse
187
+ credentials.
110
188
 
111
189
  ## License
112
190
 
package/dist/app.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { SemanticClient, WarehouseClient } from "./data.js";
1
2
  import type { SegmentStreamAppConfig } from "./config.js";
2
3
  import type { SegmentStreamRouteHandlers } from "./handlers.js";
3
4
  import type { ProxyOptions, SegmentStreamProxy } from "./proxy.js";
@@ -14,6 +15,15 @@ export interface SegmentStreamApp {
14
15
  readonly requireUser: () => Promise<AppUser>;
15
16
  /** The verified app session token for this request, or `null`. */
16
17
  readonly getAccessToken: () => Promise<string | null>;
18
+ /**
19
+ * Calls any SegmentStream API path as the signed-in member, with their organization role
20
+ * and permissions. Server-only; the session token never reaches the browser.
21
+ */
22
+ readonly fetch: (path: string, init?: RequestInit) => Promise<Response>;
23
+ /** Read-only SQL over the workspace warehouse, as the signed-in member. Server-only. */
24
+ readonly warehouse: WarehouseClient;
25
+ /** Governed metrics from the workspace's deployed semantic models. Server-only. */
26
+ readonly semantic: SemanticClient;
17
27
  }
18
28
  /** Replaceable capabilities, for tests. */
19
29
  export interface AppDependencies {
package/dist/app.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { resolveConfig } from "./config.js";
2
+ import { createDataClient } from "./data.js";
2
3
  import { createHandlers } from "./handlers.js";
3
4
  import { createProxy } from "./proxy.js";
4
5
  import { createServerSession } from "./server.js";
@@ -16,11 +17,19 @@ export function createApp(config, dependencies = {}) {
16
17
  revokeSession: sessionClient.revoke,
17
18
  };
18
19
  const serverSession = createServerSession(context);
20
+ const data = createDataClient({
21
+ config: resolved,
22
+ fetch: fetchImplementation,
23
+ getAccessToken: serverSession.getAccessToken,
24
+ });
19
25
  return {
20
26
  proxy: (options) => createProxy(context, options),
21
27
  handlers: () => createHandlers(context),
22
28
  getUser: serverSession.getUser,
23
29
  requireUser: serverSession.requireUser,
24
30
  getAccessToken: serverSession.getAccessToken,
31
+ fetch: data.fetch,
32
+ warehouse: data.warehouse,
33
+ semantic: data.semantic,
25
34
  };
26
35
  }
package/dist/data.d.ts ADDED
@@ -0,0 +1,138 @@
1
+ import type { ResolvedConfig } from "./config.js";
2
+ import type { QueryParameters } from "./parameters.js";
3
+ import type { Fetch } from "./sessions.js";
4
+ /** A failed SegmentStream API request; `code` is the API error code. */
5
+ export declare class SegmentStreamApiError extends Error {
6
+ readonly code: string;
7
+ readonly status: number;
8
+ readonly detail: string;
9
+ readonly requestId: string | undefined;
10
+ readonly retryable: boolean;
11
+ /** The warehouse query this error concerns, when one was started. */
12
+ readonly queryId: string | undefined;
13
+ constructor(input: {
14
+ readonly code: string;
15
+ readonly status: number;
16
+ readonly detail: string;
17
+ readonly requestId?: string | undefined;
18
+ readonly retryable?: boolean;
19
+ readonly queryId?: string | undefined;
20
+ });
21
+ }
22
+ export type WarehouseValue = string | number | boolean | null;
23
+ export type WarehouseRow = Record<string, WarehouseValue>;
24
+ export interface WarehouseColumn {
25
+ readonly name: string;
26
+ readonly type: string;
27
+ readonly mode: string;
28
+ readonly description: string;
29
+ }
30
+ /** How much of a query result to wait for and collect. */
31
+ export interface WarehouseReadOptions {
32
+ /** Rows to collect across pages before stopping. Defaults to 10,000. */
33
+ readonly maxRows?: number;
34
+ /** Rows per API page, 1–1,000. Defaults to the smaller of `maxRows` and 1,000. */
35
+ readonly pageSize?: number;
36
+ /** How long to wait for a running query. Defaults to 60 seconds. */
37
+ readonly timeoutMs?: number;
38
+ readonly signal?: AbortSignal;
39
+ }
40
+ export interface WarehouseQueryOptions extends WarehouseReadOptions {
41
+ /** Values for named `@parameters` in the SQL. Never interpolate values into the SQL text. */
42
+ readonly params?: QueryParameters;
43
+ /** Lowers the workspace's per-query byte ceiling for this query. */
44
+ readonly maximumBytesBilled?: number | bigint | string;
45
+ }
46
+ export interface WarehouseQueryResult<Row = WarehouseRow> {
47
+ readonly queryId: string | null;
48
+ readonly columns: readonly WarehouseColumn[];
49
+ readonly rows: Row[];
50
+ /** Rows in the full result, which may exceed `rows` when `truncated`. */
51
+ readonly totalRows: number | null;
52
+ /** True when more rows exist than `maxRows` allowed. */
53
+ readonly truncated: boolean;
54
+ readonly bytesProcessed: string | null;
55
+ readonly bytesBilled: string | null;
56
+ readonly cacheHit: boolean | null;
57
+ }
58
+ export interface WarehouseDryRun {
59
+ readonly columns: readonly WarehouseColumn[];
60
+ readonly bytesProcessed: string | null;
61
+ readonly maximumBytesBilled: string;
62
+ }
63
+ export interface SemanticQueryRequest {
64
+ readonly dimensions: readonly string[];
65
+ readonly measures: readonly string[];
66
+ readonly dateRange: {
67
+ readonly from: string;
68
+ readonly to: string;
69
+ };
70
+ readonly granularity?: "day" | "week" | "month";
71
+ readonly filters?: ReadonlyArray<{
72
+ readonly dimension: string;
73
+ readonly operator: "equals" | "in" | "isNull" | "isNotNull";
74
+ readonly value?: string | number | boolean;
75
+ readonly values?: ReadonlyArray<string | number | boolean>;
76
+ }>;
77
+ readonly orderBy?: ReadonlyArray<{
78
+ readonly measure?: string;
79
+ readonly dimension?: string;
80
+ readonly direction: "asc" | "desc";
81
+ }>;
82
+ readonly limit?: number;
83
+ }
84
+ export interface SemanticField {
85
+ readonly name: string;
86
+ readonly label: string;
87
+ readonly type: string;
88
+ readonly unit?: string;
89
+ readonly format?: string;
90
+ }
91
+ export interface SemanticQueryResult<Row = Record<string, string | boolean | null>> {
92
+ readonly deploymentId: string | null;
93
+ readonly definitionDigest: string;
94
+ readonly fields: readonly SemanticField[];
95
+ readonly rows: Row[];
96
+ readonly truncated: boolean;
97
+ readonly execution: {
98
+ readonly elapsedMs: number;
99
+ readonly bytesProcessed?: string;
100
+ readonly bytesBilled?: string;
101
+ };
102
+ }
103
+ export interface WarehouseClient {
104
+ /** Runs read-only SQL in the workspace warehouse as the signed-in user's app session. */
105
+ readonly query: <Row = WarehouseRow>(sql: string, options?: WarehouseQueryOptions) => Promise<WarehouseQueryResult<Row>>;
106
+ /**
107
+ * Resumes a query by ID from a later request. Queries keep running in the warehouse for
108
+ * up to an hour after `query()` stops waiting, and each request uses a fresh app token.
109
+ */
110
+ readonly result: <Row = WarehouseRow>(queryId: string, options?: WarehouseReadOptions) => Promise<WarehouseQueryResult<Row>>;
111
+ /** Validates SQL and estimates bytes processed without running it. */
112
+ readonly dryRun: (sql: string, options?: Pick<WarehouseQueryOptions, "params" | "maximumBytesBilled" | "signal">) => Promise<WarehouseDryRun>;
113
+ }
114
+ export interface SemanticClient {
115
+ readonly list: () => Promise<{
116
+ readonly deploymentId: string | null;
117
+ readonly models: readonly unknown[];
118
+ }>;
119
+ readonly get: (model: string) => Promise<{
120
+ readonly deploymentId: string | null;
121
+ readonly definitionDigest: string;
122
+ readonly model: unknown;
123
+ }>;
124
+ readonly query: <Row = Record<string, string | boolean | null>>(model: string, request: SemanticQueryRequest) => Promise<SemanticQueryResult<Row>>;
125
+ }
126
+ export interface DataClientDependencies {
127
+ readonly config: ResolvedConfig;
128
+ readonly fetch: Fetch;
129
+ readonly getAccessToken: () => Promise<string | null>;
130
+ readonly sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
131
+ readonly now?: () => number;
132
+ }
133
+ /** Server-side SegmentStream API access as the signed-in member. */
134
+ export declare function createDataClient(dependencies: DataClientDependencies): {
135
+ fetch: (path: string, init?: RequestInit) => Promise<Response>;
136
+ warehouse: WarehouseClient;
137
+ semantic: SemanticClient;
138
+ };
package/dist/data.js ADDED
@@ -0,0 +1,225 @@
1
+ import { encodeParameters } from "./parameters.js";
2
+ /** A failed SegmentStream API request; `code` is the API error code. */
3
+ export class SegmentStreamApiError extends Error {
4
+ code;
5
+ status;
6
+ detail;
7
+ requestId;
8
+ retryable;
9
+ /** The warehouse query this error concerns, when one was started. */
10
+ queryId;
11
+ constructor(input) {
12
+ super(`${input.detail} (${input.code})`);
13
+ this.name = "SegmentStreamApiError";
14
+ this.code = input.code;
15
+ this.status = input.status;
16
+ this.detail = input.detail;
17
+ this.requestId = input.requestId;
18
+ this.retryable = input.retryable ?? false;
19
+ this.queryId = input.queryId;
20
+ }
21
+ }
22
+ const REQUEST_TIMEOUT_MS = 45_000;
23
+ const DEFAULT_MAX_ROWS = 10_000;
24
+ const MAXIMUM_PAGE_SIZE = 1000;
25
+ const DEFAULT_TIMEOUT_MS = 60_000;
26
+ const FIRST_POLL_MS = 500;
27
+ const LONGEST_POLL_MS = 2000;
28
+ /** Server-side SegmentStream API access as the signed-in member. */
29
+ export function createDataClient(dependencies) {
30
+ const { config } = dependencies;
31
+ const workspace = `/api/v1/workspaces/${encodeURIComponent(config.workspaceId)}`;
32
+ const sleep = dependencies.sleep ?? defaultSleep;
33
+ const now = dependencies.now ?? Date.now;
34
+ /** Calls any SegmentStream API path as the signed-in member; the token never leaves the server. */
35
+ async function apiFetch(path, init = {}) {
36
+ if (!path.startsWith("/api/")) {
37
+ throw new TypeError('SegmentStream API paths start with "/api/", for example "/api/v1/me".');
38
+ }
39
+ const token = await dependencies.getAccessToken();
40
+ if (token === null) {
41
+ throw new SegmentStreamApiError({
42
+ code: "app_session_missing",
43
+ status: 401,
44
+ detail: "No signed-in SegmentStream session for this request; call SegmentStream from Server Components, Route Handlers, or Server Functions behind the SegmentStream proxy.",
45
+ });
46
+ }
47
+ const headers = new Headers(init.headers);
48
+ const timeout = AbortSignal.timeout(REQUEST_TIMEOUT_MS);
49
+ headers.set("authorization", `Bearer ${token}`);
50
+ if (!headers.has("accept")) {
51
+ headers.set("accept", "application/json");
52
+ }
53
+ try {
54
+ return await dependencies.fetch(`${config.baseUrl}${path}`, {
55
+ ...init,
56
+ headers,
57
+ cache: "no-store",
58
+ redirect: "error",
59
+ signal: init.signal ? AbortSignal.any([init.signal, timeout]) : timeout,
60
+ });
61
+ }
62
+ catch (error) {
63
+ if (init.signal?.aborted) {
64
+ throw error;
65
+ }
66
+ throw new SegmentStreamApiError({
67
+ code: "segmentstream_unreachable",
68
+ status: 503,
69
+ detail: "SegmentStream could not be reached.",
70
+ retryable: true,
71
+ });
72
+ }
73
+ }
74
+ async function call(method, path, body, signal) {
75
+ const response = await apiFetch(path, {
76
+ method,
77
+ ...(body === undefined
78
+ ? {}
79
+ : { body: JSON.stringify(body), headers: { "content-type": "application/json" } }),
80
+ ...(signal ? { signal } : {}),
81
+ });
82
+ const payload = await readJson(response);
83
+ if (!response.ok) {
84
+ throw errorFrom(response.status, payload);
85
+ }
86
+ return payload;
87
+ }
88
+ function limits(options) {
89
+ const maxRows = positiveInteger(options.maxRows ?? DEFAULT_MAX_ROWS, "maxRows");
90
+ const pageSize = positiveInteger(options.pageSize ?? Math.min(maxRows, MAXIMUM_PAGE_SIZE), "pageSize");
91
+ return { maxRows, pageSize, deadline: now() + (options.timeoutMs ?? DEFAULT_TIMEOUT_MS) };
92
+ }
93
+ /** Waits for a running query, then follows pages up to `maxRows`. */
94
+ async function collect(first, { maxRows, pageSize, deadline }, signal) {
95
+ let page = first;
96
+ const queryId = page.id ?? null;
97
+ let wait = FIRST_POLL_MS;
98
+ while (page.state === "running") {
99
+ if (queryId === null || now() + wait > deadline) {
100
+ throw new SegmentStreamApiError({
101
+ code: "query_timeout",
102
+ status: 504,
103
+ detail: "The query is still running; resume it from a later request with warehouse.result(queryId).",
104
+ retryable: true,
105
+ queryId: queryId ?? undefined,
106
+ });
107
+ }
108
+ await sleep(wait, signal);
109
+ wait = Math.min(wait * 2, LONGEST_POLL_MS);
110
+ page = (await read(queryId, pageSize, undefined, signal)).query;
111
+ }
112
+ const head = page;
113
+ const rows = [...page.rows];
114
+ while (page.nextPageToken !== undefined && rows.length < maxRows && queryId !== null) {
115
+ page = (await read(queryId, pageSize, page.nextPageToken, signal)).query;
116
+ rows.push(...page.rows);
117
+ }
118
+ return {
119
+ queryId,
120
+ columns: head.columns,
121
+ rows: rows.slice(0, maxRows),
122
+ totalRows: head.totalRows === undefined ? null : Number(head.totalRows),
123
+ truncated: rows.length > maxRows || page.nextPageToken !== undefined,
124
+ bytesProcessed: head.bytesProcessed ?? null,
125
+ bytesBilled: head.bytesBilled ?? null,
126
+ cacheHit: head.cacheHit ?? null,
127
+ };
128
+ }
129
+ async function query(sql, options = {}) {
130
+ const bounds = limits(options);
131
+ const { query: page } = await call("POST", `${workspace}/warehouse/queries`, {
132
+ sql,
133
+ parameters: encodeParameters(options.params),
134
+ ...bytesLimit(options.maximumBytesBilled),
135
+ pageSize: bounds.pageSize,
136
+ }, options.signal);
137
+ return collect(page, bounds, options.signal);
138
+ }
139
+ async function result(queryId, options = {}) {
140
+ const bounds = limits(options);
141
+ const { query: page } = await read(queryId, bounds.pageSize, undefined, options.signal);
142
+ return collect(page, bounds, options.signal);
143
+ }
144
+ function read(queryId, pageSize, pageToken, signal) {
145
+ const search = new URLSearchParams({ pageSize: String(pageSize) });
146
+ if (pageToken !== undefined) {
147
+ search.set("pageToken", pageToken);
148
+ }
149
+ return call("GET", `${workspace}/warehouse/queries/${encodeURIComponent(queryId)}?${search.toString()}`, undefined, signal);
150
+ }
151
+ const warehouse = {
152
+ query,
153
+ result,
154
+ dryRun: async (sql, options = {}) => {
155
+ const { query: page } = await call("POST", `${workspace}/warehouse/queries`, {
156
+ sql,
157
+ parameters: encodeParameters(options.params),
158
+ ...bytesLimit(options.maximumBytesBilled),
159
+ dryRun: true,
160
+ }, options.signal);
161
+ return {
162
+ columns: page.columns,
163
+ bytesProcessed: page.bytesProcessed ?? null,
164
+ maximumBytesBilled: page.maximumBytesBilled,
165
+ };
166
+ },
167
+ };
168
+ const semantic = {
169
+ list: () => call("GET", `${workspace}/semantic-models`),
170
+ get: (model) => call("GET", `${workspace}/semantic-models/${encodeURIComponent(model)}`),
171
+ query: (model, request) => call("POST", `${workspace}/semantic-models/${encodeURIComponent(model)}/query`, request),
172
+ };
173
+ return { fetch: apiFetch, warehouse, semantic };
174
+ }
175
+ function bytesLimit(value) {
176
+ if (value === undefined) {
177
+ return {};
178
+ }
179
+ const text = String(value);
180
+ if (!/^[1-9][0-9]*$/.test(text)) {
181
+ throw new TypeError("maximumBytesBilled must be a positive whole number of bytes.");
182
+ }
183
+ return { maximumBytesBilled: text };
184
+ }
185
+ function positiveInteger(value, name) {
186
+ if (!Number.isInteger(value) || value < 1) {
187
+ throw new TypeError(`${name} must be a positive integer.`);
188
+ }
189
+ return value;
190
+ }
191
+ async function readJson(response) {
192
+ try {
193
+ return await response.json();
194
+ }
195
+ catch {
196
+ return null;
197
+ }
198
+ }
199
+ function errorFrom(status, payload) {
200
+ const body = typeof payload === "object" && payload !== null ? payload : {};
201
+ return new SegmentStreamApiError({
202
+ code: typeof body.error === "string" ? body.error : `http_${status}`,
203
+ status,
204
+ detail: typeof body.detail === "string" ? body.detail : "SegmentStream rejected the request.",
205
+ requestId: typeof body.requestId === "string" ? body.requestId : undefined,
206
+ retryable: typeof body.retryable === "boolean" ? body.retryable : status >= 500,
207
+ });
208
+ }
209
+ function defaultSleep(ms, signal) {
210
+ return new Promise((resolve, reject) => {
211
+ if (signal?.aborted) {
212
+ reject(signal.reason);
213
+ return;
214
+ }
215
+ const timer = setTimeout(() => {
216
+ signal?.removeEventListener("abort", abort);
217
+ resolve();
218
+ }, ms);
219
+ const abort = () => {
220
+ clearTimeout(timer);
221
+ reject(signal?.reason);
222
+ };
223
+ signal?.addEventListener("abort", abort, { once: true });
224
+ });
225
+ }
package/dist/index.d.ts CHANGED
@@ -11,7 +11,11 @@ import type { SegmentStreamAppConfig } from "./config.js";
11
11
  * ```
12
12
  */
13
13
  export declare function createSegmentStreamApp(config: SegmentStreamAppConfig): SegmentStreamApp;
14
+ export { SegmentStreamApiError } from "./data.js";
15
+ export { sql } from "./parameters.js";
14
16
  export type { SegmentStreamApp } from "./app.js";
17
+ export type { SemanticClient, SemanticField, SemanticQueryRequest, SemanticQueryResult, WarehouseClient, WarehouseColumn, WarehouseDryRun, WarehouseQueryOptions, WarehouseReadOptions, WarehouseQueryResult, WarehouseRow, WarehouseValue, } from "./data.js";
18
+ export type { ParameterType, QueryParameterValue, QueryParameters, TypedParameter, } from "./parameters.js";
15
19
  export type { SegmentStreamAppConfig } from "./config.js";
16
20
  export type { SegmentStreamRouteHandlers, RouteHandler } from "./handlers.js";
17
21
  export type { ProxyOptions, SegmentStreamProxy } from "./proxy.js";
package/dist/index.js CHANGED
@@ -12,3 +12,5 @@ import { createApp } from "./app.js";
12
12
  export function createSegmentStreamApp(config) {
13
13
  return createApp(config);
14
14
  }
15
+ export { SegmentStreamApiError } from "./data.js";
16
+ export { sql } from "./parameters.js";
@@ -0,0 +1,55 @@
1
+ export declare const PARAMETER_TYPES: readonly ["STRING", "INT64", "FLOAT64", "NUMERIC", "BIGNUMERIC", "BOOL", "DATE", "DATETIME", "TIMESTAMP"];
2
+ export type ParameterType = (typeof PARAMETER_TYPES)[number];
3
+ declare const TYPED: unique symbol;
4
+ /** A value with an explicit warehouse type, created with the `sql` helpers. */
5
+ export interface TypedParameter {
6
+ readonly [TYPED]: true;
7
+ readonly type: ParameterType;
8
+ readonly value?: string;
9
+ readonly values?: readonly string[];
10
+ }
11
+ /** A value bound to a named `@parameter`; never spliced into the SQL text. */
12
+ export type QueryParameterValue = string | number | bigint | boolean | Date | TypedParameter | ReadonlyArray<string | number | bigint | boolean | Date>;
13
+ export type QueryParameters = Readonly<Record<string, QueryParameterValue>>;
14
+ /** The wire form the SegmentStream API accepts. */
15
+ export type EncodedParameter = {
16
+ readonly name: string;
17
+ readonly type: ParameterType;
18
+ readonly value: string;
19
+ } | {
20
+ readonly name: string;
21
+ readonly type: ParameterType;
22
+ readonly values: readonly string[];
23
+ };
24
+ /**
25
+ * Explicit parameter types for values JavaScript cannot express unambiguously.
26
+ *
27
+ * ```ts
28
+ * await segmentstream.warehouse.query(
29
+ * "SELECT * FROM gold.orders WHERE order_date >= @from AND amount > @minimum",
30
+ * { params: { from: sql.date("2026-09-01"), minimum: sql.numeric("10.50") } },
31
+ * );
32
+ * ```
33
+ */
34
+ export declare const sql: {
35
+ readonly string: (value: string) => TypedParameter;
36
+ readonly int64: (value: number | bigint | string) => TypedParameter;
37
+ readonly float64: (value: number) => TypedParameter;
38
+ readonly numeric: (value: string | number) => TypedParameter;
39
+ readonly bignumeric: (value: string | number) => TypedParameter;
40
+ readonly bool: (value: boolean) => TypedParameter;
41
+ /** A calendar date, `YYYY-MM-DD`; a `Date` uses its UTC day. */
42
+ readonly date: (value: string | Date) => TypedParameter;
43
+ /** A civil date and time without a time zone, `YYYY-MM-DDTHH:MM:SS[.ffffff]`. */
44
+ readonly datetime: (value: string) => TypedParameter;
45
+ readonly timestamp: (value: string | Date) => TypedParameter;
46
+ /** An array of one element type; needed for empty arrays and non-default element types. */
47
+ readonly array: (type: ParameterType, values: ReadonlyArray<string | number | bigint | boolean | Date>) => {
48
+ [TYPED]: true;
49
+ type: "STRING" | "INT64" | "FLOAT64" | "NUMERIC" | "BIGNUMERIC" | "BOOL" | "DATE" | "DATETIME" | "TIMESTAMP";
50
+ values: string[];
51
+ };
52
+ };
53
+ /** Infers warehouse types for named parameters and validates their names. */
54
+ export declare function encodeParameters(parameters: QueryParameters | undefined): EncodedParameter[];
55
+ export {};
@@ -0,0 +1,118 @@
1
+ export const PARAMETER_TYPES = [
2
+ "STRING",
3
+ "INT64",
4
+ "FLOAT64",
5
+ "NUMERIC",
6
+ "BIGNUMERIC",
7
+ "BOOL",
8
+ "DATE",
9
+ "DATETIME",
10
+ "TIMESTAMP",
11
+ ];
12
+ const TYPED = Symbol.for("segmentstream.app.queryParameter");
13
+ const NAME = /^[A-Za-z_][A-Za-z0-9_]{0,127}$/;
14
+ function typed(type, value) {
15
+ return { [TYPED]: true, type, value };
16
+ }
17
+ function isoDate(value, name) {
18
+ if (value instanceof Date) {
19
+ if (Number.isNaN(value.getTime())) {
20
+ throw new TypeError(`sql.${name}() received an invalid Date.`);
21
+ }
22
+ return value.toISOString().slice(0, 10);
23
+ }
24
+ return value;
25
+ }
26
+ function timestamp(value) {
27
+ if (value instanceof Date) {
28
+ if (Number.isNaN(value.getTime())) {
29
+ throw new TypeError("sql.timestamp() received an invalid Date.");
30
+ }
31
+ return value.toISOString();
32
+ }
33
+ return value;
34
+ }
35
+ /**
36
+ * Explicit parameter types for values JavaScript cannot express unambiguously.
37
+ *
38
+ * ```ts
39
+ * await segmentstream.warehouse.query(
40
+ * "SELECT * FROM gold.orders WHERE order_date >= @from AND amount > @minimum",
41
+ * { params: { from: sql.date("2026-09-01"), minimum: sql.numeric("10.50") } },
42
+ * );
43
+ * ```
44
+ */
45
+ export const sql = {
46
+ string: (value) => typed("STRING", value),
47
+ int64: (value) => typed("INT64", String(value)),
48
+ float64: (value) => typed("FLOAT64", String(value)),
49
+ numeric: (value) => typed("NUMERIC", String(value)),
50
+ bignumeric: (value) => typed("BIGNUMERIC", String(value)),
51
+ bool: (value) => typed("BOOL", String(value)),
52
+ /** A calendar date, `YYYY-MM-DD`; a `Date` uses its UTC day. */
53
+ date: (value) => typed("DATE", isoDate(value, "date")),
54
+ /** A civil date and time without a time zone, `YYYY-MM-DDTHH:MM:SS[.ffffff]`. */
55
+ datetime: (value) => typed("DATETIME", value),
56
+ timestamp: (value) => typed("TIMESTAMP", timestamp(value)),
57
+ /** An array of one element type; needed for empty arrays and non-default element types. */
58
+ array: (type, values) => ({
59
+ [TYPED]: true,
60
+ type,
61
+ values: values.map((value) => scalar(value, "array element").value),
62
+ }),
63
+ };
64
+ function isTyped(value) {
65
+ return typeof value === "object" && value !== null && TYPED in value;
66
+ }
67
+ function scalar(value, name) {
68
+ switch (typeof value) {
69
+ case "string":
70
+ return { type: "STRING", value };
71
+ case "boolean":
72
+ return { type: "BOOL", value: String(value) };
73
+ case "bigint":
74
+ return { type: "INT64", value: String(value) };
75
+ case "number":
76
+ return Number.isSafeInteger(value)
77
+ ? { type: "INT64", value: String(value) }
78
+ : { type: "FLOAT64", value: String(value) };
79
+ default:
80
+ if (value instanceof Date) {
81
+ return { type: "TIMESTAMP", value: timestamp(value) };
82
+ }
83
+ throw new TypeError(`Query parameter "${name}" must be a string, number, bigint, boolean, Date, or sql.* value; ` +
84
+ "use SQL NULL in the query text instead of a null parameter.");
85
+ }
86
+ }
87
+ /** Infers warehouse types for named parameters and validates their names. */
88
+ export function encodeParameters(parameters) {
89
+ if (parameters === undefined) {
90
+ return [];
91
+ }
92
+ return Object.entries(parameters).map(([name, value]) => {
93
+ if (!NAME.test(name)) {
94
+ throw new TypeError(`Query parameter name "${name}" must start with a letter or underscore and contain only letters, digits, and underscores.`);
95
+ }
96
+ if (isTyped(value)) {
97
+ return value.values === undefined
98
+ ? { name, type: value.type, value: value.value ?? "" }
99
+ : { name, type: value.type, values: value.values };
100
+ }
101
+ if (Array.isArray(value)) {
102
+ if (value.length === 0) {
103
+ throw new TypeError(`Query parameter "${name}" is an empty array; use sql.array(type, []) to declare its element type.`);
104
+ }
105
+ const items = value.map((item) => scalar(item, name));
106
+ const type = items.every((item) => item.type === items[0]?.type)
107
+ ? items[0].type
108
+ : items.every((item) => item.type === "INT64" || item.type === "FLOAT64")
109
+ ? "FLOAT64"
110
+ : undefined;
111
+ if (type === undefined) {
112
+ throw new TypeError(`Query parameter "${name}" mixes element types; use sql.array(type, values).`);
113
+ }
114
+ return { name, type, values: items.map((item) => item.value) };
115
+ }
116
+ return { name, ...scalar(value, name) };
117
+ });
118
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@segmentstream-cloud/app",
3
- "version": "0.1.0",
4
- "description": "Sign-in and session handling for SegmentStream workspace apps built with Next.js.",
3
+ "version": "0.2.0",
4
+ "description": "Sign-in, sessions, and warehouse data access for SegmentStream workspace apps built with Next.js.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "SegmentStream",
7
7
  "homepage": "https://segmentstream.com",