@fadhilp/stateql 0.10.0 → 0.11.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
@@ -7,7 +7,7 @@ reusable and operations traceable across commands.
7
7
 
8
8
  StateQL is built around durable handles:
9
9
 
10
- 1. Run a query and receive a result handle such as `q_1`.
10
+ 1. Run a query and receive a result handle such as `q_k7m2v5x9c3d6f8h4j2n7p5r9tw`.
11
11
  2. Reuse, filter, page, count, alias, or export that stored result without
12
12
  rerunning the original SQL.
13
13
  3. Use operation, plan, and transaction handles to inspect and control writes.
@@ -40,25 +40,25 @@ Parameters keep values separate from SQL. `ORDER BY` makes paging stable, and
40
40
  one-line JSON:
41
41
 
42
42
  ```json
43
- {"ok":true,"handle":"q_1","rows":[{"id":7,"name":"Ada","email":"ada@example.com"},{"id":12,"name":"Grace","email":"grace@example.com"},{"id":18,"name":"Linus","email":"linus@kernel.org"}],"truncated":false,"cached":false,"total":3,"next_offset":null}
43
+ {"ok":true,"handle":"q_k7m2v5x9c3d6f8h4j2n7p5r9tw","rows":[{"id":7,"name":"Ada","email":"ada@example.com"},{"id":12,"name":"Grace","email":"grace@example.com"},{"id":18,"name":"Linus","email":"linus@kernel.org"}],"truncated":false,"cached":false,"total":3,"next_offset":null}
44
44
  ```
45
45
 
46
- `q_1` is a durable snapshot. Filter it locally without accessing the original
46
+ `q_k7m2v5x9c3d6f8h4j2n7p5r9tw` is a durable snapshot. Filter it locally without accessing the original
47
47
  database:
48
48
 
49
49
  ```bash
50
- stql filter q_1 "email LIKE ?" --param "%@example.com"
50
+ stql filter q_k7m2v5x9c3d6f8h4j2n7p5r9tw "email LIKE ?" --param "%@example.com"
51
51
  ```
52
52
 
53
53
  ```json
54
- {"ok":true,"handle":"q_2","rows":[{"id":7,"name":"Ada","email":"ada@example.com"},{"id":12,"name":"Grace","email":"grace@example.com"}],"truncated":false,"cached":false,"total":2,"next_offset":null}
54
+ {"ok":true,"handle":"q_z4n8c2v6b3m7k5j9h2g4f6d8sa","rows":[{"id":7,"name":"Ada","email":"ada@example.com"},{"id":12,"name":"Grace","email":"grace@example.com"}],"truncated":false,"cached":false,"total":2,"next_offset":null}
55
55
  ```
56
56
 
57
57
  The filtered snapshot receives its own handle. Give it a readable alias, page
58
58
  through it, inspect its count, or export it without rerunning SQL:
59
59
 
60
60
  ```bash
61
- stql alias set example-users q_2
61
+ stql alias set example-users q_z4n8c2v6b3m7k5j9h2g4f6d8sa
62
62
  stql rows example-users --offset 0 --limit 1
63
63
  stql rows example-users --offset 1 --limit 1
64
64
  stql count example-users
@@ -68,16 +68,18 @@ stql export example-users --output example-users.csv --format csv
68
68
  Example first page:
69
69
 
70
70
  ```json
71
- {"ok":true,"handle":"q_2","rows":[{"id":7,"name":"Ada","email":"ada@example.com"}],"total":2,"truncated":true,"next_offset":1}
71
+ {"ok":true,"handle":"q_z4n8c2v6b3m7k5j9h2g4f6d8sa","rows":[{"id":7,"name":"Ada","email":"ada@example.com"}],"total":2,"truncated":true,"next_offset":1}
72
72
  ```
73
73
 
74
- Running the same normalized query with the same parameters reuses `q_1` while
74
+ Running the same normalized query with the same parameters reuses `q_k7m2v5x9c3d6f8h4j2n7p5r9tw` while
75
75
  its cache entry is valid. Use `--cache bypass` when a fresh read is required.
76
76
 
77
77
  ## Connections and profiles
78
78
 
79
79
  A connection accepts exactly one source: a direct target, `--env`,
80
- `--credential-ref`, or `--profile`.
80
+ `--credential-ref`, or `--profile`. Library and batch callers may additionally
81
+ attach `passwordRef`/`password_ref` to a literal password-free remote target;
82
+ it is not a fourth source.
81
83
 
82
84
  ```bash
