@fadhilp/stateql 0.11.0 → 0.12.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
@@ -216,6 +216,37 @@ Use `--params JSON` for a JSON array or named parameters. Use
216
216
  `--params-file FILE` when JSON is awkward to quote; `--params-file -` reads
217
217
  JSON from standard input.
218
218
 
219
+ ### PostgreSQL diagnostics and maintenance
220
+
221
+ Run PostgreSQL plans through `query`:
222
+
223
+ ```bash
224
+ stql query "EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) SELECT * FROM jobs WHERE id = 42"
225
+ ```
226
+
227
+ Plain `EXPLAIN` may plan a structurally validated `SELECT`, `INSERT`, `UPDATE`,
228
+ or `DELETE`. Because `EXPLAIN ANALYZE` executes its inner statement, StateQL
229
+ accepts only a validated read-only `SELECT`; `SELECT INTO`, writing CTEs, and
230
+ mutations are rejected. Diagnostics execute inside PostgreSQL `BEGIN READ ONLY`
231
+ and are never reused from cache. `--cache require` therefore returns
232
+ `CACHE_MISS` without executing the diagnostic.
233
+
234
+ `VACUUM`, `ANALYZE`, `REINDEX`, and `CLUSTER` are PostgreSQL maintenance writes:
235
+
236
+ ```bash
237
+ stql exec "VACUUM (ANALYZE) public.jobs" --allow-destructive
238
+ stql plan "REINDEX TABLE public.jobs" --allow-destructive
239
+ ```
240
+
241
+ They require a read-write connection and `--allow-destructive`, reject StateQL
242
+ parameters, and run as individually tracked autocommit operations. They cannot
243
+ be staged in a StateQL transaction. A timeout or cancellation after dispatch is
244
+ reported as `OUTCOME_UNKNOWN`; inspect database state before replaying it. Raw
245
+ `BEGIN`, `COMMIT`, `ROLLBACK`, savepoint, and other transaction-control SQL
246
+ remain unsupported—use `stql transaction` commands instead. See
247
+ [`SQL_COMMAND_ROADMAP.md`](SQL_COMMAND_ROADMAP.md) for the exact implemented
248
+ boundary and deferred command categories.
249
+
219
250
  ### Native MongoDB
220
251
 
221
252
  MongoDB commands use official Extended JSON (EJSON), so BSON values survive the
@@ -479,16 +510,40 @@ context as `options.executionContext` for all commands in that batch.
479
510
 
480
511
  ### Actor workspaces
481
512
 
482
- `StateQL.forActor(...)` resolves the actor's attached session directly from
483
- StateQL storage, avoiding a duplicate actor-to-session mapping in integrations.
484
- On first use, it creates a legacy-compatible session named after the actor. Use
485
- `new StateQL({ session, actor })` when the session is already known.
513
+ `StateQL.forWorkspace(...)` is a trusted-host primitive that atomically creates
514
+ or reopens a durable workspace, attaches the requested actor, and returns a
515
+ client bound to that actor:
516
+
517
+ ```ts
518
+ const stateql = StateQL.forWorkspace({
519
+ home: "./.stql",
520
+ workspace: "pylon-global",
521
+ actor: "pylon-session:abc123",
522
+ credentialResolver,
523
+ signal,
524
+ });
525
+ ```
486
526
 
487
- Membership is managed only through the library API, not batch commands:
488
- `linkActor(session, actorId)`, `unlinkActor(session, actorId)`,
489
- `listActors(session)`, and `resolveActor(actorId)`. An existing member must link
490
- an actor before that actor opens an existing workspace. Integrations should ask
491
- for user confirmation before changing membership or the shared connection.
527
+ Repeated opens of the same actor and workspace are idempotent. An actor already
528
+ attached elsewhere fails with a `StateQLError` whose code is
529
+ `PERMISSION_DENIED`; StateQL never moves or merges it. All actor options,
530
+ including limits, credential resolution, cancellation, `home`, and `now`, are
531
+ preserved. The workspace name also reserves a same-named actor identity for
532
+ legacy compatibility, so workspace and actor identifiers must be globally
533
+ collision-free. The returned client is still bound only to `actor`, preserving
534
+ plan, transaction, operation, and history ownership.
535
+
536
+ `StateQL.forActor(...)` retains its existing behavior: it resolves the actor's
537
+ attached session directly from StateQL storage and creates a legacy-compatible
538
+ session named after the actor on first use. Use `new StateQL({ session, actor })`
539
+ when the session and membership are already known.
540
+
541
+ Membership management and `forWorkspace` are library-only host capabilities,
542
+ not batch or CLI commands. Existing member-authorized management remains
543
+ available through `linkActor(session, actorId)`, `unlinkActor(session, actorId)`,
544
+ `listActors(session)`, and `resolveActor(actorId)`. Integrations should ask for
545
+ user confirmation before changing membership or the shared connection; a host
546
+ calling `forWorkspace` is responsible for authorizing that workspace access.
492
547
 
493
548
  ### Harness credential resolution
494
549
 
@@ -33,6 +33,7 @@ export interface Adapter {
33
33
  ping(): Promise<void>;
34
34
  read(sql: string, params: SqlParameters): Promise<ReadResult>;
35
35
  write(sql: string, params: SqlParameters, expectedRows?: 1): Promise<WriteResult>;
36
+ writeAutocommit?(sql: string, params: SqlParameters): Promise<WriteResult>;
36
37
  writeBatch(operations: BatchWriteOperation[], isolation: string): Promise<WriteResult[]>;
37
38
  signature(): Promise<string>;
38
39
  inspect(kind: string, table?: string): Promise<unknown>;
@@ -218,6 +218,12 @@ export function normalizePostgresConnectionString(source) {
218
218
  return source;
219
219
  }
220
220
  }
221
+ const POSTGRES_AUTOCOMMIT_STATEMENTS = new Set([
222
+ "vacuum",
223
+ "analyze",
224
+ "reindex",
225
+ "cluster",
226
+ ]);
221
227
  class PostgresAdapter {
222
228
  readOnly;
223
229
  context;
@@ -294,9 +300,36 @@ class PostgresAdapter {
294
300
  throw new AdapterWriteError(errorText(error), true);
295
301
  }
296
302
  }
