@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 CHANGED
@@ -193,7 +193,12 @@ with `as`. Database commands may set `timeout_ms`; otherwise they use the
193
193
  30-second default.
194
194
 
195
195
  State metadata lives under `STQL_HOME`, or the platform data directory when
196
- unset. Set `STQL_SESSION` to select a named session.
196
+ unset. Set `STQL_SESSION` to select a named session and `STQL_ACTOR` to select
197
+ an attached actor for CLI invocations. A session is a shared workspace:
198
+ attached actors reuse its connection, handles, aliases, cache, and
199
+ state version, while plans and staged transactions remain owned by their
200
+ creating actor. Callers that omit `actor` keep the legacy behavior where the
201
+ actor ID is the session name.
197
202
 
198
203
  Read cache entries expire after five minutes; materialized handles expire after
199
204
  24 hours. Expired results and plans are deleted when StateQL next opens. Queries
@@ -235,6 +240,8 @@ import { StateQL } from "@fadhilp/stateql";
235
240
 
236
241
  const stateql = new StateQL({
237
242
  home: "./.stql",
243
+ session: "shared-workspace",
244
+ actor: "pi-session-id",
238
245
  timeoutMs: 30_000,
239
246
  maxResultBytes: 16 * 1024 * 1024,
240
247
  });
@@ -250,3 +257,9 @@ if (response.ok) {
250
257
  });
251
258
  }