83
85
  stql connect <sqlite-path|postgres-url|mysql-url|mongodb-url> [--name NAME] [--read-write]
@@ -108,16 +110,18 @@ export SQLITE_DATABASE='sqlite:./app.sqlite'
108
110
  stql connect --env SQLITE_DATABASE --name local --read-only
109
111
  ```
110
112
 
111
- StateQL stores no PostgreSQL, MySQL, or MongoDB password. Credential-bearing
112
- URLs must be supplied through `--env`. SQLite paths remain persisted as
113
- connection metadata.
113
+ StateQL stores no PostgreSQL, MySQL, MongoDB, or Redis password.
114
+ Credential-bearing URLs must be supplied through `--env` or an opaque full-URL
115
+ credential reference. SQLite paths remain persisted as connection metadata.
114
116
 
115
117
  ### Local profiles
116
118
 
117
119
  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.
120
+ opaque credential reference together with read-only policy. A remote literal
121
+ target may additionally store a `password_ref`; SQLite, environment-backed, and
122
+ full-URL `credential_ref` profiles cannot. Credential values are never stored.
123
+ Profiles persist under `STQL_HOME` with other StateQL metadata, and list/show
124
+ responses include nullable `credential_ref` and `password_ref` fields.
121
125
 
122
126
  ```bash
123
127
  stql profile add local ./app.sqlite --read-write
@@ -134,6 +138,21 @@ apply environment-variable syntax or normalization to them. They can only be
134
138
  resolved by a trusted host `CredentialResolver`, so the standalone CLI may
135
139
  store them in profiles but cannot connect with them.
136
140
 
141
+ Library callers can keep nonsecret endpoint, username, database, TLS, and CA
142
+ options in the literal URL while resolving only its password:
143
+
144
+ ```ts
145
+ await stateql.connect(
146
+ "postgres://app@db.example/app?sslmode=verify-full&sslrootcert=/etc/app-ca.pem",
147
+ { passwordRef: "vault://database/app/password", readOnly: true },
148
+ );
149
+ ```
150
+
151
+ The same field is accepted by `addProfile`, `updateProfile`, and batch
152
+ `connect`/`profile.add`/`profile.update` commands (snake case in batch input).
153
+ Targets with an embedded password or query parameters that override endpoint or
154
+ credential fields are rejected before credential resolution or driver access.
155
+
137
156
  A bare connection target matching a profile name resolves to that profile;
138
157
  otherwise it remains a path or database URL.
139
158
 
@@ -473,9 +492,21 @@ for user confirmation before changing membership or the shared connection.
473
492
 
474
493
  ### Harness credential resolution
475
494
 
476
- Library integrations can resolve environment-variable names or opaque
477
- credential references through a trusted approval or secret-storage layer
478
- instead of mutating `process.env`:
495
+ Library integrations can resolve environment-variable names, opaque full-URL
496
+ credential references, or password-only references through a trusted approval
497
+ or secret-storage layer instead of mutating `process.env`:
498
+
499
+ Integrations pinned to an older published package should gate setup before
500
+ sending `password_ref`:
501
+
502
+ ```ts
503
+ if ((StateQL.passwordReferenceVersion ?? 0) < 1) {
504
+ throw new Error("Installed StateQL does not support password references.");
505
+ }
506
+ ```
507
+
508
+ `passwordReferenceVersion = 1` guarantees the password-only resolver request,
509
+ validation, persistence, reconnect, and redaction contract documented below.
479
510
 
