@walkeros/server-destination-clickhouse 4.7.0-next-1790245733116 → 4.7.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/CHANGELOG.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @walkeros/server-destination-clickhouse
2
2
 
3
- ## 4.7.0-next-1790245733116
3
+ ## 4.7.0
4
4
 
5
5
  ### Minor Changes
6
6
 
@@ -12,18 +12,29 @@
12
12
 
13
13
  ### Patch Changes
14
14
 
15
- - Updated dependencies [57788bc]
15
+ - 74821ed: Simulate records every vendor call through one recorder, `observeEnv`
16
+ from `@walkeros/core`, and runs offline: BigQuery, Data Manager and the
17
+ Sheets, GCS and S3 stores reach the network through `env`. Client and `Env`
18
+ types of BigQuery, Firehose, PostHog, SQS and Pub/Sub are structural.
19
+ `@walkeros/cli` exports `resolveExportName` and `selectDevExamples` and drops
20
+ `findExample` and `compareOutput`.
21
+ - Updated dependencies [74821ed]
16
22
  - Updated dependencies [06b498a]
17
- - Updated dependencies [f5a1cfd]
18
- - Updated dependencies [ef02916]
23
+ - Updated dependencies [74821ed]
24
+ - Updated dependencies [64b06de]
25
+ - Updated dependencies [4b4937f]
26
+ - Updated dependencies [74821ed]
27
+ - Updated dependencies [74821ed]
28
+ - Updated dependencies [74821ed]
19
29
  - Updated dependencies [06b498a]
