@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.
- package/README.md +14 -1
- package/dist/src/cli.js +7 -2
- package/dist/src/index.d.ts +1 -1
- package/dist/src/response-data.js +2 -0
- package/dist/src/stateql.d.ts +15 -2
- package/dist/src/stateql.js +302 -55
- package/dist/src/store.d.ts +44 -9
- package/dist/src/store.js +437 -91
- package/dist/src/types.d.ts +49 -0
- package/package.json +1 -1
package/dist/src/stateql.js
CHANGED
|
@@ -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.
|
|
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
|
-
? {
|
|
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.
|
|
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
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
throw new StateQLError("STALE_PLAN", "
|
|
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
|
-
|
|
827
|
-
const adapter = await createAdapter(connection, context);
|
|
992
|
+
let retainClaim = false;
|
|
828
993
|
try {
|
|
829
|
-
|
|
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
|
|
835
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
1147
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
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.
|