480
511
  ```ts
481
512
  import {
@@ -512,27 +543,31 @@ Credential resolution has its own two-minute default deadline
512
543
  The database-operation timeout begins after a credential is resolved.
513
544
 
514
545
  When no custom resolver is configured, StateQL reads only `secret_env`
515
- references from `process.env`; `credential_ref` never falls back to the
516
- environment. A configured resolver is authoritative for both sources: returning
517
- `undefined` produces `CREDENTIAL_UNAVAILABLE` and never falls back to the
518
- process environment. Resolver requests include `source` (`secret_env` or
519
- `credential_ref`) while retaining `reference`; source may be omitted only on
520
- legacy secret-environment request objects. Resolvers may throw
521
- `CredentialResolutionError` with `denied`, `cancelled`, `timeout`, or
522
- `unavailable` to produce controlled, secret-free failures. Unknown resolver
523
- errors are replaced with a generic `CREDENTIAL_RESOLUTION_FAILED` response.
546
+ references from `process.env`; `credential_ref` and `password_ref` never fall
547
+ back to the environment. A configured resolver is authoritative for all
548
+ sources: returning `undefined` produces `CREDENTIAL_UNAVAILABLE` and never falls
549
+ back to the process environment. Resolver requests retain `reference` and
550
+ include `source` (`secret_env`, `credential_ref`, or `password_ref`); source may
551
+ be omitted only on legacy secret-environment request objects. A `password_ref`
552
+ request additionally includes the exact password-free effective `target`.
553
+ Resolvers may throw `CredentialResolutionError` with `denied`, `cancelled`,
554
+ `timeout`, or `unavailable` to produce controlled, secret-free failures. Unknown
555
+ resolver errors are replaced with a generic `CREDENTIAL_RESOLUTION_FAILED`
556
+ response.
524
557
 
525
558
  StateQL calls the resolver only immediately before database access, after SQL
526
559
  safety and duplicate checks. Requests contain actor and session identity, the
527
560
  operation's effective read/write access, an abort signal, and sanitized
528
561
  connection metadata.
529
562
 
530
- Returned values must be complete PostgreSQL, MySQL, MongoDB, or Redis URLs, or
531
- explicit `sqlite:` sources. StateQL validates the source and its stored driver before
532
- adapter construction and normalizes SQLite paths. Credential-bearing database
533
- URLs are redacted before connection metadata is persisted and never enter
534
- history, snapshots, cache keys, or responses. SQLite paths remain persisted
535
- connection metadata, as they are for direct SQLite connections.
563
+ For `secret_env` and `credential_ref`, returned values must be complete
564
+ PostgreSQL, MySQL, MongoDB, or Redis URLs, or explicit `sqlite:` sources. For
565
+ `password_ref`, the resolver returns only the password; an explicit empty string
566
+ is a resolved password, while `undefined` fails closed. StateQL percent-encodes
567
+ and injects only that password into the original target for adapter use, leaving
568
+ all nonsecret URL/TLS/CA bytes unchanged. It persists only the original target
569
+ and reference. Resolved credentials never enter connection metadata, history,
570
+ snapshots, cache keys, responses, or stored errors.
536
571
 
537
572
  Harnesses remain responsible for approval policy, binding lifetime, revocation,
538
573
  and keeping values out of their own logs and model-visible data.
@@ -547,13 +582,26 @@ retry.
547
582
  ### Result identities and aliases
548
583
 
549
584
  Every materialized SQL, MongoDB, Redis, table, or derived result keeps its
550
- immutable `q_*` `result_id` and receives a cryptographically random 10-character
551
- lowercase base32 `display_alias`. `ResultData.alias` normally equals that alias.
552
- When a batch command supplies `as`, `alias` remains the caller alias for backward
553
- compatibility while `display_alias` remains canonical. Generated aliases are
554
- session-scoped, allocated atomically with the result, stable on cache reuse, and
555
- cannot be reassigned by `setAlias`; explicit aliases and all old handles continue
556
- to resolve.
585
+ immutable canonical `q_*` `result_id`. New canonical resource IDs use a
586
+ cryptographically random 26-character lowercase base32 suffix; existing
587
+ incremental IDs such as `q_121` remain valid and are not rewritten. Results also
588
+ receive a random 10-character lowercase base32 `display_alias`.
589
+ `ResultData.alias` normally equals that alias. When a batch command supplies
590
+ `as`, `alias` remains the caller alias for backward compatibility while
591
+ `display_alias` remains canonical. Generated aliases are session-scoped,
592
+ allocated atomically with the result, stable on cache reuse, and cannot be
593
+ reassigned by `setAlias`; explicit aliases and all old handles continue to
594
+ resolve.
595
+
596
+ Connections likewise retain canonical `conn_*` IDs with random 26-character
597
+ suffixes and receive persistent random 10-character lowercase base32 aliases,
598
+ exposed as `alias` and `display_alias` by `connect()` and
599
+ `snapshot().connection` (optional in snapshot types for older producers).
600
+ Connection aliases are unique within the state store, allocated atomically with
601
+ the connection, and backfilled for existing records on startup. They survive
602
+ reopening; reconnecting creates a new ID and alias. They are display identities
603
+ only, separate from result aliases; internal references and lookups continue to
604
+ use canonical connection IDs.
557
605
 
558
606
  ### Safe profile updates
559
607
 
@@ -562,19 +610,33 @@ updateProfile(name, {
562
610
  target?: string | null,
563
611
  secretEnv?: string | null,
564
612
  credentialRef?: string | null,
613
+ passwordRef?: string | null,
565
614
  readOnly?: boolean,
566
615
  })
567
616
  ```