20
- - Updated dependencies [ef02916]
21
- - Updated dependencies [2aebc53]
22
- - Updated dependencies [f5a1cfd]
23
- - Updated dependencies [f5a1cfd]
24
- - Updated dependencies [2aebc53]
25
- - Updated dependencies [8f8aea6]
26
- - Updated dependencies [2aebc53]
27
- - Updated dependencies [2aebc53]
28
- - @walkeros/core@4.7.0-next-1790245733116
29
- - @walkeros/server-core@4.7.0-next-1790245733116
30
+ - Updated dependencies [74821ed]
31
+ - Updated dependencies [74821ed]
32
+ - Updated dependencies [74821ed]
33
+ - Updated dependencies [74821ed]
34
+ - Updated dependencies [74821ed]
35
+ - Updated dependencies [e860006]
36
+ - Updated dependencies [74821ed]
37
+ - Updated dependencies [74821ed]
38
+ - Updated dependencies [4f89234]
39
+ - @walkeros/core@4.7.0
40
+ - @walkeros/server-core@4.7.0
package/dist/dev.js CHANGED
@@ -1 +1 @@
1
- "use strict";var e,t=Object.defineProperty,i=Object.getOwnPropertyDescriptor,n=Object.getOwnPropertyNames,s=Object.prototype.hasOwnProperty,r=(e,i)=>{for(var n in i)t(e,n,{get:i[n],enumerable:!0})},o=(e,r,o,a)=>{if(r&&"object"==typeof r||"function"==typeof r)for(let c of n(r))s.call(e,c)||c===o||t(e,c,{get:()=>r[c],enumerable:!(a=i(r,c))||a.enumerable});return e},a={};r(a,{examples:()=>g,schemas:()=>c}),module.exports=(e=a,o(t({},"__esModule",{value:!0}),e));var c={};r(c,{MappingSchema:()=>p,SettingsSchema:()=>d,mapping:()=>h,settings:()=>m});var l=require("@walkeros/core/dev"),u=require("@walkeros/core/dev"),d=u.z.object({url:u.z.string().min(1).describe("ClickHouse HTTP endpoint (like https://host.clickhouse.cloud:8443)"),database:u.z.string().min(1).default("default").describe("Database holding the events table (like analytics)"),table:u.z.string().min(1).default("events").describe("Table every event is inserted into (like events)"),maxRetries:u.z.number().int().min(0).default(1).describe("Retries added on top of the first attempt, so total attempts = 1 + maxRetries (like 1 for two attempts)"),clickhouse:u.z.record(u.z.string(),u.z.unknown()).optional().describe("Raw @clickhouse/client options merged into the created client (like { request_timeout: 60000 })"),clickhouseSettings:u.z.record(u.z.string(),u.z.unknown()).optional().describe("ClickHouse settings applied to every insert (like { async_insert: 1 })")}),p=require("@walkeros/core/dev").z.object({}),m=(0,l.zodToSchema)(d),h=(0,l.zodToSchema)(p),g={};r(g,{env:()=>f,step:()=>T});var f={};r(f,{push:()=>O,simulation:()=>q});var k={};r(k,{__failInsert:()=>C,__getCalls:()=>y,__reset:()=>_,createClient:()=>S});var v=require("@clickhouse/client");((e,t,i)=>{o(e,t,"default"),i&&o(i,t,"default")})(k,require("@clickhouse/client"));var b,w=[];function y(){return w}function _(){w.length=0,b=void 0}function C(e,t={}){b={remaining:e,code:t.code,message:t.message??"clickhouse insert failed",type:t.type}}var S=e=>(w.push(["createClient",e]),{async insert(e){w.push(["insert",e]);const t=function(){if(!b||b.remaining<1)return;b.remaining-=1;const{code:e,message:t,type:i}=b;return void 0===e?new Error(t):new v.ClickHouseError({message:t,code:e,type:i})}();if(t)throw t;return{executed:!0,query_id:`mock-insert-${w.length}`,response_headers:{}}},async close(){w.push(["close"])}}),O={ClickHouseClient:S},q=["ClickHouseClient"],T={};r(T,{init:()=>D,initPassthrough:()=>j,pageView:()=>$,purchase:()=>J});var x=require("@walkeros/core");function z(e,t){return String(e).padStart(t,"0")}function U(e){const t=new Date(e);return`${[z(t.getUTCFullYear(),4),z(t.getUTCMonth()+1,2),z(t.getUTCDate(),2)].join("-")} ${[z(t.getUTCHours(),2),z(t.getUTCMinutes(),2),z(t.getUTCSeconds(),2)].join(":")}.${z(t.getUTCMilliseconds(),3)}`}function R(e){if(null==e)return"";if("object"==typeof e)if(Array.isArray(e)){if(0===e.length)return""}else if(0===Object.keys(e).length)return"";return JSON.stringify(e)}function E(e){return{name:e.name,data:R(e.data),context:R(e.context),globals:R(e.globals),custom:R(e.custom),user:R(e.user),nested:R(e.nested),consent:R(e.consent),id:e.id,trigger:e.trigger,entity:e.entity,action:e.action,timestamp:U(e.timestamp),timing:e.timing,source:R(e.source)}}var H=require("crypto");require("@clickhouse/client");var P={url:"https://clickhouse.example.com:8443",database:"analytics",table:"events"},D={title:"Initialization",description:"Init creates a single ClickHouse client for the configured endpoint, database and credentials. The client is held on the resolved config and reused by every insert. Its request timeout is derived from the collector timeout so a retried insert cannot outlive the delivery it belongs to, and request compression is enabled.",in:{settings:P,credentials:{username:"walkeros_writer",password:"$secret.CLICKHOUSE_PASSWORD"}},out:[["createClient",{url:"https://clickhouse.example.com:8443",database:"analytics",username:"walkeros_writer",password:"$secret.CLICKHOUSE_PASSWORD",request_timeout:4812,compression:{request:!0}}]]},j={title:"Client option passthrough",description:"The raw settings.clickhouse options reach the client underneath the destination's own settings: url, database and the credentials slot win over them, an explicit request_timeout or compression wins over the derived default, and any other option travels through untouched.",public:!1,in:{settings:{url:"https://clickhouse.example.com:8443",database:"analytics",clickhouse:{url:"https://ignored.example.com:8443",database:"ignored",username:"ignored_user",request_timeout:6e4,max_open_connections:25,application:"walkeros"}},credentials:{username:"walkeros_writer",password:"$secret.CLICKHOUSE_PASSWORD"}},out:[["createClient",{url:"https://clickhouse.example.com:8443",database:"analytics",username:"walkeros_writer",password:"$secret.CLICKHOUSE_PASSWORD",request_timeout:6e4,max_open_connections:25,application:"walkeros",compression:{request:!0}}]]};function A(e){return[["insert",{table:P.table,values:[E(e)],format:"JSONEachRow",clickhouse_settings:{async_insert:0,input_format_skip_unknown_fields:0,input_format_null_as_default:0,insert_deduplication_token:(t=[e.id],(0,H.createHash)("sha256").update(JSON.stringify(t)).digest("hex"))}}]];var t}var I=(0,x.getEvent)("page view",{timestamp:1700000000123}),$={title:"Page view",description:"A page view is inserted as a single JSONEachRow row. The payload columns (data, context, globals, custom, user, nested, consent, source) are JSON-encoded strings and the timestamp is a UTC datetime literal, both produced by eventToRow.",in:I,mapping:void 0,out:A(I)},N=(0,x.getEvent)("order complete",{timestamp:1700000000456,data:{id:"ORD-500",currency:"EUR",total:199.99,items:[{sku:"SKU-1",quantity:2},{sku:"SKU-2",quantity:1}]}}),J={title:"Purchase",description:"An order event is inserted as one row. The nested items array is JSON-encoded into the data column, so a richer payload needs no table change. Promoting a hot key to its own column is a MATERIALIZED expression in the table DDL, not a destination setting.",in:N,mapping:void 0,out:A(N)};//# sourceMappingURL=dev.js.map
1
+ "use strict";var e,t=Object.defineProperty,i=Object.getOwnPropertyDescriptor,n=Object.getOwnPropertyNames,s=Object.prototype.hasOwnProperty,r=(e,i)=>{for(var n in i)t(e,n,{get:i[n],enumerable:!0})},o=(e,r,o,a)=>{if(r&&"object"==typeof r||"function"==typeof r)for(let c of n(r))s.call(e,c)||c===o||t(e,c,{get:()=>r[c],enumerable:!(a=i(r,c))||a.enumerable});return e},a={};r(a,{examples:()=>g,schemas:()=>c}),module.exports=(e=a,o(t({},"__esModule",{value:!0}),e));var c={};r(c,{MappingSchema:()=>p,SettingsSchema:()=>d,mapping:()=>h,settings:()=>m});var l=require("@walkeros/core/dev"),u=require("@walkeros/core/dev"),d=u.z.object({url:u.z.string().min(1).describe("ClickHouse HTTP endpoint (like https://host.clickhouse.cloud:8443)"),database:u.z.string().min(1).default("default").describe("Database holding the events table (like analytics)"),table:u.z.string().min(1).default("events").describe("Table every event is inserted into (like events)"),maxRetries:u.z.number().int().min(0).default(1).describe("Retries added on top of the first attempt, so total attempts = 1 + maxRetries (like 1 for two attempts)"),clickhouse:u.z.record(u.z.string(),u.z.unknown()).optional().describe("Raw @clickhouse/client options merged into the created client (like { request_timeout: 60000 })"),clickhouseSettings:u.z.record(u.z.string(),u.z.unknown()).optional().describe("ClickHouse settings applied to every insert (like { async_insert: 1 })")}),p=require("@walkeros/core/dev").z.object({}),m=(0,l.zodToSchema)(d),h=(0,l.zodToSchema)(p),g={};r(g,{env:()=>f,step:()=>T});var f={};r(f,{push:()=>O,simulation:()=>q});var k={};r(k,{__failInsert:()=>C,__getCalls:()=>y,__reset:()=>_,createClient:()=>S});var v=require("@clickhouse/client");((e,t,i)=>{o(e,t,"default"),i&&o(i,t,"default")})(k,require("@clickhouse/client"));var b,w=[];function y(){return w}function _(){w.length=0,b=void 0}function C(e,t={}){b={remaining:e,code:t.code,message:t.message??"clickhouse insert failed",type:t.type}}var S=e=>(w.push(["createClient",e]),{async insert(e){w.push(["insert",e]);const t=function(){if(!b||b.remaining<1)return;b.remaining-=1;const{code:e,message:t,type:i}=b;return void 0===e?new Error(t):new v.ClickHouseError({message:t,code:e,type:i})}();if(t)throw t;return{executed:!0,query_id:`mock-insert-${w.length}`,response_headers:{}}},async close(){w.push(["close"])}}),O={ClickHouseClient:S},q=["call:ClickHouseClient.insert"],T={};r(T,{init:()=>D,initPassthrough:()=>j,pageView:()=>$,purchase:()=>J});var x=require("@walkeros/core");function z(e,t){return String(e).padStart(t,"0")}function U(e){const t=new Date(e);return`${[z(t.getUTCFullYear(),4),z(t.getUTCMonth()+1,2),z(t.getUTCDate(),2)].join("-")} ${[z(t.getUTCHours(),2),z(t.getUTCMinutes(),2),z(t.getUTCSeconds(),2)].join(":")}.${z(t.getUTCMilliseconds(),3)}`}function R(e){if(null==e)return"";if("object"==typeof e)if(Array.isArray(e)){if(0===e.length)return""}else if(0===Object.keys(e).length)return"";return JSON.stringify(e)}function E(e){return{name:e.name,data:R(e.data),context:R(e.context),globals:R(e.globals),custom:R(e.custom),user:R(e.user),nested:R(e.nested),consent:R(e.consent),id:e.id,trigger:e.trigger,entity:e.entity,action:e.action,timestamp:U(e.timestamp),timing:e.timing,source:R(e.source)}}var H=require("crypto");require("@clickhouse/client");var P={url:"https://clickhouse.example.com:8443",database:"analytics",table:"events"},D={title:"Initialization",description:"Init creates a single ClickHouse client for the configured endpoint, database and credentials. The client is held on the resolved config and reused by every insert. Its request timeout is derived from the collector timeout so a retried insert cannot outlive the delivery it belongs to, and request compression is enabled.",in:{settings:P,credentials:{username:"walkeros_writer",password:"$secret.CLICKHOUSE_PASSWORD"}},out:[["createClient",{url:"https://clickhouse.example.com:8443",database:"analytics",username:"walkeros_writer",password:"$secret.CLICKHOUSE_PASSWORD",request_timeout:4812,compression:{request:!0}}]]},j={title:"Client option passthrough",description:"The raw settings.clickhouse options reach the client underneath the destination's own settings: url, database and the credentials slot win over them, an explicit request_timeout or compression wins over the derived default, and any other option travels through untouched.",public:!1,in:{settings:{url:"https://clickhouse.example.com:8443",database:"analytics",clickhouse:{url:"https://ignored.example.com:8443",database:"ignored",username:"ignored_user",request_timeout:6e4,max_open_connections:25,application:"walkeros"}},credentials:{username:"walkeros_writer",password:"$secret.CLICKHOUSE_PASSWORD"}},out:[["createClient",{url:"https://clickhouse.example.com:8443",database:"analytics",username:"walkeros_writer",password:"$secret.CLICKHOUSE_PASSWORD",request_timeout:6e4,max_open_connections:25,application:"walkeros",compression:{request:!0}}]]};function A(e){return[["insert",{table:P.table,values:[E(e)],format:"JSONEachRow",clickhouse_settings:{async_insert:0,input_format_skip_unknown_fields:0,input_format_null_as_default:0,insert_deduplication_token:(t=[e.id],(0,H.createHash)("sha256").update(JSON.stringify(t)).digest("hex"))}}]];var t}var I=(0,x.getEvent)("page view",{timestamp:1700000000123}),$={title:"Page view",description:"A page view is inserted as a single JSONEachRow row. The payload columns (data, context, globals, custom, user, nested, consent, source) are JSON-encoded strings and the timestamp is a UTC datetime literal, both produced by eventToRow.",in:I,mapping:void 0,out:A(I)},N=(0,x.getEvent)("order complete",{timestamp:1700000000456,data:{id:"ORD-500",currency:"EUR",total:199.99,items:[{sku:"SKU-1",quantity:2},{sku:"SKU-2",quantity:1}]}}),J={title:"Purchase",description:"An order event is inserted as one row. The nested items array is JSON-encoded into the data column, so a richer payload needs no table change. Promoting a hot key to its own column is a MATERIALIZED expression in the table DDL, not a destination setting.",in:N,mapping:void 0,out:A(N)};//# sourceMappingURL=dev.js.map
package/dist/dev.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/dev.ts","../src/schemas/index.ts","../src/schemas/settings.ts","../src/schemas/mapping.ts","../src/examples/index.ts","../src/examples/env.ts","../src/__mocks__/@clickhouse/client.ts","../src/examples/step.ts","../src/eventToRow.ts","../src/insert.ts","../src/config.ts"],"sourcesContent":["export * as schemas from './schemas';\nexport * as examples from './examples';\n","import { zodToSchema } from '@walkeros/core/dev';\nimport { SettingsSchema } from './settings';\nimport { MappingSchema } from './mapping';\n\nexport { SettingsSchema, type Settings } from './settings';\nexport { MappingSchema, type Mapping } from './mapping';\n\n// JSON Schema\nexport const settings = zodToSchema(SettingsSchema);\nexport const mapping = zodToSchema(MappingSchema);\n","import { z } from '@walkeros/core/dev';\n\nexport const SettingsSchema = z.object({\n url: z\n .string()\n .min(1)\n .describe(\n 'ClickHouse HTTP endpoint (like https://host.clickhouse.cloud:8443)',\n ),\n database: z\n .string()\n .min(1)\n .default('default')\n .describe('Database holding the events table (like analytics)'),\n table: z\n .string()\n .min(1)\n .default('events')\n .describe('Table every event is inserted into (like events)'),\n maxRetries: z\n .number()\n .int()\n .min(0)\n .default(1)\n .describe(\n 'Retries added on top of the first attempt, so total attempts = 1 + maxRetries (like 1 for two attempts)',\n ),\n clickhouse: z\n .record(z.string(), z.unknown())\n .optional()\n .describe(\n 'Raw @clickhouse/client options merged into the created client (like { request_timeout: 60000 })',\n ),\n clickhouseSettings: z\n .record(z.string(), z.unknown())\n .optional()\n .describe(\n 'ClickHouse settings applied to every insert (like { async_insert: 1 })',\n ),\n});\n\nexport type Settings = z.infer<typeof SettingsSchema>;\n","import { z } from '@walkeros/core/dev';\n\n/**\n * ClickHouse Mapping Schema\n * ClickHouse receives raw events, so there is no event-level mapping\n * configuration.\n */\nexport const MappingSchema = z.object({});\n\n/**\n * Type inference from MappingSchema\n */\nexport type Mapping = z.infer<typeof MappingSchema>;\n","export * as env from './env';\nexport * as step from './step';\n","import type { Env } from '../types';\nimport { createClient } from '../__mocks__/@clickhouse/client';\n\n/**\n * Example environment for the ClickHouse destination.\n *\n * The client factory is the recording stand-in from the package-local\n * __mocks__ folder, so examples, tests and simulations share one source of\n * truth and never reach a ClickHouse server.\n */\nexport const push: Env = {\n ClickHouseClient: createClient,\n};\n\nexport const simulation = ['ClickHouseClient'];\n","// Recording stand-in for `createClient` from `@clickhouse/client`.\n//\n// Jest resolves `@clickhouse/client` to this file for every test in the\n// package, so the star re-export below keeps every other SDK name (types,\n// `ClickHouseError`, the format constants) reachable instead of undefined. The\n// local `createClient` shadows the real one.\n//\n// The returned client is a plain object carrying only `insert` and `close`,\n// the two methods this destination is allowed to call. Nothing from the real\n// SDK is constructed, so no request can leave the process by any path.\n\nimport type { Readable } from 'stream';\nimport type {\n ClickHouseClientConfigOptions,\n InsertParams,\n InsertResult,\n} from '@clickhouse/client';\nimport { ClickHouseError } from '@clickhouse/client';\nimport type { ClickHouseClientSurface } from '../../types';\n\nexport * from '@clickhouse/client';\n\n/** One recorded interaction: the callable name followed by its arguments. */\nexport type MockCall = readonly [string, ...unknown[]];\n\ninterface Failure {\n remaining: number;\n /**\n * ClickHouse error codes are strings (like '159' for a server-side timeout\n * or '252' for too many parts). Omit it for a codeless plain Error.\n */\n code?: string;\n message: string;\n type?: string;\n}\n\nconst calls: MockCall[] = [];\nlet failure: Failure | undefined;\n\n/** Every interaction recorded so far, in order. */\nexport function __getCalls(): readonly MockCall[] {\n return calls;\n}\n\n/** Clears the capture array and any pending insert failure. */\nexport function __reset(): void {\n calls.length = 0;\n failure = undefined;\n}\n\n/**\n * Rejects the next `times` inserts, then lets inserts succeed again.\n *\n * With a `code` the rejection is a real `ClickHouseError` carrying that string\n * code, so retry logic is exercised against the class the server actually\n * produces. Without a `code` it is a plain `Error` with no code at all.\n */\nexport function __failInsert(\n times: number,\n options: { code?: string; message?: string; type?: string } = {},\n): void {\n failure = {\n remaining: times,\n code: options.code,\n message: options.message ?? 'clickhouse insert failed',\n type: options.type,\n };\n}\n\nfunction nextFailure(): Error | undefined {\n if (!failure || failure.remaining < 1) return undefined;\n\n failure.remaining -= 1;\n const { code, message, type } = failure;\n\n return code === undefined\n ? new Error(message)\n : new ClickHouseError({ message, code, type });\n}\n\nexport const createClient = (\n config: ClickHouseClientConfigOptions,\n): ClickHouseClientSurface => {\n calls.push(['createClient', config]);\n\n return {\n async insert<T>(params: InsertParams<Readable, T>): Promise<InsertResult> {\n calls.push(['insert', params]);\n\n const error = nextFailure();\n if (error) throw error;\n\n return {\n executed: true,\n query_id: `mock-insert-${calls.length}`,\n response_headers: {},\n };\n },\n\n async close(): Promise<void> {\n calls.push(['close']);\n },\n };\n};\n","import type { Flow, WalkerOS } from '@walkeros/core';\nimport { getEvent } from '@walkeros/core';\nimport { eventToRow } from '../eventToRow';\nimport { deduplicationToken } from '../insert';\n\n/**\n * The endpoint, database and table the examples below write to. The table is\n * stated once because two places need it, the config a user writes and the\n * insert that config produces, and an example where those two disagree would\n * assert nothing.\n */\nconst settings = {\n url: 'https://clickhouse.example.com:8443',\n database: 'analytics',\n table: 'events',\n};\n\n/**\n * Destination bootstrap.\n *\n * `in` is the config a user copy-pastes into a flow: the endpoint, the\n * database and table to write to, and the credentials slot backed by a managed\n * secret. Init creates exactly one ClickHouse client from it and holds that\n * client on the resolved config, so every insert reuses the same connection\n * pool.\n *\n * Two options the user did not write reach the client. `request_timeout` is\n * derived from the collector's per-delivery timeout (10000 ms by default) minus\n * what the retry delays can take, split across the attempts: at one retry that\n * is 4812 ms, so the second attempt finishes before the collector stops waiting\n * and dead-letters a batch the client would still land. Request compression is\n * off in this client by default and insert bodies are large, repetitive JSON,\n * so it is turned on. Both yield to `settings.clickhouse`.\n */\nexport const init: Flow.StepExample = {\n title: 'Initialization',\n description:\n 'Init creates a single ClickHouse client for the configured endpoint, database and credentials. The client is held on the resolved config and reused by every insert. Its request timeout is derived from the collector timeout so a retried insert cannot outlive the delivery it belongs to, and request compression is enabled.',\n in: {\n settings,\n credentials: {\n username: 'walkeros_writer',\n password: '$secret.CLICKHOUSE_PASSWORD',\n },\n },\n out: [\n [\n 'createClient',\n {\n url: 'https://clickhouse.example.com:8443',\n database: 'analytics',\n username: 'walkeros_writer',\n password: '$secret.CLICKHOUSE_PASSWORD',\n request_timeout: 4812,\n compression: { request: true },\n },\n ],\n ],\n};\n\n/**\n * What happens to the raw `settings.clickhouse` passthrough.\n *\n * The merge runs in four layers, lowest first: the passthrough, then the\n * derived defaults where the passthrough left their key unset, then `url` and\n * `database`, then `credentials`. An insert names only the table, so a\n * passthrough `database` that disagreed with the configured one would move\n * every row to a different database in silence; the configured one wins. The\n * credentials slot wins over a username or password left in the passthrough,\n * and an option the destination does not name travels through untouched.\n */\nexport const initPassthrough: Flow.StepExample = {\n title: 'Client option passthrough',\n description:\n \"The raw settings.clickhouse options reach the client underneath the destination's own settings: url, database and the credentials slot win over them, an explicit request_timeout or compression wins over the derived default, and any other option travels through untouched.\",\n public: false,\n in: {\n settings: {\n url: 'https://clickhouse.example.com:8443',\n database: 'analytics',\n clickhouse: {\n url: 'https://ignored.example.com:8443',\n database: 'ignored',\n username: 'ignored_user',\n request_timeout: 60000,\n max_open_connections: 25,\n application: 'walkeros',\n },\n },\n credentials: {\n username: 'walkeros_writer',\n password: '$secret.CLICKHOUSE_PASSWORD',\n },\n },\n out: [\n [\n 'createClient',\n {\n url: 'https://clickhouse.example.com:8443',\n database: 'analytics',\n username: 'walkeros_writer',\n password: '$secret.CLICKHOUSE_PASSWORD',\n request_timeout: 60000,\n max_open_connections: 25,\n application: 'walkeros',\n compression: { request: true },\n },\n ],\n ],\n};\n\n// Event step examples belong below this line. The step-example test iterates\n// every entry exported from this module and slices the captured calls by\n// `init.out.length`, so an appended entry needs no test change.\n\n/**\n * The insert one event produces. Rows come from `eventToRow`, so an example can\n * never claim a column shape the destination does not emit.\n *\n * The per-insert settings are spelled out here rather than read from the code\n * on purpose. They are the three settings that make a schema mismatch loud\n * instead of silent and keep the acknowledgement tied to a durable write,\n * nothing in the type system forces the insert call to send them, and an\n * insert that quietly stopped sending them would keep every other test green.\n * The deduplication token sits inside that same object: it is what lets a\n * retry of a batch the server may already have written land without counting\n * those rows twice, and it is computed from the event ids, so this example\n * reads it from the destination rather than restating a digest.\n */\nfunction expectedInsert(event: WalkerOS.Event): Flow.StepOut {\n return [\n [\n 'insert',\n {\n table: settings.table,\n values: [eventToRow(event)],\n format: 'JSONEachRow',\n clickhouse_settings: {\n async_insert: 0,\n input_format_skip_unknown_fields: 0,\n input_format_null_as_default: 0,\n insert_deduplication_token: deduplicationToken([event.id]),\n },\n },\n ],\n ];\n}\n\nconst pageViewEvent = getEvent('page view', { timestamp: 1700000000123 });\n\n/**\n * One event, one row, one insert. Every payload travels as a JSON-encoded\n * string and the timestamp as a `YYYY-MM-DD hh:mm:ss.SSS` UTC literal, the\n * format every ClickHouse date parser reads identically.\n */\nexport const pageView: Flow.StepExample = {\n title: 'Page view',\n description:\n 'A page view is inserted as a single JSONEachRow row. The payload columns (data, context, globals, custom, user, nested, consent, source) are JSON-encoded strings and the timestamp is a UTC datetime literal, both produced by eventToRow.',\n in: pageViewEvent,\n mapping: undefined,\n out: expectedInsert(pageViewEvent),\n};\n\nconst purchaseEvent = getEvent('order complete', {\n timestamp: 1700000000456,\n data: {\n id: 'ORD-500',\n currency: 'EUR',\n total: 199.99,\n items: [\n { sku: 'SKU-1', quantity: 2 },\n { sku: 'SKU-2', quantity: 1 },\n ],\n },\n});\n\n/**\n * An order carries an array of items. The whole `data` object, array\n * included, is JSON-encoded into the single `data` column, so a flow can send\n * any shape without the table knowing about it.\n */\nexport const purchase: Flow.StepExample = {\n title: 'Purchase',\n description:\n 'An order event is inserted as one row. The nested items array is JSON-encoded into the data column, so a richer payload needs no table change. Promoting a hot key to its own column is a MATERIALIZED expression in the table DDL, not a destination setting.',\n in: purchaseEvent,\n mapping: undefined,\n out: expectedInsert(purchaseEvent),\n};\n","import type { WalkerOS } from '@walkeros/core';\n\n/**\n * One insert row, in the canonical walkerOS Event v4 column order: name, data,\n * context, globals, custom, user, nested, consent, id, trigger, entity,\n * action, timestamp, timing, source.\n *\n * Payload columns are JSON-encoded strings rather than nested structures, so\n * the table needs no schema knowledge of what a flow sends. Hot keys become\n * real columns in the table's own DDL through `MATERIALIZED` expressions over\n * these strings, which costs the destination nothing.\n *\n * No column is nullable. Inserts run with `input_format_null_as_default = 0`,\n * which turns a stray `null` into a loud server-side error instead of a silent\n * default, so an absent payload has to arrive as an empty string. A non-finite\n * `timing` is the one value the type still admits that reaches the wire as a\n * null, `JSON.stringify` writing `NaN` and `Infinity` that way: the server\n * rejects the batch, the rejection is terminal and is not retried, and the\n * wrong number stays visible in the event rather than becoming a plausible `0`\n * in the table.\n */\nexport interface ClickHouseRow {\n [column: string]: string | number;\n name: string;\n data: string;\n context: string;\n globals: string;\n custom: string;\n user: string;\n nested: string;\n consent: string;\n id: string;\n trigger: string;\n entity: string;\n action: string;\n timestamp: string;\n timing: number;\n source: string;\n}\n\n/**\n * A row as the insert path receives it: the canonical shape above, or the\n * object a flow's mapping placed in `context.data`.\n *\n * A mapped row replaces the canonical one wholesale and travels verbatim, so a\n * partner whose table is not the reference DDL configures its columns in the\n * flow. Its shape is whatever the flow wrote, and this type claims no more\n * than that.\n */\nexport type InsertRow = ClickHouseRow | WalkerOS.AnyObject;\n\nfunction pad(value: number, length: number): string {\n return String(value).padStart(length, '0');\n}\n\n/**\n * `YYYY-MM-DD hh:mm:ss.SSS` in UTC, the one literal every ClickHouse server\n * reads the same way. The `basic` date parser rejects the ISO 8601 `T` and `Z`\n * markers, and a bare number is read as Unix seconds by newer servers but as\n * the raw value at the column's precision by older ones, so epoch\n * milliseconds would land off by a factor of 1000 depending on the version.\n * The reference DDL declares the column `DateTime64(3, 'UTC')`, and a string\n * is read in the column's own timezone.\n */\nfunction toDateTime(timestamp: number): string {\n const date = new Date(timestamp);\n\n const day = [\n pad(date.getUTCFullYear(), 4),\n pad(date.getUTCMonth() + 1, 2),\n pad(date.getUTCDate(), 2),\n ].join('-');\n\n const time = [\n pad(date.getUTCHours(), 2),\n pad(date.getUTCMinutes(), 2),\n pad(date.getUTCSeconds(), 2),\n ].join(':');\n\n return `${day} ${time}.${pad(date.getUTCMilliseconds(), 3)}`;\n}\n\n/**\n * JSON for anything carrying content, `''` for anything not. Absent values,\n * empty objects and empty arrays collapse to the same empty string so no\n * column ever holds a null.\n */\nfunction jsonOrEmpty(value: unknown): string {\n if (value === undefined || value === null) return '';\n\n if (typeof value === 'object') {\n if (Array.isArray(value)) {\n if (value.length === 0) return '';\n } else if (Object.keys(value).length === 0) return '';\n }\n\n return JSON.stringify(value);\n}\n\n/** Convert one walkerOS event into one ClickHouse row. */\nexport function eventToRow(event: WalkerOS.Event): ClickHouseRow {\n return {\n name: event.name,\n data: jsonOrEmpty(event.data),\n context: jsonOrEmpty(event.context),\n globals: jsonOrEmpty(event.globals),\n custom: jsonOrEmpty(event.custom),\n user: jsonOrEmpty(event.user),\n nested: jsonOrEmpty(event.nested),\n consent: jsonOrEmpty(event.consent),\n id: event.id,\n trigger: event.trigger,\n entity: event.entity,\n action: event.action,\n timestamp: toDateTime(event.timestamp),\n timing: event.timing,\n source: jsonOrEmpty(event.source),\n };\n}\n","import type { Readable } from 'stream';\nimport type { ClickHouseSettings, InsertParams } from '@clickhouse/client';\nimport type { Logger } from '@walkeros/core';\nimport { createHash } from 'node:crypto';\nimport { classify, errorCode } from './classify';\nimport {\n RETRY_JITTER_MAX,\n normalizeMaxRetries,\n resolveInsertSettings,\n retryDelay,\n} from './config';\nimport type { InsertRow } from './eventToRow';\nimport type { Settings } from './types';\n\n/**\n * One row on its way to the table, beside the id of the event it came from.\n *\n * The id travels beside the row rather than inside it because a row is not\n * guaranteed to carry one. A mapped row is whatever a flow wrote, and `id` is\n * a column a partner's table need not have. Reading the token out of the row\n * would give every id-less mapped row the same token, which\n * `deduplicationToken` explains is silent, total data loss.\n */\nexport interface InsertEntry {\n /** The row as it travels: a mapped object verbatim, or the conversion. */\n row: InsertRow;\n /** The walkerOS event id. The only input the token is derived from. */\n id: string;\n}\n\n/**\n * The deduplication token sent with a batch: a digest over its event ids, in\n * the order they are inserted.\n *\n * Retrying an insert without one double-counts every row of a batch the server\n * had already written when the answer stopped coming. Synchronous inserts\n * deduplicate on the block contents by themselves, but only where the window\n * is on: `non_replicated_deduplication_window` defaults to 0, so a plain\n * MergeTree does nothing unless the table says otherwise. An explicit token\n * identifies the block for both MergeTree families, but it prevents duplicates\n * only where insert deduplication is enabled: by default on a\n * ReplicatedMergeTree, and on a plain MergeTree only with a positive window.\n *\n * A digest rather than a random uuid, so a redelivery of the same events\n * produces the same token and deduplicates too, as long as it lands inside the\n * table's deduplication window, which counts blocks and not time. The ids\n * travel as a JSON array so that no id can imitate the separator between two\n * of them, and the order is part of the digest because the same rows in\n * another order are another block to ClickHouse.\n *\n * Event ids and nothing else. The server deduplicates on this token INSTEAD of\n * the block contents, and skips a repeated block while reporting success, so\n * two deliveries that share a token are one delivery with no error, no dead\n * letter entry and no counter anywhere: a token that could repeat across\n * different events is silent data loss. The ids are the one input that is\n * always present and unique per event, which the row is not, and they identify\n * the events rather than describing them, so two different events that a\n * mapping happened to shape identically stay two blocks.\n */\nexport function deduplicationToken(ids: readonly string[]): string {\n return createHash('sha256').update(JSON.stringify(ids)).digest('hex');\n}\n\n/**\n * How much of the base delay this wait takes: all of it, plus up to half again.\n * Two runtimes that failed on the same server-side condition would otherwise\n * retry in lockstep and meet it together a second time.\n */\nfunction drawJitter(): number {\n return 1 + Math.random() * (RETRY_JITTER_MAX - 1);\n}\n\nfunction wait(ms: number): Promise<void> {\n return new Promise((resolve) => {\n setTimeout(resolve, ms);\n });\n}\n\n/**\n * Inserts one batch, retrying a transient failure up to `maxRetries` times.\n *\n * One flushed batch is one insert, whatever its size: the rows travel as a\n * single JSONEachRow body, and `format` is fixed because a table written in two\n * formats is a table nobody can read back.\n *\n * The parameters are built once and every attempt sends that same object, so\n * the deduplication token cannot be regenerated halfway through a retry, which\n * is the one thing that would turn a safe retry into duplicated rows.\n *\n * The delays come from the same function the per-attempt request timeout was\n * derived from, so what the loop waits and what the client was configured for\n * cannot drift apart and leave an attempt running past the collector's race.\n *\n * A failure that survives the retries is rethrown as it arrived, ClickHouse\n * code included: the collector dead-letters the whole batch on a throw and\n * keeps the error beside it, and the code is what makes that entry readable.\n */\nexport async function insertRows(\n entries: readonly InsertEntry[],\n settings: Settings,\n logger: Logger.Instance,\n): Promise<void> {\n const { client, table } = settings;\n\n // What counts as a usable retry count is decided in one place for both halves\n // of the mechanism, the loop here and the budget the request timeout was\n // derived from. A count that is not a finite number would otherwise never end\n // this loop, `attempt >= NaN` and `attempt >= Infinity` being false forever,\n // and nothing cancels a destination's work once the collector has stopped\n // waiting for it.\n const retries = normalizeMaxRetries(settings.maxRetries);\n\n // Our defaults first, then what the flow set, then the token: a token an\n // operator could pin would collapse every insert into one deduplicated block\n // and drop every batch after the first without an error anywhere.\n const clickhouse_settings: ClickHouseSettings = {\n ...resolveInsertSettings(settings.clickhouseSettings),\n insert_deduplication_token: deduplicationToken(\n entries.map((entry) => entry.id),\n ),\n };\n\n const params: InsertParams<Readable, InsertRow> = {\n table,\n values: entries.map((entry) => entry.row),\n format: 'JSONEachRow',\n clickhouse_settings,\n };\n\n for (let attempt = 0; ; attempt += 1) {\n logger.debug('ClickHouse insert', { table, rows: entries.length, attempt });\n\n try {\n await client.insert(params);\n\n return;\n } catch (error) {\n // Classified before the budget is read, so the operator hears about a\n // terminal failure even where there was never a retry to skip.\n const retryable = classify(error, logger) === 'retryable';\n\n if (!retryable || attempt >= retries) throw error;\n\n const delay = retryDelay(attempt, drawJitter());\n\n // A finite but absurd retry count passes the clamp above and would spend\n // it on insert attempts against a live server. The delays double, so they\n // leave the finite range on their own after about a thousand retries, and\n // reading that as the end of the loop bounds it without inventing a\n // maximum attempt count or restating the schedule. The budget the request\n // timeout comes from ends on the matching condition: it stops when its\n // running sum stops being finite, this loop when a single delay does.\n if (!Number.isFinite(delay)) throw error;\n\n logger.warn('ClickHouse insert failed, retrying', {\n code: errorCode(error),\n attempt,\n delay,\n });\n\n await wait(delay);\n }\n }\n}\n","import type {\n ClickHouseClientConfigOptions,\n ClickHouseSettings,\n} from '@clickhouse/client';\nimport type { Logger } from '@walkeros/core';\nimport { createClient } from '@clickhouse/client';\nimport type {\n ClickHouseClientFactory,\n Config,\n Credentials,\n Env,\n InitSettings,\n PartialConfig,\n Settings,\n} from './types';\n\n/** Database written to when a flow names none. */\nexport const DEFAULT_DATABASE = 'default';\n\n/** Table written to when a flow names none. */\nexport const DEFAULT_TABLE = 'events';\n\n/** Retries added on top of the first attempt when a flow names none. */\nexport const DEFAULT_MAX_RETRIES = 1;\n\n/** The collector's own per-delivery race, used when `config.timeout` is unset. */\nexport const DEFAULT_TIMEOUT_MS = 10_000;\n\n/** Delay before the first retry. Every further retry doubles it. */\nexport const RETRY_BASE_DELAY_MS = 250;\n\n/** Highest multiplier the retry jitter can apply to a single delay. */\nexport const RETRY_JITTER_MAX = 1.5;\n\n/** Floor for the derived per-attempt timeout. */\nexport const MIN_REQUEST_TIMEOUT_MS = 1_000;\n\n/**\n * `maxRetries` as the retry mechanism agrees to read it: a whole, finite count,\n * never negative. Everything else means no retries at all.\n *\n * A flow's config arrives as JSON and reaches the resolution helpers without\n * passing the schema, so `int().min(0)` does not stand between a config file and\n * this code. `JSON.parse('{\"maxRetries\": 1e999}')` yields `Infinity`, which\n * would spin the budget loop below inside `init`, on the collector's startup\n * path. `NaN` is quieter and reaches the client as a `request_timeout` of `NaN`.\n *\n * Every part of the retry mechanism normalizes through this one function, so the\n * budget the timeout was derived from and the loop that spends it cannot\n * disagree about what a valid count is.\n */\nexport function normalizeMaxRetries(maxRetries: number): number {\n return Number.isFinite(maxRetries) ? Math.max(0, Math.floor(maxRetries)) : 0;\n}\n\n/**\n * How long to wait before the retry at `retry`, counting the first retry as 0:\n * the base delay doubled once per retry, scaled by the jitter.\n *\n * The retry loop and the budget below call this same function, so what the\n * per-attempt timeout was derived from and what the loop actually waits cannot\n * drift apart. A loop passes its own drawn jitter; the budget takes the default,\n * which is the largest jitter that can be drawn.\n */\nexport function retryDelay(\n retry: number,\n jitter: number = RETRY_JITTER_MAX,\n): number {\n return RETRY_BASE_DELAY_MS * 2 ** retry * jitter;\n}\n\n/**\n * Longest total time the retry delays alone can take: every one of the\n * `maxRetries` delays at its longest jittered length. The delays sit between\n * the `1 + maxRetries` attempts, so they spend the same budget the attempts do.\n *\n * The delays double, so they pass what a float can hold after about a thousand\n * of them and the total stops changing. Returning at that point bounds the loop\n * for any count at all, without inventing a maximum number of retries.\n */\nexport function retryDelayBudget(maxRetries: number): number {\n const retries = normalizeMaxRetries(maxRetries);\n let budget = 0;\n\n for (let retry = 0; retry < retries; retry += 1) {\n budget += retryDelay(retry);\n\n if (!Number.isFinite(budget)) return budget;\n }\n\n return budget;\n}\n\n/**\n * Per-request timeout for the client, derived so that every attempt and every\n * retry delay finishes inside the collector's race.\n *\n * The client's own default is 30 s, three times the collector's 10 s. Left\n * there, the collector stops waiting at 10 s, dead-letters the batch and counts\n * it lost while the client is still sending, and the rows land at 12 s anyway:\n * a batch counted lost and actually written. Splitting what the delays leave\n * over across the attempts keeps the last attempt finishing first.\n *\n * The floor keeps a short timeout or a high retry count from deriving a useless\n * or negative value. Such a configuration cannot fit its own retries either\n * way, and the collector's race is what bounds it there. Once the floor fires,\n * the attempts and their delays add up to more than `timeout`, and the\n * collector cuts the later attempts off when it stops waiting.\n */\nexport function resolveRequestTimeout(\n timeout: number,\n maxRetries: number,\n): number {\n // Both arguments are normalized rather than trusted. A retry count of NaN\n // would otherwise carry straight through the arithmetic into a\n // `request_timeout` of NaN, and a non-finite timeout into one of Infinity:\n // a hung insert the collector can only end by dead-lettering the batch.\n const retries = normalizeMaxRetries(maxRetries);\n const attempts = retries + 1;\n\n return Math.max(\n MIN_REQUEST_TIMEOUT_MS,\n Math.floor(\n (resolveTimeout(timeout) - retryDelayBudget(retries)) / attempts,\n ),\n );\n}\n\n/**\n * The collector's per-delivery race, as this destination reads it: a positive,\n * finite number of milliseconds, or the collector's own default.\n */\nexport function resolveTimeout(timeout?: number): number {\n if (timeout === undefined || !Number.isFinite(timeout) || timeout <= 0)\n return DEFAULT_TIMEOUT_MS;\n\n return timeout;\n}\n\n/**\n * ClickHouse settings sent with every insert.\n *\n * `async_insert: 0` keeps the server's acknowledgement tied to a durable write.\n * An async insert is acknowledged before the data reaches the table, and an\n * error raised at the later flush has no way back to the caller, so a lost\n * write would neither retry nor reach the dead letter queue. The server-side\n * default is documented inconsistently, so the value travels explicitly.\n *\n * `input_format_skip_unknown_fields: 0` and `input_format_null_as_default: 0`\n * turn the server's silent repairs off. Both are on by default: the first drops\n * a JSON key the table does not know, the second rewrites an explicit null into\n * the column default. A walkerOS field missing from a partner's table would\n * disappear with no error and no warning, found weeks later in reporting. At 0\n * the whole batch fails loudly, is attributed and reaches the counters.\n *\n * A flow's own `clickhouseSettings` sit on top of all three.\n */\nconst INSERT_SETTINGS: ClickHouseSettings = {\n async_insert: 0,\n input_format_skip_unknown_fields: 0,\n input_format_null_as_default: 0,\n};\n\n/** The per-insert settings, with a flow's own `clickhouseSettings` on top. */\nexport function resolveInsertSettings(\n custom?: ClickHouseSettings,\n): ClickHouseSettings {\n return { ...INSERT_SETTINGS, ...custom };\n}\n\n/**\n * The host of an endpoint, with any userinfo left behind.\n *\n * A url is one of the places a password can legitimately sit\n * (`https://user:pass@host:port`), so the configured string is never safe to\n * put in a log line and only the host ever is. A url the client would reject\n * still reaches here when the factory comes from `env`, which parses nothing.\n */\nfunction endpointHost(url: string): string {\n try {\n return new URL(url).host;\n } catch {\n return 'unparsed';\n }\n}\n\n/** The resolved settings before init attaches the live client. */\ntype ResolvedSettings = Omit<Settings, 'client'>;\n\nfunction withDefaults(\n settings: InitSettings,\n logger: Logger.Instance,\n): ResolvedSettings {\n const { url, clickhouse } = settings;\n\n if (!url) return logger.throw('Config settings url missing');\n\n return {\n url,\n database: settings.database || DEFAULT_DATABASE,\n table: settings.table || DEFAULT_TABLE,\n maxRetries: settings.maxRetries ?? DEFAULT_MAX_RETRIES,\n ...(clickhouse && { clickhouse }),\n clickhouseSettings: resolveInsertSettings(settings.clickhouseSettings),\n };\n}\n\n/**\n * The options `createClient` receives, merged lowest layer first:\n *\n * 1. `settings.clickhouse`, the raw SDK passthrough.\n * 2. The derived defaults, `request_timeout` and request compression, each\n * applied only where the passthrough left its key unset.\n * 3. `url` and `database`, the destination's own first-class settings. They\n * always win: an insert names only the table, so a passthrough `database`\n * that disagreed would move every row to a different database in silence.\n * 4. `credentials`, the strictly typed slot backed by a managed secret. It wins\n * over a username or password left in the passthrough; with the slot empty\n * the passthrough reaches the client untouched.\n *\n * The table, the retry count and the per-insert settings are not client\n * options and stay off this object.\n */\nexport function resolveClientOptions(\n settings: ResolvedSettings,\n credentials: Credentials | undefined,\n timeout: number,\n): ClickHouseClientConfigOptions {\n const options: ClickHouseClientConfigOptions = { ...settings.clickhouse };\n\n if (options.request_timeout === undefined)\n options.request_timeout = resolveRequestTimeout(\n timeout,\n settings.maxRetries,\n );\n\n // Both directions are off by default in this client. Compressing the request\n // is the one that pays here: insert bodies are large, repetitive JSON.\n if (options.compression === undefined)\n options.compression = { request: true };\n\n options.url = settings.url;\n options.database = settings.database;\n\n if (credentials) {\n options.username = credentials.username;\n options.password = credentials.password;\n }\n\n return options;\n}\n\n/**\n * Resolves the config init hands forward, and creates the one client every\n * insert reuses. Nothing is queried: a missing table surfaces at the first\n * insert, as a loud, attributable failure.\n */\nexport function getConfig(\n config: PartialConfig,\n env: Env | undefined,\n logger: Logger.Instance,\n): Config {\n const { settings } = config;\n\n if (!settings) return logger.throw('Config settings missing');\n\n const resolved = withDefaults(settings, logger);\n const options = resolveClientOptions(\n resolved,\n config.credentials,\n resolveTimeout(config.timeout),\n );\n\n const factory: ClickHouseClientFactory =\n env?.ClickHouseClient || createClient;\n const client = factory(options);\n\n // Every credential stays out of this line deliberately, the url included:\n // init is the one place that holds them, and a debug log is the easiest way\n // to spill them.\n logger.debug('ClickHouse client created', {\n host: endpointHost(resolved.url),\n database: resolved.database,\n table: resolved.table,\n });\n\n return { ...config, settings: { ...resolved, client } };\n}\n\n/**\n * The settings the insert path works with.\n *\n * Core resolves `Config.settings` from the init slot, so a push receives the\n * settings as a user wrote them: the client optional, the defaults unapplied.\n * This narrows them once. It guards that init ran and re-applies the defaults,\n * so the insert path holds a live client and a complete configuration without\n * guarding field by field.\n */\nexport function resolveSettings(\n config: PartialConfig,\n logger: Logger.Instance,\n): Settings {\n const { settings } = config;\n\n if (!settings) return logger.throw('Config settings missing, init() not run');\n\n const { client } = settings;\n\n if (!client) return logger.throw('ClickHouse client missing, init() not run');\n\n return { ...withDefaults(settings, logger), client };\n}\n"],"mappings":"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","names":["import_dev","import_dev","import_client","settings"]}
1
+ {"version":3,"sources":["../src/dev.ts","../src/schemas/index.ts","../src/schemas/settings.ts","../src/schemas/mapping.ts","../src/examples/index.ts","../src/examples/env.ts","../src/__mocks__/@clickhouse/client.ts","../src/examples/step.ts","../src/eventToRow.ts","../src/insert.ts","../src/config.ts"],"sourcesContent":["export * as schemas from './schemas';\nexport * as examples from './examples';\n","import { zodToSchema } from '@walkeros/core/dev';\nimport { SettingsSchema } from './settings';\nimport { MappingSchema } from './mapping';\n\nexport { SettingsSchema, type Settings } from './settings';\nexport { MappingSchema, type Mapping } from './mapping';\n\n// JSON Schema\nexport const settings = zodToSchema(SettingsSchema);\nexport const mapping = zodToSchema(MappingSchema);\n","import { z } from '@walkeros/core/dev';\n\nexport const SettingsSchema = z.object({\n url: z\n .string()\n .min(1)\n .describe(\n 'ClickHouse HTTP endpoint (like https://host.clickhouse.cloud:8443)',\n ),\n database: z\n .string()\n .min(1)\n .default('default')\n .describe('Database holding the events table (like analytics)'),\n table: z\n .string()\n .min(1)\n .default('events')\n .describe('Table every event is inserted into (like events)'),\n maxRetries: z\n .number()\n .int()\n .min(0)\n .default(1)\n .describe(\n 'Retries added on top of the first attempt, so total attempts = 1 + maxRetries (like 1 for two attempts)',\n ),\n clickhouse: z\n .record(z.string(), z.unknown())\n .optional()\n .describe(\n 'Raw @clickhouse/client options merged into the created client (like { request_timeout: 60000 })',\n ),\n clickhouseSettings: z\n .record(z.string(), z.unknown())\n .optional()\n .describe(\n 'ClickHouse settings applied to every insert (like { async_insert: 1 })',\n ),\n});\n\nexport type Settings = z.infer<typeof SettingsSchema>;\n","import { z } from '@walkeros/core/dev';\n\n/**\n * ClickHouse Mapping Schema\n * ClickHouse receives raw events, so there is no event-level mapping\n * configuration.\n */\nexport const MappingSchema = z.object({});\n\n/**\n * Type inference from MappingSchema\n */\nexport type Mapping = z.infer<typeof MappingSchema>;\n","export * as env from './env';\nexport * as step from './step';\n","import type { Env } from '../types';\nimport { createClient } from '../__mocks__/@clickhouse/client';\n\n/**\n * Example environment for the ClickHouse destination.\n *\n * The client factory is the recording stand-in from the package-local\n * __mocks__ folder, so examples, tests and simulations share one source of\n * truth and never reach a ClickHouse server.\n */\nexport const push: Env = {\n ClickHouseClient: createClient,\n};\n\nexport const simulation = ['call:ClickHouseClient.insert'];\n","// Recording stand-in for `createClient` from `@clickhouse/client`.\n//\n// Jest resolves `@clickhouse/client` to this file for every test in the\n// package, so the star re-export below keeps every other SDK name (types,\n// `ClickHouseError`, the format constants) reachable instead of undefined. The\n// local `createClient` shadows the real one.\n//\n// The returned client is a plain object carrying only `insert` and `close`,\n// the two methods this destination is allowed to call. Nothing from the real\n// SDK is constructed, so no request can leave the process by any path.\n\nimport type { Readable } from 'stream';\nimport type {\n ClickHouseClientConfigOptions,\n InsertParams,\n InsertResult,\n} from '@clickhouse/client';\nimport { ClickHouseError } from '@clickhouse/client';\nimport type { ClickHouseClientSurface } from '../../types';\n\nexport * from '@clickhouse/client';\n\n/** One recorded interaction: the callable name followed by its arguments. */\nexport type MockCall = readonly [string, ...unknown[]];\n\ninterface Failure {\n remaining: number;\n /**\n * ClickHouse error codes are strings (like '159' for a server-side timeout\n * or '252' for too many parts). Omit it for a codeless plain Error.\n */\n code?: string;\n message: string;\n type?: string;\n}\n\nconst calls: MockCall[] = [];\nlet failure: Failure | undefined;\n\n/** Every interaction recorded so far, in order. */\nexport function __getCalls(): readonly MockCall[] {\n return calls;\n}\n\n/** Clears the capture array and any pending insert failure. */\nexport function __reset(): void {\n calls.length = 0;\n failure = undefined;\n}\n\n/**\n * Rejects the next `times` inserts, then lets inserts succeed again.\n *\n * With a `code` the rejection is a real `ClickHouseError` carrying that string\n * code, so retry logic is exercised against the class the server actually\n * produces. Without a `code` it is a plain `Error` with no code at all.\n */\nexport function __failInsert(\n times: number,\n options: { code?: string; message?: string; type?: string } = {},\n): void {\n failure = {\n remaining: times,\n code: options.code,\n message: options.message ?? 'clickhouse insert failed',\n type: options.type,\n };\n}\n\nfunction nextFailure(): Error | undefined {\n if (!failure || failure.remaining < 1) return undefined;\n\n failure.remaining -= 1;\n const { code, message, type } = failure;\n\n return code === undefined\n ? new Error(message)\n : new ClickHouseError({ message, code, type });\n}\n\nexport const createClient = (\n config: ClickHouseClientConfigOptions,\n): ClickHouseClientSurface => {\n calls.push(['createClient', config]);\n\n return {\n async insert<T>(params: InsertParams<Readable, T>): Promise<InsertResult> {\n calls.push(['insert', params]);\n\n const error = nextFailure();\n if (error) throw error;\n\n return {\n executed: true,\n query_id: `mock-insert-${calls.length}`,\n response_headers: {},\n };\n },\n\n async close(): Promise<void> {\n calls.push(['close']);\n },\n };\n};\n","import type { Flow, WalkerOS } from '@walkeros/core';\nimport { getEvent } from '@walkeros/core';\nimport { eventToRow } from '../eventToRow';\nimport { deduplicationToken } from '../insert';\n\n/**\n * The endpoint, database and table the examples below write to. The table is\n * stated once because two places need it, the config a user writes and the\n * insert that config produces, and an example where those two disagree would\n * assert nothing.\n */\nconst settings = {\n url: 'https://clickhouse.example.com:8443',\n database: 'analytics',\n table: 'events',\n};\n\n/**\n * Destination bootstrap.\n *\n * `in` is the config a user copy-pastes into a flow: the endpoint, the\n * database and table to write to, and the credentials slot backed by a managed\n * secret. Init creates exactly one ClickHouse client from it and holds that\n * client on the resolved config, so every insert reuses the same connection\n * pool.\n *\n * Two options the user did not write reach the client. `request_timeout` is\n * derived from the collector's per-delivery timeout (10000 ms by default) minus\n * what the retry delays can take, split across the attempts: at one retry that\n * is 4812 ms, so the second attempt finishes before the collector stops waiting\n * and dead-letters a batch the client would still land. Request compression is\n * off in this client by default and insert bodies are large, repetitive JSON,\n * so it is turned on. Both yield to `settings.clickhouse`.\n */\nexport const init: Flow.StepExample = {\n title: 'Initialization',\n description:\n 'Init creates a single ClickHouse client for the configured endpoint, database and credentials. The client is held on the resolved config and reused by every insert. Its request timeout is derived from the collector timeout so a retried insert cannot outlive the delivery it belongs to, and request compression is enabled.',\n in: {\n settings,\n credentials: {\n username: 'walkeros_writer',\n password: '$secret.CLICKHOUSE_PASSWORD',\n },\n },\n out: [\n [\n 'createClient',\n {\n url: 'https://clickhouse.example.com:8443',\n database: 'analytics',\n username: 'walkeros_writer',\n password: '$secret.CLICKHOUSE_PASSWORD',\n request_timeout: 4812,\n compression: { request: true },\n },\n ],\n ],\n};\n\n/**\n * What happens to the raw `settings.clickhouse` passthrough.\n *\n * The merge runs in four layers, lowest first: the passthrough, then the\n * derived defaults where the passthrough left their key unset, then `url` and\n * `database`, then `credentials`. An insert names only the table, so a\n * passthrough `database` that disagreed with the configured one would move\n * every row to a different database in silence; the configured one wins. The\n * credentials slot wins over a username or password left in the passthrough,\n * and an option the destination does not name travels through untouched.\n */\nexport const initPassthrough: Flow.StepExample = {\n title: 'Client option passthrough',\n description:\n \"The raw settings.clickhouse options reach the client underneath the destination's own settings: url, database and the credentials slot win over them, an explicit request_timeout or compression wins over the derived default, and any other option travels through untouched.\",\n public: false,\n in: {\n settings: {\n url: 'https://clickhouse.example.com:8443',\n database: 'analytics',\n clickhouse: {\n url: 'https://ignored.example.com:8443',\n database: 'ignored',\n username: 'ignored_user',\n request_timeout: 60000,\n max_open_connections: 25,\n application: 'walkeros',\n },\n },\n credentials: {\n username: 'walkeros_writer',\n password: '$secret.CLICKHOUSE_PASSWORD',\n },\n },\n out: [\n [\n 'createClient',\n {\n url: 'https://clickhouse.example.com:8443',\n database: 'analytics',\n username: 'walkeros_writer',\n password: '$secret.CLICKHOUSE_PASSWORD',\n request_timeout: 60000,\n max_open_connections: 25,\n application: 'walkeros',\n compression: { request: true },\n },\n ],\n ],\n};\n\n// Event step examples belong below this line. The step-example test iterates\n// every entry exported from this module and slices the captured calls by\n// `init.out.length`, so an appended entry needs no test change.\n\n/**\n * The insert one event produces. Rows come from `eventToRow`, so an example can\n * never claim a column shape the destination does not emit.\n *\n * The per-insert settings are spelled out here rather than read from the code\n * on purpose. They are the three settings that make a schema mismatch loud\n * instead of silent and keep the acknowledgement tied to a durable write,\n * nothing in the type system forces the insert call to send them, and an\n * insert that quietly stopped sending them would keep every other test green.\n * The deduplication token sits inside that same object: it is what lets a\n * retry of a batch the server may already have written land without counting\n * those rows twice, and it is computed from the event ids, so this example\n * reads it from the destination rather than restating a digest.\n */\nfunction expectedInsert(event: WalkerOS.Event): Flow.StepOut {\n return [\n [\n 'insert',\n {\n table: settings.table,\n values: [eventToRow(event)],\n format: 'JSONEachRow',\n clickhouse_settings: {\n async_insert: 0,\n input_format_skip_unknown_fields: 0,\n input_format_null_as_default: 0,\n insert_deduplication_token: deduplicationToken([event.id]),\n },\n },\n ],\n ];\n}\n\nconst pageViewEvent = getEvent('page view', { timestamp: 1700000000123 });\n\n/**\n * One event, one row, one insert. Every payload travels as a JSON-encoded\n * string and the timestamp as a `YYYY-MM-DD hh:mm:ss.SSS` UTC literal, the\n * format every ClickHouse date parser reads identically.\n */\nexport const pageView: Flow.StepExample = {\n title: 'Page view',\n description:\n 'A page view is inserted as a single JSONEachRow row. The payload columns (data, context, globals, custom, user, nested, consent, source) are JSON-encoded strings and the timestamp is a UTC datetime literal, both produced by eventToRow.',\n in: pageViewEvent,\n mapping: undefined,\n out: expectedInsert(pageViewEvent),\n};\n\nconst purchaseEvent = getEvent('order complete', {\n timestamp: 1700000000456,\n data: {\n id: 'ORD-500',\n currency: 'EUR',\n total: 199.99,\n items: [\n { sku: 'SKU-1', quantity: 2 },\n { sku: 'SKU-2', quantity: 1 },\n ],\n },\n});\n\n/**\n * An order carries an array of items. The whole `data` object, array\n * included, is JSON-encoded into the single `data` column, so a flow can send\n * any shape without the table knowing about it.\n */\nexport const purchase: Flow.StepExample = {\n title: 'Purchase',\n description:\n 'An order event is inserted as one row. The nested items array is JSON-encoded into the data column, so a richer payload needs no table change. Promoting a hot key to its own column is a MATERIALIZED expression in the table DDL, not a destination setting.',\n in: purchaseEvent,\n mapping: undefined,\n out: expectedInsert(purchaseEvent),\n};\n","import type { WalkerOS } from '@walkeros/core';\n\n/**\n * One insert row, in the canonical walkerOS Event v4 column order: name, data,\n * context, globals, custom, user, nested, consent, id, trigger, entity,\n * action, timestamp, timing, source.\n *\n * Payload columns are JSON-encoded strings rather than nested structures, so\n * the table needs no schema knowledge of what a flow sends. Hot keys become\n * real columns in the table's own DDL through `MATERIALIZED` expressions over\n * these strings, which costs the destination nothing.\n *\n * No column is nullable. Inserts run with `input_format_null_as_default = 0`,\n * which turns a stray `null` into a loud server-side error instead of a silent\n * default, so an absent payload has to arrive as an empty string. A non-finite\n * `timing` is the one value the type still admits that reaches the wire as a\n * null, `JSON.stringify` writing `NaN` and `Infinity` that way: the server\n * rejects the batch, the rejection is terminal and is not retried, and the\n * wrong number stays visible in the event rather than becoming a plausible `0`\n * in the table.\n */\nexport interface ClickHouseRow {\n [column: string]: string | number;\n name: string;\n data: string;\n context: string;\n globals: string;\n custom: string;\n user: string;\n nested: string;\n consent: string;\n id: string;\n trigger: string;\n entity: string;\n action: string;\n timestamp: string;\n timing: number;\n source: string;\n}\n\n/**\n * A row as the insert path receives it: the canonical shape above, or the\n * object a flow's mapping placed in `context.data`.\n *\n * A mapped row replaces the canonical one wholesale and travels verbatim, so a\n * partner whose table is not the reference DDL configures its columns in the\n * flow. Its shape is whatever the flow wrote, and this type claims no more\n * than that.\n */\nexport type InsertRow = ClickHouseRow | WalkerOS.AnyObject;\n\nfunction pad(value: number, length: number): string {\n return String(value).padStart(length, '0');\n}\n\n/**\n * `YYYY-MM-DD hh:mm:ss.SSS` in UTC, the one literal every ClickHouse server\n * reads the same way. The `basic` date parser rejects the ISO 8601 `T` and `Z`\n * markers, and a bare number is read as Unix seconds by newer servers but as\n * the raw value at the column's precision by older ones, so epoch\n * milliseconds would land off by a factor of 1000 depending on the version.\n * The reference DDL declares the column `DateTime64(3, 'UTC')`, and a string\n * is read in the column's own timezone.\n */\nfunction toDateTime(timestamp: number): string {\n const date = new Date(timestamp);\n\n const day = [\n pad(date.getUTCFullYear(), 4),\n pad(date.getUTCMonth() + 1, 2),\n pad(date.getUTCDate(), 2),\n ].join('-');\n\n const time = [\n pad(date.getUTCHours(), 2),\n pad(date.getUTCMinutes(), 2),\n pad(date.getUTCSeconds(), 2),\n ].join(':');\n\n return `${day} ${time}.${pad(date.getUTCMilliseconds(), 3)}`;\n}\n\n/**\n * JSON for anything carrying content, `''` for anything not. Absent values,\n * empty objects and empty arrays collapse to the same empty string so no\n * column ever holds a null.\n */\nfunction jsonOrEmpty(value: unknown): string {\n if (value === undefined || value === null) return '';\n\n if (typeof value === 'object') {\n if (Array.isArray(value)) {\n if (value.length === 0) return '';\n } else if (Object.keys(value).length === 0) return '';\n }\n\n return JSON.stringify(value);\n}\n\n/** Convert one walkerOS event into one ClickHouse row. */\nexport function eventToRow(event: WalkerOS.Event): ClickHouseRow {\n return {\n name: event.name,\n data: jsonOrEmpty(event.data),\n context: jsonOrEmpty(event.context),\n globals: jsonOrEmpty(event.globals),\n custom: jsonOrEmpty(event.custom),\n user: jsonOrEmpty(event.user),\n nested: jsonOrEmpty(event.nested),\n consent: jsonOrEmpty(event.consent),\n id: event.id,\n trigger: event.trigger,\n entity: event.entity,\n action: event.action,\n timestamp: toDateTime(event.timestamp),\n timing: event.timing,\n source: jsonOrEmpty(event.source),\n };\n}\n","import type { Readable } from 'stream';\nimport type { ClickHouseSettings, InsertParams } from '@clickhouse/client';\nimport type { Logger } from '@walkeros/core';\nimport { createHash } from 'node:crypto';\nimport { classify, errorCode } from './classify';\nimport {\n RETRY_JITTER_MAX,\n normalizeMaxRetries,\n resolveInsertSettings,\n retryDelay,\n} from './config';\nimport type { InsertRow } from './eventToRow';\nimport type { Settings } from './types';\n\n/**\n * One row on its way to the table, beside the id of the event it came from.\n *\n * The id travels beside the row rather than inside it because a row is not\n * guaranteed to carry one. A mapped row is whatever a flow wrote, and `id` is\n * a column a partner's table need not have. Reading the token out of the row\n * would give every id-less mapped row the same token, which\n * `deduplicationToken` explains is silent, total data loss.\n */\nexport interface InsertEntry {\n /** The row as it travels: a mapped object verbatim, or the conversion. */\n row: InsertRow;\n /** The walkerOS event id. The only input the token is derived from. */\n id: string;\n}\n\n/**\n * The deduplication token sent with a batch: a digest over its event ids, in\n * the order they are inserted.\n *\n * Retrying an insert without one double-counts every row of a batch the server\n * had already written when the answer stopped coming. Synchronous inserts\n * deduplicate on the block contents by themselves, but only where the window\n * is on: `non_replicated_deduplication_window` defaults to 0, so a plain\n * MergeTree does nothing unless the table says otherwise. An explicit token\n * identifies the block for both MergeTree families, but it prevents duplicates\n * only where insert deduplication is enabled: by default on a\n * ReplicatedMergeTree, and on a plain MergeTree only with a positive window.\n *\n * A digest rather than a random uuid, so a redelivery of the same events\n * produces the same token and deduplicates too, as long as it lands inside the\n * table's deduplication window, which counts blocks and not time. The ids\n * travel as a JSON array so that no id can imitate the separator between two\n * of them, and the order is part of the digest because the same rows in\n * another order are another block to ClickHouse.\n *\n * Event ids and nothing else. The server deduplicates on this token INSTEAD of\n * the block contents, and skips a repeated block while reporting success, so\n * two deliveries that share a token are one delivery with no error, no dead\n * letter entry and no counter anywhere: a token that could repeat across\n * different events is silent data loss. The ids are the one input that is\n * always present and unique per event, which the row is not, and they identify\n * the events rather than describing them, so two different events that a\n * mapping happened to shape identically stay two blocks.\n */\nexport function deduplicationToken(ids: readonly string[]): string {\n return createHash('sha256').update(JSON.stringify(ids)).digest('hex');\n}\n\n/**\n * How much of the base delay this wait takes: all of it, plus up to half again.\n * Two runtimes that failed on the same server-side condition would otherwise\n * retry in lockstep and meet it together a second time.\n */\nfunction drawJitter(): number {\n return 1 + Math.random() * (RETRY_JITTER_MAX - 1);\n}\n\nfunction wait(ms: number): Promise<void> {\n return new Promise((resolve) => {\n setTimeout(resolve, ms);\n });\n}\n\n/**\n * Inserts one batch, retrying a transient failure up to `maxRetries` times.\n *\n * One flushed batch is one insert, whatever its size: the rows travel as a\n * single JSONEachRow body, and `format` is fixed because a table written in two\n * formats is a table nobody can read back.\n *\n * The parameters are built once and every attempt sends that same object, so\n * the deduplication token cannot be regenerated halfway through a retry, which\n * is the one thing that would turn a safe retry into duplicated rows.\n *\n * The delays come from the same function the per-attempt request timeout was\n * derived from, so what the loop waits and what the client was configured for\n * cannot drift apart and leave an attempt running past the collector's race.\n *\n * A failure that survives the retries is rethrown as it arrived, ClickHouse\n * code included: the collector dead-letters the whole batch on a throw and\n * keeps the error beside it, and the code is what makes that entry readable.\n */\nexport async function insertRows(\n entries: readonly InsertEntry[],\n settings: Settings,\n logger: Logger.Instance,\n): Promise<void> {\n const { client, table } = settings;\n\n // What counts as a usable retry count is decided in one place for both halves\n // of the mechanism, the loop here and the budget the request timeout was\n // derived from. A count that is not a finite number would otherwise never end\n // this loop, `attempt >= NaN` and `attempt >= Infinity` being false forever,\n // and nothing cancels a destination's work once the collector has stopped\n // waiting for it.\n const retries = normalizeMaxRetries(settings.maxRetries);\n\n // Our defaults first, then what the flow set, then the token: a token an\n // operator could pin would collapse every insert into one deduplicated block\n // and drop every batch after the first without an error anywhere.\n const clickhouse_settings: ClickHouseSettings = {\n ...resolveInsertSettings(settings.clickhouseSettings),\n insert_deduplication_token: deduplicationToken(\n entries.map((entry) => entry.id),\n ),\n };\n\n const params: InsertParams<Readable, InsertRow> = {\n table,\n values: entries.map((entry) => entry.row),\n format: 'JSONEachRow',\n clickhouse_settings,\n };\n\n for (let attempt = 0; ; attempt += 1) {\n logger.debug('ClickHouse insert', { table, rows: entries.length, attempt });\n\n try {\n await client.insert(params);\n\n return;\n } catch (error) {\n // Classified before the budget is read, so the operator hears about a\n // terminal failure even where there was never a retry to skip.\n const retryable = classify(error, logger) === 'retryable';\n\n if (!retryable || attempt >= retries) throw error;\n\n const delay = retryDelay(attempt, drawJitter());\n\n // A finite but absurd retry count passes the clamp above and would spend\n // it on insert attempts against a live server. The delays double, so they\n // leave the finite range on their own after about a thousand retries, and\n // reading that as the end of the loop bounds it without inventing a\n // maximum attempt count or restating the schedule. The budget the request\n // timeout comes from ends on the matching condition: it stops when its\n // running sum stops being finite, this loop when a single delay does.\n if (!Number.isFinite(delay)) throw error;\n\n logger.warn('ClickHouse insert failed, retrying', {\n code: errorCode(error),\n attempt,\n delay,\n });\n\n await wait(delay);\n }\n }\n}\n","import type {\n ClickHouseClientConfigOptions,\n ClickHouseSettings,\n} from '@clickhouse/client';\nimport type { Logger } from '@walkeros/core';\nimport { createClient } from '@clickhouse/client';\nimport type {\n ClickHouseClientFactory,\n Config,\n Credentials,\n Env,\n InitSettings,\n PartialConfig,\n Settings,\n} from './types';\n\n/** Database written to when a flow names none. */\nexport const DEFAULT_DATABASE = 'default';\n\n/** Table written to when a flow names none. */\nexport const DEFAULT_TABLE = 'events';\n\n/** Retries added on top of the first attempt when a flow names none. */\nexport const DEFAULT_MAX_RETRIES = 1;\n\n/** The collector's own per-delivery race, used when `config.timeout` is unset. */\nexport const DEFAULT_TIMEOUT_MS = 10_000;\n\n/** Delay before the first retry. Every further retry doubles it. */\nexport const RETRY_BASE_DELAY_MS = 250;\n\n/** Highest multiplier the retry jitter can apply to a single delay. */\nexport const RETRY_JITTER_MAX = 1.5;\n\n/** Floor for the derived per-attempt timeout. */\nexport const MIN_REQUEST_TIMEOUT_MS = 1_000;\n\n/**\n * `maxRetries` as the retry mechanism agrees to read it: a whole, finite count,\n * never negative. Everything else means no retries at all.\n *\n * A flow's config arrives as JSON and reaches the resolution helpers without\n * passing the schema, so `int().min(0)` does not stand between a config file and\n * this code. `JSON.parse('{\"maxRetries\": 1e999}')` yields `Infinity`, which\n * would spin the budget loop below inside `init`, on the collector's startup\n * path. `NaN` is quieter and reaches the client as a `request_timeout` of `NaN`.\n *\n * Every part of the retry mechanism normalizes through this one function, so the\n * budget the timeout was derived from and the loop that spends it cannot\n * disagree about what a valid count is.\n */\nexport function normalizeMaxRetries(maxRetries: number): number {\n return Number.isFinite(maxRetries) ? Math.max(0, Math.floor(maxRetries)) : 0;\n}\n\n/**\n * How long to wait before the retry at `retry`, counting the first retry as 0:\n * the base delay doubled once per retry, scaled by the jitter.\n *\n * The retry loop and the budget below call this same function, so what the\n * per-attempt timeout was derived from and what the loop actually waits cannot\n * drift apart. A loop passes its own drawn jitter; the budget takes the default,\n * which is the largest jitter that can be drawn.\n */\nexport function retryDelay(\n retry: number,\n jitter: number = RETRY_JITTER_MAX,\n): number {\n return RETRY_BASE_DELAY_MS * 2 ** retry * jitter;\n}\n\n/**\n * Longest total time the retry delays alone can take: every one of the\n * `maxRetries` delays at its longest jittered length. The delays sit between\n * the `1 + maxRetries` attempts, so they spend the same budget the attempts do.\n *\n * The delays double, so they pass what a float can hold after about a thousand\n * of them and the total stops changing. Returning at that point bounds the loop\n * for any count at all, without inventing a maximum number of retries.\n */\nexport function retryDelayBudget(maxRetries: number): number {\n const retries = normalizeMaxRetries(maxRetries);\n let budget = 0;\n\n for (let retry = 0; retry < retries; retry += 1) {\n budget += retryDelay(retry);\n\n if (!Number.isFinite(budget)) return budget;\n }\n\n return budget;\n}\n\n/**\n * Per-request timeout for the client, derived so that every attempt and every\n * retry delay finishes inside the collector's race.\n *\n * The client's own default is 30 s, three times the collector's 10 s. Left\n * there, the collector stops waiting at 10 s, dead-letters the batch and counts\n * it lost while the client is still sending, and the rows land at 12 s anyway:\n * a batch counted lost and actually written. Splitting what the delays leave\n * over across the attempts keeps the last attempt finishing first.\n *\n * The floor keeps a short timeout or a high retry count from deriving a useless\n * or negative value. Such a configuration cannot fit its own retries either\n * way, and the collector's race is what bounds it there. Once the floor fires,\n * the attempts and their delays add up to more than `timeout`, and the\n * collector cuts the later attempts off when it stops waiting.\n */\nexport function resolveRequestTimeout(\n timeout: number,\n maxRetries: number,\n): number {\n // Both arguments are normalized rather than trusted. A retry count of NaN\n // would otherwise carry straight through the arithmetic into a\n // `request_timeout` of NaN, and a non-finite timeout into one of Infinity:\n // a hung insert the collector can only end by dead-lettering the batch.\n const retries = normalizeMaxRetries(maxRetries);\n const attempts = retries + 1;\n\n return Math.max(\n MIN_REQUEST_TIMEOUT_MS,\n Math.floor(\n (resolveTimeout(timeout) - retryDelayBudget(retries)) / attempts,\n ),\n );\n}\n\n/**\n * The collector's per-delivery race, as this destination reads it: a positive,\n * finite number of milliseconds, or the collector's own default.\n */\nexport function resolveTimeout(timeout?: number): number {\n if (timeout === undefined || !Number.isFinite(timeout) || timeout <= 0)\n return DEFAULT_TIMEOUT_MS;\n\n return timeout;\n}\n\n/**\n * ClickHouse settings sent with every insert.\n *\n * `async_insert: 0` keeps the server's acknowledgement tied to a durable write.\n * An async insert is acknowledged before the data reaches the table, and an\n * error raised at the later flush has no way back to the caller, so a lost\n * write would neither retry nor reach the dead letter queue. The server-side\n * default is documented inconsistently, so the value travels explicitly.\n *\n * `input_format_skip_unknown_fields: 0` and `input_format_null_as_default: 0`\n * turn the server's silent repairs off. Both are on by default: the first drops\n * a JSON key the table does not know, the second rewrites an explicit null into\n * the column default. A walkerOS field missing from a partner's table would\n * disappear with no error and no warning, found weeks later in reporting. At 0\n * the whole batch fails loudly, is attributed and reaches the counters.\n *\n * A flow's own `clickhouseSettings` sit on top of all three.\n */\nconst INSERT_SETTINGS: ClickHouseSettings = {\n async_insert: 0,\n input_format_skip_unknown_fields: 0,\n input_format_null_as_default: 0,\n};\n\n/** The per-insert settings, with a flow's own `clickhouseSettings` on top. */\nexport function resolveInsertSettings(\n custom?: ClickHouseSettings,\n): ClickHouseSettings {\n return { ...INSERT_SETTINGS, ...custom };\n}\n\n/**\n * The host of an endpoint, with any userinfo left behind.\n *\n * A url is one of the places a password can legitimately sit\n * (`https://user:pass@host:port`), so the configured string is never safe to\n * put in a log line and only the host ever is. A url the client would reject\n * still reaches here when the factory comes from `env`, which parses nothing.\n */\nfunction endpointHost(url: string): string {\n try {\n return new URL(url).host;\n } catch {\n return 'unparsed';\n }\n}\n\n/** The resolved settings before init attaches the live client. */\ntype ResolvedSettings = Omit<Settings, 'client'>;\n\nfunction withDefaults(\n settings: InitSettings,\n logger: Logger.Instance,\n): ResolvedSettings {\n const { url, clickhouse } = settings;\n\n if (!url) return logger.throw('Config settings url missing');\n\n return {\n url,\n database: settings.database || DEFAULT_DATABASE,\n table: settings.table || DEFAULT_TABLE,\n maxRetries: settings.maxRetries ?? DEFAULT_MAX_RETRIES,\n ...(clickhouse && { clickhouse }),\n clickhouseSettings: resolveInsertSettings(settings.clickhouseSettings),\n };\n}\n\n/**\n * The options `createClient` receives, merged lowest layer first:\n *\n * 1. `settings.clickhouse`, the raw SDK passthrough.\n * 2. The derived defaults, `request_timeout` and request compression, each\n * applied only where the passthrough left its key unset.\n * 3. `url` and `database`, the destination's own first-class settings. They\n * always win: an insert names only the table, so a passthrough `database`\n * that disagreed would move every row to a different database in silence.\n * 4. `credentials`, the strictly typed slot backed by a managed secret. It wins\n * over a username or password left in the passthrough; with the slot empty\n * the passthrough reaches the client untouched.\n *\n * The table, the retry count and the per-insert settings are not client\n * options and stay off this object.\n */\nexport function resolveClientOptions(\n settings: ResolvedSettings,\n credentials: Credentials | undefined,\n timeout: number,\n): ClickHouseClientConfigOptions {\n const options: ClickHouseClientConfigOptions = { ...settings.clickhouse };\n\n if (options.request_timeout === undefined)\n options.request_timeout = resolveRequestTimeout(\n timeout,\n settings.maxRetries,\n );\n\n // Both directions are off by default in this client. Compressing the request\n // is the one that pays here: insert bodies are large, repetitive JSON.\n if (options.compression === undefined)\n options.compression = { request: true };\n\n options.url = settings.url;\n options.database = settings.database;\n\n if (credentials) {\n options.username = credentials.username;\n options.password = credentials.password;\n }\n\n return options;\n}\n\n/**\n * Resolves the config init hands forward, and creates the one client every\n * insert reuses. Nothing is queried: a missing table surfaces at the first\n * insert, as a loud, attributable failure.\n */\nexport function getConfig(\n config: PartialConfig,\n env: Env | undefined,\n logger: Logger.Instance,\n): Config {\n const { settings } = config;\n\n if (!settings) return logger.throw('Config settings missing');\n\n const resolved = withDefaults(settings, logger);\n const options = resolveClientOptions(\n resolved,\n config.credentials,\n resolveTimeout(config.timeout),\n );\n\n const factory: ClickHouseClientFactory =\n env?.ClickHouseClient || createClient;\n const client = factory(options);\n\n // Every credential stays out of this line deliberately, the url included:\n // init is the one place that holds them, and a debug log is the easiest way\n // to spill them.\n logger.debug('ClickHouse client created', {\n host: endpointHost(resolved.url),\n database: resolved.database,\n table: resolved.table,\n });\n\n return { ...config, settings: { ...resolved, client } };\n}\n\n/**\n * The settings the insert path works with.\n *\n * Core resolves `Config.settings` from the init slot, so a push receives the\n * settings as a user wrote them: the client optional, the defaults unapplied.\n * This narrows them once. It guards that init ran and re-applies the defaults,\n * so the insert path holds a live client and a complete configuration without\n * guarding field by field.\n */\nexport function resolveSettings(\n config: PartialConfig,\n logger: Logger.Instance,\n): Settings {\n const { settings } = config;\n\n if (!settings) return logger.throw('Config settings missing, init() not run');\n\n const { client } = settings;\n\n if (!client) return logger.throw('ClickHouse client missing, init() not run');\n\n return { ...withDefaults(settings, logger), client };\n}\n"],"mappings":"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","names":["import_dev","import_dev","import_client","settings"]}
package/dist/dev.mjs CHANGED
@@ -1 +1 @@
1
- var e=Object.defineProperty,t=Object.getOwnPropertyDescriptor,i=Object.getOwnPropertyNames,n=Object.prototype.hasOwnProperty,s=(t,i)=>{for(var n in i)e(t,n,{get:i[n],enumerable:!0})},r=(s,r,o,a)=>{if(r&&"object"==typeof r||"function"==typeof r)for(let c of i(r))n.call(s,c)||c===o||e(s,c,{get:()=>r[c],enumerable:!(a=t(r,c))||a.enumerable});return s},o={};s(o,{MappingSchema:()=>d,SettingsSchema:()=>l,mapping:()=>p,settings:()=>m});import{zodToSchema as a}from"@walkeros/core/dev";import{z as c}from"@walkeros/core/dev";var l=c.object({url:c.string().min(1).describe("ClickHouse HTTP endpoint (like https://host.clickhouse.cloud:8443)"),database:c.string().min(1).default("default").describe("Database holding the events table (like analytics)"),table:c.string().min(1).default("events").describe("Table every event is inserted into (like events)"),maxRetries:c.number().int().min(0).default(1).describe("Retries added on top of the first attempt, so total attempts = 1 + maxRetries (like 1 for two attempts)"),clickhouse:c.record(c.string(),c.unknown()).optional().describe("Raw @clickhouse/client options merged into the created client (like { request_timeout: 60000 })"),clickhouseSettings:c.record(c.string(),c.unknown()).optional().describe("ClickHouse settings applied to every insert (like { async_insert: 1 })")});import{z as u}from"@walkeros/core/dev";var d=u.object({}),m=a(l),p=a(d),h={};s(h,{env:()=>g,step:()=>R});var g={};s(g,{push:()=>T,simulation:()=>U});var f,k,b={};s(b,{__failInsert:()=>O,__getCalls:()=>C,__reset:()=>S,createClient:()=>x}),r(b,f=y,"default"),k&&r(k,f,"default");import{ClickHouseError as w}from"@clickhouse/client";import*as y from"@clickhouse/client";var v,_=[];function C(){return _}function S(){_.length=0,v=void 0}function O(e,t={}){v={remaining:e,code:t.code,message:t.message??"clickhouse insert failed",type:t.type}}var x=e=>(_.push(["createClient",e]),{async insert(e){_.push(["insert",e]);const t=function(){if(!v||v.remaining<1)return;v.remaining-=1;const{code:e,message:t,type:i}=v;return void 0===e?new Error(t):new w({message:t,code:e,type:i})}();if(t)throw t;return{executed:!0,query_id:`mock-insert-${_.length}`,response_headers:{}}},async close(){_.push(["close"])}}),T={ClickHouseClient:x},U=["ClickHouseClient"],R={};s(R,{init:()=>$,initPassthrough:()=>N,pageView:()=>L,purchase:()=>W});import{getEvent as P}from"@walkeros/core";function q(e,t){return String(e).padStart(t,"0")}function D(e){const t=new Date(e);return`${[q(t.getUTCFullYear(),4),q(t.getUTCMonth()+1,2),q(t.getUTCDate(),2)].join("-")} ${[q(t.getUTCHours(),2),q(t.getUTCMinutes(),2),q(t.getUTCSeconds(),2)].join(":")}.${q(t.getUTCMilliseconds(),3)}`}function j(e){if(null==e)return"";if("object"==typeof e)if(Array.isArray(e)){if(0===e.length)return""}else if(0===Object.keys(e).length)return"";return JSON.stringify(e)}function H(e){return{name:e.name,data:j(e.data),context:j(e.context),globals:j(e.globals),custom:j(e.custom),user:j(e.user),nested:j(e.nested),consent:j(e.consent),id:e.id,trigger:e.trigger,entity:e.entity,action:e.action,timestamp:D(e.timestamp),timing:e.timing,source:j(e.source)}}import{createHash as A}from"crypto";import{createClient as E}from"@clickhouse/client";var I={url:"https://clickhouse.example.com:8443",database:"analytics",table:"events"},$={title:"Initialization",description:"Init creates a single ClickHouse client for the configured endpoint, database and credentials. The client is held on the resolved config and reused by every insert. Its request timeout is derived from the collector timeout so a retried insert cannot outlive the delivery it belongs to, and request compression is enabled.",in:{settings:I,credentials:{username:"walkeros_writer",password:"$secret.CLICKHOUSE_PASSWORD"}},out:[["createClient",{url:"https://clickhouse.example.com:8443",database:"analytics",username:"walkeros_writer",password:"$secret.CLICKHOUSE_PASSWORD",request_timeout:4812,compression:{request:!0}}]]},N={title:"Client option passthrough",description:"The raw settings.clickhouse options reach the client underneath the destination's own settings: url, database and the credentials slot win over them, an explicit request_timeout or compression wins over the derived default, and any other option travels through untouched.",public:!1,in:{settings:{url:"https://clickhouse.example.com:8443",database:"analytics",clickhouse:{url:"https://ignored.example.com:8443",database:"ignored",username:"ignored_user",request_timeout:6e4,max_open_connections:25,application:"walkeros"}},credentials:{username:"walkeros_writer",password:"$secret.CLICKHOUSE_PASSWORD"}},out:[["createClient",{url:"https://clickhouse.example.com:8443",database:"analytics",username:"walkeros_writer",password:"$secret.CLICKHOUSE_PASSWORD",request_timeout:6e4,max_open_connections:25,application:"walkeros",compression:{request:!0}}]]};function J(e){return[["insert",{table:I.table,values:[H(e)],format:"JSONEachRow",clickhouse_settings:{async_insert:0,input_format_skip_unknown_fields:0,input_format_null_as_default:0,insert_deduplication_token:(t=[e.id],A("sha256").update(JSON.stringify(t)).digest("hex"))}}]];var t}var K=P("page view",{timestamp:1700000000123}),L={title:"Page view",description:"A page view is inserted as a single JSONEachRow row. The payload columns (data, context, globals, custom, user, nested, consent, source) are JSON-encoded strings and the timestamp is a UTC datetime literal, both produced by eventToRow.",in:K,mapping:void 0,out:J(K)},M=P("order complete",{timestamp:1700000000456,data:{id:"ORD-500",currency:"EUR",total:199.99,items:[{sku:"SKU-1",quantity:2},{sku:"SKU-2",quantity:1}]}}),W={title:"Purchase",description:"An order event is inserted as one row. The nested items array is JSON-encoded into the data column, so a richer payload needs no table change. Promoting a hot key to its own column is a MATERIALIZED expression in the table DDL, not a destination setting.",in:M,mapping:void 0,out:J(M)};export{h as examples,o as schemas};//# sourceMappingURL=dev.mjs.map
1
+ var e=Object.defineProperty,t=Object.getOwnPropertyDescriptor,i=Object.getOwnPropertyNames,n=Object.prototype.hasOwnProperty,s=(t,i)=>{for(var n in i)e(t,n,{get:i[n],enumerable:!0})},r=(s,r,o,a)=>{if(r&&"object"==typeof r||"function"==typeof r)for(let c of i(r))n.call(s,c)||c===o||e(s,c,{get:()=>r[c],enumerable:!(a=t(r,c))||a.enumerable});return s},o={};s(o,{MappingSchema:()=>d,SettingsSchema:()=>l,mapping:()=>p,settings:()=>m});import{zodToSchema as a}from"@walkeros/core/dev";import{z as c}from"@walkeros/core/dev";var l=c.object({url:c.string().min(1).describe("ClickHouse HTTP endpoint (like https://host.clickhouse.cloud:8443)"),database:c.string().min(1).default("default").describe("Database holding the events table (like analytics)"),table:c.string().min(1).default("events").describe("Table every event is inserted into (like events)"),maxRetries:c.number().int().min(0).default(1).describe("Retries added on top of the first attempt, so total attempts = 1 + maxRetries (like 1 for two attempts)"),clickhouse:c.record(c.string(),c.unknown()).optional().describe("Raw @clickhouse/client options merged into the created client (like { request_timeout: 60000 })"),clickhouseSettings:c.record(c.string(),c.unknown()).optional().describe("ClickHouse settings applied to every insert (like { async_insert: 1 })")});import{z as u}from"@walkeros/core/dev";var d=u.object({}),m=a(l),p=a(d),h={};s(h,{env:()=>g,step:()=>R});var g={};s(g,{push:()=>T,simulation:()=>U});var f,k,b={};s(b,{__failInsert:()=>O,__getCalls:()=>C,__reset:()=>S,createClient:()=>x}),r(b,f=y,"default"),k&&r(k,f,"default");import{ClickHouseError as w}from"@clickhouse/client";import*as y from"@clickhouse/client";var v,_=[];function C(){return _}function S(){_.length=0,v=void 0}function O(e,t={}){v={remaining:e,code:t.code,message:t.message??"clickhouse insert failed",type:t.type}}var x=e=>(_.push(["createClient",e]),{async insert(e){_.push(["insert",e]);const t=function(){if(!v||v.remaining<1)return;v.remaining-=1;const{code:e,message:t,type:i}=v;return void 0===e?new Error(t):new w({message:t,code:e,type:i})}();if(t)throw t;return{executed:!0,query_id:`mock-insert-${_.length}`,response_headers:{}}},async close(){_.push(["close"])}}),T={ClickHouseClient:x},U=["call:ClickHouseClient.insert"],R={};s(R,{init:()=>$,initPassthrough:()=>N,pageView:()=>L,purchase:()=>W});import{getEvent as P}from"@walkeros/core";function q(e,t){return String(e).padStart(t,"0")}function D(e){const t=new Date(e);return`${[q(t.getUTCFullYear(),4),q(t.getUTCMonth()+1,2),q(t.getUTCDate(),2)].join("-")} ${[q(t.getUTCHours(),2),q(t.getUTCMinutes(),2),q(t.getUTCSeconds(),2)].join(":")}.${q(t.getUTCMilliseconds(),3)}`}function j(e){if(null==e)return"";if("object"==typeof e)if(Array.isArray(e)){if(0===e.length)return""}else if(0===Object.keys(e).length)return"";return JSON.stringify(e)}function H(e){return{name:e.name,data:j(e.data),context:j(e.context),globals:j(e.globals),custom:j(e.custom),user:j(e.user),nested:j(e.nested),consent:j(e.consent),id:e.id,trigger:e.trigger,entity:e.entity,action:e.action,timestamp:D(e.timestamp),timing:e.timing,source:j(e.source)}}import{createHash as A}from"crypto";import{createClient as E}from"@clickhouse/client";var I={url:"https://clickhouse.example.com:8443",database:"analytics",table:"events"},$={title:"Initialization",description:"Init creates a single ClickHouse client for the configured endpoint, database and credentials. The client is held on the resolved config and reused by every insert. Its request timeout is derived from the collector timeout so a retried insert cannot outlive the delivery it belongs to, and request compression is enabled.",in:{settings:I,credentials:{username:"walkeros_writer",password:"$secret.CLICKHOUSE_PASSWORD"}},out:[["createClient",{url:"https://clickhouse.example.com:8443",database:"analytics",username:"walkeros_writer",password:"$secret.CLICKHOUSE_PASSWORD",request_timeout:4812,compression:{request:!0}}]]},N={title:"Client option passthrough",description:"The raw settings.clickhouse options reach the client underneath the destination's own settings: url, database and the credentials slot win over them, an explicit request_timeout or compression wins over the derived default, and any other option travels through untouched.",public:!1,in:{settings:{url:"https://clickhouse.example.com:8443",database:"analytics",clickhouse:{url:"https://ignored.example.com:8443",database:"ignored",username:"ignored_user",request_timeout:6e4,max_open_connections:25,application:"walkeros"}},credentials:{username:"walkeros_writer",password:"$secret.CLICKHOUSE_PASSWORD"}},out:[["createClient",{url:"https://clickhouse.example.com:8443",database:"analytics",username:"walkeros_writer",password:"$secret.CLICKHOUSE_PASSWORD",request_timeout:6e4,max_open_connections:25,application:"walkeros",compression:{request:!0}}]]};function J(e){return[["insert",{table:I.table,values:[H(e)],format:"JSONEachRow",clickhouse_settings:{async_insert:0,input_format_skip_unknown_fields:0,input_format_null_as_default:0,insert_deduplication_token:(t=[e.id],A("sha256").update(JSON.stringify(t)).digest("hex"))}}]];var t}var K=P("page view",{timestamp:1700000000123}),L={title:"Page view",description:"A page view is inserted as a single JSONEachRow row. The payload columns (data, context, globals, custom, user, nested, consent, source) are JSON-encoded strings and the timestamp is a UTC datetime literal, both produced by eventToRow.",in:K,mapping:void 0,out:J(K)},M=P("order complete",{timestamp:1700000000456,data:{id:"ORD-500",currency:"EUR",total:199.99,items:[{sku:"SKU-1",quantity:2},{sku:"SKU-2",quantity:1}]}}),W={title:"Purchase",description:"An order event is inserted as one row. The nested items array is JSON-encoded into the data column, so a richer payload needs no table change. Promoting a hot key to its own column is a MATERIALIZED expression in the table DDL, not a destination setting.",in:M,mapping:void 0,out:J(M)};export{h as examples,o as schemas};//# sourceMappingURL=dev.mjs.map
package/dist/dev.mjs.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/schemas/index.ts","../src/schemas/settings.ts","../src/schemas/mapping.ts","../src/examples/index.ts","../src/examples/env.ts","../src/__mocks__/@clickhouse/client.ts","../src/examples/step.ts","../src/eventToRow.ts","../src/insert.ts","../src/config.ts"],"sourcesContent":["import { zodToSchema } from '@walkeros/core/dev';\nimport { SettingsSchema } from './settings';\nimport { MappingSchema } from './mapping';\n\nexport { SettingsSchema, type Settings } from './settings';\nexport { MappingSchema, type Mapping } from './mapping';\n\n// JSON Schema\nexport const settings = zodToSchema(SettingsSchema);\nexport const mapping = zodToSchema(MappingSchema);\n","import { z } from '@walkeros/core/dev';\n\nexport const SettingsSchema = z.object({\n url: z\n .string()\n .min(1)\n .describe(\n 'ClickHouse HTTP endpoint (like https://host.clickhouse.cloud:8443)',\n ),\n database: z\n .string()\n .min(1)\n .default('default')\n .describe('Database holding the events table (like analytics)'),\n table: z\n .string()\n .min(1)\n .default('events')\n .describe('Table every event is inserted into (like events)'),\n maxRetries: z\n .number()\n .int()\n .min(0)\n .default(1)\n .describe(\n 'Retries added on top of the first attempt, so total attempts = 1 + maxRetries (like 1 for two attempts)',\n ),\n clickhouse: z\n .record(z.string(), z.unknown())\n .optional()\n .describe(\n 'Raw @clickhouse/client options merged into the created client (like { request_timeout: 60000 })',\n ),\n clickhouseSettings: z\n .record(z.string(), z.unknown())\n .optional()\n .describe(\n 'ClickHouse settings applied to every insert (like { async_insert: 1 })',\n ),\n});\n\nexport type Settings = z.infer<typeof SettingsSchema>;\n","import { z } from '@walkeros/core/dev';\n\n/**\n * ClickHouse Mapping Schema\n * ClickHouse receives raw events, so there is no event-level mapping\n * configuration.\n */\nexport const MappingSchema = z.object({});\n\n/**\n * Type inference from MappingSchema\n */\nexport type Mapping = z.infer<typeof MappingSchema>;\n","export * as env from './env';\nexport * as step from './step';\n","import type { Env } from '../types';\nimport { createClient } from '../__mocks__/@clickhouse/client';\n\n/**\n * Example environment for the ClickHouse destination.\n *\n * The client factory is the recording stand-in from the package-local\n * __mocks__ folder, so examples, tests and simulations share one source of\n * truth and never reach a ClickHouse server.\n */\nexport const push: Env = {\n ClickHouseClient: createClient,\n};\n\nexport const simulation = ['ClickHouseClient'];\n","// Recording stand-in for `createClient` from `@clickhouse/client`.\n//\n// Jest resolves `@clickhouse/client` to this file for every test in the\n// package, so the star re-export below keeps every other SDK name (types,\n// `ClickHouseError`, the format constants) reachable instead of undefined. The\n// local `createClient` shadows the real one.\n//\n// The returned client is a plain object carrying only `insert` and `close`,\n// the two methods this destination is allowed to call. Nothing from the real\n// SDK is constructed, so no request can leave the process by any path.\n\nimport type { Readable } from 'stream';\nimport type {\n ClickHouseClientConfigOptions,\n InsertParams,\n InsertResult,\n} from '@clickhouse/client';\nimport { ClickHouseError } from '@clickhouse/client';\nimport type { ClickHouseClientSurface } from '../../types';\n\nexport * from '@clickhouse/client';\n\n/** One recorded interaction: the callable name followed by its arguments. */\nexport type MockCall = readonly [string, ...unknown[]];\n\ninterface Failure {\n remaining: number;\n /**\n * ClickHouse error codes are strings (like '159' for a server-side timeout\n * or '252' for too many parts). Omit it for a codeless plain Error.\n */\n code?: string;\n message: string;\n type?: string;\n}\n\nconst calls: MockCall[] = [];\nlet failure: Failure | undefined;\n\n/** Every interaction recorded so far, in order. */\nexport function __getCalls(): readonly MockCall[] {\n return calls;\n}\n\n/** Clears the capture array and any pending insert failure. */\nexport function __reset(): void {\n calls.length = 0;\n failure = undefined;\n}\n\n/**\n * Rejects the next `times` inserts, then lets inserts succeed again.\n *\n * With a `code` the rejection is a real `ClickHouseError` carrying that string\n * code, so retry logic is exercised against the class the server actually\n * produces. Without a `code` it is a plain `Error` with no code at all.\n */\nexport function __failInsert(\n times: number,\n options: { code?: string; message?: string; type?: string } = {},\n): void {\n failure = {\n remaining: times,\n code: options.code,\n message: options.message ?? 'clickhouse insert failed',\n type: options.type,\n };\n}\n\nfunction nextFailure(): Error | undefined {\n if (!failure || failure.remaining < 1) return undefined;\n\n failure.remaining -= 1;\n const { code, message, type } = failure;\n\n return code === undefined\n ? new Error(message)\n : new ClickHouseError({ message, code, type });\n}\n\nexport const createClient = (\n config: ClickHouseClientConfigOptions,\n): ClickHouseClientSurface => {\n calls.push(['createClient', config]);\n\n return {\n async insert<T>(params: InsertParams<Readable, T>): Promise<InsertResult> {\n calls.push(['insert', params]);\n\n const error = nextFailure();\n if (error) throw error;\n\n return {\n executed: true,\n query_id: `mock-insert-${calls.length}`,\n response_headers: {},\n };\n },\n\n async close(): Promise<void> {\n calls.push(['close']);\n },\n };\n};\n","import type { Flow, WalkerOS } from '@walkeros/core';\nimport { getEvent } from '@walkeros/core';\nimport { eventToRow } from '../eventToRow';\nimport { deduplicationToken } from '../insert';\n\n/**\n * The endpoint, database and table the examples below write to. The table is\n * stated once because two places need it, the config a user writes and the\n * insert that config produces, and an example where those two disagree would\n * assert nothing.\n */\nconst settings = {\n url: 'https://clickhouse.example.com:8443',\n database: 'analytics',\n table: 'events',\n};\n\n/**\n * Destination bootstrap.\n *\n * `in` is the config a user copy-pastes into a flow: the endpoint, the\n * database and table to write to, and the credentials slot backed by a managed\n * secret. Init creates exactly one ClickHouse client from it and holds that\n * client on the resolved config, so every insert reuses the same connection\n * pool.\n *\n * Two options the user did not write reach the client. `request_timeout` is\n * derived from the collector's per-delivery timeout (10000 ms by default) minus\n * what the retry delays can take, split across the attempts: at one retry that\n * is 4812 ms, so the second attempt finishes before the collector stops waiting\n * and dead-letters a batch the client would still land. Request compression is\n * off in this client by default and insert bodies are large, repetitive JSON,\n * so it is turned on. Both yield to `settings.clickhouse`.\n */\nexport const init: Flow.StepExample = {\n title: 'Initialization',\n description:\n 'Init creates a single ClickHouse client for the configured endpoint, database and credentials. The client is held on the resolved config and reused by every insert. Its request timeout is derived from the collector timeout so a retried insert cannot outlive the delivery it belongs to, and request compression is enabled.',\n in: {\n settings,\n credentials: {\n username: 'walkeros_writer',\n password: '$secret.CLICKHOUSE_PASSWORD',\n },\n },\n out: [\n [\n 'createClient',\n {\n url: 'https://clickhouse.example.com:8443',\n database: 'analytics',\n username: 'walkeros_writer',\n password: '$secret.CLICKHOUSE_PASSWORD',\n request_timeout: 4812,\n compression: { request: true },\n },\n ],\n ],\n};\n\n/**\n * What happens to the raw `settings.clickhouse` passthrough.\n *\n * The merge runs in four layers, lowest first: the passthrough, then the\n * derived defaults where the passthrough left their key unset, then `url` and\n * `database`, then `credentials`. An insert names only the table, so a\n * passthrough `database` that disagreed with the configured one would move\n * every row to a different database in silence; the configured one wins. The\n * credentials slot wins over a username or password left in the passthrough,\n * and an option the destination does not name travels through untouched.\n */\nexport const initPassthrough: Flow.StepExample = {\n title: 'Client option passthrough',\n description:\n \"The raw settings.clickhouse options reach the client underneath the destination's own settings: url, database and the credentials slot win over them, an explicit request_timeout or compression wins over the derived default, and any other option travels through untouched.\",\n public: false,\n in: {\n settings: {\n url: 'https://clickhouse.example.com:8443',\n database: 'analytics',\n clickhouse: {\n url: 'https://ignored.example.com:8443',\n database: 'ignored',\n username: 'ignored_user',\n request_timeout: 60000,\n max_open_connections: 25,\n application: 'walkeros',\n },\n },\n credentials: {\n username: 'walkeros_writer',\n password: '$secret.CLICKHOUSE_PASSWORD',\n },\n },\n out: [\n [\n 'createClient',\n {\n url: 'https://clickhouse.example.com:8443',\n database: 'analytics',\n username: 'walkeros_writer',\n password: '$secret.CLICKHOUSE_PASSWORD',\n request_timeout: 60000,\n max_open_connections: 25,\n application: 'walkeros',\n compression: { request: true },\n },\n ],\n ],\n};\n\n// Event step examples belong below this line. The step-example test iterates\n// every entry exported from this module and slices the captured calls by\n// `init.out.length`, so an appended entry needs no test change.\n\n/**\n * The insert one event produces. Rows come from `eventToRow`, so an example can\n * never claim a column shape the destination does not emit.\n *\n * The per-insert settings are spelled out here rather than read from the code\n * on purpose. They are the three settings that make a schema mismatch loud\n * instead of silent and keep the acknowledgement tied to a durable write,\n * nothing in the type system forces the insert call to send them, and an\n * insert that quietly stopped sending them would keep every other test green.\n * The deduplication token sits inside that same object: it is what lets a\n * retry of a batch the server may already have written land without counting\n * those rows twice, and it is computed from the event ids, so this example\n * reads it from the destination rather than restating a digest.\n */\nfunction expectedInsert(event: WalkerOS.Event): Flow.StepOut {\n return [\n [\n 'insert',\n {\n table: settings.table,\n values: [eventToRow(event)],\n format: 'JSONEachRow',\n clickhouse_settings: {\n async_insert: 0,\n input_format_skip_unknown_fields: 0,\n input_format_null_as_default: 0,\n insert_deduplication_token: deduplicationToken([event.id]),\n },\n },\n ],\n ];\n}\n\nconst pageViewEvent = getEvent('page view', { timestamp: 1700000000123 });\n\n/**\n * One event, one row, one insert. Every payload travels as a JSON-encoded\n * string and the timestamp as a `YYYY-MM-DD hh:mm:ss.SSS` UTC literal, the\n * format every ClickHouse date parser reads identically.\n */\nexport const pageView: Flow.StepExample = {\n title: 'Page view',\n description:\n 'A page view is inserted as a single JSONEachRow row. The payload columns (data, context, globals, custom, user, nested, consent, source) are JSON-encoded strings and the timestamp is a UTC datetime literal, both produced by eventToRow.',\n in: pageViewEvent,\n mapping: undefined,\n out: expectedInsert(pageViewEvent),\n};\n\nconst purchaseEvent = getEvent('order complete', {\n timestamp: 1700000000456,\n data: {\n id: 'ORD-500',\n currency: 'EUR',\n total: 199.99,\n items: [\n { sku: 'SKU-1', quantity: 2 },\n { sku: 'SKU-2', quantity: 1 },\n ],\n },\n});\n\n/**\n * An order carries an array of items. The whole `data` object, array\n * included, is JSON-encoded into the single `data` column, so a flow can send\n * any shape without the table knowing about it.\n */\nexport const purchase: Flow.StepExample = {\n title: 'Purchase',\n description:\n 'An order event is inserted as one row. The nested items array is JSON-encoded into the data column, so a richer payload needs no table change. Promoting a hot key to its own column is a MATERIALIZED expression in the table DDL, not a destination setting.',\n in: purchaseEvent,\n mapping: undefined,\n out: expectedInsert(purchaseEvent),\n};\n","import type { WalkerOS } from '@walkeros/core';\n\n/**\n * One insert row, in the canonical walkerOS Event v4 column order: name, data,\n * context, globals, custom, user, nested, consent, id, trigger, entity,\n * action, timestamp, timing, source.\n *\n * Payload columns are JSON-encoded strings rather than nested structures, so\n * the table needs no schema knowledge of what a flow sends. Hot keys become\n * real columns in the table's own DDL through `MATERIALIZED` expressions over\n * these strings, which costs the destination nothing.\n *\n * No column is nullable. Inserts run with `input_format_null_as_default = 0`,\n * which turns a stray `null` into a loud server-side error instead of a silent\n * default, so an absent payload has to arrive as an empty string. A non-finite\n * `timing` is the one value the type still admits that reaches the wire as a\n * null, `JSON.stringify` writing `NaN` and `Infinity` that way: the server\n * rejects the batch, the rejection is terminal and is not retried, and the\n * wrong number stays visible in the event rather than becoming a plausible `0`\n * in the table.\n */\nexport interface ClickHouseRow {\n [column: string]: string | number;\n name: string;\n data: string;\n context: string;\n globals: string;\n custom: string;\n user: string;\n nested: string;\n consent: string;\n id: string;\n trigger: string;\n entity: string;\n action: string;\n timestamp: string;\n timing: number;\n source: string;\n}\n\n/**\n * A row as the insert path receives it: the canonical shape above, or the\n * object a flow's mapping placed in `context.data`.\n *\n * A mapped row replaces the canonical one wholesale and travels verbatim, so a\n * partner whose table is not the reference DDL configures its columns in the\n * flow. Its shape is whatever the flow wrote, and this type claims no more\n * than that.\n */\nexport type InsertRow = ClickHouseRow | WalkerOS.AnyObject;\n\nfunction pad(value: number, length: number): string {\n return String(value).padStart(length, '0');\n}\n\n/**\n * `YYYY-MM-DD hh:mm:ss.SSS` in UTC, the one literal every ClickHouse server\n * reads the same way. The `basic` date parser rejects the ISO 8601 `T` and `Z`\n * markers, and a bare number is read as Unix seconds by newer servers but as\n * the raw value at the column's precision by older ones, so epoch\n * milliseconds would land off by a factor of 1000 depending on the version.\n * The reference DDL declares the column `DateTime64(3, 'UTC')`, and a string\n * is read in the column's own timezone.\n */\nfunction toDateTime(timestamp: number): string {\n const date = new Date(timestamp);\n\n const day = [\n pad(date.getUTCFullYear(), 4),\n pad(date.getUTCMonth() + 1, 2),\n pad(date.getUTCDate(), 2),\n ].join('-');\n\n const time = [\n pad(date.getUTCHours(), 2),\n pad(date.getUTCMinutes(), 2),\n pad(date.getUTCSeconds(), 2),\n ].join(':');\n\n return `${day} ${time}.${pad(date.getUTCMilliseconds(), 3)}`;\n}\n\n/**\n * JSON for anything carrying content, `''` for anything not. Absent values,\n * empty objects and empty arrays collapse to the same empty string so no\n * column ever holds a null.\n */\nfunction jsonOrEmpty(value: unknown): string {\n if (value === undefined || value === null) return '';\n\n if (typeof value === 'object') {\n if (Array.isArray(value)) {\n if (value.length === 0) return '';\n } else if (Object.keys(value).length === 0) return '';\n }\n\n return JSON.stringify(value);\n}\n\n/** Convert one walkerOS event into one ClickHouse row. */\nexport function eventToRow(event: WalkerOS.Event): ClickHouseRow {\n return {\n name: event.name,\n data: jsonOrEmpty(event.data),\n context: jsonOrEmpty(event.context),\n globals: jsonOrEmpty(event.globals),\n custom: jsonOrEmpty(event.custom),\n user: jsonOrEmpty(event.user),\n nested: jsonOrEmpty(event.nested),\n consent: jsonOrEmpty(event.consent),\n id: event.id,\n trigger: event.trigger,\n entity: event.entity,\n action: event.action,\n timestamp: toDateTime(event.timestamp),\n timing: event.timing,\n source: jsonOrEmpty(event.source),\n };\n}\n","import type { Readable } from 'stream';\nimport type { ClickHouseSettings, InsertParams } from '@clickhouse/client';\nimport type { Logger } from '@walkeros/core';\nimport { createHash } from 'node:crypto';\nimport { classify, errorCode } from './classify';\nimport {\n RETRY_JITTER_MAX,\n normalizeMaxRetries,\n resolveInsertSettings,\n retryDelay,\n} from './config';\nimport type { InsertRow } from './eventToRow';\nimport type { Settings } from './types';\n\n/**\n * One row on its way to the table, beside the id of the event it came from.\n *\n * The id travels beside the row rather than inside it because a row is not\n * guaranteed to carry one. A mapped row is whatever a flow wrote, and `id` is\n * a column a partner's table need not have. Reading the token out of the row\n * would give every id-less mapped row the same token, which\n * `deduplicationToken` explains is silent, total data loss.\n */\nexport interface InsertEntry {\n /** The row as it travels: a mapped object verbatim, or the conversion. */\n row: InsertRow;\n /** The walkerOS event id. The only input the token is derived from. */\n id: string;\n}\n\n/**\n * The deduplication token sent with a batch: a digest over its event ids, in\n * the order they are inserted.\n *\n * Retrying an insert without one double-counts every row of a batch the server\n * had already written when the answer stopped coming. Synchronous inserts\n * deduplicate on the block contents by themselves, but only where the window\n * is on: `non_replicated_deduplication_window` defaults to 0, so a plain\n * MergeTree does nothing unless the table says otherwise. An explicit token\n * identifies the block for both MergeTree families, but it prevents duplicates\n * only where insert deduplication is enabled: by default on a\n * ReplicatedMergeTree, and on a plain MergeTree only with a positive window.\n *\n * A digest rather than a random uuid, so a redelivery of the same events\n * produces the same token and deduplicates too, as long as it lands inside the\n * table's deduplication window, which counts blocks and not time. The ids\n * travel as a JSON array so that no id can imitate the separator between two\n * of them, and the order is part of the digest because the same rows in\n * another order are another block to ClickHouse.\n *\n * Event ids and nothing else. The server deduplicates on this token INSTEAD of\n * the block contents, and skips a repeated block while reporting success, so\n * two deliveries that share a token are one delivery with no error, no dead\n * letter entry and no counter anywhere: a token that could repeat across\n * different events is silent data loss. The ids are the one input that is\n * always present and unique per event, which the row is not, and they identify\n * the events rather than describing them, so two different events that a\n * mapping happened to shape identically stay two blocks.\n */\nexport function deduplicationToken(ids: readonly string[]): string {\n return createHash('sha256').update(JSON.stringify(ids)).digest('hex');\n}\n\n/**\n * How much of the base delay this wait takes: all of it, plus up to half again.\n * Two runtimes that failed on the same server-side condition would otherwise\n * retry in lockstep and meet it together a second time.\n */\nfunction drawJitter(): number {\n return 1 + Math.random() * (RETRY_JITTER_MAX - 1);\n}\n\nfunction wait(ms: number): Promise<void> {\n return new Promise((resolve) => {\n setTimeout(resolve, ms);\n });\n}\n\n/**\n * Inserts one batch, retrying a transient failure up to `maxRetries` times.\n *\n * One flushed batch is one insert, whatever its size: the rows travel as a\n * single JSONEachRow body, and `format` is fixed because a table written in two\n * formats is a table nobody can read back.\n *\n * The parameters are built once and every attempt sends that same object, so\n * the deduplication token cannot be regenerated halfway through a retry, which\n * is the one thing that would turn a safe retry into duplicated rows.\n *\n * The delays come from the same function the per-attempt request timeout was\n * derived from, so what the loop waits and what the client was configured for\n * cannot drift apart and leave an attempt running past the collector's race.\n *\n * A failure that survives the retries is rethrown as it arrived, ClickHouse\n * code included: the collector dead-letters the whole batch on a throw and\n * keeps the error beside it, and the code is what makes that entry readable.\n */\nexport async function insertRows(\n entries: readonly InsertEntry[],\n settings: Settings,\n logger: Logger.Instance,\n): Promise<void> {\n const { client, table } = settings;\n\n // What counts as a usable retry count is decided in one place for both halves\n // of the mechanism, the loop here and the budget the request timeout was\n // derived from. A count that is not a finite number would otherwise never end\n // this loop, `attempt >= NaN` and `attempt >= Infinity` being false forever,\n // and nothing cancels a destination's work once the collector has stopped\n // waiting for it.\n const retries = normalizeMaxRetries(settings.maxRetries);\n\n // Our defaults first, then what the flow set, then the token: a token an\n // operator could pin would collapse every insert into one deduplicated block\n // and drop every batch after the first without an error anywhere.\n const clickhouse_settings: ClickHouseSettings = {\n ...resolveInsertSettings(settings.clickhouseSettings),\n insert_deduplication_token: deduplicationToken(\n entries.map((entry) => entry.id),\n ),\n };\n\n const params: InsertParams<Readable, InsertRow> = {\n table,\n values: entries.map((entry) => entry.row),\n format: 'JSONEachRow',\n clickhouse_settings,\n };\n\n for (let attempt = 0; ; attempt += 1) {\n logger.debug('ClickHouse insert', { table, rows: entries.length, attempt });\n\n try {\n await client.insert(params);\n\n return;\n } catch (error) {\n // Classified before the budget is read, so the operator hears about a\n // terminal failure even where there was never a retry to skip.\n const retryable = classify(error, logger) === 'retryable';\n\n if (!retryable || attempt >= retries) throw error;\n\n const delay = retryDelay(attempt, drawJitter());\n\n // A finite but absurd retry count passes the clamp above and would spend\n // it on insert attempts against a live server. The delays double, so they\n // leave the finite range on their own after about a thousand retries, and\n // reading that as the end of the loop bounds it without inventing a\n // maximum attempt count or restating the schedule. The budget the request\n // timeout comes from ends on the matching condition: it stops when its\n // running sum stops being finite, this loop when a single delay does.\n if (!Number.isFinite(delay)) throw error;\n\n logger.warn('ClickHouse insert failed, retrying', {\n code: errorCode(error),\n attempt,\n delay,\n });\n\n await wait(delay);\n }\n }\n}\n","import type {\n ClickHouseClientConfigOptions,\n ClickHouseSettings,\n} from '@clickhouse/client';\nimport type { Logger } from '@walkeros/core';\nimport { createClient } from '@clickhouse/client';\nimport type {\n ClickHouseClientFactory,\n Config,\n Credentials,\n Env,\n InitSettings,\n PartialConfig,\n Settings,\n} from './types';\n\n/** Database written to when a flow names none. */\nexport const DEFAULT_DATABASE = 'default';\n\n/** Table written to when a flow names none. */\nexport const DEFAULT_TABLE = 'events';\n\n/** Retries added on top of the first attempt when a flow names none. */\nexport const DEFAULT_MAX_RETRIES = 1;\n\n/** The collector's own per-delivery race, used when `config.timeout` is unset. */\nexport const DEFAULT_TIMEOUT_MS = 10_000;\n\n/** Delay before the first retry. Every further retry doubles it. */\nexport const RETRY_BASE_DELAY_MS = 250;\n\n/** Highest multiplier the retry jitter can apply to a single delay. */\nexport const RETRY_JITTER_MAX = 1.5;\n\n/** Floor for the derived per-attempt timeout. */\nexport const MIN_REQUEST_TIMEOUT_MS = 1_000;\n\n/**\n * `maxRetries` as the retry mechanism agrees to read it: a whole, finite count,\n * never negative. Everything else means no retries at all.\n *\n * A flow's config arrives as JSON and reaches the resolution helpers without\n * passing the schema, so `int().min(0)` does not stand between a config file and\n * this code. `JSON.parse('{\"maxRetries\": 1e999}')` yields `Infinity`, which\n * would spin the budget loop below inside `init`, on the collector's startup\n * path. `NaN` is quieter and reaches the client as a `request_timeout` of `NaN`.\n *\n * Every part of the retry mechanism normalizes through this one function, so the\n * budget the timeout was derived from and the loop that spends it cannot\n * disagree about what a valid count is.\n */\nexport function normalizeMaxRetries(maxRetries: number): number {\n return Number.isFinite(maxRetries) ? Math.max(0, Math.floor(maxRetries)) : 0;\n}\n\n/**\n * How long to wait before the retry at `retry`, counting the first retry as 0:\n * the base delay doubled once per retry, scaled by the jitter.\n *\n * The retry loop and the budget below call this same function, so what the\n * per-attempt timeout was derived from and what the loop actually waits cannot\n * drift apart. A loop passes its own drawn jitter; the budget takes the default,\n * which is the largest jitter that can be drawn.\n */\nexport function retryDelay(\n retry: number,\n jitter: number = RETRY_JITTER_MAX,\n): number {\n return RETRY_BASE_DELAY_MS * 2 ** retry * jitter;\n}\n\n/**\n * Longest total time the retry delays alone can take: every one of the\n * `maxRetries` delays at its longest jittered length. The delays sit between\n * the `1 + maxRetries` attempts, so they spend the same budget the attempts do.\n *\n * The delays double, so they pass what a float can hold after about a thousand\n * of them and the total stops changing. Returning at that point bounds the loop\n * for any count at all, without inventing a maximum number of retries.\n */\nexport function retryDelayBudget(maxRetries: number): number {\n const retries = normalizeMaxRetries(maxRetries);\n let budget = 0;\n\n for (let retry = 0; retry < retries; retry += 1) {\n budget += retryDelay(retry);\n\n if (!Number.isFinite(budget)) return budget;\n }\n\n return budget;\n}\n\n/**\n * Per-request timeout for the client, derived so that every attempt and every\n * retry delay finishes inside the collector's race.\n *\n * The client's own default is 30 s, three times the collector's 10 s. Left\n * there, the collector stops waiting at 10 s, dead-letters the batch and counts\n * it lost while the client is still sending, and the rows land at 12 s anyway:\n * a batch counted lost and actually written. Splitting what the delays leave\n * over across the attempts keeps the last attempt finishing first.\n *\n * The floor keeps a short timeout or a high retry count from deriving a useless\n * or negative value. Such a configuration cannot fit its own retries either\n * way, and the collector's race is what bounds it there. Once the floor fires,\n * the attempts and their delays add up to more than `timeout`, and the\n * collector cuts the later attempts off when it stops waiting.\n */\nexport function resolveRequestTimeout(\n timeout: number,\n maxRetries: number,\n): number {\n // Both arguments are normalized rather than trusted. A retry count of NaN\n // would otherwise carry straight through the arithmetic into a\n // `request_timeout` of NaN, and a non-finite timeout into one of Infinity:\n // a hung insert the collector can only end by dead-lettering the batch.\n const retries = normalizeMaxRetries(maxRetries);\n const attempts = retries + 1;\n\n return Math.max(\n MIN_REQUEST_TIMEOUT_MS,\n Math.floor(\n (resolveTimeout(timeout) - retryDelayBudget(retries)) / attempts,\n ),\n );\n}\n\n/**\n * The collector's per-delivery race, as this destination reads it: a positive,\n * finite number of milliseconds, or the collector's own default.\n */\nexport function resolveTimeout(timeout?: number): number {\n if (timeout === undefined || !Number.isFinite(timeout) || timeout <= 0)\n return DEFAULT_TIMEOUT_MS;\n\n return timeout;\n}\n\n/**\n * ClickHouse settings sent with every insert.\n *\n * `async_insert: 0` keeps the server's acknowledgement tied to a durable write.\n * An async insert is acknowledged before the data reaches the table, and an\n * error raised at the later flush has no way back to the caller, so a lost\n * write would neither retry nor reach the dead letter queue. The server-side\n * default is documented inconsistently, so the value travels explicitly.\n *\n * `input_format_skip_unknown_fields: 0` and `input_format_null_as_default: 0`\n * turn the server's silent repairs off. Both are on by default: the first drops\n * a JSON key the table does not know, the second rewrites an explicit null into\n * the column default. A walkerOS field missing from a partner's table would\n * disappear with no error and no warning, found weeks later in reporting. At 0\n * the whole batch fails loudly, is attributed and reaches the counters.\n *\n * A flow's own `clickhouseSettings` sit on top of all three.\n */\nconst INSERT_SETTINGS: ClickHouseSettings = {\n async_insert: 0,\n input_format_skip_unknown_fields: 0,\n input_format_null_as_default: 0,\n};\n\n/** The per-insert settings, with a flow's own `clickhouseSettings` on top. */\nexport function resolveInsertSettings(\n custom?: ClickHouseSettings,\n): ClickHouseSettings {\n return { ...INSERT_SETTINGS, ...custom };\n}\n\n/**\n * The host of an endpoint, with any userinfo left behind.\n *\n * A url is one of the places a password can legitimately sit\n * (`https://user:pass@host:port`), so the configured string is never safe to\n * put in a log line and only the host ever is. A url the client would reject\n * still reaches here when the factory comes from `env`, which parses nothing.\n */\nfunction endpointHost(url: string): string {\n try {\n return new URL(url).host;\n } catch {\n return 'unparsed';\n }\n}\n\n/** The resolved settings before init attaches the live client. */\ntype ResolvedSettings = Omit<Settings, 'client'>;\n\nfunction withDefaults(\n settings: InitSettings,\n logger: Logger.Instance,\n): ResolvedSettings {\n const { url, clickhouse } = settings;\n\n if (!url) return logger.throw('Config settings url missing');\n\n return {\n url,\n database: settings.database || DEFAULT_DATABASE,\n table: settings.table || DEFAULT_TABLE,\n maxRetries: settings.maxRetries ?? DEFAULT_MAX_RETRIES,\n ...(clickhouse && { clickhouse }),\n clickhouseSettings: resolveInsertSettings(settings.clickhouseSettings),\n };\n}\n\n/**\n * The options `createClient` receives, merged lowest layer first:\n *\n * 1. `settings.clickhouse`, the raw SDK passthrough.\n * 2. The derived defaults, `request_timeout` and request compression, each\n * applied only where the passthrough left its key unset.\n * 3. `url` and `database`, the destination's own first-class settings. They\n * always win: an insert names only the table, so a passthrough `database`\n * that disagreed would move every row to a different database in silence.\n * 4. `credentials`, the strictly typed slot backed by a managed secret. It wins\n * over a username or password left in the passthrough; with the slot empty\n * the passthrough reaches the client untouched.\n *\n * The table, the retry count and the per-insert settings are not client\n * options and stay off this object.\n */\nexport function resolveClientOptions(\n settings: ResolvedSettings,\n credentials: Credentials | undefined,\n timeout: number,\n): ClickHouseClientConfigOptions {\n const options: ClickHouseClientConfigOptions = { ...settings.clickhouse };\n\n if (options.request_timeout === undefined)\n options.request_timeout = resolveRequestTimeout(\n timeout,\n settings.maxRetries,\n );\n\n // Both directions are off by default in this client. Compressing the request\n // is the one that pays here: insert bodies are large, repetitive JSON.\n if (options.compression === undefined)\n options.compression = { request: true };\n\n options.url = settings.url;\n options.database = settings.database;\n\n if (credentials) {\n options.username = credentials.username;\n options.password = credentials.password;\n }\n\n return options;\n}\n\n/**\n * Resolves the config init hands forward, and creates the one client every\n * insert reuses. Nothing is queried: a missing table surfaces at the first\n * insert, as a loud, attributable failure.\n */\nexport function getConfig(\n config: PartialConfig,\n env: Env | undefined,\n logger: Logger.Instance,\n): Config {\n const { settings } = config;\n\n if (!settings) return logger.throw('Config settings missing');\n\n const resolved = withDefaults(settings, logger);\n const options = resolveClientOptions(\n resolved,\n config.credentials,\n resolveTimeout(config.timeout),\n );\n\n const factory: ClickHouseClientFactory =\n env?.ClickHouseClient || createClient;\n const client = factory(options);\n\n // Every credential stays out of this line deliberately, the url included:\n // init is the one place that holds them, and a debug log is the easiest way\n // to spill them.\n logger.debug('ClickHouse client created', {\n host: endpointHost(resolved.url),\n database: resolved.database,\n table: resolved.table,\n });\n\n return { ...config, settings: { ...resolved, client } };\n}\n\n/**\n * The settings the insert path works with.\n *\n * Core resolves `Config.settings` from the init slot, so a push receives the\n * settings as a user wrote them: the client optional, the defaults unapplied.\n * This narrows them once. It guards that init ran and re-applies the defaults,\n * so the insert path holds a live client and a complete configuration without\n * guarding field by field.\n */\nexport function resolveSettings(\n config: PartialConfig,\n logger: Logger.Instance,\n): Settings {\n const { settings } = config;\n\n if (!settings) return logger.throw('Config settings missing, init() not run');\n\n const { client } = settings;\n\n if (!client) return logger.throw('ClickHouse client missing, init() not run');\n\n return { ...withDefaults(settings, logger), client };\n}\n"],"mappings":"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","names":["z","createClient","settings"]}
1
+ {"version":3,"sources":["../src/schemas/index.ts","../src/schemas/settings.ts","../src/schemas/mapping.ts","../src/examples/index.ts","../src/examples/env.ts","../src/__mocks__/@clickhouse/client.ts","../src/examples/step.ts","../src/eventToRow.ts","../src/insert.ts","../src/config.ts"],"sourcesContent":["import { zodToSchema } from '@walkeros/core/dev';\nimport { SettingsSchema } from './settings';\nimport { MappingSchema } from './mapping';\n\nexport { SettingsSchema, type Settings } from './settings';\nexport { MappingSchema, type Mapping } from './mapping';\n\n// JSON Schema\nexport const settings = zodToSchema(SettingsSchema);\nexport const mapping = zodToSchema(MappingSchema);\n","import { z } from '@walkeros/core/dev';\n\nexport const SettingsSchema = z.object({\n url: z\n .string()\n .min(1)\n .describe(\n 'ClickHouse HTTP endpoint (like https://host.clickhouse.cloud:8443)',\n ),\n database: z\n .string()\n .min(1)\n .default('default')\n .describe('Database holding the events table (like analytics)'),\n table: z\n .string()\n .min(1)\n .default('events')\n .describe('Table every event is inserted into (like events)'),\n maxRetries: z\n .number()\n .int()\n .min(0)\n .default(1)\n .describe(\n 'Retries added on top of the first attempt, so total attempts = 1 + maxRetries (like 1 for two attempts)',\n ),\n clickhouse: z\n .record(z.string(), z.unknown())\n .optional()\n .describe(\n 'Raw @clickhouse/client options merged into the created client (like { request_timeout: 60000 })',\n ),\n clickhouseSettings: z\n .record(z.string(), z.unknown())\n .optional()\n .describe(\n 'ClickHouse settings applied to every insert (like { async_insert: 1 })',\n ),\n});\n\nexport type Settings = z.infer<typeof SettingsSchema>;\n","import { z } from '@walkeros/core/dev';\n\n/**\n * ClickHouse Mapping Schema\n * ClickHouse receives raw events, so there is no event-level mapping\n * configuration.\n */\nexport const MappingSchema = z.object({});\n\n/**\n * Type inference from MappingSchema\n */\nexport type Mapping = z.infer<typeof MappingSchema>;\n","export * as env from './env';\nexport * as step from './step';\n","import type { Env } from '../types';\nimport { createClient } from '../__mocks__/@clickhouse/client';\n\n/**\n * Example environment for the ClickHouse destination.\n *\n * The client factory is the recording stand-in from the package-local\n * __mocks__ folder, so examples, tests and simulations share one source of\n * truth and never reach a ClickHouse server.\n */\nexport const push: Env = {\n ClickHouseClient: createClient,\n};\n\nexport const simulation = ['call:ClickHouseClient.insert'];\n","// Recording stand-in for `createClient` from `@clickhouse/client`.\n//\n// Jest resolves `@clickhouse/client` to this file for every test in the\n// package, so the star re-export below keeps every other SDK name (types,\n// `ClickHouseError`, the format constants) reachable instead of undefined. The\n// local `createClient` shadows the real one.\n//\n// The returned client is a plain object carrying only `insert` and `close`,\n// the two methods this destination is allowed to call. Nothing from the real\n// SDK is constructed, so no request can leave the process by any path.\n\nimport type { Readable } from 'stream';\nimport type {\n ClickHouseClientConfigOptions,\n InsertParams,\n InsertResult,\n} from '@clickhouse/client';\nimport { ClickHouseError } from '@clickhouse/client';\nimport type { ClickHouseClientSurface } from '../../types';\n\nexport * from '@clickhouse/client';\n\n/** One recorded interaction: the callable name followed by its arguments. */\nexport type MockCall = readonly [string, ...unknown[]];\n\ninterface Failure {\n remaining: number;\n /**\n * ClickHouse error codes are strings (like '159' for a server-side timeout\n * or '252' for too many parts). Omit it for a codeless plain Error.\n */\n code?: string;\n message: string;\n type?: string;\n}\n\nconst calls: MockCall[] = [];\nlet failure: Failure | undefined;\n\n/** Every interaction recorded so far, in order. */\nexport function __getCalls(): readonly MockCall[] {\n return calls;\n}\n\n/** Clears the capture array and any pending insert failure. */\nexport function __reset(): void {\n calls.length = 0;\n failure = undefined;\n}\n\n/**\n * Rejects the next `times` inserts, then lets inserts succeed again.\n *\n * With a `code` the rejection is a real `ClickHouseError` carrying that string\n * code, so retry logic is exercised against the class the server actually\n * produces. Without a `code` it is a plain `Error` with no code at all.\n */\nexport function __failInsert(\n times: number,\n options: { code?: string; message?: string; type?: string } = {},\n): void {\n failure = {\n remaining: times,\n code: options.code,\n message: options.message ?? 'clickhouse insert failed',\n type: options.type,\n };\n}\n\nfunction nextFailure(): Error | undefined {\n if (!failure || failure.remaining < 1) return undefined;\n\n failure.remaining -= 1;\n const { code, message, type } = failure;\n\n return code === undefined\n ? new Error(message)\n : new ClickHouseError({ message, code, type });\n}\n\nexport const createClient = (\n config: ClickHouseClientConfigOptions,\n): ClickHouseClientSurface => {\n calls.push(['createClient', config]);\n\n return {\n async insert<T>(params: InsertParams<Readable, T>): Promise<InsertResult> {\n calls.push(['insert', params]);\n\n const error = nextFailure();\n if (error) throw error;\n\n return {\n executed: true,\n query_id: `mock-insert-${calls.length}`,\n response_headers: {},\n };\n },\n\n async close(): Promise<void> {\n calls.push(['close']);\n },\n };\n};\n","import type { Flow, WalkerOS } from '@walkeros/core';\nimport { getEvent } from '@walkeros/core';\nimport { eventToRow } from '../eventToRow';\nimport { deduplicationToken } from '../insert';\n\n/**\n * The endpoint, database and table the examples below write to. The table is\n * stated once because two places need it, the config a user writes and the\n * insert that config produces, and an example where those two disagree would\n * assert nothing.\n */\nconst settings = {\n url: 'https://clickhouse.example.com:8443',\n database: 'analytics',\n table: 'events',\n};\n\n/**\n * Destination bootstrap.\n *\n * `in` is the config a user copy-pastes into a flow: the endpoint, the\n * database and table to write to, and the credentials slot backed by a managed\n * secret. Init creates exactly one ClickHouse client from it and holds that\n * client on the resolved config, so every insert reuses the same connection\n * pool.\n *\n * Two options the user did not write reach the client. `request_timeout` is\n * derived from the collector's per-delivery timeout (10000 ms by default) minus\n * what the retry delays can take, split across the attempts: at one retry that\n * is 4812 ms, so the second attempt finishes before the collector stops waiting\n * and dead-letters a batch the client would still land. Request compression is\n * off in this client by default and insert bodies are large, repetitive JSON,\n * so it is turned on. Both yield to `settings.clickhouse`.\n */\nexport const init: Flow.StepExample = {\n title: 'Initialization',\n description:\n 'Init creates a single ClickHouse client for the configured endpoint, database and credentials. The client is held on the resolved config and reused by every insert. Its request timeout is derived from the collector timeout so a retried insert cannot outlive the delivery it belongs to, and request compression is enabled.',\n in: {\n settings,\n credentials: {\n username: 'walkeros_writer',\n password: '$secret.CLICKHOUSE_PASSWORD',\n },\n },\n out: [\n [\n 'createClient',\n {\n url: 'https://clickhouse.example.com:8443',\n database: 'analytics',\n username: 'walkeros_writer',\n password: '$secret.CLICKHOUSE_PASSWORD',\n request_timeout: 4812,\n compression: { request: true },\n },\n ],\n ],\n};\n\n/**\n * What happens to the raw `settings.clickhouse` passthrough.\n *\n * The merge runs in four layers, lowest first: the passthrough, then the\n * derived defaults where the passthrough left their key unset, then `url` and\n * `database`, then `credentials`. An insert names only the table, so a\n * passthrough `database` that disagreed with the configured one would move\n * every row to a different database in silence; the configured one wins. The\n * credentials slot wins over a username or password left in the passthrough,\n * and an option the destination does not name travels through untouched.\n */\nexport const initPassthrough: Flow.StepExample = {\n title: 'Client option passthrough',\n description:\n \"The raw settings.clickhouse options reach the client underneath the destination's own settings: url, database and the credentials slot win over them, an explicit request_timeout or compression wins over the derived default, and any other option travels through untouched.\",\n public: false,\n in: {\n settings: {\n url: 'https://clickhouse.example.com:8443',\n database: 'analytics',\n clickhouse: {\n url: 'https://ignored.example.com:8443',\n database: 'ignored',\n username: 'ignored_user',\n request_timeout: 60000,\n max_open_connections: 25,\n application: 'walkeros',\n },\n },\n credentials: {\n username: 'walkeros_writer',\n password: '$secret.CLICKHOUSE_PASSWORD',\n },\n },\n out: [\n [\n 'createClient',\n {\n url: 'https://clickhouse.example.com:8443',\n database: 'analytics',\n username: 'walkeros_writer',\n password: '$secret.CLICKHOUSE_PASSWORD',\n request_timeout: 60000,\n max_open_connections: 25,\n application: 'walkeros',\n compression: { request: true },\n },\n ],\n ],\n};\n\n// Event step examples belong below this line. The step-example test iterates\n// every entry exported from this module and slices the captured calls by\n// `init.out.length`, so an appended entry needs no test change.\n\n/**\n * The insert one event produces. Rows come from `eventToRow`, so an example can\n * never claim a column shape the destination does not emit.\n *\n * The per-insert settings are spelled out here rather than read from the code\n * on purpose. They are the three settings that make a schema mismatch loud\n * instead of silent and keep the acknowledgement tied to a durable write,\n * nothing in the type system forces the insert call to send them, and an\n * insert that quietly stopped sending them would keep every other test green.\n * The deduplication token sits inside that same object: it is what lets a\n * retry of a batch the server may already have written land without counting\n * those rows twice, and it is computed from the event ids, so this example\n * reads it from the destination rather than restating a digest.\n */\nfunction expectedInsert(event: WalkerOS.Event): Flow.StepOut {\n return [\n [\n 'insert',\n {\n table: settings.table,\n values: [eventToRow(event)],\n format: 'JSONEachRow',\n clickhouse_settings: {\n async_insert: 0,\n input_format_skip_unknown_fields: 0,\n input_format_null_as_default: 0,\n insert_deduplication_token: deduplicationToken([event.id]),\n },\n },\n ],\n ];\n}\n\nconst pageViewEvent = getEvent('page view', { timestamp: 1700000000123 });\n\n/**\n * One event, one row, one insert. Every payload travels as a JSON-encoded\n * string and the timestamp as a `YYYY-MM-DD hh:mm:ss.SSS` UTC literal, the\n * format every ClickHouse date parser reads identically.\n */\nexport const pageView: Flow.StepExample = {\n title: 'Page view',\n description:\n 'A page view is inserted as a single JSONEachRow row. The payload columns (data, context, globals, custom, user, nested, consent, source) are JSON-encoded strings and the timestamp is a UTC datetime literal, both produced by eventToRow.',\n in: pageViewEvent,\n mapping: undefined,\n out: expectedInsert(pageViewEvent),\n};\n\nconst purchaseEvent = getEvent('order complete', {\n timestamp: 1700000000456,\n data: {\n id: 'ORD-500',\n currency: 'EUR',\n total: 199.99,\n items: [\n { sku: 'SKU-1', quantity: 2 },\n { sku: 'SKU-2', quantity: 1 },\n ],\n },\n});\n\n/**\n * An order carries an array of items. The whole `data` object, array\n * included, is JSON-encoded into the single `data` column, so a flow can send\n * any shape without the table knowing about it.\n */\nexport const purchase: Flow.StepExample = {\n title: 'Purchase',\n description:\n 'An order event is inserted as one row. The nested items array is JSON-encoded into the data column, so a richer payload needs no table change. Promoting a hot key to its own column is a MATERIALIZED expression in the table DDL, not a destination setting.',\n in: purchaseEvent,\n mapping: undefined,\n out: expectedInsert(purchaseEvent),\n};\n","import type { WalkerOS } from '@walkeros/core';\n\n/**\n * One insert row, in the canonical walkerOS Event v4 column order: name, data,\n * context, globals, custom, user, nested, consent, id, trigger, entity,\n * action, timestamp, timing, source.\n *\n * Payload columns are JSON-encoded strings rather than nested structures, so\n * the table needs no schema knowledge of what a flow sends. Hot keys become\n * real columns in the table's own DDL through `MATERIALIZED` expressions over\n * these strings, which costs the destination nothing.\n *\n * No column is nullable. Inserts run with `input_format_null_as_default = 0`,\n * which turns a stray `null` into a loud server-side error instead of a silent\n * default, so an absent payload has to arrive as an empty string. A non-finite\n * `timing` is the one value the type still admits that reaches the wire as a\n * null, `JSON.stringify` writing `NaN` and `Infinity` that way: the server\n * rejects the batch, the rejection is terminal and is not retried, and the\n * wrong number stays visible in the event rather than becoming a plausible `0`\n * in the table.\n */\nexport interface ClickHouseRow {\n [column: string]: string | number;\n name: string;\n data: string;\n context: string;\n globals: string;\n custom: string;\n user: string;\n nested: string;\n consent: string;\n id: string;\n trigger: string;\n entity: string;\n action: string;\n timestamp: string;\n timing: number;\n source: string;\n}\n\n/**\n * A row as the insert path receives it: the canonical shape above, or the\n * object a flow's mapping placed in `context.data`.\n *\n * A mapped row replaces the canonical one wholesale and travels verbatim, so a\n * partner whose table is not the reference DDL configures its columns in the\n * flow. Its shape is whatever the flow wrote, and this type claims no more\n * than that.\n */\nexport type InsertRow = ClickHouseRow | WalkerOS.AnyObject;\n\nfunction pad(value: number, length: number): string {\n return String(value).padStart(length, '0');\n}\n\n/**\n * `YYYY-MM-DD hh:mm:ss.SSS` in UTC, the one literal every ClickHouse server\n * reads the same way. The `basic` date parser rejects the ISO 8601 `T` and `Z`\n * markers, and a bare number is read as Unix seconds by newer servers but as\n * the raw value at the column's precision by older ones, so epoch\n * milliseconds would land off by a factor of 1000 depending on the version.\n * The reference DDL declares the column `DateTime64(3, 'UTC')`, and a string\n * is read in the column's own timezone.\n */\nfunction toDateTime(timestamp: number): string {\n const date = new Date(timestamp);\n\n const day = [\n pad(date.getUTCFullYear(), 4),\n pad(date.getUTCMonth() + 1, 2),\n pad(date.getUTCDate(), 2),\n ].join('-');\n\n const time = [\n pad(date.getUTCHours(), 2),\n pad(date.getUTCMinutes(), 2),\n pad(date.getUTCSeconds(), 2),\n ].join(':');\n\n return `${day} ${time}.${pad(date.getUTCMilliseconds(), 3)}`;\n}\n\n/**\n * JSON for anything carrying content, `''` for anything not. Absent values,\n * empty objects and empty arrays collapse to the same empty string so no\n * column ever holds a null.\n */\nfunction jsonOrEmpty(value: unknown): string {\n if (value === undefined || value === null) return '';\n\n if (typeof value === 'object') {\n if (Array.isArray(value)) {\n if (value.length === 0) return '';\n } else if (Object.keys(value).length === 0) return '';\n }\n\n return JSON.stringify(value);\n}\n\n/** Convert one walkerOS event into one ClickHouse row. */\nexport function eventToRow(event: WalkerOS.Event): ClickHouseRow {\n return {\n name: event.name,\n data: jsonOrEmpty(event.data),\n context: jsonOrEmpty(event.context),\n globals: jsonOrEmpty(event.globals),\n custom: jsonOrEmpty(event.custom),\n user: jsonOrEmpty(event.user),\n nested: jsonOrEmpty(event.nested),\n consent: jsonOrEmpty(event.consent),\n id: event.id,\n trigger: event.trigger,\n entity: event.entity,\n action: event.action,\n timestamp: toDateTime(event.timestamp),\n timing: event.timing,\n source: jsonOrEmpty(event.source),\n };\n}\n","import type { Readable } from 'stream';\nimport type { ClickHouseSettings, InsertParams } from '@clickhouse/client';\nimport type { Logger } from '@walkeros/core';\nimport { createHash } from 'node:crypto';\nimport { classify, errorCode } from './classify';\nimport {\n RETRY_JITTER_MAX,\n normalizeMaxRetries,\n resolveInsertSettings,\n retryDelay,\n} from './config';\nimport type { InsertRow } from './eventToRow';\nimport type { Settings } from './types';\n\n/**\n * One row on its way to the table, beside the id of the event it came from.\n *\n * The id travels beside the row rather than inside it because a row is not\n * guaranteed to carry one. A mapped row is whatever a flow wrote, and `id` is\n * a column a partner's table need not have. Reading the token out of the row\n * would give every id-less mapped row the same token, which\n * `deduplicationToken` explains is silent, total data loss.\n */\nexport interface InsertEntry {\n /** The row as it travels: a mapped object verbatim, or the conversion. */\n row: InsertRow;\n /** The walkerOS event id. The only input the token is derived from. */\n id: string;\n}\n\n/**\n * The deduplication token sent with a batch: a digest over its event ids, in\n * the order they are inserted.\n *\n * Retrying an insert without one double-counts every row of a batch the server\n * had already written when the answer stopped coming. Synchronous inserts\n * deduplicate on the block contents by themselves, but only where the window\n * is on: `non_replicated_deduplication_window` defaults to 0, so a plain\n * MergeTree does nothing unless the table says otherwise. An explicit token\n * identifies the block for both MergeTree families, but it prevents duplicates\n * only where insert deduplication is enabled: by default on a\n * ReplicatedMergeTree, and on a plain MergeTree only with a positive window.\n *\n * A digest rather than a random uuid, so a redelivery of the same events\n * produces the same token and deduplicates too, as long as it lands inside the\n * table's deduplication window, which counts blocks and not time. The ids\n * travel as a JSON array so that no id can imitate the separator between two\n * of them, and the order is part of the digest because the same rows in\n * another order are another block to ClickHouse.\n *\n * Event ids and nothing else. The server deduplicates on this token INSTEAD of\n * the block contents, and skips a repeated block while reporting success, so\n * two deliveries that share a token are one delivery with no error, no dead\n * letter entry and no counter anywhere: a token that could repeat across\n * different events is silent data loss. The ids are the one input that is\n * always present and unique per event, which the row is not, and they identify\n * the events rather than describing them, so two different events that a\n * mapping happened to shape identically stay two blocks.\n */\nexport function deduplicationToken(ids: readonly string[]): string {\n return createHash('sha256').update(JSON.stringify(ids)).digest('hex');\n}\n\n/**\n * How much of the base delay this wait takes: all of it, plus up to half again.\n * Two runtimes that failed on the same server-side condition would otherwise\n * retry in lockstep and meet it together a second time.\n */\nfunction drawJitter(): number {\n return 1 + Math.random() * (RETRY_JITTER_MAX - 1);\n}\n\nfunction wait(ms: number): Promise<void> {\n return new Promise((resolve) => {\n setTimeout(resolve, ms);\n });\n}\n\n/**\n * Inserts one batch, retrying a transient failure up to `maxRetries` times.\n *\n * One flushed batch is one insert, whatever its size: the rows travel as a\n * single JSONEachRow body, and `format` is fixed because a table written in two\n * formats is a table nobody can read back.\n *\n * The parameters are built once and every attempt sends that same object, so\n * the deduplication token cannot be regenerated halfway through a retry, which\n * is the one thing that would turn a safe retry into duplicated rows.\n *\n * The delays come from the same function the per-attempt request timeout was\n * derived from, so what the loop waits and what the client was configured for\n * cannot drift apart and leave an attempt running past the collector's race.\n *\n * A failure that survives the retries is rethrown as it arrived, ClickHouse\n * code included: the collector dead-letters the whole batch on a throw and\n * keeps the error beside it, and the code is what makes that entry readable.\n */\nexport async function insertRows(\n entries: readonly InsertEntry[],\n settings: Settings,\n logger: Logger.Instance,\n): Promise<void> {\n const { client, table } = settings;\n\n // What counts as a usable retry count is decided in one place for both halves\n // of the mechanism, the loop here and the budget the request timeout was\n // derived from. A count that is not a finite number would otherwise never end\n // this loop, `attempt >= NaN` and `attempt >= Infinity` being false forever,\n // and nothing cancels a destination's work once the collector has stopped\n // waiting for it.\n const retries = normalizeMaxRetries(settings.maxRetries);\n\n // Our defaults first, then what the flow set, then the token: a token an\n // operator could pin would collapse every insert into one deduplicated block\n // and drop every batch after the first without an error anywhere.\n const clickhouse_settings: ClickHouseSettings = {\n ...resolveInsertSettings(settings.clickhouseSettings),\n insert_deduplication_token: deduplicationToken(\n entries.map((entry) => entry.id),\n ),\n };\n\n const params: InsertParams<Readable, InsertRow> = {\n table,\n values: entries.map((entry) => entry.row),\n format: 'JSONEachRow',\n clickhouse_settings,\n };\n\n for (let attempt = 0; ; attempt += 1) {\n logger.debug('ClickHouse insert', { table, rows: entries.length, attempt });\n\n try {\n await client.insert(params);\n\n return;\n } catch (error) {\n // Classified before the budget is read, so the operator hears about a\n // terminal failure even where there was never a retry to skip.\n const retryable = classify(error, logger) === 'retryable';\n\n if (!retryable || attempt >= retries) throw error;\n\n const delay = retryDelay(attempt, drawJitter());\n\n // A finite but absurd retry count passes the clamp above and would spend\n // it on insert attempts against a live server. The delays double, so they\n // leave the finite range on their own after about a thousand retries, and\n // reading that as the end of the loop bounds it without inventing a\n // maximum attempt count or restating the schedule. The budget the request\n // timeout comes from ends on the matching condition: it stops when its\n // running sum stops being finite, this loop when a single delay does.\n if (!Number.isFinite(delay)) throw error;\n\n logger.warn('ClickHouse insert failed, retrying', {\n code: errorCode(error),\n attempt,\n delay,\n });\n\n await wait(delay);\n }\n }\n}\n","import type {\n ClickHouseClientConfigOptions,\n ClickHouseSettings,\n} from '@clickhouse/client';\nimport type { Logger } from '@walkeros/core';\nimport { createClient } from '@clickhouse/client';\nimport type {\n ClickHouseClientFactory,\n Config,\n Credentials,\n Env,\n InitSettings,\n PartialConfig,\n Settings,\n} from './types';\n\n/** Database written to when a flow names none. */\nexport const DEFAULT_DATABASE = 'default';\n\n/** Table written to when a flow names none. */\nexport const DEFAULT_TABLE = 'events';\n\n/** Retries added on top of the first attempt when a flow names none. */\nexport const DEFAULT_MAX_RETRIES = 1;\n\n/** The collector's own per-delivery race, used when `config.timeout` is unset. */\nexport const DEFAULT_TIMEOUT_MS = 10_000;\n\n/** Delay before the first retry. Every further retry doubles it. */\nexport const RETRY_BASE_DELAY_MS = 250;\n\n/** Highest multiplier the retry jitter can apply to a single delay. */\nexport const RETRY_JITTER_MAX = 1.5;\n\n/** Floor for the derived per-attempt timeout. */\nexport const MIN_REQUEST_TIMEOUT_MS = 1_000;\n\n/**\n * `maxRetries` as the retry mechanism agrees to read it: a whole, finite count,\n * never negative. Everything else means no retries at all.\n *\n * A flow's config arrives as JSON and reaches the resolution helpers without\n * passing the schema, so `int().min(0)` does not stand between a config file and\n * this code. `JSON.parse('{\"maxRetries\": 1e999}')` yields `Infinity`, which\n * would spin the budget loop below inside `init`, on the collector's startup\n * path. `NaN` is quieter and reaches the client as a `request_timeout` of `NaN`.\n *\n * Every part of the retry mechanism normalizes through this one function, so the\n * budget the timeout was derived from and the loop that spends it cannot\n * disagree about what a valid count is.\n */\nexport function normalizeMaxRetries(maxRetries: number): number {\n return Number.isFinite(maxRetries) ? Math.max(0, Math.floor(maxRetries)) : 0;\n}\n\n/**\n * How long to wait before the retry at `retry`, counting the first retry as 0:\n * the base delay doubled once per retry, scaled by the jitter.\n *\n * The retry loop and the budget below call this same function, so what the\n * per-attempt timeout was derived from and what the loop actually waits cannot\n * drift apart. A loop passes its own drawn jitter; the budget takes the default,\n * which is the largest jitter that can be drawn.\n */\nexport function retryDelay(\n retry: number,\n jitter: number = RETRY_JITTER_MAX,\n): number {\n return RETRY_BASE_DELAY_MS * 2 ** retry * jitter;\n}\n\n/**\n * Longest total time the retry delays alone can take: every one of the\n * `maxRetries` delays at its longest jittered length. The delays sit between\n * the `1 + maxRetries` attempts, so they spend the same budget the attempts do.\n *\n * The delays double, so they pass what a float can hold after about a thousand\n * of them and the total stops changing. Returning at that point bounds the loop\n * for any count at all, without inventing a maximum number of retries.\n */\nexport function retryDelayBudget(maxRetries: number): number {\n const retries = normalizeMaxRetries(maxRetries);\n let budget = 0;\n\n for (let retry = 0; retry < retries; retry += 1) {\n budget += retryDelay(retry);\n\n if (!Number.isFinite(budget)) return budget;\n }\n\n return budget;\n}\n\n/**\n * Per-request timeout for the client, derived so that every attempt and every\n * retry delay finishes inside the collector's race.\n *\n * The client's own default is 30 s, three times the collector's 10 s. Left\n * there, the collector stops waiting at 10 s, dead-letters the batch and counts\n * it lost while the client is still sending, and the rows land at 12 s anyway:\n * a batch counted lost and actually written. Splitting what the delays leave\n * over across the attempts keeps the last attempt finishing first.\n *\n * The floor keeps a short timeout or a high retry count from deriving a useless\n * or negative value. Such a configuration cannot fit its own retries either\n * way, and the collector's race is what bounds it there. Once the floor fires,\n * the attempts and their delays add up to more than `timeout`, and the\n * collector cuts the later attempts off when it stops waiting.\n */\nexport function resolveRequestTimeout(\n timeout: number,\n maxRetries: number,\n): number {\n // Both arguments are normalized rather than trusted. A retry count of NaN\n // would otherwise carry straight through the arithmetic into a\n // `request_timeout` of NaN, and a non-finite timeout into one of Infinity:\n // a hung insert the collector can only end by dead-lettering the batch.\n const retries = normalizeMaxRetries(maxRetries);\n const attempts = retries + 1;\n\n return Math.max(\n MIN_REQUEST_TIMEOUT_MS,\n Math.floor(\n (resolveTimeout(timeout) - retryDelayBudget(retries)) / attempts,\n ),\n );\n}\n\n/**\n * The collector's per-delivery race, as this destination reads it: a positive,\n * finite number of milliseconds, or the collector's own default.\n */\nexport function resolveTimeout(timeout?: number): number {\n if (timeout === undefined || !Number.isFinite(timeout) || timeout <= 0)\n return DEFAULT_TIMEOUT_MS;\n\n return timeout;\n}\n\n/**\n * ClickHouse settings sent with every insert.\n *\n * `async_insert: 0` keeps the server's acknowledgement tied to a durable write.\n * An async insert is acknowledged before the data reaches the table, and an\n * error raised at the later flush has no way back to the caller, so a lost\n * write would neither retry nor reach the dead letter queue. The server-side\n * default is documented inconsistently, so the value travels explicitly.\n *\n * `input_format_skip_unknown_fields: 0` and `input_format_null_as_default: 0`\n * turn the server's silent repairs off. Both are on by default: the first drops\n * a JSON key the table does not know, the second rewrites an explicit null into\n * the column default. A walkerOS field missing from a partner's table would\n * disappear with no error and no warning, found weeks later in reporting. At 0\n * the whole batch fails loudly, is attributed and reaches the counters.\n *\n * A flow's own `clickhouseSettings` sit on top of all three.\n */\nconst INSERT_SETTINGS: ClickHouseSettings = {\n async_insert: 0,\n input_format_skip_unknown_fields: 0,\n input_format_null_as_default: 0,\n};\n\n/** The per-insert settings, with a flow's own `clickhouseSettings` on top. */\nexport function resolveInsertSettings(\n custom?: ClickHouseSettings,\n): ClickHouseSettings {\n return { ...INSERT_SETTINGS, ...custom };\n}\n\n/**\n * The host of an endpoint, with any userinfo left behind.\n *\n * A url is one of the places a password can legitimately sit\n * (`https://user:pass@host:port`), so the configured string is never safe to\n * put in a log line and only the host ever is. A url the client would reject\n * still reaches here when the factory comes from `env`, which parses nothing.\n */\nfunction endpointHost(url: string): string {\n try {\n return new URL(url).host;\n } catch {\n return 'unparsed';\n }\n}\n\n/** The resolved settings before init attaches the live client. */\ntype ResolvedSettings = Omit<Settings, 'client'>;\n\nfunction withDefaults(\n settings: InitSettings,\n logger: Logger.Instance,\n): ResolvedSettings {\n const { url, clickhouse } = settings;\n\n if (!url) return logger.throw('Config settings url missing');\n\n return {\n url,\n database: settings.database || DEFAULT_DATABASE,\n table: settings.table || DEFAULT_TABLE,\n maxRetries: settings.maxRetries ?? DEFAULT_MAX_RETRIES,\n ...(clickhouse && { clickhouse }),\n clickhouseSettings: resolveInsertSettings(settings.clickhouseSettings),\n };\n}\n\n/**\n * The options `createClient` receives, merged lowest layer first:\n *\n * 1. `settings.clickhouse`, the raw SDK passthrough.\n * 2. The derived defaults, `request_timeout` and request compression, each\n * applied only where the passthrough left its key unset.\n * 3. `url` and `database`, the destination's own first-class settings. They\n * always win: an insert names only the table, so a passthrough `database`\n * that disagreed would move every row to a different database in silence.\n * 4. `credentials`, the strictly typed slot backed by a managed secret. It wins\n * over a username or password left in the passthrough; with the slot empty\n * the passthrough reaches the client untouched.\n *\n * The table, the retry count and the per-insert settings are not client\n * options and stay off this object.\n */\nexport function resolveClientOptions(\n settings: ResolvedSettings,\n credentials: Credentials | undefined,\n timeout: number,\n): ClickHouseClientConfigOptions {\n const options: ClickHouseClientConfigOptions = { ...settings.clickhouse };\n\n if (options.request_timeout === undefined)\n options.request_timeout = resolveRequestTimeout(\n timeout,\n settings.maxRetries,\n );\n\n // Both directions are off by default in this client. Compressing the request\n // is the one that pays here: insert bodies are large, repetitive JSON.\n if (options.compression === undefined)\n options.compression = { request: true };\n\n options.url = settings.url;\n options.database = settings.database;\n\n if (credentials) {\n options.username = credentials.username;\n options.password = credentials.password;\n }\n\n return options;\n}\n\n/**\n * Resolves the config init hands forward, and creates the one client every\n * insert reuses. Nothing is queried: a missing table surfaces at the first\n * insert, as a loud, attributable failure.\n */\nexport function getConfig(\n config: PartialConfig,\n env: Env | undefined,\n logger: Logger.Instance,\n): Config {\n const { settings } = config;\n\n if (!settings) return logger.throw('Config settings missing');\n\n const resolved = withDefaults(settings, logger);\n const options = resolveClientOptions(\n resolved,\n config.credentials,\n resolveTimeout(config.timeout),\n );\n\n const factory: ClickHouseClientFactory =\n env?.ClickHouseClient || createClient;\n const client = factory(options);\n\n // Every credential stays out of this line deliberately, the url included:\n // init is the one place that holds them, and a debug log is the easiest way\n // to spill them.\n logger.debug('ClickHouse client created', {\n host: endpointHost(resolved.url),\n database: resolved.database,\n table: resolved.table,\n });\n\n return { ...config, settings: { ...resolved, client } };\n}\n\n/**\n * The settings the insert path works with.\n *\n * Core resolves `Config.settings` from the init slot, so a push receives the\n * settings as a user wrote them: the client optional, the defaults unapplied.\n * This narrows them once. It guards that init ran and re-applies the defaults,\n * so the insert path holds a live client and a complete configuration without\n * guarding field by field.\n */\nexport function resolveSettings(\n config: PartialConfig,\n logger: Logger.Instance,\n): Settings {\n const { settings } = config;\n\n if (!settings) return logger.throw('Config settings missing, init() not run');\n\n const { client } = settings;\n\n if (!client) return logger.throw('ClickHouse client missing, init() not run');\n\n return { ...withDefaults(settings, logger), client };\n}\n"],"mappings":"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","names":["z","createClient","settings"]}
@@ -89,7 +89,7 @@ var createClient = (config) => {
89
89
  var push = {
90
90
  ClickHouseClient: createClient
91
91
  };
92
- var simulation = ["ClickHouseClient"];
92
+ var simulation = ["call:ClickHouseClient.insert"];
93
93
 
94
94
  // src/examples/step.ts
95
95
  var step_exports = {};
@@ -80,7 +80,7 @@ var createClient = (config) => {
80
80
  var push = {
81
81
  ClickHouseClient: createClient
82
82
  };
83
- var simulation = ["ClickHouseClient"];
83
+ var simulation = ["call:ClickHouseClient.insert"];
84
84
 
85
85
  // src/examples/step.ts
86
86
  var step_exports = {};
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "$meta": {
3
3
  "package": "@walkeros/server-destination-clickhouse",
4
- "version": "4.7.0-next-1790245733116",
4
+ "version": "4.7.0",
5
5
  "type": "destination",
6
6
  "platform": [
7
7
  "server"
8
8
  ],
9
+ "exports": {},
9
10
  "docs": "https://www.walkeros.io/docs/destinations/server/clickhouse",
10
11
  "source": "https://github.com/elbwalker/walkerOS/tree/main/packages/server/destinations/clickhouse/src"
11
12
  },
@@ -75,7 +76,7 @@
75
76
  }
76
77
  },