303
+ async writeAutocommit(sql, params) {
304
+ if (this.readOnly)
305
+ throw new Error("Connection is read-only.");
306
+ let values;
307
+ try {
308
+ values = postgresParams(params);
309
+ await this.connect();
310
+ }
311
+ catch (error) {
312
+ if (error instanceof AdapterExecutionError)
313
+ throw error;
314
+ throw new AdapterWriteError(errorText(error), false);
315
+ }
316
+ try {
317
+ const result = await this.query(sql, values, true, false);
318
+ return { affectedRows: result.rowCount ?? 0 };
319
+ }
320
+ catch (error) {
321
+ if (error instanceof AdapterExecutionError)
322
+ throw error;
323
+ throw new AdapterWriteError(errorText(error), true);
324
+ }
325
+ }
297
326
  async writeBatch(operations, isolation) {
298
327
  if (this.readOnly)
299
328
  throw new Error("Connection is read-only.");
329
+ const unsupported = operations.find((operation) => POSTGRES_AUTOCOMMIT_STATEMENTS.has(operation.statement_type));
330
+ if (unsupported) {
331
+ throw new BatchWriteError(`PostgreSQL transactions cannot include ${unsupported.statement_type.toUpperCase()} maintenance statements.`, false);
332
+ }
300
333
  await this.connect();
301
334
  const level = isolation.toUpperCase();
302
335
  if (!POSTGRES_ISOLATION_LEVELS.has(level)) {
@@ -491,8 +524,8 @@ class PostgresAdapter {
491
524
  async setLocalDeadline() {
492
525
  await this.query(`SET LOCAL statement_timeout = ${remainingMilliseconds(this.context)}`, [], false);
493
526
  }
494
- async query(sql, params, outcomeUnknown) {
495
- throwIfStopped(this.context, outcomeUnknown);
527
+ async query(sql, params, outcomeUnknown, preDispatchOutcomeUnknown = outcomeUnknown) {
528
+ throwIfStopped(this.context, preDispatchOutcomeUnknown);
496
529
  try {
497
530
  return await withContext(this.client.query(sql, params), this.context, () => this.stop(), outcomeUnknown);
498
531
  }
@@ -2,4 +2,4 @@ export { StateQL } from "./stateql.js";
2
2
  export { CredentialResolutionError, StateQLError, exitCodeFor, } from "./errors.js";
3
3
  export type { CredentialResolutionFailure } from "./errors.js";
4
4
  export type { TableChange, TableIdentity, TableUpdate } from "./table-editor.js";
5
- export type { ActorData, ActorLinkData, ActorResolutionData, ActorsData, ActorUnlinkData, AliasData, ApplyData, BatchCommand, BatchCommandName, BatchOptions, CatalogObject, CatalogObjectKind, DescribeObjectData, ListObjectsData, ListObjectsFilter, 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, HistoryCategory, HistoryOptions, MongoAggregateOptions, MongoDocument, MongoFindOptions, MongoMutationOptions, MongoExecOptions, MongoPlanOptions, MongoQueryOptions, MongoReadCommand, MongoWriteCommand, MongoWriteOutcome, OperationData, PlanData, PlanOptions, ProfileData, ProfileOptions, ProfileUpdateOptions, ProfilesData, PurgeData, RedisCommand, RedisExecOptions, RedisPlanOptions, RedisQueryOptions, RedisWriteOutcome, QueryOptions, RecentOperationData, RecentResultData, RemovedProfileData, Response, ResultData, RollbackTransactionData, Row, RowsData, RowsOptions, SqlParameters, SqlDriver, StateQLActorOptions, StateQLOptions, StateConfidence, StateQLSnapshot, StateQLSnapshotOptions, StatusConnectionData, StatusData, Success, SessionData, SessionListItem, SessionsData, SessionSummaryData, TransactionData, TransactionReferenceData, Warning, } from "./types.js";
5
+ export type { ActorData, ActorLinkData, ActorResolutionData, ActorsData, ActorUnlinkData, AliasData, ApplyData, BatchCommand, BatchCommandName, BatchOptions, CatalogObject, CatalogObjectKind, DescribeObjectData, ListObjectsData, ListObjectsFilter, 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, HistoryCategory, HistoryOptions, MongoAggregateOptions, MongoDocument, MongoFindOptions, MongoMutationOptions, MongoExecOptions, MongoPlanOptions, MongoQueryOptions, MongoReadCommand, MongoWriteCommand, MongoWriteOutcome, OperationData, PlanData, PlanOptions, ProfileData, ProfileOptions, ProfileUpdateOptions, ProfilesData, PurgeData, RedisCommand, RedisExecOptions, RedisPlanOptions, RedisQueryOptions, RedisWriteOutcome, QueryOptions, RecentOperationData, RecentResultData, RemovedProfileData, Response, ResultData, RollbackTransactionData, Row, RowsData, RowsOptions, SqlParameters, SqlDriver, StateQLActorOptions, StateQLOptions, StateConfidence, StateQLSnapshot, StateQLSnapshotOptions, StateQLWorkspaceOptions, StatusConnectionData, StatusData, Success, SessionData, SessionListItem, SessionsData, SessionSummaryData, TransactionData, TransactionReferenceData, Warning, } from "./types.js";
package/dist/src/sql.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { AST } from "node-sql-parser";
2
2
  import type { Driver, SqlDriver } from "./types.js";
3
- export type StatementType = "select" | "insert" | "replace" | "update" | "delete" | "create" | "alter" | "drop" | "truncate";
3
+ export type StatementType = "select" | "insert" | "replace" | "update" | "delete" | "create" | "alter" | "drop" | "truncate" | "explain" | "vacuum" | "analyze" | "reindex" | "cluster";
4
4
  export interface SqlAnalysis {
5
5
  ast: AST;
6
6
  normalized: string;
@@ -9,6 +9,9 @@ export interface SqlAnalysis {
9
9
  unboundedMutation: boolean;
10
10
  destructive: boolean;
11
11
  ordered: boolean;
12
+ wrapForLimit: boolean;
13
+ cacheable: boolean;
14
+ requiresAutocommit: boolean;
12
15
  }
13
16
  export declare function analyzeSql(sql: string, driver: SqlDriver): SqlAnalysis;
14
17
  /** @internal Compatibility for existing connection records during Mongo rollout. */
package/dist/src/sql.js CHANGED
@@ -14,68 +14,19 @@ const SUPPORTED_STATEMENTS = new Set([
14
14
  "truncate",
15
15
  ]);
16
16
  export function analyzeSql(sql, driver) {
17
- if (driver === "mongodb") {
18
- throw new StateQLError("INVALID_SQL", "SQL is not supported for MongoDB connections.");
17
+ if (driver === "mongodb" || driver === "redis") {
18
+ throw new StateQLError("INVALID_SQL", `SQL is not supported for ${driver} connections.`);
19
19
  }
20
20
  const trimmed = sql.trim();
21
21
  if (!trimmed)
22
22
  throw new StateQLError("INVALID_SQL", "SQL is empty.");
23
23
  try {
24
- const database = driver === "postgres"
25
- ? "Postgresql"
26
- : driver === "mysql"
27
- ? "MySQL"
28
- : "Sqlite";
29
- const parserSql = driver === "postgres"
30
- ? postgresParserSql(trimmed)
31
- : trimmed;
32
- const parsed = parser.astify(parserSql, { database });
33
- if (Array.isArray(parsed)) {
34
- if (parsed.length !== 1) {
35
- throw new StateQLError("INVALID_SQL", "Exactly one SQL statement is required.");
36
- }
24
+ if (driver === "postgres") {
25
+ const postgresCommand = analyzePostgresCommand(trimmed);
26
+ if (postgresCommand)
27
+ return postgresCommand;
37
28
  }
38
- const ast = (Array.isArray(parsed) ? parsed[0] : parsed);
39
- if (!ast)
40
- throw new StateQLError("INVALID_SQL", "SQL is empty.");
41
- const rawType = String(ast.type);
42
- if (!SUPPORTED_STATEMENTS.has(rawType)) {
43
- throw new StateQLError("INVALID_SQL", `Unsupported SQL statement type "${rawType}".`);
44
- }
45
- const statementType = rawType;
46
- if (statementType === "select" && selectContainsWrite(ast)) {
47
- throw new StateQLError("INVALID_SQL", "Read statements cannot contain writes or SELECT INTO.");
48
- }
49
- const normalized = parserSql === trimmed
50
- ? parser
51
- .sqlify(ast, { database })
52
- .replace(/;\s*$/, "")
53
- .replace(/\s+/g, " ")
54
- .trim()
55
- // Keep the exact ordering modifiers in cache and idempotency fingerprints.
56
- // The parser copy is analysis-only; adapters execute the original SQL.
57
- : trimmed.replace(/;\s*$/, "");
58
- const details = ast;
59
- const read = statementType === "select";
60
- const mutation = statementType === "update" ||
61
- statementType === "delete" ||
62
- statementType === "truncate";
63
- const destructive = statementType === "drop" ||
64
- statementType === "alter" ||
65
- statementType === "delete" ||
66
- statementType === "replace" ||
67
- statementType === "truncate" ||
68
- (driver === "sqlite" &&
69
- /^(?:INSERT|UPDATE) OR REPLACE\b/i.test(normalized));
70
- return {
71
- ast,
72
- normalized,
73
- statementType,
74
- read,
75
- unboundedMutation: statementType === "truncate" || (mutation && !details.where),
76
- destructive,
77
- ordered: read && Boolean(details.orderby),
78
- };
29
+ return analyzeParsedSql(trimmed, driver);
79
30
  }
80
31
  catch (error) {
81
32
  if (error instanceof StateQLError)
@@ -84,6 +35,542 @@ export function analyzeSql(sql, driver) {
84
35
  throw new StateQLError("INVALID_SQL", message);
85
36
  }
86
37
  }
38
+ function analyzeParsedSql(sql, driver) {
39
+ const database = driver === "postgres"
40
+ ? "Postgresql"
41
+ : driver === "mysql"
42
+ ? "MySQL"
43
+ : "Sqlite";
44
+ const parserSql = driver === "postgres" ? postgresParserSql(sql) : sql;
45
+ const parsed = parser.astify(parserSql, { database });
46
+ if (Array.isArray(parsed) && parsed.length !== 1) {
47
+ throw new StateQLError("INVALID_SQL", "Exactly one SQL statement is required.");
48
+ }
49
+ const ast = (Array.isArray(parsed) ? parsed[0] : parsed);
50
+ if (!ast)
51
+ throw new StateQLError("INVALID_SQL", "SQL is empty.");
52
+ const rawType = String(ast.type);
53
+ if (!SUPPORTED_STATEMENTS.has(rawType)) {
54
+ throw new StateQLError("INVALID_SQL", `Unsupported SQL statement type "${rawType}".`);
55
+ }
56
+ const statementType = rawType;
57
+ if (statementType === "select" && selectContainsWrite(ast)) {
58
+ throw new StateQLError("INVALID_SQL", "Read statements cannot contain writes or SELECT INTO.");
59
+ }
60
+ const normalized = parserSql === sql
61
+ ? parser
62
+ .sqlify(ast, { database })
63
+ .replace(/;\s*$/, "")
64
+ .replace(/\s+/g, " ")
65
+ .trim()
66
+ // Keep the exact ordering modifiers in cache and idempotency fingerprints.
67
+ // The parser copy is analysis-only; adapters execute the original SQL.
68
+ : sql.replace(/;\s*$/, "");
69
+ const details = ast;
70
+ const read = statementType === "select";
71
+ const mutation = statementType === "update" ||
72
+ statementType === "delete" ||
73
+ statementType === "truncate";
74
+ const destructive = statementType === "drop" ||
75
+ statementType === "alter" ||
76
+ statementType === "delete" ||
77
+ statementType === "replace" ||
78
+ statementType === "truncate" ||
79
+ (driver === "sqlite" &&
80
+ /^(?:INSERT|UPDATE) OR REPLACE\b/i.test(normalized));
81
+ return {
82
+ ast,
83
+ normalized,
84
+ statementType,
85
+ read,
86
+ unboundedMutation: statementType === "truncate" || (mutation && !details.where),
87
+ destructive,
88
+ ordered: read && Boolean(details.orderby),
89
+ wrapForLimit: read,
90
+ cacheable: true,
91
+ requiresAutocommit: false,
92
+ };
93
+ }
94
+ const EXPLAIN_INNER_STATEMENTS = new Set([
95
+ "select",
96
+ "insert",
97
+ "update",
98
+ "delete",
99
+ ]);
100
+ const EXPLAIN_BOOLEAN_OPTIONS = new Set([
101
+ "ANALYZE",
102
+ "VERBOSE",
103
+ "COSTS",
104
+ "SETTINGS",
105
+ "GENERIC_PLAN",
106
+ "BUFFERS",
107
+ "WAL",
108
+ "TIMING",
109
+ "SUMMARY",
110
+ "MEMORY",
111
+ ]);
112
+ const EXPLAIN_FORMATS = new Set(["TEXT", "XML", "JSON", "YAML"]);
113
+ const EXPLAIN_SERIALIZE = new Set(["NONE", "TEXT", "BINARY"]);
114
+ const BOOLEAN_VALUES = new Set(["TRUE", "FALSE", "ON", "OFF"]);
115
+ function analyzePostgresCommand(sql) {
116
+ const scanner = new PostgresPrefixScanner(sql);
117
+ const command = scanner.readWord();
118
+ if (!command)
119
+ return undefined;
120
+ switch (command.value) {
121
+ case "EXPLAIN":
122
+ return analyzePostgresExplain(sql, scanner);
123
+ case "VACUUM":
124
+ case "ANALYZE":
125
+ case "REINDEX":
126
+ case "CLUSTER":
127
+ return analyzePostgresMaintenance(sql, command.value);
128
+ default:
129
+ return undefined;
130
+ }
131
+ }
132
+ function analyzePostgresExplain(sql, scanner) {
133
+ let analyze = false;
134
+ const seen = new Set();
135
+ if (scanner.consume("(")) {
136
+ while (true) {
137
+ const option = scanner.readWord();
138
+ if (!option || seen.has(option.value))
139
+ invalidPostgresSyntax("EXPLAIN");
140
+ seen.add(option.value);
141
+ const next = scanner.peek();
142
+ let value;
143
+ if (next !== "," && next !== ")") {
144
+ value = scanner.readWord()?.value;
145
+ if (!value)
146
+ invalidPostgresSyntax("EXPLAIN");
147
+ }
148
+ validateExplainOption(option.value, value);
149
+ if (option.value === "ANALYZE") {
150
+ analyze = value === undefined || value === "TRUE" || value === "ON";
151
+ }
152
+ if (scanner.consume(")"))
153
+ break;
154
+ if (!scanner.consume(","))
155
+ invalidPostgresSyntax("EXPLAIN");
156
+ }
157
+ }
158
+ else {
159
+ while (true) {
160
+ const option = scanner.peekWord();
161
+ if (option !== "ANALYZE" && option !== "VERBOSE")
162
+ break;
163
+ scanner.readWord();
164
+ if (seen.has(option))
165
+ invalidPostgresSyntax("EXPLAIN");
166
+ seen.add(option);
167
+ if (option === "ANALYZE")
168
+ analyze = true;
169
+ }
170
+ }
171
+ const innerSql = sql.slice(scanner.triviaEnd());
172
+ if (!innerSql)
173
+ invalidPostgresSyntax("EXPLAIN");
174
+ const inner = analyzeParsedSql(innerSql, "postgres");
175
+ if (!EXPLAIN_INNER_STATEMENTS.has(inner.statementType)) {
176
+ throw new StateQLError("INVALID_SQL", `EXPLAIN does not support ${inner.statementType.toUpperCase()} statements.`);
177
+ }
178
+ if (analyze && inner.statementType !== "select") {
179
+ throw new StateQLError("INVALID_SQL", "EXPLAIN ANALYZE accepts read-only SELECT statements only.");
180
+ }
181
+ return {
182
+ ast: inner.ast,
183
+ normalized: sql.replace(/;\s*$/, ""),
184
+ statementType: "explain",
185
+ read: true,
186
+ unboundedMutation: false,
187
+ destructive: false,
188
+ ordered: false,
189
+ wrapForLimit: false,
190
+ cacheable: false,
191
+ requiresAutocommit: false,
192
+ };
193
+ }
194
+ function validateExplainOption(option, value) {
195
+ if (EXPLAIN_BOOLEAN_OPTIONS.has(option)) {
196
+ if (value !== undefined && !BOOLEAN_VALUES.has(value))
197
+ invalidPostgresSyntax("EXPLAIN");
198
+ return;
199
+ }
200
+ if (option === "FORMAT" && value && EXPLAIN_FORMATS.has(value))
201
+ return;
202
+ if (option === "SERIALIZE" && value && EXPLAIN_SERIALIZE.has(value))
203
+ return;
204
+ invalidPostgresSyntax("EXPLAIN");
205
+ }
206
+ const VACUUM_OPTIONS = new Map([
207
+ ["FULL", "boolean"],
208
+ ["FREEZE", "boolean"],
209
+ ["VERBOSE", "boolean"],
210
+ ["ANALYZE", "boolean"],
211
+ ["DISABLE_PAGE_SKIPPING", "boolean"],
212
+ ["SKIP_LOCKED", "boolean"],
213
+ ["INDEX_CLEANUP", new Set(["AUTO", "ON", "OFF"])],
214
+ ["PROCESS_MAIN", "boolean"],
215
+ ["PROCESS_TOAST", "boolean"],
216
+ ["TRUNCATE", "boolean"],
217
+ ["PARALLEL", "number"],
218
+ ["SKIP_DATABASE_STATS", "boolean"],
219
+ ["ONLY_DATABASE_STATS", "boolean"],
220
+ ]);
221
+ const ANALYZE_OPTIONS = new Map([
222
+ ["VERBOSE", "boolean"],
223
+ ["SKIP_LOCKED", "boolean"],
224
+ ]);
225
+ const REINDEX_OPTIONS = new Map([
226
+ ["VERBOSE", "boolean"],
227
+ ["TABLESPACE", "identifier"],
228
+ ]);
229
+ const CLUSTER_OPTIONS = new Map([
230
+ ["VERBOSE", "boolean"],
231
+ ]);
232
+ function analyzePostgresMaintenance(sql, command) {
233
+ const parser = new UtilityParser(tokenizePostgresMaintenance(sql), command);
234
+ parser.expectWord(command);
235
+ switch (command) {
236
+ case "VACUUM":
237
+ parser.options(VACUUM_OPTIONS, ["FULL", "FREEZE", "VERBOSE", "ANALYZE"]);
238
+ parser.optionalTargets();
239
+ break;
240
+ case "ANALYZE":
241
+ parser.options(ANALYZE_OPTIONS, ["VERBOSE"]);
242
+ parser.optionalTargets();
243
+ break;
244
+ case "REINDEX": {
245
+ parser.options(REINDEX_OPTIONS);
246
+ const target = parser.expectOneOf([
247
+ "INDEX",
248
+ "TABLE",
249
+ "SCHEMA",
250
+ "DATABASE",
251
+ "SYSTEM",
252
+ ]);
253
+ if (target !== "SYSTEM")
254
+ parser.consumeWord("CONCURRENTLY");
255
+ if (target === "INDEX" || target === "TABLE") {
256
+ parser.qualifiedIdentifier();
257
+ }
258
+ else {
259
+ parser.identifier();
260
+ }
261
+ break;
262
+ }
263
+ case "CLUSTER":
264
+ parser.options(CLUSTER_OPTIONS, ["VERBOSE"]);
265
+ if (!parser.done()) {
266
+ parser.qualifiedIdentifier();
267
+ if (parser.consumeWord("USING"))
268
+ parser.identifier();
269
+ }
270
+ break;
271
+ }
272
+ parser.expectDone();
273
+ return {
274
+ ast: { type: command.toLowerCase() },
275
+ normalized: sql.replace(/;\s*$/, ""),
276
+ statementType: command.toLowerCase(),
277
+ read: false,
278
+ unboundedMutation: false,
279
+ destructive: true,
280
+ ordered: false,
281
+ wrapForLimit: false,
282
+ cacheable: false,
283
+ requiresAutocommit: true,
284
+ };
285
+ }
286
+ function tokenizePostgresMaintenance(sql) {
287
+ const scanner = new PostgresPrefixScanner(sql);
288
+ const tokens = [];
289
+ while (scanner.triviaEnd() < sql.length) {
290
+ const character = sql[scanner.position];
291
+ if (character === ";") {
292
+ scanner.position += 1;
293
+ if (scanner.triviaEnd() !== sql.length) {
294
+ throw new StateQLError("INVALID_SQL", "Exactly one SQL statement is required.");
295
+ }
296
+ break;
297
+ }
298
+ const word = scanner.readWord(false);
299
+ if (word) {
300
+ tokens.push({ kind: "word", value: word.value });
301
+ continue;
302
+ }
303
+ if (character === '"') {
304
+ const end = postgresQuotedIdentifierEnd(sql, scanner.position);
305
+ if (end === undefined)
306
+ invalidPostgresSyntax("maintenance");
307
+ tokens.push({ kind: "identifier", value: sql.slice(scanner.position, end) });
308
+ scanner.position = end;
309
+ continue;
310
+ }
311
+ if (/[0-9]/u.test(character)) {
312
+ const start = scanner.position;
313
+ scanner.position += 1;
314
+ while (/[0-9]/u.test(sql[scanner.position] ?? ""))
315
+ scanner.position += 1;
316
+ tokens.push({ kind: "number", value: sql.slice(start, scanner.position) });
317
+ continue;
318
+ }
319
+ if (["(", ")", ",", "."].includes(character)) {
320
+ tokens.push({ kind: "punctuation", value: character });
321
+ scanner.position += 1;
322
+ continue;
323
+ }
324
+ invalidPostgresSyntax("maintenance");
325
+ }
326
+ return tokens;
327
+ }
328
+ class UtilityParser {
329
+ tokens;
330
+ command;
331
+ index = 0;
332
+ constructor(tokens, command) {
333
+ this.tokens = tokens;
334
+ this.command = command;
335
+ }
336
+ done() {
337
+ return this.index >= this.tokens.length;
338
+ }
339
+ expectDone() {
340
+ if (!this.done())
341
+ invalidPostgresSyntax(this.command);
342
+ }
343
+ expectWord(word) {
344
+ if (!this.consumeWord(word))
345
+ invalidPostgresSyntax(this.command);
346
+ }
347
+ consumeWord(word) {
348
+ const token = this.tokens[this.index];
349
+ if (token?.kind !== "word" || token.value !== word)
350
+ return false;
351
+ this.index += 1;
352
+ return true;
353
+ }
354
+ expectOneOf(words) {
355
+ const token = this.tokens[this.index];
356
+ if (token?.kind !== "word" || !words.includes(token.value)) {
357
+ invalidPostgresSyntax(this.command);
358
+ }
359
+ this.index += 1;
360
+ return token.value;
361
+ }
362
+ options(options, legacy = []) {
363
+ if (this.consumePunctuation("(")) {
364
+ const seen = new Set();
365
+ while (true) {
366
+ const option = this.tokens[this.index];
367
+ if (option?.kind !== "word" || seen.has(option.value)) {
368
+ invalidPostgresSyntax(this.command);
369
+ }
370
+ const kind = options.get(option.value);
371
+ if (!kind)
372
+ invalidPostgresSyntax(this.command);
373
+ seen.add(option.value);
374
+ this.index += 1;
375
+ const next = this.tokens[this.index];
376
+ if (next?.value !== "," && next?.value !== ")") {
377
+ this.optionValue(kind);
378
+ }
379
+ else if (kind !== "boolean") {
380
+ invalidPostgresSyntax(this.command);
381
+ }
382
+ if (this.consumePunctuation(")"))
383
+ return seen;
384
+ if (!this.consumePunctuation(","))
385
+ invalidPostgresSyntax(this.command);
386
+ }
387
+ }
388
+ const seen = new Set();
389
+ while (true) {
390
+ const option = this.tokens[this.index];
391
+ if (option?.kind !== "word" || !legacy.includes(option.value))
392
+ return seen;
393
+ if (seen.has(option.value))
394
+ invalidPostgresSyntax(this.command);
395
+ seen.add(option.value);
396
+ this.index += 1;
397
+ }
398
+ }
399
+ optionalTargets() {
400
+ if (this.done())
401
+ return;
402
+ while (true) {
403
+ this.qualifiedIdentifier();
404
+ if (this.consumePunctuation("(")) {
405
+ this.identifier();
406
+ while (this.consumePunctuation(","))
407
+ this.identifier();
408
+ if (!this.consumePunctuation(")"))
409
+ invalidPostgresSyntax(this.command);
410
+ }
411
+ if (!this.consumePunctuation(","))
412
+ return;
413
+ }
414
+ }
415
+ qualifiedIdentifier() {
416
+ this.identifier();
417
+ while (this.consumePunctuation("."))
418
+ this.identifier();
419
+ }
420
+ identifier() {
421
+ const token = this.tokens[this.index];
422
+ if (token?.kind !== "word" && token?.kind !== "identifier") {
423
+ invalidPostgresSyntax(this.command);
424
+ }
425
+ this.index += 1;
426
+ }
427
+ optionValue(kind) {
428
+ const token = this.tokens[this.index];
429
+ if (!token)
430
+ invalidPostgresSyntax(this.command);
431
+ if (kind === "boolean") {
432
+ if (token.kind !== "word" || !BOOLEAN_VALUES.has(token.value)) {
433
+ invalidPostgresSyntax(this.command);
434
+ }
435
+ }
436
+ else if (kind === "number") {
437
+ if (token.kind !== "number")
438
+ invalidPostgresSyntax(this.command);
439
+ }
440
+ else if (kind === "identifier") {
441
+ if (token.kind !== "word" && token.kind !== "identifier") {
442
+ invalidPostgresSyntax(this.command);
443
+ }
444
+ }
445
+ else if (token.kind !== "word" || !kind.has(token.value)) {
446
+ invalidPostgresSyntax(this.command);
447
+ }
448
+ this.index += 1;
449
+ }
450
+ consumePunctuation(value) {
451
+ const token = this.tokens[this.index];
452
+ if (token?.kind !== "punctuation" || token.value !== value)
453
+ return false;
454
+ this.index += 1;
455
+ return true;
456
+ }
457
+ }
458
+ class PostgresPrefixScanner {
459
+ sql;
460
+ position = 0;
461
+ constructor(sql) {
462
+ this.sql = sql;
463
+ }
464
+ triviaEnd() {
465
+ while (this.position < this.sql.length) {
466
+ if (/\s/u.test(this.sql[this.position])) {
467
+ this.position += 1;
468
+ }
469
+ else if (this.sql.startsWith("--", this.position)) {
470
+ this.position = lineCommentEnd(this.sql, this.position + 2);
471
+ }
472
+ else if (this.sql.startsWith("/*", this.position)) {
473
+ const end = postgresBlockCommentEnd(this.sql, this.position + 2);
474
+ if (end === undefined) {
475
+ throw new StateQLError("INVALID_SQL", "Unterminated SQL comment.");
476
+ }
477
+ this.position = end;
478
+ }
479
+ else {
480
+ break;
481
+ }
482
+ }
483
+ return this.position;
484
+ }
485
+ readWord(skipTrivia = true) {
486
+ if (skipTrivia)
487
+ this.triviaEnd();
488
+ const start = this.position;
489
+ if (!identifierStart(this.sql[start]))
490
+ return undefined;
491
+ this.position += 1;
492
+ while (identifierPart(this.sql[this.position]))
493
+ this.position += 1;
494
+ return {
495
+ value: this.sql.slice(start, this.position).toUpperCase(),
496
+ start,
497
+ end: this.position,
498
+ };
499
+ }
500
+ peekWord() {
501
+ const position = this.position;
502
+ const value = this.readWord()?.value;
503
+ this.position = position;
504
+ return value;
505
+ }
506
+ peek() {
507
+ this.triviaEnd();
508
+ return this.sql[this.position];
509
+ }
510
+ consume(value) {
511
+ this.triviaEnd();
512
+ if (!this.sql.startsWith(value, this.position))
513
+ return false;
514
+ this.position += value.length;
515
+ return true;
516
+ }
517
+ }
518
+ function invalidPostgresSyntax(command) {
519
+ throw new StateQLError("INVALID_SQL", `Unsupported or invalid PostgreSQL ${command} syntax.`);
520
+ }
521
+ function postgresQuotedIdentifierEnd(sql, start) {
522
+ let index = start + 1;
523
+ while (index < sql.length) {
524
+ if (sql[index] !== '"') {
525
+ index += 1;
526
+ }
527
+ else if (sql[index + 1] === '"') {
528
+ index += 2;
529
+ }
530
+ else {
531
+ return index + 1;
532
+ }
533
+ }
534
+ return undefined;
535
+ }
536
+ function postgresQuotedStringScanEnd(sql, start) {
537
+ let index = start + 1;
538
+ while (index < sql.length) {
539
+ if (sql[index] === "\\") {
540
+ index += 2;
541
+ }
542
+ else if (sql[index] !== "'") {
543
+ index += 1;
544
+ }
545
+ else if (sql[index + 1] === "'") {
546
+ index += 2;
547
+ }
548
+ else {
549
+ return index + 1;
550
+ }
551
+ }
552
+ return index;
553
+ }
554
+ function postgresBlockCommentEnd(sql, start) {
555
+ let depth = 1;
556
+ let index = start;
557
+ while (index < sql.length) {
558
+ if (sql.startsWith("/*", index)) {
559
+ depth += 1;
560
+ index += 2;
561
+ }
562
+ else if (sql.startsWith("*/", index)) {
563
+ depth -= 1;
564
+ index += 2;
565
+ if (depth === 0)
566
+ return index;
567
+ }
568
+ else {
569
+ index += 1;
570
+ }
571
+ }
572
+ return undefined;
573
+ }
87
574
  function postgresParserSql(sql) {
88
575
  const output = sql.split("");
89
576
  const orderDepths = new Set();
@@ -97,13 +584,26 @@ function postgresParserSql(sql) {
97
584
  continue;
98
585
  }
99
586
  if (sql.startsWith("/*", index)) {
100
- index = blockCommentEnd(sql, index + 2);
587
+ const end = postgresBlockCommentEnd(sql, index + 2);
588
+ if (end === undefined) {
589
+ throw new StateQLError("INVALID_SQL", "Unterminated SQL comment.");
590
+ }
591
+ index = end;
101
592
  continue;
102
593
  }
103
594
  const character = sql[index];
104
- if (character === "'" || character === '"') {
595
+ if (character === "'") {
105
596
  previousWord = undefined;
106
- index = quotedEnd(sql, index + 1, character);
597
+ index = postgresQuotedStringScanEnd(sql, index);
598
+ continue;
599
+ }
600
+ if (character === '"') {
601
+ previousWord = undefined;
602
+ const end = postgresQuotedIdentifierEnd(sql, index);
603
+ if (end === undefined) {
604
+ throw new StateQLError("INVALID_SQL", "Unterminated quoted SQL identifier.");
605
+ }
606
+ index = end;
107
607
  continue;
108
608
  }
109
609
  if (character === "$") {
@@ -111,7 +611,10 @@ function postgresParserSql(sql) {
111
611
  if (delimiter) {
112
612
  previousWord = undefined;
113
613
  const end = sql.indexOf(delimiter, index + delimiter.length);
114
- index = end < 0 ? sql.length : end + delimiter.length;
614
+ if (end < 0) {
615
+ throw new StateQLError("INVALID_SQL", "Unterminated dollar-quoted SQL value.");
616
+ }
617
+ index = end + delimiter.length;
115
618
  continue;
116
619
  }
117
620
  }
@@ -205,24 +708,6 @@ function blockCommentEnd(sql, start) {
205
708
  }
206
709
  return index;
207
710
  }
208
- function quotedEnd(sql, start, quote) {
209
- let index = start;
210
- while (index < sql.length) {
211
- if (sql[index] === "\\" && quote === "'") {
212
- index += 2;
213
- }
214
- else if (sql[index] !== quote) {
215
- index += 1;
216
- }
217
- else if (sql[index + 1] === quote) {
218
- index += 2;
219
- }
220
- else {
221
- return index + 1;
222
- }
223
- }
224
- return index;
225
- }
226
711
  function identifierStart(value) {
227
712
  return value !== undefined && /[A-Za-z_\u0080-\uFFFF]/u.test(value);
228
713
  }
@@ -230,23 +715,35 @@ function identifierPart(value) {
230
715
  return value !== undefined && /[A-Za-z0-9_$\u0080-\uFFFF]/u.test(value);
231
716
  }
232
717
  function selectContainsWrite(ast) {
233
- const details = ast;
234
- const into = details.into;
235
- if (into?.type === "into" || into?.expr)
236
- return true;
237
- const withStatements = details.with;
238
- if (!Array.isArray(withStatements))
239
- return false;
240
- return withStatements.some((entry) => {
241
- if (!entry || typeof entry !== "object")
718
+ const visited = new Set();
719
+ const writeTypes = new Set([
720
+ "insert",
721
+ "replace",
722
+ "update",
723
+ "delete",
724
+ "create",
725
+ "alter",
726
+ "drop",
727
+ "truncate",
728
+ ]);
729
+ const visit = (value) => {
730
+ if (!value || typeof value !== "object")
242
731
  return false;
243
- const statement = entry.stmt;
244
- if (!statement || typeof statement !== "object")
732
+ if (visited.has(value))
245
733
  return false;
246
- const wrapper = statement;
247
- const child = (wrapper.ast ?? statement);
248
- if (child.type !== "select")
734
+ visited.add(value);
735
+ if (Array.isArray(value))
736
+ return value.some(visit);
737
+ const details = value;
738
+ const type = typeof details.type === "string" ? details.type : undefined;
739
+ if (type && writeTypes.has(type))
249
740
  return true;
250
- return selectContainsWrite(child);
251
- });
741
+ if (type === "select") {
742
+ const into = details.into;
743
+ if (into?.type === "into" || into?.expr)
744
+ return true;
745
+ }
746
+ return Object.values(details).some(visit);
747
+ };
748
+ return visit(ast);
252
749
  }
@@ -1,9 +1,11 @@
1
1
  import { type TableChange } from "./table-editor.js";
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";
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, StateQLWorkspaceOptions, StateQLSnapshot, StateQLSnapshotOptions, StatusData, SessionData, SessionsData, SessionSummaryData, TransactionData } from "./types.js";
3
3
  export declare class StateQL {
4
4
  /** Runtime contract marker for passwordRef/password_ref support. */
5
5
  static readonly passwordReferenceVersion: 1;
6
6
  static forActor(options: StateQLActorOptions): StateQL;
7
+ /** Opens one actor in a named shared workspace for a trusted library host. */
8
+ static forWorkspace(options: StateQLWorkspaceOptions): StateQL;
7
9
  private readonly store;
8
10
  private readonly sessionName;
9
11
  private readonly actorId;
@@ -17,6 +17,7 @@ import { compactRows, defaultHome, hash, isSqlParameters, parseJson, redact, } f
17
17
  const DEFAULT_SNAPSHOT_HISTORY_LIMIT = 50;
18
18
  const MAX_SNAPSHOT_HISTORY_LIMIT = 100;
19
19
  const DEFAULT_CREDENTIAL_RESOLUTION_TIMEOUT_MS = 120_000;
20
+ const WORKSPACE_BOOTSTRAP = Symbol("StateQL.workspaceBootstrap");
20
21
  export class StateQL {
21
22
  /** Runtime contract marker for passwordRef/password_ref support. */
22
23
  static passwordReferenceVersion = 1;
@@ -37,6 +38,21 @@ export class StateQL {
37
38
  store.close();
38
39
  }
39
40
  }
41
+ /** Opens one actor in a named shared workspace for a trusted library host. */
42
+ static forWorkspace(options) {
43
+ if (!options.workspace.trim()) {
44
+ throw new StateQLError("INVALID_COMMAND", "Workspace name is required.");
45
+ }
46
+ if (!options.actor.trim()) {
47
+ throw new StateQLError("INVALID_COMMAND", "Actor ID is required.");
48
+ }
49
+ const { workspace, ...actorOptions } = options;
50
+ return new StateQL({
51
+ ...actorOptions,
52
+ session: workspace,
53
+ [WORKSPACE_BOOTSTRAP]: true,
54
+ });
55
+ }
40
56
  store;
41
57
  sessionName;
42
58
  actorId;
@@ -80,7 +96,12 @@ export class StateQL {
80
96
  }
81
97
  const store = new StateStore(options.home ?? defaultHome(), this.now, maxStateBytes);
82
98
  try {
83
- store.bootstrapSession(this.sessionName, this.actorId, options.actor === undefined);
99
+ if (options[WORKSPACE_BOOTSTRAP]) {
100
+ store.bootstrapWorkspace(this.sessionName, this.actorId);
101
+ }
102
+ else {
103
+ store.bootstrapSession(this.sessionName, this.actorId, options.actor === undefined);
104
+ }
84
105
  }
85
106
  catch (error) {
86
107
  store.close();
@@ -651,6 +672,13 @@ export class StateQL {
651
672
  if (!analysis.read) {
652
673
  throw new StateQLError("INVALID_SQL", "query accepts read statements only; use exec for writes.");
653
674
  }
675
+ const cacheMode = options.cache ?? "auto";
676
+ if (!analysis.cacheable && cacheMode === "require") {
677
+ throw new StateQLError("CACHE_MISS", "This statement is not cacheable.", {
678
+ retryable: true,
679
+ suggestedAction: "Run with --cache auto or --cache bypass.",
680
+ });
681
+ }
654
682
  const parameters = options.params ?? [];
655
683
  const context = this.executionContext(options);
656
684
  const adapterSource = await this.resolveConnectionSource(connection, session, "query", "read", context);
@@ -668,8 +696,8 @@ export class StateQL {
668
696
  stateVersion,
669
697
  });
670
698
  const cached = this.store.findResult(fingerprint);
671
- const cacheMode = options.cache ?? "auto";
672
- if (cacheMode !== "bypass" &&
699
+ if (analysis.cacheable &&
700
+ cacheMode !== "bypass" &&
673
701
  cached &&
674
702
  cached.row_count <= this.maxResultRows &&
675
703
  this.cacheValid(cached, stateVersion, stateSignature)) {
@@ -688,7 +716,9 @@ export class StateQL {
688
716
  suggestedAction: "Run with --cache auto or --cache bypass.",
689
717
  });
690
718
  }
691
- const result = await adapter.read(boundedReadSql(sql, this.maxResultRows + 1), parameters);
719
+ const result = await adapter.read(analysis.wrapForLimit
720
+ ? boundedReadSql(sql, this.maxResultRows + 1)
721
+ : sql, parameters);
692
722
  if (result.rows.length > this.maxResultRows) {
693
723
  throw new StateQLError("OUTPUT_LIMIT_EXCEEDED", `Query exceeds the ${this.maxResultRows}-row materialization limit.`, { suggestedAction: "Add a narrower WHERE clause or LIMIT." });
694
724
  }
@@ -2179,12 +2209,19 @@ export class StateQL {
2179
2209
  throw new StateQLError("INVALID_COMMAND", "Idempotency key cannot be empty.");
2180
2210
  }
2181
2211
  const parameters = options.params ?? [];
2212
+ if (analysis.requiresAutocommit &&
2213
+ (options.expectedRows !== undefined || sqlParametersLength(parameters) > 0)) {
2214
+ throw new StateQLError("INVALID_SQL", "PostgreSQL maintenance statements do not accept StateQL parameters or row-count preconditions.");
2215
+ }
2216
+ const transactionId = session.active_transaction_id ?? undefined;
2217
+ if (analysis.requiresAutocommit && transactionId) {
2218
+ throw new StateQLError("TRANSACTION_FAILED", `${analysis.statementType.toUpperCase()} cannot be staged in a transaction.`, { suggestedAction: "Rollback or commit the staged transaction, then run the maintenance statement separately." });
2219
+ }
2182
2220
  const fingerprint = hash({
2183
2221
  sql: analysis.normalized,
2184
2222
  parameters,
2185
2223
  database: databaseIdentity(connection),
2186
2224
  });
2187
- const transactionId = session.active_transaction_id ?? undefined;
2188
2225
  if (transactionId) {
2189
2226
  const transaction = this.store.getTransaction(transactionId);
2190
2227
  if (!transaction ||
@@ -2284,7 +2321,12 @@ export class StateQL {
2284
2321
  });
2285
2322
  }
2286
2323
  try {
2287
- const write = await adapter.write(sql, parameters, options.expectedRows);
2324
+ if (analysis.requiresAutocommit && !adapter.writeAutocommit) {
2325
+ throw new AdapterWriteError(`${analysis.statementType.toUpperCase()} requires PostgreSQL autocommit execution.`, false);
2326
+ }
2327
+ const write = analysis.requiresAutocommit
2328
+ ? await adapter.writeAutocommit(sql, parameters)
2329
+ : await adapter.write(sql, parameters, options.expectedRows);
2288
2330
  try {
2289
2331
  const finalized = planClaim
2290
2332
  ? this.store.finishPlannedOperation({
@@ -3062,6 +3104,11 @@ function markTransactionOutcomeUnknown(store, transactionId, sessionId, actorId)
3062
3104
  // A stale committing transaction is recovered as unknown after five minutes.
3063
3105
  }
3064
3106
  }
3107
+ function sqlParametersLength(parameters) {
3108
+ return Array.isArray(parameters)
3109
+ ? parameters.length
3110
+ : Object.keys(parameters).length;
3111
+ }
3065
3112
  function boundedReadSql(sql, limit) {
3066
3113
  const statement = sql.trim().replace(/;\s*$/, "");
3067
3114
  return `SELECT * FROM (${statement}) AS _stateql_bounded LIMIT ${limit}`;
@@ -132,6 +132,7 @@ export declare class StateStore {
132
132
  private insertWithRandomId;
133
133
  ensureSession(name?: string): SessionRecord;
134
134
  bootstrapSession(name: string, actorId: string, ensureLegacyMembership: boolean): SessionRecord;
135
+ bootstrapWorkspace(name: string, actorId: string): SessionRecord;
135
136
  createSession(name: string): SessionRecord;
136
137
  isSessionMember(sessionId: string, actorId: string): boolean;
137
138
  linkActor(sessionId: string, requestingActorId: string, actorId: string): "linked" | "already_linked" | "actor_conflict" | "denied";
package/dist/src/store.js CHANGED
@@ -132,6 +132,56 @@ export class StateStore {
132
132
  throw error;
133
133
  }
134
134
  }
135
+ bootstrapWorkspace(name, actorId) {
136
+ const timestamp = this.now().toISOString();
137
+ this.db.exec("BEGIN IMMEDIATE");
138
+ try {
139
+ let row = this.db
140
+ .prepare("SELECT id, status FROM sessions WHERE name = ? LIMIT 1")
141
+ .get(name);
142
+ const identities = actorId === name ? [actorId] : [name, actorId];
143
+ for (const identity of identities) {
144
+ const existing = this.resolveActor(identity);
145
+ if (existing && existing.id !== row?.id) {
146
+ throw new StateQLError("PERMISSION_DENIED", `Actor "${identity}" is already attached to workspace "${existing.name}" and cannot be attached to workspace "${name}".`);
147
+ }
148
+ }
149
+ if (!row) {
150
+ const id = this.insertWithRandomId("s", "sessions", (candidate) => {
151
+ this.db
152
+ .prepare(`INSERT INTO sessions
153
+ (id, name, status, created_at, updated_at)
154
+ VALUES (?, ?, 'active', ?, ?)`)
155
+ .run(candidate, name, timestamp, timestamp);
156
+ });
157
+ row = { id, status: "active" };
158
+ }
159
+ else if (row.status !== "active") {
160
+ this.db
161
+ .prepare(`UPDATE sessions
162
+ SET status = 'active', updated_at = ?
163
+ WHERE id = ?`)
164
+ .run(timestamp, row.id);
165
+ }
166
+ for (const identity of identities) {
167
+ if (this.isSessionMember(row.id, identity))
168
+ continue;
169
+ this.db
170
+ .prepare(`INSERT INTO session_members(session_id, actor_id, attached_at)
171
+ VALUES (?, ?, ?)`)
172
+ .run(row.id, identity, timestamp);
173
+ }
174
+ const session = this.getSessionByName(name);
175
+ if (!session)
176
+ throw new Error(`Could not open workspace "${name}".`);
177
+ this.db.exec("COMMIT");
178
+ return session;
179
+ }
180
+ catch (error) {
181
+ this.db.exec("ROLLBACK");
182
+ throw error;
183
+ }
184
+ }
135
185
  createSession(name) {
136
186
  return this.bootstrapSession(name, name, true);
137
187
  }
@@ -274,6 +274,10 @@ export interface StateQLOptions extends ExecutionOptions {
274
274
  export type StateQLActorOptions = Omit<StateQLOptions, "session" | "actor"> & {
275
275
  actor: string;
276
276
  };
277
+ /** Trusted-host options for opening one actor in a named shared workspace. */
278
+ export type StateQLWorkspaceOptions = StateQLActorOptions & {
279
+ workspace: string;
280
+ };
277
281
  export interface QueryOptions extends ExecutionOptions {
278
282
  params?: SqlParameters;
279
283
  cache?: "auto" | "bypass" | "require";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fadhilp/stateql",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "Stateful, agent-oriented database CLI for safe result reuse",
5
5
  "repository": {
6
6
  "type": "git",
@@ -21,6 +21,7 @@
21
21
  "files": [
22
22
  "dist/src",
23
23
  "README.md",
24
+ "SQL_COMMAND_ROADMAP.md",
24
25
  "LICENSE"
25
26
  ],
26
27
  "scripts": {