252
259
  ```
260
+
261
+ Membership is managed only through the library API, not batch commands:
262
+ `linkActor(session, actorId)`, `unlinkActor(session, actorId)`,
263
+ `listActors(session)`, and `resolveActor(actorId)`. An existing member must link
264
+ an actor before that actor opens an existing workspace. Integrations should ask
265
+ for user confirmation before changing membership or the shared connection.
package/dist/src/cli.js CHANGED
@@ -49,6 +49,7 @@ const stateql = new StateQL({
49
49
  ...(values["timeout-ms"] === undefined
50
50
  ? {}
51
51
  : { timeoutMs: Number(values["timeout-ms"]) }),
52
+ ...(process.env.STQL_ACTOR ? { actor: process.env.STQL_ACTOR } : {}),
52
53
  signal: abortController.signal,
53
54
  });
54
55
  try {
@@ -18,6 +18,7 @@ export function profileData(profile) {
18
18
  export function operationData(operation) {
19
19
  return {
20
20
  operation_id: operation.id,
21
+ actor_id: operation.actor_id,
21
22
  statement_type: operation.statement_type,
22
23
  affected_rows: operation.affected_rows,
23
24
  status: operation.status,
@@ -32,6 +33,7 @@ export function transactionData(transaction, statements) {
32
33
  return {
33
34
  transaction_id: transaction.id,
34
35
  state: transaction.state,
36
+ owner_actor_id: transaction.owner_actor_id,
35
37
  connection_id: transaction.connection_id,
36
38
  statements,
37
39
  pending_writes: transaction.state === "active" ? statements : 0,
@@ -2,6 +2,7 @@ import type { BatchCommand, BatchOptions, ConnectOptions, ExecOptions, Execution
2
2
  export declare class StateQL {
3
3
  private readonly store;
4
4
  private readonly sessionName;
5
+ private readonly actorId;
5
6
  private readonly previewRows;
6
7
  private readonly cacheTtlSeconds;
7
8
  private readonly resultTtlSeconds;
@@ -23,6 +24,10 @@ export declare class StateQL {
23
24
  historyLimit?: number;
24
25
  }): StateQLSnapshot;
25
26
  status(): Promise<Response<unknown>>;
27
+ linkActor(session: string, actorId: string): Promise<Response<unknown>>;
28
+ unlinkActor(session: string, actorId: string): Promise<Response<unknown>>;
29
+ listActors(session: string): Promise<Response<unknown>>;
30
+ resolveActor(actorId: string): Promise<Response<unknown>>;
26
31
  startSession(name: string): Promise<Response<unknown>>;
27
32
  listSessions(): Promise<Response<unknown>>;
28
33
  showSession(idOrName?: string): Promise<Response<unknown>>;
@@ -58,6 +63,9 @@ export declare class StateQL {
58
63
  private requireResult;
59
64
  private requireConnection;
60
65
  private requireActiveTransaction;
66
+ private requireSelectedSession;
67
+ private validateActorId;
68
+ private throwMembershipDenied;
61
69
  private executionContext;
62
70
  private resultData;
63
71
  private cacheValid;
@@ -14,6 +14,7 @@ const MAX_SNAPSHOT_HISTORY_LIMIT = 100;
14
14
  export class StateQL {
15
15
  store;
16
16
  sessionName;
17
+ actorId;
17
18
  previewRows;
18
19
  cacheTtlSeconds;
19
20
  resultTtlSeconds;
@@ -26,6 +27,10 @@ export class StateQL {
26
27
  constructor(options = {}) {
27
28
  this.now = options.now ?? (() => new Date());
28
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
+ }
29
34
  this.previewRows = options.previewRows ?? 5;
30
35
  this.cacheTtlSeconds = options.cacheTtlSeconds ?? 300;
31
36
  this.resultTtlSeconds = options.resultTtlSeconds ?? 86_400;
@@ -38,7 +43,7 @@ export class StateQL {
38
43
  throw new StateQLError("INVALID_COMMAND", "maxResultRows is too large.");
39
44
  }
40
45
  this.store = new StateStore(options.home ?? defaultHome(), this.now);
41
- this.store.ensureSession(this.sessionName);
46
+ this.store.bootstrapSession(this.sessionName, this.actorId, options.actor === undefined);
42
47
  }
43
48
  close() {
44
49
  this.store.close();
@@ -113,6 +118,7 @@ export class StateQL {
113
118
  }
114
119
  const connection = this.store.addConnection({
115
120
  sessionId: session.id,
121
+ actorId: this.actorId,
116
122
  name: draft.name,
117
123
  driver,
118
124
  databaseName,
@@ -120,6 +126,9 @@ export class StateQL {
120
126
  ...(secretEnv ? { secretEnv } : {}),
121
127
  readOnly,
122
128
  });
129
+ if (!connection) {
130
+ throw new StateQLError("TRANSACTION_FAILED", "A transaction became active while changing the connection.");
131
+ }
123
132
  return {
124
133
  data: {
125
134
  connection_id: connection.id,
@@ -206,7 +215,9 @@ export class StateQL {
206
215
  if (session.active_transaction_id) {
207
216
  throw new StateQLError("TRANSACTION_FAILED", "Commit or roll back the active transaction before disconnecting.");
208
217
  }
209
- 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
+ }
210
221
  return { data: { disconnected: true }, executed: true };
211
222
  });
212
223
  }
@@ -217,6 +228,9 @@ export class StateQL {
217
228
  if (!session) {
218
229
  throw new StateQLError("INVALID_COMMAND", "The active session was not found.");
219
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
+ }
220
234
  const connection = this.store.activeConnection(session);
221
235
  const transaction = session.active_transaction_id
222
236
  ? this.store.getTransaction(session.active_transaction_id)
@@ -231,6 +245,7 @@ export class StateQL {
231
245
  name: session.name,
232
246
  status: session.status,
233
247
  },
248
+ actor_id: this.actorId,
234
249
  connection: connection
235
250
  ? {
236
251
  connection_id: connection.id,
@@ -242,7 +257,11 @@ export class StateQL {
242
257
  }
243
258
  : null,
244
259
  transaction: transaction
245
- ? { transaction_id: transaction.id, state: transaction.state }
260
+ ? {
261
+ transaction_id: transaction.id,
262
+ owner_actor_id: transaction.owner_actor_id,
263
+ state: transaction.state,
264
+ }
246
265
  : null,
247
266
  state_version: connection ? version(connection) : null,
248
267
  state_confidence: connection ? confidence(connection) : null,
@@ -255,6 +274,7 @@ export class StateQL {
255
274
  .recentOperations(session.id, 10)
256
275
  .map((operation) => ({
257
276
  handle: operation.id,
277
+ actor_id: operation.actor_id,
258
278
  type: operation.statement_type,
259
279
  affected_rows: operation.affected_rows,
260
280
  status: operation.status,
@@ -272,6 +292,7 @@ export class StateQL {
272
292
  data: {
273
293
  session_id: session.id,
274
294
  session_name: session.name,
295
+ actor_id: this.actorId,
275
296
  connection: connection
276
297
  ? {
277
298
  connection_id: connection.id,
@@ -282,7 +303,11 @@ export class StateQL {
282
303
  }
283
304
  : null,
284
305
  transaction: transaction
285
- ? { 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
+ }
286
311
  : null,
287
312
  state_version: connection ? version(connection) : null,
288
313
  },
@@ -291,6 +316,78 @@ export class StateQL {
291
316
  };
292
317
  });
293
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
+ }
294
391
  async startSession(name) {
295
392
  return this.run("session.start", async () => {
296
393
  if (!name.trim()) {
@@ -299,7 +396,7 @@ export class StateQL {
299
396
  if (this.store.getSessionByName(name)) {
300
397
  throw new StateQLError("INVALID_COMMAND", `Active session "${name}" already exists.`);
301
398
  }
302
- const session = this.store.ensureSession(name);
399
+ const session = this.store.bootstrapSession(name, name, true);
303
400
  return {
304
401
  data: sessionData(session),
305
402
  handle: session.id,
@@ -349,6 +446,7 @@ export class StateQL {
349
446
  .recentOperations(session.id, 10)
350
447
  .map((operation) => ({
351
448
  handle: operation.id,
449
+ actor_id: operation.actor_id,
352
450
  type: operation.statement_type,
353
451
  affected_rows: operation.affected_rows,
354
452
  status: operation.status,
@@ -364,7 +462,9 @@ export class StateQL {
364
462
  if (session.active_transaction_id) {
365
463
  throw new StateQLError("TRANSACTION_FAILED", "Roll back or commit the active transaction first.");
366
464
  }
367
- 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
+ }
368
468
  return {
369
469
  data: { session_id: session.id, state: "closed" },
370
470
  handle: session.id,
@@ -632,10 +732,13 @@ export class StateQL {
632
732
  const normalizedIsolation = normalizeIsolation(isolation, connection.driver);
633
733
  const transaction = this.store.createTransaction({
634
734
  sessionId: session.id,
735
+ actorId: this.actorId,
635
736
  connectionId: connection.id,
636
737
  isolation: normalizedIsolation,
637
- startVersion: version(connection),
638
738
  });
739
+ if (!transaction) {
740
+ throw new StateQLError("TRANSACTION_FAILED", "Another actor acquired the active transaction.");
741
+ }
639
742
  return {
640
743
  data: transactionData(transaction, 0),
641
744
  handle: transaction.id,
@@ -672,15 +775,16 @@ export class StateQL {
672
775
  if (version(connection) !== transaction.start_version) {
673
776
  throw new StateQLError("TRANSACTION_FAILED", "Connection state changed after the transaction began.", { suggestedAction: "Roll back and begin a new transaction." });
674
777
  }
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
778
  const adapter = await createAdapter(connection, this.executionContext(options));
680
779
  try {
681
- if (!this.store.markTransactionCommitting(transaction.id)) {
780
+ if (!this.store.markTransactionCommitting(transaction.id, session.id, this.actorId)) {
682
781
  throw new StateQLError("TRANSACTION_FAILED", "Transaction is no longer active.");
683
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
+ }
684
788
  let results;
685
789
  try {
686
790
  results = await adapter.writeBatch(operations, transaction.isolation_level);
@@ -688,7 +792,7 @@ export class StateQL {
688
792
  catch (error) {
689
793
  if ((error instanceof BatchWriteError && !error.outcomeUnknown) ||
690
794
  (error instanceof AdapterExecutionError && !error.outcomeUnknown)) {
691
- this.store.finishTransaction(transaction.id, session.id, "failed");
795
+ this.store.finishTransaction(transaction.id, session.id, this.actorId, "failed");
692
796
  if (error instanceof AdapterExecutionError) {
693
797
  throw stoppedStateQLError(error, false);
694
798
  }
@@ -696,14 +800,14 @@ export class StateQL {
696
800
  retryable: true,
697
801
  });
698
802
  }
699
- markTransactionOutcomeUnknown(this.store, transaction.id, session.id);
803
+ markTransactionOutcomeUnknown(this.store, transaction.id, session.id, this.actorId);
700
804
  throw new StateQLError("OUTCOME_UNKNOWN", errorMessage(error), {
701
805
  executed: true,
702
806
  suggestedAction: "Inspect database state before issuing any replacement write.",
703
807
  });
704
808
  }
705
809
  if (results.length !== operations.length) {
706
- markTransactionOutcomeUnknown(this.store, transaction.id, session.id);
810
+ markTransactionOutcomeUnknown(this.store, transaction.id, session.id, this.actorId);
707
811
  throw new StateQLError("OUTCOME_UNKNOWN", "Database returned an incomplete transaction result.", {
708
812
  executed: true,
709
813
  suggestedAction: "Inspect database state before issuing any replacement write.",
@@ -714,6 +818,7 @@ export class StateQL {
714
818
  stateVersion = this.store.commitTransactionMetadata({
715
819
  transactionId: transaction.id,
716
820
  sessionId: session.id,
821
+ actorId: this.actorId,
717
822
  connectionId: connection.id,
718
823
  operations: operations.map((operation, index) => ({
719
824
  id: operation.id,
@@ -722,7 +827,7 @@ export class StateQL {
722
827
  });
723
828
  }
724
829
  catch (error) {
725
- markTransactionOutcomeUnknown(this.store, transaction.id, session.id);
830
+ markTransactionOutcomeUnknown(this.store, transaction.id, session.id, this.actorId);
726
831
  throw new StateQLError("OUTCOME_UNKNOWN", errorMessage(error), {
727
832
  executed: true,
728
833
  suggestedAction: "Inspect database state before issuing any replacement write.",
@@ -756,7 +861,9 @@ export class StateQL {
756
861
  return this.run("transaction.rollback", async (session) => {
757
862
  const transaction = this.requireActiveTransaction(session, id);
758
863
  const count = this.store.transactionOperations(transaction.id).length;
759
- 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
+ }
760
867
  return {
761
868
  data: {
762
869
  transaction_id: transaction.id,
@@ -811,6 +918,7 @@ export class StateQL {
811
918
  const expiresAt = new Date(this.now().getTime() + 10 * 60_000).toISOString();
812
919
  const plan = this.store.savePlan({
813
920
  sessionId: session.id,
921
+ ownerActorId: this.actorId,
814
922
  connectionId: connection.id,
815
923
  sql,
816
924
  parameters: options.params ?? [],
@@ -838,6 +946,7 @@ export class StateQL {
838
946
  : []),
839
947
  ],
840
948
  state_version: plan.state_version,
949
+ owner_actor_id: plan.owner_actor_id,
841
950
  expires_at: plan.expires_at,
842
951
  },
843
952
  handle: plan.id,
@@ -864,6 +973,9 @@ export class StateQL {
864
973
  if (!plan || plan.session_id !== session.id) {
865
974
  throw new StateQLError("STALE_PLAN", `Plan "${planId}" was not found.`);
866
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
+ }
867
979
  if (plan.applied_operation_id) {
868
980
  throw new StateQLError("STALE_PLAN", "Plan was already applied.", {
869
981
  extra: { previous_operation_id: plan.applied_operation_id },
@@ -872,38 +984,55 @@ export class StateQL {
872
984
  if (Date.parse(plan.expires_at) <= this.now().getTime()) {
873
985
  throw new StateQLError("STALE_PLAN", "Plan has expired.");
874
986
  }
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.");
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.");
879
991
  }
880
- const context = this.executionContext(options);
881
- const adapter = await createAdapter(connection, context);
992
+ let retainClaim = false;
882
993
  try {
883
- 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) {
884
997
  throw new StateQLError("STALE_PLAN", "Database state changed after this plan was created.");
885
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
+ };
886
1024
  }
887
1025
  catch (error) {
888
- if (error instanceof AdapterExecutionError) {
889
- throw stoppedStateQLError(error, true);
1026
+ if (error instanceof StateQLError &&
1027
+ error.details.code === "OUTCOME_UNKNOWN") {
1028
+ retainClaim = true;
890
1029
  }
891
1030
  throw error;
892
1031
  }
893
1032
  finally {
894
- await adapter.close();
1033
+ if (!retainClaim)
1034
+ this.store.releasePlanClaim(plan.id, claimToken);
895
1035
  }
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
1036
  });
908
1037
  }
909
1038
  async history(limit = 20) {
@@ -1082,7 +1211,7 @@ export class StateQL {
1082
1211
  return;
1083
1212
  }
1084
1213
  }
1085
- async performExec(session, connection, sql, options, context) {
1214
+ async performExec(session, connection, sql, options, context, planClaim) {
1086
1215
  if (connection.read_only) {
1087
1216
  throw new StateQLError("READ_ONLY_CONNECTION", "Connection is read-only.", { suggestedAction: "Reconnect with --read-write." });
1088
1217
  }
@@ -1115,9 +1244,13 @@ export class StateQL {
1115
1244
  transaction.connection_id !== connection.id) {
1116
1245
  throw new StateQLError("TRANSACTION_FAILED", "Active transaction does not match the active connection.");
1117
1246
  }
1247
+ if (transaction.owner_actor_id !== this.actorId) {
1248
+ throw new StateQLError("PERMISSION_DENIED", "Only the transaction owner may stage writes.");
1249
+ }
1118
1250
  }
1119
1251
  const reservation = this.store.reserveOperation({
1120
1252
  sessionId: session.id,
1253
+ actorId: this.actorId,
1121
1254
  connectionId: connection.id,
1122
1255
  fingerprint,
1123
1256
  sql,
@@ -1129,6 +1262,17 @@ export class StateQL {
1129
1262
  idempotencyKey: options.idempotencyKey,
1130
1263
  stateVersionBefore: version(connection),
1131
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
+ }
1132
1276
  const previous = reservation.previous;
1133
1277
  if (previous &&
1134
1278
  options.idempotencyKey &&
@@ -1187,8 +1331,23 @@ export class StateQL {
1187
1331
  try {
1188
1332
  const write = await adapter.write(sql, parameters);
1189
1333
  try {
1190
- const after = this.store.bumpVersion(connection.id);
1191
- 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;
1192
1351
  return {
1193
1352
  data: {
1194
1353
  ...operationData(committed),
@@ -1285,8 +1444,24 @@ export class StateQL {
1285
1444
  session.active_transaction_id !== transaction.id) {
1286
1445
  throw new StateQLError("TRANSACTION_NOT_FOUND", `Active transaction "${transactionId}" was not found.`);
1287
1446
  }
1447
+ if (transaction.owner_actor_id !== this.actorId) {
1448
+ throw new StateQLError("PERMISSION_DENIED", "Only the transaction owner may control it.");
1449
+ }
1288
1450
  return transaction;
1289
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
+ }
1290
1465
  executionContext(options) {
1291
1466
  return createAdapterContext(executionTimeout(options.timeoutMs ?? this.timeoutMs), options.signal ?? this.signal);
1292
1467
  }
@@ -1320,12 +1495,25 @@ export class StateQL {
1320
1495
  const started = performance.now();
1321
1496
  let session = this.store.ensureSession(this.sessionName);
1322
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
+ }
1323
1510
  try {
1324
1511
  const result = await action(session);
1325
- session = result.session ?? session;
1512
+ const responseSession = result.session ?? session;
1326
1513
  this.store.addHistory({
1327
1514
  id: commandId,
1328
1515
  sessionId: session.id,
1516
+ actorId: this.actorId,
1329
1517
  command,
1330
1518
  ...(result.handle ? { handle: result.handle } : {}),
1331
1519
  executed: result.executed ?? false,
@@ -1335,7 +1523,7 @@ export class StateQL {
1335
1523
  return {
1336
1524
  ok: true,
1337
1525
  command_id: commandId,
1338
- session_id: session.id,
1526
+ session_id: responseSession.id,
1339
1527
  data: result.data,
1340
1528
  warnings: result.warnings ?? [],
1341
1529
  meta: {
@@ -1354,6 +1542,7 @@ export class StateQL {
1354
1542
  this.store.addHistory({
1355
1543
  id: commandId,
1356
1544
  sessionId: session.id,
1545
+ actorId: this.actorId,
1357
1546
  command,
1358
1547
  executed: stateqlError.details.executed,
1359
1548
  cached: false,
@@ -1377,6 +1566,7 @@ function historyEntry(item) {
1377
1566
  command_id: item.id,
1378
1567
  timestamp: item.timestamp,
1379
1568
  session_id: item.session_id,
1569
+ actor_id: item.actor_id,
1380
1570
  command: item.command,
1381
1571
  handle: item.handle,
1382
1572
  executed: Boolean(item.executed),
@@ -1385,9 +1575,9 @@ function historyEntry(item) {
1385
1575
  error_code: item.error_code,
1386
1576
  };
1387
1577
  }
1388
- function markTransactionOutcomeUnknown(store, transactionId, sessionId) {
1578
+ function markTransactionOutcomeUnknown(store, transactionId, sessionId, actorId) {
1389
1579
  try {
1390
- store.markTransactionOutcomeUnknown(transactionId, sessionId);
1580
+ store.markTransactionOutcomeUnknown(transactionId, sessionId, actorId);
1391
1581
  }
1392
1582
  catch {
1393
1583
  // A stale committing transaction is recovered as unknown after five minutes.