77
78
  "simulation": [
78
- "ClickHouseClient"
79
+ "call:ClickHouseClient.insert"
79
80
  ]
80
81
  },
81
82
  "step": {
@@ -190,7 +191,7 @@
190
191
  "consent": {
191
192
  "functional": true
192
193
  },
193
- "id": "227a087ec443a757",
194
+ "id": "077f0bd311939381",
194
195
  "trigger": "load",
195
196
  "entity": "page",
196
197
  "action": "view",
@@ -218,7 +219,7 @@
218
219
  "user": "{\"id\":\"us3r\",\"device\":\"c00k13\",\"session\":\"s3ss10n\"}",
219
220
  "nested": "[{\"entity\":\"child\",\"data\":{\"is\":\"subordinated\"}}]",
220
221
  "consent": "{\"functional\":true}",
221
- "id": "227a087ec443a757",
222
+ "id": "077f0bd311939381",
222
223
  "trigger": "load",
223
224
  "entity": "page",
224
225
  "action": "view",
@@ -232,7 +233,7 @@
232
233
  "async_insert": 0,
233
234
  "input_format_skip_unknown_fields": 0,
234
235
  "input_format_null_as_default": 0,
235
- "insert_deduplication_token": "365121f0def37efdf97badda1f76e6750560b8bb2270623d518bfc5e4867fc03"
236
+ "insert_deduplication_token": "23f0bdbaca2a4bdce8a6c7c2dd14f5daab5947b3f961d9b79e9a4023bb54d940"
236
237
  }
