@querypanel/node-sdk 1.3.0-beta.1 → 1.3.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
@@ -56,7 +56,7 @@ const createPostgresClient = () => async (sql: string, params?: unknown[]) => {
56
56
  });
57
57
 
58
58
  qp.attachClickhouse(
59
- "clicks", // uniq identifier for QueryPanel
59
+ "analytics", // uniq identifier for QueryPanel
60
60
  (params) => clickhouse.query(params),
61
61
  {
62
62
  database: "analytics", // database name
@@ -65,15 +65,10 @@ qp.attachClickhouse(
65
65
  },
66
66
  );
67
67
 
68
- // Syncs schema. Skips embedding if schema hasn't changed (no drift).
69
- // Pass { forceReindex: true } to force re-embedding.
70
- await qp.syncSchema("analytics", { tenantId: "tenant_123" });
71
-
72
68
  const response = await qp.ask("Top countries by revenue", {
73
69
  tenantId: "tenant_123",
74
70
  database: "analytics",
75
71
  });
76
- // Defaults to pipeline "v2" (POST /v2/query). Pass pipeline: "v1" only for legacy.
77
72
 
78
73
  console.log(response.sql);
79
74
  console.log(response.params);
@@ -81,23 +76,71 @@ console.table(response.rows);
81
76
  console.log(response.chart.vegaLiteSpec);
82
77
  ```
83
78
 
84
- ### Custom system instructions (v2 pipeline)
79
+ Run `syncSchema` separately — see [Schema sync](#schema-sync).
80
+
81
+ ### Schema sync
82
+
83
+ `syncSchema` introspects an attached database and uploads table/column metadata so natural-language questions can generate accurate SQL. Treat it like a **migration**, not part of `ask()`:
85
84
 
86
- If you need to enforce tenant-specific policies that should apply to every query (for example, data retention windows), you can inject additional **system prompt** instructions into the v2 pipeline (the default):
85
+ - Run it once when you first connect a database.
86
+ - Run it again when tables or columns change.
87
+ - Do not call it on every request — put it in a setup/deploy script instead.
87
88
 
88
89
  ```ts
89
- const response = await qp.ask("Revenue by product", {
90
+ // scripts/sync-schema.ts — run once at setup, or after schema changes
91
+ await qp.syncSchema("pg_demo", { tenantId: "tenant_123" });
92
+ await qp.syncSchema("analytics", { tenantId: "tenant_123" });
93
+
94
+ // Unchanged schemas skip re-embedding. Force a rebuild only when needed:
95
+ await qp.syncSchema("analytics", {
90
96
  tenantId: "tenant_123",
97
+ forceReindex: true,
98
+ });
99
+ ```
100
+
101
+ Optionally limit the payload with `tables: ["orders"]`.
102
+
103
+ ### Custom system prompt (`systemPrompt`)
104
+
105
+ Pass `systemPrompt` on `ask()` to inject extra instructions for SQL generation. Use it when a **client** needs policies QueryPanel does not know about — retention windows, a max date range, excluded event types, and similar product rules. Look up that client’s config in your backend and pass it on every question.
106
+
107
+ The text is appended to the SQL generator (and reflection) prompts. When it conflicts with generic defaults (for example the usual 30-day trend window), the caller instructions win. It does **not** replace tenant isolation — still attach with `tenantFieldName` and pass `tenantId`. Max length is 8,000 characters. There is no constructor default; `modifyChart()` does not take `systemPrompt`.
108
+
109
+ ```ts
110
+ function systemPromptForClient(client: { retentionDays: number }) {
111
+ return [
112
+ `Data retention: only query rows from the last ${client.retentionDays} days.`,
113
+ `Never return a date range longer than ${client.retentionDays} days, even if the user asks for all time or a wider window.`,
114
+ "Clamp any requested range to that maximum and bind start/end dates as parameters.",
115
+ ].join(" ");
116
+ }
117
+
118
+ const client = await loadClient(req); // your auth + billing/retention settings
119
+
120
+ const response = await qp.ask("Revenue by product over all time", {
121
+ tenantId: client.tenantId,
91
122
  database: "analytics",
92
- systemPrompt: "Retention policy: only query data from the last 30 days.",
123
+ systemPrompt: systemPromptForClient(client),
93
124
  });
94
125
  ```
95
126
 
96
- > **Deprecated:** `pipeline: "v1"` still hits `POST /query` for compatibility. Prefer omitting `pipeline` or passing `"v2"`.
127
+ Other per-client instructions:
128
+
129
+ ```ts
130
+ const response = await qp.ask("Show order volume by week", {
131
+ tenantId: client.tenantId,
132
+ database: "analytics",
133
+ systemPrompt: [
134
+ "Default time range: last 90 days unless the user names a shorter window.",
135
+ "Maximum lookback: 90 days from today.",
136
+ "Exclude orders with status refunded or cancelled.",
137
+ ].join(" "),
138
+ });
139
+ ```
97
140
 
98
- ### Restricting VizSpec chart types
141
+ ### Restricting chart types
99
142
 
100
- If your UI only supports a subset of VizSpec chart kinds (for example no area or scatter), set **`supportedChartTypes`** on the SDK constructor or on each `ask` / `modifyChart` call. The API’s `/vizspec` step then steers generation toward allowed types only (`line`, `bar`, `column`, `area`, `scatter`, `pie`). This applies whenever you use **`chartType: "vizspec"`** (including the default v2 flow after SQL runs).
143
+ If your UI only supports a subset of chart kinds (for example no area or scatter), set **`supportedChartTypes`** on the SDK constructor or on each `ask` / `modifyChart` call (`line`, `bar`, `column`, `area`, `scatter`, `pie`).
101
144
 
102
145
  ```ts
103
146
  import { QueryPanelSdkAPI, ALL_VIZ_CHART_TYPES, type ChartType } from "@querypanel/sdk";
@@ -112,8 +155,6 @@ const qp = new QueryPanelSdkAPI(url, privateKey, workspaceId, {
112
155
  await qp.ask("Revenue by month", {
113
156
  tenantId: "t1",
114
157
  database: "analytics",
115
- chartType: "vizspec",
116
- pipeline: "v2",
117
158
  supportedChartTypes: allowed,
118
159
  });
119
160
  ```
@@ -245,9 +286,37 @@ const { data, missingIds } = await qp.getChartsByIds(
245
286
  Saved charts now include the original `prompt` so you can show the question
246
287
  alongside chart history or reuse it in follow-up workflows.
247
288
 
289
+ ## Referencing Queries by `queryId` (recommended)
290
+
291
+ `ask()` and `modifyChart()` return a server-issued `queryId`. The server stores the generated SQL and bind params for that id, scoped to the authenticated workspace and tenant. **Pass the id instead of SQL** so your clients never send executable SQL or bind params:
292
+
293
+ ```ts
294
+ const response = await qp.ask("revenue by country", { tenantId, database: "analytics" });
295
+
296
+ // Modify: SQL and params are loaded from the server, client `sql` / `params` are ignored
297
+ const modified = await qp.modifyChart(
298
+ { queryId: response.queryId, question: "revenue by country", database: "analytics",
299
+ vizModifications: { chartType: "bar" } },
300
+ { tenantId },
301
+ );
302
+
303
+ // Run a stored query (e.g. in a dashboard)
304
+ await qp.runSqlForDashboard({ queryId: response.queryId, database: "analytics" }, { tenantId });
305
+
306
+ // Save a chart: the server stores the SQL and params it holds for `query_id`
307
+ await qp.createChart({ title: "Revenue", query_id: modified.queryId, vega_lite_spec: spec }, { tenantId });
308
+ ```
309
+
310
+ - When `queryId` / `query_id` is present it **takes priority**: any `sql` / `sql_params` you also send is ignored.
311
+ - An id that belongs to another tenant, or does not exist, is rejected (`404`).
312
+ - `sql` is still accepted when no id is sent, for backward compatibility. Charts saved without a `query_id` (for example older charts) keep using their stored SQL.
313
+ - `modifyChart()` returns the input `queryId` when only visualization changes, and a new `queryId` when SQL is regenerated. Custom SQL (`customSql`) has no id.
314
+ - Generation fails if the server cannot record the query, so a successful `ask()` always returns a `queryId`.
315
+ - Requires an API that supports `GET /v2/queries/:id`; upgrade the API before the SDK.
316
+
248
317
  ## Modifying Charts
249
318
 
250
- The `modifyChart()` method allows you to edit SQL and/or visualization settings, then re-execute and regenerate charts. It works with both fresh `ask()` responses and saved charts.
319
+ The `modifyChart()` method allows you to edit SQL and/or visualization settings, then re-execute and regenerate charts. It works with both fresh `ask()` responses and saved charts. Prefer `queryId` over `sql` (see above).
251
320
 
252
321
  ### Changing Visualization Settings
253
322
 
package/dist/index.cjs CHANGED
@@ -1968,11 +1968,17 @@ function buildSchemaRequest(databaseName, adapter, introspection, metadata) {
1968
1968
  dialect,
1969
1969
  tables
1970
1970
  };
1971
- if (metadata?.tenantFieldName && metadata?.tenantFieldType && metadata?.enforceTenantIsolation !== void 0) {
1971
+ if (metadata?.enforceTenantIsolation === false) {
1972
+ request.tenant_settings = {
1973
+ tenantFieldName: metadata.tenantFieldName ?? "",
1974
+ tenantFieldType: metadata.tenantFieldType ?? "String",
1975
+ enforceTenantIsolation: false
1976
+ };
1977
+ } else if (metadata?.tenantFieldName && metadata?.tenantFieldType) {
1972
1978
  request.tenant_settings = {
1973
1979
  tenantFieldName: metadata.tenantFieldName,
1974
1980
  tenantFieldType: metadata.tenantFieldType,
1975
- enforceTenantIsolation: metadata.enforceTenantIsolation
1981
+ enforceTenantIsolation: true
1976
1982
  };
1977
1983
  }
1978
1984
  return request;
@@ -2000,11 +2006,17 @@ async function ask(client, queryEngine, question, options, signal) {
2000
2006
  const databaseName = options.database ?? queryEngine.getDefaultDatabase();
2001
2007
  const metadata = databaseName ? queryEngine.getDatabaseMetadata(databaseName) : void 0;
2002
2008
  let tenantSettings;
2003
- if (metadata?.tenantFieldName) {
2009
+ if (metadata?.enforceTenantIsolation === false) {
2010
+ tenantSettings = {
2011
+ tenantFieldName: metadata.tenantFieldName ?? "",
2012
+ tenantFieldType: metadata.tenantFieldType ?? "String",
2013
+ enforceTenantIsolation: false
2014
+ };
2015
+ } else if (metadata?.tenantFieldName) {
2004
2016
  tenantSettings = {
2005
2017
  tenantFieldName: metadata.tenantFieldName,
2006
2018
  tenantFieldType: metadata.tenantFieldType,
2007
- enforceTenantIsolation: metadata.enforceTenantIsolation
2019
+ enforceTenantIsolation: true
2008
2020
  };
2009
2021
  }
2010
2022
  const queryResponse = await client.postWithHeaders(
@@ -2182,6 +2194,25 @@ function anonymizeResults(rows) {
2182
2194
  });
2183
2195
  }
2184
2196
 
2197
+ // src/routes/queries.ts
2198
+ async function resolveQuery(client, queryEngine, queryId, options, signal) {
2199
+ const response = await client.get(
2200
+ `/v2/queries/${encodeURIComponent(queryId)}`,
2201
+ options.tenantId,
2202
+ options.userId,
2203
+ options.scopes,
2204
+ signal
2205
+ );
2206
+ const paramMetadata = Array.isArray(response.params) ? response.params : [];
2207
+ return {
2208
+ queryId: response.queryId,
2209
+ sql: response.sql,
2210
+ params: queryEngine.mapGeneratedParams(paramMetadata),
2211
+ paramMetadata,
2212
+ dialect: response.dialect
2213
+ };
2214
+ }
2215
+
2185
2216
  // src/routes/modify.ts
2186
2217
  function buildModifiedQuestion(originalQuestion, modifications, pipeline, hasSessionContext = false) {
2187
2218
  const hints = [];
@@ -2336,11 +2367,28 @@ async function modifyChart(client, queryEngine, input, options, signal) {
2336
2367
  const hasCustomSql = !!input.sqlModifications?.customSql;
2337
2368
  const pipeline = resolveQueryPipeline(options?.pipeline);
2338
2369
  const queryEndpoint = pipeline === "v2" ? "/v2/query" : "/query";
2339
- let finalSql = input.sql;
2370
+ let currentSql = input.sql;
2340
2371
  let finalParams = input.params ?? {};
2341
2372
  let paramMetadata = [];
2342
2373
  let rationale;
2343
2374
  let queryId;
2375
+ if (input.queryId) {
2376
+ const resolved = await resolveQuery(
2377
+ client,
2378
+ queryEngine,
2379
+ input.queryId,
2380
+ { tenantId, userId: options?.userId, scopes: options?.scopes },
2381
+ signal
2382
+ );
2383
+ currentSql = resolved.sql;
2384
+ finalParams = resolved.params;
2385
+ paramMetadata = resolved.paramMetadata;
2386
+ queryId = resolved.queryId;
2387
+ }
2388
+ if (!currentSql) {
2389
+ throw new Error("modifyChart requires either queryId or sql.");
2390
+ }
2391
+ let finalSql = currentSql;
2344
2392
  let sqlChanged = false;
2345
2393
  let finalQuestion = input.question;
2346
2394
  const databaseName = input.database ?? queryEngine.getDefaultDatabase();
@@ -2351,17 +2399,24 @@ async function modifyChart(client, queryEngine, input, options, signal) {
2351
2399
  }
2352
2400
  const metadata = queryEngine.getDatabaseMetadata(databaseName);
2353
2401
  let tenantSettings;
2354
- if (metadata?.tenantFieldName) {
2402
+ if (metadata?.enforceTenantIsolation === false) {
2403
+ tenantSettings = {
2404
+ tenantFieldName: metadata.tenantFieldName ?? "",
2405
+ tenantFieldType: metadata.tenantFieldType ?? "String",
2406
+ enforceTenantIsolation: false
2407
+ };
2408
+ } else if (metadata?.tenantFieldName) {
2355
2409
  tenantSettings = {
2356
2410
  tenantFieldName: metadata.tenantFieldName,
2357
2411
  tenantFieldType: metadata.tenantFieldType,
2358
- enforceTenantIsolation: metadata.enforceTenantIsolation
2412
+ enforceTenantIsolation: true
2359
2413
  };
2360
2414
  }
2361
2415
  if (hasCustomSql) {
2362
2416
  finalSql = input.sqlModifications.customSql;
2363
2417
  finalParams = {};
2364
2418
  paramMetadata = [];
2419
+ queryId = void 0;
2365
2420
  sqlChanged = true;
2366
2421
  } else if (hasSqlMods && !hasCustomSql) {
2367
2422
  const modifiedQuestion = buildModifiedQuestion(
@@ -2378,7 +2433,7 @@ async function modifyChart(client, queryEngine, input, options, signal) {
2378
2433
  {
2379
2434
  question: modifiedQuestion,
2380
2435
  session_id: querypanelSessionId,
2381
- previous_sql: input.sql,
2436
+ previous_sql: currentSql,
2382
2437
  ...options?.maxRetry ? { max_retry: options.maxRetry } : {},
2383
2438
  ...tenantSettings ? { tenant_settings: tenantSettings } : {},
2384
2439
  ...databaseName ? { database: databaseName } : {},
@@ -2403,7 +2458,7 @@ async function modifyChart(client, queryEngine, input, options, signal) {
2403
2458
  }
2404
2459
  rationale = queryResponse.rationale;
2405
2460
  queryId = queryResponse.queryId;
2406
- sqlChanged = finalSql !== input.sql;
2461
+ sqlChanged = finalSql !== currentSql;
2407
2462
  }
2408
2463
  const execution = await queryEngine.validateAndExecute(
2409
2464
  finalSql,
@@ -2611,6 +2666,12 @@ var ALL_VIZ_CHART_TYPES = [
2611
2666
  ];
2612
2667
 
2613
2668
  // src/index.ts
2669
+ function resolveAttachEnforceTenantIsolation(tenantFieldName, enforceTenantIsolation) {
2670
+ if (enforceTenantIsolation !== void 0) {
2671
+ return enforceTenantIsolation;
2672
+ }
2673
+ return tenantFieldName ? true : void 0;
2674
+ }
2614
2675
  var QueryPanelSdkAPI = class _QueryPanelSdkAPI {
2615
2676
  /**
2616
2677
  * @param workspaceId - Workspace UUID (same value as your org in the dashboard). Passed to the API as JWT claim `organizationId`.
@@ -2681,7 +2742,10 @@ var QueryPanelSdkAPI = class _QueryPanelSdkAPI {
2681
2742
  tags: options?.tags,
2682
2743
  tenantFieldName: options?.tenantFieldName,
2683
2744
  tenantFieldType: options?.tenantFieldType ?? "String",
2684
- enforceTenantIsolation: options?.tenantFieldName ? options?.enforceTenantIsolation ?? true : void 0
2745
+ enforceTenantIsolation: resolveAttachEnforceTenantIsolation(
2746
+ options?.tenantFieldName,
2747
+ options?.enforceTenantIsolation
2748
+ )
2685
2749
  };
2686
2750
  this.queryEngine.attachDatabase(name, adapter, metadata);
2687
2751
  }
@@ -2694,7 +2758,10 @@ var QueryPanelSdkAPI = class _QueryPanelSdkAPI {
2694
2758
  tags: options?.tags,
2695
2759
  tenantFieldName: options?.tenantFieldName,
2696
2760
  tenantFieldType: options?.tenantFieldType ?? "String",
2697
- enforceTenantIsolation: options?.tenantFieldName ? options?.enforceTenantIsolation ?? true : void 0
2761
+ enforceTenantIsolation: resolveAttachEnforceTenantIsolation(
2762
+ options?.tenantFieldName,
2763
+ options?.enforceTenantIsolation
2764
+ )
2698
2765
  };
2699
2766
  this.queryEngine.attachDatabase(name, adapter, metadata);
2700
2767
  }
@@ -2707,7 +2774,10 @@ var QueryPanelSdkAPI = class _QueryPanelSdkAPI {
2707
2774
  tags: options?.tags,
2708
2775
  tenantFieldName: options?.tenantFieldName,
2709
2776
  tenantFieldType: options?.tenantFieldType ?? "String",
2710
- enforceTenantIsolation: options?.tenantFieldName ? options?.enforceTenantIsolation ?? true : void 0
2777
+ enforceTenantIsolation: resolveAttachEnforceTenantIsolation(
2778
+ options?.tenantFieldName,
2779
+ options?.enforceTenantIsolation
2780
+ )
2711
2781
  };
2712
2782
  this.queryEngine.attachDatabase(name, adapter, metadata);
2713
2783
  }
@@ -2833,9 +2903,23 @@ var QueryPanelSdkAPI = class _QueryPanelSdkAPI {
2833
2903
  throw new Error("tenantId is required");
2834
2904
  }
2835
2905
  const databaseName = input.database ?? "db";
2836
- const params = input.params ?? {};
2906
+ let sql = input.sql;
2907
+ let params = input.params ?? {};
2908
+ if (input.queryId) {
2909
+ const resolved = await resolveQuery(
2910
+ this.client,
2911
+ this.queryEngine,
2912
+ input.queryId,
2913
+ { tenantId }
2914
+ );
2915
+ sql = resolved.sql;
2916
+ params = resolved.params;
2917
+ }
2918
+ if (!sql) {
2919
+ throw new Error("Either queryId or sql is required");
2920
+ }
2837
2921
  const result = await this.queryEngine.validateAndExecute(
2838
- input.sql,
2922
+ sql,
2839
2923
  params,
2840
2924
  databaseName,
2841
2925
  tenantId