@flowblade/sqlduck 0.9.0 → 0.11.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/dist/index.mjs CHANGED
@@ -1,5 +1,8 @@
1
1
  import { BIGINT, BOOLEAN, DOUBLE, DuckDBDataChunk, DuckDBTimestampValue, FLOAT, HUGEINT, INTEGER, SMALLINT, TIMESTAMP, TINYINT, UBIGINT, UHUGEINT, UINTEGER, USMALLINT, UTINYINT, UUID, VARCHAR } from "@duckdb/node-api";
2
+ import { getLogger } from "@logtape/logtape";
2
3
  import * as z from "zod";
4
+ import { assertNever } from "@httpx/assert";
5
+ import { isPlainObject } from "@httpx/plain-object";
3
6
  //#region src/helpers/duck-exec.ts
4
7
  var DuckExec = class {
5
8
  #conn;
@@ -117,6 +120,12 @@ const createOnDataAppendedCollector = () => {
117
120
  };
118
121
  };
119
122
  //#endregion
123
+ //#region src/config/flowblade-logtape-sqlduck.config.ts
124
+ const flowbladeLogtapeSqlduckConfig = { categories: ["flowblade", "sqlduck"] };
125
+ //#endregion
126
+ //#region src/logger/sqlduck-default-logtape-logger.ts
127
+ const sqlduckDefaultLogtapeLogger = getLogger(flowbladeLogtapeSqlduckConfig.categories);
128
+ //#endregion
120
129
  //#region src/table/get-duckdb-number-column-type.ts
