@fadhilp/stateql 0.8.0 → 0.9.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
@@ -415,6 +415,7 @@ const stateql = StateQL.forActor({
415
415
  home: "./.stql",
416
416
  actor: "pi-session-id",
417
417
  timeoutMs: 30_000,
418
+ credentialTimeoutMs: 120_000,
418
419
  maxResultBytes: 16 * 1024 * 1024,
419
420
  maxStateBytes: 256 * 1024 * 1024,
420
421
  });
@@ -506,6 +507,10 @@ const stateql = StateQL.forActor({
506
507
  });
507
508
  ```
508
509
 
510
+ Credential resolution has its own two-minute default deadline
511
+ (`credentialTimeoutMs`) and remains cancellable through `request.signal`.
512
+ The database-operation timeout begins after a credential is resolved.
513
+
509
514
  When no custom resolver is configured, StateQL reads only `secret_env`
510
515
  references from `process.env`; `credential_ref` never falls back to the
511
516
  environment. A configured resolver is authoritative for both sources: returning
@@ -9,6 +9,7 @@ export interface WriteResult {
9
9
  }
10
10
  export interface AdapterContext {
11
11
  deadline: number;
12
+ timeoutMs?: number;
12
13
  signal?: AbortSignal;
13
14
  }
14
15
  export declare class AdapterExecutionError extends Error {
@@ -28,7 +29,7 @@ export interface Adapter {
28
29
  readonly confidence: StateConfidence;
29
30
  ping(): Promise<void>;
30
31
  read(sql: string, params: SqlParameters): Promise<ReadResult>;
31
- write(sql: string, params: SqlParameters): Promise<WriteResult>;
32
+ write(sql: string, params: SqlParameters, expectedRows?: 1): Promise<WriteResult>;
32
33
  writeBatch(operations: OperationRecord[], isolation: string): Promise<WriteResult[]>;
33
34
  signature(): Promise<string>;
34
35
  inspect(kind: string, table?: string): Promise<unknown>;
@@ -32,6 +32,7 @@ export class AdapterWriteError extends Error {
32
32
  export function createAdapterContext(timeoutMs, signal) {
33
33
  return {
34
34
  deadline: Date.now() + timeoutMs,
35
+ timeoutMs,
35
36
  ...(signal ? { signal } : {}),
36
37
  };
37
38
  }
@@ -95,8 +96,8 @@ class SQLiteAdapter {
95
96
  async read(sql, params) {
96
97
  return this.call("read", [sql, params], false, false);
97
98
  }
98
- async write(sql, params) {
99
- return this.call("write", [sql, params], true, false);
99
+ async write(sql, params, expectedRows) {
100
+ return this.call("write", [sql, params, expectedRows], true, false);
100
101
  }
101
102
  async writeBatch(operations, isolation) {
102
103
  return this.call("writeBatch", [operations, isolation], true, true);
@@ -250,7 +251,7 @@ class PostgresAdapter {
250
251
  throw error;
251
252
  }
252
253
  }
253
- async write(sql, params) {
254
+ async write(sql, params, expectedRows) {
254
255
  if (this.readOnly)
255
256
  throw new Error("Connection is read-only.");
256
257
  try {
@@ -266,6 +267,8 @@ class PostgresAdapter {
266
267
  try {
267
268
  await this.setLocalDeadline();
268
269
  const result = await this.query(sql, postgresParams(params), true);
270
+ if (expectedRows === 1 && result.rowCount !== 1)
271
+ throw new Error("ROW_CONFLICT: The row changed or no longer has a unique identity.");
269
272
  committing = true;
270
273
  await this.query("COMMIT", [], true);
271
274
  return { affectedRows: result.rowCount ?? 0 };
@@ -357,6 +360,18 @@ class PostgresAdapter {
357
360
  const [schema, name] = table.includes(".")
358
361
  ? table.split(".", 2)
359
362
  : ["public", table];
363
+ if (kind === "editable") {
364
+ await this.setLocalDeadline();
365
+ const result = await this.query(`SELECT c.column_name AS name, c.data_type AS type, c.is_nullable = 'YES' AS nullable,
366
+ (c.is_generated = 'ALWAYS' OR c.is_identity = 'YES') AS generated,
367
+ COALESCE(k.ordinal_position, 0) AS key
368
+ FROM information_schema.columns c
369
+ LEFT JOIN information_schema.key_column_usage k ON k.table_schema = c.table_schema AND k.table_name = c.table_name AND k.column_name = c.column_name
370
+ AND EXISTS (SELECT 1 FROM information_schema.table_constraints t WHERE t.constraint_schema = k.constraint_schema AND t.constraint_name = k.constraint_name AND t.table_name = k.table_name AND t.constraint_type = 'PRIMARY KEY')
371
+ WHERE c.table_schema = $1 AND c.table_name = $2 ORDER BY c.ordinal_position`, [schema, name], false);
372
+ const objects = await this.query("SELECT table_type FROM information_schema.tables WHERE table_schema = $1 AND table_name = $2", [schema, name], false);
373
+ return { writable: objects.rows[0]?.table_type === "BASE TABLE", columns: result.rows.map(column => ({ ...column, key: Number(column.key) })) };
374
+ }
360
375
  await this.setLocalDeadline();
361
376
  const columns = await this.query(`SELECT column_name AS name, data_type AS type,
362
377
  is_nullable = 'YES' AS nullable
@@ -471,7 +486,7 @@ class MySqlAdapter {
471
486
  throw error;
472
487
  }
473
488
  }
