@fadhilp/stateql 0.4.0 → 0.4.2

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
@@ -64,7 +64,9 @@ Example first page:
64
64
  Running the same normalized query with the same parameters reuses `q_1` while
65
65
  its cache is valid. Use `--cache bypass` when a fresh read is required.
66
66
 
67
- PostgreSQL and MySQL credentials should come from environment variables:
67
+ PostgreSQL and MySQL credentials should come from environment variables. The
68
+ variable must contain the complete connection URL, not only its password.
69
+ Environment-backed SQLite paths require an explicit `sqlite:` prefix.
68
70
 
69
71
  ```bash
70
72
  export APP_DATABASE_URL='postgres://user:password@host/app'
@@ -72,15 +74,25 @@ stql connect --env APP_DATABASE_URL --name app --read-only
72
74
 
73
75
  export MYSQL_DATABASE_URL='mysql://user:password@host/app'
74
76
  stql connect --env MYSQL_DATABASE_URL --name mysql-app --read-only
77
+
78
+ export SQLITE_DATABASE='sqlite:./app.sqlite'
79
+ stql connect --env SQLITE_DATABASE --name local --read-only
75
80
  ```
76
81
 
82
+ A connection accepts exactly one direct target, `--env`, or `--profile` source.
83
+ For PostgreSQL, StateQL preserves strict TLS verification by normalizing
84
+ `sslmode=prefer`, `require`, and `verify-ca` to `verify-full` before opening the
85
+ adapter. Use `sslmode=verify-full` explicitly for clarity. Setting
86
+ `uselibpqcompat=true` opts out and keeps libpq-compatible SSL semantics.
87
+
77
88
  MySQL uses positional `?` parameters. MariaDB compatibility is not currently
78
89
  claimed.
79
90
 
80
91
  ## Commands
81
92
 
