@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.
- package/README.md +102 -2
- package/auth/accounts/accounts.remote.ts +116 -0
- package/auth/accounts/accounts.ts +332 -0
- package/auth/accounts/handle.ts +102 -0
- package/auth/accounts/invites.ts +149 -0
- package/auth/accounts/permissions.ts +90 -0
- package/auth/accounts/schemas.ts +22 -0
- package/auth/config.ts +77 -0
- package/auth/emails.ts +57 -0
- package/auth/schema.ts +152 -0
- package/auth/sessions/handle.ts +124 -0
- package/auth/sessions/magic-link/magic-link.ts +71 -0
- package/auth/sessions/passkeys/client.ts +115 -0
- package/auth/sessions/passkeys/passkeys.ts +295 -0
- package/auth/sessions/sessions.ts +101 -0
- package/auth/sveltekit/loads.ts +59 -0
- package/auth/sveltekit/login.ts +28 -0
- package/auth/sveltekit/routes.ts +264 -0
- package/auth/users.ts +49 -0
- package/cloudflare/access.ts +134 -0
- package/cloudflare/amqp/client.ts +276 -0
- package/cloudflare/amqp/testing.ts +9 -0
- package/cloudflare/context.ts +66 -0
- package/cloudflare/dispatch/client.ts +30 -0
- package/cloudflare/email/base.ts +59 -0
- package/cloudflare/email/client.ts +51 -0
- package/cloudflare/email/dev.ts +14 -0
- package/cloudflare/email/smtp.ts +65 -0
- package/cloudflare/email/testing.ts +18 -0
- package/cloudflare/r2/client.ts +154 -0
- package/cloudflare/r2/testing.ts +43 -0
- package/cloudflare/rate-limiter.ts +65 -0
- package/cloudflare/wait-until.ts +12 -0
- package/cloudflare/workers/index.ts +311 -0
- package/cloudflare/workers/testing.ts +94 -0
- package/cloudflare/workers/ticking-do.ts +104 -0
- package/cloudflare/workers-ai/client.ts +30 -0
- package/cloudflare/workers-ai/testing.ts +17 -0
- package/cloudflare/workers-env.ts +12 -0
- package/eslint-config.js +115 -0
- package/monitoring/alerts/client.ts +48 -0
- package/monitoring/alerts/event-config.ts +33 -0
- package/monitoring/alerts/policy.ts +39 -0
- package/monitoring/alerts/registry.ts +44 -0
- package/monitoring/gates/client.ts +90 -0
- package/monitoring/gates/dev.ts +29 -0
- package/monitoring/gates/testing.ts +30 -0
- package/monitoring/logger/client.ts +66 -0
- package/monitoring/prometheus/client.ts +147 -0
- package/monitoring/prometheus/dev.ts +23 -0
- package/monitoring/prometheus/gen/remote_write_pb.ts +105 -0
- package/monitoring/prometheus/metrics.ts +180 -0
- package/monitoring/prometheus/testing.ts +19 -0
- package/monitoring/slack/api.ts +68 -0
- package/monitoring/slack/client.ts +33 -0
- package/monitoring/slack/events.ts +15 -0
- package/package.json +156 -5
- package/server/logger.ts +63 -0
- package/server/rate-limit-client.ts +88 -0
- package/server/rate-limit.ts +24 -0
- package/server/remote-functions.ts +139 -0
- package/server/result.ts +18 -0
- package/server/turnstile.ts +19 -0
- package/storage/clickhouse/client.ts +441 -0
- package/storage/clickhouse/compiler.ts +218 -0
- package/storage/clickhouse/config.ts +13 -0
- package/storage/clickhouse/dev.ts +18 -0
- package/storage/clickhouse/error.ts +61 -0
- package/storage/clickhouse/index.ts +4 -0
- package/storage/clickhouse/native-decoder.ts +192 -0
- package/storage/clickhouse/readers.ts +167 -0
- package/storage/clickhouse/retry.ts +30 -0
- package/storage/clickhouse/schema.ts +163 -0
- package/storage/clickhouse/testing.ts +81 -0
- package/storage/manticore/client.ts +165 -0
- package/storage/manticore/schema.ts +23 -0
- package/storage/postgres/client.ts +44 -0
- package/storage/postgres/testing.ts +275 -0
- package/storage/redis/client.ts +219 -0
- package/storage/redis/protocol.ts +119 -0
- package/storage/redis/schema.ts +17 -0
- package/storage/redis/testing.ts +122 -0
- package/utils/crypto.ts +36 -0
- package/utils/datetime.ts +338 -0
- package/utils/email.ts +173 -0
- package/utils/env.ts +171 -0
- package/utils/errors.ts +67 -0
- package/utils/form.ts +135 -0
- package/utils/http-status.ts +42 -0
- package/utils/money.ts +10 -0
- package/utils/public-host.ts +66 -0
- package/utils/schema.ts +176 -0
- package/utils/strings/format.ts +51 -0
- package/utils/strings/normalize.ts +129 -0
- package/utils/transport.ts +25 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { ClickHouseSettings } from './schema';
|
|
2
|
+
|
|
3
|
+
import { ClickHouseClientCore } from './client';
|
|
4
|
+
|
|
5
|
+
export type ClickHouseDevClientConfig = {
|
|
6
|
+
keepAlive?: boolean;
|
|
7
|
+
password: string;
|
|
8
|
+
requestTimeoutMs?: number;
|
|
9
|
+
settings?: ClickHouseSettings;
|
|
10
|
+
url: string;
|
|
11
|
+
user: string;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export class ClickHouseDevClient extends ClickHouseClientCore {
|
|
15
|
+
constructor({ url, ...config }: ClickHouseDevClientConfig) {
|
|
16
|
+
super(url, undefined, config);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* An `AggregateFunction()` column holds the aggregate function's own serialized state
|
|
5
|
+
* (function-specific binary blob with NO length prefix, https://clickhouse.com/docs/sql-reference/data-types/aggregatefunction) so there is no generic way to delimit or
|
|
6
|
+
* interpret it without reimplementing each function's deserializer (and trying would desync the rest
|
|
7
|
+
* of the block). Finalize it server-side instead with `finalizeAggregation(col)` or `<func>Merge(col)`
|
|
8
|
+
*/
|
|
9
|
+
export class AggregateFunctionNativeDecodingError extends Error {
|
|
10
|
+
constructor(type: string) {
|
|
11
|
+
super(
|
|
12
|
+
`Native decode: AggregateFunction state is opaque and not generically decodable (${type})`
|
|
13
|
+
);
|
|
14
|
+
this.name = 'AggregateFunctionNativeDecodingError';
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A ClickHouse wire type with no Native decoder — either genuinely opaque/self-describing (`Dynamic`,
|
|
20
|
+
* `JSON`, `QBit`: variable per-row layouts we don't parse) or simply not implemented yet. To read such
|
|
21
|
+
* a column, project a decodable form in SQL (cast it, extract fields, or `toString()` it).
|
|
22
|
+
*/
|
|
23
|
+
export class UnsupportedClickHouseTypeError extends Error {
|
|
24
|
+
constructor(type: string) {
|
|
25
|
+
super(`Native decode: unsupported ClickHouse type ${type}`);
|
|
26
|
+
this.name = 'UnsupportedClickHouseTypeError';
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* ClickHouse `Interval*` columns aren't decodable here. An Interval is a duration whose meaning depends on
|
|
32
|
+
* its unit (Day/Second/…) and it carries no stable value mapping worth decoding — and CH won't return it
|
|
33
|
+
* from most reads anyway. Store the duration as a plain integer column instead (e.g. `UInt32` of days or
|
|
34
|
+
* `Int64` of seconds) and read that.
|
|
35
|
+
*/
|
|
36
|
+
export class IntervalColumnUnsupportedError extends Error {
|
|
37
|
+
constructor(type: string) {
|
|
38
|
+
super(
|
|
39
|
+
`Native decode: Interval columns are not supported (${type}) — store the duration as an integer column (e.g. UInt32 days / Int64 seconds) and read that instead`
|
|
40
|
+
);
|
|
41
|
+
this.name = 'IntervalColumnUnsupportedError';
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* A column's zod type can't be mapped to a ClickHouse wire type: it carries no `.meta({ ch })` and
|
|
47
|
+
* isn't a structurally-derivable shape. Fixes by case: a bare leaf needs `.meta({ ch: '<type>' })`;
|
|
48
|
+
* `z.intersection` should be one `z.object` (merge with `.extend()`); types with no column meaning
|
|
49
|
+
* (`z.function`, `z.promise`, a `.transform()` whose source carries no `ch`, …) can't be read.
|
|
50
|
+
*/
|
|
51
|
+
export class UndecodableColumnError extends Error {
|
|
52
|
+
constructor(field: z.core.$ZodType) {
|
|
53
|
+
const kind = field.constructor?.name ?? 'unknown zod type';
|
|
54
|
+
const hint =
|
|
55
|
+
field instanceof z.ZodIntersection
|
|
56
|
+
? ' — merge the schemas into one z.object with .extend()'
|
|
57
|
+
: " — tag it with .meta({ ch: '<ClickHouse type>' })";
|
|
58
|
+
super(`Native decode: cannot derive a ClickHouse type from ${kind}${hint}`);
|
|
59
|
+
this.name = 'UndecodableColumnError';
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Cursor,
|
|
3
|
+
readString,
|
|
4
|
+
readUInt8,
|
|
5
|
+
readUInt16,
|
|
6
|
+
readUInt32,
|
|
7
|
+
readUInt64,
|
|
8
|
+
readUVarint,
|
|
9
|
+
} from '@clickhouse/rowbinary';
|
|
10
|
+
|
|
11
|
+
import type { ClickHouseColumn, ColumnNode, DecodedColumn, NodeKind } from './schema';
|
|
12
|
+
|
|
13
|
+
/** Decode each Native block against the precompiled columns. Binding is POSITIONAL — the SELECT must
|
|
14
|
+
* project columns in schema field order — guarded by a name + CH-type assert per column (a mismatch
|
|
15
|
+
* throws loudly rather than mis-binding). Rows are NOT validated: the client trusts CH and the decode
|
|
16
|
+
* tree already carries the only boundary transform (DateTime/DateTime64 → ZonedDateTime, in the leaf). */
|
|
17
|
+
export function decodeNativeBuffer(
|
|
18
|
+
buf: Buffer,
|
|
19
|
+
columns: ClickHouseColumn[]
|
|
20
|
+
): Record<string, unknown>[] {
|
|
21
|
+
const nCols = columns.length;
|
|
22
|
+
const cursor = new Cursor(buf);
|
|
23
|
+
const rows: Record<string, unknown>[] = [];
|
|
24
|
+
while (cursor.pos < buf.length) {
|
|
25
|
+
const blockCols = readUVarint(cursor);
|
|
26
|
+
const nRows = readUVarint(cursor);
|
|
27
|
+
if (blockCols !== nCols)
|
|
28
|
+
throw new Error(`Native decode: schema has ${nCols} columns, block has ${blockCols}`);
|
|
29
|
+
const colArrays = new Array<DecodedColumn>(nCols);
|
|
30
|
+
// CH is a columnar DBMS, data is stored per-column, NOT per-row
|
|
31
|
+
for (let colIndex = 0; colIndex < nCols; colIndex++) {
|
|
32
|
+
const name = readString(cursor);
|
|
33
|
+
const chType = readString(cursor);
|
|
34
|
+
const col = columns[colIndex];
|
|
35
|
+
if (name !== col.name)
|
|
36
|
+
throw new Error(
|
|
37
|
+
`Native decode: column ${colIndex} is '${name}', schema expects '${col.name}'`
|
|
38
|
+
);
|
|
39
|
+
if (chType !== col.type)
|
|
40
|
+
throw new Error(`Native decode: column ${name} wire type ${chType} ≠ schema ${col.type}`);
|
|
41
|
+
colArrays[colIndex] = decodeColumn(cursor, col.decodeTree, nRows);
|
|
42
|
+
}
|
|
43
|
+
for (let rowIndex = 0; rowIndex < nRows; rowIndex++) {
|
|
44
|
+
const row: Record<string, unknown> = {};
|
|
45
|
+
for (let colIndex = 0; colIndex < nCols; colIndex++)
|
|
46
|
+
row[columns[colIndex].name] = colArrays[colIndex][rowIndex];
|
|
47
|
+
rows.push(row);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return rows;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Walk a decode tree, decoding `n` column values; each kind's Native framing lives in its decoder. */
|
|
54
|
+
function decodeColumn(cursor: Cursor, tree: ColumnNode, n: number): DecodedColumn {
|
|
55
|
+
switch (tree.kind) {
|
|
56
|
+
case 'scalar':
|
|
57
|
+
return decodeScalar(cursor, tree, n);
|
|
58
|
+
case 'nullable':
|
|
59
|
+
return decodeNullable(cursor, tree, n);
|
|
60
|
+
case 'array':
|
|
61
|
+
return decodeArray(cursor, tree, n);
|
|
62
|
+
case 'lowCardinality':
|
|
63
|
+
return decodeLowCardinality(cursor, tree);
|
|
64
|
+
case 'map':
|
|
65
|
+
return decodeMap(cursor, tree, n);
|
|
66
|
+
case 'tuple':
|
|
67
|
+
return decodeTuple(cursor, tree, n);
|
|
68
|
+
case 'variant':
|
|
69
|
+
return decodeVariant(cursor, tree, n);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function decodeScalar(cursor: Cursor, node: NodeKind<'scalar'>, n: number): DecodedColumn {
|
|
74
|
+
const out = new Array<unknown>(n);
|
|
75
|
+
for (let rowIndex = 0; rowIndex < n; rowIndex++) out[rowIndex] = node.read(cursor);
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Full null map (one byte per row), then all inner values. */
|
|
80
|
+
function decodeNullable(cursor: Cursor, node: NodeKind<'nullable'>, n: number): DecodedColumn {
|
|
81
|
+
const mask = new Uint8Array(n);
|
|
82
|
+
for (let rowIndex = 0; rowIndex < n; rowIndex++) mask[rowIndex] = readUInt8(cursor);
|
|
83
|
+
const values = decodeColumn(cursor, node.inner, n);
|
|
84
|
+
for (let rowIndex = 0; rowIndex < n; rowIndex++) if (mask[rowIndex]) values[rowIndex] = null;
|
|
85
|
+
return values;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Cumulative u64 offsets, then the flattened elements sliced per row. */
|
|
89
|
+
function decodeArray(cursor: Cursor, node: NodeKind<'array'>, n: number): DecodedColumn {
|
|
90
|
+
const offsets = new Array<number>(n);
|
|
91
|
+
for (let rowIndex = 0; rowIndex < n; rowIndex++) offsets[rowIndex] = Number(readUInt64(cursor));
|
|
92
|
+
const elems = decodeColumn(cursor, node.inner, n > 0 ? offsets[n - 1] : 0);
|
|
93
|
+
const out = new Array<unknown>(n);
|
|
94
|
+
let start = 0;
|
|
95
|
+
for (let rowIndex = 0; rowIndex < n; rowIndex++) {
|
|
96
|
+
const slice = elems.slice(start, offsets[rowIndex]);
|
|
97
|
+
out[rowIndex] = node.asSet ? new Set(slice) : slice; // z.set → JS Set (dedups, drops order)
|
|
98
|
+
start = offsets[rowIndex];
|
|
99
|
+
}
|
|
100
|
+
return out;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** u64 version, u64 index-flags (low byte = index width), u64 dict size, the dict, u64 row count,
|
|
104
|
+
* then one dict index per row. For Nullable inner, index 0 means NULL. */
|
|
105
|
+
function decodeLowCardinality(cursor: Cursor, node: NodeKind<'lowCardinality'>): DecodedColumn {
|
|
106
|
+
readUInt64(cursor); // serialization version (SharedDictionariesWithAdditionalKeys = 1)
|
|
107
|
+
const indexWidth = Number(readUInt64(cursor)) & 0xff;
|
|
108
|
+
const dict = decodeColumn(cursor, node.inner, Number(readUInt64(cursor)));
|
|
109
|
+
const rows = Number(readUInt64(cursor));
|
|
110
|
+
const narrowReader = [readUInt8, readUInt16, readUInt32][indexWidth]; // undefined ⇒ 8-byte index
|
|
111
|
+
const out = new Array<unknown>(rows);
|
|
112
|
+
for (let rowIndex = 0; rowIndex < rows; rowIndex++) {
|
|
113
|
+
const dictIndex = narrowReader ? narrowReader(cursor) : Number(readUInt64(cursor));
|
|
114
|
+
out[rowIndex] = node.nullable && dictIndex === 0 ? null : dict[dictIndex];
|
|
115
|
+
}
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Cumulative u64 offsets, then the flat keys column, then the flat values column. */
|
|
120
|
+
function decodeMap(cursor: Cursor, node: NodeKind<'map'>, n: number): DecodedColumn {
|
|
121
|
+
const offsets = new Array<number>(n);
|
|
122
|
+
for (let rowIndex = 0; rowIndex < n; rowIndex++) offsets[rowIndex] = Number(readUInt64(cursor));
|
|
123
|
+
const total = n > 0 ? offsets[n - 1] : 0;
|
|
124
|
+
const keys = decodeColumn(cursor, node.key, total);
|
|
125
|
+
const vals = decodeColumn(cursor, node.value, total);
|
|
126
|
+
const out = new Array<unknown>(n);
|
|
127
|
+
let start = 0;
|
|
128
|
+
for (let rowIndex = 0; rowIndex < n; rowIndex++) {
|
|
129
|
+
if (node.asMap) {
|
|
130
|
+
// z.map → JS Map, preserving typed keys (a number key stays a number, unlike object keys).
|
|
131
|
+
const map = new Map<unknown, unknown>();
|
|
132
|
+
for (let entryIndex = start; entryIndex < offsets[rowIndex]; entryIndex++)
|
|
133
|
+
map.set(keys[entryIndex], vals[entryIndex]);
|
|
134
|
+
out[rowIndex] = map;
|
|
135
|
+
} else {
|
|
136
|
+
const obj: Record<string, unknown> = {};
|
|
137
|
+
for (let entryIndex = start; entryIndex < offsets[rowIndex]; entryIndex++)
|
|
138
|
+
obj[String(keys[entryIndex])] = vals[entryIndex];
|
|
139
|
+
out[rowIndex] = obj;
|
|
140
|
+
}
|
|
141
|
+
start = offsets[rowIndex];
|
|
142
|
+
}
|
|
143
|
+
return out;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Each element is a full column of n values, laid out one after another. */
|
|
147
|
+
function decodeTuple(cursor: Cursor, node: NodeKind<'tuple'>, n: number): DecodedColumn {
|
|
148
|
+
const cols = node.fields.map((field) => decodeColumn(cursor, field.node, n));
|
|
149
|
+
const out = new Array<unknown>(n);
|
|
150
|
+
for (let rowIndex = 0; rowIndex < n; rowIndex++) {
|
|
151
|
+
if (node.named) {
|
|
152
|
+
const obj: Record<string, unknown> = {};
|
|
153
|
+
for (let fieldIndex = 0; fieldIndex < node.fields.length; fieldIndex++)
|
|
154
|
+
obj[node.fields[fieldIndex].name as string] = cols[fieldIndex][rowIndex];
|
|
155
|
+
out[rowIndex] = obj;
|
|
156
|
+
} else {
|
|
157
|
+
out[rowIndex] = cols.map((fieldValues) => fieldValues[rowIndex]);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return out;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** u64 discriminator mode (0 = basic), one discriminator byte per row (variant index, or 255 = NULL),
|
|
164
|
+
* then each variant's values compacted in type order. */
|
|
165
|
+
function decodeVariant(cursor: Cursor, node: NodeKind<'variant'>, n: number): DecodedColumn {
|
|
166
|
+
const mode = readUInt64(cursor);
|
|
167
|
+
if (mode !== 0n) throw new Error(`Native decode: Variant compact mode (${mode}) not supported`);
|
|
168
|
+
const discriminators = new Uint8Array(n);
|
|
169
|
+
for (let rowIndex = 0; rowIndex < n; rowIndex++) discriminators[rowIndex] = readUInt8(cursor);
|
|
170
|
+
const counts = new Array<number>(node.variants.length).fill(0);
|
|
171
|
+
for (let rowIndex = 0; rowIndex < n; rowIndex++) {
|
|
172
|
+
const disc = discriminators[rowIndex];
|
|
173
|
+
if (disc === 255) continue;
|
|
174
|
+
// Guard an out-of-range discriminator with a clear error — otherwise `counts[disc]` is undefined,
|
|
175
|
+
// increments to NaN, and later crashes cryptically on `cols[disc]` in the assembly loop.
|
|
176
|
+
if (disc >= node.variants.length)
|
|
177
|
+
throw new Error(
|
|
178
|
+
`Native decode: Variant discriminator ${disc} out of range (0..${node.variants.length - 1})`
|
|
179
|
+
);
|
|
180
|
+
counts[disc]++;
|
|
181
|
+
}
|
|
182
|
+
const cols = node.variants.map((variantNode, variantIndex) =>
|
|
183
|
+
decodeColumn(cursor, variantNode, counts[variantIndex])
|
|
184
|
+
);
|
|
185
|
+
const positions = new Array<number>(node.variants.length).fill(0);
|
|
186
|
+
const out = new Array<unknown>(n);
|
|
187
|
+
for (let rowIndex = 0; rowIndex < n; rowIndex++) {
|
|
188
|
+
const discriminator = discriminators[rowIndex];
|
|
189
|
+
out[rowIndex] = discriminator === 255 ? null : cols[discriminator][positions[discriminator]++];
|
|
190
|
+
}
|
|
191
|
+
return out;
|
|
192
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import {
|
|
2
|
+
formatDecimal,
|
|
3
|
+
formatIPv4,
|
|
4
|
+
formatIPv6,
|
|
5
|
+
formatUUID,
|
|
6
|
+
readBFloat16,
|
|
7
|
+
readBool,
|
|
8
|
+
readDecimal32,
|
|
9
|
+
readDecimal64,
|
|
10
|
+
readDecimal128,
|
|
11
|
+
readDecimal256,
|
|
12
|
+
readEnum8,
|
|
13
|
+
readEnum16,
|
|
14
|
+
type Reader,
|
|
15
|
+
readFixedString,
|
|
16
|
+
readFloat32,
|
|
17
|
+
readFloat64,
|
|
18
|
+
readInt8,
|
|
19
|
+
readInt16,
|
|
20
|
+
readInt32,
|
|
21
|
+
readInt64,
|
|
22
|
+
readInt128,
|
|
23
|
+
readInt256,
|
|
24
|
+
readIPv4,
|
|
25
|
+
readIPv6,
|
|
26
|
+
readNothing,
|
|
27
|
+
readString,
|
|
28
|
+
readTime,
|
|
29
|
+
readTime64,
|
|
30
|
+
readUInt8,
|
|
31
|
+
readUInt16,
|
|
32
|
+
readUInt32,
|
|
33
|
+
readUInt64,
|
|
34
|
+
readUInt128,
|
|
35
|
+
readUInt256,
|
|
36
|
+
readUUID,
|
|
37
|
+
} from '@clickhouse/rowbinary';
|
|
38
|
+
|
|
39
|
+
import { IntervalColumnUnsupportedError, UnsupportedClickHouseTypeError } from './error';
|
|
40
|
+
|
|
41
|
+
// The single source of truth for CH scalar type → @clickhouse/rowbinary leaf reader. Two callers:
|
|
42
|
+
// the CH primitives in ./schema seed their `.meta({ read })` from it (so the compile path reads the
|
|
43
|
+
// reader off the leaf, not a switch), and the compiler's string parser resolves raw-`ch`-string
|
|
44
|
+
// escapes (geo leaves, Variant/Nested fields, LowCardinality inner) through it.
|
|
45
|
+
|
|
46
|
+
/** Resolve a scalar CH type to its @clickhouse/rowbinary leaf reader. */
|
|
47
|
+
export function scalarReader(type: string): Reader<unknown> {
|
|
48
|
+
/** Pick the right fixed-width Decimal reader for `Decimal(P,S)` / `Decimal32..256(S)`. */
|
|
49
|
+
function decimalReader(t: string) {
|
|
50
|
+
const m = t.match(/^Decimal(32|64|128|256)?\((\d+)(?:,\s*(\d+))?\)$/);
|
|
51
|
+
if (!m) throw new Error(`Native decode: cannot parse ${t}`);
|
|
52
|
+
if (m[1]) {
|
|
53
|
+
const scale = Number(m[2]);
|
|
54
|
+
if (m[1] === '32') return readDecimal32(scale);
|
|
55
|
+
if (m[1] === '64') return readDecimal64(scale);
|
|
56
|
+
if (m[1] === '128') return readDecimal128(scale);
|
|
57
|
+
return readDecimal256(scale);
|
|
58
|
+
}
|
|
59
|
+
const precision = Number(m[2]);
|
|
60
|
+
const scale = Number(m[3]);
|
|
61
|
+
if (precision <= 9) return readDecimal32(scale);
|
|
62
|
+
if (precision <= 18) return readDecimal64(scale);
|
|
63
|
+
if (precision <= 38) return readDecimal128(scale);
|
|
64
|
+
return readDecimal256(scale);
|
|
65
|
+
}
|
|
66
|
+
/** Map an `Enum8/16('name' = n, ...)` type string to value → name. */
|
|
67
|
+
function parseEnum(t: string): Map<number, string> {
|
|
68
|
+
const names = new Map<number, string>();
|
|
69
|
+
const re = /'((?:[^'\\]|\\.)*)'\s*=\s*(-?\d+)/g;
|
|
70
|
+
let m: null | RegExpExecArray;
|
|
71
|
+
while ((m = re.exec(t)) !== null) names.set(Number(m[2]), m[1]);
|
|
72
|
+
return names;
|
|
73
|
+
}
|
|
74
|
+
switch (type) {
|
|
75
|
+
case 'String':
|
|
76
|
+
return readString;
|
|
77
|
+
case 'UInt8':
|
|
78
|
+
return readUInt8;
|
|
79
|
+
case 'UInt16':
|
|
80
|
+
return readUInt16;
|
|
81
|
+
case 'UInt32':
|
|
82
|
+
return readUInt32;
|
|
83
|
+
case 'UInt64':
|
|
84
|
+
return readUInt64;
|
|
85
|
+
case 'UInt128':
|
|
86
|
+
return readUInt128;
|
|
87
|
+
case 'UInt256':
|
|
88
|
+
return readUInt256;
|
|
89
|
+
case 'Int8':
|
|
90
|
+
return readInt8;
|
|
91
|
+
case 'Int16':
|
|
92
|
+
return readInt16;
|
|
93
|
+
case 'Int32':
|
|
94
|
+
return readInt32;
|
|
95
|
+
case 'Int64':
|
|
96
|
+
return readInt64;
|
|
97
|
+
case 'Int128':
|
|
98
|
+
return readInt128;
|
|
99
|
+
case 'Int256':
|
|
100
|
+
return readInt256;
|
|
101
|
+
case 'Float32':
|
|
102
|
+
return readFloat32;
|
|
103
|
+
case 'Float64':
|
|
104
|
+
return readFloat64;
|
|
105
|
+
case 'Bool':
|
|
106
|
+
case 'Boolean':
|
|
107
|
+
return readBool;
|
|
108
|
+
case 'Date':
|
|
109
|
+
return (s) => new Date(readUInt16(s) * 86_400_000).toISOString().slice(0, 10);
|
|
110
|
+
case 'Date32':
|
|
111
|
+
return (s) => new Date(readInt32(s) * 86_400_000).toISOString().slice(0, 10);
|
|
112
|
+
case 'UUID':
|
|
113
|
+
return (s) => formatUUID(readUUID(s));
|
|
114
|
+
case 'IPv4':
|
|
115
|
+
return (s) => formatIPv4(readIPv4(s));
|
|
116
|
+
case 'IPv6':
|
|
117
|
+
return (s) => formatIPv6(readIPv6(s));
|
|
118
|
+
case 'Time':
|
|
119
|
+
return readTime; // Int32 seconds-of-day
|
|
120
|
+
case 'BFloat16':
|
|
121
|
+
return readBFloat16;
|
|
122
|
+
case 'Nothing':
|
|
123
|
+
return readNothing; // only reachable via a malformed empty Array(Nothing)/Nullable(Nothing)
|
|
124
|
+
}
|
|
125
|
+
if (type.startsWith('FixedString(')) {
|
|
126
|
+
return readFixedString(Number(type.slice('FixedString('.length, -1)));
|
|
127
|
+
}
|
|
128
|
+
if (type.startsWith('Time64')) {
|
|
129
|
+
const p = Number(type.slice('Time64('.length).split(/[,)]/)[0]);
|
|
130
|
+
const reader = readTime64(p);
|
|
131
|
+
const div = 10 ** p;
|
|
132
|
+
return (s) => Number(reader(s)[0]) / div; // fractional seconds-of-day
|
|
133
|
+
}
|
|
134
|
+
if (type.startsWith('Interval')) throw new IntervalColumnUnsupportedError(type);
|
|
135
|
+
// Binary formats ignore date_time_output_format — DateTime is always the raw epoch integer on the
|
|
136
|
+
// wire. Return epoch seconds (Date as 'YYYY-MM-DD'), matching the JSONEachRow path so the row-map
|
|
137
|
+
// conversion to ZonedDateTime/CalendarDate is identical across both formats.
|
|
138
|
+
if (type.startsWith('DateTime64')) {
|
|
139
|
+
// Ticks (Int64) at scale p. Split seconds/fraction via bigint so a ns value (>2^53) doesn't
|
|
140
|
+
// lose precision through Number() before the divide. Yields fractional epoch seconds.
|
|
141
|
+
const div = 10n ** BigInt(Number(type.slice('DateTime64('.length).split(/[,)]/)[0]));
|
|
142
|
+
return (s) => {
|
|
143
|
+
const ticks = readInt64(s);
|
|
144
|
+
return Number(ticks / div) + Number(ticks % div) / Number(div);
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
if (type.startsWith('DateTime')) return readUInt32; // epoch seconds
|
|
148
|
+
if (type.startsWith('Enum8(')) {
|
|
149
|
+
const names = parseEnum(type);
|
|
150
|
+
return (s) => {
|
|
151
|
+
const v = readEnum8(s);
|
|
152
|
+
return names.get(v) ?? v;
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
if (type.startsWith('Enum16(')) {
|
|
156
|
+
const names = parseEnum(type);
|
|
157
|
+
return (s) => {
|
|
158
|
+
const v = readEnum16(s);
|
|
159
|
+
return names.get(v) ?? v;
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
if (type.startsWith('Decimal')) {
|
|
163
|
+
const reader = decimalReader(type);
|
|
164
|
+
return (s) => formatDecimal(reader(s));
|
|
165
|
+
}
|
|
166
|
+
throw new UnsupportedClickHouseTypeError(type);
|
|
167
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { ClickHouseError } from '@clickhouse/client-web';
|
|
2
|
+
|
|
3
|
+
/** A `ClickHouseError` is a deterministic server rejection (bad SQL, memory limit, unknown table) — not
|
|
4
|
+
* worth retrying. Anything else is a transient transport blip, retryable for an idempotent read. Our own
|
|
5
|
+
* deadline is caught via `signal.aborted` in `withTransportRetry`, never by matching `err.name`. */
|
|
6
|
+
export function isRetryableTransportError(err: unknown): boolean {
|
|
7
|
+
return !(err instanceof ClickHouseError);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Retry an idempotent CH transport call over a transient connection drop — exp backoff + jitter. Owns a
|
|
11
|
+
* per-attempt deadline (`AbortSignal.timeout`) and passes it to `run`, which MUST forward it to the CH
|
|
12
|
+
* call's `abort_signal`. A fired deadline is detected via `signal.aborted`, so our own timeout stops the
|
|
13
|
+
* loop while a fast transport failure is still retried. */
|
|
14
|
+
export async function withTransportRetry<T>(
|
|
15
|
+
run: (signal: AbortSignal) => Promise<T>,
|
|
16
|
+
{ attempts = 3, timeoutMs = 60_000 }: { attempts?: number; timeoutMs?: number } = {}
|
|
17
|
+
): Promise<T> {
|
|
18
|
+
for (let i = 0; ; i++) {
|
|
19
|
+
const signal = AbortSignal.timeout(timeoutMs);
|
|
20
|
+
try {
|
|
21
|
+
return await run(signal);
|
|
22
|
+
} catch (err) {
|
|
23
|
+
// Our own deadline elapsed → stop; retrying would only stack latency past the ceiling.
|
|
24
|
+
if (signal.aborted) throw err;
|
|
25
|
+
if (i >= attempts - 1 || !isRetryableTransportError(err)) throw err;
|
|
26
|
+
const backoff = 2 ** i * 50 + Math.random() * 50; // 50ms/100ms + jitter
|
|
27
|
+
await new Promise((resolve) => setTimeout(resolve, backoff));
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import type { Reader } from '@clickhouse/rowbinary';
|
|
2
|
+
|
|
3
|
+
import { ZonedDateTime } from '@internationalized/date';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
|
|
6
|
+
import { Datetime } from '../../utils/datetime';
|
|
7
|
+
import { chWireType } from './compiler';
|
|
8
|
+
import { scalarReader } from './readers';
|
|
9
|
+
|
|
10
|
+
/** Per-query ClickHouse settings, passed straight through to the server (e.g. `max_block_size`). */
|
|
11
|
+
export type ClickHouseSettings = Record<string, boolean | number | string | undefined>;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* A compiled decode tree for one column: the CH type parsed once into a tree the decoder walks.
|
|
15
|
+
* `decodeColumn` recurses over this (cheap, tagged dispatch) instead of re-parsing the type string.
|
|
16
|
+
*/
|
|
17
|
+
export type ColumnNode =
|
|
18
|
+
| { asMap?: boolean; key: ColumnNode; kind: 'map'; value: ColumnNode }
|
|
19
|
+
| { asSet?: boolean; inner: ColumnNode; kind: 'array' }
|
|
20
|
+
| { fields: { name?: string; node: ColumnNode }[]; kind: 'tuple'; named: boolean }
|
|
21
|
+
| { inner: ColumnNode; kind: 'lowCardinality'; nullable: boolean }
|
|
22
|
+
| { inner: ColumnNode; kind: 'nullable' }
|
|
23
|
+
| { kind: 'scalar'; read: Reader<unknown> }
|
|
24
|
+
| { kind: 'variant'; variants: ColumnNode[] };
|
|
25
|
+
|
|
26
|
+
/** The ColumnNode variant for a given kind — used to type each decoder's `node`. */
|
|
27
|
+
export type NodeKind<K extends ColumnNode['kind']> = Extract<ColumnNode, { kind: K }>;
|
|
28
|
+
|
|
29
|
+
/** A compiled column: its name, its CH wire type (the drift-guard key), and its decode tree. */
|
|
30
|
+
export type ClickHouseColumn = { decodeTree: ColumnNode; name: string; type: string };
|
|
31
|
+
|
|
32
|
+
export type DecodedColumn = unknown[];
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Read-time engine semantics — how the builder collapses each engine to return final rows. `sortKey` /
|
|
36
|
+
* `version` / `measures` are column names (any CH expression is allowed — they're spliced into the SQL).
|
|
37
|
+
*
|
|
38
|
+
* For the merge engines, a read ALWAYS returns the collapsed state (latest-per-key / summed). For
|
|
39
|
+
* Replacing/Summing, `final` picks the METHOD and is a correctness lever, so it's REQUIRED:
|
|
40
|
+
* • `final: true` → `FINAL` — collapses THEN filters (correct when you filter on MUTABLE columns).
|
|
41
|
+
* • `final: false` → cheaper `LIMIT 1 BY` / `sum()+GROUP BY` — filters THEN collapses (only safe for
|
|
42
|
+
* filters on the sortKey / immutable columns; a mutable filter can keep a stale-but-matching version).
|
|
43
|
+
* Collapsing/VersionedCollapsing read via the universal `FINAL` (their value columns are real, so FINAL is
|
|
44
|
+
* the correct read). `AggregatingMergeTree` is intentionally absent — its per-column combinator collapse
|
|
45
|
+
* lives in the ORM (see the ch-aggregating-mergetree-debt memory), not this flat client.
|
|
46
|
+
*/
|
|
47
|
+
export type TableEngine =
|
|
48
|
+
| { final: boolean; kind: 'ReplacingMergeTree'; sortKey: string[]; version?: string }
|
|
49
|
+
| { final: boolean; kind: 'SummingMergeTree'; measures: string[]; sortKey: string[] }
|
|
50
|
+
| { kind: 'CollapsingMergeTree' }
|
|
51
|
+
| { kind: 'MergeTree' }
|
|
52
|
+
| { kind: 'Plain' }
|
|
53
|
+
// Straight-read engines (no merge collapse): Log/TinyLog/StripeLog/Memory/Buffer/Null/Join/View/
|
|
54
|
+
// MaterializedView/Distributed/Merge/File/URL/…
|
|
55
|
+
| { kind: 'VersionedCollapsingMergeTree' };
|
|
56
|
+
|
|
57
|
+
// The `ClickHouseTable` runtime object lives in ./client (a class that owns its compiled-query cache and
|
|
58
|
+
// builds engine-correct SQL); the engine descriptor it consumes — `TableEngine` above — stays here with
|
|
59
|
+
// the rest of the wire-type vocabulary.
|
|
60
|
+
|
|
61
|
+
// ── CH-typed zod primitives (the `CH` factory) ─────────────────────────────────────────────────────
|
|
62
|
+
// One namespace of reusable validators, each tagged with its leaf CH wire type via .meta({ ch }) and
|
|
63
|
+
// — for scalars — its @clickhouse/rowbinary leaf reader via .meta({ read }), so the decoder reads the
|
|
64
|
+
// reader straight off the leaf instead of re-deriving it from a type-string switch. Wrappers compose
|
|
65
|
+
// structurally: z.array(CH.UInt32) → Array(UInt32), CH.String.nullable() → Nullable(String), because
|
|
66
|
+
// compileColumn derives Array/Nullable/Tuple/Map from the zod structure. Types invisible in zod
|
|
67
|
+
// (LowCardinality, geo, and the Enum/Decimal/DateTime params) carry an explicit ch string; the
|
|
68
|
+
// non-scalar ones (LowCardinality, geo) omit `read` and are compiled via the string path.
|
|
69
|
+
|
|
70
|
+
// Builds a scalar CH primitive: a base zod type tagged with its ch wire type + leaf reader.
|
|
71
|
+
// Named `scalar` (not `leaf`/`node`) — `node` collides with the decode tree's ColumnNode.
|
|
72
|
+
const scalar = <T extends z.ZodType>(base: T, ch: string) =>
|
|
73
|
+
base.meta({ ch, read: scalarReader(ch) });
|
|
74
|
+
|
|
75
|
+
const enumType = (bits: 8 | 16, members: Record<string, number>) =>
|
|
76
|
+
`Enum${bits}(${Object.entries(members)
|
|
77
|
+
.map(([k, v]) => `'${k}' = ${v}`)
|
|
78
|
+
.join(', ')})`;
|
|
79
|
+
|
|
80
|
+
export const CH = {
|
|
81
|
+
BFloat16: scalar(z.number(), 'BFloat16'),
|
|
82
|
+
Bool: scalar(z.boolean(), 'Bool'),
|
|
83
|
+
Date: scalar(z.string(), 'Date'),
|
|
84
|
+
Date32: scalar(z.string(), 'Date32'),
|
|
85
|
+
DateTime: scalar(z.number(), 'DateTime'),
|
|
86
|
+
// DateTime64 epoch ↔ ZonedDateTime — the domain always stores instants, so DateTime64 IS zoned (like
|
|
87
|
+
// `ZonedDateTime` for plain DateTime). Reads decode fractional epoch seconds → ZonedDateTime (sub-second
|
|
88
|
+
// kept); the ch + read live on the codec's INPUT scalar (compileColumn unwraps the codec to it).
|
|
89
|
+
DateTime64: (precision: number, tz?: string) =>
|
|
90
|
+
z.codec(
|
|
91
|
+
scalar(z.number(), tz ? `DateTime64(${precision}, '${tz}')` : `DateTime64(${precision})`),
|
|
92
|
+
z.instanceof(ZonedDateTime),
|
|
93
|
+
{
|
|
94
|
+
decode: (epoch) => Datetime.zonedFromUnixSeconds(epoch),
|
|
95
|
+
encode: (zdt) => Datetime.toUnixSeconds(zdt),
|
|
96
|
+
}
|
|
97
|
+
),
|
|
98
|
+
Decimal: (precision: number, scale: number) =>
|
|
99
|
+
scalar(z.string(), `Decimal(${precision}, ${scale})`),
|
|
100
|
+
Enum: (bits: 8 | 16, members: Record<string, number>) =>
|
|
101
|
+
scalar(z.enum(Object.keys(members) as [string, ...string[]]), enumType(bits, members)),
|
|
102
|
+
// Parameterized scalar constructors.
|
|
103
|
+
FixedString: (n: number) => scalar(z.string(), `FixedString(${n})`),
|
|
104
|
+
Float32: scalar(z.number(), 'Float32'),
|
|
105
|
+
Float64: scalar(z.number(), 'Float64'),
|
|
106
|
+
Int128: scalar(z.bigint(), 'Int128'),
|
|
107
|
+
Int16: scalar(z.number(), 'Int16'),
|
|
108
|
+
Int256: scalar(z.bigint(), 'Int256'),
|
|
109
|
+
Int32: scalar(z.number(), 'Int32'),
|
|
110
|
+
Int64: scalar(z.bigint(), 'Int64'),
|
|
111
|
+
Int8: scalar(z.number(), 'Int8'),
|
|
112
|
+
IPv4: scalar(z.string(), 'IPv4'),
|
|
113
|
+
IPv6: scalar(z.string(), 'IPv6'),
|
|
114
|
+
LineString: z.array(z.tuple([z.number(), z.number()])).meta({ ch: 'LineString' }),
|
|
115
|
+
LowCardNullableString: z.string().nullable().meta({ ch: 'LowCardinality(Nullable(String))' }),
|
|
116
|
+
// Non-scalar wrappers carry a raw ch string and no `read` — compiled via the string path.
|
|
117
|
+
LowCardString: z.string().meta({ ch: 'LowCardinality(String)' }),
|
|
118
|
+
MultiLineString: z
|
|
119
|
+
.array(z.array(z.tuple([z.number(), z.number()])))
|
|
120
|
+
.meta({ ch: 'MultiLineString' }),
|
|
121
|
+
MultiPolygon: z
|
|
122
|
+
.array(z.array(z.array(z.tuple([z.number(), z.number()]))))
|
|
123
|
+
.meta({ ch: 'MultiPolygon' }),
|
|
124
|
+
// Nested(name Type, …) — decodes as `Array(Tuple(named …))`, i.e. an array of row-objects. The
|
|
125
|
+
// `z.array(z.object(shape))` validates; the `Nested(…)` ch string drives the decode.
|
|
126
|
+
Nested: <T extends z.ZodRawShape>(shape: T) =>
|
|
127
|
+
z.array(z.object(shape)).meta({
|
|
128
|
+
ch: `Nested(${Object.entries(shape)
|
|
129
|
+
.map(([name, t]) => `${name} ${chWireType(t)}`)
|
|
130
|
+
.join(', ')})`,
|
|
131
|
+
}),
|
|
132
|
+
// Geo types report their own wire name (Point/Ring/…), not the underlying Tuple/Array. A Point is a
|
|
133
|
+
// (Float64, Float64) tuple; each further geo type nests one Array deeper (LineString/Ring share a shape,
|
|
134
|
+
// as do Polygon/MultiLineString — see the decoder's geo cases).
|
|
135
|
+
Point: z.tuple([z.number(), z.number()]).meta({ ch: 'Point' }),
|
|
136
|
+
Polygon: z.array(z.array(z.tuple([z.number(), z.number()]))).meta({ ch: 'Polygon' }),
|
|
137
|
+
Ring: z.array(z.tuple([z.number(), z.number()])).meta({ ch: 'Ring' }),
|
|
138
|
+
String: scalar(z.string(), 'String'),
|
|
139
|
+
Time: scalar(z.number(), 'Time'),
|
|
140
|
+
Time64: (precision: number) => scalar(z.number(), `Time64(${precision})`),
|
|
141
|
+
UInt128: scalar(z.bigint(), 'UInt128'),
|
|
142
|
+
UInt16: scalar(z.number(), 'UInt16'),
|
|
143
|
+
UInt256: scalar(z.bigint(), 'UInt256'),
|
|
144
|
+
UInt32: scalar(z.number(), 'UInt32'),
|
|
145
|
+
UInt64: scalar(z.bigint(), 'UInt64'),
|
|
146
|
+
UInt8: scalar(z.number(), 'UInt8'),
|
|
147
|
+
UUID: scalar(z.string(), 'UUID'),
|
|
148
|
+
// Variant(T1, T2, …) — a column holding one of several types OR NULL (a Variant is implicitly nullable in CH:
|
|
149
|
+
// a row can be "none of the types"), so the validation union includes `z.null()`. The `Variant(…)` ch string
|
|
150
|
+
// (built from each inner's full wire type — inners may themselves be wrapped) drives the decode via the string
|
|
151
|
+
// path (no single leaf reader).
|
|
152
|
+
Variant: <T extends [z.ZodType, z.ZodType, ...z.ZodType[]]>(...inners: T) =>
|
|
153
|
+
z.union([...inners, z.null()]).meta({
|
|
154
|
+
ch: `Variant(${inners.map((i) => chWireType(i)).join(', ')})`,
|
|
155
|
+
}),
|
|
156
|
+
// DateTime epoch ↔ domain instant, a BIDIRECTIONAL codec: `.parse` (read) decodes epoch → ZonedDateTime;
|
|
157
|
+
// `z.encode` (insert) encodes ZonedDateTime → epoch. The ch + read live on the codec's INPUT (the
|
|
158
|
+
// `scalar` DateTime) — `compileColumn` unwraps the codec to its input to drive the wire decode of epoch.
|
|
159
|
+
ZonedDateTime: z.codec(scalar(z.number(), 'DateTime'), z.instanceof(ZonedDateTime), {
|
|
160
|
+
decode: (epoch) => Datetime.zonedFromUnixSeconds(epoch),
|
|
161
|
+
encode: (zdt) => Datetime.toUnixSeconds(zdt),
|
|
162
|
+
}),
|
|
163
|
+
} as const;
|