@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 +14 -1
- package/dist/src/cli.js +1 -0
- package/dist/src/response-data.js +2 -0
- package/dist/src/stateql.d.ts +8 -0
- package/dist/src/stateql.js +235 -45
- package/dist/src/store.d.ts +44 -9
- package/dist/src/store.js +437 -91
- package/dist/src/types.d.ts +5 -0
- package/package.json +1 -1
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
|
@@ -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,
|
package/dist/src/stateql.d.ts
CHANGED
|
@@ -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;
|
package/dist/src/stateql.js
CHANGED
|
@@ -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.
|
|
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
|
-
? {
|
|
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
|
-
? {
|
|
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.
|
|
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
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
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.");
|
|
879
991
|
}
|
|
880
|
-
|
|
881
|
-
const adapter = await createAdapter(connection, context);
|
|
992
|
+
let retainClaim = false;
|
|
882
993
|
try {
|
|
883
|
-
|
|
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
|
|
889
|
-
|
|
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
|
-
|
|
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
|
|
1191
|
-
|
|
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
|
-
|
|
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:
|
|
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.
|