@fadhilp/stateql 0.2.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -1
- package/dist/src/cli.js +1 -0
- package/dist/src/response-data.js +2 -0
- package/dist/src/stateql.d.ts +8 -0
- package/dist/src/stateql.js +235 -45
- package/dist/src/store.d.ts +44 -9
- package/dist/src/store.js +437 -91
- package/dist/src/types.d.ts +5 -0
- package/package.json +1 -1
package/dist/src/store.js
CHANGED
|
@@ -41,20 +41,129 @@ export class StateStore {
|
|
|
41
41
|
return this.createSession(name);
|
|
42
42
|
this.db
|
|
43
43
|
.prepare(`UPDATE sessions
|
|
44
|
-
SET status = 'active',
|
|
44
|
+
SET status = 'active', updated_at = ?
|
|
45
45
|
WHERE id = ?`)
|
|
46
46
|
.run(this.now().toISOString(), closed.id);
|
|
47
47
|
return this.getSessionByName(name);
|
|
48
48
|
}
|
|
49
|
-
|
|
49
|
+
bootstrapSession(name, actorId, ensureLegacyMembership) {
|
|
50
50
|
const timestamp = this.now().toISOString();
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
51
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
52
|
+
try {
|
|
53
|
+
let row = this.db
|
|
54
|
+
.prepare("SELECT id, status FROM sessions WHERE name = ? LIMIT 1")
|
|
55
|
+
.get(name);
|
|
56
|
+
const created = !row;
|
|
57
|
+
if (!row) {
|
|
58
|
+
const id = this.nextId("s");
|
|
59
|
+
this.db
|
|
60
|
+
.prepare(`INSERT INTO sessions
|
|
61
|
+
(id, name, status, created_at, updated_at)
|
|
62
|
+
VALUES (?, ?, 'active', ?, ?)`)
|
|
63
|
+
.run(id, name, timestamp, timestamp);
|
|
64
|
+
row = { id, status: "active" };
|
|
65
|
+
}
|
|
66
|
+
else if (row.status !== "active") {
|
|
67
|
+
this.db
|
|
68
|
+
.prepare(`UPDATE sessions
|
|
69
|
+
SET status = 'active', updated_at = ?
|
|
70
|
+
WHERE id = ?`)
|
|
71
|
+
.run(timestamp, row.id);
|
|
72
|
+
}
|
|
73
|
+
if (created) {
|
|
74
|
+
this.db
|
|
75
|
+
.prepare(`INSERT INTO session_members(session_id, actor_id, attached_at)
|
|
76
|
+
VALUES (?, ?, ?)`)
|
|
77
|
+
.run(row.id, actorId, timestamp);
|
|
78
|
+
}
|
|
79
|
+
else if (ensureLegacyMembership) {
|
|
80
|
+
this.db
|
|
81
|
+
.prepare(`INSERT OR IGNORE INTO session_members
|
|
82
|
+
(session_id, actor_id, attached_at)
|
|
83
|
+
VALUES (?, ?, ?)`)
|
|
84
|
+
.run(row.id, actorId, timestamp);
|
|
85
|
+
}
|
|
86
|
+
this.db.exec("COMMIT");
|
|
87
|
+
return this.getSession(row.id);
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
this.db.exec("ROLLBACK");
|
|
91
|
+
throw error;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
createSession(name) {
|
|
95
|
+
return this.bootstrapSession(name, name, true);
|
|
96
|
+
}
|
|
97
|
+
isSessionMember(sessionId, actorId) {
|
|
98
|
+
return Boolean(this.db
|
|
99
|
+
.prepare(`SELECT 1 FROM session_members
|
|
100
|
+
WHERE session_id = ? AND actor_id = ?`)
|
|
101
|
+
.get(sessionId, actorId));
|
|
102
|
+
}
|
|
103
|
+
linkActor(sessionId, requestingActorId, actorId) {
|
|
104
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
105
|
+
try {
|
|
106
|
+
if (!this.isSessionMember(sessionId, requestingActorId)) {
|
|
107
|
+
this.db.exec("COMMIT");
|
|
108
|
+
return "denied";
|
|
109
|
+
}
|
|
110
|
+
const existing = this.resolveActor(actorId);
|
|
111
|
+
if (existing) {
|
|
112
|
+
this.db.exec("COMMIT");
|
|
113
|
+
return existing.id === sessionId ? "already_linked" : "actor_conflict";
|
|
114
|
+
}
|
|
115
|
+
this.db
|
|
116
|
+
.prepare(`INSERT INTO session_members(session_id, actor_id, attached_at)
|
|
117
|
+
VALUES (?, ?, ?)`)
|
|
118
|
+
.run(sessionId, actorId, this.now().toISOString());
|
|
119
|
+
this.db.exec("COMMIT");
|
|
120
|
+
return "linked";
|
|
121
|
+
}
|
|
122
|
+
catch (error) {
|
|
123
|
+
this.db.exec("ROLLBACK");
|
|
124
|
+
throw error;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
unlinkActor(sessionId, requestingActorId, actorId) {
|
|
128
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
129
|
+
try {
|
|
130
|
+
if (!this.isSessionMember(sessionId, requestingActorId)) {
|
|
131
|
+
this.db.exec("COMMIT");
|
|
132
|
+
return "denied";
|
|
133
|
+
}
|
|
134
|
+
const ownsTransaction = this.db
|
|
135
|
+
.prepare(`SELECT 1 FROM transactions
|
|
136
|
+
WHERE session_id = ? AND owner_actor_id = ?
|
|
137
|
+
AND state IN ('active', 'committing')
|
|
138
|
+
LIMIT 1`)
|
|
139
|
+
.get(sessionId, actorId);
|
|
140
|
+
if (ownsTransaction) {
|
|
141
|
+
this.db.exec("COMMIT");
|
|
142
|
+
return "owns_transaction";
|
|
143
|
+
}
|
|
144
|
+
const result = this.db
|
|
145
|
+
.prepare("DELETE FROM session_members WHERE session_id = ? AND actor_id = ?")
|
|
146
|
+
.run(sessionId, actorId);
|
|
147
|
+
this.db.exec("COMMIT");
|
|
148
|
+
return Number(result.changes) ? "unlinked" : "not_linked";
|
|
149
|
+
}
|
|
150
|
+
catch (error) {
|
|
151
|
+
this.db.exec("ROLLBACK");
|
|
152
|
+
throw error;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
listActors(sessionId) {
|
|
156
|
+
return this.db
|
|
157
|
+
.prepare(`SELECT * FROM session_members
|
|
158
|
+
WHERE session_id = ? ORDER BY attached_at, actor_id`)
|
|
159
|
+
.all(sessionId);
|
|
160
|
+
}
|
|
161
|
+
resolveActor(actorId) {
|
|
162
|
+
return this.db
|
|
163
|
+
.prepare(`SELECT sessions.* FROM sessions
|
|
164
|
+
JOIN session_members ON session_members.session_id = sessions.id
|
|
165
|
+
WHERE session_members.actor_id = ? LIMIT 1`)
|
|
166
|
+
.get(actorId);
|
|
58
167
|
}
|
|
59
168
|
getSession(idOrName) {
|
|
60
169
|
return this.db
|
|
@@ -73,13 +182,25 @@ export class StateStore {
|
|
|
73
182
|
.prepare("SELECT * FROM sessions ORDER BY created_at")
|
|
74
183
|
.all();
|
|
75
184
|
}
|
|
76
|
-
closeSession(sessionId) {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
.
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
185
|
+
closeSession(sessionId, actorId) {
|
|
186
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
187
|
+
try {
|
|
188
|
+
const result = this.db
|
|
189
|
+
.prepare(`UPDATE sessions
|
|
190
|
+
SET status = 'closed', updated_at = ?
|
|
191
|
+
WHERE id = ? AND active_transaction_id IS NULL
|
|
192
|
+
AND EXISTS (
|
|
193
|
+
SELECT 1 FROM session_members
|
|
194
|
+
WHERE session_id = sessions.id AND actor_id = ?
|
|
195
|
+
)`)
|
|
196
|
+
.run(this.now().toISOString(), sessionId, actorId);
|
|
197
|
+
this.db.exec("COMMIT");
|
|
198
|
+
return Number(result.changes) === 1;
|
|
199
|
+
}
|
|
200
|
+
catch (error) {
|
|
201
|
+
this.db.exec("ROLLBACK");
|
|
202
|
+
throw error;
|
|
203
|
+
}
|
|
83
204
|
}
|
|
84
205
|
addProfile(input) {
|
|
85
206
|
const timestamp = this.now().toISOString();
|
|
@@ -107,19 +228,38 @@ export class StateStore {
|
|
|
107
228
|
return Number(result.changes) > 0;
|
|
108
229
|
}
|
|
109
230
|
addConnection(input) {
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
231
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
232
|
+
try {
|
|
233
|
+
const allowed = this.db
|
|
234
|
+
.prepare(`SELECT 1 FROM sessions
|
|
235
|
+
JOIN session_members ON session_members.session_id = sessions.id
|
|
236
|
+
WHERE sessions.id = ? AND session_members.actor_id = ?
|
|
237
|
+
AND sessions.active_transaction_id IS NULL`)
|
|
238
|
+
.get(input.sessionId, input.actorId);
|
|
239
|
+
if (!allowed) {
|
|
240
|
+
this.db.exec("COMMIT");
|
|
241
|
+
return undefined;
|
|
242
|
+
}
|
|
243
|
+
const id = this.nextId("conn");
|
|
244
|
+
const timestamp = this.now().toISOString();
|
|
245
|
+
this.db
|
|
246
|
+
.prepare(`INSERT INTO connections
|
|
247
|
+
(id, session_id, name, driver, database_name, source, secret_env,
|
|
248
|
+
read_only, version, created_at)
|
|
249
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?)`)
|
|
250
|
+
.run(id, input.sessionId, input.name, input.driver, input.databaseName, input.source, input.secretEnv ?? null, input.readOnly ? 1 : 0, timestamp);
|
|
251
|
+
this.db
|
|
252
|
+
.prepare(`UPDATE sessions
|
|
253
|
+
SET active_connection_id = ?, updated_at = ?
|
|
254
|
+
WHERE id = ?`)
|
|
255
|
+
.run(id, timestamp, input.sessionId);
|
|
256
|
+
this.db.exec("COMMIT");
|
|
257
|
+
return this.getConnection(id);
|
|
258
|
+
}
|
|
259
|
+
catch (error) {
|
|
260
|
+
this.db.exec("ROLLBACK");
|
|
261
|
+
throw error;
|
|
262
|
+
}
|
|
123
263
|
}
|
|
124
264
|
getConnection(id) {
|
|
125
265
|
return this.db
|
|
@@ -131,13 +271,25 @@ export class StateStore {
|
|
|
131
271
|
return undefined;
|
|
132
272
|
return this.getConnection(session.active_connection_id);
|
|
133
273
|
}
|
|
134
|
-
disconnect(sessionId) {
|
|
135
|
-
this.db
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
274
|
+
disconnect(sessionId, actorId) {
|
|
275
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
276
|
+
try {
|
|
277
|
+
const result = this.db
|
|
278
|
+
.prepare(`UPDATE sessions
|
|
279
|
+
SET active_connection_id = NULL, updated_at = ?
|
|
280
|
+
WHERE id = ? AND active_transaction_id IS NULL
|
|
281
|
+
AND EXISTS (
|
|
282
|
+
SELECT 1 FROM session_members
|
|
283
|
+
WHERE session_id = sessions.id AND actor_id = ?
|
|
284
|
+
)`)
|
|
285
|
+
.run(this.now().toISOString(), sessionId, actorId);
|
|
286
|
+
this.db.exec("COMMIT");
|
|
287
|
+
return Number(result.changes) === 1;
|
|
288
|
+
}
|
|
289
|
+
catch (error) {
|
|
290
|
+
this.db.exec("ROLLBACK");
|
|
291
|
+
throw error;
|
|
292
|
+
}
|
|
141
293
|
}
|
|
142
294
|
bumpVersion(connectionId) {
|
|
143
295
|
const row = this.db
|
|
@@ -196,11 +348,11 @@ export class StateStore {
|
|
|
196
348
|
const id = this.nextId("op");
|
|
197
349
|
this.db
|
|
198
350
|
.prepare(`INSERT INTO operations
|
|
199
|
-
(id, session_id, connection_id, fingerprint, sql, parameters,
|
|
351
|
+
(id, session_id, actor_id, connection_id, fingerprint, sql, parameters,
|
|
200
352
|
statement_type, affected_rows, status, transaction_id, replay_of,
|
|
201
353
|
idempotency_key, state_version_before, state_version_after, created_at)
|
|
202
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
203
|
-
.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());
|
|
354
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
355
|
+
.run(id, input.sessionId, input.actorId, 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());
|
|
204
356
|
return this.getOperation(id);
|
|
205
357
|
}
|
|
206
358
|
getOperation(id) {
|
|
@@ -239,6 +391,25 @@ export class StateStore {
|
|
|
239
391
|
reserveOperation(input) {
|
|
240
392
|
this.db.exec("BEGIN IMMEDIATE");
|
|
241
393
|
try {
|
|
394
|
+
if (!this.isSessionMember(input.sessionId, input.actorId)) {
|
|
395
|
+
this.db.exec("COMMIT");
|
|
396
|
+
return { denied: "membership" };
|
|
397
|
+
}
|
|
398
|
+
const session = this.db
|
|
399
|
+
.prepare("SELECT active_transaction_id FROM sessions WHERE id = ? AND active_connection_id = ?")
|
|
400
|
+
.get(input.sessionId, input.connectionId);
|
|
401
|
+
const validTransaction = input.transactionId
|
|
402
|
+
? session?.active_transaction_id === input.transactionId &&
|
|
403
|
+
Boolean(this.db
|
|
404
|
+
.prepare(`SELECT 1 FROM transactions
|
|
405
|
+
WHERE id = ? AND session_id = ? AND connection_id = ?
|
|
406
|
+
AND owner_actor_id = ? AND state = 'active'`)
|
|
407
|
+
.get(input.transactionId, input.sessionId, input.connectionId, input.actorId))
|
|
408
|
+
: session?.active_transaction_id === null;
|
|
409
|
+
if (!validTransaction) {
|
|
410
|
+
this.db.exec("COMMIT");
|
|
411
|
+
return { denied: "transaction" };
|
|
412
|
+
}
|
|
242
413
|
const previous = this.findDuplicateOperation({
|
|
243
414
|
connectionId: input.connectionId,
|
|
244
415
|
fingerprint: input.fingerprint,
|
|
@@ -250,6 +421,7 @@ export class StateStore {
|
|
|
250
421
|
}
|
|
251
422
|
const operation = this.saveOperation({
|
|
252
423
|
sessionId: input.sessionId,
|
|
424
|
+
actorId: input.actorId,
|
|
253
425
|
connectionId: input.connectionId,
|
|
254
426
|
fingerprint: input.fingerprint,
|
|
255
427
|
sql: input.sql,
|
|
@@ -290,18 +462,42 @@ export class StateStore {
|
|
|
290
462
|
createTransaction(input) {
|
|
291
463
|
const id = this.nextId("tx");
|
|
292
464
|
const timestamp = this.now().toISOString();
|
|
293
|
-
this.db
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
465
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
466
|
+
try {
|
|
467
|
+
const eligible = this.db
|
|
468
|
+
.prepare(`SELECT connections.version FROM sessions
|
|
469
|
+
JOIN session_members ON session_members.session_id = sessions.id
|
|
470
|
+
JOIN connections ON connections.id = sessions.active_connection_id
|
|
471
|
+
WHERE sessions.id = ? AND session_members.actor_id = ?
|
|
472
|
+
AND connections.id = ? AND sessions.active_transaction_id IS NULL
|
|
473
|
+
AND NOT EXISTS (
|
|
474
|
+
SELECT 1 FROM operations
|
|
475
|
+
WHERE operations.session_id = sessions.id
|
|
476
|
+
AND operations.status = 'executing'
|
|
477
|
+
)`)
|
|
478
|
+
.get(input.sessionId, input.actorId, input.connectionId);
|
|
479
|
+
if (!eligible) {
|
|
480
|
+
this.db.exec("COMMIT");
|
|
481
|
+
return undefined;
|
|
482
|
+
}
|
|
483
|
+
this.db
|
|
484
|
+
.prepare(`INSERT INTO transactions
|
|
485
|
+
(id, session_id, owner_actor_id, connection_id, state,
|
|
486
|
+
isolation_level, start_version, created_at)
|
|
487
|
+
VALUES (?, ?, ?, ?, 'active', ?, ?, ?)`)
|
|
488
|
+
.run(id, input.sessionId, input.actorId, input.connectionId, input.isolation, `sv_${eligible.version}`, timestamp);
|
|
489
|
+
this.db
|
|
490
|
+
.prepare(`UPDATE sessions
|
|
491
|
+
SET active_transaction_id = ?, updated_at = ?
|
|
492
|
+
WHERE id = ?`)
|
|
493
|
+
.run(id, timestamp, input.sessionId);
|
|
494
|
+
this.db.exec("COMMIT");
|
|
495
|
+
return this.getTransaction(id);
|
|
496
|
+
}
|
|
497
|
+
catch (error) {
|
|
498
|
+
this.db.exec("ROLLBACK");
|
|
499
|
+
throw error;
|
|
500
|
+
}
|
|
305
501
|
}
|
|
306
502
|
getTransaction(id) {
|
|
307
503
|
return this.db
|
|
@@ -313,23 +509,41 @@ export class StateStore {
|
|
|
313
509
|
.prepare("SELECT * FROM operations WHERE transaction_id = ? ORDER BY created_at")
|
|
314
510
|
.all(transactionId);
|
|
315
511
|
}
|
|
316
|
-
markTransactionCommitting(transactionId) {
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
512
|
+
markTransactionCommitting(transactionId, sessionId, actorId) {
|
|
513
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
514
|
+
try {
|
|
515
|
+
const result = this.db
|
|
516
|
+
.prepare(`UPDATE transactions
|
|
517
|
+
SET state = 'committing', ended_at = ?
|
|
518
|
+
WHERE id = ? AND session_id = ? AND owner_actor_id = ?
|
|
519
|
+
AND state = 'active'
|
|
520
|
+
AND EXISTS (
|
|
521
|
+
SELECT 1 FROM sessions
|
|
522
|
+
WHERE sessions.id = transactions.session_id
|
|
523
|
+
AND sessions.active_transaction_id = transactions.id
|
|
524
|
+
)`)
|
|
525
|
+
.run(this.now().toISOString(), transactionId, sessionId, actorId);
|
|
526
|
+
this.db.exec("COMMIT");
|
|
527
|
+
return Number(result.changes) === 1;
|
|
528
|
+
}
|
|
529
|
+
catch (error) {
|
|
530
|
+
this.db.exec("ROLLBACK");
|
|
531
|
+
throw error;
|
|
532
|
+
}
|
|
323
533
|
}
|
|
324
|
-
markTransactionOutcomeUnknown(transactionId, sessionId) {
|
|
534
|
+
markTransactionOutcomeUnknown(transactionId, sessionId, actorId) {
|
|
325
535
|
const timestamp = this.now().toISOString();
|
|
326
536
|
this.db.exec("BEGIN IMMEDIATE");
|
|
327
537
|
try {
|
|
328
|
-
this.db
|
|
538
|
+
const result = this.db
|
|
329
539
|
.prepare(`UPDATE transactions
|
|
330
540
|
SET state = 'outcome_unknown', ended_at = ?
|
|
331
|
-
WHERE id =
|
|
332
|
-
|
|
541
|
+
WHERE id = ? AND session_id = ? AND owner_actor_id = ?
|
|
542
|
+
AND state = 'committing'`)
|
|
543
|
+
.run(timestamp, transactionId, sessionId, actorId);
|
|
544
|
+
if (Number(result.changes) !== 1) {
|
|
545
|
+
throw new Error("Transaction ownership changed.");
|
|
546
|
+
}
|
|
333
547
|
this.db
|
|
334
548
|
.prepare(`UPDATE operations SET status = 'outcome_unknown'
|
|
335
549
|
WHERE transaction_id = ? AND status = 'pending'`)
|
|
@@ -337,8 +551,8 @@ export class StateStore {
|
|
|
337
551
|
this.db
|
|
338
552
|
.prepare(`UPDATE sessions
|
|
339
553
|
SET active_transaction_id = NULL, updated_at = ?
|
|
340
|
-
WHERE id = ?`)
|
|
341
|
-
.run(timestamp, sessionId);
|
|
554
|
+
WHERE id = ? AND active_transaction_id = ?`)
|
|
555
|
+
.run(timestamp, sessionId, transactionId);
|
|
342
556
|
this.db.exec("COMMIT");
|
|
343
557
|
}
|
|
344
558
|
catch (error) {
|
|
@@ -346,25 +560,30 @@ export class StateStore {
|
|
|
346
560
|
throw error;
|
|
347
561
|
}
|
|
348
562
|
}
|
|
349
|
-
finishTransaction(transactionId, sessionId, state) {
|
|
563
|
+
finishTransaction(transactionId, sessionId, actorId, state) {
|
|
350
564
|
const timestamp = this.now().toISOString();
|
|
351
565
|
this.db.exec("BEGIN IMMEDIATE");
|
|
352
566
|
try {
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
.
|
|
567
|
+
const expectedState = state === "rolled_back" ? "active" : "committing";
|
|
568
|
+
const result = this.db
|
|
569
|
+
.prepare(`UPDATE transactions SET state = ?, ended_at = ?
|
|
570
|
+
WHERE id = ? AND session_id = ? AND owner_actor_id = ? AND state = ?`)
|
|
571
|
+
.run(state, timestamp, transactionId, sessionId, actorId, expectedState);
|
|
572
|
+
if (Number(result.changes) !== 1) {
|
|
573
|
+
this.db.exec("COMMIT");
|
|
574
|
+
return false;
|
|
575
|
+
}
|
|
356
576
|
this.db
|
|
357
577
|
.prepare(`UPDATE sessions
|
|
358
578
|
SET active_transaction_id = NULL, updated_at = ?
|
|
359
|
-
WHERE id = ?`)
|
|
360
|
-
.run(timestamp, sessionId);
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
.run(state, transactionId);
|
|
366
|
-
}
|
|
579
|
+
WHERE id = ? AND active_transaction_id = ?`)
|
|
580
|
+
.run(timestamp, sessionId, transactionId);
|
|
581
|
+
this.db
|
|
582
|
+
.prepare(`UPDATE operations SET status = ?
|
|
583
|
+
WHERE transaction_id = ? AND status = 'pending'`)
|
|
584
|
+
.run(state, transactionId);
|
|
367
585
|
this.db.exec("COMMIT");
|
|
586
|
+
return true;
|
|
368
587
|
}
|
|
369
588
|
catch (error) {
|
|
370
589
|
this.db.exec("ROLLBACK");
|
|
@@ -375,6 +594,17 @@ export class StateStore {
|
|
|
375
594
|
const timestamp = this.now().toISOString();
|
|
376
595
|
this.db.exec("BEGIN IMMEDIATE");
|
|
377
596
|
try {
|
|
597
|
+
const transaction = this.db
|
|
598
|
+
.prepare(`SELECT 1 FROM transactions
|
|
599
|
+
JOIN sessions ON sessions.id = transactions.session_id
|
|
600
|
+
WHERE transactions.id = ? AND transactions.session_id = ?
|
|
601
|
+
AND transactions.owner_actor_id = ?
|
|
602
|
+
AND transactions.connection_id = ?
|
|
603
|
+
AND transactions.state = 'committing'
|
|
604
|
+
AND sessions.active_transaction_id = transactions.id`)
|
|
605
|
+
.get(input.transactionId, input.sessionId, input.actorId, input.connectionId);
|
|
606
|
+
if (!transaction)
|
|
607
|
+
throw new Error("Transaction ownership changed.");
|
|
378
608
|
let stateVersion = `sv_${(this.getConnection(input.connectionId)?.version ?? 0)}`;
|
|
379
609
|
for (const operation of input.operations) {
|
|
380
610
|
const row = this.db
|
|
@@ -412,11 +642,11 @@ export class StateStore {
|
|
|
412
642
|
const id = this.nextId("p");
|
|
413
643
|
this.db
|
|
414
644
|
.prepare(`INSERT INTO plans
|
|
415
|
-
(id, session_id, connection_id, sql, parameters,
|
|
416
|
-
state_version, state_signature, destructive,
|
|
417
|
-
allow_destructive, expires_at, created_at)
|
|
418
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
419
|
-
.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());
|
|
645
|
+
(id, session_id, owner_actor_id, connection_id, sql, parameters,
|
|
646
|
+
statement_type, state_version, state_signature, destructive,
|
|
647
|
+
allow_unbounded, allow_destructive, expires_at, created_at)
|
|
648
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
649
|
+
.run(id, input.sessionId, input.ownerActorId, 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());
|
|
420
650
|
return this.getPlan(id);
|
|
421
651
|
}
|
|
422
652
|
getPlan(id) {
|
|
@@ -424,19 +654,69 @@ export class StateStore {
|
|
|
424
654
|
.prepare("SELECT * FROM plans WHERE id = ?")
|
|
425
655
|
.get(id);
|
|
426
656
|
}
|
|
427
|
-
|
|
657
|
+
claimPlan(planId, sessionId, actorId, claimToken) {
|
|
658
|
+
return this.db
|
|
659
|
+
.prepare(`UPDATE plans SET claim_token = ?
|
|
660
|
+
WHERE id = ? AND session_id = ? AND owner_actor_id = ?
|
|
661
|
+
AND applied_operation_id IS NULL AND claim_token IS NULL
|
|
662
|
+
AND EXISTS (
|
|
663
|
+
SELECT 1 FROM session_members
|
|
664
|
+
WHERE session_id = plans.session_id AND actor_id = plans.owner_actor_id
|
|
665
|
+
)
|
|
666
|
+
RETURNING *`)
|
|
667
|
+
.get(claimToken, planId, sessionId, actorId);
|
|
668
|
+
}
|
|
669
|
+
releasePlanClaim(planId, claimToken) {
|
|
428
670
|
this.db
|
|
429
|
-
.prepare("UPDATE plans SET
|
|
430
|
-
.run(
|
|
671
|
+
.prepare("UPDATE plans SET claim_token = NULL WHERE id = ? AND claim_token = ?")
|
|
672
|
+
.run(planId, claimToken);
|
|
673
|
+
}
|
|
674
|
+
finishPlannedOperation(input) {
|
|
675
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
676
|
+
try {
|
|
677
|
+
const eligible = this.db
|
|
678
|
+
.prepare(`SELECT 1 FROM operations
|
|
679
|
+
JOIN plans ON plans.id = ?
|
|
680
|
+
WHERE operations.id = ? AND operations.connection_id = ?
|
|
681
|
+
AND operations.status = 'executing'
|
|
682
|
+
AND plans.connection_id = operations.connection_id
|
|
683
|
+
AND plans.session_id = operations.session_id
|
|
684
|
+
AND plans.claim_token = ?
|
|
685
|
+
AND plans.applied_operation_id IS NULL`)
|
|
686
|
+
.get(input.planId, input.operationId, input.connectionId, input.claimToken);
|
|
687
|
+
if (!eligible)
|
|
688
|
+
throw new Error("Plan claim changed before finalization.");
|
|
689
|
+
const row = this.db
|
|
690
|
+
.prepare(`UPDATE connections SET version = version + 1
|
|
691
|
+
WHERE id = ? RETURNING version`)
|
|
692
|
+
.get(input.connectionId);
|
|
693
|
+
const stateVersion = `sv_${row.version}`;
|
|
694
|
+
this.db
|
|
695
|
+
.prepare(`UPDATE operations
|
|
696
|
+
SET status = 'committed', affected_rows = ?, state_version_after = ?
|
|
697
|
+
WHERE id = ?`)
|
|
698
|
+
.run(input.affectedRows, stateVersion, input.operationId);
|
|
699
|
+
this.db
|
|
700
|
+
.prepare(`UPDATE plans SET applied_operation_id = ?, claim_token = NULL
|
|
701
|
+
WHERE id = ? AND claim_token = ?`)
|
|
702
|
+
.run(input.operationId, input.planId, input.claimToken);
|
|
703
|
+
const operation = this.getOperation(input.operationId);
|
|
704
|
+
this.db.exec("COMMIT");
|
|
705
|
+
return { operation, stateVersion };
|
|
706
|
+
}
|
|
707
|
+
catch (error) {
|
|
708
|
+
this.db.exec("ROLLBACK");
|
|
709
|
+
throw error;
|
|
710
|
+
}
|
|
431
711
|
}
|
|
432
712
|
addHistory(input) {
|
|
433
713
|
const id = input.id ?? this.nextId("cmd");
|
|
434
714
|
this.db
|
|
435
715
|
.prepare(`INSERT INTO history
|
|
436
|
-
(id, timestamp, session_id, command, handle, executed,
|
|
437
|
-
success, error_code)
|
|
438
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
439
|
-
.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);
|
|
716
|
+
(id, timestamp, session_id, actor_id, command, handle, executed,
|
|
717
|
+
cached, success, error_code)
|
|
718
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
719
|
+
.run(id, this.now().toISOString(), input.sessionId, input.actorId, input.command, input.handle ?? null, input.executed ? 1 : 0, input.cached ? 1 : 0, input.success ? 1 : 0, input.errorCode ?? null);
|
|
440
720
|
this.db
|
|
441
721
|
.prepare(`DELETE FROM history
|
|
442
722
|
WHERE rowid IN (
|
|
@@ -489,7 +769,9 @@ export class StateStore {
|
|
|
489
769
|
)`)
|
|
490
770
|
.run(timestamp);
|
|
491
771
|
this.db.prepare("DELETE FROM results WHERE expires_at <= ?").run(timestamp);
|
|
492
|
-
this.db
|
|
772
|
+
this.db
|
|
773
|
+
.prepare("DELETE FROM plans WHERE expires_at <= ? AND claim_token IS NULL")
|
|
774
|
+
.run(timestamp);
|
|
493
775
|
this.db.exec("COMMIT");
|
|
494
776
|
}
|
|
495
777
|
catch (error) {
|
|
@@ -530,7 +812,18 @@ export class StateStore {
|
|
|
530
812
|
}
|
|
531
813
|
}
|
|
532
814
|
migrate() {
|
|
533
|
-
this.db.exec(
|
|
815
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
816
|
+
try {
|
|
817
|
+
this.db.exec(`
|
|
818
|
+
CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
819
|
+
name TEXT PRIMARY KEY,
|
|
820
|
+
applied_at TEXT NOT NULL
|
|
821
|
+
);
|
|
822
|
+
`);
|
|
823
|
+
const actorMigrationApplied = Boolean(this.db
|
|
824
|
+
.prepare("SELECT 1 FROM schema_migrations WHERE name = 'shared_session_actors_v1'")
|
|
825
|
+
.get());
|
|
826
|
+
this.db.exec(`
|
|
534
827
|
CREATE TABLE IF NOT EXISTS counters (
|
|
535
828
|
prefix TEXT PRIMARY KEY,
|
|
536
829
|
value INTEGER NOT NULL
|
|
@@ -544,6 +837,13 @@ export class StateStore {
|
|
|
544
837
|
created_at TEXT NOT NULL,
|
|
545
838
|
updated_at TEXT NOT NULL
|
|
546
839
|
);
|
|
840
|
+
CREATE TABLE IF NOT EXISTS session_members (
|
|
841
|
+
session_id TEXT NOT NULL,
|
|
842
|
+
actor_id TEXT NOT NULL UNIQUE,
|
|
843
|
+
attached_at TEXT NOT NULL,
|
|
844
|
+
PRIMARY KEY(session_id, actor_id),
|
|
845
|
+
FOREIGN KEY(session_id) REFERENCES sessions(id)
|
|
846
|
+
);
|
|
547
847
|
CREATE TABLE IF NOT EXISTS profiles (
|
|
548
848
|
name TEXT PRIMARY KEY,
|
|
549
849
|
target TEXT,
|
|
@@ -594,6 +894,7 @@ export class StateStore {
|
|
|
594
894
|
CREATE TABLE IF NOT EXISTS operations (
|
|
595
895
|
id TEXT PRIMARY KEY,
|
|
596
896
|
session_id TEXT NOT NULL,
|
|
897
|
+
actor_id TEXT NOT NULL,
|
|
597
898
|
connection_id TEXT NOT NULL,
|
|
598
899
|
fingerprint TEXT NOT NULL,
|
|
599
900
|
sql TEXT NOT NULL,
|
|
@@ -616,6 +917,7 @@ export class StateStore {
|
|
|
616
917
|
CREATE TABLE IF NOT EXISTS transactions (
|
|
617
918
|
id TEXT PRIMARY KEY,
|
|
618
919
|
session_id TEXT NOT NULL,
|
|
920
|
+
owner_actor_id TEXT NOT NULL,
|
|
619
921
|
connection_id TEXT NOT NULL,
|
|
620
922
|
state TEXT NOT NULL,
|
|
621
923
|
isolation_level TEXT NOT NULL,
|
|
@@ -626,6 +928,7 @@ export class StateStore {
|
|
|
626
928
|
CREATE TABLE IF NOT EXISTS plans (
|
|
627
929
|
id TEXT PRIMARY KEY,
|
|
628
930
|
session_id TEXT NOT NULL,
|
|
931
|
+
owner_actor_id TEXT NOT NULL,
|
|
629
932
|
connection_id TEXT NOT NULL,
|
|
630
933
|
sql TEXT NOT NULL,
|
|
631
934
|
parameters TEXT NOT NULL,
|
|
@@ -637,12 +940,14 @@ export class StateStore {
|
|
|
637
940
|
allow_destructive INTEGER NOT NULL,
|
|
638
941
|
expires_at TEXT NOT NULL,
|
|
639
942
|
applied_operation_id TEXT,
|
|
943
|
+
claim_token TEXT,
|
|
640
944
|
created_at TEXT NOT NULL
|
|
641
945
|
);
|
|
642
946
|
CREATE TABLE IF NOT EXISTS history (
|
|
643
947
|
id TEXT PRIMARY KEY,
|
|
644
948
|
timestamp TEXT NOT NULL,
|
|
645
949
|
session_id TEXT NOT NULL,
|
|
950
|
+
actor_id TEXT NOT NULL,
|
|
646
951
|
command TEXT NOT NULL,
|
|
647
952
|
handle TEXT,
|
|
648
953
|
executed INTEGER NOT NULL,
|
|
@@ -652,6 +957,47 @@ export class StateStore {
|
|
|
652
957
|
);
|
|
653
958
|
CREATE INDEX IF NOT EXISTS history_session
|
|
654
959
|
ON history(session_id);
|
|
655
|
-
|
|
960
|
+
`);
|
|
961
|
+
this.addColumn("operations", "actor_id", "TEXT");
|
|
962
|
+
this.addColumn("transactions", "owner_actor_id", "TEXT");
|
|
963
|
+
this.addColumn("plans", "owner_actor_id", "TEXT");
|
|
964
|
+
this.addColumn("plans", "claim_token", "TEXT");
|
|
965
|
+
this.addColumn("history", "actor_id", "TEXT");
|
|
966
|
+
if (!actorMigrationApplied) {
|
|
967
|
+
this.db.exec(`
|
|
968
|
+
INSERT OR IGNORE INTO session_members(session_id, actor_id, attached_at)
|
|
969
|
+
SELECT id, name, created_at FROM sessions;
|
|
970
|
+
`);
|
|
971
|
+
}
|
|
972
|
+
this.db.exec(`
|
|
973
|
+
UPDATE operations SET actor_id = (
|
|
974
|
+
SELECT name FROM sessions WHERE sessions.id = operations.session_id
|
|
975
|
+
) WHERE actor_id IS NULL;
|
|
976
|
+
UPDATE transactions SET owner_actor_id = (
|
|
977
|
+
SELECT name FROM sessions WHERE sessions.id = transactions.session_id
|
|
978
|
+
) WHERE owner_actor_id IS NULL;
|
|
979
|
+
UPDATE plans SET owner_actor_id = (
|
|
980
|
+
SELECT name FROM sessions WHERE sessions.id = plans.session_id
|
|
981
|
+
) WHERE owner_actor_id IS NULL;
|
|
982
|
+
UPDATE history SET actor_id = (
|
|
983
|
+
SELECT name FROM sessions WHERE sessions.id = history.session_id
|
|
984
|
+
) WHERE actor_id IS NULL;
|
|
985
|
+
`);
|
|
986
|
+
this.db
|
|
987
|
+
.prepare(`INSERT OR IGNORE INTO schema_migrations(name, applied_at)
|
|
988
|
+
VALUES ('shared_session_actors_v1', ?)`)
|
|
989
|
+
.run(this.now().toISOString());
|
|
990
|
+
this.db.exec("COMMIT");
|
|
991
|
+
}
|
|
992
|
+
catch (error) {
|
|
993
|
+
this.db.exec("ROLLBACK");
|
|
994
|
+
throw error;
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
addColumn(table, column, definition) {
|
|
998
|
+
const columns = this.db.prepare(`PRAGMA table_info(${table})`).all();
|
|
999
|
+
if (!columns.some((candidate) => candidate.name === column)) {
|
|
1000
|
+
this.db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
|
|
1001
|
+
}
|
|
656
1002
|
}
|
|
657
1003
|
}
|