sandal-db 1.0.7 → 1.0.8

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 (3) hide show
  1. package/README.md +5 -5
  2. package/dist/cli.js +442 -109
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -136,7 +136,7 @@ npx sandal-db@latest md [options] [fileOrText]
136
136
  | `-k, --key <key>` | `string` | API key corresponding to the selected provider. | Environment / Config |
137
137
  | `--strict` | `boolean` | Hard-blocks full database drops and bulk table truncations. | `true` |
138
138
  | `--no-strict` | `flag` | Disables strict safety mode. | `false` |
139
- | `--allow-full-wipe` | `flag` | Explicitly permits database-level drops after interactive phrase confirmation. | `false` |
139
+ | `--allow-full-wipe` | `flag` | Explicitly permits full database wipes and db admin tasks after interactive confirmation. | `false` |
140
140
  | `--threshold <number>` | `integer` | Row count threshold above which updates are classified as dangerous operations. | `50` |
141
141
  | `--markdown` | `boolean` | Formats and renders LLM responses as rich terminal markdown. | `true` |
142
142
  | `--no-markdown` | `flag` | Disables terminal markdown rendering and outputs raw text. | `false` |
@@ -404,16 +404,16 @@ Any mutation query lacking a filter or `WHERE` clause poses extreme operational
404
404
  ? Destructive operation with no filter. Type "DELETE ALL" to confirm:
405
405
  ```
406
406
 
407
- ### 6. Strict Mode and Full-Wipe Hard Blocking
407
+ ### 6. Strict Mode, Full-Wipe, and Admin Tasks Guardrails
408
408
 
409
- By default, `--strict` mode is enabled. Any operation that attempts to drop an entire database or drop all tables is blocked unconditionally:
409
+ By default, `--strict` mode is enabled and `--allow-full-wipe` is disabled. Any operation that attempts to drop an entire database, drop all tables, or execute database administration tasks (e.g. user/role drops, privilege changes, maintenance locks, backend termination) is blocked unconditionally:
410
410
 
411
411
  ```text
412
- Operation blocked by --strict mode: full database or all-table drop is prohibited.
412
+ Operation blocked by --strict mode: database admin tasks are prohibited.
413
413
  To allow this, start sandal-db with --allow-full-wipe.
