@mindstudio-ai/agent 0.1.102 → 0.1.104

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.js CHANGED
@@ -462,6 +462,59 @@ function buildSelect(table, options = {}) {
462
462
  if (options.offset != null) sql += ` OFFSET ${options.offset}`;
463
463
  return { sql, params: params.length > 0 ? params : void 0 };
464
464
  }
465
+ var SQL_IDENTIFIER_RE = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/;
466
+ function assertSqlIdentifier(name, context) {
467
+ if (!SQL_IDENTIFIER_RE.test(name)) {
468
+ throw new MindStudioError(
469
+ `Invalid ${context}: "${name}" \u2014 must be a plain identifier (letters, digits, _ and $, not starting with a digit).`,
470
+ "invalid_identifier",
471
+ 400
472
+ );
473
+ }
474
+ }
475
+ function aggregateExpr(fn, column) {
476
+ switch (fn) {
477
+ case "count":
478
+ return "COUNT(*)";
479
+ case "sum":
480
+ return `TOTAL(${column})`;
481
+ case "avg":
482
+ return `AVG(${column})`;
483
+ case "min":
484
+ return `MIN(${column})`;
485
+ case "max":
486
+ return `MAX(${column})`;
487
+ case "countDistinct":
488
+ return `COUNT(DISTINCT ${column})`;
489
+ }
490
+ }
491
+ function buildScalarAggregate(table, fn, column, where, whereParams) {
492
+ if (column != null) assertSqlIdentifier(column, "aggregate column");
493
+ let sql = `SELECT ${aggregateExpr(fn, column ?? void 0)} AS value FROM ${table}`;
494
+ if (where) sql += ` WHERE ${where}`;
495
+ return { sql, params: whereParams?.length ? whereParams : void 0 };
496
+ }
497
+ function buildGroupedAggregate(table, options) {
498
+ for (const col of options.by) assertSqlIdentifier(col, "group-by column");
499
+ for (const term of options.select) {
500
+ assertSqlIdentifier(term.alias, "aggregate alias");
501
+ if (term.column != null) assertSqlIdentifier(term.column, "aggregate column");
502
+ }
503
+ if (options.orderBy != null) assertSqlIdentifier(options.orderBy, "aggregate order key");
504
+ const selectList = [
505
+ ...options.by,
506
+ ...options.select.map((t) => `${aggregateExpr(t.fn, t.column)} AS ${t.alias}`)
507
+ ].join(", ");
508
+ let sql = `SELECT ${selectList} FROM ${table}`;
509
+ if (options.where) sql += ` WHERE ${options.where}`;
510
+ if (options.by.length > 0) sql += ` GROUP BY ${options.by.join(", ")}`;
511
+ if (options.orderBy) sql += ` ORDER BY ${options.orderBy}${options.desc ? " DESC" : " ASC"}`;
512
+ if (options.limit != null) sql += ` LIMIT ${options.limit}`;
513
+ return {
514
+ sql,
515
+ params: options.whereParams?.length ? options.whereParams : void 0
516
+ };
517
+ }
465
518
  function buildExists(table, where, whereParams, negate) {
466
519
  const inner = where ? `SELECT 1 FROM ${table} WHERE ${where}` : `SELECT 1 FROM ${table}`;
467
520
  const fn = negate ? "NOT EXISTS" : "EXISTS";
@@ -1119,6 +1172,187 @@ function isComparisonOp(value) {
1119
1172
  return value in JS_TO_SQL_OP;
1120
1173
  }
1121
1174
 
1175
+ // src/db/aggregate.ts
1176
+ var IDENTIFIER_RE = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/;
1177
+ var AGGREGATE_FNS = [
1178
+ "count",
1179
+ "sum",
1180
+ "avg",
1181
+ "min",
1182
+ "max",
1183
+ "countDistinct"
1184
+ ];
1185
+ function normalizeAggregateSpec(spec) {
1186
+ const by = [...spec.by ?? []];
1187
+ for (const col of by) {
1188
+ if (typeof col !== "string" || !IDENTIFIER_RE.test(col)) {
1189
+ throw new MindStudioError(
1190
+ `aggregate(): invalid group-by column "${col}" \u2014 column names are plain identifiers.`,
1191
+ "invalid_column",
1192
+ 400
1193
+ );
1194
+ }
1195
+ }
1196
+ const aliases = Object.keys(spec.select ?? {});
1197
+ if (aliases.length === 0) {
1198
+ throw new MindStudioError(
1199
+ "aggregate(): select must contain at least one aggregate, e.g. { n: { count: true } }.",
1200
+ "invalid_aggregate_spec",
1201
+ 400
1202
+ );
1203
+ }
1204
+ const select = aliases.map((alias) => {
1205
+ if (!IDENTIFIER_RE.test(alias)) {
1206
+ throw new MindStudioError(
1207
+ `aggregate(): invalid alias "${alias}" \u2014 result property names are plain identifiers.`,
1208
+ "invalid_aggregate_alias",
1209
+ 400
1210
+ );
1211
+ }
1212
+ if (by.includes(alias)) {
1213
+ throw new MindStudioError(
1214
+ `aggregate(): alias "${alias}" collides with a group-by column of the same name.`,
1215
+ "invalid_aggregate_alias",
1216
+ 400
1217
+ );
1218
+ }
1219
+ const term = spec.select[alias];
1220
+ const fns = AGGREGATE_FNS.filter((fn2) => term && fn2 in term);
1221
+ if (fns.length !== 1) {
1222
+ throw new MindStudioError(
1223
+ `aggregate(): term "${alias}" must have exactly one aggregate key (count, sum, avg, min, max, countDistinct).`,
1224
+ "invalid_aggregate_spec",
1225
+ 400
1226
+ );
1227
+ }
1228
+ const fn = fns[0];
1229
+ if (fn === "count") {
1230
+ if (term[fn] !== true) {
1231
+ throw new MindStudioError(
1232
+ `aggregate(): term "${alias}" \u2014 count takes \`true\`, e.g. { count: true }.`,
1233
+ "invalid_aggregate_spec",
1234
+ 400
1235
+ );
1236
+ }
1237
+ return { alias, fn };
1238
+ }
1239
+ const column = term[fn];
1240
+ if (typeof column !== "string" || !IDENTIFIER_RE.test(column)) {
1241
+ throw new MindStudioError(
1242
+ `aggregate(): term "${alias}" \u2014 invalid column "${String(column)}".`,
1243
+ "invalid_column",
1244
+ 400
1245
+ );
1246
+ }
1247
+ return { alias, fn, column };
1248
+ });
1249
+ if (spec.orderBy != null) {
1250
+ if (!aliases.includes(spec.orderBy) && !by.includes(spec.orderBy)) {
1251
+ throw new MindStudioError(
1252
+ `aggregate(): orderBy "${spec.orderBy}" is neither a select alias nor a group-by column.`,
1253
+ "invalid_aggregate_order",
1254
+ 400
1255
+ );
1256
+ }
1257
+ }
1258
+ return {
1259
+ by,
1260
+ select,
1261
+ orderBy: spec.orderBy,
1262
+ desc: spec.desc,
1263
+ limit: spec.limit
1264
+ };
1265
+ }
1266
+ function aggregateValues(fn, values) {
1267
+ const present = values.filter((v) => v != null);
1268
+ switch (fn) {
1269
+ case "count":
1270
+ return values.length;
1271
+ case "sum":
1272
+ return present.reduce((s, v) => s + v, 0);
1273
+ case "avg":
1274
+ return present.length === 0 ? null : present.reduce((s, v) => s + v, 0) / present.length;
1275
+ case "countDistinct":
1276
+ return new Set(present).size;
1277
+ case "min":
1278
+ return present.length === 0 ? null : present.reduce((m, v) => v < m ? v : m);
1279
+ case "max":
1280
+ return present.length === 0 ? null : present.reduce((m, v) => v > m ? v : m);
1281
+ }
1282
+ }
1283
+ function compareForOrder(a, b) {
1284
+ if (a == null && b == null) return 0;
1285
+ if (a == null) return -1;
1286
+ if (b == null) return 1;
1287
+ if (a < b) return -1;
1288
+ if (a > b) return 1;
1289
+ return 0;
1290
+ }
1291
+ function computeAggregateInJs(rows, spec) {
1292
+ const groups = /* @__PURE__ */ new Map();
1293
+ for (const row of rows) {
1294
+ const key = JSON.stringify(spec.by.map((col) => row[col]));
1295
+ const group = groups.get(key);
1296
+ if (group) group.push(row);
1297
+ else groups.set(key, [row]);
1298
+ }
1299
+ if (spec.by.length === 0 && groups.size === 0) {
1300
+ groups.set("[]", []);
1301
+ }
1302
+ let out = Array.from(groups.values()).map((groupRows) => {
1303
+ const result = {};
1304
+ for (const col of spec.by) {
1305
+ result[col] = groupRows[0]?.[col];
1306
+ }
1307
+ for (const term of spec.select) {
1308
+ result[term.alias] = aggregateValues(
1309
+ term.fn,
1310
+ term.column != null ? groupRows.map((r) => r[term.column]) : groupRows
1311
+ );
1312
+ }
1313
+ return result;
1314
+ });
1315
+ if (spec.orderBy) {
1316
+ const key = spec.orderBy;
1317
+ out.sort((a, b) => compareForOrder(a[key], b[key]));
1318
+ if (spec.desc) out.reverse();
1319
+ }
1320
+ if (spec.limit != null) {
1321
+ out = out.slice(0, spec.limit);
1322
+ }
1323
+ return spec.by.length === 0 ? out[0] : out;
1324
+ }
1325
+ function deserializeAggregateRows(rows, spec, columns) {
1326
+ const schemaFor = /* @__PURE__ */ new Map();
1327
+ for (const col of spec.by) {
1328
+ schemaFor.set(
1329
+ col,
1330
+ columns.filter((c) => c.name === col)
1331
+ );
1332
+ }
1333
+ for (const term of spec.select) {
1334
+ if ((term.fn === "min" || term.fn === "max") && term.column != null) {
1335
+ schemaFor.set(
1336
+ term.alias,
1337
+ columns.filter((c) => c.name === term.column).map((c) => ({ ...c, name: term.alias }))
1338
+ );
1339
+ }
1340
+ }
1341
+ return rows.map((raw) => {
1342
+ const row = raw;
1343
+ const result = {};
1344
+ for (const [key, value] of Object.entries(row)) {
1345
+ const schema = schemaFor.get(key);
1346
+ if (schema) {
1347
+ result[key] = deserializeRow({ [key]: value }, schema)[key];
1348
+ } else {
1349
+ result[key] = value;
1350
+ }
1351
+ }
1352
+ return result;
1353
+ });
1354
+ }
1355
+
1122
1356
  // src/db/query.ts
1123
1357
  var Query = class _Query {
1124
1358
  _predicates;
@@ -1130,6 +1364,8 @@ var Query = class _Query {
1130
1364
  /** @internal Pre-compiled WHERE clause (bypasses predicate compiler). Used by Table.get(). */
1131
1365
  _rawWhere;
1132
1366
  _rawWhereParams;
1367
+ /** @internal See AggregateState — set by aggregate terminals. */
1368
+ _aggregate;
1133
1369
  /** @internal Post-process transform applied after row deserialization. */
1134
1370
  _postProcess;
1135
1371
  constructor(config, options) {
@@ -1142,6 +1378,7 @@ var Query = class _Query {
1142
1378
  this._postProcess = options?.postProcess;
1143
1379
  this._rawWhere = options?.rawWhere;
1144
1380
  this._rawWhereParams = options?.rawWhereParams;
1381
+ this._aggregate = options?.aggregate;
1145
1382
  }
1146
1383
  _clone(overrides) {
1147
1384
  return new _Query(this._config, {
@@ -1152,7 +1389,8 @@ var Query = class _Query {
1152
1389
  offset: overrides.offset ?? this._offset,
1153
1390
  postProcess: overrides.postProcess,
1154
1391
  rawWhere: this._rawWhere,
1155
- rawWhereParams: this._rawWhereParams
1392
+ rawWhereParams: this._rawWhereParams,
1393
+ aggregate: overrides.aggregate ?? this._aggregate
1156
1394
  });
1157
1395
  }
1158
1396
  filter(predicate, bindings) {
@@ -1190,9 +1428,60 @@ var Query = class _Query {
1190
1428
  }
1191
1429
  count() {
1192
1430
  return this._clone({
1431
+ aggregate: { kind: "scalar", fn: "count" },
1193
1432
  postProcess: (rows) => rows.length
1194
1433
  });
1195
1434
  }
1435
+ /**
1436
+ * Sum of a numeric field across matching rows. Compiles to a SQL
1437
+ * aggregate (TOTAL) when possible — no rows are fetched. Returns 0 for
1438
+ * an empty set; NULL values are skipped (SQL semantics).
1439
+ */
1440
+ sum(accessor) {
1441
+ return this._clone({
1442
+ aggregate: { kind: "scalar", fn: "sum", accessor },
1443
+ postProcess: (rows) => rows.reduce((total, row) => {
1444
+ const value = accessor(row);
1445
+ return value == null ? total : total + value;
1446
+ }, 0)
1447
+ });
1448
+ }
1449
+ /**
1450
+ * Average of a numeric field across matching rows. Compiles to SQL AVG
1451
+ * when possible. Returns null for an empty set (or when every value is
1452
+ * null) — SQL AVG semantics.
1453
+ */
1454
+ avg(accessor) {
1455
+ return this._clone({
1456
+ aggregate: { kind: "scalar", fn: "avg", accessor },
1457
+ postProcess: (rows) => {
1458
+ const values = rows.map((row) => accessor(row)).filter((v) => v != null);
1459
+ return values.length === 0 ? null : values.reduce((s, v) => s + v, 0) / values.length;
1460
+ }
1461
+ });
1462
+ }
1463
+ /**
1464
+ * Number of distinct non-null values of a field across matching rows.
1465
+ * Compiles to SQL COUNT(DISTINCT col) when possible.
1466
+ */
1467
+ countDistinct(accessor) {
1468
+ return this._clone({
1469
+ aggregate: { kind: "scalar", fn: "countDistinct", accessor },
1470
+ postProcess: (rows) => new Set(rows.map((row) => accessor(row)).filter((v) => v != null)).size
1471
+ });
1472
+ }
1473
+ aggregate(spec) {
1474
+ const normalized = normalizeAggregateSpec(
1475
+ spec
1476
+ );
1477
+ return this._clone({
1478
+ aggregate: { kind: "grouped", spec: normalized },
1479
+ postProcess: (rows) => computeAggregateInJs(
1480
+ rows,
1481
+ normalized
1482
+ )
1483
+ });
1484
+ }
1196
1485
  some() {
1197
1486
  return this._clone({
1198
1487
  limit: 1,
@@ -1263,6 +1552,10 @@ var Query = class _Query {
1263
1552
  const compiled = this._compilePredicates();
1264
1553
  const sortField = this._sortAccessor ? extractFieldName(this._sortAccessor) : void 0;
1265
1554
  if (compiled.allSql) {
1555
+ const agg = this._compileAggregateSql(compiled.sqlWhere);
1556
+ if (agg) {
1557
+ return { type: "query", query: agg.query, fallbackQuery: null, config: this._config, processRaw: agg.processRaw };
1558
+ }
1266
1559
  const query = buildSelect(this._config.tableName, {
1267
1560
  where: compiled.sqlWhere || void 0,
1268
1561
  orderBy: sortField ?? void 0,
@@ -1294,6 +1587,9 @@ var Query = class _Query {
1294
1587
  * For JS-fallback queries: filter, sort, and slice in JS.
1295
1588
  */
1296
1589
  static _processResults(result, compiled) {
1590
+ if (compiled.processRaw) {
1591
+ return compiled.processRaw(result);
1592
+ }
1297
1593
  const rows = result.rows.map(
1298
1594
  (row) => deserializeRow(
1299
1595
  row,
@@ -1326,10 +1622,81 @@ var Query = class _Query {
1326
1622
  // PromiseLike
1327
1623
  // -------------------------------------------------------------------------
1328
1624
  then(onfulfilled, onrejected) {
1329
- const promise = this._execute().then(
1330
- (rows) => this._postProcess ? this._postProcess(rows) : rows
1331
- );
1332
- return promise.then(onfulfilled, onrejected);
1625
+ return this._run().then(onfulfilled, onrejected);
1626
+ }
1627
+ async _run() {
1628
+ if (this._aggregate && !this._rawWhere) {
1629
+ const compiled = this._compilePredicates();
1630
+ if (compiled.allSql) {
1631
+ const agg = this._compileAggregateSql(compiled.sqlWhere);
1632
+ if (agg) {
1633
+ const results = await this._config.executeBatch([agg.query]);
1634
+ return agg.processRaw(results[0]);
1635
+ }
1636
+ }
1637
+ }
1638
+ const rows = await this._execute();
1639
+ return this._postProcess ? this._postProcess(rows) : rows;
1640
+ }
1641
+ /**
1642
+ * @internal Compile the pending aggregate into a single SQL statement +
1643
+ * result processor. Returns null (→ fetch path, postProcess computes the
1644
+ * aggregate) when there is no aggregate, when a pre-compiled WHERE is in
1645
+ * play (Table.get()), when limit/offset are set (aggregate-the-page
1646
+ * semantics — COUNT(*)/TOTAL can't express "over the limited page"), or
1647
+ * when a scalar accessor doesn't extract to a column (computed accessors
1648
+ * stay correct via JS, with a warning to prompt optimization).
1649
+ */
1650
+ _compileAggregateSql(sqlWhere) {
1651
+ const agg = this._aggregate;
1652
+ if (!agg || this._rawWhere || this._limit != null || this._offset != null) {
1653
+ return null;
1654
+ }
1655
+ const where = sqlWhere || void 0;
1656
+ if (agg.kind === "scalar") {
1657
+ let column = null;
1658
+ if (agg.accessor) {
1659
+ column = extractFieldName(agg.accessor);
1660
+ if (column == null) {
1661
+ console.warn(
1662
+ `[mindstudio] ${agg.fn}() accessor on '${this._config.tableName}' could not be compiled to SQL \u2014 fetching matching rows and computing in JS`
1663
+ );
1664
+ return null;
1665
+ }
1666
+ }
1667
+ const query2 = buildScalarAggregate(
1668
+ this._config.tableName,
1669
+ agg.fn,
1670
+ column,
1671
+ where
1672
+ );
1673
+ const processRaw2 = (result) => {
1674
+ const value = result.rows[0]?.value;
1675
+ if (agg.fn === "avg") {
1676
+ return value == null ? null : Number(value);
1677
+ }
1678
+ return Number(value ?? 0);
1679
+ };
1680
+ return { query: query2, processRaw: processRaw2 };
1681
+ }
1682
+ const spec = agg.spec;
1683
+ const query = buildGroupedAggregate(this._config.tableName, {
1684
+ by: spec.by,
1685
+ select: spec.select,
1686
+ where,
1687
+ orderBy: spec.orderBy,
1688
+ desc: spec.desc,
1689
+ limit: spec.limit
1690
+ });
1691
+ const processRaw = (result) => {
1692
+ const rows = deserializeAggregateRows(
1693
+ result.rows,
1694
+ spec,
1695
+ this._config.columns
1696
+ );
1697
+ return spec.by.length === 0 ? rows[0] : rows;
1698
+ };
1699
+ return { query, processRaw };
1333
1700
  }
1334
1701
  catch(onrejected) {
1335
1702
  return this.then(void 0, onrejected);
@@ -1560,7 +1927,34 @@ var Table = class {
1560
1927
  max(accessor) {
1561
1928
  return this.sortBy(accessor).reverse().first();
1562
1929
  }
1563
- /** Group rows by a field. Returns a Map. */
1930
+ /**
1931
+ * Sum of a numeric field across all rows (0 for an empty table).
1932
+ * Runs as a SQL aggregate — no rows are fetched. Chain from filter()
1933
+ * to sum a subset: `Orders.filter(...).sum(o => o.amount)`.
1934
+ */
1935
+ sum(accessor) {
1936
+ return this.toArray().sum(accessor);
1937
+ }
1938
+ /**
1939
+ * Average of a numeric field across all rows (null for an empty table).
1940
+ * Runs as a SQL aggregate — no rows are fetched.
1941
+ */
1942
+ avg(accessor) {
1943
+ return this.toArray().avg(accessor);
1944
+ }
1945
+ /**
1946
+ * Number of distinct non-null values of a field across all rows.
1947
+ * Runs as a SQL aggregate — no rows are fetched.
1948
+ */
1949
+ countDistinct(accessor) {
1950
+ return this.toArray().countDistinct(accessor);
1951
+ }
1952
+ aggregate(spec) {
1953
+ return this.toArray().aggregate(spec);
1954
+ }
1955
+ /** Group rows by a field. Returns a Map of FULL rows per group — it
1956
+ * fetches everything. For grouped counts/sums/averages over tables that
1957
+ * can grow large, use `aggregate()` instead (SQL GROUP BY, no row fetch). */
1564
1958
  groupBy(accessor) {
1565
1959
  return new Query(this._config).groupBy(accessor);
1566
1960
  }
@@ -1796,6 +2190,57 @@ var Table = class {
1796
2190
  }
1797
2191
  };
1798
2192
 
2193
+ // src/db/raw.ts
2194
+ var RawQuery = class _RawQuery {
2195
+ _databaseId;
2196
+ _executeBatch;
2197
+ _query;
2198
+ constructor(databaseId, executeBatch, query) {
2199
+ this._databaseId = databaseId;
2200
+ this._executeBatch = executeBatch;
2201
+ this._query = query;
2202
+ }
2203
+ // -------------------------------------------------------------------------
2204
+ // PromiseLike — executes on await
2205
+ // -------------------------------------------------------------------------
2206
+ then(onfulfilled, onrejected) {
2207
+ return this._execute().then(onfulfilled, onrejected);
2208
+ }
2209
+ catch(onrejected) {
2210
+ return this._execute().catch(onrejected);
2211
+ }
2212
+ // -------------------------------------------------------------------------
2213
+ // Batch support
2214
+ // -------------------------------------------------------------------------
2215
+ /** @internal Compile for db.batch(). */
2216
+ _compile() {
2217
+ return {
2218
+ type: "raw",
2219
+ query: this._query,
2220
+ config: { databaseId: this._databaseId },
2221
+ processResult: (result) => _RawQuery._process(result)
2222
+ };
2223
+ }
2224
+ /** @internal Process a raw SQL result. Used by db.batch(). */
2225
+ static _processResults(result, compiled) {
2226
+ return compiled.processResult(result);
2227
+ }
2228
+ // -------------------------------------------------------------------------
2229
+ // Execution
2230
+ // -------------------------------------------------------------------------
2231
+ /** Schema-less deserialization: strips @@user@@ prefixes and best-effort
2232
+ * parses JSON-looking strings; makes no other representation promises. */
2233
+ static _process(result) {
2234
+ return result.rows.map(
2235
+ (row) => deserializeRow(row, [])
2236
+ );
2237
+ }
2238
+ async _execute() {
2239
+ const results = await this._executeBatch([this._query]);
2240
+ return _RawQuery._process(results[0]);
2241
+ }
2242
+ };
2243
+
1799
2244
  // src/db/index.ts
1800
2245
  function createDb(databases, executeBatch, authConfig, syncRoles) {
1801
2246
  return {
@@ -1823,6 +2268,16 @@ function createDb(databases, executeBatch, authConfig, syncRoles) {
1823
2268
  fromNow: (ms) => Date.now() + ms,
1824
2269
  // --- User references ---
1825
2270
  userRef: (id) => id.startsWith(USER_PREFIX) ? id.slice(USER_PREFIX.length) : id,
2271
+ // --- Raw SQL (read-only) ---
2272
+ sql(query, params, options) {
2273
+ validateRawSql(query);
2274
+ const database = resolveDatabase(databases, options?.database);
2275
+ return new RawQuery(
2276
+ database.id,
2277
+ (queries) => executeBatch(database.id, queries),
2278
+ { sql: query, params: params?.map(serializeParam) }
2279
+ );
2280
+ },
1826
2281
  // --- Batch execution ---
1827
2282
  batch: ((...operations) => {
1828
2283
  return (async () => {
@@ -1833,8 +2288,11 @@ function createDb(databases, executeBatch, authConfig, syncRoles) {
1833
2288
  if (op instanceof Mutation) {
1834
2289
  return op._compile();
1835
2290
  }
2291
+ if (op instanceof RawQuery) {
2292
+ return op._compile();
2293
+ }
1836
2294
  throw new MindStudioError(
1837
- "db.batch() only accepts Query and Mutation objects (from .filter(), .update(), .push(), etc.)",
2295
+ "db.batch() only accepts Query, Mutation, and RawQuery objects (from .filter(), .update(), .push(), db.sql(), etc.)",
1838
2296
  "invalid_batch_operation",
1839
2297
  400
1840
2298
  );
@@ -1847,6 +2305,8 @@ function createDb(databases, executeBatch, authConfig, syncRoles) {
1847
2305
  if (c.type === "query") {
1848
2306
  const sqlQuery = c.query ?? c.fallbackQuery;
1849
2307
  groups.get(dbId).push({ opIndex: i, sqlQueries: [sqlQuery] });
2308
+ } else if (c.type === "raw") {
2309
+ groups.get(dbId).push({ opIndex: i, sqlQueries: [c.query] });
1850
2310
  } else {
1851
2311
  groups.get(dbId).push({ opIndex: i, sqlQueries: c.queries });
1852
2312
  }
@@ -1879,6 +2339,8 @@ function createDb(databases, executeBatch, authConfig, syncRoles) {
1879
2339
  );
1880
2340
  }
1881
2341
  return Query._processResults(results[0], c);
2342
+ } else if (c.type === "raw") {
2343
+ return RawQuery._processResults(results[0], c);
1882
2344
  } else {
1883
2345
  return Mutation._processResults(results, c);
1884
2346
  }
@@ -1887,7 +2349,23 @@ function createDb(databases, executeBatch, authConfig, syncRoles) {
1887
2349
  })
1888
2350
  };
1889
2351
  }
1890
- function resolveTable(databases, tableName, databaseHint) {
2352
+ function validateRawSql(query) {
2353
+ if (typeof query !== "string" || !query.trim()) {
2354
+ throw new MindStudioError(
2355
+ "db.sql() requires a non-empty SQL string.",
2356
+ "invalid_sql",
2357
+ 400
2358
+ );
2359
+ }
2360
+ if (!/^\s*(select|with)\b/i.test(query)) {
2361
+ throw new MindStudioError(
2362
+ "db.sql() is read-only \u2014 the statement must start with SELECT or WITH. Use Table methods (push/update/upsert/remove) for writes.",
2363
+ "sql_read_only",
2364
+ 400
2365
+ );
2366
+ }
2367
+ }
2368
+ function resolveDatabase(databases, hint) {
1891
2369
  if (databases.length === 0) {
1892
2370
  throw new MindStudioError(
1893
2371
  `No databases found in app context. Make sure the app has at least one database configured.`,
@@ -1895,18 +2373,40 @@ function resolveTable(databases, tableName, databaseHint) {
1895
2373
  400
1896
2374
  );
1897
2375
  }
1898
- if (databaseHint) {
2376
+ if (hint) {
1899
2377
  const targetDb = databases.find(
1900
- (db2) => db2.id === databaseHint || db2.name === databaseHint
2378
+ (db2) => db2.id === hint || db2.name === hint
1901
2379
  );
1902
2380
  if (!targetDb) {
1903
2381
  const available = databases.map((db2) => db2.name || db2.id).join(", ");
1904
2382
  throw new MindStudioError(
1905
- `Database "${databaseHint}" not found. Available databases: ${available}`,
2383
+ `Database "${hint}" not found. Available databases: ${available}`,
1906
2384
  "database_not_found",
1907
2385
  400
1908
2386
  );
1909
2387
  }
2388
+ return targetDb;
2389
+ }
2390
+ if (databases.length > 1) {
2391
+ const available = databases.map((db2) => db2.name || db2.id).join(", ");
2392
+ throw new MindStudioError(
2393
+ `This app has multiple databases \u2014 pass { database } to pick one. Available databases: ${available}`,
2394
+ "ambiguous_database",
2395
+ 400
2396
+ );
2397
+ }
2398
+ return databases[0];
2399
+ }
2400
+ function resolveTable(databases, tableName, databaseHint) {
2401
+ if (databases.length === 0) {
2402
+ throw new MindStudioError(
2403
+ `No databases found in app context. Make sure the app has at least one database configured.`,
2404
+ "no_databases",
2405
+ 400
2406
+ );
2407
+ }
2408
+ if (databaseHint) {
2409
+ const targetDb = resolveDatabase(databases, databaseHint);
1910
2410
  const table = targetDb.tables.find((t) => t.name === tableName);
1911
2411
  if (!table) {
1912
2412
  const available = targetDb.tables.map((t) => t.name).join(", ");
@@ -3056,13 +3556,6 @@ var stepMetadata = {
3056
3556
  inputSchema: { "type": "object", "properties": { "webhookUrl": { "type": "string", "description": "Zapier webhook URL to send data to" }, "input": { "type": "object", "properties": {}, "required": [], "description": "Key-value pairs to send as the JSON POST body" } }, "required": ["webhookUrl", "input"] },
3057
3557
  outputSchema: { "type": "object", "properties": { "data": { "description": "Parsed webhook response from Zapier (JSON object, array, or string)" } }, "required": ["data"] }
3058
3558
  },
3059
- "queryAppDatabase": {
3060
- stepType: "queryAppDatabase",
3061
- description: "Execute a SQL query against the app managed database.",
3062
- usageNotes: '- Executes raw SQL against a SQLite database managed by the app.\n- For SELECT queries, returns rows as JSON.\n- For INSERT/UPDATE/DELETE, returns the number of affected rows.\n- Use {{variables}} directly in your SQL. By default they are automatically extracted\n and passed as safe parameterized values (preventing SQL injection).\n Example: INSERT INTO contacts (name, comment) VALUES ({{name}}, {{comment}})\n- Full MindStudio handlebars syntax is supported, including helpers like {{json myVar}},\n {{get myVar "$.path"}}, {{global.orgName}}, etc.\n- Set parameterize to false for raw/dynamic SQL where variables are interpolated directly\n into the query string. Use this when another step generates full or partial SQL, e.g.\n a bulk INSERT with a precomputed VALUES list. The user is responsible for sanitization\n when parameterize is false.',
3063
- inputSchema: { "type": "object", "properties": { "databaseId": { "type": "string", "description": "Name or ID of the app data database to query" }, "sql": { "type": "string", "description": "SQL query to execute. Use {{variables}} directly in the SQL \u2014 they are handled according to the `parameterize` setting.\n\nWhen parameterize is true (default): {{variables}} are extracted from the SQL, replaced with ? placeholders, resolved via the full MindStudio handlebars pipeline, and passed as safe parameterized values to SQLite. This prevents SQL injection. Example: INSERT INTO contacts (name, email) VALUES ({{name}}, {{email}})\n\nWhen parameterize is false: The entire SQL string is resolved via compileString (standard handlebars interpolation) and executed as-is. Use this for dynamic/generated SQL where another step builds the query. The user is responsible for safety. Example: {{generatedInsertQuery}}\n\nAsk the user for the database schema if they have not already provided it." }, "parameterize": { "type": "boolean", "description": "Whether to treat {{variables}} as parameterized query values (default: true).\n\n- true: {{vars}} are extracted, replaced with ?, and passed as bind params. Safe from SQL injection. Use for standard CRUD operations.\n- false: {{vars}} are interpolated directly into the SQL string via handlebars. Use when another step generates full or partial SQL (e.g. bulk inserts with precomputed VALUES). The user is responsible for sanitization." } }, "required": ["databaseId", "sql"] },
3064
- outputSchema: { "type": "object", "properties": { "rows": { "type": "array", "items": {}, "description": "Result rows for SELECT queries (empty array for write queries)" }, "changes": { "type": "number", "description": "Number of rows affected by INSERT, UPDATE, or DELETE queries (0 for SELECT)" } }, "required": ["rows", "changes"] }
3065
- },
3066
3559
  "queryDataSource": {
3067
3560
  stepType: "queryDataSource",
3068
3561
  description: "Legacy v1 data sources. v2 apps should use `dataSources.defineDataSource()` from @mindstudio-ai/agent instead, which owns parsing, chunking, embedding and citations. Search a vector data source (RAG) and return relevant document chunks.",
@@ -3175,9 +3668,13 @@ var stepMetadata = {
3175
3668
  "searchGoogle": {
3176
3669
  stepType: "searchGoogle",
3177
3670
  description: "Search the web using Google and return structured results.",
3178
- usageNotes: "- Defaults to us/english, but can optionally specify country and/or language.\n- Defaults to any time, but can optionally specify last hour, last day, week, month, or year.\n- Defaults to top 30 results, but can specify 1 to 100 results to return.",
3179
- inputSchema: { "type": "object", "properties": { "query": { "type": "string", "description": "The search query to send to Google" }, "exportType": { "enum": ["text", "json"], "type": "string", "description": 'Format for the variable value: "text" or "json"' }, "countryCode": { "type": "string", "description": "Google gl country code (defaults to US)" }, "languageCode": { "type": "string", "description": 'Google hl language code (defaults to "en")' }, "dateRange": { "enum": ["hour", "day", "week", "month", "year", "any"], "type": "string", "description": 'Time range filter: "hour", "day", "week", "month", "year", or "any"' }, "numResults": { "type": "number", "description": "Number of results to return (1-100, default: 30)" } }, "required": ["query", "exportType"] },
3180
- outputSchema: { "type": "object", "properties": { "results": { "type": "array", "items": { "type": "object", "properties": { "title": { "type": "string", "description": "Title of the search result" }, "description": { "type": "string", "description": "Snippet/description of the search result" }, "url": { "type": "string", "description": "URL of the search result page" } }, "required": ["title", "description", "url"] }, "description": "List of search result entries" } }, "required": ["results"] }
3671
+ usageNotes: `- Defaults to us/english, but can optionally specify country and/or language.
3672
+ - Defaults to any time, but can optionally specify last hour, last day, week, month, or year.
3673
+ - Defaults to top 30 results, but can specify 1 to 100 results to return.
3674
+ - Set fetchTopN to also read the page content of the first N results in the same step, instead of following up with a separate Scrape URL step per result. Costs meaningfully more (each page is a full scrape), so ask for the fewest you need.
3675
+ - Also returns Google's "people also ask" and related searches when available, which are useful for choosing a follow-up query.`,
3676
+ inputSchema: { "type": "object", "properties": { "query": { "type": "string", "description": "The search query to send to Google" }, "exportType": { "enum": ["text", "json"], "type": "string", "description": 'Format for the variable value: "text" or "json"' }, "countryCode": { "type": "string", "description": "Google gl country code (defaults to US)" }, "languageCode": { "type": "string", "description": 'Google hl language code (defaults to "en")' }, "dateRange": { "enum": ["hour", "day", "week", "month", "year", "any"], "type": "string", "description": 'Time range filter: "hour", "day", "week", "month", "year", or "any"' }, "numResults": { "type": "number", "description": "Number of results to return (1-100, default: 30)" }, "fetchTopN": { "type": "number", "description": "Read the page content of the first N results in this same step (0-10, default 0 = off), rather than following up with a Scrape URL step per result. Each page is a full scrape, so this costs substantially more than the search alone." } }, "required": ["query", "exportType"] },
3677
+ outputSchema: { "type": "object", "properties": { "results": { "type": "array", "items": { "type": "object", "properties": { "title": { "type": "string", "description": "Title of the search result" }, "description": { "type": "string", "description": "Snippet/description of the search result" }, "url": { "type": "string", "description": "URL of the search result page" }, "content": { "type": "string", "description": "Page content, only present when fetchTopN covered this result" } }, "required": ["title", "description", "url"] }, "description": "List of search result entries" }, "peopleAlsoAsk": { "type": "array", "items": { "type": "object", "properties": { "question": { "type": "string" }, "snippet": { "type": "string" }, "link": { "type": "string" } } }, "description": `Google's "People also ask" entries, when Google returned any` }, "relatedSearches": { "type": "array", "items": { "type": "string" }, "description": "Google's related-search suggestions, when Google returned any" } }, "required": ["results"] }
3181
3678
  },
3182
3679
  "searchGoogleCalendarEvents": {
3183
3680
  stepType: "searchGoogleCalendarEvents",
@@ -4729,9 +5226,6 @@ function applyStepMethods(AgentClass) {
4729
5226
  proto.postToZapier = function(step, options) {
4730
5227
  return this.executeStep("postToZapier", step, options);
4731
5228
  };
4732
- proto.queryAppDatabase = function(step, options) {
4733
- return this.executeStep("queryAppDatabase", step, options);
4734
- };
4735
5229
  proto.queryDataSource = function(step, options) {
4736
5230
  return this.executeStep("queryDataSource", step, options);
4737
5231
  };
@@ -6295,6 +6789,24 @@ var MindStudioAgent = class {
6295
6789
  ago: (ms) => Date.now() - ms,
6296
6790
  fromNow: (ms) => Date.now() + ms,
6297
6791
  userRef: (id) => id.startsWith("@@user@@") ? id.slice("@@user@@".length) : id,
6792
+ // Raw SQL — validate synchronously; database resolution needs context,
6793
+ // so it happens inside the executeBatch closure at execution time
6794
+ // (the same deferral lazy defineTable uses).
6795
+ sql: ((query, params, options) => {
6796
+ validateRawSql(query);
6797
+ return new RawQuery(
6798
+ "",
6799
+ async (queries) => {
6800
+ await agent.ensureContext();
6801
+ const database = resolveDatabase(
6802
+ agent._getContext().databases,
6803
+ options?.database
6804
+ );
6805
+ return agent._executeDbBatch(database.id, queries);
6806
+ },
6807
+ { sql: query, params: params?.map(serializeParam) }
6808
+ );
6809
+ }),
6298
6810
  // Batch needs context — hydrate first, then delegate to real db
6299
6811
  batch: ((...queries) => {
6300
6812
  return (async () => {
@@ -6850,7 +7362,6 @@ var monacoSnippets = {
6850
7362
  "postToSlackChannel": { fields: [["channelId", "string"], ["messageType", ["string", "blocks"]], ["message", "string"]], outputKeys: [] },
6851
7363
  "postToX": { fields: [["text", "string"]], outputKeys: [] },
6852
7364
  "postToZapier": { fields: [["webhookUrl", "string"], ["input", "object"]], outputKeys: ["data"] },
6853
- "queryAppDatabase": { fields: [["databaseId", "string"], ["sql", "string"]], outputKeys: ["rows", "changes"] },
6854
7365
  "queryDataSource": { fields: [["dataSourceId", "string"], ["query", "string"], ["maxResults", "number"]], outputKeys: ["text", "chunks", "query", "citations", "latencyMs"] },
6855
7366
  "queryExternalDatabase": { fields: [["query", "string"], ["outputFormat", ["json", "csv"]]], outputKeys: ["data"] },
6856
7367
  "redactPII": { fields: [["input", "string"], ["language", "string"], ["entities", "array"]], outputKeys: ["text"] },