237
238
  }
238
239
  ]
@@ -326,7 +327,7 @@
326
327
  "consent": {
327
328
  "functional": true
328
329
  },
329
- "id": "74d996e92eec27cf",
330
+ "id": "6422cf57f3024b57",
330
331
  "trigger": "load",
331
332
  "entity": "order",
332
333
  "action": "complete",
@@ -354,7 +355,7 @@
354
355
  "user": "{\"id\":\"us3r\",\"device\":\"c00k13\",\"session\":\"s3ss10n\"}",
355
356
  "nested": "[{\"entity\":\"product\",\"data\":{\"id\":\"ers\",\"name\":\"Everyday Ruck Snack\",\"color\":\"black\",\"size\":\"l\",\"price\":420},\"context\":{\"shopping\":[\"complete\",0]},\"nested\":[]},{\"entity\":\"product\",\"data\":{\"id\":\"cc\",\"name\":\"Cool Cap\",\"size\":\"one size\",\"price\":42},\"context\":{\"shopping\":[\"complete\",0]},\"nested\":[]},{\"entity\":\"gift\",\"data\":{\"name\":\"Surprise\"},\"context\":{\"shopping\":[\"complete\",0]},\"nested\":[]}]",
356
357
  "consent": "{\"functional\":true}",
357
- "id": "74d996e92eec27cf",
358
+ "id": "6422cf57f3024b57",
358
359
  "trigger": "load",
