@fadhilp/stateql 0.3.0 → 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 +63 -2
- 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 +6 -1
- package/dist/src/stateql.js +191 -39
- package/dist/src/types.d.ts +29 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -238,9 +238,8 @@ Interrupted commits remain fail-closed; stale `committing` records become
|
|
|
238
238
|
```ts
|
|
239
239
|
import { StateQL } from "@fadhilp/stateql";
|
|
240
240
|
|
|
241
|
-
const stateql =
|
|
241
|
+
const stateql = StateQL.forActor({
|
|
242
242
|
home: "./.stql",
|
|
243
|
-
session: "shared-workspace",
|
|
244
243
|
actor: "pi-session-id",
|
|
245
244
|
timeoutMs: 30_000,
|
|
246
245
|
maxResultBytes: 16 * 1024 * 1024,
|
|
@@ -258,6 +257,68 @@ if (response.ok) {
|
|
|
258
257
|
}
|
|
259
258
|
```
|
|
260
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
|
+
|
|
317
|
+
`StateQL.forActor(...)` resolves the actor's attached session directly from
|
|
318
|
+
StateQL storage, avoiding a duplicate actor-to-session mapping in integrations.
|
|
319
|
+
On first use, it creates a legacy-compatible session named after the actor.
|
|
320
|
+
Use `new StateQL({ session, actor })` when the session is already known.
|
|
321
|
+
|
|
261
322
|
Membership is managed only through the library API, not batch commands:
|
|
262
323
|
`linkActor(session, actorId)`, `unlinkActor(session, actorId)`,
|
|
263
324
|
`listActors(session)`, and `resolveActor(actorId)`. An existing member must link
|
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
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import type { BatchCommand, BatchOptions, ConnectOptions, ExecOptions, ExecutionOptions, FilterOptions, HistoryEntry, PlanOptions, ProfileOptions, QueryOptions, Response, RowsOptions, StateQLOptions, StateQLSnapshot } from "./types.js";
|
|
1
|
+
import type { BatchCommand, BatchOptions, ConnectOptions, ExecOptions, ExecutionOptions, FilterOptions, HistoryEntry, PlanOptions, ProfileOptions, QueryOptions, Response, RowsOptions, StateQLActorOptions, StateQLOptions, StateQLSnapshot } from "./types.js";
|
|
2
2
|
export declare class StateQL {
|
|
3
|
+
static forActor(options: StateQLActorOptions): StateQL;
|
|
3
4
|
private readonly store;
|
|
4
5
|
private readonly sessionName;
|
|
5
6
|
private readonly actorId;
|
|
@@ -11,6 +12,7 @@ export declare class StateQL {
|
|
|
11
12
|
private readonly maxResultBytes;
|
|
12
13
|
private readonly timeoutMs;
|
|
13
14
|
private readonly signal?;
|
|
15
|
+
private readonly credentialResolver?;
|
|
14
16
|
private readonly now;
|
|
15
17
|
constructor(options?: StateQLOptions);
|
|
16
18
|
close(): void;
|
|
@@ -66,6 +68,9 @@ export declare class StateQL {
|
|
|
66
68
|
private requireSelectedSession;
|
|
67
69
|
private validateActorId;
|
|
68
70
|
private throwMembershipDenied;
|
|
71
|
+
private resolveConnectionSource;
|
|
72
|
+
private resolveCredential;
|
|
73
|
+
private openAdapter;
|
|
69
74
|
private executionContext;
|
|
70
75
|
private resultData;
|
|
71
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";
|
|
@@ -12,6 +12,23 @@ import { compactRows, defaultHome, hash, parseJson, redact, } from "./util.js";
|
|
|
12
12
|
const DEFAULT_SNAPSHOT_HISTORY_LIMIT = 50;
|
|
13
13
|
const MAX_SNAPSHOT_HISTORY_LIMIT = 100;
|
|
14
14
|
export class StateQL {
|
|
15
|
+
static forActor(options) {
|
|
16
|
+
if (!options.actor.trim()) {
|
|
17
|
+
throw new StateQLError("INVALID_COMMAND", "Actor ID is required.");
|
|
18
|
+
}
|
|
19
|
+
const now = options.now ?? (() => new Date());
|
|
20
|
+
const store = new StateStore(options.home ?? defaultHome(), now);
|
|
21
|
+
try {
|
|
22
|
+
const session = store.resolveActor(options.actor);
|
|
23
|
+
if (session)
|
|
24
|
+
return new StateQL({ ...options, session: session.name });
|
|
25
|
+
const { actor, ...legacyOptions } = options;
|
|
26
|
+
return new StateQL({ ...legacyOptions, session: actor });
|
|
27
|
+
}
|
|
28
|
+
finally {
|
|
29
|
+
store.close();
|
|
30
|
+
}
|
|
31
|
+
}
|
|
15
32
|
store;
|
|
16
33
|
sessionName;
|
|
17
34
|
actorId;
|
|
@@ -23,6 +40,7 @@ export class StateQL {
|
|
|
23
40
|
maxResultBytes;
|
|
24
41
|
timeoutMs;
|
|
25
42
|
signal;
|
|
43
|
+
credentialResolver;
|
|
26
44
|
now;
|
|
27
45
|
constructor(options = {}) {
|
|
28
46
|
this.now = options.now ?? (() => new Date());
|
|
@@ -39,6 +57,7 @@ export class StateQL {
|
|
|
39
57
|
this.maxResultBytes = positiveInteger(options.maxResultBytes ?? 16 * 1024 * 1024, "maxResultBytes");
|
|
40
58
|
this.timeoutMs = executionTimeout(options.timeoutMs ?? 30_000);
|
|
41
59
|
this.signal = options.signal;
|
|
60
|
+
this.credentialResolver = options.credentialResolver;
|
|
42
61
|
if (this.maxResultRows >= Number.MAX_SAFE_INTEGER) {
|
|
43
62
|
throw new StateQLError("INVALID_COMMAND", "maxResultRows is too large.");
|
|
44
63
|
}
|
|
@@ -67,11 +86,20 @@ export class StateQL {
|
|
|
67
86
|
}
|
|
68
87
|
const resolvedTarget = profile?.target ?? target;
|
|
69
88
|
const secretEnv = options.secretEnv ?? profile?.secret_env ?? undefined;
|
|
70
|
-
|
|
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;
|
|
71
101
|
if (!secret) {
|
|
72
|
-
throw new StateQLError("INVALID_COMMAND",
|
|
73
|
-
? `Environment variable ${secretEnv} is not set.`
|
|
74
|
-
: "Connection target is required.");
|
|
102
|
+
throw new StateQLError("INVALID_COMMAND", "Connection target is required.");
|
|
75
103
|
}
|
|
76
104
|
const driver = detectDriver(secret);
|
|
77
105
|
if (driver !== "sqlite" &&
|
|
@@ -81,16 +109,15 @@ export class StateQL {
|
|
|
81
109
|
suggestedAction: "Set the URL in an environment variable and reconnect with --env NAME.",
|
|
82
110
|
});
|
|
83
111
|
}
|
|
112
|
+
const adapterSource = driver === "sqlite" ? normalizeSqliteSource(secret) : secret;
|
|
84
113
|
const source = driver === "sqlite"
|
|
85
|
-
?
|
|
114
|
+
? adapterSource
|
|
86
115
|
: secretEnv
|
|
87
116
|
? redact(secret)
|
|
88
|
-
:
|
|
117
|
+
: adapterSource;
|
|
89
118
|
const databaseName = driver === "sqlite"
|
|
90
|
-
? basename(
|
|
119
|
+
? basename(adapterSource)
|
|
91
120
|
: new URL(secret).pathname.replace(/^\//, "") || driver;
|
|
92
|
-
const readOnly = options.readOnly ??
|
|
93
|
-
(profile ? Boolean(profile.read_only) : true);
|
|
94
121
|
const draft = {
|
|
95
122
|
id: "pending",
|
|
96
123
|
session_id: session.id,
|
|
@@ -103,7 +130,7 @@ export class StateQL {
|
|
|
103
130
|
version: 0,
|
|
104
131
|
created_at: this.now().toISOString(),
|
|
105
132
|
};
|
|
106
|
-
const adapter = await
|
|
133
|
+
const adapter = await this.openAdapter(draft, context, adapterSource);
|
|
107
134
|
try {
|
|
108
135
|
await adapter.read("SELECT 1", []);
|
|
109
136
|
}
|
|
@@ -111,10 +138,10 @@ export class StateQL {
|
|
|
111
138
|
if (error instanceof AdapterExecutionError) {
|
|
112
139
|
throw stoppedStateQLError(error, false);
|
|
113
140
|
}
|
|
114
|
-
throw new StateQLError("CONNECTION_FAILED",
|
|
141
|
+
throw new StateQLError("CONNECTION_FAILED", safeCredentialErrorMessage(error, adapterSource), { retryable: true });
|
|
115
142
|
}
|
|
116
143
|
finally {
|
|
117
|
-
await adapter
|
|
144
|
+
await closeAdapterQuietly(adapter);
|
|
118
145
|
}
|
|
119
146
|
const connection = this.store.addConnection({
|
|
120
147
|
sessionId: session.id,
|
|
@@ -481,7 +508,9 @@ export class StateQL {
|
|
|
481
508
|
throw new StateQLError("INVALID_SQL", "query accepts read statements only; use exec for writes.");
|
|
482
509
|
}
|
|
483
510
|
const parameters = options.params ?? [];
|
|
484
|
-
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);
|
|
485
514
|
try {
|
|
486
515
|
const stateVersion = version(connection);
|
|
487
516
|
const stateSignature = await adapter.signature();
|
|
@@ -554,13 +583,13 @@ export class StateQL {
|
|
|
554
583
|
if (error instanceof AdapterExecutionError) {
|
|
555
584
|
throw stoppedStateQLError(error, true);
|
|
556
585
|
}
|
|
557
|
-
throw new StateQLError("QUERY_FAILED",
|
|
586
|
+
throw new StateQLError("QUERY_FAILED", safeCredentialErrorMessage(error, adapterSource), {
|
|
558
587
|
retryable: true,
|
|
559
588
|
executed: true,
|
|
560
589
|
});
|
|
561
590
|
}
|
|
562
591
|
finally {
|
|
563
|
-
await adapter
|
|
592
|
+
await closeAdapterQuietly(adapter);
|
|
564
593
|
}
|
|
565
594
|
});
|
|
566
595
|
}
|
|
@@ -775,7 +804,9 @@ export class StateQL {
|
|
|
775
804
|
if (version(connection) !== transaction.start_version) {
|
|
776
805
|
throw new StateQLError("TRANSACTION_FAILED", "Connection state changed after the transaction began.", { suggestedAction: "Roll back and begin a new transaction." });
|
|
777
806
|
}
|
|
778
|
-
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);
|
|
779
810
|
try {
|
|
780
811
|
if (!this.store.markTransactionCommitting(transaction.id, session.id, this.actorId)) {
|
|
781
812
|
throw new StateQLError("TRANSACTION_FAILED", "Transaction is no longer active.");
|
|
@@ -796,12 +827,10 @@ export class StateQL {
|
|
|
796
827
|
if (error instanceof AdapterExecutionError) {
|
|
797
828
|
throw stoppedStateQLError(error, false);
|
|
798
829
|
}
|
|
799
|
-
throw new StateQLError("TRANSACTION_FAILED", error
|
|
800
|
-
retryable: true,
|
|
801
|
-
});
|
|
830
|
+
throw new StateQLError("TRANSACTION_FAILED", safeCredentialErrorMessage(error, adapterSource), { retryable: true });
|
|
802
831
|
}
|
|
803
832
|
markTransactionOutcomeUnknown(this.store, transaction.id, session.id, this.actorId);
|
|
804
|
-
throw new StateQLError("OUTCOME_UNKNOWN",
|
|
833
|
+
throw new StateQLError("OUTCOME_UNKNOWN", safeCredentialErrorMessage(error, adapterSource), {
|
|
805
834
|
executed: true,
|
|
806
835
|
suggestedAction: "Inspect database state before issuing any replacement write.",
|
|
807
836
|
});
|
|
@@ -828,7 +857,7 @@ export class StateQL {
|
|
|
828
857
|
}
|
|
829
858
|
catch (error) {
|
|
830
859
|
markTransactionOutcomeUnknown(this.store, transaction.id, session.id, this.actorId);
|
|
831
|
-
throw new StateQLError("OUTCOME_UNKNOWN",
|
|
860
|
+
throw new StateQLError("OUTCOME_UNKNOWN", safeCredentialErrorMessage(error, adapterSource), {
|
|
832
861
|
executed: true,
|
|
833
862
|
suggestedAction: "Inspect database state before issuing any replacement write.",
|
|
834
863
|
});
|
|
@@ -880,7 +909,9 @@ export class StateQL {
|
|
|
880
909
|
return this.run(`inspect.${kind}`, async (session) => {
|
|
881
910
|
const connection = this.requireConnection(session);
|
|
882
911
|
this.rejectDuringStagedTransaction(session, "Schema inspection");
|
|
883
|
-
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);
|
|
884
915
|
try {
|
|
885
916
|
const data = await adapter.inspect(kind, table);
|
|
886
917
|
return {
|
|
@@ -894,13 +925,13 @@ export class StateQL {
|
|
|
894
925
|
if (error instanceof AdapterExecutionError) {
|
|
895
926
|
throw stoppedStateQLError(error, true);
|
|
896
927
|
}
|
|
897
|
-
throw new StateQLError("QUERY_FAILED",
|
|
928
|
+
throw new StateQLError("QUERY_FAILED", safeCredentialErrorMessage(error, adapterSource), {
|
|
898
929
|
retryable: false,
|
|
899
930
|
executed: true,
|
|
900
931
|
});
|
|
901
932
|
}
|
|
902
933
|
finally {
|
|
903
|
-
await adapter
|
|
934
|
+
await closeAdapterQuietly(adapter);
|
|
904
935
|
}
|
|
905
936
|
});
|
|
906
937
|
}
|
|
@@ -912,7 +943,9 @@ export class StateQL {
|
|
|
912
943
|
if (analysis.read) {
|
|
913
944
|
throw new StateQLError("INVALID_SQL", "plan accepts write statements only.");
|
|
914
945
|
}
|
|
915
|
-
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);
|
|
916
949
|
try {
|
|
917
950
|
const stateSignature = await adapter.signature();
|
|
918
951
|
const expiresAt = new Date(this.now().getTime() + 10 * 60_000).toISOString();
|
|
@@ -956,13 +989,15 @@ export class StateQL {
|
|
|
956
989
|
};
|
|
957
990
|
}
|
|
958
991
|
catch (error) {
|
|
992
|
+
if (error instanceof StateQLError)
|
|
993
|
+
throw error;
|
|
959
994
|
if (error instanceof AdapterExecutionError) {
|
|
960
995
|
throw stoppedStateQLError(error, true);
|
|
961
996
|
}
|
|
962
|
-
throw error;
|
|
997
|
+
throw new StateQLError("QUERY_FAILED", safeCredentialErrorMessage(error, adapterSource), { retryable: true, executed: true });
|
|
963
998
|
}
|
|
964
999
|
finally {
|
|
965
|
-
await adapter
|
|
1000
|
+
await closeAdapterQuietly(adapter);
|
|
966
1001
|
}
|
|
967
1002
|
});
|
|
968
1003
|
}
|
|
@@ -997,26 +1032,29 @@ export class StateQL {
|
|
|
997
1032
|
throw new StateQLError("STALE_PLAN", "Database state changed after this plan was created.");
|
|
998
1033
|
}
|
|
999
1034
|
const context = this.executionContext(options);
|
|
1000
|
-
const
|
|
1035
|
+
const adapterSource = await this.resolveConnectionSource(connection, session, "apply", "write", context);
|
|
1036
|
+
const adapter = await this.openAdapter(connection, context, adapterSource);
|
|
1001
1037
|
try {
|
|
1002
1038
|
if ((await adapter.signature()) !== claimed.state_signature) {
|
|
1003
1039
|
throw new StateQLError("STALE_PLAN", "Database state changed after this plan was created.");
|
|
1004
1040
|
}
|
|
1005
1041
|
}
|
|
1006
1042
|
catch (error) {
|
|
1043
|
+
if (error instanceof StateQLError)
|
|
1044
|
+
throw error;
|
|
1007
1045
|
if (error instanceof AdapterExecutionError) {
|
|
1008
1046
|
throw stoppedStateQLError(error, true);
|
|
1009
1047
|
}
|
|
1010
|
-
throw error;
|
|
1048
|
+
throw new StateQLError("QUERY_FAILED", safeCredentialErrorMessage(error, adapterSource), { retryable: true, executed: true });
|
|
1011
1049
|
}
|
|
1012
1050
|
finally {
|
|
1013
|
-
await adapter
|
|
1051
|
+
await closeAdapterQuietly(adapter);
|
|
1014
1052
|
}
|
|
1015
1053
|
const result = await this.performExec(session, connection, claimed.sql, {
|
|
1016
1054
|
params: parseJson(claimed.parameters, []),
|
|
1017
1055
|
allowUnbounded: Boolean(claimed.allow_unbounded),
|
|
1018
1056
|
allowDestructive: Boolean(claimed.allow_destructive),
|
|
1019
|
-
}, context, { planId: claimed.id, claimToken });
|
|
1057
|
+
}, context, { planId: claimed.id, claimToken }, adapterSource);
|
|
1020
1058
|
return {
|
|
1021
1059
|
...result,
|
|
1022
1060
|
data: { plan_id: claimed.id, ...result.data },
|
|
@@ -1056,6 +1094,7 @@ export class StateQL {
|
|
|
1056
1094
|
persistent_sessions: true,
|
|
1057
1095
|
result_filtering: true,
|
|
1058
1096
|
schema_inspection: true,
|
|
1097
|
+
credential_resolver: true,
|
|
1059
1098
|
deadlines: true,
|
|
1060
1099
|
cancellation: true,
|
|
1061
1100
|
},
|
|
@@ -1211,7 +1250,7 @@ export class StateQL {
|
|
|
1211
1250
|
return;
|
|
1212
1251
|
}
|
|
1213
1252
|
}
|
|
1214
|
-
async performExec(session, connection, sql, options, context, planClaim) {
|
|
1253
|
+
async performExec(session, connection, sql, options, context, planClaim, resolvedSource) {
|
|
1215
1254
|
if (connection.read_only) {
|
|
1216
1255
|
throw new StateQLError("READ_ONLY_CONNECTION", "Connection is read-only.", { suggestedAction: "Reconnect with --read-write." });
|
|
1217
1256
|
}
|
|
@@ -1319,12 +1358,18 @@ export class StateQL {
|
|
|
1319
1358
|
};
|
|
1320
1359
|
}
|
|
1321
1360
|
let adapter;
|
|
1361
|
+
let adapterSource;
|
|
1322
1362
|
try {
|
|
1323
|
-
|
|
1363
|
+
adapterSource =
|
|
1364
|
+
resolvedSource ??
|
|
1365
|
+
(await this.resolveConnectionSource(connection, session, "exec", "write", context));
|
|
1366
|
+
adapter = await this.openAdapter(connection, context, adapterSource);
|
|
1324
1367
|
}
|
|
1325
1368
|
catch (error) {
|
|
1326
1369
|
this.store.failOperation(operation.id);
|
|
1327
|
-
|
|
1370
|
+
if (error instanceof StateQLError)
|
|
1371
|
+
throw error;
|
|
1372
|
+
throw new StateQLError("CONNECTION_FAILED", "Database connection failed.", {
|
|
1328
1373
|
retryable: true,
|
|
1329
1374
|
});
|
|
1330
1375
|
}
|
|
@@ -1377,12 +1422,10 @@ export class StateQL {
|
|
|
1377
1422
|
}
|
|
1378
1423
|
if (error instanceof AdapterWriteError && !error.outcomeUnknown) {
|
|
1379
1424
|
this.store.failOperation(operation.id);
|
|
1380
|
-
throw new StateQLError("QUERY_FAILED", error
|
|
1381
|
-
executed: true,
|
|
1382
|
-
});
|
|
1425
|
+
throw new StateQLError("QUERY_FAILED", safeCredentialErrorMessage(error, adapterSource), { executed: true });
|
|
1383
1426
|
}
|
|
1384
1427
|
this.store.markOperationOutcomeUnknown(operation.id);
|
|
1385
|
-
throw new StateQLError("OUTCOME_UNKNOWN",
|
|
1428
|
+
throw new StateQLError("OUTCOME_UNKNOWN", safeCredentialErrorMessage(error, adapterSource), {
|
|
1386
1429
|
executed: true,
|
|
1387
1430
|
suggestedAction: "Inspect database state, then use --replay only if another execution is safe.",
|
|
1388
1431
|
});
|
|
@@ -1462,6 +1505,62 @@ export class StateQL {
|
|
|
1462
1505
|
throwMembershipDenied(session) {
|
|
1463
1506
|
throw new StateQLError("PERMISSION_DENIED", `Actor "${this.actorId}" is not attached to session "${session.name}".`);
|
|
1464
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
|
+
}
|
|
1465
1564
|
executionContext(options) {
|
|
1466
1565
|
return createAdapterContext(executionTimeout(options.timeoutMs ?? this.timeoutMs), options.signal ?? this.signal);
|
|
1467
1566
|
}
|
|
@@ -1622,6 +1721,59 @@ function positiveInteger(value, name) {
|
|
|
1622
1721
|
return value;
|
|
1623
1722
|
throw new StateQLError("INVALID_COMMAND", `${name} must be a positive integer.`);
|
|
1624
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
|
+
}
|
|
1625
1777
|
function executionTimeout(value) {
|
|
1626
1778
|
const timeout = positiveInteger(value, "timeoutMs");
|
|
1627
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,8 +121,12 @@ 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
|
}
|
|
127
|
+
export type StateQLActorOptions = Omit<StateQLOptions, "session" | "actor"> & {
|
|
128
|
+
actor: string;
|
|
129
|
+
};
|
|
101
130
|
export interface QueryOptions extends ExecutionOptions {
|
|
102
131
|
params?: SqlParameters;
|
|
103
132
|
cache?: "auto" | "bypass" | "require";
|
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": [
|