@fadhilp/stateql 0.8.1 → 0.10.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 +140 -5
- package/dist/src/adapters.d.ts +8 -3
- package/dist/src/adapters.js +205 -5
- package/dist/src/cli.js +66 -3
- package/dist/src/connection.d.ts +1 -0
- package/dist/src/connection.js +18 -2
- package/dist/src/index.d.ts +2 -1
- package/dist/src/migrations.js +38 -0
- package/dist/src/mongodb.d.ts +5 -3
- package/dist/src/mongodb.js +66 -4
- package/dist/src/redis.d.ts +44 -0
- package/dist/src/redis.js +395 -0
- package/dist/src/sqlite-process.js +49 -1
- package/dist/src/stateql.d.ts +56 -4
- package/dist/src/stateql.js +858 -63
- package/dist/src/store.d.ts +24 -2
- package/dist/src/store.js +92 -12
- package/dist/src/table-editor.d.ts +41 -0
- package/dist/src/table-editor.js +137 -0
- package/dist/src/types.d.ts +75 -3
- package/package.json +4 -2
package/dist/src/store.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { DatabaseSync } from "node:sqlite";
|
|
2
|
-
import type { Column, CommandOrigin, Driver, MongoWriteOutcome, Row, SqlParameters, StateConfidence } from "./types.js";
|
|
2
|
+
import type { Column, CommandOrigin, Driver, HistoryCategory, MongoWriteOutcome, Row, SqlParameters, StateConfidence } from "./types.js";
|
|
3
3
|
export interface SessionRecord {
|
|
4
4
|
id: string;
|
|
5
5
|
name: string;
|
|
@@ -46,6 +46,7 @@ export interface ResultRecord {
|
|
|
46
46
|
state_confidence: StateConfidence;
|
|
47
47
|
expires_at: string;
|
|
48
48
|
created_at: string;
|
|
49
|
+
alias?: string;
|
|
49
50
|
}
|
|
50
51
|
export interface OperationRecord {
|
|
51
52
|
id: string;
|
|
@@ -101,8 +102,11 @@ export interface HistoryRecord {
|
|
|
101
102
|
session_id: string;
|
|
102
103
|
actor_id: string;
|
|
103
104
|
origin: CommandOrigin;
|
|
105
|
+
category: HistoryCategory;
|
|
106
|
+
internal: number;
|
|
104
107
|
command: string;
|
|
105
108
|
sql: string | null;
|
|
109
|
+
target: string | null;
|
|
106
110
|
handle: string | null;
|
|
107
111
|
executed: number;
|
|
108
112
|
cached: number;
|
|
@@ -141,6 +145,13 @@ export declare class StateStore {
|
|
|
141
145
|
credentialRef?: string;
|
|
142
146
|
readOnly: boolean;
|
|
143
147
|
}): ProfileRecord;
|
|
148
|
+
updateProfile(input: {
|
|
149
|
+
name: string;
|
|
150
|
+
target: string | null;
|
|
151
|
+
secretEnv: string | null;
|
|
152
|
+
credentialRef: string | null;
|
|
153
|
+
readOnly: boolean;
|
|
154
|
+
}): ProfileRecord | undefined;
|
|
144
155
|
getProfile(name: string): ProfileRecord | undefined;
|
|
145
156
|
listProfiles(): ProfileRecord[];
|
|
146
157
|
removeProfile(name: string): boolean;
|
|
@@ -177,6 +188,9 @@ export declare class StateStore {
|
|
|
177
188
|
resultRows(result: ResultRecord): Row[];
|
|
178
189
|
resultColumns(result: ResultRecord): Column[];
|
|
179
190
|
setAlias(sessionId: string, name: string, resultId: string): void;
|
|
191
|
+
generatedAlias(resultId: string): string;
|
|
192
|
+
private allocateGeneratedAlias;
|
|
193
|
+
private backfillGeneratedAliases;
|
|
180
194
|
saveOperation(input: {
|
|
181
195
|
sessionId: string;
|
|
182
196
|
actorId: string;
|
|
@@ -276,8 +290,11 @@ export declare class StateStore {
|
|
|
276
290
|
sessionId: string;
|
|
277
291
|
actorId: string;
|
|
278
292
|
origin?: CommandOrigin;
|
|
293
|
+
category?: HistoryCategory;
|
|
294
|
+
internal?: boolean;
|
|
279
295
|
command: string;
|
|
280
296
|
sql?: string;
|
|
297
|
+
target?: string;
|
|
281
298
|
handle?: string;
|
|
282
299
|
executed: boolean;
|
|
283
300
|
cached: boolean;
|
|
@@ -285,7 +302,12 @@ export declare class StateStore {
|
|
|
285
302
|
errorCode?: string;
|
|
286
303
|
id?: string;
|
|
287
304
|
}): HistoryRecord;
|
|
288
|
-
history(sessionId: string, limit: number,
|
|
305
|
+
history(sessionId: string, limit: number, input?: CommandOrigin | {
|
|
306
|
+
origin?: CommandOrigin;
|
|
307
|
+
category?: HistoryCategory;
|
|
308
|
+
internal?: boolean;
|
|
309
|
+
offset?: number;
|
|
310
|
+
}): HistoryRecord[];
|
|
289
311
|
recentOperations(sessionId: string, limit: number): OperationRecord[];
|
|
290
312
|
knownResults(sessionId: string, limit: number): Array<ResultRecord & {
|
|
291
313
|
alias: string | null;
|
package/dist/src/store.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
1
2
|
import { chmodSync, existsSync, mkdirSync, statSync } from "node:fs";
|
|
2
3
|
import { DatabaseSync } from "node:sqlite";
|
|
3
4
|
import { join } from "node:path";
|
|
@@ -35,6 +36,7 @@ export class StateStore {
|
|
|
35
36
|
this.db.exec("PRAGMA busy_timeout = 5000");
|
|
36
37
|
this.db.exec("PRAGMA foreign_keys = ON");
|
|
37
38
|
runMigrations(this.db, this.now);
|
|
39
|
+
this.backfillGeneratedAliases();
|
|
38
40
|
this.recoverStaleCommittingTransactions();
|
|
39
41
|
this.deleteExpiredData();
|
|
40
42
|
}
|
|
@@ -240,6 +242,12 @@ export class StateStore {
|
|
|
240
242
|
.run(input.name, input.target ?? null, input.secretEnv ?? null, input.credentialRef ?? null, input.readOnly ? 1 : 0, timestamp, timestamp);
|
|
241
243
|
return this.getProfile(input.name);
|
|
242
244
|
}
|
|
245
|
+
updateProfile(input) {
|
|
246
|
+
const result = this.db.prepare(`UPDATE profiles
|
|
247
|
+
SET target = ?, secret_env = ?, credential_ref = ?, read_only = ?, updated_at = ?
|
|
248
|
+
WHERE name = ?`).run(input.target, input.secretEnv, input.credentialRef, input.readOnly ? 1 : 0, this.now().toISOString(), input.name);
|
|
249
|
+
return Number(result.changes) === 1 ? this.getProfile(input.name) : undefined;
|
|
250
|
+
}
|
|
243
251
|
getProfile(name) {
|
|
244
252
|
return this.db
|
|
245
253
|
.prepare("SELECT * FROM profiles WHERE name = ?")
|
|
@@ -341,6 +349,7 @@ export class StateStore {
|
|
|
341
349
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
342
350
|
.run(id, input.sessionId, input.connectionId, input.fingerprint, input.sql, JSON.stringify(toJsonSafe(input.parameters)), JSON.stringify(toJsonSafe(input.rows)), JSON.stringify(input.columns), input.rows.length, input.stateVersion, input.stateSignature, input.stateConfidence, input.expiresAt, this.now().toISOString());
|
|
343
351
|
this.enforceResultQuota(id);
|
|
352
|
+
this.allocateGeneratedAlias(input.sessionId, id);
|
|
344
353
|
const result = this.getResult(id);
|
|
345
354
|
this.db.exec("COMMIT");
|
|
346
355
|
return result;
|
|
@@ -351,15 +360,18 @@ export class StateStore {
|
|
|
351
360
|
}
|
|
352
361
|
}
|
|
353
362
|
findResult(fingerprint) {
|
|
354
|
-
|
|
363
|
+
const result = this.db
|
|
355
364
|
.prepare(`SELECT * FROM results
|
|
356
365
|
WHERE fingerprint = ?
|
|
357
366
|
ORDER BY created_at DESC
|
|
358
367
|
LIMIT 1`)
|
|
359
368
|
.get(fingerprint);
|
|
369
|
+
if (result)
|
|
370
|
+
result.alias = this.generatedAlias(result.id);
|
|
371
|
+
return result;
|
|
360
372
|
}
|
|
361
373
|
getResult(idOrAlias, sessionId) {
|
|
362
|
-
|
|
374
|
+
const result = this.db
|
|
363
375
|
.prepare(`SELECT results.*
|
|
364
376
|
FROM results
|
|
365
377
|
LEFT JOIN aliases
|
|
@@ -369,6 +381,9 @@ export class StateStore {
|
|
|
369
381
|
AND (? IS NULL OR results.session_id = ?)
|
|
370
382
|
LIMIT 1`)
|
|
371
383
|
.get(idOrAlias, idOrAlias, sessionId ?? null, sessionId ?? null);
|
|
384
|
+
if (result)
|
|
385
|
+
result.alias = this.generatedAlias(result.id);
|
|
386
|
+
return result;
|
|
372
387
|
}
|
|
373
388
|
resultRows(result) {
|
|
374
389
|
return parseJson(result.rows_json, `result "${result.id}" rows`, isRows);
|
|
@@ -377,12 +392,55 @@ export class StateStore {
|
|
|
377
392
|
return parseJson(result.columns_json, `result "${result.id}" columns`, isColumns);
|
|
378
393
|
}
|
|
379
394
|
setAlias(sessionId, name, resultId) {
|
|
395
|
+
const existing = this.db.prepare("SELECT result_id, generated FROM aliases WHERE session_id = ? AND name = ?").get(sessionId, name);
|
|
396
|
+
if (existing?.generated) {
|
|
397
|
+
if (existing.result_id === resultId)
|
|
398
|
+
return;
|
|
399
|
+
throw new StateQLError("INVALID_COMMAND", "Generated result aliases cannot be reassigned.");
|
|
400
|
+
}
|
|
380
401
|
this.db
|
|
381
|
-
.prepare(`INSERT INTO aliases(session_id, name, result_id)
|
|
382
|
-
VALUES (?, ?,
|
|
402
|
+
.prepare(`INSERT INTO aliases(session_id, name, result_id, generated)
|
|
403
|
+
VALUES (?, ?, ?, 0)
|
|
383
404
|
ON CONFLICT(session_id, name) DO UPDATE SET result_id = excluded.result_id`)
|
|
384
405
|
.run(sessionId, name, resultId);
|
|
385
406
|
}
|
|
407
|
+
generatedAlias(resultId) {
|
|
408
|
+
const row = this.db.prepare("SELECT name FROM aliases WHERE result_id = ? AND generated = 1").get(resultId);
|
|
409
|
+
if (!row)
|
|
410
|
+
throw new Error(`Result "${resultId}" has no generated alias.`);
|
|
411
|
+
return row.name;
|
|
412
|
+
}
|
|
413
|
+
allocateGeneratedAlias(sessionId, resultId) {
|
|
414
|
+
const existing = this.db.prepare("SELECT name FROM aliases WHERE result_id = ? AND generated = 1").get(resultId);
|
|
415
|
+
if (existing)
|
|
416
|
+
return existing.name;
|
|
417
|
+
for (let attempt = 0; attempt < 64; attempt++) {
|
|
418
|
+
const name = randomBase32Alias();
|
|
419
|
+
this.db.prepare("INSERT OR IGNORE INTO aliases(session_id, name, result_id, generated) VALUES (?, ?, ?, 1)").run(sessionId, name, resultId);
|
|
420
|
+
const allocated = this.db.prepare("SELECT name FROM aliases WHERE result_id = ? AND generated = 1").get(resultId);
|
|
421
|
+
if (allocated)
|
|
422
|
+
return allocated.name;
|
|
423
|
+
}
|
|
424
|
+
throw new Error("Could not allocate a unique result alias.");
|
|
425
|
+
}
|
|
426
|
+
backfillGeneratedAliases() {
|
|
427
|
+
const results = this.db.prepare(`SELECT results.id, results.session_id
|
|
428
|
+
FROM results
|
|
429
|
+
LEFT JOIN aliases ON aliases.result_id = results.id AND aliases.generated = 1
|
|
430
|
+
WHERE aliases.result_id IS NULL`).all();
|
|
431
|
+
if (!results.length)
|
|
432
|
+
return;
|
|
433
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
434
|
+
try {
|
|
435
|
+
for (const result of results)
|
|
436
|
+
this.allocateGeneratedAlias(result.session_id, result.id);
|
|
437
|
+
this.db.exec("COMMIT");
|
|
438
|
+
}
|
|
439
|
+
catch (error) {
|
|
440
|
+
this.db.exec("ROLLBACK");
|
|
441
|
+
throw error;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
386
444
|
saveOperation(input) {
|
|
387
445
|
const id = this.nextId("op");
|
|
388
446
|
this.db
|
|
@@ -797,10 +855,10 @@ export class StateStore {
|
|
|
797
855
|
const id = input.id ?? this.nextId("cmd");
|
|
798
856
|
this.db
|
|
799
857
|
.prepare(`INSERT INTO history
|
|
800
|
-
(id, timestamp, session_id, actor_id, origin, command, sql, handle,
|
|
858
|
+
(id, timestamp, session_id, actor_id, origin, category, internal, command, sql, target, handle,
|
|
801
859
|
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);
|
|
860
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
861
|
+
.run(id, this.now().toISOString(), input.sessionId, input.actorId, input.origin ?? "legacy", input.category ?? "management", input.internal ? 1 : 0, 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
862
|
this.db
|
|
805
863
|
.prepare(`DELETE FROM history
|
|
806
864
|
WHERE rowid IN (
|
|
@@ -814,13 +872,29 @@ export class StateStore {
|
|
|
814
872
|
.prepare("SELECT * FROM history WHERE id = ?")
|
|
815
873
|
.get(id);
|
|
816
874
|
}
|
|
817
|
-
history(sessionId, limit,
|
|
875
|
+
history(sessionId, limit, input = {}) {
|
|
876
|
+
const options = typeof input === "string" ? { origin: input } : input;
|
|
877
|
+
const predicates = ["session_id = ?"];
|
|
878
|
+
const values = [sessionId];
|
|
879
|
+
if (options.origin !== undefined) {
|
|
880
|
+
predicates.push("origin = ?");
|
|
881
|
+
values.push(options.origin);
|
|
882
|
+
}
|
|
883
|
+
if (options.category !== undefined) {
|
|
884
|
+
predicates.push("category = ?");
|
|
885
|
+
values.push(options.category);
|
|
886
|
+
}
|
|
887
|
+
if (options.internal !== undefined) {
|
|
888
|
+
predicates.push("internal = ?");
|
|
889
|
+
values.push(options.internal ? 1 : 0);
|
|
890
|
+
}
|
|
891
|
+
values.push(limit, options.offset ?? 0);
|
|
818
892
|
return this.db
|
|
819
893
|
.prepare(`SELECT * FROM history
|
|
820
|
-
WHERE
|
|
894
|
+
WHERE ${predicates.join(" AND ")}
|
|
821
895
|
ORDER BY rowid DESC
|
|
822
|
-
LIMIT ?`)
|
|
823
|
-
.all(...
|
|
896
|
+
LIMIT ? OFFSET ?`)
|
|
897
|
+
.all(...values);
|
|
824
898
|
}
|
|
825
899
|
recentOperations(sessionId, limit) {
|
|
826
900
|
return this.db
|
|
@@ -837,6 +911,7 @@ export class StateStore {
|
|
|
837
911
|
LEFT JOIN aliases
|
|
838
912
|
ON aliases.result_id = results.id
|
|
839
913
|
AND aliases.session_id = results.session_id
|
|
914
|
+
AND aliases.generated = 1
|
|
840
915
|
WHERE results.session_id = ?
|
|
841
916
|
ORDER BY results.created_at DESC
|
|
842
917
|
LIMIT ?`)
|
|
@@ -946,12 +1021,13 @@ export class StateStore {
|
|
|
946
1021
|
while (this.resultBytes() > this.maxStateBytes) {
|
|
947
1022
|
const candidate = this.db.prepare(`SELECT id FROM results
|
|
948
1023
|
WHERE id <> ? AND NOT EXISTS (
|
|
949
|
-
SELECT 1 FROM aliases WHERE aliases.result_id = results.id
|
|
1024
|
+
SELECT 1 FROM aliases WHERE aliases.result_id = results.id AND aliases.generated = 0
|
|
950
1025
|
)
|
|
951
1026
|
ORDER BY created_at, rowid LIMIT 1`).get(protectedId);
|
|
952
1027
|
if (!candidate) {
|
|
953
1028
|
throw new StateQLError("STATE_QUOTA_EXCEEDED", `Stored results exceed the ${this.maxStateBytes}-byte state quota.`, { suggestedAction: "Purge results or increase maxStateBytes." });
|
|
954
1029
|
}
|
|
1030
|
+
this.db.prepare("DELETE FROM aliases WHERE result_id = ? AND generated = 1").run(candidate.id);
|
|
955
1031
|
this.db.prepare("DELETE FROM results WHERE id = ?").run(candidate.id);
|
|
956
1032
|
}
|
|
957
1033
|
}
|
|
@@ -1019,6 +1095,10 @@ export class StateStore {
|
|
|
1019
1095
|
function outcomeJson(outcome) {
|
|
1020
1096
|
return outcome === undefined ? null : JSON.stringify(toJsonSafe(outcome));
|
|
1021
1097
|
}
|
|
1098
|
+
function randomBase32Alias() {
|
|
1099
|
+
const alphabet = "abcdefghijklmnopqrstuvwxyz234567";
|
|
1100
|
+
return [...randomBytes(10)].map((value) => alphabet[value & 31]).join("");
|
|
1101
|
+
}
|
|
1022
1102
|
function boundedHistorySql(sql) {
|
|
1023
1103
|
if (sql === undefined)
|
|
1024
1104
|
return null;
|
|
@@ -0,0 +1,41 @@
|
|
|
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 interface TableUpdateBatch {
|
|
30
|
+
version: 1;
|
|
31
|
+
updates: TableUpdate[];
|
|
32
|
+
}
|
|
33
|
+
export declare function quoteIdentifier(name: string, driver: Driver): string;
|
|
34
|
+
export declare function editableRow(metadata: EditableTable, row: Row): boolean;
|
|
35
|
+
export declare function compileTableUpdate(update: TableUpdate): {
|
|
36
|
+
sql: string;
|
|
37
|
+
params: SqlParameters;
|
|
38
|
+
mongo?: MongoWriteCommand;
|
|
39
|
+
};
|
|
40
|
+
export declare function parseTableUpdate(parameters: string): TableUpdate;
|
|
41
|
+
export declare function parseTableUpdates(parameters: string): TableUpdate[];
|
|
@@ -0,0 +1,137 @@
|
|
|
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
|
+
}
|
|
116
|
+
export function parseTableUpdates(parameters) {
|
|
117
|
+
try {
|
|
118
|
+
const outer = JSON.parse(parameters);
|
|
119
|
+
if (!Array.isArray(outer) || outer.length !== 1 || typeof outer[0] !== "string" || outer[0].length > 512 * 1024)
|
|
120
|
+
throw new Error();
|
|
121
|
+
const batch = JSON.parse(outer[0]);
|
|
122
|
+
if (!batch || batch.version !== 1 || !Array.isArray(batch.updates) || batch.updates.length < 1 || batch.updates.length > 100)
|
|
123
|
+
throw new Error();
|
|
124
|
+
for (const update of batch.updates) {
|
|
125
|
+
if (!update?.metadata || !["sqlite", "postgres", "mysql", "mongodb"].includes(update.metadata.driver) ||
|
|
126
|
+
!Array.isArray(update.metadata.columns) || update.metadata.columns.length > 100 ||
|
|
127
|
+
!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)) ||
|
|
128
|
+
!update.original || !update.changes || typeof update.changes !== "object")
|
|
129
|
+
throw new Error();
|
|
130
|
+
compileTableUpdate(update);
|
|
131
|
+
}
|
|
132
|
+
return batch.updates;
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
throw new StateQLError("STALE_PLAN", "The stored table update batch is invalid. Reload the rows and plan again.");
|
|
136
|
+
}
|
|
137
|
+
}
|
package/dist/src/types.d.ts
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
export type SqlDriver = "sqlite" | "postgres" | "mysql";
|
|
2
|
-
export type Driver = SqlDriver | "mongodb";
|
|
2
|
+
export type Driver = SqlDriver | "mongodb" | "redis";
|
|
3
3
|
export type CommandOrigin = "legacy" | "user" | "model" | "system" | "api";
|
|
4
|
+
export type HistoryCategory = "statement" | "introspection" | "management";
|
|
4
5
|
/** Trusted host metadata for one executeCommand call; never part of BatchCommand input. */
|
|
5
6
|
export interface CommandExecutionContext {
|
|
6
7
|
signal?: AbortSignal;
|
|
7
8
|
origin?: CommandOrigin;
|
|
9
|
+
/** Marks host-generated setup or introspection separately from user statements. */
|
|
10
|
+
internal?: boolean;
|
|
8
11
|
}
|
|
9
12
|
export type CredentialAccess = "read" | "write";
|
|
10
13
|
export type CredentialOperation = "connect" | "query" | "inspect" | "plan" | "exec" | "apply" | "transaction.commit";
|
|
@@ -78,14 +81,22 @@ export interface HistoryEntry {
|
|
|
78
81
|
session_id: string;
|
|
79
82
|
actor_id: string;
|
|
80
83
|
origin: CommandOrigin;
|
|
84
|
+
category: HistoryCategory;
|
|
85
|
+
internal: boolean;
|
|
81
86
|
command: string;
|
|
82
87
|
sql: string | null;
|
|
88
|
+
target?: string | null;
|
|
83
89
|
handle: string | null;
|
|
84
90
|
executed: boolean;
|
|
85
91
|
cached: boolean;
|
|
86
92
|
success: boolean;
|
|
87
93
|
error_code: string | null;
|
|
88
94
|
}
|
|
95
|
+
export interface StateQLSnapshotOptions {
|
|
96
|
+
historyLimit?: number;
|
|
97
|
+
historyCategory?: HistoryCategory;
|
|
98
|
+
historyInternal?: boolean;
|
|
99
|
+
}
|
|
89
100
|
export interface StateQLSnapshot {
|
|
90
101
|
session: {
|
|
91
102
|
session_id: string;
|
|
@@ -194,12 +205,49 @@ export interface MongoWriteOutcome {
|
|
|
194
205
|
upserted_id?: unknown;
|
|
195
206
|
deleted_count?: number;
|
|
196
207
|
}
|
|
208
|
+
export interface RedisCommand {
|
|
209
|
+
command: string;
|
|
210
|
+
args?: string[];
|
|
211
|
+
}
|
|
212
|
+
export interface RedisWriteOutcome extends MongoWriteOutcome {
|
|
213
|
+
result: string | number | null;
|
|
214
|
+
}
|
|
215
|
+
export type CatalogObjectKind = "table" | "view" | "collection" | "function" | "trigger" | "enum" | "key";
|
|
216
|
+
export interface CatalogObject {
|
|
217
|
+
kind: CatalogObjectKind;
|
|
218
|
+
schema?: string;
|
|
219
|
+
name: string;
|
|
220
|
+
/** Stable database-native overload/object identity when name alone is ambiguous. */
|
|
221
|
+
identity?: string;
|
|
222
|
+
[key: string]: unknown;
|
|
223
|
+
}
|
|
224
|
+
export interface ListObjectsFilter {
|
|
225
|
+
kind?: CatalogObjectKind;
|
|
226
|
+
schema?: string;
|
|
227
|
+
search?: string;
|
|
228
|
+
/** Numeric for SQL/MongoDB; Redis uses its opaque SCAN cursor string. */
|
|
229
|
+
offset?: number | string;
|
|
230
|
+
limit?: number;
|
|
231
|
+
}
|
|
232
|
+
export interface ListObjectsData {
|
|
233
|
+
objects: CatalogObject[];
|
|
234
|
+
next_offset: number | string | null;
|
|
235
|
+
supported_kinds: CatalogObjectKind[];
|
|
236
|
+
}
|
|
237
|
+
export interface DescribeObjectData {
|
|
238
|
+
object: CatalogObject;
|
|
239
|
+
definition?: string | Record<string, unknown> | unknown[] | null;
|
|
240
|
+
[key: string]: unknown;
|
|
241
|
+
}
|
|
197
242
|
export interface ExecutionOptions {
|
|
198
243
|
timeoutMs?: number;
|
|
199
244
|
signal?: AbortSignal;
|
|
200
245
|
}
|
|
201
246
|
export interface HistoryOptions {
|
|
202
247
|
origin?: CommandOrigin;
|
|
248
|
+
category?: HistoryCategory;
|
|
249
|
+
internal?: boolean;
|
|
250
|
+
offset?: number;
|
|
203
251
|
}
|
|
204
252
|
export interface StateQLOptions extends ExecutionOptions {
|
|
205
253
|
home?: string;
|
|
@@ -255,6 +303,21 @@ export interface ProfileOptions {
|
|
|
255
303
|
secretEnv?: string;
|
|
256
304
|
credentialRef?: string;
|
|
257
305
|
}
|
|
306
|
+
export interface ProfileUpdateOptions {
|
|
307
|
+
target?: string | null;
|
|
308
|
+
secretEnv?: string | null;
|
|
309
|
+
credentialRef?: string | null;
|
|
310
|
+
readOnly?: boolean;
|
|
311
|
+
}
|
|
312
|
+
export interface RedisQueryOptions extends ExecutionOptions {
|
|
313
|
+
cache?: "auto" | "bypass" | "require";
|
|
314
|
+
}
|
|
315
|
+
export interface RedisExecOptions extends ExecutionOptions {
|
|
316
|
+
replay?: boolean;
|
|
317
|
+
idempotencyKey?: string;
|
|
318
|
+
}
|
|
319
|
+
export interface RedisPlanOptions extends ExecutionOptions {
|
|
320
|
+
}
|
|
258
321
|
export interface RowsOptions {
|
|
259
322
|
offset?: number;
|
|
260
323
|
limit?: number;
|
|
@@ -268,7 +331,7 @@ export interface MongoPlanOptions extends ExecutionOptions {
|
|
|
268
331
|
allowUnbounded?: boolean;
|
|
269
332
|
allowDestructive?: boolean;
|
|
270
333
|
}
|
|
271
|
-
export type BatchCommandName = "connect" | "disconnect" | "status" | "profile.add" | "profile.list" | "profile.show" | "profile.remove" | "session.start" | "session.list" | "session.show" | "session.summary" | "session.close" | "query" | "filter" | "exec" | "show" | "rows" | "count" | "columns" | "alias.set" | "inspect" | "transaction.begin" | "transaction.status" | "transaction.commit" | "transaction.rollback" | "plan" | "mongo.query" | "mongo.exec" | "mongo.plan" | "apply" | "history" | "receipt" | "doctor" | "purge" | "capabilities";
|
|
334
|
+
export type BatchCommandName = "connect" | "disconnect" | "status" | "profile.add" | "profile.list" | "profile.show" | "profile.remove" | "profile.update" | "session.start" | "session.list" | "session.show" | "session.summary" | "session.close" | "query" | "filter" | "exec" | "show" | "rows" | "count" | "columns" | "alias.set" | "inspect" | "transaction.begin" | "transaction.status" | "transaction.commit" | "transaction.rollback" | "plan" | "mongo.query" | "mongo.exec" | "mongo.plan" | "redis.query" | "redis.exec" | "redis.plan" | "objects.list" | "object.describe" | "apply" | "history" | "receipt" | "doctor" | "purge" | "capabilities";
|
|
272
335
|
export interface BatchCommand {
|
|
273
336
|
command: BatchCommandName;
|
|
274
337
|
target?: string;
|
|
@@ -281,6 +344,8 @@ export interface BatchCommand {
|
|
|
281
344
|
table?: string;
|
|
282
345
|
params?: SqlParameters;
|
|
283
346
|
mongo?: MongoReadCommand | MongoWriteCommand;
|
|
347
|
+
redis?: RedisCommand;
|
|
348
|
+
object?: CatalogObject;
|
|
284
349
|
cache?: "auto" | "bypass" | "require";
|
|
285
350
|
read_only?: boolean;
|
|
286
351
|
secret_env?: string;
|
|
@@ -291,11 +356,14 @@ export interface BatchCommand {
|
|
|
291
356
|
allow_unbounded?: boolean;
|
|
292
357
|
allow_destructive?: boolean;
|
|
293
358
|
offset?: number;
|
|
359
|
+
cursor?: string;
|
|
294
360
|
limit?: number;
|
|
295
361
|
isolation?: string;
|
|
296
362
|
timeout_ms?: number;
|
|
297
363
|
/** Retrieval filter for the history command; does not attribute this command. */
|
|
298
364
|
history_origin?: CommandOrigin;
|
|
365
|
+
history_category?: HistoryCategory;
|
|
366
|
+
history_internal?: boolean;
|
|
299
367
|
scope?: "expired" | "results" | "history" | "all";
|
|
300
368
|
}
|
|
301
369
|
export interface BatchOptions {
|
|
@@ -415,6 +483,9 @@ export interface SessionSummaryData {
|
|
|
415
483
|
}
|
|
416
484
|
export interface ResultData {
|
|
417
485
|
result_id: string;
|
|
486
|
+
alias: string;
|
|
487
|
+
/** Canonical generated alias; remains stable even when alias is an explicit caller alias. */
|
|
488
|
+
display_alias: string;
|
|
418
489
|
rows: number;
|
|
419
490
|
columns: Column[];
|
|
420
491
|
preview: Row[];
|
|
@@ -427,6 +498,7 @@ export interface ResultData {
|
|
|
427
498
|
mode: string;
|
|
428
499
|
expires_at: string;
|
|
429
500
|
};
|
|
501
|
+
next_cursor?: string | null;
|
|
430
502
|
}
|
|
431
503
|
export interface RowsData {
|
|
432
504
|
result_id: string;
|
|
@@ -467,7 +539,7 @@ export interface OperationData {
|
|
|
467
539
|
state_version_before: string;
|
|
468
540
|
state_version_after: string | null;
|
|
469
541
|
replay_of?: string;
|
|
470
|
-
outcome?: MongoWriteOutcome;
|
|
542
|
+
outcome?: MongoWriteOutcome | RedisWriteOutcome;
|
|
471
543
|
}
|
|
472
544
|
export interface ExecData extends OperationData {
|
|
473
545
|
duplicate?: boolean;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fadhilp/stateql",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.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/redis.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"
|
|
@@ -37,6 +37,7 @@
|
|
|
37
37
|
"postgresql",
|
|
38
38
|
"cli",
|
|
39
39
|
"agents",
|
|
40
|
+
"redis",
|
|
40
41
|
"sql"
|
|
41
42
|
],
|
|
42
43
|
"license": "MIT",
|
|
@@ -44,6 +45,7 @@
|
|
|
44
45
|
"node": ">=22.16"
|
|
45
46
|
},
|
|
46
47
|
"dependencies": {
|
|
48
|
+
"@redis/client": "^5.12.1",
|
|
47
49
|
"mongodb": "^6.21.0",
|
|
48
50
|
"mysql2": "^3.23.1",
|
|
49
51
|
"node-sql-parser": "^5.4.0",
|