414
414
  ```
415
415
 
416
- To permit database-level destructions, the user must explicitly supply `--allow-full-wipe` at startup and subsequently type the required confirmation string during interactive execution.
416
+ To permit database-level destructions and administrative tasks, the user must explicitly supply `--allow-full-wipe` at startup and subsequently confirm the operation during interactive execution.
417
417
 
418
418
  ---
419
419
 
package/dist/cli.js CHANGED
@@ -916,16 +916,33 @@ var PostgresAdapter = class {
916
916
  const startTime = Date.now();
917
917
  const client = await this.pool.connect();
918
918
  try {
919
- const res = await client.query(sql, query.params || []);
919
+ const hasParams = Array.isArray(query.params) && query.params.length > 0;
920
+ const res = hasParams ? await client.query(sql, query.params) : await client.query(sql);
920
921
  const durationMs = Date.now() - startTime;
921
922
  let rows = [];
922
923
  let fields = [];
923
924
  let rowCount = 0;
924
925
  if (Array.isArray(res)) {
926
+ let totalAffected = 0;
927
+ const allRows = [];
928
+ let lastFields = [];
929
+ for (const stmtRes of res) {
930
+ if (stmtRes) {
931
+ if (typeof stmtRes.rowCount === "number") {
932
+ totalAffected += stmtRes.rowCount;
933
+ }
934
+ if (stmtRes.rows && stmtRes.rows.length > 0) {
935
+ allRows.push(...stmtRes.rows);
936
+ }
937
+ if (stmtRes.fields && stmtRes.fields.length > 0) {
938
+ lastFields = stmtRes.fields.map((f) => f.name);
939
+ }
940
+ }
941
+ }
925
942
  const lastResult = res[res.length - 1];
926
- rows = lastResult?.rows || [];
927
- fields = (lastResult?.fields || []).map((f) => f.name);
928
- rowCount = lastResult?.rowCount ?? rows.length;
943
+ rows = allRows.length > 0 ? allRows : lastResult?.rows || [];
944
+ fields = lastFields.length > 0 ? lastFields : (lastResult?.fields || []).map((f) => f.name);
945
+ rowCount = totalAffected > 0 ? totalAffected : lastResult?.rowCount ?? rows.length;
929
946
  } else if (res) {
930
947
  rows = res.rows || [];
931
948
  fields = (res.fields || []).map((f) => f.name);
@@ -938,7 +955,7 @@ var PostgresAdapter = class {
938
955
  rowCount,
939
956
  affectedRows: rowCount,
940
957
  durationMs,
941
- command: Array.isArray(res) ? res[res.length - 1]?.command : res?.command
958
+ command: Array.isArray(res) ? res.map((r) => r?.command).filter(Boolean).join("; ") : res?.command
942
959
  };
943
960
  } catch (err) {
944
961
  const durationMs = Date.now() - startTime;
@@ -1168,6 +1185,39 @@ var MongoAdapter = class {
1168
1185
  rows: [result]
1169
1186
  };
1170
1187
  }
1188
+ let operationsList = query.operations;
1189
+ if (!operationsList && typeof query.rawDisplay === "string") {
1190
+ try {
1191
+ const parsedDisplay = JSON.parse(query.rawDisplay);
1192
+ if (Array.isArray(parsedDisplay.operations)) {
1193
+ operationsList = parsedDisplay.operations;
1194
+ }
1195
+ } catch {
1196
+ }
1197
+ }
1198
+ if (Array.isArray(operationsList) && operationsList.length > 0) {
1199
+ let totalAffected = 0;
1200
+ const allRows = [];
1201
+ for (const op of operationsList) {
1202
+ const subResult = await this.executeQuery({
1203
+ ...op,
1204
+ rawDisplay: typeof op === "string" ? op : JSON.stringify(op)
1205
+ });
1206
+ if (!subResult.success) {
1207
+ return subResult;
1208
+ }
1209
+ totalAffected += subResult.affectedRows ?? subResult.rowCount ?? 0;
1210
+ if (subResult.rows) allRows.push(...subResult.rows);
1211
+ }
1212
+ return {
1213
+ success: true,
1214
+ rows: allRows,
1215
+ rowCount: allRows.length,
1216
+ affectedRows: totalAffected,
1217
+ durationMs: Date.now() - startTime,
1218
+ command: "multi-operation"
1219
+ };
1220
+ }
1171
1221
  let collectionName = query.collection;
1172
1222
  let operation = query.operation;
1173
1223
  let filter = query.filter || {};
@@ -1191,6 +1241,19 @@ var MongoAdapter = class {
1191
1241
  }
1192
1242
  }
1193
1243
  if (!collectionName) {
1244
+ if (operation === "dropDatabase") {
1245
+ const res = await db.dropDatabase();
1246
+ const durationMs2 = Date.now() - startTime;
1247
+ return {
1248
+ success: true,
1249
+ rows: [{ droppedDatabase: res }],
1250
+ rowCount: res ? 1 : 0,
1251
+ affectedRows: 0,
1252
+ fields: ["droppedDatabase"],
1253
+ durationMs: durationMs2,
1254
+ command: "dropDatabase"
1255
+ };
1256
+ }
1194
1257
  throw new Error("Mongo operation requires a target collection.");
1195
1258
  }
1196
1259
  const collection = db.collection(collectionName);
@@ -1280,6 +1343,12 @@ var MongoAdapter = class {
1280
1343
  rowCount = 1;
1281
1344
  break;
1282
1345
  }
1346
+ case "dropDatabase": {
1347
+ const res = await db.dropDatabase();
1348
+ rows = [{ droppedDatabase: res }];
1349
+ rowCount = res ? 1 : 0;
1350
+ break;
1351
+ }
1283
1352
  default:
1284
1353
  throw new Error(`Unsupported MongoDB operation: ${operation}`);
1285
1354
  }
@@ -1308,6 +1377,15 @@ var MongoAdapter = class {
1308
1377
  await this.connect();
1309
1378
  }
1310
1379
  try {
1380
+ if (Array.isArray(query.operations)) {
1381
+ let total = 0;
1382
+ for (const op of query.operations) {
1383
+ const opObj = typeof op === "object" && op !== null && op.rawDisplay ? op : { ...op, rawDisplay: JSON.stringify(op) };
1384
+ const count = await this.dryRunCount(opObj);
1385
+ if (count !== null) total += count;
1386
+ }
1387
+ return total > 0 ? total : null;
1388
+ }
1311
1389
  let collectionName = query.collection;
1312
1390
  let operation = query.operation;
1313
1391
  let filter = query.filter || {};
@@ -1523,6 +1601,7 @@ function classifyPostgresQuery(sql, query, rowThreshold, options) {
1523
1601
  isDestructive: true,
1524
1602
  isStructural: false,
1525
1603
  isFullWipe: true,
1604
+ isAdmin: true,
1526
1605
  requiresLiteralWord: true,
1527
1606
  literalWord: "DROP DATABASE",
1528
1607
  hasWhereClause: false,
@@ -1530,94 +1609,89 @@ function classifyPostgresQuery(sql, query, rowThreshold, options) {
1530
1609
  explanation: "Permanently deletes database or schema cascade."
1531
1610
  };
1532
1611
  }
1533
- const isCreateIndex = /CREATE\s+(UNIQUE\s+)?INDEX/i.test(cleanSql);
1534
- const isCreateTable = /CREATE\s+TABLE/i.test(cleanSql);
1535
- const isCreateSchema = /CREATE\s+SCHEMA/i.test(cleanSql);
1536
- const isAlterAdd = /ALTER\s+TABLE\s+([^\s;]+)\s+ADD\s+(COLUMN|CONSTRAINT)/i.test(cleanSql);
1537
- const isAlterOtherSafe = /ALTER\s+TABLE\s+([^\s;]+)\s+(ALTER\s+COLUMN|RENAME)/i.test(cleanSql);
1538
- if (isCreateIndex) {
1539
- const isConcurrently = /CREATE\s+(UNIQUE\s+)?INDEX\s+CONCURRENTLY/i.test(cleanSql);
1540
- let suggestion;
1541
- if (!isConcurrently) {
1542
- warnings.push(
1543
- "Index is not being created CONCURRENTLY. This will lock write operations on the table during index creation."
1544
- );
1545
- suggestion = cleanSql.replace(/CREATE\s+(UNIQUE\s+)?INDEX/i, (m) => `${m} CONCURRENTLY`);
1546
- }
1547
- const tableMatch = cleanSql.match(/ON\s+([^\s;(]+)/i);
1548
- const tableName = tableMatch ? tableMatch[1] : void 0;
1612
+ const isUserOrRole = /^(?:CREATE|ALTER|DROP)\s+(?:USER|ROLE)\b/i.test(cleanSql);
1613
+ if (isUserOrRole) {
1614
+ const isDrop = /^DROP\s+(?:USER|ROLE)\b/i.test(cleanSql);
1615
+ const isRole = /\bROLE\b/i.test(cleanSql);
1616
+ const word = isRole ? "DROP ROLE" : "DROP USER";
1549
1617
  return {
1550
- category: "structural",
1551
- isDestructive: false,
1552
- isStructural: true,
1618
+ category: "admin",
1619
+ isDestructive: isDrop,
1620
+ isStructural: false,
1553
1621
  isFullWipe: false,
1554
- requiresLiteralWord: false,
1622
+ isAdmin: true,
1623
+ requiresLiteralWord: isDrop,
1624
+ literalWord: isDrop ? word : void 0,
1555
1625
  hasWhereClause: false,
1556
- tableOrCollection: tableName,
1557
- explanation: `Creates an index on ${tableName || "table"}${isConcurrently ? " concurrently (non-blocking)" : " (may lock table during build)"}.`,
1558
- warnings,
1559
- suggestedAlternative: suggestion
1626
+ warnings: isDrop ? ["Permanently deletes database credentials and associated privileges."] : [],
1627
+ explanation: `${isDrop ? "Drops" : "Manages"} database ${isRole ? "role" : "user"}.`
1560
1628
  };
1561
1629
  }
1562
- if (isCreateTable) {
1563
- const match = cleanSql.match(/CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?([^\s;(]+)/i);
1564
- const tableName = match ? match[1] : void 0;
1630
+ const isGrant = /^GRANT\s+/i.test(cleanSql);
1631
+ const isRevoke = /^REVOKE\s+/i.test(cleanSql);
1632
+ if (isGrant || isRevoke) {
1565
1633
  return {
1566
- category: "structural",
1634
+ category: "admin",
1567
1635
  isDestructive: false,
1568
- isStructural: true,
1636
+ isStructural: false,
1569
1637
  isFullWipe: false,
1638
+ isAdmin: true,
1570
1639
  requiresLiteralWord: false,
1571
1640
  hasWhereClause: false,
1572
- tableOrCollection: tableName,
1573
- explanation: `Creates a new table "${tableName || "unknown"}".`,
1574
- warnings
1641
+ warnings: isRevoke ? ["Revoking privileges modifies access control permissions."] : [],
1642
+ explanation: isGrant ? "Grants database privileges." : "Revokes database privileges."
1575
1643
  };
1576
1644
  }
1577
- if (isCreateSchema) {
1578
- const match = cleanSql.match(/CREATE\s+SCHEMA\s+(?:IF\s+NOT\s+EXISTS\s+)?([^\s;(]+)/i);
1579
- const schemaName = match ? match[1] : void 0;
1645
+ const isVacuum = /^VACUUM\b/i.test(cleanSql);
1646
+ const isAnalyze = /^ANALYZE\b/i.test(cleanSql);
1647
+ const isReindex = /^REINDEX\b/i.test(cleanSql);
1648
+ const isCheckpoint = /^CHECKPOINT\b/i.test(cleanSql);
1649
+ if (isVacuum || isAnalyze || isReindex || isCheckpoint) {
1650
+ const isVacuumFull = /^VACUUM\s+FULL\b/i.test(cleanSql);
1651
+ const maintenanceWarnings = [];
1652
+ if (isVacuumFull) {
1653
+ maintenanceWarnings.push(
1654
+ "VACUUM FULL exclusively locks tables and may cause operational downtime on active tables."
1655
+ );
1656
+ }
1580
1657
  return {
1581
- category: "structural",
1658
+ category: "admin",
1582
1659
  isDestructive: false,
1583
- isStructural: true,
1660
+ isStructural: false,
1584
1661
  isFullWipe: false,
1662
+ isAdmin: true,
1585
1663
  requiresLiteralWord: false,
1586
1664
  hasWhereClause: false,
1587
- tableOrCollection: schemaName,
1588
- explanation: `Creates a new schema "${schemaName || "unknown"}".`,
1589
- warnings
1665
+ warnings: maintenanceWarnings,
1666
+ explanation: isVacuum ? "Performs database vacuum maintenance." : isReindex ? "Reindexes database tables or indexes." : isAnalyze ? "Collects database statistics for query planner." : "Forces a database checkpoint."
1590
1667
  };
1591
1668
  }
1592
- if (isAlterAdd || isAlterOtherSafe) {
1593
- const match = cleanSql.match(/ALTER\s+TABLE\s+([^\s;(]+)/i);
1594
- const tableName = match ? match[1] : void 0;
1669
+ const isAlterSystemOrDb = /^ALTER\s+(?:SYSTEM|DATABASE)\b/i.test(cleanSql);
1670
+ if (isAlterSystemOrDb) {
1595
1671
  return {
1596
- category: "structural",
1672
+ category: "admin",
1597
1673
  isDestructive: false,
1598
- isStructural: true,
1674
+ isStructural: false,
1599
1675
  isFullWipe: false,
1676
+ isAdmin: true,
1600
1677
  requiresLiteralWord: false,
1601
1678
  hasWhereClause: false,
1602
- tableOrCollection: tableName,
1603
- explanation: `Alters table "${tableName || "unknown"}" to add or modify structural attributes.`,
1604
- warnings
1679
+ warnings: ["Alters database server or cluster configuration settings."],
1680
+ explanation: "Modifies database or system-level configuration."
1605
1681
  };
1606
1682
  }
1607
- const isAlterDrop = /ALTER\s+TABLE\s+([^\s;]+)\s+DROP\s+COLUMN/i.test(cleanSql);
1608
- if (isAlterDrop) {
1609
- const match = cleanSql.match(/ALTER\s+TABLE\s+([^\s;(]+)/i);
1610
- const tableName = match ? match[1] : void 0;
1683
+ const isKillBackend = /\bpg_(?:terminate|cancel)_backend\s*\(/i.test(cleanSql);
1684
+ if (isKillBackend) {
1611
1685
  return {
1612
- category: "dangerous",
1686
+ category: "admin",
1613
1687
  isDestructive: true,
1614
1688
  isStructural: false,
1615
1689
  isFullWipe: false,
1690
+ isAdmin: true,
1616
1691
  requiresLiteralWord: false,
1617
1692
  hasWhereClause: false,
1618
- tableOrCollection: tableName,
1619
- explanation: `Removes a column from table "${tableName || "unknown"}", permanently discarding column data.`,
1620
- warnings: ["Removing a column is permanent and irreversible."]
1693
+ warnings: ["Terminates active database backend connection process."],
1694
+ explanation: "Terminates active backend query connection."
1621
1695
  };
1622
1696
  }
1623
1697
  const isDropTable = /DROP\s+TABLE/i.test(cleanSql);
@@ -1638,9 +1712,25 @@ function classifyPostgresQuery(sql, query, rowThreshold, options) {
1638
1712
  warnings: ["All rows will be permanently deleted."]
1639
1713
  };
1640
1714
  }
1641
- const isDelete = /^DELETE\s+FROM/i.test(cleanSql);
1715
+ const isAlterDrop = /ALTER\s+TABLE\s+([^\s;]+)\s+DROP\s+COLUMN/i.test(cleanSql);
1716
+ if (isAlterDrop) {
1717
+ const match = cleanSql.match(/ALTER\s+TABLE\s+([^\s;(]+)/i);
1718
+ const tableName = match ? match[1] : void 0;
1719
+ return {
1720
+ category: "dangerous",
1721
+ isDestructive: true,
1722
+ isStructural: false,
1723
+ isFullWipe: false,
1724
+ requiresLiteralWord: false,
1725
+ hasWhereClause: false,
1726
+ tableOrCollection: tableName,
1727
+ explanation: `Removes a column from table "${tableName || "unknown"}", permanently discarding column data.`,
1728
+ warnings: ["Removing a column is permanent and irreversible."]
1729
+ };
1730
+ }
1731
+ const isDelete = /\bDELETE\s+FROM\b/i.test(cleanSql);
1642
1732
  if (isDelete) {
1643
- const match = cleanSql.match(/^DELETE\s+FROM\s+([^\s;(]+)(?:\s+WHERE\s+([\s\S]+))?/i);
1733
+ const match = cleanSql.match(/DELETE\s+FROM\s+([^\s;(]+)(?:\s+WHERE\s+([\s\S]+?))?(?:;|$)/i);
1644
1734
  const tableName = match ? match[1] : void 0;
1645
1735
  const hasWhere = Boolean(match && match[2] && match[2].trim().length > 0);
1646
1736
  if (!hasWhere) {
@@ -1657,21 +1747,10 @@ function classifyPostgresQuery(sql, query, rowThreshold, options) {
1657
1747
  warnings: ["No WHERE clause specified: EVERY row in the table will be deleted!"]
1658
1748
  };
1659
1749
  }
1660
- return {
1661
- category: "dangerous",
1662
- isDestructive: true,
1663
- isStructural: false,
1664
- isFullWipe: false,
1665
- requiresLiteralWord: false,
1666
- hasWhereClause: true,
1667
- tableOrCollection: tableName,
1668
- explanation: `Deletes filtered rows from table "${tableName || "unknown"}".`,
1669
- warnings: ["Mutates existing database records."]
1670
- };
1671
1750
  }
1672
- const isUpdate = /^UPDATE\s+/i.test(cleanSql);
1751
+ const isUpdate = /\bUPDATE\s+[^\s;(]+\s+SET\b/i.test(cleanSql);
1673
1752
  if (isUpdate) {
1674
- const match = cleanSql.match(/^UPDATE\s+([^\s;(]+)\s+SET\s+[\s\S]+?(?:\s+WHERE\s+([\s\S]+))?$/i);
1753
+ const match = cleanSql.match(/UPDATE\s+([^\s;(]+)\s+SET\s+[\s\S]+?(?:\s+WHERE\s+([\s\S]+?))?(?:;|$)/i);
1675
1754
  const tableName = match ? match[1] : void 0;
1676
1755
  const hasWhere = Boolean(match && match[2] && match[2].trim().length > 0);
1677
1756
  if (!hasWhere) {
@@ -1688,6 +1767,101 @@ function classifyPostgresQuery(sql, query, rowThreshold, options) {
1688
1767
  warnings: ["No WHERE clause specified: EVERY row in the table will be updated!"]
1689
1768
  };
1690
1769
  }
1770
+ }
1771
+ const isCreateIndex = /CREATE\s+(UNIQUE\s+)?INDEX/i.test(cleanSql);
1772
+ const isCreateTable = /CREATE\s+TABLE/i.test(cleanSql);
1773
+ const isCreateSchema = /CREATE\s+SCHEMA/i.test(cleanSql);
1774
+ const isAlterAdd = /ALTER\s+TABLE\s+([^\s;]+)\s+ADD\s+(COLUMN|CONSTRAINT)/i.test(cleanSql);
1775
+ const isAlterOtherSafe = /ALTER\s+TABLE\s+([^\s;]+)\s+(ALTER\s+COLUMN|RENAME)/i.test(cleanSql);
1776
+ if (isCreateIndex) {
1777
+ const isConcurrently = /CREATE\s+(UNIQUE\s+)?INDEX\s+CONCURRENTLY/i.test(cleanSql);
1778
+ let suggestion;
1779
+ if (!isConcurrently) {
1780
+ warnings.push(
1781
+ "Index is not being created CONCURRENTLY. This will lock write operations on the table during index creation."
1782
+ );
1783
+ suggestion = cleanSql.replace(/CREATE\s+(UNIQUE\s+)?INDEX/i, (m) => `${m} CONCURRENTLY`);
1784
+ }
1785
+ const tableMatch = cleanSql.match(/ON\s+([^\s;(]+)/i);
1786
+ const tableName = tableMatch ? tableMatch[1] : void 0;
1787
+ return {
1788
+ category: "structural",
1789
+ isDestructive: false,
1790
+ isStructural: true,
1791
+ isFullWipe: false,
1792
+ requiresLiteralWord: false,
1793
+ hasWhereClause: false,
1794
+ tableOrCollection: tableName,
1795
+ explanation: `Creates an index on ${tableName || "table"}${isConcurrently ? " concurrently (non-blocking)" : " (may lock table during build)"}.`,
1796
+ warnings,
1797
+ suggestedAlternative: suggestion
1798
+ };
1799
+ }
1800
+ if (isCreateTable) {
1801
+ const tableMatches = [...cleanSql.matchAll(/CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?([^\s;(]+)/gi)].map((m) => m[1]);
1802
+ const hasInserts = /\bINSERT\s+INTO\b/i.test(cleanSql);
1803
+ const tableName = tableMatches[0] || "unknown";
1804
+ const explanation = tableMatches.length > 1 ? `Creates tables (${tableMatches.join(", ")})${hasInserts ? " and populates seed data" : ""}.` : `Creates a new table "${tableName}"${hasInserts ? " and populates seed data" : ""}.`;
1805
+ return {
1806
+ category: "structural",
1807
+ isDestructive: false,
1808
+ isStructural: true,
1809
+ isFullWipe: false,
1810
+ requiresLiteralWord: false,
1811
+ hasWhereClause: false,
1812
+ tableOrCollection: tableName,
1813
+ explanation,
1814
+ warnings
1815
+ };
1816
+ }
1817
+ if (isCreateSchema) {
1818
+ const match = cleanSql.match(/CREATE\s+SCHEMA\s+(?:IF\s+NOT\s+EXISTS\s+)?([^\s;(]+)/i);
1819
+ const schemaName = match ? match[1] : void 0;
1820
+ return {
1821
+ category: "structural",
1822
+ isDestructive: false,
1823
+ isStructural: true,
1824
+ isFullWipe: false,
1825
+ requiresLiteralWord: false,
1826
+ hasWhereClause: false,
1827
+ tableOrCollection: schemaName,
1828
+ explanation: `Creates a new schema "${schemaName || "unknown"}".`,
1829
+ warnings
1830
+ };
1831
+ }
1832
+ if (isAlterAdd || isAlterOtherSafe) {
1833
+ const match = cleanSql.match(/ALTER\s+TABLE\s+([^\s;(]+)/i);
1834
+ const tableName = match ? match[1] : void 0;
1835
+ return {
1836
+ category: "structural",
1837
+ isDestructive: false,
1838
+ isStructural: true,
1839
+ isFullWipe: false,
1840
+ requiresLiteralWord: false,
1841
+ hasWhereClause: false,
1842
+ tableOrCollection: tableName,
1843
+ explanation: `Alters table "${tableName || "unknown"}" to add or modify structural attributes.`,
1844
+ warnings
1845
+ };
1846
+ }
1847
+ if (isDelete) {
1848
+ const match = cleanSql.match(/DELETE\s+FROM\s+([^\s;(]+)(?:\s+WHERE\s+([\s\S]+?))?(?:;|$)/i);
1849
+ const tableName = match ? match[1] : void 0;
1850
+ return {
1851
+ category: "dangerous",
1852
+ isDestructive: true,
1853
+ isStructural: false,
1854
+ isFullWipe: false,
1855
+ requiresLiteralWord: false,
1856
+ hasWhereClause: true,
1857
+ tableOrCollection: tableName,
1858
+ explanation: `Deletes filtered rows from table "${tableName || "unknown"}".`,
1859
+ warnings: ["Mutates existing database records."]
1860
+ };
1861
+ }
1862
+ if (isUpdate) {
1863
+ const match = cleanSql.match(/UPDATE\s+([^\s;(]+)\s+SET\s+[\s\S]+?(?:\s+WHERE\s+([\s\S]+?))?(?:;|$)/i);
1864
+ const tableName = match ? match[1] : void 0;
1691
1865
  return {
1692
1866
  category: "write",
1693
1867
  isDestructive: false,
@@ -1700,10 +1874,12 @@ function classifyPostgresQuery(sql, query, rowThreshold, options) {
1700
1874
  warnings: ["Mutates existing database records."]
1701
1875
  };
1702
1876
  }
1703
- const isInsert = /^INSERT\s+INTO/i.test(cleanSql);
1877
+ const isInsert = /\bINSERT\s+INTO\b/i.test(cleanSql);
1704
1878
  if (isInsert) {
1705
- const match = cleanSql.match(/^INSERT\s+INTO\s+([^\s;(]+)/i);
1706
- const tableName = match ? match[1] : void 0;
1879
+ const insertMatches = [...cleanSql.matchAll(/INSERT\s+INTO\s+([^\s;(]+)/gi)].map((m) => m[1]);
1880
+ const uniqueTables = [...new Set(insertMatches)];
1881
+ const tableName = uniqueTables[0] || "unknown";
1882
+ const explanation = uniqueTables.length > 1 ? `Inserts new records into tables (${uniqueTables.join(", ")}).` : `Inserts new records into table "${tableName}".`;
1707
1883
  return {
1708
1884
  category: "write",
1709
1885
  isDestructive: false,
@@ -1712,7 +1888,7 @@ function classifyPostgresQuery(sql, query, rowThreshold, options) {
1712
1888
  requiresLiteralWord: false,
1713
1889
  hasWhereClause: false,
1714
1890
  tableOrCollection: tableName,
1715
- explanation: `Inserts new records into table "${tableName || "unknown"}".`,
1891
+ explanation,
1716
1892
  warnings: []
1717
1893
  };
1718
1894
  }
@@ -1743,22 +1919,54 @@ function classifyMongoQuery(rawText, query, rowThreshold, options) {
1743
1919
  let collectionName = query.collection;
1744
1920
  let operation = query.operation;
1745
1921
  let filter = query.filter;
1922
+ let operations = query.operations;
1746
1923
  if (!collectionName || !operation) {
1747
1924
  try {
1748
1925
  const parsed = JSON.parse(rawText);
1749
1926
  collectionName = parsed.collection || collectionName;
1750
1927
  operation = parsed.operation || operation;
1751
1928
  filter = parsed.filter || filter;
1929
+ operations = parsed.operations || operations;
1752
1930
  } catch {
1753
1931
  }
1754
1932
  }
1933
+ if (Array.isArray(operations) && operations.length > 0) {
1934
+ const subClassifications = operations.map((op) => {
1935
+ const display = typeof op === "string" ? op : op.rawDisplay || JSON.stringify(op);
1936
+ const opObj = typeof op === "object" && op !== null && op.rawDisplay ? op : { ...op, rawDisplay: display };
1937
+ return classifyMongoQuery(display, opObj, rowThreshold, options);
1938
+ });
1939
+ const fullWipe = subClassifications.find((c) => c.isFullWipe);
1940
+ if (fullWipe) return fullWipe;
1941
+ const admin = subClassifications.find((c) => c.category === "admin" || c.isAdmin);
1942
+ if (admin) return admin;
1943
+ const dangerous = subClassifications.find((c) => c.category === "dangerous");
1944
+ if (dangerous) return dangerous;
1945
+ const structural = subClassifications.find((c) => c.category === "structural");
1946
+ if (structural) return structural;
1947
+ const targetCols = [
1948
+ ...new Set(operations.map((o) => o.collection).filter(Boolean))
1949
+ ];
1950
+ return {
1951
+ category: "write",
1952
+ isDestructive: false,
1953
+ isStructural: false,
1954
+ isFullWipe: false,
1955
+ requiresLiteralWord: false,
1956
+ hasWhereClause: false,
1957
+ tableOrCollection: targetCols.join(", "),
1958
+ explanation: `Executes batch operations across collections (${targetCols.join(", ")}).`,
1959
+ warnings: []
1960
+ };
1961
+ }
1755
1962
  const warnings = [];
1756
- if (operation === "command" && (query.rawCommand?.dropDatabase || rawText.includes("dropDatabase"))) {
1963
+ if (operation === "dropDatabase" || operation === "command" && (query.rawCommand?.dropDatabase || rawText.includes("dropDatabase"))) {
1757
1964
  return {
1758
1965
  category: "full_wipe",
1759
1966
  isDestructive: true,
1760
1967
  isStructural: false,
1761
1968
  isFullWipe: true,
1969
+ isAdmin: true,
1762
1970
  requiresLiteralWord: true,
1763
1971
  literalWord: "DROP DATABASE",
1764
1972
  hasWhereClause: false,
@@ -1766,6 +1974,25 @@ function classifyMongoQuery(rawText, query, rowThreshold, options) {
1766
1974
  explanation: "Drops the entire database."
1767
1975
  };
1768
1976
  }
1977
+ const isAdminCmd = operation === "createUser" || operation === "dropUser" || operation === "grantRolesToUser" || operation === "revokeRolesFromUser" || operation === "repairDatabase" || operation === "compact" || operation === "reIndex" || operation === "command" && Boolean(
1978
+ query.rawCommand?.createUser || query.rawCommand?.dropUser || query.rawCommand?.grantRolesToUser || query.rawCommand?.revokeRolesFromUser || query.rawCommand?.repairDatabase || query.rawCommand?.compact || query.rawCommand?.reIndex || query.rawCommand?.killOp || query.rawCommand?.shutdown || rawText.includes("createUser") || rawText.includes("dropUser") || rawText.includes("killOp") || rawText.includes("repairDatabase")
1979
+ );
1980
+ if (isAdminCmd) {
1981
+ const isDrop = operation === "dropUser" || query.rawCommand?.dropUser || rawText.includes("dropUser");
1982
+ return {
1983
+ category: "admin",
1984
+ isDestructive: isDrop,
1985
+ isStructural: false,
1986
+ isFullWipe: false,
1987
+ isAdmin: true,
1988
+ requiresLiteralWord: isDrop,
1989
+ literalWord: isDrop ? "DROP USER" : void 0,
1990
+ hasWhereClause: false,
1991
+ tableOrCollection: collectionName,
1992
+ warnings: isDrop ? ["Permanently removes database user credentials and roles."] : [],
1993
+ explanation: `Performs MongoDB administrative operation (${operation || "command"}).`
1994
+ };
1995
+ }
1769
1996
  if (operation === "createIndex") {
1770
1997
  const isBackground = query.options?.background === true;
1771
1998
  let suggestion;
@@ -2021,6 +2248,50 @@ async function requestUserConfirmation(query, safety, affectedRows, ask = defaul
2021
2248
  );
2022
2249
  return { confirmed: answer2 };
2023
2250
  }
2251
+ if (safety.category === "admin" || safety.isAdmin) {
2252
+ const lines2 = [
2253
+ chalk2.bold.hex("#DA70D6")("\u{1F6E1}\uFE0F DATABASE ADMIN OPERATION"),
2254
+ "",
2255
+ chalk2.white(queryDisplay),
2256
+ "",
2257
+ chalk2.hex("#DA70D6")(`Action: ${safety.explanation || "Administrative database operation."}`)
2258
+ ];
2259
+ if (safety.warnings.length > 0) {
2260
+ lines2.push("");
2261
+ for (const w of safety.warnings) {
2262
+ lines2.push(chalk2.yellow(`\u2022 ${w}`));
2263
+ }
2264
+ }
2265
+ console.log(
2266
+ boxen(lines2.join("\n"), {
2267
+ padding: 1,
2268
+ borderColor: "magenta",
2269
+ borderStyle: "round",
2270
+ margin: { top: 1, bottom: 1 }
2271
+ })
2272
+ );
2273
+ if (safety.requiresLiteralWord && safety.literalWord) {
2274
+ const typed = await promptInput(
2275
+ chalk2.hex("#DA70D6").bold(
2276
+ `Administrative operation. Type "${safety.literalWord}" to confirm: `
2277
+ ),
2278
+ ask
2279
+ );
2280
+ if (typed.trim() === safety.literalWord) {
2281
+ return { confirmed: true };
2282
+ }
2283
+ return {
2284
+ confirmed: false,
2285
+ reason: `Confirmation word mismatch (expected "${safety.literalWord}"). Operation cancelled.`
2286
+ };
2287
+ }
2288
+ const answer2 = await promptConfirm(
2289
+ chalk2.hex("#DA70D6").bold("Execute database admin operation?"),
2290
+ false,
2291
+ ask
2292
+ );
2293
+ return { confirmed: answer2 };
2294
+ }
2024
2295
  if (safety.isStructural) {
2025
2296
  const lines2 = [
2026
2297
  chalk2.bold.cyan("\u{1F6E0}\uFE0F STRUCTURAL DDL OPERATION"),
@@ -2107,7 +2378,8 @@ import { HumanMessage } from "@langchain/core/messages";
2107
2378
 
2108
2379
  // src/agent/prompts.ts
2109
2380
  var REFUSAL_MESSAGE = "I'm scoped to database operations on your connected DB only (schema inspection, queries, CRUD, analysis). I can't help with that here.";
2110
- function getSystemPrompt(dbType, schemaSummary) {
2381
+ function getSystemPrompt(dbType, schemaSummary, options) {
2382
+ const allowFullWipe = options?.allowFullWipe === true;
2111
2383
  return `You are "SANDAL" (Safe Agentic Natural-language Database Access Layer), an expert, production-grade agentic database assistant.