568
617
 
569
- Omitting all source fields keeps the existing source. Supplying any source field
570
- replaces the source atomically: exactly one non-null source is required and the
571
- other source columns are cleared. Direct non-SQLite URLs containing credentials
572
- or secret-like query parameters are rejected. `profile.list/show/update` return
573
- only `{profile,target,secret_env,credential_ref,read_only}`. `target` is therefore
574
- a normalized SQLite path or a secret-free URL; reference-backed profiles expose
575
- only the environment-variable name or opaque credential reference, never a
576
- resolved value. Profile changes affect subsequent `connect` calls and do not
577
- silently mutate an already-open connection.
618
+ Omitting all source and password-reference fields keeps the existing source and
619
+ adjunct reference. Supplying any source field replaces the source atomically:
620
+ exactly one non-null source is required and the other source columns are
621
+ cleared. An omitted `passwordRef` is preserved when the existing literal target
622
+ is unchanged; changing the target clears it unless the update explicitly supplies
623
+ a replacement. Replacing the source with `secretEnv` or `credentialRef` clears
624
+ it. An explicit non-null password reference combined with either
625
+ reference-backed source is rejected. Direct URLs
626
+ with embedded passwords or secret-like query parameters are rejected.
627
+ `profile.list/show/update` return only
628
+ `{profile,target,secret_env,credential_ref,password_ref,read_only}`. Profile
629
+ changes affect subsequent `connect` calls and do not silently mutate an
630
+ already-open connection.
631
+
632
+ The `password_refs_v1` migration adds nullable `password_ref` columns to both
633
+ profiles and connections and enforces that they accompany only literal target
634
+ configuration. It rejects incompatible profile schemas/rows instead of dropping
635
+ references. Downgrading a state home containing password references is
636
+ unsupported: older binaries do not resolve this source and may attempt the
637
+ password-free target using ambient/trust authentication; constraint-protected
638
+ source replacements may also fail. Use the same or newer StateQL binary, or
639
+ explicitly clear all password references before downgrade.
578
640
 
579
641
  ### Bounded catalog
580
642
 
@@ -4,12 +4,18 @@ 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
6
  export declare function redisDatabaseName(target: string): string;
7
- export declare function credentialSource(value: string, expectedDriver?: Driver, referenceSource?: CredentialSource): {
7
+ export declare function credentialSource(value: string, expectedDriver?: Driver, referenceSource?: Exclude<CredentialSource, "password_ref">): {
8
8
  driver: Driver;
9
9
  source: string;
10
10
  };
11
11
  export declare function normalizeSqliteSource(target: string): string;
12
12
  export declare function databaseUrlHasSecret(target: string): boolean;
13
+ export declare function validatePasswordReferenceTarget(target: string): Driver;
14
+ /** Injects only password userinfo while retaining every nonsecret target byte. */
15
+ export declare function injectPassword(target: string, password: string): {
16
+ driver: Driver;
17
+ source: string;
18
+ };
13
19
  export declare function version(connection: ConnectionRecord): string;
14
20
  export declare function confidence(connection: ConnectionRecord): StateConfidence;
15
21
  export declare function validateProfileName(name: string): void;
@@ -7,6 +7,7 @@ export function databaseIdentity(connection) {
7
7
  source: connection.source,
8
8
  secretEnvironment: connection.secret_env,
9
9
  credentialReference: connection.credential_ref,
10
+ passwordReference: connection.password_ref,
10
11
  };
11
12
  }
