@zeyolabs/kit 0.0.0-stage → 2026.11.6

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.
Files changed (95) hide show
  1. package/README.md +102 -2
  2. package/auth/accounts/accounts.remote.ts +116 -0
  3. package/auth/accounts/accounts.ts +332 -0
  4. package/auth/accounts/handle.ts +102 -0
  5. package/auth/accounts/invites.ts +149 -0
  6. package/auth/accounts/permissions.ts +90 -0
  7. package/auth/accounts/schemas.ts +22 -0
  8. package/auth/config.ts +77 -0
  9. package/auth/emails.ts +57 -0
  10. package/auth/schema.ts +152 -0
  11. package/auth/sessions/handle.ts +124 -0
  12. package/auth/sessions/magic-link/magic-link.ts +71 -0
  13. package/auth/sessions/passkeys/client.ts +115 -0
  14. package/auth/sessions/passkeys/passkeys.ts +295 -0
  15. package/auth/sessions/sessions.ts +101 -0
  16. package/auth/sveltekit/loads.ts +59 -0
  17. package/auth/sveltekit/login.ts +28 -0
  18. package/auth/sveltekit/routes.ts +264 -0
  19. package/auth/users.ts +49 -0
  20. package/cloudflare/access.ts +134 -0
  21. package/cloudflare/amqp/client.ts +276 -0
  22. package/cloudflare/amqp/testing.ts +9 -0
  23. package/cloudflare/context.ts +66 -0
  24. package/cloudflare/dispatch/client.ts +30 -0
  25. package/cloudflare/email/base.ts +59 -0
  26. package/cloudflare/email/client.ts +51 -0
  27. package/cloudflare/email/dev.ts +14 -0
  28. package/cloudflare/email/smtp.ts +65 -0
  29. package/cloudflare/email/testing.ts +18 -0
  30. package/cloudflare/r2/client.ts +154 -0
  31. package/cloudflare/r2/testing.ts +43 -0
  32. package/cloudflare/rate-limiter.ts +65 -0
  33. package/cloudflare/wait-until.ts +12 -0
  34. package/cloudflare/workers/index.ts +311 -0
  35. package/cloudflare/workers/testing.ts +94 -0
  36. package/cloudflare/workers/ticking-do.ts +104 -0
  37. package/cloudflare/workers-ai/client.ts +30 -0
  38. package/cloudflare/workers-ai/testing.ts +17 -0
  39. package/cloudflare/workers-env.ts +12 -0
  40. package/eslint-config.js +115 -0
  41. package/monitoring/alerts/client.ts +48 -0
  42. package/monitoring/alerts/event-config.ts +33 -0
  43. package/monitoring/alerts/policy.ts +39 -0
  44. package/monitoring/alerts/registry.ts +44 -0
  45. package/monitoring/gates/client.ts +90 -0
  46. package/monitoring/gates/dev.ts +29 -0
  47. package/monitoring/gates/testing.ts +30 -0
  48. package/monitoring/logger/client.ts +66 -0
  49. package/monitoring/prometheus/client.ts +147 -0
  50. package/monitoring/prometheus/dev.ts +23 -0
  51. package/monitoring/prometheus/gen/remote_write_pb.ts +105 -0
  52. package/monitoring/prometheus/metrics.ts +180 -0
  53. package/monitoring/prometheus/testing.ts +19 -0
  54. package/monitoring/slack/api.ts +68 -0
  55. package/monitoring/slack/client.ts +33 -0
  56. package/monitoring/slack/events.ts +15 -0
  57. package/package.json +156 -5
  58. package/server/logger.ts +63 -0
  59. package/server/rate-limit-client.ts +88 -0
  60. package/server/rate-limit.ts +24 -0
  61. package/server/remote-functions.ts +139 -0
  62. package/server/result.ts +18 -0
  63. package/server/turnstile.ts +19 -0
  64. package/storage/clickhouse/client.ts +441 -0
  65. package/storage/clickhouse/compiler.ts +218 -0
  66. package/storage/clickhouse/config.ts +13 -0
  67. package/storage/clickhouse/dev.ts +18 -0
  68. package/storage/clickhouse/error.ts +61 -0
  69. package/storage/clickhouse/index.ts +4 -0
  70. package/storage/clickhouse/native-decoder.ts +192 -0
  71. package/storage/clickhouse/readers.ts +167 -0
  72. package/storage/clickhouse/retry.ts +30 -0
  73. package/storage/clickhouse/schema.ts +163 -0
  74. package/storage/clickhouse/testing.ts +81 -0
  75. package/storage/manticore/client.ts +165 -0
  76. package/storage/manticore/schema.ts +23 -0
  77. package/storage/postgres/client.ts +44 -0
  78. package/storage/postgres/testing.ts +275 -0
  79. package/storage/redis/client.ts +219 -0
  80. package/storage/redis/protocol.ts +119 -0
  81. package/storage/redis/schema.ts +17 -0
  82. package/storage/redis/testing.ts +122 -0
  83. package/utils/crypto.ts +36 -0
  84. package/utils/datetime.ts +338 -0
  85. package/utils/email.ts +173 -0
  86. package/utils/env.ts +171 -0
  87. package/utils/errors.ts +67 -0
  88. package/utils/form.ts +135 -0
  89. package/utils/http-status.ts +42 -0
  90. package/utils/money.ts +10 -0
  91. package/utils/public-host.ts +66 -0
  92. package/utils/schema.ts +176 -0
  93. package/utils/strings/format.ts +51 -0
  94. package/utils/strings/normalize.ts +129 -0
  95. package/utils/transport.ts +25 -0