2112
2384
  You are connected directly to a live ${dbType === "postgres" ? "PostgreSQL" : "MongoDB"} database.
2113
2385
 
@@ -2119,6 +2391,9 @@ STRICT SCOPE & SAFETY RULES
2119
2391
  - Data analysis, querying, filtering, sorting, aggregations, and statistics
2120
2392
  - CRUD operations (SELECT/find, INSERT/insertOne, UPDATE/updateOne, DELETE/deleteOne)
2121
2393
  - Structural DDL operations (CREATE TABLE, CREATE INDEX, ALTER TABLE ADD COLUMN, createCollection, createIndex)
2394
+ - Generating and seeding realistic fake, mock, or sample data across tables or collections
2395
+ - You ARE fully authorized, equipped, and expected to generate DDL (CREATE TABLE) and DML (INSERT) to fulfill the user's database tasks.
2396
+ ${allowFullWipe ? ` - Database administrative tasks and full database operations (e.g. DROP DATABASE, DROP SCHEMA CASCADE, user/role management like CREATE/ALTER/DROP USER/ROLE, GRANT/REVOKE privileges, maintenance tasks like VACUUM/REINDEX/CHECKPOINT, connection management like pg_terminate_backend/killOp, and admin commands)` : ` - Database administrative tasks (when requested, generate the appropriate query so security guardrails can inspect it)`}
2122
2397
  2. REFUSE ANY OUT-OF-SCOPE REQUEST:
