@fadhilp/stateql 0.1.1
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/LICENSE +21 -0
- package/README.md +222 -0
- package/dist/src/adapters.d.ts +23 -0
- package/dist/src/adapters.js +346 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +516 -0
- package/dist/src/errors.d.ts +12 -0
- package/dist/src/errors.js +45 -0
- package/dist/src/index.d.ts +3 -0
- package/dist/src/index.js +2 -0
- package/dist/src/sql.d.ts +13 -0
- package/dist/src/sql.js +91 -0
- package/dist/src/stateql.d.ts +55 -0
- package/dist/src/stateql.js +1655 -0
- package/dist/src/store.d.ts +245 -0
- package/dist/src/store.js +622 -0
- package/dist/src/types.d.ts +112 -0
- package/dist/src/types.js +1 -0
- package/dist/src/util.d.ts +8 -0
- package/dist/src/util.js +75 -0
- package/package.json +48 -0
|
@@ -0,0 +1,1655 @@
|
|
|
1
|
+
import { writeFileSync } from "node:fs";
|
|
2
|
+
import { basename, resolve } from "node:path";
|
|
3
|
+
import { DatabaseSync } from "node:sqlite";
|
|
4
|
+
import { env } from "node:process";
|
|
5
|
+
import { BatchWriteError, createAdapter, } from "./adapters.js";
|
|
6
|
+
import { asStateQLError, StateQLError } from "./errors.js";
|
|
7
|
+
import { analyzeSql } from "./sql.js";
|
|
8
|
+
import { StateStore, } from "./store.js";
|
|
9
|
+
import { compactRows, defaultHome, hash, parseJson, redact, } from "./util.js";
|
|
10
|
+
export class StateQL {
|
|
11
|
+
store;
|
|
12
|
+
sessionName;
|
|
13
|
+
previewRows;
|
|
14
|
+
cacheTtlSeconds;
|
|
15
|
+
resultTtlSeconds;
|
|
16
|
+
maxCellCharacters;
|
|
17
|
+
maxResultRows;
|
|
18
|
+
now;
|
|
19
|
+
constructor(options = {}) {
|
|
20
|
+
this.now = options.now ?? (() => new Date());
|
|
21
|
+
this.sessionName = options.session ?? env.STQL_SESSION ?? "default";
|
|
22
|
+
this.previewRows = options.previewRows ?? 5;
|
|
23
|
+
this.cacheTtlSeconds = options.cacheTtlSeconds ?? 300;
|
|
24
|
+
this.resultTtlSeconds = options.resultTtlSeconds ?? 86_400;
|
|
25
|
+
this.maxCellCharacters = options.maxCellCharacters ?? 200;
|
|
26
|
+
this.maxResultRows = positiveInteger(options.maxResultRows ?? 10_000, "maxResultRows");
|
|
27
|
+
if (this.maxResultRows >= Number.MAX_SAFE_INTEGER) {
|
|
28
|
+
throw new StateQLError("INVALID_COMMAND", "maxResultRows is too large.");
|
|
29
|
+
}
|
|
30
|
+
this.store = new StateStore(options.home ?? defaultHome(), this.now);
|
|
31
|
+
this.store.ensureSession(this.sessionName);
|
|
32
|
+
}
|
|
33
|
+
close() {
|
|
34
|
+
this.store.close();
|
|
35
|
+
}
|
|
36
|
+
async connect(target, options = {}) {
|
|
37
|
+
return this.run("connect", async (session) => {
|
|
38
|
+
if (session.active_transaction_id) {
|
|
39
|
+
throw new StateQLError("TRANSACTION_FAILED", "Commit or roll back the active transaction before connecting again.");
|
|
40
|
+
}
|
|
41
|
+
if (options.profile && target) {
|
|
42
|
+
throw new StateQLError("INVALID_COMMAND", "Use either a profile or a connection target, not both.");
|
|
43
|
+
}
|
|
44
|
+
const implicitProfile = !options.profile && !options.secretEnv && target
|
|
45
|
+
? this.store.getProfile(target)
|
|
46
|
+
: undefined;
|
|
47
|
+
const profile = options.profile
|
|
48
|
+
? this.store.getProfile(options.profile)
|
|
49
|
+
: implicitProfile;
|
|
50
|
+
if (options.profile && !profile) {
|
|
51
|
+
throw new StateQLError("CONNECTION_NOT_FOUND", `Profile "${options.profile}" was not found.`, { suggestedAction: "Run stql profile list." });
|
|
52
|
+
}
|
|
53
|
+
const resolvedTarget = profile?.target ?? target;
|
|
54
|
+
const secretEnv = options.secretEnv ?? profile?.secret_env ?? undefined;
|
|
55
|
+
const secret = secretEnv ? env[secretEnv] : resolvedTarget;
|
|
56
|
+
if (!secret) {
|
|
57
|
+
throw new StateQLError("INVALID_COMMAND", secretEnv
|
|
58
|
+
? `Environment variable ${secretEnv} is not set.`
|
|
59
|
+
: "Connection target is required.");
|
|
60
|
+
}
|
|
61
|
+
const driver = detectDriver(secret);
|
|
62
|
+
if (driver === "postgres" &&
|
|
63
|
+
!secretEnv &&
|
|
64
|
+
postgresUrlHasSecret(secret)) {
|
|
65
|
+
throw new StateQLError("PERMISSION_DENIED", "Credential-bearing PostgreSQL URLs must use --env.", {
|
|
66
|
+
suggestedAction: "Set the URL in an environment variable and reconnect with --env NAME.",
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
const source = driver === "sqlite"
|
|
70
|
+
? normalizeSqliteSource(secret)
|
|
71
|
+
: secretEnv
|
|
72
|
+
? redact(secret)
|
|
73
|
+
: secret;
|
|
74
|
+
const databaseName = driver === "sqlite"
|
|
75
|
+
? basename(source)
|
|
76
|
+
: new URL(secret).pathname.replace(/^\//, "") || "postgres";
|
|
77
|
+
const readOnly = options.readOnly ??
|
|
78
|
+
(profile ? Boolean(profile.read_only) : true);
|
|
79
|
+
const draft = {
|
|
80
|
+
id: "pending",
|
|
81
|
+
session_id: session.id,
|
|
82
|
+
name: options.name ?? profile?.name ?? databaseName,
|
|
83
|
+
driver,
|
|
84
|
+
database_name: databaseName,
|
|
85
|
+
source,
|
|
86
|
+
secret_env: secretEnv ?? null,
|
|
87
|
+
read_only: readOnly ? 1 : 0,
|
|
88
|
+
version: 0,
|
|
89
|
+
created_at: this.now().toISOString(),
|
|
90
|
+
};
|
|
91
|
+
const adapter = await createAdapter(draft);
|
|
92
|
+
try {
|
|
93
|
+
await adapter.read("SELECT 1", []);
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
throw new StateQLError("CONNECTION_FAILED", errorMessage(error), { retryable: true });
|
|
97
|
+
}
|
|
98
|
+
finally {
|
|
99
|
+
await adapter.close();
|
|
100
|
+
}
|
|
101
|
+
const connection = this.store.addConnection({
|
|
102
|
+
sessionId: session.id,
|
|
103
|
+
name: draft.name,
|
|
104
|
+
driver,
|
|
105
|
+
databaseName,
|
|
106
|
+
source,
|
|
107
|
+
...(secretEnv ? { secretEnv } : {}),
|
|
108
|
+
readOnly,
|
|
109
|
+
});
|
|
110
|
+
return {
|
|
111
|
+
data: {
|
|
112
|
+
connection_id: connection.id,
|
|
113
|
+
driver,
|
|
114
|
+
database: databaseName,
|
|
115
|
+
name: connection.name,
|
|
116
|
+
profile: profile?.name ?? null,
|
|
117
|
+
read_only: readOnly,
|
|
118
|
+
state_version: "sv_0",
|
|
119
|
+
state_confidence: driver === "sqlite" ? "database_reported" : "ttl_based",
|
|
120
|
+
},
|
|
121
|
+
handle: connection.id,
|
|
122
|
+
executed: true,
|
|
123
|
+
stateVersion: "sv_0",
|
|
124
|
+
confidence: driver === "sqlite" ? "database_reported" : "ttl_based",
|
|
125
|
+
};
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
async addProfile(name, target, options = {}) {
|
|
129
|
+
return this.run("profile.add", async () => {
|
|
130
|
+
validateProfileName(name);
|
|
131
|
+
if (Boolean(target) === Boolean(options.secretEnv)) {
|
|
132
|
+
throw new StateQLError("INVALID_COMMAND", "Profile requires exactly one target or secret environment variable.");
|
|
133
|
+
}
|
|
134
|
+
if (this.store.getProfile(name)) {
|
|
135
|
+
throw new StateQLError("INVALID_COMMAND", `Profile "${name}" already exists.`);
|
|
136
|
+
}
|
|
137
|
+
if (options.secretEnv && !isEnvironmentName(options.secretEnv)) {
|
|
138
|
+
throw new StateQLError("INVALID_COMMAND", "Secret environment variable name is invalid.");
|
|
139
|
+
}
|
|
140
|
+
let storedTarget = target;
|
|
141
|
+
if (target) {
|
|
142
|
+
const driver = detectDriver(target);
|
|
143
|
+
if (driver === "postgres" && postgresUrlHasSecret(target)) {
|
|
144
|
+
throw new StateQLError("PERMISSION_DENIED", "Credential-bearing PostgreSQL URLs must use --env.");
|
|
145
|
+
}
|
|
146
|
+
if (driver === "sqlite")
|
|
147
|
+
storedTarget = normalizeSqliteSource(target);
|
|
148
|
+
}
|
|
149
|
+
const profile = this.store.addProfile({
|
|
150
|
+
name,
|
|
151
|
+
target: storedTarget,
|
|
152
|
+
secretEnv: options.secretEnv,
|
|
153
|
+
readOnly: options.readOnly ?? true,
|
|
154
|
+
});
|
|
155
|
+
return {
|
|
156
|
+
data: profileData(profile),
|
|
157
|
+
handle: `profile:${profile.name}`,
|
|
158
|
+
executed: true,
|
|
159
|
+
};
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
async listProfiles() {
|
|
163
|
+
return this.run("profile.list", async () => ({
|
|
164
|
+
data: { profiles: this.store.listProfiles().map(profileData) },
|
|
165
|
+
}));
|
|
166
|
+
}
|
|
167
|
+
async showProfile(name) {
|
|
168
|
+
return this.run("profile.show", async () => {
|
|
169
|
+
const profile = this.store.getProfile(name);
|
|
170
|
+
if (!profile) {
|
|
171
|
+
throw new StateQLError("CONNECTION_NOT_FOUND", `Profile "${name}" was not found.`);
|
|
172
|
+
}
|
|
173
|
+
return {
|
|
174
|
+
data: profileData(profile),
|
|
175
|
+
handle: `profile:${profile.name}`,
|
|
176
|
+
};
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
async removeProfile(name) {
|
|
180
|
+
return this.run("profile.remove", async () => {
|
|
181
|
+
if (!this.store.removeProfile(name)) {
|
|
182
|
+
throw new StateQLError("CONNECTION_NOT_FOUND", `Profile "${name}" was not found.`);
|
|
183
|
+
}
|
|
184
|
+
return {
|
|
185
|
+
data: { profile: name, removed: true },
|
|
186
|
+
handle: `profile:${name}`,
|
|
187
|
+
executed: true,
|
|
188
|
+
};
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
async disconnect() {
|
|
192
|
+
return this.run("disconnect", async (session) => {
|
|
193
|
+
if (session.active_transaction_id) {
|
|
194
|
+
throw new StateQLError("TRANSACTION_FAILED", "Commit or roll back the active transaction before disconnecting.");
|
|
195
|
+
}
|
|
196
|
+
this.store.disconnect(session.id);
|
|
197
|
+
return { data: { disconnected: true }, executed: true };
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
async status() {
|
|
201
|
+
return this.run("status", async (session) => {
|
|
202
|
+
const connection = this.store.activeConnection(session);
|
|
203
|
+
const transaction = session.active_transaction_id
|
|
204
|
+
? this.store.getTransaction(session.active_transaction_id)
|
|
205
|
+
: undefined;
|
|
206
|
+
return {
|
|
207
|
+
data: {
|
|
208
|
+
session_id: session.id,
|
|
209
|
+
session_name: session.name,
|
|
210
|
+
connection: connection
|
|
211
|
+
? {
|
|
212
|
+
connection_id: connection.id,
|
|
213
|
+
name: connection.name,
|
|
214
|
+
driver: connection.driver,
|
|
215
|
+
database: connection.database_name,
|
|
216
|
+
read_only: Boolean(connection.read_only),
|
|
217
|
+
}
|
|
218
|
+
: null,
|
|
219
|
+
transaction: transaction
|
|
220
|
+
? { transaction_id: transaction.id, state: transaction.state }
|
|
221
|
+
: null,
|
|
222
|
+
state_version: connection ? version(connection) : null,
|
|
223
|
+
},
|
|
224
|
+
stateVersion: connection ? version(connection) : undefined,
|
|
225
|
+
confidence: connection ? confidence(connection) : undefined,
|
|
226
|
+
};
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
async startSession(name) {
|
|
230
|
+
return this.run("session.start", async () => {
|
|
231
|
+
if (!name.trim()) {
|
|
232
|
+
throw new StateQLError("INVALID_COMMAND", "Session name is required.");
|
|
233
|
+
}
|
|
234
|
+
if (this.store.getSession(name)) {
|
|
235
|
+
throw new StateQLError("INVALID_COMMAND", `Active session "${name}" already exists.`);
|
|
236
|
+
}
|
|
237
|
+
const session = this.store.createSession(name);
|
|
238
|
+
return {
|
|
239
|
+
data: sessionData(session),
|
|
240
|
+
handle: session.id,
|
|
241
|
+
executed: true,
|
|
242
|
+
session,
|
|
243
|
+
};
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
async listSessions() {
|
|
247
|
+
return this.run("session.list", async () => ({
|
|
248
|
+
data: {
|
|
249
|
+
sessions: this.store.listSessions().map((session) => ({
|
|
250
|
+
session_id: session.id,
|
|
251
|
+
name: session.name,
|
|
252
|
+
status: session.status,
|
|
253
|
+
active_connection: session.active_connection_id,
|
|
254
|
+
active_transaction: session.active_transaction_id,
|
|
255
|
+
})),
|
|
256
|
+
},
|
|
257
|
+
}));
|
|
258
|
+
}
|
|
259
|
+
async showSession(idOrName = this.sessionName) {
|
|
260
|
+
return this.run("session.show", async () => {
|
|
261
|
+
const session = this.store.getSession(idOrName);
|
|
262
|
+
if (!session) {
|
|
263
|
+
throw new StateQLError("INVALID_COMMAND", `Session "${idOrName}" was not found.`);
|
|
264
|
+
}
|
|
265
|
+
return { data: sessionData(session), session };
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
async sessionSummary() {
|
|
269
|
+
return this.run("session.summary", async (session) => {
|
|
270
|
+
const connection = this.store.activeConnection(session);
|
|
271
|
+
return {
|
|
272
|
+
data: {
|
|
273
|
+
session_id: session.id,
|
|
274
|
+
name: session.name,
|
|
275
|
+
connection: connection?.name ?? null,
|
|
276
|
+
state_version: connection ? version(connection) : null,
|
|
277
|
+
transaction: session.active_transaction_id,
|
|
278
|
+
known_results: this.store.knownResults(session.id, 10).map((result) => ({
|
|
279
|
+
alias: result.alias,
|
|
280
|
+
handle: result.id,
|
|
281
|
+
rows: result.row_count,
|
|
282
|
+
})),
|
|
283
|
+
recent_operations: this.store
|
|
284
|
+
.recentOperations(session.id, 10)
|
|
285
|
+
.map((operation) => ({
|
|
286
|
+
handle: operation.id,
|
|
287
|
+
type: operation.statement_type,
|
|
288
|
+
affected_rows: operation.affected_rows,
|
|
289
|
+
status: operation.status,
|
|
290
|
+
})),
|
|
291
|
+
},
|
|
292
|
+
stateVersion: connection ? version(connection) : undefined,
|
|
293
|
+
confidence: connection ? confidence(connection) : undefined,
|
|
294
|
+
};
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
async closeSession() {
|
|
298
|
+
return this.run("session.close", async (session) => {
|
|
299
|
+
if (session.active_transaction_id) {
|
|
300
|
+
throw new StateQLError("TRANSACTION_FAILED", "Roll back or commit the active transaction first.");
|
|
301
|
+
}
|
|
302
|
+
this.store.closeSession(session.id);
|
|
303
|
+
return {
|
|
304
|
+
data: { session_id: session.id, state: "closed" },
|
|
305
|
+
handle: session.id,
|
|
306
|
+
executed: true,
|
|
307
|
+
};
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
async query(sql, options = {}) {
|
|
311
|
+
return this.run("query", async (session) => {
|
|
312
|
+
const connection = this.requireConnection(session);
|
|
313
|
+
const analysis = analyzeSql(sql, connection.driver);
|
|
314
|
+
if (!analysis.read) {
|
|
315
|
+
throw new StateQLError("INVALID_SQL", "query accepts read statements only; use exec for writes.");
|
|
316
|
+
}
|
|
317
|
+
const parameters = options.params ?? [];
|
|
318
|
+
const adapter = await createAdapter(connection);
|
|
319
|
+
try {
|
|
320
|
+
const stateVersion = version(connection);
|
|
321
|
+
const stateSignature = await adapter.signature();
|
|
322
|
+
const fingerprint = hash({
|
|
323
|
+
sql: analysis.normalized,
|
|
324
|
+
parameters,
|
|
325
|
+
driver: connection.driver,
|
|
326
|
+
connection: connection.id,
|
|
327
|
+
database: connection.database_name,
|
|
328
|
+
transaction: session.active_transaction_id,
|
|
329
|
+
stateVersion,
|
|
330
|
+
});
|
|
331
|
+
const cached = this.store.findResult(fingerprint);
|
|
332
|
+
const cacheMode = options.cache ?? "auto";
|
|
333
|
+
if (cacheMode !== "bypass" &&
|
|
334
|
+
cached &&
|
|
335
|
+
cached.row_count <= this.maxResultRows &&
|
|
336
|
+
this.cacheValid(cached, stateVersion, stateSignature)) {
|
|
337
|
+
return {
|
|
338
|
+
data: this.resultData(cached, true),
|
|
339
|
+
handle: cached.id,
|
|
340
|
+
cached: true,
|
|
341
|
+
warnings: paginationWarnings(analysis.ordered),
|
|
342
|
+
stateVersion,
|
|
343
|
+
confidence: cached.state_confidence,
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
if (cacheMode === "require") {
|
|
347
|
+
throw new StateQLError("CACHE_MISS", "No valid cached result exists.", {
|
|
348
|
+
retryable: true,
|
|
349
|
+
suggestedAction: "Run with --cache auto or --cache bypass.",
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
const result = await adapter.read(boundedReadSql(sql, this.maxResultRows + 1), parameters);
|
|
353
|
+
if (result.rows.length > this.maxResultRows) {
|
|
354
|
+
throw new StateQLError("OUTPUT_LIMIT_EXCEEDED", `Query exceeds the ${this.maxResultRows}-row materialization limit.`, { suggestedAction: "Add a narrower WHERE clause or LIMIT." });
|
|
355
|
+
}
|
|
356
|
+
const expiresAt = new Date(this.now().getTime() + this.resultTtlSeconds * 1000).toISOString();
|
|
357
|
+
const saved = this.store.saveResult({
|
|
358
|
+
sessionId: session.id,
|
|
359
|
+
connectionId: connection.id,
|
|
360
|
+
fingerprint,
|
|
361
|
+
sql,
|
|
362
|
+
parameters,
|
|
363
|
+
rows: result.rows,
|
|
364
|
+
columns: result.columns,
|
|
365
|
+
stateVersion,
|
|
366
|
+
stateSignature,
|
|
367
|
+
stateConfidence: adapter.confidence,
|
|
368
|
+
expiresAt,
|
|
369
|
+
});
|
|
370
|
+
return {
|
|
371
|
+
data: this.resultData(saved, false),
|
|
372
|
+
handle: saved.id,
|
|
373
|
+
executed: true,
|
|
374
|
+
warnings: paginationWarnings(analysis.ordered),
|
|
375
|
+
stateVersion,
|
|
376
|
+
confidence: adapter.confidence,
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
catch (error) {
|
|
380
|
+
if (error instanceof StateQLError)
|
|
381
|
+
throw error;
|
|
382
|
+
throw new StateQLError("QUERY_FAILED", errorMessage(error), {
|
|
383
|
+
retryable: true,
|
|
384
|
+
executed: true,
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
finally {
|
|
388
|
+
await adapter.close();
|
|
389
|
+
}
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
async show(idOrAlias) {
|
|
393
|
+
return this.withResult("show", idOrAlias, async (result) => ({
|
|
394
|
+
data: this.resultData(result, true),
|
|
395
|
+
handle: result.id,
|
|
396
|
+
cached: true,
|
|
397
|
+
stateVersion: result.state_version,
|
|
398
|
+
confidence: result.state_confidence,
|
|
399
|
+
}));
|
|
400
|
+
}
|
|
401
|
+
async filter(idOrAlias, predicate, options = {}) {
|
|
402
|
+
return this.withResult("filter", idOrAlias, async (source) => {
|
|
403
|
+
const columns = this.store.resultColumns(source);
|
|
404
|
+
const filter = prepareFilterStatement(columns, predicate);
|
|
405
|
+
const parameters = options.params ?? [];
|
|
406
|
+
validateFilterParameters(filter, parameters);
|
|
407
|
+
const fingerprint = hash({
|
|
408
|
+
command: "filter",
|
|
409
|
+
source: source.id,
|
|
410
|
+
predicate: filter.normalized,
|
|
411
|
+
parameters,
|
|
412
|
+
});
|
|
413
|
+
const cached = this.store.findResult(fingerprint);
|
|
414
|
+
if (cached &&
|
|
415
|
+
cached.session_id === source.session_id &&
|
|
416
|
+
cached.connection_id === source.connection_id &&
|
|
417
|
+
Date.parse(cached.expires_at) > this.now().getTime()) {
|
|
418
|
+
return {
|
|
419
|
+
data: this.resultData(cached, true),
|
|
420
|
+
handle: cached.id,
|
|
421
|
+
cached: true,
|
|
422
|
+
stateVersion: cached.state_version,
|
|
423
|
+
confidence: cached.state_confidence,
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
const rows = filterMaterializedRows(this.store.resultRows(source), filter, parameters);
|
|
427
|
+
const saved = this.store.saveResult({
|
|
428
|
+
sessionId: source.session_id,
|
|
429
|
+
connectionId: source.connection_id,
|
|
430
|
+
fingerprint,
|
|
431
|
+
sql: `FILTER ${source.id} WHERE ${predicate.trim()}`,
|
|
432
|
+
parameters,
|
|
433
|
+
rows,
|
|
434
|
+
columns,
|
|
435
|
+
stateVersion: source.state_version,
|
|
436
|
+
stateSignature: source.state_signature,
|
|
437
|
+
stateConfidence: source.state_confidence,
|
|
438
|
+
expiresAt: source.expires_at,
|
|
439
|
+
});
|
|
440
|
+
return {
|
|
441
|
+
data: this.resultData(saved, false),
|
|
442
|
+
handle: saved.id,
|
|
443
|
+
executed: true,
|
|
444
|
+
stateVersion: saved.state_version,
|
|
445
|
+
confidence: saved.state_confidence,
|
|
446
|
+
};
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
async rows(idOrAlias, options = {}) {
|
|
450
|
+
return this.withResult("rows", idOrAlias, async (result) => {
|
|
451
|
+
const offset = nonNegativeInteger(options.offset ?? 0, "offset");
|
|
452
|
+
const limit = positiveInteger(options.limit ?? 20, "limit");
|
|
453
|
+
if (limit > 1_000) {
|
|
454
|
+
throw new StateQLError("OUTPUT_LIMIT_EXCEEDED", "limit cannot exceed 1000 rows.", { suggestedAction: "Fetch another page or use export." });
|
|
455
|
+
}
|
|
456
|
+
const allRows = this.store.resultRows(result);
|
|
457
|
+
const rows = allRows.slice(offset, offset + limit);
|
|
458
|
+
return {
|
|
459
|
+
data: {
|
|
460
|
+
result_id: result.id,
|
|
461
|
+
offset,
|
|
462
|
+
limit,
|
|
463
|
+
rows: compactRows(rows, this.maxCellCharacters),
|
|
464
|
+
returned: rows.length,
|
|
465
|
+
total: result.row_count,
|
|
466
|
+
truncated: offset + rows.length < result.row_count,
|
|
467
|
+
next_offset: offset + rows.length < result.row_count ? offset + rows.length : null,
|
|
468
|
+
},
|
|
469
|
+
handle: result.id,
|
|
470
|
+
cached: true,
|
|
471
|
+
stateVersion: result.state_version,
|
|
472
|
+
confidence: result.state_confidence,
|
|
473
|
+
};
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
async count(idOrAlias) {
|
|
477
|
+
return this.withResult("count", idOrAlias, async (result) => ({
|
|
478
|
+
data: { result_id: result.id, rows: result.row_count },
|
|
479
|
+
handle: result.id,
|
|
480
|
+
cached: true,
|
|
481
|
+
stateVersion: result.state_version,
|
|
482
|
+
confidence: result.state_confidence,
|
|
483
|
+
}));
|
|
484
|
+
}
|
|
485
|
+
async columns(idOrAlias) {
|
|
486
|
+
return this.withResult("columns", idOrAlias, async (result) => ({
|
|
487
|
+
data: {
|
|
488
|
+
result_id: result.id,
|
|
489
|
+
columns: this.store.resultColumns(result),
|
|
490
|
+
},
|
|
491
|
+
handle: result.id,
|
|
492
|
+
cached: true,
|
|
493
|
+
stateVersion: result.state_version,
|
|
494
|
+
confidence: result.state_confidence,
|
|
495
|
+
}));
|
|
496
|
+
}
|
|
497
|
+
async setAlias(name, id) {
|
|
498
|
+
return this.run("alias.set", async (session) => {
|
|
499
|
+
const result = this.requireResult(id, session);
|
|
500
|
+
this.store.setAlias(session.id, name, result.id);
|
|
501
|
+
return {
|
|
502
|
+
data: { alias: name, result_id: result.id },
|
|
503
|
+
handle: result.id,
|
|
504
|
+
executed: true,
|
|
505
|
+
};
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
async exportResult(idOrAlias, output, format = "csv") {
|
|
509
|
+
return this.withResult("export", idOrAlias, async (result) => {
|
|
510
|
+
const rows = this.store.resultRows(result);
|
|
511
|
+
const content = format === "json"
|
|
512
|
+
? `${JSON.stringify(rows, null, 2)}\n`
|
|
513
|
+
: format === "jsonl"
|
|
514
|
+
? `${rows.map((row) => JSON.stringify(row)).join("\n")}\n`
|
|
515
|
+
: rowsToCsv(rows, this.store.resultColumns(result).map((column) => column.name));
|
|
516
|
+
writeFileSync(resolve(output), content, "utf8");
|
|
517
|
+
return {
|
|
518
|
+
data: {
|
|
519
|
+
result_id: result.id,
|
|
520
|
+
output: resolve(output),
|
|
521
|
+
format,
|
|
522
|
+
rows: rows.length,
|
|
523
|
+
},
|
|
524
|
+
handle: result.id,
|
|
525
|
+
executed: true,
|
|
526
|
+
};
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
async exec(sql, options = {}) {
|
|
530
|
+
return this.run("exec", async (session) => {
|
|
531
|
+
const connection = this.requireConnection(session);
|
|
532
|
+
return this.performExec(session, connection, sql, options);
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
async receipt(id) {
|
|
536
|
+
return this.run("receipt", async (session) => {
|
|
537
|
+
const operation = this.store.getOperation(id);
|
|
538
|
+
if (!operation || operation.session_id !== session.id) {
|
|
539
|
+
throw new StateQLError("RESULT_NOT_FOUND", `Operation "${id}" was not found.`);
|
|
540
|
+
}
|
|
541
|
+
return {
|
|
542
|
+
data: operationData(operation),
|
|
543
|
+
handle: operation.id,
|
|
544
|
+
stateVersion: operation.state_version_after ?? operation.state_version_before,
|
|
545
|
+
};
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
async beginTransaction(isolation = "serializable") {
|
|
549
|
+
return this.run("transaction.begin", async (session) => {
|
|
550
|
+
const connection = this.requireConnection(session);
|
|
551
|
+
if (connection.read_only) {
|
|
552
|
+
throw new StateQLError("READ_ONLY_CONNECTION", "Cannot begin a write transaction on a read-only connection.");
|
|
553
|
+
}
|
|
554
|
+
if (session.active_transaction_id) {
|
|
555
|
+
throw new StateQLError("TRANSACTION_FAILED", `Transaction "${session.active_transaction_id}" is already active.`);
|
|
556
|
+
}
|
|
557
|
+
const normalizedIsolation = normalizeIsolation(isolation, connection.driver);
|
|
558
|
+
const transaction = this.store.createTransaction({
|
|
559
|
+
sessionId: session.id,
|
|
560
|
+
connectionId: connection.id,
|
|
561
|
+
isolation: normalizedIsolation,
|
|
562
|
+
startVersion: version(connection),
|
|
563
|
+
});
|
|
564
|
+
return {
|
|
565
|
+
data: transactionData(transaction, 0),
|
|
566
|
+
handle: transaction.id,
|
|
567
|
+
executed: true,
|
|
568
|
+
stateVersion: transaction.start_version,
|
|
569
|
+
};
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
async transactionStatus(id) {
|
|
573
|
+
return this.run("transaction.status", async (session) => {
|
|
574
|
+
const transactionId = id ?? session.active_transaction_id;
|
|
575
|
+
if (!transactionId) {
|
|
576
|
+
throw new StateQLError("TRANSACTION_NOT_FOUND", "No active transaction.");
|
|
577
|
+
}
|
|
578
|
+
const transaction = this.store.getTransaction(transactionId);
|
|
579
|
+
if (!transaction || transaction.session_id !== session.id) {
|
|
580
|
+
throw new StateQLError("TRANSACTION_NOT_FOUND", `Transaction "${transactionId}" was not found.`);
|
|
581
|
+
}
|
|
582
|
+
const operations = this.store.transactionOperations(transaction.id);
|
|
583
|
+
return {
|
|
584
|
+
data: transactionData(transaction, operations.length),
|
|
585
|
+
handle: transaction.id,
|
|
586
|
+
stateVersion: transaction.start_version,
|
|
587
|
+
};
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
async commitTransaction(id) {
|
|
591
|
+
return this.run("transaction.commit", async (session) => {
|
|
592
|
+
const transaction = this.requireActiveTransaction(session, id);
|
|
593
|
+
const connection = this.store.getConnection(transaction.connection_id);
|
|
594
|
+
if (!connection) {
|
|
595
|
+
throw new StateQLError("CONNECTION_NOT_FOUND", "Transaction connection was not found.");
|
|
596
|
+
}
|
|
597
|
+
if (version(connection) !== transaction.start_version) {
|
|
598
|
+
throw new StateQLError("TRANSACTION_FAILED", "Connection state changed after the transaction began.", { suggestedAction: "Roll back and begin a new transaction." });
|
|
599
|
+
}
|
|
600
|
+
const operations = this.store.transactionOperations(transaction.id);
|
|
601
|
+
if (operations.some((operation) => operation.connection_id !== connection.id)) {
|
|
602
|
+
throw new StateQLError("TRANSACTION_FAILED", "Transaction contains writes staged for another connection.", { suggestedAction: "Roll back the transaction." });
|
|
603
|
+
}
|
|
604
|
+
const adapter = await createAdapter(connection);
|
|
605
|
+
try {
|
|
606
|
+
if (!this.store.markTransactionCommitting(transaction.id)) {
|
|
607
|
+
throw new StateQLError("TRANSACTION_FAILED", "Transaction is no longer active.");
|
|
608
|
+
}
|
|
609
|
+
let results;
|
|
610
|
+
try {
|
|
611
|
+
results = await adapter.writeBatch(operations, transaction.isolation_level);
|
|
612
|
+
}
|
|
613
|
+
catch (error) {
|
|
614
|
+
if (error instanceof BatchWriteError && !error.outcomeUnknown) {
|
|
615
|
+
this.store.finishTransaction(transaction.id, session.id, "failed");
|
|
616
|
+
throw new StateQLError("TRANSACTION_FAILED", error.message, {
|
|
617
|
+
retryable: true,
|
|
618
|
+
});
|
|
619
|
+
}
|
|
620
|
+
markTransactionOutcomeUnknown(this.store, transaction.id, session.id);
|
|
621
|
+
throw new StateQLError("OUTCOME_UNKNOWN", errorMessage(error), {
|
|
622
|
+
executed: true,
|
|
623
|
+
suggestedAction: "Inspect database state before issuing any replacement write.",
|
|
624
|
+
});
|
|
625
|
+
}
|
|
626
|
+
if (results.length !== operations.length) {
|
|
627
|
+
markTransactionOutcomeUnknown(this.store, transaction.id, session.id);
|
|
628
|
+
throw new StateQLError("OUTCOME_UNKNOWN", "Database returned an incomplete transaction result.", {
|
|
629
|
+
executed: true,
|
|
630
|
+
suggestedAction: "Inspect database state before issuing any replacement write.",
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
let stateVersion;
|
|
634
|
+
try {
|
|
635
|
+
stateVersion = this.store.commitTransactionMetadata({
|
|
636
|
+
transactionId: transaction.id,
|
|
637
|
+
sessionId: session.id,
|
|
638
|
+
connectionId: connection.id,
|
|
639
|
+
operations: operations.map((operation, index) => ({
|
|
640
|
+
id: operation.id,
|
|
641
|
+
affectedRows: results[index].affectedRows,
|
|
642
|
+
})),
|
|
643
|
+
});
|
|
644
|
+
}
|
|
645
|
+
catch (error) {
|
|
646
|
+
markTransactionOutcomeUnknown(this.store, transaction.id, session.id);
|
|
647
|
+
throw new StateQLError("OUTCOME_UNKNOWN", errorMessage(error), {
|
|
648
|
+
executed: true,
|
|
649
|
+
suggestedAction: "Inspect database state before issuing any replacement write.",
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
return {
|
|
653
|
+
data: {
|
|
654
|
+
transaction_id: transaction.id,
|
|
655
|
+
state: "committed",
|
|
656
|
+
statements_executed: operations.length,
|
|
657
|
+
affected_rows: results.reduce((total, result) => total + result.affectedRows, 0),
|
|
658
|
+
state_version: stateVersion,
|
|
659
|
+
},
|
|
660
|
+
handle: transaction.id,
|
|
661
|
+
executed: true,
|
|
662
|
+
stateVersion,
|
|
663
|
+
confidence: adapter.confidence,
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
finally {
|
|
667
|
+
try {
|
|
668
|
+
await adapter.close();
|
|
669
|
+
}
|
|
670
|
+
catch {
|
|
671
|
+
// Transaction outcome and metadata are already recorded.
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
async rollbackTransaction(id) {
|
|
677
|
+
return this.run("transaction.rollback", async (session) => {
|
|
678
|
+
const transaction = this.requireActiveTransaction(session, id);
|
|
679
|
+
const count = this.store.transactionOperations(transaction.id).length;
|
|
680
|
+
this.store.finishTransaction(transaction.id, session.id, "rolled_back");
|
|
681
|
+
return {
|
|
682
|
+
data: {
|
|
683
|
+
transaction_id: transaction.id,
|
|
684
|
+
state: "rolled_back",
|
|
685
|
+
discarded_statements: count,
|
|
686
|
+
},
|
|
687
|
+
handle: transaction.id,
|
|
688
|
+
executed: true,
|
|
689
|
+
stateVersion: transaction.start_version,
|
|
690
|
+
};
|
|
691
|
+
});
|
|
692
|
+
}
|
|
693
|
+
async inspect(kind, table) {
|
|
694
|
+
return this.run(`inspect.${kind}`, async (session) => {
|
|
695
|
+
const connection = this.requireConnection(session);
|
|
696
|
+
const adapter = await createAdapter(connection);
|
|
697
|
+
try {
|
|
698
|
+
const data = await adapter.inspect(kind, table);
|
|
699
|
+
return {
|
|
700
|
+
data,
|
|
701
|
+
executed: true,
|
|
702
|
+
stateVersion: version(connection),
|
|
703
|
+
confidence: adapter.confidence,
|
|
704
|
+
};
|
|
705
|
+
}
|
|
706
|
+
catch (error) {
|
|
707
|
+
throw new StateQLError("QUERY_FAILED", errorMessage(error), {
|
|
708
|
+
retryable: false,
|
|
709
|
+
executed: true,
|
|
710
|
+
});
|
|
711
|
+
}
|
|
712
|
+
finally {
|
|
713
|
+
await adapter.close();
|
|
714
|
+
}
|
|
715
|
+
});
|
|
716
|
+
}
|
|
717
|
+
async plan(sql, options = {}) {
|
|
718
|
+
return this.run("plan", async (session) => {
|
|
719
|
+
const connection = this.requireConnection(session);
|
|
720
|
+
const analysis = analyzeSql(sql, connection.driver);
|
|
721
|
+
if (analysis.read) {
|
|
722
|
+
throw new StateQLError("INVALID_SQL", "plan accepts write statements only.");
|
|
723
|
+
}
|
|
724
|
+
const adapter = await createAdapter(connection);
|
|
725
|
+
try {
|
|
726
|
+
const stateSignature = await adapter.signature();
|
|
727
|
+
const expiresAt = new Date(this.now().getTime() + 10 * 60_000).toISOString();
|
|
728
|
+
const plan = this.store.savePlan({
|
|
729
|
+
sessionId: session.id,
|
|
730
|
+
connectionId: connection.id,
|
|
731
|
+
sql,
|
|
732
|
+
parameters: options.params ?? [],
|
|
733
|
+
statementType: analysis.statementType,
|
|
734
|
+
stateVersion: version(connection),
|
|
735
|
+
stateSignature,
|
|
736
|
+
destructive: analysis.destructive || analysis.unboundedMutation,
|
|
737
|
+
allowUnbounded: options.allowUnbounded ?? false,
|
|
738
|
+
allowDestructive: options.allowDestructive ?? false,
|
|
739
|
+
expiresAt,
|
|
740
|
+
});
|
|
741
|
+
return {
|
|
742
|
+
data: {
|
|
743
|
+
plan_id: plan.id,
|
|
744
|
+
statement_type: plan.statement_type,
|
|
745
|
+
destructive: Boolean(plan.destructive),
|
|
746
|
+
requires_confirmation: (analysis.unboundedMutation && !Boolean(plan.allow_unbounded)) ||
|
|
747
|
+
(analysis.destructive && !Boolean(plan.allow_destructive)),
|
|
748
|
+
required_overrides: [
|
|
749
|
+
...(analysis.unboundedMutation && !Boolean(plan.allow_unbounded)
|
|
750
|
+
? ["--allow-unbounded"]
|
|
751
|
+
: []),
|
|
752
|
+
...(analysis.destructive && !Boolean(plan.allow_destructive)
|
|
753
|
+
? ["--allow-destructive"]
|
|
754
|
+
: []),
|
|
755
|
+
],
|
|
756
|
+
state_version: plan.state_version,
|
|
757
|
+
expires_at: plan.expires_at,
|
|
758
|
+
},
|
|
759
|
+
handle: plan.id,
|
|
760
|
+
executed: true,
|
|
761
|
+
stateVersion: plan.state_version,
|
|
762
|
+
confidence: adapter.confidence,
|
|
763
|
+
};
|
|
764
|
+
}
|
|
765
|
+
finally {
|
|
766
|
+
await adapter.close();
|
|
767
|
+
}
|
|
768
|
+
});
|
|
769
|
+
}
|
|
770
|
+
async apply(planId) {
|
|
771
|
+
return this.run("apply", async (session) => {
|
|
772
|
+
const plan = this.store.getPlan(planId);
|
|
773
|
+
if (!plan || plan.session_id !== session.id) {
|
|
774
|
+
throw new StateQLError("STALE_PLAN", `Plan "${planId}" was not found.`);
|
|
775
|
+
}
|
|
776
|
+
if (plan.applied_operation_id) {
|
|
777
|
+
throw new StateQLError("STALE_PLAN", "Plan was already applied.", {
|
|
778
|
+
extra: { previous_operation_id: plan.applied_operation_id },
|
|
779
|
+
});
|
|
780
|
+
}
|
|
781
|
+
if (Date.parse(plan.expires_at) <= this.now().getTime()) {
|
|
782
|
+
throw new StateQLError("STALE_PLAN", "Plan has expired.");
|
|
783
|
+
}
|
|
784
|
+
const connection = this.requireConnection(session);
|
|
785
|
+
if (connection.id !== plan.connection_id ||
|
|
786
|
+
version(connection) !== plan.state_version) {
|
|
787
|
+
throw new StateQLError("STALE_PLAN", "Database state changed after this plan was created.");
|
|
788
|
+
}
|
|
789
|
+
const adapter = await createAdapter(connection);
|
|
790
|
+
try {
|
|
791
|
+
if ((await adapter.signature()) !== plan.state_signature) {
|
|
792
|
+
throw new StateQLError("STALE_PLAN", "Database state changed after this plan was created.");
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
finally {
|
|
796
|
+
await adapter.close();
|
|
797
|
+
}
|
|
798
|
+
const result = await this.performExec(session, connection, plan.sql, {
|
|
799
|
+
params: parseJson(plan.parameters, []),
|
|
800
|
+
allowUnbounded: Boolean(plan.allow_unbounded),
|
|
801
|
+
allowDestructive: Boolean(plan.allow_destructive),
|
|
802
|
+
});
|
|
803
|
+
const operationId = String(result.data.operation_id);
|
|
804
|
+
this.store.markPlanApplied(plan.id, operationId);
|
|
805
|
+
return {
|
|
806
|
+
...result,
|
|
807
|
+
data: { plan_id: plan.id, ...result.data },
|
|
808
|
+
};
|
|
809
|
+
});
|
|
810
|
+
}
|
|
811
|
+
async history(limit = 20) {
|
|
812
|
+
return this.run("history", async (session) => ({
|
|
813
|
+
data: {
|
|
814
|
+
history: this.store
|
|
815
|
+
.history(session.id, positiveInteger(limit, "limit"))
|
|
816
|
+
.map((item) => ({
|
|
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
|
+
})),
|
|
827
|
+
},
|
|
828
|
+
}));
|
|
829
|
+
}
|
|
830
|
+
async capabilities() {
|
|
831
|
+
return this.run("capabilities", async () => ({
|
|
832
|
+
data: {
|
|
833
|
+
drivers: ["postgres", "sqlite"],
|
|
834
|
+
features: {
|
|
835
|
+
result_handles: true,
|
|
836
|
+
write_deduplication: true,
|
|
837
|
+
transactions: true,
|
|
838
|
+
query_plans: true,
|
|
839
|
+
persistent_sessions: true,
|
|
840
|
+
result_filtering: true,
|
|
841
|
+
schema_inspection: true,
|
|
842
|
+
},
|
|
843
|
+
},
|
|
844
|
+
}));
|
|
845
|
+
}
|
|
846
|
+
async executeCommand(command) {
|
|
847
|
+
if (!command || typeof command !== "object") {
|
|
848
|
+
return this.batchFailure("Batch command must be an object.");
|
|
849
|
+
}
|
|
850
|
+
try {
|
|
851
|
+
switch (command.command) {
|
|
852
|
+
case "connect":
|
|
853
|
+
return this.connect(command.target, {
|
|
854
|
+
name: command.name,
|
|
855
|
+
readOnly: command.read_only,
|
|
856
|
+
secretEnv: command.secret_env,
|
|
857
|
+
profile: command.profile,
|
|
858
|
+
});
|
|
859
|
+
case "disconnect":
|
|
860
|
+
return this.disconnect();
|
|
861
|
+
case "status":
|
|
862
|
+
return this.status();
|
|
863
|
+
case "profile.add":
|
|
864
|
+
return this.addProfile(batchString(command.name, "name"), command.target, {
|
|
865
|
+
readOnly: command.read_only ?? true,
|
|
866
|
+
secretEnv: command.secret_env,
|
|
867
|
+
});
|
|
868
|
+
case "profile.list":
|
|
869
|
+
return this.listProfiles();
|
|
870
|
+
case "profile.show":
|
|
871
|
+
return this.showProfile(batchString(command.name, "name"));
|
|
872
|
+
case "profile.remove":
|
|
873
|
+
return this.removeProfile(batchString(command.name, "name"));
|
|
874
|
+
case "session.start":
|
|
875
|
+
return this.startSession(batchString(command.name, "name"));
|
|
876
|
+
case "session.list":
|
|
877
|
+
return this.listSessions();
|
|
878
|
+
case "session.show":
|
|
879
|
+
return this.showSession(command.name);
|
|
880
|
+
case "session.summary":
|
|
881
|
+
return this.sessionSummary();
|
|
882
|
+
case "session.close":
|
|
883
|
+
return this.closeSession();
|
|
884
|
+
case "query": {
|
|
885
|
+
const response = await this.query(batchString(command.sql, "sql"), {
|
|
886
|
+
params: command.params ?? [],
|
|
887
|
+
cache: command.cache ?? "auto",
|
|
888
|
+
});
|
|
889
|
+
if (!response.ok || !command.as)
|
|
890
|
+
return response;
|
|
891
|
+
const resultId = response.data.result_id;
|
|
892
|
+
if (typeof resultId !== "string")
|
|
893
|
+
return response;
|
|
894
|
+
this.store.setAlias(response.session_id, command.as, resultId);
|
|
895
|
+
return {
|
|
896
|
+
...response,
|
|
897
|
+
data: { ...response.data, alias: command.as },
|
|
898
|
+
};
|
|
899
|
+
}
|
|
900
|
+
case "filter": {
|
|
901
|
+
const response = await this.filter(batchString(command.handle, "handle"), batchString(command.where, "where"), { params: command.params ?? [] });
|
|
902
|
+
if (!response.ok || !command.as)
|
|
903
|
+
return response;
|
|
904
|
+
const resultId = response.data.result_id;
|
|
905
|
+
if (typeof resultId !== "string")
|
|
906
|
+
return response;
|
|
907
|
+
this.store.setAlias(response.session_id, command.as, resultId);
|
|
908
|
+
return {
|
|
909
|
+
...response,
|
|
910
|
+
data: { ...response.data, alias: command.as },
|
|
911
|
+
};
|
|
912
|
+
}
|
|
913
|
+
case "exec":
|
|
914
|
+
return this.exec(batchString(command.sql, "sql"), {
|
|
915
|
+
params: command.params ?? [],
|
|
916
|
+
replay: command.replay ?? false,
|
|
917
|
+
idempotencyKey: command.idempotency_key,
|
|
918
|
+
allowUnbounded: command.allow_unbounded ?? false,
|
|
919
|
+
allowDestructive: command.allow_destructive ?? false,
|
|
920
|
+
});
|
|
921
|
+
case "show":
|
|
922
|
+
return this.show(batchString(command.handle, "handle"));
|
|
923
|
+
case "rows":
|
|
924
|
+
return this.rows(batchString(command.handle, "handle"), {
|
|
925
|
+
offset: command.offset ?? 0,
|
|
926
|
+
limit: command.limit ?? 20,
|
|
927
|
+
});
|
|
928
|
+
case "count":
|
|
929
|
+
return this.count(batchString(command.handle, "handle"));
|
|
930
|
+
case "columns":
|
|
931
|
+
return this.columns(batchString(command.handle, "handle"));
|
|
932
|
+
case "alias.set":
|
|
933
|
+
return this.setAlias(batchString(command.name, "name"), batchString(command.handle, "handle"));
|
|
934
|
+
case "inspect":
|
|
935
|
+
return this.inspect(batchString(command.kind, "kind"), command.table);
|
|
936
|
+
case "transaction.begin":
|
|
937
|
+
return this.beginTransaction(command.isolation);
|
|
938
|
+
case "transaction.status":
|
|
939
|
+
return this.transactionStatus(command.handle);
|
|
940
|
+
case "transaction.commit":
|
|
941
|
+
return this.commitTransaction(command.handle);
|
|
942
|
+
case "transaction.rollback":
|
|
943
|
+
return this.rollbackTransaction(command.handle);
|
|
944
|
+
case "plan":
|
|
945
|
+
return this.plan(batchString(command.sql, "sql"), {
|
|
946
|
+
params: command.params ?? [],
|
|
947
|
+
allowUnbounded: command.allow_unbounded ?? false,
|
|
948
|
+
allowDestructive: command.allow_destructive,
|
|
949
|
+
});
|
|
950
|
+
case "apply":
|
|
951
|
+
return this.apply(batchString(command.handle, "handle"));
|
|
952
|
+
case "history":
|
|
953
|
+
return this.history(command.limit ?? 20);
|
|
954
|
+
case "receipt":
|
|
955
|
+
return this.receipt(batchString(command.handle, "handle"));
|
|
956
|
+
case "capabilities":
|
|
957
|
+
return this.capabilities();
|
|
958
|
+
default:
|
|
959
|
+
return this.batchFailure(`Unknown batch command "${String(command.command)}".`);
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
catch (error) {
|
|
963
|
+
return this.batchFailure(errorMessage(error));
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
async *batch(commands, options = {}) {
|
|
967
|
+
const maxCommands = options.maxCommands ?? 1_000;
|
|
968
|
+
if (!Number.isInteger(maxCommands) || maxCommands < 1) {
|
|
969
|
+
yield await this.batchFailure("maxCommands must be a positive integer.");
|
|
970
|
+
return;
|
|
971
|
+
}
|
|
972
|
+
let count = 0;
|
|
973
|
+
for await (const command of commands) {
|
|
974
|
+
count += 1;
|
|
975
|
+
if (count > maxCommands) {
|
|
976
|
+
yield await this.batchFailure(`Batch cannot exceed ${maxCommands} commands.`);
|
|
977
|
+
return;
|
|
978
|
+
}
|
|
979
|
+
const response = await this.executeCommand(command);
|
|
980
|
+
yield response;
|
|
981
|
+
if (!response.ok && !options.continueOnError)
|
|
982
|
+
return;
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
async performExec(session, connection, sql, options) {
|
|
986
|
+
if (connection.read_only) {
|
|
987
|
+
throw new StateQLError("READ_ONLY_CONNECTION", "Connection is read-only.", { suggestedAction: "Reconnect with --read-write." });
|
|
988
|
+
}
|
|
989
|
+
const analysis = analyzeSql(sql, connection.driver);
|
|
990
|
+
if (analysis.read) {
|
|
991
|
+
throw new StateQLError("INVALID_SQL", "exec accepts write statements only; use query for reads.");
|
|
992
|
+
}
|
|
993
|
+
if (analysis.unboundedMutation && !options.allowUnbounded) {
|
|
994
|
+
throw new StateQLError("UNBOUNDED_MUTATION", "Mutation has no WHERE clause.", { extra: { override_flag: "--allow-unbounded" } });
|
|
995
|
+
}
|
|
996
|
+
if (analysis.destructive && !options.allowDestructive) {
|
|
997
|
+
throw new StateQLError("DESTRUCTIVE_OPERATION_BLOCKED", "Destructive operation requires an explicit override.", { extra: { override_flag: "--allow-destructive" } });
|
|
998
|
+
}
|
|
999
|
+
const parameters = options.params ?? [];
|
|
1000
|
+
const fingerprint = hash({
|
|
1001
|
+
sql: analysis.normalized,
|
|
1002
|
+
parameters,
|
|
1003
|
+
database: databaseIdentity(connection),
|
|
1004
|
+
});
|
|
1005
|
+
const transactionId = session.active_transaction_id ?? undefined;
|
|
1006
|
+
if (transactionId) {
|
|
1007
|
+
const transaction = this.store.getTransaction(transactionId);
|
|
1008
|
+
if (!transaction ||
|
|
1009
|
+
transaction.session_id !== session.id ||
|
|
1010
|
+
transaction.state !== "active" ||
|
|
1011
|
+
transaction.connection_id !== connection.id) {
|
|
1012
|
+
throw new StateQLError("TRANSACTION_FAILED", "Active transaction does not match the active connection.");
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
const reservation = this.store.reserveOperation({
|
|
1016
|
+
sessionId: session.id,
|
|
1017
|
+
connectionId: connection.id,
|
|
1018
|
+
fingerprint,
|
|
1019
|
+
sql,
|
|
1020
|
+
parameters,
|
|
1021
|
+
statementType: analysis.statementType,
|
|
1022
|
+
status: transactionId ? "pending" : "executing",
|
|
1023
|
+
transactionId,
|
|
1024
|
+
replay: options.replay ?? false,
|
|
1025
|
+
idempotencyKey: options.idempotencyKey,
|
|
1026
|
+
stateVersionBefore: version(connection),
|
|
1027
|
+
});
|
|
1028
|
+
const previous = reservation.previous;
|
|
1029
|
+
if (previous && !reservation.operation) {
|
|
1030
|
+
if (previous.status === "executing" ||
|
|
1031
|
+
previous.status === "outcome_unknown") {
|
|
1032
|
+
throw new StateQLError("OUTCOME_UNKNOWN", "A matching write has an unknown outcome.", {
|
|
1033
|
+
executed: true,
|
|
1034
|
+
suggestedAction: "Inspect database state, then use --replay only if another execution is safe.",
|
|
1035
|
+
extra: { previous_operation_id: previous.id },
|
|
1036
|
+
});
|
|
1037
|
+
}
|
|
1038
|
+
if (options.idempotencyKey) {
|
|
1039
|
+
return {
|
|
1040
|
+
data: {
|
|
1041
|
+
...operationData(previous),
|
|
1042
|
+
duplicate: true,
|
|
1043
|
+
duplicate_of: previous.id,
|
|
1044
|
+
idempotency_key: options.idempotencyKey,
|
|
1045
|
+
},
|
|
1046
|
+
handle: previous.id,
|
|
1047
|
+
cached: true,
|
|
1048
|
+
stateVersion: previous.state_version_after ?? previous.state_version_before,
|
|
1049
|
+
};
|
|
1050
|
+
}
|
|
1051
|
+
throw new StateQLError("POTENTIAL_DUPLICATE_WRITE", "An equivalent operation was previously applied.", {
|
|
1052
|
+
extra: {
|
|
1053
|
+
previous_operation_id: previous.id,
|
|
1054
|
+
replay_required: true,
|
|
1055
|
+
},
|
|
1056
|
+
});
|
|
1057
|
+
}
|
|
1058
|
+
const operation = reservation.operation;
|
|
1059
|
+
if (transactionId) {
|
|
1060
|
+
return {
|
|
1061
|
+
data: operationData(operation),
|
|
1062
|
+
handle: operation.id,
|
|
1063
|
+
executed: false,
|
|
1064
|
+
stateVersion: version(connection),
|
|
1065
|
+
};
|
|
1066
|
+
}
|
|
1067
|
+
let adapter;
|
|
1068
|
+
try {
|
|
1069
|
+
adapter = await createAdapter(connection);
|
|
1070
|
+
}
|
|
1071
|
+
catch (error) {
|
|
1072
|
+
this.store.failOperation(operation.id);
|
|
1073
|
+
throw new StateQLError("QUERY_FAILED", errorMessage(error), {
|
|
1074
|
+
retryable: true,
|
|
1075
|
+
});
|
|
1076
|
+
}
|
|
1077
|
+
try {
|
|
1078
|
+
const write = await adapter.write(sql, parameters);
|
|
1079
|
+
try {
|
|
1080
|
+
const after = this.store.bumpVersion(connection.id);
|
|
1081
|
+
const committed = this.store.finishOperation(operation.id, write.affectedRows, after);
|
|
1082
|
+
return {
|
|
1083
|
+
data: {
|
|
1084
|
+
...operationData(committed),
|
|
1085
|
+
duplicate: Boolean(previous),
|
|
1086
|
+
duplicate_override: Boolean(previous),
|
|
1087
|
+
},
|
|
1088
|
+
handle: committed.id,
|
|
1089
|
+
executed: true,
|
|
1090
|
+
stateVersion: after,
|
|
1091
|
+
confidence: adapter.confidence,
|
|
1092
|
+
};
|
|
1093
|
+
}
|
|
1094
|
+
catch (error) {
|
|
1095
|
+
this.store.markOperationOutcomeUnknown(operation.id);
|
|
1096
|
+
throw new StateQLError("OUTCOME_UNKNOWN", errorMessage(error), {
|
|
1097
|
+
executed: true,
|
|
1098
|
+
suggestedAction: "Inspect database state before issuing any replacement write.",
|
|
1099
|
+
});
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
catch (error) {
|
|
1103
|
+
if (error instanceof StateQLError)
|
|
1104
|
+
throw error;
|
|
1105
|
+
this.store.markOperationOutcomeUnknown(operation.id);
|
|
1106
|
+
throw new StateQLError("OUTCOME_UNKNOWN", errorMessage(error), {
|
|
1107
|
+
executed: true,
|
|
1108
|
+
suggestedAction: "Inspect database state, then use --replay only if another execution is safe.",
|
|
1109
|
+
});
|
|
1110
|
+
}
|
|
1111
|
+
finally {
|
|
1112
|
+
try {
|
|
1113
|
+
await adapter.close();
|
|
1114
|
+
}
|
|
1115
|
+
catch {
|
|
1116
|
+
// Write outcome and metadata are already recorded.
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
batchFailure(message) {
|
|
1121
|
+
return this.run("batch", async () => {
|
|
1122
|
+
throw new StateQLError("INVALID_COMMAND", message);
|
|
1123
|
+
});
|
|
1124
|
+
}
|
|
1125
|
+
async withResult(command, idOrAlias, action) {
|
|
1126
|
+
return this.run(command, async (session) => {
|
|
1127
|
+
const result = this.requireResult(idOrAlias, session);
|
|
1128
|
+
if (Date.parse(result.expires_at) <= this.now().getTime()) {
|
|
1129
|
+
throw new StateQLError("RESULT_EXPIRED", `Result "${result.id}" has expired.`, {
|
|
1130
|
+
retryable: true,
|
|
1131
|
+
suggestedAction: "Run the original query again.",
|
|
1132
|
+
});
|
|
1133
|
+
}
|
|
1134
|
+
return action(result, session);
|
|
1135
|
+
});
|
|
1136
|
+
}
|
|
1137
|
+
requireResult(idOrAlias, session) {
|
|
1138
|
+
const result = this.store.getResult(idOrAlias, session.id);
|
|
1139
|
+
if (!result) {
|
|
1140
|
+
throw new StateQLError("RESULT_NOT_FOUND", `Result "${idOrAlias}" was not found.`);
|
|
1141
|
+
}
|
|
1142
|
+
return result;
|
|
1143
|
+
}
|
|
1144
|
+
requireConnection(session) {
|
|
1145
|
+
const connection = this.store.activeConnection(session);
|
|
1146
|
+
if (!connection) {
|
|
1147
|
+
throw new StateQLError("CONNECTION_NOT_FOUND", "No active connection.", { suggestedAction: "Run stql connect first." });
|
|
1148
|
+
}
|
|
1149
|
+
return connection;
|
|
1150
|
+
}
|
|
1151
|
+
requireActiveTransaction(session, id) {
|
|
1152
|
+
const transactionId = id ?? session.active_transaction_id;
|
|
1153
|
+
if (!transactionId) {
|
|
1154
|
+
throw new StateQLError("TRANSACTION_NOT_FOUND", "No active transaction.");
|
|
1155
|
+
}
|
|
1156
|
+
const transaction = this.store.getTransaction(transactionId);
|
|
1157
|
+
if (!transaction ||
|
|
1158
|
+
transaction.session_id !== session.id ||
|
|
1159
|
+
transaction.state !== "active" ||
|
|
1160
|
+
session.active_transaction_id !== transaction.id) {
|
|
1161
|
+
throw new StateQLError("TRANSACTION_NOT_FOUND", `Active transaction "${transactionId}" was not found.`);
|
|
1162
|
+
}
|
|
1163
|
+
return transaction;
|
|
1164
|
+
}
|
|
1165
|
+
resultData(result, cached) {
|
|
1166
|
+
const rows = this.store.resultRows(result);
|
|
1167
|
+
const preview = compactRows(rows.slice(0, this.previewRows), this.maxCellCharacters);
|
|
1168
|
+
return {
|
|
1169
|
+
result_id: result.id,
|
|
1170
|
+
rows: result.row_count,
|
|
1171
|
+
columns: this.store.resultColumns(result),
|
|
1172
|
+
preview,
|
|
1173
|
+
preview_count: preview.length,
|
|
1174
|
+
truncated: preview.length < result.row_count,
|
|
1175
|
+
cached,
|
|
1176
|
+
...(cached ? { duplicate_of: result.id } : {}),
|
|
1177
|
+
state_version: result.state_version,
|
|
1178
|
+
storage: {
|
|
1179
|
+
mode: "materialized",
|
|
1180
|
+
expires_at: result.expires_at,
|
|
1181
|
+
},
|
|
1182
|
+
};
|
|
1183
|
+
}
|
|
1184
|
+
cacheValid(result, stateVersion, stateSignature) {
|
|
1185
|
+
return (Date.parse(result.expires_at) > this.now().getTime() &&
|
|
1186
|
+
Date.parse(result.created_at) + this.cacheTtlSeconds * 1000 >
|
|
1187
|
+
this.now().getTime() &&
|
|
1188
|
+
result.state_version === stateVersion &&
|
|
1189
|
+
result.state_signature === stateSignature);
|
|
1190
|
+
}
|
|
1191
|
+
async run(command, action) {
|
|
1192
|
+
const started = performance.now();
|
|
1193
|
+
let session = this.store.ensureSession(this.sessionName);
|
|
1194
|
+
const commandId = this.store.nextId("cmd");
|
|
1195
|
+
try {
|
|
1196
|
+
const result = await action(session);
|
|
1197
|
+
session = result.session ?? session;
|
|
1198
|
+
this.store.addHistory({
|
|
1199
|
+
id: commandId,
|
|
1200
|
+
sessionId: session.id,
|
|
1201
|
+
command,
|
|
1202
|
+
...(result.handle ? { handle: result.handle } : {}),
|
|
1203
|
+
executed: result.executed ?? false,
|
|
1204
|
+
cached: result.cached ?? false,
|
|
1205
|
+
success: true,
|
|
1206
|
+
});
|
|
1207
|
+
return {
|
|
1208
|
+
ok: true,
|
|
1209
|
+
command_id: commandId,
|
|
1210
|
+
session_id: session.id,
|
|
1211
|
+
data: result.data,
|
|
1212
|
+
warnings: result.warnings ?? [],
|
|
1213
|
+
meta: {
|
|
1214
|
+
duration_ms: Math.round((performance.now() - started) * 1000) / 1000,
|
|
1215
|
+
...(result.stateVersion
|
|
1216
|
+
? { state_version: result.stateVersion }
|
|
1217
|
+
: {}),
|
|
1218
|
+
...(result.confidence
|
|
1219
|
+
? { state_confidence: result.confidence }
|
|
1220
|
+
: {}),
|
|
1221
|
+
},
|
|
1222
|
+
};
|
|
1223
|
+
}
|
|
1224
|
+
catch (error) {
|
|
1225
|
+
const stateqlError = asStateQLError(error);
|
|
1226
|
+
this.store.addHistory({
|
|
1227
|
+
id: commandId,
|
|
1228
|
+
sessionId: session.id,
|
|
1229
|
+
command,
|
|
1230
|
+
executed: stateqlError.details.executed,
|
|
1231
|
+
cached: false,
|
|
1232
|
+
success: false,
|
|
1233
|
+
errorCode: stateqlError.details.code,
|
|
1234
|
+
});
|
|
1235
|
+
return {
|
|
1236
|
+
ok: false,
|
|
1237
|
+
command_id: commandId,
|
|
1238
|
+
session_id: session.id,
|
|
1239
|
+
error: stateqlError.details,
|
|
1240
|
+
meta: {
|
|
1241
|
+
duration_ms: Math.round((performance.now() - started) * 1000) / 1000,
|
|
1242
|
+
},
|
|
1243
|
+
};
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
function prepareFilterStatement(columns, predicate) {
|
|
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);
|
|
1293
|
+
return {
|
|
1294
|
+
sql,
|
|
1295
|
+
normalized: analysis.normalized,
|
|
1296
|
+
tableName,
|
|
1297
|
+
indexColumn,
|
|
1298
|
+
columnNames,
|
|
1299
|
+
positionalParameters: bindings.positional,
|
|
1300
|
+
namedParameters: [...bindings.named],
|
|
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);
|
|
1466
|
+
};
|
|
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
|
+
}
|
|
1497
|
+
function markTransactionOutcomeUnknown(store, transactionId, sessionId) {
|
|
1498
|
+
try {
|
|
1499
|
+
store.markTransactionOutcomeUnknown(transactionId, sessionId);
|
|
1500
|
+
}
|
|
1501
|
+
catch {
|
|
1502
|
+
// A stale committing transaction is recovered as unknown after five minutes.
|
|
1503
|
+
}
|
|
1504
|
+
}
|
|
1505
|
+
function boundedReadSql(sql, limit) {
|
|
1506
|
+
const statement = sql.trim().replace(/;\s*$/, "");
|
|
1507
|
+
return `SELECT * FROM (${statement}) AS _stateql_bounded LIMIT ${limit}`;
|
|
1508
|
+
}
|
|
1509
|
+
function normalizeIsolation(isolation, driver) {
|
|
1510
|
+
const normalized = isolation.trim().toLowerCase().replace(/[_-]+/g, " ")
|
|
1511
|
+
.replace(/\s+/g, " ");
|
|
1512
|
+
const supported = new Set([
|
|
1513
|
+
"serializable",
|
|
1514
|
+
"repeatable read",
|
|
1515
|
+
"read committed",
|
|
1516
|
+
"read uncommitted",
|
|
1517
|
+
]);
|
|
1518
|
+
if (!supported.has(normalized)) {
|
|
1519
|
+
throw new StateQLError("INVALID_COMMAND", `Unsupported isolation level "${isolation}".`);
|
|
1520
|
+
}
|
|
1521
|
+
if (driver === "sqlite" && normalized !== "serializable") {
|
|
1522
|
+
throw new StateQLError("INVALID_COMMAND", `SQLite does not support isolation level "${normalized}".`);
|
|
1523
|
+
}
|
|
1524
|
+
return normalized;
|
|
1525
|
+
}
|
|
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
|
+
function batchString(value, name) {
|
|
1625
|
+
if (value?.trim())
|
|
1626
|
+
return value;
|
|
1627
|
+
throw new StateQLError("INVALID_COMMAND", `Batch command requires "${name}".`);
|
|
1628
|
+
}
|
|
1629
|
+
function nonNegativeInteger(value, name) {
|
|
1630
|
+
if (Number.isInteger(value) && value >= 0)
|
|
1631
|
+
return value;
|
|
1632
|
+
throw new StateQLError("INVALID_COMMAND", `${name} must be a non-negative integer.`);
|
|
1633
|
+
}
|
|
1634
|
+
function positiveInteger(value, name) {
|
|
1635
|
+
if (Number.isInteger(value) && value > 0)
|
|
1636
|
+
return value;
|
|
1637
|
+
throw new StateQLError("INVALID_COMMAND", `${name} must be a positive integer.`);
|
|
1638
|
+
}
|
|
1639
|
+
function rowsToCsv(rows, columns) {
|
|
1640
|
+
const encode = (value) => {
|
|
1641
|
+
const text = value === null || value === undefined
|
|
1642
|
+
? ""
|
|
1643
|
+
: typeof value === "object"
|
|
1644
|
+
? JSON.stringify(value)
|
|
1645
|
+
: String(value);
|
|
1646
|
+
return /[",\r\n]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text;
|
|
1647
|
+
};
|
|
1648
|
+
return [
|
|
1649
|
+
columns.map(encode).join(","),
|
|
1650
|
+
...rows.map((row) => columns.map((column) => encode(row[column])).join(",")),
|
|
1651
|
+
].join("\n") + "\n";
|
|
1652
|
+
}
|
|
1653
|
+
function errorMessage(error) {
|
|
1654
|
+
return error instanceof Error ? error.message : String(error);
|
|
1655
|
+
}
|