@fadhilp/stateql 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -76,12 +76,13 @@ its cache entry is valid. Use `--cache bypass` when a fresh read is required.
76
76
 
77
77
  ## Connections and profiles
78
78
 
79
- A connection accepts exactly one source: a direct target, `--env`, or
80
- `--profile`.
79
+ A connection accepts exactly one source: a direct target, `--env`,
80
+ `--credential-ref`, or `--profile`.
81
81
 
82
82
  ```bash
83
83
  stql connect <sqlite-path|postgres-url|mysql-url|mongodb-url> [--name NAME] [--read-write]
84
84
  stql connect --env ENV [--name NAME] [--read-write]
85
+ stql connect --credential-ref REF [--name NAME] [--read-write]
85
86
  stql connect --profile NAME
86
87
  stql disconnect
87
88
  stql status
@@ -113,19 +114,26 @@ connection metadata.
113
114
 
114
115
  ### Local profiles
115
116
 
116
- Profiles store connection targets, read-only policy, and environment-variable
117
- names. Credential values are never stored. Profiles persist under `STQL_HOME`
118
- with other StateQL metadata.
117
+ Profiles store exactly one connection target, environment-variable name, or
118
+ opaque credential reference together with read-only policy. Credential values
119
+ are never stored. Profiles persist under `STQL_HOME` with other StateQL
120
+ metadata, and list/show responses include `credential_ref` when configured.
119
121
 
120
122
  ```bash
121
123
  stql profile add local ./app.sqlite --read-write
122
124
  stql profile add production --env PROD_DATABASE_URL --read-only
125
+ stql profile add hosted --credential-ref 'vault://team/app' --read-only
123
126
  stql profile list
124
127
  stql profile show production
125
128
  stql connect local
126
129
  stql connect --profile production
127
130
  ```
128
131
 
132
+ Credential references are bounded nonempty opaque strings; StateQL does not
133
+ apply environment-variable syntax or normalization to them. They can only be
134
+ resolved by a trusted host `CredentialResolver`, so the standalone CLI may
135
+ store them in profiles but cannot connect with them.
136
+
129
137
  A bare connection target matching a profile name resolves to that profile;
130
138
  otherwise it remains a path or database URL.
131
139
 
@@ -253,8 +261,9 @@ cancels active work.
253
261
  - MySQL deadlines destroy the active connection.
254
262
  - MongoDB uses driver deadlines and closes stopped operations.
255
263
 
256
- A timed-out write may return `OUTCOME_UNKNOWN` when its commit status cannot be
257
- proven.
264
+ A timed-out or cancelled write may return `OUTCOME_UNKNOWN` when its commit
265
+ status cannot be proven. Cancellation stops that command's driver work; it does
266
+ not close the `StateQL` actor, and later commands remain usable.
258
267
 
259
268
  ## Durable state and result reuse
260
269
 
@@ -424,6 +433,30 @@ if (response.ok) {
424
433
  }
425
434
  ```
426
435
 
436
+ Hosts that dispatch batch-shaped commands can attach trusted metadata out of
437
+ band. `origin` is audit/source metadata only; it never changes actor membership,
438
+ workspace access, or write authorization.
439
+
440
+ ```ts
441
+ const controller = new AbortController();
442
+ await stateql.executeCommand(
443
+ { command: "query", sql: "SELECT * FROM users", cache: "bypass" },
444
+ { origin: "user", signal: controller.signal },
445
+ );
446
+
447
+ const userHistory = await stateql.history(50, { origin: "user" });
448
+ await stateql.executeCommand(
449
+ { command: "history", limit: 50, history_origin: "user" },
450
+ { origin: "model" },
451
+ );
452
+ ```
453
+
454
+ Supported origins are `legacy`, `user`, `model`, `system`, and `api`. Existing
455
+ direct calls and `executeCommand(command)` calls are recorded as `legacy`.
456
+ `history_origin` is only a retrieval filter; putting an `origin` field in a
457
+ `BatchCommand` cannot attribute the command. `batch` accepts the same trusted
458
+ context as `options.executionContext` for all commands in that batch.
459
+
427
460
  ### Actor workspaces
428
461
 
429
462
  `StateQL.forActor(...)` resolves the actor's attached session directly from
@@ -439,8 +472,9 @@ for user confirmation before changing membership or the shared connection.
439
472
 
440
473
  ### Harness credential resolution
441
474
 
442
- Library integrations can resolve a profile's credential reference through a
443
- trusted approval or secret-storage layer instead of mutating `process.env`:
475
+ Library integrations can resolve environment-variable names or opaque
476
+ credential references through a trusted approval or secret-storage layer
477
+ instead of mutating `process.env`:
444
478
 
445
479
  ```ts
446
480
  import {
@@ -454,6 +488,7 @@ async function resolveCredential(
454
488
  ): Promise<string | undefined> {
455
489
  const approved = await credentialBroker.request({
456
490
  reference: request.reference,
491
+ source: request.source ?? "secret_env",
457
492
  actor: request.actorId,
458
493
  session: request.session.id,
459
494
  operation: request.operation,
@@ -471,13 +506,16 @@ const stateql = StateQL.forActor({
471
506
  });
472
507
  ```
473
508
 
474
- When no custom resolver is configured, StateQL reads references from
475
- `process.env`. A configured resolver is authoritative: returning `undefined`
476
- produces `CREDENTIAL_UNAVAILABLE` and never falls back to the process
477
- environment. Resolvers may throw `CredentialResolutionError` with `denied`,
478
- `cancelled`, `timeout`, or `unavailable` to produce controlled, secret-free
479
- failures. Unknown resolver errors are replaced with a generic
480
- `CREDENTIAL_RESOLUTION_FAILED` response.
509
+ When no custom resolver is configured, StateQL reads only `secret_env`
510
+ references from `process.env`; `credential_ref` never falls back to the
511
+ environment. A configured resolver is authoritative for both sources: returning
512
+ `undefined` produces `CREDENTIAL_UNAVAILABLE` and never falls back to the
513
+ process environment. Resolver requests include `source` (`secret_env` or
514
+ `credential_ref`) while retaining `reference`; source may be omitted only on
515
+ legacy secret-environment request objects. Resolvers may throw
516
+ `CredentialResolutionError` with `denied`, `cancelled`, `timeout`, or
517
+ `unavailable` to produce controlled, secret-free failures. Unknown resolver
518
+ errors are replaced with a generic `CREDENTIAL_RESOLUTION_FAILED` response.
481
519
 
482
520
  StateQL calls the resolver only immediately before database access, after SQL
483
521
  safety and duplicate checks. Requests contain actor and session identity, the
