@voltro/plugin-clickhouse 0.1.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.
@@ -0,0 +1,82 @@
1
+ import { AggregateQuery } from '@voltro/runtime';
2
+ import { AnalyticsBucket } from '@voltro/runtime';
3
+ import { AnalyticsEvent } from '@voltro/runtime';
4
+ import { AnalyticsSinkSpec } from '@voltro/runtime';
5
+ import { AnalyticsTopEntry } from '@voltro/runtime';
6
+ import { TimeseriesQuery } from '@voltro/runtime';
7
+ import { TopNQuery } from '@voltro/runtime';
8
+
9
+ export { AggregateQuery }
10
+
11
+ export { AnalyticsBucket }
12
+
13
+ export { AnalyticsEvent }
14
+
15
+ export { AnalyticsTopEntry }
16
+
17
+ /**
18
+ * Construct the spec. `initialize` opens the client + creates the
19
+ * events table on first boot.
20
+ */
21
+ export declare const clickhouseAnalytics: (options: ClickhouseAnalyticsOptions) => AnalyticsSinkSpec;
22
+
23
+ export declare interface ClickhouseAnalyticsOptions {
24
+ /** HTTP URL of the ClickHouse server (incl. protocol + port). */
25
+ readonly url: string;
26
+ readonly username?: string;
27
+ readonly password?: string;
28
+ /** Database name. The plugin creates the `events` table inside it
29
+ * on first boot. Default: 'default'. */
30
+ readonly database?: string;
31
+ /** Override the events-table name. Default: 'events'. */
32
+ readonly table?: string;
33
+ /**
34
+ * Reactive tables to CDC-mirror into ClickHouse. Each table gets a
35
+ * `voltro_mirror_<table>` ReplacingMergeTree (`{ id, data, version,
36
+ * is_deleted }`); the framework streams `DataStore` changes into it so
37
+ * analytical queries can JOIN events against live user data:
38
+ *
39
+ * SELECT JSONExtractString(m.data, 'tier'), count()
40
+ * FROM voltro_events e
41
+ * JOIN voltro_mirror_users FINAL m ON m.id = e.subject_id
42
+ * WHERE m.is_deleted = 0
43
+ * GROUP BY 1
44
+ *
45
+ * Empty / omitted → events-only.
46
+ */
47
+ readonly mirrorTables?: ReadonlyArray<string>;
48
+ /** Primary-key column on the mirrored source rows. Default `'id'`. */
49
+ readonly mirrorPrimaryKey?: string;
50
+ /** Opt into client-side batching of `track()` inserts. Omit for immediate
51
+ * per-event inserts (a successful `track` = the row was accepted). See the
52
+ * batching note in the README for the delivery-semantics trade-off. */
53
+ readonly batch?: ClickhouseBatchOptions;
54
+ }
55
+
56
+ /** Opt-in client-side batching. Buffers `track()` rows and flushes them in
57
+ * ONE multi-row `client.insert`, cutting HTTP round-trips under load. */
58
+ export declare interface ClickhouseBatchOptions {
59
+ /** Flush once the buffer reaches this many events. Default 1000. */
60
+ readonly maxSize?: number;
61
+ /** Flush a non-empty buffer this many ms after the first event lands
62
+ * (so low-volume trickles don't sit until shutdown). Default 5000. */
63
+ readonly flushIntervalMs?: number;
64
+ }
65
+
66
+ export declare const EVENTS_TABLE_DDL: (database: string, table: string) => string;
67
+
68
+ /**
69
+ * DDL for a mirror table. `ReplacingMergeTree(version)` keyed by `id`
70
+ * gives idempotent upserts — re-inserting the same row with a newer
71
+ * `version` collapses to the latest on merge. Deletes insert a tombstone
72
+ * (`is_deleted = 1`); queries filter `is_deleted = 0` (or use the
73
+ * `FINAL` modifier). `version` is a monotonic micro-timestamp so a later
74
+ * write always wins.
75
+ */
76
+ export declare const MIRROR_TABLE_DDL: (database: string, sourceTable: string) => string;
77
+
78
+ export { TimeseriesQuery }
79
+
80
+ export { TopNQuery }
81
+
82
+ export { }
package/dist/index.js ADDED
@@ -0,0 +1,282 @@
1
+ import { createClient as e } from "@clickhouse/client";
2
+ import { Effect as t, Option as n } from "effect";
3
+ import { createLogger as r } from "@voltro/logger";
4
+ import { AnalyticsError as i } from "@voltro/runtime";
5
+ //#region src/sink.ts
6
+ var a = r({ scope: "@voltro/plugin-clickhouse" }), o = "clickhouse", s = (e) => `voltro_mirror_${e}`, c = (e, t) => `
7
+ CREATE TABLE IF NOT EXISTS ${e}.${s(t)} (
8
+ id String,
9
+ data String CODEC(ZSTD(1)),
10
+ version UInt64,
11
+ is_deleted UInt8 DEFAULT 0
12
+ ) ENGINE = ReplacingMergeTree(version)
13
+ ORDER BY id
14
+ `.trim(), l = (e, t) => `
15
+ CREATE TABLE IF NOT EXISTS ${e}.${t} (
16
+ id UUID DEFAULT generateUUIDv7(),
17
+ name LowCardinality(String),
18
+ subject_id Nullable(String),
19
+ tenant_id Nullable(String),
20
+ properties String CODEC(ZSTD(1)),
21
+ occurred_at DateTime64(3),
22
+ ingested_at DateTime64(3) DEFAULT now64()
23
+ ) ENGINE = MergeTree
24
+ ORDER BY (name, occurred_at, id)
25
+ `.trim(), u = (e) => (t) => t instanceof i ? new i({
26
+ provider: o,
27
+ operation: e,
28
+ cause: t.cause
29
+ }) : new i({
30
+ provider: o,
31
+ operation: e,
32
+ cause: t instanceof Error ? `${t.name}: ${t.message}` : String(t)
33
+ }), d = (e) => (r) => r.pipe(t.catchSomeDefect((e) => e instanceof i ? n.some(t.fail(e)) : n.none()), t.mapError(u(e))), f = /^[A-Za-z_][A-Za-z0-9_]*$/, p = (e, t) => {
34
+ if (!f.test(e)) throw new i({
35
+ provider: o,
36
+ operation: "config",
37
+ cause: `invalid ${t} ${JSON.stringify(e)} — must match ${f.source} (letters, digits, underscore; no dots/quotes/spaces)`
38
+ });
39
+ return e;
40
+ }, m = /['\\]/, h = (e) => {
41
+ for (let t = 0; t < e.length; t++) if (e.charCodeAt(t) < 32) return !0;
42
+ return !1;
43
+ }, g = (e) => (t) => {
44
+ if (m.test(t) || h(t)) throw new i({
45
+ provider: o,
46
+ operation: e,
47
+ cause: `invalid property key ${JSON.stringify(t)} — must not contain quotes, backslashes, or control characters`
48
+ });
49
+ return t;
50
+ }, _ = (e) => e.client === null ? t.fail(new i({
51
+ provider: o,
52
+ operation: "ensureClient",
53
+ cause: "client not initialized — spec.initialize must run before queries"
54
+ })) : t.succeed(e.client), v = (e) => t.gen(function* () {
55
+ let n = e.client;
56
+ e.client = null, n !== null && (yield* t.tryPromise(() => n.close()).pipe(t.catchAll((e) => t.logWarning("clickhouse analytics sink teardown failed", {
57
+ provider: o,
58
+ err: e
59
+ }))));
60
+ }), y = (e) => ({
61
+ name: e.name,
62
+ subject_id: e.subjectId ?? null,
63
+ tenant_id: e.tenantId ?? null,
64
+ properties: JSON.stringify(e.properties),
65
+ occurred_at: (e.occurredAt ?? /* @__PURE__ */ new Date()).toISOString()
66
+ }), b = (e, t) => {
67
+ let n = [], r, i = () => {
68
+ r !== void 0 && (clearTimeout(r), r = void 0);
69
+ }, o = async () => {
70
+ if (i(), n.length === 0) return;
71
+ let t = n;
72
+ n = [];
73
+ let r = e.client;
74
+ if (r === null) {
75
+ a.warn("clickhouse batch flush skipped — client not initialized; events dropped", { dropped: t.length });
76
+ return;
77
+ }
78
+ try {
79
+ await r.insert({
80
+ table: `${e.database}.${e.table}`,
81
+ values: t,
82
+ format: "JSONEachRow"
83
+ });
84
+ } catch (e) {
85
+ a.warn("clickhouse batch flush failed — dropped events", { dropped: t.length }, e);
86
+ }
87
+ };
88
+ return {
89
+ add: (e) => {
90
+ if (n.push(y(e)), n.length >= t.maxSize) {
91
+ o();
92
+ return;
93
+ }
94
+ r === void 0 && (r = setTimeout(() => {
95
+ o();
96
+ }, t.flushIntervalMs), r.unref?.());
97
+ },
98
+ flush: o
99
+ };
100
+ }, x = (e) => {
101
+ switch (e) {
102
+ case "minute": return "toStartOfMinute";
103
+ case "hour": return "toStartOfHour";
104
+ case "day": return "toStartOfDay";
105
+ case "week": return "toStartOfWeek";
106
+ case "month": return "toStartOfMonth";
107
+ }
108
+ }, S = (e, t) => `JSONExtractString(properties, '${g(e)(t)}')`, C = (e, t) => `JSONExtractFloat(properties, '${g(e)(t)}')`, w = (e, t) => t === "count" ? "count()" : t === "unique" ? "uniqExact(subject_id)" : "sum" in t ? `sum(${C(e, t.sum)})` : `avg(${C(e, t.avg)})`, T = (e, t) => t === "count" ? "count()" : `sum(${C(e, t.sum)})`, E = (e, t, n, r, i) => {
109
+ let a = {
110
+ event: t,
111
+ from: n.from.toISOString(),
112
+ to: (n.to ?? /* @__PURE__ */ new Date()).toISOString()
113
+ }, o = "name = {event:String} AND occurred_at >= {from:DateTime64(3)} AND occurred_at < {to:DateTime64(3)}";
114
+ if (i != null && (a.tenant = i, o += " AND tenant_id = {tenant:String}"), r !== void 0) {
115
+ let t = 0;
116
+ for (let [n, i] of Object.entries(r)) {
117
+ let r = `f${t++}`;
118
+ a[r] = String(i), o += ` AND ${S(e, n)} = {${r}:String}`;
119
+ }
120
+ }
121
+ return {
122
+ sql: o,
123
+ params: a
124
+ };
125
+ }, D = (e) => {
126
+ let n = (n, r) => t.gen(function* () {
127
+ p(n, "mirror table");
128
+ let i = yield* _(e);
129
+ yield* t.tryPromise({
130
+ try: () => i.insert({
131
+ table: `${e.database}.${s(n)}`,
132
+ values: [r],
133
+ format: "JSONEachRow"
134
+ }),
135
+ catch: (e) => e
136
+ });
137
+ });
138
+ return {
139
+ tables: e.mirrorTables,
140
+ primaryKey: e.mirrorPrimaryKey,
141
+ upsert: (r, i) => t.gen(function* () {
142
+ let t = String(i[e.mirrorPrimaryKey]);
143
+ yield* n(r, {
144
+ id: t,
145
+ data: JSON.stringify(i),
146
+ version: Date.now() * 1e3,
147
+ is_deleted: 0
148
+ });
149
+ }).pipe(d("mirror.upsert")),
150
+ remove: (e, r) => t.gen(function* () {
151
+ yield* n(e, {
152
+ id: String(r),
153
+ data: "{}",
154
+ version: Date.now() * 1e3,
155
+ is_deleted: 1
156
+ });
157
+ }).pipe(d("mirror.remove"))
158
+ };
159
+ }, O = (e) => {
160
+ let n = e.mirrorTables.length > 0 ? D(e) : void 0;
161
+ return {
162
+ info: {
163
+ provider: o,
164
+ capabilities: {
165
+ track: !0,
166
+ aggregate: !0,
167
+ timeseries: !0,
168
+ topN: !0,
169
+ mirror: n !== void 0
170
+ },
171
+ detail: {
172
+ database: e.database,
173
+ table: e.table,
174
+ ...e.mirrorTables.length > 0 ? { mirrorTables: e.mirrorTables } : {},
175
+ ...e.batcher === void 0 ? {} : { batching: !0 }
176
+ }
177
+ },
178
+ track: (n) => e.batcher ? t.sync(() => e.batcher.add(n)) : t.gen(function* () {
179
+ let r = yield* _(e);
180
+ yield* t.tryPromise({
181
+ try: () => r.insert({
182
+ table: `${e.database}.${e.table}`,
183
+ values: [y(n)],
184
+ format: "JSONEachRow"
185
+ }),
186
+ catch: (e) => e
187
+ });
188
+ }).pipe(d("track")),
189
+ aggregate: (n) => t.gen(function* () {
190
+ let r = yield* _(e), i = E("aggregate", n.event, n.range, n.filter, n.tenantId), a = `SELECT ${w("aggregate", n.metric)} AS value FROM ${e.database}.${e.table} WHERE ${i.sql} FORMAT JSON`, o = yield* t.tryPromise({
191
+ try: () => r.query({
192
+ query: a,
193
+ query_params: i.params
194
+ }),
195
+ catch: (e) => e
196
+ }), { data: s } = yield* t.tryPromise({
197
+ try: () => o.json(),
198
+ catch: (e) => e
199
+ }), c = s[0]?.value ?? 0;
200
+ return typeof c == "number" ? c : Number(c ?? 0);
201
+ }).pipe(d("aggregate")),
202
+ timeseries: (n) => t.gen(function* () {
203
+ let r = yield* _(e), i = E("timeseries", n.event, n.range, n.filter, n.tenantId), a = `
204
+ SELECT ${`${x(n.bucket)}(occurred_at)`} AS bucket_start, ${w("timeseries", n.metric)} AS value
205
+ FROM ${e.database}.${e.table}
206
+ WHERE ${i.sql}
207
+ GROUP BY bucket_start
208
+ ORDER BY bucket_start ASC
209
+ FORMAT JSON
210
+ `, o = yield* t.tryPromise({
211
+ try: () => r.query({
212
+ query: a,
213
+ query_params: i.params
214
+ }),
215
+ catch: (e) => e
216
+ }), { data: s } = yield* t.tryPromise({
217
+ try: () => o.json(),
218
+ catch: (e) => e
219
+ });
220
+ return s.map((e) => ({
221
+ bucketStart: new Date(e.bucket_start),
222
+ value: typeof e.value == "number" ? e.value : Number(e.value ?? 0)
223
+ }));
224
+ }).pipe(d("timeseries")),
225
+ topN: (n) => t.gen(function* () {
226
+ let r = yield* _(e), i = E("topN", n.event, n.range, n.filter, n.tenantId), a = Math.max(1, Math.floor(n.n)), o = S("topN", n.groupBy), s = `
227
+ SELECT ${o} AS key, ${T("topN", n.metric)} AS value
228
+ FROM ${e.database}.${e.table}
229
+ WHERE ${i.sql}
230
+ GROUP BY ${o}
231
+ ORDER BY value DESC
232
+ LIMIT ${a}
233
+ FORMAT JSON
234
+ `, c = yield* t.tryPromise({
235
+ try: () => r.query({
236
+ query: s,
237
+ query_params: i.params
238
+ }),
239
+ catch: (e) => e
240
+ }), { data: l } = yield* t.tryPromise({
241
+ try: () => c.json(),
242
+ catch: (e) => e
243
+ });
244
+ return l.filter((e) => e.key !== null).map((e) => ({
245
+ key: e.key,
246
+ value: typeof e.value == "number" ? e.value : Number(e.value ?? 0)
247
+ }));
248
+ }).pipe(d("topN")),
249
+ ...n === void 0 ? {} : { mirror: n }
250
+ };
251
+ }, k = 1e3, A = 5e3, j = (n) => {
252
+ let r = (n.mirrorTables ?? []).map((e) => p(e, "mirror table")), i = {
253
+ client: null,
254
+ database: p(n.database ?? "default", "database"),
255
+ table: p(n.table ?? "events", "table"),
256
+ mirrorTables: r,
257
+ mirrorPrimaryKey: n.mirrorPrimaryKey ?? "id"
258
+ };
259
+ return n.batch && (i.batcher = b(i, {
260
+ maxSize: n.batch.maxSize ?? k,
261
+ flushIntervalMs: n.batch.flushIntervalMs ?? A
262
+ })), {
263
+ kind: "clickhouse",
264
+ build: (e) => O(i),
265
+ initialize: () => t.gen(function* () {
266
+ let r = yield* t.promise(() => Promise.resolve(e({
267
+ url: n.url,
268
+ database: i.database,
269
+ ...n.username === void 0 ? {} : { username: n.username },
270
+ ...n.password === void 0 ? {} : { password: n.password }
271
+ })));
272
+ yield* t.promise(() => r.ping()), yield* t.promise(() => r.command({ query: l(i.database, i.table) }));
273
+ for (let e of i.mirrorTables) yield* t.promise(() => r.command({ query: c(i.database, e) }));
274
+ i.client = r;
275
+ }),
276
+ dispose: () => t.gen(function* () {
277
+ i.batcher && (yield* t.promise(() => i.batcher.flush())), yield* v(i);
278
+ })
279
+ };
280
+ };
281
+ //#endregion
282
+ export { l as EVENTS_TABLE_DDL, c as MIRROR_TABLE_DDL, j as clickhouseAnalytics };
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@voltro/plugin-clickhouse",
3
+ "version": "0.1.0",
4
+ "description": "ClickHouse AnalyticsSink. Native HTTP ingest into a ClickHouse cluster — self-hosted OR ClickHouse Cloud. Production-grade OLAP for high-cardinality events with millisecond aggregate queries over billions of rows.",
5
+ "keywords": [
6
+ "voltro",
7
+ "typescript",
8
+ "framework"
9
+ ],
10
+ "license": "SEE LICENSE IN LICENSE",
11
+ "homepage": "https://voltro.dev",
12
+ "bugs": {
13
+ "email": "support@voltro.dev"
14
+ },
15
+ "author": {
16
+ "name": "Voltro UG",
17
+ "url": "https://voltro.dev"
18
+ },
19
+ "type": "module",
20
+ "exports": {
21
+ ".": {
22
+ "types": "./dist/index.d.ts",
23
+ "import": "./dist/index.js",
24
+ "default": "./dist/index.js"
25
+ }
26
+ },
27
+ "main": "./dist/index.js",
28
+ "module": "./dist/index.js",
29
+ "types": "./dist/index.d.ts",
30
+ "sideEffects": false,
31
+ "engines": {
32
+ "node": ">=24.0.0"
33
+ },
34
+ "dependencies": {
35
+ "@clickhouse/client": "^1.23.1",
36
+ "@voltro/logger": "0.1.0",
37
+ "@voltro/runtime": "0.1.0"
38
+ },
39
+ "peerDependencies": {
40
+ "effect": "^3.21.4"
41
+ },
42
+ "publishConfig": {
43
+ "access": "public"
44
+ }
45
+ }