2123
2398
  - If the user asks for general programming (e.g. "write a Python script", "build a web scraper"), general Q&A, creative writing, math tutoring, or any non-database task, politely refuse immediately with:
2124
2399
  "${REFUSAL_MESSAGE}"
@@ -2126,33 +2401,62 @@ STRICT SCOPE & SAFETY RULES
2126
2401
  4. NEVER expose raw credentials, passwords, or API keys in your responses.
2127
2402
  5. NEVER hallucinate data. Ground your answers ONLY in the actual query results returned from the database.
2128
2403
 
2404
+ ${allowFullWipe ? `\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
2405
+ ADMIN & FULL-WIPE AUTHORIZATION (--allow-full-wipe ENABLED)
2406
+ \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
2407
+ \u2022 The user has launched SANDAL with --allow-full-wipe.
2408
+ \u2022 You ARE fully authorized to generate database administrative tasks, table drops, and full database wipes (such as DROP DATABASE, DROP SCHEMA CASCADE, CREATE/ALTER/DROP USER/ROLE, GRANT/REVOKE, VACUUM FULL, REINDEX, pg_terminate_backend, or MongoDB administrative commands).
2409
+ \u2022 When the user requests a database administration task or full wipe, fulfill it by generating the appropriate database query. Our multi-stage security pipeline will handle confirmation before execution.` : `\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
2410
+ ADMIN & FULL-WIPE SAFETY (--allow-full-wipe DISABLED)
2411
+ \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
2412
+ \u2022 Full database drops and destructive database admin tasks require starting sandal-db with --allow-full-wipe.
2413
+ \u2022 If the user requests a database administrative task or full wipe, generate the corresponding query so that the static security guardrails can intercept it and clearly inform the user that --allow-full-wipe is required.`}
2414
+
2129
2415
  \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