package/dist/src/cli.js CHANGED
@@ -13,6 +13,7 @@ const parsed = parseArgs({
13
13
  name: { type: "string" },
14
14
  profile: { type: "string" },
15
15
  env: { type: "string" },
16
+ "credential-ref": { type: "string" },
16
17
  "read-only": { type: "boolean" },
17
18
  "read-write": { type: "boolean" },
18
19
  params: { type: "string" },
@@ -143,6 +144,7 @@ async function dispatch() {
143
144
  return stateql.connect(subcommand, {
144
145
  ...(values.name ? { name: values.name } : {}),
145
146
  ...(values.env ? { secretEnv: values.env } : {}),
147
+ ...(values["credential-ref"] ? { credentialRef: values["credential-ref"] } : {}),
146
148
  ...(values.profile ? { profile: values.profile } : {}),
147
149
  ...(values["read-only"]
148
150
  ? { readOnly: true }
@@ -282,6 +284,7 @@ async function dispatchProfile(action, args) {
282
284
  }
283
285
  return stateql.addProfile(requireValue(args[0], "profile name"), args[1], {
284
286
  ...(values.env ? { secretEnv: values.env } : {}),
287
+ ...(values["credential-ref"] ? { credentialRef: values["credential-ref"] } : {}),
285
288
  readOnly: !values["read-write"],
286
289
  });
287
290
  case "list":
@@ -576,9 +579,10 @@ function helpText() {
576
579
  Usage: stql <command> [arguments] [options]
577
580
 
578
581
  Commands:
579
- connect, disconnect, status
580
- profile add|list|show|remove
581
- session start|list|show|summary|close
582
+ connect TARGET | --env ENV | --credential-ref REF | --profile NAME
583
+ disconnect, status
584
+ profile add NAME [TARGET | --env ENV | --credential-ref REF]
585
+ profile list|show|remove
582
586
  query, filter, exec, show, rows, count, columns, export
583
587
  mongo query|exec|plan '<EJSON command>'
584
588
  alias set
@@ -590,6 +594,7 @@ Commands:
590
594
 
591
595
  SQL parameters: --params JSON, repeated --param VALUE, or --params-file FILE.
592
596
  Deadline: --timeout-ms N (default: 30000). Ctrl+C cancels database work.
597
+ Credential refs require a trusted host CredentialResolver; the standalone CLI cannot resolve them.
593
598
  State: --max-state-bytes N, --cache-ttl-seconds N, --result-ttl-seconds N.
594
599
  Output: --output agent|json|jsonl|text|silent (default: agent).
595
600
  Batch/pipe accept JSON array files or JSONL streams. Stop on first error.`;
@@ -1,9 +1,9 @@
1
1
  import type { ConnectionRecord } from "./store.js";
2
- import type { Driver, StateConfidence } from "./types.js";
2
+ import type { CredentialSource, Driver, StateConfidence } from "./types.js";
3
3
  export declare function databaseIdentity(connection: ConnectionRecord): unknown;
4
4
  export declare function detectDriver(target: string): Driver;
5
5
  export declare function mongoDatabaseName(target: string): string;
6
- export declare function credentialSource(value: string, expectedDriver?: Driver): {
6
+ export declare function credentialSource(value: string, expectedDriver?: Driver, referenceSource?: CredentialSource): {
7
7
  driver: Driver;
8
8
  source: string;
9
9
  };
@@ -13,3 +13,4 @@ export declare function version(connection: ConnectionRecord): string;
13
13
  export declare function confidence(connection: ConnectionRecord): StateConfidence;
14
14
  export declare function validateProfileName(name: string): void;
15
15
  export declare function isEnvironmentName(name: string): boolean;
16
+ export declare function validateCredentialRef(reference: unknown): asserts reference is string;
@@ -6,6 +6,7 @@ export function databaseIdentity(connection) {
6
6
  database: connection.database_name,
7
7
  source: connection.source,
8
8
  secretEnvironment: connection.secret_env,
9
+ credentialReference: connection.credential_ref,
9
10
  };
10
11
  }
11
12
  export function detectDriver(target) {
@@ -43,11 +44,14 @@ export function mongoDatabaseName(target) {
43
44
  }
44
45
  throw new StateQLError("INVALID_COMMAND", "MongoDB URL must include an explicit database name.");
45
46
  }
46
- export function credentialSource(value, expectedDriver) {
47
+ export function credentialSource(value, expectedDriver, referenceSource = "secret_env") {
48
+ const sourceLabel = referenceSource === "credential_ref"
49
+ ? "Credential reference"
50
+ : "Secret environment variable";
47
51
  const explicitSqlite = /^sqlite:(?!\/\/)/i.test(value);
48
52
  const driver = explicitSqlite ? "sqlite" : detectDriver(value);
49
53
  if (driver === "sqlite" && (!explicitSqlite || value.length === 7)) {
50
- throw new StateQLError("INVALID_COMMAND", "Secret environment variable must contain a complete PostgreSQL/MySQL URL or an explicit sqlite: source; MongoDB URLs are also supported.", {
54
+ throw new StateQLError("INVALID_COMMAND", `${sourceLabel} must contain a complete PostgreSQL/MySQL URL or an explicit sqlite: source; MongoDB URLs are also supported.`, {
51
55
  suggestedAction: "Store the full database URL, or prefix an SQLite path with sqlite:.",
52
56
  });
53
57
  }
@@ -58,7 +62,7 @@ export function credentialSource(value, expectedDriver) {
58
62
  throw new Error();
59
63
  }
60
64
  catch {
61
- throw new StateQLError("INVALID_COMMAND", "Secret environment variable must contain a valid database URL.");
65
+ throw new StateQLError("INVALID_COMMAND", `${sourceLabel} must contain a valid database URL.`);
62
66
  }
63
67
  }
64
68
  if (expectedDriver && driver !== expectedDriver) {
@@ -101,3 +105,10 @@ export function validateProfileName(name) {
101
105
  export function isEnvironmentName(name) {
102
106
  return /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name);
103
107
  }
108
+ export function validateCredentialRef(reference) {
109
+ if (typeof reference === "string" &&
110
+ reference.trim().length > 0 &&
111
+ reference.length <= 1_024)
112
+ return;
113
+ throw new StateQLError("INVALID_COMMAND", "Credential reference must be a nonempty string of at most 1024 characters.");
114
+ }
@@ -1,4 +1,4 @@
1
1
  export { StateQL } from "./stateql.js";
2
2
  export { CredentialResolutionError, StateQLError, exitCodeFor, } from "./errors.js";
3
3
  export type { CredentialResolutionFailure } from "./errors.js";
4
- export type { ActorData, ActorLinkData, ActorResolutionData, ActorsData, ActorUnlinkData, AliasData, ApplyData, BatchCommand, BatchCommandName, BatchOptions, CapabilitiesData, CloseSessionData, Column, ColumnsData, CommitTransactionData, ConnectOptions, ConnectionData, CountData, CredentialAccess, CredentialOperation, CredentialRequest, CredentialResolver, DisconnectData, DoctorData, Driver, ExecData, ExecOptions, ExecutionOptions, ExportData, Failure, FilterOptions, HistoryData, HistoryEntry, MongoAggregateOptions, MongoDocument, MongoFindOptions, MongoMutationOptions, MongoExecOptions, MongoPlanOptions, MongoQueryOptions, MongoReadCommand, MongoWriteCommand, MongoWriteOutcome, OperationData, PlanData, PlanOptions, ProfileData, ProfileOptions, ProfilesData, PurgeData, QueryOptions, RecentOperationData, RecentResultData, RemovedProfileData, Response, ResultData, RollbackTransactionData, Row, RowsData, RowsOptions, SqlParameters, SqlDriver, StateQLActorOptions, StateQLOptions, StateConfidence, StateQLSnapshot, StatusConnectionData, StatusData, Success, SessionData, SessionListItem, SessionsData, SessionSummaryData, TransactionData, TransactionReferenceData, Warning, } from "./types.js";
4
+ export type { ActorData, ActorLinkData, ActorResolutionData, ActorsData, ActorUnlinkData, AliasData, ApplyData, BatchCommand, BatchCommandName, BatchOptions, CapabilitiesData, CommandExecutionContext, CommandOrigin, CloseSessionData, Column, ColumnsData, CommitTransactionData, ConnectOptions, ConnectionData, CountData, CredentialAccess, CredentialOperation, CredentialRequest, CredentialSource, CredentialResolver, DisconnectData, DoctorData, Driver, ExecData, ExecOptions, ExecutionOptions, ExportData, Failure, FilterOptions, HistoryData, HistoryEntry, HistoryOptions, MongoAggregateOptions, MongoDocument, MongoFindOptions, MongoMutationOptions, MongoExecOptions, MongoPlanOptions, MongoQueryOptions, MongoReadCommand, MongoWriteCommand, MongoWriteOutcome, OperationData, PlanData, PlanOptions, ProfileData, ProfileOptions, ProfilesData, PurgeData, QueryOptions, RecentOperationData, RecentResultData, RemovedProfileData, Response, ResultData, RollbackTransactionData, Row, RowsData, RowsOptions, SqlParameters, SqlDriver, StateQLActorOptions, StateQLOptions, StateConfidence, StateQLSnapshot, StatusConnectionData, StatusData, Success, SessionData, SessionListItem, SessionsData, SessionSummaryData, TransactionData, TransactionReferenceData, Warning, } from "./types.js";
@@ -64,6 +64,22 @@ const MIGRATIONS = [
64
64
  requireColumns(db, "operations", ["outcome_json"]);
65
65
  },
66
66
  },
67
+ {
68
+ name: "history_origin_v1",
69
+ apply(db) {
70
+ addColumn(db, "history", "origin", "TEXT NOT NULL DEFAULT 'legacy'");
71
+ db.exec("CREATE INDEX IF NOT EXISTS history_session_origin ON history(session_id, origin)");
72
+ },
73
+ validate(db) {
74
+ requireColumns(db, "history", ["origin"]);
75
+ requireIndexes(db, ["history_session_origin"]);
76
+ },
77
+ },
78
+ {
79
+ name: "credential_refs_v1",
80
+ apply: migrateCredentialRefs,
81
+ validate: validateCredentialRefs,
82
+ },
67
83
  ];
68
84
  export function runMigrations(db, now) {
69
85
  db.exec(`
@@ -119,10 +135,15 @@ function createInitialSchema(db) {
119
135
  name TEXT PRIMARY KEY,
120
136
  target TEXT,
121
137
  secret_env TEXT,
138
+ credential_ref TEXT,
122
139
  read_only INTEGER NOT NULL,
123
140
  created_at TEXT NOT NULL,
124
141
  updated_at TEXT NOT NULL,
125
- CHECK(target IS NOT NULL OR secret_env IS NOT NULL)
142
+ CHECK (
143
+ (target IS NOT NULL) +
144
+ (secret_env IS NOT NULL) +
145
+ (credential_ref IS NOT NULL) = 1
146
+ )
126
147
  );
127
148
  CREATE TABLE IF NOT EXISTS connections (
128
149
  id TEXT PRIMARY KEY,
@@ -132,6 +153,7 @@ function createInitialSchema(db) {
132
153
  database_name TEXT NOT NULL,
133
154
  source TEXT NOT NULL,
134
155
  secret_env TEXT,
156
+ credential_ref TEXT,
135
157
  read_only INTEGER NOT NULL,
136
158
  version INTEGER NOT NULL,
137
159
  created_at TEXT NOT NULL,
@@ -221,6 +243,7 @@ function createInitialSchema(db) {
221
243
  session_id TEXT NOT NULL,
222
244
  actor_id TEXT NOT NULL,
223
245
  command TEXT NOT NULL,
246
+ origin TEXT NOT NULL DEFAULT 'legacy',
224
247
  sql TEXT,
225
248
  handle TEXT,
226
249
  executed INTEGER NOT NULL,
@@ -289,6 +312,69 @@ function validateSharedSessionActors(db) {
289
312
  throw new Error(`State migration left ${table}.${column} empty.`);
290
313
  }
291
314
  }
315
+ function migrateCredentialRefs(db) {
316
+ const profileColumns = new Set(db.prepare("PRAGMA table_info(profiles)").all().map((column) => column.name));
317
+ const hasCredentialRef = profileColumns.has("credential_ref");
318
+ if (!profileCredentialRefSchemaCurrent(db)) {
319
+ const missingSources = db.prepare(`SELECT COUNT(*) AS count FROM profiles
320
+ WHERE target IS NULL AND secret_env IS NULL${hasCredentialRef ? " AND credential_ref IS NULL" : ""}`).get();
321
+ if (missingSources.count) {
322
+ throw new Error("State migration found a profile without a connection source.");
323
+ }
324
+ const legacyCredentialRef = hasCredentialRef ? "credential_ref" : "NULL";
325
+ db.exec(`
326
+ ALTER TABLE profiles RENAME TO profiles_legacy_credential_refs_v1;
327
+ CREATE TABLE profiles (
328
+ name TEXT PRIMARY KEY,
329
+ target TEXT,
330
+ secret_env TEXT,
331
+ credential_ref TEXT,
332
+ read_only INTEGER NOT NULL,
333
+ created_at TEXT NOT NULL,
334
+ updated_at TEXT NOT NULL,
335
+ CHECK (
336
+ (target IS NOT NULL) +
337
+ (secret_env IS NOT NULL) +
338
+ (credential_ref IS NOT NULL) = 1
339
+ )
340
+ );
341
+ INSERT INTO profiles
342
+ (name, target, secret_env, credential_ref, read_only, created_at, updated_at)
343
+ SELECT
344
+ name,
345
+ CASE
346
+ WHEN ${legacyCredentialRef} IS NULL AND secret_env IS NULL THEN target
347
+ ELSE NULL
348
+ END,
349
+ CASE WHEN ${legacyCredentialRef} IS NULL THEN secret_env ELSE NULL END,
350
+ ${legacyCredentialRef},
351
+ read_only,
352
+ created_at,
353
+ updated_at
354
+ FROM profiles_legacy_credential_refs_v1;
355
+ DROP TABLE profiles_legacy_credential_refs_v1;
356
+ `);
357
+ }
358
+ addColumn(db, "connections", "credential_ref", "TEXT");
359
+ }
360
+ function validateCredentialRefs(db) {
361
+ requireColumns(db, "profiles", ["credential_ref"]);
362
+ requireColumns(db, "connections", ["credential_ref"]);
363
+ if (!profileCredentialRefSchemaCurrent(db)) {
364
+ throw new Error("State migration did not enforce exactly one profile source.");
365
+ }
366
+ const invalid = db.prepare(`SELECT COUNT(*) AS count FROM profiles
367
+ WHERE (target IS NOT NULL) +
368
+ (secret_env IS NOT NULL) +
369
+ (credential_ref IS NOT NULL) != 1`).get();
370
+ if (invalid.count) {
371
+ throw new Error("State migration left a profile with ambiguous connection sources.");
372
+ }
373
+ }
374
+ function profileCredentialRefSchemaCurrent(db) {
375
+ const row = db.prepare("SELECT sql FROM sqlite_schema WHERE type = 'table' AND name = 'profiles'").get();
376
+ return Boolean(row?.sql && /CHECK\s*\(\s*\(target IS NOT NULL\)\s*\+\s*\(secret_env IS NOT NULL\)\s*\+\s*\(credential_ref IS NOT NULL\)\s*=\s*1\s*\)/i.test(row.sql));
377
+ }
292
378
  function addColumn(db, table, column, definition) {
293
379
  const columns = db.prepare(`PRAGMA table_info(${table})`).all();
294
380
  if (!columns.some((candidate) => candidate.name === column)) {
@@ -13,6 +13,7 @@ export function profileData(profile) {
13
13
  profile: profile.name,
14
14
  target: profile.target,
15
15
  secret_env: profile.secret_env,
16
+ credential_ref: profile.credential_ref,
16
17
  read_only: Boolean(profile.read_only),
17
18
  };
18
19
  }
@@ -1,4 +1,4 @@
1
- import type { ActorLinkData, ActorResolutionData, ActorsData, ActorUnlinkData, AliasData, ApplyData, BatchCommand, BatchOptions, CapabilitiesData, CloseSessionData, ColumnsData, CommitTransactionData, ConnectOptions, ConnectionData, CountData, DisconnectData, DoctorData, ExecData, ExecOptions, ExecutionOptions, ExportData, FilterOptions, HistoryData, OperationData, PlanData, PlanOptions, ProfileData, ProfilesData, ProfileOptions, MongoExecOptions, MongoPlanOptions, MongoQueryOptions, MongoReadCommand, MongoWriteCommand, PurgeData, QueryOptions, RemovedProfileData, Response, ResultData, RollbackTransactionData, RowsData, RowsOptions, StateQLActorOptions, StateQLOptions, StateQLSnapshot, StatusData, SessionData, SessionsData, SessionSummaryData, TransactionData } from "./types.js";
1
+ import type { ActorLinkData, ActorResolutionData, ActorsData, ActorUnlinkData, AliasData, ApplyData, BatchCommand, CommandExecutionContext, BatchOptions, CapabilitiesData, CloseSessionData, ColumnsData, CommitTransactionData, ConnectOptions, ConnectionData, CountData, DisconnectData, DoctorData, ExecData, ExecOptions, ExecutionOptions, ExportData, FilterOptions, HistoryData, HistoryOptions, OperationData, PlanData, PlanOptions, ProfileData, ProfilesData, ProfileOptions, MongoExecOptions, MongoPlanOptions, MongoQueryOptions, MongoReadCommand, MongoWriteCommand, PurgeData, QueryOptions, RemovedProfileData, Response, ResultData, RollbackTransactionData, RowsData, RowsOptions, StateQLActorOptions, StateQLOptions, StateQLSnapshot, StatusData, SessionData, SessionsData, SessionSummaryData, TransactionData } from "./types.js";
2
2
  export declare class StateQL {
3
3
  static forActor(options: StateQLActorOptions): StateQL;
4
4
  private readonly store;
@@ -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 commandContexts;
15
16
  private readonly credentialResolver?;
16
17
  private readonly now;
17
18
  private closed;
@@ -57,11 +58,11 @@ export declare class StateQL {
57
58
  plan(sql: string, options?: PlanOptions): Promise<Response<PlanData>>;
58
59
  mongoPlan(command: MongoWriteCommand, options?: MongoPlanOptions): Promise<Response<PlanData>>;
59
60
  apply(planId: string, options?: ExecutionOptions): Promise<Response<ApplyData>>;
60
- history(limit?: number): Promise<Response<HistoryData>>;
61
+ history(limit?: number, options?: HistoryOptions): Promise<Response<HistoryData>>;
61
62
  doctor(): Promise<Response<DoctorData>>;
62
63
  purge(scope?: "expired" | "results" | "history" | "all"): Promise<Response<PurgeData>>;
63
64
  capabilities(): Promise<Response<CapabilitiesData>>;
64
- executeCommand(command: BatchCommand): Promise<Response<unknown>>;
65
+ executeCommand(command: BatchCommand, context?: CommandExecutionContext): Promise<Response<unknown>>;
65
66
  batch(commands: Iterable<BatchCommand> | AsyncIterable<BatchCommand>, options?: BatchOptions): AsyncGenerator<Response<unknown>>;
66
67
  private performExec;
67
68
  private performMongoExec;
@@ -1,8 +1,9 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
1
2
  import { writeFileSync } from "node:fs";
2
3
  import { basename, resolve } from "node:path";
3
4
  import { env } from "node:process";
4
5
  import { AdapterExecutionError, AdapterWriteError, BatchWriteError, createAdapter, createAdapterContext, } from "./adapters.js";
5
- import { confidence, credentialSource, databaseIdentity, databaseUrlHasSecret, detectDriver, isEnvironmentName, mongoDatabaseName, normalizeSqliteSource, validateProfileName, version, } from "./connection.js";
6
+ import { confidence, credentialSource, databaseIdentity, databaseUrlHasSecret, detectDriver, isEnvironmentName, mongoDatabaseName, normalizeSqliteSource, validateCredentialRef, validateProfileName, version, } from "./connection.js";
6
7
  import { asStateQLError, CredentialResolutionError, StateQLError, } from "./errors.js";
7
8
  import { analyzeMongoWriteSafety, deserializeMongoWriteCommand, serializeMongoCommand, validateMongoReadCommand, validateMongoWriteCommand, MongoAdapter, } from "./mongodb.js";
8
9
  import { filterMaterializedRows, prepareFilterStatement, validateFilterParameters, } from "./filter.js";
@@ -41,6 +42,7 @@ export class StateQL {
41
42
  maxResultBytes;
42
43
  timeoutMs;
43
44
  signal;
45
+ commandContexts = new AsyncLocalStorage();
44
46
  credentialResolver;
45
47
  now;
46
48
  closed = false;
@@ -88,12 +90,16 @@ export class StateQL {
88
90
  if (session.active_transaction_id) {
89
91
  throw new StateQLError("TRANSACTION_FAILED", "Commit or roll back the active transaction before connecting again.");
90
92
  }
91
- const sourceCount = [target, options.profile, options.secretEnv]
92
- .filter((value) => value !== undefined).length;
93
- if (sourceCount > 1) {
94
- throw new StateQLError("INVALID_COMMAND", "Use exactly one connection target, profile, or secret environment variable.");
93
+ const sourceCount = [
94
+ target,
95
+ options.profile,
96
+ options.secretEnv,
97
+ options.credentialRef,
98
+ ].filter((value) => value !== undefined).length;
99
+ if (sourceCount !== 1) {
100
+ throw new StateQLError("INVALID_COMMAND", "Use exactly one connection target, profile, secret environment variable, or credential reference.");
95
101
  }
96
- const implicitProfile = !options.profile && !options.secretEnv && target
102
+ const implicitProfile = !options.profile && !options.secretEnv && !options.credentialRef && target
97
103
  ? this.store.getProfile(target)
98
104
  : undefined;
99
105
  const profile = options.profile
@@ -102,16 +108,28 @@ export class StateQL {
102
108
  if (options.profile && !profile) {
103
109
  throw new StateQLError("CONNECTION_NOT_FOUND", `Profile "${options.profile}" was not found.`, { suggestedAction: "Run stql profile list." });
104
110
  }
111
+ if (profile &&
112
+ [profile.target, profile.secret_env, profile.credential_ref]
113
+ .filter((value) => value !== null).length !== 1) {
114
+ throw new StateQLError("STATE_CORRUPTED", `Profile "${profile.name}" does not have exactly one connection source.`);
115
+ }
105
116
  const resolvedTarget = profile?.target ?? target;
106
117
  const secretEnv = options.secretEnv ?? profile?.secret_env ?? undefined;
107
- if (secretEnv && !isEnvironmentName(secretEnv)) {
118
+ const credentialRef = options.credentialRef ?? profile?.credential_ref ?? undefined;
119
+ if (secretEnv !== undefined && !isEnvironmentName(secretEnv)) {
108
120
  throw new StateQLError("INVALID_COMMAND", "Secret environment variable name is invalid.");
109
121
  }
122
+ if (credentialRef !== undefined)
123
+ validateCredentialRef(credentialRef);
124
+ const credentialReference = secretEnv ?? credentialRef;
125
+ const credentialReferenceSource = secretEnv !== undefined
126
+ ? "secret_env"
127
+ : credentialRef !== undefined ? "credential_ref" : undefined;
110
128
  const readOnly = options.readOnly ??
111
129
  (profile ? Boolean(profile.read_only) : true);
112
130
  const context = this.executionContext(options);
113
- const secret = secretEnv
114
- ? await this.resolveCredential(secretEnv, session, "connect", readOnly ? "read" : "write", context, {
131
+ const secret = credentialReference && credentialReferenceSource
132
+ ? await this.resolveCredential(credentialReference, credentialReferenceSource, session, "connect", readOnly ? "read" : "write", context, {
115
133
  ...(profile ? { profile: { name: profile.name } } : {}),
116
134
  requestedReadOnly: readOnly,
117
135
  })
@@ -119,23 +137,23 @@ export class StateQL {
119
137
  if (!secret) {
120
138
  throw new StateQLError("INVALID_COMMAND", "Connection target is required.");
121
139
  }
122
- const resolvedSource = secretEnv
123
- ? credentialSource(secret)
140
+ const resolvedSource = credentialReferenceSource
141
+ ? credentialSource(secret, undefined, credentialReferenceSource)
124
142
  : { driver: detectDriver(secret), source: secret };
125
143
  const { driver } = resolvedSource;
126
144
  if (driver !== "sqlite" &&
127
- !secretEnv &&
145
+ !credentialReference &&
128
146
  databaseUrlHasSecret(secret)) {
129
- throw new StateQLError("PERMISSION_DENIED", `Credential-bearing ${databaseDisplayName(driver)} URLs must use --env.`, {
130
- suggestedAction: "Set the URL in an environment variable and reconnect with --env NAME.",
147
+ throw new StateQLError("PERMISSION_DENIED", `Credential-bearing ${databaseDisplayName(driver)} URLs must use --env or --credential-ref.`, {
148
+ suggestedAction: "Use an environment variable or trusted host credential reference.",
131
149
  });
132
150
  }
133
- const adapterSource = secretEnv
151
+ const adapterSource = credentialReferenceSource
134
152
  ? resolvedSource.source
135
153
  : driver === "sqlite" ? normalizeSqliteSource(secret) : secret;
136
154
  const source = driver === "sqlite"
137
155
  ? adapterSource
138
- : secretEnv
156
+ : credentialReferenceSource
139
157
  ? redact(secret)
140
158
  : adapterSource;
141
159
  const databaseName = driver === "sqlite"
@@ -151,6 +169,7 @@ export class StateQL {
151
169
  database_name: databaseName,
152
170
  source,
153
171
  secret_env: secretEnv ?? null,
172
+ credential_ref: credentialRef ?? null,
154
173
  read_only: readOnly ? 1 : 0,
155
174
  version: 0,
156
175
  created_at: this.now().toISOString(),
@@ -178,6 +197,7 @@ export class StateQL {
178
197
  databaseName,
179
198
  source,
180
199
  ...(secretEnv ? { secretEnv } : {}),
200
+ ...(credentialRef ? { credentialRef } : {}),
181
201
  readOnly,
182
202
  });
183
203
  if (!connection) {
@@ -204,20 +224,25 @@ export class StateQL {
204
224
  async addProfile(name, target, options = {}) {
205
225
  return this.run("profile.add", async () => {
206
226
  validateProfileName(name);
207
- if (Boolean(target) === Boolean(options.secretEnv)) {
208
- throw new StateQLError("INVALID_COMMAND", "Profile requires exactly one target or secret environment variable.");
227
+ const sourceCount = [target, options.secretEnv, options.credentialRef]
228
+ .filter((value) => value !== undefined).length;
229
+ if (sourceCount !== 1 || target === "") {
230
+ throw new StateQLError("INVALID_COMMAND", "Profile requires exactly one target, secret environment variable, or credential reference.");
209
231
  }
210
232
  if (this.store.getProfile(name)) {
211
233
  throw new StateQLError("INVALID_COMMAND", `Profile "${name}" already exists.`);
212
234
  }
213
- if (options.secretEnv && !isEnvironmentName(options.secretEnv)) {
235
+ if (options.secretEnv !== undefined && !isEnvironmentName(options.secretEnv)) {
214
236
  throw new StateQLError("INVALID_COMMAND", "Secret environment variable name is invalid.");
215
237
  }
238
+ if (options.credentialRef !== undefined) {
239
+ validateCredentialRef(options.credentialRef);
240
+ }
216
241
  let storedTarget = target;
217
242
  if (target) {
218
243
  const driver = detectDriver(target);
219
244
  if (driver !== "sqlite" && databaseUrlHasSecret(target)) {
220
- throw new StateQLError("PERMISSION_DENIED", `Credential-bearing ${databaseDisplayName(driver)} URLs must use --env.`);
245
+ throw new StateQLError("PERMISSION_DENIED", `Credential-bearing ${databaseDisplayName(driver)} URLs must use --env or --credential-ref.`);
221
246
  }
222
247
  if (driver === "sqlite")
223
248
  storedTarget = normalizeSqliteSource(target);
@@ -226,6 +251,7 @@ export class StateQL {
226
251
  name,
227
252
  target: storedTarget,
228
253
  secretEnv: options.secretEnv,
254
+ credentialRef: options.credentialRef,
229
255
  readOnly: options.readOnly ?? true,
230
256
  });
231
257
  return {
@@ -1303,11 +1329,13 @@ export class StateQL {
1303
1329
  }
1304
1330
  }, () => historySql);
1305
1331
  }
1306
- async history(limit = 20) {
1332
+ async history(limit = 20, options = {}) {
1307
1333
  return this.run("history", async (session) => ({
1308
1334
  data: {
1309
1335
  history: this.store
1310
- .history(session.id, positiveInteger(limit, "limit"))
1336
+ .history(session.id, positiveInteger(limit, "limit"), options.origin === undefined
1337
+ ? undefined
1338
+ : parseCommandOrigin(options.origin))
1311
1339
  .map(historyEntry),
1312
1340
  },
1313
1341
  }));
@@ -1364,169 +1392,182 @@ export class StateQL {
1364
1392
  },
1365
1393
  }));
1366
1394
  }
1367
- async executeCommand(command) {
1368
- if (!command || typeof command !== "object") {
1369
- return this.batchFailure("Batch command must be an object.");
1370
- }
1395
+ async executeCommand(command, context = {}) {
1396
+ let activeContext;
1371
1397
  try {
1372
- switch (command.command) {
1373
- case "connect":
1374
- return this.connect(command.target, {
1375
- name: command.name,
1376
- readOnly: command.read_only,
1377
- secretEnv: command.secret_env,
1378
- profile: command.profile,
1379
- timeoutMs: command.timeout_ms,
1380
- });
1381
- case "disconnect":
1382
- return this.disconnect();
1383
- case "status":
1384
- return this.status();
1385
- case "profile.add":
1386
- return this.addProfile(batchString(command.name, "name"), command.target, {
1387
- readOnly: command.read_only ?? true,
1388
- secretEnv: command.secret_env,
1389
- });
1390
- case "profile.list":
1391
- return this.listProfiles();
1392
- case "profile.show":
1393
- return this.showProfile(batchString(command.name, "name"));
1394
- case "profile.remove":
1395
- return this.removeProfile(batchString(command.name, "name"));
1396
- case "session.start":
1397
- return this.startSession(batchString(command.name, "name"));
1398
- case "session.list":
1399
- return this.listSessions();
1400
- case "session.show":
1401
- return this.showSession(command.name);
1402
- case "session.summary":
1403
- return this.sessionSummary();
1404
- case "session.close":
1405
- return this.closeSession();
1406
- case "query": {
1407
- const response = await this.query(batchString(command.sql, "sql"), {
1408
- params: command.params ?? [],
1409
- cache: command.cache ?? "auto",
1410
- timeoutMs: command.timeout_ms,
1411
- });
1412
- if (!response.ok || !command.as)
1413
- return response;
1414
- const resultId = response.data.result_id;
1415
- if (typeof resultId !== "string")
1416
- return response;
1417
- this.store.setAlias(response.session_id, command.as, resultId);
1418
- return {
1419
- ...response,
1420
- data: { ...response.data, alias: command.as },
1421
- };
1422
- }
1423
- case "mongo.query": {
1424
- const response = await this.mongoQuery(command.mongo, {
1425
- cache: command.cache ?? "auto",
1426
- timeoutMs: command.timeout_ms,
1427
- });
1428
- if (!response.ok || !command.as)
1429
- return response;
1430
- const resultId = response.data.result_id;
1431
- if (typeof resultId !== "string")
1432
- return response;
1433
- this.store.setAlias(response.session_id, command.as, resultId);
1434
- return {
1435
- ...response,
1436
- data: { ...response.data, alias: command.as },
1437
- };
1438
- }
1439
- case "filter": {
1440
- const response = await this.filter(batchString(command.handle, "handle"), batchString(command.where, "where"), { params: command.params ?? [] });
1441
- if (!response.ok || !command.as)
1442
- return response;
1443
- const resultId = response.data.result_id;
1444
- if (typeof resultId !== "string")
1445
- return response;
1446
- this.store.setAlias(response.session_id, command.as, resultId);
1447
- return {
1448
- ...response,
1449
- data: { ...response.data, alias: command.as },
1450
- };
1451
- }
1452
- case "exec":
1453
- return this.exec(batchString(command.sql, "sql"), {
1454
- params: command.params ?? [],
1455
- replay: command.replay ?? false,
1456
- idempotencyKey: command.idempotency_key,
1457
- allowUnbounded: command.allow_unbounded ?? false,
1458
- allowDestructive: command.allow_destructive ?? false,
1459
- timeoutMs: command.timeout_ms,
1460
- });
1461
- case "mongo.exec":
1462
- return this.mongoExec(command.mongo, {
1463
- replay: command.replay ?? false,
1464
- idempotencyKey: command.idempotency_key,
1465
- allowUnbounded: command.allow_unbounded ?? false,
1466
- allowDestructive: command.allow_destructive ?? false,
1467
- timeoutMs: command.timeout_ms,
1468
- });
1469
- case "show":
1470
- return this.show(batchString(command.handle, "handle"));
1471
- case "rows":
1472
- return this.rows(batchString(command.handle, "handle"), {
1473
- offset: command.offset ?? 0,
1474
- limit: command.limit ?? 20,
1475
- });
1476
- case "count":
1477
- return this.count(batchString(command.handle, "handle"));
1478
- case "columns":
1479
- return this.columns(batchString(command.handle, "handle"));
1480
- case "alias.set":
1481
- return this.setAlias(batchString(command.name, "name"), batchString(command.handle, "handle"));
1482
- case "inspect":
1483
- return this.inspect(batchString(command.kind, "kind"), command.table, {
1484
- timeoutMs: command.timeout_ms,
1485
- });
1486
- case "transaction.begin":
1487
- return this.beginTransaction(command.isolation);
1488
- case "transaction.status":
1489
- return this.transactionStatus(command.handle);
1490
- case "transaction.commit":
1491
- return this.commitTransaction(command.handle, {
1492
- timeoutMs: command.timeout_ms,
1493
- });
1494
- case "transaction.rollback":
1495
- return this.rollbackTransaction(command.handle);
1496
- case "plan":
1497
- return this.plan(batchString(command.sql, "sql"), {
1498
- params: command.params ?? [],
1499
- allowUnbounded: command.allow_unbounded ?? false,
1500
- allowDestructive: command.allow_destructive,
1501
- timeoutMs: command.timeout_ms,
1502
- });
1503
- case "mongo.plan":
1504
- return this.mongoPlan(command.mongo, {
1505
- allowUnbounded: command.allow_unbounded ?? false,
1506
- allowDestructive: command.allow_destructive,
1507
- timeoutMs: command.timeout_ms,
1508
- });
1509
- case "apply":
1510
- return this.apply(batchString(command.handle, "handle"), {
1511
- timeoutMs: command.timeout_ms,
1512
- });
1513
- case "history":
1514
- return this.history(command.limit ?? 20);
1515
- case "receipt":
1516
- return this.receipt(batchString(command.handle, "handle"));
1517
- case "doctor":
1518
- return this.doctor();
1519
- case "purge":
1520
- return this.purge(command.scope ?? "expired");
1521
- case "capabilities":
1522
- return this.capabilities();
1523
- default:
1524
- return this.batchFailure(`Unknown batch command "${String(command.command)}".`);
1525
- }
1398
+ activeContext = mergeCommandExecutionContext(this.commandContexts.getStore(), context);
1526
1399
  }
1527
1400
  catch (error) {
1528
1401
  return this.batchFailure(errorMessage(error));
1529
1402
  }
1403
+ return this.commandContexts.run(activeContext, async () => {
1404
+ if (!command || typeof command !== "object") {
1405
+ return this.batchFailure("Batch command must be an object.");
1406
+ }
1407
+ try {
1408
+ switch (command.command) {
1409
+ case "connect":
1410
+ return this.connect(command.target, {
1411
+ name: command.name,
1412
+ readOnly: command.read_only,
1413
+ secretEnv: command.secret_env,
1414
+ credentialRef: command.credential_ref,
1415
+ profile: command.profile,
1416
+ timeoutMs: command.timeout_ms,
1417
+ });
1418
+ case "disconnect":
1419
+ return this.disconnect();
1420
+ case "status":
1421
+ return this.status();
1422
+ case "profile.add":
1423
+ return this.addProfile(batchString(command.name, "name"), command.target, {
1424
+ readOnly: command.read_only ?? true,
1425
+ secretEnv: command.secret_env,
1426
+ credentialRef: command.credential_ref,
1427
+ });
1428
+ case "profile.list":
1429
+ return this.listProfiles();
1430
+ case "profile.show":
1431
+ return this.showProfile(batchString(command.name, "name"));
1432
+ case "profile.remove":
1433
+ return this.removeProfile(batchString(command.name, "name"));
1434
+ case "session.start":
1435
+ return this.startSession(batchString(command.name, "name"));
1436
+ case "session.list":
1437
+ return this.listSessions();
1438
+ case "session.show":
1439
+ return this.showSession(command.name);
1440
+ case "session.summary":
1441
+ return this.sessionSummary();
1442
+ case "session.close":
1443
+ return this.closeSession();
1444
+ case "query": {
1445
+ const response = await this.query(batchString(command.sql, "sql"), {
1446
+ params: command.params ?? [],
1447
+ cache: command.cache ?? "auto",
1448
+ timeoutMs: command.timeout_ms,
1449
+ });
1450
+ if (!response.ok || !command.as)
1451
+ return response;
1452
+ const resultId = response.data.result_id;
1453
+ if (typeof resultId !== "string")
1454
+ return response;
1455
+ this.store.setAlias(response.session_id, command.as, resultId);
1456
+ return {
1457
+ ...response,
1458
+ data: { ...response.data, alias: command.as },
1459
+ };
1460
+ }
1461
+ case "mongo.query": {
1462
+ const response = await this.mongoQuery(command.mongo, {
1463
+ cache: command.cache ?? "auto",
1464
+ timeoutMs: command.timeout_ms,
1465
+ });
1466
+ if (!response.ok || !command.as)
1467
+ return response;
1468
+ const resultId = response.data.result_id;
1469
+ if (typeof resultId !== "string")
1470
+ return response;
1471
+ this.store.setAlias(response.session_id, command.as, resultId);
1472
+ return {
1473
+ ...response,
1474
+ data: { ...response.data, alias: command.as },
1475
+ };
1476
+ }
1477
+ case "filter": {
1478
+ const response = await this.filter(batchString(command.handle, "handle"), batchString(command.where, "where"), { params: command.params ?? [] });
1479
+ if (!response.ok || !command.as)
1480
+ return response;
1481
+ const resultId = response.data.result_id;
1482
+ if (typeof resultId !== "string")
1483
+ return response;
1484
+ this.store.setAlias(response.session_id, command.as, resultId);
1485
+ return {
1486
+ ...response,
1487
+ data: { ...response.data, alias: command.as },
1488
+ };
1489
+ }
1490
+ case "exec":
1491
+ return this.exec(batchString(command.sql, "sql"), {
1492
+ params: command.params ?? [],
1493
+ replay: command.replay ?? false,
1494
+ idempotencyKey: command.idempotency_key,
1495
+ allowUnbounded: command.allow_unbounded ?? false,
1496
+ allowDestructive: command.allow_destructive ?? false,
1497
+ timeoutMs: command.timeout_ms,
1498
+ });
1499
+ case "mongo.exec":
1500
+ return this.mongoExec(command.mongo, {
1501
+ replay: command.replay ?? false,
1502
+ idempotencyKey: command.idempotency_key,
1503
+ allowUnbounded: command.allow_unbounded ?? false,
1504
+ allowDestructive: command.allow_destructive ?? false,
1505
+ timeoutMs: command.timeout_ms,
1506
+ });
1507
+ case "show":
1508
+ return this.show(batchString(command.handle, "handle"));
1509
+ case "rows":
1510
+ return this.rows(batchString(command.handle, "handle"), {
1511
+ offset: command.offset ?? 0,
1512
+ limit: command.limit ?? 20,
1513
+ });
1514
+ case "count":
1515
+ return this.count(batchString(command.handle, "handle"));
1516
+ case "columns":
1517
+ return this.columns(batchString(command.handle, "handle"));
1518
+ case "alias.set":
1519
+ return this.setAlias(batchString(command.name, "name"), batchString(command.handle, "handle"));
1520
+ case "inspect":
1521
+ return this.inspect(batchString(command.kind, "kind"), command.table, {
1522
+ timeoutMs: command.timeout_ms,
1523
+ });
1524
+ case "transaction.begin":
1525
+ return this.beginTransaction(command.isolation);
1526
+ case "transaction.status":
1527
+ return this.transactionStatus(command.handle);
1528
+ case "transaction.commit":
1529
+ return this.commitTransaction(command.handle, {
1530
+ timeoutMs: command.timeout_ms,
1531
+ });
1532
+ case "transaction.rollback":
1533
+ return this.rollbackTransaction(command.handle);
1534
+ case "plan":
1535
+ return this.plan(batchString(command.sql, "sql"), {
1536
+ params: command.params ?? [],
1537
+ allowUnbounded: command.allow_unbounded ?? false,
1538
+ allowDestructive: command.allow_destructive,
1539
+ timeoutMs: command.timeout_ms,
1540
+ });
1541
+ case "mongo.plan":
1542
+ return this.mongoPlan(command.mongo, {
1543
+ allowUnbounded: command.allow_unbounded ?? false,
1544
+ allowDestructive: command.allow_destructive,
1545
+ timeoutMs: command.timeout_ms,
1546
+ });
1547
+ case "apply":
1548
+ return this.apply(batchString(command.handle, "handle"), {
1549
+ timeoutMs: command.timeout_ms,
1550
+ });
1551
+ case "history":
1552
+ return this.history(command.limit ?? 20, {
1553
+ origin: command.history_origin,
1554
+ });
1555
+ case "receipt":
1556
+ return this.receipt(batchString(command.handle, "handle"));
1557
+ case "doctor":
1558
+ return this.doctor();
1559
+ case "purge":
1560
+ return this.purge(command.scope ?? "expired");
1561
+ case "capabilities":
1562
+ return this.capabilities();
1563
+ default:
1564
+ return this.batchFailure(`Unknown batch command "${String(command.command)}".`);
1565
+ }
1566
+ }
1567
+ catch (error) {
1568
+ return this.batchFailure(errorMessage(error));
1569
+ }
1570
+ });
1530
1571
  }
1531
1572
  async *batch(commands, options = {}) {
1532
1573
  const maxCommands = options.maxCommands ?? 1_000;
@@ -1541,7 +1582,7 @@ export class StateQL {
1541
1582
  yield await this.batchFailure(`Batch cannot exceed ${maxCommands} commands.`);
1542
1583
  return;
1543
1584
  }
1544
- const response = await this.executeCommand(command);
1585
+ const response = await this.executeCommand(command, options.executionContext);
1545
1586
  yield response;
1546
1587
  if (!response.ok && !options.continueOnError)
1547
1588
  return;
@@ -2007,9 +2048,13 @@ export class StateQL {
2007
2048
  throw new StateQLError("PERMISSION_DENIED", `Actor "${this.actorId}" is not attached to session "${session.name}".`);
2008
2049
  }
2009
2050
  async resolveConnectionSource(connection, session, operation, access, context) {
2010
- if (!connection.secret_env)
2051
+ const reference = connection.secret_env ?? connection.credential_ref;
2052
+ if (!reference)
2011
2053
  return connection.source;
2012
- const value = await this.resolveCredential(connection.secret_env, session, operation, access, context, {
2054
+ const source = connection.secret_env
2055
+ ? "secret_env"
2056
+ : "credential_ref";
2057
+ const value = await this.resolveCredential(reference, source, session, operation, access, context, {
2013
2058
  connection: {
2014
2059
  id: connection.id,
2015
2060
  name: connection.name,
@@ -2018,9 +2063,9 @@ export class StateQL {
2018
2063
  readOnly: Boolean(connection.read_only),
2019
2064
  },
2020
2065
  });
2021
- return credentialSource(value, connection.driver).source;
2066
+ return credentialSource(value, connection.driver, source).source;
2022
2067
  }
2023
- async resolveCredential(reference, session, operation, access, context, details = {}) {
2068
+ async resolveCredential(reference, source, session, operation, access, context, details = {}) {
2024
2069
  const resolver = this.credentialResolver;
2025
2070
  if (!resolver) {
2026
2071
  if (context.signal?.aborted) {
@@ -2029,13 +2074,16 @@ export class StateQL {
2029
2074
  if (context.deadline <= Date.now()) {
2030
2075
  throw credentialStateQLError(reference, new CredentialResolutionError("timeout"));
2031
2076
  }
2032
- const value = env[reference];
2033
- if (value)
2034
- return value;
2077
+ if (source === "secret_env") {
2078
+ const value = env[reference];
2079
+ if (value)
2080
+ return value;
2081
+ }
2035
2082
  throw credentialStateQLError(reference, new CredentialResolutionError("unavailable"));
2036
2083
  }
2037
2084
  const request = {
2038
2085
  reference,
2086
+ source,
2039
2087
  actorId: this.actorId,
2040
2088
  session: { id: session.id, name: session.name },
2041
2089
  operation,
@@ -2074,7 +2122,7 @@ export class StateQL {
2074
2122
  }
2075
2123
  }
2076
2124
  executionContext(options) {
2077
- return createAdapterContext(executionTimeout(options.timeoutMs ?? this.timeoutMs), options.signal ?? this.signal);
2125
+ return createAdapterContext(executionTimeout(options.timeoutMs ?? this.timeoutMs), combineAbortSignals(options.signal, this.commandContexts.getStore()?.signal, this.signal));
2078
2126
  }
2079
2127
  resultData(result, cached) {
2080
2128
  const rows = this.store.resultRows(result);
@@ -2104,6 +2152,7 @@ export class StateQL {
2104
2152
  }
2105
2153
  async run(command, action, historySql) {
2106
2154
  const started = performance.now();
2155
+ const origin = this.commandContexts.getStore()?.origin ?? "legacy";
2107
2156
  let session = this.store.ensureSession(this.sessionName);
2108
2157
  const commandId = this.store.nextId("cmd");
2109
2158
  if (!this.store.isSessionMember(session.id, this.actorId)) {
@@ -2126,6 +2175,7 @@ export class StateQL {
2126
2175
  id: commandId,
2127
2176
  sessionId: session.id,
2128
2177
  actorId: this.actorId,
2178
+ origin,
2129
2179
  command,
2130
2180
  ...(result.handle ? { handle: result.handle } : {}),
2131
2181
  ...(sqlText !== undefined ? { sql: sqlText } : {}),
@@ -2157,6 +2207,7 @@ export class StateQL {
2157
2207
  id: commandId,
2158
2208
  sessionId: session.id,
2159
2209
  actorId: this.actorId,
2210
+ origin,
2160
2211
  command,
2161
2212
  ...(sqlText !== undefined ? { sql: sqlText } : {}),
2162
2213
  executed: stateqlError.details.executed,
@@ -2185,6 +2236,7 @@ function historyEntry(item) {
2185
2236
  timestamp: item.timestamp,
2186
2237
  session_id: item.session_id,
2187
2238
  actor_id: item.actor_id,
2239
+ origin: item.origin,
2188
2240
  command: item.command,
2189
2241
  sql: item.sql,
2190
2242
  handle: item.handle,
@@ -2194,6 +2246,41 @@ function historyEntry(item) {
2194
2246
  error_code: item.error_code,
2195
2247
  };
2196
2248
  }
2249
+ const COMMAND_ORIGINS = new Set([
2250
+ "legacy",
2251
+ "user",
2252
+ "model",
2253
+ "system",
2254
+ "api",
2255
+ ]);
2256
+ function parseCommandOrigin(value) {
2257
+ if (typeof value === "string" && COMMAND_ORIGINS.has(value)) {
2258
+ return value;
2259
+ }
2260
+ throw new StateQLError("INVALID_COMMAND", `Unknown command origin "${String(value)}".`);
2261
+ }
2262
+ function mergeCommandExecutionContext(inherited, supplied) {
2263
+ if (!supplied || typeof supplied !== "object") {
2264
+ throw new StateQLError("INVALID_COMMAND", "Command execution context must be an object.");
2265
+ }
2266
+ if (supplied.signal !== undefined && !(supplied.signal instanceof AbortSignal)) {
2267
+ throw new StateQLError("INVALID_COMMAND", "Command execution context signal must be an AbortSignal.");
2268
+ }
2269
+ return {
2270
+ signal: combineAbortSignals(inherited?.signal, supplied.signal),
2271
+ origin: supplied.origin === undefined
2272
+ ? inherited?.origin
2273
+ : parseCommandOrigin(supplied.origin),
2274
+ };
2275
+ }
2276
+ function combineAbortSignals(...signals) {
2277
+ const present = signals.filter((signal) => signal !== undefined);
2278
+ if (present.length === 0)
2279
+ return undefined;
2280
+ if (present.length === 1)
2281
+ return present[0];
2282
+ return AbortSignal.any(present);
2283
+ }
2197
2284
  function markTransactionOutcomeUnknown(store, transactionId, sessionId, actorId) {
2198
2285
  try {
2199
2286
  store.markTransactionOutcomeUnknown(transactionId, sessionId, actorId);
@@ -1,5 +1,5 @@
1
1
  import { DatabaseSync } from "node:sqlite";
2
- import type { Column, Driver, MongoWriteOutcome, Row, SqlParameters, StateConfidence } from "./types.js";
2
+ import type { Column, CommandOrigin, Driver, MongoWriteOutcome, Row, SqlParameters, StateConfidence } from "./types.js";
3
3
  export interface SessionRecord {
4
4
  id: string;
5
5
  name: string;
@@ -17,6 +17,7 @@ export interface ConnectionRecord {
17
17
  database_name: string;
18
18
  source: string;
19
19
  secret_env: string | null;
20
+ credential_ref: string | null;
20
21
  read_only: number;
21
22
  version: number;
22
23
  created_at: string;
@@ -25,6 +26,7 @@ export interface ProfileRecord {
25
26
  name: string;
26
27
  target: string | null;
27
28
  secret_env: string | null;
29
+ credential_ref: string | null;
28
30
  read_only: number;
29
31
  created_at: string;
30
32
  updated_at: string;
@@ -98,6 +100,7 @@ export interface HistoryRecord {
98
100
  timestamp: string;
99
101
  session_id: string;
100
102
  actor_id: string;
103
+ origin: CommandOrigin;
101
104
  command: string;
102
105
  sql: string | null;
103
106
  handle: string | null;
@@ -135,6 +138,7 @@ export declare class StateStore {
135
138
  name: string;
136
139
  target?: string;
137
140
  secretEnv?: string;
141
+ credentialRef?: string;
138
142
  readOnly: boolean;
139
143
  }): ProfileRecord;
140
144
  getProfile(name: string): ProfileRecord | undefined;
@@ -148,6 +152,7 @@ export declare class StateStore {
148
152
  databaseName: string;
149
153
  source: string;
150
154
  secretEnv?: string;
155
+ credentialRef?: string;
151
156
  readOnly: boolean;
152
157
  }): ConnectionRecord | undefined;
153
158
  getConnection(id: string): ConnectionRecord | undefined;
@@ -270,6 +275,7 @@ export declare class StateStore {
270
275
  addHistory(input: {
271
276
  sessionId: string;
272
277
  actorId: string;
278
+ origin?: CommandOrigin;
273
279
  command: string;
274
280
  sql?: string;
275
281
  handle?: string;
@@ -279,7 +285,7 @@ export declare class StateStore {
279
285
  errorCode?: string;
280
286
  id?: string;
281
287
  }): HistoryRecord;
282
- history(sessionId: string, limit: number): HistoryRecord[];
288
+ history(sessionId: string, limit: number, origin?: CommandOrigin): HistoryRecord[];
283
289
  recentOperations(sessionId: string, limit: number): OperationRecord[];
284
290
  knownResults(sessionId: string, limit: number): Array<ResultRecord & {
285
291
  alias: string | null;
package/dist/src/store.js CHANGED
@@ -235,9 +235,9 @@ export class StateStore {
235
235
  const timestamp = this.now().toISOString();
236
236
  this.db
237
237
  .prepare(`INSERT INTO profiles
238
- (name, target, secret_env, read_only, created_at, updated_at)
239
- VALUES (?, ?, ?, ?, ?, ?)`)
240
- .run(input.name, input.target ?? null, input.secretEnv ?? null, input.readOnly ? 1 : 0, timestamp, timestamp);
238
+ (name, target, secret_env, credential_ref, read_only, created_at, updated_at)
239
+ VALUES (?, ?, ?, ?, ?, ?, ?)`)
240
+ .run(input.name, input.target ?? null, input.secretEnv ?? null, input.credentialRef ?? null, input.readOnly ? 1 : 0, timestamp, timestamp);
241
241
  return this.getProfile(input.name);
242
242
  }
243
243
  getProfile(name) {
@@ -274,9 +274,9 @@ export class StateStore {
274
274
  this.db
275
275
  .prepare(`INSERT INTO connections
276
276
  (id, session_id, name, driver, database_name, source, secret_env,
277
- read_only, version, created_at)
278
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?)`)
279
- .run(id, input.sessionId, input.name, input.driver, input.databaseName, input.source, input.secretEnv ?? null, input.readOnly ? 1 : 0, timestamp);
277
+ credential_ref, read_only, version, created_at)
278
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?)`)
279
+ .run(id, input.sessionId, input.name, input.driver, input.databaseName, input.source, input.secretEnv ?? null, input.credentialRef ?? null, input.readOnly ? 1 : 0, timestamp);
280
280
  this.db
281
281
  .prepare(`UPDATE sessions
282
282
  SET active_connection_id = ?, updated_at = ?
@@ -415,6 +415,8 @@ export class StateStore {
415
415
  AND previous_connection.source = current_connection.source
416
416
  AND COALESCE(previous_connection.secret_env, '') =
417
417
  COALESCE(current_connection.secret_env, '')
418
+ AND COALESCE(previous_connection.credential_ref, '') =
419
+ COALESCE(current_connection.credential_ref, '')
418
420
  AND previous_connection.database_name = current_connection.database_name
419
421
  ORDER BY operations.created_at DESC LIMIT 1`)
420
422
  .get(input.connectionId, input.idempotencyKey);
@@ -795,10 +797,10 @@ export class StateStore {
795
797
  const id = input.id ?? this.nextId("cmd");
796
798
  this.db
797
799
  .prepare(`INSERT INTO history
798
- (id, timestamp, session_id, actor_id, command, sql, handle, executed,
799
- cached, success, error_code)
800
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
801
- .run(id, this.now().toISOString(), input.sessionId, input.actorId, input.command, boundedHistorySql(input.sql), input.handle ?? null, input.executed ? 1 : 0, input.cached ? 1 : 0, input.success ? 1 : 0, input.errorCode ?? null);
800
+ (id, timestamp, session_id, actor_id, origin, command, sql, handle,
801
+ executed, cached, success, error_code)
802
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
803
+ .run(id, this.now().toISOString(), input.sessionId, input.actorId, input.origin ?? "legacy", input.command, boundedHistorySql(input.sql), input.handle ?? null, input.executed ? 1 : 0, input.cached ? 1 : 0, input.success ? 1 : 0, input.errorCode ?? null);
802
804
  this.db
803
805
  .prepare(`DELETE FROM history
804
806
  WHERE rowid IN (
@@ -812,13 +814,13 @@ export class StateStore {
812
814
  .prepare("SELECT * FROM history WHERE id = ?")
813
815
  .get(id);
814
816
  }
815
- history(sessionId, limit) {
817
+ history(sessionId, limit, origin) {
816
818
  return this.db
817
819
  .prepare(`SELECT * FROM history
818
- WHERE session_id = ?
820
+ WHERE session_id = ?${origin === undefined ? "" : " AND origin = ?"}
819
821
  ORDER BY rowid DESC
820
822
  LIMIT ?`)
821
- .all(sessionId, limit);
823
+ .all(...(origin === undefined ? [sessionId, limit] : [sessionId, origin, limit]));
822
824
  }
823
825
  recentOperations(sessionId, limit) {
824
826
  return this.db
@@ -1,8 +1,15 @@
1
1
  export type SqlDriver = "sqlite" | "postgres" | "mysql";
2
2
  export type Driver = SqlDriver | "mongodb";
3
+ export type CommandOrigin = "legacy" | "user" | "model" | "system" | "api";
4
+ /** Trusted host metadata for one executeCommand call; never part of BatchCommand input. */
5
+ export interface CommandExecutionContext {
6
+ signal?: AbortSignal;
7
+ origin?: CommandOrigin;
8
+ }
3
9
  export type CredentialAccess = "read" | "write";
4
10
  export type CredentialOperation = "connect" | "query" | "inspect" | "plan" | "exec" | "apply" | "transaction.commit";
5
- export interface CredentialRequest {
11
+ export type CredentialSource = "secret_env" | "credential_ref";
12
+ interface CredentialRequestBase {
6
13
  reference: string;
7
14
  actorId: string;
8
15
  session: {
@@ -24,6 +31,12 @@ export interface CredentialRequest {
24
31
  readOnly: boolean;
25
32
  };
26
33
  }
34
+ /** Omitted source remains backward-compatible with legacy secret_env requests. */
35
+ export type CredentialRequest = CredentialRequestBase & ({
36
+ source?: "secret_env";
37
+ } | {
38
+ source: "credential_ref";
39
+ });
27
40
  export type CredentialResolver = (request: CredentialRequest) => string | undefined | Promise<string | undefined>;
28
41
  export type StateConfidence = "authoritative" | "transaction_snapshot" | "database_reported" | "local" | "ttl_based" | "unknown";
29
42
  export interface Warning {
@@ -64,6 +77,7 @@ export interface HistoryEntry {
64
77
  timestamp: string;
65
78
  session_id: string;
66
79
  actor_id: string;
80
+ origin: CommandOrigin;
67
81
  command: string;
68
82
  sql: string | null;
69
83
  handle: string | null;
@@ -184,6 +198,9 @@ export interface ExecutionOptions {
184
198
  timeoutMs?: number;
185
199
  signal?: AbortSignal;
186
200
  }
201
+ export interface HistoryOptions {
202
+ origin?: CommandOrigin;
203
+ }
187
204
  export interface StateQLOptions extends ExecutionOptions {
188
205
  home?: string;
189
206
  session?: string;
@@ -229,10 +246,12 @@ export interface ConnectOptions extends ExecutionOptions {
229
246
  readOnly?: boolean;
230
247
  secretEnv?: string;
231
248
  profile?: string;
249
+ credentialRef?: string;
232
250
  }
233
251
  export interface ProfileOptions {
234
252
  readOnly?: boolean;
235
253
  secretEnv?: string;
254
+ credentialRef?: string;
236
255
  }
237
256
  export interface RowsOptions {
238
257
  offset?: number;
@@ -263,6 +282,7 @@ export interface BatchCommand {
263
282
  cache?: "auto" | "bypass" | "require";
264
283
  read_only?: boolean;
265
284
  secret_env?: string;
285
+ credential_ref?: string;
266
286
  profile?: string;
267
287
  replay?: boolean;
268
288
  idempotency_key?: string;
@@ -272,11 +292,14 @@ export interface BatchCommand {
272
292
  limit?: number;
273
293
  isolation?: string;
274
294
  timeout_ms?: number;
295
+ /** Retrieval filter for the history command; does not attribute this command. */
296
+ history_origin?: CommandOrigin;
275
297
  scope?: "expired" | "results" | "history" | "all";
276
298
  }
277
299
  export interface BatchOptions {
278
300
  continueOnError?: boolean;
279
301
  maxCommands?: number;
302
+ executionContext?: CommandExecutionContext;
280
303
  }
281
304
  export interface Column {
282
305
  name: string;
@@ -298,6 +321,7 @@ export interface ProfileData {
298
321
  profile: string;
299
322
  target: string | null;
300
323
  secret_env: string | null;
324
+ credential_ref: string | null;
301
325
  read_only: boolean;
302
326
  }
303
327
  export interface ProfilesData {
@@ -521,3 +545,4 @@ export interface CloseSessionData {
521
545
  session_id: string;
522
546
  state: string;
523
547
  }
548
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fadhilp/stateql",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "Stateful, agent-oriented database CLI for safe result reuse",
5
5
  "repository": {
6
6
  "type": "git",