@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.
@@ -0,0 +1,622 @@
1
+ import { mkdirSync } from "node:fs";
2
+ import { DatabaseSync } from "node:sqlite";
3
+ import { join } from "node:path";
4
+ import { parseJson, toJsonSafe } from "./util.js";
5
+ const HISTORY_LIMIT_PER_SESSION = 10_000;
6
+ export class StateStore {
7
+ now;
8
+ db;
9
+ constructor(home, now) {
10
+ this.now = now;
11
+ const path = join(home, "state.sqlite");
12
+ mkdirSync(home, { recursive: true });
13
+ this.db = new DatabaseSync(path);
14
+ this.db.exec("PRAGMA journal_mode = WAL");
15
+ this.db.exec("PRAGMA busy_timeout = 5000");
16
+ this.db.exec("PRAGMA foreign_keys = ON");
17
+ this.migrate();
18
+ this.recoverStaleCommittingTransactions();
19
+ }
20
+ close() {
21
+ this.db.close();
22
+ }
23
+ nextId(prefix) {
24
+ this.db
25
+ .prepare("INSERT OR IGNORE INTO counters(prefix, value) VALUES (?, 0)")
26
+ .run(prefix);
27
+ const row = this.db
28
+ .prepare("UPDATE counters SET value = value + 1 WHERE prefix = ? RETURNING value")
29
+ .get(prefix);
30
+ return `${prefix}_${row.value}`;
31
+ }
32
+ ensureSession(name = "default") {
33
+ const existing = this.getSession(name);
34
+ if (existing)
35
+ return existing;
36
+ return this.createSession(name);
37
+ }
38
+ createSession(name) {
39
+ const timestamp = this.now().toISOString();
40
+ const id = this.nextId("s");
41
+ this.db
42
+ .prepare(`INSERT INTO sessions
43
+ (id, name, status, created_at, updated_at)
44
+ VALUES (?, ?, 'active', ?, ?)`)
45
+ .run(id, name, timestamp, timestamp);
46
+ return this.getSession(id);
47
+ }
48
+ getSession(idOrName) {
49
+ return this.db
50
+ .prepare(`SELECT * FROM sessions
51
+ WHERE (id = ? OR name = ?) AND status = 'active'
52
+ LIMIT 1`)
53
+ .get(idOrName, idOrName);
54
+ }
55
+ listSessions() {
56
+ return this.db
57
+ .prepare("SELECT * FROM sessions ORDER BY created_at")
58
+ .all();
59
+ }
60
+ closeSession(sessionId) {
61
+ const timestamp = this.now().toISOString();
62
+ this.db
63
+ .prepare(`UPDATE sessions
64
+ SET status = 'closed', active_transaction_id = NULL, updated_at = ?
65
+ WHERE id = ?`)
66
+ .run(timestamp, sessionId);
67
+ }
68
+ addProfile(input) {
69
+ const timestamp = this.now().toISOString();
70
+ this.db
71
+ .prepare(`INSERT INTO profiles
72
+ (name, target, secret_env, read_only, created_at, updated_at)
73
+ VALUES (?, ?, ?, ?, ?, ?)`)
74
+ .run(input.name, input.target ?? null, input.secretEnv ?? null, input.readOnly ? 1 : 0, timestamp, timestamp);
75
+ return this.getProfile(input.name);
76
+ }
77
+ getProfile(name) {
78
+ return this.db
79
+ .prepare("SELECT * FROM profiles WHERE name = ?")
80
+ .get(name);
81
+ }
82
+ listProfiles() {
83
+ return this.db
84
+ .prepare("SELECT * FROM profiles ORDER BY name")
85
+ .all();
86
+ }
87
+ removeProfile(name) {
88
+ const result = this.db
89
+ .prepare("DELETE FROM profiles WHERE name = ?")
90
+ .run(name);
91
+ return Number(result.changes) > 0;
92
+ }
93
+ addConnection(input) {
94
+ const id = this.nextId("conn");
95
+ this.db
96
+ .prepare(`INSERT INTO connections
97
+ (id, session_id, name, driver, database_name, source, secret_env,
98
+ read_only, version, created_at)
99
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?)`)
100
+ .run(id, input.sessionId, input.name, input.driver, input.databaseName, input.source, input.secretEnv ?? null, input.readOnly ? 1 : 0, this.now().toISOString());
101
+ this.db
102
+ .prepare(`UPDATE sessions
103
+ SET active_connection_id = ?, updated_at = ?
104
+ WHERE id = ?`)
105
+ .run(id, this.now().toISOString(), input.sessionId);
106
+ return this.getConnection(id);
107
+ }
108
+ getConnection(id) {
109
+ return this.db
110
+ .prepare("SELECT * FROM connections WHERE id = ?")
111
+ .get(id);
112
+ }
113
+ activeConnection(session) {
114
+ if (!session.active_connection_id)
115
+ return undefined;
116
+ return this.getConnection(session.active_connection_id);
117
+ }
118
+ disconnect(sessionId) {
119
+ this.db
120
+ .prepare(`UPDATE sessions
121
+ SET active_connection_id = NULL, active_transaction_id = NULL,
122
+ updated_at = ?
123
+ WHERE id = ?`)
124
+ .run(this.now().toISOString(), sessionId);
125
+ }
126
+ bumpVersion(connectionId) {
127
+ const row = this.db
128
+ .prepare(`UPDATE connections
129
+ SET version = version + 1
130
+ WHERE id = ?
131
+ RETURNING version`)
132
+ .get(connectionId);
133
+ return `sv_${row.version}`;
134
+ }
135
+ saveResult(input) {
136
+ const id = this.nextId("q");
137
+ this.db
138
+ .prepare(`INSERT INTO results
139
+ (id, session_id, connection_id, fingerprint, sql, parameters,
140
+ rows_json, columns_json, row_count, state_version, state_signature,
141
+ state_confidence, expires_at, created_at)
142
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
143
+ .run(id, input.sessionId, input.connectionId, input.fingerprint, input.sql, JSON.stringify(toJsonSafe(input.parameters)), JSON.stringify(toJsonSafe(input.rows)), JSON.stringify(input.columns), input.rows.length, input.stateVersion, input.stateSignature, input.stateConfidence, input.expiresAt, this.now().toISOString());
144
+ return this.getResult(id);
145
+ }
146
+ findResult(fingerprint) {
147
+ return this.db
148
+ .prepare(`SELECT * FROM results
149
+ WHERE fingerprint = ?
150
+ ORDER BY created_at DESC
151
+ LIMIT 1`)
152
+ .get(fingerprint);
153
+ }
154
+ getResult(idOrAlias, sessionId) {
155
+ return this.db
156
+ .prepare(`SELECT results.*
157
+ FROM results
158
+ LEFT JOIN aliases
159
+ ON aliases.result_id = results.id
160
+ AND aliases.session_id = results.session_id
161
+ WHERE (results.id = ? OR aliases.name = ?)
162
+ AND (? IS NULL OR results.session_id = ?)
163
+ LIMIT 1`)
164
+ .get(idOrAlias, idOrAlias, sessionId ?? null, sessionId ?? null);
165
+ }
166
+ resultRows(result) {
167
+ return parseJson(result.rows_json, []);
168
+ }
169
+ resultColumns(result) {
170
+ return parseJson(result.columns_json, []);
171
+ }
172
+ setAlias(sessionId, name, resultId) {
173
+ this.db
174
+ .prepare(`INSERT INTO aliases(session_id, name, result_id)
175
+ VALUES (?, ?, ?)
176
+ ON CONFLICT(session_id, name) DO UPDATE SET result_id = excluded.result_id`)
177
+ .run(sessionId, name, resultId);
178
+ }
179
+ saveOperation(input) {
180
+ const id = this.nextId("op");
181
+ this.db
182
+ .prepare(`INSERT INTO operations
183
+ (id, session_id, connection_id, fingerprint, sql, parameters,
184
+ statement_type, affected_rows, status, transaction_id, replay_of,
185
+ idempotency_key, state_version_before, state_version_after, created_at)
186
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
187
+ .run(id, input.sessionId, input.connectionId, input.fingerprint, input.sql, JSON.stringify(toJsonSafe(input.parameters)), input.statementType, input.affectedRows ?? null, input.status, input.transactionId ?? null, input.replayOf ?? null, input.idempotencyKey ?? null, input.stateVersionBefore, input.stateVersionAfter ?? null, this.now().toISOString());
188
+ return this.getOperation(id);
189
+ }
190
+ getOperation(id) {
191
+ return this.db
192
+ .prepare("SELECT * FROM operations WHERE id = ?")
193
+ .get(id);
194
+ }
195
+ findDuplicateOperation(input) {
196
+ if (input.idempotencyKey) {
197
+ return this.db
198
+ .prepare(`SELECT operations.*
199
+ FROM operations
200
+ JOIN connections previous_connection
201
+ ON previous_connection.id = operations.connection_id
202
+ JOIN connections current_connection
203
+ ON current_connection.id = ?
204
+ WHERE operations.idempotency_key = ?
205
+ AND operations.status IN
206
+ ('committed', 'pending', 'executing', 'outcome_unknown')
207
+ AND previous_connection.driver = current_connection.driver
208
+ AND previous_connection.source = current_connection.source
209
+ AND COALESCE(previous_connection.secret_env, '') =
210
+ COALESCE(current_connection.secret_env, '')
211
+ AND previous_connection.database_name = current_connection.database_name
212
+ ORDER BY operations.created_at DESC LIMIT 1`)
213
+ .get(input.connectionId, input.idempotencyKey);
214
+ }
215
+ return this.db
216
+ .prepare(`SELECT * FROM operations
217
+ WHERE fingerprint = ?
218
+ AND status IN
219
+ ('committed', 'pending', 'executing', 'outcome_unknown')
220
+ ORDER BY created_at DESC LIMIT 1`)
221
+ .get(input.fingerprint);
222
+ }
223
+ reserveOperation(input) {
224
+ this.db.exec("BEGIN IMMEDIATE");
225
+ try {
226
+ const previous = this.findDuplicateOperation({
227
+ connectionId: input.connectionId,
228
+ fingerprint: input.fingerprint,
229
+ idempotencyKey: input.idempotencyKey,
230
+ });
231
+ if (previous && !input.replay) {
232
+ this.db.exec("COMMIT");
233
+ return { previous };
234
+ }
235
+ const operation = this.saveOperation({
236
+ sessionId: input.sessionId,
237
+ connectionId: input.connectionId,
238
+ fingerprint: input.fingerprint,
239
+ sql: input.sql,
240
+ parameters: input.parameters,
241
+ statementType: input.statementType,
242
+ status: input.status,
243
+ transactionId: input.transactionId,
244
+ replayOf: previous?.id,
245
+ idempotencyKey: input.replay ? undefined : input.idempotencyKey,
246
+ stateVersionBefore: input.stateVersionBefore,
247
+ });
248
+ this.db.exec("COMMIT");
249
+ return { operation, previous };
250
+ }
251
+ catch (error) {
252
+ this.db.exec("ROLLBACK");
253
+ throw error;
254
+ }
255
+ }
256
+ finishOperation(operationId, affectedRows, stateVersion) {
257
+ this.db
258
+ .prepare(`UPDATE operations
259
+ SET status = 'committed', affected_rows = ?, state_version_after = ?
260
+ WHERE id = ?`)
261
+ .run(affectedRows, stateVersion, operationId);
262
+ return this.getOperation(operationId);
263
+ }
264
+ failOperation(operationId) {
265
+ this.db
266
+ .prepare("UPDATE operations SET status = 'failed' WHERE id = ?")
267
+ .run(operationId);
268
+ }
269
+ markOperationOutcomeUnknown(operationId) {
270
+ this.db
271
+ .prepare("UPDATE operations SET status = 'outcome_unknown' WHERE id = ?")
272
+ .run(operationId);
273
+ }
274
+ createTransaction(input) {
275
+ const id = this.nextId("tx");
276
+ const timestamp = this.now().toISOString();
277
+ this.db
278
+ .prepare(`INSERT INTO transactions
279
+ (id, session_id, connection_id, state, isolation_level,
280
+ start_version, created_at)
281
+ VALUES (?, ?, ?, 'active', ?, ?, ?)`)
282
+ .run(id, input.sessionId, input.connectionId, input.isolation, input.startVersion, timestamp);
283
+ this.db
284
+ .prepare(`UPDATE sessions
285
+ SET active_transaction_id = ?, updated_at = ?
286
+ WHERE id = ?`)
287
+ .run(id, timestamp, input.sessionId);
288
+ return this.getTransaction(id);
289
+ }
290
+ getTransaction(id) {
291
+ return this.db
292
+ .prepare("SELECT * FROM transactions WHERE id = ?")
293
+ .get(id);
294
+ }
295
+ transactionOperations(transactionId) {
296
+ return this.db
297
+ .prepare("SELECT * FROM operations WHERE transaction_id = ? ORDER BY created_at")
298
+ .all(transactionId);
299
+ }
300
+ markTransactionCommitting(transactionId) {
301
+ const result = this.db
302
+ .prepare(`UPDATE transactions
303
+ SET state = 'committing', ended_at = ?
304
+ WHERE id = ? AND state = 'active'`)
305
+ .run(this.now().toISOString(), transactionId);
306
+ return Number(result.changes) === 1;
307
+ }
308
+ markTransactionOutcomeUnknown(transactionId, sessionId) {
309
+ const timestamp = this.now().toISOString();
310
+ this.db.exec("BEGIN IMMEDIATE");
311
+ try {
312
+ this.db
313
+ .prepare(`UPDATE transactions
314
+ SET state = 'outcome_unknown', ended_at = ?
315
+ WHERE id = ?`)
316
+ .run(timestamp, transactionId);
317
+ this.db
318
+ .prepare(`UPDATE operations SET status = 'outcome_unknown'
319
+ WHERE transaction_id = ? AND status = 'pending'`)
320
+ .run(transactionId);
321
+ this.db
322
+ .prepare(`UPDATE sessions
323
+ SET active_transaction_id = NULL, updated_at = ?
324
+ WHERE id = ?`)
325
+ .run(timestamp, sessionId);
326
+ this.db.exec("COMMIT");
327
+ }
328
+ catch (error) {
329
+ this.db.exec("ROLLBACK");
330
+ throw error;
331
+ }
332
+ }
333
+ finishTransaction(transactionId, sessionId, state) {
334
+ const timestamp = this.now().toISOString();
335
+ this.db.exec("BEGIN IMMEDIATE");
336
+ try {
337
+ this.db
338
+ .prepare("UPDATE transactions SET state = ?, ended_at = ? WHERE id = ?")
339
+ .run(state, timestamp, transactionId);
340
+ this.db
341
+ .prepare(`UPDATE sessions
342
+ SET active_transaction_id = NULL, updated_at = ?
343
+ WHERE id = ?`)
344
+ .run(timestamp, sessionId);
345
+ if (state === "rolled_back" || state === "failed") {
346
+ this.db
347
+ .prepare(`UPDATE operations SET status = ?
348
+ WHERE transaction_id = ? AND status = 'pending'`)
349
+ .run(state, transactionId);
350
+ }
351
+ this.db.exec("COMMIT");
352
+ }
353
+ catch (error) {
354
+ this.db.exec("ROLLBACK");
355
+ throw error;
356
+ }
357
+ }
358
+ commitTransactionMetadata(input) {
359
+ const timestamp = this.now().toISOString();
360
+ this.db.exec("BEGIN IMMEDIATE");
361
+ try {
362
+ let stateVersion = `sv_${(this.getConnection(input.connectionId)?.version ?? 0)}`;
363
+ for (const operation of input.operations) {
364
+ const row = this.db
365
+ .prepare(`UPDATE connections
366
+ SET version = version + 1
367
+ WHERE id = ?
368
+ RETURNING version`)
369
+ .get(input.connectionId);
370
+ stateVersion = `sv_${row.version}`;
371
+ this.db
372
+ .prepare(`UPDATE operations
373
+ SET status = 'committed', affected_rows = ?, state_version_after = ?
374
+ WHERE id = ?`)
375
+ .run(operation.affectedRows, stateVersion, operation.id);
376
+ }
377
+ this.db
378
+ .prepare(`UPDATE transactions
379
+ SET state = 'committed', ended_at = ?
380
+ WHERE id = ?`)
381
+ .run(timestamp, input.transactionId);
382
+ this.db
383
+ .prepare(`UPDATE sessions
384
+ SET active_transaction_id = NULL, updated_at = ?
385
+ WHERE id = ?`)
386
+ .run(timestamp, input.sessionId);
387
+ this.db.exec("COMMIT");
388
+ return stateVersion;
389
+ }
390
+ catch (error) {
391
+ this.db.exec("ROLLBACK");
392
+ throw error;
393
+ }
394
+ }
395
+ savePlan(input) {
396
+ const id = this.nextId("p");
397
+ this.db
398
+ .prepare(`INSERT INTO plans
399
+ (id, session_id, connection_id, sql, parameters, statement_type,
400
+ state_version, state_signature, destructive, allow_unbounded,
401
+ allow_destructive, expires_at, created_at)
402
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
403
+ .run(id, input.sessionId, input.connectionId, input.sql, JSON.stringify(toJsonSafe(input.parameters)), input.statementType, input.stateVersion, input.stateSignature, input.destructive ? 1 : 0, input.allowUnbounded ? 1 : 0, input.allowDestructive ? 1 : 0, input.expiresAt, this.now().toISOString());
404
+ return this.getPlan(id);
405
+ }
406
+ getPlan(id) {
407
+ return this.db
408
+ .prepare("SELECT * FROM plans WHERE id = ?")
409
+ .get(id);
410
+ }
411
+ markPlanApplied(planId, operationId) {
412
+ this.db
413
+ .prepare("UPDATE plans SET applied_operation_id = ? WHERE id = ?")
414
+ .run(operationId, planId);
415
+ }
416
+ addHistory(input) {
417
+ const id = input.id ?? this.nextId("cmd");
418
+ this.db
419
+ .prepare(`INSERT INTO history
420
+ (id, timestamp, session_id, command, handle, executed, cached,
421
+ success, error_code)
422
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`)
423
+ .run(id, this.now().toISOString(), input.sessionId, input.command, input.handle ?? null, input.executed ? 1 : 0, input.cached ? 1 : 0, input.success ? 1 : 0, input.errorCode ?? null);
424
+ this.db
425
+ .prepare(`DELETE FROM history
426
+ WHERE rowid IN (
427
+ SELECT rowid FROM history
428
+ WHERE session_id = ?
429
+ ORDER BY rowid DESC
430
+ LIMIT -1 OFFSET ?
431
+ )`)
432
+ .run(input.sessionId, HISTORY_LIMIT_PER_SESSION);
433
+ return this.db
434
+ .prepare("SELECT * FROM history WHERE id = ?")
435
+ .get(id);
436
+ }
437
+ history(sessionId, limit) {
438
+ return this.db
439
+ .prepare(`SELECT * FROM history
440
+ WHERE session_id = ?
441
+ ORDER BY rowid DESC
442
+ LIMIT ?`)
443
+ .all(sessionId, limit);
444
+ }
445
+ recentOperations(sessionId, limit) {
446
+ return this.db
447
+ .prepare(`SELECT * FROM operations
448
+ WHERE session_id = ?
449
+ ORDER BY created_at DESC
450
+ LIMIT ?`)
451
+ .all(sessionId, limit);
452
+ }
453
+ knownResults(sessionId, limit) {
454
+ return this.db
455
+ .prepare(`SELECT results.*, aliases.name AS alias
456
+ FROM results
457
+ LEFT JOIN aliases
458
+ ON aliases.result_id = results.id
459
+ AND aliases.session_id = results.session_id
460
+ WHERE results.session_id = ?
461
+ ORDER BY results.created_at DESC
462
+ LIMIT ?`)
463
+ .all(sessionId, limit);
464
+ }
465
+ recoverStaleCommittingTransactions() {
466
+ const timestamp = this.now().toISOString();
467
+ const cutoff = new Date(this.now().getTime() - 5 * 60_000).toISOString();
468
+ this.db.exec("BEGIN IMMEDIATE");
469
+ try {
470
+ this.db
471
+ .prepare(`UPDATE operations SET status = 'outcome_unknown'
472
+ WHERE status = 'pending' AND transaction_id IN (
473
+ SELECT id FROM transactions
474
+ WHERE state = 'committing' AND ended_at <= ?
475
+ )`)
476
+ .run(cutoff);
477
+ this.db
478
+ .prepare(`UPDATE sessions
479
+ SET active_transaction_id = NULL, updated_at = ?
480
+ WHERE active_transaction_id IN (
481
+ SELECT id FROM transactions
482
+ WHERE state = 'committing' AND ended_at <= ?
483
+ )`)
484
+ .run(timestamp, cutoff);
485
+ this.db
486
+ .prepare(`UPDATE transactions
487
+ SET state = 'outcome_unknown', ended_at = ?
488
+ WHERE state = 'committing' AND ended_at <= ?`)
489
+ .run(timestamp, cutoff);
490
+ this.db.exec("COMMIT");
491
+ }
492
+ catch (error) {
493
+ this.db.exec("ROLLBACK");
494
+ throw error;
495
+ }
496
+ }
497
+ migrate() {
498
+ this.db.exec(`
499
+ CREATE TABLE IF NOT EXISTS counters (
500
+ prefix TEXT PRIMARY KEY,
501
+ value INTEGER NOT NULL
502
+ );
503
+ CREATE TABLE IF NOT EXISTS sessions (
504
+ id TEXT PRIMARY KEY,
505
+ name TEXT NOT NULL UNIQUE,
506
+ status TEXT NOT NULL,
507
+ active_connection_id TEXT,
508
+ active_transaction_id TEXT,
509
+ created_at TEXT NOT NULL,
510
+ updated_at TEXT NOT NULL
511
+ );
512
+ CREATE TABLE IF NOT EXISTS profiles (
513
+ name TEXT PRIMARY KEY,
514
+ target TEXT,
515
+ secret_env TEXT,
516
+ read_only INTEGER NOT NULL,
517
+ created_at TEXT NOT NULL,
518
+ updated_at TEXT NOT NULL,
519
+ CHECK(target IS NOT NULL OR secret_env IS NOT NULL)
520
+ );
521
+ CREATE TABLE IF NOT EXISTS connections (
522
+ id TEXT PRIMARY KEY,
523
+ session_id TEXT NOT NULL,
524
+ name TEXT NOT NULL,
525
+ driver TEXT NOT NULL,
526
+ database_name TEXT NOT NULL,
527
+ source TEXT NOT NULL,
528
+ secret_env TEXT,
529
+ read_only INTEGER NOT NULL,
530
+ version INTEGER NOT NULL,
531
+ created_at TEXT NOT NULL,
532
+ FOREIGN KEY(session_id) REFERENCES sessions(id)
533
+ );
534
+ CREATE TABLE IF NOT EXISTS results (
535
+ id TEXT PRIMARY KEY,
536
+ session_id TEXT NOT NULL,
537
+ connection_id TEXT NOT NULL,
538
+ fingerprint TEXT NOT NULL,
539
+ sql TEXT NOT NULL,
540
+ parameters TEXT NOT NULL,
541
+ rows_json TEXT NOT NULL,
542
+ columns_json TEXT NOT NULL,
543
+ row_count INTEGER NOT NULL,
544
+ state_version TEXT NOT NULL,
545
+ state_signature TEXT NOT NULL,
546
+ state_confidence TEXT NOT NULL,
547
+ expires_at TEXT NOT NULL,
548
+ created_at TEXT NOT NULL
549
+ );
550
+ CREATE INDEX IF NOT EXISTS results_fingerprint
551
+ ON results(fingerprint, created_at);
552
+ CREATE TABLE IF NOT EXISTS aliases (
553
+ session_id TEXT NOT NULL,
554
+ name TEXT NOT NULL,
555
+ result_id TEXT NOT NULL,
556
+ PRIMARY KEY(session_id, name),
557
+ FOREIGN KEY(result_id) REFERENCES results(id)
558
+ );
559
+ CREATE TABLE IF NOT EXISTS operations (
560
+ id TEXT PRIMARY KEY,
561
+ session_id TEXT NOT NULL,
562
+ connection_id TEXT NOT NULL,
563
+ fingerprint TEXT NOT NULL,
564
+ sql TEXT NOT NULL,
565
+ parameters TEXT NOT NULL,
566
+ statement_type TEXT NOT NULL,
567
+ affected_rows INTEGER,
568
+ status TEXT NOT NULL,
569
+ transaction_id TEXT,
570
+ replay_of TEXT,
571
+ idempotency_key TEXT,
572
+ state_version_before TEXT NOT NULL,
573
+ state_version_after TEXT,
574
+ created_at TEXT NOT NULL
575
+ );
576
+ CREATE INDEX IF NOT EXISTS operations_fingerprint
577
+ ON operations(connection_id, fingerprint, status);
578
+ CREATE UNIQUE INDEX IF NOT EXISTS operations_idempotency
579
+ ON operations(connection_id, idempotency_key)
580
+ WHERE idempotency_key IS NOT NULL AND status IN ('committed', 'pending');
581
+ CREATE TABLE IF NOT EXISTS transactions (
582
+ id TEXT PRIMARY KEY,
583
+ session_id TEXT NOT NULL,
584
+ connection_id TEXT NOT NULL,
585
+ state TEXT NOT NULL,
586
+ isolation_level TEXT NOT NULL,
587
+ start_version TEXT NOT NULL,
588
+ created_at TEXT NOT NULL,
589
+ ended_at TEXT
590
+ );
591
+ CREATE TABLE IF NOT EXISTS plans (
592
+ id TEXT PRIMARY KEY,
593
+ session_id TEXT NOT NULL,
594
+ connection_id TEXT NOT NULL,
595
+ sql TEXT NOT NULL,
596
+ parameters TEXT NOT NULL,
597
+ statement_type TEXT NOT NULL,
598
+ state_version TEXT NOT NULL,
599
+ state_signature TEXT NOT NULL,
600
+ destructive INTEGER NOT NULL,
601
+ allow_unbounded INTEGER NOT NULL,
602
+ allow_destructive INTEGER NOT NULL,
603
+ expires_at TEXT NOT NULL,
604
+ applied_operation_id TEXT,
605
+ created_at TEXT NOT NULL
606
+ );
607
+ CREATE TABLE IF NOT EXISTS history (
608
+ id TEXT PRIMARY KEY,
609
+ timestamp TEXT NOT NULL,
610
+ session_id TEXT NOT NULL,
611
+ command TEXT NOT NULL,
612
+ handle TEXT,
613
+ executed INTEGER NOT NULL,
614
+ cached INTEGER NOT NULL,
615
+ success INTEGER NOT NULL,
616
+ error_code TEXT
617
+ );
618
+ CREATE INDEX IF NOT EXISTS history_session
619
+ ON history(session_id);
620
+ `);
621
+ }
622
+ }