2130
2416
  QUERY GENERATION RULES
2131
2417
  \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
2132
2418
  ${dbType === "postgres" ? `\u2022 Target Dialect: PostgreSQL.
2133
- \u2022 Produce valid SQL. For queries with variables, prefer parameterized queries or clean literals when executing directly.
2419
+ \u2022 Produce valid SQL. For single queries with parameters, use parameterized queries ($1, $2) with params array, or clean SQL literals.
2420
+ \u2022 Multi-table & Complex Operations (Creating tables & Seeding data):
2421
+ - When the user asks to add, generate, mock, or seed data across tables (e.g. "add fake users and their spendings in different tables"):
2422
+ 1. If the required tables do not exist in the database schema, automatically generate "CREATE TABLE IF NOT EXISTS" statements for all required tables with appropriate primary keys (e.g. SERIAL PRIMARY KEY or UUID), data types, foreign key references, and timestamps.
2423
+ 2. IMPORTANT FOR MANAGED SCHEMAS (e.g. Supabase auth.users): Do NOT insert fake users or mock data directly into managed internal schemas like "auth.users". Instead, create and populate application tables in the "public" schema (e.g. "public.customers" or "public.users", "public.spendings", "public.products", "public.order_items").
2424
+ 3. Generate realistic, coherent fake data (realistic names, prices, dates, quantities) with properly matched foreign key relationships between tables.
2425
+ 4. Combine all statements into a single, cohesive multi-statement SQL script separated by semicolons (e.g. "CREATE TABLE IF NOT EXISTS ...; CREATE TABLE IF NOT EXISTS ...; INSERT INTO ...; INSERT INTO ...;").
2426
+ 5. In multi-statement scripts, embed values directly as clean SQL literals and keep "params": [] so the entire script executes cleanly.
2134
2427
  \u2022 For UPDATE or DELETE queries, ALWAYS include a specific, narrow WHERE clause unless the user explicitly requested modifying all rows.
2135
2428
  \u2022 For index creation, prefer "CREATE INDEX CONCURRENTLY" to avoid write-locking tables.
2136
2429
  \u2022 Return your query formatted in a json object:
2137
2430
  {
2138
2431
  "type": "postgres",
2139
- "sql": "<the SQL statement>",
2432
+ "sql": "<the SQL statement or multi-statement script>",
2140
2433
  "params": [],
2141
- "targetTables": ["table1"],
2434
+ "targetTables": ["table1", "table2"],
2142
2435
  "explanation": "<brief summary of what the query does>"
2143
2436
  }` : `\u2022 Target Dialect: MongoDB.
2144
- \u2022 Produce valid MongoDB operations in structured JSON format:
2437
+ \u2022 Produce valid MongoDB operations in structured JSON format.
2438
+ \u2022 Single-collection operation:
2145
2439
  {
2146
2440
  "type": "mongodb",
2147
2441
  "collection": "<collection_name>",
2148
- "operation": "find" | "aggregate" | "countDocuments" | "insertOne" | "insertMany" | "updateOne" | "updateMany" | "deleteOne" | "deleteMany" | "createIndex" | "createCollection",
2442
+ "operation": "find" | "aggregate" | "countDocuments" | "insertOne" | "insertMany" | "updateOne" | "updateMany" | "deleteOne" | "deleteMany" | "createIndex" | "createCollection"${allowFullWipe ? ' | "drop" | "dropDatabase" | "command"' : ""},
2149
2443
  "filter": {},
2150
2444
  "update": {},
2151
2445
  "pipeline": [],
2152
2446
  "document": {},
2447
+ "documents": [],
2153
2448
  "options": {},
2154
2449
  "explanation": "<brief summary of what the operation does>"
2155
2450
  }
2451
+ \u2022 Multi-collection operations (e.g. adding fake data across multiple collections):
2452
+ {
2453
+ "type": "mongodb",
2454
+ "operations": [
2455
+ { "collection": "users", "operation": "insertMany", "documents": [...] },
2456
+ { "collection": "spendings", "operation": "insertMany", "documents": [...] }
2457
+ ],
2458
+ "explanation": "<brief summary of what the operations do>"
2459
+ }
2156
2460
  \u2022 For UPDATE or DELETE operations, ALWAYS include a specific filter unless the user explicitly requested modifying all documents.
2157
2461
  \u2022 For createIndex, prefer options like {"background": true}.`}