82
93
  ```text
83
- stql connect <sqlite-path|postgres-url|mysql-url> [--name NAME] [--env ENV] [--read-write]
94
+ stql connect <sqlite-path|postgres-url|mysql-url> [--name NAME] [--read-write]
95
+ stql connect --env ENV [--name NAME] [--read-write]
84
96
  stql connect --profile NAME
85
97
  stql status
86
98
  stql profile add|list|show|remove
@@ -301,8 +313,10 @@ secret-free failures. Unknown resolver errors are replaced with a generic
301
313
  StateQL calls the resolver only immediately before database access, after SQL
302
314
  safety and duplicate checks. Requests contain actor/session identity, the
303
315
  operation's effective read/write access, an abort signal, and sanitized
304
- connection metadata. Returned values are passed directly to the adapter.
305
- Credential-bearing PostgreSQL and MySQL URLs are redacted before connection
316
+ connection metadata. Returned values must be complete PostgreSQL/MySQL URLs or
317
+ explicit `sqlite:` sources. StateQL validates the source and its stored driver
318
+ before adapter construction, and normalizes SQLite paths. Credential-bearing
319
+ PostgreSQL and MySQL URLs are redacted before connection
306
320
  metadata is persisted and never enter history, snapshots, cache keys, or
307
321
  responses. SQLite paths remain persisted connection metadata, as they are for
308
322
  direct SQLite connections. Harnesses remain responsible for approval policy,
@@ -37,3 +37,4 @@ export declare function createAdapterContext(timeoutMs: number, signal?: AbortSi
37
37
  export declare function createAdapter(connection: ConnectionRecord, context: AdapterContext, input: {
38
38
  source: string;
39
39
  }): Promise<Adapter>;
40
+ export declare function normalizePostgresConnectionString(source: string): string;
@@ -182,6 +182,23 @@ class SQLiteAdapter {
182
182
  this.pending.clear();
183
183
  }
184
184
  }
185
+ const STRICT_POSTGRES_SSL_MODE_ALIASES = new Set(["prefer", "require", "verify-ca"]);
186
+ export function normalizePostgresConnectionString(source) {
187
+ try {
188
+ const url = new URL(source);
189
+ const parameters = [...url.searchParams.entries()];
190
+ const libpqCompat = parameters.filter(([key]) => key === "uselibpqcompat").at(-1)?.[1];
191
+ const sslMode = parameters.filter(([key]) => key === "sslmode").at(-1)?.[1];
192
+ if (libpqCompat === "true" || !sslMode || !STRICT_POSTGRES_SSL_MODE_ALIASES.has(sslMode))
193
+ return source;
194
+ url.searchParams.delete("sslmode");
195
+ url.searchParams.append("sslmode", "verify-full");
196
+ return url.toString();
197
+ }
198
+ catch {
199
+ return source;
200
+ }
201
+ }
185
202
  class PostgresAdapter {
186
203
  readOnly;
187
204
  context;
@@ -194,7 +211,7 @@ class PostgresAdapter {
194
211
  this.context = context;
195
212
  const timeout = Math.min(2_147_483_647, remainingMilliseconds(context));
196
213
  this.client = new Client({
197
- connectionString: source,
214
+ connectionString: normalizePostgresConnectionString(source),
198
215
  connectionTimeoutMillis: timeout,
199
216
  statement_timeout: timeout,
200
217
  });
@@ -2,6 +2,10 @@ import type { ConnectionRecord } from "./store.js";
2
2
  import type { Driver, StateConfidence } from "./types.js";
3
3
  export declare function databaseIdentity(connection: ConnectionRecord): unknown;
4
4
  export declare function detectDriver(target: string): Driver;
5
+ export declare function credentialSource(value: string, expectedDriver?: Driver): {
6
+ driver: Driver;
7
+ source: string;
8
+ };
5
9
  export declare function normalizeSqliteSource(target: string): string;
6
10
  export declare function databaseUrlHasSecret(target: string): boolean;
7
11
  export declare function version(connection: ConnectionRecord): string;
@@ -18,8 +18,34 @@ export function detectDriver(target) {
18
18
  }
19
19
  return "sqlite";
20
20
  }
21
+ export function credentialSource(value, expectedDriver) {
22
+ const explicitSqlite = /^sqlite:(?!\/\/)/i.test(value);
23
+ const driver = explicitSqlite ? "sqlite" : detectDriver(value);
24
+ if (driver === "sqlite" && (!explicitSqlite || value.length === 7)) {
25
+ throw new StateQLError("INVALID_COMMAND", "Secret environment variable must contain a complete PostgreSQL/MySQL URL or an explicit sqlite: source.", {
26
+ suggestedAction: "Store the full database URL, or prefix an SQLite path with sqlite:.",
27
+ });
28
+ }
29
+ if (driver !== "sqlite") {
30
+ try {
31
+ const url = new URL(value);
32
+ if (!url.hostname && !url.pathname.replaceAll("/", ""))
33
+ throw new Error();
34
+ }
35
+ catch {
36
+ throw new StateQLError("INVALID_COMMAND", "Secret environment variable must contain a valid database URL.");
37
+ }
38
+ }
39
+ if (expectedDriver && driver !== expectedDriver) {
40
+ throw new StateQLError("INVALID_COMMAND", "Resolved credential driver does not match the selected database connection.");
41
+ }
42
+ return {
43
+ driver,
44
+ source: driver === "sqlite" ? normalizeSqliteSource(value) : value,
45
+ };
46
+ }
21
47
  export function normalizeSqliteSource(target) {
22
- const source = target.startsWith("sqlite:") ? target.slice(7) : target;
48
+ const source = target.replace(/^sqlite:/i, "");
23
49
  if (source === ":memory:") {
24
50
  throw new StateQLError("INVALID_COMMAND", "SQLite :memory: databases cannot persist across StateQL commands.");
25
51
  }
@@ -2,7 +2,7 @@ import { writeFileSync } from "node:fs";
2
2
  import { basename, resolve } from "node:path";
3
3
  import { env } from "node:process";
4
4
  import { AdapterExecutionError, AdapterWriteError, BatchWriteError, createAdapter, createAdapterContext, } from "./adapters.js";
5
- import { confidence, databaseIdentity, databaseUrlHasSecret, detectDriver, isEnvironmentName, normalizeSqliteSource, validateProfileName, version, } from "./connection.js";
5
+ import { confidence, credentialSource, databaseIdentity, databaseUrlHasSecret, detectDriver, isEnvironmentName, normalizeSqliteSource, validateProfileName, version, } from "./connection.js";
6
6
  import { asStateQLError, CredentialResolutionError, StateQLError, } from "./errors.js";
7
7
  import { filterMaterializedRows, prepareFilterStatement, validateFilterParameters, } from "./filter.js";
8
8
  import { operationData, paginationWarnings, profileData, rowsToCsv, sessionData, transactionData, } from "./response-data.js";
@@ -72,8 +72,10 @@ export class StateQL {
72
72
  if (session.active_transaction_id) {
73
73
  throw new StateQLError("TRANSACTION_FAILED", "Commit or roll back the active transaction before connecting again.");
74
74
  }
75
- if (options.profile && target) {
76
- throw new StateQLError("INVALID_COMMAND", "Use either a profile or a connection target, not both.");
75
+ const sourceCount = [target, options.profile, options.secretEnv]
76
+ .filter((value) => value !== undefined).length;
77
+ if (sourceCount > 1) {
78
+ throw new StateQLError("INVALID_COMMAND", "Use exactly one connection target, profile, or secret environment variable.");
77
79
  }
78
80
  const implicitProfile = !options.profile && !options.secretEnv && target
79
81
  ? this.store.getProfile(target)
@@ -101,7 +103,10 @@ export class StateQL {
101
103
  if (!secret) {
102
104
  throw new StateQLError("INVALID_COMMAND", "Connection target is required.");
103
105
  }
104
- const driver = detectDriver(secret);
106
+ const resolvedSource = secretEnv
107
+ ? credentialSource(secret)
108
+ : { driver: detectDriver(secret), source: secret };
109
+ const { driver } = resolvedSource;
105
110
  if (driver !== "sqlite" &&
106
111
  !secretEnv &&
107
112
  databaseUrlHasSecret(secret)) {
@@ -109,7 +114,9 @@ export class StateQL {
109
114
  suggestedAction: "Set the URL in an environment variable and reconnect with --env NAME.",
110
115
  });
111
116
  }
112
- const adapterSource = driver === "sqlite" ? normalizeSqliteSource(secret) : secret;
117
+ const adapterSource = secretEnv
118
+ ? resolvedSource.source
119
+ : driver === "sqlite" ? normalizeSqliteSource(secret) : secret;
113
120
  const source = driver === "sqlite"
114
121
  ? adapterSource
115
122
  : secretEnv
@@ -1508,7 +1515,7 @@ export class StateQL {
1508
1515
  async resolveConnectionSource(connection, session, operation, access, context) {
1509
1516
  if (!connection.secret_env)
1510
1517
  return connection.source;
1511
- return this.resolveCredential(connection.secret_env, session, operation, access, context, {
1518
+ const value = await this.resolveCredential(connection.secret_env, session, operation, access, context, {
1512
1519
  connection: {
1513
1520
  id: connection.id,
1514
1521
  name: connection.name,
@@ -1517,6 +1524,7 @@ export class StateQL {
1517
1524
  readOnly: Boolean(connection.read_only),
1518
1525
  },
1519
1526
  });
1527
+ return credentialSource(value, connection.driver).source;
1520
1528
  }
1521
1529
  async resolveCredential(reference, session, operation, access, context, details = {}) {
1522
1530
  const resolver = this.credentialResolver;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fadhilp/stateql",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "description": "Stateful, agent-oriented database CLI for safe result reuse",
5
5
  "repository": {
6
6
  "type": "git",