@fadhilp/stateql 0.3.1 → 0.4.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 +57 -0
- package/dist/src/adapters.d.ts +3 -1
- package/dist/src/adapters.js +2 -7
- 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 +174 -39
- package/dist/src/types.d.ts +26 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -257,6 +257,63 @@ if (response.ok) {
|
|
|
257
257
|
}
|
|
258
258
|
```
|
|
259
259
|
|
|
260
|
+
### Harness credential resolution
|
|
261
|
+
|
|
262
|
+
Library integrations can resolve a profile's credential reference through a
|
|
263
|
+
trusted approval or secret-storage layer instead of mutating `process.env`:
|
|
264
|
+
|
|
265
|
+
```ts
|
|
266
|
+
import {
|
|
267
|
+
CredentialResolutionError,
|
|
268
|
+
StateQL,
|
|
269
|
+
type CredentialRequest,
|
|
270
|
+
} from "@fadhilp/stateql";
|
|
271
|
+
|
|
272
|
+
async function resolveCredential(
|
|
273
|
+
request: CredentialRequest,
|
|
274
|
+
): Promise<string | undefined> {
|
|
275
|
+
const approved = await credentialBroker.request({
|
|
276
|
+
reference: request.reference,
|
|
277
|
+
actor: request.actorId,
|
|
278
|
+
session: request.session.id,
|
|
279
|
+
operation: request.operation,
|
|
280
|
+
access: request.access,
|
|
281
|
+
signal: request.signal,
|
|
282
|
+
});
|
|
283
|
+
if (approved.denied) throw new CredentialResolutionError("denied");
|
|
284
|
+
return approved.value;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const stateql = StateQL.forActor({
|
|
288
|
+
actor: "agent-session-id",
|
|
289
|
+
credentialResolver: resolveCredential,
|
|
290
|
+
});
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
When no custom resolver is configured, StateQL continues to read references
|
|
294
|
+
from `process.env`. A configured resolver is authoritative: returning
|
|
295
|
+
`undefined` produces `CREDENTIAL_UNAVAILABLE` and never falls back to the
|
|
296
|
+
process environment. Resolvers may throw `CredentialResolutionError` with
|
|
297
|
+
`denied`, `cancelled`, `timeout`, or `unavailable` to produce controlled,
|
|
298
|
+
secret-free failures. Unknown resolver errors are replaced with a generic
|
|
299
|
+
`CREDENTIAL_RESOLUTION_FAILED` response.
|
|
300
|
+
|
|
301
|
+
StateQL calls the resolver only immediately before database access, after SQL
|
|
302
|
+
safety and duplicate checks. Requests contain actor/session identity, the
|
|
303
|
+
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
|
|
306
|
+
metadata is persisted and never enter history, snapshots, cache keys, or
|
|
307
|
+
responses. SQLite paths remain persisted connection metadata, as they are for
|
|
308
|
+
direct SQLite connections. Harnesses remain responsible for approval policy,
|
|
309
|
+
binding lifetime, revocation, and keeping values out of their own logs and
|
|
310
|
+
model-visible data.
|
|
311
|
+
|
|
312
|
+
For writes, credential resolution happens after StateQL atomically reserves the
|
|
313
|
+
operation for duplicate protection. A resolution failure keeps a non-executed
|
|
314
|
+
`failed` audit record, does not consume the idempotency key, and permits a safe
|
|
315
|
+
retry.
|
|
316
|
+
|
|
260
317
|
`StateQL.forActor(...)` resolves the actor's attached session directly from
|
|
261
318
|
StateQL storage, avoiding a duplicate actor-to-session mapping in integrations.
|
|
262
319
|
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/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
|
@@ -3,7 +3,7 @@ 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
5
|
import { confidence, databaseIdentity, databaseUrlHasSecret, detectDriver, isEnvironmentName, normalizeSqliteSource, validateProfileName, version, } from "./connection.js";
|
|
6
|
-
import { asStateQLError, StateQLError } from "./errors.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
|
}
|
|
@@ -84,11 +86,20 @@ export class StateQL {
|
|
|
84
86
|
}
|
|
85
87
|
const resolvedTarget = profile?.target ?? target;
|
|
86
88
|
const secretEnv = options.secretEnv ?? profile?.secret_env ?? undefined;
|
|
87
|
-
|
|
89
|
+
if (secretEnv && !isEnvironmentName(secretEnv)) {
|
|
90
|
+
throw new StateQLError("INVALID_COMMAND", "Secret environment variable name is invalid.");
|
|
91
|
+
}
|
|
92
|
+
const readOnly = options.readOnly ??
|
|
93
|
+
(profile ? Boolean(profile.read_only) : true);
|
|
94
|
+
const context = this.executionContext(options);
|
|
95
|
+
const secret = secretEnv
|
|
96
|
+
? await this.resolveCredential(secretEnv, session, "connect", readOnly ? "read" : "write", context, {
|
|
97
|
+
...(profile ? { profile: { name: profile.name } } : {}),
|
|
98
|
+
requestedReadOnly: readOnly,
|
|
99
|
+
})
|
|
100
|
+
: resolvedTarget;
|
|
88
101
|
if (!secret) {
|
|
89
|
-
throw new StateQLError("INVALID_COMMAND",
|
|
90
|
-
? `Environment variable ${secretEnv} is not set.`
|
|
91
|
-
: "Connection target is required.");
|
|
102
|
+
throw new StateQLError("INVALID_COMMAND", "Connection target is required.");
|
|
92
103
|
}
|
|
93
104
|
const driver = detectDriver(secret);
|
|
94
105
|
if (driver !== "sqlite" &&
|
|
@@ -98,16 +109,15 @@ export class StateQL {
|
|
|
98
109
|
suggestedAction: "Set the URL in an environment variable and reconnect with --env NAME.",
|
|
99
110
|
});
|
|
100
111
|
}
|
|
112
|
+
const adapterSource = driver === "sqlite" ? normalizeSqliteSource(secret) : secret;
|
|
101
113
|
const source = driver === "sqlite"
|
|
102
|
-
?
|
|
114
|
+
? adapterSource
|
|
103
115
|
: secretEnv
|
|
104
116
|
? redact(secret)
|
|
105
|
-
:
|
|
117
|
+
: adapterSource;
|
|
106
118
|
const databaseName = driver === "sqlite"
|
|
107
|
-
? basename(
|
|
119
|
+
? basename(adapterSource)
|
|
108
120
|
: new URL(secret).pathname.replace(/^\//, "") || driver;
|
|
109
|
-
const readOnly = options.readOnly ??
|
|
110
|
-
(profile ? Boolean(profile.read_only) : true);
|
|
111
121
|
const draft = {
|
|
112
122
|
id: "pending",
|
|
113
123
|
session_id: session.id,
|
|
@@ -120,7 +130,7 @@ export class StateQL {
|
|
|
120
130
|
version: 0,
|
|
121
131
|
created_at: this.now().toISOString(),
|
|
122
132
|
};
|
|
123
|
-
const adapter = await
|
|
133
|
+
const adapter = await this.openAdapter(draft, context, adapterSource);
|
|
124
134
|
try {
|
|
125
135
|
await adapter.read("SELECT 1", []);
|
|
126
136
|
}
|
|
@@ -128,10 +138,10 @@ export class StateQL {
|
|
|
128
138
|
if (error instanceof AdapterExecutionError) {
|
|
129
139
|
throw stoppedStateQLError(error, false);
|
|
130
140
|
}
|
|
131
|
-
throw new StateQLError("CONNECTION_FAILED",
|
|
141
|
+
throw new StateQLError("CONNECTION_FAILED", safeCredentialErrorMessage(error, adapterSource), { retryable: true });
|
|
132
142
|
}
|
|
133
143
|
finally {
|
|
134
|
-
await adapter
|
|
144
|
+
await closeAdapterQuietly(adapter);
|
|
135
145
|
}
|
|
136
146
|
const connection = this.store.addConnection({
|
|
137
147
|
sessionId: session.id,
|
|
@@ -498,7 +508,9 @@ export class StateQL {
|
|
|
498
508
|
throw new StateQLError("INVALID_SQL", "query accepts read statements only; use exec for writes.");
|
|
499
509
|
}
|
|
500
510
|
const parameters = options.params ?? [];
|
|
501
|
-
const
|
|
511
|
+
const context = this.executionContext(options);
|
|
512
|
+
const adapterSource = await this.resolveConnectionSource(connection, session, "query", "read", context);
|
|
513
|
+
const adapter = await this.openAdapter(connection, context, adapterSource);
|
|
502
514
|
try {
|
|
503
515
|
const stateVersion = version(connection);
|
|
504
516
|
const stateSignature = await adapter.signature();
|
|
@@ -571,13 +583,13 @@ export class StateQL {
|
|
|
571
583
|
if (error instanceof AdapterExecutionError) {
|
|
572
584
|
throw stoppedStateQLError(error, true);
|
|
573
585
|
}
|
|
574
|
-
throw new StateQLError("QUERY_FAILED",
|
|
586
|
+
throw new StateQLError("QUERY_FAILED", safeCredentialErrorMessage(error, adapterSource), {
|
|
575
587
|
retryable: true,
|
|
576
588
|
executed: true,
|
|
577
589
|
});
|
|
578
590
|
}
|
|
579
591
|
finally {
|
|
580
|
-
await adapter
|
|
592
|
+
await closeAdapterQuietly(adapter);
|
|
581
593
|
}
|
|
582
594
|
});
|
|
583
595
|
}
|
|
@@ -792,7 +804,9 @@ export class StateQL {
|
|
|
792
804
|
if (version(connection) !== transaction.start_version) {
|
|
793
805
|
throw new StateQLError("TRANSACTION_FAILED", "Connection state changed after the transaction began.", { suggestedAction: "Roll back and begin a new transaction." });
|
|
794
806
|
}
|
|
795
|
-
const
|
|
807
|
+
const context = this.executionContext(options);
|
|
808
|
+
const adapterSource = await this.resolveConnectionSource(connection, session, "transaction.commit", "write", context);
|
|
809
|
+
const adapter = await this.openAdapter(connection, context, adapterSource);
|
|
796
810
|
try {
|
|
797
811
|
if (!this.store.markTransactionCommitting(transaction.id, session.id, this.actorId)) {
|
|
798
812
|
throw new StateQLError("TRANSACTION_FAILED", "Transaction is no longer active.");
|
|
@@ -813,12 +827,10 @@ export class StateQL {
|
|
|
813
827
|
if (error instanceof AdapterExecutionError) {
|
|
814
828
|
throw stoppedStateQLError(error, false);
|
|
815
829
|
}
|
|
816
|
-
throw new StateQLError("TRANSACTION_FAILED", error
|
|
817
|
-
retryable: true,
|
|
818
|
-
});
|
|
830
|
+
throw new StateQLError("TRANSACTION_FAILED", safeCredentialErrorMessage(error, adapterSource), { retryable: true });
|
|
819
831
|
}
|
|
820
832
|
markTransactionOutcomeUnknown(this.store, transaction.id, session.id, this.actorId);
|
|
821
|
-
throw new StateQLError("OUTCOME_UNKNOWN",
|
|
833
|
+
throw new StateQLError("OUTCOME_UNKNOWN", safeCredentialErrorMessage(error, adapterSource), {
|
|
822
834
|
executed: true,
|
|
823
835
|
suggestedAction: "Inspect database state before issuing any replacement write.",
|
|
824
836
|
});
|
|
@@ -845,7 +857,7 @@ export class StateQL {
|
|
|
845
857
|
}
|
|
846
858
|
catch (error) {
|
|
847
859
|
markTransactionOutcomeUnknown(this.store, transaction.id, session.id, this.actorId);
|
|
848
|
-
throw new StateQLError("OUTCOME_UNKNOWN",
|
|
860
|
+
throw new StateQLError("OUTCOME_UNKNOWN", safeCredentialErrorMessage(error, adapterSource), {
|
|
849
861
|
executed: true,
|
|
850
862
|
suggestedAction: "Inspect database state before issuing any replacement write.",
|
|
851
863
|
});
|
|
@@ -897,7 +909,9 @@ export class StateQL {
|
|
|
897
909
|
return this.run(`inspect.${kind}`, async (session) => {
|
|
898
910
|
const connection = this.requireConnection(session);
|
|
899
911
|
this.rejectDuringStagedTransaction(session, "Schema inspection");
|
|
900
|
-
const
|
|
912
|
+
const context = this.executionContext(options);
|
|
913
|
+
const adapterSource = await this.resolveConnectionSource(connection, session, "inspect", "read", context);
|
|
914
|
+
const adapter = await this.openAdapter(connection, context, adapterSource);
|
|
901
915
|
try {
|
|
902
916
|
const data = await adapter.inspect(kind, table);
|
|
903
917
|
return {
|
|
@@ -911,13 +925,13 @@ export class StateQL {
|
|
|
911
925
|
if (error instanceof AdapterExecutionError) {
|
|
912
926
|
throw stoppedStateQLError(error, true);
|
|
913
927
|
}
|
|
914
|
-
throw new StateQLError("QUERY_FAILED",
|
|
928
|
+
throw new StateQLError("QUERY_FAILED", safeCredentialErrorMessage(error, adapterSource), {
|
|
915
929
|
retryable: false,
|
|
916
930
|
executed: true,
|
|
917
931
|
});
|
|
918
932
|
}
|
|
919
933
|
finally {
|
|
920
|
-
await adapter
|
|
934
|
+
await closeAdapterQuietly(adapter);
|
|
921
935
|
}
|
|
922
936
|
});
|
|
923
937
|
}
|
|
@@ -929,7 +943,9 @@ export class StateQL {
|
|
|
929
943
|
if (analysis.read) {
|
|
930
944
|
throw new StateQLError("INVALID_SQL", "plan accepts write statements only.");
|
|
931
945
|
}
|
|
932
|
-
const
|
|
946
|
+
const context = this.executionContext(options);
|
|
947
|
+
const adapterSource = await this.resolveConnectionSource(connection, session, "plan", "read", context);
|
|
948
|
+
const adapter = await this.openAdapter(connection, context, adapterSource);
|
|
933
949
|
try {
|
|
934
950
|
const stateSignature = await adapter.signature();
|
|
935
951
|
const expiresAt = new Date(this.now().getTime() + 10 * 60_000).toISOString();
|
|
@@ -973,13 +989,15 @@ export class StateQL {
|
|
|
973
989
|
};
|
|
974
990
|
}
|
|
975
991
|
catch (error) {
|
|
992
|
+
if (error instanceof StateQLError)
|
|
993
|
+
throw error;
|
|
976
994
|
if (error instanceof AdapterExecutionError) {
|
|
977
995
|
throw stoppedStateQLError(error, true);
|
|
978
996
|
}
|
|
979
|
-
throw error;
|
|
997
|
+
throw new StateQLError("QUERY_FAILED", safeCredentialErrorMessage(error, adapterSource), { retryable: true, executed: true });
|
|
980
998
|
}
|
|
981
999
|
finally {
|
|
982
|
-
await adapter
|
|
1000
|
+
await closeAdapterQuietly(adapter);
|
|
983
1001
|
}
|
|
984
1002
|
});
|
|
985
1003
|
}
|
|
@@ -1014,26 +1032,29 @@ export class StateQL {
|
|
|
1014
1032
|
throw new StateQLError("STALE_PLAN", "Database state changed after this plan was created.");
|
|
1015
1033
|
}
|
|
1016
1034
|
const context = this.executionContext(options);
|
|
1017
|
-
const
|
|
1035
|
+
const adapterSource = await this.resolveConnectionSource(connection, session, "apply", "write", context);
|
|
1036
|
+
const adapter = await this.openAdapter(connection, context, adapterSource);
|
|
1018
1037
|
try {
|
|
1019
1038
|
if ((await adapter.signature()) !== claimed.state_signature) {
|
|
1020
1039
|
throw new StateQLError("STALE_PLAN", "Database state changed after this plan was created.");
|
|
1021
1040
|
}
|
|
1022
1041
|
}
|
|
1023
1042
|
catch (error) {
|
|
1043
|
+
if (error instanceof StateQLError)
|
|
1044
|
+
throw error;
|
|
1024
1045
|
if (error instanceof AdapterExecutionError) {
|
|
1025
1046
|
throw stoppedStateQLError(error, true);
|
|
1026
1047
|
}
|
|
1027
|
-
throw error;
|
|
1048
|
+
throw new StateQLError("QUERY_FAILED", safeCredentialErrorMessage(error, adapterSource), { retryable: true, executed: true });
|
|
1028
1049
|
}
|
|
1029
1050
|
finally {
|
|
1030
|
-
await adapter
|
|
1051
|
+
await closeAdapterQuietly(adapter);
|
|
1031
1052
|
}
|
|
1032
1053
|
const result = await this.performExec(session, connection, claimed.sql, {
|
|
1033
1054
|
params: parseJson(claimed.parameters, []),
|
|
1034
1055
|
allowUnbounded: Boolean(claimed.allow_unbounded),
|
|
1035
1056
|
allowDestructive: Boolean(claimed.allow_destructive),
|
|
1036
|
-
}, context, { planId: claimed.id, claimToken });
|
|
1057
|
+
}, context, { planId: claimed.id, claimToken }, adapterSource);
|
|
1037
1058
|
return {
|
|
1038
1059
|
...result,
|
|
1039
1060
|
data: { plan_id: claimed.id, ...result.data },
|
|
@@ -1073,6 +1094,7 @@ export class StateQL {
|
|
|
1073
1094
|
persistent_sessions: true,
|
|
1074
1095
|
result_filtering: true,
|
|
1075
1096
|
schema_inspection: true,
|
|
1097
|
+
credential_resolver: true,
|
|
1076
1098
|
deadlines: true,
|
|
1077
1099
|
cancellation: true,
|
|
1078
1100
|
},
|
|
@@ -1228,7 +1250,7 @@ export class StateQL {
|
|
|
1228
1250
|
return;
|
|
1229
1251
|
}
|
|
1230
1252
|
}
|
|
1231
|
-
async performExec(session, connection, sql, options, context, planClaim) {
|
|
1253
|
+
async performExec(session, connection, sql, options, context, planClaim, resolvedSource) {
|
|
1232
1254
|
if (connection.read_only) {
|
|
1233
1255
|
throw new StateQLError("READ_ONLY_CONNECTION", "Connection is read-only.", { suggestedAction: "Reconnect with --read-write." });
|
|
1234
1256
|
}
|
|
@@ -1336,12 +1358,18 @@ export class StateQL {
|
|
|
1336
1358
|
};
|
|
1337
1359
|
}
|
|
1338
1360
|
let adapter;
|
|
1361
|
+
let adapterSource;
|
|
1339
1362
|
try {
|
|
1340
|
-
|
|
1363
|
+
adapterSource =
|
|
1364
|
+
resolvedSource ??
|
|
1365
|
+
(await this.resolveConnectionSource(connection, session, "exec", "write", context));
|
|
1366
|
+
adapter = await this.openAdapter(connection, context, adapterSource);
|
|
1341
1367
|
}
|
|
1342
1368
|
catch (error) {
|
|
1343
1369
|
this.store.failOperation(operation.id);
|
|
1344
|
-
|
|
1370
|
+
if (error instanceof StateQLError)
|
|
1371
|
+
throw error;
|
|
1372
|
+
throw new StateQLError("CONNECTION_FAILED", "Database connection failed.", {
|
|
1345
1373
|
retryable: true,
|
|
1346
1374
|
});
|
|
1347
1375
|
}
|
|
@@ -1394,12 +1422,10 @@ export class StateQL {
|
|
|
1394
1422
|
}
|
|
1395
1423
|
if (error instanceof AdapterWriteError && !error.outcomeUnknown) {
|
|
1396
1424
|
this.store.failOperation(operation.id);
|
|
1397
|
-
throw new StateQLError("QUERY_FAILED", error
|
|
1398
|
-
executed: true,
|
|
1399
|
-
});
|
|
1425
|
+
throw new StateQLError("QUERY_FAILED", safeCredentialErrorMessage(error, adapterSource), { executed: true });
|
|
1400
1426
|
}
|
|
1401
1427
|
this.store.markOperationOutcomeUnknown(operation.id);
|
|
1402
|
-
throw new StateQLError("OUTCOME_UNKNOWN",
|
|
1428
|
+
throw new StateQLError("OUTCOME_UNKNOWN", safeCredentialErrorMessage(error, adapterSource), {
|
|
1403
1429
|
executed: true,
|
|
1404
1430
|
suggestedAction: "Inspect database state, then use --replay only if another execution is safe.",
|
|
1405
1431
|
});
|
|
@@ -1479,6 +1505,62 @@ export class StateQL {
|
|
|
1479
1505
|
throwMembershipDenied(session) {
|
|
1480
1506
|
throw new StateQLError("PERMISSION_DENIED", `Actor "${this.actorId}" is not attached to session "${session.name}".`);
|
|
1481
1507
|
}
|
|
1508
|
+
async resolveConnectionSource(connection, session, operation, access, context) {
|
|
1509
|
+
if (!connection.secret_env)
|
|
1510
|
+
return connection.source;
|
|
1511
|
+
return this.resolveCredential(connection.secret_env, session, operation, access, context, {
|
|
1512
|
+
connection: {
|
|
1513
|
+
id: connection.id,
|
|
1514
|
+
name: connection.name,
|
|
1515
|
+
driver: connection.driver,
|
|
1516
|
+
database: connection.database_name,
|
|
1517
|
+
readOnly: Boolean(connection.read_only),
|
|
1518
|
+
},
|
|
1519
|
+
});
|
|
1520
|
+
}
|
|
1521
|
+
async resolveCredential(reference, session, operation, access, context, details = {}) {
|
|
1522
|
+
const resolver = this.credentialResolver;
|
|
1523
|
+
if (!resolver) {
|
|
1524
|
+
if (context.signal?.aborted) {
|
|
1525
|
+
throw credentialStateQLError(reference, new CredentialResolutionError("cancelled"));
|
|
1526
|
+
}
|
|
1527
|
+
if (context.deadline <= Date.now()) {
|
|
1528
|
+
throw credentialStateQLError(reference, new CredentialResolutionError("timeout"));
|
|
1529
|
+
}
|
|
1530
|
+
const value = env[reference];
|
|
1531
|
+
if (value)
|
|
1532
|
+
return value;
|
|
1533
|
+
throw credentialStateQLError(reference, new CredentialResolutionError("unavailable"));
|
|
1534
|
+
}
|
|
1535
|
+
const request = {
|
|
1536
|
+
reference,
|
|
1537
|
+
actorId: this.actorId,
|
|
1538
|
+
session: { id: session.id, name: session.name },
|
|
1539
|
+
operation,
|
|
1540
|
+
access,
|
|
1541
|
+
...(context.signal ? { signal: context.signal } : {}),
|
|
1542
|
+
...details,
|
|
1543
|
+
};
|
|
1544
|
+
try {
|
|
1545
|
+
const value = await resolveCredentialBeforeDeadline(resolver, request, context);
|
|
1546
|
+
if (!value)
|
|
1547
|
+
throw new CredentialResolutionError("unavailable");
|
|
1548
|
+
return value;
|
|
1549
|
+
}
|
|
1550
|
+
catch (error) {
|
|
1551
|
+
throw credentialStateQLError(reference, error);
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1554
|
+
async openAdapter(connection, context, source) {
|
|
1555
|
+
try {
|
|
1556
|
+
return await createAdapter(connection, context, { source });
|
|
1557
|
+
}
|
|
1558
|
+
catch (error) {
|
|
1559
|
+
if (error instanceof StateQLError)
|
|
1560
|
+
throw error;
|
|
1561
|
+
throw new StateQLError("CONNECTION_FAILED", safeCredentialErrorMessage(error, source), { retryable: true });
|
|
1562
|
+
}
|
|
1563
|
+
}
|
|
1482
1564
|
executionContext(options) {
|
|
1483
1565
|
return createAdapterContext(executionTimeout(options.timeoutMs ?? this.timeoutMs), options.signal ?? this.signal);
|
|
1484
1566
|
}
|
|
@@ -1639,6 +1721,59 @@ function positiveInteger(value, name) {
|
|
|
1639
1721
|
return value;
|
|
1640
1722
|
throw new StateQLError("INVALID_COMMAND", `${name} must be a positive integer.`);
|
|
1641
1723
|
}
|
|
1724
|
+
async function closeAdapterQuietly(adapter) {
|
|
1725
|
+
try {
|
|
1726
|
+
await adapter.close();
|
|
1727
|
+
}
|
|
1728
|
+
catch {
|
|
1729
|
+
// Preserve the operation result or primary sanitized error.
|
|
1730
|
+
}
|
|
1731
|
+
}
|
|
1732
|
+
async function resolveCredentialBeforeDeadline(resolver, request, context) {
|
|
1733
|
+
if (context.signal?.aborted) {
|
|
1734
|
+
throw new CredentialResolutionError("cancelled");
|
|
1735
|
+
}
|
|
1736
|
+
const remaining = context.deadline - Date.now();
|
|
1737
|
+
if (remaining <= 0)
|
|
1738
|
+
throw new CredentialResolutionError("timeout");
|
|
1739
|
+
return new Promise((resolve, reject) => {
|
|
1740
|
+
let settled = false;
|
|
1741
|
+
const finish = (action, value) => {
|
|
1742
|
+
if (settled)
|
|
1743
|
+
return;
|
|
1744
|
+
settled = true;
|
|
1745
|
+
clearTimeout(timer);
|
|
1746
|
+
context.signal?.removeEventListener("abort", abort);
|
|
1747
|
+
action(value);
|
|
1748
|
+
};
|
|
1749
|
+
const abort = () => finish(() => reject(new CredentialResolutionError("cancelled")));
|
|
1750
|
+
const timer = setTimeout(() => finish(() => reject(new CredentialResolutionError("timeout"))), remaining);
|
|
1751
|
+
timer.unref?.();
|
|
1752
|
+
context.signal?.addEventListener("abort", abort, { once: true });
|
|
1753
|
+
Promise.resolve()
|
|
1754
|
+
.then(() => resolver(request))
|
|
1755
|
+
.then((value) => finish(resolve, value), (error) => finish(() => reject(error)));
|
|
1756
|
+
});
|
|
1757
|
+
}
|
|
1758
|
+
function credentialStateQLError(reference, error) {
|
|
1759
|
+
if (error instanceof CredentialResolutionError) {
|
|
1760
|
+
switch (error.reason) {
|
|
1761
|
+
case "unavailable":
|
|
1762
|
+
return new StateQLError("CREDENTIAL_UNAVAILABLE", `Credential reference "${reference}" is unavailable.`, { retryable: true });
|
|
1763
|
+
case "denied":
|
|
1764
|
+
return new StateQLError("PERMISSION_DENIED", `Credential access for "${reference}" was denied.`);
|
|
1765
|
+
case "cancelled":
|
|
1766
|
+
return new StateQLError("OPERATION_CANCELLED", "Credential resolution was cancelled.", { retryable: true });
|
|
1767
|
+
case "timeout":
|
|
1768
|
+
return new StateQLError("DEADLINE_EXCEEDED", "Credential resolution exceeded the operation deadline.", { retryable: true });
|
|
1769
|
+
}
|
|
1770
|
+
}
|
|
1771
|
+
return new StateQLError("CREDENTIAL_RESOLUTION_FAILED", `Credential reference "${reference}" could not be resolved.`, { retryable: true });
|
|
1772
|
+
}
|
|
1773
|
+
function safeCredentialErrorMessage(error, source) {
|
|
1774
|
+
return redact(errorMessage(error).split(source).join("[credential redacted]"))
|
|
1775
|
+
.replace(/\b([a-z][a-z0-9+.-]*:\/\/)[^\s/@]+(?::[^\s/@]*)?@/giu, "$1***@");
|
|
1776
|
+
}
|
|
1642
1777
|
function executionTimeout(value) {
|
|
1643
1778
|
const timeout = positiveInteger(value, "timeoutMs");
|
|
1644
1779
|
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.0",
|
|
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": [
|