2158
2462
 
@@ -2198,7 +2502,7 @@ var INTENT_CLASSIFICATION_PROMPT = `You are a strict security and intent classif
2198
2502
  Determine whether the following user message is a "database_task" or "out_of_scope".
2199
2503
 
2200
2504
  Definitions:
2201
- - "database_task": The user asks about database schema, tables, collections, fields, running queries, counting rows, inserting/updating/deleting data, creating tables or indexes, filtering, grouping, aggregating, or analyzing database records, or conversational follow-ups directly relating to the database session.
2505
+ - "database_task": The user asks about database schema, tables, collections, fields, running queries, counting rows, inserting/updating/deleting data, creating tables or indexes, generating or populating fake/mock/sample data or test fixtures across tables or collections, filtering, grouping, aggregating, analyzing database records, database administration tasks (e.g. database/schema wipes or drops, user/role management like CREATE/DROP USER or ROLE, GRANT/REVOKE permissions, VACUUM, REINDEX, maintenance, killing backend processes/connections, database configuration, or admin commands), or conversational follow-ups directly relating to the database session.
2202
2506
  - "out_of_scope": The user asks for general coding help (e.g., "write a python script to scrape a website", "create a React app", "write a bash script"), general programming questions, creative writing, math tutoring, conversational chat unrelated to the DB, weather, philosophy, history, or anything not acting on the connected database.
2203
2507
 
2204
2508
  Respond with EXACTLY ONE JSON OBJECT and nothing else:
@@ -2224,12 +2528,15 @@ var OBVIOUS_OUT_OF_SCOPE_PATTERNS = [
2224
2528
  /acting\s+as\s+a\s+general\s+chatbot/i
2225
2529
  ];
2226
2530
  var OBVIOUS_DATABASE_PATTERNS = [
2227
- /^(?:SELECT|INSERT|UPDATE|DELETE|DROP|CREATE|ALTER|TRUNCATE|EXPLAIN|SHOW|DESCRIBE|WITH)\b/i,
2531
+ /^(?:SELECT|INSERT|UPDATE|DELETE|DROP|CREATE|ALTER|TRUNCATE|EXPLAIN|SHOW|DESCRIBE|WITH|GRANT|REVOKE|VACUUM|REINDEX|CHECKPOINT|DISCARD|RESET|LOCK)\b/i,
2228
2532
  /\b(?:table|tables|collection|collections|schema|column|columns|index|indexes|foreign key|primary key)\b/i,
2229
2533
  /\b(?:find|aggregate|count|where|group by|order by|having|limit|join|left join|inner join)\b/i,
2230
2534
  /\b(?:how many (?:users|rows|records|orders|items|products|documents))\b/i,
2231
2535
  /\b(?:database|postgres|mongodb|mongo|pg_)\b/i,
2232
- /\b(?:query|queries|sql|result|results|rows?|records?)\b/i
2536
+ /\b(?:query|queries|sql|result|results|rows?|records?)\b/i,
2537
+ /\b(?:role|roles|permission|permissions|privilege|privileges|admin|administration|vacuum|reindex|checkpoint|terminate|kill\s+query|backend)\b/i,
2538
+ /\b(?:fake|mock|seed|populate|sample|test)\s+(?:users?|orders?|products?|customers?|data|rows?|records?|documents?|tables?|collections?|spendings?|purchases?)\b/i,
2539
+ /\b(?:add|insert|create|populate|seed|generate)\b.*?\b(?:users?|orders?|products?|customers?|data|rows?|records?|documents?|tables?|collections?|spendings?|purchases?)\b/i
2233
2540
  ];
2234
2541
  function isExplicitNoQuery(input3) {
2235
2542
  const trimmed = input3.trim();
@@ -2318,11 +2625,19 @@ ${trimmed}`;
2318
2625
  function checkStrictWipe(classification, options = {}) {
2319
2626
  const strictMode = options.strictMode !== false;
2320
2627
  const allowFullWipe = options.allowFullWipe === true;
2321
- if (classification.isFullWipe && strictMode && !allowFullWipe) {
2322
- return {
2323
- blocked: true,
2324
- message: "Operation blocked by --strict mode: full database or all-table drop is prohibited. To allow this, start sandal-db with --allow-full-wipe."
2325
- };
2628
+ if (strictMode && !allowFullWipe) {
2629
+ if (classification.isFullWipe) {
2630
+ return {
2631
+ blocked: true,
2632
+ message: "Operation blocked by --strict mode: full database or all-table drop is prohibited. To allow this, start sandal-db with --allow-full-wipe."
2633
+ };
2634
+ }
2635
+ if (classification.category === "admin" || classification.isAdmin) {
2636
+ return {
2637
+ blocked: true,
2638
+ message: "Operation blocked by --strict mode: database admin tasks are prohibited. To allow this, start sandal-db with --allow-full-wipe."
2639
+ };
2640
+ }
2326
2641
  }
2327
2642
  return { blocked: false };
2328
2643
  }
@@ -2442,7 +2757,7 @@ function createDatabaseAgent(config) {
2442
2757
  requiresQuery: false
2443
2758
  };
2444
2759
  }
