@fadhilp/stateql 0.3.1 → 0.4.1
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 +68 -2
- package/dist/src/adapters.d.ts +3 -1
- package/dist/src/adapters.js +2 -7
- package/dist/src/connection.d.ts +4 -0
- package/dist/src/connection.js +27 -1
- package/dist/src/errors.d.ts +5 -0
- package/dist/src/errors.js +9 -1
- package/dist/src/index.d.ts +3 -2
- package/dist/src/index.js +1 -1
- package/dist/src/stateql.d.ts +4 -0
- package/dist/src/stateql.js +186 -43
- package/dist/src/types.d.ts +26 -0
- package/package.json +2 -2
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,20 @@ 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.
|
|
77
83
|
MySQL uses positional `?` parameters. MariaDB compatibility is not currently
|
|
78
84
|
claimed.
|
|
79
85
|
|
|
80
86
|
## Commands
|
|
81
87
|
|
|
82
88
|
```text
|
|
83
|
-
stql connect <sqlite-path|postgres-url|mysql-url> [--name NAME] [--
|
|
89
|
+
stql connect <sqlite-path|postgres-url|mysql-url> [--name NAME] [--read-write]
|
|
90
|
+
stql connect --env ENV [--name NAME] [--read-write]
|
|
84
91
|
stql connect --profile NAME
|
|
85
92
|
stql status
|
|
86
93
|
stql profile add|list|show|remove
|
|
@@ -257,6 +264,65 @@ if (response.ok) {
|
|
|
257
264
|
}
|
|
258
265
|
```
|
|
259
266
|
|
|
267
|
+
### Harness credential resolution
|
|
268
|
+
|
|
269
|
+
Library integrations can resolve a profile's credential reference through a
|
|
270
|
+
trusted approval or secret-storage layer instead of mutating `process.env`:
|
|
271
|
+
|
|
272
|
+
```ts
|
|
273
|
+
import {
|
|
274
|
+
CredentialResolutionError,
|
|
275
|
+
StateQL,
|
|
276
|
+
type CredentialRequest,
|
|
277
|
+
} from "@fadhilp/stateql";
|
|
278
|
+
|
|
279
|
+
async function resolveCredential(
|
|
280
|
+
request: CredentialRequest,
|
|
281
|
+
): Promise<string | undefined> {
|
|
282
|
+
const approved = await credentialBroker.request({
|
|
283
|
+
reference: request.reference,
|
|
284
|
+
actor: request.actorId,
|
|
285
|
+
session: request.session.id,
|
|
286
|
+
operation: request.operation,
|
|
287
|
+
access: request.access,
|
|
288
|
+
signal: request.signal,
|
|
289
|
+
});
|
|
290
|
+
if (approved.denied) throw new CredentialResolutionError("denied");
|
|
291
|
+
return approved.value;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const stateql = StateQL.forActor({
|
|
295
|
+
actor: "agent-session-id",
|
|
296
|
+
credentialResolver: resolveCredential,
|
|
297
|
+
});
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
When no custom resolver is configured, StateQL continues to read references
|
|
301
|
+
from `process.env`. A configured resolver is authoritative: returning
|
|
302
|
+
`undefined` produces `CREDENTIAL_UNAVAILABLE` and never falls back to the
|
|
303
|
+
process environment. Resolvers may throw `CredentialResolutionError` with
|
|
304
|
+
`denied`, `cancelled`, `timeout`, or `unavailable` to produce controlled,
|
|
305
|
+
secret-free failures. Unknown resolver errors are replaced with a generic
|
|
306
|
+
`CREDENTIAL_RESOLUTION_FAILED` response.
|
|
307
|
+
|
|
308
|
+
StateQL calls the resolver only immediately before database access, after SQL
|
|
309
|
+
safety and duplicate checks. Requests contain actor/session identity, the
|
|
310
|
+
operation's effective read/write access, an abort signal, and sanitized
|
|
311
|
+
connection metadata. Returned values must be complete PostgreSQL/MySQL URLs or
|
|
312
|
+
explicit `sqlite:` sources. StateQL validates the source and its stored driver
|
|
313
|
+
before adapter construction, and normalizes SQLite paths. Credential-bearing
|
|
314
|
+
PostgreSQL and MySQL URLs are redacted before connection
|
|
315
|
+
metadata is persisted and never enter history, snapshots, cache keys, or
|
|
316
|
+
responses. SQLite paths remain persisted connection metadata, as they are for
|
|
317
|
+
direct SQLite connections. Harnesses remain responsible for approval policy,
|
|
318
|
+
binding lifetime, revocation, and keeping values out of their own logs and
|
|
319
|
+
model-visible data.
|
|
320
|
+
|
|
321
|
+
For writes, credential resolution happens after StateQL atomically reserves the
|
|
322
|
+
operation for duplicate protection. A resolution failure keeps a non-executed
|
|
323
|
+
`failed` audit record, does not consume the idempotency key, and permits a safe
|
|
324
|
+
retry.
|
|
325
|
+
|
|
260
326
|
`StateQL.forActor(...)` resolves the actor's attached session directly from
|
|
261
327
|
StateQL storage, avoiding a duplicate actor-to-session mapping in integrations.
|
|
262
328
|
On first use, it creates a legacy-compatible session named after the actor.
|
package/dist/src/adapters.d.ts
CHANGED
|
@@ -34,4 +34,6 @@ export interface Adapter {
|
|
|
34
34
|
close(): Promise<void>;
|
|
35
35
|
}
|
|
36
36
|
export declare function createAdapterContext(timeoutMs: number, signal?: AbortSignal): AdapterContext;
|
|
37
|
-
export declare function createAdapter(connection: ConnectionRecord, context: AdapterContext
|
|
37
|
+
export declare function createAdapter(connection: ConnectionRecord, context: AdapterContext, input: {
|
|
38
|
+
source: string;
|
|
39
|
+
}): Promise<Adapter>;
|
package/dist/src/adapters.js
CHANGED
|
@@ -34,13 +34,8 @@ export function createAdapterContext(timeoutMs, signal) {
|
|
|
34
34
|
...(signal ? { signal } : {}),
|
|
35
35
|
};
|
|
36
36
|
}
|
|
37
|
-
export async function createAdapter(connection, context) {
|
|
38
|
-
const source =
|
|
39
|
-
? process.env[connection.secret_env]
|
|
40
|
-
: connection.source;
|
|
41
|
-
if (!source) {
|
|
42
|
-
throw new Error(`Environment variable ${connection.secret_env ?? "(missing)"} is not set.`);
|
|
43
|
-
}
|
|
37
|
+
export async function createAdapter(connection, context, input) {
|
|
38
|
+
const { source } = input;
|
|
44
39
|
if (connection.driver === "sqlite") {
|
|
45
40
|
return new SQLiteAdapter(source, Boolean(connection.read_only), context);
|
|
46
41
|
}
|
package/dist/src/connection.d.ts
CHANGED
|
@@ -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;
|
package/dist/src/connection.js
CHANGED
|
@@ -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.
|
|
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
|
}
|
package/dist/src/errors.d.ts
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
import type { StateQLErrorShape } from "./types.js";
|
|
2
|
+
export type CredentialResolutionFailure = "unavailable" | "denied" | "cancelled" | "timeout";
|
|
3
|
+
export declare class CredentialResolutionError extends Error {
|
|
4
|
+
readonly reason: CredentialResolutionFailure;
|
|
5
|
+
constructor(reason: CredentialResolutionFailure);
|
|
6
|
+
}
|
|
2
7
|
export declare class StateQLError extends Error {
|
|
3
8
|
readonly details: StateQLErrorShape;
|
|
4
9
|
constructor(code: string, message: string, options?: {
|
package/dist/src/errors.js
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
export class CredentialResolutionError extends Error {
|
|
2
|
+
reason;
|
|
3
|
+
constructor(reason) {
|
|
4
|
+
super(`Credential resolution ${reason}.`);
|
|
5
|
+
this.reason = reason;
|
|
6
|
+
this.name = "CredentialResolutionError";
|
|
7
|
+
}
|
|
8
|
+
}
|
|
1
9
|
export class StateQLError extends Error {
|
|
2
10
|
details;
|
|
3
11
|
constructor(code, message, options = {}) {
|
|
@@ -18,7 +26,7 @@ export class StateQLError extends Error {
|
|
|
18
26
|
export function exitCodeFor(code) {
|
|
19
27
|
if (code === "INVALID_COMMAND" || code === "INVALID_SQL")
|
|
20
28
|
return 2;
|
|
21
|
-
if (code.startsWith("CONNECTION_"))
|
|
29
|
+
if (code.startsWith("CONNECTION_") || code.startsWith("CREDENTIAL_"))
|
|
22
30
|
return 3;
|
|
23
31
|
if (code === "QUERY_FAILED" ||
|
|
24
32
|
code === "DEADLINE_EXCEEDED" ||
|
package/dist/src/index.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
export { StateQL } from "./stateql.js";
|
|
2
|
-
export { StateQLError, exitCodeFor } from "./errors.js";
|
|
3
|
-
export type {
|
|
2
|
+
export { CredentialResolutionError, StateQLError, exitCodeFor, } from "./errors.js";
|
|
3
|
+
export type { CredentialResolutionFailure } from "./errors.js";
|
|
4
|
+
export type { BatchCommand, BatchCommandName, BatchOptions, ConnectOptions, CredentialAccess, CredentialOperation, CredentialRequest, CredentialResolver, ExecOptions, ExecutionOptions, Failure, FilterOptions, HistoryEntry, PlanOptions, ProfileOptions, QueryOptions, Response, RowsOptions, SqlParameters, StateQLActorOptions, StateQLOptions, StateQLSnapshot, Success, } from "./types.js";
|
package/dist/src/index.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
export { StateQL } from "./stateql.js";
|
|
2
|
-
export { StateQLError, exitCodeFor } from "./errors.js";
|
|
2
|
+
export { CredentialResolutionError, StateQLError, exitCodeFor, } from "./errors.js";
|
package/dist/src/stateql.d.ts
CHANGED
|
@@ -12,6 +12,7 @@ export declare class StateQL {
|
|
|
12
12
|
private readonly maxResultBytes;
|
|
13
13
|
private readonly timeoutMs;
|
|
14
14
|
private readonly signal?;
|
|
15
|
+
private readonly credentialResolver?;
|
|
15
16
|
private readonly now;
|
|
16
17
|
constructor(options?: StateQLOptions);
|
|
17
18
|
close(): void;
|
|
@@ -67,6 +68,9 @@ export declare class StateQL {
|
|
|
67
68
|
private requireSelectedSession;
|
|
68
69
|
private validateActorId;
|
|
69
70
|
private throwMembershipDenied;
|
|
71
|
+
private resolveConnectionSource;
|
|
72
|
+
private resolveCredential;
|
|
73
|
+
private openAdapter;
|
|
70
74
|
private executionContext;
|
|
71
75
|
private resultData;
|
|
72
76
|
private cacheValid;
|
package/dist/src/stateql.js
CHANGED
|
@@ -2,8 +2,8 @@ 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";
|
|
6
|
-
import { asStateQLError, StateQLError } from "./errors.js";
|
|
5
|
+
import { confidence, credentialSource, databaseIdentity, databaseUrlHasSecret, detectDriver, isEnvironmentName, normalizeSqliteSource, validateProfileName, version, } from "./connection.js";
|
|
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";
|
|
9
9
|
import { analyzeSql } from "./sql.js";
|
|
@@ -40,6 +40,7 @@ export class StateQL {
|
|
|
40
40
|
maxResultBytes;
|
|
41
41
|
timeoutMs;
|
|
42
42
|
signal;
|
|
43
|
+
credentialResolver;
|
|
43
44
|
now;
|
|
44
45
|
constructor(options = {}) {
|
|
45
46
|
this.now = options.now ?? (() => new Date());
|
|
@@ -56,6 +57,7 @@ export class StateQL {
|
|
|
56
57
|
this.maxResultBytes = positiveInteger(options.maxResultBytes ?? 16 * 1024 * 1024, "maxResultBytes");
|
|
57
58
|
this.timeoutMs = executionTimeout(options.timeoutMs ?? 30_000);
|
|
58
59
|
this.signal = options.signal;
|
|
60
|
+
this.credentialResolver = options.credentialResolver;
|
|
59
61
|
if (this.maxResultRows >= Number.MAX_SAFE_INTEGER) {
|
|
60
62
|
throw new StateQLError("INVALID_COMMAND", "maxResultRows is too large.");
|
|
61
63
|
}
|
|
@@ -70,8 +72,10 @@ export class StateQL {
|
|
|
70
72
|
if (session.active_transaction_id) {
|
|
71
73
|
throw new StateQLError("TRANSACTION_FAILED", "Commit or roll back the active transaction before connecting again.");
|
|
72
74
|
}
|
|
73
|
-
|
|
74
|
-
|
|
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.");
|
|
75
79
|
}
|
|
76
80
|
const implicitProfile = !options.profile && !options.secretEnv && target
|
|
77
81
|
? this.store.getProfile(target)
|
|
@@ -84,13 +88,25 @@ export class StateQL {
|
|
|
84
88
|
}
|
|
85
89
|
const resolvedTarget = profile?.target ?? target;
|
|
86
90
|
const secretEnv = options.secretEnv ?? profile?.secret_env ?? undefined;
|
|
87
|
-
|
|
91
|
+
if (secretEnv && !isEnvironmentName(secretEnv)) {
|
|
92
|
+
throw new StateQLError("INVALID_COMMAND", "Secret environment variable name is invalid.");
|
|
93
|
+
}
|
|
94
|
+
const readOnly = options.readOnly ??
|
|
95
|
+
(profile ? Boolean(profile.read_only) : true);
|
|
96
|
+
const context = this.executionContext(options);
|
|
97
|
+
const secret = secretEnv
|
|
98
|
+
? await this.resolveCredential(secretEnv, session, "connect", readOnly ? "read" : "write", context, {
|
|
99
|
+
...(profile ? { profile: { name: profile.name } } : {}),
|
|
100
|
+
requestedReadOnly: readOnly,
|
|
101
|
+
})
|
|
102
|
+
: resolvedTarget;
|
|
88
103
|
if (!secret) {
|
|
89
|
-
throw new StateQLError("INVALID_COMMAND",
|
|
90
|
-
? `Environment variable ${secretEnv} is not set.`
|
|
91
|
-
: "Connection target is required.");
|
|
104
|
+
throw new StateQLError("INVALID_COMMAND", "Connection target is required.");
|
|
92
105
|
}
|
|
93
|
-
const
|
|
106
|
+
const resolvedSource = secretEnv
|
|
107
|
+
? credentialSource(secret)
|
|
108
|
+
: { driver: detectDriver(secret), source: secret };
|
|
109
|
+
const { driver } = resolvedSource;
|
|
94
110
|
if (driver !== "sqlite" &&
|
|
95
111
|
!secretEnv &&
|
|
96
112
|
databaseUrlHasSecret(secret)) {
|
|
@@ -98,16 +114,17 @@ export class StateQL {
|
|
|
98
114
|
suggestedAction: "Set the URL in an environment variable and reconnect with --env NAME.",
|
|
99
115
|
});
|
|
100
116
|
}
|
|
117
|
+
const adapterSource = secretEnv
|
|
118
|
+
? resolvedSource.source
|
|
119
|
+
: driver === "sqlite" ? normalizeSqliteSource(secret) : secret;
|
|
101
120
|
const source = driver === "sqlite"
|
|
102
|
-
?
|
|
121
|
+
? adapterSource
|
|
103
122
|
: secretEnv
|
|
104
123
|
? redact(secret)
|
|
105
|
-
:
|
|
124
|
+
: adapterSource;
|
|
106
125
|
const databaseName = driver === "sqlite"
|
|
107
|
-
? basename(
|
|
126
|
+
? basename(adapterSource)
|
|
108
127
|
: new URL(secret).pathname.replace(/^\//, "") || driver;
|
|
109
|
-
const readOnly = options.readOnly ??
|
|
110
|
-
(profile ? Boolean(profile.read_only) : true);
|
|
111
128
|
const draft = {
|
|
112
129
|
id: "pending",
|
|
113
130
|
session_id: session.id,
|
|
@@ -120,7 +137,7 @@ export class StateQL {
|
|
|
120
137
|
version: 0,
|
|
121
138
|
created_at: this.now().toISOString(),
|
|
122
139
|
};
|
|
123
|
-
const adapter = await
|
|
140
|
+
const adapter = await this.openAdapter(draft, context, adapterSource);
|
|
124
141
|
try {
|
|
125
142
|
await adapter.read("SELECT 1", []);
|
|
126
143
|
}
|
|
@@ -128,10 +145,10 @@ export class StateQL {
|
|
|
128
145
|
if (error instanceof AdapterExecutionError) {
|
|
129
146
|
throw stoppedStateQLError(error, false);
|
|
130
147
|
}
|
|
131
|
-
throw new StateQLError("CONNECTION_FAILED",
|
|
148
|
+
throw new StateQLError("CONNECTION_FAILED", safeCredentialErrorMessage(error, adapterSource), { retryable: true });
|
|
132
149
|
}
|
|
133
150
|
finally {
|
|
134
|
-
await adapter
|
|
151
|
+
await closeAdapterQuietly(adapter);
|
|
135
152
|
}
|
|
136
153
|
const connection = this.store.addConnection({
|
|
137
154
|
sessionId: session.id,
|
|
@@ -498,7 +515,9 @@ export class StateQL {
|
|
|
498
515
|
throw new StateQLError("INVALID_SQL", "query accepts read statements only; use exec for writes.");
|
|
499
516
|
}
|
|
500
517
|
const parameters = options.params ?? [];
|
|
501
|
-
const
|
|
518
|
+
const context = this.executionContext(options);
|
|
519
|
+
const adapterSource = await this.resolveConnectionSource(connection, session, "query", "read", context);
|
|
520
|
+
const adapter = await this.openAdapter(connection, context, adapterSource);
|
|
502
521
|
try {
|
|
503
522
|
const stateVersion = version(connection);
|
|
504
523
|
const stateSignature = await adapter.signature();
|
|
@@ -571,13 +590,13 @@ export class StateQL {
|
|
|
571
590
|
if (error instanceof AdapterExecutionError) {
|
|
572
591
|
throw stoppedStateQLError(error, true);
|
|
573
592
|
}
|
|
574
|
-
throw new StateQLError("QUERY_FAILED",
|
|
593
|
+
throw new StateQLError("QUERY_FAILED", safeCredentialErrorMessage(error, adapterSource), {
|
|
575
594
|
retryable: true,
|
|
576
595
|
executed: true,
|
|
577
596
|
});
|
|
578
597
|
}
|
|
579
598
|
finally {
|
|
580
|
-
await adapter
|
|
599
|
+
await closeAdapterQuietly(adapter);
|
|
581
600
|
}
|
|
582
601
|
});
|
|
583
602
|
}
|
|
@@ -792,7 +811,9 @@ export class StateQL {
|
|
|
792
811
|
if (version(connection) !== transaction.start_version) {
|
|
793
812
|
throw new StateQLError("TRANSACTION_FAILED", "Connection state changed after the transaction began.", { suggestedAction: "Roll back and begin a new transaction." });
|
|
794
813
|
}
|
|
795
|
-
const
|
|
814
|
+
const context = this.executionContext(options);
|
|
815
|
+
const adapterSource = await this.resolveConnectionSource(connection, session, "transaction.commit", "write", context);
|
|
816
|
+
const adapter = await this.openAdapter(connection, context, adapterSource);
|
|
796
817
|
try {
|
|
797
818
|
if (!this.store.markTransactionCommitting(transaction.id, session.id, this.actorId)) {
|
|
798
819
|
throw new StateQLError("TRANSACTION_FAILED", "Transaction is no longer active.");
|
|
@@ -813,12 +834,10 @@ export class StateQL {
|
|
|
813
834
|
if (error instanceof AdapterExecutionError) {
|
|
814
835
|
throw stoppedStateQLError(error, false);
|
|
815
836
|
}
|
|
816
|
-
throw new StateQLError("TRANSACTION_FAILED", error
|
|
817
|
-
retryable: true,
|
|
818
|
-
});
|
|
837
|
+
throw new StateQLError("TRANSACTION_FAILED", safeCredentialErrorMessage(error, adapterSource), { retryable: true });
|
|
819
838
|
}
|
|
820
839
|
markTransactionOutcomeUnknown(this.store, transaction.id, session.id, this.actorId);
|
|
821
|
-
throw new StateQLError("OUTCOME_UNKNOWN",
|
|
840
|
+
throw new StateQLError("OUTCOME_UNKNOWN", safeCredentialErrorMessage(error, adapterSource), {
|
|
822
841
|
executed: true,
|
|
823
842
|
suggestedAction: "Inspect database state before issuing any replacement write.",
|
|
824
843
|
});
|
|
@@ -845,7 +864,7 @@ export class StateQL {
|
|
|
845
864
|
}
|
|
846
865
|
catch (error) {
|
|
847
866
|
markTransactionOutcomeUnknown(this.store, transaction.id, session.id, this.actorId);
|
|
848
|
-
throw new StateQLError("OUTCOME_UNKNOWN",
|
|
867
|
+
throw new StateQLError("OUTCOME_UNKNOWN", safeCredentialErrorMessage(error, adapterSource), {
|
|
849
868
|
executed: true,
|
|
850
869
|
suggestedAction: "Inspect database state before issuing any replacement write.",
|
|
851
870
|
});
|
|
@@ -897,7 +916,9 @@ export class StateQL {
|
|
|
897
916
|
return this.run(`inspect.${kind}`, async (session) => {
|
|
898
917
|
const connection = this.requireConnection(session);
|
|
899
918
|
this.rejectDuringStagedTransaction(session, "Schema inspection");
|
|
900
|
-
const
|
|
919
|
+
const context = this.executionContext(options);
|
|
920
|
+
const adapterSource = await this.resolveConnectionSource(connection, session, "inspect", "read", context);
|
|
921
|
+
const adapter = await this.openAdapter(connection, context, adapterSource);
|
|
901
922
|
try {
|
|
902
923
|
const data = await adapter.inspect(kind, table);
|
|
903
924
|
return {
|
|
@@ -911,13 +932,13 @@ export class StateQL {
|
|
|
911
932
|
if (error instanceof AdapterExecutionError) {
|
|
912
933
|
throw stoppedStateQLError(error, true);
|
|
913
934
|
}
|
|
914
|
-
throw new StateQLError("QUERY_FAILED",
|
|
935
|
+
throw new StateQLError("QUERY_FAILED", safeCredentialErrorMessage(error, adapterSource), {
|
|
915
936
|
retryable: false,
|
|
916
937
|
executed: true,
|
|
917
938
|
});
|
|
918
939
|
}
|
|
919
940
|
finally {
|
|
920
|
-
await adapter
|
|
941
|
+
await closeAdapterQuietly(adapter);
|
|
921
942
|
}
|
|
922
943
|
});
|
|
923
944
|
}
|
|
@@ -929,7 +950,9 @@ export class StateQL {
|
|
|
929
950
|
if (analysis.read) {
|
|
930
951
|
throw new StateQLError("INVALID_SQL", "plan accepts write statements only.");
|
|
931
952
|
}
|
|
932
|
-
const
|
|
953
|
+
const context = this.executionContext(options);
|
|
954
|
+
const adapterSource = await this.resolveConnectionSource(connection, session, "plan", "read", context);
|
|
955
|
+
const adapter = await this.openAdapter(connection, context, adapterSource);
|
|
933
956
|
try {
|
|
934
957
|
const stateSignature = await adapter.signature();
|
|
935
958
|
const expiresAt = new Date(this.now().getTime() + 10 * 60_000).toISOString();
|
|
@@ -973,13 +996,15 @@ export class StateQL {
|
|
|
973
996
|
};
|
|
974
997
|
}
|
|
975
998
|
catch (error) {
|
|
999
|
+
if (error instanceof StateQLError)
|
|
1000
|
+
throw error;
|
|
976
1001
|
if (error instanceof AdapterExecutionError) {
|
|
977
1002
|
throw stoppedStateQLError(error, true);
|
|
978
1003
|
}
|
|
979
|
-
throw error;
|
|
1004
|
+
throw new StateQLError("QUERY_FAILED", safeCredentialErrorMessage(error, adapterSource), { retryable: true, executed: true });
|
|
980
1005
|
}
|
|
981
1006
|
finally {
|
|
982
|
-
await adapter
|
|
1007
|
+
await closeAdapterQuietly(adapter);
|
|
983
1008
|
}
|
|
984
1009
|
});
|
|
985
1010
|
}
|
|
@@ -1014,26 +1039,29 @@ export class StateQL {
|
|
|
1014
1039
|
throw new StateQLError("STALE_PLAN", "Database state changed after this plan was created.");
|
|
1015
1040
|
}
|
|
1016
1041
|
const context = this.executionContext(options);
|
|
1017
|
-
const
|
|
1042
|
+
const adapterSource = await this.resolveConnectionSource(connection, session, "apply", "write", context);
|
|
1043
|
+
const adapter = await this.openAdapter(connection, context, adapterSource);
|
|
1018
1044
|
try {
|
|
1019
1045
|
if ((await adapter.signature()) !== claimed.state_signature) {
|
|
1020
1046
|
throw new StateQLError("STALE_PLAN", "Database state changed after this plan was created.");
|
|
1021
1047
|
}
|
|
1022
1048
|
}
|
|
1023
1049
|
catch (error) {
|
|
1050
|
+
if (error instanceof StateQLError)
|
|
1051
|
+
throw error;
|
|
1024
1052
|
if (error instanceof AdapterExecutionError) {
|
|
1025
1053
|
throw stoppedStateQLError(error, true);
|
|
1026
1054
|
}
|
|
1027
|
-
throw error;
|
|
1055
|
+
throw new StateQLError("QUERY_FAILED", safeCredentialErrorMessage(error, adapterSource), { retryable: true, executed: true });
|
|
1028
1056
|
}
|
|
1029
1057
|
finally {
|
|
1030
|
-
await adapter
|
|
1058
|
+
await closeAdapterQuietly(adapter);
|
|
1031
1059
|
}
|
|
1032
1060
|
const result = await this.performExec(session, connection, claimed.sql, {
|
|
1033
1061
|
params: parseJson(claimed.parameters, []),
|
|
1034
1062
|
allowUnbounded: Boolean(claimed.allow_unbounded),
|
|
1035
1063
|
allowDestructive: Boolean(claimed.allow_destructive),
|
|
1036
|
-
}, context, { planId: claimed.id, claimToken });
|
|
1064
|
+
}, context, { planId: claimed.id, claimToken }, adapterSource);
|
|
1037
1065
|
return {
|
|
1038
1066
|
...result,
|
|
1039
1067
|
data: { plan_id: claimed.id, ...result.data },
|
|
@@ -1073,6 +1101,7 @@ export class StateQL {
|
|
|
1073
1101
|
persistent_sessions: true,
|
|
1074
1102
|
result_filtering: true,
|
|
1075
1103
|
schema_inspection: true,
|
|
1104
|
+
credential_resolver: true,
|
|
1076
1105
|
deadlines: true,
|
|
1077
1106
|
cancellation: true,
|
|
1078
1107
|
},
|
|
@@ -1228,7 +1257,7 @@ export class StateQL {
|
|
|
1228
1257
|
return;
|
|
1229
1258
|
}
|
|
1230
1259
|
}
|
|
1231
|
-
async performExec(session, connection, sql, options, context, planClaim) {
|
|
1260
|
+
async performExec(session, connection, sql, options, context, planClaim, resolvedSource) {
|
|
1232
1261
|
if (connection.read_only) {
|
|
1233
1262
|
throw new StateQLError("READ_ONLY_CONNECTION", "Connection is read-only.", { suggestedAction: "Reconnect with --read-write." });
|
|
1234
1263
|
}
|
|
@@ -1336,12 +1365,18 @@ export class StateQL {
|
|
|
1336
1365
|
};
|
|
1337
1366
|
}
|
|
1338
1367
|
let adapter;
|
|
1368
|
+
let adapterSource;
|
|
1339
1369
|
try {
|
|
1340
|
-
|
|
1370
|
+
adapterSource =
|
|
1371
|
+
resolvedSource ??
|
|
1372
|
+
(await this.resolveConnectionSource(connection, session, "exec", "write", context));
|
|
1373
|
+
adapter = await this.openAdapter(connection, context, adapterSource);
|
|
1341
1374
|
}
|
|
1342
1375
|
catch (error) {
|
|
1343
1376
|
this.store.failOperation(operation.id);
|
|
1344
|
-
|
|
1377
|
+
if (error instanceof StateQLError)
|
|
1378
|
+
throw error;
|
|
1379
|
+
throw new StateQLError("CONNECTION_FAILED", "Database connection failed.", {
|
|
1345
1380
|
retryable: true,
|
|
1346
1381
|
});
|
|
1347
1382
|
}
|
|
@@ -1394,12 +1429,10 @@ export class StateQL {
|
|
|
1394
1429
|
}
|
|
1395
1430
|
if (error instanceof AdapterWriteError && !error.outcomeUnknown) {
|
|
1396
1431
|
this.store.failOperation(operation.id);
|
|
1397
|
-
throw new StateQLError("QUERY_FAILED", error
|
|
1398
|
-
executed: true,
|
|
1399
|
-
});
|
|
1432
|
+
throw new StateQLError("QUERY_FAILED", safeCredentialErrorMessage(error, adapterSource), { executed: true });
|
|
1400
1433
|
}
|
|
1401
1434
|
this.store.markOperationOutcomeUnknown(operation.id);
|
|
1402
|
-
throw new StateQLError("OUTCOME_UNKNOWN",
|
|
1435
|
+
throw new StateQLError("OUTCOME_UNKNOWN", safeCredentialErrorMessage(error, adapterSource), {
|
|
1403
1436
|
executed: true,
|
|
1404
1437
|
suggestedAction: "Inspect database state, then use --replay only if another execution is safe.",
|
|
1405
1438
|
});
|
|
@@ -1479,6 +1512,63 @@ export class StateQL {
|
|
|
1479
1512
|
throwMembershipDenied(session) {
|
|
1480
1513
|
throw new StateQLError("PERMISSION_DENIED", `Actor "${this.actorId}" is not attached to session "${session.name}".`);
|
|
1481
1514
|
}
|
|
1515
|
+
async resolveConnectionSource(connection, session, operation, access, context) {
|
|
1516
|
+
if (!connection.secret_env)
|
|
1517
|
+
return connection.source;
|
|
1518
|
+
const value = await this.resolveCredential(connection.secret_env, session, operation, access, context, {
|
|
1519
|
+
connection: {
|
|
1520
|
+
id: connection.id,
|
|
1521
|
+
name: connection.name,
|
|
1522
|
+
driver: connection.driver,
|
|
1523
|
+
database: connection.database_name,
|
|
1524
|
+
readOnly: Boolean(connection.read_only),
|
|
1525
|
+
},
|
|
1526
|
+
});
|
|
1527
|
+
return credentialSource(value, connection.driver).source;
|
|
1528
|
+
}
|
|
1529
|
+
async resolveCredential(reference, session, operation, access, context, details = {}) {
|
|
1530
|
+
const resolver = this.credentialResolver;
|
|
1531
|
+
if (!resolver) {
|
|
1532
|
+
if (context.signal?.aborted) {
|
|
1533
|
+
throw credentialStateQLError(reference, new CredentialResolutionError("cancelled"));
|
|
1534
|
+
}
|
|
1535
|
+
if (context.deadline <= Date.now()) {
|
|
1536
|
+
throw credentialStateQLError(reference, new CredentialResolutionError("timeout"));
|
|
1537
|
+
}
|
|
1538
|
+
const value = env[reference];
|
|
1539
|
+
if (value)
|
|
1540
|
+
return value;
|
|
1541
|
+
throw credentialStateQLError(reference, new CredentialResolutionError("unavailable"));
|
|
1542
|
+
}
|
|
1543
|
+
const request = {
|
|
1544
|
+
reference,
|
|
1545
|
+
actorId: this.actorId,
|
|
1546
|
+
session: { id: session.id, name: session.name },
|
|
1547
|
+
operation,
|
|
1548
|
+
access,
|
|
1549
|
+
...(context.signal ? { signal: context.signal } : {}),
|
|
1550
|
+
...details,
|
|
1551
|
+
};
|
|
1552
|
+
try {
|
|
1553
|
+
const value = await resolveCredentialBeforeDeadline(resolver, request, context);
|
|
1554
|
+
if (!value)
|
|
1555
|
+
throw new CredentialResolutionError("unavailable");
|
|
1556
|
+
return value;
|
|
1557
|
+
}
|
|
1558
|
+
catch (error) {
|
|
1559
|
+
throw credentialStateQLError(reference, error);
|
|
1560
|
+
}
|
|
1561
|
+
}
|
|
1562
|
+
async openAdapter(connection, context, source) {
|
|
1563
|
+
try {
|
|
1564
|
+
return await createAdapter(connection, context, { source });
|
|
1565
|
+
}
|
|
1566
|
+
catch (error) {
|
|
1567
|
+
if (error instanceof StateQLError)
|
|
1568
|
+
throw error;
|
|
1569
|
+
throw new StateQLError("CONNECTION_FAILED", safeCredentialErrorMessage(error, source), { retryable: true });
|
|
1570
|
+
}
|
|
1571
|
+
}
|
|
1482
1572
|
executionContext(options) {
|
|
1483
1573
|
return createAdapterContext(executionTimeout(options.timeoutMs ?? this.timeoutMs), options.signal ?? this.signal);
|
|
1484
1574
|
}
|
|
@@ -1639,6 +1729,59 @@ function positiveInteger(value, name) {
|
|
|
1639
1729
|
return value;
|
|
1640
1730
|
throw new StateQLError("INVALID_COMMAND", `${name} must be a positive integer.`);
|
|
1641
1731
|
}
|
|
1732
|
+
async function closeAdapterQuietly(adapter) {
|
|
1733
|
+
try {
|
|
1734
|
+
await adapter.close();
|
|
1735
|
+
}
|
|
1736
|
+
catch {
|
|
1737
|
+
// Preserve the operation result or primary sanitized error.
|
|
1738
|
+
}
|
|
1739
|
+
}
|
|
1740
|
+
async function resolveCredentialBeforeDeadline(resolver, request, context) {
|
|
1741
|
+
if (context.signal?.aborted) {
|
|
1742
|
+
throw new CredentialResolutionError("cancelled");
|
|
1743
|
+
}
|
|
1744
|
+
const remaining = context.deadline - Date.now();
|
|
1745
|
+
if (remaining <= 0)
|
|
1746
|
+
throw new CredentialResolutionError("timeout");
|
|
1747
|
+
return new Promise((resolve, reject) => {
|
|
1748
|
+
let settled = false;
|
|
1749
|
+
const finish = (action, value) => {
|
|
1750
|
+
if (settled)
|
|
1751
|
+
return;
|
|
1752
|
+
settled = true;
|
|
1753
|
+
clearTimeout(timer);
|
|
1754
|
+
context.signal?.removeEventListener("abort", abort);
|
|
1755
|
+
action(value);
|
|
1756
|
+
};
|
|
1757
|
+
const abort = () => finish(() => reject(new CredentialResolutionError("cancelled")));
|
|
1758
|
+
const timer = setTimeout(() => finish(() => reject(new CredentialResolutionError("timeout"))), remaining);
|
|
1759
|
+
timer.unref?.();
|
|
1760
|
+
context.signal?.addEventListener("abort", abort, { once: true });
|
|
1761
|
+
Promise.resolve()
|
|
1762
|
+
.then(() => resolver(request))
|
|
1763
|
+
.then((value) => finish(resolve, value), (error) => finish(() => reject(error)));
|
|
1764
|
+
});
|
|
1765
|
+
}
|
|
1766
|
+
function credentialStateQLError(reference, error) {
|
|
1767
|
+
if (error instanceof CredentialResolutionError) {
|
|
1768
|
+
switch (error.reason) {
|
|
1769
|
+
case "unavailable":
|
|
1770
|
+
return new StateQLError("CREDENTIAL_UNAVAILABLE", `Credential reference "${reference}" is unavailable.`, { retryable: true });
|
|
1771
|
+
case "denied":
|
|
1772
|
+
return new StateQLError("PERMISSION_DENIED", `Credential access for "${reference}" was denied.`);
|
|
1773
|
+
case "cancelled":
|
|
1774
|
+
return new StateQLError("OPERATION_CANCELLED", "Credential resolution was cancelled.", { retryable: true });
|
|
1775
|
+
case "timeout":
|
|
1776
|
+
return new StateQLError("DEADLINE_EXCEEDED", "Credential resolution exceeded the operation deadline.", { retryable: true });
|
|
1777
|
+
}
|
|
1778
|
+
}
|
|
1779
|
+
return new StateQLError("CREDENTIAL_RESOLUTION_FAILED", `Credential reference "${reference}" could not be resolved.`, { retryable: true });
|
|
1780
|
+
}
|
|
1781
|
+
function safeCredentialErrorMessage(error, source) {
|
|
1782
|
+
return redact(errorMessage(error).split(source).join("[credential redacted]"))
|
|
1783
|
+
.replace(/\b([a-z][a-z0-9+.-]*:\/\/)[^\s/@]+(?::[^\s/@]*)?@/giu, "$1***@");
|
|
1784
|
+
}
|
|
1642
1785
|
function executionTimeout(value) {
|
|
1643
1786
|
const timeout = positiveInteger(value, "timeoutMs");
|
|
1644
1787
|
if (timeout > 2_147_483_647) {
|
package/dist/src/types.d.ts
CHANGED
|
@@ -1,4 +1,29 @@
|
|
|
1
1
|
export type Driver = "sqlite" | "postgres" | "mysql";
|
|
2
|
+
export type CredentialAccess = "read" | "write";
|
|
3
|
+
export type CredentialOperation = "connect" | "query" | "inspect" | "plan" | "exec" | "apply" | "transaction.commit";
|
|
4
|
+
export interface CredentialRequest {
|
|
5
|
+
reference: string;
|
|
6
|
+
actorId: string;
|
|
7
|
+
session: {
|
|
8
|
+
id: string;
|
|
9
|
+
name: string;
|
|
10
|
+
};
|
|
11
|
+
operation: CredentialOperation;
|
|
12
|
+
access: CredentialAccess;
|
|
13
|
+
signal?: AbortSignal;
|
|
14
|
+
profile?: {
|
|
15
|
+
name: string;
|
|
16
|
+
};
|
|
17
|
+
requestedReadOnly?: boolean;
|
|
18
|
+
connection?: {
|
|
19
|
+
id: string;
|
|
20
|
+
name: string;
|
|
21
|
+
driver: Driver;
|
|
22
|
+
database: string;
|
|
23
|
+
readOnly: boolean;
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
export type CredentialResolver = (request: CredentialRequest) => string | undefined | Promise<string | undefined>;
|
|
2
27
|
export type StateConfidence = "authoritative" | "transaction_snapshot" | "database_reported" | "local" | "ttl_based" | "unknown";
|
|
3
28
|
export interface Warning {
|
|
4
29
|
code: string;
|
|
@@ -96,6 +121,7 @@ export interface StateQLOptions extends ExecutionOptions {
|
|
|
96
121
|
maxCellCharacters?: number;
|
|
97
122
|
maxResultRows?: number;
|
|
98
123
|
maxResultBytes?: number;
|
|
124
|
+
credentialResolver?: CredentialResolver;
|
|
99
125
|
now?: () => Date;
|
|
100
126
|
}
|
|
101
127
|
export type StateQLActorOptions = Omit<StateQLOptions, "session" | "actor"> & {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fadhilp/stateql",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "Stateful, agent-oriented database CLI for safe result reuse",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
],
|
|
26
26
|
"scripts": {
|
|
27
27
|
"build": "tsc -p tsconfig.json",
|
|
28
|
-
"test": "npm run build && node --test dist/test/cli.test.js dist/test/mysql.test.js dist/test/postgres.test.js dist/test/query.test.js dist/test/sqlite.test.js dist/test/store.test.js dist/test/terminal.test.js dist/test/transaction.test.js dist/test/write.test.js",
|
|
28
|
+
"test": "npm run build && node --test dist/test/cli.test.js dist/test/credential.test.js dist/test/mysql.test.js dist/test/postgres.test.js dist/test/query.test.js dist/test/sqlite.test.js dist/test/store.test.js dist/test/terminal.test.js dist/test/transaction.test.js dist/test/write.test.js",
|
|
29
29
|
"prepack": "npm test"
|
|
30
30
|
},
|
|
31
31
|
"keywords": [
|