@fadhilp/stateql 0.1.2 → 0.2.2
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 +47 -20
- package/dist/src/adapters.d.ts +15 -1
- package/dist/src/adapters.js +631 -181
- package/dist/src/cli.js +16 -3
- package/dist/src/connection.d.ts +10 -0
- package/dist/src/connection.js +51 -0
- package/dist/src/errors.js +6 -2
- package/dist/src/filter.d.ts +14 -0
- package/dist/src/filter.js +256 -0
- package/dist/src/index.d.ts +1 -1
- package/dist/src/response-data.d.ts +8 -0
- package/dist/src/response-data.js +66 -0
- package/dist/src/sql.js +8 -2
- package/dist/src/sqlite-process.d.ts +1 -0
- package/dist/src/sqlite-process.js +240 -0
- package/dist/src/stateql.d.ts +15 -5
- package/dist/src/stateql.js +189 -397
- package/dist/src/store.d.ts +2 -0
- package/dist/src/store.js +37 -2
- package/dist/src/types.d.ts +56 -6
- package/package.json +5 -2
package/dist/src/stateql.js
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
import { writeFileSync } from "node:fs";
|
|
2
2
|
import { basename, resolve } from "node:path";
|
|
3
|
-
import { DatabaseSync } from "node:sqlite";
|
|
4
3
|
import { env } from "node:process";
|
|
5
|
-
import { BatchWriteError, createAdapter, } from "./adapters.js";
|
|
4
|
+
import { AdapterExecutionError, AdapterWriteError, BatchWriteError, createAdapter, createAdapterContext, } from "./adapters.js";
|
|
5
|
+
import { confidence, databaseIdentity, databaseUrlHasSecret, detectDriver, isEnvironmentName, normalizeSqliteSource, validateProfileName, version, } from "./connection.js";
|
|
6
6
|
import { asStateQLError, StateQLError } from "./errors.js";
|
|
7
|
+
import { filterMaterializedRows, prepareFilterStatement, validateFilterParameters, } from "./filter.js";
|
|
8
|
+
import { operationData, paginationWarnings, profileData, rowsToCsv, sessionData, transactionData, } from "./response-data.js";
|
|
7
9
|
import { analyzeSql } from "./sql.js";
|
|
8
10
|
import { StateStore, } from "./store.js";
|
|
9
11
|
import { compactRows, defaultHome, hash, parseJson, redact, } from "./util.js";
|
|
12
|
+
const DEFAULT_SNAPSHOT_HISTORY_LIMIT = 50;
|
|
13
|
+
const MAX_SNAPSHOT_HISTORY_LIMIT = 100;
|
|
10
14
|
export class StateQL {
|
|
11
15
|
store;
|
|
12
16
|
sessionName;
|
|
@@ -15,6 +19,9 @@ export class StateQL {
|
|
|
15
19
|
resultTtlSeconds;
|
|
16
20
|
maxCellCharacters;
|
|
17
21
|
maxResultRows;
|
|
22
|
+
maxResultBytes;
|
|
23
|
+
timeoutMs;
|
|
24
|
+
signal;
|
|
18
25
|
now;
|
|
19
26
|
constructor(options = {}) {
|
|
20
27
|
this.now = options.now ?? (() => new Date());
|
|
@@ -24,6 +31,9 @@ export class StateQL {
|
|
|
24
31
|
this.resultTtlSeconds = options.resultTtlSeconds ?? 86_400;
|
|
25
32
|
this.maxCellCharacters = options.maxCellCharacters ?? 200;
|
|
26
33
|
this.maxResultRows = positiveInteger(options.maxResultRows ?? 10_000, "maxResultRows");
|
|
34
|
+
this.maxResultBytes = positiveInteger(options.maxResultBytes ?? 16 * 1024 * 1024, "maxResultBytes");
|
|
35
|
+
this.timeoutMs = executionTimeout(options.timeoutMs ?? 30_000);
|
|
36
|
+
this.signal = options.signal;
|
|
27
37
|
if (this.maxResultRows >= Number.MAX_SAFE_INTEGER) {
|
|
28
38
|
throw new StateQLError("INVALID_COMMAND", "maxResultRows is too large.");
|
|
29
39
|
}
|
|
@@ -59,10 +69,10 @@ export class StateQL {
|
|
|
59
69
|
: "Connection target is required.");
|
|
60
70
|
}
|
|
61
71
|
const driver = detectDriver(secret);
|
|
62
|
-
if (driver
|
|
72
|
+
if (driver !== "sqlite" &&
|
|
63
73
|
!secretEnv &&
|
|
64
|
-
|
|
65
|
-
throw new StateQLError("PERMISSION_DENIED",
|
|
74
|
+
databaseUrlHasSecret(secret)) {
|
|
75
|
+
throw new StateQLError("PERMISSION_DENIED", `Credential-bearing ${databaseDisplayName(driver)} URLs must use --env.`, {
|
|
66
76
|
suggestedAction: "Set the URL in an environment variable and reconnect with --env NAME.",
|
|
67
77
|
});
|
|
68
78
|
}
|
|
@@ -73,7 +83,7 @@ export class StateQL {
|
|
|
73
83
|
: secret;
|
|
74
84
|
const databaseName = driver === "sqlite"
|
|
75
85
|
? basename(source)
|
|
76
|
-
: new URL(secret).pathname.replace(/^\//, "") ||
|
|
86
|
+
: new URL(secret).pathname.replace(/^\//, "") || driver;
|
|
77
87
|
const readOnly = options.readOnly ??
|
|
78
88
|
(profile ? Boolean(profile.read_only) : true);
|
|
79
89
|
const draft = {
|
|
@@ -88,11 +98,14 @@ export class StateQL {
|
|
|
88
98
|
version: 0,
|
|
89
99
|
created_at: this.now().toISOString(),
|
|
90
100
|
};
|
|
91
|
-
const adapter = await createAdapter(draft);
|
|
101
|
+
const adapter = await createAdapter(draft, this.executionContext(options));
|
|
92
102
|
try {
|
|
93
103
|
await adapter.read("SELECT 1", []);
|
|
94
104
|
}
|
|
95
105
|
catch (error) {
|
|
106
|
+
if (error instanceof AdapterExecutionError) {
|
|
107
|
+
throw stoppedStateQLError(error, false);
|
|
108
|
+
}
|
|
96
109
|
throw new StateQLError("CONNECTION_FAILED", errorMessage(error), { retryable: true });
|
|
97
110
|
}
|
|
98
111
|
finally {
|
|
@@ -140,8 +153,8 @@ export class StateQL {
|
|
|
140
153
|
let storedTarget = target;
|
|
141
154
|
if (target) {
|
|
142
155
|
const driver = detectDriver(target);
|
|
143
|
-
if (driver
|
|
144
|
-
throw new StateQLError("PERMISSION_DENIED",
|
|
156
|
+
if (driver !== "sqlite" && databaseUrlHasSecret(target)) {
|
|
157
|
+
throw new StateQLError("PERMISSION_DENIED", `Credential-bearing ${databaseDisplayName(driver)} URLs must use --env.`);
|
|
145
158
|
}
|
|
146
159
|
if (driver === "sqlite")
|
|
147
160
|
storedTarget = normalizeSqliteSource(target);
|
|
@@ -197,6 +210,58 @@ export class StateQL {
|
|
|
197
210
|
return { data: { disconnected: true }, executed: true };
|
|
198
211
|
});
|
|
199
212
|
}
|
|
213
|
+
snapshot(options = {}) {
|
|
214
|
+
const session = this.store
|
|
215
|
+
.listSessions()
|
|
216
|
+
.find((candidate) => candidate.name === this.sessionName);
|
|
217
|
+
if (!session) {
|
|
218
|
+
throw new StateQLError("INVALID_COMMAND", "The active session was not found.");
|
|
219
|
+
}
|
|
220
|
+
const connection = this.store.activeConnection(session);
|
|
221
|
+
const transaction = session.active_transaction_id
|
|
222
|
+
? this.store.getTransaction(session.active_transaction_id)
|
|
223
|
+
: undefined;
|
|
224
|
+
const historyLimit = positiveInteger(options.historyLimit ?? DEFAULT_SNAPSHOT_HISTORY_LIMIT, "historyLimit");
|
|
225
|
+
if (historyLimit > MAX_SNAPSHOT_HISTORY_LIMIT) {
|
|
226
|
+
throw new StateQLError("INVALID_COMMAND", `historyLimit cannot exceed ${MAX_SNAPSHOT_HISTORY_LIMIT}.`);
|
|
227
|
+
}
|
|
228
|
+
return {
|
|
229
|
+
session: {
|
|
230
|
+
session_id: session.id,
|
|
231
|
+
name: session.name,
|
|
232
|
+
status: session.status,
|
|
233
|
+
},
|
|
234
|
+
connection: connection
|
|
235
|
+
? {
|
|
236
|
+
connection_id: connection.id,
|
|
237
|
+
name: connection.name,
|
|
238
|
+
status: "connected",
|
|
239
|
+
driver: connection.driver,
|
|
240
|
+
database: connection.database_name,
|
|
241
|
+
read_only: Boolean(connection.read_only),
|
|
242
|
+
}
|
|
243
|
+
: null,
|
|
244
|
+
transaction: transaction
|
|
245
|
+
? { transaction_id: transaction.id, state: transaction.state }
|
|
246
|
+
: null,
|
|
247
|
+
state_version: connection ? version(connection) : null,
|
|
248
|
+
state_confidence: connection ? confidence(connection) : null,
|
|
249
|
+
recent_results: this.store.knownResults(session.id, 10).map((result) => ({
|
|
250
|
+
alias: result.alias,
|
|
251
|
+
handle: result.id,
|
|
252
|
+
rows: result.row_count,
|
|
253
|
+
})),
|
|
254
|
+
recent_operations: this.store
|
|
255
|
+
.recentOperations(session.id, 10)
|
|
256
|
+
.map((operation) => ({
|
|
257
|
+
handle: operation.id,
|
|
258
|
+
type: operation.statement_type,
|
|
259
|
+
affected_rows: operation.affected_rows,
|
|
260
|
+
status: operation.status,
|
|
261
|
+
})),
|
|
262
|
+
history: this.store.history(session.id, historyLimit).map(historyEntry),
|
|
263
|
+
};
|
|
264
|
+
}
|
|
200
265
|
async status() {
|
|
201
266
|
return this.run("status", async (session) => {
|
|
202
267
|
const connection = this.store.activeConnection(session);
|
|
@@ -231,10 +296,10 @@ export class StateQL {
|
|
|
231
296
|
if (!name.trim()) {
|
|
232
297
|
throw new StateQLError("INVALID_COMMAND", "Session name is required.");
|
|
233
298
|
}
|
|
234
|
-
if (this.store.
|
|
299
|
+
if (this.store.getSessionByName(name)) {
|
|
235
300
|
throw new StateQLError("INVALID_COMMAND", `Active session "${name}" already exists.`);
|
|
236
301
|
}
|
|
237
|
-
const session = this.store.
|
|
302
|
+
const session = this.store.ensureSession(name);
|
|
238
303
|
return {
|
|
239
304
|
data: sessionData(session),
|
|
240
305
|
handle: session.id,
|
|
@@ -310,12 +375,13 @@ export class StateQL {
|
|
|
310
375
|
async query(sql, options = {}) {
|
|
311
376
|
return this.run("query", async (session) => {
|
|
312
377
|
const connection = this.requireConnection(session);
|
|
378
|
+
this.rejectDuringStagedTransaction(session, "Queries");
|
|
313
379
|
const analysis = analyzeSql(sql, connection.driver);
|
|
314
380
|
if (!analysis.read) {
|
|
315
381
|
throw new StateQLError("INVALID_SQL", "query accepts read statements only; use exec for writes.");
|
|
316
382
|
}
|
|
317
383
|
const parameters = options.params ?? [];
|
|
318
|
-
const adapter = await createAdapter(connection);
|
|
384
|
+
const adapter = await createAdapter(connection, this.executionContext(options));
|
|
319
385
|
try {
|
|
320
386
|
const stateVersion = version(connection);
|
|
321
387
|
const stateSignature = await adapter.signature();
|
|
@@ -353,6 +419,12 @@ export class StateQL {
|
|
|
353
419
|
if (result.rows.length > this.maxResultRows) {
|
|
354
420
|
throw new StateQLError("OUTPUT_LIMIT_EXCEEDED", `Query exceeds the ${this.maxResultRows}-row materialization limit.`, { suggestedAction: "Add a narrower WHERE clause or LIMIT." });
|
|
355
421
|
}
|
|
422
|
+
const resultBytes = Buffer.byteLength(JSON.stringify(parameters), "utf8") +
|
|
423
|
+
Buffer.byteLength(JSON.stringify(result.rows), "utf8") +
|
|
424
|
+
Buffer.byteLength(JSON.stringify(result.columns), "utf8");
|
|
425
|
+
if (resultBytes > this.maxResultBytes) {
|
|
426
|
+
throw new StateQLError("OUTPUT_LIMIT_EXCEEDED", `Query exceeds the ${this.maxResultBytes}-byte materialization limit.`, { suggestedAction: "Select fewer rows or smaller columns." });
|
|
427
|
+
}
|
|
356
428
|
const expiresAt = new Date(this.now().getTime() + this.resultTtlSeconds * 1000).toISOString();
|
|
357
429
|
const saved = this.store.saveResult({
|
|
358
430
|
sessionId: session.id,
|
|
@@ -379,6 +451,9 @@ export class StateQL {
|
|
|
379
451
|
catch (error) {
|
|
380
452
|
if (error instanceof StateQLError)
|
|
381
453
|
throw error;
|
|
454
|
+
if (error instanceof AdapterExecutionError) {
|
|
455
|
+
throw stoppedStateQLError(error, true);
|
|
456
|
+
}
|
|
382
457
|
throw new StateQLError("QUERY_FAILED", errorMessage(error), {
|
|
383
458
|
retryable: true,
|
|
384
459
|
executed: true,
|
|
@@ -529,7 +604,7 @@ export class StateQL {
|
|
|
529
604
|
async exec(sql, options = {}) {
|
|
530
605
|
return this.run("exec", async (session) => {
|
|
531
606
|
const connection = this.requireConnection(session);
|
|
532
|
-
return this.performExec(session, connection, sql, options);
|
|
607
|
+
return this.performExec(session, connection, sql, options, this.executionContext(options));
|
|
533
608
|
});
|
|
534
609
|
}
|
|
535
610
|
async receipt(id) {
|
|
@@ -587,7 +662,7 @@ export class StateQL {
|
|
|
587
662
|
};
|
|
588
663
|
});
|
|
589
664
|
}
|
|
590
|
-
async commitTransaction(id) {
|
|
665
|
+
async commitTransaction(id, options = {}) {
|
|
591
666
|
return this.run("transaction.commit", async (session) => {
|
|
592
667
|
const transaction = this.requireActiveTransaction(session, id);
|
|
593
668
|
const connection = this.store.getConnection(transaction.connection_id);
|
|
@@ -601,7 +676,7 @@ export class StateQL {
|
|
|
601
676
|
if (operations.some((operation) => operation.connection_id !== connection.id)) {
|
|
602
677
|
throw new StateQLError("TRANSACTION_FAILED", "Transaction contains writes staged for another connection.", { suggestedAction: "Roll back the transaction." });
|
|
603
678
|
}
|
|
604
|
-
const adapter = await createAdapter(connection);
|
|
679
|
+
const adapter = await createAdapter(connection, this.executionContext(options));
|
|
605
680
|
try {
|
|
606
681
|
if (!this.store.markTransactionCommitting(transaction.id)) {
|
|
607
682
|
throw new StateQLError("TRANSACTION_FAILED", "Transaction is no longer active.");
|
|
@@ -611,8 +686,12 @@ export class StateQL {
|
|
|
611
686
|
results = await adapter.writeBatch(operations, transaction.isolation_level);
|
|
612
687
|
}
|
|
613
688
|
catch (error) {
|
|
614
|
-
if (error instanceof BatchWriteError && !error.outcomeUnknown)
|
|
689
|
+
if ((error instanceof BatchWriteError && !error.outcomeUnknown) ||
|
|
690
|
+
(error instanceof AdapterExecutionError && !error.outcomeUnknown)) {
|
|
615
691
|
this.store.finishTransaction(transaction.id, session.id, "failed");
|
|
692
|
+
if (error instanceof AdapterExecutionError) {
|
|
693
|
+
throw stoppedStateQLError(error, false);
|
|
694
|
+
}
|
|
616
695
|
throw new StateQLError("TRANSACTION_FAILED", error.message, {
|
|
617
696
|
retryable: true,
|
|
618
697
|
});
|
|
@@ -690,10 +769,11 @@ export class StateQL {
|
|
|
690
769
|
};
|
|
691
770
|
});
|
|
692
771
|
}
|
|
693
|
-
async inspect(kind, table) {
|
|
772
|
+
async inspect(kind, table, options = {}) {
|
|
694
773
|
return this.run(`inspect.${kind}`, async (session) => {
|
|
695
774
|
const connection = this.requireConnection(session);
|
|
696
|
-
|
|
775
|
+
this.rejectDuringStagedTransaction(session, "Schema inspection");
|
|
776
|
+
const adapter = await createAdapter(connection, this.executionContext(options));
|
|
697
777
|
try {
|
|
698
778
|
const data = await adapter.inspect(kind, table);
|
|
699
779
|
return {
|
|
@@ -704,6 +784,9 @@ export class StateQL {
|
|
|
704
784
|
};
|
|
705
785
|
}
|
|
706
786
|
catch (error) {
|
|
787
|
+
if (error instanceof AdapterExecutionError) {
|
|
788
|
+
throw stoppedStateQLError(error, true);
|
|
789
|
+
}
|
|
707
790
|
throw new StateQLError("QUERY_FAILED", errorMessage(error), {
|
|
708
791
|
retryable: false,
|
|
709
792
|
executed: true,
|
|
@@ -717,11 +800,12 @@ export class StateQL {
|
|
|
717
800
|
async plan(sql, options = {}) {
|
|
718
801
|
return this.run("plan", async (session) => {
|
|
719
802
|
const connection = this.requireConnection(session);
|
|
803
|
+
this.rejectDuringStagedTransaction(session, "Plans");
|
|
720
804
|
const analysis = analyzeSql(sql, connection.driver);
|
|
721
805
|
if (analysis.read) {
|
|
722
806
|
throw new StateQLError("INVALID_SQL", "plan accepts write statements only.");
|
|
723
807
|
}
|
|
724
|
-
const adapter = await createAdapter(connection);
|
|
808
|
+
const adapter = await createAdapter(connection, this.executionContext(options));
|
|
725
809
|
try {
|
|
726
810
|
const stateSignature = await adapter.signature();
|
|
727
811
|
const expiresAt = new Date(this.now().getTime() + 10 * 60_000).toISOString();
|
|
@@ -762,13 +846,20 @@ export class StateQL {
|
|
|
762
846
|
confidence: adapter.confidence,
|
|
763
847
|
};
|
|
764
848
|
}
|
|
849
|
+
catch (error) {
|
|
850
|
+
if (error instanceof AdapterExecutionError) {
|
|
851
|
+
throw stoppedStateQLError(error, true);
|
|
852
|
+
}
|
|
853
|
+
throw error;
|
|
854
|
+
}
|
|
765
855
|
finally {
|
|
766
856
|
await adapter.close();
|
|
767
857
|
}
|
|
768
858
|
});
|
|
769
859
|
}
|
|
770
|
-
async apply(planId) {
|
|
860
|
+
async apply(planId, options = {}) {
|
|
771
861
|
return this.run("apply", async (session) => {
|
|
862
|
+
this.rejectDuringStagedTransaction(session, "Plans");
|
|
772
863
|
const plan = this.store.getPlan(planId);
|
|
773
864
|
if (!plan || plan.session_id !== session.id) {
|
|
774
865
|
throw new StateQLError("STALE_PLAN", `Plan "${planId}" was not found.`);
|
|
@@ -786,12 +877,19 @@ export class StateQL {
|
|
|
786
877
|
version(connection) !== plan.state_version) {
|
|
787
878
|
throw new StateQLError("STALE_PLAN", "Database state changed after this plan was created.");
|
|
788
879
|
}
|
|
789
|
-
const
|
|
880
|
+
const context = this.executionContext(options);
|
|
881
|
+
const adapter = await createAdapter(connection, context);
|
|
790
882
|
try {
|
|
791
883
|
if ((await adapter.signature()) !== plan.state_signature) {
|
|
792
884
|
throw new StateQLError("STALE_PLAN", "Database state changed after this plan was created.");
|
|
793
885
|
}
|
|
794
886
|
}
|
|
887
|
+
catch (error) {
|
|
888
|
+
if (error instanceof AdapterExecutionError) {
|
|
889
|
+
throw stoppedStateQLError(error, true);
|
|
890
|
+
}
|
|
891
|
+
throw error;
|
|
892
|
+
}
|
|
795
893
|
finally {
|
|
796
894
|
await adapter.close();
|
|
797
895
|
}
|
|
@@ -799,7 +897,7 @@ export class StateQL {
|
|
|
799
897
|
params: parseJson(plan.parameters, []),
|
|
800
898
|
allowUnbounded: Boolean(plan.allow_unbounded),
|
|
801
899
|
allowDestructive: Boolean(plan.allow_destructive),
|
|
802
|
-
});
|
|
900
|
+
}, context);
|
|
803
901
|
const operationId = String(result.data.operation_id);
|
|
804
902
|
this.store.markPlanApplied(plan.id, operationId);
|
|
805
903
|
return {
|
|
@@ -813,24 +911,14 @@ export class StateQL {
|
|
|
813
911
|
data: {
|
|
814
912
|
history: this.store
|
|
815
913
|
.history(session.id, positiveInteger(limit, "limit"))
|
|
816
|
-
.map(
|
|
817
|
-
command_id: item.id,
|
|
818
|
-
timestamp: item.timestamp,
|
|
819
|
-
session_id: item.session_id,
|
|
820
|
-
command: item.command,
|
|
821
|
-
handle: item.handle,
|
|
822
|
-
executed: Boolean(item.executed),
|
|
823
|
-
cached: Boolean(item.cached),
|
|
824
|
-
success: Boolean(item.success),
|
|
825
|
-
error_code: item.error_code,
|
|
826
|
-
})),
|
|
914
|
+
.map(historyEntry),
|
|
827
915
|
},
|
|
828
916
|
}));
|
|
829
917
|
}
|
|
830
918
|
async capabilities() {
|
|
831
919
|
return this.run("capabilities", async () => ({
|
|
832
920
|
data: {
|
|
833
|
-
drivers: ["postgres", "sqlite"],
|
|
921
|
+
drivers: ["mysql", "postgres", "sqlite"],
|
|
834
922
|
features: {
|
|
835
923
|
result_handles: true,
|
|
836
924
|
write_deduplication: true,
|
|
@@ -839,6 +927,8 @@ export class StateQL {
|
|
|
839
927
|
persistent_sessions: true,
|
|
840
928
|
result_filtering: true,
|
|
841
929
|
schema_inspection: true,
|
|
930
|
+
deadlines: true,
|
|
931
|
+
cancellation: true,
|
|
842
932
|
},
|
|
843
933
|
},
|
|
844
934
|
}));
|
|
@@ -855,6 +945,7 @@ export class StateQL {
|
|
|
855
945
|
readOnly: command.read_only,
|
|
856
946
|
secretEnv: command.secret_env,
|
|
857
947
|
profile: command.profile,
|
|
948
|
+
timeoutMs: command.timeout_ms,
|
|
858
949
|
});
|
|
859
950
|
case "disconnect":
|
|
860
951
|
return this.disconnect();
|
|
@@ -885,6 +976,7 @@ export class StateQL {
|
|
|
885
976
|
const response = await this.query(batchString(command.sql, "sql"), {
|
|
886
977
|
params: command.params ?? [],
|
|
887
978
|
cache: command.cache ?? "auto",
|
|
979
|
+
timeoutMs: command.timeout_ms,
|
|
888
980
|
});
|
|
889
981
|
if (!response.ok || !command.as)
|
|
890
982
|
return response;
|
|
@@ -917,6 +1009,7 @@ export class StateQL {
|
|
|
917
1009
|
idempotencyKey: command.idempotency_key,
|
|
918
1010
|
allowUnbounded: command.allow_unbounded ?? false,
|
|
919
1011
|
allowDestructive: command.allow_destructive ?? false,
|
|
1012
|
+
timeoutMs: command.timeout_ms,
|
|
920
1013
|
});
|
|
921
1014
|
case "show":
|
|
922
1015
|
return this.show(batchString(command.handle, "handle"));
|
|
@@ -932,13 +1025,17 @@ export class StateQL {
|
|
|
932
1025
|
case "alias.set":
|
|
933
1026
|
return this.setAlias(batchString(command.name, "name"), batchString(command.handle, "handle"));
|
|
934
1027
|
case "inspect":
|
|
935
|
-
return this.inspect(batchString(command.kind, "kind"), command.table
|
|
1028
|
+
return this.inspect(batchString(command.kind, "kind"), command.table, {
|
|
1029
|
+
timeoutMs: command.timeout_ms,
|
|
1030
|
+
});
|
|
936
1031
|
case "transaction.begin":
|
|
937
1032
|
return this.beginTransaction(command.isolation);
|
|
938
1033
|
case "transaction.status":
|
|
939
1034
|
return this.transactionStatus(command.handle);
|
|
940
1035
|
case "transaction.commit":
|
|
941
|
-
return this.commitTransaction(command.handle
|
|
1036
|
+
return this.commitTransaction(command.handle, {
|
|
1037
|
+
timeoutMs: command.timeout_ms,
|
|
1038
|
+
});
|
|
942
1039
|
case "transaction.rollback":
|
|
943
1040
|
return this.rollbackTransaction(command.handle);
|
|
944
1041
|
case "plan":
|
|
@@ -946,9 +1043,12 @@ export class StateQL {
|
|
|
946
1043
|
params: command.params ?? [],
|
|
947
1044
|
allowUnbounded: command.allow_unbounded ?? false,
|
|
948
1045
|
allowDestructive: command.allow_destructive,
|
|
1046
|
+
timeoutMs: command.timeout_ms,
|
|
949
1047
|
});
|
|
950
1048
|
case "apply":
|
|
951
|
-
return this.apply(batchString(command.handle, "handle")
|
|
1049
|
+
return this.apply(batchString(command.handle, "handle"), {
|
|
1050
|
+
timeoutMs: command.timeout_ms,
|
|
1051
|
+
});
|
|
952
1052
|
case "history":
|
|
953
1053
|
return this.history(command.limit ?? 20);
|
|
954
1054
|
case "receipt":
|
|
@@ -982,7 +1082,7 @@ export class StateQL {
|
|
|
982
1082
|
return;
|
|
983
1083
|
}
|
|
984
1084
|
}
|
|
985
|
-
async performExec(session, connection, sql, options) {
|
|
1085
|
+
async performExec(session, connection, sql, options, context) {
|
|
986
1086
|
if (connection.read_only) {
|
|
987
1087
|
throw new StateQLError("READ_ONLY_CONNECTION", "Connection is read-only.", { suggestedAction: "Reconnect with --read-write." });
|
|
988
1088
|
}
|
|
@@ -996,6 +1096,10 @@ export class StateQL {
|
|
|
996
1096
|
if (analysis.destructive && !options.allowDestructive) {
|
|
997
1097
|
throw new StateQLError("DESTRUCTIVE_OPERATION_BLOCKED", "Destructive operation requires an explicit override.", { extra: { override_flag: "--allow-destructive" } });
|
|
998
1098
|
}
|
|
1099
|
+
if (options.idempotencyKey !== undefined &&
|
|
1100
|
+
!options.idempotencyKey.trim()) {
|
|
1101
|
+
throw new StateQLError("INVALID_COMMAND", "Idempotency key cannot be empty.");
|
|
1102
|
+
}
|
|
999
1103
|
const parameters = options.params ?? [];
|
|
1000
1104
|
const fingerprint = hash({
|
|
1001
1105
|
sql: analysis.normalized,
|
|
@@ -1026,6 +1130,12 @@ export class StateQL {
|
|
|
1026
1130
|
stateVersionBefore: version(connection),
|
|
1027
1131
|
});
|
|
1028
1132
|
const previous = reservation.previous;
|
|
1133
|
+
if (previous &&
|
|
1134
|
+
options.idempotencyKey &&
|
|
1135
|
+
!options.replay &&
|
|
1136
|
+
previous.fingerprint !== fingerprint) {
|
|
1137
|
+
throw new StateQLError("IDEMPOTENCY_CONFLICT", "Idempotency key was already used for a different write.", { extra: { previous_operation_id: previous.id } });
|
|
1138
|
+
}
|
|
1029
1139
|
if (previous && !reservation.operation) {
|
|
1030
1140
|
if (previous.status === "executing" ||
|
|
1031
1141
|
previous.status === "outcome_unknown") {
|
|
@@ -1066,7 +1176,7 @@ export class StateQL {
|
|
|
1066
1176
|
}
|
|
1067
1177
|
let adapter;
|
|
1068
1178
|
try {
|
|
1069
|
-
adapter = await createAdapter(connection);
|
|
1179
|
+
adapter = await createAdapter(connection, context);
|
|
1070
1180
|
}
|
|
1071
1181
|
catch (error) {
|
|
1072
1182
|
this.store.failOperation(operation.id);
|
|
@@ -1102,6 +1212,16 @@ export class StateQL {
|
|
|
1102
1212
|
catch (error) {
|
|
1103
1213
|
if (error instanceof StateQLError)
|
|
1104
1214
|
throw error;
|
|
1215
|
+
if (error instanceof AdapterExecutionError && !error.outcomeUnknown) {
|
|
1216
|
+
this.store.failOperation(operation.id);
|
|
1217
|
+
throw stoppedStateQLError(error, false);
|
|
1218
|
+
}
|
|
1219
|
+
if (error instanceof AdapterWriteError && !error.outcomeUnknown) {
|
|
1220
|
+
this.store.failOperation(operation.id);
|
|
1221
|
+
throw new StateQLError("QUERY_FAILED", error.message, {
|
|
1222
|
+
executed: true,
|
|
1223
|
+
});
|
|
1224
|
+
}
|
|
1105
1225
|
this.store.markOperationOutcomeUnknown(operation.id);
|
|
1106
1226
|
throw new StateQLError("OUTCOME_UNKNOWN", errorMessage(error), {
|
|
1107
1227
|
executed: true,
|
|
@@ -1134,6 +1254,11 @@ export class StateQL {
|
|
|
1134
1254
|
return action(result, session);
|
|
1135
1255
|
});
|
|
1136
1256
|
}
|
|
1257
|
+
rejectDuringStagedTransaction(session, operation) {
|
|
1258
|
+
if (!session.active_transaction_id)
|
|
1259
|
+
return;
|
|
1260
|
+
throw new StateQLError("TRANSACTION_FAILED", `${operation} cannot run while a staged transaction is active.`, { suggestedAction: "Commit or roll back the transaction first." });
|
|
1261
|
+
}
|
|
1137
1262
|
requireResult(idOrAlias, session) {
|
|
1138
1263
|
const result = this.store.getResult(idOrAlias, session.id);
|
|
1139
1264
|
if (!result) {
|
|
@@ -1162,6 +1287,9 @@ export class StateQL {
|
|
|
1162
1287
|
}
|
|
1163
1288
|
return transaction;
|
|
1164
1289
|
}
|
|
1290
|
+
executionContext(options) {
|
|
1291
|
+
return createAdapterContext(executionTimeout(options.timeoutMs ?? this.timeoutMs), options.signal ?? this.signal);
|
|
1292
|
+
}
|
|
1165
1293
|
resultData(result, cached) {
|
|
1166
1294
|
const rows = this.store.resultRows(result);
|
|
1167
1295
|
const preview = compactRows(rows.slice(0, this.previewRows), this.maxCellCharacters);
|
|
@@ -1244,255 +1372,18 @@ export class StateQL {
|
|
|
1244
1372
|
}
|
|
1245
1373
|
}
|
|
1246
1374
|
}
|
|
1247
|
-
function
|
|
1248
|
-
const text = predicate.trim();
|
|
1249
|
-
if (!text) {
|
|
1250
|
-
throw new StateQLError("INVALID_SQL", "Filter predicate is required.");
|
|
1251
|
-
}
|
|
1252
|
-
const columnNames = columns.map((column) => column.name);
|
|
1253
|
-
if (columnNames.length === 0) {
|
|
1254
|
-
throw new StateQLError("INVALID_SQL", "Filter requires at least one result column.");
|
|
1255
|
-
}
|
|
1256
|
-
const names = new Set();
|
|
1257
|
-
for (const name of columnNames) {
|
|
1258
|
-
const normalized = name.toLowerCase();
|
|
1259
|
-
if (!name || name.includes("\0") || names.has(normalized)) {
|
|
1260
|
-
throw new StateQLError("INVALID_SQL", "Filter requires unique, non-empty result column names.");
|
|
1261
|
-
}
|
|
1262
|
-
names.add(normalized);
|
|
1263
|
-
}
|
|
1264
|
-
const tableName = "__stateql_filter_source";
|
|
1265
|
-
let indexColumn = "__stateql_row_index";
|
|
1266
|
-
while (names.has(indexColumn.toLowerCase()))
|
|
1267
|
-
indexColumn += "_";
|
|
1268
|
-
const sql = `SELECT ${quoteIdentifier(indexColumn)} ` +
|
|
1269
|
-
`FROM ${quoteIdentifier(tableName)} WHERE (${text})`;
|
|
1270
|
-
const analysis = analyzeSql(sql, "sqlite");
|
|
1271
|
-
const details = analysis.ast;
|
|
1272
|
-
const from = details.from;
|
|
1273
|
-
const source = Array.isArray(from)
|
|
1274
|
-
? from[0]
|
|
1275
|
-
: undefined;
|
|
1276
|
-
if (!analysis.read ||
|
|
1277
|
-
!Array.isArray(from) ||
|
|
1278
|
-
from.length !== 1 ||
|
|
1279
|
-
source?.table !== tableName ||
|
|
1280
|
-
details.with ||
|
|
1281
|
-
details.groupby ||
|
|
1282
|
-
details.having ||
|
|
1283
|
-
details.orderby ||
|
|
1284
|
-
details.limit ||
|
|
1285
|
-
details.for_update ||
|
|
1286
|
-
details._next ||
|
|
1287
|
-
details.set_op ||
|
|
1288
|
-
containsSelect(details.where)) {
|
|
1289
|
-
throw new StateQLError("INVALID_SQL", "Filter accepts one scalar predicate only.");
|
|
1290
|
-
}
|
|
1291
|
-
validateFilterExpression(details.where, names, tableName);
|
|
1292
|
-
const bindings = filterBindings(details.where);
|
|
1375
|
+
function historyEntry(item) {
|
|
1293
1376
|
return {
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
function filterMaterializedRows(rows, filter, parameters) {
|
|
1304
|
-
const db = new DatabaseSync(":memory:");
|
|
1305
|
-
try {
|
|
1306
|
-
const definitions = [
|
|
1307
|
-
`${quoteIdentifier(filter.indexColumn)} INTEGER PRIMARY KEY`,
|
|
1308
|
-
...filter.columnNames.map(quoteIdentifier),
|
|
1309
|
-
];
|
|
1310
|
-
db.exec(`CREATE TABLE ${quoteIdentifier(filter.tableName)} ` +
|
|
1311
|
-
`(${definitions.join(", ")})`);
|
|
1312
|
-
const placeholders = filter.columnNames.map(() => "?").join(", ");
|
|
1313
|
-
const insert = db.prepare(`INSERT INTO ${quoteIdentifier(filter.tableName)} (` +
|
|
1314
|
-
`${quoteIdentifier(filter.indexColumn)}, ` +
|
|
1315
|
-
`${filter.columnNames.map(quoteIdentifier).join(", ")}) ` +
|
|
1316
|
-
`VALUES (?, ${placeholders})`);
|
|
1317
|
-
db.exec("BEGIN");
|
|
1318
|
-
try {
|
|
1319
|
-
rows.forEach((row, index) => {
|
|
1320
|
-
const values = filter.columnNames.map((name) => sqliteFilterValue(row[name]));
|
|
1321
|
-
insert.run(index, ...values);
|
|
1322
|
-
});
|
|
1323
|
-
db.exec("COMMIT");
|
|
1324
|
-
}
|
|
1325
|
-
catch (error) {
|
|
1326
|
-
db.exec("ROLLBACK");
|
|
1327
|
-
throw error;
|
|
1328
|
-
}
|
|
1329
|
-
const statement = db.prepare(`${filter.sql}\nORDER BY ${quoteIdentifier(filter.indexColumn)}`);
|
|
1330
|
-
const selected = filterAll(statement, parameters);
|
|
1331
|
-
return selected.map((row) => {
|
|
1332
|
-
const index = Number(row[filter.indexColumn]);
|
|
1333
|
-
if (!Number.isInteger(index) || !rows[index]) {
|
|
1334
|
-
throw new StateQLError("INVALID_SQL", "Filter produced an invalid source row index.");
|
|
1335
|
-
}
|
|
1336
|
-
return rows[index];
|
|
1337
|
-
});
|
|
1338
|
-
}
|
|
1339
|
-
catch (error) {
|
|
1340
|
-
if (error instanceof StateQLError)
|
|
1341
|
-
throw error;
|
|
1342
|
-
throw new StateQLError("INVALID_SQL", errorMessage(error));
|
|
1343
|
-
}
|
|
1344
|
-
finally {
|
|
1345
|
-
db.close();
|
|
1346
|
-
}
|
|
1347
|
-
}
|
|
1348
|
-
function filterAll(statement, parameters) {
|
|
1349
|
-
if (Array.isArray(parameters)) {
|
|
1350
|
-
return statement.all(...parameters);
|
|
1351
|
-
}
|
|
1352
|
-
return statement.all(parameters);
|
|
1353
|
-
}
|
|
1354
|
-
function sqliteFilterValue(value) {
|
|
1355
|
-
if (value === null || value === undefined)
|
|
1356
|
-
return null;
|
|
1357
|
-
if (typeof value === "string" ||
|
|
1358
|
-
typeof value === "number" ||
|
|
1359
|
-
typeof value === "bigint") {
|
|
1360
|
-
return value;
|
|
1361
|
-
}
|
|
1362
|
-
if (typeof value === "boolean")
|
|
1363
|
-
return value ? 1 : 0;
|
|
1364
|
-
if (value instanceof Uint8Array)
|
|
1365
|
-
return value;
|
|
1366
|
-
try {
|
|
1367
|
-
return JSON.stringify(value) ?? null;
|
|
1368
|
-
}
|
|
1369
|
-
catch {
|
|
1370
|
-
return String(value);
|
|
1371
|
-
}
|
|
1372
|
-
}
|
|
1373
|
-
function quoteIdentifier(value) {
|
|
1374
|
-
return `"${value.replaceAll('"', '""')}"`;
|
|
1375
|
-
}
|
|
1376
|
-
function containsSelect(value) {
|
|
1377
|
-
if (!value || typeof value !== "object")
|
|
1378
|
-
return false;
|
|
1379
|
-
const record = value;
|
|
1380
|
-
if (record.type === "select")
|
|
1381
|
-
return true;
|
|
1382
|
-
return Object.values(record).some(containsSelect);
|
|
1383
|
-
}
|
|
1384
|
-
const FILTER_FUNCTIONS = new Set([
|
|
1385
|
-
"abs",
|
|
1386
|
-
"coalesce",
|
|
1387
|
-
"ifnull",
|
|
1388
|
-
"instr",
|
|
1389
|
-
"json_extract",
|
|
1390
|
-
"json_type",
|
|
1391
|
-
"json_valid",
|
|
1392
|
-
"length",
|
|
1393
|
-
"lower",
|
|
1394
|
-
"ltrim",
|
|
1395
|
-
"nullif",
|
|
1396
|
-
"round",
|
|
1397
|
-
"rtrim",
|
|
1398
|
-
"substr",
|
|
1399
|
-
"substring",
|
|
1400
|
-
"trim",
|
|
1401
|
-
"typeof",
|
|
1402
|
-
"upper",
|
|
1403
|
-
]);
|
|
1404
|
-
function validateFilterExpression(value, columns, tableName) {
|
|
1405
|
-
if (!value || typeof value !== "object")
|
|
1406
|
-
return;
|
|
1407
|
-
const record = value;
|
|
1408
|
-
if (record.type === "column_ref") {
|
|
1409
|
-
const column = record.column;
|
|
1410
|
-
const table = record.table;
|
|
1411
|
-
if (typeof column !== "string" ||
|
|
1412
|
-
!columns.has(column.toLowerCase()) ||
|
|
1413
|
-
(table !== null && table !== undefined && table !== tableName)) {
|
|
1414
|
-
throw new StateQLError("INVALID_SQL", `Unknown filter column "${String(column)}".`);
|
|
1415
|
-
}
|
|
1416
|
-
}
|
|
1417
|
-
else if (record.type === "double_quote_string") {
|
|
1418
|
-
const column = String(record.value);
|
|
1419
|
-
if (!columns.has(column.toLowerCase())) {
|
|
1420
|
-
throw new StateQLError("INVALID_SQL", `Unknown filter column "${column}".`);
|
|
1421
|
-
}
|
|
1422
|
-
}
|
|
1423
|
-
else if (record.type === "function") {
|
|
1424
|
-
const name = filterFunctionName(record);
|
|
1425
|
-
if (!name || !FILTER_FUNCTIONS.has(name)) {
|
|
1426
|
-
throw new StateQLError("INVALID_SQL", `Filter function "${name ?? "unknown"}" is not allowed.`);
|
|
1427
|
-
}
|
|
1428
|
-
}
|
|
1429
|
-
Object.values(record).forEach((item) => validateFilterExpression(item, columns, tableName));
|
|
1430
|
-
}
|
|
1431
|
-
function filterFunctionName(record) {
|
|
1432
|
-
const name = record.name;
|
|
1433
|
-
const parts = name?.name;
|
|
1434
|
-
if (!Array.isArray(parts))
|
|
1435
|
-
return undefined;
|
|
1436
|
-
const last = parts.at(-1);
|
|
1437
|
-
return typeof last?.value === "string" ? last.value.toLowerCase() : undefined;
|
|
1438
|
-
}
|
|
1439
|
-
function filterBindings(value) {
|
|
1440
|
-
const named = new Set();
|
|
1441
|
-
const prefixes = new Map();
|
|
1442
|
-
let positional = 0;
|
|
1443
|
-
const visit = (item) => {
|
|
1444
|
-
if (!item || typeof item !== "object")
|
|
1445
|
-
return;
|
|
1446
|
-
const record = item;
|
|
1447
|
-
if (record.type === "origin" && record.value === "?")
|
|
1448
|
-
positional += 1;
|
|
1449
|
-
if (record.type === "param" && typeof record.value === "string") {
|
|
1450
|
-
addNamed(String(record.value), `:${String(record.value)}`);
|
|
1451
|
-
}
|
|
1452
|
-
if (record.type === "var" &&
|
|
1453
|
-
typeof record.name === "string" &&
|
|
1454
|
-
(record.prefix === "$" || record.prefix === "@")) {
|
|
1455
|
-
addNamed(record.name, `${String(record.prefix)}${record.name}`);
|
|
1456
|
-
}
|
|
1457
|
-
Object.values(record).forEach(visit);
|
|
1458
|
-
};
|
|
1459
|
-
const addNamed = (name, token) => {
|
|
1460
|
-
const previous = prefixes.get(name);
|
|
1461
|
-
if (previous && previous !== token) {
|
|
1462
|
-
throw new StateQLError("INVALID_SQL", `Filter parameter "${name}" uses conflicting prefixes.`);
|
|
1463
|
-
}
|
|
1464
|
-
prefixes.set(name, token);
|
|
1465
|
-
named.add(name);
|
|
1377
|
+
command_id: item.id,
|
|
1378
|
+
timestamp: item.timestamp,
|
|
1379
|
+
session_id: item.session_id,
|
|
1380
|
+
command: item.command,
|
|
1381
|
+
handle: item.handle,
|
|
1382
|
+
executed: Boolean(item.executed),
|
|
1383
|
+
cached: Boolean(item.cached),
|
|
1384
|
+
success: Boolean(item.success),
|
|
1385
|
+
error_code: item.error_code,
|
|
1466
1386
|
};
|
|
1467
|
-
visit(value);
|
|
1468
|
-
if (positional && named.size) {
|
|
1469
|
-
throw new StateQLError("INVALID_SQL", "Filter cannot mix positional and named parameters.");
|
|
1470
|
-
}
|
|
1471
|
-
return { positional, named };
|
|
1472
|
-
}
|
|
1473
|
-
function validateFilterParameters(filter, parameters) {
|
|
1474
|
-
if (filter.positionalParameters) {
|
|
1475
|
-
if (!Array.isArray(parameters) ||
|
|
1476
|
-
parameters.length !== filter.positionalParameters) {
|
|
1477
|
-
throw new StateQLError("INVALID_SQL", `Filter requires exactly ${filter.positionalParameters} positional parameters.`);
|
|
1478
|
-
}
|
|
1479
|
-
return;
|
|
1480
|
-
}
|
|
1481
|
-
if (filter.namedParameters.length) {
|
|
1482
|
-
if (Array.isArray(parameters)) {
|
|
1483
|
-
throw new StateQLError("INVALID_SQL", "Filter requires named parameters.");
|
|
1484
|
-
}
|
|
1485
|
-
const supplied = Object.keys(parameters).sort();
|
|
1486
|
-
const expected = [...filter.namedParameters].sort();
|
|
1487
|
-
if (JSON.stringify(supplied) !== JSON.stringify(expected)) {
|
|
1488
|
-
throw new StateQLError("INVALID_SQL", `Filter requires named parameters: ${expected.join(", ")}.`);
|
|
1489
|
-
}
|
|
1490
|
-
return;
|
|
1491
|
-
}
|
|
1492
|
-
if ((Array.isArray(parameters) && parameters.length) ||
|
|
1493
|
-
(!Array.isArray(parameters) && Object.keys(parameters).length)) {
|
|
1494
|
-
throw new StateQLError("INVALID_SQL", "Filter predicate has no parameters.");
|
|
1495
|
-
}
|
|
1496
1387
|
}
|
|
1497
1388
|
function markTransactionOutcomeUnknown(store, transactionId, sessionId) {
|
|
1498
1389
|
try {
|
|
@@ -1506,6 +1397,9 @@ function boundedReadSql(sql, limit) {
|
|
|
1506
1397
|
const statement = sql.trim().replace(/;\s*$/, "");
|
|
1507
1398
|
return `SELECT * FROM (${statement}) AS _stateql_bounded LIMIT ${limit}`;
|
|
1508
1399
|
}
|
|
1400
|
+
function databaseDisplayName(driver) {
|
|
1401
|
+
return driver === "postgres" ? "PostgreSQL" : "MySQL";
|
|
1402
|
+
}
|
|
1509
1403
|
function normalizeIsolation(isolation, driver) {
|
|
1510
1404
|
const normalized = isolation.trim().toLowerCase().replace(/[_-]+/g, " ")
|
|
1511
1405
|
.replace(/\s+/g, " ");
|
|
@@ -1523,104 +1417,6 @@ function normalizeIsolation(isolation, driver) {
|
|
|
1523
1417
|
}
|
|
1524
1418
|
return normalized;
|
|
1525
1419
|
}
|
|
1526
|
-
function databaseIdentity(connection) {
|
|
1527
|
-
return {
|
|
1528
|
-
driver: connection.driver,
|
|
1529
|
-
database: connection.database_name,
|
|
1530
|
-
source: connection.source,
|
|
1531
|
-
secretEnvironment: connection.secret_env,
|
|
1532
|
-
};
|
|
1533
|
-
}
|
|
1534
|
-
function detectDriver(target) {
|
|
1535
|
-
if (/^postgres(?:ql)?:\/\//i.test(target))
|
|
1536
|
-
return "postgres";
|
|
1537
|
-
if (/^[a-z][a-z\d+.-]*:\/\//i.test(target)) {
|
|
1538
|
-
throw new StateQLError("UNSUPPORTED_DRIVER", "Only PostgreSQL and SQLite are supported.");
|
|
1539
|
-
}
|
|
1540
|
-
return "sqlite";
|
|
1541
|
-
}
|
|
1542
|
-
function normalizeSqliteSource(target) {
|
|
1543
|
-
const source = target.startsWith("sqlite:") ? target.slice(7) : target;
|
|
1544
|
-
if (source === ":memory:")
|
|
1545
|
-
return source;
|
|
1546
|
-
return resolve(source);
|
|
1547
|
-
}
|
|
1548
|
-
function postgresUrlHasSecret(target) {
|
|
1549
|
-
try {
|
|
1550
|
-
const url = new URL(target);
|
|
1551
|
-
return (Boolean(url.password) ||
|
|
1552
|
-
[...url.searchParams.keys()].some((key) => /pass|token|secret|private[_-]?key|api[_-]?key/i.test(key)));
|
|
1553
|
-
}
|
|
1554
|
-
catch {
|
|
1555
|
-
throw new StateQLError("INVALID_COMMAND", "Invalid PostgreSQL URL.");
|
|
1556
|
-
}
|
|
1557
|
-
}
|
|
1558
|
-
function version(connection) {
|
|
1559
|
-
return `sv_${connection.version}`;
|
|
1560
|
-
}
|
|
1561
|
-
function confidence(connection) {
|
|
1562
|
-
return connection.driver === "sqlite" ? "database_reported" : "ttl_based";
|
|
1563
|
-
}
|
|
1564
|
-
function sessionData(session) {
|
|
1565
|
-
return {
|
|
1566
|
-
session_id: session.id,
|
|
1567
|
-
name: session.name,
|
|
1568
|
-
state: session.status,
|
|
1569
|
-
active_connection: session.active_connection_id,
|
|
1570
|
-
active_transaction: session.active_transaction_id,
|
|
1571
|
-
};
|
|
1572
|
-
}
|
|
1573
|
-
function profileData(profile) {
|
|
1574
|
-
return {
|
|
1575
|
-
profile: profile.name,
|
|
1576
|
-
target: profile.target,
|
|
1577
|
-
secret_env: profile.secret_env,
|
|
1578
|
-
read_only: Boolean(profile.read_only),
|
|
1579
|
-
};
|
|
1580
|
-
}
|
|
1581
|
-
function validateProfileName(name) {
|
|
1582
|
-
if (/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/.test(name))
|
|
1583
|
-
return;
|
|
1584
|
-
throw new StateQLError("INVALID_COMMAND", "Profile name must be 1-64 letters, numbers, dots, underscores, or hyphens.");
|
|
1585
|
-
}
|
|
1586
|
-
function isEnvironmentName(name) {
|
|
1587
|
-
return /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name);
|
|
1588
|
-
}
|
|
1589
|
-
function operationData(operation) {
|
|
1590
|
-
return {
|
|
1591
|
-
operation_id: operation.id,
|
|
1592
|
-
statement_type: operation.statement_type,
|
|
1593
|
-
affected_rows: operation.affected_rows,
|
|
1594
|
-
status: operation.status,
|
|
1595
|
-
committed: operation.status === "committed",
|
|
1596
|
-
transaction_id: operation.transaction_id,
|
|
1597
|
-
state_version_before: operation.state_version_before,
|
|
1598
|
-
state_version_after: operation.state_version_after,
|
|
1599
|
-
...(operation.replay_of ? { replay_of: operation.replay_of } : {}),
|
|
1600
|
-
};
|
|
1601
|
-
}
|
|
1602
|
-
function transactionData(transaction, statements) {
|
|
1603
|
-
return {
|
|
1604
|
-
transaction_id: transaction.id,
|
|
1605
|
-
state: transaction.state,
|
|
1606
|
-
connection_id: transaction.connection_id,
|
|
1607
|
-
statements,
|
|
1608
|
-
pending_writes: transaction.state === "active" ? statements : 0,
|
|
1609
|
-
start_state_version: transaction.start_version,
|
|
1610
|
-
isolation_level: transaction.isolation_level,
|
|
1611
|
-
age_ms: Date.now() - Date.parse(transaction.created_at),
|
|
1612
|
-
};
|
|
1613
|
-
}
|
|
1614
|
-
function paginationWarnings(ordered) {
|
|
1615
|
-
if (ordered)
|
|
1616
|
-
return [];
|
|
1617
|
-
return [
|
|
1618
|
-
{
|
|
1619
|
-
code: "NON_DETERMINISTIC_PAGINATION",
|
|
1620
|
-
message: "Result has no explicit ORDER BY clause.",
|
|
1621
|
-
},
|
|
1622
|
-
];
|
|
1623
|
-
}
|
|
1624
1420
|
function batchString(value, name) {
|
|
1625
1421
|
if (value?.trim())
|
|
1626
1422
|
return value;
|
|
@@ -1636,19 +1432,15 @@ function positiveInteger(value, name) {
|
|
|
1636
1432
|
return value;
|
|
1637
1433
|
throw new StateQLError("INVALID_COMMAND", `${name} must be a positive integer.`);
|
|
1638
1434
|
}
|
|
1639
|
-
function
|
|
1640
|
-
const
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
};
|
|
1648
|
-
return [
|
|
1649
|
-
columns.map(encode).join(","),
|
|
1650
|
-
...rows.map((row) => columns.map((column) => encode(row[column])).join(",")),
|
|
1651
|
-
].join("\n") + "\n";
|
|
1435
|
+
function executionTimeout(value) {
|
|
1436
|
+
const timeout = positiveInteger(value, "timeoutMs");
|
|
1437
|
+
if (timeout > 2_147_483_647) {
|
|
1438
|
+
throw new StateQLError("INVALID_COMMAND", "timeoutMs cannot exceed 2147483647 milliseconds.");
|
|
1439
|
+
}
|
|
1440
|
+
return timeout;
|
|
1441
|
+
}
|
|
1442
|
+
function stoppedStateQLError(error, executed) {
|
|
1443
|
+
return new StateQLError(error.reason === "timeout" ? "DEADLINE_EXCEEDED" : "OPERATION_CANCELLED", error.message, { retryable: true, executed });
|
|
1652
1444
|
}
|
|
1653
1445
|
function errorMessage(error) {
|
|
1654
1446
|
return error instanceof Error ? error.message : String(error);
|