@fadhilp/stateql 0.2.0 → 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.
@@ -9,9 +9,12 @@ import { operationData, paginationWarnings, profileData, rowsToCsv, sessionData,
9
9
  import { analyzeSql } from "./sql.js";
10
10
  import { StateStore, } from "./store.js";
11
11
  import { compactRows, defaultHome, hash, parseJson, redact, } from "./util.js";
12
+ const DEFAULT_SNAPSHOT_HISTORY_LIMIT = 50;
13
+ const MAX_SNAPSHOT_HISTORY_LIMIT = 100;
12
14
  export class StateQL {
13
15
  store;
14
16
  sessionName;
17
+ actorId;
15
18
  previewRows;
16
19
  cacheTtlSeconds;
17
20
  resultTtlSeconds;
@@ -24,6 +27,10 @@ export class StateQL {
24
27
  constructor(options = {}) {
25
28
  this.now = options.now ?? (() => new Date());
26
29
  this.sessionName = options.session ?? env.STQL_SESSION ?? "default";
30
+ this.actorId = options.actor ?? this.sessionName;
31
+ if (!this.actorId.trim()) {
32
+ throw new StateQLError("INVALID_COMMAND", "Actor ID is required.");
33
+ }
27
34
  this.previewRows = options.previewRows ?? 5;
28
35
  this.cacheTtlSeconds = options.cacheTtlSeconds ?? 300;
29
36
  this.resultTtlSeconds = options.resultTtlSeconds ?? 86_400;
@@ -36,7 +43,7 @@ export class StateQL {
36
43
  throw new StateQLError("INVALID_COMMAND", "maxResultRows is too large.");
37
44
  }
38
45
  this.store = new StateStore(options.home ?? defaultHome(), this.now);
39
- this.store.ensureSession(this.sessionName);
46
+ this.store.bootstrapSession(this.sessionName, this.actorId, options.actor === undefined);
40
47
  }
41
48
  close() {
42
49
  this.store.close();
@@ -111,6 +118,7 @@ export class StateQL {
111
118
  }
112
119
  const connection = this.store.addConnection({
113
120
  sessionId: session.id,
121
+ actorId: this.actorId,
114
122
  name: draft.name,
115
123
  driver,
116
124
  databaseName,
@@ -118,6 +126,9 @@ export class StateQL {
118
126
  ...(secretEnv ? { secretEnv } : {}),
119
127
  readOnly,
120
128
  });
129
+ if (!connection) {
130
+ throw new StateQLError("TRANSACTION_FAILED", "A transaction became active while changing the connection.");
131
+ }
121
132
  return {
122
133
  data: {
123
134
  connection_id: connection.id,
@@ -204,10 +215,73 @@ export class StateQL {
204
215
  if (session.active_transaction_id) {
205
216
  throw new StateQLError("TRANSACTION_FAILED", "Commit or roll back the active transaction before disconnecting.");
206
217
  }
207
- this.store.disconnect(session.id);
218
+ if (!this.store.disconnect(session.id, this.actorId)) {
219
+ throw new StateQLError("TRANSACTION_FAILED", "A transaction became active while disconnecting.");
220
+ }
208
221
  return { data: { disconnected: true }, executed: true };
209
222
  });
210
223
  }
224
+ snapshot(options = {}) {
225
+ const session = this.store
226
+ .listSessions()
227
+ .find((candidate) => candidate.name === this.sessionName);
228
+ if (!session) {
229
+ throw new StateQLError("INVALID_COMMAND", "The active session was not found.");
230
+ }
231
+ if (!this.store.isSessionMember(session.id, this.actorId)) {
232
+ throw new StateQLError("PERMISSION_DENIED", `Actor "${this.actorId}" is not attached to session "${session.name}".`);
233
+ }
234
+ const connection = this.store.activeConnection(session);
235
+ const transaction = session.active_transaction_id
236
+ ? this.store.getTransaction(session.active_transaction_id)
237
+ : undefined;
238
+ const historyLimit = positiveInteger(options.historyLimit ?? DEFAULT_SNAPSHOT_HISTORY_LIMIT, "historyLimit");
239
+ if (historyLimit > MAX_SNAPSHOT_HISTORY_LIMIT) {
240
+ throw new StateQLError("INVALID_COMMAND", `historyLimit cannot exceed ${MAX_SNAPSHOT_HISTORY_LIMIT}.`);
241
+ }
242
+ return {
243
+ session: {
244
+ session_id: session.id,
245
+ name: session.name,
246
+ status: session.status,
247
+ },
248
+ actor_id: this.actorId,
249
+ connection: connection
250
+ ? {
251
+ connection_id: connection.id,
252
+ name: connection.name,
253
+ status: "connected",
254
+ driver: connection.driver,
255
+ database: connection.database_name,
256
+ read_only: Boolean(connection.read_only),
257
+ }
258
+ : null,
259
+ transaction: transaction
260
+ ? {
261
+ transaction_id: transaction.id,
262
+ owner_actor_id: transaction.owner_actor_id,
263
+ state: transaction.state,
264
+ }
265
+ : null,
266
+ state_version: connection ? version(connection) : null,
267
+ state_confidence: connection ? confidence(connection) : null,
268
+ recent_results: this.store.knownResults(session.id, 10).map((result) => ({
269
+ alias: result.alias,
270
+ handle: result.id,
271
+ rows: result.row_count,
272
+ })),
273
+ recent_operations: this.store
274
+ .recentOperations(session.id, 10)
275
+ .map((operation) => ({
276
+ handle: operation.id,
277
+ actor_id: operation.actor_id,
278
+ type: operation.statement_type,
279
+ affected_rows: operation.affected_rows,
280
+ status: operation.status,
281
+ })),
282
+ history: this.store.history(session.id, historyLimit).map(historyEntry),
283
+ };
284
+ }
211
285
  async status() {
212
286
  return this.run("status", async (session) => {
213
287
  const connection = this.store.activeConnection(session);
@@ -218,6 +292,7 @@ export class StateQL {
218
292
  data: {
219
293
  session_id: session.id,
220
294
  session_name: session.name,
295
+ actor_id: this.actorId,
221
296
  connection: connection
222
297
  ? {
223
298
  connection_id: connection.id,
@@ -228,7 +303,11 @@ export class StateQL {
228
303
  }
229
304
  : null,
230
305
  transaction: transaction
231
- ? { transaction_id: transaction.id, state: transaction.state }
306
+ ? {
307
+ transaction_id: transaction.id,
308
+ owner_actor_id: transaction.owner_actor_id,
309
+ state: transaction.state,
310
+ }
232
311
  : null,
233
312
  state_version: connection ? version(connection) : null,
234
313
  },
@@ -237,6 +316,78 @@ export class StateQL {
237
316
  };
238
317
  });
239
318
  }
319
+ async linkActor(session, actorId) {
320
+ return this.run("actor.link", async (current) => {
321
+ this.requireSelectedSession(current, session);
322
+ this.validateActorId(actorId);
323
+ const result = this.store.linkActor(current.id, this.actorId, actorId);
324
+ if (result === "actor_conflict") {
325
+ throw new StateQLError("PERMISSION_DENIED", `Actor "${actorId}" is already attached to another session.`);
326
+ }
327
+ if (result === "denied")
328
+ this.throwMembershipDenied(current);
329
+ return {
330
+ data: {
331
+ session_id: current.id,
332
+ actor_id: actorId,
333
+ linked: result === "linked",
334
+ },
335
+ executed: result === "linked",
336
+ };
337
+ });
338
+ }
339
+ async unlinkActor(session, actorId) {
340
+ return this.run("actor.unlink", async (current) => {
341
+ this.requireSelectedSession(current, session);
342
+ this.validateActorId(actorId);
343
+ const result = this.store.unlinkActor(current.id, this.actorId, actorId);
344
+ if (result === "owns_transaction") {
345
+ throw new StateQLError("TRANSACTION_FAILED", `Actor "${actorId}" owns the active transaction.`);
346
+ }
347
+ if (result === "denied")
348
+ this.throwMembershipDenied(current);
349
+ return {
350
+ data: {
351
+ session_id: current.id,
352
+ actor_id: actorId,
353
+ unlinked: result === "unlinked",
354
+ },
355
+ executed: result === "unlinked",
356
+ };
357
+ });
358
+ }
359
+ async listActors(session) {
360
+ return this.run("actor.list", async (current) => {
361
+ this.requireSelectedSession(current, session);
362
+ return {
363
+ data: {
364
+ session_id: current.id,
365
+ actors: this.store.listActors(current.id).map((member) => ({
366
+ actor_id: member.actor_id,
367
+ attached_at: member.attached_at,
368
+ })),
369
+ },
370
+ };
371
+ });
372
+ }
373
+ async resolveActor(actorId) {
374
+ return this.run("actor.resolve", async () => {
375
+ this.validateActorId(actorId);
376
+ const session = this.store.resolveActor(actorId);
377
+ return {
378
+ data: {
379
+ actor_id: actorId,
380
+ session: session
381
+ ? {
382
+ session_id: session.id,
383
+ name: session.name,
384
+ status: session.status,
385
+ }
386
+ : null,
387
+ },
388
+ };
389
+ });
390
+ }
240
391
  async startSession(name) {
241
392
  return this.run("session.start", async () => {
242
393
  if (!name.trim()) {
@@ -245,7 +396,7 @@ export class StateQL {
245
396
  if (this.store.getSessionByName(name)) {
246
397
  throw new StateQLError("INVALID_COMMAND", `Active session "${name}" already exists.`);
247
398
  }
248
- const session = this.store.ensureSession(name);
399
+ const session = this.store.bootstrapSession(name, name, true);
249
400
  return {
250
401
  data: sessionData(session),
251
402
  handle: session.id,
@@ -295,6 +446,7 @@ export class StateQL {
295
446
  .recentOperations(session.id, 10)
296
447
  .map((operation) => ({
297
448
  handle: operation.id,
449
+ actor_id: operation.actor_id,
298
450
  type: operation.statement_type,
299
451
  affected_rows: operation.affected_rows,
300
452
  status: operation.status,
@@ -310,7 +462,9 @@ export class StateQL {
310
462
  if (session.active_transaction_id) {
311
463
  throw new StateQLError("TRANSACTION_FAILED", "Roll back or commit the active transaction first.");
312
464
  }
313
- this.store.closeSession(session.id);
465
+ if (!this.store.closeSession(session.id, this.actorId)) {
466
+ throw new StateQLError("TRANSACTION_FAILED", "A transaction became active while closing the session.");
467
+ }
314
468
  return {
315
469
  data: { session_id: session.id, state: "closed" },
316
470
  handle: session.id,
@@ -578,10 +732,13 @@ export class StateQL {
578
732
  const normalizedIsolation = normalizeIsolation(isolation, connection.driver);
579
733
  const transaction = this.store.createTransaction({
580
734
  sessionId: session.id,
735
+ actorId: this.actorId,
581
736
  connectionId: connection.id,
582
737
  isolation: normalizedIsolation,
583
- startVersion: version(connection),
584
738
  });
739
+ if (!transaction) {
740
+ throw new StateQLError("TRANSACTION_FAILED", "Another actor acquired the active transaction.");
741
+ }
585
742
  return {
586
743
  data: transactionData(transaction, 0),
587
744
  handle: transaction.id,
@@ -618,15 +775,16 @@ export class StateQL {
618
775
  if (version(connection) !== transaction.start_version) {
619
776
  throw new StateQLError("TRANSACTION_FAILED", "Connection state changed after the transaction began.", { suggestedAction: "Roll back and begin a new transaction." });
620
777
  }
621
- const operations = this.store.transactionOperations(transaction.id);
622
- if (operations.some((operation) => operation.connection_id !== connection.id)) {
623
- throw new StateQLError("TRANSACTION_FAILED", "Transaction contains writes staged for another connection.", { suggestedAction: "Roll back the transaction." });
624
- }
625
778
  const adapter = await createAdapter(connection, this.executionContext(options));
626
779
  try {
627
- if (!this.store.markTransactionCommitting(transaction.id)) {
780
+ if (!this.store.markTransactionCommitting(transaction.id, session.id, this.actorId)) {
628
781
  throw new StateQLError("TRANSACTION_FAILED", "Transaction is no longer active.");
629
782
  }
783
+ const operations = this.store.transactionOperations(transaction.id);
784
+ if (operations.some((operation) => operation.connection_id !== connection.id)) {
785
+ this.store.finishTransaction(transaction.id, session.id, this.actorId, "failed");
786
+ throw new StateQLError("TRANSACTION_FAILED", "Transaction contains writes staged for another connection.");
787
+ }
630
788
  let results;
631
789
  try {
632
790
  results = await adapter.writeBatch(operations, transaction.isolation_level);
@@ -634,7 +792,7 @@ export class StateQL {
634
792
  catch (error) {
635
793
  if ((error instanceof BatchWriteError && !error.outcomeUnknown) ||
636
794
  (error instanceof AdapterExecutionError && !error.outcomeUnknown)) {
637
- this.store.finishTransaction(transaction.id, session.id, "failed");
795
+ this.store.finishTransaction(transaction.id, session.id, this.actorId, "failed");
638
796
  if (error instanceof AdapterExecutionError) {
639
797
  throw stoppedStateQLError(error, false);
640
798
  }
@@ -642,14 +800,14 @@ export class StateQL {
642
800
  retryable: true,
643
801
  });
644
802
  }
645
- markTransactionOutcomeUnknown(this.store, transaction.id, session.id);
803
+ markTransactionOutcomeUnknown(this.store, transaction.id, session.id, this.actorId);
646
804
  throw new StateQLError("OUTCOME_UNKNOWN", errorMessage(error), {
647
805
  executed: true,
648
806
  suggestedAction: "Inspect database state before issuing any replacement write.",
649
807
  });
650
808
  }
651
809
  if (results.length !== operations.length) {
652
- markTransactionOutcomeUnknown(this.store, transaction.id, session.id);
810
+ markTransactionOutcomeUnknown(this.store, transaction.id, session.id, this.actorId);
653
811
  throw new StateQLError("OUTCOME_UNKNOWN", "Database returned an incomplete transaction result.", {
654
812
  executed: true,
655
813
  suggestedAction: "Inspect database state before issuing any replacement write.",
@@ -660,6 +818,7 @@ export class StateQL {
660
818
  stateVersion = this.store.commitTransactionMetadata({
661
819
  transactionId: transaction.id,
662
820
  sessionId: session.id,
821
+ actorId: this.actorId,
663
822
  connectionId: connection.id,
664
823
  operations: operations.map((operation, index) => ({
665
824
  id: operation.id,
@@ -668,7 +827,7 @@ export class StateQL {
668
827
  });
669
828
  }
670
829
  catch (error) {
671
- markTransactionOutcomeUnknown(this.store, transaction.id, session.id);
830
+ markTransactionOutcomeUnknown(this.store, transaction.id, session.id, this.actorId);
672
831
  throw new StateQLError("OUTCOME_UNKNOWN", errorMessage(error), {
673
832
  executed: true,
674
833
  suggestedAction: "Inspect database state before issuing any replacement write.",
@@ -702,7 +861,9 @@ export class StateQL {
702
861
  return this.run("transaction.rollback", async (session) => {
703
862
  const transaction = this.requireActiveTransaction(session, id);
704
863
  const count = this.store.transactionOperations(transaction.id).length;
705
- this.store.finishTransaction(transaction.id, session.id, "rolled_back");
864
+ if (!this.store.finishTransaction(transaction.id, session.id, this.actorId, "rolled_back")) {
865
+ throw new StateQLError("TRANSACTION_FAILED", "Transaction is no longer active.");
866
+ }
706
867
  return {
707
868
  data: {
708
869
  transaction_id: transaction.id,
@@ -757,6 +918,7 @@ export class StateQL {
757
918
  const expiresAt = new Date(this.now().getTime() + 10 * 60_000).toISOString();
758
919
  const plan = this.store.savePlan({
759
920
  sessionId: session.id,
921
+ ownerActorId: this.actorId,
760
922
  connectionId: connection.id,
761
923
  sql,
762
924
  parameters: options.params ?? [],
@@ -784,6 +946,7 @@ export class StateQL {
784
946
  : []),
785
947
  ],
786
948
  state_version: plan.state_version,
949
+ owner_actor_id: plan.owner_actor_id,
787
950
  expires_at: plan.expires_at,
788
951
  },
789
952
  handle: plan.id,
@@ -810,6 +973,9 @@ export class StateQL {
810
973
  if (!plan || plan.session_id !== session.id) {
811
974
  throw new StateQLError("STALE_PLAN", `Plan "${planId}" was not found.`);
812
975
  }
976
+ if (plan.owner_actor_id !== this.actorId) {
977
+ throw new StateQLError("PERMISSION_DENIED", "Only the actor that created this plan may apply it.");
978
+ }
813
979
  if (plan.applied_operation_id) {
814
980
  throw new StateQLError("STALE_PLAN", "Plan was already applied.", {
815
981
  extra: { previous_operation_id: plan.applied_operation_id },
@@ -818,38 +984,55 @@ export class StateQL {
818
984
  if (Date.parse(plan.expires_at) <= this.now().getTime()) {
819
985
  throw new StateQLError("STALE_PLAN", "Plan has expired.");
820
986
  }
821
- const connection = this.requireConnection(session);
822
- if (connection.id !== plan.connection_id ||
823
- version(connection) !== plan.state_version) {
824
- throw new StateQLError("STALE_PLAN", "Database state changed after this plan was created.");
987
+ const claimToken = this.store.nextId("claim");
988
+ const claimed = this.store.claimPlan(plan.id, session.id, this.actorId, claimToken);
989
+ if (!claimed) {
990
+ throw new StateQLError("STALE_PLAN", "Plan is already being applied.");
825
991
  }
826
- const context = this.executionContext(options);
827
- const adapter = await createAdapter(connection, context);
992
+ let retainClaim = false;
828
993
  try {
829
- if ((await adapter.signature()) !== plan.state_signature) {
994
+ const connection = this.requireConnection(session);
995
+ if (connection.id !== claimed.connection_id ||
996
+ version(connection) !== claimed.state_version) {
830
997
  throw new StateQLError("STALE_PLAN", "Database state changed after this plan was created.");
831
998
  }
999
+ const context = this.executionContext(options);
1000
+ const adapter = await createAdapter(connection, context);
1001
+ try {
1002
+ if ((await adapter.signature()) !== claimed.state_signature) {
1003
+ throw new StateQLError("STALE_PLAN", "Database state changed after this plan was created.");
1004
+ }
1005
+ }
1006
+ catch (error) {
1007
+ if (error instanceof AdapterExecutionError) {
1008
+ throw stoppedStateQLError(error, true);
1009
+ }
1010
+ throw error;
1011
+ }
1012
+ finally {
1013
+ await adapter.close();
1014
+ }
1015
+ const result = await this.performExec(session, connection, claimed.sql, {
1016
+ params: parseJson(claimed.parameters, []),
1017
+ allowUnbounded: Boolean(claimed.allow_unbounded),
1018
+ allowDestructive: Boolean(claimed.allow_destructive),
1019
+ }, context, { planId: claimed.id, claimToken });
1020
+ return {
1021
+ ...result,
1022
+ data: { plan_id: claimed.id, ...result.data },
1023
+ };
832
1024
  }
833
1025
  catch (error) {
834
- if (error instanceof AdapterExecutionError) {
835
- throw stoppedStateQLError(error, true);
1026
+ if (error instanceof StateQLError &&
1027
+ error.details.code === "OUTCOME_UNKNOWN") {
1028
+ retainClaim = true;
836
1029
  }
837
1030
  throw error;
838
1031
  }
839
1032
  finally {
840
- await adapter.close();
1033
+ if (!retainClaim)
1034
+ this.store.releasePlanClaim(plan.id, claimToken);
841
1035
  }
842
- const result = await this.performExec(session, connection, plan.sql, {
843
- params: parseJson(plan.parameters, []),
844
- allowUnbounded: Boolean(plan.allow_unbounded),
845
- allowDestructive: Boolean(plan.allow_destructive),
846
- }, context);
847
- const operationId = String(result.data.operation_id);
848
- this.store.markPlanApplied(plan.id, operationId);
849
- return {
850
- ...result,
851
- data: { plan_id: plan.id, ...result.data },
852
- };
853
1036
  });
854
1037
  }
855
1038
  async history(limit = 20) {
@@ -857,17 +1040,7 @@ export class StateQL {
857
1040
  data: {
858
1041
  history: this.store
859
1042
  .history(session.id, positiveInteger(limit, "limit"))
860
- .map((item) => ({
861
- command_id: item.id,
862
- timestamp: item.timestamp,
863
- session_id: item.session_id,
864
- command: item.command,
865
- handle: item.handle,
866
- executed: Boolean(item.executed),
867
- cached: Boolean(item.cached),
868
- success: Boolean(item.success),
869
- error_code: item.error_code,
870
- })),
1043
+ .map(historyEntry),
871
1044
  },
872
1045
  }));
873
1046
  }
@@ -1038,7 +1211,7 @@ export class StateQL {
1038
1211
  return;
1039
1212
  }
1040
1213
  }
1041
- async performExec(session, connection, sql, options, context) {
1214
+ async performExec(session, connection, sql, options, context, planClaim) {
1042
1215
  if (connection.read_only) {
1043
1216
  throw new StateQLError("READ_ONLY_CONNECTION", "Connection is read-only.", { suggestedAction: "Reconnect with --read-write." });
1044
1217
  }
@@ -1071,9 +1244,13 @@ export class StateQL {
1071
1244
  transaction.connection_id !== connection.id) {
1072
1245
  throw new StateQLError("TRANSACTION_FAILED", "Active transaction does not match the active connection.");
1073
1246
  }
1247
+ if (transaction.owner_actor_id !== this.actorId) {
1248
+ throw new StateQLError("PERMISSION_DENIED", "Only the transaction owner may stage writes.");
1249
+ }
1074
1250
  }
1075
1251
  const reservation = this.store.reserveOperation({
1076
1252
  sessionId: session.id,
1253
+ actorId: this.actorId,
1077
1254
  connectionId: connection.id,
1078
1255
  fingerprint,
1079
1256
  sql,
@@ -1085,6 +1262,17 @@ export class StateQL {
1085
1262
  idempotencyKey: options.idempotencyKey,
1086
1263
  stateVersionBefore: version(connection),
1087
1264
  });
1265
+ if (reservation.denied === "membership") {
1266
+ throw new StateQLError("PERMISSION_DENIED", "Actor membership changed before the write was reserved.");
1267
+ }
1268
+ if (reservation.denied === "transaction") {
1269
+ const active = this.store.getSession(session.id)?.active_transaction_id;
1270
+ const transaction = active ? this.store.getTransaction(active) : undefined;
1271
+ if (transaction && transaction.owner_actor_id !== this.actorId) {
1272
+ throw new StateQLError("PERMISSION_DENIED", "Only the transaction owner may stage writes.");
1273
+ }
1274
+ throw new StateQLError("TRANSACTION_FAILED", "The active transaction changed before the write was reserved.");
1275
+ }
1088
1276
  const previous = reservation.previous;
1089
1277
  if (previous &&
1090
1278
  options.idempotencyKey &&
@@ -1143,8 +1331,23 @@ export class StateQL {
1143
1331
  try {
1144
1332
  const write = await adapter.write(sql, parameters);
1145
1333
  try {
1146
- const after = this.store.bumpVersion(connection.id);
1147
- const committed = this.store.finishOperation(operation.id, write.affectedRows, after);
1334
+ const finalized = planClaim
1335
+ ? this.store.finishPlannedOperation({
1336
+ planId: planClaim.planId,
1337
+ claimToken: planClaim.claimToken,
1338
+ operationId: operation.id,
1339
+ connectionId: connection.id,
1340
+ affectedRows: write.affectedRows,
1341
+ })
1342
+ : (() => {
1343
+ const stateVersion = this.store.bumpVersion(connection.id);
1344
+ return {
1345
+ operation: this.store.finishOperation(operation.id, write.affectedRows, stateVersion),
1346
+ stateVersion,
1347
+ };
1348
+ })();
1349
+ const committed = finalized.operation;
1350
+ const after = finalized.stateVersion;
1148
1351
  return {
1149
1352
  data: {
1150
1353
  ...operationData(committed),
@@ -1241,8 +1444,24 @@ export class StateQL {
1241
1444
  session.active_transaction_id !== transaction.id) {
1242
1445
  throw new StateQLError("TRANSACTION_NOT_FOUND", `Active transaction "${transactionId}" was not found.`);
1243
1446
  }
1447
+ if (transaction.owner_actor_id !== this.actorId) {
1448
+ throw new StateQLError("PERMISSION_DENIED", "Only the transaction owner may control it.");
1449
+ }
1244
1450
  return transaction;
1245
1451
  }
1452
+ requireSelectedSession(current, selected) {
1453
+ if (selected !== current.id && selected !== current.name) {
1454
+ throw new StateQLError("PERMISSION_DENIED", "Membership can only be managed for the selected session.");
1455
+ }
1456
+ }
1457
+ validateActorId(actorId) {
1458
+ if (!actorId.trim()) {
1459
+ throw new StateQLError("INVALID_COMMAND", "Actor ID is required.");
1460
+ }
1461
+ }
1462
+ throwMembershipDenied(session) {
1463
+ throw new StateQLError("PERMISSION_DENIED", `Actor "${this.actorId}" is not attached to session "${session.name}".`);
1464
+ }
1246
1465
  executionContext(options) {
1247
1466
  return createAdapterContext(executionTimeout(options.timeoutMs ?? this.timeoutMs), options.signal ?? this.signal);
1248
1467
  }
@@ -1276,12 +1495,25 @@ export class StateQL {
1276
1495
  const started = performance.now();
1277
1496
  let session = this.store.ensureSession(this.sessionName);
1278
1497
  const commandId = this.store.nextId("cmd");
1498
+ if (!this.store.isSessionMember(session.id, this.actorId)) {
1499
+ const error = new StateQLError("PERMISSION_DENIED", `Actor "${this.actorId}" is not attached to session "${session.name}".`);
1500
+ return {
1501
+ ok: false,
1502
+ command_id: commandId,
1503
+ session_id: session.id,
1504
+ error: error.details,
1505
+ meta: {
1506
+ duration_ms: Math.round((performance.now() - started) * 1000) / 1000,
1507
+ },
1508
+ };
1509
+ }
1279
1510
  try {
1280
1511
  const result = await action(session);
1281
- session = result.session ?? session;
1512
+ const responseSession = result.session ?? session;
1282
1513
  this.store.addHistory({
1283
1514
  id: commandId,
1284
1515
  sessionId: session.id,
1516
+ actorId: this.actorId,
1285
1517
  command,
1286
1518
  ...(result.handle ? { handle: result.handle } : {}),
1287
1519
  executed: result.executed ?? false,
@@ -1291,7 +1523,7 @@ export class StateQL {
1291
1523
  return {
1292
1524
  ok: true,
1293
1525
  command_id: commandId,
1294
- session_id: session.id,
1526
+ session_id: responseSession.id,
1295
1527
  data: result.data,
1296
1528
  warnings: result.warnings ?? [],
1297
1529
  meta: {
@@ -1310,6 +1542,7 @@ export class StateQL {
1310
1542
  this.store.addHistory({
1311
1543
  id: commandId,
1312
1544
  sessionId: session.id,
1545
+ actorId: this.actorId,
1313
1546
  command,
1314
1547
  executed: stateqlError.details.executed,
1315
1548
  cached: false,
@@ -1328,9 +1561,23 @@ export class StateQL {
1328
1561
  }
1329
1562
  }
1330
1563
  }
1331
- function markTransactionOutcomeUnknown(store, transactionId, sessionId) {
1564
+ function historyEntry(item) {
1565
+ return {
1566
+ command_id: item.id,
1567
+ timestamp: item.timestamp,
1568
+ session_id: item.session_id,
1569
+ actor_id: item.actor_id,
1570
+ command: item.command,
1571
+ handle: item.handle,
1572
+ executed: Boolean(item.executed),
1573
+ cached: Boolean(item.cached),
1574
+ success: Boolean(item.success),
1575
+ error_code: item.error_code,
1576
+ };
1577
+ }
1578
+ function markTransactionOutcomeUnknown(store, transactionId, sessionId, actorId) {
1332
1579
  try {
1333
- store.markTransactionOutcomeUnknown(transactionId, sessionId);
1580
+ store.markTransactionOutcomeUnknown(transactionId, sessionId, actorId);
1334
1581
  }
1335
1582
  catch {
1336
1583
  // A stale committing transaction is recovered as unknown after five minutes.