@malloydata/db-duckdb 0.0.390 → 0.0.392

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.
@@ -32,6 +32,14 @@ const unquoteName = (name) => {
32
32
  }
33
33
  return name;
34
34
  };
35
+ /**
36
+ * A bare or schema-qualified SQL identifier — `name` or `schema.name`. Anything
37
+ * else (file paths with dashes, dots-as-extensions, slashes, URL schemes,
38
+ * globs) is treated as a file-path string by `fetchTableSchema` and wrapped
39
+ * in single quotes so DuckDB sees it as a string literal rather than parsing
40
+ * it as a SQL identifier.
41
+ */
42
+ const SQL_IDENTIFIER_CHAIN = /^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*$/;
35
43
  class DuckDBCommon extends connection_1.BaseConnection {
36
44
  get dialectName() {
37
45
  return this.dialect.name;
@@ -123,9 +131,9 @@ class DuckDBCommon extends connection_1.BaseConnection {
123
131
  connection: this.name,
124
132
  fields: [],
125
133
  };
126
- const quotedTablePath = tablePath.match(/[:*/]/)
127
- ? `'${tablePath}'`
128
- : tablePath;
134
+ const quotedTablePath = SQL_IDENTIFIER_CHAIN.test(tablePath)
135
+ ? tablePath
136
+ : `'${tablePath}'`;
129
137
  const infoQuery = `DESCRIBE SELECT * FROM ${quotedTablePath}`;
130
138
  await this.schemaFromQuery(infoQuery, structDef);
131
139
  return structDef;
@@ -34,6 +34,8 @@ export interface NormalizedDuckDBConfig {
34
34
  motherDuckToken?: string;
35
35
  additionalExtensions: string[];
36
36
  setupSQL?: string;
37
+ shareable: boolean;
38
+ effectiveShareable: boolean;
37
39
  }
38
40
  export declare class DuckDBConfigValidationError extends Error {
39
41
  constructor(message: string);
@@ -60,7 +60,7 @@ const REQUIRED_BASELINE_EXTENSIONS = ['icu', 'json'];
60
60
  const DERIVED_TEMP_DIRECTORY_NAME = '.tmp';
61
61
  const DERIVED_SECRET_DIRECTORY_NAME = '.duckdb-secrets';
62
62
  function normalizeDuckDBConfig(config) {
63
- var _a, _b, _c, _d;
63
+ var _a, _b, _c, _d, _e;
64
64
  const securityPolicy = parseSecurityPolicy(config['securityPolicy']);
65
65
  if (securityPolicy === 'sandboxed' && !pathSecurity.isPosixHost()) {
66
66
  throw new DuckDBConfigValidationError('securityPolicy "sandboxed" is only supported on POSIX hosts');
@@ -70,6 +70,7 @@ function normalizeDuckDBConfig(config) {
70
70
  const rawSetupSQL = normalizeOptionalText(readOptionalString(config, 'setupSQL'));
71
71
  const rawMotherDuckToken = normalizeOptionalText(readOptionalString(config, 'motherDuckToken'));
72
72
  const readOnly = (_c = readOptionalBoolean(config, 'readOnly')) !== null && _c !== void 0 ? _c : false;
73
+ const shareable = (_d = readOptionalBoolean(config, 'shareable')) !== null && _d !== void 0 ? _d : false;
73
74
  const additionalExtensions = normalizeExtensions(config['additionalExtensions'], 'additionalExtensions');
74
75
  const enableExternalAccess = readOptionalBoolean(config, 'enableExternalAccess');
75
76
  const lockConfiguration = readOptionalBoolean(config, 'lockConfiguration');
@@ -175,7 +176,7 @@ function normalizeDuckDBConfig(config) {
175
176
  ? undefined
176
177
  : canonicalizeConfigPath(rawExtensionDirectory, 'extensionDirectory');
177
178
  const secretDirectory = deriveRestrictedSecretDirectory({
178
- requiresSecretNeutralization: (_d = safetyPolicy === null || safetyPolicy === void 0 ? void 0 : safetyPolicy.requiresSecretNeutralization) !== null && _d !== void 0 ? _d : false,
179
+ requiresSecretNeutralization: (_e = safetyPolicy === null || safetyPolicy === void 0 ? void 0 : safetyPolicy.requiresSecretNeutralization) !== null && _e !== void 0 ? _e : false,
179
180
  tempDirectory,
180
181
  workingDirectory,
181
182
  });
@@ -206,6 +207,10 @@ function normalizeDuckDBConfig(config) {
206
207
  motherDuckToken: rawMotherDuckToken,
207
208
  additionalExtensions,
208
209
  setupSQL: rawSetupSQL,
210
+ shareable,
211
+ effectiveShareable: shareable &&
212
+ databasePath !== ':memory:' &&
213
+ !isLikelyRemoteDatabasePath(databasePath),
209
214
  };
210
215
  }
211
216
  function buildDuckDBShareKey(config) {
@@ -226,7 +231,7 @@ function buildDuckDBShareKey(config) {
226
231
  ? ''
227
232
  : String(config.allowUnsignedExtensions), config.tempFileEncryption === undefined
228
233
  ? ''
229
- : String(config.tempFileEncryption), config.threads === undefined ? '' : String(config.threads), (_c = config.memoryLimit) !== null && _c !== void 0 ? _c : '', (_d = config.tempDirectory) !== null && _d !== void 0 ? _d : '', (_e = config.workingDirectory) !== null && _e !== void 0 ? _e : '', ...[...config.additionalExtensions].sort(), (_f = config.extensionDirectory) !== null && _f !== void 0 ? _f : '', (_g = config.motherDuckToken) !== null && _g !== void 0 ? _g : '');
234
+ : String(config.tempFileEncryption), config.threads === undefined ? '' : String(config.threads), (_c = config.memoryLimit) !== null && _c !== void 0 ? _c : '', (_d = config.tempDirectory) !== null && _d !== void 0 ? _d : '', (_e = config.workingDirectory) !== null && _e !== void 0 ? _e : '', ...[...config.additionalExtensions].sort(), (_f = config.extensionDirectory) !== null && _f !== void 0 ? _f : '', (_g = config.motherDuckToken) !== null && _g !== void 0 ? _g : '', String(config.effectiveShareable));
230
235
  }
231
236
  function sqlStringLiteral(value) {
232
237
  return `'${value.replace(/'/g, "''")}'`;
@@ -22,6 +22,7 @@ export interface DuckDBConnectionOptions extends ConnectionConfig {
22
22
  memoryLimit?: string;
23
23
  tempDirectory?: string;
24
24
  extensionDirectory?: string;
25
+ shareable?: boolean;
25
26
  }
26
27
  interface ActiveDB {
27
28
  instance: DuckDBInstance;
@@ -44,6 +45,28 @@ export declare class DuckDBConnection extends DuckDBCommon {
44
45
  * from reversible idle.
45
46
  */
46
47
  protected closed: boolean;
48
+ /**
49
+ * Shareable mode only. The DuckDBInstance backing this connection's
50
+ * `:memory:` primary database. Not shared via `activeDBs` because each
51
+ * shareable connection independently ATTACHes/DETACHes the real file —
52
+ * there is nothing to pool. Closed in `close()`.
53
+ */
54
+ private ownedInstance;
55
+ /**
56
+ * Shareable mode only. True between a successful ATTACH (in
57
+ * `setupOnce()`) and the matching DETACH (in `idle()` / `close()`).
58
+ */
59
+ private attached;
60
+ /**
61
+ * Shareable mode only. True after the first successful run of the
62
+ * once-per-connection portion of `setupOnce()` (baseline `SET`s,
63
+ * extension `LOAD`s, user `setupSQL`, `lockConfiguration`). Those run
64
+ * against the persistent `:memory:` primary and must NOT re-run on
65
+ * later wake-ups: user `setupSQL` may be non-idempotent, and any later
66
+ * `SET` would fail under `lockConfiguration=true`. Wake-ups still
67
+ * re-ATTACH the real file because that's the bit DETACH released.
68
+ */
69
+ private connectionSetupRan;
47
70
  static activeDBs: Record<string, ActiveDB>;
48
71
  constructor(options: DuckDBConnectionOptions, queryOptions?: QueryOptionsReader);
49
72
  constructor(name: string, databasePath?: string, workingDirectory?: string, queryOptions?: QueryOptionsReader);
@@ -51,6 +74,14 @@ export declare class DuckDBConnection extends DuckDBCommon {
51
74
  private init;
52
75
  protected setup(): Promise<void>;
53
76
  private setupOnce;
77
+ /**
78
+ * Shareable mode: ATTACH the real database file into the in-memory
79
+ * primary and `USE` it, so unqualified table refs resolve into the
80
+ * attached file. Idempotent — guarded by `this.attached`. Runs after
81
+ * `applyFinalBaseline()` so the corresponding `SET allowed_directories`
82
+ * (sandboxed mode) has already been applied.
83
+ */
84
+ private attachIfShareable;
54
85
  private buildInstanceOptions;
55
86
  private shouldApplyEnableExternalAccessAtOpenTime;
56
87
  private applyFinalBaseline;
@@ -63,6 +94,7 @@ export declare class DuckDBConnection extends DuckDBCommon {
63
94
  runSQLStream(sql: string, { rowLimit, abortSignal }?: RunSQLOptions): AsyncIterableIterator<QueryRecord>;
64
95
  close(): Promise<void>;
65
96
  idle(): Promise<void>;
97
+ private detachShareableFile;
66
98
  /**
67
99
  * Drop our entry from the shared `activeDBs` bookkeeping. If we were
68
100
  * the last sharer, close the underlying `DuckDBInstance`.
@@ -31,6 +31,7 @@ const node_api_1 = require("@duckdb/node-api");
31
31
  const malloy_1 = require("@malloydata/malloy");
32
32
  const package_json_1 = __importDefault(require("@malloydata/malloy/package.json"));
33
33
  const duckdb_config_1 = require("./duckdb_config");
34
+ const SHAREABLE_ATTACH_ALIAS = 'malloy_db';
34
35
  class DuckDBConnection extends duckdb_common_1.DuckDBCommon {
35
36
  constructor(arg, arg2, workingDirectory, queryOptions) {
36
37
  var _a;
@@ -42,6 +43,28 @@ class DuckDBConnection extends duckdb_common_1.DuckDBCommon {
42
43
  * from reversible idle.
43
44
  */
44
45
  this.closed = false;
46
+ /**
47
+ * Shareable mode only. The DuckDBInstance backing this connection's
48
+ * `:memory:` primary database. Not shared via `activeDBs` because each
49
+ * shareable connection independently ATTACHes/DETACHes the real file —
50
+ * there is nothing to pool. Closed in `close()`.
51
+ */
52
+ this.ownedInstance = null;
53
+ /**
54
+ * Shareable mode only. True between a successful ATTACH (in
55
+ * `setupOnce()`) and the matching DETACH (in `idle()` / `close()`).
56
+ */
57
+ this.attached = false;
58
+ /**
59
+ * Shareable mode only. True after the first successful run of the
60
+ * once-per-connection portion of `setupOnce()` (baseline `SET`s,
61
+ * extension `LOAD`s, user `setupSQL`, `lockConfiguration`). Those run
62
+ * against the persistent `:memory:` primary and must NOT re-run on
63
+ * later wake-ups: user `setupSQL` may be non-idempotent, and any later
64
+ * `SET` would fail under `lockConfiguration=true`. Wake-ups still
65
+ * re-ATTACH the real file because that's the bit DETACH released.
66
+ */
67
+ this.connectionSetupRan = false;
45
68
  const options = typeof arg === 'string'
46
69
  ? buildLegacyOptions(arg, arg2, workingDirectory)
47
70
  : arg;
@@ -72,6 +95,15 @@ class DuckDBConnection extends duckdb_common_1.DuckDBCommon {
72
95
  }
73
96
  async init() {
74
97
  try {
98
+ if (this.normalized.effectiveShareable) {
99
+ // Shareable mode: own private :memory: primary, no activeDBs
100
+ // sharing. The real database file is opened lazily via ATTACH in
101
+ // setupOnce() and released via DETACH in idle().
102
+ const instance = await node_api_1.DuckDBInstance.create(':memory:', this.buildInstanceOptions({ forShareablePrimary: true }));
103
+ this.connection = await instance.connect();
104
+ this.ownedInstance = instance;
105
+ return;
106
+ }
75
107
  const cached = DuckDBConnection.activeDBs[this.shareKey];
76
108
  if (cached) {
77
109
  this.connection = await cached.instance.connect();
@@ -116,24 +148,65 @@ class DuckDBConnection extends duckdb_common_1.DuckDBCommon {
116
148
  await this.isSetup;
117
149
  }
118
150
  async setupOnce() {
119
- await this.applyFinalBaseline();
120
- if (this.normalized.setupSQL) {
121
- for (const statement of splitSetupSQL(this.normalized.setupSQL)) {
122
- await this.runDuckDBQuery(statement);
151
+ // In non-shareable mode this whole method runs on every wake because
152
+ // the underlying DuckDBInstance was destroyed by `idle()`/`close()`,
153
+ // so the connection-level state (SETs, LOADs, user setupSQL effects)
154
+ // is gone and must be replayed.
155
+ //
156
+ // In shareable mode the `:memory:` primary survives idle and so does
157
+ // its connection-level state. Replaying the baseline + user setupSQL
158
+ // on a second wake would re-execute non-idempotent user statements
159
+ // (CREATE TABLE, INSERT, ...) and would also fail outright under
160
+ // `lockConfiguration=true` because subsequent `SET`s are rejected
161
+ // once configuration is locked. Gate everything except the ATTACH on
162
+ // `connectionSetupRan` so it only runs the first time.
163
+ const reattachOnly = this.normalized.effectiveShareable && this.connectionSetupRan;
164
+ if (!reattachOnly) {
165
+ await this.applyFinalBaseline();
166
+ }
167
+ await this.attachIfShareable();
168
+ if (!reattachOnly) {
169
+ if (this.normalized.setupSQL) {
170
+ for (const statement of splitSetupSQL(this.normalized.setupSQL)) {
171
+ await this.runDuckDBQuery(statement);
172
+ }
123
173
  }
124
- }
125
- if (this.normalized.lockConfiguration) {
126
- await this.runDuckDBQuery('SET lock_configuration=true');
174
+ if (this.normalized.lockConfiguration) {
175
+ await this.runDuckDBQuery('SET lock_configuration=true');
176
+ }
177
+ this.connectionSetupRan = true;
127
178
  }
128
179
  }
129
- buildInstanceOptions() {
180
+ /**
181
+ * Shareable mode: ATTACH the real database file into the in-memory
182
+ * primary and `USE` it, so unqualified table refs resolve into the
183
+ * attached file. Idempotent — guarded by `this.attached`. Runs after
184
+ * `applyFinalBaseline()` so the corresponding `SET allowed_directories`
185
+ * (sandboxed mode) has already been applied.
186
+ */
187
+ async attachIfShareable() {
188
+ if (!this.normalized.effectiveShareable)
189
+ return;
190
+ if (this.attached)
191
+ return;
192
+ const readOnlyClause = this.normalized.readOnly ? ' (READ_ONLY)' : '';
193
+ await this.runDuckDBQuery(`ATTACH ${(0, duckdb_config_1.sqlStringLiteral)(this.normalized.databasePath)} AS ${SHAREABLE_ATTACH_ALIAS}${readOnlyClause}`);
194
+ await this.runDuckDBQuery(`USE ${SHAREABLE_ATTACH_ALIAS}.main`);
195
+ this.attached = true;
196
+ }
197
+ buildInstanceOptions(opts) {
198
+ const forShareablePrimary = (opts === null || opts === void 0 ? void 0 : opts.forShareablePrimary) === true;
130
199
  const options = {
131
200
  custom_user_agent: `Malloy/${package_json_1.default.version}`,
132
201
  };
133
202
  if (this.normalized.motherDuckToken !== undefined) {
134
203
  options['motherduck_token'] = this.normalized.motherDuckToken;
135
204
  }
136
- if (this.normalized.readOnly) {
205
+ // In shareable mode the primary is :memory: and must stay writable —
206
+ // temp tables, manifestTemporaryTable, and search-index work all live
207
+ // there. The user's `readOnly` applies to the ATTACHed real file via
208
+ // `(READ_ONLY)` in attachIfShareable().
209
+ if (this.normalized.readOnly && !forShareablePrimary) {
137
210
  options['access_mode'] = 'READ_ONLY';
138
211
  }
139
212
  if (this.normalized.autoloadKnownExtensions !== undefined) {
@@ -276,16 +349,40 @@ class DuckDBConnection extends duckdb_common_1.DuckDBCommon {
276
349
  }
277
350
  }
278
351
  async close() {
279
- this.detachInstance();
352
+ if (this.normalized.effectiveShareable) {
353
+ await this.detachShareableFile();
354
+ if (this.ownedInstance) {
355
+ try {
356
+ this.ownedInstance.closeSync();
357
+ }
358
+ catch {
359
+ // Best effort during shutdown.
360
+ }
361
+ this.ownedInstance = null;
362
+ }
363
+ }
364
+ else {
365
+ this.detachInstance();
366
+ }
280
367
  this.connection = null;
281
368
  this.isSetup = undefined;
282
369
  this.setupError = undefined;
370
+ this.attached = false;
283
371
  this.closed = true;
284
372
  }
285
373
  async idle() {
286
374
  // No-op for in-memory: closing the instance silently destroys state.
287
375
  if (this.normalized.databasePath === ':memory:')
288
376
  return;
377
+ if (this.normalized.effectiveShareable) {
378
+ // Shareable mode: keep the in-memory primary (and its temp tables,
379
+ // schema cache, etc.) alive; just DETACH the real file so the OS
380
+ // file lock is released. Next `setup()` re-runs `setupOnce()`, which
381
+ // re-ATTACHes lazily on first use.
382
+ await this.detachShareableFile();
383
+ this.isSetup = undefined;
384
+ return;
385
+ }
289
386
  this.detachInstance();
290
387
  this.connection = null;
291
388
  this.isSetup = undefined;
@@ -294,6 +391,23 @@ class DuckDBConnection extends duckdb_common_1.DuckDBCommon {
294
391
  // use and runs a fresh init() then. Doing it eagerly here would
295
392
  // re-acquire the lock immediately and defeat the point of idling.
296
393
  }
394
+ async detachShareableFile() {
395
+ if (!this.normalized.effectiveShareable)
396
+ return;
397
+ if (!this.attached || !this.connection)
398
+ return;
399
+ try {
400
+ await this.connection.run('USE memory.main');
401
+ await this.connection.run(`DETACH ${SHAREABLE_ATTACH_ALIAS}`);
402
+ this.attached = false;
403
+ }
404
+ catch {
405
+ // If DETACH fails the lock stays held; surfacing this error would
406
+ // mask the user's original op error. We leave `this.attached` as
407
+ // `true` so a later `attachIfShareable()` doesn't try to ATTACH on
408
+ // top of a still-attached alias.
409
+ }
410
+ }
297
411
  /**
298
412
  * Drop our entry from the shared `activeDBs` bookkeeping. If we were
299
413
  * the last sharer, close the underlying `DuckDBInstance`.
package/dist/native.js CHANGED
@@ -149,6 +149,16 @@ const duckdb_connection_1 = require("./duckdb_connection");
149
149
  type: 'boolean',
150
150
  optional: true,
151
151
  },
152
+ {
153
+ name: 'shareable',
154
+ displayName: 'Shareable',
155
+ type: 'boolean',
156
+ optional: true,
157
+ description: 'When true, release the database file between operations so other ' +
158
+ 'tools (malloy-cli, the duckdb CLI, another malloy host) can use ' +
159
+ 'the same file while this connection is open. Adds a small ' +
160
+ 'per-operation overhead. Default false.',
161
+ },
152
162
  {
153
163
  name: 'setupSQL',
154
164
  displayName: 'Setup SQL',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@malloydata/db-duckdb",
3
- "version": "0.0.390",
3
+ "version": "0.0.392",
4
4
  "license": "MIT",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -53,6 +53,7 @@
53
53
  "lint-fix": "eslint '**/*.ts{,x}' --fix",
54
54
  "test": "jest --config=../../jest.config.js",
55
55
  "build": "tsc --build",
56
+ "dev": "tsc --build",
56
57
  "clean": "tsc --build --clean && rm -f tsconfig.tsbuildinfo",
57
58
  "malloyc": "ts-node ../../scripts/malloy-to-json",
58
59
  "prepublishOnly": "npm run build"
@@ -60,7 +61,7 @@
60
61
  "dependencies": {
61
62
  "@duckdb/duckdb-wasm": "1.33.1-dev13.0",
62
63
  "@duckdb/node-api": "1.4.4-r.1",
63
- "@malloydata/malloy": "0.0.390",
64
+ "@malloydata/malloy": "0.0.392",
64
65
  "@motherduck/wasm-client": "^0.6.6",
65
66
  "apache-arrow": "^17.0.0",
66
67
  "web-worker": "^1.3.0"