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