2445
- const isObviousLiveQuery = /^(?:SELECT|INSERT|UPDATE|DELETE|DROP|CREATE|ALTER|TRUNCATE|GRANT|REVOKE)\b/i.test(userInput) || /^(?:find|search|fetch|get|select|show|display|list|count|delete|update|insert|remove)\s+(?:all\s+|the\s+|every\s+|top\s+\d+\s+)?(?:users?|orders?|products?|customers?|items?|rows?|records?|documents?|accounts?|entries)\b/i.test(userInput) || /\bhow\s+many\s+(?:users|orders|products|items|rows|records|documents|customers|accounts)\b/i.test(userInput);
2760
+ const isObviousLiveQuery = /^(?:SELECT|INSERT|UPDATE|DELETE|DROP|CREATE|ALTER|TRUNCATE|GRANT|REVOKE|VACUUM|REINDEX|CHECKPOINT|DISCARD|LOCK)\b/i.test(userInput) || /\b(?:add|insert|populate|seed|generate|mock|create|make|fill|build|setup|load|import)\s+(?:fake\s+|mock\s+|sample\s+|test\s+|some\s+|new\s+|\d+\s+)?(?:users?|orders?|products?|customers?|items?|rows?|records?|documents?|accounts?|entries|data|tables?|collections?|spendings?|purchases?|transactions?|sales?)\b/i.test(userInput) || /^(?:find|search|fetch|get|select|show|display|list|count|delete|update|insert|remove|add|seed|populate)\s+(?:all\s+|the\s+|every\s+|top\s+\d+\s+)?(?:users?|orders?|products?|customers?|items?|rows?|records?|documents?|accounts?|entries|spendings?|purchases?|transactions?)\b/i.test(userInput) || /\bhow\s+many\s+(?:users|orders|products|items|rows|records|documents|customers|accounts)\b/i.test(userInput) || /\b(?:grant|revoke|vacuum|reindex|checkpoint|kill\s+query|terminate\s+backend|drop\s+database|wipe\s+database|create\s+(?:user|role)|drop\s+(?:user|role)|alter\s+(?:user|role|system|database))\b/i.test(userInput);
2446
2761
  if (isObviousLiveQuery) {
2447
2762
  return {
2448
2763
  ...baseReset,
@@ -2459,7 +2774,8 @@ function createDatabaseAgent(config) {
2459
2774
  requiresQuery: false
2460
2775
  };
2461
2776
  }
2462
- const isSchemaOrMetaQuestion = /\b(?:schema|database\s+structure)\b/i.test(userInput) || /\b(?:explain|describe|what|tell\s+me\s+about)\b.*?\b(?:tables?|collections?|columns?|indexes?|foreign\s+keys?|schema)\b/i.test(userInput) || /\b(?:what\s+(?:tables|collections|columns)\s+(?:exist|are\s+there|do\s+we\s+have))\b/i.test(userInput) || /\b(?:what\s+was\s+the\s+(?:last|previous)\s+query|why\s+did\s+(?:the\s+last\s+query|it)\s+fail)\b/i.test(userInput);
2777
+ const isMutationOrAction = /\b(?:add|insert|create|populate|seed|generate|mock|make|fill|build|setup|drop|delete|update|alter|truncate|remove)\b/i.test(userInput);
2778
+ const isSchemaOrMetaQuestion = !isMutationOrAction && (/\b(?:what|which)\s+(?:tables|collections|columns|indexes)\s+(?:exist|are\s+there|do\s+we\s+have|are\s+available)\b/i.test(userInput) || /\b(?:show|list|describe|explain|print|display|view)\s+(?:the\s+)?(?:schema|tables?|collections?|columns?|indexes?|foreign\s+keys?|database\s+structure)\b/i.test(userInput) || /\b(?:explain|describe|show|list)\s+what\s+(?:tables|collections|columns|indexes)\b/i.test(userInput) || /\b(?:what\s+is\s+the\s+(?:schema|database\s+structure))\b/i.test(userInput) || /\b(?:what\s+was\s+the\s+(?:last|previous)\s+query|why\s+did\s+(?:the\s+last\s+query|it)\s+fail)\b/i.test(userInput));
2463
2779
  if (isSchemaOrMetaQuestion && (state.schemaSummary || state.messages.length > 0)) {
2464
2780
  return {
2465
2781
  ...baseReset,
@@ -2481,8 +2797,8 @@ Recent Context:
2481
2797
  Rules:
2482
2798
  1. If the user explicitly asks NOT to run a query (e.g., "don't run query", "without querying", "do not execute queries"), requiresQuery MUST be false.
2483
2799
  2. If the user asks to format, summarize, filter, inspect, or calculate based on the previous results or conversation context, requiresQuery MUST be false.
2484
- 3. If the user asks about the schema structure, columns, or general knowledge answerable from context, requiresQuery MUST be false.
2485
- 4. If the user asks to retrieve fresh records from a table/collection, count rows in the database, modify, insert, delete, or create database objects, requiresQuery MUST be true.
2800
+ 3. If the user asks purely about the existing schema structure, list of tables/columns, or general knowledge answerable from context, requiresQuery MUST be false.
2801
+ 4. If the user asks to retrieve fresh records from a table/collection, count rows in the database, modify, insert, delete, create database objects, or generate/populate/seed fake, mock, or sample data (including creating tables for it), requiresQuery MUST be true.
2486
2802
 
2487
2803
  Respond with JSON only:
2488
2804
  {"requiresQuery": boolean, "reason": "<short explanation>"}`;
@@ -2517,10 +2833,11 @@ Respond with JSON only:
2517
2833
  const historyMessages = state.messages.slice(-4);
2518
2834
  const prompt = `Based on this user request: "${state.userInput}"
2519
2835
  And the available database schema:
2520
- ${state.schemaSummary}
2836
+ ${state.schemaSummary || "No schema loaded."}
2521
2837
 
2522
2838
  List the relevant table names (PostgreSQL) or collection names (MongoDB) needed to fulfill the request.
2523
- Respond with a JSON array of string names only, e.g. ["users", "orders"]. Do not include markdown codeblocks or extra text.`;
2839
+ Include existing tables as well as any new tables or collections that should be created or populated for this request.
2840
+ Respond with a JSON array of string names only, e.g. ["users", "spendings", "products"]. Do not include markdown codeblocks or extra text.`;
2524
2841
  try {
2525
2842
  const response = await model.invoke([
2526
2843
  ...historyMessages,
@@ -2535,7 +2852,9 @@ Respond with a JSON array of string names only, e.g. ["users", "orders"]. Do not
2535
2852
  }
2536
2853
  }
2537
2854
  async function generateQueryNode(state) {
2538
- const systemPrompt = getSystemPrompt(adapter.type, state.schemaSummary);
2855
+ const systemPrompt = getSystemPrompt(adapter.type, state.schemaSummary, {
2856
+ allowFullWipe: classifierOptions?.allowFullWipe
2857
+ });
2539
2858
  const historyMessages = state.messages.slice(-6);
2540
2859
  const userPrompt = `User request: "${state.userInput}"
2541
2860
  Target entities: ${JSON.stringify(state.targetEntities)}
@@ -2566,7 +2885,7 @@ Do not wrap with markdown or code fences.`;
2566
2885
  rawDisplay: sql
2567
2886
  };
2568
2887
  } else {
2569
- if (parsed && parsed.collection && parsed.operation) {
2888
+ if (parsed && (parsed.collection && parsed.operation || parsed.operations)) {
2570
2889
  query = {
2571
2890
  collection: parsed.collection,
2572
2891
  operation: parsed.operation,
@@ -2576,6 +2895,7 @@ Do not wrap with markdown or code fences.`;
2576
2895
  document: parsed.document,
2577
2896
  documents: parsed.documents,
2578
2897
  options: parsed.options,
2898
+ operations: parsed.operations,
2579
2899
  rawDisplay: JSON.stringify(parsed, null, 2)
2580
2900
  };
2581
2901
  } else {
@@ -2638,10 +2958,23 @@ Do not wrap with markdown or code fences.`;
2638
2958
  return {};
2639
2959
  }
2640
2960
  const result = await adapter.executeQuery(state.generatedQuery);
2641
- const isStructuralSuccess = Boolean(state.safety?.isStructural && result.success);
2961
+ const isStructuralSuccess = Boolean(
2962
+ (state.safety?.isStructural || state.safety?.isAdmin || state.safety?.isFullWipe) && result.success
2963
+ );
2964
+ let updatedSchema = state.schema;
2965
+ let updatedSummary = state.schemaSummary;
2966
+ if (isStructuralSuccess) {
2967
+ try {
2968
+ updatedSchema = await adapter.inspectSchema(true);
2969
+ updatedSummary = updatedSchema ? formatSchemaForPrompt(updatedSchema) : state.schemaSummary;
2970
+ } catch {
2971
+ }
2972
+ }
2642
2973
  return {
2643
2974
  queryResult: result,
2644
- requiresSchemaRefresh: isStructuralSuccess
2975
+ schema: updatedSchema,
2976
+ schemaSummary: updatedSummary,
2977
+ requiresSchemaRefresh: false
2645
2978
  };
2646
2979
  }
2647
2980
  async function analyzeResultsNode(state) {
@@ -2787,8 +3120,7 @@ Guidelines:
2787
3120
  1. Provide a direct, helpful, and accurate natural-language response to the user's request.
2788
3121
  2. If the user asked to do something with previous results (e.g. summarize, format, count, calculate, extract, sort), perform that operation using the PREVIOUS QUERY EXECUTION DETAILS above.
2789
3122
  3. If the user asked to use previous results but NO previous results exist in the session, politely explain that no previous query results are available in this session.
2790
- 4. DO NOT output, execute, or propose any new database queries.
2791
- 5. Ground your answer strictly in the available schema, history, or previous query results. Never hallucinate data.`;
3123
+ 4. Ground your answer strictly in the available schema, history, or previous query results. Never hallucinate data.`;
2792
3124
  try {
2793
3125
  const response = await model.invoke([
2794
3126
  ...historyMessages,
@@ -3902,7 +4234,8 @@ Schema for collection: ${collection.name}`));
3902
4234
  `${chalk4.bold("Database:")} ${chalk4.green(this.adapter.type.toUpperCase())} (${this.adapter.getMaskedUrl()})`,
3903
4235
  `${chalk4.bold("LLM Provider:")} ${chalk4.blue(this.provider)} [${chalk4.white(this.modelName)}]`,
3904
4236
  `${chalk4.bold("Chat Session:")} ${chalk4.yellow(this.sessionId)}`,
3905
- `${chalk4.bold("Strict Mode:")} ${this.strictMode ? chalk4.green("ON (Full wipes blocked)") : chalk4.yellow("OFF")}`,
4237
+ `${chalk4.bold("Strict Mode:")} ${this.strictMode ? this.allowFullWipe ? chalk4.yellow("ON (Wipes & admin allowed)") : chalk4.green("ON (Wipes & admin blocked)") : chalk4.yellow("OFF")}`,
4238
+ `${chalk4.bold("Admin Tasks:")} ${this.allowFullWipe ? chalk4.green("ALLOWED (--allow-full-wipe)") : chalk4.gray("BLOCKED (start with --allow-full-wipe)")}`,
3906
4239
  `${chalk4.bold("Markdown:")} ${this.renderMarkdown ? chalk4.green("ENABLED") : chalk4.gray("DISABLED")}`,
3907
4240
  "",
3908
4241
  chalk4.gray("Type your natural language request or SQL/Mongo query."),
@@ -3992,7 +4325,7 @@ program.name("sandal-db").description("SANDAL - Safe Agentic Natural-language Da
3992
4325
  true
3993
4326
  ).option("--no-strict", "Disable strict safety mode").option(
3994
4327
  "--allow-full-wipe",
3995
- "Explicitly allow full database / all-table wipe queries with confirmation",
4328
+ "Explicitly allow full database / all-table wipe queries and db admin tasks with confirmation",
3996
4329
  false
3997
4330
  ).option(
3998
4331
  "--threshold <number>",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sandal-db",
3
- "version": "1.0.7",
3
+ "version": "1.0.8",
4
4
  "description": "SANDAL - Safe Agentic Natural-language Database Access Layer. Production-grade agentic database assistant CLI using LangGraph and LLMs (Google Gemini, OpenAI, Anthropic)",
5
5
  "type": "module",
6
6
  "bin": {