@smart-data-engines/sde 0.1.0-dev.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.
Files changed (131) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +13 -0
  3. package/README.md +153 -0
  4. package/bin/weather.mjs +32 -0
  5. package/dist/_usage.d.ts +30 -0
  6. package/dist/_usage.js +194 -0
  7. package/dist/_usage.js.map +1 -0
  8. package/dist/bulk.d.ts +9 -0
  9. package/dist/bulk.js +83 -0
  10. package/dist/bulk.js.map +1 -0
  11. package/dist/canonical.d.ts +46 -0
  12. package/dist/canonical.js +150 -0
  13. package/dist/canonical.js.map +1 -0
  14. package/dist/capabilities.d.ts +48 -0
  15. package/dist/capabilities.js +62 -0
  16. package/dist/capabilities.js.map +1 -0
  17. package/dist/cutover.d.ts +36 -0
  18. package/dist/cutover.js +219 -0
  19. package/dist/cutover.js.map +1 -0
  20. package/dist/demo/model.d.ts +28 -0
  21. package/dist/demo/model.js +40 -0
  22. package/dist/demo/model.js.map +1 -0
  23. package/dist/demo/project.d.ts +19 -0
  24. package/dist/demo/project.js +128 -0
  25. package/dist/demo/project.js.map +1 -0
  26. package/dist/demo/weather.d.ts +73 -0
  27. package/dist/demo/weather.js +334 -0
  28. package/dist/demo/weather.js.map +1 -0
  29. package/dist/engines/_clickhouse-connection.d.ts +17 -0
  30. package/dist/engines/_clickhouse-connection.js +182 -0
  31. package/dist/engines/_clickhouse-connection.js.map +1 -0
  32. package/dist/engines/_tls-peer-identity.d.ts +2 -0
  33. package/dist/engines/_tls-peer-identity.js +23 -0
  34. package/dist/engines/_tls-peer-identity.js.map +1 -0
  35. package/dist/engines/_write-fences.d.ts +51 -0
  36. package/dist/engines/_write-fences.js +189 -0
  37. package/dist/engines/_write-fences.js.map +1 -0
  38. package/dist/engines/clickhouse.d.ts +193 -0
  39. package/dist/engines/clickhouse.js +899 -0
  40. package/dist/engines/clickhouse.js.map +1 -0
  41. package/dist/engines/postgres.d.ts +293 -0
  42. package/dist/engines/postgres.js +981 -0
  43. package/dist/engines/postgres.js.map +1 -0
  44. package/dist/errors.d.ts +89 -0
  45. package/dist/errors.js +90 -0
  46. package/dist/errors.js.map +1 -0
  47. package/dist/frozen-verification.d.ts +26 -0
  48. package/dist/frozen-verification.js +67 -0
  49. package/dist/frozen-verification.js.map +1 -0
  50. package/dist/generation.d.ts +34 -0
  51. package/dist/generation.js +81 -0
  52. package/dist/generation.js.map +1 -0
  53. package/dist/groups.d.ts +17 -0
  54. package/dist/groups.js +66 -0
  55. package/dist/groups.js.map +1 -0
  56. package/dist/hashing.d.ts +68 -0
  57. package/dist/hashing.js +146 -0
  58. package/dist/hashing.js.map +1 -0
  59. package/dist/in-place-index.d.ts +43 -0
  60. package/dist/in-place-index.js +272 -0
  61. package/dist/in-place-index.js.map +1 -0
  62. package/dist/index.d.ts +79 -0
  63. package/dist/index.js +64 -0
  64. package/dist/index.js.map +1 -0
  65. package/dist/inspection.d.ts +19 -0
  66. package/dist/inspection.js +31 -0
  67. package/dist/inspection.js.map +1 -0
  68. package/dist/internal.d.ts +42 -0
  69. package/dist/internal.js +56 -0
  70. package/dist/internal.js.map +1 -0
  71. package/dist/layout.d.ts +36 -0
  72. package/dist/layout.js +62 -0
  73. package/dist/layout.js.map +1 -0
  74. package/dist/migration.d.ts +197 -0
  75. package/dist/migration.js +592 -0
  76. package/dist/migration.js.map +1 -0
  77. package/dist/model.d.ts +93 -0
  78. package/dist/model.js +313 -0
  79. package/dist/model.js.map +1 -0
  80. package/dist/physical.d.ts +128 -0
  81. package/dist/physical.js +421 -0
  82. package/dist/physical.js.map +1 -0
  83. package/dist/placement.d.ts +157 -0
  84. package/dist/placement.js +651 -0
  85. package/dist/placement.js.map +1 -0
  86. package/dist/provisioning.d.ts +6 -0
  87. package/dist/provisioning.js +45 -0
  88. package/dist/provisioning.js.map +1 -0
  89. package/dist/query.d.ts +68 -0
  90. package/dist/query.js +340 -0
  91. package/dist/query.js.map +1 -0
  92. package/dist/routing.d.ts +25 -0
  93. package/dist/routing.js +35 -0
  94. package/dist/routing.js.map +1 -0
  95. package/dist/schema.d.ts +110 -0
  96. package/dist/schema.js +337 -0
  97. package/dist/schema.js.map +1 -0
  98. package/dist/session.d.ts +195 -0
  99. package/dist/session.js +870 -0
  100. package/dist/session.js.map +1 -0
  101. package/dist/shapes.d.ts +30 -0
  102. package/dist/shapes.js +112 -0
  103. package/dist/shapes.js.map +1 -0
  104. package/dist/staging.d.ts +29 -0
  105. package/dist/staging.js +214 -0
  106. package/dist/staging.js.map +1 -0
  107. package/dist/telemetry.d.ts +468 -0
  108. package/dist/telemetry.js +872 -0
  109. package/dist/telemetry.js.map +1 -0
  110. package/dist/testing/loader.d.ts +38 -0
  111. package/dist/testing/loader.js +86 -0
  112. package/dist/testing/loader.js.map +1 -0
  113. package/dist/testing/memory.d.ts +131 -0
  114. package/dist/testing/memory.js +311 -0
  115. package/dist/testing/memory.js.map +1 -0
  116. package/dist/timestamp.d.ts +20 -0
  117. package/dist/timestamp.js +89 -0
  118. package/dist/timestamp.js.map +1 -0
  119. package/dist/types.d.ts +79 -0
  120. package/dist/types.js +100 -0
  121. package/dist/types.js.map +1 -0
  122. package/dist/verification.d.ts +41 -0
  123. package/dist/verification.js +169 -0
  124. package/dist/verification.js.map +1 -0
  125. package/dist/watermark.d.ts +103 -0
  126. package/dist/watermark.js +170 -0
  127. package/dist/watermark.js.map +1 -0
  128. package/dist/write-fence.d.ts +58 -0
  129. package/dist/write-fence.js +225 -0
  130. package/dist/write-fence.js.map +1 -0
  131. package/package.json +86 -0