474
- async write(sql, params) {
489
+ async write(sql, params, expectedRows) {
475
490
  if (this.readOnly)
476
491
  throw new Error("Connection is read-only.");
477
492
  let values;
@@ -489,13 +504,22 @@ class MySqlAdapter {
489
504
  throw error;
490
505
  throw new AdapterWriteError(errorText(error), false);
491
506
  }
507
+ let committing = false;
492
508
  try {
493
509
  const [result] = await this.query(sql, values, true, true);
510
+ if (expectedRows === 1 && mysqlAffectedRows(result) !== 1)
511
+ throw new Error("ROW_CONFLICT: The row changed or no longer has a unique identity.");
512
+ committing = true;
494
513
  await this.query("COMMIT", [], true, false);
495
514
  return { affectedRows: mysqlAffectedRows(result) };
496
515
  }
497
516
  catch (error) {
498
- await this.rollbackQuietly();
517
+ const rolledBack = await this.rollbackQuietly();
518
+ if (!committing && rolledBack) {
519
+ if (error instanceof AdapterExecutionError)
520
+ throw new AdapterExecutionError(error.message, error.reason, false);
521
+ throw new AdapterWriteError(errorText(error), false);
522
+ }
499
523
  if (error instanceof AdapterExecutionError)
500
524
  throw error;
501
525
  throw new AdapterWriteError(errorText(error), true);
@@ -583,6 +607,16 @@ class MySqlAdapter {
583
607
  const name = separator === -1 ? table : table.slice(separator + 1);
584
608
  if (!schema || !name)
585
609
  throw new Error(`Invalid table name "${table}".`);
610
+ if (kind === "editable") {
611
+ const [result] = await this.query(`SELECT c.COLUMN_NAME AS name, c.DATA_TYPE AS type, c.IS_NULLABLE = 'YES' AS nullable,
612
+ c.EXTRA LIKE '%GENERATED%' AS \`generated\`, COALESCE(k.ORDINAL_POSITION, 0) AS \`key\`
613
+ FROM information_schema.columns c LEFT JOIN information_schema.key_column_usage k
614
+ ON k.TABLE_SCHEMA = c.TABLE_SCHEMA AND k.TABLE_NAME = c.TABLE_NAME AND k.COLUMN_NAME = c.COLUMN_NAME AND k.CONSTRAINT_NAME = 'PRIMARY'
615
+ WHERE c.TABLE_SCHEMA = ? AND c.TABLE_NAME = ? ORDER BY c.ORDINAL_POSITION`, [schema, name], false, true);
616
+ const [objects] = await this.query("SELECT ENGINE AS engine FROM information_schema.tables WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?", [schema, name], false, true);
617
+ return { writable: String(mysqlRows(objects)[0]?.engine).toLowerCase() === "innodb",
618
+ columns: mysqlRows(result).map(column => ({ ...column, nullable: Boolean(column.nullable), generated: Boolean(column.generated), key: Number(column.key) })) };
619
+ }
586
620
  const [columnResult] = await this.query(`SELECT COLUMN_NAME AS name, DATA_TYPE AS type,
587
621
  IS_NULLABLE = 'YES' AS nullable
588
622
  FROM information_schema.columns
@@ -80,6 +80,11 @@ const MIGRATIONS = [
80
80
  apply: migrateCredentialRefs,
81
81
  validate: validateCredentialRefs,
82
82
  },
83
+ {
84
+ name: "history_target_v1",
85
+ apply(db) { addColumn(db, "history", "target", "TEXT"); },
86
+ validate(db) { requireColumns(db, "history", ["target"]); },
87
+ },
83
88
  ];
84
89
  export function runMigrations(db, now) {
85
90
  db.exec(`
@@ -34,7 +34,7 @@ export declare class MongoAdapter {
34
34
  ping(): Promise<void>;
35
35
  signature(): Promise<string>;
36
36
  read(command: MongoReadCommand, maxRows: number): Promise<MongoReadResult>;
37
- write(command: MongoWriteCommand): Promise<MongoWriteResult>;
37
+ write(command: MongoWriteCommand, expectedRows?: 1): Promise<MongoWriteResult>;
38
38
  writeBatch(commands: MongoWriteCommand[], isolation: string): Promise<MongoWriteResult[]>;
39
39
  inspect(kind: string, name?: string): Promise<unknown>;
40
40
  close(): Promise<void>;
@@ -221,7 +221,7 @@ export class MongoAdapter {
221
221
  await cursor?.close().catch(() => undefined);
222
222
  }
223
223
  }
224
- async write(command) {
224
+ async write(command, expectedRows) {
225
225
  let value;
226
226
  try {
227
227
  value = validateMongoWriteCommand(command);
@@ -240,10 +240,17 @@ export class MongoAdapter {
240
240
  throw error;
241
241
  throw new AdapterWriteError(errorText(error), false);
242
242
  }
243
+ if (expectedRows === 1 && (value.operation !== "updateOne" || value.options?.upsert))
244
+ throw new AdapterWriteError("Conditional edits require updateOne without upsert.", false);
243
245
  try {
244
- return await withContext(this.executeWrite(value), this.context, () => this.stop(), true);
246
+ const result = await withContext(this.executeWrite(value), this.context, () => this.stop(), true);
247
+ if (expectedRows === 1 && result.outcome.matched_count !== 1)
248
+ throw new AdapterWriteError("ROW_CONFLICT: The document changed or was removed.", false);
249
+ return result;
245
250
  }
246
251
  catch (error) {
252
+ if (error instanceof AdapterWriteError)
253
+ throw error;
247
254
  const stopped = writeStoppedError(error, this.context, true);
248
255
  if (stopped)
249
256
  throw stopped;
@@ -342,6 +349,10 @@ export class MongoAdapter {
342
349
  if (kind === "constraints") {
343
350
  return { collection: collectionName, constraints: [] };
344
351
  }
352
+ if (kind === "editable") {
353
+ const objects = await this.client.db(this.databaseName).listCollections({ name: collectionName }, { nameOnly: true, signal: operationSignal(this.context), maxTimeMS: remainingMilliseconds(this.context) }).toArray();
354
+ return { writable: objects[0]?.type === "collection", columns: [] };
355
+ }
345
356
  const columns = await this.sampleColumns(collectionName);
346
357
  if (kind === "columns")
347
358
  return { collection: collectionName, columns };
@@ -57,7 +57,7 @@ function execute(request) {
57
57
  case "write": {
58
58
  if (readOnly)
59
59
  throw new Error("Connection is read-only.");
60
- const [sql, params] = request.args;
60
+ const [sql, params, expectedRows] = request.args;
61
61
  try {
62
62
  database.exec("BEGIN");
63
63
  }
@@ -66,6 +66,8 @@ function execute(request) {
66
66
  }
67
67
  try {
68
68
  const result = bindRun(database.prepare(sql), params);
69
+ if (expectedRows === 1 && Number(result.changes) !== 1)
70
+ throw new Error("ROW_CONFLICT: The row changed or no longer has a unique identity.");
69
71
  database.exec("COMMIT");
70
72
  return { affectedRows: Number(result.changes) };
71
73
  }
@@ -160,6 +162,11 @@ function inspect(database, kind, table) {
160
162
  if (!table)
161
163
  throw new Error(`Table is required for inspect ${kind}.`);
162
164
  const quoted = quoteSqliteLiteral(table);
165
+ if (kind === "editable") {
166
+ const object = database.prepare("SELECT type FROM sqlite_master WHERE name = ?").get(table);
167
+ const columns = database.prepare(`PRAGMA table_xinfo(${quoted})`).all();
168
+ return { writable: object?.type === "table", columns: columns.map(column => ({ name: String(column.name), type: String(column.type).toLowerCase(), nullable: !column.notnull && !column.pk, generated: Number(column.hidden) !== 0, key: Number(column.pk) })) };
169
+ }
163
170
  const columns = database
164
171
  .prepare(`PRAGMA table_info(${quoted})`)
165
172
  .all();
@@ -1,4 +1,5 @@
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";
1
+ import { type TableChange } from "./table-editor.js";
2
+ import type { ActorLinkData, ActorResolutionData, ActorsData, ActorUnlinkData, AliasData, ApplyData, BatchCommand, CommandExecutionContext, CommandOrigin, 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, Column, StateQLActorOptions, StateQLOptions, StateQLSnapshot, StatusData, SessionData, SessionsData, SessionSummaryData, TransactionData } from "./types.js";
2
3
  export declare class StateQL {
3
4
  static forActor(options: StateQLActorOptions): StateQL;
4
5
  private readonly store;
@@ -11,11 +12,15 @@ export declare class StateQL {
11
12
  private readonly maxResultRows;
12
13
  private readonly maxResultBytes;
13
14
  private readonly timeoutMs;
15
+ private readonly credentialTimeoutMs;
14
16
  private readonly signal?;
15
17
  private readonly commandContexts;
16
18
  private readonly credentialResolver?;
17
19
  private readonly now;
18
20
  private closed;
21
+ private readonly tableResults;
22
+ private readonly editTokens;
23
+ private panelRows?;
19
24
  constructor(options?: StateQLOptions);
20
25
  close(): void;
21
26
  [Symbol.dispose](): void;
@@ -46,6 +51,40 @@ export declare class StateQL {
46
51
  count(idOrAlias: string): Promise<Response<CountData>>;
47
52
  columns(idOrAlias: string): Promise<Response<ColumnsData>>;
48
53
  setAlias(name: string, id: string): Promise<Response<AliasData>>;
54
+ /** Owned, full-value pages for host UIs. Reading a page does not add a command to history. */
55
+ readMaterialized(id: string, options?: RowsOptions & {
56
+ signal?: AbortSignal;
57
+ }): RowsData & {
58
+ columns: Column[];
59
+ row_tokens: Array<string | null>;
60
+ writable_columns: string[];
61
+ editing_reason?: string;
62
+ };
63
+ /** No caller-selected filesystem paths. The host delivers these bounded attachment bytes. */
64
+ serializeResult(id: string, format: "json" | "jsonl" | "csv", signal?: AbortSignal, origin?: CommandOrigin): Promise<Response<{
65
+ content: string;
66
+ format: string;
67
+ rows: number;
68
+ }>>;
69
+ readTable(table: {
70
+ schema?: string;
71
+ name: string;
72
+ }, limit?: number, options?: QueryOptions & {
73
+ origin?: CommandOrigin;
74
+ }): Promise<Response<ResultData & {
75
+ table: {
76
+ schema?: string;
77
+ name: string;
78
+ };
79
+ sample_limit: number;
80
+ query: string;
81
+ }>>;
82
+ planTableUpdate(token: string, changes: TableChange, options?: ExecutionOptions & {
83
+ origin?: CommandOrigin;
84
+ }): Promise<Response<PlanData>>;
85
+ private editableMetadata;
86
+ private panelResult;
87
+ private fullResultRows;
49
88
  exportResult(idOrAlias: string, output: string, format?: "json" | "jsonl" | "csv"): Promise<Response<ExportData>>;
50
89
  exec(sql: string, options?: ExecOptions): Promise<Response<ExecData>>;
51
90
  mongoExec(command: MongoWriteCommand, options?: MongoExecOptions): Promise<Response<ExecData>>;
@@ -1,4 +1,6 @@
1
1
  import { AsyncLocalStorage } from "node:async_hooks";
2
+ import { randomUUID } from "node:crypto";
3
+ import { compileTableUpdate, editableRow, parseTableUpdate } from "./table-editor.js";
2
4
  import { writeFileSync } from "node:fs";
3
5
  import { basename, resolve } from "node:path";
4
6
  import { env } from "node:process";
@@ -13,6 +15,7 @@ import { StateStore, } from "./store.js";
13
15
  import { compactRows, defaultHome, hash, isSqlParameters, parseJson, redact, } from "./util.js";
14
16
  const DEFAULT_SNAPSHOT_HISTORY_LIMIT = 50;
15
17
  const MAX_SNAPSHOT_HISTORY_LIMIT = 100;
18
+ const DEFAULT_CREDENTIAL_RESOLUTION_TIMEOUT_MS = 120_000;
16
19
  export class StateQL {
17
20
  static forActor(options) {
18
21
  if (!options.actor.trim()) {
@@ -41,11 +44,16 @@ export class StateQL {
41
44
  maxResultRows;
42
45
  maxResultBytes;
43
46
  timeoutMs;
47
+ credentialTimeoutMs;
44
48
  signal;
45
49
  commandContexts = new AsyncLocalStorage();
46
50
  credentialResolver;
47
51
  now;
48
52
  closed = false;
53
+ tableResults = new Map();
54
+ editTokens = new Map();
55
+ // ponytail: cache one bounded immutable result; use indexed result storage if larger results are needed.
56
+ panelRows;
49
57
  constructor(options = {}) {
50
58
  this.now = options.now ?? (() => new Date());
51
59
  this.sessionName = options.session ?? env.STQL_SESSION ?? "default";
@@ -60,6 +68,7 @@ export class StateQL {
60
68
  this.maxResultRows = positiveInteger(options.maxResultRows ?? 10_000, "maxResultRows");
61
69
  this.maxResultBytes = positiveInteger(options.maxResultBytes ?? 16 * 1024 * 1024, "maxResultBytes");
62
70
  this.timeoutMs = executionTimeout(options.timeoutMs ?? 30_000);
71
+ this.credentialTimeoutMs = executionTimeout(options.credentialTimeoutMs ?? DEFAULT_CREDENTIAL_RESOLUTION_TIMEOUT_MS, "credentialTimeoutMs");
63
72
  const maxStateBytes = positiveInteger(options.maxStateBytes ?? 256 * 1024 * 1024, "maxStateBytes");
64
73
  this.signal = options.signal;
65
74
  this.credentialResolver = options.credentialResolver;
@@ -80,6 +89,9 @@ export class StateQL {
80
89
  if (this.closed)
81
90
  return;
82
91
  this.closed = true;
92
+ this.panelRows = undefined;
93
+ this.tableResults.clear();
94
+ this.editTokens.clear();
83
95
  this.store.close();
84
96
  }
85
97
  [Symbol.dispose]() {
@@ -852,6 +864,193 @@ export class StateQL {
852
864
  };
853
865
  });
854
866
  }
867
+ /** Owned, full-value pages for host UIs. Reading a page does not add a command to history. */
868
+ readMaterialized(id, options = {}) {
869
+ options.signal?.throwIfAborted();
870
+ const result = this.panelResult(id);
871
+ const offset = nonNegativeInteger(options.offset ?? 0, "offset");
872
+ const limit = positiveInteger(options.limit ?? 100, "limit");
873
+ if (limit > 100 || offset > 10_000)
874
+ throw new StateQLError("OUTPUT_LIMIT_EXCEEDED", "Page bounds exceeded.");
875
+ const all = this.fullResultRows(result);
876
+ const rows = [];
877
+ let bytes = 0;
878
+ for (const row of all.slice(offset, offset + limit)) {
879
+ const size = Buffer.byteLength(JSON.stringify(row), "utf8");
880
+ if (bytes + size > 200 * 1024) {
881
+ if (!rows.length)
882
+ throw new StateQLError("OUTPUT_LIMIT_EXCEEDED", "A row exceeds the browser page limit. Export this result instead.");
883
+ break;
884
+ }
885
+ rows.push(row);
886
+ bytes += size;
887
+ }
888
+ const next = offset + rows.length;
889
+ const metadata = this.tableResults.get(id);
890
+ const connection = this.store.activeConnection(this.store.ensureSession(this.sessionName));
891
+ const eligible = metadata && connection?.id === result.connection_id && !connection.read_only && version(connection) === result.state_version;
892
+ const rowTokens = rows.map(row => {
893
+ if (!eligible || !editableRow(metadata, row))
894
+ return null;
895
+ const token = randomUUID();
896
+ for (const [key, entry] of this.editTokens)
897
+ if (entry.expires <= this.now().getTime())
898
+ this.editTokens.delete(key);
899
+ if (this.editTokens.size >= 1000)
900
+ this.editTokens.delete(this.editTokens.keys().next().value);
901
+ this.editTokens.set(token, { metadata, original: structuredClone(row), sessionId: result.session_id, connectionId: result.connection_id, stateVersion: result.state_version, expires: this.now().getTime() + 10 * 60_000 });
902
+ return token;
903
+ });
904
+ const writable = metadata?.driver === "mongodb" ? [...new Set(rows.flatMap(row => Object.keys(row)))].filter(name => name !== "_id")
905
+ : metadata?.columns.filter(column => !column.key && !column.generated).map(column => column.name) ?? [];
906
+ return { row_tokens: rowTokens, writable_columns: writable, ...(!metadata?.writable ? { editing_reason: metadata?.reason ?? "Query results are read-only." } : {}),
907
+ result_id: result.id, offset, limit, rows: structuredClone(rows), columns: this.store.resultColumns(result),
908
+ returned: rows.length, total: all.length, truncated: next < all.length, next_offset: next < all.length ? next : null };
909
+ }
910
+ /** No caller-selected filesystem paths. The host delivers these bounded attachment bytes. */
911
+ async serializeResult(id, format, signal, origin = "api") {
912
+ return this.commandContexts.run(mergeCommandExecutionContext(this.commandContexts.getStore(), { signal, origin }), () => this.run("export", async () => {
913
+ if (!["json", "jsonl", "csv"].includes(format))
914
+ throw new StateQLError("INVALID_COMMAND", "Unsupported export format.");
915
+ const result = this.panelResult(id);
916
+ const rows = this.fullResultRows(result);
917
+ const columns = this.store.resultColumns(result).map(column => column.name);
918
+ const chunks = [];
919
+ let bytes = 0;
920
+ const append = (chunk) => {
921
+ bytes += Buffer.byteLength(chunk, "utf8");
922
+ if (bytes > 32 * 1024 * 1024)
923
+ throw new StateQLError("OUTPUT_LIMIT_EXCEEDED", "Export exceeds 32 MiB.");
924
+ chunks.push(chunk);
925
+ };
926
+ const csvCell = (value) => {
927
+ let valueText = value === null || value === undefined ? "" : typeof value === "object" ? JSON.stringify(value) : String(value);
928
+ if (typeof value !== "number" && /^[\s\u0000-\u001f]*[=+@-]|^[\t\r\n]/u.test(valueText))
929
+ valueText = "'" + valueText;
930
+ return /[",\r\n]/u.test(valueText) ? '"' + valueText.replaceAll('"', '""') + '"' : valueText;
931
+ };
932
+ if (format === "json")
933
+ append("[");
934
+ if (format === "csv")
935
+ append(columns.map(csvCell).join(",") + "\n");
936
+ const deadline = Date.now() + 30_000;
937
+ for (let index = 0; index < rows.length; index++) {
938
+ if (index % 100 === 0) {
939
+ await new Promise(resolve => setImmediate(resolve));
940
+ signal?.throwIfAborted();
941
+ this.signal?.throwIfAborted();
942
+ if (Date.now() > deadline)
943
+ throw new StateQLError("DEADLINE_EXCEEDED", "Export preparation timed out.");
944
+ }
945
+ const row = rows[index];
946
+ append(format === "csv" ? columns.map(column => csvCell(row[column])).join(",") + "\n"
947
+ : (format === "json" && index ? "," : "") + JSON.stringify(row) + (format === "jsonl" ? "\n" : ""));
948
+ }
949
+ signal?.throwIfAborted();
950
+ if (format === "json")
951
+ append("]\n");
952
+ this.panelResult(id);
953
+ return { data: { content: chunks.join(""), format, rows: rows.length }, handle: id };
954
+ }));
955
+ }
956
+ async readTable(table, limit = 1000, options = {}) {
957
+ return this.commandContexts.run(mergeCommandExecutionContext(this.commandContexts.getStore(), { signal: options.signal, origin: options.origin ?? "api" }), async () => {
958
+ if (!table || typeof table.name !== "string" || !table.name || table.name.length > 500 || table.name.includes("\0") ||
959
+ (table.schema !== undefined && (typeof table.schema !== "string" || !table.schema || table.schema.length > 500 || table.schema.includes("\0"))) ||
960
+ !Number.isSafeInteger(limit) || limit < 1 || limit > 10_000)
961
+ throw new StateQLError("INVALID_COMMAND", "Invalid table or sample limit.");
962
+ const snapshot = this.snapshot({ historyLimit: 1 });
963
+ const driver = snapshot.connection?.driver;
964
+ if (!driver)
965
+ throw new StateQLError("CONNECTION_NOT_FOUND", "Connect to a database first.");
966
+ if (driver === "sqlite" && table.schema && table.schema !== "main")
967
+ throw new StateQLError("INVALID_COMMAND", "Only the main SQLite schema is supported.");
968
+ if (driver === "mongodb" && table.schema)
969
+ throw new StateQLError("INVALID_COMMAND", "MongoDB collections do not accept a schema.");
970
+ const quote = (name) => driver === "mysql" ? "\`" + name.replaceAll("\`", "\`\`") + "\`" : '"' + name.replaceAll('"', '""') + '"';
971
+ const qualified = [table.schema, table.name].filter((part) => Boolean(part)).map(quote).join(".");
972
+ const query = driver === "mongodb" ? JSON.stringify({ operation: "find", collection: table.name, options: { limit } }, null, 2)
973
+ : "SELECT * FROM " + qualified + " LIMIT " + limit;
974
+ const metadata = await this.editableMetadata(table, options);
975
+ const response = driver === "mongodb"
976
+ ? await this.mongoQuery({ operation: "find", collection: table.name, options: { limit } }, options)
977
+ : await this.query(query, options);
978
+ if (!response.ok)
979
+ return response;
980
+ if (this.tableResults.size >= 20)
981
+ this.tableResults.delete(this.tableResults.keys().next().value);
982
+ this.tableResults.set(response.data.result_id, metadata);
983
+ return { ...response, data: { ...response.data, table, sample_limit: limit, query } };
984
+ });
985
+ }
986
+ async planTableUpdate(token, changes, options = {}) {
987
+ return this.commandContexts.run(mergeCommandExecutionContext(this.commandContexts.getStore(), { signal: options.signal, origin: options.origin ?? "api" }), () => this.run("table.plan", async (session) => {
988
+ this.rejectDuringStagedTransaction(session, "Table edits");
989
+ const connection = this.requireConnection(session);
990
+ const original = this.editTokens.get(token);
991
+ if (!original || original.sessionId !== session.id || original.connectionId !== connection.id ||
992
+ original.stateVersion !== version(connection) || original.expires <= this.now().getTime())
993
+ throw new StateQLError("STALE_PLAN", "Row identity expired or the connection changed. Reload the row.");
994
+ if (connection.read_only)
995
+ throw new StateQLError("READ_ONLY_CONNECTION", "This connection is read-only.");
996
+ const metadata = await this.editableMetadata(original.metadata.table, options);
997
+ if (JSON.stringify(metadata) !== JSON.stringify(original.metadata))
998
+ throw new StateQLError("STALE_PLAN", "Table metadata changed. Reload the table.");
999
+ const update = { metadata, original: original.original, changes };
1000
+ const compiled = compileTableUpdate(update);
1001
+ const context = this.executionContext(options);
1002
+ const source = await this.resolveConnectionSource(connection, session, "plan", "read", context);
1003
+ const adapter = connection.driver === "mongodb" ? await this.openMongoAdapter(connection, context, source) : await this.openAdapter(connection, context, source);
1004
+ try {
1005
+ const plan = this.store.savePlan({ sessionId: session.id, ownerActorId: this.actorId, connectionId: connection.id,
1006
+ sql: compiled.sql, parameters: [JSON.stringify(update)], statementType: "table.update", stateVersion: version(connection),
1007
+ stateSignature: await adapter.signature(), destructive: false, allowUnbounded: false, allowDestructive: false,
1008
+ expiresAt: new Date(original.expires).toISOString() });
1009
+ return { data: { plan_id: plan.id, statement_type: plan.statement_type, destructive: false, requires_confirmation: true, required_overrides: [],
1010
+ state_version: plan.state_version, owner_actor_id: this.actorId, expires_at: plan.expires_at }, handle: plan.id };
1011
+ }
1012
+ finally {
1013
+ await closeAdapterQuietly(adapter);
1014
+ }
1015
+ }));
1016
+ }
1017
+ async editableMetadata(table, options) {
1018
+ const snapshot = this.snapshot({ historyLimit: 1 });
1019
+ const driver = snapshot.connection.driver;
1020
+ const unavailable = { table, driver, columns: [], writable: false, reason: "This table or its values cannot be edited safely." };
1021
+ // The legacy inspection API splits qualified names. Fail closed for ambiguous names.
1022
+ if (table.name.includes(".") || table.schema?.includes("."))
1023
+ return { ...unavailable, reason: "Editing identifiers containing dots is not supported." };
1024
+ const name = driver === "sqlite" || driver === "mongodb" ? table.name : [table.schema, table.name].filter(Boolean).join(".");
1025
+ const response = await this.inspect("editable", name, options);
1026
+ if (!response.ok)
1027
+ return { ...unavailable, reason: response.error.code };
1028
+ const value = response.data;
1029
+ if (!value || !Array.isArray(value.columns) || value.columns.length > 100)
1030
+ return unavailable;
1031
+ return { table, driver, columns: value.columns, writable: value.writable === true && !snapshot.connection.read_only,
1032
+ ...(value.writable ? {} : { reason: "Only ordinary tables with transactional writes and a primary key can be edited." }) };
1033
+ }
1034
+ panelResult(id) {
1035
+ if (typeof id !== "string" || !id || id.length > 200)
1036
+ throw new StateQLError("INVALID_COMMAND", "A result ID is required.");
1037
+ const session = this.store.ensureSession(this.sessionName);
1038
+ if (!this.store.isSessionMember(session.id, this.actorId))
1039
+ this.throwMembershipDenied(session);
1040
+ const result = this.requireResult(id, session);
1041
+ if (result.id !== id)
1042
+ throw new StateQLError("INVALID_COMMAND", "Use an immutable result ID, not an alias.");
1043
+ if (Date.parse(result.expires_at) <= this.now().getTime())
1044
+ throw new StateQLError("RESULT_EXPIRED", "Result expired. Run the query again.");
1045
+ if (result.row_count > 10_000 || Buffer.byteLength(result.rows_json, "utf8") > 16 * 1024 * 1024)
1046
+ throw new StateQLError("OUTPUT_LIMIT_EXCEEDED", "Stored result exceeds browser limits.");
1047
+ return result;
1048
+ }
1049
+ fullResultRows(result) {
1050
+ if (this.panelRows?.id !== result.id || this.panelRows.json !== result.rows_json)
1051
+ this.panelRows = { id: result.id, json: result.rows_json, rows: this.store.resultRows(result) };
1052
+ return this.panelRows.rows;
1053
+ }
855
1054
  async exportResult(idOrAlias, output, format = "csv") {
856
1055
  return this.withResult("export", idOrAlias, async (result) => {
857
1056
  const rows = this.store.resultRows(result);
@@ -1101,7 +1300,7 @@ export class StateQL {
1101
1300
  finally {
1102
1301
  await closeAdapterQuietly(adapter);
1103
1302
  }
1104
- });
1303
+ }, undefined, table);
1105
1304
  }
1106
1305
  async plan(sql, options = {}) {
1107
1306
  return this.run("plan", async (session) => {
@@ -1254,14 +1453,18 @@ export class StateQL {
1254
1453
  if (Date.parse(plan.expires_at) <= this.now().getTime()) {
1255
1454
  throw new StateQLError("STALE_PLAN", "Plan has expired.");
1256
1455
  }
1257
- const nativePlan = plan.statement_type.startsWith("mongo.");
1456
+ const tableUpdate = plan.statement_type === "table.update" ? parseTableUpdate(plan.parameters) : undefined;
1457
+ const compiled = tableUpdate ? compileTableUpdate(tableUpdate) : undefined;
1458
+ if (compiled && compiled.sql !== plan.sql)
1459
+ throw new StateQLError("STALE_PLAN", "Stored update does not match its plan.");
1460
+ const nativePlan = tableUpdate?.metadata.driver === "mongodb" || plan.statement_type.startsWith("mongo.");
1258
1461
  historySql = nativePlan ? undefined : plan.sql;
1259
- const mongoCommand = nativePlan
1462
+ const mongoCommand = compiled?.mongo ?? (nativePlan
1260
1463
  ? storedMongoPlan(plan.parameters, plan.statement_type, plan.id)
1261
- : undefined;
1262
- const planParameters = nativePlan
1464
+ : undefined);
1465
+ const planParameters = compiled?.params ?? (nativePlan
1263
1466
  ? undefined
1264
- : parseJson(plan.parameters, `plan "${plan.id}" parameters`, isSqlParameters);
1467
+ : parseJson(plan.parameters, `plan "${plan.id}" parameters`, isSqlParameters));
1265
1468
  const claimToken = this.store.nextId("claim");
1266
1469
  const claimed = this.store.claimPlan(plan.id, session.id, this.actorId, claimToken);
1267
1470
  if (!claimed) {
@@ -1280,6 +1483,8 @@ export class StateQL {
1280
1483
  if (!nativePlan && connection.driver === "mongodb") {
1281
1484
  this.rejectMongoSql(connection, "mongoPlan");
1282
1485
  }
1486
+ if (tableUpdate && JSON.stringify(await this.editableMetadata(tableUpdate.metadata.table, options)) !== JSON.stringify(tableUpdate.metadata))
1487
+ throw new StateQLError("STALE_PLAN", "Table metadata changed. Reload and plan again.");
1283
1488
  const context = this.executionContext(options);
1284
1489
  const adapterSource = await this.resolveConnectionSource(connection, session, "apply", "write", context);
1285
1490
  const adapter = nativePlan
@@ -1305,9 +1510,11 @@ export class StateQL {
1305
1510
  ? await this.performMongoExec(session, connection, mongoCommand, {
1306
1511
  allowUnbounded: Boolean(claimed.allow_unbounded),
1307
1512
  allowDestructive: Boolean(claimed.allow_destructive),
1513
+ ...(tableUpdate ? { expectedRows: 1 } : {}),
1308
1514
  }, context, { planId: claimed.id, claimToken }, adapterSource)
1309
1515
  : await this.performExec(session, connection, claimed.sql, {
1310
1516
  params: planParameters,
1517
+ ...(tableUpdate ? { expectedRows: 1 } : {}),
1311
1518
  allowUnbounded: Boolean(claimed.allow_unbounded),
1312
1519
  allowDestructive: Boolean(claimed.allow_destructive),
1313
1520
  }, context, { planId: claimed.id, claimToken }, adapterSource);
@@ -1713,7 +1920,7 @@ export class StateQL {
1713
1920
  });
1714
1921
  }
1715
1922
  try {
1716
- const write = await adapter.write(sql, parameters);
1923
+ const write = await adapter.write(sql, parameters, options.expectedRows);
1717
1924
  try {
1718
1925
  const finalized = planClaim
1719
1926
  ? this.store.finishPlannedOperation({
@@ -1761,6 +1968,8 @@ export class StateQL {
1761
1968
  }
1762
1969
  if (error instanceof AdapterWriteError && !error.outcomeUnknown) {
1763
1970
  this.store.failOperation(operation.id);
1971
+ if (error.message.startsWith("ROW_CONFLICT:"))
1972
+ throw new StateQLError("ROW_CONFLICT", "The row changed or no longer matches. Reload before editing.");
1764
1973
  throw new StateQLError("QUERY_FAILED", safeCredentialErrorMessage(error, adapterSource), { executed: true });
1765
1974
  }
1766
1975
  this.store.markOperationOutcomeUnknown(operation.id);
@@ -1903,7 +2112,7 @@ export class StateQL {
1903
2112
  });
1904
2113
  }
1905
2114
  try {
1906
- const write = await adapter.write(value);
2115
+ const write = await adapter.write(value, options.expectedRows);
1907
2116
  try {
1908
2117
  const finalized = planClaim
1909
2118
  ? this.store.finishPlannedOperation({
@@ -1952,6 +2161,8 @@ export class StateQL {
1952
2161
  }
1953
2162
  if (error instanceof AdapterWriteError && !error.outcomeUnknown) {
1954
2163
  this.store.failOperation(operation.id);
2164
+ if (error.message.startsWith("ROW_CONFLICT:"))
2165
+ throw new StateQLError("ROW_CONFLICT", "The document changed or was removed. Reload before editing.");
1955
2166
  throw new StateQLError("QUERY_FAILED", safeCredentialErrorMessage(error, adapterSource), { executed: true });
1956
2167
  }
1957
2168
  this.store.markOperationOutcomeUnknown(operation.id);
@@ -2067,39 +2278,41 @@ export class StateQL {
2067
2278
  }
2068
2279
  async resolveCredential(reference, source, session, operation, access, context, details = {}) {
2069
2280
  const resolver = this.credentialResolver;
2281
+ const credentialContext = createAdapterContext(this.credentialTimeoutMs, context.signal);
2282
+ let value;
2070
2283
  if (!resolver) {
2071
- if (context.signal?.aborted) {
2284
+ if (credentialContext.signal?.aborted) {
2072
2285
  throw credentialStateQLError(reference, new CredentialResolutionError("cancelled"));
2073
2286
  }
2074
- if (context.deadline <= Date.now()) {
2287
+ if (credentialContext.deadline <= Date.now()) {
2075
2288
  throw credentialStateQLError(reference, new CredentialResolutionError("timeout"));
2076
2289
  }
2077
- if (source === "secret_env") {
2078
- const value = env[reference];
2079
- if (value)
2080
- return value;
2081
- }
2082
- throw credentialStateQLError(reference, new CredentialResolutionError("unavailable"));
2290
+ if (source === "secret_env")
2291
+ value = env[reference];
2083
2292
  }
2084
- const request = {
2085
- reference,
2086
- source,
2087
- actorId: this.actorId,
2088
- session: { id: session.id, name: session.name },
2089
- operation,
2090
- access,
2091
- ...(context.signal ? { signal: context.signal } : {}),
2092
- ...details,
2093
- };
2094
- try {
2095
- const value = await resolveCredentialBeforeDeadline(resolver, request, context);
2096
- if (!value)
2097
- throw new CredentialResolutionError("unavailable");
2098
- return value;
2293
+ else {
2294
+ const request = {
2295
+ reference,
2296
+ source,
2297
+ actorId: this.actorId,
2298
+ session: { id: session.id, name: session.name },
2299
+ operation,
2300
+ access,
2301
+ ...(context.signal ? { signal: context.signal } : {}),
2302
+ ...details,
2303
+ };
2304
+ try {
2305
+ value = await resolveCredentialBeforeDeadline(resolver, request, credentialContext);
2306
+ }
2307
+ catch (error) {
2308
+ throw credentialStateQLError(reference, error);
2309
+ }
2099
2310
  }
2100
- catch (error) {
2101
- throw credentialStateQLError(reference, error);
2311
+ if (!value) {
2312
+ throw credentialStateQLError(reference, new CredentialResolutionError("unavailable"));
2102
2313
  }
2314
+ context.deadline = Date.now() + (context.timeoutMs ?? this.timeoutMs);
2315
+ return value;
2103
2316
  }
2104
2317
  async openAdapter(connection, context, source) {
2105
2318
  try {
@@ -2150,7 +2363,7 @@ export class StateQL {
2150
2363
  result.state_version === stateVersion &&
2151
2364
  result.state_signature === stateSignature);
2152
2365
  }
2153
- async run(command, action, historySql) {
2366
+ async run(command, action, historySql, historyTarget) {
2154
2367
  const started = performance.now();
2155
2368
  const origin = this.commandContexts.getStore()?.origin ?? "legacy";
2156
2369
  let session = this.store.ensureSession(this.sessionName);
@@ -2177,6 +2390,7 @@ export class StateQL {
2177
2390
  actorId: this.actorId,
2178
2391
  origin,
2179
2392
  command,
2393
+ target: historyTarget,
2180
2394
  ...(result.handle ? { handle: result.handle } : {}),
2181
2395
  ...(sqlText !== undefined ? { sql: sqlText } : {}),
2182
2396
  executed: result.executed ?? false,
@@ -2209,6 +2423,7 @@ export class StateQL {
2209
2423
  actorId: this.actorId,
2210
2424
  origin,
2211
2425
  command,
2426
+ target: historyTarget,
2212
2427
  ...(sqlText !== undefined ? { sql: sqlText } : {}),
2213
2428
  executed: stateqlError.details.executed,
2214
2429
  cached: false,
@@ -2239,6 +2454,7 @@ function historyEntry(item) {
2239
2454
  origin: item.origin,
2240
2455
  command: item.command,
2241
2456
  sql: item.sql,
2457
+ ...(item.target ? { target: item.target } : {}),
2242
2458
  handle: item.handle,
2243
2459
  executed: Boolean(item.executed),
2244
2460
  cached: Boolean(item.cached),
@@ -2435,19 +2651,19 @@ function credentialStateQLError(reference, error) {
2435
2651
  case "cancelled":
2436
2652
  return new StateQLError("OPERATION_CANCELLED", "Credential resolution was cancelled.", { retryable: true });
2437
2653
  case "timeout":
2438
- return new StateQLError("DEADLINE_EXCEEDED", "Credential resolution exceeded the operation deadline.", { retryable: true });
2654
+ return new StateQLError("DEADLINE_EXCEEDED", "Credential resolution exceeded the credential deadline.", { retryable: true });
2439
2655
  }
2440
2656
  }
2441
2657
  return new StateQLError("CREDENTIAL_RESOLUTION_FAILED", `Credential reference "${reference}" could not be resolved.`, { retryable: true });
2442
2658
  }
2443
2659
  function safeCredentialErrorMessage(error, source) {
2444
2660
  return redact(errorMessage(error).split(source).join("[credential redacted]"))
2445
- .replace(/\b([a-z][a-z0-9+.-]*:\/\/)[^\s/@]+(?::[^\s/@]*)?@/giu, "$1***@");
2661
+ .replace(/\b([a-z][a-z0-9+.-]*:\/\/)[^\s\/@]+(?::[^\s\/@]*)?@/giu, "$1***@");
2446
2662
  }
2447
- function executionTimeout(value) {
2448
- const timeout = positiveInteger(value, "timeoutMs");
2663
+ function executionTimeout(value, name = "timeoutMs") {
2664
+ const timeout = positiveInteger(value, name);
2449
2665
  if (timeout > 2_147_483_647) {
2450
- throw new StateQLError("INVALID_COMMAND", "timeoutMs cannot exceed 2147483647 milliseconds.");
2666
+ throw new StateQLError("INVALID_COMMAND", `${name} cannot exceed 2147483647 milliseconds.`);
2451
2667
  }
2452
2668
  return timeout;
2453
2669
  }
@@ -103,6 +103,7 @@ export interface HistoryRecord {
103
103
  origin: CommandOrigin;
104
104
  command: string;
105
105
  sql: string | null;
106
+ target: string | null;
106
107
  handle: string | null;
107
108
  executed: number;
108
109
  cached: number;
@@ -278,6 +279,7 @@ export declare class StateStore {
278
279
  origin?: CommandOrigin;
279
280
  command: string;
280
281
  sql?: string;
282
+ target?: string;
281
283
  handle?: string;
282
284
  executed: boolean;
283
285
  cached: boolean;
package/dist/src/store.js CHANGED
@@ -797,10 +797,10 @@ export class StateStore {
797
797
  const id = input.id ?? this.nextId("cmd");
798
798
  this.db
799
799
  .prepare(`INSERT INTO history
800
- (id, timestamp, session_id, actor_id, origin, command, sql, handle,
800
+ (id, timestamp, session_id, actor_id, origin, command, sql, target, handle,
801
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
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
803
+ .run(id, this.now().toISOString(), input.sessionId, input.actorId, input.origin ?? "legacy", input.command, boundedHistorySql(input.sql), input.target?.slice(0, 1024) ?? null, input.handle ?? null, input.executed ? 1 : 0, input.cached ? 1 : 0, input.success ? 1 : 0, input.errorCode ?? null);
804
804
  this.db
805
805
  .prepare(`DELETE FROM history
806
806
  WHERE rowid IN (
@@ -0,0 +1,36 @@
1
+ import type { Driver, MongoWriteCommand, Row, SqlParameters } from "./types.js";
2
+ export interface TableIdentity {
3
+ schema?: string;
4
+ name: string;
5
+ }
6
+ export interface EditableColumn {
7
+ name: string;
8
+ type: string;
9
+ nullable: boolean;
10
+ generated: boolean;
11
+ key: number;
12
+ }
13
+ export interface EditableTable {
14
+ table: TableIdentity;
15
+ driver: Driver;
16
+ columns: EditableColumn[];
17
+ writable: boolean;
18
+ reason?: string;
19
+ }
20
+ export interface TableChange {
21
+ set?: Record<string, unknown>;
22
+ unset?: string[];
23
+ }
24
+ export interface TableUpdate {
25
+ metadata: EditableTable;
26
+ original: Row;
27
+ changes: TableChange;
28
+ }
29
+ export declare function quoteIdentifier(name: string, driver: Driver): string;
30
+ export declare function editableRow(metadata: EditableTable, row: Row): boolean;
31
+ export declare function compileTableUpdate(update: TableUpdate): {
32
+ sql: string;
33
+ params: SqlParameters;
34
+ mongo?: MongoWriteCommand;
35
+ };
36
+ export declare function parseTableUpdate(parameters: string): TableUpdate;
@@ -0,0 +1,115 @@
1
+ import { BSON } from "mongodb";
2
+ import { StateQLError } from "./errors.js";
3
+ export function quoteIdentifier(name, driver) {
4
+ if (!name || name.length > 500 || name.includes("\0"))
5
+ throw new StateQLError("INVALID_COMMAND", "Invalid database identifier.");
6
+ return driver === "mysql" ? "`" + name.replaceAll("`", "``") + "`" : '"' + name.replaceAll('"', '""') + '"';
7
+ }
8
+ function comparable(value, column) {
9
+ if (value === null)
10
+ return column.nullable;
11
+ const type = column.type.toLowerCase();
12
+ if (/^(?:tinyint|smallint|mediumint|int|integer|bigint|int2|int4|int8)(?:\b|$)/u.test(type))
13
+ return typeof value === "number" ? Number.isSafeInteger(value) : typeof value === "string" && /^-?\d+$/u.test(value);
14
+ if (/^(?:numeric|decimal)(?:\b|$)/u.test(type))
15
+ return typeof value === "string" && /^-?\d+(?:\.\d+)?$/u.test(value);
16
+ if (/^(?:boolean|bool)$/u.test(type))
17
+ return typeof value === "boolean";
18
+ if (/^(?:text|varchar|character varying|nvarchar|ntext|longtext|mediumtext|tinytext)(?:\b|$)/u.test(type))
19
+ return typeof value === "string" && value.length <= 16_384;
20
+ return false;
21
+ }
22
+ function mongoScalarType(value) {
23
+ if (value === null)
24
+ return "null";
25
+ if (typeof value === "string")
26
+ return "string";
27
+ if (typeof value === "boolean")
28
+ return "bool";
29
+ if (!value || typeof value !== "object" || Array.isArray(value) || Object.keys(value).length !== 1)
30
+ return undefined;
31
+ const types = { $oid: "objectId", $numberInt: "int", $numberLong: "long", $numberDecimal: "decimal", $date: "date" };
32
+ return types[Object.keys(value)[0]];
33
+ }
34
+ export function editableRow(metadata, row) {
35
+ if (!metadata.writable || Buffer.byteLength(JSON.stringify(row), "utf8") > 32 * 1024)
36
+ return false;
37
+ if (metadata.driver === "mongodb")
38
+ return Object.hasOwn(row, "_id") && Object.entries(row).every(([key, value]) => !key.startsWith("$") && !key.includes(".") && mongoScalarType(value) !== undefined);
39
+ const keys = metadata.columns.filter(column => column.key > 0);
40
+ return keys.length > 0 && keys.every(column => row[column.name] !== null && row[column.name] !== undefined) &&
41
+ metadata.columns.length === Object.keys(row).length && metadata.columns.every(column => Object.hasOwn(row, column.name) && comparable(row[column.name], column));
42
+ }
43
+ export function compileTableUpdate(update) {
44
+ const { metadata, original, changes } = update;
45
+ if (!editableRow(metadata, original))
46
+ throw new StateQLError("INVALID_COMMAND", "This row has no safe editable identity or comparison types.");
47
+ const entries = Object.entries(changes.set ?? {});
48
+ const unset = changes.unset ?? [];
49
+ if ((!entries.length && !unset.length) || entries.length + unset.length > 100 || Buffer.byteLength(JSON.stringify(changes), "utf8") > 32 * 1024)
50
+ throw new StateQLError("INVALID_COMMAND", "Provide bounded changed cells.");
51
+ if (metadata.driver === "mongodb") {
52
+ for (const name of [...entries.map(([name]) => name), ...unset]) {
53
+ if (name === "_id" || !Object.hasOwn(original, name) || name.startsWith("$") || name.includes("."))
54
+ throw new StateQLError("INVALID_COMMAND", "Only existing non-key fields can be changed.");
55
+ }
56
+ const document = BSON.EJSON.deserialize(original, { relaxed: false });
57
+ const mongo = {
58
+ operation: "updateOne", collection: metadata.table.name,
59
+ filter: { _id: document._id, $expr: { $and: [
60
+ { $eq: ["$$ROOT", { $literal: document }] },
61
+ ...Object.entries(original).map(([name, value]) => ({ $eq: [{ $type: "$" + name }, mongoScalarType(value)] })),
62
+ ] } },
63
+ update: { ...(entries.length ? { $set: BSON.EJSON.deserialize(Object.fromEntries(entries), { relaxed: false }) } : {}), ...(unset.length ? { $unset: Object.fromEntries(unset.map(name => [name, ""])) } : {}) },
64
+ options: { upsert: false, collation: { locale: "simple" } },
65
+ };
66
+ return { sql: "MongoDB conditional row update", params: [], mongo };
67
+ }
68
+ if (unset.length)
69
+ throw new StateQLError("INVALID_COMMAND", "SQL columns cannot be unset; use null where allowed.");
70
+ const driver = metadata.driver;
71
+ const quote = (name) => quoteIdentifier(name, driver);
72
+ const params = [];
73
+ const parameter = (value) => { params.push(value); return driver === "postgres" ? "$" + params.length : "?"; };
74
+ const assignments = entries.map(([name, value]) => {
75
+ const column = metadata.columns.find(column => column.name === name);
76
+ if (!column || column.key || column.generated || !comparable(value, column))
77
+ throw new StateQLError("INVALID_COMMAND", "A changed column is generated, a key, or has an unsupported value.");
78
+ return quote(name) + " = " + parameter(value);
79
+ });
80
+ const predicates = metadata.columns.map(column => {
81
+ const name = quote(column.name);
82
+ if (original[column.name] === null)
83
+ return name + " IS NULL";
84
+ const placeholder = parameter(original[column.name]);
85
+ const isText = /text|varchar|character varying/u.test(column.type.toLowerCase());
86
+ if (driver === "postgres") {
87
+ const left = isText ? "convert_to(" + name + ", 'UTF8')" : name;
88
+ const right = isText ? "convert_to(" + placeholder + ", 'UTF8')" : placeholder;
89
+ return left + " = " + right;
90
+ }
91
+ if (driver === "mysql")
92
+ return isText ? "CAST(" + name + " AS BINARY) = CAST(" + placeholder + " AS BINARY)" : name + " = " + placeholder;
93
+ return isText ? "CAST(" + name + " AS BLOB) IS CAST(" + placeholder + " AS BLOB)" : name + " IS " + placeholder;
94
+ });
95
+ const table = [metadata.table.schema, metadata.table.name].filter((name) => Boolean(name)).map(quote).join(".");
96
+ return { sql: "UPDATE " + table + " SET " + assignments.join(", ") + " WHERE " + predicates.join(" AND "), params };
97
+ }
98
+ export function parseTableUpdate(parameters) {
99
+ try {
100
+ const outer = JSON.parse(parameters);
101
+ if (!Array.isArray(outer) || outer.length !== 1 || typeof outer[0] !== "string" || outer[0].length > 128 * 1024)
102
+ throw new Error();
103
+ const update = JSON.parse(outer[0]);
104
+ if (!update || !update.metadata || !["sqlite", "postgres", "mysql", "mongodb"].includes(update.metadata.driver) ||
105
+ !Array.isArray(update.metadata.columns) || update.metadata.columns.length > 100 ||
106
+ !update.metadata.columns.every(column => typeof column.name === "string" && typeof column.type === "string" && typeof column.nullable === "boolean" && typeof column.generated === "boolean" && Number.isSafeInteger(column.key)) ||
107
+ !update.original || !update.changes || typeof update.changes !== "object")
108
+ throw new Error();
109
+ compileTableUpdate(update);
110
+ return update;
111
+ }
112
+ catch {
113
+ throw new StateQLError("STALE_PLAN", "The stored table update is invalid. Reload the row and plan again.");
114
+ }
115
+ }
@@ -80,6 +80,7 @@ export interface HistoryEntry {
80
80
  origin: CommandOrigin;
81
81
  command: string;
82
82
  sql: string | null;
83
+ target?: string | null;
83
84
  handle: string | null;
84
85
  executed: boolean;
85
86
  cached: boolean;
@@ -213,6 +214,8 @@ export interface StateQLOptions extends ExecutionOptions {
213
214
  maxResultBytes?: number;
214
215
  maxStateBytes?: number;
215
216
  credentialResolver?: CredentialResolver;
217
+ /** Maximum time allowed for one credential resolution; defaults to two minutes. */
218
+ credentialTimeoutMs?: number;
216
219
  now?: () => Date;
217
220
  }
218
221
  export type StateQLActorOptions = Omit<StateQLOptions, "session" | "actor"> & {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fadhilp/stateql",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Stateful, agent-oriented database CLI for safe result reuse",
5
5
  "repository": {
6
6
  "type": "git",
@@ -25,7 +25,7 @@
25
25
  ],
26
26
  "scripts": {
27
27
  "build": "tsc -p tsconfig.json",
28
- "test": "npm run build && node --test dist/test/cli.test.js dist/test/credential.test.js dist/test/mongodb.test.js dist/test/mysql.test.js dist/test/postgres.test.js dist/test/query.test.js dist/test/sqlite.test.js dist/test/store.test.js dist/test/terminal.test.js dist/test/transaction.test.js dist/test/write.test.js",
28
+ "test": "npm run build && node --test dist/test/cli.test.js dist/test/credential.test.js dist/test/mongodb.test.js dist/test/mysql.test.js dist/test/postgres.test.js dist/test/query.test.js dist/test/sqlite.test.js dist/test/store.test.js dist/test/terminal.test.js dist/test/transaction.test.js dist/test/write.test.js dist/test/panel.test.js",
29
29
  "release": "npm version",
30
30
  "version": "npm install",
31
31
  "prepack": "npm test"