@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/README.md +19 -2
- package/dist/src/cli.js +1 -0
- package/dist/src/index.d.ts +1 -1
- package/dist/src/response-data.js +2 -0
- package/dist/src/stateql.d.ts +10 -1
- package/dist/src/stateql.js +252 -45
- package/dist/src/store.d.ts +44 -9
- package/dist/src/store.js +437 -91
- package/dist/src/types.d.ts +8 -0
- package/package.json +1 -1
package/dist/src/stateql.js
CHANGED
|
@@ -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.
|
|
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
|
-
? {
|
|
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
|
-
? {
|
|
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.
|
|
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
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
throw new StateQLError("STALE_PLAN", "
|
|
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
|
-
|
|
881
|
-
const adapter = await createAdapter(connection, context);
|
|
1009
|
+
let retainClaim = false;
|
|
882
1010
|
try {
|
|
883
|
-
|
|
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
|
|
889
|
-
|
|
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
|
-
|
|
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
|
|
1191
|
-
|
|
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
|
-
|
|
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:
|
|
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.
|