@deepagents/text2sql 3.1.0 → 4.0.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/README.md CHANGED
@@ -34,7 +34,7 @@ Requires Node.js LTS
34
34
  import { groq } from '@ai-sdk/groq';
35
35
  import pg from 'pg';
36
36
 
37
- import { Text2Sql } from '@deepagents/text2sql';
37
+ import { FileIndexLock, Text2Sql } from '@deepagents/text2sql';
38
38
  import {
39
39
  Postgres,
40
40
  columnValues,
@@ -69,6 +69,7 @@ const adapter = new Postgres({
69
69
  const text2sql = new Text2Sql({
70
70
  model: groq('openai/gpt-oss-20b'),
71
71
  adapters: { main: adapter },
72
+ lock: new FileIndexLock(),
72
73
  });
73
74
 
74
75
  // Generate SQL
@@ -82,6 +83,8 @@ console.log(sql);
82
83
  The adapter-map key (`main` here) is the adapter name. Reuse that same key in
83
84
  `text2sql.toSql(..., 'main')` and in any `sql validate <db> "..."` /
84
85
  `sql run <db> "..."` calls from a sandbox where the package CLI is installed.
86
+ This is the configured connection name, not the SQL-level database or schema
87
+ name (for example, SQLite's default `main` schema).
85
88
 
86
89
  Adapter names must match `/^[A-Za-z_][A-Za-z0-9_]*$/`. If you build adapter
87
90
  maps dynamically, use `isValidAdapterName(name)` to check one key or
@@ -170,15 +173,21 @@ or upload/write that module before you call `sql index` or `chat()`.
170
173
 
171
174
  `instructions()` returns the SQL-flavored system fragments (policies, workflows,
172
175
  clarifications, style guides) — spread them into `context.set()` alongside the
173
- schema fragments returned by `sql index`. Add or replace them with your
174
- own domain fragments as needed.
176
+ schema fragments returned by `sql index`. The index output starts with an
177
+ `available_databases` fragment that lists the exact configured adapter names the
178
+ model must pass to `sql validate <db>` and `sql run <db>`. Add or replace the
179
+ instructions with your own domain fragments as needed.
175
180
 
176
181
  ## Advanced: SQL CLI in Sandboxes
177
182
 
178
183
  `sql validate <db> "..."` and `sql run <db> "..."` are real commands from the
179
- `@deepagents/text2sql` package. `sql index` writes schema fragments plus
180
- progress events for chat setup, indexing all configured adapters by default
181
- (same as `--all`) unless adapter names are provided.
184
+ `@deepagents/text2sql` package. `<db>` is the configured adapter name. With a
185
+ single configured adapter, a mistaken database selector is routed to that sole
186
+ adapter and the command prints a note; with multiple adapters, unknown names
187
+ fail and print the available list. `sql index` writes an `available_databases`
188
+ fragment, schema fragments, and progress events for chat setup, indexing all
189
+ configured adapters by default (same as `--all`) unless adapter names are
190
+ provided.
182
191
 
183
192
  Install the package inside the sandbox and set `TEXT2SQL_ADAPTERS` to a module
184
193
  whose default export is `Record<string, Adapter>`. Missing `sql` means the
@@ -208,11 +217,16 @@ import { InMemoryFs, createVirtualSandbox } from '@deepagents/context';
208
217
  import {
209
218
  type CreateSqlCommandOptions,
210
219
  type CreateSqlCommandResult,
220
+ FileIndexLock,
211
221
  Text2Sql,
212
222
  createSqlCommand,
213
223
  } from '@deepagents/text2sql';
214
224
 
215
- const text2sql = new Text2Sql({ model, adapters: { main: adapter } });
225
+ const text2sql = new Text2Sql({
226
+ model,
227
+ adapters: { main: adapter },
228
+ lock: new FileIndexLock(),
229
+ });
216
230
 
217
231
  const commandOptions: CreateSqlCommandOptions = {
218
232
  outputDir: '/sql-artifacts',
@@ -334,8 +348,8 @@ for await (const chunk of followUp) {
334
348
  ## Schema index caching & coordination
335
349
 
336
350
  Schema introspection is the expensive part of indexing. `Text2Sql` and
337
- `AdapterIndexer` own no storage and no locking — you inject both as optional
338
- primitives, keyed per adapter:
351
+ `AdapterIndexer` own no storage and no locking — you inject them, keyed per
352
+ adapter. A `lock` is **required**; a `cache` is optional:
339
353
 
340
354
  ```typescript
341
355
  export interface IndexCache {
@@ -350,27 +364,35 @@ export interface IndexLock {
350
364
  }
351
365
  ```
352
366
 
353
- - **No `cache`** → every `index()` introspects fresh (no cache events).
354
- - **`cache` only** → warm reads skip introspection within whatever the cache
355
- spans.
356
- - **`cache` + `lock`** → introspection is single-flight: concurrent callers for
357
- the same adapter wait on the lock, then read the warm cache.
367
+ - **`lock` only** (no `cache`) → introspection is serialized but not
368
+ deduplicated: each waiter re-introspects, because the cache recheck under the
369
+ lock is what turns serialization into single-flight.
370
+ - **`lock` + `cache`** → introspection is single-flight: concurrent callers for
371
+ the same adapter wait on the lock, then read the warm cache the holder wrote.
358
372
 
359
- A file-backed `IndexCache` ships as a composable primitive:
373
+ File-backed `IndexCache` and `IndexLock` implementations ship as composable
374
+ primitives:
360
375
 
361
376
  ```typescript
362
- import { FileIndexCache, Text2Sql } from '@deepagents/text2sql';
377
+ import { FileIndexCache, FileIndexLock, Text2Sql } from '@deepagents/text2sql';
363
378
 
364
379
  const text2sql = new Text2Sql({
365
380
  model,
366
381
  adapters: { main: adapter },
367
- // Point `dir` at a shared volume to share one cache across processes.
382
+ // Point both `dir`s at a shared volume to share one cache + lock across
383
+ // processes on the same filesystem.
368
384
  cache: new FileIndexCache({ dir: '/var/cache/text2sql', namespace: 'v1' }),
385
+ lock: new FileIndexLock({ dir: '/var/cache/text2sql', namespace: 'v1' }),
369
386
  });
370
387
  ```
371
388
 
372
389
  `FileIndexCache` writes atomically (temp + rename) and treats an unparseable
373
- file as a miss, so a torn read self-heals into a re-introspect.
390
+ file as a miss, so a torn read self-heals into a re-introspect. `FileIndexLock`
391
+ (built on `proper-lockfile`, options `{ dir?, namespace?, stale?, retries? }`)
392
+ serializes processes that share a POSIX filesystem; a held lock auto-refreshes
393
+ its mtime so a slow introspection is not mistaken for a crash, and acquisition
394
+ fails closed once retries are exhausted. Both `dir`s default to the OS temp
395
+ directory.
374
396
 
375
397
  ### Horizontally-scaled deployments
376
398
 
@@ -378,8 +400,10 @@ Running many processes/containers against one database (e.g. a fleet of
378
400
  daemons)? Two coordination tiers:
379
401
 
380
402
  - The **lock alone** serializes introspection — never two concurrent
381
- introspections of the same database (caps peak DB load). Back it with a
382
- distributed lock you already operate (a Redis lock, a Postgres
403
+ introspections of the same database (caps peak DB load). The shipped
404
+ `FileIndexLock` covers processes that share a POSIX filesystem (one host, or a
405
+ shared volume — NFSv4 / EFS); for hosts without a shared filesystem, back it
406
+ with a distributed lock you already operate (a Redis lock, a Postgres
383
407
  `pg_advisory_lock`, etc.).
384
408
  - The **lock plus a shared cache directory** gives fleet-wide single-flight:
385
409
  the lock holder writes the cache on the shared volume, and every waiter reads
@@ -405,11 +429,12 @@ import { createUIMessageStream } from 'ai';
405
429
 
406
430
  import {
407
431
  AdapterIndexer,
432
+ FileIndexLock,
408
433
  TEXT2SQL_INDEX_PROGRESS_CHUNK,
409
434
  type Text2SqlIndexProgressEvent,
410
435
  } from '@deepagents/text2sql';
411
436
 
412
- const indexer = new AdapterIndexer({ adapters });
437
+ const indexer = new AdapterIndexer({ adapters, lock: new FileIndexLock() });
413
438
 
414
439
  await context.continue(user('Show me top 10 customers'));
415
440
 
package/dist/bin/sql.js CHANGED
@@ -68,6 +68,51 @@ var FileIndexCache = class {
68
68
  }
69
69
  };
70
70
 
71
+ // packages/text2sql/src/lib/index-lock.ts
72
+ import { createHash as createHash2 } from "node:crypto";
73
+ import { mkdir as mkdir2 } from "node:fs/promises";
74
+ import { tmpdir as tmpdir2 } from "node:os";
75
+ import * as path2 from "node:path";
76
+ import { lock as acquireLock } from "proper-lockfile";
77
+ var DEFAULT_STALE_MS = 3e4;
78
+ var DEFAULT_RETRIES = {
79
+ retries: 10,
80
+ factor: 2,
81
+ minTimeout: 100,
82
+ maxTimeout: 2e3
83
+ };
84
+ var FileIndexLock = class {
85
+ #dir;
86
+ #namespace;
87
+ #stale;
88
+ #retries;
89
+ constructor(options = {}) {
90
+ this.#dir = options.dir ?? tmpdir2();
91
+ this.#namespace = options.namespace;
92
+ this.#stale = options.stale ?? DEFAULT_STALE_MS;
93
+ this.#retries = options.retries ?? DEFAULT_RETRIES;
94
+ }
95
+ async run(key, fn) {
96
+ await mkdir2(this.#dir, { recursive: true });
97
+ const release = await acquireLock(this.#lockPathFor(key), {
98
+ realpath: false,
99
+ stale: this.#stale,
100
+ retries: this.#retries
101
+ });
102
+ try {
103
+ return await fn();
104
+ } finally {
105
+ await release().catch(() => {
106
+ });
107
+ }
108
+ }
109
+ #lockPathFor(key) {
110
+ const watermark = this.#namespace ? `${this.#namespace}-${key}` : key;
111
+ const hash = createHash2("md5").update(watermark).digest("hex");
112
+ return path2.join(this.#dir, `text2sql-lock-${hash}`);
113
+ }
114
+ };
115
+
71
116
  // packages/text2sql/src/lib/sql.ts
72
117
  import "@deepagents/agent";
73
118
 
@@ -76,6 +121,8 @@ import { fragment } from "@deepagents/context";
76
121
 
77
122
  // packages/text2sql/src/lib/adapter-name.ts
78
123
  var ADAPTER_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
124
+ var DATABASE_NAME_FRAGMENT = "database";
125
+ var RESERVED_ADAPTER_NAMES = /* @__PURE__ */ new Set([DATABASE_NAME_FRAGMENT]);
79
126
  function isValidAdapterName(name) {
80
127
  return name != null && ADAPTER_NAME_PATTERN.test(name);
81
128
  }
@@ -86,6 +133,9 @@ function validateAdapterNames(names) {
86
133
  `Invalid adapter name "${name}": must match ${ADAPTER_NAME_PATTERN}`
87
134
  );
88
135
  }
136
+ if (RESERVED_ADAPTER_NAMES.has(name)) {
137
+ throw new Error(`Adapter name "${name}" is reserved`);
138
+ }
89
139
  }
90
140
  }
91
141
 
@@ -197,6 +247,9 @@ function timestampProgressHandler(onProgress) {
197
247
  });
198
248
  };
199
249
  }
