@fadhilp/stateql 0.8.1 → 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.
@@ -29,7 +29,7 @@ export interface Adapter {
29
29
  readonly confidence: StateConfidence;
30
30
  ping(): Promise<void>;
31
31
  read(sql: string, params: SqlParameters): Promise<ReadResult>;
32
- write(sql: string, params: SqlParameters): Promise<WriteResult>;
32
+ write(sql: string, params: SqlParameters, expectedRows?: 1): Promise<WriteResult>;
33
33
  writeBatch(operations: OperationRecord[], isolation: string): Promise<WriteResult[]>;
34
34
  signature(): Promise<string>;
35
35
  inspect(kind: string, table?: string): Promise<unknown>;
@@ -96,8 +96,8 @@ class SQLiteAdapter {
96
96
  async read(sql, params) {
97
97
  return this.call("read", [sql, params], false, false);
98
98
  }
99
- async write(sql, params) {
100
- return this.call("write", [sql, params], true, false);
99
+ async write(sql, params, expectedRows) {
100
+ return this.call("write", [sql, params, expectedRows], true, false);
101
101
  }
102
102
  async writeBatch(operations, isolation) {
103
103
  return this.call("writeBatch", [operations, isolation], true, true);
@@ -251,7 +251,7 @@ class PostgresAdapter {
251
251
  throw error;
252
252
  }
253
253
  }
254
- async write(sql, params) {
254
+ async write(sql, params, expectedRows) {
255
255
  if (this.readOnly)
256
256
  throw new Error("Connection is read-only.");
257
257
  try {
@@ -267,6 +267,8 @@ class PostgresAdapter {
267
267
  try {
268
268
  await this.setLocalDeadline();
269
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.");
270
272
  committing = true;
271
273
  await this.query("COMMIT", [], true);
272
274
  return { affectedRows: result.rowCount ?? 0 };
@@ -358,6 +360,18 @@ class PostgresAdapter {
358
360
  const [schema, name] = table.includes(".")
359
361
  ? table.split(".", 2)
360
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
+ }
361
375
  await this.setLocalDeadline();
362
376
  const columns = await this.query(`SELECT column_name AS name, data_type AS type,
363
377
  is_nullable = 'YES' AS nullable
@@ -472,7 +486,7 @@ class MySqlAdapter {
472
486
  throw error;
473
487
  }
474
488
  }
475
- async write(sql, params) {
489
+ async write(sql, params, expectedRows) {
476
490
  if (this.readOnly)
477
491
  throw new Error("Connection is read-only.");
478
492
  let values;
@@ -490,13 +504,22 @@ class MySqlAdapter {
490
504
  throw error;
491
505
  throw new AdapterWriteError(errorText(error), false);
492
506
  }
507
+ let committing = false;
493
508
  try {
494
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;
495
513
  await this.query("COMMIT", [], true, false);
496
514
  return { affectedRows: mysqlAffectedRows(result) };
497
515
  }
498
516
  catch (error) {
499
- 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
+ }
500
523
  if (error instanceof AdapterExecutionError)
501
524
  throw error;
502
525
  throw new AdapterWriteError(errorText(error), true);
@@ -584,6 +607,16 @@ class MySqlAdapter {
584
607
  const name = separator === -1 ? table : table.slice(separator + 1);
585
608
  if (!schema || !name)
586
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
+ }
587
620
  const [columnResult] = await this.query(`SELECT COLUMN_NAME AS name, DATA_TYPE AS type,
588
621
  IS_NULLABLE = 'YES' AS nullable
589
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;
@@ -17,6 +18,9 @@ export declare class StateQL {
17
18
  private readonly credentialResolver?;
18
19
  private readonly now;
19
20
  private closed;
21
+ private readonly tableResults;
22
+ private readonly editTokens;
23
+ private panelRows?;
20
24
  constructor(options?: StateQLOptions);
21
25
  close(): void;
22
26
  [Symbol.dispose](): void;
@@ -47,6 +51,40 @@ export declare class StateQL {
47
51
  count(idOrAlias: string): Promise<Response<CountData>>;
48
52
  columns(idOrAlias: string): Promise<Response<ColumnsData>>;
49
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;
50
88
  exportResult(idOrAlias: string, output: string, format?: "json" | "jsonl" | "csv"): Promise<Response<ExportData>>;
51
89
  exec(sql: string, options?: ExecOptions): Promise<Response<ExecData>>;
52
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";
@@ -48,6 +50,10 @@ export class StateQL {
48
50
  credentialResolver;
49
51
  now;
50
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;
51
57
  constructor(options = {}) {
52
58
  this.now = options.now ?? (() => new Date());
53
59
  this.sessionName = options.session ?? env.STQL_SESSION ?? "default";
@@ -83,6 +89,9 @@ export class StateQL {
83
89
  if (this.closed)
84
90
  return;
85
91
  this.closed = true;
92
+ this.panelRows = undefined;
93
+ this.tableResults.clear();
94
+ this.editTokens.clear();
86
95
  this.store.close();
87
96
  }
88
97
  [Symbol.dispose]() {
@@ -855,6 +864,193 @@ export class StateQL {
855
864
  };
856
865
  });
857
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
+ }
858
1054
  async exportResult(idOrAlias, output, format = "csv") {
859
1055
  return this.withResult("export", idOrAlias, async (result) => {
860
1056
  const rows = this.store.resultRows(result);
@@ -1104,7 +1300,7 @@ export class StateQL {
1104
1300
  finally {
1105
1301
  await closeAdapterQuietly(adapter);
1106
1302
  }
1107
- });
1303
+ }, undefined, table);
1108
1304
  }
1109
1305
  async plan(sql, options = {}) {
1110
1306
  return this.run("plan", async (session) => {
@@ -1257,14 +1453,18 @@ export class StateQL {
1257
1453
  if (Date.parse(plan.expires_at) <= this.now().getTime()) {
1258
1454
  throw new StateQLError("STALE_PLAN", "Plan has expired.");
1259
1455
  }
1260
- 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.");
1261
1461
  historySql = nativePlan ? undefined : plan.sql;
1262
- const mongoCommand = nativePlan
1462
+ const mongoCommand = compiled?.mongo ?? (nativePlan
1263
1463
  ? storedMongoPlan(plan.parameters, plan.statement_type, plan.id)
1264
- : undefined;
1265
- const planParameters = nativePlan
1464
+ : undefined);
1465
+ const planParameters = compiled?.params ?? (nativePlan
1266
1466
  ? undefined
1267
- : parseJson(plan.parameters, `plan "${plan.id}" parameters`, isSqlParameters);
1467
+ : parseJson(plan.parameters, `plan "${plan.id}" parameters`, isSqlParameters));
1268
1468
  const claimToken = this.store.nextId("claim");
1269
1469
  const claimed = this.store.claimPlan(plan.id, session.id, this.actorId, claimToken);
1270
1470
  if (!claimed) {
@@ -1283,6 +1483,8 @@ export class StateQL {
1283
1483
  if (!nativePlan && connection.driver === "mongodb") {
1284
1484
  this.rejectMongoSql(connection, "mongoPlan");
1285
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.");
1286
1488
  const context = this.executionContext(options);
1287
1489
  const adapterSource = await this.resolveConnectionSource(connection, session, "apply", "write", context);
1288
1490
  const adapter = nativePlan
@@ -1308,9 +1510,11 @@ export class StateQL {
1308
1510
  ? await this.performMongoExec(session, connection, mongoCommand, {
1309
1511
  allowUnbounded: Boolean(claimed.allow_unbounded),
1310
1512
  allowDestructive: Boolean(claimed.allow_destructive),
1513
+ ...(tableUpdate ? { expectedRows: 1 } : {}),
1311
1514
  }, context, { planId: claimed.id, claimToken }, adapterSource)
1312
1515
  : await this.performExec(session, connection, claimed.sql, {
1313
1516
  params: planParameters,
1517
+ ...(tableUpdate ? { expectedRows: 1 } : {}),
1314
1518
  allowUnbounded: Boolean(claimed.allow_unbounded),
1315
1519
  allowDestructive: Boolean(claimed.allow_destructive),
1316
1520
  }, context, { planId: claimed.id, claimToken }, adapterSource);
@@ -1716,7 +1920,7 @@ export class StateQL {
1716
1920
  });
1717
1921
  }
1718
1922
  try {
1719
- const write = await adapter.write(sql, parameters);
1923
+ const write = await adapter.write(sql, parameters, options.expectedRows);
1720
1924
  try {
1721
1925
  const finalized = planClaim
1722
1926
  ? this.store.finishPlannedOperation({
@@ -1764,6 +1968,8 @@ export class StateQL {
1764
1968
  }
1765
1969
  if (error instanceof AdapterWriteError && !error.outcomeUnknown) {
1766
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.");
1767
1973
  throw new StateQLError("QUERY_FAILED", safeCredentialErrorMessage(error, adapterSource), { executed: true });
1768
1974
  }
1769
1975
  this.store.markOperationOutcomeUnknown(operation.id);
@@ -1906,7 +2112,7 @@ export class StateQL {
1906
2112
  });
1907
2113
  }
1908
2114
  try {
1909
- const write = await adapter.write(value);
2115
+ const write = await adapter.write(value, options.expectedRows);
1910
2116
  try {
1911
2117
  const finalized = planClaim
1912
2118
  ? this.store.finishPlannedOperation({
@@ -1955,6 +2161,8 @@ export class StateQL {
1955
2161
  }
1956
2162
  if (error instanceof AdapterWriteError && !error.outcomeUnknown) {
1957
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.");
1958
2166
  throw new StateQLError("QUERY_FAILED", safeCredentialErrorMessage(error, adapterSource), { executed: true });
1959
2167
  }
1960
2168
  this.store.markOperationOutcomeUnknown(operation.id);
@@ -2155,7 +2363,7 @@ export class StateQL {
2155
2363
  result.state_version === stateVersion &&
2156
2364
  result.state_signature === stateSignature);
2157
2365
  }
2158
- async run(command, action, historySql) {
2366
+ async run(command, action, historySql, historyTarget) {
2159
2367
  const started = performance.now();
2160
2368
  const origin = this.commandContexts.getStore()?.origin ?? "legacy";
2161
2369
  let session = this.store.ensureSession(this.sessionName);
@@ -2182,6 +2390,7 @@ export class StateQL {
2182
2390
  actorId: this.actorId,
2183
2391
  origin,
2184
2392
  command,
2393
+ target: historyTarget,
2185
2394
  ...(result.handle ? { handle: result.handle } : {}),
2186
2395
  ...(sqlText !== undefined ? { sql: sqlText } : {}),
2187
2396
  executed: result.executed ?? false,
@@ -2214,6 +2423,7 @@ export class StateQL {
2214
2423
  actorId: this.actorId,
2215
2424
  origin,
2216
2425
  command,
2426
+ target: historyTarget,
2217
2427
  ...(sqlText !== undefined ? { sql: sqlText } : {}),
2218
2428
  executed: stateqlError.details.executed,
2219
2429
  cached: false,
@@ -2244,6 +2454,7 @@ function historyEntry(item) {
2244
2454
  origin: item.origin,
2245
2455
  command: item.command,
2246
2456
  sql: item.sql,
2457
+ ...(item.target ? { target: item.target } : {}),
2247
2458
  handle: item.handle,
2248
2459
  executed: Boolean(item.executed),
2249
2460
  cached: Boolean(item.cached),
@@ -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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fadhilp/stateql",
3
- "version": "0.8.1",
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"