@@ -0,0 +1,899 @@
1
+ import { UsageGate } from '../_usage.js';
2
+ /**
3
+ * ClickHouse adapter, over the HTTP interface, with no driver dependency at all.
4
+ *
5
+ * **Why this one is written and the PostgreSQL one is not.** There is an official Node client and
6
+ * the reference implementation uses the official Python one, so the symmetry would have been to
7
+ * take it. Measured instead of assumed: `@clickhouse/client` 1.23 requires **Node 20 or newer**,
8
+ * and this package declares Node 18 to 22 and its CI runs all three. The two ways out were to drop
9
+ * Node 18 - narrowing a published claim to gain a dependency - or to pin a superseded version of
10
+ * the client, which is the version conflict the zero-dependency rule exists to avoid. So neither.
11
+ * ClickHouse's HTTP interface needs no client: this runtime has one.
12
+ *
13
+ * Two consequences are worth stating rather than discovering. This adapter owns **both** timeouts
14
+ * instead of inheriting a driver's defaults, which is how the reference implementation found that
15
+ * its ClickHouse `connect_timeout` never fired - the TCP connect succeeds, so what was left was a
16
+ * read timeout whose driver default is 300 seconds. And every type conversion here is ours, which
17
+ * is not extra work: it was already ours in the reference, because a driver's idea of what a
18
+ * `DateTime64` differs from the next driver's.
19
+ *
20
+ * Three things here are genuinely different from the PostgreSQL adapter, and none of them is a
21
+ * detail. Each is a place where "the same operation" means something else because the engine is
22
+ * different, and the product's position is that such a difference must be *stated* rather than
23
+ * smoothed over.
24
+ *
25
+ * **A naive datetime is UTC. Always, explicitly, here.** A `Date` in this runtime is an instant, so
26
+ * there is no naive case to get wrong on the way in - but there is on the way *out*: ClickHouse
27
+ * returns `DateTime64(6, 'UTC')` as `2026-08-27 12:00:00.123456`, with no zone in the text. Read as
28
+ * local time that is a different instant on every machine, and after a migration between engines
29
+ * every timestamp in the client's analytics would shift by the offset of whichever host happened to
30
+ * write the row. The reference measured that divergence at two hours with no error anywhere.
31
+ *
32
+ * **There are no transactions, and this adapter says so instead of pretending.** `transaction()`
33
+ * throws. Not a no-op that runs the body: a caller who believes they are in a transaction and is
34
+ * not has been lied to at the worst possible moment.
35
+ *
36
+ * **Keys are not enforced, so the table is a `ReplacingMergeTree` and point reads use `FINAL`.**
37
+ * A MergeTree does not enforce uniqueness on its `ORDER BY`, so a second write of the same key does
38
+ * not raise the way a primary key does - and that divergence cannot be removed, only chosen. Plain
39
+ * `MergeTree` would leave two rows and make every aggregate over that group quietly wrong;
40
+ * `ReplacingMergeTree` keeps the newest and collapses the rest at merge time. `FINAL` on point reads
41
+ * and counts is what makes the collapse visible immediately rather than eventually, and it is not
42
+ * free - which is the trade, stated.
43
+ */
44
+ import { request as httpRequest } from 'node:http';
45
+ import { request as httpsRequest } from 'node:https';
46
+ import { parseDsn, verifiedTls } from './_clickhouse-connection.js';
47
+ import { compareCodePoints } from '../canonical.js';
48
+ import { batchColumns } from '../bulk.js';
49
+ import { readRow, readSql, summarySql } from '../query.js';
50
+ import { EngineError } from '../errors.js';
51
+ import { exactBytes } from '../telemetry.js';
52
+ import { Timestamp } from '../timestamp.js';
53
+ import { WriteFence } from '../write-fence.js';
54
+ import { ClickHouseFences, fenceIO } from './_write-fences.js';
55
+ import { keyColumns, sameWidth } from '../migration.js';
56
+ import { declaredTables, parseIdentifierList, parsePartitionKey } from '../physical.js';
57
+ import { BACKFILL_TABLE, WATERMARK_TABLE } from '../placement.js';
58
+ import { QUOTE, schemaStatements } from '../schema.js';
59
+ const quote = QUOTE['clickhouse'];
60
+ /** Milliseconds, and the same argument as the PostgreSQL adapter's constant of the same name. */
61
+ export const CONNECT_TIMEOUT_MS = 10_000;
62
+ /**
63
+ * Milliseconds, for the version exchange `connect()` performs.
64
+ *
65
+ * Set to something different from the connect bound on purpose. **Opening is bounded tightly;
66
+ * reading is not, past this exchange** - an analytical query legitimately takes minutes and a
67
+ * library that timed it out would be deciding something about the caller's workload. The handshake
68
+ * is the one exchange this library knows the shape of.
69
+ *
70
+ * The reference implementation reaches the same place through its driver's `send_receive_timeout`,
71
+ * and that is measured rather than assumed: with that bound at 15 seconds, a 23-second and a
72
+ * 24-second query both returned normally, so the bound does not cut a slow query. Here the
73
+ * distinction is structural instead - only the handshake request carries a timeout - which is one
74
+ * fewer thing to be right about.
75
+ */
76
+ export const HANDSHAKE_TIMEOUT_MS = 15_000;
77
+ /**
78
+ * One value, converted from ClickHouse's JSON text into what this library promises, using the
79
+ * column type the server reported.
80
+ *
81
+ * The type comes from the response's own `meta` block rather than from the layout, for the reason
82
+ * the PostgreSQL adapter reads field OIDs: the server is the authority on what it stored, and a map
83
+ * can be stale in a way a response cannot.
84
+ *
85
+ * Four conversions, and each one is the same decision the PostgreSQL adapter makes, arrived at from
86
+ * the other side - which is what makes the two agree.
87
+ */
88
+ function convert(value, type) {
89
+ if (value === null || value === undefined)
90
+ return null;
91
+ const bare = type.replace(/^Nullable\((.*)\)$/, '$1').replace(/^LowCardinality\((.*)\)$/, '$1');
92
+ if (/^U?Int(64|128|256)/.test(bare)) {
93
+ // A string in ClickHouse's JSON, because a JSON number cannot hold an int64 - and a `number`
94
+ // here would lose precision above 2^53, which is not exotic for an identifier column.
95
+ if (typeof value !== 'string')
96
+ throw new Error('ClickHouse returned an unquoted exact integer');
97
+ return BigInt(value);
98
+ }
99
+ if (bare.startsWith('Decimal')) {
100
+ // No exact decimal in this runtime, and turning `12.34` into a float is the one conversion this
101
+ // library must never do quietly: the value comes back changed and nothing raises.
102
+ if (typeof value !== 'string')
103
+ throw new Error('ClickHouse returned an unquoted exact decimal');
104
+ return value;
105
+ }
106
+ if (bare.startsWith('DateTime')) {
107
+ // `2026-08-27 12:00:00.000`, with no zone in the text. Read as local time it is a different
108
+ // instant on every machine, so the zone is supplied here - and it is UTC because that is what
109
+ // the column declares and what the PostgreSQL side stores.
110
+ return Timestamp.from(String(value));
111
+ }
112
+ if (bare.startsWith('Date')) {
113
+ // A calendar date has no time and no zone. `YYYY-MM-DD` says exactly that, and the PostgreSQL
114
+ // adapter renders its own `date` back to the same form for the same reason.
115
+ return String(value);
116
+ }
117
+ if (bare.startsWith('Bool'))
118
+ return Boolean(value);
119
+ return value;
120
+ }
121
+ /** One value on the way *in*, as a JSON-safe form ClickHouse will accept. */
122
+ function outbound(value) {
123
+ if (typeof value === 'bigint')
124
+ return value.toString();
125
+ if (value instanceof Timestamp || value instanceof Date) {
126
+ // Date remains a valid millisecond-resolution input; Timestamp retains six digits.
127
+ // Both serialize UTC parts, independently of the process timezone.
128
+ return value.toISOString().replace('T', ' ').replace('Z', '');
129
+ }
130
+ return value;
131
+ }
132
+ export class ClickHouseEngine {
133
+ dialect = 'clickhouse';
134
+ usage = new UsageGate();
135
+ target;
136
+ trust = {};
137
+ open = false;
138
+ serverVersion = null;
139
+ constructor(dsn) {
140
+ this.target = parseDsn(dsn);
141
+ }
142
+ /**
143
+ * Ask the server what it is, with a bound on how long that may take.
144
+ *
145
+ * The equivalent of the reference implementation's driver handshake, and it exists for the same
146
+ * measurement: a host that accepts the TCP connection and never answers hung a call for as long
147
+ * as the test would wait. Here the reason is one layer up - the TCP connect succeeds, so a connect
148
+ * bound never fires, and what is left is the wait for a response.
149
+ */
150
+ async connect() {
151
+ return this.usage.operation(async () => {
152
+ if (this.open)
153
+ return;
154
+ try {
155
+ this.trust = this.target.secure ? verifiedTls(this.target) : {};
156
+ const result = await this.send('SELECT version()', {
157
+ timeoutMs: Math.ceil(this.target.send_receive_timeout * 1000),
158
+ format: 'JSON',
159
+ });
160
+ const parsed = JSON.parse(result);
161
+ this.serverVersion = String(Object.values(parsed.data?.[0] ?? {})[0] ?? '');
162
+ }
163
+ catch (error) {
164
+ throw new EngineError(`could not connect to ClickHouse: ${message(error)}`);
165
+ }
166
+ this.open = true;
167
+ });
168
+ }
169
+ async close() {
170
+ return this.usage.operation(async () => {
171
+ // Nothing to close: every request is its own HTTP exchange on an agent this adapter does not
172
+ // keep alive. Present so that the shape matches the other adapter - a caller writing
173
+ // `close()` in a `finally` should not have to know which engine they have.
174
+ this.open = false;
175
+ });
176
+ }
177
+ /** What the server said it is, or null before `connect()`. Read by nothing; useful in a report. */
178
+ get version() {
179
+ return this.serverVersion;
180
+ }
181
+ ensureOpen() {
182
+ if (!this.open)
183
+ throw new EngineError('not connected; call connect() first');
184
+ }
185
+ /**
186
+ * One HTTP exchange.
187
+ *
188
+ * The handshake has a receive-inactivity default. Later queries retain their unbounded
189
+ * default unless the caller supplies send_receive_timeout. Neither is an absolute execution
190
+ * deadline; server-side max_execution_time can constrain the workload itself.
191
+ */
192
+ send(sql, options = {}) {
193
+ return this.usage.operation(async () => {
194
+ const query = options.format === undefined ? sql : `${sql} FORMAT ${options.format}`;
195
+ const search = new URLSearchParams({ database: this.target.database });
196
+ if (options.format === 'JSON') {
197
+ // JSON.parse would round native numeric tokens before convert() can inspect their type.
198
+ // Pin the wire contract per request, independently of server/version/profile defaults.
199
+ search.set('output_format_json_quote_64bit_integers', '1');
200
+ search.set('output_format_json_quote_decimals', '1');
201
+ search.set('output_format_decimal_trailing_zeros', '1');
202
+ }
203
+ if (options.body === undefined)
204
+ search.set('query', query);
205
+ else
206
+ search.set('query', query);
207
+ const path = `/?${search.toString()}`;
208
+ const payload = options.body ?? '';
209
+ const requestFn = this.target.protocol === 'https:' ? httpsRequest : httpRequest;
210
+ const inactivity = options.timeoutMs ?? (this.target.receive_timeout_supplied
211
+ ? Math.ceil(this.target.send_receive_timeout * 1000) : undefined);
212
+ return new Promise((resolve, reject) => {
213
+ const req = requestFn({
214
+ ...this.trust,
215
+ protocol: this.target.protocol,
216
+ host: this.target.host,
217
+ port: this.target.port,
218
+ method: 'POST',
219
+ path,
220
+ headers: {
221
+ Authorization: `Basic ${Buffer.from(`${this.target.username}:${this.target.password}`, 'utf8').toString('base64')}`,
222
+ 'Content-Type': 'text/plain; charset=utf-8',
223
+ 'Content-Length': Buffer.byteLength(payload),
224
+ },
225
+ ...(inactivity === undefined ? {} : { timeout: inactivity }),
226
+ }, (response) => {
227
+ const chunks = [];
228
+ response.on('data', (chunk) => chunks.push(chunk));
229
+ response.on('end', () => {
230
+ const text = Buffer.concat(chunks).toString('utf8');
231
+ const status = response.statusCode ?? 0;
232
+ if (status >= 200 && status < 300)
233
+ resolve(text);
234
+ // ClickHouse puts its own message in the body, and it names the column or the setting.
235
+ // A summary of ours would lose exactly that. The numeric code rides along for the one
236
+ // caller that has to tell a missing grant from every other failure.
237
+ else {
238
+ const header = response.headers['x-clickhouse-exception-code'];
239
+ const code = typeof header === 'string' && /^\d+$/.test(header)
240
+ ? Number(header) : Number(/^Code: (\d+)\./.exec(text.trim())?.[1] ?? Number.NaN);
241
+ reject(Object.assign(new Error(`ClickHouse answered ${status}: ${text.trim()}`), Number.isInteger(code) ? { clickhouseCode: code } : {}));
242
+ }
243
+ });
244
+ });
245
+ // `timeout` on the options bounds socket inactivity, which covers a host that accepts the
246
+ // connection and then says nothing - the case this exists for. The socket has to be destroyed
247
+ // explicitly: Node emits the event and leaves the request open otherwise.
248
+ req.on('timeout', () => {
249
+ req.destroy(new Error(`no answer within ${inactivity} ms. The socket was accepted, so this is a host ` +
250
+ `that took the connection and did not answer - a firewall that accepts, a load ` +
251
+ `balancer with no healthy backend, a server mid-restart.`));
252
+ });
253
+ const connecting = setTimeout(() => req.destroy(new Error('ClickHouse connection establishment timed out')), Math.ceil(this.target.connect_timeout * 1000));
254
+ const connected = () => { clearTimeout(connecting); };
255
+ req.on('socket', (socket) => {
256
+ if ((!this.target.secure && !socket.connecting) ||
257
+ (this.target.secure && socket.authorized === true))
258
+ connected();
259
+ else
260
+ socket.once(this.target.secure ? 'secureConnect' : 'connect', connected);
261
+ socket.once('error', connected);
262
+ });
263
+ req.once('close', connected);
264
+ req.on('error', (error) => { connected(); reject(error); });
265
+ req.end(payload);
266
+ });
267
+ });
268
+ }
269
+ async query(sql) {
270
+ this.ensureOpen();
271
+ const text = await this.send(sql, { format: 'JSON' });
272
+ const parsed = JSON.parse(text);
273
+ const types = new Map((parsed.meta ?? []).map((column) => [column.name, column.type]));
274
+ return (parsed.data ?? []).map((row) => {
275
+ const out = {};
276
+ for (const [name, value] of Object.entries(row)) {
277
+ out[name] = convert(value, types.get(name) ?? 'String');
278
+ }
279
+ return out;
280
+ });
281
+ }
282
+ async command(sql) {
283
+ this.ensureOpen();
284
+ await this.send(sql);
285
+ }
286
+ /** DDL capability; use a dedicated connection separate from application traffic. */
287
+ writeFence(table, options) {
288
+ return new WriteFence(new ClickHouseFences({
289
+ query: (sql) => fenceIO(() => this.query(sql)), command: (sql) => fenceIO(() => this.command(sql)),
290
+ literal,
291
+ }), table, options);
292
+ }
293
+ // --- schema ------------------------------------------------------------------------------
294
+ async ensureSchema(layout, options) {
295
+ return this.usage.operation(async () => {
296
+ const statements = schemaStatements(layout, { keys: options.keys, dialect: this.dialect });
297
+ for (const statement of statements) {
298
+ try {
299
+ await this.command(statement);
300
+ }
301
+ catch (error) {
302
+ throw new EngineError(`schema statement failed: ${statement}: ${message(error)}`);
303
+ }
304
+ }
305
+ await this.verifySchema(layout);
306
+ // Returned, not refused: whether a physical difference is a refusal (a person provisioning)
307
+ // or a report (a running session) is the caller's decision. Columns and types refuse above.
308
+ return this.physicalFindings(layout, options.keys);
309
+ });
310
+ }
311
+ /**
312
+ * Check that what exists is what the map describes, for the reason the PostgreSQL adapter does:
313
+ * `CREATE TABLE IF NOT EXISTS` accepts a table of that name whatever shape it is in.
314
+ *
315
+ * A missing column is refused; an extra one is allowed, because a client may have added one
316
+ * outside SDE and the map has no opinion about it.
317
+ *
318
+ * **Types too, and here they need no normalising at all.** Measured against every type this
319
+ * library renders: `system.columns.type` returns the exact string we wrote, down to the space in
320
+ * `Decimal(12, 2)` and the quotes in `DateTime64(6, 'UTC')`. So the comparison is literal, and
321
+ * it is literal on purpose - a renderer that started emitting a different spelling of the same
322
+ * type would fail this, which is the right way round for a document we sign.
323
+ */
324
+ /** Check existing physical columns without issuing DDL; report the physical design. */
325
+ async validateSchema(layout, options = {}) {
326
+ return this.usage.operation(async () => {
327
+ await this.verifySchema(layout);
328
+ return options.keys === undefined ? [] : this.physicalFindings(layout, options.keys);
329
+ });
330
+ }
331
+ /**
332
+ * Sort key, partition and data-skipping indexes as the catalogue reports them.
333
+ *
334
+ * The catalogue formats expressions, so they are parsed into names (`parseIdentifierList`)
335
+ * rather than compared with a string we predict: 24.8 leaves `select` bare and quotes `null`, and
336
+ * a formatting rule that moved between releases would turn a correct table into a refusal. An
337
+ * expression the parser does not understand is reported as it stands.
338
+ *
339
+ * `system.tables` is filtered by the login's own table privileges, so a restricted runtime login
340
+ * reads it without a grant. `system.data_skipping_indices` needs an explicit one (measured, 24.8:
341
+ * code 497), so it is read only when a table declares an index, and a login without the grant
342
+ * gets "unverified" rather than a failed session - a check about performance must not become the
343
+ * outage requirement 3.6 forbids. Provisioning refuses on it, since "could not look" is not
344
+ * "looked and agreed".
345
+ */
346
+ async physicalFindings(layout, keys) {
347
+ const declared = declaredTables(layout, keys);
348
+ if (declared.length === 0)
349
+ return [];
350
+ const tables = declared.map((entry) => literal(entry.table)).join(', ');
351
+ const described = await this.query('SELECT name, sorting_key, partition_key FROM system.tables ' +
352
+ `WHERE database = currentDatabase() AND name IN (${tables})`);
353
+ const keysFound = new Map();
354
+ for (const row of described) {
355
+ keysFound.set(String(row['name']), [String(row['sorting_key']), String(row['partition_key'])]);
356
+ }
357
+ const present = new Map();
358
+ let unreadable = '';
359
+ const indexed = declared
360
+ .filter((entry) => entry.indexes.length > 0)
361
+ .map((entry) => entry.table)
362
+ .sort(compareCodePoints);
363
+ if (indexed.length > 0) {
364
+ let rows = [];
365
+ try {
366
+ rows = await this.query('SELECT table, name, type_full, expr, granularity FROM system.data_skipping_indices ' +
367
+ `WHERE database = currentDatabase() AND table IN (${indexed.map(literal).join(', ')})`);
368
+ }
369
+ catch (error) {
370
+ if (error.clickhouseCode !== 497)
371
+ throw error;
372
+ unreadable =
373
+ 'unverified: this login cannot read system.data_skipping_indices ' +
374
+ '(GRANT SELECT ON system.data_skipping_indices to verify it)';
375
+ }
376
+ for (const row of rows) {
377
+ const table = String(row['table']);
378
+ const byName = present.get(table) ?? new Map();
379
+ byName.set(String(row['name']), [String(row['type_full']), String(row['expr']), Number(row['granularity'])]);
380
+ present.set(table, byName);
381
+ }
382
+ }
383
+ const same = (a, b) => a.length === b.length && a.every((value, position) => value === b[position]);
384
+ const findings = [];
385
+ for (const entry of declared) {
386
+ const found = keysFound.get(entry.table);
387
+ if (found === undefined)
388
+ continue; // verifySchema has already refused a missing table
389
+ const [sorting, partition] = found;
390
+ let foundKey;
391
+ try {
392
+ foundKey = parseIdentifierList(sorting);
393
+ }
394
+ catch {
395
+ foundKey = null;
396
+ }
397
+ if (foundKey === null || !same(foundKey, entry.key)) {
398
+ findings.push({
399
+ table: entry.table,
400
+ aspect: 'sort key',
401
+ declared: JSON.stringify(entry.key),
402
+ found: foundKey === null ? JSON.stringify(sorting) : JSON.stringify(foundKey),
403
+ });
404
+ }
405
+ let foundPartition;
406
+ try {
407
+ foundPartition = parsePartitionKey(partition);
408
+ }
409
+ catch {
410
+ foundPartition = undefined;
411
+ }
412
+ const declaredPartition = entry.partition;
413
+ const partitionMatches = foundPartition !== undefined &&
414
+ (foundPartition === null
415
+ ? declaredPartition === null
416
+ : declaredPartition !== null &&
417
+ foundPartition[0] === declaredPartition[0] &&
418
+ foundPartition[1] === declaredPartition[1]);
419
+ if (!partitionMatches) {
420
+ findings.push({
421
+ table: entry.table,
422
+ aspect: 'partition',
423
+ declared: JSON.stringify(declaredPartition),
424
+ found: foundPartition === undefined ? JSON.stringify(partition) : JSON.stringify(foundPartition),
425
+ });
426
+ }
427
+ for (const index of entry.indexes) {
428
+ const wanted = `${index.typeFull} on ${JSON.stringify(index.columns)} granularity ${String(index.granularity)}`;
429
+ const got = present.get(entry.table)?.get(index.name);
430
+ if (got === undefined) {
431
+ findings.push({
432
+ table: entry.table,
433
+ aspect: `index ${index.name}`,
434
+ declared: wanted,
435
+ found: unreadable === '' ? 'absent' : unreadable,
436
+ });
437
+ continue;
438
+ }
439
+ const [typeFull, expr, granularity] = got;
440
+ let columns;
441
+ try {
442
+ columns = parseIdentifierList(expr);
443
+ }
444
+ catch {
445
+ columns = null;
446
+ }
447
+ if (typeFull !== index.typeFull || columns === null || !same(columns, index.columns) || granularity !== index.granularity) {
448
+ findings.push({
449
+ table: entry.table,
450
+ aspect: `index ${index.name}`,
451
+ declared: wanted,
452
+ found: `${typeFull} on ${columns === null ? JSON.stringify(expr) : JSON.stringify(columns)} granularity ${granularity}`,
453
+ });
454
+ }
455
+ }
456
+ }
457
+ return findings;
458
+ }
459
+ async verifySchema(layout) {
460
+ const expected = new Map();
461
+ for (const [entity, table] of Object.entries(layout.tables)) {
462
+ expected.set(table, layout.columns[entity] ?? {});
463
+ }
464
+ if (expected.size === 0)
465
+ return;
466
+ const names = [...expected.keys()].sort(compareCodePoints);
467
+ // Through `literal`, not through a second escaper. The first version of this line inlined one
468
+ // that escaped a quote and **not** a backslash, so a table name ending in one would have
469
+ // closed the string early - and a table name is not always ours: a hand-written map is a
470
+ // supported mode, and an unhashed entity name can contain anything. Found by CodeQL
471
+ // (`js/incomplete-sanitization`), which is the same finding as the module docstring's rule
472
+ // about quoting identifiers in one place, arriving one level down.
473
+ const list = names.map(literal).join(', ');
474
+ const rows = await this.query(`SELECT table, name, type FROM system.columns WHERE database = currentDatabase() ` +
475
+ `AND table IN (${list})`);
476
+ const found = new Map();
477
+ for (const row of rows) {
478
+ const table = String(row['table']);
479
+ const columns = found.get(table) ?? new Map();
480
+ columns.set(String(row['name']), String(row['type']));
481
+ found.set(table, columns);
482
+ }
483
+ for (const table of names) {
484
+ const actual = found.get(table);
485
+ if (actual === undefined) {
486
+ throw new EngineError(`'${table}' does not exist after applying the schema. The statement reported success, ` +
487
+ `so this is a permissions or database problem rather than a bad map.`);
488
+ }
489
+ const columns = expected.get(table);
490
+ const missing = Object.keys(columns)
491
+ .filter((column) => !actual.has(column))
492
+ .sort();
493
+ if (missing.length > 0) {
494
+ throw new EngineError(`'${table}' already existed with a different shape: the map needs ` +
495
+ `[${missing.join(', ')}] and the table has ` +
496
+ `[${[...actual.keys()].sort().join(', ')}]. ` +
497
+ `CREATE TABLE IF NOT EXISTS keeps whatever is there, so this table came from ` +
498
+ `somewhere else. Refusing here rather than at the first insert.`);
499
+ }
500
+ for (const column of Object.keys(columns).sort()) {
501
+ const declared = columns[column];
502
+ const reported = actual.get(column);
503
+ if (reported === declared)
504
+ continue;
505
+ throw new EngineError(`${table}.${column} is '${reported}' and this map declares it '${declared}'. ` +
506
+ `CREATE TABLE IF NOT EXISTS keeps a table of that name whatever shape it is in, and ` +
507
+ `this library never alters a column's type - so the table came from somewhere else, ` +
508
+ `or from a map that rendered this column differently. Refusing rather than writing ` +
509
+ `into it: with a timestamp the difference is usually precision, and a write that ` +
510
+ `succeeds and comes back rounded is worse than one that fails.`);
511
+ }
512
+ }
513
+ }
514
+ // --- data --------------------------------------------------------------------------------
515
+ async insertRows(table, rows) {
516
+ if (rows.length === 0)
517
+ return;
518
+ const columns = Object.keys(rows[0]).sort();
519
+ const body = rows
520
+ .map((row) => {
521
+ const out = {};
522
+ for (const column of columns)
523
+ out[column] = outbound(row[column]);
524
+ return JSON.stringify(out);
525
+ })
526
+ .join('\n');
527
+ this.ensureOpen();
528
+ await this.send(`INSERT INTO ${quote(table)} (${columns.map(quote).join(', ')}) FORMAT JSONEachRow`, { body });
529
+ }
530
+ async insert(table, values) {
531
+ return this.usage.operation(async () => {
532
+ if (Object.keys(values).length === 0)
533
+ throw new EngineError('nothing to insert');
534
+ try {
535
+ await this.insertRows(table, [values]);
536
+ }
537
+ catch (error) {
538
+ throw new EngineError(`insert into ${table} failed: ${message(error)}`);
539
+ }
540
+ });
541
+ }
542
+ /**
543
+ * One row by key, with `FINAL` so a superseded row is never returned.
544
+ *
545
+ * Without `FINAL` a key that has been saved twice returns whichever duplicate the scan reaches
546
+ * first until a merge happens - which is to say, nondeterministically the old value. Paying for
547
+ * `FINAL` on a point read is the cheaper half of that trade.
548
+ */
549
+ /** One HTTP batch, without a cross-block transactional guarantee. */
550
+ async insertMany(table, rows) {
551
+ return this.usage.operation(async () => {
552
+ const columns = batchColumns(rows);
553
+ if (columns.length === 0)
554
+ return;
555
+ try {
556
+ await this.insertRows(table, rows);
557
+ }
558
+ catch (error) {
559
+ throw new EngineError(`batch insert into ${table} failed: ${message(error)}`);
560
+ }
561
+ });
562
+ }
563
+ async get(table, key) {
564
+ return this.usage.operation(async () => {
565
+ const columns = Object.keys(key).sort();
566
+ const where = columns.map((column) => `${quote(column)} = ${literal(key[column])}`).join(' AND ');
567
+ try {
568
+ const rows = await this.query(`SELECT * FROM ${quote(table)} FINAL WHERE ${where} LIMIT 1`);
569
+ return rows[0] ?? null;
570
+ }
571
+ catch (error) {
572
+ throw new EngineError(`select from ${table} failed: ${message(error)}`);
573
+ }
574
+ });
575
+ }
576
+ async selectRows(table, plan) {
577
+ return this.usage.operation(async () => {
578
+ const statement = readSql(table, plan, this.dialect, literal);
579
+ try {
580
+ const rows = await this.query(statement);
581
+ for (const row of rows)
582
+ for (const column of plan.columns) {
583
+ const value = row[column.name];
584
+ if (value !== null && ['float32', 'float64'].includes(column.type)) {
585
+ const text = String(value).toLowerCase();
586
+ row[column.name] = text === 'inf' || text === '+inf' ? Infinity : text === '-inf' ? -Infinity : Number(text);
587
+ }
588
+ }
589
+ return rows.map(row => readRow(plan.columns, row));
590
+ }
591
+ catch (error) {
592
+ throw new EngineError('logical scan of ' + table + ' failed: ' + message(error));
593
+ }
594
+ });
595
+ }
596
+ async countRows(table, plan) {
597
+ return this.usage.operation(async () => {
598
+ const statement = readSql(table, plan, this.dialect, literal, true);
599
+ try {
600
+ const row = (await this.query(statement))[0];
601
+ if (row === undefined || typeof row['sde_count'] !== 'string')
602
+ throw new EngineError('count query returned no exact result');
603
+ return BigInt(row['sde_count']);
604
+ }
605
+ catch (error) {
606
+ throw new EngineError('logical count of ' + table + ' failed: ' + message(error));
607
+ }
608
+ });
609
+ }
610
+ async summarizeRows(table, plan, column) {
611
+ return this.usage.operation(async () => {
612
+ const statement = summarySql(table, plan, column, this.dialect, literal);
613
+ try {
614
+ const row = (await this.query(statement))[0];
615
+ if (row === undefined)
616
+ throw new EngineError('summary query returned no result');
617
+ return row;
618
+ }
619
+ catch (error) {
620
+ throw new EngineError('logical summary of ' + table + ' failed: ' + message(error));
621
+ }
622
+ });
623
+ }
624
+ /**
625
+ * Each table's bytes and its secondary index bytes, from active parts. Numbers only.
626
+ *
627
+ * One statement: `system.tables` says which names exist - readable by a runtime login without
628
+ * any grant, and only for its own tables - and `system.parts` what their active parts occupy,
629
+ * which needs the column grant in `STORAGE_COLUMNS`. An empty table has no parts and reads 0
630
+ * bytes; a missing one is absent, because a missing table is not an empty one.
631
+ */
632
+ async storageSizes(tables) {
633
+ return this.usage.operation(async () => {
634
+ if (tables.length === 0)
635
+ return new Map();
636
+ const names = tables.map((table) => literal(table)).join(', ');
637
+ const sql = 'SELECT t.name AS name, toString(sum(p.bytes_on_disk)) AS total, ' +
638
+ 'toString(sum(p.secondary_indices_compressed_bytes + p.secondary_indices_marks_bytes)) ' +
639
+ 'AS secondary FROM system.tables AS t LEFT JOIN (' +
640
+ 'SELECT table, bytes_on_disk, secondary_indices_compressed_bytes, ' +
641
+ 'secondary_indices_marks_bytes FROM system.parts ' +
642
+ 'WHERE active AND database = currentDatabase()' +
643
+ ') AS p ON p.table = t.name ' +
644
+ `WHERE t.database = currentDatabase() AND t.name IN (${names}) ` +
645
+ 'GROUP BY t.name SETTINGS join_use_nulls = 0';
646
+ try {
647
+ const rows = await this.query(sql);
648
+ return new Map(rows.map((row) => [
649
+ String(row['name']),
650
+ [exactBytes(row['total']), exactBytes(row['secondary'])],
651
+ ]));
652
+ }
653
+ catch (error) {
654
+ throw new EngineError('storage sizes could not be read: ' + message(error), { cause: error });
655
+ }
656
+ });
657
+ }
658
+ async count(table) {
659
+ return this.usage.operation(async () => {
660
+ try {
661
+ const rows = await this.query(`SELECT count() AS n FROM ${quote(table)} FINAL`);
662
+ return Number(rows[0]?.['n'] ?? 0);
663
+ }
664
+ catch (error) {
665
+ throw new EngineError(`count on ${table} failed: ${message(error)}`);
666
+ }
667
+ });
668
+ }
669
+ // --- rollback protection ------------------------------------------------------------------
670
+ //
671
+ // Append-only and `max()`, which is what makes this identical in both engines: no key to enforce,
672
+ // no row to update, nothing to contend over.
673
+ async mapWatermark() {
674
+ return this.usage.operation(async () => {
675
+ try {
676
+ const existing = await this.query(`EXISTS TABLE ${quote(WATERMARK_TABLE)}`);
677
+ const present = Number(existing[0]?.['result']);
678
+ if (present !== 0 && present !== 1) {
679
+ throw new EngineError('watermark catalog lookup returned no presence result');
680
+ }
681
+ if (present === 0) {
682
+ await this.command(`CREATE TABLE IF NOT EXISTS ${quote(WATERMARK_TABLE)} (` +
683
+ `${quote('map_version')} Int64, ${quote('model_version')} String, ` +
684
+ `${quote('seen_at')} DateTime64(3, 'UTC') DEFAULT now64(3)) ` +
685
+ `ENGINE = MergeTree ORDER BY (${quote('map_version')})`);
686
+ }
687
+ const rows = await this.query(`SELECT max(${quote('map_version')}) AS high, count() AS n FROM ${quote(WATERMARK_TABLE)}`);
688
+ const row = rows[0];
689
+ if (row === undefined || Number(row['n']) === 0)
690
+ return null;
691
+ return Number(row['high']);
692
+ }
693
+ catch (error) {
694
+ throw new EngineError(`reading ${WATERMARK_TABLE} failed: ${message(error)}`);
695
+ }
696
+ });
697
+ }
698
+ async recordMapVersion(version, options) {
699
+ return this.usage.operation(async () => {
700
+ try {
701
+ await this.insertRows(WATERMARK_TABLE, [
702
+ { map_version: version, model_version: options.modelVersion },
703
+ ]);
704
+ }
705
+ catch (error) {
706
+ throw new EngineError(`recording a map version in ${WATERMARK_TABLE} failed: ${message(error)}`);
707
+ }
708
+ });
709
+ }
710
+ // --- migration ---------------------------------------------------------------------------
711
+ /**
712
+ * Rows in key order, strictly after one key and up to another inclusive, with `FINAL`.
713
+ *
714
+ * `FINAL` here is not tidiness: without it a key saved twice returns two rows until a merge
715
+ * happens, and the next page starts strictly after that key - so one of the duplicates is read
716
+ * and the other is not, which for a backfill means copying a row this engine considers
717
+ * superseded.
718
+ */
719
+ async keyRange(table, order, options = {}) {
720
+ return this.usage.operation(async () => {
721
+ const cols = keyColumns(order, table);
722
+ const tuple = `(${cols.map(quote).join(', ')})`;
723
+ const clauses = [];
724
+ if (options.after !== undefined) {
725
+ sameWidth(options.after, cols, 'after');
726
+ clauses.push(`${tuple} > (${options.after.map(literal).join(', ')})`);
727
+ }
728
+ if (options.upto !== undefined) {
729
+ sameWidth(options.upto, cols, 'upto');
730
+ clauses.push(`${tuple} <= (${options.upto.map(literal).join(', ')})`);
731
+ }
732
+ const where = clauses.length > 0 ? ` WHERE ${clauses.join(' AND ')}` : '';
733
+ const cap = options.limit === undefined ? '' : ` LIMIT ${Number(options.limit)}`;
734
+ try {
735
+ return await this.query(`SELECT * FROM ${quote(table)} FINAL${where} ORDER BY ${tuple}${cap}`);
736
+ }
737
+ catch (error) {
738
+ throw new EngineError(`key range select from ${table} failed: ${message(error)}`);
739
+ }
740
+ });
741
+ }
742
+ async nthKey(table, order, options) {
743
+ return this.usage.operation(async () => {
744
+ const cols = keyColumns(order, table);
745
+ if (options.position < 1) {
746
+ throw new EngineError(`position is one-based; ${options.position} is not a row`);
747
+ }
748
+ const projection = cols.map(quote).join(', ');
749
+ try {
750
+ const rows = await this.query(`SELECT ${projection} FROM ${quote(table)} FINAL ORDER BY (${projection}) ` +
751
+ `LIMIT 1 OFFSET ${Number(options.position) - 1}`);
752
+ const row = rows[0];
753
+ if (row === undefined)
754
+ return null;
755
+ return cols.map((column) => row[column]);
756
+ }
757
+ catch (error) {
758
+ throw new EngineError(`reading row ${options.position} of ${table} failed: ${message(error)}`);
759
+ }
760
+ });
761
+ }
762
+ /**
763
+ * Insert rows. Idempotence here is the table's, not the statement's.
764
+ *
765
+ * There is no `ON CONFLICT` to ask for: a `ReplacingMergeTree` keeps the newest row for a key and
766
+ * collapses the rest at merge time, so recopying a chunk after a crash converges instead of
767
+ * raising. Not the same mechanism as the PostgreSQL adapter's, which is exactly why the live
768
+ * tests run a backfill in every direction rather than in one.
769
+ */
770
+ async copyIn(table, rows) {
771
+ return this.usage.operation(async () => {
772
+ if (rows.length === 0)
773
+ return;
774
+ const columns = Object.keys(rows[0]).sort();
775
+ for (const row of rows) {
776
+ const here = Object.keys(row).sort();
777
+ if (here.join(' ') !== columns.join(' ')) {
778
+ throw new EngineError(`copyIn into ${table} was given rows with different columns ([${columns.join(', ')}] ` +
779
+ `and [${here.join(', ')}]). A chunk comes from one table, so this is a caller ` +
780
+ `assembling it from two.`);
781
+ }
782
+ }
783
+ try {
784
+ await this.insertRows(table, rows);
785
+ }
786
+ catch (error) {
787
+ throw new EngineError(`copying ${rows.length} rows into ${table} failed: ${message(error)}`);
788
+ }
789
+ });
790
+ }
791
+ async backfillMarker(options) {
792
+ return this.usage.operation(async () => {
793
+ try {
794
+ await this.command(`CREATE TABLE IF NOT EXISTS ${quote(BACKFILL_TABLE)} (` +
795
+ `${quote('materialization')} String, ${quote('entity')} String, ` +
796
+ `${quote('rows_copied')} Int64, ${quote('at')} DateTime64(3, 'UTC') DEFAULT now64(3)) ` +
797
+ `ENGINE = MergeTree ORDER BY (${quote('materialization')}, ${quote('entity')})`);
798
+ const rows = await this.query(`SELECT max(${quote('rows_copied')}) AS high, count() AS n FROM ` +
799
+ `${quote(BACKFILL_TABLE)} WHERE ${quote('materialization')} = ` +
800
+ `${literal(options.materialization)} AND ${quote('entity')} = ${literal(options.entity)}`);
801
+ const row = rows[0];
802
+ if (row === undefined || Number(row['n']) === 0)
803
+ return 0;
804
+ return Number(row['high']);
805
+ }
806
+ catch (error) {
807
+ throw new EngineError(`reading ${BACKFILL_TABLE} failed: ${message(error)}`);
808
+ }
809
+ });
810
+ }
811
+ async recordBackfillMarker(options) {
812
+ return this.usage.operation(async () => {
813
+ try {
814
+ await this.insertRows(BACKFILL_TABLE, [
815
+ {
816
+ materialization: options.materialization,
817
+ entity: options.entity,
818
+ rows_copied: options.rows,
819
+ },
820
+ ]);
821
+ }
822
+ catch (error) {
823
+ throw new EngineError(`recording backfill progress in ${BACKFILL_TABLE} failed: ${message(error)}`);
824
+ }
825
+ });
826
+ }
827
+ // --- transactions ------------------------------------------------------------------------
828
+ /**
829
+ * Refuses. There is no transaction here to give you.
830
+ *
831
+ * A no-op that ran the body would be the friendlier signature and the worse library: the caller
832
+ * would believe a group of writes was atomic, and would find out otherwise from the state of the
833
+ * data rather than from an exception.
834
+ *
835
+ * The way out is not a flag. Declare the atomicity - `atomicWith` on the entity - and the planner
836
+ * is then obliged to place those entities in one group, and one group is one engine, so it will
837
+ * not be this one.
838
+ */
839
+ transaction(_body) {
840
+ throw new EngineError('ClickHouse has no multi-statement transactions, so this adapter will not pretend to start ' +
841
+ 'one. If these writes have to commit together, declare it: atomicWith on the entities ' +
842
+ 'makes them one colocation group, one group is one engine, and the planner is then not ' +
843
+ 'permitted to put them here. A no-op that ran your callback would let the writes proceed ' +
844
+ 'and let you believe they were atomic.');
845
+ }
846
+ }
847
+ /**
848
+ * A SQL literal for one value, and **the only place in this file that escapes a string**.
849
+ *
850
+ * Exported so it can be checked directly, because the alternative is checking it through a server
851
+ * and a server reinterprets escapes inside identifiers: a table name carrying a backslash comes
852
+ * back as a *different* name rather than as a syntax error, so a live test on one cannot isolate
853
+ * this rule. `adapters.test.ts` pins the two characters and also refuses a second escaper anywhere
854
+ * else in this file - which is the class of defect that produced it, found by CodeQL as
855
+ * `js/incomplete-sanitization`.
856
+ *
857
+ * ClickHouse's HTTP interface has no bound parameters in the sense the PostgreSQL protocol does -
858
+ * it has `param_name` substitution, which needs the declared type of every parameter in the query
859
+ * text. So these are rendered, and the rendering is **total**: every kind this library can hold has
860
+ * a branch, and an unknown kind throws rather than falling through to a string. A value that
861
+ * reached a query as an unquoted `[object Object]` would be a syntax error at best.
862
+ */
863
+ /**
864
+ * The columns of `system.parts` a storage measurement reads, and all a runtime login is granted.
865
+ * ClickHouse 24.8 refuses `system.parts` to a login with only table grants; a column grant on these
866
+ * names lets it read the parts of its own tables and no others (measured).
867
+ */
868
+ export const STORAGE_COLUMNS = [
869
+ 'database',
870
+ 'table',
871
+ 'active',
872
+ 'bytes_on_disk',
873
+ 'secondary_indices_compressed_bytes',
874
+ 'secondary_indices_marks_bytes',
875
+ ];
876
+ export function literal(value) {
877
+ if (value === null || value === undefined)
878
+ return 'NULL';
879
+ if (typeof value === 'number') {
880
+ if (!Number.isFinite(value)) {
881
+ throw new EngineError(`${value} is not a value any engine here can store`);
882
+ }
883
+ return String(value);
884
+ }
885
+ if (typeof value === 'bigint')
886
+ return value.toString();
887
+ if (typeof value === 'boolean')
888
+ return value ? '1' : '0';
889
+ if (value instanceof Timestamp || value instanceof Date)
890
+ return `'${String(outbound(value))}'`;
891
+ if (typeof value === 'string')
892
+ return `'${value.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
893
+ throw new EngineError(`a key value of type ${typeof value} cannot be rendered for ClickHouse. Rendering it as a ` +
894
+ `string would produce a query that is wrong rather than one that fails.`);
895
+ }
896
+ function message(error) {
897
+ return error instanceof Error ? error.message : String(error);
898
+ }
899
+ //# sourceMappingURL=clickhouse.js.map