@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.
@@ -12,8 +12,26 @@ import { compactRows, defaultHome, hash, parseJson, redact, } from "./util.js";
12
12
  const DEFAULT_SNAPSHOT_HISTORY_LIMIT = 50;
13
13
  const MAX_SNAPSHOT_HISTORY_LIMIT = 100;
14
14
  export class StateQL {
15
+ static forActor(options) {
16
+ if (!options.actor.trim()) {
17
+ throw new StateQLError("INVALID_COMMAND", "Actor ID is required.");
18
+ }
19
+ const now = options.now ?? (() => new Date());
20
+ const store = new StateStore(options.home ?? defaultHome(), now);
21
+ try {
22
+ const session = store.resolveActor(options.actor);
23
+ if (session)
24
+ return new StateQL({ ...options, session: session.name });
25
+ const { actor, ...legacyOptions } = options;
26
+ return new StateQL({ ...legacyOptions, session: actor });
27
+ }
28
+ finally {
29
+ store.close();
30
+ }
31
+ }
15
32
  store;
16
33
  sessionName;
34
+ actorId;
17
35
  previewRows;
18
36
  cacheTtlSeconds;
19
37
  resultTtlSeconds;
@@ -26,6 +44,10 @@ export class StateQL {
26
44
  constructor(options = {}) {
27
45
  this.now = options.now ?? (() => new Date());
28
46
  this.sessionName = options.session ?? env.STQL_SESSION ?? "default";
47
+ this.actorId = options.actor ?? this.sessionName;
48
+ if (!this.actorId.trim()) {
49
+ throw new StateQLError("INVALID_COMMAND", "Actor ID is required.");
50
+ }
29
51
  this.previewRows = options.previewRows ?? 5;
30
52
  this.cacheTtlSeconds = options.cacheTtlSeconds ?? 300;
31
53
  this.resultTtlSeconds = options.resultTtlSeconds ?? 86_400;
@@ -38,7 +60,7 @@ export class StateQL {
38
60
  throw new StateQLError("INVALID_COMMAND", "maxResultRows is too large.");
39
61
  }
40
62
  this.store = new StateStore(options.home ?? defaultHome(), this.now);
41
- this.store.ensureSession(this.sessionName);
63
+ this.store.bootstrapSession(this.sessionName, this.actorId, options.actor === undefined);
42
64
  }
43
65
  close() {
44
66
  this.store.close();
@@ -113,6 +135,7 @@ export class StateQL {
113
135
  }
114
136
  const connection = this.store.addConnection({
115
137
  sessionId: session.id,
138
+ actorId: this.actorId,
116
139
  name: draft.name,
117
140
  driver,
118
141
  databaseName,
@@ -120,6 +143,9 @@ export class StateQL {
120
143
  ...(secretEnv ? { secretEnv } : {}),
121
144
  readOnly,
122
145
  });
146
+ if (!connection) {
147
+ throw new StateQLError("TRANSACTION_FAILED", "A transaction became active while changing the connection.");
148
+ }
123
149
  return {
124
150
  data: {
125
151
  connection_id: connection.id,
@@ -206,7 +232,9 @@ export class StateQL {
206
232
  if (session.active_transaction_id) {
207
233
  throw new StateQLError("TRANSACTION_FAILED", "Commit or roll back the active transaction before disconnecting.");
208
234
  }
209
- this.store.disconnect(session.id);
235
+ if (!this.store.disconnect(session.id, this.actorId)) {
236
+ throw new StateQLError("TRANSACTION_FAILED", "A transaction became active while disconnecting.");
237
+ }
210
238
  return { data: { disconnected: true }, executed: true };
211
239
  });
212
240
  }
@@ -217,6 +245,9 @@ export class StateQL {
217
245
  if (!session) {
218
246
  throw new StateQLError("INVALID_COMMAND", "The active session was not found.");
219
247
  }
248
+ if (!this.store.isSessionMember(session.id, this.actorId)) {
249
+ throw new StateQLError("PERMISSION_DENIED", `Actor "${this.actorId}" is not attached to session "${session.name}".`);
250
+ }
220
251
  const connection = this.store.activeConnection(session);
221
252
  const transaction = session.active_transaction_id
222
253
  ? this.store.getTransaction(session.active_transaction_id)
@@ -231,6 +262,7 @@ export class StateQL {
231
262
  name: session.name,
232
263
  status: session.status,
233
264
  },
265
+ actor_id: this.actorId,
234
266
  connection: connection
235
267
  ? {
236
268
  connection_id: connection.id,
@@ -242,7 +274,11 @@ export class StateQL {
242
274
  }
243
275
  : null,
244
276
  transaction: transaction
245
- ? { transaction_id: transaction.id, state: transaction.state }
277
+ ? {
278
+ transaction_id: transaction.id,
279
+ owner_actor_id: transaction.owner_actor_id,
280
+ state: transaction.state,
281
+ }
246
282
  : null,
247
283
  state_version: connection ? version(connection) : null,
248
284
  state_confidence: connection ? confidence(connection) : null,
@@ -255,6 +291,7 @@ export class StateQL {
255
291
  .recentOperations(session.id, 10)
256
292
  .map((operation) => ({
257
293
  handle: operation.id,
294
+ actor_id: operation.actor_id,
258
295
  type: operation.statement_type,
259
296
  affected_rows: operation.affected_rows,
260
297
  status: operation.status,
@@ -272,6 +309,7 @@ export class StateQL {
272
309
  data: {
273
310
  session_id: session.id,
274
311
  session_name: session.name,
312
+ actor_id: this.actorId,
275
313
  connection: connection
276
314
  ? {
277
315
  connection_id: connection.id,
@@ -282,7 +320,11 @@ export class StateQL {
282
320
  }
283
321
  : null,
284
322
  transaction: transaction
285
- ? { transaction_id: transaction.id, state: transaction.state }
323
+ ? {
324
+ transaction_id: transaction.id,
325
+ owner_actor_id: transaction.owner_actor_id,
326
+ state: transaction.state,
327
+ }
286
328
  : null,
287
329
  state_version: connection ? version(connection) : null,
288
330
  },
@@ -291,6 +333,78 @@ export class StateQL {
291
333
  };
292
334
  });
293
335
  }
336
+ async linkActor(session, actorId) {
337
+ return this.run("actor.link", async (current) => {
338
+ this.requireSelectedSession(current, session);
339
+ this.validateActorId(actorId);
340
+ const result = this.store.linkActor(current.id, this.actorId, actorId);
341
+ if (result === "actor_conflict") {
342
+ throw new StateQLError("PERMISSION_DENIED", `Actor "${actorId}" is already attached to another session.`);
343
+ }
344
+ if (result === "denied")
345
+ this.throwMembershipDenied(current);
346
+ return {
347
+ data: {
348
+ session_id: current.id,
349
+ actor_id: actorId,
350
+ linked: result === "linked",
351
+ },
352
+ executed: result === "linked",
353
+ };
354
+ });
355
+ }
356
+ async unlinkActor(session, actorId) {
357
+ return this.run("actor.unlink", async (current) => {
358
+ this.requireSelectedSession(current, session);
359
+ this.validateActorId(actorId);
360
+ const result = this.store.unlinkActor(current.id, this.actorId, actorId);
361
+ if (result === "owns_transaction") {
362
+ throw new StateQLError("TRANSACTION_FAILED", `Actor "${actorId}" owns the active transaction.`);
363
+ }
364
+ if (result === "denied")
365
+ this.throwMembershipDenied(current);
366
+ return {
367
+ data: {
368
+ session_id: current.id,
369
+ actor_id: actorId,
370
+ unlinked: result === "unlinked",
371
+ },
372
+ executed: result === "unlinked",
373
+ };
374
+ });
375
+ }
376
+ async listActors(session) {
377
+ return this.run("actor.list", async (current) => {
378
+ this.requireSelectedSession(current, session);
379
+ return {
380
+ data: {
381
+ session_id: current.id,
382
+ actors: this.store.listActors(current.id).map((member) => ({
383
+ actor_id: member.actor_id,
384
+ attached_at: member.attached_at,
385
+ })),
386
+ },
387
+ };
388
+ });
389
+ }
390
+ async resolveActor(actorId) {
391
+ return this.run("actor.resolve", async () => {
392
+ this.validateActorId(actorId);
393
+ const session = this.store.resolveActor(actorId);
394
+ return {
395
+ data: {
396
+ actor_id: actorId,
397
+ session: session
398
+ ? {
399
+ session_id: session.id,
400
+ name: session.name,
401
+ status: session.status,
402
+ }
403
+ : null,
404
+ },
405
+ };
406
+ });
407
+ }
294
408
  async startSession(name) {
295
409
  return this.run("session.start", async () => {
296
410
  if (!name.trim()) {
@@ -299,7 +413,7 @@ export class StateQL {
299
413
  if (this.store.getSessionByName(name)) {
300
414
  throw new StateQLError("INVALID_COMMAND", `Active session "${name}" already exists.`);
301
415
  }
302
- const session = this.store.ensureSession(name);
416
+ const session = this.store.bootstrapSession(name, name, true);
303
417
  return {
304
418
  data: sessionData(session),
305
419
  handle: session.id,
@@ -349,6 +463,7 @@ export class StateQL {
349
463
  .recentOperations(session.id, 10)
350
464
  .map((operation) => ({
351
465
  handle: operation.id,
466
+ actor_id: operation.actor_id,
352
467
  type: operation.statement_type,
353
468
  affected_rows: operation.affected_rows,
354
469
  status: operation.status,
@@ -364,7 +479,9 @@ export class StateQL {
364
479
  if (session.active_transaction_id) {
365
480
  throw new StateQLError("TRANSACTION_FAILED", "Roll back or commit the active transaction first.");
366
481
  }
367
- this.store.closeSession(session.id);
482
+ if (!this.store.closeSession(session.id, this.actorId)) {
483
+ throw new StateQLError("TRANSACTION_FAILED", "A transaction became active while closing the session.");
484
+ }
368
485
  return {
369
486
  data: { session_id: session.id, state: "closed" },
370
487
  handle: session.id,
@@ -632,10 +749,13 @@ export class StateQL {
632
749
  const normalizedIsolation = normalizeIsolation(isolation, connection.driver);
633
750
  const transaction = this.store.createTransaction({
634
751
  sessionId: session.id,
752
+ actorId: this.actorId,
635
753
  connectionId: connection.id,
636
754
  isolation: normalizedIsolation,
637
- startVersion: version(connection),
638
755
  });
756
+ if (!transaction) {
757
+ throw new StateQLError("TRANSACTION_FAILED", "Another actor acquired the active transaction.");
758
+ }
639
759
  return {
640
760
  data: transactionData(transaction, 0),
641
761
  handle: transaction.id,
@@ -672,15 +792,16 @@ export class StateQL {
672
792
  if (version(connection) !== transaction.start_version) {
673
793
  throw new StateQLError("TRANSACTION_FAILED", "Connection state changed after the transaction began.", { suggestedAction: "Roll back and begin a new transaction." });
674
794
  }
675
- const operations = this.store.transactionOperations(transaction.id);
676
- if (operations.some((operation) => operation.connection_id !== connection.id)) {
677
- throw new StateQLError("TRANSACTION_FAILED", "Transaction contains writes staged for another connection.", { suggestedAction: "Roll back the transaction." });
678
- }
679
795
  const adapter = await createAdapter(connection, this.executionContext(options));
680
796
  try {
681
- if (!this.store.markTransactionCommitting(transaction.id)) {
797
+ if (!this.store.markTransactionCommitting(transaction.id, session.id, this.actorId)) {
682
798
  throw new StateQLError("TRANSACTION_FAILED", "Transaction is no longer active.");
683
799
  }
800
+ const operations = this.store.transactionOperations(transaction.id);
801
+ if (operations.some((operation) => operation.connection_id !== connection.id)) {
802
+ this.store.finishTransaction(transaction.id, session.id, this.actorId, "failed");
803
+ throw new StateQLError("TRANSACTION_FAILED", "Transaction contains writes staged for another connection.");
804
+ }
684
805
  let results;
685
806
  try {
686
807
  results = await adapter.writeBatch(operations, transaction.isolation_level);
@@ -688,7 +809,7 @@ export class StateQL {
688
809
  catch (error) {
689
810
  if ((error instanceof BatchWriteError && !error.outcomeUnknown) ||
690
811
  (error instanceof AdapterExecutionError && !error.outcomeUnknown)) {
691
- this.store.finishTransaction(transaction.id, session.id, "failed");
812
+ this.store.finishTransaction(transaction.id, session.id, this.actorId, "failed");
692
813
  if (error instanceof AdapterExecutionError) {
693
814
  throw stoppedStateQLError(error, false);
694
815
  }
@@ -696,14 +817,14 @@ export class StateQL {
696
817
  retryable: true,
697
818
  });
698
819
  }
699
- markTransactionOutcomeUnknown(this.store, transaction.id, session.id);
820
+ markTransactionOutcomeUnknown(this.store, transaction.id, session.id, this.actorId);
700
821
  throw new StateQLError("OUTCOME_UNKNOWN", errorMessage(error), {
701
822
  executed: true,
702
823
  suggestedAction: "Inspect database state before issuing any replacement write.",
703
824
  });
704
825
  }
705
826
  if (results.length !== operations.length) {
706
- markTransactionOutcomeUnknown(this.store, transaction.id, session.id);
827
+ markTransactionOutcomeUnknown(this.store, transaction.id, session.id, this.actorId);
707
828
  throw new StateQLError("OUTCOME_UNKNOWN", "Database returned an incomplete transaction result.", {
708
829
  executed: true,
709
830
  suggestedAction: "Inspect database state before issuing any replacement write.",
@@ -714,6 +835,7 @@ export class StateQL {
714
835
  stateVersion = this.store.commitTransactionMetadata({
715
836
  transactionId: transaction.id,
716
837
  sessionId: session.id,
838
+ actorId: this.actorId,
717
839
  connectionId: connection.id,
718
840
  operations: operations.map((operation, index) => ({
719
841
  id: operation.id,
@@ -722,7 +844,7 @@ export class StateQL {
722
844
  });
723
845
  }
724
846
  catch (error) {
725
- markTransactionOutcomeUnknown(this.store, transaction.id, session.id);
847
+ markTransactionOutcomeUnknown(this.store, transaction.id, session.id, this.actorId);
726
848
  throw new StateQLError("OUTCOME_UNKNOWN", errorMessage(error), {
727
849
  executed: true,
728
850
  suggestedAction: "Inspect database state before issuing any replacement write.",
@@ -756,7 +878,9 @@ export class StateQL {
756
878
  return this.run("transaction.rollback", async (session) => {
757
879
  const transaction = this.requireActiveTransaction(session, id);
758
880
  const count = this.store.transactionOperations(transaction.id).length;
759
- this.store.finishTransaction(transaction.id, session.id, "rolled_back");
881
+ if (!this.store.finishTransaction(transaction.id, session.id, this.actorId, "rolled_back")) {
882
+ throw new StateQLError("TRANSACTION_FAILED", "Transaction is no longer active.");
883
+ }
760
884
  return {
761
885
  data: {
762
886
  transaction_id: transaction.id,
@@ -811,6 +935,7 @@ export class StateQL {
811
935
  const expiresAt = new Date(this.now().getTime() + 10 * 60_000).toISOString();
812
936
  const plan = this.store.savePlan({
813
937
  sessionId: session.id,
938
+ ownerActorId: this.actorId,
814
939
  connectionId: connection.id,
815
940
  sql,
816
941
  parameters: options.params ?? [],
@@ -838,6 +963,7 @@ export class StateQL {
838
963
  : []),
839
964
  ],
840
965
  state_version: plan.state_version,
966
+ owner_actor_id: plan.owner_actor_id,
841
967
  expires_at: plan.expires_at,
842
968
  },
843
969
  handle: plan.id,
@@ -864,6 +990,9 @@ export class StateQL {
864
990
  if (!plan || plan.session_id !== session.id) {
865
991
  throw new StateQLError("STALE_PLAN", `Plan "${planId}" was not found.`);
866
992
  }
993
+ if (plan.owner_actor_id !== this.actorId) {
994
+ throw new StateQLError("PERMISSION_DENIED", "Only the actor that created this plan may apply it.");
995
+ }
867
996
  if (plan.applied_operation_id) {
868
997
  throw new StateQLError("STALE_PLAN", "Plan was already applied.", {
869
998
  extra: { previous_operation_id: plan.applied_operation_id },
@@ -872,38 +1001,55 @@ export class StateQL {
872
1001
  if (Date.parse(plan.expires_at) <= this.now().getTime()) {
873
1002
  throw new StateQLError("STALE_PLAN", "Plan has expired.");
874
1003
  }
875
- const connection = this.requireConnection(session);
876
- if (connection.id !== plan.connection_id ||
877
- version(connection) !== plan.state_version) {
878
- throw new StateQLError("STALE_PLAN", "Database state changed after this plan was created.");
1004
+ const claimToken = this.store.nextId("claim");
1005
+ const claimed = this.store.claimPlan(plan.id, session.id, this.actorId, claimToken);
1006
+ if (!claimed) {
1007
+ throw new StateQLError("STALE_PLAN", "Plan is already being applied.");
879
1008
  }
880
- const context = this.executionContext(options);
881
- const adapter = await createAdapter(connection, context);
1009
+ let retainClaim = false;
882
1010
  try {
883
- if ((await adapter.signature()) !== plan.state_signature) {
1011
+ const connection = this.requireConnection(session);
1012
+ if (connection.id !== claimed.connection_id ||
1013
+ version(connection) !== claimed.state_version) {
884
1014
  throw new StateQLError("STALE_PLAN", "Database state changed after this plan was created.");
885
1015
  }
1016
+ const context = this.executionContext(options);
1017
+ const adapter = await createAdapter(connection, context);
1018
+ try {
1019
+ if ((await adapter.signature()) !== claimed.state_signature) {
1020
+ throw new StateQLError("STALE_PLAN", "Database state changed after this plan was created.");
1021
+ }
1022
+ }
1023
+ catch (error) {
1024
+ if (error instanceof AdapterExecutionError) {
1025
+ throw stoppedStateQLError(error, true);
1026
+ }
1027
+ throw error;
1028
+ }
1029
+ finally {
1030
+ await adapter.close();
1031
+ }
1032
+ const result = await this.performExec(session, connection, claimed.sql, {
1033
+ params: parseJson(claimed.parameters, []),
1034
+ allowUnbounded: Boolean(claimed.allow_unbounded),
1035
+ allowDestructive: Boolean(claimed.allow_destructive),
1036
+ }, context, { planId: claimed.id, claimToken });
1037
+ return {
1038
+ ...result,
1039
+ data: { plan_id: claimed.id, ...result.data },
1040
+ };
886
1041
  }
887
1042
  catch (error) {
888
- if (error instanceof AdapterExecutionError) {
889
- throw stoppedStateQLError(error, true);
1043
+ if (error instanceof StateQLError &&
1044
+ error.details.code === "OUTCOME_UNKNOWN") {
1045
+ retainClaim = true;
890
1046
  }
891
1047
  throw error;
892
1048
  }
893
1049
  finally {
894
- await adapter.close();
1050
+ if (!retainClaim)
1051
+ this.store.releasePlanClaim(plan.id, claimToken);
895
1052
  }
896
- const result = await this.performExec(session, connection, plan.sql, {
897
- params: parseJson(plan.parameters, []),
898
- allowUnbounded: Boolean(plan.allow_unbounded),
899
- allowDestructive: Boolean(plan.allow_destructive),
900
- }, context);
901
- const operationId = String(result.data.operation_id);
902
- this.store.markPlanApplied(plan.id, operationId);
903
- return {
904
- ...result,
905
- data: { plan_id: plan.id, ...result.data },
906
- };
907
1053
  });
908
1054
  }
909
1055
  async history(limit = 20) {
@@ -1082,7 +1228,7 @@ export class StateQL {
1082
1228
  return;
1083
1229
  }
1084
1230
  }
1085
- async performExec(session, connection, sql, options, context) {
1231
+ async performExec(session, connection, sql, options, context, planClaim) {
1086
1232
  if (connection.read_only) {
1087
1233
  throw new StateQLError("READ_ONLY_CONNECTION", "Connection is read-only.", { suggestedAction: "Reconnect with --read-write." });
1088
1234
  }
@@ -1115,9 +1261,13 @@ export class StateQL {
1115
1261
  transaction.connection_id !== connection.id) {
1116
1262
  throw new StateQLError("TRANSACTION_FAILED", "Active transaction does not match the active connection.");
1117
1263
  }
1264
+ if (transaction.owner_actor_id !== this.actorId) {
1265
+ throw new StateQLError("PERMISSION_DENIED", "Only the transaction owner may stage writes.");
1266
+ }
1118
1267
  }
1119
1268
  const reservation = this.store.reserveOperation({
1120
1269
  sessionId: session.id,
1270
+ actorId: this.actorId,
1121
1271
  connectionId: connection.id,
1122
1272
  fingerprint,
1123
1273
  sql,
@@ -1129,6 +1279,17 @@ export class StateQL {
1129
1279
  idempotencyKey: options.idempotencyKey,
1130
1280
  stateVersionBefore: version(connection),
1131
1281
  });
1282
+ if (reservation.denied === "membership") {
1283
+ throw new StateQLError("PERMISSION_DENIED", "Actor membership changed before the write was reserved.");
1284
+ }
1285
+ if (reservation.denied === "transaction") {
1286
+ const active = this.store.getSession(session.id)?.active_transaction_id;
1287
+ const transaction = active ? this.store.getTransaction(active) : undefined;
1288
+ if (transaction && transaction.owner_actor_id !== this.actorId) {
1289
+ throw new StateQLError("PERMISSION_DENIED", "Only the transaction owner may stage writes.");
1290
+ }
1291
+ throw new StateQLError("TRANSACTION_FAILED", "The active transaction changed before the write was reserved.");
1292
+ }
1132
1293
  const previous = reservation.previous;
1133
1294
  if (previous &&
1134
1295
  options.idempotencyKey &&
@@ -1187,8 +1348,23 @@ export class StateQL {
1187
1348
  try {
1188
1349
  const write = await adapter.write(sql, parameters);
1189
1350
  try {
1190
- const after = this.store.bumpVersion(connection.id);
1191
- const committed = this.store.finishOperation(operation.id, write.affectedRows, after);
1351
+ const finalized = planClaim
1352
+ ? this.store.finishPlannedOperation({
1353
+ planId: planClaim.planId,
1354
+ claimToken: planClaim.claimToken,
1355
+ operationId: operation.id,
1356
+ connectionId: connection.id,
1357
+ affectedRows: write.affectedRows,
1358
+ })
1359
+ : (() => {
1360
+ const stateVersion = this.store.bumpVersion(connection.id);
1361
+ return {
1362
+ operation: this.store.finishOperation(operation.id, write.affectedRows, stateVersion),
1363
+ stateVersion,
1364
+ };
1365
+ })();
1366
+ const committed = finalized.operation;
1367
+ const after = finalized.stateVersion;
1192
1368
  return {
1193
1369
  data: {
1194
1370
  ...operationData(committed),
@@ -1285,8 +1461,24 @@ export class StateQL {
1285
1461
  session.active_transaction_id !== transaction.id) {
1286
1462
  throw new StateQLError("TRANSACTION_NOT_FOUND", `Active transaction "${transactionId}" was not found.`);
1287
1463
  }
1464
+ if (transaction.owner_actor_id !== this.actorId) {
1465
+ throw new StateQLError("PERMISSION_DENIED", "Only the transaction owner may control it.");
1466
+ }
1288
1467
  return transaction;
1289
1468
  }
1469
+ requireSelectedSession(current, selected) {
1470
+ if (selected !== current.id && selected !== current.name) {
1471
+ throw new StateQLError("PERMISSION_DENIED", "Membership can only be managed for the selected session.");
1472
+ }
1473
+ }
1474
+ validateActorId(actorId) {
1475
+ if (!actorId.trim()) {
1476
+ throw new StateQLError("INVALID_COMMAND", "Actor ID is required.");
1477
+ }
1478
+ }
1479
+ throwMembershipDenied(session) {
1480
+ throw new StateQLError("PERMISSION_DENIED", `Actor "${this.actorId}" is not attached to session "${session.name}".`);
1481
+ }
1290
1482
  executionContext(options) {
1291
1483
  return createAdapterContext(executionTimeout(options.timeoutMs ?? this.timeoutMs), options.signal ?? this.signal);
1292
1484
  }
@@ -1320,12 +1512,25 @@ export class StateQL {
1320
1512
  const started = performance.now();
1321
1513
  let session = this.store.ensureSession(this.sessionName);
1322
1514
  const commandId = this.store.nextId("cmd");
1515
+ if (!this.store.isSessionMember(session.id, this.actorId)) {
1516
+ const error = new StateQLError("PERMISSION_DENIED", `Actor "${this.actorId}" is not attached to session "${session.name}".`);
1517
+ return {
1518
+ ok: false,
1519
+ command_id: commandId,
1520
+ session_id: session.id,
1521
+ error: error.details,
1522
+ meta: {
1523
+ duration_ms: Math.round((performance.now() - started) * 1000) / 1000,
1524
+ },
1525
+ };
1526
+ }
1323
1527
  try {
1324
1528
  const result = await action(session);
1325
- session = result.session ?? session;
1529
+ const responseSession = result.session ?? session;
1326
1530
  this.store.addHistory({
1327
1531
  id: commandId,
1328
1532
  sessionId: session.id,
1533
+ actorId: this.actorId,
1329
1534
  command,
1330
1535
  ...(result.handle ? { handle: result.handle } : {}),
1331
1536
  executed: result.executed ?? false,
@@ -1335,7 +1540,7 @@ export class StateQL {
1335
1540
  return {
1336
1541
  ok: true,
1337
1542
  command_id: commandId,
1338
- session_id: session.id,
1543
+ session_id: responseSession.id,
1339
1544
  data: result.data,
1340
1545
  warnings: result.warnings ?? [],
1341
1546
  meta: {
@@ -1354,6 +1559,7 @@ export class StateQL {
1354
1559
  this.store.addHistory({
1355
1560
  id: commandId,
1356
1561
  sessionId: session.id,
1562
+ actorId: this.actorId,
1357
1563
  command,
1358
1564
  executed: stateqlError.details.executed,
1359
1565
  cached: false,
@@ -1377,6 +1583,7 @@ function historyEntry(item) {
1377
1583
  command_id: item.id,
1378
1584
  timestamp: item.timestamp,
1379
1585
  session_id: item.session_id,
1586
+ actor_id: item.actor_id,
1380
1587
  command: item.command,
1381
1588
  handle: item.handle,
1382
1589
  executed: Boolean(item.executed),
@@ -1385,9 +1592,9 @@ function historyEntry(item) {
1385
1592
  error_code: item.error_code,
1386
1593
  };
1387
1594
  }
1388
- function markTransactionOutcomeUnknown(store, transactionId, sessionId) {
1595
+ function markTransactionOutcomeUnknown(store, transactionId, sessionId, actorId) {
1389
1596
  try {
1390
- store.markTransactionOutcomeUnknown(transactionId, sessionId);
1597
+ store.markTransactionOutcomeUnknown(transactionId, sessionId, actorId);
1391
1598
  }
1392
1599
  catch {
1393
1600
  // A stale committing transaction is recovered as unknown after five minutes.