121
130
  const isFloatValue = (value) => {
122
131
  if (!Number.isFinite(value)) return true;
@@ -150,6 +159,16 @@ const createOptions = {
150
159
  CREATE_OR_REPLACE: "CREATE OR REPLACE TABLE",
151
160
  IF_NOT_EXISTS: "CREATE TABLE IF NOT EXISTS"
152
161
  };
162
+ const duckDbTypesMap = new Map([
163
+ ["VARCHAR", VARCHAR],
164
+ ["BIGINT", BIGINT],
165
+ ["TIMESTAMP", TIMESTAMP],
166
+ ["UUID", UUID],
167
+ ["BOOLEAN", BOOLEAN],
168
+ ["INTEGER", INTEGER],
169
+ ["DOUBLE", DOUBLE],
170
+ ["FLOAT", FLOAT]
171
+ ]);
153
172
  const getTableCreateFromZod = (params) => {
154
173
  const { table, schema, options } = params;
155
174
  const { create = "CREATE" } = options ?? {};
@@ -162,9 +181,10 @@ const getTableCreateFromZod = (params) => {
162
181
  if (json.properties === void 0) throw new TypeError("Schema must have at least one property");
163
182
  const columnTypesMap = /* @__PURE__ */ new Map();
164
183
  for (const [columnName, def] of Object.entries(json.properties)) {
165
- const { type, nullable, format, primaryKey, minimum, maximum } = def;
184
+ const { type, duckdbType, nullable, format, primaryKey, minimum, maximum } = def;
166
185
  const c = { name: columnName };
167
- switch (type) {
186
+ if (duckdbType !== void 0 && duckDbTypesMap.has(duckdbType)) c.duckdbType = duckDbTypesMap.get(duckdbType);
187
+ else switch (type) {
168
188
  case "string":
169
189
  switch (format) {
170
190
  case "date-time":
@@ -220,15 +240,18 @@ const getTableCreateFromZod = (params) => {
220
240
  //#endregion
221
241
  //#region src/table/create-table-from-zod.ts
222
242
  const createTableFromZod = async (params) => {
223
- const { conn, table, schema, options } = params;
243
+ const { conn, table, schema, options, logger = sqlduckDefaultLogtapeLogger } = params;
224
244
  const { ddl, columnTypes } = getTableCreateFromZod({
225
245
  table,
226
246
  schema,
227
247
  options
228
248
  });
249
+ logger.debug(`Generate DDL for table '${table.getFullName()}'`, { ddl });
229
250
  try {
230
251
  await conn.run(ddl);
252
+ logger.info(`Table '${table.getFullName()}' successfully created`, { ddl });
231
253
  } catch (e) {
254
+ logger.error(`Failed to create table '${table.getFullName()}': ${e.message}`, { ddl });
232
255
  throw new Error(`Failed to create table '${table.getFullName()}': ${e.message}`, { cause: e });
233
256
  }
234
257
  return {
@@ -281,11 +304,11 @@ async function* rowsToColumnsChunks(params) {
281
304
  //#endregion
282
305
  //#region src/sql-duck.ts
283
306
  var SqlDuck = class {
284
- #duck;
307
+ #conn;
285
308
  #logger;
286
309
  constructor(params) {
287
- this.#duck = params.conn;
288
- this.#logger = params.logger;
310
+ this.#conn = params.conn;
311
+ this.#logger = params.logger ?? sqlduckDefaultLogtapeLogger;
289
312
  }
290
313
  /**
291
314
  * Create a table from a Zod schema and fill it with data from a row stream.
@@ -329,12 +352,12 @@ var SqlDuck = class {
329
352
  if (!Number.isSafeInteger(chunkSize) || chunkSize < 1 || chunkSize > 2048) throw new Error("chunkSize must be a number between 1 and 2048");
330
353
  const timeStart = Date.now();
331
354
  const { columnTypes, ddl } = await createTableFromZod({
332
- conn: this.#duck,
355
+ conn: this.#conn,
333
356
  schema,
334
357
  table,
335
358
  options: createOptions
336
359
  });
337
- const appender = await this.#duck.createAppender(table.tableName, table.schemaName, table.databaseName);
360
+ const appender = await this.#conn.createAppender(table.tableName, table.schemaName, table.databaseName);
338
361
  const chunkTypes = Array.from(columnTypes.values());
339
362
  let totalRows = 0;
340
363
  const dataAppendedCollector = createOnDataAppendedCollector();
@@ -342,29 +365,74 @@ var SqlDuck = class {
342
365
  rows: rowStream,
343
366
  chunkSize
344
367
  });
345
- for await (const dataChunk of columnStream) {
346
- const chunk = DuckDBDataChunk.create(chunkTypes);
347
- if (this.#logger) this.#logger(`Inserting chunk of ${dataChunk.length} rows`);
348
- totalRows += dataChunk?.[0]?.length ?? 0;
349
- chunk.setColumns(dataChunk);
350
- appender.appendDataChunk(chunk);
351
- appender.flushSync();
352
- if (onDataAppended !== void 0) {
353
- const payload = dataAppendedCollector(totalRows);
354
- if (isOnDataAppendedAsyncCb(onDataAppended)) await onDataAppended(payload);
355
- else onDataAppended(payload);
368
+ try {
369
+ for await (const dataChunk of columnStream) {
370
+ const chunk = DuckDBDataChunk.create(chunkTypes);
371
+ this.#logger.debug(`Inserting chunk of ${dataChunk.length} rows`, { table: table.getFullName() });
372
+ totalRows += dataChunk?.[0]?.length ?? 0;
373
+ chunk.setColumns(dataChunk);
374
+ appender.appendDataChunk(chunk);
375
+ appender.flushSync();
376
+ if (onDataAppended !== void 0) {
377
+ const payload = dataAppendedCollector(totalRows);
378
+ if (isOnDataAppendedAsyncCb(onDataAppended)) await onDataAppended(payload);
379
+ else onDataAppended(payload);
380
+ }
356
381
  }
382
+ appender.closeSync();
383
+ const timeMs = Math.round(Date.now() - timeStart);
384
+ this.#logger.info(`Successfully appended ${totalRows} rows into '${table.getFullName()}' in ${timeMs}ms`, {
385
+ table: table.getFullName(),
386
+ timeMs,
387
+ totalRows
388
+ });
389
+ return {
390
+ timeMs,
391
+ totalRows,
392
+ createTableDDL: ddl
393
+ };
394
+ } catch (e) {
395
+ appender.closeSync();
396
+ const msg = `Failed to append data into table '${table.getFullName()}' - ${e?.message ?? ""}`;
397
+ this.#logger.error(msg, { table: table.getFullName() });
398
+ throw new Error(msg, { cause: e });
357
399
  }
358
- appender.closeSync();
400
+ };
401
+ };
402
+ //#endregion
403
+ //#region src/utils/zod-codecs.ts
404
+ const zodCodecs = {
405
+ dateToString: z.codec(z.date(), z.iso.datetime(), {
406
+ decode: (date) => date.toISOString(),
407
+ encode: (isoString) => new Date(isoString)
408
+ }),
409
+ bigintToString: z.codec(z.bigint(), z.string().meta({ format: "int64" }), {
410
+ decode: (bigint) => bigint.toString(),
411
+ encode: BigInt
412
+ })
413
+ };
414
+ //#endregion
415
+ //#region src/objects/database.ts
416
+ var Database = class {
417
+ #params;
418
+ get alias() {
419
+ return this.#params.alias;
420
+ }
421
+ constructor(params) {
422
+ this.#params = params;
423
+ }
424
+ toJson() {
359
425
  return {
360
- timeMs: Math.round(Date.now() - timeStart),
361
- totalRows,
362
- createTableDDL: ddl
426
+ type: "database",
427
+ params: { alias: this.#params.alias }
363
428
  };
364
- };
429
+ }
430
+ [Symbol.toStringTag]() {
431
+ return this.alias;
432
+ }
365
433
  };
366
434
  //#endregion
367
- //#region src/table/table.ts
435
+ //#region src/objects/table.ts
368
436
  var Table = class Table {
369
437
  #fqTable;
370
438
  get tableName() {
@@ -406,16 +474,261 @@ var Table = class Table {
406
474
  };
407
475
  };
408
476
  //#endregion
409
- //#region src/utils/zod-codecs.ts
410
- const zodCodecs = {
411
- dateToString: z.codec(z.date(), z.iso.datetime(), {
412
- decode: (date) => date.toISOString(),
413
- encode: (isoString) => new Date(isoString)
414
- }),
415
- bigintToString: z.codec(z.bigint(), z.string().meta({ format: "int64" }), {
416
- decode: (bigint) => bigint.toString(),
417
- encode: BigInt
418
- })
477
+ //#region src/validation/core/duckdb-reserved-keywords.ts
478
+ /**
479
+ * DuckDB reserved keywords that cannot be used as unquoted identifiers.
480
+ * @see https://duckdb.org/docs/sql/keywords-and-identifiers.html
481
+ */
482
+ const duckdbReservedKeywords = [
483
+ "ALL",
484
+ "ANALYSE",
485
+ "ANALYZE",
486
+ "AND",
487
+ "ANY",
488
+ "ARRAY",
489
+ "AS",
490
+ "ASC",
491
+ "ASYMMETRIC",
492
+ "BOTH",
493
+ "CASE",
494
+ "CAST",
495
+ "CHECK",
496
+ "COLLATE",
497
+ "COLUMN",
498
+ "CONSTRAINT",
499
+ "CREATE",
500
+ "CROSS",
501
+ "CURRENT_CATALOG",
502
+ "CURRENT_DATE",
503
+ "CURRENT_ROLE",
504
+ "CURRENT_SCHEMA",
505
+ "CURRENT_TIME",
506
+ "CURRENT_TIMESTAMP",
507
+ "CURRENT_USER",
508
+ "DEFAULT",
509
+ "DEFERRABLE",
510
+ "DESC",
511
+ "DISTINCT",
512
+ "DO",
513
+ "ELSE",
514
+ "END",
515
+ "EXCEPT",
516
+ "EXISTS",
517
+ "EXTRACT",
518
+ "FALSE",
519
+ "FETCH",
520
+ "FOR",
521
+ "FOREIGN",
522
+ "FROM",
523
+ "GRANT",
524
+ "GROUP",
525
+ "HAVING",
526
+ "IF",
527
+ "ILIKE",
528
+ "IN",
529
+ "INITIALLY",
530
+ "INNER",
531
+ "INTERSECT",
532
+ "INTO",
533
+ "IS",
534
+ "ISNULL",
535
+ "JOIN",
536
+ "LATERAL",
537
+ "LEADING",
538
+ "LEFT",
539
+ "LIKE",
540
+ "LIMIT",
541
+ "LOCALTIME",
542
+ "LOCALTIMESTAMP",
543
+ "NATURAL",
544
+ "NOT",
545
+ "NOTNULL",
546
+ "NULL",
547
+ "OFFSET",
548
+ "ON",
549
+ "ONLY",
550
+ "OR",
551
+ "ORDER",
552
+ "OUTER",
553
+ "OVERLAPS",
554
+ "PLACING",
555
+ "PRIMARY",
556
+ "REFERENCES",
557
+ "RETURNING",
558
+ "RIGHT",
559
+ "ROW",
560
+ "SELECT",
561
+ "SESSION_USER",
562
+ "SIMILAR",
563
+ "SOME",
564
+ "SYMMETRIC",
565
+ "TABLE",
566
+ "THEN",
567
+ "TO",
568
+ "TRAILING",
569
+ "TRUE",
570
+ "UNION",
571
+ "UNIQUE",
572
+ "USING",
573
+ "VARIADIC",
574
+ "VERBOSE",
575
+ "WHEN",
576
+ "WHERE",
577
+ "WINDOW",
578
+ "WITH"
579
+ ];
580
+ //#endregion
581
+ //#region src/validation/zod/duckdb-valid-names.schemas.ts
582
+ const duckdbMaximumObjectNameLength = 120;
583
+ const duckDbObjectNameRegex = /^[a-z_]\w*$/i;
584
+ const duckdbReservedKeywordsSet = new Set(duckdbReservedKeywords.map((k) => k.toUpperCase()));
585
+ const duckTableNameSchema = z.string().min(1).max(duckdbMaximumObjectNameLength).regex(duckDbObjectNameRegex, "Table name must start with a letter or underscore, and contain only letters, numbers and underscores").refine((value) => !duckdbReservedKeywordsSet.has(value.toUpperCase()), { error: `Value is a DuckDB reserved keyword and cannot be used as a table name` });
586
+ const duckTableAliasSchema = duckTableNameSchema;
587
+ //#endregion
588
+ //#region src/manager/database/duck-database-manager.schemas.ts
589
+ const duckdbAttachOptionsSchema = z.strictObject({
590
+ ACCESS_MODE: z.optional(z.enum([
591
+ "READ_ONLY",
592
+ "READ_WRITE",
593
+ "AUTOMATIC"
594
+ ])),
595
+ COMPRESS: z.optional(z.enum(["true", "false"])),
596
+ TYPE: z.optional(z.enum(["DUCKDB", "SQLITE"])),
597
+ BLOCK_SIZE: z.optional(z.int32().min(16384).max(262144)),
598
+ ROW_GROUP_SIZE: z.optional(z.int32().positive()),
599
+ STORAGE_VERSION: z.optional(z.string().startsWith("v").regex(/^v?\d{1,4}\.\d{1,4}\.\d{1,4}$/)),
600
+ ENCRYPTION_KEY: z.optional(z.string().min(8)),
601
+ ENCRYPTION_CIPHER: z.optional(z.enum([
602
+ "CBC",
603
+ "CTR",
604
+ "GCM"
605
+ ]))
606
+ });
607
+ const duckDatabaseManagerDbParamsSchema = z.discriminatedUnion("type", [z.strictObject({
608
+ type: z.literal(":memory:"),
609
+ alias: duckTableAliasSchema,
610
+ options: z.optional(duckdbAttachOptionsSchema)
611
+ }), z.strictObject({
612
+ type: z.literal("duckdb"),
613
+ path: z.string().min(4).endsWith(".db"),
614
+ alias: duckTableAliasSchema,
615
+ options: z.optional(duckdbAttachOptionsSchema)
616
+ })]);
617
+ //#endregion
618
+ //#region src/manager/database/commands/duck-database-attach-command.ts
619
+ var DuckDatabaseAttachCommand = class {
620
+ options;
621
+ dbParams;
622
+ constructor(dbParams, options) {
623
+ this.dbParams = dbParams;
624
+ this.options = options ?? {};
625
+ }
626
+ getRawSql = () => {
627
+ const dbParams = this.dbParams;
628
+ const parts = ["ATTACH", this.options.behaviour].filter(Boolean);
629
+ const { type, alias } = dbParams;
630
+ switch (type) {
631
+ case ":memory:":
632
+ parts.push("':memory:'");
633
+ break;
634
+ case "duckdb":
635
+ parts.push(`'${dbParams.path}'`);
636
+ break;
637
+ default: assertNever(type);
638
+ }
639
+ if (alias !== null) parts.push("AS", `${alias}`);
640
+ const options = isPlainObject(dbParams.options) ? Object.entries(dbParams.options).map(([key, value]) => {
641
+ return key === "ACCESS_MODE" ? value : `${key} '${value}'`;
642
+ }) : [];
643
+ if (options.length > 0) parts.push(`(${options.join(", ")})`);
644
+ return parts.filter(Boolean).join(" ");
645
+ };
646
+ };
647
+ //#endregion
648
+ //#region src/manager/database/duck-database-manager.ts
649
+ var DuckDatabaseManager = class {
650
+ #conn;
651
+ #logger;
652
+ constructor(conn, params) {
653
+ this.#conn = conn;
654
+ this.#logger = params?.logger ?? sqlduckDefaultLogtapeLogger.with({ source: "DuckDatabaseManager" });
655
+ }
656
+ /**
657
+ * Attach a database to the current connection
658
+ *
659
+ * @example
660
+ * ```typescript
661
+ * const dbManager = new DuckDatabaseManager(conn);
662
+ * const database = dbManager.attach({
663
+ * type: ':memory:', // can be 'duckdb', 's3'...
664
+ * alias: 'mydb',
665
+ * options: { COMPRESS: 'true' }
666
+ * });
667
+ *
668
+ * console.log(database.alias); // 'mydb'
669
+ * ```
670
+ */
671
+ attach = async (dbParams, options) => {
672
+ const params = z.parse(duckDatabaseManagerDbParamsSchema, dbParams);
673
+ const rawSql = new DuckDatabaseAttachCommand(params, options).getRawSql();
674
+ await this.#executeRawSqlCommand(`attach(${params.alias})`, rawSql);
675
+ return new Database({ alias: params.alias });
676
+ };
677
+ attachOrReplace = async (dbParams) => {
678
+ return this.attach(dbParams, { behaviour: "OR REPLACE" });
679
+ };
680
+ attachIfNotExists = async (dbParams) => {
681
+ return this.attach(dbParams, { behaviour: "IF NOT EXISTS" });
682
+ };
683
+ showDatabases = async () => {
684
+ return await this.#executeRawSqlCommand("showDatabases()", `SHOW DATABASES`);
685
+ };
686
+ detach = async (dbAlias) => {
687
+ const safeAlias = z.parse(duckTableAliasSchema, dbAlias);
688
+ await this.#executeRawSqlCommand(`detach(${safeAlias})`, `DETACH ${safeAlias}`);
689
+ return true;
690
+ };
691
+ detachIfExists = async (dbAlias) => {
692
+ const safeAlias = z.parse(duckTableAliasSchema, dbAlias);
693
+ await this.#executeRawSqlCommand(`detachIfExists(${safeAlias})`, `DETACH IF EXISTS ${safeAlias}`);
694
+ return true;
695
+ };
696
+ /**
697
+ * The statistics recomputed by the ANALYZE statement are only used for join order optimization.
698
+ *
699
+ * It is therefore recommended to recompute these statistics for improved join orders,
700
+ * especially after performing large updates (inserts and/or deletes).
701
+ *
702
+ * @link https://duckdb.org/docs/stable/sql/statements/analyze
703
+ */
704
+ analyze = async () => {
705
+ await this.#executeRawSqlCommand("analyze()", "ANALYZE");
706
+ return true;
707
+ };
708
+ checkpoint = async (dbAlias) => {
709
+ const safeAlias = z.parse(duckTableAliasSchema, dbAlias);
710
+ await this.#executeRawSqlCommand(`checkpoint(${safeAlias})`, `CHECKPOINT ${safeAlias}`);
711
+ return true;
712
+ };
713
+ #executeRawSqlCommand = async (name, rawSql) => {
714
+ const startTime = Date.now();
715
+ try {
716
+ const result = await this.#conn.runAndReadAll(rawSql);
717
+ const timeMs = Math.round(Date.now() - startTime);
718
+ const data = result.getRowObjectsJS();
719
+ this.#logger.info(`DuckDatabaseManager.${name} in ${timeMs}ms`, { timeMs });
720
+ return data;
721
+ } catch (e) {
722
+ const msg = `DuckDatabaseManager: failed to run "${name}" - ${e?.message ?? ""}`;
723
+ const timeMs = Math.round(Date.now() - startTime);
724
+ this.#logger.error(msg, {
725
+ name,
726
+ sql: rawSql,
727
+ timeMs
728
+ });
729
+ throw new Error(msg, { cause: e });
730
+ }
731
+ };
419
732
  };
420
733
  //#endregion
421
- export { DuckMemory, SqlDuck, Table, getTableCreateFromZod, zodCodecs };
734
+ export { Database, DuckDatabaseManager, DuckMemory, SqlDuck, Table, duckDatabaseManagerDbParamsSchema, duckTableAliasSchema, duckTableNameSchema, duckdbReservedKeywords, flowbladeLogtapeSqlduckConfig, getTableCreateFromZod, sqlduckDefaultLogtapeLogger, zodCodecs };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flowblade/sqlduck",
3
- "version": "0.9.0",
3
+ "version": "0.11.0",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "exports": {
@@ -57,8 +57,11 @@
57
57
  "@flowblade/core": "^0.2.26",
58
58
  "@flowblade/source-duckdb": "^0.20.1",
59
59
  "@flowblade/sql-tag": "^0.3.2",
60
+ "@httpx/assert": "^0.16.8",
61
+ "@httpx/plain-object": "^2.1.8",
62
+ "@logtape/logtape": "^2.0.5",
60
63
  "@standard-schema/spec": "^1.1.0",
61
- "p-mutex": "^1.0.0",
64
+ "p-queue": "9.1.0",
62
65
  "valibot": "^1.3.1",
63
66
  "zod": "^4.3.6"
64
67
  },
@@ -67,7 +70,7 @@
67
70
  },
68
71
  "devDependencies": {
69
72
  "@belgattitude/eslint-config-bases": "8.10.0",
70
- "@dotenvx/dotenvx": "1.55.1",
73
+ "@dotenvx/dotenvx": "1.57.2",
71
74
  "@duckdb/node-api": "1.5.1-r.1",
72
75
  "@faker-js/faker": "10.3.0",
73
76
  "@flowblade/source-kysely": "^1.3.0",
@@ -81,7 +84,7 @@
81
84
  "@types/node": "25.5.0",
82
85
  "@typescript-eslint/eslint-plugin": "8.57.2",
83
86
  "@typescript-eslint/parser": "8.57.2",
84
- "@typescript/native-preview": "7.0.0-dev.20260316.1",
87
+ "@typescript/native-preview": "7.0.0-dev.20260324.1",
85
88
  "@vitest/coverage-v8": "4.1.1",
86
89
  "@vitest/ui": "4.1.1",
87
90
  "ansis": "4.2.0",
@@ -89,6 +92,7 @@
89
92
  "core-js": "3.49.0",
90
93
  "cross-env": "10.1.0",
91
94
  "es-check": "9.6.3",
95
+ "es-toolkit": "1.45.1",
92
96
  "esbuild": "0.27.4",
93
97
  "eslint": "8.57.1",
94
98
  "execa": "9.6.1",
@@ -105,11 +109,11 @@
105
109
  "tarn": "3.0.2",
106
110
  "tedious": "19.2.1",
107
111
  "testcontainers": "11.13.0",
108
- "tsdown": "0.21.4",
112
+ "tsdown": "0.21.5",
109
113
  "tsx": "4.21.0",
110
- "typedoc": "0.28.17",
114
+ "typedoc": "0.28.18",
111
115
  "typedoc-plugin-markdown": "4.11.0",
112
- "typescript": "5.9.3",
116
+ "typescript": "6.0.2",
113
117
  "vitest": "4.1.1"
114
118
  },
115
119
  "files": [