250
+ function databaseNameFragment(name) {
251
+ return { name: DATABASE_NAME_FRAGMENT, data: name };
252
+ }
200
253
  var AdapterIndexer = class {
201
254
  #adapters;
202
255
  #cache;
@@ -223,7 +276,7 @@ var AdapterIndexer = class {
223
276
  const settled = await Promise.allSettled(
224
277
  entries.map(async ([name, adapter]) => {
225
278
  const schema = await this.#indexAdapter(name, adapter, progress);
226
- return fragment(name, ...schema);
279
+ return fragment(name, databaseNameFragment(name), ...schema);
227
280
  })
228
281
  );
229
282
  const failed = settled.find((result) => result.status === "rejected");
@@ -310,7 +363,7 @@ var AdapterIndexer = class {
310
363
  });
311
364
  return cached;
312
365
  }
313
- const fragments = this.#lock ? await this.#lock.run(key, populate) : await populate();
366
+ const fragments = await this.#lock.run(key, populate);
314
367
  if (servedFromCache) emitCacheHit();
315
368
  progress({
316
369
  type: "adapter:end",
@@ -926,14 +979,25 @@ var Text2Sql = class {
926
979
  };
927
980
 
928
981
  // packages/text2sql/src/bin/command.ts
929
- import * as path2 from "node:path";
982
+ import * as path3 from "node:path";
983
+
984
+ // packages/text2sql/src/lib/resolve-adapter.ts
985
+ function resolveAdapter(text2Sql, db) {
986
+ if (text2Sql.hasAdapter(db)) return db;
987
+ const names = text2Sql.adapterNames();
988
+ if (names.length === 1) return names[0];
989
+ const available = names.join(", ") || "(none configured)";
990
+ throw new Error(`unknown database "${db}". Available: ${available}`);
991
+ }
992
+
993
+ // packages/text2sql/src/bin/command.ts
930
994
  var OUT_DIR_OPTION = {
931
995
  flag: "--out-dir <path>",
932
996
  description: "Output directory (default: $TEXT2SQL_OUT_DIR or ./sql)"
933
997
  };
934
998
  function resolveOutputDir(ctx, options) {
935
999
  const explicit = typeof options.outDir === "string" ? options.outDir : void 0;
936
- return path2.resolve(ctx.cwd, explicit ?? ctx.env.TEXT2SQL_OUT_DIR ?? "./sql");
1000
+ return path3.resolve(ctx.cwd, explicit ?? ctx.env.TEXT2SQL_OUT_DIR ?? "./sql");
937
1001
  }
938
1002
  var CommandError = class extends Error {
939
1003
  command;
@@ -958,24 +1022,25 @@ var SqlCommand = class {
958
1022
  fail(message, exitCode = 1) {
959
1023
  throw new CommandError(this.name, message, exitCode);
960
1024
  }
961
- requireAdapterName(text2Sql, db) {
962
- const available = text2Sql.adapterNames().join(", ") || "(none configured)";
1025
+ resolveAdapterName(text2Sql, db) {
963
1026
  if (!db) {
1027
+ const available = text2Sql.adapterNames().join(", ") || "(none configured)";
964
1028
  this.fail(
965
1029
  `missing database name. Usage: sql ${this.name} ${this.usage}. Available: ${available}`
966
1030
  );
967
1031
  }
968
- if (!text2Sql.hasAdapter(db)) {
969
- this.fail(`unknown database "${db}". Available: ${available}`);
1032
+ try {
1033
+ return resolveAdapter(text2Sql, db);
1034
+ } catch (error) {
1035
+ this.fail(errorMessage2(error));
970
1036
  }
971
- return db;
972
1037
  }
973
1038
  };
974
1039
  var SqlQueryCommand = class extends SqlCommand {
975
1040
  args = "<db> <...sql>";
976
1041
  async execute(ctx, args, options) {
977
1042
  const [db, sqlParts] = args;
978
- const name = this.requireAdapterName(ctx.text2Sql, db);
1043
+ const name = this.resolveAdapterName(ctx.text2Sql, db);
979
1044
  const sql = (sqlParts ?? []).join(" ").trim();
980
1045
  if (!sql) this.fail("no query provided");
981
1046
  try {
@@ -991,12 +1056,12 @@ var SqlQueryCommand = class extends SqlCommand {
991
1056
  // packages/text2sql/src/bin/commands/index.ts
992
1057
  import { constants, createWriteStream } from "node:fs";
993
1058
  import {
994
- mkdir as mkdir2,
1059
+ mkdir as mkdir3,
995
1060
  open,
996
1061
  stat,
997
1062
  writeFile as writeFile2
998
1063
  } from "node:fs/promises";
999
- import * as path3 from "node:path";
1064
+ import * as path4 from "node:path";
1000
1065
  import { finished } from "node:stream/promises";
1001
1066
  import { v7 } from "uuid";
1002
1067
 
@@ -1040,13 +1105,13 @@ var IndexCommand = class extends SqlCommand {
1040
1105
  const verbose = this.resolveVerbose(options.verbose);
1041
1106
  const outputDir = resolveOutputDir(ctx, options);
1042
1107
  const id = v7();
1043
- const fragmentsPath = path3.join(outputDir, `index-${id}.json`);
1108
+ const fragmentsPath = path4.join(outputDir, `index-${id}.json`);
1044
1109
  const explicitEventsPath = resolveExplicitEventsPath(ctx);
1045
- const eventsPath = explicitEventsPath ?? path3.join(outputDir, `index-${id}.events.ndjson`);
1110
+ const eventsPath = explicitEventsPath ?? path4.join(outputDir, `index-${id}.events.ndjson`);
1046
1111
  try {
1047
- await mkdir2(outputDir, { recursive: true });
1112
+ await mkdir3(outputDir, { recursive: true });
1048
1113
  if (explicitEventsPath) {
1049
- await mkdir2(path3.dirname(explicitEventsPath), { recursive: true });
1114
+ await mkdir3(path4.dirname(explicitEventsPath), { recursive: true });
1050
1115
  }
1051
1116
  const eventsStream = await openEventsWriter(
1052
1117
  eventsPath,
@@ -1062,8 +1127,7 @@ var IndexCommand = class extends SqlCommand {
1062
1127
  const manifest = {
1063
1128
  fragmentsPath,
1064
1129
  eventsPath,
1065
- adapters: names,
1066
- fragments: countSchemaFragments(fragments)
1130
+ adapters: names
1067
1131
  };
1068
1132
  ctx.stdout.write(JSON.stringify(manifest, null, 2) + "\n");
1069
1133
  return 0;
@@ -1107,7 +1171,7 @@ function createProgressHandler(eventsStream, verbose, stderr) {
1107
1171
  }
1108
1172
  function resolveExplicitEventsPath(ctx) {
1109
1173
  const explicit = ctx.env.TEXT2SQL_INDEX_EVENTS_PATH?.trim();
1110
- return explicit ? path3.resolve(ctx.cwd, explicit) : null;
1174
+ return explicit ? path4.resolve(ctx.cwd, explicit) : null;
1111
1175
  }
1112
1176
  async function openEventsWriter(eventsPath, explicitEventsPath, stderr) {
1113
1177
  const probe = explicitEventsPath ? await probeFifoWriter(explicitEventsPath, stderr) : null;
@@ -1164,21 +1228,13 @@ function waitForOpen(stream) {
1164
1228
  stream.once("error", onError);
1165
1229
  });
1166
1230
  }
1167
- function countSchemaFragments(fragments) {
1168
- return fragments.reduce((count, adapterFragment) => {
1169
- if (Array.isArray(adapterFragment.data)) {
1170
- return count + adapterFragment.data.length;
1171
- }
1172
- return count + 1;
1173
- }, 0);
1174
- }
1175
1231
  function dedupe(values) {
1176
1232
  return [...new Set(values)];
1177
1233
  }
1178
1234
 
1179
1235
  // packages/text2sql/src/bin/commands/run.ts
1180
- import { mkdir as mkdir3, writeFile as writeFile3 } from "node:fs/promises";
1181
- import * as path4 from "node:path";
1236
+ import { mkdir as mkdir4, writeFile as writeFile3 } from "node:fs/promises";
1237
+ import * as path5 from "node:path";
1182
1238
  import { v7 as v72 } from "uuid";
1183
1239
  var RunCommand = class extends SqlQueryCommand {
1184
1240
  name = "run";
@@ -1189,9 +1245,9 @@ var RunCommand = class extends SqlQueryCommand {
1189
1245
  async runQuery(ctx, name, sql, options) {
1190
1246
  const outputDir = resolveOutputDir(ctx, options);
1191
1247
  const { rows, columns } = await ctx.text2Sql.run(name, sql);
1192
- const outPath = path4.join(outputDir, `${v72()}.json`);
1248
+ const outPath = path5.join(outputDir, `${v72()}.json`);
1193
1249
  try {
1194
- await mkdir3(outputDir, { recursive: true });
1250
+ await mkdir4(outputDir, { recursive: true });
1195
1251
  await writeFile3(outPath, JSON.stringify(rows, null, 2));
1196
1252
  } catch (error) {
1197
1253
  this.fail(errorMessage2(error));
@@ -1336,12 +1392,19 @@ function resolveIndexCache() {
1336
1392
  }
1337
1393
  return new FileIndexCache({ dir, namespace });
1338
1394
  }
1395
+ function resolveIndexLock() {
1396
+ return new FileIndexLock({
1397
+ dir: process.env.TEXT2SQL_INDEX_LOCK_DIR ?? process.env.TEXT2SQL_INDEX_CACHE_DIR,
1398
+ namespace: process.env.TEXT2SQL_INDEX_VERSION
1399
+ });
1400
+ }
1339
1401
  async function runCommand(command, positional, options) {
1340
1402
  let ctx;
1341
1403
  try {
1342
1404
  const text2Sql = new Text2Sql({
1343
1405
  adapters: await loadAdapters(),
1344
- cache: resolveIndexCache()
1406
+ cache: resolveIndexCache(),
1407
+ lock: resolveIndexLock()
1345
1408
  });
1346
1409
  ctx = {
1347
1410
  text2Sql,