359
360
  "entity": "order",
360
361
  "action": "complete",
@@ -368,7 +369,7 @@
368
369
  "async_insert": 0,
369
370
  "input_format_skip_unknown_fields": 0,
370
371
  "input_format_null_as_default": 0,
371
- "insert_deduplication_token": "9827bd27c3439ca91e64f31c532fb06d94aa68b65af762390330d1acba715ded"
372
+ "insert_deduplication_token": "722ef45f43bebf84b20ca273b29929e49335761c622a7063e11fbf004dff2568"
372
373
  }
373
374
  }
374
375
  ]
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@walkeros/server-destination-clickhouse",
3
3
  "description": "ClickHouse server destination for walkerOS (bulk JSONEachRow inserts, bounded retry, insert deduplication)",
4
- "version": "4.7.0-next-1790245733116",
4
+ "version": "4.7.0",
5
5
  "license": "MIT",
6
6
  "exports": {
7
7
  ".": {
@@ -36,8 +36,8 @@
36
36
  },
37
37
  "dependencies": {
38
38
  "@clickhouse/client": "1.23.1",
39
- "@walkeros/core": "4.7.0-next-1790245733116",
40
- "@walkeros/server-core": "4.7.0-next-1790245733116"
39
+ "@walkeros/core": "4.7.0",
40
+ "@walkeros/server-core": "4.7.0"
41
41
  },
42
42
  "repository": {
43
43
  "url": "git+https://github.com/elbwalker/walkerOS.git",