@fadhilp/stateql 0.2.2 → 0.3.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/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', active_transaction_id = NULL, updated_at = ?
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
- createSession(name) {
49
+ bootstrapSession(name, actorId, ensureLegacyMembership) {
50
50
  const timestamp = this.now().toISOString();
51
- const id = this.nextId("s");
52
- this.db
53
- .prepare(`INSERT INTO sessions
54
- (id, name, status, created_at, updated_at)
55
- VALUES (?, ?, 'active', ?, ?)`)
56
- .run(id, name, timestamp, timestamp);
57
- return this.getSession(id);
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
- const timestamp = this.now().toISOString();
78
- this.db
79
- .prepare(`UPDATE sessions
80
- SET status = 'closed', active_transaction_id = NULL, updated_at = ?
81
- WHERE id = ?`)
82
- .run(timestamp, sessionId);
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
- const id = this.nextId("conn");
111
- this.db
112
- .prepare(`INSERT INTO connections
113
- (id, session_id, name, driver, database_name, source, secret_env,
114
- read_only, version, created_at)
115
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?)`)
116
- .run(id, input.sessionId, input.name, input.driver, input.databaseName, input.source, input.secretEnv ?? null, input.readOnly ? 1 : 0, this.now().toISOString());
117
- this.db
118
- .prepare(`UPDATE sessions
119
- SET active_connection_id = ?, updated_at = ?
120
- WHERE id = ?`)
121
- .run(id, this.now().toISOString(), input.sessionId);
122
- return this.getConnection(id);
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
- .prepare(`UPDATE sessions
137
- SET active_connection_id = NULL, active_transaction_id = NULL,
138
- updated_at = ?
139
- WHERE id = ?`)
140
- .run(this.now().toISOString(), sessionId);
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
- .prepare(`INSERT INTO transactions
295
- (id, session_id, connection_id, state, isolation_level,
296
- start_version, created_at)
297
- VALUES (?, ?, ?, 'active', ?, ?, ?)`)
298
- .run(id, input.sessionId, input.connectionId, input.isolation, input.startVersion, timestamp);
299
- this.db
300
- .prepare(`UPDATE sessions
301
- SET active_transaction_id = ?, updated_at = ?
302
- WHERE id = ?`)
303
- .run(id, timestamp, input.sessionId);
304
- return this.getTransaction(id);
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
- const result = this.db
318
- .prepare(`UPDATE transactions
319
- SET state = 'committing', ended_at = ?
320
- WHERE id = ? AND state = 'active'`)
321
- .run(this.now().toISOString(), transactionId);
322
- return Number(result.changes) === 1;
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
- .run(timestamp, transactionId);
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
- this.db
354
- .prepare("UPDATE transactions SET state = ?, ended_at = ? WHERE id = ?")
355
- .run(state, timestamp, transactionId);
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
- if (state === "rolled_back" || state === "failed") {
362
- this.db
363
- .prepare(`UPDATE operations SET status = ?
364
- WHERE transaction_id = ? AND status = 'pending'`)
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, statement_type,
416
- state_version, state_signature, destructive, allow_unbounded,
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
- markPlanApplied(planId, operationId) {
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 applied_operation_id = ? WHERE id = ?")
430
- .run(operationId, planId);
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, cached,
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.prepare("DELETE FROM plans WHERE expires_at <= ?").run(timestamp);
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
  }