12
13
  export function detectDriver(target) {
@@ -24,22 +25,26 @@ export function detectDriver(target) {
24
25
  return "sqlite";
25
26
  }
26
27
  export function mongoDatabaseName(target) {
27
- let url;
28
+ let pathname;
28
29
  try {
29
- url = new URL(target);
30
+ const url = new URL(target);
31
+ if (!["mongodb:", "mongodb+srv:"].includes(url.protocol.toLowerCase()) || !url.hostname)
32
+ throw new Error();
33
+ pathname = url.pathname;
30
34
  }
31
35
  catch {
32
- throw new StateQLError("INVALID_COMMAND", "Invalid MongoDB URL.");
33
- }
34
- if (!["mongodb:", "mongodb+srv:"].includes(url.protocol.toLowerCase()) ||
35
- !url.hostname) {
36
- throw new StateQLError("INVALID_COMMAND", "Invalid MongoDB URL.");
36
+ const match = /^mongodb(?:\+srv)?:\/\/([^/?#]+)(\/[^?#]*)?(?:[?#]|$)/i.exec(target);
37
+ if (!match)
38
+ throw new StateQLError("INVALID_COMMAND", "Invalid MongoDB URL.");
39
+ const authority = match[1];
40
+ const at = authority.lastIndexOf("@");
41
+ validateMongoHosts(authority.slice(at + 1), /^mongodb\+srv:/i.test(target));
42
+ pathname = match[2] ?? "";
37
43
  }
38
44
  try {
39
- const database = decodeURIComponent(url.pathname.replace(/^\//, ""));
40
- if (database && !database.includes("/") && !database.includes("\0")) {
45
+ const database = decodeURIComponent(pathname.replace(/^\//, ""));
46
+ if (database && !database.includes("/") && !database.includes("\0"))
41
47
  return database;
42
- }
43
48
  }
44
49
  catch {
45
50
  // Report malformed escaping as an invalid explicit database name.
@@ -98,8 +103,15 @@ export function normalizeSqliteSource(target) {
98
103
  }
99
104
  export function databaseUrlHasSecret(target) {
100
105
  try {
101
- const url = new URL(target);
102
- return (Boolean(url.username) ||
106
+ const match = /^([a-z][a-z\d+.-]*:\/\/)([^/?#]*)([\s\S]*)$/i.exec(target);
107
+ if (!match)
108
+ throw new Error();
109
+ const authority = match[2];
110
+ const at = authority.lastIndexOf("@");
111
+ const url = /^mongodb(?:\+srv)?:\/\//i.test(target)
112
+ ? new URL(`http://placeholder${match[3]}`)
113
+ : new URL(target);
114
+ return ((at >= 0 && authority.slice(0, at).includes(":")) ||
103
115
  Boolean(url.password) ||
104
116
  [...url.searchParams.keys()].some((key) => /pass|token|secret|private[_-]?key|api[_-]?key/i.test(key)));
105
117
  }
@@ -107,6 +119,99 @@ export function databaseUrlHasSecret(target) {
107
119
  throw new StateQLError("INVALID_COMMAND", "Invalid database URL.");
108
120
  }
109
121
  }
122
+ const AMBIGUOUS_PASSWORD_TARGET_PARAMETERS = new Set([
123
+ "host",
124
+ "hostaddr",
125
+ "hostname",
126
+ "port",
127
+ "socket",
128
+ "socketpath",
129
+ "user",
130
+ "username",
131
+ ]);
132
+ export function validatePasswordReferenceTarget(target) {
133
+ if (/\s/.test(target)) {
134
+ throw new StateQLError("INVALID_COMMAND", "Password-reference target must not contain whitespace.");
135
+ }
136
+ const driver = detectDriver(target);
137
+ if (driver === "sqlite") {
138
+ throw new StateQLError("INVALID_COMMAND", "Password references require a remote PostgreSQL, MySQL, MongoDB, or Redis target.");
139
+ }
140
+ const authorityMatch = /^([a-z][a-z\d+.-]*:\/\/)([^/?#]*)([\s\S]*)$/i.exec(target);
141
+ if (!authorityMatch) {
142
+ throw new StateQLError("INVALID_COMMAND", "Password-reference target must be a valid remote database URL.");
143
+ }
144
+ const authority = authorityMatch[2];
145
+ const at = authority.lastIndexOf("@");
146
+ const userinfo = at >= 0 ? authority.slice(0, at) : "";
147
+ if (userinfo.includes(":") || userinfo.includes("@")) {
148
+ throw new StateQLError("PERMISSION_DENIED", "Password-reference target must not contain an embedded password.");
149
+ }
150
+ if (/%(?![0-9a-f]{2})/i.test(userinfo)) {
151
+ throw new StateQLError("INVALID_COMMAND", "Password-reference target contains malformed username escaping.");
152
+ }
153
+ try {
154
+ if (userinfo)
155
+ decodeURIComponent(userinfo);
156
+ }
157
+ catch {
158
+ throw new StateQLError("INVALID_COMMAND", "Password-reference target contains malformed username escaping.");
159
+ }
160
+ let url;
161
+ try {
162
+ if (driver === "mongodb") {
163
+ validateMongoHosts(authority.slice(at + 1), /^mongodb\+srv:/i.test(target));
164
+ url = new URL(`http://placeholder${authorityMatch[3]}`);
165
+ }
166
+ else {
167
+ url = new URL(target);
168
+ if (!url.hostname || url.password)
169
+ throw new Error();
170
+ }
171
+ }
172
+ catch {
173
+ throw new StateQLError("INVALID_COMMAND", "Password-reference target must be a valid remote database URL.");
174
+ }
175
+ for (const key of url.searchParams.keys()) {
176
+ const normalized = key.toLowerCase().replaceAll("_", "").replaceAll("-", "");
177
+ if (AMBIGUOUS_PASSWORD_TARGET_PARAMETERS.has(normalized) ||
178
+ /pass|token|secret|privatekey|apikey/.test(normalized)) {
179
+ throw new StateQLError("PERMISSION_DENIED", "Password-reference target must not contain endpoint or credential query overrides.");
180
+ }
181
+ }
182
+ if (driver === "mongodb" && !userinfo) {
183
+ throw new StateQLError("INVALID_COMMAND", "MongoDB password-reference targets require a username.");
184
+ }
185
+ return driver;
186
+ }
187
+ function validateMongoHosts(hosts, srv) {
188
+ const entries = hosts.split(",");
189
+ if (!hosts || (srv && entries.length !== 1) || entries.some((host) => {
190
+ if (srv)
191
+ return !/^[^:[\],%]+$/u.test(host);
192
+ return !(/^[^:[\],%]+(?::\d+)?$/u.test(host) || /^\[[0-9a-f:.]+\](?::\d+)?$/iu.test(host));
193
+ })) {
194
+ throw new StateQLError("INVALID_COMMAND", "Password-reference target must be a valid remote database URL.");
195
+ }
196
+ }
197
+ /** Injects only password userinfo while retaining every nonsecret target byte. */
198
+ export function injectPassword(target, password) {
199
+ const driver = validatePasswordReferenceTarget(target);
200
+ let encoded;
201
+ try {
202
+ encoded = encodeURIComponent(password);
203
+ }
204
+ catch {
205
+ throw new StateQLError("CREDENTIAL_RESOLUTION_FAILED", "Resolved password could not be encoded.");
206
+ }
207
+ const match = /^([a-z][a-z\d+.-]*:\/\/)([^/?#]*)([\s\S]*)$/i.exec(target);
208
+ const authority = match[2];
209
+ const at = authority.lastIndexOf("@");
210
+ const injectedAuthority = at >= 0
211
+ ? `${authority.slice(0, at)}:${encoded}${authority.slice(at)}`
212
+ : `:${encoded}@${authority}`;
213
+ return { driver, source: `${match[1]}${injectedAuthority}${match[3]}` };
214
+ }
110
215
  export function version(connection) {
111
216
  return `sv_${connection.version}`;
112
217
  }
@@ -115,6 +115,22 @@ const MIGRATIONS = [
115
115
  requireIndexes(db, ["history_session_category"]);
116
116
  },
117
117
  },
118
+ {
119
+ name: "connection_aliases_v1",
120
+ apply(db) {
121
+ addColumn(db, "connections", "alias", "TEXT");
122
+ db.exec("CREATE UNIQUE INDEX IF NOT EXISTS connections_alias ON connections(alias)");
123
+ },
124
+ validate(db) {
125
+ requireColumns(db, "connections", ["alias"]);
126
+ requireIndexes(db, ["connections_alias"]);
127
+ },
128
+ },
129
+ {
130
+ name: "password_refs_v1",
131
+ apply: migratePasswordRefs,
132
+ validate: validatePasswordRefs,
133
+ },
118
134
  ];
119
135
  export function runMigrations(db, now) {
120
136
  db.exec(`
@@ -353,7 +369,11 @@ function validateSharedSessionActors(db) {
353
369
  function migrateCredentialRefs(db) {
354
370
  const profileColumns = new Set(db.prepare("PRAGMA table_info(profiles)").all().map((column) => column.name));
355
371
  const hasCredentialRef = profileColumns.has("credential_ref");
372
+ const hasPasswordRef = profileColumns.has("password_ref");
356
373
  if (!profileCredentialRefSchemaCurrent(db)) {
374
+ if (hasPasswordRef) {
375
+ throw new Error("State migration cannot safely rebuild a profile schema that already contains password references.");
376
+ }
357
377
  const missingSources = db.prepare(`SELECT COUNT(*) AS count FROM profiles
358
378
  WHERE target IS NULL AND secret_env IS NULL${hasCredentialRef ? " AND credential_ref IS NULL" : ""}`).get();
359
379
  if (missingSources.count) {
@@ -413,6 +433,117 @@ function profileCredentialRefSchemaCurrent(db) {
413
433
  const row = db.prepare("SELECT sql FROM sqlite_schema WHERE type = 'table' AND name = 'profiles'").get();
414
434
  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));
415
435
  }
436
+ function migratePasswordRefs(db) {
437
+ const profileColumns = tableColumns(db, "profiles");
438
+ const profilePasswordRef = profileColumns.has("password_ref") ? "password_ref" : "NULL";
439
+ if (!profilePasswordRefSchemaCurrent(db)) {
440
+ const invalid = db.prepare(`SELECT COUNT(*) AS count FROM profiles
441
+ WHERE ${profilePasswordRef} IS NOT NULL AND target IS NULL`).get();
442
+ if (invalid.count) {
443
+ throw new Error("State migration found a password reference without a target profile source.");
444
+ }
445
+ db.exec(`
446
+ ALTER TABLE profiles RENAME TO profiles_legacy_password_refs_v1;
447
+ CREATE TABLE profiles (
448
+ name TEXT PRIMARY KEY,
449
+ target TEXT,
450
+ secret_env TEXT,
451
+ credential_ref TEXT,
452
+ password_ref TEXT,
453
+ read_only INTEGER NOT NULL,
454
+ created_at TEXT NOT NULL,
455
+ updated_at TEXT NOT NULL,
456
+ CHECK (
457
+ (target IS NOT NULL) +
458
+ (secret_env IS NOT NULL) +
459
+ (credential_ref IS NOT NULL) = 1
460
+ ),
461
+ CHECK (password_ref IS NULL OR target IS NOT NULL)
462
+ );
463
+ INSERT INTO profiles
464
+ (name, target, secret_env, credential_ref, password_ref, read_only, created_at, updated_at)
465
+ SELECT name, target, secret_env, credential_ref, ${profilePasswordRef}, read_only, created_at, updated_at
466
+ FROM profiles_legacy_password_refs_v1;
467
+ DROP TABLE profiles_legacy_password_refs_v1;
468
+ `);
469
+ }
470
+ const connectionColumns = tableColumns(db, "connections");
471
+ const connectionPasswordRef = connectionColumns.has("password_ref") ? "password_ref" : "NULL";
472
+ if (!connectionPasswordRefSchemaCurrent(db)) {
473
+ const invalid = db.prepare(`SELECT COUNT(*) AS count FROM connections
474
+ WHERE ${connectionPasswordRef} IS NOT NULL
475
+ AND (secret_env IS NOT NULL OR credential_ref IS NOT NULL OR
476
+ driver NOT IN ('postgres', 'mysql', 'mongodb', 'redis'))`).get();
477
+ if (invalid.count) {
478
+ throw new Error("State migration found a password reference on a non-target connection source.");
479
+ }
480
+ db.exec(`
481
+ ALTER TABLE connections RENAME TO connections_legacy_password_refs_v1;
482
+ CREATE TABLE connections (
483
+ id TEXT PRIMARY KEY,
484
+ session_id TEXT NOT NULL,
485
+ name TEXT NOT NULL,
486
+ driver TEXT NOT NULL,
487
+ database_name TEXT NOT NULL,
488
+ source TEXT NOT NULL,
489
+ secret_env TEXT,
490
+ credential_ref TEXT,
491
+ password_ref TEXT,
492
+ read_only INTEGER NOT NULL,
493
+ version INTEGER NOT NULL,
494
+ created_at TEXT NOT NULL,
495
+ alias TEXT,
496
+ CHECK (
497
+ password_ref IS NULL OR
498
+ (secret_env IS NULL AND credential_ref IS NULL AND
499
+ driver IN ('postgres', 'mysql', 'mongodb', 'redis'))
500
+ ),
501
+ FOREIGN KEY(session_id) REFERENCES sessions(id)
502
+ );
503
+ INSERT INTO connections
504
+ (id, session_id, name, driver, database_name, source, secret_env,
505
+ credential_ref, password_ref, read_only, version, created_at, alias)
506
+ SELECT id, session_id, name, driver, database_name, source, secret_env,
507
+ credential_ref, ${connectionPasswordRef}, read_only, version, created_at, alias
508
+ FROM connections_legacy_password_refs_v1;
509
+ DROP TABLE connections_legacy_password_refs_v1;
510
+ CREATE UNIQUE INDEX connections_alias ON connections(alias);
511
+ `);
512
+ }
513
+ }
514
+ function validatePasswordRefs(db) {
515
+ requireColumns(db, "profiles", ["password_ref"]);
516
+ requireColumns(db, "connections", ["password_ref"]);
517
+ if (!profilePasswordRefSchemaCurrent(db) || !connectionPasswordRefSchemaCurrent(db)) {
518
+ throw new Error("State migration did not enforce password-reference source constraints.");
519
+ }
520
+ const invalidProfiles = db.prepare(`SELECT COUNT(*) AS count FROM profiles
521
+ WHERE password_ref IS NOT NULL AND target IS NULL`).get();
522
+ const invalidConnections = db.prepare(`SELECT COUNT(*) AS count FROM connections
523
+ WHERE password_ref IS NOT NULL
524
+ AND (secret_env IS NOT NULL OR credential_ref IS NOT NULL OR
525
+ driver NOT IN ('postgres', 'mysql', 'mongodb', 'redis'))`).get();
526
+ if (invalidProfiles.count || invalidConnections.count) {
527
+ throw new Error("State migration left an invalid password-reference source.");
528
+ }
529
+ }
530
+ function profilePasswordRefSchemaCurrent(db) {
531
+ const sql = tableSql(db, "profiles");
532
+ return profileCredentialRefSchemaCurrent(db) &&
533
+ /CHECK\s*\(\s*password_ref IS NULL OR target IS NOT NULL\s*\)/i.test(sql);
534
+ }
535
+ function connectionPasswordRefSchemaCurrent(db) {
536
+ return /CHECK\s*\(\s*password_ref IS NULL OR\s*\(secret_env IS NULL AND credential_ref IS NULL AND\s*driver IN \('postgres', 'mysql', 'mongodb', 'redis'\)\)\s*\)/i
537
+ .test(tableSql(db, "connections"));
538
+ }
539
+ function tableSql(db, table) {
540
+ const row = db.prepare("SELECT sql FROM sqlite_schema WHERE type = 'table' AND name = ?").get(table);
541
+ return row?.sql ?? "";
542
+ }
543
+ function tableColumns(db, table) {
544
+ return new Set(db.prepare(`PRAGMA table_info(${table})`).all()
545
+ .map((column) => column.name));
546
+ }
416
547
  function addColumn(db, table, column, definition) {
417
548
  const columns = db.prepare(`PRAGMA table_info(${table})`).all();
418
549
  if (!columns.some((candidate) => candidate.name === column)) {
@@ -14,6 +14,7 @@ export function profileData(profile) {
14
14
  target: profile.target,
15
15
  secret_env: profile.secret_env,
16
16
  credential_ref: profile.credential_ref,
17
+ password_ref: profile.password_ref,
17
18
  read_only: Boolean(profile.read_only),
18
19
  };
19
20
  }
@@ -1,6 +1,8 @@
1
1
  import { type TableChange } from "./table-editor.js";
2
2
  import type { ActorLinkData, ActorResolutionData, ActorsData, ActorUnlinkData, AliasData, ApplyData, BatchCommand, CommandExecutionContext, CommandOrigin, BatchOptions, CapabilitiesData, CatalogObject, DescribeObjectData, ListObjectsData, ListObjectsFilter, CloseSessionData, ColumnsData, CommitTransactionData, ConnectOptions, ConnectionData, CountData, DisconnectData, DoctorData, ExecData, ExecOptions, ExecutionOptions, ExportData, FilterOptions, HistoryData, HistoryOptions, OperationData, PlanData, PlanOptions, ProfileData, ProfilesData, ProfileOptions, ProfileUpdateOptions, MongoExecOptions, MongoPlanOptions, MongoQueryOptions, MongoReadCommand, MongoWriteCommand, RedisCommand, RedisExecOptions, RedisPlanOptions, RedisQueryOptions, PurgeData, QueryOptions, RemovedProfileData, Response, ResultData, RollbackTransactionData, RowsData, RowsOptions, Column, StateQLActorOptions, StateQLOptions, StateQLSnapshot, StateQLSnapshotOptions, StatusData, SessionData, SessionsData, SessionSummaryData, TransactionData } from "./types.js";
3
3
  export declare class StateQL {
4
+ /** Runtime contract marker for passwordRef/password_ref support. */
5
+ static readonly passwordReferenceVersion: 1;
4
6
  static forActor(options: StateQLActorOptions): StateQL;
5
7
  private readonly store;
6
8
  private readonly sessionName;