@@ -0,0 +1,441 @@
1
+ import type { z } from 'zod';
2
+
3
+ import { createClient } from '@clickhouse/client-web';
4
+ import { ZonedDateTime } from '@internationalized/date';
5
+
6
+ import type { ClickHouseColumn, ClickHouseSettings, TableEngine } from './schema';
7
+
8
+ import { logger } from '../../monitoring/logger/client';
9
+ import { Datetime } from '../../utils/datetime';
10
+ import { chTypeOf, compileSchema } from './compiler';
11
+ import { defaultReadSettings } from './config';
12
+ import { decodeNativeBuffer } from './native-decoder';
13
+ import { withTransportRetry } from './retry';
14
+
15
+ type ChSharedConfig = {
16
+ keepAlive?: boolean;
17
+ password: string;
18
+ requestTimeoutMs?: number;
19
+ settings?: ClickHouseSettings;
20
+ user: string;
21
+ };
22
+
23
+ export type ClickHouseClientConfig = ChSharedConfig & { vpc: Fetcher };
24
+
25
+ export interface IClickHouseClient {
26
+ command(
27
+ sql: string,
28
+ params?: Record<string, unknown>,
29
+ settings?: ClickHouseSettings
30
+ ): Promise<void>;
31
+ insert<S extends z.ZodObject>(
32
+ table: string,
33
+ schema: S,
34
+ rows: z.infer<S>[],
35
+ settings?: ClickHouseSettings
36
+ ): Promise<void>;
37
+ rawSelect<S extends z.ZodObject>(
38
+ schema: S,
39
+ sql: string,
40
+ params?: Record<string, unknown>,
41
+ settings?: ClickHouseSettings
42
+ ): Promise<z.infer<S>[]>;
43
+ select<T extends z.ZodObject, K extends keyof T['shape'] & string>(
44
+ table: ClickHouseTable<T>,
45
+ columns: K[]
46
+ ): Select<T, K>;
47
+ }
48
+
49
+ /** CH wire types whose value must be a JSON *string* on a `JSONCompactEachRow` insert: 64-bit+ ints (a bigint
50
+ * isn't JSON-serialisable — `JSON.stringify` throws) and `DateTime64` (a JSON number is REJECTED / mis-scaled
51
+ * as ticks — only a string of fractional epoch seconds parses). Anchored (`^`) so narrower types (`Int32`,
52
+ * plain `DateTime`) don't match; `U?Int` covers signed + unsigned. NB `Time64` is deliberately absent — CH
53
+ * wants a `'HH:MM:SS.fff'` CLOCK string for it, not the numeric seconds `String(value)` would emit, so a
54
+ * `Time64` insert needs a `z.string()` override (like an exact sub-second `DateTime64`). */
55
+ const INSERTS_AS_STRING = /^(U?Int(64|128|256)|Interval|DateTime64)/;
56
+
57
+ // Native-format read client. One instance per (runtime, config); the caller caches it (per-isolate
58
+ // singleton via the app's `ch()` factory). Reads run `FORMAT Native` and decode the columnar bytes with
59
+ // @clickhouse/rowbinary's leaf readers. Rows are NOT validated — CH is trusted (validation lives at the
60
+ // app edges); the ONE boundary transform, DateTime/DateTime64 → ZonedDateTime, rides on the leaf reader.
61
+ //
62
+ // The primary read surface is a column-SUBSET `select` returning a chainable `Select`: pick columns, then
63
+ // refine with `.where / .orderBy / .limitBy / .limit / .offset / .distinct / .settings`; `await` builds the
64
+ // engine-correct SQL (`buildSelect`) + runs it. `rawSelect` is the escape hatch for arbitrary `FORMAT
65
+ // Native` SQL the typed select can't express (CASTs, functions, aggregates, ad-hoc analytics).
66
+ export abstract class ClickHouseClientCore implements IClickHouseClient {
67
+ private readonly chClient: ReturnType<typeof createClient>;
68
+
69
+ protected constructor(
70
+ url: string,
71
+ fetchImpl: typeof fetch | undefined,
72
+ { keepAlive, password, requestTimeoutMs, settings, user }: ChSharedConfig
73
+ ) {
74
+ this.chClient = createClient({
75
+ // Applied to every request; a per-query `clickhouse_settings` is merged over these (caller wins).
76
+ clickhouse_settings: settings ?? defaultReadSettings(requestTimeoutMs),
77
+ fetch: fetchImpl,
78
+ // Undefined → client default (keep-alive on); `false` opts into a fresh connection per request.
79
+ keep_alive: keepAlive === false ? { enabled: false } : undefined,
80
+ password,
81
+ request_timeout: requestTimeoutMs ?? 60_000,
82
+ url,
83
+ username: user,
84
+ });
85
+ }
86
+
87
+ /** Run a statement that returns no result set — DDL, TRUNCATE, OPTIMIZE, INSERT … SELECT, etc. */
88
+ async command(
89
+ sql: string,
90
+ params?: Record<string, unknown>,
91
+ settings?: ClickHouseSettings
92
+ ): Promise<void> {
93
+ await this.withLogging(sql, async () => {
94
+ await this.chClient.command({
95
+ clickhouse_settings: settings,
96
+ query: sql,
97
+ query_params: params,
98
+ });
99
+ });
100
+ }
101
+
102
+ /** Insert rows as `JSONCompactEachRow`. The schema's field order IS the column order (assumed to match
103
+ * the table), so each row is mapped to a positional array → `INSERT INTO table (…columns) FORMAT
104
+ * JSONCompactEachRow`. Rows are NOT validated — CH is trusted (validation lives at the app edges). The
105
+ * only value transform is the DateTime boundary: a `ZonedDateTime` serialises to epoch seconds so the
106
+ * caller hands `insert` domain instants, not wire numbers. Two wire classes can't ride a JSON number
107
+ * and are coerced to strings inline (64-bit+ ints, `DateTime64`/`Time64`). */
108
+ async insert<S extends z.ZodObject>(
109
+ table: string,
110
+ schema: S,
111
+ rows: z.infer<S>[],
112
+ settings?: ClickHouseSettings
113
+ ): Promise<void> {
114
+ if (rows.length === 0) return; // nothing to insert — skip the (empty) network round-trip
115
+ const columns = Object.keys(schema.shape);
116
+ const chTypes = columns.map((c) => chTypeOf(schema.shape[c] as z.core.$ZodType) ?? '');
117
+ // Which columns serialise to a JSON string (per `INSERTS_AS_STRING`) — resolved ONCE, then a single flag
118
+ // check in the per-value loop below (a JSON write-side artifact only; the Native read decodes them back).
119
+ const asString = chTypes.map((t) => INSERTS_AS_STRING.test(t));
120
+ // DateTime64 keeps sub-second precision: a ZonedDateTime serialises to FRACTIONAL epoch seconds, not the
121
+ // whole-second `Datetime.toUnixSeconds` used for plain DateTime (which CH truncates to the second anyway).
122
+ const asDateTime64 = chTypes.map((t) => t.startsWith('DateTime64'));
123
+ const values = rows.map((row) => {
124
+ const r = row as Record<string, unknown>;
125
+ // Positional array for JSONCompactEachRow. Domain → wire: a ZonedDateTime → epoch seconds (the
126
+ // DateTime/DateTime64 boundary); the string-transport columns → String. Everything else rides raw.
127
+ return columns.map((column, i) => {
128
+ const raw = r[column];
129
+ const value =
130
+ raw instanceof ZonedDateTime
131
+ ? asDateTime64[i]
132
+ ? raw.toDate().getTime() / 1000
133
+ : Datetime.toUnixSeconds(raw)
134
+ : raw;
135
+ return asString[i] && value != null ? String(value) : value;
136
+ });
137
+ });
138
+ await this.withLogging(`insert ${table}`, async () => {
139
+ await this.chClient.insert({
140
+ clickhouse_settings: settings,
141
+ columns: columns as [string, ...string[]],
142
+ format: 'JSONCompactEachRow',
143
+ table,
144
+ values,
145
+ });
146
+ });
147
+ }
148
+
149
+ /** Escape hatch: run ARBITRARY `FORMAT Native` SQL and decode/validate it against a CH zod schema — for
150
+ * reads the typed `select` can't express (a CAST, a function, a table function, aggregates, ad-hoc
151
+ * analytics). Prefer `select`; reach for this only when a table column-subset won't do. `compileSchema`
152
+ * throws for opaque / undecodable types before any network call. */
153
+ async rawSelect<S extends z.ZodObject>(
154
+ schema: S,
155
+ sql: string,
156
+ params?: Record<string, unknown>,
157
+ settings?: ClickHouseSettings
158
+ ): Promise<z.infer<S>[]> {
159
+ return this.execAndDecodeNative(sql, compileSchema(schema), params, settings) as Promise<
160
+ z.infer<S>[]
161
+ >;
162
+ }
163
+
164
+ /** Open a column-subset read of a table — returns a chainable `Select`. `.where('id = {id:UInt32}', { id })`
165
+ * adds a parameterized WHERE; `.orderBy/.limitBy/.limit/.offset/.distinct` order + page the result (uniform
166
+ * across engines — the collapse is handled in the FROM). `await` (or `.then`) builds the engine-correct SQL,
167
+ * runs it `FORMAT Native`, decodes + validates against the picked schema. */
168
+ select<T extends z.ZodObject, K extends keyof T['shape'] & string>(
169
+ table: ClickHouseTable<T>,
170
+ columns: K[]
171
+ ): Select<T, K> {
172
+ return new SelectImpl<T, K>(table, { columns }, (sql, decodeColumns, params, settings) =>
173
+ this.execAndDecodeNative(sql, decodeColumns, params, settings)
174
+ );
175
+ }
176
+
177
+ /** Shared read path: run `sql FORMAT Native`, drain the stream to one buffer, and decode the columnar
178
+ * bytes with the precompiled decode columns. No validation — the decode tree yields the rows directly. */
179
+ private async execAndDecodeNative(
180
+ sql: string,
181
+ decodeColumns: ClickHouseColumn[],
182
+ params?: Record<string, unknown>,
183
+ settings?: ClickHouseSettings
184
+ ): Promise<Record<string, unknown>[]> {
185
+ // Retry exec + drain (a dropped connection throws) — reads are idempotent; the trace fires when the
186
+ // bytes land, or once the retry is exhausted. Decode stays OUTSIDE the retry so a decode bug isn't
187
+ // misread as a transient blip.
188
+ const bytes = await this.withLogging(sql, () =>
189
+ withTransportRetry(async (signal) => {
190
+ // Strip a trailing `;` — we append ` FORMAT Native`, and `…; FORMAT Native` trips CH's
191
+ // "Multi-statements are not allowed".
192
+ const { stream } = await this.chClient.exec({
193
+ abort_signal: signal,
194
+ clickhouse_settings: settings,
195
+ query: `${sql.replace(/;\s*$/, '')} FORMAT Native`,
196
+ query_params: params,
197
+ });
198
+ // Drain the response stream to one contiguous buffer via the platform Response API (no manual
199
+ // chunk loop). Cursor needs a Node Buffer (string reads use buf.toString('utf8')); Buffer =
200
+ // nodejs_compat.
201
+ return new Response(stream as unknown as ReadableStream<Uint8Array>).arrayBuffer();
202
+ })
203
+ );
204
+ return decodeNativeBuffer(Buffer.from(bytes), decodeColumns);
205
+ }
206
+
207
+ /** Run a CH round-trip, reporting ERROR + Sentry on failure. Timing/tracing lives in real spans, not here. */
208
+ private async withLogging<T>(summary: string, run: () => Promise<T>): Promise<T> {
209
+ try {
210
+ return await run();
211
+ } catch (err) {
212
+ await logger.error(err, { op: 'clickhouse', summary });
213
+ throw err;
214
+ }
215
+ }
216
+ }
217
+
218
+ export class ClickHouseClient extends ClickHouseClientCore {
219
+ constructor({ vpc, ...config }: ClickHouseClientConfig) {
220
+ super('http://localhost:8123', vpc.fetch.bind(vpc), config);
221
+ }
222
+ }
223
+
224
+ /**
225
+ * A ClickHouse table: a zod object of ALL columns (the single source that drives both Native decode and row
226
+ * validation) + the engine descriptor. Reads go through `ClickHouseClient.select`; the table only compiles the
227
+ * engine-correct SQL (`buildSelect`), cached per read shape. The engine is a runtime value (not a type param) —
228
+ * the chain is uniform (`Select<T,K>`) because the collapse is handled entirely in the FROM, never the tail.
229
+ *
230
+ * PURE and connection-free. Construct ONCE as a module constant: it owns the select cache + per-column decode
231
+ * plans, so an inline `new` in a hot path would get a cold cache every call.
232
+ */
233
+ export class ClickHouseTable<T extends z.ZodObject = z.ZodObject> {
234
+ readonly engine: TableEngine;
235
+ readonly name: string;
236
+ /** The zod object of ALL columns — drives Native decode (the wire type + leaf readers). */
237
+ readonly schema: T;
238
+ /** Each column's decode plan, compiled ONCE at construction and keyed by name — so a column shared across
239
+ * subsets is compiled a single time; `buildSelect` PICKS from here instead of recompiling per subset. */
240
+ private readonly compiledColumns: Map<string, ClickHouseColumn>;
241
+ /** Built SELECTs (SQL + decode columns), keyed by the read's value-independent shape. */
242
+ private readonly selectCache = new Map<string, { columns: ClickHouseColumn[]; sql: string }>();
243
+
244
+ constructor(def: { engine: TableEngine; name: string; schema: T }) {
245
+ this.name = def.name;
246
+ this.schema = def.schema;
247
+ this.engine = def.engine;
248
+ // Compile every column once at construction (tables are module constants): fail-fast on an undecodable
249
+ // column at module load, AND keep the decode plans by name so a column shared across subsets is compiled
250
+ // once — `buildSelect` picks from here rather than recompiling per read.
251
+ this.compiledColumns = new Map(compileSchema(def.schema).map((c) => [c.name, c]));
252
+ }
253
+
254
+ /** Build (and memoize) the engine-correct SELECT for a read spec. Keyed by the JSON of the spec's
255
+ * value-independent parts — structure-faithful, so a comma in a column name can't collide two reads. */
256
+ cachedSelect(spec: SelectSpec): { columns: ClickHouseColumn[]; sql: string } {
257
+ const key = JSON.stringify([
258
+ spec.columns,
259
+ spec.where ?? null,
260
+ spec.orderBy ?? null,
261
+ spec.limitBy ?? null,
262
+ spec.limit ?? null,
263
+ spec.offset ?? null,
264
+ spec.distinct ?? false,
265
+ ]);
266
+ let hit = this.selectCache.get(key);
267
+ if (!hit) {
268
+ hit = this.buildSelect(spec);
269
+ this.selectCache.set(key, hit);
270
+ }
271
+ return hit;
272
+ }
273
+
274
+ /** Engine-correct SELECT for a read spec. The collapse lives in the FROM — a bare table (MergeTree/Plain),
275
+ * `FINAL` (Replacing/Summing final:true, Collapsing, VersionedCollapsing), or `sum()+GROUP BY` (Summing
276
+ * final:false) — so the user tail (`ORDER BY`/`LIMIT BY`/`LIMIT`/`DISTINCT`) appends uniformly on top.
277
+ *
278
+ * The ONE exception is Replacing final:false: its cheap `LIMIT 1 BY` collapse OCCUPIES the `ORDER BY` slot,
279
+ * so a read that also carries a user tail is wrapped in a subquery (collapse inside — with the WHERE, to keep
280
+ * filter-then-collapse — and the user tail outside). With no user tail it stays flat (byte-identical to a
281
+ * bare `LIMIT 1 BY` read), so the common point-read SQL is unchanged. */
282
+ private buildSelect(spec: SelectSpec): {
283
+ columns: ClickHouseColumn[];
284
+ sql: string;
285
+ } {
286
+ const { columns, distinct, limit, limitBy, offset, orderBy, where } = spec;
287
+ const engine = this.engine;
288
+ const proj = columns.join(', ');
289
+ const distinctKw = distinct ? 'DISTINCT ' : '';
290
+ const whereClause = where ? ` WHERE ${where}` : '';
291
+ const orderClause = orderBy?.length
292
+ ? ` ORDER BY ${orderBy.map((o) => `${o.column} ${o.dir}`).join(', ')}`
293
+ : '';
294
+ // `LIMIT n BY <keys>` sits AFTER ORDER BY (which decides WHICH n per key) and BEFORE the outer LIMIT.
295
+ const limitByClause = limitBy ? ` LIMIT ${limitBy.n} BY ${limitBy.by.join(', ')}` : '';
296
+ const limitClause =
297
+ limit != null ? ` LIMIT ${limit}${offset != null ? ` OFFSET ${offset}` : ''}` : '';
298
+ const userTail = `${orderClause}${limitByClause}${limitClause}`;
299
+
300
+ let sql: string;
301
+ if (engine.kind === 'ReplacingMergeTree' && !engine.final) {
302
+ // Cheap latest-per-key: filter (WHERE) THEN collapse via ORDER BY … LIMIT 1 BY.
303
+ const key = engine.sortKey.join(', ');
304
+ const ordering = engine.version ? `${key}, ${engine.version} DESC` : key;
305
+ const collapse = `${whereClause} ORDER BY ${ordering} LIMIT 1 BY ${key}`;
306
+ if (!distinct && !userTail) {
307
+ sql = `SELECT ${proj} FROM ${this.name}${collapse}`;
308
+ } else {
309
+ // User tail would collide with the collapse's own tail → wrap. The inner must also project the
310
+ // sortKey + version so its ORDER BY / LIMIT 1 BY can reference them; the outer selects just `proj`.
311
+ const innerCols = [
312
+ ...new Set([...columns, ...engine.sortKey, ...(engine.version ? [engine.version] : [])]),
313
+ ].join(', ');
314
+ sql = `SELECT ${distinctKw}${proj} FROM (SELECT ${innerCols} FROM ${this.name}${collapse})${userTail}`;
315
+ }
316
+ } else {
317
+ // Flat forms: the collapse is a FROM modifier (FINAL) or a same-level GROUP BY, so the user tail appends
318
+ // directly. WHERE sits after FROM — Summing filters before its GROUP BY; FINAL filters after the collapse
319
+ // (its correctness contract). MergeTree/Plain are a bare projection.
320
+ const usesFinal =
321
+ (engine.kind === 'ReplacingMergeTree' && engine.final) ||
322
+ (engine.kind === 'SummingMergeTree' && engine.final) ||
323
+ engine.kind === 'CollapsingMergeTree' ||
324
+ engine.kind === 'VersionedCollapsingMergeTree';
325
+ const base = usesFinal ? `${this.name} FINAL` : this.name;
326
+ let projFinal = proj;
327
+ let groupBy = '';
328
+ if (engine.kind === 'SummingMergeTree' && !engine.final) {
329
+ const measures = new Set<string>(engine.measures);
330
+ projFinal = columns.map((c) => (measures.has(c) ? `sum(${c}) AS ${c}` : c)).join(', ');
331
+ const groupKeys = columns.filter((c) => !measures.has(c));
332
+ if (groupKeys.length) groupBy = ` GROUP BY ${groupKeys.join(', ')}`;
333
+ }
334
+ sql = `SELECT ${distinctKw}${projFinal} FROM ${base}${whereClause}${groupBy}${userTail}`;
335
+ }
336
+ // Pick the decode columns in the COLUMNS-ARRAY order (not the table's field order) so they line up
337
+ // positionally with the SELECT list whatever order the caller picked. Decode plans are picked by name
338
+ // (compiled once at construction, DateTime leaves already guarded), never recompiled per read.
339
+ return { columns: columns.map((c) => this.compiledColumns.get(c)!), sql };
340
+ }
341
+ }
342
+
343
+ /** The value-independent shape of a built read — what `cachedSelect` keys + compiles. `params`/`settings`
344
+ * are runtime-only and live on the `Select`, not here. */
345
+ type SelectSpec = {
346
+ columns: string[];
347
+ distinct?: boolean;
348
+ limit?: number;
349
+ /** `LIMIT n BY <keys>` — keep n rows per distinct key tuple (top-N-per-group). */
350
+ limitBy?: { by: string[]; n: number };
351
+ offset?: number;
352
+ orderBy?: { column: string; dir: 'ASC' | 'DESC' }[];
353
+ where?: string;
354
+ };
355
+
356
+ type Rows<T extends z.ZodObject, K extends keyof T['shape'] & string> = Pick<z.infer<T>, K>[];
357
+ type SelectRunner = (
358
+ sql: string,
359
+ decodeColumns: ClickHouseColumn[],
360
+ params?: Record<string, unknown>,
361
+ settings?: ClickHouseSettings
362
+ ) => Promise<unknown[]>;
363
+ // ── the chainable Select ────────────────────────────────────────────────────────────────────────────
364
+ type SortDir = 'asc' | 'desc';
365
+
366
+ /** A chainable read: pick columns, then refine with `.where`/`.orderBy`/`.limitBy`/`.limit`/`.offset`/
367
+ * `.distinct`/`.settings`. Uniform across engines — the collapse is handled in the FROM (see `buildSelect`),
368
+ * so every method is available regardless of engine. `await` (or `.then`) builds the SQL + runs it (Native
369
+ * decode). */
370
+ export interface Select<
371
+ T extends z.ZodObject,
372
+ K extends keyof T['shape'] & string,
373
+ > extends PromiseLike<Rows<T, K>> {
374
+ distinct(): Select<T, K>;
375
+ limit(n: number): Select<T, K>;
376
+ /** `LIMIT n BY <keys>` — keep n rows per distinct key tuple (top-N-per-group). Pair with `orderBy` to
377
+ * decide WHICH n. Replaces any prior `limitBy` on the chain. */
378
+ limitBy(n: number, ...by: K[]): Select<T, K>;
379
+ offset(n: number): Select<T, K>;
380
+ /** Append an `ORDER BY` key (typed column + direction). Call order is the sort precedence. */
381
+ orderBy(column: K, dir?: SortDir): Select<T, K>;
382
+ /** Per-query ClickHouse settings. */
383
+ settings(settings: ClickHouseSettings): Select<T, K>;
384
+ /** Parameterized WHERE template — `where('id = {id:UInt32}', { id })`. */
385
+ where(sql: string, params?: Record<string, unknown>): Select<T, K>;
386
+ }
387
+
388
+ /** Runtime impl of `Select` — accumulates a `SelectSpec`; `then` builds the engine-correct SQL and runs it. */
389
+ class SelectImpl<T extends z.ZodObject, K extends keyof T['shape'] & string> implements Select<
390
+ T,
391
+ K
392
+ > {
393
+ constructor(
394
+ private readonly table: ClickHouseTable<T>,
395
+ private readonly spec: SelectSpec,
396
+ private readonly run: SelectRunner,
397
+ private readonly params?: Record<string, unknown>,
398
+ private readonly extraSettings?: ClickHouseSettings
399
+ ) {}
400
+
401
+ distinct(): Select<T, K> {
402
+ return this.patch({ distinct: true });
403
+ }
404
+
405
+ limit(n: number): Select<T, K> {
406
+ return this.patch({ limit: n });
407
+ }
408
+ limitBy(n: number, ...by: K[]): Select<T, K> {
409
+ return this.patch({ limitBy: { by, n } });
410
+ }
411
+ offset(n: number): Select<T, K> {
412
+ return this.patch({ offset: n });
413
+ }
414
+ orderBy(column: K, dir: SortDir = 'asc'): Select<T, K> {
415
+ const item = { column, dir: dir === 'desc' ? ('DESC' as const) : ('ASC' as const) };
416
+ return this.patch({ orderBy: [...(this.spec.orderBy ?? []), item] });
417
+ }
418
+ settings(settings: ClickHouseSettings): Select<T, K> {
419
+ return this.patch({}, this.params, settings);
420
+ }
421
+ then<F = Rows<T, K>, J = never>(
422
+ onFulfilled?: ((value: Rows<T, K>) => F | PromiseLike<F>) | null,
423
+ onRejected?: ((reason: unknown) => J | PromiseLike<J>) | null
424
+ ): PromiseLike<F | J> {
425
+ const built = this.table.cachedSelect(this.spec);
426
+ return (
427
+ this.run(built.sql, built.columns, this.params, this.extraSettings) as Promise<Rows<T, K>>
428
+ ).then(onFulfilled, onRejected);
429
+ }
430
+ where(sql: string, params?: Record<string, unknown>): Select<T, K> {
431
+ return this.patch({ where: sql }, params);
432
+ }
433
+
434
+ private patch(
435
+ p: Partial<SelectSpec>,
436
+ params = this.params,
437
+ settings = this.extraSettings
438
+ ): Select<T, K> {
439
+ return new SelectImpl<T, K>(this.table, { ...this.spec, ...p }, this.run, params, settings);
440
+ }
441
+ }
@@ -0,0 +1,218 @@
1
+ import type { Reader } from '@clickhouse/rowbinary';
2
+
3
+ import { z } from 'zod';
4
+
5
+ import type { ClickHouseColumn, ColumnNode } from './schema';
6
+
7
+ import { Datetime } from '../../utils/datetime';
8
+ import { AggregateFunctionNativeDecodingError, UndecodableColumnError } from './error';
9
+ import { scalarReader } from './readers';
10
+
11
+ /** Wrap a scalar leaf reader so a `DateTime`/`DateTime64` column surfaces as a domain `ZonedDateTime`,
12
+ * never a raw epoch number — the CH-boundary conversion the read path owns now that rows are no longer
13
+ * run through zod. Keyed on the wire type, so it rides through `Nullable`/`Array`/… (the wrappers compose
14
+ * from this leaf). `Date`/`Date32`/`Time`/`Time64` are NOT instants, so they pass through untouched. */
15
+ function dateTimeGuard(ch: string, read: Reader<unknown>): Reader<unknown> {
16
+ if (!ch.startsWith('DateTime')) return read;
17
+ return (cursor) => Datetime.zonedFromUnixSeconds(read(cursor) as number);
18
+ }
19
+
20
+ /** Compile a read schema into its columns (in field order). Each column pairs the derived CH wire type
21
+ * (the drift-guard key) with its compiled decode tree. A PURE builder — no memoization here; callers
22
+ * cache the result on the long-lived `ClickHouseTable` (per-column at construction + per query). */
23
+ export function compileSchema(schema: z.ZodObject): ClickHouseColumn[] {
24
+ return Object.entries(schema.shape).map(([name, zodType]) => {
25
+ const { node, type } = compileColumn(zodType as z.core.$ZodType);
26
+ return { decodeTree: node, name, type };
27
+ });
28
+ }
29
+
30
+ /** One walk of a zod field → its full CH wire type (the drift-guard key) AND its decode tree. A
31
+ * scalar primitive carries both on its `.meta({ ch, read })`, so its leaf is read directly (no
32
+ * type-string switch); structural wrappers (Array/Nullable/Tuple/Map) compose from the zod shape,
33
+ * setting `asMap`/`asSet` inline; a raw `.meta({ ch })` string with no `read` (geo, LowCardinality,
34
+ * Variant, Nested) is authoritative and parsed by `parseTypeTree`. */
35
+ function compileColumn(field: z.core.$ZodType): { node: ColumnNode; type: string } {
36
+ field = unwrapModifiers(field);
37
+ const meta = z.globalRegistry.get(field);
38
+ const ch = meta?.ch;
39
+ if (typeof ch === 'string') {
40
+ const read = meta?.read as Reader<unknown> | undefined;
41
+ if (read) return { node: { kind: 'scalar', read: dateTimeGuard(ch, read) }, type: ch };
42
+ // Raw ch string is authoritative — parse it (geo, LowCardinality, Variant, Nested, …).
43
+ return { node: parseTypeTree(ch), type: ch };
44
+ }
45
+ if (field instanceof z.ZodNullable) {
46
+ const inner = compileColumn(field.unwrap());
47
+ return { node: { inner: inner.node, kind: 'nullable' }, type: `Nullable(${inner.type})` };
48
+ }
49
+ if (field instanceof z.ZodArray) {
50
+ const inner = compileColumn(field.element);
51
+ return { node: { inner: inner.node, kind: 'array' }, type: `Array(${inner.type})` };
52
+ }
53
+ // z.set → JS Set: same Array(...) wire type, flagged so the reader rebuilds a Set.
54
+ if (field instanceof z.ZodSet) {
55
+ const inner = compileColumn(field.def.valueType);
56
+ return {
57
+ node: { asSet: true, inner: inner.node, kind: 'array' },
58
+ type: `Array(${inner.type})`,
59
+ };
60
+ }
61
+ if (field instanceof z.ZodObject) {
62
+ const fields = Object.entries(field.shape).map(([name, value]) => {
63
+ const inner = compileColumn(value as z.core.$ZodType);
64
+ return { name, node: inner.node, type: inner.type };
65
+ });
66
+ return {
67
+ node: {
68
+ fields: fields.map((f) => ({ name: f.name, node: f.node })),
69
+ kind: 'tuple',
70
+ named: true,
71
+ },
72
+ type: `Tuple(${fields.map((f) => `${f.name} ${f.type}`).join(', ')})`,
73
+ };
74
+ }
75
+ if (field instanceof z.ZodTuple) {
76
+ const items = field.def.items.map((item) => compileColumn(item));
77
+ return {
78
+ node: { fields: items.map((i) => ({ node: i.node })), kind: 'tuple', named: false },
79
+ type: `Tuple(${items.map((i) => i.type).join(', ')})`,
80
+ };
81
+ }
82
+ // z.record → Map(...) object; z.map → JS Map, flagged so the reader rebuilds a Map (typed keys).
83
+ if (field instanceof z.ZodRecord) {
84
+ const key = compileColumn(field.keyType);
85
+ const value = compileColumn(field.valueType);
86
+ return {
87
+ node: { key: key.node, kind: 'map', value: value.node },
88
+ type: `Map(${key.type}, ${value.type})`,
89
+ };
90
+ }
91
+ if (field instanceof z.ZodMap) {
92
+ const key = compileColumn(field.keyType);
93
+ const value = compileColumn(field.valueType);
94
+ return {
95
+ node: { asMap: true, key: key.node, kind: 'map', value: value.node },
96
+ type: `Map(${key.type}, ${value.type})`,
97
+ };
98
+ }
99
+ throw new UndecodableColumnError(field);
100
+ }
101
+
102
+ /** Strip zod-only wrappers that leave no trace in the decode tree, exposing the schema the tree was
103
+ * compiled from (a pipe's CH side is its input schema). */
104
+ function unwrapModifiers(field: z.core.$ZodType): z.core.$ZodType {
105
+ for (;;) {
106
+ if (
107
+ field instanceof z.ZodOptional ||
108
+ field instanceof z.ZodNonOptional ||
109
+ field instanceof z.ZodDefault ||
110
+ field instanceof z.ZodPrefault ||
111
+ field instanceof z.ZodCatch ||
112
+ field instanceof z.ZodReadonly ||
113
+ field instanceof z.ZodLazy
114
+ )
115
+ field = field.unwrap();
116
+ else if (field instanceof z.ZodPipe) field = field.def.in;
117
+ else return field;
118
+ }
119
+ }
120
+
121
+ /** The CH wire type on a column's leaf `.meta({ ch })`, or `undefined` if it has none — a bare `z.string()`
122
+ * insert override, or a structural type with no leaf ch. A lenient, TYPE-ONLY companion to `compileColumn`
123
+ * (which builds the decode tree and THROWS on an untyped column): callers that only need the wire type — e.g.
124
+ * `insert` choosing a value's JSON transport form — use this. Unwraps the modifier wrappers + a Nullable. */
125
+ export function chTypeOf(field: z.core.$ZodType): string | undefined {
126
+ let f = unwrapModifiers(field);
127
+ if (f instanceof z.ZodNullable) f = unwrapModifiers(f.unwrap());
128
+ const ch = z.globalRegistry.get(f)?.ch;
129
+ return typeof ch === 'string' ? ch : undefined;
130
+ }
131
+
132
+ /** The FULL CH wire type of a field — e.g. `Nullable(DateTime64(3))`, `Array(UInt32)` — i.e. `compileColumn`'s
133
+ * type side, without building the decode tree it discards. Lets the `CH.Variant`/`CH.Nested` factory helpers
134
+ * assemble a composite ch string from their inner primitives (which may themselves be wrapped). Throws (like
135
+ * `compileColumn`) if a field has no derivable wire type. */
136
+ export function chWireType(field: z.core.$ZodType): string {
137
+ return compileColumn(field).type;
138
+ }
139
+
140
+ /** Split CH type arguments on top-level commas, ignoring commas nested in parens
141
+ * (`Tuple(a UInt32, b Map(String, UInt32))` → two args, not three). */
142
+ function splitArgs(s: string): string[] {
143
+ const out: string[] = [];
144
+ let depth = 0;
145
+ let start = 0;
146
+ let inQuotes = false;
147
+ for (let i = 0; i < s.length; i++) {
148
+ const c = s[i];
149
+ // Track single-quoted literals (e.g. an Enum member name) so a `(` or `,` inside one doesn't
150
+ // desync the paren depth or split mid-name. `\'` is an escaped quote, not a delimiter.
151
+ if (c === "'" && s[i - 1] !== '\\') inQuotes = !inQuotes;
152
+ else if (!inQuotes) {
153
+ if (c === '(') depth++;
154
+ else if (c === ')') depth--;
155
+ else if (c === ',' && depth === 0) {
156
+ out.push(s.slice(start, i).trim());
157
+ start = i + 1;
158
+ }
159
+ }
160
+ }
161
+ out.push(s.slice(start).trim());
162
+ return out;
163
+ }
164
+
165
+ /** Parse a raw CH type STRING into a decode tree — for the `.meta({ ch })` escape leaves (geo,
166
+ * LowCardinality, Variant, Nested) and `raw()` ad-hoc SQL, where there's no zod leaf carrying a
167
+ * reader. Done once per column, not per value; scalar leaves resolve through `scalarReader`. */
168
+ function parseTypeTree(type: string): ColumnNode {
169
+ type = type.trim(); // tolerate stray whitespace in a hand-written `.meta({ ch })` string
170
+ /** Inner of a `Wrapper(...)` type: strip the named prefix and the closing paren. */
171
+ const arg = (prefix: string) => type.slice(prefix.length, -1).trim();
172
+
173
+ // Alias / transparent types recurse on their structural form: geo → Tuple/Array of Float64,
174
+ // SimpleAggregateFunction → its value type, Nested → Array(Tuple(...)).
175
+ if (type === 'Point') return parseTypeTree('Tuple(Float64, Float64)');
176
+ if (type === 'Ring' || type === 'LineString')
177
+ return parseTypeTree('Array(Tuple(Float64, Float64))');
178
+ if (type === 'Polygon' || type === 'MultiLineString')
179
+ return parseTypeTree('Array(Array(Tuple(Float64, Float64)))');
180
+ if (type === 'MultiPolygon') return parseTypeTree('Array(Array(Array(Tuple(Float64, Float64))))');
181
+ if (type.startsWith('SimpleAggregateFunction('))
182
+ return parseTypeTree(splitArgs(arg('SimpleAggregateFunction(')).at(-1)!);
183
+ if (type.startsWith('Nested(')) return parseTypeTree(`Array(Tuple(${arg('Nested(')}))`);
184
+
185
+ if (type.startsWith('Tuple(')) {
186
+ const fields = splitArgs(arg('Tuple(')).map((field) => {
187
+ // `name Type` (named) or `Type` (anon); the boundary is the first space before any '('.
188
+ const space = field.indexOf(' ');
189
+ const paren = field.indexOf('(');
190
+ const named = space > 0 && (paren < 0 || space < paren);
191
+ return {
192
+ name: named ? field.slice(0, space) : undefined,
193
+ node: parseTypeTree(named ? field.slice(space + 1) : field),
194
+ };
195
+ });
196
+ return { fields, kind: 'tuple', named: fields.every((f) => f.name !== undefined) };
197
+ }
198
+ if (type.startsWith('Map(')) {
199
+ const [key, value] = splitArgs(arg('Map('));
200
+ return { key: parseTypeTree(key), kind: 'map', value: parseTypeTree(value) };
201
+ }
202
+ if (type.startsWith('Variant('))
203
+ return { kind: 'variant', variants: splitArgs(arg('Variant(')).map(parseTypeTree) };
204
+ if (type.startsWith('AggregateFunction(')) throw new AggregateFunctionNativeDecodingError(type);
205
+ if (type.startsWith('Nullable('))
206
+ return { inner: parseTypeTree(arg('Nullable(')), kind: 'nullable' };
207
+ if (type.startsWith('Array(')) return { inner: parseTypeTree(arg('Array(')), kind: 'array' };
208
+ if (type.startsWith('LowCardinality(')) {
209
+ const inner = arg('LowCardinality(');
210
+ const nullable = inner.startsWith('Nullable(');
211
+ return {
212
+ inner: parseTypeTree(nullable ? inner.slice('Nullable('.length, -1).trim() : inner),
213
+ kind: 'lowCardinality',
214
+ nullable,
215
+ };
216
+ }
217
+ return { kind: 'scalar', read: dateTimeGuard(type, scalarReader(type)) };
218
+ }
@@ -0,0 +1,13 @@
1
+ import type { ClickHouseSettings } from './schema';
2
+
3
+ const REQUEST_TIMEOUT_MS = 60_000;
4
+
5
+ /** Settings applied to every request. `max_execution_time` is the server-side twin of the client's
6
+ * request timeout (ms → whole seconds); `max_threads` caps per-query parallelism so one heavy scan
7
+ * can't monopolise the cluster — a genuinely heavy read raises it via a per-query `.settings()`. */
8
+ export function defaultReadSettings(requestTimeoutMs = REQUEST_TIMEOUT_MS): ClickHouseSettings {
9
+ return {
10
+ max_execution_time: Math.ceil(requestTimeoutMs / 1000),
11
+ max_threads: 8,
12
+ };
13
+ }