@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.
- package/dist/src/adapters.d.ts +1 -1
- package/dist/src/adapters.js +38 -5
- package/dist/src/migrations.js +5 -0
- package/dist/src/mongodb.d.ts +1 -1
- package/dist/src/mongodb.js +13 -2
- package/dist/src/sqlite-process.js +8 -1
- package/dist/src/stateql.d.ts +39 -1
- package/dist/src/stateql.js +220 -9
- package/dist/src/store.d.ts +2 -0
- package/dist/src/store.js +3 -3
- package/dist/src/table-editor.d.ts +36 -0
- package/dist/src/table-editor.js +115 -0
- package/dist/src/types.d.ts +1 -0
- package/package.json +2 -2
package/dist/src/adapters.d.ts
CHANGED
|
@@ -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>;
|
package/dist/src/adapters.js
CHANGED
|
@@ -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
|
package/dist/src/migrations.js
CHANGED
|
@@ -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(`
|
package/dist/src/mongodb.d.ts
CHANGED
|
@@ -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>;
|
package/dist/src/mongodb.js
CHANGED
|
@@ -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
|
-
|
|
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();
|
package/dist/src/stateql.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import
|
|
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>>;
|
package/dist/src/stateql.js
CHANGED
|
@@ -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
|
|
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),
|
package/dist/src/store.d.ts
CHANGED
|
@@ -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
|
+
}
|
package/dist/src/types.d.ts
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fadhilp/stateql",
|
|
3
|
-
"version": "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"
|